@syncular/client 0.20.0 → 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 +22 -5
- package/dist/apply.d.ts +14 -7
- package/dist/apply.js +75 -53
- package/dist/bun-database.d.ts +1 -1
- package/dist/bun-database.js +2 -2
- package/dist/client.d.ts +10 -10
- package/dist/client.js +162 -64
- package/dist/database.d.ts +3 -2
- package/dist/encryption.d.ts +1 -1
- package/dist/encryption.js +4 -2
- package/dist/node-database.d.ts +1 -1
- package/dist/node-database.js +2 -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/reactive-store.js +49 -8
- package/dist/remote.js +40 -6
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +19 -5
- package/dist/wasm-database.js +3 -3
- package/package.json +3 -3
- package/src/apply.ts +86 -60
- package/src/bun-database.ts +6 -2
- package/src/client.ts +195 -72
- package/src/database.ts +7 -2
- package/src/encryption.ts +13 -6
- package/src/node-database.ts +6 -2
- package/src/outbox.ts +12 -12
- package/src/outcomes.ts +14 -1
- package/src/reactive-store.ts +58 -8
- package/src/remote.ts +54 -7
- package/src/schema.ts +29 -5
- package/src/wasm-database.ts +7 -3
package/dist/client.js
CHANGED
|
@@ -9,7 +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 { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, upsertLocalRow, } from './apply.js';
|
|
12
|
+
import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
|
|
13
13
|
import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
|
|
14
14
|
import { registerDevtools } from './devtools.js';
|
|
15
15
|
import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, } from './diagnostics.js';
|
|
@@ -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. */
|
|
@@ -236,6 +236,7 @@ export class SyncClient {
|
|
|
236
236
|
// an application-issued sync() call.
|
|
237
237
|
const startupWork = this.#schemaFloor === undefined &&
|
|
238
238
|
(countOutbox(this.#db) > 0 ||
|
|
239
|
+
loadPendingEvictions(this.#db).length > 0 ||
|
|
239
240
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
240
241
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
241
242
|
this.#needsPull = true;
|
|
@@ -1126,22 +1127,17 @@ export class SyncClient {
|
|
|
1126
1127
|
}
|
|
1127
1128
|
socket.send(encodePresencePublish(scopeKey, doc));
|
|
1128
1129
|
}
|
|
1129
|
-
/**
|
|
1130
|
-
* §4.2 accept mask: the configured override, or the rows baseline plus
|
|
1131
|
-
* bit 2 when the backend can import sqlite images (§5.3) and a segment
|
|
1132
|
-
* downloader exists (sqlite segments are never inline, §5.7), plus
|
|
1133
|
-
* bit 3 when the downloader exposes a direct URL fetch (§5.4
|
|
1134
|
-
* capability negotiation).
|
|
1135
|
-
*/
|
|
1130
|
+
/** Advertise supported segment formats; all imports yield between commits. */
|
|
1136
1131
|
#acceptMask() {
|
|
1137
1132
|
const configured = this.#config.limits?.accept;
|
|
1138
1133
|
if (configured !== undefined)
|
|
1139
1134
|
return configured;
|
|
1140
1135
|
const segments = this.#config.segments;
|
|
1141
|
-
const sqliteCapable = typeof this.#db.withSqliteImage === 'function' && segments !== undefined;
|
|
1142
1136
|
const urlCapable = typeof segments?.fetchUrl === 'function';
|
|
1143
1137
|
return (ACCEPT_ROWS_BASELINE |
|
|
1144
|
-
(
|
|
1138
|
+
(typeof this.#db.withSqliteImage === 'function' && segments !== undefined
|
|
1139
|
+
? ACCEPT_SQLITE
|
|
1140
|
+
: 0) |
|
|
1145
1141
|
(urlCapable ? ACCEPT_SIGNED_URLS : 0));
|
|
1146
1142
|
}
|
|
1147
1143
|
subscriptions() {
|
|
@@ -1199,8 +1195,8 @@ export class SyncClient {
|
|
|
1199
1195
|
* Set the live window units for a base (§4.8): a value-sharded family of
|
|
1200
1196
|
* subscriptions, one per unit. Computes the diff against the registry —
|
|
1201
1197
|
* added units get fresh subscriptions (image-lane bootstrap on the next
|
|
1202
|
-
* sync); removed units are unsubscribed
|
|
1203
|
-
* transaction (E1–E4). Idempotent: calling with the same units is a
|
|
1198
|
+
* sync); removed units are unsubscribed with their first eviction chunk
|
|
1199
|
+
* in one local transaction (E1–E4). Idempotent: calling with the same units is a
|
|
1204
1200
|
* no-op. Re-entry (a unit removed then re-added) cancels any deferred
|
|
1205
1201
|
* eviction and fresh-bootstraps.
|
|
1206
1202
|
*
|
|
@@ -1258,7 +1254,7 @@ export class SyncClient {
|
|
|
1258
1254
|
for (const { unit, subId } of live) {
|
|
1259
1255
|
if (wanted.has(unit))
|
|
1260
1256
|
continue;
|
|
1261
|
-
this.#evictUnit(baseKey, base, unit, subId);
|
|
1257
|
+
await this.#evictUnit(baseKey, base, unit, subId);
|
|
1262
1258
|
changed = true;
|
|
1263
1259
|
}
|
|
1264
1260
|
});
|
|
@@ -1297,8 +1293,8 @@ export class SyncClient {
|
|
|
1297
1293
|
return { units: live.map((u) => u.unit), pending };
|
|
1298
1294
|
}
|
|
1299
1295
|
/**
|
|
1300
|
-
* §4.8 E1–E4: evict one departing unit
|
|
1301
|
-
*
|
|
1296
|
+
* §4.8 E1–E4: evict one departing unit in committed chunks, fusing
|
|
1297
|
+
* its first chunk with unsubscription. Deletes the unit's rows EXCEPT those pinned by a
|
|
1302
1298
|
* pending outbox commit (E1); if any pin remains, records a deferred
|
|
1303
1299
|
* eviction retried on the next outbox drain. Discards the subscription's
|
|
1304
1300
|
* cursor/resume/effective-echo (E3) and its version state with the rows
|
|
@@ -1306,19 +1302,21 @@ export class SyncClient {
|
|
|
1306
1302
|
* with no local scope-column mapping, surfaces a configuration error and
|
|
1307
1303
|
* evicts nothing (§4.8/§3.3).
|
|
1308
1304
|
*/
|
|
1309
|
-
#evictUnit(baseKey, base, unit, subId) {
|
|
1305
|
+
async #evictUnit(baseKey, base, unit, subId) {
|
|
1310
1306
|
const table = this.#table(base.table);
|
|
1311
1307
|
const sub = getSubscription(this.#db, subId);
|
|
1312
1308
|
// The rows a unit holds live under its LAST effective scopes if it ever
|
|
1313
1309
|
// synced; before first sync, the requested unit scopes are the match.
|
|
1314
1310
|
const effective = sub?.effectiveScopes ?? unitScopes(base, unit);
|
|
1315
1311
|
const pinned = this.#pinnedRowIds(base.table);
|
|
1312
|
+
let remaining = false;
|
|
1316
1313
|
this.#applyBatch((batch) => {
|
|
1317
1314
|
this.#db.transaction(() => {
|
|
1318
|
-
const
|
|
1315
|
+
const result = evictScopedRows(this.#db, table, effective, pinned);
|
|
1316
|
+
remaining = result.remaining;
|
|
1319
1317
|
deleteWindowUnit(this.#db, baseKey, unit);
|
|
1320
1318
|
deleteSubscription(this.#db, subId);
|
|
1321
|
-
if (deferred) {
|
|
1319
|
+
if (result.deferred) {
|
|
1322
1320
|
savePendingEviction(this.#db, subId, base.table, effective);
|
|
1323
1321
|
}
|
|
1324
1322
|
else {
|
|
@@ -1330,13 +1328,17 @@ export class SyncClient {
|
|
|
1330
1328
|
this.#needsPull = true;
|
|
1331
1329
|
batch.status();
|
|
1332
1330
|
});
|
|
1331
|
+
if (remaining) {
|
|
1332
|
+
await yieldToHost();
|
|
1333
|
+
await this.#drainPendingEvictions();
|
|
1334
|
+
}
|
|
1333
1335
|
}
|
|
1334
1336
|
/**
|
|
1335
1337
|
* §4.8 E1: retry deferred evictions after the outbox drains. A pinned
|
|
1336
1338
|
* unit's rows are removed once no pending commit references them; a unit
|
|
1337
1339
|
* that re-entered the window in the meantime has no pending record left.
|
|
1338
1340
|
*/
|
|
1339
|
-
#drainPendingEvictions() {
|
|
1341
|
+
async #drainPendingEvictions() {
|
|
1340
1342
|
const pending = loadPendingEvictions(this.#db);
|
|
1341
1343
|
if (pending.length === 0)
|
|
1342
1344
|
return;
|
|
@@ -1346,16 +1348,23 @@ export class SyncClient {
|
|
|
1346
1348
|
deletePendingEviction(this.#db, entry.subId);
|
|
1347
1349
|
continue;
|
|
1348
1350
|
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1351
|
+
let remaining;
|
|
1352
|
+
do {
|
|
1353
|
+
if (!loadPendingEvictions(this.#db).some((item) => item.subId === entry.subId))
|
|
1354
|
+
break;
|
|
1355
|
+
// Local writes may add pins while the worker yields between chunks.
|
|
1356
|
+
const pinned = this.#pinnedRowIds(entry.table);
|
|
1357
|
+
remaining = false;
|
|
1358
|
+
this.#applyBatch((batch) => {
|
|
1359
|
+
const result = evictScopedRows(this.#db, table, entry.effective, pinned);
|
|
1360
|
+
remaining = result.remaining;
|
|
1361
|
+
if (!result.deferred)
|
|
1355
1362
|
deletePendingEviction(this.#db, entry.subId);
|
|
1363
|
+
batch.scopeMap(table, entry.effective);
|
|
1356
1364
|
});
|
|
1357
|
-
|
|
1358
|
-
|
|
1365
|
+
if (remaining)
|
|
1366
|
+
await yieldToHost();
|
|
1367
|
+
} while (remaining);
|
|
1359
1368
|
}
|
|
1360
1369
|
}
|
|
1361
1370
|
/**
|
|
@@ -1381,10 +1390,10 @@ export class SyncClient {
|
|
|
1381
1390
|
mutate(mutations) {
|
|
1382
1391
|
return this.#recordMutations(mutations);
|
|
1383
1392
|
}
|
|
1384
|
-
#recordMutations(mutations,
|
|
1393
|
+
#recordMutations(mutations, partial = false) {
|
|
1385
1394
|
this.#requireActive();
|
|
1386
1395
|
const clientCommitId = crypto.randomUUID();
|
|
1387
|
-
const operations = mutations.map((mutation
|
|
1396
|
+
const operations = mutations.map((mutation) => {
|
|
1388
1397
|
const table = this.#table(mutation.table);
|
|
1389
1398
|
if (mutation.op === 'delete') {
|
|
1390
1399
|
return {
|
|
@@ -1396,6 +1405,45 @@ export class SyncClient {
|
|
|
1396
1405
|
: {}),
|
|
1397
1406
|
};
|
|
1398
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
|
+
}
|
|
1399
1447
|
const values = recordToRowValues(table, mutation.values);
|
|
1400
1448
|
const pkValue = values[table.primaryKeyIndex];
|
|
1401
1449
|
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
@@ -1413,9 +1461,6 @@ export class SyncClient {
|
|
|
1413
1461
|
? { baseVersion: mutation.baseVersion }
|
|
1414
1462
|
: {}),
|
|
1415
1463
|
values: json,
|
|
1416
|
-
...(changedFieldsByIndex[index] !== undefined
|
|
1417
|
-
? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
|
|
1418
|
-
: {}),
|
|
1419
1464
|
};
|
|
1420
1465
|
});
|
|
1421
1466
|
this.#applyBatch((batch) => {
|
|
@@ -1462,40 +1507,35 @@ export class SyncClient {
|
|
|
1462
1507
|
};
|
|
1463
1508
|
}
|
|
1464
1509
|
/**
|
|
1465
|
-
* Partial
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
*
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
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.
|
|
1471
1516
|
*/
|
|
1472
1517
|
patch(table, rowId, partial, options) {
|
|
1473
1518
|
this.#requireActive();
|
|
1474
1519
|
const compiled = this.#table(table);
|
|
1475
1520
|
const pkColumn = compiled.columns[compiled.primaryKeyIndex];
|
|
1476
|
-
const
|
|
1477
|
-
const
|
|
1478
|
-
if (
|
|
1479
|
-
throw new ClientSyncError('sync.invalid_request', `table ${compiled.name}:
|
|
1480
|
-
}
|
|
1481
|
-
const record = {};
|
|
1482
|
-
for (const column of compiled.columns) {
|
|
1483
|
-
record[column.name] = fromSqlValue(column, row[column.name] ?? null);
|
|
1484
|
-
}
|
|
1485
|
-
const normalizedPartial = normalizeRecordKeys(compiled, partial);
|
|
1486
|
-
for (const [name, value] of normalizedPartial) {
|
|
1487
|
-
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`);
|
|
1488
1525
|
}
|
|
1489
1526
|
return this.#recordMutations([
|
|
1490
1527
|
{
|
|
1491
1528
|
table,
|
|
1492
1529
|
op: 'upsert',
|
|
1493
|
-
values:
|
|
1530
|
+
values: {
|
|
1531
|
+
...Object.fromEntries(normalized),
|
|
1532
|
+
[pkColumn.name]: rowId,
|
|
1533
|
+
},
|
|
1494
1534
|
...(options?.baseVersion !== undefined
|
|
1495
1535
|
? { baseVersion: options.baseVersion }
|
|
1496
1536
|
: {}),
|
|
1497
1537
|
},
|
|
1498
|
-
],
|
|
1538
|
+
], true);
|
|
1499
1539
|
}
|
|
1500
1540
|
/**
|
|
1501
1541
|
* Apply one host-authorized local security purge. The host must stop/gate
|
|
@@ -1920,6 +1960,7 @@ export class SyncClient {
|
|
|
1920
1960
|
// survive it — the reference server keeps no replay buffer (§8.2).
|
|
1921
1961
|
this.#setSyncNeeded(false);
|
|
1922
1962
|
try {
|
|
1963
|
+
await this.#drainPendingEvictions();
|
|
1923
1964
|
const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
1924
1965
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
1925
1966
|
// them, so the server-side existence check (§6.6) passes.
|
|
@@ -1981,7 +2022,7 @@ export class SyncClient {
|
|
|
1981
2022
|
const summary = await this.#processResponse(message, outbox, subs, 'pull', resetEpoch);
|
|
1982
2023
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
1983
2024
|
// a shrunk window unit — retry any deferred evictions now.
|
|
1984
|
-
this.#drainPendingEvictions();
|
|
2025
|
+
await this.#drainPendingEvictions();
|
|
1985
2026
|
this.#progress.update(summary.failed.length > 0
|
|
1986
2027
|
? { state: 'failed', errorCode: 'sync.scope_revoked' }
|
|
1987
2028
|
: { state: 'complete' });
|
|
@@ -2028,10 +2069,21 @@ export class SyncClient {
|
|
|
2028
2069
|
* Pull repeatedly until quiescent: no commits delivered, no bootstrap
|
|
2029
2070
|
* pages pending, no resets to recover (§4.5 "pull again" SHOULD).
|
|
2030
2071
|
*/
|
|
2031
|
-
async syncUntilIdle(maxRounds
|
|
2072
|
+
async syncUntilIdle(maxRounds) {
|
|
2073
|
+
const budget = maxRounds ?? 20;
|
|
2032
2074
|
let last;
|
|
2033
|
-
for (let round = 0; round <
|
|
2075
|
+
for (let round = 0; round < budget; round++) {
|
|
2076
|
+
const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]));
|
|
2034
2077
|
last = await this.sync();
|
|
2078
|
+
if (maxRounds === undefined &&
|
|
2079
|
+
last.segmentRowsApplied > 0 &&
|
|
2080
|
+
last.bootstrapping.length > 0 &&
|
|
2081
|
+
before !==
|
|
2082
|
+
JSON.stringify(loadSubscriptions(this.#db).map((sub) => [
|
|
2083
|
+
sub.id,
|
|
2084
|
+
sub.bootstrapState,
|
|
2085
|
+
])))
|
|
2086
|
+
round = -1;
|
|
2035
2087
|
if (last.schemaFloor !== undefined)
|
|
2036
2088
|
return last;
|
|
2037
2089
|
if (last.commitsApplied === 0 &&
|
|
@@ -2043,7 +2095,7 @@ export class SyncClient {
|
|
|
2043
2095
|
return last;
|
|
2044
2096
|
}
|
|
2045
2097
|
}
|
|
2046
|
-
throw new ClientSyncError('sync.invalid_request',
|
|
2098
|
+
throw new ClientSyncError('sync.invalid_request', 'sync did not reach idle within the round budget');
|
|
2047
2099
|
}
|
|
2048
2100
|
/**
|
|
2049
2101
|
* One request/response round trip (§8.7): over the socket whenever it
|
|
@@ -2469,6 +2521,7 @@ export class SyncClient {
|
|
|
2469
2521
|
});
|
|
2470
2522
|
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
|
|
2471
2523
|
clearFirst,
|
|
2524
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
2472
2525
|
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
2473
2526
|
effective,
|
|
2474
2527
|
transaction: (fn) => this.#applyBatch((batch) => {
|
|
@@ -2477,7 +2530,9 @@ export class SyncClient {
|
|
|
2477
2530
|
this.#scopedRowsExist(table, effective))) {
|
|
2478
2531
|
batch.table(table.name);
|
|
2479
2532
|
}
|
|
2480
|
-
|
|
2533
|
+
const result = fn();
|
|
2534
|
+
this.#replayOutbox();
|
|
2535
|
+
return result;
|
|
2481
2536
|
}),
|
|
2482
2537
|
}, this.#encryption), section.fresh && !section.cleared);
|
|
2483
2538
|
break;
|
|
@@ -2510,6 +2565,7 @@ export class SyncClient {
|
|
|
2510
2565
|
scopeDigest: frame.scopeDigest,
|
|
2511
2566
|
}, {
|
|
2512
2567
|
clearFirst,
|
|
2568
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
2513
2569
|
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
2514
2570
|
effective,
|
|
2515
2571
|
transaction: (fn) => this.#applyBatch((batch) => {
|
|
@@ -2518,7 +2574,9 @@ export class SyncClient {
|
|
|
2518
2574
|
this.#scopedRowsExist(table, effective))) {
|
|
2519
2575
|
batch.table(table.name);
|
|
2520
2576
|
}
|
|
2521
|
-
|
|
2577
|
+
const result = fn();
|
|
2578
|
+
this.#replayOutbox();
|
|
2579
|
+
return result;
|
|
2522
2580
|
}),
|
|
2523
2581
|
}), section.fresh && !section.cleared);
|
|
2524
2582
|
}
|
|
@@ -2526,6 +2584,7 @@ export class SyncClient {
|
|
|
2526
2584
|
const segment = decodeRowsSegment(bytes);
|
|
2527
2585
|
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
|
|
2528
2586
|
clearFirst,
|
|
2587
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
2529
2588
|
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
2530
2589
|
effective,
|
|
2531
2590
|
transaction: (fn) => this.#applyBatch((batch) => {
|
|
@@ -2534,7 +2593,9 @@ export class SyncClient {
|
|
|
2534
2593
|
this.#scopedRowsExist(table, effective))) {
|
|
2535
2594
|
batch.table(table.name);
|
|
2536
2595
|
}
|
|
2537
|
-
|
|
2596
|
+
const result = fn();
|
|
2597
|
+
this.#replayOutbox();
|
|
2598
|
+
return result;
|
|
2538
2599
|
}),
|
|
2539
2600
|
}, this.#encryption), section.fresh &&
|
|
2540
2601
|
!section.cleared &&
|
|
@@ -2658,6 +2719,7 @@ export class SyncClient {
|
|
|
2658
2719
|
message: result.message,
|
|
2659
2720
|
serverVersion: result.serverVersion,
|
|
2660
2721
|
serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
|
|
2722
|
+
conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
|
|
2661
2723
|
...(operation !== undefined ? { operation } : {}),
|
|
2662
2724
|
};
|
|
2663
2725
|
this.#conflicts.push(conflict);
|
|
@@ -2720,6 +2782,25 @@ export class SyncClient {
|
|
|
2720
2782
|
});
|
|
2721
2783
|
return record;
|
|
2722
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
|
+
}
|
|
2723
2804
|
async #applyCommit(frame, summary) {
|
|
2724
2805
|
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
2725
2806
|
this.#recordCommitChanges(batch, frame);
|
|
@@ -3116,9 +3197,25 @@ export class SyncClient {
|
|
|
3116
3197
|
deleteLocalRow(this.#db, table, op.rowId);
|
|
3117
3198
|
continue;
|
|
3118
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
|
+
}
|
|
3119
3212
|
const values = table.columns.map((column) => {
|
|
3120
|
-
const value =
|
|
3121
|
-
|
|
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);
|
|
3122
3219
|
});
|
|
3123
3220
|
// Record the row's scope keys from its scope columns (I2 refinement).
|
|
3124
3221
|
if (batch !== undefined) {
|
|
@@ -3134,8 +3231,9 @@ export class SyncClient {
|
|
|
3134
3231
|
if (!precise)
|
|
3135
3232
|
batch.table(table.name);
|
|
3136
3233
|
}
|
|
3137
|
-
const
|
|
3138
|
-
|
|
3234
|
+
const version = local === undefined
|
|
3235
|
+
? OPTIMISTIC_VERSION
|
|
3236
|
+
: local[SYNC_VERSION_COLUMN];
|
|
3139
3237
|
upsertLocalRow(this.#db, table, values, version);
|
|
3140
3238
|
}
|
|
3141
3239
|
}
|
package/dist/database.d.ts
CHANGED
|
@@ -20,11 +20,12 @@ export interface ClientDatabase {
|
|
|
20
20
|
/**
|
|
21
21
|
* Optional sqlite-image capability (SPEC §5.3): expose `bytes` as an
|
|
22
22
|
* attached read-only database schema named `alias` for the duration of
|
|
23
|
-
* `fn`,
|
|
23
|
+
* `fn`, awaiting its result before detaching. No transaction spans its awaits.
|
|
24
|
+
* Presence of this method is what makes the client
|
|
24
25
|
* advertise `accept` bit 2 (§4.2). MUST be called outside any open
|
|
25
26
|
* transaction (SQLite cannot ATTACH inside one); `fn` may open its own.
|
|
26
27
|
*/
|
|
27
|
-
withSqliteImage?<T>(bytes: Uint8Array, alias: string, fn: () => T): T
|
|
28
|
+
withSqliteImage?<T>(bytes: Uint8Array, alias: string, fn: () => T | Promise<T>): Promise<T>;
|
|
28
29
|
close(): void;
|
|
29
30
|
}
|
|
30
31
|
/** Attach-schema aliases are code-chosen, never user input — enforce it. */
|
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/node-database.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export declare class NodeClientDatabase implements ClientDatabase {
|
|
|
12
12
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
13
13
|
transaction<T>(fn: () => T): T;
|
|
14
14
|
/** §5.3 image import through a private file attached for one callback. */
|
|
15
|
-
withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T
|
|
15
|
+
withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T | Promise<T>): Promise<T>;
|
|
16
16
|
close(): void;
|
|
17
17
|
}
|
|
18
18
|
export declare function openNodeDatabase(path?: string): ClientDatabase;
|
package/dist/node-database.js
CHANGED
|
@@ -52,7 +52,7 @@ export class NodeClientDatabase {
|
|
|
52
52
|
return runTransaction(this.#tx, (sql) => this.db.exec(sql), fn);
|
|
53
53
|
}
|
|
54
54
|
/** §5.3 image import through a private file attached for one callback. */
|
|
55
|
-
withSqliteImage(bytes, alias, fn) {
|
|
55
|
+
async withSqliteImage(bytes, alias, fn) {
|
|
56
56
|
assertImageAlias(alias);
|
|
57
57
|
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
|
58
58
|
const path = join(dir, 'segment.db');
|
|
@@ -60,7 +60,7 @@ export class NodeClientDatabase {
|
|
|
60
60
|
writeFileSync(path, bytes);
|
|
61
61
|
this.db.prepare(`ATTACH DATABASE ? AS ${alias}`).run(path);
|
|
62
62
|
try {
|
|
63
|
-
return fn();
|
|
63
|
+
return await fn();
|
|
64
64
|
}
|
|
65
65
|
finally {
|
|
66
66
|
this.db.prepare(`DETACH DATABASE ${alias}`).run();
|
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),
|