@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,143 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
3
|
+
import { SqliteCacheEngine } from './sqlite-cache-engine';
|
|
4
|
+
import { stubTransport } from './sqlite-transport.fixture';
|
|
5
|
+
import { serializeRow } from './sqlite-plan-sql';
|
|
6
|
+
import type { Row } from './cache-engine';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Integration: the DevTools Database explorer's statements run end-to-end
|
|
10
|
+
* (translate → engine SQL → REAL in-memory SQLite, the same
|
|
11
|
+
* @sqlite.org/sqlite-wasm build the worker loads). The unit test asserts the
|
|
12
|
+
* translation; this asserts the SQL actually executes and returns the right
|
|
13
|
+
* rows — the paging window in particular is easy to render into invalid SQLite
|
|
14
|
+
* (there is no bare OFFSET).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
let db: any;
|
|
18
|
+
|
|
19
|
+
function makeLogger(): any {
|
|
20
|
+
const noop = () => {};
|
|
21
|
+
const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
22
|
+
l.child = () => l;
|
|
23
|
+
return l;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An engine whose transport is a real SQLite database. */
|
|
27
|
+
function realEngine(): SqliteCacheEngine {
|
|
28
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
29
|
+
stubTransport(engine, (type, payload: any) => {
|
|
30
|
+
switch (type) {
|
|
31
|
+
case 'open':
|
|
32
|
+
for (const t of payload.systemTables ?? []) {
|
|
33
|
+
db.exec({
|
|
34
|
+
sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return { persisted: true };
|
|
38
|
+
case 'exec':
|
|
39
|
+
return {
|
|
40
|
+
rows: db.exec({
|
|
41
|
+
sql: payload.sql,
|
|
42
|
+
bind: payload.bind,
|
|
43
|
+
rowMode: 'object',
|
|
44
|
+
returnValue: 'resultRows',
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
case 'run':
|
|
48
|
+
db.exec({ sql: payload.sql, bind: payload.bind });
|
|
49
|
+
return {};
|
|
50
|
+
case 'batch':
|
|
51
|
+
for (const stmt of payload as { sql: string; bind?: unknown[] }[]) {
|
|
52
|
+
db.exec({ sql: stmt.sql, bind: stmt.bind });
|
|
53
|
+
}
|
|
54
|
+
return {};
|
|
55
|
+
default:
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return engine;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function seed(table: string, rows: Row[]): void {
|
|
63
|
+
db.exec({
|
|
64
|
+
sql: `CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`,
|
|
65
|
+
});
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
db.exec({
|
|
68
|
+
sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?)`,
|
|
69
|
+
bind: [row.id, serializeRow(row)],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
const sqlite3: any = await sqlite3InitModule();
|
|
76
|
+
db = new sqlite3.oo1.DB(':memory:', 'c');
|
|
77
|
+
seed(
|
|
78
|
+
'game_insight',
|
|
79
|
+
Array.from({ length: 5 }, (_, i) => ({
|
|
80
|
+
id: `game_insight:${i}`,
|
|
81
|
+
n: i,
|
|
82
|
+
tag: i % 2 ? 'odd' : 'even',
|
|
83
|
+
}))
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('DevTools explorer against real SQLite', () => {
|
|
88
|
+
it('pages a table with LIMIT/START', async () => {
|
|
89
|
+
const engine = realEngine();
|
|
90
|
+
await engine.connect('anon');
|
|
91
|
+
|
|
92
|
+
const [page1] = await engine.query<[Row[]]>('SELECT * FROM game_insight LIMIT 2 START 0');
|
|
93
|
+
const [page2] = await engine.query<[Row[]]>('SELECT * FROM game_insight LIMIT 2 START 2');
|
|
94
|
+
const [tail] = await engine.query<[Row[]]>('SELECT * FROM game_insight LIMIT 20 START 4');
|
|
95
|
+
|
|
96
|
+
expect(page1.map((r) => r.id)).toEqual(['game_insight:0', 'game_insight:1']);
|
|
97
|
+
expect(page2.map((r) => r.id)).toEqual(['game_insight:2', 'game_insight:3']);
|
|
98
|
+
expect(tail.map((r) => r.id)).toEqual(['game_insight:4']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('counts rows, with and without a filter', async () => {
|
|
102
|
+
const engine = realEngine();
|
|
103
|
+
await engine.connect('anon');
|
|
104
|
+
|
|
105
|
+
const [all] = await engine.query<[{ count: number }[]]>(
|
|
106
|
+
'SELECT count() FROM game_insight GROUP ALL'
|
|
107
|
+
);
|
|
108
|
+
const [odd] = await engine.query<[{ count: number }[]]>(
|
|
109
|
+
"SELECT count() FROM game_insight WHERE tag = 'odd' GROUP ALL"
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(all).toEqual([{ count: 5 }]);
|
|
113
|
+
expect(odd).toEqual([{ count: 2 }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('lists tables via INFO FOR DB', async () => {
|
|
117
|
+
const engine = realEngine();
|
|
118
|
+
await engine.connect('anon');
|
|
119
|
+
|
|
120
|
+
const [info] = await engine.query<[{ tables: Record<string, string> }]>('INFO FOR DB');
|
|
121
|
+
|
|
122
|
+
expect(Object.keys(info.tables)).toContain('game_insight');
|
|
123
|
+
// Seeded system tables show up too — the panel's "internal" toggle filters
|
|
124
|
+
// them, the engine must not pre-filter.
|
|
125
|
+
expect(Object.keys(info.tables)).toContain('_00_query');
|
|
126
|
+
expect(Object.keys(info.tables).some((t) => t.startsWith('sqlite_'))).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('edits and deletes a row by literal record id', async () => {
|
|
130
|
+
const engine = realEngine();
|
|
131
|
+
await engine.connect('anon');
|
|
132
|
+
|
|
133
|
+
await engine.query('UPDATE game_insight:1 MERGE $updates', { updates: { tag: 'edited' } });
|
|
134
|
+
const [edited] = await engine.query<[Row[]]>('SELECT * FROM game_insight LIMIT 20 START 1');
|
|
135
|
+
expect(edited[0]).toMatchObject({ id: 'game_insight:1', n: 1, tag: 'edited' });
|
|
136
|
+
|
|
137
|
+
await engine.query('DELETE game_insight:1');
|
|
138
|
+
const [after] = await engine.query<[{ count: number }[]]>(
|
|
139
|
+
'SELECT count() FROM game_insight GROUP ALL'
|
|
140
|
+
);
|
|
141
|
+
expect(after).toEqual([{ count: 4 }]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SqliteCacheEngine } from './sqlite-cache-engine';
|
|
3
|
+
import { stubTransport } from './sqlite-transport.fixture';
|
|
4
|
+
import { translateSurql } from './surql-translate';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The statements the DevTools Database explorer emits against the LOCAL store.
|
|
8
|
+
* None of them come from `surql`, so they only ever exercised the SurrealDB
|
|
9
|
+
* engine: on SQLite the paging read threw "unsupported SurrealQL for
|
|
10
|
+
* translation: SELECT * FROM game_insight LIMIT 20 START 0", the table list came
|
|
11
|
+
* back empty (`INFO FOR DB` lowered to a noop) and row edit/delete never
|
|
12
|
+
* matched (they inline a literal record id).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function makeLogger(): any {
|
|
16
|
+
const noop = () => {};
|
|
17
|
+
const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
18
|
+
l.child = () => l;
|
|
19
|
+
return l;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Records every exec/run SQL so the paging window can be asserted. */
|
|
23
|
+
function recordingEngine(rows: Record<string, unknown[]> = {}) {
|
|
24
|
+
const sql: string[] = [];
|
|
25
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
26
|
+
stubTransport(engine, (type, payload: any) => {
|
|
27
|
+
if (type === 'open') return { persisted: true };
|
|
28
|
+
if (type === 'exec' || type === 'run') {
|
|
29
|
+
sql.push(payload.sql);
|
|
30
|
+
for (const [needle, out] of Object.entries(rows)) {
|
|
31
|
+
if (payload.sql.includes(needle)) return { rows: out };
|
|
32
|
+
}
|
|
33
|
+
return { rows: [] };
|
|
34
|
+
}
|
|
35
|
+
return {};
|
|
36
|
+
});
|
|
37
|
+
return { engine, sql };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('DevTools table paging (LIMIT/START)', () => {
|
|
41
|
+
it('translates the window instead of throwing', () => {
|
|
42
|
+
const { ops } = translateSurql('SELECT * FROM game_insight LIMIT 20 START 0', {});
|
|
43
|
+
expect(ops).toEqual([
|
|
44
|
+
{
|
|
45
|
+
kind: 'selectTable',
|
|
46
|
+
table: 'game_insight',
|
|
47
|
+
where: undefined,
|
|
48
|
+
orderBy: undefined,
|
|
49
|
+
select: undefined,
|
|
50
|
+
value: undefined,
|
|
51
|
+
limit: 20,
|
|
52
|
+
start: 0,
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('renders LIMIT/OFFSET in SQL, page 3 of 20', async () => {
|
|
58
|
+
const { engine, sql } = recordingEngine();
|
|
59
|
+
await engine.connect('anon');
|
|
60
|
+
await engine.query('SELECT * FROM game_insight LIMIT 20 START 40');
|
|
61
|
+
expect(sql.some((s) => /SELECT data FROM "game_insight" LIMIT 20 OFFSET 40$/.test(s))).toBe(
|
|
62
|
+
true
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('keeps WHERE and ORDER BY alongside the window', () => {
|
|
67
|
+
const { ops } = translateSurql(
|
|
68
|
+
'SELECT * FROM game WHERE done = true ORDER BY date desc LIMIT 10 START 30',
|
|
69
|
+
{}
|
|
70
|
+
);
|
|
71
|
+
expect(ops[0]).toMatchObject({
|
|
72
|
+
kind: 'selectTable',
|
|
73
|
+
table: 'game',
|
|
74
|
+
where: [{ field: 'done', op: '=', value: true }],
|
|
75
|
+
orderBy: [['date', 'desc']],
|
|
76
|
+
limit: 10,
|
|
77
|
+
start: 30,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('leaves a LIMIT that is part of a string literal alone', () => {
|
|
82
|
+
const { ops } = translateSurql("SELECT * FROM game WHERE note = 'LIMIT 5'", {});
|
|
83
|
+
expect(ops[0]).toMatchObject({
|
|
84
|
+
kind: 'selectTable',
|
|
85
|
+
where: [{ field: 'note', op: '=', value: 'LIMIT 5' }],
|
|
86
|
+
limit: undefined,
|
|
87
|
+
start: undefined,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('a START with no LIMIT still renders valid SQLite', async () => {
|
|
92
|
+
const { engine, sql } = recordingEngine();
|
|
93
|
+
await engine.connect('anon');
|
|
94
|
+
await engine.query('SELECT * FROM game START 5');
|
|
95
|
+
// SQLite has no bare OFFSET — `LIMIT -1` is its "everything" sentinel.
|
|
96
|
+
expect(sql.some((s) => s.endsWith('LIMIT -1 OFFSET 5'))).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('DevTools row count', () => {
|
|
101
|
+
it("answers `SELECT count() FROM t GROUP ALL` in SurrealDB's shape", async () => {
|
|
102
|
+
const { engine } = recordingEngine({ 'COUNT(*)': [{ n: 137 }] });
|
|
103
|
+
await engine.connect('anon');
|
|
104
|
+
const res = await engine.query<[{ count: number }[]]>(
|
|
105
|
+
'SELECT count() FROM game_insight GROUP ALL'
|
|
106
|
+
);
|
|
107
|
+
expect(res[0]).toEqual([{ count: 137 }]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('counts zero rows as 0, not undefined', async () => {
|
|
111
|
+
const { engine } = recordingEngine();
|
|
112
|
+
await engine.connect('anon');
|
|
113
|
+
const res = await engine.query<[{ count: number }[]]>('SELECT count() FROM empty GROUP ALL');
|
|
114
|
+
expect(res[0]).toEqual([{ count: 0 }]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('DevTools table list', () => {
|
|
119
|
+
it('answers INFO FOR DB from sqlite_master, minus SQLite internals', async () => {
|
|
120
|
+
const { engine } = recordingEngine({
|
|
121
|
+
sqlite_master: [{ name: 'game' }, { name: '_00_query' }],
|
|
122
|
+
});
|
|
123
|
+
await engine.connect('anon');
|
|
124
|
+
const [info] = await engine.query<[{ tables: Record<string, string> }]>('INFO FOR DB');
|
|
125
|
+
expect(Object.keys(info.tables)).toEqual(['game', '_00_query']);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('DevTools row edit / delete (literal record ids)', () => {
|
|
130
|
+
it('UPDATE <table>:<id> MERGE $updates writes that row', async () => {
|
|
131
|
+
const { engine, sql } = recordingEngine();
|
|
132
|
+
await engine.connect('anon');
|
|
133
|
+
await engine.query('UPDATE game:abc MERGE $updates', { updates: { white: 'hikaru' } });
|
|
134
|
+
const write = sql.find((s) => s.startsWith('INSERT INTO "game"'));
|
|
135
|
+
expect(write).toBeDefined();
|
|
136
|
+
expect(write).toContain('json_patch');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('DELETE <table>:<id> deletes one row, DELETE <table> still clears the table', () => {
|
|
140
|
+
expect(translateSurql('DELETE game:abc', {}).ops[0]).toEqual({
|
|
141
|
+
kind: 'delete',
|
|
142
|
+
id: 'game:abc',
|
|
143
|
+
});
|
|
144
|
+
expect(translateSurql('DELETE game', {}).ops[0]).toEqual({ kind: 'deleteAll', table: 'game' });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('refuses a table-wide MERGE rather than writing a row named after the table', () => {
|
|
148
|
+
// `UPDATE game MERGE $x` means every row of `game` in SurrealQL. Unsupported
|
|
149
|
+
// here — and it must SAY so, not silently create `game:undefined`.
|
|
150
|
+
expect(() => translateSurql('UPDATE game MERGE $x', { x: {} })).toThrow(
|
|
151
|
+
/unsupported SurrealQL/
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { verifyLockStillHeld } from './sqlite-lock-verify';
|
|
3
|
+
|
|
4
|
+
describe('verifyLockStillHeld', () => {
|
|
5
|
+
it('fences when the lock is no longer held', async () => {
|
|
6
|
+
const fence = vi.fn(async () => {});
|
|
7
|
+
await verifyLockStillHeld({ query: async () => ({ held: [] }) }, 'lock-a', fence, 50);
|
|
8
|
+
expect(fence).toHaveBeenCalledWith('lock missing after suspected freeze');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('does nothing when the lock is still held', async () => {
|
|
12
|
+
const fence = vi.fn(async () => {});
|
|
13
|
+
await verifyLockStillHeld({ query: async () => ({ held: [{ name: 'lock-a' }] }) }, 'lock-a', fence, 50);
|
|
14
|
+
expect(fence).not.toHaveBeenCalled();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('resolves within the bound when the lock query never answers, without fencing', async () => {
|
|
18
|
+
const fence = vi.fn(async () => {});
|
|
19
|
+
const warn = vi.fn();
|
|
20
|
+
const started = Date.now();
|
|
21
|
+
await verifyLockStillHeld({ query: () => new Promise(() => {}) }, 'lock-a', fence, 30, warn);
|
|
22
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
23
|
+
expect(fence).not.toHaveBeenCalled();
|
|
24
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('is a no-op without a lock or a query API', async () => {
|
|
28
|
+
const fence = vi.fn(async () => {});
|
|
29
|
+
await verifyLockStillHeld(null, 'x', fence);
|
|
30
|
+
await verifyLockStillHeld({ query: async () => ({}) }, undefined, fence);
|
|
31
|
+
expect(fence).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Do we still hold the OPFS lock?" - asked by the SQLite worker after a gap
|
|
3
|
+
* in its ticks long enough that the tab may have been frozen and the broker
|
|
4
|
+
* may have handed the lock to another tab.
|
|
5
|
+
*
|
|
6
|
+
* Bounded. `navigator.locks.query()` has no deadline of its own, and every op
|
|
7
|
+
* on the worker's chain awaits this verification: one query that never
|
|
8
|
+
* resolves stopped every reply to every client for good. On timeout we resolve
|
|
9
|
+
* WITHOUT fencing - the steal callback registered on the lock request remains
|
|
10
|
+
* the backstop that fences a genuinely stolen lock - and say so.
|
|
11
|
+
*/
|
|
12
|
+
export interface LockQueryLike {
|
|
13
|
+
query(): Promise<{ held?: { name?: string }[] }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const LOCK_VERIFY_TIMEOUT_MS = 2_000;
|
|
17
|
+
|
|
18
|
+
export async function verifyLockStillHeld(
|
|
19
|
+
locks: LockQueryLike | null | undefined,
|
|
20
|
+
name: string | undefined,
|
|
21
|
+
fence: (reason: string) => Promise<void> | void,
|
|
22
|
+
timeoutMs = LOCK_VERIFY_TIMEOUT_MS,
|
|
23
|
+
warn: (msg: string) => void = () => {}
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
if (!locks || !name || typeof locks.query !== 'function') return;
|
|
26
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
27
|
+
try {
|
|
28
|
+
const state = await Promise.race([
|
|
29
|
+
locks.query(),
|
|
30
|
+
new Promise<null>((resolve) => {
|
|
31
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
34
|
+
if (state === null) {
|
|
35
|
+
warn(`lock verification did not answer within ${timeoutMs}ms; trusting the steal callback`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const stillHeld = (state.held ?? []).some((l) => l.name === name);
|
|
39
|
+
if (!stillHeld) await fence('lock missing after suspected freeze');
|
|
40
|
+
} catch {
|
|
41
|
+
/* query unavailable: fall back to the steal callback */
|
|
42
|
+
} finally {
|
|
43
|
+
if (timer) clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { openDb } from './sqlite-open';
|
|
3
|
+
|
|
4
|
+
class FakeDb {
|
|
5
|
+
constructor(public arg: unknown) {}
|
|
6
|
+
exec() {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
close() {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Minimal stand-in for the initialized sqlite-wasm module. `install` is the
|
|
13
|
+
* `installOpfsSAHPoolVfs` behavior under test; omit it to model a build that
|
|
14
|
+
* lacks the SAHPool VFS entirely. */
|
|
15
|
+
function makeSqlite3(install?: (opts: any) => Promise<unknown>) {
|
|
16
|
+
const sqlite3: any = { oo1: { DB: FakeDb } };
|
|
17
|
+
if (install) sqlite3.installOpfsSAHPoolVfs = vi.fn(install);
|
|
18
|
+
return sqlite3;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pool = { OpfsSAHPoolDb: FakeDb };
|
|
22
|
+
/** What a pool locked by another tab of the app actually throws. */
|
|
23
|
+
function lockedError(): Error {
|
|
24
|
+
const e = new Error('Access Handles cannot be acquired');
|
|
25
|
+
e.name = 'NoModificationAllowedError';
|
|
26
|
+
return e;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const noSleep = () => Promise.resolve();
|
|
30
|
+
|
|
31
|
+
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
errSpy.mockRestore();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// The bare `catch {}` this replaces turned every OPFS failure into a silent
|
|
40
|
+
// full-RAM database: no log, no reason, no way for the app to know its writes
|
|
41
|
+
// die on reload. Each case below pins one half of the fix: keep trying when
|
|
42
|
+
// retrying can plausibly work, and when it can't, say so loudly and hand the
|
|
43
|
+
// reason back.
|
|
44
|
+
describe('openDb', () => {
|
|
45
|
+
it('opens the OPFS pool on the first attempt', async () => {
|
|
46
|
+
const sqlite3 = makeSqlite3(async () => pool);
|
|
47
|
+
const res = await openDb(sqlite3, 'user:abc', true, { sleep: noSleep });
|
|
48
|
+
|
|
49
|
+
expect(res.persisted).toBe(true);
|
|
50
|
+
expect(res.opfsError).toBeUndefined();
|
|
51
|
+
expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(1);
|
|
52
|
+
// The pool is named per bucket, and the first try must NOT force a re-init
|
|
53
|
+
// (that would throw away a pool another attempt is legitimately using).
|
|
54
|
+
expect(sqlite3.installOpfsSAHPoolVfs.mock.calls[0][0]).toEqual({ name: 'sp00ky-user:abc' });
|
|
55
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The tab-closing race: the old tab still holds the sync access handles when
|
|
59
|
+
// the new one boots. Without a retry that tab is stuck in RAM for its whole
|
|
60
|
+
// lifetime, even though the lock frees milliseconds later.
|
|
61
|
+
it('retries a locked pool and succeeds, forcing re-init after the first failure', async () => {
|
|
62
|
+
let calls = 0;
|
|
63
|
+
const sqlite3 = makeSqlite3(async () => {
|
|
64
|
+
if (++calls < 3) throw lockedError();
|
|
65
|
+
return pool;
|
|
66
|
+
});
|
|
67
|
+
const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
|
|
68
|
+
|
|
69
|
+
expect(res.persisted).toBe(true);
|
|
70
|
+
expect(res.opfsError).toBeUndefined();
|
|
71
|
+
expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(3);
|
|
72
|
+
// sqlite-wasm caches the first rejection against the VFS name, so retries
|
|
73
|
+
// that don't ask for a real re-init just replay it.
|
|
74
|
+
const [first, second, third] = sqlite3.installOpfsSAHPoolVfs.mock.calls.map((c: any[]) => c[0]);
|
|
75
|
+
expect(first.forceReinitIfPreviouslyFailed).toBeUndefined();
|
|
76
|
+
expect(second.forceReinitIfPreviouslyFailed).toBe(true);
|
|
77
|
+
expect(third.forceReinitIfPreviouslyFailed).toBe(true);
|
|
78
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('falls back loudly after exhausting the retries, keeping the reason', async () => {
|
|
82
|
+
const sqlite3 = makeSqlite3(async () => {
|
|
83
|
+
throw lockedError();
|
|
84
|
+
});
|
|
85
|
+
const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
|
|
86
|
+
|
|
87
|
+
expect(res.persisted).toBe(false);
|
|
88
|
+
// The DOMException name is the diagnostic part: it names the lock.
|
|
89
|
+
expect(res.opfsError).toContain('NoModificationAllowedError');
|
|
90
|
+
expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(3);
|
|
91
|
+
expect(errSpy).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(String(errSpy.mock.calls[0][0])).toContain('IN MEMORY');
|
|
93
|
+
// Still a usable handle: losing durability must not break the app.
|
|
94
|
+
expect(res.db).toBeDefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// An insecure context has no sync access handles at all, so retrying just
|
|
98
|
+
// adds boot latency to a foregone conclusion.
|
|
99
|
+
it('does not retry when the OPFS APIs are missing entirely', async () => {
|
|
100
|
+
const sqlite3 = makeSqlite3(async () => {
|
|
101
|
+
throw new Error('Missing required OPFS APIs.');
|
|
102
|
+
});
|
|
103
|
+
const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
|
|
104
|
+
|
|
105
|
+
expect(res.persisted).toBe(false);
|
|
106
|
+
expect(res.opfsError).toContain('Missing required OPFS APIs');
|
|
107
|
+
expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(errSpy).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('reports a build without the SAHPool VFS without calling anything', async () => {
|
|
112
|
+
const sqlite3 = makeSqlite3();
|
|
113
|
+
const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
|
|
114
|
+
|
|
115
|
+
expect(res.persisted).toBe(false);
|
|
116
|
+
expect(res.opfsError).toContain('installOpfsSAHPoolVfs');
|
|
117
|
+
expect(errSpy).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// `store: 'memory'` is a configuration choice, not a degradation, so it must
|
|
121
|
+
// stay silent and carry no error for the UI to warn about.
|
|
122
|
+
it('opens in memory quietly when persistence was not requested', async () => {
|
|
123
|
+
const sqlite3 = makeSqlite3(async () => pool);
|
|
124
|
+
const res = await openDb(sqlite3, 'main', false, { sleep: noSleep });
|
|
125
|
+
|
|
126
|
+
expect(res.persisted).toBe(false);
|
|
127
|
+
expect(res.opfsError).toBeUndefined();
|
|
128
|
+
expect(sqlite3.installOpfsSAHPoolVfs).not.toHaveBeenCalled();
|
|
129
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('honors maxAttempts and waits the configured backoff between tries', async () => {
|
|
133
|
+
const slept: number[] = [];
|
|
134
|
+
const sqlite3 = makeSqlite3(async () => {
|
|
135
|
+
throw lockedError();
|
|
136
|
+
});
|
|
137
|
+
const res = await openDb(sqlite3, 'main', true, {
|
|
138
|
+
maxAttempts: 4,
|
|
139
|
+
backoffMs: [10, 20],
|
|
140
|
+
sleep: async (ms) => {
|
|
141
|
+
slept.push(ms);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(res.persisted).toBe(false);
|
|
146
|
+
expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(4);
|
|
147
|
+
// One sleep per gap (never after the last attempt), last delay repeating.
|
|
148
|
+
expect(slept).toEqual([10, 20, 20]);
|
|
149
|
+
});
|
|
150
|
+
});
|