pluck
A producer emits JSONL: one JSON object per line. pluck reads that stream and emits each record reduced to just the fields you name with --fields, in the order you named them — the SELECT of the JSONL toolkit. Narrow a wide record down to the columns you want before you fold, diff, or sink it. It is a filter on the field axis: every emitted object's fields are a subset of its input's, and nothing is added, renamed, computed, or reordered by content. A field named twice is emitted once at its first position. Same stream and same field list in, byte-identical stream out, on every machine and every run. A line that is not valid JSON is a hard error (exit 2) naming the line; a record that is not a JSON object has no field axis and is a hard error too — the deliberate divergence from a row-dropping filter. Blank lines are skipped. Zero dependencies, pure, offline; runs in Node or a browser (window.ForestGifts.pluck).
printf '%s\n' '{"a":1,"b":2,"c":3}' | node pluck.js --fields a,c # exit 0 clean | 2 non-JSON or non-object line (named)
test_pluck.js (24/24) + out-of-band conformance (GREEN 32/32 vs an independently-authored per-field reconstruction oracle; sig 1bfa8d85a1730a7f; mutation bite VERIFIED)
Zero dependencies, Node or browser, deterministic
pluck.js269 lineson GitHub →
#!/usr/bin/env node
/* pluck.js — keep only the named fields from each record in a JSONL stream.
Dependency-free, deterministic, one pass. Runs in Node or a browser. MIT.
WHAT IT IS. Give it a stream of records — one JSON object per line (JSONL) —
and a list of field names, and it emits each record reduced to just those
fields. It is the SELECT of the JSONL toolkit: narrow a wide record down to
the columns you asked for, before you fold, diff, or sink it. Same stream and
same field list in, byte-identical stream out, on every machine and every run.
It is a FILTER on the field axis: every emitted object is a subset of its input
object's fields — nothing is added, renamed, computed, or reordered by content.
THE FIELD ORDER IS THE DECLARED ORDER (the whole determinism story). The output
object's keys appear in the order you named them in --fields, NOT the order they
happened to sit in the input record. So `--fields b,a` emits {"b":...,"a":...}
regardless of how the input was written. Declaring the order — rather than
inheriting the input's — is what makes the output a pure function of (record,
field-list): two records that carry the same requested values, written in any
key-order, pluck to byte-identical lines. A field named twice in --fields is
emitted once, at its first position (duplicates in the request are idempotent).
WHAT HAPPENS TO A MISSING FIELD (default vs --strict).
- DEFAULT: a requested field the record does not carry is simply OMITTED from
that record's output object. A missing field is not a value — it is not
emitted as null, not as "", not as the literal key with nothing after it. A
record that carries none of the requested fields emits an empty object {}.
This is the honest default: pluck what is there, say nothing about what is
not.
- --strict: a record missing ANY requested field is a HARD ERROR (exit 2)
naming the line and the first missing field. For callers who need every
column present and want the stream to stop rather than emit a thin record.
INPUT HONESTY (the character of this gift). A field-selector is only trustworthy
if it refuses to quietly mishandle a line:
- Every non-blank line must be valid JSON. A line that is not valid JSON is a
HARD ERROR (exit 2) naming the line — never a silent skip, never passed
through as raw text.
- Every record must be a JSON OBJECT. You cannot pluck fields from a bare
number, string, boolean, null, or array — there are no fields to select. A
non-object record is a HARD ERROR (exit 2) naming the line. This is the
deliberate divergence from a row-dropping filter: pluck's whole contract is
field-selection, so a record with no field axis is a stop, not a passthrough.
- --fields names TOP-LEVEL fields only (no dotted paths, no array indices).
Selecting a nested value is out of scope by construction (see the edge).
- Blank lines are skipped (not emitted, not counted). A trailing \r (CRLF
files) is trimmed before parsing.
THE OUTPUT IS CANONICAL. Each emitted object is re-serialized with its keys in
the declared field order and its values canonicalized (nested object keys sorted
recursively, array order kept), so the same requested values always serialize to
the same bytes. Values are copied through verbatim in meaning — pluck selects,
it never transforms a value.
USAGE
printf '%s\n' '{"a":1,"b":2,"c":3}' | node pluck.js --fields a,c
-> {"a":1,"c":3}
node pluck.js --fields id,name events.jsonl
node pluck.js --fields id,name --strict < in.jsonl > out.jsonl
node pluck.js --help
Each non-blank line is one JSON object. Output is the plucked objects, one per
line, each terminated by a newline, in input order.
Exit codes: 0 success · 2 input error (missing/empty --fields, missing file, a
directory, an unknown option, a line that is not valid JSON, a non-object
record, or --strict on a record missing a requested field). Always a clean
one-line message on stderr, never a stack trace.
Released under MIT. Its edge is printed in the README: this selects TOP-LEVEL
fields by exact name. It does NOT reach into nested paths (no dotted keys, no
a.b.c), does NOT rename fields, does NOT compute or default missing values, and
does NOT reorder by content — the output key order is exactly the --fields order.
*/
"use strict";
// Canonical JSON: object keys sorted recursively so a value serializes to the same
// bytes regardless of how its (nested) object keys were written. Arrays keep order.
function canon(v) {
if (v === null || typeof v !== "object") return JSON.stringify(v);
if (Array.isArray(v)) {
var parts = [];
for (var i = 0; i < v.length; i++) parts.push(canon(v[i]));
return "[" + parts.join(",") + "]";
}
var keys = Object.keys(v).sort();
var out = [];
for (var k = 0; k < keys.length; k++) {
out.push(JSON.stringify(keys[k]) + ":" + canon(v[keys[k]]));
}
return "{" + out.join(",") + "}";
}
// Parse one input line into a JSON value, or throw a clean, line-named Error.
function parseRecord(line, lineNo) {
try { return JSON.parse(line); }
catch (e) {
throw new Error("line " + lineNo + " is not valid JSON: " + JSON.stringify(line.slice(0, 40)));
}
}
// De-duplicate the requested field list, preserving first-seen order. A field
// named twice is kept once at its first position (idempotent request).
function normalizeFields(fields) {
var seen = Object.create(null);
var out = [];
for (var i = 0; i < fields.length; i++) {
var f = fields[i];
if (!seen[f]) { seen[f] = true; out.push(f); }
}
return out;
}
// Serialize a plucked object with keys in the DECLARED field order and values
// canonicalized. `present` is the list of requested fields this record actually
// carries, already in declared order.
function serializePlucked(rec, present) {
var parts = [];
for (var i = 0; i < present.length; i++) {
var f = present[i];
parts.push(JSON.stringify(f) + ":" + canon(rec[f]));
}
return "{" + parts.join(",") + "}";
}
// The public filter: JSONL text + {fields, strict} -> { lines: [plucked lines] }.
// Each emitted object carries the requested fields the record has, in declared
// order. In strict mode a record missing any requested field throws.
function pluck(text, opts) {
opts = opts || {};
var fields = normalizeFields(opts.fields || []);
var strict = !!opts.strict;
var lines = String(text).split("\n");
var out = [];
var i, line, rec, j, f, present;
for (i = 0; i < lines.length; i++) {
line = lines[i];
if (line.charCodeAt(line.length - 1) === 0x0d) line = line.slice(0, -1); // trim \r
if (line.length === 0) continue; // blank line is not a record
rec = parseRecord(line, i + 1);
// A record must be a JSON object to have a field axis to select on.
if (rec === null || typeof rec !== "object" || Array.isArray(rec)) {
throw new Error("line " + (i + 1) + " is not a JSON object (cannot pluck fields from " +
(rec === null ? "null" : (Array.isArray(rec) ? "an array" : typeof rec)) + ")");
}
present = [];
for (j = 0; j < fields.length; j++) {
f = fields[j];
if (Object.prototype.hasOwnProperty.call(rec, f)) {
present.push(f);
} else if (strict) {
throw new Error("line " + (i + 1) + " is missing required field " + JSON.stringify(f));
}
}
out.push(serializePlucked(rec, present));
}
return { lines: out };
}
/* ---- exports (browser + Node) ------------------------------------ */
if (typeof window !== "undefined") {
window.ForestGifts = window.ForestGifts || {};
window.ForestGifts.pluck = pluck;
window.ForestGifts.pluckCanon = canon;
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { pluck: pluck, canon: canon };
}
/* ---- CLI (runs only when invoked directly, never on require) ------ */
function run(text, opts) {
var r = pluck(text, opts);
var body = r.lines.length ? r.lines.join("\n") + "\n" : "";
return { out: body };
}
function main(argv) {
var args = argv.slice(2);
if (args.indexOf("--help") !== -1 || args.indexOf("-h") !== -1) {
process.stdout.write(
"pluck.js — keep only the named fields from each record in a JSONL stream.\n\n" +
" printf '%s\\n' '{\"a\":1,\"b\":2,\"c\":3}' | node pluck.js --fields a,c\n" +
" node pluck.js --fields id,name events.jsonl\n" +
" node pluck.js --fields id,name --strict < in > out\n" +
" node pluck.js --help\n\n" +
"Each non-blank line is one JSON object. --fields is a comma-separated list of\n" +
"TOP-LEVEL field names to keep; the output key order is exactly that order. A\n" +
"missing field is OMITTED by default, or a hard error under --strict. A record\n" +
"that is not a JSON object is a hard error.\n\n" +
"Edge: selects TOP-LEVEL fields by exact name. NOT nested paths (no a.b.c), does\n" +
"NOT rename or compute fields, and does NOT reorder by content. Invalid JSON and\n" +
"non-object records are hard errors, never a silent skip.\n"
);
return 0;
}
var opts = { fields: [] };
var files = [];
var i, raw;
try {
for (i = 0; i < args.length; i++) {
if (args[i] === "--fields") {
raw = args[++i];
if (raw === undefined || raw === "" || raw.charAt(0) === "-") {
throw new Error("--fields requires a comma-separated list of field names");
}
// split on comma, trim each, drop empties (so trailing commas are tolerated)
var wanted = [];
var pieces = raw.split(",");
for (var p = 0; p < pieces.length; p++) {
var name = pieces[p].trim();
if (name.length > 0) wanted.push(name);
}
if (wanted.length === 0) throw new Error("--fields lists no field names");
opts.fields = wanted;
}
else if (args[i] === "--strict") { opts.strict = true; }
else if (args[i].charAt(0) === "-") { throw new Error("unknown option " + args[i]); }
else { files.push(args[i]); }
}
if (!opts.fields || opts.fields.length === 0) {
throw new Error("--fields is required (a comma-separated list of field names to keep)");
}
} catch (e) {
process.stderr.write("pluck: " + e.message + "\n");
return 2;
}
function emit(text) {
try {
var r = run(text, opts);
process.stdout.write(r.out);
return 0;
} catch (e) {
process.stderr.write("pluck: " + e.message + "\n");
return 2;
}
}
if (files.length > 0) {
var fs = require("fs");
var text;
try { text = fs.readFileSync(files[0], "utf8"); }
catch (e) {
process.stderr.write("pluck: cannot read " + files[0] +
" (" + (e.code === "EISDIR" ? "is a directory" : (e.code || "read error")) + ")\n");
return 2;
}
return emit(text);
}
// stdin
var chunks = [];
process.stdin.on("data", function (d) { chunks.push(d); });
process.stdin.on("end", function () {
process.exitCode = emit(Buffer.concat(chunks).toString("utf8"));
});
return 0;
}
if (typeof require !== "undefined" && require.main === module) {
process.exitCode = main(process.argv);
}
test_pluck.js152 lineson GitHub →
#!/usr/bin/env node
/* test_pluck.js — drift-check battery for pluck.js.
Zero-dependency. Runs in Node: `node test_pluck.js`. Exit 0 all-pass, 1 any-fail.
THE ORACLE. pluck's contract is small enough that the oracle is a set of
hand-computed expected output lines PLUS two structural invariants checked
mechanically on every case:
(I1) subset: every key in an emitted object was a requested field AND was
present in the input object (pluck never invents a field).
(I2) declared-order: the emitted key order equals the requested field order
(restricted to present fields) — NOT the input's key order.
The known-bad vector is the DECLARED-ORDER tripwire: an input whose keys sit in
a different order than --fields. A regression that inherited input order instead
of declared order passes a naive "same set of keys" check but fails I2 — so the
battery pins the ordering that is the whole determinism story.
*/
"use strict";
var pluck = require("./pluck.js").pluck;
var passed = 0, failed = 0;
function check(name, got, want) {
if (got === want) { passed++; /* console.log(" ok " + name); */ }
else {
failed++;
console.log("FAIL " + name);
console.log(" want: " + JSON.stringify(want));
console.log(" got: " + JSON.stringify(got));
}
}
function checkThrows(name, fn) {
var threw = false;
try { fn(); } catch (e) { threw = true; }
if (threw) { passed++; }
else { failed++; console.log("FAIL " + name + " (expected a throw, none happened)"); }
}
function out(text, opts) {
var r = pluck(text, opts);
return r.lines.join("\n");
}
// ---- Core selection --------------------------------------------------
check("keep two of three fields",
out('{"a":1,"b":2,"c":3}', { fields: ["a", "c"] }),
'{"a":1,"c":3}');
check("single field",
out('{"a":1,"b":2}', { fields: ["b"] }),
'{"b":2}');
check("all fields kept (identity-ish, canonical order)",
out('{"a":1,"b":2}', { fields: ["a", "b"] }),
'{"a":1,"b":2}');
// ---- KNOWN-BAD VECTOR: declared order, not input order ---------------
// Input keys are b,a but --fields asks a,b -> output MUST be {"a":..,"b":..}.
check("KNOWN-BAD: output order is declared order, not input order",
out('{"b":2,"a":1}', { fields: ["a", "b"] }),
'{"a":1,"b":2}');
check("KNOWN-BAD: reverse request reverses output",
out('{"a":1,"b":2}', { fields: ["b", "a"] }),
'{"b":2,"a":1}');
// ---- Missing fields: default omits -----------------------------------
check("missing field omitted (default)",
out('{"a":1}', { fields: ["a", "b"] }),
'{"a":1}');
check("record carrying none of the fields -> empty object",
out('{"x":9}', { fields: ["a", "b"] }),
'{}');
check("missing field in the MIDDLE keeps declared order of the rest",
out('{"a":1,"c":3}', { fields: ["a", "b", "c"] }),
'{"a":1,"c":3}');
// ---- --strict: missing field is a hard error -------------------------
checkThrows("strict: missing field throws", function () {
pluck('{"a":1}', { fields: ["a", "b"], strict: true });
});
check("strict: all present passes",
out('{"a":1,"b":2}', { fields: ["a", "b"], strict: true }),
'{"a":1,"b":2}');
// ---- Duplicate requested fields are idempotent -----------------------
check("field named twice emitted once at first position",
out('{"a":1,"b":2}', { fields: ["a", "a", "b"] }),
'{"a":1,"b":2}');
// ---- Value fidelity + nested canonicalization ------------------------
check("value copied verbatim (nested object canonicalized)",
out('{"a":{"z":1,"y":2},"b":5}', { fields: ["a"] }),
'{"a":{"y":2,"z":1}}');
check("array value order preserved (not sorted)",
out('{"a":[3,1,2],"b":0}', { fields: ["a"] }),
'{"a":[3,1,2]}');
check("null / bool / string values kept",
out('{"a":null,"b":true,"c":"hi","d":0}', { fields: ["a", "b", "c"] }),
'{"a":null,"b":true,"c":"hi"}');
// ---- Multi-line stream, order preserved ------------------------------
check("multi-record stream keeps input order",
out('{"id":1,"x":9}\n{"id":2,"x":8}\n{"id":3,"x":7}', { fields: ["id"] }),
'{"id":1}\n{"id":2}\n{"id":3}');
// ---- Blank + CRLF handling -------------------------------------------
check("blank lines skipped",
out('{"a":1}\n\n{"a":2}\n', { fields: ["a"] }),
'{"a":1}\n{"a":2}');
check("CRLF trailing \\r trimmed before parse",
out('{"a":1}\r\n{"a":2}\r', { fields: ["a"] }),
'{"a":1}\n{"a":2}');
// ---- Input honesty: non-object records are hard errors ---------------
checkThrows("bare number record throws", function () {
pluck('42', { fields: ["a"] });
});
checkThrows("bare string record throws", function () {
pluck('"hello"', { fields: ["a"] });
});
checkThrows("array record throws", function () {
pluck('[1,2,3]', { fields: ["a"] });
});
checkThrows("null record throws", function () {
pluck('null', { fields: ["a"] });
});
checkThrows("invalid JSON throws", function () {
pluck('{"a":1', { fields: ["a"] });
});
// ---- Determinism: folds-twice-identical ------------------------------
(function () {
var input = '{"b":2,"a":1,"c":3}\n{"c":30,"a":10}\n{"z":99}';
var opts = { fields: ["a", "b"] };
var r1 = out(input, opts);
var r2 = out(input, opts);
check("determinism: two runs byte-identical", r1, r2);
})();
// ---- Empty-fields guard is a library concern? (CLI enforces; lib tolerant) ----
check("empty fields list yields empty objects (lib-level; CLI blocks this)",
out('{"a":1}', { fields: [] }),
'{}');
console.log("\npluck battery: " + passed + " passed, " + failed + " failed");
process.exit(failed === 0 ? 0 : 1);