@spooky-sync/core 0.0.1-canary.322 → 0.0.1-canary.324

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.
Files changed (33) hide show
  1. package/AGENTS.md +4 -3
  2. package/dist/index.d.ts +156 -161
  3. package/dist/index.js +910 -482
  4. package/dist/types.d.ts +17 -6
  5. package/package.json +4 -4
  6. package/src/boot/preload.saga.test.ts +29 -0
  7. package/src/boot/preload.saga.ts +11 -2
  8. package/src/client/services.ts +3 -1
  9. package/src/client/sp00ky-client.test.ts +4 -1
  10. package/src/client/sp00ky-client.ts +37 -4
  11. package/src/modules/crdt/acceptance.ts +75 -0
  12. package/src/modules/crdt/bytes.ts +50 -0
  13. package/src/modules/crdt/crdt-field.ts +48 -255
  14. package/src/modules/crdt/crdt.test.ts +365 -0
  15. package/src/modules/crdt/hash.ts +103 -0
  16. package/src/modules/crdt/index.ts +610 -363
  17. package/src/modules/crdt/mirror.ts +37 -0
  18. package/src/mutation/push.saga.test.ts +16 -0
  19. package/src/mutation/push.saga.ts +9 -1
  20. package/src/query/anon-query-isolation.integration.test.ts +31 -0
  21. package/src/query/env.ts +9 -0
  22. package/src/services/database/cache-engine.ts +10 -0
  23. package/src/services/database/local-migrator.ts +5 -3
  24. package/src/services/database/sqlite-cache-engine.ts +28 -0
  25. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  26. package/src/services/database/sqlite-column-reset.ts +91 -0
  27. package/src/services/stream-processor/index.ts +24 -0
  28. package/src/services/stream-processor/stream-processor.prime.test.ts +36 -0
  29. package/src/testing/fake-services.ts +1 -1
  30. package/src/types.ts +7 -6
  31. package/src/utils/surql.ts +3 -4
  32. package/src/modules/crdt/crdt-hydration.test.ts +0 -210
  33. package/src/modules/crdt/crdt-reconnect.test.ts +0 -195
