@syncular/client 0.11.0 → 0.13.0

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
@@ -123,6 +123,14 @@ that rolled back with the terminating operation. It stays in the protected
123
123
  client database and is never added to the wire protocol, preferences, or
124
124
  telemetry; successful and historical outcomes may omit it.
125
125
 
126
+ Rejected optimistic state is removed immediately, even when a validator emits
127
+ no server row in the pull half. The TypeScript client stores protected,
128
+ restart-safe before-images beside each pending outbox commit, restores the last
129
+ confirmed rows (including rejected deletes and atomic siblings), and then
130
+ replays later pending edits. These before-images are internal rollback state:
131
+ they are never encoded on the wire or exposed through pending commits, outcome
132
+ envelopes, diagnostics, preferences, or telemetry.
133
+
126
134
  Use `patch(table, rowId, partial, { baseVersion? })` for editor-style partial
127
135
  updates. The wire still carries a full row, but the durable local operation
128
136
  records a sorted `changedFields` list so conflict and rejection UI knows which
package/dist/client.js CHANGED
@@ -14,7 +14,7 @@ import { registerDevtools } from './devtools.js';
14
14
  import { ClientSyncError } from './errors.js';
15
15
  import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
16
16
  import { singleOwnerLock, } from './leader-lock.js';
17
- import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, OutboxEncodeError, } from './outbox.js';
17
+ import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
18
18
  import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
19
19
  import { assertReadOnlyQuery } from './query-guard.js';
20
20
  import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
@@ -1004,7 +1004,7 @@ export class SyncClient {
1004
1004
  });
1005
1005
  this.#applyBatch((batch) => {
1006
1006
  this.#db.transaction(() => {
1007
- appendOutboxCommit(this.#db, clientCommitId, operations, this.#now());
1007
+ appendOutboxCommit(this.#db, clientCommitId, operations, this.#now(), this.#captureBeforeImages(operations));
1008
1008
  this.#applyOperationsLocally(operations, batch);
1009
1009
  batch.status();
1010
1010
  });
@@ -1123,21 +1123,7 @@ export class SyncClient {
1123
1123
  */
1124
1124
  #dropIncompatibleCommit(commit, message) {
1125
1125
  this.#applyBatch((batch) => {
1126
- deleteOutboxCommit(this.#db, commit.clientCommitId);
1127
- for (const operation of commit.operations) {
1128
- if (operation.op !== 'upsert')
1129
- continue;
1130
- const table = this.#schema.tables.get(operation.table);
1131
- if (table === undefined)
1132
- continue;
1133
- const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
1134
- if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
1135
- if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
1136
- batch.table(table.name);
1137
- }
1138
- deleteLocalRow(this.#db, table, operation.rowId);
1139
- }
1140
- }
1126
+ this.#rollbackFailedCommit(commit, batch);
1141
1127
  const rejection = {
1142
1128
  clientCommitId: commit.clientCommitId,
1143
1129
  opIndex: 0,
@@ -1811,26 +1797,11 @@ export class SyncClient {
1811
1797
  });
1812
1798
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
1813
1799
  batch.outcomes();
1814
- // §7.2: stop optimistic display and decide about dependents — the
1815
- // commit leaves the outbox; rows it created that the server never
1816
- // confirmed are undone here, rows it overwrote reconcile via the pull
1817
- // half (the conflict record carries the server row for the app).
1800
+ // §7.2: remove the rejected optimistic layer. Before-images restore
1801
+ // validator-rejected updates even when the server emitted no new COMMIT;
1802
+ // later pending overlays are then replayed with rebased before-images.
1818
1803
  this.#db.transaction(() => {
1819
- deleteOutboxCommit(this.#db, frame.clientCommitId);
1820
- for (const operation of commit.operations) {
1821
- if (operation.op !== 'upsert')
1822
- continue;
1823
- const table = this.#schema.tables.get(operation.table);
1824
- if (table === undefined)
1825
- continue;
1826
- const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
1827
- if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
1828
- if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
1829
- batch.table(table.name);
1830
- }
1831
- deleteLocalRow(this.#db, table, operation.rowId);
1832
- }
1833
- }
1804
+ this.#rollbackFailedCommit(commit, batch);
1834
1805
  });
1835
1806
  batch.status();
1836
1807
  summary.rejected.push(frame.clientCommitId);
