@syncular/client 0.30.12 → 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/dist/client.d.ts +3 -0
- package/dist/client.js +130 -122
- 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/client.ts +148 -150
- package/src/worker-entry.ts +13 -50
- package/src/worker-host.ts +4 -0
- package/src/worker-protocol.ts +1 -0
package/dist/client.d.ts
CHANGED
|
@@ -104,6 +104,7 @@ export interface SyncClientLimits {
|
|
|
104
104
|
readonly outcomeRetentionMaxEntries?: number;
|
|
105
105
|
}
|
|
106
106
|
export interface SyncClientConfig {
|
|
107
|
+
readonly transportEnabled?: boolean;
|
|
107
108
|
/** Keep rejected/conflicting local intent visible until explicit resolution. */
|
|
108
109
|
readonly retainFailedCommits?: boolean;
|
|
109
110
|
readonly database: ClientDatabase;
|
|
@@ -295,6 +296,8 @@ export declare class SyncClient {
|
|
|
295
296
|
/** Acquire leadership, create local tables, resolve the clientId. */
|
|
296
297
|
start(): Promise<void>;
|
|
297
298
|
close(): Promise<void>;
|
|
299
|
+
/** Host-owned transport policy; the local replica remains available. */
|
|
300
|
+
setTransportEnabled(enabled: boolean): void;
|
|
298
301
|
/** Current fail-closed local-replica security state. */
|
|
299
302
|
securityLifecycle(): SecurityLifecycle;
|
|
300
303
|
/**
|
package/dist/client.js
CHANGED
|
@@ -133,6 +133,7 @@ export class SyncClient {
|
|
|
133
133
|
/** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
|
|
134
134
|
#previousVersion;
|
|
135
135
|
#securityLifecycle;
|
|
136
|
+
#transportEnabled;
|
|
136
137
|
#now;
|
|
137
138
|
#outcomeRetentionMaxEntries;
|
|
138
139
|
#retainFailedCommits;
|
|
@@ -221,6 +222,11 @@ export class SyncClient {
|
|
|
221
222
|
#protectedAsync = new Set();
|
|
222
223
|
#preflightBarrier;
|
|
223
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;
|
|
224
230
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
225
231
|
throw invalidRequest('securityPreflight and encryption are mutually exclusive; activateSecurity installs keys');
|
|
226
232
|
}
|
|
@@ -537,6 +543,8 @@ export class SyncClient {
|
|
|
537
543
|
this.#syncIntentListeners.clear();
|
|
538
544
|
}
|
|
539
545
|
#emitSyncNeeded(reason) {
|
|
546
|
+
if (!this.#transportEnabled)
|
|
547
|
+
return;
|
|
540
548
|
try {
|
|
541
549
|
this.#config.onSyncNeeded?.(reason);
|
|
542
550
|
}
|
|
@@ -553,6 +561,8 @@ export class SyncClient {
|
|
|
553
561
|
}
|
|
554
562
|
}
|
|
555
563
|
#emitSyncIntent(intent) {
|
|
564
|
+
if (!this.#transportEnabled && intent.kind !== 'none')
|
|
565
|
+
return;
|
|
556
566
|
try {
|
|
557
567
|
this.#config.onSyncIntent?.(intent);
|
|
558
568
|
}
|
|
@@ -568,6 +578,25 @@ export class SyncClient {
|
|
|
568
578
|
}
|
|
569
579
|
}
|
|
570
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
|
+
}
|
|
571
600
|
/** Current fail-closed local-replica security state. */
|
|
572
601
|
securityLifecycle() {
|
|
573
602
|
return this.#securityLifecycle;
|
|
@@ -1028,12 +1057,14 @@ export class SyncClient {
|
|
|
1028
1057
|
}
|
|
1029
1058
|
const capturedAtMs = this.#now();
|
|
1030
1059
|
const leaseState = this.#diagnosticLease(capturedAtMs);
|
|
1031
|
-
const connectivity = this.#
|
|
1032
|
-
? '
|
|
1033
|
-
: this.#lastRound?.status === '
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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';
|
|
1037
1068
|
const expectedOrder = new Map(expected.map((item, index) => [item.id, index]));
|
|
1038
1069
|
const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
|
|
1039
1070
|
const aExpected = expectedOrder.get(a.id);
|
|
@@ -1057,16 +1088,12 @@ export class SyncClient {
|
|
|
1057
1088
|
realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
|
|
1058
1089
|
}),
|
|
1059
1090
|
securityLifecycle: this.#securityLifecycle,
|
|
1060
|
-
schema: {
|
|
1091
|
+
schema: definedObject({
|
|
1061
1092
|
currentVersion: this.#config.schema.version,
|
|
1062
1093
|
upgrading: this.#upgrading,
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
...(this.#schemaFloor?.latestSchemaVersion !== undefined
|
|
1067
|
-
? { latestVersion: this.#schemaFloor.latestSchemaVersion }
|
|
1068
|
-
: {}),
|
|
1069
|
-
},
|
|
1094
|
+
requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
|
|
1095
|
+
latestVersion: this.#schemaFloor?.latestSchemaVersion,
|
|
1096
|
+
}),
|
|
1070
1097
|
replica: {
|
|
1071
1098
|
localRevision: getLocalRevision(this.#db).toString(),
|
|
1072
1099
|
syncNeeded: this.#needsPull,
|
|
@@ -1246,7 +1273,11 @@ export class SyncClient {
|
|
|
1246
1273
|
this.#requireActive();
|
|
1247
1274
|
const task = Promise.resolve().then(fn);
|
|
1248
1275
|
this.#protectedAsync.add(task);
|
|
1249
|
-
|
|
1276
|
+
const settled = () => {
|
|
1277
|
+
if (this.#realtimeConnectPromise === task)
|
|
1278
|
+
this.#realtimeConnectPromise = undefined;
|
|
1279
|
+
};
|
|
1280
|
+
void task.then(settled, settled);
|
|
1250
1281
|
return task;
|
|
1251
1282
|
}
|
|
1252
1283
|
// -- blobs (§5.9) ---------------------------------------------------------
|
|
@@ -1272,14 +1303,12 @@ export class SyncClient {
|
|
|
1272
1303
|
recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
|
|
1273
1304
|
});
|
|
1274
1305
|
this.#enforceBlobCacheCap();
|
|
1275
|
-
return {
|
|
1306
|
+
return definedObject({
|
|
1276
1307
|
blobId,
|
|
1277
1308
|
byteLength: bytes.length,
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
...(options?.name !== undefined ? { name: options.name } : {}),
|
|
1282
|
-
};
|
|
1309
|
+
mediaType: options?.mediaType,
|
|
1310
|
+
name: options?.name,
|
|
1311
|
+
});
|
|
1283
1312
|
}
|
|
1284
1313
|
/** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
|
|
1285
1314
|
blobRefString(ref) {
|
|
@@ -1309,6 +1338,7 @@ export class SyncClient {
|
|
|
1309
1338
|
// presign configured) a signed url the client fetches directly. On a url
|
|
1310
1339
|
// arm the client MUST NOT attach host auth and MUST NOT fall through:
|
|
1311
1340
|
// failure => re-request, the caller's next fetchBlob mints a fresh url.
|
|
1341
|
+
this.#requireTransport();
|
|
1312
1342
|
const response = await transport.download(blobId);
|
|
1313
1343
|
let bytes;
|
|
1314
1344
|
if (response.kind === 'url') {
|
|
@@ -1350,6 +1380,7 @@ export class SyncClient {
|
|
|
1350
1380
|
}
|
|
1351
1381
|
/** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
|
|
1352
1382
|
flushBlobUploads() {
|
|
1383
|
+
this.#requireTransport();
|
|
1353
1384
|
return this.#runProtectedAsync(() => this.#flushBlobUploads());
|
|
1354
1385
|
}
|
|
1355
1386
|
async #flushBlobUploads() {
|
|
@@ -1443,12 +1474,9 @@ export class SyncClient {
|
|
|
1443
1474
|
else if (replacement !== undefined) {
|
|
1444
1475
|
throw invalidRequest('replacementClientCommitId is valid only for superseded outcomes');
|
|
1445
1476
|
}
|
|
1446
|
-
const allowed = (current.status === 'conflict' &&
|
|
1477
|
+
const allowed = ((current.status === 'conflict' || current.status === 'rejected') &&
|
|
1447
1478
|
(input.resolution === 'resolved_keep_server' ||
|
|
1448
1479
|
input.resolution === 'superseded')) ||
|
|
1449
|
-
(current.status === 'rejected' &&
|
|
1450
|
-
(input.resolution === 'superseded' ||
|
|
1451
|
-
input.resolution === 'resolved_keep_server')) ||
|
|
1452
1480
|
((current.status === 'applied' || current.status === 'cached') &&
|
|
1453
1481
|
input.resolution === 'dismissed');
|
|
1454
1482
|
if (!allowed) {
|
|
@@ -1529,6 +1557,7 @@ export class SyncClient {
|
|
|
1529
1557
|
*/
|
|
1530
1558
|
setPresence(scopeKey, doc) {
|
|
1531
1559
|
this.#requireActive();
|
|
1560
|
+
this.#requireTransport();
|
|
1532
1561
|
const socket = this.#socket;
|
|
1533
1562
|
if (socket === undefined) {
|
|
1534
1563
|
throw invalidRequest('setPresence requires a connected realtime socket (§8.6)');
|
|
@@ -1667,7 +1696,9 @@ export class SyncClient {
|
|
|
1667
1696
|
}
|
|
1668
1697
|
});
|
|
1669
1698
|
const effects = {
|
|
1670
|
-
sync: changed || widened
|
|
1699
|
+
sync: this.#transportEnabled && (changed || widened)
|
|
1700
|
+
? { kind: 'interactive' }
|
|
1701
|
+
: { kind: 'none' },
|
|
1671
1702
|
};
|
|
1672
1703
|
if (effects.sync.kind === 'interactive') {
|
|
1673
1704
|
this.#emitSyncIntent(effects.sync);
|
|
@@ -1922,7 +1953,9 @@ export class SyncClient {
|
|
|
1922
1953
|
mutateCommand(mutations) {
|
|
1923
1954
|
return {
|
|
1924
1955
|
value: this.mutate(mutations),
|
|
1925
|
-
effects: {
|
|
1956
|
+
effects: {
|
|
1957
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
1958
|
+
},
|
|
1926
1959
|
};
|
|
1927
1960
|
}
|
|
1928
1961
|
/**
|
|
@@ -1943,17 +1976,15 @@ export class SyncClient {
|
|
|
1943
1976
|
throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
|
|
1944
1977
|
}
|
|
1945
1978
|
return this.#recordMutations([
|
|
1946
|
-
{
|
|
1979
|
+
definedObject({
|
|
1947
1980
|
table,
|
|
1948
1981
|
op: 'patch',
|
|
1949
1982
|
values: {
|
|
1950
1983
|
...Object.fromEntries(normalized),
|
|
1951
1984
|
[pkColumn.name]: rowId,
|
|
1952
1985
|
},
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
: {}),
|
|
1956
|
-
},
|
|
1986
|
+
baseVersion: options?.baseVersion,
|
|
1987
|
+
}),
|
|
1957
1988
|
]);
|
|
1958
1989
|
}
|
|
1959
1990
|
/**
|
|
@@ -2187,7 +2218,9 @@ export class SyncClient {
|
|
|
2187
2218
|
patchCommand(table, rowId, partial, options) {
|
|
2188
2219
|
return {
|
|
2189
2220
|
value: this.patch(table, rowId, partial, options),
|
|
2190
|
-
effects: {
|
|
2221
|
+
effects: {
|
|
2222
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
2223
|
+
},
|
|
2191
2224
|
};
|
|
2192
2225
|
}
|
|
2193
2226
|
// -- lease state (§7.3.5) ---------------------------------------------------
|
|
@@ -2282,16 +2315,14 @@ export class SyncClient {
|
|
|
2282
2315
|
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
2283
2316
|
this.#rollbackFailedCommit(commit, batch);
|
|
2284
2317
|
this.#replayOutbox();
|
|
2285
|
-
const rejection = {
|
|
2318
|
+
const rejection = definedObject({
|
|
2286
2319
|
clientCommitId: commit.clientCommitId,
|
|
2287
2320
|
opIndex: 0,
|
|
2288
2321
|
code,
|
|
2289
2322
|
message,
|
|
2290
2323
|
retryable: false,
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
: {}),
|
|
2294
|
-
};
|
|
2324
|
+
operation: commit.operations[0],
|
|
2325
|
+
});
|
|
2295
2326
|
this.#rejections.push(rejection);
|
|
2296
2327
|
recordCommitOutcome(this.#db, {
|
|
2297
2328
|
clientCommitId: commit.clientCommitId,
|
|
@@ -2319,7 +2350,7 @@ export class SyncClient {
|
|
|
2319
2350
|
sync() {
|
|
2320
2351
|
this.#requireActive();
|
|
2321
2352
|
if (this.#syncOutstanding) {
|
|
2322
|
-
return Promise.reject(invalidRequest('sync() is already running
|
|
2353
|
+
return Promise.reject(invalidRequest('sync() is already running; coalesce wake-ups'));
|
|
2323
2354
|
}
|
|
2324
2355
|
this.#syncOutstanding = true;
|
|
2325
2356
|
this.#beginDiagnosticsDeferral();
|
|
@@ -2361,6 +2392,8 @@ export class SyncClient {
|
|
|
2361
2392
|
})
|
|
2362
2393
|
.finally(() => {
|
|
2363
2394
|
this.#syncOutstanding = false;
|
|
2395
|
+
if (!this.#transportEnabled)
|
|
2396
|
+
this.disconnectRealtime();
|
|
2364
2397
|
this.#endDiagnosticsDeferral();
|
|
2365
2398
|
});
|
|
2366
2399
|
}
|
|
@@ -2393,6 +2426,7 @@ export class SyncClient {
|
|
|
2393
2426
|
: 'client.unknown_failure';
|
|
2394
2427
|
}
|
|
2395
2428
|
async #runSync() {
|
|
2429
|
+
this.#requireTransport();
|
|
2396
2430
|
this.#roundRetryDelayMs = undefined;
|
|
2397
2431
|
this.#progress.emit({
|
|
2398
2432
|
attempt: ++this.#progressAttempt,
|
|
@@ -2422,7 +2456,7 @@ export class SyncClient {
|
|
|
2422
2456
|
if (logEpoch !== undefined &&
|
|
2423
2457
|
this.#hasBlobs &&
|
|
2424
2458
|
this.#config.blobs !== undefined) {
|
|
2425
|
-
await this
|
|
2459
|
+
await this.#flushBlobUploads();
|
|
2426
2460
|
}
|
|
2427
2461
|
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
2428
2462
|
// cannot express itself under the new schema (a dropped column/table)
|
|
@@ -2538,6 +2572,8 @@ export class SyncClient {
|
|
|
2538
2572
|
for (let round = 0; round < budget; round++) {
|
|
2539
2573
|
const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]));
|
|
2540
2574
|
last = await this.sync();
|
|
2575
|
+
if (!this.#transportEnabled || last.schemaFloor !== undefined)
|
|
2576
|
+
return last;
|
|
2541
2577
|
if (maxRounds === undefined &&
|
|
2542
2578
|
last.segmentRowsApplied > 0 &&
|
|
2543
2579
|
last.bootstrapping.length > 0 &&
|
|
@@ -2547,8 +2583,6 @@ export class SyncClient {
|
|
|
2547
2583
|
sub.bootstrapState,
|
|
2548
2584
|
])))
|
|
2549
2585
|
round = -1;
|
|
2550
|
-
if (last.schemaFloor !== undefined)
|
|
2551
|
-
return last;
|
|
2552
2586
|
if (last.commitsApplied === 0 &&
|
|
2553
2587
|
last.segmentRowsApplied === 0 &&
|
|
2554
2588
|
last.bootstrapping.length === 0 &&
|
|
@@ -2640,6 +2674,7 @@ export class SyncClient {
|
|
|
2640
2674
|
}
|
|
2641
2675
|
connectRealtime() {
|
|
2642
2676
|
this.#requireActive();
|
|
2677
|
+
this.#requireTransport();
|
|
2643
2678
|
if (this.#realtimePolicy === 'off') {
|
|
2644
2679
|
return Promise.reject(invalidRequest("realtimePolicy is 'off'; realtime connect is refused"));
|
|
2645
2680
|
}
|
|
@@ -2695,9 +2730,13 @@ export class SyncClient {
|
|
|
2695
2730
|
},
|
|
2696
2731
|
});
|
|
2697
2732
|
openedSocket = socket;
|
|
2733
|
+
if (!this.#transportEnabled) {
|
|
2734
|
+
socket.close();
|
|
2735
|
+
this.#requireTransport();
|
|
2736
|
+
}
|
|
2698
2737
|
if (this.#securityLifecycle === 'preflight') {
|
|
2699
2738
|
socket.close();
|
|
2700
|
-
|
|
2739
|
+
this.#requireActive();
|
|
2701
2740
|
}
|
|
2702
2741
|
if (generation !== this.#realtimeGeneration || !this.#started) {
|
|
2703
2742
|
socket.close();
|
|
@@ -2834,6 +2873,8 @@ export class SyncClient {
|
|
|
2834
2873
|
}
|
|
2835
2874
|
}
|
|
2836
2875
|
async #handleRealtimeBinary(bytes) {
|
|
2876
|
+
if (!this.#transportEnabled)
|
|
2877
|
+
return;
|
|
2837
2878
|
if (this.#syncing) {
|
|
2838
2879
|
// Fast path: a pull is mid-flight; let it win and recover the gap
|
|
2839
2880
|
// itself — re-pulling is idempotent, interleaved application is not
|
|
@@ -2862,7 +2903,8 @@ export class SyncClient {
|
|
|
2862
2903
|
});
|
|
2863
2904
|
}
|
|
2864
2905
|
#sendAck(cursor) {
|
|
2865
|
-
this.#
|
|
2906
|
+
if (this.#transportEnabled)
|
|
2907
|
+
this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
|
|
2866
2908
|
}
|
|
2867
2909
|
/** Ack the highest cursor that is contiguously applied for every sub. */
|
|
2868
2910
|
#ackAfterPull() {
|
|
@@ -3017,58 +3059,24 @@ export class SyncClient {
|
|
|
3017
3059
|
await this.#applyCommit(frame, summary);
|
|
3018
3060
|
}
|
|
3019
3061
|
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
|
-
}
|
|
3062
|
+
case 'SEGMENT_INLINE':
|
|
3055
3063
|
case 'SEGMENT_REF': {
|
|
3056
3064
|
if (section === undefined ||
|
|
3057
3065
|
section.skip ||
|
|
3058
|
-
section.sub === undefined)
|
|
3066
|
+
section.sub === undefined)
|
|
3059
3067
|
break;
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
// broken server — fail loud, never skip or guess.
|
|
3063
|
-
if (frame.mediaType === 'sqlite' &&
|
|
3068
|
+
if (frame.type === 'SEGMENT_REF' &&
|
|
3069
|
+
frame.mediaType === 'sqlite' &&
|
|
3064
3070
|
(this.#acceptMask() & ACCEPT_SQLITE) === 0) {
|
|
3065
3071
|
throw invalidRequest('SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
|
|
3066
3072
|
}
|
|
3067
|
-
const bytes =
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
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.
|
|
3072
3080
|
if (frame.rowCursor !== undefined ||
|
|
3073
3081
|
frame.nextRowCursor !== undefined) {
|
|
3074
3082
|
throw invalidRequest('sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
|
|
@@ -3078,43 +3086,26 @@ export class SyncClient {
|
|
|
3078
3086
|
rowCount: frame.rowCount,
|
|
3079
3087
|
asOfCommitSeq: frame.asOfCommitSeq,
|
|
3080
3088
|
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);
|
|
3089
|
+
}, this.#segmentApplyOptions(table, clearFirst, effective, frame.rowCount > 0, resetEpoch)), section.fresh && !section.cleared);
|
|
3097
3090
|
}
|
|
3098
3091
|
else {
|
|
3099
3092
|
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 &&
|
|
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 &&
|
|
3116
3106
|
!section.cleared &&
|
|
3117
|
-
frame.
|
|
3107
|
+
(frame.type === 'SEGMENT_INLINE' ||
|
|
3108
|
+
frame.rowCursor === undefined));
|
|
3118
3109
|
}
|
|
3119
3110
|
break;
|
|
3120
3111
|
}
|
|
@@ -3378,6 +3369,23 @@ export class SyncClient {
|
|
|
3378
3369
|
return (scope !== undefined &&
|
|
3379
3370
|
this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`, scope.params).length > 0);
|
|
3380
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
|
+
};
|
|
3388
|
+
}
|
|
3381
3389
|
/**
|
|
3382
3390
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
3383
3391
|
* (no local scope-column mapping) marks the subscription `failed` and
|
package/dist/worker-entry.js
CHANGED
|
@@ -94,6 +94,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
94
94
|
autoSyncScheduled = false;
|
|
95
95
|
if (closed ||
|
|
96
96
|
client === undefined ||
|
|
97
|
+
offline ||
|
|
97
98
|
client.securityLifecycle() === 'preflight') {
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
@@ -116,6 +117,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
116
117
|
if (!autoSync ||
|
|
117
118
|
closed ||
|
|
118
119
|
client === undefined ||
|
|
120
|
+
offline ||
|
|
119
121
|
client.securityLifecycle() === 'preflight' ||
|
|
120
122
|
intent.kind === 'none') {
|
|
121
123
|
return;
|
|
@@ -156,31 +158,6 @@ export function startSyncWorker(overrides = {}) {
|
|
|
156
158
|
}
|
|
157
159
|
return client;
|
|
158
160
|
}
|
|
159
|
-
function assertOnline() {
|
|
160
|
-
if (offline) {
|
|
161
|
-
throw new ClientSyncError('sync.transport_failed', 'the worker transport is offline (setOffline(true))', true);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
function gateOffline(inner) {
|
|
165
|
-
return (request) => {
|
|
166
|
-
assertOnline();
|
|
167
|
-
return inner(request);
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
/** Offline gate that preserves the §5.4 `fetchUrl` capability marker. */
|
|
171
|
-
function gateSegmentsOffline(inner) {
|
|
172
|
-
const fetchUrl = inner.fetchUrl;
|
|
173
|
-
return Object.assign(gateOffline(inner), {
|
|
174
|
-
...(fetchUrl !== undefined
|
|
175
|
-
? {
|
|
176
|
-
fetchUrl: (url, onProgress) => {
|
|
177
|
-
assertOnline();
|
|
178
|
-
return fetchUrl(url, onProgress);
|
|
179
|
-
},
|
|
180
|
-
}
|
|
181
|
-
: {}),
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
161
|
async function init(config) {
|
|
185
162
|
if (client !== undefined) {
|
|
186
163
|
throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
|
|
@@ -215,9 +192,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
215
192
|
}
|
|
216
193
|
}
|
|
217
194
|
}
|
|
218
|
-
const transport =
|
|
195
|
+
const transport = overrides.createTransport !== undefined
|
|
219
196
|
? overrides.createTransport(config)
|
|
220
|
-
: httpSyncTransport(config.endpoints.syncUrl, http)
|
|
197
|
+
: httpSyncTransport(config.endpoints.syncUrl, http);
|
|
221
198
|
const segments = overrides.createSegments !== undefined
|
|
222
199
|
? overrides.createSegments(config)
|
|
223
200
|
: config.endpoints.segmentsUrl !== undefined
|
|
@@ -231,18 +208,15 @@ export function startSyncWorker(overrides = {}) {
|
|
|
231
208
|
// `{clientId}` URL placeholder — start the client first, then attach
|
|
232
209
|
// the connector through a mutable slot read at connect time.
|
|
233
210
|
let realtimeConnector;
|
|
211
|
+
offline = config.transportEnabled === false;
|
|
234
212
|
const started = new SyncClient({
|
|
213
|
+
transportEnabled: !offline,
|
|
235
214
|
database,
|
|
236
215
|
schema: config.schema,
|
|
237
216
|
transport,
|
|
238
|
-
...(segments !== undefined
|
|
239
|
-
? { segments: gateSegmentsOffline(segments) }
|
|
240
|
-
: {}),
|
|
217
|
+
...(segments !== undefined ? { segments } : {}),
|
|
241
218
|
...(blobs !== undefined ? { blobs } : {}),
|
|
242
219
|
realtime: (handlers) => {
|
|
243
|
-
if (offline) {
|
|
244
|
-
throw new ClientSyncError('sync.transport_failed', 'the worker realtime channel is offline (setOffline(true))', true);
|
|
245
|
-
}
|
|
246
220
|
if (realtimeConnector === undefined) {
|
|
247
221
|
throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
|
|
248
222
|
}
|
|
@@ -399,8 +373,13 @@ export function startSyncWorker(overrides = {}) {
|
|
|
399
373
|
},
|
|
400
374
|
setOffline: (value) => {
|
|
401
375
|
offline = value;
|
|
402
|
-
|
|
403
|
-
|
|
376
|
+
requireClient().setTransportEnabled(!value);
|
|
377
|
+
if (offline) {
|
|
378
|
+
if (backgroundTimer !== undefined)
|
|
379
|
+
clearTimeout(backgroundTimer);
|
|
380
|
+
backgroundTimer = undefined;
|
|
381
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
382
|
+
}
|
|
404
383
|
else if (client !== undefined) {
|
|
405
384
|
const snapshot = client.diagnosticsSnapshot();
|
|
406
385
|
post({
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -85,6 +85,7 @@ export interface SyncClientHandleConfig {
|
|
|
85
85
|
readonly encryption?: EncryptionKeyringConfig;
|
|
86
86
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
87
87
|
readonly securityPreflight?: boolean;
|
|
88
|
+
readonly transportEnabled?: boolean;
|
|
88
89
|
readonly clientId?: string;
|
|
89
90
|
readonly limits?: SyncClientLimits;
|
|
90
91
|
readonly retainFailedCommits?: boolean;
|
package/dist/worker-host.js
CHANGED
|
@@ -566,6 +566,9 @@ function buildInitConfig(config, headers = config.headers) {
|
|
|
566
566
|
...(config.encryption !== undefined
|
|
567
567
|
? { encryption: config.encryption }
|
|
568
568
|
: {}),
|
|
569
|
+
...(config.transportEnabled !== undefined
|
|
570
|
+
? { transportEnabled: config.transportEnabled }
|
|
571
|
+
: {}),
|
|
569
572
|
...(config.securityPreflight !== undefined
|
|
570
573
|
? { securityPreflight: config.securityPreflight }
|
|
571
574
|
: {}),
|
|
@@ -78,6 +78,7 @@ export interface WorkerInitConfig {
|
|
|
78
78
|
readonly encryption?: EncryptionKeyringConfig;
|
|
79
79
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
80
80
|
readonly securityPreflight?: boolean;
|
|
81
|
+
readonly transportEnabled?: boolean;
|
|
81
82
|
readonly clientId?: string;
|
|
82
83
|
readonly limits?: SyncClientLimits;
|
|
83
84
|
readonly retainFailedCommits?: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.13",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.30.
|
|
92
|
+
"@syncular/core": "0.30.13"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.30.
|
|
95
|
+
"@syncular/server": "0.30.13",
|
|
96
96
|
"playwright": "1.63.0",
|
|
97
97
|
"@electric-sql/pglite": "^0.5.4"
|
|
98
98
|
}
|