@syncular/client 0.30.7 → 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 +112 -74
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +4 -0
- 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 +3 -0
- 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 +182 -170
- package/src/errors.ts +8 -0
- 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 +3 -0
- 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
|
);
|
|
@@ -3545,10 +3555,7 @@ export class SyncClient {
|
|
|
3545
3555
|
const responseBytes = await this.#roundTrip(requestBytes);
|
|
3546
3556
|
const message = decodeMessage(responseBytes);
|
|
3547
3557
|
if (message.msgKind !== 'response') {
|
|
3548
|
-
throw
|
|
3549
|
-
'sync.invalid_request',
|
|
3550
|
-
'transport returned a non-response message',
|
|
3551
|
-
);
|
|
3558
|
+
throw invalidRequest('transport returned a non-response message');
|
|
3552
3559
|
}
|
|
3553
3560
|
const summary = await this.#processResponse(
|
|
3554
3561
|
message,
|
|
@@ -3653,10 +3660,7 @@ export class SyncClient {
|
|
|
3653
3660
|
return last;
|
|
3654
3661
|
}
|
|
3655
3662
|
}
|
|
3656
|
-
throw
|
|
3657
|
-
'sync.invalid_request',
|
|
3658
|
-
'sync did not reach idle within the round budget',
|
|
3659
|
-
);
|
|
3663
|
+
throw invalidRequest('sync did not reach idle within the round budget');
|
|
3660
3664
|
}
|
|
3661
3665
|
|
|
3662
3666
|
/**
|
|
@@ -3762,10 +3766,7 @@ export class SyncClient {
|
|
|
3762
3766
|
this.#requireActive();
|
|
3763
3767
|
if (this.#realtimePolicy === 'off') {
|
|
3764
3768
|
return Promise.reject(
|
|
3765
|
-
|
|
3766
|
-
'sync.invalid_request',
|
|
3767
|
-
"realtimePolicy is 'off'; realtime connect is refused",
|
|
3768
|
-
),
|
|
3769
|
+
invalidRequest("realtimePolicy is 'off'; realtime connect is refused"),
|
|
3769
3770
|
);
|
|
3770
3771
|
}
|
|
3771
3772
|
if (this.#socket !== undefined) return Promise.resolve();
|
|
@@ -3797,10 +3798,7 @@ export class SyncClient {
|
|
|
3797
3798
|
async #connectRealtime(generation: number): Promise<void> {
|
|
3798
3799
|
const connector = this.#config.realtime;
|
|
3799
3800
|
if (connector === undefined) {
|
|
3800
|
-
throw
|
|
3801
|
-
'sync.invalid_request',
|
|
3802
|
-
'no realtime connector configured',
|
|
3803
|
-
);
|
|
3801
|
+
throw invalidRequest('no realtime connector configured');
|
|
3804
3802
|
}
|
|
3805
3803
|
let openedSocket: RealtimeSocket | undefined;
|
|
3806
3804
|
try {
|
|
@@ -3886,8 +3884,7 @@ export class SyncClient {
|
|
|
3886
3884
|
} catch (error) {
|
|
3887
3885
|
this.#pendingRound = undefined;
|
|
3888
3886
|
round.reject(
|
|
3889
|
-
|
|
3890
|
-
'sync.invalid_request',
|
|
3887
|
+
invalidRequest(
|
|
3891
3888
|
`malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`,
|
|
3892
3889
|
),
|
|
3893
3890
|
);
|
|
@@ -3897,10 +3894,7 @@ export class SyncClient {
|
|
|
3897
3894
|
this.#pendingRound = undefined;
|
|
3898
3895
|
if (done.excess > 0) {
|
|
3899
3896
|
round.reject(
|
|
3900
|
-
|
|
3901
|
-
'sync.invalid_request',
|
|
3902
|
-
'response bytes past END of the round stream (§8.7)',
|
|
3903
|
-
),
|
|
3897
|
+
invalidRequest('response bytes past END of the round stream (§8.7)'),
|
|
3904
3898
|
);
|
|
3905
3899
|
return;
|
|
3906
3900
|
}
|
|
@@ -4064,7 +4058,7 @@ export class SyncClient {
|
|
|
4064
4058
|
|
|
4065
4059
|
const header = message.frames[0];
|
|
4066
4060
|
if (header?.type !== 'RESP_HEADER') {
|
|
4067
|
-
throw
|
|
4061
|
+
throw invalidRequest('missing RESP_HEADER');
|
|
4068
4062
|
}
|
|
4069
4063
|
if (
|
|
4070
4064
|
message.wireVersion < 2 ||
|
|
@@ -4291,8 +4285,7 @@ export class SyncClient {
|
|
|
4291
4285
|
frame.mediaType === 'sqlite' &&
|
|
4292
4286
|
(this.#acceptMask() & ACCEPT_SQLITE) === 0
|
|
4293
4287
|
) {
|
|
4294
|
-
throw
|
|
4295
|
-
'sync.invalid_request',
|
|
4288
|
+
throw invalidRequest(
|
|
4296
4289
|
'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
|
|
4297
4290
|
);
|
|
4298
4291
|
}
|
|
@@ -4305,8 +4298,7 @@ export class SyncClient {
|
|
|
4305
4298
|
frame.rowCursor !== undefined ||
|
|
4306
4299
|
frame.nextRowCursor !== undefined
|
|
4307
4300
|
) {
|
|
4308
|
-
throw
|
|
4309
|
-
'sync.invalid_request',
|
|
4301
|
+
throw invalidRequest(
|
|
4310
4302
|
'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)',
|
|
4311
4303
|
);
|
|
4312
4304
|
}
|
|
@@ -4570,6 +4562,12 @@ export class SyncClient {
|
|
|
4570
4562
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
4571
4563
|
// later pending overlays are then replayed with rebased before-images.
|
|
4572
4564
|
this.#db.transaction(() => {
|
|
4565
|
+
if (this.#retainFailedCommits)
|
|
4566
|
+
retainFailedRows(
|
|
4567
|
+
this.#db,
|
|
4568
|
+
commit,
|
|
4569
|
+
listOutboxBeforeImages(this.#db, commit.clientCommitId),
|
|
4570
|
+
);
|
|
4573
4571
|
this.#rollbackFailedCommit(commit, batch);
|
|
4574
4572
|
});
|
|
4575
4573
|
batch.status();
|
|
@@ -4769,8 +4767,7 @@ export class SyncClient {
|
|
|
4769
4767
|
): Promise<Uint8Array> {
|
|
4770
4768
|
const downloader = this.#config.segments;
|
|
4771
4769
|
if (downloader === undefined) {
|
|
4772
|
-
throw
|
|
4773
|
-
'sync.invalid_request',
|
|
4770
|
+
throw invalidRequest(
|
|
4774
4771
|
'received SEGMENT_REF but no segment downloader is configured',
|
|
4775
4772
|
);
|
|
4776
4773
|
}
|
|
@@ -4798,8 +4795,7 @@ export class SyncClient {
|
|
|
4798
4795
|
fetchUrl === undefined ||
|
|
4799
4796
|
(this.#acceptMask() & ACCEPT_SIGNED_URLS) === 0
|
|
4800
4797
|
) {
|
|
4801
|
-
throw
|
|
4802
|
-
'sync.invalid_request',
|
|
4798
|
+
throw invalidRequest(
|
|
4803
4799
|
'SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)',
|
|
4804
4800
|
);
|
|
4805
4801
|
}
|
|
@@ -4829,8 +4825,7 @@ export class SyncClient {
|
|
|
4829
4825
|
onProgress(bytes.byteLength);
|
|
4830
4826
|
const hash = await sha256Hex(bytes);
|
|
4831
4827
|
if (`sha256:${hash}` !== frame.segmentId) {
|
|
4832
|
-
throw
|
|
4833
|
-
'sync.invalid_request',
|
|
4828
|
+
throw invalidRequest(
|
|
4834
4829
|
`segment ${frame.segmentId} failed content-address verification (§5.1)`,
|
|
4835
4830
|
true,
|
|
4836
4831
|
);
|
|
@@ -4912,6 +4907,22 @@ export class SyncClient {
|
|
|
4912
4907
|
Object.keys(lastEffective).length > 0
|
|
4913
4908
|
) {
|
|
4914
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
|
+
}
|
|
4915
4926
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
4916
4927
|
batch.scopeMap(table, lastEffective);
|
|
4917
4928
|
const pendingById = new Map(
|
|
@@ -5055,7 +5066,7 @@ export class SyncClient {
|
|
|
5055
5066
|
const values = table.columns.map((column) =>
|
|
5056
5067
|
jsonToRowValue(image.values?.[column.name] ?? null),
|
|
5057
5068
|
);
|
|
5058
|
-
upsertLocalRow(this.#db, table, values, image.syncVersion);
|
|
5069
|
+
upsertLocalRow(this.#db, table, values, image.syncVersion, false);
|
|
5059
5070
|
}
|
|
5060
5071
|
|
|
5061
5072
|
#legacyUndoOptimisticRows(
|
|
@@ -5132,7 +5143,7 @@ export class SyncClient {
|
|
|
5132
5143
|
: this.#recordStoredRowScopes(batch, table, op.rowId);
|
|
5133
5144
|
if (op.op === 'delete') {
|
|
5134
5145
|
if (batch !== undefined && !precise) batch.table(op.table);
|
|
5135
|
-
deleteLocalRow(this.#db, table, op.rowId);
|
|
5146
|
+
deleteLocalRow(this.#db, table, op.rowId, false);
|
|
5136
5147
|
continue;
|
|
5137
5148
|
}
|
|
5138
5149
|
// §7.1 overlay: apply the operation's PRESENT columns over the current
|
|
@@ -5173,19 +5184,23 @@ export class SyncClient {
|
|
|
5173
5184
|
local === undefined
|
|
5174
5185
|
? OPTIMISTIC_VERSION
|
|
5175
5186
|
: (local[SYNC_VERSION_COLUMN] as number);
|
|
5176
|
-
upsertLocalRow(this.#db, table, values, version);
|
|
5187
|
+
upsertLocalRow(this.#db, table, values, version, false);
|
|
5177
5188
|
}
|
|
5178
5189
|
}
|
|
5179
5190
|
|
|
5180
5191
|
/** Re-apply every pending outbox commit on top of server state (§7.1). */
|
|
5181
5192
|
#replayOutbox(): void {
|
|
5182
|
-
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);
|
|
5183
5197
|
if (pending.length === 0) return;
|
|
5184
5198
|
this.#applyBatch((batch) => {
|
|
5185
5199
|
this.#db.transaction(() => {
|
|
5186
|
-
|
|
5200
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
5201
|
+
restoreFailedBases(this.#db, this.#schema, true);
|
|
5202
|
+
for (const commit of pending)
|
|
5187
5203
|
this.#applyOperationsLocally(commit.operations, batch);
|
|
5188
|
-
}
|
|
5189
5204
|
});
|
|
5190
5205
|
});
|
|
5191
5206
|
}
|
|
@@ -5210,10 +5225,7 @@ export class SyncClient {
|
|
|
5210
5225
|
|
|
5211
5226
|
#requireStarted(): void {
|
|
5212
5227
|
if (!this.#started) {
|
|
5213
|
-
throw
|
|
5214
|
-
'sync.invalid_request',
|
|
5215
|
-
'SyncClient.start() has not completed',
|
|
5216
|
-
);
|
|
5228
|
+
throw invalidRequest('SyncClient.start() has not completed');
|
|
5217
5229
|
}
|
|
5218
5230
|
}
|
|
5219
5231
|
|