@syncular/client 0.10.0 → 0.12.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
@@ -117,6 +117,11 @@ the same SQLite transaction that drains their outbox commit. Conflict entries
117
117
  retain the losing operation plus `serverVersion`/`serverRow`; active failures
118
118
  restore after restart and are never removed by retention. Configure the
119
119
  history cap with `limits.outcomeRetentionMaxEntries` (default 1,000).
120
+ Failed outcomes additionally retain the complete ordered local commit envelope
121
+ as `outcome.operations`, so a domain recovery flow can reconstruct siblings
122
+ that rolled back with the terminating operation. It stays in the protected
123
+ client database and is never added to the wire protocol, preferences, or
124
+ telemetry; successful and historical outcomes may omit it.
120
125
 
121
126
  Use `patch(table, rowId, partial, { baseVersion? })` for editor-style partial
122
127
  updates. The wire still carries a full row, but the durable local operation
package/dist/client.js CHANGED
@@ -1154,6 +1154,7 @@ export class SyncClient {
1154
1154
  status: 'rejected',
1155
1155
  recordedAtMs: this.#now(),
1156
1156
  results: [{ status: 'error', rejection }],
1157
+ operations: commit.operations,
1157
1158
  });
1158
1159
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
1159
1160
  batch.status();
@@ -1806,6 +1807,7 @@ export class SyncClient {
1806
1807
  : 'rejected',
1807
1808
  recordedAtMs: this.#now(),
1808
1809
  results: outcomeResults,
1810
+ operations: commit.operations,
1809
1811
  });
1810
1812
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
1811
1813
  batch.outcomes();
@@ -2083,6 +2085,7 @@ export class SyncClient {
2083
2085
  status: 'rejected',
2084
2086
  recordedAtMs: this.#now(),
2085
2087
  results,
2088
+ operations: commit.operations,
2086
2089
  });
2087
2090
  }
