@spooky-sync/core 0.0.1-canary.240 → 0.0.1-canary.242
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/sqlite-open.js +10 -1
- 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/database/sqlite-plan-sql.ts +16 -1
- package/src/services/database/sqlite-projection.test.ts +25 -3
- 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,296 @@
|
|
|
1
|
+
import type { Logger } from '../services/logger/index';
|
|
2
|
+
import type { QueryAuthorityCallback, QueryHash, QueryStatus, QueryStatusCallback, QueryUpdateCallback } from '../types';
|
|
3
|
+
import type { OutEvent, RuntimeEvent } from '../kernel/events';
|
|
4
|
+
import type { Adapters, Interpreter } from '../kernel/interpreter';
|
|
5
|
+
import { createInterpreter } from '../kernel/interpreter';
|
|
6
|
+
import type { Lane, Saga } from '../kernel/saga';
|
|
7
|
+
import { runSaga } from '../kernel/saga';
|
|
8
|
+
import type { ClientState } from '../state/client-state';
|
|
9
|
+
import { emptyState } from '../state/client-state';
|
|
10
|
+
import { deriveStatus, isAuthoritative } from '../state/lifecycle';
|
|
11
|
+
import * as R from '../state/reducers';
|
|
12
|
+
import { fetchingQueryCount, pendingMutationCount } from '../state/selectors';
|
|
13
|
+
import type { SagaEnv } from '../query/env';
|
|
14
|
+
import { route } from './router';
|
|
15
|
+
|
|
16
|
+
interface Waiter {
|
|
17
|
+
until: (s: ClientState) => boolean;
|
|
18
|
+
resolve: () => void;
|
|
19
|
+
reject: (e: unknown) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RuntimeOptions {
|
|
23
|
+
env: SagaEnv;
|
|
24
|
+
adapters: Adapters;
|
|
25
|
+
logger: Logger;
|
|
26
|
+
tabId: string;
|
|
27
|
+
initialState?: ClientState;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The one effectful object of the engine: holds the state, runs sagas on
|
|
32
|
+
* lanes, fires timers, fans events out to subscribers and schedules
|
|
33
|
+
* materialization for dirty queries. No decision logic lives here.
|
|
34
|
+
*/
|
|
35
|
+
export class Runtime {
|
|
36
|
+
private stateValue: ClientState;
|
|
37
|
+
private readonly interpret: Interpreter;
|
|
38
|
+
private readonly env: SagaEnv;
|
|
39
|
+
private readonly adapters: Adapters;
|
|
40
|
+
private readonly logger: Logger;
|
|
41
|
+
private readonly serialLanes = new Map<string, Promise<unknown>>();
|
|
42
|
+
private readonly dedupeLanes = new Map<string, Promise<unknown>>();
|
|
43
|
+
private readonly waiters = new Set<Waiter>();
|
|
44
|
+
private readonly recordSubs = new Map<QueryHash, Set<QueryUpdateCallback>>();
|
|
45
|
+
private readonly statusSubs = new Map<QueryHash, Set<QueryStatusCallback>>();
|
|
46
|
+
private readonly authoritySubs = new Map<QueryHash, Set<QueryAuthorityCallback>>();
|
|
47
|
+
private readonly lastStatus = new Map<QueryHash, QueryStatus>();
|
|
48
|
+
private readonly listeners = new Map<string, Set<(event: OutEvent) => void>>();
|
|
49
|
+
private readonly scheduledMaterialize = new Set<QueryHash>();
|
|
50
|
+
private readonly timerKeys = new Set<string>();
|
|
51
|
+
private lastActivity = { fetching: 0, pending: 0 };
|
|
52
|
+
private lastHealth: ClientState['sync']['health'];
|
|
53
|
+
private disposed = false;
|
|
54
|
+
|
|
55
|
+
constructor(opts: RuntimeOptions) {
|
|
56
|
+
this.env = opts.env;
|
|
57
|
+
this.logger = opts.logger;
|
|
58
|
+
this.stateValue = opts.initialState ?? emptyState({ tabId: opts.tabId });
|
|
59
|
+
this.lastHealth = this.stateValue.sync.health;
|
|
60
|
+
const timers = opts.adapters.timers;
|
|
61
|
+
this.adapters = {
|
|
62
|
+
...opts.adapters,
|
|
63
|
+
timers: {
|
|
64
|
+
set: (key, ms, fire) => {
|
|
65
|
+
this.timerKeys.add(key);
|
|
66
|
+
timers.set(key, ms, () => {
|
|
67
|
+
this.timerKeys.delete(key);
|
|
68
|
+
if (!this.disposed) fire();
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
clear: (key) => {
|
|
72
|
+
this.timerKeys.delete(key);
|
|
73
|
+
timers.clear(key);
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
this.interpret = createInterpreter(this.adapters, {
|
|
78
|
+
getState: () => this.stateValue,
|
|
79
|
+
setState: (next) => this.setState(next),
|
|
80
|
+
waitFor: (until, signal) => this.waitFor(until, signal),
|
|
81
|
+
emit: (event) => this.emit(event),
|
|
82
|
+
dispatch: (event) => void this.dispatch(event),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get state(): ClientState {
|
|
87
|
+
return this.stateValue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Run a saga, optionally on a lane. Rejections propagate to the caller. */
|
|
91
|
+
run<R>(saga: Saga<R>, opts: { lane?: Lane; signal?: AbortSignal } = {}): Promise<R> {
|
|
92
|
+
const exec = () => runSaga(saga, (effect) => this.interpret(effect, { signal: opts.signal }));
|
|
93
|
+
const lane = opts.lane;
|
|
94
|
+
if (!lane) return exec();
|
|
95
|
+
if (lane.kind === 'dedupe') {
|
|
96
|
+
const running = this.dedupeLanes.get(lane.key);
|
|
97
|
+
if (running) return running as Promise<R>;
|
|
98
|
+
const p = exec().finally(() => {
|
|
99
|
+
if (this.dedupeLanes.get(lane.key) === p) this.dedupeLanes.delete(lane.key);
|
|
100
|
+
});
|
|
101
|
+
this.dedupeLanes.set(lane.key, p);
|
|
102
|
+
return p;
|
|
103
|
+
}
|
|
104
|
+
const prev = this.serialLanes.get(lane.key) ?? Promise.resolve();
|
|
105
|
+
const p = prev.then(exec, exec);
|
|
106
|
+
const tail = p.catch(() => undefined);
|
|
107
|
+
this.serialLanes.set(lane.key, tail);
|
|
108
|
+
void tail.then(() => {
|
|
109
|
+
if (this.serialLanes.get(lane.key) === tail) this.serialLanes.delete(lane.key);
|
|
110
|
+
});
|
|
111
|
+
return p;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Route an event to its saga. Never rejects: failures are logged. */
|
|
115
|
+
dispatch(event: RuntimeEvent): Promise<void> {
|
|
116
|
+
if (this.disposed) return Promise.resolve();
|
|
117
|
+
const target = route(this.env, event);
|
|
118
|
+
return this.run(target.saga, { lane: target.lane }).then(
|
|
119
|
+
() => undefined,
|
|
120
|
+
(error) => {
|
|
121
|
+
this.logger.error({ error, event: event.type, Category: 'sp00ky-client::Runtime::dispatch' }, 'saga failed');
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private setState(next: ClientState): void {
|
|
127
|
+
const prev = this.stateValue;
|
|
128
|
+
if (next === prev) return;
|
|
129
|
+
this.stateValue = next;
|
|
130
|
+
for (const w of [...this.waiters]) {
|
|
131
|
+
let ok = false;
|
|
132
|
+
try {
|
|
133
|
+
ok = w.until(next);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
this.waiters.delete(w);
|
|
136
|
+
w.reject(error);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (ok) {
|
|
140
|
+
this.waiters.delete(w);
|
|
141
|
+
w.resolve();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const hash of next.dirty) {
|
|
145
|
+
if (this.scheduledMaterialize.has(hash)) continue;
|
|
146
|
+
this.scheduledMaterialize.add(hash);
|
|
147
|
+
this.adapters.timers.set(`mat:${hash}`, this.env.materializeDebounceMs, () => {
|
|
148
|
+
this.scheduledMaterialize.delete(hash);
|
|
149
|
+
void this.dispatch({ type: 'Materialize', hash });
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
for (const [hash, subs] of this.statusSubs) {
|
|
153
|
+
const entry = next.queries.get(hash);
|
|
154
|
+
if (!entry) continue;
|
|
155
|
+
const status = deriveStatus(entry.lifecycle);
|
|
156
|
+
if (this.lastStatus.get(hash) === status) continue;
|
|
157
|
+
this.lastStatus.set(hash, status);
|
|
158
|
+
for (const cb of subs) this.safely(() => cb(status));
|
|
159
|
+
this.notify({ type: 'query:status', hash, status });
|
|
160
|
+
}
|
|
161
|
+
const activity = { fetching: fetchingQueryCount(next), pending: pendingMutationCount(next) };
|
|
162
|
+
if (activity.fetching !== this.lastActivity.fetching || activity.pending !== this.lastActivity.pending) {
|
|
163
|
+
this.lastActivity = activity;
|
|
164
|
+
this.notify({ type: 'activity:changed', ...activity });
|
|
165
|
+
}
|
|
166
|
+
// Health is notified from here, not from the saga that folds a sync round:
|
|
167
|
+
// `connection` moves through `setConnection` on a transport event with no
|
|
168
|
+
// round attached, and a subscriber that only heard the round would show a
|
|
169
|
+
// stale socket state forever. `setHealth` keeps the identity stable while
|
|
170
|
+
// the values are unchanged, so this fires exactly on real transitions.
|
|
171
|
+
if (next.sync.health !== this.lastHealth) {
|
|
172
|
+
this.lastHealth = next.sync.health;
|
|
173
|
+
this.notify({ type: 'health:changed', health: next.sync.health });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
waitFor(until: (s: ClientState) => boolean, signal?: AbortSignal): Promise<void> {
|
|
178
|
+
try {
|
|
179
|
+
if (until(this.stateValue)) return Promise.resolve();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return Promise.reject(error);
|
|
182
|
+
}
|
|
183
|
+
if (signal?.aborted) return Promise.reject(new Error('aborted'));
|
|
184
|
+
return new Promise<void>((resolve, reject) => {
|
|
185
|
+
const waiter: Waiter = { until, resolve, reject };
|
|
186
|
+
this.waiters.add(waiter);
|
|
187
|
+
signal?.addEventListener(
|
|
188
|
+
'abort',
|
|
189
|
+
() => {
|
|
190
|
+
if (this.waiters.delete(waiter)) reject(new Error('aborted'));
|
|
191
|
+
},
|
|
192
|
+
{ once: true }
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Deliver an outbound event to its subscribers and listeners. */
|
|
198
|
+
emit(event: OutEvent): void {
|
|
199
|
+
switch (event.type) {
|
|
200
|
+
case 'query:records':
|
|
201
|
+
for (const cb of this.recordSubs.get(event.hash) ?? []) this.safely(() => cb(event.records as Record<string, any>[]));
|
|
202
|
+
break;
|
|
203
|
+
case 'query:authority':
|
|
204
|
+
for (const cb of this.authoritySubs.get(event.hash) ?? []) this.safely(() => cb(event.known));
|
|
205
|
+
break;
|
|
206
|
+
case 'log':
|
|
207
|
+
this.logger[event.level](Object.assign({}, event.data as object | undefined, { Category: 'sp00ky-client::saga' }), event.message);
|
|
208
|
+
break;
|
|
209
|
+
default:
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
this.notify(event);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private notify(event: OutEvent): void {
|
|
216
|
+
for (const cb of this.listeners.get(event.type) ?? []) this.safely(() => cb(event));
|
|
217
|
+
for (const cb of this.listeners.get('*') ?? []) this.safely(() => cb(event));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private safely(fn: () => void): void {
|
|
221
|
+
try {
|
|
222
|
+
fn();
|
|
223
|
+
} catch (error) {
|
|
224
|
+
this.logger.error({ error, Category: 'sp00ky-client::Runtime::subscriber' }, 'subscriber threw');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Observe outbound events by type (`'*'` for all). */
|
|
229
|
+
on(type: OutEvent['type'] | '*', cb: (event: OutEvent) => void): () => void {
|
|
230
|
+
const set = this.listeners.get(type) ?? new Set();
|
|
231
|
+
set.add(cb);
|
|
232
|
+
this.listeners.set(type, set);
|
|
233
|
+
return () => void set.delete(cb);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
subscribe(hash: QueryHash, cb: QueryUpdateCallback, options: { immediate?: boolean } = {}): () => void {
|
|
237
|
+
const set = this.recordSubs.get(hash) ?? new Set();
|
|
238
|
+
set.add(cb);
|
|
239
|
+
this.recordSubs.set(hash, set);
|
|
240
|
+
this.setState(R.subscribe(hash)(this.stateValue));
|
|
241
|
+
if (options.immediate) {
|
|
242
|
+
const entry = this.stateValue.queries.get(hash);
|
|
243
|
+
if (entry) this.safely(() => cb(entry.records as Record<string, any>[]));
|
|
244
|
+
}
|
|
245
|
+
return () => {
|
|
246
|
+
if (!set.delete(cb)) return;
|
|
247
|
+
if (set.size === 0) this.recordSubs.delete(hash);
|
|
248
|
+
this.setState(R.unsubscribe(hash, this.adapters.clock.now())(this.stateValue));
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
subscribeStatus(hash: QueryHash, cb: QueryStatusCallback, options: { immediate?: boolean } = {}): () => void {
|
|
253
|
+
const set = this.statusSubs.get(hash) ?? new Set();
|
|
254
|
+
set.add(cb);
|
|
255
|
+
this.statusSubs.set(hash, set);
|
|
256
|
+
const entry = this.stateValue.queries.get(hash);
|
|
257
|
+
if (entry) {
|
|
258
|
+
const status = deriveStatus(entry.lifecycle);
|
|
259
|
+
this.lastStatus.set(hash, status);
|
|
260
|
+
if (options.immediate) this.safely(() => cb(status));
|
|
261
|
+
}
|
|
262
|
+
return () => {
|
|
263
|
+
set.delete(cb);
|
|
264
|
+
if (set.size === 0) {
|
|
265
|
+
this.statusSubs.delete(hash);
|
|
266
|
+
this.lastStatus.delete(hash);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
subscribeAuthority(hash: QueryHash, cb: QueryAuthorityCallback, options: { immediate?: boolean } = {}): () => void {
|
|
272
|
+
const set = this.authoritySubs.get(hash) ?? new Set();
|
|
273
|
+
set.add(cb);
|
|
274
|
+
this.authoritySubs.set(hash, set);
|
|
275
|
+
if (options.immediate) {
|
|
276
|
+
const entry = this.stateValue.queries.get(hash);
|
|
277
|
+
if (entry) this.safely(() => cb(isAuthoritative(entry.lifecycle)));
|
|
278
|
+
}
|
|
279
|
+
return () => {
|
|
280
|
+
set.delete(cb);
|
|
281
|
+
if (set.size === 0) this.authoritySubs.delete(hash);
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Apply a reducer outside a saga (facade conveniences such as timing reports). */
|
|
286
|
+
update(reducer: (s: ClientState) => ClientState): void {
|
|
287
|
+
this.setState(reducer(this.stateValue));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
dispose(): void {
|
|
291
|
+
this.disposed = true;
|
|
292
|
+
for (const key of [...this.timerKeys]) this.adapters.timers.clear(key);
|
|
293
|
+
for (const w of this.waiters) w.reject(new Error('runtime disposed'));
|
|
294
|
+
this.waiters.clear();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { RecordId, type Uuid } from 'surrealdb';
|
|
2
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
3
|
+
import type { Sp00kyConfig, PersistenceClient } from '../types';
|
|
4
|
+
import type { Logger } from '../services/logger/index';
|
|
5
|
+
import { createLogger } from '../services/logger/index';
|
|
6
|
+
import { ConnectionSupervisor, LocalMigrator, RemoteDatabaseService, createLocalEngine } from '../services/database/index';
|
|
7
|
+
import type { LocalStore } from '../services/database/index';
|
|
8
|
+
import { StreamProcessorService } from '../services/stream-processor/index';
|
|
9
|
+
import type { StreamUpdate } from '../services/stream-processor/index';
|
|
10
|
+
import { extractSelectPermissions } from '../services/stream-processor/permissions';
|
|
11
|
+
import { EventSystem } from '../events/index';
|
|
12
|
+
import { AuthService } from '../modules/auth/index';
|
|
13
|
+
import { CrdtManager } from '../modules/crdt/index';
|
|
14
|
+
import { preloadLoro } from '../modules/crdt/loro-loader';
|
|
15
|
+
import { LocalStoragePersistenceClient } from '../services/persistence/localstorage';
|
|
16
|
+
import { SurrealDBPersistenceClient } from '../services/persistence/surrealdb';
|
|
17
|
+
import { ResilientPersistenceClient } from '../services/persistence/resilient';
|
|
18
|
+
import { ANON_USER_ID } from '../modules/ref-tables';
|
|
19
|
+
import { detectSharedTabsSupport } from '../services/tabs/support';
|
|
20
|
+
import { TabsCoordinator, type CoordinatorHooks } from '../services/tabs/coordinator';
|
|
21
|
+
import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
|
|
22
|
+
import { computeTabsFingerprint, hash53 } from '../services/tabs/protocol';
|
|
23
|
+
import type { SqliteCacheEngine } from '../services/database/sqlite-cache-engine';
|
|
24
|
+
import type { BlobCache } from '../services/blobs/index';
|
|
25
|
+
import { MemoryBlobStore, createBlobCache, resolveBlobBudget } from '../services/blobs/index';
|
|
26
|
+
import type { Adapters } from '../kernel/interpreter';
|
|
27
|
+
import type { RuntimeEvent } from '../kernel/events';
|
|
28
|
+
import type { ServiceCalls } from '../kernel/effects';
|
|
29
|
+
import { encodeRecordId } from '../utils/index';
|
|
30
|
+
import { mintMutationId } from '../mutation/mutation-id';
|
|
31
|
+
import { BucketHandle, bucketContentToBlob } from './bucket-handle';
|
|
32
|
+
|
|
33
|
+
const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
|
|
34
|
+
|
|
35
|
+
export function readBootBucketHint(): string | null {
|
|
36
|
+
try {
|
|
37
|
+
return typeof localStorage !== 'undefined' ? localStorage.getItem(LAST_BUCKET_KEY) : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeBootBucketHint(bucketId: string): void {
|
|
44
|
+
try {
|
|
45
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(LAST_BUCKET_KEY, bucketId);
|
|
46
|
+
} catch {
|
|
47
|
+
/* private-mode storage errors: boot falls back to the anon bucket */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function sha256Hex(input: string): Promise<string> {
|
|
52
|
+
const bytes = new TextEncoder().encode(input);
|
|
53
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
54
|
+
return Array.from(new Uint8Array(digest))
|
|
55
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
56
|
+
.join('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function mintSalt(): string {
|
|
60
|
+
const c = (globalThis as { crypto?: Crypto }).crypto;
|
|
61
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
62
|
+
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Everything the facade talks to besides the runtime. */
|
|
66
|
+
export interface Services<S extends SchemaStructure> {
|
|
67
|
+
logger: Logger;
|
|
68
|
+
local: LocalStore;
|
|
69
|
+
remote: RemoteDatabaseService;
|
|
70
|
+
connectionSupervisor: ConnectionSupervisor;
|
|
71
|
+
blobs: BlobCache;
|
|
72
|
+
persistence: PersistenceClient;
|
|
73
|
+
streamProcessor: StreamProcessorService;
|
|
74
|
+
migrator: LocalMigrator;
|
|
75
|
+
crdt: CrdtManager;
|
|
76
|
+
auth: AuthService<S>;
|
|
77
|
+
tabs: TabsCoordinator | null;
|
|
78
|
+
tabsUnsupportedReason: string | undefined;
|
|
79
|
+
tabId: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What the services need back from the runtime once it exists. */
|
|
83
|
+
export interface ServiceHost {
|
|
84
|
+
dispatch(event: RuntimeEvent): void;
|
|
85
|
+
/** Called by the tabs coordinator hooks with the hub / forwarder to relay through. */
|
|
86
|
+
setTabsChannel(channel: { hub: LeaderSyncHub | null; forwarder: SyncForwarder | null }): void;
|
|
87
|
+
/** Late-bound modules the boot saga starts (feature flags, app releases). */
|
|
88
|
+
initModules(): void;
|
|
89
|
+
detachWindow(): void;
|
|
90
|
+
setDetachWindow(fn: () => void): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createServices<S extends SchemaStructure>(config: Sp00kyConfig<S>): Services<S> {
|
|
94
|
+
const logger = createLogger(config.logLevel ?? 'info', config.otelTransmit);
|
|
95
|
+
if (config.crdt) void preloadLoro();
|
|
96
|
+
const tabsSupport = detectSharedTabsSupport(config);
|
|
97
|
+
const local = createLocalEngine(config.localEngine, config.database, logger, { shared: tabsSupport.supported });
|
|
98
|
+
const remote = new RemoteDatabaseService(config.database, logger);
|
|
99
|
+
const connectionSupervisor = new ConnectionSupervisor(remote, logger);
|
|
100
|
+
const rawBucket = (name: string) => new BucketHandle(name, remote, null, { blurhash: config.blurhash, logger });
|
|
101
|
+
const blobs = createBlobCache({
|
|
102
|
+
local,
|
|
103
|
+
namespace: ANON_USER_ID,
|
|
104
|
+
logger,
|
|
105
|
+
fetchRemote: async (key) => bucketContentToBlob(await rawBucket(key.bucket).get(key.path)),
|
|
106
|
+
headRemote: (key) => rawBucket(key.bucket).head(key.path),
|
|
107
|
+
maxBytes: config.blobCache?.maxBytes,
|
|
108
|
+
store: config.blobCache?.enabled === false ? new MemoryBlobStore(ANON_USER_ID) : undefined,
|
|
109
|
+
});
|
|
110
|
+
let persistence: PersistenceClient;
|
|
111
|
+
if (config.persistenceClient === 'surrealdb') persistence = new SurrealDBPersistenceClient(local, logger);
|
|
112
|
+
else if (config.persistenceClient === 'localstorage' || !config.persistenceClient) persistence = new LocalStoragePersistenceClient(logger);
|
|
113
|
+
else persistence = config.persistenceClient;
|
|
114
|
+
persistence = new ResilientPersistenceClient(persistence, logger);
|
|
115
|
+
const streamProcessor = new StreamProcessorService(new EventSystem(['stream_update']), local, logger);
|
|
116
|
+
streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === 'sqlite', config.circuitCheckpointMs);
|
|
117
|
+
streamProcessor.configureProjection(config.circuitProjection ?? true);
|
|
118
|
+
const migrator = new LocalMigrator(local, logger);
|
|
119
|
+
const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 500);
|
|
120
|
+
const auth = new AuthService(config.schema, remote, persistence, logger);
|
|
121
|
+
const tabId = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
|
|
122
|
+
return {
|
|
123
|
+
logger,
|
|
124
|
+
local,
|
|
125
|
+
remote,
|
|
126
|
+
connectionSupervisor,
|
|
127
|
+
blobs,
|
|
128
|
+
persistence,
|
|
129
|
+
streamProcessor,
|
|
130
|
+
migrator,
|
|
131
|
+
crdt,
|
|
132
|
+
auth,
|
|
133
|
+
tabs: null,
|
|
134
|
+
tabsUnsupportedReason: tabsSupport.supported ? undefined : (tabsSupport as { reason: string }).reason,
|
|
135
|
+
tabId,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The shared-tabs coordinator, wired so role changes and relays become runtime events. */
|
|
140
|
+
export function createTabsCoordinator<S extends SchemaStructure>(config: Sp00kyConfig<S>, s: Services<S>, host: ServiceHost): TabsCoordinator {
|
|
141
|
+
const engine = s.local as SqliteCacheEngine;
|
|
142
|
+
const hooks: CoordinatorHooks = {
|
|
143
|
+
adoptOwner: (bucketId, opts) =>
|
|
144
|
+
engine.adoptOwner(bucketId, { workerLockName: opts.workerLockName, allowMemoryFallback: opts.allowMemoryFallback, resumeHeld: opts.resumeHeld }),
|
|
145
|
+
adoptAttached: (dbPort, snapshot) => engine.adoptAttached(dbPort, snapshot, (reason) => engine.onLeaderLost(reason)),
|
|
146
|
+
releaseOwnership: () => engine.releaseOwnership(),
|
|
147
|
+
onLeaderLost: (reason) => engine.onLeaderLost(reason),
|
|
148
|
+
exposeClientPort: (clientId, port) => engine.exposeClientPort(clientId, port),
|
|
149
|
+
removeClientPort: (clientId) => engine.removeClientPort(clientId),
|
|
150
|
+
becomeSyncLeader: (hub) => {
|
|
151
|
+
s.streamProcessor.setPersistenceEnabled(true);
|
|
152
|
+
hub.onFollowerMessage = (_tabId, msg) => host.dispatch({ type: 'TabMessage', message: followerToRuntime(msg) });
|
|
153
|
+
host.setTabsChannel({ hub, forwarder: null });
|
|
154
|
+
host.dispatch({ type: 'TabRole', role: 'leader' });
|
|
155
|
+
},
|
|
156
|
+
resumeSyncLeaderDuties: async () => {
|
|
157
|
+
host.dispatch({ type: 'Drain' });
|
|
158
|
+
host.dispatch({ type: 'LiveStart' });
|
|
159
|
+
},
|
|
160
|
+
becomeSyncFollower: (forwarder) => {
|
|
161
|
+
s.streamProcessor.setPersistenceEnabled(false);
|
|
162
|
+
forwarder.onLeaderMessage = (msg) => host.dispatch({ type: 'TabMessage', message: leaderToRuntime(msg) });
|
|
163
|
+
host.setTabsChannel({ hub: null, forwarder });
|
|
164
|
+
host.dispatch({ type: 'TabRole', role: 'follower' });
|
|
165
|
+
},
|
|
166
|
+
becomeSyncSolo: () => {
|
|
167
|
+
s.streamProcessor.setPersistenceEnabled(true);
|
|
168
|
+
host.setTabsChannel({ hub: null, forwarder: null });
|
|
169
|
+
host.dispatch({ type: 'TabRole', role: 'solo' });
|
|
170
|
+
},
|
|
171
|
+
currentStorageHealth: () => s.local.storageHealth ?? { status: 'unknown', fallback: false },
|
|
172
|
+
};
|
|
173
|
+
return new TabsCoordinator({
|
|
174
|
+
tabId: s.tabId,
|
|
175
|
+
fingerprint: computeTabsFingerprint({
|
|
176
|
+
coreVersion: typeof __SP00KY_CORE_VERSION__ !== 'undefined' ? __SP00KY_CORE_VERSION__ : 'unknown',
|
|
177
|
+
schemaHash: hash53(config.schemaSurql),
|
|
178
|
+
endpoint: config.database.endpoint ?? '',
|
|
179
|
+
namespace: config.database.namespace,
|
|
180
|
+
database: config.database.database,
|
|
181
|
+
}),
|
|
182
|
+
hooks,
|
|
183
|
+
logger: s.logger,
|
|
184
|
+
onLeaderPageHide: () => void engine.shutdownOwnedWorker(),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Wire-protocol messages become the runtime's tab messages. */
|
|
189
|
+
export function followerToRuntime(msg: { type: string; [k: string]: unknown }): unknown {
|
|
190
|
+
switch (msg.type) {
|
|
191
|
+
case 'mutation-enqueued':
|
|
192
|
+
return { type: 'outbox-changed', mutationId: msg.mutationId };
|
|
193
|
+
case 'ingest':
|
|
194
|
+
return { type: 'ingest', records: msg.tuples };
|
|
195
|
+
case 'request-poll':
|
|
196
|
+
return { type: 'membership-dirty', hashes: [] };
|
|
197
|
+
default:
|
|
198
|
+
return msg;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function leaderToRuntime(msg: { type: string; [k: string]: unknown }): unknown {
|
|
203
|
+
switch (msg.type) {
|
|
204
|
+
case 'ingest-relay':
|
|
205
|
+
return { type: 'ingest', records: msg.tuples };
|
|
206
|
+
default:
|
|
207
|
+
return msg;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The hash a `_00_list_ref` edge belongs to: the id part of its `in` (`_00_query:<hash>`). */
|
|
212
|
+
export function hashOfEdge(value: unknown): string | null {
|
|
213
|
+
const inId = (value as { in?: unknown } | null)?.in;
|
|
214
|
+
if (!inId) return null;
|
|
215
|
+
const str = inId instanceof RecordId ? String(inId.id) : String(inId).replace(/^_00_query:/, '');
|
|
216
|
+
return str.length > 0 ? str : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Build the adapters the interpreter drives from the services. */
|
|
220
|
+
export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S>, s: Services<S>, host: ServiceHost, lateModules: () => { init(): void }[]): Adapters {
|
|
221
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
222
|
+
const services: ServiceCalls = {
|
|
223
|
+
'hint.read': readBootBucketHint,
|
|
224
|
+
'hint.write': writeBootBucketHint,
|
|
225
|
+
'local.connect': (bucketId) => s.local.connect(bucketId),
|
|
226
|
+
'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
|
|
227
|
+
'local.beginSwitch': () => s.local.beginSwitch(),
|
|
228
|
+
'local.currentBucketId': () => s.local.currentBucketId,
|
|
229
|
+
'local.usesSurqlSchema': () => s.local.usesSurqlSchema,
|
|
230
|
+
'migrator.provision': () => s.migrator.provision(config.schemaSurql),
|
|
231
|
+
'blobs.start': async (bucketId) => {
|
|
232
|
+
s.blobs.setMaxBytes(await resolveBlobBudget(config.blobCache?.maxBytes));
|
|
233
|
+
await s.blobs.start(bucketId);
|
|
234
|
+
},
|
|
235
|
+
'blobs.setNamespace': (bucketId) => s.blobs.setNamespace(bucketId),
|
|
236
|
+
'blobs.clear': () => s.blobs.clear(),
|
|
237
|
+
'ssp.init': () => s.streamProcessor.init(),
|
|
238
|
+
'ssp.setPermissions': () => s.streamProcessor.setPermissions(extractSelectPermissions(config.schemaSurql)),
|
|
239
|
+
'ssp.setSessionAuth': (authId, access) => s.streamProcessor.setSessionAuth(authId, access),
|
|
240
|
+
'ssp.prime': (pendingIds) =>
|
|
241
|
+
s.streamProcessor.primeFromLocal({
|
|
242
|
+
tables: [...config.schema.tables.map((t) => t.name), '_00_user_feature', '_00_app_release'],
|
|
243
|
+
schemaHash: String(hash53(config.schemaSurql)),
|
|
244
|
+
pendingIds: new Set(pendingIds),
|
|
245
|
+
onVersions: (_table, entries) => host.dispatch({ type: 'VersionsPrimed', entries }),
|
|
246
|
+
}),
|
|
247
|
+
'ssp.reset': () => s.streamProcessor.reset(),
|
|
248
|
+
'ssp.setPersistence': (enabled) => s.streamProcessor.setPersistenceEnabled(enabled),
|
|
249
|
+
'auth.restoreSession': () => s.auth.restoreSessionFromToken(),
|
|
250
|
+
'auth.init': () => s.auth.init(),
|
|
251
|
+
'auth.sessionAuthId': () => {
|
|
252
|
+
const id = s.auth.currentUser?.id;
|
|
253
|
+
if (!id) return null;
|
|
254
|
+
return typeof id === 'string' ? id : encodeRecordId(id);
|
|
255
|
+
},
|
|
256
|
+
'auth.access': () => s.auth.access,
|
|
257
|
+
'auth.token': () => s.auth.token,
|
|
258
|
+
'auth.currentUser': () => (s.auth.currentUser as Record<string, unknown> | null) ?? null,
|
|
259
|
+
'remote.connect': () => s.remote.connect(),
|
|
260
|
+
'remote.releaseViews': (ids) => {
|
|
261
|
+
const list = ids
|
|
262
|
+
.map((id) => String(id))
|
|
263
|
+
.filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id))
|
|
264
|
+
.join(', ');
|
|
265
|
+
if (list) s.remote.beaconSql(`FOR $id IN [${list}] { LET $_released = fn::query::unsubscribe($id); };`);
|
|
266
|
+
},
|
|
267
|
+
'supervisor.start': () => s.connectionSupervisor.start(),
|
|
268
|
+
'tabs.start': (bucketId) => {
|
|
269
|
+
if (!s.tabs) throw new Error('shared tabs unavailable');
|
|
270
|
+
return s.tabs.start(bucketId);
|
|
271
|
+
},
|
|
272
|
+
'tabs.moveToBucket': (bucketId) => {
|
|
273
|
+
if (!s.tabs) throw new Error('shared tabs unavailable');
|
|
274
|
+
return s.tabs.moveToBucket(bucketId);
|
|
275
|
+
},
|
|
276
|
+
'crdt.setSessionId': (id) => s.crdt.setSessionId(id),
|
|
277
|
+
'crdt.closeAll': (flush) => s.crdt.closeAll({ flush }),
|
|
278
|
+
'persistence.set': (key, value) => s.persistence.set(key, value),
|
|
279
|
+
'features.init': () => {
|
|
280
|
+
for (const m of lateModules()) m.init();
|
|
281
|
+
},
|
|
282
|
+
'releases.init': () => undefined,
|
|
283
|
+
'window.attach': () => {
|
|
284
|
+
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
|
|
285
|
+
const onPageHide = (event: PageTransitionEvent) => {
|
|
286
|
+
if (event.persisted) return;
|
|
287
|
+
host.dispatch({ type: 'PageHide' });
|
|
288
|
+
};
|
|
289
|
+
const onWake = () => {
|
|
290
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
|
291
|
+
host.dispatch({ type: 'HeartbeatNow' });
|
|
292
|
+
};
|
|
293
|
+
window.addEventListener('pagehide', onPageHide);
|
|
294
|
+
window.addEventListener('online', onWake);
|
|
295
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onWake);
|
|
296
|
+
host.setDetachWindow(() => {
|
|
297
|
+
window.removeEventListener('pagehide', onPageHide);
|
|
298
|
+
window.removeEventListener('online', onWake);
|
|
299
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onWake);
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
return {
|
|
304
|
+
local: s.local,
|
|
305
|
+
remote: {
|
|
306
|
+
queryResponses: (sql, vars) => s.remote.queryResponses(sql, vars),
|
|
307
|
+
live: async (table, onChange) => {
|
|
308
|
+
const [uuid] = await s.remote.query<[Uuid]>(`LIVE SELECT * FROM ${table}`);
|
|
309
|
+
const live = await s.remote.getClient().liveOf(uuid);
|
|
310
|
+
live.subscribe((message) => {
|
|
311
|
+
if (message.action === 'KILLED') return;
|
|
312
|
+
const hash = hashOfEdge(message.value);
|
|
313
|
+
if (hash) onChange([hash]);
|
|
314
|
+
});
|
|
315
|
+
return String(uuid);
|
|
316
|
+
},
|
|
317
|
+
kill: async (uuid) => {
|
|
318
|
+
await s.remote.query('KILL $u', { u: uuid });
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
ssp: {
|
|
322
|
+
registerQueryPlan: (plan) => s.streamProcessor.registerQueryPlan(plan) as StreamUpdate | undefined,
|
|
323
|
+
unregisterQueryPlan: (hash) => s.streamProcessor.unregisterQueryPlan(hash),
|
|
324
|
+
ingestMany: (records) => s.streamProcessor.ingestMany(records),
|
|
325
|
+
},
|
|
326
|
+
timers: {
|
|
327
|
+
set: (key, ms, fire) => {
|
|
328
|
+
const prev = timers.get(key);
|
|
329
|
+
if (prev) clearTimeout(prev);
|
|
330
|
+
timers.set(
|
|
331
|
+
key,
|
|
332
|
+
setTimeout(() => {
|
|
333
|
+
timers.delete(key);
|
|
334
|
+
fire();
|
|
335
|
+
}, ms)
|
|
336
|
+
);
|
|
337
|
+
},
|
|
338
|
+
clear: (key) => {
|
|
339
|
+
const prev = timers.get(key);
|
|
340
|
+
if (prev) clearTimeout(prev);
|
|
341
|
+
timers.delete(key);
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
clock: { now: () => Date.now() },
|
|
345
|
+
ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt },
|
|
346
|
+
hash: sha256Hex,
|
|
347
|
+
services,
|
|
348
|
+
};
|
|
349
|
+
}
|