@coda-console/kernel 0.0.0 → 0.1.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/src/kernel.mjs ADDED
@@ -0,0 +1,1038 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { META, MIGRATIONS, SQL } from './schema.mjs';
3
+ import { Transactions } from './transaction.mjs';
4
+
5
+ /**
6
+ * Thrown by `accept` to refuse a call: the run ends `failed` with `effects: 'none'`, and a repeat gets the same. Thrown
7
+ * by `answer` to refuse an answer: the run stays as it was, and the receipt says why.
8
+ */
9
+ export class Rejected extends Error {
10
+ /**
11
+ * @param {string} code
12
+ * @param {string} [message]
13
+ */
14
+ constructor(code, message = code) {
15
+ super(message);
16
+ this.name = 'Rejected';
17
+ this.code = code;
18
+ }
19
+ }
20
+
21
+ /** Thrown by `accept` to not take a call now: nothing is kept, and the caller sends it again on a later pump(). */
22
+ export class Busy extends Error {
23
+ constructor(message = 'busy') {
24
+ super(message);
25
+ this.name = 'Busy';
26
+ }
27
+ }
28
+
29
+ const ENDED = new Set(['completed', 'failed', 'canceled']);
30
+ const EFFECTS = new Set(['none', 'unknown', 'some']);
31
+ const WORKING = new Set(['working', 'input-required']);
32
+ // Receipts after which a letter stays in the outbox, untouched, until the next pump() (C10).
33
+ const HOLD = new Set(['busy', 'not_ready']);
34
+ // Letters the send loop hands over before it yields to the event loop once.
35
+ const BATCH = 8;
36
+ // The fields each kind of letter has (6); a name in `must` outside these makes the letter unsupported.
37
+ const fields = names => new Set(`v kind from to must ${names}`.split(' '));
38
+ const FIELDS = {
39
+ call: fields('id op input digest awaited parent confirmed'),
40
+ status: fields('call rev status asked asking progress'),
41
+ result: fields('call outcome result error progress'),
42
+ cancel: fields('call reason'),
43
+ answer: fields('id call asked answer'),
44
+ };
45
+
46
+ /**
47
+ * @typedef {{ from: string, id: string }} Ref
48
+ * @typedef {{ outcome: 'completed', result?: unknown } | { outcome: 'failed', error?: unknown } | { outcome: 'canceled' }} Outcome
49
+ * @typedef {{ from: string, id: string, to: string, op: string, input: unknown, awaited: boolean, parent: unknown }} Incoming
50
+ * @typedef {{ from: string, id: string, asked: number, answer: unknown }} Answer
51
+ * @typedef {{ accept: (call: Incoming) => Outcome | undefined, cancel?: (run: object, reason: unknown) => void,
52
+ * answer?: (run: object, answer: Answer) => void }} Handlers
53
+ * @typedef {{ status?: (call: object) => void, result?: (call: object) => void, late?: (call: object) => void }} ResultHandlers
54
+ * @typedef {{ ok: true, view?: object, applied?: boolean } | { ok: false, code: string, error?: { code: string, message: string } }} Receipt
55
+ */
56
+
57
+ /**
58
+ * @param {{
59
+ * db: import('node:sqlite').DatabaseSync,
60
+ * now: () => number,
61
+ * send: (to: string, envelope: object) => Promise<Receipt>,
62
+ * onError?: (error: unknown) => void,
63
+ * wake?: (due: number | null) => void,
64
+ * schedule?: (fn: () => void) => void,
65
+ * }} options
66
+ */
67
+ export function createKernel(options) {
68
+ return new Kernel(options);
69
+ }
70
+
71
+ class Kernel {
72
+ #db;
73
+ #now;
74
+ #send;
75
+ #onError;
76
+ #wake;
77
+ #schedule;
78
+ #tx;
79
+ /** @type {Map<string, import('node:sqlite').StatementSync>} */
80
+ #statements = new Map();
81
+ /** @type {Map<string, Map<string, Handlers>>} */
82
+ #handlers = new Map();
83
+ /** @type {Map<string, ResultHandlers>} */
84
+ #results = new Map();
85
+ #started = false;
86
+ #closed = false;
87
+ // The send loop's memory (R1): all of it may be lost, the outbox says what is still owed.
88
+ #running = false;
89
+ #again = false;
90
+ /** @type {Set<number>} letters, by `seq`, handed to send() and not answered yet */
91
+ #flying = new Set();
92
+ /** letters and inquiries handed to send() and not answered yet */
93
+ #out = 0;
94
+ /** @type {Set<number>} letters that wait for the next pump() */
95
+ #held = new Set();
96
+ /** @type {Map<string, Ref>} inquiries that got no receipt and go again on the next pump() (C5) */
97
+ #rereads = new Map();
98
+ /** @type {Array<() => void>} */
99
+ #idle = [];
100
+ /** @type {number | null} the earliest due time the host was last told of; null: it holds no timer (7, R1) */
101
+ #told = null;
102
+ /** @type {Array<{ ref: Ref, what: string, resolve: (call: object) => void, reject: (error: Error) => void }>} when()'s (R1) */
103
+ #waiters = [];
104
+
105
+ constructor({ db, now, send, onError = error => console.error(error), wake, schedule = setImmediate }) {
106
+ this.#db = db;
107
+ this.#now = now;
108
+ this.#send = send;
109
+ this.#onError = onError;
110
+ this.#wake = wake;
111
+ this.#schedule = schedule;
112
+ this.#tx = new Transactions(db, error => this.#onError(error), () => this.#committed());
113
+ }
114
+
115
+ /**
116
+ * @template T
117
+ * @param {() => T} fn
118
+ * @returns {T}
119
+ */
120
+ transaction(fn) {
121
+ if (this.#closed) throw new Error('the kernel is closed');
122
+ return this.#tx.run(fn);
123
+ }
124
+
125
+ /** @param {() => void} fn */
126
+ afterCommit(fn) {
127
+ this.#tx.after(fn);
128
+ }
129
+
130
+ migrate() {
131
+ this.#inside();
132
+ this.#db.exec(META);
133
+ const version = this.#q(SQL.version).get()?.value ?? 0;
134
+ // A database newer than this code was written by a later kernel: running on it, or writing the older version back,
135
+ // would break the later one.
136
+ if (version > MIGRATIONS.length) throw new Error(`the database is at schema version ${version}, newer than this kernel's ${MIGRATIONS.length}`);
137
+ if (version === MIGRATIONS.length) return;
138
+ for (const step of MIGRATIONS.slice(version)) this.#db.exec(step);
139
+ this.#q(SQL.setVersion).run(MIGRATIONS.length);
140
+ }
141
+
142
+ /**
143
+ * @param {string} address
144
+ * @param {string} op
145
+ * @param {Handlers} handlers
146
+ */
147
+ handle(address, op, handlers) {
148
+ if (!this.#handlers.has(address)) this.#handlers.set(address, new Map());
149
+ this.#handlers.get(address).set(op, handlers);
150
+ }
151
+
152
+ /**
153
+ * @param {string} address
154
+ * @param {ResultHandlers} handlers
155
+ */
156
+ results(address, handlers) {
157
+ this.#results.set(address, handlers);
158
+ }
159
+
160
+ /**
161
+ * @param {{ from: string, id: string, to: string, op: string, input?: unknown, lane?: string, by?: Ref,
162
+ * awaited?: boolean, parent?: unknown, memo?: unknown }} call
163
+ */
164
+ call({ from, id, to, op, input = null, lane = null, by, awaited = false, parent = null, memo }) {
165
+ this.#inside();
166
+ const digest = digestOf([to, op, input, parent]);
167
+ const found = this.#found(from, id, digest);
168
+ if (found) return found;
169
+ awaited = this.#awaits(from, by, awaited);
170
+ this.#start({ from, id, to, op, digest, input, parent, lane, by, awaited, memo });
171
+ return defined({ from, id, to, op, state: 'sending', memo, by: by && { from: by.from, id: by.id }, awaited });
172
+ }
173
+
174
+ /**
175
+ * A call to make once `delayMs` has passed (7). Until then it is a timer, which shares its id with the calls.
176
+ * @param {{ from: string, id: string, delayMs: number, to: string, op: string, input?: unknown, lane?: string, by?: Ref,
177
+ * awaited?: boolean, parent?: unknown, memo?: unknown }} timer
178
+ */
179
+ after({ from, id, delayMs, to, op, input = null, lane = null, by, awaited = false, parent = null, memo }) {
180
+ this.#inside();
181
+ if (!Number.isFinite(delayMs) || delayMs < 0) throw new Error(`delayMs must be a finite number not below 0, not ${delayMs}`);
182
+ const digest = digestOf([to, op, input, parent]);
183
+ const found = this.#found(from, id, digest);
184
+ if (found) return found;
185
+ awaited = this.#awaits(from, by, awaited);
186
+ this.#q(SQL.addTimer).run(from, id, to, op, digest, json(input), json(parent), lane, by?.from ?? null, by?.id ?? null, awaited ? 1 : 0,
187
+ json(memo), this.#now() + delayMs);
188
+ return timerView(this.#q(SQL.timer).get(from, id));
189
+ }
190
+
191
+ /**
192
+ * Takes back a timer not yet due (7). A timer that has become a call is left alone.
193
+ * @param {Ref} timer
194
+ */
195
+ unschedule(timer) {
196
+ this.#inside();
197
+ if (!this.#unschedule(timer)) this.#made(timer);
198
+ }
199
+
200
+ /** Turns every timer that has come due into a call, in one transaction that calls no handler (7). */
201
+ tick() {
202
+ this.#outside();
203
+ const due = this.#q(SQL.dueTimers).all(this.#now());
204
+ if (due.length > 0) {
205
+ this.transaction(() => {
206
+ for (const row of due) {
207
+ this.#q(SQL.dropTimer).run(row.from_addr, row.id);
208
+ this.#start({ from: row.from_addr, id: row.id, to: row.to_addr, op: row.op, digest: row.digest, input: parse(row.input), parent: parse(row.parent),
209
+ lane: row.lane, by: byOf(row), awaited: row.awaited === 1, memo: parse(row.memo) });
210
+ }
211
+ });
212
+ }
213
+ // The host's timer has just gone off, and its clock may run a little early: while timers are left, it hears again.
214
+ if (!this.#started || !this.#wake) return;
215
+ const next = this.#q(SQL.firstDue).get().due;
216
+ if (next !== null) this.#tell(next);
217
+ }
218
+
219
+ /**
220
+ * A run reports how it is going (C6).
221
+ * @param {Ref} run
222
+ * @param {{ status?: 'working' | 'input-required', asking?: unknown, progress?: unknown }} change
223
+ * what is not given stays as it was; `asking` only means something while the run is `input-required`
224
+ */
225
+ update(run, { status, asking, progress }) {
226
+ this.#inside();
227
+ const row = this.#open(run);
228
+ const next = status ?? row.status;
229
+ if (!WORKING.has(next)) throw new Error(`a run cannot be updated to ${next}`);
230
+ const question = next === 'input-required' ? (asking === undefined ? row.asking : json(asking)) : null;
231
+ const asks = next === 'input-required' && (row.status !== 'input-required' || question !== row.asking);
232
+ const now = this.#q(SQL.updateRun).get(next, row.asked + (asks ? 1 : 0), question, progress === undefined ? row.progress : json(progress),
233
+ row.from_addr, row.id);
234
+ if (now.root === 1) return;
235
+ // The newest status replaces one not yet confirmed, in its place (R3).
236
+ const old = this.#q(SQL.dropStatusLetter).get(now.from_addr, now.id);
237
+ this.#owe(now.from_addr, null, 'status', { from: now.from_addr, id: now.id },
238
+ { v: 1, kind: 'status', from: now.to_addr, to: now.from_addr, call: { from: now.from_addr, id: now.id }, rev: now.rev,
239
+ status: now.status, asked: now.asked, asking: parse(now.asking), progress: parse(now.progress) }, old?.pos);
240
+ }
241
+
242
+ /**
243
+ * @param {Ref} run
244
+ * @param {Outcome} outcome
245
+ * @param {{ progress?: unknown }} [options] without it, the run's last progress stays
246
+ */
247
+ settle(run, outcome, { progress } = {}) {
248
+ this.#inside();
249
+ if (!ENDED.has(outcome.outcome)) throw new Error(`${outcome.outcome} is not an outcome`);
250
+ if (outcome.outcome === 'failed') outcome = { ...outcome, error: written(outcome.error) };
251
+ const row = this.#open(run);
252
+ if (this.#q(SQL.awaiting).get(row.from_addr, row.id) || this.#q(SQL.awaitedTimer).get(row.from_addr, row.id))
253
+ throw new Error(`run ${run.from}/${run.id} still awaits a call it made`);
254
+ this.#end(row.from_addr, row.id, outcome, progress);
255
+ }
256
+
257
+ /**
258
+ * Starts a run that nobody called (2). Its status and outcome go to no one.
259
+ * @param {{ address: string, op: string, id: string, parent?: unknown }} run
260
+ */
261
+ root({ address, op, id, parent = null }) {
262
+ this.#inside();
263
+ this.#q(SQL.addRun).run(address, id, address, op, null, json(parent), 0, 1);
264
+ return runView(this.#q(SQL.run).get(address, id));
265
+ }
266
+
267
+ /**
268
+ * A handler stops a run of its own, a root run say: as if its caller had canceled it, without calling `cancel` (C7).
269
+ * @param {Ref} run
270
+ */
271
+ stop(run) {
272
+ this.#inside();
273
+ const row = this.#q(SQL.run).get(run.from, run.id);
274
+ if (!row) throw new Error(`there is no run ${run.from}/${run.id}`);
275
+ this.#halt(row);
276
+ }
277
+
278
+ /**
279
+ * The runs a handler took and that have not ended, root runs included: what a host scans again on start (T4).
280
+ * @param {{ address: string, status?: string }} filter
281
+ */
282
+ runs({ address, status = null }) {
283
+ return this.#q(SQL.runs).all(address, status).map(runView);
284
+ }
285
+
286
+ /**
287
+ * The calls an address made that have not ended (T4).
288
+ * @param {{ address: string, state?: string }} filter
289
+ */
290
+ calls({ address, state = null }) {
291
+ return this.#q(SQL.calls).all(address, state).map(callView);
292
+ }
293
+
294
+ /**
295
+ * What an address has not finished (8): its calls and runs, its timers not yet due, and the letters it owes, in the
296
+ * order they go.
297
+ * @param {{ address: string }} filter
298
+ */
299
+ pending({ address }) {
300
+ return {
301
+ calls: this.calls({ address }),
302
+ runs: this.runs({ address }),
303
+ timers: this.#q(SQL.timers).all(address).map(timerView),
304
+ letters: this.#q(SQL.letters).all(address).map(row => ({ kind: row.kind, to: row.to_addr, call: { from: row.call_from, id: row.call_id } })),
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Waits until a call has left `sending`, or has ended (8); a timer is waited for until it is a call. Only memory
310
+ * holds the wait: after close() or a crash it never ends (R1).
311
+ * @param {Ref} ref
312
+ * @param {'accepted' | 'ended'} what
313
+ * @returns {Promise<object>} the call, as call() returns it
314
+ */
315
+ when(ref, what) {
316
+ this.#outside();
317
+ return new Promise((resolve, reject) => {
318
+ const waiter = { ref: { from: ref.from, id: ref.id }, what, resolve, reject };
319
+ if (!this.#met(waiter)) this.#waiters.push(waiter);
320
+ });
321
+ }
322
+
323
+ /**
324
+ * The caller no longer waits for this call (C9). The callee is not told; its outcome still comes to `result`.
325
+ * @param {Ref} call
326
+ */
327
+ detach(call) {
328
+ this.#inside();
329
+ if (this.#q(SQL.detachTimer).run(call.from, call.id).changes > 0) return;
330
+ // An ended call no longer changes (C3).
331
+ if (!ENDED.has(this.#made(call).state)) this.#q(SQL.detach).run(call.from, call.id);
332
+ }
333
+
334
+ /**
335
+ * The caller asks the callee to cancel the call's run (C7). How and when the run ends is the callee's; its outcome
336
+ * still comes. A call that has ended is not canceled.
337
+ * @param {Ref} call
338
+ * @param {unknown} [reason] goes to the callee's `cancel` as it is
339
+ */
340
+ cancel(call, reason) {
341
+ this.#inside();
342
+ if (this.#unschedule(call)) return;
343
+ const row = this.#made(call);
344
+ if (!ENDED.has(row.state)) this.#recall(row, reason);
345
+ }
346
+
347
+ /**
348
+ * The caller gives up on a call: it ends failed here, without a call to `result`, and the callee is asked to cancel
349
+ * (C12). The callee's own outcome, when it comes, goes to `late`.
350
+ * @param {Ref} call
351
+ */
352
+ abandon(call) {
353
+ this.#inside();
354
+ if (this.#unschedule(call)) return;
355
+ const row = this.#made(call);
356
+ if (ENDED.has(row.state)) return;
357
+ this.#recall(row);
358
+ this.#q(SQL.abandonCall).run(json({ code: 'abandoned', message: 'the caller abandoned this call', effects: 'unknown' }), row.from_addr, row.id);
359
+ }
360
+
361
+ /**
362
+ * Sends an open call again with `confirmed`, to learn how its run is now (C5). What it learns goes to the result
363
+ * handlers; a call that is not open is left alone.
364
+ * @param {Ref} call
365
+ */
366
+ inquire(call) {
367
+ this.#outside();
368
+ // A timer not yet due has no run to ask about.
369
+ if (this.#q(SQL.timer).get(call.from, call.id)) return;
370
+ const row = this.#made(call);
371
+ if (row.state === 'open') this.#ask(row);
372
+ }
373
+
374
+ /**
375
+ * Answers a run waiting for input (C8). Nothing of it is kept here: it goes once, and the answerer decides whether to
376
+ * send it again under the same id.
377
+ * @param {{ from: string, id: string, to: string, call: Ref, asked: number, answer?: unknown }} answer
378
+ * @returns {Promise<Receipt>} rejected when send() is
379
+ */
380
+ answer({ from, id, to, call, asked, answer }) {
381
+ this.#outside();
382
+ const envelope = { v: 1, kind: 'answer', from, to, id, call: { from: call.from, id: call.id }, asked, answer };
383
+ return new Promise(resolve => resolve(this.#local(to) ? this.receive(envelope) : this.#send(to, envelope)));
384
+ }
385
+
386
+ /**
387
+ * @param {object} envelope
388
+ * @returns {Receipt}
389
+ */
390
+ receive(envelope) {
391
+ this.#outside();
392
+ if (!this.#started) return { ok: false, code: 'not_ready' };
393
+ const known = envelope.v === 1 ? FIELDS[envelope.kind] : undefined;
394
+ if (!known || envelope.must?.some(name => !known.has(name))) return { ok: false, code: 'unsupported' };
395
+ return this.transaction(() => this.#take(envelope));
396
+ }
397
+
398
+ pump() {
399
+ this.#outside();
400
+ this.#held.clear();
401
+ // One transaction per owed result: one transaction calls the functions of one address only.
402
+ for (const { from_addr, id } of this.#q(SQL.owedCalls).all()) this.transaction(() => this.#notify(from_addr, id));
403
+ const rereads = [...this.#rereads.values()];
404
+ this.#rereads.clear();
405
+ for (const call of rereads) this.inquire(call);
406
+ this.#kick();
407
+ }
408
+
409
+ recover() {
410
+ this.#outside();
411
+ this.#started = true;
412
+ this.pump();
413
+ for (const row of this.#q(SQL.openCalls).all()) this.#ask(row);
414
+ this.tick();
415
+ }
416
+
417
+ /** @returns {Promise<void>} */
418
+ idle() {
419
+ if (!this.#running && this.#out === 0) return Promise.resolve();
420
+ return new Promise(resolve => this.#idle.push(resolve));
421
+ }
422
+
423
+ close() {
424
+ this.#closed = true;
425
+ }
426
+
427
+ #inside() {
428
+ if (!this.#tx.open) throw new Error('this must be called inside kernel.transaction()');
429
+ }
430
+
431
+ #outside() {
432
+ if (this.#closed) throw new Error('the kernel is closed');
433
+ if (this.#tx.open) throw new Error('this opens its own transaction and must not be called inside one');
434
+ }
435
+
436
+ /**
437
+ * What a request id already names, a call or a timer, if anything: ids are shared between the two (C1, 7). Throws
438
+ * when it was made with other content.
439
+ * @param {string} from
440
+ * @param {string} id
441
+ * @param {string} digest
442
+ */
443
+ #found(from, id, digest) {
444
+ const call = this.#q(SQL.call).get(from, id);
445
+ const found = call ?? this.#q(SQL.timer).get(from, id);
446
+ if (!found) return undefined;
447
+ if (found.digest !== digest) throw new Error(`call ${from}/${id} already exists with other content`);
448
+ return call ? callView(call) : timerView(found);
449
+ }
450
+
451
+ /**
452
+ * Whether a call or a timer that run `by` makes is awaited (C9). `by` must be a run of `from`.
453
+ * @param {string} from
454
+ * @param {Ref | undefined} by
455
+ * @param {boolean} awaited
456
+ */
457
+ #awaits(from, by, awaited) {
458
+ if (!by) return awaited;
459
+ const run = this.#q(SQL.run).get(by.from, by.id);
460
+ if (run?.to_addr !== from) throw new Error(`there is no run ${by.from}/${by.id} at ${from}`);
461
+ // A run asked to cancel no longer waits for what it starts (C9).
462
+ if (run.cancel_asked === 1) return false;
463
+ // An ended run awaiting a call would break C9.
464
+ if (awaited && ENDED.has(run.status)) throw new Error(`run ${by.from}/${by.id} has already ended and cannot await a call`);
465
+ return awaited;
466
+ }
467
+
468
+ // Writes a call and owes its letter (5.1): for call(), and for a timer that has come due.
469
+ #start({ from, id, to, op, digest, input, parent, lane, by, awaited, memo }) {
470
+ this.#q(SQL.addCall).run(from, id, to, op, digest, json(input), json(parent), lane, by?.from ?? null, by?.id ?? null, awaited ? 1 : 0, json(memo));
471
+ this.#owe(to, lane, 'call', { from, id }, { v: 1, kind: 'call', from, to, id, op, input, digest, awaited, parent });
472
+ }
473
+
474
+ /**
475
+ * Takes back a timer not yet due, without a trace (7); false when there is none.
476
+ * @param {Ref} timer
477
+ */
478
+ #unschedule(timer) {
479
+ return this.#q(SQL.dropTimer).run(timer.from, timer.id).changes > 0;
480
+ }
481
+
482
+ /**
483
+ * A call this instance made.
484
+ * @param {Ref} call
485
+ */
486
+ #made(call) {
487
+ const row = this.#q(SQL.call).get(call.from, call.id);
488
+ if (!row) throw new Error(`there is no call ${call.from}/${call.id}`);
489
+ return row;
490
+ }
491
+
492
+ /**
493
+ * A run that may still change (C3).
494
+ * @param {Ref} run
495
+ */
496
+ #open(run) {
497
+ const row = this.#q(SQL.run).get(run.from, run.id);
498
+ if (!row) throw new Error(`there is no run ${run.from}/${run.id}`);
499
+ if (ENDED.has(row.status)) throw new Error(`run ${run.from}/${run.id} has already ended`);
500
+ return row;
501
+ }
502
+
503
+ /** @param {string} sql */
504
+ #q(sql) {
505
+ let statement = this.#statements.get(sql);
506
+ if (!statement) this.#statements.set(sql, statement = this.#db.prepare(sql));
507
+ return statement;
508
+ }
509
+
510
+ /** @param {string} address */
511
+ #local(address) {
512
+ return this.#handlers.has(address) || this.#results.has(address);
513
+ }
514
+
515
+ /**
516
+ * @param {string} to
517
+ * @param {string | null} lane
518
+ * @param {'call' | 'status' | 'result' | 'cancel'} kind
519
+ * @param {Ref} call
520
+ * @param {object} envelope
521
+ * @param {number} [pos] the place of the letter this one replaces; a new letter goes last
522
+ */
523
+ #owe(to, lane, kind, call, envelope, pos = null) {
524
+ this.#q(SQL.owe).run(pos, envelope.from, to, lane, kind, call.from, call.id, JSON.stringify(envelope));
525
+ }
526
+
527
+ /** @param {object} envelope of a kind in FIELDS */
528
+ #take(envelope) {
529
+ switch (envelope.kind) {
530
+ case 'call':
531
+ return this.#accept(envelope);
532
+ case 'cancel':
533
+ return this.#revoke(envelope);
534
+ case 'status':
535
+ return this.#hear(envelope.call, envelope.from, envelope);
536
+ case 'result':
537
+ return this.#conclude(envelope.call, envelope.from, envelope);
538
+ case 'answer':
539
+ return this.#reply(envelope);
540
+ }
541
+ }
542
+
543
+ // The callee's side of a call letter (5.2, C1).
544
+ #accept({ from, id, to, op, input, digest, awaited, parent, confirmed }) {
545
+ const found = this.#q(SQL.run).get(from, id);
546
+ // A record that a cancel left alone has no digest to compare (C1, C7).
547
+ if (found) return found.digest === digest || found.op === null ? { ok: true, view: view(found) } : { ok: false, code: 'conflict' };
548
+ // A confirmed call was taken once; with no record of it here, it is not run now (C5).
549
+ if (confirmed) return { ok: false, code: 'unknown_call' };
550
+ this.#q(SQL.addRun).run(from, id, to, op, digest, json(parent), awaited ? 1 : 0, 0);
551
+ const handlers = this.#handlers.get(to)?.get(op);
552
+ if (!handlers) this.#end(from, id, failure('no_handler', `nothing handles ${op} at ${to}`));
553
+ else {
554
+ try {
555
+ this.transaction(() => {
556
+ const outcome = handlers.accept({ from, id, to, op, input, awaited, parent });
557
+ if (outcome) this.settle({ from, id }, outcome);
558
+ });
559
+ } catch (error) {
560
+ if (error instanceof Busy) {
561
+ this.#q(SQL.dropRun).run(from, id);
562
+ return { ok: false, code: 'busy' };
563
+ }
564
+ if (error instanceof Rejected) this.#end(from, id, failure(error.code, error.message));
565
+ else {
566
+ this.#tx.after(() => this.#onError(error));
567
+ this.#end(from, id, failure('handler_error', error?.message));
568
+ }
569
+ }
570
+ }
571
+ return { ok: true, view: view(this.#q(SQL.run).get(from, id)) };
572
+ }
573
+
574
+ // Writes a run's outcome and owes its caller a letter carrying it in place of any status not yet confirmed (C3, C4, C6).
575
+ #end(from, id, { outcome, result, error }, progress) {
576
+ const row = this.#q(SQL.endRun).get(outcome, json(result), json(error), json(progress), from, id);
577
+ if (row.root === 1) return;
578
+ this.#q(SQL.dropStatusLetter).get(from, id);
579
+ this.#owe(from, null, 'result', { from, id },
580
+ { v: 1, kind: 'result', from: row.to_addr, to: from, call: { from, id }, outcome, result, error, progress: parse(row.progress) });
581
+ }
582
+
583
+ // The callee's side of a cancel letter (C7).
584
+ #revoke({ from, to, call, reason }) {
585
+ if (from !== call.from) {
586
+ this.#tx.after(() => this.#onError(new Error(`${from} sent a cancel for call ${call.from}/${call.id}, which ${call.from} made`)));
587
+ return { ok: true };
588
+ }
589
+ const row = this.#q(SQL.run).get(call.from, call.id);
590
+ if (!row) {
591
+ // The cancel came before its call: this record, without op, parent or digest, answers the call when it comes.
592
+ this.#q(SQL.addRun).run(call.from, call.id, to, null, null, null, 0, 0);
593
+ this.#end(call.from, call.id, { outcome: 'canceled' });
594
+ } else if (this.#halt(row, reason)) {
595
+ // T6: a cancel function that throws loses its own changes only, and is not called again.
596
+ try {
597
+ this.transaction(() => this.#handlers.get(row.to_addr)?.get(row.op)?.cancel?.(runView(this.#q(SQL.run).get(call.from, call.id)), reason));
598
+ } catch (error) {
599
+ this.#tx.after(() => this.#onError(error));
600
+ }
601
+ }
602
+ return { ok: true, view: view(this.#q(SQL.run).get(call.from, call.id)) };
603
+ }
604
+
605
+ // The callee's side of an answer (C8): taken at most once per question, and per number.
606
+ #reply({ from, to, id, call, asked, answer }) {
607
+ const row = this.#q(SQL.run).get(call.from, call.id);
608
+ if (row?.to_addr !== to) return { ok: false, code: 'unknown_call' };
609
+ if (this.#q(SQL.answer).get(from, id)) return { ok: true, applied: true, view: view(row) };
610
+ if (row.status !== 'input-required' || asked !== row.asked) return { ok: true, applied: false, view: view(row) };
611
+ const handlers = this.#handlers.get(row.to_addr)?.get(row.op);
612
+ if (!handlers?.answer) return { ok: false, code: 'rejected', error: { code: 'no_handler', message: `nothing answers ${row.op} at ${row.to_addr}` } };
613
+ try {
614
+ // The run is working again before its handler sees the answer; whatever the handler throws, the question stands.
615
+ this.transaction(() => {
616
+ this.update(call, { status: 'working' });
617
+ this.#q(SQL.addAnswer).run(from, id, call.from, call.id, asked);
618
+ handlers.answer(runView(this.#q(SQL.run).get(call.from, call.id)), { from, id, asked, answer });
619
+ });
620
+ } catch (error) {
621
+ if (error instanceof Rejected) return { ok: false, code: 'rejected', error: { code: error.code, message: error.message } };
622
+ this.#tx.after(() => this.#onError(error));
623
+ return { ok: false, code: 'handler_error' };
624
+ }
625
+ return { ok: true, applied: true, view: view(this.#q(SQL.run).get(call.from, call.id)) };
626
+ }
627
+
628
+ /**
629
+ * Marks a run asked to cancel, drops its timers and cancels the calls it awaits (C7, 7). False when the run has ended
630
+ * or was asked before.
631
+ * @param {object} row
632
+ * @param {unknown} [reason]
633
+ */
634
+ #halt(row, reason) {
635
+ if (ENDED.has(row.status) || row.cancel_asked === 1) return false;
636
+ this.#q(SQL.haltRun).run(row.from_addr, row.id);
637
+ this.#q(SQL.dropRunTimers).run(row.from_addr, row.id);
638
+ for (const call of this.#q(SQL.awaiting).all(row.from_addr, row.id)) this.#recall(call, reason);
639
+ return true;
640
+ }
641
+
642
+ /**
643
+ * Owes the callee a cancel (C7): in the place of a call letter not yet confirmed, holding its lane, or else outside
644
+ * any lane. A cancel already owed is the one that goes.
645
+ * @param {object} row the call, not ended
646
+ * @param {unknown} [reason]
647
+ */
648
+ #recall(row, reason) {
649
+ if (this.#q(SQL.cancelLetter).get(row.from_addr, row.id)) return;
650
+ const call = { from: row.from_addr, id: row.id };
651
+ const letter = this.#q(SQL.dropCallLetter).get(row.from_addr, row.id);
652
+ this.#owe(row.to_addr, letter ? row.lane : null, 'cancel', call, { v: 1, kind: 'cancel', from: row.from_addr, to: row.to_addr, call, reason },
653
+ letter?.pos);
654
+ }
655
+
656
+ /**
657
+ * The caller hears a status of the run its call started (C6).
658
+ * @param {Ref} call
659
+ * @param {string} sender
660
+ * @param {{ rev: number, status: string, asked: number, asking?: unknown, progress?: unknown }} status
661
+ */
662
+ #hear(call, sender, { rev, status, asked, asking, progress }) {
663
+ const row = this.#known(call, sender, 'a status');
664
+ if (!row || ENDED.has(row.state) || rev <= row.rev) return { ok: true };
665
+ if (row.state === 'sending') this.#q(SQL.dropCallLetter).run(call.from, call.id);
666
+ const known = WORKING.has(status) ? status : 'working';
667
+ const now = this.#q(SQL.hearCall).get(known, rev, asked, known === 'input-required' ? json(asking) : null, json(progress), call.from, call.id);
668
+ // T6: a status handler that throws loses its own changes only, and is not called again.
669
+ try {
670
+ this.transaction(() => this.#results.get(call.from)?.status?.(callView(now)));
671
+ } catch (error) {
672
+ this.#tx.after(() => this.#onError(error));
673
+ }
674
+ return { ok: true };
675
+ }
676
+
677
+ /**
678
+ * The caller's row of a call that a status or an outcome is about, if it came from the call's callee (C4).
679
+ * @param {Ref} call
680
+ * @param {string} sender
681
+ * @param {string} what
682
+ */
683
+ #known(call, sender, what) {
684
+ const row = this.#q(SQL.call).get(call.from, call.id);
685
+ if (!row) {
686
+ this.#tx.after(() => this.#onError(new Error(`${what} arrived for unknown call ${call.from}/${call.id}`)));
687
+ return undefined;
688
+ }
689
+ if (sender !== row.to_addr) {
690
+ this.#tx.after(() => this.#onError(new Error(`${sender} sent ${what} for call ${call.from}/${call.id}, which went to ${row.to_addr}`)));
691
+ return undefined;
692
+ }
693
+ return row;
694
+ }
695
+
696
+ /**
697
+ * The caller learns an outcome, from a result letter or from a receipt that carries one (C4).
698
+ * @param {Ref} call
699
+ * @param {string} sender the address the outcome came from
700
+ * @param {{ outcome: string, result?: unknown, error?: unknown, progress?: unknown }} ending
701
+ * @param {boolean} [local] the caller's kernel wrote this outcome itself
702
+ */
703
+ #conclude(call, sender, ending, local = false) {
704
+ const row = this.#known(call, sender, 'an outcome');
705
+ // After an abandon, the callee's own outcome is still taken once, as the late one (C12).
706
+ const late = row?.abandoned === 1 && row.late === null && !local;
707
+ if (!row || (ENDED.has(row.state) && !late)) return { ok: true };
708
+ let { outcome, result, error, progress } = ending;
709
+ if (!ENDED.has(outcome)) {
710
+ error = { code: 'unknown_outcome', effects: 'unknown', original: { outcome, result, error } };
711
+ outcome = 'failed';
712
+ result = undefined;
713
+ local = true;
714
+ } else if (outcome === 'failed' && !local) error = heard(error);
715
+ if (late) this.#q(SQL.lateCall).run(JSON.stringify({ outcome, result, error, progress }), call.from, call.id);
716
+ else {
717
+ // Hearing the outcome means the callee took the call: its letter goes, and its lane moves on. A cancel not yet
718
+ // confirmed goes too; an abandoned call keeps its cancel until confirmed.
719
+ if (row.state === 'sending') this.#q(SQL.dropCallLetter).run(call.from, call.id);
720
+ this.#q(SQL.dropCancelLetter).run(call.from, call.id);
721
+ this.#q(SQL.endCall).run(outcome, json(result), json(error), json(progress), local ? 1 : 0, call.from, call.id);
722
+ }
723
+ this.#notify(call.from, call.id);
724
+ return { ok: true };
725
+ }
726
+
727
+ // Hands an ended call to its result handler, or an abandoned one's late outcome to `late`. Until that commits, the call
728
+ // stays owed and pump() tries again (C4, C12, T6).
729
+ #notify(from, id) {
730
+ try {
731
+ this.transaction(() => {
732
+ const row = this.#q(SQL.call).get(from, id);
733
+ this.#results.get(from)?.[row.late === null ? 'result' : 'late']?.(callView(row));
734
+ this.#q(SQL.paidCall).run(from, id);
735
+ });
736
+ } catch (error) {
737
+ this.#tx.after(() => this.#onError(error));
738
+ }
739
+ }
740
+
741
+ /**
742
+ * What a receipt means for the letter it answers, in the sender's transaction.
743
+ * @param {object} row
744
+ * @param {Receipt} receipt
745
+ * @param {boolean} [direct] the letter was delivered locally, in this same transaction
746
+ */
747
+ #heard(row, receipt, direct = false) {
748
+ this.#q(SQL.dropLetter).run(row.seq);
749
+ const call = { from: row.call_from, id: row.call_id };
750
+ if (!receipt.ok) {
751
+ if (row.kind === 'call') this.#conclude(call, row.to_addr, failure(receipt.code, `${row.to_addr} answered ${receipt.code}`), true);
752
+ // A cancel in the place of a call letter not yet confirmed: that letter may have arrived all the same (C7).
753
+ else if (row.kind === 'cancel' && this.#q(SQL.call).get(call.from, call.id).state === 'sending')
754
+ this.#conclude(call, row.to_addr, failure(receipt.code, `${row.to_addr} answered ${receipt.code} to a cancel`, 'unknown'), true);
755
+ else this.#tx.after(() => this.#onError(new Error(`${row.to_addr} refused a ${row.kind} letter: ${receipt.code}`)));
756
+ return;
757
+ }
758
+ if (row.kind !== 'call' && row.kind !== 'cancel') return;
759
+ // A local callee has just run its own functions in this transaction; its outcome and its status come in their own
760
+ // letters (4, C6).
761
+ if (!direct && receipt.view.outcome) this.#conclude(call, row.to_addr, receipt.view);
762
+ else {
763
+ this.#q(SQL.openCall).run(call.from, call.id);
764
+ if (!direct) this.#hear(call, row.to_addr, receipt.view);
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Sends an open call again with `confirmed` (C5). It is not kept in the outbox: without a receipt it waits in memory
770
+ * for the next pump(). One to a local address is taken at once; there the callee runs no function of its own.
771
+ * @param {object} row
772
+ */
773
+ #ask(row) {
774
+ const call = { from: row.from_addr, id: row.id };
775
+ const envelope = { v: 1, kind: 'call', from: row.from_addr, to: row.to_addr, id: row.id, op: row.op, input: parse(row.input),
776
+ digest: row.digest, awaited: row.awaited === 1, parent: parse(row.parent), confirmed: true };
777
+ if (this.#local(row.to_addr)) {
778
+ this.transaction(() => this.#reread(call, row.to_addr, this.#take(envelope)));
779
+ return;
780
+ }
781
+ this.#hand(row.to_addr, envelope, receipt => {
782
+ if (receipt === undefined || HOLD.has(receipt.code)) this.#rereads.set(JSON.stringify([call.from, call.id]), call);
783
+ else {
784
+ try {
785
+ this.transaction(() => this.#reread(call, row.to_addr, receipt));
786
+ } catch (error) {
787
+ this.#rereads.set(JSON.stringify([call.from, call.id]), call);
788
+ this.#onError(error);
789
+ }
790
+ }
791
+ });
792
+ }
793
+
794
+ /**
795
+ * What the receipt of an inquiry means for the call (C5): an outcome, a newer status, or that the callee has no record.
796
+ * @param {Ref} call
797
+ * @param {string} to
798
+ * @param {Receipt} receipt
799
+ */
800
+ #reread(call, to, receipt) {
801
+ if (!receipt.ok) {
802
+ if (receipt.code === 'unknown_call') {
803
+ this.#conclude(call, to, { outcome: 'failed', error: { code: 'lost', message: `${to} has no record of this call`, effects: 'unknown' } }, true);
804
+ } else this.#tx.after(() => this.#onError(new Error(`${to} answered ${receipt.code} to an inquiry about ${call.from}/${call.id}`)));
805
+ } else if (receipt.view.outcome) this.#conclude(call, to, receipt.view);
806
+ else this.#hear(call, to, receipt.view);
807
+ }
808
+
809
+ // After every outermost commit: the send loop goes on, the waiters of when() look again, and the host hears of a new
810
+ // earliest timer (T3, 8, 7).
811
+ #committed() {
812
+ this.#kick();
813
+ this.#waiters = this.#waiters.filter(waiter => !this.#met(waiter));
814
+ if (!this.#started || !this.#wake) return;
815
+ const due = this.#q(SQL.firstDue).get().due;
816
+ if (due !== this.#told) this.#tell(due);
817
+ }
818
+
819
+ /**
820
+ * Ends a wait of when() if it can: true when it is done with.
821
+ * @param {{ ref: Ref, what: string, resolve: (call: object) => void, reject: (error: Error) => void }} waiter
822
+ */
823
+ #met({ ref, what, resolve, reject }) {
824
+ const row = this.#q(SQL.call).get(ref.from, ref.id);
825
+ if (row) {
826
+ if (what === 'accepted' ? row.state === 'sending' : !ENDED.has(row.state)) return false;
827
+ resolve(callView(row));
828
+ return true;
829
+ }
830
+ // A timer is still to become the call; one taken back never will.
831
+ if (this.#q(SQL.timer).get(ref.from, ref.id)) return false;
832
+ reject(new Error(`there is no call ${ref.from}/${ref.id}`));
833
+ return true;
834
+ }
835
+
836
+ /** @param {number | null} due */
837
+ #tell(due) {
838
+ this.#told = due;
839
+ try {
840
+ this.#wake(due);
841
+ } catch (error) {
842
+ this.#onError(error);
843
+ }
844
+ }
845
+
846
+ #kick() {
847
+ if (!this.#started || this.#closed) return;
848
+ if (this.#running) {
849
+ this.#again = true;
850
+ return;
851
+ }
852
+ this.#running = true;
853
+ queueMicrotask(() => this.#pass().catch(error => this.#onError(error)));
854
+ }
855
+
856
+ // One walk over the outbox in order; walks again when something committed meanwhile.
857
+ async #pass() {
858
+ let after = 0;
859
+ let handed = 0;
860
+ try {
861
+ for (;;) {
862
+ if (this.#closed) return;
863
+ const row = this.#q(SQL.next).get(after);
864
+ if (!row) {
865
+ if (!this.#again) break;
866
+ this.#again = false;
867
+ after = 0;
868
+ continue;
869
+ }
870
+ after = row.pos;
871
+ if (this.#flying.has(row.seq) || this.#held.has(row.seq)) continue;
872
+ if (this.#local(row.to_addr)) this.#deliver(row);
873
+ else this.#post(row);
874
+ if (++handed % BATCH === 0) await new Promise(resolve => this.#schedule(resolve));
875
+ }
876
+ } finally {
877
+ // Whatever ended this walk, the next commit or pump() starts another.
878
+ this.#running = false;
879
+ this.#settled();
880
+ }
881
+ }
882
+
883
+ // A letter to a local address: take it, drop it from the outbox and apply the receipt, in one transaction.
884
+ #deliver(row) {
885
+ try {
886
+ const receipt = this.transaction(() => {
887
+ const receipt = this.#take(JSON.parse(row.envelope));
888
+ if (!HOLD.has(receipt.code)) this.#heard(row, receipt, true);
889
+ return receipt;
890
+ });
891
+ if (HOLD.has(receipt.code)) this.#held.add(row.seq);
892
+ } catch (error) {
893
+ this.#held.add(row.seq);
894
+ this.#onError(error);
895
+ }
896
+ }
897
+
898
+ #post(row) {
899
+ this.#flying.add(row.seq);
900
+ this.#hand(row.to_addr, JSON.parse(row.envelope), receipt => {
901
+ this.#flying.delete(row.seq);
902
+ this.#answered(row, receipt);
903
+ });
904
+ }
905
+
906
+ /**
907
+ * @param {string} to
908
+ * @param {object} envelope
909
+ * @param {(receipt: Receipt | undefined) => void} answered gets undefined when send() rejected
910
+ */
911
+ #hand(to, envelope, answered) {
912
+ this.#out += 1;
913
+ // A send() that throws instead of returning a promise counts as rejected.
914
+ new Promise(resolve => resolve(this.#send(to, envelope))).then(receipt => receipt, () => undefined).then(receipt => {
915
+ // A closed kernel no longer writes: a receipt that comes back now is void (R4).
916
+ if (this.#closed) return;
917
+ this.#out -= 1;
918
+ answered(receipt);
919
+ this.#settled();
920
+ });
921
+ }
922
+
923
+ /**
924
+ * @param {object} row
925
+ * @param {Receipt | undefined} receipt undefined when send() rejected
926
+ */
927
+ #answered(row, receipt) {
928
+ if (receipt === undefined || HOLD.has(receipt.code)) {
929
+ this.#held.add(row.seq);
930
+ return;
931
+ }
932
+ try {
933
+ this.transaction(() => this.#heard(row, receipt));
934
+ } catch (error) {
935
+ this.#held.add(row.seq);
936
+ this.#onError(error);
937
+ }
938
+ }
939
+
940
+ #settled() {
941
+ if (this.#running || this.#out > 0) return;
942
+ for (const resolve of this.#idle.splice(0)) resolve();
943
+ }
944
+ }
945
+
946
+ function failure(code, message, effects = 'none') {
947
+ return { outcome: 'failed', error: { code, message, effects } };
948
+ }
949
+
950
+ /**
951
+ * A failure as its callee writes it (C11): `unknown` unless it says otherwise. An error that is no object, or effects
952
+ * of no known kind, is the handler's mistake.
953
+ * @param {unknown} [error]
954
+ */
955
+ function written(error) {
956
+ if (error == null) return { effects: 'unknown' };
957
+ if (!isObject(error)) throw new Error(`the error of a failure must be an object, not ${JSON.stringify(error)}`);
958
+ const effects = error.effects ?? 'unknown';
959
+ if (!EFFECTS.has(effects)) throw new Error(`effects must be none, unknown or some, not ${JSON.stringify(effects)}`);
960
+ return { ...error, effects };
961
+ }
962
+
963
+ /**
964
+ * A failure as its caller hears it (6, C11): what this kernel does not know reads as `unknown`, and the rest is kept.
965
+ * @param {unknown} error
966
+ */
967
+ function heard(error) {
968
+ if (error == null) return { effects: 'unknown' };
969
+ if (!isObject(error)) return { effects: 'unknown', original: error };
970
+ return EFFECTS.has(error.effects) ? error : { ...error, effects: 'unknown' };
971
+ }
972
+
973
+ const isObject = value => typeof value === 'object' && value !== null && !Array.isArray(value);
974
+
975
+ /** @param {unknown} value */
976
+ function json(value) {
977
+ return value === undefined ? null : JSON.stringify(value);
978
+ }
979
+
980
+ /** @param {string | null} text */
981
+ function parse(text) {
982
+ return text === null ? undefined : JSON.parse(text);
983
+ }
984
+
985
+ /** @param {object} object */
986
+ function defined(object) {
987
+ return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
988
+ }
989
+
990
+ // The outcome fields of an ended row.
991
+ function outcomeOf(name, row) {
992
+ return { outcome: name, result: parse(row.result), error: parse(row.error), progress: parse(row.progress) };
993
+ }
994
+
995
+ // A run as its caller sees it in a receipt (6).
996
+ function view(row) {
997
+ return defined({ status: row.status, rev: row.rev, asked: row.asked, asking: parse(row.asking), progress: parse(row.progress),
998
+ ...(ENDED.has(row.status) && outcomeOf(row.status, row)) });
999
+ }
1000
+
1001
+ // A run as its handler's host sees it in runs().
1002
+ function runView(row) {
1003
+ return defined({ from: row.from_addr, id: row.id, to: row.to_addr, op: row.op, parent: parse(row.parent), awaited: row.awaited === 1,
1004
+ cancelAsked: row.cancel_asked === 1, status: row.status, rev: row.rev, asked: row.asked, asking: parse(row.asking), progress: parse(row.progress),
1005
+ ...(row.root === 1 && { root: true }) });
1006
+ }
1007
+
1008
+ // The run that made a call or a timer (C9).
1009
+ function byOf(row) {
1010
+ return row.by_from === null ? undefined : { from: row.by_from, id: row.by_id };
1011
+ }
1012
+
1013
+ // A timer as its host sees it, until it comes due and is a call (7).
1014
+ function timerView(row) {
1015
+ return defined({ from: row.from_addr, id: row.id, to: row.to_addr, op: row.op, state: 'scheduled', dueAt: row.due, memo: parse(row.memo),
1016
+ by: byOf(row), awaited: row.awaited === 1 });
1017
+ }
1018
+
1019
+ // A call as the caller's host sees it: with the last status it heard while open, with its outcome once ended.
1020
+ function callView(row) {
1021
+ return defined({ from: row.from_addr, id: row.id, to: row.to_addr, op: row.op, state: row.state, memo: parse(row.memo), by: byOf(row),
1022
+ awaited: row.awaited === 1,
1023
+ ...(row.state === 'open' && { status: row.status, rev: row.rev, asked: row.asked, asking: parse(row.asking), progress: parse(row.progress) }),
1024
+ ...(ENDED.has(row.state) && outcomeOf(row.state, row)), ...(row.local === 1 && { local: true }),
1025
+ ...(row.late !== null && { late: parse(row.late) }) });
1026
+ }
1027
+
1028
+ // The digest of what makes two calls under one id the same call (C1). Key order is not content.
1029
+ function digestOf(value) {
1030
+ return createHash('sha256').update(canonical(value)).digest('hex');
1031
+ }
1032
+
1033
+ function canonical(value) {
1034
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
1035
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
1036
+ const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
1037
+ return `{${keys.map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
1038
+ }