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

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.
@@ -60,6 +60,8 @@ describe('parsePendingRow', () => {
60
60
  status: 'pending',
61
61
  ackedAt: null,
62
62
  attempts: 0,
63
+ // A row read back from the store may have been sent by a session that died.
64
+ inDoubt: true,
63
65
  });
64
66
  expect(rows.loadPendingRows()).toBe('SELECT * FROM _00_pending_mutations ORDER BY id ASC');
65
67
  expect(rows.loadFailedRows()).toBe('SELECT * FROM _00_failed_mutations ORDER BY failedAt ASC');
@@ -143,7 +145,14 @@ describe('remoteBatch', () => {
143
145
  { id: 'm3', mutationType: 'delete', recordId: 'thing:3', tableName: 'thing', createdAt: 0, v: 2 },
144
146
  { id: 'm4', mutationType: 'update', recordId: 'thing:4', tableName: 'thing', createdAt: 0, v: 2 },
145
147
  ]);
146
- expect(sql).toBe('CREATE ONLY $id0 SET a = $d0_a;\nUPDATE $id1 MERGE $data1;\nDELETE $id2;\nUPDATE $id3 MERGE $data3');
148
+ // Chained: each statement runs only if the one before it committed.
149
+ const skip = `IF !$ok0 { THROW "${rows.BATCH_SKIPPED}" }; `;
150
+ expect(sql.split(';\n')).toEqual([
151
+ 'LET $ok0 = { CREATE ONLY $id0 SET a = $d0_a; true }',
152
+ `LET $ok1 = { ${skip}UPDATE $id1 MERGE $data1; true }`,
153
+ `LET $ok2 = { ${skip.replace('$ok0', '$ok1')}DELETE $id2; true }`,
154
+ `LET $ok3 = { ${skip.replace('$ok0', '$ok2')}UPDATE $id3 MERGE $data3; true }`,
155
+ ]);
147
156
  expect(vars).toEqual({
148
157
  id0: rid('thing', '1'),
149
158
  d0_a: 1,
@@ -153,7 +162,45 @@ describe('remoteBatch', () => {
153
162
  id3: rid('thing', '4'),
154
163
  data3: {},
155
164
  });
156
- expect(rows.remoteBatch([{ id: 'm', mutationType: 'create', recordId: 'thing:9', tableName: 'thing', createdAt: 0, v: 2 }]).sql).toBe('CREATE ONLY $id0 SET ');
165
+ expect(rows.remoteBatch([{ id: 'm', mutationType: 'create', recordId: 'thing:9', tableName: 'thing', createdAt: 0, v: 2 }]).sql).toBe('LET $ok0 = { CREATE ONLY $id0 SET ; true }');
166
+ });
167
+
168
+ it('an in-doubt CREATE is a no-op when the server row matches every field no later in-doubt row of it may have changed', () => {
169
+ const row = (id: string, mutationType: rows.PendingMutationRow['mutationType'], recordId: string, data?: Record<string, unknown>): rows.PendingMutationRow => ({
170
+ id,
171
+ mutationType,
172
+ recordId,
173
+ tableName: recordId.split(':')[0],
174
+ data,
175
+ createdAt: 0,
176
+ v: 2,
177
+ });
178
+ const was = (i: number, ...keys: string[]) =>
179
+ `LET $was${i} = (SELECT * FROM ONLY $id${i}); IF ${[`($was${i} = NONE)`, ...keys.map((k) => `($was${i}.${k} != $d${i}_${k})`)].join(' OR ')} { CREATE ONLY $id${i} SET ${['a', 'b']
180
+ .map((k) => `${k} = $d${i}_${k}`)
181
+ .join(', ')} }`;
182
+ const statement = (sql: string, i: number) => sql.split(';\n')[i].replace(/^LET \$ok\d+ = \{ (IF !\$ok\d+ \{ THROW "[^"]+" \}; )?/, '').replace(/; true \}$/, '');
183
+ const batch = [
184
+ row('m1', 'create', 'thing:1', { a: 1, b: 1 }),
185
+ row('m2', 'update', 'thing:1', { b: 2 }),
186
+ row('m3', 'create', 'thing:2', { a: 1, b: 1 }),
187
+ row('m4', 'update', 'thing:2', { a: 2 }), // not in doubt: it never ran
188
+ row('m5', 'create', 'thing:3', { a: 1, b: 1 }),
189
+ row('m6', 'delete', 'thing:3'),
190
+ row('m7', 'create', 'thing:4', { a: 1, b: 1 }),
191
+ row('m8', 'update', 'thing:9', { a: 2 }), // another record
192
+ row('m9', 'update', 'thing:1'), // no payload: changes nothing
193
+ ];
194
+ const { sql } = rows.remoteBatch(batch, new Set(['m1', 'm2', 'm3', 'm5', 'm6', 'm8', 'm9']));
195
+ expect(statement(sql, 0)).toBe(was(0, 'a')); // b may be m2's
196
+ expect(statement(sql, 2)).toBe(was(2, 'a', 'b'));
197
+ expect(statement(sql, 4)).toBe(was(4)); // m6 may have deleted it and something else re-created it
198
+ expect(statement(sql, 6)).toBe('CREATE ONLY $id6 SET a = $d6_a, b = $d6_b'); // not in doubt: strict
199
+ // A CRDT log row's id is fresh and the row immutable: existence is proof, in doubt or not.
200
+ expect(statement(rows.remoteBatch([row('l', 'create', 'doc__crdt_log:1', { a: 1, b: 1 })]).sql, 0)).toBe(was(0));
201
+ expect(statement(rows.remoteBatch([row('e', 'create', 'thing:5')], new Set(['e'])).sql, 0)).toBe(
202
+ 'LET $was0 = (SELECT * FROM ONLY $id0); IF ($was0 = NONE) { CREATE ONLY $id0 SET }'
203
+ );
157
204
  });
158
205
  });
159
206
 
@@ -109,6 +109,11 @@ export function hydrateRowData(row: PendingMutationRow, columns: Record<string,
109
109
  }
110
110
  }
