@syncular/client 0.20.1 → 0.21.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
@@ -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
@@ -22,7 +22,7 @@ import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, }
22
22
  import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, iterateOutbox, countOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
23
23
  import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
24
24
  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';
25
+ 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
26
  import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
27
27
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
28
28
  /** Stable client-local error while protected operations are preflight-gated. */
@@ -1390,10 +1390,10 @@ export class SyncClient {
1390
1390
  mutate(mutations) {
1391
1391
  return this.#recordMutations(mutations);
1392
1392
  }
1393
- #recordMutations(mutations, changedFieldsByIndex = []) {
1393
+ #recordMutations(mutations, partial = false) {
1394
1394
  this.#requireActive();
1395
1395
  const clientCommitId = crypto.randomUUID();
1396
- const operations = mutations.map((mutation, index) => {
1396
+ const operations = mutations.map((mutation) => {
1397
1397
  const table = this.#table(mutation.table);
1398
1398
  if (mutation.op === 'delete') {
1399
1399
  return {
@@ -1405,6 +1405,45 @@ export class SyncClient {
1405
1405
  : {}),
1406
1406
  };
1407
1407
  }
1408
+ if (partial) {
1409
+ // §6.7 patch: the values map IS the presence set — the primary key
1410
+ // plus the supplied non-scope columns. Absent columns keep their
1411
+ // stored value; the overlay and the server both apply only the
1412
+ // present ones.
1413
+ const normalized = normalizeRecordKeys(table, mutation.values);
1414
+ const scopeColumns = new Set(table.scopeColumnByVariable.values());
1415
+ const json = {};
1416
+ for (const column of table.columns) {
1417
+ if (!normalized.has(column.name))
1418
+ continue;
1419
+ if (scopeColumns.has(column.name)) {
1420
+ throw new ClientSyncError('sync.invalid_request', `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
1421
+ }
1422
+ const value = normalized.get(column.name);
1423
+ if (value === undefined || value === null) {
1424
+ if (!column.nullable) {
1425
+ throw new ClientSyncError('sync.invalid_request', `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`);
1426
+ }
1427
+ json[column.name] = null;
1428
+ continue;
1429
+ }
1430
+ json[column.name] = rowValueToJson(coerceSqlRepresentation(column, value));
1431
+ }
1432
+ const pkColumn = table.columns[table.primaryKeyIndex];
1433
+ const pkValue = json[pkColumn.name];
1434
+ if (typeof pkValue !== 'string' || pkValue.length === 0) {
1435
+ throw new ClientSyncError('sync.invalid_request', `table ${table.name}: upsert requires a non-empty string primary key`);
1436
+ }
1437
+ return {
1438
+ table: mutation.table,
1439
+ rowId: pkValue,
1440
+ op: 'upsert',
1441
+ ...(mutation.baseVersion !== undefined
1442
+ ? { baseVersion: mutation.baseVersion }
1443
+ : {}),
1444
+ values: json,
1445
+ };
1446
+ }
1408
1447
  const values = recordToRowValues(table, mutation.values);
1409
1448
  const pkValue = values[table.primaryKeyIndex];
1410
1449
  if (typeof pkValue !== 'string' || pkValue.length === 0) {
@@ -1422,9 +1461,6 @@ export class SyncClient {
1422
1461
  ? { baseVersion: mutation.baseVersion }
1423
1462
  : {}),
1424
1463
  values: json,
1425
- ...(changedFieldsByIndex[index] !== undefined
1426
- ? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
1427
- : {}),
1428
1464
  };
1429
1465
  });
1430
1466
  this.#applyBatch((batch) => {
@@ -1471,40 +1507,35 @@ export class SyncClient {
1471
1507
  };
1472
1508
  }
1473
1509
  /**
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.
1510
+ * Partial update over the §6.1 sparse wire: record one upsert whose
1511
+ * presence set is the primary key plus the supplied non-scope columns.
1512
+ * `partial` keys follow the same two-casing rule as mutation values
1513
+ * (snake_case or camelCase). Absent columns keep their stored value on
1514
+ * the server and in the local overlay; when the local row is absent the
1515
+ * overlay leaves it absent (§7.1) and the server answers per §5.2.
1480
1516
  */
1481
1517
  patch(table, rowId, partial, options) {
1482
1518
  this.#requireActive();
1483
1519
  const compiled = this.#table(table);
1484
1520
  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;
1521
+ const normalized = normalizeRecordKeys(compiled, partial);
1522
+ const pkOverride = normalized.get(pkColumn.name);
1523
+ if (pkOverride !== undefined && pkOverride !== rowId) {
1524
+ throw new ClientSyncError('sync.invalid_request', `table ${compiled.name}: patch cannot change the primary key`);
1497
1525
  }
1498
1526
  return this.#recordMutations([
1499
1527
  {
1500
1528
  table,
1501
1529
  op: 'upsert',
1502
- values: record,
1530
+ values: {
1531
+ ...Object.fromEntries(normalized),
1532
+ [pkColumn.name]: rowId,
1533
+ },
1503
1534
  ...(options?.baseVersion !== undefined
1504
1535
  ? { baseVersion: options.baseVersion }
1505
1536
  : {}),
1506
1537
  },
1507
- ], [[...normalizedPartial.keys()].sort()]);
1538
+ ], true);
1508
1539
  }
1509
1540
  /**
1510
1541
  * Apply one host-authorized local security purge. The host must stop/gate
@@ -2688,6 +2719,7 @@ export class SyncClient {
2688
2719
  message: result.message,
2689
2720
  serverVersion: result.serverVersion,
2690
2721
  serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
2722
+ conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
2691
2723
  ...(operation !== undefined ? { operation } : {}),
2692
2724
  };
2693
2725
  this.#conflicts.push(conflict);
@@ -2750,6 +2782,25 @@ export class SyncClient {
2750
2782
  });
2751
2783
  return record;
2752
2784
  }
2785
+ /**
2786
+ * §6.3 `conflictColumns`: decode the presence-layout bitmap into the names
2787
+ * of the present columns whose `column_version` exceeded `baseVersion`. A
2788
+ * custom merge recomputes exactly these columns (§6.5).
2789
+ */
2790
+ #decodeConflictColumns(tableName, bitmap) {
2791
+ if (tableName === undefined)
2792
+ return [];
2793
+ const table = this.#schema.tables.get(tableName);
2794
+ if (table === undefined)
2795
+ return [];
2796
+ const names = [];
2797
+ table.columns.forEach((column, index) => {
2798
+ const byte = bitmap[index >> 3] ?? 0;
2799
+ if (((byte >> (index & 7)) & 1) === 1)
2800
+ names.push(column.name);
2801
+ });
2802
+ return names;
2803
+ }
2753
2804
  async #applyCommit(frame, summary) {
2754
2805
  await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
2755
2806
  this.#recordCommitChanges(batch, frame);
@@ -3146,9 +3197,25 @@ export class SyncClient {
3146
3197
  deleteLocalRow(this.#db, table, op.rowId);
3147
3198
  continue;
3148
3199
  }
3200
+ // §7.1 overlay: apply the operation's PRESENT columns over the current
3201
+ // local row. A partial operation over an absent local row leaves it
3202
+ // absent — the server answers it with sync.row_deleted or
3203
+ // sync.row_missing, and the local mirror never invents a base.
3204
+ const opValues = op.values ?? {};
3205
+ const full = table.columns.every((column) => column.name in opValues);
3206
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [op.rowId])[0];
3207
+ if (local === undefined && !full) {
3208
+ if (batch !== undefined && !precise)
3209
+ batch.table(op.table);
3210
+ continue;
3211
+ }
3149
3212
  const values = table.columns.map((column) => {
3150
- const value = op.values?.[column.name];
3151
- return value === undefined ? null : jsonToRowValue(value);
3213
+ const value = opValues[column.name];
3214
+ if (value !== undefined)
3215
+ return jsonToRowValue(value);
3216
+ return local === undefined
3217
+ ? null
3218
+ : fromSqlValue(column, local[column.name] ?? null);
3152
3219
  });
3153
3220
  // Record the row's scope keys from its scope columns (I2 refinement).
3154
3221
  if (batch !== undefined) {
@@ -3164,8 +3231,9 @@ export class SyncClient {
3164
3231
  if (!precise)
3165
3232
  batch.table(table.name);
3166
3233
  }
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;
3234
+ const version = local === undefined
3235
+ ? OPTIMISTIC_VERSION
3236
+ : local[SYNC_VERSION_COLUMN];
3169
3237
  upsertLocalRow(this.#db, table, values, version);
3170
3238
  }
3171
3239
  }
@@ -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[]): 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
@@ -72,7 +72,9 @@ export async function encryptRowValues(config, table, rowId, values) {
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
+ // A sparse operation's absent columns are densified to null for key
76
+ // selection only; the encryption loop leaves them untouched.
77
+ const keyId = encryptionKeyId(config, table, rowId, values.map((value) => value ?? null));
76
78
  for (let i = 0; i < table.columns.length; i++) {
77
79
  const column = table.columns[i];
78
80
  if (column === undefined || !column.encrypted)
@@ -84,7 +86,7 @@ export async function encryptRowValues(config, table, rowId, values) {
84
86
  if (key === undefined) {
85
87
  throw new DecryptError(`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`);
86
88
  }
87
- out[i] = await encryptValue(declaredTypeOf(column), value, keyId, key, config.nonceSource);
89
+ out[i] = (await encryptValue(declaredTypeOf(column), value, keyId, key, config.nonceSource));
88
90
  }
89
91
  return out;
90
92
  }
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;
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
  /**
@@ -158,7 +156,7 @@ export async function encodeOutboxCommit(schema, commit, encryption) {
158
156
  if (op.values === undefined) {
159
157
  throw new ClientSyncError('sync.invalid_request', `outbox upsert on ${op.table}/${op.rowId} has no values`);
160
158
  }
161
- let values = orderedValues(table, op.values);
159
+ let values = sparseValues(table, op.values);
162
160
  if (encryption !== undefined && table.hasEncryptedColumns) {
163
161
  // Lazy: opt-in E2EE never enters an encryption-free app's bundle.
164
162
  const { encryptRowValues } = await import('./encryption.js');
@@ -169,7 +167,7 @@ export async function encodeOutboxCommit(schema, commit, encryption) {
169
167
  rowId: op.rowId,
170
168
  op: 'upsert',
171
169
  ...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
172
- payload: encodeRow(table.columns, values),
170
+ payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
173
171
  });
174
172
  }
175
173
  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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.20.1",
3
+ "version": "0.21.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",
@@ -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.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.20.1",
95
+ "@syncular/server": "0.21.0",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
package/src/client.ts CHANGED
@@ -158,6 +158,8 @@ import {
158
158
  ensureLocalBookkeepingSchema,
159
159
  ensureLocalSyncedSchema,
160
160
  fromSqlValue,
161
+ coerceSqlRepresentation,
162
+ type JsonRowValue,
161
163
  jsonToRowValue,
162
164
  LOCAL_SCHEMA_VERSION_KEY,
163
165
  normalizeRecordKeys,
@@ -2127,11 +2129,11 @@ export class SyncClient {
2127
2129
 
2128
2130
  #recordMutations(
2129
2131
  mutations: readonly MutationInput[],
2130
- changedFieldsByIndex: readonly (readonly string[] | undefined)[] = [],
2132
+ partial = false,
2131
2133
  ): string {
2132
2134
  this.#requireActive();
2133
2135
  const clientCommitId = crypto.randomUUID();
2134
- const operations: OutboxOperation[] = mutations.map((mutation, index) => {
2136
+ const operations: OutboxOperation[] = mutations.map((mutation) => {
2135
2137
  const table = this.#table(mutation.table);
2136
2138
  if (mutation.op === 'delete') {
2137
2139
  return {
@@ -2143,6 +2145,55 @@ export class SyncClient {
2143
2145
  : {}),
2144
2146
  };
2145
2147
  }
2148
+ if (partial) {
2149
+ // §6.7 patch: the values map IS the presence set — the primary key
2150
+ // plus the supplied non-scope columns. Absent columns keep their
2151
+ // stored value; the overlay and the server both apply only the
2152
+ // present ones.
2153
+ const normalized = normalizeRecordKeys(table, mutation.values);
2154
+ const scopeColumns = new Set(table.scopeColumnByVariable.values());
2155
+ const json: Record<string, JsonRowValue> = {};
2156
+ for (const column of table.columns) {
2157
+ if (!normalized.has(column.name)) continue;
2158
+ if (scopeColumns.has(column.name)) {
2159
+ throw new ClientSyncError(
2160
+ 'sync.invalid_request',
2161
+ `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
2162
+ );
2163
+ }
2164
+ const value = normalized.get(column.name);
2165
+ if (value === undefined || value === null) {
2166
+ if (!column.nullable) {
2167
+ throw new ClientSyncError(
2168
+ 'sync.invalid_request',
2169
+ `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`,
2170
+ );
2171
+ }
2172
+ json[column.name] = null;
2173
+ continue;
2174
+ }
2175
+ json[column.name] = rowValueToJson(
2176
+ coerceSqlRepresentation(column, value) as RowValue,
2177
+ );
2178
+ }
2179
+ const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
2180
+ const pkValue = json[pkColumn.name];
2181
+ if (typeof pkValue !== 'string' || pkValue.length === 0) {
2182
+ throw new ClientSyncError(
2183
+ 'sync.invalid_request',
2184
+ `table ${table.name}: upsert requires a non-empty string primary key`,
2185
+ );
2186
+ }
2187
+ return {
2188
+ table: mutation.table,
2189
+ rowId: pkValue,
2190
+ op: 'upsert',
2191
+ ...(mutation.baseVersion !== undefined
2192
+ ? { baseVersion: mutation.baseVersion }
2193
+ : {}),
2194
+ values: json,
2195
+ };
2196
+ }
2146
2197
  const values = recordToRowValues(table, mutation.values);
2147
2198
  const pkValue = values[table.primaryKeyIndex];
2148
2199
  if (typeof pkValue !== 'string' || pkValue.length === 0) {
@@ -2163,9 +2214,6 @@ export class SyncClient {
2163
2214
  ? { baseVersion: mutation.baseVersion }
2164
2215
  : {}),
2165
2216
  values: json,
2166
- ...(changedFieldsByIndex[index] !== undefined
2167
- ? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
2168
- : {}),
2169
2217
  };
2170
2218
  });
2171
2219
  this.#applyBatch((batch) => {
@@ -2218,12 +2266,12 @@ export class SyncClient {
2218
2266
  }
2219
2267
 
2220
2268
  /**
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.
2269
+ * Partial update over the §6.1 sparse wire: record one upsert whose
2270
+ * presence set is the primary key plus the supplied non-scope columns.
2271
+ * `partial` keys follow the same two-casing rule as mutation values
2272
+ * (snake_case or camelCase). Absent columns keep their stored value on
2273
+ * the server and in the local overlay; when the local row is absent the
2274
+ * overlay leaves it absent (§7.1) and the server answers per §5.2.
2227
2275
  */
2228
2276
  patch(
2229
2277
  table: string,
@@ -2234,37 +2282,29 @@ export class SyncClient {
2234
2282
  this.#requireActive();
2235
2283
  const compiled = this.#table(table);
2236
2284
  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) {
2285
+ const normalized = normalizeRecordKeys(compiled, partial);
2286
+ const pkOverride = normalized.get(pkColumn.name);
2287
+ if (pkOverride !== undefined && pkOverride !== rowId) {
2243
2288
  throw new ClientSyncError(
2244
2289
  'sync.invalid_request',
2245
- `table ${compiled.name}: no local row with primary key ${JSON.stringify(rowId)} to patch`,
2290
+ `table ${compiled.name}: patch cannot change the primary key`,
2246
2291
  );
2247
2292
  }
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
2293
  return this.#recordMutations(
2257
2294
  [
2258
2295
  {
2259
2296
  table,
2260
2297
  op: 'upsert',
2261
- values: record,
2298
+ values: {
2299
+ ...Object.fromEntries(normalized),
2300
+ [pkColumn.name]: rowId,
2301
+ },
2262
2302
  ...(options?.baseVersion !== undefined
2263
2303
  ? { baseVersion: options.baseVersion }
2264
2304
  : {}),
2265
2305
  },
2266
2306
  ],
2267
- [[...normalizedPartial.keys()].sort()],
2307
+ true,
2268
2308
  );
2269
2309
  }
2270
2310
 
@@ -3735,6 +3775,10 @@ export class SyncClient {
3735
3775
  message: result.message,
3736
3776
  serverVersion: result.serverVersion,
3737
3777
  serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
3778
+ conflictColumns: this.#decodeConflictColumns(
3779
+ operation?.table,
3780
+ result.conflictColumns,
3781
+ ),
3738
3782
  ...(operation !== undefined ? { operation } : {}),
3739
3783
  };
3740
3784
  this.#conflicts.push(conflict);
@@ -3804,6 +3848,26 @@ export class SyncClient {
3804
3848
  return record;
3805
3849
  }
3806
3850
 
3851
+ /**
3852
+ * §6.3 `conflictColumns`: decode the presence-layout bitmap into the names
3853
+ * of the present columns whose `column_version` exceeded `baseVersion`. A
3854
+ * custom merge recomputes exactly these columns (§6.5).
3855
+ */
3856
+ #decodeConflictColumns(
3857
+ tableName: string | undefined,
3858
+ bitmap: Uint8Array,
3859
+ ): readonly string[] {
3860
+ if (tableName === undefined) return [];
3861
+ const table = this.#schema.tables.get(tableName);
3862
+ if (table === undefined) return [];
3863
+ const names: string[] = [];
3864
+ table.columns.forEach((column, index) => {
3865
+ const byte = bitmap[index >> 3] ?? 0;
3866
+ if (((byte >> (index & 7)) & 1) === 1) names.push(column.name);
3867
+ });
3868
+ return names;
3869
+ }
3870
+
3807
3871
  async #applyCommit(
3808
3872
  frame: CommitFrame,
3809
3873
  summary: MutableSummary,
@@ -4318,9 +4382,26 @@ export class SyncClient {
4318
4382
  deleteLocalRow(this.#db, table, op.rowId);
4319
4383
  continue;
4320
4384
  }
4385
+ // §7.1 overlay: apply the operation's PRESENT columns over the current
4386
+ // local row. A partial operation over an absent local row leaves it
4387
+ // absent — the server answers it with sync.row_deleted or
4388
+ // sync.row_missing, and the local mirror never invents a base.
4389
+ const opValues = op.values ?? {};
4390
+ const full = table.columns.every((column) => column.name in opValues);
4391
+ const local = this.#db.query(
4392
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
4393
+ [op.rowId],
4394
+ )[0];
4395
+ if (local === undefined && !full) {
4396
+ if (batch !== undefined && !precise) batch.table(op.table);
4397
+ continue;
4398
+ }
4321
4399
  const values = table.columns.map((column) => {
4322
- const value = op.values?.[column.name];
4323
- return value === undefined ? null : jsonToRowValue(value);
4400
+ const value = opValues[column.name];
4401
+ if (value !== undefined) return jsonToRowValue(value);
4402
+ return local === undefined
4403
+ ? null
4404
+ : fromSqlValue(column, local[column.name] ?? null);
4324
4405
  });
4325
4406
  // Record the row's scope keys from its scope columns (I2 refinement).
4326
4407
  if (batch !== undefined) {
@@ -4335,12 +4416,10 @@ export class SyncClient {
4335
4416
  }
4336
4417
  if (!precise) batch.table(table.name);
4337
4418
  }
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
4419
  const version =
4343
- existing === undefined ? OPTIMISTIC_VERSION : (existing.v as number);
4420
+ local === undefined
4421
+ ? OPTIMISTIC_VERSION
4422
+ : (local[SYNC_VERSION_COLUMN] as number);
4344
4423
  upsertLocalRow(this.#db, table, values, version);
4345
4424
  }
4346
4425
  }
