@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.210
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,616 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { pureWriteOpResult, SqliteCacheEngine } from './sqlite-cache-engine';
|
|
4
|
+
import { stubTransport } from './sqlite-transport.fixture';
|
|
5
|
+
import { BrokerPortClosedError } from './sqlite-transport';
|
|
6
|
+
import { translateSurql } from './surql-translate';
|
|
7
|
+
import type { SqlOp } from './surql-translate';
|
|
8
|
+
import { surql } from '../../utils/surql';
|
|
9
|
+
|
|
10
|
+
function makeLogger(): any {
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
13
|
+
l.child = () => l;
|
|
14
|
+
return l;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A fake transport handler that models the real SQLite worker's open/closed
|
|
19
|
+
* lifecycle: `open` opens the DB, `close` closes it, and `exec`/`run` REJECT
|
|
20
|
+
* with "sqlite: DB not open" when the DB isn't currently open — exactly the
|
|
21
|
+
* failure the fix targets. Records every message `type` (across transport
|
|
22
|
+
* generations) so tests can also inspect dispatch order.
|
|
23
|
+
*/
|
|
24
|
+
function lifecycleHandler(log: string[]) {
|
|
25
|
+
let dbOpen = false;
|
|
26
|
+
return (type: string) => {
|
|
27
|
+
log.push(type);
|
|
28
|
+
if (type === 'open') {
|
|
29
|
+
dbOpen = true;
|
|
30
|
+
return { persisted: true };
|
|
31
|
+
}
|
|
32
|
+
if (type === 'close') {
|
|
33
|
+
dbOpen = false;
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
if (type === 'exec' || type === 'select' || type === 'run' || type === 'batch') {
|
|
37
|
+
if (!dbOpen) throw new Error('sqlite: DB not open');
|
|
38
|
+
if (type === 'exec') return { rows: [] };
|
|
39
|
+
if (type === 'select') return { rows: [], relationFetches: 0 };
|
|
40
|
+
}
|
|
41
|
+
return {};
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Regression: switchBucket must run close → reopen as a single serialized
|
|
46
|
+
// opQueue entry. Otherwise a read/write enqueued during an auth/bucket change
|
|
47
|
+
// dispatches to the just-closed worker → "sqlite: DB not open" (the crash on
|
|
48
|
+
// sign-in). The invariant: no `exec` may appear between a `close` and the next
|
|
49
|
+
// `open` in the message stream.
|
|
50
|
+
describe('SqliteCacheEngine.switchBucket serialization', () => {
|
|
51
|
+
it('never dispatches an op to a closed DB during a bucket switch', async () => {
|
|
52
|
+
const log: string[] = [];
|
|
53
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
54
|
+
stubTransport(engine, lifecycleHandler(log));
|
|
55
|
+
|
|
56
|
+
await engine.connect('anon');
|
|
57
|
+
expect(log).toContain('open');
|
|
58
|
+
|
|
59
|
+
// Fire a switch and a concurrent read (exactly what the sign-in query
|
|
60
|
+
// re-registration does). With the old non-atomic switch the read's `exec`
|
|
61
|
+
// dispatched to the closed/terminated worker and REJECTED ("sqlite: DB not
|
|
62
|
+
// open" / "not connected") — the uncaught crash. The atomic switch makes the
|
|
63
|
+
// read wait for the reopen and resolve against the new bucket.
|
|
64
|
+
const switching = engine.switchBucket('user:abc');
|
|
65
|
+
const reading = engine.getById('_00_query', 'h1');
|
|
66
|
+
await expect(Promise.all([switching, reading])).resolves.toBeDefined();
|
|
67
|
+
await expect(reading).resolves.toBeNull(); // missing row → null, not a throw
|
|
68
|
+
|
|
69
|
+
// And the close→reopen ran with no op wedged between them.
|
|
70
|
+
const closeIdx = log.indexOf('close');
|
|
71
|
+
const openAfter = log.indexOf('open', closeIdx + 1);
|
|
72
|
+
expect(openAfter).toBeGreaterThan(closeIdx);
|
|
73
|
+
expect(log.slice(closeIdx + 1, openAfter)).not.toContain('exec');
|
|
74
|
+
expect(engine.currentBucketId).toBe('user:abc');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Regression: DEFINE is a noop on this engine, so the `_00_*` internal tables
|
|
79
|
+
// the migrator DEFINEs are never physically created — a fresh bucket that READS
|
|
80
|
+
// one before any write (the sync layer selects `_00_query` at startup) threw
|
|
81
|
+
// "no such table: _00_query" and wedged the client on "Loading database". The
|
|
82
|
+
// fix seeds them inside `open`; assert the open message carries them.
|
|
83
|
+
describe('SqliteCacheEngine system-table seeding', () => {
|
|
84
|
+
it('passes the _00_* system tables to the worker open (fresh-bucket safe)', async () => {
|
|
85
|
+
const msgs: any[] = [];
|
|
86
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
87
|
+
stubTransport(engine, (type, payload) => {
|
|
88
|
+
msgs.push({ type, payload });
|
|
89
|
+
return type === 'open' ? { persisted: true } : {};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await engine.connect('user:fresh');
|
|
93
|
+
const open = msgs.find((m) => m.type === 'open');
|
|
94
|
+
expect(open?.payload?.systemTables).toContain('_00_query');
|
|
95
|
+
expect(open?.payload?.systemTables).toContain('_00_pending_mutations');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// The worker's `persisted`/`opfsError` reply used to die in a `logger.info`
|
|
100
|
+
// line, so a host app running pino at `fatal` (whitepawn does) could not tell a
|
|
101
|
+
// disk-backed store from a full-RAM one. It now lands on the engine as
|
|
102
|
+
// observable state the app can render.
|
|
103
|
+
describe('SqliteCacheEngine storage health', () => {
|
|
104
|
+
/** Engine wired to a worker whose `open` replies with `openReply`. */
|
|
105
|
+
function makeEngine(openReply: Record<string, unknown>, opts?: { useOpfs?: boolean }) {
|
|
106
|
+
const logs: { level: string; msg: string; meta: any }[] = [];
|
|
107
|
+
const logger: any = {};
|
|
108
|
+
for (const level of ['debug', 'info', 'warn', 'error', 'trace']) {
|
|
109
|
+
logger[level] = (meta: any, msg: string) => logs.push({ level, msg, meta });
|
|
110
|
+
}
|
|
111
|
+
logger.child = () => logger;
|
|
112
|
+
|
|
113
|
+
const engine = new SqliteCacheEngine(
|
|
114
|
+
{ namespace: 'n', database: 'd' } as any,
|
|
115
|
+
logger,
|
|
116
|
+
opts ?? {}
|
|
117
|
+
);
|
|
118
|
+
stubTransport(engine, (type) => (type === 'open' ? openReply : {}));
|
|
119
|
+
return { engine, logs };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
it('publishes a persistent store and logs no error', async () => {
|
|
123
|
+
const { engine, logs } = makeEngine({ persisted: true });
|
|
124
|
+
await engine.connect('user:abc');
|
|
125
|
+
|
|
126
|
+
expect(engine.storageHealth).toEqual({
|
|
127
|
+
status: 'persistent',
|
|
128
|
+
fallback: false,
|
|
129
|
+
error: undefined,
|
|
130
|
+
});
|
|
131
|
+
expect(logs.some((l) => l.level === 'error')).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('publishes the fallback, its reason, and an error log when OPFS is lost', async () => {
|
|
135
|
+
const { engine, logs } = makeEngine({
|
|
136
|
+
persisted: false,
|
|
137
|
+
opfsError: 'NoModificationAllowedError: locked',
|
|
138
|
+
});
|
|
139
|
+
await engine.connect('user:abc');
|
|
140
|
+
|
|
141
|
+
expect(engine.storageHealth).toEqual({
|
|
142
|
+
status: 'memory',
|
|
143
|
+
fallback: true,
|
|
144
|
+
error: 'NoModificationAllowedError: locked',
|
|
145
|
+
});
|
|
146
|
+
const err = logs.find((l) => l.level === 'error');
|
|
147
|
+
expect(err?.msg).toContain('IN MEMORY');
|
|
148
|
+
expect(err?.meta.opfsError).toBe('NoModificationAllowedError: locked');
|
|
149
|
+
// Inspectable from the console without any logging configured.
|
|
150
|
+
expect((globalThis as any).__sqliteStats.persisted).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// A subscriber almost always attaches AFTER connect() (components mount
|
|
154
|
+
// later), so an immediate fire is the only way it learns about a fallback.
|
|
155
|
+
it('fires a late subscriber with the current snapshot', async () => {
|
|
156
|
+
const { engine } = makeEngine({ persisted: false, opfsError: 'boom' });
|
|
157
|
+
await engine.connect('user:abc');
|
|
158
|
+
|
|
159
|
+
const seen: any[] = [];
|
|
160
|
+
const unsub = engine.subscribeToStorageHealth((h) => seen.push(h));
|
|
161
|
+
expect(seen).toEqual([{ status: 'memory', fallback: true, error: 'boom' }]);
|
|
162
|
+
unsub();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// `store: 'memory'` asked for RAM, so it is not a fallback and must not warn.
|
|
166
|
+
it('does not flag a configured in-memory store as a fallback', async () => {
|
|
167
|
+
const { engine, logs } = makeEngine({ persisted: false }, { useOpfs: false });
|
|
168
|
+
await engine.connect('user:abc');
|
|
169
|
+
|
|
170
|
+
expect(engine.storageHealth).toEqual({ status: 'memory', fallback: false, error: undefined });
|
|
171
|
+
expect(logs.some((l) => l.level === 'error')).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Storage numbers for the DevTools Storage tab: DB size via the pragmas, row
|
|
176
|
+
// counts on demand, and the configured-vs-effective workerSelect split. Errors
|
|
177
|
+
// must land in `error` (the worker may be mid bucket-switch), never throw.
|
|
178
|
+
describe('SqliteCacheEngine.getStorageDiagnostics', () => {
|
|
179
|
+
function makeEngine(execRows: (sql: string) => unknown[]) {
|
|
180
|
+
const noop = () => {};
|
|
181
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
182
|
+
logger.child = () => logger;
|
|
183
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, logger);
|
|
184
|
+
stubTransport(engine, (type, payload) =>
|
|
185
|
+
type === 'open' ? { persisted: true } : type === 'exec' ? { rows: execRows(payload.sql) } : {}
|
|
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({ namespace: 'n', database: 'd' } as any, makeLogger(), {
|
|
239
|
+
shared: true,
|
|
240
|
+
useOpfs: true,
|
|
241
|
+
});
|
|
242
|
+
stubTransport(engine, lifecycleHandler(log));
|
|
243
|
+
return { engine, log };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A fake follower dbPort: replies like the worker over postMessage. */
|
|
247
|
+
function fakeLeaderPort() {
|
|
248
|
+
const port: any = {
|
|
249
|
+
onmessage: null as null | ((ev: { data: any }) => void),
|
|
250
|
+
onmessageerror: null,
|
|
251
|
+
started: false,
|
|
252
|
+
closed: false,
|
|
253
|
+
start() {
|
|
254
|
+
this.started = true;
|
|
255
|
+
},
|
|
256
|
+
close() {
|
|
257
|
+
this.closed = true;
|
|
258
|
+
},
|
|
259
|
+
postMessage(msg: any) {
|
|
260
|
+
queueMicrotask(() => {
|
|
261
|
+
const rest =
|
|
262
|
+
msg.type === 'exec'
|
|
263
|
+
? { rows: [] }
|
|
264
|
+
: msg.type === 'select'
|
|
265
|
+
? { rows: [], relationFetches: 0 }
|
|
266
|
+
: {};
|
|
267
|
+
port.onmessage?.({ data: { id: msg.id, ok: true, wt: 0, ...rest } });
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
return port;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
it('adoptOwner opens under the worker lock and wipes stale _00_query rows', async () => {
|
|
275
|
+
const { engine, log } = makeSharedEngine();
|
|
276
|
+
const health = await engine.adoptOwner('anon', {
|
|
277
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
|
|
278
|
+
allowMemoryFallback: false,
|
|
279
|
+
resumeHeld: false,
|
|
280
|
+
});
|
|
281
|
+
expect(health.status).toBe('persistent');
|
|
282
|
+
expect(engine.storageHealth.role).toBe('leader');
|
|
283
|
+
expect(log[0]).toBe('open');
|
|
284
|
+
expect(log).toContain('run'); // the DELETE _00_query wipe
|
|
285
|
+
expect(engine.currentBucketId).toBe('anon');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('adoptAttached serves reads over the leader port and reports follower role', async () => {
|
|
289
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger(), {
|
|
290
|
+
shared: true,
|
|
291
|
+
});
|
|
292
|
+
const port = fakeLeaderPort();
|
|
293
|
+
await engine.adoptAttached(
|
|
294
|
+
port,
|
|
295
|
+
{ bucketId: 'anon', storageHealth: { status: 'persistent', fallback: false } },
|
|
296
|
+
() => {}
|
|
297
|
+
);
|
|
298
|
+
expect(port.started).toBe(true);
|
|
299
|
+
expect(engine.storageHealth).toMatchObject({ status: 'persistent', role: 'follower' });
|
|
300
|
+
await expect(engine.getById('game', 'game:1')).resolves.toBeNull();
|
|
301
|
+
expect((globalThis as any).__sqliteStats.proxiedOps).toBeGreaterThan(0);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('parks ops through a leader loss and releases them on promotion', async () => {
|
|
305
|
+
const { engine } = makeSharedEngine();
|
|
306
|
+
const port = fakeLeaderPort();
|
|
307
|
+
await engine.adoptAttached(
|
|
308
|
+
port,
|
|
309
|
+
{ bucketId: 'anon', storageHealth: { status: 'persistent', fallback: false } },
|
|
310
|
+
() => {}
|
|
311
|
+
);
|
|
312
|
+
const epochBefore = engine.epoch;
|
|
313
|
+
engine.onLeaderLost('leader tab closed');
|
|
314
|
+
expect(engine.epoch).toBe(epochBefore + 1);
|
|
315
|
+
|
|
316
|
+
// Issued during the leaderless window: must not reject immediately.
|
|
317
|
+
const read = engine.getById('_00_query', 'h1');
|
|
318
|
+
let settled = false;
|
|
319
|
+
void read.finally(() => {
|
|
320
|
+
settled = true;
|
|
321
|
+
});
|
|
322
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
323
|
+
expect(settled).toBe(false);
|
|
324
|
+
|
|
325
|
+
await engine.adoptOwner('anon', {
|
|
326
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:2',
|
|
327
|
+
allowMemoryFallback: false,
|
|
328
|
+
resumeHeld: false,
|
|
329
|
+
});
|
|
330
|
+
await expect(read).resolves.toBeNull();
|
|
331
|
+
expect(engine.storageHealth.role).toBe('leader');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* A transport with the REAL pending-map semantics (the shared fixture never
|
|
336
|
+
* rejects on close, which would make the drain test vacuous): `exec` parks
|
|
337
|
+
* until `flush()`, and `close` rejects whatever is still parked.
|
|
338
|
+
*/
|
|
339
|
+
function deferredTransport() {
|
|
340
|
+
const parked: Array<{ resolve: (v: any) => void; reject: (e: unknown) => void }> = [];
|
|
341
|
+
let closed = false;
|
|
342
|
+
const transport: any = {
|
|
343
|
+
kind: 'worker',
|
|
344
|
+
get connected() {
|
|
345
|
+
return !closed;
|
|
346
|
+
},
|
|
347
|
+
call(type: string) {
|
|
348
|
+
if (closed) return Promise.reject(new Error('SQLite worker crashed: transport closed'));
|
|
349
|
+
if (type === 'open') return Promise.resolve({ persisted: true });
|
|
350
|
+
if (type !== 'exec') return Promise.resolve({});
|
|
351
|
+
return new Promise((resolve, reject) => parked.push({ resolve, reject }));
|
|
352
|
+
},
|
|
353
|
+
shutdown: () => Promise.resolve(),
|
|
354
|
+
failAll() {},
|
|
355
|
+
close(reason = 'closed', err?: Error) {
|
|
356
|
+
if (closed) return;
|
|
357
|
+
closed = true;
|
|
358
|
+
const e = err ?? new Error(`SQLite worker crashed: ${reason}`);
|
|
359
|
+
for (const p of parked.splice(0)) p.reject(e);
|
|
360
|
+
},
|
|
361
|
+
flush() {
|
|
362
|
+
for (const p of parked.splice(0)) p.resolve({ rows: [] });
|
|
363
|
+
},
|
|
364
|
+
get parkedCount() {
|
|
365
|
+
return parked.length;
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
return transport;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Regression (WhitePawn first-login): the bucket switch runs
|
|
372
|
+
// moveToBucket → teardownLeader → releaseOwnership, which lives on the
|
|
373
|
+
// transition chain, NOT the opQueue — so a query's fetch can be sitting at
|
|
374
|
+
// the worker when ownership is released. Tearing the transport down under it
|
|
375
|
+
// rejected that fetch with "SQLite worker crashed: ownership released", which
|
|
376
|
+
// nothing retries (and nobody caught: an unhandled rejection in the console).
|
|
377
|
+
it('releaseOwnership waits for in-flight ops instead of killing them', async () => {
|
|
378
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger(), {
|
|
379
|
+
shared: true,
|
|
380
|
+
});
|
|
381
|
+
const transport = deferredTransport();
|
|
382
|
+
(engine as any).createTransport = () => transport;
|
|
383
|
+
await engine.adoptOwner('anon', {
|
|
384
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
|
|
385
|
+
allowMemoryFallback: false,
|
|
386
|
+
resumeHeld: false,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const read = engine.getById('_00_query', 'h1');
|
|
390
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
391
|
+
expect(transport.parkedCount).toBe(1);
|
|
392
|
+
|
|
393
|
+
const released = engine.releaseOwnership();
|
|
394
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
395
|
+
// Still parked: the teardown is waiting on it rather than failing it.
|
|
396
|
+
expect(transport.parkedCount).toBe(1);
|
|
397
|
+
transport.flush();
|
|
398
|
+
|
|
399
|
+
await released;
|
|
400
|
+
await expect(read).resolves.toBeNull();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('fails an undrainable op as a retryable transport loss, not a crash', async () => {
|
|
404
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger(), {
|
|
405
|
+
shared: true,
|
|
406
|
+
});
|
|
407
|
+
const transport = deferredTransport();
|
|
408
|
+
(engine as any).createTransport = () => transport;
|
|
409
|
+
await engine.adoptOwner('anon', {
|
|
410
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
|
|
411
|
+
allowMemoryFallback: false,
|
|
412
|
+
resumeHeld: false,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const read = engine.getById('_00_query', 'h1');
|
|
416
|
+
const caught = read.catch((e: unknown) => e);
|
|
417
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
418
|
+
// The op never answers: the drain times out and the teardown proceeds.
|
|
419
|
+
(engine as any).drainInFlight = () => Promise.resolve();
|
|
420
|
+
await engine.releaseOwnership();
|
|
421
|
+
|
|
422
|
+
const err = await caught;
|
|
423
|
+
expect(err).toBeInstanceOf(BrokerPortClosedError);
|
|
424
|
+
expect((err as Error).message).toContain('ownership released');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('bumps the epoch on promotion after having had a store (fences in-flight chains)', async () => {
|
|
428
|
+
const { engine } = makeSharedEngine();
|
|
429
|
+
await engine.adoptOwner('anon', {
|
|
430
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
|
|
431
|
+
allowMemoryFallback: false,
|
|
432
|
+
resumeHeld: false,
|
|
433
|
+
});
|
|
434
|
+
const before = engine.epoch;
|
|
435
|
+
await engine.adoptOwner('anon', {
|
|
436
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:3',
|
|
437
|
+
allowMemoryFallback: false,
|
|
438
|
+
resumeHeld: false,
|
|
439
|
+
});
|
|
440
|
+
expect(engine.epoch).toBeGreaterThan(before);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// `pureWriteOpResult` is the single source of truth for what a pure-write op
|
|
445
|
+
// contributes to a query's per-statement results. The batched fast path in
|
|
446
|
+
// `query()` and the per-op `execOp` path BOTH route through it, so a caller that
|
|
447
|
+
// reads a statement's output (e.g. `create()` reads `resultIndex:0` for the new
|
|
448
|
+
// row + its id) sees the same shape either way.
|
|
449
|
+
describe('pureWriteOpResult', () => {
|
|
450
|
+
it('echoes the written row (with id) for an upsert — no read-back', () => {
|
|
451
|
+
const op: SqlOp = {
|
|
452
|
+
kind: 'upsert',
|
|
453
|
+
id: 'connection:CONN_abc',
|
|
454
|
+
data: { provider: 'chesscom', username: 'hikaru' },
|
|
455
|
+
mode: 'replace',
|
|
456
|
+
};
|
|
457
|
+
expect(pureWriteOpResult(op)).toEqual({
|
|
458
|
+
provider: 'chesscom',
|
|
459
|
+
username: 'hikaru',
|
|
460
|
+
id: 'connection:CONN_abc',
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('stringifies a RecordId id via stableKey', () => {
|
|
465
|
+
const op: SqlOp = {
|
|
466
|
+
kind: 'upsert',
|
|
467
|
+
id: new RecordId('connection', 'CONN_abc'),
|
|
468
|
+
data: { provider: 'lichess' },
|
|
469
|
+
mode: 'replace',
|
|
470
|
+
};
|
|
471
|
+
expect(pureWriteOpResult(op)).toEqual({ provider: 'lichess', id: 'connection:CONN_abc' });
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('yields [] for delete / deleteAll and null for noop', () => {
|
|
475
|
+
expect(pureWriteOpResult({ kind: 'delete', id: 'game:1' })).toEqual([]);
|
|
476
|
+
expect(pureWriteOpResult({ kind: 'deleteAll', table: 'game' })).toEqual([]);
|
|
477
|
+
expect(pureWriteOpResult({ kind: 'noop' })).toBeNull();
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Regression: a single `create()` compiles to an all-upsert transaction
|
|
482
|
+
// (createSet for the row + createMutation for the pending-mutation log) and
|
|
483
|
+
// extracts `resultIndex:0` for the created row. The SQLite fast path must return
|
|
484
|
+
// that row (with its id) at that index — returning `[]` there dropped the id and
|
|
485
|
+
// crashed the reconcile in `encodeRecordId` ("reading 'table'").
|
|
486
|
+
describe('create() tx result shaping (fast path parity)', () => {
|
|
487
|
+
it('resultIndex:0 carries the created row with its id', () => {
|
|
488
|
+
const rid = new RecordId('connection', 'CONN_abc');
|
|
489
|
+
const mid = new RecordId('_00_pending_mutations', '1');
|
|
490
|
+
const vars = {
|
|
491
|
+
id: rid,
|
|
492
|
+
mid,
|
|
493
|
+
data_provider: 'chesscom',
|
|
494
|
+
data_username: 'hikaru',
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// Same statement pair DataModule.create emits.
|
|
498
|
+
const sealed = surql.seal(
|
|
499
|
+
surql.tx([
|
|
500
|
+
surql.createSet('id', [
|
|
501
|
+
{ key: 'provider', variable: 'data_provider' },
|
|
502
|
+
{ key: 'username', variable: 'data_username' },
|
|
503
|
+
]),
|
|
504
|
+
surql.createMutation('create', 'mid', 'id', 'data'),
|
|
505
|
+
]),
|
|
506
|
+
{ resultIndex: 0 }
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const { transaction, ops } = translateSurql(sealed.sql, vars);
|
|
510
|
+
expect(transaction).toBe(true);
|
|
511
|
+
// Both statements are upserts → the engine takes the all-write fast path.
|
|
512
|
+
expect(ops.every((o) => o.kind === 'upsert')).toBe(true);
|
|
513
|
+
|
|
514
|
+
// Fast-path shaping: [null (BEGIN), ...one result per statement].
|
|
515
|
+
const shaped = [null, ...ops.map(pureWriteOpResult)];
|
|
516
|
+
const created = sealed.extract(shaped) as unknown as { id: unknown; provider: string };
|
|
517
|
+
|
|
518
|
+
expect(created.id).toBe('connection:CONN_abc');
|
|
519
|
+
expect(created.provider).toBe('chesscom');
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// The outbox row is the ONLY copy of a pending create once the in-memory
|
|
523
|
+
// UpEvent is gone (reload, or a shared-tabs follower whose row the leader
|
|
524
|
+
// replays). It must carry the payload: `processUpEvent` does
|
|
525
|
+
// `Object.keys(event.data)`, so a row written with `data` unbound produces a
|
|
526
|
+
// create that can never be sent AND blocks every later mutation behind it.
|
|
527
|
+
//
|
|
528
|
+
// Note the vars: the RECORD is written from per-key `data_<field>` vars, so
|
|
529
|
+
// it is easy to emit `data = $data` and never bind `data` at the call site.
|
|
530
|
+
// That is exactly the bug this asserts against.
|
|
531
|
+
it('the outbox row carries the whole payload, not just the record id', () => {
|
|
532
|
+
const payload = { provider: 'chesscom', username: 'hikaru' };
|
|
533
|
+
const vars = {
|
|
534
|
+
id: new RecordId('connection', 'CONN_abc'),
|
|
535
|
+
mid: new RecordId('_00_pending_mutations', '1'),
|
|
536
|
+
data: payload,
|
|
537
|
+
data_provider: 'chesscom',
|
|
538
|
+
data_username: 'hikaru',
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const sealed = surql.seal(
|
|
542
|
+
surql.tx([
|
|
543
|
+
surql.createSet('id', [
|
|
544
|
+
{ key: 'provider', variable: 'data_provider' },
|
|
545
|
+
{ key: 'username', variable: 'data_username' },
|
|
546
|
+
]),
|
|
547
|
+
surql.createMutation('create', 'mid', 'id', 'data'),
|
|
548
|
+
]),
|
|
549
|
+
{ resultIndex: 0 }
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const { ops } = translateSurql(sealed.sql, vars);
|
|
553
|
+
const outboxRow = pureWriteOpResult(ops[1]) as Record<string, unknown>;
|
|
554
|
+
|
|
555
|
+
expect(outboxRow.mutationType).toBe('create');
|
|
556
|
+
expect(outboxRow.data).toEqual(payload);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
// The circuit snapshot + version scan the boot-time prime reads through this
|
|
562
|
+
// engine. Bytes and meta must round-trip as ONE batch (a reader must never see
|
|
563
|
+
// new bytes with old meta) and a table that does not exist yet scans as empty.
|
|
564
|
+
describe('SqliteCacheEngine circuit snapshot + scanVersions', () => {
|
|
565
|
+
it('stores bytes and meta in one batch and reads them back', async () => {
|
|
566
|
+
const stored = new Map<string, unknown>();
|
|
567
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
568
|
+
const types: string[] = [];
|
|
569
|
+
stubTransport(engine, (type: string, payload: any) => {
|
|
570
|
+
types.push(type);
|
|
571
|
+
if (type === 'open') return { persisted: true };
|
|
572
|
+
if (type === 'batch') {
|
|
573
|
+
for (const stmt of payload as { sql: string; bind: unknown[] }[]) {
|
|
574
|
+
stored.set(stmt.bind[0] as string, stmt.bind[1]);
|
|
575
|
+
}
|
|
576
|
+
return {};
|
|
577
|
+
}
|
|
578
|
+
if (type === 'exec') {
|
|
579
|
+
const ids = (payload.bind ?? []) as string[];
|
|
580
|
+
return { rows: ids.filter((id) => stored.has(id)).map((id) => ({ id, data: stored.get(id) })) };
|
|
581
|
+
}
|
|
582
|
+
return {};
|
|
583
|
+
});
|
|
584
|
+
await engine.connect('anon');
|
|
585
|
+
|
|
586
|
+
expect(await engine.getSnapshot('circuit')).toBeNull();
|
|
587
|
+
const bytes = new Uint8Array([1, 2, 3]);
|
|
588
|
+
await engine.putSnapshot('circuit', bytes, { formatVersion: 1, schemaHash: 'h', savedAt: 5 });
|
|
589
|
+
expect(types.filter((t) => t === 'batch')).toHaveLength(1);
|
|
590
|
+
const snap = await engine.getSnapshot('circuit');
|
|
591
|
+
expect(snap?.bytes).toEqual(bytes);
|
|
592
|
+
expect(snap?.meta).toEqual({ formatVersion: 1, schemaHash: 'h', savedAt: 5 });
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('scans (id, rv) per table and reads a missing table as empty', async () => {
|
|
596
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
597
|
+
stubTransport(engine, (type: string, payload: any) => {
|
|
598
|
+
if (type === 'open') return { persisted: true };
|
|
599
|
+
if (type === 'exec') {
|
|
600
|
+
if (String(payload.sql).includes('"thing"')) {
|
|
601
|
+
return { rows: [{ id: 'thing:a', rv: 3 }, { id: 'thing:b', rv: null }] };
|
|
602
|
+
}
|
|
603
|
+
throw new Error('no such table');
|
|
604
|
+
}
|
|
605
|
+
return {};
|
|
606
|
+
});
|
|
607
|
+
await engine.connect('anon');
|
|
608
|
+
expect(await engine.scanVersions(['thing', 'ghost'])).toEqual({
|
|
609
|
+
thing: [
|
|
610
|
+
['thing:a', 3],
|
|
611
|
+
['thing:b', 0],
|
|
612
|
+
],
|
|
613
|
+
ghost: [],
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { SqliteCacheEngine } from './sqlite-cache-engine';
|
|
3
|
+
import { stubTransport } from './sqlite-transport.fixture';
|
|
4
|
+
import { LocalOpTimeoutError } from './errors';
|
|
5
|
+
import { withRetry } from '../../utils/index';
|
|
6
|
+
|
|
7
|
+
function makeLogger(): any {
|
|
8
|
+
const noop = () => {};
|
|
9
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
10
|
+
logger.child = () => logger;
|
|
11
|
+
return logger;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The worker transport parks a call until the worker replies. A worker that
|
|
16
|
+
* never did (starved, or wedged on an unbounded lock check) left every caller
|
|
17
|
+
* waiting forever. Each round trip now has a deadline.
|
|
18
|
+
*/
|
|
19
|
+
describe('SqliteCacheEngine local op deadline', () => {
|
|
20
|
+
afterEach(() => vi.useRealTimers());
|
|
21
|
+
|
|
22
|
+
async function openEngine(handler: (type: string, payload: any) => unknown) {
|
|
23
|
+
const engine = new SqliteCacheEngine(
|
|
24
|
+
{ namespace: 'n', database: 'd', localOpTimeoutMs: 50 } as any,
|
|
25
|
+
makeLogger()
|
|
26
|
+
);
|
|
27
|
+
stubTransport(engine, handler);
|
|
28
|
+
await engine.connect('bucket-a');
|
|
29
|
+
return engine;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('rejects a call that never answers with LocalOpTimeoutError and keeps the queue moving', async () => {
|
|
33
|
+
vi.useFakeTimers();
|
|
34
|
+
let hang = false;
|
|
35
|
+
const engine = await openEngine((type) => {
|
|
36
|
+
if (hang && type !== 'open') return new Promise(() => {});
|
|
37
|
+
return { rows: [], result: [] };
|
|
38
|
+
});
|
|
39
|
+
hang = true;
|
|
40
|
+
const stuck = engine.query('SELECT * FROM game');
|
|
41
|
+
const settled = stuck.then(
|
|
42
|
+
() => 'resolved',
|
|
43
|
+
(e) => e
|
|
44
|
+
);
|
|
45
|
+
await vi.advanceTimersByTimeAsync(60);
|
|
46
|
+
const err = await settled;
|
|
47
|
+
expect(err).toBeInstanceOf(LocalOpTimeoutError);
|
|
48
|
+
expect((err as Error).message).toMatch(/timed out/);
|
|
49
|
+
// The next op runs; the transport is not torn down for a slow worker.
|
|
50
|
+
hang = false;
|
|
51
|
+
await expect(engine.query('SELECT * FROM game')).resolves.toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('is not retried by withRetry', async () => {
|
|
55
|
+
const attempts = vi.fn(async () => {
|
|
56
|
+
throw new LocalOpTimeoutError('run', 5);
|
|
57
|
+
});
|
|
58
|
+
await expect(withRetry(makeLogger(), attempts)).rejects.toBeInstanceOf(LocalOpTimeoutError);
|
|
59
|
+
expect(attempts).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
});
|