@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.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * JSON Pointer (RFC 6901), JSON Patch (RFC 6902) and JSON Merge Patch
3
+ * (RFC 7386).
4
+ *
5
+ * All three are implemented in full. Patching is transactional: the document
6
+ * is cloned, every operation is applied in order, and a failure anywhere
7
+ * leaves the caller's input untouched and reports which operation index
8
+ * failed — which is the behaviour RFC 6902 §5 requires.
9
+ */
10
+ import { deepClone, deepEqual, isPlainObject } from "./json";
11
+ export class PatchError extends Error {
12
+ opIndex;
13
+ constructor(message, opIndex) {
14
+ super(message);
15
+ this.opIndex = opIndex;
16
+ }
17
+ }
18
+ /** Decode one JSON Pointer reference token: `~1` is `/`, `~0` is `~`. */
19
+ export function unescapeToken(token) {
20
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
21
+ }
22
+ /** Encode one reference token for embedding in a pointer. */
23
+ export function escapeToken(token) {
24
+ return token.replace(/~/g, "~0").replace(/\//g, "~1");
25
+ }
26
+ /** Split a JSON Pointer into its decoded tokens. `""` is the whole document. */
27
+ export function parsePointer(pointer) {
28
+ if (pointer === "")
29
+ return [];
30
+ if (!pointer.startsWith("/")) {
31
+ throw new Error(`JSON Pointer must be empty or start with '/', got ${JSON.stringify(pointer)}`);
32
+ }
33
+ return pointer.slice(1).split("/").map(unescapeToken);
34
+ }
35
+ /** Build a pointer from already-decoded tokens. */
36
+ export function formatPointer(tokens) {
37
+ return tokens.map((t) => `/${escapeToken(t)}`).join("");
38
+ }
39
+ /** Resolve a pointer, returning `{ found: false }` rather than throwing when it misses. */
40
+ export function resolvePointer(doc, pointer) {
41
+ let tokens;
42
+ try {
43
+ tokens = parsePointer(pointer);
44
+ }
45
+ catch (err) {
46
+ return { found: false, reason: err.message };
47
+ }
48
+ let cur = doc;
49
+ for (const token of tokens) {
50
+ if (Array.isArray(cur)) {
51
+ if (!/^(0|[1-9][0-9]*)$/.test(token)) {
52
+ return { found: false, reason: `"${token}" is not an array index` };
53
+ }
54
+ const i = Number(token);
55
+ if (i >= cur.length)
56
+ return { found: false, reason: `index ${i} is past the end` };
57
+ cur = cur[i];
58
+ continue;
59
+ }
60
+ if (isPlainObject(cur)) {
61
+ if (!Object.hasOwn(cur, token))
62
+ return { found: false, reason: `no member "${token}"` };
63
+ cur = cur[token];
64
+ continue;
65
+ }
66
+ return { found: false, reason: `cannot descend into a ${cur === null ? "null" : typeof cur}` };
67
+ }
68
+ return { found: true, value: cur };
69
+ }
70
+ /**
71
+ * The container that holds a pointer's final token, plus that token.
72
+ *
73
+ * A syntactically invalid pointer is the caller's mistake, so it comes back
74
+ * as a `PatchError` carrying the operation index — the same shape every
75
+ * other failure here has — rather than as the bare `Error` `parsePointer`
76
+ * raises, which would escape the tool as a crash.
77
+ */
78
+ function locateParent(doc, pointer, opIndex) {
79
+ let tokens;
80
+ try {
81
+ tokens = parsePointer(pointer);
82
+ }
83
+ catch (err) {
84
+ throw new PatchError(err.message, opIndex);
85
+ }
86
+ if (tokens.length === 0)
87
+ throw new PatchError("cannot address the root's parent", opIndex);
88
+ const parentPointer = formatPointer(tokens.slice(0, -1));
89
+ const found = resolvePointer(doc, parentPointer);
90
+ if (!found.found) {
91
+ throw new PatchError(`path "${pointer}" does not exist: ${found.reason}`, opIndex);
92
+ }
93
+ return { parent: found.value, token: tokens[tokens.length - 1] };
94
+ }
95
+ function addAt(parent, token, value, pointer, i) {
96
+ if (Array.isArray(parent)) {
97
+ if (token === "-") {
98
+ parent.push(value);
99
+ return;
100
+ }
101
+ if (!/^(0|[1-9][0-9]*)$/.test(token)) {
102
+ throw new PatchError(`"${token}" is not an array index in "${pointer}"`, i);
103
+ }
104
+ const idx = Number(token);
105
+ if (idx > parent.length) {
106
+ throw new PatchError(`index ${idx} is past the end of the array in "${pointer}"`, i);
107
+ }
108
+ parent.splice(idx, 0, value);
109
+ return;
110
+ }
111
+ if (isPlainObject(parent)) {
112
+ parent[token] = value;
113
+ return;
114
+ }
115
+ throw new PatchError(`cannot add to a ${typeof parent} at "${pointer}"`, i);
116
+ }
117
+ function removeAt(parent, token, pointer, i) {
118
+ if (Array.isArray(parent)) {
119
+ if (!/^(0|[1-9][0-9]*)$/.test(token)) {
120
+ throw new PatchError(`"${token}" is not an array index in "${pointer}"`, i);
121
+ }
122
+ const idx = Number(token);
123
+ if (idx >= parent.length) {
124
+ throw new PatchError(`index ${idx} is past the end of the array in "${pointer}"`, i);
125
+ }
126
+ return parent.splice(idx, 1)[0];
127
+ }
128
+ if (isPlainObject(parent)) {
129
+ if (!Object.hasOwn(parent, token)) {
130
+ throw new PatchError(`no member "${token}" to remove at "${pointer}"`, i);
131
+ }
132
+ const old = parent[token];
133
+ delete parent[token];
134
+ return old;
135
+ }
136
+ throw new PatchError(`cannot remove from a ${typeof parent} at "${pointer}"`, i);
137
+ }
138
+ /**
139
+ * Apply an RFC 6902 patch. Returns the new document; the input is never
140
+ * mutated. Throws `PatchError` (carrying the failing operation's index) if
141
+ * any operation cannot be applied, leaving nothing half-done.
142
+ */
143
+ export function applyJsonPatch(doc, ops) {
144
+ let working = deepClone(doc);
145
+ ops.forEach((op, i) => {
146
+ switch (op.op) {
147
+ case "test": {
148
+ const found = resolvePointer(working, op.path);
149
+ if (!found.found)
150
+ throw new PatchError(`test failed: "${op.path}" ${found.reason}`, i);
151
+ if (!deepEqual(found.value, op.value)) {
152
+ throw new PatchError(`test failed: "${op.path}" is not the expected value`, i);
153
+ }
154
+ return;
155
+ }
156
+ case "add": {
157
+ if (op.path === "") {
158
+ working = deepClone(op.value);
159
+ return;
160
+ }
161
+ const { parent, token } = locateParent(working, op.path, i);
162
+ addAt(parent, token, deepClone(op.value), op.path, i);
163
+ return;
164
+ }
165
+ case "remove": {
166
+ if (op.path === "")
167
+ throw new PatchError("cannot remove the whole document", i);
168
+ const { parent, token } = locateParent(working, op.path, i);
169
+ removeAt(parent, token, op.path, i);
170
+ return;
171
+ }
172
+ case "replace": {
173
+ if (op.path === "") {
174
+ working = deepClone(op.value);
175
+ return;
176
+ }
177
+ const existing = resolvePointer(working, op.path);
178
+ if (!existing.found) {
179
+ throw new PatchError(`cannot replace "${op.path}": ${existing.reason}`, i);
180
+ }
181
+ const { parent, token } = locateParent(working, op.path, i);
182
+ removeAt(parent, token, op.path, i);
183
+ addAt(parent, token, deepClone(op.value), op.path, i);
184
+ return;
185
+ }
186
+ case "move": {
187
+ if (op.path.startsWith(`${op.from}/`)) {
188
+ throw new PatchError(`cannot move "${op.from}" into its own child "${op.path}"`, i);
189
+ }
190
+ // RFC 6902 §4.4: the "from" location MUST exist, and that holds even
191
+ // when it equals "path". Taking the no-op shortcut first would let a
192
+ // move of something that is not there report success.
193
+ const src = resolvePointer(working, op.from);
194
+ if (!src.found)
195
+ throw new PatchError(`cannot move from "${op.from}": ${src.reason}`, i);
196
+ if (op.path === op.from)
197
+ return;
198
+ const from = locateParent(working, op.from, i);
199
+ const moved = removeAt(from.parent, from.token, op.from, i);
200
+ if (op.path === "") {
201
+ working = moved;
202
+ return;
203
+ }
204
+ const dst = locateParent(working, op.path, i);
205
+ addAt(dst.parent, dst.token, moved, op.path, i);
206
+ return;
207
+ }
208
+ case "copy": {
209
+ const src = resolvePointer(working, op.from);
210
+ if (!src.found)
211
+ throw new PatchError(`cannot copy from "${op.from}": ${src.reason}`, i);
212
+ if (op.path === "") {
213
+ working = deepClone(src.value);
214
+ return;
215
+ }
216
+ const dst = locateParent(working, op.path, i);
217
+ addAt(dst.parent, dst.token, deepClone(src.value), op.path, i);
218
+ return;
219
+ }
220
+ default: {
221
+ const bad = op;
222
+ throw new PatchError(`unknown operation ${JSON.stringify(bad.op)}`, i);
223
+ }
224
+ }
225
+ });
226
+ return working;
227
+ }
228
+ /**
229
+ * Apply an RFC 7386 merge patch: an object merges key by key, `null` deletes
230
+ * a key, and any non-object patch replaces the target outright. Arrays are
231
+ * replaced whole — that is the RFC's behaviour, not a shortcut here.
232
+ */
233
+ export function applyMergePatch(target, patch) {
234
+ if (!isPlainObject(patch))
235
+ return deepClone(patch);
236
+ const base = isPlainObject(target)
237
+ ? deepClone(target)
238
+ : {};
239
+ for (const key of Object.keys(patch)) {
240
+ const value = patch[key];
241
+ if (value === null)
242
+ delete base[key];
243
+ else
244
+ base[key] = applyMergePatch(base[key], value);
245
+ }
246
+ return base;
247
+ }
248
+ /**
249
+ * Derive the RFC 7386 merge patch that turns `from` into `to`. Returns the
250
+ * smallest object that does so; `null` entries mark deletions. Where either
251
+ * side is not an object the result is `to` itself, since a merge patch
252
+ * cannot express a partial array or scalar change.
253
+ */
254
+ export function diffMergePatch(from, to) {
255
+ if (!isPlainObject(from) || !isPlainObject(to))
256
+ return deepClone(to);
257
+ const out = {};
258
+ for (const key of Object.keys(to)) {
259
+ if (!Object.hasOwn(from, key)) {
260
+ out[key] = deepClone(to[key]);
261
+ continue;
262
+ }
263
+ if (deepEqual(from[key], to[key]))
264
+ continue;
265
+ out[key] = diffMergePatch(from[key], to[key]);
266
+ }
267
+ for (const key of Object.keys(from)) {
268
+ if (!Object.hasOwn(to, key))
269
+ out[key] = null;
270
+ }
271
+ return out;
272
+ }
@@ -0,0 +1,156 @@
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
+ export type Record_ = Record<string, unknown>;
11
+ /** Read a dotted path out of a record. Returns undefined for any miss. */
12
+ export declare function getPath(record: unknown, path: string): unknown;
13
+ /** Write a dotted path into a record, creating intermediate objects. */
14
+ export declare function setPath(target: Record_, path: string, value: unknown): void;
15
+ export type Comparison = "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "contains" | "startsWith" | "endsWith" | "in" | "matches" | "exists" | "empty";
16
+ export type Condition = {
17
+ field: string;
18
+ op: Comparison;
19
+ value?: unknown;
20
+ };
21
+ /** All conditions must hold ("and"), or any one ("or"). */
22
+ export type Predicate = {
23
+ all?: Condition[];
24
+ any?: Condition[];
25
+ none?: Condition[];
26
+ };
27
+ export declare class PredicateError extends Error {
28
+ }
29
+ /** Evaluate one condition against a record. */
30
+ export declare function testCondition(record: unknown, cond: Condition): boolean;
31
+ /**
32
+ * Order two scalars, or null when they are not comparable. Numbers compare
33
+ * numerically, strings by code unit, booleans false-before-true; a
34
+ * number/string pair is not comparable, which is what stops `"10" < 9`
35
+ * quietly being true.
36
+ */
37
+ export declare function compareValues(a: unknown, b: unknown): number | null;
38
+ /** Evaluate a whole predicate. An empty predicate matches everything. */
39
+ export declare function testPredicate(record: unknown, predicate: Predicate): boolean;
40
+ export type SortKey = {
41
+ field: string;
42
+ direction?: "asc" | "desc";
43
+ /** Compare strings case-insensitively. Has no effect on other types. */
44
+ caseInsensitive?: boolean;
45
+ /** Where undefined and null go. Default "last", whatever the direction. */
46
+ nulls?: "first" | "last";
47
+ };
48
+ /**
49
+ * Sort records by one or more keys. The sort is stable and total: values of
50
+ * different types are ordered by a fixed type rank (null < boolean < number
51
+ * < string < array < object) so the result never depends on the input order
52
+ * of incomparable rows.
53
+ */
54
+ export declare function sortRecords<T>(records: ReadonlyArray<T>, keys: ReadonlyArray<SortKey>): T[];
55
+ /**
56
+ * The type-rank order. `TYPE_ORDER[rankOf(v)]` is the name of `v`'s rank, so
57
+ * the documented order and the code that implements it cannot drift.
58
+ * `undefined` ranks with `null`, since neither is a JSON value a comparison
59
+ * can say anything else about.
60
+ */
61
+ export declare const TYPE_ORDER: ReadonlyArray<string>;
62
+ /**
63
+ * A total order over JSON values, used so a sort is never arbitrary.
64
+ *
65
+ * Within a rank, scalars compare naturally. Arrays and objects compare by
66
+ * their canonical text, which is lexicographic and therefore *not*
67
+ * element-wise numeric: `[1, 10]` sorts before `[1, 2]`, because `"1"`
68
+ * precedes `"2"` at the fourth character. That is the price of an order that
69
+ * is total and never has to invent a comparison between a number and an
70
+ * object; sort by a scalar field when the order has to mean something.
71
+ */
72
+ export declare function totalCompare(a: unknown, b: unknown): number;
73
+ /** Keep only the named fields (dotted paths), preserving the requested order. */
74
+ export declare function selectFields(record: unknown, fields: ReadonlyArray<string>): Record_;
75
+ /** Drop the named fields (dotted paths) from a shallow copy. */
76
+ export declare function omitFields(record: Record_, fields: ReadonlyArray<string>): Record_;
77
+ export type AggregateFn = "count" | "sum" | "min" | "max" | "avg" | "first" | "last" | "distinct";
78
+ export type Aggregation = {
79
+ as: string;
80
+ fn: AggregateFn;
81
+ field?: string;
82
+ };
83
+ export type GroupResult = {
84
+ key: Record_;
85
+ count: number;
86
+ } & Record_;
87
+ /**
88
+ * Group records by one or more fields and reduce each group.
89
+ *
90
+ * `sum` and `avg` ignore non-numeric values and report how many were skipped
91
+ * via the `_skipped` count, rather than producing NaN. `min`/`max` use the
92
+ * same total order as `sortRecords`. `distinct` counts distinct values by
93
+ * their canonical form, so `{a:1,b:2}` and `{b:2,a:1}` are one value.
94
+ */
95
+ export declare function aggregate(records: ReadonlyArray<Record_>, groupBy: ReadonlyArray<string>, aggregations: ReadonlyArray<Aggregation>): {
96
+ groups: Record_[];
97
+ skipped: number;
98
+ };
99
+ export type JoinKind = "inner" | "left" | "right" | "full";
100
+ export type JoinOptions = {
101
+ kind: JoinKind;
102
+ leftKey: string;
103
+ rightKey: string;
104
+ /** Prefix applied to right-hand fields whose name collides with a left one. */
105
+ rightPrefix: string;
106
+ maxRows: number;
107
+ };
108
+ /**
109
+ * Join two record arrays on a key. Matching is by the canonical form of the
110
+ * key value, so `1` and `"1"` are different keys — a silent coercion here is
111
+ * how a join quietly loses rows.
112
+ *
113
+ * Every right-hand match is emitted, so a one-to-many join multiplies rows,
114
+ * as SQL does. `maxRows` bounds that.
115
+ */
116
+ export declare function joinRecords(left: ReadonlyArray<Record_>, right: ReadonlyArray<Record_>, options: JoinOptions): {
117
+ rows: Record_[];
118
+ truncated: boolean;
119
+ unmatchedLeft: number;
120
+ unmatchedRight: number;
121
+ };
122
+ /** Records to a column-oriented object: `[{a:1},{a:2}]` becomes `{a:[1,2]}`. */
123
+ export declare function recordsToColumns(records: ReadonlyArray<Record_>): Record<string, unknown[]>;
124
+ /**
125
+ * Columns back to records. Columns of differing length are padded with null
126
+ * up to the longest, and the caller is told how many were short.
127
+ */
128
+ export declare function columnsToRecords(columns: Record<string, ReadonlyArray<unknown>>): {
129
+ records: Record_[];
130
+ ragged: string[];
131
+ };
132
+ /** Flatten nested objects into dotted keys. Arrays become `a.0`, `a.1` when expanded. */
133
+ export declare function flattenObject(value: unknown, separator: string, expandArrays: boolean, maxDepth: number): Record<string, unknown>;
134
+ /**
135
+ * Rebuild nested objects from dotted keys. A numeric segment becomes an
136
+ * array index when `arraysFromNumericKeys` is on and the keys under that
137
+ * prefix are a contiguous run from 0; otherwise it stays an object key, so
138
+ * `{"a.2": 1}` does not silently produce two nulls.
139
+ */
140
+ export declare function unflattenObject(flat: Record<string, unknown>, separator: string, arraysFromNumericKeys: boolean): unknown;
141
+ /**
142
+ * De-duplicate records by a key path, or by the whole value when no key is
143
+ * given. Identity is the canonical (key-sorted) serialization, so two
144
+ * records that differ only in key order are one record.
145
+ */
146
+ export declare function dedupeRecords<T>(records: ReadonlyArray<T>, keyFields: ReadonlyArray<string>, keep: "first" | "last"): {
147
+ records: T[];
148
+ removed: number;
149
+ };
150
+ export type SampleMode = "head" | "tail" | "everyNth" | "evenly";
151
+ /**
152
+ * Take a deterministic sample. There is no random mode on purpose: a tool
153
+ * that returned a different subset on each call could not be cached, retried
154
+ * or compared between runs. `evenly` spreads picks across the whole array.
155
+ */
156
+ export declare function sampleRecords<T>(records: ReadonlyArray<T>, mode: SampleMode, count: number, step: number): T[];