@@ -2127,6 +2098,96 @@ export class SyncClient {
2127
2098
  return false;
2128
2099
  }
2129
2100
  // -- optimistic state ----------------------------------------------------------
2101
+ #captureBeforeImage(operation, opIndex) {
2102
+ const table = this.#schema.tables.get(operation.table);
2103
+ if (table === undefined)
2104
+ return { opIndex, existed: false };
2105
+ const columns = [
2106
+ ...table.columns.map((column) => quoteIdent(column.name)),
2107
+ quoteIdent(SYNC_VERSION_COLUMN),
2108
+ ];
2109
+ const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
2110
+ if (row === undefined)
2111
+ return { opIndex, existed: false };
2112
+ const values = {};
2113
+ for (const column of table.columns) {
2114
+ values[column.name] = rowValueToJson(fromSqlValue(column, row[column.name] ?? null));
2115
+ }
2116
+ const syncVersion = row[SYNC_VERSION_COLUMN];
2117
+ if (typeof syncVersion !== 'number') {
2118
+ throw new ClientSyncError('sync.local_corrupt', `local row ${operation.table}/${operation.rowId} has no sync version`);
2119
+ }
2120
+ return { opIndex, existed: true, syncVersion, values };
2121
+ }
2122
+ #captureBeforeImages(operations, onlyKeys) {
2123
+ return operations.flatMap((operation, opIndex) => onlyKeys && !onlyKeys.has(`${operation.table}\u0000${operation.rowId}`)
2124
+ ? []
2125
+ : [this.#captureBeforeImage(operation, opIndex)]);
2126
+ }
2127
+ #restoreBeforeImage(operation, image, batch) {
2128
+ const table = this.#schema.tables.get(operation.table);
2129
+ if (table === undefined)
2130
+ return;
2131
+ batch.table(table.name);
2132
+ if (!image.existed) {
2133
+ deleteLocalRow(this.#db, table, operation.rowId);
2134
+ return;
2135
+ }
2136
+ if (image.values === undefined || image.syncVersion === undefined) {
2137
+ throw new ClientSyncError('sync.local_corrupt', `rollback image for ${operation.table}/${operation.rowId} is incomplete`);
2138
+ }
2139
+ const values = table.columns.map((column) => jsonToRowValue(image.values?.[column.name] ?? null));
2140
+ upsertLocalRow(this.#db, table, values, image.syncVersion);
2141
+ }
2142
+ #legacyUndoOptimisticRows(commit, batch) {
2143
+ for (const operation of commit.operations) {
2144
+ if (operation.op !== 'upsert')
2145
+ continue;
2146
+ const table = this.#schema.tables.get(operation.table);
2147
+ if (table === undefined)
2148
+ continue;
2149
+ const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
2150
+ if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
2151
+ batch.table(table.name);
2152
+ deleteLocalRow(this.#db, table, operation.rowId);
2153
+ }
2154
+ }
2155
+ }
2156
+ #rollbackFailedCommit(commit, batch) {
2157
+ const images = listOutboxBeforeImages(this.#db, commit.clientCommitId);
2158
+ const imageByIndex = new Map(images.map((image) => [image.opIndex, image]));
2159
+ const complete = commit.operations.every((_, index) => imageByIndex.has(index));
2160
+ const affectedKeys = new Set(commit.operations.map((operation) => `${operation.table}\u0000${operation.rowId}`));
2161
+ if (complete) {
2162
+ const restored = new Set();
2163
+ commit.operations.forEach((operation, index) => {
2164
+ const key = `${operation.table}\u0000${operation.rowId}`;
2165
+ if (restored.has(key))
2166
+ return;
2167
+ const image = imageByIndex.get(index);
2168
+ if (image)
2169
+ this.#restoreBeforeImage(operation, image, batch);
2170
+ restored.add(key);
2171
+ });
2172
+ }
2173
+ else {
2174
+ // Pending commits written by older clients have no before-images.
2175
+ // Preserve the old fail-closed behavior rather than guessing a base.
2176
+ this.#legacyUndoOptimisticRows(commit, batch);
2177
+ }
2178
+ deleteOutboxCommit(this.#db, commit.clientCommitId);
2179
+ if (!complete)
2180
+ return;
2181
+ for (const later of listOutbox(this.#db)) {
2182
+ if (later.seq <= commit.seq)
2183
+ continue;
2184
+ const affectedOperations = later.operations.filter((operation) => affectedKeys.has(`${operation.table}\u0000${operation.rowId}`));
2185
+ if (affectedOperations.length === 0)
2186
+ continue;
2187
+ replaceOutboxBeforeImages(this.#db, later.clientCommitId, this.#captureBeforeImages(later.operations, affectedKeys));
2188
+ this.#applyOperationsLocally(affectedOperations, batch);
2189
+ }
2190
+ }
2130
2191
  #applyOperationsLocally(operations, batch) {
2131
2192
  for (const op of operations) {
2132
2193
  const table = this.#table(op.table);
package/dist/outbox.d.ts CHANGED
@@ -30,10 +30,19 @@ export interface OutboxCommit {
30
30
  readonly createdAtMs: number;
31
31
  readonly operations: readonly OutboxOperation[];
32
32
  }
33
- export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number): void;
33
+ /** Protected local rollback image; never encoded or exposed in outcomes. */
34
+ export interface OutboxBeforeImage {
35
+ readonly opIndex: number;
36
+ readonly existed: boolean;
37
+ readonly syncVersion?: number;
38
+ readonly values?: Readonly<Record<string, JsonRowValue>>;
39
+ }
40
+ export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
34
41
  /** Pending commits in FIFO creation order (§7.1). */
35
42
  export declare function listOutbox(db: ClientDatabase): OutboxCommit[];
36
43
  export declare function deleteOutboxCommit(db: ClientDatabase, clientCommitId: string): void;
44
+ export declare function listOutboxBeforeImages(db: ClientDatabase, clientCommitId: string): OutboxBeforeImage[];
45
+ export declare function replaceOutboxBeforeImages(db: ClientDatabase, clientCommitId: string, replacements: readonly OutboxBeforeImage[]): void;
37
46
  /**
38
47
  * §7.4.4: after a schema bump, a persisted upsert may name a column the new
39
48
  * schema no longer has. The value has nowhere to go and there is no
package/dist/outbox.js CHANGED
@@ -8,12 +8,23 @@
8
8
  import { encodeRow, } from '@syncular/core';
9
9
  import { ClientSyncError } from './errors.js';
10
10
  import { jsonToRowValue, } from './schema.js';
11
- export function appendOutboxCommit(db, clientCommitId, operations, nowMs) {
11
+ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, beforeImages = []) {
12
12
  if (operations.length === 0) {
13
13
  throw new ClientSyncError('sync.empty_commit', 'a local commit must carry at least one operation (§6.1)');
14
14
  }
15
15
  db.exec(`INSERT INTO _syncular_outbox(client_commit_id, created_at_ms, operations)
16
16
  VALUES (?, ?, ?)`, [clientCommitId, nowMs, JSON.stringify(operations)]);
17
+ for (const image of beforeImages) {
18
+ db.exec(`INSERT INTO _syncular_outbox_before_images(
19
+ client_commit_id, op_index, existed, sync_version, values_json
20
+ ) VALUES (?, ?, ?, ?, ?)`, [
21
+ clientCommitId,
22
+ image.opIndex,
23
+ image.existed ? 1 : 0,
24
+ image.syncVersion ?? null,
25
+ image.values === undefined ? null : JSON.stringify(image.values),
26
+ ]);
27
+ }
17
28
  }
18
29
  /** Pending commits in FIFO creation order (§7.1). */
19
30
  export function listOutbox(db) {
@@ -28,10 +39,44 @@ export function listOutbox(db) {
28
39
  }));
29
40
  }
30
41
  export function deleteOutboxCommit(db, clientCommitId) {
42
+ db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id = ?', [clientCommitId]);
31
43
  db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id = ?', [
32
44
  clientCommitId,
33
45
  ]);
34
46
  }
