@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 +11 -0
- package/dist/client.js +24 -6
- package/dist/outbox.d.ts +5 -0
- package/dist/outcomes.d.ts +3 -1
- package/package.json +3 -3
- package/src/client.ts +51 -13
- package/src/outbox.ts +5 -0
- package/src/outcomes.ts +3 -1
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
|
-
|
|
1042
|
+
const normalizedPartial = normalizeRecordKeys(compiled, partial);
|
|
1043
|
+
for (const [name, value] of normalizedPartial) {
|
|
1037
1044
|
record[name] = value;
|
|
1038
1045
|
}
|
|
1039
|
-
return this
|
|
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;
|
package/dist/outcomes.d.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.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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1602
|
+
const normalizedPartial = normalizeRecordKeys(compiled, partial);
|
|
1603
|
+
for (const [name, value] of normalizedPartial) {
|
|
1592
1604
|
record[name] = value;
|
|
1593
1605
|
}
|
|
1594
|
-
return this
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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(
|
|
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
|
|