@spooky-sync/core 0.0.1-canary.154 → 0.0.1-canary.155
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.d.ts +151 -0
- package/dist/index.js +1509 -120
- package/dist/{sqlite-plan-sql.js → sqlite-open.js} +83 -1
- package/dist/sqlite-worker.js +268 -108
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +25 -0
- package/package.json +4 -4
- package/scripts/check-broker-bundle.mjs +33 -0
- package/src/modules/cache/index.ts +35 -0
- package/src/modules/data/index.ts +13 -3
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/devtools/index.ts +9 -0
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
- package/src/modules/sync/queue/queue-up.ts +74 -38
- package/src/modules/sync/scheduler.ts +4 -2
- package/src/modules/sync/sync.ts +186 -8
- package/src/services/database/engine-factory.ts +3 -2
- package/src/services/database/sqlite-cache-engine.test.ts +160 -103
- package/src/services/database/sqlite-cache-engine.ts +285 -72
- package/src/services/database/sqlite-open.ts +36 -1
- package/src/services/database/sqlite-select.test.ts +13 -46
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +219 -0
- package/src/services/database/sqlite-worker.ts +340 -55
- package/src/services/stream-processor/index.ts +11 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +192 -0
- package/src/services/tabs/coordinator.ts +567 -0
- package/src/services/tabs/fake-ports.fixture.ts +70 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +239 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +581 -0
- package/src/sp00ky.ts +164 -6
- package/src/types.ts +25 -0
- package/tsdown.config.ts +19 -10
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox mutation ids. Previously `_00_pending_mutations:${Date.now()}`, which
|
|
3
|
+
* (a) collides when two mutations land in the same millisecond, a real case
|
|
4
|
+
* once multiple tabs share one store, and (b) never actually ordered the
|
|
5
|
+
* drain, because `loadFromDatabase` sorted by a `created_at` field that does
|
|
6
|
+
* not exist on the table. The new shape is sortable AND collision-free:
|
|
7
|
+
*
|
|
8
|
+
* <13-digit zero-padded ms timestamp>_<4-digit base36 seq>_<tabId>
|
|
9
|
+
*
|
|
10
|
+
* Lexicographic order == chronological order (per tab; cross-tab within-ms
|
|
11
|
+
* ordering is arbitrary, which matches reality), so `ORDER BY id` in the
|
|
12
|
+
* drain is now meaningful. The tabId suffix also routes rollbacks back to the
|
|
13
|
+
* tab that owns the mutation in shared-tabs mode.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
let seq = 0;
|
|
17
|
+
|
|
18
|
+
/** Tab identity for mutation ids in SOLO mode, minted once per session. In
|
|
19
|
+
* shared-tabs mode the coordinator's tabId is passed in instead. */
|
|
20
|
+
const FALLBACK_TAB_ID = Math.random().toString(36).slice(2, 8);
|
|
21
|
+
|
|
22
|
+
export function mintMutationId(tabId?: string): string {
|
|
23
|
+
const ts = Date.now().toString().padStart(13, '0');
|
|
24
|
+
const n = (seq = (seq + 1) % 36 ** 4).toString(36).padStart(4, '0');
|
|
25
|
+
return `_00_pending_mutations:${ts}_${n}_${tabId ?? FALLBACK_TAB_ID}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The tab that created the mutation, or null for a legacy numeric id. */
|
|
29
|
+
export function mutationOwnerTabId(mutationId: string): string | null {
|
|
30
|
+
const raw = mutationId.startsWith('_00_pending_mutations:')
|
|
31
|
+
? mutationId.slice('_00_pending_mutations:'.length)
|
|
32
|
+
: mutationId;
|
|
33
|
+
const parts = raw.split('_');
|
|
34
|
+
return parts.length >= 3 ? parts[parts.length - 1] : null;
|
|
35
|
+
}
|
|
@@ -51,6 +51,13 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
51
51
|
// (the on-demand GET_STATE pull) still works before the push channel turns on.
|
|
52
52
|
private enabled = false;
|
|
53
53
|
|
|
54
|
+
/** Shared-tabs snapshot for the panel, wired by Sp00kyClient when active. */
|
|
55
|
+
private tabsInfoProvider: (() => Record<string, unknown> | null) | null = null;
|
|
56
|
+
|
|
57
|
+
setTabsInfoProvider(provider: () => Record<string, unknown> | null): void {
|
|
58
|
+
this.tabsInfoProvider = provider;
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
// Full local table list (incl. internal `_00_*`), enumerated from the local DB
|
|
55
62
|
// via our own reliable `service.query` — the DevTools panel's page-eval query
|
|
56
63
|
// bridge is unreliable under load, so the panel relies on this instead.
|
|
@@ -327,6 +334,8 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
327
334
|
// Durability of the local store. `fallback: true` means persistence was
|
|
328
335
|
// requested but the dataset is actually sitting in RAM.
|
|
329
336
|
storage: this.databaseService.storageHealth ?? { status: 'unknown', fallback: false },
|
|
337
|
+
// Shared-tabs role state (null when the feature is off / fell back).
|
|
338
|
+
tabs: this.tabsInfoProvider?.() ?? null,
|
|
330
339
|
},
|
|
331
340
|
});
|
|
332
341
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { UpQueue } from './queue-up';
|
|
3
|
+
|
|
4
|
+
function makeLogger(): any {
|
|
5
|
+
const noop = () => {};
|
|
6
|
+
const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
7
|
+
l.child = () => l;
|
|
8
|
+
return l;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ROW = {
|
|
12
|
+
id: '_00_pending_mutations:0001753875000000_0001_tabB',
|
|
13
|
+
mutationType: 'create',
|
|
14
|
+
recordId: 'game:g1',
|
|
15
|
+
data: { title: 'x' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Shared-tabs: followers commit outbox rows into the shared store and notify
|
|
19
|
+
// the leader by id. The notify may be REPLAYED (failover buffering), so the
|
|
20
|
+
// enqueue must be idempotent, and only the leader ever drains.
|
|
21
|
+
describe('UpQueue.enqueueFromDatabase', () => {
|
|
22
|
+
function makeQueue(rows: Record<string, unknown[]>) {
|
|
23
|
+
const query = vi.fn(async (_sql: string, vars?: Record<string, unknown>) => {
|
|
24
|
+
const id = String(vars?.mutation_id ?? '');
|
|
25
|
+
return [rows[id] ?? []];
|
|
26
|
+
});
|
|
27
|
+
const local: any = { query };
|
|
28
|
+
return { queue: new UpQueue(local, makeLogger()), query };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
it('loads exactly the forwarded row and enqueues it once', async () => {
|
|
32
|
+
const { queue } = makeQueue({ [ROW.id]: [ROW] });
|
|
33
|
+
await queue.enqueueFromDatabase(ROW.id);
|
|
34
|
+
expect(queue.size).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('is idempotent: a replayed notify does not double-enqueue', async () => {
|
|
38
|
+
const { queue, query } = makeQueue({ [ROW.id]: [ROW] });
|
|
39
|
+
await queue.enqueueFromDatabase(ROW.id);
|
|
40
|
+
await queue.enqueueFromDatabase(ROW.id);
|
|
41
|
+
expect(queue.size).toBe(1);
|
|
42
|
+
// The second call short-circuits before even touching the store.
|
|
43
|
+
expect(query).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('a row that never committed is a silent no-op (the app promise already rejected)', async () => {
|
|
47
|
+
const { queue } = makeQueue({});
|
|
48
|
+
await queue.enqueueFromDatabase(ROW.id);
|
|
49
|
+
expect(queue.size).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -6,7 +6,12 @@ import {
|
|
|
6
6
|
createSyncQueueEventSystem,
|
|
7
7
|
SyncQueueEventTypes,
|
|
8
8
|
} from '../events/index';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
parseRecordIdString,
|
|
11
|
+
extractTablePart,
|
|
12
|
+
classifySyncError,
|
|
13
|
+
encodeRecordId,
|
|
14
|
+
} from '../../../utils/index';
|
|
10
15
|
import type { Logger } from '../../../services/logger/index';
|
|
11
16
|
import type { PushEventOptions } from '../../../events/index';
|
|
12
17
|
|
|
@@ -184,49 +189,41 @@ export class UpQueue {
|
|
|
184
189
|
return this.local.query(`DELETE $mutation_id`, { mutation_id });
|
|
185
190
|
}
|
|
186
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Shared-tabs: a follower committed an outbox row into the shared store and
|
|
194
|
+
* notified the leader; enqueue exactly that row. Idempotent: a replayed
|
|
195
|
+
* notify (failover) or a row already queued is a no-op, and a row that never
|
|
196
|
+
* committed simply is not found (the follower's own write promise already
|
|
197
|
+
* rejected in that case).
|
|
198
|
+
*/
|
|
199
|
+
async enqueueFromDatabase(mutationId: string): Promise<void> {
|
|
200
|
+
if (this.queue.some((e) => encodeUpEventId(e) === mutationId)) return;
|
|
201
|
+
try {
|
|
202
|
+
const [records] = await this.local.query<any>(`SELECT * FROM $mutation_id`, {
|
|
203
|
+
mutation_id: parseRecordIdString(mutationId),
|
|
204
|
+
});
|
|
205
|
+
const event = Array.isArray(records) && records[0] ? rowToUpEvent(records[0], this.logger) : null;
|
|
206
|
+
if (event) this.addToQueue(event);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
this.logger.error(
|
|
209
|
+
{ error, mutationId, Category: 'sp00ky-client::UpQueue::enqueueFromDatabase' },
|
|
210
|
+
'Failed to load a forwarded mutation'
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
187
215
|
async loadFromDatabase() {
|
|
188
216
|
try {
|
|
217
|
+
// ORDER BY id: mutation ids are zero-padded-timestamp-prefixed (see
|
|
218
|
+
// modules/data/mutation-id.ts), so lexicographic id order IS chronological
|
|
219
|
+
// order. (The previous created_at was never a defined field on this
|
|
220
|
+
// table, so the old ordering was undefined.)
|
|
189
221
|
const [records] = await this.local.query<any>(
|
|
190
|
-
`SELECT * FROM _00_pending_mutations ORDER BY
|
|
222
|
+
`SELECT * FROM _00_pending_mutations ORDER BY id ASC`
|
|
191
223
|
);
|
|
192
224
|
|
|
193
225
|
this.queue = records
|
|
194
|
-
.map((r: any): UpEvent | null =>
|
|
195
|
-
switch (r.mutationType) {
|
|
196
|
-
case 'create':
|
|
197
|
-
return {
|
|
198
|
-
type: 'create',
|
|
199
|
-
mutation_id: parseRecordIdString(r.id),
|
|
200
|
-
record_id: parseRecordIdString(r.recordId),
|
|
201
|
-
data: r.data,
|
|
202
|
-
tableName: extractTablePart(r.recordId),
|
|
203
|
-
};
|
|
204
|
-
case 'update':
|
|
205
|
-
return {
|
|
206
|
-
type: 'update',
|
|
207
|
-
mutation_id: parseRecordIdString(r.id),
|
|
208
|
-
record_id: parseRecordIdString(r.recordId),
|
|
209
|
-
data: r.data,
|
|
210
|
-
beforeRecord: r.beforeRecord,
|
|
211
|
-
};
|
|
212
|
-
case 'delete':
|
|
213
|
-
return {
|
|
214
|
-
type: 'delete',
|
|
215
|
-
mutation_id: parseRecordIdString(r.id),
|
|
216
|
-
record_id: parseRecordIdString(r.recordId),
|
|
217
|
-
};
|
|
218
|
-
default:
|
|
219
|
-
this.logger.warn(
|
|
220
|
-
{
|
|
221
|
-
mutationType: r.mutationType,
|
|
222
|
-
record: r,
|
|
223
|
-
Category: 'sp00ky-client::UpQueue::loadFromDatabase',
|
|
224
|
-
},
|
|
225
|
-
'Unknown mutation type'
|
|
226
|
-
);
|
|
227
|
-
return null;
|
|
228
|
-
}
|
|
229
|
-
})
|
|
226
|
+
.map((r: any): UpEvent | null => rowToUpEvent(r, this.logger))
|
|
230
227
|
.filter((e: UpEvent | null): e is UpEvent => e !== null);
|
|
231
228
|
} catch (error) {
|
|
232
229
|
this.logger.error(
|
|
@@ -236,3 +233,42 @@ export class UpQueue {
|
|
|
236
233
|
}
|
|
237
234
|
}
|
|
238
235
|
}
|
|
236
|
+
|
|
237
|
+
/** The stable string id of an event's outbox row. */
|
|
238
|
+
function encodeUpEventId(event: UpEvent): string {
|
|
239
|
+
return encodeRecordId(event.mutation_id);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Materialize one `_00_pending_mutations` row into an UpEvent. */
|
|
243
|
+
function rowToUpEvent(r: any, logger: Logger): UpEvent | null {
|
|
244
|
+
switch (r.mutationType) {
|
|
245
|
+
case 'create':
|
|
246
|
+
return {
|
|
247
|
+
type: 'create',
|
|
248
|
+
mutation_id: parseRecordIdString(r.id),
|
|
249
|
+
record_id: parseRecordIdString(r.recordId),
|
|
250
|
+
data: r.data,
|
|
251
|
+
tableName: extractTablePart(r.recordId),
|
|
252
|
+
};
|
|
253
|
+
case 'update':
|
|
254
|
+
return {
|
|
255
|
+
type: 'update',
|
|
256
|
+
mutation_id: parseRecordIdString(r.id),
|
|
257
|
+
record_id: parseRecordIdString(r.recordId),
|
|
258
|
+
data: r.data,
|
|
259
|
+
beforeRecord: r.beforeRecord,
|
|
260
|
+
};
|
|
261
|
+
case 'delete':
|
|
262
|
+
return {
|
|
263
|
+
type: 'delete',
|
|
264
|
+
mutation_id: parseRecordIdString(r.id),
|
|
265
|
+
record_id: parseRecordIdString(r.recordId),
|
|
266
|
+
};
|
|
267
|
+
default:
|
|
268
|
+
logger.warn(
|
|
269
|
+
{ mutationType: r.mutationType, record: r, Category: 'sp00ky-client::UpQueue::rowToUpEvent' },
|
|
270
|
+
'Unknown mutation type'
|
|
271
|
+
);
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -26,8 +26,10 @@ export class SyncScheduler {
|
|
|
26
26
|
private onSyncOutcome?: (ok: boolean, error?: unknown) => void
|
|
27
27
|
) {}
|
|
28
28
|
|
|
29
|
-
async init() {
|
|
30
|
-
|
|
29
|
+
async init(opts: { loadOutbox?: boolean } = {}) {
|
|
30
|
+
// Shared-tabs followers skip the outbox load: the shared store's
|
|
31
|
+
// `_00_pending_mutations` is drained by exactly one tab (the leader).
|
|
32
|
+
if (opts.loadOutbox !== false) await this.upQueue.loadFromDatabase();
|
|
31
33
|
this.upQueue.events.subscribe(SyncQueueEventTypes.MutationEnqueued, this.syncUp.bind(this));
|
|
32
34
|
this.downQueue.events.subscribe(
|
|
33
35
|
SyncQueueEventTypes.QueryItemEnqueued,
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -22,6 +22,9 @@ import type { CacheModule } from '../cache/index';
|
|
|
22
22
|
import type { DataModule } from '../data/index';
|
|
23
23
|
import { classifySyncError, encodeRecordId, extractIdPart, extractTablePart, surql } from '../../utils/index';
|
|
24
24
|
import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
|
|
25
|
+
import { mutationOwnerTabId } from '../data/mutation-id';
|
|
26
|
+
import type { LeaderSyncHub, SyncForwarder } from '../../services/tabs/coordinator';
|
|
27
|
+
import { parseRecordIdString } from '../../utils/index';
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* Tunables for `Sp00kySync` construction.
|
|
@@ -75,6 +78,15 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
75
78
|
// subscription in `Sp00kyClient`; null when unauthenticated.
|
|
76
79
|
private currentUserId: string | null = null;
|
|
77
80
|
|
|
81
|
+
// ---- shared-tabs role state ----
|
|
82
|
+
// Followers keep their own remote WS (registration, per-query sync, poll)
|
|
83
|
+
// but must never drain the shared outbox or hold a second list_ref LIVE.
|
|
84
|
+
// The leader relays its LIVE events and routes rollbacks by mutation owner.
|
|
85
|
+
private tabRole: 'solo' | 'leader' | 'follower' = 'solo';
|
|
86
|
+
private tabId: string | null = null;
|
|
87
|
+
private hub: LeaderSyncHub | null = null;
|
|
88
|
+
private forwarder: SyncForwarder | null = null;
|
|
89
|
+
|
|
78
90
|
private refMode: RefMode = DEFAULT_REF_MODE;
|
|
79
91
|
|
|
80
92
|
// When true, an unauthenticated client still runs the `_00_list_ref` poll
|
|
@@ -366,7 +378,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
366
378
|
public async init() {
|
|
367
379
|
if (this.isInit) throw new Error('Sp00kySync is already initialized');
|
|
368
380
|
this.isInit = true;
|
|
369
|
-
await this.scheduler.init();
|
|
381
|
+
await this.scheduler.init({ loadOutbox: this.tabRole !== 'follower' });
|
|
370
382
|
this.subscribeToReconnect();
|
|
371
383
|
void this.scheduler.syncUp();
|
|
372
384
|
void this.scheduler.syncDown();
|
|
@@ -393,6 +405,116 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
393
405
|
}
|
|
394
406
|
}
|
|
395
407
|
|
|
408
|
+
// ---- shared-tabs roles ------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
/** Set BEFORE init(): shapes what init boots (a follower loads no outbox and
|
|
411
|
+
* never starts LIVE; its own registration/poll paths stay untouched). */
|
|
412
|
+
public setTabContext(role: 'solo' | 'leader' | 'follower', tabId: string | null): void {
|
|
413
|
+
this.tabRole = role;
|
|
414
|
+
this.tabId = tabId;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Leader duties: drain the shared outbox, own the single list_ref LIVE,
|
|
418
|
+
* relay LIVE events and rollbacks to followers via `hub`. Idempotent for a
|
|
419
|
+
* boot-time leader; a runtime promotion (failover) reloads the outbox,
|
|
420
|
+
* which now holds EVERY tab's rows, and restarts LIVE under this session. */
|
|
421
|
+
public async promoteToLeader(hub: LeaderSyncHub): Promise<void> {
|
|
422
|
+
this.tabRole = 'leader';
|
|
423
|
+
this.hub = hub;
|
|
424
|
+
this.forwarder = null;
|
|
425
|
+
hub.onFollowerMessage = (tabId, msg) => {
|
|
426
|
+
void tabId;
|
|
427
|
+
switch (msg.type) {
|
|
428
|
+
case 'sync-hello':
|
|
429
|
+
break;
|
|
430
|
+
case 'mutation-enqueued':
|
|
431
|
+
void this.enqueueForwardedMutation(msg.mutationId);
|
|
432
|
+
break;
|
|
433
|
+
case 'request-poll':
|
|
434
|
+
this.listRefIdleStreak = 0;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
if (this.isInit) {
|
|
439
|
+
await this.upQueue.loadFromDatabase();
|
|
440
|
+
void this.scheduler.syncUp();
|
|
441
|
+
if (this.currentUserId || this.anonLiveEnabled) {
|
|
442
|
+
this.startListRefPoll();
|
|
443
|
+
await this.restartRefLiveQuery().catch((err) => {
|
|
444
|
+
this.logger.warn(
|
|
445
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::promoteToLeader' },
|
|
446
|
+
'LIVE restart failed on promotion; poll fallback covers it'
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Follower duties: no outbox drain, no LIVE. Mutations forward to the
|
|
454
|
+
* leader; everything else (registration, per-query sync, poll) runs
|
|
455
|
+
* against this tab's own remote session as usual. */
|
|
456
|
+
public demoteToFollower(forwarder: SyncForwarder): void {
|
|
457
|
+
this.tabRole = 'follower';
|
|
458
|
+
this.hub = null;
|
|
459
|
+
this.forwarder = forwarder;
|
|
460
|
+
void this.killRefLiveQuery();
|
|
461
|
+
forwarder.onLeaderMessage = (msg) => {
|
|
462
|
+
switch (msg.type) {
|
|
463
|
+
case 'list-ref-change':
|
|
464
|
+
void this.applyRelayedListRefChange(msg).catch((err) => {
|
|
465
|
+
this.logger.error(
|
|
466
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::relay' },
|
|
467
|
+
'Relayed list_ref change failed'
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
break;
|
|
471
|
+
case 'mutation-rolled-back':
|
|
472
|
+
this.events.emit(SyncEventTypes.MutationRolledBack, {
|
|
473
|
+
eventType: msg.eventType,
|
|
474
|
+
recordId: msg.recordId,
|
|
475
|
+
error: msg.error,
|
|
476
|
+
});
|
|
477
|
+
break;
|
|
478
|
+
default:
|
|
479
|
+
// db-ready and ingest-relay are handled by the engine/cache wiring
|
|
480
|
+
// in Sp00kyClient before messages reach this handler.
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** A forwarded outbox row from a follower: load + drain it. Idempotent. */
|
|
487
|
+
public async enqueueForwardedMutation(mutationId: string): Promise<void> {
|
|
488
|
+
if (this.tabRole !== 'leader') return;
|
|
489
|
+
await this.upQueue.enqueueFromDatabase(mutationId);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** A relayed `_00_list_ref` LIVE event: resolve against THIS tab's queries
|
|
493
|
+
* and run the exact same handling the LIVE subscription would have. */
|
|
494
|
+
private async applyRelayedListRefChange(msg: {
|
|
495
|
+
action: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
496
|
+
queryId: string;
|
|
497
|
+
recordId: string;
|
|
498
|
+
version: number;
|
|
499
|
+
parent: boolean;
|
|
500
|
+
}): Promise<void> {
|
|
501
|
+
const queryId = parseRecordIdString(msg.queryId);
|
|
502
|
+
// Foreign query (another tab's session-salted hash): not ours, ignore.
|
|
503
|
+
if (!this.dataModule.getQueryById(queryId)) return;
|
|
504
|
+
const recordId = parseRecordIdString(msg.recordId);
|
|
505
|
+
if (msg.parent) {
|
|
506
|
+
await this.handleRemoteSubqueryChange(msg.action, queryId, recordId, msg.version);
|
|
507
|
+
} else {
|
|
508
|
+
await this.handleRemoteListRefChange(msg.action, queryId, recordId, msg.version);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** One immediate poll cycle (failover convergence). */
|
|
513
|
+
public async forcePollRound(): Promise<void> {
|
|
514
|
+
this.listRefIdleStreak = 0;
|
|
515
|
+
await this.pollListRefForActiveQueries().catch(() => false);
|
|
516
|
+
}
|
|
517
|
+
|
|
396
518
|
/**
|
|
397
519
|
* Quiesce all sync activity ahead of a local-bucket switch. After this
|
|
398
520
|
* resolves, nothing in the sync module writes to the local store: the poll
|
|
@@ -772,6 +894,9 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
772
894
|
}
|
|
773
895
|
|
|
774
896
|
private async startRefLiveQueries() {
|
|
897
|
+
// Shared-tabs follower: exactly one LIVE per user exists, on the leader;
|
|
898
|
+
// its events reach this tab through the relay.
|
|
899
|
+
if (this.tabRole === 'follower') return;
|
|
775
900
|
const tableName = this.listRefTable();
|
|
776
901
|
this.logger.debug(
|
|
777
902
|
{ tableName, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
|
|
@@ -837,6 +962,20 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
837
962
|
this.lastLiveEventAt = Date.now();
|
|
838
963
|
this.listRefIdleStreak = 0;
|
|
839
964
|
|
|
965
|
+
// Shared-tabs leader: the list_ref table is USER-scoped, so this LIVE also
|
|
966
|
+
// carries events for FOLLOWER tabs' queries (their own session-salted
|
|
967
|
+
// hashes). Relay every primary event; each follower resolves the queryId
|
|
968
|
+
// against its own DataModule and ignores foreign ones. Then continue with
|
|
969
|
+
// this tab's own handling below.
|
|
970
|
+
this.hub?.broadcast({
|
|
971
|
+
type: 'list-ref-change',
|
|
972
|
+
action,
|
|
973
|
+
queryId: encodeRecordId(queryId),
|
|
974
|
+
recordId: encodeRecordId(recordId),
|
|
975
|
+
version,
|
|
976
|
+
parent: false,
|
|
977
|
+
});
|
|
978
|
+
|
|
840
979
|
// NOTE: DELETE is handled like CREATE/UPDATE below. When another window (or
|
|
841
980
|
// this one) deletes a record, the server's SSP removes it from `_00_list_ref`
|
|
842
981
|
// and the LIVE subscription delivers a DELETE here — `createDiffFromDbOp`
|
|
@@ -846,13 +985,17 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
846
985
|
const existing = this.dataModule.getQueryById(queryId);
|
|
847
986
|
|
|
848
987
|
if (!existing) {
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
988
|
+
// With a hub attached, an unknown query is the NORMAL case (it belongs
|
|
989
|
+
// to a follower tab); without one it still warrants the warning.
|
|
990
|
+
if (!this.hub) {
|
|
991
|
+
this.logger.warn(
|
|
992
|
+
{
|
|
993
|
+
queryId: queryId.toString(),
|
|
994
|
+
Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
|
|
995
|
+
},
|
|
996
|
+
'Received remote update for unknown local query'
|
|
997
|
+
);
|
|
998
|
+
}
|
|
856
999
|
return;
|
|
857
1000
|
}
|
|
858
1001
|
|
|
@@ -899,6 +1042,16 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
899
1042
|
|
|
900
1043
|
if (action === 'DELETE') return;
|
|
901
1044
|
|
|
1045
|
+
// Relay child-edge events too (see handleRemoteListRefChange).
|
|
1046
|
+
this.hub?.broadcast({
|
|
1047
|
+
type: 'list-ref-change',
|
|
1048
|
+
action,
|
|
1049
|
+
queryId: encodeRecordId(queryId),
|
|
1050
|
+
recordId: encodeRecordId(childId),
|
|
1051
|
+
version,
|
|
1052
|
+
parent: true,
|
|
1053
|
+
});
|
|
1054
|
+
|
|
902
1055
|
const existing = this.dataModule.getQueryById(queryId);
|
|
903
1056
|
if (!existing) return;
|
|
904
1057
|
|
|
@@ -1013,6 +1166,21 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1013
1166
|
recordId,
|
|
1014
1167
|
error: error.message,
|
|
1015
1168
|
});
|
|
1169
|
+
|
|
1170
|
+
// Shared-tabs: the store rollback above already propagated to every tab
|
|
1171
|
+
// via the ingest relay; additionally deliver the EVENT to the tab that
|
|
1172
|
+
// owns the mutation so its UI (toasts, subscribeToRollbacks) fires there.
|
|
1173
|
+
const mutationId = encodeRecordId(event.mutation_id);
|
|
1174
|
+
const owner = mutationOwnerTabId(mutationId);
|
|
1175
|
+
if (this.hub && owner && this.tabId && owner !== this.tabId) {
|
|
1176
|
+
this.hub.sendTo(owner, {
|
|
1177
|
+
type: 'mutation-rolled-back',
|
|
1178
|
+
mutationId,
|
|
1179
|
+
recordId,
|
|
1180
|
+
eventType: event.type,
|
|
1181
|
+
error: error.message,
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1016
1184
|
}
|
|
1017
1185
|
|
|
1018
1186
|
private async processDownEvent(event: DownEvent) {
|
|
@@ -1171,6 +1339,16 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1171
1339
|
* @param mutations Array of UpEvents (create/update/delete) to enqueue.
|
|
1172
1340
|
*/
|
|
1173
1341
|
public async enqueueMutation(mutations: UpEvent[]) {
|
|
1342
|
+
// Follower: the outbox rows are already committed in the SHARED store (the
|
|
1343
|
+
// mutation tx went through the leader's worker); only the leader drains,
|
|
1344
|
+
// so hand over the ids instead of queueing locally. A notify lost in a
|
|
1345
|
+
// failover window is covered by the new leader's loadFromDatabase.
|
|
1346
|
+
if (this.tabRole === 'follower') {
|
|
1347
|
+
for (const m of mutations) {
|
|
1348
|
+
this.forwarder?.mutationEnqueued(encodeRecordId(m.mutation_id));
|
|
1349
|
+
}
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1174
1352
|
this.scheduler.enqueueMutation(mutations);
|
|
1175
1353
|
}
|
|
1176
1354
|
|
|
@@ -16,7 +16,8 @@ import { SqliteCacheEngine } from './sqlite-cache-engine';
|
|
|
16
16
|
export function createLocalEngine(
|
|
17
17
|
choice: LocalEngineChoice | undefined,
|
|
18
18
|
config: Sp00kyConfig<any>['database'],
|
|
19
|
-
logger: Logger
|
|
19
|
+
logger: Logger,
|
|
20
|
+
opts: { shared?: boolean } = {}
|
|
20
21
|
): LocalStore {
|
|
21
22
|
if (choice === undefined || choice === 'surrealdb') {
|
|
22
23
|
return new SurrealCacheEngine(config, logger);
|
|
@@ -25,7 +26,7 @@ export function createLocalEngine(
|
|
|
25
26
|
// Mirror the SurrealDB engine's `store` semantics: 'memory' → in-memory
|
|
26
27
|
// SQLite (transient), 'indexeddb' → OPFS-backed (durable).
|
|
27
28
|
const useOpfs = (config.store ?? 'memory') !== 'memory';
|
|
28
|
-
return new SqliteCacheEngine(config, logger, { useOpfs });
|
|
29
|
+
return new SqliteCacheEngine(config, logger, { useOpfs, shared: opts.shared });
|
|
29
30
|
}
|
|
30
31
|
// Custom engine instance — trust it to satisfy LocalStore.
|
|
31
32
|
return choice as unknown as LocalStore;
|