@syncular/client 0.21.0 → 0.22.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,6 +9,7 @@ import { ProgressEmitter, } from './progress.js';
9
9
  * `sync()` round (§7.2); local reads go straight to the database.
10
10
  */
11
11
  import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
12
+ import { EncryptError } from '@syncular/core';
12
13
  import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
13
14
  import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
14
15
  import { registerDevtools } from './devtools.js';
@@ -1412,12 +1413,31 @@ export class SyncClient {
1412
1413
  // present ones.
1413
1414
  const normalized = normalizeRecordKeys(table, mutation.values);
1414
1415
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
1416
+ const pkColumn = table.columns[table.primaryKeyIndex];
1415
1417
  const json = {};
1416
1418
  for (const column of table.columns) {
1417
1419
  if (!normalized.has(column.name))
1418
1420
  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
+ // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
1422
+ // server accepts a present scope column whose value equals the
1423
+ // stored row and applies it as a no-op, so a decoded envelope
1424
+ // round-trips. The local row is the only value the client can
1425
+ // prove equality against; when the row is absent locally the
1426
+ // client cannot prove equality against the server's stored row
1427
+ // and fails closed. A primary key that is also a scope column
1428
+ // needs no stored-row read: the primary key in a sparse payload is
1429
+ // by construction the row id being patched (§6.1), so its value is
1430
+ // proven equal already.
1431
+ if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
1432
+ const pkValue = normalized.get(pkColumn.name);
1433
+ const local = typeof pkValue === 'string' && pkValue.length > 0
1434
+ ? this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0]
1435
+ : undefined;
1436
+ if (local === undefined ||
1437
+ !Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
1438
+ throw new ClientSyncError('sync.invalid_request', `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
1439
+ }
1440
+ continue;
1421
1441
  }
1422
1442
  const value = normalized.get(column.name);
1423
1443
  if (value === undefined || value === null) {
@@ -1429,7 +1449,6 @@ export class SyncClient {
1429
1449
  }
1430
1450
  json[column.name] = rowValueToJson(coerceSqlRepresentation(column, value));
1431
1451
  }
1432
- const pkColumn = table.columns[table.primaryKeyIndex];
1433
1452
  const pkValue = json[pkColumn.name];
1434
1453
  if (typeof pkValue !== 'string' || pkValue.length === 0) {
1435
1454
  throw new ClientSyncError('sync.invalid_request', `table ${table.name}: upsert requires a non-empty string primary key`);
@@ -1807,8 +1826,20 @@ export class SyncClient {
1807
1826
  try {
1808
1827
  pushFrames.push(
1809
1828
  // §5.11: encrypted columns are encrypted at this encode-at-send
1810
- // seam before the row codec serializes them.
1811
- await encodeOutboxCommit(this.#schema, commit, this.#encryption));
1829
+ // seam before the row codec serializes them. The stored local row
1830
+ // supplies an ABSENT key-id selector (§6.1 presence set first,
1831
+ // stored row second); an unresolvable key becomes a durable
1832
+ // rejection for that commit, never a sync() throw.
1833
+ await encodeOutboxCommit(this.#schema, commit, this.#encryption, (table, rowId) => {
1834
+ const selector = this.#encryption?.keyIdColumns?.[table.name];
1835
+ if (selector === undefined)
1836
+ return undefined;
1837
+ const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId])[0];
1838
+ const value = row?.value;
1839
+ return typeof value === 'string' && value.length > 0
1840
+ ? value
1841
+ : undefined;
1842
+ }));
1812
1843
  outbox.push(commit);
1813
1844
  ops += commit.operations.length;
1814
1845
  }
@@ -1817,6 +1848,10 @@ export class SyncClient {
1817
1848
  this.#dropIncompatibleCommit(commit, error.message);
1818
1849
  continue;
1819
1850
  }
1851
+ if (error instanceof EncryptError) {
1852
+ this.#dropIncompatibleCommit(commit, error.message, error.code);
1853
+ continue;
1854
+ }
1820
1855
  throw error;
1821
1856
  }
1822
1857
  }
@@ -1826,14 +1861,15 @@ export class SyncClient {
1826
1861
  * §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
1827
1862
  * §7.2 `rejected` surface — the commit leaves the outbox, its
1828
1863
  * purely-optimistic rows are undone, and a rejection record is raised.
1864
+ * §5.11: the same drop carries an encode-seam `client.encrypt_failed`.
1829
1865
  */
1830
- #dropIncompatibleCommit(commit, message) {
1866
+ #dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE) {
1831
1867
  this.#applyBatch((batch) => {
1832
1868
  this.#rollbackFailedCommit(commit, batch);
1833
1869
  const rejection = {
1834
1870
  clientCommitId: commit.clientCommitId,
1835
1871
  opIndex: 0,
1836
- code: OUTBOX_INCOMPATIBLE_CODE,
1872
+ code,
1837
1873
  message,
1838
1874
  retryable: false,
1839
1875
  ...(commit.operations[0] !== undefined
@@ -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<T extends RowValue | undefined>(config: EncryptionConfig, table: CompiledClientTable, rowId: string, values: readonly T[]): Promise<T[]>;
60
+ export declare function encryptRowValues<T extends RowValue | undefined>(config: EncryptionConfig, table: CompiledClientTable, rowId: string, values: readonly T[], storedKeyId?: string): Promise<T[]>;
61
61
  /**
62
62
  * Decrypt the encrypted columns of a decoded positional row value array
63
63
  * (returns a new array). Called at the apply seam (§4.5 COMMIT, §5.6 rows
@@ -8,7 +8,7 @@
8
8
  * bridge between the positional `RowValue[]` and the `@syncular/core` §5.11
9
9
  * envelope primitives.
10
10
  */
11
- import { DecryptError, decryptValue, encryptValue, KEY_LENGTH, } from '@syncular/core';
11
+ import { DecryptError, decryptValue, EncryptError, encryptValue, KEY_LENGTH, } from '@syncular/core';
12
12
  import { ClientSyncError } from './errors.js';
13
13
  /**
14
14
  * Convert a portable keyring into the direct-client encryption contract.
@@ -36,7 +36,7 @@ function encryptionKeyId(config, table, rowId, values) {
36
36
  if (config.keyIdFor !== undefined) {
37
37
  const selected = config.keyIdFor(table.name, rowId, values);
38
38
  if (selected.length === 0) {
39
- throw new DecryptError(`empty encryption key id selected for table ${JSON.stringify(table.name)}`);
39
+ throw new EncryptError(`empty encryption key id selected for table ${JSON.stringify(table.name)}`);
40
40
  }
41
41
  return selected;
42
42
  }
@@ -45,14 +45,14 @@ function encryptionKeyId(config, table, rowId, values) {
45
45
  return table.name;
46
46
  const index = table.columns.findIndex((column) => column.name === selectorColumn);
47
47
  if (index < 0) {
48
- throw new DecryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} is not present on table ${JSON.stringify(table.name)}`);
48
+ throw new EncryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} is not present on table ${JSON.stringify(table.name)}`);
49
49
  }
50
50
  if (table.columns[index]?.encrypted === true) {
51
- throw new DecryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must not be encrypted`);
51
+ throw new EncryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must not be encrypted`);
52
52
  }
53
53
  const selected = values[index];
54
54
  if (typeof selected !== 'string' || selected.length === 0) {
55
- throw new DecryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must contain a non-empty string`);
55
+ throw new EncryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must contain a non-empty string`);
56
56
  }
57
57
  return selected;
58
58
  }
@@ -68,13 +68,38 @@ function declaredTypeOf(column) {
68
68
  * value becomes the §5.11 ciphertext-envelope `bytes`. NULLs pass through
69
69
  * unencrypted (§5.11).
70
70
  */
71
- export async function encryptRowValues(config, table, rowId, values) {
71
+ export async function encryptRowValues(config, table, rowId, values, storedKeyId) {
72
72
  if (!table.hasEncryptedColumns)
73
73
  return values.slice();
74
74
  const out = values.slice();
75
- // 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));
75
+ // Sparse semantics: absent (undefined) and NULL columns are never
76
+ // re-encrypted, so a patch that presents no encrypted value needs no key.
77
+ let hasPresentEncrypted = false;
78
+ for (let i = 0; i < table.columns.length; i++) {
79
+ if (table.columns[i]?.encrypted !== true)
80
+ continue;
81
+ const value = out[i];
82
+ if (value !== undefined && value !== null) {
83
+ hasPresentEncrypted = true;
84
+ break;
85
+ }
86
+ }
87
+ if (!hasPresentEncrypted)
88
+ return out;
89
+ // Key selection reads the presence set first, then the stored local row.
90
+ // Absent slots densify to null for selection only; the loop below still
91
+ // leaves them untouched. Absent ≠ NULL (§6.1): only an ABSENT selector
92
+ // slot is filled from the stored row, never a present NULL.
93
+ const selected = values.map((value) => value ?? null);
94
+ const selectorColumn = config.keyIdColumns?.[table.name];
95
+ if (selectorColumn !== undefined &&
96
+ config.keyIdFor === undefined &&
97
+ storedKeyId !== undefined) {
98
+ const index = table.columns.findIndex((column) => column.name === selectorColumn);
99
+ if (index >= 0 && values[index] === undefined)
100
+ selected[index] = storedKeyId;
101
+ }
102
+ const keyId = encryptionKeyId(config, table, rowId, selected);
78
103
  for (let i = 0; i < table.columns.length; i++) {
79
104
  const column = table.columns[i];
80
105
  if (column === undefined || !column.encrypted)
@@ -84,7 +109,7 @@ export async function encryptRowValues(config, table, rowId, values) {
84
109
  continue; // NULL stays NULL
85
110
  const key = config.keyProvider(keyId);
86
111
  if (key === undefined) {
87
- throw new DecryptError(`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`);
112
+ throw new EncryptError(`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`);
88
113
  }
89
114
  out[i] = (await encryptValue(declaredTypeOf(column), value, keyId, key, config.nonceSource));
90
115
  }
package/dist/outbox.d.ts CHANGED
@@ -61,9 +61,10 @@ export declare class OutboxEncodeError extends ClientSyncError {
61
61
  * schema's row codec (§6.1). When `encryption` is configured, encrypted
62
62
  * columns (§5.11) are encrypted here — the encode-at-send seam — before the
63
63
  * row codec serializes them as ciphertext-envelope `bytes`. Async because
64
- * WebCrypto is async.
64
+ * WebCrypto is async. `storedKeyIdFor` supplies the stored local row's
65
+ * key-id selector for a sparse upsert that omits that column (§5.11).
65
66
  */
66
- export declare function encodeOutboxCommit(schema: CompiledClientSchema, commit: OutboxCommit, encryption?: EncryptionConfig): Promise<PushCommitFrame>;
67
+ export declare function encodeOutboxCommit(schema: CompiledClientSchema, commit: OutboxCommit, encryption?: EncryptionConfig, storedKeyIdFor?: (table: CompiledClientTable, rowId: string) => string | undefined): Promise<PushCommitFrame>;
67
68
  /**
68
69
  * §3.3: drop pending commits that write into a revoked scope instead of
69
70
  * replaying them into guaranteed rejections. A whole commit is dropped when
package/dist/outbox.js CHANGED
@@ -132,9 +132,10 @@ function sparseValues(table, values) {
132
132
  * schema's row codec (§6.1). When `encryption` is configured, encrypted
133
133
  * columns (§5.11) are encrypted here — the encode-at-send seam — before the
134
134
  * row codec serializes them as ciphertext-envelope `bytes`. Async because
135
- * WebCrypto is async.
135
+ * WebCrypto is async. `storedKeyIdFor` supplies the stored local row's
136
+ * key-id selector for a sparse upsert that omits that column (§5.11).
136
137
  */
137
- export async function encodeOutboxCommit(schema, commit, encryption) {
138
+ export async function encodeOutboxCommit(schema, commit, encryption, storedKeyIdFor) {
138
139
  const operations = [];
139
140
  for (const op of commit.operations) {
140
141
  if (op.op === 'delete') {
@@ -160,7 +161,7 @@ export async function encodeOutboxCommit(schema, commit, encryption) {
160
161
  if (encryption !== undefined && table.hasEncryptedColumns) {
161
162
  // Lazy: opt-in E2EE never enters an encryption-free app's bundle.
162
163
  const { encryptRowValues } = await import('./encryption.js');
163
- values = await encryptRowValues(encryption, table, op.rowId, values);
164
+ values = await encryptRowValues(encryption, table, op.rowId, values, storedKeyIdFor?.(table, op.rowId));
164
165
  }
165
166
  operations.push({
166
167
  table: op.table,
@@ -82,15 +82,21 @@ function scheduleMicrotask(task) {
82
82
  }
83
83
  function batchMatches(batch, spec) {
84
84
  for (const change of batch.tables) {
85
- const dependency = spec.dependencies.find((candidate) => candidate.table === change.table);
86
- if (dependency === undefined)
87
- continue;
88
- if (dependency.scopeKeys === undefined || change.scopeKeys === undefined) {
89
- return true;
90
- }
91
- for (const key of dependency.scopeKeys) {
92
- if (change.scopeKeys.has(key))
85
+ // A composed spec carries one dependency per query branch. Every
86
+ // dependency on the changed table is consulted: a scoped dependency
87
+ // that does not cover the change must not mask a sibling dependency
88
+ // on the same table that does.
89
+ for (const dependency of spec.dependencies) {
90
+ if (dependency.table !== change.table)
91
+ continue;
92
+ if (dependency.scopeKeys === undefined ||
93
+ change.scopeKeys === undefined) {
93
94
  return true;
95
+ }
96
+ for (const key of dependency.scopeKeys) {
97
+ if (change.scopeKeys.has(key))
98
+ return true;
99
+ }
94
100
  }
95
101
  }
96
102
  for (const change of batch.windows) {
@@ -268,8 +274,27 @@ class QueryEntry {
268
274
  this.#onAvailabilityChange();
269
275
  if (this.spec.claimCoverage !== false) {
270
276
  const claims = [];
277
+ // A composed spec carries one coverage entry per query branch, and
278
+ // several branches can target the same base. One claim per distinct
279
+ // base covers the union of their units; a later entry for the same
280
+ // base does not replace the earlier one.
281
+ const unitsByBase = new Map();
271
282
  for (const coverage of this.spec.coverage ?? []) {
272
- claims.push(this.store.setWindowClaim(this.#owner, coverage.base, coverage.units));
283
+ const baseKey = windowBaseKey(coverage.base);
284
+ const existing = unitsByBase.get(baseKey);
285
+ if (existing === undefined) {
286
+ unitsByBase.set(baseKey, {
287
+ base: coverage.base,
288
+ units: new Set(coverage.units),
289
+ });
290
+ }
291
+ else {
292
+ for (const unit of coverage.units)
293
+ existing.units.add(unit);
294
+ }
295
+ }
296
+ for (const { base, units } of unitsByBase.values()) {
297
+ claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
273
298
  }
274
299
  const generation = this.#generation;
275
300
  this.#claimPending = claims.length > 0;
package/dist/schema.js CHANGED
@@ -31,6 +31,17 @@ export function compileClientSchema(schema) {
31
31
  if (primaryKeyIndex === undefined) {
32
32
  throw new Error(`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`);
33
33
  }
34
+ // §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
35
+ // string every implementation produces identically and local storage
36
+ // resolves without deferring to a SQLite build.
37
+ const primaryKeyColumn = table.columns[primaryKeyIndex];
38
+ if (primaryKeyColumn !== undefined &&
39
+ primaryKeyColumn.type !== 'string' &&
40
+ primaryKeyColumn.type !== 'integer' &&
41
+ primaryKeyColumn.type !== 'boolean' &&
42
+ primaryKeyColumn.type !== 'json') {
43
+ throw new Error(`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`);
44
+ }
34
45
  if (table.scopes.length === 0) {
35
46
  throw new Error(`table ${table.name}: every synced table declares at least one scope pattern (§3.1)`);
36
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.21.0",
3
+ "version": "0.22.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.21.0"
92
+ "@syncular/core": "0.22.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.21.0",
95
+ "@syncular/server": "0.22.0",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
package/src/client.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  type SubStartFrame,
38
38
  type WakeReason,
39
39
  } from '@syncular/core';
40
+ import { EncryptError } from '@syncular/core';
40
41
  import {
41
42
  applyCommitFrame,
42
43
  applyRowsSegment,
@@ -2152,14 +2153,49 @@ export class SyncClient {
2152
2153
  // present ones.
2153
2154
  const normalized = normalizeRecordKeys(table, mutation.values);
2154
2155
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
2156
+ const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
2155
2157
  const json: Record<string, JsonRowValue> = {};
2156
2158
  for (const column of table.columns) {
2157
2159
  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
- );
2160
+ // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
2161
+ // server accepts a present scope column whose value equals the
2162
+ // stored row and applies it as a no-op, so a decoded envelope
2163
+ // round-trips. The local row is the only value the client can
2164
+ // prove equality against; when the row is absent locally the
2165
+ // client cannot prove equality against the server's stored row
2166
+ // and fails closed. A primary key that is also a scope column
2167
+ // needs no stored-row read: the primary key in a sparse payload is
2168
+ // by construction the row id being patched (§6.1), so its value is
2169
+ // proven equal already.
2170
+ if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
2171
+ const pkValue = normalized.get(pkColumn.name);
2172
+ const local =
2173
+ typeof pkValue === 'string' && pkValue.length > 0
2174
+ ? this.#db.query(
2175
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2176
+ [pkValue],
2177
+ )[0]
2178
+ : undefined;
2179
+ if (
2180
+ local === undefined ||
2181
+ !Object.is(
2182
+ rowValueToJson(
2183
+ fromSqlValue(column, local[column.name] ?? null),
2184
+ ),
2185
+ rowValueToJson(
2186
+ coerceSqlRepresentation(
2187
+ column,
2188
+ normalized.get(column.name),
2189
+ ) as RowValue,
2190
+ ),
2191
+ )
2192
+ ) {
2193
+ throw new ClientSyncError(
2194
+ 'sync.invalid_request',
2195
+ `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
2196
+ );
2197
+ }
2198
+ continue;
2163
2199
  }
