@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
package/dist/types.d.ts CHANGED
@@ -451,6 +451,16 @@ interface LocalStore extends LocalCacheEngine {
451
451
  * without it boots the circuit empty, as before.
452
452
  */
453
453
  scanVersions?(tables: string[]): Promise<Record<string, [string, number][]>>;
454
+ /**
455
+ * Reset the cached `_00_rv` of every table whose column set (`fingerprints`,
456
+ * see `columnFingerprints`) changed since the store last recorded it, except
457
+ * the rows in `keep`, and record the new sets. Returns the tables reset.
458
+ * For engines that keep bodies across schema changes: a body landed under an
459
+ * older schema lacks the columns it did not know, at the server's version,
460
+ * and would never be fetched again. OPTIONAL: an engine that rebuilds its
461
+ * store on a schema change (SurrealDB) does not need it.
462
+ */
463
+ resetChangedTables?(fingerprints: Record<string, string>, keep: Set<string>): Promise<string[]>;
454
464
  /**
455
465
  * Circuit snapshot storage, keyed. Lives in the same durable store as the
456
466
  * rows (OPFS SQLite), so it is per-bucket by construction, atomic, and
@@ -1570,11 +1580,11 @@ interface Sp00kyConfig<S extends SchemaStructure> {
1570
1580
  streamDebounceTime?: number;
1571
1581
  /**
1572
1582
  * Debounce time in milliseconds for syncing collaborative (CRDT) field
1573
- * changes to the remote database. Local writes happen immediately on
1574
- * every keystroke (so reload/offline works), but the remote UPSERT is
1575
- * coalesced over this window. Lower = snappier remote propagation +
1576
- * more network traffic; higher = less traffic + more lag for other
1577
- * collaborators. Defaults to 500ms.
1583
+ * edits to the server. Local state is kept on every change (so reload and
1584
+ * offline work), but a field's own new ops go out as one edit-log row per
1585
+ * pause of this length (at most 2 s apart while typing continues).
1586
+ * Lower = snappier propagation and more rows; higher = fewer rows and more
1587
+ * lag for other collaborators. Defaults to 300ms.
1578
1588
  */
1579
1589
  crdtDebounceMs?: number;
1580
1590
  /**
@@ -1632,7 +1642,8 @@ interface Sp00kyConfig<S extends SchemaStructure> {
1632
1642
  * page gets live `useQuery` updates over world-readable tables. Requires the
1633
1643
  * server to be deployed with `anonymousLiveQueries: true` in `sp00ky.yml`
1634
1644
  * (this flag must match it). Defaults to `false`: anonymous clients can read
1635
- * one-shot but never sync live.
1645
+ * one-shot but never sync live, and a `preload` made while signed out returns
1646
+ * at once rather than wait for membership that never comes.
1636
1647
  */
1637
1648
  enableAnonymousLiveQueries?: boolean;
1638
1649
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.322",
3
+ "version": "0.0.1-canary.324",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,13 +75,13 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.322",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.322",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.324",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.324",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
83
83
  "fast-json-patch": "^3.1.1",
84
- "loro-crdt": "^1.5.6",
84
+ "loro-crdt": "1.11.0",
85
85
  "pino": "^10.1.0",
86
86
  "surrealdb": "2.1.0"
87
87
  },
@@ -21,6 +21,7 @@ describe('preload', () => {
21
21
  });
