@spooky-sync/core 0.0.1-canary.153 → 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 (43) hide show
  1. package/dist/index.d.ts +151 -0
  2. package/dist/index.js +1694 -126
  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 +55 -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 +82 -0
  15. package/src/modules/devtools/storage-info.test.ts +79 -0
  16. package/src/modules/devtools/storage-info.ts +116 -0
  17. package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
  18. package/src/modules/sync/queue/queue-up.ts +74 -38
  19. package/src/modules/sync/scheduler.ts +4 -2
  20. package/src/modules/sync/sync.ts +186 -8
  21. package/src/services/database/cache-engine.ts +8 -0
  22. package/src/services/database/engine-factory.ts +3 -2
  23. package/src/services/database/sqlite-cache-engine.test.ts +214 -85
  24. package/src/services/database/sqlite-cache-engine.ts +345 -76
  25. package/src/services/database/sqlite-open.ts +36 -1
  26. package/src/services/database/sqlite-select.test.ts +13 -46
  27. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  28. package/src/services/database/sqlite-transport.ts +219 -0
  29. package/src/services/database/sqlite-worker.ts +340 -55
  30. package/src/services/database/surreal-cache-engine.ts +2 -0
  31. package/src/services/stream-processor/index.ts +11 -2
  32. package/src/services/tabs/broker-client.ts +283 -0
  33. package/src/services/tabs/broker.test.ts +278 -0
  34. package/src/services/tabs/coordinator.test.ts +192 -0
  35. package/src/services/tabs/coordinator.ts +567 -0
  36. package/src/services/tabs/fake-ports.fixture.ts +70 -0
  37. package/src/services/tabs/leader-locks.ts +75 -0
  38. package/src/services/tabs/protocol.ts +239 -0
  39. package/src/services/tabs/support.ts +36 -0
  40. package/src/services/tabs/tabs-broker-worker.ts +581 -0
  41. package/src/sp00ky.ts +164 -6
  42. package/src/types.ts +25 -0
  43. package/tsdown.config.ts +19 -10
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mintMutationId, mutationOwnerTabId } from './mutation-id';
3
+
4
+ // The old `_00_pending_mutations:${Date.now()}` collided within a millisecond
5
+ // (guaranteed once multiple tabs share a store) and never actually ordered the
6
+ // drain. These pin the new id's three properties: unique, sortable, routable.
7
+ describe('mutation ids', () => {
8
+ it('never collides within a burst', () => {
9
+ const ids = new Set(Array.from({ length: 1000 }, () => mintMutationId('tabx')));
10
+ expect(ids.size).toBe(1000);
11
+ });
12
+
13
+ it('sorts lexicographically in mint order (ORDER BY id is chronological)', () => {
14
+ const ids = Array.from({ length: 200 }, () => mintMutationId('tabx'));
15
+ expect([...ids].sort()).toEqual(ids);
16
+ });
17
+
18
+ it('routes back to the owning tab', () => {
19
+ expect(mutationOwnerTabId(mintMutationId('tab-abc'))).toBe('tab-abc');
20
+ });
21
+
22
+ it('returns null for a legacy numeric id instead of misrouting', () => {
23
+ expect(mutationOwnerTabId('_00_pending_mutations:1753875000000')).toBeNull();
24
+ });
25
+ });
@@ -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
+ }
@@ -22,6 +22,7 @@ import {
22
22
  parseBackendInfo,
23
23
  UNAVAILABLE,
24
24
  } from './versions';
25
+ import { walkOpfs, type StorageInfo } from './storage-info';
25
26
 
26
27
  // Real bundled frontend versions, injected at build time by tsdown's
27
28
  // version-define plugin (see tsdown.config.ts). The `typeof` guard keeps these
@@ -50,6 +51,13 @@ export class DevToolsService implements StreamUpdateReceiver {
50
51
  // (the on-demand GET_STATE pull) still works before the push channel turns on.
51
52
  private enabled = false;
52
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
+
53
61
  // Full local table list (incl. internal `_00_*`), enumerated from the local DB
54
62
  // via our own reliable `service.query` — the DevTools panel's page-eval query
55
63
  // bridge is unreliable under load, so the panel relies on this instead.
@@ -326,10 +334,78 @@ export class DevToolsService implements StreamUpdateReceiver {
326
334
  // Durability of the local store. `fallback: true` means persistence was
327
335
  // requested but the dataset is actually sitting in RAM.
328
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,
329
339
  },
330
340
  });
