@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.
- package/dist/index.d.ts +151 -0
- package/dist/index.js +1694 -126
- 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 +55 -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 +82 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +116 -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/cache-engine.ts +8 -0
- package/src/services/database/engine-factory.ts +3 -2
- package/src/services/database/sqlite-cache-engine.test.ts +214 -85
- package/src/services/database/sqlite-cache-engine.ts +345 -76
- 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/database/surreal-cache-engine.ts +2 -0
- 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,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 {
|
|
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,
|