@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 +43 -7
- package/dist/encryption.d.ts +1 -1
- package/dist/encryption.js +35 -10
- package/dist/outbox.d.ts +3 -2
- package/dist/outbox.js +4 -3
- package/dist/reactive-store.js +34 -9
- package/dist/schema.js +11 -0
- package/package.json +3 -3
- package/src/client.ts +73 -10
- package/src/encryption.ts +37 -13
- package/src/outbox.ts +13 -2
- package/src/reactive-store.ts +36 -16
- package/src/schema.ts +15 -0
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
|
-
|
|
1420
|
-
|
|
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
|
-
|
|
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
|
|
1872
|
+
code,
|
|
1837
1873
|
message,
|
|
1838
1874
|
retryable: false,
|
|
1839
1875
|
...(commit.operations[0] !== undefined
|
package/dist/encryption.d.ts
CHANGED
|
@@ -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
|
package/dist/encryption.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
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
|
|
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/dist/reactive-store.js
CHANGED
|
@@ -82,15 +82,21 @@ function scheduleMicrotask(task) {
|
|
|
82
82
|
}
|
|
83
83
|
function batchMatches(batch, spec) {
|
|
84
84
|
for (const change of batch.tables) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
92
|
+
"@syncular/core": "0.22.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "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
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
table
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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(
|
|
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,
|
package/src/reactive-store.ts
CHANGED
|
@@ -196,15 +196,21 @@ function batchMatches<Row>(
|
|
|
196
196
|
spec: ReactiveQuerySpec<Row>,
|
|
197
197
|
): boolean {
|
|
198
198
|
for (const change of batch.tables) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
coverage.
|
|
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)`,
|