@syncular/client 0.8.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
@@ -118,6 +118,17 @@ 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
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
+
121
132
  Resolution is explicit and one-way: conflicts can keep the server result or
122
133
  link to a replacement commit, rejections can link to a replacement, and
123
134
  successful history may be dismissed. See SPEC §7.2.1.
package/dist/client.js CHANGED
@@ -963,9 +963,12 @@ export class SyncClient {
963
963
  * Returns the generated `clientCommitId`.
964
964
  */
965
965
  mutate(mutations) {
966
+ return this.#recordMutations(mutations);
967
+ }
968
+ #recordMutations(mutations, changedFieldsByIndex = []) {
966
969
  this.#requireStarted();
967
970
  const clientCommitId = crypto.randomUUID();
968
- const operations = mutations.map((mutation) => {
971
+ const operations = mutations.map((mutation, index) => {
969
972
  const table = this.#table(mutation.table);
970
973
  if (mutation.op === 'delete') {
971
974
  return {
@@ -994,6 +997,9 @@ export class SyncClient {
994
997
  ? { baseVersion: mutation.baseVersion }
995
998
  : {}),
996
999
  values: json,
1000
+ ...(changedFieldsByIndex[index] !== undefined
1001
+ ? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
1002
+ : {}),
997
1003
  };
998
1004
  });
999
1005
  this.#applyBatch((batch) => {
@@ -1033,10 +1039,11 @@ export class SyncClient {
1033
1039
  for (const column of compiled.columns) {
1034
1040
  record[column.name] = fromSqlValue(column, row[column.name] ?? null);
1035
1041
  }
1036
- for (const [name, value] of normalizeRecordKeys(compiled, partial)) {
1042
+ const normalizedPartial = normalizeRecordKeys(compiled, partial);
1043
+ for (const [name, value] of normalizedPartial) {
1037
1044
  record[name] = value;
1038
1045
  }
1039
- return this.mutate([
1046
+ return this.#recordMutations([
1040
1047
  {
1041
1048
  table,
1042
1049
  op: 'upsert',
@@ -1045,7 +1052,7 @@ export class SyncClient {
1045
1052
  ? { baseVersion: options.baseVersion }
1046
1053
  : {}),
1047
1054
  },
1048
- ]);
1055
+ ], [[...normalizedPartial.keys()].sort()]);
1049
1056
  }
1050
1057
  /** Host-facing patch result with explicit network work intent (§7.5). */
1051
1058
  patchCommand(table, rowId, partial, options) {
@@ -1519,6 +1526,12 @@ export class SyncClient {
1519
1526
  const summary = emptySummary(sentCommits.length);
1520
1527
  const commitsById = new Map(sentCommits.map((commit) => [commit.clientCommitId, commit]));
1521
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
+ }
1522
1535
  const header = message.frames[0];
1523
1536
  if (header?.type !== 'RESP_HEADER') {
1524
1537
  throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
@@ -1563,7 +1576,10 @@ export class SyncClient {
1563
1576
  });
1564
1577
  break;
1565
1578
  case 'PUSH_RESULT':
1566
- 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.
1567
1583
  break;
1568
1584
  case 'SUB_START': {
1569
1585
  const sub = subsById.get(frame.id);
@@ -1710,7 +1726,7 @@ export class SyncClient {
1710
1726
  }
1711
1727
  return { ...summary, bootstrapping };
1712
1728
  }
1713
- #handlePushResult(frame, commitsById, summary, batch) {
1729
+ #handlePushResult(frame, commitsById, summary, batch, rejectionDetails) {
1714
1730
  const commit = commitsById.get(frame.clientCommitId);
1715
1731
  if (commit === undefined)
1716
1732
  return;
@@ -1765,12 +1781,14 @@ export class SyncClient {
1765
1781
  this.#config.onConflict?.(conflict);
1766
1782
  }
1767
1783
  else if (result.status === 'error') {
1784
+ const details = rejectionDetails?.get(result.opIndex);
1768
1785
  const rejection = {
1769
1786
  clientCommitId: frame.clientCommitId,
1770
1787
  opIndex: result.opIndex,
1771
1788
  code: result.code,
1772
1789
  message: result.message,
1773
1790
  retryable: result.retryable,
1791
+ ...(details !== undefined ? { details } : {}),
1774
1792
  ...(operation !== undefined ? { operation } : {}),
1775
1793
  };
1776
1794
  this.#rejections.push(rejection);
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;
@@ -6,7 +6,7 @@
6
6
  * restart can never turn "rejected" into an inferred success. Conflict payloads
7
7
  * deliberately stay local; retention never deletes an unresolved failure.
8
8
  */
9
- import type { RowValue } from '@syncular/core';
9
+ import type { RejectionDetails, RowValue } from '@syncular/core';
10
10
  import type { ClientDatabase } from './database.js';
11
11
  import type { OutboxOperation } from './outbox.js';
12
12
  export interface ConflictRecord {
@@ -28,6 +28,8 @@ export interface RejectionRecord {
28
28
  readonly code: string;
29
29
  readonly message: string;
30
30
  readonly retryable: boolean;
31
+ /** Bounded host-declared metadata safe for authorized recovery UI. */
32
+ readonly details?: RejectionDetails;
31
33
  readonly operation?: OutboxOperation;
32
34
  }
33
35
  export type CommitOutcomeStatus = 'applied' | 'cached' | 'conflict' | 'rejected';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.8.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.8.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.8.0",
95
+ "@syncular/server": "0.9.0",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/client.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  parseRealtimeServerEvent,
23
23
  REALTIME_TAG_DELTA,
24
24
  REALTIME_TAG_ROUND,
25
+ type RejectionDetails,
25
26
  type RequestFrame,
26
27
  type ResponseMessage,
27
28
  type RowColumn,
@@ -1502,9 +1503,16 @@ export class SyncClient {
1502
1503
  * Returns the generated `clientCommitId`.
1503
1504
  */
1504
1505
  mutate(mutations: readonly MutationInput[]): string {
1506
+ return this.#recordMutations(mutations);
1507
+ }
1508
+
1509
+ #recordMutations(
1510
+ mutations: readonly MutationInput[],
1511
+ changedFieldsByIndex: readonly (readonly string[] | undefined)[] = [],
1512
+ ): string {
1505
1513
  this.#requireStarted();
1506
1514
  const clientCommitId = crypto.randomUUID();
1507
- const operations: OutboxOperation[] = mutations.map((mutation) => {
1515
+ const operations: OutboxOperation[] = mutations.map((mutation, index) => {
1508
1516
  const table = this.#table(mutation.table);
1509
1517
  if (mutation.op === 'delete') {
1510
1518
  return {
@@ -1536,6 +1544,9 @@ export class SyncClient {
1536
1544
  ? { baseVersion: mutation.baseVersion }
1537
1545
  : {}),
1538
1546
  values: json,
1547
+ ...(changedFieldsByIndex[index] !== undefined
1548
+ ? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
1549
+ : {}),
1539
1550
  };
1540
1551
  });
1541
1552
  this.#applyBatch((batch) => {
@@ -1588,19 +1599,23 @@ export class SyncClient {
1588
1599
  for (const column of compiled.columns as readonly RowColumn[]) {
1589
1600
  record[column.name] = fromSqlValue(column, row[column.name] ?? null);
1590
1601
  }
1591
- for (const [name, value] of normalizeRecordKeys(compiled, partial)) {
1602
+ const normalizedPartial = normalizeRecordKeys(compiled, partial);
1603
+ for (const [name, value] of normalizedPartial) {
1592
1604
  record[name] = value;
1593
1605
  }
1594
- return this.mutate([
1595
- {
1596
- table,
1597
- op: 'upsert',
1598
- values: record,
1599
- ...(options?.baseVersion !== undefined
1600
- ? { baseVersion: options.baseVersion }
1601
- : {}),
1602
- },
1603
- ]);
1606
+ return this.#recordMutations(
1607
+ [
1608
+ {
1609
+ table,
1610
+ op: 'upsert',
1611
+ values: record,
1612
+ ...(options?.baseVersion !== undefined
1613
+ ? { baseVersion: options.baseVersion }
1614
+ : {}),
1615
+ },
1616
+ ],
1617
+ [[...normalizedPartial.keys()].sort()],
1618
+ );
1604
1619
  }
1605
1620
 
1606
1621
  /** Host-facing patch result with explicit network work intent (§7.5). */
@@ -2150,6 +2165,17 @@ export class SyncClient {
2150
2165
  const subsById = new Map(
2151
2166
  (sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]),
2152
2167
  );
2168
+ const rejectionDetailsByCommit = new Map<
2169
+ string,
2170
+ ReadonlyMap<number, RejectionDetails>
2171
+ >();
2172
+ for (const frame of message.frames) {
2173
+ if (frame.type !== 'PUSH_RESULT_DETAILS') continue;
2174
+ rejectionDetailsByCommit.set(
2175
+ frame.clientCommitId,
2176
+ new Map(frame.entries.map((entry) => [entry.opIndex, entry.details])),
2177
+ );
2178
+ }
2153
2179
 
2154
2180
  const header = message.frames[0];
2155
2181
  if (header?.type !== 'RESP_HEADER') {
@@ -2198,9 +2224,18 @@ export class SyncClient {
2198
2224
  break;
2199
2225
  case 'PUSH_RESULT':
2200
2226
  this.#applyBatch((batch) =>
2201
- this.#handlePushResult(frame, commitsById, summary, batch),
2227
+ this.#handlePushResult(
2228
+ frame,
2229
+ commitsById,
2230
+ summary,
2231
+ batch,
2232
+ rejectionDetailsByCommit.get(frame.clientCommitId),
2233
+ ),
2202
2234
  );
2203
2235
  break;
2236
+ case 'PUSH_RESULT_DETAILS':
2237
+ // Pre-indexed above so companion ordering remains wire-additive.
2238
+ break;
2204
2239
  case 'SUB_START': {
2205
2240
  const sub = subsById.get(frame.id);
2206
2241
  const fresh =
@@ -2427,6 +2462,7 @@ export class SyncClient {
2427
2462
  commitsById: ReadonlyMap<string, OutboxCommit>,
2428
2463
  summary: MutableSummary,
2429
2464
  batch: ChangeAccumulator,
2465
+ rejectionDetails: ReadonlyMap<number, RejectionDetails> | undefined,
2430
2466
  ): void {
2431
2467
  const commit = commitsById.get(frame.clientCommitId);
2432
2468
  if (commit === undefined) return;
@@ -2483,12 +2519,14 @@ export class SyncClient {
2483
2519
  summary.conflicts.push(conflict);
2484
2520
  this.#config.onConflict?.(conflict);
2485
2521
  } else if (result.status === 'error') {
2522
+ const details = rejectionDetails?.get(result.opIndex);
2486
2523
  const rejection: RejectionRecord = {
2487
2524
  clientCommitId: frame.clientCommitId,
2488
2525
  opIndex: result.opIndex,
2489
2526
  code: result.code,
2490
2527
  message: result.message,
2491
2528
  retryable: result.retryable,
2529
+ ...(details !== undefined ? { details } : {}),
2492
2530
  ...(operation !== undefined ? { operation } : {}),
2493
2531
  };
2494
2532
  this.#rejections.push(rejection);
package/src/outbox.ts CHANGED
@@ -29,6 +29,11 @@ export interface OutboxOperation {
29
29
  readonly baseVersion?: number;
30
30
  /** Full-row values keyed by column name; present iff `op` is `upsert`. */
31
31
  readonly values?: Readonly<Record<string, JsonRowValue>>;
32
+ /**
33
+ * Local-only normalized columns intentionally supplied to `patch()`.
34
+ * Absent for full-row mutate/upsert because intent is then unknown.
35
+ */
36
+ readonly changedFields?: readonly string[];
32
37
  }
33
38
 
34
39
  export interface OutboxCommit {
package/src/outcomes.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * restart can never turn "rejected" into an inferred success. Conflict payloads
7
7
  * deliberately stay local; retention never deletes an unresolved failure.
8
8
  */
9
- import type { RowValue } from '@syncular/core';
9
+ import type { RejectionDetails, RowValue } from '@syncular/core';
10
10
  import type { ClientDatabase } from './database';
11
11
  import { ClientSyncError } from './errors';
12
12
  import type { OutboxOperation } from './outbox';
@@ -32,6 +32,8 @@ export interface RejectionRecord {
32
32
  readonly code: string;
33
33
  readonly message: string;
34
34
  readonly retryable: boolean;
35
+ /** Bounded host-declared metadata safe for authorized recovery UI. */
36
+ readonly details?: RejectionDetails;
35
37
  readonly operation?: OutboxOperation;
36
38
  }
37
39