@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,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
@@ -385,6 +385,7 @@ export class ReactiveClientStore {
385
385
  #offChange;
386
386
  status;
387
387
  conflicts;
388
+ outcomes;
388
389
  constructor(client) {
389
390
  this.client = client;
390
391
  const status = new ValueEntry({ status: undefined, error: undefined, isLoading: true }, async () => {
@@ -421,10 +422,24 @@ export class ReactiveClientStore {
421
422
  };
422
423
  }
423
424
  });
425
+ const outcomes = new ValueEntry({ outcomes: [], error: undefined, isLoading: true }, async () => {
426
+ try {
427
+ return {
428
+ outcomes: await client.commitOutcomes(),
429
+ error: undefined,
430
+ isLoading: false,
431
+ };
432
+ }
433
+ catch (error) {
434
+ return { outcomes: [], error: errorOf(error), isLoading: false };
435
+ }
436
+ });
424
437
  this.status = status;
425
438
  this.conflicts = conflicts;
439
+ this.outcomes = outcomes;
426
440
  status.refresh();
427
441
  conflicts.refresh();
442
+ outcomes.refresh();
428
443
  this.start();
429
444
  }
430
445
  query(spec) {
@@ -563,6 +578,8 @@ export class ReactiveClientStore {
563
578
  if (batch.conflictsChanged || batch.rejectionsChanged) {
564
579
  this.conflicts.refresh();
565
580
  }
581
+ if (batch.outcomesChanged)
582
+ this.outcomes.refresh();
566
583
  });
567
584
  }
568
585
  dispose() {
package/dist/schema.js CHANGED
@@ -184,6 +184,18 @@ export function ensureLocalSchema(db, schema) {
184
184
  client_commit_id TEXT NOT NULL UNIQUE,
185
185
  created_at_ms INTEGER NOT NULL,
186
186
  operations TEXT NOT NULL)`);
187
+ db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
188
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
189
+ client_commit_id TEXT NOT NULL UNIQUE,
190
+ status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
191
+ recorded_at_ms INTEGER NOT NULL,
192
+ results TEXT NOT NULL,
193
+ resolution TEXT NOT NULL DEFAULT 'active'
194
+ CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
195
+ resolved_at_ms INTEGER,
196
+ replacement_client_commit_id TEXT)`);
197
+ db.exec(`CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
198
+ ON _syncular_commit_outcomes(resolution, seq)`);
187
199
  db.exec(`CREATE TABLE IF NOT EXISTS _syncular_subscriptions(
188
200
  id TEXT PRIMARY KEY,
189
201
  tbl TEXT NOT NULL,
@@ -286,6 +286,9 @@ export function startSyncWorker(overrides = {}) {
286
286
  statusSnapshot: () => requireClient().statusSnapshot(),
287
287
  conflicts: () => requireClient().conflicts,
288
288
  rejections: () => requireClient().rejections,
289
+ commitOutcome: (clientCommitId) => requireClient().commitOutcome(clientCommitId),
290
+ commitOutcomes: (query) => requireClient().commitOutcomes(query),
291
+ resolveCommitOutcome: (input) => requireClient().resolveCommitOutcome(input),
289
292
  schemaFloor: () => requireClient().schemaFloor,
290
293
  leaseState: () => requireClient().leaseState,
291
294
  upgrading: () => requireClient().upgrading,
@@ -29,6 +29,7 @@ import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type Inv
29
29
  import { type LeaderLease, type LeaderLock } from './leader-lock.js';
30
30
  import { type CrossTabChannel, FollowerLink, LeaderBridge } from './multi-tab.js';
31
31
  import type { OutboxCommit } from './outbox.js';
32
+ import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
32
33
  import type { ClientSchema } from './schema.js';
33
34
  import type { SubscriptionRecord } from './state.js';
34
35
  import type { WindowBase } from './window.js';
@@ -149,6 +150,9 @@ export declare class SyncClientHandle {
149
150
  statusSnapshot(): Promise<SyncStatusSnapshot>;
150
151
  conflicts(): Promise<readonly ConflictRecord[]>;
151
152
  rejections(): Promise<readonly RejectionRecord[]>;
153
+ commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined>;
154
+ commitOutcomes(query?: CommitOutcomeQuery): Promise<readonly CommitOutcome[]>;
155
+ resolveCommitOutcome(input: ResolveCommitOutcomeInput): Promise<CommitOutcome>;
152
156
  schemaFloor(): Promise<SchemaFloor | undefined>;
153
157
  leaseState(): Promise<LeaseState | undefined>;
154
158
  /** §7.4.5: true while a schema-bump reset + first re-bootstrap runs. */
@@ -186,6 +186,15 @@ export class SyncClientHandle {
186
186
  rejections() {
187
187
  return this.#call('rejections', []);
188
188
  }
189
+ commitOutcome(clientCommitId) {
190
+ return this.#call('commitOutcome', [clientCommitId]);
191
+ }
192
+ commitOutcomes(query = {}) {
193
+ return this.#call('commitOutcomes', [query]);
194
+ }
195
+ resolveCommitOutcome(input) {
196
+ return this.#call('resolveCommitOutcome', [input]);
197
+ }
189
198
  schemaFloor() {
190
199
  return this.#call('schemaFloor', []);
191
200
  }
@@ -22,6 +22,7 @@ import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryRead
22
22
  import type { SqlRow, SqlValue } from './database.js';
23
23
  import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
24
24
  import type { OutboxCommit } from './outbox.js';
25
+ import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
25
26
  import type { ClientSchema } from './schema.js';
26
27
  import type { SubscriptionRecord } from './state.js';
27
28
  import type { WindowBase } from './window.js';
@@ -93,6 +94,9 @@ export interface WorkerApi {
93
94
  statusSnapshot(): SyncStatusSnapshot;
94
95
  conflicts(): readonly ConflictRecord[];
95
96
  rejections(): readonly RejectionRecord[];
97
+ commitOutcome(clientCommitId: string): CommitOutcome | undefined;
98
+ commitOutcomes(query?: CommitOutcomeQuery): readonly CommitOutcome[];
99
+ resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
96
100
  schemaFloor(): SchemaFloor | undefined;
97
101
  /** §7.3.5: the opaque auth-lease state, or undefined. */
98
102
  leaseState(): LeaseState | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.7.0"
84
+ "@syncular/core": "0.9.0"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.7.0",
95
+ "@syncular/server": "0.9.0",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }