@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,370 @@
1
+ import type { Uuid } from 'surrealdb';
2
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
3
+ import type { Sp00kyConfig, PersistenceClient } from '../types';
4
+ import type { Logger } from '../services/logger/index';
5
+ import { LogTap, createLogger } from '../services/logger/index';
6
+ import { ConnectionSupervisor, LocalMigrator, RemoteDatabaseService, createLocalEngine } from '../services/database/index';
7
+ import type { LocalStore } from '../services/database/index';
8
+ import { StreamProcessorService } from '../services/stream-processor/index';
9
+ import type { StreamUpdate } from '../services/stream-processor/index';
10
+ import { extractSelectPermissions } from '../services/stream-processor/permissions';
11
+ import { EventSystem } from '../events/index';
12
+ import { AuthService } from '../modules/auth/index';
13
+ import { CrdtManager } from '../modules/crdt/index';
14
+ import { preloadLoro } from '../modules/crdt/loro-loader';
15
+ import { LocalStoragePersistenceClient } from '../services/persistence/localstorage';
16
+ import { SurrealDBPersistenceClient } from '../services/persistence/surrealdb';
17
+ import { ResilientPersistenceClient } from '../services/persistence/resilient';
18
+ import { ANON_USER_ID } from '../modules/ref-tables';
19
+ import { detectSharedTabsSupport } from '../services/tabs/support';
20
+ import { TabsCoordinator, type CoordinatorHooks } from '../services/tabs/coordinator';
21
+ import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
22
+ import { computeTabsFingerprint, hash53 } from '../services/tabs/protocol';
23
+ import type { SqliteCacheEngine } from '../services/database/sqlite-cache-engine';
24
+ import type { BlobCache } from '../services/blobs/index';
25
+ import { MemoryBlobStore, createBlobCache, resolveBlobBudget } from '../services/blobs/index';
26
+ import type { Adapters } from '../kernel/interpreter';
27
+ import type { RuntimeEvent } from '../kernel/events';
28
+ import type { ServiceCalls } from '../kernel/effects';
29
+ import { encodeRecordId } from '../utils/index';
30
+ import { sha256Hex } from '../utils/sha256';
31
+ import { hashOfEdge, rowOfEdge } from '../utils/edges';
32
+ import { mintMutationId } from '../mutation/mutation-id';
33
+ import { BucketHandle, bucketContentToBlob } from './bucket-handle';
34
+
35
+ const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
36
+
37
+ export function readBootBucketHint(): string | null {
38
+ try {
39
+ return typeof localStorage !== 'undefined' ? localStorage.getItem(LAST_BUCKET_KEY) : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export function writeBootBucketHint(bucketId: string): void {
46
+ try {
47
+ if (typeof localStorage !== 'undefined') localStorage.setItem(LAST_BUCKET_KEY, bucketId);
48
+ } catch {
49
+ /* private-mode storage errors: boot falls back to the anon bucket */
50
+ }
51
+ }
52
+
53
+ // Moved to a pure module so `@spooky-sync/core/pure` and the live feed share
54
+ // it; re-exported here for existing imports.
55
+ export { sha256Hex };
56
+
57
+ export function mintSalt(): string {
58
+ const c = (globalThis as { crypto?: Crypto }).crypto;
59
+ if (c?.randomUUID) return c.randomUUID();
60
+ return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
61
+ }
62
+
63
+ /** Everything the facade talks to besides the runtime. */
64
+ export interface Services<S extends SchemaStructure> {
65
+ logger: Logger;
66
+ /** What the logger wrote, for the DevTools Logs tab. Absent in test fakes. */
67
+ logTap?: LogTap;
68
+ local: LocalStore;
69
+ remote: RemoteDatabaseService;
70
+ connectionSupervisor: ConnectionSupervisor;
71
+ blobs: BlobCache;
72
+ persistence: PersistenceClient;
73
+ streamProcessor: StreamProcessorService;
74
+ migrator: LocalMigrator;
75
+ crdt: CrdtManager;
76
+ auth: AuthService<S>;
77
+ tabs: TabsCoordinator | null;
78
+ tabsUnsupportedReason: string | undefined;
79
+ tabId: string;
80
+ }
81
+
82
+ /** What the services need back from the runtime once it exists. */
83
+ export interface ServiceHost {
84
+ dispatch(event: RuntimeEvent): void;
85
+ /** Called by the tabs coordinator hooks with the hub / forwarder to relay through. */
86
+ setTabsChannel(channel: { hub: LeaderSyncHub | null; forwarder: SyncForwarder | null }): void;
87
+ /** Late-bound modules the boot saga starts (feature flags, app releases). */
88
+ initModules(): void;
89
+ detachWindow(): void;
90
+ setDetachWindow(fn: () => void): void;
91
+ }
92
+
93
+ export function createServices<S extends SchemaStructure>(config: Sp00kyConfig<S>): Services<S> {
94
+ const logTap = new LogTap(config.logLevel ?? 'info');
95
+ const logger = createLogger(config.logLevel ?? 'info', config.otelTransmit, logTap);
96
+ if (config.crdt) void preloadLoro();
97
+ const tabsSupport = detectSharedTabsSupport(config);
98
+ const local = createLocalEngine(config.localEngine, config.database, logger, { shared: tabsSupport.supported });
99
+ const remote = new RemoteDatabaseService(config.database, logger);
100
+ const connectionSupervisor = new ConnectionSupervisor(remote, logger);
101
+ const rawBucket = (name: string) => new BucketHandle(name, remote, null, { blurhash: config.blurhash, logger });
102
+ const blobs = createBlobCache({
103
+ local,
104
+ namespace: ANON_USER_ID,
105
+ logger,
106
+ fetchRemote: async (key) => bucketContentToBlob(await rawBucket(key.bucket).get(key.path)),
107
+ headRemote: (key) => rawBucket(key.bucket).head(key.path),
108
+ maxBytes: config.blobCache?.maxBytes,
109
+ store: config.blobCache?.enabled === false ? new MemoryBlobStore(ANON_USER_ID) : undefined,
110
+ });
111
+ let persistence: PersistenceClient;
112
+ if (config.persistenceClient === 'surrealdb') persistence = new SurrealDBPersistenceClient(local, logger);
113
+ else if (config.persistenceClient === 'localstorage' || !config.persistenceClient) persistence = new LocalStoragePersistenceClient(logger);
114
+ else persistence = config.persistenceClient;
115
+ persistence = new ResilientPersistenceClient(persistence, logger);
116
+ const streamProcessor = new StreamProcessorService(new EventSystem(['stream_update']), local, logger);
117
+ streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === 'sqlite', config.circuitCheckpointMs);
118
+ streamProcessor.configureProjection(config.circuitProjection ?? true);
119
+ const migrator = new LocalMigrator(local, logger);
120
+ const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 500);
121
+ const auth = new AuthService(config.schema, remote, persistence, logger);
122
+ const tabId = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
123
+ return {
124
+ logger,
125
+ logTap,
126
+ local,
127
+ remote,
128
+ connectionSupervisor,
129
+ blobs,
130
+ persistence,
131
+ streamProcessor,
132
+ migrator,
133
+ crdt,
134
+ auth,
135
+ tabs: null,
136
+ tabsUnsupportedReason: tabsSupport.supported ? undefined : (tabsSupport as { reason: string }).reason,
137
+ tabId,
138
+ };
139
+ }
140
+
141
+ /** The shared-tabs coordinator, wired so role changes and relays become runtime events. */
142
+ export function createTabsCoordinator<S extends SchemaStructure>(config: Sp00kyConfig<S>, s: Services<S>, host: ServiceHost): TabsCoordinator {
143
+ const engine = s.local as SqliteCacheEngine;
144
+ const hooks: CoordinatorHooks = {
145
+ adoptOwner: (bucketId, opts) =>
146
+ engine.adoptOwner(bucketId, { workerLockName: opts.workerLockName, allowMemoryFallback: opts.allowMemoryFallback, resumeHeld: opts.resumeHeld }),
147
+ adoptAttached: (dbPort, snapshot) => engine.adoptAttached(dbPort, snapshot, (reason) => engine.onLeaderLost(reason)),
148
+ releaseOwnership: () => engine.releaseOwnership(),
149
+ onLeaderLost: (reason) => engine.onLeaderLost(reason),
150
+ exposeClientPort: (clientId, port) => engine.exposeClientPort(clientId, port),
151
+ removeClientPort: (clientId) => engine.removeClientPort(clientId),
152
+ becomeSyncLeader: (hub) => {
153
+ s.streamProcessor.setPersistenceEnabled(true);
154
+ hub.onFollowerMessage = (_tabId, msg) => host.dispatch({ type: 'TabMessage', message: followerToRuntime(msg) });
155
+ host.setTabsChannel({ hub, forwarder: null });
156
+ host.dispatch({ type: 'TabRole', role: 'leader' });
157
+ },
158
+ resumeSyncLeaderDuties: async () => {
159
+ host.dispatch({ type: 'Drain' });
160
+ host.dispatch({ type: 'LiveStart' });
161
+ },
162
+ becomeSyncFollower: (forwarder) => {
163
+ s.streamProcessor.setPersistenceEnabled(false);
164
+ forwarder.onLeaderMessage = (msg) => host.dispatch({ type: 'TabMessage', message: leaderToRuntime(msg) });
165
+ host.setTabsChannel({ hub: null, forwarder });
166
+ host.dispatch({ type: 'TabRole', role: 'follower' });
167
+ },
168
+ becomeSyncSolo: () => {
169
+ s.streamProcessor.setPersistenceEnabled(true);
170
+ host.setTabsChannel({ hub: null, forwarder: null });
171
+ host.dispatch({ type: 'TabRole', role: 'solo' });
172
+ },
173
+ currentStorageHealth: () => s.local.storageHealth ?? { status: 'unknown', fallback: false },
174
+ };
175
+ return new TabsCoordinator({
176
+ tabId: s.tabId,
177
+ fingerprint: computeTabsFingerprint({
178
+ coreVersion: typeof __SP00KY_CORE_VERSION__ !== 'undefined' ? __SP00KY_CORE_VERSION__ : 'unknown',
179
+ schemaHash: hash53(config.schemaSurql),
180
+ endpoint: config.database.endpoint ?? '',
181
+ namespace: config.database.namespace,
182
+ database: config.database.database,
183
+ }),
184
+ hooks,
185
+ logger: s.logger,
186
+ onLeaderPageHide: () => void engine.shutdownOwnedWorker(),
187
+ });
188
+ }
189
+
190
+ /** Wire-protocol messages become the runtime's tab messages. */
191
+ export function followerToRuntime(msg: { type: string; [k: string]: unknown }): unknown {
192
+ switch (msg.type) {
193
+ case 'mutation-enqueued':
194
+ return { type: 'outbox-changed', mutationId: msg.mutationId };
195
+ case 'ingest':
196
+ return { type: 'ingest', records: msg.tuples };
197
+ case 'request-poll':
198
+ return { type: 'membership-dirty', hashes: [] };
199
+ default:
200
+ return msg;
201
+ }
202
+ }
203
+
204
+ export function leaderToRuntime(msg: { type: string; [k: string]: unknown }): unknown {
205
+ switch (msg.type) {
206
+ case 'ingest-relay':
207
+ return { type: 'ingest', records: msg.tuples };
208
+ default:
209
+ return msg;
210
+ }
211
+ }
212
+
213
+ // Edge decoding lives in a pure module (the wasm-free live feed uses it too);
214
+ // re-exported here for existing imports.
215
+ export { hashOfEdge, rowOfEdge };
216
+
217
+ /** Build the adapters the interpreter drives from the services. */
218
+ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S>, s: Services<S>, host: ServiceHost, lateModules: () => { init(): void }[]): Adapters {
219
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
220
+ const services: ServiceCalls = {
221
+ 'hint.read': readBootBucketHint,
222
+ 'hint.write': writeBootBucketHint,
223
+ 'local.connect': (bucketId) => s.local.connect(bucketId),
224
+ 'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
225
+ 'local.beginSwitch': () => s.local.beginSwitch(),
226
+ 'local.currentBucketId': () => s.local.currentBucketId,
227
+ 'local.usesSurqlSchema': () => s.local.usesSurqlSchema,
228
+ 'local.dropBucket': async (bucketId) => {
229
+ await s.local.dropBucket?.(bucketId);
230
+ },
231
+ 'migrator.provision': () => s.migrator.provision(config.schemaSurql),
232
+ 'blobs.start': async (bucketId) => {
233
+ s.blobs.setMaxBytes(await resolveBlobBudget(config.blobCache?.maxBytes));
234
+ await s.blobs.start(bucketId);
235
+ },
236
+ 'blobs.setNamespace': (bucketId) => s.blobs.setNamespace(bucketId),
237
+ 'blobs.clear': () => s.blobs.clear(),
238
+ 'ssp.init': () => s.streamProcessor.init(),
239
+ 'ssp.setPermissions': () => s.streamProcessor.setPermissions(extractSelectPermissions(config.schemaSurql)),
240
+ 'ssp.setSessionAuth': (authId, access) => s.streamProcessor.setSessionAuth(authId, access),
241
+ 'ssp.prime': (pendingIds) =>
242
+ s.streamProcessor.primeFromLocal({
243
+ tables: [...config.schema.tables.map((t) => t.name), '_00_user_feature', '_00_app_release'],
244
+ schemaHash: String(hash53(config.schemaSurql)),
245
+ pendingIds: new Set(pendingIds),
246
+ onVersions: (_table, entries) => host.dispatch({ type: 'VersionsPrimed', entries }),
247
+ }),
248
+ 'ssp.reset': () => s.streamProcessor.reset(),
249
+ 'ssp.setPersistence': (enabled) => s.streamProcessor.setPersistenceEnabled(enabled),
250
+ 'auth.restoreSession': () => s.auth.restoreSessionFromToken(),
251
+ 'auth.init': () => s.auth.init(),
252
+ 'auth.sessionAuthId': () => {
253
+ const id = s.auth.currentUser?.id;
254
+ if (!id) return null;
255
+ return typeof id === 'string' ? id : encodeRecordId(id);
256
+ },
257
+ 'auth.access': () => s.auth.access,
258
+ 'auth.token': () => s.auth.token,
259
+ 'auth.currentUser': () => (s.auth.currentUser as Record<string, unknown> | null) ?? null,
260
+ 'auth.consumeEndedImpersonation': () => s.auth.consumeEndedImpersonation(),
261
+ 'remote.connect': () => s.remote.connect(),
262
+ 'remote.releaseViews': (ids) => {
263
+ const list = ids
264
+ .map((id) => String(id))
265
+ .filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id))
266
+ .join(', ');
267
+ if (list) s.remote.beaconSql(`FOR $id IN [${list}] { LET $_released = fn::query::unsubscribe($id); };`);
268
+ },
269
+ 'supervisor.start': () => s.connectionSupervisor.start(),
270
+ 'tabs.start': (bucketId) => {
271
+ if (!s.tabs) throw new Error('shared tabs unavailable');
272
+ return s.tabs.start(bucketId);
273
+ },
274
+ 'tabs.moveToBucket': (bucketId) => {
275
+ if (!s.tabs) throw new Error('shared tabs unavailable');
276
+ return s.tabs.moveToBucket(bucketId);
277
+ },
278
+ 'crdt.setSessionId': (id) => s.crdt.setSessionId(id),
279
+ 'crdt.closeAll': (flush) => s.crdt.closeAll({ flush }),
280
+ 'persistence.set': (key, value) => s.persistence.set(key, value),
281
+ 'features.init': () => {
282
+ for (const m of lateModules()) m.init();
283
+ },
284
+ 'releases.init': () => undefined,
285
+ 'window.attach': () => {
286
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
287
+ // Hidden is the last moment a page reliably gets to run: a reload or a
288
+ // closed tab follows it, and a backgrounded mobile tab may be killed
289
+ // without any further event. Pending debounced writes go out then.
290
+ const onPageHide = (event: PageTransitionEvent) => {
291
+ host.dispatch({ type: 'FlushPendingWrites' });
292
+ if (event.persisted) return;
293
+ host.dispatch({ type: 'PageHide' });
294
+ };
295
+ const onWake = () => {
296
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
297
+ host.dispatch({ type: 'HeartbeatNow' });
298
+ };
299
+ const onVisibility = () => {
300
+ if (document.visibilityState === 'hidden') host.dispatch({ type: 'FlushPendingWrites' });
301
+ else onWake();
302
+ };
303
+ window.addEventListener('pagehide', onPageHide);
304
+ window.addEventListener('online', onWake);
305
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
306
+ host.setDetachWindow(() => {
307
+ window.removeEventListener('pagehide', onPageHide);
308
+ window.removeEventListener('online', onWake);
309
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
310
+ });
311
+ },
312
+ };
313
+ return {
314
+ local: s.local,
315
+ remote: {
316
+ queryResponses: (sql, vars) => s.remote.queryResponses(sql, vars),
317
+ live: async (table, onChange) => {
318
+ // `FETCH out` resolves the edge's target into the notification, so the
319
+ // body arrives with the doorbell instead of costing a second round
320
+ // trip. The clause is only ever added, never relied on: a server that
321
+ // ignores it, or a row the session cannot read, simply yields no
322
+ // inline row and the fetch path takes over.
323
+ const join = config.liveInlineBodies ? ' FETCH out' : '';
324
+ const [uuid] = await s.remote.query<[Uuid]>(`LIVE SELECT * FROM ${table}${join}`);
325
+ const live = await s.remote.getClient().liveOf(uuid);
326
+ live.subscribe((message) => {
327
+ if (message.action === 'KILLED') return;
328
+ const hash = hashOfEdge(message.value);
329
+ if (!hash) return;
330
+ // Not on DELETE: that edge is a row leaving the view, and for a row
331
+ // that was actually deleted the join yields nothing anyway. Landing
332
+ // a body there would only ever write back what is going away.
333
+ const row = message.action === 'DELETE' ? null : rowOfEdge(message.value);
334
+ onChange([hash], row ? [row] : undefined);
335
+ });
336
+ return String(uuid);
337
+ },
338
+ kill: async (uuid) => {
339
+ await s.remote.query('KILL $u', { u: uuid });
340
+ },
341
+ },
342
+ ssp: {
343
+ registerQueryPlan: (plan) => s.streamProcessor.registerQueryPlan(plan) as StreamUpdate | undefined,
344
+ unregisterQueryPlan: (hash) => s.streamProcessor.unregisterQueryPlan(hash),
345
+ ingestMany: (records) => s.streamProcessor.ingestMany(records),
346
+ },
347
+ timers: {
348
+ set: (key, ms, fire) => {
349
+ const prev = timers.get(key);
350
+ if (prev) clearTimeout(prev);
351
+ timers.set(
352
+ key,
353
+ setTimeout(() => {
354
+ timers.delete(key);
355
+ fire();
356
+ }, ms)
357
+ );
358
+ },
359
+ clear: (key) => {
360
+ const prev = timers.get(key);
361
+ if (prev) clearTimeout(prev);
362
+ timers.delete(key);
363
+ },
364
+ },
365
+ clock: { now: () => Date.now() },
366
+ ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt },
367
+ hash: sha256Hex,
368
+ services,
369
+ };
370
+ }
@@ -0,0 +1,303 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { Sp00kyClient } from './sp00ky-client';
4
+ import { Runtime } from './runtime';
5
+ import { fakeAdapters } from '../testing/adapters';
6
+ import { fakeServiceBundle } from '../testing/fake-services';
7
+ import { defaultEnv } from '../query/env';
8
+ import type { StatementResult } from '../kernel/effects';
9
+
10
+ const schema = { tables: [{ name: 'thing', columns: { a: {} } }], backends: { api: { outboxTable: 'thing', routes: { go: { args: {} } } } } } as any;
11
+ const config = { database: { namespace: 'n', database: 'd' }, schema, schemaSurql: '', logLevel: 'silent' } as any;
12
+ const ok = (result: unknown): StatementResult => ({ status: 'OK', result });
13
+ const tick = () => new Promise((r) => setTimeout(r, 0));
14
+ const flush = async (n = 6) => {
15
+ for (let i = 0; i < n; i++) await tick();
16
+ };
17
+
18
+ function makeClient(over: Parameters<typeof fakeAdapters>[0] = {}) {
19
+ const services = fakeServiceBundle<any>();
20
+ const a = fakeAdapters({
21
+ services: {
22
+ 'hint.read': () => 'u1',
23
+ 'local.usesSurqlSchema': () => false,
24
+ 'auth.restoreSession': async () => null,
25
+ 'auth.sessionAuthId': () => null,
26
+ 'remote.connect': () => new Promise(() => {}),
27
+ ...over.services,
28
+ },
29
+ ...over,
30
+ });
31
+ const runtime = new Runtime({ env: defaultEnv(schema, { materializeDebounceMs: 1 }), adapters: a.adapters, logger: services.logger, tabId: 'tab-test' });
32
+ const client = new Sp00kyClient<any>(config, { services, runtime });
33
+ return { client, a, services, runtime };
34
+ }
35
+
36
+ describe('Sp00kyClient facade', () => {
37
+ it('hands DevTools the outbox and the failed tray', async () => {
38
+ const { client } = makeClient();
39
+ await client.init();
40
+ const source = (client as any).devTools.mutationSource;
41
+ expect(source.state()).toBe(client.state);
42
+ expect(await source.listFailed()).toEqual([]);
43
+ expect(await source.retryFailed('_00_pending_mutations:gone')).toBe(false);
44
+ expect(await source.discardFailed('_00_pending_mutations:gone')).toBe(false);
45
+ });
46
+
47
+ it('logs each finished statement at debug, and nothing below it', () => {
48
+ const { services } = makeClient();
49
+ const lines: unknown[] = [];
50
+ const log = services.logger as any;
51
+ log.debug = (fields: unknown, msg: string) => lines.push([fields, msg]);
52
+ log.isLevelEnabled = () => true;
53
+ const long = `SELECT * FROM thing WHERE ${'a = 1 AND '.repeat(30)}b = 2`;
54
+ services.emitStoreEvent('DATABASE_LOCAL_QUERY', { query: 'SELECT\n 1', vars: {}, duration: 1.234, success: true });
55
+ services.emitStoreEvent('DATABASE_REMOTE_QUERY', { query: long, duration: 3, success: false, error: 'boom' });
56
+ services.emitStoreEvent('DATABASE_LOCAL_QUERY', undefined);
57
+ log.isLevelEnabled = () => false;
58
+ services.emitStoreEvent('DATABASE_LOCAL_QUERY', { query: 'SELECT 3' });
59
+ expect(lines).toEqual([
60
+ [expect.objectContaining({ store: 'local', ms: 1.2, ok: true }), 'SELECT 1'],
61
+ [expect.objectContaining({ store: 'remote', query: long, ok: false, error: 'boom' }), `${long.slice(0, 160)}…`],
62
+ [expect.objectContaining({ store: 'local', ms: 0, ok: true }), ''],
63
+ ]);
64
+ expect((lines[0] as any[])[0].query).toBeUndefined();
65
+ });
66
+
67
+ it('init resolves from the local store while the remote connect never does', async () => {
68
+ const { client, a } = makeClient();
69
+ expect(client.isLocalReady()).toBe(false);
70
+ await client.init();
71
+ expect(client.isLocalReady()).toBe(true);
72
+ await flush();
73
+ expect(a.names()).toContain('service.remote.connect');
74
+ expect(a.names().indexOf('service.local.connect')).toBeLessThan(a.names().indexOf('service.remote.connect'));
75
+ expect(client.syncHealth.status).toBe('healthy');
76
+ expect(client.storageHealth).toEqual({ status: 'unknown', fallback: false });
77
+ expect(client.tabRole).toBeNull();
78
+ expect(client.liveRetryCount).toBe(0);
79
+ });
80
+
81
+ it('queryRaw registers locally and returns the hash before any remote work; subscribers paint from the local store', async () => {
82
+ // Boot loads the view index: `thing:1` is vouched for, `thing:2` is an orphan and stays hidden.
83
+ const stored = (sql: string) =>
84
+ sql.startsWith('SELECT * FROM thing')
85
+ ? [[{ id: 'thing:1' }, { id: 'thing:2' }]]
86
+ : sql === 'SELECT * FROM _00_view'
87
+ ? [[{ id: '_00_view:k', ids: [['thing:1', 1]] }]]
88
+ : [];
89
+ const { client, a } = makeClient({ local: { query: (async (sql: string) => stored(sql)) as any } });
90
+ await client.init();
91
+ const hash = await client.queryRaw('SELECT * FROM thing', {}, '10m');
92
+ expect(client.state.queries.get(hash)!.lifecycle.phase).toBe('cold');
93
+ const rows: unknown[] = [];
94
+ const off = await client.subscribe(hash, (r) => rows.push(r), { immediate: true });
95
+ a.timers.fire(`mat:${hash}`);
96
+ await flush();
97
+ expect(rows.at(-1)).toEqual([{ id: 'thing:1' }]);
98
+ const statuses: string[] = [];
99
+ client.subscribeQueryStatus(hash, (s) => statuses.push(s), { immediate: true });
100
+ const auth: boolean[] = [];
101
+ client.subscribeQueryAuthority(hash, (k) => auth.push(k), { immediate: true });
102
+ expect(statuses[0]).toBeDefined();
103
+ expect(auth).toEqual([false]);
104
+ client.reportFrontendTiming(hash, 3);
105
+ client.reportFrontendTiming(hash, Number.NaN);
106
+ expect(client.state.queries.get(hash)!.telemetry.phaseLast.frontend).toBe(3);
107
+ off();
108
+ client.deregisterQuery(hash);
109
+ await flush();
110
+ expect(client.state.queries.has(hash)).toBe(false);
111
+ client.deregisterQuery('missing');
112
+ });
113
+
114
+ it('create writes locally, mirrors the outbox and reports activity; the tray API reads state', async () => {
115
+ const { client } = makeClient({
116
+ local: {
117
+ execute: (async () => ({ id: new RecordId('thing', '1'), a: 1 })) as any,
118
+ // The drain reads the pending rows back; hand it delete rows so the (empty) push answer keeps them queued.
119
+ query: (async (sql: string, vars: any) =>
120
+ sql === 'SELECT * FROM $ids' && String(vars.ids[0]).startsWith('_00_pending_mutations')
121
+ ? [vars.ids.map((id: unknown) => ({ id: String(id), mutationType: 'delete', recordId: 'thing:1' }))]
122
+ : []) as any,
123
+ },
124
+ });
125
+ await client.init();
126
+ const counts: number[] = [];
127
+ const off = client.subscribeToPendingMutations((n) => counts.push(n));
128
+ const created = await client.create('thing:1', { a: 1 });
129
+ expect(created).toEqual({ id: new RecordId('thing', '1'), a: 1 });
130
+ expect(client.pendingMutationCount).toBe(1);
131
+ expect(counts).toEqual([0, 1]);
132
+ off();
133
+ const updated = await client.update('thing', 'thing:1', { a: 2 });
134
+ expect(updated).toEqual({ a: 2 });
135
+ await client.delete('thing', 'thing:1');
136
+ await client.run('api', 'go', {} as never);
137
+ expect(client.state.outbox).toHaveLength(4);
138
+ const fetching: number[] = [];
139
+ client.subscribeToFetchActivity((n) => fetching.push(n));
140
+ expect(fetching).toEqual([0]);
141
+ const tray: number[] = [];
142
+ client.subscribeToFailedMutations((n) => tray.push(n));
143
+ expect(tray).toEqual([0]);
144
+ expect(client.failedMutationCount).toBe(0);
145
+ expect(await client.listFailedMutations()).toEqual([]);
146
+ expect(await client.retryFailedMutation('nope')).toBe(false);
147
+ expect(await client.discardFailedMutation('nope')).toBe(false);
148
+ const health: unknown[] = [];
149
+ client.subscribeToSyncHealth((h) => health.push(h.status));
150
+ expect(health).toEqual([client.syncHealth.status]);
151
+ let stored = 0;
152
+ client.subscribeToStorageHealth(() => stored++);
153
+ expect(stored).toBe(1);
154
+ });
155
+
156
+ it('preload: cold waits for membership + bodies; cached returns at once; abort rejects', async () => {
157
+ const edges = [{ out: new RecordId('thing', '1'), version: 1 }];
158
+ const { client, a } = makeClient({
159
+ remote: {
160
+ queryResponses: async (sql: string) => {
161
+ if (sql.startsWith('fn::query::register')) return [ok(null), ok(edges), ok({ rowCount: 1, state: 'ready' }), ok([])];
162
+ if (sql.startsWith('SELECT * FROM $ids')) return [ok([{ id: new RecordId('thing', '1'), a: 1 }])];
163
+ return [ok(null)];
164
+ },
165
+ },
166
+ local: { select: (async () => [{ id: 'thing:1' }]) as any },
167
+ });
168
+ await client.init();
169
+ a.timers.fire('lifecycle');
170
+ const q = { innerQuery: { tableName: 'thing', selectQuery: { query: 'SELECT * FROM thing', vars: {}, plan: { table: 'thing' } } } } as any;
171
+ const done = client.preload(q).then(() => 'settled');
172
+ await flush(10);
173
+ for (const key of [...a.timers.pending.keys()]) if (key.startsWith('mat:')) a.timers.fire(key);
174
+ await flush(10);
175
+ expect(await done).toBe('settled');
176
+ const hash = [...client.state.queries.keys()][0];
177
+ expect(client.state.queries.get(hash)!.lifecycle.phase).toBe('live');
178
+ await expect(client.preload(q)).resolves.toBeUndefined();
179
+ const ctrl = new AbortController();
180
+ const q2 = { innerQuery: { tableName: 'thing', selectQuery: { query: 'SELECT * FROM thing WHERE a = 1', vars: {} } } } as any;
181
+ const aborted = client.preload(q2, { signal: ctrl.signal });
182
+ await flush(4);
183
+ ctrl.abort();
184
+ await expect(aborted).rejects.toThrow('aborted');
185
+ });
186
+
187
+ it('adapter callbacks become runtime events; tabs relays reach the hub/forwarder; close tears down', async () => {
188
+ const { client, services, runtime } = makeClient();
189
+ await client.init();
190
+ const seen: string[] = [];
191
+ runtime.on('*', (e) => seen.push(e.type));
192
+ services.receivers.forEach((r: any) => r.onStreamUpdate?.({ queryHash: 'zz', localArray: [] }));
193
+ services.connectionListeners.forEach((cb) => cb('connected'));
194
+ services.authListeners.forEach((cb) => cb(null));
195
+ await flush();
196
+ const sent: unknown[] = [];
197
+ (client as any).hub = { relayIngest: (t: unknown) => sent.push(['relay', t]), broadcast: (m: unknown) => sent.push(['bcast', m]), sendTo: (id: string, m: unknown) => sent.push(['to', id, m]) };
198
+ runtime.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: [] } });
199
+ runtime.emit({ type: 'tabs:broadcast', message: { type: 'mutation-settled' } });
200
+ runtime.emit({ type: 'tabs:sendTo', tabId: 'x', message: { type: 'mutation-rolled-back' } });
201
+ (client as any).hub = null;
202
+ (client as any).forwarder = { ingest: (t: unknown) => sent.push(['f-ingest', t]), mutationEnqueued: (id: string) => sent.push(['f-enq', id]) };
203
+ runtime.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: [] } });
204
+ runtime.emit({ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId: 'm' } });
205
+ runtime.emit({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: [] } });
206
+ expect(sent).toEqual([['relay', []], ['bcast', { type: 'mutation-settled' }], ['to', 'x', { type: 'mutation-rolled-back' }], ['f-ingest', []], ['f-enq', 'm']]);
207
+ runtime.emit({ type: 'query:status', hash: 'h', status: 'idle' });
208
+ runtime.emit({ type: 'query:authority', hash: 'h', known: true });
209
+ runtime.emit({ type: 'query:view-lost', hash: 'h' });
210
+ runtime.emit({ type: 'mutation:event', event: { type: 'create', record_id: new RecordId('thing', '1'), data: {} } });
211
+ runtime.emit({ type: 'mutation:settled', mutationId: 'm', recordId: 'thing:1', eventType: 'create' });
212
+ runtime.emit({ type: 'mutation:rolled-back', mutationId: 'm2', recordId: 'thing:1', eventType: 'update', error: 'denied' });
213
+ runtime.emit({ type: 'tray:changed', count: 1 });
214
+ expect(await client.authenticate('tok')).toBe('tok');
215
+ await client.deauthenticate();
216
+ expect(await client.useRemote(async () => 'r')).toBe('r');
217
+ expect(await client.remoteQuery('RETURN 1')).toEqual(['remote']);
218
+ expect(client.remoteClient).toBeDefined();
219
+ expect(client.localClient).toBe('local-client');
220
+ expect(client.getBlobCacheStats()).toEqual({ entries: 0 });
221
+ expect(client.bucket('files')).toBeDefined();
222
+ expect(await client.openCrdtField('t', 'r', 'f')).toBe('field');
223
+ client.closeCrdtField('t', 'r', 'f');
224
+ expect(client.getFeatureOverrides()).toEqual({});
225
+ client.setFeatureOverride('k', 'v');
226
+ client.clearFeatureOverrides();
227
+ expect(client.feature('k')).toBeDefined();
228
+ expect(client.appRelease('web')).toBeDefined();
229
+ await client.close();
230
+ expect(client.state.localReady).toBe(true);
231
+ });
232
+ });
233
+
234
+ describe('Sp00kyClient facade (builder, devtools source, event fan-out)', () => {
235
+ it('query() builds through the QueryBuilder and registers locally; dispatch feeds the engine; DevTools reads state', async () => {
236
+ const { client, runtime } = makeClient();
237
+ await client.init();
238
+ const { hash } = await (client.query('thing', {} as any) as any).build().run();
239
+ expect(client.state.queries.has(hash)).toBe(true);
240
+ const source = (client as any).devTools.dataManager;
241
+ expect(source.getActiveQueries().map((q: any) => q.config.surql)).toEqual(['SELECT * FROM thing;']);
242
+ const entry = client.state.queries.get(hash)!;
243
+ expect(source.getQueryById(entry.def.id)!.config.membershipKey).toBe(entry.def.viewKey);
244
+ expect(source.getQueryById({ id: 'nope', table: '_00_query' } as any)).toBeUndefined();
245
+ expect(source.phaseTimings(source.getActiveQueries()[0]).updateCount).toBe(0);
246
+ expect(source.phaseTimings({ config: { id: { id: 'nope' } } } as any)).toEqual({});
247
+ await client.dispatch({ type: 'PollTick' });
248
+ await expect((client.query('nope' as any, {} as any) as any).build().run()).rejects.toThrow('Table nope not found');
249
+ const tray: number[] = [];
250
+ const fetching: number[] = [];
251
+ const health: string[] = [];
252
+ client.subscribeToFailedMutations((n) => tray.push(n));
253
+ client.subscribeToFetchActivity((n) => fetching.push(n));
254
+ client.subscribeToSyncHealth((h) => health.push(h.status));
255
+ runtime.emit({ type: 'tray:changed', count: 2 });
256
+ runtime.emit({ type: 'activity:changed', fetching: 3, pending: 0 });
257
+ runtime.emit({ type: 'health:changed', health: { ...client.syncHealth, status: 'degraded' } });
258
+ runtime.emit({ type: 'query:evicted', hash });
259
+ expect(tray).toEqual([0, 2]);
260
+ expect(fetching).toEqual([0, 3]);
261
+ expect(health).toEqual(['healthy', 'degraded']);
262
+ const pending: number[] = [];
263
+ client.subscribeToPendingMutations((n) => pending.push(n));
264
+ runtime.emit({ type: 'activity:changed', fetching: 0, pending: 4 });
265
+ expect(pending).toEqual([0, 4]);
266
+ });
267
+ it('gates queryRaw/useRemote/remoteQuery behind allowRawRemote when the schema policy turns the allowlist on', async () => {
268
+ const services = fakeServiceBundle<any>();
269
+ const a = fakeAdapters({});
270
+ const runtime = new Runtime({ env: defaultEnv(schema), adapters: a.adapters, logger: services.logger, tabId: 't' });
271
+ const withPolicy = (mode: string | undefined, allowRawRemote?: boolean) =>
272
+ new Sp00kyClient<any>(
273
+ { ...config, schema: mode === undefined ? schema : { ...schema, policy: { queryAllowlist: mode } }, allowRawRemote } as any,
274
+ { services, runtime }
275
+ );
276
+ const msg = (m: string) => `sp00ky: ${m} is disabled because sync.queryAllowlist is on for this schema; pass allowRawRemote: true in the client config to opt in`;
277
+ for (const mode of ['warn', 'enforce']) {
278
+ const gated = withPolicy(mode);
279
+ await expect(gated.queryRaw('SELECT * FROM thing', {}, '10m')).rejects.toThrow(msg('queryRaw'));
280
+ await expect(gated.useRemote(() => 1)).rejects.toThrow(msg('useRemote'));
281
+ await expect(gated.remoteQuery('RETURN 1')).rejects.toThrow(msg('remoteQuery'));
282
+ expect(a.names().filter((n) => n === 'remote.query')).toEqual([]);
283
+ }
284
+ for (const client of [withPolicy('enforce', true), withPolicy('off'), withPolicy(undefined)]) {
285
+ await expect(client.useRemote(() => 1)).resolves.toBe(1);
286
+ await expect(client.remoteQuery('RETURN 1')).resolves.toBeDefined();
287
+ }
288
+ });
289
+
290
+ it('honours config knobs when building the saga env', () => {
291
+ const services = fakeServiceBundle<any>();
292
+ const a = fakeAdapters();
293
+ const runtime = new Runtime({ env: defaultEnv(schema), adapters: a.adapters, logger: services.logger, tabId: 't' });
294
+ const tuned = new Sp00kyClient<any>(
295
+ { ...config, database: { ...config.database, queryTimeoutMs: 5 }, syncHealth: false, pushTimeoutMs: 7, refSyncIntervalMs: 9, streamDebounceTime: 11, enableAnonymousLiveQueries: true, sharedTabs: true } as any,
296
+ { services, runtime, env: { defaultTtlMs: 1 } }
297
+ );
298
+ expect((tuned as any).env).toMatchObject({ remoteTimeoutMs: 5, degradeAfter: 0, pushTimeoutMs: 7, pollBaseMs: 9, materializeDebounceMs: 11, anonLive: true, defaultTtlMs: 1 });
299
+ const degraded = new Sp00kyClient<any>({ ...config, syncHealth: { degradeAfterConsecutiveFailures: 2 } } as any, { services, runtime });
300
+ expect((degraded as any).env.degradeAfter).toBe(2);
301
+ expect(tuned.tabRole).toBeNull();
302
+ });
303
+ });