@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/src/client.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { BLOB_COMMIT_REFS } from './blob';
|
|
2
|
+
import { hasUniqueIndex } from './schema';
|
|
3
|
+
import { OUTCOMES_TABLE } from './outcomes';
|
|
4
|
+
import { OUTBOX_TABLE } from './outbox';
|
|
1
5
|
import { RETAINED_ROWS } from './failed-overlay';
|
|
2
6
|
import {
|
|
3
7
|
dropFailedRows,
|
|
4
8
|
failedOverlayCommits,
|
|
5
9
|
retainFailedRows,
|
|
6
10
|
restoreFailedBases,
|
|
11
|
+
type OverlayScope,
|
|
7
12
|
uniqueConflicts,
|
|
8
13
|
} from './failed-overlay';
|
|
9
14
|
import {
|
|
@@ -349,6 +354,7 @@ export interface SyncClientLimits {
|
|
|
349
354
|
}
|
|
350
355
|
|
|
351
356
|
export interface SyncClientConfig {
|
|
357
|
+
readonly transportEnabled?: boolean;
|
|
352
358
|
/** Keep rejected/conflicting local intent visible until explicit resolution. */
|
|
353
359
|
readonly retainFailedCommits?: boolean;
|
|
354
360
|
readonly database: ClientDatabase;
|
|
@@ -747,6 +753,7 @@ export class SyncClient {
|
|
|
747
753
|
/** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
|
|
748
754
|
readonly #previousVersion: PreviousVersionCaptureConfig | undefined;
|
|
749
755
|
#securityLifecycle: SecurityLifecycle;
|
|
756
|
+
#transportEnabled: boolean;
|
|
750
757
|
readonly #now: () => number;
|
|
751
758
|
readonly #outcomeRetentionMaxEntries: number;
|
|
752
759
|
readonly #retainFailedCommits: boolean;
|
|
@@ -838,6 +845,13 @@ export class SyncClient {
|
|
|
838
845
|
#preflightBarrier: Promise<void> | undefined;
|
|
839
846
|
|
|
840
847
|
constructor(config: SyncClientConfig) {
|
|
848
|
+
if (
|
|
849
|
+
config.transportEnabled !== undefined &&
|
|
850
|
+
typeof config.transportEnabled !== 'boolean'
|
|
851
|
+
) {
|
|
852
|
+
throw invalidRequest('transportEnabled must be a boolean');
|
|
853
|
+
}
|
|
854
|
+
this.#transportEnabled = config.transportEnabled !== false;
|
|
841
855
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
842
856
|
throw invalidRequest(
|
|
843
857
|
'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
|
|
@@ -1159,6 +1173,13 @@ export class SyncClient {
|
|
|
1159
1173
|
|
|
1160
1174
|
/** §2.1 reset after the server reports a different log continuity. */
|
|
1161
1175
|
#runLogEpochReset(logEpoch: string): string[] {
|
|
1176
|
+
if (getMeta(this.#db, LOG_EPOCH_META_KEY) === undefined) {
|
|
1177
|
+
this.#applyBatch(() => setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch));
|
|
1178
|
+
this.#setSyncNeeded(true);
|
|
1179
|
+
this.#emitSyncNeeded('startup');
|
|
1180
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1181
|
+
return [];
|
|
1182
|
+
}
|
|
1162
1183
|
const subscriptions = loadSubscriptions(this.#db);
|
|
1163
1184
|
const pending = listOutbox(this.#db);
|
|
1164
1185
|
this.#setUpgrading(true);
|
|
@@ -1225,6 +1246,7 @@ export class SyncClient {
|
|
|
1225
1246
|
}
|
|
1226
1247
|
|
|
1227
1248
|
#emitSyncNeeded(reason: 'startup' | 'hello' | WakeReason): void {
|
|
1249
|
+
if (!this.#transportEnabled) return;
|
|
1228
1250
|
try {
|
|
1229
1251
|
this.#config.onSyncNeeded?.(reason);
|
|
1230
1252
|
} catch {
|
|
@@ -1240,6 +1262,7 @@ export class SyncClient {
|
|
|
1240
1262
|
}
|
|
1241
1263
|
|
|
1242
1264
|
#emitSyncIntent(intent: SyncIntent): void {
|
|
1265
|
+
if (!this.#transportEnabled && intent.kind !== 'none') return;
|
|
1243
1266
|
try {
|
|
1244
1267
|
this.#config.onSyncIntent?.(intent);
|
|
1245
1268
|
} catch {
|
|
@@ -1254,6 +1277,27 @@ export class SyncClient {
|
|
|
1254
1277
|
}
|
|
1255
1278
|
}
|
|
1256
1279
|
|
|
1280
|
+
/** Host-owned transport policy; the local replica remains available. */
|
|
1281
|
+
setTransportEnabled(enabled: boolean): void {
|
|
1282
|
+
this.#requireStarted();
|
|
1283
|
+
if (typeof enabled !== 'boolean')
|
|
1284
|
+
throw invalidRequest('transportEnabled must be a boolean');
|
|
1285
|
+
if (this.#transportEnabled === enabled) return;
|
|
1286
|
+
this.#transportEnabled = enabled;
|
|
1287
|
+
if (!enabled && !this.#syncOutstanding) this.disconnectRealtime();
|
|
1288
|
+
this.#emitSyncIntent(
|
|
1289
|
+
enabled && this.#securityLifecycle === 'active'
|
|
1290
|
+
? { kind: 'interactive' }
|
|
1291
|
+
: { kind: 'none' },
|
|
1292
|
+
);
|
|
1293
|
+
this.#emitDiagnostics();
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
#requireTransport(): void {
|
|
1297
|
+
if (!this.#transportEnabled)
|
|
1298
|
+
throw new ClientSyncError('sync.offline', 'transport gate is closed');
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1257
1301
|
/** Current fail-closed local-replica security state. */
|
|
1258
1302
|
securityLifecycle(): SecurityLifecycle {
|
|
1259
1303
|
return this.#securityLifecycle;
|
|
@@ -1799,8 +1843,9 @@ export class SyncClient {
|
|
|
1799
1843
|
}
|
|
1800
1844
|
const capturedAtMs = this.#now();
|
|
1801
1845
|
const leaseState = this.#diagnosticLease(capturedAtMs);
|
|
1802
|
-
const connectivity =
|
|
1803
|
-
|
|
1846
|
+
const connectivity = !this.#transportEnabled
|
|
1847
|
+
? 'offline'
|
|
1848
|
+
: this.#lastRound?.status === 'succeeded'
|
|
1804
1849
|
? 'online'
|
|
1805
1850
|
: this.#lastRound?.status === 'failed' &&
|
|
1806
1851
|
this.#transportFailureCode(this.#lastRound.errorCode)
|
|
@@ -1833,16 +1878,12 @@ export class SyncClient {
|
|
|
1833
1878
|
realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
|
|
1834
1879
|
}),
|
|
1835
1880
|
securityLifecycle: this.#securityLifecycle,
|
|
1836
|
-
schema: {
|
|
1881
|
+
schema: definedObject({
|
|
1837
1882
|
currentVersion: this.#config.schema.version,
|
|
1838
1883
|
upgrading: this.#upgrading,
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
...(this.#schemaFloor?.latestSchemaVersion !== undefined
|
|
1843
|
-
? { latestVersion: this.#schemaFloor.latestSchemaVersion }
|
|
1844
|
-
: {}),
|
|
1845
|
-
},
|
|
1884
|
+
requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
|
|
1885
|
+
latestVersion: this.#schemaFloor?.latestSchemaVersion,
|
|
1886
|
+
}),
|
|
1846
1887
|
replica: {
|
|
1847
1888
|
localRevision: getLocalRevision(this.#db).toString(),
|
|
1848
1889
|
syncNeeded: this.#needsPull,
|
|
@@ -1888,11 +1929,11 @@ export class SyncClient {
|
|
|
1888
1929
|
);
|
|
1889
1930
|
const outboxBytes = Number(
|
|
1890
1931
|
this.#db.query(
|
|
1891
|
-
|
|
1932
|
+
`SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM ${OUTBOX_TABLE}`,
|
|
1892
1933
|
)[0]?.bytes ?? 0,
|
|
1893
1934
|
);
|
|
1894
1935
|
const outcome = this.#db.query(
|
|
1895
|
-
|
|
1936
|
+
`SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM ${OUTCOMES_TABLE}`,
|
|
1896
1937
|
)[0];
|
|
1897
1938
|
const blobBytes = this.#hasBlobs
|
|
1898
1939
|
? Number(
|
|
@@ -2058,10 +2099,11 @@ export class SyncClient {
|
|
|
2058
2099
|
this.#requireActive();
|
|
2059
2100
|
const task = Promise.resolve().then(fn);
|
|
2060
2101
|
this.#protectedAsync.add(task);
|
|
2061
|
-
|
|
2062
|
-
(
|
|
2063
|
-
|
|
2064
|
-
|
|
2102
|
+
const settled = () => {
|
|
2103
|
+
if (this.#realtimeConnectPromise === task)
|
|
2104
|
+
this.#realtimeConnectPromise = undefined;
|
|
2105
|
+
};
|
|
2106
|
+
void task.then(settled, settled);
|
|
2065
2107
|
return task;
|
|
2066
2108
|
}
|
|
2067
2109
|
|
|
@@ -2098,14 +2140,12 @@ export class SyncClient {
|
|
|
2098
2140
|
recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
|
|
2099
2141
|
});
|
|
2100
2142
|
this.#enforceBlobCacheCap();
|
|
2101
|
-
return {
|
|
2143
|
+
return definedObject({
|
|
2102
2144
|
blobId,
|
|
2103
2145
|
byteLength: bytes.length,
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
...(options?.name !== undefined ? { name: options.name } : {}),
|
|
2108
|
-
};
|
|
2146
|
+
mediaType: options?.mediaType,
|
|
2147
|
+
name: options?.name,
|
|
2148
|
+
});
|
|
2109
2149
|
}
|
|
2110
2150
|
|
|
2111
2151
|
/** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
|
|
@@ -2139,6 +2179,7 @@ export class SyncClient {
|
|
|
2139
2179
|
// presign configured) a signed url the client fetches directly. On a url
|
|
2140
2180
|
// arm the client MUST NOT attach host auth and MUST NOT fall through:
|
|
2141
2181
|
// failure => re-request, the caller's next fetchBlob mints a fresh url.
|
|
2182
|
+
this.#requireTransport();
|
|
2142
2183
|
const response = await transport.download(blobId);
|
|
2143
2184
|
let bytes: Uint8Array;
|
|
2144
2185
|
if (response.kind === 'url') {
|
|
@@ -2190,6 +2231,7 @@ export class SyncClient {
|
|
|
2190
2231
|
|
|
2191
2232
|
/** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
|
|
2192
2233
|
flushBlobUploads(): Promise<void> {
|
|
2234
|
+
this.#requireTransport();
|
|
2193
2235
|
return this.#runProtectedAsync(() => this.#flushBlobUploads());
|
|
2194
2236
|
}
|
|
2195
2237
|
|
|
@@ -2318,12 +2360,9 @@ export class SyncClient {
|
|
|
2318
2360
|
);
|
|
2319
2361
|
}
|
|
2320
2362
|
const allowed =
|
|
2321
|
-
(current.status === 'conflict' &&
|
|
2363
|
+
((current.status === 'conflict' || current.status === 'rejected') &&
|
|
2322
2364
|
(input.resolution === 'resolved_keep_server' ||
|
|
2323
2365
|
input.resolution === 'superseded')) ||
|
|
2324
|
-
(current.status === 'rejected' &&
|
|
2325
|
-
(input.resolution === 'superseded' ||
|
|
2326
|
-
input.resolution === 'resolved_keep_server')) ||
|
|
2327
2366
|
((current.status === 'applied' || current.status === 'cached') &&
|
|
2328
2367
|
input.resolution === 'dismissed');
|
|
2329
2368
|
if (!allowed) {
|
|
@@ -2426,6 +2465,7 @@ export class SyncClient {
|
|
|
2426
2465
|
*/
|
|
2427
2466
|
setPresence(scopeKey: string, doc: Record<string, unknown> | null): void {
|
|
2428
2467
|
this.#requireActive();
|
|
2468
|
+
this.#requireTransport();
|
|
2429
2469
|
const socket = this.#socket;
|
|
2430
2470
|
if (socket === undefined) {
|
|
2431
2471
|
throw invalidRequest(
|
|
@@ -2594,7 +2634,10 @@ export class SyncClient {
|
|
|
2594
2634
|
}
|
|
2595
2635
|
});
|
|
2596
2636
|
const effects: CommandEffects = {
|
|
2597
|
-
sync:
|
|
2637
|
+
sync:
|
|
2638
|
+
this.#transportEnabled && (changed || widened)
|
|
2639
|
+
? { kind: 'interactive' }
|
|
2640
|
+
: { kind: 'none' },
|
|
2598
2641
|
};
|
|
2599
2642
|
if (effects.sync.kind === 'interactive') {
|
|
2600
2643
|
this.#emitSyncIntent(effects.sync);
|
|
@@ -2863,7 +2906,7 @@ export class SyncClient {
|
|
|
2863
2906
|
continue;
|
|
2864
2907
|
}
|
|
2865
2908
|
this.#db.exec(
|
|
2866
|
-
|
|
2909
|
+
`INSERT OR IGNORE INTO ${BLOB_COMMIT_REFS}(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)`,
|
|
2867
2910
|
[clientCommitId, blobId, blobId],
|
|
2868
2911
|
);
|
|
2869
2912
|
}
|
|
@@ -2898,7 +2941,9 @@ export class SyncClient {
|
|
|
2898
2941
|
mutateCommand(mutations: readonly MutationInput[]): CommandResult<string> {
|
|
2899
2942
|
return {
|
|
2900
2943
|
value: this.mutate(mutations),
|
|
2901
|
-
effects: {
|
|
2944
|
+
effects: {
|
|
2945
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
2946
|
+
},
|
|
2902
2947
|
};
|
|
2903
2948
|
}
|
|
2904
2949
|
|
|
@@ -2927,17 +2972,15 @@ export class SyncClient {
|
|
|
2927
2972
|
);
|
|
2928
2973
|
}
|
|
2929
2974
|
return this.#recordMutations([
|
|
2930
|
-
{
|
|
2975
|
+
definedObject({
|
|
2931
2976
|
table,
|
|
2932
2977
|
op: 'patch',
|
|
2933
2978
|
values: {
|
|
2934
2979
|
...Object.fromEntries(normalized),
|
|
2935
2980
|
[pkColumn.name]: rowId,
|
|
2936
2981
|
},
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
: {}),
|
|
2940
|
-
},
|
|
2982
|
+
baseVersion: options?.baseVersion,
|
|
2983
|
+
}),
|
|
2941
2984
|
]);
|
|
2942
2985
|
}
|
|
2943
2986
|
|
|
@@ -3222,7 +3265,9 @@ export class SyncClient {
|
|
|
3222
3265
|
): CommandResult<string> {
|
|
3223
3266
|
return {
|
|
3224
3267
|
value: this.patch(table, rowId, partial, options),
|
|
3225
|
-
effects: {
|
|
3268
|
+
effects: {
|
|
3269
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
3270
|
+
},
|
|
3226
3271
|
};
|
|
3227
3272
|
}
|
|
3228
3273
|
|
|
@@ -3262,7 +3307,7 @@ export class SyncClient {
|
|
|
3262
3307
|
// Pin before the first encryption await: mutations can append while a
|
|
3263
3308
|
// round is encoding, and belong to the next request.
|
|
3264
3309
|
const bounds = this.#db.query(
|
|
3265
|
-
|
|
3310
|
+
`SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM ${OUTBOX_TABLE}`,
|
|
3266
3311
|
)[0]!;
|
|
3267
3312
|
const pendingCount = bounds.count as number;
|
|
3268
3313
|
const throughSeq = (bounds.last_seq as number | null) ?? 0;
|
|
@@ -3346,16 +3391,14 @@ export class SyncClient {
|
|
|
3346
3391
|
);
|
|
3347
3392
|
this.#rollbackFailedCommit(commit, batch);
|
|
3348
3393
|
this.#replayOutbox();
|
|
3349
|
-
const rejection: RejectionRecord = {
|
|
3394
|
+
const rejection: RejectionRecord = definedObject({
|
|
3350
3395
|
clientCommitId: commit.clientCommitId,
|
|
3351
3396
|
opIndex: 0,
|
|
3352
3397
|
code,
|
|
3353
3398
|
message,
|
|
3354
3399
|
retryable: false,
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
: {}),
|
|
3358
|
-
};
|
|
3400
|
+
operation: commit.operations[0],
|
|
3401
|
+
});
|
|
3359
3402
|
this.#rejections.push(rejection);
|
|
3360
3403
|
recordCommitOutcome(this.#db, {
|
|
3361
3404
|
clientCommitId: commit.clientCommitId,
|
|
@@ -3386,9 +3429,7 @@ export class SyncClient {
|
|
|
3386
3429
|
this.#requireActive();
|
|
3387
3430
|
if (this.#syncOutstanding) {
|
|
3388
3431
|
return Promise.reject(
|
|
3389
|
-
invalidRequest(
|
|
3390
|
-
'sync() is already running — the core owns one loop (coalesce wake-ups)',
|
|
3391
|
-
),
|
|
3432
|
+
invalidRequest('sync() is already running; coalesce wake-ups'),
|
|
3392
3433
|
);
|
|
3393
3434
|
}
|
|
3394
3435
|
this.#syncOutstanding = true;
|
|
@@ -3438,6 +3479,7 @@ export class SyncClient {
|
|
|
3438
3479
|
)
|
|
3439
3480
|
.finally(() => {
|
|
3440
3481
|
this.#syncOutstanding = false;
|
|
3482
|
+
if (!this.#transportEnabled) this.disconnectRealtime();
|
|
3441
3483
|
this.#endDiagnosticsDeferral();
|
|
3442
3484
|
});
|
|
3443
3485
|
}
|
|
@@ -3476,6 +3518,7 @@ export class SyncClient {
|
|
|
3476
3518
|
}
|
|
3477
3519
|
|
|
3478
3520
|
async #runSync(): Promise<SyncSummary> {
|
|
3521
|
+
this.#requireTransport();
|
|
3479
3522
|
this.#roundRetryDelayMs = undefined;
|
|
3480
3523
|
this.#progress.emit({
|
|
3481
3524
|
attempt: ++this.#progressAttempt,
|
|
@@ -3507,7 +3550,7 @@ export class SyncClient {
|
|
|
3507
3550
|
this.#hasBlobs &&
|
|
3508
3551
|
this.#config.blobs !== undefined
|
|
3509
3552
|
) {
|
|
3510
|
-
await this
|
|
3553
|
+
await this.#flushBlobUploads();
|
|
3511
3554
|
}
|
|
3512
3555
|
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
3513
3556
|
// cannot express itself under the new schema (a dropped column/table)
|
|
@@ -3641,6 +3684,8 @@ export class SyncClient {
|
|
|
3641
3684
|
loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
|
|
3642
3685
|
);
|
|
3643
3686
|
last = await this.sync();
|
|
3687
|
+
if (!this.#transportEnabled || last.schemaFloor !== undefined)
|
|
3688
|
+
return last;
|
|
3644
3689
|
if (
|
|
3645
3690
|
maxRounds === undefined &&
|
|
3646
3691
|
last.segmentRowsApplied > 0 &&
|
|
@@ -3654,7 +3699,6 @@ export class SyncClient {
|
|
|
3654
3699
|
)
|
|
3655
3700
|
)
|
|
3656
3701
|
round = -1;
|
|
3657
|
-
if (last.schemaFloor !== undefined) return last;
|
|
3658
3702
|
if (
|
|
3659
3703
|
last.commitsApplied === 0 &&
|
|
3660
3704
|
last.segmentRowsApplied === 0 &&
|
|
@@ -3770,6 +3814,7 @@ export class SyncClient {
|
|
|
3770
3814
|
|
|
3771
3815
|
connectRealtime(): Promise<void> {
|
|
3772
3816
|
this.#requireActive();
|
|
3817
|
+
this.#requireTransport();
|
|
3773
3818
|
if (this.#realtimePolicy === 'off') {
|
|
3774
3819
|
return Promise.reject(
|
|
3775
3820
|
invalidRequest("realtimePolicy is 'off'; realtime connect is refused"),
|
|
@@ -3830,12 +3875,13 @@ export class SyncClient {
|
|
|
3830
3875
|
},
|
|
3831
3876
|
});
|
|
3832
3877
|
openedSocket = socket;
|
|
3878
|
+
if (!this.#transportEnabled) {
|
|
3879
|
+
socket.close();
|
|
3880
|
+
this.#requireTransport();
|
|
3881
|
+
}
|
|
3833
3882
|
if (this.#securityLifecycle === 'preflight') {
|
|
3834
3883
|
socket.close();
|
|
3835
|
-
|
|
3836
|
-
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
3837
|
-
'realtime connected after the client entered security preflight',
|
|
3838
|
-
);
|
|
3884
|
+
this.#requireActive();
|
|
3839
3885
|
}
|
|
3840
3886
|
if (generation !== this.#realtimeGeneration || !this.#started) {
|
|
3841
3887
|
socket.close();
|
|
@@ -3983,6 +4029,7 @@ export class SyncClient {
|
|
|
3983
4029
|
}
|
|
3984
4030
|
|
|
3985
4031
|
async #handleRealtimeBinary(bytes: Uint8Array): Promise<void> {
|
|
4032
|
+
if (!this.#transportEnabled) return;
|
|
3986
4033
|
if (this.#syncing) {
|
|
3987
4034
|
// Fast path: a pull is mid-flight; let it win and recover the gap
|
|
3988
4035
|
// itself — re-pulling is idempotent, interleaved application is not
|
|
@@ -4010,7 +4057,8 @@ export class SyncClient {
|
|
|
4010
4057
|
}
|
|
4011
4058
|
|
|
4012
4059
|
#sendAck(cursor: number): void {
|
|
4013
|
-
this.#
|
|
4060
|
+
if (this.#transportEnabled)
|
|
4061
|
+
this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
|
|
4014
4062
|
}
|
|
4015
4063
|
|
|
4016
4064
|
/** Ack the highest cursor that is contiguously applied for every sub. */
|
|
@@ -4220,74 +4268,16 @@ export class SyncClient {
|
|
|
4220
4268
|
await this.#applyCommit(frame, summary);
|
|
4221
4269
|
}
|
|
4222
4270
|
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
|
-
}
|
|
4271
|
+
case 'SEGMENT_INLINE':
|
|
4280
4272
|
case 'SEGMENT_REF': {
|
|
4281
4273
|
if (
|
|
4282
4274
|
section === undefined ||
|
|
4283
4275
|
section.skip ||
|
|
4284
4276
|
section.sub === undefined
|
|
4285
|
-
)
|
|
4277
|
+
)
|
|
4286
4278
|
break;
|
|
4287
|
-
}
|
|
4288
|
-
// §4.2: a descriptor whose mediaType was not advertised is a
|
|
4289
|
-
// broken server — fail loud, never skip or guess.
|
|
4290
4279
|
if (
|
|
4280
|
+
frame.type === 'SEGMENT_REF' &&
|
|
4291
4281
|
frame.mediaType === 'sqlite' &&
|
|
4292
4282
|
(this.#acceptMask() & ACCEPT_SQLITE) === 0
|
|
4293
4283
|
) {
|
|
@@ -4295,11 +4285,14 @@ export class SyncClient {
|
|
|
4295
4285
|
'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
|
|
4296
4286
|
);
|
|
4297
4287
|
}
|
|
4298
|
-
const bytes =
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4288
|
+
const bytes =
|
|
4289
|
+
frame.type === 'SEGMENT_REF'
|
|
4290
|
+
? await this.#downloadSegment(frame, section.sub)
|
|
4291
|
+
: frame.payload;
|
|
4292
|
+
if (frame.type === 'SEGMENT_REF')
|
|
4293
|
+
this.#progress.update({ phase: 'import' });
|
|
4294
|
+
if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
|
|
4295
|
+
// Images are whole-table; paged image descriptors are invalid.
|
|
4303
4296
|
if (
|
|
4304
4297
|
frame.rowCursor !== undefined ||
|
|
4305
4298
|
frame.nextRowCursor !== undefined
|
|
@@ -4323,31 +4316,33 @@ export class SyncClient {
|
|
|
4323
4316
|
asOfCommitSeq: frame.asOfCommitSeq,
|
|
4324
4317
|
scopeDigest: frame.scopeDigest,
|
|
4325
4318
|
},
|
|
4326
|
-
|
|
4319
|
+
this.#segmentApplyOptions(
|
|
4320
|
+
table,
|
|
4327
4321
|
clearFirst,
|
|
4328
|
-
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
4329
|
-
onProgress: (rowsProcessed) =>
|
|
4330
|
-
this.#progress.update({ rowsProcessed }),
|
|
4331
4322
|
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
|
-
},
|
|
4323
|
+
frame.rowCount > 0,
|
|
4324
|
+
resetEpoch,
|
|
4325
|
+
),
|
|
4346
4326
|
),
|
|
4347
4327
|
section.fresh && !section.cleared,
|
|
4348
4328
|
);
|
|
4349
4329
|
} else {
|
|
4350
4330
|
const segment = decodeRowsSegment(bytes);
|
|
4331
|
+
if (frame.type === 'SEGMENT_INLINE') {
|
|
4332
|
+
this.#progress.update({
|
|
4333
|
+
phase: 'import',
|
|
4334
|
+
subscriptionId: section.sub.id,
|
|
4335
|
+
table: section.sub.table,
|
|
4336
|
+
segmentId: undefined,
|
|
4337
|
+
bytesReceived: bytes.byteLength,
|
|
4338
|
+
bytesTotal: bytes.byteLength,
|
|
4339
|
+
rowsProcessed: 0,
|
|
4340
|
+
rowsTotal: segment.blocks.reduce(
|
|
4341
|
+
(n, block) => n + block.length,
|
|
4342
|
+
0,
|
|
4343
|
+
),
|
|
4344
|
+
});
|
|
4345
|
+
}
|
|
4351
4346
|
await this.#applySegmentOrFail(
|
|
4352
4347
|
section,
|
|
4353
4348
|
summary,
|
|
@@ -4357,31 +4352,19 @@ export class SyncClient {
|
|
|
4357
4352
|
this.#schema,
|
|
4358
4353
|
table,
|
|
4359
4354
|
segment,
|
|
4360
|
-
|
|
4355
|
+
this.#segmentApplyOptions(
|
|
4356
|
+
table,
|
|
4361
4357
|
clearFirst,
|
|
4362
|
-
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
4363
|
-
onProgress: (rowsProcessed) =>
|
|
4364
|
-
this.#progress.update({ rowsProcessed }),
|
|
4365
4358
|
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
|
-
},
|
|
4359
|
+
segment.blocks.some((block) => block.length > 0),
|
|
4360
|
+
resetEpoch,
|
|
4361
|
+
),
|
|
4380
4362
|
this.#encryption,
|
|
4381
4363
|
),
|
|
4382
4364
|
section.fresh &&
|
|
4383
4365
|
!section.cleared &&
|
|
4384
|
-
frame.
|
|
4366
|
+
(frame.type === 'SEGMENT_INLINE' ||
|
|
4367
|
+
frame.rowCursor === undefined),
|
|
4385
4368
|
);
|
|
4386
4369
|
}
|
|
4387
4370
|
break;
|
|
@@ -4427,13 +4410,7 @@ export class SyncClient {
|
|
|
4427
4410
|
if (errorFrame !== undefined) break;
|
|
4428
4411
|
}
|
|
4429
4412
|
} finally {
|
|
4430
|
-
|
|
4431
|
-
// §7.1: local reads see outbox state applied optimistically — replay
|
|
4432
|
-
// the still-pending commits on top of the freshly applied server state.
|
|
4433
|
-
this.#replayOutbox();
|
|
4434
|
-
} finally {
|
|
4435
|
-
this.#endDiagnosticsDeferral();
|
|
4436
|
-
}
|
|
4413
|
+
this.#endDiagnosticsDeferral();
|
|
4437
4414
|
}
|
|
4438
4415
|
|
|
4439
4416
|
if (errorFrame !== undefined) throw errorFrame;
|
|
@@ -4587,6 +4564,15 @@ export class SyncClient {
|
|
|
4587
4564
|
listOutboxBeforeImages(this.#db, commit.clientCommitId),
|
|
4588
4565
|
);
|
|
4589
4566
|
this.#rollbackFailedCommit(commit, batch);
|
|
4567
|
+
this.#replayOutbox(
|
|
4568
|
+
commit.operations.map((operation) =>
|
|
4569
|
+
JSON.stringify(
|
|
4570
|
+
hasUniqueIndex(this.#table(operation.table))
|
|
4571
|
+
? [operation.table]
|
|
4572
|
+
: [operation.table, operation.rowId],
|
|
4573
|
+
),
|
|
4574
|
+
),
|
|
4575
|
+
);
|
|
4590
4576
|
});
|
|
4591
4577
|
batch.status();
|
|
4592
4578
|
summary.rejected.push(frame.clientCommitId);
|
|
@@ -4596,7 +4582,7 @@ export class SyncClient {
|
|
|
4596
4582
|
#outboxCommitExists(clientCommitId: string): boolean {
|
|
4597
4583
|
return (
|
|
4598
4584
|
this.#db.query(
|
|
4599
|
-
|
|
4585
|
+
`SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`,
|
|
4600
4586
|
[clientCommitId],
|
|
4601
4587
|
).length > 0
|
|
4602
4588
|
);
|
|
@@ -4650,7 +4636,7 @@ export class SyncClient {
|
|
|
4650
4636
|
this.#applyBatch((batch) => {
|
|
4651
4637
|
this.#recordCommitChanges(batch, frame);
|
|
4652
4638
|
const result = fn();
|
|
4653
|
-
this.#replayOutbox(result);
|
|
4639
|
+
this.#replayOutbox(result, false);
|
|
4654
4640
|
return result;
|
|
4655
4641
|
}),
|
|
4656
4642
|
);
|
|
@@ -4712,6 +4698,33 @@ export class SyncClient {
|
|
|
4712
4698
|
);
|
|
4713
4699
|
}
|
|
4714
4700
|
|
|
4701
|
+
/** Shared transaction, reset fence and progress for both segment formats. */
|
|
4702
|
+
#segmentApplyOptions(
|
|
4703
|
+
table: CompiledClientTable,
|
|
4704
|
+
clearFirst: boolean,
|
|
4705
|
+
effective: ScopeMap,
|
|
4706
|
+
hasRows: boolean,
|
|
4707
|
+
resetEpoch: number,
|
|
4708
|
+
): Parameters<typeof applyRowsSegment>[4] {
|
|
4709
|
+
return {
|
|
4710
|
+
clearFirst,
|
|
4711
|
+
effective,
|
|
4712
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
4713
|
+
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
4714
|
+
transaction: (fn) =>
|
|
4715
|
+
this.#applyBatch((batch) => {
|
|
4716
|
+
if (
|
|
4717
|
+
hasRows ||
|
|
4718
|
+
(clearFirst && this.#scopedRowsExist(table, effective))
|
|
4719
|
+
)
|
|
4720
|
+
batch.table(table.name);
|
|
4721
|
+
const result = fn();
|
|
4722
|
+
this.#replayOutbox(result, false);
|
|
4723
|
+
return result;
|
|
4724
|
+
}),
|
|
4725
|
+
};
|
|
4726
|
+
}
|
|
4727
|
+
|
|
4715
4728
|
/**
|
|
4716
4729
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
4717
4730
|
* (no local scope-column mapping) marks the subscription `failed` and
|
|
@@ -4890,14 +4903,17 @@ export class SyncClient {
|
|
|
4890
4903
|
}),
|
|
4891
4904
|
);
|
|
4892
4905
|
if (covered.length) {
|
|
4893
|
-
|
|
4906
|
+
const scope = covered.map((row) =>
|
|
4907
|
+
JSON.stringify([sub.table, String(row.id)]),
|
|
4908
|
+
);
|
|
4909
|
+
restoreFailedBases(this.#db, this.#schema, scope);
|
|
4894
4910
|
for (const row of covered)
|
|
4895
4911
|
this.#db.exec(
|
|
4896
4912
|
`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
|
|
4897
4913
|
[sub.table, String(row.id), nextCursor],
|
|
4898
4914
|
);
|
|
4899
4915
|
batch.table(sub.table);
|
|
4900
|
-
this.#replayOutbox();
|
|
4916
|
+
this.#replayOutbox(scope, false);
|
|
4901
4917
|
}
|
|
4902
4918
|
}
|
|
4903
4919
|
if (completed && registered !== undefined) {
|
|
@@ -4947,13 +4963,15 @@ export class SyncClient {
|
|
|
4947
4963
|
Object.keys(lastEffective).length > 0
|
|
4948
4964
|
) {
|
|
4949
4965
|
try {
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
name
|
|
4955
|
-
|
|
4956
|
-
|
|
4966
|
+
const scope = [
|
|
4967
|
+
JSON.stringify([table.name]),
|
|
4968
|
+
...this.#dropRetainedRows(
|
|
4969
|
+
batch,
|
|
4970
|
+
(name, values) =>
|
|
4971
|
+
name === table.name &&
|
|
4972
|
+
matchesLocalScopes(table, lastEffective, values),
|
|
4973
|
+
),
|
|
4974
|
+
];
|
|
4957
4975
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
4958
4976
|
batch.scopeMap(table, lastEffective);
|
|
4959
4977
|
const pendingById = new Map(
|
|
@@ -4999,6 +5017,7 @@ export class SyncClient {
|
|
|
4999
5017
|
batch.rejections();
|
|
5000
5018
|
batch.outcomes();
|
|
5001
5019
|
}
|
|
5020
|
+
this.#replayOutbox(scope);
|
|
5002
5021
|
this.#deleteUnreferencedBlobs();
|
|
5003
5022
|
} catch (error) {
|
|
5004
5023
|
if (
|
|
@@ -5219,7 +5238,7 @@ export class SyncClient {
|
|
|
5219
5238
|
: (local[SYNC_VERSION_COLUMN] as number);
|
|
5220
5239
|
if (
|
|
5221
5240
|
replay &&
|
|
5222
|
-
table
|
|
5241
|
+
hasUniqueIndex(table) &&
|
|
5223
5242
|
uniqueConflicts(
|
|
5224
5243
|
this.#db,
|
|
5225
5244
|
table,
|
|
@@ -5247,8 +5266,9 @@ export class SyncClient {
|
|
|
5247
5266
|
table: string,
|
|
5248
5267
|
values: Readonly<Record<string, JsonRowValue>>,
|
|
5249
5268
|
) => boolean,
|
|
5250
|
-
):
|
|
5251
|
-
|
|
5269
|
+
): string[] {
|
|
5270
|
+
const scope: string[] = [];
|
|
5271
|
+
for (const commit of dropFailedRows(this.#db, this.#schema, matches)) {
|
|
5252
5272
|
batch.conflicts();
|
|
5253
5273
|
batch.rejections();
|
|
5254
5274
|
batch.outcomes();
|
|
@@ -5258,32 +5278,39 @@ export class SyncClient {
|
|
|
5258
5278
|
this.#rejections = this.#rejections.filter(
|
|
5259
5279
|
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
5260
5280
|
);
|
|
5261
|
-
for (const op of commit.operations)
|
|
5281
|
+
for (const op of commit.operations) {
|
|
5282
|
+
batch.table(op.table);
|
|
5283
|
+
scope.push(JSON.stringify([op.table, op.rowId]));
|
|
5284
|
+
}
|
|
5262
5285
|
}
|
|
5286
|
+
return scope;
|
|
5263
5287
|
}
|
|
5264
5288
|
|
|
5265
|
-
#replayOutbox(
|
|
5266
|
-
|
|
5267
|
-
const pending = [
|
|
5268
|
-
...failedOverlayCommits(this.#db, false, rowKeys),
|
|
5269
|
-
...listOutbox(this.#db, rowKeys),
|
|
5270
|
-
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
5271
|
-
if (pending.length === 0 && acknowledged.length === 0) return;
|
|
5289
|
+
#replayOutbox(scope?: OverlayScope, restoreBases = true): void {
|
|
5290
|
+
if (scope?.length === 0) return;
|
|
5272
5291
|
this.#applyBatch((batch) => {
|
|
5273
|
-
this.#db
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5292
|
+
if (restoreBases) restoreFailedBases(this.#db, this.#schema, scope);
|
|
5293
|
+
const acknowledged = failedOverlayCommits(
|
|
5294
|
+
this.#db,
|
|
5295
|
+
true,
|
|
5296
|
+
scope,
|
|
5297
|
+
this.#schema,
|
|
5298
|
+
);
|
|
5299
|
+
const pending = [
|
|
5300
|
+
...failedOverlayCommits(this.#db, false, scope, this.#schema),
|
|
5301
|
+
...listOutbox(this.#db, scope),
|
|
5302
|
+
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
5303
|
+
for (const commit of [...acknowledged, ...pending])
|
|
5304
|
+
this.#applyOperationsLocally(
|
|
5305
|
+
commit.operations.filter(
|
|
5306
|
+
(op) =>
|
|
5307
|
+
scope === undefined ||
|
|
5308
|
+
scope.includes(JSON.stringify([op.table, op.rowId])) ||
|
|
5309
|
+
scope.includes(JSON.stringify([op.table])),
|
|
5310
|
+
),
|
|
5311
|
+
batch,
|
|
5312
|
+
true,
|
|
5313
|
+
);
|
|
5287
5314
|
});
|
|
5288
5315
|
}
|
|
5289
5316
|
|