@syncular/client 0.7.0 → 0.8.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 +14 -0
- package/dist/client.d.ts +21 -25
- package/dist/client.js +146 -4
- 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/outcomes.d.ts +79 -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 +214 -30
- package/src/index.ts +1 -0
- package/src/invalidation.ts +9 -1
- package/src/outcomes.ts +274 -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,20 @@ 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
|
+
Resolution is explicit and one-way: conflicts can keep the server result or
|
|
122
|
+
link to a replacement commit, rejections can link to a replacement, and
|
|
123
|
+
successful history may be dismissed. See SPEC §7.2.1.
|
|
124
|
+
|
|
111
125
|
## The support floor (no fallback ladder)
|
|
112
126
|
|
|
113
127
|
- 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;
|
|
@@ -1057,7 +1131,7 @@ export class SyncClient {
|
|
|
1057
1131
|
deleteLocalRow(this.#db, table, operation.rowId);
|
|
1058
1132
|
}
|
|
1059
1133
|
}
|
|
1060
|
-
|
|
1134
|
+
const rejection = {
|
|
1061
1135
|
clientCommitId: commit.clientCommitId,
|
|
1062
1136
|
opIndex: 0,
|
|
1063
1137
|
code: OUTBOX_INCOMPATIBLE_CODE,
|
|
@@ -1066,9 +1140,18 @@ export class SyncClient {
|
|
|
1066
1140
|
...(commit.operations[0] !== undefined
|
|
1067
1141
|
? { operation: commit.operations[0] }
|
|
1068
1142
|
: {}),
|
|
1143
|
+
};
|
|
1144
|
+
this.#rejections.push(rejection);
|
|
1145
|
+
recordCommitOutcome(this.#db, {
|
|
1146
|
+
clientCommitId: commit.clientCommitId,
|
|
1147
|
+
status: 'rejected',
|
|
1148
|
+
recordedAtMs: this.#now(),
|
|
1149
|
+
results: [{ status: 'error', rejection }],
|
|
1069
1150
|
});
|
|
1151
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1070
1152
|
batch.status();
|
|
1071
1153
|
batch.rejections();
|
|
1154
|
+
batch.outcomes();
|
|
1072
1155
|
});
|
|
1073
1156
|
}
|
|
1074
1157
|
// -- sync -------------------------------------------------------------------
|
|
@@ -1634,8 +1717,19 @@ export class SyncClient {
|
|
|
1634
1717
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
1635
1718
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
1636
1719
|
// "already applied, you may have missed the ack".
|
|
1720
|
+
recordCommitOutcome(this.#db, {
|
|
1721
|
+
clientCommitId: frame.clientCommitId,
|
|
1722
|
+
status: frame.status,
|
|
1723
|
+
recordedAtMs: this.#now(),
|
|
1724
|
+
results: frame.results.map((result) => ({
|
|
1725
|
+
status: 'applied',
|
|
1726
|
+
opIndex: result.opIndex,
|
|
1727
|
+
})),
|
|
1728
|
+
});
|
|
1637
1729
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
1730
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1638
1731
|
batch.status();
|
|
1732
|
+
batch.outcomes();
|
|
1639
1733
|
summary.applied.push(frame.clientCommitId);
|
|
1640
1734
|
return;
|
|
1641
1735
|
}
|
|
@@ -1649,6 +1743,7 @@ export class SyncClient {
|
|
|
1649
1743
|
summary.retryable.push(frame.clientCommitId);
|
|
1650
1744
|
return;
|
|
1651
1745
|
}
|
|
1746
|
+
const outcomeResults = [];
|
|
1652
1747
|
for (const result of frame.results) {
|
|
1653
1748
|
const operation = commit.operations[result.opIndex];
|
|
1654
1749
|
if (result.status === 'conflict') {
|
|
@@ -1664,22 +1759,38 @@ export class SyncClient {
|
|
|
1664
1759
|
...(operation !== undefined ? { operation } : {}),
|
|
1665
1760
|
};
|
|
1666
1761
|
this.#conflicts.push(conflict);
|
|
1762
|
+
outcomeResults.push({ status: 'conflict', conflict });
|
|
1667
1763
|
batch.conflicts();
|
|
1668
1764
|
summary.conflicts.push(conflict);
|
|
1669
1765
|
this.#config.onConflict?.(conflict);
|
|
1670
1766
|
}
|
|
1671
1767
|
else if (result.status === 'error') {
|
|
1672
|
-
|
|
1768
|
+
const rejection = {
|
|
1673
1769
|
clientCommitId: frame.clientCommitId,
|
|
1674
1770
|
opIndex: result.opIndex,
|
|
1675
1771
|
code: result.code,
|
|
1676
1772
|
message: result.message,
|
|
1677
1773
|
retryable: result.retryable,
|
|
1678
1774
|
...(operation !== undefined ? { operation } : {}),
|
|
1679
|
-
}
|
|
1775
|
+
};
|
|
1776
|
+
this.#rejections.push(rejection);
|
|
1777
|
+
outcomeResults.push({ status: 'error', rejection });
|
|
1680
1778
|
batch.rejections();
|
|
1681
1779
|
}
|
|
1780
|
+
else {
|
|
1781
|
+
outcomeResults.push({ status: 'applied', opIndex: result.opIndex });
|
|
1782
|
+
}
|
|
1682
1783
|
}
|
|
1784
|
+
recordCommitOutcome(this.#db, {
|
|
1785
|
+
clientCommitId: frame.clientCommitId,
|
|
1786
|
+
status: outcomeResults.some((result) => result.status === 'conflict')
|
|
1787
|
+
? 'conflict'
|
|
1788
|
+
: 'rejected',
|
|
1789
|
+
recordedAtMs: this.#now(),
|
|
1790
|
+
results: outcomeResults,
|
|
1791
|
+
});
|
|
1792
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1793
|
+
batch.outcomes();
|
|
1683
1794
|
// §7.2: stop optimistic display and decide about dependents — the
|
|
1684
1795
|
// commit leaves the outbox; rows it created that the server never
|
|
1685
1796
|
// confirmed are undone here, rows it overwrote reconcile via the pull
|
|
@@ -1927,8 +2038,39 @@ export class SyncClient {
|
|
|
1927
2038
|
try {
|
|
1928
2039
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
1929
2040
|
batch.scopeMap(table, lastEffective);
|
|
1930
|
-
|
|
2041
|
+
const pendingById = new Map(listOutbox(this.#db).map((commit) => [
|
|
2042
|
+
commit.clientCommitId,
|
|
2043
|
+
commit,
|
|
2044
|
+
]));
|
|
2045
|
+
const droppedIds = dropOutboxCommitsInScope(this.#db, table, lastEffective);
|
|
2046
|
+
if (droppedIds.length > 0) {
|
|
2047
|
+
for (const clientCommitId of droppedIds) {
|
|
2048
|
+
const commit = pendingById.get(clientCommitId);
|
|
2049
|
+
if (commit === undefined)
|
|
2050
|
+
continue;
|
|
2051
|
+
const results = commit.operations.map((operation, opIndex) => {
|
|
2052
|
+
const rejection = {
|
|
2053
|
+
clientCommitId,
|
|
2054
|
+
opIndex,
|
|
2055
|
+
code: 'sync.scope_revoked',
|
|
2056
|
+
message: 'the commit was dropped because its effective scope was revoked',
|
|
2057
|
+
retryable: false,
|
|
2058
|
+
operation,
|
|
2059
|
+
};
|
|
2060
|
+
this.#rejections.push(rejection);
|
|
2061
|
+
return { status: 'error', rejection };
|
|
2062
|
+
});
|
|
2063
|
+
recordCommitOutcome(this.#db, {
|
|
2064
|
+
clientCommitId,
|
|
2065
|
+
status: 'rejected',
|
|
2066
|
+
recordedAtMs: this.#now(),
|
|
2067
|
+
results,
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1931
2071
|
batch.status();
|
|
2072
|
+
batch.rejections();
|
|
2073
|
+
batch.outcomes();
|
|
1932
2074
|
}
|
|
1933
2075
|
this.#reconcileBlobs(true);
|
|
1934
2076
|
}
|
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
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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 { 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
|
+
readonly operation?: OutboxOperation;
|
|
32
|
+
}
|
|
33
|
+
export type CommitOutcomeStatus = 'applied' | 'cached' | 'conflict' | 'rejected';
|
|
34
|
+
export type CommitOutcomeResolution = 'active' | 'resolved_keep_server' | 'superseded' | 'dismissed';
|
|
35
|
+
export type CommitOperationOutcome = {
|
|
36
|
+
readonly status: 'applied';
|
|
37
|
+
readonly opIndex: number;
|
|
38
|
+
} | {
|
|
39
|
+
readonly status: 'conflict';
|
|
40
|
+
readonly conflict: ConflictRecord;
|
|
41
|
+
} | {
|
|
42
|
+
readonly status: 'error';
|
|
43
|
+
readonly rejection: RejectionRecord;
|
|
44
|
+
};
|
|
45
|
+
export interface CommitOutcome {
|
|
46
|
+
/** Monotonic local journal order; not a server sequence. */
|
|
47
|
+
readonly sequence: number;
|
|
48
|
+
readonly clientCommitId: string;
|
|
49
|
+
readonly status: CommitOutcomeStatus;
|
|
50
|
+
readonly recordedAtMs: number;
|
|
51
|
+
readonly results: readonly CommitOperationOutcome[];
|
|
52
|
+
readonly resolution: CommitOutcomeResolution;
|
|
53
|
+
readonly resolvedAtMs?: number;
|
|
54
|
+
readonly replacementClientCommitId?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface CommitOutcomeQuery {
|
|
57
|
+
/** Newest-first result cap. Defaults to all retained entries. */
|
|
58
|
+
readonly limit?: number;
|
|
59
|
+
/** Only unresolved conflict/rejection outcomes. */
|
|
60
|
+
readonly activeOnly?: boolean;
|
|
61
|
+
}
|
|
62
|
+
export interface ResolveCommitOutcomeInput {
|
|
63
|
+
readonly clientCommitId: string;
|
|
64
|
+
readonly resolution: Exclude<CommitOutcomeResolution, 'active'>;
|
|
65
|
+
readonly replacementClientCommitId?: string;
|
|
66
|
+
}
|
|
67
|
+
export declare function recordCommitOutcome(db: ClientDatabase, outcome: Omit<CommitOutcome, 'sequence' | 'resolution'>): CommitOutcome;
|
|
68
|
+
export declare function commitOutcome(db: ClientDatabase, clientCommitId: string): CommitOutcome | undefined;
|
|
69
|
+
export declare function listCommitOutcomes(db: ClientDatabase, query?: CommitOutcomeQuery): CommitOutcome[];
|
|
70
|
+
export declare function persistCommitOutcomeResolution(db: ClientDatabase, input: ResolveCommitOutcomeInput, nowMs: number): CommitOutcome | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Bound journal growth without deleting active failures. If active failures
|
|
73
|
+
* alone exceed the cap the journal intentionally remains over-capacity.
|
|
74
|
+
*/
|
|
75
|
+
export declare function pruneCommitOutcomes(db: ClientDatabase, maxEntries: number): number;
|
|
76
|
+
export declare function activeFailureRecords(outcomes: readonly CommitOutcome[]): {
|
|
77
|
+
readonly conflicts: ConflictRecord[];
|
|
78
|
+
readonly rejections: RejectionRecord[];
|
|
79
|
+
};
|
package/dist/outcomes.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ClientSyncError } from './errors.js';
|
|
2
|
+
import { jsonToRowValue, rowValueToJson } from './schema.js';
|
|
3
|
+
function encodeResults(results) {
|
|
4
|
+
const stored = results.map((result) => {
|
|
5
|
+
if (result.status !== 'conflict')
|
|
6
|
+
return result;
|
|
7
|
+
return {
|
|
8
|
+
status: 'conflict',
|
|
9
|
+
conflict: {
|
|
10
|
+
...result.conflict,
|
|
11
|
+
serverRow: Object.fromEntries(Object.entries(result.conflict.serverRow).map(([key, value]) => [
|
|
12
|
+
key,
|
|
13
|
+
rowValueToJson(value),
|
|
14
|
+
])),
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
return JSON.stringify(stored);
|
|
19
|
+
}
|
|
20
|
+
function decodeResults(raw) {
|
|
21
|
+
const stored = JSON.parse(raw);
|
|
22
|
+
return stored.map((result) => {
|
|
23
|
+
if (result.status !== 'conflict')
|
|
24
|
+
return result;
|
|
25
|
+
return {
|
|
26
|
+
status: 'conflict',
|
|
27
|
+
conflict: {
|
|
28
|
+
...result.conflict,
|
|
29
|
+
serverRow: Object.fromEntries(Object.entries(result.conflict.serverRow).map(([key, value]) => [
|
|
30
|
+
key,
|
|
31
|
+
jsonToRowValue(value),
|
|
32
|
+
])),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function parseOutcome(row) {
|
|
38
|
+
return {
|
|
39
|
+
sequence: row.seq,
|
|
40
|
+
clientCommitId: row.client_commit_id,
|
|
41
|
+
status: row.status,
|
|
42
|
+
recordedAtMs: row.recorded_at_ms,
|
|
43
|
+
results: decodeResults(row.results),
|
|
44
|
+
resolution: row.resolution,
|
|
45
|
+
...(typeof row.resolved_at_ms === 'number'
|
|
46
|
+
? { resolvedAtMs: row.resolved_at_ms }
|
|
47
|
+
: {}),
|
|
48
|
+
...(typeof row.replacement_client_commit_id === 'string'
|
|
49
|
+
? { replacementClientCommitId: row.replacement_client_commit_id }
|
|
50
|
+
: {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function recordCommitOutcome(db, outcome) {
|
|
54
|
+
db.exec(`INSERT INTO _syncular_commit_outcomes(
|
|
55
|
+
client_commit_id, status, recorded_at_ms, results, resolution
|
|
56
|
+
) VALUES (?, ?, ?, ?, 'active')`, [
|
|
57
|
+
outcome.clientCommitId,
|
|
58
|
+
outcome.status,
|
|
59
|
+
outcome.recordedAtMs,
|
|
60
|
+
encodeResults(outcome.results),
|
|
61
|
+
]);
|
|
62
|
+
return commitOutcome(db, outcome.clientCommitId);
|
|
63
|
+
}
|
|
64
|
+
export function commitOutcome(db, clientCommitId) {
|
|
65
|
+
const row = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results,
|
|
66
|
+
resolution, resolved_at_ms, replacement_client_commit_id
|
|
67
|
+
FROM _syncular_commit_outcomes WHERE client_commit_id = ?`, [clientCommitId])[0];
|
|
68
|
+
return row === undefined ? undefined : parseOutcome(row);
|
|
69
|
+
}
|
|
70
|
+
export function listCommitOutcomes(db, query = {}) {
|
|
71
|
+
const limit = query.limit;
|
|
72
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) {
|
|
73
|
+
throw new ClientSyncError('sync.invalid_request', 'commit outcome limit must be a positive safe integer');
|
|
74
|
+
}
|
|
75
|
+
const where = query.activeOnly
|
|
76
|
+
? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
|
|
77
|
+
: '';
|
|
78
|
+
const rows = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results,
|
|
79
|
+
resolution, resolved_at_ms, replacement_client_commit_id
|
|
80
|
+
FROM _syncular_commit_outcomes ${where}
|
|
81
|
+
ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
|
|
82
|
+
return rows.map(parseOutcome);
|
|
83
|
+
}
|
|
84
|
+
export function persistCommitOutcomeResolution(db, input, nowMs) {
|
|
85
|
+
db.exec(`UPDATE _syncular_commit_outcomes
|
|
86
|
+
SET resolution = ?, resolved_at_ms = ?, replacement_client_commit_id = ?
|
|
87
|
+
WHERE client_commit_id = ? AND resolution = 'active'`, [
|
|
88
|
+
input.resolution,
|
|
89
|
+
nowMs,
|
|
90
|
+
input.replacementClientCommitId ?? null,
|
|
91
|
+
input.clientCommitId,
|
|
92
|
+
]);
|
|
93
|
+
return commitOutcome(db, input.clientCommitId);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Bound journal growth without deleting active failures. If active failures
|
|
97
|
+
* alone exceed the cap the journal intentionally remains over-capacity.
|
|
98
|
+
*/
|
|
99
|
+
export function pruneCommitOutcomes(db, maxEntries) {
|
|
100
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
101
|
+
throw new ClientSyncError('sync.invalid_request', 'outcome retention maxEntries must be a positive safe integer');
|
|
102
|
+
}
|
|
103
|
+
const count = db.query('SELECT COUNT(*) AS count FROM _syncular_commit_outcomes')[0]?.count;
|
|
104
|
+
const excess = Math.max(0, (count ?? 0) - maxEntries);
|
|
105
|
+
if (excess === 0)
|
|
106
|
+
return 0;
|
|
107
|
+
const candidates = db.query(`SELECT seq FROM _syncular_commit_outcomes
|
|
108
|
+
WHERE status IN ('applied', 'cached') OR resolution != 'active'
|
|
109
|
+
ORDER BY seq ASC LIMIT ?`, [excess]);
|
|
110
|
+
for (const candidate of candidates) {
|
|
111
|
+
db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq = ?', [
|
|
112
|
+
candidate.seq,
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
return candidates.length;
|
|
116
|
+
}
|
|
117
|
+
export function activeFailureRecords(outcomes) {
|
|
118
|
+
const conflicts = [];
|
|
119
|
+
const rejections = [];
|
|
120
|
+
for (const outcome of outcomes) {
|
|
121
|
+
if (outcome.resolution !== 'active')
|
|
122
|
+
continue;
|
|
123
|
+
for (const result of outcome.results) {
|
|
124
|
+
if (result.status === 'conflict')
|
|
125
|
+
conflicts.push(result.conflict);
|
|
126
|
+
if (result.status === 'error')
|
|
127
|
+
rejections.push(result.rejection);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { conflicts, rejections };
|
|
131
|
+
}
|
package/dist/reactive-store.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
|
|
1
|
+
import type { CommitOutcome, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
|
|
2
2
|
import type { SqlValue } from './database.js';
|
|
3
3
|
import type { ClientChangeListener, SyncStatusSnapshot } from './invalidation.js';
|
|
4
4
|
import { type WindowBase } from './window.js';
|
|
@@ -29,6 +29,7 @@ export interface ReactiveQueryClient {
|
|
|
29
29
|
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
30
30
|
readonly conflicts: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
31
31
|
readonly rejections: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
32
|
+
commitOutcomes(): readonly CommitOutcome[] | Promise<readonly CommitOutcome[]>;
|
|
32
33
|
setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
|
|
33
34
|
windowState(base: WindowBase): WindowState | Promise<WindowState>;
|
|
34
35
|
}
|
|
@@ -54,6 +55,11 @@ export interface ConflictStoreSnapshot<Conflict = unknown, Rejection = unknown>
|
|
|
54
55
|
readonly error: Error | undefined;
|
|
55
56
|
readonly isLoading: boolean;
|
|
56
57
|
}
|
|
58
|
+
export interface OutcomeStoreSnapshot {
|
|
59
|
+
readonly outcomes: readonly CommitOutcome[];
|
|
60
|
+
readonly error: Error | undefined;
|
|
61
|
+
readonly isLoading: boolean;
|
|
62
|
+
}
|
|
57
63
|
/** Lossless deterministic identity for query params, bytes, and row keys. */
|
|
58
64
|
export declare function canonicalValue(value: unknown): string;
|
|
59
65
|
export declare class ReactiveClientStore {
|
|
@@ -61,6 +67,7 @@ export declare class ReactiveClientStore {
|
|
|
61
67
|
readonly client: ReactiveQueryClient;
|
|
62
68
|
readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
|
|
63
69
|
readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
|
|
70
|
+
readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
|
|
64
71
|
constructor(client: ReactiveQueryClient);
|
|
65
72
|
query<Row>(spec: ReactiveQuerySpec<Row>): ExternalStoreEntry<LiveQueryResult<Row>>;
|
|
66
73
|
/** Retain a composable window working set outside React. The returned
|