@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/README.md CHANGED
@@ -321,10 +321,10 @@ they are never encoded on the wire or exposed through pending commits, outcome
321
321
  envelopes, diagnostics, preferences, or telemetry.
322
322
 
323
323
  Use `patch(table, rowId, partial, { baseVersion? })` for editor-style partial
324
- updates. The wire still carries a full row, but the durable local operation
325
- records a sorted `changedFields` list so conflict and rejection UI knows which
326
- fields the user intended to touch. That intent is local-only and never enters
327
- `PUSH_COMMIT`; full-row `mutate` operations omit it.
324
+ updates. The wire carries a sparse operation: the primary key plus the supplied
325
+ columns are present, and the durable local operation keeps the same presence
326
+ set so conflict and rejection UI knows which columns the user touched. A
327
+ full-row `mutate` marks every column present.
328
328
 
329
329
  ## Application-authorized local security purge
330
330
 
package/dist/client.d.ts CHANGED
@@ -415,12 +415,12 @@ export declare class SyncClient {
415
415
  /** Host-facing mutation result with explicit network work intent (§7.5). */
416
416
  mutateCommand(mutations: readonly MutationInput[]): CommandResult<string>;
417
417
  /**
418
- * Partial-update convenience over the §6.1 full-row wire: read the
419
- * current LOCAL row, merge `partial` over it, and record one full-row
420
- * upsert through `mutate()`. `partial` keys follow the same two-casing
421
- * rule as mutation values (snake_case or camelCase). The row must be
422
- * locally present (subscribed/windowed-in); patching an absent row is
423
- * an error — there is no base to merge into.
418
+ * Partial update over the §6.1 sparse wire: record one upsert whose
419
+ * presence set is the primary key plus the supplied non-scope columns.
420
+ * `partial` keys follow the same two-casing rule as mutation values
421
+ * (snake_case or camelCase). Absent columns keep their stored value on
422
+ * the server and in the local overlay; when the local row is absent the
423
+ * overlay leaves it absent (§7.1) and the server answers per §5.2.
424
424
  */
425
425
  patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
426
426
  readonly baseVersion?: number;
package/dist/client.js CHANGED
@@ -9,6 +9,7 @@ import { ProgressEmitter, } from './progress.js';
9
9
  * `sync()` round (§7.2); local reads go straight to the database.
10
10
  */
11
11
  import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
12
+ import { EncryptError } from '@syncular/core';
12
13
  import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
13
14
  import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
14
15
  import { registerDevtools } from './devtools.js';
@@ -22,7 +23,7 @@ import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, }
22
23
  import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, iterateOutbox, countOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
23
24
  import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
24
25
  import { assertReadOnlyQuery } from './query-guard.js';
25
- import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
26
+ import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
26
27
  import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
27
28
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
28
29
  /** Stable client-local error while protected operations are preflight-gated. */
