@syncular/client 0.41.2 → 0.42.1

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 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
- await this.#applyCommit(frame, summary);
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
- errorFrame = new ClientSyncError(frame.code, frame.message, frame.retryable);
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
- constructor(code: string, message: string, retryable?: boolean, details?: Readonly<Record<string, unknown>>);
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
- constructor(code, message, retryable = false, details) {
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;
@@ -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);
@@ -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>>;
@@ -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.41.2",
3
+ "version": "0.42.1",
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.41.2"
100
+ "@syncular/core": "0.42.1"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.41.2",
103
+ "@syncular/server": "0.42.1",
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
- await this.#applyCommit(frame, summary);
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(code, message, retryable, {
71
- causeKind: 'status',
72
- httpStatus: response.status,
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 {
@@ -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);
@@ -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
  }
@@ -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;