@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.
@@ -0,0 +1,276 @@
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';
11
+ import { ClientSyncError } from './errors';
12
+ import type { OutboxOperation } from './outbox';
13
+ import { type JsonRowValue, jsonToRowValue, rowValueToJson } from './schema';
14
+
15
+ export interface ConflictRecord {
16
+ readonly clientCommitId: string;
17
+ readonly opIndex: number;
18
+ readonly table: string;
19
+ readonly rowId: string;
20
+ readonly code: string;
21
+ readonly message: string;
22
+ readonly serverVersion: number;
23
+ /** The current server row, decoded — resolve without a round-trip. */
24
+ readonly serverRow: Readonly<Record<string, RowValue>>;
25
+ /** The losing local operation (absent only for malformed op indexes). */
26
+ readonly operation?: OutboxOperation;
27
+ }
28
+
29
+ export interface RejectionRecord {
30
+ readonly clientCommitId: string;
31
+ readonly opIndex: number;
32
+ readonly code: string;
33
+ readonly message: string;
34
+ readonly retryable: boolean;
35
+ /** Bounded host-declared metadata safe for authorized recovery UI. */
36
+ readonly details?: RejectionDetails;
37
+ readonly operation?: OutboxOperation;
38
+ }
39
+
40
+ export type CommitOutcomeStatus =
41
+ | 'applied'
42
+ | 'cached'
43
+ | 'conflict'
44
+ | 'rejected';
45
+
46
+ export type CommitOutcomeResolution =
47
+ | 'active'
48
+ | 'resolved_keep_server'
49
+ | 'superseded'
50
+ | 'dismissed';
51
+
52
+ export type CommitOperationOutcome =
53
+ | {
54
+ readonly status: 'applied';
55
+ readonly opIndex: number;
56
+ }
57
+ | {
58
+ readonly status: 'conflict';
59
+ readonly conflict: ConflictRecord;
60
+ }
61
+ | {
62
+ readonly status: 'error';
63
+ readonly rejection: RejectionRecord;
64
+ };
65
+
66
+ export interface CommitOutcome {
67
+ /** Monotonic local journal order; not a server sequence. */
68
+ readonly sequence: number;
69
+ readonly clientCommitId: string;
70
+ readonly status: CommitOutcomeStatus;
71
+ readonly recordedAtMs: number;
72
+ readonly results: readonly CommitOperationOutcome[];
73
+ readonly resolution: CommitOutcomeResolution;
74
+ readonly resolvedAtMs?: number;
75
+ readonly replacementClientCommitId?: string;
76
+ }
77
+
78
+ export interface CommitOutcomeQuery {
79
+ /** Newest-first result cap. Defaults to all retained entries. */
80
+ readonly limit?: number;
81
+ /** Only unresolved conflict/rejection outcomes. */
82
+ readonly activeOnly?: boolean;
83
+ }
84
+
85
+ export interface ResolveCommitOutcomeInput {
86
+ readonly clientCommitId: string;
87
+ readonly resolution: Exclude<CommitOutcomeResolution, 'active'>;
88
+ readonly replacementClientCommitId?: string;
89
+ }
90
+
91
+ interface StoredConflictRecord extends Omit<ConflictRecord, 'serverRow'> {
92
+ readonly serverRow: Readonly<Record<string, JsonRowValue>>;
93
+ }
94
+
95
+ type StoredCommitOperationOutcome =
96
+ | Extract<CommitOperationOutcome, { status: 'applied' }>
97
+ | { readonly status: 'conflict'; readonly conflict: StoredConflictRecord }
98
+ | Extract<CommitOperationOutcome, { status: 'error' }>;
99
+
100
+ function encodeResults(results: readonly CommitOperationOutcome[]): string {
101
+ const stored: StoredCommitOperationOutcome[] = results.map((result) => {
102
+ if (result.status !== 'conflict') return result;
103
+ return {
104
+ status: 'conflict',
105
+ conflict: {
106
+ ...result.conflict,
107
+ serverRow: Object.fromEntries(
108
+ Object.entries(result.conflict.serverRow).map(([key, value]) => [
109
+ key,
110
+ rowValueToJson(value),
111
+ ]),
112
+ ),
113
+ },
114
+ };
115
+ });
116
+ return JSON.stringify(stored);
117
+ }
118
+
119
+ function decodeResults(raw: string): CommitOperationOutcome[] {
120
+ const stored = JSON.parse(raw) as StoredCommitOperationOutcome[];
121
+ return stored.map((result) => {
122
+ if (result.status !== 'conflict') return result;
123
+ return {
124
+ status: 'conflict',
125
+ conflict: {
126
+ ...result.conflict,
127
+ serverRow: Object.fromEntries(
128
+ Object.entries(result.conflict.serverRow).map(([key, value]) => [
129
+ key,
130
+ jsonToRowValue(value),
131
+ ]),
132
+ ),
133
+ },
134
+ };
135
+ });
136
+ }
137
+
138
+ function parseOutcome(row: Readonly<Record<string, unknown>>): CommitOutcome {
139
+ return {
140
+ sequence: row.seq as number,
141
+ clientCommitId: row.client_commit_id as string,
142
+ status: row.status as CommitOutcomeStatus,
143
+ recordedAtMs: row.recorded_at_ms as number,
144
+ results: decodeResults(row.results as string),
145
+ resolution: row.resolution as CommitOutcomeResolution,
146
+ ...(typeof row.resolved_at_ms === 'number'
147
+ ? { resolvedAtMs: row.resolved_at_ms }
148
+ : {}),
149
+ ...(typeof row.replacement_client_commit_id === 'string'
150
+ ? { replacementClientCommitId: row.replacement_client_commit_id }
151
+ : {}),
152
+ };
153
+ }
154
+
155
+ export function recordCommitOutcome(
156
+ db: ClientDatabase,
157
+ outcome: Omit<CommitOutcome, 'sequence' | 'resolution'>,
158
+ ): CommitOutcome {
159
+ db.exec(
160
+ `INSERT INTO _syncular_commit_outcomes(
161
+ client_commit_id, status, recorded_at_ms, results, resolution
162
+ ) VALUES (?, ?, ?, ?, 'active')`,
163
+ [
164
+ outcome.clientCommitId,
165
+ outcome.status,
166
+ outcome.recordedAtMs,
167
+ encodeResults(outcome.results),
168
+ ],
169
+ );
170
+ return commitOutcome(db, outcome.clientCommitId) as CommitOutcome;
171
+ }
172
+
173
+ export function commitOutcome(
174
+ db: ClientDatabase,
175
+ clientCommitId: string,
176
+ ): CommitOutcome | undefined {
177
+ const row = db.query(
178
+ `SELECT seq, client_commit_id, status, recorded_at_ms, results,
179
+ resolution, resolved_at_ms, replacement_client_commit_id
180
+ FROM _syncular_commit_outcomes WHERE client_commit_id = ?`,
181
+ [clientCommitId],
182
+ )[0];
183
+ return row === undefined ? undefined : parseOutcome(row);
184
+ }
185
+
186
+ export function listCommitOutcomes(
187
+ db: ClientDatabase,
188
+ query: CommitOutcomeQuery = {},
189
+ ): CommitOutcome[] {
190
+ const limit = query.limit;
191
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) {
192
+ throw new ClientSyncError(
193
+ 'sync.invalid_request',
194
+ 'commit outcome limit must be a positive safe integer',
195
+ );
196
+ }
197
+ const where = query.activeOnly
198
+ ? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
199
+ : '';
200
+ const rows = db.query(
201
+ `SELECT seq, client_commit_id, status, recorded_at_ms, results,
202
+ resolution, resolved_at_ms, replacement_client_commit_id
203
+ FROM _syncular_commit_outcomes ${where}
204
+ ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`,
205
+ limit === undefined ? [] : [limit],
206
+ );
207
+ return rows.map(parseOutcome);
208
+ }
209
+
210
+ export function persistCommitOutcomeResolution(
211
+ db: ClientDatabase,
212
+ input: ResolveCommitOutcomeInput,
213
+ nowMs: number,
214
+ ): CommitOutcome | undefined {
215
+ db.exec(
216
+ `UPDATE _syncular_commit_outcomes
217
+ SET resolution = ?, resolved_at_ms = ?, replacement_client_commit_id = ?
218
+ WHERE client_commit_id = ? AND resolution = 'active'`,
219
+ [
220
+ input.resolution,
221
+ nowMs,
222
+ input.replacementClientCommitId ?? null,
223
+ input.clientCommitId,
224
+ ],
225
+ );
226
+ return commitOutcome(db, input.clientCommitId);
227
+ }
228
+
229
+ /**
230
+ * Bound journal growth without deleting active failures. If active failures
231
+ * alone exceed the cap the journal intentionally remains over-capacity.
232
+ */
233
+ export function pruneCommitOutcomes(
234
+ db: ClientDatabase,
235
+ maxEntries: number,
236
+ ): number {
237
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
238
+ throw new ClientSyncError(
239
+ 'sync.invalid_request',
240
+ 'outcome retention maxEntries must be a positive safe integer',
241
+ );
242
+ }
243
+ const count = db.query(
244
+ 'SELECT COUNT(*) AS count FROM _syncular_commit_outcomes',
245
+ )[0]?.count as number | undefined;
246
+ const excess = Math.max(0, (count ?? 0) - maxEntries);
247
+ if (excess === 0) return 0;
248
+ const candidates = db.query(
249
+ `SELECT seq FROM _syncular_commit_outcomes
250
+ WHERE status IN ('applied', 'cached') OR resolution != 'active'
251
+ ORDER BY seq ASC LIMIT ?`,
252
+ [excess],
253
+ );
254
+ for (const candidate of candidates) {
255
+ db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq = ?', [
256
+ candidate.seq as number,
257
+ ]);
258
+ }
259
+ return candidates.length;
260
+ }
261
+
262
+ export function activeFailureRecords(outcomes: readonly CommitOutcome[]): {
263
+ readonly conflicts: ConflictRecord[];
264
+ readonly rejections: RejectionRecord[];
265
+ } {
266
+ const conflicts: ConflictRecord[] = [];
267
+ const rejections: RejectionRecord[] = [];
268
+ for (const outcome of outcomes) {
269
+ if (outcome.resolution !== 'active') continue;
270
+ for (const result of outcome.results) {
271
+ if (result.status === 'conflict') conflicts.push(result.conflict);
272
+ if (result.status === 'error') rejections.push(result.rejection);
273
+ }
274
+ }
275
+ return { conflicts, rejections };
276
+ }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ CommitOutcome,
2
3
  QueryReadSpec,
