@spooky-sync/core 0.0.1-canary.31 → 0.0.1-canary.310

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