@syncular/client 0.30.12 → 0.30.14
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/dist/apply.d.ts +3 -2
- package/dist/apply.js +63 -15
- package/dist/blob.d.ts +1 -0
- package/dist/blob.js +4 -3
- package/dist/client.d.ts +3 -0
- package/dist/client.js +180 -159
- package/dist/failed-overlay.d.ts +6 -3
- package/dist/failed-overlay.js +50 -48
- package/dist/outbox.d.ts +4 -1
- package/dist/outbox.js +18 -12
- package/dist/outcomes.d.ts +1 -0
- package/dist/outcomes.js +12 -7
- package/dist/previous-version.js +3 -3
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +23 -14
- package/dist/state.d.ts +2 -0
- package/dist/state.js +15 -15
- package/dist/window.d.ts +2 -0
- package/dist/window.js +13 -10
- 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 +106 -37
- package/src/blob.ts +4 -3
- package/src/client.ts +224 -197
- package/src/failed-overlay.ts +96 -78
- package/src/outbox.ts +19 -16
- package/src/outcomes.ts +12 -12
- package/src/previous-version.ts +3 -3
- package/src/schema.ts +24 -18
- package/src/state.ts +15 -15
- package/src/window.ts +13 -15
- 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,7 @@
|
|
|
1
|
+
import { BLOB_COMMIT_REFS } from './blob.js';
|
|
2
|
+
import { hasUniqueIndex } from './schema.js';
|
|
3
|
+
import { OUTCOMES_TABLE } from './outcomes.js';
|
|
4
|
+
import { OUTBOX_TABLE } from './outbox.js';
|
|
1
5
|
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
2
6
|
import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
|
|
3
7
|
import { ProgressEmitter, } from './progress.js';
|
|
@@ -133,6 +137,7 @@ export class SyncClient {
|
|
|
133
137
|
/** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
|
|
134
138
|
#previousVersion;
|
|
135
139
|
#securityLifecycle;
|
|
140
|
+
#transportEnabled;
|
|
136
141
|
#now;
|
|
137
142
|
#outcomeRetentionMaxEntries;
|
|
138
143
|
#retainFailedCommits;
|
|
@@ -221,6 +226,11 @@ export class SyncClient {
|
|
|
221
226
|
#protectedAsync = new Set();
|
|
222
227
|
#preflightBarrier;
|
|
223
228
|
constructor(config) {
|
|
229
|
+
if (config.transportEnabled !== undefined &&
|
|
230
|
+
typeof config.transportEnabled !== 'boolean') {
|
|
231
|
+
throw invalidRequest('transportEnabled must be a boolean');
|
|
232
|
+
}
|
|
233
|
+
this.#transportEnabled = config.transportEnabled !== false;
|
|
224
234
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
225
235
|
throw invalidRequest('securityPreflight and encryption are mutually exclusive; activateSecurity installs keys');
|
|
226
236
|
}
|
|
@@ -472,6 +482,13 @@ export class SyncClient {
|
|
|
472
482
|
}
|
|
473
483
|
/** §2.1 reset after the server reports a different log continuity. */
|
|
474
484
|
#runLogEpochReset(logEpoch) {
|
|
485
|
+
if (getMeta(this.#db, LOG_EPOCH_META_KEY) === undefined) {
|
|
486
|
+
this.#applyBatch(() => setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch));
|
|
487
|
+
this.#setSyncNeeded(true);
|
|
488
|
+
this.#emitSyncNeeded('startup');
|
|
489
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
475
492
|
const subscriptions = loadSubscriptions(this.#db);
|
|
476
493
|
const pending = listOutbox(this.#db);
|
|
477
494
|
this.#setUpgrading(true);
|
|
@@ -537,6 +554,8 @@ export class SyncClient {
|
|
|
537
554
|
this.#syncIntentListeners.clear();
|
|
538
555
|
}
|
|
539
556
|
#emitSyncNeeded(reason) {
|
|
557
|
+
if (!this.#transportEnabled)
|
|
558
|
+
return;
|
|
540
559
|
try {
|
|
541
560
|
this.#config.onSyncNeeded?.(reason);
|
|
542
561
|
}
|
|
@@ -553,6 +572,8 @@ export class SyncClient {
|
|
|
553
572
|
}
|
|
554
573
|
}
|
|
555
574
|
#emitSyncIntent(intent) {
|
|
575
|
+
if (!this.#transportEnabled && intent.kind !== 'none')
|
|
576
|
+
return;
|
|
556
577
|
try {
|
|
557
578
|
this.#config.onSyncIntent?.(intent);
|
|
558
579
|
}
|
|
@@ -568,6 +589,25 @@ export class SyncClient {
|
|
|
568
589
|
}
|
|
569
590
|
}
|
|
570
591
|
}
|
|
592
|
+
/** Host-owned transport policy; the local replica remains available. */
|
|
593
|
+
setTransportEnabled(enabled) {
|
|
594
|
+
this.#requireStarted();
|
|
595
|
+
if (typeof enabled !== 'boolean')
|
|
596
|
+
throw invalidRequest('transportEnabled must be a boolean');
|
|
597
|
+
if (this.#transportEnabled === enabled)
|
|
598
|
+
return;
|
|
599
|
+
this.#transportEnabled = enabled;
|
|
600
|
+
if (!enabled && !this.#syncOutstanding)
|
|
601
|
+
this.disconnectRealtime();
|
|
602
|
+
this.#emitSyncIntent(enabled && this.#securityLifecycle === 'active'
|
|
603
|
+
? { kind: 'interactive' }
|
|
604
|
+
: { kind: 'none' });
|
|
605
|
+
this.#emitDiagnostics();
|
|
606
|
+
}
|
|
607
|
+
#requireTransport() {
|
|
608
|
+
if (!this.#transportEnabled)
|
|
609
|
+
throw new ClientSyncError('sync.offline', 'transport gate is closed');
|
|
610
|
+
}
|
|
571
611
|
/** Current fail-closed local-replica security state. */
|
|
572
612
|
securityLifecycle() {
|
|
573
613
|
return this.#securityLifecycle;
|
|
@@ -1028,12 +1068,14 @@ export class SyncClient {
|
|
|
1028
1068
|
}
|
|
1029
1069
|
const capturedAtMs = this.#now();
|
|
1030
1070
|
const leaseState = this.#diagnosticLease(capturedAtMs);
|
|
1031
|
-
const connectivity = this.#
|
|
1032
|
-
? '
|
|
1033
|
-
: this.#lastRound?.status === '
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1071
|
+
const connectivity = !this.#transportEnabled
|
|
1072
|
+
? 'offline'
|
|
1073
|
+
: this.#lastRound?.status === 'succeeded'
|
|
1074
|
+
? 'online'
|
|
1075
|
+
: this.#lastRound?.status === 'failed' &&
|
|
1076
|
+
this.#transportFailureCode(this.#lastRound.errorCode)
|
|
1077
|
+
? 'offline'
|
|
1078
|
+
: 'unknown';
|
|
1037
1079
|
const expectedOrder = new Map(expected.map((item, index) => [item.id, index]));
|
|
1038
1080
|
const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
|
|
1039
1081
|
const aExpected = expectedOrder.get(a.id);
|
|
@@ -1057,16 +1099,12 @@ export class SyncClient {
|
|
|
1057
1099
|
realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
|
|
1058
1100
|
}),
|
|
1059
1101
|
securityLifecycle: this.#securityLifecycle,
|
|
1060
|
-
schema: {
|
|
1102
|
+
schema: definedObject({
|
|
1061
1103
|
currentVersion: this.#config.schema.version,
|
|
1062
1104
|
upgrading: this.#upgrading,
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
...(this.#schemaFloor?.latestSchemaVersion !== undefined
|
|
1067
|
-
? { latestVersion: this.#schemaFloor.latestSchemaVersion }
|
|
1068
|
-
: {}),
|
|
1069
|
-
},
|
|
1105
|
+
requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
|
|
1106
|
+
latestVersion: this.#schemaFloor?.latestSchemaVersion,
|
|
1107
|
+
}),
|
|
1070
1108
|
replica: {
|
|
1071
1109
|
localRevision: getLocalRevision(this.#db).toString(),
|
|
1072
1110
|
syncNeeded: this.#needsPull,
|
|
@@ -1101,8 +1139,8 @@ export class SyncClient {
|
|
|
1101
1139
|
try {
|
|
1102
1140
|
const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
|
|
1103
1141
|
const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
|
|
1104
|
-
const outboxBytes = Number(this.#db.query(
|
|
1105
|
-
const outcome = this.#db.query(
|
|
1142
|
+
const outboxBytes = Number(this.#db.query(`SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM ${OUTBOX_TABLE}`)[0]?.bytes ?? 0);
|
|
1143
|
+
const outcome = this.#db.query(`SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM ${OUTCOMES_TABLE}`)[0];
|
|
1106
1144
|
const blobBytes = this.#hasBlobs
|
|
1107
1145
|
? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
|
|
1108
1146
|
: 0;
|
|
@@ -1246,7 +1284,11 @@ export class SyncClient {
|
|
|
1246
1284
|
this.#requireActive();
|
|
1247
1285
|
const task = Promise.resolve().then(fn);
|
|
1248
1286
|
this.#protectedAsync.add(task);
|
|
1249
|
-
|
|
1287
|
+
const settled = () => {
|
|
1288
|
+
if (this.#realtimeConnectPromise === task)
|
|
1289
|
+
this.#realtimeConnectPromise = undefined;
|
|
1290
|
+
};
|
|
1291
|
+
void task.then(settled, settled);
|
|
1250
1292
|
return task;
|
|
1251
1293
|
}
|
|
1252
1294
|
// -- blobs (§5.9) ---------------------------------------------------------
|
|
@@ -1272,14 +1314,12 @@ export class SyncClient {
|
|
|
1272
1314
|
recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
|
|
1273
1315
|
});
|
|
1274
1316
|
this.#enforceBlobCacheCap();
|
|
1275
|
-
return {
|
|
1317
|
+
return definedObject({
|
|
1276
1318
|
blobId,
|
|
1277
1319
|
byteLength: bytes.length,
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
...(options?.name !== undefined ? { name: options.name } : {}),
|
|
1282
|
-
};
|
|
1320
|
+
mediaType: options?.mediaType,
|
|
1321
|
+
name: options?.name,
|
|
1322
|
+
});
|
|
1283
1323
|
}
|
|
1284
1324
|
/** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
|
|
1285
1325
|
blobRefString(ref) {
|
|
@@ -1309,6 +1349,7 @@ export class SyncClient {
|
|
|
1309
1349
|
// presign configured) a signed url the client fetches directly. On a url
|
|
1310
1350
|
// arm the client MUST NOT attach host auth and MUST NOT fall through:
|
|
1311
1351
|
// failure => re-request, the caller's next fetchBlob mints a fresh url.
|
|
1352
|
+
this.#requireTransport();
|
|
1312
1353
|
const response = await transport.download(blobId);
|
|
1313
1354
|
let bytes;
|
|
1314
1355
|
if (response.kind === 'url') {
|
|
@@ -1350,6 +1391,7 @@ export class SyncClient {
|
|
|
1350
1391
|
}
|
|
1351
1392
|
/** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
|
|
1352
1393
|
flushBlobUploads() {
|
|
1394
|
+
this.#requireTransport();
|
|
1353
1395
|
return this.#runProtectedAsync(() => this.#flushBlobUploads());
|
|
1354
1396
|
}
|
|
1355
1397
|
async #flushBlobUploads() {
|
|
@@ -1443,12 +1485,9 @@ export class SyncClient {
|
|
|
1443
1485
|
else if (replacement !== undefined) {
|
|
1444
1486
|
throw invalidRequest('replacementClientCommitId is valid only for superseded outcomes');
|
|
1445
1487
|
}
|
|
1446
|
-
const allowed = (current.status === 'conflict' &&
|
|
1488
|
+
const allowed = ((current.status === 'conflict' || current.status === 'rejected') &&
|
|
1447
1489
|
(input.resolution === 'resolved_keep_server' ||
|
|
1448
1490
|
input.resolution === 'superseded')) ||
|
|
1449
|
-
(current.status === 'rejected' &&
|
|
1450
|
-
(input.resolution === 'superseded' ||
|
|
1451
|
-
input.resolution === 'resolved_keep_server')) ||
|
|
1452
1491
|
((current.status === 'applied' || current.status === 'cached') &&
|
|
1453
1492
|
input.resolution === 'dismissed');
|
|
1454
1493
|
if (!allowed) {
|
|
@@ -1529,6 +1568,7 @@ export class SyncClient {
|
|
|
1529
1568
|
*/
|
|
1530
1569
|
setPresence(scopeKey, doc) {
|
|
1531
1570
|
this.#requireActive();
|
|
1571
|
+
this.#requireTransport();
|
|
1532
1572
|
const socket = this.#socket;
|
|
1533
1573
|
if (socket === undefined) {
|
|
1534
1574
|
throw invalidRequest('setPresence requires a connected realtime socket (§8.6)');
|
|
@@ -1667,7 +1707,9 @@ export class SyncClient {
|
|
|
1667
1707
|
}
|
|
1668
1708
|
});
|
|
1669
1709
|
const effects = {
|
|
1670
|
-
sync: changed || widened
|
|
1710
|
+
sync: this.#transportEnabled && (changed || widened)
|
|
1711
|
+
? { kind: 'interactive' }
|
|
1712
|
+
: { kind: 'none' },
|
|
1671
1713
|
};
|
|
1672
1714
|
if (effects.sync.kind === 'interactive') {
|
|
1673
1715
|
this.#emitSyncIntent(effects.sync);
|
|
@@ -1894,7 +1936,7 @@ export class SyncClient {
|
|
|
1894
1936
|
catch {
|
|
1895
1937
|
continue;
|
|
1896
1938
|
}
|
|
1897
|
-
this.#db.exec(
|
|
1939
|
+
this.#db.exec(`INSERT OR IGNORE INTO ${BLOB_COMMIT_REFS}(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)`, [clientCommitId, blobId, blobId]);
|
|
1898
1940
|
}
|
|
1899
1941
|
}
|
|
1900
1942
|
try {
|
|
@@ -1922,7 +1964,9 @@ export class SyncClient {
|
|
|
1922
1964
|
mutateCommand(mutations) {
|
|
1923
1965
|
return {
|
|
1924
1966
|
value: this.mutate(mutations),
|
|
1925
|
-
effects: {
|
|
1967
|
+
effects: {
|
|
1968
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
1969
|
+
},
|
|
1926
1970
|
};
|
|
1927
1971
|
}
|
|
1928
1972
|
/**
|
|
@@ -1943,17 +1987,15 @@ export class SyncClient {
|
|
|
1943
1987
|
throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
|
|
1944
1988
|
}
|
|
1945
1989
|
return this.#recordMutations([
|
|
1946
|
-
{
|
|
1990
|
+
definedObject({
|
|
1947
1991
|
table,
|
|
1948
1992
|
op: 'patch',
|
|
1949
1993
|
values: {
|
|
1950
1994
|
...Object.fromEntries(normalized),
|
|
1951
1995
|
[pkColumn.name]: rowId,
|
|
1952
1996
|
},
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
: {}),
|
|
1956
|
-
},
|
|
1997
|
+
baseVersion: options?.baseVersion,
|
|
1998
|
+
}),
|
|
1957
1999
|
]);
|
|
1958
2000
|
}
|
|
1959
2001
|
/**
|
|
@@ -2187,7 +2229,9 @@ export class SyncClient {
|
|
|
2187
2229
|
patchCommand(table, rowId, partial, options) {
|
|
2188
2230
|
return {
|
|
2189
2231
|
value: this.patch(table, rowId, partial, options),
|
|
2190
|
-
effects: {
|
|
2232
|
+
effects: {
|
|
2233
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
2234
|
+
},
|
|
2191
2235
|
};
|
|
2192
2236
|
}
|
|
2193
2237
|
// -- lease state (§7.3.5) ---------------------------------------------------
|
|
@@ -2217,7 +2261,7 @@ export class SyncClient {
|
|
|
2217
2261
|
async #encodeOutboxForPush() {
|
|
2218
2262
|
// Pin before the first encryption await: mutations can append while a
|
|
2219
2263
|
// round is encoding, and belong to the next request.
|
|
2220
|
-
const bounds = this.#db.query(
|
|
2264
|
+
const bounds = this.#db.query(`SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM ${OUTBOX_TABLE}`)[0];
|
|
2221
2265
|
const pendingCount = bounds.count;
|
|
2222
2266
|
const throughSeq = bounds.last_seq ?? 0;
|
|
2223
2267
|
const pushFrames = [];
|
|
@@ -2282,16 +2326,14 @@ export class SyncClient {
|
|
|
2282
2326
|
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
2283
2327
|
this.#rollbackFailedCommit(commit, batch);
|
|
2284
2328
|
this.#replayOutbox();
|
|
2285
|
-
const rejection = {
|
|
2329
|
+
const rejection = definedObject({
|
|
2286
2330
|
clientCommitId: commit.clientCommitId,
|
|
2287
2331
|
opIndex: 0,
|
|
2288
2332
|
code,
|
|
2289
2333
|
message,
|
|
2290
2334
|
retryable: false,
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
: {}),
|
|
2294
|
-
};
|
|
2335
|
+
operation: commit.operations[0],
|
|
2336
|
+
});
|
|
2295
2337
|
this.#rejections.push(rejection);
|
|
2296
2338
|
recordCommitOutcome(this.#db, {
|
|
2297
2339
|
clientCommitId: commit.clientCommitId,
|
|
@@ -2319,7 +2361,7 @@ export class SyncClient {
|
|
|
2319
2361
|
sync() {
|
|
2320
2362
|
this.#requireActive();
|
|
2321
2363
|
if (this.#syncOutstanding) {
|
|
2322
|
-
return Promise.reject(invalidRequest('sync() is already running
|
|
2364
|
+
return Promise.reject(invalidRequest('sync() is already running; coalesce wake-ups'));
|
|
2323
2365
|
}
|
|
2324
2366
|
this.#syncOutstanding = true;
|
|
2325
2367
|
this.#beginDiagnosticsDeferral();
|
|
@@ -2361,6 +2403,8 @@ export class SyncClient {
|
|
|
2361
2403
|
})
|
|
2362
2404
|
.finally(() => {
|
|
2363
2405
|
this.#syncOutstanding = false;
|
|
2406
|
+
if (!this.#transportEnabled)
|
|
2407
|
+
this.disconnectRealtime();
|
|
2364
2408
|
this.#endDiagnosticsDeferral();
|
|
2365
2409
|
});
|
|
2366
2410
|
}
|
|
@@ -2393,6 +2437,7 @@ export class SyncClient {
|
|
|
2393
2437
|
: 'client.unknown_failure';
|
|
2394
2438
|
}
|
|
2395
2439
|
async #runSync() {
|
|
2440
|
+
this.#requireTransport();
|
|
2396
2441
|
this.#roundRetryDelayMs = undefined;
|
|
2397
2442
|
this.#progress.emit({
|
|
2398
2443
|
attempt: ++this.#progressAttempt,
|
|
@@ -2422,7 +2467,7 @@ export class SyncClient {
|
|
|
2422
2467
|
if (logEpoch !== undefined &&
|
|
2423
2468
|
this.#hasBlobs &&
|
|
2424
2469
|
this.#config.blobs !== undefined) {
|
|
2425
|
-
await this
|
|
2470
|
+
await this.#flushBlobUploads();
|
|
2426
2471
|
}
|
|
2427
2472
|
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
2428
2473
|
// cannot express itself under the new schema (a dropped column/table)
|
|
@@ -2538,6 +2583,8 @@ export class SyncClient {
|
|
|
2538
2583
|
for (let round = 0; round < budget; round++) {
|
|
2539
2584
|
const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]));
|
|
2540
2585
|
last = await this.sync();
|
|
2586
|
+
if (!this.#transportEnabled || last.schemaFloor !== undefined)
|
|
2587
|
+
return last;
|
|
2541
2588
|
if (maxRounds === undefined &&
|
|
2542
2589
|
last.segmentRowsApplied > 0 &&
|
|
2543
2590
|
last.bootstrapping.length > 0 &&
|
|
@@ -2547,8 +2594,6 @@ export class SyncClient {
|
|
|
2547
2594
|
sub.bootstrapState,
|
|
2548
2595
|
])))
|
|
2549
2596
|
round = -1;
|
|
2550
|
-
if (last.schemaFloor !== undefined)
|
|
2551
|
-
return last;
|
|
2552
2597
|
if (last.commitsApplied === 0 &&
|
|
2553
2598
|
last.segmentRowsApplied === 0 &&
|
|
2554
2599
|
last.bootstrapping.length === 0 &&
|
|
@@ -2640,6 +2685,7 @@ export class SyncClient {
|
|
|
2640
2685
|
}
|
|
2641
2686
|
connectRealtime() {
|
|
2642
2687
|
this.#requireActive();
|
|
2688
|
+
this.#requireTransport();
|
|
2643
2689
|
if (this.#realtimePolicy === 'off') {
|
|
2644
2690
|
return Promise.reject(invalidRequest("realtimePolicy is 'off'; realtime connect is refused"));
|
|
2645
2691
|
}
|
|
@@ -2695,9 +2741,13 @@ export class SyncClient {
|
|
|
2695
2741
|
},
|
|
2696
2742
|
});
|
|
2697
2743
|
openedSocket = socket;
|
|
2744
|
+
if (!this.#transportEnabled) {
|
|
2745
|
+
socket.close();
|
|
2746
|
+
this.#requireTransport();
|
|
2747
|
+
}
|
|
2698
2748
|
if (this.#securityLifecycle === 'preflight') {
|
|
2699
2749
|
socket.close();
|
|
2700
|
-
|
|
2750
|
+
this.#requireActive();
|
|
2701
2751
|
}
|
|
2702
2752
|
if (generation !== this.#realtimeGeneration || !this.#started) {
|
|
2703
2753
|
socket.close();
|
|
@@ -2834,6 +2884,8 @@ export class SyncClient {
|
|
|
2834
2884
|
}
|
|
2835
2885
|
}
|
|
2836
2886
|
async #handleRealtimeBinary(bytes) {
|
|
2887
|
+
if (!this.#transportEnabled)
|
|
2888
|
+
return;
|
|
2837
2889
|
if (this.#syncing) {
|
|
2838
2890
|
// Fast path: a pull is mid-flight; let it win and recover the gap
|
|
2839
2891
|
// itself — re-pulling is idempotent, interleaved application is not
|
|
@@ -2862,7 +2914,8 @@ export class SyncClient {
|
|
|
2862
2914
|
});
|
|
2863
2915
|
}
|
|
2864
2916
|
#sendAck(cursor) {
|
|
2865
|
-
this.#
|
|
2917
|
+
if (this.#transportEnabled)
|
|
2918
|
+
this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
|
|
2866
2919
|
}
|
|
2867
2920
|
/** Ack the highest cursor that is contiguously applied for every sub. */
|
|
2868
2921
|
#ackAfterPull() {
|
|
@@ -3017,58 +3070,24 @@ export class SyncClient {
|
|
|
3017
3070
|
await this.#applyCommit(frame, summary);
|
|
3018
3071
|
}
|
|
3019
3072
|
break;
|
|
3020
|
-
case 'SEGMENT_INLINE':
|
|
3021
|
-
if (section === undefined ||
|
|
3022
|
-
section.skip ||
|
|
3023
|
-
section.sub === undefined) {
|
|
3024
|
-
break;
|
|
3025
|
-
}
|
|
3026
|
-
const segment = decodeRowsSegment(frame.payload);
|
|
3027
|
-
this.#progress.update({
|
|
3028
|
-
phase: 'import',
|
|
3029
|
-
subscriptionId: section.sub.id,
|
|
3030
|
-
table: section.sub.table,
|
|
3031
|
-
segmentId: undefined,
|
|
3032
|
-
bytesReceived: frame.payload.byteLength,
|
|
3033
|
-
bytesTotal: frame.payload.byteLength,
|
|
3034
|
-
rowsProcessed: 0,
|
|
3035
|
-
rowsTotal: segment.blocks.reduce((n, block) => n + block.length, 0),
|
|
3036
|
-
});
|
|
3037
|
-
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
|
|
3038
|
-
clearFirst,
|
|
3039
|
-
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3040
|
-
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
3041
|
-
effective,
|
|
3042
|
-
transaction: (fn) => this.#applyBatch((batch) => {
|
|
3043
|
-
if (segment.blocks.some((block) => block.length > 0) ||
|
|
3044
|
-
(clearFirst &&
|
|
3045
|
-
this.#scopedRowsExist(table, effective))) {
|
|
3046
|
-
batch.table(table.name);
|
|
3047
|
-
}
|
|
3048
|
-
const result = fn();
|
|
3049
|
-
this.#replayOutbox();
|
|
3050
|
-
return result;
|
|
3051
|
-
}),
|
|
3052
|
-
}, this.#encryption), section.fresh && !section.cleared);
|
|
3053
|
-
break;
|
|
3054
|
-
}
|
|
3073
|
+
case 'SEGMENT_INLINE':
|
|
3055
3074
|
case 'SEGMENT_REF': {
|
|
3056
3075
|
if (section === undefined ||
|
|
3057
3076
|
section.skip ||
|
|
3058
|
-
section.sub === undefined)
|
|
3077
|
+
section.sub === undefined)
|
|
3059
3078
|
break;
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
// broken server — fail loud, never skip or guess.
|
|
3063
|
-
if (frame.mediaType === 'sqlite' &&
|
|
3079
|
+
if (frame.type === 'SEGMENT_REF' &&
|
|
3080
|
+
frame.mediaType === 'sqlite' &&
|
|
3064
3081
|
(this.#acceptMask() & ACCEPT_SQLITE) === 0) {
|
|
3065
3082
|
throw invalidRequest('SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
|
|
3066
3083
|
}
|
|
3067
|
-
const bytes =
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3084
|
+
const bytes = frame.type === 'SEGMENT_REF'
|
|
3085
|
+
? await this.#downloadSegment(frame, section.sub)
|
|
3086
|
+
: frame.payload;
|
|
3087
|
+
if (frame.type === 'SEGMENT_REF')
|
|
3088
|
+
this.#progress.update({ phase: 'import' });
|
|
3089
|
+
if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
|
|
3090
|
+
// Images are whole-table; paged image descriptors are invalid.
|
|
3072
3091
|
if (frame.rowCursor !== undefined ||
|
|
3073
3092
|
frame.nextRowCursor !== undefined) {
|
|
3074
3093
|
throw invalidRequest('sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
|
|
@@ -3078,43 +3097,26 @@ export class SyncClient {
|
|
|
3078
3097
|
rowCount: frame.rowCount,
|
|
3079
3098
|
asOfCommitSeq: frame.asOfCommitSeq,
|
|
3080
3099
|
scopeDigest: frame.scopeDigest,
|
|
3081
|
-
},
|
|
3082
|
-
clearFirst,
|
|
3083
|
-
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3084
|
-
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
3085
|
-
effective,
|
|
3086
|
-
transaction: (fn) => this.#applyBatch((batch) => {
|
|
3087
|
-
if (frame.rowCount > 0 ||
|
|
3088
|
-
(clearFirst &&
|
|
3089
|
-
this.#scopedRowsExist(table, effective))) {
|
|
3090
|
-
batch.table(table.name);
|
|
3091
|
-
}
|
|
3092
|
-
const result = fn();
|
|
3093
|
-
this.#replayOutbox();
|
|
3094
|
-
return result;
|
|
3095
|
-
}),
|
|
3096
|
-
}), section.fresh && !section.cleared);
|
|
3100
|
+
}, this.#segmentApplyOptions(table, clearFirst, effective, frame.rowCount > 0, resetEpoch)), section.fresh && !section.cleared);
|
|
3097
3101
|
}
|
|
3098
3102
|
else {
|
|
3099
3103
|
const segment = decodeRowsSegment(bytes);
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
return result;
|
|
3114
|
-
}),
|
|
3115
|
-
}, this.#encryption), section.fresh &&
|
|
3104
|
+
if (frame.type === 'SEGMENT_INLINE') {
|
|
3105
|
+
this.#progress.update({
|
|
3106
|
+
phase: 'import',
|
|
3107
|
+
subscriptionId: section.sub.id,
|
|
3108
|
+
table: section.sub.table,
|
|
3109
|
+
segmentId: undefined,
|
|
3110
|
+
bytesReceived: bytes.byteLength,
|
|
3111
|
+
bytesTotal: bytes.byteLength,
|
|
3112
|
+
rowsProcessed: 0,
|
|
3113
|
+
rowsTotal: segment.blocks.reduce((n, block) => n + block.length, 0),
|
|
3114
|
+
});
|
|
3115
|
+
}
|
|
3116
|
+
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 &&
|
|
3116
3117
|
!section.cleared &&
|
|
3117
|
-
frame.
|
|
3118
|
+
(frame.type === 'SEGMENT_INLINE' ||
|
|
3119
|
+
frame.rowCursor === undefined));
|
|
3118
3120
|
}
|
|
3119
3121
|
break;
|
|
3120
3122
|
}
|
|
@@ -3149,14 +3151,7 @@ export class SyncClient {
|
|
|
3149
3151
|
}
|
|
3150
3152
|
}
|
|
3151
3153
|
finally {
|
|
3152
|
-
|
|
3153
|
-
// §7.1: local reads see outbox state applied optimistically — replay
|
|
3154
|
-
// the still-pending commits on top of the freshly applied server state.
|
|
3155
|
-
this.#replayOutbox();
|
|
3156
|
-
}
|
|
3157
|
-
finally {
|
|
3158
|
-
this.#endDiagnosticsDeferral();
|
|
3159
|
-
}
|
|
3154
|
+
this.#endDiagnosticsDeferral();
|
|
3160
3155
|
}
|
|
3161
3156
|
if (errorFrame !== undefined)
|
|
3162
3157
|
throw errorFrame;
|
|
@@ -3285,13 +3280,16 @@ export class SyncClient {
|
|
|
3285
3280
|
if (this.#retainFailedCommits)
|
|
3286
3281
|
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
3287
3282
|
this.#rollbackFailedCommit(commit, batch);
|
|
3283
|
+
this.#replayOutbox(commit.operations.map((operation) => JSON.stringify(hasUniqueIndex(this.#table(operation.table))
|
|
3284
|
+
? [operation.table]
|
|
3285
|
+
: [operation.table, operation.rowId])));
|
|
3288
3286
|
});
|
|
3289
3287
|
batch.status();
|
|
3290
3288
|
summary.rejected.push(frame.clientCommitId);
|
|
3291
3289
|
return true;
|
|
3292
3290
|
}
|
|
3293
3291
|
#outboxCommitExists(clientCommitId) {
|
|
3294
|
-
return (this.#db.query(
|
|
3292
|
+
return (this.#db.query(`SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`, [clientCommitId]).length > 0);
|
|
3295
3293
|
}
|
|
3296
3294
|
#decodeServerRow(tableName, payload) {
|
|
3297
3295
|
if (tableName === undefined)
|
|
@@ -3329,7 +3327,7 @@ export class SyncClient {
|
|
|
3329
3327
|
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
3330
3328
|
this.#recordCommitChanges(batch, frame);
|
|
3331
3329
|
const result = fn();
|
|
3332
|
-
this.#replayOutbox(result);
|
|
3330
|
+
this.#replayOutbox(result, false);
|
|
3333
3331
|
return result;
|
|
3334
3332
|
}));
|
|
3335
3333
|
summary.commitsApplied += 1;
|
|
@@ -3378,6 +3376,23 @@ export class SyncClient {
|
|
|
3378
3376
|
return (scope !== undefined &&
|
|
3379
3377
|
this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`, scope.params).length > 0);
|
|
3380
3378
|
}
|
|
3379
|
+
/** Shared transaction, reset fence and progress for both segment formats. */
|
|
3380
|
+
#segmentApplyOptions(table, clearFirst, effective, hasRows, resetEpoch) {
|
|
3381
|
+
return {
|
|
3382
|
+
clearFirst,
|
|
3383
|
+
effective,
|
|
3384
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3385
|
+
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
3386
|
+
transaction: (fn) => this.#applyBatch((batch) => {
|
|
3387
|
+
if (hasRows ||
|
|
3388
|
+
(clearFirst && this.#scopedRowsExist(table, effective)))
|
|
3389
|
+
batch.table(table.name);
|
|
3390
|
+
const result = fn();
|
|
3391
|
+
this.#replayOutbox(result, false);
|
|
3392
|
+
return result;
|
|
3393
|
+
}),
|
|
3394
|
+
};
|
|
3395
|
+
}
|
|
3381
3396
|
/**
|
|
3382
3397
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
3383
3398
|
* (no local scope-column mapping) marks the subscription `failed` and
|
|
@@ -3505,11 +3520,12 @@ export class SyncClient {
|
|
|
3505
3520
|
return matchesLocalScopes(table, effective, values);
|
|
3506
3521
|
}));
|
|
3507
3522
|
if (covered.length) {
|
|
3508
|
-
|
|
3523
|
+
const scope = covered.map((row) => JSON.stringify([sub.table, String(row.id)]));
|
|
3524
|
+
restoreFailedBases(this.#db, this.#schema, scope);
|
|
3509
3525
|
for (const row of covered)
|
|
3510
3526
|
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [sub.table, String(row.id), nextCursor]);
|
|
3511
3527
|
batch.table(sub.table);
|
|
3512
|
-
this.#replayOutbox();
|
|
3528
|
+
this.#replayOutbox(scope, false);
|
|
3513
3529
|
}
|
|
3514
3530
|
}
|
|
3515
3531
|
if (completed && registered !== undefined) {
|
|
@@ -3552,9 +3568,11 @@ export class SyncClient {
|
|
|
3552
3568
|
if (lastEffective !== undefined &&
|
|
3553
3569
|
Object.keys(lastEffective).length > 0) {
|
|
3554
3570
|
try {
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3571
|
+
const scope = [
|
|
3572
|
+
JSON.stringify([table.name]),
|
|
3573
|
+
...this.#dropRetainedRows(batch, (name, values) => name === table.name &&
|
|
3574
|
+
matchesLocalScopes(table, lastEffective, values)),
|
|
3575
|
+
];
|
|
3558
3576
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
3559
3577
|
batch.scopeMap(table, lastEffective);
|
|
3560
3578
|
const pendingById = new Map(listOutbox(this.#db).map((commit) => [
|
|
@@ -3592,6 +3610,7 @@ export class SyncClient {
|
|
|
3592
3610
|
batch.rejections();
|
|
3593
3611
|
batch.outcomes();
|
|
3594
3612
|
}
|
|
3613
|
+
this.#replayOutbox(scope);
|
|
3595
3614
|
this.#deleteUnreferencedBlobs();
|
|
3596
3615
|
}
|
|
3597
3616
|
catch (error) {
|
|
@@ -3764,7 +3783,7 @@ export class SyncClient {
|
|
|
3764
3783
|
? OPTIMISTIC_VERSION
|
|
3765
3784
|
: local[SYNC_VERSION_COLUMN];
|
|
3766
3785
|
if (replay &&
|
|
3767
|
-
table
|
|
3786
|
+
hasUniqueIndex(table) &&
|
|
3768
3787
|
uniqueConflicts(this.#db, table, Object.fromEntries(table.columns.map((column, index) => [
|
|
3769
3788
|
column.name,
|
|
3770
3789
|
values[index] ?? null,
|
|
@@ -3778,33 +3797,35 @@ export class SyncClient {
|
|
|
3778
3797
|
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
|
|
3779
3798
|
}
|
|
3780
3799
|
#dropRetainedRows(batch, matches) {
|
|
3781
|
-
|
|
3800
|
+
const scope = [];
|
|
3801
|
+
for (const commit of dropFailedRows(this.#db, this.#schema, matches)) {
|
|
3782
3802
|
batch.conflicts();
|
|
3783
3803
|
batch.rejections();
|
|
3784
3804
|
batch.outcomes();
|
|
3785
3805
|
this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3786
3806
|
this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3787
|
-
for (const op of commit.operations)
|
|
3807
|
+
for (const op of commit.operations) {
|
|
3788
3808
|
batch.table(op.table);
|
|
3809
|
+
scope.push(JSON.stringify([op.table, op.rowId]));
|
|
3810
|
+
}
|
|
3789
3811
|
}
|
|
3812
|
+
return scope;
|
|
3790
3813
|
}
|
|
3791
|
-
#replayOutbox(
|
|
3792
|
-
|
|
3793
|
-
const pending = [
|
|
3794
|
-
...failedOverlayCommits(this.#db, false, rowKeys),
|
|
3795
|
-
...listOutbox(this.#db, rowKeys),
|
|
3796
|
-
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
3797
|
-
if (pending.length === 0 && acknowledged.length === 0)
|
|
3814
|
+
#replayOutbox(scope, restoreBases = true) {
|
|
3815
|
+
if (scope?.length === 0)
|
|
3798
3816
|
return;
|
|
3799
3817
|
this.#applyBatch((batch) => {
|
|
3800
|
-
|
|
3801
|
-
restoreFailedBases(this.#db, this.#schema,
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3818
|
+
if (restoreBases)
|
|
3819
|
+
restoreFailedBases(this.#db, this.#schema, scope);
|
|
3820
|
+
const acknowledged = failedOverlayCommits(this.#db, true, scope, this.#schema);
|
|
3821
|
+
const pending = [
|
|
3822
|
+
...failedOverlayCommits(this.#db, false, scope, this.#schema),
|
|
3823
|
+
...listOutbox(this.#db, scope),
|
|
3824
|
+
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
3825
|
+
for (const commit of [...acknowledged, ...pending])
|
|
3826
|
+
this.#applyOperationsLocally(commit.operations.filter((op) => scope === undefined ||
|
|
3827
|
+
scope.includes(JSON.stringify([op.table, op.rowId])) ||
|
|
3828
|
+
scope.includes(JSON.stringify([op.table]))), batch, true);
|
|
3808
3829
|
});
|
|
3809
3830
|
}
|
|
3810
3831
|
#deleteUnreferencedBlobs() {
|