@crewhaus/tool-data 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +988 -0
- package/dist/lib/csv.d.ts +80 -0
- package/dist/lib/csv.js +240 -0
- package/dist/lib/diff.d.ts +44 -0
- package/dist/lib/diff.js +166 -0
- package/dist/lib/json.d.ts +65 -0
- package/dist/lib/json.js +133 -0
- package/dist/lib/jsonl.d.ts +39 -0
- package/dist/lib/jsonl.js +63 -0
- package/dist/lib/jsonpath.d.ts +91 -0
- package/dist/lib/jsonpath.js +430 -0
- package/dist/lib/patch.d.ts +72 -0
- package/dist/lib/patch.js +272 -0
- package/dist/lib/table.d.ts +156 -0
- package/dist/lib/table.js +571 -0
- package/dist/lib/toml.d.ts +59 -0
- package/dist/lib/toml.js +530 -0
- package/dist/lib/xml.d.ts +72 -0
- package/dist/lib/xml.js +344 -0
- package/dist/lib/yaml.d.ts +61 -0
- package/dist/lib/yaml.js +598 -0
- package/package.json +45 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operations over an array of records — the shape almost every API response,
|
|
3
|
+
* CSV file and query result arrives in.
|
|
4
|
+
*
|
|
5
|
+
* Field references are dotted paths (`user.name`), so nested records work
|
|
6
|
+
* without flattening them first. Ordering is always total and stable: where
|
|
7
|
+
* two rows compare equal the original order is kept, so the same input gives
|
|
8
|
+
* the same output every time.
|
|
9
|
+
*/
|
|
10
|
+
import { canonicalStringify, deepEqual, isPlainObject } from "./json";
|
|
11
|
+
/** Read a dotted path out of a record. Returns undefined for any miss. */
|
|
12
|
+
export function getPath(record, path) {
|
|
13
|
+
if (path === "")
|
|
14
|
+
return record;
|
|
15
|
+
let cur = record;
|
|
16
|
+
for (const seg of path.split(".")) {
|
|
17
|
+
if (isPlainObject(cur)) {
|
|
18
|
+
cur = cur[seg];
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(cur) && /^\d+$/.test(seg)) {
|
|
22
|
+
cur = cur[Number(seg)];
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return cur;
|
|
28
|
+
}
|
|
29
|
+
/** Write a dotted path into a record, creating intermediate objects. */
|
|
30
|
+
export function setPath(target, path, value) {
|
|
31
|
+
const segs = path.split(".");
|
|
32
|
+
let cur = target;
|
|
33
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
34
|
+
const seg = segs[i];
|
|
35
|
+
const next = cur[seg];
|
|
36
|
+
if (!isPlainObject(next)) {
|
|
37
|
+
const fresh = {};
|
|
38
|
+
cur[seg] = fresh;
|
|
39
|
+
cur = fresh;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
cur = next;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
cur[segs[segs.length - 1]] = value;
|
|
46
|
+
}
|
|
47
|
+
export class PredicateError extends Error {
|
|
48
|
+
}
|
|
49
|
+
/** Evaluate one condition against a record. */
|
|
50
|
+
export function testCondition(record, cond) {
|
|
51
|
+
const actual = getPath(record, cond.field);
|
|
52
|
+
const expected = cond.value;
|
|
53
|
+
switch (cond.op) {
|
|
54
|
+
case "exists":
|
|
55
|
+
return actual !== undefined && actual !== null;
|
|
56
|
+
case "empty":
|
|
57
|
+
return (actual === undefined ||
|
|
58
|
+
actual === null ||
|
|
59
|
+
actual === "" ||
|
|
60
|
+
(Array.isArray(actual) && actual.length === 0) ||
|
|
61
|
+
(isPlainObject(actual) && Object.keys(actual).length === 0));
|
|
62
|
+
case "eq":
|
|
63
|
+
return deepEqual(actual, expected);
|
|
64
|
+
case "ne":
|
|
65
|
+
return !deepEqual(actual, expected);
|
|
66
|
+
case "in":
|
|
67
|
+
return Array.isArray(expected) && expected.some((e) => deepEqual(actual, e));
|
|
68
|
+
case "contains": {
|
|
69
|
+
if (Array.isArray(actual))
|
|
70
|
+
return actual.some((e) => deepEqual(e, expected));
|
|
71
|
+
return String(actual ?? "").includes(String(expected ?? ""));
|
|
72
|
+
}
|
|
73
|
+
case "startsWith":
|
|
74
|
+
return String(actual ?? "").startsWith(String(expected ?? ""));
|
|
75
|
+
case "endsWith":
|
|
76
|
+
return String(actual ?? "").endsWith(String(expected ?? ""));
|
|
77
|
+
case "matches": {
|
|
78
|
+
if (typeof expected !== "string") {
|
|
79
|
+
throw new PredicateError(`"matches" needs a regular-expression string for ${cond.field}`);
|
|
80
|
+
}
|
|
81
|
+
let re;
|
|
82
|
+
try {
|
|
83
|
+
re = new RegExp(expected);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
throw new PredicateError(`invalid regex for ${cond.field}: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
return actual !== undefined && actual !== null && re.test(String(actual));
|
|
89
|
+
}
|
|
90
|
+
default:
|
|
91
|
+
return compareOrdered(actual, expected, cond.op);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function compareOrdered(actual, expected, op) {
|
|
95
|
+
const cmp = compareValues(actual, expected);
|
|
96
|
+
if (cmp === null)
|
|
97
|
+
return false;
|
|
98
|
+
if (op === "lt")
|
|
99
|
+
return cmp < 0;
|
|
100
|
+
if (op === "lte")
|
|
101
|
+
return cmp <= 0;
|
|
102
|
+
if (op === "gt")
|
|
103
|
+
return cmp > 0;
|
|
104
|
+
if (op === "gte")
|
|
105
|
+
return cmp >= 0;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Order two scalars, or null when they are not comparable. Numbers compare
|
|
110
|
+
* numerically, strings by code unit, booleans false-before-true; a
|
|
111
|
+
* number/string pair is not comparable, which is what stops `"10" < 9`
|
|
112
|
+
* quietly being true.
|
|
113
|
+
*/
|
|
114
|
+
export function compareValues(a, b) {
|
|
115
|
+
if (typeof a === "number" && typeof b === "number")
|
|
116
|
+
return a === b ? 0 : a < b ? -1 : 1;
|
|
117
|
+
if (typeof a === "string" && typeof b === "string")
|
|
118
|
+
return a === b ? 0 : a < b ? -1 : 1;
|
|
119
|
+
if (typeof a === "boolean" && typeof b === "boolean")
|
|
120
|
+
return a === b ? 0 : a ? 1 : -1;
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
/** Evaluate a whole predicate. An empty predicate matches everything. */
|
|
124
|
+
export function testPredicate(record, predicate) {
|
|
125
|
+
const all = predicate.all ?? [];
|
|
126
|
+
const any = predicate.any ?? [];
|
|
127
|
+
const none = predicate.none ?? [];
|
|
128
|
+
for (const c of all)
|
|
129
|
+
if (!testCondition(record, c))
|
|
130
|
+
return false;
|
|
131
|
+
for (const c of none)
|
|
132
|
+
if (testCondition(record, c))
|
|
133
|
+
return false;
|
|
134
|
+
if (any.length > 0 && !any.some((c) => testCondition(record, c)))
|
|
135
|
+
return false;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Sort records by one or more keys. The sort is stable and total: values of
|
|
140
|
+
* different types are ordered by a fixed type rank (null < boolean < number
|
|
141
|
+
* < string < array < object) so the result never depends on the input order
|
|
142
|
+
* of incomparable rows.
|
|
143
|
+
*/
|
|
144
|
+
export function sortRecords(records, keys) {
|
|
145
|
+
const decorated = records.map((value, index) => ({ value, index }));
|
|
146
|
+
decorated.sort((left, right) => {
|
|
147
|
+
for (const key of keys) {
|
|
148
|
+
const dir = key.direction === "desc" ? -1 : 1;
|
|
149
|
+
let a = getPath(left.value, key.field);
|
|
150
|
+
let b = getPath(right.value, key.field);
|
|
151
|
+
const aNull = a === undefined || a === null;
|
|
152
|
+
const bNull = b === undefined || b === null;
|
|
153
|
+
if (aNull || bNull) {
|
|
154
|
+
if (aNull && bNull)
|
|
155
|
+
continue;
|
|
156
|
+
const nullsLast = (key.nulls ?? "last") === "last";
|
|
157
|
+
return aNull === nullsLast ? 1 : -1;
|
|
158
|
+
}
|
|
159
|
+
if (key.caseInsensitive === true) {
|
|
160
|
+
if (typeof a === "string")
|
|
161
|
+
a = a.toLowerCase();
|
|
162
|
+
if (typeof b === "string")
|
|
163
|
+
b = b.toLowerCase();
|
|
164
|
+
}
|
|
165
|
+
const cmp = totalCompare(a, b);
|
|
166
|
+
if (cmp !== 0)
|
|
167
|
+
return cmp * dir;
|
|
168
|
+
}
|
|
169
|
+
return left.index - right.index;
|
|
170
|
+
});
|
|
171
|
+
return decorated.map((d) => d.value);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The type-rank order. `TYPE_ORDER[rankOf(v)]` is the name of `v`'s rank, so
|
|
175
|
+
* the documented order and the code that implements it cannot drift.
|
|
176
|
+
* `undefined` ranks with `null`, since neither is a JSON value a comparison
|
|
177
|
+
* can say anything else about.
|
|
178
|
+
*/
|
|
179
|
+
export const TYPE_ORDER = Object.freeze([
|
|
180
|
+
"null",
|
|
181
|
+
"boolean",
|
|
182
|
+
"number",
|
|
183
|
+
"string",
|
|
184
|
+
"array",
|
|
185
|
+
"object",
|
|
186
|
+
]);
|
|
187
|
+
function rankOf(v) {
|
|
188
|
+
if (v === null || v === undefined)
|
|
189
|
+
return 0;
|
|
190
|
+
if (typeof v === "boolean")
|
|
191
|
+
return 1;
|
|
192
|
+
if (typeof v === "number")
|
|
193
|
+
return 2;
|
|
194
|
+
if (typeof v === "string")
|
|
195
|
+
return 3;
|
|
196
|
+
if (Array.isArray(v))
|
|
197
|
+
return 4;
|
|
198
|
+
return 5;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* A total order over JSON values, used so a sort is never arbitrary.
|
|
202
|
+
*
|
|
203
|
+
* Within a rank, scalars compare naturally. Arrays and objects compare by
|
|
204
|
+
* their canonical text, which is lexicographic and therefore *not*
|
|
205
|
+
* element-wise numeric: `[1, 10]` sorts before `[1, 2]`, because `"1"`
|
|
206
|
+
* precedes `"2"` at the fourth character. That is the price of an order that
|
|
207
|
+
* is total and never has to invent a comparison between a number and an
|
|
208
|
+
* object; sort by a scalar field when the order has to mean something.
|
|
209
|
+
*/
|
|
210
|
+
export function totalCompare(a, b) {
|
|
211
|
+
const ra = rankOf(a);
|
|
212
|
+
const rb = rankOf(b);
|
|
213
|
+
if (ra !== rb)
|
|
214
|
+
return ra < rb ? -1 : 1;
|
|
215
|
+
const direct = compareValues(a, b);
|
|
216
|
+
if (direct !== null)
|
|
217
|
+
return direct;
|
|
218
|
+
// null/undefined against each other, and arrays and objects: order by
|
|
219
|
+
// canonical text, which is exact and stable. A hash would be neither —
|
|
220
|
+
// two different values that collided would compare equal.
|
|
221
|
+
const sa = canonicalStringify(a);
|
|
222
|
+
const sb = canonicalStringify(b);
|
|
223
|
+
return sa === sb ? 0 : sa < sb ? -1 : 1;
|
|
224
|
+
}
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Projection, grouping, joining
|
|
227
|
+
/** Keep only the named fields (dotted paths), preserving the requested order. */
|
|
228
|
+
export function selectFields(record, fields) {
|
|
229
|
+
const out = {};
|
|
230
|
+
for (const field of fields) {
|
|
231
|
+
const value = getPath(record, field);
|
|
232
|
+
if (value !== undefined)
|
|
233
|
+
setPath(out, field, value);
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
/** Drop the named fields (dotted paths) from a shallow copy. */
|
|
238
|
+
export function omitFields(record, fields) {
|
|
239
|
+
const out = structuredCloneish(record);
|
|
240
|
+
for (const field of fields) {
|
|
241
|
+
const segs = field.split(".");
|
|
242
|
+
let cur = out;
|
|
243
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
244
|
+
if (!isPlainObject(cur))
|
|
245
|
+
break;
|
|
246
|
+
cur = cur[segs[i]];
|
|
247
|
+
}
|
|
248
|
+
if (isPlainObject(cur))
|
|
249
|
+
delete cur[segs[segs.length - 1]];
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
function structuredCloneish(value) {
|
|
254
|
+
const out = {};
|
|
255
|
+
for (const k of Object.keys(value)) {
|
|
256
|
+
const v = value[k];
|
|
257
|
+
out[k] = isPlainObject(v) ? structuredCloneish(v) : v;
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Group records by one or more fields and reduce each group.
|
|
263
|
+
*
|
|
264
|
+
* `sum` and `avg` ignore non-numeric values and report how many were skipped
|
|
265
|
+
* via the `_skipped` count, rather than producing NaN. `min`/`max` use the
|
|
266
|
+
* same total order as `sortRecords`. `distinct` counts distinct values by
|
|
267
|
+
* their canonical form, so `{a:1,b:2}` and `{b:2,a:1}` are one value.
|
|
268
|
+
*/
|
|
269
|
+
export function aggregate(records, groupBy, aggregations) {
|
|
270
|
+
const buckets = new Map();
|
|
271
|
+
for (const rec of records) {
|
|
272
|
+
const key = {};
|
|
273
|
+
for (const field of groupBy)
|
|
274
|
+
key[field] = getPath(rec, field) ?? null;
|
|
275
|
+
const id = canonicalStringify(key);
|
|
276
|
+
const existing = buckets.get(id);
|
|
277
|
+
if (existing === undefined)
|
|
278
|
+
buckets.set(id, { key, rows: [rec] });
|
|
279
|
+
else
|
|
280
|
+
existing.rows.push(rec);
|
|
281
|
+
}
|
|
282
|
+
let skipped = 0;
|
|
283
|
+
const groups = [];
|
|
284
|
+
for (const bucket of buckets.values()) {
|
|
285
|
+
const out = { ...bucket.key, count: bucket.rows.length };
|
|
286
|
+
for (const agg of aggregations) {
|
|
287
|
+
const field = agg.field ?? "";
|
|
288
|
+
const values = bucket.rows.map((r) => getPath(r, field));
|
|
289
|
+
switch (agg.fn) {
|
|
290
|
+
case "count":
|
|
291
|
+
out[agg.as] = field === "" ? bucket.rows.length : values.filter(isPresent).length;
|
|
292
|
+
break;
|
|
293
|
+
case "sum":
|
|
294
|
+
case "avg": {
|
|
295
|
+
const nums = values.filter((v) => typeof v === "number");
|
|
296
|
+
skipped += values.filter(isPresent).length - nums.length;
|
|
297
|
+
const total = nums.reduce((a, b) => a + b, 0);
|
|
298
|
+
out[agg.as] = agg.fn === "sum" ? total : nums.length === 0 ? null : total / nums.length;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "min":
|
|
302
|
+
case "max": {
|
|
303
|
+
const present = values.filter(isPresent);
|
|
304
|
+
if (present.length === 0) {
|
|
305
|
+
out[agg.as] = null;
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
out[agg.as] = present.reduce((best, v) => {
|
|
309
|
+
const cmp = totalCompare(v, best);
|
|
310
|
+
return (agg.fn === "min" ? cmp < 0 : cmp > 0) ? v : best;
|
|
311
|
+
});
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
case "first":
|
|
315
|
+
out[agg.as] = values[0] ?? null;
|
|
316
|
+
break;
|
|
317
|
+
case "last":
|
|
318
|
+
out[agg.as] = values[values.length - 1] ?? null;
|
|
319
|
+
break;
|
|
320
|
+
case "distinct":
|
|
321
|
+
out[agg.as] = new Set(values.filter(isPresent).map((v) => canonicalStringify(v))).size;
|
|
322
|
+
break;
|
|
323
|
+
default:
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
groups.push(out);
|
|
328
|
+
}
|
|
329
|
+
return { groups, skipped };
|
|
330
|
+
}
|
|
331
|
+
function isPresent(v) {
|
|
332
|
+
return v !== undefined && v !== null;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Join two record arrays on a key. Matching is by the canonical form of the
|
|
336
|
+
* key value, so `1` and `"1"` are different keys — a silent coercion here is
|
|
337
|
+
* how a join quietly loses rows.
|
|
338
|
+
*
|
|
339
|
+
* Every right-hand match is emitted, so a one-to-many join multiplies rows,
|
|
340
|
+
* as SQL does. `maxRows` bounds that.
|
|
341
|
+
*/
|
|
342
|
+
export function joinRecords(left, right, options) {
|
|
343
|
+
const index = new Map();
|
|
344
|
+
for (const r of right) {
|
|
345
|
+
const key = getPath(r, options.rightKey);
|
|
346
|
+
if (!isPresent(key))
|
|
347
|
+
continue;
|
|
348
|
+
const id = canonicalStringify(key);
|
|
349
|
+
const bucket = index.get(id);
|
|
350
|
+
if (bucket === undefined)
|
|
351
|
+
index.set(id, [r]);
|
|
352
|
+
else
|
|
353
|
+
bucket.push(r);
|
|
354
|
+
}
|
|
355
|
+
const rows = [];
|
|
356
|
+
const matchedRight = new Set();
|
|
357
|
+
let unmatchedLeft = 0;
|
|
358
|
+
let truncated = false;
|
|
359
|
+
const push = (row) => {
|
|
360
|
+
if (rows.length >= options.maxRows) {
|
|
361
|
+
truncated = true;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
rows.push(row);
|
|
365
|
+
};
|
|
366
|
+
for (const l of left) {
|
|
367
|
+
const key = getPath(l, options.leftKey);
|
|
368
|
+
const id = isPresent(key) ? canonicalStringify(key) : null;
|
|
369
|
+
const matches = id === null ? undefined : index.get(id);
|
|
370
|
+
if (matches === undefined || matches.length === 0) {
|
|
371
|
+
unmatchedLeft += 1;
|
|
372
|
+
if (options.kind === "left" || options.kind === "full")
|
|
373
|
+
push({ ...l });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (id !== null)
|
|
377
|
+
matchedRight.add(id);
|
|
378
|
+
for (const r of matches)
|
|
379
|
+
push(mergeRow(l, r, options.rightPrefix));
|
|
380
|
+
}
|
|
381
|
+
let unmatchedRight = 0;
|
|
382
|
+
for (const r of right) {
|
|
383
|
+
const key = getPath(r, options.rightKey);
|
|
384
|
+
const id = isPresent(key) ? canonicalStringify(key) : null;
|
|
385
|
+
if (id === null || !matchedRight.has(id)) {
|
|
386
|
+
unmatchedRight += 1;
|
|
387
|
+
if (options.kind === "right" || options.kind === "full")
|
|
388
|
+
push({ ...r });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return { rows, truncated, unmatchedLeft, unmatchedRight };
|
|
392
|
+
}
|
|
393
|
+
function mergeRow(left, right, prefix) {
|
|
394
|
+
const out = { ...left };
|
|
395
|
+
for (const k of Object.keys(right)) {
|
|
396
|
+
out[Object.hasOwn(left, k) ? `${prefix}${k}` : k] = right[k];
|
|
397
|
+
}
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// Reshaping
|
|
402
|
+
/** Records to a column-oriented object: `[{a:1},{a:2}]` becomes `{a:[1,2]}`. */
|
|
403
|
+
export function recordsToColumns(records) {
|
|
404
|
+
const keys = [];
|
|
405
|
+
const seen = new Set();
|
|
406
|
+
for (const rec of records) {
|
|
407
|
+
for (const k of Object.keys(rec)) {
|
|
408
|
+
if (!seen.has(k)) {
|
|
409
|
+
seen.add(k);
|
|
410
|
+
keys.push(k);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const out = {};
|
|
415
|
+
for (const k of keys)
|
|
416
|
+
out[k] = records.map((r) => (Object.hasOwn(r, k) ? r[k] : null));
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Columns back to records. Columns of differing length are padded with null
|
|
421
|
+
* up to the longest, and the caller is told how many were short.
|
|
422
|
+
*/
|
|
423
|
+
export function columnsToRecords(columns) {
|
|
424
|
+
const keys = Object.keys(columns);
|
|
425
|
+
const lengths = keys.map((k) => (columns[k] ?? []).length);
|
|
426
|
+
const max = lengths.length === 0 ? 0 : Math.max(...lengths);
|
|
427
|
+
const ragged = keys.filter((k) => (columns[k] ?? []).length !== max);
|
|
428
|
+
const records = [];
|
|
429
|
+
for (let i = 0; i < max; i++) {
|
|
430
|
+
const rec = {};
|
|
431
|
+
for (const k of keys) {
|
|
432
|
+
const col = columns[k] ?? [];
|
|
433
|
+
rec[k] = i < col.length ? col[i] : null;
|
|
434
|
+
}
|
|
435
|
+
records.push(rec);
|
|
436
|
+
}
|
|
437
|
+
return { records, ragged };
|
|
438
|
+
}
|
|
439
|
+
/** Flatten nested objects into dotted keys. Arrays become `a.0`, `a.1` when expanded. */
|
|
440
|
+
export function flattenObject(value, separator, expandArrays, maxDepth) {
|
|
441
|
+
const out = {};
|
|
442
|
+
const walk = (node, prefix, depth) => {
|
|
443
|
+
if (depth >= maxDepth) {
|
|
444
|
+
out[prefix] = node;
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (isPlainObject(node)) {
|
|
448
|
+
const keys = Object.keys(node);
|
|
449
|
+
if (keys.length === 0) {
|
|
450
|
+
if (prefix !== "")
|
|
451
|
+
out[prefix] = {};
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
for (const k of keys)
|
|
455
|
+
walk(node[k], prefix === "" ? k : `${prefix}${separator}${k}`, depth + 1);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (Array.isArray(node) && expandArrays) {
|
|
459
|
+
if (node.length === 0) {
|
|
460
|
+
if (prefix !== "")
|
|
461
|
+
out[prefix] = [];
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
node.forEach((el, i) => walk(el, prefix === "" ? String(i) : `${prefix}${separator}${i}`, depth + 1));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
out[prefix] = node;
|
|
468
|
+
};
|
|
469
|
+
walk(value, "", 0);
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Rebuild nested objects from dotted keys. A numeric segment becomes an
|
|
474
|
+
* array index when `arraysFromNumericKeys` is on and the keys under that
|
|
475
|
+
* prefix are a contiguous run from 0; otherwise it stays an object key, so
|
|
476
|
+
* `{"a.2": 1}` does not silently produce two nulls.
|
|
477
|
+
*/
|
|
478
|
+
export function unflattenObject(flat, separator, arraysFromNumericKeys) {
|
|
479
|
+
const root = {};
|
|
480
|
+
for (const key of Object.keys(flat)) {
|
|
481
|
+
const segs = key.split(separator);
|
|
482
|
+
let cur = root;
|
|
483
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
484
|
+
const seg = segs[i];
|
|
485
|
+
const next = cur[seg];
|
|
486
|
+
if (!isPlainObject(next)) {
|
|
487
|
+
const fresh = {};
|
|
488
|
+
cur[seg] = fresh;
|
|
489
|
+
cur = fresh;
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
cur = next;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
cur[segs[segs.length - 1]] = flat[key];
|
|
496
|
+
}
|
|
497
|
+
return arraysFromNumericKeys ? arrayify(root) : root;
|
|
498
|
+
}
|
|
499
|
+
function arrayify(node) {
|
|
500
|
+
if (!isPlainObject(node))
|
|
501
|
+
return node;
|
|
502
|
+
const keys = Object.keys(node);
|
|
503
|
+
const converted = {};
|
|
504
|
+
for (const k of keys)
|
|
505
|
+
converted[k] = arrayify(node[k]);
|
|
506
|
+
if (keys.length === 0)
|
|
507
|
+
return converted;
|
|
508
|
+
const allNumeric = keys.every((k) => /^(0|[1-9][0-9]*)$/.test(k));
|
|
509
|
+
if (!allNumeric)
|
|
510
|
+
return converted;
|
|
511
|
+
const indices = keys.map(Number).sort((a, b) => a - b);
|
|
512
|
+
const contiguous = indices.every((n, i) => n === i);
|
|
513
|
+
if (!contiguous)
|
|
514
|
+
return converted;
|
|
515
|
+
return indices.map((n) => converted[String(n)]);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* De-duplicate records by a key path, or by the whole value when no key is
|
|
519
|
+
* given. Identity is the canonical (key-sorted) serialization, so two
|
|
520
|
+
* records that differ only in key order are one record.
|
|
521
|
+
*/
|
|
522
|
+
export function dedupeRecords(records, keyFields, keep) {
|
|
523
|
+
const seen = new Map();
|
|
524
|
+
const out = [];
|
|
525
|
+
for (const rec of records) {
|
|
526
|
+
const id = keyFields.length === 0
|
|
527
|
+
? canonicalStringify(rec)
|
|
528
|
+
: canonicalStringify(keyFields.map((f) => getPath(rec, f) ?? null));
|
|
529
|
+
const at = seen.get(id);
|
|
530
|
+
if (at === undefined) {
|
|
531
|
+
seen.set(id, out.length);
|
|
532
|
+
out.push(rec);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (keep === "last")
|
|
536
|
+
out[at] = rec;
|
|
537
|
+
}
|
|
538
|
+
return { records: out, removed: records.length - out.length };
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Take a deterministic sample. There is no random mode on purpose: a tool
|
|
542
|
+
* that returned a different subset on each call could not be cached, retried
|
|
543
|
+
* or compared between runs. `evenly` spreads picks across the whole array.
|
|
544
|
+
*/
|
|
545
|
+
export function sampleRecords(records, mode, count, step) {
|
|
546
|
+
if (records.length === 0)
|
|
547
|
+
return [];
|
|
548
|
+
switch (mode) {
|
|
549
|
+
case "head":
|
|
550
|
+
return records.slice(0, count);
|
|
551
|
+
case "tail":
|
|
552
|
+
return records.slice(Math.max(0, records.length - count));
|
|
553
|
+
case "everyNth": {
|
|
554
|
+
const out = [];
|
|
555
|
+
for (let i = 0; i < records.length && out.length < count; i += Math.max(1, step)) {
|
|
556
|
+
out.push(records[i]);
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
default: {
|
|
561
|
+
const n = Math.min(count, records.length);
|
|
562
|
+
if (n <= 1)
|
|
563
|
+
return n === 1 ? [records[0]] : [];
|
|
564
|
+
const out = [];
|
|
565
|
+
for (let i = 0; i < n; i++) {
|
|
566
|
+
out.push(records[Math.round((i * (records.length - 1)) / (n - 1))]);
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A hand-written TOML reader and writer. As with the YAML module, there is
|
|
3
|
+
* no dependency behind it, so the supported subset below is the contract.
|
|
4
|
+
*
|
|
5
|
+
* ## Read: supported
|
|
6
|
+
*
|
|
7
|
+
* - `key = value` pairs, bare, quoted and dotted keys (`a.b.c = 1`).
|
|
8
|
+
* - Tables `[a.b]` and arrays of tables `[[a.b]]`, at any depth.
|
|
9
|
+
* - Basic strings `"..."` with the standard escapes including `\uXXXX` and
|
|
10
|
+
* `\UXXXXXXXX`, literal strings `'...'`, and the multi-line `"""..."""`
|
|
11
|
+
* and `'''...'''` forms, with the leading-newline trim and the backslash
|
|
12
|
+
* line continuation.
|
|
13
|
+
* - Integers, including `_` separators and `0x` / `0o` / `0b` literals.
|
|
14
|
+
* - Floats, including exponents, `inf` and `nan`.
|
|
15
|
+
* - Booleans, arrays (nested, multi-line, with trailing commas) and inline
|
|
16
|
+
* tables `{ a = 1, b = "x" }`.
|
|
17
|
+
* - `#` comments.
|
|
18
|
+
*
|
|
19
|
+
* ## Read: deliberate departures, each documented rather than silent
|
|
20
|
+
*
|
|
21
|
+
* - **Dates and times stay strings.** TOML has first-class offset date-time,
|
|
22
|
+
* local date-time, date and time types. JSON has none of them, so
|
|
23
|
+
* converting to a `Date` would make a round trip lossy. `2026-01-01` reads
|
|
24
|
+
* back as the string `"2026-01-01"`.
|
|
25
|
+
* - Redefining a table or key is an error, as the spec requires — including
|
|
26
|
+
* both halves of the rule: a `[header]` may not reopen a table a dotted
|
|
27
|
+
* key created, and a dotted key may not reach into a table a `[header]`
|
|
28
|
+
* already defined.
|
|
29
|
+
* - `inf` and `nan` read as the JavaScript infinities and NaN and write back
|
|
30
|
+
* out as `inf` / `nan`, but JSON cannot spell them, so converting to JSON
|
|
31
|
+
* renders them `null`.
|
|
32
|
+
* - Integers outside the JavaScript safe range are kept as strings rather
|
|
33
|
+
* than silently rounded.
|
|
34
|
+
*
|
|
35
|
+
* ## Write
|
|
36
|
+
*
|
|
37
|
+
* Scalars and scalar arrays first, then sub-tables, then arrays of tables —
|
|
38
|
+
* which is the order that reads back identically. A value TOML cannot hold
|
|
39
|
+
* (`null`) is skipped, and the writer reports which keys it dropped rather
|
|
40
|
+
* than pretending the output is complete.
|
|
41
|
+
*/
|
|
42
|
+
export declare class TomlError extends Error {
|
|
43
|
+
readonly line: number;
|
|
44
|
+
constructor(message: string, line: number);
|
|
45
|
+
}
|
|
46
|
+
/** Parse a TOML document into JSON values. Throws `TomlError` with a line number. */
|
|
47
|
+
export declare function parseToml(text: string): Record<string, unknown>;
|
|
48
|
+
/** Type a bare TOML token. Exported so the tests can pin the exact rules. */
|
|
49
|
+
export declare function scalarFromToken(raw: string, line: number): unknown;
|
|
50
|
+
export type TomlWriteResult = {
|
|
51
|
+
text: string;
|
|
52
|
+
skipped: string[];
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Serialize an object as TOML. Returns the text plus the dotted paths of any
|
|
56
|
+
* keys that were dropped because TOML has no representation for them
|
|
57
|
+
* (`null` and `undefined`).
|
|
58
|
+
*/
|
|
59
|
+
export declare function stringifyToml(value: unknown): TomlWriteResult;
|