@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331

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 (266) hide show
  1. package/AGENTS.md +84 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2080 -142
  4. package/dist/index.js +16009 -2480
  5. package/dist/live.d.ts +149 -0
  6. package/dist/live.js +739 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +373 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +392 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +477 -0
  18. package/dist/types.d.ts +1857 -38
  19. package/dist/worker-edges.js +480 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +48 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +28 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +173 -0
  27. package/skills/sp00ky-core/SKILL.md +32 -7
  28. package/skills/sp00ky-core/references/config.md +7 -1
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +203 -0
  32. package/src/boot/boot.saga.ts +146 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +80 -0
  36. package/src/boot/preload.saga.ts +40 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +195 -0
  40. package/src/client/bucket-handle.ts +352 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +74 -0
  46. package/src/client/services.ts +434 -0
  47. package/src/client/sp00ky-client.test.ts +341 -0
  48. package/src/client/sp00ky-client.ts +625 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +167 -0
  56. package/src/kernel/events.ts +57 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +512 -0
  62. package/src/live/index.ts +1059 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +228 -0
  66. package/src/modules/auth/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +476 -22
  72. package/src/modules/crdt/acceptance.ts +75 -0
  73. package/src/modules/crdt/bytes.ts +50 -0
  74. package/src/modules/crdt/crdt-field.ts +125 -0
  75. package/src/modules/crdt/crdt.test.ts +469 -0
  76. package/src/modules/crdt/hash.ts +103 -0
  77. package/src/modules/crdt/index.ts +757 -0
  78. package/src/modules/crdt/loro-loader.ts +25 -0
  79. package/src/modules/crdt/mirror.ts +37 -0
  80. package/src/modules/devtools/flags.ts +349 -0
  81. package/src/modules/devtools/index.ts +746 -95
  82. package/src/modules/devtools/mutations.test.ts +92 -0
  83. package/src/modules/devtools/mutations.ts +160 -0
  84. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  85. package/src/modules/devtools/state-shape.test.ts +263 -0
  86. package/src/modules/devtools/storage-info.test.ts +79 -0
  87. package/src/modules/devtools/storage-info.ts +168 -0
  88. package/src/modules/devtools/versions.test.ts +74 -0
  89. package/src/modules/devtools/versions.ts +110 -0
  90. package/src/modules/feature-flag/index.test.ts +298 -0
  91. package/src/modules/feature-flag/index.ts +316 -0
  92. package/src/modules/ref-tables.test.ts +93 -0
  93. package/src/modules/ref-tables.ts +91 -0
  94. package/src/modules/web-push/index.test.ts +824 -0
  95. package/src/modules/web-push/index.ts +1027 -0
  96. package/src/mutation/jobs.test.ts +25 -0
  97. package/src/mutation/jobs.ts +41 -0
  98. package/src/mutation/mutation-id.test.ts +37 -0
  99. package/src/mutation/mutation-id.ts +35 -0
  100. package/src/mutation/pending-writes.integration.test.ts +175 -0
  101. package/src/mutation/push.saga.test.ts +301 -0
  102. package/src/mutation/push.saga.ts +225 -0
  103. package/src/mutation/rows.test.ts +231 -0
  104. package/src/mutation/rows.ts +427 -0
  105. package/src/mutation/tray.saga.test.ts +63 -0
  106. package/src/mutation/tray.saga.ts +48 -0
  107. package/src/mutation/write.saga.test.ts +316 -0
  108. package/src/mutation/write.saga.ts +268 -0
  109. package/src/otel/index.ts +10 -7
  110. package/src/pure.ts +66 -0
  111. package/src/push/types.ts +174 -0
  112. package/src/query/anon-query-isolation.integration.test.ts +527 -0
  113. package/src/query/env.test.ts +23 -0
  114. package/src/query/env.ts +65 -0
  115. package/src/query/fetch.saga.test.ts +199 -0
  116. package/src/query/fetch.saga.ts +120 -0
  117. package/src/query/hash.test.ts +14 -0
  118. package/src/query/hash.ts +18 -0
  119. package/src/query/id-set-plan.test.ts +132 -0
  120. package/src/query/lifecycle.saga.test.ts +274 -0
  121. package/src/query/lifecycle.saga.ts +186 -0
  122. package/src/query/list-ref-live.integration.test.ts +203 -0
  123. package/src/query/materialize.saga.test.ts +115 -0
  124. package/src/query/materialize.saga.ts +84 -0
  125. package/src/query/materialize.test.ts +58 -0
  126. package/src/query/materialize.ts +57 -0
  127. package/src/query/membership.saga.test.ts +407 -0
  128. package/src/query/membership.saga.ts +282 -0
  129. package/src/query/membership.test.ts +118 -0
  130. package/src/query/membership.ts +201 -0
  131. package/src/query/register.saga.test.ts +311 -0
  132. package/src/query/register.saga.ts +235 -0
  133. package/src/query/render-set.test.ts +47 -0
  134. package/src/query/render-set.ts +71 -0
  135. package/src/query/sql.test.ts +118 -0
  136. package/src/query/sql.ts +157 -0
  137. package/src/query/window-query.test.ts +70 -0
  138. package/src/query/window-query.ts +208 -0
  139. package/src/services/blobs/blob-cache.test.ts +359 -0
  140. package/src/services/blobs/blob-cache.ts +603 -0
  141. package/src/services/blobs/blob-manifest.ts +227 -0
  142. package/src/services/blobs/blob-store.test.ts +77 -0
  143. package/src/services/blobs/blob-store.ts +359 -0
  144. package/src/services/blobs/blob.fixture.ts +90 -0
  145. package/src/services/blobs/index.ts +70 -0
  146. package/src/services/database/cache-engine.ts +209 -0
  147. package/src/services/database/connection-supervisor.test.ts +318 -0
  148. package/src/services/database/connection-supervisor.ts +422 -0
  149. package/src/services/database/database.query-timeout.test.ts +140 -0
  150. package/src/services/database/database.responses.test.ts +43 -0
  151. package/src/services/database/database.ts +156 -61
  152. package/src/services/database/engine-factory.ts +33 -0
  153. package/src/services/database/errors.ts +34 -0
  154. package/src/services/database/events/index.ts +2 -1
  155. package/src/services/database/index.ts +7 -0
  156. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  157. package/src/services/database/local-migrator.integration.test.ts +110 -0
  158. package/src/services/database/local-migrator.ts +61 -16
  159. package/src/services/database/local.test.ts +150 -0
  160. package/src/services/database/local.ts +508 -63
  161. package/src/services/database/plan-render.test.ts +159 -0
  162. package/src/services/database/plan-render.ts +108 -0
  163. package/src/services/database/relation-resolver.test.ts +484 -0
  164. package/src/services/database/relation-resolver.ts +0 -0
  165. package/src/services/database/remote.ts +285 -6
  166. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  167. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  168. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  169. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  170. package/src/services/database/sqlite-column-reset.ts +91 -0
  171. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  172. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  173. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  174. package/src/services/database/sqlite-lock-verify.ts +45 -0
  175. package/src/services/database/sqlite-open.test.ts +150 -0
  176. package/src/services/database/sqlite-open.ts +164 -0
  177. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  178. package/src/services/database/sqlite-plan-sql.ts +196 -0
  179. package/src/services/database/sqlite-projection.test.ts +121 -0
  180. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  181. package/src/services/database/sqlite-select.test.ts +261 -0
  182. package/src/services/database/sqlite-select.ts +131 -0
  183. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  184. package/src/services/database/sqlite-transport.ts +224 -0
  185. package/src/services/database/sqlite-worker.ts +437 -0
  186. package/src/services/database/surql-translate.ts +417 -0
  187. package/src/services/database/surreal-cache-engine.ts +163 -0
  188. package/src/services/logger/index.test.ts +132 -0
  189. package/src/services/logger/index.ts +189 -7
  190. package/src/services/persistence/localstorage.ts +2 -2
  191. package/src/services/persistence/resilient.ts +10 -3
  192. package/src/services/persistence/surrealdb.ts +4 -4
  193. package/src/services/stream-processor/index.ts +806 -67
  194. package/src/services/stream-processor/permissions.test.ts +47 -0
  195. package/src/services/stream-processor/permissions.ts +53 -0
  196. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  197. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  198. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  199. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  200. package/src/services/stream-processor/wasm-types.ts +58 -2
  201. package/src/services/tabs/broker-client.ts +284 -0
  202. package/src/services/tabs/broker.test.ts +381 -0
  203. package/src/services/tabs/coordinator.test.ts +386 -0
  204. package/src/services/tabs/coordinator.ts +646 -0
  205. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  206. package/src/services/tabs/leader-locks.ts +75 -0
  207. package/src/services/tabs/protocol.ts +262 -0
  208. package/src/services/tabs/support.ts +36 -0
  209. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  210. package/src/sp00ky.ts +5 -395
  211. package/src/state/client-state.ts +208 -0
  212. package/src/state/lifecycle.test.ts +141 -0
  213. package/src/state/lifecycle.ts +99 -0
  214. package/src/state/reducers.test.ts +306 -0
  215. package/src/state/reducers.ts +531 -0
  216. package/src/state/selectors.test.ts +188 -0
  217. package/src/state/selectors.ts +258 -0
  218. package/src/sw/index.test.ts +582 -0
  219. package/src/sw/index.ts +607 -0
  220. package/src/sw/store.ts +96 -0
  221. package/src/sync/connection.saga.test.ts +97 -0
  222. package/src/sync/connection.saga.ts +89 -0
  223. package/src/sync/live.saga.test.ts +178 -0
  224. package/src/sync/live.saga.ts +107 -0
  225. package/src/sync/policy.health.test.ts +56 -0
  226. package/src/sync/policy.test.ts +663 -0
  227. package/src/sync/policy.ts +515 -0
  228. package/src/sync/poll.saga.test.ts +51 -0
  229. package/src/sync/poll.saga.ts +36 -0
  230. package/src/sync/tabs.saga.test.ts +78 -0
  231. package/src/sync/tabs.saga.ts +96 -0
  232. package/src/testing/adapters.ts +137 -0
  233. package/src/testing/build.ts +63 -0
  234. package/src/testing/fake-services.ts +63 -0
  235. package/src/testing/run-pure.test.ts +92 -0
  236. package/src/testing/run-pure.ts +128 -0
  237. package/src/testing/services.ts +17 -0
  238. package/src/types.ts +633 -10
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +39 -0
  241. package/src/utils/error-classification.test.ts +54 -0
  242. package/src/utils/error-classification.ts +25 -0
  243. package/src/utils/index.ts +89 -12
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +64 -2
  246. package/src/utils/semver.test.ts +32 -0
  247. package/src/utils/semver.ts +30 -0
  248. package/src/utils/sha256.ts +15 -0
  249. package/src/utils/surql.ts +29 -18
  250. package/src/utils/utils.test.ts +3 -2
  251. package/tsdown.config.ts +117 -3
  252. package/vitest.config.ts +48 -0
  253. package/src/modules/cache/index.ts +0 -241
  254. package/src/modules/cache/types.ts +0 -19
  255. package/src/modules/data/data.test.ts +0 -58
  256. package/src/modules/data/index.ts +0 -879
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -77
  259. package/src/modules/sync/index.ts +0 -3
  260. package/src/modules/sync/queue/index.ts +0 -2
  261. package/src/modules/sync/queue/queue-down.ts +0 -89
  262. package/src/modules/sync/queue/queue-up.ts +0 -223
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -421
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,625 @@
1
+ import type { Surreal } from 'surrealdb';
2
+ import type {
3
+ BackendNames,
4
+ BackendRoutes,
5
+ FinalQuery,
6
+ GetTable,
7
+ InnerQuery,
8
+ QueryOptions,
9
+ RoutePayload,
10
+ SchemaStructure,
11
+ TableModel,
12
+ TableNames,
13
+ BucketNames,
14
+ } from '@spooky-sync/query-builder';
15
+ import { QueryBuilder } from '@spooky-sync/query-builder';
16
+ import type {
17
+ PreloadOptions,
18
+ QueryAuthorityCallback,
19
+ QueryStatusCallback,
20
+ QueryTimeToLive,
21
+ RunOptions,
22
+ Sp00kyConfig,
23
+ Sp00kyQueryResultPromise,
24
+ StorageHealth,
25
+ SyncHealth,
26
+ UpdateOptions,
27
+ } from '../types';
28
+ import type { TabRole } from '../services/tabs/protocol';
29
+ import { StreamProcessorService } from '../services/stream-processor/index';
30
+ import type { StreamUpdateReceiver } from '../services/stream-processor/index';
31
+ import { AuthService } from '../modules/auth/index';
32
+ import { CrdtField } from '../modules/crdt/index';
33
+ import { FeatureFlagModule, FeatureFlagHandle } from '../modules/feature-flag/index';
34
+ import type { FeatureFlagOptions, FeatureFlagOverride } from '../modules/feature-flag/index';
35
+ import { AppReleaseModule, AppReleaseHandle } from '../modules/app-release/index';
36
+ import type { AppReleaseOptions } from '../modules/app-release/index';
37
+ import { WebPushModule } from '../modules/web-push/index';
38
+ import { DevToolsService } from '../modules/devtools/index';
39
+ import type { DevToolsQuerySource } from '../modules/devtools/index';
40
+ import { parseQueryParams, generateId } from '../utils/index';
41
+ import type { OutEvent, RuntimeEvent } from '../kernel/events';
42
+ import type { ClientState } from '../state/client-state';
43
+ import * as R from '../state/reducers';
44
+ import { pendingMutationCount, fetchingQueryCount, phaseTimings, toQueryState } from '../state/selectors';
45
+ import { defaultEnv, type SagaEnv } from '../query/env';
46
+ import { registerLocal, type RegisterInput } from '../query/register.saga';
47
+ import { evictQuery } from '../query/lifecycle.saga';
48
+ import { write } from '../mutation/write.saga';
49
+ import { discardFailed, listFailed, retryFailed } from '../mutation/tray.saga';
50
+ import type { FailedMutationRow } from '../mutation/rows';
51
+ import { buildJobRecord } from '../mutation/jobs';
52
+ import { boot } from '../boot/boot.saga';
53
+ import { preload } from '../boot/preload.saga';
54
+ import { DEGRADE_AFTER_FAILURES, OUTBOX_BATCH_SIZE, PUSH_TIMEOUT_MS } from '../kernel/constants';
55
+ import { Runtime } from './runtime';
56
+ import { createAdapters, createServices, createTabsCoordinator, type ServiceHost, type Services } from './services';
57
+ import { BucketHandle, bucketSettings } from './bucket-handle';
58
+ import { ImpersonationBanner } from '../modules/auth/impersonation-banner';
59
+ import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
60
+
61
+ const UNKNOWN_STORAGE_HEALTH: StorageHealth = Object.freeze({ status: 'unknown', fallback: false });
62
+ /** Longest SurrealQL a statement log line shows as its message. */
63
+ const STATEMENT_MSG_MAX = 160;
64
+
65
+ export interface Sp00kyClientDeps<S extends SchemaStructure> {
66
+ services?: Services<S>;
67
+ runtime?: Runtime;
68
+ env?: Partial<SagaEnv>;
69
+ }
70
+
71
+ /**
72
+ * The public client. Every method is a thin translation into a saga run, a
73
+ * state read, or a subscription on the runtime; the engine's behaviour lives
74
+ * in the sagas.
75
+ */
76
+ export class Sp00kyClient<S extends SchemaStructure> {
77
+ public readonly auth: AuthService<S>;
78
+ public readonly streamProcessor: StreamProcessorService;
79
+ /** Web Push for this browser: subscribe, devices, self-pushes, service worker bridge. */
80
+ public readonly webPush: WebPushModule;
81
+
82
+ private readonly services: Services<S>;
83
+ private readonly env: SagaEnv;
84
+ private readonly runtime: Runtime;
85
+ private readonly featureFlags: FeatureFlagModule<S>;
86
+ private readonly appReleases: AppReleaseModule<S>;
87
+ /** Teardown for subscriptions the client itself owns. */
88
+ private readonly disposers: Array<() => void> = [];
89
+ /** Assigned in `wireAdapters`, which the constructor always runs. */
90
+ private impersonationBanner!: ImpersonationBanner;
91
+ private readonly devTools: DevToolsService;
92
+ private hub: LeaderSyncHub | null = null;
93
+ private forwarder: SyncForwarder | null = null;
94
+ private detachWindow: (() => void) | null = null;
95
+ private sharedActive = false;
96
+
97
+ constructor(
98
+ private readonly config: Sp00kyConfig<S>,
99
+ deps: Sp00kyClientDeps<S> = {}
100
+ ) {
101
+ this.services = deps.services ?? createServices(config);
102
+ const s = this.services;
103
+ this.env = defaultEnv(config.schema, {
104
+ anonLive: config.enableAnonymousLiveQueries === true,
105
+ remoteTimeoutMs: Math.max(0, config.database.queryTimeoutMs ?? 60_000),
106
+ pushTimeoutMs: config.pushTimeoutMs ?? PUSH_TIMEOUT_MS,
107
+ outboxBatchSize: OUTBOX_BATCH_SIZE,
108
+ degradeAfter: config.syncHealth === false ? 0 : (config.syncHealth?.degradeAfterConsecutiveFailures ?? DEGRADE_AFTER_FAILURES),
109
+ materializeDebounceMs: config.streamDebounceTime ?? 50,
110
+ pollBaseMs: config.refSyncIntervalMs && config.refSyncIntervalMs > 0 ? config.refSyncIntervalMs : 500,
111
+ ...deps.env,
112
+ });
113
+ const host: ServiceHost = {
114
+ dispatch: (event) => void this.runtime.dispatch(event),
115
+ setTabsChannel: ({ hub, forwarder }) => {
116
+ this.hub = hub;
117
+ this.forwarder = forwarder;
118
+ this.sharedActive = hub !== null || forwarder !== null;
119
+ },
120
+ initModules: () => undefined,
121
+ detachWindow: () => this.detachWindow?.(),
122
+ setDetachWindow: (fn) => (this.detachWindow = fn),
123
+ };
124
+ if (!deps.runtime && s.tabs === null && s.tabsUnsupportedReason === undefined) {
125
+ s.tabs = createTabsCoordinator(config, s, host);
126
+ }
127
+ this.runtime =
128
+ deps.runtime ??
129
+ new Runtime({
130
+ env: this.env,
131
+ adapters: createAdapters(config, s, host, () => [this.featureFlags, this.appReleases]),
132
+ logger: s.logger,
133
+ tabId: s.tabId,
134
+ });
135
+ this.auth = s.auth;
136
+ this.streamProcessor = s.streamProcessor;
137
+
138
+ const liveQueryPort = {
139
+ query: (table: string, surql: string, params: Record<string, unknown>, ttl: QueryTimeToLive) =>
140
+ this.runtime.run(registerLocal(this.env, { tableName: table, surql, params, ttl })),
141
+ subscribe: (hash: string, cb: (records: Record<string, any>[]) => void, options?: { immediate?: boolean }) =>
142
+ this.runtime.subscribe(hash, cb, options),
143
+ };
144
+ const remoteRegisterPort = { enqueueDownEvent: () => void this.runtime.dispatch({ type: 'EnsureRegistered' }) };
145
+ // CRDT edit logs ride ordinary queries and the ordinary outbox.
146
+ s.crdt.attach({
147
+ watch: async (table, where, onRows) => {
148
+ const { hash } = await (this.query as any)(table, {}).where(where).build().run();
149
+ const stopRows = this.runtime.subscribe(hash, onRows, { immediate: true });
150
+ let known = false;
151
+ let idle = false;
152
+ let done!: () => void;
153
+ const settled = new Promise<void>((resolve) => (done = resolve));
154
+ const check = () => known && idle && done();
155
+ const stopAuthority = this.runtime.subscribeAuthority(hash, (k) => ((known = k), check()), { immediate: true });
156
+ const stopStatus = this.runtime.subscribeStatus(hash, (st) => ((idle = st === 'idle'), check()), { immediate: true });
157
+ return {
158
+ stop: () => {
159
+ stopRows();
160
+ stopAuthority();
161
+ stopStatus();
162
+ },
163
+ settled,
164
+ };
165
+ },
166
+ create: (id, data) => this.create(id, data),
167
+ });
168
+ this.featureFlags = new FeatureFlagModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
169
+ this.appReleases = new AppReleaseModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
170
+ this.webPush = new WebPushModule({
171
+ remote: s.remote,
172
+ auth: s.auth,
173
+ persistence: s.persistence,
174
+ logger: s.logger,
175
+ database: config.database,
176
+ connection: s.connectionSupervisor,
177
+ config: config.webPush,
178
+ });
179
+ this.disposers.push(this.webPush.attach());
180
+
181
+ const querySource: DevToolsQuerySource = {
182
+ getActiveQueries: () => [...this.runtime.state.queries.values()].map(toQueryState),
183
+ getQueryById: (id) => {
184
+ const entry = [...this.runtime.state.queries.values()].find((e) => String(e.def.id) === String(id));
185
+ return entry ? toQueryState(entry) : undefined;
186
+ },
187
+ phaseTimings: (q) => {
188
+ const entry = this.runtime.state.queries.get(q.config.id.id as string) ?? [...this.runtime.state.queries.values()].find((e) => String(e.def.id) === String(q.config.id));
189
+ return entry ? phaseTimings(entry) : ({} as never);
190
+ },
191
+ };
192
+ this.devTools = new DevToolsService(s.local, s.remote, s.logger, config.schema, s.auth, querySource);
193
+ this.devTools.setFeatureFlagOverrides(this.featureFlags);
194
+ s.streamProcessor.addReceiver(this.devTools);
195
+ this.devTools.setBlobInfoProvider(() => s.blobs.stats());
196
+ this.devTools.setMutationSource({
197
+ state: () => this.runtime.state,
198
+ listFailed: () => this.listFailedMutations(),
199
+ retryFailed: (id) => this.retryFailedMutation(id),
200
+ discardFailed: (id) => this.discardFailedMutation(id),
201
+ });
202
+ if (s.logTap) this.devTools.setLogTap(s.logTap);
203
+ if (config.sharedTabs) {
204
+ this.devTools.setTabsInfoProvider(() => {
205
+ const c = s.tabs;
206
+ if (!this.sharedActive || !c) return { active: false, reason: s.tabsUnsupportedReason ?? 'fell-back' };
207
+ return {
208
+ active: true,
209
+ role: c.role,
210
+ tabId: c.tabId,
211
+ leadershipId: c.leadershipId,
212
+ leaderTabId: c.role === 'leader' ? c.tabId : c.leaderTabId,
213
+ ...(this.hub ? { followers: this.hub.followerCount, relayedBatches: this.hub.relayedBatches } : {}),
214
+ };
215
+ });
216
+ }
217
+ this.wireAdapters();
218
+ }
219
+
220
+ /** Adapter callbacks become runtime events; runtime events reach the legacy services. */
221
+ private wireAdapters(): void {
222
+ const s = this.services;
223
+ const receiver: StreamUpdateReceiver = { onStreamUpdate: (update) => void this.runtime.dispatch({ type: 'StreamUpdate', update }) };
224
+ s.streamProcessor.addReceiver(receiver);
225
+ s.connectionSupervisor.subscribe((state) => void this.runtime.dispatch({ type: 'ConnectionChanged', state }));
226
+ // Changes only. The immediate answer here is always "signed out" (nothing
227
+ // is restored before `init()`), and boot owns the first identity: it opens
228
+ // the bucket the hint names and announces the restored session itself. As
229
+ // a flip, that answer switched to the anon bucket while boot was still
230
+ // opening the user's, so two clients provisioned at once, boot failed, and
231
+ // a persistent store never got past the provider's fallback on reload.
232
+ s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }), { immediate: false });
233
+ // Whoever is looking at the page should be able to tell, and leave. The
234
+ // app can restyle this, replace it (`mode: 'custom'`, with a fallback if
235
+ // its own never renders) or take the job over entirely (`mode: 'none'`).
236
+ this.impersonationBanner = new ImpersonationBanner(
237
+ () => s.auth.stopImpersonating(),
238
+ this.config.impersonationBanner
239
+ );
240
+ const banner = this.impersonationBanner;
241
+ this.disposers.push(
242
+ s.auth.subscribeImpersonation((info) => banner.update(info)),
243
+ () => banner.unmount()
244
+ );
245
+ this.runtime.on('tabs:broadcast', (e) => {
246
+ if (e.type !== 'tabs:broadcast') return;
247
+ const msg = e.message as { type: string; records?: unknown[]; mutationId?: string };
248
+ if (this.hub) {
249
+ if (msg.type === 'ingest') this.hub.relayIngest(msg.records as never);
250
+ else this.hub.broadcast(msg as never);
251
+ } else if (this.forwarder) {
252
+ if (msg.type === 'ingest') this.forwarder.ingest(msg.records as never);
253
+ else if (msg.type === 'outbox-changed' && msg.mutationId) this.forwarder.mutationEnqueued(msg.mutationId);
254
+ }
255
+ });
256
+ this.runtime.on('tabs:sendTo', (e) => {
257
+ if (e.type === 'tabs:sendTo' && this.hub) this.hub.sendTo(e.tabId, e.message as never);
258
+ });
259
+ this.runtime.on('*', (e) => this.forwardToDevTools(e));
260
+ // One debug line per finished statement, for both stores and every engine
261
+ // (the SQLite engine logs none of its own). Guarded: this is the hottest
262
+ // path there is, and at the default `info` the line is never written.
263
+ // The message is the SurrealQL itself, so the Logs tab reads like a query
264
+ // trail; a long statement is cut there and kept whole in `query`.
265
+ const logStatement = (store: 'local' | 'remote') => (event: any) => {
266
+ if (!s.logger.isLevelEnabled('debug')) return;
267
+ const q = event.payload ?? {};
268
+ const sql = String(q.query ?? '').replace(/\s+/g, ' ').trim();
269
+ const cut = sql.length > STATEMENT_MSG_MAX;
270
+ s.logger.debug(
271
+ {
272
+ store,
273
+ ...(cut ? { query: q.query } : {}),
274
+ vars: q.vars,
275
+ ms: Math.round((q.duration ?? 0) * 10) / 10,
276
+ ok: q.success !== false,
277
+ error: q.error,
278
+ Category: `sp00ky-client::${store}`,
279
+ },
280
+ cut ? `${sql.slice(0, STATEMENT_MSG_MAX)}…` : sql
281
+ );
282
+ };
283
+ s.local.getEvents().subscribe('DATABASE_LOCAL_QUERY', logStatement('local'));
284
+ s.remote.getEvents().subscribe('DATABASE_REMOTE_QUERY', logStatement('remote'));
285
+ }
286
+
287
+ /** Runtime events the DevTools panel follows; query transitions also go to the log. */
288
+ private forwardToDevTools(e: OutEvent): void {
289
+ const log = this.services.logger;
290
+ switch (e.type) {
291
+ case 'query:status':
292
+ log.debug({ hash: e.hash, status: e.status, Category: 'sp00ky-client::query' }, 'Query status');
293
+ this.devTools.onStateChanged();
294
+ break;
295
+ case 'query:authority':
296
+ log.debug({ hash: e.hash, known: e.known, Category: 'sp00ky-client::query' }, e.known ? 'Query membership known' : 'Query membership unknown');
297
+ this.devTools.onStateChanged();
298
+ break;
299
+ case 'query:view-lost':
300
+ log.info({ hash: e.hash, Category: 'sp00ky-client::query' }, 'Query view lost on the server; re-registering');
301
+ this.devTools.onStateChanged();
302
+ break;
303
+ case 'mutation:event':
304
+ this.devTools.onMutation([e.event]);
305
+ break;
306
+ case 'mutation:settled':
307
+ this.devTools.onMutationOutcome({ mutationId: e.mutationId, recordId: e.recordId, eventType: e.eventType, status: 'synced' });
308
+ break;
309
+ case 'mutation:rolled-back':
310
+ this.devTools.onMutationOutcome({ mutationId: e.mutationId, recordId: e.recordId, eventType: e.eventType, status: 'rolled-back', error: e.error });
311
+ break;
312
+ case 'tray:changed':
313
+ case 'health:changed':
314
+ case 'activity:changed':
315
+ this.devTools.onStateChanged();
316
+ break;
317
+ default:
318
+ break;
319
+ }
320
+ }
321
+
322
+ // ---- lifecycle ---------------------------------------------------------------
323
+
324
+ async init(): Promise<void> {
325
+ await this.runtime.run(boot(this.env, { sharedTabs: this.services.tabs !== null }));
326
+ }
327
+
328
+ async close(): Promise<void> {
329
+ const s = this.services;
330
+ s.connectionSupervisor.dispose();
331
+ for (const dispose of this.disposers.splice(0)) dispose();
332
+ this.runtime.dispose();
333
+ await this.featureFlags.closeAll();
334
+ await this.appReleases.closeAll();
335
+ s.crdt.closeAll();
336
+ s.crdt.dispose();
337
+ await s.blobs.close().catch(() => {});
338
+ await s.streamProcessor.checkpoint('close').catch(() => {});
339
+ if (s.tabs) await s.tabs.stop();
340
+ this.detachWindow?.();
341
+ this.detachWindow = null;
342
+ await s.local.close();
343
+ await s.remote.close();
344
+ s.streamProcessor.dispose();
345
+ }
346
+
347
+ isLocalReady(): boolean {
348
+ return this.runtime.state.localReady;
349
+ }
350
+
351
+ // ---- queries -----------------------------------------------------------------
352
+
353
+ query<Table extends TableNames<S>>(
354
+ table: Table,
355
+ options: QueryOptions<TableModel<GetTable<S, Table>>, false>,
356
+ ttl: QueryTimeToLive = '10m'
357
+ ): QueryBuilder<S, Table, Sp00kyQueryResultPromise> {
358
+ return new QueryBuilder<S, Table, Sp00kyQueryResultPromise>(this.config.schema, table, async (q) => ({ hash: await this.initQuery(table, q, ttl) }), options);
359
+ }
360
+
361
+ private registerInput(table: string, q: InnerQuery<any, any, any>, ttl: QueryTimeToLive): RegisterInput {
362
+ const tableSchema = this.config.schema.tables.find((t) => t.name === table);
363
+ if (!tableSchema) throw new Error(`Table ${table} not found`);
364
+ return {
365
+ tableName: table,
366
+ surql: q.selectQuery.query,
367
+ params: parseQueryParams(tableSchema.columns, q.selectQuery.vars ?? {}),
368
+ ttl,
369
+ plan: q.selectQuery.plan,
370
+ };
371
+ }
372
+
373
+ private initQuery(table: string, q: InnerQuery<any, any, any>, ttl: QueryTimeToLive): Promise<string> {
374
+ return this.runtime.run(registerLocal(this.env, this.registerInput(table, q, ttl)));
375
+ }
376
+
377
+ /**
378
+ * The raw remote escape hatches bypass the query builder, so their shapes
379
+ * can never be on the server's query allowlist. When the generated schema
380
+ * says the allowlist is on, refuse them unless the app opted in explicitly.
381
+ */
382
+ private assertRawRemoteAllowed(method: string): void {
383
+ const mode = this.config.schema.policy?.queryAllowlist;
384
+ if ((mode === 'warn' || mode === 'enforce') && !this.config.allowRawRemote) {
385
+ throw new Error(
386
+ `sp00ky: ${method} is disabled because sync.queryAllowlist is on for this schema; pass allowRawRemote: true in the client config to opt in`
387
+ );
388
+ }
389
+ }
390
+
391
+ async queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive): Promise<string> {
392
+ this.assertRawRemoteAllowed('queryRaw');
393
+ const tableName = sql.split('FROM ')[1]?.split(' ')[0] ?? '';
394
+ return this.runtime.run(registerLocal(this.env, { tableName, surql: sql, params, ttl }));
395
+ }
396
+
397
+ /**
398
+ * Preload = register a query nobody subscribes to. Resolved before on this
399
+ * device: returns at once. Never resolved: resolves when the server's
400
+ * membership and every body are local. `signal` aborts the wait.
401
+ */
402
+ async preload(finalQuery: FinalQuery<S, any, any, any, any, any>, options?: PreloadOptions): Promise<void> {
403
+ const q = finalQuery.innerQuery;
404
+ await this.runtime.run(preload(this.env, this.registerInput(q.tableName, q, '10m')), { signal: options?.signal });
405
+ }
406
+
407
+ async subscribe(queryHash: string, callback: (records: Record<string, any>[]) => void, options?: { immediate?: boolean }): Promise<() => void> {
408
+ return this.runtime.subscribe(queryHash, callback, options);
409
+ }
410
+
411
+ deregisterQuery(queryHash: string): void {
412
+ const entry = this.runtime.state.queries.get(queryHash);
413
+ if (!entry || entry.subscribers > 0) return;
414
+ void this.runtime.run(evictQuery(queryHash), { lane: { kind: 'serial', key: `mat:${queryHash}` } });
415
+ }
416
+
417
+ subscribeQueryStatus(queryHash: string, callback: QueryStatusCallback, options?: { immediate?: boolean }): () => void {
418
+ return this.runtime.subscribeStatus(queryHash, callback, options);
419
+ }
420
+
421
+ subscribeQueryAuthority(queryHash: string, callback: QueryAuthorityCallback, options?: { immediate?: boolean }): () => void {
422
+ return this.runtime.subscribeAuthority(queryHash, callback, options);
423
+ }
424
+
425
+ reportFrontendTiming(queryHash: string, ms: number): void {
426
+ if (Number.isFinite(ms)) this.runtime.update(R.recordPhase(queryHash, 'frontend', ms));
427
+ }
428
+
429
+ // ---- mutations ---------------------------------------------------------------
430
+
431
+ async create<T extends Record<string, unknown>>(id: string, data: T): Promise<T> {
432
+ const out = await this.runtime.run(write(this.env, { kind: 'create', recordId: id, data }));
433
+ return (out.record ?? data) as T;
434
+ }
435
+
436
+ async update<T extends Record<string, unknown>>(_table: string, id: string, data: Partial<T>, options?: UpdateOptions): Promise<T> {
437
+ const out = await this.runtime.run(write(this.env, { kind: 'update', recordId: id, data: data as Record<string, unknown>, options }));
438
+ return (out.record ?? data) as T;
439
+ }
440
+
441
+ async delete(_table: string, id: string): Promise<void> {
442
+ await this.runtime.run(write(this.env, { kind: 'delete', recordId: id }));
443
+ }
444
+
445
+ async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions): Promise<void> {
446
+ const { tableName, record } = buildJobRecord(this.config.schema, String(backend), String(path), payload as Record<string, unknown>, options);
447
+ await this.create(`${tableName}:${generateId()}`, record);
448
+ }
449
+
450
+ // ---- failed-writes tray -------------------------------------------------------
451
+
452
+ get failedMutationCount(): number {
453
+ return this.runtime.state.failedCount;
454
+ }
455
+
456
+ subscribeToFailedMutations(cb: (count: number) => void): () => void {
457
+ cb(this.runtime.state.failedCount);
458
+ return this.runtime.on('tray:changed', (e) => {
459
+ if (e.type === 'tray:changed') cb(e.count);
460
+ });
461
+ }
462
+
463
+ listFailedMutations(): Promise<FailedMutationRow[]> {
464
+ return this.runtime.run(listFailed());
465
+ }
466
+
467
+ async retryFailedMutation(mutationId: string): Promise<boolean> {
468
+ return this.runtime.run(retryFailed(this.env, mutationId), { lane: { kind: 'serial', key: 'tray' } });
469
+ }
470
+
471
+ async discardFailedMutation(mutationId: string): Promise<boolean> {
472
+ return this.runtime.run(discardFailed(mutationId), { lane: { kind: 'serial', key: 'tray' } });
473
+ }
474
+
475
+ // ---- observability -----------------------------------------------------------
476
+
477
+ get tabRole(): TabRole | null {
478
+ return this.sharedActive && this.services.tabs ? this.services.tabs.role : null;
479
+ }
480
+
481
+ get remoteClient(): Surreal {
482
+ return this.services.remote.getClient();
483
+ }
484
+
485
+ get localClient(): unknown {
486
+ return this.services.local.getClient();
487
+ }
488
+
489
+ get pendingMutationCount(): number {
490
+ return pendingMutationCount(this.runtime.state);
491
+ }
492
+
493
+ /** Kept for the e2e suite; the saga core has no LIVE retry ladder. */
494
+ get liveRetryCount(): number {
495
+ return 0;
496
+ }
497
+
498
+ subscribeToPendingMutations(cb: (count: number) => void): () => void {
499
+ cb(this.pendingMutationCount);
500
+ return this.runtime.on('activity:changed', (e) => {
501
+ if (e.type === 'activity:changed') cb(e.pending);
502
+ });
503
+ }
504
+
505
+ get fetchingQueryCount(): number {
506
+ return fetchingQueryCount(this.runtime.state);
507
+ }
508
+
509
+ subscribeToFetchActivity(cb: (fetching: number) => void): () => void {
510
+ cb(this.fetchingQueryCount);
511
+ return this.runtime.on('activity:changed', (e) => {
512
+ if (e.type === 'activity:changed') cb(e.fetching);
513
+ });
514
+ }
515
+
516
+ get syncHealth(): SyncHealth {
517
+ return this.runtime.state.sync.health;
518
+ }
519
+
520
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
521
+ cb(this.syncHealth);
522
+ return this.runtime.on('health:changed', (e) => {
523
+ if (e.type === 'health:changed') cb(e.health);
524
+ });
525
+ }
526
+
527
+ get storageHealth(): StorageHealth {
528
+ return this.services.local.storageHealth ?? UNKNOWN_STORAGE_HEALTH;
529
+ }
530
+
531
+ subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
532
+ if (this.services.local.subscribeToStorageHealth) return this.services.local.subscribeToStorageHealth(cb);
533
+ cb(UNKNOWN_STORAGE_HEALTH);
534
+ return () => {};
535
+ }
536
+
537
+ /** The engine state, read-only. For tests and DevTools. */
538
+ get state(): ClientState {
539
+ return this.runtime.state;
540
+ }
541
+
542
+ /** Feed an event into the engine. For adapters, tests and DevTools. */
543
+ dispatch(event: RuntimeEvent): Promise<void> {
544
+ return this.runtime.dispatch(event);
545
+ }
546
+
547
+ // ---- auth / remote / crdt / flags / buckets ------------------------------------
548
+
549
+ authenticate(token: string) {
550
+ this.services.remote.setAuthToken(token);
551
+ return this.services.remote.getClient().authenticate(token);
552
+ }
553
+
554
+ deauthenticate() {
555
+ return this.services.remote.getClient().invalidate();
556
+ }
557
+
558
+ async useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T> {
559
+ this.assertRawRemoteAllowed('useRemote');
560
+ return fn(this.services.remote.getClient());
561
+ }
562
+
563
+ async remoteQuery<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T> {
564
+ this.assertRawRemoteAllowed('remoteQuery');
565
+ return this.services.remote.query<T>(sql, vars);
566
+ }
567
+
568
+ /**
569
+ * Tell the client that the app's own impersonation banner is on screen.
570
+ * Only meaningful with `impersonationBanner: { mode: 'custom' }`, where it
571
+ * cancels the fallback that would otherwise show the built-in banner. Call
572
+ * it while the custom banner is rendered (the Solid `useImpersonation`
573
+ * hook does it for you with `rendersBanner: true`).
574
+ */
575
+ acknowledgeImpersonationBanner(): void {
576
+ this.impersonationBanner.acknowledge();
577
+ }
578
+
579
+ feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle {
580
+ return this.featureFlags.feature(key, options);
581
+ }
582
+
583
+ setFeatureOverride(key: string, variant: string | null, payload?: unknown): void {
584
+ this.featureFlags.setLocalOverride(key, variant, payload);
585
+ }
586
+
587
+ clearFeatureOverrides(): void {
588
+ this.featureFlags.clearLocalOverrides();
589
+ }
590
+
591
+ getFeatureOverrides(): Record<string, FeatureFlagOverride> {
592
+ return this.featureFlags.getLocalOverrides();
593
+ }
594
+
595
+ appRelease(app: string, options?: AppReleaseOptions): AppReleaseHandle {
596
+ return this.appReleases.release(app, options);
597
+ }
598
+
599
+ /** Open a `@crdt` field for editing. `draft` (from {@link createCrdtDraft})
600
+ * hands over edits made before the record existed. */
601
+ async openCrdtField(table: string, recordId: string, field: string, draft?: CrdtField): Promise<CrdtField> {
602
+ return this.services.crdt.open(table, recordId, field, draft);
603
+ }
604
+
605
+ /** A detached `@crdt` document for a record not created yet; see
606
+ * {@link openCrdtField}. */
607
+ async createCrdtDraft(table: string, field: string): Promise<CrdtField> {
608
+ return this.services.crdt.createDraft(table, field);
609
+ }
610
+
611
+ /** Close a `@crdt` field. The last close resolves once its unsent edits
612
+ * are queued in the outbox; offline, once they are saved on the device. */
613
+ closeCrdtField(table: string, recordId: string, field: string): Promise<void> {
614
+ return this.services.crdt.close(table, recordId, field);
615
+ }
616
+
617
+ bucket<B extends BucketNames<S>>(name: B): BucketHandle {
618
+ return new BucketHandle(name, this.services.remote, this.services.blobs, bucketSettings(this.config, name, this.services.logger));
619
+ }
620
+
621
+ getBlobCacheStats() {
622
+ return this.services.blobs.stats();
623
+ }
624
+ }
625
+
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { EventSystem, EventDefinition } from './index';
2
+ import type { EventDefinition } from './index';
3
+ import { EventSystem } from './index';
3
4
 
4
5
  // Define test event types
5
6
  type TestEvents = {
@@ -172,6 +172,9 @@ export class EventSystem<E extends EventTypeMap> {
172
172
  * @param payload The data associated with the event.
173
173
  */
174
174
  emit<T extends EventType<E>, P extends EventPayload<E, T>>(type: T, payload: P): void {
175
+ // `{ type, payload }` structurally matches `Event<E, T>` (= the indexed
176
+ // access `E[T]`), but TS can't verify assignability to an indexed-access
177
+ // type over the generic `E`/`T`, so the constructed event is asserted.
175
178
  const event = {
176
179
  type,
177
180
  payload,