@syncular/client 0.20.1 → 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/README.md +4 -4
- package/dist/client.d.ts +6 -6
- package/dist/client.js +120 -34
- package/dist/encryption.d.ts +1 -1
- package/dist/encryption.js +36 -9
- package/dist/outbox.d.ts +8 -7
- package/dist/outbox.js +9 -10
- package/dist/outcomes.d.ts +6 -0
- package/dist/outcomes.js +2 -0
- package/dist/remote.js +40 -6
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +4 -1
- package/package.json +3 -3
- package/src/client.ts +147 -39
- package/src/encryption.ts +42 -11
- package/src/outbox.ts +25 -14
- package/src/outcomes.ts +14 -1
- package/src/remote.ts +54 -7
- package/src/schema.ts +7 -1
package/README.md
CHANGED
|
@@ -321,10 +321,10 @@ they are never encoded on the wire or exposed through pending commits, outcome
|
|
|
321
321
|
envelopes, diagnostics, preferences, or telemetry.
|
|
322
322
|
|
|
323
323
|
Use `patch(table, rowId, partial, { baseVersion? })` for editor-style partial
|
|
324
|
-
updates. The wire
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
324
|
+
updates. The wire carries a sparse operation: the primary key plus the supplied
|
|
325
|
+
columns are present, and the durable local operation keeps the same presence
|
|
326
|
+
set so conflict and rejection UI knows which columns the user touched. A
|
|
327
|
+
full-row `mutate` marks every column present.
|
|
328
328
|
|
|
329
329
|
## Application-authorized local security purge
|
|
330
330
|
|
package/dist/client.d.ts
CHANGED
|
@@ -415,12 +415,12 @@ export declare class SyncClient {
|
|
|
415
415
|
/** Host-facing mutation result with explicit network work intent (§7.5). */
|
|
416
416
|
mutateCommand(mutations: readonly MutationInput[]): CommandResult<string>;
|
|
417
417
|
/**
|
|
418
|
-
* Partial
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
418
|
+
* Partial update over the §6.1 sparse wire: record one upsert whose
|
|
419
|
+
* presence set is the primary key plus the supplied non-scope columns.
|
|
420
|
+
* `partial` keys follow the same two-casing rule as mutation values
|
|
421
|
+
* (snake_case or camelCase). Absent columns keep their stored value on
|
|
422
|
+
* the server and in the local overlay; when the local row is absent the
|
|
423
|
+
* overlay leaves it absent (§7.1) and the server answers per §5.2.
|
|
424
424
|
*/
|
|
425
425
|
patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
|
|
426
426
|
readonly baseVersion?: number;
|
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';
|
|
@@ -22,7 +23,7 @@ import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, }
|
|
|
22
23
|
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, iterateOutbox, countOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
|
|
23
24
|
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
24
25
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
25
|
-
import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
26
|
+
import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
26
27
|
import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
|
|
27
28
|
import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
|
|
28
29
|
/** Stable client-local error while protected operations are preflight-gated. */
|
|
@@ -1390,10 +1391,10 @@ export class SyncClient {
|
|
|
1390
1391
|
mutate(mutations) {
|
|
1391
1392
|
return this.#recordMutations(mutations);
|
|
1392
1393
|
}
|
|
1393
|
-
#recordMutations(mutations,
|
|
1394
|
+
#recordMutations(mutations, partial = false) {
|
|
1394
1395
|
this.#requireActive();
|
|
1395
1396
|
const clientCommitId = crypto.randomUUID();
|
|
1396
|
-
const operations = mutations.map((mutation
|
|
1397
|
+
const operations = mutations.map((mutation) => {
|
|
1397
1398
|
const table = this.#table(mutation.table);
|
|
1398
1399
|
if (mutation.op === 'delete') {
|
|
1399
1400
|
return {
|
|
@@ -1405,6 +1406,45 @@ export class SyncClient {
|
|
|
1405
1406
|
: {}),
|
|
1406
1407
|
};
|
|
1407
1408
|
}
|
|
1409
|
+
if (partial) {
|
|
1410
|
+
// §6.7 patch: the values map IS the presence set — the primary key
|
|
1411
|
+
// plus the supplied non-scope columns. Absent columns keep their
|
|
1412
|
+
// stored value; the overlay and the server both apply only the
|
|
1413
|
+
// present ones.
|
|
1414
|
+
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
1415
|
+
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
1416
|
+
const json = {};
|
|
1417
|
+
for (const column of table.columns) {
|
|
1418
|
+
if (!normalized.has(column.name))
|
|
1419
|
+
continue;
|
|
1420
|
+
if (scopeColumns.has(column.name)) {
|
|
1421
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
|
|
1422
|
+
}
|
|
1423
|
+
const value = normalized.get(column.name);
|
|
1424
|
+
if (value === undefined || value === null) {
|
|
1425
|
+
if (!column.nullable) {
|
|
1426
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`);
|
|
1427
|
+
}
|
|
1428
|
+
json[column.name] = null;
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
json[column.name] = rowValueToJson(coerceSqlRepresentation(column, value));
|
|
1432
|
+
}
|
|
1433
|
+
const pkColumn = table.columns[table.primaryKeyIndex];
|
|
1434
|
+
const pkValue = json[pkColumn.name];
|
|
1435
|
+
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
1436
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: upsert requires a non-empty string primary key`);
|
|
1437
|
+
}
|
|
1438
|
+
return {
|
|
1439
|
+
table: mutation.table,
|
|
1440
|
+
rowId: pkValue,
|
|
1441
|
+
op: 'upsert',
|
|
1442
|
+
...(mutation.baseVersion !== undefined
|
|
1443
|
+
? { baseVersion: mutation.baseVersion }
|
|
1444
|
+
: {}),
|
|
1445
|
+
values: json,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1408
1448
|
const values = recordToRowValues(table, mutation.values);
|
|
1409
1449
|
const pkValue = values[table.primaryKeyIndex];
|
|
1410
1450
|
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
@@ -1422,9 +1462,6 @@ export class SyncClient {
|
|
|
1422
1462
|
? { baseVersion: mutation.baseVersion }
|
|
1423
1463
|
: {}),
|
|
1424
1464
|
values: json,
|
|
1425
|
-
...(changedFieldsByIndex[index] !== undefined
|
|
1426
|
-
? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
|
|
1427
|
-
: {}),
|
|
1428
1465
|
};
|
|
1429
1466
|
});
|
|
1430
1467
|
this.#applyBatch((batch) => {
|
|
@@ -1471,40 +1508,35 @@ export class SyncClient {
|
|
|
1471
1508
|
};
|
|
1472
1509
|
}
|
|
1473
1510
|
/**
|
|
1474
|
-
* Partial
|
|
1475
|
-
*
|
|
1476
|
-
*
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1511
|
+
* Partial update over the §6.1 sparse wire: record one upsert whose
|
|
1512
|
+
* presence set is the primary key plus the supplied non-scope columns.
|
|
1513
|
+
* `partial` keys follow the same two-casing rule as mutation values
|
|
1514
|
+
* (snake_case or camelCase). Absent columns keep their stored value on
|
|
1515
|
+
* the server and in the local overlay; when the local row is absent the
|
|
1516
|
+
* overlay leaves it absent (§7.1) and the server answers per §5.2.
|
|
1480
1517
|
*/
|
|
1481
1518
|
patch(table, rowId, partial, options) {
|
|
1482
1519
|
this.#requireActive();
|
|
1483
1520
|
const compiled = this.#table(table);
|
|
1484
1521
|
const pkColumn = compiled.columns[compiled.primaryKeyIndex];
|
|
1485
|
-
const
|
|
1486
|
-
const
|
|
1487
|
-
if (
|
|
1488
|
-
throw new ClientSyncError('sync.invalid_request', `table ${compiled.name}:
|
|
1489
|
-
}
|
|
1490
|
-
const record = {};
|
|
1491
|
-
for (const column of compiled.columns) {
|
|
1492
|
-
record[column.name] = fromSqlValue(column, row[column.name] ?? null);
|
|
1493
|
-
}
|
|
1494
|
-
const normalizedPartial = normalizeRecordKeys(compiled, partial);
|
|
1495
|
-
for (const [name, value] of normalizedPartial) {
|
|
1496
|
-
record[name] = value;
|
|
1522
|
+
const normalized = normalizeRecordKeys(compiled, partial);
|
|
1523
|
+
const pkOverride = normalized.get(pkColumn.name);
|
|
1524
|
+
if (pkOverride !== undefined && pkOverride !== rowId) {
|
|
1525
|
+
throw new ClientSyncError('sync.invalid_request', `table ${compiled.name}: patch cannot change the primary key`);
|
|
1497
1526
|
}
|
|
1498
1527
|
return this.#recordMutations([
|
|
1499
1528
|
{
|
|
1500
1529
|
table,
|
|
1501
1530
|
op: 'upsert',
|
|
1502
|
-
values:
|
|
1531
|
+
values: {
|
|
1532
|
+
...Object.fromEntries(normalized),
|
|
1533
|
+
[pkColumn.name]: rowId,
|
|
1534
|
+
},
|
|
1503
1535
|
...(options?.baseVersion !== undefined
|
|
1504
1536
|
? { baseVersion: options.baseVersion }
|
|
1505
1537
|
: {}),
|
|
1506
1538
|
},
|
|
1507
|
-
],
|
|
1539
|
+
], true);
|
|
1508
1540
|
}
|
|
1509
1541
|
/**
|
|
1510
1542
|
* Apply one host-authorized local security purge. The host must stop/gate
|
|
@@ -1776,8 +1808,20 @@ export class SyncClient {
|
|
|
1776
1808
|
try {
|
|
1777
1809
|
pushFrames.push(
|
|
1778
1810
|
// §5.11: encrypted columns are encrypted at this encode-at-send
|
|
1779
|
-
// seam before the row codec serializes them.
|
|
1780
|
-
|
|
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
|
+
}));
|
|
1781
1825
|
outbox.push(commit);
|
|
1782
1826
|
ops += commit.operations.length;
|
|
1783
1827
|
}
|
|
@@ -1786,6 +1830,10 @@ export class SyncClient {
|
|
|
1786
1830
|
this.#dropIncompatibleCommit(commit, error.message);
|
|
1787
1831
|
continue;
|
|
1788
1832
|
}
|
|
1833
|
+
if (error instanceof EncryptError) {
|
|
1834
|
+
this.#dropIncompatibleCommit(commit, error.message, error.code);
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1789
1837
|
throw error;
|
|
1790
1838
|
}
|
|
1791
1839
|
}
|
|
@@ -1795,14 +1843,15 @@ export class SyncClient {
|
|
|
1795
1843
|
* §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
|
|
1796
1844
|
* §7.2 `rejected` surface — the commit leaves the outbox, its
|
|
1797
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`.
|
|
1798
1847
|
*/
|
|
1799
|
-
#dropIncompatibleCommit(commit, message) {
|
|
1848
|
+
#dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE) {
|
|
1800
1849
|
this.#applyBatch((batch) => {
|
|
1801
1850
|
this.#rollbackFailedCommit(commit, batch);
|
|
1802
1851
|
const rejection = {
|
|
1803
1852
|
clientCommitId: commit.clientCommitId,
|
|
1804
1853
|
opIndex: 0,
|
|
1805
|
-
code
|
|
1854
|
+
code,
|
|
1806
1855
|
message,
|
|
1807
1856
|
retryable: false,
|
|
1808
1857
|
...(commit.operations[0] !== undefined
|
|
@@ -2688,6 +2737,7 @@ export class SyncClient {
|
|
|
2688
2737
|
message: result.message,
|
|
2689
2738
|
serverVersion: result.serverVersion,
|
|
2690
2739
|
serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
|
|
2740
|
+
conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
|
|
2691
2741
|
...(operation !== undefined ? { operation } : {}),
|
|
2692
2742
|
};
|
|
2693
2743
|
this.#conflicts.push(conflict);
|
|
@@ -2750,6 +2800,25 @@ export class SyncClient {
|
|
|
2750
2800
|
});
|
|
2751
2801
|
return record;
|
|
2752
2802
|
}
|
|
2803
|
+
/**
|
|
2804
|
+
* §6.3 `conflictColumns`: decode the presence-layout bitmap into the names
|
|
2805
|
+
* of the present columns whose `column_version` exceeded `baseVersion`. A
|
|
2806
|
+
* custom merge recomputes exactly these columns (§6.5).
|
|
2807
|
+
*/
|
|
2808
|
+
#decodeConflictColumns(tableName, bitmap) {
|
|
2809
|
+
if (tableName === undefined)
|
|
2810
|
+
return [];
|
|
2811
|
+
const table = this.#schema.tables.get(tableName);
|
|
2812
|
+
if (table === undefined)
|
|
2813
|
+
return [];
|
|
2814
|
+
const names = [];
|
|
2815
|
+
table.columns.forEach((column, index) => {
|
|
2816
|
+
const byte = bitmap[index >> 3] ?? 0;
|
|
2817
|
+
if (((byte >> (index & 7)) & 1) === 1)
|
|
2818
|
+
names.push(column.name);
|
|
2819
|
+
});
|
|
2820
|
+
return names;
|
|
2821
|
+
}
|
|
2753
2822
|
async #applyCommit(frame, summary) {
|
|
2754
2823
|
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
2755
2824
|
this.#recordCommitChanges(batch, frame);
|
|
@@ -3146,9 +3215,25 @@ export class SyncClient {
|
|
|
3146
3215
|
deleteLocalRow(this.#db, table, op.rowId);
|
|
3147
3216
|
continue;
|
|
3148
3217
|
}
|
|
3218
|
+
// §7.1 overlay: apply the operation's PRESENT columns over the current
|
|
3219
|
+
// local row. A partial operation over an absent local row leaves it
|
|
3220
|
+
// absent — the server answers it with sync.row_deleted or
|
|
3221
|
+
// sync.row_missing, and the local mirror never invents a base.
|
|
3222
|
+
const opValues = op.values ?? {};
|
|
3223
|
+
const full = table.columns.every((column) => column.name in opValues);
|
|
3224
|
+
const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [op.rowId])[0];
|
|
3225
|
+
if (local === undefined && !full) {
|
|
3226
|
+
if (batch !== undefined && !precise)
|
|
3227
|
+
batch.table(op.table);
|
|
3228
|
+
continue;
|
|
3229
|
+
}
|
|
3149
3230
|
const values = table.columns.map((column) => {
|
|
3150
|
-
const value =
|
|
3151
|
-
|
|
3231
|
+
const value = opValues[column.name];
|
|
3232
|
+
if (value !== undefined)
|
|
3233
|
+
return jsonToRowValue(value);
|
|
3234
|
+
return local === undefined
|
|
3235
|
+
? null
|
|
3236
|
+
: fromSqlValue(column, local[column.name] ?? null);
|
|
3152
3237
|
});
|
|
3153
3238
|
// Record the row's scope keys from its scope columns (I2 refinement).
|
|
3154
3239
|
if (batch !== undefined) {
|
|
@@ -3164,8 +3249,9 @@ export class SyncClient {
|
|
|
3164
3249
|
if (!precise)
|
|
3165
3250
|
batch.table(table.name);
|
|
3166
3251
|
}
|
|
3167
|
-
const
|
|
3168
|
-
|
|
3252
|
+
const version = local === undefined
|
|
3253
|
+
? OPTIMISTIC_VERSION
|
|
3254
|
+
: local[SYNC_VERSION_COLUMN];
|
|
3169
3255
|
upsertLocalRow(this.#db, table, values, version);
|
|
3170
3256
|
}
|
|
3171
3257
|
}
|
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(config: EncryptionConfig, table: CompiledClientTable, rowId: string, values: readonly
|
|
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,11 +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
|
-
|
|
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);
|
|
76
103
|
for (let i = 0; i < table.columns.length; i++) {
|
|
77
104
|
const column = table.columns[i];
|
|
78
105
|
if (column === undefined || !column.encrypted)
|
|
@@ -82,9 +109,9 @@ export async function encryptRowValues(config, table, rowId, values) {
|
|
|
82
109
|
continue; // NULL stays NULL
|
|
83
110
|
const key = config.keyProvider(keyId);
|
|
84
111
|
if (key === undefined) {
|
|
85
|
-
throw new
|
|
112
|
+
throw new EncryptError(`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`);
|
|
86
113
|
}
|
|
87
|
-
out[i] = await encryptValue(declaredTypeOf(column), value, keyId, key, config.nonceSource);
|
|
114
|
+
out[i] = (await encryptValue(declaredTypeOf(column), value, keyId, key, config.nonceSource));
|
|
88
115
|
}
|
|
89
116
|
return out;
|
|
90
117
|
}
|
package/dist/outbox.d.ts
CHANGED
|
@@ -16,13 +16,13 @@ export interface OutboxOperation {
|
|
|
16
16
|
readonly op: 'upsert' | 'delete';
|
|
17
17
|
/** Optimistic-concurrency token (§6.2); absent = last-write-wins. */
|
|
18
18
|
readonly baseVersion?: number;
|
|
19
|
-
/** Full-row values keyed by column name; present iff `op` is `upsert`. */
|
|
20
|
-
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
21
19
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* The PRESENT columns keyed by column name; present iff `op` is `upsert`.
|
|
21
|
+
* A key the operation writes carries its value; a column the operation
|
|
22
|
+
* leaves unchanged has no key (§6.1 sparse rows, §7.2.1: intent is the
|
|
23
|
+
* presence set — `mutate` marks every column, `patch` the supplied ones).
|
|
24
24
|
*/
|
|
25
|
-
readonly
|
|
25
|
+
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
26
26
|
}
|
|
27
27
|
export interface OutboxCommit {
|
|
28
28
|
readonly seq: number;
|
|
@@ -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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* request is built — a commit recorded under schema N replays after an
|
|
6
6
|
* upgrade to N+1 by re-encoding.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { encodeSparseRow, } from '@syncular/core';
|
|
9
9
|
import { ClientSyncError } from './errors.js';
|
|
10
10
|
import { jsonToRowValue, } from './schema.js';
|
|
11
11
|
export function appendOutboxCommit(db, clientCommitId, operations, nowMs, beforeImages = []) {
|
|
@@ -114,7 +114,7 @@ export class OutboxEncodeError extends ClientSyncError {
|
|
|
114
114
|
super('sync.outbox_incompatible', message, false);
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
-
function
|
|
117
|
+
function sparseValues(table, values) {
|
|
118
118
|
// Any persisted key that is not a column of the CURRENT schema means the
|
|
119
119
|
// bump removed (or renamed) it — the commit cannot be expressed now.
|
|
120
120
|
for (const key of Object.keys(values)) {
|
|
@@ -124,9 +124,7 @@ function orderedValues(table, values) {
|
|
|
124
124
|
}
|
|
125
125
|
return table.columns.map((column) => {
|
|
126
126
|
const value = values[column.name];
|
|
127
|
-
|
|
128
|
-
return null;
|
|
129
|
-
return jsonToRowValue(value);
|
|
127
|
+
return value === undefined ? undefined : jsonToRowValue(value);
|
|
130
128
|
});
|
|
131
129
|
}
|
|
132
130
|
/**
|
|
@@ -134,9 +132,10 @@ function orderedValues(table, values) {
|
|
|
134
132
|
* schema's row codec (§6.1). When `encryption` is configured, encrypted
|
|
135
133
|
* columns (§5.11) are encrypted here — the encode-at-send seam — before the
|
|
136
134
|
* row codec serializes them as ciphertext-envelope `bytes`. Async because
|
|
137
|
-
* 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).
|
|
138
137
|
*/
|
|
139
|
-
export async function encodeOutboxCommit(schema, commit, encryption) {
|
|
138
|
+
export async function encodeOutboxCommit(schema, commit, encryption, storedKeyIdFor) {
|
|
140
139
|
const operations = [];
|
|
141
140
|
for (const op of commit.operations) {
|
|
142
141
|
if (op.op === 'delete') {
|
|
@@ -158,18 +157,18 @@ export async function encodeOutboxCommit(schema, commit, encryption) {
|
|
|
158
157
|
if (op.values === undefined) {
|
|
159
158
|
throw new ClientSyncError('sync.invalid_request', `outbox upsert on ${op.table}/${op.rowId} has no values`);
|
|
160
159
|
}
|
|
161
|
-
let values =
|
|
160
|
+
let values = sparseValues(table, op.values);
|
|
162
161
|
if (encryption !== undefined && table.hasEncryptedColumns) {
|
|
163
162
|
// Lazy: opt-in E2EE never enters an encryption-free app's bundle.
|
|
164
163
|
const { encryptRowValues } = await import('./encryption.js');
|
|
165
|
-
values = await encryptRowValues(encryption, table, op.rowId, values);
|
|
164
|
+
values = await encryptRowValues(encryption, table, op.rowId, values, storedKeyIdFor?.(table, op.rowId));
|
|
166
165
|
}
|
|
167
166
|
operations.push({
|
|
168
167
|
table: op.table,
|
|
169
168
|
rowId: op.rowId,
|
|
170
169
|
op: 'upsert',
|
|
171
170
|
...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
|
|
172
|
-
payload:
|
|
171
|
+
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
173
172
|
});
|
|
174
173
|
}
|
|
175
174
|
return {
|
package/dist/outcomes.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface ConflictRecord {
|
|
|
19
19
|
readonly serverVersion: number;
|
|
20
20
|
/** The current server row, decoded — resolve without a round-trip. */
|
|
21
21
|
readonly serverRow: Readonly<Record<string, RowValue>>;
|
|
22
|
+
/**
|
|
23
|
+
* §6.3: the present columns whose `column_version` exceeded the losing
|
|
24
|
+
* operation's `baseVersion`. A custom merge recomputes exactly these;
|
|
25
|
+
* keep-server and keep-local ignore them (§6.5).
|
|
26
|
+
*/
|
|
27
|
+
readonly conflictColumns: readonly string[];
|
|
22
28
|
/** The losing local operation (absent only for malformed op indexes). */
|
|
23
29
|
readonly operation?: OutboxOperation;
|
|
24
30
|
}
|
package/dist/outcomes.js
CHANGED
|
@@ -26,6 +26,8 @@ function decodeResults(raw) {
|
|
|
26
26
|
status: 'conflict',
|
|
27
27
|
conflict: {
|
|
28
28
|
...result.conflict,
|
|
29
|
+
// Pre-column-version journal entries carry no conflictColumns.
|
|
30
|
+
conflictColumns: result.conflict.conflictColumns ?? [],
|
|
29
31
|
serverRow: Object.fromEntries(Object.entries(result.conflict.serverRow).map(([key, value]) => [
|
|
30
32
|
key,
|
|
31
33
|
jsonToRowValue(value),
|
package/dist/remote.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Database-less SSP2 producer (§6.10). It prepares and sends ordinary commits
|
|
3
3
|
* through the existing push path without creating a local replica or outbox.
|
|
4
4
|
*/
|
|
5
|
-
import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage,
|
|
5
|
+
import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
|
|
6
6
|
import { encryptRowValues } from './encryption.js';
|
|
7
7
|
import { ClientSyncError } from './errors.js';
|
|
8
8
|
import { compileClientSchema, recordToRowValues, } from './schema.js';
|
|
@@ -71,6 +71,7 @@ export class SyncRemoteClient {
|
|
|
71
71
|
#operationSocketGeneration = 0;
|
|
72
72
|
#watches = new Map();
|
|
73
73
|
#encryption;
|
|
74
|
+
/** Pinned by config, or acquired on the first prepared commit (§2.1). */
|
|
74
75
|
#logEpoch;
|
|
75
76
|
constructor(config) {
|
|
76
77
|
if (config.clientId.length === 0) {
|
|
@@ -101,6 +102,41 @@ export class SyncRemoteClient {
|
|
|
101
102
|
if (input.mutations.length === 0) {
|
|
102
103
|
throw new ClientSyncError('sync.empty_commit', 'a remote commit must carry at least one mutation (§6.1)');
|
|
103
104
|
}
|
|
105
|
+
let logEpoch = this.#logEpoch;
|
|
106
|
+
if (logEpoch === undefined) {
|
|
107
|
+
// §2.1: a push round binds to the partition log epoch; an acquisition
|
|
108
|
+
// round carries no push commits.
|
|
109
|
+
if (this.#transport === undefined) {
|
|
110
|
+
throw new ClientSyncError('client.remote_sync_unconfigured', 'SyncRemoteClient has no sync transport for ordinary commits');
|
|
111
|
+
}
|
|
112
|
+
const acquisition = decodeMessage(await this.#transport(encodeMessage({
|
|
113
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
114
|
+
msgKind: 'request',
|
|
115
|
+
frames: [
|
|
116
|
+
{
|
|
117
|
+
type: 'REQ_HEADER',
|
|
118
|
+
clientId: this.#clientId,
|
|
119
|
+
schemaVersion: schema.version,
|
|
120
|
+
},
|
|
121
|
+
// A pull that asks for nothing: the round exists for its epoch.
|
|
122
|
+
{
|
|
123
|
+
type: 'PULL_HEADER',
|
|
124
|
+
limitCommits: 0,
|
|
125
|
+
limitSnapshotRows: 0,
|
|
126
|
+
maxSnapshotPages: 0,
|
|
127
|
+
accept: 0b0011,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
})));
|
|
131
|
+
const acquisitionHeader = acquisition.frames[0];
|
|
132
|
+
if (acquisition.msgKind !== 'response' ||
|
|
133
|
+
acquisitionHeader?.type !== 'RESP_HEADER' ||
|
|
134
|
+
acquisitionHeader.logEpoch === undefined) {
|
|
135
|
+
throw new ClientSyncError('client.invalid_host_response', 'remote epoch acquisition returned an invalid response');
|
|
136
|
+
}
|
|
137
|
+
logEpoch = acquisitionHeader.logEpoch;
|
|
138
|
+
this.#logEpoch = logEpoch;
|
|
139
|
+
}
|
|
104
140
|
const operations = [];
|
|
105
141
|
for (const mutation of input.mutations) {
|
|
106
142
|
const table = schema.tables.get(mutation.table);
|
|
@@ -136,22 +172,20 @@ export class SyncRemoteClient {
|
|
|
136
172
|
...(mutation.baseVersion !== undefined
|
|
137
173
|
? { baseVersion: mutation.baseVersion }
|
|
138
174
|
: {}),
|
|
139
|
-
payload:
|
|
175
|
+
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
140
176
|
});
|
|
141
177
|
}
|
|
142
178
|
return {
|
|
143
179
|
requestId: input.requestId,
|
|
144
180
|
bytes: encodeMessage({
|
|
145
|
-
wireVersion:
|
|
181
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
146
182
|
msgKind: 'request',
|
|
147
183
|
frames: [
|
|
148
184
|
{
|
|
149
185
|
type: 'REQ_HEADER',
|
|
150
186
|
clientId: this.#clientId,
|
|
151
187
|
schemaVersion: schema.version,
|
|
152
|
-
|
|
153
|
-
? { logEpoch: this.#logEpoch }
|
|
154
|
-
: {}),
|
|
188
|
+
logEpoch,
|
|
155
189
|
},
|
|
156
190
|
{
|
|
157
191
|
type: 'PUSH_COMMIT',
|
package/dist/schema.d.ts
CHANGED
|
@@ -138,6 +138,14 @@ export declare function fromSqlValue(column: RowColumn, value: SqlValue): RowVal
|
|
|
138
138
|
* reserved `_sync_*` names); giving one column in both casings is an error.
|
|
139
139
|
*/
|
|
140
140
|
export declare function normalizeRecordKeys(table: CompiledClientTable, record: Readonly<Record<string, unknown>>): Map<string, unknown>;
|
|
141
|
+
/**
|
|
142
|
+
* Accept the local SQL representation of a value alongside the app-facing
|
|
143
|
+
* one, so a row read straight off the mirror (`SELECT *`) feeds back into
|
|
144
|
+
* `mutate()` without per-column fixups: SQLite stores booleans as 0/1 and
|
|
145
|
+
* may surface integers as bigint. Anything else passes through untouched —
|
|
146
|
+
* the codec still fails loud on genuine type garbage at encode time.
|
|
147
|
+
*/
|
|
148
|
+
export declare function coerceSqlRepresentation(column: RowColumn, value: unknown): unknown;
|
|
141
149
|
/**
|
|
142
150
|
* App-facing record → schema-ordered row values for the codec and the
|
|
143
151
|
* local mirror. Missing keys become NULL; unknown keys fail loud (see
|
package/dist/schema.js
CHANGED
|
@@ -195,6 +195,9 @@ function sqlType(column) {
|
|
|
195
195
|
/** §7.4.1 persisted local schema-version marker (`_syncular_meta` key). */
|
|
196
196
|
export const LOCAL_SCHEMA_VERSION_KEY = 'localSchemaVersion';
|
|
197
197
|
function createSyncedTable(db, table) {
|
|
198
|
+
// §6.11: a declared reference is deliberately NOT emitted here. Windowing
|
|
199
|
+
// evicts a parent independently of its children, so local SQLite never
|
|
200
|
+
// enforces a reference; the server enforces it.
|
|
198
201
|
const columns = table.columns.map((column) => {
|
|
199
202
|
const notNull = column.nullable ? '' : ' NOT NULL';
|
|
200
203
|
const pk = column.name === table.primaryKey ? ' PRIMARY KEY' : '';
|
|
@@ -513,7 +516,7 @@ export function normalizeRecordKeys(table, record) {
|
|
|
513
516
|
* may surface integers as bigint. Anything else passes through untouched —
|
|
514
517
|
* the codec still fails loud on genuine type garbage at encode time.
|
|
515
518
|
*/
|
|
516
|
-
function coerceSqlRepresentation(column, value) {
|
|
519
|
+
export function coerceSqlRepresentation(column, value) {
|
|
517
520
|
switch (localColumnType(column)) {
|
|
518
521
|
case 'boolean':
|
|
519
522
|
return value === 0 ? false : value === 1 ? true : value;
|