@syncular/client 0.7.0 → 0.8.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 +14 -0
- package/dist/client.d.ts +21 -25
- package/dist/client.js +146 -4
- 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/outcomes.d.ts +79 -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 +214 -30
- package/src/index.ts +1 -0
- package/src/invalidation.ts +9 -1
- package/src/outcomes.ts +274 -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/dist/reactive-store.js
CHANGED
|
@@ -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,
|
package/dist/worker-entry.js
CHANGED
|
@@ -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,
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -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. */
|
package/dist/worker-host.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.8.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.8.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.8.0",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -89,6 +89,20 @@ import {
|
|
|
89
89
|
OutboxEncodeError,
|
|
90
90
|
type OutboxOperation,
|
|
91
91
|
} from './outbox';
|
|
92
|
+
import {
|
|
93
|
+
activeFailureRecords,
|
|
94
|
+
type CommitOperationOutcome,
|
|
95
|
+
type CommitOutcome,
|
|
96
|
+
type CommitOutcomeQuery,
|
|
97
|
+
type ConflictRecord,
|
|
98
|
+
listCommitOutcomes,
|
|
99
|
+
persistCommitOutcomeResolution,
|
|
100
|
+
pruneCommitOutcomes,
|
|
101
|
+
type RejectionRecord,
|
|
102
|
+
type ResolveCommitOutcomeInput,
|
|
103
|
+
commitOutcome as readCommitOutcome,
|
|
104
|
+
recordCommitOutcome,
|
|
105
|
+
} from './outcomes';
|
|
92
106
|
import { assertReadOnlyQuery } from './query-guard';
|
|
93
107
|
import {
|
|
94
108
|
type ClientSchema,
|
|
@@ -159,30 +173,14 @@ export type MutationInput =
|
|
|
159
173
|
readonly baseVersion?: number;
|
|
160
174
|
};
|
|
161
175
|
|
|
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
|
-
}
|
|
176
|
+
export type {
|
|
177
|
+
CommitOperationOutcome,
|
|
178
|
+
CommitOutcome,
|
|
179
|
+
CommitOutcomeQuery,
|
|
180
|
+
ConflictRecord,
|
|
181
|
+
RejectionRecord,
|
|
182
|
+
ResolveCommitOutcomeInput,
|
|
183
|
+
} from './outcomes';
|
|
186
184
|
|
|
187
185
|
export interface SchemaFloor {
|
|
188
186
|
readonly requiredSchemaVersion?: number;
|
|
@@ -246,6 +244,12 @@ export interface SyncClientLimits {
|
|
|
246
244
|
* `withSqliteImage` and a segment downloader is configured (§5.3).
|
|
247
245
|
*/
|
|
248
246
|
readonly accept?: number;
|
|
247
|
+
/**
|
|
248
|
+
* Maximum retained durable commit outcomes. Old applied/cached or resolved
|
|
249
|
+
* entries are pruned first; unresolved conflicts/rejections are never
|
|
250
|
+
* deleted to satisfy this cap. Defaults to 1,000.
|
|
251
|
+
*/
|
|
252
|
+
readonly outcomeRetentionMaxEntries?: number;
|
|
249
253
|
}
|
|
250
254
|
|
|
251
255
|
export interface SyncClientConfig {
|
|
@@ -454,6 +458,7 @@ export class SyncClient {
|
|
|
454
458
|
/** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
|
|
455
459
|
readonly #encryption: EncryptionConfig | undefined;
|
|
456
460
|
readonly #now: () => number;
|
|
461
|
+
readonly #outcomeRetentionMaxEntries: number;
|
|
457
462
|
#started = false;
|
|
458
463
|
#lease: LeaderLease | undefined;
|
|
459
464
|
#clientId = '';
|
|
@@ -511,6 +516,18 @@ export class SyncClient {
|
|
|
511
516
|
this.#schema = compileClientSchema(config.schema);
|
|
512
517
|
this.#encryption = config.encryption;
|
|
513
518
|
this.#now = config.now ?? Date.now;
|
|
519
|
+
const outcomeRetentionMaxEntries =
|
|
520
|
+
config.limits?.outcomeRetentionMaxEntries ?? 1_000;
|
|
521
|
+
if (
|
|
522
|
+
!Number.isSafeInteger(outcomeRetentionMaxEntries) ||
|
|
523
|
+
outcomeRetentionMaxEntries < 1
|
|
524
|
+
) {
|
|
525
|
+
throw new ClientSyncError(
|
|
526
|
+
'sync.invalid_request',
|
|
527
|
+
'outcomeRetentionMaxEntries must be a positive safe integer',
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
this.#outcomeRetentionMaxEntries = outcomeRetentionMaxEntries;
|
|
514
531
|
this.#hasBlobs = schemaHasBlobs(this.#schema);
|
|
515
532
|
}
|
|
516
533
|
|
|
@@ -525,6 +542,14 @@ export class SyncClient {
|
|
|
525
542
|
);
|
|
526
543
|
ensureLocalSchema(this.#db, this.#schema);
|
|
527
544
|
if (this.#hasBlobs) ensureBlobSchema(this.#db);
|
|
545
|
+
this.#db.transaction(() => {
|
|
546
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
547
|
+
});
|
|
548
|
+
const activeFailures = activeFailureRecords(
|
|
549
|
+
listCommitOutcomes(this.#db, { activeOnly: true }),
|
|
550
|
+
);
|
|
551
|
+
this.#conflicts = activeFailures.conflicts;
|
|
552
|
+
this.#rejections = activeFailures.rejections;
|
|
528
553
|
const persisted = getMeta(this.#db, 'clientId');
|
|
529
554
|
if (
|
|
530
555
|
persisted !== undefined &&
|
|
@@ -1037,6 +1062,92 @@ export class SyncClient {
|
|
|
1037
1062
|
return this.#rejections;
|
|
1038
1063
|
}
|
|
1039
1064
|
|
|
1065
|
+
/** One durable final outcome by the originating client commit id. */
|
|
1066
|
+
commitOutcome(clientCommitId: string): CommitOutcome | undefined {
|
|
1067
|
+
this.#requireStarted();
|
|
1068
|
+
return readCommitOutcome(this.#db, clientCommitId);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** Newest-first durable outcome journal. */
|
|
1072
|
+
commitOutcomes(query: CommitOutcomeQuery = {}): readonly CommitOutcome[] {
|
|
1073
|
+
this.#requireStarted();
|
|
1074
|
+
return listCommitOutcomes(this.#db, query);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Mark a durable failure handled without deleting its evidence. Conflicts
|
|
1079
|
+
* may keep the server row or link to a replacement commit; rejections may
|
|
1080
|
+
* only be superseded by a named replacement. Applied/cached history may be
|
|
1081
|
+
* dismissed. The transition is one-way and survives restart.
|
|
1082
|
+
*/
|
|
1083
|
+
resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome {
|
|
1084
|
+
this.#requireStarted();
|
|
1085
|
+
const current = readCommitOutcome(this.#db, input.clientCommitId);
|
|
1086
|
+
if (current === undefined) {
|
|
1087
|
+
throw new ClientSyncError(
|
|
1088
|
+
'sync.outcome_not_found',
|
|
1089
|
+
`no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`,
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
if (current.resolution !== 'active') return current;
|
|
1093
|
+
const replacement = input.replacementClientCommitId;
|
|
1094
|
+
if (input.resolution === 'superseded') {
|
|
1095
|
+
if (
|
|
1096
|
+
replacement === undefined ||
|
|
1097
|
+
replacement.length === 0 ||
|
|
1098
|
+
replacement === input.clientCommitId
|
|
1099
|
+
) {
|
|
1100
|
+
throw new ClientSyncError(
|
|
1101
|
+
'sync.invalid_request',
|
|
1102
|
+
'superseded outcomes require a distinct replacementClientCommitId',
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
} else if (replacement !== undefined) {
|
|
1106
|
+
throw new ClientSyncError(
|
|
1107
|
+
'sync.invalid_request',
|
|
1108
|
+
'replacementClientCommitId is valid only for superseded outcomes',
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
const allowed =
|
|
1112
|
+
(current.status === 'conflict' &&
|
|
1113
|
+
(input.resolution === 'resolved_keep_server' ||
|
|
1114
|
+
input.resolution === 'superseded')) ||
|
|
1115
|
+
(current.status === 'rejected' && input.resolution === 'superseded') ||
|
|
1116
|
+
((current.status === 'applied' || current.status === 'cached') &&
|
|
1117
|
+
input.resolution === 'dismissed');
|
|
1118
|
+
if (!allowed) {
|
|
1119
|
+
throw new ClientSyncError(
|
|
1120
|
+
'sync.invalid_request',
|
|
1121
|
+
`resolution ${input.resolution} is invalid for ${current.status} outcome`,
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return this.#applyBatch((batch) => {
|
|
1126
|
+
const resolved = persistCommitOutcomeResolution(
|
|
1127
|
+
this.#db,
|
|
1128
|
+
input,
|
|
1129
|
+
this.#now(),
|
|
1130
|
+
);
|
|
1131
|
+
if (resolved === undefined) {
|
|
1132
|
+
throw new ClientSyncError(
|
|
1133
|
+
'sync.outcome_not_found',
|
|
1134
|
+
`no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`,
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
this.#conflicts = this.#conflicts.filter(
|
|
1138
|
+
(record) => record.clientCommitId !== input.clientCommitId,
|
|
1139
|
+
);
|
|
1140
|
+
this.#rejections = this.#rejections.filter(
|
|
1141
|
+
(record) => record.clientCommitId !== input.clientCommitId,
|
|
1142
|
+
);
|
|
1143
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1144
|
+
batch.outcomes();
|
|
1145
|
+
if (current.status === 'conflict') batch.conflicts();
|
|
1146
|
+
if (current.status === 'rejected') batch.rejections();
|
|
1147
|
+
return resolved;
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1040
1151
|
/** Non-undefined once the server declared a schema floor (§1.6). */
|
|
1041
1152
|
get schemaFloor(): SchemaFloor | undefined {
|
|
1042
1153
|
return this.#schemaFloor;
|
|
@@ -1595,7 +1706,7 @@ export class SyncClient {
|
|
|
1595
1706
|
deleteLocalRow(this.#db, table, operation.rowId);
|
|
1596
1707
|
}
|
|
1597
1708
|
}
|
|
1598
|
-
|
|
1709
|
+
const rejection: RejectionRecord = {
|
|
1599
1710
|
clientCommitId: commit.clientCommitId,
|
|
1600
1711
|
opIndex: 0,
|
|
1601
1712
|
code: OUTBOX_INCOMPATIBLE_CODE,
|
|
@@ -1604,9 +1715,18 @@ export class SyncClient {
|
|
|
1604
1715
|
...(commit.operations[0] !== undefined
|
|
1605
1716
|
? { operation: commit.operations[0] }
|
|
1606
1717
|
: {}),
|
|
1718
|
+
};
|
|
1719
|
+
this.#rejections.push(rejection);
|
|
1720
|
+
recordCommitOutcome(this.#db, {
|
|
1721
|
+
clientCommitId: commit.clientCommitId,
|
|
1722
|
+
status: 'rejected',
|
|
1723
|
+
recordedAtMs: this.#now(),
|
|
1724
|
+
results: [{ status: 'error', rejection }],
|
|
1607
1725
|
});
|
|
1726
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1608
1727
|
batch.status();
|
|
1609
1728
|
batch.rejections();
|
|
1729
|
+
batch.outcomes();
|
|
1610
1730
|
});
|
|
1611
1731
|
}
|
|
1612
1732
|
|
|
@@ -2313,8 +2433,19 @@ export class SyncClient {
|
|
|
2313
2433
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
2314
2434
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
2315
2435
|
// "already applied, you may have missed the ack".
|
|
2436
|
+
recordCommitOutcome(this.#db, {
|
|
2437
|
+
clientCommitId: frame.clientCommitId,
|
|
2438
|
+
status: frame.status,
|
|
2439
|
+
recordedAtMs: this.#now(),
|
|
2440
|
+
results: frame.results.map((result) => ({
|
|
2441
|
+
status: 'applied' as const,
|
|
2442
|
+
opIndex: result.opIndex,
|
|
2443
|
+
})),
|
|
2444
|
+
});
|
|
2316
2445
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
2446
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2317
2447
|
batch.status();
|
|
2448
|
+
batch.outcomes();
|
|
2318
2449
|
summary.applied.push(frame.clientCommitId);
|
|
2319
2450
|
return;
|
|
2320
2451
|
}
|
|
@@ -2331,6 +2462,7 @@ export class SyncClient {
|
|
|
2331
2462
|
summary.retryable.push(frame.clientCommitId);
|
|
2332
2463
|
return;
|
|
2333
2464
|
}
|
|
2465
|
+
const outcomeResults: CommitOperationOutcome[] = [];
|
|
2334
2466
|
for (const result of frame.results) {
|
|
2335
2467
|
const operation = commit.operations[result.opIndex];
|
|
2336
2468
|
if (result.status === 'conflict') {
|
|
@@ -2346,21 +2478,36 @@ export class SyncClient {
|
|
|
2346
2478
|
...(operation !== undefined ? { operation } : {}),
|
|
2347
2479
|
};
|
|
2348
2480
|
this.#conflicts.push(conflict);
|
|
2481
|
+
outcomeResults.push({ status: 'conflict', conflict });
|
|
2349
2482
|
batch.conflicts();
|
|
2350
2483
|
summary.conflicts.push(conflict);
|
|
2351
2484
|
this.#config.onConflict?.(conflict);
|
|
2352
2485
|
} else if (result.status === 'error') {
|
|
2353
|
-
|
|
2486
|
+
const rejection: RejectionRecord = {
|
|
2354
2487
|
clientCommitId: frame.clientCommitId,
|
|
2355
2488
|
opIndex: result.opIndex,
|
|
2356
2489
|
code: result.code,
|
|
2357
2490
|
message: result.message,
|
|
2358
2491
|
retryable: result.retryable,
|
|
2359
2492
|
...(operation !== undefined ? { operation } : {}),
|
|
2360
|
-
}
|
|
2493
|
+
};
|
|
2494
|
+
this.#rejections.push(rejection);
|
|
2495
|
+
outcomeResults.push({ status: 'error', rejection });
|
|
2361
2496
|
batch.rejections();
|
|
2497
|
+
} else {
|
|
2498
|
+
outcomeResults.push({ status: 'applied', opIndex: result.opIndex });
|
|
2362
2499
|
}
|
|
2363
2500
|
}
|
|
2501
|
+
recordCommitOutcome(this.#db, {
|
|
2502
|
+
clientCommitId: frame.clientCommitId,
|
|
2503
|
+
status: outcomeResults.some((result) => result.status === 'conflict')
|
|
2504
|
+
? 'conflict'
|
|
2505
|
+
: 'rejected',
|
|
2506
|
+
recordedAtMs: this.#now(),
|
|
2507
|
+
results: outcomeResults,
|
|
2508
|
+
});
|
|
2509
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2510
|
+
batch.outcomes();
|
|
2364
2511
|
// §7.2: stop optimistic display and decide about dependents — the
|
|
2365
2512
|
// commit leaves the outbox; rows it created that the server never
|
|
2366
2513
|
// confirmed are undone here, rows it overwrote reconcile via the pull
|
|
@@ -2680,10 +2827,47 @@ export class SyncClient {
|
|
|
2680
2827
|
try {
|
|
2681
2828
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
2682
2829
|
batch.scopeMap(table, lastEffective);
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2830
|
+
const pendingById = new Map(
|
|
2831
|
+
listOutbox(this.#db).map((commit) => [
|
|
2832
|
+
commit.clientCommitId,
|
|
2833
|
+
commit,
|
|
2834
|
+
]),
|
|
2835
|
+
);
|
|
2836
|
+
const droppedIds = dropOutboxCommitsInScope(
|
|
2837
|
+
this.#db,
|
|
2838
|
+
table,
|
|
2839
|
+
lastEffective,
|
|
2840
|
+
);
|
|
2841
|
+
if (droppedIds.length > 0) {
|
|
2842
|
+
for (const clientCommitId of droppedIds) {
|
|
2843
|
+
const commit = pendingById.get(clientCommitId);
|
|
2844
|
+
if (commit === undefined) continue;
|
|
2845
|
+
const results: CommitOperationOutcome[] = commit.operations.map(
|
|
2846
|
+
(operation, opIndex) => {
|
|
2847
|
+
const rejection: RejectionRecord = {
|
|
2848
|
+
clientCommitId,
|
|
2849
|
+
opIndex,
|
|
2850
|
+
code: 'sync.scope_revoked',
|
|
2851
|
+
message:
|
|
2852
|
+
'the commit was dropped because its effective scope was revoked',
|
|
2853
|
+
retryable: false,
|
|
2854
|
+
operation,
|
|
2855
|
+
};
|
|
2856
|
+
this.#rejections.push(rejection);
|
|
2857
|
+
return { status: 'error', rejection };
|
|
2858
|
+
},
|
|
2859
|
+
);
|
|
2860
|
+
recordCommitOutcome(this.#db, {
|
|
2861
|
+
clientCommitId,
|
|
2862
|
+
status: 'rejected',
|
|
2863
|
+
recordedAtMs: this.#now(),
|
|
2864
|
+
results,
|
|
2865
|
+
});
|
|
2866
|
+
}
|
|
2867
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2686
2868
|
batch.status();
|
|
2869
|
+
batch.rejections();
|
|
2870
|
+
batch.outcomes();
|
|
2687
2871
|
}
|
|
2688
2872
|
this.#reconcileBlobs(true);
|
|
2689
2873
|
} 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
|
}
|