package/src/encryption.ts CHANGED
@@ -142,15 +142,22 @@ function declaredTypeOf(column: RowColumn): DeclaredType {
142
142
  * value becomes the §5.11 ciphertext-envelope `bytes`. NULLs pass through
143
143
  * unencrypted (§5.11).
144
144
  */
145
- export async function encryptRowValues(
145
+ export async function encryptRowValues<T extends RowValue | undefined>(
146
146
  config: EncryptionConfig,
147
147
  table: CompiledClientTable,
148
148
  rowId: string,
149
- values: readonly RowValue[],
150
- ): Promise<RowValue[]> {
149
+ values: readonly T[],
150
+ ): Promise<T[]> {
151
151
  if (!table.hasEncryptedColumns) return values.slice();
152
152
  const out = values.slice();
153
- const keyId = encryptionKeyId(config, table, rowId, values);
153
+ // A sparse operation's absent columns are densified to null for key
154
+ // selection only; the encryption loop leaves them untouched.
155
+ const keyId = encryptionKeyId(
156
+ config,
157
+ table,
158
+ rowId,
159
+ values.map((value) => value ?? null),
160
+ );
154
161
  for (let i = 0; i < table.columns.length; i++) {
155
162
  const column = table.columns[i];
156
163
  if (column === undefined || !column.encrypted) continue;
@@ -162,13 +169,13 @@ export async function encryptRowValues(
162
169
  `no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`,
163
170
  );
164
171
  }
165
- out[i] = await encryptValue(
172
+ out[i] = (await encryptValue(
166
173
  declaredTypeOf(column),
167
174
  value as PlainValue,
168
175
  keyId,
169
176
  key,
170
177
  config.nonceSource,
171
- );
178
+ )) as T;
172
179
  }
173
180
  return out;
174
181
  }
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
 
@@ -270,7 +270,7 @@ export async function encodeOutboxCommit(
270
270
  `outbox upsert on ${op.table}/${op.rowId} has no values`,
271
271
  );
272
272
  }
