@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 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 RowValue, type ScopeMap, type WakeReason } from '@syncular/core';
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, type OutboxOperation } from './outbox.js';
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
- /** A §6.3 conflict result, surfaced to the app never auto-resolved. */
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
- this.#rejections.push({
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
- this.#rejections.push({
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
- if (dropOutboxCommitsInScope(this.#db, table, lastEffective).length > 0) {
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';
@@ -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. */
@@ -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
+ };
@@ -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
+ }
@@ -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