2088
2091
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
@@ -51,6 +51,12 @@ export interface CommitOutcome {
51
51
  readonly status: CommitOutcomeStatus;
52
52
  readonly recordedAtMs: number;
53
53
  readonly results: readonly CommitOperationOutcome[];
54
+ /**
55
+ * Complete local failed-commit envelope, retained after outbox drain so an
56
+ * authorized application can reconstruct atomic aggregate intent. Absent
57
+ * for successful and historical outcomes. Never sent over the wire.
58
+ */
59
+ readonly operations?: readonly OutboxOperation[];
54
60
  readonly resolution: CommitOutcomeResolution;
55
61
  readonly resolvedAtMs?: number;
56
62
  readonly replacementClientCommitId?: string;
package/dist/outcomes.js CHANGED
@@ -41,6 +41,9 @@ function parseOutcome(row) {
41
41
  status: row.status,
42
42
  recordedAtMs: row.recorded_at_ms,
43
43
  results: decodeResults(row.results),
44
+ ...(typeof row.operations === 'string'
45
+ ? { operations: JSON.parse(row.operations) }
46
+ : {}),
44
47
  resolution: row.resolution,
45
48
  ...(typeof row.resolved_at_ms === 'number'
46
49
  ? { resolvedAtMs: row.resolved_at_ms }
@@ -52,17 +55,20 @@ function parseOutcome(row) {
52
55
  }
53
56
  export function recordCommitOutcome(db, outcome) {
54
57
  db.exec(`INSERT INTO _syncular_commit_outcomes(
55
- client_commit_id, status, recorded_at_ms, results, resolution
56
- ) VALUES (?, ?, ?, ?, 'active')`, [
58
+ client_commit_id, status, recorded_at_ms, results, operations, resolution
59
+ ) VALUES (?, ?, ?, ?, ?, 'active')`, [
57
60
  outcome.clientCommitId,
58
61
  outcome.status,
59
62
  outcome.recordedAtMs,
60
63
  encodeResults(outcome.results),
64
+ outcome.operations === undefined
65
+ ? null
66
+ : JSON.stringify(outcome.operations),
61
67
  ]);
62
68
  return commitOutcome(db, outcome.clientCommitId);
63
69
  }
64
70
  export function commitOutcome(db, clientCommitId) {
65
- const row = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results,
71
+ const row = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results, operations,
66
72
  resolution, resolved_at_ms, replacement_client_commit_id
67
73
  FROM _syncular_commit_outcomes WHERE client_commit_id = ?`, [clientCommitId])[0];
68
74
  return row === undefined ? undefined : parseOutcome(row);
@@ -75,7 +81,7 @@ export function listCommitOutcomes(db, query = {}) {
75
81
  const where = query.activeOnly
76
82
  ? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
77
83
  : '';
78
- const rows = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results,
84
+ const rows = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results, operations,
79
85
  resolution, resolved_at_ms, replacement_client_commit_id
80
86
  FROM _syncular_commit_outcomes ${where}
81
87
  ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
package/dist/schema.js CHANGED
@@ -190,10 +190,19 @@ export function ensureLocalSchema(db, schema) {
190
190
  status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
191
191
  recorded_at_ms INTEGER NOT NULL,
192
192
  results TEXT NOT NULL,
193
+ operations TEXT,
193
194
  resolution TEXT NOT NULL DEFAULT 'active'
194
195
  CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
195
196
  resolved_at_ms INTEGER,
196
197
  replacement_client_commit_id TEXT)`);
198
+ // Outcomes created before durable aggregate recovery retain no commit
199
+ // envelope. New failed outcomes store it in the protected client DB.
200
+ try {
201
+ db.exec('ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations TEXT');
202
+ }
203
+ catch {
204
+ // column already exists — the CREATE above included it
205
+ }
197
206
  db.exec(`CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
198
207
  ON _syncular_commit_outcomes(resolution, seq)`);
199
208
  db.exec(`CREATE TABLE IF NOT EXISTS _syncular_subscriptions(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.10.0",
3
+ "version": "0.12.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.10.0"
84
+ "@syncular/core": "0.12.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.10.0",
95
+ "@syncular/server": "0.12.0",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/client.ts CHANGED
@@ -1737,6 +1737,7 @@ export class SyncClient {
1737
1737
  status: 'rejected',
1738
1738
  recordedAtMs: this.#now(),
1739
1739
  results: [{ status: 'error', rejection }],
1740
+ operations: commit.operations,
1740
1741
  });
1741
1742
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
1742
1743
  batch.status();
@@ -2543,6 +2544,7 @@ export class SyncClient {
2543
2544
  : 'rejected',
2544
2545
  recordedAtMs: this.#now(),
2545
2546
  results: outcomeResults,
2547
+ operations: commit.operations,
2546
2548
  });
2547
2549
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2548
2550
  batch.outcomes();
@@ -2900,6 +2902,7 @@ export class SyncClient {
2900
2902
  status: 'rejected',
2901
2903
  recordedAtMs: this.#now(),
2902
2904
  results,
2905
+ operations: commit.operations,
2903
2906
  });
2904
2907
  }
2905
2908
  pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
package/src/outcomes.ts CHANGED
@@ -70,6 +70,12 @@ export interface CommitOutcome {
70
70
  readonly status: CommitOutcomeStatus;
71
71
  readonly recordedAtMs: number;
72
72
  readonly results: readonly CommitOperationOutcome[];
73
+ /**
74
+ * Complete local failed-commit envelope, retained after outbox drain so an
75
+ * authorized application can reconstruct atomic aggregate intent. Absent
76
+ * for successful and historical outcomes. Never sent over the wire.
77
+ */
78
+ readonly operations?: readonly OutboxOperation[];
73
79
  readonly resolution: CommitOutcomeResolution;
74
80
  readonly resolvedAtMs?: number;
75
81
  readonly replacementClientCommitId?: string;
@@ -142,6 +148,9 @@ function parseOutcome(row: Readonly<Record<string, unknown>>): CommitOutcome {
142
148
  status: row.status as CommitOutcomeStatus,
143
149
  recordedAtMs: row.recorded_at_ms as number,
144
150
  results: decodeResults(row.results as string),
151
+ ...(typeof row.operations === 'string'
152
+ ? { operations: JSON.parse(row.operations) as OutboxOperation[] }
153
+ : {}),
145
154
  resolution: row.resolution as CommitOutcomeResolution,
146
155
  ...(typeof row.resolved_at_ms === 'number'
147
156
  ? { resolvedAtMs: row.resolved_at_ms }
@@ -158,13 +167,16 @@ export function recordCommitOutcome(
158
167
  ): CommitOutcome {
159
168
  db.exec(
160
169
  `INSERT INTO _syncular_commit_outcomes(
161
- client_commit_id, status, recorded_at_ms, results, resolution
162
- ) VALUES (?, ?, ?, ?, 'active')`,
170
+ client_commit_id, status, recorded_at_ms, results, operations, resolution
171
+ ) VALUES (?, ?, ?, ?, ?, 'active')`,
163
172
  [
164
173
  outcome.clientCommitId,
165
174
  outcome.status,
166
175
  outcome.recordedAtMs,
167
176
  encodeResults(outcome.results),
177
+ outcome.operations === undefined
178
+ ? null
179
+ : JSON.stringify(outcome.operations),
168
180
  ],
169
181
  );
170
182
  return commitOutcome(db, outcome.clientCommitId) as CommitOutcome;
@@ -175,7 +187,7 @@ export function commitOutcome(
175
187
  clientCommitId: string,
176
188
  ): CommitOutcome | undefined {
177
189
  const row = db.query(
178
- `SELECT seq, client_commit_id, status, recorded_at_ms, results,
190
+ `SELECT seq, client_commit_id, status, recorded_at_ms, results, operations,
179
191
  resolution, resolved_at_ms, replacement_client_commit_id
180
192
  FROM _syncular_commit_outcomes WHERE client_commit_id = ?`,
181
193
  [clientCommitId],
@@ -198,7 +210,7 @@ export function listCommitOutcomes(
198
210
  ? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
199
211
  : '';
200
212
  const rows = db.query(
201
- `SELECT seq, client_commit_id, status, recorded_at_ms, results,
213
+ `SELECT seq, client_commit_id, status, recorded_at_ms, results, operations,
202
214
  resolution, resolved_at_ms, replacement_client_commit_id
203
215
  FROM _syncular_commit_outcomes ${where}
204
216
  ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`,
package/src/schema.ts CHANGED
@@ -294,10 +294,20 @@ export function ensureLocalSchema(
294
294
  status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
295
295
  recorded_at_ms INTEGER NOT NULL,
296
296
  results TEXT NOT NULL,
297
+ operations TEXT,
297
298
  resolution TEXT NOT NULL DEFAULT 'active'
298
299
  CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
299
300
  resolved_at_ms INTEGER,
300
301
  replacement_client_commit_id TEXT)`);
302
+ // Outcomes created before durable aggregate recovery retain no commit
303
+ // envelope. New failed outcomes store it in the protected client DB.
304
+ try {
305
+ db.exec(
306
+ 'ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations TEXT',
307
+ );
308
+ } catch {
309
+ // column already exists — the CREATE above included it
310
+ }
301
311
  db.exec(`CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
302
312
  ON _syncular_commit_outcomes(resolution, seq)`);
303
313
  db.exec(`CREATE TABLE IF NOT EXISTS _syncular_subscriptions(