@syncular/client 0.30.7 → 0.30.10
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 +16 -2
- package/dist/apply.d.ts +2 -2
- package/dist/apply.js +25 -8
- package/dist/client.d.ts +6 -4
- package/dist/client.js +120 -81
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +4 -0
- package/dist/failed-overlay.d.ts +10 -0
- package/dist/failed-overlay.js +102 -0
- package/dist/invalidation.d.ts +1 -0
- package/dist/invalidation.js +3 -0
- package/dist/outcomes.d.ts +10 -0
- package/dist/outcomes.js +18 -3
- package/dist/remote.js +7 -2
- package/dist/schema.js +7 -5
- package/dist/wasm-database.js +5 -5
- package/dist/window.js +4 -4
- package/dist/worker-entry.js +3 -0
- package/dist/worker-host.d.ts +1 -0
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +1 -0
- package/package.json +5 -4
- package/src/apply.ts +46 -13
- package/src/client.ts +197 -183
- package/src/errors.ts +8 -0
- package/src/failed-overlay.ts +174 -0
- package/src/invalidation.ts +4 -0
- package/src/outcomes.ts +41 -3
- package/src/remote.ts +15 -1
- package/src/schema.ts +7 -9
- package/src/wasm-database.ts +5 -8
- package/src/window.ts +4 -7
- package/src/worker-entry.ts +3 -0
- package/src/worker-host.ts +4 -0
- package/src/worker-protocol.ts +1 -0
package/dist/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, } from './failed-overlay.js';
|
|
1
2
|
import { ProgressEmitter, } from './progress.js';
|
|
2
3
|
/**
|
|
3
4
|
* SyncClient implements the client side of SPEC.md §§3–8.
|
|
@@ -14,7 +15,7 @@ import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow,
|
|
|
14
15
|
import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
|
|
15
16
|
import { registerDevtools } from './devtools.js';
|
|
16
17
|
import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, MAX_DIAGNOSTIC_QUERY_FAILURES, } from './diagnostics.js';
|
|
17
|
-
import { ClientSyncError, classifySqliteFailure, REALTIME_LOST_CODE, RealtimeUnavailableError, } from './errors.js';
|
|
18
|
+
import { ClientSyncError, invalidRequest, classifySqliteFailure, REALTIME_LOST_CODE, RealtimeUnavailableError, } from './errors.js';
|
|
18
19
|
import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
19
20
|
import { singleOwnerLock, } from './leader-lock.js';
|
|
20
21
|
import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
|
|
@@ -85,7 +86,7 @@ function boundedBudget(value, fallback, name) {
|
|
|
85
86
|
if (value === undefined)
|
|
86
87
|
return fallback;
|
|
87
88
|
if (!Number.isSafeInteger(value) || value < 1) {
|
|
88
|
-
throw
|
|
89
|
+
throw invalidRequest(`previousVersionContext.${name} must be a positive safe integer`);
|
|
89
90
|
}
|
|
90
91
|
return value;
|
|
91
92
|
}
|
|
@@ -107,7 +108,7 @@ function resolvePreviousVersionLimit(limit) {
|
|
|
107
108
|
if (limit === undefined)
|
|
108
109
|
return 50;
|
|
109
110
|
if (!Number.isFinite(limit) || limit < 1) {
|
|
110
|
-
throw
|
|
111
|
+
throw invalidRequest('previousVersionSnapshot limit must be a positive number');
|
|
111
112
|
}
|
|
112
113
|
return Math.min(PREVIOUS_VERSION_MAX_LIMIT, Math.trunc(limit));
|
|
113
114
|
}
|
|
@@ -133,6 +134,7 @@ export class SyncClient {
|
|
|
133
134
|
#securityLifecycle;
|
|
134
135
|
#now;
|
|
135
136
|
#outcomeRetentionMaxEntries;
|
|
137
|
+
#retainFailedCommits;
|
|
136
138
|
#started = false;
|
|
137
139
|
#lease;
|
|
138
140
|
#clientId = '';
|
|
@@ -219,18 +221,19 @@ export class SyncClient {
|
|
|
219
221
|
#preflightBarrier;
|
|
220
222
|
constructor(config) {
|
|
221
223
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
222
|
-
throw
|
|
224
|
+
throw invalidRequest('securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
|
|
223
225
|
}
|
|
226
|
+
this.#retainFailedCommits = config.retainFailedCommits === true;
|
|
224
227
|
this.#db = config.database;
|
|
225
228
|
this.#schema = compileClientSchema(config.schema);
|
|
226
229
|
const realtimePolicy = config.realtimePolicy ?? 'optional';
|
|
227
230
|
if (realtimePolicy !== 'required' &&
|
|
228
231
|
realtimePolicy !== 'optional' &&
|
|
229
232
|
realtimePolicy !== 'off') {
|
|
230
|
-
throw
|
|
233
|
+
throw invalidRequest("realtimePolicy must be 'required', 'optional', or 'off'");
|
|
231
234
|
}
|
|
232
235
|
if (realtimePolicy === 'required' && config.realtime === undefined) {
|
|
233
|
-
throw
|
|
236
|
+
throw invalidRequest("realtimePolicy 'required' needs a realtime connector");
|
|
234
237
|
}
|
|
235
238
|
this.#config = config;
|
|
236
239
|
this.#realtimePolicy = realtimePolicy;
|
|
@@ -247,7 +250,7 @@ export class SyncClient {
|
|
|
247
250
|
const outcomeRetentionMaxEntries = config.limits?.outcomeRetentionMaxEntries ?? 1_000;
|
|
248
251
|
if (!Number.isSafeInteger(outcomeRetentionMaxEntries) ||
|
|
249
252
|
outcomeRetentionMaxEntries < 1) {
|
|
250
|
-
throw
|
|
253
|
+
throw invalidRequest('outcomeRetentionMaxEntries must be a positive safe integer');
|
|
251
254
|
}
|
|
252
255
|
this.#outcomeRetentionMaxEntries = outcomeRetentionMaxEntries;
|
|
253
256
|
// RFC 0005: the container needs a sibling database file. A host without
|
|
@@ -596,7 +599,7 @@ export class SyncClient {
|
|
|
596
599
|
async activateSecurity(options = {}) {
|
|
597
600
|
this.#requireStarted();
|
|
598
601
|
if (this.#securityLifecycle === 'active') {
|
|
599
|
-
throw
|
|
602
|
+
throw invalidRequest('activateSecurity requires the client to be in security preflight');
|
|
600
603
|
}
|
|
601
604
|
await (this.#preflightBarrier ?? this.#opChain);
|
|
602
605
|
this.#encryption = options.encryption;
|
|
@@ -767,7 +770,7 @@ export class SyncClient {
|
|
|
767
770
|
}
|
|
768
771
|
const table = record.tables.find((entry) => entry.name === spec.table);
|
|
769
772
|
if (table === undefined) {
|
|
770
|
-
throw
|
|
773
|
+
throw invalidRequest(`previousVersionSnapshot names unknown previous table ${JSON.stringify(spec.table)}`);
|
|
771
774
|
}
|
|
772
775
|
const read = withPreviousVersionContainer(this.#db, (container) => readPreviousVersionRows(container, table, spec.rowIds?.map(String) ?? [], resolvePreviousVersionLimit(spec.limit)));
|
|
773
776
|
if (read === undefined) {
|
|
@@ -960,7 +963,7 @@ export class SyncClient {
|
|
|
960
963
|
this.#requireActive();
|
|
961
964
|
const expected = request.expectedSubscriptions ?? [];
|
|
962
965
|
if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
|
|
963
|
-
throw
|
|
966
|
+
throw invalidRequest(`diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`);
|
|
964
967
|
}
|
|
965
968
|
const registered = loadSubscriptions(this.#db);
|
|
966
969
|
const subscriptions = new Map();
|
|
@@ -993,7 +996,7 @@ export class SyncClient {
|
|
|
993
996
|
item.id.length === 0 ||
|
|
994
997
|
typeof item.table !== 'string' ||
|
|
995
998
|
item.table.length === 0) {
|
|
996
|
-
throw
|
|
999
|
+
throw invalidRequest('diagnosticsSnapshot expected subscriptions require non-empty id and table strings');
|
|
997
1000
|
}
|
|
998
1001
|
const registeredSubscription = subscriptions.get(item.id);
|
|
999
1002
|
if (registeredSubscription !== undefined &&
|
|
@@ -1181,6 +1184,10 @@ export class SyncClient {
|
|
|
1181
1184
|
this.#batch = batch;
|
|
1182
1185
|
try {
|
|
1183
1186
|
result = fn(batch);
|
|
1187
|
+
if (this.#db
|
|
1188
|
+
.query('SELECT DISTINCT tbl FROM _syncular_failed_rows')
|
|
1189
|
+
.some((row) => batch.hasTable(String(row.tbl))))
|
|
1190
|
+
batch.outcomes();
|
|
1184
1191
|
if (batch.touched) {
|
|
1185
1192
|
revision = bumpLocalRevision(this.#db);
|
|
1186
1193
|
if (batch.statusChanged) {
|
|
@@ -1258,7 +1265,7 @@ export class SyncClient {
|
|
|
1258
1265
|
}
|
|
1259
1266
|
async #uploadBlob(bytes, options) {
|
|
1260
1267
|
if (this.#config.blobs === undefined) {
|
|
1261
|
-
throw
|
|
1268
|
+
throw invalidRequest('uploadBlob requires a blob transport (SyncClientConfig.blobs, §5.9)');
|
|
1262
1269
|
}
|
|
1263
1270
|
const blobId = await computeBlobId(bytes);
|
|
1264
1271
|
const nowMs = this.#now();
|
|
@@ -1298,7 +1305,7 @@ export class SyncClient {
|
|
|
1298
1305
|
return cached;
|
|
1299
1306
|
const transport = this.#config.blobs;
|
|
1300
1307
|
if (transport === undefined) {
|
|
1301
|
-
throw
|
|
1308
|
+
throw invalidRequest('fetchBlob requires a blob transport (SyncClientConfig.blobs, §5.9)');
|
|
1302
1309
|
}
|
|
1303
1310
|
// §5.9.5: the authorized endpoint serves bytes inline OR (always-issue,
|
|
1304
1311
|
// presign configured) a signed url the client fetches directly. On a url
|
|
@@ -1308,7 +1315,7 @@ export class SyncClient {
|
|
|
1308
1315
|
let bytes;
|
|
1309
1316
|
if (response.kind === 'url') {
|
|
1310
1317
|
if (transport.fetchUrl === undefined) {
|
|
1311
|
-
throw
|
|
1318
|
+
throw invalidRequest('blob download returned a url but the transport cannot fetch urls (§5.9.5)');
|
|
1312
1319
|
}
|
|
1313
1320
|
if (response.urlExpiresAtMs !== undefined &&
|
|
1314
1321
|
response.urlExpiresAtMs <= this.#now()) {
|
|
@@ -1325,14 +1332,14 @@ export class SyncClient {
|
|
|
1325
1332
|
// §5.9.5 inherits §5.1: verify the content address, reject on mismatch.
|
|
1326
1333
|
// On the url path this invalidates the fetch (no fall-through) — the
|
|
1327
1334
|
// next fetchBlob re-requests the authorized endpoint (§5.9.5 recovery).
|
|
1328
|
-
throw
|
|
1335
|
+
throw invalidRequest(`blob content address mismatch for ${blobId} (§5.9.5)`);
|
|
1329
1336
|
}
|
|
1330
1337
|
putCachedBlob(this.#db, blobId, bytes, this.#now());
|
|
1331
1338
|
this.#db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
1332
1339
|
this.#enforceBlobCacheCap();
|
|
1333
1340
|
const stored = getCachedBlob(this.#db, blobId);
|
|
1334
1341
|
if (stored === undefined) {
|
|
1335
|
-
throw
|
|
1342
|
+
throw invalidRequest('blob cache write failed');
|
|
1336
1343
|
}
|
|
1337
1344
|
return stored;
|
|
1338
1345
|
}
|
|
@@ -1416,7 +1423,7 @@ export class SyncClient {
|
|
|
1416
1423
|
/**
|
|
1417
1424
|
* Mark a durable failure handled without deleting its evidence. Conflicts
|
|
1418
1425
|
* may keep the server row or link to a replacement commit; rejections may
|
|
1419
|
-
*
|
|
1426
|
+
* also keep the server row or link to a named replacement. Applied/cached history may be
|
|
1420
1427
|
* dismissed. The transition is one-way and survives restart.
|
|
1421
1428
|
*/
|
|
1422
1429
|
resolveCommitOutcome(input) {
|
|
@@ -1432,22 +1439,30 @@ export class SyncClient {
|
|
|
1432
1439
|
if (replacement === undefined ||
|
|
1433
1440
|
replacement.length === 0 ||
|
|
1434
1441
|
replacement === input.clientCommitId) {
|
|
1435
|
-
throw
|
|
1442
|
+
throw invalidRequest('superseded outcomes require a distinct replacementClientCommitId');
|
|
1436
1443
|
}
|
|
1437
1444
|
}
|
|
1438
1445
|
else if (replacement !== undefined) {
|
|
1439
|
-
throw
|
|
1446
|
+
throw invalidRequest('replacementClientCommitId is valid only for superseded outcomes');
|
|
1440
1447
|
}
|
|
1441
1448
|
const allowed = (current.status === 'conflict' &&
|
|
1442
1449
|
(input.resolution === 'resolved_keep_server' ||
|
|
1443
1450
|
input.resolution === 'superseded')) ||
|
|
1444
|
-
(current.status === 'rejected' &&
|
|
1451
|
+
(current.status === 'rejected' &&
|
|
1452
|
+
(input.resolution === 'superseded' ||
|
|
1453
|
+
input.resolution === 'resolved_keep_server')) ||
|
|
1445
1454
|
((current.status === 'applied' || current.status === 'cached') &&
|
|
1446
1455
|
input.resolution === 'dismissed');
|
|
1447
1456
|
if (!allowed) {
|
|
1448
|
-
throw
|
|
1457
|
+
throw invalidRequest(`resolution ${input.resolution} is invalid for ${current.status} outcome`);
|
|
1449
1458
|
}
|
|
1459
|
+
const priorConflicts = this.#conflicts;
|
|
1460
|
+
const priorRejections = this.#rejections;
|
|
1450
1461
|
return this.#applyBatch((batch) => {
|
|
1462
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
1463
|
+
this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [
|
|
1464
|
+
input.clientCommitId,
|
|
1465
|
+
]);
|
|
1451
1466
|
const resolved = persistCommitOutcomeResolution(this.#db, input, this.#now());
|
|
1452
1467
|
if (resolved === undefined) {
|
|
1453
1468
|
throw new ClientSyncError('sync.outcome_not_found', `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`);
|
|
@@ -1455,12 +1470,19 @@ export class SyncClient {
|
|
|
1455
1470
|
this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== input.clientCommitId);
|
|
1456
1471
|
this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== input.clientCommitId);
|
|
1457
1472
|
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1473
|
+
this.#replayOutbox();
|
|
1474
|
+
for (const operation of current.operations ?? [])
|
|
1475
|
+
batch.table(operation.table);
|
|
1458
1476
|
batch.outcomes();
|
|
1459
1477
|
if (current.status === 'conflict')
|
|
1460
1478
|
batch.conflicts();
|
|
1461
1479
|
if (current.status === 'rejected')
|
|
1462
1480
|
batch.rejections();
|
|
1463
1481
|
return resolved;
|
|
1482
|
+
}, undefined, (error) => {
|
|
1483
|
+
this.#conflicts = priorConflicts;
|
|
1484
|
+
this.#rejections = priorRejections;
|
|
1485
|
+
throw error;
|
|
1464
1486
|
});
|
|
1465
1487
|
}
|
|
1466
1488
|
/** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
|
|
@@ -1511,7 +1533,7 @@ export class SyncClient {
|
|
|
1511
1533
|
this.#requireActive();
|
|
1512
1534
|
const socket = this.#socket;
|
|
1513
1535
|
if (socket === undefined) {
|
|
1514
|
-
throw
|
|
1536
|
+
throw invalidRequest('setPresence requires a connected realtime socket (§8.6)');
|
|
1515
1537
|
}
|
|
1516
1538
|
socket.send(encodePresencePublish(scopeKey, doc));
|
|
1517
1539
|
}
|
|
@@ -1600,7 +1622,7 @@ export class SyncClient {
|
|
|
1600
1622
|
this.#requireActive();
|
|
1601
1623
|
const table = this.#table(base.table);
|
|
1602
1624
|
if (!table.scopeColumnByVariable.has(base.variable)) {
|
|
1603
|
-
throw
|
|
1625
|
+
throw invalidRequest(`setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`);
|
|
1604
1626
|
}
|
|
1605
1627
|
// Serialize the whole window edit: it spans an `await deriveSubId` between
|
|
1606
1628
|
// db transactions, so without the chain a delta apply (or a concurrent
|
|
@@ -1761,7 +1783,10 @@ export class SyncClient {
|
|
|
1761
1783
|
*/
|
|
1762
1784
|
#pinnedRowIds(table) {
|
|
1763
1785
|
const pinned = new Set();
|
|
1764
|
-
for (const commit of
|
|
1786
|
+
for (const commit of [
|
|
1787
|
+
...failedOverlayCommits(this.#db),
|
|
1788
|
+
...listOutbox(this.#db),
|
|
1789
|
+
]) {
|
|
1765
1790
|
for (const op of commit.operations) {
|
|
1766
1791
|
if (op.table === table)
|
|
1767
1792
|
pinned.add(op.rowId);
|
|
@@ -1778,7 +1803,7 @@ export class SyncClient {
|
|
|
1778
1803
|
mutate(mutations) {
|
|
1779
1804
|
return this.#recordMutations(mutations);
|
|
1780
1805
|
}
|
|
1781
|
-
#recordMutations(mutations
|
|
1806
|
+
#recordMutations(mutations) {
|
|
1782
1807
|
this.#requireActive();
|
|
1783
1808
|
const clientCommitId = crypto.randomUUID();
|
|
1784
1809
|
const operations = mutations.map((mutation) => {
|
|
@@ -1793,7 +1818,8 @@ export class SyncClient {
|
|
|
1793
1818
|
: {}),
|
|
1794
1819
|
};
|
|
1795
1820
|
}
|
|
1796
|
-
|
|
1821
|
+
const json = {};
|
|
1822
|
+
if (mutation.op === 'patch') {
|
|
1797
1823
|
// §6.7 patch: the values map IS the presence set — the primary key
|
|
1798
1824
|
// plus the supplied non-scope columns. Absent columns keep their
|
|
1799
1825
|
// stored value; the overlay and the server both apply only the
|
|
@@ -1801,7 +1827,10 @@ export class SyncClient {
|
|
|
1801
1827
|
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
1802
1828
|
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
1803
1829
|
const pkColumn = table.columns[table.primaryKeyIndex];
|
|
1804
|
-
const
|
|
1830
|
+
const pkValue = requireRowId(normalized.get(pkColumn.name));
|
|
1831
|
+
const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0];
|
|
1832
|
+
if (!local)
|
|
1833
|
+
throw new ClientSyncError('sync.row_missing', 'a sparse patch requires a local row');
|
|
1805
1834
|
for (const column of table.columns) {
|
|
1806
1835
|
if (!normalized.has(column.name))
|
|
1807
1836
|
continue;
|
|
@@ -1816,49 +1845,29 @@ export class SyncClient {
|
|
|
1816
1845
|
// by construction the row id being patched (§6.1), so its value is
|
|
1817
1846
|
// proven equal already.
|
|
1818
1847
|
if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
? this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0]
|
|
1822
|
-
: undefined;
|
|
1823
|
-
if (local === undefined ||
|
|
1824
|
-
!Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
|
|
1825
|
-
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
|
|
1848
|
+
if (!Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
|
|
1849
|
+
throw invalidRequest(`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
|
|
1826
1850
|
}
|
|
1827
1851
|
continue;
|
|
1828
1852
|
}
|
|
1829
1853
|
const value = normalized.get(column.name);
|
|
1830
1854
|
if (value === undefined || value === null) {
|
|
1831
1855
|
if (!column.nullable) {
|
|
1832
|
-
throw
|
|
1856
|
+
throw invalidRequest(`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`);
|
|
1833
1857
|
}
|
|
1834
1858
|
json[column.name] = null;
|
|
1835
1859
|
continue;
|
|
1836
1860
|
}
|
|
1837
1861
|
json[column.name] = rowValueToJson(coerceSqlRepresentation(column, value));
|
|
1838
1862
|
}
|
|
1839
|
-
const pkValue = json[pkColumn.name];
|
|
1840
|
-
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
1841
|
-
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: upsert requires a non-empty string primary key`);
|
|
1842
|
-
}
|
|
1843
|
-
return {
|
|
1844
|
-
table: mutation.table,
|
|
1845
|
-
rowId: pkValue,
|
|
1846
|
-
op: 'upsert',
|
|
1847
|
-
...(mutation.baseVersion !== undefined
|
|
1848
|
-
? { baseVersion: mutation.baseVersion }
|
|
1849
|
-
: {}),
|
|
1850
|
-
values: json,
|
|
1851
|
-
};
|
|
1852
1863
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1864
|
+
else {
|
|
1865
|
+
const values = recordToRowValues(table, mutation.values);
|
|
1866
|
+
table.columns.forEach((column, index) => {
|
|
1867
|
+
json[column.name] = rowValueToJson(values[index] ?? null);
|
|
1868
|
+
});
|
|
1857
1869
|
}
|
|
1858
|
-
const
|
|
1859
|
-
table.columns.forEach((column, index) => {
|
|
1860
|
-
json[column.name] = rowValueToJson(values[index] ?? null);
|
|
1861
|
-
});
|
|
1870
|
+
const pkValue = requireRowId(json[table.primaryKey]);
|
|
1862
1871
|
return {
|
|
1863
1872
|
table: mutation.table,
|
|
1864
1873
|
rowId: pkValue,
|
|
@@ -1940,12 +1949,12 @@ export class SyncClient {
|
|
|
1940
1949
|
const normalized = normalizeRecordKeys(compiled, partial);
|
|
1941
1950
|
const pkOverride = normalized.get(pkColumn.name);
|
|
1942
1951
|
if (pkOverride !== undefined && pkOverride !== rowId) {
|
|
1943
|
-
throw
|
|
1952
|
+
throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
|
|
1944
1953
|
}
|
|
1945
1954
|
return this.#recordMutations([
|
|
1946
1955
|
{
|
|
1947
1956
|
table,
|
|
1948
|
-
op: '
|
|
1957
|
+
op: 'patch',
|
|
1949
1958
|
values: {
|
|
1950
1959
|
...Object.fromEntries(normalized),
|
|
1951
1960
|
[pkColumn.name]: rowId,
|
|
@@ -1954,7 +1963,7 @@ export class SyncClient {
|
|
|
1954
1963
|
? { baseVersion: options.baseVersion }
|
|
1955
1964
|
: {}),
|
|
1956
1965
|
},
|
|
1957
|
-
]
|
|
1966
|
+
]);
|
|
1958
1967
|
}
|
|
1959
1968
|
/**
|
|
1960
1969
|
* Apply one host-authorized local security purge. The host must stop/gate
|
|
@@ -1973,7 +1982,7 @@ export class SyncClient {
|
|
|
1973
1982
|
const appliedPlan = getMeta(this.#db, metaKey);
|
|
1974
1983
|
if (appliedPlan !== undefined) {
|
|
1975
1984
|
if (appliedPlan !== purge.canonicalPlan) {
|
|
1976
|
-
throw
|
|
1985
|
+
throw invalidRequest(`local purge id ${JSON.stringify(purge.purgeId)} was already used with a different plan`);
|
|
1977
1986
|
}
|
|
1978
1987
|
return { alreadyApplied: true, purgedRows: 0, droppedCommits: 0 };
|
|
1979
1988
|
}
|
|
@@ -1985,6 +1994,13 @@ export class SyncClient {
|
|
|
1985
1994
|
try {
|
|
1986
1995
|
return this.#applyBatch((batch) => {
|
|
1987
1996
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
1997
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
1998
|
+
const droppedRetained = dropFailedRows(this.#db, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
|
|
1999
|
+
for (const commit of droppedRetained) {
|
|
2000
|
+
for (const operation of commit.operations)
|
|
2001
|
+
batch.table(operation.table);
|
|
2002
|
+
batch.outcomes();
|
|
2003
|
+
}
|
|
1988
2004
|
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
1989
2005
|
// a commit is doomed when any of its ops touches a base-matching
|
|
1990
2006
|
// row. Base values only become visible here as rollbacks restore
|
|
@@ -2067,6 +2083,7 @@ export class SyncClient {
|
|
|
2067
2083
|
batch.rejections();
|
|
2068
2084
|
batch.outcomes();
|
|
2069
2085
|
}
|
|
2086
|
+
this.#replayOutbox();
|
|
2070
2087
|
this.#deleteUnreferencedBlobs();
|
|
2071
2088
|
setMeta(this.#db, metaKey, purge.canonicalPlan);
|
|
2072
2089
|
return {
|
|
@@ -2107,7 +2124,7 @@ export class SyncClient {
|
|
|
2107
2124
|
};
|
|
2108
2125
|
}
|
|
2109
2126
|
if (this.#schemaFloor !== undefined) {
|
|
2110
|
-
throw
|
|
2127
|
+
throw invalidRequest('local rebootstrap cannot bypass an active schema-floor stop; update the application first');
|
|
2111
2128
|
}
|
|
2112
2129
|
const pending = listOutbox(this.#db);
|
|
2113
2130
|
const resetSubscriptions = loadSubscriptions(this.#db).length;
|
|
@@ -2272,7 +2289,10 @@ export class SyncClient {
|
|
|
2272
2289
|
*/
|
|
2273
2290
|
#dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE) {
|
|
2274
2291
|
this.#applyBatch((batch) => {
|
|
2292
|
+
if (this.#retainFailedCommits && code === 'client.encrypt_failed')
|
|
2293
|
+
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
2275
2294
|
this.#rollbackFailedCommit(commit, batch);
|
|
2295
|
+
this.#replayOutbox();
|
|
2276
2296
|
const rejection = {
|
|
2277
2297
|
clientCommitId: commit.clientCommitId,
|
|
2278
2298
|
opIndex: 0,
|
|
@@ -2310,7 +2330,7 @@ export class SyncClient {
|
|
|
2310
2330
|
sync() {
|
|
2311
2331
|
this.#requireActive();
|
|
2312
2332
|
if (this.#syncOutstanding) {
|
|
2313
|
-
return Promise.reject(
|
|
2333
|
+
return Promise.reject(invalidRequest('sync() is already running — the core owns one loop (coalesce wake-ups)'));
|
|
2314
2334
|
}
|
|
2315
2335
|
this.#syncOutstanding = true;
|
|
2316
2336
|
this.#beginDiagnosticsDeferral();
|
|
@@ -2465,7 +2485,7 @@ export class SyncClient {
|
|
|
2465
2485
|
const responseBytes = await this.#roundTrip(requestBytes);
|
|
2466
2486
|
const message = decodeMessage(responseBytes);
|
|
2467
2487
|
if (message.msgKind !== 'response') {
|
|
2468
|
-
throw
|
|
2488
|
+
throw invalidRequest('transport returned a non-response message');
|
|
2469
2489
|
}
|
|
2470
2490
|
const summary = await this.#processResponse(message, outbox, subs, 'pull', resetEpoch);
|
|
2471
2491
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
@@ -2553,7 +2573,7 @@ export class SyncClient {
|
|
|
2553
2573
|
return last;
|
|
2554
2574
|
}
|
|
2555
2575
|
}
|
|
2556
|
-
throw
|
|
2576
|
+
throw invalidRequest('sync did not reach idle within the round budget');
|
|
2557
2577
|
}
|
|
2558
2578
|
/**
|
|
2559
2579
|
* One request/response round trip (§8.7): over the socket whenever it
|
|
@@ -2636,7 +2656,7 @@ export class SyncClient {
|
|
|
2636
2656
|
connectRealtime() {
|
|
2637
2657
|
this.#requireActive();
|
|
2638
2658
|
if (this.#realtimePolicy === 'off') {
|
|
2639
|
-
return Promise.reject(
|
|
2659
|
+
return Promise.reject(invalidRequest("realtimePolicy is 'off'; realtime connect is refused"));
|
|
2640
2660
|
}
|
|
2641
2661
|
if (this.#socket !== undefined)
|
|
2642
2662
|
return Promise.resolve();
|
|
@@ -2662,7 +2682,7 @@ export class SyncClient {
|
|
|
2662
2682
|
async #connectRealtime(generation) {
|
|
2663
2683
|
const connector = this.#config.realtime;
|
|
2664
2684
|
if (connector === undefined) {
|
|
2665
|
-
throw
|
|
2685
|
+
throw invalidRequest('no realtime connector configured');
|
|
2666
2686
|
}
|
|
2667
2687
|
let openedSocket;
|
|
2668
2688
|
try {
|
|
@@ -2743,14 +2763,14 @@ export class SyncClient {
|
|
|
2743
2763
|
}
|
|
2744
2764
|
catch (error) {
|
|
2745
2765
|
this.#pendingRound = undefined;
|
|
2746
|
-
round.reject(
|
|
2766
|
+
round.reject(invalidRequest(`malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`));
|
|
2747
2767
|
return;
|
|
2748
2768
|
}
|
|
2749
2769
|
if (done === undefined)
|
|
2750
2770
|
return;
|
|
2751
2771
|
this.#pendingRound = undefined;
|
|
2752
2772
|
if (done.excess > 0) {
|
|
2753
|
-
round.reject(
|
|
2773
|
+
round.reject(invalidRequest('response bytes past END of the round stream (§8.7)'));
|
|
2754
2774
|
return;
|
|
2755
2775
|
}
|
|
2756
2776
|
round.resolve(done.message.slice());
|
|
@@ -2890,7 +2910,7 @@ export class SyncClient {
|
|
|
2890
2910
|
}
|
|
2891
2911
|
const header = message.frames[0];
|
|
2892
2912
|
if (header?.type !== 'RESP_HEADER') {
|
|
2893
|
-
throw
|
|
2913
|
+
throw invalidRequest('missing RESP_HEADER');
|
|
2894
2914
|
}
|
|
2895
2915
|
if (message.wireVersion < 2 ||
|
|
2896
2916
|
header.logEpoch === undefined ||
|
|
@@ -3057,7 +3077,7 @@ export class SyncClient {
|
|
|
3057
3077
|
// broken server — fail loud, never skip or guess.
|
|
3058
3078
|
if (frame.mediaType === 'sqlite' &&
|
|
3059
3079
|
(this.#acceptMask() & ACCEPT_SQLITE) === 0) {
|
|
3060
|
-
throw
|
|
3080
|
+
throw invalidRequest('SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
|
|
3061
3081
|
}
|
|
3062
3082
|
const bytes = await this.#downloadSegment(frame, section.sub);
|
|
3063
3083
|
this.#progress.update({ phase: 'import' });
|
|
@@ -3066,7 +3086,7 @@ export class SyncClient {
|
|
|
3066
3086
|
// invalid, and the image is always its table's first page.
|
|
3067
3087
|
if (frame.rowCursor !== undefined ||
|
|
3068
3088
|
frame.nextRowCursor !== undefined) {
|
|
3069
|
-
throw
|
|
3089
|
+
throw invalidRequest('sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
|
|
3070
3090
|
}
|
|
3071
3091
|
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applySqliteSegment(this.#db, this.#schema, table, bytes, {
|
|
3072
3092
|
table: frame.table,
|
|
@@ -3273,6 +3293,8 @@ export class SyncClient {
|
|
|
3273
3293
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
3274
3294
|
// later pending overlays are then replayed with rebased before-images.
|
|
3275
3295
|
this.#db.transaction(() => {
|
|
3296
|
+
if (this.#retainFailedCommits)
|
|
3297
|
+
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
3276
3298
|
this.#rollbackFailedCommit(commit, batch);
|
|
3277
3299
|
});
|
|
3278
3300
|
batch.status();
|
|
@@ -3423,7 +3445,7 @@ export class SyncClient {
|
|
|
3423
3445
|
async #downloadSegment(frame, sub) {
|
|
3424
3446
|
const downloader = this.#config.segments;
|
|
3425
3447
|
if (downloader === undefined) {
|
|
3426
|
-
throw
|
|
3448
|
+
throw invalidRequest('received SEGMENT_REF but no segment downloader is configured');
|
|
3427
3449
|
}
|
|
3428
3450
|
this.#progress.update({
|
|
3429
3451
|
phase: 'download',
|
|
@@ -3447,7 +3469,7 @@ export class SyncClient {
|
|
|
3447
3469
|
const fetchUrl = downloader.fetchUrl;
|
|
3448
3470
|
if (fetchUrl === undefined ||
|
|
3449
3471
|
(this.#acceptMask() & ACCEPT_SIGNED_URLS) === 0) {
|
|
3450
|
-
throw
|
|
3472
|
+
throw invalidRequest('SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)');
|
|
3451
3473
|
}
|
|
3452
3474
|
if (frame.urlExpiresAtMs !== undefined &&
|
|
3453
3475
|
frame.urlExpiresAtMs <= this.#now()) {
|
|
@@ -3470,7 +3492,7 @@ export class SyncClient {
|
|
|
3470
3492
|
onProgress(bytes.byteLength);
|
|
3471
3493
|
const hash = await sha256Hex(bytes);
|
|
3472
3494
|
if (`sha256:${hash}` !== frame.segmentId) {
|
|
3473
|
-
throw
|
|
3495
|
+
throw invalidRequest(`segment ${frame.segmentId} failed content-address verification (§5.1)`, true);
|
|
3474
3496
|
}
|
|
3475
3497
|
return bytes;
|
|
3476
3498
|
}
|
|
@@ -3537,6 +3559,14 @@ export class SyncClient {
|
|
|
3537
3559
|
if (lastEffective !== undefined &&
|
|
3538
3560
|
Object.keys(lastEffective).length > 0) {
|
|
3539
3561
|
try {
|
|
3562
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
3563
|
+
const droppedRetained = dropFailedRows(this.#db, (name, values) => name === table.name &&
|
|
3564
|
+
Object.entries(lastEffective).every(([variable, allowed]) => allowed.includes(String(values[table.scopeColumnByVariable.get(variable)]))));
|
|
3565
|
+
for (const commit of droppedRetained) {
|
|
3566
|
+
for (const operation of commit.operations)
|
|
3567
|
+
batch.table(operation.table);
|
|
3568
|
+
batch.outcomes();
|
|
3569
|
+
}
|
|
3540
3570
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
3541
3571
|
batch.scopeMap(table, lastEffective);
|
|
3542
3572
|
const pendingById = new Map(listOutbox(this.#db).map((commit) => [
|
|
@@ -3647,7 +3677,7 @@ export class SyncClient {
|
|
|
3647
3677
|
throw new ClientSyncError('sync.local_corrupt', `rollback image for ${operation.table}/${operation.rowId} is incomplete`);
|
|
3648
3678
|
}
|
|
3649
3679
|
const values = table.columns.map((column) => jsonToRowValue(image.values?.[column.name] ?? null));
|
|
3650
|
-
upsertLocalRow(this.#db, table, values, image.syncVersion);
|
|
3680
|
+
upsertLocalRow(this.#db, table, values, image.syncVersion, false);
|
|
3651
3681
|
}
|
|
3652
3682
|
#legacyUndoOptimisticRows(commit, batch) {
|
|
3653
3683
|
for (const operation of commit.operations) {
|
|
@@ -3707,7 +3737,7 @@ export class SyncClient {
|
|
|
3707
3737
|
if (op.op === 'delete') {
|
|
3708
3738
|
if (batch !== undefined && !precise)
|
|
3709
3739
|
batch.table(op.table);
|
|
3710
|
-
deleteLocalRow(this.#db, table, op.rowId);
|
|
3740
|
+
deleteLocalRow(this.#db, table, op.rowId, false);
|
|
3711
3741
|
continue;
|
|
3712
3742
|
}
|
|
3713
3743
|
// §7.1 overlay: apply the operation's PRESENT columns over the current
|
|
@@ -3747,19 +3777,23 @@ export class SyncClient {
|
|
|
3747
3777
|
const version = local === undefined
|
|
3748
3778
|
? OPTIMISTIC_VERSION
|
|
3749
3779
|
: local[SYNC_VERSION_COLUMN];
|
|
3750
|
-
upsertLocalRow(this.#db, table, values, version);
|
|
3780
|
+
upsertLocalRow(this.#db, table, values, version, false);
|
|
3751
3781
|
}
|
|
3752
3782
|
}
|
|
3753
3783
|
/** Re-apply every pending outbox commit on top of server state (§7.1). */
|
|
3754
3784
|
#replayOutbox() {
|
|
3755
|
-
const pending =
|
|
3785
|
+
const pending = [
|
|
3786
|
+
...failedOverlayCommits(this.#db),
|
|
3787
|
+
...listOutbox(this.#db),
|
|
3788
|
+
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
3756
3789
|
if (pending.length === 0)
|
|
3757
3790
|
return;
|
|
3758
3791
|
this.#applyBatch((batch) => {
|
|
3759
3792
|
this.#db.transaction(() => {
|
|
3760
|
-
|
|
3793
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
3794
|
+
restoreFailedBases(this.#db, this.#schema, true);
|
|
3795
|
+
for (const commit of pending)
|
|
3761
3796
|
this.#applyOperationsLocally(commit.operations, batch);
|
|
3762
|
-
}
|
|
3763
3797
|
});
|
|
3764
3798
|
});
|
|
3765
3799
|
}
|
|
@@ -3778,7 +3812,7 @@ export class SyncClient {
|
|
|
3778
3812
|
}
|
|
3779
3813
|
#requireStarted() {
|
|
3780
3814
|
if (!this.#started) {
|
|
3781
|
-
throw
|
|
3815
|
+
throw invalidRequest('SyncClient.start() has not completed');
|
|
3782
3816
|
}
|
|
3783
3817
|
}
|
|
3784
3818
|
#requireActive() {
|
|
@@ -3788,3 +3822,8 @@ export class SyncClient {
|
|
|
3788
3822
|
}
|
|
3789
3823
|
}
|
|
3790
3824
|
}
|
|
3825
|
+
function requireRowId(value) {
|
|
3826
|
+
if (typeof value !== 'string' || !value.length)
|
|
3827
|
+
throw invalidRequest('a mutation requires a non-empty string primary key');
|
|
3828
|
+
return value;
|
|
3829
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export declare class ClientSyncError extends Error {
|
|
|
25
25
|
readonly details: Readonly<Record<string, unknown>> | undefined;
|
|
26
26
|
constructor(code: string, message: string, retryable?: boolean, details?: Readonly<Record<string, unknown>>);
|
|
27
27
|
}
|
|
28
|
+
/** Shared invalid-request construction for client validators and transport guards. */
|
|
29
|
+
export declare function invalidRequest(message: string, retryable?: boolean): ClientSyncError;
|
|
28
30
|
/**
|
|
29
31
|
* SPEC §7.5: the latest sync attempt failed while a live query still waited
|
|
30
32
|
* for its required coverage. `code` is the attempt's static progress error
|
package/dist/errors.js
CHANGED
|
@@ -29,6 +29,10 @@ export class ClientSyncError extends Error {
|
|
|
29
29
|
this.details = details;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/** Shared invalid-request construction for client validators and transport guards. */
|
|
33
|
+
export function invalidRequest(message, retryable = false) {
|
|
34
|
+
return new ClientSyncError('sync.invalid_request', message, retryable);
|
|
35
|
+
}
|
|
32
36
|
/**
|
|
33
37
|
* SPEC §7.5: the latest sync attempt failed while a live query still waited
|
|
34
38
|
* for its required coverage. `code` is the attempt's static progress error
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ClientDatabase } from './database.js';
|
|
2
|
+
import type { CompiledClientSchema, JsonRowValue } from './schema.js';
|
|
3
|
+
import type { OutboxCommit, OutboxBeforeImage } from './outbox.js';
|
|
4
|
+
/** Protected failure state. The server base advances independently of intent. */
|
|
5
|
+
export declare function ensureFailedOverlaySchema(db: ClientDatabase): void;
|
|
6
|
+
export declare function retainedBaseWrite(db: ClientDatabase, table: string, rowId: string, values?: Readonly<Record<string, JsonRowValue>>, version?: number): void;
|
|
7
|
+
export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[]): void;
|
|
8
|
+
export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean): void;
|
|
9
|
+
export declare function failedOverlayCommits(db: ClientDatabase): OutboxCommit[];
|
|
10
|
+
export declare function dropFailedRows(db: ClientDatabase, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
|