@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 +5 -4
- package/dist/sqlite-open.js +14 -1
- package/dist/tabs-broker-worker.js +9 -4
- package/package.json +3 -3
- package/src/services/database/sqlite-cache-engine.ts +2 -1
- package/src/services/database/sqlite-plan-sql.test.ts +19 -0
- package/src/services/database/sqlite-plan-sql.ts +15 -1
- package/src/services/tabs/broker.test.ts +54 -0
- package/src/services/tabs/tabs-broker-worker.ts +16 -4
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.
|
|
2192
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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
|
-
* -
|
|
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.
|
|
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,
|
package/dist/sqlite-open.js
CHANGED
|
@@ -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, {
|
|
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
|
|
135
|
-
*
|
|
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.
|
|
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.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
-
* -
|
|
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
|
-
|
|
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
|
|
254
|
-
*
|
|
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,
|