@@ -0,0 +1,365 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import * as loro from 'loro-crdt';
3
+ import { LoroDoc } from 'loro-crdt';
4
+ import { check } from './acceptance';
5
+ import { asBytes } from './bytes';
6
+ import { canonicalState, stateHash } from './hash';
7
+ import { CrdtManager, type CrdtPorts } from './index';
8
+ import { renderText } from './mirror';
9
+
10
+ const silent = { child: () => silent, debug: () => {}, info: () => {}, warn: () => {}, error: () => {} } as any;
11
+
12
+ const schema = {
13
+ tables: [
14
+ { name: 'doc', columns: { body: { type: 'string', crdt: 'text' }, title: { type: 'string' } } },
15
+ { name: 'doc__crdt_log', columns: { doc: {}, field: {}, peer: {}, data: {} } },
16
+ ],
17
+ } as any;
18
+
19
+ type Row = { id: string; doc: string; field: string; peer: string; data: Uint8Array };
20
+
21
+ /** One fake backend shared by every client: the edit log, the peer claims
22
+ * and the published documents, plus what each client's local store holds. */
23
+ class World {
24
+ log: Row[] = [];
25
+ peers = new Map<string, string>();
26
+ published = new Map<string, { rev: number; snapshot: Uint8Array; upto?: unknown; state_hash?: string }>();
27
+ online = true;
28
+ private watchers = new Set<{ table: string; where: Record<string, unknown>; cb: (rows: any[]) => void }>();
29
+
30
+ client(user: string, store = new Map<string, any>(), logger: any = silent) {
31
+ const local = {
32
+ getById: async (_t: string, id: any) => store.get(String(id)) ?? null,
33
+ upsert: async (_t: string, id: any, data: any) => void store.set(String(id), structuredClone(data)),
34
+ } as any;
35
+ const remote = {
36
+ subscribeConnection: () => () => {},
37
+ query: async (sql: string, vars: any) => {
38
+ if (!this.online) throw new Error('not connected');
39
+ if (sql.startsWith('CREATE')) {
40
+ const owner = this.peers.get(vars.peer);
41
+ if (owner) throw new Error(`Database record \`_00_crdt_peer:${vars.peer}\` already exists`);
42
+ this.peers.set(vars.peer, user);
43
+ return [[{ id: `_00_crdt_peer:${vars.peer}` }]];
44
+ }
45
+ if (sql.startsWith('SELECT VALUE author')) return [this.peers.get(vars.peer) === user ? user : null];
46
+ if (sql.includes('_00_crdt_doc')) return [this.published.get(`${vars.doc}|${vars.field}`) ?? null];
47
+ return [[]];
48
+ },
49
+ } as any;
50
+ const manager = new CrdtManager(schema, local, remote, logger, 300);
51
+ const ports: CrdtPorts = {
52
+ watch: async (table, where, cb) => {
53
+ const w = { table, where, cb };
54
+ this.watchers.add(w);
55
+ cb(this.rowsFor(table, where));
56
+ return { stop: () => void this.watchers.delete(w), settled: Promise.resolve() };
57
+ },
58
+ create: async (id, data: any) => {
59
+ const owner = this.peers.get(data.peer);
60
+ if (owner !== user) throw new Error('peer not owned');
61
+ this.log.push({ id, doc: data.doc, field: data.field, peer: data.peer, data: data.data });
62
+ queueMicrotask(() => this.notify());
63
+ return data;
64
+ },
65
+ };
66
+ manager.attach(ports);
67
+ return { manager, store };
68
+ }
69
+
70
+ rowsFor(table: string, where: Record<string, unknown>) {
71
+ if (!table.endsWith('__crdt_log')) return [];
72
+ return this.log.filter((r) => r.doc === where.doc && r.field === where.field);
73
+ }
74
+
75
+ notify() {
76
+ for (const w of this.watchers) w.cb(this.rowsFor(w.table, w.where));
77
+ }
78
+
79
+ /** Fold like the SSP: publish `server` as the document and drop the log. */
80
+ async fold(doc: string, field: string, server: LoroDoc, opts: { rev?: number; hash?: string } = {}) {
81
+ this.published.set(`${doc}|${field}`, {
82
+ rev: opts.rev ?? 1,
83
+ snapshot: server.export({ mode: 'snapshot' }),
84
+ upto: { vv: Object.fromEntries(server.oplogVersion().toJSON()), frontiers: server.oplogFrontiers() },
85
+ state_hash: opts.hash ?? (await stateHash(loro, server)),
86
+ });
87
+ this.log = this.log.filter((r) => !(r.doc === doc && r.field === field));
88
+ this.notify();
89
+ }
90
+ }
91
+
92
+ const text = (field: { getDoc(): LoroDoc }) => field.getDoc().getText('body').toString();
93
+
94
+ /** Let real async work (the WebCrypto digest) finish while timers are fake. */
95
+ async function realWait(ms = 50) {
96
+ vi.useRealTimers();
97
+ await new Promise((resolve) => setTimeout(resolve, ms));
98
+ vi.useFakeTimers();
99
+ }
100
+
101
+ describe('CRDT fields', () => {
102
+ beforeEach(() => vi.useFakeTimers());
103
+ afterEach(() => vi.useRealTimers());
104
+
105
+ it('send only their own ops as log rows, and concurrent edits converge', async () => {
106
+ const world = new World();
107
+ const a = world.client('user:a');
108
+ const b = world.client('user:b');
109
+ const fa = await a.manager.open('doc', 'doc:1', 'body');
110
+ const fb = await b.manager.open('doc', 'doc:1', 'body');
111
+
112
+ fa.getDoc().getText('body').insert(0, 'Hello');
113
+ fa.getDoc().commit();
114
+ fb.getDoc().getText('body').insert(0, 'World');
115
+ fb.getDoc().commit();
116
+ await vi.advanceTimersByTimeAsync(400);
117
+
118
+ expect(world.log).toHaveLength(2);
119
+ for (const row of world.log) {
120
+ expect(check(loro, row.data, row.peer).ok).toBe(true);
121
+ }
122
+ expect(text(fa)).toBe(text(fb));
123
+ expect(text(fa)).toHaveLength(10);
124
+
125
+ // A's next edit carries only A's new op, not B's imported ones.
126
+ fa.getDoc().getText('body').insert(0, '>');
127
+ fa.getDoc().commit();
128
+ await vi.advanceTimersByTimeAsync(400);
129
+ const last = world.log.at(-1)!;
130
+ const verdict = check(loro, last.data, last.peer);
131
+ expect(verdict.ok && verdict.accepted.peer).toBe(fa.getDoc().peerIdStr);
132
+ expect(text(fb)).toBe(text(fa));
133
+ });
134
+
135
+ it('a burst of typing goes out after the pause, or after the max wait at the latest', async () => {
136
+ const world = new World();
137
+ const a = world.client('user:a');
138
+ const f = await a.manager.open('doc', 'doc:1', 'body');
139
+ for (let i = 0; i < 10; i++) {
140
+ f.getDoc().getText('body').insert(i, 'x');
141
+ f.getDoc().commit();
142
+ await vi.advanceTimersByTimeAsync(250);
143
+ }
144
+ expect(world.log.length).toBeGreaterThanOrEqual(1);
145
+ expect(world.log.length).toBeLessThan(10);
146
+ });
147
+
148
+ it('send nothing until the peer is claimed, then everything at once', async () => {
149
+ const world = new World();
150
+ world.online = false;
151
+ const a = world.client('user:a');
152
+ const b = world.client('user:b');
153
+ const fa = await a.manager.open('doc', 'doc:1', 'body');
154
+ const fb = await b.manager.open('doc', 'doc:1', 'body');
155
+ fa.getDoc().getText('body').insert(0, 'offline edit');
156
+ fa.getDoc().commit();
157
+ await vi.advanceTimersByTimeAsync(1000);
158
+ expect(world.log).toHaveLength(0);
159
+
160
+ world.online = true;
161
+ await vi.advanceTimersByTimeAsync(6000);
162
+ expect(world.log).toHaveLength(1);
163
+ expect(text(fb)).toBe('offline edit');
164
+ });
165
+
166
+ it("send a closed session's unsent edits under its own peer on the next open", async () => {
167
+ const world = new World();
168
+ world.online = false;
169
+ const store = new Map<string, any>();
170
+ const first = world.client('user:a', store);
171
+ const f1 = await first.manager.open('doc', 'doc:1', 'body');
172
+ const oldPeer = f1.getDoc().peerIdStr;
173
+ f1.getDoc().getText('body').insert(0, 'kept');
174
+ f1.getDoc().commit();
175
+ first.manager.closeAll({ flush: true });
176
+ await vi.advanceTimersByTimeAsync(1000);
177
+ expect(world.log).toHaveLength(0);
178
+
179
+ world.online = true;
180
+ const second = world.client('user:a', store); // a reload: same device store, new peer
181
+ const f2 = await second.manager.open('doc', 'doc:1', 'body');
182
+ expect(text(f2)).toBe('kept');
183
+ expect(f2.getDoc().peerIdStr).not.toBe(oldPeer);
184
+ await vi.advanceTimersByTimeAsync(400);
185
+ expect(world.log.map((r) => r.peer)).toEqual([oldPeer]);
186
+ expect(world.peers.get(oldPeer)).toBe('user:a');
187
+ });
188
+
189
+ it('never import a row that fails the acceptance rule', async () => {
190
+ const world = new World();
191
+ const b = world.client('user:b');
192
+ const forged = new LoroDoc();
193
+ forged.setPeerId(42);
194
+ forged.getText('body').insert(0, 'forged');
195
+ forged.commit();
196
+ world.log.push({ id: 'doc__crdt_log:x', doc: 'doc:1', field: 'body', peer: '7', data: forged.export({ mode: 'update' }) });
197
+ world.log.push({ id: 'doc__crdt_log:y', doc: 'doc:1', field: 'body', peer: '42', data: forged.export({ mode: 'snapshot' }) });
198
+ const fb = await b.manager.open('doc', 'doc:1', 'body');
199
+ expect(text(fb)).toBe('');
200
+ });
201
+
202
+ it('a first open on a device starts from the published document', async () => {
203
+ const world = new World();
204
+ const server = new LoroDoc();
205
+ server.getText('body').insert(0, 'published');
206
+ server.commit();
207
+ world.published.set('doc:1|body', { rev: 3, snapshot: server.export({ mode: 'snapshot' }) });
208
+ const a = world.client('user:a');
209
+ const f = await a.manager.open('doc', 'doc:1', 'body');
210
+ expect(f.hasContent()).toBe(true);
211
+ expect(text(f)).toBe('published');
212
+ });
213
+
214
+ it('share one document between opens and stop with the last close', async () => {
215
+ const world = new World();
216
+ const a = world.client('user:a');
217
+ const [x, y] = await Promise.all([a.manager.open('doc', 'doc:1', 'body'), a.manager.open('doc', 'doc:1', 'body')]);
218
+ expect(x).toBe(y);
219
+ a.manager.close('doc', 'doc:1', 'body');
220
+ x.getDoc().getText('body').insert(0, 'still open');
221
+ x.getDoc().commit();
222
+ await vi.advanceTimersByTimeAsync(400);
223
+ expect(world.log).toHaveLength(1);
224
+ a.manager.close('doc', 'doc:1', 'body');
225
+ await vi.advanceTimersByTimeAsync(10);
226
+ const reopened = await a.manager.open('doc', 'doc:1', 'body');
227
+ expect(reopened).not.toBe(x);
228
+ });
229
+
230
+ it('adopt a draft made before the record existed and send it under the draft peer', async () => {
231
+ const world = new World();
232
+ const a = world.client('user:a');
233
+ const b = world.client('user:b');
234
+ const draft = await a.manager.createDraft('doc', 'body');
235
+ draft.getDoc().getText('body').insert(0, 'first words');
236
+ draft.getDoc().commit();
237
+ await vi.advanceTimersByTimeAsync(1000);
238
+ expect(world.log).toHaveLength(0);
239
+
240
+ const f = await a.manager.open('doc', 'doc:1', 'body', draft);
241
+ expect(text(f)).toBe('first words');
242
+ await vi.advanceTimersByTimeAsync(10);
243
+ expect(world.log.map((r) => r.peer)).toEqual([draft.getDoc().peerIdStr]);
244
+ const fb = await b.manager.open('doc', 'doc:1', 'body');
245
+ expect(text(fb)).toBe('first words');
246
+ });
247
+
248
+ it('a device that missed folded rows catches up from the published document', async () => {
249
+ const world = new World();
250
+ const a = world.client('user:a');
251
+ const b = world.client('user:b');
252
+ const fa = await a.manager.open('doc', 'doc:1', 'body');
253
+ fa.getDoc().getText('body').insert(0, 'Hello');
254
+ fa.getDoc().commit();
255
+ await vi.advanceTimersByTimeAsync(400);
256
+ const fb = await b.manager.open('doc', 'doc:1', 'body');
257
+ expect(text(fb)).toBe('Hello');
258
+
259
+ // The server folded in an edit whose row B never received.
260
+ const server = LoroDoc.fromSnapshot(fa.getDoc().export({ mode: 'snapshot' }));
261
+ const other = new LoroDoc();
262
+ other.setPeerId(99);
263
+ other.import(server.export({ mode: 'update' }));
264
+ other.getText('body').insert(5, ' world');
265
+ other.commit();
266
+ server.import(other.export({ mode: 'update' }));
267
+ await world.fold('doc:1', 'body', server);
268
+ await vi.advanceTimersByTimeAsync(1100);
269
+ expect(text(fb)).toBe('Hello world');
270
+ });
271
+
272
+ it('report a document that differs from the published one at the same version', async () => {
273
+ const world = new World();
274
+ const warn = vi.fn();
275
+ const logger = { ...silent, warn, child: () => logger };
276
+ const a = world.client('user:a', new Map(), logger);
277
+ const fa = await a.manager.open('doc', 'doc:1', 'body');
278
+ fa.getDoc().getText('body').insert(0, 'same ops');
279
+ fa.getDoc().commit();
280
+ await vi.advanceTimersByTimeAsync(400);
281
+ const server = LoroDoc.fromSnapshot(fa.getDoc().export({ mode: 'snapshot' }));
282
+
283
+ await world.fold('doc:1', 'body', server, { hash: await stateHash(loro, server) });
284
+ await vi.advanceTimersByTimeAsync(1100);
285
+ await realWait();
286
+ expect(warn).not.toHaveBeenCalled();
287
+
288
+ fa.getDoc().getText('body').insert(0, '>');
289
+ fa.getDoc().commit();
290
+ await vi.advanceTimersByTimeAsync(400);
291
+ server.import(fa.getDoc().export({ mode: 'update' }));
292
+ await world.fold('doc:1', 'body', server, { rev: 2, hash: 'f'.repeat(64) });
293
+ await vi.advanceTimersByTimeAsync(1100);
294
+ await realWait();
295
+ expect(warn).toHaveBeenCalledTimes(1);
296
+ });
297
+
298
+ it('refuse to open a field that is not @crdt', async () => {
299
+ const world = new World();
300
+ const a = world.client('user:a');
301
+ await expect(a.manager.open('doc', 'doc:1', 'title')).rejects.toThrow(/not a CRDT field/);
302
+ });
303
+ });
304
+
305
+ describe('acceptance rule', () => {
306
+ it('refuses blobs that make the decoder trap, without throwing', async () => {
307
+ const fixtures = await import('../../../../ssp-node/tests/fixtures/crdt_hostile_blobs.json');
308
+ const hostile = (fixtures.default ?? fixtures) as Array<{ peer: string; blob: string }>;
309
+ for (const h of hostile) {
310
+ const bytes = Uint8Array.from(Buffer.from(h.blob, 'base64'));
311
+ expect(check(loro, bytes, h.peer).ok).toBe(false);
312
+ }
313
+ });
314
+
315
+ it('normalizes every byte shape a store can hand back', () => {
316
+ const b = new Uint8Array([1, 2, 3]);
317
+ expect(asBytes(b)).toBe(b);
318
+ expect(Array.from(asBytes(b.buffer)!)).toEqual([1, 2, 3]);
319
+ expect(Array.from(asBytes([1, 2, 3])!)).toEqual([1, 2, 3]);
320
+ expect(asBytes('nope')).toBeUndefined();
321
+ });
322
+ });
323
+
324
+ describe('state hash', () => {
325
+ it('matches the server fold on the shared vectors', async () => {
326
+ const fixtures = await import('../../../../ssp-node/tests/fixtures/crdt_state_hash.json');
327
+ const vectors = (fixtures.default ?? fixtures) as Array<{ name: string; snapshot: string; canonical: string; hash: string }>;
328
+ expect(vectors.length).toBeGreaterThan(0);
329
+ for (const v of vectors) {
330
+ const doc = LoroDoc.fromSnapshot(Uint8Array.from(Buffer.from(v.snapshot, 'base64')));
331
+ expect(canonicalState(loro, doc), v.name).toBe(v.canonical);
332
+ expect(await stateHash(loro, doc), v.name).toBe(v.hash);
333
+ }
334
+ });
335
+ });
336
+
337
+ describe('text mirror', () => {
338
+ it('renders text and prosemirror documents like the server fold', () => {
339
+ const doc = new LoroDoc();
340
+ doc.getText('summary').insert(0, 'héllo 🌍');
341
+ expect(renderText(doc, 'summary', 'text')).toBe('héllo 🌍');
342
+
343
+ const pm = new LoroDoc();
344
+ const root = pm.getMap('doc');
345
+ root.set('nodeName', 'doc');
346
+ const blocks = root.setContainer('children', new loro.LoroList());
347
+ const para = (parent: InstanceType<typeof loro.LoroList>, name: string) => {
348
+ const m = parent.insertContainer(parent.length, new loro.LoroMap());
349
+ m.set('nodeName', name);
350
+ return m.setContainer('children', new loro.LoroList());
351
+ };
352
+ const run = (parent: InstanceType<typeof loro.LoroList>, s: string) =>
353
+ parent.insertContainer(parent.length, new loro.LoroText()).insert(0, s);
354
+ run(para(blocks, 'heading'), 'Title');
355
+ const p = para(blocks, 'paragraph');
356
+ run(p, 'one ');
357
+ run(p, 'bold');
358
+ p.insertContainer(p.length, new loro.LoroMap()).set('nodeName', 'hard_break');
359
+ run(p, 'two');
360
+ para(blocks, 'paragraph');
361
+ pm.commit();
362
+ // Same fixture and expectation as `mirror.rs`.
363
+ expect(renderText(pm, 'body', 'prosemirror')).toBe('Title\none bold\ntwo\n');
364
+ });
365
+ });
@@ -0,0 +1,103 @@
1
+ import type { Container, LoroDoc, LoroTreeNode } from 'loro-crdt';
2
+ import { encodeBase64 } from './bytes';
3
+
4
+ type LoroModule = typeof import('loro-crdt');
5
+
6
+ /**
7
+ * Canonical state hash of a document: the same function as the server fold's
8
+ * (`packages/ssp-node/src/crdt/hash.rs`), checked against the shared vectors in
9
+ * `packages/ssp-node/tests/fixtures/crdt_state_hash.json`.
10
+ *
11
+ * stateHash(doc) = hex(SHA-256(utf8(JCS(roots(doc)))))
12
+ *
13
+ * JCS is RFC 8785 (keys in UTF-16 order, `JSON.stringify` strings,
14
+ * `Number#toString` numbers). Loro values map as: integers to numbers,
15
+ * NaN/Infinity to `{"$f64": ...}`, bytes to `{"$bytes": "<base64>"}`, a text
16
+ * to its string (marks are not hashed). Root keys holding `""`, `{}`, `[]` or
17
+ * `0` are left out: touching a root creates it, but a document built from
18
+ * update blobs alone does not have it.
19
+ */
20
+ export async function stateHash(loro: LoroModule, doc: LoroDoc): Promise<string> {
21
+ const bytes = new TextEncoder().encode(canonicalState(loro, doc));
22
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
23
+ return Array.from(digest, (b) => b.toString(16).padStart(2, '0')).join('');
24
+ }
25
+
26
+ /** The canonical JSON {@link stateHash} is taken over. */
27
+ export function canonicalState(loro: LoroModule, doc: LoroDoc): string {
28
+ // Built by walking the containers into null-prototype objects: `toJSON()`
29
+ // assigns keys, which silently drops a map key named `__proto__`.
30
+ const roots: Record<string, unknown> = Object.create(null);
31
+ for (const [name, id] of Object.entries(doc.getShallowValue())) {
32
+ const value = walk(loro, doc.getContainerById(id));
33
+ if (!isEmptyRoot(value)) roots[name] = value;
34
+ }
35
+ return canonicalize(roots);
36
+ }
37
+
38
+ function walk(loro: LoroModule, v: unknown): unknown {
39
+ if (!loro.isContainer(v)) return v;
40
+ const c = v as Container;
41
+ switch (c.kind()) {
42
+ case 'Map': {
43
+ const map = c as import('loro-crdt').LoroMap;
44
+ const out: Record<string, unknown> = Object.create(null);
45
+ for (const k of map.keys()) out[k] = walk(loro, map.get(k));
46
+ return out;
47
+ }
48
+ case 'List':
49
+ case 'MovableList':
50
+ return (c as import('loro-crdt').LoroList).toArray().map((item) => walk(loro, item));
51
+ case 'Text':
52
+ return (c as import('loro-crdt').LoroText).toString();
53
+ case 'Counter':
54
+ return (c as unknown as { value: number }).value;
55
+ case 'Tree':
56
+ return (c as import('loro-crdt').LoroTree).roots().map((n) => walkTreeNode(loro, n));
57
+ default:
58
+ throw new Error(`unsupported Loro container ${c.kind()}`);
59
+ }
60
+ }
61
+
62
+ function walkTreeNode(loro: LoroModule, n: LoroTreeNode): unknown {
63
+ const out: Record<string, unknown> = Object.create(null);
64
+ out.parent = n.parent()?.id ?? null;
65
+ out.index = n.index();
66
+ out.meta = walk(loro, n.data);
67
+ out.id = n.id;
68
+ out.fractional_index = n.fractionalIndex();
69
+ out.children = (n.children() ?? []).map((child) => walkTreeNode(loro, child));
70
+ return out;
71
+ }
72
+
73
+ function isEmptyRoot(v: unknown): boolean {
74
+ if (v === '' || v === 0) return true;
75
+ if (Array.isArray(v)) return v.length === 0;
76
+ if (v && typeof v === 'object' && !(v instanceof Uint8Array)) return Object.keys(v).length === 0;
77
+ return false;
78
+ }
79
+
80
+ function canonicalize(v: unknown): string {
81
+ if (v === null || v === undefined) return 'null';
82
+ switch (typeof v) {
83
+ case 'boolean':
84
+ return v ? 'true' : 'false';
85
+ case 'number':
86
+ if (!Number.isFinite(v)) return canonicalize({ $f64: String(v) });
87
+ return Object.is(v, -0) ? '0' : String(v);
88
+ case 'string':
89
+ return JSON.stringify(v);
90
+ case 'bigint':
91
+ return canonicalize(Number(v));
92
+ case 'object': {
93
+ if (v instanceof Uint8Array) return canonicalize({ $bytes: encodeBase64(v) });
94
+ if (Array.isArray(v)) return '[' + v.map(canonicalize).join(',') + ']';
95
+ const obj = v as Record<string, unknown>;
96
+ // Default sort compares UTF-16 code units, as RFC 8785 asks.
97
+ const keys = Object.keys(obj).sort();
98
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalize(obj[k])).join(',') + '}';
99
+ }
100
+ default:
101
+ throw new Error(`unsupported value type ${typeof v}`);
102
+ }
103
+ }