@syncular/server 0.15.12 → 0.15.14
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/README.md +4 -0
- package/dist/d1-storage.js +89 -8
- package/dist/postgres-storage.js +12 -4
- package/dist/push.js +37 -11
- package/dist/sqlite-storage.js +12 -3
- package/dist/storage-errors.d.ts +20 -0
- package/dist/storage-errors.js +46 -0
- package/dist/storage.d.ts +7 -1
- package/package.json +2 -2
- package/src/d1-storage.ts +110 -4
- package/src/postgres-storage.ts +23 -9
- package/src/push.ts +63 -14
- package/src/sqlite-storage.ts +18 -3
- package/src/storage-errors.ts +62 -0
- package/src/storage.ts +9 -1
package/README.md
CHANGED
|
@@ -173,6 +173,10 @@ Whole-commit validation checks a client-proposed commit; it does not grant
|
|
|
173
173
|
authority. Privileged operations such as connecting facilities still belong in
|
|
174
174
|
explicit server-authoritative commands.
|
|
175
175
|
|
|
176
|
+
The task-oriented [concurrency and conflict-correction guide](https://syncular.dev/guide-concurrency-correction/)
|
|
177
|
+
shows version projection, aggregate rollback, corrected replacement commits,
|
|
178
|
+
explicit acknowledgement, and restart-safe recovery UI together.
|
|
179
|
+
|
|
176
180
|
## Structured events (the ops seam)
|
|
177
181
|
|
|
178
182
|
One optional interface, `SyncularServerEvents`, carries every
|
package/dist/d1-storage.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
var _a;
|
|
1
2
|
/**
|
|
2
3
|
* Cloudflare D1 server storage (TODO §4.2 — the Workers deployment rung).
|
|
3
4
|
*
|
|
@@ -39,10 +40,19 @@
|
|
|
39
40
|
* This mirrors PostgreSQL's per-partition row lock, achieved by placement
|
|
40
41
|
* rather than a lock D1 does not expose.
|
|
41
42
|
*/
|
|
43
|
+
import { decodeRow } from '@syncular/core';
|
|
42
44
|
import { syncError } from './errors.js';
|
|
43
|
-
import { commitWindowPageSql, deleteRowSql, dropTableDdl, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, upsertSql, upsertValues, } from './relational-rows.js';
|
|
45
|
+
import { commitWindowPageSql, deleteRowSql, dropTableDdl, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
44
46
|
import { matchesEffective } from './scopes.js';
|
|
45
47
|
import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
|
|
48
|
+
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
49
|
+
function relationalValuesEqual(left, right) {
|
|
50
|
+
if (left instanceof Uint8Array && right instanceof Uint8Array) {
|
|
51
|
+
return (left.length === right.length &&
|
|
52
|
+
left.every((value, index) => value === right[index]));
|
|
53
|
+
}
|
|
54
|
+
return Object.is(left, right);
|
|
55
|
+
}
|
|
46
56
|
class D1Transaction {
|
|
47
57
|
#db;
|
|
48
58
|
#partition;
|
|
@@ -53,6 +63,7 @@ class D1Transaction {
|
|
|
53
63
|
/** Live snapshot of `max_commit_seq`, advanced within this transaction. */
|
|
54
64
|
#maxCommitSeq;
|
|
55
65
|
#commitValidationCheckpoint;
|
|
66
|
+
#lastApplicationOpIndex;
|
|
56
67
|
/**
|
|
57
68
|
* Read-your-own-writes overlay (§6.2 needs `getRow` to see buffered writes
|
|
58
69
|
* of the same commit — e.g. two ops touching the same row): keyed
|
|
@@ -77,7 +88,7 @@ class D1Transaction {
|
|
|
77
88
|
}
|
|
78
89
|
async getRow(table, rowId) {
|
|
79
90
|
this.#assertOpen();
|
|
80
|
-
const pending = this.#pending.get(
|
|
91
|
+
const pending = this.#pending.get(_a.#key(table, rowId));
|
|
81
92
|
if (pending !== undefined) {
|
|
82
93
|
return pending.kind === 'row' ? pending.row : undefined;
|
|
83
94
|
}
|
|
@@ -159,14 +170,75 @@ class D1Transaction {
|
|
|
159
170
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
160
171
|
await this.commit();
|
|
161
172
|
}
|
|
162
|
-
async
|
|
173
|
+
async #assertNoUniqueCollision(table, row, opIndex) {
|
|
174
|
+
if (!table.materialize)
|
|
175
|
+
return;
|
|
176
|
+
const uniqueIndexes = table.indexes.filter((index) => index.unique === true);
|
|
177
|
+
if (uniqueIndexes.length === 0)
|
|
178
|
+
return;
|
|
179
|
+
const incoming = decodeRow(table.columns, row.payload);
|
|
180
|
+
for (const index of uniqueIndexes) {
|
|
181
|
+
const indices = index.columns.map((column) => {
|
|
182
|
+
const resolved = table.columnIndex.get(column);
|
|
183
|
+
if (resolved === undefined) {
|
|
184
|
+
throw new Error(`compiled unique index references unknown column`);
|
|
185
|
+
}
|
|
186
|
+
return resolved;
|
|
187
|
+
});
|
|
188
|
+
const values = indices.map((position) => incoming[position] ?? null);
|
|
189
|
+
// SQLite UNIQUE permits multiple rows when any indexed value is NULL.
|
|
190
|
+
if (values.some((value) => value === null))
|
|
191
|
+
continue;
|
|
192
|
+
for (const [key, pending] of this.#pending) {
|
|
193
|
+
if (!key.startsWith(`${table.name}\u0000`) || pending.kind !== 'row') {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (pending.row.rowId === row.rowId)
|
|
197
|
+
continue;
|
|
198
|
+
const candidate = decodeRow(table.columns, pending.row.payload);
|
|
199
|
+
if (indices.every((position, valueIndex) => relationalValuesEqual(candidate[position] ?? null, values[valueIndex] ?? null))) {
|
|
200
|
+
throw new StorageConstraintError(undefined, opIndex);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const predicates = index.columns
|
|
204
|
+
.map((column) => `${quoteIdent(column)}=?`)
|
|
205
|
+
.join(' AND ');
|
|
206
|
+
const sql = `SELECT ${quoteIdent('_sync_row_id')} AS row_id FROM ${quoteIdent(table.name)} WHERE ${quoteIdent('_sync_partition')}=? AND ${predicates} AND ${quoteIdent('_sync_row_id')}<>? LIMIT 1`;
|
|
207
|
+
const bind = indices.map((position, valueIndex) => {
|
|
208
|
+
const schemaColumn = position === undefined ? undefined : table.columns[position];
|
|
209
|
+
if (schemaColumn === undefined) {
|
|
210
|
+
throw new Error(`compiled unique index references unknown column`);
|
|
211
|
+
}
|
|
212
|
+
return toSqlValue(schemaColumn, values[valueIndex] ?? null, 'sqlite');
|
|
213
|
+
});
|
|
214
|
+
const persisted = await this.#db
|
|
215
|
+
.prepare(sql)
|
|
216
|
+
.bind(this.#partition, ...bind, row.rowId)
|
|
217
|
+
.first();
|
|
218
|
+
if (persisted === null)
|
|
219
|
+
continue;
|
|
220
|
+
const pending = this.#pending.get(_a.#key(table.name, persisted.row_id));
|
|
221
|
+
if (pending?.kind === 'deleted')
|
|
222
|
+
continue;
|
|
223
|
+
if (pending?.kind === 'row') {
|
|
224
|
+
const candidate = decodeRow(table.columns, pending.row.payload);
|
|
225
|
+
const stillCollides = indices.every((position, valueIndex) => relationalValuesEqual(candidate[position] ?? null, values[valueIndex] ?? null));
|
|
226
|
+
if (!stillCollides)
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
throw new StorageConstraintError(undefined, opIndex);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async upsertRow(table, row, context) {
|
|
163
233
|
this.#assertOpen();
|
|
164
|
-
this.#
|
|
234
|
+
const compiled = this.#resolveTable(table);
|
|
235
|
+
await this.#assertNoUniqueCollision(compiled, row, context?.opIndex);
|
|
236
|
+
this.#lastApplicationOpIndex = context?.opIndex;
|
|
237
|
+
this.#pending.set(_a.#key(table, row.rowId), {
|
|
165
238
|
kind: 'row',
|
|
166
239
|
row,
|
|
167
240
|
});
|
|
168
241
|
const p = this.#partition;
|
|
169
|
-
const compiled = this.#resolveTable(table);
|
|
170
242
|
this.#buffer_(upsertSql(compiled, 'sqlite'), upsertValues(compiled, p, row, 'sqlite'));
|
|
171
243
|
this.#buffer_('DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?', [p, table, row.rowId]);
|
|
172
244
|
for (const [variable, value] of Object.entries(row.scopes)) {
|
|
@@ -175,7 +247,7 @@ class D1Transaction {
|
|
|
175
247
|
}
|
|
176
248
|
async deleteRow(table, rowId) {
|
|
177
249
|
this.#assertOpen();
|
|
178
|
-
this.#pending.set(
|
|
250
|
+
this.#pending.set(_a.#key(table, rowId), { kind: 'deleted' });
|
|
179
251
|
const p = this.#partition;
|
|
180
252
|
this.#buffer_(deleteRowSql(this.#resolveTable(table), 'sqlite'), [
|
|
181
253
|
p,
|
|
@@ -237,7 +309,7 @@ class D1Transaction {
|
|
|
237
309
|
}
|
|
238
310
|
async putPushResult(clientId, clientCommitId, result) {
|
|
239
311
|
this.#assertOpen();
|
|
240
|
-
this.#buffer_('INSERT OR
|
|
312
|
+
this.#buffer_('INSERT OR IGNORE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)', [this.#partition, clientId, clientCommitId, serializePushResult(result)]);
|
|
241
313
|
}
|
|
242
314
|
async commit() {
|
|
243
315
|
this.#assertOpen();
|
|
@@ -246,7 +318,15 @@ class D1Transaction {
|
|
|
246
318
|
return;
|
|
247
319
|
const statements = this.#buffer.map((entry) => this.#db.prepare(entry.sql).bind(...entry.params));
|
|
248
320
|
// One atomic D1 batch — the §6.4 all-or-nothing commit.
|
|
249
|
-
|
|
321
|
+
try {
|
|
322
|
+
await this.#db.batch(statements);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (isD1ConstraintError(error)) {
|
|
326
|
+
throw new StorageConstraintError(error, this.#lastApplicationOpIndex);
|
|
327
|
+
}
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
250
330
|
}
|
|
251
331
|
async rollback() {
|
|
252
332
|
if (!this.#open)
|
|
@@ -255,6 +335,7 @@ class D1Transaction {
|
|
|
255
335
|
this.#buffer.length = 0;
|
|
256
336
|
}
|
|
257
337
|
}
|
|
338
|
+
_a = D1Transaction;
|
|
258
339
|
/**
|
|
259
340
|
* D1 caps bound parameters at 100 per statement; the relational upsert
|
|
260
341
|
* binds one per app column plus the five `_sync_*` meta columns.
|
package/dist/postgres-storage.js
CHANGED
|
@@ -2,6 +2,7 @@ import { syncError } from './errors.js';
|
|
|
2
2
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
3
3
|
import { commitWindowPageSql, deleteRowSql, dropTableDdl, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
4
4
|
import { matchesEffective } from './scopes.js';
|
|
5
|
+
import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
5
6
|
/**
|
|
6
7
|
* Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
|
|
7
8
|
*
|
|
@@ -303,9 +304,17 @@ class PostgresTransaction {
|
|
|
303
304
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
304
305
|
await this.commit();
|
|
305
306
|
}
|
|
306
|
-
async upsertRow(table, row) {
|
|
307
|
+
async upsertRow(table, row, context) {
|
|
307
308
|
this.#assertOpen();
|
|
308
|
-
|
|
309
|
+
try {
|
|
310
|
+
await writeRowOn(this.#client, this.#resolveTable(table), this.#partition, row);
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (isPostgresConstraintError(error)) {
|
|
314
|
+
throw new StorageConstraintError(error, context?.opIndex);
|
|
315
|
+
}
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
309
318
|
}
|
|
310
319
|
async deleteRow(table, rowId) {
|
|
311
320
|
this.#assertOpen();
|
|
@@ -371,8 +380,7 @@ class PostgresTransaction {
|
|
|
371
380
|
this.#assertOpen();
|
|
372
381
|
await this.#client.query(`INSERT INTO sync_push_results(partition, client_id, client_commit_id, result)
|
|
373
382
|
VALUES ($1,$2,$3,$4)
|
|
374
|
-
ON CONFLICT (partition, client_id, client_commit_id) DO
|
|
375
|
-
SET result=EXCLUDED.result`, [
|
|
383
|
+
ON CONFLICT (partition, client_id, client_commit_id) DO NOTHING`, [
|
|
376
384
|
this.#partition,
|
|
377
385
|
clientId,
|
|
378
386
|
clientCommitId,
|
package/dist/push.js
CHANGED
|
@@ -21,6 +21,7 @@ import { decodeRow, encodeRow, parseBlobRef, } from '@syncular/core';
|
|
|
21
21
|
import { clockOf } from './context.js';
|
|
22
22
|
import { SyncError } from './errors.js';
|
|
23
23
|
import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.js';
|
|
24
|
+
import { StorageConstraintError } from './storage-errors.js';
|
|
24
25
|
import { CommitValidationRejection, toValidateRow, ValidationRejection, } from './validate.js';
|
|
25
26
|
/**
|
|
26
27
|
* Extract the blobIds a decoded row references through its `blob_ref`
|
|
@@ -263,7 +264,7 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
263
264
|
scopes: stored.scopes,
|
|
264
265
|
payload: newPayload,
|
|
265
266
|
};
|
|
266
|
-
await tx.upsertRow(op.table, newRow);
|
|
267
|
+
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
267
268
|
return {
|
|
268
269
|
kind: 'applied',
|
|
269
270
|
change: {
|
|
@@ -329,7 +330,7 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
329
330
|
scopes: extracted.scopes,
|
|
330
331
|
payload: insertPayload,
|
|
331
332
|
};
|
|
332
|
-
await tx.upsertRow(op.table, newRow);
|
|
333
|
+
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
333
334
|
return {
|
|
334
335
|
kind: 'applied',
|
|
335
336
|
change: {
|
|
@@ -481,6 +482,22 @@ function idempotencyCacheMissFrame(clientCommitId, error) {
|
|
|
481
482
|
],
|
|
482
483
|
};
|
|
483
484
|
}
|
|
485
|
+
async function persistRejectedPushResult(storage, partition, clientId, clientCommitId, stored) {
|
|
486
|
+
const rejectionTx = await storage.begin(partition);
|
|
487
|
+
try {
|
|
488
|
+
await rejectionTx.putPushResult(clientId, clientCommitId, stored);
|
|
489
|
+
await rejectionTx.commit();
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
await rejectionTx.rollback();
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
|
|
496
|
+
if (canonical === undefined) {
|
|
497
|
+
throw new Error('push rejection finalization did not persist an outcome');
|
|
498
|
+
}
|
|
499
|
+
return canonical;
|
|
500
|
+
}
|
|
484
501
|
/**
|
|
485
502
|
* Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
|
|
486
503
|
* atomic apply (§6.4), realtime notification for applied commits.
|
|
@@ -578,15 +595,8 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
578
595
|
}
|
|
579
596
|
else {
|
|
580
597
|
await tx.rollback();
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
await rejectionTx.putPushResult(clientId, frame.clientCommitId, stored);
|
|
584
|
-
await rejectionTx.commit();
|
|
585
|
-
}
|
|
586
|
-
catch (error) {
|
|
587
|
-
await rejectionTx.rollback();
|
|
588
|
-
throw error;
|
|
589
|
-
}
|
|
598
|
+
const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
|
|
599
|
+
return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
|
|
590
600
|
}
|
|
591
601
|
return resultFrame(frame.clientCommitId, stored, false);
|
|
592
602
|
}
|
|
@@ -612,6 +622,22 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
612
622
|
}
|
|
613
623
|
catch (error) {
|
|
614
624
|
await tx.rollback();
|
|
625
|
+
if (error instanceof StorageConstraintError) {
|
|
626
|
+
const stored = {
|
|
627
|
+
status: 'rejected',
|
|
628
|
+
results: [
|
|
629
|
+
{
|
|
630
|
+
opIndex: error.opIndex ?? 0,
|
|
631
|
+
status: 'error',
|
|
632
|
+
code: 'sync.constraint_violation',
|
|
633
|
+
message: 'write violates a relational constraint',
|
|
634
|
+
retryable: false,
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
};
|
|
638
|
+
const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
|
|
639
|
+
return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
|
|
640
|
+
}
|
|
615
641
|
throw error;
|
|
616
642
|
}
|
|
617
643
|
}
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -11,6 +11,7 @@ import { syncError } from './errors.js';
|
|
|
11
11
|
import { commitWindowPageSql, deleteRowSql, dropTableDdl, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
12
12
|
import { matchesEffective } from './scopes.js';
|
|
13
13
|
import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
|
|
14
|
+
import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
14
15
|
class SqliteTransaction {
|
|
15
16
|
#storage;
|
|
16
17
|
#partition;
|
|
@@ -50,9 +51,17 @@ class SqliteTransaction {
|
|
|
50
51
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
51
52
|
await this.commit();
|
|
52
53
|
}
|
|
53
|
-
async upsertRow(table, row) {
|
|
54
|
+
async upsertRow(table, row, context) {
|
|
54
55
|
this.#assertOpen();
|
|
55
|
-
|
|
56
|
+
try {
|
|
57
|
+
this.#storage.writeRow(this.#partition, table, row);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (isSqliteConstraintError(error)) {
|
|
61
|
+
throw new StorageConstraintError(error, context?.opIndex);
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
56
65
|
}
|
|
57
66
|
async deleteRow(table, rowId) {
|
|
58
67
|
this.#assertOpen();
|
|
@@ -93,7 +102,7 @@ class SqliteTransaction {
|
|
|
93
102
|
async putPushResult(clientId, clientCommitId, result) {
|
|
94
103
|
this.#assertOpen();
|
|
95
104
|
this.#storage.db
|
|
96
|
-
.query('INSERT OR
|
|
105
|
+
.query('INSERT OR IGNORE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)')
|
|
97
106
|
.run(this.#partition, clientId, clientCommitId, serializePushResult(result));
|
|
98
107
|
}
|
|
99
108
|
async commit() {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy-safe storage failures which the push protocol is allowed to turn
|
|
3
|
+
* into durable application-write rejections. Database-specific errors remain
|
|
4
|
+
* attached only as an internal cause and never cross the protocol boundary.
|
|
5
|
+
*/
|
|
6
|
+
export declare class StorageConstraintError extends Error {
|
|
7
|
+
readonly name = "StorageConstraintError";
|
|
8
|
+
readonly opIndex: number | undefined;
|
|
9
|
+
constructor(cause: unknown, opIndex?: number);
|
|
10
|
+
}
|
|
11
|
+
/** SQLite primary/extended constraint result codes (`SQLITE_CONSTRAINT*`). */
|
|
12
|
+
export declare function isSqliteConstraintError(error: unknown): boolean;
|
|
13
|
+
/** PostgreSQL SQLSTATE class 23: integrity constraint violation. */
|
|
14
|
+
export declare function isPostgresConstraintError(error: unknown): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* D1 may preserve SQLite's structured code or expose only a bounded platform
|
|
17
|
+
* prefix. The text fallback is adapter-private classification only: no part of
|
|
18
|
+
* the original message is copied to the public StorageConstraintError.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isD1ConstraintError(error: unknown): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy-safe storage failures which the push protocol is allowed to turn
|
|
3
|
+
* into durable application-write rejections. Database-specific errors remain
|
|
4
|
+
* attached only as an internal cause and never cross the protocol boundary.
|
|
5
|
+
*/
|
|
6
|
+
export class StorageConstraintError extends Error {
|
|
7
|
+
name = 'StorageConstraintError';
|
|
8
|
+
opIndex;
|
|
9
|
+
constructor(cause, opIndex) {
|
|
10
|
+
super('application row violates a relational constraint', { cause });
|
|
11
|
+
this.opIndex = opIndex;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function driverError(error) {
|
|
15
|
+
return typeof error === 'object' && error !== null
|
|
16
|
+
? error
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
19
|
+
/** SQLite primary/extended constraint result codes (`SQLITE_CONSTRAINT*`). */
|
|
20
|
+
export function isSqliteConstraintError(error) {
|
|
21
|
+
const candidate = driverError(error);
|
|
22
|
+
const code = candidate?.code;
|
|
23
|
+
if (typeof code === 'string' &&
|
|
24
|
+
(code === 'SQLITE_CONSTRAINT' || code.startsWith('SQLITE_CONSTRAINT_'))) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
const errno = candidate?.errno;
|
|
28
|
+
return typeof errno === 'number' && (errno & 0xff) === 19;
|
|
29
|
+
}
|
|
30
|
+
/** PostgreSQL SQLSTATE class 23: integrity constraint violation. */
|
|
31
|
+
export function isPostgresConstraintError(error) {
|
|
32
|
+
const code = driverError(error)?.code;
|
|
33
|
+
return typeof code === 'string' && /^23[0-9A-Z]{3}$/.test(code);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* D1 may preserve SQLite's structured code or expose only a bounded platform
|
|
37
|
+
* prefix. The text fallback is adapter-private classification only: no part of
|
|
38
|
+
* the original message is copied to the public StorageConstraintError.
|
|
39
|
+
*/
|
|
40
|
+
export function isD1ConstraintError(error) {
|
|
41
|
+
if (isSqliteConstraintError(error))
|
|
42
|
+
return true;
|
|
43
|
+
const message = driverError(error)?.message;
|
|
44
|
+
return (typeof message === 'string' &&
|
|
45
|
+
/^(?:D1(?:_EXEC)?_ERROR:\s*)?(?:UNIQUE|NOT NULL|CHECK|FOREIGN KEY) constraint failed\b/i.test(message));
|
|
46
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -168,10 +168,16 @@ export interface StorageTransaction {
|
|
|
168
168
|
* `commitValidator` is configured so a concurrent duplicate cannot rerun it.
|
|
169
169
|
*/
|
|
170
170
|
commitRejectedPushResult?(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
|
|
171
|
-
upsertRow(table: string, row: StoredRow
|
|
171
|
+
upsertRow(table: string, row: StoredRow, context?: {
|
|
172
|
+
readonly opIndex: number;
|
|
173
|
+
}): Promise<void>;
|
|
172
174
|
deleteRow(table: string, rowId: string): Promise<void>;
|
|
173
175
|
/** Allocates the next per-partition commitSeq and appends the commit. */
|
|
174
176
|
appendCommit(commit: NewCommit): Promise<number>;
|
|
177
|
+
/**
|
|
178
|
+
* Persist an idempotency outcome only when the key is still absent. The
|
|
179
|
+
* first writer wins; callers read the canonical value after commit.
|
|
180
|
+
*/
|
|
175
181
|
putPushResult(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
|
|
176
182
|
/**
|
|
177
183
|
* Blob reference index (§5.9.4) — ADDITIVE, optional. Set the blobIds a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.14",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"!dist/**/*.test.d.ts"
|
|
54
54
|
],
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@syncular/core": "0.15.
|
|
56
|
+
"@syncular/core": "0.15.14"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/d1-storage.ts
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
* This mirrors PostgreSQL's per-partition row lock, achieved by placement
|
|
40
40
|
* rather than a lock D1 does not expose.
|
|
41
41
|
*/
|
|
42
|
+
import { decodeRow, type RowValue } from '@syncular/core';
|
|
42
43
|
import { syncError } from './errors';
|
|
43
44
|
import {
|
|
44
45
|
commitWindowPageSql,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
layoutsOf,
|
|
48
49
|
migratePayload,
|
|
49
50
|
parseLayouts,
|
|
51
|
+
quoteIdent,
|
|
50
52
|
retiredTableNames,
|
|
51
53
|
rewritePlan,
|
|
52
54
|
rewriteRowSql,
|
|
@@ -59,6 +61,7 @@ import {
|
|
|
59
61
|
selectRowSql,
|
|
60
62
|
selectRowsForRewriteSql,
|
|
61
63
|
tableColumnNames,
|
|
64
|
+
toSqlValue,
|
|
62
65
|
upsertSql,
|
|
63
66
|
upsertValues,
|
|
64
67
|
} from './relational-rows';
|
|
@@ -91,6 +94,7 @@ import type {
|
|
|
91
94
|
StoredPushResult,
|
|
92
95
|
StoredRow,
|
|
93
96
|
} from './storage';
|
|
97
|
+
import { isD1ConstraintError, StorageConstraintError } from './storage-errors';
|
|
94
98
|
|
|
95
99
|
// -- The subset of the D1 API this storage uses (structural typing) ---------
|
|
96
100
|
// Declared locally so the package takes no `@cloudflare/workers-types`
|
|
@@ -115,6 +119,16 @@ interface BufferedStatement {
|
|
|
115
119
|
readonly params: readonly unknown[];
|
|
116
120
|
}
|
|
117
121
|
|
|
122
|
+
function relationalValuesEqual(left: RowValue, right: RowValue): boolean {
|
|
123
|
+
if (left instanceof Uint8Array && right instanceof Uint8Array) {
|
|
124
|
+
return (
|
|
125
|
+
left.length === right.length &&
|
|
126
|
+
left.every((value, index) => value === right[index])
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return Object.is(left, right);
|
|
130
|
+
}
|
|
131
|
+
|
|
118
132
|
/** Read-your-own-writes overlay entry: a buffered upsert, or a deletion. */
|
|
119
133
|
type PendingRow =
|
|
120
134
|
| { readonly kind: 'row'; readonly row: StoredRow }
|
|
@@ -132,6 +146,7 @@ class D1Transaction implements StorageTransaction {
|
|
|
132
146
|
/** Live snapshot of `max_commit_seq`, advanced within this transaction. */
|
|
133
147
|
#maxCommitSeq: number | undefined;
|
|
134
148
|
#commitValidationCheckpoint: number | undefined;
|
|
149
|
+
#lastApplicationOpIndex: number | undefined;
|
|
135
150
|
/**
|
|
136
151
|
* Read-your-own-writes overlay (§6.2 needs `getRow` to see buffered writes
|
|
137
152
|
* of the same commit — e.g. two ops touching the same row): keyed
|
|
@@ -270,14 +285,98 @@ class D1Transaction implements StorageTransaction {
|
|
|
270
285
|
await this.commit();
|
|
271
286
|
}
|
|
272
287
|
|
|
273
|
-
async
|
|
288
|
+
async #assertNoUniqueCollision(
|
|
289
|
+
table: CompiledTable,
|
|
290
|
+
row: StoredRow,
|
|
291
|
+
opIndex: number | undefined,
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
if (!table.materialize) return;
|
|
294
|
+
const uniqueIndexes = table.indexes.filter(
|
|
295
|
+
(index) => index.unique === true,
|
|
296
|
+
);
|
|
297
|
+
if (uniqueIndexes.length === 0) return;
|
|
298
|
+
const incoming = decodeRow(table.columns, row.payload);
|
|
299
|
+
|
|
300
|
+
for (const index of uniqueIndexes) {
|
|
301
|
+
const indices = index.columns.map((column) => {
|
|
302
|
+
const resolved = table.columnIndex.get(column);
|
|
303
|
+
if (resolved === undefined) {
|
|
304
|
+
throw new Error(`compiled unique index references unknown column`);
|
|
305
|
+
}
|
|
306
|
+
return resolved;
|
|
307
|
+
});
|
|
308
|
+
const values = indices.map((position) => incoming[position] ?? null);
|
|
309
|
+
// SQLite UNIQUE permits multiple rows when any indexed value is NULL.
|
|
310
|
+
if (values.some((value) => value === null)) continue;
|
|
311
|
+
|
|
312
|
+
for (const [key, pending] of this.#pending) {
|
|
313
|
+
if (!key.startsWith(`${table.name}\u0000`) || pending.kind !== 'row') {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (pending.row.rowId === row.rowId) continue;
|
|
317
|
+
const candidate = decodeRow(table.columns, pending.row.payload);
|
|
318
|
+
if (
|
|
319
|
+
indices.every((position, valueIndex) =>
|
|
320
|
+
relationalValuesEqual(
|
|
321
|
+
candidate[position] ?? null,
|
|
322
|
+
values[valueIndex] ?? null,
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
) {
|
|
326
|
+
throw new StorageConstraintError(undefined, opIndex);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const predicates = index.columns
|
|
331
|
+
.map((column) => `${quoteIdent(column)}=?`)
|
|
332
|
+
.join(' AND ');
|
|
333
|
+
const sql = `SELECT ${quoteIdent('_sync_row_id')} AS row_id FROM ${quoteIdent(table.name)} WHERE ${quoteIdent('_sync_partition')}=? AND ${predicates} AND ${quoteIdent('_sync_row_id')}<>? LIMIT 1`;
|
|
334
|
+
const bind = indices.map((position, valueIndex) => {
|
|
335
|
+
const schemaColumn =
|
|
336
|
+
position === undefined ? undefined : table.columns[position];
|
|
337
|
+
if (schemaColumn === undefined) {
|
|
338
|
+
throw new Error(`compiled unique index references unknown column`);
|
|
339
|
+
}
|
|
340
|
+
return toSqlValue(schemaColumn, values[valueIndex] ?? null, 'sqlite');
|
|
341
|
+
});
|
|
342
|
+
const persisted = await this.#db
|
|
343
|
+
.prepare(sql)
|
|
344
|
+
.bind(this.#partition, ...bind, row.rowId)
|
|
345
|
+
.first<{ row_id: string }>();
|
|
346
|
+
if (persisted === null) continue;
|
|
347
|
+
|
|
348
|
+
const pending = this.#pending.get(
|
|
349
|
+
D1Transaction.#key(table.name, persisted.row_id),
|
|
350
|
+
);
|
|
351
|
+
if (pending?.kind === 'deleted') continue;
|
|
352
|
+
if (pending?.kind === 'row') {
|
|
353
|
+
const candidate = decodeRow(table.columns, pending.row.payload);
|
|
354
|
+
const stillCollides = indices.every((position, valueIndex) =>
|
|
355
|
+
relationalValuesEqual(
|
|
356
|
+
candidate[position] ?? null,
|
|
357
|
+
values[valueIndex] ?? null,
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
if (!stillCollides) continue;
|
|
361
|
+
}
|
|
362
|
+
throw new StorageConstraintError(undefined, opIndex);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async upsertRow(
|
|
367
|
+
table: string,
|
|
368
|
+
row: StoredRow,
|
|
369
|
+
context?: { readonly opIndex: number },
|
|
370
|
+
): Promise<void> {
|
|
274
371
|
this.#assertOpen();
|
|
372
|
+
const compiled = this.#resolveTable(table);
|
|
373
|
+
await this.#assertNoUniqueCollision(compiled, row, context?.opIndex);
|
|
374
|
+
this.#lastApplicationOpIndex = context?.opIndex;
|
|
275
375
|
this.#pending.set(D1Transaction.#key(table, row.rowId), {
|
|
276
376
|
kind: 'row',
|
|
277
377
|
row,
|
|
278
378
|
});
|
|
279
379
|
const p = this.#partition;
|
|
280
|
-
const compiled = this.#resolveTable(table);
|
|
281
380
|
this.#buffer_(
|
|
282
381
|
upsertSql(compiled, 'sqlite'),
|
|
283
382
|
upsertValues(compiled, p, row, 'sqlite'),
|
|
@@ -394,7 +493,7 @@ class D1Transaction implements StorageTransaction {
|
|
|
394
493
|
): Promise<void> {
|
|
395
494
|
this.#assertOpen();
|
|
396
495
|
this.#buffer_(
|
|
397
|
-
'INSERT OR
|
|
496
|
+
'INSERT OR IGNORE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)',
|
|
398
497
|
[this.#partition, clientId, clientCommitId, serializePushResult(result)],
|
|
399
498
|
);
|
|
400
499
|
}
|
|
@@ -407,7 +506,14 @@ class D1Transaction implements StorageTransaction {
|
|
|
407
506
|
this.#db.prepare(entry.sql).bind(...entry.params),
|
|
408
507
|
);
|
|
409
508
|
// One atomic D1 batch — the §6.4 all-or-nothing commit.
|
|
410
|
-
|
|
509
|
+
try {
|
|
510
|
+
await this.#db.batch(statements);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (isD1ConstraintError(error)) {
|
|
513
|
+
throw new StorageConstraintError(error, this.#lastApplicationOpIndex);
|
|
514
|
+
}
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
411
517
|
}
|
|
412
518
|
|
|
413
519
|
async rollback(): Promise<void> {
|
package/src/postgres-storage.ts
CHANGED
|
@@ -84,6 +84,10 @@ import type {
|
|
|
84
84
|
StoredPushResult,
|
|
85
85
|
StoredRow,
|
|
86
86
|
} from './storage';
|
|
87
|
+
import {
|
|
88
|
+
isPostgresConstraintError,
|
|
89
|
+
StorageConstraintError,
|
|
90
|
+
} from './storage-errors';
|
|
87
91
|
|
|
88
92
|
/**
|
|
89
93
|
* Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
|
|
@@ -506,14 +510,25 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
506
510
|
await this.commit();
|
|
507
511
|
}
|
|
508
512
|
|
|
509
|
-
async upsertRow(
|
|
513
|
+
async upsertRow(
|
|
514
|
+
table: string,
|
|
515
|
+
row: StoredRow,
|
|
516
|
+
context?: { readonly opIndex: number },
|
|
517
|
+
): Promise<void> {
|
|
510
518
|
this.#assertOpen();
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
519
|
+
try {
|
|
520
|
+
await writeRowOn(
|
|
521
|
+
this.#client,
|
|
522
|
+
this.#resolveTable(table),
|
|
523
|
+
this.#partition,
|
|
524
|
+
row,
|
|
525
|
+
);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
if (isPostgresConstraintError(error)) {
|
|
528
|
+
throw new StorageConstraintError(error, context?.opIndex);
|
|
529
|
+
}
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
517
532
|
}
|
|
518
533
|
|
|
519
534
|
async deleteRow(table: string, rowId: string): Promise<void> {
|
|
@@ -618,8 +633,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
618
633
|
await this.#client.query(
|
|
619
634
|
`INSERT INTO sync_push_results(partition, client_id, client_commit_id, result)
|
|
620
635
|
VALUES ($1,$2,$3,$4)
|
|
621
|
-
ON CONFLICT (partition, client_id, client_commit_id) DO
|
|
622
|
-
SET result=EXCLUDED.result`,
|
|
636
|
+
ON CONFLICT (partition, client_id, client_commit_id) DO NOTHING`,
|
|
623
637
|
[
|
|
624
638
|
this.#partition,
|
|
625
639
|
clientId,
|
package/src/push.ts
CHANGED
|
@@ -42,6 +42,7 @@ import type {
|
|
|
42
42
|
StoredCommit,
|
|
43
43
|
StoredPushResult,
|
|
44
44
|
} from './storage';
|
|
45
|
+
import { StorageConstraintError } from './storage-errors';
|
|
45
46
|
import type {
|
|
46
47
|
CommitValidationReader,
|
|
47
48
|
CommitValidator,
|
|
@@ -435,7 +436,7 @@ async function applyOperation(
|
|
|
435
436
|
scopes: stored.scopes,
|
|
436
437
|
payload: newPayload,
|
|
437
438
|
};
|
|
438
|
-
await tx.upsertRow(op.table, newRow);
|
|
439
|
+
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
439
440
|
return {
|
|
440
441
|
kind: 'applied',
|
|
441
442
|
change: {
|
|
@@ -540,7 +541,7 @@ async function applyOperation(
|
|
|
540
541
|
scopes: extracted.scopes,
|
|
541
542
|
payload: insertPayload,
|
|
542
543
|
};
|
|
543
|
-
await tx.upsertRow(op.table, newRow);
|
|
544
|
+
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
544
545
|
return {
|
|
545
546
|
kind: 'applied',
|
|
546
547
|
change: {
|
|
@@ -769,6 +770,32 @@ function idempotencyCacheMissFrame(
|
|
|
769
770
|
};
|
|
770
771
|
}
|
|
771
772
|
|
|
773
|
+
async function persistRejectedPushResult(
|
|
774
|
+
storage: SyncRequestContext['storage'],
|
|
775
|
+
partition: string,
|
|
776
|
+
clientId: string,
|
|
777
|
+
clientCommitId: string,
|
|
778
|
+
stored: StoredPushResult,
|
|
779
|
+
): Promise<StoredPushResult> {
|
|
780
|
+
const rejectionTx = await storage.begin(partition);
|
|
781
|
+
try {
|
|
782
|
+
await rejectionTx.putPushResult(clientId, clientCommitId, stored);
|
|
783
|
+
await rejectionTx.commit();
|
|
784
|
+
} catch (error) {
|
|
785
|
+
await rejectionTx.rollback();
|
|
786
|
+
throw error;
|
|
787
|
+
}
|
|
788
|
+
const canonical = await storage.getPushResult(
|
|
789
|
+
partition,
|
|
790
|
+
clientId,
|
|
791
|
+
clientCommitId,
|
|
792
|
+
);
|
|
793
|
+
if (canonical === undefined) {
|
|
794
|
+
throw new Error('push rejection finalization did not persist an outcome');
|
|
795
|
+
}
|
|
796
|
+
return canonical;
|
|
797
|
+
}
|
|
798
|
+
|
|
772
799
|
export interface AppliedCommitEvent {
|
|
773
800
|
readonly commit: StoredCommit;
|
|
774
801
|
}
|
|
@@ -912,18 +939,18 @@ export async function processPushCommit(
|
|
|
912
939
|
await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
|
|
913
940
|
} else {
|
|
914
941
|
await tx.rollback();
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
942
|
+
const canonical = await persistRejectedPushResult(
|
|
943
|
+
storage,
|
|
944
|
+
partition,
|
|
945
|
+
clientId,
|
|
946
|
+
frame.clientCommitId,
|
|
947
|
+
stored,
|
|
948
|
+
);
|
|
949
|
+
return resultFrame(
|
|
950
|
+
frame.clientCommitId,
|
|
951
|
+
canonical,
|
|
952
|
+
canonical !== stored,
|
|
953
|
+
);
|
|
927
954
|
}
|
|
928
955
|
return resultFrame(frame.clientCommitId, stored, false);
|
|
929
956
|
}
|
|
@@ -949,6 +976,28 @@ export async function processPushCommit(
|
|
|
949
976
|
return resultFrame(frame.clientCommitId, stored, false);
|
|
950
977
|
} catch (error) {
|
|
951
978
|
await tx.rollback();
|
|
979
|
+
if (error instanceof StorageConstraintError) {
|
|
980
|
+
const stored: StoredPushResult = {
|
|
981
|
+
status: 'rejected',
|
|
982
|
+
results: [
|
|
983
|
+
{
|
|
984
|
+
opIndex: error.opIndex ?? 0,
|
|
985
|
+
status: 'error',
|
|
986
|
+
code: 'sync.constraint_violation',
|
|
987
|
+
message: 'write violates a relational constraint',
|
|
988
|
+
retryable: false,
|
|
989
|
+
},
|
|
990
|
+
],
|
|
991
|
+
};
|
|
992
|
+
const canonical = await persistRejectedPushResult(
|
|
993
|
+
storage,
|
|
994
|
+
partition,
|
|
995
|
+
clientId,
|
|
996
|
+
frame.clientCommitId,
|
|
997
|
+
stored,
|
|
998
|
+
);
|
|
999
|
+
return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
|
|
1000
|
+
}
|
|
952
1001
|
throw error;
|
|
953
1002
|
}
|
|
954
1003
|
}
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -57,6 +57,10 @@ import type {
|
|
|
57
57
|
StoredPushResult,
|
|
58
58
|
StoredRow,
|
|
59
59
|
} from './storage';
|
|
60
|
+
import {
|
|
61
|
+
isSqliteConstraintError,
|
|
62
|
+
StorageConstraintError,
|
|
63
|
+
} from './storage-errors';
|
|
60
64
|
|
|
61
65
|
class SqliteTransaction implements StorageTransaction {
|
|
62
66
|
#storage: SqliteServerStorage;
|
|
@@ -113,9 +117,20 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
113
117
|
await this.commit();
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
async upsertRow(
|
|
120
|
+
async upsertRow(
|
|
121
|
+
table: string,
|
|
122
|
+
row: StoredRow,
|
|
123
|
+
context?: { readonly opIndex: number },
|
|
124
|
+
): Promise<void> {
|
|
117
125
|
this.#assertOpen();
|
|
118
|
-
|
|
126
|
+
try {
|
|
127
|
+
this.#storage.writeRow(this.#partition, table, row);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (isSqliteConstraintError(error)) {
|
|
130
|
+
throw new StorageConstraintError(error, context?.opIndex);
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
119
134
|
}
|
|
120
135
|
|
|
121
136
|
async deleteRow(table: string, rowId: string): Promise<void> {
|
|
@@ -209,7 +224,7 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
209
224
|
this.#assertOpen();
|
|
210
225
|
this.#storage.db
|
|
211
226
|
.query(
|
|
212
|
-
'INSERT OR
|
|
227
|
+
'INSERT OR IGNORE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)',
|
|
213
228
|
)
|
|
214
229
|
.run(
|
|
215
230
|
this.#partition,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy-safe storage failures which the push protocol is allowed to turn
|
|
3
|
+
* into durable application-write rejections. Database-specific errors remain
|
|
4
|
+
* attached only as an internal cause and never cross the protocol boundary.
|
|
5
|
+
*/
|
|
6
|
+
export class StorageConstraintError extends Error {
|
|
7
|
+
override readonly name = 'StorageConstraintError';
|
|
8
|
+
readonly opIndex: number | undefined;
|
|
9
|
+
|
|
10
|
+
constructor(cause: unknown, opIndex?: number) {
|
|
11
|
+
super('application row violates a relational constraint', { cause });
|
|
12
|
+
this.opIndex = opIndex;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DriverError {
|
|
17
|
+
readonly code?: unknown;
|
|
18
|
+
readonly errno?: unknown;
|
|
19
|
+
readonly message?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function driverError(error: unknown): DriverError | undefined {
|
|
23
|
+
return typeof error === 'object' && error !== null
|
|
24
|
+
? (error as DriverError)
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** SQLite primary/extended constraint result codes (`SQLITE_CONSTRAINT*`). */
|
|
29
|
+
export function isSqliteConstraintError(error: unknown): boolean {
|
|
30
|
+
const candidate = driverError(error);
|
|
31
|
+
const code = candidate?.code;
|
|
32
|
+
if (
|
|
33
|
+
typeof code === 'string' &&
|
|
34
|
+
(code === 'SQLITE_CONSTRAINT' || code.startsWith('SQLITE_CONSTRAINT_'))
|
|
35
|
+
) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const errno = candidate?.errno;
|
|
39
|
+
return typeof errno === 'number' && (errno & 0xff) === 19;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** PostgreSQL SQLSTATE class 23: integrity constraint violation. */
|
|
43
|
+
export function isPostgresConstraintError(error: unknown): boolean {
|
|
44
|
+
const code = driverError(error)?.code;
|
|
45
|
+
return typeof code === 'string' && /^23[0-9A-Z]{3}$/.test(code);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* D1 may preserve SQLite's structured code or expose only a bounded platform
|
|
50
|
+
* prefix. The text fallback is adapter-private classification only: no part of
|
|
51
|
+
* the original message is copied to the public StorageConstraintError.
|
|
52
|
+
*/
|
|
53
|
+
export function isD1ConstraintError(error: unknown): boolean {
|
|
54
|
+
if (isSqliteConstraintError(error)) return true;
|
|
55
|
+
const message = driverError(error)?.message;
|
|
56
|
+
return (
|
|
57
|
+
typeof message === 'string' &&
|
|
58
|
+
/^(?:D1(?:_EXEC)?_ERROR:\s*)?(?:UNIQUE|NOT NULL|CHECK|FOREIGN KEY) constraint failed\b/i.test(
|
|
59
|
+
message,
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
}
|
package/src/storage.ts
CHANGED
|
@@ -188,10 +188,18 @@ export interface StorageTransaction {
|
|
|
188
188
|
clientCommitId: string,
|
|
189
189
|
result: StoredPushResult,
|
|
190
190
|
): Promise<void>;
|
|
191
|
-
upsertRow(
|
|
191
|
+
upsertRow(
|
|
192
|
+
table: string,
|
|
193
|
+
row: StoredRow,
|
|
194
|
+
context?: { readonly opIndex: number },
|
|
195
|
+
): Promise<void>;
|
|
192
196
|
deleteRow(table: string, rowId: string): Promise<void>;
|
|
193
197
|
/** Allocates the next per-partition commitSeq and appends the commit. */
|
|
194
198
|
appendCommit(commit: NewCommit): Promise<number>;
|
|
199
|
+
/**
|
|
200
|
+
* Persist an idempotency outcome only when the key is still absent. The
|
|
201
|
+
* first writer wins; callers read the canonical value after commit.
|
|
202
|
+
*/
|
|
195
203
|
putPushResult(
|
|
196
204
|
clientId: string,
|
|
197
205
|
clientCommitId: string,
|