@syncular/client 0.30.15 → 0.31.0
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 +37 -1
- package/dist/client.d.ts +102 -2
- package/dist/client.js +668 -146
- package/dist/diagnostics.d.ts +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +8 -3
- package/dist/failed-overlay.js +12 -8
- package/dist/http.d.ts +3 -3
- package/dist/http.js +88 -61
- package/dist/index.d.ts +1 -1
- package/dist/index.js +0 -11
- package/dist/outcomes.d.ts +9 -1
- package/dist/outcomes.js +186 -34
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +31 -1
- package/dist/state.js +5 -4
- package/dist/sync-scheduler.js +8 -0
- package/dist/worker-entry.d.ts +18 -0
- package/dist/worker-entry.js +45 -46
- package/dist/worker-host.d.ts +3 -2
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +2 -2
- package/package.json +11 -3
- package/src/client.ts +936 -185
- package/src/diagnostics.ts +2 -0
- package/src/errors.ts +10 -4
- package/src/failed-overlay.ts +10 -15
- package/src/http.ts +198 -96
- package/src/index.ts +4 -5
- package/src/outcomes.ts +230 -46
- package/src/schema.ts +42 -1
- package/src/state.ts +14 -4
- package/src/sync-scheduler.ts +10 -0
- package/src/worker-entry.ts +54 -38
- package/src/worker-host.ts +10 -1
- package/src/worker-protocol.ts +2 -1
package/src/client.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
decodeRow,
|
|
34
34
|
decodeRowsSegment,
|
|
35
35
|
encodeMessage,
|
|
36
|
+
encodeRow,
|
|
37
|
+
encodeSparseRow,
|
|
36
38
|
encodePresencePublish,
|
|
37
39
|
MessageStreamScanner,
|
|
38
40
|
PROTOCOL_WIRE_VERSION,
|
|
@@ -52,6 +54,7 @@ import {
|
|
|
52
54
|
type WakeReason,
|
|
53
55
|
} from '@syncular/core';
|
|
54
56
|
import { EncryptError } from '@syncular/core';
|
|
57
|
+
import { EncodeError } from '@syncular/core';
|
|
55
58
|
import {
|
|
56
59
|
applyCommitFrame,
|
|
57
60
|
applyRowsSegment,
|
|
@@ -166,8 +169,10 @@ import {
|
|
|
166
169
|
type CommitOutcome,
|
|
167
170
|
type CommitOutcomeQuery,
|
|
168
171
|
type ConflictRecord,
|
|
172
|
+
isStoredOperation,
|
|
169
173
|
listCommitOutcomes,
|
|
170
174
|
persistCommitOutcomeResolution,
|
|
175
|
+
persistedCommitOutcome,
|
|
171
176
|
pruneCommitOutcomes,
|
|
172
177
|
type RejectionRecord,
|
|
173
178
|
type ResolveCommitOutcomeInput,
|
|
@@ -210,6 +215,7 @@ import {
|
|
|
210
215
|
ensureLocalSyncedSchema,
|
|
211
216
|
fromSqlValue,
|
|
212
217
|
coerceSqlRepresentation,
|
|
218
|
+
localColumnType,
|
|
213
219
|
type JsonRowValue,
|
|
214
220
|
jsonToRowValue,
|
|
215
221
|
LOCAL_SCHEMA_VERSION_KEY,
|
|
@@ -234,6 +240,7 @@ import {
|
|
|
234
240
|
type SubscriptionRecord,
|
|
235
241
|
saveSubscription,
|
|
236
242
|
setMeta,
|
|
243
|
+
SUBSCRIPTIONS_TABLE,
|
|
237
244
|
} from './state';
|
|
238
245
|
import type {
|
|
239
246
|
RealtimeConnector,
|
|
@@ -336,6 +343,15 @@ export interface SyncSummary {
|
|
|
336
343
|
readonly deferredCommits?: number;
|
|
337
344
|
}
|
|
338
345
|
|
|
346
|
+
/**
|
|
347
|
+
* The result of `syncUntilIdle`. The report aggregates every round that ran;
|
|
348
|
+
* `budgetExhausted` is true when the round budget ran out with the client not
|
|
349
|
+
* idle. An exhausted run is a partial success, never idle.
|
|
350
|
+
*/
|
|
351
|
+
export interface SyncIdleResult extends SyncSummary {
|
|
352
|
+
readonly budgetExhausted: boolean;
|
|
353
|
+
}
|
|
354
|
+
|
|
339
355
|
export interface SyncClientLimits {
|
|
340
356
|
readonly limitCommits?: number;
|
|
341
357
|
readonly limitSnapshotRows?: number;
|
|
@@ -352,6 +368,22 @@ export interface SyncClientLimits {
|
|
|
352
368
|
* deleted to satisfy this cap. Defaults to 1,000.
|
|
353
369
|
*/
|
|
354
370
|
readonly outcomeRetentionMaxEntries?: number;
|
|
371
|
+
/** §7.1: maximum whole commits in one push request. Default: unbounded. */
|
|
372
|
+
readonly maxPushCommitsPerRequest?: number;
|
|
373
|
+
/**
|
|
374
|
+
* §7.1: maximum operations across the push commits in one request. Default
|
|
375
|
+
* 500. A first commit that alone exceeds it is a typed
|
|
376
|
+
* `client.push_request_too_large` capacity error and stays queued.
|
|
377
|
+
*/
|
|
378
|
+
readonly maxPushOperationsPerRequest?: number;
|
|
379
|
+
/**
|
|
380
|
+
* §7.1: maximum bytes of the complete encoded SSP2 request (8-byte
|
|
381
|
+
* envelope, request header, push commits, pull header, subscriptions,
|
|
382
|
+
* framing, and any §5.11 ciphertext). Default: unbounded. A request whose
|
|
383
|
+
* fixed frames alone exceed it, or whose first commit alone does not fit,
|
|
384
|
+
* raises `client.push_request_too_large` and stays queued.
|
|
385
|
+
*/
|
|
386
|
+
readonly maxPushRequestBytes?: number;
|
|
355
387
|
}
|
|
356
388
|
|
|
357
389
|
export interface SyncClientConfig {
|
|
@@ -540,6 +572,70 @@ export interface QuerySnapshot<Row = SqlRow> {
|
|
|
540
572
|
readonly coverage: CoverageSnapshot;
|
|
541
573
|
}
|
|
542
574
|
|
|
575
|
+
/** §7.5: one read-only statement in an atomic snapshot read. */
|
|
576
|
+
export interface SnapshotStatement {
|
|
577
|
+
readonly sql: string;
|
|
578
|
+
readonly params?: readonly SqlValue[];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* §7.5: the catch-up state of one subscription as persisted in the snapshot,
|
|
583
|
+
* never the mutable owner cache. `unknown` means the client does not currently
|
|
584
|
+
* hold the subscription (never registered, forgotten, or removed) and carries
|
|
585
|
+
* no fabricated table or cursor.
|
|
586
|
+
*/
|
|
587
|
+
export type SubscriptionCatchup =
|
|
588
|
+
| { readonly state: 'unknown'; readonly id: string }
|
|
589
|
+
| {
|
|
590
|
+
readonly state: 'known';
|
|
591
|
+
readonly id: string;
|
|
592
|
+
readonly table: string;
|
|
593
|
+
readonly status: 'active' | 'revoked' | 'failed';
|
|
594
|
+
readonly cursor: number;
|
|
595
|
+
readonly hasResumeToken: boolean;
|
|
596
|
+
/** True only for `active` with `cursor >= 0` and no resume token. */
|
|
597
|
+
readonly bootstrapComplete: boolean;
|
|
598
|
+
/** True only for `active` while a resume token remains or `cursor < 0`. */
|
|
599
|
+
readonly knownPendingPages: boolean;
|
|
600
|
+
readonly effectiveScopes?: unknown;
|
|
601
|
+
readonly reasonCode?: string;
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* §7.5: delivery status for one client commit id. `pending` while the id still
|
|
606
|
+
* has an outbox entry (outbox precedence), otherwise the persisted-journal
|
|
607
|
+
* outcome, otherwise an explicit `unknown` (never held, or pruned by
|
|
608
|
+
* retention). The outcome omits the owner-derived `retainedRows` images.
|
|
609
|
+
*/
|
|
610
|
+
export type CommitDelivery =
|
|
611
|
+
| { readonly status: 'pending'; readonly clientCommitId: string }
|
|
612
|
+
| {
|
|
613
|
+
readonly status: 'known';
|
|
614
|
+
readonly clientCommitId: string;
|
|
615
|
+
readonly outcome: CommitOutcome;
|
|
616
|
+
}
|
|
617
|
+
| { readonly status: 'unknown'; readonly clientCommitId: string };
|
|
618
|
+
|
|
619
|
+
/** §7.5: one atomic read request over a single SQLite read transaction. */
|
|
620
|
+
export interface SnapshotReadRequest {
|
|
621
|
+
readonly statements: readonly SnapshotStatement[];
|
|
622
|
+
readonly coverage?: readonly WindowCoverage[];
|
|
623
|
+
readonly subscriptions?: readonly string[];
|
|
624
|
+
readonly commitIds?: readonly string[];
|
|
625
|
+
/** §7.5 owner: a failed owned read is reported in `queryFailures`. */
|
|
626
|
+
readonly owner?: QueryOwner;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** §7.5: one atomic read result: one revision, every statement's rows, the
|
|
630
|
+
* requested coverage, catch-up states, and deliveries. */
|
|
631
|
+
export interface SnapshotRead<Row = SqlRow> {
|
|
632
|
+
readonly revision: LocalRevision;
|
|
633
|
+
readonly queries: readonly (readonly Row[])[];
|
|
634
|
+
readonly coverage: CoverageSnapshot;
|
|
635
|
+
readonly subscriptions: readonly SubscriptionCatchup[];
|
|
636
|
+
readonly deliveries: readonly CommitDelivery[];
|
|
637
|
+
}
|
|
638
|
+
|
|
543
639
|
/**
|
|
544
640
|
* True iff `unit` is windowed-in AND its bootstrap completed (§4.8 I3):
|
|
545
641
|
* registered and not pending. A unit with zero server rows still becomes
|
|
@@ -564,6 +660,11 @@ const ACCEPT_SIGNED_URLS = 1 << 3;
|
|
|
564
660
|
* Never a wire code (§10.3) — surfaced through the rejection channel.
|
|
565
661
|
*/
|
|
566
662
|
const OUTBOX_INCOMPATIBLE_CODE = 'sync.outbox_incompatible';
|
|
663
|
+
/** §7.4.4: static text for a persisted commit the current codec refuses. */
|
|
664
|
+
const LEGACY_VALUE_REFUSAL_MESSAGE =
|
|
665
|
+
'the persisted commit carries values the current codec refuses';
|
|
666
|
+
/** §7.4.4: bounded reason on every intrinsic value drop. */
|
|
667
|
+
const INVALID_STORED_VALUES_REASON = 'invalid_stored_values';
|
|
567
668
|
|
|
568
669
|
/** §6.1 per-request operation cap (matches the server's shipped default —
|
|
569
670
|
* `sync.too_many_operations` above it). The push half sends whole commits in
|
|
@@ -719,6 +820,7 @@ function realtimeErrorCode(error: unknown): string | undefined {
|
|
|
719
820
|
export type ClientSnapshotMethods = Pick<
|
|
720
821
|
SyncClient,
|
|
721
822
|
| 'querySnapshot'
|
|
823
|
+
| 'snapshotRead'
|
|
722
824
|
| 'statusSnapshot'
|
|
723
825
|
| 'diagnosticsSnapshot'
|
|
724
826
|
| 'conflicts'
|
|
@@ -855,6 +957,26 @@ export class SyncClient {
|
|
|
855
957
|
throw invalidRequest('transportEnabled must be a boolean');
|
|
856
958
|
}
|
|
857
959
|
this.#transportEnabled = config.transportEnabled !== false;
|
|
960
|
+
for (const [name, value] of [
|
|
961
|
+
['maxPushCommitsPerRequest', config.limits?.maxPushCommitsPerRequest],
|
|
962
|
+
[
|
|
963
|
+
'maxPushOperationsPerRequest',
|
|
964
|
+
config.limits?.maxPushOperationsPerRequest,
|
|
965
|
+
],
|
|
966
|
+
['maxPushRequestBytes', config.limits?.maxPushRequestBytes],
|
|
967
|
+
] as const) {
|
|
968
|
+
if (
|
|
969
|
+
value !== undefined &&
|
|
970
|
+
(!Number.isInteger(value) || value < 1 || value > 0xffffffff)
|
|
971
|
+
) {
|
|
972
|
+
throw new ClientSyncError(
|
|
973
|
+
'sync.invalid_request',
|
|
974
|
+
'push limit must be an integer in 1..=4294967295',
|
|
975
|
+
false,
|
|
976
|
+
{ limit: name },
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
858
980
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
859
981
|
throw invalidRequest(
|
|
860
982
|
'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
|
|
@@ -922,57 +1044,142 @@ export class SyncClient {
|
|
|
922
1044
|
this.#lease = await lock.acquire(
|
|
923
1045
|
this.#config.lockName ?? 'syncular-leader',
|
|
924
1046
|
);
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1047
|
+
// §7.4.2: the entire synchronous storage phase below (marker guard,
|
|
1048
|
+
// identity and bookkeeping writes, schema reset/replay, container
|
|
1049
|
+
// reconciliation, subscription pruning) runs as one deferred transaction
|
|
1050
|
+
// whose first statement is the marker read. That read pins SQLite's
|
|
1051
|
+
// snapshot before any write: a replica another process upgraded after the
|
|
1052
|
+
// read can never be written over (its snapshot fails with
|
|
1053
|
+
// BUSY/BUSY_SNAPSHOT), and one upgraded before the read refuses with
|
|
1054
|
+
// `client.schema_downgrade`. The batch emits its change event only after
|
|
1055
|
+
// the commit. The onUpgrading callback still reports reset intent.
|
|
1056
|
+
let subscriptions!: ReturnType<typeof pruneUnknownSubscriptions>;
|
|
1057
|
+
try {
|
|
1058
|
+
this.#applyBatch(() => {
|
|
1059
|
+
// §7.4.1/§7.4.2: this is the transaction's first read, so it pins the
|
|
1060
|
+
// snapshot every write below commits under. `undefined` is an absent
|
|
1061
|
+
// marker (fresh/legacy); a non-canonical value, a `_syncular_meta` that
|
|
1062
|
+
// is not a table, or a duplicated marker row is unreadable state.
|
|
1063
|
+
let marker: number | undefined;
|
|
1064
|
+
try {
|
|
1065
|
+
const meta = this.#db.query(
|
|
1066
|
+
"SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
|
|
1067
|
+
)[0];
|
|
1068
|
+
if (meta !== undefined) {
|
|
1069
|
+
if (meta.type !== 'table')
|
|
1070
|
+
throw new Error('invalid metadata table');
|
|
1071
|
+
const rows = this.#db.query(
|
|
1072
|
+
'SELECT value FROM _syncular_meta WHERE key = ?',
|
|
1073
|
+
[LOCAL_SCHEMA_VERSION_KEY],
|
|
1074
|
+
);
|
|
1075
|
+
// A duplicated marker cannot be resolved. Picking one row could
|
|
1076
|
+
// select an older version and drive a destructive reset over a
|
|
1077
|
+
// newer replica.
|
|
1078
|
+
if (rows.length > 1) throw new Error('invalid schema marker');
|
|
1079
|
+
const row = rows[0];
|
|
1080
|
+
if (
|
|
1081
|
+
row === undefined &&
|
|
1082
|
+
this.#db.query(
|
|
1083
|
+
"SELECT 1 FROM _syncular_meta WHERE key = 'localSchemaDescriptor' LIMIT 1",
|
|
1084
|
+
).length > 0
|
|
1085
|
+
) {
|
|
1086
|
+
throw new Error('missing paired schema marker');
|
|
1087
|
+
}
|
|
1088
|
+
if (row !== undefined) {
|
|
1089
|
+
const value = row.value;
|
|
1090
|
+
const version = Number(value);
|
|
1091
|
+
if (
|
|
1092
|
+
typeof value !== 'string' ||
|
|
1093
|
+
!Number.isInteger(version) ||
|
|
1094
|
+
version < 1 ||
|
|
1095
|
+
version > 2147483647 ||
|
|
1096
|
+
String(version) !== value
|
|
1097
|
+
) {
|
|
1098
|
+
throw new Error('invalid schema marker');
|
|
1099
|
+
}
|
|
1100
|
+
marker = version;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
// A recognized SQLite storage failure (including BUSY/LOCKED
|
|
1105
|
+
// contention on the marker read) stays classified; only an invalid
|
|
1106
|
+
// or otherwise unreadable marker is `sync.local_corrupt`.
|
|
1107
|
+
const failure = classifySqliteFailure(error);
|
|
1108
|
+
if (failure.code !== undefined) throw failure.error;
|
|
1109
|
+
throw new ClientSyncError(
|
|
1110
|
+
'sync.local_corrupt',
|
|
1111
|
+
'persisted local schema marker is unreadable or invalid',
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
if (marker !== undefined && marker > this.#schema.version) {
|
|
1115
|
+
throw new ClientSyncError(
|
|
1116
|
+
'client.schema_downgrade',
|
|
1117
|
+
'persisted local schema is newer than the requested schema',
|
|
1118
|
+
false,
|
|
1119
|
+
{
|
|
1120
|
+
persistedVersion: marker,
|
|
1121
|
+
requestedVersion: this.#schema.version,
|
|
1122
|
+
},
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
// New app indexes may reference columns that only exist after the
|
|
1126
|
+
// reset.
|
|
1127
|
+
ensureLocalBookkeepingSchema(this.#db);
|
|
1128
|
+
if (this.#hasBlobs) ensureBlobSchema(this.#db);
|
|
1129
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1130
|
+
const activeFailures = activeFailureRecords(
|
|
1131
|
+
listCommitOutcomes(this.#db, { activeOnly: true }),
|
|
1132
|
+
);
|
|
1133
|
+
this.#conflicts = activeFailures.conflicts;
|
|
1134
|
+
this.#rejections = activeFailures.rejections;
|
|
1135
|
+
const persisted = getMeta(this.#db, 'clientId');
|
|
1136
|
+
if (
|
|
1137
|
+
persisted !== undefined &&
|
|
1138
|
+
this.#config.clientId !== undefined &&
|
|
1139
|
+
persisted !== this.#config.clientId
|
|
1140
|
+
) {
|
|
1141
|
+
throw new ClientSyncError(
|
|
1142
|
+
'client.identity_mismatch',
|
|
1143
|
+
`database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`,
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
this.#clientId =
|
|
1147
|
+
persisted ?? this.#config.clientId ?? crypto.randomUUID();
|
|
1148
|
+
if (persisted === undefined) {
|
|
1149
|
+
setMeta(this.#db, 'clientId', this.#clientId);
|
|
1150
|
+
}
|
|
1151
|
+
// §7.3.5: restore the persisted lease so leaseState survives restart.
|
|
1152
|
+
const leaseJson = getMeta(this.#db, 'leaseState');
|
|
1153
|
+
if (leaseJson !== undefined) {
|
|
1154
|
+
this.#leaseState = JSON.parse(leaseJson) as LeaseState;
|
|
1155
|
+
}
|
|
1156
|
+
// §7.4.2 trigger 1: the persisted local schema version is lower than
|
|
1157
|
+
// the generated version this client ships — run the wipe/re-bootstrap
|
|
1158
|
+
// reset before the first sync round. A fresh install (no marker) is
|
|
1159
|
+
// treated as already at the generated version.
|
|
1160
|
+
this.#detectAndResetSchema(marker);
|
|
1161
|
+
if (marker === this.#schema.version) {
|
|
1162
|
+
this.#replayOutbox(undefined, true, false);
|
|
1163
|
+
}
|
|
1164
|
+
// RFC 0005 D9: a container with missing/stale metadata, or past its
|
|
1165
|
+
// TTL, is discarded at boot before anything can read it; the capture
|
|
1166
|
+
// above already ran against this transaction's validated marker. The
|
|
1167
|
+
// orphan/stale sweep is unconditional; the TTL is aware-binary hygiene
|
|
1168
|
+
// only.
|
|
1169
|
+
this.#reconcilePreviousVersionAtBoot();
|
|
1170
|
+
// A registration remains app intent only while its table exists in the
|
|
1171
|
+
// running schema. Removed-table registrations would poison every pull.
|
|
1172
|
+
subscriptions = pruneUnknownSubscriptions(
|
|
1173
|
+
this.#db,
|
|
1174
|
+
loadSubscriptions(this.#db),
|
|
1175
|
+
this.#schema,
|
|
1176
|
+
);
|
|
1177
|
+
});
|
|
1178
|
+
} catch (error) {
|
|
944
1179
|
await this.#lease.release();
|
|
945
1180
|
this.#lease = undefined;
|
|
946
|
-
throw
|
|
947
|
-
'client.identity_mismatch',
|
|
948
|
-
`database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`,
|
|
949
|
-
);
|
|
1181
|
+
throw classifySqliteFailure(error).error;
|
|
950
1182
|
}
|
|
951
|
-
this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
|
|
952
|
-
if (persisted === undefined) {
|
|
953
|
-
setMeta(this.#db, 'clientId', this.#clientId);
|
|
954
|
-
}
|
|
955
|
-
// §7.3.5: restore the persisted lease so leaseState survives restart.
|
|
956
|
-
const leaseJson = getMeta(this.#db, 'leaseState');
|
|
957
|
-
if (leaseJson !== undefined) {
|
|
958
|
-
this.#leaseState = JSON.parse(leaseJson) as LeaseState;
|
|
959
|
-
}
|
|
960
|
-
// §7.4.2 trigger 1: the persisted local schema version differs from the
|
|
961
|
-
// generated version this client ships — run the wipe/re-bootstrap reset
|
|
962
|
-
// before the first sync round. A fresh install (no marker) is treated as
|
|
963
|
-
// already at the generated version.
|
|
964
|
-
this.#detectAndResetSchema();
|
|
965
|
-
// RFC 0005 D9: a container with missing/stale metadata, or past its TTL, is
|
|
966
|
-
// discarded at boot before anything can read it. The orphan/stale sweep is
|
|
967
|
-
// unconditional; the TTL is aware-binary hygiene only.
|
|
968
|
-
this.#reconcilePreviousVersionAtBoot();
|
|
969
|
-
// A registration remains app intent only while its table exists in the
|
|
970
|
-
// running schema. Removed-table registrations would poison every pull.
|
|
971
|
-
const subscriptions = pruneUnknownSubscriptions(
|
|
972
|
-
this.#db,
|
|
973
|
-
loadSubscriptions(this.#db),
|
|
974
|
-
this.#schema,
|
|
975
|
-
);
|
|
976
1183
|
this.#started = true;
|
|
977
1184
|
// A persisted active subscription needs one catch-up round on every open:
|
|
978
1185
|
// realtime only covers changes after the socket connects, and an
|
|
@@ -1012,12 +1219,14 @@ export class SyncClient {
|
|
|
1012
1219
|
|
|
1013
1220
|
/**
|
|
1014
1221
|
* §7.4.1/§7.4.2: compare the generated schema version to the persisted
|
|
1015
|
-
* marker and run the §7.4.3 reset when
|
|
1222
|
+
* marker and run the §7.4.3 reset when it increases. Idempotent by the
|
|
1016
1223
|
* marker — a mid-reset crash re-runs the reset on the next boot.
|
|
1224
|
+
*
|
|
1225
|
+
* The startup phase's single transaction already pinned and validated the
|
|
1226
|
+
* marker, so the shape chosen here commits under that snapshot.
|
|
1017
1227
|
*/
|
|
1018
|
-
#detectAndResetSchema(): void {
|
|
1019
|
-
|
|
1020
|
-
if (markerJson === undefined) {
|
|
1228
|
+
#detectAndResetSchema(marker: number | undefined): void {
|
|
1229
|
+
if (marker === undefined) {
|
|
1021
1230
|
// Fresh install: the tables just created match the running code.
|
|
1022
1231
|
this.#db.transaction(() => {
|
|
1023
1232
|
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
@@ -1031,7 +1240,6 @@ export class SyncClient {
|
|
|
1031
1240
|
});
|
|
1032
1241
|
return;
|
|
1033
1242
|
}
|
|
1034
|
-
const marker = Number(markerJson);
|
|
1035
1243
|
if (marker === this.#schema.version) {
|
|
1036
1244
|
// Same-version opens remain self-healing for absent tables/indexes, and
|
|
1037
1245
|
// RFC 0005 D1 backfills the descriptor for a database first opened by
|
|
@@ -1088,6 +1296,7 @@ export class SyncClient {
|
|
|
1088
1296
|
this.#discardAcknowledgedRows();
|
|
1089
1297
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
1090
1298
|
resetSubscriptionsForBump(this.#db);
|
|
1299
|
+
this.#replayOutbox();
|
|
1091
1300
|
setMeta(
|
|
1092
1301
|
this.#db,
|
|
1093
1302
|
LOCAL_SCHEMA_VERSION_KEY,
|
|
@@ -1099,11 +1308,10 @@ export class SyncClient {
|
|
|
1099
1308
|
// Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
|
|
1100
1309
|
for (const table of this.#schema.tables.values()) batch.table(table.name);
|
|
1101
1310
|
});
|
|
1102
|
-
// The stop state is over: this client now ships a servable schema.
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1311
|
+
// The stop state is over: this client now ships a servable schema.
|
|
1312
|
+
// The reset transaction already replayed pending intent before committing
|
|
1313
|
+
// its marker and visible rows (§7.4.5).
|
|
1105
1314
|
this.#setSchemaFloor(undefined);
|
|
1106
|
-
this.#replayOutbox();
|
|
1107
1315
|
}
|
|
1108
1316
|
|
|
1109
1317
|
/**
|
|
@@ -1186,23 +1394,38 @@ export class SyncClient {
|
|
|
1186
1394
|
return [];
|
|
1187
1395
|
}
|
|
1188
1396
|
const subscriptions = loadSubscriptions(this.#db);
|
|
1189
|
-
const pending = listOutbox(this.#db);
|
|
1190
1397
|
this.#setUpgrading(true);
|
|
1191
1398
|
// RFC 0005 D5: the log-epoch reset is not a schema bump. It performs the
|
|
1192
1399
|
// orphan sweep but captures no new shadow.
|
|
1193
1400
|
this.#sweepPreviousVersionContainer();
|
|
1194
|
-
this.#
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
this.#
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1401
|
+
const priorRejections = [...this.#rejections];
|
|
1402
|
+
this.#applyBatch(
|
|
1403
|
+
(batch) => {
|
|
1404
|
+
this.#db.transaction(() => {
|
|
1405
|
+
this.#discardAcknowledgedRows();
|
|
1406
|
+
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
1407
|
+
resetSubscriptionsForBump(this.#db);
|
|
1408
|
+
setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
|
|
1409
|
+
// §7.1/§7.4.4: recover codec-invalid persisted values in this reset
|
|
1410
|
+
// transaction before they replay, so the reset boundary never fails on
|
|
1411
|
+
// a commit an earlier version accepted.
|
|
1412
|
+
this.#dropUnencodableOutbox();
|
|
1413
|
+
for (const commit of listOutbox(this.#db)) {
|
|
1414
|
+
this.#applyOperationsLocally(commit.operations, batch);
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
for (const table of this.#schema.tables.values())
|
|
1418
|
+
batch.table(table.name);
|
|
1419
|
+
},
|
|
1420
|
+
undefined,
|
|
1421
|
+
(error) => {
|
|
1422
|
+
// A failure after the recovery drop, including the reset's revision or
|
|
1423
|
+
// COMMIT, rolls the database back: mirror that for the in-memory
|
|
1424
|
+
// rejection cache so no dropped commit is published.
|
|
1425
|
+
this.#rejections = priorRejections;
|
|
1426
|
+
throw error;
|
|
1427
|
+
},
|
|
1428
|
+
);
|
|
1206
1429
|
this.#localResetEpoch += 1;
|
|
1207
1430
|
this.#setSyncNeeded(true);
|
|
1208
1431
|
this.#emitSyncNeeded('startup');
|
|
@@ -1419,20 +1642,154 @@ export class SyncClient {
|
|
|
1419
1642
|
* Reactive integrations use this instead of composing `query()` and
|
|
1420
1643
|
* `windowState()` across separate worker/IPC calls.
|
|
1421
1644
|
*/
|
|
1422
|
-
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row
|
|
1645
|
+
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row>;
|
|
1646
|
+
querySnapshot(spec: QueryReadSpec): QuerySnapshot {
|
|
1647
|
+
const read = this.#readSnapshot({
|
|
1648
|
+
statements: [
|
|
1649
|
+
{
|
|
1650
|
+
sql: spec.sql,
|
|
1651
|
+
...(spec.params !== undefined ? { params: spec.params } : {}),
|
|
1652
|
+
},
|
|
1653
|
+
],
|
|
1654
|
+
...(spec.coverage !== undefined ? { coverage: spec.coverage } : {}),
|
|
1655
|
+
...(spec.owner !== undefined ? { owner: spec.owner } : {}),
|
|
1656
|
+
});
|
|
1657
|
+
const rows = read.queries[0];
|
|
1658
|
+
if (read.queries.length !== 1 || rows === undefined) {
|
|
1659
|
+
throw new ClientSyncError(
|
|
1660
|
+
'client.failed',
|
|
1661
|
+
'querySnapshot produced no statement result',
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
return {
|
|
1665
|
+
revision: read.revision,
|
|
1666
|
+
rows,
|
|
1667
|
+
coverage: read.coverage,
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* §7.5: resolve several read-only statements, window coverage, subscription
|
|
1673
|
+
* catch-up states, and commit deliveries from one SQLite read transaction
|
|
1674
|
+
* and one revision. A host that needs those together reads them together;
|
|
1675
|
+
* every statement passes the read-only guard and any failure rolls the whole
|
|
1676
|
+
* read back.
|
|
1677
|
+
*/
|
|
1678
|
+
snapshotRead<Row = SqlRow>(spec: SnapshotReadRequest): SnapshotRead<Row>;
|
|
1679
|
+
snapshotRead(spec: SnapshotReadRequest): SnapshotRead {
|
|
1680
|
+
return this.#readSnapshot(spec);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/** §7.5: one subscription's persisted catch-up state (never the owner cache). */
|
|
1684
|
+
subscriptionCatchup(id: string): SubscriptionCatchup {
|
|
1685
|
+
return this.#readSnapshot({ statements: [], subscriptions: [id] })
|
|
1686
|
+
.subscriptions[0]!;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/** §7.5: one client commit's delivery status: pending, its retained outcome,
|
|
1690
|
+
* or an explicit unknown (never held, or pruned by retention). */
|
|
1691
|
+
commitDelivery(id: string): CommitDelivery {
|
|
1692
|
+
return this.#readSnapshot({ statements: [], commitIds: [id] })
|
|
1693
|
+
.deliveries[0]!;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* §7.5: the one atomic read primitive. Every public read (`querySnapshot`,
|
|
1698
|
+
* `snapshotRead`, `subscriptionCatchup`, `commitDelivery`) enters here, so
|
|
1699
|
+
* the read-only guard, transaction, revision, coverage, catch-up, delivery,
|
|
1700
|
+
* and owner-failure observation exist once.
|
|
1701
|
+
*/
|
|
1702
|
+
#readSnapshot(request: SnapshotReadRequest): SnapshotRead {
|
|
1423
1703
|
this.#requireActive();
|
|
1424
|
-
const owner =
|
|
1425
|
-
|
|
1704
|
+
const owner = request.owner;
|
|
1705
|
+
const catchupOf = (id: string): SubscriptionCatchup => {
|
|
1706
|
+
function corrupt(): never {
|
|
1707
|
+
throw new ClientSyncError(
|
|
1708
|
+
'sync.local_corrupt',
|
|
1709
|
+
'persisted subscription state is invalid',
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
const row = this.#db.query(
|
|
1713
|
+
`SELECT tbl,status,cursor,bootstrap_state,effective_scopes,reason_code FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`,
|
|
1714
|
+
[id],
|
|
1715
|
+
)[0];
|
|
1716
|
+
if (row === undefined) return { state: 'unknown', id };
|
|
1717
|
+
const table = row.tbl;
|
|
1718
|
+
const status = row.status;
|
|
1719
|
+
const cursor = row.cursor;
|
|
1720
|
+
if (
|
|
1721
|
+
typeof table !== 'string' ||
|
|
1722
|
+
(status !== 'active' && status !== 'revoked' && status !== 'failed') ||
|
|
1723
|
+
typeof cursor !== 'number' ||
|
|
1724
|
+
!Number.isSafeInteger(cursor)
|
|
1725
|
+
) {
|
|
1726
|
+
corrupt();
|
|
1727
|
+
}
|
|
1728
|
+
const token = row.bootstrap_state;
|
|
1729
|
+
if (token !== null && typeof token !== 'string') corrupt();
|
|
1730
|
+
const rawScopes = row.effective_scopes;
|
|
1731
|
+
let effectiveScopes: unknown;
|
|
1732
|
+
if (rawScopes !== null) {
|
|
1733
|
+
if (typeof rawScopes !== 'string') corrupt();
|
|
1734
|
+
try {
|
|
1735
|
+
effectiveScopes = JSON.parse(rawScopes);
|
|
1736
|
+
if (effectiveScopes === null) effectiveScopes = undefined;
|
|
1737
|
+
else if (
|
|
1738
|
+
typeof effectiveScopes !== 'object' ||
|
|
1739
|
+
Array.isArray(effectiveScopes) ||
|
|
1740
|
+
!Object.values(effectiveScopes).every(
|
|
1741
|
+
(values) =>
|
|
1742
|
+
Array.isArray(values) &&
|
|
1743
|
+
values.every((value) => typeof value === 'string'),
|
|
1744
|
+
)
|
|
1745
|
+
)
|
|
1746
|
+
corrupt();
|
|
1747
|
+
} catch {
|
|
1748
|
+
corrupt();
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const reasonCode = row.reason_code;
|
|
1752
|
+
if (reasonCode !== null && typeof reasonCode !== 'string') corrupt();
|
|
1753
|
+
const active = status === 'active';
|
|
1754
|
+
const hasResumeToken = token !== null;
|
|
1755
|
+
return {
|
|
1756
|
+
state: 'known',
|
|
1757
|
+
id,
|
|
1758
|
+
table,
|
|
1759
|
+
status,
|
|
1760
|
+
cursor,
|
|
1761
|
+
hasResumeToken,
|
|
1762
|
+
bootstrapComplete: active && cursor >= 0 && !hasResumeToken,
|
|
1763
|
+
knownPendingPages: active && (hasResumeToken || cursor < 0),
|
|
1764
|
+
...(effectiveScopes !== undefined ? { effectiveScopes } : {}),
|
|
1765
|
+
...(reasonCode !== null ? { reasonCode } : {}),
|
|
1766
|
+
};
|
|
1767
|
+
};
|
|
1768
|
+
const deliveryOf = (id: string): CommitDelivery => {
|
|
1769
|
+
const pending =
|
|
1770
|
+
this.#db.query(
|
|
1771
|
+
`SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`,
|
|
1772
|
+
[id],
|
|
1773
|
+
).length > 0;
|
|
1774
|
+
if (pending) return { status: 'pending', clientCommitId: id };
|
|
1775
|
+
const outcome = persistedCommitOutcome(this.#db, id);
|
|
1776
|
+
return outcome === undefined
|
|
1777
|
+
? { status: 'unknown', clientCommitId: id }
|
|
1778
|
+
: { status: 'known', clientCommitId: id, outcome };
|
|
1779
|
+
};
|
|
1780
|
+
let read: SnapshotRead;
|
|
1426
1781
|
try {
|
|
1427
|
-
|
|
1428
|
-
|
|
1782
|
+
for (const statement of request.statements) {
|
|
1783
|
+
assertReadOnlyQuery(statement.sql);
|
|
1784
|
+
}
|
|
1785
|
+
read = this.#db.transaction(() => {
|
|
1429
1786
|
const revision = getLocalRevision(this.#db);
|
|
1430
|
-
const
|
|
1431
|
-
this.#db.query(
|
|
1432
|
-
)
|
|
1787
|
+
const queries = request.statements.map((statement) =>
|
|
1788
|
+
stripSyncColumns(this.#db.query(statement.sql, statement.params)),
|
|
1789
|
+
);
|
|
1433
1790
|
const pending: WindowUnitRef[] = [];
|
|
1434
1791
|
const missing: WindowUnitRef[] = [];
|
|
1435
|
-
for (const requested of
|
|
1792
|
+
for (const requested of request.coverage ?? []) {
|
|
1436
1793
|
const baseKey = windowBaseKey(requested.base);
|
|
1437
1794
|
const state = this.windowState(requested.base);
|
|
1438
1795
|
for (const unit of new Set(requested.units)) {
|
|
@@ -1443,19 +1800,26 @@ export class SyncClient {
|
|
|
1443
1800
|
}
|
|
1444
1801
|
return {
|
|
1445
1802
|
revision,
|
|
1446
|
-
|
|
1803
|
+
queries,
|
|
1447
1804
|
coverage: {
|
|
1448
1805
|
complete: pending.length === 0 && missing.length === 0,
|
|
1449
1806
|
pending,
|
|
1450
1807
|
missing,
|
|
1451
1808
|
},
|
|
1809
|
+
subscriptions: (request.subscriptions ?? []).map(catchupOf),
|
|
1810
|
+
deliveries: (request.commitIds ?? []).map(deliveryOf),
|
|
1452
1811
|
};
|
|
1453
1812
|
});
|
|
1454
1813
|
} catch (error) {
|
|
1455
1814
|
const failure = classifySqliteFailure(error);
|
|
1456
|
-
const sqliteCode = failure.sqliteCode;
|
|
1457
|
-
const code = failure.code ?? 'client.query_failed';
|
|
1458
1815
|
if (owner !== undefined) {
|
|
1816
|
+
const code: DiagnosticQueryFailure['code'] =
|
|
1817
|
+
failure.code ??
|
|
1818
|
+
(error instanceof ClientSyncError &&
|
|
1819
|
+
error.code === 'sync.local_corrupt'
|
|
1820
|
+
? 'sync.local_corrupt'
|
|
1821
|
+
: 'client.query_failed');
|
|
1822
|
+
const sqliteCode = failure.sqliteCode;
|
|
1459
1823
|
const tables = [...new Set(owner.tables)].sort();
|
|
1460
1824
|
const previous = this.#queryFailures.get(owner.id);
|
|
1461
1825
|
if (
|
|
@@ -1471,7 +1835,7 @@ export class SyncClient {
|
|
|
1471
1835
|
id: owner.id,
|
|
1472
1836
|
tables,
|
|
1473
1837
|
code,
|
|
1474
|
-
sqliteCode
|
|
1838
|
+
sqliteCode,
|
|
1475
1839
|
atMs: this.#now(),
|
|
1476
1840
|
}),
|
|
1477
1841
|
);
|
|
@@ -1488,7 +1852,7 @@ export class SyncClient {
|
|
|
1488
1852
|
if (owner !== undefined && this.#queryFailures.delete(owner.id)) {
|
|
1489
1853
|
this.#emitDiagnostics();
|
|
1490
1854
|
}
|
|
1491
|
-
return
|
|
1855
|
+
return read;
|
|
1492
1856
|
}
|
|
1493
1857
|
|
|
1494
1858
|
// -- previous-version context (RFC 0005) ----------------------------------
|
|
@@ -1532,6 +1896,13 @@ export class SyncClient {
|
|
|
1532
1896
|
refusal?.reason ?? 'no-previous-descriptor',
|
|
1533
1897
|
);
|
|
1534
1898
|
}
|
|
1899
|
+
if (record.currentVersion !== currentVersion) {
|
|
1900
|
+
this.#discardPreviousVersion();
|
|
1901
|
+
return this.#previousVersionUnavailable(
|
|
1902
|
+
currentVersion,
|
|
1903
|
+
'no-previous-descriptor',
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1535
1906
|
if (
|
|
1536
1907
|
nowMs - record.createdAtMs >
|
|
1537
1908
|
previousVersionMaxAgeMs(this.#config.previousVersionContext)
|
|
@@ -2873,6 +3244,21 @@ export class SyncClient {
|
|
|
2873
3244
|
});
|
|
2874
3245
|
}
|
|
2875
3246
|
const pkValue = requireRowId(json[table.primaryKey]);
|
|
3247
|
+
// §7.1: declared-type validation runs before the commit is recorded, so a
|
|
3248
|
+
// refused value leaves no outbox entry, no optimistic row, no revision
|
|
3249
|
+
// and no observation event.
|
|
3250
|
+
const refusal = this.#operationValuesError(table, json);
|
|
3251
|
+
if (refusal !== undefined)
|
|
3252
|
+
// §7.5: the same structured shape the native authoring APIs return — a
|
|
3253
|
+
// static message, and the dynamic cause in `details.legacyCause`.
|
|
3254
|
+
throw new ClientSyncError(
|
|
3255
|
+
'sync.invalid_request',
|
|
3256
|
+
'the authoring request is invalid',
|
|
3257
|
+
false,
|
|
3258
|
+
{
|
|
3259
|
+
legacyCause: `sync.invalid_request: table ${table.name}: ${refusal}`,
|
|
3260
|
+
},
|
|
3261
|
+
);
|
|
2876
3262
|
return definedObject({
|
|
2877
3263
|
table: mutation.table,
|
|
2878
3264
|
rowId: pkValue,
|
|
@@ -3296,9 +3682,14 @@ export class SyncClient {
|
|
|
3296
3682
|
* incompatible`); its purely-optimistic rows are undone (§7.2). Returns
|
|
3297
3683
|
* the encoded push frames index-aligned with the surviving `outbox`.
|
|
3298
3684
|
*/
|
|
3299
|
-
async #encodeOutboxForPush(
|
|
3685
|
+
async #encodeOutboxForPush(
|
|
3686
|
+
header: RequestFrame,
|
|
3687
|
+
pull: RequestFrame,
|
|
3688
|
+
byteCap?: number,
|
|
3689
|
+
): Promise<{
|
|
3300
3690
|
pushFrames: RequestFrame[];
|
|
3301
3691
|
outbox: OutboxCommit[];
|
|
3692
|
+
frameBytes: number[];
|
|
3302
3693
|
deferred: number;
|
|
3303
3694
|
}> {
|
|
3304
3695
|
// Pin before the first encryption await: mutations can append while a
|
|
@@ -3308,26 +3699,38 @@ export class SyncClient {
|
|
|
3308
3699
|
)[0]!;
|
|
3309
3700
|
const pendingCount = bounds.count as number;
|
|
3310
3701
|
const throughSeq = (bounds.last_seq as number | null) ?? 0;
|
|
3702
|
+
const maxCommits = this.#config.limits?.maxPushCommitsPerRequest;
|
|
3703
|
+
const maxOps =
|
|
3704
|
+
this.#config.limits?.maxPushOperationsPerRequest ?? MAX_OPS_PER_REQUEST;
|
|
3705
|
+
// A request must start with REQ_HEADER and carry PULL_HEADER; those two
|
|
3706
|
+
// frames make a valid probe whose encoded size cancels out of the
|
|
3707
|
+
// per-commit delta measured against the canonical encoder.
|
|
3708
|
+
const baseBytes =
|
|
3709
|
+
byteCap === undefined
|
|
3710
|
+
? 0
|
|
3711
|
+
: encodeMessage({
|
|
3712
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
3713
|
+
msgKind: 'request',
|
|
3714
|
+
frames: [header, pull],
|
|
3715
|
+
}).length;
|
|
3311
3716
|
const pushFrames: RequestFrame[] = [];
|
|
3312
3717
|
const outbox: OutboxCommit[] = [];
|
|
3718
|
+
const frameBytes: number[] = [];
|
|
3313
3719
|
let deferred = 0;
|
|
3314
3720
|
let ops = 0;
|
|
3721
|
+
let bytes = 0;
|
|
3315
3722
|
let processed = 0;
|
|
3316
3723
|
for (const commit of iterateOutbox(this.#db, throughSeq)) {
|
|
3317
|
-
// §6.1
|
|
3318
|
-
//
|
|
3319
|
-
//
|
|
3320
|
-
|
|
3321
|
-
if (
|
|
3322
|
-
outbox.length > 0 &&
|
|
3323
|
-
ops + commit.operations.length > MAX_OPS_PER_REQUEST
|
|
3324
|
-
) {
|
|
3724
|
+
// §6.1/§7.1: the commit-count and operation caps are hard. A first
|
|
3725
|
+
// commit that alone exceeds the operation cap is a typed capacity error
|
|
3726
|
+
// and stays queued; a later one defers with the complete suffix.
|
|
3727
|
+
if (maxCommits !== undefined && pushFrames.length >= maxCommits) {
|
|
3325
3728
|
deferred = pendingCount - processed;
|
|
3326
3729
|
break;
|
|
3327
3730
|
}
|
|
3328
|
-
|
|
3731
|
+
let pushFrame: RequestFrame;
|
|
3329
3732
|
try {
|
|
3330
|
-
|
|
3733
|
+
pushFrame =
|
|
3331
3734
|
// §5.11: encrypted columns are encrypted at this encode-at-send
|
|
3332
3735
|
// seam before the row codec serializes them. The stored local row
|
|
3333
3736
|
// supplies an ABSENT key-id selector (§6.1 presence set first,
|
|
@@ -3349,23 +3752,99 @@ export class SyncClient {
|
|
|
3349
3752
|
? value
|
|
3350
3753
|
: undefined;
|
|
3351
3754
|
},
|
|
3352
|
-
)
|
|
3353
|
-
);
|
|
3354
|
-
outbox.push(commit);
|
|
3355
|
-
ops += commit.operations.length;
|
|
3755
|
+
);
|
|
3356
3756
|
} catch (error) {
|
|
3357
3757
|
if (error instanceof OutboxEncodeError) {
|
|
3358
3758
|
this.#dropIncompatibleCommit(commit, error.message);
|
|
3759
|
+
processed += 1;
|
|
3359
3760
|
continue;
|
|
3360
3761
|
}
|
|
3361
3762
|
if (error instanceof EncryptError) {
|
|
3362
3763
|
this.#dropIncompatibleCommit(commit, error.message, error.code);
|
|
3764
|
+
processed += 1;
|
|
3765
|
+
continue;
|
|
3766
|
+
}
|
|
3767
|
+
// §7.1/§7.4.4: a persisted value the current codec refuses (an earlier
|
|
3768
|
+
// version accepted it) is an incompatible commit, never a wedged queue.
|
|
3769
|
+
if (
|
|
3770
|
+
error instanceof EncodeError ||
|
|
3771
|
+
(error instanceof ClientSyncError &&
|
|
3772
|
+
error.code === 'sync.invalid_request')
|
|
3773
|
+
) {
|
|
3774
|
+
this.#dropIncompatibleCommit(
|
|
3775
|
+
commit,
|
|
3776
|
+
LEGACY_VALUE_REFUSAL_MESSAGE,
|
|
3777
|
+
OUTBOX_INCOMPATIBLE_CODE,
|
|
3778
|
+
true,
|
|
3779
|
+
INVALID_STORED_VALUES_REASON,
|
|
3780
|
+
);
|
|
3781
|
+
processed += 1;
|
|
3363
3782
|
continue;
|
|
3364
3783
|
}
|
|
3365
3784
|
throw error;
|
|
3366
3785
|
}
|
|
3786
|
+
// The operation cap is checked after the encode/drop so an unencodable
|
|
3787
|
+
// head is dropped as a durable rejection first, matching the native
|
|
3788
|
+
// prepass priority.
|
|
3789
|
+
if (ops + commit.operations.length > maxOps) {
|
|
3790
|
+
if (pushFrames.length === 0) {
|
|
3791
|
+
throw this.#pushCapacityError(
|
|
3792
|
+
'operations',
|
|
3793
|
+
maxOps,
|
|
3794
|
+
commit.operations.length,
|
|
3795
|
+
commit.clientCommitId,
|
|
3796
|
+
);
|
|
3797
|
+
}
|
|
3798
|
+
deferred = pendingCount - processed;
|
|
3799
|
+
break;
|
|
3800
|
+
}
|
|
3801
|
+
const frame =
|
|
3802
|
+
byteCap === undefined
|
|
3803
|
+
? 0
|
|
3804
|
+
: encodeMessage({
|
|
3805
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
3806
|
+
msgKind: 'request',
|
|
3807
|
+
frames: [header, pushFrame, pull],
|
|
3808
|
+
}).length - baseBytes;
|
|
3809
|
+
// The head is always encoded so the caller can report the full
|
|
3810
|
+
// projected request size with the final header and subscription bytes;
|
|
3811
|
+
// a later commit defers once it no longer fits.
|
|
3812
|
+
if (
|
|
3813
|
+
byteCap !== undefined &&
|
|
3814
|
+
pushFrames.length > 0 &&
|
|
3815
|
+
bytes + frame > byteCap
|
|
3816
|
+
) {
|
|
3817
|
+
deferred = pendingCount - processed;
|
|
3818
|
+
break;
|
|
3819
|
+
}
|
|
3820
|
+
pushFrames.push(pushFrame);
|
|
3821
|
+
outbox.push(commit);
|
|
3822
|
+
frameBytes.push(frame);
|
|
3823
|
+
ops += commit.operations.length;
|
|
3824
|
+
bytes += frame;
|
|
3825
|
+
processed += 1;
|
|
3367
3826
|
}
|
|
3368
|
-
return { pushFrames, outbox, deferred };
|
|
3827
|
+
return { pushFrames, outbox, frameBytes, deferred };
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
/** §7.1: a configured push capacity limit cannot carry this request. */
|
|
3831
|
+
#pushCapacityError(
|
|
3832
|
+
kind: 'operations' | 'bytes',
|
|
3833
|
+
limit: number,
|
|
3834
|
+
size: number,
|
|
3835
|
+
clientCommitId?: string,
|
|
3836
|
+
): ClientSyncError {
|
|
3837
|
+
return new ClientSyncError(
|
|
3838
|
+
'client.push_request_too_large',
|
|
3839
|
+
'the push request exceeds a configured capacity limit',
|
|
3840
|
+
false,
|
|
3841
|
+
definedObject({
|
|
3842
|
+
kind,
|
|
3843
|
+
limit,
|
|
3844
|
+
size,
|
|
3845
|
+
...(clientCommitId !== undefined ? { clientCommitId } : {}),
|
|
3846
|
+
}),
|
|
3847
|
+
);
|
|
3369
3848
|
}
|
|
3370
3849
|
|
|
3371
3850
|
/**
|
|
@@ -3378,37 +3857,56 @@ export class SyncClient {
|
|
|
3378
3857
|
commit: OutboxCommit,
|
|
3379
3858
|
message: string,
|
|
3380
3859
|
code: string = OUTBOX_INCOMPATIBLE_CODE,
|
|
3860
|
+
replay = true,
|
|
3861
|
+
reason?: string,
|
|
3862
|
+
classifiedFailures?: ReadonlySet<string>,
|
|
3381
3863
|
): void {
|
|
3382
|
-
this.#
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3864
|
+
const priorRejections = [...this.#rejections];
|
|
3865
|
+
this.#applyBatch(
|
|
3866
|
+
(batch) => {
|
|
3867
|
+
if (this.#retainFailedCommits && code === 'client.encrypt_failed')
|
|
3868
|
+
retainFailedRows(
|
|
3869
|
+
this.#db,
|
|
3870
|
+
commit,
|
|
3871
|
+
listOutboxBeforeImages(this.#db, commit.clientCommitId),
|
|
3872
|
+
);
|
|
3873
|
+
this.#rollbackFailedCommit(commit, batch, classifiedFailures);
|
|
3874
|
+
if (replay) this.#replayOutbox();
|
|
3875
|
+
// §7.2/§7.4.4: the journal holds the operation envelope only when its
|
|
3876
|
+
// stored shape is representable. A dropped legacy commit whose envelope
|
|
3877
|
+
// the journal cannot carry keeps its durable commitId, code and a bounded
|
|
3878
|
+
// `details.reason` instead of an opaque rewritten value.
|
|
3879
|
+
const representable = commit.operations.every(isStoredOperation);
|
|
3880
|
+
const rejection: RejectionRecord = definedObject({
|
|
3881
|
+
clientCommitId: commit.clientCommitId,
|
|
3882
|
+
opIndex: 0,
|
|
3883
|
+
code,
|
|
3884
|
+
message,
|
|
3885
|
+
retryable: false,
|
|
3886
|
+
...(representable ? { operation: commit.operations[0] } : {}),
|
|
3887
|
+
...(reason === undefined ? {} : { details: { reason } }),
|
|
3888
|
+
});
|
|
3889
|
+
this.#rejections.push(rejection);
|
|
3890
|
+
recordCommitOutcome(this.#db, {
|
|
3891
|
+
clientCommitId: commit.clientCommitId,
|
|
3892
|
+
status: 'rejected',
|
|
3893
|
+
recordedAtMs: this.#now(),
|
|
3894
|
+
results: [{ status: 'error', rejection }],
|
|
3895
|
+
...(representable ? { operations: commit.operations } : {}),
|
|
3896
|
+
});
|
|
3897
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3898
|
+
batch.status();
|
|
3899
|
+
batch.rejections();
|
|
3900
|
+
batch.outcomes();
|
|
3901
|
+
},
|
|
3902
|
+
undefined,
|
|
3903
|
+
(error) => {
|
|
3904
|
+
// The drop's own outcome write or publication failed: mirror the
|
|
3905
|
+
// rollback so no rejection the journal did not keep stays in memory.
|
|
3906
|
+
this.#rejections = priorRejections;
|
|
3907
|
+
throw error;
|
|
3908
|
+
},
|
|
3909
|
+
);
|
|
3412
3910
|
}
|
|
3413
3911
|
|
|
3414
3912
|
// -- sync -------------------------------------------------------------------
|
|
@@ -3540,6 +4038,34 @@ export class SyncClient {
|
|
|
3540
4038
|
try {
|
|
3541
4039
|
await this.#drainPendingEvictions();
|
|
3542
4040
|
const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
4041
|
+
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
4042
|
+
// cannot express itself under the new schema (a dropped column/table)
|
|
4043
|
+
// is removed from the push and surfaced as a rejection, never wedging
|
|
4044
|
+
// the queue. `pushFrames` and `outbox` stay index-aligned for result
|
|
4045
|
+
// mapping.
|
|
4046
|
+
const limits = this.#config.limits;
|
|
4047
|
+
const byteCap = limits?.maxPushRequestBytes;
|
|
4048
|
+
const header: RequestFrame = definedObject({
|
|
4049
|
+
type: 'REQ_HEADER',
|
|
4050
|
+
clientId: this.#clientId,
|
|
4051
|
+
schemaVersion: this.#schema.version,
|
|
4052
|
+
logEpoch: logEpoch,
|
|
4053
|
+
});
|
|
4054
|
+
const pull: RequestFrame = {
|
|
4055
|
+
type: 'PULL_HEADER',
|
|
4056
|
+
limitCommits: limits?.limitCommits ?? 0,
|
|
4057
|
+
limitSnapshotRows: limits?.limitSnapshotRows ?? 0,
|
|
4058
|
+
maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
|
|
4059
|
+
accept: this.#acceptMask(),
|
|
4060
|
+
};
|
|
4061
|
+
const {
|
|
4062
|
+
pushFrames,
|
|
4063
|
+
outbox,
|
|
4064
|
+
frameBytes,
|
|
4065
|
+
deferred: encodedDeferred,
|
|
4066
|
+
} = logEpoch === undefined
|
|
4067
|
+
? { pushFrames: [], outbox: [], frameBytes: [], deferred: 0 }
|
|
4068
|
+
: await this.#encodeOutboxForPush(header, pull, byteCap);
|
|
3543
4069
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
3544
4070
|
// them, so the server-side existence check (§6.6) passes.
|
|
3545
4071
|
if (
|
|
@@ -3549,37 +4075,16 @@ export class SyncClient {
|
|
|
3549
4075
|
) {
|
|
3550
4076
|
await this.#flushBlobUploads();
|
|
3551
4077
|
}
|
|
3552
|
-
//
|
|
3553
|
-
//
|
|
3554
|
-
//
|
|
3555
|
-
// the queue. `pushFrames` and `outbox` stay index-aligned for result
|
|
3556
|
-
// mapping.
|
|
3557
|
-
const { pushFrames, outbox, deferred } =
|
|
3558
|
-
logEpoch === undefined
|
|
3559
|
-
? { pushFrames: [], outbox: [], deferred: 0 }
|
|
3560
|
-
: await this.#encodeOutboxForPush();
|
|
3561
|
-
// Captured together with the subscription state below: the response
|
|
3562
|
-
// apply persists SUB_END cursors only while this epoch is current.
|
|
4078
|
+
// Captured after encoding: a subscribe/unsubscribe during the
|
|
4079
|
+
// encryption await must not make the final request exceed the byte
|
|
4080
|
+
// budget, and the reset epoch must be the one the response apply checks.
|
|
3563
4081
|
const resetEpoch = this.#localResetEpoch;
|
|
3564
4082
|
const subs = loadSubscriptions(this.#db).filter(
|
|
3565
4083
|
(sub) => sub.status === 'active',
|
|
3566
4084
|
);
|
|
3567
|
-
const
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
type: 'REQ_HEADER',
|
|
3571
|
-
clientId: this.#clientId,
|
|
3572
|
-
schemaVersion: this.#schema.version,
|
|
3573
|
-
logEpoch: logEpoch,
|
|
3574
|
-
}),
|
|
3575
|
-
...pushFrames,
|
|
3576
|
-
{
|
|
3577
|
-
type: 'PULL_HEADER',
|
|
3578
|
-
limitCommits: limits?.limitCommits ?? 0,
|
|
3579
|
-
limitSnapshotRows: limits?.limitSnapshotRows ?? 0,
|
|
3580
|
-
maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
|
|
3581
|
-
accept: this.#acceptMask(),
|
|
3582
|
-
},
|
|
4085
|
+
const fixedFrames: RequestFrame[] = [
|
|
4086
|
+
header,
|
|
4087
|
+
pull,
|
|
3583
4088
|
...subs.map(
|
|
3584
4089
|
(sub): RequestFrame =>
|
|
3585
4090
|
definedObject({
|
|
@@ -3593,6 +4098,42 @@ export class SyncClient {
|
|
|
3593
4098
|
}),
|
|
3594
4099
|
),
|
|
3595
4100
|
];
|
|
4101
|
+
let admitted = pushFrames.length;
|
|
4102
|
+
let deferred = encodedDeferred;
|
|
4103
|
+
if (byteCap !== undefined) {
|
|
4104
|
+
// The fixed frames encode as envelope + fixed frames + END; each
|
|
4105
|
+
// admitted push frame adds its own (5 + payload). Trimming the tail
|
|
4106
|
+
// re-fits the request to the final subscription state.
|
|
4107
|
+
const fixedBytes = encodeMessage({
|
|
4108
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
4109
|
+
msgKind: 'request',
|
|
4110
|
+
frames: fixedFrames,
|
|
4111
|
+
}).length;
|
|
4112
|
+
if (fixedBytes > byteCap)
|
|
4113
|
+
throw this.#pushCapacityError('bytes', byteCap, fixedBytes);
|
|
4114
|
+
let admittedBytes = 0;
|
|
4115
|
+
for (const size of frameBytes) admittedBytes += size;
|
|
4116
|
+
while (admitted > 0 && fixedBytes + admittedBytes > byteCap) {
|
|
4117
|
+
admitted -= 1;
|
|
4118
|
+
admittedBytes -= frameBytes[admitted]!;
|
|
4119
|
+
deferred += 1;
|
|
4120
|
+
}
|
|
4121
|
+
if (admitted === 0 && pushFrames.length > 0) {
|
|
4122
|
+
throw this.#pushCapacityError(
|
|
4123
|
+
'bytes',
|
|
4124
|
+
byteCap,
|
|
4125
|
+
fixedBytes + frameBytes[0]!,
|
|
4126
|
+
outbox[0]!.clientCommitId,
|
|
4127
|
+
);
|
|
4128
|
+
}
|
|
4129
|
+
pushFrames.length = admitted;
|
|
4130
|
+
outbox.length = admitted;
|
|
4131
|
+
}
|
|
4132
|
+
const frames: RequestFrame[] = [
|
|
4133
|
+
fixedFrames[0]!,
|
|
4134
|
+
...pushFrames,
|
|
4135
|
+
...fixedFrames.slice(1),
|
|
4136
|
+
];
|
|
3596
4137
|
const requestBytes = encodeMessage({
|
|
3597
4138
|
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
3598
4139
|
msgKind: 'request',
|
|
@@ -3673,16 +4214,73 @@ export class SyncClient {
|
|
|
3673
4214
|
* Pull repeatedly until quiescent: no commits delivered, no bootstrap
|
|
3674
4215
|
* pages pending, no resets to recover (§4.5 "pull again" SHOULD).
|
|
3675
4216
|
*/
|
|
3676
|
-
async syncUntilIdle(maxRounds?: number): Promise<
|
|
4217
|
+
async syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult> {
|
|
4218
|
+
// §7.7: an explicit budget is a strict positive round count. Zero,
|
|
4219
|
+
// fractional, and out-of-range values are caller errors.
|
|
4220
|
+
if (
|
|
4221
|
+
maxRounds !== undefined &&
|
|
4222
|
+
(!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff)
|
|
4223
|
+
)
|
|
4224
|
+
throw invalidRequest('maxRounds must be a positive integer');
|
|
3677
4225
|
const budget = maxRounds ?? 20;
|
|
3678
|
-
|
|
4226
|
+
const aggregate = {
|
|
4227
|
+
pushed: 0,
|
|
4228
|
+
applied: [] as string[],
|
|
4229
|
+
rejected: [] as string[],
|
|
4230
|
+
retryable: [] as string[],
|
|
4231
|
+
conflicts: [] as ConflictRecord[],
|
|
4232
|
+
commitsApplied: 0,
|
|
4233
|
+
segmentRowsApplied: 0,
|
|
4234
|
+
bootstrapping: [] as string[],
|
|
4235
|
+
resets: [] as string[],
|
|
4236
|
+
revoked: [] as string[],
|
|
4237
|
+
failed: [] as string[],
|
|
4238
|
+
deferredCommits: 0,
|
|
4239
|
+
schemaFloor: undefined as SchemaFloor | undefined,
|
|
4240
|
+
};
|
|
4241
|
+
const result = (budgetExhausted: boolean): SyncIdleResult => ({
|
|
4242
|
+
pushed: aggregate.pushed,
|
|
4243
|
+
applied: aggregate.applied,
|
|
4244
|
+
rejected: aggregate.rejected,
|
|
4245
|
+
retryable: aggregate.retryable,
|
|
4246
|
+
conflicts: aggregate.conflicts,
|
|
4247
|
+
commitsApplied: aggregate.commitsApplied,
|
|
4248
|
+
segmentRowsApplied: aggregate.segmentRowsApplied,
|
|
4249
|
+
bootstrapping: aggregate.bootstrapping,
|
|
4250
|
+
resets: aggregate.resets,
|
|
4251
|
+
revoked: aggregate.revoked,
|
|
4252
|
+
failed: aggregate.failed,
|
|
4253
|
+
...(aggregate.deferredCommits > 0
|
|
4254
|
+
? { deferredCommits: aggregate.deferredCommits }
|
|
4255
|
+
: {}),
|
|
4256
|
+
...(aggregate.schemaFloor !== undefined
|
|
4257
|
+
? { schemaFloor: aggregate.schemaFloor }
|
|
4258
|
+
: {}),
|
|
4259
|
+
budgetExhausted,
|
|
4260
|
+
});
|
|
3679
4261
|
for (let round = 0; round < budget; round++) {
|
|
3680
4262
|
const before = JSON.stringify(
|
|
3681
4263
|
loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
|
|
3682
4264
|
);
|
|
3683
|
-
last = await this.sync();
|
|
4265
|
+
const last = await this.sync();
|
|
4266
|
+
// Historical counters accumulate; current readiness (`bootstrapping`,
|
|
4267
|
+
// `deferredCommits`, `schemaFloor`) describes the latest round.
|
|
4268
|
+
aggregate.pushed += last.pushed;
|
|
4269
|
+
aggregate.applied.push(...last.applied);
|
|
4270
|
+
aggregate.rejected.push(...last.rejected);
|
|
4271
|
+
aggregate.retryable.push(...last.retryable);
|
|
4272
|
+
aggregate.conflicts.push(...last.conflicts);
|
|
4273
|
+
aggregate.commitsApplied += last.commitsApplied;
|
|
4274
|
+
aggregate.segmentRowsApplied += last.segmentRowsApplied;
|
|
4275
|
+
aggregate.bootstrapping = [...last.bootstrapping];
|
|
4276
|
+
aggregate.resets.push(...last.resets);
|
|
4277
|
+
aggregate.revoked.push(...last.revoked);
|
|
4278
|
+
aggregate.failed.push(...last.failed);
|
|
4279
|
+
aggregate.deferredCommits = last.deferredCommits ?? 0;
|
|
4280
|
+
aggregate.schemaFloor = last.schemaFloor;
|
|
4281
|
+
// A disabled transport or a schema floor ends the loop, not the budget.
|
|
3684
4282
|
if (!this.#transportEnabled || last.schemaFloor !== undefined)
|
|
3685
|
-
return
|
|
4283
|
+
return result(false);
|
|
3686
4284
|
if (
|
|
3687
4285
|
maxRounds === undefined &&
|
|
3688
4286
|
last.segmentRowsApplied > 0 &&
|
|
@@ -3704,10 +4302,10 @@ export class SyncClient {
|
|
|
3704
4302
|
(last.deferredCommits ?? 0) === 0 &&
|
|
3705
4303
|
!this.#needsPull
|
|
3706
4304
|
) {
|
|
3707
|
-
return
|
|
4305
|
+
return result(false);
|
|
3708
4306
|
}
|
|
3709
4307
|
}
|
|
3710
|
-
|
|
4308
|
+
return result(true);
|
|
3711
4309
|
}
|
|
3712
4310
|
|
|
3713
4311
|
/**
|
|
@@ -3734,7 +4332,7 @@ export class SyncClient {
|
|
|
3734
4332
|
tagged.set(request, 1);
|
|
3735
4333
|
try {
|
|
3736
4334
|
socket.sendBytes(tagged);
|
|
3737
|
-
} catch
|
|
4335
|
+
} catch {
|
|
3738
4336
|
this.#pendingRound = undefined;
|
|
3739
4337
|
// The socket could not carry the round: record the same loss the
|
|
3740
4338
|
// Rust core records when its realtime round call fails (§8.8).
|
|
@@ -3742,8 +4340,9 @@ export class SyncClient {
|
|
|
3742
4340
|
reject(
|
|
3743
4341
|
new ClientSyncError(
|
|
3744
4342
|
'sync.transport_failed',
|
|
3745
|
-
|
|
4343
|
+
'socket round send failed',
|
|
3746
4344
|
true,
|
|
4345
|
+
{ causeKind: 'unknown' },
|
|
3747
4346
|
),
|
|
3748
4347
|
);
|
|
3749
4348
|
}
|
|
@@ -3765,8 +4364,9 @@ export class SyncClient {
|
|
|
3765
4364
|
}
|
|
3766
4365
|
throw new ClientSyncError(
|
|
3767
4366
|
'sync.transport_failed',
|
|
3768
|
-
|
|
4367
|
+
'transport round failed',
|
|
3769
4368
|
true,
|
|
4369
|
+
{ causeKind: 'unknown' },
|
|
3770
4370
|
);
|
|
3771
4371
|
});
|
|
3772
4372
|
}
|
|
@@ -3930,13 +4530,9 @@ export class SyncClient {
|
|
|
3930
4530
|
let done: ReturnType<MessageStreamScanner['push']>;
|
|
3931
4531
|
try {
|
|
3932
4532
|
done = round.scanner.push(body);
|
|
3933
|
-
} catch
|
|
4533
|
+
} catch {
|
|
3934
4534
|
this.#pendingRound = undefined;
|
|
3935
|
-
round.reject(
|
|
3936
|
-
invalidRequest(
|
|
3937
|
-
`malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`,
|
|
3938
|
-
),
|
|
3939
|
-
);
|
|
4535
|
+
round.reject(invalidRequest('malformed round response stream (§8.7)'));
|
|
3940
4536
|
return;
|
|
3941
4537
|
}
|
|
3942
4538
|
if (done === undefined) return;
|
|
@@ -4561,14 +5157,23 @@ export class SyncClient {
|
|
|
4561
5157
|
listOutboxBeforeImages(this.#db, commit.clientCommitId),
|
|
4562
5158
|
);
|
|
4563
5159
|
this.#rollbackFailedCommit(commit, batch);
|
|
5160
|
+
// §7.2: rebasing a rejection reaches rows the same-row replay inside
|
|
5161
|
+
// `#rollbackFailedCommit` cannot: peers of a secondary UNIQUE index
|
|
5162
|
+
// (table-wide scope) and later commits over legacy before-images.
|
|
5163
|
+
// ACK-only groups reconcile nothing and stay zero-work.
|
|
4564
5164
|
this.#replayOutbox(
|
|
4565
|
-
commit.operations.
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
5165
|
+
commit.operations.flatMap((operation) => {
|
|
5166
|
+
const table = this.#schema.tables.get(operation.table);
|
|
5167
|
+
return table === undefined
|
|
5168
|
+
? []
|
|
5169
|
+
: [
|
|
5170
|
+
JSON.stringify(
|
|
5171
|
+
hasUniqueIndex(table)
|
|
5172
|
+
? [operation.table]
|
|
5173
|
+
: [operation.table, operation.rowId],
|
|
5174
|
+
),
|
|
5175
|
+
];
|
|
5176
|
+
}),
|
|
4572
5177
|
);
|
|
4573
5178
|
});
|
|
4574
5179
|
batch.status();
|
|
@@ -5136,7 +5741,11 @@ export class SyncClient {
|
|
|
5136
5741
|
}
|
|
5137
5742
|
}
|
|
5138
5743
|
|
|
5139
|
-
#rollbackFailedCommit(
|
|
5744
|
+
#rollbackFailedCommit(
|
|
5745
|
+
commit: OutboxCommit,
|
|
5746
|
+
batch: ChangeAccumulator,
|
|
5747
|
+
classifiedFailures?: ReadonlySet<string>,
|
|
5748
|
+
): void {
|
|
5140
5749
|
const images = listOutboxBeforeImages(this.#db, commit.clientCommitId);
|
|
5141
5750
|
const imageByIndex = new Map(images.map((image) => [image.opIndex, image]));
|
|
5142
5751
|
const complete = commit.operations.every((_, index) =>
|
|
@@ -5175,6 +5784,10 @@ export class SyncClient {
|
|
|
5175
5784
|
later.clientCommitId,
|
|
5176
5785
|
this.#captureBeforeImages(later.operations, affectedKeys),
|
|
5177
5786
|
);
|
|
5787
|
+
// §7.4.4: a later commit the recovery already classified refused never
|
|
5788
|
+
// replays. Its before-image is still rebased above, so dropping it later
|
|
5789
|
+
// restores the same base this rollback just restored.
|
|
5790
|
+
if (classifiedFailures?.has(later.clientCommitId)) continue;
|
|
5178
5791
|
this.#applyOperationsLocally(affectedOperations, batch);
|
|
5179
5792
|
}
|
|
5180
5793
|
}
|
|
@@ -5185,7 +5798,17 @@ export class SyncClient {
|
|
|
5185
5798
|
replay = false,
|
|
5186
5799
|
): void {
|
|
5187
5800
|
for (const op of operations) {
|
|
5188
|
-
|
|
5801
|
+
// §7.4.4: the overlay replay is schema-agnostic (§0), so an operation
|
|
5802
|
+
// whose table the current schema removed has no local mirror to replay
|
|
5803
|
+
// into and is skipped here. A removed-table upsert stays durable for the
|
|
5804
|
+
// send-time `sync.outbox_incompatible` classification; a value-free
|
|
5805
|
+
// delete carries no values to classify and keeps ordinary server
|
|
5806
|
+
// validation. Every other call runs against the live schema and keeps the
|
|
5807
|
+
// loud `sync.unknown_table`.
|
|
5808
|
+
const table = replay
|
|
5809
|
+
? this.#schema.tables.get(op.table)
|
|
5810
|
+
: this.#table(op.table);
|
|
5811
|
+
if (table === undefined) continue;
|
|
5189
5812
|
let precise =
|
|
5190
5813
|
batch === undefined
|
|
5191
5814
|
? false
|
|
@@ -5214,8 +5837,32 @@ export class SyncClient {
|
|
|
5214
5837
|
if (value !== undefined) return jsonToRowValue(value);
|
|
5215
5838
|
return local === undefined
|
|
5216
5839
|
? null
|
|
5217
|
-
:
|
|
5840
|
+
: replay
|
|
5841
|
+
? (coerceSqlRepresentation(
|
|
5842
|
+
column,
|
|
5843
|
+
local[column.name] ?? null,
|
|
5844
|
+
) as RowValue)
|
|
5845
|
+
: fromSqlValue(column, local[column.name] ?? null);
|
|
5218
5846
|
});
|
|
5847
|
+
if (replay) {
|
|
5848
|
+
try {
|
|
5849
|
+
encodeRow(
|
|
5850
|
+
table.columns.map((column, index) => ({
|
|
5851
|
+
...column,
|
|
5852
|
+
type:
|
|
5853
|
+
column.encrypted && values[index] instanceof Uint8Array
|
|
5854
|
+
? 'bytes'
|
|
5855
|
+
: localColumnType(column),
|
|
5856
|
+
})),
|
|
5857
|
+
values,
|
|
5858
|
+
);
|
|
5859
|
+
} catch {
|
|
5860
|
+
throw new ClientSyncError(
|
|
5861
|
+
'sync.local_corrupt',
|
|
5862
|
+
'persisted optimistic row value is invalid',
|
|
5863
|
+
);
|
|
5864
|
+
}
|
|
5865
|
+
}
|
|
5219
5866
|
// Record the row's scope keys from its scope columns (I2 refinement).
|
|
5220
5867
|
if (batch !== undefined) {
|
|
5221
5868
|
for (const [variable, column] of table.scopeColumnByVariable) {
|
|
@@ -5283,9 +5930,102 @@ export class SyncClient {
|
|
|
5283
5930
|
return scope;
|
|
5284
5931
|
}
|
|
5285
5932
|
|
|
5286
|
-
|
|
5933
|
+
/**
|
|
5934
|
+
* §7.1: the operation's values must encode under the current schema. Returns
|
|
5935
|
+
* the refusal message, or `undefined` when they encode. Structural failures
|
|
5936
|
+
* (a programming error, never caller data) propagate unchanged.
|
|
5937
|
+
*/
|
|
5938
|
+
#operationValuesError(
|
|
5939
|
+
table: CompiledClientTable,
|
|
5940
|
+
values: Readonly<Record<string, JsonRowValue>>,
|
|
5941
|
+
): string | undefined {
|
|
5942
|
+
const declared = table.columns.map((column) => ({
|
|
5943
|
+
...column,
|
|
5944
|
+
type: localColumnType(column),
|
|
5945
|
+
}));
|
|
5946
|
+
const sparse: Array<RowValue | undefined> = [];
|
|
5947
|
+
for (const column of table.columns) {
|
|
5948
|
+
const raw = values[column.name];
|
|
5949
|
+
if (raw === undefined) {
|
|
5950
|
+
sparse.push(undefined);
|
|
5951
|
+
continue;
|
|
5952
|
+
}
|
|
5953
|
+
// §7.1: the schema-agnostic outbox form serializes through JSON, which
|
|
5954
|
+
// turns NaN and ±Infinity into null. The authoring boundary refuses them
|
|
5955
|
+
// instead of recording a different value; the wire `f64` encoding itself
|
|
5956
|
+
// stays IEEE-754 binary64 (§2.4).
|
|
5957
|
+
if (
|
|
5958
|
+
localColumnType(column) === 'float' &&
|
|
5959
|
+
typeof raw === 'number' &&
|
|
5960
|
+
!Number.isFinite(raw)
|
|
5961
|
+
)
|
|
5962
|
+
return `column ${column.name} (float) requires a finite number value`;
|
|
5963
|
+
try {
|
|
5964
|
+
sparse.push(jsonToRowValue(raw));
|
|
5965
|
+
} catch (error) {
|
|
5966
|
+
if (error instanceof ClientSyncError)
|
|
5967
|
+
return `column ${column.name}: ${error.message}`;
|
|
5968
|
+
throw error;
|
|
5969
|
+
}
|
|
5970
|
+
}
|
|
5971
|
+
try {
|
|
5972
|
+
encodeSparseRow(declared, table.primaryKeyIndex, sparse);
|
|
5973
|
+
return undefined;
|
|
5974
|
+
} catch (error) {
|
|
5975
|
+
if (error instanceof EncodeError || error instanceof ClientSyncError)
|
|
5976
|
+
return error.message;
|
|
5977
|
+
throw error;
|
|
5978
|
+
}
|
|
5979
|
+
}
|
|
5980
|
+
|
|
5981
|
+
/**
|
|
5982
|
+
* §7.4.4: a pending commit whose own values the current codec refuses (an
|
|
5983
|
+
* envelope or a type an earlier version accepted) leaves the outbox as
|
|
5984
|
+
* `sync.outbox_incompatible` instead of failing every replay and sync. The
|
|
5985
|
+
* commit goes through the rejection channel; encryption and storage failures
|
|
5986
|
+
* never reach this path.
|
|
5987
|
+
*/
|
|
5988
|
+
#dropUnencodableOutbox(): void {
|
|
5989
|
+
const failures: OutboxCommit[] = [];
|
|
5990
|
+
for (const commit of listOutbox(this.#db)) {
|
|
5991
|
+
const refused = commit.operations.some((op) => {
|
|
5992
|
+
if (op.op !== 'upsert' || op.values === undefined) return false;
|
|
5993
|
+
const table = this.#schema.tables.get(op.table);
|
|
5994
|
+
if (table === undefined) return false;
|
|
5995
|
+
return this.#operationValuesError(table, op.values) !== undefined;
|
|
5996
|
+
});
|
|
5997
|
+
if (refused) failures.push(commit);
|
|
5998
|
+
}
|
|
5999
|
+
// §7.4.4: every refused commit is classified before the FIFO drop pass, so
|
|
6000
|
+
// a rollback never replays a refused successor it has not removed yet.
|
|
6001
|
+
const classifiedFailures = new Set(
|
|
6002
|
+
failures.map((commit) => commit.clientCommitId),
|
|
6003
|
+
);
|
|
6004
|
+
for (const commit of failures)
|
|
6005
|
+
this.#dropIncompatibleCommit(
|
|
6006
|
+
commit,
|
|
6007
|
+
LEGACY_VALUE_REFUSAL_MESSAGE,
|
|
6008
|
+
OUTBOX_INCOMPATIBLE_CODE,
|
|
6009
|
+
false,
|
|
6010
|
+
INVALID_STORED_VALUES_REASON,
|
|
6011
|
+
classifiedFailures,
|
|
6012
|
+
);
|
|
6013
|
+
}
|
|
6014
|
+
|
|
6015
|
+
#replayOutbox(
|
|
6016
|
+
scope?: OverlayScope,
|
|
6017
|
+
restoreBases = true,
|
|
6018
|
+
observe = true,
|
|
6019
|
+
): void {
|
|
5287
6020
|
if (scope?.length === 0) return;
|
|
5288
|
-
|
|
6021
|
+
const replay = (batch?: ChangeAccumulator) => {
|
|
6022
|
+
// §7.4.4: classify value-unencodable pending commits inside the
|
|
6023
|
+
// reconciliation transaction, so a commit persisted by an earlier version
|
|
6024
|
+
// is dropped atomically with the replay it would otherwise fail. A
|
|
6025
|
+
// scoped (partial) reconciliation leaves the classification to these full
|
|
6026
|
+
// boundaries and to the send-time drop, so a partial rebuild never drops
|
|
6027
|
+
// a commit whose other tables are outside the scope.
|
|
6028
|
+
if (scope === undefined) this.#dropUnencodableOutbox();
|
|
5289
6029
|
if (restoreBases) restoreFailedBases(this.#db, this.#schema, scope);
|
|
5290
6030
|
const acknowledged = failedOverlayCommits(
|
|
5291
6031
|
this.#db,
|
|
@@ -5308,7 +6048,18 @@ export class SyncClient {
|
|
|
5308
6048
|
batch,
|
|
5309
6049
|
true,
|
|
5310
6050
|
);
|
|
5311
|
-
}
|
|
6051
|
+
};
|
|
6052
|
+
const priorRejections = [...this.#rejections];
|
|
6053
|
+
try {
|
|
6054
|
+
if (observe) this.#applyBatch(replay);
|
|
6055
|
+
else this.#db.transaction(() => replay());
|
|
6056
|
+
} catch (error) {
|
|
6057
|
+
// SQLite rolled the reconciliation back: mirror that rollback for the
|
|
6058
|
+
// in-memory rejection cache, so a later failure in the same transaction
|
|
6059
|
+
// publishes no drop the durable journal did not keep.
|
|
6060
|
+
this.#rejections = priorRejections;
|
|
6061
|
+
throw error;
|
|
6062
|
+
}
|
|
5312
6063
|
}
|
|
5313
6064
|
|
|
5314
6065
|
#deleteUnreferencedBlobs(): void {
|