@@ -1390,10 +1391,10 @@ export class SyncClient {
1390
1391
  mutate(mutations) {
1391
1392
  return this.#recordMutations(mutations);
1392
1393
  }
1393
- #recordMutations(mutations, changedFieldsByIndex = []) {
1394
+ #recordMutations(mutations, partial = false) {
1394
1395
  this.#requireActive();
1395
1396
  const clientCommitId = crypto.randomUUID();
1396
- const operations = mutations.map((mutation, index) => {
1397
+ const operations = mutations.map((mutation) => {
1397
1398
  const table = this.#table(mutation.table);
1398
1399
  if (mutation.op === 'delete') {
1399
1400
  return {
@@ -1405,6 +1406,45 @@ export class SyncClient {
1405
1406
  : {}),
1406
1407
  };
1407
1408
  }
1409
+ if (partial) {
1410
+ // §6.7 patch: the values map IS the presence set — the primary key
1411
+ // plus the supplied non-scope columns. Absent columns keep their
1412
+ // stored value; the overlay and the server both apply only the
1413
+ // present ones.
1414
+ const normalized = normalizeRecordKeys(table, mutation.values);
1415
+ const scopeColumns = new Set(table.scopeColumnByVariable.values());
1416
+ const json = {};
1417
+ for (const column of table.columns) {
1418
+ if (!normalized.has(column.name))
1419
+ continue;
1420
+ if (scopeColumns.has(column.name)) {
1421
+ throw new ClientSyncError('sync.invalid_request', `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
1422
+ }
1423
+ const value = normalized.get(column.name);
1424
+ if (value === undefined || value === null) {
1425
+ if (!column.nullable) {
1426
+ throw new ClientSyncError('sync.invalid_request', `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`);
1427
+ }
1428
+ json[column.name] = null;
1429
+ continue;
1430
+ }
1431
+ json[column.name] = rowValueToJson(coerceSqlRepresentation(column, value));
1432
+ }
1433
+ const pkColumn = table.columns[table.primaryKeyIndex];
1434
+ const pkValue = json[pkColumn.name];
1435
+ if (typeof pkValue !== 'string' || pkValue.length === 0) {
1436
+ throw new ClientSyncError('sync.invalid_request', `table ${table.name}: upsert requires a non-empty string primary key`);
1437
+ }
1438
+ return {
1439
+ table: mutation.table,
1440
+ rowId: pkValue,
1441
+ op: 'upsert',
1442
+ ...(mutation.baseVersion !== undefined
1443
+ ? { baseVersion: mutation.baseVersion }
1444
+ : {}),
1445
+ values: json,
1446
+ };
1447
+ }
1408
1448
  const values = recordToRowValues(table, mutation.values);
1409
1449
  const pkValue = values[table.primaryKeyIndex];
1410
1450
  if (typeof pkValue !== 'string' || pkValue.length === 0) {
@@ -1422,9 +1462,6 @@ export class SyncClient {
1422
1462
  ? { baseVersion: mutation.baseVersion }
1423
1463
  : {}),
1424
1464
  values: json,
1425
- ...(changedFieldsByIndex[index] !== undefined
1426
- ? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
1427
- : {}),
1428
1465
  };
1429
1466
  });
1430
1467
  this.#applyBatch((batch) => {
@@ -1471,40 +1508,35 @@ export class SyncClient {
1471
1508
  };
1472
1509
  }
1473
1510
  /**
1474
- * Partial-update convenience over the §6.1 full-row wire: read the
1475
- * current LOCAL row, merge `partial` over it, and record one full-row
1476
- * upsert through `mutate()`. `partial` keys follow the same two-casing
1477
- * rule as mutation values (snake_case or camelCase). The row must be
1478
- * locally present (subscribed/windowed-in); patching an absent row is
1479
- * an error — there is no base to merge into.
1511
+ * Partial update over the §6.1 sparse wire: record one upsert whose
1512
+ * presence set is the primary key plus the supplied non-scope columns.
1513
+ * `partial` keys follow the same two-casing rule as mutation values
1514
+ * (snake_case or camelCase). Absent columns keep their stored value on
1515
+ * the server and in the local overlay; when the local row is absent the
1516
+ * overlay leaves it absent (§7.1) and the server answers per §5.2.
1480
1517
  */
1481
1518
  patch(table, rowId, partial, options) {
1482
1519
  this.#requireActive();
1483
1520
  const compiled = this.#table(table);
1484
1521
  const pkColumn = compiled.columns[compiled.primaryKeyIndex];
1485
- const rows = this.#db.query(`SELECT * FROM ${quoteIdent(compiled.name)} WHERE ${quoteIdent(pkColumn.name)} = ?`, [rowId]);
1486
- const row = rows[0];
1487
- if (row === undefined) {
1488
- throw new ClientSyncError('sync.invalid_request', `table ${compiled.name}: no local row with primary key ${JSON.stringify(rowId)} to patch`);
1489
- }
1490
- const record = {};
1491
- for (const column of compiled.columns) {
1492
- record[column.name] = fromSqlValue(column, row[column.name] ?? null);
1493
- }
1494
- const normalizedPartial = normalizeRecordKeys(compiled, partial);
1495
- for (const [name, value] of normalizedPartial) {
1496
- record[name] = value;
1522
+ const normalized = normalizeRecordKeys(compiled, partial);
1523
+ const pkOverride = normalized.get(pkColumn.name);
1524
+ if (pkOverride !== undefined && pkOverride !== rowId) {
1525
+ throw new ClientSyncError('sync.invalid_request', `table ${compiled.name}: patch cannot change the primary key`);
1497
1526
  }
1498
1527
  return this.#recordMutations([
1499
1528
  {
1500
1529
  table,
1501
1530
  op: 'upsert',
1502
- values: record,
1531
+ values: {
1532
+ ...Object.fromEntries(normalized),
1533
+ [pkColumn.name]: rowId,
1534
+ },
1503
1535
  ...(options?.baseVersion !== undefined
1504
1536
  ? { baseVersion: options.baseVersion }
1505
1537
  : {}),
1506
1538
  },
1507
- ], [[...normalizedPartial.keys()].sort()]);
1539
+ ], true);
1508
1540
  }
1509
1541
  /**
1510
1542
  * Apply one host-authorized local security purge. The host must stop/gate
@@ -1776,8 +1808,20 @@ export class SyncClient {
1776
1808
  try {
1777
1809
  pushFrames.push(
1778
1810
  // §5.11: encrypted columns are encrypted at this encode-at-send
1779
- // seam before the row codec serializes them.
1780
- await encodeOutboxCommit(this.#schema, commit, this.#encryption));
1811
+ // seam before the row codec serializes them. The stored local row
1812
+ // supplies an ABSENT key-id selector (§6.1 presence set first,
1813
+ // stored row second); an unresolvable key becomes a durable
1814
+ // rejection for that commit, never a sync() throw.
1815
+ await encodeOutboxCommit(this.#schema, commit, this.#encryption, (table, rowId) => {
1816
+ const selector = this.#encryption?.keyIdColumns?.[table.name];
1817
+ if (selector === undefined)
1818
+ return undefined;
1819
+ const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId])[0];
1820
+ const value = row?.value;
1821
+ return typeof value === 'string' && value.length > 0
1822
+ ? value
1823
+ : undefined;
1824
+ }));
1781
1825
  outbox.push(commit);
1782
1826
  ops += commit.operations.length;
1783
1827
  }
@@ -1786,6 +1830,10 @@ export class SyncClient {
1786
1830
  this.#dropIncompatibleCommit(commit, error.message);
1787
1831
  continue;
1788
1832
  }
1833
+ if (error instanceof EncryptError) {
1834
+ this.#dropIncompatibleCommit(commit, error.message, error.code);
1835
+ continue;
1836
+ }
1789
1837
  throw error;
1790
1838
  }
1791
1839
  }
@@ -1795,14 +1843,15 @@ export class SyncClient {
1795
1843
  * §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
1796
1844
  * §7.2 `rejected` surface — the commit leaves the outbox, its
1797
1845
  * purely-optimistic rows are undone, and a rejection record is raised.
1846
+ * §5.11: the same drop carries an encode-seam `client.encrypt_failed`.
1798
1847
  */
1799
- #dropIncompatibleCommit(commit, message) {
1848
+ #dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE) {
1800
1849
  this.#applyBatch((batch) => {
1801
1850
  this.#rollbackFailedCommit(commit, batch);
1802
1851
  const rejection = {
1803
1852
  clientCommitId: commit.clientCommitId,
1804
1853
  opIndex: 0,
1805
- code: OUTBOX_INCOMPATIBLE_CODE,
1854
+ code,
1806
1855
  message,
1807
1856
  retryable: false,
1808
1857
  ...(commit.operations[0] !== undefined
@@ -2688,6 +2737,7 @@ export class SyncClient {
2688
2737
  message: result.message,
2689
2738
  serverVersion: result.serverVersion,
2690
2739
  serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
2740
+ conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
2691
2741
  ...(operation !== undefined ? { operation } : {}),
2692
2742
  };
2693
2743
  this.#conflicts.push(conflict);
@@ -2750,6 +2800,25 @@ export class SyncClient {
2750
2800
  });
2751
2801
  return record;
2752
2802
  }
2803
+ /**
2804
+ * §6.3 `conflictColumns`: decode the presence-layout bitmap into the names
2805
+ * of the present columns whose `column_version` exceeded `baseVersion`. A
2806
+ * custom merge recomputes exactly these columns (§6.5).
2807
+ */
2808
+ #decodeConflictColumns(tableName, bitmap) {
2809
+ if (tableName === undefined)
2810
+ return [];
2811
+ const table = this.#schema.tables.get(tableName);
2812
+ if (table === undefined)
2813
+ return [];
2814
+ const names = [];
2815
+ table.columns.forEach((column, index) => {
2816
+ const byte = bitmap[index >> 3] ?? 0;
2817
+ if (((byte >> (index & 7)) & 1) === 1)
2818
+ names.push(column.name);
2819
+ });
2820
+ return names;
2821
+ }
2753
2822
  async #applyCommit(frame, summary) {
2754
2823
  await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
2755
2824
  this.#recordCommitChanges(batch, frame);
@@ -3146,9 +3215,25 @@ export class SyncClient {
3146
3215
  deleteLocalRow(this.#db, table, op.rowId);
3147
3216
  continue;
3148
3217
  }
3218
+ // §7.1 overlay: apply the operation's PRESENT columns over the current
3219
+ // local row. A partial operation over an absent local row leaves it
3220
+ // absent — the server answers it with sync.row_deleted or
3221
+ // sync.row_missing, and the local mirror never invents a base.
3222
+ const opValues = op.values ?? {};
3223
+ const full = table.columns.every((column) => column.name in opValues);
3224
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [op.rowId])[0];
3225
+ if (local === undefined && !full) {
3226
+ if (batch !== undefined && !precise)
3227
+ batch.table(op.table);
3228
+ continue;
3229
+ }
3149
3230
  const values = table.columns.map((column) => {
3150
- const value = op.values?.[column.name];
3151
- return value === undefined ? null : jsonToRowValue(value);
3231
+ const value = opValues[column.name];
3232
+ if (value !== undefined)
3233
+ return jsonToRowValue(value);
3234
+ return local === undefined
3235
+ ? null
3236
+ : fromSqlValue(column, local[column.name] ?? null);
3152
3237
  });
3153
3238
  // Record the row's scope keys from its scope columns (I2 refinement).
3154
3239
  if (batch !== undefined) {
@@ -3164,8 +3249,9 @@ export class SyncClient {
3164
3249
  if (!precise)
3165
3250
  batch.table(table.name);
3166
3251
  }
3167
- const existing = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [op.rowId])[0];
3168
- const version = existing === undefined ? OPTIMISTIC_VERSION : existing.v;
3252
+ const version = local === undefined
3253
+ ? OPTIMISTIC_VERSION
3254
+ : local[SYNC_VERSION_COLUMN];
3169
3255
  upsertLocalRow(this.#db, table, values, version);
3170
3256
  }
3171
3257
  }
@@ -57,7 +57,7 @@ export declare function encryptionConfigFromKeyring(keyring: EncryptionKeyringCo
57
57
  * value becomes the §5.11 ciphertext-envelope `bytes`. NULLs pass through
58
58
  * unencrypted (§5.11).
59
59
  */
60
- export declare function encryptRowValues(config: EncryptionConfig, table: CompiledClientTable, rowId: string, values: readonly RowValue[]): Promise<RowValue[]>;
60
+ export declare function encryptRowValues<T extends RowValue | undefined>(config: EncryptionConfig, table: CompiledClientTable, rowId: string, values: readonly T[], storedKeyId?: string): Promise<T[]>;
61
61
  /**
62
62
  * Decrypt the encrypted columns of a decoded positional row value array
63
63
  * (returns a new array). Called at the apply seam (§4.5 COMMIT, §5.6 rows
@@ -8,7 +8,7 @@
8
8
  * bridge between the positional `RowValue[]` and the `@syncular/core` §5.11
9
9
  * envelope primitives.
10
10
  */
11
- import { DecryptError, decryptValue, encryptValue, KEY_LENGTH, } from '@syncular/core';
11
+ import { DecryptError, decryptValue, EncryptError, encryptValue, KEY_LENGTH, } from '@syncular/core';
12
12
  import { ClientSyncError } from './errors.js';
13
13
  /**
14
14
  * Convert a portable keyring into the direct-client encryption contract.
@@ -36,7 +36,7 @@ function encryptionKeyId(config, table, rowId, values) {
36
36
  if (config.keyIdFor !== undefined) {
37
37
  const selected = config.keyIdFor(table.name, rowId, values);
38
38
  if (selected.length === 0) {
39
- throw new DecryptError(`empty encryption key id selected for table ${JSON.stringify(table.name)}`);
39
+ throw new EncryptError(`empty encryption key id selected for table ${JSON.stringify(table.name)}`);
40
40
  }
41
41
  return selected;
42
42
  }
@@ -45,14 +45,14 @@ function encryptionKeyId(config, table, rowId, values) {
45
45
  return table.name;
46
46
  const index = table.columns.findIndex((column) => column.name === selectorColumn);
47
47
  if (index < 0) {
48
- throw new DecryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} is not present on table ${JSON.stringify(table.name)}`);
48
+ throw new EncryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} is not present on table ${JSON.stringify(table.name)}`);
49
49
  }
50
50
  if (table.columns[index]?.encrypted === true) {
51
- throw new DecryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must not be encrypted`);
51
+ throw new EncryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must not be encrypted`);
52
52
  }
53
53
  const selected = values[index];
54
54
  if (typeof selected !== 'string' || selected.length === 0) {
55
- throw new DecryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must contain a non-empty string`);
55
+ throw new EncryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must contain a non-empty string`);
56
56
  }
57
57
  return selected;
58
58
  }
@@ -68,11 +68,38 @@ function declaredTypeOf(column) {
68
68
  * value becomes the §5.11 ciphertext-envelope `bytes`. NULLs pass through
69
69
  * unencrypted (§5.11).
70
70
  */
71
- export async function encryptRowValues(config, table, rowId, values) {
71
+ export async function encryptRowValues(config, table, rowId, values, storedKeyId) {
72
72
  if (!table.hasEncryptedColumns)
73
73
  return values.slice();
74
74
  const out = values.slice();
75
- const keyId = encryptionKeyId(config, table, rowId, values);
75
+ // Sparse semantics: absent (undefined) and NULL columns are never
76
+ // re-encrypted, so a patch that presents no encrypted value needs no key.
77
+ let hasPresentEncrypted = false;
78
+ for (let i = 0; i < table.columns.length; i++) {
79
+ if (table.columns[i]?.encrypted !== true)
80
+ continue;
81
+ const value = out[i];
82
+ if (value !== undefined && value !== null) {
83
+ hasPresentEncrypted = true;
84
+ break;
85
+ }
86
+ }
87
+ if (!hasPresentEncrypted)
88
+ return out;
89
+ // Key selection reads the presence set first, then the stored local row.
90
+ // Absent slots densify to null for selection only; the loop below still
91
+ // leaves them untouched. Absent ≠ NULL (§6.1): only an ABSENT selector
92
+ // slot is filled from the stored row, never a present NULL.
93
+ const selected = values.map((value) => value ?? null);
94
+ const selectorColumn = config.keyIdColumns?.[table.name];
95
+ if (selectorColumn !== undefined &&
96
+ config.keyIdFor === undefined &&
97
+ storedKeyId !== undefined) {
98
+ const index = table.columns.findIndex((column) => column.name === selectorColumn);
99
+ if (index >= 0 && values[index] === undefined)
100
+ selected[index] = storedKeyId;
101
+ }
102
+ const keyId = encryptionKeyId(config, table, rowId, selected);
76
103
  for (let i = 0; i < table.columns.length; i++) {
77
104
  const column = table.columns[i];
78
105
  if (column === undefined || !column.encrypted)
@@ -82,9 +109,9 @@ export async function encryptRowValues(config, table, rowId, values) {
82
109
  continue; // NULL stays NULL
83
110
  const key = config.keyProvider(keyId);
84
111
  if (key === undefined) {
85
- throw new DecryptError(`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`);
112
+ throw new EncryptError(`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`);
86
113
  }
87
- out[i] = await encryptValue(declaredTypeOf(column), value, keyId, key, config.nonceSource);
114
+ out[i] = (await encryptValue(declaredTypeOf(column), value, keyId, key, config.nonceSource));
88
115
  }
89
116
  return out;
90
117
  }
package/dist/outbox.d.ts CHANGED
@@ -16,13 +16,13 @@ export interface OutboxOperation {
16
16
  readonly op: 'upsert' | 'delete';
17
17
  /** Optimistic-concurrency token (§6.2); absent = last-write-wins. */
18
18
  readonly baseVersion?: number;
19
- /** Full-row values keyed by column name; present iff `op` is `upsert`. */
20
- readonly values?: Readonly<Record<string, JsonRowValue>>;
21
19
  /**
22
- * Local-only normalized columns intentionally supplied to `patch()`.
23
- * Absent for full-row mutate/upsert because intent is then unknown.
20
+ * The PRESENT columns keyed by column name; present iff `op` is `upsert`.
21
+ * A key the operation writes carries its value; a column the operation
22
+ * leaves unchanged has no key (§6.1 sparse rows, §7.2.1: intent is the
23
+ * presence set — `mutate` marks every column, `patch` the supplied ones).
24
24
  */
25
- readonly changedFields?: readonly string[];
25
+ readonly values?: Readonly<Record<string, JsonRowValue>>;
26
26
  }
27
27
  export interface OutboxCommit {
28
28
  readonly seq: number;
@@ -61,9 +61,10 @@ export declare class OutboxEncodeError extends ClientSyncError {
61
61
  * schema's row codec (§6.1). When `encryption` is configured, encrypted
62
62
  * columns (§5.11) are encrypted here — the encode-at-send seam — before the
63
63
  * row codec serializes them as ciphertext-envelope `bytes`. Async because
64
- * WebCrypto is async.
64
+ * WebCrypto is async. `storedKeyIdFor` supplies the stored local row's
65
+ * key-id selector for a sparse upsert that omits that column (§5.11).
65
66
  */
66
- export declare function encodeOutboxCommit(schema: CompiledClientSchema, commit: OutboxCommit, encryption?: EncryptionConfig): Promise<PushCommitFrame>;
67
+ export declare function encodeOutboxCommit(schema: CompiledClientSchema, commit: OutboxCommit, encryption?: EncryptionConfig, storedKeyIdFor?: (table: CompiledClientTable, rowId: string) => string | undefined): Promise<PushCommitFrame>;
67
68
  /**
68
69
  * §3.3: drop pending commits that write into a revoked scope instead of
69
70
  * replaying them into guaranteed rejections. A whole commit is dropped when
package/dist/outbox.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * request is built — a commit recorded under schema N replays after an
6
6
  * upgrade to N+1 by re-encoding.
7
7
  */
8
- import { encodeRow, } from '@syncular/core';
8
+ import { encodeSparseRow, } from '@syncular/core';
9
9
  import { ClientSyncError } from './errors.js';
10
10
  import { jsonToRowValue, } from './schema.js';
11
11
  export function appendOutboxCommit(db, clientCommitId, operations, nowMs, beforeImages = []) {
@@ -114,7 +114,7 @@ export class OutboxEncodeError extends ClientSyncError {
114
114
  super('sync.outbox_incompatible', message, false);
115
115
  }
116
116
  }
117
- function orderedValues(table, values) {
117
+ function sparseValues(table, values) {
118
118
  // Any persisted key that is not a column of the CURRENT schema means the
119
119
  // bump removed (or renamed) it — the commit cannot be expressed now.
120
120
  for (const key of Object.keys(values)) {
@@ -124,9 +124,7 @@ function orderedValues(table, values) {
124
124
  }
125
125
  return table.columns.map((column) => {
126
126
  const value = values[column.name];
127
- if (value === undefined)
128
- return null;
129
- return jsonToRowValue(value);
127
+ return value === undefined ? undefined : jsonToRowValue(value);
130
128
  });
131
129
  }
132
130
  /**
@@ -134,9 +132,10 @@ function orderedValues(table, values) {
134
132
  * schema's row codec (§6.1). When `encryption` is configured, encrypted
135
133
  * columns (§5.11) are encrypted here — the encode-at-send seam — before the
136
134
  * row codec serializes them as ciphertext-envelope `bytes`. Async because
137
- * WebCrypto is async.
135
+ * WebCrypto is async. `storedKeyIdFor` supplies the stored local row's
136
+ * key-id selector for a sparse upsert that omits that column (§5.11).
138
137
  */
139
- export async function encodeOutboxCommit(schema, commit, encryption) {
138
+ export async function encodeOutboxCommit(schema, commit, encryption, storedKeyIdFor) {
140
139
  const operations = [];
141
140
  for (const op of commit.operations) {
142
141
  if (op.op === 'delete') {
@@ -158,18 +157,18 @@ export async function encodeOutboxCommit(schema, commit, encryption) {
158
157
  if (op.values === undefined) {
159
158
  throw new ClientSyncError('sync.invalid_request', `outbox upsert on ${op.table}/${op.rowId} has no values`);
160
159
  }
161
- let values = orderedValues(table, op.values);
160
+ let values = sparseValues(table, op.values);
162
161
  if (encryption !== undefined && table.hasEncryptedColumns) {
163
162
  // Lazy: opt-in E2EE never enters an encryption-free app's bundle.
164
163
  const { encryptRowValues } = await import('./encryption.js');
165
- values = await encryptRowValues(encryption, table, op.rowId, values);
164
+ values = await encryptRowValues(encryption, table, op.rowId, values, storedKeyIdFor?.(table, op.rowId));
166
165
  }
167
166
  operations.push({
168
167
  table: op.table,
169
168
  rowId: op.rowId,
170
169
  op: 'upsert',
171
170
  ...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
172
- payload: encodeRow(table.columns, values),
171
+ payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
173
172
  });
174
173
  }
175
174
  return {
@@ -19,6 +19,12 @@ export interface ConflictRecord {
19
19
  readonly serverVersion: number;
20
20
  /** The current server row, decoded — resolve without a round-trip. */
21
21
  readonly serverRow: Readonly<Record<string, RowValue>>;
22
+ /**
23
+ * §6.3: the present columns whose `column_version` exceeded the losing
24
+ * operation's `baseVersion`. A custom merge recomputes exactly these;
25
+ * keep-server and keep-local ignore them (§6.5).
26
+ */
27
+ readonly conflictColumns: readonly string[];
22
28
  /** The losing local operation (absent only for malformed op indexes). */
23
29
  readonly operation?: OutboxOperation;
24
30
  }
package/dist/outcomes.js CHANGED
@@ -26,6 +26,8 @@ function decodeResults(raw) {
26
26
  status: 'conflict',
27
27
  conflict: {
28
28
  ...result.conflict,
29
+ // Pre-column-version journal entries carry no conflictColumns.
30
+ conflictColumns: result.conflict.conflictColumns ?? [],
29
31
  serverRow: Object.fromEntries(Object.entries(result.conflict.serverRow).map(([key, value]) => [
30
32
  key,
31
33
  jsonToRowValue(value),
package/dist/remote.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Database-less SSP2 producer (§6.10). It prepares and sends ordinary commits
3
3
  * through the existing push path without creating a local replica or outbox.
4
4
  */
5
- import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeRow, } from '@syncular/core';
5
+ import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
6
6
  import { encryptRowValues } from './encryption.js';
7
7
  import { ClientSyncError } from './errors.js';
8
8
  import { compileClientSchema, recordToRowValues, } from './schema.js';
@@ -71,6 +71,7 @@ export class SyncRemoteClient {
71
71
  #operationSocketGeneration = 0;
72
72
  #watches = new Map();
73
73
  #encryption;
74
+ /** Pinned by config, or acquired on the first prepared commit (§2.1). */
74
75
  #logEpoch;
75
76
  constructor(config) {
76
77
  if (config.clientId.length === 0) {
@@ -101,6 +102,41 @@ export class SyncRemoteClient {
101
102
  if (input.mutations.length === 0) {
102
103
  throw new ClientSyncError('sync.empty_commit', 'a remote commit must carry at least one mutation (§6.1)');
103
104
  }
105
+ let logEpoch = this.#logEpoch;
106
+ if (logEpoch === undefined) {
107
+ // §2.1: a push round binds to the partition log epoch; an acquisition
108
+ // round carries no push commits.
109
+ if (this.#transport === undefined) {
110
+ throw new ClientSyncError('client.remote_sync_unconfigured', 'SyncRemoteClient has no sync transport for ordinary commits');
111
+ }
112
+ const acquisition = decodeMessage(await this.#transport(encodeMessage({
113
+ wireVersion: PROTOCOL_WIRE_VERSION,
114
+ msgKind: 'request',
115
+ frames: [
116
+ {
117
+ type: 'REQ_HEADER',
118
+ clientId: this.#clientId,
119
+ schemaVersion: schema.version,
120
+ },
121
+ // A pull that asks for nothing: the round exists for its epoch.
122
+ {
123
+ type: 'PULL_HEADER',
124
+ limitCommits: 0,
125
+ limitSnapshotRows: 0,
126
+ maxSnapshotPages: 0,
127
+ accept: 0b0011,
128
+ },
129
+ ],
130
+ })));
131
+ const acquisitionHeader = acquisition.frames[0];
132
+ if (acquisition.msgKind !== 'response' ||
133
+ acquisitionHeader?.type !== 'RESP_HEADER' ||
134
+ acquisitionHeader.logEpoch === undefined) {
135
+ throw new ClientSyncError('client.invalid_host_response', 'remote epoch acquisition returned an invalid response');
136
+ }
137
+ logEpoch = acquisitionHeader.logEpoch;
138
+ this.#logEpoch = logEpoch;
139
+ }
104
140
  const operations = [];
105
141
  for (const mutation of input.mutations) {
106
142
  const table = schema.tables.get(mutation.table);
@@ -136,22 +172,20 @@ export class SyncRemoteClient {
136
172
  ...(mutation.baseVersion !== undefined
137
173
  ? { baseVersion: mutation.baseVersion }
138
174
  : {}),
139
- payload: encodeRow(table.columns, values),
175
+ payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
140
176
  });
141
177
  }
142
178
  return {
143
179
  requestId: input.requestId,
144
180
  bytes: encodeMessage({
145
- wireVersion: this.#logEpoch === undefined ? 1 : 2,
181
+ wireVersion: PROTOCOL_WIRE_VERSION,
146
182
  msgKind: 'request',
147
183
  frames: [
148
184
  {
149
185
  type: 'REQ_HEADER',
150
186
  clientId: this.#clientId,
151
187
  schemaVersion: schema.version,
152
- ...(this.#logEpoch !== undefined
153
- ? { logEpoch: this.#logEpoch }
154
- : {}),
188
+ logEpoch,
155
189
  },
156
190
  {
157
191
  type: 'PUSH_COMMIT',
package/dist/schema.d.ts CHANGED
@@ -138,6 +138,14 @@ export declare function fromSqlValue(column: RowColumn, value: SqlValue): RowVal
138
138
  * reserved `_sync_*` names); giving one column in both casings is an error.
139
139
  */
140
140
  export declare function normalizeRecordKeys(table: CompiledClientTable, record: Readonly<Record<string, unknown>>): Map<string, unknown>;
141
+ /**
142
+ * Accept the local SQL representation of a value alongside the app-facing
143
+ * one, so a row read straight off the mirror (`SELECT *`) feeds back into
144
+ * `mutate()` without per-column fixups: SQLite stores booleans as 0/1 and
145
+ * may surface integers as bigint. Anything else passes through untouched —
146
+ * the codec still fails loud on genuine type garbage at encode time.
147
+ */
148
+ export declare function coerceSqlRepresentation(column: RowColumn, value: unknown): unknown;
141
149
  /**
142
150
  * App-facing record → schema-ordered row values for the codec and the
143
151
  * local mirror. Missing keys become NULL; unknown keys fail loud (see
package/dist/schema.js CHANGED
@@ -195,6 +195,9 @@ function sqlType(column) {
195
195
  /** §7.4.1 persisted local schema-version marker (`_syncular_meta` key). */
196
196
  export const LOCAL_SCHEMA_VERSION_KEY = 'localSchemaVersion';
197
197
  function createSyncedTable(db, table) {
198
+ // §6.11: a declared reference is deliberately NOT emitted here. Windowing
199
+ // evicts a parent independently of its children, so local SQLite never
200
+ // enforces a reference; the server enforces it.
198
201
  const columns = table.columns.map((column) => {
199
202
  const notNull = column.nullable ? '' : ' NOT NULL';
200
203
  const pk = column.name === table.primaryKey ? ' PRIMARY KEY' : '';
@@ -513,7 +516,7 @@ export function normalizeRecordKeys(table, record) {
513
516
  * may surface integers as bigint. Anything else passes through untouched —
514
517
  * the codec still fails loud on genuine type garbage at encode time.
515
518
  */
516
- function coerceSqlRepresentation(column, value) {
519
+ export function coerceSqlRepresentation(column, value) {
517
520
  switch (localColumnType(column)) {
518
521
  case 'boolean':
519
522
  return value === 0 ? false : value === 1 ? true : value;