@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.
|
|
92
|
+
"@syncular/core": "0.21.1"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.21.1",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type SubStartFrame,
|
|
38
38
|
type WakeReason,
|
|
39
39
|
} from '@syncular/core';
|
|
40
|
+
import { EncryptError } from '@syncular/core';
|
|
40
41
|
import {
|
|
41
42
|
applyCommitFrame,
|
|
42
43
|
applyRowsSegment,
|
|
@@ -158,6 +159,8 @@ import {
|
|
|
158
159
|
ensureLocalBookkeepingSchema,
|
|
159
160
|
ensureLocalSyncedSchema,
|
|
160
161
|
fromSqlValue,
|
|
162
|
+
coerceSqlRepresentation,
|
|
163
|
+
type JsonRowValue,
|
|
161
164
|
jsonToRowValue,
|
|
162
165
|
LOCAL_SCHEMA_VERSION_KEY,
|
|
163
166
|
normalizeRecordKeys,
|
|
@@ -2127,11 +2130,11 @@ export class SyncClient {
|
|
|
2127
2130
|
|
|
2128
2131
|
#recordMutations(
|
|
2129
2132
|
mutations: readonly MutationInput[],
|
|
2130
|
-
|
|
2133
|
+
partial = false,
|
|
2131
2134
|
): string {
|
|
2132
2135
|
this.#requireActive();
|
|
2133
2136
|
const clientCommitId = crypto.randomUUID();
|
|
2134
|
-
const operations: OutboxOperation[] = mutations.map((mutation
|
|
2137
|
+
const operations: OutboxOperation[] = mutations.map((mutation) => {
|
|
2135
2138
|
const table = this.#table(mutation.table);
|
|
2136
2139
|
if (mutation.op === 'delete') {
|
|
2137
2140
|
return {
|
|
@@ -2143,6 +2146,55 @@ export class SyncClient {
|
|
|
2143
2146
|
: {}),
|
|
2144
2147
|
};
|
|
2145
2148
|
}
|
|
2149
|
+
if (partial) {
|
|
2150
|
+
// §6.7 patch: the values map IS the presence set — the primary key
|
|
2151
|
+
// plus the supplied non-scope columns. Absent columns keep their
|
|
2152
|
+
// stored value; the overlay and the server both apply only the
|
|
2153
|
+
// present ones.
|
|
2154
|
+
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
2155
|
+
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
2156
|
+
const json: Record<string, JsonRowValue> = {};
|
|
2157
|
+
for (const column of table.columns) {
|
|
2158
|
+
if (!normalized.has(column.name)) continue;
|
|
2159
|
+
if (scopeColumns.has(column.name)) {
|
|
2160
|
+
throw new ClientSyncError(
|
|
2161
|
+
'sync.invalid_request',
|
|
2162
|
+
`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
2165
|
+
const value = normalized.get(column.name);
|
|
2166
|
+
if (value === undefined || value === null) {
|
|
2167
|
+
if (!column.nullable) {
|
|
2168
|
+
throw new ClientSyncError(
|
|
2169
|
+
'sync.invalid_request',
|
|
2170
|
+
`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`,
|
|
2171
|
+
);
|
|
2172
|
+
}
|
|
2173
|
+
json[column.name] = null;
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
json[column.name] = rowValueToJson(
|
|
2177
|
+
coerceSqlRepresentation(column, value) as RowValue,
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
|
|
2181
|
+
const pkValue = json[pkColumn.name];
|
|
2182
|
+
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
2183
|
+
throw new ClientSyncError(
|
|
2184
|
+
'sync.invalid_request',
|
|
2185
|
+
`table ${table.name}: upsert requires a non-empty string primary key`,
|
|
2186
|
+
);
|
|
2187
|
+
}
|
|
2188
|
+
return {
|
|
2189
|
+
table: mutation.table,
|
|
2190
|
+
rowId: pkValue,
|
|
2191
|
+
op: 'upsert',
|
|
2192
|
+
...(mutation.baseVersion !== undefined
|
|
2193
|
+
? { baseVersion: mutation.baseVersion }
|
|
2194
|
+
: {}),
|
|
2195
|
+
values: json,
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2146
2198
|
const values = recordToRowValues(table, mutation.values);
|
|
2147
2199
|
const pkValue = values[table.primaryKeyIndex];
|
|
2148
2200
|
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
@@ -2163,9 +2215,6 @@ export class SyncClient {
|
|
|
2163
2215
|
? { baseVersion: mutation.baseVersion }
|
|
2164
2216
|
: {}),
|
|
2165
2217
|
values: json,
|
|
2166
|
-
...(changedFieldsByIndex[index] !== undefined
|
|
2167
|
-
? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
|
|
2168
|
-
: {}),
|
|
2169
2218
|
};
|
|
2170
2219
|
});
|
|
2171
2220
|
this.#applyBatch((batch) => {
|
|
@@ -2218,12 +2267,12 @@ export class SyncClient {
|
|
|
2218
2267
|
}
|
|
2219
2268
|
|
|
2220
2269
|
/**
|
|
2221
|
-
* Partial
|
|
2222
|
-
*
|
|
2223
|
-
*
|
|
2224
|
-
*
|
|
2225
|
-
*
|
|
2226
|
-
*
|
|
2270
|
+
* Partial update over the §6.1 sparse wire: record one upsert whose
|
|
2271
|
+
* presence set is the primary key plus the supplied non-scope columns.
|
|
2272
|
+
* `partial` keys follow the same two-casing rule as mutation values
|
|
2273
|
+
* (snake_case or camelCase). Absent columns keep their stored value on
|
|
2274
|
+
* the server and in the local overlay; when the local row is absent the
|
|
2275
|
+
* overlay leaves it absent (§7.1) and the server answers per §5.2.
|
|
2227
2276
|
*/
|
|
2228
2277
|
patch(
|
|
2229
2278
|
table: string,
|
|
@@ -2234,37 +2283,29 @@ export class SyncClient {
|
|
|
2234
2283
|
this.#requireActive();
|
|
2235
2284
|
const compiled = this.#table(table);
|
|
2236
2285
|
const pkColumn = compiled.columns[compiled.primaryKeyIndex] as RowColumn;
|
|
2237
|
-
const
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
);
|
|
2241
|
-
const row = rows[0];
|
|
2242
|
-
if (row === undefined) {
|
|
2286
|
+
const normalized = normalizeRecordKeys(compiled, partial);
|
|
2287
|
+
const pkOverride = normalized.get(pkColumn.name);
|
|
2288
|
+
if (pkOverride !== undefined && pkOverride !== rowId) {
|
|
2243
2289
|
throw new ClientSyncError(
|
|
2244
2290
|
'sync.invalid_request',
|
|
2245
|
-
`table ${compiled.name}:
|
|
2291
|
+
`table ${compiled.name}: patch cannot change the primary key`,
|
|
2246
2292
|
);
|
|
2247
2293
|
}
|
|
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
2294
|
return this.#recordMutations(
|
|
2257
2295
|
[
|
|
2258
2296
|
{
|
|
2259
2297
|
table,
|
|
2260
2298
|
op: 'upsert',
|
|
2261
|
-
values:
|
|
2299
|
+
values: {
|
|
2300
|
+
...Object.fromEntries(normalized),
|
|
2301
|
+
[pkColumn.name]: rowId,
|
|
2302
|
+
},
|
|
2262
2303
|
...(options?.baseVersion !== undefined
|
|
2263
2304
|
? { baseVersion: options.baseVersion }
|
|
2264
2305
|
: {}),
|
|
2265
2306
|
},
|
|
2266
2307
|
],
|
|
2267
|
-
|
|
2308
|
+
true,
|
|
2268
2309
|
);
|
|
2269
2310
|
}
|
|
2270
2311
|
|
|
@@ -2598,8 +2639,27 @@ export class SyncClient {
|
|
|
2598
2639
|
try {
|
|
2599
2640
|
pushFrames.push(
|
|
2600
2641
|
// §5.11: encrypted columns are encrypted at this encode-at-send
|
|
2601
|
-
// seam before the row codec serializes them.
|
|
2602
|
-
|
|
2642
|
+
// seam before the row codec serializes them. The stored local row
|
|
2643
|
+
// supplies an ABSENT key-id selector (§6.1 presence set first,
|
|
2644
|
+
// stored row second); an unresolvable key becomes a durable
|
|
2645
|
+
// rejection for that commit, never a sync() throw.
|
|
2646
|
+
await encodeOutboxCommit(
|
|
2647
|
+
this.#schema,
|
|
2648
|
+
commit,
|
|
2649
|
+
this.#encryption,
|
|
2650
|
+
(table, rowId) => {
|
|
2651
|
+
const selector = this.#encryption?.keyIdColumns?.[table.name];
|
|
2652
|
+
if (selector === undefined) return undefined;
|
|
2653
|
+
const row = this.#db.query(
|
|
2654
|
+
`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
2655
|
+
[rowId],
|
|
2656
|
+
)[0];
|
|
2657
|
+
const value = row?.value;
|
|
2658
|
+
return typeof value === 'string' && value.length > 0
|
|
2659
|
+
? value
|
|
2660
|
+
: undefined;
|
|
2661
|
+
},
|
|
2662
|
+
),
|
|
2603
2663
|
);
|
|
2604
2664
|
outbox.push(commit);
|
|
2605
2665
|
ops += commit.operations.length;
|
|
@@ -2608,6 +2668,10 @@ export class SyncClient {
|
|
|
2608
2668
|
this.#dropIncompatibleCommit(commit, error.message);
|
|
2609
2669
|
continue;
|
|
2610
2670
|
}
|
|
2671
|
+
if (error instanceof EncryptError) {
|
|
2672
|
+
this.#dropIncompatibleCommit(commit, error.message, error.code);
|
|
2673
|
+
continue;
|
|
2674
|
+
}
|
|
2611
2675
|
throw error;
|
|
2612
2676
|
}
|
|
2613
2677
|
}
|
|
@@ -2618,14 +2682,19 @@ export class SyncClient {
|
|
|
2618
2682
|
* §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
|
|
2619
2683
|
* §7.2 `rejected` surface — the commit leaves the outbox, its
|
|
2620
2684
|
* purely-optimistic rows are undone, and a rejection record is raised.
|
|
2685
|
+
* §5.11: the same drop carries an encode-seam `client.encrypt_failed`.
|
|
2621
2686
|
*/
|
|
2622
|
-
#dropIncompatibleCommit(
|
|
2687
|
+
#dropIncompatibleCommit(
|
|
2688
|
+
commit: OutboxCommit,
|
|
2689
|
+
message: string,
|
|
2690
|
+
code: string = OUTBOX_INCOMPATIBLE_CODE,
|
|
2691
|
+
): void {
|
|
2623
2692
|
this.#applyBatch((batch) => {
|
|
2624
2693
|
this.#rollbackFailedCommit(commit, batch);
|
|
2625
2694
|
const rejection: RejectionRecord = {
|
|
2626
2695
|
clientCommitId: commit.clientCommitId,
|
|
2627
2696
|
opIndex: 0,
|
|
2628
|
-
code
|
|
2697
|
+
code,
|
|
2629
2698
|
message,
|
|
2630
2699
|
retryable: false,
|
|
2631
2700
|
...(commit.operations[0] !== undefined
|
|
@@ -3735,6 +3804,10 @@ export class SyncClient {
|
|
|
3735
3804
|
message: result.message,
|
|
3736
3805
|
serverVersion: result.serverVersion,
|
|
3737
3806
|
serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
|
|
3807
|
+
conflictColumns: this.#decodeConflictColumns(
|
|
3808
|
+
operation?.table,
|
|
3809
|
+
result.conflictColumns,
|
|
3810
|
+
),
|
|
3738
3811
|
...(operation !== undefined ? { operation } : {}),
|
|
3739
3812
|
};
|
|
3740
3813
|
this.#conflicts.push(conflict);
|
|
@@ -3804,6 +3877,26 @@ export class SyncClient {
|
|
|
3804
3877
|
return record;
|
|
3805
3878
|
}
|
|
3806
3879
|
|
|
3880
|
+
/**
|
|
3881
|
+
* §6.3 `conflictColumns`: decode the presence-layout bitmap into the names
|
|
3882
|
+
* of the present columns whose `column_version` exceeded `baseVersion`. A
|
|
3883
|
+
* custom merge recomputes exactly these columns (§6.5).
|
|
3884
|
+
*/
|
|
3885
|
+
#decodeConflictColumns(
|
|
3886
|
+
tableName: string | undefined,
|
|
3887
|
+
bitmap: Uint8Array,
|
|
3888
|
+
): readonly string[] {
|
|
3889
|
+
if (tableName === undefined) return [];
|
|
3890
|
+
const table = this.#schema.tables.get(tableName);
|
|
3891
|
+
if (table === undefined) return [];
|
|
3892
|
+
const names: string[] = [];
|
|
3893
|
+
table.columns.forEach((column, index) => {
|
|
3894
|
+
const byte = bitmap[index >> 3] ?? 0;
|
|
3895
|
+
if (((byte >> (index & 7)) & 1) === 1) names.push(column.name);
|
|
3896
|
+
});
|
|
3897
|
+
return names;
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3807
3900
|
async #applyCommit(
|
|
3808
3901
|
frame: CommitFrame,
|
|
3809
3902
|
summary: MutableSummary,
|
|
@@ -4318,9 +4411,26 @@ export class SyncClient {
|
|
|
4318
4411
|
deleteLocalRow(this.#db, table, op.rowId);
|
|
4319
4412
|
continue;
|
|
4320
4413
|
}
|
|
4414
|
+
// §7.1 overlay: apply the operation's PRESENT columns over the current
|
|
4415
|
+
// local row. A partial operation over an absent local row leaves it
|
|
4416
|
+
// absent — the server answers it with sync.row_deleted or
|
|
4417
|
+
// sync.row_missing, and the local mirror never invents a base.
|
|
4418
|
+
const opValues = op.values ?? {};
|
|
4419
|
+
const full = table.columns.every((column) => column.name in opValues);
|
|
4420
|
+
const local = this.#db.query(
|
|
4421
|
+
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
4422
|
+
[op.rowId],
|
|
4423
|
+
)[0];
|
|
4424
|
+
if (local === undefined && !full) {
|
|
4425
|
+
if (batch !== undefined && !precise) batch.table(op.table);
|
|
4426
|
+
continue;
|
|
4427
|
+
}
|
|
4321
4428
|
const values = table.columns.map((column) => {
|
|
4322
|
-
const value =
|
|
4323
|
-
|
|
4429
|
+
const value = opValues[column.name];
|
|
4430
|
+
if (value !== undefined) return jsonToRowValue(value);
|
|
4431
|
+
return local === undefined
|
|
4432
|
+
? null
|
|
4433
|
+
: fromSqlValue(column, local[column.name] ?? null);
|
|
4324
4434
|
});
|
|
4325
4435
|
// Record the row's scope keys from its scope columns (I2 refinement).
|
|
4326
4436
|
if (batch !== undefined) {
|
|
@@ -4335,12 +4445,10 @@ export class SyncClient {
|
|
|
4335
4445
|
}
|
|
4336
4446
|
if (!precise) batch.table(table.name);
|
|
4337
4447
|
}
|
|
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
4448
|
const version =
|
|
4343
|
-
|
|
4449
|
+
local === undefined
|
|
4450
|
+
? OPTIMISTIC_VERSION
|
|
4451
|
+
: (local[SYNC_VERSION_COLUMN] as number);
|
|
4344
4452
|
upsertLocalRow(this.#db, table, values, version);
|
|
4345
4453
|
}
|
|
4346
4454
|
}
|
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
|
}
|
|
@@ -142,15 +143,45 @@ function declaredTypeOf(column: RowColumn): DeclaredType {
|
|
|
142
143
|
* value becomes the §5.11 ciphertext-envelope `bytes`. NULLs pass through
|
|
143
144
|
* unencrypted (§5.11).
|
|
144
145
|
*/
|
|
145
|
-
export async function encryptRowValues(
|
|
146
|
+
export async function encryptRowValues<T extends RowValue | undefined>(
|
|
146
147
|
config: EncryptionConfig,
|
|
147
148
|
table: CompiledClientTable,
|
|
148
149
|
rowId: string,
|
|
149
|
-
values: readonly
|
|
150
|
-
|
|
150
|
+
values: readonly T[],
|
|
151
|
+
storedKeyId?: string,
|
|
152
|
+
): Promise<T[]> {
|
|
151
153
|
if (!table.hasEncryptedColumns) return values.slice();
|
|
152
154
|
const out = values.slice();
|
|
153
|
-
|
|
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);
|
|
154
185
|
for (let i = 0; i < table.columns.length; i++) {
|
|
155
186
|
const column = table.columns[i];
|
|
156
187
|
if (column === undefined || !column.encrypted) continue;
|
|
@@ -158,17 +189,17 @@ export async function encryptRowValues(
|
|
|
158
189
|
if (value === null || value === undefined) continue; // NULL stays NULL
|
|
159
190
|
const key = config.keyProvider(keyId);
|
|
160
191
|
if (key === undefined) {
|
|
161
|
-
throw new
|
|
192
|
+
throw new EncryptError(
|
|
162
193
|
`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`,
|
|
163
194
|
);
|
|
164
195
|
}
|
|
165
|
-
out[i] = await encryptValue(
|
|
196
|
+
out[i] = (await encryptValue(
|
|
166
197
|
declaredTypeOf(column),
|
|
167
198
|
value as PlainValue,
|
|
168
199
|
keyId,
|
|
169
200
|
key,
|
|
170
201
|
config.nonceSource,
|
|
171
|
-
);
|
|
202
|
+
)) as T;
|
|
172
203
|
}
|
|
173
204
|
return out;
|
|
174
205
|
}
|
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
|
|
|
@@ -237,12 +237,17 @@ function orderedValues(
|
|
|
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) {
|
|
@@ -270,18 +275,24 @@ export async function encodeOutboxCommit(
|
|
|
270
275
|
`outbox upsert on ${op.table}/${op.rowId} has no values`,
|
|
271
276
|
);
|
|
272
277
|
}
|
|
273
|
-
let values =
|
|
278
|
+
let values = sparseValues(table, op.values);
|
|
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,
|
|
281
292
|
rowId: op.rowId,
|
|
282
293
|
op: 'upsert',
|
|
283
294
|
...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
|
|
284
|
-
payload:
|
|
295
|
+
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
285
296
|
});
|
|
286
297
|
}
|
|
287
298
|
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',
|