@syncular/client 0.30.6 → 0.30.9
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 +12 -2
- package/dist/apply.d.ts +2 -2
- package/dist/apply.js +25 -8
- package/dist/client.d.ts +6 -4
- package/dist/client.js +114 -75
- package/dist/database.d.ts +3 -0
- package/dist/database.js +26 -7
- package/dist/diagnostics.d.ts +1 -1
- package/dist/errors.d.ts +5 -1
- package/dist/errors.js +61 -15
- package/dist/failed-overlay.d.ts +10 -0
- package/dist/failed-overlay.js +97 -0
- package/dist/invalidation.d.ts +1 -0
- package/dist/invalidation.js +3 -0
- package/dist/outcomes.d.ts +10 -0
- package/dist/outcomes.js +18 -3
- package/dist/remote.js +7 -2
- package/dist/schema.js +7 -5
- package/dist/wasm-database.js +5 -5
- package/dist/window.js +4 -4
- package/dist/worker-entry.js +6 -2
- 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 +46 -13
- package/src/client.ts +184 -171
- package/src/database.ts +24 -8
- package/src/diagnostics.ts +1 -0
- package/src/errors.ts +82 -20
- package/src/failed-overlay.ts +165 -0
- package/src/invalidation.ts +4 -0
- package/src/outcomes.ts +41 -3
- package/src/remote.ts +15 -1
- package/src/schema.ts +7 -9
- package/src/wasm-database.ts +5 -8
- package/src/window.ts +4 -7
- package/src/worker-entry.ts +10 -2
- package/src/worker-host.ts +4 -0
- package/src/worker-protocol.ts +1 -0
package/src/client.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
dropFailedRows,
|
|
3
|
+
failedOverlayCommits,
|
|
4
|
+
retainFailedRows,
|
|
5
|
+
restoreFailedBases,
|
|
6
|
+
} from './failed-overlay';
|
|
1
7
|
import {
|
|
2
8
|
ProgressEmitter,
|
|
3
9
|
type SyncProgress,
|
|
@@ -88,6 +94,7 @@ import {
|
|
|
88
94
|
import type { EncryptionConfig } from './encryption';
|
|
89
95
|
import {
|
|
90
96
|
ClientSyncError,
|
|
97
|
+
invalidRequest,
|
|
91
98
|
classifySqliteFailure,
|
|
92
99
|
REALTIME_LOST_CODE,
|
|
93
100
|
RealtimeUnavailableError,
|
|
@@ -244,8 +251,8 @@ import {
|
|
|
244
251
|
export type MutationInput =
|
|
245
252
|
| {
|
|
246
253
|
readonly table: string;
|
|
247
|
-
readonly op: 'upsert';
|
|
248
|
-
/**
|
|
254
|
+
readonly op: 'upsert' | 'patch';
|
|
255
|
+
/** Column-keyed full row for upsert, or primary key plus present columns for patch. */
|
|
249
256
|
readonly values: Readonly<Record<string, unknown>>;
|
|
250
257
|
readonly baseVersion?: number;
|
|
251
258
|
}
|
|
@@ -260,6 +267,7 @@ export type {
|
|
|
260
267
|
CommitOperationOutcome,
|
|
261
268
|
CommitOutcome,
|
|
262
269
|
CommitOutcomeQuery,
|
|
270
|
+
RetainedCommitRow,
|
|
263
271
|
ConflictRecord,
|
|
264
272
|
RejectionRecord,
|
|
265
273
|
ResolveCommitOutcomeInput,
|
|
@@ -336,6 +344,8 @@ export interface SyncClientLimits {
|
|
|
336
344
|
}
|
|
337
345
|
|
|
338
346
|
export interface SyncClientConfig {
|
|
347
|
+
/** Keep rejected/conflicting local intent visible until explicit resolution. */
|
|
348
|
+
readonly retainFailedCommits?: boolean;
|
|
339
349
|
readonly database: ClientDatabase;
|
|
340
350
|
readonly schema: ClientSchema;
|
|
341
351
|
readonly transport: SyncTransport;
|
|
@@ -618,8 +628,7 @@ function boundedBudget(
|
|
|
618
628
|
): number {
|
|
619
629
|
if (value === undefined) return fallback;
|
|
620
630
|
if (!Number.isSafeInteger(value) || value < 1) {
|
|
621
|
-
throw
|
|
622
|
-
'sync.invalid_request',
|
|
631
|
+
throw invalidRequest(
|
|
623
632
|
`previousVersionContext.${name} must be a positive safe integer`,
|
|
624
633
|
);
|
|
625
634
|
}
|
|
@@ -668,8 +677,7 @@ function previousVersionMaxAgeMs(
|
|
|
668
677
|
function resolvePreviousVersionLimit(limit: number | undefined): number {
|
|
669
678
|
if (limit === undefined) return 50;
|
|
670
679
|
if (!Number.isFinite(limit) || limit < 1) {
|
|
671
|
-
throw
|
|
672
|
-
'sync.invalid_request',
|
|
680
|
+
throw invalidRequest(
|
|
673
681
|
'previousVersionSnapshot limit must be a positive number',
|
|
674
682
|
);
|
|
675
683
|
}
|
|
@@ -736,6 +744,7 @@ export class SyncClient {
|
|
|
736
744
|
#securityLifecycle: SecurityLifecycle;
|
|
737
745
|
readonly #now: () => number;
|
|
738
746
|
readonly #outcomeRetentionMaxEntries: number;
|
|
747
|
+
readonly #retainFailedCommits: boolean;
|
|
739
748
|
#started = false;
|
|
740
749
|
#lease: LeaderLease | undefined;
|
|
741
750
|
#clientId = '';
|
|
@@ -825,11 +834,11 @@ export class SyncClient {
|
|
|
825
834
|
|
|
826
835
|
constructor(config: SyncClientConfig) {
|
|
827
836
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
828
|
-
throw
|
|
829
|
-
'sync.invalid_request',
|
|
837
|
+
throw invalidRequest(
|
|
830
838
|
'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
|
|
831
839
|
);
|
|
832
840
|
}
|
|
841
|
+
this.#retainFailedCommits = config.retainFailedCommits === true;
|
|
833
842
|
this.#db = config.database;
|
|
834
843
|
this.#schema = compileClientSchema(config.schema);
|
|
835
844
|
const realtimePolicy = config.realtimePolicy ?? 'optional';
|
|
@@ -838,14 +847,12 @@ export class SyncClient {
|
|
|
838
847
|
realtimePolicy !== 'optional' &&
|
|
839
848
|
realtimePolicy !== 'off'
|
|
840
849
|
) {
|
|
841
|
-
throw
|
|
842
|
-
'sync.invalid_request',
|
|
850
|
+
throw invalidRequest(
|
|
843
851
|
"realtimePolicy must be 'required', 'optional', or 'off'",
|
|
844
852
|
);
|
|
845
853
|
}
|
|
846
854
|
if (realtimePolicy === 'required' && config.realtime === undefined) {
|
|
847
|
-
throw
|
|
848
|
-
'sync.invalid_request',
|
|
855
|
+
throw invalidRequest(
|
|
849
856
|
"realtimePolicy 'required' needs a realtime connector",
|
|
850
857
|
);
|
|
851
858
|
}
|
|
@@ -867,8 +874,7 @@ export class SyncClient {
|
|
|
867
874
|
!Number.isSafeInteger(outcomeRetentionMaxEntries) ||
|
|
868
875
|
outcomeRetentionMaxEntries < 1
|
|
869
876
|
) {
|
|
870
|
-
throw
|
|
871
|
-
'sync.invalid_request',
|
|
877
|
+
throw invalidRequest(
|
|
872
878
|
'outcomeRetentionMaxEntries must be a positive safe integer',
|
|
873
879
|
);
|
|
874
880
|
}
|
|
@@ -1272,8 +1278,7 @@ export class SyncClient {
|
|
|
1272
1278
|
async activateSecurity(options: SecurityActivation = {}): Promise<void> {
|
|
1273
1279
|
this.#requireStarted();
|
|
1274
1280
|
if (this.#securityLifecycle === 'active') {
|
|
1275
|
-
throw
|
|
1276
|
-
'sync.invalid_request',
|
|
1281
|
+
throw invalidRequest(
|
|
1277
1282
|
'activateSecurity requires the client to be in security preflight',
|
|
1278
1283
|
);
|
|
1279
1284
|
}
|
|
@@ -1471,8 +1476,7 @@ export class SyncClient {
|
|
|
1471
1476
|
}
|
|
1472
1477
|
const table = record.tables.find((entry) => entry.name === spec.table);
|
|
1473
1478
|
if (table === undefined) {
|
|
1474
|
-
throw
|
|
1475
|
-
'sync.invalid_request',
|
|
1479
|
+
throw invalidRequest(
|
|
1476
1480
|
`previousVersionSnapshot names unknown previous table ${JSON.stringify(spec.table)}`,
|
|
1477
1481
|
);
|
|
1478
1482
|
}
|
|
@@ -1707,8 +1711,7 @@ export class SyncClient {
|
|
|
1707
1711
|
this.#requireActive();
|
|
1708
1712
|
const expected = request.expectedSubscriptions ?? [];
|
|
1709
1713
|
if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
|
|
1710
|
-
throw
|
|
1711
|
-
'sync.invalid_request',
|
|
1714
|
+
throw invalidRequest(
|
|
1712
1715
|
`diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`,
|
|
1713
1716
|
);
|
|
1714
1717
|
}
|
|
@@ -1747,8 +1750,7 @@ export class SyncClient {
|
|
|
1747
1750
|
typeof item.table !== 'string' ||
|
|
1748
1751
|
item.table.length === 0
|
|
1749
1752
|
) {
|
|
1750
|
-
throw
|
|
1751
|
-
'sync.invalid_request',
|
|
1753
|
+
throw invalidRequest(
|
|
1752
1754
|
'diagnosticsSnapshot expected subscriptions require non-empty id and table strings',
|
|
1753
1755
|
);
|
|
1754
1756
|
}
|
|
@@ -1973,6 +1975,12 @@ export class SyncClient {
|
|
|
1973
1975
|
this.#batch = batch;
|
|
1974
1976
|
try {
|
|
1975
1977
|
result = fn(batch);
|
|
1978
|
+
if (
|
|
1979
|
+
this.#db
|
|
1980
|
+
.query('SELECT DISTINCT tbl FROM _syncular_failed_rows')
|
|
1981
|
+
.some((row) => batch.hasTable(String(row.tbl)))
|
|
1982
|
+
)
|
|
1983
|
+
batch.outcomes();
|
|
1976
1984
|
if (batch.touched) {
|
|
1977
1985
|
revision = bumpLocalRevision(this.#db);
|
|
1978
1986
|
if (batch.statusChanged) {
|
|
@@ -2065,8 +2073,7 @@ export class SyncClient {
|
|
|
2065
2073
|
options?: { readonly mediaType?: string; readonly name?: string },
|
|
2066
2074
|
): Promise<BlobRef> {
|
|
2067
2075
|
if (this.#config.blobs === undefined) {
|
|
2068
|
-
throw
|
|
2069
|
-
'sync.invalid_request',
|
|
2076
|
+
throw invalidRequest(
|
|
2070
2077
|
'uploadBlob requires a blob transport (SyncClientConfig.blobs, §5.9)',
|
|
2071
2078
|
);
|
|
2072
2079
|
}
|
|
@@ -2110,8 +2117,7 @@ export class SyncClient {
|
|
|
2110
2117
|
if (cached !== undefined) return cached;
|
|
2111
2118
|
const transport = this.#config.blobs;
|
|
2112
2119
|
if (transport === undefined) {
|
|
2113
|
-
throw
|
|
2114
|
-
'sync.invalid_request',
|
|
2120
|
+
throw invalidRequest(
|
|
2115
2121
|
'fetchBlob requires a blob transport (SyncClientConfig.blobs, §5.9)',
|
|
2116
2122
|
);
|
|
2117
2123
|
}
|
|
@@ -2123,8 +2129,7 @@ export class SyncClient {
|
|
|
2123
2129
|
let bytes: Uint8Array;
|
|
2124
2130
|
if (response.kind === 'url') {
|
|
2125
2131
|
if (transport.fetchUrl === undefined) {
|
|
2126
|
-
throw
|
|
2127
|
-
'sync.invalid_request',
|
|
2132
|
+
throw invalidRequest(
|
|
2128
2133
|
'blob download returned a url but the transport cannot fetch urls (§5.9.5)',
|
|
2129
2134
|
);
|
|
2130
2135
|
}
|
|
@@ -2148,8 +2153,7 @@ export class SyncClient {
|
|
|
2148
2153
|
// §5.9.5 inherits §5.1: verify the content address, reject on mismatch.
|
|
2149
2154
|
// On the url path this invalidates the fetch (no fall-through) — the
|
|
2150
2155
|
// next fetchBlob re-requests the authorized endpoint (§5.9.5 recovery).
|
|
2151
|
-
throw
|
|
2152
|
-
'sync.invalid_request',
|
|
2156
|
+
throw invalidRequest(
|
|
2153
2157
|
`blob content address mismatch for ${blobId} (§5.9.5)`,
|
|
2154
2158
|
);
|
|
2155
2159
|
}
|
|
@@ -2158,10 +2162,7 @@ export class SyncClient {
|
|
|
2158
2162
|
this.#enforceBlobCacheCap();
|
|
2159
2163
|
const stored = getCachedBlob(this.#db, blobId);
|
|
2160
2164
|
if (stored === undefined) {
|
|
2161
|
-
throw
|
|
2162
|
-
'sync.invalid_request',
|
|
2163
|
-
'blob cache write failed',
|
|
2164
|
-
);
|
|
2165
|
+
throw invalidRequest('blob cache write failed');
|
|
2165
2166
|
}
|
|
2166
2167
|
return stored;
|
|
2167
2168
|
}
|
|
@@ -2273,7 +2274,7 @@ export class SyncClient {
|
|
|
2273
2274
|
/**
|
|
2274
2275
|
* Mark a durable failure handled without deleting its evidence. Conflicts
|
|
2275
2276
|
* may keep the server row or link to a replacement commit; rejections may
|
|
2276
|
-
*
|
|
2277
|
+
* also keep the server row or link to a named replacement. Applied/cached history may be
|
|
2277
2278
|
* dismissed. The transition is one-way and survives restart.
|
|
2278
2279
|
*/
|
|
2279
2280
|
resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome {
|
|
@@ -2293,14 +2294,12 @@ export class SyncClient {
|
|
|
2293
2294
|
replacement.length === 0 ||
|
|
2294
2295
|
replacement === input.clientCommitId
|
|
2295
2296
|
) {
|
|
2296
|
-
throw
|
|
2297
|
-
'sync.invalid_request',
|
|
2297
|
+
throw invalidRequest(
|
|
2298
2298
|
'superseded outcomes require a distinct replacementClientCommitId',
|
|
2299
2299
|
);
|
|
2300
2300
|
}
|
|
2301
2301
|
} else if (replacement !== undefined) {
|
|
2302
|
-
throw
|
|
2303
|
-
'sync.invalid_request',
|
|
2302
|
+
throw invalidRequest(
|
|
2304
2303
|
'replacementClientCommitId is valid only for superseded outcomes',
|
|
2305
2304
|
);
|
|
2306
2305
|
}
|
|
@@ -2308,40 +2307,58 @@ export class SyncClient {
|
|
|
2308
2307
|
(current.status === 'conflict' &&
|
|
2309
2308
|
(input.resolution === 'resolved_keep_server' ||
|
|
2310
2309
|
input.resolution === 'superseded')) ||
|
|
2311
|
-
(current.status === 'rejected' &&
|
|
2310
|
+
(current.status === 'rejected' &&
|
|
2311
|
+
(input.resolution === 'superseded' ||
|
|
2312
|
+
input.resolution === 'resolved_keep_server')) ||
|
|
2312
2313
|
((current.status === 'applied' || current.status === 'cached') &&
|
|
2313
2314
|
input.resolution === 'dismissed');
|
|
2314
2315
|
if (!allowed) {
|
|
2315
|
-
throw
|
|
2316
|
-
'sync.invalid_request',
|
|
2316
|
+
throw invalidRequest(
|
|
2317
2317
|
`resolution ${input.resolution} is invalid for ${current.status} outcome`,
|
|
2318
2318
|
);
|
|
2319
2319
|
}
|
|
2320
2320
|
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
this.#
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2321
|
+
const priorConflicts = this.#conflicts;
|
|
2322
|
+
const priorRejections = this.#rejections;
|
|
2323
|
+
return this.#applyBatch(
|
|
2324
|
+
(batch) => {
|
|
2325
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
2326
|
+
this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [
|
|
2327
|
+
input.clientCommitId,
|
|
2328
|
+
]);
|
|
2329
|
+
const resolved = persistCommitOutcomeResolution(
|
|
2330
|
+
this.#db,
|
|
2331
|
+
input,
|
|
2332
|
+
this.#now(),
|
|
2331
2333
|
);
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2334
|
+
if (resolved === undefined) {
|
|
2335
|
+
throw new ClientSyncError(
|
|
2336
|
+
'sync.outcome_not_found',
|
|
2337
|
+
`no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`,
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
this.#conflicts = this.#conflicts.filter(
|
|
2341
|
+
(record) => record.clientCommitId !== input.clientCommitId,
|
|
2342
|
+
);
|
|
2343
|
+
this.#rejections = this.#rejections.filter(
|
|
2344
|
+
(record) => record.clientCommitId !== input.clientCommitId,
|
|
2345
|
+
);
|
|
2346
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2347
|
+
this.#replayOutbox();
|
|
2348
|
+
for (const operation of current.operations ?? [])
|
|
2349
|
+
batch.table(operation.table);
|
|
2350
|
+
batch.outcomes();
|
|
2351
|
+
if (current.status === 'conflict') batch.conflicts();
|
|
2352
|
+
if (current.status === 'rejected') batch.rejections();
|
|
2353
|
+
return resolved;
|
|
2354
|
+
},
|
|
2355
|
+
undefined,
|
|
2356
|
+
(error) => {
|
|
2357
|
+
this.#conflicts = priorConflicts;
|
|
2358
|
+
this.#rejections = priorRejections;
|
|
2359
|
+
throw error;
|
|
2360
|
+
},
|
|
2361
|
+
);
|
|
2345
2362
|
}
|
|
2346
2363
|
|
|
2347
2364
|
/** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
|
|
@@ -2397,8 +2414,7 @@ export class SyncClient {
|
|
|
2397
2414
|
this.#requireActive();
|
|
2398
2415
|
const socket = this.#socket;
|
|
2399
2416
|
if (socket === undefined) {
|
|
2400
|
-
throw
|
|
2401
|
-
'sync.invalid_request',
|
|
2417
|
+
throw invalidRequest(
|
|
2402
2418
|
'setPresence requires a connected realtime socket (§8.6)',
|
|
2403
2419
|
);
|
|
2404
2420
|
}
|
|
@@ -2509,8 +2525,7 @@ export class SyncClient {
|
|
|
2509
2525
|
this.#requireActive();
|
|
2510
2526
|
const table = this.#table(base.table);
|
|
2511
2527
|
if (!table.scopeColumnByVariable.has(base.variable)) {
|
|
2512
|
-
throw
|
|
2513
|
-
'sync.invalid_request',
|
|
2528
|
+
throw invalidRequest(
|
|
2514
2529
|
`setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`,
|
|
2515
2530
|
);
|
|
2516
2531
|
}
|
|
@@ -2689,7 +2704,10 @@ export class SyncClient {
|
|
|
2689
2704
|
*/
|
|
2690
2705
|
#pinnedRowIds(table: string): Set<string> {
|
|
2691
2706
|
const pinned = new Set<string>();
|
|
2692
|
-
for (const commit of
|
|
2707
|
+
for (const commit of [
|
|
2708
|
+
...failedOverlayCommits(this.#db),
|
|
2709
|
+
...listOutbox(this.#db),
|
|
2710
|
+
]) {
|
|
2693
2711
|
for (const op of commit.operations) {
|
|
2694
2712
|
if (op.table === table) pinned.add(op.rowId);
|
|
2695
2713
|
}
|
|
@@ -2708,10 +2726,7 @@ export class SyncClient {
|
|
|
2708
2726
|
return this.#recordMutations(mutations);
|
|
2709
2727
|
}
|
|
2710
2728
|
|
|
2711
|
-
#recordMutations(
|
|
2712
|
-
mutations: readonly MutationInput[],
|
|
2713
|
-
partial = false,
|
|
2714
|
-
): string {
|
|
2729
|
+
#recordMutations(mutations: readonly MutationInput[]): string {
|
|
2715
2730
|
this.#requireActive();
|
|
2716
2731
|
const clientCommitId = crypto.randomUUID();
|
|
2717
2732
|
const operations: OutboxOperation[] = mutations.map((mutation) => {
|
|
@@ -2726,7 +2741,8 @@ export class SyncClient {
|
|
|
2726
2741
|
: {}),
|
|
2727
2742
|
};
|
|
2728
2743
|
}
|
|
2729
|
-
|
|
2744
|
+
const json: Record<string, JsonRowValue> = {};
|
|
2745
|
+
if (mutation.op === 'patch') {
|
|
2730
2746
|
// §6.7 patch: the values map IS the presence set — the primary key
|
|
2731
2747
|
// plus the supplied non-scope columns. Absent columns keep their
|
|
2732
2748
|
// stored value; the overlay and the server both apply only the
|
|
@@ -2734,7 +2750,6 @@ export class SyncClient {
|
|
|
2734
2750
|
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
2735
2751
|
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
2736
2752
|
const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
|
|
2737
|
-
const json: Record<string, JsonRowValue> = {};
|
|
2738
2753
|
for (const column of table.columns) {
|
|
2739
2754
|
if (!normalized.has(column.name)) continue;
|
|
2740
2755
|
// §3.4 rule 5 / §6.2: scope columns are immutable on update. The
|
|
@@ -2770,8 +2785,7 @@ export class SyncClient {
|
|
|
2770
2785
|
),
|
|
2771
2786
|
)
|
|
2772
2787
|
) {
|
|
2773
|
-
throw
|
|
2774
|
-
'sync.invalid_request',
|
|
2788
|
+
throw invalidRequest(
|
|
2775
2789
|
`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
|
|
2776
2790
|
);
|
|
2777
2791
|
}
|
|
@@ -2780,8 +2794,7 @@ export class SyncClient {
|
|
|
2780
2794
|
const value = normalized.get(column.name);
|
|
2781
2795
|
if (value === undefined || value === null) {
|
|
2782
2796
|
if (!column.nullable) {
|
|
2783
|
-
throw
|
|
2784
|
-
'sync.invalid_request',
|
|
2797
|
+
throw invalidRequest(
|
|
2785
2798
|
`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`,
|
|
2786
2799
|
);
|
|
2787
2800
|
}
|
|
@@ -2792,35 +2805,18 @@ export class SyncClient {
|
|
|
2792
2805
|
coerceSqlRepresentation(column, value) as RowValue,
|
|
2793
2806
|
);
|
|
2794
2807
|
}
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
);
|
|
2801
|
-
}
|
|
2802
|
-
return {
|
|
2803
|
-
table: mutation.table,
|
|
2804
|
-
rowId: pkValue,
|
|
2805
|
-
op: 'upsert',
|
|
2806
|
-
...(mutation.baseVersion !== undefined
|
|
2807
|
-
? { baseVersion: mutation.baseVersion }
|
|
2808
|
-
: {}),
|
|
2809
|
-
values: json,
|
|
2810
|
-
};
|
|
2808
|
+
} else {
|
|
2809
|
+
const values = recordToRowValues(table, mutation.values);
|
|
2810
|
+
table.columns.forEach((column, index) => {
|
|
2811
|
+
json[column.name] = rowValueToJson(values[index] ?? null);
|
|
2812
|
+
});
|
|
2811
2813
|
}
|
|
2812
|
-
const
|
|
2813
|
-
const pkValue = values[table.primaryKeyIndex];
|
|
2814
|
+
const pkValue = json[table.primaryKey];
|
|
2814
2815
|
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
2815
|
-
throw
|
|
2816
|
-
'sync.invalid_request',
|
|
2816
|
+
throw invalidRequest(
|
|
2817
2817
|
`table ${table.name}: upsert requires a non-empty string primary key`,
|
|
2818
2818
|
);
|
|
2819
2819
|
}
|
|
2820
|
-
const json: Record<string, ReturnType<typeof rowValueToJson>> = {};
|
|
2821
|
-
table.columns.forEach((column, index) => {
|
|
2822
|
-
json[column.name] = rowValueToJson(values[index] ?? null);
|
|
2823
|
-
});
|
|
2824
2820
|
return {
|
|
2825
2821
|
table: mutation.table,
|
|
2826
2822
|
rowId: pkValue,
|
|
@@ -2917,27 +2913,23 @@ export class SyncClient {
|
|
|
2917
2913
|
const normalized = normalizeRecordKeys(compiled, partial);
|
|
2918
2914
|
const pkOverride = normalized.get(pkColumn.name);
|
|
2919
2915
|
if (pkOverride !== undefined && pkOverride !== rowId) {
|
|
2920
|
-
throw
|
|
2921
|
-
'sync.invalid_request',
|
|
2916
|
+
throw invalidRequest(
|
|
2922
2917
|
`table ${compiled.name}: patch cannot change the primary key`,
|
|
2923
2918
|
);
|
|
2924
2919
|
}
|
|
2925
|
-
return this.#recordMutations(
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
[pkColumn.name]: rowId,
|
|
2933
|
-
},
|
|
2934
|
-
...(options?.baseVersion !== undefined
|
|
2935
|
-
? { baseVersion: options.baseVersion }
|
|
2936
|
-
: {}),
|
|
2920
|
+
return this.#recordMutations([
|
|
2921
|
+
{
|
|
2922
|
+
table,
|
|
2923
|
+
op: 'patch',
|
|
2924
|
+
values: {
|
|
2925
|
+
...Object.fromEntries(normalized),
|
|
2926
|
+
[pkColumn.name]: rowId,
|
|
2937
2927
|
},
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2928
|
+
...(options?.baseVersion !== undefined
|
|
2929
|
+
? { baseVersion: options.baseVersion }
|
|
2930
|
+
: {}),
|
|
2931
|
+
},
|
|
2932
|
+
]);
|
|
2941
2933
|
}
|
|
2942
2934
|
|
|
2943
2935
|
/**
|
|
@@ -2957,8 +2949,7 @@ export class SyncClient {
|
|
|
2957
2949
|
const appliedPlan = getMeta(this.#db, metaKey);
|
|
2958
2950
|
if (appliedPlan !== undefined) {
|
|
2959
2951
|
if (appliedPlan !== purge.canonicalPlan) {
|
|
2960
|
-
throw
|
|
2961
|
-
'sync.invalid_request',
|
|
2952
|
+
throw invalidRequest(
|
|
2962
2953
|
`local purge id ${JSON.stringify(purge.purgeId)} was already used with a different plan`,
|
|
2963
2954
|
);
|
|
2964
2955
|
}
|
|
@@ -2973,6 +2964,19 @@ export class SyncClient {
|
|
|
2973
2964
|
try {
|
|
2974
2965
|
return this.#applyBatch((batch) => {
|
|
2975
2966
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
2967
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
2968
|
+
const droppedRetained = dropFailedRows(this.#db, (table, values) =>
|
|
2969
|
+
(targetsByTable.get(table) ?? []).some((target) =>
|
|
2970
|
+
target.selectors.every((selector) =>
|
|
2971
|
+
selector.values.includes(String(values[selector.column])),
|
|
2972
|
+
),
|
|
2973
|
+
),
|
|
2974
|
+
);
|
|
2975
|
+
for (const commit of droppedRetained) {
|
|
2976
|
+
for (const operation of commit.operations)
|
|
2977
|
+
batch.table(operation.table);
|
|
2978
|
+
batch.outcomes();
|
|
2979
|
+
}
|
|
2976
2980
|
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
2977
2981
|
// a commit is doomed when any of its ops touches a base-matching
|
|
2978
2982
|
// row. Base values only become visible here as rollbacks restore
|
|
@@ -3074,6 +3078,7 @@ export class SyncClient {
|
|
|
3074
3078
|
batch.rejections();
|
|
3075
3079
|
batch.outcomes();
|
|
3076
3080
|
}
|
|
3081
|
+
this.#replayOutbox();
|
|
3077
3082
|
this.#deleteUnreferencedBlobs();
|
|
3078
3083
|
setMeta(this.#db, metaKey, purge.canonicalPlan);
|
|
3079
3084
|
return {
|
|
@@ -3116,8 +3121,7 @@ export class SyncClient {
|
|
|
3116
3121
|
};
|
|
3117
3122
|
}
|
|
3118
3123
|
if (this.#schemaFloor !== undefined) {
|
|
3119
|
-
throw
|
|
3120
|
-
'sync.invalid_request',
|
|
3124
|
+
throw invalidRequest(
|
|
3121
3125
|
'local rebootstrap cannot bypass an active schema-floor stop; update the application first',
|
|
3122
3126
|
);
|
|
3123
3127
|
}
|
|
@@ -3327,7 +3331,14 @@ export class SyncClient {
|
|
|
3327
3331
|
code: string = OUTBOX_INCOMPATIBLE_CODE,
|
|
3328
3332
|
): void {
|
|
3329
3333
|
this.#applyBatch((batch) => {
|
|
3334
|
+
if (this.#retainFailedCommits && code === 'client.encrypt_failed')
|
|
3335
|
+
retainFailedRows(
|
|
3336
|
+
this.#db,
|
|
3337
|
+
commit,
|
|
3338
|
+
listOutboxBeforeImages(this.#db, commit.clientCommitId),
|
|
3339
|
+
);
|
|
3330
3340
|
this.#rollbackFailedCommit(commit, batch);
|
|
3341
|
+
this.#replayOutbox();
|
|
3331
3342
|
const rejection: RejectionRecord = {
|
|
3332
3343
|
clientCommitId: commit.clientCommitId,
|
|
3333
3344
|
opIndex: 0,
|
|
@@ -3368,8 +3379,7 @@ export class SyncClient {
|
|
|
3368
3379
|
this.#requireActive();
|
|
3369
3380
|
if (this.#syncOutstanding) {
|
|
3370
3381
|
return Promise.reject(
|
|
3371
|
-
|
|
3372
|
-
'sync.invalid_request',
|
|
3382
|
+
invalidRequest(
|
|
3373
3383
|
'sync() is already running — the core owns one loop (coalesce wake-ups)',
|
|
3374
3384
|
),
|
|
3375
3385
|
);
|
|
@@ -3391,7 +3401,8 @@ export class SyncClient {
|
|
|
3391
3401
|
this.#emitDiagnostics();
|
|
3392
3402
|
return summary;
|
|
3393
3403
|
},
|
|
3394
|
-
(
|
|
3404
|
+
(caught: unknown) => {
|
|
3405
|
+
const error = classifySqliteFailure(caught).error;
|
|
3395
3406
|
const completedAtMs = this.#now();
|
|
3396
3407
|
const code = (error as { code?: unknown }).code;
|
|
3397
3408
|
this.#progress.update({
|
|
@@ -3544,10 +3555,7 @@ export class SyncClient {
|
|
|
3544
3555
|
const responseBytes = await this.#roundTrip(requestBytes);
|
|
3545
3556
|
const message = decodeMessage(responseBytes);
|
|
3546
3557
|
if (message.msgKind !== 'response') {
|
|
3547
|
-
throw
|
|
3548
|
-
'sync.invalid_request',
|
|
3549
|
-
'transport returned a non-response message',
|
|
3550
|
-
);
|
|
3558
|
+
throw invalidRequest('transport returned a non-response message');
|
|
3551
3559
|
}
|
|
3552
3560
|
const summary = await this.#processResponse(
|
|
3553
3561
|
message,
|
|
@@ -3652,10 +3660,7 @@ export class SyncClient {
|
|
|
3652
3660
|
return last;
|
|
3653
3661
|
}
|
|
3654
3662
|
}
|
|
3655
|
-
throw
|
|
3656
|
-
'sync.invalid_request',
|
|
3657
|
-
'sync did not reach idle within the round budget',
|
|
3658
|
-
);
|
|
3663
|
+
throw invalidRequest('sync did not reach idle within the round budget');
|
|
3659
3664
|
}
|
|
3660
3665
|
|
|
3661
3666
|
/**
|
|
@@ -3761,10 +3766,7 @@ export class SyncClient {
|
|
|
3761
3766
|
this.#requireActive();
|
|
3762
3767
|
if (this.#realtimePolicy === 'off') {
|
|
3763
3768
|
return Promise.reject(
|
|
3764
|
-
|
|
3765
|
-
'sync.invalid_request',
|
|
3766
|
-
"realtimePolicy is 'off'; realtime connect is refused",
|
|
3767
|
-
),
|
|
3769
|
+
invalidRequest("realtimePolicy is 'off'; realtime connect is refused"),
|
|
3768
3770
|
);
|
|
3769
3771
|
}
|
|
3770
3772
|
if (this.#socket !== undefined) return Promise.resolve();
|
|
@@ -3796,10 +3798,7 @@ export class SyncClient {
|
|
|
3796
3798
|
async #connectRealtime(generation: number): Promise<void> {
|
|
3797
3799
|
const connector = this.#config.realtime;
|
|
3798
3800
|
if (connector === undefined) {
|
|
3799
|
-
throw
|
|
3800
|
-
'sync.invalid_request',
|
|
3801
|
-
'no realtime connector configured',
|
|
3802
|
-
);
|
|
3801
|
+
throw invalidRequest('no realtime connector configured');
|
|
3803
3802
|
}
|
|
3804
3803
|
let openedSocket: RealtimeSocket | undefined;
|
|
3805
3804
|
try {
|
|
@@ -3885,8 +3884,7 @@ export class SyncClient {
|
|
|
3885
3884
|
} catch (error) {
|
|
3886
3885
|
this.#pendingRound = undefined;
|
|
3887
3886
|
round.reject(
|
|
3888
|
-
|
|
3889
|
-
'sync.invalid_request',
|
|
3887
|
+
invalidRequest(
|
|
3890
3888
|
`malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`,
|
|
3891
3889
|
),
|
|
3892
3890
|
);
|
|
@@ -3896,10 +3894,7 @@ export class SyncClient {
|
|
|
3896
3894
|
this.#pendingRound = undefined;
|
|
3897
3895
|
if (done.excess > 0) {
|
|
3898
3896
|
round.reject(
|
|
3899
|
-
|
|
3900
|
-
'sync.invalid_request',
|
|
3901
|
-
'response bytes past END of the round stream (§8.7)',
|
|
3902
|
-
),
|
|
3897
|
+
invalidRequest('response bytes past END of the round stream (§8.7)'),
|
|
3903
3898
|
);
|
|
3904
3899
|
return;
|
|
3905
3900
|
}
|
|
@@ -4063,7 +4058,7 @@ export class SyncClient {
|
|
|
4063
4058
|
|
|
4064
4059
|
const header = message.frames[0];
|
|
4065
4060
|
if (header?.type !== 'RESP_HEADER') {
|
|
4066
|
-
throw
|
|
4061
|
+
throw invalidRequest('missing RESP_HEADER');
|
|
4067
4062
|
}
|
|
4068
4063
|
if (
|
|
4069
4064
|
message.wireVersion < 2 ||
|
|
@@ -4290,8 +4285,7 @@ export class SyncClient {
|
|
|
4290
4285
|
frame.mediaType === 'sqlite' &&
|
|
4291
4286
|
(this.#acceptMask() & ACCEPT_SQLITE) === 0
|
|
4292
4287
|
) {
|
|
4293
|
-
throw
|
|
4294
|
-
'sync.invalid_request',
|
|
4288
|
+
throw invalidRequest(
|
|
4295
4289
|
'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
|
|
4296
4290
|
);
|
|
4297
4291
|
}
|
|
@@ -4304,8 +4298,7 @@ export class SyncClient {
|
|
|
4304
4298
|
frame.rowCursor !== undefined ||
|
|
4305
4299
|
frame.nextRowCursor !== undefined
|
|
4306
4300
|
) {
|
|
4307
|
-
throw
|
|
4308
|
-
'sync.invalid_request',
|
|
4301
|
+
throw invalidRequest(
|
|
4309
4302
|
'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)',
|
|
4310
4303
|
);
|
|
4311
4304
|
}
|
|
@@ -4569,6 +4562,12 @@ export class SyncClient {
|
|
|
4569
4562
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
4570
4563
|
// later pending overlays are then replayed with rebased before-images.
|
|
4571
4564
|
this.#db.transaction(() => {
|
|
4565
|
+
if (this.#retainFailedCommits)
|
|
4566
|
+
retainFailedRows(
|
|
4567
|
+
this.#db,
|
|
4568
|
+
commit,
|
|
4569
|
+
listOutboxBeforeImages(this.#db, commit.clientCommitId),
|
|
4570
|
+
);
|
|
4572
4571
|
this.#rollbackFailedCommit(commit, batch);
|
|
4573
4572
|
});
|
|
4574
4573
|
batch.status();
|
|
@@ -4768,8 +4767,7 @@ export class SyncClient {
|
|
|
4768
4767
|
): Promise<Uint8Array> {
|
|
4769
4768
|
const downloader = this.#config.segments;
|
|
4770
4769
|
if (downloader === undefined) {
|
|
4771
|
-
throw
|
|
4772
|
-
'sync.invalid_request',
|
|
4770
|
+
throw invalidRequest(
|
|
4773
4771
|
'received SEGMENT_REF but no segment downloader is configured',
|
|
4774
4772
|
);
|
|
4775
4773
|
}
|
|
@@ -4797,8 +4795,7 @@ export class SyncClient {
|
|
|
4797
4795
|
fetchUrl === undefined ||
|
|
4798
4796
|
(this.#acceptMask() & ACCEPT_SIGNED_URLS) === 0
|
|
4799
4797
|
) {
|
|
4800
|
-
throw
|
|
4801
|
-
'sync.invalid_request',
|
|
4798
|
+
throw invalidRequest(
|
|
4802
4799
|
'SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)',
|
|
4803
4800
|
);
|
|
4804
4801
|
}
|
|
@@ -4828,8 +4825,7 @@ export class SyncClient {
|
|
|
4828
4825
|
onProgress(bytes.byteLength);
|
|
4829
4826
|
const hash = await sha256Hex(bytes);
|
|
4830
4827
|
if (`sha256:${hash}` !== frame.segmentId) {
|
|
4831
|
-
throw
|
|
4832
|
-
'sync.invalid_request',
|
|
4828
|
+
throw invalidRequest(
|
|
4833
4829
|
`segment ${frame.segmentId} failed content-address verification (§5.1)`,
|
|
4834
4830
|
true,
|
|
4835
4831
|
);
|
|
@@ -4911,6 +4907,22 @@ export class SyncClient {
|
|
|
4911
4907
|
Object.keys(lastEffective).length > 0
|
|
4912
4908
|
) {
|
|
4913
4909
|
try {
|
|
4910
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
4911
|
+
const droppedRetained = dropFailedRows(
|
|
4912
|
+
this.#db,
|
|
4913
|
+
(name, values) =>
|
|
4914
|
+
name === table.name &&
|
|
4915
|
+
Object.entries(lastEffective).every(([variable, allowed]) =>
|
|
4916
|
+
allowed.includes(
|
|
4917
|
+
String(values[table.scopeColumnByVariable.get(variable)!]),
|
|
4918
|
+
),
|
|
4919
|
+
),
|
|
4920
|
+
);
|
|
4921
|
+
for (const commit of droppedRetained) {
|
|
4922
|
+
for (const operation of commit.operations)
|
|
4923
|
+
batch.table(operation.table);
|
|
4924
|
+
batch.outcomes();
|
|
4925
|
+
}
|
|
4914
4926
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
4915
4927
|
batch.scopeMap(table, lastEffective);
|
|
4916
4928
|
const pendingById = new Map(
|
|
@@ -5054,7 +5066,7 @@ export class SyncClient {
|
|
|
5054
5066
|
const values = table.columns.map((column) =>
|
|
5055
5067
|
jsonToRowValue(image.values?.[column.name] ?? null),
|
|
5056
5068
|
);
|
|
5057
|
-
upsertLocalRow(this.#db, table, values, image.syncVersion);
|
|
5069
|
+
upsertLocalRow(this.#db, table, values, image.syncVersion, false);
|
|
5058
5070
|
}
|
|
5059
5071
|
|
|
5060
5072
|
#legacyUndoOptimisticRows(
|
|
@@ -5131,7 +5143,7 @@ export class SyncClient {
|
|
|
5131
5143
|
: this.#recordStoredRowScopes(batch, table, op.rowId);
|
|
5132
5144
|
if (op.op === 'delete') {
|
|
5133
5145
|
if (batch !== undefined && !precise) batch.table(op.table);
|
|
5134
|
-
deleteLocalRow(this.#db, table, op.rowId);
|
|
5146
|
+
deleteLocalRow(this.#db, table, op.rowId, false);
|
|
5135
5147
|
continue;
|
|
5136
5148
|
}
|
|
5137
5149
|
// §7.1 overlay: apply the operation's PRESENT columns over the current
|
|
@@ -5172,19 +5184,23 @@ export class SyncClient {
|
|
|
5172
5184
|
local === undefined
|
|
5173
5185
|
? OPTIMISTIC_VERSION
|
|
5174
5186
|
: (local[SYNC_VERSION_COLUMN] as number);
|
|
5175
|
-
upsertLocalRow(this.#db, table, values, version);
|
|
5187
|
+
upsertLocalRow(this.#db, table, values, version, false);
|
|
5176
5188
|
}
|
|
5177
5189
|
}
|
|
5178
5190
|
|
|
5179
5191
|
/** Re-apply every pending outbox commit on top of server state (§7.1). */
|
|
5180
5192
|
#replayOutbox(): void {
|
|
5181
|
-
const pending =
|
|
5193
|
+
const pending = [
|
|
5194
|
+
...failedOverlayCommits(this.#db),
|
|
5195
|
+
...listOutbox(this.#db),
|
|
5196
|
+
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
5182
5197
|
if (pending.length === 0) return;
|
|
5183
5198
|
this.#applyBatch((batch) => {
|
|
5184
5199
|
this.#db.transaction(() => {
|
|
5185
|
-
|
|
5200
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
5201
|
+
restoreFailedBases(this.#db, this.#schema, true);
|
|
5202
|
+
for (const commit of pending)
|
|
5186
5203
|
this.#applyOperationsLocally(commit.operations, batch);
|
|
5187
|
-
}
|
|
5188
5204
|
});
|
|
5189
5205
|
});
|
|
5190
5206
|
}
|
|
@@ -5209,10 +5225,7 @@ export class SyncClient {
|
|
|
5209
5225
|
|
|
5210
5226
|
#requireStarted(): void {
|
|
5211
5227
|
if (!this.#started) {
|
|
5212
|
-
throw
|
|
5213
|
-
'sync.invalid_request',
|
|
5214
|
-
'SyncClient.start() has not completed',
|
|
5215
|
-
);
|
|
5228
|
+
throw invalidRequest('SyncClient.start() has not completed');
|
|
5216
5229
|
}
|
|
5217
5230
|
}
|
|
5218
5231
|
|