3
4
  QuerySnapshot,
4
5
  WindowCoverage,
@@ -49,6 +50,9 @@ export interface ReactiveQueryClient {
49
50
  readonly rejections:
50
51
  | readonly unknown[]
51
52
  | (() => readonly unknown[] | Promise<readonly unknown[]>);
53
+ commitOutcomes():
54
+ | readonly CommitOutcome[]
55
+ | Promise<readonly CommitOutcome[]>;
52
56
  setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
53
57
  windowState(base: WindowBase): WindowState | Promise<WindowState>;
54
58
  }
@@ -82,6 +86,12 @@ export interface ConflictStoreSnapshot<
82
86
  readonly isLoading: boolean;
83
87
  }
84
88
 
89
+ export interface OutcomeStoreSnapshot {
90
+ readonly outcomes: readonly CommitOutcome[];
91
+ readonly error: Error | undefined;
92
+ readonly isLoading: boolean;
93
+ }
94
+
85
95
  function errorOf(value: unknown): Error {
86
96
  return value instanceof Error ? value : new Error(String(value));
87
97
  }
@@ -497,6 +507,7 @@ export class ReactiveClientStore {
497
507
  #offChange: (() => void) | undefined;
498
508
  readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
499
509
  readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
510
+ readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
500
511
 
501
512
  constructor(readonly client: ReactiveQueryClient) {
502
513
  const status = new ValueEntry<StatusStoreSnapshot>(
@@ -537,10 +548,26 @@ export class ReactiveClientStore {
537
548
  }
538
549
  },
539
550
  );
551
+ const outcomes = new ValueEntry<OutcomeStoreSnapshot>(
552
+ { outcomes: [], error: undefined, isLoading: true },
553
+ async () => {
554
+ try {
555
+ return {
556
+ outcomes: await client.commitOutcomes(),
557
+ error: undefined,
558
+ isLoading: false,
559
+ };
560
+ } catch (error) {
561
+ return { outcomes: [], error: errorOf(error), isLoading: false };
562
+ }
563
+ },
564
+ );
540
565
  this.status = status;
541
566
  this.conflicts = conflicts;
567
+ this.outcomes = outcomes;
542
568
  status.refresh();
543
569
  conflicts.refresh();
570
+ outcomes.refresh();
544
571
  this.start();
545
572
  }
