@syncular/client 0.30.11 → 0.30.13
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 +7 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +44 -36
- package/dist/client.d.ts +3 -0
- package/dist/client.js +205 -181
- package/dist/failed-overlay.d.ts +4 -3
- package/dist/failed-overlay.js +58 -43
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +14 -24
- package/dist/outcomes.js +6 -5
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +63 -72
- package/dist/worker-entry.js +14 -35
- 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 +81 -50
- package/src/client.ts +263 -226
- package/src/failed-overlay.ts +68 -57
- package/src/outbox.ts +19 -28
- package/src/outcomes.ts +7 -6
- package/src/schema.ts +81 -88
- package/src/worker-entry.ts +13 -50
- 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 { RETAINED_ROWS } from './failed-overlay.js';
|
|
1
2
|
import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
|
|
2
3
|
import { ProgressEmitter, } from './progress.js';
|
|
3
4
|
/**
|
|
@@ -11,7 +12,7 @@ import { ProgressEmitter, } from './progress.js';
|
|
|
11
12
|
*/
|
|
12
13
|
import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
13
14
|
import { EncryptError } from '@syncular/core';
|
|
14
|
-
import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
|
|
15
|
+
import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, localScopePredicate, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
|
|
15
16
|
import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
|
|
16
17
|
import { registerDevtools } from './devtools.js';
|
|
17
18
|
import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, MAX_DIAGNOSTIC_QUERY_FAILURES, } from './diagnostics.js';
|
|
@@ -25,7 +26,7 @@ import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encod
|
|
|
25
26
|
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
26
27
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
27
28
|
import { buildPreviousVersionAudit, capturePreviousVersion, clearPreviousVersionAudit, clearPreviousVersionRefusal, dropPreviousVersionContainer, loadLocalSchemaDescriptor, PREVIOUS_VERSION_CONTAINER_NAME, PREVIOUS_VERSION_MAX_LIMIT, readPreviousVersionContainer, readPreviousVersionRows, setLocalSchemaDescriptor, storedPreviousVersionAudit, storedPreviousVersionRefusal, withPreviousVersionContainer, writePreviousVersionAudit, writePreviousVersionRefusal, } from './previous-version.js';
|
|
28
|
-
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';
|
|
29
|
+
import { compileClientSchema, matchesLocalScopes, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
29
30
|
import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, saveSubscriptionScopeSchema, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
|
|
30
31
|
import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
|
|
31
32
|
/** Stable client-local error while protected operations are preflight-gated. */
|
|
@@ -132,6 +133,7 @@ export class SyncClient {
|
|
|
132
133
|
/** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
|
|
133
134
|
#previousVersion;
|
|
134
135
|
#securityLifecycle;
|
|
136
|
+
#transportEnabled;
|
|
135
137
|
#now;
|
|
136
138
|
#outcomeRetentionMaxEntries;
|
|
137
139
|
#retainFailedCommits;
|
|
@@ -220,8 +222,13 @@ export class SyncClient {
|
|
|
220
222
|
#protectedAsync = new Set();
|
|
221
223
|
#preflightBarrier;
|
|
222
224
|
constructor(config) {
|
|
225
|
+
if (config.transportEnabled !== undefined &&
|
|
226
|
+
typeof config.transportEnabled !== 'boolean') {
|
|
227
|
+
throw invalidRequest('transportEnabled must be a boolean');
|
|
228
|
+
}
|
|
229
|
+
this.#transportEnabled = config.transportEnabled !== false;
|
|
223
230
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
224
|
-
throw invalidRequest('securityPreflight and encryption are mutually exclusive;
|
|
231
|
+
throw invalidRequest('securityPreflight and encryption are mutually exclusive; activateSecurity installs keys');
|
|
225
232
|
}
|
|
226
233
|
this.#retainFailedCommits = config.retainFailedCommits === true;
|
|
227
234
|
this.#db = config.database;
|
|
@@ -287,7 +294,7 @@ export class SyncClient {
|
|
|
287
294
|
persisted !== this.#config.clientId) {
|
|
288
295
|
await this.#lease.release();
|
|
289
296
|
this.#lease = undefined;
|
|
290
|
-
throw new ClientSyncError('client.identity_mismatch', `
|
|
297
|
+
throw new ClientSyncError('client.identity_mismatch', `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`);
|
|
291
298
|
}
|
|
292
299
|
this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
|
|
293
300
|
if (persisted === undefined) {
|
|
@@ -319,6 +326,7 @@ export class SyncClient {
|
|
|
319
326
|
// an application-issued sync() call.
|
|
320
327
|
const startupWork = this.#schemaFloor === undefined &&
|
|
321
328
|
(countOutbox(this.#db) > 0 ||
|
|
329
|
+
this.#db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`).length > 0 ||
|
|
322
330
|
loadPendingEvictions(this.#db).length > 0 ||
|
|
323
331
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
324
332
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
@@ -396,6 +404,7 @@ export class SyncClient {
|
|
|
396
404
|
writePreviousVersionAudit(this.#db, buildPreviousVersionAudit(this.#schema, listOutbox(this.#db), previousVersion, this.#schema.version, this.#now()));
|
|
397
405
|
}
|
|
398
406
|
pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema, true);
|
|
407
|
+
this.#discardAcknowledgedRows();
|
|
399
408
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
400
409
|
resetSubscriptionsForBump(this.#db);
|
|
401
410
|
setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
|
|
@@ -477,6 +486,7 @@ export class SyncClient {
|
|
|
477
486
|
this.#sweepPreviousVersionContainer();
|
|
478
487
|
this.#applyBatch((batch) => {
|
|
479
488
|
this.#db.transaction(() => {
|
|
489
|
+
this.#discardAcknowledgedRows();
|
|
480
490
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
481
491
|
resetSubscriptionsForBump(this.#db);
|
|
482
492
|
setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
|
|
@@ -533,6 +543,8 @@ export class SyncClient {
|
|
|
533
543
|
this.#syncIntentListeners.clear();
|
|
534
544
|
}
|
|
535
545
|
#emitSyncNeeded(reason) {
|
|
546
|
+
if (!this.#transportEnabled)
|
|
547
|
+
return;
|
|
536
548
|
try {
|
|
537
549
|
this.#config.onSyncNeeded?.(reason);
|
|
538
550
|
}
|
|
@@ -549,6 +561,8 @@ export class SyncClient {
|
|
|
549
561
|
}
|
|
550
562
|
}
|
|
551
563
|
#emitSyncIntent(intent) {
|
|
564
|
+
if (!this.#transportEnabled && intent.kind !== 'none')
|
|
565
|
+
return;
|
|
552
566
|
try {
|
|
553
567
|
this.#config.onSyncIntent?.(intent);
|
|
554
568
|
}
|
|
@@ -564,6 +578,25 @@ export class SyncClient {
|
|
|
564
578
|
}
|
|
565
579
|
}
|
|
566
580
|
}
|
|
581
|
+
/** Host-owned transport policy; the local replica remains available. */
|
|
582
|
+
setTransportEnabled(enabled) {
|
|
583
|
+
this.#requireStarted();
|
|
584
|
+
if (typeof enabled !== 'boolean')
|
|
585
|
+
throw invalidRequest('transportEnabled must be a boolean');
|
|
586
|
+
if (this.#transportEnabled === enabled)
|
|
587
|
+
return;
|
|
588
|
+
this.#transportEnabled = enabled;
|
|
589
|
+
if (!enabled && !this.#syncOutstanding)
|
|
590
|
+
this.disconnectRealtime();
|
|
591
|
+
this.#emitSyncIntent(enabled && this.#securityLifecycle === 'active'
|
|
592
|
+
? { kind: 'interactive' }
|
|
593
|
+
: { kind: 'none' });
|
|
594
|
+
this.#emitDiagnostics();
|
|
595
|
+
}
|
|
596
|
+
#requireTransport() {
|
|
597
|
+
if (!this.#transportEnabled)
|
|
598
|
+
throw new ClientSyncError('sync.offline', 'transport gate is closed');
|
|
599
|
+
}
|
|
567
600
|
/** Current fail-closed local-replica security state. */
|
|
568
601
|
securityLifecycle() {
|
|
569
602
|
return this.#securityLifecycle;
|
|
@@ -1024,12 +1057,14 @@ export class SyncClient {
|
|
|
1024
1057
|
}
|
|
1025
1058
|
const capturedAtMs = this.#now();
|
|
1026
1059
|
const leaseState = this.#diagnosticLease(capturedAtMs);
|
|
1027
|
-
const connectivity = this.#
|
|
1028
|
-
? '
|
|
1029
|
-
: this.#lastRound?.status === '
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1060
|
+
const connectivity = !this.#transportEnabled
|
|
1061
|
+
? 'offline'
|
|
1062
|
+
: this.#lastRound?.status === 'succeeded'
|
|
1063
|
+
? 'online'
|
|
1064
|
+
: this.#lastRound?.status === 'failed' &&
|
|
1065
|
+
this.#transportFailureCode(this.#lastRound.errorCode)
|
|
1066
|
+
? 'offline'
|
|
1067
|
+
: 'unknown';
|
|
1033
1068
|
const expectedOrder = new Map(expected.map((item, index) => [item.id, index]));
|
|
1034
1069
|
const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
|
|
1035
1070
|
const aExpected = expectedOrder.get(a.id);
|
|
@@ -1053,16 +1088,12 @@ export class SyncClient {
|
|
|
1053
1088
|
realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
|
|
1054
1089
|
}),
|
|
1055
1090
|
securityLifecycle: this.#securityLifecycle,
|
|
1056
|
-
schema: {
|
|
1091
|
+
schema: definedObject({
|
|
1057
1092
|
currentVersion: this.#config.schema.version,
|
|
1058
1093
|
upgrading: this.#upgrading,
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
...(this.#schemaFloor?.latestSchemaVersion !== undefined
|
|
1063
|
-
? { latestVersion: this.#schemaFloor.latestSchemaVersion }
|
|
1064
|
-
: {}),
|
|
1065
|
-
},
|
|
1094
|
+
requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
|
|
1095
|
+
latestVersion: this.#schemaFloor?.latestSchemaVersion,
|
|
1096
|
+
}),
|
|
1066
1097
|
replica: {
|
|
1067
1098
|
localRevision: getLocalRevision(this.#db).toString(),
|
|
1068
1099
|
syncNeeded: this.#needsPull,
|
|
@@ -1179,7 +1210,7 @@ export class SyncClient {
|
|
|
1179
1210
|
try {
|
|
1180
1211
|
result = fn(batch);
|
|
1181
1212
|
if (this.#db
|
|
1182
|
-
.query(
|
|
1213
|
+
.query(`SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`)
|
|
1183
1214
|
.some((row) => batch.hasTable(String(row.tbl))))
|
|
1184
1215
|
batch.outcomes();
|
|
1185
1216
|
if (batch.touched) {
|
|
@@ -1242,7 +1273,11 @@ export class SyncClient {
|
|
|
1242
1273
|
this.#requireActive();
|
|
1243
1274
|
const task = Promise.resolve().then(fn);
|
|
1244
1275
|
this.#protectedAsync.add(task);
|
|
1245
|
-
|
|
1276
|
+
const settled = () => {
|
|
1277
|
+
if (this.#realtimeConnectPromise === task)
|
|
1278
|
+
this.#realtimeConnectPromise = undefined;
|
|
1279
|
+
};
|
|
1280
|
+
void task.then(settled, settled);
|
|
1246
1281
|
return task;
|
|
1247
1282
|
}
|
|
1248
1283
|
// -- blobs (§5.9) ---------------------------------------------------------
|
|
@@ -1268,14 +1303,12 @@ export class SyncClient {
|
|
|
1268
1303
|
recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
|
|
1269
1304
|
});
|
|
1270
1305
|
this.#enforceBlobCacheCap();
|
|
1271
|
-
return {
|
|
1306
|
+
return definedObject({
|
|
1272
1307
|
blobId,
|
|
1273
1308
|
byteLength: bytes.length,
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
...(options?.name !== undefined ? { name: options.name } : {}),
|
|
1278
|
-
};
|
|
1309
|
+
mediaType: options?.mediaType,
|
|
1310
|
+
name: options?.name,
|
|
1311
|
+
});
|
|
1279
1312
|
}
|
|
1280
1313
|
/** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
|
|
1281
1314
|
blobRefString(ref) {
|
|
@@ -1305,6 +1338,7 @@ export class SyncClient {
|
|
|
1305
1338
|
// presign configured) a signed url the client fetches directly. On a url
|
|
1306
1339
|
// arm the client MUST NOT attach host auth and MUST NOT fall through:
|
|
1307
1340
|
// failure => re-request, the caller's next fetchBlob mints a fresh url.
|
|
1341
|
+
this.#requireTransport();
|
|
1308
1342
|
const response = await transport.download(blobId);
|
|
1309
1343
|
let bytes;
|
|
1310
1344
|
if (response.kind === 'url') {
|
|
@@ -1346,6 +1380,7 @@ export class SyncClient {
|
|
|
1346
1380
|
}
|
|
1347
1381
|
/** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
|
|
1348
1382
|
flushBlobUploads() {
|
|
1383
|
+
this.#requireTransport();
|
|
1349
1384
|
return this.#runProtectedAsync(() => this.#flushBlobUploads());
|
|
1350
1385
|
}
|
|
1351
1386
|
async #flushBlobUploads() {
|
|
@@ -1439,12 +1474,9 @@ export class SyncClient {
|
|
|
1439
1474
|
else if (replacement !== undefined) {
|
|
1440
1475
|
throw invalidRequest('replacementClientCommitId is valid only for superseded outcomes');
|
|
1441
1476
|
}
|
|
1442
|
-
const allowed = (current.status === 'conflict' &&
|
|
1477
|
+
const allowed = ((current.status === 'conflict' || current.status === 'rejected') &&
|
|
1443
1478
|
(input.resolution === 'resolved_keep_server' ||
|
|
1444
1479
|
input.resolution === 'superseded')) ||
|
|
1445
|
-
(current.status === 'rejected' &&
|
|
1446
|
-
(input.resolution === 'superseded' ||
|
|
1447
|
-
input.resolution === 'resolved_keep_server')) ||
|
|
1448
1480
|
((current.status === 'applied' || current.status === 'cached') &&
|
|
1449
1481
|
input.resolution === 'dismissed');
|
|
1450
1482
|
if (!allowed) {
|
|
@@ -1454,7 +1486,7 @@ export class SyncClient {
|
|
|
1454
1486
|
const priorRejections = this.#rejections;
|
|
1455
1487
|
return this.#applyBatch((batch) => {
|
|
1456
1488
|
restoreFailedBases(this.#db, this.#schema);
|
|
1457
|
-
this.#db.exec(
|
|
1489
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
|
|
1458
1490
|
input.clientCommitId,
|
|
1459
1491
|
]);
|
|
1460
1492
|
const resolved = persistCommitOutcomeResolution(this.#db, input, this.#now());
|
|
@@ -1525,6 +1557,7 @@ export class SyncClient {
|
|
|
1525
1557
|
*/
|
|
1526
1558
|
setPresence(scopeKey, doc) {
|
|
1527
1559
|
this.#requireActive();
|
|
1560
|
+
this.#requireTransport();
|
|
1528
1561
|
const socket = this.#socket;
|
|
1529
1562
|
if (socket === undefined) {
|
|
1530
1563
|
throw invalidRequest('setPresence requires a connected realtime socket (§8.6)');
|
|
@@ -1569,7 +1602,7 @@ export class SyncClient {
|
|
|
1569
1602
|
canonicalScopeJson(input.scopes) &&
|
|
1570
1603
|
existing.params === input.params;
|
|
1571
1604
|
if (!sameIntent) {
|
|
1572
|
-
throw new ClientSyncError('client.subscription_intent_mismatch', '
|
|
1605
|
+
throw new ClientSyncError('client.subscription_intent_mismatch', 'subscription id already names different table, scopes, or params');
|
|
1573
1606
|
}
|
|
1574
1607
|
return;
|
|
1575
1608
|
}
|
|
@@ -1616,7 +1649,7 @@ export class SyncClient {
|
|
|
1616
1649
|
this.#requireActive();
|
|
1617
1650
|
const table = this.#table(base.table);
|
|
1618
1651
|
if (!table.scopeColumnByVariable.has(base.variable)) {
|
|
1619
|
-
throw invalidRequest(
|
|
1652
|
+
throw invalidRequest(`${base.table}: unknown scope variable ${base.variable} (§4.8)`);
|
|
1620
1653
|
}
|
|
1621
1654
|
// Serialize the whole window edit: it spans an `await deriveSubId` between
|
|
1622
1655
|
// db transactions, so without the chain a delta apply (or a concurrent
|
|
@@ -1663,7 +1696,9 @@ export class SyncClient {
|
|
|
1663
1696
|
}
|
|
1664
1697
|
});
|
|
1665
1698
|
const effects = {
|
|
1666
|
-
sync: changed || widened
|
|
1699
|
+
sync: this.#transportEnabled && (changed || widened)
|
|
1700
|
+
? { kind: 'interactive' }
|
|
1701
|
+
: { kind: 'none' },
|
|
1667
1702
|
};
|
|
1668
1703
|
if (effects.sync.kind === 'interactive') {
|
|
1669
1704
|
this.#emitSyncIntent(effects.sync);
|
|
@@ -1918,7 +1953,9 @@ export class SyncClient {
|
|
|
1918
1953
|
mutateCommand(mutations) {
|
|
1919
1954
|
return {
|
|
1920
1955
|
value: this.mutate(mutations),
|
|
1921
|
-
effects: {
|
|
1956
|
+
effects: {
|
|
1957
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
1958
|
+
},
|
|
1922
1959
|
};
|
|
1923
1960
|
}
|
|
1924
1961
|
/**
|
|
@@ -1939,17 +1976,15 @@ export class SyncClient {
|
|
|
1939
1976
|
throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
|
|
1940
1977
|
}
|
|
1941
1978
|
return this.#recordMutations([
|
|
1942
|
-
{
|
|
1979
|
+
definedObject({
|
|
1943
1980
|
table,
|
|
1944
1981
|
op: 'patch',
|
|
1945
1982
|
values: {
|
|
1946
1983
|
...Object.fromEntries(normalized),
|
|
1947
1984
|
[pkColumn.name]: rowId,
|
|
1948
1985
|
},
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
: {}),
|
|
1952
|
-
},
|
|
1986
|
+
baseVersion: options?.baseVersion,
|
|
1987
|
+
}),
|
|
1953
1988
|
]);
|
|
1954
1989
|
}
|
|
1955
1990
|
/**
|
|
@@ -1983,16 +2018,7 @@ export class SyncClient {
|
|
|
1983
2018
|
return this.#applyBatch((batch) => {
|
|
1984
2019
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
1985
2020
|
restoreFailedBases(this.#db, this.#schema);
|
|
1986
|
-
|
|
1987
|
-
for (const commit of droppedRetained) {
|
|
1988
|
-
batch.conflicts();
|
|
1989
|
-
batch.rejections();
|
|
1990
|
-
this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
1991
|
-
this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
1992
|
-
for (const operation of commit.operations)
|
|
1993
|
-
batch.table(operation.table);
|
|
1994
|
-
batch.outcomes();
|
|
1995
|
-
}
|
|
2021
|
+
this.#dropRetainedRows(batch, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
|
|
1996
2022
|
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
1997
2023
|
// a commit is doomed when any of its ops touches a base-matching
|
|
1998
2024
|
// row. Base values only become visible here as rollbacks restore
|
|
@@ -2128,6 +2154,7 @@ export class SyncClient {
|
|
|
2128
2154
|
this.#upgrading = true;
|
|
2129
2155
|
this.#needsPull = true;
|
|
2130
2156
|
batch.status();
|
|
2157
|
+
this.#discardAcknowledgedRows();
|
|
2131
2158
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
2132
2159
|
resetSubscriptionsForBump(this.#db);
|
|
2133
2160
|
for (const table of this.#schema.tables.values()) {
|
|
@@ -2191,7 +2218,9 @@ export class SyncClient {
|
|
|
2191
2218
|
patchCommand(table, rowId, partial, options) {
|
|
2192
2219
|
return {
|
|
2193
2220
|
value: this.patch(table, rowId, partial, options),
|
|
2194
|
-
effects: {
|
|
2221
|
+
effects: {
|
|
2222
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
2223
|
+
},
|
|
2195
2224
|
};
|
|
2196
2225
|
}
|
|
2197
2226
|
// -- lease state (§7.3.5) ---------------------------------------------------
|
|
@@ -2286,16 +2315,14 @@ export class SyncClient {
|
|
|
2286
2315
|
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
2287
2316
|
this.#rollbackFailedCommit(commit, batch);
|
|
2288
2317
|
this.#replayOutbox();
|
|
2289
|
-
const rejection = {
|
|
2318
|
+
const rejection = definedObject({
|
|
2290
2319
|
clientCommitId: commit.clientCommitId,
|
|
2291
2320
|
opIndex: 0,
|
|
2292
2321
|
code,
|
|
2293
2322
|
message,
|
|
2294
2323
|
retryable: false,
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
: {}),
|
|
2298
|
-
};
|
|
2324
|
+
operation: commit.operations[0],
|
|
2325
|
+
});
|
|
2299
2326
|
this.#rejections.push(rejection);
|
|
2300
2327
|
recordCommitOutcome(this.#db, {
|
|
2301
2328
|
clientCommitId: commit.clientCommitId,
|
|
@@ -2323,7 +2350,7 @@ export class SyncClient {
|
|
|
2323
2350
|
sync() {
|
|
2324
2351
|
this.#requireActive();
|
|
2325
2352
|
if (this.#syncOutstanding) {
|
|
2326
|
-
return Promise.reject(invalidRequest('sync() is already running
|
|
2353
|
+
return Promise.reject(invalidRequest('sync() is already running; coalesce wake-ups'));
|
|
2327
2354
|
}
|
|
2328
2355
|
this.#syncOutstanding = true;
|
|
2329
2356
|
this.#beginDiagnosticsDeferral();
|
|
@@ -2365,6 +2392,8 @@ export class SyncClient {
|
|
|
2365
2392
|
})
|
|
2366
2393
|
.finally(() => {
|
|
2367
2394
|
this.#syncOutstanding = false;
|
|
2395
|
+
if (!this.#transportEnabled)
|
|
2396
|
+
this.disconnectRealtime();
|
|
2368
2397
|
this.#endDiagnosticsDeferral();
|
|
2369
2398
|
});
|
|
2370
2399
|
}
|
|
@@ -2397,6 +2426,7 @@ export class SyncClient {
|
|
|
2397
2426
|
: 'client.unknown_failure';
|
|
2398
2427
|
}
|
|
2399
2428
|
async #runSync() {
|
|
2429
|
+
this.#requireTransport();
|
|
2400
2430
|
this.#roundRetryDelayMs = undefined;
|
|
2401
2431
|
this.#progress.emit({
|
|
2402
2432
|
attempt: ++this.#progressAttempt,
|
|
@@ -2426,7 +2456,7 @@ export class SyncClient {
|
|
|
2426
2456
|
if (logEpoch !== undefined &&
|
|
2427
2457
|
this.#hasBlobs &&
|
|
2428
2458
|
this.#config.blobs !== undefined) {
|
|
2429
|
-
await this
|
|
2459
|
+
await this.#flushBlobUploads();
|
|
2430
2460
|
}
|
|
2431
2461
|
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
2432
2462
|
// cannot express itself under the new schema (a dropped column/table)
|
|
@@ -2542,6 +2572,8 @@ export class SyncClient {
|
|
|
2542
2572
|
for (let round = 0; round < budget; round++) {
|
|
2543
2573
|
const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]));
|
|
2544
2574
|
last = await this.sync();
|
|
2575
|
+
if (!this.#transportEnabled || last.schemaFloor !== undefined)
|
|
2576
|
+
return last;
|
|
2545
2577
|
if (maxRounds === undefined &&
|
|
2546
2578
|
last.segmentRowsApplied > 0 &&
|
|
2547
2579
|
last.bootstrapping.length > 0 &&
|
|
@@ -2551,8 +2583,6 @@ export class SyncClient {
|
|
|
2551
2583
|
sub.bootstrapState,
|
|
2552
2584
|
])))
|
|
2553
2585
|
round = -1;
|
|
2554
|
-
if (last.schemaFloor !== undefined)
|
|
2555
|
-
return last;
|
|
2556
2586
|
if (last.commitsApplied === 0 &&
|
|
2557
2587
|
last.segmentRowsApplied === 0 &&
|
|
2558
2588
|
last.bootstrapping.length === 0 &&
|
|
@@ -2644,6 +2674,7 @@ export class SyncClient {
|
|
|
2644
2674
|
}
|
|
2645
2675
|
connectRealtime() {
|
|
2646
2676
|
this.#requireActive();
|
|
2677
|
+
this.#requireTransport();
|
|
2647
2678
|
if (this.#realtimePolicy === 'off') {
|
|
2648
2679
|
return Promise.reject(invalidRequest("realtimePolicy is 'off'; realtime connect is refused"));
|
|
2649
2680
|
}
|
|
@@ -2699,9 +2730,13 @@ export class SyncClient {
|
|
|
2699
2730
|
},
|
|
2700
2731
|
});
|
|
2701
2732
|
openedSocket = socket;
|
|
2733
|
+
if (!this.#transportEnabled) {
|
|
2734
|
+
socket.close();
|
|
2735
|
+
this.#requireTransport();
|
|
2736
|
+
}
|
|
2702
2737
|
if (this.#securityLifecycle === 'preflight') {
|
|
2703
2738
|
socket.close();
|
|
2704
|
-
|
|
2739
|
+
this.#requireActive();
|
|
2705
2740
|
}
|
|
2706
2741
|
if (generation !== this.#realtimeGeneration || !this.#started) {
|
|
2707
2742
|
socket.close();
|
|
@@ -2838,6 +2873,8 @@ export class SyncClient {
|
|
|
2838
2873
|
}
|
|
2839
2874
|
}
|
|
2840
2875
|
async #handleRealtimeBinary(bytes) {
|
|
2876
|
+
if (!this.#transportEnabled)
|
|
2877
|
+
return;
|
|
2841
2878
|
if (this.#syncing) {
|
|
2842
2879
|
// Fast path: a pull is mid-flight; let it win and recover the gap
|
|
2843
2880
|
// itself — re-pulling is idempotent, interleaved application is not
|
|
@@ -2866,7 +2903,8 @@ export class SyncClient {
|
|
|
2866
2903
|
});
|
|
2867
2904
|
}
|
|
2868
2905
|
#sendAck(cursor) {
|
|
2869
|
-
this.#
|
|
2906
|
+
if (this.#transportEnabled)
|
|
2907
|
+
this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
|
|
2870
2908
|
}
|
|
2871
2909
|
/** Ack the highest cursor that is contiguously applied for every sub. */
|
|
2872
2910
|
#ackAfterPull() {
|
|
@@ -2972,6 +3010,7 @@ export class SyncClient {
|
|
|
2972
3010
|
index += 1;
|
|
2973
3011
|
}
|
|
2974
3012
|
}
|
|
3013
|
+
const previousNeedsPull = this.#needsPull;
|
|
2975
3014
|
const conflictCount = this.#conflicts.length;
|
|
2976
3015
|
const rejectionCount = this.#rejections.length;
|
|
2977
3016
|
let outboxCount = countOutbox(this.#db);
|
|
@@ -2988,9 +3027,10 @@ export class SyncClient {
|
|
|
2988
3027
|
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2989
3028
|
}
|
|
2990
3029
|
}, () => this.#statusSnapshot(outboxCount), (cause) => {
|
|
3030
|
+
this.#needsPull = previousNeedsPull;
|
|
2991
3031
|
this.#conflicts.length = conflictCount;
|
|
2992
3032
|
this.#rejections.length = rejectionCount;
|
|
2993
|
-
const error = new ClientSyncError('client.outcome_persistence_failed', '
|
|
3033
|
+
const error = new ClientSyncError('client.outcome_persistence_failed', 'outcome persistence failed');
|
|
2994
3034
|
error.cause = cause;
|
|
2995
3035
|
throw error;
|
|
2996
3036
|
});
|
|
@@ -3019,58 +3059,24 @@ export class SyncClient {
|
|
|
3019
3059
|
await this.#applyCommit(frame, summary);
|
|
3020
3060
|
}
|
|
3021
3061
|
break;
|
|
3022
|
-
case 'SEGMENT_INLINE':
|
|
3023
|
-
if (section === undefined ||
|
|
3024
|
-
section.skip ||
|
|
3025
|
-
section.sub === undefined) {
|
|
3026
|
-
break;
|
|
3027
|
-
}
|
|
3028
|
-
const segment = decodeRowsSegment(frame.payload);
|
|
3029
|
-
this.#progress.update({
|
|
3030
|
-
phase: 'import',
|
|
3031
|
-
subscriptionId: section.sub.id,
|
|
3032
|
-
table: section.sub.table,
|
|
3033
|
-
segmentId: undefined,
|
|
3034
|
-
bytesReceived: frame.payload.byteLength,
|
|
3035
|
-
bytesTotal: frame.payload.byteLength,
|
|
3036
|
-
rowsProcessed: 0,
|
|
3037
|
-
rowsTotal: segment.blocks.reduce((n, block) => n + block.length, 0),
|
|
3038
|
-
});
|
|
3039
|
-
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
|
|
3040
|
-
clearFirst,
|
|
3041
|
-
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3042
|
-
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
3043
|
-
effective,
|
|
3044
|
-
transaction: (fn) => this.#applyBatch((batch) => {
|
|
3045
|
-
if (segment.blocks.some((block) => block.length > 0) ||
|
|
3046
|
-
(clearFirst &&
|
|
3047
|
-
this.#scopedRowsExist(table, effective))) {
|
|
3048
|
-
batch.table(table.name);
|
|
3049
|
-
}
|
|
3050
|
-
const result = fn();
|
|
3051
|
-
this.#replayOutbox();
|
|
3052
|
-
return result;
|
|
3053
|
-
}),
|
|
3054
|
-
}, this.#encryption), section.fresh && !section.cleared);
|
|
3055
|
-
break;
|
|
3056
|
-
}
|
|
3062
|
+
case 'SEGMENT_INLINE':
|
|
3057
3063
|
case 'SEGMENT_REF': {
|
|
3058
3064
|
if (section === undefined ||
|
|
3059
3065
|
section.skip ||
|
|
3060
|
-
section.sub === undefined)
|
|
3066
|
+
section.sub === undefined)
|
|
3061
3067
|
break;
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
// broken server — fail loud, never skip or guess.
|
|
3065
|
-
if (frame.mediaType === 'sqlite' &&
|
|
3068
|
+
if (frame.type === 'SEGMENT_REF' &&
|
|
3069
|
+
frame.mediaType === 'sqlite' &&
|
|
3066
3070
|
(this.#acceptMask() & ACCEPT_SQLITE) === 0) {
|
|
3067
3071
|
throw invalidRequest('SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
|
|
3068
3072
|
}
|
|
3069
|
-
const bytes =
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3073
|
+
const bytes = frame.type === 'SEGMENT_REF'
|
|
3074
|
+
? await this.#downloadSegment(frame, section.sub)
|
|
3075
|
+
: frame.payload;
|
|
3076
|
+
if (frame.type === 'SEGMENT_REF')
|
|
3077
|
+
this.#progress.update({ phase: 'import' });
|
|
3078
|
+
if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
|
|
3079
|
+
// Images are whole-table; paged image descriptors are invalid.
|
|
3074
3080
|
if (frame.rowCursor !== undefined ||
|
|
3075
3081
|
frame.nextRowCursor !== undefined) {
|
|
3076
3082
|
throw invalidRequest('sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
|
|
@@ -3080,43 +3086,26 @@ export class SyncClient {
|
|
|
3080
3086
|
rowCount: frame.rowCount,
|
|
3081
3087
|
asOfCommitSeq: frame.asOfCommitSeq,
|
|
3082
3088
|
scopeDigest: frame.scopeDigest,
|
|
3083
|
-
},
|
|
3084
|
-
clearFirst,
|
|
3085
|
-
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3086
|
-
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
3087
|
-
effective,
|
|
3088
|
-
transaction: (fn) => this.#applyBatch((batch) => {
|
|
3089
|
-
if (frame.rowCount > 0 ||
|
|
3090
|
-
(clearFirst &&
|
|
3091
|
-
this.#scopedRowsExist(table, effective))) {
|
|
3092
|
-
batch.table(table.name);
|
|
3093
|
-
}
|
|
3094
|
-
const result = fn();
|
|
3095
|
-
this.#replayOutbox();
|
|
3096
|
-
return result;
|
|
3097
|
-
}),
|
|
3098
|
-
}), section.fresh && !section.cleared);
|
|
3089
|
+
}, this.#segmentApplyOptions(table, clearFirst, effective, frame.rowCount > 0, resetEpoch)), section.fresh && !section.cleared);
|
|
3099
3090
|
}
|
|
3100
3091
|
else {
|
|
3101
3092
|
const segment = decodeRowsSegment(bytes);
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
return result;
|
|
3116
|
-
}),
|
|
3117
|
-
}, this.#encryption), section.fresh &&
|
|
3093
|
+
if (frame.type === 'SEGMENT_INLINE') {
|
|
3094
|
+
this.#progress.update({
|
|
3095
|
+
phase: 'import',
|
|
3096
|
+
subscriptionId: section.sub.id,
|
|
3097
|
+
table: section.sub.table,
|
|
3098
|
+
segmentId: undefined,
|
|
3099
|
+
bytesReceived: bytes.byteLength,
|
|
3100
|
+
bytesTotal: bytes.byteLength,
|
|
3101
|
+
rowsProcessed: 0,
|
|
3102
|
+
rowsTotal: segment.blocks.reduce((n, block) => n + block.length, 0),
|
|
3103
|
+
});
|
|
3104
|
+
}
|
|
3105
|
+
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, this.#segmentApplyOptions(table, clearFirst, effective, segment.blocks.some((block) => block.length > 0), resetEpoch), this.#encryption), section.fresh &&
|
|
3118
3106
|
!section.cleared &&
|
|
3119
|
-
frame.
|
|
3107
|
+
(frame.type === 'SEGMENT_INLINE' ||
|
|
3108
|
+
frame.rowCursor === undefined));
|
|
3120
3109
|
}
|
|
3121
3110
|
break;
|
|
3122
3111
|
}
|
|
@@ -3201,6 +3190,10 @@ export class SyncClient {
|
|
|
3201
3190
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
3202
3191
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
3203
3192
|
// "already applied, you may have missed the ack".
|
|
3193
|
+
if (frame.commitSeq === undefined)
|
|
3194
|
+
throw new ClientSyncError('client.invalid_host_response', 'ACK lacks a commit sequence');
|
|
3195
|
+
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId), frame.commitSeq);
|
|
3196
|
+
this.#setSyncNeeded(true);
|
|
3204
3197
|
recordCommitOutcome(this.#db, {
|
|
3205
3198
|
clientCommitId: frame.clientCommitId,
|
|
3206
3199
|
status: frame.status,
|
|
@@ -3327,10 +3320,7 @@ export class SyncClient {
|
|
|
3327
3320
|
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
3328
3321
|
this.#recordCommitChanges(batch, frame);
|
|
3329
3322
|
const result = fn();
|
|
3330
|
-
|
|
3331
|
-
this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
|
|
3332
|
-
.length > 0)
|
|
3333
|
-
this.#replayOutbox();
|
|
3323
|
+
this.#replayOutbox(result);
|
|
3334
3324
|
return result;
|
|
3335
3325
|
}));
|
|
3336
3326
|
summary.commitsApplied += 1;
|
|
@@ -3375,19 +3365,26 @@ export class SyncClient {
|
|
|
3375
3365
|
}
|
|
3376
3366
|
/** Whether a fresh-bootstrap clear would remove at least one local row. */
|
|
3377
3367
|
#scopedRowsExist(table, effective) {
|
|
3378
|
-
const
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3368
|
+
const scope = localScopePredicate(table, effective);
|
|
3369
|
+
return (scope !== undefined &&
|
|
3370
|
+
this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`, scope.params).length > 0);
|
|
3371
|
+
}
|
|
3372
|
+
/** Shared transaction, reset fence and progress for both segment formats. */
|
|
3373
|
+
#segmentApplyOptions(table, clearFirst, effective, hasRows, resetEpoch) {
|
|
3374
|
+
return {
|
|
3375
|
+
clearFirst,
|
|
3376
|
+
effective,
|
|
3377
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3378
|
+
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
3379
|
+
transaction: (fn) => this.#applyBatch((batch) => {
|
|
3380
|
+
if (hasRows ||
|
|
3381
|
+
(clearFirst && this.#scopedRowsExist(table, effective)))
|
|
3382
|
+
batch.table(table.name);
|
|
3383
|
+
const result = fn();
|
|
3384
|
+
this.#replayOutbox();
|
|
3385
|
+
return result;
|
|
3386
|
+
}),
|
|
3387
|
+
};
|
|
3391
3388
|
}
|
|
3392
3389
|
/**
|
|
3393
3390
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
@@ -3461,7 +3458,7 @@ export class SyncClient {
|
|
|
3461
3458
|
if (frame.urlExpiresAtMs !== undefined &&
|
|
3462
3459
|
frame.urlExpiresAtMs <= this.#now()) {
|
|
3463
3460
|
// §5.4: MUST NOT start a fetch at/past expiry.
|
|
3464
|
-
throw new ClientSyncError('sync.segment_expired', `
|
|
3461
|
+
throw new ClientSyncError('sync.segment_expired', `segment download grant expired; pull again (§5.4)`, true);
|
|
3465
3462
|
}
|
|
3466
3463
|
bytes = await fetchUrl(frame.url, onProgress);
|
|
3467
3464
|
}
|
|
@@ -3504,6 +3501,25 @@ export class SyncClient {
|
|
|
3504
3501
|
effectiveScopes: start.effectiveScopes,
|
|
3505
3502
|
status: 'active',
|
|
3506
3503
|
}));
|
|
3504
|
+
if (completed) {
|
|
3505
|
+
const table = this.#table(sub.table);
|
|
3506
|
+
const effective = start.effectiveScopes;
|
|
3507
|
+
const covered = this.#db
|
|
3508
|
+
.query(`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq<=?`, [sub.table, nextCursor])
|
|
3509
|
+
.filter((row) => [row.intent, row.base].some((json) => {
|
|
3510
|
+
if (json === null)
|
|
3511
|
+
return false;
|
|
3512
|
+
const values = JSON.parse(String(json));
|
|
3513
|
+
return matchesLocalScopes(table, effective, values);
|
|
3514
|
+
}));
|
|
3515
|
+
if (covered.length) {
|
|
3516
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
3517
|
+
for (const row of covered)
|
|
3518
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [sub.table, String(row.id), nextCursor]);
|
|
3519
|
+
batch.table(sub.table);
|
|
3520
|
+
this.#replayOutbox();
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3507
3523
|
if (completed && registered !== undefined) {
|
|
3508
3524
|
// A zero-row bootstrap is a window-domain transition, not a fake
|
|
3509
3525
|
// row/table change (SPEC §4.8 / §7.5).
|
|
@@ -3545,17 +3561,8 @@ export class SyncClient {
|
|
|
3545
3561
|
Object.keys(lastEffective).length > 0) {
|
|
3546
3562
|
try {
|
|
3547
3563
|
restoreFailedBases(this.#db, this.#schema);
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
for (const commit of droppedRetained) {
|
|
3551
|
-
batch.conflicts();
|
|
3552
|
-
batch.rejections();
|
|
3553
|
-
this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3554
|
-
this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3555
|
-
for (const operation of commit.operations)
|
|
3556
|
-
batch.table(operation.table);
|
|
3557
|
-
batch.outcomes();
|
|
3558
|
-
}
|
|
3564
|
+
this.#dropRetainedRows(batch, (name, values) => name === table.name &&
|
|
3565
|
+
matchesLocalScopes(table, lastEffective, values));
|
|
3559
3566
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
3560
3567
|
batch.scopeMap(table, lastEffective);
|
|
3561
3568
|
const pendingById = new Map(listOutbox(this.#db).map((commit) => [
|
|
@@ -3775,19 +3782,36 @@ export class SyncClient {
|
|
|
3775
3782
|
}
|
|
3776
3783
|
}
|
|
3777
3784
|
/** Re-apply every pending outbox commit on top of server state (§7.1). */
|
|
3778
|
-
#
|
|
3785
|
+
#discardAcknowledgedRows() {
|
|
3786
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
|
|
3787
|
+
}
|
|
3788
|
+
#dropRetainedRows(batch, matches) {
|
|
3789
|
+
for (const commit of dropFailedRows(this.#db, matches)) {
|
|
3790
|
+
batch.conflicts();
|
|
3791
|
+
batch.rejections();
|
|
3792
|
+
batch.outcomes();
|
|
3793
|
+
this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3794
|
+
this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3795
|
+
for (const op of commit.operations)
|
|
3796
|
+
batch.table(op.table);
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
#replayOutbox(rowKeys) {
|
|
3800
|
+
const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
|
|
3779
3801
|
const pending = [
|
|
3780
|
-
...failedOverlayCommits(this.#db),
|
|
3781
|
-
...listOutbox(this.#db),
|
|
3802
|
+
...failedOverlayCommits(this.#db, false, rowKeys),
|
|
3803
|
+
...listOutbox(this.#db, rowKeys),
|
|
3782
3804
|
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
3783
|
-
if (pending.length === 0)
|
|
3805
|
+
if (pending.length === 0 && acknowledged.length === 0)
|
|
3784
3806
|
return;
|
|
3785
3807
|
this.#applyBatch((batch) => {
|
|
3786
3808
|
this.#db.transaction(() => {
|
|
3787
|
-
restoreFailedBases(this.#db, this.#schema);
|
|
3788
|
-
restoreFailedBases(this.#db, this.#schema, true);
|
|
3789
|
-
for (const commit of pending)
|
|
3790
|
-
this.#applyOperationsLocally(
|
|
3809
|
+
restoreFailedBases(this.#db, this.#schema, false, rowKeys);
|
|
3810
|
+
restoreFailedBases(this.#db, this.#schema, true, rowKeys);
|
|
3811
|
+
for (const commit of [...acknowledged, ...pending])
|
|
3812
|
+
this.#applyOperationsLocally(rowKeys === undefined
|
|
3813
|
+
? commit.operations
|
|
3814
|
+
: commit.operations.filter((op) => rowKeys.includes(JSON.stringify([op.table, op.rowId]))), batch, true);
|
|
3791
3815
|
});
|
|
3792
3816
|
});
|
|
3793
3817
|
}
|
|
@@ -3800,19 +3824,19 @@ export class SyncClient {
|
|
|
3800
3824
|
#table(name) {
|
|
3801
3825
|
const table = this.#schema.tables.get(name);
|
|
3802
3826
|
if (table === undefined) {
|
|
3803
|
-
throw new ClientSyncError('sync.unknown_table', `unknown
|
|
3827
|
+
throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
|
|
3804
3828
|
}
|
|
3805
3829
|
return table;
|
|
3806
3830
|
}
|
|
3807
3831
|
#requireStarted() {
|
|
3808
3832
|
if (!this.#started) {
|
|
3809
|
-
throw invalidRequest('SyncClient.start()
|
|
3833
|
+
throw invalidRequest('call SyncClient.start() first');
|
|
3810
3834
|
}
|
|
3811
3835
|
}
|
|
3812
3836
|
#requireActive() {
|
|
3813
3837
|
this.#requireStarted();
|
|
3814
3838
|
if (this.#securityLifecycle === 'preflight') {
|
|
3815
|
-
throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, '
|
|
3839
|
+
throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'security preflight must complete before accessing protected data; call activateSecurity');
|
|
3816
3840
|
}
|
|
3817
3841
|
}
|
|
3818
3842
|
}
|