@spooky-sync/core 0.0.1-canary.25 → 0.0.1-canary.255

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.
Files changed (228) hide show
  1. package/AGENTS.md +78 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2187 -80
  4. package/dist/index.js +12653 -2581
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +331 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +787 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +158 -0
  23. package/src/boot/bucket-switch.saga.ts +115 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +94 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +382 -0
  35. package/src/client/sp00ky-client.test.ts +264 -0
  36. package/src/client/sp00ky-client.ts +499 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +54 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +159 -0
  44. package/src/kernel/events.ts +55 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +148 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +2 -1
  53. package/src/modules/auth/index.ts +138 -25
  54. package/src/modules/crdt/crdt-field.ts +294 -0
  55. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  56. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  57. package/src/modules/crdt/index.ts +463 -0
  58. package/src/modules/crdt/loro-loader.ts +25 -0
  59. package/src/modules/devtools/flags.ts +349 -0
  60. package/src/modules/devtools/index.ts +459 -47
  61. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  62. package/src/modules/devtools/state-shape.test.ts +149 -0
  63. package/src/modules/devtools/storage-info.test.ts +79 -0
  64. package/src/modules/devtools/storage-info.ts +168 -0
  65. package/src/modules/devtools/versions.test.ts +74 -0
  66. package/src/modules/devtools/versions.ts +110 -0
  67. package/src/modules/feature-flag/index.test.ts +251 -0
  68. package/src/modules/feature-flag/index.ts +316 -0
  69. package/src/modules/ref-tables.test.ts +91 -0
  70. package/src/modules/ref-tables.ts +88 -0
  71. package/src/mutation/jobs.test.ts +25 -0
  72. package/src/mutation/jobs.ts +41 -0
  73. package/src/mutation/mutation-id.test.ts +37 -0
  74. package/src/mutation/mutation-id.ts +35 -0
  75. package/src/mutation/push.saga.test.ts +285 -0
  76. package/src/mutation/push.saga.ts +215 -0
  77. package/src/mutation/rows.test.ts +201 -0
  78. package/src/mutation/rows.ts +377 -0
  79. package/src/mutation/tray.saga.test.ts +63 -0
  80. package/src/mutation/tray.saga.ts +48 -0
  81. package/src/mutation/write.saga.test.ts +163 -0
  82. package/src/mutation/write.saga.ts +166 -0
  83. package/src/otel/index.ts +13 -10
  84. package/src/query/env.test.ts +23 -0
  85. package/src/query/env.ts +56 -0
  86. package/src/query/fetch.saga.test.ts +156 -0
  87. package/src/query/fetch.saga.ts +115 -0
  88. package/src/query/hash.test.ts +14 -0
  89. package/src/query/hash.ts +18 -0
  90. package/src/query/id-set-plan.test.ts +132 -0
  91. package/src/query/lifecycle.saga.test.ts +122 -0
  92. package/src/query/lifecycle.saga.ts +101 -0
  93. package/src/query/materialize.saga.test.ts +82 -0
  94. package/src/query/materialize.saga.ts +75 -0
  95. package/src/query/materialize.test.ts +50 -0
  96. package/src/query/materialize.ts +44 -0
  97. package/src/query/membership.saga.test.ts +246 -0
  98. package/src/query/membership.saga.ts +208 -0
  99. package/src/query/membership.test.ts +118 -0
  100. package/src/query/membership.ts +158 -0
  101. package/src/query/register.saga.test.ts +285 -0
  102. package/src/query/register.saga.ts +216 -0
  103. package/src/query/render-set.test.ts +36 -0
  104. package/src/query/render-set.ts +60 -0
  105. package/src/query/sql.test.ts +62 -0
  106. package/src/query/sql.ts +79 -0
  107. package/src/query/window-query.test.ts +70 -0
  108. package/src/query/window-query.ts +195 -0
  109. package/src/services/blobs/blob-cache.test.ts +359 -0
  110. package/src/services/blobs/blob-cache.ts +603 -0
  111. package/src/services/blobs/blob-manifest.ts +227 -0
  112. package/src/services/blobs/blob-store.test.ts +77 -0
  113. package/src/services/blobs/blob-store.ts +359 -0
  114. package/src/services/blobs/blob.fixture.ts +90 -0
  115. package/src/services/blobs/index.ts +70 -0
  116. package/src/services/database/cache-engine.ts +193 -0
  117. package/src/services/database/connection-supervisor.test.ts +318 -0
  118. package/src/services/database/connection-supervisor.ts +422 -0
  119. package/src/services/database/database.query-timeout.test.ts +140 -0
  120. package/src/services/database/database.responses.test.ts +43 -0
  121. package/src/services/database/database.ts +145 -62
  122. package/src/services/database/engine-factory.ts +33 -0
  123. package/src/services/database/errors.ts +34 -0
  124. package/src/services/database/events/index.ts +2 -1
  125. package/src/services/database/index.ts +7 -0
  126. package/src/services/database/local-migrator.ts +30 -27
  127. package/src/services/database/local.test.ts +64 -0
  128. package/src/services/database/local.ts +484 -67
  129. package/src/services/database/plan-render.test.ts +159 -0
  130. package/src/services/database/plan-render.ts +108 -0
  131. package/src/services/database/relation-resolver.test.ts +467 -0
  132. package/src/services/database/relation-resolver.ts +0 -0
  133. package/src/services/database/remote.ts +222 -15
  134. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  135. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  136. package/src/services/database/sqlite-cache-engine.ts +1349 -0
  137. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  138. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  139. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  140. package/src/services/database/sqlite-lock-verify.ts +45 -0
  141. package/src/services/database/sqlite-open.test.ts +150 -0
  142. package/src/services/database/sqlite-open.ts +164 -0
  143. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  144. package/src/services/database/sqlite-plan-sql.ts +153 -0
  145. package/src/services/database/sqlite-projection.test.ts +121 -0
  146. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  147. package/src/services/database/sqlite-select.test.ts +246 -0
  148. package/src/services/database/sqlite-select.ts +131 -0
  149. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  150. package/src/services/database/sqlite-transport.ts +224 -0
  151. package/src/services/database/sqlite-worker.ts +437 -0
  152. package/src/services/database/surql-translate.ts +416 -0
  153. package/src/services/database/surreal-cache-engine.ts +161 -0
  154. package/src/services/logger/index.ts +3 -2
  155. package/src/services/persistence/localstorage.ts +2 -2
  156. package/src/services/persistence/resilient.ts +11 -4
  157. package/src/services/persistence/surrealdb.ts +10 -10
  158. package/src/services/stream-processor/index.ts +796 -84
  159. package/src/services/stream-processor/permissions.test.ts +47 -0
  160. package/src/services/stream-processor/permissions.ts +53 -0
  161. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  162. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  163. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  164. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  165. package/src/services/stream-processor/wasm-types.ts +59 -3
  166. package/src/services/tabs/broker-client.ts +283 -0
  167. package/src/services/tabs/broker.test.ts +327 -0
  168. package/src/services/tabs/coordinator.test.ts +365 -0
  169. package/src/services/tabs/coordinator.ts +633 -0
  170. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  171. package/src/services/tabs/leader-locks.ts +75 -0
  172. package/src/services/tabs/protocol.ts +262 -0
  173. package/src/services/tabs/support.ts +36 -0
  174. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  175. package/src/sp00ky.ts +5 -0
  176. package/src/state/client-state.ts +184 -0
  177. package/src/state/lifecycle.test.ts +118 -0
  178. package/src/state/lifecycle.ts +86 -0
  179. package/src/state/reducers.test.ts +280 -0
  180. package/src/state/reducers.ts +476 -0
  181. package/src/state/selectors.test.ts +149 -0
  182. package/src/state/selectors.ts +184 -0
  183. package/src/sync/connection.saga.test.ts +86 -0
  184. package/src/sync/connection.saga.ts +85 -0
  185. package/src/sync/live.saga.test.ts +178 -0
  186. package/src/sync/live.saga.ts +107 -0
  187. package/src/sync/policy.health.test.ts +56 -0
  188. package/src/sync/policy.test.ts +659 -0
  189. package/src/sync/policy.ts +502 -0
  190. package/src/sync/poll.saga.test.ts +51 -0
  191. package/src/sync/poll.saga.ts +36 -0
  192. package/src/sync/tabs.saga.test.ts +74 -0
  193. package/src/sync/tabs.saga.ts +93 -0
  194. package/src/testing/adapters.ts +133 -0
  195. package/src/testing/build.ts +61 -0
  196. package/src/testing/fake-services.ts +34 -0
  197. package/src/testing/run-pure.test.ts +92 -0
  198. package/src/testing/run-pure.ts +128 -0
  199. package/src/testing/services.ts +17 -0
  200. package/src/types.ts +591 -14
  201. package/src/utils/blurhash.ts +90 -0
  202. package/src/utils/error-classification.test.ts +54 -0
  203. package/src/utils/error-classification.ts +25 -0
  204. package/src/utils/index.ts +79 -13
  205. package/src/utils/parser.test.ts +49 -120
  206. package/src/utils/parser.ts +32 -2
  207. package/src/utils/semver.test.ts +32 -0
  208. package/src/utils/semver.ts +30 -0
  209. package/src/utils/surql.ts +30 -18
  210. package/src/utils/utils.test.ts +2 -1
  211. package/src/utils/withRetry.test.ts +1 -1
  212. package/tsdown.config.ts +86 -1
  213. package/vitest.config.ts +48 -0
  214. package/src/modules/cache/index.ts +0 -241
  215. package/src/modules/cache/types.ts +0 -19
  216. package/src/modules/data/data.test.ts +0 -58
  217. package/src/modules/data/index.ts +0 -864
  218. package/src/modules/sync/engine.ts +0 -166
  219. package/src/modules/sync/events/index.ts +0 -77
  220. package/src/modules/sync/index.ts +0 -3
  221. package/src/modules/sync/queue/index.ts +0 -2
  222. package/src/modules/sync/queue/queue-down.ts +0 -89
  223. package/src/modules/sync/queue/queue-up.ts +0 -223
  224. package/src/modules/sync/scheduler.ts +0 -84
  225. package/src/modules/sync/sync.ts +0 -421
  226. package/src/modules/sync/utils.test.ts +0 -311
  227. package/src/modules/sync/utils.ts +0 -167
  228. package/src/spooky.ts +0 -395
