@syncular/client 0.7.0 → 0.9.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 +25 -0
- package/dist/client.d.ts +21 -25
- package/dist/client.js +170 -10
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invalidation.d.ts +2 -0
- package/dist/invalidation.js +7 -1
- package/dist/outbox.d.ts +5 -0
- package/dist/outcomes.d.ts +81 -0
- package/dist/outcomes.js +131 -0
- package/dist/reactive-store.d.ts +8 -1
- package/dist/reactive-store.js +17 -0
- package/dist/schema.js +12 -0
- package/dist/worker-entry.js +3 -0
- package/dist/worker-host.d.ts +4 -0
- package/dist/worker-host.js +9 -0
- package/dist/worker-protocol.d.ts +4 -0
- package/package.json +3 -3
- package/src/client.ts +265 -43
- package/src/index.ts +1 -0
- package/src/invalidation.ts +9 -1
- package/src/outbox.ts +5 -0
- package/src/outcomes.ts +276 -0
- package/src/reactive-store.ts +28 -0
- package/src/schema.ts +12 -0
- package/src/worker-entry.ts +5 -0
- package/src/worker-host.ts +21 -0
- package/src/worker-protocol.ts +8 -0
package/README.md
CHANGED
|
@@ -108,6 +108,31 @@ With `multiTab` off (the default) the single-tab contract is unchanged: a
|
|
|
108
108
|
losing tab is an `isLeader === false` handle whose calls reject with
|
|
109
109
|
`client.not_leader`.
|
|
110
110
|
|
|
111
|
+
## Durable commit outcomes
|
|
112
|
+
|
|
113
|
+
`SyncClient` and every host bridge expose `commitOutcome(id)`,
|
|
114
|
+
`commitOutcomes({ limit?, activeOnly? })`, and `resolveCommitOutcome(input)`.
|
|
115
|
+
Final `applied`, `cached`, `conflict`, and `rejected` results are journaled in
|
|
116
|
+
the same SQLite transaction that drains their outbox commit. Conflict entries
|
|
117
|
+
retain the losing operation plus `serverVersion`/`serverRow`; active failures
|
|
118
|
+
restore after restart and are never removed by retention. Configure the
|
|
119
|
+
history cap with `limits.outcomeRetentionMaxEntries` (default 1,000).
|
|
120
|
+
|
|
121
|
+
Use `patch(table, rowId, partial, { baseVersion? })` for editor-style partial
|
|
122
|
+
updates. The wire still carries a full row, but the durable local operation
|
|
123
|
+
records a sorted `changedFields` list so conflict and rejection UI knows which
|
|
124
|
+
fields the user intended to touch. That intent is local-only and never enters
|
|
125
|
+
`PUSH_COMMIT`; full-row `mutate` operations omit it.
|
|
126
|
+
|
|
127
|
+
Validator rejections may include bounded `details` (`fieldPaths`, `reason`,
|
|
128
|
+
`requiredAction`, and explicitly safe `references`). The details persist with
|
|
129
|
+
the rejection. Treat every value as a machine hint: map known values to
|
|
130
|
+
localized app UI and never render the diagnostic `message` directly.
|
|
131
|
+
|
|
132
|
+
Resolution is explicit and one-way: conflicts can keep the server result or
|
|
133
|
+
link to a replacement commit, rejections can link to a replacement, and
|
|
134
|
+
successful history may be dismissed. See SPEC §7.2.1.
|
|
135
|
+
|
|
111
136
|
## The support floor (no fallback ladder)
|
|
112
137
|
|
|
113
138
|
- Persistence is **OPFS via `opfs-sahpool`, only**. No COOP/COEP headers
|
package/dist/client.d.ts
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
* ownership behind `LeaderLock`. One combined push+pull request per
|
|
8
8
|
* `sync()` round (§7.2); local reads go straight to the database.
|
|
9
9
|
*/
|
|
10
|
-
import { type
|
|
10
|
+
import { type ScopeMap, type WakeReason } from '@syncular/core';
|
|
11
11
|
import { type BlobRef, type BlobTransport, type CachedBlob } from './blob.js';
|
|
12
12
|
import type { ClientDatabase, SqlRow, SqlValue } from './database.js';
|
|
13
13
|
import type { EncryptionConfig } from './encryption.js';
|
|
14
14
|
import { type ClientChangeListener, type CommandResult, type InvalidationListener, type LocalRevision, type SyncIntent, type SyncStatusSnapshot } from './invalidation.js';
|
|
15
15
|
import { type LeaderLock } from './leader-lock.js';
|
|
16
|
-
import { type OutboxCommit
|
|
16
|
+
import { type OutboxCommit } from './outbox.js';
|
|
17
|
+
import { type CommitOutcome, type CommitOutcomeQuery, type ConflictRecord, type RejectionRecord, type ResolveCommitOutcomeInput } from './outcomes.js';
|
|
17
18
|
import { type ClientSchema } from './schema.js';
|
|
18
19
|
import { type SubscriptionRecord } from './state.js';
|
|
19
20
|
import type { RealtimeConnector, SegmentDownloader, SyncTransport } from './transport.js';
|
|
@@ -30,29 +31,7 @@ export type MutationInput = {
|
|
|
30
31
|
readonly rowId: string;
|
|
31
32
|
readonly baseVersion?: number;
|
|
32
33
|
};
|
|
33
|
-
|
|
34
|
-
export interface ConflictRecord {
|
|
35
|
-
readonly clientCommitId: string;
|
|
36
|
-
readonly opIndex: number;
|
|
37
|
-
readonly table: string;
|
|
38
|
-
readonly rowId: string;
|
|
39
|
-
readonly code: string;
|
|
40
|
-
readonly message: string;
|
|
41
|
-
readonly serverVersion: number;
|
|
42
|
-
/** The current server row, decoded — resolve without a round-trip. */
|
|
43
|
-
readonly serverRow: Readonly<Record<string, RowValue>>;
|
|
44
|
-
/** The losing local operation (absent only for malformed op indexes). */
|
|
45
|
-
readonly operation?: OutboxOperation;
|
|
46
|
-
}
|
|
47
|
-
/** A non-conflict `error` result from a rejected commit (§6.3). */
|
|
48
|
-
export interface RejectionRecord {
|
|
49
|
-
readonly clientCommitId: string;
|
|
50
|
-
readonly opIndex: number;
|
|
51
|
-
readonly code: string;
|
|
52
|
-
readonly message: string;
|
|
53
|
-
readonly retryable: boolean;
|
|
54
|
-
readonly operation?: OutboxOperation;
|
|
55
|
-
}
|
|
34
|
+
export type { CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, ConflictRecord, RejectionRecord, ResolveCommitOutcomeInput, } from './outcomes.js';
|
|
56
35
|
export interface SchemaFloor {
|
|
57
36
|
readonly requiredSchemaVersion?: number;
|
|
58
37
|
readonly latestSchemaVersion?: number;
|
|
@@ -112,6 +91,12 @@ export interface SyncClientLimits {
|
|
|
112
91
|
* `withSqliteImage` and a segment downloader is configured (§5.3).
|
|
113
92
|
*/
|
|
114
93
|
readonly accept?: number;
|
|
94
|
+
/**
|
|
95
|
+
* Maximum retained durable commit outcomes. Old applied/cached or resolved
|
|
96
|
+
* entries are pruned first; unresolved conflicts/rejections are never
|
|
97
|
+
* deleted to satisfy this cap. Defaults to 1,000.
|
|
98
|
+
*/
|
|
99
|
+
readonly outcomeRetentionMaxEntries?: number;
|
|
115
100
|
}
|
|
116
101
|
export interface SyncClientConfig {
|
|
117
102
|
readonly database: ClientDatabase;
|
|
@@ -290,6 +275,17 @@ export declare class SyncClient {
|
|
|
290
275
|
flushBlobUploads(): Promise<void>;
|
|
291
276
|
get conflicts(): readonly ConflictRecord[];
|
|
292
277
|
get rejections(): readonly RejectionRecord[];
|
|
278
|
+
/** One durable final outcome by the originating client commit id. */
|
|
279
|
+
commitOutcome(clientCommitId: string): CommitOutcome | undefined;
|
|
280
|
+
/** Newest-first durable outcome journal. */
|
|
281
|
+
commitOutcomes(query?: CommitOutcomeQuery): readonly CommitOutcome[];
|
|
282
|
+
/**
|
|
283
|
+
* Mark a durable failure handled without deleting its evidence. Conflicts
|
|
284
|
+
* may keep the server row or link to a replacement commit; rejections may
|
|
285
|
+
* only be superseded by a named replacement. Applied/cached history may be
|
|
286
|
+
* dismissed. The transition is one-way and survives restart.
|
|
287
|
+
*/
|
|
288
|
+
resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
|
|
293
289
|
/** Non-undefined once the server declared a schema floor (§1.6). */
|
|
294
290
|
get schemaFloor(): SchemaFloor | undefined;
|
|
295
291
|
/**
|
package/dist/client.js
CHANGED
|
@@ -15,6 +15,7 @@ import { ClientSyncError } from './errors.js';
|
|
|
15
15
|
import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
16
16
|
import { singleOwnerLock, } from './leader-lock.js';
|
|
17
17
|
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, OutboxEncodeError, } from './outbox.js';
|
|
18
|
+
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
18
19
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
19
20
|
import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
20
21
|
import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
|
|
@@ -71,6 +72,7 @@ export class SyncClient {
|
|
|
71
72
|
/** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
|
|
72
73
|
#encryption;
|
|
73
74
|
#now;
|
|
75
|
+
#outcomeRetentionMaxEntries;
|
|
74
76
|
#started = false;
|
|
75
77
|
#lease;
|
|
76
78
|
#clientId = '';
|
|
@@ -127,6 +129,12 @@ export class SyncClient {
|
|
|
127
129
|
this.#schema = compileClientSchema(config.schema);
|
|
128
130
|
this.#encryption = config.encryption;
|
|
129
131
|
this.#now = config.now ?? Date.now;
|
|
132
|
+
const outcomeRetentionMaxEntries = config.limits?.outcomeRetentionMaxEntries ?? 1_000;
|
|
133
|
+
if (!Number.isSafeInteger(outcomeRetentionMaxEntries) ||
|
|
134
|
+
outcomeRetentionMaxEntries < 1) {
|
|
135
|
+
throw new ClientSyncError('sync.invalid_request', 'outcomeRetentionMaxEntries must be a positive safe integer');
|
|
136
|
+
}
|
|
137
|
+
this.#outcomeRetentionMaxEntries = outcomeRetentionMaxEntries;
|
|
130
138
|
this.#hasBlobs = schemaHasBlobs(this.#schema);
|
|
131
139
|
}
|
|
132
140
|
// -- lifecycle ------------------------------------------------------------
|
|
@@ -139,6 +147,12 @@ export class SyncClient {
|
|
|
139
147
|
ensureLocalSchema(this.#db, this.#schema);
|
|
140
148
|
if (this.#hasBlobs)
|
|
141
149
|
ensureBlobSchema(this.#db);
|
|
150
|
+
this.#db.transaction(() => {
|
|
151
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
152
|
+
});
|
|
153
|
+
const activeFailures = activeFailureRecords(listCommitOutcomes(this.#db, { activeOnly: true }));
|
|
154
|
+
this.#conflicts = activeFailures.conflicts;
|
|
155
|
+
this.#rejections = activeFailures.rejections;
|
|
142
156
|
const persisted = getMeta(this.#db, 'clientId');
|
|
143
157
|
if (persisted !== undefined &&
|
|
144
158
|
this.#config.clientId !== undefined &&
|
|
@@ -577,6 +591,66 @@ export class SyncClient {
|
|
|
577
591
|
get rejections() {
|
|
578
592
|
return this.#rejections;
|
|
579
593
|
}
|
|
594
|
+
/** One durable final outcome by the originating client commit id. */
|
|
595
|
+
commitOutcome(clientCommitId) {
|
|
596
|
+
this.#requireStarted();
|
|
597
|
+
return readCommitOutcome(this.#db, clientCommitId);
|
|
598
|
+
}
|
|
599
|
+
/** Newest-first durable outcome journal. */
|
|
600
|
+
commitOutcomes(query = {}) {
|
|
601
|
+
this.#requireStarted();
|
|
602
|
+
return listCommitOutcomes(this.#db, query);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Mark a durable failure handled without deleting its evidence. Conflicts
|
|
606
|
+
* may keep the server row or link to a replacement commit; rejections may
|
|
607
|
+
* only be superseded by a named replacement. Applied/cached history may be
|
|
608
|
+
* dismissed. The transition is one-way and survives restart.
|
|
609
|
+
*/
|
|
610
|
+
resolveCommitOutcome(input) {
|
|
611
|
+
this.#requireStarted();
|
|
612
|
+
const current = readCommitOutcome(this.#db, input.clientCommitId);
|
|
613
|
+
if (current === undefined) {
|
|
614
|
+
throw new ClientSyncError('sync.outcome_not_found', `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`);
|
|
615
|
+
}
|
|
616
|
+
if (current.resolution !== 'active')
|
|
617
|
+
return current;
|
|
618
|
+
const replacement = input.replacementClientCommitId;
|
|
619
|
+
if (input.resolution === 'superseded') {
|
|
620
|
+
if (replacement === undefined ||
|
|
621
|
+
replacement.length === 0 ||
|
|
622
|
+
replacement === input.clientCommitId) {
|
|
623
|
+
throw new ClientSyncError('sync.invalid_request', 'superseded outcomes require a distinct replacementClientCommitId');
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
else if (replacement !== undefined) {
|
|
627
|
+
throw new ClientSyncError('sync.invalid_request', 'replacementClientCommitId is valid only for superseded outcomes');
|
|
628
|
+
}
|
|
629
|
+
const allowed = (current.status === 'conflict' &&
|
|
630
|
+
(input.resolution === 'resolved_keep_server' ||
|
|
631
|
+
input.resolution === 'superseded')) ||
|
|
632
|
+
(current.status === 'rejected' && input.resolution === 'superseded') ||
|
|
633
|
+
((current.status === 'applied' || current.status === 'cached') &&
|
|
634
|
+
input.resolution === 'dismissed');
|
|
635
|
+
if (!allowed) {
|
|
636
|
+
throw new ClientSyncError('sync.invalid_request', `resolution ${input.resolution} is invalid for ${current.status} outcome`);
|
|
637
|
+
}
|
|
638
|
+
return this.#applyBatch((batch) => {
|
|
639
|
+
const resolved = persistCommitOutcomeResolution(this.#db, input, this.#now());
|
|
640
|
+
if (resolved === undefined) {
|
|
641
|
+
throw new ClientSyncError('sync.outcome_not_found', `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`);
|
|
642
|
+
}
|
|
643
|
+
this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== input.clientCommitId);
|
|
644
|
+
this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== input.clientCommitId);
|
|
645
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
646
|
+
batch.outcomes();
|
|
647
|
+
if (current.status === 'conflict')
|
|
648
|
+
batch.conflicts();
|
|
649
|
+
if (current.status === 'rejected')
|
|
650
|
+
batch.rejections();
|
|
651
|
+
return resolved;
|
|
652
|
+
});
|
|
653
|
+
}
|
|
580
654
|
/** Non-undefined once the server declared a schema floor (§1.6). */
|
|
581
655
|
get schemaFloor() {
|
|
582
656
|
return this.#schemaFloor;
|
|
@@ -889,9 +963,12 @@ export class SyncClient {
|
|
|
889
963
|
* Returns the generated `clientCommitId`.
|
|
890
964
|
*/
|
|
891
965
|
mutate(mutations) {
|
|
966
|
+
return this.#recordMutations(mutations);
|
|
967
|
+
}
|
|
968
|
+
#recordMutations(mutations, changedFieldsByIndex = []) {
|
|
892
969
|
this.#requireStarted();
|
|
893
970
|
const clientCommitId = crypto.randomUUID();
|
|
894
|
-
const operations = mutations.map((mutation) => {
|
|
971
|
+
const operations = mutations.map((mutation, index) => {
|
|
895
972
|
const table = this.#table(mutation.table);
|
|
896
973
|
if (mutation.op === 'delete') {
|
|
897
974
|
return {
|
|
@@ -920,6 +997,9 @@ export class SyncClient {
|
|
|
920
997
|
? { baseVersion: mutation.baseVersion }
|
|
921
998
|
: {}),
|
|
922
999
|
values: json,
|
|
1000
|
+
...(changedFieldsByIndex[index] !== undefined
|
|
1001
|
+
? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
|
|
1002
|
+
: {}),
|
|
923
1003
|
};
|
|
924
1004
|
});
|
|
925
1005
|
this.#applyBatch((batch) => {
|
|
@@ -959,10 +1039,11 @@ export class SyncClient {
|
|
|
959
1039
|
for (const column of compiled.columns) {
|
|
960
1040
|
record[column.name] = fromSqlValue(column, row[column.name] ?? null);
|
|
961
1041
|
}
|
|
962
|
-
|
|
1042
|
+
const normalizedPartial = normalizeRecordKeys(compiled, partial);
|
|
1043
|
+
for (const [name, value] of normalizedPartial) {
|
|
963
1044
|
record[name] = value;
|
|
964
1045
|
}
|
|
965
|
-
return this
|
|
1046
|
+
return this.#recordMutations([
|
|
966
1047
|
{
|
|
967
1048
|
table,
|
|
968
1049
|
op: 'upsert',
|
|
@@ -971,7 +1052,7 @@ export class SyncClient {
|
|
|
971
1052
|
? { baseVersion: options.baseVersion }
|
|
972
1053
|
: {}),
|
|
973
1054
|
},
|
|
974
|
-
]);
|
|
1055
|
+
], [[...normalizedPartial.keys()].sort()]);
|
|
975
1056
|
}
|
|
976
1057
|
/** Host-facing patch result with explicit network work intent (§7.5). */
|
|
977
1058
|
patchCommand(table, rowId, partial, options) {
|
|
@@ -1057,7 +1138,7 @@ export class SyncClient {
|
|
|
1057
1138
|
deleteLocalRow(this.#db, table, operation.rowId);
|
|
1058
1139
|
}
|
|
1059
1140
|
}
|
|
1060
|
-
|
|
1141
|
+
const rejection = {
|
|
1061
1142
|
clientCommitId: commit.clientCommitId,
|
|
1062
1143
|
opIndex: 0,
|
|
1063
1144
|
code: OUTBOX_INCOMPATIBLE_CODE,
|
|
@@ -1066,9 +1147,18 @@ export class SyncClient {
|
|
|
1066
1147
|
...(commit.operations[0] !== undefined
|
|
1067
1148
|
? { operation: commit.operations[0] }
|
|
1068
1149
|
: {}),
|
|
1150
|
+
};
|
|
1151
|
+
this.#rejections.push(rejection);
|
|
1152
|
+
recordCommitOutcome(this.#db, {
|
|
1153
|
+
clientCommitId: commit.clientCommitId,
|
|
1154
|
+
status: 'rejected',
|
|
1155
|
+
recordedAtMs: this.#now(),
|
|
1156
|
+
results: [{ status: 'error', rejection }],
|
|
1069
1157
|
});
|
|
1158
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1070
1159
|
batch.status();
|
|
1071
1160
|
batch.rejections();
|
|
1161
|
+
batch.outcomes();
|
|
1072
1162
|
});
|
|
1073
1163
|
}
|
|
1074
1164
|
// -- sync -------------------------------------------------------------------
|
|
@@ -1436,6 +1526,12 @@ export class SyncClient {
|
|
|
1436
1526
|
const summary = emptySummary(sentCommits.length);
|
|
1437
1527
|
const commitsById = new Map(sentCommits.map((commit) => [commit.clientCommitId, commit]));
|
|
1438
1528
|
const subsById = new Map((sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]));
|
|
1529
|
+
const rejectionDetailsByCommit = new Map();
|
|
1530
|
+
for (const frame of message.frames) {
|
|
1531
|
+
if (frame.type !== 'PUSH_RESULT_DETAILS')
|
|
1532
|
+
continue;
|
|
1533
|
+
rejectionDetailsByCommit.set(frame.clientCommitId, new Map(frame.entries.map((entry) => [entry.opIndex, entry.details])));
|
|
1534
|
+
}
|
|
1439
1535
|
const header = message.frames[0];
|
|
1440
1536
|
if (header?.type !== 'RESP_HEADER') {
|
|
1441
1537
|
throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
|
|
@@ -1480,7 +1576,10 @@ export class SyncClient {
|
|
|
1480
1576
|
});
|
|
1481
1577
|
break;
|
|
1482
1578
|
case 'PUSH_RESULT':
|
|
1483
|
-
this.#applyBatch((batch) => this.#handlePushResult(frame, commitsById, summary, batch));
|
|
1579
|
+
this.#applyBatch((batch) => this.#handlePushResult(frame, commitsById, summary, batch, rejectionDetailsByCommit.get(frame.clientCommitId)));
|
|
1580
|
+
break;
|
|
1581
|
+
case 'PUSH_RESULT_DETAILS':
|
|
1582
|
+
// Pre-indexed above so companion ordering remains wire-additive.
|
|
1484
1583
|
break;
|
|
1485
1584
|
case 'SUB_START': {
|
|
1486
1585
|
const sub = subsById.get(frame.id);
|
|
@@ -1627,15 +1726,26 @@ export class SyncClient {
|
|
|
1627
1726
|
}
|
|
1628
1727
|
return { ...summary, bootstrapping };
|
|
1629
1728
|
}
|
|
1630
|
-
#handlePushResult(frame, commitsById, summary, batch) {
|
|
1729
|
+
#handlePushResult(frame, commitsById, summary, batch, rejectionDetails) {
|
|
1631
1730
|
const commit = commitsById.get(frame.clientCommitId);
|
|
1632
1731
|
if (commit === undefined)
|
|
1633
1732
|
return;
|
|
1634
1733
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
1635
1734
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
1636
1735
|
// "already applied, you may have missed the ack".
|
|
1736
|
+
recordCommitOutcome(this.#db, {
|
|
1737
|
+
clientCommitId: frame.clientCommitId,
|
|
1738
|
+
status: frame.status,
|
|
1739
|
+
recordedAtMs: this.#now(),
|
|
1740
|
+
results: frame.results.map((result) => ({
|
|
1741
|
+
status: 'applied',
|
|
1742
|
+
opIndex: result.opIndex,
|
|
1743
|
+
})),
|
|
1744
|
+
});
|
|
1637
1745
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
1746
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1638
1747
|
batch.status();
|
|
1748
|
+
batch.outcomes();
|
|
1639
1749
|
summary.applied.push(frame.clientCommitId);
|
|
1640
1750
|
return;
|
|
1641
1751
|
}
|
|
@@ -1649,6 +1759,7 @@ export class SyncClient {
|
|
|
1649
1759
|
summary.retryable.push(frame.clientCommitId);
|
|
1650
1760
|
return;
|
|
1651
1761
|
}
|
|
1762
|
+
const outcomeResults = [];
|
|
1652
1763
|
for (const result of frame.results) {
|
|
1653
1764
|
const operation = commit.operations[result.opIndex];
|
|
1654
1765
|
if (result.status === 'conflict') {
|
|
@@ -1664,22 +1775,40 @@ export class SyncClient {
|
|
|
1664
1775
|
...(operation !== undefined ? { operation } : {}),
|
|
1665
1776
|
};
|
|
1666
1777
|
this.#conflicts.push(conflict);
|
|
1778
|
+
outcomeResults.push({ status: 'conflict', conflict });
|
|
1667
1779
|
batch.conflicts();
|
|
1668
1780
|
summary.conflicts.push(conflict);
|
|
1669
1781
|
this.#config.onConflict?.(conflict);
|
|
1670
1782
|
}
|
|
1671
1783
|
else if (result.status === 'error') {
|
|
1672
|
-
|
|
1784
|
+
const details = rejectionDetails?.get(result.opIndex);
|
|
1785
|
+
const rejection = {
|
|
1673
1786
|
clientCommitId: frame.clientCommitId,
|
|
1674
1787
|
opIndex: result.opIndex,
|
|
1675
1788
|
code: result.code,
|
|
1676
1789
|
message: result.message,
|
|
1677
1790
|
retryable: result.retryable,
|
|
1791
|
+
...(details !== undefined ? { details } : {}),
|
|
1678
1792
|
...(operation !== undefined ? { operation } : {}),
|
|
1679
|
-
}
|
|
1793
|
+
};
|
|
1794
|
+
this.#rejections.push(rejection);
|
|
1795
|
+
outcomeResults.push({ status: 'error', rejection });
|
|
1680
1796
|
batch.rejections();
|
|
1681
1797
|
}
|
|
1798
|
+
else {
|
|
1799
|
+
outcomeResults.push({ status: 'applied', opIndex: result.opIndex });
|
|
1800
|
+
}
|
|
1682
1801
|
}
|
|
1802
|
+
recordCommitOutcome(this.#db, {
|
|
1803
|
+
clientCommitId: frame.clientCommitId,
|
|
1804
|
+
status: outcomeResults.some((result) => result.status === 'conflict')
|
|
1805
|
+
? 'conflict'
|
|
1806
|
+
: 'rejected',
|
|
1807
|
+
recordedAtMs: this.#now(),
|
|
1808
|
+
results: outcomeResults,
|
|
1809
|
+
});
|
|
1810
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1811
|
+
batch.outcomes();
|
|
1683
1812
|
// §7.2: stop optimistic display and decide about dependents — the
|
|
1684
1813
|
// commit leaves the outbox; rows it created that the server never
|
|
1685
1814
|
// confirmed are undone here, rows it overwrote reconcile via the pull
|
|
@@ -1927,8 +2056,39 @@ export class SyncClient {
|
|
|
1927
2056
|
try {
|
|
1928
2057
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
1929
2058
|
batch.scopeMap(table, lastEffective);
|
|
1930
|
-
|
|
2059
|
+
const pendingById = new Map(listOutbox(this.#db).map((commit) => [
|
|
2060
|
+
commit.clientCommitId,
|
|
2061
|
+
commit,
|
|
2062
|
+
]));
|
|
2063
|
+
const droppedIds = dropOutboxCommitsInScope(this.#db, table, lastEffective);
|
|
2064
|
+
if (droppedIds.length > 0) {
|
|
2065
|
+
for (const clientCommitId of droppedIds) {
|
|
2066
|
+
const commit = pendingById.get(clientCommitId);
|
|
2067
|
+
if (commit === undefined)
|
|
2068
|
+
continue;
|
|
2069
|
+
const results = commit.operations.map((operation, opIndex) => {
|
|
2070
|
+
const rejection = {
|
|
2071
|
+
clientCommitId,
|
|
2072
|
+
opIndex,
|
|
2073
|
+
code: 'sync.scope_revoked',
|
|
2074
|
+
message: 'the commit was dropped because its effective scope was revoked',
|
|
2075
|
+
retryable: false,
|
|
2076
|
+
operation,
|
|
2077
|
+
};
|
|
2078
|
+
this.#rejections.push(rejection);
|
|
2079
|
+
return { status: 'error', rejection };
|
|
2080
|
+
});
|
|
2081
|
+
recordCommitOutcome(this.#db, {
|
|
2082
|
+
clientCommitId,
|
|
2083
|
+
status: 'rejected',
|
|
2084
|
+
recordedAtMs: this.#now(),
|
|
2085
|
+
results,
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1931
2089
|
batch.status();
|
|
2090
|
+
batch.rejections();
|
|
2091
|
+
batch.outcomes();
|
|
1932
2092
|
}
|
|
1933
2093
|
this.#reconcileBlobs(true);
|
|
1934
2094
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export * from './leader-lock.js';
|
|
|
22
22
|
export * from './multi-tab.js';
|
|
23
23
|
export * from './naming.js';
|
|
24
24
|
export * from './outbox.js';
|
|
25
|
+
export * from './outcomes.js';
|
|
25
26
|
export * from './query-guard.js';
|
|
26
27
|
export * from './reactive-store.js';
|
|
27
28
|
export * from './schema.js';
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ export * from './leader-lock.js';
|
|
|
22
22
|
export * from './multi-tab.js';
|
|
23
23
|
export * from './naming.js';
|
|
24
24
|
export * from './outbox.js';
|
|
25
|
+
export * from './outcomes.js';
|
|
25
26
|
export * from './query-guard.js';
|
|
26
27
|
export * from './reactive-store.js';
|
|
27
28
|
export * from './schema.js';
|
package/dist/invalidation.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export interface ClientChangeBatch {
|
|
|
33
33
|
readonly status?: SyncStatusSnapshot;
|
|
34
34
|
readonly conflictsChanged: boolean;
|
|
35
35
|
readonly rejectionsChanged: boolean;
|
|
36
|
+
readonly outcomesChanged: boolean;
|
|
36
37
|
}
|
|
37
38
|
export type ClientChangeListener = (batch: ClientChangeBatch) => void;
|
|
38
39
|
/** Network work created by a core command (SPEC §7.5). */
|
|
@@ -67,6 +68,7 @@ export declare class ChangeAccumulator {
|
|
|
67
68
|
status(): void;
|
|
68
69
|
conflicts(): void;
|
|
69
70
|
rejections(): void;
|
|
71
|
+
outcomes(): void;
|
|
70
72
|
/** Add precise keys for a requested/effective scope map. */
|
|
71
73
|
scopeMap(table: CompiledClientTable, scopes: ScopeMap): void;
|
|
72
74
|
/** Add precise keys for a COMMIT change's stored scope values. */
|
package/dist/invalidation.js
CHANGED
|
@@ -11,6 +11,7 @@ export class ChangeAccumulator {
|
|
|
11
11
|
#status = false;
|
|
12
12
|
#conflicts = false;
|
|
13
13
|
#rejections = false;
|
|
14
|
+
#outcomes = false;
|
|
14
15
|
/** Mark a whole table dirty, discarding any weaker scope-only facts. */
|
|
15
16
|
table(name) {
|
|
16
17
|
this.#tables.set(name, { tableWide: true, scopeKeys: undefined });
|
|
@@ -50,6 +51,9 @@ export class ChangeAccumulator {
|
|
|
50
51
|
rejections() {
|
|
51
52
|
this.#rejections = true;
|
|
52
53
|
}
|
|
54
|
+
outcomes() {
|
|
55
|
+
this.#outcomes = true;
|
|
56
|
+
}
|
|
53
57
|
/** Add precise keys for a requested/effective scope map. */
|
|
54
58
|
scopeMap(table, scopes) {
|
|
55
59
|
for (const [variable, values] of Object.entries(scopes)) {
|
|
@@ -74,7 +78,8 @@ export class ChangeAccumulator {
|
|
|
74
78
|
this.#windows.size > 0 ||
|
|
75
79
|
this.#status ||
|
|
76
80
|
this.#conflicts ||
|
|
77
|
-
this.#rejections
|
|
81
|
+
this.#rejections ||
|
|
82
|
+
this.#outcomes);
|
|
78
83
|
}
|
|
79
84
|
get statusChanged() {
|
|
80
85
|
return this.#status;
|
|
@@ -104,6 +109,7 @@ export class ChangeAccumulator {
|
|
|
104
109
|
...(this.#status ? { status: status } : {}),
|
|
105
110
|
conflictsChanged: this.#conflicts,
|
|
106
111
|
rejectionsChanged: this.#rejections,
|
|
112
|
+
outcomesChanged: this.#outcomes,
|
|
107
113
|
};
|
|
108
114
|
}
|
|
109
115
|
}
|
package/dist/outbox.d.ts
CHANGED
|
@@ -18,6 +18,11 @@ export interface OutboxOperation {
|
|
|
18
18
|
readonly baseVersion?: number;
|
|
19
19
|
/** Full-row values keyed by column name; present iff `op` is `upsert`. */
|
|
20
20
|
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
21
|
+
/**
|
|
22
|
+
* Local-only normalized columns intentionally supplied to `patch()`.
|
|
23
|
+
* Absent for full-row mutate/upsert because intent is then unknown.
|
|
24
|
+
*/
|
|
25
|
+
readonly changedFields?: readonly string[];
|
|
21
26
|
}
|
|
22
27
|
export interface OutboxCommit {
|
|
23
28
|
readonly seq: number;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable per-client commit outcomes.
|
|
3
|
+
*
|
|
4
|
+
* The journal is client-local protected database state. A final push result is
|
|
5
|
+
* written in the same SQLite transaction that drains its outbox commit, so a
|
|
6
|
+
* restart can never turn "rejected" into an inferred success. Conflict payloads
|
|
7
|
+
* deliberately stay local; retention never deletes an unresolved failure.
|
|
8
|
+
*/
|
|
9
|
+
import type { RejectionDetails, RowValue } from '@syncular/core';
|
|
10
|
+
import type { ClientDatabase } from './database.js';
|
|
11
|
+
import type { OutboxOperation } from './outbox.js';
|
|
12
|
+
export interface ConflictRecord {
|
|
13
|
+
readonly clientCommitId: string;
|
|
14
|
+
readonly opIndex: number;
|
|
15
|
+
readonly table: string;
|
|
16
|
+
readonly rowId: string;
|
|
17
|
+
readonly code: string;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
readonly serverVersion: number;
|
|
20
|
+
/** The current server row, decoded — resolve without a round-trip. */
|
|
21
|
+
readonly serverRow: Readonly<Record<string, RowValue>>;
|
|
22
|
+
/** The losing local operation (absent only for malformed op indexes). */
|
|
23
|
+
readonly operation?: OutboxOperation;
|
|
24
|
+
}
|
|
25
|
+
export interface RejectionRecord {
|
|
26
|
+
readonly clientCommitId: string;
|
|
27
|
+
readonly opIndex: number;
|
|
28
|
+
readonly code: string;
|
|
29
|
+
readonly message: string;
|
|
30
|
+
readonly retryable: boolean;
|
|
31
|
+
/** Bounded host-declared metadata safe for authorized recovery UI. */
|
|
32
|
+
readonly details?: RejectionDetails;
|
|
33
|
+
readonly operation?: OutboxOperation;
|
|
34
|
+
}
|
|
35
|
+
export type CommitOutcomeStatus = 'applied' | 'cached' | 'conflict' | 'rejected';
|
|
36
|
+
export type CommitOutcomeResolution = 'active' | 'resolved_keep_server' | 'superseded' | 'dismissed';
|
|
37
|
+
export type CommitOperationOutcome = {
|
|
38
|
+
readonly status: 'applied';
|
|
39
|
+
readonly opIndex: number;
|
|
40
|
+
} | {
|
|
41
|
+
readonly status: 'conflict';
|
|
42
|
+
readonly conflict: ConflictRecord;
|
|
43
|
+
} | {
|
|
44
|
+
readonly status: 'error';
|
|
45
|
+
readonly rejection: RejectionRecord;
|
|
46
|
+
};
|
|
47
|
+
export interface CommitOutcome {
|
|
48
|
+
/** Monotonic local journal order; not a server sequence. */
|
|
49
|
+
readonly sequence: number;
|
|
50
|
+
readonly clientCommitId: string;
|
|
51
|
+
readonly status: CommitOutcomeStatus;
|
|
52
|
+
readonly recordedAtMs: number;
|
|
53
|
+
readonly results: readonly CommitOperationOutcome[];
|
|
54
|
+
readonly resolution: CommitOutcomeResolution;
|
|
55
|
+
readonly resolvedAtMs?: number;
|
|
56
|
+
readonly replacementClientCommitId?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface CommitOutcomeQuery {
|
|
59
|
+
/** Newest-first result cap. Defaults to all retained entries. */
|
|
60
|
+
readonly limit?: number;
|
|
61
|
+
/** Only unresolved conflict/rejection outcomes. */
|
|
62
|
+
readonly activeOnly?: boolean;
|
|
63
|
+
}
|
|
64
|
+
export interface ResolveCommitOutcomeInput {
|
|
65
|
+
readonly clientCommitId: string;
|
|
66
|
+
readonly resolution: Exclude<CommitOutcomeResolution, 'active'>;
|
|
67
|
+
readonly replacementClientCommitId?: string;
|
|
68
|
+
}
|
|
69
|
+
export declare function recordCommitOutcome(db: ClientDatabase, outcome: Omit<CommitOutcome, 'sequence' | 'resolution'>): CommitOutcome;
|
|
70
|
+
export declare function commitOutcome(db: ClientDatabase, clientCommitId: string): CommitOutcome | undefined;
|
|
71
|
+
export declare function listCommitOutcomes(db: ClientDatabase, query?: CommitOutcomeQuery): CommitOutcome[];
|
|
72
|
+
export declare function persistCommitOutcomeResolution(db: ClientDatabase, input: ResolveCommitOutcomeInput, nowMs: number): CommitOutcome | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Bound journal growth without deleting active failures. If active failures
|
|
75
|
+
* alone exceed the cap the journal intentionally remains over-capacity.
|
|
76
|
+
*/
|
|
77
|
+
export declare function pruneCommitOutcomes(db: ClientDatabase, maxEntries: number): number;
|
|
78
|
+
export declare function activeFailureRecords(outcomes: readonly CommitOutcome[]): {
|
|
79
|
+
readonly conflicts: ConflictRecord[];
|
|
80
|
+
readonly rejections: RejectionRecord[];
|
|
81
|
+
};
|