@spooky-sync/core 0.0.1-canary.333 → 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.
@@ -6,6 +6,7 @@ import { fx } from '../kernel/effects';
6
6
  import type { Saga } from '../kernel/saga';
7
7
  import * as R from '../state/reducers';
8
8
  import { Runtime } from './runtime';
9
+ import { rebindQueries } from '../boot/bucket-switch.saga';
9
10
 
10
11
  const env = defaultEnv({ tables: [] } as any, { materializeDebounceMs: 5 });
11
12
  const noop = () => {};
@@ -88,6 +89,45 @@ describe('Runtime state hooks', () => {
88
89
  expect(seen).toEqual([[{ id: 'thing:1' }]]);
89
90
  expect(rt.state.dirty.has('q')).toBe(false);
90
91
  });
92
+ it('the bucket-switch window: a re-seeded query never reads "known and idle" before its rows are painted', async () => {
93
+ // A sign-in: the query was registered in the anon bucket (cold, nothing
94
+ // painted), then the switch re-seeds it from the user's durable view.
95
+ // Before the fix the re-seed announced authority with `records` still
96
+ // empty, so a binding's `isSettled() && !row` held for a beat and the app
97
+ // created a row the user already had.
98
+ const { a, rt } = make(
99
+ {
100
+ local: {
101
+ getById: async () => ({ ids: [['thing:1', 1]], confirmed: true }),
102
+ select: async (plan: any) => (plan.ids ?? []).map((id: any) => ({ id: `${id.table}:${id.id}` })),
103
+ },
104
+ },
105
+ { ...buildState([buildEntry({ def: { hash: 'q', plan: { table: 'thing' } as any } })]), sessionId: 's' }
106
+ );
107
+ // What a binding derives `isSettled()` and `data()` from.
108
+ let known = false;
109
+ let idle = false;
110
+ let rows: unknown[] = [];
111
+ const verdicts: string[] = [];
112
+ const look = () => {
113
+ if (known && idle) verdicts.push(rows.length > 0 ? 'settled with rows' : 'settled empty');
114
+ };
115
+ rt.subscribe('q', (r) => ((rows = r), look()), { immediate: true });
116
+ rt.subscribeAuthority('q', (k) => ((known = k), look()), { immediate: true });
117
+ rt.subscribeStatus('q', (st) => ((idle = st === 'idle'), look()), { immediate: true });
118
+
119
+ await rt.run(rebindQueries());
120
+ expect(rt.state.queries.get('q')!.lifecycle).toMatchObject({ phase: 'cached', fetchDepth: 0, answered: false });
121
+ expect(idle).toBe(true);
122
+ expect(known).toBe(false);
123
+
124
+ a.timers.fire('mat:q');
125
+ await tick();
126
+ await tick();
127
+ expect(rows).toEqual([{ id: 'thing:1' }]);
128
+ expect(known).toBe(true);
129
+ expect(verdicts).toEqual(['settled with rows']);
130
+ });
91
131
  it('status and authority subscriptions fire on transitions, immediate delivers the current value', async () => {
92
132
  const { rt } = make({}, buildState([buildEntry({ def: { hash: 'q' } })]));
93
133
  const statuses: string[] = [];
@@ -7,7 +7,7 @@ import type { Lane, Saga } from '../kernel/saga';
7
7
  import { runSaga } from '../kernel/saga';
8
8
  import type { ClientState } from '../state/client-state';
9
9
  import { emptyState } from '../state/client-state';
10
- import { isAuthoritative } from '../state/lifecycle';
10
+ import { isAnswered } from '../state/lifecycle';
11
11
  import * as R from '../state/reducers';
12
12
  import { fetchingQueryCount, pendingMutationCount, queryStatus } from '../state/selectors';
13
13
  import type { SagaEnv } from '../query/env';
@@ -274,7 +274,7 @@ export class Runtime {
274
274
  this.authoritySubs.set(hash, set);
275
275
  if (options.immediate) {
276
276
  const entry = this.stateValue.queries.get(hash);
277
- if (entry) this.safely(() => cb(isAuthoritative(entry.lifecycle)));
277
+ if (entry) this.safely(() => cb(isAnswered(entry.lifecycle)));
278
278
  }
279
279
  return () => {
280
280
  set.delete(cb);
@@ -318,7 +318,9 @@ export class DevToolsService implements StreamUpdateReceiver {
318
318
  // the panel: is the server's set known, has a non-empty one been seen
319
319
  // this session, how many empty reads were ignored.
320
320
  membershipKnown: q.config.membershipKnown === true,
321
- authoritative: q.config.membershipKnown === true,
321
+ // What the bindings' `isAuthoritative()` reads: the known set's rows
322
+ // are painted, not just the set read.
323
+ authoritative: q.answered === true,
322
324
  viewLost: q.viewLost === true,
323
325
  serverState: q.serverState ?? null,
324
326
  // Detailed per-phase processing-time breakdown (SSP sub-phases, local/
@@ -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), {