@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/src/client.ts
CHANGED
|
@@ -349,6 +349,7 @@ export interface SyncClientLimits {
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
export interface SyncClientConfig {
|
|
352
|
+
readonly transportEnabled?: boolean;
|
|
352
353
|
/** Keep rejected/conflicting local intent visible until explicit resolution. */
|
|
353
354
|
readonly retainFailedCommits?: boolean;
|
|
354
355
|
readonly database: ClientDatabase;
|
|
@@ -747,6 +748,7 @@ export class SyncClient {
|
|
|
747
748
|
/** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
|
|
748
749
|
readonly #previousVersion: PreviousVersionCaptureConfig | undefined;
|
|
749
750
|
#securityLifecycle: SecurityLifecycle;
|
|
751
|
+
#transportEnabled: boolean;
|
|
750
752
|
readonly #now: () => number;
|
|
751
753
|
readonly #outcomeRetentionMaxEntries: number;
|
|
752
754
|
readonly #retainFailedCommits: boolean;
|
|
@@ -838,6 +840,13 @@ export class SyncClient {
|
|
|
838
840
|
#preflightBarrier: Promise<void> | undefined;
|
|
839
841
|
|
|
840
842
|
constructor(config: SyncClientConfig) {
|
|
843
|
+
if (
|
|
844
|
+
config.transportEnabled !== undefined &&
|
|
845
|
+
typeof config.transportEnabled !== 'boolean'
|
|
846
|
+
) {
|
|
847
|
+
throw invalidRequest('transportEnabled must be a boolean');
|
|
848
|
+
}
|
|
849
|
+
this.#transportEnabled = config.transportEnabled !== false;
|
|
841
850
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
842
851
|
throw invalidRequest(
|
|
843
852
|
'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
|
|
@@ -1225,6 +1234,7 @@ export class SyncClient {
|
|
|
1225
1234
|
}
|
|
1226
1235
|
|
|
1227
1236
|
#emitSyncNeeded(reason: 'startup' | 'hello' | WakeReason): void {
|
|
1237
|
+
if (!this.#transportEnabled) return;
|
|
1228
1238
|
try {
|
|
1229
1239
|
this.#config.onSyncNeeded?.(reason);
|
|
1230
1240
|
} catch {
|
|
@@ -1240,6 +1250,7 @@ export class SyncClient {
|
|
|
1240
1250
|
}
|
|
1241
1251
|
|
|
1242
1252
|
#emitSyncIntent(intent: SyncIntent): void {
|
|
1253
|
+
if (!this.#transportEnabled && intent.kind !== 'none') return;
|
|
1243
1254
|
try {
|
|
1244
1255
|
this.#config.onSyncIntent?.(intent);
|
|
1245
1256
|
} catch {
|
|
@@ -1254,6 +1265,27 @@ export class SyncClient {
|
|
|
1254
1265
|
}
|
|
1255
1266
|
}
|
|
1256
1267
|
|
|
1268
|
+
/** Host-owned transport policy; the local replica remains available. */
|
|
1269
|
+
setTransportEnabled(enabled: boolean): void {
|
|
1270
|
+
this.#requireStarted();
|
|
1271
|
+
if (typeof enabled !== 'boolean')
|
|
1272
|
+
throw invalidRequest('transportEnabled must be a boolean');
|
|
1273
|
+
if (this.#transportEnabled === enabled) return;
|
|
1274
|
+
this.#transportEnabled = enabled;
|
|
1275
|
+
if (!enabled && !this.#syncOutstanding) this.disconnectRealtime();
|
|
1276
|
+
this.#emitSyncIntent(
|
|
1277
|
+
enabled && this.#securityLifecycle === 'active'
|
|
1278
|
+
? { kind: 'interactive' }
|
|
1279
|
+
: { kind: 'none' },
|
|
1280
|
+
);
|
|
1281
|
+
this.#emitDiagnostics();
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
#requireTransport(): void {
|
|
1285
|
+
if (!this.#transportEnabled)
|
|
1286
|
+
throw new ClientSyncError('sync.offline', 'transport gate is closed');
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1257
1289
|
/** Current fail-closed local-replica security state. */
|
|
1258
1290
|
securityLifecycle(): SecurityLifecycle {
|
|
1259
1291
|
return this.#securityLifecycle;
|
|
@@ -1799,8 +1831,9 @@ export class SyncClient {
|
|
|
1799
1831
|
}
|
|
1800
1832
|
const capturedAtMs = this.#now();
|
|
1801
1833
|
const leaseState = this.#diagnosticLease(capturedAtMs);
|
|
1802
|
-
const connectivity =
|
|
1803
|
-
|
|
1834
|
+
const connectivity = !this.#transportEnabled
|
|
1835
|
+
? 'offline'
|
|
1836
|
+
: this.#lastRound?.status === 'succeeded'
|
|
1804
1837
|
? 'online'
|
|
1805
1838
|
: this.#lastRound?.status === 'failed' &&
|
|
1806
1839
|
this.#transportFailureCode(this.#lastRound.errorCode)
|
|
@@ -1833,16 +1866,12 @@ export class SyncClient {
|
|
|
1833
1866
|
realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
|
|
1834
1867
|
}),
|
|
1835
1868
|
securityLifecycle: this.#securityLifecycle,
|
|
1836
|
-
schema: {
|
|
1869
|
+
schema: definedObject({
|
|
1837
1870
|
currentVersion: this.#config.schema.version,
|
|
1838
1871
|
upgrading: this.#upgrading,
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
...(this.#schemaFloor?.latestSchemaVersion !== undefined
|
|
1843
|
-
? { latestVersion: this.#schemaFloor.latestSchemaVersion }
|
|
1844
|
-
: {}),
|
|
1845
|
-
},
|
|
1872
|
+
requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
|
|
1873
|
+
latestVersion: this.#schemaFloor?.latestSchemaVersion,
|
|
1874
|
+
}),
|
|
1846
1875
|
replica: {
|
|
1847
1876
|
localRevision: getLocalRevision(this.#db).toString(),
|
|
1848
1877
|
syncNeeded: this.#needsPull,
|
|
@@ -2058,10 +2087,11 @@ export class SyncClient {
|
|
|
2058
2087
|
this.#requireActive();
|
|
2059
2088
|
const task = Promise.resolve().then(fn);
|
|
2060
2089
|
this.#protectedAsync.add(task);
|
|
2061
|
-
|
|
2062
|
-
(
|
|
2063
|
-
|
|
2064
|
-
|
|
2090
|
+
const settled = () => {
|
|
2091
|
+
if (this.#realtimeConnectPromise === task)
|
|
2092
|
+
this.#realtimeConnectPromise = undefined;
|
|
2093
|
+
};
|
|
2094
|
+
void task.then(settled, settled);
|
|
2065
2095
|
return task;
|
|
2066
2096
|
}
|
|
2067
2097
|
|
|
@@ -2098,14 +2128,12 @@ export class SyncClient {
|
|
|
2098
2128
|
recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
|
|
2099
2129
|
});
|
|
2100
2130
|
this.#enforceBlobCacheCap();
|
|
2101
|
-
return {
|
|
2131
|
+
return definedObject({
|
|
2102
2132
|
blobId,
|
|
2103
2133
|
byteLength: bytes.length,
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
...(options?.name !== undefined ? { name: options.name } : {}),
|
|
2108
|
-
};
|
|
2134
|
+
mediaType: options?.mediaType,
|
|
2135
|
+
name: options?.name,
|
|
2136
|
+
});
|
|
2109
2137
|
}
|
|
2110
2138
|
|
|
2111
2139
|
/** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
|
|
@@ -2139,6 +2167,7 @@ export class SyncClient {
|
|
|
2139
2167
|
// presign configured) a signed url the client fetches directly. On a url
|
|
2140
2168
|
// arm the client MUST NOT attach host auth and MUST NOT fall through:
|
|
2141
2169
|
// failure => re-request, the caller's next fetchBlob mints a fresh url.
|
|
2170
|
+
this.#requireTransport();
|
|
2142
2171
|
const response = await transport.download(blobId);
|
|
2143
2172
|
let bytes: Uint8Array;
|
|
2144
2173
|
if (response.kind === 'url') {
|
|
@@ -2190,6 +2219,7 @@ export class SyncClient {
|
|
|
2190
2219
|
|
|
2191
2220
|
/** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
|
|
2192
2221
|
flushBlobUploads(): Promise<void> {
|
|
2222
|
+
this.#requireTransport();
|
|
2193
2223
|
return this.#runProtectedAsync(() => this.#flushBlobUploads());
|
|
2194
2224
|
}
|
|
2195
2225
|
|
|
@@ -2318,12 +2348,9 @@ export class SyncClient {
|
|
|
2318
2348
|
);
|
|
2319
2349
|
}
|
|
2320
2350
|
const allowed =
|
|
2321
|
-
(current.status === 'conflict' &&
|
|
2351
|
+
((current.status === 'conflict' || current.status === 'rejected') &&
|
|
2322
2352
|
(input.resolution === 'resolved_keep_server' ||
|
|
2323
2353
|
input.resolution === 'superseded')) ||
|
|
2324
|
-
(current.status === 'rejected' &&
|
|
2325
|
-
(input.resolution === 'superseded' ||
|
|
2326
|
-
input.resolution === 'resolved_keep_server')) ||
|
|
2327
2354
|
((current.status === 'applied' || current.status === 'cached') &&
|
|
2328
2355
|
input.resolution === 'dismissed');
|
|
2329
2356
|
if (!allowed) {
|
|
@@ -2426,6 +2453,7 @@ export class SyncClient {
|
|
|
2426
2453
|
*/
|
|
2427
2454
|
setPresence(scopeKey: string, doc: Record<string, unknown> | null): void {
|
|
2428
2455
|
this.#requireActive();
|
|
2456
|
+
this.#requireTransport();
|
|
2429
2457
|
const socket = this.#socket;
|
|
2430
2458
|
if (socket === undefined) {
|
|
2431
2459
|
throw invalidRequest(
|
|
@@ -2594,7 +2622,10 @@ export class SyncClient {
|
|
|
2594
2622
|
}
|
|
2595
2623
|
});
|
|
2596
2624
|
const effects: CommandEffects = {
|
|
2597
|
-
sync:
|
|
2625
|
+
sync:
|
|
2626
|
+
this.#transportEnabled && (changed || widened)
|
|
2627
|
+
? { kind: 'interactive' }
|
|
2628
|
+
: { kind: 'none' },
|
|
2598
2629
|
};
|
|
2599
2630
|
if (effects.sync.kind === 'interactive') {
|
|
2600
2631
|
this.#emitSyncIntent(effects.sync);
|
|
@@ -2898,7 +2929,9 @@ export class SyncClient {
|
|
|
2898
2929
|
mutateCommand(mutations: readonly MutationInput[]): CommandResult<string> {
|
|
2899
2930
|
return {
|
|
2900
2931
|
value: this.mutate(mutations),
|
|
2901
|
-
effects: {
|
|
2932
|
+
effects: {
|
|
2933
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
2934
|
+
},
|
|
2902
2935
|
};
|
|
2903
2936
|
}
|
|
2904
2937
|
|
|
@@ -2927,17 +2960,15 @@ export class SyncClient {
|
|
|
2927
2960
|
);
|
|
2928
2961
|
}
|
|
2929
2962
|
return this.#recordMutations([
|
|
2930
|
-
{
|
|
2963
|
+
definedObject({
|
|
2931
2964
|
table,
|
|
2932
2965
|
op: 'patch',
|
|
2933
2966
|
values: {
|
|
2934
2967
|
...Object.fromEntries(normalized),
|
|
2935
2968
|
[pkColumn.name]: rowId,
|
|
2936
2969
|
},
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
: {}),
|
|
2940
|
-
},
|
|
2970
|
+
baseVersion: options?.baseVersion,
|
|
2971
|
+
}),
|
|
2941
2972
|
]);
|
|
2942
2973
|
}
|
|
2943
2974
|
|
|
@@ -3222,7 +3253,9 @@ export class SyncClient {
|
|
|
3222
3253
|
): CommandResult<string> {
|
|
3223
3254
|
return {
|
|
3224
3255
|
value: this.patch(table, rowId, partial, options),
|
|
3225
|
-
effects: {
|
|
3256
|
+
effects: {
|
|
3257
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
3258
|
+
},
|
|
3226
3259
|
};
|
|
3227
3260
|
}
|
|
3228
3261
|
|
|
@@ -3346,16 +3379,14 @@ export class SyncClient {
|
|
|
3346
3379
|
);
|
|
3347
3380
|
this.#rollbackFailedCommit(commit, batch);
|
|
3348
3381
|
this.#replayOutbox();
|
|
3349
|
-
const rejection: RejectionRecord = {
|
|
3382
|
+
const rejection: RejectionRecord = definedObject({
|
|
3350
3383
|
clientCommitId: commit.clientCommitId,
|
|
3351
3384
|
opIndex: 0,
|
|
3352
3385
|
code,
|
|
3353
3386
|
message,
|
|
3354
3387
|
retryable: false,
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
: {}),
|
|
3358
|
-
};
|
|
3388
|
+
operation: commit.operations[0],
|
|
3389
|
+
});
|
|
3359
3390
|
this.#rejections.push(rejection);
|
|
3360
3391
|
recordCommitOutcome(this.#db, {
|
|
3361
3392
|
clientCommitId: commit.clientCommitId,
|
|
@@ -3386,9 +3417,7 @@ export class SyncClient {
|
|
|
3386
3417
|
this.#requireActive();
|
|
3387
3418
|
if (this.#syncOutstanding) {
|
|
3388
3419
|
return Promise.reject(
|
|
3389
|
-
invalidRequest(
|
|
3390
|
-
'sync() is already running — the core owns one loop (coalesce wake-ups)',
|
|
3391
|
-
),
|
|
3420
|
+
invalidRequest('sync() is already running; coalesce wake-ups'),
|
|
3392
3421
|
);
|
|
3393
3422
|
}
|
|
3394
3423
|
this.#syncOutstanding = true;
|
|
@@ -3438,6 +3467,7 @@ export class SyncClient {
|
|
|
3438
3467
|
)
|
|
3439
3468
|
.finally(() => {
|
|
3440
3469
|
this.#syncOutstanding = false;
|
|
3470
|
+
if (!this.#transportEnabled) this.disconnectRealtime();
|
|
3441
3471
|
this.#endDiagnosticsDeferral();
|
|
3442
3472
|
});
|
|
3443
3473
|
}
|
|
@@ -3476,6 +3506,7 @@ export class SyncClient {
|
|
|
3476
3506
|
}
|
|
3477
3507
|
|
|
3478
3508
|
async #runSync(): Promise<SyncSummary> {
|
|
3509
|
+
this.#requireTransport();
|
|
3479
3510
|
this.#roundRetryDelayMs = undefined;
|
|
3480
3511
|
this.#progress.emit({
|
|
3481
3512
|
attempt: ++this.#progressAttempt,
|
|
@@ -3507,7 +3538,7 @@ export class SyncClient {
|
|
|
3507
3538
|
this.#hasBlobs &&
|
|
3508
3539
|
this.#config.blobs !== undefined
|
|
3509
3540
|
) {
|
|
3510
|
-
await this
|
|
3541
|
+
await this.#flushBlobUploads();
|
|
3511
3542
|
}
|
|
3512
3543
|
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
3513
3544
|
// cannot express itself under the new schema (a dropped column/table)
|
|
@@ -3641,6 +3672,8 @@ export class SyncClient {
|
|
|
3641
3672
|
loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
|
|
3642
3673
|
);
|
|
3643
3674
|
last = await this.sync();
|
|
3675
|
+
if (!this.#transportEnabled || last.schemaFloor !== undefined)
|
|
3676
|
+
return last;
|
|
3644
3677
|
if (
|
|
3645
3678
|
maxRounds === undefined &&
|
|
3646
3679
|
last.segmentRowsApplied > 0 &&
|
|
@@ -3654,7 +3687,6 @@ export class SyncClient {
|
|
|
3654
3687
|
)
|
|
3655
3688
|
)
|
|
3656
3689
|
round = -1;
|
|
3657
|
-
if (last.schemaFloor !== undefined) return last;
|
|
3658
3690
|
if (
|
|
3659
3691
|
last.commitsApplied === 0 &&
|
|
3660
3692
|
last.segmentRowsApplied === 0 &&
|
|
@@ -3770,6 +3802,7 @@ export class SyncClient {
|
|
|
3770
3802
|
|
|
3771
3803
|
connectRealtime(): Promise<void> {
|
|
3772
3804
|
this.#requireActive();
|
|
3805
|
+
this.#requireTransport();
|
|
3773
3806
|
if (this.#realtimePolicy === 'off') {
|
|
3774
3807
|
return Promise.reject(
|
|
3775
3808
|
invalidRequest("realtimePolicy is 'off'; realtime connect is refused"),
|
|
@@ -3830,12 +3863,13 @@ export class SyncClient {
|
|
|
3830
3863
|
},
|
|
3831
3864
|
});
|
|
3832
3865
|
openedSocket = socket;
|
|
3866
|
+
if (!this.#transportEnabled) {
|
|
3867
|
+
socket.close();
|
|
3868
|
+
this.#requireTransport();
|
|
3869
|
+
}
|
|
3833
3870
|
if (this.#securityLifecycle === 'preflight') {
|
|
3834
3871
|
socket.close();
|
|
3835
|
-
|
|
3836
|
-
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
3837
|
-
'realtime connected after the client entered security preflight',
|
|
3838
|
-
);
|
|
3872
|
+
this.#requireActive();
|
|
3839
3873
|
}
|
|
3840
3874
|
if (generation !== this.#realtimeGeneration || !this.#started) {
|
|
3841
3875
|
socket.close();
|
|
@@ -3983,6 +4017,7 @@ export class SyncClient {
|
|
|
3983
4017
|
}
|
|
3984
4018
|
|
|
3985
4019
|
async #handleRealtimeBinary(bytes: Uint8Array): Promise<void> {
|
|
4020
|
+
if (!this.#transportEnabled) return;
|
|
3986
4021
|
if (this.#syncing) {
|
|
3987
4022
|
// Fast path: a pull is mid-flight; let it win and recover the gap
|
|
3988
4023
|
// itself — re-pulling is idempotent, interleaved application is not
|
|
@@ -4010,7 +4045,8 @@ export class SyncClient {
|
|
|
4010
4045
|
}
|
|
4011
4046
|
|
|
4012
4047
|
#sendAck(cursor: number): void {
|
|
4013
|
-
this.#
|
|
4048
|
+
if (this.#transportEnabled)
|
|
4049
|
+
this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
|
|
4014
4050
|
}
|
|
4015
4051
|
|
|
4016
4052
|
/** Ack the highest cursor that is contiguously applied for every sub. */
|
|
@@ -4220,74 +4256,16 @@ export class SyncClient {
|
|
|
4220
4256
|
await this.#applyCommit(frame, summary);
|
|
4221
4257
|
}
|
|
4222
4258
|
break;
|
|
4223
|
-
case 'SEGMENT_INLINE':
|
|
4224
|
-
if (
|
|
4225
|
-
section === undefined ||
|
|
4226
|
-
section.skip ||
|
|
4227
|
-
section.sub === undefined
|
|
4228
|
-
) {
|
|
4229
|
-
break;
|
|
4230
|
-
}
|
|
4231
|
-
const segment = decodeRowsSegment(frame.payload);
|
|
4232
|
-
this.#progress.update({
|
|
4233
|
-
phase: 'import',
|
|
4234
|
-
subscriptionId: section.sub.id,
|
|
4235
|
-
table: section.sub.table,
|
|
4236
|
-
segmentId: undefined,
|
|
4237
|
-
bytesReceived: frame.payload.byteLength,
|
|
4238
|
-
bytesTotal: frame.payload.byteLength,
|
|
4239
|
-
rowsProcessed: 0,
|
|
4240
|
-
rowsTotal: segment.blocks.reduce(
|
|
4241
|
-
(n, block) => n + block.length,
|
|
4242
|
-
0,
|
|
4243
|
-
),
|
|
4244
|
-
});
|
|
4245
|
-
await this.#applySegmentOrFail(
|
|
4246
|
-
section,
|
|
4247
|
-
summary,
|
|
4248
|
-
(table, clearFirst, effective) =>
|
|
4249
|
-
applyRowsSegment(
|
|
4250
|
-
this.#db,
|
|
4251
|
-
this.#schema,
|
|
4252
|
-
table,
|
|
4253
|
-
segment,
|
|
4254
|
-
{
|
|
4255
|
-
clearFirst,
|
|
4256
|
-
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
4257
|
-
onProgress: (rowsProcessed) =>
|
|
4258
|
-
this.#progress.update({ rowsProcessed }),
|
|
4259
|
-
effective,
|
|
4260
|
-
transaction: (fn) =>
|
|
4261
|
-
this.#applyBatch((batch) => {
|
|
4262
|
-
if (
|
|
4263
|
-
segment.blocks.some((block) => block.length > 0) ||
|
|
4264
|
-
(clearFirst &&
|
|
4265
|
-
this.#scopedRowsExist(table, effective))
|
|
4266
|
-
) {
|
|
4267
|
-
batch.table(table.name);
|
|
4268
|
-
}
|
|
4269
|
-
const result = fn();
|
|
4270
|
-
this.#replayOutbox();
|
|
4271
|
-
return result;
|
|
4272
|
-
}),
|
|
4273
|
-
},
|
|
4274
|
-
this.#encryption,
|
|
4275
|
-
),
|
|
4276
|
-
section.fresh && !section.cleared,
|
|
4277
|
-
);
|
|
4278
|
-
break;
|
|
4279
|
-
}
|
|
4259
|
+
case 'SEGMENT_INLINE':
|
|
4280
4260
|
case 'SEGMENT_REF': {
|
|
4281
4261
|
if (
|
|
4282
4262
|
section === undefined ||
|
|
4283
4263
|
section.skip ||
|
|
4284
4264
|
section.sub === undefined
|
|
4285
|
-
)
|
|
4265
|
+
)
|
|
4286
4266
|
break;
|
|
4287
|
-
}
|
|
4288
|
-
// §4.2: a descriptor whose mediaType was not advertised is a
|
|
4289
|
-
// broken server — fail loud, never skip or guess.
|
|
4290
4267
|
if (
|
|
4268
|
+
frame.type === 'SEGMENT_REF' &&
|
|
4291
4269
|
frame.mediaType === 'sqlite' &&
|
|
4292
4270
|
(this.#acceptMask() & ACCEPT_SQLITE) === 0
|
|
4293
4271
|
) {
|
|
@@ -4295,11 +4273,14 @@ export class SyncClient {
|
|
|
4295
4273
|
'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
|
|
4296
4274
|
);
|
|
4297
4275
|
}
|
|
4298
|
-
const bytes =
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4276
|
+
const bytes =
|
|
4277
|
+
frame.type === 'SEGMENT_REF'
|
|
4278
|
+
? await this.#downloadSegment(frame, section.sub)
|
|
4279
|
+
: frame.payload;
|
|
4280
|
+
if (frame.type === 'SEGMENT_REF')
|
|
4281
|
+
this.#progress.update({ phase: 'import' });
|
|
4282
|
+
if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
|
|
4283
|
+
// Images are whole-table; paged image descriptors are invalid.
|
|
4303
4284
|
if (
|
|
4304
4285
|
frame.rowCursor !== undefined ||
|
|
4305
4286
|
frame.nextRowCursor !== undefined
|
|
@@ -4323,31 +4304,33 @@ export class SyncClient {
|
|
|
4323
4304
|
asOfCommitSeq: frame.asOfCommitSeq,
|
|
4324
4305
|
scopeDigest: frame.scopeDigest,
|
|
4325
4306
|
},
|
|
4326
|
-
|
|
4307
|
+
this.#segmentApplyOptions(
|
|
4308
|
+
table,
|
|
4327
4309
|
clearFirst,
|
|
4328
|
-
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
4329
|
-
onProgress: (rowsProcessed) =>
|
|
4330
|
-
this.#progress.update({ rowsProcessed }),
|
|
4331
4310
|
effective,
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
frame.rowCount > 0 ||
|
|
4336
|
-
(clearFirst &&
|
|
4337
|
-
this.#scopedRowsExist(table, effective))
|
|
4338
|
-
) {
|
|
4339
|
-
batch.table(table.name);
|
|
4340
|
-
}
|
|
4341
|
-
const result = fn();
|
|
4342
|
-
this.#replayOutbox();
|
|
4343
|
-
return result;
|
|
4344
|
-
}),
|
|
4345
|
-
},
|
|
4311
|
+
frame.rowCount > 0,
|
|
4312
|
+
resetEpoch,
|
|
4313
|
+
),
|
|
4346
4314
|
),
|
|
4347
4315
|
section.fresh && !section.cleared,
|
|
4348
4316
|
);
|
|
4349
4317
|
} else {
|
|
4350
4318
|
const segment = decodeRowsSegment(bytes);
|
|
4319
|
+
if (frame.type === 'SEGMENT_INLINE') {
|
|
4320
|
+
this.#progress.update({
|
|
4321
|
+
phase: 'import',
|
|
4322
|
+
subscriptionId: section.sub.id,
|
|
4323
|
+
table: section.sub.table,
|
|
4324
|
+
segmentId: undefined,
|
|
4325
|
+
bytesReceived: bytes.byteLength,
|
|
4326
|
+
bytesTotal: bytes.byteLength,
|
|
4327
|
+
rowsProcessed: 0,
|
|
4328
|
+
rowsTotal: segment.blocks.reduce(
|
|
4329
|
+
(n, block) => n + block.length,
|
|
4330
|
+
0,
|
|
4331
|
+
),
|
|
4332
|
+
});
|
|
4333
|
+
}
|
|
4351
4334
|
await this.#applySegmentOrFail(
|
|
4352
4335
|
section,
|
|
4353
4336
|
summary,
|
|
@@ -4357,31 +4340,19 @@ export class SyncClient {
|
|
|
4357
4340
|
this.#schema,
|
|
4358
4341
|
table,
|
|
4359
4342
|
segment,
|
|
4360
|
-
|
|
4343
|
+
this.#segmentApplyOptions(
|
|
4344
|
+
table,
|
|
4361
4345
|
clearFirst,
|
|
4362
|
-
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
4363
|
-
onProgress: (rowsProcessed) =>
|
|
4364
|
-
this.#progress.update({ rowsProcessed }),
|
|
4365
4346
|
effective,
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
segment.blocks.some((block) => block.length > 0) ||
|
|
4370
|
-
(clearFirst &&
|
|
4371
|
-
this.#scopedRowsExist(table, effective))
|
|
4372
|
-
) {
|
|
4373
|
-
batch.table(table.name);
|
|
4374
|
-
}
|
|
4375
|
-
const result = fn();
|
|
4376
|
-
this.#replayOutbox();
|
|
4377
|
-
return result;
|
|
4378
|
-
}),
|
|
4379
|
-
},
|
|
4347
|
+
segment.blocks.some((block) => block.length > 0),
|
|
4348
|
+
resetEpoch,
|
|
4349
|
+
),
|
|
4380
4350
|
this.#encryption,
|
|
4381
4351
|
),
|
|
4382
4352
|
section.fresh &&
|
|
4383
4353
|
!section.cleared &&
|
|
4384
|
-
frame.
|
|
4354
|
+
(frame.type === 'SEGMENT_INLINE' ||
|
|
4355
|
+
frame.rowCursor === undefined),
|
|
4385
4356
|
);
|
|
4386
4357
|
}
|
|
4387
4358
|
break;
|
|
@@ -4712,6 +4683,33 @@ export class SyncClient {
|
|
|
4712
4683
|
);
|
|
4713
4684
|
}
|
|
4714
4685
|
|
|
4686
|
+
/** Shared transaction, reset fence and progress for both segment formats. */
|
|
4687
|
+
#segmentApplyOptions(
|
|
4688
|
+
table: CompiledClientTable,
|
|
4689
|
+
clearFirst: boolean,
|
|
4690
|
+
effective: ScopeMap,
|
|
4691
|
+
hasRows: boolean,
|
|
4692
|
+
resetEpoch: number,
|
|
4693
|
+
): Parameters<typeof applyRowsSegment>[4] {
|
|
4694
|
+
return {
|
|
4695
|
+
clearFirst,
|
|
4696
|
+
effective,
|
|
4697
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
4698
|
+
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
4699
|
+
transaction: (fn) =>
|
|
4700
|
+
this.#applyBatch((batch) => {
|
|
4701
|
+
if (
|
|
4702
|
+
hasRows ||
|
|
4703
|
+
(clearFirst && this.#scopedRowsExist(table, effective))
|
|
4704
|
+
)
|
|
4705
|
+
batch.table(table.name);
|
|
4706
|
+
const result = fn();
|
|
4707
|
+
this.#replayOutbox();
|
|
4708
|
+
return result;
|
|
4709
|
+
}),
|
|
4710
|
+
};
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4715
4713
|
/**
|
|
4716
4714
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
4717
4715
|
* (no local scope-column mapping) marks the subscription `failed` and
|
package/src/worker-entry.ts
CHANGED
|
@@ -165,6 +165,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
165
165
|
if (
|
|
166
166
|
closed ||
|
|
167
167
|
client === undefined ||
|
|
168
|
+
offline ||
|
|
168
169
|
client.securityLifecycle() === 'preflight'
|
|
169
170
|
) {
|
|
170
171
|
return;
|
|
@@ -189,6 +190,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
189
190
|
!autoSync ||
|
|
190
191
|
closed ||
|
|
191
192
|
client === undefined ||
|
|
193
|
+
offline ||
|
|
192
194
|
client.securityLifecycle() === 'preflight' ||
|
|
193
195
|
intent.kind === 'none'
|
|
194
196
|
) {
|
|
@@ -235,41 +237,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
235
237
|
return client;
|
|
236
238
|
}
|
|
237
239
|
|
|
238
|
-
function assertOnline(): void {
|
|
239
|
-
if (offline) {
|
|
240
|
-
throw new ClientSyncError(
|
|
241
|
-
'sync.transport_failed',
|
|
242
|
-
'the worker transport is offline (setOffline(true))',
|
|
243
|
-
true,
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function gateOffline<T>(inner: (request: T) => Promise<Uint8Array>) {
|
|
249
|
-
return (request: T): Promise<Uint8Array> => {
|
|
250
|
-
assertOnline();
|
|
251
|
-
return inner(request);
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** Offline gate that preserves the §5.4 `fetchUrl` capability marker. */
|
|
256
|
-
function gateSegmentsOffline(inner: SegmentDownloader): SegmentDownloader {
|
|
257
|
-
const fetchUrl = inner.fetchUrl;
|
|
258
|
-
return Object.assign(gateOffline(inner), {
|
|
259
|
-
...(fetchUrl !== undefined
|
|
260
|
-
? {
|
|
261
|
-
fetchUrl: (
|
|
262
|
-
url: string,
|
|
263
|
-
onProgress?: (bytesReceived: number) => void,
|
|
264
|
-
) => {
|
|
265
|
-
assertOnline();
|
|
266
|
-
return fetchUrl(url, onProgress);
|
|
267
|
-
},
|
|
268
|
-
}
|
|
269
|
-
: {}),
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
240
|
async function init(config: WorkerInitConfig): Promise<WorkerInitResult> {
|
|
274
241
|
if (client !== undefined) {
|
|
275
242
|
throw new ClientSyncError(
|
|
@@ -308,11 +275,10 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
308
275
|
}
|
|
309
276
|
}
|
|
310
277
|
}
|
|
311
|
-
const transport =
|
|
278
|
+
const transport =
|
|
312
279
|
overrides.createTransport !== undefined
|
|
313
280
|
? overrides.createTransport(config)
|
|
314
|
-
: httpSyncTransport(config.endpoints.syncUrl, http)
|
|
315
|
-
);
|
|
281
|
+
: httpSyncTransport(config.endpoints.syncUrl, http);
|
|
316
282
|
const segments =
|
|
317
283
|
overrides.createSegments !== undefined
|
|
318
284
|
? overrides.createSegments(config)
|
|
@@ -329,22 +295,15 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
329
295
|
// `{clientId}` URL placeholder — start the client first, then attach
|
|
330
296
|
// the connector through a mutable slot read at connect time.
|
|
331
297
|
let realtimeConnector: RealtimeConnector | undefined;
|
|
298
|
+
offline = config.transportEnabled === false;
|
|
332
299
|
const started = new SyncClient({
|
|
300
|
+
transportEnabled: !offline,
|
|
333
301
|
database,
|
|
334
302
|
schema: config.schema,
|
|
335
303
|
transport,
|
|
336
|
-
...(segments !== undefined
|
|
337
|
-
? { segments: gateSegmentsOffline(segments) }
|
|
338
|
-
: {}),
|
|
304
|
+
...(segments !== undefined ? { segments } : {}),
|
|
339
305
|
...(blobs !== undefined ? { blobs } : {}),
|
|
340
306
|
realtime: (handlers) => {
|
|
341
|
-
if (offline) {
|
|
342
|
-
throw new ClientSyncError(
|
|
343
|
-
'sync.transport_failed',
|
|
344
|
-
'the worker realtime channel is offline (setOffline(true))',
|
|
345
|
-
true,
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
307
|
if (realtimeConnector === undefined) {
|
|
349
308
|
throw new ClientSyncError(
|
|
350
309
|
'sync.invalid_request',
|
|
@@ -515,8 +474,12 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
515
474
|
},
|
|
516
475
|
setOffline: (value) => {
|
|
517
476
|
offline = value;
|
|
518
|
-
|
|
519
|
-
|
|
477
|
+
requireClient().setTransportEnabled(!value);
|
|
478
|
+
if (offline) {
|
|
479
|
+
if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
|
|
480
|
+
backgroundTimer = undefined;
|
|
481
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
482
|
+
} else if (client !== undefined) {
|
|
520
483
|
const snapshot = client.diagnosticsSnapshot();
|
|
521
484
|
post({
|
|
522
485
|
t: 'event',
|
package/src/worker-host.ts
CHANGED
|
@@ -194,6 +194,7 @@ export interface SyncClientHandleConfig {
|
|
|
194
194
|
readonly encryption?: EncryptionKeyringConfig;
|
|
195
195
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
196
196
|
readonly securityPreflight?: boolean;
|
|
197
|
+
readonly transportEnabled?: boolean;
|
|
197
198
|
readonly clientId?: string;
|
|
198
199
|
readonly limits?: SyncClientLimits;
|
|
199
200
|
readonly retainFailedCommits?: boolean;
|
|
@@ -947,6 +948,9 @@ function buildInitConfig(
|
|
|
947
948
|
...(config.encryption !== undefined
|
|
948
949
|
? { encryption: config.encryption }
|
|
949
950
|
: {}),
|
|
951
|
+
...(config.transportEnabled !== undefined
|
|
952
|
+
? { transportEnabled: config.transportEnabled }
|
|
953
|
+
: {}),
|
|
950
954
|
...(config.securityPreflight !== undefined
|
|
951
955
|
? { securityPreflight: config.securityPreflight }
|
|
952
956
|
: {}),
|