111
111
 
112
+ /**
113
+ * A row read back from the store at boot, bucket switch or leader takeover.
114
+ * The session that queued it may have sent it and died before the answer (a
115
+ * closed tab, a killed app, a lost leader), so its outcome is in doubt.
116
+ */
112
117
  export const toOutboxItem = (row: PendingMutationRow): OutboxItem => ({
113
118
  id: row.id,
114
119
  type: row.mutationType,
@@ -117,6 +122,7 @@ export const toOutboxItem = (row: PendingMutationRow): OutboxItem => ({
117
122
  status: 'pending',
118
123
  ackedAt: null,
119
124
  attempts: 0,
125
+ inDoubt: true,
120
126
  });
121
127
 
122
128
  /**
@@ -286,29 +292,95 @@ export interface RemoteBatch {
286
292
  }
287
293
 
288
294
  /**
289
- * Many outbox rows as ONE multi-statement request. Statement i answers row i.
290
- * Each statement is its own transaction on the server, as before.
295
+ * What a statement of a `remoteBatch` throws instead of running, because an
296
+ * earlier statement in the same request failed.
291
297
  */
292
- export function remoteBatch(rows: ReadonlyArray<PendingMutationRow>): RemoteBatch {
293
- const vars: Vars = {};
294
- const stmts = rows.map((row, i) => {
295
- const rid = parseRecordIdString(row.recordId);
296
- vars[`id${i}`] = rid;
297
- switch (row.mutationType) {
298
- case 'create': {
299
- const data = row.data ?? {};
300
- const sets = Object.keys(data).map((key) => {
301
- vars[`d${i}_${key}`] = data[key];
302
- return `${key} = $d${i}_${key}`;
303
- });
304
- return `CREATE ONLY $id${i} SET ${sets.join(', ')}`;
305
- }
306
- case 'update':
307
- vars[`data${i}`] = row.data ?? {};
308
- return `UPDATE $id${i} MERGE $data${i}`;
309
- case 'delete':
310
- return `DELETE $id${i}`;
298
+ export const BATCH_SKIPPED = 'sp00ky: not run, an earlier statement in this batch failed';
299
+
300
+ /** A CRDT edit-log row (`<table>__crdt_log`): written once under a fresh id, never changed. */
301
+ const isLogTable = (table: string): boolean => table.endsWith('__crdt_log');
302
+
303
+ /**
304
+ * Which fields of the row already on the server must equal the payload of the
305
+ * CREATE at `i` for that row to count as this CREATE's own earlier commit, or
306
+ * `null` when the CREATE must not tolerate an existing row at all.
307
+ *
308
+ * Only a CREATE whose outcome is in doubt tolerates one: elsewhere "already
309
+ * exists" means someone else holds the id, a rejection as before. The check
310
+ * is every payload field except those a later in-doubt row of the same
311
+ * record, which may have committed in the same lost round, could have changed
312
+ * since; those rows run again after it and set them anyway. A later in-doubt
313
+ * DELETE or CREATE of the record may have replaced the row, so then existence
314
+ * is all that is checked. Rows that are not in doubt never ran.
315
+ *
316
+ * A CRDT log row's id is fresh and the row immutable, so its existence is
317
+ * proof enough, in doubt or not.
318
+ */
319
+ function matchFields(rows: ReadonlyArray<PendingMutationRow>, i: number, inDoubt: ReadonlySet<string>): string[] | null {
320
+ const row = rows[i];
321
+ if (isLogTable(row.tableName)) return [];
322
+ if (!inDoubt.has(row.id)) return null;
323
+ const changed = new Set<string>();
324
+ for (const later of rows.slice(i + 1)) {
325
+ if (later.recordId !== row.recordId || !inDoubt.has(later.id)) continue;
326
+ if (later.mutationType !== 'update') return [];
327
+ for (const key of Object.keys(later.data ?? {})) changed.add(key);
328
+ }
329
+ return Object.keys(row.data ?? {}).filter((key) => !changed.has(key));
330
+ }
331
+
332
+ function remoteStatement(rows: ReadonlyArray<PendingMutationRow>, i: number, vars: Vars, inDoubt: ReadonlySet<string>): string {
333
+ const row = rows[i];
334
+ vars[`id${i}`] = parseRecordIdString(row.recordId);
335
+ switch (row.mutationType) {
336
+ case 'create': {
337
+ const data = row.data ?? {};
338
+ const sets = Object.keys(data).map((key) => {
339
+ vars[`d${i}_${key}`] = data[key];
340
+ return `${key} = $d${i}_${key}`;
341
+ });
342
+ const create = `CREATE ONLY $id${i} SET ${sets.join(', ')}`;
343
+ const match = matchFields(rows, i, inDoubt);
344
+ if (!match) return create;
345
+ // The CREATE runs unless the row is there and matches; on a mismatch it
346
+ // fails with SurrealDB's own "already exists", a rejection as before.
347
+ const differs = [`($was${i} = NONE)`, ...match.map((key) => `($was${i}.${key} != $d${i}_${key})`)];
348
+ return `LET $was${i} = (SELECT * FROM ONLY $id${i}); IF ${differs.join(' OR ')} { ${create} }`;
311
349
  }
350
+ case 'update':
351
+ vars[`data${i}`] = row.data ?? {};
352
+ return `UPDATE $id${i} MERGE $data${i}`;
353
+ case 'delete':
354
+ return `DELETE $id${i}`;
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Many outbox rows as ONE multi-statement request. Statement i answers row i
360
+ * and is its own transaction on the server, as before, but the statements are
361
+ * chained: statement i binds `$ok<i>` only when it commits, and statement i+1
362
+ * throws `BATCH_SKIPPED` without touching anything unless `$ok<i>` is bound
363
+ * (a LET lives for one request only). So the server stops at the first
364
+ * failure: everything before it is committed, nothing after it ran.
365
+ *
366
+ * Without the chain, statements after a failed one ran anyway. A retryable
367
+ * failure at i left the rows after i queued although they had committed; the
368
+ * re-sent CREATE then read "already exists", was taken for a rejection and
369
+ * rolled back locally while the server kept the row. Acking them instead
370
+ * would have let a later write land before an earlier one it may depend on.
371
+ *
372
+ * The chain cannot help when the answer itself is lost: the request may have
373
+ * committed any prefix of the batch. Rows named in `inDoubt` (mutation ids)
374
+ * are such rows. An UPDATE MERGE or a DELETE is safe to run twice, a CREATE
375
+ * is not, so an in-doubt CREATE checks the row already there first (see
376
+ * `matchFields`) and is a no-op when it is its own: the batch goes on in
377
+ * order, with no "already exists" to cut it.
378
+ */
379
+ export function remoteBatch(rows: ReadonlyArray<PendingMutationRow>, inDoubt: ReadonlySet<string> = new Set()): RemoteBatch {
380
+ const vars: Vars = {};
381
+ const stmts = rows.map((_, i) => {
382
+ const guard = i === 0 ? '' : `IF !$ok${i - 1} { THROW "${BATCH_SKIPPED}" }; `;
383
+ return `LET $ok${i} = { ${guard}${remoteStatement(rows, i, vars, inDoubt)}; true }`;
312
384
  });
313
385
  return { sql: stmts.join(';\n'), vars };
314
386
  }
@@ -187,7 +187,9 @@ describe('session guard ends sync for an open socket (real SurrealDB)', () => {
187
187
  const seen: unknown[] = [];
188
188
  const [id] = (await db.query('LIVE SELECT * FROM _00_list_ref_user_alice')) as [unknown];
189
189
  const stream = await db.liveOf(id as never);
190
- stream.subscribe((message: { action: string; value: unknown }) => {
190
+ // Typed by the SDK (`LiveMessage`), not a narrower hand-written shape:
191
+ // the hand-written one failed `tsc` and so the core Test job.
192
+ stream.subscribe((message) => {
191
193
  if (message.action !== 'KILLED') seen.push(message.value);
192
194
  });
193
195
  return seen;
@@ -79,6 +79,14 @@ export interface OutboxItem {
79
79
  readonly status: OutboxStatus;
80
80
  readonly ackedAt: number | null;
81
81
  readonly attempts: number;
82
+ /**
83
+ * A request carrying this row may have committed without the answer
84
+ * reaching this client: it threw a transport error, or the row was loaded
85
+ * from the store, so a session that died mid-push may have sent it. Its
86
+ * CREATE then accepts the row already on the server when that row holds
87
+ * what it would write (see `remoteBatch`). Absent means false.
88
+ */
89
+ readonly inDoubt?: boolean;
82
90
  }
83
91
 
84
92
  export type TabRole = 'solo' | 'leader' | 'follower';
@@ -208,6 +208,9 @@ describe('outbox', () => {
208
208
  const s3 = R.outboxBumpAttempts('m1')(s2);
209
209
  expect(s3.outbox[0].attempts).toBe(1);
210
210
  expect(R.outboxBumpAttempts('nope')(s3)).toBe(s3);
211
+ const doubt = R.outboxMarkInDoubt(['m1', 'nope'])(s3);
212
+ expect(doubt.outbox[0]).toMatchObject({ id: 'm1', inDoubt: true, attempts: 1 });
213
+ expect(R.outboxMarkInDoubt(['m1'])(doubt)).toBe(doubt);
211
214
  const s4 = R.outboxRemove('m1')(s3);
212
215
  expect(s4.outbox).toEqual([]);
213
216
  expect(s4.dirty.has('a')).toBe(true);
@@ -408,6 +408,15 @@ export const outboxBumpAttempts =
408
408
  return { ...s, outbox };
409
409
  };
410
410
 
411
+ /** Mark items whose last push may have committed although no answer came back. */
412
+ export const outboxMarkInDoubt =
413
+ (ids: readonly string[]): Reducer =>
414
+ (s) => {
415
+ const marked = new Set(ids);
416
+ if (!s.outbox.some((i) => marked.has(i.id) && !i.inDoubt)) return s;
417
+ return { ...s, outbox: s.outbox.map((i) => (marked.has(i.id) && !i.inDoubt ? { ...i, inDoubt: true } : i)) };
418
+ };
419
+
411
420
  /** Drop acked items older than `graceMs` (membership never named them). */
412
421
  export const outboxPruneAcked =
413
422
  (now: number, graceMs: number): Reducer =>
@@ -39,10 +39,43 @@ describe('classifySyncError', () => {
39
39
  expect(classifySyncError(new Error(message))).toBe('network');
40
40
  });
41
41
 
42
+ // Verbatim from SurrealDB 3.1.5 and 3.3.4: the write was refused because
43
+ // sp00ky's own ingest event could not reach the scheduler / SSP. The outbox
44
+ // must keep these queued, never roll them back (see ingest-outage.integration.test.ts).
45
+ it.each([
46
+ // Refused connection and a post past the event TIMEOUT read the same.
47
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: error sending request for url (http://scheduler:9667/ingest)',
48
+ 'Error while processing event _00_item_delete: There was an error processing a remote HTTP request: error sending request for url (http://host.docker.internal:50466/ingest)',
49
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 500 Internal Server Error',
50
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 502 Bad Gateway',
51
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 503 Service Unavailable',
52
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 408 Request Timeout',
53
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 429 Too Many Requests',
54
+ 'Error while processing event _00_item_mutation: failed to lookup address information: Name or service not known',
55
+ // An app event that wrote to a synced table carries the inner event.
56
+ 'Error while processing event app_fanout: Error while processing event _00_b_mutation: There was an error processing a remote HTTP request: 503 Service Unavailable',
57
+ ])('classifies a failed sp00ky ingest post %j as network (retry, never rollback)', (message) => {
58
+ expect(classifySyncError(new Error(message))).toBe('network');
59
+ });
60
+
42
61
  it.each([
43
62
  'Permission denied',
63
+ // Verbatim from SurrealDB 3.3.4: a record user running a statement it may not.
64
+ 'IAM error: Not enough permissions to perform this action',
44
65
  'There was a problem with the database: record already exists',
45
66
  'Parse error: unexpected token',
67
+ "Couldn't coerce value for field `title` of `item:1`: Expected `string` but found `1`",
68
+ // An app's own event: its endpoint, its THROW, its business.
69
+ 'Error while processing event notify_crm: There was an error processing a remote HTTP request: 500 Internal Server Error',
70
+ 'Error while processing event notify_crm: There was an error processing a remote HTTP request: error sending request for url (http://crm.example/hook)',
71
+ 'Error while processing event cf_guard: An error occurred: conformance guard: write refused',
72
+ // A THROW inside a sp00ky event is not a failed post.
73
+ 'Error while processing event _00_item_mutation: An error occurred: inner',
74
+ // A 4xx from /ingest no retry fixes: payload rejected, too large, secret mismatch.
75
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 400 Bad Request',
76
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 401 Unauthorized',
77
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 413 Payload Too Large',
78
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 422 Unprocessable Entity',
46
79
  ])('classifies %j as application', (message) => {
47
80
  expect(classifySyncError(new Error(message))).toBe('application');
48
81
  });
@@ -39,10 +39,37 @@ const NETWORK_ERROR_PATTERNS = [
39
39
  'specify a database',
40
40
  ];
41
41
 
42
+ /**
43
+ * A write SurrealDB refused because sp00ky's OWN ingest post failed: every
44
+ * synced table's `_00_<table>_mutation` / `_00_<table>_delete` event posts to
45
+ * the scheduler or SSP inside the write's transaction (`sp00ky.rs::ingest_post`),
46
+ * so a scheduler that is down, slow past the event TIMEOUT, or answering 5xx
47
+ * aborts the write. That is an outage, not the mutation's fault, and it clears
48
+ * on its own; classified `application` it was rolled back and discarded, so
49
+ * every write queued during a scheduler restart or a cold SSP was lost.
50
+ *
51
+ * Wording on SurrealDB 3.1.5 and 3.3.4 (the event name may be nested inside an
52
+ * app event that wrote to a synced table):
53
+ * "Error while processing event _00_item_mutation: There was an error
54
+ * processing a remote HTTP request: error sending request for url (...)"
55
+ * (refused connection or TIMEOUT), "...: 503 Service Unavailable" (5xx),
56
+ * "Error while processing event _00_item_mutation: failed to lookup address
57
+ * information: ..." (DNS).
58
+ *
59
+ * Only `_00_` events count: an app's own event posting to its own endpoint
60
+ * stays `application`, as does a 4xx from `/ingest` other than 408/429 (a
61
+ * malformed or oversized payload, a secret mismatch), which no retry fixes and
62
+ * which would otherwise hold the outbox's serial lane forever.
63
+ */
64
+ const SP00KY_EVENT_POST_FAILED =
65
+ /error while processing event _00_[^:\s]*: (?:there was an error processing a remote http request: (?:error sending request|5\d\d\b|408\b|429\b)|failed to lookup address information)/;
66
+
42
67
  export function classifySyncError(error: unknown): 'network' | 'application' {
43
68
  const message =
44
69
  error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
45
70
 
71
+ if (SP00KY_EVENT_POST_FAILED.test(message)) return 'network';
72
+
46
73
  for (const pattern of NETWORK_ERROR_PATTERNS) {
47
74
  if (message.includes(pattern)) {
48
75
  return 'network';