@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201
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 +2184 -54
- package/dist/index.js +11515 -2399
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +276 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +421 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +688 -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 +35 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +112 -32
- 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 +462 -0
- package/src/modules/data/data.rebind.test.ts +147 -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 +1580 -130
- 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 +386 -37
- package/src/modules/devtools/notify-throttle.test.ts +149 -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 +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +107 -0
- package/src/modules/sync/queue/queue-down.ts +35 -6
- 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 +156 -0
- package/src/modules/sync/scheduler.ts +158 -11
- 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 +134 -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.ts +1558 -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 +160 -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 +32 -12
- package/src/services/database/engine-factory.ts +33 -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 +478 -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 +558 -0
- package/src/services/database/sqlite-cache-engine.ts +1257 -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-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 +106 -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 +121 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +221 -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 +141 -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 +444 -52
- 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 +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +23 -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 +244 -0
- package/src/services/tabs/coordinator.ts +576 -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 +242 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +586 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1543 -0
- package/src/types.ts +496 -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 +73 -13
- package/src/utils/parser.ts +3 -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,121 @@
|
|
|
1
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
2
|
+
import { resolveRelations, stableKey } from './relation-resolver';
|
|
3
|
+
import { renderOrderSql, renderWhereSql, reviveRow, project } from './sqlite-plan-sql';
|
|
4
|
+
import type { OrderBy, RelationFetch, Row, RowFetcher } from './cache-engine';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worker-side execution of a whole {@link QueryPlan} — table creation, base
|
|
8
|
+
* select (either the `ids` window or where/order/limit), projection, and the
|
|
9
|
+
* full `.related()` tree via the SHARED `resolveRelations` — against an
|
|
10
|
+
* injected DB handle. Runs inside `sqlite-worker.ts` so the engine pays ONE
|
|
11
|
+
* postMessage round-trip per select instead of one per table/relation level;
|
|
12
|
+
* extracted into its own module so the logic is unit-testable off-worker
|
|
13
|
+
* (parity with the engine's legacy multi-hop path).
|
|
14
|
+
*
|
|
15
|
+
* Semantics mirror `SqliteCacheEngine.selectLegacy` exactly: same SQL, same
|
|
16
|
+
* ordering rules, same projection, same resolver.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The slice of the worker's DB surface `executeSelect` needs. */
|
|
20
|
+
export interface SelectDb {
|
|
21
|
+
/** Run a row-returning statement; rows come back as `{ data: <json> }`. */
|
|
22
|
+
exec(sql: string, bind?: unknown[]): { data: string }[];
|
|
23
|
+
/** Run a statement for effect only (CREATE TABLE). */
|
|
24
|
+
run(sql: string, bind?: unknown[]): void;
|
|
25
|
+
/** Tables already CREATEd on this handle (caller owns the lifecycle). */
|
|
26
|
+
knownTables: Set<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureTable(db: SelectDb, table: string): void {
|
|
30
|
+
if (db.knownTables.has(table)) return;
|
|
31
|
+
db.run(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`);
|
|
32
|
+
db.knownTables.add(table);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function execRows(db: SelectDb, sql: string, bind: unknown[]): Row[] {
|
|
36
|
+
return db.exec(sql, bind).map((r) => reviveRow(r.data));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Mirrors the engine's `selectByIds`: fetch by primary id, preserving `ids`
|
|
40
|
+
* order unless an ORDER BY overrides it. */
|
|
41
|
+
function selectByIds(
|
|
42
|
+
db: SelectDb,
|
|
43
|
+
table: string,
|
|
44
|
+
ids: unknown[],
|
|
45
|
+
opts?: { select?: string[]; orderBy?: OrderBy }
|
|
46
|
+
): Row[] {
|
|
47
|
+
if (ids.length === 0) return [];
|
|
48
|
+
ensureTable(db, table);
|
|
49
|
+
const keys = ids.map(stableKey);
|
|
50
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
51
|
+
let sql = `SELECT data FROM "${table}" WHERE id IN (${placeholders})`;
|
|
52
|
+
if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
|
|
53
|
+
let rows = execRows(db, sql, keys);
|
|
54
|
+
if (!opts?.orderBy || opts.orderBy.length === 0) {
|
|
55
|
+
const pos = new Map(keys.map((k, i) => [k, i]));
|
|
56
|
+
rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
|
|
57
|
+
}
|
|
58
|
+
return opts?.select ? rows.map((r) => project(r, opts.select!)) : rows;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Mirrors the engine's `fetchRelation` SQL exactly. */
|
|
62
|
+
function fetchRelation(db: SelectDb, req: RelationFetch): Row[] {
|
|
63
|
+
ensureTable(db, req.table);
|
|
64
|
+
const keys = req.keys.map(stableKey);
|
|
65
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
66
|
+
const bind: unknown[] = [...keys];
|
|
67
|
+
const lhs = req.matchField === 'id' ? 'id' : `json_extract(data, '$.${req.matchField}')`;
|
|
68
|
+
let sql = `SELECT data FROM "${req.table}" WHERE ${lhs} IN (${placeholders})`;
|
|
69
|
+
if (req.where && req.where.length > 0) {
|
|
70
|
+
sql += ` AND ${renderWhereSql(req.where, bind, {})}`;
|
|
71
|
+
}
|
|
72
|
+
if (req.orderBy && req.orderBy.length > 0) sql += renderOrderSql(req.orderBy);
|
|
73
|
+
const rows = execRows(db, sql, bind);
|
|
74
|
+
return req.select ? rows.map((r) => project(r, req.select!)) : rows;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function executeSelect(
|
|
78
|
+
plan: QueryPlan,
|
|
79
|
+
params: Record<string, unknown>,
|
|
80
|
+
db: SelectDb
|
|
81
|
+
): Promise<{ rows: Row[]; relationFetches: number }> {
|
|
82
|
+
// Per-call fetch counter (the engine folds it into `__sqliteStats`).
|
|
83
|
+
const counter = { n: 0 };
|
|
84
|
+
const fetcher: RowFetcher = {
|
|
85
|
+
fetchRelation: (req) => {
|
|
86
|
+
counter.n++;
|
|
87
|
+
return Promise.resolve(fetchRelation(db, req));
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
// Window materialization: base rows are exactly `plan.ids`, ordered.
|
|
91
|
+
if (plan.ids) {
|
|
92
|
+
const rows = selectByIds(db, plan.table, plan.ids, {
|
|
93
|
+
select: plan.select,
|
|
94
|
+
orderBy: plan.orderBy,
|
|
95
|
+
});
|
|
96
|
+
await resolveRelations(rows, plan.relations, fetcher);
|
|
97
|
+
return { rows, relationFetches: counter.n };
|
|
98
|
+
}
|
|
99
|
+
ensureTable(db, plan.table);
|
|
100
|
+
const bind: unknown[] = [];
|
|
101
|
+
let sql = `SELECT data FROM "${plan.table}"`;
|
|
102
|
+
if (plan.where && plan.where.length > 0) {
|
|
103
|
+
sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
|
|
104
|
+
}
|
|
105
|
+
if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
|
|
106
|
+
// A query with no ORDER BY still has to render in SOME order, and "whatever
|
|
107
|
+
// SQLite hands back" is insertion order — which disagrees with the order the
|
|
108
|
+
// same query gets once it renders from server membership, and disagrees with
|
|
109
|
+
// SurrealDB, whose natural order is by id. That mismatch is visible: the
|
|
110
|
+
// first paint comes from this scan and the second from membership, so an
|
|
111
|
+
// unordered list visibly reshuffled about a second after load. Ordering by
|
|
112
|
+
// id here makes the two agree and makes the result stable across reloads.
|
|
113
|
+
else sql += ` ORDER BY id`;
|
|
114
|
+
if (plan.limit !== undefined) sql += ` LIMIT ${Number(plan.limit)}`;
|
|
115
|
+
if (plan.offset !== undefined) sql += ` OFFSET ${Number(plan.offset)}`;
|
|
116
|
+
const rows = execRows(db, sql, bind);
|
|
117
|
+
// Optional projection trimming to match `SELECT <fields>`.
|
|
118
|
+
const projected = plan.select ? rows.map((r) => project(r, plan.select!)) : rows;
|
|
119
|
+
await resolveRelations(projected, plan.relations, fetcher);
|
|
120
|
+
return { rows: projected, relationFetches: counter.n };
|
|
121
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only helper: stubs `SqliteCacheEngine.createTransport` with a fake
|
|
3
|
+
* transport driven by a plain handler function, replacing the old pattern of
|
|
4
|
+
* faking a whole Worker plus the engine's pending-map wiring. Not exported
|
|
5
|
+
* from the package; imported only by *.test.ts files.
|
|
6
|
+
*/
|
|
7
|
+
import type { SqliteTransport } from './sqlite-transport';
|
|
8
|
+
|
|
9
|
+
export type FakeTransportHandler = (type: string, payload: any) => unknown | Promise<unknown>;
|
|
10
|
+
|
|
11
|
+
/** Replace the engine's transport factory. Each open spawns a fresh fake, like
|
|
12
|
+
* the real factory spawns a fresh Worker. The handler returns the reply rest
|
|
13
|
+
* (without id/ok/wt) or throws to produce an error reply. */
|
|
14
|
+
export function stubTransport(engine: unknown, handler: FakeTransportHandler): void {
|
|
15
|
+
(engine as { createTransport: () => SqliteTransport }).createTransport = () => {
|
|
16
|
+
let closed = false;
|
|
17
|
+
return {
|
|
18
|
+
kind: 'worker',
|
|
19
|
+
get connected() {
|
|
20
|
+
return !closed;
|
|
21
|
+
},
|
|
22
|
+
call: <T>(type: string, payload?: unknown) =>
|
|
23
|
+
Promise.resolve().then(() => handler(type, payload)) as Promise<T>,
|
|
24
|
+
failAll() {},
|
|
25
|
+
close() {
|
|
26
|
+
closed = true;
|
|
27
|
+
},
|
|
28
|
+
} as SqliteTransport;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire between `SqliteCacheEngine` and the SQLite worker, extracted so the
|
|
3
|
+
* engine can swap it at runtime without touching any SQL logic. Two shapes:
|
|
4
|
+
*
|
|
5
|
+
* - {@link WorkerSqliteTransport}: owns a dedicated Worker (solo mode, and the
|
|
6
|
+
* leader tab in shared-tabs mode). Controls the worker's lifecycle and can
|
|
7
|
+
* forward follower MessagePorts into it (`add-client`).
|
|
8
|
+
* - {@link PortSqliteTransport}: speaks the same request/response protocol over
|
|
9
|
+
* a MessagePort handed out by the leader (follower tabs). No lifecycle
|
|
10
|
+
* control; the port dying surfaces through `onPortDead`.
|
|
11
|
+
*
|
|
12
|
+
* Both keep the pending-map semantics the engine had inline before: requests
|
|
13
|
+
* keyed by a locally-minted numeric id, one resolve/reject per id, `failAll`
|
|
14
|
+
* rejects everything in flight (worker crash, port loss, role change).
|
|
15
|
+
*/
|
|
16
|
+
import type { Logger } from '../logger/index';
|
|
17
|
+
|
|
18
|
+
export interface SqliteTransport {
|
|
19
|
+
readonly kind: 'worker' | 'port';
|
|
20
|
+
/** True while the transport can carry a request. */
|
|
21
|
+
readonly connected: boolean;
|
|
22
|
+
call<T = unknown>(type: string, payload?: unknown): Promise<T>;
|
|
23
|
+
/** Reject every pending request with `reason`. Safe to call repeatedly. */
|
|
24
|
+
failAll(reason: string): void;
|
|
25
|
+
/** failAll + release the underlying channel. Terminal. `err` overrides the
|
|
26
|
+
* transport's own error shape — used for a deliberate teardown (role change)
|
|
27
|
+
* so callers see a retryable "transport lost", not "the worker crashed". */
|
|
28
|
+
close(reason?: string, err?: Error): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Thrown into pending follower calls when the leader (or its port) goes away.
|
|
32
|
+
* Callers treat it like a transient error: the op may be retried once a new
|
|
33
|
+
* leader is attached; nothing was necessarily executed. */
|
|
34
|
+
export class BrokerPortClosedError extends Error {
|
|
35
|
+
readonly retryable = true;
|
|
36
|
+
constructor(reason: string) {
|
|
37
|
+
super(`sqlite transport lost: ${reason}`);
|
|
38
|
+
this.name = 'BrokerPortClosedError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Pending {
|
|
43
|
+
resolve: (v: any) => void;
|
|
44
|
+
reject: (e: unknown) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Shared request/response bookkeeping over any postMessage-shaped channel. */
|
|
48
|
+
abstract class BaseTransport implements SqliteTransport {
|
|
49
|
+
abstract readonly kind: 'worker' | 'port';
|
|
50
|
+
protected pending = new Map<number, Pending>();
|
|
51
|
+
protected seq = 0;
|
|
52
|
+
protected closed = false;
|
|
53
|
+
|
|
54
|
+
constructor(protected logger: Logger) {}
|
|
55
|
+
|
|
56
|
+
get connected(): boolean {
|
|
57
|
+
return !this.closed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
protected abstract post(msg: unknown, transfer?: Transferable[]): void;
|
|
61
|
+
protected abstract makeError(reason: string): Error;
|
|
62
|
+
|
|
63
|
+
protected handleMessage(data: any): void {
|
|
64
|
+
const { id, ok, error, ...rest } = data ?? {};
|
|
65
|
+
const p = this.pending.get(id);
|
|
66
|
+
if (!p) return;
|
|
67
|
+
this.pending.delete(id);
|
|
68
|
+
if (ok) p.resolve(rest);
|
|
69
|
+
else p.reject(new Error(error));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
call<T = unknown>(type: string, payload?: unknown): Promise<T> {
|
|
73
|
+
if (this.closed) return Promise.reject(this.makeError('transport closed'));
|
|
74
|
+
const id = ++this.seq;
|
|
75
|
+
return new Promise<T>((resolve, reject) => {
|
|
76
|
+
this.pending.set(id, { resolve, reject });
|
|
77
|
+
try {
|
|
78
|
+
this.post({ id, type, payload });
|
|
79
|
+
} catch (e) {
|
|
80
|
+
this.pending.delete(id);
|
|
81
|
+
reject(e);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
failAll(reason: string, err?: Error): void {
|
|
87
|
+
if (this.pending.size === 0) return;
|
|
88
|
+
const e = err ?? this.makeError(reason);
|
|
89
|
+
for (const [, p] of this.pending) p.reject(e);
|
|
90
|
+
this.pending.clear();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
close(reason = 'closed', err?: Error): void {
|
|
94
|
+
if (this.closed) return;
|
|
95
|
+
this.closed = true;
|
|
96
|
+
this.failAll(reason, err);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class WorkerSqliteTransport extends BaseTransport {
|
|
101
|
+
readonly kind = 'worker' as const;
|
|
102
|
+
private worker: Worker;
|
|
103
|
+
/** Fired when the worker reports it fenced itself after a leadership steal
|
|
104
|
+
* (see sqlite-worker.ts). The engine must treat the store as gone. */
|
|
105
|
+
onLockLost: ((reason: string) => void) | null = null;
|
|
106
|
+
|
|
107
|
+
constructor(logger: Logger) {
|
|
108
|
+
super(logger);
|
|
109
|
+
// Source references the `.ts` so the monorepo's src-bundling consumers
|
|
110
|
+
// (e.g. the example app, which aliases `@spooky-sync/core` to `src`)
|
|
111
|
+
// resolve it — Vite handles `.ts` workers. For the published package, the
|
|
112
|
+
// tsdown build rewrites this to `./sqlite-worker.js` (the top-level
|
|
113
|
+
// emitted entry; see tsdown.config.ts), which the flat `dist/index.js`
|
|
114
|
+
// resolves. The worker (+ `@sqlite.org/sqlite-wasm`) still loads lazily —
|
|
115
|
+
// only when `localEngine: 'sqlite'` is used.
|
|
116
|
+
this.worker = new Worker(new URL('./sqlite-worker.ts', import.meta.url), { type: 'module' });
|
|
117
|
+
this.worker.onmessage = (ev: MessageEvent) => {
|
|
118
|
+
// Unsolicited worker-initiated notification, not a reply.
|
|
119
|
+
if (ev.data?.type === 'lock-lost') {
|
|
120
|
+
this.logger.error(
|
|
121
|
+
{ reason: ev.data.reason, Category: 'sp00ky-client::SqliteCacheEngine::worker' },
|
|
122
|
+
'SQLite worker fenced after leadership loss'
|
|
123
|
+
);
|
|
124
|
+
this.failAll('worker fenced');
|
|
125
|
+
this.onLockLost?.(String(ev.data.reason ?? 'lock-lost'));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.handleMessage(ev.data);
|
|
129
|
+
};
|
|
130
|
+
// Surface a worker crash (wasm abort / OOM) instead of leaving every
|
|
131
|
+
// pending call hung forever — reject them all with a clear error.
|
|
132
|
+
const crash = (msg: string) => {
|
|
133
|
+
this.logger.error(
|
|
134
|
+
{ err: this.makeError(msg), Category: 'sp00ky-client::SqliteCacheEngine::worker' },
|
|
135
|
+
'Worker error'
|
|
136
|
+
);
|
|
137
|
+
this.failAll(msg);
|
|
138
|
+
};
|
|
139
|
+
this.worker.onerror = (e: ErrorEvent) => crash(e.message || 'onerror');
|
|
140
|
+
this.worker.onmessageerror = () => crash('messageerror');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
protected post(msg: unknown, transfer?: Transferable[]): void {
|
|
144
|
+
if (transfer) this.worker.postMessage(msg, transfer);
|
|
145
|
+
else this.worker.postMessage(msg);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
protected makeError(reason: string): Error {
|
|
149
|
+
return new Error(`SQLite worker crashed: ${reason}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Forward a follower's MessagePort into the worker as an extra client. */
|
|
153
|
+
addClientPort(clientId: string, port: MessagePort): Promise<void> {
|
|
154
|
+
if (this.closed) return Promise.reject(this.makeError('transport closed'));
|
|
155
|
+
const id = ++this.seq;
|
|
156
|
+
return new Promise<void>((resolve, reject) => {
|
|
157
|
+
this.pending.set(id, { resolve: () => resolve(), reject });
|
|
158
|
+
this.worker.postMessage({ id, type: 'add-client', payload: { clientId } }, [port]);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
removeClientPort(clientId: string): Promise<void> {
|
|
163
|
+
return this.call('remove-client', { clientId }).then(() => undefined);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Ask the worker to close + pauseVfs + self-close (graceful pagehide). */
|
|
167
|
+
shutdown(): Promise<void> {
|
|
168
|
+
return this.call('shutdown').then(() => undefined);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
close(reason = 'closed', err?: Error): void {
|
|
172
|
+
if (this.closed) return;
|
|
173
|
+
super.close(reason, err);
|
|
174
|
+
this.worker.terminate();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export class PortSqliteTransport extends BaseTransport {
|
|
179
|
+
readonly kind = 'port' as const;
|
|
180
|
+
|
|
181
|
+
constructor(
|
|
182
|
+
private port: MessagePort,
|
|
183
|
+
private onPortDead: (reason: string) => void,
|
|
184
|
+
logger: Logger
|
|
185
|
+
) {
|
|
186
|
+
super(logger);
|
|
187
|
+
port.onmessage = (ev: MessageEvent) => this.handleMessage(ev.data);
|
|
188
|
+
port.onmessageerror = () => this.dead('messageerror');
|
|
189
|
+
port.start?.();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The broker/coordinator learned the leader is gone; the port itself has no
|
|
193
|
+
* close event, so the coordinator calls this explicitly. */
|
|
194
|
+
markDead(reason: string): void {
|
|
195
|
+
this.dead(reason);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private dead(reason: string, err?: Error): void {
|
|
199
|
+
if (this.closed) return;
|
|
200
|
+
this.closed = true;
|
|
201
|
+
this.failAll(reason, err);
|
|
202
|
+
try {
|
|
203
|
+
this.port.close();
|
|
204
|
+
} catch {
|
|
205
|
+
/* ignore */
|
|
206
|
+
}
|
|
207
|
+
this.onPortDead(reason);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
protected post(msg: unknown): void {
|
|
211
|
+
this.port.postMessage(msg);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
protected makeError(reason: string): Error {
|
|
215
|
+
return new BrokerPortClosedError(reason);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
close(reason = 'closed', err?: Error): void {
|
|
219
|
+
this.dead(reason, err);
|
|
220
|
+
}
|
|
221
|
+
}
|