273
- let values = orderedValues(table, op.values);
273
+ let values = sparseValues(table, op.values);
274
274
  if (encryption !== undefined && table.hasEncryptedColumns) {
275
275
  // Lazy: opt-in E2EE never enters an encryption-free app's bundle.
276
276
  const { encryptRowValues } = await import('./encryption');
@@ -281,7 +281,7 @@ export async function encodeOutboxCommit(
281
281
  rowId: op.rowId,
282
282
  op: 'upsert',
283
283
  ...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
284
- payload: encodeRow(table.columns, values),
284
+ payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
285
285
  });
286
286
  }
287
287
  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',
package/src/schema.ts CHANGED
@@ -318,6 +318,9 @@ function createSyncedTable(
318
318
  db: ClientDatabase,
319
319
  table: CompiledClientTable,
320
320
  ): void {
321
+ // §6.11: a declared reference is deliberately NOT emitted here. Windowing
322
+ // evicts a parent independently of its children, so local SQLite never
323
+ // enforces a reference; the server enforces it.
321
324
  const columns = table.columns.map((column) => {
322
325
  const notNull = column.nullable ? '' : ' NOT NULL';
323
326
  const pk = column.name === table.primaryKey ? ' PRIMARY KEY' : '';
@@ -716,7 +719,10 @@ export function normalizeRecordKeys(
716
719
  * may surface integers as bigint. Anything else passes through untouched —
717
720
  * the codec still fails loud on genuine type garbage at encode time.
718
721
  */
719
- function coerceSqlRepresentation(column: RowColumn, value: unknown): unknown {
722
+ export function coerceSqlRepresentation(
723
+ column: RowColumn,
724
+ value: unknown,
725
+ ): unknown {
720
726
  switch (localColumnType(column)) {
721
727
  case 'boolean':
722
728
  return value === 0 ? false : value === 1 ? true : value;