@syncular/client 0.20.1 → 0.21.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.20.1",
3
+ "version": "0.21.1",
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",
@@ -89,10 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.20.1"
92
+ "@syncular/core": "0.21.1"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.20.1",
95
+ "@syncular/server": "0.21.1",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
package/src/client.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  type SubStartFrame,
38
38
  type WakeReason,
39
39
  } from '@syncular/core';
40
+ import { EncryptError } from '@syncular/core';
40
41
  import {
41
42
  applyCommitFrame,
42
43
  applyRowsSegment,
@@ -158,6 +159,8 @@ import {
158
159
  ensureLocalBookkeepingSchema,
159
160
  ensureLocalSyncedSchema,
160
161
  fromSqlValue,
162
+ coerceSqlRepresentation,
163
+ type JsonRowValue,
161
164
  jsonToRowValue,
162
165
  LOCAL_SCHEMA_VERSION_KEY,
163
166
  normalizeRecordKeys,
@@ -2127,11 +2130,11 @@ export class SyncClient {
2127
2130
 
2128
2131
  #recordMutations(
2129
2132
  mutations: readonly MutationInput[],
2130
- changedFieldsByIndex: readonly (readonly string[] | undefined)[] = [],
2133
+ partial = false,
2131
2134
  ): string {
2132
2135
  this.#requireActive();
2133
2136
  const clientCommitId = crypto.randomUUID();
2134
- const operations: OutboxOperation[] = mutations.map((mutation, index) => {
2137
+ const operations: OutboxOperation[] = mutations.map((mutation) => {
2135
2138
  const table = this.#table(mutation.table);
2136
2139
  if (mutation.op === 'delete') {
2137
2140
  return {
@@ -2143,6 +2146,55 @@ export class SyncClient {
2143
2146
  : {}),
2144
2147
  };
2145
2148
  }
2149
+ if (partial) {
2150
+ // §6.7 patch: the values map IS the presence set — the primary key
2151
+ // plus the supplied non-scope columns. Absent columns keep their
2152
+ // stored value; the overlay and the server both apply only the
2153
+ // present ones.
2154
+ const normalized = normalizeRecordKeys(table, mutation.values);
2155
+ const scopeColumns = new Set(table.scopeColumnByVariable.values());
2156
+ const json: Record<string, JsonRowValue> = {};
2157
+ for (const column of table.columns) {
2158
+ if (!normalized.has(column.name)) continue;
2159
+ if (scopeColumns.has(column.name)) {
2160
+ throw new ClientSyncError(
2161
+ 'sync.invalid_request',
2162
+ `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
2163
+ );
2164
+ }
2165
+ const value = normalized.get(column.name);
2166
+ if (value === undefined || value === null) {
2167
+ if (!column.nullable) {
2168
+ throw new ClientSyncError(
2169
+ 'sync.invalid_request',
2170
+ `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`,
2171
+ );
2172
+ }
2173
+ json[column.name] = null;
2174
+ continue;
2175
+ }
2176
+ json[column.name] = rowValueToJson(
2177
+ coerceSqlRepresentation(column, value) as RowValue,
2178
+ );
2179
+ }
2180
+ const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
2181
+ const pkValue = json[pkColumn.name];
2182
+ if (typeof pkValue !== 'string' || pkValue.length === 0) {
2183
+ throw new ClientSyncError(
2184
+ 'sync.invalid_request',
2185
+ `table ${table.name}: upsert requires a non-empty string primary key`,
2186
+ );
2187
+ }
2188
+ return {
2189
+ table: mutation.table,
2190
+ rowId: pkValue,
2191
+ op: 'upsert',
2192
+ ...(mutation.baseVersion !== undefined
2193
+ ? { baseVersion: mutation.baseVersion }
2194
+ : {}),
2195
+ values: json,
2196
+ };
2197
+ }
2146
2198
  const values = recordToRowValues(table, mutation.values);
2147
2199
  const pkValue = values[table.primaryKeyIndex];
2148
2200
  if (typeof pkValue !== 'string' || pkValue.length === 0) {
@@ -2163,9 +2215,6 @@ export class SyncClient {
2163
2215
  ? { baseVersion: mutation.baseVersion }
2164
2216
  : {}),
2165
2217
  values: json,
2166
- ...(changedFieldsByIndex[index] !== undefined
2167
- ? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
2168
- : {}),
2169
2218
  };
2170
2219
  });
2171
2220
  this.#applyBatch((batch) => {
@@ -2218,12 +2267,12 @@ export class SyncClient {
2218
2267
  }
2219
2268
 
2220
2269
  /**
2221
- * Partial-update convenience over the §6.1 full-row wire: read the
2222
- * current LOCAL row, merge `partial` over it, and record one full-row
2223
- * upsert through `mutate()`. `partial` keys follow the same two-casing
2224
- * rule as mutation values (snake_case or camelCase). The row must be
2225
- * locally present (subscribed/windowed-in); patching an absent row is
2226
- * an error — there is no base to merge into.
2270
+ * Partial update over the §6.1 sparse wire: record one upsert whose
2271
+ * presence set is the primary key plus the supplied non-scope columns.
2272
+ * `partial` keys follow the same two-casing rule as mutation values
2273
+ * (snake_case or camelCase). Absent columns keep their stored value on
2274
+ * the server and in the local overlay; when the local row is absent the
2275
+ * overlay leaves it absent (§7.1) and the server answers per §5.2.
2227
2276
  */
2228
2277
  patch(
2229
2278
  table: string,
@@ -2234,37 +2283,29 @@ export class SyncClient {
2234
2283
  this.#requireActive();
2235
2284
  const compiled = this.#table(table);
2236
2285
  const pkColumn = compiled.columns[compiled.primaryKeyIndex] as RowColumn;
2237
- const rows = this.#db.query(
2238
- `SELECT * FROM ${quoteIdent(compiled.name)} WHERE ${quoteIdent(pkColumn.name)} = ?`,
2239
- [rowId],
2240
- );
2241
- const row = rows[0];
2242
- if (row === undefined) {
2286
+ const normalized = normalizeRecordKeys(compiled, partial);
2287
+ const pkOverride = normalized.get(pkColumn.name);
2288
+ if (pkOverride !== undefined && pkOverride !== rowId) {
2243
2289
  throw new ClientSyncError(
2244
2290
  'sync.invalid_request',
2245
- `table ${compiled.name}: no local row with primary key ${JSON.stringify(rowId)} to patch`,
2291
+ `table ${compiled.name}: patch cannot change the primary key`,
2246
2292
  );
2247
2293
  }
2248
- const record: Record<string, unknown> = {};
2249
- for (const column of compiled.columns as readonly RowColumn[]) {
2250
- record[column.name] = fromSqlValue(column, row[column.name] ?? null);
2251
- }
2252
- const normalizedPartial = normalizeRecordKeys(compiled, partial);
2253
- for (const [name, value] of normalizedPartial) {
2254
- record[name] = value;
2255
- }
2256
2294
  return this.#recordMutations(
2257
2295
  [
2258
2296
  {
2259
2297
  table,
2260
2298
  op: 'upsert',
2261
- values: record,
2299
+ values: {
2300
+ ...Object.fromEntries(normalized),
2301
+ [pkColumn.name]: rowId,
2302
+ },
2262
2303
  ...(options?.baseVersion !== undefined
2263
2304
  ? { baseVersion: options.baseVersion }
2264
2305
  : {}),
2265
2306
  },
2266
2307
  ],
2267
- [[...normalizedPartial.keys()].sort()],
2308
+ true,
2268
2309
  );
2269
2310
  }
2270
2311
 
@@ -2598,8 +2639,27 @@ export class SyncClient {
2598
2639
  try {
2599
2640
  pushFrames.push(
2600
2641
  // §5.11: encrypted columns are encrypted at this encode-at-send
2601
- // seam before the row codec serializes them.
2602
- await encodeOutboxCommit(this.#schema, commit, this.#encryption),
2642
+ // seam before the row codec serializes them. The stored local row
2643
+ // supplies an ABSENT key-id selector (§6.1 presence set first,
2644
+ // stored row second); an unresolvable key becomes a durable
2645
+ // rejection for that commit, never a sync() throw.
2646
+ await encodeOutboxCommit(
2647
+ this.#schema,
2648
+ commit,
2649
+ this.#encryption,
2650
+ (table, rowId) => {
2651
+ const selector = this.#encryption?.keyIdColumns?.[table.name];
2652
+ if (selector === undefined) return undefined;
2653
+ const row = this.#db.query(
2654
+ `SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2655
+ [rowId],
2656
+ )[0];
2657
+ const value = row?.value;
2658
+ return typeof value === 'string' && value.length > 0
2659
+ ? value
2660
+ : undefined;
2661
+ },
2662
+ ),
2603
2663
  );
2604
2664
  outbox.push(commit);
2605
2665
  ops += commit.operations.length;
@@ -2608,6 +2668,10 @@ export class SyncClient {
2608
2668
  this.#dropIncompatibleCommit(commit, error.message);
2609
2669
  continue;
2610
2670
  }
2671
+ if (error instanceof EncryptError) {
2672
+ this.#dropIncompatibleCommit(commit, error.message, error.code);
2673
+ continue;
2674
+ }
2611
2675
  throw error;
2612
2676
  }
2613
2677
  }
@@ -2618,14 +2682,19 @@ export class SyncClient {
2618
2682
  * §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
2619
2683
  * §7.2 `rejected` surface — the commit leaves the outbox, its
2620
2684
  * purely-optimistic rows are undone, and a rejection record is raised.
2685
+ * §5.11: the same drop carries an encode-seam `client.encrypt_failed`.
2621
2686
  */
2622
- #dropIncompatibleCommit(commit: OutboxCommit, message: string): void {
2687
+ #dropIncompatibleCommit(
2688
+ commit: OutboxCommit,
2689
+ message: string,
2690
+ code: string = OUTBOX_INCOMPATIBLE_CODE,
2691
+ ): void {
2623
2692
  this.#applyBatch((batch) => {
2624
2693
  this.#rollbackFailedCommit(commit, batch);
2625
2694
  const rejection: RejectionRecord = {
2626
2695
  clientCommitId: commit.clientCommitId,
2627
2696
  opIndex: 0,
2628
- code: OUTBOX_INCOMPATIBLE_CODE,
2697
+ code,
2629
2698
  message,
2630
2699
  retryable: false,
2631
2700
  ...(commit.operations[0] !== undefined
@@ -3735,6 +3804,10 @@ export class SyncClient {
3735
3804
  message: result.message,
3736
3805
  serverVersion: result.serverVersion,
3737
3806
  serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
3807
+ conflictColumns: this.#decodeConflictColumns(
3808
+ operation?.table,
3809
+ result.conflictColumns,
3810
+ ),
3738
3811
  ...(operation !== undefined ? { operation } : {}),
3739
3812
  };
3740
3813
  this.#conflicts.push(conflict);
@@ -3804,6 +3877,26 @@ export class SyncClient {
3804
3877
  return record;
3805
3878
  }
3806
3879
 
3880
+ /**
3881
+ * §6.3 `conflictColumns`: decode the presence-layout bitmap into the names
3882
+ * of the present columns whose `column_version` exceeded `baseVersion`. A
3883
+ * custom merge recomputes exactly these columns (§6.5).
3884
+ */
3885
+ #decodeConflictColumns(
3886
+ tableName: string | undefined,
3887
+ bitmap: Uint8Array,
3888
+ ): readonly string[] {
3889
+ if (tableName === undefined) return [];
3890
+ const table = this.#schema.tables.get(tableName);
3891
+ if (table === undefined) return [];
3892
+ const names: string[] = [];
3893
+ table.columns.forEach((column, index) => {
3894
+ const byte = bitmap[index >> 3] ?? 0;
3895
+ if (((byte >> (index & 7)) & 1) === 1) names.push(column.name);
3896
+ });
3897
+ return names;
3898
+ }
3899
+
3807
3900
  async #applyCommit(
3808
3901
  frame: CommitFrame,
3809
3902
  summary: MutableSummary,
@@ -4318,9 +4411,26 @@ export class SyncClient {
4318
4411
  deleteLocalRow(this.#db, table, op.rowId);
4319
4412
  continue;
4320
4413
  }
4414
+ // §7.1 overlay: apply the operation's PRESENT columns over the current
4415
+ // local row. A partial operation over an absent local row leaves it
4416
+ // absent — the server answers it with sync.row_deleted or
4417
+ // sync.row_missing, and the local mirror never invents a base.
4418
+ const opValues = op.values ?? {};
4419
+ const full = table.columns.every((column) => column.name in opValues);
4420
+ const local = this.#db.query(
4421
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
4422
+ [op.rowId],
4423
+ )[0];
4424
+ if (local === undefined && !full) {
4425
+ if (batch !== undefined && !precise) batch.table(op.table);
4426
+ continue;
4427
+ }
4321
4428
  const values = table.columns.map((column) => {
4322
- const value = op.values?.[column.name];
4323
- return value === undefined ? null : jsonToRowValue(value);
4429
+ const value = opValues[column.name];
4430
+ if (value !== undefined) return jsonToRowValue(value);
4431
+ return local === undefined
4432
+ ? null
4433
+ : fromSqlValue(column, local[column.name] ?? null);
4324
4434
  });
4325
4435
  // Record the row's scope keys from its scope columns (I2 refinement).
4326
4436
  if (batch !== undefined) {
@@ -4335,12 +4445,10 @@ export class SyncClient {
4335
4445
  }
4336
4446
  if (!precise) batch.table(table.name);
4337
4447
  }
4338
- const existing = this.#db.query(
4339
- `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
4340
- [op.rowId],
4341
- )[0];
4342
4448
  const version =
4343
- existing === undefined ? OPTIMISTIC_VERSION : (existing.v as number);
4449
+ local === undefined
4450
+ ? OPTIMISTIC_VERSION
4451
+ : (local[SYNC_VERSION_COLUMN] as number);
4344
4452
  upsertLocalRow(this.#db, table, values, version);
4345
4453
  }
4346
4454
  }
package/src/encryption.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type DeclaredType,
13
13
  DecryptError,
14
14
  decryptValue,
15
+ EncryptError,
15
16
  encryptValue,
16
17
  KEY_LENGTH,
17
18
  type NonceSource,
@@ -99,7 +100,7 @@ function encryptionKeyId(
99
100
  if (config.keyIdFor !== undefined) {
100
101
  const selected = config.keyIdFor(table.name, rowId, values);
101
102
  if (selected.length === 0) {
102
- throw new DecryptError(
103
+ throw new EncryptError(
103
104
  `empty encryption key id selected for table ${JSON.stringify(table.name)}`,
104
105
  );
105
106
  }
@@ -111,18 +112,18 @@ function encryptionKeyId(
111
112
  (column) => column.name === selectorColumn,
112
113
  );
113
114
  if (index < 0) {
114
- throw new DecryptError(
115
+ throw new EncryptError(
115
116
  `encryption key-id column ${JSON.stringify(selectorColumn)} is not present on table ${JSON.stringify(table.name)}`,
116
117
  );
117
118
  }
118
119
  if (table.columns[index]?.encrypted === true) {
119
- throw new DecryptError(
120
+ throw new EncryptError(
120
121
  `encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must not be encrypted`,
121
122
  );
122
123
  }
123
124
  const selected = values[index];
124
125
  if (typeof selected !== 'string' || selected.length === 0) {
125
- throw new DecryptError(
126
+ throw new EncryptError(
126
127
  `encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must contain a non-empty string`,
127
128
  );
128
129
  }
@@ -142,15 +143,45 @@ function declaredTypeOf(column: RowColumn): DeclaredType {
142
143
  * value becomes the §5.11 ciphertext-envelope `bytes`. NULLs pass through
143
144
  * unencrypted (§5.11).
144
145
  */
145
- export async function encryptRowValues(
146
+ export async function encryptRowValues<T extends RowValue | undefined>(
146
147
  config: EncryptionConfig,
147
148
  table: CompiledClientTable,
148
149
  rowId: string,
149
- values: readonly RowValue[],
150
- ): Promise<RowValue[]> {
150
+ values: readonly T[],
151
+ storedKeyId?: string,
152
+ ): Promise<T[]> {
151
153
  if (!table.hasEncryptedColumns) return values.slice();
152
154
  const out = values.slice();
153
- const keyId = encryptionKeyId(config, table, rowId, values);
155
+ // Sparse semantics: absent (undefined) and NULL columns are never
156
+ // re-encrypted, so a patch that presents no encrypted value needs no key.
157
+ let hasPresentEncrypted = false;
158
+ for (let i = 0; i < table.columns.length; i++) {
159
+ if (table.columns[i]?.encrypted !== true) continue;
160
+ const value = out[i];
161
+ if (value !== undefined && value !== null) {
162
+ hasPresentEncrypted = true;
163
+ break;
164
+ }
165
+ }
166
+ if (!hasPresentEncrypted) return out;
167
+ // Key selection reads the presence set first, then the stored local row.
168
+ // Absent slots densify to null for selection only; the loop below still
169
+ // leaves them untouched. Absent ≠ NULL (§6.1): only an ABSENT selector
170
+ // slot is filled from the stored row, never a present NULL.
171
+ const selected: RowValue[] = values.map((value) => value ?? null);
172
+ const selectorColumn = config.keyIdColumns?.[table.name];
173
+ if (
174
+ selectorColumn !== undefined &&
175
+ config.keyIdFor === undefined &&
176
+ storedKeyId !== undefined
177
+ ) {
178
+ const index = table.columns.findIndex(
179
+ (column) => column.name === selectorColumn,
180
+ );
181
+ if (index >= 0 && values[index] === undefined)
182
+ selected[index] = storedKeyId;
183
+ }
184
+ const keyId = encryptionKeyId(config, table, rowId, selected);
154
185
  for (let i = 0; i < table.columns.length; i++) {
155
186
  const column = table.columns[i];
156
187
  if (column === undefined || !column.encrypted) continue;
@@ -158,17 +189,17 @@ export async function encryptRowValues(
158
189
  if (value === null || value === undefined) continue; // NULL stays NULL
159
190
  const key = config.keyProvider(keyId);
160
191
  if (key === undefined) {
161
- throw new DecryptError(
192
+ throw new EncryptError(
162
193
  `no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`,
163
194
  );
164
195
  }
165
- out[i] = await encryptValue(
196
+ out[i] = (await encryptValue(
166
197
  declaredTypeOf(column),
167
198
  value as PlainValue,
168
199
  keyId,
169
200
  key,
170
201
  config.nonceSource,
171
- );
202
+ )) as T;
172
203
  }
173
204
  return out;
174
205
  }
package/src/outbox.ts CHANGED
@@ -6,10 +6,11 @@
6
6
  * upgrade to N+1 by re-encoding.
7
7
  */
8
8
  import {
9
- encodeRow,
9
+ encodeSparseRow,
10
10
  type PushCommitFrame,
11
11
  type PushOperation,
12
12
  type ScopeMap,
13
+ type SparseRowValue,
13
14
  } from '@syncular/core';
14
15
  import type { ClientDatabase, SqlRow } from './database';
15
16
  import type { EncryptionConfig } from './encryption';
@@ -27,13 +28,13 @@ export interface OutboxOperation {
27
28
  readonly op: 'upsert' | 'delete';
28
29
  /** Optimistic-concurrency token (§6.2); absent = last-write-wins. */
29
30
  readonly baseVersion?: number;
30
- /** Full-row values keyed by column name; present iff `op` is `upsert`. */
31
- readonly values?: Readonly<Record<string, JsonRowValue>>;
32
31
  /**
33
- * Local-only normalized columns intentionally supplied to `patch()`.
34
- * Absent for full-row mutate/upsert because intent is then unknown.
32
+ * The PRESENT columns keyed by column name; present iff `op` is `upsert`.
33
+ * A key the operation writes carries its value; a column the operation
34
+ * leaves unchanged has no key (§6.1 sparse rows, §7.2.1: intent is the
35
+ * presence set — `mutate` marks every column, `patch` the supplied ones).
35
36
  */
36
- readonly changedFields?: readonly string[];
37
+ readonly values?: Readonly<Record<string, JsonRowValue>>;
37
38
  }
38
39
 
39
40
  export interface OutboxCommit {
@@ -212,10 +213,10 @@ export class OutboxEncodeError extends ClientSyncError {
212
213
  }
213
214
  }
214
215
 
215
- function orderedValues(
216
+ function sparseValues(
216
217
  table: CompiledClientTable,
217
218
  values: Readonly<Record<string, JsonRowValue>>,
218
- ) {
219
+ ): SparseRowValue[] {
219
220
  // Any persisted key that is not a column of the CURRENT schema means the
220
221
  // bump removed (or renamed) it — the commit cannot be expressed now.
221
222
  for (const key of Object.keys(values)) {
@@ -227,8 +228,7 @@ function orderedValues(
227
228
  }
228
229
  return table.columns.map((column) => {
229
230
  const value = values[column.name];
230
- if (value === undefined) return null;
231
- return jsonToRowValue(value);
231
+ return value === undefined ? undefined : jsonToRowValue(value);
232
232
  });
233
233
  }
234
234
 
@@ -237,12 +237,17 @@ function orderedValues(
237
237
  * schema's row codec (§6.1). When `encryption` is configured, encrypted
238
238
  * columns (§5.11) are encrypted here — the encode-at-send seam — before the
239
239
  * row codec serializes them as ciphertext-envelope `bytes`. Async because
240
- * WebCrypto is async.
240
+ * WebCrypto is async. `storedKeyIdFor` supplies the stored local row's
241
+ * key-id selector for a sparse upsert that omits that column (§5.11).
241
242
  */
242
243
  export async function encodeOutboxCommit(
243
244
  schema: CompiledClientSchema,
244
245
  commit: OutboxCommit,
245
246
  encryption?: EncryptionConfig,
247
+ storedKeyIdFor?: (
248
+ table: CompiledClientTable,
249
+ rowId: string,
250
+ ) => string | undefined,
246
251
  ): Promise<PushCommitFrame> {
247
252
  const operations: PushOperation[] = [];
248
253
  for (const op of commit.operations) {
@@ -270,18 +275,24 @@ export async function encodeOutboxCommit(
270
275
  `outbox upsert on ${op.table}/${op.rowId} has no values`,
271
276
  );
272
277
  }
273
- let values = orderedValues(table, op.values);
278
+ let values = sparseValues(table, op.values);
274
279
  if (encryption !== undefined && table.hasEncryptedColumns) {
275
280
  // Lazy: opt-in E2EE never enters an encryption-free app's bundle.
276
281
  const { encryptRowValues } = await import('./encryption');
277
- values = await encryptRowValues(encryption, table, op.rowId, values);
282
+ values = await encryptRowValues(
283
+ encryption,
284
+ table,
285
+ op.rowId,
286
+ values,
287
+ storedKeyIdFor?.(table, op.rowId),
288
+ );
278
289
  }
279
290
  operations.push({
280
291
  table: op.table,
281
292
  rowId: op.rowId,
282
293
  op: 'upsert',
283
294
  ...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
284
- payload: encodeRow(table.columns, values),
295
+ payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
285
296
  });
286
297
  }
287
298
  return {
package/src/outcomes.ts CHANGED
@@ -22,6 +22,12 @@ export interface ConflictRecord {
22
22
  readonly serverVersion: number;
23
23
  /** The current server row, decoded — resolve without a round-trip. */
24
24
  readonly serverRow: Readonly<Record<string, RowValue>>;
25
+ /**
26
+ * §6.3: the present columns whose `column_version` exceeded the losing
27
+ * operation's `baseVersion`. A custom merge recomputes exactly these;
28
+ * keep-server and keep-local ignore them (§6.5).
29
+ */
30
+ readonly conflictColumns: readonly string[];
25
31
  /** The losing local operation (absent only for malformed op indexes). */
26
32
  readonly operation?: OutboxOperation;
27
33
  }
@@ -94,8 +100,13 @@ export interface ResolveCommitOutcomeInput {
94
100
  readonly replacementClientCommitId?: string;
95
101
  }
96
102
 
97
- interface StoredConflictRecord extends Omit<ConflictRecord, 'serverRow'> {
103
+ interface StoredConflictRecord extends Omit<
104
+ ConflictRecord,
105
+ 'serverRow' | 'conflictColumns'
106
+ > {
98
107
  readonly serverRow: Readonly<Record<string, JsonRowValue>>;
108
+ /** Absent in journal entries written before wire version 3. */
109
+ readonly conflictColumns?: readonly string[];
99
110
  }
100
111
 
101
112
  type StoredCommitOperationOutcome =
@@ -130,6 +141,8 @@ function decodeResults(raw: string): CommitOperationOutcome[] {
130
141
  status: 'conflict',
131
142
  conflict: {
132
143
  ...result.conflict,
144
+ // Pre-column-version journal entries carry no conflictColumns.
145
+ conflictColumns: result.conflict.conflictColumns ?? [],
133
146
  serverRow: Object.fromEntries(
134
147
  Object.entries(result.conflict.serverRow).map(([key, value]) => [
135
148
  key,
package/src/remote.ts CHANGED
@@ -9,7 +9,8 @@ import {
9
9
  encodeMessage,
10
10
  encodeRemoteOperationRequest,
11
11
  encodeRemoteOperationRealtimeMessage,
12
- encodeRow,
12
+ encodeSparseRow,
13
+ PROTOCOL_WIRE_VERSION,
13
14
  type PushOperation,
14
15
  type PushOperationResult,
15
16
  type PushResultDetailsFrame,
@@ -204,7 +205,8 @@ export class SyncRemoteClient {
204
205
  }
205
206
  >();
206
207
  readonly #encryption: EncryptionConfig | undefined;
207
- readonly #logEpoch: string | undefined;
208
+ /** Pinned by config, or acquired on the first prepared commit (§2.1). */
209
+ #logEpoch: string | undefined;
208
210
 
209
211
  constructor(config: SyncRemoteClientConfig) {
210
212
  if (config.clientId.length === 0) {
@@ -242,6 +244,53 @@ export class SyncRemoteClient {
242
244
  'a remote commit must carry at least one mutation (§6.1)',
243
245
  );
244
246
  }
247
+ let logEpoch = this.#logEpoch;
248
+ if (logEpoch === undefined) {
249
+ // §2.1: a push round binds to the partition log epoch; an acquisition
250
+ // round carries no push commits.
251
+ if (this.#transport === undefined) {
252
+ throw new ClientSyncError(
253
+ 'client.remote_sync_unconfigured',
254
+ 'SyncRemoteClient has no sync transport for ordinary commits',
255
+ );
256
+ }
257
+ const acquisition = decodeMessage(
258
+ await this.#transport(
259
+ encodeMessage({
260
+ wireVersion: PROTOCOL_WIRE_VERSION,
261
+ msgKind: 'request',
262
+ frames: [
263
+ {
264
+ type: 'REQ_HEADER',
265
+ clientId: this.#clientId,
266
+ schemaVersion: schema.version,
267
+ },
268
+ // A pull that asks for nothing: the round exists for its epoch.
269
+ {
270
+ type: 'PULL_HEADER',
271
+ limitCommits: 0,
272
+ limitSnapshotRows: 0,
273
+ maxSnapshotPages: 0,
274
+ accept: 0b0011,
275
+ },
276
+ ],
277
+ }),
278
+ ),
279
+ );
280
+ const acquisitionHeader = acquisition.frames[0];
281
+ if (
282
+ acquisition.msgKind !== 'response' ||
283
+ acquisitionHeader?.type !== 'RESP_HEADER' ||
284
+ acquisitionHeader.logEpoch === undefined
285
+ ) {
286
+ throw new ClientSyncError(
287
+ 'client.invalid_host_response',
288
+ 'remote epoch acquisition returned an invalid response',
289
+ );
290
+ }
291
+ logEpoch = acquisitionHeader.logEpoch;
292
+ this.#logEpoch = logEpoch;
293
+ }
245
294
  const operations: PushOperation[] = [];
246
295
  for (const mutation of input.mutations) {
247
296
  const table = schema.tables.get(mutation.table);
@@ -277,22 +326,20 @@ export class SyncRemoteClient {
277
326
  ...(mutation.baseVersion !== undefined
278
327
  ? { baseVersion: mutation.baseVersion }
279
328
  : {}),
280
- payload: encodeRow(table.columns, values),
329
+ payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
281
330
  });
282
331
  }
283
332
  return {
284
333
  requestId: input.requestId,
285
334
  bytes: encodeMessage({
286
- wireVersion: this.#logEpoch === undefined ? 1 : 2,
335
+ wireVersion: PROTOCOL_WIRE_VERSION,
287
336
  msgKind: 'request',
288
337
  frames: [
289
338
  {
290
339
  type: 'REQ_HEADER',
291
340
  clientId: this.#clientId,
292
341
  schemaVersion: schema.version,
293
- ...(this.#logEpoch !== undefined
294
- ? { logEpoch: this.#logEpoch }
295
- : {}),
342
+ logEpoch,
296
343
  },
297
344
  {
298
345
  type: 'PUSH_COMMIT',