@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331
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 +84 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2080 -142
- package/dist/index.js +16009 -2480
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +373 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +477 -0
- package/dist/types.d.ts +1857 -38
- package/dist/worker-edges.js +480 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +32 -7
- package/skills/sp00ky-core/references/config.md +7 -1
- package/src/boot/auth-flip.saga.test.ts +135 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +203 -0
- package/src/boot/boot.saga.ts +146 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +80 -0
- package/src/boot/preload.saga.ts +40 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.test.ts +74 -0
- package/src/client/services.ts +434 -0
- package/src/client/sp00ky-client.test.ts +341 -0
- package/src/client/sp00ky-client.ts +625 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +167 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +512 -0
- package/src/live/index.ts +1059 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +228 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +476 -22
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +469 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +757 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -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 +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +301 -0
- package/src/mutation/push.saga.ts +225 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +527 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +186 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +407 -0
- package/src/query/membership.saga.ts +282 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +311 -0
- package/src/query/register.saga.ts +235 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +157 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -0
- 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 +209 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +156 -61
- 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/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +61 -16
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +508 -63
- 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 +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +285 -6
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -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 +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +261 -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 +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +806 -67
- 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 +234 -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 +58 -2
- package/src/services/tabs/broker-client.ts +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -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 +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +652 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +208 -0
- package/src/state/lifecycle.test.ts +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +306 -0
- package/src/state/reducers.ts +531 -0
- package/src/state/selectors.test.ts +188 -0
- package/src/state/selectors.ts +258 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +97 -0
- package/src/sync/connection.saga.ts +89 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +663 -0
- package/src/sync/policy.ts +515 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +633 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +89 -12
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +29 -18
- package/src/utils/utils.test.ts +3 -2
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
3
|
+
import { COLUMNS_ROW_ID, columnFingerprints, planColumnReset } from './sqlite-column-reset';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A body landed under an older schema lacks the columns that schema did not
|
|
7
|
+
* know, at the server's version. Without a reset the next build never fetches
|
|
8
|
+
* it again (WhitePawn's puzzles stuck on "Preparing" for want of `layout`).
|
|
9
|
+
* These run the planned statements against a REAL in-memory SQLite, the same
|
|
10
|
+
* build the worker loads.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
let db: any;
|
|
14
|
+
|
|
15
|
+
const rows = (sql: string, bind?: unknown[]) =>
|
|
16
|
+
db.exec({ sql, bind, rowMode: 'object', returnValue: 'resultRows' }) as Record<string, unknown>[];
|
|
17
|
+
|
|
18
|
+
const versions = (table: string) =>
|
|
19
|
+
Object.fromEntries(
|
|
20
|
+
rows(`SELECT id, json_extract(data, '$._00_rv') AS rv FROM "${table}"`).map((r) => [r.id, r.rv])
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const storedSets = () => {
|
|
24
|
+
const r = rows(`SELECT data FROM "_00_schema" WHERE id = ?`, [COLUMNS_ROW_ID]);
|
|
25
|
+
return r[0] ? (JSON.parse(r[0].data as string) as Record<string, string>) : null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const existing = () => new Set(rows(`SELECT name FROM sqlite_master WHERE type = 'table'`).map((r) => r.name as string));
|
|
29
|
+
|
|
30
|
+
function run(current: Record<string, string>, keep: string[] = []) {
|
|
31
|
+
const plan = planColumnReset(storedSets(), current, existing(), keep);
|
|
32
|
+
db.exec({ sql: 'BEGIN' });
|
|
33
|
+
for (const s of plan.statements) db.exec({ sql: s.sql, bind: s.bind });
|
|
34
|
+
db.exec({ sql: 'COMMIT' });
|
|
35
|
+
return plan;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
const sqlite3: any = await sqlite3InitModule();
|
|
40
|
+
db = new sqlite3.oo1.DB(':memory:', 'c');
|
|
41
|
+
for (const t of ['_00_schema', 'puzzle', 'game']) {
|
|
42
|
+
db.exec({ sql: `CREATE TABLE "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
|
|
43
|
+
}
|
|
44
|
+
const put = (t: string, id: string, body: object) =>
|
|
45
|
+
db.exec({ sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?)`, bind: [id, JSON.stringify(body)] });
|
|
46
|
+
// Landed by the old build: no `layout`, at the server's version 6.
|
|
47
|
+
put('puzzle', 'puzzle:a', { title: 'A', _00_rv: 6 });
|
|
48
|
+
put('puzzle', 'puzzle:b', { title: 'B', _00_rv: 6 });
|
|
49
|
+
put('game', 'game:1', { white: 'x', _00_rv: 3 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => db?.close());
|
|
53
|
+
|
|
54
|
+
const OLD = { puzzle: 'title', game: 'white' };
|
|
55
|
+
const NEW = { puzzle: 'layout,title', game: 'white' };
|
|
56
|
+
|
|
57
|
+
describe('column reset on the SQLite cache', () => {
|
|
58
|
+
it('fingerprints are the sorted declared columns', () => {
|
|
59
|
+
expect(columnFingerprints([{ name: 'puzzle', columns: { title: {}, layout: {} } }])).toEqual({
|
|
60
|
+
puzzle: 'layout,title',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('first boot with no recorded sets resets every cached table once', () => {
|
|
65
|
+
const plan = run(NEW);
|
|
66
|
+
expect(plan.changed.sort()).toEqual(['game', 'puzzle']);
|
|
67
|
+
expect(versions('puzzle')).toEqual({ 'puzzle:a': 0, 'puzzle:b': 0 });
|
|
68
|
+
expect(versions('game')).toEqual({ 'game:1': 0 });
|
|
69
|
+
expect(storedSets()).toEqual(NEW);
|
|
70
|
+
// Bodies stay: they keep rendering until the refetch lands.
|
|
71
|
+
expect(rows(`SELECT data FROM puzzle WHERE id = 'puzzle:a'`)[0].data).toContain('"title":"A"');
|
|
72
|
+
|
|
73
|
+
// The next boot of the same build changes nothing.
|
|
74
|
+
const again = run(NEW);
|
|
75
|
+
expect(again.statements).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('a new column resets only that table', () => {
|
|
79
|
+
run(OLD);
|
|
80
|
+
db.exec({ sql: `UPDATE puzzle SET data = json_set(data, '$._00_rv', 6)` });
|
|
81
|
+
db.exec({ sql: `UPDATE game SET data = json_set(data, '$._00_rv', 3)` });
|
|
82
|
+
|
|
83
|
+
const plan = run(NEW);
|
|
84
|
+
expect(plan.changed).toEqual(['puzzle']);
|
|
85
|
+
expect(versions('puzzle')).toEqual({ 'puzzle:a': 0, 'puzzle:b': 0 });
|
|
86
|
+
expect(versions('game')).toEqual({ 'game:1': 3 });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps the version of a row with an unsettled local write', () => {
|
|
90
|
+
const plan = run(NEW, ['puzzle:b', 'game:9']);
|
|
91
|
+
expect(plan.changed).toContain('puzzle');
|
|
92
|
+
expect(versions('puzzle')).toEqual({ 'puzzle:a': 0, 'puzzle:b': 6 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('records a table the store has no rows for without touching it', () => {
|
|
96
|
+
const plan = run({ ...NEW, puzzle_chapter: 'puzzle,sort,title' });
|
|
97
|
+
expect(plan.changed).not.toContain('puzzle_chapter');
|
|
98
|
+
expect(storedSets()).toEqual({ ...NEW, puzzle_chapter: 'puzzle,sort,title' });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cached bodies outlive a schema change on the SQLite engine, and that loses
|
|
3
|
+
* columns for good.
|
|
4
|
+
*
|
|
5
|
+
* A body is landed through `cleanRecord`, which keeps only the columns the
|
|
6
|
+
* running app's schema declares, and is stored at the server's `_00_rv`. The
|
|
7
|
+
* engine is schemaless (no migrator, nothing is recreated when the schema
|
|
8
|
+
* changes), so a tab on an older build that fetched a row after the server
|
|
9
|
+
* added and backfilled a column stored it WITHOUT that column at the new
|
|
10
|
+
* version. The next build knows the column, but its local version equals the
|
|
11
|
+
* server's, so the fetch plan never asks for the row again and the column
|
|
12
|
+
* stays missing until something else bumps the row. (WhitePawn, 2026-10-11:
|
|
13
|
+
* every puzzle read as unconverted because `layout` never arrived.)
|
|
14
|
+
*
|
|
15
|
+
* So the store remembers each table's column set (`_00_schema`, row
|
|
16
|
+
* `columns`). On boot, before the prime reads the versions, a table whose set
|
|
17
|
+
* changed, or every table when no set was recorded yet, gets its cached
|
|
18
|
+
* `_00_rv` reset to 0. The bodies stay and keep rendering; any view that names
|
|
19
|
+
* one of those rows sees it as stale and fetches it again, whole. Rows with an
|
|
20
|
+
* unsettled local write keep their version: their echo is a newer body anyway.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** `_00_schema` row holding the recorded column sets. */
|
|
24
|
+
export const COLUMNS_ROW_ID = 'columns';
|
|
25
|
+
|
|
26
|
+
export interface ColumnResetPlan {
|
|
27
|
+
/** Tables with cached rows whose versions are reset. */
|
|
28
|
+
changed: string[];
|
|
29
|
+
/** One transaction: the resets, then the new column sets. Empty when nothing changed. */
|
|
30
|
+
statements: { sql: string; bind?: unknown[] }[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The column set of every table, as `cleanRecord` sees it: the sorted declared
|
|
35
|
+
* column names. Plain names rather than a hash, so a stored set says what it is.
|
|
36
|
+
*/
|
|
37
|
+
export function columnFingerprints(
|
|
38
|
+
tables: readonly { name: string; columns: Record<string, unknown> }[]
|
|
39
|
+
): Record<string, string> {
|
|
40
|
+
const out: Record<string, string> = {};
|
|
41
|
+
for (const t of tables) out[t.name] = Object.keys(t.columns).sort().join(',');
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sameSets = (a: Record<string, string> | null, b: Record<string, string>): boolean => {
|
|
46
|
+
if (!a) return false;
|
|
47
|
+
const ka = Object.keys(a);
|
|
48
|
+
return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What to run for the column sets `current`, given the `stored` ones (null when
|
|
53
|
+
* none were recorded), the tables that exist in the store, and the encoded ids
|
|
54
|
+
* (`table:id`) with an unsettled local write.
|
|
55
|
+
*/
|
|
56
|
+
export function planColumnReset(
|
|
57
|
+
stored: Record<string, string> | null,
|
|
58
|
+
current: Record<string, string>,
|
|
59
|
+
existingTables: ReadonlySet<string>,
|
|
60
|
+
keep: Iterable<string>
|
|
61
|
+
): ColumnResetPlan {
|
|
62
|
+
if (sameSets(stored, current)) return { changed: [], statements: [] };
|
|
63
|
+
|
|
64
|
+
const keepByTable = new Map<string, string[]>();
|
|
65
|
+
for (const id of keep) {
|
|
66
|
+
const table = id.slice(0, id.indexOf(':'));
|
|
67
|
+
if (!keepByTable.has(table)) keepByTable.set(table, []);
|
|
68
|
+
keepByTable.get(table)!.push(id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const changed: string[] = [];
|
|
72
|
+
const statements: ColumnResetPlan['statements'] = [];
|
|
73
|
+
for (const [table, set] of Object.entries(current)) {
|
|
74
|
+
if (stored && stored[table] === set) continue;
|
|
75
|
+
if (!existingTables.has(table)) continue;
|
|
76
|
+
changed.push(table);
|
|
77
|
+
const quoted = `"${table.replace(/"/g, '""')}"`;
|
|
78
|
+
const reset = `UPDATE ${quoted} SET data = json_set(data, '$._00_rv', 0) WHERE json_extract(data, '$._00_rv') != 0`;
|
|
79
|
+
const kept = keepByTable.get(table);
|
|
80
|
+
statements.push(
|
|
81
|
+
kept
|
|
82
|
+
? { sql: `${reset} AND id NOT IN (SELECT value FROM json_each(?))`, bind: [JSON.stringify(kept)] }
|
|
83
|
+
: { sql: reset }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
statements.push({
|
|
87
|
+
sql: `INSERT OR REPLACE INTO "_00_schema"(id, data) VALUES (?, ?)`,
|
|
88
|
+
bind: [COLUMNS_ROW_ID, JSON.stringify(current)],
|
|
89
|
+
});
|
|
90
|
+
return { changed, statements };
|
|
91
|
+
}
|
|
@@ -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_view');
|
|
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
|
+
}
|