@syncular/client 0.20.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/client.d.ts +6 -6
- package/dist/client.js +98 -30
- package/dist/encryption.d.ts +1 -1
- package/dist/encryption.js +4 -2
- package/dist/outbox.d.ts +5 -5
- package/dist/outbox.js +5 -7
- 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 +114 -35
- package/src/encryption.ts +13 -6
- package/src/outbox.ts +12 -12
- 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
|
@@ -22,7 +22,7 @@ import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, }
|
|
|
22
22
|
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, iterateOutbox, countOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
|
|
23
23
|
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
24
24
|
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';
|
|
25
|
+
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
26
|
import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
|
|
27
27
|
import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
|
|
28
28
|
/** Stable client-local error while protected operations are preflight-gated. */
|
|
@@ -1390,10 +1390,10 @@ export class SyncClient {
|
|
|
1390
1390
|
mutate(mutations) {
|
|
1391
1391
|
return this.#recordMutations(mutations);
|
|
1392
1392
|
}
|
|
1393
|
-
#recordMutations(mutations,
|
|
1393
|
+
#recordMutations(mutations, partial = false) {
|
|
1394
1394
|
this.#requireActive();
|
|
1395
1395
|
const clientCommitId = crypto.randomUUID();
|
|
1396
|
-
const operations = mutations.map((mutation
|
|
1396
|
+
const operations = mutations.map((mutation) => {
|
|
1397
1397
|
const table = this.#table(mutation.table);
|
|
1398
1398
|
if (mutation.op === 'delete') {
|
|
1399
1399
|
return {
|
|
@@ -1405,6 +1405,45 @@ export class SyncClient {
|
|
|
1405
1405
|
: {}),
|
|
1406
1406
|
};
|
|
1407
1407
|
}
|
|
1408
|
+
if (partial) {
|
|
1409
|
+
// §6.7 patch: the values map IS the presence set — the primary key
|
|
1410
|
+
// plus the supplied non-scope columns. Absent columns keep their
|
|
1411
|
+
// stored value; the overlay and the server both apply only the
|
|
1412
|
+
// present ones.
|
|
1413
|
+
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
1414
|
+
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
1415
|
+
const json = {};
|
|
1416
|
+
for (const column of table.columns) {
|
|
1417
|
+
if (!normalized.has(column.name))
|
|
1418
|
+
continue;
|
|
1419
|
+
if (scopeColumns.has(column.name)) {
|
|
1420
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
|
|
1421
|
+
}
|
|
1422
|
+
const value = normalized.get(column.name);
|
|
1423
|
+
if (value === undefined || value === null) {
|
|
1424
|
+
if (!column.nullable) {
|
|
1425
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`);
|
|
1426
|
+
}
|
|
1427
|
+
json[column.name] = null;
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
json[column.name] = rowValueToJson(coerceSqlRepresentation(column, value));
|
|
1431
|
+
}
|
|
1432
|
+
const pkColumn = table.columns[table.primaryKeyIndex];
|
|
1433
|
+
const pkValue = json[pkColumn.name];
|
|
1434
|
+
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
1435
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: upsert requires a non-empty string primary key`);
|
|
1436
|
+
}
|
|
1437
|
+
return {
|
|
1438
|
+
table: mutation.table,
|
|
1439
|
+
rowId: pkValue,
|
|
1440
|
+
op: 'upsert',
|
|
1441
|
+
...(mutation.baseVersion !== undefined
|
|
1442
|
+
? { baseVersion: mutation.baseVersion }
|
|
1443
|
+
: {}),
|
|
1444
|
+
values: json,
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1408
1447
|
const values = recordToRowValues(table, mutation.values);
|
|
1409
1448
|
const pkValue = values[table.primaryKeyIndex];
|
|
1410
1449
|
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
@@ -1422,9 +1461,6 @@ export class SyncClient {
|
|
|
1422
1461
|
? { baseVersion: mutation.baseVersion }
|
|
1423
1462
|
: {}),
|
|
1424
1463
|
values: json,
|
|
1425
|
-
...(changedFieldsByIndex[index] !== undefined
|
|
1426
|
-
? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
|
|
1427
|
-
: {}),
|
|
1428
1464
|
};
|
|
1429
1465
|
});
|
|
1430
1466
|
this.#applyBatch((batch) => {
|
|
@@ -1471,40 +1507,35 @@ export class SyncClient {
|
|
|
1471
1507
|
};
|
|
1472
1508
|
}
|
|
1473
1509
|
/**
|
|
1474
|
-
* Partial
|
|
1475
|
-
*
|
|
1476
|
-
*
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1510
|
+
* Partial update over the §6.1 sparse wire: record one upsert whose
|
|
1511
|
+
* presence set is the primary key plus the supplied non-scope columns.
|
|
1512
|
+
* `partial` keys follow the same two-casing rule as mutation values
|
|
1513
|
+
* (snake_case or camelCase). Absent columns keep their stored value on
|
|
1514
|
+
* the server and in the local overlay; when the local row is absent the
|
|
1515
|
+
* overlay leaves it absent (§7.1) and the server answers per §5.2.
|
|
1480
1516
|
*/
|
|
1481
1517
|
patch(table, rowId, partial, options) {
|
|
1482
1518
|
this.#requireActive();
|
|
1483
1519
|
const compiled = this.#table(table);
|
|
1484
1520
|
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;
|
|
1521
|
+
const normalized = normalizeRecordKeys(compiled, partial);
|
|
1522
|
+
const pkOverride = normalized.get(pkColumn.name);
|
|
1523
|
+
if (pkOverride !== undefined && pkOverride !== rowId) {
|
|
1524
|
+
throw new ClientSyncError('sync.invalid_request', `table ${compiled.name}: patch cannot change the primary key`);
|
|
1497
1525
|
}
|
|
1498
1526
|
return this.#recordMutations([
|
|
1499
1527
|
{
|
|
1500
1528
|
table,
|
|
1501
1529
|
op: 'upsert',
|
|
1502
|
-
values:
|
|
1530
|
+
values: {
|
|
1531
|
+
...Object.fromEntries(normalized),
|
|
1532
|
+
[pkColumn.name]: rowId,
|
|
1533
|
+
},
|
|
1503
1534
|
...(options?.baseVersion !== undefined
|
|
1504
1535
|
? { baseVersion: options.baseVersion }
|
|
1505
1536
|
: {}),
|
|
1506
1537
|
},
|
|
1507
|
-
],
|
|
1538
|
+
], true);
|
|
1508
1539
|
}
|
|
1509
1540
|
/**
|
|
1510
1541
|
* Apply one host-authorized local security purge. The host must stop/gate
|
|
@@ -2688,6 +2719,7 @@ export class SyncClient {
|
|
|
2688
2719
|
message: result.message,
|
|
2689
2720
|
serverVersion: result.serverVersion,
|
|
2690
2721
|
serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
|
|
2722
|
+
conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
|
|
2691
2723
|
...(operation !== undefined ? { operation } : {}),
|
|
2692
2724
|
};
|
|
2693
2725
|
this.#conflicts.push(conflict);
|
|
@@ -2750,6 +2782,25 @@ export class SyncClient {
|
|
|
2750
2782
|
});
|
|
2751
2783
|
return record;
|
|
2752
2784
|
}
|
|
2785
|
+
/**
|
|
2786
|
+
* §6.3 `conflictColumns`: decode the presence-layout bitmap into the names
|
|
2787
|
+
* of the present columns whose `column_version` exceeded `baseVersion`. A
|
|
2788
|
+
* custom merge recomputes exactly these columns (§6.5).
|
|
2789
|
+
*/
|
|
2790
|
+
#decodeConflictColumns(tableName, bitmap) {
|
|
2791
|
+
if (tableName === undefined)
|
|
2792
|
+
return [];
|
|
2793
|
+
const table = this.#schema.tables.get(tableName);
|
|
2794
|
+
if (table === undefined)
|
|
2795
|
+
return [];
|
|
2796
|
+
const names = [];
|
|
2797
|
+
table.columns.forEach((column, index) => {
|
|
2798
|
+
const byte = bitmap[index >> 3] ?? 0;
|
|
2799
|
+
if (((byte >> (index & 7)) & 1) === 1)
|
|
2800
|
+
names.push(column.name);
|
|
2801
|
+
});
|
|
2802
|
+
return names;
|
|
2803
|
+
}
|
|
2753
2804
|
async #applyCommit(frame, summary) {
|
|
2754
2805
|
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
2755
2806
|
this.#recordCommitChanges(batch, frame);
|
|
@@ -3146,9 +3197,25 @@ export class SyncClient {
|
|
|
3146
3197
|
deleteLocalRow(this.#db, table, op.rowId);
|
|
3147
3198
|
continue;
|
|
3148
3199
|
}
|
|
3200
|
+
// §7.1 overlay: apply the operation's PRESENT columns over the current
|
|
3201
|
+
// local row. A partial operation over an absent local row leaves it
|
|
3202
|
+
// absent — the server answers it with sync.row_deleted or
|
|
3203
|
+
// sync.row_missing, and the local mirror never invents a base.
|
|
3204
|
+
const opValues = op.values ?? {};
|
|
3205
|
+
const full = table.columns.every((column) => column.name in opValues);
|
|
3206
|
+
const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [op.rowId])[0];
|
|
3207
|
+
if (local === undefined && !full) {
|
|
3208
|
+
if (batch !== undefined && !precise)
|
|
3209
|
+
batch.table(op.table);
|
|
3210
|
+
continue;
|
|
3211
|
+
}
|
|
3149
3212
|
const values = table.columns.map((column) => {
|
|
3150
|
-
const value =
|
|
3151
|
-
|
|
3213
|
+
const value = opValues[column.name];
|
|
3214
|
+
if (value !== undefined)
|
|
3215
|
+
return jsonToRowValue(value);
|
|
3216
|
+
return local === undefined
|
|
3217
|
+
? null
|
|
3218
|
+
: fromSqlValue(column, local[column.name] ?? null);
|
|
3152
3219
|
});
|
|
3153
3220
|
// Record the row's scope keys from its scope columns (I2 refinement).
|
|
3154
3221
|
if (batch !== undefined) {
|
|
@@ -3164,8 +3231,9 @@ export class SyncClient {
|
|
|
3164
3231
|
if (!precise)
|
|
3165
3232
|
batch.table(table.name);
|
|
3166
3233
|
}
|
|
3167
|
-
const
|
|
3168
|
-
|
|
3234
|
+
const version = local === undefined
|
|
3235
|
+
? OPTIMISTIC_VERSION
|
|
3236
|
+
: local[SYNC_VERSION_COLUMN];
|
|
3169
3237
|
upsertLocalRow(this.#db, table, values, version);
|
|
3170
3238
|
}
|
|
3171
3239
|
}
|
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[]): 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
|
@@ -72,7 +72,9 @@ export async function encryptRowValues(config, table, rowId, values) {
|
|
|
72
72
|
if (!table.hasEncryptedColumns)
|
|
73
73
|
return values.slice();
|
|
74
74
|
const out = values.slice();
|
|
75
|
-
|
|
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));
|
|
76
78
|
for (let i = 0; i < table.columns.length; i++) {
|
|
77
79
|
const column = table.columns[i];
|
|
78
80
|
if (column === undefined || !column.encrypted)
|
|
@@ -84,7 +86,7 @@ export async function encryptRowValues(config, table, rowId, values) {
|
|
|
84
86
|
if (key === undefined) {
|
|
85
87
|
throw new DecryptError(`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`);
|
|
86
88
|
}
|
|
87
|
-
out[i] = await encryptValue(declaredTypeOf(column), value, keyId, key, config.nonceSource);
|
|
89
|
+
out[i] = (await encryptValue(declaredTypeOf(column), value, keyId, key, config.nonceSource));
|
|
88
90
|
}
|
|
89
91
|
return out;
|
|
90
92
|
}
|
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;
|
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
|
/**
|
|
@@ -158,7 +156,7 @@ export async function encodeOutboxCommit(schema, commit, encryption) {
|
|
|
158
156
|
if (op.values === undefined) {
|
|
159
157
|
throw new ClientSyncError('sync.invalid_request', `outbox upsert on ${op.table}/${op.rowId} has no values`);
|
|
160
158
|
}
|
|
161
|
-
let values =
|
|
159
|
+
let values = sparseValues(table, op.values);
|
|
162
160
|
if (encryption !== undefined && table.hasEncryptedColumns) {
|
|
163
161
|
// Lazy: opt-in E2EE never enters an encryption-free app's bundle.
|
|
164
162
|
const { encryptRowValues } = await import('./encryption.js');
|
|
@@ -169,7 +167,7 @@ export async function encodeOutboxCommit(schema, commit, encryption) {
|
|
|
169
167
|
rowId: op.rowId,
|
|
170
168
|
op: 'upsert',
|
|
171
169
|
...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
|
|
172
|
-
payload:
|
|
170
|
+
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
173
171
|
});
|
|
174
172
|
}
|
|
175
173
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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.21.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.21.0",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -158,6 +158,8 @@ import {
|
|
|
158
158
|
ensureLocalBookkeepingSchema,
|
|
159
159
|
ensureLocalSyncedSchema,
|
|
160
160
|
fromSqlValue,
|
|
161
|
+
coerceSqlRepresentation,
|
|
162
|
+
type JsonRowValue,
|
|
161
163
|
jsonToRowValue,
|
|
162
164
|
LOCAL_SCHEMA_VERSION_KEY,
|
|
163
165
|
normalizeRecordKeys,
|
|
@@ -2127,11 +2129,11 @@ export class SyncClient {
|
|
|
2127
2129
|
|
|
2128
2130
|
#recordMutations(
|
|
2129
2131
|
mutations: readonly MutationInput[],
|
|
2130
|
-
|
|
2132
|
+
partial = false,
|
|
2131
2133
|
): string {
|
|
2132
2134
|
this.#requireActive();
|
|
2133
2135
|
const clientCommitId = crypto.randomUUID();
|
|
2134
|
-
const operations: OutboxOperation[] = mutations.map((mutation
|
|
2136
|
+
const operations: OutboxOperation[] = mutations.map((mutation) => {
|
|
2135
2137
|
const table = this.#table(mutation.table);
|
|
2136
2138
|
if (mutation.op === 'delete') {
|
|
2137
2139
|
return {
|
|
@@ -2143,6 +2145,55 @@ export class SyncClient {
|
|
|
2143
2145
|
: {}),
|
|
2144
2146
|
};
|
|
2145
2147
|
}
|
|
2148
|
+
if (partial) {
|
|
2149
|
+
// §6.7 patch: the values map IS the presence set — the primary key
|
|
2150
|
+
// plus the supplied non-scope columns. Absent columns keep their
|
|
2151
|
+
// stored value; the overlay and the server both apply only the
|
|
2152
|
+
// present ones.
|
|
2153
|
+
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
2154
|
+
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
2155
|
+
const json: Record<string, JsonRowValue> = {};
|
|
2156
|
+
for (const column of table.columns) {
|
|
2157
|
+
if (!normalized.has(column.name)) continue;
|
|
2158
|
+
if (scopeColumns.has(column.name)) {
|
|
2159
|
+
throw new ClientSyncError(
|
|
2160
|
+
'sync.invalid_request',
|
|
2161
|
+
`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
const value = normalized.get(column.name);
|
|
2165
|
+
if (value === undefined || value === null) {
|
|
2166
|
+
if (!column.nullable) {
|
|
2167
|
+
throw new ClientSyncError(
|
|
2168
|
+
'sync.invalid_request',
|
|
2169
|
+
`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`,
|
|
2170
|
+
);
|
|
2171
|
+
}
|
|
2172
|
+
json[column.name] = null;
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
json[column.name] = rowValueToJson(
|
|
2176
|
+
coerceSqlRepresentation(column, value) as RowValue,
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
|
|
2180
|
+
const pkValue = json[pkColumn.name];
|
|
2181
|
+
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
2182
|
+
throw new ClientSyncError(
|
|
2183
|
+
'sync.invalid_request',
|
|
2184
|
+
`table ${table.name}: upsert requires a non-empty string primary key`,
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
return {
|
|
2188
|
+
table: mutation.table,
|
|
2189
|
+
rowId: pkValue,
|
|
2190
|
+
op: 'upsert',
|
|
2191
|
+
...(mutation.baseVersion !== undefined
|
|
2192
|
+
? { baseVersion: mutation.baseVersion }
|
|
2193
|
+
: {}),
|
|
2194
|
+
values: json,
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2146
2197
|
const values = recordToRowValues(table, mutation.values);
|
|
2147
2198
|
const pkValue = values[table.primaryKeyIndex];
|
|
2148
2199
|
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
@@ -2163,9 +2214,6 @@ export class SyncClient {
|
|
|
2163
2214
|
? { baseVersion: mutation.baseVersion }
|
|
2164
2215
|
: {}),
|
|
2165
2216
|
values: json,
|
|
2166
|
-
...(changedFieldsByIndex[index] !== undefined
|
|
2167
|
-
? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
|
|
2168
|
-
: {}),
|
|
2169
2217
|
};
|
|
2170
2218
|
});
|
|
2171
2219
|
this.#applyBatch((batch) => {
|
|
@@ -2218,12 +2266,12 @@ export class SyncClient {
|
|
|
2218
2266
|
}
|
|
2219
2267
|
|
|
2220
2268
|
/**
|
|
2221
|
-
* Partial
|
|
2222
|
-
*
|
|
2223
|
-
*
|
|
2224
|
-
*
|
|
2225
|
-
*
|
|
2226
|
-
*
|
|
2269
|
+
* Partial update over the §6.1 sparse wire: record one upsert whose
|
|
2270
|
+
* presence set is the primary key plus the supplied non-scope columns.
|
|
2271
|
+
* `partial` keys follow the same two-casing rule as mutation values
|
|
2272
|
+
* (snake_case or camelCase). Absent columns keep their stored value on
|
|
2273
|
+
* the server and in the local overlay; when the local row is absent the
|
|
2274
|
+
* overlay leaves it absent (§7.1) and the server answers per §5.2.
|
|
2227
2275
|
*/
|
|
2228
2276
|
patch(
|
|
2229
2277
|
table: string,
|
|
@@ -2234,37 +2282,29 @@ export class SyncClient {
|
|
|
2234
2282
|
this.#requireActive();
|
|
2235
2283
|
const compiled = this.#table(table);
|
|
2236
2284
|
const pkColumn = compiled.columns[compiled.primaryKeyIndex] as RowColumn;
|
|
2237
|
-
const
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
);
|
|
2241
|
-
const row = rows[0];
|
|
2242
|
-
if (row === undefined) {
|
|
2285
|
+
const normalized = normalizeRecordKeys(compiled, partial);
|
|
2286
|
+
const pkOverride = normalized.get(pkColumn.name);
|
|
2287
|
+
if (pkOverride !== undefined && pkOverride !== rowId) {
|
|
2243
2288
|
throw new ClientSyncError(
|
|
2244
2289
|
'sync.invalid_request',
|
|
2245
|
-
`table ${compiled.name}:
|
|
2290
|
+
`table ${compiled.name}: patch cannot change the primary key`,
|
|
2246
2291
|
);
|
|
2247
2292
|
}
|
|
2248
|
-
const record: Record<string, unknown> = {};
|
|
2249
|
-
for (const column of compiled.columns as readonly RowColumn[]) {
|
|
2250
|
-
record[column.name] = fromSqlValue(column, row[column.name] ?? null);
|
|
2251
|
-
}
|
|
2252
|
-
const normalizedPartial = normalizeRecordKeys(compiled, partial);
|
|
2253
|
-
for (const [name, value] of normalizedPartial) {
|
|
2254
|
-
record[name] = value;
|
|
2255
|
-
}
|
|
2256
2293
|
return this.#recordMutations(
|
|
2257
2294
|
[
|
|
2258
2295
|
{
|
|
2259
2296
|
table,
|
|
2260
2297
|
op: 'upsert',
|
|
2261
|
-
values:
|
|
2298
|
+
values: {
|
|
2299
|
+
...Object.fromEntries(normalized),
|
|
2300
|
+
[pkColumn.name]: rowId,
|
|
2301
|
+
},
|
|
2262
2302
|
...(options?.baseVersion !== undefined
|
|
2263
2303
|
? { baseVersion: options.baseVersion }
|
|
2264
2304
|
: {}),
|
|
2265
2305
|
},
|
|
2266
2306
|
],
|
|
2267
|
-
|
|
2307
|
+
true,
|
|
2268
2308
|
);
|
|
2269
2309
|
}
|
|
2270
2310
|
|
|
@@ -3735,6 +3775,10 @@ export class SyncClient {
|
|
|
3735
3775
|
message: result.message,
|
|
3736
3776
|
serverVersion: result.serverVersion,
|
|
3737
3777
|
serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
|
|
3778
|
+
conflictColumns: this.#decodeConflictColumns(
|
|
3779
|
+
operation?.table,
|
|
3780
|
+
result.conflictColumns,
|
|
3781
|
+
),
|
|
3738
3782
|
...(operation !== undefined ? { operation } : {}),
|
|
3739
3783
|
};
|
|
3740
3784
|
this.#conflicts.push(conflict);
|
|
@@ -3804,6 +3848,26 @@ export class SyncClient {
|
|
|
3804
3848
|
return record;
|
|
3805
3849
|
}
|
|
3806
3850
|
|
|
3851
|
+
/**
|
|
3852
|
+
* §6.3 `conflictColumns`: decode the presence-layout bitmap into the names
|
|
3853
|
+
* of the present columns whose `column_version` exceeded `baseVersion`. A
|
|
3854
|
+
* custom merge recomputes exactly these columns (§6.5).
|
|
3855
|
+
*/
|
|
3856
|
+
#decodeConflictColumns(
|
|
3857
|
+
tableName: string | undefined,
|
|
3858
|
+
bitmap: Uint8Array,
|
|
3859
|
+
): readonly string[] {
|
|
3860
|
+
if (tableName === undefined) return [];
|
|
3861
|
+
const table = this.#schema.tables.get(tableName);
|
|
3862
|
+
if (table === undefined) return [];
|
|
3863
|
+
const names: string[] = [];
|
|
3864
|
+
table.columns.forEach((column, index) => {
|
|
3865
|
+
const byte = bitmap[index >> 3] ?? 0;
|
|
3866
|
+
if (((byte >> (index & 7)) & 1) === 1) names.push(column.name);
|
|
3867
|
+
});
|
|
3868
|
+
return names;
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3807
3871
|
async #applyCommit(
|
|
3808
3872
|
frame: CommitFrame,
|
|
3809
3873
|
summary: MutableSummary,
|
|
@@ -4318,9 +4382,26 @@ export class SyncClient {
|
|
|
4318
4382
|
deleteLocalRow(this.#db, table, op.rowId);
|
|
4319
4383
|
continue;
|
|
4320
4384
|
}
|
|
4385
|
+
// §7.1 overlay: apply the operation's PRESENT columns over the current
|
|
4386
|
+
// local row. A partial operation over an absent local row leaves it
|
|
4387
|
+
// absent — the server answers it with sync.row_deleted or
|
|
4388
|
+
// sync.row_missing, and the local mirror never invents a base.
|
|
4389
|
+
const opValues = op.values ?? {};
|
|
4390
|
+
const full = table.columns.every((column) => column.name in opValues);
|
|
4391
|
+
const local = this.#db.query(
|
|
4392
|
+
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
4393
|
+
[op.rowId],
|
|
4394
|
+
)[0];
|
|
4395
|
+
if (local === undefined && !full) {
|
|
4396
|
+
if (batch !== undefined && !precise) batch.table(op.table);
|
|
4397
|
+
continue;
|
|
4398
|
+
}
|
|
4321
4399
|
const values = table.columns.map((column) => {
|
|
4322
|
-
const value =
|
|
4323
|
-
|
|
4400
|
+
const value = opValues[column.name];
|
|
4401
|
+
if (value !== undefined) return jsonToRowValue(value);
|
|
4402
|
+
return local === undefined
|
|
4403
|
+
? null
|
|
4404
|
+
: fromSqlValue(column, local[column.name] ?? null);
|
|
4324
4405
|
});
|
|
4325
4406
|
// Record the row's scope keys from its scope columns (I2 refinement).
|
|
4326
4407
|
if (batch !== undefined) {
|
|
@@ -4335,12 +4416,10 @@ export class SyncClient {
|
|
|
4335
4416
|
}
|
|
4336
4417
|
if (!precise) batch.table(table.name);
|
|
4337
4418
|
}
|
|
4338
|
-
const existing = this.#db.query(
|
|
4339
|
-
`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
4340
|
-
[op.rowId],
|
|
4341
|
-
)[0];
|
|
4342
4419
|
const version =
|
|
4343
|
-
|
|
4420
|
+
local === undefined
|
|
4421
|
+
? OPTIMISTIC_VERSION
|
|
4422
|
+
: (local[SYNC_VERSION_COLUMN] as number);
|
|
4344
4423
|
upsertLocalRow(this.#db, table, values, version);
|
|
4345
4424
|
}
|
|
4346
4425
|
}
|
package/src/encryption.ts
CHANGED
|
@@ -142,15 +142,22 @@ function declaredTypeOf(column: RowColumn): DeclaredType {
|
|
|
142
142
|
* value becomes the §5.11 ciphertext-envelope `bytes`. NULLs pass through
|
|
143
143
|
* unencrypted (§5.11).
|
|
144
144
|
*/
|
|
145
|
-
export async function encryptRowValues(
|
|
145
|
+
export async function encryptRowValues<T extends RowValue | undefined>(
|
|
146
146
|
config: EncryptionConfig,
|
|
147
147
|
table: CompiledClientTable,
|
|
148
148
|
rowId: string,
|
|
149
|
-
values: readonly
|
|
150
|
-
): Promise<
|
|
149
|
+
values: readonly T[],
|
|
150
|
+
): Promise<T[]> {
|
|
151
151
|
if (!table.hasEncryptedColumns) return values.slice();
|
|
152
152
|
const out = values.slice();
|
|
153
|
-
|
|
153
|
+
// A sparse operation's absent columns are densified to null for key
|
|
154
|
+
// selection only; the encryption loop leaves them untouched.
|
|
155
|
+
const keyId = encryptionKeyId(
|
|
156
|
+
config,
|
|
157
|
+
table,
|
|
158
|
+
rowId,
|
|
159
|
+
values.map((value) => value ?? null),
|
|
160
|
+
);
|
|
154
161
|
for (let i = 0; i < table.columns.length; i++) {
|
|
155
162
|
const column = table.columns[i];
|
|
156
163
|
if (column === undefined || !column.encrypted) continue;
|
|
@@ -162,13 +169,13 @@ export async function encryptRowValues(
|
|
|
162
169
|
`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`,
|
|
163
170
|
);
|
|
164
171
|
}
|
|
165
|
-
out[i] = await encryptValue(
|
|
172
|
+
out[i] = (await encryptValue(
|
|
166
173
|
declaredTypeOf(column),
|
|
167
174
|
value as PlainValue,
|
|
168
175
|
keyId,
|
|
169
176
|
key,
|
|
170
177
|
config.nonceSource,
|
|
171
|
-
);
|
|
178
|
+
)) as T;
|
|
172
179
|
}
|
|
173
180
|
return out;
|
|
174
181
|
}
|
package/src/outbox.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* upgrade to N+1 by re-encoding.
|
|
7
7
|
*/
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
encodeSparseRow,
|
|
10
10
|
type PushCommitFrame,
|
|
11
11
|
type PushOperation,
|
|
12
12
|
type ScopeMap,
|
|
13
|
+
type SparseRowValue,
|
|
13
14
|
} from '@syncular/core';
|
|
14
15
|
import type { ClientDatabase, SqlRow } from './database';
|
|
15
16
|
import type { EncryptionConfig } from './encryption';
|
|
@@ -27,13 +28,13 @@ export interface OutboxOperation {
|
|
|
27
28
|
readonly op: 'upsert' | 'delete';
|
|
28
29
|
/** Optimistic-concurrency token (§6.2); absent = last-write-wins. */
|
|
29
30
|
readonly baseVersion?: number;
|
|
30
|
-
/** Full-row values keyed by column name; present iff `op` is `upsert`. */
|
|
31
|
-
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
32
31
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* The PRESENT columns keyed by column name; present iff `op` is `upsert`.
|
|
33
|
+
* A key the operation writes carries its value; a column the operation
|
|
34
|
+
* leaves unchanged has no key (§6.1 sparse rows, §7.2.1: intent is the
|
|
35
|
+
* presence set — `mutate` marks every column, `patch` the supplied ones).
|
|
35
36
|
*/
|
|
36
|
-
readonly
|
|
37
|
+
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export interface OutboxCommit {
|
|
@@ -212,10 +213,10 @@ export class OutboxEncodeError extends ClientSyncError {
|
|
|
212
213
|
}
|
|
213
214
|
}
|
|
214
215
|
|
|
215
|
-
function
|
|
216
|
+
function sparseValues(
|
|
216
217
|
table: CompiledClientTable,
|
|
217
218
|
values: Readonly<Record<string, JsonRowValue>>,
|
|
218
|
-
) {
|
|
219
|
+
): SparseRowValue[] {
|
|
219
220
|
// Any persisted key that is not a column of the CURRENT schema means the
|
|
220
221
|
// bump removed (or renamed) it — the commit cannot be expressed now.
|
|
221
222
|
for (const key of Object.keys(values)) {
|
|
@@ -227,8 +228,7 @@ function orderedValues(
|
|
|
227
228
|
}
|
|
228
229
|
return table.columns.map((column) => {
|
|
229
230
|
const value = values[column.name];
|
|
230
|
-
|
|
231
|
-
return jsonToRowValue(value);
|
|
231
|
+
return value === undefined ? undefined : jsonToRowValue(value);
|
|
232
232
|
});
|
|
233
233
|
}
|
|
234
234
|
|
|
@@ -270,7 +270,7 @@ export async function encodeOutboxCommit(
|
|
|
270
270
|
`outbox upsert on ${op.table}/${op.rowId} has no values`,
|
|
271
271
|
);
|
|
272
272
|
}
|
|
273
|
-
let values =
|
|
273
|
+
let values = sparseValues(table, op.values);
|
|
274
274
|
if (encryption !== undefined && table.hasEncryptedColumns) {
|
|
275
275
|
// Lazy: opt-in E2EE never enters an encryption-free app's bundle.
|
|
276
276
|
const { encryptRowValues } = await import('./encryption');
|
|
@@ -281,7 +281,7 @@ export async function encodeOutboxCommit(
|
|
|
281
281
|
rowId: op.rowId,
|
|
282
282
|
op: 'upsert',
|
|
283
283
|
...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
|
|
284
|
-
payload:
|
|
284
|
+
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
285
285
|
});
|
|
286
286
|
}
|
|
287
287
|
return {
|
package/src/outcomes.ts
CHANGED
|
@@ -22,6 +22,12 @@ export interface ConflictRecord {
|
|
|
22
22
|
readonly serverVersion: number;
|
|
23
23
|
/** The current server row, decoded — resolve without a round-trip. */
|
|
24
24
|
readonly serverRow: Readonly<Record<string, RowValue>>;
|
|
25
|
+
/**
|
|
26
|
+
* §6.3: the present columns whose `column_version` exceeded the losing
|
|
27
|
+
* operation's `baseVersion`. A custom merge recomputes exactly these;
|
|
28
|
+
* keep-server and keep-local ignore them (§6.5).
|
|
29
|
+
*/
|
|
30
|
+
readonly conflictColumns: readonly string[];
|
|
25
31
|
/** The losing local operation (absent only for malformed op indexes). */
|
|
26
32
|
readonly operation?: OutboxOperation;
|
|
27
33
|
}
|
|
@@ -94,8 +100,13 @@ export interface ResolveCommitOutcomeInput {
|
|
|
94
100
|
readonly replacementClientCommitId?: string;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
interface StoredConflictRecord extends Omit<
|
|
103
|
+
interface StoredConflictRecord extends Omit<
|
|
104
|
+
ConflictRecord,
|
|
105
|
+
'serverRow' | 'conflictColumns'
|
|
106
|
+
> {
|
|
98
107
|
readonly serverRow: Readonly<Record<string, JsonRowValue>>;
|
|
108
|
+
/** Absent in journal entries written before wire version 3. */
|
|
109
|
+
readonly conflictColumns?: readonly string[];
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
type StoredCommitOperationOutcome =
|
|
@@ -130,6 +141,8 @@ function decodeResults(raw: string): CommitOperationOutcome[] {
|
|
|
130
141
|
status: 'conflict',
|
|
131
142
|
conflict: {
|
|
132
143
|
...result.conflict,
|
|
144
|
+
// Pre-column-version journal entries carry no conflictColumns.
|
|
145
|
+
conflictColumns: result.conflict.conflictColumns ?? [],
|
|
133
146
|
serverRow: Object.fromEntries(
|
|
134
147
|
Object.entries(result.conflict.serverRow).map(([key, value]) => [
|
|
135
148
|
key,
|
package/src/remote.ts
CHANGED
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
encodeMessage,
|
|
10
10
|
encodeRemoteOperationRequest,
|
|
11
11
|
encodeRemoteOperationRealtimeMessage,
|
|
12
|
-
|
|
12
|
+
encodeSparseRow,
|
|
13
|
+
PROTOCOL_WIRE_VERSION,
|
|
13
14
|
type PushOperation,
|
|
14
15
|
type PushOperationResult,
|
|
15
16
|
type PushResultDetailsFrame,
|
|
@@ -204,7 +205,8 @@ export class SyncRemoteClient {
|
|
|
204
205
|
}
|
|
205
206
|
>();
|
|
206
207
|
readonly #encryption: EncryptionConfig | undefined;
|
|
207
|
-
|
|
208
|
+
/** Pinned by config, or acquired on the first prepared commit (§2.1). */
|
|
209
|
+
#logEpoch: string | undefined;
|
|
208
210
|
|
|
209
211
|
constructor(config: SyncRemoteClientConfig) {
|
|
210
212
|
if (config.clientId.length === 0) {
|
|
@@ -242,6 +244,53 @@ export class SyncRemoteClient {
|
|
|
242
244
|
'a remote commit must carry at least one mutation (§6.1)',
|
|
243
245
|
);
|
|
244
246
|
}
|
|
247
|
+
let logEpoch = this.#logEpoch;
|
|
248
|
+
if (logEpoch === undefined) {
|
|
249
|
+
// §2.1: a push round binds to the partition log epoch; an acquisition
|
|
250
|
+
// round carries no push commits.
|
|
251
|
+
if (this.#transport === undefined) {
|
|
252
|
+
throw new ClientSyncError(
|
|
253
|
+
'client.remote_sync_unconfigured',
|
|
254
|
+
'SyncRemoteClient has no sync transport for ordinary commits',
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const acquisition = decodeMessage(
|
|
258
|
+
await this.#transport(
|
|
259
|
+
encodeMessage({
|
|
260
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
261
|
+
msgKind: 'request',
|
|
262
|
+
frames: [
|
|
263
|
+
{
|
|
264
|
+
type: 'REQ_HEADER',
|
|
265
|
+
clientId: this.#clientId,
|
|
266
|
+
schemaVersion: schema.version,
|
|
267
|
+
},
|
|
268
|
+
// A pull that asks for nothing: the round exists for its epoch.
|
|
269
|
+
{
|
|
270
|
+
type: 'PULL_HEADER',
|
|
271
|
+
limitCommits: 0,
|
|
272
|
+
limitSnapshotRows: 0,
|
|
273
|
+
maxSnapshotPages: 0,
|
|
274
|
+
accept: 0b0011,
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
}),
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
const acquisitionHeader = acquisition.frames[0];
|
|
281
|
+
if (
|
|
282
|
+
acquisition.msgKind !== 'response' ||
|
|
283
|
+
acquisitionHeader?.type !== 'RESP_HEADER' ||
|
|
284
|
+
acquisitionHeader.logEpoch === undefined
|
|
285
|
+
) {
|
|
286
|
+
throw new ClientSyncError(
|
|
287
|
+
'client.invalid_host_response',
|
|
288
|
+
'remote epoch acquisition returned an invalid response',
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
logEpoch = acquisitionHeader.logEpoch;
|
|
292
|
+
this.#logEpoch = logEpoch;
|
|
293
|
+
}
|
|
245
294
|
const operations: PushOperation[] = [];
|
|
246
295
|
for (const mutation of input.mutations) {
|
|
247
296
|
const table = schema.tables.get(mutation.table);
|
|
@@ -277,22 +326,20 @@ export class SyncRemoteClient {
|
|
|
277
326
|
...(mutation.baseVersion !== undefined
|
|
278
327
|
? { baseVersion: mutation.baseVersion }
|
|
279
328
|
: {}),
|
|
280
|
-
payload:
|
|
329
|
+
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
281
330
|
});
|
|
282
331
|
}
|
|
283
332
|
return {
|
|
284
333
|
requestId: input.requestId,
|
|
285
334
|
bytes: encodeMessage({
|
|
286
|
-
wireVersion:
|
|
335
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
287
336
|
msgKind: 'request',
|
|
288
337
|
frames: [
|
|
289
338
|
{
|
|
290
339
|
type: 'REQ_HEADER',
|
|
291
340
|
clientId: this.#clientId,
|
|
292
341
|
schemaVersion: schema.version,
|
|
293
|
-
|
|
294
|
-
? { logEpoch: this.#logEpoch }
|
|
295
|
-
: {}),
|
|
342
|
+
logEpoch,
|
|
296
343
|
},
|
|
297
344
|
{
|
|
298
345
|
type: 'PUSH_COMMIT',
|
package/src/schema.ts
CHANGED
|
@@ -318,6 +318,9 @@ function createSyncedTable(
|
|
|
318
318
|
db: ClientDatabase,
|
|
319
319
|
table: CompiledClientTable,
|
|
320
320
|
): void {
|
|
321
|
+
// §6.11: a declared reference is deliberately NOT emitted here. Windowing
|
|
322
|
+
// evicts a parent independently of its children, so local SQLite never
|
|
323
|
+
// enforces a reference; the server enforces it.
|
|
321
324
|
const columns = table.columns.map((column) => {
|
|
322
325
|
const notNull = column.nullable ? '' : ' NOT NULL';
|
|
323
326
|
const pk = column.name === table.primaryKey ? ' PRIMARY KEY' : '';
|
|
@@ -716,7 +719,10 @@ export function normalizeRecordKeys(
|
|
|
716
719
|
* may surface integers as bigint. Anything else passes through untouched —
|
|
717
720
|
* the codec still fails loud on genuine type garbage at encode time.
|
|
718
721
|
*/
|
|
719
|
-
function coerceSqlRepresentation(
|
|
722
|
+
export function coerceSqlRepresentation(
|
|
723
|
+
column: RowColumn,
|
|
724
|
+
value: unknown,
|
|
725
|
+
): unknown {
|
|
720
726
|
switch (localColumnType(column)) {
|
|
721
727
|
case 'boolean':
|
|
722
728
|
return value === 0 ? false : value === 1 ? true : value;
|