22
22
  it('never resolved: waits for settled (membership + bodies + first render), or fails when the registration failed', async () => {
23
23
  const hash = await sha256Hex(queryHashInput(input, null));
24
+ const signedIn = buildState([], R.setIdentity({ userId: 'user:alice' }));
24
25
  const settleIt = (ctx: any) => {
25
26
  ctx.state = R.compose(
26
27
  R.commitMembership(hash, [['thing:1', 1]], true),
@@ -29,10 +30,12 @@ describe('preload', () => {
29
30
  )(ctx.state);
30
31
  };
31
32
  const out = await runPure(preload(env, input), {
33
+ state: signedIn,
32
34
  handlers: { 'local.getById': () => null, 'ssp.register': sspOk, 'state.wait': (_e, ctx) => settleIt(ctx) },
33
35
  });
34
36
  expect(out.result).toEqual({ hash, waited: true });
35
37
  const failed = runPure(preload(env, input), {
38
+ state: signedIn,
36
39
  handlers: {
37
40
  'local.getById': () => null,
38
41
  'ssp.register': sspOk,
@@ -41,6 +44,7 @@ describe('preload', () => {
41
44
  });
42
45
  await expect(failed).rejects.toBeInstanceOf(PreloadFailedError);
43
46
  const gone = runPure(preload(env, input), {
47
+ state: signedIn,
44
48
  handlers: { 'local.getById': () => null, 'ssp.register': sspOk, 'state.wait': (_e, ctx) => void (ctx.state = R.removeQuery(hash)(ctx.state)) },
45
49
  });
46
50
  await expect(gone).rejects.toThrow(/could not settle/);
@@ -48,4 +52,29 @@ describe('preload', () => {
48
52
  const cached = await runPure(preload(env, input), { state: already });
49
53
  expect(cached.result.waited).toBe(false);
50
54
  });
55
+ it('signed out without anonymous live queries: registers but never waits, since no membership can reach this session', async () => {
56
+ const out = await runPure(preload(env, input), {
57
+ handlers: { 'local.getById': () => null, 'ssp.register': sspOk },
58
+ });
59
+ expect(out.result.waited).toBe(false);
60
+ expect(out.log.filter((e) => e.kind === 'state.wait')).toHaveLength(0);
61
+ expect(out.log.some((e) => e.kind === 'dispatch' && (e as any).event?.type === 'EnsureRegistered')).toBe(true);
62
+
63
+ // With anonymous live queries a signed-out session is sent its membership
64
+ // on `_00_list_ref_anon`, so it waits like a signed-in one.
65
+ const hash = await sha256Hex(queryHashInput(input, null));
66
+ const anon = await runPure(preload(defaultEnv(env.schema, { anonLive: true }), input), {
67
+ handlers: {
68
+ 'local.getById': () => null,
69
+ 'ssp.register': sspOk,
70
+ 'state.wait': (_e, ctx) =>
71
+ void (ctx.state = R.compose(
72
+ R.commitMembership(hash, [['thing:1', 1]], true),
73
+ R.setVersions([['thing:1', 1]]),
74
+ R.setRecords(hash, [{ id: 'thing:1' }], true, 1)
75
+ )(ctx.state)),
76
+ },
77
+ });
78
+ expect(anon.result).toEqual({ hash, waited: true });
79
+ });
51
80
  });
@@ -3,6 +3,7 @@ import type { Saga } from '../kernel/saga';
3
3
  import { fx } from '../kernel/effects';
4
4
  import { settleFailed, settled } from '../state/selectors';
5
5
  import type { SagaEnv } from '../query/env';
6
+ import { receivesMembership } from '../query/env';
6
7
  import type { RegisterInput } from '../query/register.saga';
7
8
  import { registerLocal } from '../query/register.saga';
8
9
 
@@ -19,11 +20,19 @@ export class PreloadFailedError extends Error {
19
20
  * Never resolved: blocks until the server's membership and every body are
20
21
  * local and the first materialization ran. The entry is evicted like any
21
22
  * other query a ttl after it was last watched (or registered).
23
+ *
24
+ * A session that cannot be sent membership (signed out, without anonymous
25
+ * live queries) does not wait either: a view with rows would never settle,
26
+ * and an app that gates its first paint on a preload showed its loading
27
+ * screen forever, signed-out landing page included.
22
28
  */
23
29
  export function* preload(env: SagaEnv, input: RegisterInput): Saga<{ hash: QueryHash; waited: boolean }> {
24
30
  const hash = yield* registerLocal(env, input);
25
- const phase = (yield fx.state.read((s) => s.queries.get(hash)?.lifecycle.phase)) as string | undefined;
26
- if (phase !== 'cold') return { hash, waited: false };
31
+ const [phase, reachable] = (yield fx.state.read((s) => [s.queries.get(hash)?.lifecycle.phase, receivesMembership(env, s)])) as [
32
+ string | undefined,
33
+ boolean,
34
+ ];
35
+ if (phase !== 'cold' || !reachable) return { hash, waited: false };
27
36
  yield fx.state.wait((s) => settled(s, hash) || settleFailed(s, hash));
28
37
  const failed = (yield fx.state.read((s) => settleFailed(s, hash))) as boolean;
29
38
  if (failed) throw new PreloadFailedError(hash);
@@ -21,6 +21,7 @@ import { TabsCoordinator, type CoordinatorHooks } from '../services/tabs/coordin
21
21
  import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
22
22
  import { computeTabsFingerprint, hash53 } from '../services/tabs/protocol';
23
23
  import type { SqliteCacheEngine } from '../services/database/sqlite-cache-engine';
24
+ import { columnFingerprints } from '../services/database/sqlite-column-reset';
24
25
  import type { BlobCache } from '../services/blobs/index';
25
26
  import { MemoryBlobStore, createBlobCache, resolveBlobBudget } from '../services/blobs/index';
26
27
  import type { Adapters } from '../kernel/interpreter';
@@ -175,7 +176,7 @@ export function createServices<S extends SchemaStructure>(config: Sp00kyConfig<S
175
176
  streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === 'sqlite', config.circuitCheckpointMs);
176
177
  streamProcessor.configureProjection(config.circuitProjection ?? true);
177
178
  const migrator = new LocalMigrator(local, logger);
178
- const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 500);
179
+ const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 300);
179
180
  const auth = new AuthService(config.schema, remote, persistence, logger);
180
181
  const tabId = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
181
182
  return {
@@ -308,6 +309,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
308
309
  tables: [...config.schema.tables.map((t) => t.name), '_00_user_feature', '_00_app_release'],
309
310
  schemaHash: String(hash53(config.schemaSurql)),
310
311
  pendingIds: new Set(pendingIds),
312
+ columnFingerprints: columnFingerprints(config.schema.tables),
311
313
  onVersions: (_table, entries) => host.dispatch({ type: 'VersionsPrimed', entries }),
312
314
  }),
313
315
  'ssp.reset': () => s.streamProcessor.reset(),
@@ -18,6 +18,7 @@ const flush = async (n = 6) => {
18
18
  function makeClient(over: Parameters<typeof fakeAdapters>[0] = {}) {
19
19
  const services = fakeServiceBundle<any>();
20
20
  const a = fakeAdapters({
21
+ ...over,
21
22
  services: {
22
23
  'hint.read': () => 'u1',
23
24
  'local.usesSurqlSchema': () => false,
@@ -26,7 +27,6 @@ function makeClient(over: Parameters<typeof fakeAdapters>[0] = {}) {
26
27
  'remote.connect': () => new Promise(() => {}),
27
28
  ...over.services,
28
29
  },
29
- ...over,
30
30
  });
31
31
  const runtime = new Runtime({ env: defaultEnv(schema, { materializeDebounceMs: 1 }), adapters: a.adapters, logger: services.logger, tabId: 'tab-test' });
32
32
  const client = new Sp00kyClient<any>(config, { services, runtime });
@@ -155,7 +155,10 @@ describe('Sp00kyClient facade', () => {
155
155
 
156
156
  it('preload: cold waits for membership + bodies; cached returns at once; abort rejects', async () => {
157
157
  const edges = [{ out: new RecordId('thing', '1'), version: 1 }];
158
+ // Signed in: a signed-out session without anonymous live queries is never
159
+ // sent membership, so its preload does not wait (see `preload`).
158
160
  const { client, a } = makeClient({
161
+ services: { 'auth.restoreSession': async () => 'user:u1', 'auth.sessionAuthId': () => 'user:u1' },
159
162
  remote: {
160
163
  queryResponses: async (sql: string) => {
161
164
  if (sql.startsWith('fn::query::register')) return [ok(null), ok(edges), ok({ rowCount: 1, state: 'ready' }), ok([])];
@@ -142,6 +142,29 @@ export class Sp00kyClient<S extends SchemaStructure> {
142
142
  this.runtime.subscribe(hash, cb, options),
143
143
  };
144
144
  const remoteRegisterPort = { enqueueDownEvent: () => void this.runtime.dispatch({ type: 'EnsureRegistered' }) };
145
+ // CRDT edit logs ride ordinary queries and the ordinary outbox.
146
+ s.crdt.attach({
147
+ watch: async (table, where, onRows) => {
148
+ const { hash } = await (this.query as any)(table, {}).where(where).build().run();
149
+ const stopRows = this.runtime.subscribe(hash, onRows, { immediate: true });
150
+ let known = false;
151
+ let idle = false;
152
+ let done!: () => void;
153
+ const settled = new Promise<void>((resolve) => (done = resolve));
154
+ const check = () => known && idle && done();
155
+ const stopAuthority = this.runtime.subscribeAuthority(hash, (k) => ((known = k), check()), { immediate: true });
156
+ const stopStatus = this.runtime.subscribeStatus(hash, (st) => ((idle = st === 'idle'), check()), { immediate: true });
157
+ return {
158
+ stop: () => {
159
+ stopRows();
160
+ stopAuthority();
161
+ stopStatus();
162
+ },
163
+ settled,
164
+ };
165
+ },
166
+ create: (id, data) => this.create(id, data),
167
+ });
145
168
  this.featureFlags = new FeatureFlagModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
146
169
  this.appReleases = new AppReleaseModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
147
170
  this.webPush = new WebPushModule({
@@ -567,12 +590,22 @@ export class Sp00kyClient<S extends SchemaStructure> {
567
590
  return this.appReleases.release(app, options);
568
591
  }
569
592
 
570
- async openCrdtField(table: string, recordId: string, field: string, fallbackText?: string): Promise<CrdtField> {
571
- return this.services.crdt.open(table, recordId, field, fallbackText);
593
+ /** Open a `@crdt` field for editing. `draft` (from {@link createCrdtDraft})
594
+ * hands over edits made before the record existed. */
595
+ async openCrdtField(table: string, recordId: string, field: string, draft?: CrdtField): Promise<CrdtField> {
596
+ return this.services.crdt.open(table, recordId, field, draft);
597
+ }
598
+
599
+ /** A detached `@crdt` document for a record not created yet; see
600
+ * {@link openCrdtField}. */
601
+ async createCrdtDraft(table: string, field: string): Promise<CrdtField> {
602
+ return this.services.crdt.createDraft(table, field);
572
603
  }
573
604
 
574
- closeCrdtField(table: string, recordId: string, field: string): void {
575
- this.services.crdt.close(table, recordId, field);
605
+ /** Close a `@crdt` field. The last close resolves once its unsent edits
606
+ * are queued in the outbox. */
607
+ closeCrdtField(table: string, recordId: string, field: string): Promise<void> {
608
+ return this.services.crdt.close(table, recordId, field);
576
609
  }
577
610
 
578
611
  bucket<B extends BucketNames<S>>(name: B): BucketHandle {
@@ -0,0 +1,75 @@
1
+ import type * as Loro from 'loro-crdt';
2
+
3
+ /**
4
+ * Which edit-log rows may enter a document.
5
+ *
6
+ * Every client and the SSP fold (`packages/ssp-node/src/crdt/acceptance.rs`)
7
+ * apply this exact rule before importing a row, so they import the same rows
8
+ * and converge on the same document: a row one side accepted and another
9
+ * refused would leave them different forever (a CRDT never forgets an op).
10
+ *
11
+ * - the blob decodes as a Loro **update** (a snapshot could smuggle in
12
+ * arbitrary history from any peer);
13
+ * - it is no larger than the column allows;
14
+ * - every op in it was made by the row's `peer`, which the database already
15
+ * checked belongs to the row's author (`_00_crdt_peer`).
16
+ *
17
+ * Loro's decoder can trap on crafted input with a valid checksum, so a raw
18
+ * header check rejects non-update blobs before decoding, and decoding runs
19
+ * under `try`.
20
+ */
21
+
22
+ export type Rejection =
23
+ | 'empty'
24
+ | 'too_large'
25
+ | 'bad_peer'
26
+ | 'header'
27
+ | 'undecodable'
28
+ | 'not_an_update'
29
+ | 'no_ops'
30
+ | 'foreign_peer';
31
+
32
+ export interface Accepted {
33
+ peer: `${number}`;
34
+ /** Exclusive end counter of the row's ops for `peer`. */
35
+ end: number;
36
+ }
37
+
38
+ export type Verdict = { ok: true; accepted: Accepted } | { ok: false; reason: Rejection };
39
+
40
+ /** The largest blob a row may carry (the generated column's cap). */
41
+ export const MAX_DELTA_BYTES = 256 * 1024;
42
+
43
+ const PEER = /^\d{1,20}$/;
44
+ /** `"loro"`: every Loro blob starts with it. */
45
+ const MAGIC = [0x6c, 0x6f, 0x72, 0x6f];
46
+ /** Header bytes 20..22 (big-endian) carry the encoding mode; 4 is an update. */
47
+ const MODE_UPDATES = 4;
48
+
49
+ export function check(
50
+ loro: Pick<typeof Loro, 'decodeImportBlobMeta'>,
51
+ blob: Uint8Array | undefined,
52
+ peer: unknown,
53
+ maxBytes = MAX_DELTA_BYTES
54
+ ): Verdict {
55
+ if (!blob || blob.length === 0) return { ok: false, reason: 'empty' };
56
+ if (blob.length > maxBytes) return { ok: false, reason: 'too_large' };
57
+ if (typeof peer !== 'string' || !PEER.test(peer)) return { ok: false, reason: 'bad_peer' };
58
+ if (blob.length < 22 || MAGIC.some((b, i) => blob[i] !== b)) return { ok: false, reason: 'header' };
59
+ if (((blob[20] << 8) | blob[21]) !== MODE_UPDATES) return { ok: false, reason: 'not_an_update' };
60
+ let meta: ReturnType<typeof Loro.decodeImportBlobMeta>;
61
+ try {
62
+ meta = loro.decodeImportBlobMeta(blob, true);
63
+ } catch {
64
+ return { ok: false, reason: 'undecodable' };
65
+ }
66
+ if (meta.mode !== 'update') return { ok: false, reason: 'not_an_update' };
67
+ const start = meta.partialStartVersionVector.toJSON();
68
+ const authors: Array<[string, number]> = [];
69
+ for (const [p, end] of meta.partialEndVersionVector.toJSON()) {
70
+ if (end > (start.get(p) ?? 0)) authors.push([String(p), end]);
71
+ }
72
+ if (authors.length === 0) return { ok: false, reason: 'no_ops' };
73
+ if (authors.length > 1 || BigInt(authors[0][0]) !== BigInt(peer)) return { ok: false, reason: 'foreign_peer' };
74
+ return { ok: true, accepted: { peer: peer as `${number}`, end: authors[0][1] } };
75
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Byte columns reach the client in several shapes depending on the engine and
3
+ * the path: `Uint8Array` (SQLite, a fresh local write), `ArrayBuffer` (the
4
+ * surrealdb SDK's CBOR decode), a typed-array view, or a `number[]` (bytes
5
+ * nested in a FLEXIBLE object on the SurrealDB-WASM engine). Normalize all of
6
+ * them; anything else is not bytes.
7
+ */
8
+ export function asBytes(value: unknown): Uint8Array | undefined {
9
+ if (value instanceof Uint8Array) return value;
10
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
11
+ if (ArrayBuffer.isView(value)) {
12
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
13
+ }
14
+ if (Array.isArray(value) && value.length > 0 && value.every((n) => typeof n === 'number')) {
15
+ return Uint8Array.from(value as number[]);
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ export function decodeBase64(b64: string): Uint8Array {
21
+ if (typeof atob === 'function') {
22
+ const binary = atob(b64);
23
+ const bytes = new Uint8Array(binary.length);
24
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
25
+ return bytes;
26
+ }
27
+ return new Uint8Array(Buffer.from(b64, 'base64'));
28
+ }
29
+
30
+ export function encodeBase64(bytes: Uint8Array): string {
31
+ if (typeof btoa === 'function') {
32
+ let binary = '';
33
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
34
+ return btoa(binary);
35
+ }
36
+ return Buffer.from(bytes).toString('base64');
37
+ }
38
+
39
+ /**
40
+ * A random Loro peer id below 2^53 (so it is exact as a JS number), as the
41
+ * decimal string Loro uses for `PeerID`. Each tab picks a new one per session
42
+ * and claims it in `_00_crdt_peer` before any of its edits are sent.
43
+ */
44
+ export function randomPeer(): `${number}` {
45
+ const words = new Uint32Array(2);
46
+ crypto.getRandomValues(words);
47
+ const high = words[0] & 0x1fffff; // 21 bits
48
+ const id = high * 0x100000000 + words[1];
49
+ return String(id === 0 ? 1 : id) as `${number}`;
50
+ }