@spooky-sync/core 0.0.1-canary.328 → 0.0.1-canary.329

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/dist/index.js CHANGED
@@ -2188,8 +2188,8 @@ function trimHistory(history, cap) {
2188
2188
 
2189
2189
  //#endregion
2190
2190
  //#region src/modules/devtools/index.ts
2191
- const CORE_VERSION = "0.0.1-canary.328";
2192
- const WASM_VERSION = "0.0.1-canary.328";
2191
+ const CORE_VERSION = "0.0.1-canary.329";
2192
+ const WASM_VERSION = "0.0.1-canary.329";
2193
2193
  const SURREAL_VERSION = "3.1.0";
2194
2194
  var DevToolsService = class DevToolsService {
2195
2195
  version = CORE_VERSION;
@@ -9895,7 +9895,8 @@ function pureWriteOpResult(op) {
9895
9895
  * shared {@link resolveRelations} — identical to the SurrealDB backend.
9896
9896
  *
9897
9897
  * Value normalization (JSON round-trip):
9898
- * - `Uint8Array`/bytes → `{ "__u8": <base64> }` (CRDT snapshots survive).
9898
+ * - Bytes in any shape (`Uint8Array`, the SDK's `ArrayBuffer`) →
9899
+ * `{ "__u8": <base64> }`, read back as `Uint8Array` (CRDT data survives).
9899
9900
  * - Record links / ids → their `table:id` string form (so `json_extract`
9900
9901
  * comparisons and `IN` matching are consistent). NOTE: link fields therefore
9901
9902
  * read back as strings, not `RecordId` instances — the one shape difference
@@ -15392,7 +15393,7 @@ function createTabsCoordinator(config, s, host) {
15392
15393
  return new TabsCoordinator({
15393
15394
  tabId: s.tabId,
15394
15395
  fingerprint: computeTabsFingerprint({
15395
- coreVersion: "0.0.1-canary.328",
15396
+ coreVersion: "0.0.1-canary.329",
15396
15397
  schemaHash: hash53(config.schemaSurql),
15397
15398
  endpoint: config.database.endpoint ?? "",
15398
15399
  namespace: config.database.namespace,
@@ -194,9 +194,10 @@ function scalar(value) {
194
194
  }
195
195
  function serializeRow(row) {
196
196
  return JSON.stringify(row, function(k, v) {
197
- if (v instanceof Uint8Array) return { __u8: toBase64(v) };
198
197
  const raw = this[k];
199
198
  if (raw && typeof raw === "object") {
199
+ const bytes = bytesOf(raw);
200
+ if (bytes) return { __u8: toBase64(bytes) };
200
201
  const key = recordIdKey(raw);
201
202
  if (key !== null) return key;
202
203
  }
@@ -204,6 +205,18 @@ function serializeRow(row) {
204
205
  });
205
206
  }
206
207
  /**
208
+ * The bytes of a binary value, whatever shape it arrived in. Rows from the
209
+ * server carry a `bytes` column as an `ArrayBuffer` (the surrealdb SDK's CBOR
210
+ * decode), not a `Uint8Array`, and `JSON.stringify` turns an `ArrayBuffer`
211
+ * into `{}`: every synced CRDT log row and presence state was stored empty.
212
+ */
213
+ function bytesOf(value) {
214
+ if (value instanceof Uint8Array) return value;
215
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
216
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
217
+ return null;
218
+ }
219
+ /**
207
220
  * Serialize a MERGE row ONCE for both halves of the merge upsert: the row a
208
221
  * fresh INSERT stores and the RFC 7396 delta `json_patch` applies on conflict.
209
222
  *
@@ -81,7 +81,10 @@ function clearLeader(ns, opts) {
81
81
  brokerInstanceId,
82
82
  leadershipId: leader.leadershipId
83
83
  });
84
- if (opts.removeTab && tab) removeTab(ns, leader.tabId, { notifyLeader: false });
84
+ if (opts.removeTab && tab) removeTab(ns, leader.tabId, {
85
+ notifyLeader: false,
86
+ closePort: false
87
+ });
85
88
  for (const t of ns.tabs.values()) if (t.tabId !== leader.tabId) post(t.port, {
86
89
  type: "close-follower-ports",
87
90
  brokerInstanceId,
@@ -131,8 +134,9 @@ function failPromotion(ns, tabId, leadershipId, opts) {
131
134
  /**
132
135
  * Fast crash detection: queue a Web Lock request on the leader's tab lock.
133
136
  * Locks release the instant their holder's tab dies, so being GRANTED means
134
- * the leader is gone; release immediately (return from the callback) and
135
- * re-elect. The leadershipId guard drops stale grants after a normal handoff.
137
+ * the leader is gone from this bucket (dead, or moved to another bucket on
138
+ * sign-in/out); release immediately (return from the callback) and re-elect.
139
+ * The leadershipId guard drops stale grants after a normal handoff.
136
140
  */
137
141
  function startTabLockMonitor(ns, leadershipId) {
138
142
  const locks = navigator.locks;
@@ -344,8 +348,9 @@ function handleTabMessage(port, msg, ports) {
344
348
  });
345
349
  return;
346
350
  }
347
- const ns = getNamespace(msg.fingerprint, msg.bucketId);
348
351
  for (const other of [...namespaces.values()]) if (other.tabs.has(msg.tabId)) evictTab(other, msg.tabId, "rehello", { closePort: false });
352
+ canonicalFingerprint ??= msg.fingerprint;
353
+ const ns = getNamespace(msg.fingerprint, msg.bucketId);
349
354
  const tab = {
350
355
  port,
351
356
  tabId: msg.tabId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.328",
3
+ "version": "0.0.1-canary.329",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,8 +75,8 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.328",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.328",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.329",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.329",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.1.0",
82
82
  "@surrealdb/wasm-native": "^3.3.4",
@@ -102,7 +102,8 @@ export function pureWriteOpResult(op: SqlOp): unknown {
102
102
  * shared {@link resolveRelations} — identical to the SurrealDB backend.
103
103
  *
104
104
  * Value normalization (JSON round-trip):
105
- * - `Uint8Array`/bytes → `{ "__u8": <base64> }` (CRDT snapshots survive).
105
+ * - Bytes in any shape (`Uint8Array`, the SDK's `ArrayBuffer`) →
106
+ * `{ "__u8": <base64> }`, read back as `Uint8Array` (CRDT data survives).
106
107
  * - Record links / ids → their `table:id` string form (so `json_extract`
107
108
  * comparisons and `IN` matching are consistent). NOTE: link fields therefore
108
109
  * read back as strings, not `RecordId` instances — the one shape difference
@@ -93,6 +93,25 @@ describe('serializeRow / reviveRow round-trip', () => {
93
93
  expect([...(back.blob as Uint8Array)]).toEqual([0, 128, 255]);
94
94
  });
95
95
 
96
+ // The surrealdb SDK decodes a `bytes` column as an ArrayBuffer, which
97
+ // JSON.stringify writes as `{}`: synced CRDT log rows (and presence states)
98
+ // landed with no data, so every other client's edit was refused as empty.
99
+ it('keeps the bytes of every binary shape: an SDK ArrayBuffer, a view into a larger buffer', () => {
100
+ const backing = new Uint8Array([9, 9, 1, 2, 250, 9]);
101
+ const json = serializeRow({
102
+ id: 'thread__crdt_log:1',
103
+ data: backing.slice(2, 5).buffer,
104
+ window: backing.subarray(2, 5),
105
+ dv: new DataView(backing.buffer, 2, 3),
106
+ nested: { state: [new Int8Array([1, 2, -6]).buffer] },
107
+ });
108
+ const back = reviveRow(json) as any;
109
+ for (const bytes of [back.data, back.window, back.dv, back.nested.state[0]]) {
110
+ expect(bytes).toBeInstanceOf(Uint8Array);
111
+ expect([...bytes]).toEqual([1, 2, 250]);
112
+ }
113
+ });
114
+
96
115
  it('serializes RecordId-shaped links to strings and takes the fast parse path otherwise', () => {
97
116
  const json = serializeRow({ id: 't:1', author: { tb: 'user', id: 'u1' }, n: 2 });
98
117
  expect(reviveRow(json)).toEqual({ id: 't:1', author: 'user:u1', n: 2 });
@@ -61,11 +61,12 @@ export function scalar(value: unknown): unknown {
61
61
 
62
62
  export function serializeRow(row: Row): string {
63
63
  return JSON.stringify(row, function (this: Record<string, unknown>, k, v) {
64
- if (v instanceof Uint8Array) return { __u8: toBase64(v) };
65
64
  // `v` has already been through `toJSON()`, and a RecordId's is escaped
66
65
  // SurrealQL (`game:⟨476g…⟩`), so key the original value instead.
67
66
  const raw = this[k];
68
67
  if (raw && typeof raw === 'object') {
68
+ const bytes = bytesOf(raw);
69
+ if (bytes) return { __u8: toBase64(bytes) };
69
70
  const key = recordIdKey(raw);
70
71
  if (key !== null) return key;
71
72
  }
@@ -73,6 +74,19 @@ export function serializeRow(row: Row): string {
73
74
  });
74
75
  }
75
76
 
77
+ /**
78
+ * The bytes of a binary value, whatever shape it arrived in. Rows from the
79
+ * server carry a `bytes` column as an `ArrayBuffer` (the surrealdb SDK's CBOR
80
+ * decode), not a `Uint8Array`, and `JSON.stringify` turns an `ArrayBuffer`
81
+ * into `{}`: every synced CRDT log row and presence state was stored empty.
82
+ */
83
+ function bytesOf(value: object): Uint8Array | null {
84
+ if (value instanceof Uint8Array) return value;
85
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
86
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
87
+ return null;
88
+ }
89
+
76
90
  /**
77
91
  * Serialize a MERGE row ONCE for both halves of the merge upsert: the row a
78
92
  * fresh INSERT stores and the RFC 7396 delta `json_patch` applies on conflict.
@@ -324,4 +324,58 @@ describe('tabs broker: namespaces', () => {
324
324
  // and a is offered leadership of the new bucket.
325
325
  expect(a.ofType('become-leader').length).toBeGreaterThan(1);
326
326
  });
327
+
328
+ it('keeps the port of a leader whose released tab lock means it left the bucket, not that it died', async () => {
329
+ // The broker's crash detector queues a request on the leader's tab lock;
330
+ // here it is granted the moment the tab releases it, as on sign-in.
331
+ const grants: Array<() => void> = [];
332
+ vi.stubGlobal('navigator', {
333
+ locks: {
334
+ request: (_name: string, opts: { ifAvailable?: boolean; steal?: boolean }, cb: (lock: unknown) => unknown) =>
335
+ new Promise((resolve) => {
336
+ if (opts.ifAvailable || opts.steal) resolve(cb({}));
337
+ else grants.push(() => resolve(cb({})));
338
+ }),
339
+ },
340
+ });
341
+ try {
342
+ const a = connectTab('tab-a');
343
+ await ackLeadership(a);
344
+ expect(grants).toHaveLength(1);
345
+ grants[0]();
346
+ await flush();
347
+ a.send({
348
+ type: 'hello',
349
+ tabId: 'tab-a',
350
+ fingerprint: 'fp-test',
351
+ bucketId: 'user1',
352
+ visibility: 'visible',
353
+ heldLeadership: null,
354
+ });
355
+ await flush();
356
+ expect(a.ofType('become-leader').map((m) => m.leadershipId)).toEqual([1, 2]);
357
+ } finally {
358
+ vi.unstubAllGlobals();
359
+ }
360
+ });
361
+
362
+ it('elects the only tab in its new bucket when it signs in alone', async () => {
363
+ const a = connectTab('tab-a');
364
+ await ackLeadership(a);
365
+ a.send({
366
+ type: 'hello',
367
+ tabId: 'tab-a',
368
+ fingerprint: 'fp-test',
369
+ bucketId: 'user1',
370
+ visibility: 'visible',
371
+ heldLeadership: null,
372
+ });
373
+ await flush();
374
+ expect(a.ofType('become-leader').map((m) => m.leadershipId)).toEqual([1, 2]);
375
+ await ackLeadership(a, 'user1');
376
+ // Still pinged in the new bucket, so the client's watchdog stays quiet.
377
+ const pings = a.ofType('ping').length;
378
+ await vi.advanceTimersByTimeAsync(10_000);
379
+ expect(a.ofType('ping').length).toBeGreaterThan(pings);
380
+ });
327
381
  });
@@ -183,7 +183,12 @@ function clearLeader(
183
183
  if (tab && opts.demote) {
184
184
  post(tab.port, { type: 'demote', brokerInstanceId, leadershipId: leader.leadershipId });
185
185
  }
186
- if (opts.removeTab && tab) removeTab(ns, leader.tabId, { notifyLeader: false });
186
+ // Its port stays open: the only caller is the tab-lock monitor, and a lock
187
+ // released is not always a dead tab. A leader that signs in or out drops
188
+ // its lock before re-helloing into the new bucket, and closing the port
189
+ // here swallowed that hello, leaving the tab roleless until its watchdog
190
+ // reconnected. A dead tab's port is gone anyway.
191
+ if (opts.removeTab && tab) removeTab(ns, leader.tabId, { notifyLeader: false, closePort: false });
187
192
  // Followers must drop their ports; new ones are minted after re-election.
188
193
  for (const t of ns.tabs.values()) {
189
194
  if (t.tabId !== leader.tabId) {
@@ -250,8 +255,9 @@ function failPromotion(
250
255
  /**
251
256
  * Fast crash detection: queue a Web Lock request on the leader's tab lock.
252
257
  * Locks release the instant their holder's tab dies, so being GRANTED means
253
- * the leader is gone; release immediately (return from the callback) and
254
- * re-elect. The leadershipId guard drops stale grants after a normal handoff.
258
+ * the leader is gone from this bucket (dead, or moved to another bucket on
259
+ * sign-in/out); release immediately (return from the callback) and re-elect.
260
+ * The leadershipId guard drops stale grants after a normal handoff.
255
261
  */
256
262
  function startTabLockMonitor(ns: Namespace, leadershipId: number): void {
257
263
  const locks = (navigator as { locks?: LockManager }).locks;
@@ -489,7 +495,6 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
489
495
  post(port, { type: 'unsupported', brokerInstanceId, reason: 'fingerprint-mismatch' });
490
496
  return;
491
497
  }
492
- const ns = getNamespace(msg.fingerprint, msg.bucketId);
493
498
  // Drop stale state for this tab EVERYWHERE first. A re-hello is either a
494
499
  // reconnect (same namespace) or a bucket switch (leave old namespace,
495
500
  // join the new one); in both cases exactly one entry may remain, and if
@@ -498,6 +503,13 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
498
503
  for (const other of [...namespaces.values()]) {
499
504
  if (other.tabs.has(msg.tabId)) evictTab(other, msg.tabId, 'rehello', { closePort: false });
500
505
  }
506
+ // Only now look the namespace up: evicting the broker's last tab resets it
507
+ // as idle (all namespaces and the fingerprint dropped), and a namespace
508
+ // fetched before that would be orphaned, never pinged or found again. A
509
+ // lone tab signing in lost its leader that way until its watchdog
510
+ // reconnected.
511
+ canonicalFingerprint ??= msg.fingerprint;
512
+ const ns = getNamespace(msg.fingerprint, msg.bucketId);
501
513
  const tab: BrokerTab = {
502
514
  port,
503
515
  tabId: msg.tabId,