@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211
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/AGENTS.md +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only doubles for the blob cache. Follows the repo's convention of
|
|
3
|
+
* hand-rolled fakes at the module boundary (cf. `sqlite-transport.fixture.ts`)
|
|
4
|
+
* rather than pulling in a DOM or a fake-indexeddb: `BlobCache` is pure logic
|
|
5
|
+
* over `BlobStore` + `LocalStore`, so both can be faked in node.
|
|
6
|
+
*/
|
|
7
|
+
import { RecordId } from 'surrealdb';
|
|
8
|
+
import type { Id, LocalStore, Row } from '../database/cache-engine';
|
|
9
|
+
|
|
10
|
+
/** A `LocalStore` with only the three verbs `BlobManifest` uses. */
|
|
11
|
+
export interface FakeLocalStore {
|
|
12
|
+
store: LocalStore;
|
|
13
|
+
rows: Map<string, Row>;
|
|
14
|
+
/** Counts, so a test can assert the manifest batched instead of write-storming. */
|
|
15
|
+
upserts: number;
|
|
16
|
+
deletes: number;
|
|
17
|
+
/** Make every read fail — the memory-fallback / wiped-store case. */
|
|
18
|
+
failReads: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function idOf(id: Id): string {
|
|
22
|
+
return id instanceof RecordId ? String(id.id) : String(id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function fakeLocalStore(): FakeLocalStore {
|
|
26
|
+
const state: FakeLocalStore = {
|
|
27
|
+
rows: new Map(),
|
|
28
|
+
upserts: 0,
|
|
29
|
+
deletes: 0,
|
|
30
|
+
failReads: false,
|
|
31
|
+
store: null as unknown as LocalStore,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
state.store = {
|
|
35
|
+
async selectByIds(_table: string, ids: Id[]): Promise<Row[]> {
|
|
36
|
+
if (state.failReads) throw new Error('local store unavailable');
|
|
37
|
+
const out: Row[] = [];
|
|
38
|
+
for (const id of ids) {
|
|
39
|
+
const key = idOf(id);
|
|
40
|
+
const row = state.rows.get(key);
|
|
41
|
+
if (row) out.push({ ...row, id: new RecordId('_00_blob', key) });
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
},
|
|
45
|
+
async upsert(_table: string, id: Id, data: Row): Promise<void> {
|
|
46
|
+
state.upserts++;
|
|
47
|
+
state.rows.set(idOf(id), { ...data });
|
|
48
|
+
},
|
|
49
|
+
async delete(_table: string, id: Id): Promise<void> {
|
|
50
|
+
state.deletes++;
|
|
51
|
+
state.rows.delete(idOf(id));
|
|
52
|
+
},
|
|
53
|
+
} as unknown as LocalStore;
|
|
54
|
+
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A logger that satisfies the `Logger` shape without printing. */
|
|
59
|
+
export function silentLogger(): any {
|
|
60
|
+
const noop = () => {};
|
|
61
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, fatal: noop, trace: noop };
|
|
62
|
+
logger.child = () => logger;
|
|
63
|
+
return logger;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Deterministic object-URL factory: no DOM needed, and revokes are countable. */
|
|
67
|
+
export function fakeUrls() {
|
|
68
|
+
let next = 0;
|
|
69
|
+
const live = new Set<string>();
|
|
70
|
+
const revoked: string[] = [];
|
|
71
|
+
return {
|
|
72
|
+
live,
|
|
73
|
+
revoked,
|
|
74
|
+
factory: {
|
|
75
|
+
create: (_blob: Blob) => {
|
|
76
|
+
const url = `blob:fake/${next++}`;
|
|
77
|
+
live.add(url);
|
|
78
|
+
return url;
|
|
79
|
+
},
|
|
80
|
+
revoke: (url: string) => {
|
|
81
|
+
live.delete(url);
|
|
82
|
+
revoked.push(url);
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function bytes(size: number, fill = 'a'): Blob {
|
|
89
|
+
return new Blob([fill.repeat(size)]);
|
|
90
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Logger } from '../logger/index';
|
|
2
|
+
import type { LocalStore } from '../database/cache-engine';
|
|
3
|
+
import type { BlobCacheOptions } from './blob-cache';
|
|
4
|
+
import { BlobCache } from './blob-cache';
|
|
5
|
+
import { BlobManifest } from './blob-manifest';
|
|
6
|
+
import type { BlobStore } from './blob-store';
|
|
7
|
+
import { MemoryBlobStore, OpfsBlobStore, opfsWritableSupported } from './blob-store';
|
|
8
|
+
|
|
9
|
+
export type { BlobKey, BlobStat, BlobStore } from './blob-store';
|
|
10
|
+
export { BLOB_ROOT_DIR, BlobKeyError, MemoryBlobStore, OpfsBlobStore, blobKeyId, opfsWritableSupported } from './blob-store';
|
|
11
|
+
export type { BlobEntry } from './blob-manifest';
|
|
12
|
+
export { BLOB_TABLE, BlobManifest } from './blob-manifest';
|
|
13
|
+
export type { BlobCacheOptions, BlobCacheStats, BlobReadOptions, BlobUrlLease } from './blob-cache';
|
|
14
|
+
export { BlobCache } from './blob-cache';
|
|
15
|
+
|
|
16
|
+
/** Ceiling on the default budget, before the quota fraction is applied. */
|
|
17
|
+
const MAX_DEFAULT_BUDGET_BYTES = 512 * 1024 * 1024;
|
|
18
|
+
/** Share of the origin quota the blob cache may claim by default. The local
|
|
19
|
+
* database, the SQLite pool and any app storage share the same quota. */
|
|
20
|
+
const QUOTA_FRACTION = 0.25;
|
|
21
|
+
/** Used until `resolveBlobBudget()` reports back, and when there is no
|
|
22
|
+
* `navigator.storage.estimate()` to ask. */
|
|
23
|
+
export const FALLBACK_BLOB_BUDGET_BYTES = 128 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Budget from the real origin quota when the browser will tell us, otherwise a
|
|
27
|
+
* conservative constant. Async because `estimate()` is; call it once at init
|
|
28
|
+
* and hand the result to {@link BlobCache.setMaxBytes}.
|
|
29
|
+
*/
|
|
30
|
+
export async function resolveBlobBudget(configured?: number): Promise<number> {
|
|
31
|
+
if (typeof configured === 'number' && configured > 0) return configured;
|
|
32
|
+
try {
|
|
33
|
+
const { quota } = (await navigator.storage.estimate()) ?? {};
|
|
34
|
+
if (typeof quota === 'number' && quota > 0) {
|
|
35
|
+
return Math.min(MAX_DEFAULT_BUDGET_BYTES, Math.floor(quota * QUOTA_FRACTION));
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
/* private mode, or no Storage API: fall through */
|
|
39
|
+
}
|
|
40
|
+
return FALLBACK_BLOB_BUDGET_BYTES;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CreateBlobCacheOptions {
|
|
44
|
+
local: LocalStore;
|
|
45
|
+
namespace: string;
|
|
46
|
+
logger: Logger;
|
|
47
|
+
fetchRemote: BlobCacheOptions['fetchRemote'];
|
|
48
|
+
headRemote?: BlobCacheOptions['headRemote'];
|
|
49
|
+
maxBytes?: number;
|
|
50
|
+
/** Force a store instead of feature-detecting. Tests and custom engines. */
|
|
51
|
+
store?: BlobStore;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the cache for a client. Falls back to an in-memory store when OPFS
|
|
56
|
+
* cannot be written (Safari before `createWritable`, private modes, non-browser
|
|
57
|
+
* hosts): the cache still dedupes and serves within the tab, which is exactly
|
|
58
|
+
* the behaviour that existed before it — nothing regresses, nothing persists.
|
|
59
|
+
*/
|
|
60
|
+
export function createBlobCache(opts: CreateBlobCacheOptions): BlobCache {
|
|
61
|
+
const store = opts.store ?? (opfsWritableSupported() ? new OpfsBlobStore(opts.namespace) : new MemoryBlobStore(opts.namespace));
|
|
62
|
+
return new BlobCache({
|
|
63
|
+
store,
|
|
64
|
+
manifest: new BlobManifest(opts.local),
|
|
65
|
+
fetchRemote: opts.fetchRemote,
|
|
66
|
+
headRemote: opts.headRemote,
|
|
67
|
+
logger: opts.logger,
|
|
68
|
+
maxBytes: opts.maxBytes ?? FALLBACK_BLOB_BUDGET_BYTES,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { QueryPlan, RelationPlan, WhereNode } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { SealedQuery } from '../../utils/surql';
|
|
3
|
+
import type { DatabaseEventSystem } from './events/index';
|
|
4
|
+
import type { Sp00kyConfig, StorageHealth } from '../../types';
|
|
5
|
+
import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-info';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A materialized row. Keys are field names; values are already decoded to the
|
|
9
|
+
* client's runtime shapes (RecordId stays a RecordId, bytes a Uint8Array, …) so
|
|
10
|
+
* every backend hands `DataModule` the same shape SurrealDB does today.
|
|
11
|
+
*/
|
|
12
|
+
export type Row = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
/** A record identifier — a `RecordId` or its stable string form (`table:id`). */
|
|
15
|
+
export type Id = unknown;
|
|
16
|
+
|
|
17
|
+
/** How an order clause is expressed everywhere in the engine layer. */
|
|
18
|
+
export type OrderBy = [field: string, direction: 'asc' | 'desc'][];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Batched relation fetch: "give me every row of `table` whose `matchField` is
|
|
22
|
+
* one of `keys`, filtered by `where`, ordered by `orderBy`". This is the single
|
|
23
|
+
* primitive relation decomposition (§3) leans on — implemented as
|
|
24
|
+
* `SELECT … WHERE <matchField> IN (…)` on SQLite, `SELECT … FROM $keys` /
|
|
25
|
+
* `WHERE <matchField> IN $keys` on SurrealDB, or an index scan elsewhere. Order
|
|
26
|
+
* here is a hint; the resolver re-applies order+limit PER PARENT after grouping.
|
|
27
|
+
*/
|
|
28
|
+
export interface RelationFetch {
|
|
29
|
+
table: string;
|
|
30
|
+
matchField: string;
|
|
31
|
+
keys: Id[];
|
|
32
|
+
where?: WhereNode[];
|
|
33
|
+
orderBy?: OrderBy;
|
|
34
|
+
select?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The read side of an engine, minus relation resolution — the surface a
|
|
39
|
+
* {@link RelationResolver} needs. Kept separate so the resolver can be unit
|
|
40
|
+
* tested against an in-memory fake without a full engine.
|
|
41
|
+
*/
|
|
42
|
+
export interface RowFetcher {
|
|
43
|
+
/** Batched fan-out fetch. See {@link RelationFetch}. */
|
|
44
|
+
fetchRelation(req: RelationFetch): Promise<Row[]>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A transaction handle — the same verbs as the engine, but atomic. */
|
|
48
|
+
export interface EngineTx {
|
|
49
|
+
upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void>;
|
|
50
|
+
patch(table: string, id: Id, patches: unknown[]): Promise<void>;
|
|
51
|
+
delete(table: string, id: Id): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A pluggable local cache backend. SurrealDB (the default) and SQLite both
|
|
56
|
+
* implement this; the rest of the client talks verbs, never SurrealQL.
|
|
57
|
+
*
|
|
58
|
+
* Reactivity is NOT part of this contract: the local cache is passive. The SSP
|
|
59
|
+
* (remote) drives change; `DataModule` writes rows here and re-reads them. The
|
|
60
|
+
* `epoch` field preserves the existing bucket-switch fencing (see
|
|
61
|
+
* `LocalDatabaseService.epoch`): an async chain captures it at start and its
|
|
62
|
+
* write is dropped if the epoch moved (a bucket switch) in between.
|
|
63
|
+
*/
|
|
64
|
+
export interface LocalCacheEngine extends RowFetcher {
|
|
65
|
+
/** Monotonic store generation; bumped on every bucket switch. */
|
|
66
|
+
readonly epoch: number;
|
|
67
|
+
|
|
68
|
+
connect(bucketId: string): Promise<void>;
|
|
69
|
+
switchBucket(bucketId: string): Promise<void>;
|
|
70
|
+
close(): Promise<void>;
|
|
71
|
+
|
|
72
|
+
/** Run `fn` inside a single atomic transaction. */
|
|
73
|
+
transaction<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Materialize a query, including its `.related()` tree (via §3
|
|
77
|
+
* decomposition). Params bind `where` `paramRef`s and any windowing id-set.
|
|
78
|
+
*/
|
|
79
|
+
select(plan: QueryPlan, params?: Record<string, unknown>): Promise<Row[]>;
|
|
80
|
+
|
|
81
|
+
/** Fetch rows by primary id, preserving `ids` order; missing ids are skipped. */
|
|
82
|
+
selectByIds(table: string, ids: Id[], opts?: { select?: string[]; orderBy?: OrderBy }): Promise<Row[]>;
|
|
83
|
+
|
|
84
|
+
/** Single-record read by primary id, or `null`. */
|
|
85
|
+
getById(table: string, id: Id): Promise<Row | null>;
|
|
86
|
+
|
|
87
|
+
upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void>;
|
|
88
|
+
patch(table: string, id: Id, patches: unknown[]): Promise<void>;
|
|
89
|
+
delete(table: string, id: Id): Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The full surface the client's `this.local` field depends on: the
|
|
94
|
+
* engine-neutral {@link LocalCacheEngine} verbs PLUS the legacy
|
|
95
|
+
* SurrealQL/lifecycle methods the not-yet-migrated call sites still use.
|
|
96
|
+
* `SurrealCacheEngine` (subclass of `LocalDatabaseService`) and
|
|
97
|
+
* `SqliteCacheEngine` (via a SurrealQL-vocabulary shim) both satisfy this, so
|
|
98
|
+
* either can back `this.local`.
|
|
99
|
+
*
|
|
100
|
+
* `getClient()` returns the underlying SurrealDB `Surreal` handle where one
|
|
101
|
+
* exists (SurrealDB backend); backends without one (SQLite) throw — it is only
|
|
102
|
+
* used by advanced/DevTools paths, never on the hot path.
|
|
103
|
+
*/
|
|
104
|
+
export interface LocalStore extends LocalCacheEngine {
|
|
105
|
+
/**
|
|
106
|
+
* Whether this engine needs SurrealQL schema provisioning (`DEFINE TABLE`,
|
|
107
|
+
* `DEFINE FIELD`, …) run against it at init / bucket switch. SurrealDB → true;
|
|
108
|
+
* schemaless engines (SQLite creates tables lazily) → false, so the client
|
|
109
|
+
* skips the `LocalMigrator` entirely for them.
|
|
110
|
+
*/
|
|
111
|
+
readonly usesSurqlSchema: boolean;
|
|
112
|
+
query<T extends unknown[]>(
|
|
113
|
+
query: string,
|
|
114
|
+
vars?: Record<string, unknown>,
|
|
115
|
+
opts?: { epoch?: number }
|
|
116
|
+
): Promise<T>;
|
|
117
|
+
execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>, opts?: { epoch?: number }): Promise<T>;
|
|
118
|
+
queryUngated<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T>;
|
|
119
|
+
switchStore(bucketId: string): Promise<void>;
|
|
120
|
+
beginSwitch(): () => void;
|
|
121
|
+
getEvents(): DatabaseEventSystem;
|
|
122
|
+
getClient(): unknown;
|
|
123
|
+
getConfig(): Sp00kyConfig<any>['database'];
|
|
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>;
|
|
132
|
+
/**
|
|
133
|
+
* Durability of this engine's local store. OPTIONAL: engines that don't
|
|
134
|
+
* report it (SurrealDB, custom engines) are treated as `'unknown'` by the
|
|
135
|
+
* client facade, so adding this needs no change on their side.
|
|
136
|
+
*/
|
|
137
|
+
readonly storageHealth?: StorageHealth;
|
|
138
|
+
/** Fires immediately with the current snapshot, then on every change.
|
|
139
|
+
* Returns an unsubscribe function. */
|
|
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;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Selected local cache backend. Mirrors the `persistenceClient` config pattern. */
|
|
177
|
+
export type LocalEngineChoice = 'surrealdb' | 'sqlite' | LocalStore;
|
|
178
|
+
|
|
179
|
+
/** Thrown when relation decomposition nests past {@link MAX_RELATION_DEPTH} —
|
|
180
|
+
* a guard against a cyclic schema producing unbounded fan-out. */
|
|
181
|
+
export class RelationCycleError extends Error {
|
|
182
|
+
constructor(path: string[]) {
|
|
183
|
+
super(`Relation nesting exceeded safe depth; possible cyclic schema: ${path.join(' -> ')}`);
|
|
184
|
+
this.name = 'RelationCycleError';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Defensive ceiling on relation nesting depth. A finite plan tree never hits
|
|
189
|
+
* this in practice; it exists so a malformed/cyclic plan fails loudly instead
|
|
190
|
+
* of running away. */
|
|
191
|
+
export const MAX_RELATION_DEPTH = 12;
|
|
192
|
+
|
|
193
|
+
export type { QueryPlan, RelationPlan, WhereNode };
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ConnectionSupervisor } from './connection-supervisor';
|
|
3
|
+
import type { ReconnectConfig } from '../../types';
|
|
4
|
+
|
|
5
|
+
// The supervisor covers the two failure modes the SDK's own reconnect cannot:
|
|
6
|
+
// - it stopped trying (attempts exhausted, or its post-reconnect handshake
|
|
7
|
+
// threw and it terminated the engine) — nothing else would ever reconnect
|
|
8
|
+
// - the socket never closed at all (half-open: peer gone, readyState OPEN,
|
|
9
|
+
// no `close` event, so no reconnect is ever triggered)
|
|
10
|
+
|
|
11
|
+
const CONFIG: Required<ReconnectConfig> = {
|
|
12
|
+
attempts: -1,
|
|
13
|
+
retryDelayMax: 8_000,
|
|
14
|
+
heartbeatIntervalMs: 1_000,
|
|
15
|
+
heartbeatTimeoutMs: 500,
|
|
16
|
+
superviseRetryDelayMaxMs: 4_000,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function makeRemote() {
|
|
20
|
+
const handlers = new Map<string, Array<(...a: any[]) => void>>();
|
|
21
|
+
const state = { status: 'connected' as string };
|
|
22
|
+
const remote: any = {
|
|
23
|
+
getStatus: () => state.status,
|
|
24
|
+
getReconnectConfig: () => CONFIG,
|
|
25
|
+
subscribeConnection: (event: string, cb: (...a: any[]) => void) => {
|
|
26
|
+
const arr = handlers.get(event) ?? [];
|
|
27
|
+
arr.push(cb);
|
|
28
|
+
handlers.set(event, arr);
|
|
29
|
+
return () => {
|
|
30
|
+
handlers.set(
|
|
31
|
+
event,
|
|
32
|
+
(handlers.get(event) ?? []).filter((h) => h !== cb)
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
connect: vi.fn().mockImplementation(async () => {
|
|
37
|
+
state.status = 'connected';
|
|
38
|
+
}),
|
|
39
|
+
forceClose: vi.fn().mockImplementation(async () => {
|
|
40
|
+
state.status = 'disconnected';
|
|
41
|
+
emit('disconnected');
|
|
42
|
+
}),
|
|
43
|
+
query: vi.fn().mockResolvedValue([true]),
|
|
44
|
+
};
|
|
45
|
+
function emit(event: string, ...args: any[]) {
|
|
46
|
+
for (const cb of Array.from(handlers.get(event) ?? [])) cb(...args);
|
|
47
|
+
}
|
|
48
|
+
return { remote, emit, state, handlerCount: () => handlers.size };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const silentLogger: any = (() => {
|
|
52
|
+
const l: any = {
|
|
53
|
+
child: () => l,
|
|
54
|
+
debug: () => {},
|
|
55
|
+
info: () => {},
|
|
56
|
+
warn: () => {},
|
|
57
|
+
error: () => {},
|
|
58
|
+
trace: () => {},
|
|
59
|
+
};
|
|
60
|
+
return l;
|
|
61
|
+
})();
|
|
62
|
+
|
|
63
|
+
function makeSupervisor(remote: any) {
|
|
64
|
+
return new ConnectionSupervisor(remote, silentLogger, CONFIG);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('ConnectionSupervisor', () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.useFakeTimers();
|
|
70
|
+
vi.clearAllMocks();
|
|
71
|
+
});
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
vi.useRealTimers();
|
|
74
|
+
vi.unstubAllGlobals();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('reconnects after the SDK gives up, retrying on backoff until it succeeds', async () => {
|
|
78
|
+
const { remote, emit, state } = makeRemote();
|
|
79
|
+
// Two failures, then success — proves the loop keeps going rather than
|
|
80
|
+
// giving up like the SDK does.
|
|
81
|
+
remote.connect
|
|
82
|
+
.mockRejectedValueOnce(new Error('refused'))
|
|
83
|
+
.mockRejectedValueOnce(new Error('refused'))
|
|
84
|
+
.mockImplementationOnce(async () => {
|
|
85
|
+
state.status = 'connected';
|
|
86
|
+
emit('connected');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const sup = makeSupervisor(remote);
|
|
90
|
+
sup.start();
|
|
91
|
+
|
|
92
|
+
state.status = 'disconnected';
|
|
93
|
+
emit('disconnected');
|
|
94
|
+
expect(sup.connection).toBe('disconnected');
|
|
95
|
+
|
|
96
|
+
// 1s, then 2s, then 4s (capped at superviseRetryDelayMaxMs).
|
|
97
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
98
|
+
expect(remote.connect).toHaveBeenCalledTimes(1);
|
|
99
|
+
expect(sup.connection).toBe('reconnecting');
|
|
100
|
+
|
|
101
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
102
|
+
expect(remote.connect).toHaveBeenCalledTimes(2);
|
|
103
|
+
|
|
104
|
+
await vi.advanceTimersByTimeAsync(4_000);
|
|
105
|
+
expect(remote.connect).toHaveBeenCalledTimes(3);
|
|
106
|
+
expect(sup.connection).toBe('connected');
|
|
107
|
+
|
|
108
|
+
// Recovered: no further attempts.
|
|
109
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
110
|
+
expect(remote.connect).toHaveBeenCalledTimes(3);
|
|
111
|
+
|
|
112
|
+
sup.dispose();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not reconnect while the SDK is still retrying', async () => {
|
|
116
|
+
const { remote, emit, state } = makeRemote();
|
|
117
|
+
const sup = makeSupervisor(remote);
|
|
118
|
+
sup.start();
|
|
119
|
+
|
|
120
|
+
state.status = 'reconnecting';
|
|
121
|
+
emit('reconnecting');
|
|
122
|
+
expect(sup.connection).toBe('reconnecting');
|
|
123
|
+
|
|
124
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
125
|
+
// The SDK owns the socket during its own retry loop; racing it would open
|
|
126
|
+
// a second connection.
|
|
127
|
+
expect(remote.connect).not.toHaveBeenCalled();
|
|
128
|
+
|
|
129
|
+
sup.dispose();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('forces a teardown when a heartbeat never answers (half-open socket)', async () => {
|
|
133
|
+
const { remote, emit } = makeRemote();
|
|
134
|
+
// The defining symptom: the RPC never settles and no `close` ever fires.
|
|
135
|
+
remote.query.mockImplementation(() => new Promise(() => {}));
|
|
136
|
+
|
|
137
|
+
const sup = makeSupervisor(remote);
|
|
138
|
+
emit('connected');
|
|
139
|
+
sup.start();
|
|
140
|
+
|
|
141
|
+
await vi.advanceTimersByTimeAsync(CONFIG.heartbeatIntervalMs);
|
|
142
|
+
expect(remote.query).toHaveBeenCalledWith('RETURN true');
|
|
143
|
+
expect(remote.forceClose).not.toHaveBeenCalled();
|
|
144
|
+
|
|
145
|
+
// One failure is inconclusive — the probe shares a queue with ordinary
|
|
146
|
+
// traffic, so it re-probes rather than tearing down a possibly-fine socket.
|
|
147
|
+
await vi.advanceTimersByTimeAsync(CONFIG.heartbeatTimeoutMs);
|
|
148
|
+
expect(remote.forceClose).not.toHaveBeenCalled();
|
|
149
|
+
|
|
150
|
+
// The second consecutive failure is the one that tears it down.
|
|
151
|
+
await vi.advanceTimersByTimeAsync(5_000 + CONFIG.heartbeatTimeoutMs);
|
|
152
|
+
expect(remote.forceClose).toHaveBeenCalledTimes(1);
|
|
153
|
+
|
|
154
|
+
// The forced close published `disconnected`, so the revive loop takes over.
|
|
155
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
156
|
+
expect(remote.connect).toHaveBeenCalled();
|
|
157
|
+
|
|
158
|
+
sup.dispose();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('does not tear down a healthy socket after a single slow probe', async () => {
|
|
162
|
+
// The regression this guards: the heartbeat rides the same serialized queue
|
|
163
|
+
// as every other RPC, so one busy window (a big sync burst) used to
|
|
164
|
+
// force-close a working connection — and the reconnect then re-registered
|
|
165
|
+
// every active query about a second later.
|
|
166
|
+
const { remote, emit } = makeRemote();
|
|
167
|
+
let calls = 0;
|
|
168
|
+
remote.query.mockImplementation(() => {
|
|
169
|
+
calls++;
|
|
170
|
+
// First probe hangs past its deadline, the next answers normally.
|
|
171
|
+
return calls === 1 ? new Promise(() => {}) : Promise.resolve(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const sup = makeSupervisor(remote);
|
|
175
|
+
emit('connected');
|
|
176
|
+
sup.start();
|
|
177
|
+
|
|
178
|
+
await vi.advanceTimersByTimeAsync(CONFIG.heartbeatIntervalMs + CONFIG.heartbeatTimeoutMs);
|
|
179
|
+
expect(remote.forceClose).not.toHaveBeenCalled();
|
|
180
|
+
|
|
181
|
+
// The retry succeeds, so the socket survives and heartbeating continues.
|
|
182
|
+
await vi.advanceTimersByTimeAsync(5_000 + CONFIG.heartbeatIntervalMs * 2);
|
|
183
|
+
expect(remote.forceClose).not.toHaveBeenCalled();
|
|
184
|
+
expect(sup.connection).toBe('connected');
|
|
185
|
+
|
|
186
|
+
sup.dispose();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('keeps heartbeating while the connection is healthy', async () => {
|
|
190
|
+
const { remote, emit } = makeRemote();
|
|
191
|
+
const sup = makeSupervisor(remote);
|
|
192
|
+
emit('connected');
|
|
193
|
+
sup.start();
|
|
194
|
+
|
|
195
|
+
await vi.advanceTimersByTimeAsync(CONFIG.heartbeatIntervalMs * 3 + 10);
|
|
196
|
+
expect(remote.query.mock.calls.length).toBeGreaterThanOrEqual(3);
|
|
197
|
+
expect(remote.forceClose).not.toHaveBeenCalled();
|
|
198
|
+
expect(sup.connection).toBe('connected');
|
|
199
|
+
|
|
200
|
+
sup.dispose();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('parks reconnects while the browser reports offline, and resumes on online', async () => {
|
|
204
|
+
const listeners = new Map<string, Array<() => void>>();
|
|
205
|
+
vi.stubGlobal('window', {
|
|
206
|
+
addEventListener: (e: string, cb: () => void) => {
|
|
207
|
+
listeners.set(e, [...(listeners.get(e) ?? []), cb]);
|
|
208
|
+
},
|
|
209
|
+
removeEventListener: () => {},
|
|
210
|
+
});
|
|
211
|
+
const { remote, emit, state } = makeRemote();
|
|
212
|
+
const sup = makeSupervisor(remote);
|
|
213
|
+
sup.start();
|
|
214
|
+
|
|
215
|
+
state.status = 'disconnected';
|
|
216
|
+
emit('disconnected');
|
|
217
|
+
listeners.get('offline')?.forEach((cb) => cb());
|
|
218
|
+
|
|
219
|
+
// Retrying a socket against a down interface only burns backoff.
|
|
220
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
221
|
+
expect(remote.connect).not.toHaveBeenCalled();
|
|
222
|
+
|
|
223
|
+
// `online` is the strongest available hint a reconnect will now work, so it
|
|
224
|
+
// probes immediately rather than waiting out a backoff.
|
|
225
|
+
listeners.get('online')?.forEach((cb) => cb());
|
|
226
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
227
|
+
expect(remote.connect).toHaveBeenCalledTimes(1);
|
|
228
|
+
|
|
229
|
+
sup.dispose();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('probes immediately when a hidden tab becomes visible', async () => {
|
|
233
|
+
const listeners = new Map<string, Array<() => void>>();
|
|
234
|
+
vi.stubGlobal('document', {
|
|
235
|
+
visibilityState: 'visible',
|
|
236
|
+
addEventListener: (e: string, cb: () => void) => {
|
|
237
|
+
listeners.set(e, [...(listeners.get(e) ?? []), cb]);
|
|
238
|
+
},
|
|
239
|
+
removeEventListener: () => {},
|
|
240
|
+
});
|
|
241
|
+
const { remote, emit } = makeRemote();
|
|
242
|
+
const sup = makeSupervisor(remote);
|
|
243
|
+
emit('connected');
|
|
244
|
+
sup.start();
|
|
245
|
+
|
|
246
|
+
listeners.get('visibilitychange')?.forEach((cb) => cb());
|
|
247
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
248
|
+
// Connected-looking socket: probe it rather than reconnect. A sleep/wake
|
|
249
|
+
// cycle is exactly how you get a socket that looks fine and isn't.
|
|
250
|
+
expect(remote.query).toHaveBeenCalledWith('RETURN true');
|
|
251
|
+
|
|
252
|
+
sup.dispose();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('dispose stops every timer and unsubscribes', async () => {
|
|
256
|
+
const { remote, emit, state } = makeRemote();
|
|
257
|
+
const sup = makeSupervisor(remote);
|
|
258
|
+
emit('connected');
|
|
259
|
+
sup.start();
|
|
260
|
+
|
|
261
|
+
sup.dispose();
|
|
262
|
+
state.status = 'disconnected';
|
|
263
|
+
emit('disconnected');
|
|
264
|
+
|
|
265
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
266
|
+
expect(remote.connect).not.toHaveBeenCalled();
|
|
267
|
+
expect(remote.query).not.toHaveBeenCalled();
|
|
268
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('reports state changes to subscribers', () => {
|
|
272
|
+
const { remote, emit, state } = makeRemote();
|
|
273
|
+
const sup = makeSupervisor(remote);
|
|
274
|
+
sup.start();
|
|
275
|
+
|
|
276
|
+
const seen: string[] = [];
|
|
277
|
+
sup.subscribe((s) => seen.push(s));
|
|
278
|
+
// Fires immediately with the current value.
|
|
279
|
+
expect(seen).toEqual(['connected']);
|
|
280
|
+
|
|
281
|
+
state.status = 'reconnecting';
|
|
282
|
+
emit('reconnecting');
|
|
283
|
+
state.status = 'connected';
|
|
284
|
+
emit('connected');
|
|
285
|
+
expect(seen).toEqual(['connected', 'reconnecting', 'connected']);
|
|
286
|
+
|
|
287
|
+
sup.dispose();
|
|
288
|
+
});
|
|
289
|
+
});
|