@syncular/client 0.30.11 → 0.30.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +44 -36
- package/dist/client.d.ts +3 -0
- package/dist/client.js +205 -181
- package/dist/failed-overlay.d.ts +4 -3
- package/dist/failed-overlay.js +58 -43
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +14 -24
- package/dist/outcomes.js +6 -5
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +63 -72
- package/dist/worker-entry.js +14 -35
- package/dist/worker-host.d.ts +1 -0
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +1 -0
- package/package.json +3 -3
- package/src/apply.ts +81 -50
- package/src/client.ts +263 -226
- package/src/failed-overlay.ts +68 -57
- package/src/outbox.ts +19 -28
- package/src/outcomes.ts +7 -6
- package/src/schema.ts +81 -88
- package/src/worker-entry.ts +13 -50
- package/src/worker-host.ts +4 -0
- package/src/worker-protocol.ts +1 -0
package/src/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RETAINED_ROWS } from './failed-overlay';
|
|
1
2
|
import {
|
|
2
3
|
dropFailedRows,
|
|
3
4
|
failedOverlayCommits,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
applySqliteSegment,
|
|
52
53
|
deleteLocalRow,
|
|
53
54
|
deleteScopedRows,
|
|
55
|
+
localScopePredicate,
|
|
54
56
|
evictScopedRows,
|
|
55
57
|
yieldToHost,
|
|
56
58
|
upsertLocalRow,
|
|
@@ -196,6 +198,7 @@ import {
|
|
|
196
198
|
type CompiledClientSchema,
|
|
197
199
|
type CompiledClientTable,
|
|
198
200
|
compileClientSchema,
|
|
201
|
+
matchesLocalScopes,
|
|
199
202
|
dropAndRecreateSyncedTables,
|
|
200
203
|
ensureLocalBookkeepingSchema,
|
|
201
204
|
ensureLocalSyncedSchema,
|
|
@@ -346,6 +349,7 @@ export interface SyncClientLimits {
|
|
|
346
349
|
}
|
|
347
350
|
|
|
348
351
|
export interface SyncClientConfig {
|
|
352
|
+
readonly transportEnabled?: boolean;
|
|
349
353
|
/** Keep rejected/conflicting local intent visible until explicit resolution. */
|
|
350
354
|
readonly retainFailedCommits?: boolean;
|
|
351
355
|
readonly database: ClientDatabase;
|
|
@@ -744,6 +748,7 @@ export class SyncClient {
|
|
|
744
748
|
/** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
|
|
745
749
|
readonly #previousVersion: PreviousVersionCaptureConfig | undefined;
|
|
746
750
|
#securityLifecycle: SecurityLifecycle;
|
|
751
|
+
#transportEnabled: boolean;
|
|
747
752
|
readonly #now: () => number;
|
|
748
753
|
readonly #outcomeRetentionMaxEntries: number;
|
|
749
754
|
readonly #retainFailedCommits: boolean;
|
|
@@ -835,9 +840,16 @@ export class SyncClient {
|
|
|
835
840
|
#preflightBarrier: Promise<void> | undefined;
|
|
836
841
|
|
|
837
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;
|
|
838
850
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
839
851
|
throw invalidRequest(
|
|
840
|
-
'securityPreflight and encryption are mutually exclusive;
|
|
852
|
+
'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
|
|
841
853
|
);
|
|
842
854
|
}
|
|
843
855
|
this.#retainFailedCommits = config.retainFailedCommits === true;
|
|
@@ -923,7 +935,7 @@ export class SyncClient {
|
|
|
923
935
|
this.#lease = undefined;
|
|
924
936
|
throw new ClientSyncError(
|
|
925
937
|
'client.identity_mismatch',
|
|
926
|
-
`
|
|
938
|
+
`database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`,
|
|
927
939
|
);
|
|
928
940
|
}
|
|
929
941
|
this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
|
|
@@ -961,6 +973,9 @@ export class SyncClient {
|
|
|
961
973
|
const startupWork =
|
|
962
974
|
this.#schemaFloor === undefined &&
|
|
963
975
|
(countOutbox(this.#db) > 0 ||
|
|
976
|
+
this.#db.query(
|
|
977
|
+
`SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`,
|
|
978
|
+
).length > 0 ||
|
|
964
979
|
loadPendingEvictions(this.#db).length > 0 ||
|
|
965
980
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
966
981
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
@@ -1060,6 +1075,7 @@ export class SyncClient {
|
|
|
1060
1075
|
this.#schema,
|
|
1061
1076
|
true,
|
|
1062
1077
|
);
|
|
1078
|
+
this.#discardAcknowledgedRows();
|
|
1063
1079
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
1064
1080
|
resetSubscriptionsForBump(this.#db);
|
|
1065
1081
|
setMeta(
|
|
@@ -1160,6 +1176,7 @@ export class SyncClient {
|
|
|
1160
1176
|
this.#sweepPreviousVersionContainer();
|
|
1161
1177
|
this.#applyBatch((batch) => {
|
|
1162
1178
|
this.#db.transaction(() => {
|
|
1179
|
+
this.#discardAcknowledgedRows();
|
|
1163
1180
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
1164
1181
|
resetSubscriptionsForBump(this.#db);
|
|
1165
1182
|
setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
|
|
@@ -1217,6 +1234,7 @@ export class SyncClient {
|
|
|
1217
1234
|
}
|
|
1218
1235
|
|
|
1219
1236
|
#emitSyncNeeded(reason: 'startup' | 'hello' | WakeReason): void {
|
|
1237
|
+
if (!this.#transportEnabled) return;
|
|
1220
1238
|
try {
|
|
1221
1239
|
this.#config.onSyncNeeded?.(reason);
|
|
1222
1240
|
} catch {
|
|
@@ -1232,6 +1250,7 @@ export class SyncClient {
|
|
|
1232
1250
|
}
|
|
1233
1251
|
|
|
1234
1252
|
#emitSyncIntent(intent: SyncIntent): void {
|
|
1253
|
+
if (!this.#transportEnabled && intent.kind !== 'none') return;
|
|
1235
1254
|
try {
|
|
1236
1255
|
this.#config.onSyncIntent?.(intent);
|
|
1237
1256
|
} catch {
|
|
@@ -1246,6 +1265,27 @@ export class SyncClient {
|
|
|
1246
1265
|
}
|
|
1247
1266
|
}
|
|
1248
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
|
+
|
|
1249
1289
|
/** Current fail-closed local-replica security state. */
|
|
1250
1290
|
securityLifecycle(): SecurityLifecycle {
|
|
1251
1291
|
return this.#securityLifecycle;
|
|
@@ -1791,8 +1831,9 @@ export class SyncClient {
|
|
|
1791
1831
|
}
|
|
1792
1832
|
const capturedAtMs = this.#now();
|
|
1793
1833
|
const leaseState = this.#diagnosticLease(capturedAtMs);
|
|
1794
|
-
const connectivity =
|
|
1795
|
-
|
|
1834
|
+
const connectivity = !this.#transportEnabled
|
|
1835
|
+
? 'offline'
|
|
1836
|
+
: this.#lastRound?.status === 'succeeded'
|
|
1796
1837
|
? 'online'
|
|
1797
1838
|
: this.#lastRound?.status === 'failed' &&
|
|
1798
1839
|
this.#transportFailureCode(this.#lastRound.errorCode)
|
|
@@ -1825,16 +1866,12 @@ export class SyncClient {
|
|
|
1825
1866
|
realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
|
|
1826
1867
|
}),
|
|
1827
1868
|
securityLifecycle: this.#securityLifecycle,
|
|
1828
|
-
schema: {
|
|
1869
|
+
schema: definedObject({
|
|
1829
1870
|
currentVersion: this.#config.schema.version,
|
|
1830
1871
|
upgrading: this.#upgrading,
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
...(this.#schemaFloor?.latestSchemaVersion !== undefined
|
|
1835
|
-
? { latestVersion: this.#schemaFloor.latestSchemaVersion }
|
|
1836
|
-
: {}),
|
|
1837
|
-
},
|
|
1872
|
+
requiredVersion: this.#schemaFloor?.requiredSchemaVersion,
|
|
1873
|
+
latestVersion: this.#schemaFloor?.latestSchemaVersion,
|
|
1874
|
+
}),
|
|
1838
1875
|
replica: {
|
|
1839
1876
|
localRevision: getLocalRevision(this.#db).toString(),
|
|
1840
1877
|
syncNeeded: this.#needsPull,
|
|
@@ -1981,7 +2018,9 @@ export class SyncClient {
|
|
|
1981
2018
|
result = fn(batch);
|
|
1982
2019
|
if (
|
|
1983
2020
|
this.#db
|
|
1984
|
-
.query(
|
|
2021
|
+
.query(
|
|
2022
|
+
`SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`,
|
|
2023
|
+
)
|
|
1985
2024
|
.some((row) => batch.hasTable(String(row.tbl)))
|
|
1986
2025
|
)
|
|
1987
2026
|
batch.outcomes();
|
|
@@ -2048,10 +2087,11 @@ export class SyncClient {
|
|
|
2048
2087
|
this.#requireActive();
|
|
2049
2088
|
const task = Promise.resolve().then(fn);
|
|
2050
2089
|
this.#protectedAsync.add(task);
|
|
2051
|
-
|
|
2052
|
-
(
|
|
2053
|
-
|
|
2054
|
-
|
|
2090
|
+
const settled = () => {
|
|
2091
|
+
if (this.#realtimeConnectPromise === task)
|
|
2092
|
+
this.#realtimeConnectPromise = undefined;
|
|
2093
|
+
};
|
|
2094
|
+
void task.then(settled, settled);
|
|
2055
2095
|
return task;
|
|
2056
2096
|
}
|
|
2057
2097
|
|
|
@@ -2088,14 +2128,12 @@ export class SyncClient {
|
|
|
2088
2128
|
recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
|
|
2089
2129
|
});
|
|
2090
2130
|
this.#enforceBlobCacheCap();
|
|
2091
|
-
return {
|
|
2131
|
+
return definedObject({
|
|
2092
2132
|
blobId,
|
|
2093
2133
|
byteLength: bytes.length,
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
...(options?.name !== undefined ? { name: options.name } : {}),
|
|
2098
|
-
};
|
|
2134
|
+
mediaType: options?.mediaType,
|
|
2135
|
+
name: options?.name,
|
|
2136
|
+
});
|
|
2099
2137
|
}
|
|
2100
2138
|
|
|
2101
2139
|
/** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
|
|
@@ -2129,6 +2167,7 @@ export class SyncClient {
|
|
|
2129
2167
|
// presign configured) a signed url the client fetches directly. On a url
|
|
2130
2168
|
// arm the client MUST NOT attach host auth and MUST NOT fall through:
|
|
2131
2169
|
// failure => re-request, the caller's next fetchBlob mints a fresh url.
|
|
2170
|
+
this.#requireTransport();
|
|
2132
2171
|
const response = await transport.download(blobId);
|
|
2133
2172
|
let bytes: Uint8Array;
|
|
2134
2173
|
if (response.kind === 'url') {
|
|
@@ -2180,6 +2219,7 @@ export class SyncClient {
|
|
|
2180
2219
|
|
|
2181
2220
|
/** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
|
|
2182
2221
|
flushBlobUploads(): Promise<void> {
|
|
2222
|
+
this.#requireTransport();
|
|
2183
2223
|
return this.#runProtectedAsync(() => this.#flushBlobUploads());
|
|
2184
2224
|
}
|
|
2185
2225
|
|
|
@@ -2308,12 +2348,9 @@ export class SyncClient {
|
|
|
2308
2348
|
);
|
|
2309
2349
|
}
|
|
2310
2350
|
const allowed =
|
|
2311
|
-
(current.status === 'conflict' &&
|
|
2351
|
+
((current.status === 'conflict' || current.status === 'rejected') &&
|
|
2312
2352
|
(input.resolution === 'resolved_keep_server' ||
|
|
2313
2353
|
input.resolution === 'superseded')) ||
|
|
2314
|
-
(current.status === 'rejected' &&
|
|
2315
|
-
(input.resolution === 'superseded' ||
|
|
2316
|
-
input.resolution === 'resolved_keep_server')) ||
|
|
2317
2354
|
((current.status === 'applied' || current.status === 'cached') &&
|
|
2318
2355
|
input.resolution === 'dismissed');
|
|
2319
2356
|
if (!allowed) {
|
|
@@ -2327,7 +2364,7 @@ export class SyncClient {
|
|
|
2327
2364
|
return this.#applyBatch(
|
|
2328
2365
|
(batch) => {
|
|
2329
2366
|
restoreFailedBases(this.#db, this.#schema);
|
|
2330
|
-
this.#db.exec(
|
|
2367
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
|
|
2331
2368
|
input.clientCommitId,
|
|
2332
2369
|
]);
|
|
2333
2370
|
const resolved = persistCommitOutcomeResolution(
|
|
@@ -2416,6 +2453,7 @@ export class SyncClient {
|
|
|
2416
2453
|
*/
|
|
2417
2454
|
setPresence(scopeKey: string, doc: Record<string, unknown> | null): void {
|
|
2418
2455
|
this.#requireActive();
|
|
2456
|
+
this.#requireTransport();
|
|
2419
2457
|
const socket = this.#socket;
|
|
2420
2458
|
if (socket === undefined) {
|
|
2421
2459
|
throw invalidRequest(
|
|
@@ -2475,7 +2513,7 @@ export class SyncClient {
|
|
|
2475
2513
|
if (!sameIntent) {
|
|
2476
2514
|
throw new ClientSyncError(
|
|
2477
2515
|
'client.subscription_intent_mismatch',
|
|
2478
|
-
'
|
|
2516
|
+
'subscription id already names different table, scopes, or params',
|
|
2479
2517
|
);
|
|
2480
2518
|
}
|
|
2481
2519
|
return;
|
|
@@ -2533,7 +2571,7 @@ export class SyncClient {
|
|
|
2533
2571
|
const table = this.#table(base.table);
|
|
2534
2572
|
if (!table.scopeColumnByVariable.has(base.variable)) {
|
|
2535
2573
|
throw invalidRequest(
|
|
2536
|
-
|
|
2574
|
+
`${base.table}: unknown scope variable ${base.variable} (§4.8)`,
|
|
2537
2575
|
);
|
|
2538
2576
|
}
|
|
2539
2577
|
// Serialize the whole window edit: it spans an `await deriveSubId` between
|
|
@@ -2584,7 +2622,10 @@ export class SyncClient {
|
|
|
2584
2622
|
}
|
|
2585
2623
|
});
|
|
2586
2624
|
const effects: CommandEffects = {
|
|
2587
|
-
sync:
|
|
2625
|
+
sync:
|
|
2626
|
+
this.#transportEnabled && (changed || widened)
|
|
2627
|
+
? { kind: 'interactive' }
|
|
2628
|
+
: { kind: 'none' },
|
|
2588
2629
|
};
|
|
2589
2630
|
if (effects.sync.kind === 'interactive') {
|
|
2590
2631
|
this.#emitSyncIntent(effects.sync);
|
|
@@ -2888,7 +2929,9 @@ export class SyncClient {
|
|
|
2888
2929
|
mutateCommand(mutations: readonly MutationInput[]): CommandResult<string> {
|
|
2889
2930
|
return {
|
|
2890
2931
|
value: this.mutate(mutations),
|
|
2891
|
-
effects: {
|
|
2932
|
+
effects: {
|
|
2933
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
2934
|
+
},
|
|
2892
2935
|
};
|
|
2893
2936
|
}
|
|
2894
2937
|
|
|
@@ -2917,17 +2960,15 @@ export class SyncClient {
|
|
|
2917
2960
|
);
|
|
2918
2961
|
}
|
|
2919
2962
|
return this.#recordMutations([
|
|
2920
|
-
{
|
|
2963
|
+
definedObject({
|
|
2921
2964
|
table,
|
|
2922
2965
|
op: 'patch',
|
|
2923
2966
|
values: {
|
|
2924
2967
|
...Object.fromEntries(normalized),
|
|
2925
2968
|
[pkColumn.name]: rowId,
|
|
2926
2969
|
},
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
: {}),
|
|
2930
|
-
},
|
|
2970
|
+
baseVersion: options?.baseVersion,
|
|
2971
|
+
}),
|
|
2931
2972
|
]);
|
|
2932
2973
|
}
|
|
2933
2974
|
|
|
@@ -2965,26 +3006,13 @@ export class SyncClient {
|
|
|
2965
3006
|
return this.#applyBatch((batch) => {
|
|
2966
3007
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
2967
3008
|
restoreFailedBases(this.#db, this.#schema);
|
|
2968
|
-
|
|
3009
|
+
this.#dropRetainedRows(batch, (table, values) =>
|
|
2969
3010
|
(targetsByTable.get(table) ?? []).some((target) =>
|
|
2970
3011
|
target.selectors.every((selector) =>
|
|
2971
3012
|
selector.values.includes(String(values[selector.column])),
|
|
2972
3013
|
),
|
|
2973
3014
|
),
|
|
2974
3015
|
);
|
|
2975
|
-
for (const commit of droppedRetained) {
|
|
2976
|
-
batch.conflicts();
|
|
2977
|
-
batch.rejections();
|
|
2978
|
-
this.#conflicts = this.#conflicts.filter(
|
|
2979
|
-
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
2980
|
-
);
|
|
2981
|
-
this.#rejections = this.#rejections.filter(
|
|
2982
|
-
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
2983
|
-
);
|
|
2984
|
-
for (const operation of commit.operations)
|
|
2985
|
-
batch.table(operation.table);
|
|
2986
|
-
batch.outcomes();
|
|
2987
|
-
}
|
|
2988
3016
|
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
2989
3017
|
// a commit is doomed when any of its ops touches a base-matching
|
|
2990
3018
|
// row. Base values only become visible here as rollbacks restore
|
|
@@ -3144,6 +3172,7 @@ export class SyncClient {
|
|
|
3144
3172
|
this.#upgrading = true;
|
|
3145
3173
|
this.#needsPull = true;
|
|
3146
3174
|
batch.status();
|
|
3175
|
+
this.#discardAcknowledgedRows();
|
|
3147
3176
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
3148
3177
|
resetSubscriptionsForBump(this.#db);
|
|
3149
3178
|
for (const table of this.#schema.tables.values()) {
|
|
@@ -3224,7 +3253,9 @@ export class SyncClient {
|
|
|
3224
3253
|
): CommandResult<string> {
|
|
3225
3254
|
return {
|
|
3226
3255
|
value: this.patch(table, rowId, partial, options),
|
|
3227
|
-
effects: {
|
|
3256
|
+
effects: {
|
|
3257
|
+
sync: { kind: this.#transportEnabled ? 'interactive' : 'none' },
|
|
3258
|
+
},
|
|
3228
3259
|
};
|
|
3229
3260
|
}
|
|
3230
3261
|
|
|
@@ -3348,16 +3379,14 @@ export class SyncClient {
|
|
|
3348
3379
|
);
|
|
3349
3380
|
this.#rollbackFailedCommit(commit, batch);
|
|
3350
3381
|
this.#replayOutbox();
|
|
3351
|
-
const rejection: RejectionRecord = {
|
|
3382
|
+
const rejection: RejectionRecord = definedObject({
|
|
3352
3383
|
clientCommitId: commit.clientCommitId,
|
|
3353
3384
|
opIndex: 0,
|
|
3354
3385
|
code,
|
|
3355
3386
|
message,
|
|
3356
3387
|
retryable: false,
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
: {}),
|
|
3360
|
-
};
|
|
3388
|
+
operation: commit.operations[0],
|
|
3389
|
+
});
|
|
3361
3390
|
this.#rejections.push(rejection);
|
|
3362
3391
|
recordCommitOutcome(this.#db, {
|
|
3363
3392
|
clientCommitId: commit.clientCommitId,
|
|
@@ -3388,9 +3417,7 @@ export class SyncClient {
|
|
|
3388
3417
|
this.#requireActive();
|
|
3389
3418
|
if (this.#syncOutstanding) {
|
|
3390
3419
|
return Promise.reject(
|
|
3391
|
-
invalidRequest(
|
|
3392
|
-
'sync() is already running — the core owns one loop (coalesce wake-ups)',
|
|
3393
|
-
),
|
|
3420
|
+
invalidRequest('sync() is already running; coalesce wake-ups'),
|
|
3394
3421
|
);
|
|
3395
3422
|
}
|
|
3396
3423
|
this.#syncOutstanding = true;
|
|
@@ -3440,6 +3467,7 @@ export class SyncClient {
|
|
|
3440
3467
|
)
|
|
3441
3468
|
.finally(() => {
|
|
3442
3469
|
this.#syncOutstanding = false;
|
|
3470
|
+
if (!this.#transportEnabled) this.disconnectRealtime();
|
|
3443
3471
|
this.#endDiagnosticsDeferral();
|
|
3444
3472
|
});
|
|
3445
3473
|
}
|
|
@@ -3478,6 +3506,7 @@ export class SyncClient {
|
|
|
3478
3506
|
}
|
|
3479
3507
|
|
|
3480
3508
|
async #runSync(): Promise<SyncSummary> {
|
|
3509
|
+
this.#requireTransport();
|
|
3481
3510
|
this.#roundRetryDelayMs = undefined;
|
|
3482
3511
|
this.#progress.emit({
|
|
3483
3512
|
attempt: ++this.#progressAttempt,
|
|
@@ -3509,7 +3538,7 @@ export class SyncClient {
|
|
|
3509
3538
|
this.#hasBlobs &&
|
|
3510
3539
|
this.#config.blobs !== undefined
|
|
3511
3540
|
) {
|
|
3512
|
-
await this
|
|
3541
|
+
await this.#flushBlobUploads();
|
|
3513
3542
|
}
|
|
3514
3543
|
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
3515
3544
|
// cannot express itself under the new schema (a dropped column/table)
|
|
@@ -3643,6 +3672,8 @@ export class SyncClient {
|
|
|
3643
3672
|
loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
|
|
3644
3673
|
);
|
|
3645
3674
|
last = await this.sync();
|
|
3675
|
+
if (!this.#transportEnabled || last.schemaFloor !== undefined)
|
|
3676
|
+
return last;
|
|
3646
3677
|
if (
|
|
3647
3678
|
maxRounds === undefined &&
|
|
3648
3679
|
last.segmentRowsApplied > 0 &&
|
|
@@ -3656,7 +3687,6 @@ export class SyncClient {
|
|
|
3656
3687
|
)
|
|
3657
3688
|
)
|
|
3658
3689
|
round = -1;
|
|
3659
|
-
if (last.schemaFloor !== undefined) return last;
|
|
3660
3690
|
if (
|
|
3661
3691
|
last.commitsApplied === 0 &&
|
|
3662
3692
|
last.segmentRowsApplied === 0 &&
|
|
@@ -3772,6 +3802,7 @@ export class SyncClient {
|
|
|
3772
3802
|
|
|
3773
3803
|
connectRealtime(): Promise<void> {
|
|
3774
3804
|
this.#requireActive();
|
|
3805
|
+
this.#requireTransport();
|
|
3775
3806
|
if (this.#realtimePolicy === 'off') {
|
|
3776
3807
|
return Promise.reject(
|
|
3777
3808
|
invalidRequest("realtimePolicy is 'off'; realtime connect is refused"),
|
|
@@ -3832,12 +3863,13 @@ export class SyncClient {
|
|
|
3832
3863
|
},
|
|
3833
3864
|
});
|
|
3834
3865
|
openedSocket = socket;
|
|
3866
|
+
if (!this.#transportEnabled) {
|
|
3867
|
+
socket.close();
|
|
3868
|
+
this.#requireTransport();
|
|
3869
|
+
}
|
|
3835
3870
|
if (this.#securityLifecycle === 'preflight') {
|
|
3836
3871
|
socket.close();
|
|
3837
|
-
|
|
3838
|
-
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
3839
|
-
'realtime connected after the client entered security preflight',
|
|
3840
|
-
);
|
|
3872
|
+
this.#requireActive();
|
|
3841
3873
|
}
|
|
3842
3874
|
if (generation !== this.#realtimeGeneration || !this.#started) {
|
|
3843
3875
|
socket.close();
|
|
@@ -3985,6 +4017,7 @@ export class SyncClient {
|
|
|
3985
4017
|
}
|
|
3986
4018
|
|
|
3987
4019
|
async #handleRealtimeBinary(bytes: Uint8Array): Promise<void> {
|
|
4020
|
+
if (!this.#transportEnabled) return;
|
|
3988
4021
|
if (this.#syncing) {
|
|
3989
4022
|
// Fast path: a pull is mid-flight; let it win and recover the gap
|
|
3990
4023
|
// itself — re-pulling is idempotent, interleaved application is not
|
|
@@ -4012,7 +4045,8 @@ export class SyncClient {
|
|
|
4012
4045
|
}
|
|
4013
4046
|
|
|
4014
4047
|
#sendAck(cursor: number): void {
|
|
4015
|
-
this.#
|
|
4048
|
+
if (this.#transportEnabled)
|
|
4049
|
+
this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
|
|
4016
4050
|
}
|
|
4017
4051
|
|
|
4018
4052
|
/** Ack the highest cursor that is contiguously applied for every sub. */
|
|
@@ -4151,6 +4185,7 @@ export class SyncClient {
|
|
|
4151
4185
|
index += 1;
|
|
4152
4186
|
}
|
|
4153
4187
|
}
|
|
4188
|
+
const previousNeedsPull = this.#needsPull;
|
|
4154
4189
|
const conflictCount = this.#conflicts.length;
|
|
4155
4190
|
const rejectionCount = this.#rejections.length;
|
|
4156
4191
|
let outboxCount = countOutbox(this.#db);
|
|
@@ -4183,11 +4218,12 @@ export class SyncClient {
|
|
|
4183
4218
|
},
|
|
4184
4219
|
() => this.#statusSnapshot(outboxCount),
|
|
4185
4220
|
(cause) => {
|
|
4221
|
+
this.#needsPull = previousNeedsPull;
|
|
4186
4222
|
this.#conflicts.length = conflictCount;
|
|
4187
4223
|
this.#rejections.length = rejectionCount;
|
|
4188
4224
|
const error = new ClientSyncError(
|
|
4189
4225
|
'client.outcome_persistence_failed',
|
|
4190
|
-
'
|
|
4226
|
+
'outcome persistence failed',
|
|
4191
4227
|
);
|
|
4192
4228
|
error.cause = cause;
|
|
4193
4229
|
throw error;
|
|
@@ -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;
|
|
@@ -4485,6 +4456,18 @@ export class SyncClient {
|
|
|
4485
4456
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
4486
4457
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
4487
4458
|
// "already applied, you may have missed the ack".
|
|
4459
|
+
if (frame.commitSeq === undefined)
|
|
4460
|
+
throw new ClientSyncError(
|
|
4461
|
+
'client.invalid_host_response',
|
|
4462
|
+
'ACK lacks a commit sequence',
|
|
4463
|
+
);
|
|
4464
|
+
retainFailedRows(
|
|
4465
|
+
this.#db,
|
|
4466
|
+
commit,
|
|
4467
|
+
listOutboxBeforeImages(this.#db, commit.clientCommitId),
|
|
4468
|
+
frame.commitSeq,
|
|
4469
|
+
);
|
|
4470
|
+
this.#setSyncNeeded(true);
|
|
4488
4471
|
recordCommitOutcome(this.#db, {
|
|
4489
4472
|
clientCommitId: frame.clientCommitId,
|
|
4490
4473
|
status: frame.status,
|
|
@@ -4638,12 +4621,7 @@ export class SyncClient {
|
|
|
4638
4621
|
this.#applyBatch((batch) => {
|
|
4639
4622
|
this.#recordCommitChanges(batch, frame);
|
|
4640
4623
|
const result = fn();
|
|
4641
|
-
|
|
4642
|
-
this.#retainFailedCommits &&
|
|
4643
|
-
this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
|
|
4644
|
-
.length > 0
|
|
4645
|
-
)
|
|
4646
|
-
this.#replayOutbox();
|
|
4624
|
+
this.#replayOutbox(result);
|
|
4647
4625
|
return result;
|
|
4648
4626
|
}),
|
|
4649
4627
|
);
|
|
@@ -4695,26 +4673,43 @@ export class SyncClient {
|
|
|
4695
4673
|
|
|
4696
4674
|
/** Whether a fresh-bootstrap clear would remove at least one local row. */
|
|
4697
4675
|
#scopedRowsExist(table: CompiledClientTable, effective: ScopeMap): boolean {
|
|
4698
|
-
const
|
|
4699
|
-
if (entries.length === 0) return false;
|
|
4700
|
-
const clauses: string[] = [];
|
|
4701
|
-
const params: string[] = [];
|
|
4702
|
-
for (const [variable, values] of entries) {
|
|
4703
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
4704
|
-
if (column === undefined || values.length === 0) return false;
|
|
4705
|
-
clauses.push(
|
|
4706
|
-
`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
|
|
4707
|
-
);
|
|
4708
|
-
params.push(...values);
|
|
4709
|
-
}
|
|
4676
|
+
const scope = localScopePredicate(table, effective);
|
|
4710
4677
|
return (
|
|
4678
|
+
scope !== undefined &&
|
|
4711
4679
|
this.#db.query(
|
|
4712
|
-
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${
|
|
4713
|
-
params,
|
|
4680
|
+
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`,
|
|
4681
|
+
scope.params,
|
|
4714
4682
|
).length > 0
|
|
4715
4683
|
);
|
|
4716
4684
|
}
|
|
4717
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
|
+
|
|
4718
4713
|
/**
|
|
4719
4714
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
4720
4715
|
* (no local scope-column mapping) marks the subscription `failed` and
|
|
@@ -4817,7 +4812,7 @@ export class SyncClient {
|
|
|
4817
4812
|
// §5.4: MUST NOT start a fetch at/past expiry.
|
|
4818
4813
|
throw new ClientSyncError(
|
|
4819
4814
|
'sync.segment_expired',
|
|
4820
|
-
`
|
|
4815
|
+
`segment download grant expired; pull again (§5.4)`,
|
|
4821
4816
|
true,
|
|
4822
4817
|
);
|
|
4823
4818
|
}
|
|
@@ -4875,6 +4870,34 @@ export class SyncClient {
|
|
|
4875
4870
|
status: 'active',
|
|
4876
4871
|
}),
|
|
4877
4872
|
);
|
|
4873
|
+
if (completed) {
|
|
4874
|
+
const table = this.#table(sub.table);
|
|
4875
|
+
const effective = start.effectiveScopes;
|
|
4876
|
+
const covered = this.#db
|
|
4877
|
+
.query(
|
|
4878
|
+
`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq<=?`,
|
|
4879
|
+
[sub.table, nextCursor],
|
|
4880
|
+
)
|
|
4881
|
+
.filter((row) =>
|
|
4882
|
+
[row.intent, row.base].some((json) => {
|
|
4883
|
+
if (json === null) return false;
|
|
4884
|
+
const values: Record<string, JsonRowValue> = JSON.parse(
|
|
4885
|
+
String(json),
|
|
4886
|
+
);
|
|
4887
|
+
return matchesLocalScopes(table, effective, values);
|
|
4888
|
+
}),
|
|
4889
|
+
);
|
|
4890
|
+
if (covered.length) {
|
|
4891
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
4892
|
+
for (const row of covered)
|
|
4893
|
+
this.#db.exec(
|
|
4894
|
+
`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
|
|
4895
|
+
[sub.table, String(row.id), nextCursor],
|
|
4896
|
+
);
|
|
4897
|
+
batch.table(sub.table);
|
|
4898
|
+
this.#replayOutbox();
|
|
4899
|
+
}
|
|
4900
|
+
}
|
|
4878
4901
|
if (completed && registered !== undefined) {
|
|
4879
4902
|
// A zero-row bootstrap is a window-domain transition, not a fake
|
|
4880
4903
|
// row/table change (SPEC §4.8 / §7.5).
|
|
@@ -4923,29 +4946,12 @@ export class SyncClient {
|
|
|
4923
4946
|
) {
|
|
4924
4947
|
try {
|
|
4925
4948
|
restoreFailedBases(this.#db, this.#schema);
|
|
4926
|
-
|
|
4927
|
-
|
|
4949
|
+
this.#dropRetainedRows(
|
|
4950
|
+
batch,
|
|
4928
4951
|
(name, values) =>
|
|
4929
4952
|
name === table.name &&
|
|
4930
|
-
|
|
4931
|
-
allowed.includes(
|
|
4932
|
-
String(values[table.scopeColumnByVariable.get(variable)!]),
|
|
4933
|
-
),
|
|
4934
|
-
),
|
|
4953
|
+
matchesLocalScopes(table, lastEffective, values),
|
|
4935
4954
|
);
|
|
4936
|
-
for (const commit of droppedRetained) {
|
|
4937
|
-
batch.conflicts();
|
|
4938
|
-
batch.rejections();
|
|
4939
|
-
this.#conflicts = this.#conflicts.filter(
|
|
4940
|
-
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
4941
|
-
);
|
|
4942
|
-
this.#rejections = this.#rejections.filter(
|
|
4943
|
-
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
4944
|
-
);
|
|
4945
|
-
for (const operation of commit.operations)
|
|
4946
|
-
batch.table(operation.table);
|
|
4947
|
-
batch.outcomes();
|
|
4948
|
-
}
|
|
4949
4955
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
4950
4956
|
batch.scopeMap(table, lastEffective);
|
|
4951
4957
|
const pendingById = new Map(
|
|
@@ -5229,18 +5235,52 @@ export class SyncClient {
|
|
|
5229
5235
|
}
|
|
5230
5236
|
|
|
5231
5237
|
/** Re-apply every pending outbox commit on top of server state (§7.1). */
|
|
5232
|
-
#
|
|
5238
|
+
#discardAcknowledgedRows(): void {
|
|
5239
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
|
|
5240
|
+
}
|
|
5241
|
+
|
|
5242
|
+
#dropRetainedRows(
|
|
5243
|
+
batch: ChangeAccumulator,
|
|
5244
|
+
matches: (
|
|
5245
|
+
table: string,
|
|
5246
|
+
values: Readonly<Record<string, JsonRowValue>>,
|
|
5247
|
+
) => boolean,
|
|
5248
|
+
): void {
|
|
5249
|
+
for (const commit of dropFailedRows(this.#db, matches)) {
|
|
5250
|
+
batch.conflicts();
|
|
5251
|
+
batch.rejections();
|
|
5252
|
+
batch.outcomes();
|
|
5253
|
+
this.#conflicts = this.#conflicts.filter(
|
|
5254
|
+
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
5255
|
+
);
|
|
5256
|
+
this.#rejections = this.#rejections.filter(
|
|
5257
|
+
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
5258
|
+
);
|
|
5259
|
+
for (const op of commit.operations) batch.table(op.table);
|
|
5260
|
+
}
|
|
5261
|
+
}
|
|
5262
|
+
|
|
5263
|
+
#replayOutbox(rowKeys?: readonly string[]): void {
|
|
5264
|
+
const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
|
|
5233
5265
|
const pending = [
|
|
5234
|
-
...failedOverlayCommits(this.#db),
|
|
5235
|
-
...listOutbox(this.#db),
|
|
5266
|
+
...failedOverlayCommits(this.#db, false, rowKeys),
|
|
5267
|
+
...listOutbox(this.#db, rowKeys),
|
|
5236
5268
|
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
5237
|
-
if (pending.length === 0) return;
|
|
5269
|
+
if (pending.length === 0 && acknowledged.length === 0) return;
|
|
5238
5270
|
this.#applyBatch((batch) => {
|
|
5239
5271
|
this.#db.transaction(() => {
|
|
5240
|
-
restoreFailedBases(this.#db, this.#schema);
|
|
5241
|
-
restoreFailedBases(this.#db, this.#schema, true);
|
|
5242
|
-
for (const commit of pending)
|
|
5243
|
-
this.#applyOperationsLocally(
|
|
5272
|
+
restoreFailedBases(this.#db, this.#schema, false, rowKeys);
|
|
5273
|
+
restoreFailedBases(this.#db, this.#schema, true, rowKeys);
|
|
5274
|
+
for (const commit of [...acknowledged, ...pending])
|
|
5275
|
+
this.#applyOperationsLocally(
|
|
5276
|
+
rowKeys === undefined
|
|
5277
|
+
? commit.operations
|
|
5278
|
+
: commit.operations.filter((op) =>
|
|
5279
|
+
rowKeys.includes(JSON.stringify([op.table, op.rowId])),
|
|
5280
|
+
),
|
|
5281
|
+
batch,
|
|
5282
|
+
true,
|
|
5283
|
+
);
|
|
5244
5284
|
});
|
|
5245
5285
|
});
|
|
5246
5286
|
}
|
|
@@ -5255,17 +5295,14 @@ export class SyncClient {
|
|
|
5255
5295
|
#table(name: string): CompiledClientTable {
|
|
5256
5296
|
const table = this.#schema.tables.get(name);
|
|
5257
5297
|
if (table === undefined) {
|
|
5258
|
-
throw new ClientSyncError(
|
|
5259
|
-
'sync.unknown_table',
|
|
5260
|
-
`unknown local table ${JSON.stringify(name)}`,
|
|
5261
|
-
);
|
|
5298
|
+
throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
|
|
5262
5299
|
}
|
|
5263
5300
|
return table;
|
|
5264
5301
|
}
|
|
5265
5302
|
|
|
5266
5303
|
#requireStarted(): void {
|
|
5267
5304
|
if (!this.#started) {
|
|
5268
|
-
throw invalidRequest('SyncClient.start()
|
|
5305
|
+
throw invalidRequest('call SyncClient.start() first');
|
|
5269
5306
|
}
|
|
5270
5307
|
}
|
|
5271
5308
|
|
|
@@ -5274,7 +5311,7 @@ export class SyncClient {
|
|
|
5274
5311
|
if (this.#securityLifecycle === 'preflight') {
|
|
5275
5312
|
throw new ClientSyncError(
|
|
5276
5313
|
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
5277
|
-
'
|
|
5314
|
+
'security preflight must complete before accessing protected data; call activateSecurity',
|
|
5278
5315
|
);
|
|
5279
5316
|
}
|
|
5280
5317
|
}
|