2164
2200
  const value = normalized.get(column.name);
2165
2201
  if (value === undefined || value === null) {
@@ -2176,7 +2212,6 @@ export class SyncClient {
2176
2212
  coerceSqlRepresentation(column, value) as RowValue,
2177
2213
  );
2178
2214
  }
2179
- const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
2180
2215
  const pkValue = json[pkColumn.name];
2181
2216
  if (typeof pkValue !== 'string' || pkValue.length === 0) {
2182
2217
  throw new ClientSyncError(
@@ -2638,8 +2673,27 @@ export class SyncClient {
2638
2673
  try {
2639
2674
  pushFrames.push(
2640
2675
  // §5.11: encrypted columns are encrypted at this encode-at-send
2641
- // seam before the row codec serializes them.
2642
- await encodeOutboxCommit(this.#schema, commit, this.#encryption),
2676
+ // seam before the row codec serializes them. The stored local row
2677
+ // supplies an ABSENT key-id selector (§6.1 presence set first,
2678
+ // stored row second); an unresolvable key becomes a durable
2679
+ // rejection for that commit, never a sync() throw.
2680
+ await encodeOutboxCommit(
2681
+ this.#schema,
2682
+ commit,
2683
+ this.#encryption,
2684
+ (table, rowId) => {
2685
+ const selector = this.#encryption?.keyIdColumns?.[table.name];
2686
+ if (selector === undefined) return undefined;
2687
+ const row = this.#db.query(
2688
+ `SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2689
+ [rowId],
2690
+ )[0];
2691
+ const value = row?.value;
2692
+ return typeof value === 'string' && value.length > 0
2693
+ ? value
2694
+ : undefined;
2695
+ },
2696
+ ),
2643
2697
  );
2644
2698
  outbox.push(commit);
2645
2699
  ops += commit.operations.length;
@@ -2648,6 +2702,10 @@ export class SyncClient {
2648
2702
  this.#dropIncompatibleCommit(commit, error.message);
2649
2703
  continue;
2650
2704
  }
2705
+ if (error instanceof EncryptError) {
2706
+ this.#dropIncompatibleCommit(commit, error.message, error.code);
2707
+ continue;
2708
+ }
2651
2709
  throw error;
2652
2710
  }
2653
2711
  }
@@ -2658,14 +2716,19 @@ export class SyncClient {
2658
2716
  * §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
2659
2717
  * §7.2 `rejected` surface — the commit leaves the outbox, its
2660
2718
  * purely-optimistic rows are undone, and a rejection record is raised.
2719
+ * §5.11: the same drop carries an encode-seam `client.encrypt_failed`.
2661
2720
  */
2662
- #dropIncompatibleCommit(commit: OutboxCommit, message: string): void {
2721
+ #dropIncompatibleCommit(
2722
+ commit: OutboxCommit,
2723
+ message: string,
2724
+ code: string = OUTBOX_INCOMPATIBLE_CODE,
2725
+ ): void {
2663
2726
  this.#applyBatch((batch) => {
2664
2727
  this.#rollbackFailedCommit(commit, batch);
2665
2728
  const rejection: RejectionRecord = {
2666
2729
  clientCommitId: commit.clientCommitId,
2667
2730
  opIndex: 0,
2668
- code: OUTBOX_INCOMPATIBLE_CODE,
2731
+ code,
2669
2732
  message,
2670
2733
  retryable: false,
2671
2734
  ...(commit.operations[0] !== undefined
package/src/encryption.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type DeclaredType,
13
13
  DecryptError,
14
14
  decryptValue,
15
+ EncryptError,
15
16
  encryptValue,
16
17
  KEY_LENGTH,
17
18
  type NonceSource,
@@ -99,7 +100,7 @@ function encryptionKeyId(
99
100
  if (config.keyIdFor !== undefined) {
100
101
  const selected = config.keyIdFor(table.name, rowId, values);
101
102
  if (selected.length === 0) {
102
- throw new DecryptError(
103
+ throw new EncryptError(
103
104
  `empty encryption key id selected for table ${JSON.stringify(table.name)}`,
104
105
  );
105
106
  }
@@ -111,18 +112,18 @@ function encryptionKeyId(
111
112
  (column) => column.name === selectorColumn,
112
113
  );
113
114
  if (index < 0) {
114
- throw new DecryptError(
115
+ throw new EncryptError(
115
116
  `encryption key-id column ${JSON.stringify(selectorColumn)} is not present on table ${JSON.stringify(table.name)}`,
116
117
  );
117
118
  }
118
119
  if (table.columns[index]?.encrypted === true) {
119
- throw new DecryptError(
120
+ throw new EncryptError(
120
121
  `encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must not be encrypted`,
121
122
  );
122
123
  }
123
124
  const selected = values[index];
124
125
  if (typeof selected !== 'string' || selected.length === 0) {
125
- throw new DecryptError(
126
+ throw new EncryptError(
126
127
  `encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must contain a non-empty string`,
127
128
  );
128
129
  }
@@ -147,17 +148,40 @@ export async function encryptRowValues<T extends RowValue | undefined>(
147
148
  table: CompiledClientTable,
148
149
  rowId: string,
149
150
  values: readonly T[],
151
+ storedKeyId?: string,
150
152
  ): Promise<T[]> {
151
153
  if (!table.hasEncryptedColumns) return values.slice();
152
154
  const out = values.slice();
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
- );
155
+ // Sparse semantics: absent (undefined) and NULL columns are never
156
+ // re-encrypted, so a patch that presents no encrypted value needs no key.
157
+ let hasPresentEncrypted = false;
158
+ for (let i = 0; i < table.columns.length; i++) {
159
+ if (table.columns[i]?.encrypted !== true) continue;
160
+ const value = out[i];
161
+ if (value !== undefined && value !== null) {
162
+ hasPresentEncrypted = true;
163
+ break;
164
+ }
165
+ }
166
+ if (!hasPresentEncrypted) return out;
167
+ // Key selection reads the presence set first, then the stored local row.
168
+ // Absent slots densify to null for selection only; the loop below still
169
+ // leaves them untouched. Absent ≠ NULL (§6.1): only an ABSENT selector
170
+ // slot is filled from the stored row, never a present NULL.
171
+ const selected: RowValue[] = values.map((value) => value ?? null);
172
+ const selectorColumn = config.keyIdColumns?.[table.name];
173
+ if (
174
+ selectorColumn !== undefined &&
175
+ config.keyIdFor === undefined &&
176
+ storedKeyId !== undefined
177
+ ) {
178
+ const index = table.columns.findIndex(
179
+ (column) => column.name === selectorColumn,
180
+ );
181
+ if (index >= 0 && values[index] === undefined)
182
+ selected[index] = storedKeyId;
183
+ }
184
+ const keyId = encryptionKeyId(config, table, rowId, selected);
161
185
  for (let i = 0; i < table.columns.length; i++) {
162
186
  const column = table.columns[i];
163
187
  if (column === undefined || !column.encrypted) continue;
@@ -165,7 +189,7 @@ export async function encryptRowValues<T extends RowValue | undefined>(
165
189
  if (value === null || value === undefined) continue; // NULL stays NULL
166
190
  const key = config.keyProvider(keyId);
167
191
  if (key === undefined) {
168
- throw new DecryptError(
192
+ throw new EncryptError(
169
193
  `no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`,
170
194
  );
171
195
  }
package/src/outbox.ts CHANGED
@@ -237,12 +237,17 @@ function sparseValues(
237
237
  * schema's row codec (§6.1). When `encryption` is configured, encrypted
238
238
  * columns (§5.11) are encrypted here — the encode-at-send seam — before the
239
239
  * row codec serializes them as ciphertext-envelope `bytes`. Async because
240
- * WebCrypto is async.
240
+ * WebCrypto is async. `storedKeyIdFor` supplies the stored local row's
241
+ * key-id selector for a sparse upsert that omits that column (§5.11).
241
242
  */
242
243
  export async function encodeOutboxCommit(
243
244
  schema: CompiledClientSchema,
244
245
  commit: OutboxCommit,
245
246
  encryption?: EncryptionConfig,
247
+ storedKeyIdFor?: (
248
+ table: CompiledClientTable,
249
+ rowId: string,
250
+ ) => string | undefined,
246
251
  ): Promise<PushCommitFrame> {
247
252
  const operations: PushOperation[] = [];
248
253
  for (const op of commit.operations) {
@@ -274,7 +279,13 @@ export async function encodeOutboxCommit(
274
279
  if (encryption !== undefined && table.hasEncryptedColumns) {
275
280
  // Lazy: opt-in E2EE never enters an encryption-free app's bundle.
276
281
  const { encryptRowValues } = await import('./encryption');
277
- values = await encryptRowValues(encryption, table, op.rowId, values);
282
+ values = await encryptRowValues(
283
+ encryption,
284
+ table,
285
+ op.rowId,
286
+ values,
287
+ storedKeyIdFor?.(table, op.rowId),
288
+ );
278
289
  }
279
290
  operations.push({
280
291
  table: op.table,
@@ -196,15 +196,21 @@ function batchMatches<Row>(
196
196
  spec: ReactiveQuerySpec<Row>,
197
197
  ): boolean {
198
198
  for (const change of batch.tables) {
199
- const dependency = spec.dependencies.find(
200
- (candidate) => candidate.table === change.table,
201
- );
202
- if (dependency === undefined) continue;
203
- if (dependency.scopeKeys === undefined || change.scopeKeys === undefined) {
204
- return true;
205
- }
206
- for (const key of dependency.scopeKeys) {
207
- if (change.scopeKeys.has(key)) return true;
199
+ // A composed spec carries one dependency per query branch. Every
200
+ // dependency on the changed table is consulted: a scoped dependency
201
+ // that does not cover the change must not mask a sibling dependency
202
+ // on the same table that does.
203
+ for (const dependency of spec.dependencies) {
204
+ if (dependency.table !== change.table) continue;
205
+ if (
206
+ dependency.scopeKeys === undefined ||
207
+ change.scopeKeys === undefined
208
+ ) {
209
+ return true;
210
+ }
211
+ for (const key of dependency.scopeKeys) {
212
+ if (change.scopeKeys.has(key)) return true;
213
+ }
208
214
  }
209
215
  }
210
216
  for (const change of batch.windows) {
@@ -389,14 +395,28 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
389
395
  this.#onAvailabilityChange();
390
396
  if (this.spec.claimCoverage !== false) {
391
397
  const claims: Promise<void>[] = [];
398
+ // A composed spec carries one coverage entry per query branch, and
399
+ // several branches can target the same base. One claim per distinct
400
+ // base covers the union of their units; a later entry for the same
401
+ // base does not replace the earlier one.
402
+ const unitsByBase = new Map<
403
+ string,
404
+ { base: WindowBase; units: Set<string> }
405
+ >();
392
406
  for (const coverage of this.spec.coverage ?? []) {
393
- claims.push(
394
- this.store.setWindowClaim(
395
- this.#owner,
396
- coverage.base,
397
- coverage.units,
398
- ),
399
- );
407
+ const baseKey = windowBaseKey(coverage.base);
408
+ const existing = unitsByBase.get(baseKey);
409
+ if (existing === undefined) {
410
+ unitsByBase.set(baseKey, {
411
+ base: coverage.base,
412
+ units: new Set(coverage.units),
413
+ });
414
+ } else {
415
+ for (const unit of coverage.units) existing.units.add(unit);
416
+ }
417
+ }
418
+ for (const { base, units } of unitsByBase.values()) {
419
+ claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
400
420
  }
401
421
  const generation = this.#generation;
402
422
  this.#claimPending = claims.length > 0;
package/src/schema.ts CHANGED
@@ -121,6 +121,21 @@ export function compileClientSchema(
121
121
  `table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
122
122
  );
123
123
  }
124
+ // §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
125
+ // string every implementation produces identically and local storage
126
+ // resolves without deferring to a SQLite build.
127
+ const primaryKeyColumn = table.columns[primaryKeyIndex];
128
+ if (
129
+ primaryKeyColumn !== undefined &&
130
+ primaryKeyColumn.type !== 'string' &&
131
+ primaryKeyColumn.type !== 'integer' &&
132
+ primaryKeyColumn.type !== 'boolean' &&
133
+ primaryKeyColumn.type !== 'json'
134
+ ) {
135
+ throw new Error(
136
+ `table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`,
137
+ );
138
+ }
124
139
  if (table.scopes.length === 0) {
125
140
  throw new Error(
126
141
  `table ${table.name}: every synced table declares at least one scope pattern (§3.1)`,