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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/AGENTS.md +78 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2187 -80
  4. package/dist/index.js +12653 -2581
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +331 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +787 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +158 -0
  23. package/src/boot/bucket-switch.saga.ts +115 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +94 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +382 -0
  35. package/src/client/sp00ky-client.test.ts +264 -0
  36. package/src/client/sp00ky-client.ts +499 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +54 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +159 -0
  44. package/src/kernel/events.ts +55 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +148 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +2 -1
  53. package/src/modules/auth/index.ts +138 -25
  54. package/src/modules/crdt/crdt-field.ts +294 -0
  55. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  56. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  57. package/src/modules/crdt/index.ts +463 -0
  58. package/src/modules/crdt/loro-loader.ts +25 -0
  59. package/src/modules/devtools/flags.ts +349 -0
  60. package/src/modules/devtools/index.ts +459 -47
  61. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  62. package/src/modules/devtools/state-shape.test.ts +149 -0
  63. package/src/modules/devtools/storage-info.test.ts +79 -0
  64. package/src/modules/devtools/storage-info.ts +168 -0
  65. package/src/modules/devtools/versions.test.ts +74 -0
  66. package/src/modules/devtools/versions.ts +110 -0
  67. package/src/modules/feature-flag/index.test.ts +251 -0
  68. package/src/modules/feature-flag/index.ts +316 -0
  69. package/src/modules/ref-tables.test.ts +91 -0
  70. package/src/modules/ref-tables.ts +88 -0
  71. package/src/mutation/jobs.test.ts +25 -0
  72. package/src/mutation/jobs.ts +41 -0
  73. package/src/mutation/mutation-id.test.ts +37 -0
  74. package/src/mutation/mutation-id.ts +35 -0
  75. package/src/mutation/push.saga.test.ts +285 -0
  76. package/src/mutation/push.saga.ts +215 -0
  77. package/src/mutation/rows.test.ts +201 -0
  78. package/src/mutation/rows.ts +377 -0
  79. package/src/mutation/tray.saga.test.ts +63 -0
  80. package/src/mutation/tray.saga.ts +48 -0
  81. package/src/mutation/write.saga.test.ts +163 -0
  82. package/src/mutation/write.saga.ts +166 -0
  83. package/src/otel/index.ts +13 -10
  84. package/src/query/env.test.ts +23 -0
  85. package/src/query/env.ts +56 -0
  86. package/src/query/fetch.saga.test.ts +156 -0
  87. package/src/query/fetch.saga.ts +115 -0
  88. package/src/query/hash.test.ts +14 -0
  89. package/src/query/hash.ts +18 -0
  90. package/src/query/id-set-plan.test.ts +132 -0
  91. package/src/query/lifecycle.saga.test.ts +122 -0
  92. package/src/query/lifecycle.saga.ts +101 -0
  93. package/src/query/materialize.saga.test.ts +82 -0
  94. package/src/query/materialize.saga.ts +75 -0
  95. package/src/query/materialize.test.ts +50 -0
  96. package/src/query/materialize.ts +44 -0
  97. package/src/query/membership.saga.test.ts +246 -0
  98. package/src/query/membership.saga.ts +208 -0
  99. package/src/query/membership.test.ts +118 -0
  100. package/src/query/membership.ts +158 -0
  101. package/src/query/register.saga.test.ts +285 -0
  102. package/src/query/register.saga.ts +216 -0
  103. package/src/query/render-set.test.ts +36 -0
  104. package/src/query/render-set.ts +60 -0
  105. package/src/query/sql.test.ts +62 -0
  106. package/src/query/sql.ts +79 -0
  107. package/src/query/window-query.test.ts +70 -0
  108. package/src/query/window-query.ts +195 -0
  109. package/src/services/blobs/blob-cache.test.ts +359 -0
  110. package/src/services/blobs/blob-cache.ts +603 -0
  111. package/src/services/blobs/blob-manifest.ts +227 -0
  112. package/src/services/blobs/blob-store.test.ts +77 -0
  113. package/src/services/blobs/blob-store.ts +359 -0
  114. package/src/services/blobs/blob.fixture.ts +90 -0
  115. package/src/services/blobs/index.ts +70 -0
  116. package/src/services/database/cache-engine.ts +193 -0
  117. package/src/services/database/connection-supervisor.test.ts +318 -0
  118. package/src/services/database/connection-supervisor.ts +422 -0
  119. package/src/services/database/database.query-timeout.test.ts +140 -0
  120. package/src/services/database/database.responses.test.ts +43 -0
  121. package/src/services/database/database.ts +145 -62
  122. package/src/services/database/engine-factory.ts +33 -0
  123. package/src/services/database/errors.ts +34 -0
  124. package/src/services/database/events/index.ts +2 -1
  125. package/src/services/database/index.ts +7 -0
  126. package/src/services/database/local-migrator.ts +30 -27
  127. package/src/services/database/local.test.ts +64 -0
  128. package/src/services/database/local.ts +484 -67
  129. package/src/services/database/plan-render.test.ts +159 -0
  130. package/src/services/database/plan-render.ts +108 -0
  131. package/src/services/database/relation-resolver.test.ts +467 -0
  132. package/src/services/database/relation-resolver.ts +0 -0
  133. package/src/services/database/remote.ts +222 -15
  134. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  135. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  136. package/src/services/database/sqlite-cache-engine.ts +1349 -0
  137. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  138. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  139. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  140. package/src/services/database/sqlite-lock-verify.ts +45 -0
  141. package/src/services/database/sqlite-open.test.ts +150 -0
  142. package/src/services/database/sqlite-open.ts +164 -0
  143. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  144. package/src/services/database/sqlite-plan-sql.ts +153 -0
  145. package/src/services/database/sqlite-projection.test.ts +121 -0
  146. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  147. package/src/services/database/sqlite-select.test.ts +246 -0
  148. package/src/services/database/sqlite-select.ts +131 -0
  149. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  150. package/src/services/database/sqlite-transport.ts +224 -0
  151. package/src/services/database/sqlite-worker.ts +437 -0
  152. package/src/services/database/surql-translate.ts +416 -0
  153. package/src/services/database/surreal-cache-engine.ts +161 -0
  154. package/src/services/logger/index.ts +3 -2
  155. package/src/services/persistence/localstorage.ts +2 -2
  156. package/src/services/persistence/resilient.ts +11 -4
  157. package/src/services/persistence/surrealdb.ts +10 -10
  158. package/src/services/stream-processor/index.ts +796 -84
  159. package/src/services/stream-processor/permissions.test.ts +47 -0
  160. package/src/services/stream-processor/permissions.ts +53 -0
  161. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  162. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  163. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  164. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  165. package/src/services/stream-processor/wasm-types.ts +59 -3
  166. package/src/services/tabs/broker-client.ts +283 -0
  167. package/src/services/tabs/broker.test.ts +327 -0
  168. package/src/services/tabs/coordinator.test.ts +365 -0
  169. package/src/services/tabs/coordinator.ts +633 -0
  170. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  171. package/src/services/tabs/leader-locks.ts +75 -0
  172. package/src/services/tabs/protocol.ts +262 -0
  173. package/src/services/tabs/support.ts +36 -0
  174. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  175. package/src/sp00ky.ts +5 -0
  176. package/src/state/client-state.ts +184 -0
  177. package/src/state/lifecycle.test.ts +118 -0
  178. package/src/state/lifecycle.ts +86 -0
  179. package/src/state/reducers.test.ts +280 -0
  180. package/src/state/reducers.ts +476 -0
  181. package/src/state/selectors.test.ts +149 -0
  182. package/src/state/selectors.ts +184 -0
  183. package/src/sync/connection.saga.test.ts +86 -0
  184. package/src/sync/connection.saga.ts +85 -0
  185. package/src/sync/live.saga.test.ts +178 -0
  186. package/src/sync/live.saga.ts +107 -0
  187. package/src/sync/policy.health.test.ts +56 -0
  188. package/src/sync/policy.test.ts +659 -0
  189. package/src/sync/policy.ts +502 -0
  190. package/src/sync/poll.saga.test.ts +51 -0
  191. package/src/sync/poll.saga.ts +36 -0
  192. package/src/sync/tabs.saga.test.ts +74 -0
  193. package/src/sync/tabs.saga.ts +93 -0
  194. package/src/testing/adapters.ts +133 -0
  195. package/src/testing/build.ts +61 -0
  196. package/src/testing/fake-services.ts +34 -0
  197. package/src/testing/run-pure.test.ts +92 -0
  198. package/src/testing/run-pure.ts +128 -0
  199. package/src/testing/services.ts +17 -0
  200. package/src/types.ts +591 -14
  201. package/src/utils/blurhash.ts +90 -0
  202. package/src/utils/error-classification.test.ts +54 -0
  203. package/src/utils/error-classification.ts +25 -0
  204. package/src/utils/index.ts +79 -13
  205. package/src/utils/parser.test.ts +49 -120
  206. package/src/utils/parser.ts +32 -2
  207. package/src/utils/semver.test.ts +32 -0
  208. package/src/utils/semver.ts +30 -0
  209. package/src/utils/surql.ts +30 -18
  210. package/src/utils/utils.test.ts +2 -1
  211. package/src/utils/withRetry.test.ts +1 -1
  212. package/tsdown.config.ts +86 -1
  213. package/vitest.config.ts +48 -0
  214. package/src/modules/cache/index.ts +0 -241
  215. package/src/modules/cache/types.ts +0 -19
  216. package/src/modules/data/data.test.ts +0 -58
  217. package/src/modules/data/index.ts +0 -864
  218. package/src/modules/sync/engine.ts +0 -166
  219. package/src/modules/sync/events/index.ts +0 -77
  220. package/src/modules/sync/index.ts +0 -3
  221. package/src/modules/sync/queue/index.ts +0 -2
  222. package/src/modules/sync/queue/queue-down.ts +0 -89
  223. package/src/modules/sync/queue/queue-up.ts +0 -223
  224. package/src/modules/sync/scheduler.ts +0 -84
  225. package/src/modules/sync/sync.ts +0 -421
  226. package/src/modules/sync/utils.test.ts +0 -311
  227. package/src/modules/sync/utils.ts +0 -167
  228. package/src/spooky.ts +0 -395