47
+ export function listOutboxBeforeImages(db, clientCommitId) {
48
+ return db
49
+ .query(`SELECT op_index, existed, sync_version, values_json
50
+ FROM _syncular_outbox_before_images
51
+ WHERE client_commit_id = ? ORDER BY op_index`, [clientCommitId])
52
+ .map((row) => ({
53
+ opIndex: row.op_index,
54
+ existed: row.existed === 1,
55
+ ...(typeof row.sync_version === 'number'
56
+ ? { syncVersion: row.sync_version }
57
+ : {}),
58
+ ...(typeof row.values_json === 'string'
59
+ ? {
60
+ values: JSON.parse(row.values_json),
61
+ }
62
+ : {}),
63
+ }));
64
+ }
65
+ export function replaceOutboxBeforeImages(db, clientCommitId, replacements) {
66
+ for (const image of replacements) {
67
+ db.exec(`DELETE FROM _syncular_outbox_before_images
68
+ WHERE client_commit_id = ? AND op_index = ?`, [clientCommitId, image.opIndex]);
69
+ db.exec(`INSERT INTO _syncular_outbox_before_images(
70
+ client_commit_id, op_index, existed, sync_version, values_json
71
+ ) VALUES (?, ?, ?, ?, ?)`, [
72
+ clientCommitId,
73
+ image.opIndex,
74
+ image.existed ? 1 : 0,
75
+ image.syncVersion ?? null,
76
+ image.values === undefined ? null : JSON.stringify(image.values),
77
+ ]);
78
+ }
79
+ }
35
80
  /**
36
81
  * §7.4.4: after a schema bump, a persisted upsert may name a column the new
37
82
  * schema no longer has. The value has nowhere to go and there is no
package/dist/schema.js CHANGED
@@ -184,6 +184,13 @@ export function ensureLocalSchema(db, schema) {
184
184
  client_commit_id TEXT NOT NULL UNIQUE,
185
185
  created_at_ms INTEGER NOT NULL,
186
186
  operations TEXT NOT NULL)`);
187
+ db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox_before_images(
188
+ client_commit_id TEXT NOT NULL,
189
+ op_index INTEGER NOT NULL,
190
+ existed INTEGER NOT NULL CHECK(existed IN (0, 1)),
191
+ sync_version INTEGER,
192
+ values_json TEXT,
193
+ PRIMARY KEY(client_commit_id, op_index))`);
187
194
  db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
188
195
  seq INTEGER PRIMARY KEY AUTOINCREMENT,
189
196
  client_commit_id TEXT NOT NULL UNIQUE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.11.0"
84
+ "@syncular/core": "0.13.0"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.11.0",
95
+ "@syncular/server": "0.13.0",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/client.ts CHANGED
@@ -86,9 +86,12 @@ import {
86
86
  dropOutboxCommitsInScope,
87
87
  encodeOutboxCommit,
88
88
  listOutbox,
89
+ listOutboxBeforeImages,
90
+ type OutboxBeforeImage,
89
91
  type OutboxCommit,
90
92
  OutboxEncodeError,
91
93
  type OutboxOperation,
94
+ replaceOutboxBeforeImages,
92
95
  } from './outbox';
93
96
  import {
94
97
  activeFailureRecords,
@@ -1551,7 +1554,13 @@ export class SyncClient {
1551
1554
  });
1552
1555
  this.#applyBatch((batch) => {
1553
1556
  this.#db.transaction(() => {
1554
- appendOutboxCommit(this.#db, clientCommitId, operations, this.#now());
1557
+ appendOutboxCommit(
1558
+ this.#db,
1559
+ clientCommitId,
1560
+ operations,
1561
+ this.#now(),
1562
+ this.#captureBeforeImages(operations),
1563
+ );
1555
1564
  this.#applyOperationsLocally(operations, batch);
1556
1565
  batch.status();
1557
1566
  });
@@ -1705,22 +1714,7 @@ export class SyncClient {
1705
1714
  */
