@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,264 @@
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('init resolves from the local store while the remote connect never does', async () => {
38
+ const { client, a } = makeClient();
39
+ expect(client.isLocalReady()).toBe(false);
40
+ await client.init();
41
+ expect(client.isLocalReady()).toBe(true);
42
+ await flush();
43
+ expect(a.names()).toContain('service.remote.connect');
44
+ expect(a.names().indexOf('service.local.connect')).toBeLessThan(a.names().indexOf('service.remote.connect'));
45
+ expect(client.syncHealth.status).toBe('healthy');
46
+ expect(client.storageHealth).toEqual({ status: 'unknown', fallback: false });
47
+ expect(client.tabRole).toBeNull();
48
+ expect(client.liveRetryCount).toBe(0);
49
+ });
50
+
51
+ it('queryRaw registers locally and returns the hash before any remote work; subscribers paint from the local store', async () => {
52
+ const { client, a } = makeClient({ local: { query: (async (sql: string) => (sql.startsWith('SELECT * FROM thing') ? [[{ id: 'thing:1' }]] : [])) as any } });
53
+ await client.init();
54
+ const hash = await client.queryRaw('SELECT * FROM thing', {}, '10m');
55
+ expect(client.state.queries.get(hash)!.lifecycle.phase).toBe('cold');
56
+ const rows: unknown[] = [];
57
+ const off = await client.subscribe(hash, (r) => rows.push(r), { immediate: true });
58
+ a.timers.fire(`mat:${hash}`);
59
+ await flush();
60
+ expect(rows.at(-1)).toEqual([{ id: 'thing:1' }]);
61
+ const statuses: string[] = [];
62
+ client.subscribeQueryStatus(hash, (s) => statuses.push(s), { immediate: true });
63
+ const auth: boolean[] = [];
64
+ client.subscribeQueryAuthority(hash, (k) => auth.push(k), { immediate: true });
65
+ expect(statuses[0]).toBeDefined();
66
+ expect(auth).toEqual([false]);
67
+ client.reportFrontendTiming(hash, 3);
68
+ client.reportFrontendTiming(hash, Number.NaN);
69
+ expect(client.state.queries.get(hash)!.telemetry.phaseLast.frontend).toBe(3);
70
+ off();
71
+ client.deregisterQuery(hash);
72
+ await flush();
73
+ expect(client.state.queries.has(hash)).toBe(false);
74
+ client.deregisterQuery('missing');
75
+ });
76
+
77
+ it('create writes locally, mirrors the outbox and reports activity; the tray API reads state', async () => {
78
+ const { client } = makeClient({
79
+ local: {
80
+ execute: (async () => ({ id: new RecordId('thing', '1'), a: 1 })) as any,
81
+ // The drain reads the pending rows back; hand it delete rows so the (empty) push answer keeps them queued.
82
+ query: (async (sql: string, vars: any) =>
83
+ sql === 'SELECT * FROM $ids' && String(vars.ids[0]).startsWith('_00_pending_mutations')
84
+ ? [vars.ids.map((id: unknown) => ({ id: String(id), mutationType: 'delete', recordId: 'thing:1' }))]
85
+ : []) as any,
86
+ },
87
+ });
88
+ await client.init();
89
+ const counts: number[] = [];
90
+ const off = client.subscribeToPendingMutations((n) => counts.push(n));
91
+ const created = await client.create('thing:1', { a: 1 });
92
+ expect(created).toEqual({ id: new RecordId('thing', '1'), a: 1 });
93
+ expect(client.pendingMutationCount).toBe(1);
94
+ expect(counts).toEqual([0, 1]);
95
+ off();
96
+ const updated = await client.update('thing', 'thing:1', { a: 2 });
97
+ expect(updated).toEqual({ a: 2 });
98
+ await client.delete('thing', 'thing:1');
99
+ await client.run('api', 'go', {} as never);
100
+ expect(client.state.outbox).toHaveLength(4);
101
+ const fetching: number[] = [];
102
+ client.subscribeToFetchActivity((n) => fetching.push(n));
103
+ expect(fetching).toEqual([0]);
104
+ const tray: number[] = [];
105
+ client.subscribeToFailedMutations((n) => tray.push(n));
106
+ expect(tray).toEqual([0]);
107
+ expect(client.failedMutationCount).toBe(0);
108
+ expect(await client.listFailedMutations()).toEqual([]);
109
+ expect(await client.retryFailedMutation('nope')).toBe(false);
110
+ expect(await client.discardFailedMutation('nope')).toBe(false);
111
+ const health: unknown[] = [];
112
+ client.subscribeToSyncHealth((h) => health.push(h.status));
113
+ expect(health).toEqual([client.syncHealth.status]);
114
+ let stored = 0;
115
+ client.subscribeToStorageHealth(() => stored++);
116
+ expect(stored).toBe(1);
117
+ });
118
+
119
+ it('preload: cold waits for membership + bodies; cached returns at once; abort rejects', async () => {
120
+ const edges = [{ out: new RecordId('thing', '1'), version: 1 }];
121
+ const { client, a } = makeClient({
122
+ remote: {
123
+ queryResponses: async (sql: string) => {
124
+ if (sql.startsWith('fn::query::register')) return [ok(null), ok(edges), ok({ rowCount: 1, state: 'ready' }), ok([])];
125
+ if (sql.startsWith('SELECT * FROM $ids')) return [ok([{ id: new RecordId('thing', '1'), a: 1 }])];
126
+ return [ok(null)];
127
+ },
128
+ },
129
+ local: { select: (async () => [{ id: 'thing:1' }]) as any },
130
+ });
131
+ await client.init();
132
+ a.timers.fire('lifecycle');
133
+ const q = { innerQuery: { tableName: 'thing', selectQuery: { query: 'SELECT * FROM thing', vars: {}, plan: { table: 'thing' } } } } as any;
134
+ const done = client.preload(q).then(() => 'settled');
135
+ await flush(10);
136
+ for (const key of [...a.timers.pending.keys()]) if (key.startsWith('mat:')) a.timers.fire(key);
137
+ await flush(10);
138
+ expect(await done).toBe('settled');
139
+ const hash = [...client.state.queries.keys()][0];
140
+ expect(client.state.queries.get(hash)!.lifecycle.phase).toBe('live');
141
+ await expect(client.preload(q)).resolves.toBeUndefined();
142
+ const ctrl = new AbortController();
143
+ const q2 = { innerQuery: { tableName: 'thing', selectQuery: { query: 'SELECT * FROM thing WHERE a = 1', vars: {} } } } as any;
144
+ const aborted = client.preload(q2, { signal: ctrl.signal });
145
+ await flush(4);
146
+ ctrl.abort();
147
+ await expect(aborted).rejects.toThrow('aborted');
148
+ });
149
+
150
+ it('adapter callbacks become runtime events; tabs relays reach the hub/forwarder; close tears down', async () => {
151
+ const { client, services, runtime } = makeClient();
152
+ await client.init();
153
+ const seen: string[] = [];
154
+ runtime.on('*', (e) => seen.push(e.type));
155
+ services.receivers.forEach((r: any) => r.onStreamUpdate?.({ queryHash: 'zz', localArray: [] }));
156
+ services.connectionListeners.forEach((cb) => cb('connected'));
157
+ services.authListeners.forEach((cb) => cb(null));
158
+ await flush();
159
+ const sent: unknown[] = [];
160
+ (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]) };
161
+ runtime.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: [] } });
162
+ runtime.emit({ type: 'tabs:broadcast', message: { type: 'mutation-settled' } });
163
+ runtime.emit({ type: 'tabs:sendTo', tabId: 'x', message: { type: 'mutation-rolled-back' } });
164
+ (client as any).hub = null;
165
+ (client as any).forwarder = { ingest: (t: unknown) => sent.push(['f-ingest', t]), mutationEnqueued: (id: string) => sent.push(['f-enq', id]) };
166
+ runtime.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: [] } });
167
+ runtime.emit({ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId: 'm' } });
168
+ runtime.emit({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: [] } });
169
+ expect(sent).toEqual([['relay', []], ['bcast', { type: 'mutation-settled' }], ['to', 'x', { type: 'mutation-rolled-back' }], ['f-ingest', []], ['f-enq', 'm']]);
170
+ runtime.emit({ type: 'query:status', hash: 'h', status: 'idle' });
171
+ runtime.emit({ type: 'query:authority', hash: 'h', known: true });
172
+ runtime.emit({ type: 'query:view-lost', hash: 'h' });
173
+ runtime.emit({ type: 'mutation:event', event: { type: 'create', record_id: new RecordId('thing', '1'), data: {} } });
174
+ runtime.emit({ type: 'devtools', name: 'X', data: 1 });
175
+ expect(await client.authenticate('tok')).toBe('tok');
176
+ await client.deauthenticate();
177
+ expect(await client.useRemote(async () => 'r')).toBe('r');
178
+ expect(await client.remoteQuery('RETURN 1')).toEqual(['remote']);
179
+ expect(client.remoteClient).toBeDefined();
180
+ expect(client.localClient).toBe('local-client');
181
+ expect(client.getBlobCacheStats()).toEqual({ entries: 0 });
182
+ expect(client.bucket('files')).toBeDefined();
183
+ expect(await client.openCrdtField('t', 'r', 'f')).toBe('field');
184
+ client.closeCrdtField('t', 'r', 'f');
185
+ expect(client.getFeatureOverrides()).toEqual({});
186
+ client.setFeatureOverride('k', 'v');
187
+ client.clearFeatureOverrides();
188
+ expect(client.feature('k')).toBeDefined();
189
+ expect(client.appRelease('web')).toBeDefined();
190
+ await client.close();
191
+ expect(client.state.localReady).toBe(true);
192
+ });
193
+ });
194
+
195
+ describe('Sp00kyClient facade (builder, devtools source, event fan-out)', () => {
196
+ it('query() builds through the QueryBuilder and registers locally; dispatch feeds the engine; DevTools reads state', async () => {
197
+ const { client, runtime } = makeClient();
198
+ await client.init();
199
+ const { hash } = await (client.query('thing', {} as any) as any).build().run();
200
+ expect(client.state.queries.has(hash)).toBe(true);
201
+ const source = (client as any).devTools.dataManager;
202
+ expect(source.getActiveQueries().map((q: any) => q.config.surql)).toEqual(['SELECT * FROM thing;']);
203
+ const entry = client.state.queries.get(hash)!;
204
+ expect(source.getQueryById(entry.def.id)!.config.membershipKey).toBe(entry.def.viewKey);
205
+ expect(source.getQueryById({ id: 'nope', table: '_00_query' } as any)).toBeUndefined();
206
+ expect(source.phaseTimings(source.getActiveQueries()[0]).updateCount).toBe(0);
207
+ expect(source.phaseTimings({ config: { id: { id: 'nope' } } } as any)).toEqual({});
208
+ await client.dispatch({ type: 'PollTick' });
209
+ await expect((client.query('nope' as any, {} as any) as any).build().run()).rejects.toThrow('Table nope not found');
210
+ const tray: number[] = [];
211
+ const fetching: number[] = [];
212
+ const health: string[] = [];
213
+ client.subscribeToFailedMutations((n) => tray.push(n));
214
+ client.subscribeToFetchActivity((n) => fetching.push(n));
215
+ client.subscribeToSyncHealth((h) => health.push(h.status));
216
+ runtime.emit({ type: 'tray:changed', count: 2 });
217
+ runtime.emit({ type: 'activity:changed', fetching: 3, pending: 0 });
218
+ runtime.emit({ type: 'health:changed', health: { ...client.syncHealth, status: 'degraded' } });
219
+ runtime.emit({ type: 'query:evicted', hash });
220
+ expect(tray).toEqual([0, 2]);
221
+ expect(fetching).toEqual([0, 3]);
222
+ expect(health).toEqual(['healthy', 'degraded']);
223
+ const pending: number[] = [];
224
+ client.subscribeToPendingMutations((n) => pending.push(n));
225
+ runtime.emit({ type: 'activity:changed', fetching: 0, pending: 4 });
226
+ expect(pending).toEqual([0, 4]);
227
+ });
228
+ it('gates queryRaw/useRemote/remoteQuery behind allowRawRemote when the schema policy turns the allowlist on', async () => {
229
+ const services = fakeServiceBundle<any>();
230
+ const a = fakeAdapters({});
231
+ const runtime = new Runtime({ env: defaultEnv(schema), adapters: a.adapters, logger: services.logger, tabId: 't' });
232
+ const withPolicy = (mode: string | undefined, allowRawRemote?: boolean) =>
233
+ new Sp00kyClient<any>(
234
+ { ...config, schema: mode === undefined ? schema : { ...schema, policy: { queryAllowlist: mode } }, allowRawRemote } as any,
235
+ { services, runtime }
236
+ );
237
+ 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`;
238
+ for (const mode of ['warn', 'enforce']) {
239
+ const gated = withPolicy(mode);
240
+ await expect(gated.queryRaw('SELECT * FROM thing', {}, '10m')).rejects.toThrow(msg('queryRaw'));
241
+ await expect(gated.useRemote(() => 1)).rejects.toThrow(msg('useRemote'));
242
+ await expect(gated.remoteQuery('RETURN 1')).rejects.toThrow(msg('remoteQuery'));
243
+ expect(a.names().filter((n) => n === 'remote.query')).toEqual([]);
244
+ }
245
+ for (const client of [withPolicy('enforce', true), withPolicy('off'), withPolicy(undefined)]) {
246
+ await expect(client.useRemote(() => 1)).resolves.toBe(1);
247
+ await expect(client.remoteQuery('RETURN 1')).resolves.toBeDefined();
248
+ }
249
+ });
250
+
251
+ it('honours config knobs when building the saga env', () => {
252
+ const services = fakeServiceBundle<any>();
253
+ const a = fakeAdapters();
254
+ const runtime = new Runtime({ env: defaultEnv(schema), adapters: a.adapters, logger: services.logger, tabId: 't' });
255
+ const tuned = new Sp00kyClient<any>(
256
+ { ...config, database: { ...config.database, queryTimeoutMs: 5 }, syncHealth: false, pushTimeoutMs: 7, refSyncIntervalMs: 9, streamDebounceTime: 11, enableAnonymousLiveQueries: true, sharedTabs: true } as any,
257
+ { services, runtime, env: { defaultTtlMs: 1 } }
258
+ );
259
+ expect((tuned as any).env).toMatchObject({ remoteTimeoutMs: 5, degradeAfter: 0, pushTimeoutMs: 7, pollBaseMs: 9, materializeDebounceMs: 11, anonLive: true, defaultTtlMs: 1 });
260
+ const degraded = new Sp00kyClient<any>({ ...config, syncHealth: { degradeAfterConsecutiveFailures: 2 } } as any, { services, runtime });
261
+ expect((degraded as any).env.degradeAfter).toBe(2);
262
+ expect(tuned.tabRole).toBeNull();
263
+ });
264
+ });