@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.
Files changed (39) hide show
  1. package/dist/index.d.ts +151 -0
  2. package/dist/index.js +1509 -120
  3. package/dist/{sqlite-plan-sql.js → sqlite-open.js} +83 -1
  4. package/dist/sqlite-worker.js +268 -108
  5. package/dist/tabs-broker-worker.d.ts +8 -0
  6. package/dist/tabs-broker-worker.js +434 -0
  7. package/dist/types.d.ts +25 -0
  8. package/package.json +4 -4
  9. package/scripts/check-broker-bundle.mjs +33 -0
  10. package/src/modules/cache/index.ts +35 -0
  11. package/src/modules/data/index.ts +13 -3
  12. package/src/modules/data/mutation-id.test.ts +25 -0
  13. package/src/modules/data/mutation-id.ts +35 -0
  14. package/src/modules/devtools/index.ts +9 -0
  15. package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
  16. package/src/modules/sync/queue/queue-up.ts +74 -38
  17. package/src/modules/sync/scheduler.ts +4 -2
  18. package/src/modules/sync/sync.ts +186 -8
  19. package/src/services/database/engine-factory.ts +3 -2
  20. package/src/services/database/sqlite-cache-engine.test.ts +160 -103
  21. package/src/services/database/sqlite-cache-engine.ts +285 -72
  22. package/src/services/database/sqlite-open.ts +36 -1
  23. package/src/services/database/sqlite-select.test.ts +13 -46
  24. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  25. package/src/services/database/sqlite-transport.ts +219 -0
  26. package/src/services/database/sqlite-worker.ts +340 -55
  27. package/src/services/stream-processor/index.ts +11 -2
  28. package/src/services/tabs/broker-client.ts +283 -0
  29. package/src/services/tabs/broker.test.ts +278 -0
  30. package/src/services/tabs/coordinator.test.ts +192 -0
  31. package/src/services/tabs/coordinator.ts +567 -0
  32. package/src/services/tabs/fake-ports.fixture.ts +70 -0
  33. package/src/services/tabs/leader-locks.ts +75 -0
  34. package/src/services/tabs/protocol.ts +239 -0
  35. package/src/services/tabs/support.ts +36 -0
  36. package/src/services/tabs/tabs-broker-worker.ts +581 -0
  37. package/src/sp00ky.ts +164 -6
  38. package/src/types.ts +25 -0
  39. 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 { parseRecordIdString, extractTablePart, classifySyncError } from '../../../utils/index';
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 created_at ASC`
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
- await this.upQueue.loadFromDatabase();
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,
@@ -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
- this.logger.warn(
850
- {
851
- queryId: queryId.toString(),
852
- Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
853
- },
854
- 'Received remote update for unknown local query'
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;