@spooky-sync/core 0.0.1-canary.240 → 0.0.1-canary.241

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