@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
package/src/modules/sync/sync.ts
CHANGED
|
@@ -22,6 +22,9 @@ import type { CacheModule } from '../cache/index';
|
|
|
22
22
|
import type { DataModule } from '../data/index';
|
|
23
23
|
import { classifySyncError, encodeRecordId, extractIdPart, extractTablePart, surql } from '../../utils/index';
|
|
24
24
|
import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
|
|
25
|
+
import { mutationOwnerTabId } from '../data/mutation-id';
|
|
26
|
+
import type { LeaderSyncHub, SyncForwarder } from '../../services/tabs/coordinator';
|
|
27
|
+
import { parseRecordIdString } from '../../utils/index';
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* Tunables for `Sp00kySync` construction.
|
|
@@ -75,6 +78,15 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
75
78
|
// subscription in `Sp00kyClient`; null when unauthenticated.
|
|
76
79
|
private currentUserId: string | null = null;
|
|
77
80
|
|
|
81
|
+
// ---- shared-tabs role state ----
|
|
82
|
+
// Followers keep their own remote WS (registration, per-query sync, poll)
|
|
83
|
+
// but must never drain the shared outbox or hold a second list_ref LIVE.
|
|
84
|
+
// The leader relays its LIVE events and routes rollbacks by mutation owner.
|
|
85
|
+
private tabRole: 'solo' | 'leader' | 'follower' = 'solo';
|
|
86
|
+
private tabId: string | null = null;
|
|
87
|
+
private hub: LeaderSyncHub | null = null;
|
|
88
|
+
private forwarder: SyncForwarder | null = null;
|
|
89
|
+
|
|
78
90
|
private refMode: RefMode = DEFAULT_REF_MODE;
|
|
79
91
|
|
|
80
92
|
// When true, an unauthenticated client still runs the `_00_list_ref` poll
|
|
@@ -366,7 +378,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
366
378
|
public async init() {
|
|
367
379
|
if (this.isInit) throw new Error('Sp00kySync is already initialized');
|
|
368
380
|
this.isInit = true;
|
|
369
|
-
await this.scheduler.init();
|
|
381
|
+
await this.scheduler.init({ loadOutbox: this.tabRole !== 'follower' });
|
|
370
382
|
this.subscribeToReconnect();
|
|
371
383
|
void this.scheduler.syncUp();
|
|
372
384
|
void this.scheduler.syncDown();
|
|
@@ -393,6 +405,116 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
393
405
|
}
|
|
394
406
|
}
|
|
395
407
|
|
|
408
|
+
// ---- shared-tabs roles ------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
/** Set BEFORE init(): shapes what init boots (a follower loads no outbox and
|
|
411
|
+
* never starts LIVE; its own registration/poll paths stay untouched). */
|
|
412
|
+
public setTabContext(role: 'solo' | 'leader' | 'follower', tabId: string | null): void {
|
|
413
|
+
this.tabRole = role;
|
|
414
|
+
this.tabId = tabId;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Leader duties: drain the shared outbox, own the single list_ref LIVE,
|
|
418
|
+
* relay LIVE events and rollbacks to followers via `hub`. Idempotent for a
|
|
419
|
+
* boot-time leader; a runtime promotion (failover) reloads the outbox,
|
|
420
|
+
* which now holds EVERY tab's rows, and restarts LIVE under this session. */
|
|
421
|
+
public async promoteToLeader(hub: LeaderSyncHub): Promise<void> {
|
|
422
|
+
this.tabRole = 'leader';
|
|
423
|
+
this.hub = hub;
|
|
424
|
+
this.forwarder = null;
|
|
425
|
+
hub.onFollowerMessage = (tabId, msg) => {
|
|
426
|
+
void tabId;
|
|
427
|
+
switch (msg.type) {
|
|
428
|
+
case 'sync-hello':
|
|
429
|
+
break;
|
|
430
|
+
case 'mutation-enqueued':
|
|
431
|
+
void this.enqueueForwardedMutation(msg.mutationId);
|
|
432
|
+
break;
|
|
433
|
+
case 'request-poll':
|
|
434
|
+
this.listRefIdleStreak = 0;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
if (this.isInit) {
|
|
439
|
+
await this.upQueue.loadFromDatabase();
|
|
440
|
+
void this.scheduler.syncUp();
|
|
441
|
+
if (this.currentUserId || this.anonLiveEnabled) {
|
|
442
|
+
this.startListRefPoll();
|
|
443
|
+
await this.restartRefLiveQuery().catch((err) => {
|
|
444
|
+
this.logger.warn(
|
|
445
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::promoteToLeader' },
|
|
446
|
+
'LIVE restart failed on promotion; poll fallback covers it'
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Follower duties: no outbox drain, no LIVE. Mutations forward to the
|
|
454
|
+
* leader; everything else (registration, per-query sync, poll) runs
|
|
455
|
+
* against this tab's own remote session as usual. */
|
|
456
|
+
public demoteToFollower(forwarder: SyncForwarder): void {
|
|
457
|
+
this.tabRole = 'follower';
|
|
458
|
+
this.hub = null;
|
|
459
|
+
this.forwarder = forwarder;
|
|
460
|
+
void this.killRefLiveQuery();
|
|
461
|
+
forwarder.onLeaderMessage = (msg) => {
|
|
462
|
+
switch (msg.type) {
|
|
463
|
+
case 'list-ref-change':
|
|
464
|
+
void this.applyRelayedListRefChange(msg).catch((err) => {
|
|
465
|
+
this.logger.error(
|
|
466
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::relay' },
|
|
467
|
+
'Relayed list_ref change failed'
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
break;
|
|
471
|
+
case 'mutation-rolled-back':
|
|
472
|
+
this.events.emit(SyncEventTypes.MutationRolledBack, {
|
|
473
|
+
eventType: msg.eventType,
|
|
474
|
+
recordId: msg.recordId,
|
|
475
|
+
error: msg.error,
|
|
476
|
+
});
|
|
477
|
+
break;
|
|
478
|
+
default:
|
|
479
|
+
// db-ready and ingest-relay are handled by the engine/cache wiring
|
|
480
|
+
// in Sp00kyClient before messages reach this handler.
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** A forwarded outbox row from a follower: load + drain it. Idempotent. */
|
|
487
|
+
public async enqueueForwardedMutation(mutationId: string): Promise<void> {
|
|
488
|
+
if (this.tabRole !== 'leader') return;
|
|
489
|
+
await this.upQueue.enqueueFromDatabase(mutationId);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** A relayed `_00_list_ref` LIVE event: resolve against THIS tab's queries
|
|
493
|
+
* and run the exact same handling the LIVE subscription would have. */
|
|
494
|
+
private async applyRelayedListRefChange(msg: {
|
|
495
|
+
action: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
496
|
+
queryId: string;
|
|
497
|
+
recordId: string;
|
|
498
|
+
version: number;
|
|
499
|
+
parent: boolean;
|
|
500
|
+
}): Promise<void> {
|
|
501
|
+
const queryId = parseRecordIdString(msg.queryId);
|
|
502
|
+
// Foreign query (another tab's session-salted hash): not ours, ignore.
|
|
503
|
+
if (!this.dataModule.getQueryById(queryId)) return;
|
|
504
|
+
const recordId = parseRecordIdString(msg.recordId);
|
|
505
|
+
if (msg.parent) {
|
|
506
|
+
await this.handleRemoteSubqueryChange(msg.action, queryId, recordId, msg.version);
|
|
507
|
+
} else {
|
|
508
|
+
await this.handleRemoteListRefChange(msg.action, queryId, recordId, msg.version);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** One immediate poll cycle (failover convergence). */
|
|
513
|
+
public async forcePollRound(): Promise<void> {
|
|
514
|
+
this.listRefIdleStreak = 0;
|
|
515
|
+
await this.pollListRefForActiveQueries().catch(() => false);
|
|
516
|
+
}
|
|
517
|
+
|
|
396
518
|
/**
|
|
397
519
|
* Quiesce all sync activity ahead of a local-bucket switch. After this
|
|
398
520
|
* resolves, nothing in the sync module writes to the local store: the poll
|
|
@@ -772,6 +894,9 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
772
894
|
}
|
|
773
895
|
|
|
774
896
|
private async startRefLiveQueries() {
|
|
897
|
+
// Shared-tabs follower: exactly one LIVE per user exists, on the leader;
|
|
898
|
+
// its events reach this tab through the relay.
|
|
899
|
+
if (this.tabRole === 'follower') return;
|
|
775
900
|
const tableName = this.listRefTable();
|
|
776
901
|
this.logger.debug(
|
|
777
902
|
{ tableName, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
|
|
@@ -837,6 +962,20 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
837
962
|
this.lastLiveEventAt = Date.now();
|
|
838
963
|
this.listRefIdleStreak = 0;
|
|
839
964
|
|
|
965
|
+
// Shared-tabs leader: the list_ref table is USER-scoped, so this LIVE also
|
|
966
|
+
// carries events for FOLLOWER tabs' queries (their own session-salted
|
|
967
|
+
// hashes). Relay every primary event; each follower resolves the queryId
|
|
968
|
+
// against its own DataModule and ignores foreign ones. Then continue with
|
|
969
|
+
// this tab's own handling below.
|
|
970
|
+
this.hub?.broadcast({
|
|
971
|
+
type: 'list-ref-change',
|
|
972
|
+
action,
|
|
973
|
+
queryId: encodeRecordId(queryId),
|
|
974
|
+
recordId: encodeRecordId(recordId),
|
|
975
|
+
version,
|
|
976
|
+
parent: false,
|
|
977
|
+
});
|
|
978
|
+
|
|
840
979
|
// NOTE: DELETE is handled like CREATE/UPDATE below. When another window (or
|
|
841
980
|
// this one) deletes a record, the server's SSP removes it from `_00_list_ref`
|
|
842
981
|
// and the LIVE subscription delivers a DELETE here — `createDiffFromDbOp`
|
|
@@ -846,13 +985,17 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
846
985
|
const existing = this.dataModule.getQueryById(queryId);
|
|
847
986
|
|
|
848
987
|
if (!existing) {
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
988
|
+
// With a hub attached, an unknown query is the NORMAL case (it belongs
|
|
989
|
+
// to a follower tab); without one it still warrants the warning.
|
|
990
|
+
if (!this.hub) {
|
|
991
|
+
this.logger.warn(
|
|
992
|
+
{
|
|
993
|
+
queryId: queryId.toString(),
|
|
994
|
+
Category: 'sp00ky-client::Sp00kySync::handleRemoteListRefChange',
|
|
995
|
+
},
|
|
996
|
+
'Received remote update for unknown local query'
|
|
997
|
+
);
|
|
998
|
+
}
|
|
856
999
|
return;
|
|
857
1000
|
}
|
|
858
1001
|
|
|
@@ -899,6 +1042,16 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
899
1042
|
|
|
900
1043
|
if (action === 'DELETE') return;
|
|
901
1044
|
|
|
1045
|
+
// Relay child-edge events too (see handleRemoteListRefChange).
|
|
1046
|
+
this.hub?.broadcast({
|
|
1047
|
+
type: 'list-ref-change',
|
|
1048
|
+
action,
|
|
1049
|
+
queryId: encodeRecordId(queryId),
|
|
1050
|
+
recordId: encodeRecordId(childId),
|
|
1051
|
+
version,
|
|
1052
|
+
parent: true,
|
|
1053
|
+
});
|
|
1054
|
+
|
|
902
1055
|
const existing = this.dataModule.getQueryById(queryId);
|
|
903
1056
|
if (!existing) return;
|
|
904
1057
|
|
|
@@ -1013,6 +1166,21 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1013
1166
|
recordId,
|
|
1014
1167
|
error: error.message,
|
|
1015
1168
|
});
|
|
1169
|
+
|
|
1170
|
+
// Shared-tabs: the store rollback above already propagated to every tab
|
|
1171
|
+
// via the ingest relay; additionally deliver the EVENT to the tab that
|
|
1172
|
+
// owns the mutation so its UI (toasts, subscribeToRollbacks) fires there.
|
|
1173
|
+
const mutationId = encodeRecordId(event.mutation_id);
|
|
1174
|
+
const owner = mutationOwnerTabId(mutationId);
|
|
1175
|
+
if (this.hub && owner && this.tabId && owner !== this.tabId) {
|
|
1176
|
+
this.hub.sendTo(owner, {
|
|
1177
|
+
type: 'mutation-rolled-back',
|
|
1178
|
+
mutationId,
|
|
1179
|
+
recordId,
|
|
1180
|
+
eventType: event.type,
|
|
1181
|
+
error: error.message,
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1016
1184
|
}
|
|
1017
1185
|
|
|
1018
1186
|
private async processDownEvent(event: DownEvent) {
|
|
@@ -1171,6 +1339,16 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1171
1339
|
* @param mutations Array of UpEvents (create/update/delete) to enqueue.
|
|
1172
1340
|
*/
|
|
1173
1341
|
public async enqueueMutation(mutations: UpEvent[]) {
|
|
1342
|
+
// Follower: the outbox rows are already committed in the SHARED store (the
|
|
1343
|
+
// mutation tx went through the leader's worker); only the leader drains,
|
|
1344
|
+
// so hand over the ids instead of queueing locally. A notify lost in a
|
|
1345
|
+
// failover window is covered by the new leader's loadFromDatabase.
|
|
1346
|
+
if (this.tabRole === 'follower') {
|
|
1347
|
+
for (const m of mutations) {
|
|
1348
|
+
this.forwarder?.mutationEnqueued(encodeRecordId(m.mutation_id));
|
|
1349
|
+
}
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1174
1352
|
this.scheduler.enqueueMutation(mutations);
|
|
1175
1353
|
}
|
|
1176
1354
|
|
|
@@ -2,6 +2,7 @@ import type { QueryPlan, RelationPlan, WhereNode } from '@spooky-sync/query-buil
|
|
|
2
2
|
import type { SealedQuery } from '../../utils/surql';
|
|
3
3
|
import type { DatabaseEventSystem } from './events/index';
|
|
4
4
|
import type { Sp00kyConfig, StorageHealth } from '../../types';
|
|
5
|
+
import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-info';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* A materialized row. Keys are field names; values are already decoded to the
|
|
@@ -121,6 +122,13 @@ export interface LocalStore extends LocalCacheEngine {
|
|
|
121
122
|
getClient(): unknown;
|
|
122
123
|
getConfig(): Sp00kyConfig<any>['database'];
|
|
123
124
|
readonly currentBucketId: string;
|
|
125
|
+
/** Which built-in backend this is. OPTIONAL: absent (custom engines) is
|
|
126
|
+
* reported as `'custom'` by DevTools. More robust than `instanceof` for
|
|
127
|
+
* engines constructed outside this package. */
|
|
128
|
+
readonly engineKind?: 'surrealdb' | 'sqlite';
|
|
129
|
+
/** Engine-specific storage numbers for DevTools (DB file size, per-table
|
|
130
|
+
* row counts). OPTIONAL: only engines with something to report implement it. */
|
|
131
|
+
getStorageDiagnostics?(opts?: { tableCounts?: boolean }): Promise<EngineStorageDiagnostics>;
|
|
124
132
|
/**
|
|
125
133
|
* Durability of this engine's local store. OPTIONAL: engines that don't
|
|
126
134
|
* report it (SurrealDB, custom engines) are treated as `'unknown'` by the
|
|
@@ -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;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { RecordId } from 'surrealdb';
|
|
3
3
|
import { pureWriteOpResult, SqliteCacheEngine } from './sqlite-cache-engine';
|
|
4
|
+
import { stubTransport } from './sqlite-transport.fixture';
|
|
4
5
|
import { translateSurql } from './surql-translate';
|
|
5
6
|
import type { SqlOp } from './surql-translate';
|
|
6
7
|
import { surql } from '../../utils/surql';
|
|
@@ -13,42 +14,31 @@ function makeLogger(): any {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
* A fake
|
|
17
|
-
* `open` opens the DB, `close` closes it, and `exec`/`run` REJECT
|
|
18
|
-
* "sqlite: DB not open" when the DB isn't currently open — exactly the
|
|
19
|
-
* the fix targets. Records every message `type` (across
|
|
20
|
-
* tests can also inspect dispatch order.
|
|
17
|
+
* A fake transport handler that models the real SQLite worker's open/closed
|
|
18
|
+
* lifecycle: `open` opens the DB, `close` closes it, and `exec`/`run` REJECT
|
|
19
|
+
* with "sqlite: DB not open" when the DB isn't currently open — exactly the
|
|
20
|
+
* failure the fix targets. Records every message `type` (across transport
|
|
21
|
+
* generations) so tests can also inspect dispatch order.
|
|
21
22
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
else rest = { rows: [] };
|
|
42
|
-
} else if (msg.type === 'select') {
|
|
43
|
-
if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
|
|
44
|
-
else rest = { rows: [], relationFetches: 0 };
|
|
45
|
-
} else if (msg.type === 'run' || msg.type === 'batch') {
|
|
46
|
-
if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
|
|
47
|
-
}
|
|
48
|
-
this.onmessage?.({ data: { id: msg.id, ok, error, ...rest } });
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
terminate() {}
|
|
23
|
+
function lifecycleHandler(log: string[]) {
|
|
24
|
+
let dbOpen = false;
|
|
25
|
+
return (type: string) => {
|
|
26
|
+
log.push(type);
|
|
27
|
+
if (type === 'open') {
|
|
28
|
+
dbOpen = true;
|
|
29
|
+
return { persisted: true };
|
|
30
|
+
}
|
|
31
|
+
if (type === 'close') {
|
|
32
|
+
dbOpen = false;
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
if (type === 'exec' || type === 'select' || type === 'run' || type === 'batch') {
|
|
36
|
+
if (!dbOpen) throw new Error('sqlite: DB not open');
|
|
37
|
+
if (type === 'exec') return { rows: [] };
|
|
38
|
+
if (type === 'select') return { rows: [], relationFetches: 0 };
|
|
39
|
+
}
|
|
40
|
+
return {};
|
|
41
|
+
};
|
|
52
42
|
}
|
|
53
43
|
|
|
54
44
|
// Regression: switchBucket must run close → reopen as a single serialized
|
|
@@ -60,19 +50,7 @@ describe('SqliteCacheEngine.switchBucket serialization', () => {
|
|
|
60
50
|
it('never dispatches an op to a closed DB during a bucket switch', async () => {
|
|
61
51
|
const log: string[] = [];
|
|
62
52
|
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
63
|
-
|
|
64
|
-
(engine as any).spawnWorker = () => {
|
|
65
|
-
const w = new FakeWorker(log);
|
|
66
|
-
w.onmessage = (ev: any) => {
|
|
67
|
-
const { id, ok, error, ...rest } = ev.data ?? {};
|
|
68
|
-
const p = (engine as any).pending.get(id);
|
|
69
|
-
if (!p) return;
|
|
70
|
-
(engine as any).pending.delete(id);
|
|
71
|
-
if (ok) p.resolve(rest);
|
|
72
|
-
else p.reject(new Error(error));
|
|
73
|
-
};
|
|
74
|
-
return w as unknown as Worker;
|
|
75
|
-
};
|
|
53
|
+
stubTransport(engine, lifecycleHandler(log));
|
|
76
54
|
|
|
77
55
|
await engine.connect('anon');
|
|
78
56
|
expect(log).toContain('open');
|
|
@@ -105,28 +83,10 @@ describe('SqliteCacheEngine system-table seeding', () => {
|
|
|
105
83
|
it('passes the _00_* system tables to the worker open (fresh-bucket safe)', async () => {
|
|
106
84
|
const msgs: any[] = [];
|
|
107
85
|
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
108
|
-
(engine
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
Promise.resolve().then(() => {
|
|
113
|
-
const rest = msg.type === 'open' ? { persisted: true } : {};
|
|
114
|
-
w.onmessage?.({ data: { id: msg.id, ok: true, ...rest } });
|
|
115
|
-
});
|
|
116
|
-
};
|
|
117
|
-
// Wire pending resolution like the real spawnWorker.
|
|
118
|
-
const inner = w.onmessage;
|
|
119
|
-
w.onmessage = (ev: any) => {
|
|
120
|
-
const { id, ok, error, ...rest } = ev.data ?? {};
|
|
121
|
-
const p = (engine as any).pending.get(id);
|
|
122
|
-
if (!p) return;
|
|
123
|
-
(engine as any).pending.delete(id);
|
|
124
|
-
if (ok) p.resolve(rest);
|
|
125
|
-
else p.reject(new Error(error));
|
|
126
|
-
void inner;
|
|
127
|
-
};
|
|
128
|
-
return w as unknown as Worker;
|
|
129
|
-
};
|
|
86
|
+
stubTransport(engine, (type, payload) => {
|
|
87
|
+
msgs.push({ type, payload });
|
|
88
|
+
return type === 'open' ? { persisted: true } : {};
|
|
89
|
+
});
|
|
130
90
|
|
|
131
91
|
await engine.connect('user:fresh');
|
|
132
92
|
const open = msgs.find((m) => m.type === 'open');
|
|
@@ -154,21 +114,7 @@ describe('SqliteCacheEngine storage health', () => {
|
|
|
154
114
|
logger,
|
|
155
115
|
opts ?? {}
|
|
156
116
|
);
|
|
157
|
-
(engine
|
|
158
|
-
const w: any = { onmessage: null, onerror: null, onmessageerror: null, terminate() {} };
|
|
159
|
-
w.postMessage = (msg: any) => {
|
|
160
|
-
Promise.resolve().then(() => {
|
|
161
|
-
const rest = msg.type === 'open' ? openReply : {};
|
|
162
|
-
const { id, ok, error, ...payload } = { id: msg.id, ok: true, error: undefined, ...rest };
|
|
163
|
-
const p = (engine as any).pending.get(id);
|
|
164
|
-
if (!p) return;
|
|
165
|
-
(engine as any).pending.delete(id);
|
|
166
|
-
if (ok) p.resolve(payload);
|
|
167
|
-
else p.reject(new Error(error));
|
|
168
|
-
});
|
|
169
|
-
};
|
|
170
|
-
return w as unknown as Worker;
|
|
171
|
-
};
|
|
117
|
+
stubTransport(engine, (type) => (type === 'open' ? openReply : {}));
|
|
172
118
|
return { engine, logs };
|
|
173
119
|
}
|
|
174
120
|
|
|
@@ -222,6 +168,189 @@ describe('SqliteCacheEngine storage health', () => {
|
|
|
222
168
|
});
|
|
223
169
|
});
|
|
224
170
|
|
|
171
|
+
// Storage numbers for the DevTools Storage tab: DB size via the pragmas, row
|
|
172
|
+
// counts on demand, and the configured-vs-effective workerSelect split. Errors
|
|
173
|
+
// must land in `error` (the worker may be mid bucket-switch), never throw.
|
|
174
|
+
describe('SqliteCacheEngine.getStorageDiagnostics', () => {
|
|
175
|
+
function makeEngine(execRows: (sql: string) => unknown[]) {
|
|
176
|
+
const noop = () => {};
|
|
177
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
178
|
+
logger.child = () => logger;
|
|
179
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, logger);
|
|
180
|
+
stubTransport(engine, (type, payload) =>
|
|
181
|
+
type === 'open'
|
|
182
|
+
? { persisted: true }
|
|
183
|
+
: type === 'exec'
|
|
184
|
+
? { rows: execRows(payload.sql) }
|
|
185
|
+
: {}
|
|
186
|
+
);
|
|
187
|
+
return engine;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it('reports size, freelist, and per-table counts', async () => {
|
|
191
|
+
const engine = makeEngine((sql) => {
|
|
192
|
+
if (sql.includes('pragma_page_count')) return [{ bytes: 40960, freelist: 4096 }];
|
|
193
|
+
if (sql.includes('sqlite_master')) return [{ name: '_00_query' }, { name: 'game' }];
|
|
194
|
+
if (sql.includes('COUNT(*)'))
|
|
195
|
+
return [
|
|
196
|
+
{ t: '_00_query', n: 3 },
|
|
197
|
+
{ t: 'game', n: 12 },
|
|
198
|
+
];
|
|
199
|
+
return [];
|
|
200
|
+
});
|
|
201
|
+
await engine.connect('user:abc');
|
|
202
|
+
|
|
203
|
+
const diag = await engine.getStorageDiagnostics({ tableCounts: true });
|
|
204
|
+
expect(diag.engine).toBe('sqlite');
|
|
205
|
+
expect(diag.bucketId).toBe('user:abc');
|
|
206
|
+
expect(diag.dbSizeBytes).toBe(40960);
|
|
207
|
+
expect(diag.freelistBytes).toBe(4096);
|
|
208
|
+
expect(diag.tableCounts).toEqual([
|
|
209
|
+
{ table: '_00_query', rows: 3 },
|
|
210
|
+
{ table: 'game', rows: 12 },
|
|
211
|
+
]);
|
|
212
|
+
// Default config: workerSelect on, never downgraded.
|
|
213
|
+
expect(diag.workerSelectConfigured).toBe(true);
|
|
214
|
+
expect(diag.workerSelectEffective).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('skips table counts unless asked and never throws on a dead worker', async () => {
|
|
218
|
+
const engine = makeEngine(() => [{ bytes: 8192, freelist: 0 }]);
|
|
219
|
+
await engine.connect('anon');
|
|
220
|
+
|
|
221
|
+
const diag = await engine.getStorageDiagnostics();
|
|
222
|
+
expect(diag.tableCounts).toBeUndefined();
|
|
223
|
+
|
|
224
|
+
// No worker at all → the failure lands in `error`, not as a throw.
|
|
225
|
+
const cold = makeEngine(() => []);
|
|
226
|
+
const coldDiag = await cold.getStorageDiagnostics();
|
|
227
|
+
expect(coldDiag.error).toContain('not connected');
|
|
228
|
+
expect(coldDiag.bucketId).toBe('anon');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Shared-tabs engine role modes: the engine object survives every role change
|
|
233
|
+
// (one monotonic epoch per tab), followers speak the same protocol over a
|
|
234
|
+
// MessagePort, and the leaderless window parks ops instead of failing the UI.
|
|
235
|
+
describe('SqliteCacheEngine role modes', () => {
|
|
236
|
+
function makeSharedEngine() {
|
|
237
|
+
const log: string[] = [];
|
|
238
|
+
const engine = new SqliteCacheEngine(
|
|
239
|
+
{ namespace: 'n', database: 'd' } as any,
|
|
240
|
+
makeLogger(),
|
|
241
|
+
{ shared: true, useOpfs: true }
|
|
242
|
+
);
|
|
243
|
+
stubTransport(engine, lifecycleHandler(log));
|
|
244
|
+
return { engine, log };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A fake follower dbPort: replies like the worker over postMessage. */
|
|
248
|
+
function fakeLeaderPort() {
|
|
249
|
+
const port: any = {
|
|
250
|
+
onmessage: null as null | ((ev: { data: any }) => void),
|
|
251
|
+
onmessageerror: null,
|
|
252
|
+
started: false,
|
|
253
|
+
closed: false,
|
|
254
|
+
start() {
|
|
255
|
+
this.started = true;
|
|
256
|
+
},
|
|
257
|
+
close() {
|
|
258
|
+
this.closed = true;
|
|
259
|
+
},
|
|
260
|
+
postMessage(msg: any) {
|
|
261
|
+
queueMicrotask(() => {
|
|
262
|
+
const rest =
|
|
263
|
+
msg.type === 'exec'
|
|
264
|
+
? { rows: [] }
|
|
265
|
+
: msg.type === 'select'
|
|
266
|
+
? { rows: [], relationFetches: 0 }
|
|
267
|
+
: {};
|
|
268
|
+
port.onmessage?.({ data: { id: msg.id, ok: true, wt: 0, ...rest } });
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
return port;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
it('adoptOwner opens under the worker lock and wipes stale _00_query rows', async () => {
|
|
276
|
+
const { engine, log } = makeSharedEngine();
|
|
277
|
+
const health = await engine.adoptOwner('anon', {
|
|
278
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
|
|
279
|
+
allowMemoryFallback: false,
|
|
280
|
+
resumeHeld: false,
|
|
281
|
+
});
|
|
282
|
+
expect(health.status).toBe('persistent');
|
|
283
|
+
expect(engine.storageHealth.role).toBe('leader');
|
|
284
|
+
expect(log[0]).toBe('open');
|
|
285
|
+
expect(log).toContain('run'); // the DELETE _00_query wipe
|
|
286
|
+
expect(engine.currentBucketId).toBe('anon');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('adoptAttached serves reads over the leader port and reports follower role', async () => {
|
|
290
|
+
const engine = new SqliteCacheEngine(
|
|
291
|
+
{ namespace: 'n', database: 'd' } as any,
|
|
292
|
+
makeLogger(),
|
|
293
|
+
{ shared: true }
|
|
294
|
+
);
|
|
295
|
+
const port = fakeLeaderPort();
|
|
296
|
+
await engine.adoptAttached(
|
|
297
|
+
port,
|
|
298
|
+
{ bucketId: 'anon', storageHealth: { status: 'persistent', fallback: false } },
|
|
299
|
+
() => {}
|
|
300
|
+
);
|
|
301
|
+
expect(port.started).toBe(true);
|
|
302
|
+
expect(engine.storageHealth).toMatchObject({ status: 'persistent', role: 'follower' });
|
|
303
|
+
await expect(engine.getById('game', 'game:1')).resolves.toBeNull();
|
|
304
|
+
expect((globalThis as any).__sqliteStats.proxiedOps).toBeGreaterThan(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('parks ops through a leader loss and releases them on promotion', async () => {
|
|
308
|
+
const { engine } = makeSharedEngine();
|
|
309
|
+
const port = fakeLeaderPort();
|
|
310
|
+
await engine.adoptAttached(
|
|
311
|
+
port,
|
|
312
|
+
{ bucketId: 'anon', storageHealth: { status: 'persistent', fallback: false } },
|
|
313
|
+
() => {}
|
|
314
|
+
);
|
|
315
|
+
const epochBefore = engine.epoch;
|
|
316
|
+
engine.onLeaderLost('leader tab closed');
|
|
317
|
+
expect(engine.epoch).toBe(epochBefore + 1);
|
|
318
|
+
|
|
319
|
+
// Issued during the leaderless window: must not reject immediately.
|
|
320
|
+
const read = engine.getById('_00_query', 'h1');
|
|
321
|
+
let settled = false;
|
|
322
|
+
void read.finally(() => {
|
|
323
|
+
settled = true;
|
|
324
|
+
});
|
|
325
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
326
|
+
expect(settled).toBe(false);
|
|
327
|
+
|
|
328
|
+
await engine.adoptOwner('anon', {
|
|
329
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:2',
|
|
330
|
+
allowMemoryFallback: false,
|
|
331
|
+
resumeHeld: false,
|
|
332
|
+
});
|
|
333
|
+
await expect(read).resolves.toBeNull();
|
|
334
|
+
expect(engine.storageHealth.role).toBe('leader');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('bumps the epoch on promotion after having had a store (fences in-flight chains)', async () => {
|
|
338
|
+
const { engine } = makeSharedEngine();
|
|
339
|
+
await engine.adoptOwner('anon', {
|
|
340
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
|
|
341
|
+
allowMemoryFallback: false,
|
|
342
|
+
resumeHeld: false,
|
|
343
|
+
});
|
|
344
|
+
const before = engine.epoch;
|
|
345
|
+
await engine.adoptOwner('anon', {
|
|
346
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:3',
|
|
347
|
+
allowMemoryFallback: false,
|
|
348
|
+
resumeHeld: false,
|
|
349
|
+
});
|
|
350
|
+
expect(engine.epoch).toBeGreaterThan(before);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
225
354
|
// `pureWriteOpResult` is the single source of truth for what a pure-write op
|
|
226
355
|
// contributes to a query's per-statement results. The batched fast path in
|
|
227
356
|
// `query()` and the per-op `execOp` path BOTH route through it, so a caller that
|