@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/src/client.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  deleteLocalRow,
45
45
  deleteScopedRows,
46
46
  evictScopedRows,
47
+ yieldToHost,
47
48
  upsertLocalRow,
48
49
  } from './apply';
49
50
  import {
@@ -157,6 +158,8 @@ import {
157
158
  ensureLocalBookkeepingSchema,
158
159
  ensureLocalSyncedSchema,
159
160
  fromSqlValue,
161
+ coerceSqlRepresentation,
162
+ type JsonRowValue,
160
163
  jsonToRowValue,
161
164
  LOCAL_SCHEMA_VERSION_KEY,
162
165
  normalizeRecordKeys,
@@ -286,8 +289,8 @@ export interface SyncClientLimits {
286
289
  readonly maxSnapshotPages?: number;
287
290
  /**
288
291
  * §4.2 accept bitmask; defaults to inline + external rows (0b0011)
289
- * plus sqlite images (bit 2) when the database backend implements
290
- * `withSqliteImage` and a segment downloader is configured (§5.3).
292
+ * plus SQLite images and signed URLs when supported. Both image chunks
293
+ * and rows blocks commit before yielding to local reads (§7.7).
291
294
  */
292
295
  readonly accept?: number;
293
296
  /**
@@ -748,6 +751,7 @@ export class SyncClient {
748
751
  const startupWork =
749
752
  this.#schemaFloor === undefined &&
750
753
  (countOutbox(this.#db) > 0 ||
754
+ loadPendingEvictions(this.#db).length > 0 ||
751
755
  subscriptions.some((sub) => sub.status === 'active'));
752
756
  if (startupWork && this.#securityLifecycle === 'active') {
753
757
  this.#needsPull = true;
@@ -1820,23 +1824,17 @@ export class SyncClient {
1820
1824
  socket.send(encodePresencePublish(scopeKey, doc));
1821
1825
  }
1822
1826
 
1823
- /**
1824
- * §4.2 accept mask: the configured override, or the rows baseline plus
1825
- * bit 2 when the backend can import sqlite images (§5.3) and a segment
1826
- * downloader exists (sqlite segments are never inline, §5.7), plus
1827
- * bit 3 when the downloader exposes a direct URL fetch (§5.4
1828
- * capability negotiation).
1829
- */
1827
+ /** Advertise supported segment formats; all imports yield between commits. */
1830
1828
  #acceptMask(): number {
1831
1829
  const configured = this.#config.limits?.accept;
1832
1830
  if (configured !== undefined) return configured;
1833
1831
  const segments = this.#config.segments;
1834
- const sqliteCapable =
1835
- typeof this.#db.withSqliteImage === 'function' && segments !== undefined;
1836
1832
  const urlCapable = typeof segments?.fetchUrl === 'function';
1837
1833
  return (
1838
1834
  ACCEPT_ROWS_BASELINE |
1839
- (sqliteCapable ? ACCEPT_SQLITE : 0) |
1835
+ (typeof this.#db.withSqliteImage === 'function' && segments !== undefined
1836
+ ? ACCEPT_SQLITE
1837
+ : 0) |
1840
1838
  (urlCapable ? ACCEPT_SIGNED_URLS : 0)
1841
1839
  );
1842
1840
  }
@@ -1909,8 +1907,8 @@ export class SyncClient {
1909
1907
  * Set the live window units for a base (§4.8): a value-sharded family of
1910
1908
  * subscriptions, one per unit. Computes the diff against the registry —
1911
1909
  * added units get fresh subscriptions (image-lane bootstrap on the next
1912
- * sync); removed units are unsubscribed and evicted, fused in one local
1913
- * transaction (E1–E4). Idempotent: calling with the same units is a
1910
+ * sync); removed units are unsubscribed with their first eviction chunk
1911
+ * in one local transaction (E1–E4). Idempotent: calling with the same units is a
1914
1912
  * no-op. Re-entry (a unit removed then re-added) cancels any deferred
1915
1913
  * eviction and fresh-bootstraps.
1916
1914
  *
@@ -1975,7 +1973,7 @@ export class SyncClient {
1975
1973
  // Shrink: units live but not wanted → unsubscribe fused with eviction.
1976
1974
  for (const { unit, subId } of live) {
1977
1975
  if (wanted.has(unit)) continue;
1978
- this.#evictUnit(baseKey, base, unit, subId);
1976
+ await this.#evictUnit(baseKey, base, unit, subId);
1979
1977
  changed = true;
1980
1978
  }
1981
1979
  });
@@ -2018,8 +2016,8 @@ export class SyncClient {
2018
2016
  }
2019
2017
 
2020
2018
  /**
2021
- * §4.8 E1–E4: evict one departing unit, fused with its unsubscription in
2022
- * one transaction. Deletes the unit's rows EXCEPT those pinned by a
2019
+ * §4.8 E1–E4: evict one departing unit in committed chunks, fusing
2020
+ * its first chunk with unsubscription. Deletes the unit's rows EXCEPT those pinned by a
2023
2021
  * pending outbox commit (E1); if any pin remains, records a deferred
2024
2022
  * eviction retried on the next outbox drain. Discards the subscription's
2025
2023
  * cursor/resume/effective-echo (E3) and its version state with the rows
@@ -2027,24 +2025,26 @@ export class SyncClient {
2027
2025
  * with no local scope-column mapping, surfaces a configuration error and
2028
2026
  * evicts nothing (§4.8/§3.3).
2029
2027
  */
2030
- #evictUnit(
2028
+ async #evictUnit(
2031
2029
  baseKey: string,
2032
2030
  base: WindowBase,
2033
2031
  unit: string,
2034
2032
  subId: string,
2035
- ): void {
2033
+ ): Promise<void> {
2036
2034
  const table = this.#table(base.table);
2037
2035
  const sub = getSubscription(this.#db, subId);
2038
2036
  // The rows a unit holds live under its LAST effective scopes if it ever
2039
2037
  // synced; before first sync, the requested unit scopes are the match.
2040
2038
  const effective = sub?.effectiveScopes ?? unitScopes(base, unit);
2041
2039
  const pinned = this.#pinnedRowIds(base.table);
2040
+ let remaining = false;
2042
2041
  this.#applyBatch((batch) => {
2043
2042
  this.#db.transaction(() => {
2044
- const deferred = evictScopedRows(this.#db, table, effective, pinned);
2043
+ const result = evictScopedRows(this.#db, table, effective, pinned);
2044
+ remaining = result.remaining;
2045
2045
  deleteWindowUnit(this.#db, baseKey, unit);
2046
2046
  deleteSubscription(this.#db, subId);
2047
- if (deferred) {
2047
+ if (result.deferred) {
2048
2048
  savePendingEviction(this.#db, subId, base.table, effective);
2049
2049
  } else {
2050
2050
  deletePendingEviction(this.#db, subId);
@@ -2055,6 +2055,10 @@ export class SyncClient {
2055
2055
  this.#needsPull = true;
2056
2056
  batch.status();
2057
2057
  });
2058
+ if (remaining) {
2059
+ await yieldToHost();
2060
+ await this.#drainPendingEvictions();
2061
+ }
2058
2062
  }
2059
2063
 
2060
2064
  /**
@@ -2062,7 +2066,7 @@ export class SyncClient {
2062
2066
  * unit's rows are removed once no pending commit references them; a unit
2063
2067
  * that re-entered the window in the meantime has no pending record left.
2064
2068
  */
2065
- #drainPendingEvictions(): void {
2069
+ async #drainPendingEvictions(): Promise<void> {
2066
2070
  const pending = loadPendingEvictions(this.#db);
2067
2071
  if (pending.length === 0) return;
2068
2072
  for (const entry of pending) {
@@ -2071,15 +2075,30 @@ export class SyncClient {
2071
2075
  deletePendingEviction(this.#db, entry.subId);
2072
2076
  continue;
2073
2077
  }
2074
- const pinned = this.#pinnedRowIds(entry.table);
2075
- this.#applyBatch((batch) => {
2076
- let deferred = false;
2077
- this.#db.transaction(() => {
2078
- deferred = evictScopedRows(this.#db, table, entry.effective, pinned);
2079
- if (!deferred) deletePendingEviction(this.#db, entry.subId);
2078
+ let remaining: boolean;
2079
+ do {
2080
+ if (
2081
+ !loadPendingEvictions(this.#db).some(
2082
+ (item) => item.subId === entry.subId,
2083
+ )
2084
+ )
2085
+ break;
2086
+ // Local writes may add pins while the worker yields between chunks.
2087
+ const pinned = this.#pinnedRowIds(entry.table);
2088
+ remaining = false;
2089
+ this.#applyBatch((batch) => {
2090
+ const result = evictScopedRows(
2091
+ this.#db,
2092
+ table,
2093
+ entry.effective,
2094
+ pinned,
2095
+ );
2096
+ remaining = result.remaining;
2097
+ if (!result.deferred) deletePendingEviction(this.#db, entry.subId);
2098
+ batch.scopeMap(table, entry.effective);
2080
2099
  });
2081
- batch.scopeMap(table, entry.effective);
2082
- });
2100
+ if (remaining) await yieldToHost();
2101
+ } while (remaining);
2083
2102
  }
2084
2103
  }
2085
2104
 
@@ -2110,11 +2129,11 @@ export class SyncClient {
2110
2129
 
2111
2130
  #recordMutations(
2112
2131
  mutations: readonly MutationInput[],
2113
- changedFieldsByIndex: readonly (readonly string[] | undefined)[] = [],
2132
+ partial = false,
2114
2133
  ): string {
2115
2134
  this.#requireActive();
2116
2135
  const clientCommitId = crypto.randomUUID();
2117
- const operations: OutboxOperation[] = mutations.map((mutation, index) => {
2136
+ const operations: OutboxOperation[] = mutations.map((mutation) => {
2118
2137
  const table = this.#table(mutation.table);
2119
2138
  if (mutation.op === 'delete') {
2120
2139
  return {
@@ -2126,6 +2145,55 @@ export class SyncClient {
2126
2145
  : {}),
2127
2146
  };
2128
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
+ }
2129
2197
  const values = recordToRowValues(table, mutation.values);
2130
2198
  const pkValue = values[table.primaryKeyIndex];
2131
2199
  if (typeof pkValue !== 'string' || pkValue.length === 0) {
@@ -2146,9 +2214,6 @@ export class SyncClient {
2146
2214
  ? { baseVersion: mutation.baseVersion }
2147
2215
  : {}),
2148
2216
  values: json,
2149
- ...(changedFieldsByIndex[index] !== undefined
2150
- ? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
2151
- : {}),
2152
2217
  };
2153
2218
  });
2154
2219
  this.#applyBatch((batch) => {
@@ -2201,12 +2266,12 @@ export class SyncClient {
2201
2266
  }
2202
2267
 
2203
2268
  /**
2204
- * Partial-update convenience over the §6.1 full-row wire: read the
2205
- * current LOCAL row, merge `partial` over it, and record one full-row
2206
- * upsert through `mutate()`. `partial` keys follow the same two-casing
2207
- * rule as mutation values (snake_case or camelCase). The row must be
2208
- * locally present (subscribed/windowed-in); patching an absent row is
2209
- * 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.
2210
2275
  */
2211
2276
  patch(
2212
2277
  table: string,
@@ -2217,37 +2282,29 @@ export class SyncClient {
2217
2282
  this.#requireActive();
2218
2283
  const compiled = this.#table(table);
2219
2284
  const pkColumn = compiled.columns[compiled.primaryKeyIndex] as RowColumn;
2220
- const rows = this.#db.query(
2221
- `SELECT * FROM ${quoteIdent(compiled.name)} WHERE ${quoteIdent(pkColumn.name)} = ?`,
2222
- [rowId],
2223
- );
2224
- const row = rows[0];
2225
- if (row === undefined) {
2285
+ const normalized = normalizeRecordKeys(compiled, partial);
2286
+ const pkOverride = normalized.get(pkColumn.name);
2287
+ if (pkOverride !== undefined && pkOverride !== rowId) {
2226
2288
  throw new ClientSyncError(
2227
2289
  'sync.invalid_request',
2228
- `table ${compiled.name}: no local row with primary key ${JSON.stringify(rowId)} to patch`,
2290
+ `table ${compiled.name}: patch cannot change the primary key`,
2229
2291
  );
2230
2292
  }
2231
- const record: Record<string, unknown> = {};
2232
- for (const column of compiled.columns as readonly RowColumn[]) {
2233
- record[column.name] = fromSqlValue(column, row[column.name] ?? null);
2234
- }
2235
- const normalizedPartial = normalizeRecordKeys(compiled, partial);
2236
- for (const [name, value] of normalizedPartial) {
2237
- record[name] = value;
2238
- }
2239
2293
  return this.#recordMutations(
2240
2294
  [
2241
2295
  {
2242
2296
  table,
2243
2297
  op: 'upsert',
2244
- values: record,
2298
+ values: {
2299
+ ...Object.fromEntries(normalized),
2300
+ [pkColumn.name]: rowId,
2301
+ },
2245
2302
  ...(options?.baseVersion !== undefined
2246
2303
  ? { baseVersion: options.baseVersion }
2247
2304
  : {}),
2248
2305
  },
2249
2306
  ],
2250
- [[...normalizedPartial.keys()].sort()],
2307
+ true,
2251
2308
  );
2252
2309
  }
2253
2310
 
@@ -2753,6 +2810,7 @@ export class SyncClient {
2753
2810
  // survive it — the reference server keeps no replay buffer (§8.2).
2754
2811
  this.#setSyncNeeded(false);
2755
2812
  try {
2813
+ await this.#drainPendingEvictions();
2756
2814
  const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
2757
2815
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
2758
2816
  // them, so the server-side existence check (§6.6) passes.
@@ -2830,7 +2888,7 @@ export class SyncClient {
2830
2888
  );
2831
2889
  // §4.8 E1: the push half may have drained commits that pinned rows of
2832
2890
  // a shrunk window unit — retry any deferred evictions now.
2833
- this.#drainPendingEvictions();
2891
+ await this.#drainPendingEvictions();
2834
2892
  this.#progress.update(
2835
2893
  summary.failed.length > 0
2836
2894
  ? { state: 'failed', errorCode: 'sync.scope_revoked' }
@@ -2879,10 +2937,27 @@ export class SyncClient {
2879
2937
  * Pull repeatedly until quiescent: no commits delivered, no bootstrap
2880
2938
  * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
2881
2939
  */
2882
- async syncUntilIdle(maxRounds = 20): Promise<SyncSummary> {
2940
+ async syncUntilIdle(maxRounds?: number): Promise<SyncSummary> {
2941
+ const budget = maxRounds ?? 20;
2883
2942
  let last: SyncSummary | undefined;
2884
- for (let round = 0; round < maxRounds; round++) {
2943
+ for (let round = 0; round < budget; round++) {
2944
+ const before = JSON.stringify(
2945
+ loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
2946
+ );
2885
2947
  last = await this.sync();
2948
+ if (
2949
+ maxRounds === undefined &&
2950
+ last.segmentRowsApplied > 0 &&
2951
+ last.bootstrapping.length > 0 &&
2952
+ before !==
2953
+ JSON.stringify(
2954
+ loadSubscriptions(this.#db).map((sub) => [
2955
+ sub.id,
2956
+ sub.bootstrapState,
2957
+ ]),
2958
+ )
2959
+ )
2960
+ round = -1;
2886
2961
  if (last.schemaFloor !== undefined) return last;
2887
2962
  if (
2888
2963
  last.commitsApplied === 0 &&
@@ -2897,7 +2972,7 @@ export class SyncClient {
2897
2972
  }
2898
2973
  throw new ClientSyncError(
2899
2974
  'sync.invalid_request',
2900
- `sync did not reach idle within ${maxRounds} rounds`,
2975
+ 'sync did not reach idle within the round budget',
2901
2976
  );
2902
2977
  }
2903
2978
 
@@ -3428,6 +3503,7 @@ export class SyncClient {
3428
3503
  segment,
3429
3504
  {
3430
3505
  clearFirst,
3506
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
3431
3507
  onProgress: (rowsProcessed) =>
3432
3508
  this.#progress.update({ rowsProcessed }),
3433
3509
  effective,
@@ -3440,7 +3516,9 @@ export class SyncClient {
3440
3516
  ) {
3441
3517
  batch.table(table.name);
3442
3518
  }
3443
- return fn();
3519
+ const result = fn();
3520
+ this.#replayOutbox();
3521
+ return result;
3444
3522
  }),
3445
3523
  },
3446
3524
  this.#encryption,
@@ -3499,6 +3577,7 @@ export class SyncClient {
3499
3577
  },
3500
3578
  {
3501
3579
  clearFirst,
3580
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
3502
3581
  onProgress: (rowsProcessed) =>
3503
3582
  this.#progress.update({ rowsProcessed }),
3504
3583
  effective,
@@ -3511,7 +3590,9 @@ export class SyncClient {
3511
3590
  ) {
3512
3591
  batch.table(table.name);
3513
3592
  }
3514
- return fn();
3593
+ const result = fn();
3594
+ this.#replayOutbox();
3595
+ return result;
3515
3596
  }),
3516
3597
  },
3517
3598
  ),
@@ -3530,6 +3611,7 @@ export class SyncClient {
3530
3611
  segment,
3531
3612
  {
3532
3613
  clearFirst,
3614
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
3533
3615
  onProgress: (rowsProcessed) =>
3534
3616
  this.#progress.update({ rowsProcessed }),
3535
3617
  effective,
@@ -3542,7 +3624,9 @@ export class SyncClient {
3542
3624
  ) {
3543
3625
  batch.table(table.name);
3544
3626
  }
3545
- return fn();
3627
+ const result = fn();
3628
+ this.#replayOutbox();
3629
+ return result;
3546
3630
  }),
3547
3631
  },
3548
3632
  this.#encryption,
@@ -3691,6 +3775,10 @@ export class SyncClient {
3691
3775
  message: result.message,
3692
3776
  serverVersion: result.serverVersion,
3693
3777
  serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
3778
+ conflictColumns: this.#decodeConflictColumns(
3779
+ operation?.table,
3780
+ result.conflictColumns,
3781
+ ),
3694
3782
  ...(operation !== undefined ? { operation } : {}),
3695
3783
  };
3696
3784
  this.#conflicts.push(conflict);
@@ -3760,6 +3848,26 @@ export class SyncClient {
3760
3848
  return record;
3761
3849
  }
3762
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
+
3763
3871
  async #applyCommit(
3764
3872
  frame: CommitFrame,
3765
3873
  summary: MutableSummary,
@@ -4274,9 +4382,26 @@ export class SyncClient {
4274
4382
  deleteLocalRow(this.#db, table, op.rowId);
4275
4383
  continue;
4276
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
+ }
4277
4399
  const values = table.columns.map((column) => {
4278
- const value = op.values?.[column.name];
4279
- 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);
4280
4405
  });
4281
4406
  // Record the row's scope keys from its scope columns (I2 refinement).
4282
4407
  if (batch !== undefined) {
@@ -4291,12 +4416,10 @@ export class SyncClient {
4291
4416
  }
4292
4417
  if (!precise) batch.table(table.name);
4293
4418
  }
4294
- const existing = this.#db.query(
4295
- `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
4296
- [op.rowId],
4297
- )[0];
4298
4419
  const version =
4299
- existing === undefined ? OPTIMISTIC_VERSION : (existing.v as number);
4420
+ local === undefined
4421
+ ? OPTIMISTIC_VERSION
4422
+ : (local[SYNC_VERSION_COLUMN] as number);
4300
4423
  upsertLocalRow(this.#db, table, values, version);
4301
4424
  }
4302
4425
  }
package/src/database.ts CHANGED
@@ -22,11 +22,16 @@ export interface ClientDatabase {
22
22
  /**
23
23
  * Optional sqlite-image capability (SPEC §5.3): expose `bytes` as an
24
24
  * attached read-only database schema named `alias` for the duration of
25
- * `fn`, then detach. Presence of this method is what makes the client
25
+ * `fn`, awaiting its result before detaching. No transaction spans its awaits.
26
+ * Presence of this method is what makes the client
26
27
  * advertise `accept` bit 2 (§4.2). MUST be called outside any open
27
28
  * transaction (SQLite cannot ATTACH inside one); `fn` may open its own.
28
29
  */
29
- withSqliteImage?<T>(bytes: Uint8Array, alias: string, fn: () => T): T;
30
+ withSqliteImage?<T>(
31
+ bytes: Uint8Array,
32
+ alias: string,
33
+ fn: () => T | Promise<T>,
34
+ ): Promise<T>;
30
35
  close(): void;
31
36
  }
32
37
 
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
  }
@@ -63,7 +63,11 @@ export class NodeClientDatabase implements ClientDatabase {
63
63
  }
64
64
 
65
65
  /** §5.3 image import through a private file attached for one callback. */
66
- withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T {
66
+ async withSqliteImage<T>(
67
+ bytes: Uint8Array,
68
+ alias: string,
69
+ fn: () => T | Promise<T>,
70
+ ): Promise<T> {
67
71
  assertImageAlias(alias);
68
72
  const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
69
73
  const path = join(dir, 'segment.db');
@@ -71,7 +75,7 @@ export class NodeClientDatabase implements ClientDatabase {
71
75
  writeFileSync(path, bytes);
72
76
  this.db.prepare(`ATTACH DATABASE ? AS ${alias}`).run(path);
73
77
  try {
74
- return fn();
78
+ return await fn();
75
79
  } finally {
76
80
  this.db.prepare(`DETACH DATABASE ${alias}`).run();
77
81
  }
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 {