@syncular/client 0.30.15 → 0.31.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/src/outcomes.ts CHANGED
@@ -9,11 +9,16 @@ import { RETAINED_ROWS } from './failed-overlay';
9
9
  * restart can never turn "rejected" into an inferred success. Conflict payloads
10
10
  * deliberately stay local; retention never deletes an unresolved failure.
11
11
  */
12
- import type { RejectionDetails, RowValue } from '@syncular/core';
12
+ import {
13
+ normalizeRejectionDetails,
14
+ type RejectionDetails,
15
+ type RowValue,
16
+ } from '@syncular/core';
13
17
  import type { ClientDatabase } from './database';
14
18
  import { ClientSyncError } from './errors';
15
19
  import type { OutboxOperation } from './outbox';
16
20
  import {
21
+ isBytesEnvelope,
17
22
  mapRowValues,
18
23
  type JsonRowValue,
19
24
  jsonToRowValue,
@@ -155,61 +160,168 @@ function encodeResults(results: readonly CommitOperationOutcome[]): string {
155
160
  return JSON.stringify(stored);
156
161
  }
157
162
 
163
+ /** A JSON object that is neither null nor an array. */
164
+ function isRecord(value: unknown): value is Record<string, unknown> {
165
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
166
+ }
167
+
168
+ function isStoredRow(value: unknown): value is Record<string, JsonRowValue> {
169
+ return (
170
+ isRecord(value) &&
171
+ Object.values(value).every(
172
+ (cell) =>
173
+ cell === null ||
174
+ typeof cell === 'string' ||
175
+ typeof cell === 'boolean' ||
176
+ (typeof cell === 'number' && Number.isFinite(cell)) ||
177
+ isBytesEnvelope(cell),
178
+ )
179
+ );
180
+ }
181
+
182
+ export function isStoredOperation(value: unknown): value is OutboxOperation {
183
+ return (
184
+ isRecord(value) &&
185
+ typeof value.table === 'string' &&
186
+ typeof value.rowId === 'string' &&
187
+ (value.baseVersion === undefined ||
188
+ Number.isSafeInteger(value.baseVersion)) &&
189
+ ((value.op === 'upsert' && isStoredRow(value.values)) ||
190
+ (value.op === 'delete' && value.values === undefined))
191
+ );
192
+ }
193
+
158
194
  function decodeResults(raw: string): CommitOperationOutcome[] {
195
+ function corrupt(): never {
196
+ throw new ClientSyncError(
197
+ 'sync.local_corrupt',
198
+ 'persisted commit outcome is invalid',
199
+ );
200
+ }
159
201
  const stored = JSON.parse(raw) as StoredCommitOperationOutcome[];
202
+ if (!Array.isArray(stored)) corrupt();
160
203
  return stored.map((result) => {
161
- if (result.status !== 'conflict') return result;
162
- return {
163
- status: 'conflict',
164
- conflict: {
165
- ...result.conflict,
166
- // Pre-column-version journal entries carry no conflictColumns.
167
- conflictColumns: result.conflict.conflictColumns ?? [],
168
- serverRow: mapRowValues(result.conflict.serverRow, jsonToRowValue),
169
- },
170
- };
204
+ if (!isRecord(result)) corrupt();
205
+ if (result.status === 'applied') {
206
+ if (
207
+ !Number.isInteger(result.opIndex) ||
208
+ result.opIndex < -2147483648 ||
209
+ result.opIndex > 2147483647
210
+ )
211
+ corrupt();
212
+ return result;
213
+ }
214
+ if (result.status === 'conflict') {
215
+ const conflict = result.conflict;
216
+ if (
217
+ !isRecord(conflict) ||
218
+ !isStoredRow(conflict.serverRow) ||
219
+ typeof conflict.clientCommitId !== 'string' ||
220
+ !Number.isInteger(conflict.opIndex) ||
221
+ conflict.opIndex < -2147483648 ||
222
+ conflict.opIndex > 2147483647 ||
223
+ typeof conflict.table !== 'string' ||
224
+ typeof conflict.rowId !== 'string' ||
225
+ typeof conflict.code !== 'string' ||
226
+ typeof conflict.message !== 'string' ||
227
+ (conflict.operation !== undefined &&
228
+ !isStoredOperation(conflict.operation)) ||
229
+ !Number.isSafeInteger(conflict.serverVersion) ||
230
+ (conflict.conflictColumns !== undefined &&
231
+ (!Array.isArray(conflict.conflictColumns) ||
232
+ !conflict.conflictColumns.every(
233
+ (column) => typeof column === 'string',
234
+ )))
235
+ ) {
236
+ corrupt();
237
+ }
238
+ return {
239
+ status: 'conflict',
240
+ conflict: {
241
+ ...conflict,
242
+ // Pre-column-version journal entries carry no conflictColumns.
243
+ conflictColumns: conflict.conflictColumns ?? [],
244
+ serverRow: mapRowValues(conflict.serverRow, jsonToRowValue),
245
+ },
246
+ };
247
+ }
248
+ if (result.status === 'error') {
249
+ const rejection = result.rejection;
250
+ if (
251
+ !isRecord(rejection) ||
252
+ typeof rejection.clientCommitId !== 'string' ||
253
+ !Number.isInteger(rejection.opIndex) ||
254
+ rejection.opIndex < -2147483648 ||
255
+ rejection.opIndex > 2147483647 ||
256
+ typeof rejection.code !== 'string' ||
257
+ typeof rejection.message !== 'string' ||
258
+ typeof rejection.retryable !== 'boolean' ||
259
+ (rejection.operation !== undefined &&
260
+ !isStoredOperation(rejection.operation))
261
+ ) {
262
+ corrupt();
263
+ }
264
+ try {
265
+ const { details, ...fields } = rejection;
266
+ return {
267
+ status: 'error',
268
+ rejection: {
269
+ ...fields,
270
+ ...(details == null
271
+ ? {}
272
+ : { details: normalizeRejectionDetails(details) }),
273
+ },
274
+ };
275
+ } catch {
276
+ corrupt();
277
+ }
278
+ }
279
+ corrupt();
171
280
  });
172
281
  }
173
282
 
174
283
  function parseOutcome(
175
284
  db: ClientDatabase,
176
285
  row: Readonly<Record<string, unknown>>,
286
+ attachRetainedRows = true,
177
287
  ): CommitOutcome {
178
- const retainedRows: RetainedCommitRow[] = db
179
- .query(
180
- `SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`,
181
- [row.client_commit_id as string],
182
- )
183
- .map((retained) => {
184
- const decode = (value: unknown): Record<string, RowValue> | null =>
185
- value === null
186
- ? null
187
- : Object.fromEntries(
188
- Object.entries(
189
- JSON.parse(String(value)) as Record<string, JsonRowValue>,
190
- ).map(([key, value]) => [key, jsonToRowValue(value)]),
191
- );
192
- return {
193
- table: retained.tbl as string,
194
- rowId: retained.id as string,
195
- localRow: decode(retained.intent),
196
- ...(retained.unique_conflicts === null
197
- ? {}
198
- : {
199
- uniqueConflicts: (
200
- JSON.parse(retained.unique_conflicts as string) as NonNullable<
201
- RetainedCommitRow['uniqueConflicts']
202
- >
203
- ).map((conflict) => ({
204
- ...conflict,
205
- serverRow: decode(JSON.stringify(conflict.serverRow))!,
206
- })),
207
- }),
208
- serverRow: decode(retained.base),
209
- serverVersion:
210
- retained.version === null ? null : (retained.version as number),
211
- };
212
- });
288
+ const retainedRows: RetainedCommitRow[] = attachRetainedRows
289
+ ? db
290
+ .query(
291
+ `SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`,
292
+ [row.client_commit_id as string],
293
+ )
294
+ .map((retained) => {
295
+ const decode = (value: unknown): Record<string, RowValue> | null =>
296
+ value === null
297
+ ? null
298
+ : Object.fromEntries(
299
+ Object.entries(
300
+ JSON.parse(String(value)) as Record<string, JsonRowValue>,
301
+ ).map(([key, value]) => [key, jsonToRowValue(value)]),
302
+ );
303
+ return {
304
+ table: retained.tbl as string,
305
+ rowId: retained.id as string,
306
+ localRow: decode(retained.intent),
307
+ ...(retained.unique_conflicts === null
308
+ ? {}
309
+ : {
310
+ uniqueConflicts: (
311
+ JSON.parse(
312
+ retained.unique_conflicts as string,
313
+ ) as NonNullable<RetainedCommitRow['uniqueConflicts']>
314
+ ).map((conflict) => ({
315
+ ...conflict,
316
+ serverRow: decode(JSON.stringify(conflict.serverRow))!,
317
+ })),
318
+ }),
319
+ serverRow: decode(retained.base),
320
+ serverVersion:
321
+ retained.version === null ? null : (retained.version as number),
322
+ };
323
+ })
324
+ : [];
213
325
  return {
214
326
  sequence: row.seq as number,
215
327
  clientCommitId: row.client_commit_id as string,
@@ -259,6 +371,78 @@ export function commitOutcome(
259
371
  return row === undefined ? undefined : parseOutcome(db, row);
260
372
  }
261
373
 
374
+ /**
375
+ * §7.5 persisted-journal view: the stored outcome WITHOUT the owner-derived
376
+ * `retainedRows` images. A malformed journal row (invalid status, resolution,
377
+ * results, or operations JSON) raises the same static `sync.local_corrupt` the
378
+ * native sidecar reports, never a raw `SyntaxError`.
379
+ */
380
+ export function persistedCommitOutcome(
381
+ db: ClientDatabase,
382
+ clientCommitId: string,
383
+ ): CommitOutcome | undefined {
384
+ const row = db.query(`${SELECT_OUTCOME} WHERE client_commit_id=?`, [
385
+ clientCommitId,
386
+ ])[0];
387
+ if (row === undefined) return undefined;
388
+ function corrupt(): never {
389
+ throw new ClientSyncError(
390
+ 'sync.local_corrupt',
391
+ 'persisted commit outcome is invalid',
392
+ );
393
+ }
394
+ const status = row.status;
395
+ const resolution = row.resolution;
396
+ if (
397
+ (status !== 'applied' &&
398
+ status !== 'cached' &&
399
+ status !== 'conflict' &&
400
+ status !== 'rejected') ||
401
+ (resolution !== 'active' &&
402
+ resolution !== 'resolved_keep_server' &&
403
+ resolution !== 'superseded' &&
404
+ resolution !== 'dismissed') ||
405
+ !Number.isSafeInteger(row.seq) ||
406
+ typeof row.client_commit_id !== 'string' ||
407
+ !Number.isSafeInteger(row.recorded_at_ms) ||
408
+ typeof row.results !== 'string' ||
409
+ (row.operations !== null && typeof row.operations !== 'string') ||
410
+ (row.resolved_at_ms !== null &&
411
+ !Number.isSafeInteger(row.resolved_at_ms)) ||
412
+ (row.replacement_client_commit_id !== null &&
413
+ typeof row.replacement_client_commit_id !== 'string')
414
+ ) {
415
+ corrupt();
416
+ }
417
+ try {
418
+ const outcome = parseOutcome(db, row, false);
419
+ const hasConflict = outcome.results.some(
420
+ (result) => result.status === 'conflict',
421
+ );
422
+ const hasError = outcome.results.some(
423
+ (result) => result.status === 'error',
424
+ );
425
+ const redacted =
426
+ outcome.results.length === 0 && outcome.operations === undefined;
427
+ if (
428
+ ((status === 'applied' || status === 'cached') &&
429
+ (hasConflict || hasError)) ||
430
+ (status === 'conflict' && !(hasConflict || redacted)) ||
431
+ (status === 'rejected' && (hasConflict || !(hasError || redacted)))
432
+ )
433
+ corrupt();
434
+ if (
435
+ outcome.operations !== undefined &&
436
+ (!Array.isArray(outcome.operations) ||
437
+ !outcome.operations.every(isStoredOperation))
438
+ )
439
+ corrupt();
440
+ return outcome;
441
+ } catch {
442
+ corrupt();
443
+ }
444
+ }
445
+
262
446
  export function listCommitOutcomes(
263
447
  db: ClientDatabase,
264
448
  query: CommitOutcomeQuery = {},
package/src/schema.ts CHANGED
@@ -105,6 +105,19 @@ function invalidSchema(message: string): never {
105
105
  export function compileClientSchema(
106
106
  schema: ClientSchema,
107
107
  ): CompiledClientSchema {
108
+ // §7.4.2: the persisted marker is a canonical positive i32 and a replica
109
+ // refuses any requested version outside that range before it touches
110
+ // storage, so a client can never persist a marker it would reject on the
111
+ // next open.
112
+ if (
113
+ !Number.isInteger(schema.version) ||
114
+ schema.version < 1 ||
115
+ schema.version > 2147483647
116
+ ) {
117
+ throw invalidRequest(
118
+ 'generated schema version must be an integer in 1..=2147483647',
119
+ );
120
+ }
108
121
  const tables = new Map<string, CompiledClientTable>();
109
122
  const schemaObjectNames = new Set<string>();
110
123
  for (const table of schema.tables) {
@@ -726,7 +739,13 @@ export function coerceSqlRepresentation(
726
739
  switch (localColumnType(column)) {
727
740
  case 'boolean':
728
741
  return value === 0 ? false : value === 1 ? true : value;
729
- case 'integer':
742
+ case 'integer': {
743
+ const integer = typeof value === 'bigint' ? Number(value) : value;
744
+ if (typeof integer === 'number' && !Number.isSafeInteger(integer)) {
745
+ throw invalidRequest('integer value is outside the safe-integer range');
746
+ }
747
+ return integer;
748
+ }
730
749
  case 'float':
731
750
  return typeof value === 'bigint' ? Number(value) : value;
732
751
  default:
@@ -775,6 +794,24 @@ function bytesToHex(bytes: Uint8Array): string {
775
794
  return out;
776
795
  }
777
796
 
797
+ /**
798
+ * The canonical §7.1 byte envelope: exactly one `$bytes` key holding a string
799
+ * of hexadecimal digit pairs. Uppercase digits are accepted; encoders emit
800
+ * lowercase. A malformed envelope is a caller error, never a value to coerce.
801
+ */
802
+ export function isBytesEnvelope(
803
+ value: unknown,
804
+ ): value is { readonly $bytes: string } {
805
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
806
+ return false;
807
+ const record = value as Record<string, unknown>;
808
+ return (
809
+ Object.keys(record).length === 1 &&
810
+ typeof record.$bytes === 'string' &&
811
+ /^(?:[0-9a-fA-F]{2})*$/.test(record.$bytes)
812
+ );
813
+ }
814
+
778
815
  export function hexToBytes(hex: string): Uint8Array {
779
816
  const out = new Uint8Array(hex.length / 2);
780
817
  for (let i = 0; i < out.length; i++) {
@@ -790,6 +827,10 @@ export function rowValueToJson(value: RowValue): JsonRowValue {
790
827
 
791
828
  export function jsonToRowValue(value: JsonRowValue): RowValue {
792
829
  if (typeof value === 'object' && value !== null) {
830
+ if (!isBytesEnvelope(value))
831
+ throw invalidRequest(
832
+ 'an object value must be one $bytes key holding an even-length hexadecimal string',
833
+ );
793
834
  return hexToBytes(value.$bytes);
794
835
  }
795
836
  return value;
package/src/state.ts CHANGED
@@ -9,6 +9,7 @@ export const SUBSCRIPTIONS_TABLE = '_syncular_subscriptions';
9
9
  * exactly for that purpose.
10
10
  */
11
11
  import type { ScopeMap } from '@syncular/core';
12
+ import { ClientSyncError } from './errors';
12
13
  import type { CompiledClientSchema } from './schema';
13
14
  import type { ClientDatabase } from './database';
14
15
  import type { LocalRevision } from './invalidation';
@@ -225,12 +226,18 @@ export function setMeta(db: ClientDatabase, key: string, value: string): void {
225
226
  export function getLocalRevision(db: ClientDatabase): LocalRevision {
226
227
  const raw = getMeta(db, LOCAL_REVISION_KEY);
227
228
  if (raw === undefined) return 0n;
228
- if (!/^(0|[1-9][0-9]*)$/.test(raw)) {
229
- throw new Error(`invalid persisted local revision ${JSON.stringify(raw)}`);
229
+ if (typeof raw !== 'string' || !/^(0|[1-9][0-9]*)$/.test(raw)) {
230
+ throw new ClientSyncError(
231
+ 'sync.local_corrupt',
232
+ 'persisted local revision is invalid',
233
+ );
230
234
  }
231
235
  const revision = BigInt(raw);
232
236
  if (revision > MAX_U64) {
233
- throw new Error(`persisted local revision exceeds u64: ${raw}`);
237
+ throw new ClientSyncError(
238
+ 'sync.local_corrupt',
239
+ 'persisted local revision is invalid',
240
+ );
234
241
  }
235
242
  return revision;
236
243
  }
@@ -242,7 +249,10 @@ export function getLocalRevision(db: ClientDatabase): LocalRevision {
242
249
  export function bumpLocalRevision(db: ClientDatabase): LocalRevision {
243
250
  const current = getLocalRevision(db);
244
251
  if (current === MAX_U64) {
245
- throw new Error('local revision exhausted u64');
252
+ throw new ClientSyncError(
253
+ 'sync.local_corrupt',
254
+ 'local revision exhausted u64',
255
+ );
246
256
  }
247
257
  const next = current + 1n;
248
258
  setMeta(db, LOCAL_REVISION_KEY, next.toString());
@@ -87,6 +87,16 @@ export function installSyncScheduler(
87
87
  backgroundReady = false;
88
88
  void client
89
89
  .syncUntilIdle(options.maxRounds)
90
+ .then((result) => {
91
+ if (
92
+ result !== null &&
93
+ typeof result === 'object' &&
94
+ 'budgetExhausted' in result &&
95
+ result.budgetExhausted === true
96
+ ) {
97
+ queueImmediate();
98
+ }
99
+ })
90
100
  .catch((error: unknown) => {
91
101
  if (!stopped) report(error);
92
102
  })
@@ -13,7 +13,7 @@
13
13
  * RPC-driven and auto-driven sync rounds serialize on one queue because
14
14
  * the core owns exactly one loop.
15
15
  */
16
- import { defineAuthorityReads } from './authority';
16
+ import type { defineAuthorityReads } from './authority';
17
17
  import { SyncClient } from './client';
18
18
  import type { ClientDatabase } from './database';
19
19
  import {
@@ -52,6 +52,8 @@ import {
52
52
  } from './worker-protocol';
53
53
 
54
54
  export interface SyncWorkerOverrides {
55
+ /** Explicit authority reader factory. Required when init declares authorityReads. */
56
+ readonly createAuthorityReads?: typeof defineAuthorityReads;
55
57
  /** Database factory indirection: tests inject bun:sqlite here. */
56
58
  readonly openDatabase?: (
57
59
  config: WorkerInitConfig,
@@ -175,6 +177,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
175
177
  void serializedSync(() => running.syncUntilIdle())
176
178
  .then((summary) => {
177
179
  if (!closed) post({ t: 'event', event: { kind: 'synced', summary } });
180
+ if (summary.budgetExhausted) consumeSyncIntent({ kind: 'interactive' });
178
181
  })
179
182
  .catch((error: unknown) => {
180
183
  if (!closed) {
@@ -239,6 +242,11 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
239
242
  }
240
243
 
241
244
  async function init(config: WorkerInitConfig): Promise<WorkerInitResult> {
245
+ if (config.authorityReads !== undefined && !overrides.createAuthorityReads)
246
+ throw new ClientSyncError(
247
+ 'client.authority_read_forbidden',
248
+ 'authorityReads requires startSyncWorker({ createAuthorityReads })',
249
+ );
242
250
  if (client !== undefined) {
243
251
  throw new ClientSyncError(
244
252
  WORKER_FAILED_CODE,
@@ -325,7 +333,11 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
325
333
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
326
334
  : {}),
327
335
  ...(config.authorityReads !== undefined
328
- ? { authorityReads: defineAuthorityReads(config.authorityReads) }
336
+ ? {
337
+ authorityReads: overrides.createAuthorityReads!(
338
+ config.authorityReads,
339
+ ),
340
+ }
329
341
  : {}),
330
342
  ...(config.securityPreflight !== undefined
331
343
  ? { securityPreflight: config.securityPreflight }
@@ -393,8 +405,47 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
393
405
  return { clientId: started.clientId };
394
406
  }
395
407
 
408
+ // Forward every argument to core methods, including authority's zero-argument check.
409
+ const coreMethods = [
410
+ 'securityLifecycle',
411
+ 'subscribe',
412
+ 'unsubscribe',
413
+ 'windowState',
414
+ 'purgeLocalData',
415
+ 'rebootstrapLocalData',
416
+ 'previousVersionSnapshot',
417
+ 'previousVersionAudit',
418
+ 'previousVersionDiscard',
419
+ 'query',
420
+ 'authoritySnapshot',
421
+ 'querySnapshot',
422
+ 'snapshotRead',
423
+ 'statusSnapshot',
424
+ 'conflicts',
425
+ 'rejections',
426
+ 'commitOutcome',
427
+ 'commitOutcomes',
428
+ 'resolveCommitOutcome',
429
+ 'pendingCommits',
430
+ 'subscriptions',
431
+ 'subscription',
432
+ 'connectRealtime',
433
+ 'disconnectRealtime',
434
+ 'setPresence',
435
+ 'presence',
436
+ 'uploadBlob',
437
+ 'fetchBlob',
438
+ ] as const satisfies readonly (keyof WorkerApi & keyof SyncClient)[];
396
439
  const api: WorkerApi = {
397
- securityLifecycle: () => requireClient().securityLifecycle(),
440
+ ...(Object.fromEntries(
441
+ coreMethods.map((method) => [
442
+ method,
443
+ (...args: unknown[]) => {
444
+ const running = requireClient();
445
+ return Reflect.apply(running[method], running, args);
446
+ },
447
+ ]),
448
+ ) as Pick<WorkerApi, (typeof coreMethods)[number]>),
398
449
  beginSecurityPreflight: async () => {
399
450
  if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
400
451
  backgroundTimer = undefined;
@@ -409,12 +460,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
409
460
  : {}),
410
461
  });
411
462
  },
412
- subscribe: (input) => requireClient().subscribe(input),
413
- unsubscribe: (id) => requireClient().unsubscribe(id),
414
463
  setWindow: async (base, units) => {
415
464
  await requireClient().setWindowCommand(base, units);
416
465
  },
417
- windowState: (base) => requireClient().windowState(base),
418
466
  mutate: (mutations) => {
419
467
  return requireClient().mutateCommand(mutations).value;
420
468
  },
@@ -429,13 +477,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
429
477
  );
430
478
  return result.value;
431
479
  },
432
- purgeLocalData: (input) => requireClient().purgeLocalData(input),
433
- rebootstrapLocalData: (input) =>
434
- requireClient().rebootstrapLocalData(input),
435
- previousVersionSnapshot: (spec) =>
436
- requireClient().previousVersionSnapshot(spec),
437
- previousVersionAudit: () => requireClient().previousVersionAudit(),
438
- previousVersionDiscard: () => requireClient().previousVersionDiscard(),
439
480
  sync: () => {
440
481
  const running = requireClient();
441
482
  return serializedSync(() => running.sync());
@@ -444,11 +485,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
444
485
  const running = requireClient();
445
486
  return serializedSync(() => running.syncUntilIdle(maxRounds));
446
487
  },
447
- query: (sql, params) => requireClient().query(sql, params),
448
- authoritySnapshot: () => requireClient().authoritySnapshot(),
449
- querySnapshot: (spec) => requireClient().querySnapshot(spec),
450
488
  localRevision: () => requireClient().localRevision,
451
- statusSnapshot: () => requireClient().statusSnapshot(),
452
489
  diagnosticsSnapshot: (request) => {
453
490
  const snapshot = requireClient().diagnosticsSnapshot(request);
454
491
  return withClientDiagnosticsHost(snapshot, {
@@ -458,22 +495,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
458
495
  realtime: snapshot.host.realtime,
459
496
  });
460
497
  },
461
- conflicts: () => requireClient().conflicts(),
462
- rejections: () => requireClient().rejections(),
463
- commitOutcome: (clientCommitId) =>
464
- requireClient().commitOutcome(clientCommitId),
465
- commitOutcomes: (query) => requireClient().commitOutcomes(query),
466
- resolveCommitOutcome: (input) =>
467
- requireClient().resolveCommitOutcome(input),
468
- pendingCommits: () => requireClient().pendingCommits(),
469
- subscriptions: () => requireClient().subscriptions(),
470
- subscription: (id) => requireClient().subscription(id),
471
- connectRealtime: () => requireClient().connectRealtime(),
472
- disconnectRealtime: () => requireClient().disconnectRealtime(),
473
- setPresence: (scopeKey, doc) => requireClient().setPresence(scopeKey, doc),
474
- presence: (scopeKey) => requireClient().presence(scopeKey),
475
- uploadBlob: (bytes, options) => requireClient().uploadBlob(bytes, options),
476
- fetchBlob: (blobIdOrRef) => requireClient().fetchBlob(blobIdOrRef),
477
498
  setHeaders: (next) => {
478
499
  headers = next;
479
500
  },
@@ -525,11 +546,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
525
546
  ]);
526
547
 
527
548
  async function dispatch(message: WorkerCallMessage): Promise<unknown> {
528
- if (message.method === 'authoritySnapshot' && message.args.length)
529
- throw new ClientSyncError(
530
- 'client.authority_read_forbidden',
531
- 'authoritySnapshot accepts no arguments',
532
- );
533
549
  const method = api[message.method] as (...args: unknown[]) => unknown;
534
550
  const invoke = () => method.apply(api, message.args as unknown[]);
535
551
  if (syncChainMethods.has(message.method)) {
@@ -37,10 +37,13 @@ import type {
37
37
  QuerySnapshot,
38
38
  RejectionRecord,
39
39
  SecurityLifecycle,
40
+ SnapshotRead,
41
+ SnapshotReadRequest,
40
42
  SubscribeInput,
41
43
  SyncClientConfig,
42
44
  SyncClientLimits,
43
45
  SyncSummary,
46
+ SyncIdleResult,
44
47
  WindowState,
45
48
  } from './client';
46
49
  import type { SqlRow, SqlValue } from './database';
@@ -650,7 +653,7 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
650
653
  return this.#call('sync', []);
651
654
  }
652
655
 
653
- syncUntilIdle(maxRounds?: number): Promise<SyncSummary> {
656
+ syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult> {
654
657
  return this.#call('syncUntilIdle', [maxRounds]);
655
658
  }
656
659
 
@@ -673,6 +676,12 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
673
676
  return this.#call('querySnapshot', [spec]) as Promise<QuerySnapshot<Row>>;
674
677
  }
675
678
 
679
+ snapshotRead<Row = SqlRow>(
680
+ spec: SnapshotReadRequest,
681
+ ): Promise<SnapshotRead<Row>> {
682
+ return this.#call('snapshotRead', [spec]) as Promise<SnapshotRead<Row>>;
683
+ }
684
+
676
685
  localRevision(): Promise<LocalRevision> {
677
686
  return this.#call('localRevision', []);
678
687
  }
@@ -31,6 +31,7 @@ import type {
31
31
  SyncClientConfig,
32
32
  SyncClientLimits,
33
33
  SyncSummary,
34
+ SyncIdleResult,
34
35
  WindowState,
35
36
  } from './client';
36
37
  import type { SqlRow, SqlValue } from './database';
@@ -185,7 +186,7 @@ export interface WorkerApi extends Omit<
185
186
  /** RFC 0005 D9: the executable rollback step — drop the container. */
186
187
  previousVersionDiscard(): { present: boolean; discarded: boolean };
187
188
  sync(): Promise<SyncSummary>;
188
- syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
189
+ syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
189
190
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
190
191
  authoritySnapshot(): import('./authority').AuthoritySnapshot;
191
192
  querySnapshot(spec: QueryReadSpec): QuerySnapshot;