@spooky-sync/core 0.0.1-canary.334 → 0.0.1-canary.335

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.
@@ -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
+ });
@@ -6,11 +6,18 @@ import * as R from '../state/reducers';
6
6
  import { defaultEnv } from '../query/env';
7
7
  import { drain, loadOutbox, refreshFailedCount, rollback } from './push.saga';
8
8
  import type { StatementResult } from '../kernel/effects';
9
- import type { PendingMutationRow } from './rows';
9
+ import { BATCH_SKIPPED, type PendingMutationRow } from './rows';
10
10
 
11
11
  const env = defaultEnv({ tables: [{ name: 'thing', columns: {} }] } as any, { outboxBatchSize: 2 });
12
12
  const ok = (result: unknown = null): StatementResult => ({ status: 'OK', result });
13
13
  const err = (error: string): StatementResult => ({ status: 'ERR', error });
14
+ /** What SurrealDB answers for a chained statement whose predecessor failed. */
15
+ const skipped = (): StatementResult => err(`An error occurred: ${BATCH_SKIPPED}`);
16
+ /** The row numbers a pushed batch carries, from its `$id<i>` vars. */
17
+ const pushedRows = (vars: Record<string, unknown>) =>
18
+ Object.keys(vars)
19
+ .filter((k) => /^id\d+$/.test(k))
20
+ .map((k) => String((vars[k] as RecordId).id));
14
21
  const pendingRow = (n: number, type: PendingMutationRow['mutationType'] = 'create', extra: Partial<PendingMutationRow> = {}) => ({
15
22
  id: `_00_pending_mutations:m${n}`,
16
23
  mutationType: type,
@@ -56,7 +63,10 @@ describe('drain', () => {
56
63
  },
57
64
  },
58
65
  });
59
- expect(pushed).toEqual(['CREATE ONLY $id0 SET a = $d0_a;\nCREATE ONLY $id1 SET a = $d1_a', 'CREATE ONLY $id0 SET a = $d0_a']);
66
+ expect(pushed).toEqual([
67
+ `LET $ok0 = { CREATE ONLY $id0 SET a = $d0_a; true };\nLET $ok1 = { IF !$ok0 { THROW "${BATCH_SKIPPED}" }; CREATE ONLY $id1 SET a = $d1_a; true }`,
68
+ 'LET $ok0 = { CREATE ONLY $id0 SET a = $d0_a; true }',
69
+ ]);
60
70
  expect(out.state.outbox.every((i) => i.status === 'acked' && i.ackedAt === 500)).toBe(true);
61
71
  expect(out.log.filter((e) => e.kind === 'local.query' && (e as any).sql === 'DELETE $mid')).toHaveLength(3);
62
72
  expect(out.emitted.filter((e) => e.type === 'mutation:settled')).toHaveLength(3);
@@ -64,34 +74,49 @@ describe('drain', () => {
64
74
  expect(out.dispatched.filter((d) => d.type === 'SyncOutcome')).toEqual([{ type: 'SyncOutcome', ok: true, error: undefined }, { type: 'SyncOutcome', ok: true, error: undefined }]);
65
75
  });
66
76
 