331
341
  }
332
342
 
343
+ /**
344
+ * Full storage diagnostics for the DevTools Storage tab. Every section is
345
+ * gathered independently and failures land in that section's `error` field,
346
+ * so one broken source (a mid-switch worker, a browser without OPFS) never
347
+ * blanks the whole panel.
348
+ */
349
+ public async getStorageInfo(opts?: { tableCounts?: boolean }): Promise<StorageInfo> {
350
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
351
+
352
+ const info: StorageInfo = {
353
+ at: Date.now(),
354
+ engine: {
355
+ kind: this.databaseService.engineKind ?? 'custom',
356
+ store: this.databaseService.getConfig()?.store ?? 'memory',
357
+ bucketId: this.databaseService.currentBucketId,
358
+ },
359
+ health: this.databaseService.storageHealth ?? { status: 'unknown', fallback: false },
360
+ browser: {},
361
+ opfs: { supported: false, entries: [], totalBytes: 0, truncated: false },
362
+ };
363
+
364
+ try {
365
+ if (nav?.storage?.estimate) {
366
+ const est = await nav.storage.estimate();
367
+ info.browser.usage = est.usage;
368
+ info.browser.quota = est.quota;
369
+ // Chrome-only per-storage-system breakdown; absent elsewhere.
370
+ const details = (est as any).usageDetails;
371
+ if (details && typeof details === 'object') info.browser.usageDetails = details;
372
+ }
373
+ if (nav?.storage?.persisted) {
374
+ info.browser.persisted = await nav.storage.persisted();
375
+ }
376
+ } catch (e) {
377
+ info.browser.error = e instanceof Error ? e.message : String(e);
378
+ }
379
+
380
+ info.opfs = await walkOpfs();
381
+
382
+ const stats = (globalThis as any).__sqliteStats;
383
+ if (stats && typeof stats === 'object') {
384
+ info.sqliteStats = { ...stats, byType: { ...(stats.byType ?? {}) } };
385
+ }
386
+
387
+ try {
388
+ info.engineDiagnostics = await this.databaseService.getStorageDiagnostics?.(opts);
389
+ } catch (e) {
390
+ this.logger.warn(
391
+ { err: e, Category: 'sp00ky-client::DevToolsService::getStorageInfo' },
392
+ 'Engine storage diagnostics failed'
393
+ );
394
+ }
395
+
396
+ return this.serializeForDevTools(info);
397
+ }
398
+
399
+ /** Ask the browser to exempt this origin's storage from eviction. */
400
+ public async requestPersistentStorage(): Promise<{ granted: boolean }> {
401
+ try {
402
+ const granted = (await navigator.storage?.persist?.()) ?? false;
403
+ return { granted };
404
+ } catch {
405
+ return { granted: false };
406
+ }
407
+ }
408
+
333
409
  private notifyDevTools() {
334
410
  // No consumer attached → no getState() serialization, no postMessage broadcast.
335
411
  if (!this.enabled) return;
@@ -383,6 +459,10 @@ export class DevToolsService implements StreamUpdateReceiver {
383
459
  const result: Record<string, any> = {};
384
460
  for (const key in data) {
385
461
  if (Object.prototype.hasOwnProperty.call(data, key)) {
462
+ // Skip absent optional fields: recursing them would emit the STRING
463
+ // 'undefined' (the top-level mapping below), which panels then have
464
+ // to filter back out (see 3d84fe8a).
465
+ if (data[key] === undefined) continue;
386
466
  result[key] = this.serializeForDevTools(data[key], seen);
387
467
  }
388
468
  }
@@ -402,6 +482,8 @@ export class DevToolsService implements StreamUpdateReceiver {
402
482
  this.notifyDevTools();
403
483
  },
404
484
  refreshVersions: () => this.refreshBackendVersions(),
485
+ getStorageInfo: (opts?: { tableCounts?: boolean }) => this.getStorageInfo(opts),
486
+ requestPersistentStorage: () => this.requestPersistentStorage(),
405
487
  getTableData: async (tableName: string) => {
406
488
  try {
407
489
  // Returns the first statement result as T.
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { walkOpfs } from './storage-info';
3
+
4
+ /** Minimal in-memory OPFS: directories are nested objects, files are numbers
5
+ * (their size) or 'locked' (getFile() throws, like a live SAHPool handle). */
6
+ type FakeTree = { [name: string]: FakeTree | number | 'locked' };
7
+
8
+ function makeDirHandle(tree: FakeTree): any {
9
+ return {
10
+ kind: 'directory',
11
+ entries: async function* () {
12
+ for (const [name, node] of Object.entries(tree)) {
13
+ if (typeof node === 'object') {
14
+ yield [name, makeDirHandle(node)];
15
+ } else {
16
+ yield [
17
+ name,
18
+ {
19
+ kind: 'file',
20
+ getFile: async () => {
21
+ if (node === 'locked') throw new DOMException('locked', 'NoModificationAllowedError');
22
+ return { size: node };
23
+ },
24
+ },
25
+ ];
26
+ }
27
+ }
28
+ },
29
+ };
30
+ }
31
+
32
+ function stubOpfs(tree: FakeTree | null) {
33
+ vi.stubGlobal('navigator', tree === null ? {} : {
34
+ storage: { getDirectory: async () => makeDirHandle(tree) },
35
+ });
36
+ }
37
+
38
+ afterEach(() => {
39
+ vi.unstubAllGlobals();
40
+ });
41
+
42
+ describe('walkOpfs', () => {
43
+ it('reports unsupported without OPFS APIs', async () => {
44
+ stubOpfs(null);
45
+ expect(await walkOpfs()).toEqual({
46
+ supported: false,
47
+ entries: [],
48
+ totalBytes: 0,
49
+ truncated: false,
50
+ });
51
+ });
52
+
53
+ it('walks recursively, sums readable sizes, and omits size for locked files', async () => {
54
+ stubOpfs({
55
+ '.sp00ky-anon': { '0000000001': 4096, '0000000002': 'locked' },
56
+ 'other.txt': 10,
57
+ });
58
+ const res = await walkOpfs();
59
+ expect(res.supported).toBe(true);
60
+ expect(res.truncated).toBe(false);
61
+ // Locked file present but without a size; total counts only readable bytes.
62
+ expect(res.totalBytes).toBe(4106);
63
+ expect(res.entries).toEqual([
64
+ { path: '.sp00ky-anon', kind: 'directory' },
65
+ { path: '.sp00ky-anon/0000000001', kind: 'file', size: 4096 },
66
+ { path: '.sp00ky-anon/0000000002', kind: 'file' },
67
+ { path: 'other.txt', kind: 'file', size: 10 },
68
+ ]);
69
+ });
70
+
71
+ it('caps the listing and flags truncation', async () => {
72
+ const big: FakeTree = {};
73
+ for (let i = 0; i < 10; i++) big[`f${i}`] = 1;
74
+ stubOpfs(big);
75
+ const res = await walkOpfs(5);
76
+ expect(res.truncated).toBe(true);
77
+ expect(res.entries.length).toBe(5);
78
+ });
79
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Storage diagnostics for the DevTools Storage tab: what engine backs the
3
+ * local cache, whether it actually persists, how much of the device's quota
4
+ * the origin uses, and what is physically sitting in OPFS. Assembled by
5
+ * `DevToolsService.getStorageInfo()`; everything here is JSON-safe.
6
+ */
7
+
8
+ export interface OpfsEntry {
9
+ /** Path relative to the OPFS root, e.g. `.sp00ky-anon/0000000000000001`. */
10
+ path: string;
11
+ kind: 'file' | 'directory';
12
+ /** Absent when the file's size can't be read (e.g. an exclusive sync access
13
+ * handle is held on it — exactly the case during SAHPool contention). */
14
+ size?: number;
15
+ }
16
+
17
+ /** Engine-side numbers only the engine can produce (worker round-trips). */
18
+ export interface EngineStorageDiagnostics {
19
+ engine: 'sqlite';
20
+ bucketId: string;
21
+ useOpfs: boolean;
22
+ workerSelectConfigured: boolean;
23
+ /** `false` while configured `true` means the runtime downgraded to the
24
+ * legacy multi-hop select (stale cached worker bundle). */
25
+ workerSelectEffective: boolean;
26
+ /** page_count * page_size. */
27
+ dbSizeBytes?: number;
28
+ /** freelist_count * page_size — reclaimable via VACUUM. */
29
+ freelistBytes?: number;
30
+ tableCounts?: { table: string; rows: number }[];
31
+ error?: string;
32
+ }
33
+
34
+ export interface StorageInfo {
35
+ at: number;
36
+ engine: { kind: 'surrealdb' | 'sqlite' | 'custom'; store: string; bucketId: string };
37
+ health: { status: 'unknown' | 'persistent' | 'memory'; fallback: boolean; error?: string };
38
+ browser: {
39
+ /** `navigator.storage.persisted()` — whether the origin's storage is
40
+ * exempt from eviction (unrelated to the OPFS pool lock). */
41
+ persisted?: boolean;
42
+ usage?: number;
43
+ quota?: number;
44
+ /** Chrome-only per-system breakdown from `estimate()`. */
45
+ usageDetails?: Record<string, number>;
46
+ error?: string;
47
+ };
48
+ opfs: {
49
+ supported: boolean;
50
+ entries: OpfsEntry[];
51
+ totalBytes: number;
52
+ truncated: boolean;
53
+ error?: string;
54
+ };
55
+ /** Snapshot of `globalThis.__sqliteStats` (SQLite engine only). */
56
+ sqliteStats?: Record<string, unknown>;
57
+ engineDiagnostics?: EngineStorageDiagnostics;
58
+ }
59
+
60
+ /**
61
+ * Recursively list the origin's OPFS. Sizes come from `handle.getFile()`,
62
+ * which throws for a file another context holds an exclusive sync access
63
+ * handle on — SAHPool does exactly that for its whole pool, so a locked file
64
+ * (size omitted) is a live "who has the pool" signal, not a failure.
65
+ */
66
+ export async function walkOpfs(maxEntries = 2000, maxDepth = 8): Promise<StorageInfo['opfs']> {
67
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
68
+ if (!nav?.storage?.getDirectory) {
69
+ return { supported: false, entries: [], totalBytes: 0, truncated: false };
70
+ }
71
+ const entries: OpfsEntry[] = [];
72
+ let totalBytes = 0;
73
+ let truncated = false;
74
+ try {
75
+ const root = await nav.storage.getDirectory();
76
+ const walk = async (dir: FileSystemDirectoryHandle, prefix: string, depth: number) => {
77
+ if (depth > maxDepth) return;
78
+ // entries() is standard; older lib.dom typings may lack it.
79
+ for await (const [name, handle] of (dir as any).entries() as AsyncIterable<
80
+ [string, FileSystemHandle]
81
+ >) {
82
+ if (entries.length >= maxEntries) {
83
+ truncated = true;
84
+ return;
85
+ }
86
+ const path = prefix ? `${prefix}/${name}` : name;
87
+ if (handle.kind === 'directory') {
88
+ entries.push({ path, kind: 'directory' });
89
+ await walk(handle as FileSystemDirectoryHandle, path, depth + 1);
90
+ } else {
91
+ let size: number | undefined;
92
+ try {
93
+ size = (await (handle as FileSystemFileHandle).getFile()).size;
94
+ totalBytes += size;
95
+ } catch {
96
+ // Locked by an exclusive access handle (e.g. a live SAHPool).
97
+ }
98
+ const entry: OpfsEntry = { path, kind: 'file' };
99
+ if (size !== undefined) entry.size = size;
100
+ entries.push(entry);
101
+ }
102
+ }
103
+ };
104
+ await walk(root, '', 0);
105
+ entries.sort((a, b) => a.path.localeCompare(b.path));
106
+ return { supported: true, entries, totalBytes, truncated };
107
+ } catch (e) {
108
+ return {
109
+ supported: true,
110
+ entries,
111
+ totalBytes,
112
+ truncated,
113
+ error: e instanceof Error ? e.message : String(e),
114
+ };
115
+ }
116
+ }
@@ -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,