@spooky-sync/core 0.0.1-canary.240 → 0.0.1-canary.241
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 +34 -15
- package/README.md +56 -16
- package/dist/index.d.ts +1136 -1846
- package/dist/index.js +10168 -11267
- package/dist/types.d.ts +22 -79
- package/package.json +8 -4
- package/skills/sp00ky-core/SKILL.md +18 -0
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +151 -0
- package/src/boot/boot.saga.ts +128 -0
- package/src/boot/bucket-switch.saga.test.ts +158 -0
- package/src/boot/bucket-switch.saga.ts +115 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +56 -0
- package/src/client/router.ts +94 -0
- package/src/client/runtime.test.ts +192 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +349 -0
- package/src/client/sp00ky-client.test.ts +241 -0
- package/src/client/sp00ky-client.ts +482 -0
- package/src/index.ts +1 -0
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +54 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +159 -0
- package/src/kernel/events.ts +55 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +143 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/modules/app-release/index.ts +13 -5
- package/src/modules/devtools/index.ts +10 -3
- package/src/modules/devtools/state-shape.test.ts +4 -1
- package/src/modules/feature-flag/index.ts +13 -5
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/{modules/data → mutation}/mutation-id.test.ts +12 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +215 -0
- package/src/mutation/rows.test.ts +201 -0
- package/src/mutation/rows.ts +377 -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 +163 -0
- package/src/mutation/write.saga.ts +166 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +156 -0
- package/src/query/fetch.saga.ts +109 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/{modules/data → query}/id-set-plan.test.ts +12 -2
- package/src/query/lifecycle.saga.test.ts +122 -0
- package/src/query/lifecycle.saga.ts +101 -0
- package/src/query/materialize.saga.test.ts +82 -0
- package/src/query/materialize.saga.ts +75 -0
- package/src/query/materialize.test.ts +50 -0
- package/src/query/materialize.ts +44 -0
- package/src/query/membership.saga.test.ts +246 -0
- package/src/query/membership.saga.ts +208 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +158 -0
- package/src/query/register.saga.test.ts +251 -0
- package/src/query/register.saga.ts +190 -0
- package/src/query/render-set.test.ts +36 -0
- package/src/query/render-set.ts +60 -0
- package/src/query/sql.test.ts +62 -0
- package/src/query/sql.ts +79 -0
- package/src/{modules/data → query}/window-query.test.ts +18 -0
- package/src/{modules/data → query}/window-query.ts +2 -1
- package/src/services/database/connection-supervisor.test.ts +29 -0
- package/src/services/database/connection-supervisor.ts +7 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +25 -10
- package/src/services/database/local-migrator.ts +2 -2
- package/src/services/database/plan-render.test.ts +1 -1
- package/src/services/database/sqlite-cache-engine.test.ts +4 -3
- package/src/services/database/sqlite-cache-engine.ts +2 -11
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +1 -1
- package/src/services/tabs/protocol.ts +7 -3
- package/src/sp00ky.ts +5 -1854
- package/src/state/client-state.ts +184 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +280 -0
- package/src/state/reducers.ts +476 -0
- package/src/state/selectors.test.ts +149 -0
- package/src/state/selectors.ts +184 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +69 -0
- package/src/sync/live.saga.ts +49 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/{modules/sync/utils.test.ts → sync/policy.test.ts} +31 -3
- package/src/{modules/sync/utils.ts → sync/policy.ts} +67 -5
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +74 -0
- package/src/sync/tabs.saga.ts +92 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +61 -0
- package/src/testing/fake-services.ts +34 -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 +8 -7
- package/src/utils/utils.test.ts +2 -1
- package/vitest.config.ts +48 -0
- package/coverage/lcov-report/base.css +0 -224
- package/coverage/lcov-report/block-navigation.js +0 -87
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +0 -476
- package/coverage/lcov-report/prettify.css +0 -1
- package/coverage/lcov-report/prettify.js +0 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +0 -210
- package/coverage/lcov-report/src/boot/auth-flip.saga.ts.html +0 -271
- package/coverage/lcov-report/src/boot/boot.saga.ts.html +0 -469
- package/coverage/lcov-report/src/boot/bucket-switch.saga.ts.html +0 -430
- package/coverage/lcov-report/src/boot/index.html +0 -161
- package/coverage/lcov-report/src/boot/preload.saga.ts.html +0 -178
- package/coverage/lcov-report/src/client/bucket-handle.ts.html +0 -766
- package/coverage/lcov-report/src/client/index.html +0 -161
- package/coverage/lcov-report/src/client/router.ts.html +0 -367
- package/coverage/lcov-report/src/client/runtime.ts.html +0 -940
- package/coverage/lcov-report/src/client/sp00ky-client.ts.html +0 -1531
- package/coverage/lcov-report/src/events/index.html +0 -116
- package/coverage/lcov-report/src/events/index.ts.html +0 -877
- package/coverage/lcov-report/src/index.html +0 -146
- package/coverage/lcov-report/src/index.ts.html +0 -202
- package/coverage/lcov-report/src/kernel/constants.ts.html +0 -247
- package/coverage/lcov-report/src/kernel/effects.ts.html +0 -562
- package/coverage/lcov-report/src/kernel/events.ts.html +0 -250
- package/coverage/lcov-report/src/kernel/index.html +0 -176
- package/coverage/lcov-report/src/kernel/interpreter.ts.html +0 -514
- package/coverage/lcov-report/src/kernel/saga.ts.html +0 -304
- package/coverage/lcov-report/src/modules/app-release/index.html +0 -116
- package/coverage/lcov-report/src/modules/app-release/index.ts.html +0 -712
- package/coverage/lcov-report/src/modules/auth/events/index.html +0 -116
- package/coverage/lcov-report/src/modules/auth/events/index.ts.html +0 -142
- package/coverage/lcov-report/src/modules/auth/index.html +0 -116
- package/coverage/lcov-report/src/modules/auth/index.ts.html +0 -1225
- package/coverage/lcov-report/src/modules/crdt/crdt-field.ts.html +0 -967
- package/coverage/lcov-report/src/modules/crdt/index.html +0 -146
- package/coverage/lcov-report/src/modules/crdt/index.ts.html +0 -1474
- package/coverage/lcov-report/src/modules/crdt/loro-loader.ts.html +0 -160
- package/coverage/lcov-report/src/modules/devtools/flags.ts.html +0 -1132
- package/coverage/lcov-report/src/modules/devtools/index.html +0 -161
- package/coverage/lcov-report/src/modules/devtools/index.ts.html +0 -2413
- package/coverage/lcov-report/src/modules/devtools/storage-info.ts.html +0 -589
- package/coverage/lcov-report/src/modules/devtools/versions.ts.html +0 -415
- package/coverage/lcov-report/src/modules/feature-flag/index.html +0 -116
- package/coverage/lcov-report/src/modules/feature-flag/index.ts.html +0 -1033
- package/coverage/lcov-report/src/modules/index.html +0 -116
- package/coverage/lcov-report/src/modules/ref-tables.ts.html +0 -349
- package/coverage/lcov-report/src/mutation/index.html +0 -191
- package/coverage/lcov-report/src/mutation/jobs.ts.html +0 -208
- package/coverage/lcov-report/src/mutation/mutation-id.ts.html +0 -190
- package/coverage/lcov-report/src/mutation/push.saga.ts.html +0 -730
- package/coverage/lcov-report/src/mutation/rows.ts.html +0 -1216
- package/coverage/lcov-report/src/mutation/tray.saga.ts.html +0 -229
- package/coverage/lcov-report/src/mutation/write.saga.ts.html +0 -583
- package/coverage/lcov-report/src/otel/index.html +0 -116
- package/coverage/lcov-report/src/otel/index.ts.html +0 -466
- package/coverage/lcov-report/src/query/env.ts.html +0 -253
- package/coverage/lcov-report/src/query/fetch.saga.ts.html +0 -412
- package/coverage/lcov-report/src/query/hash.ts.html +0 -139
- package/coverage/lcov-report/src/query/index.html +0 -281
- package/coverage/lcov-report/src/query/lifecycle.saga.ts.html +0 -388
- package/coverage/lcov-report/src/query/materialize.saga.ts.html +0 -310
- package/coverage/lcov-report/src/query/materialize.ts.html +0 -217
- package/coverage/lcov-report/src/query/membership.saga.ts.html +0 -709
- package/coverage/lcov-report/src/query/membership.ts.html +0 -559
- package/coverage/lcov-report/src/query/register.saga.ts.html +0 -655
- package/coverage/lcov-report/src/query/render-set.ts.html +0 -265
- package/coverage/lcov-report/src/query/sql.ts.html +0 -322
- package/coverage/lcov-report/src/query/window-query.ts.html +0 -670
- package/coverage/lcov-report/src/services/blobs/blob-cache.ts.html +0 -1894
- package/coverage/lcov-report/src/services/blobs/blob-manifest.ts.html +0 -766
- package/coverage/lcov-report/src/services/blobs/blob-store.ts.html +0 -1162
- package/coverage/lcov-report/src/services/blobs/index.html +0 -161
- package/coverage/lcov-report/src/services/blobs/index.ts.html +0 -295
- package/coverage/lcov-report/src/services/database/cache-engine.ts.html +0 -664
- package/coverage/lcov-report/src/services/database/connection-supervisor.ts.html +0 -1330
- package/coverage/lcov-report/src/services/database/database.ts.html +0 -658
- package/coverage/lcov-report/src/services/database/engine-factory.ts.html +0 -184
- package/coverage/lcov-report/src/services/database/errors.ts.html +0 -187
- package/coverage/lcov-report/src/services/database/events/index.html +0 -116
- package/coverage/lcov-report/src/services/database/events/index.ts.html +0 -184
- package/coverage/lcov-report/src/services/database/index.html +0 -386
- package/coverage/lcov-report/src/services/database/index.ts.html +0 -121
- package/coverage/lcov-report/src/services/database/local-migrator.ts.html +0 -703
- package/coverage/lcov-report/src/services/database/local.ts.html +0 -1633
- package/coverage/lcov-report/src/services/database/plan-render.ts.html +0 -409
- package/coverage/lcov-report/src/services/database/relation-resolver.ts.html +0 -682
- package/coverage/lcov-report/src/services/database/remote.ts.html +0 -1036
- package/coverage/lcov-report/src/services/database/sqlite-cache-engine.ts.html +0 -4132
- package/coverage/lcov-report/src/services/database/sqlite-lock-verify.ts.html +0 -220
- package/coverage/lcov-report/src/services/database/sqlite-open.ts.html +0 -577
- package/coverage/lcov-report/src/services/database/sqlite-plan-sql.ts.html +0 -499
- package/coverage/lcov-report/src/services/database/sqlite-select.ts.html +0 -478
- package/coverage/lcov-report/src/services/database/sqlite-transport.ts.html +0 -757
- package/coverage/lcov-report/src/services/database/surql-translate.ts.html +0 -1333
- package/coverage/lcov-report/src/services/database/surreal-cache-engine.ts.html +0 -568
- package/coverage/lcov-report/src/services/logger/index.html +0 -116
- package/coverage/lcov-report/src/services/logger/index.ts.html +0 -154
- package/coverage/lcov-report/src/services/persistence/index.html +0 -146
- package/coverage/lcov-report/src/services/persistence/localstorage.ts.html +0 -163
- package/coverage/lcov-report/src/services/persistence/resilient.ts.html +0 -208
- package/coverage/lcov-report/src/services/persistence/surrealdb.ts.html +0 -271
- package/coverage/lcov-report/src/services/stream-processor/index.html +0 -146
- package/coverage/lcov-report/src/services/stream-processor/index.ts.html +0 -3313
- package/coverage/lcov-report/src/services/stream-processor/permissions.ts.html +0 -244
- package/coverage/lcov-report/src/services/stream-processor/wasm-types.ts.html +0 -346
- package/coverage/lcov-report/src/services/tabs/broker-client.ts.html +0 -934
- package/coverage/lcov-report/src/services/tabs/coordinator.ts.html +0 -1984
- package/coverage/lcov-report/src/services/tabs/index.html +0 -191
- package/coverage/lcov-report/src/services/tabs/leader-locks.ts.html +0 -310
- package/coverage/lcov-report/src/services/tabs/protocol.ts.html +0 -871
- package/coverage/lcov-report/src/services/tabs/support.ts.html +0 -193
- package/coverage/lcov-report/src/services/tabs/tabs-broker-worker.ts.html +0 -2005
- package/coverage/lcov-report/src/sp00ky.ts.html +0 -100
- package/coverage/lcov-report/src/state/client-state.ts.html +0 -637
- package/coverage/lcov-report/src/state/index.html +0 -161
- package/coverage/lcov-report/src/state/lifecycle.ts.html +0 -343
- package/coverage/lcov-report/src/state/reducers.ts.html +0 -1471
- package/coverage/lcov-report/src/state/selectors.ts.html +0 -637
- package/coverage/lcov-report/src/sync/connection.saga.ts.html +0 -343
- package/coverage/lcov-report/src/sync/index.html +0 -176
- package/coverage/lcov-report/src/sync/live.saga.ts.html +0 -232
- package/coverage/lcov-report/src/sync/policy.ts.html +0 -1606
- package/coverage/lcov-report/src/sync/poll.saga.ts.html +0 -193
- package/coverage/lcov-report/src/sync/tabs.saga.ts.html +0 -361
- package/coverage/lcov-report/src/types.ts.html +0 -2419
- package/coverage/lcov-report/src/utils/blurhash.ts.html +0 -355
- package/coverage/lcov-report/src/utils/error-classification.ts.html +0 -244
- package/coverage/lcov-report/src/utils/index.html +0 -191
- package/coverage/lcov-report/src/utils/index.ts.html +0 -820
- package/coverage/lcov-report/src/utils/parser.ts.html +0 -313
- package/coverage/lcov-report/src/utils/semver.ts.html +0 -175
- package/coverage/lcov-report/src/utils/surql.ts.html +0 -667
- package/coverage/lcov.info +0 -19079
- package/src/modules/cache/cache.relay.test.ts +0 -95
- package/src/modules/cache/index.ts +0 -361
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.activity.test.ts +0 -109
- package/src/modules/data/data.authority.test.ts +0 -122
- package/src/modules/data/data.heartbeat-wake.test.ts +0 -115
- package/src/modules/data/data.hydration.test.ts +0 -142
- package/src/modules/data/data.membership.test.ts +0 -623
- package/src/modules/data/data.notify-table.test.ts +0 -41
- package/src/modules/data/data.pending-ids.test.ts +0 -199
- package/src/modules/data/data.rebind.test.ts +0 -170
- package/src/modules/data/data.rematerialize.test.ts +0 -114
- package/src/modules/data/data.run.test.ts +0 -113
- package/src/modules/data/data.settled-writes.test.ts +0 -235
- package/src/modules/data/data.status.test.ts +0 -249
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -2745
- package/src/modules/sync/engine.ts +0 -250
- package/src/modules/sync/events/index.ts +0 -84
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.test.ts +0 -228
- package/src/modules/sync/queue/queue-down.ts +0 -184
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +0 -164
- package/src/modules/sync/queue/queue-up.ts +0 -407
- package/src/modules/sync/scheduler.pause.test.ts +0 -109
- package/src/modules/sync/scheduler.retry.test.ts +0 -237
- package/src/modules/sync/scheduler.ts +0 -286
- package/src/modules/sync/sync.cleanup.test.ts +0 -116
- package/src/modules/sync/sync.health.test.ts +0 -167
- package/src/modules/sync/sync.heartbeat.test.ts +0 -80
- package/src/modules/sync/sync.list-ref-meta.test.ts +0 -193
- package/src/modules/sync/sync.live-removal.test.ts +0 -289
- package/src/modules/sync/sync.reconnect.test.ts +0 -145
- package/src/modules/sync/sync.rematerialize-after-fetch.test.ts +0 -93
- package/src/modules/sync/sync.settled-poll.test.ts +0 -58
- package/src/modules/sync/sync.subquery.test.ts +0 -82
- package/src/modules/sync/sync.tabs.test.ts +0 -249
- package/src/modules/sync/sync.ts +0 -2626
- package/src/sp00ky.auth-order.test.ts +0 -112
- package/src/sp00ky.init-query.test.ts +0 -183
- package/src/sp00ky.local-first.test.ts +0 -60
- /package/src/{modules/data → mutation}/mutation-id.ts +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { RecordId } from 'surrealdb';
|
|
2
|
+
import type { MembershipOutcome, RecordVersionArray, ServerViewMeta } from '../types';
|
|
3
|
+
import type { QueryPhase } from '../state/lifecycle';
|
|
4
|
+
import { encodeRecordId } from '../utils/index';
|
|
5
|
+
|
|
6
|
+
/** The durable `_00_view` row: the last server-confirmed membership. */
|
|
7
|
+
export interface DurableView {
|
|
8
|
+
ids: RecordVersionArray;
|
|
9
|
+
confirmed: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseViewRow(row: unknown): DurableView | null {
|
|
13
|
+
if (!row || typeof row !== 'object') return null;
|
|
14
|
+
const ids = (row as { ids?: unknown }).ids;
|
|
15
|
+
if (!Array.isArray(ids)) return null;
|
|
16
|
+
return { ids: ids as RecordVersionArray, confirmed: (row as { confirmed?: unknown }).confirmed === true };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* "Resolved before": the server answered this query on this device at some
|
|
21
|
+
* point. An unconfirmed empty row cannot be told apart from one written before
|
|
22
|
+
* the marker existed, so it does not count.
|
|
23
|
+
*/
|
|
24
|
+
export const isResolvedBefore = (view: DurableView | null): boolean =>
|
|
25
|
+
view !== null && (view.ids.length > 0 || view.confirmed);
|
|
26
|
+
|
|
27
|
+
/** The `{ rowCount, state }` object the row-count selects return per row. */
|
|
28
|
+
export interface QueryMetaRow {
|
|
29
|
+
id?: RecordId<string>;
|
|
30
|
+
rowCount?: number | null;
|
|
31
|
+
state?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function metaFromRow(row: QueryMetaRow | null | undefined): ServerViewMeta {
|
|
35
|
+
if (!row || typeof row !== 'object') return { present: false, rowCount: null, state: null };
|
|
36
|
+
const state = row.state === 'materializing' || row.state === 'ready' ? row.state : null;
|
|
37
|
+
return { present: true, rowCount: typeof row.rowCount === 'number' ? row.rowCount : null, state };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Collapse duplicate `(id, version)` pairs to one per id, keeping the highest version. */
|
|
41
|
+
export function dedupeRecordVersions(pairs: RecordVersionArray): RecordVersionArray {
|
|
42
|
+
if (pairs.length < 2) return pairs;
|
|
43
|
+
const best = new Map<string, number>();
|
|
44
|
+
for (const [id, version] of pairs) {
|
|
45
|
+
const prev = best.get(id);
|
|
46
|
+
if (prev === undefined || version > prev) best.set(id, version);
|
|
47
|
+
}
|
|
48
|
+
if (best.size === pairs.length) return pairs;
|
|
49
|
+
return Array.from(best.entries());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MembershipDecisionInput {
|
|
53
|
+
phase: QueryPhase;
|
|
54
|
+
/** Number of ids currently held as membership. */
|
|
55
|
+
held: number;
|
|
56
|
+
remoteArray: RecordVersionArray;
|
|
57
|
+
meta?: ServerViewMeta;
|
|
58
|
+
verifiedRemoval?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether a server id-set may be applied. A NON-EMPTY set is always the
|
|
63
|
+
* server's answer. An EMPTY set is believed only when the server stands
|
|
64
|
+
* behind it: the `_00_query` row is present, `ready` (or pre-`state`), and
|
|
65
|
+
* reports zero rows. A missing row while we hold membership is a lost view.
|
|
66
|
+
*/
|
|
67
|
+
export function decideMembershipOutcome(input: MembershipDecisionInput): MembershipOutcome {
|
|
68
|
+
const { phase, held, remoteArray, meta, verifiedRemoval } = input;
|
|
69
|
+
if (remoteArray.length > 0 || verifiedRemoval) return 'applied';
|
|
70
|
+
if (!meta || !meta.present) {
|
|
71
|
+
return held > 0 || phase !== 'cold' ? 'view-lost' : 'ignored';
|
|
72
|
+
}
|
|
73
|
+
const knownEmpty = meta.rowCount === 0 && meta.state !== 'materializing';
|
|
74
|
+
return knownEmpty ? 'applied' : 'ignored';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- list_ref snapshots ------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export interface ListRefEdgeRow {
|
|
80
|
+
in: RecordId<string>;
|
|
81
|
+
out: RecordId<string>;
|
|
82
|
+
version: number;
|
|
83
|
+
parent?: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ListRefSnapshot {
|
|
87
|
+
primary: RecordVersionArray;
|
|
88
|
+
subquery: RecordVersionArray;
|
|
89
|
+
meta: ServerViewMeta;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const toPairs = (rows: Array<{ out: RecordId<string>; version: number }> | null | undefined): RecordVersionArray =>
|
|
93
|
+
dedupeRecordVersions(Array.isArray(rows) ? rows.map((r) => [encodeRecordId(r.out), r.version]) : []);
|
|
94
|
+
|
|
95
|
+
/** Fold the single-query statement batch (edges, meta, children) into a snapshot. */
|
|
96
|
+
export function snapshotFromSingle(
|
|
97
|
+
items: Array<{ out: RecordId<string>; version: number }> | null,
|
|
98
|
+
metaRow: QueryMetaRow | null,
|
|
99
|
+
children: Array<{ out: RecordId<string>; version: number }> | null
|
|
100
|
+
): ListRefSnapshot | null {
|
|
101
|
+
if (!Array.isArray(items)) return null;
|
|
102
|
+
return { primary: toPairs(items), subquery: toPairs(children), meta: metaFromRow(metaRow) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fold the many-query statement batch into one snapshot per hash. Every hash
|
|
107
|
+
* in `hashById` gets a snapshot; a query whose row did not come back reads
|
|
108
|
+
* `present: false`.
|
|
109
|
+
*/
|
|
110
|
+
export function snapshotsFromBatch(
|
|
111
|
+
edges: ListRefEdgeRow[] | null,
|
|
112
|
+
counts: Array<QueryMetaRow | null> | null,
|
|
113
|
+
hashById: ReadonlyMap<string, string>
|
|
114
|
+
): Map<string, ListRefSnapshot> {
|
|
115
|
+
const out = new Map<string, ListRefSnapshot>();
|
|
116
|
+
if (!Array.isArray(edges)) return out;
|
|
117
|
+
for (const hash of hashById.values()) {
|
|
118
|
+
out.set(hash, { primary: [], subquery: [], meta: { present: false, rowCount: null, state: null } });
|
|
119
|
+
}
|
|
120
|
+
for (const row of edges) {
|
|
121
|
+
const hash = hashById.get(encodeRecordId(row.in));
|
|
122
|
+
if (!hash) continue;
|
|
123
|
+
const snapshot = out.get(hash)!;
|
|
124
|
+
const pair: [string, number] = [encodeRecordId(row.out), row.version];
|
|
125
|
+
if (row.parent == null) snapshot.primary.push(pair);
|
|
126
|
+
else snapshot.subquery.push(pair);
|
|
127
|
+
}
|
|
128
|
+
for (const snapshot of out.values()) {
|
|
129
|
+
snapshot.primary = dedupeRecordVersions(snapshot.primary);
|
|
130
|
+
snapshot.subquery = dedupeRecordVersions(snapshot.subquery);
|
|
131
|
+
}
|
|
132
|
+
for (const count of Array.isArray(counts) ? counts : []) {
|
|
133
|
+
if (!count || !count.id) continue;
|
|
134
|
+
const hash = hashById.get(encodeRecordId(count.id));
|
|
135
|
+
if (!hash) continue;
|
|
136
|
+
out.get(hash)!.meta = metaFromRow(count);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A batch that must not be believed at face value: a query we hold rows for
|
|
143
|
+
* whose `_00_query` row did not come back, or no edge at all while the server
|
|
144
|
+
* still reports rows for a held query. Such hashes are re-read one at a time.
|
|
145
|
+
*/
|
|
146
|
+
export function suspectHashes(
|
|
147
|
+
snapshots: ReadonlyMap<string, ListRefSnapshot>,
|
|
148
|
+
heldCounts: ReadonlyMap<string, number>,
|
|
149
|
+
edgeCount: number
|
|
150
|
+
): string[] {
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
for (const [hash, snapshot] of snapshots) {
|
|
153
|
+
const held = heldCounts.get(hash) ?? 0;
|
|
154
|
+
if (held === 0) continue;
|
|
155
|
+
if (!snapshot.meta.present || (edgeCount === 0 && (snapshot.meta.rowCount ?? 0) > 0)) out.push(hash);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
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, 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('guards: missing entry, already registered / failed, registering without retry', async () => {
|
|
160
|
+
const none = await runPure(registerRemote(env, 'zzz'), { state: buildState([]) });
|
|
161
|
+
expect(none.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
|
|
162
|
+
for (const remote of ['registered', 'failed', 'registering'] as const) {
|
|
163
|
+
const out = await runPure(registerRemote(env, 'a'), { state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote } })]) });
|
|
164
|
+
expect(out.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
|
|
165
|
+
}
|
|
166
|
+
const retry = await runPure(registerRemote(env, 'a', true), {
|
|
167
|
+
state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registering' } })]),
|
|
168
|
+
handlers: { 'remote.query': happy, 'local.upsert': () => undefined },
|
|
169
|
+
});
|
|
170
|
+
expect(retry.state.queries.get('a')!.lifecycle.remote).toBe('registered');
|
|
171
|
+
});
|
|
172
|
+
it('materializing answer schedules the re-read ladder', async () => {
|
|
173
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
174
|
+
state: buildState([entry]),
|
|
175
|
+
handlers: { 'remote.query': () => [okStmt(null), okStmt([]), okStmt({ rowCount: 3, state: 'materializing' }), okStmt([])] },
|
|
176
|
+
});
|
|
177
|
+
expect(out.state.membershipDirty.has('a')).toBe(true);
|
|
178
|
+
expect(out.state.membershipReread.get('a')).toBe(1);
|
|
179
|
+
expect(out.timers.get('membership')).toEqual({ ms: 150, event: { type: 'ReadDirtyMembership' } });
|
|
180
|
+
expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ phase: 'cold', remote: 'registered' });
|
|
181
|
+
});
|
|
182
|
+
it('errors: transport failure backs off, statement errors count as failures, budget exhausts to failed', async () => {
|
|
183
|
+
const boom = () => {
|
|
184
|
+
throw new Error('socket closed');
|
|
185
|
+
};
|
|
186
|
+
const first = await runPure(registerRemote(env, 'a'), { state: buildState([entry]), handlers: { 'remote.query': boom } });
|
|
187
|
+
expect(first.state.queries.get('a')!.lifecycle).toMatchObject({ remote: 'registering', fetchDepth: 0 });
|
|
188
|
+
expect(first.state.queries.get('a')!.registerAttempts).toBe(1);
|
|
189
|
+
expect(first.timers.get('register:a')).toEqual({ ms: 1000, event: { type: 'RegisterRemote', hash: 'a', retry: true } });
|
|
190
|
+
expect(first.dispatched).toContainEqual({ type: 'SyncOutcome', ok: false, error: new Error('socket closed') });
|
|
191
|
+
const errStmt = await runPure(registerRemote(env, 'a'), {
|
|
192
|
+
state: buildState([entry]),
|
|
193
|
+
handlers: { 'remote.query': () => [{ status: 'ERR', error: 'denied' }] },
|
|
194
|
+
});
|
|
195
|
+
expect(errStmt.state.queries.get('a')!.registerAttempts).toBe(1);
|
|
196
|
+
const edgesErr = await runPure(registerRemote(env, 'a'), {
|
|
197
|
+
state: buildState([entry]),
|
|
198
|
+
handlers: { 'remote.query': () => [okStmt(null), { status: 'ERR', error: 'no table' }, okStmt(null), okStmt(null)] },
|
|
199
|
+
});
|
|
200
|
+
expect(edgesErr.state.queries.get('a')!.registerAttempts).toBe(1);
|
|
201
|
+
const noArray = await runPure(registerRemote(env, 'a'), {
|
|
202
|
+
state: buildState([entry]),
|
|
203
|
+
handlers: { 'remote.query': () => [okStmt(null), okStmt('nope'), okStmt(null), okStmt(null)] },
|
|
204
|
+
});
|
|
205
|
+
expect(noArray.state.queries.get('a')!.registerAttempts).toBe(1);
|
|
206
|
+
const empty = await runPure(registerRemote(env, 'a'), { state: buildState([entry]), handlers: { 'remote.query': () => [] } });
|
|
207
|
+
expect(empty.state.queries.get('a')!.registerAttempts).toBe(1);
|
|
208
|
+
const last = await runPure(registerRemote(env, 'a'), {
|
|
209
|
+
state: buildState([buildEntry({ def: { hash: 'a' }, registerAttempts: 2 })]),
|
|
210
|
+
handlers: { 'remote.query': boom },
|
|
211
|
+
});
|
|
212
|
+
expect(last.state.queries.get('a')!.lifecycle.remote).toBe('failed');
|
|
213
|
+
expect(last.timers.size).toBe(0);
|
|
214
|
+
});
|
|
215
|
+
it('tolerates ERR meta/children statements: edges still apply, missing meta reads as no row', async () => {
|
|
216
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
217
|
+
state: buildState([entry]),
|
|
218
|
+
handlers: {
|
|
219
|
+
'remote.query': () => [okStmt(null), okStmt([{ out: new RecordId('thing', '1'), version: 1 }]), { status: 'ERR', error: 'meta' }, { status: 'ERR', error: 'kids' }],
|
|
220
|
+
'local.upsert': () => undefined,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const e = out.state.queries.get('a')!;
|
|
224
|
+
expect(e.lifecycle).toMatchObject({ phase: 'live', remote: 'registered' });
|
|
225
|
+
expect(e.serverState).toBeNull();
|
|
226
|
+
expect(e.subqueryRemoteArray).toEqual([]);
|
|
227
|
+
});
|
|
228
|
+
it('stops when the entry disappears mid-flight', async () => {
|
|
229
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
230
|
+
state: buildState([entry]),
|
|
231
|
+
handlers: {
|
|
232
|
+
'remote.query': (_e, ctx) => {
|
|
233
|
+
ctx.state = R.removeQuery('a')(ctx.state);
|
|
234
|
+
return happy();
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
expect(out.state.queries.has('a')).toBe(false);
|
|
239
|
+
expect(out.dispatched).toEqual([]);
|
|
240
|
+
const gone = await runPure(registerRemote(env, 'a'), {
|
|
241
|
+
state: buildState([entry]),
|
|
242
|
+
handlers: {
|
|
243
|
+
'remote.query': (_e, ctx) => {
|
|
244
|
+
ctx.state = R.removeQuery('a')(ctx.state);
|
|
245
|
+
throw new Error('x');
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
expect(gone.dispatched).toEqual([]);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
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 { isResolvedBefore, parseViewRow, snapshotFromSingle } from './membership';
|
|
18
|
+
import { applyMembership, applySubqueryChildren } 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
|
+
telemetry: { ...emptyTelemetry(), registrationTimings: reg.timings },
|
|
90
|
+
};
|
|
91
|
+
yield fx.state.update(R.putQuery(entry));
|
|
92
|
+
yield fx.dispatch({ type: 'EnsureRegistered' });
|
|
93
|
+
return hash;
|
|
94
|
+
} finally {
|
|
95
|
+
yield fx.state.update(R.endRegistering(hash));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Make the remote match the desired set: start a registration for every
|
|
101
|
+
* query that has none. Idempotent. With `requireAuth` (after a reconnect or
|
|
102
|
+
* while degraded) it first waits for `$auth.id` to be visible on the socket:
|
|
103
|
+
* a register issued before that stamps the view with an empty identity.
|
|
104
|
+
*/
|
|
105
|
+
export function* ensureRegistered(env: SagaEnv, opts: { requireAuth?: boolean; attempt?: number } = {}): Saga<void> {
|
|
106
|
+
const [hashes, userId] = (yield fx.state.read((s) => [desiredRegistrations(s), s.userId])) as [QueryHash[], string | null];
|
|
107
|
+
if (hashes.length === 0) return;
|
|
108
|
+
if (opts.requireAuth && userId !== null) {
|
|
109
|
+
let authed = false;
|
|
110
|
+
try {
|
|
111
|
+
const res = (yield fx.remote.query('RETURN $auth.id', undefined, env.remoteTimeoutMs)) as StatementResult[];
|
|
112
|
+
authed = res[0]?.status === 'OK' && res[0].result != null;
|
|
113
|
+
} catch {
|
|
114
|
+
authed = false;
|
|
115
|
+
}
|
|
116
|
+
if (!authed) {
|
|
117
|
+
const attempt = (opts.attempt ?? 0) + 1;
|
|
118
|
+
if (attempt >= AUTH_READY_MAX_ATTEMPTS) {
|
|
119
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'auth identity never became visible; not registering', data: { attempt } });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
yield fx.timer.set('ensure-registered', AUTH_READY_RETRY_MS, { type: 'EnsureRegistered', requireAuth: true, attempt });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const hash of hashes) yield fx.dispatch({ type: 'RegisterRemote', hash });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const stmt = (results: StatementResult[], i: number): StatementResult | undefined => results[i];
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One remote registration: `fn::query::register` plus the edge/meta/children
|
|
133
|
+
* read in ONE request, then membership application. Concurrent with every
|
|
134
|
+
* other registration; retried on its own backoff; stops when the entry is
|
|
135
|
+
* gone.
|
|
136
|
+
*/
|
|
137
|
+
export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): Saga<void> {
|
|
138
|
+
const state = (yield fx.state.read((s) => s)) as ClientState;
|
|
139
|
+
const entry = state.queries.get(hash);
|
|
140
|
+
if (!entry) return;
|
|
141
|
+
const { remote } = entry.lifecycle;
|
|
142
|
+
if (remote === 'registered' || remote === 'failed' || (remote === 'registering' && !retry)) return;
|
|
143
|
+
yield fx.state.update(
|
|
144
|
+
R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin' }))
|
|
145
|
+
);
|
|
146
|
+
try {
|
|
147
|
+
const table = listRefTable(env, state);
|
|
148
|
+
const results = (yield fx.remote.query(
|
|
149
|
+
sql.registerSelect(table),
|
|
150
|
+
sql.registerVars({ id: entry.def.id, surql: entry.def.surql, params: { ...entry.def.params }, ttl: entry.def.ttl }),
|
|
151
|
+
env.remoteTimeoutMs
|
|
152
|
+
)) as StatementResult[];
|
|
153
|
+
const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
|
|
154
|
+
if (!stillThere) return;
|
|
155
|
+
const register = stmt(results, 0);
|
|
156
|
+
if (!register || register.status === 'ERR') throw new Error(register?.status === 'ERR' ? register.error : 'register returned nothing');
|
|
157
|
+
const edges = stmt(results, 1);
|
|
158
|
+
const meta = stmt(results, 2);
|
|
159
|
+
const children = stmt(results, 3);
|
|
160
|
+
const snap = snapshotFromSingle(
|
|
161
|
+
edges?.status === 'OK' ? (edges.result as never) : null,
|
|
162
|
+
meta?.status === 'OK' ? (meta.result as never) : null,
|
|
163
|
+
children?.status === 'OK' ? (children.result as never) : null
|
|
164
|
+
);
|
|
165
|
+
if (!snap) throw new Error(edges?.status === 'ERR' ? edges.error : 'edge read returned no array');
|
|
166
|
+
const outcome = yield* applyMembership(hash, snap.primary, snap.meta);
|
|
167
|
+
if (outcome === 'ignored' && snap.meta.present && snap.meta.state === 'materializing') {
|
|
168
|
+
yield fx.state.update(R.compose(R.markMembershipDirty([hash]), R.setMembershipReread(hash, 1)));
|
|
169
|
+
yield fx.timer.set('membership', 150, { type: 'ReadDirtyMembership' });
|
|
170
|
+
}
|
|
171
|
+
yield* applySubqueryChildren(hash, snap.subquery);
|
|
172
|
+
yield fx.state.update(R.compose(R.applyLifecycle(hash, { type: 'remote-registered' }), R.resetRegisterAttempts(hash)));
|
|
173
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: true });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
|
|
176
|
+
if (!current) return;
|
|
177
|
+
const attempts = current.registerAttempts + 1;
|
|
178
|
+
yield fx.state.update(R.bumpRegisterAttempts(hash));
|
|
179
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'remote registration failed', data: { hash, attempts, error } });
|
|
180
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
|
|
181
|
+
if (attempts >= REGISTER_MAX_RETRIES) {
|
|
182
|
+
yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
|
|
183
|
+
} else {
|
|
184
|
+
yield fx.timer.set(`register:${hash}`, backoffMs(attempts), { type: 'RegisterRemote', hash, retry: true });
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
|
|
188
|
+
if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end' }));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildRenderIds, 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
|
+
});
|