1706
1715
  #dropIncompatibleCommit(commit: OutboxCommit, message: string): void {
1707
1716
  this.#applyBatch((batch) => {
1708
- deleteOutboxCommit(this.#db, commit.clientCommitId);
1709
- for (const operation of commit.operations) {
1710
- if (operation.op !== 'upsert') continue;
1711
- const table = this.#schema.tables.get(operation.table);
1712
- if (table === undefined) continue;
1713
- const row = this.#db.query(
1714
- `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
1715
- [operation.rowId],
1716
- )[0];
1717
- if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
1718
- if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
1719
- batch.table(table.name);
1720
- }
1721
- deleteLocalRow(this.#db, table, operation.rowId);
1722
- }
1723
- }
1717
+ this.#rollbackFailedCommit(commit, batch);
1724
1718
  const rejection: RejectionRecord = {
1725
1719
  clientCommitId: commit.clientCommitId,
1726
1720
  opIndex: 0,
@@ -2548,27 +2542,11 @@ export class SyncClient {
2548
2542
  });
2549
2543
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2550
2544
  batch.outcomes();
2551
- // §7.2: stop optimistic display and decide about dependents — the
2552
- // commit leaves the outbox; rows it created that the server never
2553
- // confirmed are undone here, rows it overwrote reconcile via the pull
2554
- // half (the conflict record carries the server row for the app).
2545
+ // §7.2: remove the rejected optimistic layer. Before-images restore
2546
+ // validator-rejected updates even when the server emitted no new COMMIT;
2547
+ // later pending overlays are then replayed with rebased before-images.
2555
2548
  this.#db.transaction(() => {
2556
- deleteOutboxCommit(this.#db, frame.clientCommitId);
2557
- for (const operation of commit.operations) {
2558
- if (operation.op !== 'upsert') continue;
2559
- const table = this.#schema.tables.get(operation.table);
2560
- if (table === undefined) continue;
2561
- const row = this.#db.query(
2562
- `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2563
- [operation.rowId],
2564
- )[0];
2565
- if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
2566
- if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
2567
- batch.table(table.name);
2568
- }
2569
- deleteLocalRow(this.#db, table, operation.rowId);
2570
- }
2571
- }
2549
+ this.#rollbackFailedCommit(commit, batch);
2572
2550
  });
