@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.
Files changed (292) hide show
  1. package/AGENTS.md +34 -15
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +1136 -1846
  4. package/dist/index.js +10168 -11267
  5. package/dist/sqlite-open.js +10 -1
  6. package/dist/types.d.ts +22 -79
  7. package/package.json +8 -4
  8. package/skills/sp00ky-core/SKILL.md +18 -0
  9. package/src/boot/auth-flip.saga.test.ts +82 -0
  10. package/src/boot/auth-flip.saga.ts +62 -0
  11. package/src/boot/boot.saga.test.ts +151 -0
  12. package/src/boot/boot.saga.ts +128 -0
  13. package/src/boot/bucket-switch.saga.test.ts +158 -0
  14. package/src/boot/bucket-switch.saga.ts +115 -0
  15. package/src/boot/preload.saga.test.ts +51 -0
  16. package/src/boot/preload.saga.ts +31 -0
  17. package/src/client/bucket-handle.test.ts +117 -0
  18. package/src/client/bucket-handle.ts +227 -0
  19. package/src/client/router.test.ts +56 -0
  20. package/src/client/router.ts +94 -0
  21. package/src/client/runtime.test.ts +192 -0
  22. package/src/client/runtime.ts +296 -0
  23. package/src/client/services.ts +349 -0
  24. package/src/client/sp00ky-client.test.ts +241 -0
  25. package/src/client/sp00ky-client.ts +482 -0
  26. package/src/index.ts +1 -0
  27. package/src/kernel/constants.test.ts +19 -0
  28. package/src/kernel/constants.ts +54 -0
  29. package/src/kernel/effects.test.ts +40 -0
  30. package/src/kernel/effects.ts +159 -0
  31. package/src/kernel/events.ts +55 -0
  32. package/src/kernel/interpreter.test.ts +116 -0
  33. package/src/kernel/interpreter.ts +143 -0
  34. package/src/kernel/saga.test.ts +89 -0
  35. package/src/kernel/saga.ts +73 -0
  36. package/src/modules/app-release/index.ts +13 -5
  37. package/src/modules/devtools/index.ts +10 -3
  38. package/src/modules/devtools/state-shape.test.ts +4 -1
  39. package/src/modules/feature-flag/index.ts +13 -5
  40. package/src/mutation/jobs.test.ts +25 -0
  41. package/src/mutation/jobs.ts +41 -0
  42. package/src/{modules/data → mutation}/mutation-id.test.ts +12 -0
  43. package/src/mutation/push.saga.test.ts +285 -0
  44. package/src/mutation/push.saga.ts +215 -0
  45. package/src/mutation/rows.test.ts +201 -0
  46. package/src/mutation/rows.ts +377 -0
  47. package/src/mutation/tray.saga.test.ts +63 -0
  48. package/src/mutation/tray.saga.ts +48 -0
  49. package/src/mutation/write.saga.test.ts +163 -0
  50. package/src/mutation/write.saga.ts +166 -0
  51. package/src/query/env.test.ts +23 -0
  52. package/src/query/env.ts +56 -0
  53. package/src/query/fetch.saga.test.ts +156 -0
  54. package/src/query/fetch.saga.ts +109 -0
  55. package/src/query/hash.test.ts +14 -0
  56. package/src/query/hash.ts +18 -0
  57. package/src/{modules/data → query}/id-set-plan.test.ts +12 -2
  58. package/src/query/lifecycle.saga.test.ts +122 -0
  59. package/src/query/lifecycle.saga.ts +101 -0
  60. package/src/query/materialize.saga.test.ts +82 -0
  61. package/src/query/materialize.saga.ts +75 -0
  62. package/src/query/materialize.test.ts +50 -0
  63. package/src/query/materialize.ts +44 -0
  64. package/src/query/membership.saga.test.ts +246 -0
  65. package/src/query/membership.saga.ts +208 -0
  66. package/src/query/membership.test.ts +118 -0
  67. package/src/query/membership.ts +158 -0
  68. package/src/query/register.saga.test.ts +251 -0
  69. package/src/query/register.saga.ts +190 -0
  70. package/src/query/render-set.test.ts +36 -0
  71. package/src/query/render-set.ts +60 -0
  72. package/src/query/sql.test.ts +62 -0
  73. package/src/query/sql.ts +79 -0
  74. package/src/{modules/data → query}/window-query.test.ts +18 -0
  75. package/src/{modules/data → query}/window-query.ts +2 -1
  76. package/src/services/database/connection-supervisor.test.ts +29 -0
  77. package/src/services/database/connection-supervisor.ts +7 -0
  78. package/src/services/database/database.responses.test.ts +43 -0
  79. package/src/services/database/database.ts +25 -10
  80. package/src/services/database/local-migrator.ts +2 -2
  81. package/src/services/database/plan-render.test.ts +1 -1
  82. package/src/services/database/sqlite-cache-engine.test.ts +4 -3
  83. package/src/services/database/sqlite-cache-engine.ts +2 -11
  84. package/src/services/database/sqlite-devtools-queries.integration.test.ts +1 -1
  85. package/src/services/database/sqlite-plan-sql.ts +16 -1
  86. package/src/services/database/sqlite-projection.test.ts +25 -3
  87. package/src/services/tabs/protocol.ts +7 -3
  88. package/src/sp00ky.ts +5 -1854
  89. package/src/state/client-state.ts +184 -0
  90. package/src/state/lifecycle.test.ts +118 -0
  91. package/src/state/lifecycle.ts +86 -0
  92. package/src/state/reducers.test.ts +280 -0
  93. package/src/state/reducers.ts +476 -0
  94. package/src/state/selectors.test.ts +149 -0
  95. package/src/state/selectors.ts +184 -0
  96. package/src/sync/connection.saga.test.ts +86 -0
  97. package/src/sync/connection.saga.ts +85 -0
  98. package/src/sync/live.saga.test.ts +69 -0
  99. package/src/sync/live.saga.ts +49 -0
  100. package/src/sync/policy.health.test.ts +56 -0
  101. package/src/{modules/sync/utils.test.ts → sync/policy.test.ts} +31 -3
  102. package/src/{modules/sync/utils.ts → sync/policy.ts} +67 -5
  103. package/src/sync/poll.saga.test.ts +51 -0
  104. package/src/sync/poll.saga.ts +36 -0
  105. package/src/sync/tabs.saga.test.ts +74 -0
  106. package/src/sync/tabs.saga.ts +92 -0
  107. package/src/testing/adapters.ts +133 -0
  108. package/src/testing/build.ts +61 -0
  109. package/src/testing/fake-services.ts +34 -0
  110. package/src/testing/run-pure.test.ts +92 -0
  111. package/src/testing/run-pure.ts +128 -0
  112. package/src/testing/services.ts +17 -0
  113. package/src/types.ts +8 -7
  114. package/src/utils/utils.test.ts +2 -1
  115. package/vitest.config.ts +48 -0
  116. package/coverage/lcov-report/base.css +0 -224
  117. package/coverage/lcov-report/block-navigation.js +0 -87
  118. package/coverage/lcov-report/favicon.png +0 -0
  119. package/coverage/lcov-report/index.html +0 -476
  120. package/coverage/lcov-report/prettify.css +0 -1
  121. package/coverage/lcov-report/prettify.js +0 -2
  122. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  123. package/coverage/lcov-report/sorter.js +0 -210
  124. package/coverage/lcov-report/src/boot/auth-flip.saga.ts.html +0 -271
  125. package/coverage/lcov-report/src/boot/boot.saga.ts.html +0 -469
  126. package/coverage/lcov-report/src/boot/bucket-switch.saga.ts.html +0 -430
  127. package/coverage/lcov-report/src/boot/index.html +0 -161
  128. package/coverage/lcov-report/src/boot/preload.saga.ts.html +0 -178
  129. package/coverage/lcov-report/src/client/bucket-handle.ts.html +0 -766
  130. package/coverage/lcov-report/src/client/index.html +0 -161
  131. package/coverage/lcov-report/src/client/router.ts.html +0 -367
  132. package/coverage/lcov-report/src/client/runtime.ts.html +0 -940
  133. package/coverage/lcov-report/src/client/sp00ky-client.ts.html +0 -1531
  134. package/coverage/lcov-report/src/events/index.html +0 -116
  135. package/coverage/lcov-report/src/events/index.ts.html +0 -877
  136. package/coverage/lcov-report/src/index.html +0 -146
  137. package/coverage/lcov-report/src/index.ts.html +0 -202
  138. package/coverage/lcov-report/src/kernel/constants.ts.html +0 -247
  139. package/coverage/lcov-report/src/kernel/effects.ts.html +0 -562
  140. package/coverage/lcov-report/src/kernel/events.ts.html +0 -250
  141. package/coverage/lcov-report/src/kernel/index.html +0 -176
  142. package/coverage/lcov-report/src/kernel/interpreter.ts.html +0 -514
  143. package/coverage/lcov-report/src/kernel/saga.ts.html +0 -304
  144. package/coverage/lcov-report/src/modules/app-release/index.html +0 -116
  145. package/coverage/lcov-report/src/modules/app-release/index.ts.html +0 -712
  146. package/coverage/lcov-report/src/modules/auth/events/index.html +0 -116
  147. package/coverage/lcov-report/src/modules/auth/events/index.ts.html +0 -142
  148. package/coverage/lcov-report/src/modules/auth/index.html +0 -116
  149. package/coverage/lcov-report/src/modules/auth/index.ts.html +0 -1225
  150. package/coverage/lcov-report/src/modules/crdt/crdt-field.ts.html +0 -967
  151. package/coverage/lcov-report/src/modules/crdt/index.html +0 -146
  152. package/coverage/lcov-report/src/modules/crdt/index.ts.html +0 -1474
  153. package/coverage/lcov-report/src/modules/crdt/loro-loader.ts.html +0 -160
  154. package/coverage/lcov-report/src/modules/devtools/flags.ts.html +0 -1132
  155. package/coverage/lcov-report/src/modules/devtools/index.html +0 -161
  156. package/coverage/lcov-report/src/modules/devtools/index.ts.html +0 -2413
  157. package/coverage/lcov-report/src/modules/devtools/storage-info.ts.html +0 -589
  158. package/coverage/lcov-report/src/modules/devtools/versions.ts.html +0 -415
  159. package/coverage/lcov-report/src/modules/feature-flag/index.html +0 -116
  160. package/coverage/lcov-report/src/modules/feature-flag/index.ts.html +0 -1033
  161. package/coverage/lcov-report/src/modules/index.html +0 -116
  162. package/coverage/lcov-report/src/modules/ref-tables.ts.html +0 -349
  163. package/coverage/lcov-report/src/mutation/index.html +0 -191
  164. package/coverage/lcov-report/src/mutation/jobs.ts.html +0 -208
  165. package/coverage/lcov-report/src/mutation/mutation-id.ts.html +0 -190
  166. package/coverage/lcov-report/src/mutation/push.saga.ts.html +0 -730
  167. package/coverage/lcov-report/src/mutation/rows.ts.html +0 -1216
  168. package/coverage/lcov-report/src/mutation/tray.saga.ts.html +0 -229
  169. package/coverage/lcov-report/src/mutation/write.saga.ts.html +0 -583
  170. package/coverage/lcov-report/src/otel/index.html +0 -116
  171. package/coverage/lcov-report/src/otel/index.ts.html +0 -466
  172. package/coverage/lcov-report/src/query/env.ts.html +0 -253
  173. package/coverage/lcov-report/src/query/fetch.saga.ts.html +0 -412
  174. package/coverage/lcov-report/src/query/hash.ts.html +0 -139
  175. package/coverage/lcov-report/src/query/index.html +0 -281
  176. package/coverage/lcov-report/src/query/lifecycle.saga.ts.html +0 -388
  177. package/coverage/lcov-report/src/query/materialize.saga.ts.html +0 -310
  178. package/coverage/lcov-report/src/query/materialize.ts.html +0 -217
  179. package/coverage/lcov-report/src/query/membership.saga.ts.html +0 -709
  180. package/coverage/lcov-report/src/query/membership.ts.html +0 -559
  181. package/coverage/lcov-report/src/query/register.saga.ts.html +0 -655
  182. package/coverage/lcov-report/src/query/render-set.ts.html +0 -265
  183. package/coverage/lcov-report/src/query/sql.ts.html +0 -322
  184. package/coverage/lcov-report/src/query/window-query.ts.html +0 -670
  185. package/coverage/lcov-report/src/services/blobs/blob-cache.ts.html +0 -1894
  186. package/coverage/lcov-report/src/services/blobs/blob-manifest.ts.html +0 -766
  187. package/coverage/lcov-report/src/services/blobs/blob-store.ts.html +0 -1162
  188. package/coverage/lcov-report/src/services/blobs/index.html +0 -161
  189. package/coverage/lcov-report/src/services/blobs/index.ts.html +0 -295
  190. package/coverage/lcov-report/src/services/database/cache-engine.ts.html +0 -664
  191. package/coverage/lcov-report/src/services/database/connection-supervisor.ts.html +0 -1330
  192. package/coverage/lcov-report/src/services/database/database.ts.html +0 -658
  193. package/coverage/lcov-report/src/services/database/engine-factory.ts.html +0 -184
  194. package/coverage/lcov-report/src/services/database/errors.ts.html +0 -187
  195. package/coverage/lcov-report/src/services/database/events/index.html +0 -116
  196. package/coverage/lcov-report/src/services/database/events/index.ts.html +0 -184
  197. package/coverage/lcov-report/src/services/database/index.html +0 -386
  198. package/coverage/lcov-report/src/services/database/index.ts.html +0 -121
  199. package/coverage/lcov-report/src/services/database/local-migrator.ts.html +0 -703
  200. package/coverage/lcov-report/src/services/database/local.ts.html +0 -1633
  201. package/coverage/lcov-report/src/services/database/plan-render.ts.html +0 -409
  202. package/coverage/lcov-report/src/services/database/relation-resolver.ts.html +0 -682
  203. package/coverage/lcov-report/src/services/database/remote.ts.html +0 -1036
  204. package/coverage/lcov-report/src/services/database/sqlite-cache-engine.ts.html +0 -4132
  205. package/coverage/lcov-report/src/services/database/sqlite-lock-verify.ts.html +0 -220
  206. package/coverage/lcov-report/src/services/database/sqlite-open.ts.html +0 -577
  207. package/coverage/lcov-report/src/services/database/sqlite-plan-sql.ts.html +0 -499
  208. package/coverage/lcov-report/src/services/database/sqlite-select.ts.html +0 -478
  209. package/coverage/lcov-report/src/services/database/sqlite-transport.ts.html +0 -757
  210. package/coverage/lcov-report/src/services/database/surql-translate.ts.html +0 -1333
  211. package/coverage/lcov-report/src/services/database/surreal-cache-engine.ts.html +0 -568
  212. package/coverage/lcov-report/src/services/logger/index.html +0 -116
  213. package/coverage/lcov-report/src/services/logger/index.ts.html +0 -154
  214. package/coverage/lcov-report/src/services/persistence/index.html +0 -146
  215. package/coverage/lcov-report/src/services/persistence/localstorage.ts.html +0 -163
  216. package/coverage/lcov-report/src/services/persistence/resilient.ts.html +0 -208
  217. package/coverage/lcov-report/src/services/persistence/surrealdb.ts.html +0 -271
  218. package/coverage/lcov-report/src/services/stream-processor/index.html +0 -146
  219. package/coverage/lcov-report/src/services/stream-processor/index.ts.html +0 -3313
  220. package/coverage/lcov-report/src/services/stream-processor/permissions.ts.html +0 -244
  221. package/coverage/lcov-report/src/services/stream-processor/wasm-types.ts.html +0 -346
  222. package/coverage/lcov-report/src/services/tabs/broker-client.ts.html +0 -934
  223. package/coverage/lcov-report/src/services/tabs/coordinator.ts.html +0 -1984
  224. package/coverage/lcov-report/src/services/tabs/index.html +0 -191
  225. package/coverage/lcov-report/src/services/tabs/leader-locks.ts.html +0 -310
  226. package/coverage/lcov-report/src/services/tabs/protocol.ts.html +0 -871
  227. package/coverage/lcov-report/src/services/tabs/support.ts.html +0 -193
  228. package/coverage/lcov-report/src/services/tabs/tabs-broker-worker.ts.html +0 -2005
  229. package/coverage/lcov-report/src/sp00ky.ts.html +0 -100
  230. package/coverage/lcov-report/src/state/client-state.ts.html +0 -637
  231. package/coverage/lcov-report/src/state/index.html +0 -161
  232. package/coverage/lcov-report/src/state/lifecycle.ts.html +0 -343
  233. package/coverage/lcov-report/src/state/reducers.ts.html +0 -1471
  234. package/coverage/lcov-report/src/state/selectors.ts.html +0 -637
  235. package/coverage/lcov-report/src/sync/connection.saga.ts.html +0 -343
  236. package/coverage/lcov-report/src/sync/index.html +0 -176
  237. package/coverage/lcov-report/src/sync/live.saga.ts.html +0 -232
  238. package/coverage/lcov-report/src/sync/policy.ts.html +0 -1606
  239. package/coverage/lcov-report/src/sync/poll.saga.ts.html +0 -193
  240. package/coverage/lcov-report/src/sync/tabs.saga.ts.html +0 -361
  241. package/coverage/lcov-report/src/types.ts.html +0 -2419
  242. package/coverage/lcov-report/src/utils/blurhash.ts.html +0 -355
  243. package/coverage/lcov-report/src/utils/error-classification.ts.html +0 -244
  244. package/coverage/lcov-report/src/utils/index.html +0 -191
  245. package/coverage/lcov-report/src/utils/index.ts.html +0 -820
  246. package/coverage/lcov-report/src/utils/parser.ts.html +0 -313
  247. package/coverage/lcov-report/src/utils/semver.ts.html +0 -175
  248. package/coverage/lcov-report/src/utils/surql.ts.html +0 -667
  249. package/coverage/lcov.info +0 -19079
  250. package/src/modules/cache/cache.relay.test.ts +0 -95
  251. package/src/modules/cache/index.ts +0 -361
  252. package/src/modules/cache/types.ts +0 -19
  253. package/src/modules/data/data.activity.test.ts +0 -109
  254. package/src/modules/data/data.authority.test.ts +0 -122
  255. package/src/modules/data/data.heartbeat-wake.test.ts +0 -115
  256. package/src/modules/data/data.hydration.test.ts +0 -142
  257. package/src/modules/data/data.membership.test.ts +0 -623
  258. package/src/modules/data/data.notify-table.test.ts +0 -41
  259. package/src/modules/data/data.pending-ids.test.ts +0 -199
  260. package/src/modules/data/data.rebind.test.ts +0 -170
  261. package/src/modules/data/data.rematerialize.test.ts +0 -114
  262. package/src/modules/data/data.run.test.ts +0 -113
  263. package/src/modules/data/data.settled-writes.test.ts +0 -235
  264. package/src/modules/data/data.status.test.ts +0 -249
  265. package/src/modules/data/data.test.ts +0 -58
  266. package/src/modules/data/index.ts +0 -2745
  267. package/src/modules/sync/engine.ts +0 -250
  268. package/src/modules/sync/events/index.ts +0 -84
  269. package/src/modules/sync/index.ts +0 -3
  270. package/src/modules/sync/queue/index.ts +0 -2
  271. package/src/modules/sync/queue/queue-down.test.ts +0 -228
  272. package/src/modules/sync/queue/queue-down.ts +0 -184
  273. package/src/modules/sync/queue/queue-up.forwarded.test.ts +0 -164
  274. package/src/modules/sync/queue/queue-up.ts +0 -407
  275. package/src/modules/sync/scheduler.pause.test.ts +0 -109
  276. package/src/modules/sync/scheduler.retry.test.ts +0 -237
  277. package/src/modules/sync/scheduler.ts +0 -286
  278. package/src/modules/sync/sync.cleanup.test.ts +0 -116
  279. package/src/modules/sync/sync.health.test.ts +0 -167
  280. package/src/modules/sync/sync.heartbeat.test.ts +0 -80
  281. package/src/modules/sync/sync.list-ref-meta.test.ts +0 -193
  282. package/src/modules/sync/sync.live-removal.test.ts +0 -289
  283. package/src/modules/sync/sync.reconnect.test.ts +0 -145
  284. package/src/modules/sync/sync.rematerialize-after-fetch.test.ts +0 -93
  285. package/src/modules/sync/sync.settled-poll.test.ts +0 -58
  286. package/src/modules/sync/sync.subquery.test.ts +0 -82
  287. package/src/modules/sync/sync.tabs.test.ts +0 -249
  288. package/src/modules/sync/sync.ts +0 -2626
  289. package/src/sp00ky.auth-order.test.ts +0 -112
  290. package/src/sp00ky.init-query.test.ts +0 -183
  291. package/src/sp00ky.local-first.test.ts +0 -60
  292. /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
+ }