@@ -0,0 +1,499 @@
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 { DevToolsService } from '../modules/devtools/index';
38
+ import type { DevToolsQuerySource } from '../modules/devtools/index';
39
+ import { parseQueryParams, generateId } from '../utils/index';
40
+ import type { OutEvent, RuntimeEvent } from '../kernel/events';
41
+ import type { ClientState } from '../state/client-state';
42
+ import * as R from '../state/reducers';
43
+ import { pendingMutationCount, fetchingQueryCount, phaseTimings, toQueryState } from '../state/selectors';
44
+ import { defaultEnv, type SagaEnv } from '../query/env';
45
+ import { registerLocal, type RegisterInput } from '../query/register.saga';
46
+ import { evictQuery } from '../query/lifecycle.saga';
47
+ import { write } from '../mutation/write.saga';
48
+ import { discardFailed, listFailed, retryFailed } from '../mutation/tray.saga';
49
+ import type { FailedMutationRow } from '../mutation/rows';
50
+ import { buildJobRecord } from '../mutation/jobs';
51
+ import { boot } from '../boot/boot.saga';
52
+ import { preload } from '../boot/preload.saga';
53
+ import { DEGRADE_AFTER_FAILURES, OUTBOX_BATCH_SIZE, PUSH_TIMEOUT_MS } from '../kernel/constants';
54
+ import { Runtime } from './runtime';
55
+ import { createAdapters, createServices, createTabsCoordinator, type ServiceHost, type Services } from './services';
56
+ import { BucketHandle } from './bucket-handle';
57
+ import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
58
+
59
+ const UNKNOWN_STORAGE_HEALTH: StorageHealth = Object.freeze({ status: 'unknown', fallback: false });
60
+
61
+ export interface Sp00kyClientDeps<S extends SchemaStructure> {
62
+ services?: Services<S>;
63
+ runtime?: Runtime;
64
+ env?: Partial<SagaEnv>;
65
+ }
66
+
67
+ /**
68
+ * The public client. Every method is a thin translation into a saga run, a
69
+ * state read, or a subscription on the runtime; the engine's behaviour lives
70
+ * in the sagas.
71
+ */
72
+ export class Sp00kyClient<S extends SchemaStructure> {
73
+ public readonly auth: AuthService<S>;
74
+ public readonly streamProcessor: StreamProcessorService;
75
+
76
+ private readonly services: Services<S>;
77
+ private readonly env: SagaEnv;
78
+ private readonly runtime: Runtime;
79
+ private readonly featureFlags: FeatureFlagModule<S>;
80
+ private readonly appReleases: AppReleaseModule<S>;
81
+ private readonly devTools: DevToolsService;
82
+ private hub: LeaderSyncHub | null = null;
83
+ private forwarder: SyncForwarder | null = null;
84
+ private detachWindow: (() => void) | null = null;
85
+ private sharedActive = false;
86
+
87
+ constructor(
88
+ private readonly config: Sp00kyConfig<S>,
89
+ deps: Sp00kyClientDeps<S> = {}
90
+ ) {
91
+ this.services = deps.services ?? createServices(config);
92
+ const s = this.services;
93
+ this.env = defaultEnv(config.schema, {
94
+ anonLive: config.enableAnonymousLiveQueries === true,
95
+ remoteTimeoutMs: Math.max(0, config.database.queryTimeoutMs ?? 60_000),
96
+ pushTimeoutMs: config.pushTimeoutMs ?? PUSH_TIMEOUT_MS,
97
+ outboxBatchSize: OUTBOX_BATCH_SIZE,
98
+ degradeAfter: config.syncHealth === false ? 0 : (config.syncHealth?.degradeAfterConsecutiveFailures ?? DEGRADE_AFTER_FAILURES),
99
+ materializeDebounceMs: config.streamDebounceTime ?? 50,
100
+ pollBaseMs: config.refSyncIntervalMs && config.refSyncIntervalMs > 0 ? config.refSyncIntervalMs : 500,
101
+ ...deps.env,
102
+ });
103
+ const host: ServiceHost = {
104
+ dispatch: (event) => void this.runtime.dispatch(event),
105
+ setTabsChannel: ({ hub, forwarder }) => {
106
+ this.hub = hub;
107
+ this.forwarder = forwarder;
108
+ this.sharedActive = hub !== null || forwarder !== null;
109
+ },
110
+ initModules: () => undefined,
111
+ detachWindow: () => this.detachWindow?.(),
112
+ setDetachWindow: (fn) => (this.detachWindow = fn),
113
+ };
114
+ if (!deps.runtime && s.tabs === null && s.tabsUnsupportedReason === undefined) {
115
+ s.tabs = createTabsCoordinator(config, s, host);
116
+ }
117
+ this.runtime =
118
+ deps.runtime ??
119
+ new Runtime({
120
+ env: this.env,
121
+ adapters: createAdapters(config, s, host, () => [this.featureFlags, this.appReleases]),
122
+ logger: s.logger,
123
+ tabId: s.tabId,
124
+ });
125
+ this.auth = s.auth;
126
+ this.streamProcessor = s.streamProcessor;
127
+
128
+ const liveQueryPort = {
129
+ query: (table: string, surql: string, params: Record<string, unknown>, ttl: QueryTimeToLive) =>
130
+ this.runtime.run(registerLocal(this.env, { tableName: table, surql, params, ttl })),
131
+ subscribe: (hash: string, cb: (records: Record<string, any>[]) => void, options?: { immediate?: boolean }) =>
132
+ this.runtime.subscribe(hash, cb, options),
133
+ };
134
+ const remoteRegisterPort = { enqueueDownEvent: () => void this.runtime.dispatch({ type: 'EnsureRegistered' }) };
135
+ this.featureFlags = new FeatureFlagModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
136
+ this.appReleases = new AppReleaseModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
137
+
138
+ const querySource: DevToolsQuerySource = {
139
+ getActiveQueries: () => [...this.runtime.state.queries.values()].map(toQueryState),
140
+ getQueryById: (id) => {
141
+ const entry = [...this.runtime.state.queries.values()].find((e) => String(e.def.id) === String(id));
142
+ return entry ? toQueryState(entry) : undefined;
143
+ },
144
+ phaseTimings: (q) => {
145
+ 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));
146
+ return entry ? phaseTimings(entry) : ({} as never);
147
+ },
148
+ };
149
+ this.devTools = new DevToolsService(s.local, s.remote, s.logger, config.schema, s.auth, querySource);
150
+ this.devTools.setFeatureFlagOverrides(this.featureFlags);
151
+ s.streamProcessor.addReceiver(this.devTools);
152
+ this.devTools.setBlobInfoProvider(() => s.blobs.stats());
153
+ if (config.sharedTabs) {
154
+ this.devTools.setTabsInfoProvider(() => {
155
+ const c = s.tabs;
156
+ if (!this.sharedActive || !c) return { active: false, reason: s.tabsUnsupportedReason ?? 'fell-back' };
157
+ return {
158
+ active: true,
159
+ role: c.role,
160
+ tabId: c.tabId,
161
+ leadershipId: c.leadershipId,
162
+ leaderTabId: c.role === 'leader' ? c.tabId : c.leaderTabId,
163
+ ...(this.hub ? { followers: this.hub.followerCount, relayedBatches: this.hub.relayedBatches } : {}),
164
+ };
165
+ });
166
+ }
167
+ this.wireAdapters();
168
+ }
169
+
170
+ /** Adapter callbacks become runtime events; runtime events reach the legacy services. */
171
+ private wireAdapters(): void {
172
+ const s = this.services;
173
+ const receiver: StreamUpdateReceiver = { onStreamUpdate: (update) => void this.runtime.dispatch({ type: 'StreamUpdate', update }) };
174
+ s.streamProcessor.addReceiver(receiver);
175
+ s.connectionSupervisor.subscribe((state) => void this.runtime.dispatch({ type: 'ConnectionChanged', state }));
176
+ s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }));
177
+ this.runtime.on('tabs:broadcast', (e) => {
178
+ if (e.type !== 'tabs:broadcast') return;
179
+ const msg = e.message as { type: string; records?: unknown[]; mutationId?: string };
180
+ if (this.hub) {
181
+ if (msg.type === 'ingest') this.hub.relayIngest(msg.records as never);
182
+ else this.hub.broadcast(msg as never);
183
+ } else if (this.forwarder) {
184
+ if (msg.type === 'ingest') this.forwarder.ingest(msg.records as never);
185
+ else if (msg.type === 'outbox-changed' && msg.mutationId) this.forwarder.mutationEnqueued(msg.mutationId);
186
+ }
187
+ });
188
+ this.runtime.on('tabs:sendTo', (e) => {
189
+ if (e.type === 'tabs:sendTo' && this.hub) this.hub.sendTo(e.tabId, e.message as never);
190
+ });
191
+ this.runtime.on('*', (e) => this.forwardToDevTools(e));
192
+ s.local.getEvents().subscribe('DATABASE_LOCAL_QUERY', (event: any) => this.devTools.logEvent('LOCAL_QUERY', event.payload));
193
+ s.remote.getEvents().subscribe('DATABASE_REMOTE_QUERY', (event: any) => this.devTools.logEvent('REMOTE_QUERY', event.payload));
194
+ }
195
+
196
+ private forwardToDevTools(e: OutEvent): void {
197
+ switch (e.type) {
198
+ case 'query:status':
199
+ this.devTools.logEvent('QUERY_STATUS_CHANGED', { queryHash: e.hash, status: e.status });
200
+ break;
201
+ case 'query:authority':
202
+ this.devTools.logEvent('QUERY_AUTHORITY_CHANGED', { queryHash: e.hash, known: e.known });
203
+ break;
204
+ case 'query:view-lost':
205
+ this.devTools.logEvent('QUERY_VIEW_LOST', { queryHash: e.hash });
206
+ break;
207
+ case 'mutation:event':
208
+ this.devTools.onMutation([e.event]);
209
+ break;
210
+ case 'devtools':
211
+ this.devTools.logEvent(e.name, e.data);
212
+ break;
213
+ default:
214
+ break;
215
+ }
216
+ }
217
+
218
+ // ---- lifecycle ---------------------------------------------------------------
219
+
220
+ async init(): Promise<void> {
221
+ await this.runtime.run(boot(this.env, { sharedTabs: this.services.tabs !== null }));
222
+ }
223
+
224
+ async close(): Promise<void> {
225
+ const s = this.services;
226
+ s.connectionSupervisor.dispose();
227
+ this.runtime.dispose();
228
+ await this.featureFlags.closeAll();
229
+ await this.appReleases.closeAll();
230
+ s.crdt.closeAll();
231
+ s.crdt.dispose();
232
+ await s.blobs.close().catch(() => {});
233
+ await s.streamProcessor.checkpoint('close').catch(() => {});
234
+ if (s.tabs) await s.tabs.stop();
235
+ this.detachWindow?.();
236
+ this.detachWindow = null;
237
+ await s.local.close();
238
+ await s.remote.close();
239
+ s.streamProcessor.dispose();
240
+ }
241
+
242
+ isLocalReady(): boolean {
243
+ return this.runtime.state.localReady;
244
+ }
245
+
246
+ // ---- queries -----------------------------------------------------------------
247
+
248
+ query<Table extends TableNames<S>>(
249
+ table: Table,
250
+ options: QueryOptions<TableModel<GetTable<S, Table>>, false>,
251
+ ttl: QueryTimeToLive = '10m'
252
+ ): QueryBuilder<S, Table, Sp00kyQueryResultPromise> {
253
+ return new QueryBuilder<S, Table, Sp00kyQueryResultPromise>(this.config.schema, table, async (q) => ({ hash: await this.initQuery(table, q, ttl) }), options);
254
+ }
255
+
256
+ private registerInput(table: string, q: InnerQuery<any, any, any>, ttl: QueryTimeToLive): RegisterInput {
257
+ const tableSchema = this.config.schema.tables.find((t) => t.name === table);
258
+ if (!tableSchema) throw new Error(`Table ${table} not found`);
259
+ return {
260
+ tableName: table,
261
+ surql: q.selectQuery.query,
262
+ params: parseQueryParams(tableSchema.columns, q.selectQuery.vars ?? {}),
263
+ ttl,
264
+ plan: q.selectQuery.plan,
265
+ };
266
+ }
267
+
268
+ private initQuery(table: string, q: InnerQuery<any, any, any>, ttl: QueryTimeToLive): Promise<string> {
269
+ return this.runtime.run(registerLocal(this.env, this.registerInput(table, q, ttl)));
270
+ }
271
+
272
+ /**
273
+ * The raw remote escape hatches bypass the query builder, so their shapes
274
+ * can never be on the server's query allowlist. When the generated schema
275
+ * says the allowlist is on, refuse them unless the app opted in explicitly.
276
+ */
277
+ private assertRawRemoteAllowed(method: string): void {
278
+ const mode = this.config.schema.policy?.queryAllowlist;
279
+ if ((mode === 'warn' || mode === 'enforce') && !this.config.allowRawRemote) {
280
+ throw new Error(
281
+ `sp00ky: ${method} is disabled because sync.queryAllowlist is on for this schema; pass allowRawRemote: true in the client config to opt in`
282
+ );
283
+ }
284
+ }
285
+
286
+ async queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive): Promise<string> {
287
+ this.assertRawRemoteAllowed('queryRaw');
288
+ const tableName = sql.split('FROM ')[1]?.split(' ')[0] ?? '';
289
+ return this.runtime.run(registerLocal(this.env, { tableName, surql: sql, params, ttl }));
290
+ }
291
+
292
+ /**
293
+ * Preload = register a query nobody subscribes to. Resolved before on this
294
+ * device: returns at once. Never resolved: resolves when the server's
295
+ * membership and every body are local. `signal` aborts the wait.
296
+ */
297
+ async preload(finalQuery: FinalQuery<S, any, any, any, any, any>, options?: PreloadOptions): Promise<void> {
298
+ const q = finalQuery.innerQuery;
299
+ await this.runtime.run(preload(this.env, this.registerInput(q.tableName, q, '10m')), { signal: options?.signal });
300
+ }
301
+
302
+ async subscribe(queryHash: string, callback: (records: Record<string, any>[]) => void, options?: { immediate?: boolean }): Promise<() => void> {
303
+ return this.runtime.subscribe(queryHash, callback, options);
304
+ }
305
+
306
+ deregisterQuery(queryHash: string): void {
307
+ const entry = this.runtime.state.queries.get(queryHash);
308
+ if (!entry || entry.subscribers > 0) return;
309
+ void this.runtime.run(evictQuery(queryHash), { lane: { kind: 'serial', key: `mat:${queryHash}` } });
310
+ }
311
+
312
+ subscribeQueryStatus(queryHash: string, callback: QueryStatusCallback, options?: { immediate?: boolean }): () => void {
313
+ return this.runtime.subscribeStatus(queryHash, callback, options);
314
+ }
315
+
316
+ subscribeQueryAuthority(queryHash: string, callback: QueryAuthorityCallback, options?: { immediate?: boolean }): () => void {
317
+ return this.runtime.subscribeAuthority(queryHash, callback, options);
318
+ }
319
+
320
+ reportFrontendTiming(queryHash: string, ms: number): void {
321
+ if (Number.isFinite(ms)) this.runtime.update(R.recordPhase(queryHash, 'frontend', ms));
322
+ }
323
+
324
+ // ---- mutations ---------------------------------------------------------------
325
+
326
+ async create<T extends Record<string, unknown>>(id: string, data: T): Promise<T> {
327
+ const out = await this.runtime.run(write(this.env, { kind: 'create', recordId: id, data }));
328
+ return (out.record ?? data) as T;
329
+ }
330
+
331
+ async update<T extends Record<string, unknown>>(_table: string, id: string, data: Partial<T>, options?: UpdateOptions): Promise<T> {
332
+ const out = await this.runtime.run(write(this.env, { kind: 'update', recordId: id, data: data as Record<string, unknown>, options }));
333
+ return (out.record ?? data) as T;
334
+ }
335
+
336
+ async delete(_table: string, id: string): Promise<void> {
337
+ await this.runtime.run(write(this.env, { kind: 'delete', recordId: id }));
338
+ }
339
+
340
+ async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions): Promise<void> {
341
+ const { tableName, record } = buildJobRecord(this.config.schema, String(backend), String(path), payload as Record<string, unknown>, options);
342
+ await this.create(`${tableName}:${generateId()}`, record);
343
+ }
344
+
345
+ // ---- failed-writes tray -------------------------------------------------------
346
+
347
+ get failedMutationCount(): number {
348
+ return this.runtime.state.failedCount;
349
+ }
350
+
351
+ subscribeToFailedMutations(cb: (count: number) => void): () => void {
352
+ cb(this.runtime.state.failedCount);
353
+ return this.runtime.on('tray:changed', (e) => {
354
+ if (e.type === 'tray:changed') cb(e.count);
355
+ });
356
+ }
357
+
358
+ listFailedMutations(): Promise<FailedMutationRow[]> {
359
+ return this.runtime.run(listFailed());
360
+ }
361
+
362
+ async retryFailedMutation(mutationId: string): Promise<boolean> {
363
+ return this.runtime.run(retryFailed(this.env, mutationId), { lane: { kind: 'serial', key: 'tray' } });
364
+ }
365
+
366
+ async discardFailedMutation(mutationId: string): Promise<boolean> {
367
+ return this.runtime.run(discardFailed(mutationId), { lane: { kind: 'serial', key: 'tray' } });
368
+ }
369
+
370
+ // ---- observability -----------------------------------------------------------
371
+
372
+ get tabRole(): TabRole | null {
373
+ return this.sharedActive && this.services.tabs ? this.services.tabs.role : null;
374
+ }
375
+
376
+ get remoteClient(): Surreal {
377
+ return this.services.remote.getClient();
378
+ }
379
+
380
+ get localClient(): unknown {
381
+ return this.services.local.getClient();
382
+ }
383
+
384
+ get pendingMutationCount(): number {
385
+ return pendingMutationCount(this.runtime.state);
386
+ }
387
+
388
+ /** Kept for the e2e suite; the saga core has no LIVE retry ladder. */
389
+ get liveRetryCount(): number {
390
+ return 0;
391
+ }
392
+
393
+ subscribeToPendingMutations(cb: (count: number) => void): () => void {
394
+ cb(this.pendingMutationCount);
395
+ return this.runtime.on('activity:changed', (e) => {
396
+ if (e.type === 'activity:changed') cb(e.pending);
397
+ });
398
+ }
399
+
400
+ get fetchingQueryCount(): number {
401
+ return fetchingQueryCount(this.runtime.state);
402
+ }
403
+
404
+ subscribeToFetchActivity(cb: (fetching: number) => void): () => void {
405
+ cb(this.fetchingQueryCount);
406
+ return this.runtime.on('activity:changed', (e) => {
407
+ if (e.type === 'activity:changed') cb(e.fetching);
408
+ });
409
+ }
410
+
411
+ get syncHealth(): SyncHealth {
412
+ return this.runtime.state.sync.health;
413
+ }
414
+
415
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
416
+ cb(this.syncHealth);
417
+ return this.runtime.on('health:changed', (e) => {
418
+ if (e.type === 'health:changed') cb(e.health);
419
+ });
420
+ }
421
+
422
+ get storageHealth(): StorageHealth {
423
+ return this.services.local.storageHealth ?? UNKNOWN_STORAGE_HEALTH;
424
+ }
425
+
426
+ subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
427
+ if (this.services.local.subscribeToStorageHealth) return this.services.local.subscribeToStorageHealth(cb);
428
+ cb(UNKNOWN_STORAGE_HEALTH);
429
+ return () => {};
430
+ }
431
+
432
+ /** The engine state, read-only. For tests and DevTools. */
433
+ get state(): ClientState {
434
+ return this.runtime.state;
435
+ }
436
+
437
+ /** Feed an event into the engine. For adapters, tests and DevTools. */
438
+ dispatch(event: RuntimeEvent): Promise<void> {
439
+ return this.runtime.dispatch(event);
440
+ }
441
+
442
+ // ---- auth / remote / crdt / flags / buckets ------------------------------------
443
+
444
+ authenticate(token: string) {
445
+ this.services.remote.setAuthToken(token);
446
+ return this.services.remote.getClient().authenticate(token);
447
+ }
448
+
449
+ deauthenticate() {
450
+ return this.services.remote.getClient().invalidate();
451
+ }
452
+
453
+ async useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T> {
454
+ this.assertRawRemoteAllowed('useRemote');
455
+ return fn(this.services.remote.getClient());
456
+ }
457
+
458
+ async remoteQuery<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T> {
459
+ this.assertRawRemoteAllowed('remoteQuery');
460
+ return this.services.remote.query<T>(sql, vars);
461
+ }
462
+
463
+ feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle {
464
+ return this.featureFlags.feature(key, options);
465
+ }
466
+
467
+ setFeatureOverride(key: string, variant: string | null, payload?: unknown): void {
468
+ this.featureFlags.setLocalOverride(key, variant, payload);
469
+ }
470
+
471
+ clearFeatureOverrides(): void {
472
+ this.featureFlags.clearLocalOverrides();
473
+ }
474
+
475
+ getFeatureOverrides(): Record<string, FeatureFlagOverride> {
476
+ return this.featureFlags.getLocalOverrides();
477
+ }
478
+
479
+ appRelease(app: string, options?: AppReleaseOptions): AppReleaseHandle {
480
+ return this.appReleases.release(app, options);
481
+ }
482
+
483
+ async openCrdtField(table: string, recordId: string, field: string, fallbackText?: string): Promise<CrdtField> {
484
+ return this.services.crdt.open(table, recordId, field, fallbackText);
485
+ }
486
+
487
+ closeCrdtField(table: string, recordId: string, field: string): void {
488
+ this.services.crdt.close(table, recordId, field);
489
+ }
490
+
491
+ bucket<B extends BucketNames<S>>(name: B): BucketHandle {
492
+ return new BucketHandle(name, this.services.remote, this.services.blobs, { blurhash: this.config.blurhash, logger: this.services.logger });
493
+ }
494
+
495
+ getBlobCacheStats() {
496
+ return this.services.blobs.stats();
497
+ }
498
+ }
499
+
@@ -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,39 @@
1
1
  export * from './types';
