@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,311 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { runPure, sha256Hex } from '../testing/run-pure';
|
|
4
|
+
import { buildEntry, buildState } from '../testing/build';
|
|
5
|
+
import { defaultEnv } from './env';
|
|
6
|
+
import { queryHashInput } from './hash';
|
|
7
|
+
import { ensureRegistered, isNotAllowlisted, registerLocal, registerRemote } from './register.saga';
|
|
8
|
+
import * as R from '../state/reducers';
|
|
9
|
+
import { emptyState } from '../state/client-state';
|
|
10
|
+
import type { StatementResult } from '../kernel/effects';
|
|
11
|
+
|
|
12
|
+
const env = defaultEnv({ tables: [{ name: 'thing', columns: {} }] } as any);
|
|
13
|
+
const input = { tableName: 'thing', surql: 'SELECT * FROM thing', params: {}, ttl: '10m' as const };
|
|
14
|
+
const sspOk = () => ({ localArray: [['thing:9', 1]], timings: { parseMs: 1, planMs: 1, snapshotMs: 1, wallMs: 1 } });
|
|
15
|
+
const okStmt = (result: unknown): StatementResult => ({ status: 'OK', result });
|
|
16
|
+
|
|
17
|
+
describe('registerLocal', () => {
|
|
18
|
+
it('cold: no view row -> cold entry, SSP view, EnsureRegistered', async () => {
|
|
19
|
+
const state = { ...emptyState({ tabId: 't' }), sessionId: 'sess' };
|
|
20
|
+
const out = await runPure(registerLocal(env, input), {
|
|
21
|
+
state,
|
|
22
|
+
handlers: { 'local.getById': () => null, 'ssp.register': sspOk },
|
|
23
|
+
});
|
|
24
|
+
const hash = await sha256Hex(queryHashInput(input, 'sess'));
|
|
25
|
+
expect(out.result).toBe(hash);
|
|
26
|
+
const entry = out.state.queries.get(hash)!;
|
|
27
|
+
expect(entry.lifecycle.phase).toBe('cold');
|
|
28
|
+
expect(entry.remoteArray).toEqual([]);
|
|
29
|
+
expect(entry.localArray).toEqual([['thing:9', 1]]);
|
|
30
|
+
expect(entry.def.ttlMs).toBe(600_000);
|
|
31
|
+
expect(entry.telemetry.registrationTimings.wallMs).toBe(1);
|
|
32
|
+
expect(out.state.dirty.has(hash)).toBe(true);
|
|
33
|
+
expect(out.state.registering.size).toBe(0);
|
|
34
|
+
expect(out.dispatched).toEqual([{ type: 'EnsureRegistered' }]);
|
|
35
|
+
const reg = out.log.find((e) => e.kind === 'ssp.register');
|
|
36
|
+
expect(reg).toMatchObject({ plan: { queryHash: hash, tableName: 'thing', ttl: '10m' } });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('cached: a durable view row seeds membership; unconfirmed empty stays cold; a failing read is cold', async () => {
|
|
40
|
+
const cached = await runPure(registerLocal(env, input), {
|
|
41
|
+
handlers: { 'local.getById': () => ({ ids: [['thing:1', 2]], confirmed: false }), 'ssp.register': sspOk },
|
|
42
|
+
});
|
|
43
|
+
const e1 = cached.state.queries.get(cached.result)!;
|
|
44
|
+
expect(e1.lifecycle.phase).toBe('cached');
|
|
45
|
+
expect(e1.remoteArray).toEqual([['thing:1', 2]]);
|
|
46
|
+
const confirmedEmpty = await runPure(registerLocal(env, input), {
|
|
47
|
+
handlers: { 'local.getById': () => ({ ids: [], confirmed: true }), 'ssp.register': sspOk },
|
|
48
|
+
});
|
|
49
|
+
expect(confirmedEmpty.state.queries.get(confirmedEmpty.result)!.lifecycle.phase).toBe('cached');
|
|
50
|
+
const unconfirmed = await runPure(registerLocal(env, input), {
|
|
51
|
+
handlers: { 'local.getById': () => ({ ids: [], confirmed: false }), 'ssp.register': sspOk },
|
|
52
|
+
});
|
|
53
|
+
expect(unconfirmed.state.queries.get(unconfirmed.result)!.lifecycle.phase).toBe('cold');
|
|
54
|
+
const failing = await runPure(registerLocal(env, input), {
|
|
55
|
+
handlers: {
|
|
56
|
+
'local.getById': () => {
|
|
57
|
+
throw new Error('no table');
|
|
58
|
+
},
|
|
59
|
+
'ssp.register': sspOk,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
expect(failing.state.queries.get(failing.result)!.lifecycle.phase).toBe('cold');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('dedupes: active returns immediately, pending waits for the other registration', async () => {
|
|
66
|
+
const hash = await sha256Hex(queryHashInput(input, null));
|
|
67
|
+
const active = buildState([buildEntry({ def: { hash } })]);
|
|
68
|
+
const a = await runPure(registerLocal(env, input), { state: active });
|
|
69
|
+
expect(a.result).toBe(hash);
|
|
70
|
+
expect(a.log.filter((e) => e.kind === 'ssp.register')).toHaveLength(0);
|
|
71
|
+
const pending = R.beginRegistering(hash)(emptyState({ tabId: 't' }));
|
|
72
|
+
const p = await runPure(registerLocal(env, input), {
|
|
73
|
+
state: pending,
|
|
74
|
+
handlers: { 'state.wait': (_e, ctx) => void (ctx.state = R.endRegistering(hash)(ctx.state)) },
|
|
75
|
+
});
|
|
76
|
+
expect(p.result).toBe(hash);
|
|
77
|
+
expect(p.log.some((e) => e.kind === 'state.wait')).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('a failing SSP registration clears the in-flight marker and rethrows', async () => {
|
|
81
|
+
await expect(
|
|
82
|
+
runPure(registerLocal(env, input), {
|
|
83
|
+
handlers: {
|
|
84
|
+
'local.getById': () => null,
|
|
85
|
+
'ssp.register': () => {
|
|
86
|
+
throw new Error('wasm down');
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
).rejects.toThrow('wasm down');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('ensureRegistered', () => {
|
|
95
|
+
it('dispatches RegisterRemote for every unregistered query only', async () => {
|
|
96
|
+
const s = buildState([buildEntry({ def: { hash: 'a' } }), buildEntry({ def: { hash: 'b' }, lifecycle: { remote: 'registered' } })]);
|
|
97
|
+
const out = await runPure(ensureRegistered(env), { state: s });
|
|
98
|
+
expect(out.dispatched).toEqual([{ type: 'RegisterRemote', hash: 'a' }]);
|
|
99
|
+
const none = await runPure(ensureRegistered(env), { state: buildState() });
|
|
100
|
+
expect(none.dispatched).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
it('with requireAuth: probes $auth.id, retries on a timer, gives up after the budget, skips when anonymous', async () => {
|
|
103
|
+
const s = { ...buildState([buildEntry({ def: { hash: 'a' } })]), userId: 'user:1' };
|
|
104
|
+
const authed = await runPure(ensureRegistered(env, { requireAuth: true }), {
|
|
105
|
+
state: s,
|
|
106
|
+
handlers: { 'remote.query': () => [okStmt('user:1')] },
|
|
107
|
+
});
|
|
108
|
+
expect(authed.dispatched).toEqual([{ type: 'RegisterRemote', hash: 'a' }]);
|
|
109
|
+
const notYet = await runPure(ensureRegistered(env, { requireAuth: true, attempt: 2 }), {
|
|
110
|
+
state: s,
|
|
111
|
+
handlers: { 'remote.query': () => [okStmt(null)] },
|
|
112
|
+
});
|
|
113
|
+
expect(notYet.dispatched).toEqual([]);
|
|
114
|
+
expect(notYet.timers.get('ensure-registered')).toEqual({ ms: 500, event: { type: 'EnsureRegistered', requireAuth: true, attempt: 3 } });
|
|
115
|
+
const thrown = await runPure(ensureRegistered(env, { requireAuth: true }), {
|
|
116
|
+
state: s,
|
|
117
|
+
handlers: {
|
|
118
|
+
'remote.query': () => {
|
|
119
|
+
throw new Error('offline');
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(thrown.timers.has('ensure-registered')).toBe(true);
|
|
124
|
+
const exhausted = await runPure(ensureRegistered(env, { requireAuth: true, attempt: 9 }), {
|
|
125
|
+
state: s,
|
|
126
|
+
handlers: { 'remote.query': () => [okStmt(null)] },
|
|
127
|
+
});
|
|
128
|
+
expect(exhausted.timers.size).toBe(0);
|
|
129
|
+
expect(exhausted.emitted).toEqual([expect.objectContaining({ type: 'log', level: 'warn' })]);
|
|
130
|
+
const anon = await runPure(ensureRegistered(env, { requireAuth: true }), { state: { ...s, userId: null } });
|
|
131
|
+
expect(anon.dispatched).toEqual([{ type: 'RegisterRemote', hash: 'a' }]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('registerRemote', () => {
|
|
136
|
+
const entry = buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'cold' } });
|
|
137
|
+
const happy = () => [
|
|
138
|
+
okStmt(null),
|
|
139
|
+
okStmt([{ out: new RecordId('thing', '1'), version: 1 }]),
|
|
140
|
+
okStmt({ rowCount: 1, state: 'ready' }),
|
|
141
|
+
okStmt([{ out: new RecordId('child', 'c'), version: 1 }]),
|
|
142
|
+
];
|
|
143
|
+
it('registers, applies membership + children, flips registered, fetch-begin/end balanced', async () => {
|
|
144
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
145
|
+
state: buildState([entry]),
|
|
146
|
+
handlers: { 'remote.query': happy, 'local.upsert': () => undefined },
|
|
147
|
+
});
|
|
148
|
+
const e = out.state.queries.get('a')!;
|
|
149
|
+
expect(e.lifecycle).toMatchObject({ phase: 'live', remote: 'registered', fetchDepth: 0 });
|
|
150
|
+
expect(e.remoteArray).toEqual([['thing:1', 1]]);
|
|
151
|
+
expect(e.subqueryRemoteArray).toEqual([['child:c', 1]]);
|
|
152
|
+
expect(e.serverState).toBe('ready');
|
|
153
|
+
const req = out.log.find((x) => x.kind === 'remote.query') as any;
|
|
154
|
+
expect(req.sql.startsWith('fn::query::register($config);')).toBe(true);
|
|
155
|
+
expect(req.vars.config).toMatchObject({ surql: entry.def.surql, ttl: '10m' });
|
|
156
|
+
expect(out.dispatched).toEqual([{ type: 'FetchRows' }, { type: 'FetchRows' }, { type: 'SyncOutcome', ok: true }]);
|
|
157
|
+
expect(out.emitted).toContainEqual({ type: 'query:authority', hash: 'a', known: true });
|
|
158
|
+
});
|
|
159
|
+
it('a cold registration is foreground; a cached or live one is background', async () => {
|
|
160
|
+
for (const [phase, background] of [['cold', 0], ['cached', 1], ['live', 1]] as const) {
|
|
161
|
+
const seen: Array<[number, number]> = [];
|
|
162
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
163
|
+
state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { phase } })]),
|
|
164
|
+
handlers: {
|
|
165
|
+
'remote.query': (_e: unknown, ctx) => {
|
|
166
|
+
const l = ctx.state.queries.get('a')!.lifecycle;
|
|
167
|
+
seen.push([l.fetchDepth, l.backgroundDepth]);
|
|
168
|
+
return happy();
|
|
169
|
+
},
|
|
170
|
+
'local.upsert': () => undefined,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
expect(seen).toEqual([[1, background]]);
|
|
174
|
+
expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
it('guards: missing entry, already registered / failed, registering without retry', async () => {
|
|
178
|
+
const none = await runPure(registerRemote(env, 'zzz'), { state: buildState([]) });
|
|
179
|
+
expect(none.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
|
|
180
|
+
for (const remote of ['registered', 'failed', 'registering'] as const) {
|
|
181
|
+
const out = await runPure(registerRemote(env, 'a'), { state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote } })]) });
|
|
182
|
+
expect(out.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
|
|
183
|
+
}
|
|
184
|
+
const retry = await runPure(registerRemote(env, 'a', true), {
|
|
185
|
+
state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registering' } })]),
|
|
186
|
+
handlers: { 'remote.query': happy, 'local.upsert': () => undefined },
|
|
187
|
+
});
|
|
188
|
+
expect(retry.state.queries.get('a')!.lifecycle.remote).toBe('registered');
|
|
189
|
+
});
|
|
190
|
+
it('materializing answer schedules the re-read ladder', async () => {
|
|
191
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
192
|
+
state: buildState([entry]),
|
|
193
|
+
handlers: { 'remote.query': () => [okStmt(null), okStmt([]), okStmt({ rowCount: 3, state: 'materializing' }), okStmt([])] },
|
|
194
|
+
});
|
|
195
|
+
expect(out.state.membershipDirty.has('a')).toBe(true);
|
|
196
|
+
expect(out.state.membershipReread.get('a')).toBe(1);
|
|
197
|
+
expect(out.timers.get('membership')).toEqual({ ms: 150, event: { type: 'ReadDirtyMembership' } });
|
|
198
|
+
expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ phase: 'cold', remote: 'registered' });
|
|
199
|
+
});
|
|
200
|
+
it('errors: transport failure backs off, statement errors count as failures, budget exhausts to failed', async () => {
|
|
201
|
+
const boom = () => {
|
|
202
|
+
throw new Error('socket closed');
|
|
203
|
+
};
|
|
204
|
+
const first = await runPure(registerRemote(env, 'a'), { state: buildState([entry]), handlers: { 'remote.query': boom } });
|
|
205
|
+
expect(first.state.queries.get('a')!.lifecycle).toMatchObject({ remote: 'registering', fetchDepth: 0 });
|
|
206
|
+
expect(first.state.queries.get('a')!.registerAttempts).toBe(1);
|
|
207
|
+
expect(first.timers.get('register:a')).toEqual({ ms: 1000, event: { type: 'RegisterRemote', hash: 'a', retry: true } });
|
|
208
|
+
expect(first.dispatched).toContainEqual({ type: 'SyncOutcome', ok: false, error: new Error('socket closed') });
|
|
209
|
+
const errStmt = await runPure(registerRemote(env, 'a'), {
|
|
210
|
+
state: buildState([entry]),
|
|
211
|
+
handlers: { 'remote.query': () => [{ status: 'ERR', error: 'denied' }] },
|
|
212
|
+
});
|
|
213
|
+
expect(errStmt.state.queries.get('a')!.registerAttempts).toBe(1);
|
|
214
|
+
// The register statement answered but the read-back did not: the query
|
|
215
|
+
// IS registered, and its membership is read again rather than guessed.
|
|
216
|
+
for (const readBack of [
|
|
217
|
+
[okStmt(null), { status: 'ERR', error: 'no table' }, okStmt(null), okStmt(null)],
|
|
218
|
+
[okStmt(null), okStmt('nope'), okStmt(null), okStmt(null)],
|
|
219
|
+
] as StatementResult[][]) {
|
|
220
|
+
const out = await runPure(registerRemote(env, 'a'), { state: buildState([entry]), handlers: { 'remote.query': () => readBack } });
|
|
221
|
+
const e = out.state.queries.get('a')!;
|
|
222
|
+
expect(e.registerAttempts).toBe(0);
|
|
223
|
+
expect(e.lifecycle).toMatchObject({ remote: 'registered', fetchDepth: 0 });
|
|
224
|
+
expect(out.state.membershipDirty.has('a')).toBe(true);
|
|
225
|
+
expect(out.timers.get('membership')).toEqual({ ms: 50, event: { type: 'ReadDirtyMembership' } });
|
|
226
|
+
expect(out.dispatched).toContainEqual({ type: 'SyncOutcome', ok: false, error: 'registration read-back failed' });
|
|
227
|
+
}
|
|
228
|
+
const empty = await runPure(registerRemote(env, 'a'), { state: buildState([entry]), handlers: { 'remote.query': () => [] } });
|
|
229
|
+
expect(empty.state.queries.get('a')!.registerAttempts).toBe(1);
|
|
230
|
+
const last = await runPure(registerRemote(env, 'a'), {
|
|
231
|
+
state: buildState([buildEntry({ def: { hash: 'a' }, registerAttempts: 2 })]),
|
|
232
|
+
handlers: { 'remote.query': boom },
|
|
233
|
+
});
|
|
234
|
+
expect(last.state.queries.get('a')!.lifecycle.remote).toBe('failed');
|
|
235
|
+
expect(last.timers.size).toBe(0);
|
|
236
|
+
});
|
|
237
|
+
it('not allowlisted: 403/Forbidden/not_allowlisted fails after ONE attempt with an error log, no retry timer', async () => {
|
|
238
|
+
for (const text of ['HTTP 403 Forbidden', 'There was an error processing a remote HTTP request: {"error":"not_allowlisted"}', 'status 403']) {
|
|
239
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
240
|
+
state: buildState([entry]),
|
|
241
|
+
handlers: { 'remote.query': () => [{ status: 'ERR', error: text }] },
|
|
242
|
+
});
|
|
243
|
+
const e = out.state.queries.get('a')!;
|
|
244
|
+
expect(e.lifecycle).toMatchObject({ remote: 'failed', phase: 'cold', fetchDepth: 0 });
|
|
245
|
+
expect(e.registerAttempts).toBe(0);
|
|
246
|
+
expect(out.timers.size).toBe(0);
|
|
247
|
+
expect(out.emitted).toContainEqual(
|
|
248
|
+
expect.objectContaining({
|
|
249
|
+
type: 'log',
|
|
250
|
+
level: 'error',
|
|
251
|
+
message: 'query refused: not allowlisted',
|
|
252
|
+
data: expect.objectContaining({ hash: 'a', table: entry.def.tableName, surql: entry.def.surql, params: [] }),
|
|
253
|
+
})
|
|
254
|
+
);
|
|
255
|
+
expect(out.dispatched).toContainEqual(expect.objectContaining({ type: 'SyncOutcome', ok: false }));
|
|
256
|
+
}
|
|
257
|
+
const thrown = await runPure(registerRemote(env, 'a'), {
|
|
258
|
+
state: buildState([entry]),
|
|
259
|
+
handlers: {
|
|
260
|
+
'remote.query': () => {
|
|
261
|
+
throw new Error('Forbidden');
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
expect(thrown.state.queries.get('a')!.lifecycle.remote).toBe('failed');
|
|
266
|
+
expect(thrown.timers.size).toBe(0);
|
|
267
|
+
expect(isNotAllowlisted(new Error('socket closed'))).toBe(false);
|
|
268
|
+
expect(isNotAllowlisted('HTTP 4030')).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('an ERR meta or children statement applies nothing and re-reads, instead of reading as a lost row', async () => {
|
|
272
|
+
const lostBefore = buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'view-lost' }, remoteArray: [['thing:1', 1]], viewLostCount: 1 });
|
|
273
|
+
for (const [meta, kids] of [
|
|
274
|
+
[{ status: 'ERR', error: 'meta' }, okStmt([])],
|
|
275
|
+
[okStmt({ rowCount: 1, state: 'ready' }), { status: 'ERR', error: 'kids' }],
|
|
276
|
+
] as StatementResult[][]) {
|
|
277
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
278
|
+
state: buildState([lostBefore]),
|
|
279
|
+
handlers: { 'remote.query': () => [okStmt(null), okStmt([{ out: new RecordId('thing', '1'), version: 1 }]), meta, kids] },
|
|
280
|
+
});
|
|
281
|
+
const e = out.state.queries.get('a')!;
|
|
282
|
+
expect(e.lifecycle).toMatchObject({ phase: 'view-lost', remote: 'registered' });
|
|
283
|
+
expect(e.viewLostCount).toBe(1);
|
|
284
|
+
expect(out.dispatched.filter((d) => d.type === 'EnsureRegistered')).toEqual([]);
|
|
285
|
+
expect(out.state.membershipDirty.has('a')).toBe(true);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
it('stops when the entry disappears mid-flight', async () => {
|
|
289
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
290
|
+
state: buildState([entry]),
|
|
291
|
+
handlers: {
|
|
292
|
+
'remote.query': (_e, ctx) => {
|
|
293
|
+
ctx.state = R.removeQuery('a')(ctx.state);
|
|
294
|
+
return happy();
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
expect(out.state.queries.has('a')).toBe(false);
|
|
299
|
+
expect(out.dispatched).toEqual([]);
|
|
300
|
+
const gone = await runPure(registerRemote(env, 'a'), {
|
|
301
|
+
state: buildState([entry]),
|
|
302
|
+
handlers: {
|
|
303
|
+
'remote.query': (_e, ctx) => {
|
|
304
|
+
ctx.state = R.removeQuery('a')(ctx.state);
|
|
305
|
+
throw new Error('x');
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
expect(gone.dispatched).toEqual([]);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import type { QueryHash, QueryTimeToLive } from '../types';
|
|
4
|
+
import type { Saga } from '../kernel/saga';
|
|
5
|
+
import type { RegisterResult, StatementResult } from '../kernel/effects';
|
|
6
|
+
import { fx } from '../kernel/effects';
|
|
7
|
+
import { AUTH_READY_MAX_ATTEMPTS, AUTH_READY_RETRY_MS, REGISTER_MAX_RETRIES, backoffMs } from '../kernel/constants';
|
|
8
|
+
import type { ClientState, QueryEntry } from '../state/client-state';
|
|
9
|
+
import { emptyTelemetry } from '../state/client-state';
|
|
10
|
+
import { seedLifecycle } from '../state/lifecycle';
|
|
11
|
+
import * as R from '../state/reducers';
|
|
12
|
+
import { desiredRegistrations } from '../state/selectors';
|
|
13
|
+
import { parseDuration } from '../utils/index';
|
|
14
|
+
import type { SagaEnv } from './env';
|
|
15
|
+
import { listRefTable } from './env';
|
|
16
|
+
import { queryHashInput, viewKeyInput } from './hash';
|
|
17
|
+
import { FAILED, isResolvedBefore, parseViewRow, snapshotFromSingle } from './membership';
|
|
18
|
+
import { applyMembership, applySubqueryChildren, markMembershipDirty } from './membership.saga';
|
|
19
|
+
import * as sql from './sql';
|
|
20
|
+
|
|
21
|
+
export interface RegisterInput {
|
|
22
|
+
tableName: string;
|
|
23
|
+
surql: string;
|
|
24
|
+
params: Record<string, unknown>;
|
|
25
|
+
ttl: QueryTimeToLive;
|
|
26
|
+
plan?: QueryPlan;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Register a query locally: compute its keys, read the durable `_00_view`
|
|
31
|
+
* row, build the SSP local view and publish the entry. Returns as soon as the
|
|
32
|
+
* entry exists (the first paint comes from `materialize`); the remote
|
|
33
|
+
* registration is dispatched, never awaited.
|
|
34
|
+
*/
|
|
35
|
+
export function* registerLocal(env: SagaEnv, input: RegisterInput): Saga<QueryHash> {
|
|
36
|
+
const sessionId = (yield fx.state.read((s) => s.sessionId)) as string | null;
|
|
37
|
+
const hash = (yield fx.hash(queryHashInput(input, sessionId))) as string;
|
|
38
|
+
const status = (yield fx.state.read((s) => (s.queries.has(hash) ? 'active' : s.registering.has(hash) ? 'pending' : 'new'))) as
|
|
39
|
+
| 'active'
|
|
40
|
+
| 'pending'
|
|
41
|
+
| 'new';
|
|
42
|
+
if (status === 'active') return hash;
|
|
43
|
+
if (status === 'pending') {
|
|
44
|
+
yield fx.state.wait((s) => !s.registering.has(hash));
|
|
45
|
+
return hash;
|
|
46
|
+
}
|
|
47
|
+
yield fx.state.update(R.beginRegistering(hash));
|
|
48
|
+
try {
|
|
49
|
+
const viewKey = (yield fx.hash(viewKeyInput(input))) as string;
|
|
50
|
+
let viewRow: unknown = null;
|
|
51
|
+
try {
|
|
52
|
+
viewRow = yield fx.local.getById(sql.VIEW_TABLE, sql.viewRecordId(viewKey));
|
|
53
|
+
} catch {
|
|
54
|
+
viewRow = null;
|
|
55
|
+
}
|
|
56
|
+
const view = parseViewRow(viewRow);
|
|
57
|
+
const now = (yield fx.now()) as number;
|
|
58
|
+
const reg = (yield fx.ssp.register({
|
|
59
|
+
queryHash: hash,
|
|
60
|
+
surql: input.surql,
|
|
61
|
+
params: input.params,
|
|
62
|
+
ttl: input.ttl,
|
|
63
|
+
tableName: input.tableName,
|
|
64
|
+
})) as RegisterResult;
|
|
65
|
+
const entry: QueryEntry = {
|
|
66
|
+
def: {
|
|
67
|
+
id: new RecordId('_00_query', hash),
|
|
68
|
+
hash,
|
|
69
|
+
viewKey,
|
|
70
|
+
surql: input.surql,
|
|
71
|
+
params: input.params,
|
|
72
|
+
plan: input.plan,
|
|
73
|
+
ttl: input.ttl,
|
|
74
|
+
ttlMs: parseDuration(input.ttl),
|
|
75
|
+
tableName: input.tableName,
|
|
76
|
+
createdAt: now,
|
|
77
|
+
},
|
|
78
|
+
lifecycle: seedLifecycle(isResolvedBefore(view)),
|
|
79
|
+
remoteArray: view?.ids ?? [],
|
|
80
|
+
localArray: reg.localArray,
|
|
81
|
+
subqueryRemoteArray: [],
|
|
82
|
+
records: [],
|
|
83
|
+
serverState: null,
|
|
84
|
+
subscribers: 0,
|
|
85
|
+
lastSubscriberLeftAt: now,
|
|
86
|
+
lastHeartbeatAt: null,
|
|
87
|
+
lastPolledAt: null,
|
|
88
|
+
registerAttempts: 0,
|
|
89
|
+
viewLostCount: 0,
|
|
90
|
+
viewLostRetryAt: null,
|
|
91
|
+
telemetry: { ...emptyTelemetry(), registrationTimings: reg.timings },
|
|
92
|
+
};
|
|
93
|
+
yield fx.state.update(R.putQuery(entry));
|
|
94
|
+
yield fx.dispatch({ type: 'EnsureRegistered' });
|
|
95
|
+
return hash;
|
|
96
|
+
} finally {
|
|
97
|
+
yield fx.state.update(R.endRegistering(hash));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Make the remote match the desired set: start a registration for every
|
|
103
|
+
* query that has none. Idempotent. With `requireAuth` (after a reconnect or
|
|
104
|
+
* while degraded) it first waits for `$auth.id` to be visible on the socket:
|
|
105
|
+
* a register issued before that stamps the view with an empty identity.
|
|
106
|
+
*/
|
|
107
|
+
export function* ensureRegistered(env: SagaEnv, opts: { requireAuth?: boolean; attempt?: number } = {}): Saga<void> {
|
|
108
|
+
const [hashes, userId] = (yield fx.state.read((s) => [desiredRegistrations(s), s.userId])) as [QueryHash[], string | null];
|
|
109
|
+
if (hashes.length === 0) return;
|
|
110
|
+
if (opts.requireAuth && userId !== null) {
|
|
111
|
+
let authed = false;
|
|
112
|
+
try {
|
|
113
|
+
const res = (yield fx.remote.query('RETURN $auth.id', undefined, env.remoteTimeoutMs)) as StatementResult[];
|
|
114
|
+
authed = res[0]?.status === 'OK' && res[0].result != null;
|
|
115
|
+
} catch {
|
|
116
|
+
authed = false;
|
|
117
|
+
}
|
|
118
|
+
if (!authed) {
|
|
119
|
+
const attempt = (opts.attempt ?? 0) + 1;
|
|
120
|
+
if (attempt >= AUTH_READY_MAX_ATTEMPTS) {
|
|
121
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'auth identity never became visible; not registering', data: { attempt } });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
yield fx.timer.set('ensure-registered', AUTH_READY_RETRY_MS, { type: 'EnsureRegistered', requireAuth: true, attempt });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const hash of hashes) yield fx.dispatch({ type: 'RegisterRemote', hash });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const stmt = (results: StatementResult[], i: number): StatementResult | undefined => results[i];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The SSP answers a registration whose shape is not on the query allowlist
|
|
135
|
+
* with HTTP 403 `{"error":"not_allowlisted"}`. It reaches the client as the
|
|
136
|
+
* `fn::query::register` statement error carrying SurrealDB's http error text
|
|
137
|
+
* (status reason and/or body), and no retry can change the answer.
|
|
138
|
+
*/
|
|
139
|
+
export const isNotAllowlisted = (error: unknown): boolean =>
|
|
140
|
+
/not_allowlisted|\b403\b|forbidden/i.test(String((error as { message?: unknown })?.message ?? error));
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One remote registration: `fn::query::register` plus the edge/meta/children
|
|
144
|
+
* read in ONE request, then membership application. Concurrent with every
|
|
145
|
+
* other registration; retried on its own backoff; stops when the entry is
|
|
146
|
+
* gone.
|
|
147
|
+
*/
|
|
148
|
+
export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): Saga<void> {
|
|
149
|
+
const state = (yield fx.state.read((s) => s)) as ClientState;
|
|
150
|
+
const entry = state.queries.get(hash);
|
|
151
|
+
if (!entry) return;
|
|
152
|
+
const { remote } = entry.lifecycle;
|
|
153
|
+
if (remote === 'registered' || remote === 'failed' || (remote === 'registering' && !retry)) return;
|
|
154
|
+
// A query that already shows rows (cached or live) only re-verifies them
|
|
155
|
+
// here: the cycle holds `fetching` but is not traffic the user waits on.
|
|
156
|
+
const background = entry.lifecycle.phase !== 'cold';
|
|
157
|
+
yield fx.state.update(
|
|
158
|
+
R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin', background }))
|
|
159
|
+
);
|
|
160
|
+
try {
|
|
161
|
+
const table = listRefTable(env, state);
|
|
162
|
+
const results = (yield fx.remote.query(
|
|
163
|
+
sql.registerSelect(table),
|
|
164
|
+
sql.registerVars({
|
|
165
|
+
id: entry.def.id,
|
|
166
|
+
surql: entry.def.surql,
|
|
167
|
+
params: { ...entry.def.params },
|
|
168
|
+
ttl: entry.def.ttl,
|
|
169
|
+
...(state.ownerSecret ? { owner: state.ownerSecret } : {}),
|
|
170
|
+
}),
|
|
171
|
+
env.remoteTimeoutMs
|
|
172
|
+
)) as StatementResult[];
|
|
173
|
+
const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
|
|
174
|
+
if (!stillThere) return;
|
|
175
|
+
const register = stmt(results, 0);
|
|
176
|
+
if (!register || register.status === 'ERR') throw new Error(register?.status === 'ERR' ? register.error : 'register returned nothing');
|
|
177
|
+
const answer = (r: StatementResult | undefined): unknown => (r?.status === 'OK' ? r.result : FAILED);
|
|
178
|
+
const snap = snapshotFromSingle(
|
|
179
|
+
answer(stmt(results, 1)) as never,
|
|
180
|
+
answer(stmt(results, 2)) as never,
|
|
181
|
+
answer(stmt(results, 3)) as never
|
|
182
|
+
);
|
|
183
|
+
if (!snap) {
|
|
184
|
+
// Registered, but the read-back did not answer. That says nothing about
|
|
185
|
+
// the row, so read membership again rather than guess: a guess of
|
|
186
|
+
// "gone" re-registered the query, and under load the next read-back
|
|
187
|
+
// failed the same way.
|
|
188
|
+
yield fx.state.update(R.compose(R.applyLifecycle(hash, { type: 'remote-registered' }), R.resetRegisterAttempts(hash)));
|
|
189
|
+
yield fx.emit({ type: 'log', level: 'debug', message: 'registration read-back did not answer; re-reading membership', data: { hash } });
|
|
190
|
+
yield* markMembershipDirty([hash]);
|
|
191
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: false, error: 'registration read-back failed' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const outcome = yield* applyMembership(hash, snap.primary, snap.meta);
|
|
195
|
+
if (outcome === 'ignored' && snap.meta.present && snap.meta.state === 'materializing') {
|
|
196
|
+
yield fx.state.update(R.compose(R.markMembershipDirty([hash]), R.setMembershipReread(hash, 1)));
|
|
197
|
+
yield fx.timer.set('membership', 150, { type: 'ReadDirtyMembership' });
|
|
198
|
+
}
|
|
199
|
+
yield* applySubqueryChildren(hash, snap.subquery);
|
|
200
|
+
yield fx.state.update(R.compose(R.applyLifecycle(hash, { type: 'remote-registered' }), R.resetRegisterAttempts(hash)));
|
|
201
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: true });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
|
|
204
|
+
if (!current) return;
|
|
205
|
+
if (isNotAllowlisted(error)) {
|
|
206
|
+
yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
|
|
207
|
+
yield fx.emit({
|
|
208
|
+
type: 'log',
|
|
209
|
+
level: 'error',
|
|
210
|
+
message: 'query refused: not allowlisted',
|
|
211
|
+
data: {
|
|
212
|
+
hash,
|
|
213
|
+
table: entry.def.tableName,
|
|
214
|
+
surql: entry.def.surql,
|
|
215
|
+
params: Object.keys(entry.def.params),
|
|
216
|
+
hint: 'Add or regenerate the query allowlist: spky generate, then spky deploy (or restart spky dev).',
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const attempts = current.registerAttempts + 1;
|
|
223
|
+
yield fx.state.update(R.bumpRegisterAttempts(hash));
|
|
224
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'remote registration failed', data: { hash, attempts, error } });
|
|
225
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
|
|
226
|
+
if (attempts >= REGISTER_MAX_RETRIES) {
|
|
227
|
+
yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
|
|
228
|
+
} else {
|
|
229
|
+
yield fx.timer.set(`register:${hash}`, backoffMs(attempts), { type: 'RegisterRemote', hash, retry: true });
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
|
|
233
|
+
if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildRenderIds, paintsCold, resolveMembership } from './render-set';
|
|
3
|
+
|
|
4
|
+
const ov = (writes: string[] = [], deletes: string[] = []) => ({ writes: new Set(writes), deletes: new Set(deletes) });
|
|
5
|
+
|
|
6
|
+
describe('resolveMembership', () => {
|
|
7
|
+
it('cold scans, cold window uses the local view, anything else uses membership', () => {
|
|
8
|
+
const remoteArray: [string, number][] = [['t:1', 1]];
|
|
9
|
+
const localArray: [string, number][] = [['t:2', 1]];
|
|
10
|
+
expect(resolveMembership({ phase: 'cold', remoteArray, localArray, isWindow: false })).toBeNull();
|
|
11
|
+
expect(resolveMembership({ phase: 'cold', remoteArray, localArray, isWindow: true })).toBe(localArray);
|
|
12
|
+
expect(resolveMembership({ phase: 'cached', remoteArray, localArray, isWindow: false })).toBe(remoteArray);
|
|
13
|
+
expect(resolveMembership({ phase: 'view-lost', remoteArray, localArray, isWindow: true })).toBe(remoteArray);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('buildRenderIds', () => {
|
|
18
|
+
const opts = { hasExplicitOrder: false, isWindow: false };
|
|
19
|
+
it('membership minus deletes, sorted', () => {
|
|
20
|
+
expect(buildRenderIds([['t:b', 1], ['t:a', 1], ['t:c', 1]], [], ov([], ['t:c']), opts)).toEqual(['t:a', 't:b']);
|
|
21
|
+
});
|
|
22
|
+
it('adds pending writes the local view admits, never twice, never deleted', () => {
|
|
23
|
+
const ids = buildRenderIds(
|
|
24
|
+
[['t:1', 1]],
|
|
25
|
+
[['t:1', 1], ['t:2', 1], ['t:3', 1], ['t:4', 1]],
|
|
26
|
+
ov(['t:1', 't:2', 't:3', 't:9'], ['t:3']),
|
|
27
|
+
opts
|
|
28
|
+
);
|
|
29
|
+
expect(ids).toEqual(['t:1', 't:2']);
|
|
30
|
+
});
|
|
31
|
+
it('keeps server order for windows and explicit orderBy; dedupes membership', () => {
|
|
32
|
+
const m: [string, number][] = [['t:b', 1], ['t:a', 1], ['t:b', 1]];
|
|
33
|
+
expect(buildRenderIds(m, [], ov(), { hasExplicitOrder: true, isWindow: false })).toEqual(['t:b', 't:a']);
|
|
34
|
+
expect(buildRenderIds(m, [], ov(), { hasExplicitOrder: false, isWindow: true })).toEqual(['t:b', 't:a']);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('paintsCold', () => {
|
|
39
|
+
it('only vouched bodies, optimistic writes and bookkeeping paint before the server answers', () => {
|
|
40
|
+
const vouched = new Set(['t:1']);
|
|
41
|
+
expect(paintsCold('t:1', vouched, ov())).toBe(true);
|
|
42
|
+
expect(paintsCold('t:2', vouched, ov(['t:2']))).toBe(true);
|
|
43
|
+
expect(paintsCold('_00_user_feature:f', vouched, ov())).toBe(true);
|
|
44
|
+
expect(paintsCold('t:3', vouched, ov([], ['t:3']))).toBe(false);
|
|
45
|
+
expect(paintsCold('t:3', vouched, ov())).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { RecordVersionArray } from '../types';
|
|
2
|
+
import type { QueryPhase } from '../state/lifecycle';
|
|
3
|
+
import type { Overlay } from '../state/selectors';
|
|
4
|
+
|
|
5
|
+
export interface RenderInput {
|
|
6
|
+
phase: QueryPhase;
|
|
7
|
+
remoteArray: RecordVersionArray;
|
|
8
|
+
localArray: RecordVersionArray;
|
|
9
|
+
isWindow: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The id-set a query renders from, or `null` when it must fall back to a
|
|
14
|
+
* local predicate scan (a cold query that was never resolved on this device).
|
|
15
|
+
* A cold window has no usable scan (re-applying `START m` against the shared
|
|
16
|
+
* store returns the wrong rows), so it renders the SSP's local window instead.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveMembership(input: RenderInput): RecordVersionArray | null {
|
|
19
|
+
if (input.phase !== 'cold') return input.remoteArray;
|
|
20
|
+
if (input.isWindow) return input.localArray;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a body may paint before the server has answered (`cold`). One no
|
|
26
|
+
* durable view vouches for and no optimistic write touches is an orphan:
|
|
27
|
+
* deleted upstream while no view watched it, or dropped from every view since.
|
|
28
|
+
* The predicate scan would otherwise resurrect it until the server answers,
|
|
29
|
+
* and indefinitely offline.
|
|
30
|
+
*/
|
|
31
|
+
export function paintsCold(id: string, vouched: ReadonlySet<string>, overlay: Overlay): boolean {
|
|
32
|
+
return id.startsWith('_00_') || vouched.has(id) || overlay.writes.has(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RenderOptions {
|
|
36
|
+
hasExplicitOrder: boolean;
|
|
37
|
+
isWindow: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* render = (membership ∪ (writes ∩ localView)) − deletes
|
|
42
|
+
*
|
|
43
|
+
* `writes`/`deletes` are the outbox overlay (pending and acked items). The
|
|
44
|
+
* middle term keeps optimistic writes visible without re-admitting stale rows:
|
|
45
|
+
* `localView` (the SSP's local id-set) says whether the written row matches
|
|
46
|
+
* the query per local truth. Sorted unless the query orders itself or is a
|
|
47
|
+
* window (whose id order is the slice order).
|
|
48
|
+
*/
|
|
49
|
+
export function buildRenderIds(
|
|
50
|
+
membership: RecordVersionArray,
|
|
51
|
+
localView: RecordVersionArray,
|
|
52
|
+
overlay: Overlay,
|
|
53
|
+
opts: RenderOptions
|
|
54
|
+
): string[] {
|
|
55
|
+
const ordered: string[] = [];
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
for (const [id] of membership) {
|
|
58
|
+
if (overlay.deletes.has(id) || seen.has(id)) continue;
|
|
59
|
+
seen.add(id);
|
|
60
|
+
ordered.push(id);
|
|
61
|
+
}
|
|
62
|
+
if (overlay.writes.size > 0) {
|
|
63
|
+
for (const [id] of localView) {
|
|
64
|
+
if (!overlay.writes.has(id) || overlay.deletes.has(id) || seen.has(id)) continue;
|
|
65
|
+
seen.add(id);
|
|
66
|
+
ordered.push(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!opts.hasExplicitOrder && !opts.isWindow) ordered.sort();
|
|
70
|
+
return ordered;
|
|
71
|
+
}
|