@syncular/client 0.30.7 → 0.30.9
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 +12 -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 +112 -74
- 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 +97 -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 +3 -3
- package/src/apply.ts +46 -13
- package/src/client.ts +182 -170
- package/src/errors.ts +8 -0
- package/src/failed-overlay.ts +165 -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,6 @@ 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 json = {};
|
|
1805
1830
|
for (const column of table.columns) {
|
|
1806
1831
|
if (!normalized.has(column.name))
|
|
1807
1832
|
continue;
|
|
@@ -1822,43 +1847,31 @@ export class SyncClient {
|
|
|
1822
1847
|
: undefined;
|
|
1823
1848
|
if (local === undefined ||
|
|
1824
1849
|
!Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
|
|
1825
|
-
throw
|
|
1850
|
+
throw invalidRequest(`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
|
|
1826
1851
|
}
|
|
1827
1852
|
continue;
|
|
1828
1853
|
}
|
|
1829
1854
|
const value = normalized.get(column.name);
|
|
1830
1855
|
if (value === undefined || value === null) {
|
|
1831
1856
|
if (!column.nullable) {
|
|
1832
|
-
throw
|
|
1857
|
+
throw invalidRequest(`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`);
|
|
1833
1858
|
}
|
|
1834
1859
|
json[column.name] = null;
|
|
1835
1860
|
continue;
|
|
1836
1861
|
}
|
|
1837
1862
|
json[column.name] = rowValueToJson(coerceSqlRepresentation(column, value));
|
|
1838
1863
|
}
|
|
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
1864
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
1865
|
+
else {
|
|
1866
|
+
const values = recordToRowValues(table, mutation.values);
|
|
1867
|
+
table.columns.forEach((column, index) => {
|
|
1868
|
+
json[column.name] = rowValueToJson(values[index] ?? null);
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
const pkValue = json[table.primaryKey];
|
|
1855
1872
|
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
1856
|
-
throw
|
|
1873
|
+
throw invalidRequest(`table ${table.name}: upsert requires a non-empty string primary key`);
|
|
1857
1874
|
}
|
|
1858
|
-
const json = {};
|
|
1859
|
-
table.columns.forEach((column, index) => {
|
|
1860
|
-
json[column.name] = rowValueToJson(values[index] ?? null);
|
|
1861
|
-
});
|
|
1862
1875
|
return {
|
|
1863
1876
|
table: mutation.table,
|
|
1864
1877
|
rowId: pkValue,
|
|
@@ -1940,12 +1953,12 @@ export class SyncClient {
|
|
|
1940
1953
|
const normalized = normalizeRecordKeys(compiled, partial);
|
|
1941
1954
|
const pkOverride = normalized.get(pkColumn.name);
|
|
1942
1955
|
if (pkOverride !== undefined && pkOverride !== rowId) {
|
|
1943
|
-
throw
|
|
1956
|
+
throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
|
|
1944
1957
|
}
|
|
1945
1958
|
return this.#recordMutations([
|
|
1946
1959
|
{
|
|
1947
1960
|
table,
|
|
1948
|
-
op: '
|
|
1961
|
+
op: 'patch',
|
|
1949
1962
|
values: {
|
|
1950
1963
|
...Object.fromEntries(normalized),
|
|
1951
1964
|
[pkColumn.name]: rowId,
|
|
@@ -1954,7 +1967,7 @@ export class SyncClient {
|
|
|
1954
1967
|
? { baseVersion: options.baseVersion }
|
|
1955
1968
|
: {}),
|
|
1956
1969
|
},
|
|
1957
|
-
]
|
|
1970
|
+
]);
|
|
1958
1971
|
}
|
|
1959
1972
|
/**
|
|
1960
1973
|
* Apply one host-authorized local security purge. The host must stop/gate
|
|
@@ -1973,7 +1986,7 @@ export class SyncClient {
|
|
|
1973
1986
|
const appliedPlan = getMeta(this.#db, metaKey);
|
|
1974
1987
|
if (appliedPlan !== undefined) {
|
|
1975
1988
|
if (appliedPlan !== purge.canonicalPlan) {
|
|
1976
|
-
throw
|
|
1989
|
+
throw invalidRequest(`local purge id ${JSON.stringify(purge.purgeId)} was already used with a different plan`);
|
|
1977
1990
|
}
|
|
1978
1991
|
return { alreadyApplied: true, purgedRows: 0, droppedCommits: 0 };
|
|
1979
1992
|
}
|
|
@@ -1985,6 +1998,13 @@ export class SyncClient {
|
|
|
1985
1998
|
try {
|
|
1986
1999
|
return this.#applyBatch((batch) => {
|
|
1987
2000
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
2001
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
2002
|
+
const droppedRetained = dropFailedRows(this.#db, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
|
|
2003
|
+
for (const commit of droppedRetained) {
|
|
2004
|
+
for (const operation of commit.operations)
|
|
2005
|
+
batch.table(operation.table);
|
|
2006
|
+
batch.outcomes();
|
|
2007
|
+
}
|
|
1988
2008
|
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
1989
2009
|
// a commit is doomed when any of its ops touches a base-matching
|
|
1990
2010
|
// row. Base values only become visible here as rollbacks restore
|
|
@@ -2067,6 +2087,7 @@ export class SyncClient {
|
|
|
2067
2087
|
batch.rejections();
|
|
2068
2088
|
batch.outcomes();
|
|
2069
2089
|
}
|
|
2090
|
+
this.#replayOutbox();
|
|
2070
2091
|
this.#deleteUnreferencedBlobs();
|
|
2071
2092
|
setMeta(this.#db, metaKey, purge.canonicalPlan);
|
|
2072
2093
|
return {
|
|
@@ -2107,7 +2128,7 @@ export class SyncClient {
|
|
|
2107
2128
|
};
|
|
2108
2129
|
}
|
|
2109
2130
|
if (this.#schemaFloor !== undefined) {
|
|
2110
|
-
throw
|
|
2131
|
+
throw invalidRequest('local rebootstrap cannot bypass an active schema-floor stop; update the application first');
|
|
2111
2132
|
}
|
|
2112
2133
|
const pending = listOutbox(this.#db);
|
|
2113
2134
|
const resetSubscriptions = loadSubscriptions(this.#db).length;
|
|
@@ -2272,7 +2293,10 @@ export class SyncClient {
|
|
|
2272
2293
|
*/
|
|
2273
2294
|
#dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE) {
|
|
2274
2295
|
this.#applyBatch((batch) => {
|
|
2296
|
+
if (this.#retainFailedCommits && code === 'client.encrypt_failed')
|
|
2297
|
+
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
2275
2298
|
this.#rollbackFailedCommit(commit, batch);
|
|
2299
|
+
this.#replayOutbox();
|
|
2276
2300
|
const rejection = {
|
|
2277
2301
|
clientCommitId: commit.clientCommitId,
|
|
2278
2302
|
opIndex: 0,
|
|
@@ -2310,7 +2334,7 @@ export class SyncClient {
|
|
|
2310
2334
|
sync() {
|
|
2311
2335
|
this.#requireActive();
|
|
2312
2336
|
if (this.#syncOutstanding) {
|
|
2313
|
-
return Promise.reject(
|
|
2337
|
+
return Promise.reject(invalidRequest('sync() is already running — the core owns one loop (coalesce wake-ups)'));
|
|
2314
2338
|
}
|
|
2315
2339
|
this.#syncOutstanding = true;
|
|
2316
2340
|
this.#beginDiagnosticsDeferral();
|
|
@@ -2465,7 +2489,7 @@ export class SyncClient {
|
|
|
2465
2489
|
const responseBytes = await this.#roundTrip(requestBytes);
|
|
2466
2490
|
const message = decodeMessage(responseBytes);
|
|
2467
2491
|
if (message.msgKind !== 'response') {
|
|
2468
|
-
throw
|
|
2492
|
+
throw invalidRequest('transport returned a non-response message');
|
|
2469
2493
|
}
|
|
2470
2494
|
const summary = await this.#processResponse(message, outbox, subs, 'pull', resetEpoch);
|
|
2471
2495
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
@@ -2553,7 +2577,7 @@ export class SyncClient {
|
|
|
2553
2577
|
return last;
|
|
2554
2578
|
}
|
|
2555
2579
|
}
|
|
2556
|
-
throw
|
|
2580
|
+
throw invalidRequest('sync did not reach idle within the round budget');
|
|
2557
2581
|
}
|
|
2558
2582
|
/**
|
|
2559
2583
|
* One request/response round trip (§8.7): over the socket whenever it
|
|
@@ -2636,7 +2660,7 @@ export class SyncClient {
|
|
|
2636
2660
|
connectRealtime() {
|
|
2637
2661
|
this.#requireActive();
|
|
2638
2662
|
if (this.#realtimePolicy === 'off') {
|
|
2639
|
-
return Promise.reject(
|
|
2663
|
+
return Promise.reject(invalidRequest("realtimePolicy is 'off'; realtime connect is refused"));
|
|
2640
2664
|
}
|
|
2641
2665
|
if (this.#socket !== undefined)
|
|
2642
2666
|
return Promise.resolve();
|
|
@@ -2662,7 +2686,7 @@ export class SyncClient {
|
|
|
2662
2686
|
async #connectRealtime(generation) {
|
|
2663
2687
|
const connector = this.#config.realtime;
|
|
2664
2688
|
if (connector === undefined) {
|
|
2665
|
-
throw
|
|
2689
|
+
throw invalidRequest('no realtime connector configured');
|
|
2666
2690
|
}
|
|
2667
2691
|
let openedSocket;
|
|
2668
2692
|
try {
|
|
@@ -2743,14 +2767,14 @@ export class SyncClient {
|
|
|
2743
2767
|
}
|
|
2744
2768
|
catch (error) {
|
|
2745
2769
|
this.#pendingRound = undefined;
|
|
2746
|
-
round.reject(
|
|
2770
|
+
round.reject(invalidRequest(`malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`));
|
|
2747
2771
|
return;
|
|
2748
2772
|
}
|
|
2749
2773
|
if (done === undefined)
|
|
2750
2774
|
return;
|
|
2751
2775
|
this.#pendingRound = undefined;
|
|
2752
2776
|
if (done.excess > 0) {
|
|
2753
|
-
round.reject(
|
|
2777
|
+
round.reject(invalidRequest('response bytes past END of the round stream (§8.7)'));
|
|
2754
2778
|
return;
|
|
2755
2779
|
}
|
|
2756
2780
|
round.resolve(done.message.slice());
|
|
@@ -2890,7 +2914,7 @@ export class SyncClient {
|
|
|
2890
2914
|
}
|
|
2891
2915
|
const header = message.frames[0];
|
|
2892
2916
|
if (header?.type !== 'RESP_HEADER') {
|
|
2893
|
-
throw
|
|
2917
|
+
throw invalidRequest('missing RESP_HEADER');
|
|
2894
2918
|
}
|
|
2895
2919
|
if (message.wireVersion < 2 ||
|
|
2896
2920
|
header.logEpoch === undefined ||
|
|
@@ -3057,7 +3081,7 @@ export class SyncClient {
|
|
|
3057
3081
|
// broken server — fail loud, never skip or guess.
|
|
3058
3082
|
if (frame.mediaType === 'sqlite' &&
|
|
3059
3083
|
(this.#acceptMask() & ACCEPT_SQLITE) === 0) {
|
|
3060
|
-
throw
|
|
3084
|
+
throw invalidRequest('SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
|
|
3061
3085
|
}
|
|
3062
3086
|
const bytes = await this.#downloadSegment(frame, section.sub);
|
|
3063
3087
|
this.#progress.update({ phase: 'import' });
|
|
@@ -3066,7 +3090,7 @@ export class SyncClient {
|
|
|
3066
3090
|
// invalid, and the image is always its table's first page.
|
|
3067
3091
|
if (frame.rowCursor !== undefined ||
|
|
3068
3092
|
frame.nextRowCursor !== undefined) {
|
|
3069
|
-
throw
|
|
3093
|
+
throw invalidRequest('sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
|
|
3070
3094
|
}
|
|
3071
3095
|
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applySqliteSegment(this.#db, this.#schema, table, bytes, {
|
|
3072
3096
|
table: frame.table,
|
|
@@ -3273,6 +3297,8 @@ export class SyncClient {
|
|
|
3273
3297
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
3274
3298
|
// later pending overlays are then replayed with rebased before-images.
|
|
3275
3299
|
this.#db.transaction(() => {
|
|
3300
|
+
if (this.#retainFailedCommits)
|
|
3301
|
+
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
3276
3302
|
this.#rollbackFailedCommit(commit, batch);
|
|
3277
3303
|
});
|
|
3278
3304
|
batch.status();
|
|
@@ -3423,7 +3449,7 @@ export class SyncClient {
|
|
|
3423
3449
|
async #downloadSegment(frame, sub) {
|
|
3424
3450
|
const downloader = this.#config.segments;
|
|
3425
3451
|
if (downloader === undefined) {
|
|
3426
|
-
throw
|
|
3452
|
+
throw invalidRequest('received SEGMENT_REF but no segment downloader is configured');
|
|
3427
3453
|
}
|
|
3428
3454
|
this.#progress.update({
|
|
3429
3455
|
phase: 'download',
|
|
@@ -3447,7 +3473,7 @@ export class SyncClient {
|
|
|
3447
3473
|
const fetchUrl = downloader.fetchUrl;
|
|
3448
3474
|
if (fetchUrl === undefined ||
|
|
3449
3475
|
(this.#acceptMask() & ACCEPT_SIGNED_URLS) === 0) {
|
|
3450
|
-
throw
|
|
3476
|
+
throw invalidRequest('SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)');
|
|
3451
3477
|
}
|
|
3452
3478
|
if (frame.urlExpiresAtMs !== undefined &&
|
|
3453
3479
|
frame.urlExpiresAtMs <= this.#now()) {
|
|
@@ -3470,7 +3496,7 @@ export class SyncClient {
|
|
|
3470
3496
|
onProgress(bytes.byteLength);
|
|
3471
3497
|
const hash = await sha256Hex(bytes);
|
|
3472
3498
|
if (`sha256:${hash}` !== frame.segmentId) {
|
|
3473
|
-
throw
|
|
3499
|
+
throw invalidRequest(`segment ${frame.segmentId} failed content-address verification (§5.1)`, true);
|
|
3474
3500
|
}
|
|
3475
3501
|
return bytes;
|
|
3476
3502
|
}
|
|
@@ -3537,6 +3563,14 @@ export class SyncClient {
|
|
|
3537
3563
|
if (lastEffective !== undefined &&
|
|
3538
3564
|
Object.keys(lastEffective).length > 0) {
|
|
3539
3565
|
try {
|
|
3566
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
3567
|
+
const droppedRetained = dropFailedRows(this.#db, (name, values) => name === table.name &&
|
|
3568
|
+
Object.entries(lastEffective).every(([variable, allowed]) => allowed.includes(String(values[table.scopeColumnByVariable.get(variable)]))));
|
|
3569
|
+
for (const commit of droppedRetained) {
|
|
3570
|
+
for (const operation of commit.operations)
|
|
3571
|
+
batch.table(operation.table);
|
|
3572
|
+
batch.outcomes();
|
|
3573
|
+
}
|
|
3540
3574
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
3541
3575
|
batch.scopeMap(table, lastEffective);
|
|
3542
3576
|
const pendingById = new Map(listOutbox(this.#db).map((commit) => [
|
|
@@ -3647,7 +3681,7 @@ export class SyncClient {
|
|
|
3647
3681
|
throw new ClientSyncError('sync.local_corrupt', `rollback image for ${operation.table}/${operation.rowId} is incomplete`);
|
|
3648
3682
|
}
|
|
3649
3683
|
const values = table.columns.map((column) => jsonToRowValue(image.values?.[column.name] ?? null));
|
|
3650
|
-
upsertLocalRow(this.#db, table, values, image.syncVersion);
|
|
3684
|
+
upsertLocalRow(this.#db, table, values, image.syncVersion, false);
|
|
3651
3685
|
}
|
|
3652
3686
|
#legacyUndoOptimisticRows(commit, batch) {
|
|
3653
3687
|
for (const operation of commit.operations) {
|
|
@@ -3707,7 +3741,7 @@ export class SyncClient {
|
|
|
3707
3741
|
if (op.op === 'delete') {
|
|
3708
3742
|
if (batch !== undefined && !precise)
|
|
3709
3743
|
batch.table(op.table);
|
|
3710
|
-
deleteLocalRow(this.#db, table, op.rowId);
|
|
3744
|
+
deleteLocalRow(this.#db, table, op.rowId, false);
|
|
3711
3745
|
continue;
|
|
3712
3746
|
}
|
|
3713
3747
|
// §7.1 overlay: apply the operation's PRESENT columns over the current
|
|
@@ -3747,19 +3781,23 @@ export class SyncClient {
|
|
|
3747
3781
|
const version = local === undefined
|
|
3748
3782
|
? OPTIMISTIC_VERSION
|
|
3749
3783
|
: local[SYNC_VERSION_COLUMN];
|
|
3750
|
-
upsertLocalRow(this.#db, table, values, version);
|
|
3784
|
+
upsertLocalRow(this.#db, table, values, version, false);
|
|
3751
3785
|
}
|
|
3752
3786
|
}
|
|
3753
3787
|
/** Re-apply every pending outbox commit on top of server state (§7.1). */
|
|
3754
3788
|
#replayOutbox() {
|
|
3755
|
-
const pending =
|
|
3789
|
+
const pending = [
|
|
3790
|
+
...failedOverlayCommits(this.#db),
|
|
3791
|
+
...listOutbox(this.#db),
|
|
3792
|
+
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
3756
3793
|
if (pending.length === 0)
|
|
3757
3794
|
return;
|
|
3758
3795
|
this.#applyBatch((batch) => {
|
|
3759
3796
|
this.#db.transaction(() => {
|
|
3760
|
-
|
|
3797
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
3798
|
+
restoreFailedBases(this.#db, this.#schema, true);
|
|
3799
|
+
for (const commit of pending)
|
|
3761
3800
|
this.#applyOperationsLocally(commit.operations, batch);
|
|
3762
|
-
}
|
|
3763
3801
|
});
|
|
3764
3802
|
});
|
|
3765
3803
|
}
|
|
@@ -3778,7 +3816,7 @@ export class SyncClient {
|
|
|
3778
3816
|
}
|
|
3779
3817
|
#requireStarted() {
|
|
3780
3818
|
if (!this.#started) {
|
|
3781
|
-
throw
|
|
3819
|
+
throw invalidRequest('SyncClient.start() has not completed');
|
|
3782
3820
|
}
|
|
3783
3821
|
}
|
|
3784
3822
|
#requireActive() {
|
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[];
|