@syncular/client 0.41.2 → 0.42.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/dist/client.d.ts +8 -0
- package/dist/client.js +105 -3
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +5 -2
- package/dist/http.js +10 -1
- package/dist/worker-entry.js +1 -0
- package/dist/worker-host.d.ts +6 -0
- package/dist/worker-host.js +10 -0
- package/dist/worker-protocol.d.ts +1 -0
- package/package.json +3 -3
- package/src/client.ts +114 -2
- package/src/errors.ts +5 -0
- package/src/http.ts +23 -4
- package/src/worker-entry.ts +1 -0
- package/src/worker-host.ts +11 -0
- package/src/worker-protocol.ts +1 -0
package/dist/client.d.ts
CHANGED
|
@@ -402,6 +402,14 @@ export declare class SyncClient {
|
|
|
402
402
|
*/
|
|
403
403
|
activateSecurity(options?: SecurityActivation): Promise<void>;
|
|
404
404
|
get clientId(): string;
|
|
405
|
+
/**
|
|
406
|
+
* Rotate the caller's client id (§1.5). Use it after a
|
|
407
|
+
* `sync.invalid_client_id` refusal (`recommendedAction: resetClientId`):
|
|
408
|
+
* the next round carries the new id. The local replica and its outbox stay
|
|
409
|
+
* in place; wipe them yourself when the stored data belongs to the account
|
|
410
|
+
* that signed out.
|
|
411
|
+
*/
|
|
412
|
+
resetClientId(): string;
|
|
405
413
|
/** The underlying database: raw SQL is the local query API. */
|
|
406
414
|
get database(): ClientDatabase;
|
|
407
415
|
/**
|
package/dist/client.js
CHANGED
|
@@ -784,6 +784,21 @@ export class SyncClient {
|
|
|
784
784
|
get clientId() {
|
|
785
785
|
return this.#clientId;
|
|
786
786
|
}
|
|
787
|
+
/**
|
|
788
|
+
* Rotate the caller's client id (§1.5). Use it after a
|
|
789
|
+
* `sync.invalid_client_id` refusal (`recommendedAction: resetClientId`):
|
|
790
|
+
* the next round carries the new id. The local replica and its outbox stay
|
|
791
|
+
* in place; wipe them yourself when the stored data belongs to the account
|
|
792
|
+
* that signed out.
|
|
793
|
+
*/
|
|
794
|
+
resetClientId() {
|
|
795
|
+
this.#requireStarted();
|
|
796
|
+
this.disconnectRealtime();
|
|
797
|
+
const clientId = crypto.randomUUID();
|
|
798
|
+
setMeta(this.#db, 'clientId', clientId);
|
|
799
|
+
this.#clientId = clientId;
|
|
800
|
+
return clientId;
|
|
801
|
+
}
|
|
787
802
|
/** The underlying database: raw SQL is the local query API. */
|
|
788
803
|
get database() {
|
|
789
804
|
this.#requireActive();
|
|
@@ -3510,6 +3525,7 @@ export class SyncClient {
|
|
|
3510
3525
|
// at creation, never unhandled.
|
|
3511
3526
|
const prefetched = new Map();
|
|
3512
3527
|
let reporting;
|
|
3528
|
+
const deleteKey = (commitSeq, table, rowId) => `${commitSeq}\u0000${table}\u0000${rowId}`;
|
|
3513
3529
|
{
|
|
3514
3530
|
let openSub;
|
|
3515
3531
|
for (const frame of message.frames) {
|
|
@@ -3544,6 +3560,68 @@ export class SyncClient {
|
|
|
3544
3560
|
}
|
|
3545
3561
|
}
|
|
3546
3562
|
}
|
|
3563
|
+
// §2.2 scope migration: the server emits the delete tagged with the old
|
|
3564
|
+
// scope and the upsert tagged with the new one in one commit. A reader
|
|
3565
|
+
// that holds both scopes receives them in two subscription sections, so
|
|
3566
|
+
// the section order would otherwise decide whether the row survives. One
|
|
3567
|
+
// commit's delete and upsert of the same row are one change, and the
|
|
3568
|
+
// upsert wins. A frame that carries both itself keeps its own operation
|
|
3569
|
+
// order; only a pair split across sections is coalesced.
|
|
3570
|
+
const coalescedDeletes = new Set();
|
|
3571
|
+
{
|
|
3572
|
+
const ops = new Map();
|
|
3573
|
+
const bothInFrame = new Set();
|
|
3574
|
+
let openSub;
|
|
3575
|
+
let frameIndex = -1;
|
|
3576
|
+
for (const frame of message.frames) {
|
|
3577
|
+
if (frame.type === 'SUB_START') {
|
|
3578
|
+
const sub = subsById.get(frame.id);
|
|
3579
|
+
openSub =
|
|
3580
|
+
sub === undefined ||
|
|
3581
|
+
(mode === 'delta' &&
|
|
3582
|
+
(sub.status !== 'active' || sub.bootstrapState !== undefined))
|
|
3583
|
+
? undefined
|
|
3584
|
+
: sub;
|
|
3585
|
+
}
|
|
3586
|
+
else if (frame.type === 'SUB_END') {
|
|
3587
|
+
openSub = undefined;
|
|
3588
|
+
}
|
|
3589
|
+
else if (frame.type === 'ERROR') {
|
|
3590
|
+
break;
|
|
3591
|
+
}
|
|
3592
|
+
else if (frame.type === 'COMMIT' && openSub !== undefined) {
|
|
3593
|
+
frameIndex += 1;
|
|
3594
|
+
const local = new Map();
|
|
3595
|
+
for (const change of frame.changes) {
|
|
3596
|
+
const table = frame.tables[change.tableIndex];
|
|
3597
|
+
if (table === undefined)
|
|
3598
|
+
continue;
|
|
3599
|
+
const key = deleteKey(frame.commitSeq, table, change.rowId);
|
|
3600
|
+
const entry = ops.get(key) ?? {
|
|
3601
|
+
upserts: new Set(),
|
|
3602
|
+
deletes: new Set(),
|
|
3603
|
+
};
|
|
3604
|
+
if (change.op === 'upsert')
|
|
3605
|
+
entry.upserts.add(frameIndex);
|
|
3606
|
+
else
|
|
3607
|
+
entry.deletes.add(frameIndex);
|
|
3608
|
+
ops.set(key, entry);
|
|
3609
|
+
local.set(key, (local.get(key) ?? 0) | (change.op === 'upsert' ? 1 : 2));
|
|
3610
|
+
}
|
|
3611
|
+
for (const [key, seen] of local) {
|
|
3612
|
+
if (seen === 3)
|
|
3613
|
+
bothInFrame.add(key);
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
for (const [key, entry] of ops) {
|
|
3618
|
+
if (!bothInFrame.has(key) &&
|
|
3619
|
+
entry.upserts.size > 0 &&
|
|
3620
|
+
entry.deletes.size > 0) {
|
|
3621
|
+
coalescedDeletes.add(key);
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3547
3625
|
// Each durable observer transaction emits its own revisioned batch.
|
|
3548
3626
|
// Async decrypt/download work happens outside SQLite transactions.
|
|
3549
3627
|
this.#beginDiagnosticsDeferral();
|
|
@@ -3620,7 +3698,16 @@ export class SyncClient {
|
|
|
3620
3698
|
}
|
|
3621
3699
|
case 'COMMIT':
|
|
3622
3700
|
if (section !== undefined && !section.skip) {
|
|
3623
|
-
|
|
3701
|
+
const changes = frame.changes.filter((change) => {
|
|
3702
|
+
if (change.op !== 'delete')
|
|
3703
|
+
return true;
|
|
3704
|
+
const table = frame.tables[change.tableIndex];
|
|
3705
|
+
return (table === undefined ||
|
|
3706
|
+
!coalescedDeletes.has(deleteKey(frame.commitSeq, table, change.rowId)));
|
|
3707
|
+
});
|
|
3708
|
+
if (changes.length > 0) {
|
|
3709
|
+
await this.#applyCommit({ ...frame, changes }, summary);
|
|
3710
|
+
}
|
|
3624
3711
|
}
|
|
3625
3712
|
break;
|
|
3626
3713
|
case 'SEGMENT_INLINE':
|
|
@@ -3708,12 +3795,27 @@ export class SyncClient {
|
|
|
3708
3795
|
section = undefined;
|
|
3709
3796
|
break;
|
|
3710
3797
|
}
|
|
3711
|
-
case 'ERROR':
|
|
3798
|
+
case 'ERROR': {
|
|
3712
3799
|
// §1.4 rule 5 / §1.6: the request failed; the open
|
|
3713
3800
|
// subscription's SUB_END values are never persisted.
|
|
3714
|
-
|
|
3801
|
+
let details;
|
|
3802
|
+
if (frame.details !== undefined) {
|
|
3803
|
+
try {
|
|
3804
|
+
const parsed = JSON.parse(frame.details);
|
|
3805
|
+
if (typeof parsed === 'object' &&
|
|
3806
|
+
parsed !== null &&
|
|
3807
|
+
!Array.isArray(parsed)) {
|
|
3808
|
+
details = parsed;
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
catch {
|
|
3812
|
+
// A malformed details member adds no evidence.
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
errorFrame = new ClientSyncError(frame.code, frame.message, frame.retryable, details, frame.recommendedAction);
|
|
3715
3816
|
section = undefined;
|
|
3716
3817
|
break;
|
|
3818
|
+
}
|
|
3717
3819
|
case 'UNKNOWN':
|
|
3718
3820
|
break; // §1.2 rule 2: skipped, never interpreted
|
|
3719
3821
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -23,7 +23,9 @@ export declare class ClientSyncError extends Error {
|
|
|
23
23
|
readonly code: string;
|
|
24
24
|
readonly retryable: boolean;
|
|
25
25
|
readonly details: Readonly<Record<string, unknown>> | undefined;
|
|
26
|
-
|
|
26
|
+
/** §10.1 `recommendedAction` from an authenticated HTTP error body. */
|
|
27
|
+
readonly recommendedAction: string | undefined;
|
|
28
|
+
constructor(code: string, message: string, retryable?: boolean, details?: Readonly<Record<string, unknown>>, recommendedAction?: string);
|
|
27
29
|
}
|
|
28
30
|
/** Shared invalid-request construction for client validators and transport guards. */
|
|
29
31
|
export declare function invalidRequest(message: string, retryable?: boolean): ClientSyncError;
|
package/dist/errors.js
CHANGED
|
@@ -22,11 +22,14 @@ export class ClientSyncError extends Error {
|
|
|
22
22
|
code;
|
|
23
23
|
retryable;
|
|
24
24
|
details;
|
|
25
|
-
|
|
25
|
+
/** §10.1 `recommendedAction` from an authenticated HTTP error body. */
|
|
26
|
+
recommendedAction;
|
|
27
|
+
constructor(code, message, retryable = false, details, recommendedAction) {
|
|
26
28
|
super(message);
|
|
27
29
|
this.code = code;
|
|
28
30
|
this.retryable = retryable;
|
|
29
31
|
this.details = details;
|
|
32
|
+
this.recommendedAction = recommendedAction;
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
35
|
/** Shared invalid-request construction for client validators and transport guards. */
|
|
@@ -91,7 +94,7 @@ export function classifySqliteFailure(error) {
|
|
|
91
94
|
message: rollback instanceof Error ? rollback.message : String(rollback),
|
|
92
95
|
sqliteCode: classifySqliteFailure(rollback).sqliteCode,
|
|
93
96
|
},
|
|
94
|
-
});
|
|
97
|
+
}, error.recommendedAction);
|
|
95
98
|
Object.defineProperty(retained, 'cause', { value: error });
|
|
96
99
|
}
|
|
97
100
|
return {
|
package/dist/http.js
CHANGED
|
@@ -28,6 +28,8 @@ async function throwHttpError(response, defaultCode = 'sync.transport_failed') {
|
|
|
28
28
|
let retryable = response.status >= 500 ||
|
|
29
29
|
response.status === 429 ||
|
|
30
30
|
defaultCode === 'sync.auth_required';
|
|
31
|
+
let recommendedAction;
|
|
32
|
+
let serverDetails;
|
|
31
33
|
try {
|
|
32
34
|
const body = (await response.json());
|
|
33
35
|
// Only the blob download endpoint can establish absence.
|
|
@@ -38,15 +40,22 @@ async function throwHttpError(response, defaultCode = 'sync.transport_failed') {
|
|
|
38
40
|
message = body.message;
|
|
39
41
|
if (typeof body.retryable === 'boolean')
|
|
40
42
|
retryable = body.retryable;
|
|
43
|
+
if (typeof body.recommendedAction === 'string')
|
|
44
|
+
recommendedAction = body.recommendedAction;
|
|
45
|
+
if (typeof body.details === 'object' &&
|
|
46
|
+
body.details !== null &&
|
|
47
|
+
!Array.isArray(body.details))
|
|
48
|
+
serverDetails = body.details;
|
|
41
49
|
}
|
|
42
50
|
}
|
|
43
51
|
catch {
|
|
44
52
|
// non-JSON error body — keep the HTTP-status defaults
|
|
45
53
|
}
|
|
46
54
|
throw new ClientSyncError(code, message, retryable, {
|
|
55
|
+
...serverDetails,
|
|
47
56
|
causeKind: 'status',
|
|
48
57
|
httpStatus: response.status,
|
|
49
|
-
});
|
|
58
|
+
}, recommendedAction);
|
|
50
59
|
}
|
|
51
60
|
function hostHeaders(options) {
|
|
52
61
|
const headers = options?.headers;
|
package/dist/worker-entry.js
CHANGED
|
@@ -371,6 +371,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
371
371
|
const running = requireClient();
|
|
372
372
|
return serializedSync(() => running.syncUntilIdle(maxRounds));
|
|
373
373
|
},
|
|
374
|
+
resetClientId: () => requireClient().resetClientId(),
|
|
374
375
|
localRevision: () => requireClient().localRevision,
|
|
375
376
|
diagnosticsSnapshot: (request) => {
|
|
376
377
|
const snapshot = requireClient().diagnosticsSnapshot(request);
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -246,6 +246,12 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
246
246
|
}>;
|
|
247
247
|
sync(): Promise<SyncSummary>;
|
|
248
248
|
syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
|
|
249
|
+
/**
|
|
250
|
+
* Rotate the client id (§1.5) after a `sync.invalid_client_id` refusal.
|
|
251
|
+
* The worker's next round carries the new id; the replica and outbox stay
|
|
252
|
+
* in place.
|
|
253
|
+
*/
|
|
254
|
+
resetClientId(): Promise<string>;
|
|
249
255
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
|
250
256
|
authoritySnapshot(): Promise<import('./authority.js').AuthoritySnapshot>;
|
|
251
257
|
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
package/dist/worker-host.js
CHANGED
|
@@ -332,6 +332,16 @@ export class SyncClientHandle {
|
|
|
332
332
|
syncUntilIdle(maxRounds) {
|
|
333
333
|
return this.#call('syncUntilIdle', [maxRounds]);
|
|
334
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Rotate the client id (§1.5) after a `sync.invalid_client_id` refusal.
|
|
337
|
+
* The worker's next round carries the new id; the replica and outbox stay
|
|
338
|
+
* in place.
|
|
339
|
+
*/
|
|
340
|
+
async resetClientId() {
|
|
341
|
+
const clientId = await this.#call('resetClientId', []);
|
|
342
|
+
this.#clientId = clientId;
|
|
343
|
+
return clientId;
|
|
344
|
+
}
|
|
335
345
|
query(sql, params) {
|
|
336
346
|
return this.#call('query', [sql, params]);
|
|
337
347
|
}
|
|
@@ -139,6 +139,7 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
|
|
|
139
139
|
};
|
|
140
140
|
sync(): Promise<SyncSummary>;
|
|
141
141
|
syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
|
|
142
|
+
resetClientId(): string;
|
|
142
143
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
143
144
|
authoritySnapshot(): import('./authority.js').AuthoritySnapshot;
|
|
144
145
|
querySnapshot(spec: QueryReadSpec): QuerySnapshot;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.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",
|
|
@@ -97,10 +97,10 @@
|
|
|
97
97
|
},
|
|
98
98
|
"dependencies": {
|
|
99
99
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
100
|
-
"@syncular/core": "0.
|
|
100
|
+
"@syncular/core": "0.42.0"
|
|
101
101
|
},
|
|
102
102
|
"devDependencies": {
|
|
103
|
-
"@syncular/server": "0.
|
|
103
|
+
"@syncular/server": "0.42.0",
|
|
104
104
|
"playwright": "1.63.0",
|
|
105
105
|
"@electric-sql/pglite": "^0.5.4"
|
|
106
106
|
}
|
package/src/client.ts
CHANGED
|
@@ -1617,6 +1617,22 @@ export class SyncClient {
|
|
|
1617
1617
|
return this.#clientId;
|
|
1618
1618
|
}
|
|
1619
1619
|
|
|
1620
|
+
/**
|
|
1621
|
+
* Rotate the caller's client id (§1.5). Use it after a
|
|
1622
|
+
* `sync.invalid_client_id` refusal (`recommendedAction: resetClientId`):
|
|
1623
|
+
* the next round carries the new id. The local replica and its outbox stay
|
|
1624
|
+
* in place; wipe them yourself when the stored data belongs to the account
|
|
1625
|
+
* that signed out.
|
|
1626
|
+
*/
|
|
1627
|
+
resetClientId(): string {
|
|
1628
|
+
this.#requireStarted();
|
|
1629
|
+
this.disconnectRealtime();
|
|
1630
|
+
const clientId = crypto.randomUUID();
|
|
1631
|
+
setMeta(this.#db, 'clientId', clientId);
|
|
1632
|
+
this.#clientId = clientId;
|
|
1633
|
+
return clientId;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1620
1636
|
/** The underlying database: raw SQL is the local query API. */
|
|
1621
1637
|
get database(): ClientDatabase {
|
|
1622
1638
|
this.#requireActive();
|
|
@@ -4903,6 +4919,8 @@ export class SyncClient {
|
|
|
4903
4919
|
// at creation, never unhandled.
|
|
4904
4920
|
const prefetched = new Map<SegmentRefFrame, Promise<Uint8Array>>();
|
|
4905
4921
|
let reporting: SegmentRefFrame | undefined;
|
|
4922
|
+
const deleteKey = (commitSeq: number, table: string, rowId: string) =>
|
|
4923
|
+
`${commitSeq}\u0000${table}\u0000${rowId}`;
|
|
4906
4924
|
{
|
|
4907
4925
|
let openSub: SubscriptionRecord | undefined;
|
|
4908
4926
|
for (const frame of message.frames) {
|
|
@@ -4939,6 +4957,70 @@ export class SyncClient {
|
|
|
4939
4957
|
}
|
|
4940
4958
|
}
|
|
4941
4959
|
|
|
4960
|
+
// §2.2 scope migration: the server emits the delete tagged with the old
|
|
4961
|
+
// scope and the upsert tagged with the new one in one commit. A reader
|
|
4962
|
+
// that holds both scopes receives them in two subscription sections, so
|
|
4963
|
+
// the section order would otherwise decide whether the row survives. One
|
|
4964
|
+
// commit's delete and upsert of the same row are one change, and the
|
|
4965
|
+
// upsert wins. A frame that carries both itself keeps its own operation
|
|
4966
|
+
// order; only a pair split across sections is coalesced.
|
|
4967
|
+
const coalescedDeletes = new Set<string>();
|
|
4968
|
+
{
|
|
4969
|
+
const ops = new Map<
|
|
4970
|
+
string,
|
|
4971
|
+
{ readonly upserts: Set<number>; readonly deletes: Set<number> }
|
|
4972
|
+
>();
|
|
4973
|
+
const bothInFrame = new Set<string>();
|
|
4974
|
+
let openSub: SubscriptionRecord | undefined;
|
|
4975
|
+
let frameIndex = -1;
|
|
4976
|
+
for (const frame of message.frames) {
|
|
4977
|
+
if (frame.type === 'SUB_START') {
|
|
4978
|
+
const sub = subsById.get(frame.id);
|
|
4979
|
+
openSub =
|
|
4980
|
+
sub === undefined ||
|
|
4981
|
+
(mode === 'delta' &&
|
|
4982
|
+
(sub.status !== 'active' || sub.bootstrapState !== undefined))
|
|
4983
|
+
? undefined
|
|
4984
|
+
: sub;
|
|
4985
|
+
} else if (frame.type === 'SUB_END') {
|
|
4986
|
+
openSub = undefined;
|
|
4987
|
+
} else if (frame.type === 'ERROR') {
|
|
4988
|
+
break;
|
|
4989
|
+
} else if (frame.type === 'COMMIT' && openSub !== undefined) {
|
|
4990
|
+
frameIndex += 1;
|
|
4991
|
+
const local = new Map<string, number>();
|
|
4992
|
+
for (const change of frame.changes) {
|
|
4993
|
+
const table = frame.tables[change.tableIndex];
|
|
4994
|
+
if (table === undefined) continue;
|
|
4995
|
+
const key = deleteKey(frame.commitSeq, table, change.rowId);
|
|
4996
|
+
const entry = ops.get(key) ?? {
|
|
4997
|
+
upserts: new Set<number>(),
|
|
4998
|
+
deletes: new Set<number>(),
|
|
4999
|
+
};
|
|
5000
|
+
if (change.op === 'upsert') entry.upserts.add(frameIndex);
|
|
5001
|
+
else entry.deletes.add(frameIndex);
|
|
5002
|
+
ops.set(key, entry);
|
|
5003
|
+
local.set(
|
|
5004
|
+
key,
|
|
5005
|
+
(local.get(key) ?? 0) | (change.op === 'upsert' ? 1 : 2),
|
|
5006
|
+
);
|
|
5007
|
+
}
|
|
5008
|
+
for (const [key, seen] of local) {
|
|
5009
|
+
if (seen === 3) bothInFrame.add(key);
|
|
5010
|
+
}
|
|
5011
|
+
}
|
|
5012
|
+
}
|
|
5013
|
+
for (const [key, entry] of ops) {
|
|
5014
|
+
if (
|
|
5015
|
+
!bothInFrame.has(key) &&
|
|
5016
|
+
entry.upserts.size > 0 &&
|
|
5017
|
+
entry.deletes.size > 0
|
|
5018
|
+
) {
|
|
5019
|
+
coalescedDeletes.add(key);
|
|
5020
|
+
}
|
|
5021
|
+
}
|
|
5022
|
+
}
|
|
5023
|
+
|
|
4942
5024
|
// Each durable observer transaction emits its own revisioned batch.
|
|
4943
5025
|
// Async decrypt/download work happens outside SQLite transactions.
|
|
4944
5026
|
this.#beginDiagnosticsDeferral();
|
|
@@ -5036,7 +5118,19 @@ export class SyncClient {
|
|
|
5036
5118
|
}
|
|
5037
5119
|
case 'COMMIT':
|
|
5038
5120
|
if (section !== undefined && !section.skip) {
|
|
5039
|
-
|
|
5121
|
+
const changes = frame.changes.filter((change) => {
|
|
5122
|
+
if (change.op !== 'delete') return true;
|
|
5123
|
+
const table = frame.tables[change.tableIndex];
|
|
5124
|
+
return (
|
|
5125
|
+
table === undefined ||
|
|
5126
|
+
!coalescedDeletes.has(
|
|
5127
|
+
deleteKey(frame.commitSeq, table, change.rowId),
|
|
5128
|
+
)
|
|
5129
|
+
);
|
|
5130
|
+
});
|
|
5131
|
+
if (changes.length > 0) {
|
|
5132
|
+
await this.#applyCommit({ ...frame, changes }, summary);
|
|
5133
|
+
}
|
|
5040
5134
|
}
|
|
5041
5135
|
break;
|
|
5042
5136
|
case 'SEGMENT_INLINE':
|
|
@@ -5182,16 +5276,34 @@ export class SyncClient {
|
|
|
5182
5276
|
section = undefined;
|
|
5183
5277
|
break;
|
|
5184
5278
|
}
|
|
5185
|
-
case 'ERROR':
|
|
5279
|
+
case 'ERROR': {
|
|
5186
5280
|
// §1.4 rule 5 / §1.6: the request failed; the open
|
|
5187
5281
|
// subscription's SUB_END values are never persisted.
|
|
5282
|
+
let details: Readonly<Record<string, unknown>> | undefined;
|
|
5283
|
+
if (frame.details !== undefined) {
|
|
5284
|
+
try {
|
|
5285
|
+
const parsed: unknown = JSON.parse(frame.details);
|
|
5286
|
+
if (
|
|
5287
|
+
typeof parsed === 'object' &&
|
|
5288
|
+
parsed !== null &&
|
|
5289
|
+
!Array.isArray(parsed)
|
|
5290
|
+
) {
|
|
5291
|
+
details = parsed as Readonly<Record<string, unknown>>;
|
|
5292
|
+
}
|
|
5293
|
+
} catch {
|
|
5294
|
+
// A malformed details member adds no evidence.
|
|
5295
|
+
}
|
|
5296
|
+
}
|
|
5188
5297
|
errorFrame = new ClientSyncError(
|
|
5189
5298
|
frame.code,
|
|
5190
5299
|
frame.message,
|
|
5191
5300
|
frame.retryable,
|
|
5301
|
+
details,
|
|
5302
|
+
frame.recommendedAction,
|
|
5192
5303
|
);
|
|
5193
5304
|
section = undefined;
|
|
5194
5305
|
break;
|
|
5306
|
+
}
|
|
5195
5307
|
case 'UNKNOWN':
|
|
5196
5308
|
break; // §1.2 rule 2: skipped, never interpreted
|
|
5197
5309
|
}
|
package/src/errors.ts
CHANGED
|
@@ -31,17 +31,21 @@ export class ClientSyncError extends Error {
|
|
|
31
31
|
readonly code: string;
|
|
32
32
|
readonly retryable: boolean;
|
|
33
33
|
readonly details: Readonly<Record<string, unknown>> | undefined;
|
|
34
|
+
/** §10.1 `recommendedAction` from an authenticated HTTP error body. */
|
|
35
|
+
readonly recommendedAction: string | undefined;
|
|
34
36
|
|
|
35
37
|
constructor(
|
|
36
38
|
code: string,
|
|
37
39
|
message: string,
|
|
38
40
|
retryable = false,
|
|
39
41
|
details?: Readonly<Record<string, unknown>>,
|
|
42
|
+
recommendedAction?: string,
|
|
40
43
|
) {
|
|
41
44
|
super(message);
|
|
42
45
|
this.code = code;
|
|
43
46
|
this.retryable = retryable;
|
|
44
47
|
this.details = details;
|
|
48
|
+
this.recommendedAction = recommendedAction;
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -144,6 +148,7 @@ export function classifySqliteFailure(error: unknown): {
|
|
|
144
148
|
sqliteCode: classifySqliteFailure(rollback).sqliteCode,
|
|
145
149
|
},
|
|
146
150
|
},
|
|
151
|
+
error.recommendedAction,
|
|
147
152
|
);
|
|
148
153
|
Object.defineProperty(retained, 'cause', { value: error });
|
|
149
154
|
}
|
package/src/http.ts
CHANGED
|
@@ -52,25 +52,44 @@ async function throwHttpError(
|
|
|
52
52
|
response.status >= 500 ||
|
|
53
53
|
response.status === 429 ||
|
|
54
54
|
defaultCode === 'sync.auth_required';
|
|
55
|
+
let recommendedAction: string | undefined;
|
|
56
|
+
let serverDetails: Readonly<Record<string, unknown>> | undefined;
|
|
55
57
|
try {
|
|
56
58
|
const body = (await response.json()) as {
|
|
57
59
|
code?: string;
|
|
58
60
|
message?: string;
|
|
59
61
|
retryable?: boolean;
|
|
62
|
+
recommendedAction?: string;
|
|
63
|
+
details?: unknown;
|
|
60
64
|
};
|
|
61
65
|
// Only the blob download endpoint can establish absence.
|
|
62
66
|
if (body.code !== 'blob.not_found' || defaultCode === 'blob.not_found') {
|
|
63
67
|
if (typeof body.code === 'string') code = body.code;
|
|
64
68
|
if (typeof body.message === 'string') message = body.message;
|
|
65
69
|
if (typeof body.retryable === 'boolean') retryable = body.retryable;
|
|
70
|
+
if (typeof body.recommendedAction === 'string')
|
|
71
|
+
recommendedAction = body.recommendedAction;
|
|
72
|
+
if (
|
|
73
|
+
typeof body.details === 'object' &&
|
|
74
|
+
body.details !== null &&
|
|
75
|
+
!Array.isArray(body.details)
|
|
76
|
+
)
|
|
77
|
+
serverDetails = body.details as Readonly<Record<string, unknown>>;
|
|
66
78
|
}
|
|
67
79
|
} catch {
|
|
68
80
|
// non-JSON error body — keep the HTTP-status defaults
|
|
69
81
|
}
|
|
70
|
-
throw new ClientSyncError(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
throw new ClientSyncError(
|
|
83
|
+
code,
|
|
84
|
+
message,
|
|
85
|
+
retryable,
|
|
86
|
+
{
|
|
87
|
+
...serverDetails,
|
|
88
|
+
causeKind: 'status',
|
|
89
|
+
httpStatus: response.status,
|
|
90
|
+
},
|
|
91
|
+
recommendedAction,
|
|
92
|
+
);
|
|
74
93
|
}
|
|
75
94
|
|
|
76
95
|
export interface HttpTransportOptions {
|
package/src/worker-entry.ts
CHANGED
|
@@ -492,6 +492,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
492
492
|
const running = requireClient();
|
|
493
493
|
return serializedSync(() => running.syncUntilIdle(maxRounds));
|
|
494
494
|
},
|
|
495
|
+
resetClientId: () => requireClient().resetClientId(),
|
|
495
496
|
localRevision: () => requireClient().localRevision,
|
|
496
497
|
diagnosticsSnapshot: (request) => {
|
|
497
498
|
const snapshot = requireClient().diagnosticsSnapshot(request);
|
package/src/worker-host.ts
CHANGED
|
@@ -661,6 +661,17 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
661
661
|
return this.#call('syncUntilIdle', [maxRounds]);
|
|
662
662
|
}
|
|
663
663
|
|
|
664
|
+
/**
|
|
665
|
+
* Rotate the client id (§1.5) after a `sync.invalid_client_id` refusal.
|
|
666
|
+
* The worker's next round carries the new id; the replica and outbox stay
|
|
667
|
+
* in place.
|
|
668
|
+
*/
|
|
669
|
+
async resetClientId(): Promise<string> {
|
|
670
|
+
const clientId = await this.#call('resetClientId', []);
|
|
671
|
+
this.#clientId = clientId;
|
|
672
|
+
return clientId;
|
|
673
|
+
}
|
|
674
|
+
|
|
664
675
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]> {
|
|
665
676
|
return this.#call('query', [sql, params]);
|
|
666
677
|
}
|
package/src/worker-protocol.ts
CHANGED
|
@@ -188,6 +188,7 @@ export interface WorkerApi extends Omit<
|
|
|
188
188
|
previousVersionDiscard(): { present: boolean; discarded: boolean };
|
|
189
189
|
sync(): Promise<SyncSummary>;
|
|
190
190
|
syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
|
|
191
|
+
resetClientId(): string;
|
|
191
192
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
192
193
|
authoritySnapshot(): import('./authority').AuthoritySnapshot;
|
|
193
194
|
querySnapshot(spec: QueryReadSpec): QuerySnapshot;
|