@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 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 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;
@@ -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
- for (const [name, value] of normalizeRecordKeys(compiled, partial)) {
1042
+ const normalizedPartial = normalizeRecordKeys(compiled, partial);
1043
+ for (const [name, value] of normalizedPartial) {
963
1044
  record[name] = value;
964
1045
  }
965
- return this.mutate([
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
- this.#rejections.push({
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
- this.#rejections.push({
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
- if (dropOutboxCommitsInScope(this.#db, table, lastEffective).length > 0) {
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';
@@ -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
  }
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
+ };