@@ -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,382 @@
1
+ import { RecordId, type Uuid } from 'surrealdb';
2
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
3
+ import type { InlineRow, 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
+ /**
220
+ * The row an edge notification carries, when the subscription joined it on.
221
+ *
222
+ * Without `FETCH out` the edge's `out` is a record id and there is nothing to
223
+ * land, so this returns null and the caller falls back to fetching the body.
224
+ * It also returns null for a row the session may not read (the join yields
225
+ * `out: null`) and for an edge whose target has been deleted.
226
+ */
227
+ export function rowOfEdge(value: unknown): InlineRow | null {
228
+ const edge = value as { out?: unknown; version?: unknown } | null;
229
+ const out = edge?.out;
230
+ if (!out || typeof out !== 'object' || Array.isArray(out) || out instanceof RecordId) return null;
231
+ // Must be a real RecordId: the landing path keys off `row.id` being one, and
232
+ // would otherwise record the version for a body it never wrote.
233
+ const rid = (out as { id?: unknown }).id;
234
+ if (!(rid instanceof RecordId)) return null;
235
+ const id = encodeRecordId(rid);
236
+ const version = typeof edge?.version === 'number' ? edge.version : null;
237
+ if (version === null) return null;
238
+ return { id, version, record: out as Record<string, unknown> };
239
+ }
240
+
241
+ /** Build the adapters the interpreter drives from the services. */
242
+ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S>, s: Services<S>, host: ServiceHost, lateModules: () => { init(): void }[]): Adapters {
243
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
244
+ const services: ServiceCalls = {
245
+ 'hint.read': readBootBucketHint,
246
+ 'hint.write': writeBootBucketHint,
247
+ 'local.connect': (bucketId) => s.local.connect(bucketId),
248
+ 'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
249
+ 'local.beginSwitch': () => s.local.beginSwitch(),
250
+ 'local.currentBucketId': () => s.local.currentBucketId,
251
+ 'local.usesSurqlSchema': () => s.local.usesSurqlSchema,
252
+ 'migrator.provision': () => s.migrator.provision(config.schemaSurql),
253
+ 'blobs.start': async (bucketId) => {
254
+ s.blobs.setMaxBytes(await resolveBlobBudget(config.blobCache?.maxBytes));
255
+ await s.blobs.start(bucketId);
256
+ },
257
+ 'blobs.setNamespace': (bucketId) => s.blobs.setNamespace(bucketId),
258
+ 'blobs.clear': () => s.blobs.clear(),
259
+ 'ssp.init': () => s.streamProcessor.init(),
260
+ 'ssp.setPermissions': () => s.streamProcessor.setPermissions(extractSelectPermissions(config.schemaSurql)),
261
+ 'ssp.setSessionAuth': (authId, access) => s.streamProcessor.setSessionAuth(authId, access),
262
+ 'ssp.prime': (pendingIds) =>
263
+ s.streamProcessor.primeFromLocal({
264
+ tables: [...config.schema.tables.map((t) => t.name), '_00_user_feature', '_00_app_release'],
265
+ schemaHash: String(hash53(config.schemaSurql)),
266
+ pendingIds: new Set(pendingIds),
267
+ onVersions: (_table, entries) => host.dispatch({ type: 'VersionsPrimed', entries }),
268
+ }),
269
+ 'ssp.reset': () => s.streamProcessor.reset(),
270
+ 'ssp.setPersistence': (enabled) => s.streamProcessor.setPersistenceEnabled(enabled),
271
+ 'auth.restoreSession': () => s.auth.restoreSessionFromToken(),
272
+ 'auth.init': () => s.auth.init(),
273
+ 'auth.sessionAuthId': () => {
274
+ const id = s.auth.currentUser?.id;
275
+ if (!id) return null;
276
+ return typeof id === 'string' ? id : encodeRecordId(id);
277
+ },
278
+ 'auth.access': () => s.auth.access,
279
+ 'auth.token': () => s.auth.token,
280
+ 'auth.currentUser': () => (s.auth.currentUser as Record<string, unknown> | null) ?? null,
281
+ 'remote.connect': () => s.remote.connect(),
282
+ 'remote.releaseViews': (ids) => {
283
+ const list = ids
284
+ .map((id) => String(id))
285
+ .filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id))
286
+ .join(', ');
287
+ if (list) s.remote.beaconSql(`FOR $id IN [${list}] { LET $_released = fn::query::unsubscribe($id); };`);
288
+ },
289
+ 'supervisor.start': () => s.connectionSupervisor.start(),
290
+ 'tabs.start': (bucketId) => {
291
+ if (!s.tabs) throw new Error('shared tabs unavailable');
292
+ return s.tabs.start(bucketId);
293
+ },
294
+ 'tabs.moveToBucket': (bucketId) => {
295
+ if (!s.tabs) throw new Error('shared tabs unavailable');
296
+ return s.tabs.moveToBucket(bucketId);
297
+ },
298
+ 'crdt.setSessionId': (id) => s.crdt.setSessionId(id),
299
+ 'crdt.closeAll': (flush) => s.crdt.closeAll({ flush }),
300
+ 'persistence.set': (key, value) => s.persistence.set(key, value),
301
+ 'features.init': () => {
302
+ for (const m of lateModules()) m.init();
303
+ },
304
+ 'releases.init': () => undefined,
305
+ 'window.attach': () => {
306
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
307
+ const onPageHide = (event: PageTransitionEvent) => {
308
+ if (event.persisted) return;
309
+ host.dispatch({ type: 'PageHide' });
310
+ };
311
+ const onWake = () => {
312
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
313
+ host.dispatch({ type: 'HeartbeatNow' });
314
+ };
315
+ window.addEventListener('pagehide', onPageHide);
316
+ window.addEventListener('online', onWake);
317
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onWake);
318
+ host.setDetachWindow(() => {
319
+ window.removeEventListener('pagehide', onPageHide);
320
+ window.removeEventListener('online', onWake);
321
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onWake);
322
+ });
323
+ },
324
+ };
325
+ return {
326
+ local: s.local,
327
+ remote: {
328
+ queryResponses: (sql, vars) => s.remote.queryResponses(sql, vars),
329
+ live: async (table, onChange) => {
330
+ // `FETCH out` resolves the edge's target into the notification, so the
331
+ // body arrives with the doorbell instead of costing a second round
332
+ // trip. The clause is only ever added, never relied on: a server that
333
+ // ignores it, or a row the session cannot read, simply yields no
334
+ // inline row and the fetch path takes over.
335
+ const join = config.liveInlineBodies ? ' FETCH out' : '';
336
+ const [uuid] = await s.remote.query<[Uuid]>(`LIVE SELECT * FROM ${table}${join}`);
337
+ const live = await s.remote.getClient().liveOf(uuid);
338
+ live.subscribe((message) => {
339
+ if (message.action === 'KILLED') return;
340
+ const hash = hashOfEdge(message.value);
341
+ if (!hash) return;
342
+ // Not on DELETE: that edge is a row leaving the view, and for a row
343
+ // that was actually deleted the join yields nothing anyway. Landing
344
+ // a body there would only ever write back what is going away.
345
+ const row = message.action === 'DELETE' ? null : rowOfEdge(message.value);
346
+ onChange([hash], row ? [row] : undefined);
347
+ });
348
+ return String(uuid);
349
+ },
350
+ kill: async (uuid) => {
351
+ await s.remote.query('KILL $u', { u: uuid });
352
+ },
353
+ },
354
+ ssp: {
355
+ registerQueryPlan: (plan) => s.streamProcessor.registerQueryPlan(plan) as StreamUpdate | undefined,
356
+ unregisterQueryPlan: (hash) => s.streamProcessor.unregisterQueryPlan(hash),
357
+ ingestMany: (records) => s.streamProcessor.ingestMany(records),
358
+ },
359
+ timers: {
360
+ set: (key, ms, fire) => {
361
+ const prev = timers.get(key);
362
+ if (prev) clearTimeout(prev);
363
+ timers.set(
364
+ key,
365
+ setTimeout(() => {
366
+ timers.delete(key);
367
+ fire();
368
+ }, ms)
369
+ );
370
+ },
371
+ clear: (key) => {
372
+ const prev = timers.get(key);
373
+ if (prev) clearTimeout(prev);
374
+ timers.delete(key);
375
+ },
376
+ },
377
+ clock: { now: () => Date.now() },
378
+ ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt },
379
+ hash: sha256Hex,
380
+ services,
381
+ };
382
+ }