546
573
 
@@ -681,6 +708,7 @@ export class ReactiveClientStore {
681
708
  if (batch.conflictsChanged || batch.rejectionsChanged) {
682
709
  this.conflicts.refresh();
683
710
  }
711
+ if (batch.outcomesChanged) this.outcomes.refresh();
684
712
  });
685
713
  }
686
714
 
package/src/schema.ts CHANGED
@@ -288,6 +288,18 @@ export function ensureLocalSchema(
288
288
  client_commit_id TEXT NOT NULL UNIQUE,
289
289
  created_at_ms INTEGER NOT NULL,
290
290
  operations TEXT NOT NULL)`);
291
+ db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
292
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
293
+ client_commit_id TEXT NOT NULL UNIQUE,
294
+ status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
295
+ recorded_at_ms INTEGER NOT NULL,
296
+ results TEXT NOT NULL,
297
+ resolution TEXT NOT NULL DEFAULT 'active'
298
+ CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
299
+ resolved_at_ms INTEGER,
300
+ replacement_client_commit_id TEXT)`);
301
+ db.exec(`CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
302
+ ON _syncular_commit_outcomes(resolution, seq)`);
291
303
  db.exec(`CREATE TABLE IF NOT EXISTS _syncular_subscriptions(
292
304
  id TEXT PRIMARY KEY,
293
305
  tbl TEXT NOT NULL,
@@ -378,6 +378,11 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
378
378
  statusSnapshot: () => requireClient().statusSnapshot(),
379
379
  conflicts: () => requireClient().conflicts,
380
380
  rejections: () => requireClient().rejections,
381
+ commitOutcome: (clientCommitId) =>
382
+ requireClient().commitOutcome(clientCommitId),
383
+ commitOutcomes: (query) => requireClient().commitOutcomes(query),
384
+ resolveCommitOutcome: (input) =>
385
+ requireClient().resolveCommitOutcome(input),
381
386
  schemaFloor: () => requireClient().schemaFloor,
382
387
  leaseState: () => requireClient().leaseState,
383
388
  upgrading: () => requireClient().upgrading,
@@ -64,6 +64,11 @@ import {
64
64
  newTabId,
65
65
  } from './multi-tab';
66
66
  import type { OutboxCommit } from './outbox';
67
+ import type {
68
+ CommitOutcome,
69
+ CommitOutcomeQuery,
70
+ ResolveCommitOutcomeInput,
71
+ } from './outcomes';
67
72
  import type { ClientSchema } from './schema';
68
73
  import type { SubscriptionRecord } from './state';
69
74
  import type { WindowBase } from './window';
@@ -385,6 +390,22 @@ export class SyncClientHandle {
385
390
  return this.#call('rejections', []);
386
391
  }
387
392
 
393
+ commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined> {
394
+ return this.#call('commitOutcome', [clientCommitId]);
395
+ }
396
+
397
+ commitOutcomes(
398
+ query: CommitOutcomeQuery = {},
399
+ ): Promise<readonly CommitOutcome[]> {
400
+ return this.#call('commitOutcomes', [query]);
401
+ }
402
+
403
+ resolveCommitOutcome(
404
+ input: ResolveCommitOutcomeInput,
405
+ ): Promise<CommitOutcome> {
406
+ return this.#call('resolveCommitOutcome', [input]);
407
+ }
408
+
388
409
  schemaFloor(): Promise<SchemaFloor | undefined> {
389
410
  return this.#call('schemaFloor', []);
390
411
  }
@@ -39,6 +39,11 @@ import type {
39
39
  SyncStatusSnapshot,
40
40
  } from './invalidation';
41
41
  import type { OutboxCommit } from './outbox';
42
+ import type {
43
+ CommitOutcome,
44
+ CommitOutcomeQuery,
45
+ ResolveCommitOutcomeInput,
46
+ } from './outcomes';
42
47
  import type { ClientSchema } from './schema';
43
48
  import type { SubscriptionRecord } from './state';
44
49
  import type { WindowBase } from './window';
@@ -133,6 +138,9 @@ export interface WorkerApi {
133
138
  statusSnapshot(): SyncStatusSnapshot;
134
139
  conflicts(): readonly ConflictRecord[];
135
140
  rejections(): readonly RejectionRecord[];
141
+ commitOutcome(clientCommitId: string): CommitOutcome | undefined;
142
+ commitOutcomes(query?: CommitOutcomeQuery): readonly CommitOutcome[];
143
+ resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
136
144
  schemaFloor(): SchemaFloor | undefined;
137
145
  /** §7.3.5: the opaque auth-lease state, or undefined. */
138
146
  leaseState(): LeaseState | undefined;