2573
2551
  batch.status();
2574
2552
  summary.rejected.push(frame.clientCommitId);
@@ -2945,6 +2923,134 @@ export class SyncClient {
2945
2923
 
2946
2924
  // -- optimistic state ----------------------------------------------------------
2947
2925
 
2926
+ #captureBeforeImage(
2927
+ operation: OutboxOperation,
2928
+ opIndex: number,
2929
+ ): OutboxBeforeImage {
2930
+ const table = this.#schema.tables.get(operation.table);
2931
+ if (table === undefined) return { opIndex, existed: false };
2932
+ const columns = [
2933
+ ...table.columns.map((column) => quoteIdent(column.name)),
2934
+ quoteIdent(SYNC_VERSION_COLUMN),
2935
+ ];
2936
+ const row = this.#db.query(
2937
+ `SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2938
+ [operation.rowId],
2939
+ )[0];
2940
+ if (row === undefined) return { opIndex, existed: false };
2941
+ const values: Record<string, ReturnType<typeof rowValueToJson>> = {};
2942
+ for (const column of table.columns) {
2943
+ values[column.name] = rowValueToJson(
2944
+ fromSqlValue(column, row[column.name] ?? null),
2945
+ );
2946
+ }
2947
+ const syncVersion = row[SYNC_VERSION_COLUMN];
2948
+ if (typeof syncVersion !== 'number') {
2949
+ throw new ClientSyncError(
2950
+ 'sync.local_corrupt',
2951
+ `local row ${operation.table}/${operation.rowId} has no sync version`,
2952
+ );
2953
+ }
2954
+ return { opIndex, existed: true, syncVersion, values };
2955
+ }
2956
+
2957
+ #captureBeforeImages(
2958
+ operations: readonly OutboxOperation[],
2959
+ onlyKeys?: ReadonlySet<string>,
2960
+ ): OutboxBeforeImage[] {
2961
+ return operations.flatMap((operation, opIndex) =>
2962
+ onlyKeys && !onlyKeys.has(`${operation.table}\u0000${operation.rowId}`)
2963
+ ? []
2964
+ : [this.#captureBeforeImage(operation, opIndex)],
2965
+ );
2966
+ }
2967
+
2968
+ #restoreBeforeImage(
2969
+ operation: OutboxOperation,
2970
+ image: OutboxBeforeImage,
2971
+ batch: ChangeAccumulator,
2972
+ ): void {
2973
+ const table = this.#schema.tables.get(operation.table);
2974
+ if (table === undefined) return;
2975
+ batch.table(table.name);
2976
+ if (!image.existed) {
2977
+ deleteLocalRow(this.#db, table, operation.rowId);
2978
+ return;
2979
+ }
2980
+ if (image.values === undefined || image.syncVersion === undefined) {
2981
+ throw new ClientSyncError(
2982
+ 'sync.local_corrupt',
2983
+ `rollback image for ${operation.table}/${operation.rowId} is incomplete`,
2984
+ );
2985
+ }
2986
+ const values = table.columns.map((column) =>
2987
+ jsonToRowValue(image.values?.[column.name] ?? null),
2988
+ );
2989
+ upsertLocalRow(this.#db, table, values, image.syncVersion);
2990
+ }
2991
+
2992
+ #legacyUndoOptimisticRows(
2993
+ commit: OutboxCommit,
2994
+ batch: ChangeAccumulator,
2995
+ ): void {
2996
+ for (const operation of commit.operations) {
2997
+ if (operation.op !== 'upsert') continue;
2998
+ const table = this.#schema.tables.get(operation.table);
2999
+ if (table === undefined) continue;
3000
+ const row = this.#db.query(
3001
+ `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
3002
+ [operation.rowId],
3003
+ )[0];
3004
+ if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
3005
+ batch.table(table.name);
3006
+ deleteLocalRow(this.#db, table, operation.rowId);
3007
+ }
3008
+ }
3009
+ }
3010
+
3011
+ #rollbackFailedCommit(commit: OutboxCommit, batch: ChangeAccumulator): void {
3012
+ const images = listOutboxBeforeImages(this.#db, commit.clientCommitId);
3013
+ const imageByIndex = new Map(images.map((image) => [image.opIndex, image]));
3014
+ const complete = commit.operations.every((_, index) =>
3015
+ imageByIndex.has(index),
3016
+ );
3017
+ const affectedKeys = new Set(
3018
+ commit.operations.map(
3019
+ (operation) => `${operation.table}\u0000${operation.rowId}`,
3020
+ ),
3021
+ );
3022
+ if (complete) {
3023
+ const restored = new Set<string>();
3024
+ commit.operations.forEach((operation, index) => {
3025
+ const key = `${operation.table}\u0000${operation.rowId}`;
3026
+ if (restored.has(key)) return;
3027
+ const image = imageByIndex.get(index);
3028
+ if (image) this.#restoreBeforeImage(operation, image, batch);
3029
+ restored.add(key);
3030
+ });
3031
+ } else {
3032
+ // Pending commits written by older clients have no before-images.
3033
+ // Preserve the old fail-closed behavior rather than guessing a base.
3034
+ this.#legacyUndoOptimisticRows(commit, batch);
3035
+ }
3036
+ deleteOutboxCommit(this.#db, commit.clientCommitId);
3037
+
3038
+ if (!complete) return;
3039
+ for (const later of listOutbox(this.#db)) {
3040
+ if (later.seq <= commit.seq) continue;
3041
+ const affectedOperations = later.operations.filter((operation) =>
3042
+ affectedKeys.has(`${operation.table}\u0000${operation.rowId}`),
3043
+ );
3044
+ if (affectedOperations.length === 0) continue;
3045
+ replaceOutboxBeforeImages(
3046
+ this.#db,
3047
+ later.clientCommitId,
3048
+ this.#captureBeforeImages(later.operations, affectedKeys),
3049
+ );
3050
+ this.#applyOperationsLocally(affectedOperations, batch);
3051
+ }
3052
+ }
3053
+
2948
3054
  #applyOperationsLocally(
2949
3055
  operations: readonly OutboxOperation[],
2950
3056
  batch?: ChangeAccumulator,
package/src/outbox.ts CHANGED
@@ -43,11 +43,20 @@ export interface OutboxCommit {
43
43
  readonly operations: readonly OutboxOperation[];
44
44
  }
45
45
 
46
+ /** Protected local rollback image; never encoded or exposed in outcomes. */
47
+ export interface OutboxBeforeImage {
48
+ readonly opIndex: number;
49
+ readonly existed: boolean;
50
+ readonly syncVersion?: number;
51
+ readonly values?: Readonly<Record<string, JsonRowValue>>;
52
+ }
53
+
46
54
  export function appendOutboxCommit(
47
55
  db: ClientDatabase,
48
56
  clientCommitId: string,
49
57
  operations: readonly OutboxOperation[],
50
58
  nowMs: number,
59
+ beforeImages: readonly OutboxBeforeImage[] = [],
51
60
  ): void {
52
61
  if (operations.length === 0) {
53
62
  throw new ClientSyncError(
@@ -60,6 +69,20 @@ export function appendOutboxCommit(
60
69
  VALUES (?, ?, ?)`,
61
70
  [clientCommitId, nowMs, JSON.stringify(operations)],
62
71
  );
72
+ for (const image of beforeImages) {
73
+ db.exec(
74
+ `INSERT INTO _syncular_outbox_before_images(
75
+ client_commit_id, op_index, existed, sync_version, values_json
76
+ ) VALUES (?, ?, ?, ?, ?)`,
77
+ [
78
+ clientCommitId,
79
+ image.opIndex,
80
+ image.existed ? 1 : 0,
81
+ image.syncVersion ?? null,
82
+ image.values === undefined ? null : JSON.stringify(image.values),
83
+ ],
84
+ );
85
+ }
63
86
  }
64
87
 
65
88
  /** Pending commits in FIFO creation order (§7.1). */
@@ -81,11 +104,68 @@ export function deleteOutboxCommit(
81
104
  db: ClientDatabase,
82
105
  clientCommitId: string,
83
106
  ): void {
107
+ db.exec(
108
+ 'DELETE FROM _syncular_outbox_before_images WHERE client_commit_id = ?',
109
+ [clientCommitId],
110
+ );
84
111
  db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id = ?', [
85
112
  clientCommitId,
86
113
  ]);
87
114
  }
88
115
 
116
+ export function listOutboxBeforeImages(
117
+ db: ClientDatabase,
118
+ clientCommitId: string,
119
+ ): OutboxBeforeImage[] {
120
+ return db
121
+ .query(
122
+ `SELECT op_index, existed, sync_version, values_json
123
+ FROM _syncular_outbox_before_images
124
+ WHERE client_commit_id = ? ORDER BY op_index`,
125
+ [clientCommitId],
126
+ )
127
+ .map((row) => ({
128
+ opIndex: row.op_index as number,
129
+ existed: row.existed === 1,
130
+ ...(typeof row.sync_version === 'number'
131
+ ? { syncVersion: row.sync_version }
132
+ : {}),
133
+ ...(typeof row.values_json === 'string'
134
+ ? {
135
+ values: JSON.parse(row.values_json) as Readonly<
136
+ Record<string, JsonRowValue>
137
+ >,
138
+ }
139
+ : {}),
140
+ }));
141
+ }
142
+
143
+ export function replaceOutboxBeforeImages(
144
+ db: ClientDatabase,
145
+ clientCommitId: string,
146
+ replacements: readonly OutboxBeforeImage[],
147
+ ): void {
148
+ for (const image of replacements) {
149
+ db.exec(
150
+ `DELETE FROM _syncular_outbox_before_images
151
+ WHERE client_commit_id = ? AND op_index = ?`,
152
+ [clientCommitId, image.opIndex],
153
+ );
154
+ db.exec(
155
+ `INSERT INTO _syncular_outbox_before_images(
156
+ client_commit_id, op_index, existed, sync_version, values_json
157
+ ) VALUES (?, ?, ?, ?, ?)`,
158
+ [
159
+ clientCommitId,
160
+ image.opIndex,
161
+ image.existed ? 1 : 0,
162
+ image.syncVersion ?? null,
163
+ image.values === undefined ? null : JSON.stringify(image.values),
164
+ ],
165
+ );
166
+ }
167
+ }
168
+
89
169
  /**
90
170
  * §7.4.4: after a schema bump, a persisted upsert may name a column the new
91
171
  * schema no longer has. The value has nowhere to go and there is no
package/src/schema.ts CHANGED
@@ -288,6 +288,13 @@ export function ensureLocalSchema(
288
288
  client_commit_id TEXT NOT NULL UNIQUE,
289
289
  created_at_ms INTEGER NOT NULL,
290
290
  operations TEXT NOT NULL)`);
291
+ db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox_before_images(
292
+ client_commit_id TEXT NOT NULL,
293
+ op_index INTEGER NOT NULL,
294
+ existed INTEGER NOT NULL CHECK(existed IN (0, 1)),
295
+ sync_version INTEGER,
296
+ values_json TEXT,
297
+ PRIMARY KEY(client_commit_id, op_index))`);
291
298
  db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
292
299
  seq INTEGER PRIMARY KEY AUTOINCREMENT,
293
300
  client_commit_id TEXT NOT NULL UNIQUE,