@junoflow/testkit 0.11.0 → 0.13.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 +156 -17
- package/package.json +1 -1
- package/src/ask.js +364 -0
- package/src/bindings.js +254 -28
- package/src/conformance.js +26 -1
- package/src/flow.js +44 -15
- package/src/handles.js +472 -0
- package/src/index.d.ts +210 -6
- package/src/index.js +187 -0
- package/src/scenario.js +14 -3
package/src/handles.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { fail } from './assert.js';
|
|
2
|
+
|
|
3
|
+
// The handle half of the flow harness: ctx.after, ctx.at, ctx.all and
|
|
4
|
+
// ctx.wait, plus the state handles (state.<b>.when|becomes|changes), the
|
|
5
|
+
// event handle (targets.<i>.untilEvent), the run handle
|
|
6
|
+
// (targets.<f>.start|startAll), the press handle (controls.<b>.untilPressed)
|
|
7
|
+
// and the question handle (ctx.ask), built as sdk/src/handles.ts and the
|
|
8
|
+
// engine's kinds behave, minus the clock and the bus. The harness cannot
|
|
9
|
+
// suspend, so a handle fires only where the engine would fire it inside Open:
|
|
10
|
+
// an `at` whose instant is at or before h.now(), and a `when` without `for`
|
|
11
|
+
// whose binding's value already matches — and a run handle or a question,
|
|
12
|
+
// which fires at open with the test's answer. An `after`, a `becomes`, a
|
|
13
|
+
// `changes`, a `for` hold, an event, a press and an unanswered question never
|
|
14
|
+
// fire. An `all` fires at open when every member did, and is cancelled with
|
|
15
|
+
// any member. A wait with a fired handle listed returns it; any other wait is
|
|
16
|
+
// refused.
|
|
17
|
+
|
|
18
|
+
/** The engine's cap on open handles per run (decision 6 of the wait plan). */
|
|
19
|
+
export const MAX_OPEN_HANDLES = 100;
|
|
20
|
+
|
|
21
|
+
const CAP_MESSAGE = `run has ${MAX_OPEN_HANDLES} open handles; cancel the ones it no longer needs (a handle created in a loop wants "using")`;
|
|
22
|
+
|
|
23
|
+
// Go's time.ParseDuration, as juno/valid-duration spells it.
|
|
24
|
+
const GO_DURATION =
|
|
25
|
+
/^[+-]?(\d+(\.\d*)?|\.\d+)(ns|us|µs|μs|ms|s|m|h)((\d+(\.\d*)?|\.\d+)(ns|us|µs|μs|ms|s|m|h))*$/;
|
|
26
|
+
|
|
27
|
+
// time.RFC3339 as Go parses it: seconds required, an offset required.
|
|
28
|
+
const RFC3339 =
|
|
29
|
+
/^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
30
|
+
|
|
31
|
+
// `using` lowers to Symbol.dispose where the runtime has it, and to this
|
|
32
|
+
// registered symbol where it does not — the fallback esbuild and tsc emit.
|
|
33
|
+
const DISPOSE = Symbol.dispose ?? Symbol.for('Symbol.dispose');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An instant as the engine writes fired_at: RFC 3339 in UTC with trailing
|
|
37
|
+
* fractional zeros trimmed (Go's RFC3339Nano).
|
|
38
|
+
*/
|
|
39
|
+
export function rfc3339(ms) {
|
|
40
|
+
return new Date(ms)
|
|
41
|
+
.toISOString()
|
|
42
|
+
.replace(/\.(\d*?)0+Z$/, (_, d) => (d ? `.${d}Z` : 'Z'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One run's handle table. `refuse` is the run's recording fail(); `instant`
|
|
47
|
+
* reads the harness clock (epoch ms, or undefined with none set); `render`
|
|
48
|
+
* spells a call's arguments for a refusal.
|
|
49
|
+
*/
|
|
50
|
+
export function createHandleTable({ refuse, instant, render, harnessName }) {
|
|
51
|
+
const handles = [];
|
|
52
|
+
let handleIdx = 0;
|
|
53
|
+
let waitIdx = 0;
|
|
54
|
+
// The SDK reports a refused open at the next wait or when the handler
|
|
55
|
+
// returns, never at the constructor, which is synchronous. Kept here and
|
|
56
|
+
// thrown at the same two points.
|
|
57
|
+
let refusal = null;
|
|
58
|
+
|
|
59
|
+
const record = (message) => {
|
|
60
|
+
try {
|
|
61
|
+
refuse(message);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
refusal ??= err;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const byObject = new WeakMap();
|
|
68
|
+
|
|
69
|
+
// name is the constructor as a refusal spells it; kind is what the host
|
|
70
|
+
// records (both `after` and `at` are timers there). spec carries the host's
|
|
71
|
+
// refusal (`error`), or when it fires at open, `firesAt` (epoch ms) and
|
|
72
|
+
// `value`; and `label` when it is not the second argument, as a timer's is.
|
|
73
|
+
function open(name, args, spec, kind = 'timer') {
|
|
74
|
+
const id = `__h_${handleIdx++}__`;
|
|
75
|
+
const label = 'label' in spec ? spec.label : args[1];
|
|
76
|
+
const h = {
|
|
77
|
+
id,
|
|
78
|
+
kind,
|
|
79
|
+
label,
|
|
80
|
+
status: 'open',
|
|
81
|
+
call: spec.call ?? `${name}(${render(args)})`,
|
|
82
|
+
members: spec.members,
|
|
83
|
+
returned: false,
|
|
84
|
+
value: undefined,
|
|
85
|
+
firedAt: undefined,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const openCount = handles.filter((x) => x.status === 'open').length;
|
|
89
|
+
let error = spec.error;
|
|
90
|
+
if (!error && openCount >= MAX_OPEN_HANDLES) error = CAP_MESSAGE;
|
|
91
|
+
if (error) {
|
|
92
|
+
// A refused open leaves nothing behind on the host.
|
|
93
|
+
h.status = 'refused';
|
|
94
|
+
record(error);
|
|
95
|
+
} else {
|
|
96
|
+
handles.push(h);
|
|
97
|
+
if (spec.firesAt !== undefined) {
|
|
98
|
+
h.status = 'fired';
|
|
99
|
+
h.value = spec.value ?? null;
|
|
100
|
+
h.firedAt = rfc3339(spec.firesAt);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return handleObject(h);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function handleObject(h) {
|
|
107
|
+
const notReturned = () => {
|
|
108
|
+
throw new Error(`handle ${h.id} has not been returned by a wait`);
|
|
109
|
+
};
|
|
110
|
+
const obj = {
|
|
111
|
+
id: h.id,
|
|
112
|
+
label: h.label,
|
|
113
|
+
get fired() {
|
|
114
|
+
return h.returned;
|
|
115
|
+
},
|
|
116
|
+
get value() {
|
|
117
|
+
if (!h.returned) notReturned();
|
|
118
|
+
return h.value;
|
|
119
|
+
},
|
|
120
|
+
get firedAt() {
|
|
121
|
+
if (!h.returned) notReturned();
|
|
122
|
+
return h.firedAt;
|
|
123
|
+
},
|
|
124
|
+
cancel() {
|
|
125
|
+
if (h.cancelled) return;
|
|
126
|
+
h.cancelled = true;
|
|
127
|
+
if (h.status === 'open') cancelOpen(h);
|
|
128
|
+
},
|
|
129
|
+
[DISPOSE]() {
|
|
130
|
+
obj.cancel();
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
byObject.set(obj, h);
|
|
134
|
+
return obj;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The engine's cancelHandle: close the handle, then every open all that
|
|
138
|
+
// lists it, recursively — an all whose member cannot fire cannot either.
|
|
139
|
+
// A cascaded all is refused by a later wait, as the host refuses it.
|
|
140
|
+
function cancelOpen(h) {
|
|
141
|
+
h.status = 'cancelled';
|
|
142
|
+
for (const a of handles) {
|
|
143
|
+
if (a.kind === 'all' && a.status === 'open' && a.members.includes(h)) {
|
|
144
|
+
a.cancelled = true;
|
|
145
|
+
cancelOpen(a);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
after(duration, label) {
|
|
152
|
+
const args = [duration, label];
|
|
153
|
+
const ok =
|
|
154
|
+
typeof duration === 'string' &&
|
|
155
|
+
(duration === '0' || GO_DURATION.test(duration));
|
|
156
|
+
if (!ok) {
|
|
157
|
+
return open('after', args, {
|
|
158
|
+
error: `after: ${JSON.stringify(duration)} is not a duration (e.g. "30s", "5m")`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return open('after', args, {});
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
at(when, label) {
|
|
165
|
+
const args = [when, label];
|
|
166
|
+
const ms =
|
|
167
|
+
typeof when === 'string' && RFC3339.test(when) ? Date.parse(when) : NaN;
|
|
168
|
+
if (Number.isNaN(ms)) {
|
|
169
|
+
return open('at', args, {
|
|
170
|
+
error: `at: ${JSON.stringify(when)} is not an RFC 3339 instant`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const now = instant();
|
|
174
|
+
if (now === undefined) {
|
|
175
|
+
refuse(
|
|
176
|
+
`at(${render(args)}) was called but the test set no clock — h.now('2026-10-04T18:42:00') ` +
|
|
177
|
+
`(wall time in h.timezone(), default UTC), or ${harnessName}({ now })`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return open('at', args, { firesAt: ms <= now ? ms : undefined });
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* state.<binding>.when|becomes|changes. `call` is how a refusal spells
|
|
185
|
+
* the constructor (`state.door.when`); `current` answers the binding's
|
|
186
|
+
* value, read only when a `when` without `for` needs it to decide whether
|
|
187
|
+
* the handle fires at open; `error` is a host refusal the caller already
|
|
188
|
+
* knows of (a binding that grants no value access).
|
|
189
|
+
*/
|
|
190
|
+
stateHandle(mode, call, args, { current, error }) {
|
|
191
|
+
const [match, opts] = mode === 'changes' ? [undefined, args[0]] : args;
|
|
192
|
+
const label = opts?.label;
|
|
193
|
+
const conds =
|
|
194
|
+
mode === 'changes' ? undefined : compileMatch(mode, match, refuse);
|
|
195
|
+
const hold = mode === 'changes' ? undefined : opts?.for;
|
|
196
|
+
if (!error && hold !== undefined && !isPositiveDuration(hold)) {
|
|
197
|
+
error = `${mode}: for ${JSON.stringify(hold)} is not a duration (e.g. "30s", "5m")`;
|
|
198
|
+
}
|
|
199
|
+
if (error) return open(call, args, { error, label }, 'state');
|
|
200
|
+
const spec = { label };
|
|
201
|
+
if (mode === 'when' && hold === undefined) {
|
|
202
|
+
const value = current();
|
|
203
|
+
if (matches(value, conds)) {
|
|
204
|
+
spec.firesAt = instant() ?? Date.now();
|
|
205
|
+
spec.value = value;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return open(call, args, spec, 'state');
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* targets.<binding>.untilEvent. The harness has no bus, so it never
|
|
213
|
+
* fires; a `where` value that is not a scalar is the host's refusal.
|
|
214
|
+
*/
|
|
215
|
+
eventHandle(call, args) {
|
|
216
|
+
const [, where, opts] = args;
|
|
217
|
+
let error;
|
|
218
|
+
for (const field of Object.keys(where ?? {}).sort()) {
|
|
219
|
+
if (!isScalar(where[field])) {
|
|
220
|
+
error = `untilEvent: where.${field} must be a string, number, boolean or null`;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return open(call, args, { error, label: opts?.label }, 'event');
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
/** controls.<b>.untilPressed: never fires, since nothing presses in a test. */
|
|
228
|
+
pressHandle(call, args) {
|
|
229
|
+
const [opts] = args;
|
|
230
|
+
return open(call, args, { label: opts?.label }, 'press');
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* targets.<flow>.start|startAll. The harness runs no child, so a run
|
|
235
|
+
* handle fires at open with the value the test's answer makes, or is the
|
|
236
|
+
* host's refusal when `error` is set.
|
|
237
|
+
*/
|
|
238
|
+
runHandle(call, args, { label, value, error }) {
|
|
239
|
+
if (error !== undefined) return open(call, args, { error, label }, 'run');
|
|
240
|
+
return open(
|
|
241
|
+
call,
|
|
242
|
+
args,
|
|
243
|
+
{ label, firesAt: instant() ?? Date.now(), value },
|
|
244
|
+
'run'
|
|
245
|
+
);
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* ctx.ask. The harness has no channels, so a question fires at open with
|
|
250
|
+
* the value the test's answer makes, stays open with none, or is the
|
|
251
|
+
* host's refusal when `error` is set.
|
|
252
|
+
*/
|
|
253
|
+
questionHandle(call, args, { label, value, error }) {
|
|
254
|
+
if (error !== undefined) {
|
|
255
|
+
return open(call, args, { error, label }, 'question');
|
|
256
|
+
}
|
|
257
|
+
const spec = { label };
|
|
258
|
+
if (value !== undefined) {
|
|
259
|
+
spec.firesAt = instant() ?? Date.now();
|
|
260
|
+
spec.value = value;
|
|
261
|
+
}
|
|
262
|
+
return open(call, args, spec, 'question');
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* ctx.all: fires at open when every member fired at open, with their
|
|
267
|
+
* values in list order at the latest member's firedAt; else never.
|
|
268
|
+
*/
|
|
269
|
+
all(list, label) {
|
|
270
|
+
if (!Array.isArray(list)) {
|
|
271
|
+
refuse(`all takes an array of handles, got ${render([list])}`);
|
|
272
|
+
}
|
|
273
|
+
const rows = list.filter(Boolean).map((obj) => {
|
|
274
|
+
const h = byObject.get(obj);
|
|
275
|
+
if (!h) refuse(`all lists ${render([obj])}, which is not a handle`);
|
|
276
|
+
return h;
|
|
277
|
+
});
|
|
278
|
+
const call = `all([${rows.map((h) => h.call).join(', ')}]${label === undefined ? '' : `, ${render([label])}`})`;
|
|
279
|
+
const spec = { call, label, members: rows };
|
|
280
|
+
if (rows.length === 0) {
|
|
281
|
+
spec.error = 'all needs at least one handle';
|
|
282
|
+
} else {
|
|
283
|
+
const bad =
|
|
284
|
+
rows.find((h) => h.status === 'refused') ??
|
|
285
|
+
rows.find((h) => h.status === 'cancelled');
|
|
286
|
+
if (bad) {
|
|
287
|
+
spec.error =
|
|
288
|
+
bad.status === 'refused'
|
|
289
|
+
? `all lists ${bad.id}, which this run never opened`
|
|
290
|
+
: `all lists ${bad.id}, which was cancelled`;
|
|
291
|
+
} else if (rows.every((h) => h.status === 'fired')) {
|
|
292
|
+
spec.firesAt = Math.max(...rows.map((h) => Date.parse(h.firedAt)));
|
|
293
|
+
spec.value = rows.map((h) => h.value);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return open('all', [list, label], spec, 'all');
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
async wait(list, label) {
|
|
300
|
+
waitIdx++;
|
|
301
|
+
if (!Array.isArray(list)) {
|
|
302
|
+
refuse(`wait takes an array of handles, got ${render([list])}`);
|
|
303
|
+
}
|
|
304
|
+
const listed = list.filter(Boolean);
|
|
305
|
+
if (listed.length === 0) refuse('wait needs at least one handle');
|
|
306
|
+
const rows = listed.map((obj) => {
|
|
307
|
+
const h = byObject.get(obj);
|
|
308
|
+
if (!h) refuse(`wait lists ${render([obj])}, which is not a handle`);
|
|
309
|
+
return h;
|
|
310
|
+
});
|
|
311
|
+
for (const h of rows) {
|
|
312
|
+
if (h.cancelled) refuse(`wait lists ${h.id}, which was cancelled`);
|
|
313
|
+
}
|
|
314
|
+
settle();
|
|
315
|
+
|
|
316
|
+
// The earliest fired_at wins; ties go to the earlier list position.
|
|
317
|
+
let best = null;
|
|
318
|
+
for (const h of rows) {
|
|
319
|
+
if (h.status !== 'fired') continue;
|
|
320
|
+
if (best === null || Date.parse(h.firedAt) < Date.parse(best.firedAt)) {
|
|
321
|
+
best = h;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (best) {
|
|
325
|
+
best.returned = true;
|
|
326
|
+
return listed[rows.indexOf(best)];
|
|
327
|
+
}
|
|
328
|
+
const calls = rows.map((h) => h.call).join(', ');
|
|
329
|
+
refuse(
|
|
330
|
+
`a handler cannot suspend in a test: wait([${calls}]${label === undefined ? '' : `, ${render([label])}`})`
|
|
331
|
+
);
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
/** Throws the first refused open, as the SDK's settle() does. */
|
|
335
|
+
settle,
|
|
336
|
+
|
|
337
|
+
/** A run that ended cancels every handle still open, as the engine does. */
|
|
338
|
+
close() {
|
|
339
|
+
for (const h of handles) if (h.status === 'open') h.status = 'cancelled';
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
list() {
|
|
343
|
+
return handles.map(({ id, kind, label, status }) => ({
|
|
344
|
+
id,
|
|
345
|
+
kind,
|
|
346
|
+
label,
|
|
347
|
+
status,
|
|
348
|
+
}));
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
function settle() {
|
|
353
|
+
if (refusal) throw refusal;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A Go duration greater than zero, as the state kind accepts for `for`. */
|
|
358
|
+
export function isPositiveDuration(d) {
|
|
359
|
+
if (typeof d !== 'string' || !GO_DURATION.test(d) || d.startsWith('-')) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
return [...d.matchAll(/(\d+(?:\.\d*)?|\.\d+)[a-zµμ]+/g)].some(
|
|
363
|
+
(m) => Number(m[1]) > 0
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ─── match semantics (internal/handlekind) ─────────────────────────────────
|
|
368
|
+
//
|
|
369
|
+
// One definition of what `where` and a state match mean, held to the Go side
|
|
370
|
+
// by internal/handlekind/testdata/handle-match.json (test/handles.test.js).
|
|
371
|
+
|
|
372
|
+
/** A state match's object keys, and the op each compiles to (sdk/src/context.ts). */
|
|
373
|
+
const MATCH_KEYS = new Map([
|
|
374
|
+
['in', 'in'],
|
|
375
|
+
['not', 'ne'],
|
|
376
|
+
['notIn', 'not_in'],
|
|
377
|
+
['above', 'gt'],
|
|
378
|
+
['below', 'lt'],
|
|
379
|
+
['atLeast', 'ge'],
|
|
380
|
+
['atMost', 'le'],
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* compileMatch in sdk/src/context.ts: a plain value is one `eq`; an object is
|
|
385
|
+
* one condition per key present. An unknown key is refused, as the SDK throws.
|
|
386
|
+
*/
|
|
387
|
+
export function compileMatch(ctor, match, refuse = fail) {
|
|
388
|
+
if (match === null || typeof match !== 'object' || Array.isArray(match)) {
|
|
389
|
+
return [{ op: 'eq', value: match ?? null }];
|
|
390
|
+
}
|
|
391
|
+
const conds = [];
|
|
392
|
+
for (const [key, value] of Object.entries(match)) {
|
|
393
|
+
const op = MATCH_KEYS.get(key);
|
|
394
|
+
if (op === undefined) {
|
|
395
|
+
refuse(
|
|
396
|
+
`${ctor}: match has no condition "${key}" (have: ${[...MATCH_KEYS.keys()].join(', ')})`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (value !== undefined) conds.push({ op, value });
|
|
400
|
+
}
|
|
401
|
+
return conds;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** handlekind.Matches: every condition holds. */
|
|
405
|
+
export function matches(value, conds) {
|
|
406
|
+
return conds.every((c) => matchCond(value, c));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function matchCond(v, { op, value }) {
|
|
410
|
+
switch (op) {
|
|
411
|
+
case 'eq':
|
|
412
|
+
return jsonEqual(v, value);
|
|
413
|
+
case 'ne':
|
|
414
|
+
return !jsonEqual(v, value);
|
|
415
|
+
case 'in':
|
|
416
|
+
case 'not_in': {
|
|
417
|
+
if (!Array.isArray(value)) return false;
|
|
418
|
+
return value.some((x) => jsonEqual(v, x)) === (op === 'in');
|
|
419
|
+
}
|
|
420
|
+
case 'gt':
|
|
421
|
+
case 'lt':
|
|
422
|
+
case 'ge':
|
|
423
|
+
case 'le': {
|
|
424
|
+
if (typeof v !== 'number' || typeof value !== 'number') return false;
|
|
425
|
+
if (op === 'gt') return v > value;
|
|
426
|
+
if (op === 'lt') return v < value;
|
|
427
|
+
if (op === 'ge') return v >= value;
|
|
428
|
+
return v <= value;
|
|
429
|
+
}
|
|
430
|
+
default:
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* handlekind.MatchWhere: every where field present on the payload and equal
|
|
437
|
+
* as a JSON value; a where value that is not a scalar never matches.
|
|
438
|
+
*/
|
|
439
|
+
export function matchWhere(payload, where) {
|
|
440
|
+
const fields = Object.keys(where ?? {});
|
|
441
|
+
if (fields.length === 0) return true;
|
|
442
|
+
if (
|
|
443
|
+
payload === null ||
|
|
444
|
+
typeof payload !== 'object' ||
|
|
445
|
+
Array.isArray(payload)
|
|
446
|
+
) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
return fields.every(
|
|
450
|
+
(f) =>
|
|
451
|
+
isScalar(where[f]) &&
|
|
452
|
+
Object.hasOwn(payload, f) &&
|
|
453
|
+
jsonEqual(payload[f], where[f])
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isScalar(v) {
|
|
458
|
+
return v === null || ['string', 'number', 'boolean'].includes(typeof v);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Equal as JSON values: object keys in any order, 1 and "1" different. */
|
|
462
|
+
function jsonEqual(a, b) {
|
|
463
|
+
return canonical(a) === canonical(b);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function canonical(v) {
|
|
467
|
+
if (v === undefined) return 'null';
|
|
468
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
469
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
470
|
+
const keys = Object.keys(v).sort();
|
|
471
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
|
|
472
|
+
}
|