@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.
- package/README.md +25 -0
- package/dist/client.d.ts +21 -25
- package/dist/client.js +170 -10
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invalidation.d.ts +2 -0
- package/dist/invalidation.js +7 -1
- package/dist/outbox.d.ts +5 -0
- package/dist/outcomes.d.ts +81 -0
- package/dist/outcomes.js +131 -0
- package/dist/reactive-store.d.ts +8 -1
- package/dist/reactive-store.js +17 -0
- package/dist/schema.js +12 -0
- package/dist/worker-entry.js +3 -0
- package/dist/worker-host.d.ts +4 -0
- package/dist/worker-host.js +9 -0
- package/dist/worker-protocol.d.ts +4 -0
- package/package.json +3 -3
- package/src/client.ts +265 -43
- package/src/index.ts +1 -0
- package/src/invalidation.ts +9 -1
- package/src/outbox.ts +5 -0
- package/src/outcomes.ts +276 -0
- package/src/reactive-store.ts +28 -0
- package/src/schema.ts +12 -0
- package/src/worker-entry.ts +5 -0
- package/src/worker-host.ts +21 -0
- package/src/worker-protocol.ts +8 -0
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,
|
|
@@ -89,6 +90,20 @@ import {
|
|
|
89
90
|
OutboxEncodeError,
|
|
90
91
|
type OutboxOperation,
|
|
91
92
|
} from './outbox';
|
|
93
|
+
import {
|
|
94
|
+
activeFailureRecords,
|
|
95
|
+
type CommitOperationOutcome,
|
|
96
|
+
type CommitOutcome,
|
|
97
|
+
type CommitOutcomeQuery,
|
|
98
|
+
type ConflictRecord,
|
|
99
|
+
listCommitOutcomes,
|
|
100
|
+
persistCommitOutcomeResolution,
|
|
101
|
+
pruneCommitOutcomes,
|
|
102
|
+
type RejectionRecord,
|
|
103
|
+
type ResolveCommitOutcomeInput,
|
|
104
|
+
commitOutcome as readCommitOutcome,
|
|
105
|
+
recordCommitOutcome,
|
|
106
|
+
} from './outcomes';
|
|
92
107
|
import { assertReadOnlyQuery } from './query-guard';
|
|
93
108
|
import {
|
|
94
109
|
type ClientSchema,
|
|
@@ -159,30 +174,14 @@ export type MutationInput =
|
|
|
159
174
|
readonly baseVersion?: number;
|
|
160
175
|
};
|
|
161
176
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
readonly serverVersion: number;
|
|
171
|
-
/** The current server row, decoded — resolve without a round-trip. */
|
|
172
|
-
readonly serverRow: Readonly<Record<string, RowValue>>;
|
|
173
|
-
/** The losing local operation (absent only for malformed op indexes). */
|
|
174
|
-
readonly operation?: OutboxOperation;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** A non-conflict `error` result from a rejected commit (§6.3). */
|
|
178
|
-
export interface RejectionRecord {
|
|
179
|
-
readonly clientCommitId: string;
|
|
180
|
-
readonly opIndex: number;
|
|
181
|
-
readonly code: string;
|
|
182
|
-
readonly message: string;
|
|
183
|
-
readonly retryable: boolean;
|
|
184
|
-
readonly operation?: OutboxOperation;
|
|
185
|
-
}
|
|
177
|
+
export type {
|
|
178
|
+
CommitOperationOutcome,
|
|
179
|
+
CommitOutcome,
|
|
180
|
+
CommitOutcomeQuery,
|
|
181
|
+
ConflictRecord,
|
|
182
|
+
RejectionRecord,
|
|
183
|
+
ResolveCommitOutcomeInput,
|
|
184
|
+
} from './outcomes';
|
|
186
185
|
|
|
187
186
|
export interface SchemaFloor {
|
|
188
187
|
readonly requiredSchemaVersion?: number;
|
|
@@ -246,6 +245,12 @@ export interface SyncClientLimits {
|
|
|
246
245
|
* `withSqliteImage` and a segment downloader is configured (§5.3).
|
|
247
246
|
*/
|
|
248
247
|
readonly accept?: number;
|
|
248
|
+
/**
|
|
249
|
+
* Maximum retained durable commit outcomes. Old applied/cached or resolved
|
|
250
|
+
* entries are pruned first; unresolved conflicts/rejections are never
|
|
251
|
+
* deleted to satisfy this cap. Defaults to 1,000.
|
|
252
|
+
*/
|
|
253
|
+
readonly outcomeRetentionMaxEntries?: number;
|
|
249
254
|
}
|
|
250
255
|
|
|
251
256
|
export interface SyncClientConfig {
|
|
@@ -454,6 +459,7 @@ export class SyncClient {
|
|
|
454
459
|
/** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
|
|
455
460
|
readonly #encryption: EncryptionConfig | undefined;
|
|
456
461
|
readonly #now: () => number;
|
|
462
|
+
readonly #outcomeRetentionMaxEntries: number;
|
|
457
463
|
#started = false;
|
|
458
464
|
#lease: LeaderLease | undefined;
|
|
459
465
|
#clientId = '';
|
|
@@ -511,6 +517,18 @@ export class SyncClient {
|
|
|
511
517
|
this.#schema = compileClientSchema(config.schema);
|
|
512
518
|
this.#encryption = config.encryption;
|
|
513
519
|
this.#now = config.now ?? Date.now;
|
|
520
|
+
const outcomeRetentionMaxEntries =
|
|
521
|
+
config.limits?.outcomeRetentionMaxEntries ?? 1_000;
|
|
522
|
+
if (
|
|
523
|
+
!Number.isSafeInteger(outcomeRetentionMaxEntries) ||
|
|
524
|
+
outcomeRetentionMaxEntries < 1
|
|
525
|
+
) {
|
|
526
|
+
throw new ClientSyncError(
|
|
527
|
+
'sync.invalid_request',
|
|
528
|
+
'outcomeRetentionMaxEntries must be a positive safe integer',
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
this.#outcomeRetentionMaxEntries = outcomeRetentionMaxEntries;
|
|
514
532
|
this.#hasBlobs = schemaHasBlobs(this.#schema);
|
|
515
533
|
}
|
|
516
534
|
|
|
@@ -525,6 +543,14 @@ export class SyncClient {
|
|
|
525
543
|
);
|
|
526
544
|
ensureLocalSchema(this.#db, this.#schema);
|
|
527
545
|
if (this.#hasBlobs) ensureBlobSchema(this.#db);
|
|
546
|
+
this.#db.transaction(() => {
|
|
547
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
548
|
+
});
|
|
549
|
+
const activeFailures = activeFailureRecords(
|
|
550
|
+
listCommitOutcomes(this.#db, { activeOnly: true }),
|
|
551
|
+
);
|
|
552
|
+
this.#conflicts = activeFailures.conflicts;
|
|
553
|
+
this.#rejections = activeFailures.rejections;
|
|
528
554
|
const persisted = getMeta(this.#db, 'clientId');
|
|
529
555
|
if (
|
|
530
556
|
persisted !== undefined &&
|
|
@@ -1037,6 +1063,92 @@ export class SyncClient {
|
|
|
1037
1063
|
return this.#rejections;
|
|
1038
1064
|
}
|
|
1039
1065
|
|
|
1066
|
+
/** One durable final outcome by the originating client commit id. */
|
|
1067
|
+
commitOutcome(clientCommitId: string): CommitOutcome | undefined {
|
|
1068
|
+
this.#requireStarted();
|
|
1069
|
+
return readCommitOutcome(this.#db, clientCommitId);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** Newest-first durable outcome journal. */
|
|
1073
|
+
commitOutcomes(query: CommitOutcomeQuery = {}): readonly CommitOutcome[] {
|
|
1074
|
+
this.#requireStarted();
|
|
1075
|
+
return listCommitOutcomes(this.#db, query);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Mark a durable failure handled without deleting its evidence. Conflicts
|
|
1080
|
+
* may keep the server row or link to a replacement commit; rejections may
|
|
1081
|
+
* only be superseded by a named replacement. Applied/cached history may be
|
|
1082
|
+
* dismissed. The transition is one-way and survives restart.
|
|
1083
|
+
*/
|
|
1084
|
+
resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome {
|
|
1085
|
+
this.#requireStarted();
|
|
1086
|
+
const current = readCommitOutcome(this.#db, input.clientCommitId);
|
|
1087
|
+
if (current === undefined) {
|
|
1088
|
+
throw new ClientSyncError(
|
|
1089
|
+
'sync.outcome_not_found',
|
|
1090
|
+
`no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
if (current.resolution !== 'active') return current;
|
|
1094
|
+
const replacement = input.replacementClientCommitId;
|
|
1095
|
+
if (input.resolution === 'superseded') {
|
|
1096
|
+
if (
|
|
1097
|
+
replacement === undefined ||
|
|
1098
|
+
replacement.length === 0 ||
|
|
1099
|
+
replacement === input.clientCommitId
|
|
1100
|
+
) {
|
|
1101
|
+
throw new ClientSyncError(
|
|
1102
|
+
'sync.invalid_request',
|
|
1103
|
+
'superseded outcomes require a distinct replacementClientCommitId',
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
} else if (replacement !== undefined) {
|
|
1107
|
+
throw new ClientSyncError(
|
|
1108
|
+
'sync.invalid_request',
|
|
1109
|
+
'replacementClientCommitId is valid only for superseded outcomes',
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
const allowed =
|
|
1113
|
+
(current.status === 'conflict' &&
|
|
1114
|
+
(input.resolution === 'resolved_keep_server' ||
|
|
1115
|
+
input.resolution === 'superseded')) ||
|
|
1116
|
+
(current.status === 'rejected' && input.resolution === 'superseded') ||
|
|
1117
|
+
((current.status === 'applied' || current.status === 'cached') &&
|
|
1118
|
+
input.resolution === 'dismissed');
|
|
1119
|
+
if (!allowed) {
|
|
1120
|
+
throw new ClientSyncError(
|
|
1121
|
+
'sync.invalid_request',
|
|
1122
|
+
`resolution ${input.resolution} is invalid for ${current.status} outcome`,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return this.#applyBatch((batch) => {
|
|
1127
|
+
const resolved = persistCommitOutcomeResolution(
|
|
1128
|
+
this.#db,
|
|
1129
|
+
input,
|
|
1130
|
+
this.#now(),
|
|
1131
|
+
);
|
|
1132
|
+
if (resolved === undefined) {
|
|
1133
|
+
throw new ClientSyncError(
|
|
1134
|
+
'sync.outcome_not_found',
|
|
1135
|
+
`no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`,
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
this.#conflicts = this.#conflicts.filter(
|
|
1139
|
+
(record) => record.clientCommitId !== input.clientCommitId,
|
|
1140
|
+
);
|
|
1141
|
+
this.#rejections = this.#rejections.filter(
|
|
1142
|
+
(record) => record.clientCommitId !== input.clientCommitId,
|
|
1143
|
+
);
|
|
1144
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1145
|
+
batch.outcomes();
|
|
1146
|
+
if (current.status === 'conflict') batch.conflicts();
|
|
1147
|
+
if (current.status === 'rejected') batch.rejections();
|
|
1148
|
+
return resolved;
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1040
1152
|
/** Non-undefined once the server declared a schema floor (§1.6). */
|
|
1041
1153
|
get schemaFloor(): SchemaFloor | undefined {
|
|
1042
1154
|
return this.#schemaFloor;
|
|
@@ -1391,9 +1503,16 @@ export class SyncClient {
|
|
|
1391
1503
|
* Returns the generated `clientCommitId`.
|
|
1392
1504
|
*/
|
|
1393
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 {
|
|
1394
1513
|
this.#requireStarted();
|
|
1395
1514
|
const clientCommitId = crypto.randomUUID();
|
|
1396
|
-
const operations: OutboxOperation[] = mutations.map((mutation) => {
|
|
1515
|
+
const operations: OutboxOperation[] = mutations.map((mutation, index) => {
|
|
1397
1516
|
const table = this.#table(mutation.table);
|
|
1398
1517
|
if (mutation.op === 'delete') {
|
|
1399
1518
|
return {
|
|
@@ -1425,6 +1544,9 @@ export class SyncClient {
|
|
|
1425
1544
|
? { baseVersion: mutation.baseVersion }
|
|
1426
1545
|
: {}),
|
|
1427
1546
|
values: json,
|
|
1547
|
+
...(changedFieldsByIndex[index] !== undefined
|
|
1548
|
+
? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
|
|
1549
|
+
: {}),
|
|
1428
1550
|
};
|
|
1429
1551
|
});
|
|
1430
1552
|
this.#applyBatch((batch) => {
|
|
@@ -1477,19 +1599,23 @@ export class SyncClient {
|
|
|
1477
1599
|
for (const column of compiled.columns as readonly RowColumn[]) {
|
|
1478
1600
|
record[column.name] = fromSqlValue(column, row[column.name] ?? null);
|
|
1479
1601
|
}
|
|
1480
|
-
|
|
1602
|
+
const normalizedPartial = normalizeRecordKeys(compiled, partial);
|
|
1603
|
+
for (const [name, value] of normalizedPartial) {
|
|
1481
1604
|
record[name] = value;
|
|
1482
1605
|
}
|
|
1483
|
-
return this
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
+
);
|
|
1493
1619
|
}
|
|
1494
1620
|
|
|
1495
1621
|
/** Host-facing patch result with explicit network work intent (§7.5). */
|
|
@@ -1595,7 +1721,7 @@ export class SyncClient {
|
|
|
1595
1721
|
deleteLocalRow(this.#db, table, operation.rowId);
|
|
1596
1722
|
}
|
|
1597
1723
|
}
|
|
1598
|
-
|
|
1724
|
+
const rejection: RejectionRecord = {
|
|
1599
1725
|
clientCommitId: commit.clientCommitId,
|
|
1600
1726
|
opIndex: 0,
|
|
1601
1727
|
code: OUTBOX_INCOMPATIBLE_CODE,
|
|
@@ -1604,9 +1730,18 @@ export class SyncClient {
|
|
|
1604
1730
|
...(commit.operations[0] !== undefined
|
|
1605
1731
|
? { operation: commit.operations[0] }
|
|
1606
1732
|
: {}),
|
|
1733
|
+
};
|
|
1734
|
+
this.#rejections.push(rejection);
|
|
1735
|
+
recordCommitOutcome(this.#db, {
|
|
1736
|
+
clientCommitId: commit.clientCommitId,
|
|
1737
|
+
status: 'rejected',
|
|
1738
|
+
recordedAtMs: this.#now(),
|
|
1739
|
+
results: [{ status: 'error', rejection }],
|
|
1607
1740
|
});
|
|
1741
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1608
1742
|
batch.status();
|
|
1609
1743
|
batch.rejections();
|
|
1744
|
+
batch.outcomes();
|
|
1610
1745
|
});
|
|
1611
1746
|
}
|
|
1612
1747
|
|
|
@@ -2030,6 +2165,17 @@ export class SyncClient {
|
|
|
2030
2165
|
const subsById = new Map(
|
|
2031
2166
|
(sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]),
|
|
2032
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
|
+
}
|
|
2033
2179
|
|
|
2034
2180
|
const header = message.frames[0];
|
|
2035
2181
|
if (header?.type !== 'RESP_HEADER') {
|
|
@@ -2078,9 +2224,18 @@ export class SyncClient {
|
|
|
2078
2224
|
break;
|
|
2079
2225
|
case 'PUSH_RESULT':
|
|
2080
2226
|
this.#applyBatch((batch) =>
|
|
2081
|
-
this.#handlePushResult(
|
|
2227
|
+
this.#handlePushResult(
|
|
2228
|
+
frame,
|
|
2229
|
+
commitsById,
|
|
2230
|
+
summary,
|
|
2231
|
+
batch,
|
|
2232
|
+
rejectionDetailsByCommit.get(frame.clientCommitId),
|
|
2233
|
+
),
|
|
2082
2234
|
);
|
|
2083
2235
|
break;
|
|
2236
|
+
case 'PUSH_RESULT_DETAILS':
|
|
2237
|
+
// Pre-indexed above so companion ordering remains wire-additive.
|
|
2238
|
+
break;
|
|
2084
2239
|
case 'SUB_START': {
|
|
2085
2240
|
const sub = subsById.get(frame.id);
|
|
2086
2241
|
const fresh =
|
|
@@ -2307,14 +2462,26 @@ export class SyncClient {
|
|
|
2307
2462
|
commitsById: ReadonlyMap<string, OutboxCommit>,
|
|
2308
2463
|
summary: MutableSummary,
|
|
2309
2464
|
batch: ChangeAccumulator,
|
|
2465
|
+
rejectionDetails: ReadonlyMap<number, RejectionDetails> | undefined,
|
|
2310
2466
|
): void {
|
|
2311
2467
|
const commit = commitsById.get(frame.clientCommitId);
|
|
2312
2468
|
if (commit === undefined) return;
|
|
2313
2469
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
2314
2470
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
2315
2471
|
// "already applied, you may have missed the ack".
|
|
2472
|
+
recordCommitOutcome(this.#db, {
|
|
2473
|
+
clientCommitId: frame.clientCommitId,
|
|
2474
|
+
status: frame.status,
|
|
2475
|
+
recordedAtMs: this.#now(),
|
|
2476
|
+
results: frame.results.map((result) => ({
|
|
2477
|
+
status: 'applied' as const,
|
|
2478
|
+
opIndex: result.opIndex,
|
|
2479
|
+
})),
|
|
2480
|
+
});
|
|
2316
2481
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
2482
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2317
2483
|
batch.status();
|
|
2484
|
+
batch.outcomes();
|
|
2318
2485
|
summary.applied.push(frame.clientCommitId);
|
|
2319
2486
|
return;
|
|
2320
2487
|
}
|
|
@@ -2331,6 +2498,7 @@ export class SyncClient {
|
|
|
2331
2498
|
summary.retryable.push(frame.clientCommitId);
|
|
2332
2499
|
return;
|
|
2333
2500
|
}
|
|
2501
|
+
const outcomeResults: CommitOperationOutcome[] = [];
|
|
2334
2502
|
for (const result of frame.results) {
|
|
2335
2503
|
const operation = commit.operations[result.opIndex];
|
|
2336
2504
|
if (result.status === 'conflict') {
|
|
@@ -2346,21 +2514,38 @@ export class SyncClient {
|
|
|
2346
2514
|
...(operation !== undefined ? { operation } : {}),
|
|
2347
2515
|
};
|
|
2348
2516
|
this.#conflicts.push(conflict);
|
|
2517
|
+
outcomeResults.push({ status: 'conflict', conflict });
|
|
2349
2518
|
batch.conflicts();
|
|
2350
2519
|
summary.conflicts.push(conflict);
|
|
2351
2520
|
this.#config.onConflict?.(conflict);
|
|
2352
2521
|
} else if (result.status === 'error') {
|
|
2353
|
-
|
|
2522
|
+
const details = rejectionDetails?.get(result.opIndex);
|
|
2523
|
+
const rejection: RejectionRecord = {
|
|
2354
2524
|
clientCommitId: frame.clientCommitId,
|
|
2355
2525
|
opIndex: result.opIndex,
|
|
2356
2526
|
code: result.code,
|
|
2357
2527
|
message: result.message,
|
|
2358
2528
|
retryable: result.retryable,
|
|
2529
|
+
...(details !== undefined ? { details } : {}),
|
|
2359
2530
|
...(operation !== undefined ? { operation } : {}),
|
|
2360
|
-
}
|
|
2531
|
+
};
|
|
2532
|
+
this.#rejections.push(rejection);
|
|
2533
|
+
outcomeResults.push({ status: 'error', rejection });
|
|
2361
2534
|
batch.rejections();
|
|
2535
|
+
} else {
|
|
2536
|
+
outcomeResults.push({ status: 'applied', opIndex: result.opIndex });
|
|
2362
2537
|
}
|
|
2363
2538
|
}
|
|
2539
|
+
recordCommitOutcome(this.#db, {
|
|
2540
|
+
clientCommitId: frame.clientCommitId,
|
|
2541
|
+
status: outcomeResults.some((result) => result.status === 'conflict')
|
|
2542
|
+
? 'conflict'
|
|
2543
|
+
: 'rejected',
|
|
2544
|
+
recordedAtMs: this.#now(),
|
|
2545
|
+
results: outcomeResults,
|
|
2546
|
+
});
|
|
2547
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2548
|
+
batch.outcomes();
|
|
2364
2549
|
// §7.2: stop optimistic display and decide about dependents — the
|
|
2365
2550
|
// commit leaves the outbox; rows it created that the server never
|
|
2366
2551
|
// confirmed are undone here, rows it overwrote reconcile via the pull
|
|
@@ -2680,10 +2865,47 @@ export class SyncClient {
|
|
|
2680
2865
|
try {
|
|
2681
2866
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
2682
2867
|
batch.scopeMap(table, lastEffective);
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2868
|
+
const pendingById = new Map(
|
|
2869
|
+
listOutbox(this.#db).map((commit) => [
|
|
2870
|
+
commit.clientCommitId,
|
|
2871
|
+
commit,
|
|
2872
|
+
]),
|
|
2873
|
+
);
|
|
2874
|
+
const droppedIds = dropOutboxCommitsInScope(
|
|
2875
|
+
this.#db,
|
|
2876
|
+
table,
|
|
2877
|
+
lastEffective,
|
|
2878
|
+
);
|
|
2879
|
+
if (droppedIds.length > 0) {
|
|
2880
|
+
for (const clientCommitId of droppedIds) {
|
|
2881
|
+
const commit = pendingById.get(clientCommitId);
|
|
2882
|
+
if (commit === undefined) continue;
|
|
2883
|
+
const results: CommitOperationOutcome[] = commit.operations.map(
|
|
2884
|
+
(operation, opIndex) => {
|
|
2885
|
+
const rejection: RejectionRecord = {
|
|
2886
|
+
clientCommitId,
|
|
2887
|
+
opIndex,
|
|
2888
|
+
code: 'sync.scope_revoked',
|
|
2889
|
+
message:
|
|
2890
|
+
'the commit was dropped because its effective scope was revoked',
|
|
2891
|
+
retryable: false,
|
|
2892
|
+
operation,
|
|
2893
|
+
};
|
|
2894
|
+
this.#rejections.push(rejection);
|
|
2895
|
+
return { status: 'error', rejection };
|
|
2896
|
+
},
|
|
2897
|
+
);
|
|
2898
|
+
recordCommitOutcome(this.#db, {
|
|
2899
|
+
clientCommitId,
|
|
2900
|
+
status: 'rejected',
|
|
2901
|
+
recordedAtMs: this.#now(),
|
|
2902
|
+
results,
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2686
2906
|
batch.status();
|
|
2907
|
+
batch.rejections();
|
|
2908
|
+
batch.outcomes();
|
|
2687
2909
|
}
|
|
2688
2910
|
this.#reconcileBlobs(true);
|
|
2689
2911
|
} catch (error) {
|
package/src/index.ts
CHANGED
package/src/invalidation.ts
CHANGED
|
@@ -38,6 +38,7 @@ export interface ClientChangeBatch {
|
|
|
38
38
|
readonly status?: SyncStatusSnapshot;
|
|
39
39
|
readonly conflictsChanged: boolean;
|
|
40
40
|
readonly rejectionsChanged: boolean;
|
|
41
|
+
readonly outcomesChanged: boolean;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export type ClientChangeListener = (batch: ClientChangeBatch) => void;
|
|
@@ -81,6 +82,7 @@ export class ChangeAccumulator {
|
|
|
81
82
|
#status = false;
|
|
82
83
|
#conflicts = false;
|
|
83
84
|
#rejections = false;
|
|
85
|
+
#outcomes = false;
|
|
84
86
|
|
|
85
87
|
/** Mark a whole table dirty, discarding any weaker scope-only facts. */
|
|
86
88
|
table(name: string): void {
|
|
@@ -126,6 +128,10 @@ export class ChangeAccumulator {
|
|
|
126
128
|
this.#rejections = true;
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
outcomes(): void {
|
|
132
|
+
this.#outcomes = true;
|
|
133
|
+
}
|
|
134
|
+
|
|
129
135
|
/** Add precise keys for a requested/effective scope map. */
|
|
130
136
|
scopeMap(table: CompiledClientTable, scopes: ScopeMap): void {
|
|
131
137
|
for (const [variable, values] of Object.entries(scopes)) {
|
|
@@ -154,7 +160,8 @@ export class ChangeAccumulator {
|
|
|
154
160
|
this.#windows.size > 0 ||
|
|
155
161
|
this.#status ||
|
|
156
162
|
this.#conflicts ||
|
|
157
|
-
this.#rejections
|
|
163
|
+
this.#rejections ||
|
|
164
|
+
this.#outcomes
|
|
158
165
|
);
|
|
159
166
|
}
|
|
160
167
|
|
|
@@ -192,6 +199,7 @@ export class ChangeAccumulator {
|
|
|
192
199
|
...(this.#status ? { status: status as SyncStatusSnapshot } : {}),
|
|
193
200
|
conflictsChanged: this.#conflicts,
|
|
194
201
|
rejectionsChanged: this.#rejections,
|
|
202
|
+
outcomesChanged: this.#outcomes,
|
|
195
203
|
};
|
|
196
204
|
}
|
|
197
205
|
}
|
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 {
|