@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,92 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Regression guard for the auth-callback ordering bug we fixed in
|
|
6
|
+
// `Sp00kyClient.init()`. The contract:
|
|
7
|
+
//
|
|
8
|
+
// this.auth.subscribe(async (userId) => {
|
|
9
|
+
// this.dataModule.setCurrentUserId(userId); // ← must be first
|
|
10
|
+
// // ... awaits and other work ...
|
|
11
|
+
// });
|
|
12
|
+
//
|
|
13
|
+
// If any future refactor moves `setCurrentUserId` after the first
|
|
14
|
+
// `await`, the AuthProvider's sibling subscriber gets to run with a
|
|
15
|
+
// stale `dataModule.currentUserId` and registers queries against the
|
|
16
|
+
// wrong `_00_query[_user_*]` / `_00_list_ref_user_<id>` tables.
|
|
17
|
+
//
|
|
18
|
+
// A runtime mock test for this is heavy — Sp00kyClient drags in
|
|
19
|
+
// SurrealDB, the WASM SSP, the persistence client, etc. A
|
|
20
|
+
// structural regex test is enough: it catches the only failure mode
|
|
21
|
+
// (someone moves the synchronous setCurrentUserId call) without
|
|
22
|
+
// needing the full graph.
|
|
23
|
+
|
|
24
|
+
describe('Sp00kyClient.auth.subscribe ordering invariant', () => {
|
|
25
|
+
const sourcePath = resolve(__dirname, 'sp00ky.ts');
|
|
26
|
+
const source = readFileSync(sourcePath, 'utf-8');
|
|
27
|
+
|
|
28
|
+
it('source file is readable', () => {
|
|
29
|
+
expect(source.length).toBeGreaterThan(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('auth.subscribe sets currentUserId before any await', () => {
|
|
33
|
+
// Find the auth.subscribe arrow body. Match the contents of the
|
|
34
|
+
// outermost `{}` after `this.auth.subscribe(async (userId) => `.
|
|
35
|
+
const match = source.match(
|
|
36
|
+
/this\.auth\.subscribe\(\s*async\s*\(\s*userId\s*[^)]*\)\s*=>\s*\{([\s\S]*?)\n {6}\}\s*\)/
|
|
37
|
+
);
|
|
38
|
+
expect(
|
|
39
|
+
match,
|
|
40
|
+
'expected an auth.subscribe(async (userId) => { ... }) block in sp00ky.ts'
|
|
41
|
+
).not.toBeNull();
|
|
42
|
+
|
|
43
|
+
const body = match![1];
|
|
44
|
+
|
|
45
|
+
// Strip line comments so a stray `//` doesn't trick the regex.
|
|
46
|
+
const stripped = body
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map((line) => line.replace(/\/\/.*$/, ''))
|
|
49
|
+
.join('\n');
|
|
50
|
+
|
|
51
|
+
const setUserIdIdx = stripped.indexOf('this.dataModule.setCurrentUserId(userId)');
|
|
52
|
+
expect(
|
|
53
|
+
setUserIdIdx,
|
|
54
|
+
'dataModule.setCurrentUserId(userId) must appear in the auth.subscribe body'
|
|
55
|
+
).toBeGreaterThanOrEqual(0);
|
|
56
|
+
|
|
57
|
+
const firstAwaitIdx = stripped.search(/\bawait\b/);
|
|
58
|
+
if (firstAwaitIdx >= 0) {
|
|
59
|
+
expect(
|
|
60
|
+
setUserIdIdx,
|
|
61
|
+
`dataModule.setCurrentUserId(userId) (idx ${setUserIdIdx}) must come BEFORE the first \`await\` (idx ${firstAwaitIdx}) so the AuthProvider's sibling subscriber sees a fresh user id when it fires synchronously after our callback. Moving the call past an \`await\` reintroduces the stale-user-id race documented in docs/surrealdb-bugs/ if a related gap is found, and the prior session's retrospective.`
|
|
62
|
+
).toBeLessThan(firstAwaitIdx);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('writes the boot-bucket hint synchronously and switches buckets before sync.setCurrentUserId', () => {
|
|
67
|
+
const match = source.match(
|
|
68
|
+
/this\.auth\.subscribe\(\s*async\s*\(\s*userId\s*[^)]*\)\s*=>\s*\{([\s\S]*?)\n {6}\}\s*\)/
|
|
69
|
+
);
|
|
70
|
+
expect(match).not.toBeNull();
|
|
71
|
+
const stripped = match![1]
|
|
72
|
+
.split('\n')
|
|
73
|
+
.map((line) => line.replace(/\/\/.*$/, ''))
|
|
74
|
+
.join('\n');
|
|
75
|
+
|
|
76
|
+
const hintIdx = stripped.indexOf('writeBootBucketHint(');
|
|
77
|
+
const bucketIdx = stripped.indexOf('this.ensureLocalBucket(userId)');
|
|
78
|
+
const syncUserIdx = stripped.indexOf('this.sync.setCurrentUserId(userId)');
|
|
79
|
+
const firstAwaitIdx = stripped.search(/\bawait\b/);
|
|
80
|
+
|
|
81
|
+
// The hint must be written before the first await: a reload landing
|
|
82
|
+
// mid-switch has to boot straight into the target bucket.
|
|
83
|
+
expect(hintIdx, 'writeBootBucketHint must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
|
|
84
|
+
expect(hintIdx, 'writeBootBucketHint must run before the first await').toBeLessThan(firstAwaitIdx);
|
|
85
|
+
|
|
86
|
+
// The bucket switch must complete before the sync module re-registers
|
|
87
|
+
// LIVE/poll for the new user — those immediately write to the local store.
|
|
88
|
+
expect(bucketIdx, 'ensureLocalBucket must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
|
|
89
|
+
expect(syncUserIdx, 'sync.setCurrentUserId must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
|
|
90
|
+
expect(bucketIdx, 'ensureLocalBucket must run before sync.setCurrentUserId').toBeLessThan(syncUserIdx);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { Sp00kyClient } from './sp00ky';
|
|
5
|
+
|
|
6
|
+
// Guards for the local-first paint contract of `Sp00kyClient.initQuery`:
|
|
7
|
+
// the hash must resolve (and the hook paint from local cache) without any
|
|
8
|
+
// network on the awaited path, while the opt-in instant-hydrate + the
|
|
9
|
+
// `register` down-event run in a background chain that (a) keeps hydrate
|
|
10
|
+
// strictly before the enqueue, (b) hydrates only when `instantHydrate:
|
|
11
|
+
// true` (default off — the register lifecycle is the single freshness
|
|
12
|
+
// path), (c) never rejects, and (d) is shared by concurrent mounts of the
|
|
13
|
+
// same query.
|
|
14
|
+
//
|
|
15
|
+
// Structural half: like sp00ky.auth-order.test.ts, a regex over the source
|
|
16
|
+
// catches the ordering regressions a runtime mock can't cheaply cover
|
|
17
|
+
// (Sp00kyClient's constructor drags in SurrealDB + the WASM SSP).
|
|
18
|
+
// Behavioral half: `finishQueryInit`/`initQuery` only touch a handful of
|
|
19
|
+
// injected fields, so a bare `Object.create(Sp00kyClient.prototype)` with
|
|
20
|
+
// stubs exercises the real method bodies without the constructor.
|
|
21
|
+
|
|
22
|
+
describe('Sp00kyClient.initQuery structural invariants', () => {
|
|
23
|
+
const source = readFileSync(resolve(__dirname, 'sp00ky.ts'), 'utf-8');
|
|
24
|
+
|
|
25
|
+
const methodBody = (name: string): string => {
|
|
26
|
+
const match = source.match(new RegExp(`private async ${name}[\\s\\S]*?\\n \\}`));
|
|
27
|
+
expect(match, `expected a "private async ${name}" method in sp00ky.ts`).not.toBeNull();
|
|
28
|
+
// Strip line comments so prose mentioning awaits/calls can't trip the checks.
|
|
29
|
+
return match![0]
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((line) => line.replace(/\/\/.*$/, ''))
|
|
32
|
+
.join('\n');
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
it('initQuery returns the hash with no network on the awaited path', () => {
|
|
36
|
+
const body = methodBody('initQuery');
|
|
37
|
+
expect(body).toContain('return hash');
|
|
38
|
+
expect(body, 'initQuery must not await the remote (paint path is network-free)').not.toMatch(
|
|
39
|
+
/await this\.remote\./
|
|
40
|
+
);
|
|
41
|
+
expect(body, 'the register enqueue belongs to the background chain').not.toMatch(
|
|
42
|
+
/await this\.sync\.enqueueDownEvent/
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('finishQueryInit hydrates strictly before enqueuing register', () => {
|
|
47
|
+
const body = methodBody('finishQueryInit');
|
|
48
|
+
const fetchIdx = body.indexOf('this.remote.query');
|
|
49
|
+
const enqueueIdx = body.indexOf('this.sync.enqueueDownEvent');
|
|
50
|
+
expect(fetchIdx).toBeGreaterThanOrEqual(0);
|
|
51
|
+
expect(enqueueIdx).toBeGreaterThanOrEqual(0);
|
|
52
|
+
expect(
|
|
53
|
+
fetchIdx,
|
|
54
|
+
'hydrate must settle before the register enqueue so a stale one-shot snapshot can never land after the authoritative _00_list_ref overwrite'
|
|
55
|
+
).toBeLessThan(enqueueIdx);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('finishQueryInit captures the bucket epoch before the remote fetch', () => {
|
|
59
|
+
const body = methodBody('finishQueryInit');
|
|
60
|
+
const epochIdx = body.indexOf('this.local.epoch');
|
|
61
|
+
const fetchIdx = body.indexOf('this.remote.query');
|
|
62
|
+
expect(epochIdx).toBeGreaterThanOrEqual(0);
|
|
63
|
+
expect(epochIdx, 'epoch must be read before the fetch to fence bucket switches').toBeLessThan(
|
|
64
|
+
fetchIdx
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('Sp00kyClient.finishQueryInit behavior', () => {
|
|
70
|
+
const hash = 'sha-hash';
|
|
71
|
+
const q: any = { hash: 42, selectQuery: { query: 'SELECT * FROM user', vars: {} } };
|
|
72
|
+
|
|
73
|
+
let client: any;
|
|
74
|
+
let calls: string[];
|
|
75
|
+
let enqueued: any[];
|
|
76
|
+
let epoch: number;
|
|
77
|
+
let cold: boolean;
|
|
78
|
+
let remoteImpl: () => Promise<any>;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
calls = [];
|
|
82
|
+
enqueued = [];
|
|
83
|
+
epoch = 1;
|
|
84
|
+
cold = true;
|
|
85
|
+
remoteImpl = async () => {
|
|
86
|
+
calls.push('fetch');
|
|
87
|
+
return [[{ id: 'user:a' }]];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const noop = () => {};
|
|
91
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
92
|
+
logger.child = () => logger;
|
|
93
|
+
|
|
94
|
+
client = Object.create(Sp00kyClient.prototype);
|
|
95
|
+
Object.assign(client, {
|
|
96
|
+
config: { instantHydrate: true, schema: { tables: [{ name: 'user', columns: {} }] } },
|
|
97
|
+
logger,
|
|
98
|
+
preloadedHashes: new Set<number>(),
|
|
99
|
+
pendingQueryInits: new Map<string, Promise<void>>(),
|
|
100
|
+
local: {
|
|
101
|
+
get epoch() {
|
|
102
|
+
return epoch;
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
remote: { query: (...args: any[]) => remoteImpl() },
|
|
106
|
+
sync: {
|
|
107
|
+
enqueueDownEvent: (event: any) => {
|
|
108
|
+
calls.push('enqueue');
|
|
109
|
+
enqueued.push(event);
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
dataModule: {
|
|
113
|
+
isCold: () => cold,
|
|
114
|
+
applyHydration: async () => {
|
|
115
|
+
calls.push('hydrate');
|
|
116
|
+
},
|
|
117
|
+
query: async () => hash,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('cold path with hydrate enabled: fetch → hydrate → enqueue, in order', async () => {
|
|
123
|
+
await client.finishQueryInit(hash, q, {});
|
|
124
|
+
expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
|
|
125
|
+
expect(enqueued).toEqual([{ type: 'register', payload: { hash } }]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('a preloaded query still hydrates when enabled — cache-first never depends on WHY rows are cached', async () => {
|
|
129
|
+
client.preloadedHashes.add(q.hash);
|
|
130
|
+
await client.finishQueryInit(hash, q, {});
|
|
131
|
+
expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('warm query (not cold) goes straight to enqueue', async () => {
|
|
135
|
+
cold = false;
|
|
136
|
+
await client.finishQueryInit(hash, q, {});
|
|
137
|
+
expect(calls).toEqual(['enqueue']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('a rejected hydrate fetch still enqueues register and never rejects', async () => {
|
|
141
|
+
remoteImpl = async () => {
|
|
142
|
+
calls.push('fetch');
|
|
143
|
+
throw new Error('offline');
|
|
144
|
+
};
|
|
145
|
+
await expect(client.finishQueryInit(hash, q, {})).resolves.toBeUndefined();
|
|
146
|
+
expect(calls).toEqual(['fetch', 'enqueue']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('a bucket switch during the fetch skips applyHydration', async () => {
|
|
150
|
+
remoteImpl = async () => {
|
|
151
|
+
calls.push('fetch');
|
|
152
|
+
epoch = 2; // switch lands while the fetch is in flight
|
|
153
|
+
return [[{ id: 'user:a' }]];
|
|
154
|
+
};
|
|
155
|
+
await client.finishQueryInit(hash, q, {});
|
|
156
|
+
expect(calls).toEqual(['fetch', 'enqueue']);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('default (instantHydrate unset) does not hydrate — register lifecycle is the only freshness path', async () => {
|
|
160
|
+
delete client.config.instantHydrate;
|
|
161
|
+
await client.finishQueryInit(hash, q, {});
|
|
162
|
+
expect(calls).toEqual(['enqueue']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('instantHydrate: false does not hydrate', async () => {
|
|
166
|
+
client.config.instantHydrate = false;
|
|
167
|
+
await client.finishQueryInit(hash, q, {});
|
|
168
|
+
expect(calls).toEqual(['enqueue']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('concurrent initQuery calls for the same hash share one background chain', async () => {
|
|
172
|
+
const [h1, h2] = await Promise.all([
|
|
173
|
+
client.initQuery('user', q, '10m'),
|
|
174
|
+
client.initQuery('user', q, '10m'),
|
|
175
|
+
]);
|
|
176
|
+
expect(h1).toBe(hash);
|
|
177
|
+
expect(h2).toBe(hash);
|
|
178
|
+
// Let the shared chain drain.
|
|
179
|
+
await Promise.all([...client.pendingQueryInits.values()]);
|
|
180
|
+
expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
|
|
181
|
+
expect(client.pendingQueryInits.size).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Structural guard, in the spirit of sp00ky.init-query.test.ts: the paint path
|
|
6
|
+
// must stay network-free. `init()` resolves as soon as the LOCAL store is
|
|
7
|
+
// usable, and every consumer gates its first render on that, so anything that
|
|
8
|
+
// reaches the network belongs in `initRemote()` instead.
|
|
9
|
+
//
|
|
10
|
+
// This is the invariant that makes a warm reload instant and an offline boot
|
|
11
|
+
// possible at all. It is easy to undo by adding one innocent-looking `await`,
|
|
12
|
+
// hence a test rather than a comment.
|
|
13
|
+
const source = readFileSync(join(__dirname, 'sp00ky.ts'), 'utf8');
|
|
14
|
+
|
|
15
|
+
function methodBody(name: string): string {
|
|
16
|
+
const re = new RegExp(`(?:private )?async ${name}\\([^)]*\\)[^{]*\\{[\\s\\S]*?\\n \\}`);
|
|
17
|
+
const m = re.exec(source);
|
|
18
|
+
if (!m) throw new Error(`could not locate ${name}()`);
|
|
19
|
+
return m[0];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('local-first boot', () => {
|
|
23
|
+
const init = methodBody('init');
|
|
24
|
+
|
|
25
|
+
it('init() never awaits the remote', () => {
|
|
26
|
+
expect(init).not.toMatch(/await this\.remote\./);
|
|
27
|
+
expect(init).not.toMatch(/await this\.auth\.init\(\)/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('init() hands the network half off without awaiting it', () => {
|
|
31
|
+
expect(init).toMatch(/void this\.initRemote\(\)/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('init() restores the session locally and marks the client ready', () => {
|
|
35
|
+
expect(init).toMatch(/await this\.auth\.restoreSessionFromToken\(\)/);
|
|
36
|
+
expect(init).toMatch(/this\.localReady = true/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('the query-id salt is minted locally, not fetched from the server', () => {
|
|
40
|
+
// No remaining CALL - the surviving mentions are comments explaining why.
|
|
41
|
+
expect(source).not.toMatch(/query[^\n]*RETURN <string>session::id\(\)/);
|
|
42
|
+
expect(init).toMatch(/this\.mintSessionSalt\(\)/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('permissions are seeded before anything can register a view', () => {
|
|
46
|
+
const perms = init.indexOf('setPermissions(');
|
|
47
|
+
const restore = init.indexOf('restoreSessionFromToken');
|
|
48
|
+
expect(perms).toBeGreaterThan(-1);
|
|
49
|
+
expect(perms).toBeLessThan(restore);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('initRemote() tolerates an unreachable server and still supervises', () => {
|
|
53
|
+
const remote = methodBody('initRemote');
|
|
54
|
+
// connect is wrapped, not fatal
|
|
55
|
+
expect(remote).toMatch(/try\s*\{[\s\S]*await this\.remote\.connect\(\)[\s\S]*\}\s*catch/);
|
|
56
|
+
// and the supervisor starts regardless, or a boot-time failure never heals
|
|
57
|
+
const connectCatchEnd = remote.indexOf('connectionSupervisor.start()');
|
|
58
|
+
expect(connectCatchEnd).toBeGreaterThan(-1);
|
|
59
|
+
});
|
|
60
|
+
});
|