@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.320

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 (259) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2034 -137
  4. package/dist/index.js +15137 -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 +337 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +379 -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 +1811 -38
  19. package/dist/worker-edges.js +439 -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 +180 -0
  32. package/src/boot/boot.saga.ts +142 -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.test.ts +51 -0
  46. package/src/client/services.ts +404 -0
  47. package/src/client/sp00ky-client.test.ts +303 -0
  48. package/src/client/sp00ky-client.ts +586 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +163 -0
  56. package/src/kernel/events.ts +57 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +478 -0
  62. package/src/live/index.ts +1013 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +169 -0
  66. package/src/modules/auth/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +466 -19
  72. package/src/modules/crdt/crdt-field.ts +294 -0
  73. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  74. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  75. package/src/modules/crdt/index.ts +463 -0
  76. package/src/modules/crdt/loro-loader.ts +25 -0
  77. package/src/modules/devtools/flags.ts +349 -0
  78. package/src/modules/devtools/index.ts +746 -95
  79. package/src/modules/devtools/mutations.test.ts +92 -0
  80. package/src/modules/devtools/mutations.ts +160 -0
  81. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  82. package/src/modules/devtools/state-shape.test.ts +263 -0
  83. package/src/modules/devtools/storage-info.test.ts +79 -0
  84. package/src/modules/devtools/storage-info.ts +168 -0
  85. package/src/modules/devtools/versions.test.ts +74 -0
  86. package/src/modules/devtools/versions.ts +110 -0
  87. package/src/modules/feature-flag/index.test.ts +298 -0
  88. package/src/modules/feature-flag/index.ts +316 -0
  89. package/src/modules/ref-tables.test.ts +91 -0
  90. package/src/modules/ref-tables.ts +88 -0
  91. package/src/modules/web-push/index.test.ts +824 -0
  92. package/src/modules/web-push/index.ts +1027 -0
  93. package/src/mutation/jobs.test.ts +25 -0
  94. package/src/mutation/jobs.ts +41 -0
  95. package/src/mutation/mutation-id.test.ts +37 -0
  96. package/src/mutation/mutation-id.ts +35 -0
  97. package/src/mutation/pending-writes.integration.test.ts +175 -0
  98. package/src/mutation/push.saga.test.ts +285 -0
  99. package/src/mutation/push.saga.ts +217 -0
  100. package/src/mutation/rows.test.ts +231 -0
  101. package/src/mutation/rows.ts +427 -0
  102. package/src/mutation/tray.saga.test.ts +63 -0
  103. package/src/mutation/tray.saga.ts +48 -0
  104. package/src/mutation/write.saga.test.ts +316 -0
  105. package/src/mutation/write.saga.ts +268 -0
  106. package/src/otel/index.ts +10 -7
  107. package/src/pure.ts +65 -0
  108. package/src/push/types.ts +174 -0
  109. package/src/query/env.test.ts +23 -0
  110. package/src/query/env.ts +56 -0
  111. package/src/query/fetch.saga.test.ts +199 -0
  112. package/src/query/fetch.saga.ts +120 -0
  113. package/src/query/hash.test.ts +14 -0
  114. package/src/query/hash.ts +18 -0
  115. package/src/query/id-set-plan.test.ts +132 -0
  116. package/src/query/lifecycle.saga.test.ts +274 -0
  117. package/src/query/lifecycle.saga.ts +159 -0
  118. package/src/query/materialize.saga.test.ts +115 -0
  119. package/src/query/materialize.saga.ts +84 -0
  120. package/src/query/materialize.test.ts +58 -0
  121. package/src/query/materialize.ts +57 -0
  122. package/src/query/membership.saga.test.ts +350 -0
  123. package/src/query/membership.saga.ts +278 -0
  124. package/src/query/membership.test.ts +118 -0
  125. package/src/query/membership.ts +201 -0
  126. package/src/query/register.saga.test.ts +311 -0
  127. package/src/query/register.saga.ts +229 -0
  128. package/src/query/render-set.test.ts +47 -0
  129. package/src/query/render-set.ts +71 -0
  130. package/src/query/sql.test.ts +81 -0
  131. package/src/query/sql.ts +112 -0
  132. package/src/query/window-query.test.ts +70 -0
  133. package/src/query/window-query.ts +195 -0
  134. package/src/services/blobs/blob-cache.test.ts +359 -0
  135. package/src/services/blobs/blob-cache.ts +603 -0
  136. package/src/services/blobs/blob-manifest.ts +227 -0
  137. package/src/services/blobs/blob-store.test.ts +77 -0
  138. package/src/services/blobs/blob-store.ts +359 -0
  139. package/src/services/blobs/blob.fixture.ts +90 -0
  140. package/src/services/blobs/index.ts +70 -0
  141. package/src/services/database/cache-engine.ts +199 -0
  142. package/src/services/database/connection-supervisor.test.ts +318 -0
  143. package/src/services/database/connection-supervisor.ts +422 -0
  144. package/src/services/database/database.query-timeout.test.ts +140 -0
  145. package/src/services/database/database.responses.test.ts +43 -0
  146. package/src/services/database/database.ts +146 -61
  147. package/src/services/database/engine-factory.ts +33 -0
  148. package/src/services/database/errors.ts +34 -0
  149. package/src/services/database/events/index.ts +2 -1
  150. package/src/services/database/index.ts +7 -0
  151. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  152. package/src/services/database/local-migrator.integration.test.ts +110 -0
  153. package/src/services/database/local-migrator.ts +59 -16
  154. package/src/services/database/local.test.ts +64 -0
  155. package/src/services/database/local.ts +487 -63
  156. package/src/services/database/plan-render.test.ts +159 -0
  157. package/src/services/database/plan-render.ts +108 -0
  158. package/src/services/database/relation-resolver.test.ts +484 -0
  159. package/src/services/database/relation-resolver.ts +0 -0
  160. package/src/services/database/remote.ts +255 -6
  161. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  162. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  163. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  164. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  165. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  166. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  167. package/src/services/database/sqlite-lock-verify.ts +45 -0
  168. package/src/services/database/sqlite-open.test.ts +150 -0
  169. package/src/services/database/sqlite-open.ts +164 -0
  170. package/src/services/database/sqlite-plan-sql.test.ts +145 -0
  171. package/src/services/database/sqlite-plan-sql.ts +182 -0
  172. package/src/services/database/sqlite-projection.test.ts +121 -0
  173. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  174. package/src/services/database/sqlite-select.test.ts +261 -0
  175. package/src/services/database/sqlite-select.ts +131 -0
  176. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  177. package/src/services/database/sqlite-transport.ts +224 -0
  178. package/src/services/database/sqlite-worker.ts +437 -0
  179. package/src/services/database/surql-translate.ts +416 -0
  180. package/src/services/database/surreal-cache-engine.ts +161 -0
  181. package/src/services/logger/index.test.ts +132 -0
  182. package/src/services/logger/index.ts +189 -7
  183. package/src/services/persistence/localstorage.ts +2 -2
  184. package/src/services/persistence/resilient.ts +10 -3
  185. package/src/services/persistence/surrealdb.ts +4 -4
  186. package/src/services/stream-processor/index.ts +783 -68
  187. package/src/services/stream-processor/permissions.test.ts +47 -0
  188. package/src/services/stream-processor/permissions.ts +53 -0
  189. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  190. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  191. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  192. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  193. package/src/services/stream-processor/wasm-types.ts +58 -2
  194. package/src/services/tabs/broker-client.ts +283 -0
  195. package/src/services/tabs/broker.test.ts +327 -0
  196. package/src/services/tabs/coordinator.test.ts +386 -0
  197. package/src/services/tabs/coordinator.ts +638 -0
  198. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  199. package/src/services/tabs/leader-locks.ts +75 -0
  200. package/src/services/tabs/protocol.ts +262 -0
  201. package/src/services/tabs/support.ts +36 -0
  202. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  203. package/src/sp00ky.ts +5 -395
  204. package/src/state/client-state.ts +200 -0
  205. package/src/state/lifecycle.test.ts +141 -0
  206. package/src/state/lifecycle.ts +99 -0
  207. package/src/state/reducers.test.ts +304 -0
  208. package/src/state/reducers.ts +526 -0
  209. package/src/state/selectors.test.ts +175 -0
  210. package/src/state/selectors.ts +222 -0
  211. package/src/sw/index.test.ts +582 -0
  212. package/src/sw/index.ts +607 -0
  213. package/src/sw/store.ts +96 -0
  214. package/src/sync/connection.saga.test.ts +86 -0
  215. package/src/sync/connection.saga.ts +85 -0
  216. package/src/sync/live.saga.test.ts +178 -0
  217. package/src/sync/live.saga.ts +107 -0
  218. package/src/sync/policy.health.test.ts +56 -0
  219. package/src/sync/policy.test.ts +659 -0
  220. package/src/sync/policy.ts +502 -0
  221. package/src/sync/poll.saga.test.ts +51 -0
  222. package/src/sync/poll.saga.ts +36 -0
  223. package/src/sync/tabs.saga.test.ts +78 -0
  224. package/src/sync/tabs.saga.ts +96 -0
  225. package/src/testing/adapters.ts +133 -0
  226. package/src/testing/build.ts +63 -0
  227. package/src/testing/fake-services.ts +48 -0
  228. package/src/testing/run-pure.test.ts +92 -0
  229. package/src/testing/run-pure.ts +128 -0
  230. package/src/testing/services.ts +17 -0
  231. package/src/types.ts +624 -10
  232. package/src/utils/blurhash.ts +90 -0
  233. package/src/utils/edges.ts +39 -0
  234. package/src/utils/error-classification.test.ts +54 -0
  235. package/src/utils/error-classification.ts +25 -0
  236. package/src/utils/index.ts +78 -12
  237. package/src/utils/parser.test.ts +67 -102
  238. package/src/utils/parser.ts +64 -2
  239. package/src/utils/semver.test.ts +32 -0
  240. package/src/utils/semver.ts +30 -0
  241. package/src/utils/sha256.ts +15 -0
  242. package/src/utils/surql.ts +30 -18
  243. package/src/utils/utils.test.ts +3 -2
  244. package/tsdown.config.ts +117 -3
  245. package/vitest.config.ts +48 -0
  246. package/src/modules/cache/index.ts +0 -241
  247. package/src/modules/cache/types.ts +0 -19
  248. package/src/modules/data/data.test.ts +0 -58
  249. package/src/modules/data/index.ts +0 -879
  250. package/src/modules/sync/engine.ts +0 -173
  251. package/src/modules/sync/events/index.ts +0 -77
  252. package/src/modules/sync/index.ts +0 -3
  253. package/src/modules/sync/queue/index.ts +0 -2
  254. package/src/modules/sync/queue/queue-down.ts +0 -89
  255. package/src/modules/sync/queue/queue-up.ts +0 -223
  256. package/src/modules/sync/scheduler.ts +0 -84
  257. package/src/modules/sync/sync.ts +0 -421
  258. package/src/modules/sync/utils.test.ts +0 -311
  259. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,96 @@
