@syncular/client 0.20.0 → 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/dist/client.js CHANGED
@@ -9,7 +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 { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, upsertLocalRow, } from './apply.js';
12
+ import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
13
13
  import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
14
14
  import { registerDevtools } from './devtools.js';
15
15
  import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, } from './diagnostics.js';
@@ -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. */
@@ -236,6 +236,7 @@ export class SyncClient {
236
236
  // an application-issued sync() call.
237
237
  const startupWork = this.#schemaFloor === undefined &&
238
238
  (countOutbox(this.#db) > 0 ||
239
+ loadPendingEvictions(this.#db).length > 0 ||
239
240
  subscriptions.some((sub) => sub.status === 'active'));
240
241
  if (startupWork && this.#securityLifecycle === 'active') {
241
242
  this.#needsPull = true;
@@ -1126,22 +1127,17 @@ export class SyncClient {
1126
1127
  }
1127
1128
  socket.send(encodePresencePublish(scopeKey, doc));
1128
1129
  }
1129
- /**
1130
- * §4.2 accept mask: the configured override, or the rows baseline plus
1131
- * bit 2 when the backend can import sqlite images (§5.3) and a segment
1132
- * downloader exists (sqlite segments are never inline, §5.7), plus
1133
- * bit 3 when the downloader exposes a direct URL fetch (§5.4
1134
- * capability negotiation).
1135
- */
1130
+ /** Advertise supported segment formats; all imports yield between commits. */
1136
1131
  #acceptMask() {
1137
1132
  const configured = this.#config.limits?.accept;
1138
1133
  if (configured !== undefined)
1139
1134
  return configured;
1140
1135
  const segments = this.#config.segments;
1141
- const sqliteCapable = typeof this.#db.withSqliteImage === 'function' && segments !== undefined;
1142
1136
  const urlCapable = typeof segments?.fetchUrl === 'function';
1143
1137
  return (ACCEPT_ROWS_BASELINE |
1144
- (sqliteCapable ? ACCEPT_SQLITE : 0) |
1138
+ (typeof this.#db.withSqliteImage === 'function' && segments !== undefined
1139
+ ? ACCEPT_SQLITE
1140
+ : 0) |
1145
1141
  (urlCapable ? ACCEPT_SIGNED_URLS : 0));
1146
1142
  }
1147
1143
  subscriptions() {
@@ -1199,8 +1195,8 @@ export class SyncClient {
1199
1195
  * Set the live window units for a base (§4.8): a value-sharded family of
1200
1196
  * subscriptions, one per unit. Computes the diff against the registry —
1201
1197
  * added units get fresh subscriptions (image-lane bootstrap on the next
1202
- * sync); removed units are unsubscribed and evicted, fused in one local
1203
- * transaction (E1–E4). Idempotent: calling with the same units is a
1198
+ * sync); removed units are unsubscribed with their first eviction chunk
1199
+ * in one local transaction (E1–E4). Idempotent: calling with the same units is a
1204
1200
  * no-op. Re-entry (a unit removed then re-added) cancels any deferred
1205
1201
  * eviction and fresh-bootstraps.
1206
1202
  *
@@ -1258,7 +1254,7 @@ export class SyncClient {
1258
1254
  for (const { unit, subId } of live) {
1259
1255
  if (wanted.has(unit))
1260
1256
  continue;
1261
- this.#evictUnit(baseKey, base, unit, subId);
1257
+ await this.#evictUnit(baseKey, base, unit, subId);
1262
1258
  changed = true;
1263
1259
  }
1264
1260
  });
@@ -1297,8 +1293,8 @@ export class SyncClient {
1297
1293
  return { units: live.map((u) => u.unit), pending };
1298
1294
  }
1299
1295
  /**
1300
- * §4.8 E1–E4: evict one departing unit, fused with its unsubscription in
1301
- * one transaction. Deletes the unit's rows EXCEPT those pinned by a
1296
+ * §4.8 E1–E4: evict one departing unit in committed chunks, fusing
1297
+ * its first chunk with unsubscription. Deletes the unit's rows EXCEPT those pinned by a
1302
1298
  * pending outbox commit (E1); if any pin remains, records a deferred
1303
1299
  * eviction retried on the next outbox drain. Discards the subscription's
1304
1300
  * cursor/resume/effective-echo (E3) and its version state with the rows
@@ -1306,19 +1302,21 @@ export class SyncClient {
1306
1302
  * with no local scope-column mapping, surfaces a configuration error and
1307
1303
  * evicts nothing (§4.8/§3.3).
1308
1304
  */
1309
- #evictUnit(baseKey, base, unit, subId) {
1305
+ async #evictUnit(baseKey, base, unit, subId) {
1310
1306
  const table = this.#table(base.table);
1311
1307
  const sub = getSubscription(this.#db, subId);
1312
1308
  // The rows a unit holds live under its LAST effective scopes if it ever
1313
1309
  // synced; before first sync, the requested unit scopes are the match.
1314
1310
  const effective = sub?.effectiveScopes ?? unitScopes(base, unit);
1315
1311
  const pinned = this.#pinnedRowIds(base.table);
1312
+ let remaining = false;
1316
1313
  this.#applyBatch((batch) => {
1317
1314
  this.#db.transaction(() => {
1318
- const deferred = evictScopedRows(this.#db, table, effective, pinned);
1315
+ const result = evictScopedRows(this.#db, table, effective, pinned);
1316
+ remaining = result.remaining;
1319
1317
  deleteWindowUnit(this.#db, baseKey, unit);
1320
1318
  deleteSubscription(this.#db, subId);
1321
- if (deferred) {
1319
+ if (result.deferred) {
1322
1320
  savePendingEviction(this.#db, subId, base.table, effective);
1323
1321
  }
1324
1322
  else {
@@ -1330,13 +1328,17 @@ export class SyncClient {
1330
1328
  this.#needsPull = true;
1331
1329
  batch.status();
1332
1330
  });
1331
+ if (remaining) {
1332
+ await yieldToHost();
1333
+ await this.#drainPendingEvictions();
1334
+ }
1333
1335
  }
1334
1336
  /**
1335
1337
  * §4.8 E1: retry deferred evictions after the outbox drains. A pinned
1336
1338
  * unit's rows are removed once no pending commit references them; a unit
1337
1339
  * that re-entered the window in the meantime has no pending record left.
1338
1340
  */
1339
- #drainPendingEvictions() {
1341
+ async #drainPendingEvictions() {
1340
1342
  const pending = loadPendingEvictions(this.#db);
1341
1343
  if (pending.length === 0)
1342
1344
  return;
@@ -1346,16 +1348,23 @@ export class SyncClient {
1346
1348
  deletePendingEviction(this.#db, entry.subId);
1347
1349
  continue;
1348
1350
  }
1349
- const pinned = this.#pinnedRowIds(entry.table);
1350
- this.#applyBatch((batch) => {
1351
- let deferred = false;
1352
- this.#db.transaction(() => {
1353
- deferred = evictScopedRows(this.#db, table, entry.effective, pinned);
1354
- if (!deferred)
1351
+ let remaining;
1352
+ do {
1353
+ if (!loadPendingEvictions(this.#db).some((item) => item.subId === entry.subId))
1354
+ break;
1355
+ // Local writes may add pins while the worker yields between chunks.
1356
+ const pinned = this.#pinnedRowIds(entry.table);
1357
+ remaining = false;
1358
+ this.#applyBatch((batch) => {
1359
+ const result = evictScopedRows(this.#db, table, entry.effective, pinned);
1360
+ remaining = result.remaining;
1361
+ if (!result.deferred)
1355
1362
  deletePendingEviction(this.#db, entry.subId);
1363
+ batch.scopeMap(table, entry.effective);
1356
1364
  });
1357
- batch.scopeMap(table, entry.effective);
1358
- });
1365
+ if (remaining)
1366
+ await yieldToHost();
1367
+ } while (remaining);
1359
1368
  }
1360
1369
  }
1361
1370
  /**
@@ -1381,10 +1390,10 @@ export class SyncClient {
1381
1390
  mutate(mutations) {
1382
1391
  return this.#recordMutations(mutations);
1383
1392
  }
1384
- #recordMutations(mutations, changedFieldsByIndex = []) {
1393
+ #recordMutations(mutations, partial = false) {
1385
1394
  this.#requireActive();
1386
1395
  const clientCommitId = crypto.randomUUID();
1387
- const operations = mutations.map((mutation, index) => {
1396
+ const operations = mutations.map((mutation) => {
1388
1397
  const table = this.#table(mutation.table);
1389
1398
  if (mutation.op === 'delete') {
1390
1399
  return {
@@ -1396,6 +1405,45 @@ export class SyncClient {
1396
1405
  : {}),
1397
1406
  };
1398
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
+ }
1399
1447
  const values = recordToRowValues(table, mutation.values);
1400
1448
  const pkValue = values[table.primaryKeyIndex];
1401
1449
  if (typeof pkValue !== 'string' || pkValue.length === 0) {
@@ -1413,9 +1461,6 @@ export class SyncClient {
1413
1461
  ? { baseVersion: mutation.baseVersion }
1414
1462
  : {}),
1415
1463
  values: json,
1416
- ...(changedFieldsByIndex[index] !== undefined
1417
- ? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
1418
- : {}),
1419
1464
  };
1420
1465
  });
1421
1466
  this.#applyBatch((batch) => {
@@ -1462,40 +1507,35 @@ export class SyncClient {
1462
1507
  };
1463
1508
  }
1464
1509
  /**
1465
- * Partial-update convenience over the §6.1 full-row wire: read the
1466
- * current LOCAL row, merge `partial` over it, and record one full-row
1467
- * upsert through `mutate()`. `partial` keys follow the same two-casing
1468
- * rule as mutation values (snake_case or camelCase). The row must be
1469
- * locally present (subscribed/windowed-in); patching an absent row is
1470
- * 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.
1471
1516
  */
1472
1517
  patch(table, rowId, partial, options) {
1473
1518
  this.#requireActive();
1474
1519
  const compiled = this.#table(table);
1475
1520
  const pkColumn = compiled.columns[compiled.primaryKeyIndex];
1476
- const rows = this.#db.query(`SELECT * FROM ${quoteIdent(compiled.name)} WHERE ${quoteIdent(pkColumn.name)} = ?`, [rowId]);
1477
- const row = rows[0];
1478
- if (row === undefined) {
1479
- throw new ClientSyncError('sync.invalid_request', `table ${compiled.name}: no local row with primary key ${JSON.stringify(rowId)} to patch`);
1480
- }
1481
- const record = {};
1482
- for (const column of compiled.columns) {
1483
- record[column.name] = fromSqlValue(column, row[column.name] ?? null);
1484
- }
1485
- const normalizedPartial = normalizeRecordKeys(compiled, partial);
1486
- for (const [name, value] of normalizedPartial) {
1487
- 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`);
1488
1525
  }
1489
1526
  return this.#recordMutations([
1490
1527
  {
1491
1528
  table,
1492
1529
  op: 'upsert',
1493
- values: record,
1530
+ values: {
1531
+ ...Object.fromEntries(normalized),
1532
+ [pkColumn.name]: rowId,
1533
+ },
1494
1534
  ...(options?.baseVersion !== undefined
1495
1535
  ? { baseVersion: options.baseVersion }
1496
1536
  : {}),
1497
1537
  },
1498
- ], [[...normalizedPartial.keys()].sort()]);
1538
+ ], true);
1499
1539
  }
1500
1540
  /**
1501
1541
  * Apply one host-authorized local security purge. The host must stop/gate
@@ -1920,6 +1960,7 @@ export class SyncClient {
1920
1960
  // survive it — the reference server keeps no replay buffer (§8.2).
1921
1961
  this.#setSyncNeeded(false);
1922
1962
  try {
1963
+ await this.#drainPendingEvictions();
1923
1964
  const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
1924
1965
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
1925
1966
  // them, so the server-side existence check (§6.6) passes.
@@ -1981,7 +2022,7 @@ export class SyncClient {
1981
2022
  const summary = await this.#processResponse(message, outbox, subs, 'pull', resetEpoch);
1982
2023
  // §4.8 E1: the push half may have drained commits that pinned rows of
1983
2024
  // a shrunk window unit — retry any deferred evictions now.
1984
- this.#drainPendingEvictions();
2025
+ await this.#drainPendingEvictions();
1985
2026
  this.#progress.update(summary.failed.length > 0
1986
2027
  ? { state: 'failed', errorCode: 'sync.scope_revoked' }
1987
2028
  : { state: 'complete' });
@@ -2028,10 +2069,21 @@ export class SyncClient {
2028
2069
  * Pull repeatedly until quiescent: no commits delivered, no bootstrap
2029
2070
  * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
2030
2071
  */
2031
- async syncUntilIdle(maxRounds = 20) {
2072
+ async syncUntilIdle(maxRounds) {
2073
+ const budget = maxRounds ?? 20;
2032
2074
  let last;
2033
- for (let round = 0; round < maxRounds; round++) {
2075
+ for (let round = 0; round < budget; round++) {
2076
+ const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]));
2034
2077
  last = await this.sync();
2078
+ if (maxRounds === undefined &&
2079
+ last.segmentRowsApplied > 0 &&
2080
+ last.bootstrapping.length > 0 &&
2081
+ before !==
2082
+ JSON.stringify(loadSubscriptions(this.#db).map((sub) => [
2083
+ sub.id,
2084
+ sub.bootstrapState,
2085
+ ])))
2086
+ round = -1;
2035
2087
  if (last.schemaFloor !== undefined)
2036
2088
  return last;
2037
2089
  if (last.commitsApplied === 0 &&
@@ -2043,7 +2095,7 @@ export class SyncClient {
2043
2095
  return last;
2044
2096
  }
2045
2097
  }
2046
- throw new ClientSyncError('sync.invalid_request', `sync did not reach idle within ${maxRounds} rounds`);
2098
+ throw new ClientSyncError('sync.invalid_request', 'sync did not reach idle within the round budget');
2047
2099
  }
2048
2100
  /**
2049
2101
  * One request/response round trip (§8.7): over the socket whenever it
@@ -2469,6 +2521,7 @@ export class SyncClient {
2469
2521
  });
2470
2522
  await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
2471
2523
  clearFirst,
2524
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
2472
2525
  onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
2473
2526
  effective,
2474
2527
  transaction: (fn) => this.#applyBatch((batch) => {
@@ -2477,7 +2530,9 @@ export class SyncClient {
2477
2530
  this.#scopedRowsExist(table, effective))) {
2478
2531
  batch.table(table.name);
2479
2532
  }
2480
- return fn();
2533
+ const result = fn();
2534
+ this.#replayOutbox();
2535
+ return result;
2481
2536
  }),
2482
2537
  }, this.#encryption), section.fresh && !section.cleared);
2483
2538
  break;
@@ -2510,6 +2565,7 @@ export class SyncClient {
2510
2565
  scopeDigest: frame.scopeDigest,
2511
2566
  }, {
2512
2567
  clearFirst,
2568
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
2513
2569
  onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
2514
2570
  effective,
2515
2571
  transaction: (fn) => this.#applyBatch((batch) => {
@@ -2518,7 +2574,9 @@ export class SyncClient {
2518
2574
  this.#scopedRowsExist(table, effective))) {
2519
2575
  batch.table(table.name);
2520
2576
  }
2521
- return fn();
2577
+ const result = fn();
2578
+ this.#replayOutbox();
2579
+ return result;
2522
2580
  }),
2523
2581
  }), section.fresh && !section.cleared);
2524
2582
  }
@@ -2526,6 +2584,7 @@ export class SyncClient {
2526
2584
  const segment = decodeRowsSegment(bytes);
2527
2585
  await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
2528
2586
  clearFirst,
2587
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
2529
2588
  onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
2530
2589
  effective,
2531
2590
  transaction: (fn) => this.#applyBatch((batch) => {
@@ -2534,7 +2593,9 @@ export class SyncClient {
2534
2593
  this.#scopedRowsExist(table, effective))) {
2535
2594
  batch.table(table.name);
2536
2595
  }
2537
- return fn();
2596
+ const result = fn();
2597
+ this.#replayOutbox();
2598
+ return result;
2538
2599
  }),
2539
2600
  }, this.#encryption), section.fresh &&
2540
2601
  !section.cleared &&
@@ -2658,6 +2719,7 @@ export class SyncClient {
2658
2719
  message: result.message,
2659
2720
  serverVersion: result.serverVersion,
2660
2721
  serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
2722
+ conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
2661
2723
  ...(operation !== undefined ? { operation } : {}),
2662
2724
  };
2663
2725
  this.#conflicts.push(conflict);
@@ -2720,6 +2782,25 @@ export class SyncClient {
2720
2782
  });
2721
2783
  return record;
2722
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
+ }
2723
2804
  async #applyCommit(frame, summary) {
2724
2805
  await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
2725
2806
  this.#recordCommitChanges(batch, frame);
@@ -3116,9 +3197,25 @@ export class SyncClient {
3116
3197
  deleteLocalRow(this.#db, table, op.rowId);
3117
3198
  continue;
3118
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
+ }
3119
3212
  const values = table.columns.map((column) => {
3120
- const value = op.values?.[column.name];
3121
- 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);
3122
3219
  });
3123
3220
  // Record the row's scope keys from its scope columns (I2 refinement).
3124
3221
  if (batch !== undefined) {
@@ -3134,8 +3231,9 @@ export class SyncClient {
3134
3231
  if (!precise)
3135
3232
  batch.table(table.name);
3136
3233
  }
3137
- const existing = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [op.rowId])[0];
3138
- const version = existing === undefined ? OPTIMISTIC_VERSION : existing.v;
3234
+ const version = local === undefined
3235
+ ? OPTIMISTIC_VERSION
3236
+ : local[SYNC_VERSION_COLUMN];
3139
3237
  upsertLocalRow(this.#db, table, values, version);
3140
3238
  }
3141
3239
  }
@@ -20,11 +20,12 @@ export interface ClientDatabase {
20
20
  /**
21
21
  * Optional sqlite-image capability (SPEC §5.3): expose `bytes` as an
22
22
  * attached read-only database schema named `alias` for the duration of
23
- * `fn`, then detach. Presence of this method is what makes the client
23
+ * `fn`, awaiting its result before detaching. No transaction spans its awaits.
24
+ * Presence of this method is what makes the client
24
25
  * advertise `accept` bit 2 (§4.2). MUST be called outside any open
25
26
  * transaction (SQLite cannot ATTACH inside one); `fn` may open its own.
26
27
  */
27
- withSqliteImage?<T>(bytes: Uint8Array, alias: string, fn: () => T): T;
28
+ withSqliteImage?<T>(bytes: Uint8Array, alias: string, fn: () => T | Promise<T>): Promise<T>;
28
29
  close(): void;
29
30
  }
30
31
  /** Attach-schema aliases are code-chosen, never user input — enforce it. */
@@ -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
  }
@@ -12,7 +12,7 @@ export declare class NodeClientDatabase implements ClientDatabase {
12
12
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
13
13
  transaction<T>(fn: () => T): T;
14
14
  /** §5.3 image import through a private file attached for one callback. */
15
- withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T;
15
+ withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T | Promise<T>): Promise<T>;
16
16
  close(): void;
17
17
  }
18
18
  export declare function openNodeDatabase(path?: string): ClientDatabase;
@@ -52,7 +52,7 @@ export class NodeClientDatabase {
52
52
  return runTransaction(this.#tx, (sql) => this.db.exec(sql), fn);
53
53
  }
54
54
  /** §5.3 image import through a private file attached for one callback. */
55
- withSqliteImage(bytes, alias, fn) {
55
+ async withSqliteImage(bytes, alias, fn) {
56
56
  assertImageAlias(alias);
57
57
  const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
58
58
  const path = join(dir, 'segment.db');
@@ -60,7 +60,7 @@ export class NodeClientDatabase {
60
60
  writeFileSync(path, bytes);
61
61
  this.db.prepare(`ATTACH DATABASE ? AS ${alias}`).run(path);
62
62
  try {
63
- return fn();
63
+ return await fn();
64
64
  }
65
65
  finally {
66
66
  this.db.prepare(`DETACH DATABASE ${alias}`).run();
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),