@spooky-sync/core 0.0.1-canary.204 → 0.0.1-canary.206
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 +172 -44
- package/dist/index.js +636 -189
- package/dist/tabs-broker-worker.js +51 -13
- package/dist/types.d.ts +60 -13
- package/package.json +3 -3
- package/src/modules/cache/index.ts +21 -4
- package/src/modules/data/data.membership.test.ts +62 -1
- package/src/modules/data/data.rebind.test.ts +23 -0
- package/src/modules/data/index.ts +57 -17
- package/src/modules/devtools/index.ts +9 -1
- package/src/modules/sync/engine.ts +76 -58
- package/src/modules/sync/sync.ts +65 -8
- package/src/services/database/cache-engine.ts +33 -0
- package/src/services/database/sqlite-cache-engine.test.ts +58 -0
- package/src/services/database/sqlite-cache-engine.ts +71 -1
- package/src/services/database/surreal-cache-engine.ts +20 -0
- package/src/services/stream-processor/index.ts +471 -225
- package/src/services/stream-processor/stream-processor.batch.test.ts +3 -13
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +77 -67
- package/src/services/stream-processor/wasm-types.ts +32 -1
- package/src/services/tabs/broker.test.ts +49 -0
- package/src/services/tabs/coordinator.test.ts +6 -1
- package/src/services/tabs/coordinator.ts +35 -3
- package/src/services/tabs/tabs-broker-worker.ts +66 -12
- package/src/sp00ky.ts +54 -8
- package/src/types.ts +29 -13
|
@@ -8,6 +8,9 @@ import { SyncEventTypes, createSyncEventSystem } from './events/index';
|
|
|
8
8
|
import { encodeRecordId } from '../../utils/index';
|
|
9
9
|
import { cleanRecord } from '../../utils/parser';
|
|
10
10
|
|
|
11
|
+
/** Ids per remote `SELECT * FROM $ids` round trip. */
|
|
12
|
+
const FETCH_CHUNK = 500;
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* SyncEngine handles the core sync operations: fetching remote records,
|
|
13
16
|
* caching them locally, and ingesting into DBSP.
|
|
@@ -55,10 +58,16 @@ export class SyncEngine {
|
|
|
55
58
|
stillRemoteIds = await this.handleRemovedRecords(removed);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
// Fetch added/updated records from remote
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// Fetch added/updated records from remote. Skip ids whose body the local
|
|
62
|
+
// store already holds at this version (the circuit prime seeded the memo
|
|
63
|
+
// from `_00_rv`): after a reload every id in the server's list_ref used to
|
|
64
|
+
// classify as `added` against an empty circuit, and this fetched the
|
|
65
|
+
// whole working set again.
|
|
66
|
+
const toFetch = [...added, ...updated].filter((item) => {
|
|
67
|
+
const localVersion = this.cache.lookup(encodeRecordId(item.id));
|
|
68
|
+
return !(localVersion && item.version <= localVersion);
|
|
69
|
+
});
|
|
70
|
+
if (toFetch.length === 0) {
|
|
62
71
|
return { remoteFetchMs: 0, stillRemoteIds };
|
|
63
72
|
}
|
|
64
73
|
|
|
@@ -67,66 +76,75 @@ export class SyncEngine {
|
|
|
67
76
|
for (const item of toFetch) {
|
|
68
77
|
versionMap.set(encodeRecordId(item.id), item.version);
|
|
69
78
|
}
|
|
79
|
+
const addedIds = new Set(added.map((item) => encodeRecordId(item.id)));
|
|
70
80
|
|
|
71
81
|
// Fetch records from remote — avoid SELECT *, <subquery> FROM $param
|
|
72
82
|
// pattern which drops the * fields in SurrealDB v3 (known bug).
|
|
73
83
|
// Versions are already known from the diff's list_ref data.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
84
|
+
//
|
|
85
|
+
// Chunked so a genuinely cold load lands progressively: one response
|
|
86
|
+
// holding thousands of bodies, then one transaction MERGEing all of them,
|
|
87
|
+
// was a single main-thread stall and a memory spike on top of it.
|
|
88
|
+
let remoteFetchMs = 0;
|
|
89
|
+
const remoteResults: RecordWithId[] = [];
|
|
90
|
+
for (let i = 0; i < toFetch.length; i += FETCH_CHUNK) {
|
|
91
|
+
const idsToFetch = toFetch.slice(i, i + FETCH_CHUNK).map((x) => x.id);
|
|
92
|
+
const remoteFetchStart = performance.now();
|
|
93
|
+
const [chunkResults] = await this.remote.query<[RecordWithId[]]>(
|
|
94
|
+
'SELECT * FROM $idsToFetch',
|
|
95
|
+
{ idsToFetch }
|
|
96
|
+
);
|
|
97
|
+
remoteFetchMs += performance.now() - remoteFetchStart;
|
|
98
|
+
|
|
99
|
+
// Prepare batch for cache (which handles both DB and DBSP)
|
|
100
|
+
const cacheBatch: CacheRecord[] = [];
|
|
101
|
+
for (const record of chunkResults ?? []) {
|
|
102
|
+
if (!record?.id) {
|
|
103
|
+
this.logger.warn(
|
|
104
|
+
{
|
|
105
|
+
record,
|
|
106
|
+
idsToFetch,
|
|
107
|
+
Category: 'sp00ky-client::SyncEngine::syncRecords',
|
|
108
|
+
},
|
|
109
|
+
'Remote record has no id (possibly deleted). Skipping record'
|
|
110
|
+
);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
remoteResults.push(record);
|
|
114
|
+
const fullId = encodeRecordId(record.id);
|
|
115
|
+
const table = record.id.table.toString();
|
|
116
|
+
const version = versionMap.get(fullId) ?? 0;
|
|
117
|
+
|
|
118
|
+
const localVersion = this.cache.lookup(fullId);
|
|
119
|
+
if (localVersion && version <= localVersion) {
|
|
120
|
+
this.logger.info(
|
|
121
|
+
{
|
|
122
|
+
recordId: fullId,
|
|
123
|
+
version,
|
|
124
|
+
localVersion,
|
|
125
|
+
Category: 'sp00ky-client::SyncEngine::syncRecords',
|
|
126
|
+
},
|
|
127
|
+
'Local version is higher than remote version. Skipping record'
|
|
128
|
+
);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
132
|
+
const cleanedRecord = tableSchema
|
|
133
|
+
? cleanRecord(tableSchema.columns, record)
|
|
134
|
+
: record;
|
|
135
|
+
|
|
136
|
+
cacheBatch.push({
|
|
137
|
+
table,
|
|
138
|
+
op: addedIds.has(fullId) ? 'CREATE' : 'UPDATE',
|
|
139
|
+
record: cleanedRecord as RecordWithId,
|
|
140
|
+
version,
|
|
141
|
+
});
|
|
113
142
|
}
|
|
114
|
-
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
115
|
-
const cleanedRecord = tableSchema
|
|
116
|
-
? cleanRecord(tableSchema.columns, record)
|
|
117
|
-
: record;
|
|
118
|
-
|
|
119
|
-
cacheBatch.push({
|
|
120
|
-
table,
|
|
121
|
-
op: isAdded ? 'CREATE' : 'UPDATE',
|
|
122
|
-
record: cleanedRecord as RecordWithId,
|
|
123
|
-
version,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
143
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
// Use CacheModule to handle both local DB and DBSP ingestion
|
|
145
|
+
if (cacheBatch.length > 0) {
|
|
146
|
+
await this.cache.saveBatch(cacheBatch);
|
|
147
|
+
}
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
this.events.emit(SyncEventTypes.RemoteDataIngested, {
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -527,14 +527,40 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
527
527
|
this.tabId = tabId;
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
-
/**
|
|
531
|
-
*
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
530
|
+
/** In-flight {@link resumeLeaderDuties}, so a second call joins the first
|
|
531
|
+
* instead of double-draining the outbox. */
|
|
532
|
+
private leaderDutiesInFlight: Promise<void> | null = null;
|
|
533
|
+
|
|
534
|
+
/** Resolves once the in-browser circuit has been primed from the local
|
|
535
|
+
* store. Every sync diff waits on it: diffing against an empty circuit
|
|
536
|
+
* classifies the whole working set as missing and re-downloads it. */
|
|
537
|
+
private primeGate: () => Promise<void> = () => Promise.resolve();
|
|
538
|
+
/** The prime we last waited on. `whenPrimed` hands out one promise per
|
|
539
|
+
* prime, so a new identity means a new prime (boot, bucket switch) ran. */
|
|
540
|
+
private settledPrime: Promise<void> | null = null;
|
|
541
|
+
|
|
542
|
+
setPrimeGate(gate: () => Promise<void>): void {
|
|
543
|
+
this.primeGate = gate;
|
|
544
|
+
this.settledPrime = null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Leader WIRING only, and deliberately synchronous.
|
|
549
|
+
*
|
|
550
|
+
* The coordinator publishes the leader role and tells the broker
|
|
551
|
+
* `leader-ready` the moment the store is adopted, and the broker can mint a
|
|
552
|
+
* follower's ports on the very next tick. So the follower-message handler
|
|
553
|
+
* has to be live before this returns, or a mutation forwarded in that window
|
|
554
|
+
* is dropped. Everything that can block (outbox reload, LIVE restart) moved
|
|
555
|
+
* to {@link resumeLeaderDuties}: a promotion that waits on the network holds
|
|
556
|
+
* `leader-ready` back, and a broker whose leader never reports ready serves
|
|
557
|
+
* no follower ports and re-elects no one, which wedges the whole namespace.
|
|
558
|
+
*/
|
|
559
|
+
public promoteToLeader(hub: LeaderSyncHub): void {
|
|
535
560
|
this.tabRole = 'leader';
|
|
536
561
|
this.hub = hub;
|
|
537
562
|
this.forwarder = null;
|
|
563
|
+
this.leaderDutiesInFlight = null;
|
|
538
564
|
hub.onFollowerMessage = (tabId, msg) => {
|
|
539
565
|
void tabId;
|
|
540
566
|
switch (msg.type) {
|
|
@@ -548,19 +574,34 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
548
574
|
break;
|
|
549
575
|
}
|
|
550
576
|
};
|
|
551
|
-
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Leader duties: drain the shared outbox, own the single list_ref LIVE,
|
|
580
|
+
* relay LIVE events and rollbacks to followers. Idempotent for a boot-time
|
|
581
|
+
* leader; a runtime promotion (failover) reloads the outbox, which now
|
|
582
|
+
* holds EVERY tab's rows, and restarts LIVE under this session. Runs in the
|
|
583
|
+
* background off the promotion path, so however long it takes (or if it
|
|
584
|
+
* never finishes) the tab is already a working leader. */
|
|
585
|
+
public resumeLeaderDuties(): Promise<void> {
|
|
586
|
+
if (this.leaderDutiesInFlight) return this.leaderDutiesInFlight;
|
|
587
|
+
const run = (async () => {
|
|
588
|
+
if (!this.isInit || this.tabRole !== 'leader') return;
|
|
552
589
|
await this.upQueue.loadFromDatabase();
|
|
553
590
|
void this.scheduler.syncUp();
|
|
554
591
|
if (this.currentUserId || this.anonLiveEnabled) {
|
|
555
592
|
this.startListRefPoll();
|
|
556
593
|
await this.restartRefLiveQuery().catch((err) => {
|
|
557
594
|
this.logger.warn(
|
|
558
|
-
{ err, Category: 'sp00ky-client::Sp00kySync::
|
|
595
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::resumeLeaderDuties' },
|
|
559
596
|
'LIVE restart failed on promotion; poll fallback covers it'
|
|
560
597
|
);
|
|
561
598
|
});
|
|
562
599
|
}
|
|
563
|
-
}
|
|
600
|
+
})();
|
|
601
|
+
this.leaderDutiesInFlight = run;
|
|
602
|
+
return run.finally(() => {
|
|
603
|
+
if (this.leaderDutiesInFlight === run) this.leaderDutiesInFlight = null;
|
|
604
|
+
});
|
|
564
605
|
}
|
|
565
606
|
|
|
566
607
|
/** Follower duties: no outbox drain, no LIVE. Mutations forward to the
|
|
@@ -570,6 +611,9 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
570
611
|
this.tabRole = 'follower';
|
|
571
612
|
this.hub = null;
|
|
572
613
|
this.forwarder = forwarder;
|
|
614
|
+
// A still-running resumeLeaderDuties self-cancels on its `tabRole` guard;
|
|
615
|
+
// drop the handle so a later re-promotion starts a fresh drain.
|
|
616
|
+
this.leaderDutiesInFlight = null;
|
|
573
617
|
void this.killRefLiveQuery();
|
|
574
618
|
forwarder.onLeaderMessage = (msg) => {
|
|
575
619
|
switch (msg.type) {
|
|
@@ -1531,6 +1575,19 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1531
1575
|
* means the single resulting UI update lands after this completes.
|
|
1532
1576
|
*/
|
|
1533
1577
|
private async runSyncForQuery(hash: string, diff: RecordVersionDiff): Promise<void> {
|
|
1578
|
+
// The diff was computed against whatever the circuit held at call time; if
|
|
1579
|
+
// the boot prime is still filling it, wait and recompute from the primed
|
|
1580
|
+
// `localArray` so the delta is real rather than "everything".
|
|
1581
|
+
const prime = this.primeGate();
|
|
1582
|
+
if (prime !== this.settledPrime) {
|
|
1583
|
+
await prime;
|
|
1584
|
+
this.settledPrime = prime;
|
|
1585
|
+
const fresh = this.dataModule.getQueryByHash(hash);
|
|
1586
|
+
if (!fresh) return;
|
|
1587
|
+
const recomputed = new ArraySyncer(fresh.config.localArray, fresh.config.remoteArray).nextSet();
|
|
1588
|
+
if (!recomputed) return;
|
|
1589
|
+
diff = recomputed;
|
|
1590
|
+
}
|
|
1534
1591
|
// Don't let sync re-add a record the user just deleted locally. The remote
|
|
1535
1592
|
// delete is queued in the outbox, so until it's processed the server's
|
|
1536
1593
|
// `_00_list_ref` still lists the record — the diff then classifies it as
|
|
@@ -138,6 +138,39 @@ export interface LocalStore extends LocalCacheEngine {
|
|
|
138
138
|
/** Fires immediately with the current snapshot, then on every change.
|
|
139
139
|
* Returns an unsubscribe function. */
|
|
140
140
|
subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
|
|
141
|
+
/**
|
|
142
|
+
* Every cached row's `(id, _00_rv)` per table, ids in stable `table:id`
|
|
143
|
+
* form. The in-browser circuit primes and reconciles itself from this on
|
|
144
|
+
* boot instead of re-downloading the working set. OPTIONAL: an engine
|
|
145
|
+
* without it boots the circuit empty, as before.
|
|
146
|
+
*/
|
|
147
|
+
scanVersions?(tables: string[]): Promise<Record<string, [string, number][]>>;
|
|
148
|
+
/**
|
|
149
|
+
* Circuit snapshot storage, keyed. Lives in the same durable store as the
|
|
150
|
+
* rows (OPFS SQLite), so it is per-bucket by construction, atomic, and
|
|
151
|
+
* readable by follower tabs over the port transport. OPTIONAL: an engine
|
|
152
|
+
* without it primes from `scanVersions` alone.
|
|
153
|
+
*/
|
|
154
|
+
getSnapshot?(key: string): Promise<StoredSnapshot | null>;
|
|
155
|
+
putSnapshot?(key: string, bytes: Uint8Array, meta: SnapshotMeta): Promise<void>;
|
|
156
|
+
deleteSnapshot?(key: string): Promise<void>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Describes a stored circuit snapshot; what decides whether it is usable. */
|
|
160
|
+
export interface SnapshotMeta {
|
|
161
|
+
/** Bump when the wire shape the circuit reads changes. */
|
|
162
|
+
formatVersion: number;
|
|
163
|
+
/** Hash of the schema the rows were projected under. */
|
|
164
|
+
schemaHash: string;
|
|
165
|
+
savedAt: number;
|
|
166
|
+
/** Highest `_00_rv` per table at save time (diagnostics). */
|
|
167
|
+
maxRv?: Record<string, number>;
|
|
168
|
+
[key: string]: unknown;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface StoredSnapshot {
|
|
172
|
+
bytes: Uint8Array;
|
|
173
|
+
meta: SnapshotMeta;
|
|
141
174
|
}
|
|
142
175
|
|
|
143
176
|
/** Selected local cache backend. Mirrors the `persistenceClient` config pattern. */
|
|
@@ -556,3 +556,61 @@ describe('create() tx result shaping (fast path parity)', () => {
|
|
|
556
556
|
expect(outboxRow.data).toEqual(payload);
|
|
557
557
|
});
|
|
558
558
|
});
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
// The circuit snapshot + version scan the boot-time prime reads through this
|
|
562
|
+
// engine. Bytes and meta must round-trip as ONE batch (a reader must never see
|
|
563
|
+
// new bytes with old meta) and a table that does not exist yet scans as empty.
|
|
564
|
+
describe('SqliteCacheEngine circuit snapshot + scanVersions', () => {
|
|
565
|
+
it('stores bytes and meta in one batch and reads them back', async () => {
|
|
566
|
+
const stored = new Map<string, unknown>();
|
|
567
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
568
|
+
const types: string[] = [];
|
|
569
|
+
stubTransport(engine, (type: string, payload: any) => {
|
|
570
|
+
types.push(type);
|
|
571
|
+
if (type === 'open') return { persisted: true };
|
|
572
|
+
if (type === 'batch') {
|
|
573
|
+
for (const stmt of payload as { sql: string; bind: unknown[] }[]) {
|
|
574
|
+
stored.set(stmt.bind[0] as string, stmt.bind[1]);
|
|
575
|
+
}
|
|
576
|
+
return {};
|
|
577
|
+
}
|
|
578
|
+
if (type === 'exec') {
|
|
579
|
+
const ids = (payload.bind ?? []) as string[];
|
|
580
|
+
return { rows: ids.filter((id) => stored.has(id)).map((id) => ({ id, data: stored.get(id) })) };
|
|
581
|
+
}
|
|
582
|
+
return {};
|
|
583
|
+
});
|
|
584
|
+
await engine.connect('anon');
|
|
585
|
+
|
|
586
|
+
expect(await engine.getSnapshot('circuit')).toBeNull();
|
|
587
|
+
const bytes = new Uint8Array([1, 2, 3]);
|
|
588
|
+
await engine.putSnapshot('circuit', bytes, { formatVersion: 1, schemaHash: 'h', savedAt: 5 });
|
|
589
|
+
expect(types.filter((t) => t === 'batch')).toHaveLength(1);
|
|
590
|
+
const snap = await engine.getSnapshot('circuit');
|
|
591
|
+
expect(snap?.bytes).toEqual(bytes);
|
|
592
|
+
expect(snap?.meta).toEqual({ formatVersion: 1, schemaHash: 'h', savedAt: 5 });
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('scans (id, rv) per table and reads a missing table as empty', async () => {
|
|
596
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
597
|
+
stubTransport(engine, (type: string, payload: any) => {
|
|
598
|
+
if (type === 'open') return { persisted: true };
|
|
599
|
+
if (type === 'exec') {
|
|
600
|
+
if (String(payload.sql).includes('"thing"')) {
|
|
601
|
+
return { rows: [{ id: 'thing:a', rv: 3 }, { id: 'thing:b', rv: null }] };
|
|
602
|
+
}
|
|
603
|
+
throw new Error('no such table');
|
|
604
|
+
}
|
|
605
|
+
return {};
|
|
606
|
+
});
|
|
607
|
+
await engine.connect('anon');
|
|
608
|
+
expect(await engine.scanVersions(['thing', 'ghost'])).toEqual({
|
|
609
|
+
thing: [
|
|
610
|
+
['thing:a', 3],
|
|
611
|
+
['thing:b', 0],
|
|
612
|
+
],
|
|
613
|
+
ghost: [],
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from './sqlite-plan-sql';
|
|
11
11
|
import type { Logger } from '../logger/index';
|
|
12
12
|
import type { Sp00kyConfig, StorageHealth } from '../../types';
|
|
13
|
+
import type { SnapshotMeta, StoredSnapshot } from './cache-engine';
|
|
13
14
|
import type { SealedQuery } from '../../utils/surql';
|
|
14
15
|
import { resolveRelations, stableKey } from './relation-resolver';
|
|
15
16
|
import {
|
|
@@ -50,8 +51,13 @@ import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-in
|
|
|
50
51
|
* on a fresh bucket throws "no such table". Keep in sync with the systemSchema
|
|
51
52
|
* block in `local-migrator.ts`.
|
|
52
53
|
*/
|
|
54
|
+
const SNAPSHOT_TABLE = '_00_circuit_snapshot';
|
|
55
|
+
|
|
53
56
|
const SYSTEM_TABLES = [
|
|
54
57
|
'_00_stream_processor_state',
|
|
58
|
+
// In-browser circuit snapshot: one BLOB row (`circuit`) + a JSON meta row.
|
|
59
|
+
// Excluded from DevTools' table listing, since its data column is not JSON.
|
|
60
|
+
'_00_circuit_snapshot',
|
|
55
61
|
'_00_query',
|
|
56
62
|
'_00_preload',
|
|
57
63
|
'_00_window',
|
|
@@ -197,7 +203,7 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
197
203
|
const { rows: tables } = await this.call<{ rows: { name: string }[] }>('exec', {
|
|
198
204
|
sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
|
|
199
205
|
});
|
|
200
|
-
const names = (tables ?? []).map((r) => r.name);
|
|
206
|
+
const names = (tables ?? []).map((r) => r.name).filter((n) => n !== SNAPSHOT_TABLE);
|
|
201
207
|
if (names.length) {
|
|
202
208
|
// Names come from sqlite_master itself; double-quoting is enough.
|
|
203
209
|
const sql = names
|
|
@@ -837,6 +843,70 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
837
843
|
return rows[0] ?? null;
|
|
838
844
|
}
|
|
839
845
|
|
|
846
|
+
/**
|
|
847
|
+
* `(id, _00_rv)` of every row per table, straight off the worker: no body
|
|
848
|
+
* parse, one round trip per table. A table that does not exist yet reads as
|
|
849
|
+
* empty rather than failing the whole scan.
|
|
850
|
+
*/
|
|
851
|
+
async scanVersions(tables: string[]): Promise<Record<string, [string, number][]>> {
|
|
852
|
+
const out: Record<string, [string, number][]> = {};
|
|
853
|
+
for (const table of tables) {
|
|
854
|
+
try {
|
|
855
|
+
const { rows } = await this.call<{ rows: { id: string; rv: unknown }[] }>('exec', {
|
|
856
|
+
sql: `SELECT id, json_extract(data, '$._00_rv') AS rv FROM "${table.replace(/"/g, '""')}"`,
|
|
857
|
+
});
|
|
858
|
+
out[table] = (rows ?? []).map((r) => [r.id, Number(r.rv) || 0]);
|
|
859
|
+
} catch {
|
|
860
|
+
out[table] = [];
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return out;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async getSnapshot(key: string): Promise<StoredSnapshot | null> {
|
|
867
|
+
const { rows } = await this.call<{ rows: { id: string; data: unknown }[] }>('exec', {
|
|
868
|
+
sql: `SELECT id, data FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,
|
|
869
|
+
bind: [key, `${key}:meta`],
|
|
870
|
+
});
|
|
871
|
+
let bytes: Uint8Array | null = null;
|
|
872
|
+
let meta: SnapshotMeta | null = null;
|
|
873
|
+
for (const r of rows ?? []) {
|
|
874
|
+
if (r.id === key) {
|
|
875
|
+
if (r.data instanceof Uint8Array) bytes = r.data;
|
|
876
|
+
else if (r.data instanceof ArrayBuffer) bytes = new Uint8Array(r.data);
|
|
877
|
+
else if (typeof r.data === 'string') bytes = new TextEncoder().encode(r.data);
|
|
878
|
+
} else if (typeof r.data === 'string') {
|
|
879
|
+
try {
|
|
880
|
+
meta = JSON.parse(r.data) as SnapshotMeta;
|
|
881
|
+
} catch {
|
|
882
|
+
meta = null;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return bytes && meta ? { bytes, meta } : null;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async putSnapshot(key: string, bytes: Uint8Array, meta: SnapshotMeta): Promise<void> {
|
|
890
|
+
// One transaction, so a reader never sees new bytes with old meta.
|
|
891
|
+
await this.call('batch', [
|
|
892
|
+
{
|
|
893
|
+
sql: `INSERT OR REPLACE INTO "${SNAPSHOT_TABLE}" (id, data) VALUES (?, ?)`,
|
|
894
|
+
bind: [key, bytes],
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
sql: `INSERT OR REPLACE INTO "${SNAPSHOT_TABLE}" (id, data) VALUES (?, ?)`,
|
|
898
|
+
bind: [`${key}:meta`, JSON.stringify(meta)],
|
|
899
|
+
},
|
|
900
|
+
]);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async deleteSnapshot(key: string): Promise<void> {
|
|
904
|
+
await this.call('run', {
|
|
905
|
+
sql: `DELETE FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,
|
|
906
|
+
bind: [key, `${key}:meta`],
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
840
910
|
// ---- writes --------------------------------------------------------------
|
|
841
911
|
|
|
842
912
|
async upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void> {
|
|
@@ -99,6 +99,26 @@ export class SurrealCacheEngine extends LocalDatabaseService implements LocalCac
|
|
|
99
99
|
return new RecordId(table, raw);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* `(id, _00_rv)` of every row per table. Runs on the main thread against the
|
|
104
|
+
* embedded engine (no worker here), which is why the circuit prime caps how
|
|
105
|
+
* much it is willing to read through this path.
|
|
106
|
+
*/
|
|
107
|
+
async scanVersions(tables: string[]): Promise<Record<string, [string, number][]>> {
|
|
108
|
+
const out: Record<string, [string, number][]> = {};
|
|
109
|
+
for (const table of tables) {
|
|
110
|
+
try {
|
|
111
|
+
const [rows] = await this.query<[{ id: Id; _00_rv?: unknown }[]]>(
|
|
112
|
+
`SELECT id, _00_rv FROM ${table};`
|
|
113
|
+
);
|
|
114
|
+
out[table] = (rows ?? []).map((r) => [stableKey(r.id), Number(r._00_rv) || 0]);
|
|
115
|
+
} catch {
|
|
116
|
+
out[table] = [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
102
122
|
async getById(table: string, id: Id): Promise<Row | null> {
|
|
103
123
|
const [row] = await this.query<[Row | null]>('SELECT * FROM ONLY $__id;', {
|
|
104
124
|
__id: this.toRecordId(table, id),
|