@syncular/client 0.21.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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';
@@ -1807,8 +1808,20 @@ export class SyncClient {
1807
1808
  try {
1808
1809
  pushFrames.push(
1809
1810
  // §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));
1811
+ // seam before the row codec serializes them. The stored local row
1812
+ // supplies an ABSENT key-id selector (§6.1 presence set first,
1813
+ // stored row second); an unresolvable key becomes a durable
1814
+ // rejection for that commit, never a sync() throw.
1815
+ await encodeOutboxCommit(this.#schema, commit, this.#encryption, (table, rowId) => {
1816
+ const selector = this.#encryption?.keyIdColumns?.[table.name];
1817
+ if (selector === undefined)
1818
+ return undefined;
1819
+ const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId])[0];
1820
+ const value = row?.value;
1821
+ return typeof value === 'string' && value.length > 0
1822
+ ? value
1823
+ : undefined;
1824
+ }));
1812
1825
  outbox.push(commit);
1813
1826
  ops += commit.operations.length;
1814
1827
  }
@@ -1817,6 +1830,10 @@ export class SyncClient {
1817
1830
  this.#dropIncompatibleCommit(commit, error.message);
1818
1831
  continue;
1819
1832
  }
1833
+ if (error instanceof EncryptError) {
1834
+ this.#dropIncompatibleCommit(commit, error.message, error.code);
1835
+ continue;
1836
+ }
1820
1837
  throw error;
1821
1838
  }
1822
1839
  }
@@ -1826,14 +1843,15 @@ export class SyncClient {
1826
1843
  * §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
1827
1844
  * §7.2 `rejected` surface — the commit leaves the outbox, its
1828
1845
  * purely-optimistic rows are undone, and a rejection record is raised.
1846
+ * §5.11: the same drop carries an encode-seam `client.encrypt_failed`.
1829
1847
  */
1830
- #dropIncompatibleCommit(commit, message) {
1848
+ #dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE) {
1831
1849
  this.#applyBatch((batch) => {
1832
1850
  this.#rollbackFailedCommit(commit, batch);
1833
1851
  const rejection = {
1834
1852
  clientCommitId: commit.clientCommitId,
1835
1853
  opIndex: 0,
1836
- code: OUTBOX_INCOMPATIBLE_CODE,
1854
+ code,
1837
1855
  message,
1838
1856
  retryable: false,
1839
1857
  ...(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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
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.21.1"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.21.0",
95
+ "@syncular/server": "0.21.1",
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,
@@ -2638,8 +2639,27 @@ export class SyncClient {
2638
2639
  try {
2639
2640
  pushFrames.push(
2640
2641
  // §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),
2642
+ // seam before the row codec serializes them. The stored local row
2643
+ // supplies an ABSENT key-id selector (§6.1 presence set first,
2644
+ // stored row second); an unresolvable key becomes a durable
2645
+ // rejection for that commit, never a sync() throw.
2646
+ await encodeOutboxCommit(
2647
+ this.#schema,
2648
+ commit,
2649
+ this.#encryption,
2650
+ (table, rowId) => {
2651
+ const selector = this.#encryption?.keyIdColumns?.[table.name];
2652
+ if (selector === undefined) return undefined;
2653
+ const row = this.#db.query(
2654
+ `SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2655
+ [rowId],
2656
+ )[0];
2657
+ const value = row?.value;
2658
+ return typeof value === 'string' && value.length > 0
2659
+ ? value
2660
+ : undefined;
2661
+ },
2662
+ ),
2643
2663
  );
2644
2664
  outbox.push(commit);
2645
2665
  ops += commit.operations.length;
@@ -2648,6 +2668,10 @@ export class SyncClient {
2648
2668
  this.#dropIncompatibleCommit(commit, error.message);
2649
2669
  continue;
2650
2670
  }
2671
+ if (error instanceof EncryptError) {
2672
+ this.#dropIncompatibleCommit(commit, error.message, error.code);
2673
+ continue;
2674
+ }
2651
2675
  throw error;
2652
2676
  }
2653
2677
  }
@@ -2658,14 +2682,19 @@ export class SyncClient {
2658
2682
  * §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
2659
2683
  * §7.2 `rejected` surface — the commit leaves the outbox, its
2660
2684
  * purely-optimistic rows are undone, and a rejection record is raised.
2685
+ * §5.11: the same drop carries an encode-seam `client.encrypt_failed`.
2661
2686
  */
2662
- #dropIncompatibleCommit(commit: OutboxCommit, message: string): void {
2687
+ #dropIncompatibleCommit(
2688
+ commit: OutboxCommit,
2689
+ message: string,
2690
+ code: string = OUTBOX_INCOMPATIBLE_CODE,
2691
+ ): void {
2663
2692
  this.#applyBatch((batch) => {
2664
2693
  this.#rollbackFailedCommit(commit, batch);
2665
2694
  const rejection: RejectionRecord = {
2666
2695
  clientCommitId: commit.clientCommitId,
2667
2696
  opIndex: 0,
2668
- code: OUTBOX_INCOMPATIBLE_CODE,
2697
+ code,
2669
2698
  message,
2670
2699
  retryable: false,
2671
2700
  ...(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,