@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 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
@@ -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(D1Transaction.#key(table, rowId));
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 upsertRow(table, row) {
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.#pending.set(D1Transaction.#key(table, row.rowId), {
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(D1Transaction.#key(table, rowId), { kind: 'deleted' });
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 REPLACE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)', [this.#partition, clientId, clientCommitId, serializePushResult(result)]);
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
- await this.#db.batch(statements);
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.
@@ -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
- await writeRowOn(this.#client, this.#resolveTable(table), this.#partition, row);
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 UPDATE
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 rejectionTx = await storage.begin(partition);
582
- try {
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
  }
@@ -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
- this.#storage.writeRow(this.#partition, table, row);
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 REPLACE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)')
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): Promise<void>;
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.12",
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.12"
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 upsertRow(table: string, row: StoredRow): Promise<void> {
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 REPLACE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)',
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
- await this.#db.batch(statements);
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> {
@@ -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(table: string, row: StoredRow): Promise<void> {
513
+ async upsertRow(
514
+ table: string,
515
+ row: StoredRow,
516
+ context?: { readonly opIndex: number },
517
+ ): Promise<void> {
510
518
  this.#assertOpen();
511
- await writeRowOn(
512
- this.#client,
513
- this.#resolveTable(table),
514
- this.#partition,
515
- row,
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 UPDATE
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 rejectionTx = await storage.begin(partition);
916
- try {
917
- await rejectionTx.putPushResult(
918
- clientId,
919
- frame.clientCommitId,
920
- stored,
921
- );
922
- await rejectionTx.commit();
923
- } catch (error) {
924
- await rejectionTx.rollback();
925
- throw error;
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
  }
@@ -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(table: string, row: StoredRow): Promise<void> {
120
+ async upsertRow(
121
+ table: string,
122
+ row: StoredRow,
123
+ context?: { readonly opIndex: number },
124
+ ): Promise<void> {
117
125
  this.#assertOpen();
118
- this.#storage.writeRow(this.#partition, table, row);
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 REPLACE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)',
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(table: string, row: StoredRow): Promise<void>;
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,