@syncular/client 0.30.7 → 0.30.10
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 +16 -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 +120 -81
- 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 +102 -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 +5 -4
- package/src/apply.ts +46 -13
- package/src/client.ts +197 -183
- package/src/errors.ts +8 -0
- package/src/failed-overlay.ts +174 -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,16 @@ 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
|
|
2753
|
+
const pkValue = requireRowId(normalized.get(pkColumn.name));
|
|
2754
|
+
const local = this.#db.query(
|
|
2755
|
+
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
2756
|
+
[pkValue],
|
|
2757
|
+
)[0];
|
|
2758
|
+
if (!local)
|
|
2759
|
+
throw new ClientSyncError(
|
|
2760
|
+
'sync.row_missing',
|
|
2761
|
+
'a sparse patch requires a local row',
|
|
2762
|
+
);
|
|
2738
2763
|
for (const column of table.columns) {
|
|
2739
2764
|
if (!normalized.has(column.name)) continue;
|
|
2740
2765
|
// §3.4 rule 5 / §6.2: scope columns are immutable on update. The
|
|
@@ -2748,16 +2773,7 @@ export class SyncClient {
|
|
|
2748
2773
|
// by construction the row id being patched (§6.1), so its value is
|
|
2749
2774
|
// proven equal already.
|
|
2750
2775
|
if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
|
|
2751
|
-
const pkValue = normalized.get(pkColumn.name);
|
|
2752
|
-
const local =
|
|
2753
|
-
typeof pkValue === 'string' && pkValue.length > 0
|
|
2754
|
-
? this.#db.query(
|
|
2755
|
-
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
2756
|
-
[pkValue],
|
|
2757
|
-
)[0]
|
|
2758
|
-
: undefined;
|
|
2759
2776
|
if (
|
|
2760
|
-
local === undefined ||
|
|
2761
2777
|
!Object.is(
|
|
2762
2778
|
rowValueToJson(
|
|
2763
2779
|
fromSqlValue(column, local[column.name] ?? null),
|
|
@@ -2770,8 +2786,7 @@ export class SyncClient {
|
|
|
2770
2786
|
),
|
|
2771
2787
|
)
|
|
2772
2788
|
) {
|
|
2773
|
-
throw
|
|
2774
|
-
'sync.invalid_request',
|
|
2789
|
+
throw invalidRequest(
|
|
2775
2790
|
`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
|
|
2776
2791
|
);
|
|
2777
2792
|
}
|
|
@@ -2780,8 +2795,7 @@ export class SyncClient {
|
|
|
2780
2795
|
const value = normalized.get(column.name);
|
|
2781
2796
|
if (value === undefined || value === null) {
|
|
2782
2797
|
if (!column.nullable) {
|
|
2783
|
-
throw
|
|
2784
|
-
'sync.invalid_request',
|
|
2798
|
+
throw invalidRequest(
|
|
2785
2799
|
`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`,
|
|
2786
2800
|
);
|
|
2787
2801
|
}
|
|
@@ -2792,35 +2806,13 @@ export class SyncClient {
|
|
|
2792
2806
|
coerceSqlRepresentation(column, value) as RowValue,
|
|
2793
2807
|
);
|
|
2794
2808
|
}
|
|
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
|
-
};
|
|
2811
|
-
}
|
|
2812
|
-
const values = recordToRowValues(table, mutation.values);
|
|
2813
|
-
const pkValue = values[table.primaryKeyIndex];
|
|
2814
|
-
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
2815
|
-
throw new ClientSyncError(
|
|
2816
|
-
'sync.invalid_request',
|
|
2817
|
-
`table ${table.name}: upsert requires a non-empty string primary key`,
|
|
2818
|
-
);
|
|
2809
|
+
} else {
|
|
2810
|
+
const values = recordToRowValues(table, mutation.values);
|
|
2811
|
+
table.columns.forEach((column, index) => {
|
|
2812
|
+
json[column.name] = rowValueToJson(values[index] ?? null);
|
|
2813
|
+
});
|
|
2819
2814
|
}
|
|
2820
|
-
const
|
|
2821
|
-
table.columns.forEach((column, index) => {
|
|
2822
|
-
json[column.name] = rowValueToJson(values[index] ?? null);
|
|
2823
|
-
});
|
|
2815
|
+
const pkValue = requireRowId(json[table.primaryKey]);
|
|
2824
2816
|
return {
|
|
2825
2817
|
table: mutation.table,
|
|
2826
2818
|
rowId: pkValue,
|
|
@@ -2917,27 +2909,23 @@ export class SyncClient {
|
|
|
2917
2909
|
const normalized = normalizeRecordKeys(compiled, partial);
|
|
2918
2910
|
const pkOverride = normalized.get(pkColumn.name);
|
|
2919
2911
|
if (pkOverride !== undefined && pkOverride !== rowId) {
|
|
2920
|
-
throw
|
|
2921
|
-
'sync.invalid_request',
|
|
2912
|
+
throw invalidRequest(
|
|
2922
2913
|
`table ${compiled.name}: patch cannot change the primary key`,
|
|
2923
2914
|
);
|
|
2924
2915
|
}
|
|
2925
|
-
return this.#recordMutations(
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
[pkColumn.name]: rowId,
|
|
2933
|
-
},
|
|
2934
|
-
...(options?.baseVersion !== undefined
|
|
2935
|
-
? { baseVersion: options.baseVersion }
|
|
2936
|
-
: {}),
|
|
2916
|
+
return this.#recordMutations([
|
|
2917
|
+
{
|
|
2918
|
+
table,
|
|
2919
|
+
op: 'patch',
|
|
2920
|
+
values: {
|
|
2921
|
+
...Object.fromEntries(normalized),
|
|
2922
|
+
[pkColumn.name]: rowId,
|
|
2937
2923
|
},
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2924
|
+
...(options?.baseVersion !== undefined
|
|
2925
|
+
? { baseVersion: options.baseVersion }
|
|
2926
|
+
: {}),
|
|
2927
|
+
},
|
|
2928
|
+
]);
|
|
2941
2929
|
}
|
|
2942
2930
|
|
|
2943
2931
|
/**
|
|
@@ -2957,8 +2945,7 @@ export class SyncClient {
|
|
|
2957
2945
|
const appliedPlan = getMeta(this.#db, metaKey);
|
|
2958
2946
|
if (appliedPlan !== undefined) {
|
|
2959
2947
|
if (appliedPlan !== purge.canonicalPlan) {
|
|
2960
|
-
throw
|
|
2961
|
-
'sync.invalid_request',
|
|
2948
|
+
throw invalidRequest(
|
|
2962
2949
|
`local purge id ${JSON.stringify(purge.purgeId)} was already used with a different plan`,
|
|
2963
2950
|
);
|
|
2964
2951
|
}
|
|
@@ -2973,6 +2960,19 @@ export class SyncClient {
|
|
|
2973
2960
|
try {
|
|
2974
2961
|
return this.#applyBatch((batch) => {
|
|
2975
2962
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
2963
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
2964
|
+
const droppedRetained = dropFailedRows(this.#db, (table, values) =>
|
|
2965
|
+
(targetsByTable.get(table) ?? []).some((target) =>
|
|
2966
|
+
target.selectors.every((selector) =>
|
|
2967
|
+
selector.values.includes(String(values[selector.column])),
|
|
2968
|
+
),
|
|
2969
|
+
),
|
|
2970
|
+
);
|
|
2971
|
+
for (const commit of droppedRetained) {
|
|
2972
|
+
for (const operation of commit.operations)
|
|
2973
|
+
batch.table(operation.table);
|
|
2974
|
+
batch.outcomes();
|
|
2975
|
+
}
|
|
2976
2976
|
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
2977
2977
|
// a commit is doomed when any of its ops touches a base-matching
|
|
2978
2978
|
// row. Base values only become visible here as rollbacks restore
|
|
@@ -3074,6 +3074,7 @@ export class SyncClient {
|
|
|
3074
3074
|
batch.rejections();
|
|
3075
3075
|
batch.outcomes();
|
|
3076
3076
|
}
|
|
3077
|
+
this.#replayOutbox();
|
|
3077
3078
|
this.#deleteUnreferencedBlobs();
|
|
3078
3079
|
setMeta(this.#db, metaKey, purge.canonicalPlan);
|
|
3079
3080
|
return {
|
|
@@ -3116,8 +3117,7 @@ export class SyncClient {
|
|
|
3116
3117
|
};
|
|
3117
3118
|
}
|
|
3118
3119
|
if (this.#schemaFloor !== undefined) {
|
|
3119
|
-
throw
|
|
3120
|
-
'sync.invalid_request',
|
|
3120
|
+
throw invalidRequest(
|
|
3121
3121
|
'local rebootstrap cannot bypass an active schema-floor stop; update the application first',
|
|
3122
3122
|
);
|
|
3123
3123
|
}
|
|
@@ -3327,7 +3327,14 @@ export class SyncClient {
|
|
|
3327
3327
|
code: string = OUTBOX_INCOMPATIBLE_CODE,
|
|
3328
3328
|
): void {
|
|
3329
3329
|
this.#applyBatch((batch) => {
|
|
3330
|
+
if (this.#retainFailedCommits && code === 'client.encrypt_failed')
|
|
3331
|
+
retainFailedRows(
|
|
3332
|
+
this.#db,
|
|
3333
|
+
commit,
|
|
3334
|
+
listOutboxBeforeImages(this.#db, commit.clientCommitId),
|
|
3335
|
+
);
|
|
3330
3336
|
this.#rollbackFailedCommit(commit, batch);
|
|
3337
|
+
this.#replayOutbox();
|
|
3331
3338
|
const rejection: RejectionRecord = {
|
|
3332
3339
|
clientCommitId: commit.clientCommitId,
|
|
3333
3340
|
opIndex: 0,
|
|
@@ -3368,8 +3375,7 @@ export class SyncClient {
|
|
|
3368
3375
|
this.#requireActive();
|
|
3369
3376
|
if (this.#syncOutstanding) {
|
|
3370
3377
|
return Promise.reject(
|
|
3371
|
-
|
|
3372
|
-
'sync.invalid_request',
|
|
3378
|
+
invalidRequest(
|
|
3373
3379
|
'sync() is already running — the core owns one loop (coalesce wake-ups)',
|
|
3374
3380
|
),
|
|
3375
3381
|
);
|
|
@@ -3545,10 +3551,7 @@ export class SyncClient {
|
|
|
3545
3551
|
const responseBytes = await this.#roundTrip(requestBytes);
|
|
3546
3552
|
const message = decodeMessage(responseBytes);
|
|
3547
3553
|
if (message.msgKind !== 'response') {
|
|
3548
|
-
throw
|
|
3549
|
-
'sync.invalid_request',
|
|
3550
|
-
'transport returned a non-response message',
|
|
3551
|
-
);
|
|
3554
|
+
throw invalidRequest('transport returned a non-response message');
|
|
3552
3555
|
}
|
|
3553
3556
|
const summary = await this.#processResponse(
|
|
3554
3557
|
message,
|
|
@@ -3653,10 +3656,7 @@ export class SyncClient {
|
|
|
3653
3656
|
return last;
|
|
3654
3657
|
}
|
|
3655
3658
|
}
|
|
3656
|
-
throw
|
|
3657
|
-
'sync.invalid_request',
|
|
3658
|
-
'sync did not reach idle within the round budget',
|
|
3659
|
-
);
|
|
3659
|
+
throw invalidRequest('sync did not reach idle within the round budget');
|
|
3660
3660
|
}
|
|
3661
3661
|
|
|
3662
3662
|
/**
|
|
@@ -3762,10 +3762,7 @@ export class SyncClient {
|
|
|
3762
3762
|
this.#requireActive();
|
|
3763
3763
|
if (this.#realtimePolicy === 'off') {
|
|
3764
3764
|
return Promise.reject(
|
|
3765
|
-
|
|
3766
|
-
'sync.invalid_request',
|
|
3767
|
-
"realtimePolicy is 'off'; realtime connect is refused",
|
|
3768
|
-
),
|
|
3765
|
+
invalidRequest("realtimePolicy is 'off'; realtime connect is refused"),
|
|
3769
3766
|
);
|
|
3770
3767
|
}
|
|
3771
3768
|
if (this.#socket !== undefined) return Promise.resolve();
|
|
@@ -3797,10 +3794,7 @@ export class SyncClient {
|
|
|
3797
3794
|
async #connectRealtime(generation: number): Promise<void> {
|
|
3798
3795
|
const connector = this.#config.realtime;
|
|
3799
3796
|
if (connector === undefined) {
|
|
3800
|
-
throw
|
|
3801
|
-
'sync.invalid_request',
|
|
3802
|
-
'no realtime connector configured',
|
|
3803
|
-
);
|
|
3797
|
+
throw invalidRequest('no realtime connector configured');
|
|
3804
3798
|
}
|
|
3805
3799
|
let openedSocket: RealtimeSocket | undefined;
|
|
3806
3800
|
try {
|
|
@@ -3886,8 +3880,7 @@ export class SyncClient {
|
|
|
3886
3880
|
} catch (error) {
|
|
3887
3881
|
this.#pendingRound = undefined;
|
|
3888
3882
|
round.reject(
|
|
3889
|
-
|
|
3890
|
-
'sync.invalid_request',
|
|
3883
|
+
invalidRequest(
|
|
3891
3884
|
`malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`,
|
|
3892
3885
|
),
|
|
3893
3886
|
);
|
|
@@ -3897,10 +3890,7 @@ export class SyncClient {
|
|
|
3897
3890
|
this.#pendingRound = undefined;
|
|
3898
3891
|
if (done.excess > 0) {
|
|
3899
3892
|
round.reject(
|
|
3900
|
-
|
|
3901
|
-
'sync.invalid_request',
|
|
3902
|
-
'response bytes past END of the round stream (§8.7)',
|
|
3903
|
-
),
|
|
3893
|
+
invalidRequest('response bytes past END of the round stream (§8.7)'),
|
|
3904
3894
|
);
|
|
3905
3895
|
return;
|
|
3906
3896
|
}
|
|
@@ -4064,7 +4054,7 @@ export class SyncClient {
|
|
|
4064
4054
|
|
|
4065
4055
|
const header = message.frames[0];
|
|
4066
4056
|
if (header?.type !== 'RESP_HEADER') {
|
|
4067
|
-
throw
|
|
4057
|
+
throw invalidRequest('missing RESP_HEADER');
|
|
4068
4058
|
}
|
|
4069
4059
|
if (
|
|
4070
4060
|
message.wireVersion < 2 ||
|
|
@@ -4291,8 +4281,7 @@ export class SyncClient {
|
|
|
4291
4281
|
frame.mediaType === 'sqlite' &&
|
|
4292
4282
|
(this.#acceptMask() & ACCEPT_SQLITE) === 0
|
|
4293
4283
|
) {
|
|
4294
|
-
throw
|
|
4295
|
-
'sync.invalid_request',
|
|
4284
|
+
throw invalidRequest(
|
|
4296
4285
|
'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
|
|
4297
4286
|
);
|
|
4298
4287
|
}
|
|
@@ -4305,8 +4294,7 @@ export class SyncClient {
|
|
|
4305
4294
|
frame.rowCursor !== undefined ||
|
|
4306
4295
|
frame.nextRowCursor !== undefined
|
|
4307
4296
|
) {
|
|
4308
|
-
throw
|
|
4309
|
-
'sync.invalid_request',
|
|
4297
|
+
throw invalidRequest(
|
|
4310
4298
|
'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)',
|
|
4311
4299
|
);
|
|
4312
4300
|
}
|
|
@@ -4570,6 +4558,12 @@ export class SyncClient {
|
|
|
4570
4558
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
4571
4559
|
// later pending overlays are then replayed with rebased before-images.
|
|
4572
4560
|
this.#db.transaction(() => {
|
|
4561
|
+
if (this.#retainFailedCommits)
|
|
4562
|
+
retainFailedRows(
|
|
4563
|
+
this.#db,
|
|
4564
|
+
commit,
|
|
4565
|
+
listOutboxBeforeImages(this.#db, commit.clientCommitId),
|
|
4566
|
+
);
|
|
4573
4567
|
this.#rollbackFailedCommit(commit, batch);
|
|
4574
4568
|
});
|
|
4575
4569
|
batch.status();
|
|
@@ -4769,8 +4763,7 @@ export class SyncClient {
|
|
|
4769
4763
|
): Promise<Uint8Array> {
|
|
4770
4764
|
const downloader = this.#config.segments;
|
|
4771
4765
|
if (downloader === undefined) {
|
|
4772
|
-
throw
|
|
4773
|
-
'sync.invalid_request',
|
|
4766
|
+
throw invalidRequest(
|
|
4774
4767
|
'received SEGMENT_REF but no segment downloader is configured',
|
|
4775
4768
|
);
|
|
4776
4769
|
}
|
|
@@ -4798,8 +4791,7 @@ export class SyncClient {
|
|
|
4798
4791
|
fetchUrl === undefined ||
|
|
4799
4792
|
(this.#acceptMask() & ACCEPT_SIGNED_URLS) === 0
|
|
4800
4793
|
) {
|
|
4801
|
-
throw
|
|
4802
|
-
'sync.invalid_request',
|
|
4794
|
+
throw invalidRequest(
|
|
4803
4795
|
'SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)',
|
|
4804
4796
|
);
|
|
4805
4797
|
}
|
|
@@ -4829,8 +4821,7 @@ export class SyncClient {
|
|
|
4829
4821
|
onProgress(bytes.byteLength);
|
|
4830
4822
|
const hash = await sha256Hex(bytes);
|
|
4831
4823
|
if (`sha256:${hash}` !== frame.segmentId) {
|
|
4832
|
-
throw
|
|
4833
|
-
'sync.invalid_request',
|
|
4824
|
+
throw invalidRequest(
|
|
4834
4825
|
`segment ${frame.segmentId} failed content-address verification (§5.1)`,
|
|
4835
4826
|
true,
|
|
4836
4827
|
);
|
|
@@ -4912,6 +4903,22 @@ export class SyncClient {
|
|
|
4912
4903
|
Object.keys(lastEffective).length > 0
|
|
4913
4904
|
) {
|
|
4914
4905
|
try {
|
|
4906
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
4907
|
+
const droppedRetained = dropFailedRows(
|
|
4908
|
+
this.#db,
|
|
4909
|
+
(name, values) =>
|
|
4910
|
+
name === table.name &&
|
|
4911
|
+
Object.entries(lastEffective).every(([variable, allowed]) =>
|
|
4912
|
+
allowed.includes(
|
|
4913
|
+
String(values[table.scopeColumnByVariable.get(variable)!]),
|
|
4914
|
+
),
|
|
4915
|
+
),
|
|
4916
|
+
);
|
|
4917
|
+
for (const commit of droppedRetained) {
|
|
4918
|
+
for (const operation of commit.operations)
|
|
4919
|
+
batch.table(operation.table);
|
|
4920
|
+
batch.outcomes();
|
|
4921
|
+
}
|
|
4915
4922
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
4916
4923
|
batch.scopeMap(table, lastEffective);
|
|
4917
4924
|
const pendingById = new Map(
|
|
@@ -5055,7 +5062,7 @@ export class SyncClient {
|
|
|
5055
5062
|
const values = table.columns.map((column) =>
|
|
5056
5063
|
jsonToRowValue(image.values?.[column.name] ?? null),
|
|
5057
5064
|
);
|
|
5058
|
-
upsertLocalRow(this.#db, table, values, image.syncVersion);
|
|
5065
|
+
upsertLocalRow(this.#db, table, values, image.syncVersion, false);
|
|
5059
5066
|
}
|
|
5060
5067
|
|
|
5061
5068
|
#legacyUndoOptimisticRows(
|
|
@@ -5132,7 +5139,7 @@ export class SyncClient {
|
|
|
5132
5139
|
: this.#recordStoredRowScopes(batch, table, op.rowId);
|
|
5133
5140
|
if (op.op === 'delete') {
|
|
5134
5141
|
if (batch !== undefined && !precise) batch.table(op.table);
|
|
5135
|
-
deleteLocalRow(this.#db, table, op.rowId);
|
|
5142
|
+
deleteLocalRow(this.#db, table, op.rowId, false);
|
|
5136
5143
|
continue;
|
|
5137
5144
|
}
|
|
5138
5145
|
// §7.1 overlay: apply the operation's PRESENT columns over the current
|
|
@@ -5173,19 +5180,23 @@ export class SyncClient {
|
|
|
5173
5180
|
local === undefined
|
|
5174
5181
|
? OPTIMISTIC_VERSION
|
|
5175
5182
|
: (local[SYNC_VERSION_COLUMN] as number);
|
|
5176
|
-
upsertLocalRow(this.#db, table, values, version);
|
|
5183
|
+
upsertLocalRow(this.#db, table, values, version, false);
|
|
5177
5184
|
}
|
|
5178
5185
|
}
|
|
5179
5186
|
|
|
5180
5187
|
/** Re-apply every pending outbox commit on top of server state (§7.1). */
|
|
5181
5188
|
#replayOutbox(): void {
|
|
5182
|
-
const pending =
|
|
5189
|
+
const pending = [
|
|
5190
|
+
...failedOverlayCommits(this.#db),
|
|
5191
|
+
...listOutbox(this.#db),
|
|
5192
|
+
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
5183
5193
|
if (pending.length === 0) return;
|
|
5184
5194
|
this.#applyBatch((batch) => {
|
|
5185
5195
|
this.#db.transaction(() => {
|
|
5186
|
-
|
|
5196
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
5197
|
+
restoreFailedBases(this.#db, this.#schema, true);
|
|
5198
|
+
for (const commit of pending)
|
|
5187
5199
|
this.#applyOperationsLocally(commit.operations, batch);
|
|
5188
|
-
}
|
|
5189
5200
|
});
|
|
5190
5201
|
});
|
|
5191
5202
|
}
|
|
@@ -5210,10 +5221,7 @@ export class SyncClient {
|
|
|
5210
5221
|
|
|
5211
5222
|
#requireStarted(): void {
|
|
5212
5223
|
if (!this.#started) {
|
|
5213
|
-
throw
|
|
5214
|
-
'sync.invalid_request',
|
|
5215
|
-
'SyncClient.start() has not completed',
|
|
5216
|
-
);
|
|
5224
|
+
throw invalidRequest('SyncClient.start() has not completed');
|
|
5217
5225
|
}
|
|
5218
5226
|
}
|
|
5219
5227
|
|
|
@@ -5227,3 +5235,9 @@ export class SyncClient {
|
|
|
5227
5235
|
}
|
|
5228
5236
|
}
|
|
5229
5237
|
}
|
|
5238
|
+
|
|
5239
|
+
function requireRowId(value: unknown): string {
|
|
5240
|
+
if (typeof value !== 'string' || !value.length)
|
|
5241
|
+
throw invalidRequest('a mutation requires a non-empty string primary key');
|
|
5242
|
+
return value;
|
|
5243
|
+
}
|