@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.
- package/AGENTS.md +8 -2
- package/README.md +4 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +140 -25
- package/package.json +3 -3
- package/skills/sp00ky-core/SKILL.md +6 -1
- package/src/mutation/ingest-outage.integration.test.ts +400 -0
- package/src/mutation/push.saga.test.ts +74 -14
- package/src/mutation/push.saga.ts +25 -5
- package/src/mutation/rows.test.ts +49 -2
- package/src/mutation/rows.ts +93 -21
- package/src/state/client-state.ts +8 -0
- package/src/state/reducers.test.ts +3 -0
- package/src/state/reducers.ts +9 -0
- package/src/utils/error-classification.test.ts +33 -0
- package/src/utils/error-classification.ts +27 -0
|
@@ -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
|
-
|
|
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
|
|
package/src/mutation/rows.ts
CHANGED
|
@@ -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
|
-
*
|
|
290
|
-
*
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
}
|
|
@@ -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);
|
package/src/state/reducers.ts
CHANGED
|
@@ -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';
|