67
- it('a rejected statement rolls back only that row; the rest of the batch is acked', async () => {
77
+ it('a rejected statement rolls back only that row; the rows the server skipped after it go out again at once', async () => {
78
+ const pushed: string[][] = [];
68
79
  const out = await runPure(drain(env), {
69
- state: stateWith([1, 2]),
80
+ state: stateWith([1, 2, 3]),
70
81
  handlers: {
71
- 'local.query': (e: any) => (e.sql === 'SELECT * FROM $ids' ? [[pendingRow(1), pendingRow(2)]] : []),
72
- 'remote.query': () => [err('permission denied'), ok()],
82
+ 'local.query': (e: any) =>
83
+ e.sql === 'SELECT * FROM $ids' ? [e.vars.ids.map((id: RecordId) => pendingRow(Number(String(id.id).slice(1))))] : [],
84
+ 'remote.query': (e: any) => {
85
+ pushed.push(pushedRows(e.vars));
86
+ return pushed.length === 1 ? [err('permission denied'), skipped()] : e.sql.split(';\n').map(() => ok());
87
+ },
73
88
  'local.execute': () => undefined,
74
89
  'ssp.ingest': () => undefined,
75
90
  },
76
91
  });
77
- expect(out.state.outbox.map((i) => [i.id, i.status])).toEqual([['_00_pending_mutations:m2', 'acked']]);
92
+ expect(pushed).toEqual([['1', '2'], ['2', '3']]);
93
+ expect(out.state.outbox.map((i) => [i.id, i.status, i.attempts])).toEqual([
94
+ ['_00_pending_mutations:m2', 'acked', 0],
95
+ ['_00_pending_mutations:m3', 'acked', 0],
96
+ ]);
78
97
  expect(out.state.failedCount).toBe(1);
79
- expect(out.emitted).toContainEqual(expect.objectContaining({ type: 'mutation:rolled-back', mutationId: '_00_pending_mutations:m1', error: 'permission denied' }));
80
- expect(out.dispatched.at(-1)).toEqual({ type: 'SyncOutcome', ok: true, error: undefined });
98
+ expect(out.emitted.filter((e) => e.type === 'mutation:rolled-back')).toEqual([
99
+ expect.objectContaining({ mutationId: '_00_pending_mutations:m1', error: 'permission denied' }),
100
+ ]);
101
+ expect(out.timers.has('outbox')).toBe(false);
102
+ expect(out.dispatched.filter((d) => d.type === 'SyncOutcome').every((d: any) => d.ok)).toBe(true);
81
103
  });
82
104
 
83
105
  it('a CRDT edit-log CREATE that finds its row already there is acked, any other duplicate rolls back', async () => {
84
106
  const logEnv = defaultEnv({ tables: [{ name: 'thing', columns: {} }, { name: 'doc__crdt_log', columns: {} }] } as any, { outboxBatchSize: 2 });
107
+ const logRow = pendingRow(1, 'create', { tableName: 'doc__crdt_log', recordId: 'doc__crdt_log:1' });
108
+ let calls = 0;
85
109
  const out = await runPure(drain(logEnv), {
86
110
  state: stateWith([1, 2]),
87
111
  handlers: {
88
112
  'local.query': (e: any) =>
89
- e.sql === 'SELECT * FROM $ids'
90
- ? [[pendingRow(1, 'create', { tableName: 'doc__crdt_log', recordId: 'doc__crdt_log:1' }), pendingRow(2)]]
91
- : [],
92
- 'remote.query': () => [err('Database record `doc__crdt_log:1` already exists'), err('Database record `thing:2` already exists')],
113
+ e.sql === 'SELECT * FROM $ids' ? [[logRow, pendingRow(2)].filter((r) => e.vars.ids.some((id: RecordId) => r.id.endsWith(String(id.id))))] : [],
114
+ // The duplicate log row still stops the chain on the server: row 2 goes again on its own.
115
+ 'remote.query': () =>
116
+ ++calls === 1 ? [err('Database record `doc__crdt_log:1` already exists'), skipped()] : [err('Database record `thing:2` already exists')],
93
117
  },
94
118
  });
119
+ expect(calls).toBe(2);
95
120
  expect(out.state.outbox.find((i) => i.id === '_00_pending_mutations:m1')?.status).toBe('acked');
96
121
  expect(out.state.outbox.some((i) => i.id === '_00_pending_mutations:m2')).toBe(false);
97
122
  });
@@ -108,6 +133,8 @@ describe('drain', () => {
108
133
  });
109
134
  expect(thrown.state.outbox.map((i) => i.status)).toEqual(['pending', 'pending']);
110
135
  expect(thrown.state.outbox[0].attempts).toBe(1);
136
+ // No answer: the server may have committed any of them.
137
+ expect(thrown.state.outbox.map((i) => i.inDoubt)).toEqual([true, true]);
111
138
  expect(thrown.timers.get('outbox')).toEqual({ ms: 500, event: { type: 'Drain' } });
112
139
  expect(thrown.dispatched).toEqual([{ type: 'SyncOutcome', ok: false, error: new Error('socket closed') }]);
113
140
  const mid = await runPure(drain(env), {
@@ -115,12 +142,14 @@ describe('drain', () => {
115
142
  handlers: { 'local.query': () => [[pendingRow(1), pendingRow(2)]], 'remote.query': () => [ok(), err('connection reset')] },
116
143
  });
117
144
  expect(mid.state.outbox.map((i) => [i.status, i.attempts])).toEqual([['acked', 0], ['pending', 1]]);
145
+ // A failed statement did not commit, so its row is not in doubt.
146
+ expect(mid.state.outbox[1].inDoubt).toBeUndefined();
118
147
  expect(mid.timers.get('outbox')!.ms).toBe(500);
119
148
  const short = await runPure(drain(env), {
120
149
  state: stateWith([1, 2]),
121
150
  handlers: { 'local.query': () => [[pendingRow(1), pendingRow(2)]], 'remote.query': () => [ok()] },
122
151
  });
123
- expect(short.state.outbox.map((i) => i.status)).toEqual(['acked', 'pending']);
152
+ expect(short.state.outbox.map((i) => [i.status, i.inDoubt])).toEqual([['acked', undefined], ['pending', true]]);
124
153
  const undefinedRows = await runPure(drain(env), { state: stateWith([1]), handlers: { 'local.query': () => undefined } });
125
154
  expect(undefinedRows.state.outbox).toEqual([]);
126
155
  const notArray = await runPure(drain(env), {
@@ -130,6 +159,37 @@ describe('drain', () => {
130
159
  expect(notArray.timers.has('outbox')).toBe(true);
131
160
  });
132
161
 
162
+ it('a push whose answer was lost goes out again with CREATEs that accept their own earlier commit', async () => {
163
+ const pushed: string[] = [];
164
+ const rowsOf = (e: any) => [e.vars.ids.map((id: RecordId) => pendingRow(Number(String(id.id).slice(1))))];
165
+ const out = await runPure(drain(env), {
166
+ state: stateWith([1, 2]),
167
+ handlers: {
168
+ 'local.query': (e: any) => (e.sql === 'SELECT * FROM $ids' ? rowsOf(e) : []),
169
+ 'remote.query': (e: any) => {
170
+ pushed.push(e.sql);
171
+ // The server ran the batch; the answer never came back.
172
+ if (pushed.length === 1) throw new Error('Remote request timed out after 15000ms');
173
+ return [ok(), ok()];
174
+ },
175
+ },
176
+ });
177
+ expect(out.timers.get('outbox')).toEqual({ ms: 500, event: { type: 'Drain' } });
178
+ const again = await runPure(drain(env), {
179
+ state: out.state,
180
+ handlers: {
181
+ 'local.query': (e: any) => (e.sql === 'SELECT * FROM $ids' ? rowsOf(e) : []),
182
+ 'remote.query': (e: any) => (pushed.push(e.sql), [ok(), ok()]),
183
+ },
184
+ });
185
+ const tolerant = (i: number) =>
186
+ `LET $was${i} = (SELECT * FROM ONLY $id${i}); IF ($was${i} = NONE) OR ($was${i}.a != $d${i}_a) { CREATE ONLY $id${i} SET a = $d${i}_a }`;
187
+ expect(pushed[0]).not.toContain('$was');
188
+ expect(pushed[1]).toBe(`LET $ok0 = { ${tolerant(0)}; true };\nLET $ok1 = { IF !$ok0 { THROW "${BATCH_SKIPPED}" }; ${tolerant(1)}; true }`);
189
+ expect(again.emitted.filter((e) => e.type === 'mutation:rolled-back')).toEqual([]);
190
+ expect(again.state.outbox.map((i) => i.status)).toEqual(['acked', 'acked']);
191
+ });
192
+
133
193
  it('a request rejected as a whole with an application error rolls back the head and continues', async () => {
134
194
  let calls = 0;
135
195
  const out = await runPure(drain(env), {
@@ -19,6 +19,7 @@ import {
19
19
  planRevert,
20
20
  remoteBatch,
21
21
  toOutboxItem,
22
+ BATCH_SKIPPED,
22
23
  FAILED_TABLE,
23
24
  type PendingMutationRow,
24
25
  } from './rows';
@@ -71,6 +72,8 @@ const ERR = (r: StatementResult | undefined): string | null => (r && r.status ==
71
72
  * A CRDT edit-log row (`<table>__crdt_log`) is written once and never changes,
72
73
  * so a CREATE retried after its first attempt committed (and only the answer
73
74
  * was lost) finds it already there: that is a success, not a rejection.
75
+ * `remoteBatch` already makes such a CREATE a no-op on the server; this covers
76
+ * a row the user may create but not read back, where that check cannot see it.
74
77
  */
75
78
  const isRepeatedLogCreate = (row: { mutationType: string; tableName: string }, err: string): boolean =>
76
79
  row.mutationType === 'create' && row.tableName.endsWith('__crdt_log') && /already exists/i.test(err);
@@ -80,6 +83,17 @@ const isRepeatedLogCreate = (row: { mutationType: string; tableName: string }, e
80
83
  * per request, per-statement outcome. Accepted rows are acked (they stay in
81
84
  * the overlay until membership names them), rejected rows are rolled back,
82
85
  * a transport failure leaves the unsent tail queued behind a backoff timer.
86
+ * The server stops a batch at its first failed statement (`remoteBatch`), so
87
+ * the rows after a rejection are sent again at once, and a re-sent row behind
88
+ * a retryable failure never ran before, so it cannot read "already exists".
89
+ *
90
+ * A request that throws a transport error (the push timeout, a dropped
91
+ * socket) may still have committed on the server, and so may one sent by a
92
+ * session that died before its answer. Those rows are marked in doubt
93
+ * (`OutboxItem.inDoubt`, every row loaded from the store is), and their
94
+ * re-sent CREATE accepts the row already on the server when it holds what the
95
+ * CREATE would write, instead of failing "already exists" and rolling back a
96
+ * write the server kept.
83
97
  */
84
98
  export function* drain(env: SagaEnv): Saga<void> {
85
99
  for (;;) {
@@ -102,12 +116,12 @@ export function* drain(env: SagaEnv): Saga<void> {
102
116
  }
103
117
  if (rows.length === 0) continue;
104
118
 
105
- const req = remoteBatch(rows);
119
+ const req = remoteBatch(rows, new Set(batch.filter((i) => i.inDoubt).map((i) => i.id)));
106
120
  let results: StatementResult[];
107
121
  try {
108
122
  const raw = (yield fx.remote.query(req.sql, req.vars, env.pushTimeoutMs)) as unknown;
109
- // A response without statement results is a transport fault: nothing
110
- // ran, everything stays queued (handled as `!r` below).
123
+ // A response without statement results is a transport fault: every
124
+ // row stays queued, in doubt (handled as `!r` below).
111
125
  results = Array.isArray(raw) ? (raw as StatementResult[]) : [];
112
126
  } catch (error) {
113
127
  if (classifySyncError(error) === 'application') {
@@ -117,8 +131,9 @@ export function* drain(env: SagaEnv): Saga<void> {
117
131
  yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
118
132
  continue;
119
133
  }
134
+ // No answer: the server may have committed any prefix of the batch.
120
135
  const attempts = batch.find((i) => i.id === rows[0].id)!.attempts;
121
- yield fx.state.update(R.outboxBumpAttempts(rows[0].id));
136
+ yield fx.state.update(R.compose(R.outboxMarkInDoubt(rows.map((row) => row.id)), R.outboxBumpAttempts(rows[0].id)));
122
137
  yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
123
138
  yield fx.timer.set('outbox', backoffMs(attempts), { type: 'Drain' });
124
139
  return;
@@ -130,7 +145,9 @@ export function* drain(env: SagaEnv): Saga<void> {
130
145
  const row = rows[i];
131
146
  const r = results[i];
132
147
  if (!r) {
133
- // Statements after a transport-level cut: they never ran.
148
+ // Statements after a transport-level cut: no answer says whether
149
+ // they ran, so they are in doubt.
150
+ yield fx.state.update(R.outboxMarkInDoubt(rows.slice(i).map((unanswered) => unanswered.id)));
134
151
  cutAt = i;
135
152
  break;
136
153
  }
@@ -147,6 +164,9 @@ export function* drain(env: SagaEnv): Saga<void> {
147
164
  yield fx.emit({ type: 'tabs:broadcast', message: { type: 'mutation-settled', mutationId: row.id, recordId: row.recordId, eventType: row.mutationType } });
148
165
  continue;
149
166
  }
167
+ // An earlier statement in this batch was rejected (or was a repeated
168
+ // log row), so this one never ran: the next round sends it again.
169
+ if (err.includes(BATCH_SKIPPED)) break;
150
170
  if (classifySyncError(new Error(err)) === 'network') {
151
171
  cutAt = i;
152
172
  break;