1
+ import type { Saga } from '../kernel/saga';
2
+ import { fx } from '../kernel/effects';
3
+ import type { IngestRecord } from '../services/stream-processor/index';
4
+ import type { ClientState, TabRole } from '../state/client-state';
5
+ import * as R from '../state/reducers';
6
+ import type { SagaEnv } from '../query/env';
7
+ import { loadOutbox } from '../mutation/push.saga';
8
+ import { liveChange } from './live.saga';
9
+
10
+ /**
11
+ * Shared-tabs role changes. The leader owns the outbox drain, the poll and
12
+ * LIVE; a follower keeps its own circuit and materialization but gets
13
+ * membership dirt, acks and rollbacks relayed.
14
+ */
15
+ export function* setRole(env: SagaEnv, role: TabRole): Saga<void> {
16
+ const prev = (yield fx.state.read((s) => s.tabRole)) as TabRole;
17
+ if (prev === role) return;
18
+ yield fx.state.update(R.setTabRole(role));
19
+ if (role === 'follower') {
20
+ yield fx.timer.clear('poll');
21
+ yield fx.timer.clear('outbox');
22
+ yield fx.state.update(R.patchSync({ liveUuid: null, liveTable: null }));
23
+ return;
24
+ }
25
+ yield* loadOutbox(env);
26
+ yield fx.dispatch({ type: 'LiveStart' });
27
+ yield fx.dispatch({ type: 'PollTick' });
28
+ yield fx.dispatch({ type: 'EnsureRegistered' });
29
+ }
30
+
31
+ type TabMessage =
32
+ | { type: 'ingest'; records: IngestRecord[] }
33
+ | { type: 'membership-dirty'; hashes: string[] }
34
+ | { type: 'outbox-changed'; mutationId: string }
35
+ | { type: 'mutation-settled'; mutationId: string; recordId: string; eventType: string }
36
+ | { type: 'mutation-rolled-back'; mutationId: string; recordId: string; eventType: string; error: string }
37
+ | { type: 'failed-mutations-changed'; count: number };
38
+
39
+ /** A message from another tab of the same origin. */
40
+ export function* tabMessage(env: SagaEnv, raw: unknown): Saga<void> {
41
+ const msg = raw as TabMessage | null;
42
+ if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string') return;
43
+ const role = (yield fx.state.read((s) => s.tabRole)) as ClientState['tabRole'];
44
+ switch (msg.type) {
45
+ case 'ingest': {
46
+ if (!Array.isArray(msg.records) || msg.records.length === 0) return;
47
+ try {
48
+ yield fx.ssp.ingest(msg.records);
49
+ } catch (error) {
50
+ yield fx.emit({ type: 'log', level: 'warn', message: 'relayed ingest failed', data: { error } });
51
+ }
52
+ yield fx.state.update(
53
+ R.compose(
54
+ R.setVersions(
55
+ msg.records
56
+ .filter((r) => r.op !== 'DELETE')
57
+ .map((r) => [r.id, typeof r.record?._00_rv === 'number' ? (r.record._00_rv as number) : 0] as const)
58
+ ),
59
+ R.deleteVersions(msg.records.filter((r) => r.op === 'DELETE').map((r) => r.id)),
60
+ ...[...new Set(msg.records.map((r) => r.table))].map((t) => R.markTableDirty(t))
61
+ )
62
+ );
63
+ // A body another tab deleted (its orphan GC) may still be one this tab
64
+ // names: with the version gone, the fetch plan pulls it back.
65
+ if (msg.records.some((r) => r.op === 'DELETE')) yield fx.dispatch({ type: 'FetchRows' });
66
+ return;
67
+ }
68
+ case 'membership-dirty':
69
+ // No inline rows here: the leader relays its own bodies as an `ingest`.
70
+ if (Array.isArray(msg.hashes)) yield* liveChange(env, msg.hashes);
71
+ return;
72
+ case 'outbox-changed':
73
+ if (role !== 'leader') return;
74
+ yield* loadOutbox(env);
75
+ return;
76
+ case 'mutation-settled': {
77
+ const now = (yield fx.now()) as number;
78
+ yield fx.state.update(R.outboxAck(msg.mutationId, now));
79
+ yield fx.emit({ type: 'mutation:settled', mutationId: msg.mutationId, recordId: msg.recordId, eventType: msg.eventType });
80
+ return;
81
+ }
82
+ case 'mutation-rolled-back': {
83
+ const table = msg.recordId.slice(0, msg.recordId.indexOf(':'));
84
+ yield fx.state.update(R.compose(R.outboxRemove(msg.mutationId), R.markTableDirty(table)));
85
+ yield fx.emit({ type: 'mutation:rolled-back', mutationId: msg.mutationId, recordId: msg.recordId, eventType: msg.eventType, error: msg.error });
86
+ return;
87
+ }
88
+ case 'failed-mutations-changed':
89
+ if (typeof msg.count !== 'number') return;
90
+ yield fx.state.update(R.setFailedCount(msg.count));
91
+ yield fx.emit({ type: 'tray:changed', count: msg.count });
92
+ return;
93
+ default:
94
+ return;
95
+ }
96
+ }
@@ -0,0 +1,133 @@
1
+ import type { Adapters, InterpreterHost } from '../kernel/interpreter';
2
+ import type { ServiceCalls } from '../kernel/effects';
3
+ import type { OutEvent, RuntimeEvent } from '../kernel/events';
4
+ import type { ClientState } from '../state/client-state';
5
+ import { emptyState } from '../state/client-state';
6
+ import { sha256Hex } from './run-pure';
7
+
8
+ export interface FakeTimers {
9
+ pending: Map<string, { ms: number; fire: () => void }>;
10
+ fire(key: string): void;
11
+ fireAll(): void;
12
+ }
13
+
14
+ /**
15
+ * Recording adapters for interpreter / runtime / facade tests. Every call is
16
+ * logged; behaviour is scripted by overriding the methods you care about.
17
+ */
18
+ export function fakeAdapters(over: {
19
+ local?: Partial<Adapters['local']>;
20
+ remote?: Partial<Adapters['remote']>;
21
+ ssp?: Partial<Adapters['ssp']>;
22
+ services?: Partial<ServiceCalls>;
23
+ now?: number;
24
+ } = {}) {
25
+ const calls: Array<[string, unknown[]]> = [];
26
+ const log = (name: string, ...args: unknown[]) => calls.push([name, args]);
27
+ let now = over.now ?? 1_700_000_000_000;
28
+ let epoch = 0;
29
+ const timers: FakeTimers = {
30
+ pending: new Map(),
31
+ fire(key) {
32
+ const t = timers.pending.get(key);
33
+ if (!t) return;
34
+ timers.pending.delete(key);
35
+ t.fire();
36
+ },
37
+ fireAll() {
38
+ for (const key of [...timers.pending.keys()]) timers.fire(key);
39
+ },
40
+ };
41
+ const services = new Proxy({} as ServiceCalls, {
42
+ get(_t, name: string) {
43
+ return (...args: unknown[]) => {
44
+ log(`service.${name}`, ...args);
45
+ const fn = (over.services as Record<string, (...a: unknown[]) => unknown> | undefined)?.[name];
46
+ return fn ? fn(...args) : undefined;
47
+ };
48
+ },
49
+ });
50
+ let ids = 0;
51
+ const adapters: Adapters = {
52
+ local: {
53
+ get epoch() {
54
+ return epoch;
55
+ },
56
+ query: (async (sql: string, vars?: Record<string, unknown>) => (log('local.query', sql, vars), [])) as Adapters['local']['query'],
57
+ execute: (async (q: { sql: string }, vars?: Record<string, unknown>) => (log('local.execute', q.sql, vars), undefined)) as Adapters['local']['execute'],
58
+ select: async (plan, params) => (log('local.select', plan, params), []),
59
+ getById: async (table, id) => (log('local.getById', table, id), null),
60
+ upsert: async (table, id, data, mode) => void log('local.upsert', table, id, data, mode),
61
+ delete: async (table, id) => void log('local.delete', table, id),
62
+ ...over.local,
63
+ },
64
+ remote: {
65
+ queryResponses: async (sql, vars) => (log('remote.query', sql, vars), []),
66
+ live: async (table) => (log('remote.live', table), 'live-uuid'),
67
+ kill: async (uuid) => void log('remote.kill', uuid),
68
+ ...over.remote,
69
+ },
70
+ ssp: {
71
+ registerQueryPlan: (plan) => (log('ssp.register', plan.queryHash), { queryHash: plan.queryHash, localArray: [] }),
72
+ unregisterQueryPlan: (hash) => void log('ssp.unregister', hash),
73
+ ingestMany: (records) => (log('ssp.ingest', records), records),
74
+ ...over.ssp,
75
+ },
76
+ timers: {
77
+ set: (key, ms, fire) => void timers.pending.set(key, { ms, fire }),
78
+ clear: (key) => void timers.pending.delete(key),
79
+ },
80
+ clock: { now: () => now },
81
+ ids: { mutation: () => `_00_pending_mutations:${String(++ids).padStart(13, '0')}_0001_tab`, salt: () => `salt-${++ids}` },
82
+ hash: sha256Hex,
83
+ services,
84
+ };
85
+ return {
86
+ adapters,
87
+ calls,
88
+ timers,
89
+ names: () => calls.map(([n]) => n),
90
+ advance(ms: number) {
91
+ now += ms;
92
+ },
93
+ bumpEpoch() {
94
+ epoch += 1;
95
+ },
96
+ };
97
+ }
98
+
99
+ /** A minimal host: holds state, resolves waits when the predicate holds after an update. */
100
+ export function fakeHost(initial: ClientState = emptyState({ tabId: 'tab-a' })) {
101
+ let state = initial;
102
+ const emitted: OutEvent[] = [];
103
+ const dispatched: RuntimeEvent[] = [];
104
+ const waiters = new Set<{ until: (s: ClientState) => boolean; resolve: () => void; reject: (e: unknown) => void }>();
105
+ const check = () => {
106
+ for (const w of [...waiters]) {
107
+ if (w.until(state)) {
108
+ waiters.delete(w);
109
+ w.resolve();
110
+ }
111
+ }
112
+ };
113
+ const host: InterpreterHost = {
114
+ getState: () => state,
115
+ setState: (next) => {
116
+ state = next;
117
+ check();
118
+ },
119
+ waitFor: (until, signal) =>
120
+ new Promise<void>((resolve, reject) => {
121
+ if (until(state)) return resolve();
122
+ const w = { until, resolve, reject };
123
+ waiters.add(w);
124
+ signal?.addEventListener('abort', () => {
125
+ waiters.delete(w);
126
+ reject(new Error('aborted'));
127
+ });
128
+ }),
129
+ emit: (e) => void emitted.push(e),
130
+ dispatch: (e) => void dispatched.push(e),
131
+ };
132
+ return { host, emitted, dispatched, get state() { return state; }, waiters };
133
+ }
@@ -0,0 +1,63 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type { ClientState, OutboxItem, QueryDefinition, QueryEntry } from '../state/client-state';
3
+ import { emptyState, emptyTelemetry } from '../state/client-state';
4
+ import { seedLifecycle } from '../state/lifecycle';
5
+ import { compose, putQuery } from '../state/reducers';
6
+ import type { Reducer } from '../state/reducers';
7
+
8
+ type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> | T[K] : T[K] };
9
+
10
+ export function buildDefinition(over: Partial<QueryDefinition> = {}): QueryDefinition {
11
+ const hash = over.hash ?? 'h1';
12
+ return {
13
+ id: over.id ?? (new RecordId('_00_query', hash) as any),
14
+ hash,
15
+ viewKey: over.viewKey ?? `view-${hash}`,
16
+ surql: over.surql ?? 'SELECT * FROM thing',
17
+ params: over.params ?? {},
18
+ plan: over.plan,
19
+ ttl: over.ttl ?? '10m',
20
+ ttlMs: over.ttlMs ?? 600_000,
21
+ tableName: over.tableName ?? 'thing',
22
+ createdAt: over.createdAt ?? 1_700_000_000_000,
23
+ };
24
+ }
25
+
26
+ export function buildEntry(over: DeepPartial<QueryEntry> & { def?: Partial<QueryDefinition> } = {}): QueryEntry {
27
+ const def = buildDefinition(over.def ?? {});
28
+ return {
29
+ def,
30
+ lifecycle: { ...seedLifecycle(false), ...(over.lifecycle as object) },
31
+ remoteArray: (over.remoteArray as QueryEntry['remoteArray']) ?? [],
32
+ localArray: (over.localArray as QueryEntry['localArray']) ?? [],
33
+ subqueryRemoteArray: (over.subqueryRemoteArray as QueryEntry['subqueryRemoteArray']) ?? [],
34
+ records: (over.records as QueryEntry['records']) ?? [],
35
+ serverState: over.serverState ?? null,
36
+ subscribers: over.subscribers ?? 0,
37
+ lastSubscriberLeftAt: over.lastSubscriberLeftAt ?? null,
38
+ lastHeartbeatAt: over.lastHeartbeatAt ?? null,
39
+ lastPolledAt: over.lastPolledAt ?? null,
40
+ registerAttempts: over.registerAttempts ?? 0,
41
+ viewLostCount: over.viewLostCount ?? 0,
42
+ viewLostRetryAt: over.viewLostRetryAt ?? null,
43
+ telemetry: { ...emptyTelemetry(), ...(over.telemetry as object) },
44
+ };
45
+ }
46
+
47
+ export function buildOutboxItem(over: Partial<OutboxItem> = {}): OutboxItem {
48
+ return {
49
+ id: over.id ?? 'm1',
50
+ type: over.type ?? 'create',
51
+ recordId: over.recordId ?? 'thing:1',
52
+ table: over.table ?? 'thing',
53
+ status: over.status ?? 'pending',
54
+ ackedAt: over.ackedAt ?? null,
55
+ attempts: over.attempts ?? 0,
56
+ };
57
+ }
58
+
59
+ /** A state holding the given entries, with `dirty` cleared (as after materialization). */
60
+ export function buildState(entries: QueryEntry[] = [], ...extra: Reducer[]): ClientState {
61
+ const base = compose(...entries.map(putQuery))(emptyState({ tabId: 'tab-a' }));
62
+ return compose(...extra)({ ...base, dirty: new Set() });
63
+ }
@@ -0,0 +1,48 @@
1
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
2
+ import type { Services } from '../client/services';
3
+
4
+ const noop = () => {};
5
+
6
+ /**
7
+ * The legacy services the facade constructs around, as inert stubs. Only the
8
+ * members the facade touches at construction / close time exist; sagas never
9
+ * see these (they go through the adapters' `service` calls).
10
+ */
11
+ export function fakeServiceBundle<S extends SchemaStructure>(over: Partial<Services<S>> = {}): Services<S> & {
12
+ authListeners: Array<(u: string | null) => void>;
13
+ connectionListeners: Array<(s: string) => void>;
14
+ receivers: unknown[];
15
+ /** Fire a store event (`DATABASE_LOCAL_QUERY`, ...) at whoever subscribed. */
16
+ emitStoreEvent: (type: string, payload: unknown) => void;
17
+ } {
18
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop, child: () => logger };
19
+ const authListeners: Array<(u: string | null) => void> = [];
20
+ const connectionListeners: Array<(s: string) => void> = [];
21
+ const receivers: unknown[] = [];
22
+ const storeListeners = new Map<string, Array<(e: { payload: unknown }) => void>>();
23
+ const events = {
24
+ subscribe: (type: string, cb: (e: { payload: unknown }) => void) => {
25
+ storeListeners.set(type, [...(storeListeners.get(type) ?? []), cb]);
26
+ },
27
+ };
28
+ const emitStoreEvent = (type: string, payload: unknown) => {
29
+ for (const cb of storeListeners.get(type) ?? []) cb({ payload });
30
+ };
31
+ const bundle = {
32
+ logger,
33
+ local: { getEvents: () => events, getClient: () => 'local-client', storageHealth: undefined, subscribeToStorageHealth: undefined, close: async () => undefined } as any,
34
+ remote: { getEvents: () => events, getClient: () => ({ authenticate: async (t: string) => t, invalidate: async () => undefined }), setAuthToken: noop, query: async () => ['remote'], close: async () => undefined } as any,
35
+ connectionSupervisor: { subscribe: (cb: (s: string) => void) => (connectionListeners.push(cb), noop), dispose: noop } as any,
36
+ blobs: { stats: () => ({ entries: 0 }), close: async () => undefined } as any,
37
+ persistence: { set: async () => undefined, get: async () => null, remove: async () => undefined },
38
+ streamProcessor: { addReceiver: (r: unknown) => void receivers.push(r), checkpoint: async () => undefined, dispose: noop } as any,
39
+ migrator: {} as any,
40
+ crdt: { closeAll: noop, dispose: noop, open: async () => 'field', close: noop } as any,
41
+ auth: { subscribe: (cb: (u: string | null) => void) => (authListeners.push(cb), noop), subscribeImpersonation: () => noop, onBeforeSignOut: () => noop, currentUser: null, isAuthenticated: false, token: null, impersonation: null, eventSystem: { subscribe: noop } } as any,
42
+ tabs: null,
43
+ tabsUnsupportedReason: 'test',
44
+ tabId: 'tab-test',
45
+ ...over,
46
+ } as Services<S>;
47
+ return Object.assign(bundle, { authListeners, connectionListeners, receivers, emitStoreEvent });
48
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { fx } from '../kernel/effects';
3
+ import type { Saga } from '../kernel/saga';
4
+ import { bySqlPrefix, runPure, UnhandledEffectError } from './run-pure';
5
+ import { setFailedCount } from '../state/reducers';
6
+
7
+ describe('runPure', () => {
8
+ it('handles state, clock, ids, hash, timers, emit, dispatch', async () => {
9
+ function* saga(): Saga<Record<string, unknown>> {
10
+ const before = (yield fx.state.read((s) => s.failedCount)) as number;
11
+ yield fx.state.update(setFailedCount(3));
12
+ const after = (yield fx.state.read((s) => s.failedCount)) as number;
13
+ const now = (yield fx.now()) as number;
14
+ const id1 = (yield fx.id('mutation')) as string;
15
+ const id2 = (yield fx.id('salt')) as string;
16
+ const hash = (yield fx.hash('abc')) as string;
17
+ yield fx.timer.set('k', 10, { type: 'Drain' });
18
+ yield fx.timer.set('k', 20, { type: 'Drain' });
19
+ yield fx.timer.set('j', 5, { type: 'FetchRows' });
20
+ yield fx.timer.clear('j');
21
+ yield fx.emit({ type: 'tray:changed', count: 1 });
22
+ yield fx.dispatch({ type: 'FetchRows' });
23
+ return { before, after, now, id1, id2, hash };
24
+ }
25
+ const out = await runPure(saga(), { now: 5 });
26
+ expect(out.result).toEqual({
27
+ before: 0,
28
+ after: 3,
29
+ now: 5,
30
+ id1: 'mutation-1',
31
+ id2: 'salt-2',
32
+ hash: 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
33
+ });
34
+ expect(out.state.failedCount).toBe(3);
35
+ expect([...out.timers.entries()]).toEqual([['k', { ms: 20, event: { type: 'Drain' } }]]);
36
+ expect(out.emitted).toEqual([{ type: 'tray:changed', count: 1 }]);
37
+ expect(out.dispatched).toEqual([{ type: 'FetchRows' }]);
38
+ expect(out.log.map((e) => e.kind)[0]).toBe('state.read');
39
+ });
40
+
41
+ it('state.wait passes when the predicate holds and throws when it would block', async () => {
42
+ function* ok(): Saga<string> {
43
+ yield fx.state.wait((s) => s.failedCount === 0);
44
+ return 'passed';
45
+ }
46
+ await expect(runPure(ok())).resolves.toMatchObject({ result: 'passed' });
47
+ function* blocked(): Saga<void> {
48
+ yield fx.state.wait((s) => s.failedCount === 9);
49
+ }
50
+ await expect(runPure(blocked())).rejects.toThrow(/would block/);
51
+ });
52
+
53
+ it('routes adapter effects to handlers and throws on unhandled ones', async () => {
54
+ function* saga(): Saga<unknown> {
55
+ return yield fx.remote.query('RETURN true');
56
+ }
57
+ const out = await runPure(saga(), { handlers: { 'remote.query': () => [{ status: 'OK', result: true }] } });
58
+ expect(out.result).toEqual([{ status: 'OK', result: true }]);
59
+ await expect(runPure(saga())).rejects.toBeInstanceOf(UnhandledEffectError);
60
+ });
61
+
62
+ it('all returns settled results in order and keeps going after a failure', async () => {
63
+ function* saga(): Saga<unknown> {
64
+ return yield fx.all([fx.remote.query('A'), fx.remote.query('B'), fx.now()]);
65
+ }
66
+ const out = await runPure(saga(), {
67
+ now: 1,
68
+ handlers: {
69
+ 'remote.query': (e) => {
70
+ if ((e as { sql: string }).sql === 'B') throw new Error('nope');
71
+ return 'a';
72
+ },
73
+ },
74
+ });
75
+ expect(out.result).toEqual([
76
+ { ok: true, value: 'a' },
77
+ { ok: false, error: new Error('nope') },
78
+ { ok: true, value: 1 },
79
+ ]);
80
+ });
81
+
82
+ it('bySqlPrefix answers by prefix and rejects unknown SQL', async () => {
83
+ const handler = bySqlPrefix([
84
+ ['SELECT', () => 'rows'],
85
+ ['DELETE', () => 'gone'],
86
+ ]);
87
+ expect(handler(fx.local.query('SELECT * FROM x'), {} as never)).toBe('rows');
88
+ expect(handler(fx.local.query('DELETE x'), {} as never)).toBe('gone');
89
+ expect(() => handler(fx.local.query('UPDATE x'), {} as never)).toThrow(/no scripted answer/);
90
+ expect(() => handler(fx.now(), {} as never)).toThrow(/no scripted answer/);
91
+ });
92
+ });
@@ -0,0 +1,128 @@
1
+ import { webcrypto } from 'node:crypto';
2
+ import type { Effect, EffectKind, Settled } from '../kernel/effects';
3
+ import type { OutEvent, RuntimeEvent } from '../kernel/events';
4
+ import type { Saga } from '../kernel/saga';
5
+ import { runSaga } from '../kernel/saga';
6
+ import type { ClientState } from '../state/client-state';
7
+ import { emptyState } from '../state/client-state';
8
+
9
+ export type EffectHandler = (effect: Effect, ctx: PureContext) => unknown | Promise<unknown>;
10
+
11
+ export interface PureContext {
12
+ state: ClientState;
13
+ now: number;
14
+ log: Effect[];
15
+ timers: Map<string, { ms: number; event: RuntimeEvent }>;
16
+ emitted: OutEvent[];
17
+ dispatched: RuntimeEvent[];
18
+ ids: number;
19
+ }
20
+
21
+ export interface RunPureOptions {
22
+ state?: ClientState;
23
+ now?: number;
24
+ /** What `local.epoch` answers; a store-side counter, so it is scripted like `now`. */
25
+ epoch?: number;
26
+ handlers?: Partial<Record<EffectKind, EffectHandler>>;
27
+ }
28
+
29
+ export interface RunPureResult<R> extends PureContext {
30
+ result: R;
31
+ }
32
+
33
+ export class UnhandledEffectError extends Error {
34
+ constructor(public readonly effect: Effect) {
35
+ super(`runPure: no handler for effect '${effect.kind}'`);
36
+ this.name = 'UnhandledEffectError';
37
+ }
38
+ }
39
+
40
+ export async function sha256Hex(input: string): Promise<string> {
41
+ const bytes = new TextEncoder().encode(input);
42
+ const digest = await webcrypto.subtle.digest('SHA-256', bytes);
43
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
44
+ }
45
+
46
+ /**
47
+ * Drive a saga with canned effect results. `state.*`, `now`, `id`, `hash`,
48
+ * `timer.*`, `emit`, `dispatch` and `local.epoch` are handled here; every other
49
+ * adapter effect (`local.*`, `remote.*`, `ssp.*`) must have a handler or the run throws, so
50
+ * a test can never pass by an effect silently returning `undefined`.
51
+ */
52
+ export async function runPure<R>(saga: Saga<R>, opts: RunPureOptions = {}): Promise<RunPureResult<R>> {
53
+ const ctx: PureContext = {
54
+ state: opts.state ?? emptyState({ tabId: 'tab-test' }),
55
+ now: opts.now ?? 1_700_000_000_000,
56
+ log: [],
57
+ timers: new Map(),
58
+ emitted: [],
59
+ dispatched: [],
60
+ ids: 0,
61
+ };
62
+ const interpret = async (effect: Effect): Promise<unknown> => {
63
+ ctx.log.push(effect);
64
+ const custom = opts.handlers?.[effect.kind];
65
+ if (custom) return custom(effect, ctx);
66
+ switch (effect.kind) {
67
+ case 'state.read':
68
+ return effect.select(ctx.state);
69
+ case 'state.update':
70
+ ctx.state = effect.fn(ctx.state);
71
+ return ctx.state;
72
+ case 'state.wait':
73
+ if (effect.until(ctx.state)) return undefined;
74
+ throw new Error('runPure: state.wait would block; script a handler or prepare the state');
75
+ case 'now':
76
+ return ctx.now;
77
+ case 'local.epoch':
78
+ return opts.epoch ?? 0;
79
+ case 'id':
80
+ ctx.ids += 1;
81
+ return `${effect.scope}-${ctx.ids}`;
82
+ case 'hash':
83
+ return sha256Hex(effect.input);
84
+ case 'timer.set':
85
+ ctx.timers.set(effect.key, { ms: effect.ms, event: effect.event });
86
+ return undefined;
87
+ case 'timer.clear':
88
+ ctx.timers.delete(effect.key);
89
+ return undefined;
90
+ case 'emit':
91
+ ctx.emitted.push(effect.event);
92
+ return undefined;
93
+ case 'dispatch':
94
+ ctx.dispatched.push(effect.event);
95
+ return undefined;
96
+ case 'service':
97
+ throw new UnhandledEffectError(effect);
98
+ case 'all': {
99
+ const results: Settled[] = [];
100
+ for (const inner of effect.effects) {
101
+ try {
102
+ results.push({ ok: true, value: await interpret(inner) });
103
+ } catch (error) {
104
+ results.push({ ok: false, error });
105
+ }
106
+ }
107
+ return results;
108
+ }
109
+ default:
110
+ throw new UnhandledEffectError(effect);
111
+ }
112
+ };
113
+ const result = await runSaga(saga, interpret);
114
+ return { ...ctx, result };
115
+ }
116
+
117
+ /** Handler helper: answer effects by SQL prefix (first match wins). */
118
+ export function bySqlPrefix(
119
+ table: Array<[prefix: string, answer: (effect: Effect & { sql: string }) => unknown]>
120
+ ): EffectHandler {
121
+ return (effect) => {
122
+ const sql = (effect as { sql?: string }).sql ?? '';
123
+ for (const [prefix, answer] of table) {
124
+ if (sql.startsWith(prefix)) return answer(effect as Effect & { sql: string });
125
+ }
126
+ throw new Error(`no scripted answer for SQL: ${sql.slice(0, 80)}`);
127
+ };
128
+ }
@@ -0,0 +1,17 @@
1
+ import type { Effect, ServiceCalls, ServiceName } from '../kernel/effects';
2
+ import type { EffectHandler } from './run-pure';
3
+
4
+ type Impl = { [N in ServiceName]?: (...args: Parameters<ServiceCalls[N]>) => unknown };
5
+
6
+ /** Script the `service` effect by name and record every call. */
7
+ export function fakeServices(impl: Impl = {}) {
8
+ const calls: Array<[ServiceName, unknown[]]> = [];
9
+ const handler: EffectHandler = (effect: Effect) => {
10
+ if (effect.kind !== 'service') throw new Error('not a service effect');
11
+ calls.push([effect.name, effect.args]);
12
+ const fn = impl[effect.name] as ((...a: unknown[]) => unknown) | undefined;
13
+ if (!fn) return undefined;
14
+ return fn(...effect.args);
15
+ };
16
+ return { handler, calls, names: () => calls.map(([n]) => n) };
17
+ }