2
- export * from './spooky';
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 type {
19
+ BlobCacheStats,
20
+ BlobEntry,
21
+ BlobKey,
22
+ BlobReadOptions,
23
+ BlobUrlLease,
24
+ } from './services/blobs/index';
25
+ export type { FailedMutationRow as FailedMutation } from './mutation/rows';
26
+ export { semverGt } from './utils/semver';
27
+ export { LocalOpTimeoutError, DEFAULT_LOCAL_OP_TIMEOUT_MS } from './services/database/errors';
28
+ export { fileToUint8Array, textToHtml } from './utils/index';
29
+ export {
30
+ blurhashSidecarPath,
31
+ encodeImageToBlurhash,
32
+ encodeBlurhash,
33
+ decodeBlurhash,
34
+ isBlurhashValid,
35
+ isImagePath,
36
+ BLURHASH_IMAGE_EXTENSIONS,
37
+ type BlurhashSetting,
38
+ type BlurhashEncodeOptions,
39
+ } from './utils/blurhash';
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { backoffMs, RETRY_BASE_MS, RETRY_MAX_MS } from './constants';
3
+
4
+ describe('backoffMs', () => {
5
+ it('doubles from the base and caps at the max', () => {
6
+ expect(backoffMs(0)).toBe(RETRY_BASE_MS);
7
+ expect(backoffMs(1)).toBe(RETRY_BASE_MS * 2);
8
+ expect(backoffMs(3)).toBe(RETRY_BASE_MS * 8);
9
+ expect(backoffMs(20)).toBe(RETRY_MAX_MS);
10
+ });
11
+ it('clamps negative and huge attempts', () => {
12
+ expect(backoffMs(-5)).toBe(RETRY_BASE_MS);
13
+ expect(backoffMs(10_000)).toBe(RETRY_MAX_MS);
14
+ });
15
+ it('honours custom base and max', () => {
16
+ expect(backoffMs(2, 100, 250)).toBe(250);
17
+ expect(backoffMs(1, 100, 250)).toBe(200);
18
+ });
19
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Every timing constant of the saga core in one place. Sagas never call
3
+ * `setTimeout`; they yield `timer.set` effects with one of these delays, so a
4
+ * test can pin the schedule by asserting the effect log.
5
+ */
6
+
7
+ /** Trailing coalesce for materialization after a state change. */
8
+ export const MATERIALIZE_DEBOUNCE_MS = 50;
9
+ /** Coalesce window for LIVE membership dirt before the batched edge re-read. */
10
+ export const MEMBERSHIP_COALESCE_MS = 50;
11
+ /** Re-read ladder while the server reports a view as `materializing`. */
12
+ export const MATERIALIZING_REREAD_LADDER_MS: readonly number[] = [150, 400, 1000];
13
+ /** How long an acked outbox item stays in the overlay when membership never names it. */
14
+ export const ACK_GRACE_MS = 30_000;
15
+ /** Ids per body-fetch statement. */
16
+ export const FETCH_CHUNK = 500;
17
+ /** Outbox statements per push request. */
18
+ export const OUTBOX_BATCH_SIZE = 50;
19
+ /** Push deadline per outbox batch. */
20
+ export const PUSH_TIMEOUT_MS = 30_000;
21
+ /** Exponential backoff for outbox / fetch / registration retries. */
22
+ export const RETRY_BASE_MS = 500;
23
+ export const RETRY_MAX_MS = 15_000;
24
+ /** Registration attempts before a query is reported as failed to settle. */
25
+ export const REGISTER_MAX_RETRIES = 3;
26
+ /** `_00_list_ref` poll cadence (fallback when LIVE is quiet). */
27
+ export const LIST_REF_POLL_BASE_MS = 500;
28
+ export const LIST_REF_POLL_MAX_MS = 5_000;
29
+ export const LIST_REF_ROW_BUDGET = 1_500;
30
+ export const LIST_REF_LARGE_VIEW_EDGES = 1_000;
31
+ export const LIST_REF_LARGE_VIEW_POLL_MS = 15_000;
32
+ /** Self-heal cadence while sync health is degraded. */
33
+ export const SELF_HEAL_BASE_MS = 2_000;
34
+ export const SELF_HEAL_MAX_MS = 30_000;
35
+ /** Consecutive failed rounds before health flips to degraded. */
36
+ export const DEGRADE_AFTER_FAILURES = 3;
37
+ /** Burst coalescing of reconnects. */
38
+ export const RECONNECT_REFETCH_COOLDOWN_MS = 10_000;
39
+ /** Waiting for `$auth.id` to be visible after a reconnect. */
40
+ export const AUTH_READY_RETRY_MS = 500;
41
+ export const AUTH_READY_MAX_ATTEMPTS = 10;
42
+ /** Heartbeat at this fraction of the shortest ttl in state. */
43
+ export const TTL_HEARTBEAT_FRACTION = 0.5;
44
+ /** Orphan body garbage collection cadence. */
45
+ export const GC_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
46
+ /** Remote adapter slot limit. */
47
+ export const REMOTE_CONCURRENCY = 16;
48
+ /** Rolling telemetry sample window per query. */
49
+ export const TELEMETRY_SAMPLE_WINDOW = 100;
50
+
51
+ export function backoffMs(attempt: number, base = RETRY_BASE_MS, max = RETRY_MAX_MS): number {
52
+ const n = Math.max(0, Math.min(attempt, 30));
53
+ return Math.min(max, base * 2 ** n);
54
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { fx } from './effects';
3
+
4
+ describe('effect constructors', () => {
5
+ it('build every effect kind as plain data', () => {
6
+ const sealed = { sql: 'x;', extract: (r: unknown[]) => r[0] };
7
+ const plan = { table: 't' } as any;
8
+ const cases: Array<[ReturnType<typeof fx.now>, string]> = [
9
+ [fx.local.query('SELECT 1', { a: 1 }, 3), 'local.query'],
10
+ [fx.local.select(plan, { b: 2 }), 'local.select'],
11
+ [fx.local.execute(sealed, {}, 1), 'local.execute'],
12
+ [fx.local.getById('t', 'x'), 'local.getById'],
13
+ [fx.local.upsert('t', 'x', { a: 1 }, 'merge'), 'local.upsert'],
14
+ [fx.local.delete('t', 'x'), 'local.delete'],
15
+ [fx.remote.query('RETURN true', undefined, 10), 'remote.query'],
16
+ [fx.remote.live('_00_list_ref'), 'remote.live'],
17
+ [fx.remote.kill('u'), 'remote.kill'],
18
+ [fx.ssp.register({ queryHash: 'h', surql: 's', params: {}, ttl: '10m', tableName: 't' }), 'ssp.register'],
19
+ [fx.ssp.unregister('h'), 'ssp.unregister'],
20
+ [fx.ssp.ingest([]), 'ssp.ingest'],
21
+ [fx.timer.set('k', 5, { type: 'Drain' }), 'timer.set'],
22
+ [fx.timer.clear('k'), 'timer.clear'],
23
+ [fx.state.read((s) => s.tabId), 'state.read'],
24
+ [fx.state.update((s) => s), 'state.update'],
25
+ [fx.state.wait((s) => s.primed), 'state.wait'],
26
+ [fx.now(), 'now'],
27
+ [fx.id('mutation'), 'id'],
28
+ [fx.hash('abc'), 'hash'],
29
+ [fx.emit({ type: 'tray:changed', count: 1 }), 'emit'],
30
+ [fx.dispatch({ type: 'FetchRows' }), 'dispatch'],
31
+ [fx.all([fx.now()]), 'all'],
32
+ [fx.service('local.connect', 'anon'), 'service'],
33
+ ];
34
+ for (const [effect, kind] of cases) expect(effect.kind).toBe(kind);
35
+ expect(fx.local.query('SELECT 1', { a: 1 }, 3)).toEqual({ kind: 'local.query', sql: 'SELECT 1', vars: { a: 1 }, epoch: 3 });
36
+ expect(fx.local.epoch()).toEqual({ kind: 'local.epoch' });
37
+ expect(fx.timer.set('k', 5, { type: 'Drain' })).toEqual({ kind: 'timer.set', key: 'k', ms: 5, event: { type: 'Drain' } });
38
+ expect(fx.service('hint.write', 'u1')).toEqual({ kind: 'service', name: 'hint.write', args: ['u1'] });
39
+ });
40
+ });