@spooky-sync/core 0.0.1-canary.38 → 0.0.1-canary.381

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 (266) hide show
  1. package/AGENTS.md +91 -0
  2. package/README.md +59 -16
  3. package/dist/index.d.ts +2238 -144
  4. package/dist/index.js +18149 -2475
  5. package/dist/live.d.ts +149 -0
  6. package/dist/live.js +739 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/pure.d.ts +401 -0
  9. package/dist/pure.js +24 -0
  10. package/dist/sqlite-open.js +392 -0
  11. package/dist/sqlite-worker.d.ts +1 -0
  12. package/dist/sqlite-worker.js +439 -0
  13. package/dist/sw.d.ts +207 -0
  14. package/dist/sw.js +393 -0
  15. package/dist/tabs-broker-worker.d.ts +8 -0
  16. package/dist/tabs-broker-worker.js +477 -0
  17. package/dist/types.d.ts +2082 -39
  18. package/dist/worker-edges.js +498 -0
  19. package/dist/worker-impersonation.js +60 -0
  20. package/dist/worker-ref-tables.d.ts +48 -0
  21. package/dist/worker-types.d.ts +112 -0
  22. package/dist/worker-types.js +73 -0
  23. package/package.json +28 -7
  24. package/scripts/check-broker-bundle.mjs +33 -0
  25. package/scripts/check-worker-entries.mjs +173 -0
  26. package/skills/sp00ky-core/SKILL.md +42 -8
  27. package/skills/sp00ky-core/references/config.md +8 -2
  28. package/src/boot/auth-flip.saga.test.ts +174 -0
  29. package/src/boot/auth-flip.saga.ts +77 -0
  30. package/src/boot/boot.saga.test.ts +203 -0
  31. package/src/boot/boot.saga.ts +146 -0
  32. package/src/boot/bucket-switch.saga.test.ts +191 -0
  33. package/src/boot/bucket-switch.saga.ts +151 -0
  34. package/src/boot/preload.saga.test.ts +96 -0
  35. package/src/boot/preload.saga.ts +45 -0
  36. package/src/bucket-blurhash.test.ts +148 -0
  37. package/src/build-globals.d.ts +12 -0
  38. package/src/client/bucket-handle.test.ts +195 -0
  39. package/src/client/bucket-handle.ts +352 -0
  40. package/src/client/router.test.ts +59 -0
  41. package/src/client/router.ts +104 -0
  42. package/src/client/runtime.test.ts +319 -0
  43. package/src/client/runtime.ts +307 -0
  44. package/src/client/services.test.ts +74 -0
  45. package/src/client/services.ts +437 -0
  46. package/src/client/sp00ky-client.test.ts +428 -0
  47. package/src/client/sp00ky-client.ts +737 -0
  48. package/src/events/index.ts +3 -0
  49. package/src/index.ts +86 -1
  50. package/src/kernel/constants.test.ts +19 -0
  51. package/src/kernel/constants.ts +94 -0
  52. package/src/kernel/effects.test.ts +40 -0
  53. package/src/kernel/effects.ts +171 -0
  54. package/src/kernel/events.ts +60 -0
  55. package/src/kernel/interpreter.test.ts +116 -0
  56. package/src/kernel/interpreter.ts +148 -0
  57. package/src/kernel/saga.test.ts +89 -0
  58. package/src/kernel/saga.ts +73 -0
  59. package/src/live/index.test.ts +512 -0
  60. package/src/live/index.ts +1059 -0
  61. package/src/modules/app-release/index.test.ts +125 -0
  62. package/src/modules/app-release/index.ts +209 -0
  63. package/src/modules/auth/auth.local-first.test.ts +228 -0
  64. package/src/modules/auth/auth.session-recheck.test.ts +140 -0
  65. package/src/modules/auth/events/index.ts +7 -1
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.integration.test.ts +209 -0
  68. package/src/modules/auth/impersonation.test.ts +377 -0
  69. package/src/modules/auth/impersonation.ts +179 -0
  70. package/src/modules/auth/index.ts +538 -14
  71. package/src/modules/channel/channel.test.ts +752 -0
  72. package/src/modules/channel/index.ts +1153 -0
  73. package/src/modules/channel/webrtc.ts +472 -0
  74. package/src/modules/crdt/acceptance.ts +75 -0
  75. package/src/modules/crdt/bytes.ts +50 -0
  76. package/src/modules/crdt/crdt-field.ts +125 -0
  77. package/src/modules/crdt/crdt.test.ts +546 -0
  78. package/src/modules/crdt/hash.ts +103 -0
  79. package/src/modules/crdt/index.ts +821 -0
  80. package/src/modules/crdt/loro-loader.ts +25 -0
  81. package/src/modules/crdt/mirror.ts +37 -0
  82. package/src/modules/devtools/flags.ts +349 -0
  83. package/src/modules/devtools/index.ts +746 -93
  84. package/src/modules/devtools/mutations.test.ts +92 -0
  85. package/src/modules/devtools/mutations.ts +160 -0
  86. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  87. package/src/modules/devtools/state-shape.test.ts +263 -0
  88. package/src/modules/devtools/storage-info.test.ts +79 -0
  89. package/src/modules/devtools/storage-info.ts +168 -0
  90. package/src/modules/devtools/versions.test.ts +74 -0
  91. package/src/modules/devtools/versions.ts +110 -0
  92. package/src/modules/feature-flag/index.test.ts +298 -0
  93. package/src/modules/feature-flag/index.ts +316 -0
  94. package/src/modules/ref-tables.test.ts +93 -0
  95. package/src/modules/ref-tables.ts +91 -0
  96. package/src/modules/web-push/index.test.ts +824 -0
  97. package/src/modules/web-push/index.ts +1027 -0
  98. package/src/mutation/ingest-outage.integration.test.ts +400 -0
  99. package/src/mutation/jobs.test.ts +25 -0
  100. package/src/mutation/jobs.ts +41 -0
  101. package/src/mutation/mutation-id.test.ts +37 -0
  102. package/src/mutation/mutation-id.ts +35 -0
  103. package/src/mutation/pending-writes.integration.test.ts +175 -0
  104. package/src/mutation/push.saga.test.ts +361 -0
  105. package/src/mutation/push.saga.ts +245 -0
  106. package/src/mutation/rows.test.ts +278 -0
  107. package/src/mutation/rows.ts +515 -0
  108. package/src/mutation/tray.saga.test.ts +63 -0
  109. package/src/mutation/tray.saga.ts +48 -0
  110. package/src/mutation/write.saga.test.ts +436 -0
  111. package/src/mutation/write.saga.ts +384 -0
  112. package/src/pure.ts +66 -0
  113. package/src/push/types.ts +174 -0
  114. package/src/query/anon-query-isolation.integration.test.ts +531 -0
  115. package/src/query/env.test.ts +23 -0
  116. package/src/query/env.ts +65 -0
  117. package/src/query/fetch.saga.test.ts +199 -0
  118. package/src/query/fetch.saga.ts +120 -0
  119. package/src/query/hash.test.ts +14 -0
  120. package/src/query/hash.ts +18 -0
  121. package/src/query/id-set-plan.test.ts +132 -0
  122. package/src/query/lifecycle.saga.test.ts +274 -0
  123. package/src/query/lifecycle.saga.ts +186 -0
  124. package/src/query/list-ref-live.integration.test.ts +203 -0
  125. package/src/query/materialize.saga.test.ts +200 -0
  126. package/src/query/materialize.saga.ts +102 -0
  127. package/src/query/materialize.test.ts +58 -0
  128. package/src/query/materialize.ts +57 -0
  129. package/src/query/membership.saga.test.ts +514 -0
  130. package/src/query/membership.saga.ts +339 -0
  131. package/src/query/membership.test.ts +151 -0
  132. package/src/query/membership.ts +234 -0
  133. package/src/query/register.saga.test.ts +369 -0
  134. package/src/query/register.saga.ts +293 -0
  135. package/src/query/render-set.test.ts +47 -0
  136. package/src/query/render-set.ts +71 -0
  137. package/src/query/session-guard.integration.test.ts +304 -0
  138. package/src/query/sql.test.ts +118 -0
  139. package/src/query/sql.ts +174 -0
  140. package/src/query/window-query.test.ts +70 -0
  141. package/src/query/window-query.ts +208 -0
  142. package/src/services/blobs/blob-cache.test.ts +359 -0
  143. package/src/services/blobs/blob-cache.ts +603 -0
  144. package/src/services/blobs/blob-manifest.ts +227 -0
  145. package/src/services/blobs/blob-store.test.ts +77 -0
  146. package/src/services/blobs/blob-store.ts +359 -0
  147. package/src/services/blobs/blob.fixture.ts +90 -0
  148. package/src/services/blobs/index.ts +70 -0
  149. package/src/services/database/cache-engine.ts +209 -0
  150. package/src/services/database/connection-supervisor.test.ts +318 -0
  151. package/src/services/database/connection-supervisor.ts +422 -0
  152. package/src/services/database/database.query-timeout.test.ts +140 -0
  153. package/src/services/database/database.responses.test.ts +43 -0
  154. package/src/services/database/database.ts +151 -56
  155. package/src/services/database/engine-factory.ts +33 -0
  156. package/src/services/database/errors.ts +34 -0
  157. package/src/services/database/index.ts +7 -0
  158. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  159. package/src/services/database/local-migrator.integration.test.ts +110 -0
  160. package/src/services/database/local-migrator.ts +54 -9
  161. package/src/services/database/local.test.ts +150 -0
  162. package/src/services/database/local.ts +503 -59
  163. package/src/services/database/plan-render.test.ts +159 -0
  164. package/src/services/database/plan-render.ts +108 -0
  165. package/src/services/database/relation-resolver.test.ts +484 -0
  166. package/src/services/database/relation-resolver.ts +0 -0
  167. package/src/services/database/remote.ts +283 -4
  168. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  169. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  170. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  171. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  172. package/src/services/database/sqlite-column-reset.ts +91 -0
  173. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  174. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  175. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  176. package/src/services/database/sqlite-lock-verify.ts +45 -0
  177. package/src/services/database/sqlite-open.test.ts +150 -0
  178. package/src/services/database/sqlite-open.ts +164 -0
  179. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  180. package/src/services/database/sqlite-plan-sql.ts +196 -0
  181. package/src/services/database/sqlite-projection.test.ts +121 -0
  182. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  183. package/src/services/database/sqlite-select.test.ts +261 -0
  184. package/src/services/database/sqlite-select.ts +131 -0
  185. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  186. package/src/services/database/sqlite-transport.ts +224 -0
  187. package/src/services/database/sqlite-worker.ts +437 -0
  188. package/src/services/database/surql-translate.ts +417 -0
  189. package/src/services/database/surreal-cache-engine.ts +163 -0
  190. package/src/services/logger/index.test.ts +132 -0
  191. package/src/services/logger/index.ts +187 -6
  192. package/src/services/persistence/resilient.ts +8 -1
  193. package/src/services/persistence/surrealdb.ts +2 -2
  194. package/src/services/stream-processor/index.ts +801 -63
  195. package/src/services/stream-processor/permissions.test.ts +47 -0
  196. package/src/services/stream-processor/permissions.ts +53 -0
  197. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  198. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  199. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  200. package/src/services/stream-processor/wasm-types.ts +57 -1
  201. package/src/services/tabs/broker-client.ts +284 -0
  202. package/src/services/tabs/broker.test.ts +381 -0
  203. package/src/services/tabs/coordinator.test.ts +386 -0
  204. package/src/services/tabs/coordinator.ts +646 -0
  205. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  206. package/src/services/tabs/leader-locks.ts +75 -0
  207. package/src/services/tabs/protocol.ts +262 -0
  208. package/src/services/tabs/support.ts +36 -0
  209. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  210. package/src/sp00ky.ts +5 -394
  211. package/src/state/client-state.ts +231 -0
  212. package/src/state/lifecycle.test.ts +184 -0
  213. package/src/state/lifecycle.ts +127 -0
  214. package/src/state/reducers.test.ts +337 -0
  215. package/src/state/reducers.ts +571 -0
  216. package/src/state/selectors.test.ts +203 -0
  217. package/src/state/selectors.ts +264 -0
  218. package/src/sw/index.test.ts +582 -0
  219. package/src/sw/index.ts +607 -0
  220. package/src/sw/store.ts +96 -0
  221. package/src/sync/connection.saga.test.ts +97 -0
  222. package/src/sync/connection.saga.ts +89 -0
  223. package/src/sync/live.saga.test.ts +229 -0
  224. package/src/sync/live.saga.ts +116 -0
  225. package/src/sync/policy.health.test.ts +56 -0
  226. package/src/sync/policy.test.ts +663 -0
  227. package/src/sync/policy.ts +515 -0
  228. package/src/sync/poll.saga.test.ts +51 -0
  229. package/src/sync/poll.saga.ts +36 -0
  230. package/src/sync/tabs.saga.test.ts +78 -0
  231. package/src/sync/tabs.saga.ts +96 -0
  232. package/src/testing/adapters.ts +137 -0
  233. package/src/testing/build.ts +63 -0
  234. package/src/testing/fake-services.ts +63 -0
  235. package/src/testing/run-pure.test.ts +92 -0
  236. package/src/testing/run-pure.ts +128 -0
  237. package/src/testing/services.ts +17 -0
  238. package/src/types.ts +694 -7
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +57 -0
  241. package/src/utils/error-classification.test.ts +116 -0
  242. package/src/utils/error-classification.ts +74 -0
  243. package/src/utils/index.ts +83 -6
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +63 -2
  246. package/src/utils/semver.test.ts +32 -0
  247. package/src/utils/semver.ts +30 -0
  248. package/src/utils/sha256.ts +15 -0
  249. package/src/utils/surql.ts +22 -11
  250. package/src/utils/utils.test.ts +3 -2
  251. package/tsdown.config.ts +117 -3
  252. package/vitest.config.ts +48 -0
  253. package/src/modules/cache/index.ts +0 -241
  254. package/src/modules/cache/types.ts +0 -19
  255. package/src/modules/data/data.test.ts +0 -58
  256. package/src/modules/data/index.ts +0 -882
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -78
  259. package/src/modules/sync/index.ts +0 -3
  260. package/src/modules/sync/queue/index.ts +0 -2
  261. package/src/modules/sync/queue/queue-down.ts +0 -90
  262. package/src/modules/sync/queue/queue-up.ts +0 -224
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -422
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -169
@@ -0,0 +1,400 @@
1
+ import { createServer, type Server } from 'node:http';
2
+ import { Socket } from 'node:net';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
7
+ import { RecordId, Surreal } from 'surrealdb';
8
+ import { liveEndpoint } from '../live/index';
9
+ import { defaultEnv } from '../query/env';
10
+ import { RemoteDatabaseService } from '../services/database/remote';
11
+ import { buildState } from '../testing/build';
12
+ import { runPure } from '../testing/run-pure';
13
+ import type { ClientState } from '../state/client-state';
14
+ import type { OutEvent } from '../kernel/events';
15
+ import type { StatementResult } from '../kernel/effects';
16
+ import { mintMutationId } from './mutation-id';
17
+ import { drain, loadOutbox } from './push.saga';
18
+ import { BATCH_SKIPPED } from './rows';
19
+ import { write } from './write.saga';
20
+
21
+ /**
22
+ * Integration: a queued write that SurrealDB refuses because sp00ky's OWN
23
+ * ingest post failed (scheduler/SSP down, slow past the event TIMEOUT, or
24
+ * answering 5xx) must stay in the outbox and land once `/ingest` is back. It
25
+ * used to read as an application error, so the client rolled the write back
26
+ * and discarded it: silent data loss on every scheduler outage.
27
+ *
28
+ * Runs the real `write` and `drain` sagas, the client's real remote adapter,
29
+ * and the `http::post` line `spky migrate` generates (read from the Rust
30
+ * source, so the two cannot drift) against a real SurrealDB, with an HTTP sink
31
+ * in this process playing the scheduler. Boot one first, e.g.:
32
+ * docker run -d --name spooky-surreal-it -p 18011:8000 \
33
+ * surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
34
+ *
35
+ * Override the endpoint with SURREAL_IT_ENDPOINT and the host SurrealDB reaches
36
+ * this process at with SURREAL_IT_SINK_HOST (default `host.docker.internal`;
37
+ * `127.0.0.1` for a SurrealDB running on the host). Skips if unreachable.
38
+ */
39
+ const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
40
+ const sinkHost = process.env.SURREAL_IT_SINK_HOST ?? 'host.docker.internal';
41
+ const NS = 'it';
42
+ /** Short so the hang case stays quick; the shipped value is 10s. */
43
+ const EVENT_TIMEOUT_SECS = 2;
44
+
45
+ const cliSource = (file: string) =>
46
+ readFileSync(fileURLToPath(new URL(`../../../../apps/cli/src/${file}`, import.meta.url)), 'utf8');
47
+
48
+ /** The `/ingest` post every generated event makes (`sp00ky.rs::ingest_post`). */
49
+ function ingestPost(): string {
50
+ const src = cliSource('sp00ky.rs');
51
+ const m = /fn ingest_post\(\) -> String \{\s*format!\(\s*"(.+?)\\n",/s.exec(src);
52
+ if (!m) throw new Error('ingest_post format string not found');
53
+ if (!src.includes('"DEFINE EVENT OVERWRITE _00_{}_mutation ON TABLE {}\\n"')) {
54
+ throw new Error('the mutation event is no longer named _00_<table>_mutation');
55
+ }
56
+ return m[1]
57
+ .replaceAll('\\"', '"')
58
+ .replaceAll('{{', '{')
59
+ .replaceAll('}}', '}')
60
+ .replace('{}', String(EVENT_TIMEOUT_SECS));
61
+ }
62
+
63
+ /** `flap` answers the next post 503, then switches itself back to `ok`. */
64
+ type SinkMode = 'ok' | '500' | '503' | 'hang' | 'flap';
65
+
66
+ /** Plays the scheduler's `/ingest` (and an app's own third-party endpoint). */
67
+ function startSink(): Promise<{ server: Server; port: number; mode: { current: SinkMode }; hits: string[] }> {
68
+ const mode = { current: 'ok' as SinkMode };
69
+ const hits: string[] = [];
70
+ const server = createServer((req, res) => {
71
+ req.resume();
72
+ hits.push(`${req.method} ${req.url} ${mode.current}`);
73
+ if (mode.current === 'hang') return; // never answers; SurrealDB's TIMEOUT fires
74
+ if (mode.current === 'flap') {
75
+ mode.current = 'ok';
76
+ res.writeHead(503);
77
+ res.end();
78
+ return;
79
+ }
80
+ if (mode.current === 'ok') {
81
+ res.writeHead(200, { 'content-type': 'application/json' });
82
+ res.end('{}');
83
+ return;
84
+ }
85
+ res.writeHead(Number(mode.current));
86
+ res.end();
87
+ });
88
+ return new Promise((resolve) =>
89
+ server.listen(0, '127.0.0.1', () => {
90
+ const address = server.address();
91
+ resolve({ server, port: typeof address === 'object' && address ? address.port : 0, mode, hits });
92
+ })
93
+ );
94
+ }
95
+
96
+ /** A port nothing listens on: a refused connection. */
97
+ async function closedPort(): Promise<number> {
98
+ const server = createServer();
99
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
100
+ const address = server.address();
101
+ await new Promise<void>((resolve) => server.close(() => resolve()));
102
+ return typeof address === 'object' && address ? address.port : 1;
103
+ }
104
+
105
+ const silent: any = { debug() {}, info() {}, warn() {}, error() {}, trace() {} };
106
+ silent.child = () => silent;
107
+
108
+ const env = defaultEnv({
109
+ tables: [
110
+ { name: 'item', columns: { title: { type: 'string', optional: true } } },
111
+ { name: 'lead', columns: { title: { type: 'string', optional: true } } },
112
+ ],
113
+ } as any);
114
+
115
+ describe('a write refused by a failed ingest post is retried, not rolled back (real SurrealDB)', () => {
116
+ let reachable = false;
117
+ let local: Surreal;
118
+ let remote: RemoteDatabaseService;
119
+ let sink: Awaited<ReturnType<typeof startSink>>;
120
+ let sinkUrl = '';
121
+ let database = '';
122
+ /** Every per-statement error the remote answered, in order. */
123
+ const statementErrors: string[] = [];
124
+
125
+ beforeAll(async () => {
126
+ const url = new URL(endpoint);
127
+ reachable = await new Promise<boolean>((resolve) => {
128
+ const socket = new Socket();
129
+ const done = (ok: boolean) => {
130
+ socket.destroy();
131
+ resolve(ok);
132
+ };
133
+ socket.setTimeout(1_000, () => done(false));
134
+ socket.once('error', () => done(false));
135
+ socket.connect(Number(url.port || 80), url.hostname, () => done(true));
136
+ });
137
+ if (!reachable) return;
138
+
139
+ sink = await startSink();
140
+ sinkUrl = `http://${sinkHost}:${sink.port}`;
141
+
142
+ // The wasm engine loads its binary with `fetch(new URL(..., import.meta.url))`,
143
+ // and Node's fetch has no `file:` scheme: serve that one read from disk.
144
+ const realFetch = globalThis.fetch;
145
+ vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
146
+ const target = input instanceof URL ? input : new URL(String(input));
147
+ if (target.protocol === 'file:') return new Response(await readFile(fileURLToPath(target)));
148
+ return realFetch(input as RequestInfo, init);
149
+ });
150
+ const { createWasmEngines } = await import('@surrealdb/wasm');
151
+ local = new Surreal({ engines: createWasmEngines() });
152
+ await local.connect('mem://');
153
+ await local.use({ namespace: 'n', database: 'd' });
154
+ await local.query('DEFINE TABLE item SCHEMALESS; DEFINE TABLE lead SCHEMALESS; DEFINE TABLE _00_pending_mutations SCHEMALESS;');
155
+
156
+ database = `ingest_outage_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
157
+ remote = new RemoteDatabaseService({ endpoint, namespace: NS, database } as any, silent);
158
+ await remote.connect();
159
+ await remote.signin({ username: 'root', password: 'root' });
160
+ await remote.query(`
161
+ DEFINE PARAM OVERWRITE $sp00ky_endpoint VALUE '${sinkUrl}' PERMISSIONS NONE;
162
+ DEFINE PARAM OVERWRITE $sp00ky_secret VALUE 'it-secret' PERMISSIONS NONE;
163
+ DEFINE TABLE item SCHEMALESS;
164
+ DEFINE EVENT OVERWRITE _00_item_mutation ON TABLE item
165
+ WHEN $before != $after AND $event != "DELETE"
166
+ THEN {
167
+ LET $payload = { table: 'item', op: $event, id: <string>$after.id, record: $after, hash: "" };
168
+ ${ingestPost()}
169
+ };
170
+ DEFINE TABLE lead SCHEMALESS;
171
+ `);
172
+ }, 60_000);
173
+
174
+ afterAll(async () => {
175
+ if (!reachable) return;
176
+ sink.mode.current = 'ok';
177
+ await remote?.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
178
+ await remote?.close().catch(() => undefined);
179
+ await local?.close().catch(() => undefined);
180
+ sink?.server.closeAllConnections();
181
+ await new Promise<void>((resolve) => sink?.server.close(() => resolve()));
182
+ vi.unstubAllGlobals();
183
+ });
184
+
185
+ /**
186
+ * With `loseAnswer` the push reaches the server and commits, then the client
187
+ * sees the push timeout instead of the answer (a slow server, a socket that
188
+ * dropped on the way back).
189
+ */
190
+ const run = (saga: Generator<any, any, any>, state: ClientState, opts: { loseAnswer?: boolean } = {}) =>
191
+ runPure(saga, {
192
+ state,
193
+ now: Date.now(),
194
+ handlers: {
195
+ 'local.query': (e: any) => local.query(e.sql, e.vars) as unknown as Promise<unknown>,
196
+ 'local.execute': async (e: any) => e.query.extract((await local.query(e.query.sql, e.vars)) as unknown[]),
197
+ 'remote.query': async (e: any) => {
198
+ const results = (await remote.queryResponses(e.sql, e.vars)) as StatementResult[];
199
+ for (const r of results) if (r.status === 'ERR') statementErrors.push(r.error);
200
+ if (opts.loseAnswer) throw new Error(`Remote request timed out after ${e.timeoutMs}ms`);
201
+ return results;
202
+ },
203
+ 'ssp.ingest': () => undefined,
204
+ id: () => mintMutationId(state.tabId),
205
+ },
206
+ });
207
+
208
+ const remoteRow = async (id: RecordId<string>) =>
209
+ ((await remote.query('SELECT * FROM ONLY $id', { id })) as [Record<string, unknown> | null])[0] ?? null;
210
+ const localRow = async (id: RecordId<string>) =>
211
+ ((await local.query('SELECT * FROM ONLY $id', { id })) as [Record<string, unknown> | null])[0] ?? null;
212
+ const rolledBack = (emitted: OutEvent[]) => emitted.filter((e) => e.type === 'mutation:rolled-back');
213
+ const rid = (recordId: string) => new RecordId('item', recordId.slice('item:'.length));
214
+ const setEndpoint = (url: string) =>
215
+ remote.query(`DEFINE PARAM OVERWRITE $sp00ky_endpoint VALUE '${url}' PERMISSIONS NONE`);
216
+
217
+ /** Queue creates locally, the way `db.create` does, and return the state with them in the outbox. */
218
+ async function queueCreate(...recordIds: string[]): Promise<ClientState> {
219
+ let state = buildState();
220
+ for (const recordId of recordIds) {
221
+ state = (await run(write(env, { kind: 'create', recordId, data: { title: recordId } }), state)).state;
222
+ }
223
+ expect(state.outbox).toHaveLength(recordIds.length);
224
+ return state;
225
+ }
226
+
227
+ /**
228
+ * The outbox of `state`, loaded again from the local store the way a
229
+ * restarted client does (without what other tests left in the store).
230
+ */
231
+ async function reloaded(state: ClientState): Promise<ClientState> {
232
+ const ids = new Set(state.outbox.map((i) => i.id));
233
+ const loaded = (await run(loadOutbox(env), { ...buildState(), tabId: state.tabId })).state;
234
+ return { ...loaded, outbox: loaded.outbox.filter((i) => ids.has(i.id)) };
235
+ }
236
+
237
+ it.for([
238
+ ['a refused connection', 'refused'],
239
+ ['a post that hangs past the event TIMEOUT', 'hang'],
240
+ ['a 500 from /ingest', '500'],
241
+ ['a 503 from a cold SSP', '503'],
242
+ ] as const)('%s: the write stays queued, then lands once /ingest answers', { timeout: 30_000 }, async ([, failure], ctx) => {
243
+ if (!reachable) ctx.skip();
244
+ const id = new RecordId('item', `outage_${failure}`);
245
+ const state = await queueCreate(`item:outage_${failure}`);
246
+ const errorsBefore = statementErrors.length;
247
+
248
+ await setEndpoint(failure === 'refused' ? `http://${sinkHost}:${await closedPort()}` : sinkUrl);
249
+ sink.mode.current = failure === 'refused' ? 'ok' : failure;
250
+ const failed = await run(drain(env), state);
251
+
252
+ const seen = statementErrors.slice(errorsBefore);
253
+ expect(seen).toHaveLength(1);
254
+ expect(seen[0]).toMatch(
255
+ /Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: /
256
+ );
257
+ expect(rolledBack(failed.emitted)).toEqual([]);
258
+ expect(failed.state.outbox).toEqual([expect.objectContaining({ recordId: `item:outage_${failure}`, attempts: 1 })]);
259
+ expect(failed.timers.get('outbox')).toEqual({ ms: expect.any(Number), event: { type: 'Drain' } });
260
+ expect(await localRow(id)).toMatchObject({ title: `item:outage_${failure}` });
261
+ expect(await remoteRow(id)).toBeNull();
262
+
263
+ // The scheduler is back: the retry lands the write exactly once.
264
+ await setEndpoint(sinkUrl);
265
+ sink.mode.current = 'ok';
266
+ const landed = await run(drain(env), failed.state);
267
+ expect(rolledBack(landed.emitted)).toEqual([]);
268
+ expect(landed.emitted).toContainEqual(expect.objectContaining({ type: 'mutation:settled', recordId: `item:outage_${failure}` }));
269
+ expect(landed.state.outbox.filter((i) => i.status === 'pending')).toEqual([]);
270
+ expect(await remoteRow(id)).toMatchObject({ title: `item:outage_${failure}` });
271
+ });
272
+
273
+ it('an ingest post that fails once mid-batch: the batch stops there, then every write lands once', { timeout: 30_000 }, async (ctx) => {
274
+ if (!reachable) ctx.skip();
275
+ const tag = Math.random().toString(36).slice(2, 8);
276
+ const [a, b] = [`item:flap_a_${tag}`, `item:flap_b_${tag}`];
277
+ const state = await queueCreate(a, b);
278
+ const errorsBefore = statementErrors.length;
279
+
280
+ // The first post (item a's) answers 503, every later one 200. Statements
281
+ // used to run independently, so b committed while a was refused, stayed
282
+ // queued behind a's cut, and its re-sent CREATE then failed "already
283
+ // exists" and was rolled back locally while the server kept the row.
284
+ await setEndpoint(sinkUrl);
285
+ sink.mode.current = 'flap';
286
+ const cut = await run(drain(env), state);
287
+ const seen = statementErrors.slice(errorsBefore);
288
+ expect(seen).toHaveLength(2);
289
+ expect(seen[0]).toMatch(/Error while processing event _00_item_mutation: .*503 Service Unavailable/);
290
+ expect(seen[1]).toContain(BATCH_SKIPPED);
291
+ expect(rolledBack(cut.emitted)).toEqual([]);
292
+ expect(cut.state.outbox.map((i) => [i.recordId, i.status])).toEqual([
293
+ [a, 'pending'],
294
+ [b, 'pending'],
295
+ ]);
296
+ expect(await remoteRow(rid(a))).toBeNull();
297
+ expect(await remoteRow(rid(b))).toBeNull();
298
+
299
+ const landed = await run(drain(env), cut.state);
300
+ expect(statementErrors.slice(errorsBefore + 2)).toEqual([]);
301
+ expect(rolledBack(landed.emitted)).toEqual([]);
302
+ expect(landed.emitted.filter((e) => e.type === 'mutation:settled').map((e: any) => e.recordId)).toEqual([a, b]);
303
+ expect(landed.state.outbox.filter((i) => i.status === 'pending')).toEqual([]);
304
+ expect(landed.state.failedCount).toBe(0);
305
+ for (const id of [a, b]) {
306
+ expect(await remoteRow(rid(id))).toMatchObject({ title: id });
307
+ expect(await localRow(rid(id))).toMatchObject({ title: id });
308
+ }
309
+ });
310
+
311
+ it.for([['in the same session'], ['after a restart']] as const)(
312
+ 'a push whose answer is lost after the server committed, re-sent %s: nothing rolls back, every write lands once',
313
+ { timeout: 30_000 },
314
+ async ([when], ctx) => {
315
+ if (!reachable) ctx.skip();
316
+ await setEndpoint(sinkUrl);
317
+ sink.mode.current = 'ok';
318
+ const tag = Math.random().toString(36).slice(2, 8);
319
+ const [a, b] = [`item:lost_a_${tag}`, `item:lost_b_${tag}`];
320
+ let state = await queueCreate(a, b);
321
+ // Edited before the push: the batch carries a's CREATE and then its UPDATE.
322
+ state = (await run(write(env, { kind: 'update', recordId: a, data: { title: 'edited' } }), state)).state;
323
+ const [errorsBefore, postsBefore] = [statementErrors.length, sink.hits.length];
324
+
325
+ const lost = await run(drain(env), state, { loseAnswer: true });
326
+ expect(statementErrors.slice(errorsBefore)).toEqual([]);
327
+ expect(sink.hits.length - postsBefore).toBe(3); // the server ran CREATE a, CREATE b, UPDATE a
328
+ expect(await remoteRow(rid(a))).toMatchObject({ title: 'edited' });
329
+ expect(await remoteRow(rid(b))).toMatchObject({ title: b });
330
+ expect(rolledBack(lost.emitted)).toEqual([]);
331
+ expect(lost.state.outbox.map((i) => [i.recordId, i.status, i.inDoubt])).toEqual([
332
+ [a, 'pending', true],
333
+ [b, 'pending', true],
334
+ [a, 'pending', true],
335
+ ]);
336
+
337
+ // The re-sent CREATEs used to fail "already exists" and roll back: the
338
+ // local rows deleted, two false entries in the tray, the server keeping both.
339
+ const retry = when === 'after a restart' ? await reloaded(lost.state) : lost.state;
340
+ const landed = await run(drain(env), retry);
341
+ expect(statementErrors.slice(errorsBefore)).toEqual([]);
342
+ expect(rolledBack(landed.emitted)).toEqual([]);
343
+ expect(landed.state.failedCount).toBe(retry.failedCount);
344
+ expect(landed.emitted.filter((e) => e.type === 'mutation:settled').map((e: any) => e.recordId)).toEqual([a, b, a]);
345
+ expect(landed.state.outbox.filter((i) => i.status === 'pending')).toEqual([]);
346
+ // Once: the re-send changed nothing on the server, so no event fired again.
347
+ expect(sink.hits.length - postsBefore).toBe(3);
348
+ expect(await remoteRow(rid(a))).toMatchObject({ title: 'edited' });
349
+ expect(await remoteRow(rid(b))).toMatchObject({ title: b });
350
+ expect(await localRow(rid(a))).toMatchObject({ title: 'edited' });
351
+ expect(await localRow(rid(b))).toMatchObject({ title: b });
352
+ }
353
+ );
354
+
355
+ it('an in-doubt CREATE whose id someone else holds with other content still rolls back', { timeout: 30_000 }, async (ctx) => {
356
+ if (!reachable) ctx.skip();
357
+ await setEndpoint(sinkUrl);
358
+ sink.mode.current = 'ok';
359
+ const recordId = `item:taken_${Math.random().toString(36).slice(2, 8)}`;
360
+ await remote.query('CREATE ONLY $id SET title = "theirs"', { id: rid(recordId) });
361
+ const state = await queueCreate(recordId);
362
+ const errorsBefore = statementErrors.length;
363
+
364
+ const lost = await run(drain(env), state, { loseAnswer: true });
365
+ expect(lost.state.outbox).toEqual([expect.objectContaining({ recordId, inDoubt: true })]);
366
+ const res = await run(drain(env), lost.state);
367
+ expect(statementErrors.slice(errorsBefore)).toEqual([
368
+ expect.stringMatching(/already exists/),
369
+ expect.stringMatching(/already exists/),
370
+ ]);
371
+ expect(rolledBack(res.emitted)).toEqual([expect.objectContaining({ recordId, error: expect.stringMatching(/already exists/) })]);
372
+ expect(res.state.outbox).toEqual([]);
373
+ expect(await remoteRow(rid(recordId))).toMatchObject({ title: 'theirs' });
374
+ expect(await localRow(rid(recordId))).toBeNull();
375
+ });
376
+
377
+ it.for([
378
+ [
379
+ "a failing http::post in the app's own event",
380
+ `DEFINE EVENT OVERWRITE notify_crm ON TABLE lead THEN { SELECT * FROM http::post('${'SINK'}/crm', {}) TIMEOUT 2s; };`,
381
+ /Error while processing event notify_crm: There was an error processing a remote HTTP request: 500/,
382
+ ],
383
+ [
384
+ "a THROW from the app's own event",
385
+ `DEFINE EVENT OVERWRITE notify_crm ON TABLE lead THEN { THROW 'lead refused'; };`,
386
+ /lead refused/,
387
+ ],
388
+ ] as const)('%s still rolls the write back', { timeout: 30_000 }, async ([, ddl, expected], ctx) => {
389
+ if (!reachable) ctx.skip();
390
+ await setEndpoint(sinkUrl);
391
+ await remote.query(ddl.replace('SINK', sinkUrl));
392
+ sink.mode.current = '500';
393
+ const recordId = `lead:${Math.random().toString(36).slice(2, 8)}`;
394
+ const state = await queueCreate(recordId);
395
+ const res = await run(drain(env), state);
396
+ sink.mode.current = 'ok';
397
+ expect(rolledBack(res.emitted)).toEqual([expect.objectContaining({ recordId, error: expect.stringMatching(expected) })]);
398
+ expect(res.state.outbox).toEqual([]);
399
+ });
400
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildJobRecord } from './jobs';
3
+
4
+ const schema = {
5
+ tables: [],
6
+ backends: {
7
+ api: { outboxTable: '_00_job_api', routes: { hello: { args: { name: { optional: false }, tone: { optional: true } } } } },
8
+ bare: { routes: { x: { args: {} } } },
9
+ },
10
+ } as any;
11
+
12
+ describe('buildJobRecord', () => {
13
+ it('builds a pending job row with the route payload and option overrides', () => {
14
+ const { tableName, record } = buildJobRecord(schema, 'api', 'hello', { name: 'a', extra: 1 }, { max_retries: 5, retry_strategy: 'exponential', timeout: 9, delay: 100, assignedTo: 'w1' });
15
+ expect(tableName).toBe('_00_job_api');
16
+ expect(record).toEqual({ path: 'hello', payload: JSON.stringify({ name: 'a', tone: undefined }), status: 'pending', max_retries: 5, retry_strategy: 'exponential', timeout: 9, delay: 100, assigned_to: 'w1' });
17
+ expect(buildJobRecord(schema, 'api', 'hello', { name: 'a' }).record).toMatchObject({ max_retries: 3, retry_strategy: 'linear' });
18
+ });
19
+ it('rejects unknown routes, missing outbox tables and missing required args', () => {
20
+ expect(() => buildJobRecord(schema, 'api', 'nope', {})).toThrow('Route api.nope not found');
21
+ expect(() => buildJobRecord(schema, 'bare', 'x', {})).toThrow('Outbox table for backend bare not found');
22
+ expect(() => buildJobRecord(schema, 'api', 'hello', {})).toThrow('Missing required argument name');
23
+ expect(() => buildJobRecord({ tables: [] } as any, 'api', 'hello', {})).toThrow('Route api.hello not found');
24
+ });
25
+ });
@@ -0,0 +1,41 @@
1
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
2
+ import type { RunOptions } from '../types';
3
+
4
+ /**
5
+ * Build the outbox job record for a backend route and resolve its table.
6
+ * Every job is a single execution; recurring work is declared server-side.
7
+ */
8
+ export function buildJobRecord(
9
+ schema: SchemaStructure,
10
+ backend: string,
11
+ path: string,
12
+ data: Record<string, unknown>,
13
+ options?: RunOptions
14
+ ): { tableName: string; record: Record<string, unknown> } {
15
+ const backends = (schema as { backends?: Record<string, { outboxTable?: string; routes?: Record<string, { args: Record<string, { optional: boolean }> }> }> }).backends;
16
+ const route = backends?.[backend]?.routes?.[path];
17
+ if (!route) throw new Error(`Route ${backend}.${path} not found`);
18
+ const tableName = backends?.[backend]?.outboxTable;
19
+ if (!tableName) throw new Error(`Outbox table for backend ${backend} not found`);
20
+
21
+ const payload: Record<string, unknown> = {};
22
+ for (const argName of Object.keys(route.args)) {
23
+ const arg = route.args[argName];
24
+ if (data[argName] === undefined && arg.optional === false) throw new Error(`Missing required argument ${argName}`);
25
+ payload[argName] = data[argName];
26
+ }
27
+
28
+ const record: Record<string, unknown> = {
29
+ path,
30
+ payload: JSON.stringify(payload),
31
+ // Explicit: the schema's server-side DEFAULT does not apply to an
32
+ // optimistic local create, and in-flight indicators key on `pending`.
33
+ status: 'pending',
34
+ max_retries: options?.max_retries ?? 3,
35
+ retry_strategy: options?.retry_strategy ?? 'linear',
36
+ };
37
+ if (options?.timeout != null) record.timeout = options.timeout;
38
+ if (options?.delay != null) record.delay = options.delay;
39
+ if (options?.assignedTo) record.assigned_to = options.assignedTo;
40
+ return { tableName, record };
41
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mintMutationId, mutationOwnerTabId } from './mutation-id';
3
+
4
+ describe('mutationOwnerTabId (raw ids)', () => {
5
+ it('mints with the fallback tab id when none is given', () => {
6
+ const id = mintMutationId();
7
+ expect(id.startsWith('_00_pending_mutations:')).toBe(true);
8
+ expect(mutationOwnerTabId(id)).toMatch(/^[a-z0-9]+$/);
9
+ });
10
+ it('accepts an id without the table prefix', () => {
11
+ expect(mutationOwnerTabId('1700000000000_0001_tab9')).toBe('tab9');
12
+ expect(mutationOwnerTabId('1700000000000')).toBeNull();
13
+ });
14
+ });
15
+
16
+ // The old `_00_pending_mutations:${Date.now()}` collided within a millisecond
17
+ // (guaranteed once multiple tabs share a store) and never actually ordered the
18
+ // drain. These pin the new id's three properties: unique, sortable, routable.
19
+ describe('mutation ids', () => {
20
+ it('never collides within a burst', () => {
21
+ const ids = new Set(Array.from({ length: 1000 }, () => mintMutationId('tabx')));
22
+ expect(ids.size).toBe(1000);
23
+ });
24
+
25
+ it('sorts lexicographically in mint order (ORDER BY id is chronological)', () => {
26
+ const ids = Array.from({ length: 200 }, () => mintMutationId('tabx'));
27
+ expect([...ids].sort()).toEqual(ids);
28
+ });
29
+
30
+ it('routes back to the owning tab', () => {
31
+ expect(mutationOwnerTabId(mintMutationId('tab-abc'))).toBe('tab-abc');
32
+ });
33
+
34
+ it('returns null for a legacy numeric id instead of misrouting', () => {
35
+ expect(mutationOwnerTabId('_00_pending_mutations:1753875000000')).toBeNull();
36
+ });
37
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Outbox mutation ids. Previously `_00_pending_mutations:${Date.now()}`, which
3
+ * (a) collides when two mutations land in the same millisecond, a real case
4
+ * once multiple tabs share one store, and (b) never actually ordered the
5
+ * drain, because `loadFromDatabase` sorted by a `created_at` field that does
6
+ * not exist on the table. The new shape is sortable AND collision-free:
7
+ *
8
+ * <13-digit zero-padded ms timestamp>_<4-digit base36 seq>_<tabId>
9
+ *
10
+ * Lexicographic order == chronological order (per tab; cross-tab within-ms
11
+ * ordering is arbitrary, which matches reality), so `ORDER BY id` in the
12
+ * drain is now meaningful. The tabId suffix also routes rollbacks back to the
13
+ * tab that owns the mutation in shared-tabs mode.
14
+ */
15
+
16
+ let seq = 0;
17
+
18
+ /** Tab identity for mutation ids in SOLO mode, minted once per session. In
19
+ * shared-tabs mode the coordinator's tabId is passed in instead. */
20
+ const FALLBACK_TAB_ID = Math.random().toString(36).slice(2, 8);
21
+
22
+ export function mintMutationId(tabId?: string): string {
23
+ const ts = Date.now().toString().padStart(13, '0');
24
+ const n = (seq = (seq + 1) % 36 ** 4).toString(36).padStart(4, '0');
25
+ return `_00_pending_mutations:${ts}_${n}_${tabId ?? FALLBACK_TAB_ID}`;
26
+ }
27
+
28
+ /** The tab that created the mutation, or null for a legacy numeric id. */
29
+ export function mutationOwnerTabId(mutationId: string): string | null {
30
+ const raw = mutationId.startsWith('_00_pending_mutations:')
31
+ ? mutationId.slice('_00_pending_mutations:'.length)
32
+ : mutationId;
33
+ const parts = raw.split('_');
34
+ return parts.length >= 3 ? parts[parts.length - 1] : null;
35
+ }