@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/dist/outcomes.js CHANGED
@@ -1,8 +1,17 @@
1
1
  export const OUTCOMES_TABLE = '_syncular_commit_outcomes';
2
2
  const SELECT_OUTCOME = `SELECT * FROM ${OUTCOMES_TABLE}`;
3
3
  import { RETAINED_ROWS } from './failed-overlay.js';
4
+ /**
5
+ * Durable per-client commit outcomes.
6
+ *
7
+ * The journal is client-local protected database state. A final push result is
8
+ * written in the same SQLite transaction that drains its outbox commit, so a
9
+ * restart can never turn "rejected" into an inferred success. Conflict payloads
10
+ * deliberately stay local; retention never deletes an unresolved failure.
11
+ */
12
+ import { normalizeRejectionDetails, } from '@syncular/core';
4
13
  import { ClientSyncError } from './errors.js';
5
- import { mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
14
+ import { isBytesEnvelope, mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
6
15
  function encodeResults(results) {
7
16
  const stored = results.map((result) => {
8
17
  if (result.status !== 'conflict')
@@ -17,45 +26,132 @@ function encodeResults(results) {
17
26
  });
18
27
  return JSON.stringify(stored);
19
28
  }
29
+ /** A JSON object that is neither null nor an array. */
30
+ function isRecord(value) {
31
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
32
+ }
33
+ function isStoredRow(value) {
34
+ return (isRecord(value) &&
35
+ Object.values(value).every((cell) => cell === null ||
36
+ typeof cell === 'string' ||
37
+ typeof cell === 'boolean' ||
38
+ (typeof cell === 'number' && Number.isFinite(cell)) ||
39
+ isBytesEnvelope(cell)));
40
+ }
41
+ export function isStoredOperation(value) {
42
+ return (isRecord(value) &&
43
+ typeof value.table === 'string' &&
44
+ typeof value.rowId === 'string' &&
45
+ (value.baseVersion === undefined ||
46
+ Number.isSafeInteger(value.baseVersion)) &&
47
+ ((value.op === 'upsert' && isStoredRow(value.values)) ||
48
+ (value.op === 'delete' && value.values === undefined)));
49
+ }
20
50
  function decodeResults(raw) {
51
+ function corrupt() {
52
+ throw new ClientSyncError('sync.local_corrupt', 'persisted commit outcome is invalid');
53
+ }
21
54
  const stored = JSON.parse(raw);
55
+ if (!Array.isArray(stored))
56
+ corrupt();
22
57
  return stored.map((result) => {
23
- if (result.status !== 'conflict')
58
+ if (!isRecord(result))
59
+ corrupt();
60
+ if (result.status === 'applied') {
61
+ if (!Number.isInteger(result.opIndex) ||
62
+ result.opIndex < -2147483648 ||
63
+ result.opIndex > 2147483647)
64
+ corrupt();
24
65
  return result;
25
- return {
26
- status: 'conflict',
27
- conflict: {
28
- ...result.conflict,
29
- // Pre-column-version journal entries carry no conflictColumns.
30
- conflictColumns: result.conflict.conflictColumns ?? [],
31
- serverRow: mapRowValues(result.conflict.serverRow, jsonToRowValue),
32
- },
33
- };
66
+ }
67
+ if (result.status === 'conflict') {
68
+ const conflict = result.conflict;
69
+ if (!isRecord(conflict) ||
70
+ !isStoredRow(conflict.serverRow) ||
71
+ typeof conflict.clientCommitId !== 'string' ||
72
+ !Number.isInteger(conflict.opIndex) ||
73
+ conflict.opIndex < -2147483648 ||
74
+ conflict.opIndex > 2147483647 ||
75
+ typeof conflict.table !== 'string' ||
76
+ typeof conflict.rowId !== 'string' ||
77
+ typeof conflict.code !== 'string' ||
78
+ typeof conflict.message !== 'string' ||
79
+ (conflict.operation !== undefined &&
80
+ !isStoredOperation(conflict.operation)) ||
81
+ !Number.isSafeInteger(conflict.serverVersion) ||
82
+ (conflict.conflictColumns !== undefined &&
83
+ (!Array.isArray(conflict.conflictColumns) ||
84
+ !conflict.conflictColumns.every((column) => typeof column === 'string')))) {
85
+ corrupt();
86
+ }
87
+ return {
88
+ status: 'conflict',
89
+ conflict: {
90
+ ...conflict,
91
+ // Pre-column-version journal entries carry no conflictColumns.
92
+ conflictColumns: conflict.conflictColumns ?? [],
93
+ serverRow: mapRowValues(conflict.serverRow, jsonToRowValue),
94
+ },
95
+ };
96
+ }
97
+ if (result.status === 'error') {
98
+ const rejection = result.rejection;
99
+ if (!isRecord(rejection) ||
100
+ typeof rejection.clientCommitId !== 'string' ||
101
+ !Number.isInteger(rejection.opIndex) ||
102
+ rejection.opIndex < -2147483648 ||
103
+ rejection.opIndex > 2147483647 ||
104
+ typeof rejection.code !== 'string' ||
105
+ typeof rejection.message !== 'string' ||
106
+ typeof rejection.retryable !== 'boolean' ||
107
+ (rejection.operation !== undefined &&
108
+ !isStoredOperation(rejection.operation))) {
109
+ corrupt();
110
+ }
111
+ try {
112
+ const { details, ...fields } = rejection;
113
+ return {
114
+ status: 'error',
115
+ rejection: {
116
+ ...fields,
117
+ ...(details == null
118
+ ? {}
119
+ : { details: normalizeRejectionDetails(details) }),
120
+ },
121
+ };
122
+ }
123
+ catch {
124
+ corrupt();
125
+ }
126
+ }
127
+ corrupt();
34
128
  });
35
129
  }
36
- function parseOutcome(db, row) {
37
- const retainedRows = db
38
- .query(`SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`, [row.client_commit_id])
39
- .map((retained) => {
40
- const decode = (value) => value === null
41
- ? null
42
- : Object.fromEntries(Object.entries(JSON.parse(String(value))).map(([key, value]) => [key, jsonToRowValue(value)]));
43
- return {
44
- table: retained.tbl,
45
- rowId: retained.id,
46
- localRow: decode(retained.intent),
47
- ...(retained.unique_conflicts === null
48
- ? {}
49
- : {
50
- uniqueConflicts: JSON.parse(retained.unique_conflicts).map((conflict) => ({
51
- ...conflict,
52
- serverRow: decode(JSON.stringify(conflict.serverRow)),
53
- })),
54
- }),
55
- serverRow: decode(retained.base),
56
- serverVersion: retained.version === null ? null : retained.version,
57
- };
58
- });
130
+ function parseOutcome(db, row, attachRetainedRows = true) {
131
+ const retainedRows = attachRetainedRows
132
+ ? db
133
+ .query(`SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`, [row.client_commit_id])
134
+ .map((retained) => {
135
+ const decode = (value) => value === null
136
+ ? null
137
+ : Object.fromEntries(Object.entries(JSON.parse(String(value))).map(([key, value]) => [key, jsonToRowValue(value)]));
138
+ return {
139
+ table: retained.tbl,
140
+ rowId: retained.id,
141
+ localRow: decode(retained.intent),
142
+ ...(retained.unique_conflicts === null
143
+ ? {}
144
+ : {
145
+ uniqueConflicts: JSON.parse(retained.unique_conflicts).map((conflict) => ({
146
+ ...conflict,
147
+ serverRow: decode(JSON.stringify(conflict.serverRow)),
148
+ })),
149
+ }),
150
+ serverRow: decode(retained.base),
151
+ serverVersion: retained.version === null ? null : retained.version,
152
+ };
153
+ })
154
+ : [];
59
155
  return {
60
156
  sequence: row.seq,
61
157
  clientCommitId: row.client_commit_id,
@@ -93,6 +189,62 @@ export function commitOutcome(db, clientCommitId) {
93
189
  ])[0];
94
190
  return row === undefined ? undefined : parseOutcome(db, row);
95
191
  }
192
+ /**
193
+ * §7.5 persisted-journal view: the stored outcome WITHOUT the owner-derived
194
+ * `retainedRows` images. A malformed journal row (invalid status, resolution,
195
+ * results, or operations JSON) raises the same static `sync.local_corrupt` the
196
+ * native sidecar reports, never a raw `SyntaxError`.
197
+ */
198
+ export function persistedCommitOutcome(db, clientCommitId) {
199
+ const row = db.query(`${SELECT_OUTCOME} WHERE client_commit_id=?`, [
200
+ clientCommitId,
201
+ ])[0];
202
+ if (row === undefined)
203
+ return undefined;
204
+ function corrupt() {
205
+ throw new ClientSyncError('sync.local_corrupt', 'persisted commit outcome is invalid');
206
+ }
207
+ const status = row.status;
208
+ const resolution = row.resolution;
209
+ if ((status !== 'applied' &&
210
+ status !== 'cached' &&
211
+ status !== 'conflict' &&
212
+ status !== 'rejected') ||
213
+ (resolution !== 'active' &&
214
+ resolution !== 'resolved_keep_server' &&
215
+ resolution !== 'superseded' &&
216
+ resolution !== 'dismissed') ||
217
+ !Number.isSafeInteger(row.seq) ||
218
+ typeof row.client_commit_id !== 'string' ||
219
+ !Number.isSafeInteger(row.recorded_at_ms) ||
220
+ typeof row.results !== 'string' ||
221
+ (row.operations !== null && typeof row.operations !== 'string') ||
222
+ (row.resolved_at_ms !== null &&
223
+ !Number.isSafeInteger(row.resolved_at_ms)) ||
224
+ (row.replacement_client_commit_id !== null &&
225
+ typeof row.replacement_client_commit_id !== 'string')) {
226
+ corrupt();
227
+ }
228
+ try {
229
+ const outcome = parseOutcome(db, row, false);
230
+ const hasConflict = outcome.results.some((result) => result.status === 'conflict');
231
+ const hasError = outcome.results.some((result) => result.status === 'error');
232
+ const redacted = outcome.results.length === 0 && outcome.operations === undefined;
233
+ if (((status === 'applied' || status === 'cached') &&
234
+ (hasConflict || hasError)) ||
235
+ (status === 'conflict' && !(hasConflict || redacted)) ||
236
+ (status === 'rejected' && (hasConflict || !(hasError || redacted))))
237
+ corrupt();
238
+ if (outcome.operations !== undefined &&
239
+ (!Array.isArray(outcome.operations) ||
240
+ !outcome.operations.every(isStoredOperation)))
241
+ corrupt();
242
+ return outcome;
243
+ }
244
+ catch {
245
+ corrupt();
246
+ }
247
+ }
96
248
  export function listCommitOutcomes(db, query = {}) {
97
249
  const limit = query.limit;
98
250
  if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) {
package/dist/schema.d.ts CHANGED
@@ -159,6 +159,14 @@ export declare function recordToRowValues(table: CompiledClientTable, record: Re
159
159
  export type JsonRowValue = string | number | boolean | null | {
160
160
  readonly $bytes: string;
161
161
  };
162
+ /**
163
+ * The canonical §7.1 byte envelope: exactly one `$bytes` key holding a string
164
+ * of hexadecimal digit pairs. Uppercase digits are accepted; encoders emit
165
+ * lowercase. A malformed envelope is a caller error, never a value to coerce.
166
+ */
167
+ export declare function isBytesEnvelope(value: unknown): value is {
168
+ readonly $bytes: string;
169
+ };
162
170
  export declare function hexToBytes(hex: string): Uint8Array;
163
171
  export declare function rowValueToJson(value: RowValue): JsonRowValue;
164
172
  export declare function jsonToRowValue(value: JsonRowValue): RowValue;
package/dist/schema.js CHANGED
@@ -20,6 +20,15 @@ function invalidSchema(message) {
20
20
  throw new Error(message);
21
21
  }
22
22
  export function compileClientSchema(schema) {
23
+ // §7.4.2: the persisted marker is a canonical positive i32 and a replica
24
+ // refuses any requested version outside that range before it touches
25
+ // storage, so a client can never persist a marker it would reject on the
26
+ // next open.
27
+ if (!Number.isInteger(schema.version) ||
28
+ schema.version < 1 ||
29
+ schema.version > 2147483647) {
30
+ throw invalidRequest('generated schema version must be an integer in 1..=2147483647');
31
+ }
23
32
  const tables = new Map();
24
33
  const schemaObjectNames = new Set();
25
34
  for (const table of schema.tables) {
@@ -496,7 +505,13 @@ export function coerceSqlRepresentation(column, value) {
496
505
  switch (localColumnType(column)) {
497
506
  case 'boolean':
498
507
  return value === 0 ? false : value === 1 ? true : value;
499
- case 'integer':
508
+ case 'integer': {
509
+ const integer = typeof value === 'bigint' ? Number(value) : value;
510
+ if (typeof integer === 'number' && !Number.isSafeInteger(integer)) {
511
+ throw invalidRequest('integer value is outside the safe-integer range');
512
+ }
513
+ return integer;
514
+ }
500
515
  case 'float':
501
516
  return typeof value === 'bigint' ? Number(value) : value;
502
517
  default:
@@ -527,6 +542,19 @@ function bytesToHex(bytes) {
527
542
  out += byte.toString(16).padStart(2, '0');
528
543
  return out;
529
544
  }
545
+ /**
546
+ * The canonical §7.1 byte envelope: exactly one `$bytes` key holding a string
547
+ * of hexadecimal digit pairs. Uppercase digits are accepted; encoders emit
548
+ * lowercase. A malformed envelope is a caller error, never a value to coerce.
549
+ */
550
+ export function isBytesEnvelope(value) {
551
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
552
+ return false;
553
+ const record = value;
554
+ return (Object.keys(record).length === 1 &&
555
+ typeof record.$bytes === 'string' &&
556
+ /^(?:[0-9a-fA-F]{2})*$/.test(record.$bytes));
557
+ }
530
558
  export function hexToBytes(hex) {
531
559
  const out = new Uint8Array(hex.length / 2);
532
560
  for (let i = 0; i < out.length; i++) {
@@ -541,6 +569,8 @@ export function rowValueToJson(value) {
541
569
  }
542
570
  export function jsonToRowValue(value) {
543
571
  if (typeof value === 'object' && value !== null) {
572
+ if (!isBytesEnvelope(value))
573
+ throw invalidRequest('an object value must be one $bytes key holding an even-length hexadecimal string');
544
574
  return hexToBytes(value.$bytes);
545
575
  }
546
576
  return value;
package/dist/state.js CHANGED
@@ -2,6 +2,7 @@ export const META_TABLE = '_syncular_meta';
2
2
  import { PENDING_EVICTIONS } from './window.js';
3
3
  import { WINDOWS_TABLE } from './window.js';
4
4
  export const SUBSCRIPTIONS_TABLE = '_syncular_subscriptions';
5
+ import { ClientSyncError } from './errors.js';
5
6
  export const LOCAL_REVISION_KEY = 'localRevision';
6
7
  const MAX_U64 = 18446744073709551615n;
7
8
  function rowToRecord(row) {
@@ -145,12 +146,12 @@ export function getLocalRevision(db) {
145
146
  const raw = getMeta(db, LOCAL_REVISION_KEY);
146
147
  if (raw === undefined)
147
148
  return 0n;
148
- if (!/^(0|[1-9][0-9]*)$/.test(raw)) {
149
- throw new Error(`invalid persisted local revision ${JSON.stringify(raw)}`);
149
+ if (typeof raw !== 'string' || !/^(0|[1-9][0-9]*)$/.test(raw)) {
150
+ throw new ClientSyncError('sync.local_corrupt', 'persisted local revision is invalid');
150
151
  }
151
152
  const revision = BigInt(raw);
152
153
  if (revision > MAX_U64) {
153
- throw new Error(`persisted local revision exceeds u64: ${raw}`);
154
+ throw new ClientSyncError('sync.local_corrupt', 'persisted local revision is invalid');
154
155
  }
155
156
  return revision;
156
157
  }
@@ -161,7 +162,7 @@ export function getLocalRevision(db) {
161
162
  export function bumpLocalRevision(db) {
162
163
  const current = getLocalRevision(db);
163
164
  if (current === MAX_U64) {
164
- throw new Error('local revision exhausted u64');
165
+ throw new ClientSyncError('sync.local_corrupt', 'local revision exhausted u64');
165
166
  }
166
167
  const next = current + 1n;
167
168
  setMeta(db, LOCAL_REVISION_KEY, next.toString());
@@ -55,6 +55,14 @@ export function installSyncScheduler(client, options = {}) {
55
55
  backgroundReady = false;
56
56
  void client
57
57
  .syncUntilIdle(options.maxRounds)
58
+ .then((result) => {
59
+ if (result !== null &&
60
+ typeof result === 'object' &&
61
+ 'budgetExhausted' in result &&
62
+ result.budgetExhausted === true) {
63
+ queueImmediate();
64
+ }
65
+ })
58
66
  .catch((error) => {
59
67
  if (!stopped)
60
68
  report(error);
@@ -1,7 +1,25 @@
1
+ /**
2
+ * Worker-side bootstrap: constructs the WHOLE
3
+ * client core — SyncClient + transports + realtime + SQLite — inside the
4
+ * worker and serves the `worker-protocol` RPC over postMessage.
5
+ *
6
+ * Browsers get the defaults: opfs-sahpool persistence and the fetch/WS
7
+ * transports from `./http`, wired from `WorkerInitConfig.endpoints`.
8
+ * Tests inject a database factory (bun:sqlite) through `overrides` — the
9
+ * same bootstrap, the same RPC, a different SQLite.
10
+ *
11
+ * The §8.4 host loop lives HERE: wake-ups (hello/`sync` events, delta
12
+ * drops) coalesce into one intent-driven `syncUntilIdle` round in the worker;
13
+ * RPC-driven and auto-driven sync rounds serialize on one queue because
14
+ * the core owns exactly one loop.
15
+ */
16
+ import type { defineAuthorityReads } from './authority.js';
1
17
  import type { ClientDatabase } from './database.js';
2
18
  import type { RealtimeConnector, SegmentDownloader, SyncTransport } from './transport.js';
3
19
  import { type WorkerInitConfig } from './worker-protocol.js';
4
20
  export interface SyncWorkerOverrides {
21
+ /** Explicit authority reader factory. Required when init declares authorityReads. */
22
+ readonly createAuthorityReads?: typeof defineAuthorityReads;
5
23
  /** Database factory indirection: tests inject bun:sqlite here. */
6
24
  readonly openDatabase?: (config: WorkerInitConfig) => Promise<ClientDatabase> | ClientDatabase;
7
25
  /** Retry deadline indirection for deterministic worker-host tests. */
@@ -1,19 +1,3 @@
1
- /**
2
- * Worker-side bootstrap: constructs the WHOLE
3
- * client core — SyncClient + transports + realtime + SQLite — inside the
4
- * worker and serves the `worker-protocol` RPC over postMessage.
5
- *
6
- * Browsers get the defaults: opfs-sahpool persistence and the fetch/WS
7
- * transports from `./http`, wired from `WorkerInitConfig.endpoints`.
8
- * Tests inject a database factory (bun:sqlite) through `overrides` — the
9
- * same bootstrap, the same RPC, a different SQLite.
10
- *
11
- * The §8.4 host loop lives HERE: wake-ups (hello/`sync` events, delta
12
- * drops) coalesce into one intent-driven `syncUntilIdle` round in the worker;
13
- * RPC-driven and auto-driven sync rounds serialize on one queue because
14
- * the core owns exactly one loop.
15
- */
16
- import { defineAuthorityReads } from './authority.js';
17
1
  import { SyncClient } from './client.js';
18
2
  import { withClientDiagnosticsHost, } from './diagnostics.js';
19
3
  import { encryptionConfigFromKeyring } from './encryption.js';
@@ -104,6 +88,8 @@ export function startSyncWorker(overrides = {}) {
104
88
  .then((summary) => {
105
89
  if (!closed)
106
90
  post({ t: 'event', event: { kind: 'synced', summary } });
91
+ if (summary.budgetExhausted)
92
+ consumeSyncIntent({ kind: 'interactive' });
107
93
  })
108
94
  .catch((error) => {
109
95
  if (!closed) {
@@ -160,6 +146,8 @@ export function startSyncWorker(overrides = {}) {
160
146
  return client;
161
147
  }
162
148
  async function init(config) {
149
+ if (config.authorityReads !== undefined && !overrides.createAuthorityReads)
150
+ throw new ClientSyncError('client.authority_read_forbidden', 'authorityReads requires startSyncWorker({ createAuthorityReads })');
163
151
  if (client !== undefined) {
164
152
  throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
165
153
  }
@@ -235,7 +223,9 @@ export function startSyncWorker(overrides = {}) {
235
223
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
236
224
  : {}),
237
225
  ...(config.authorityReads !== undefined
238
- ? { authorityReads: defineAuthorityReads(config.authorityReads) }
226
+ ? {
227
+ authorityReads: overrides.createAuthorityReads(config.authorityReads),
228
+ }
239
229
  : {}),
240
230
  ...(config.securityPreflight !== undefined
241
231
  ? { securityPreflight: config.securityPreflight }
@@ -300,8 +290,45 @@ export function startSyncWorker(overrides = {}) {
300
290
  consumeSyncIntent({ kind: 'interactive' });
301
291
  return { clientId: started.clientId };
302
292
  }
293
+ // Forward every argument to core methods, including authority's zero-argument check.
294
+ const coreMethods = [
295
+ 'securityLifecycle',
296
+ 'subscribe',
297
+ 'unsubscribe',
298
+ 'windowState',
299
+ 'purgeLocalData',
300
+ 'rebootstrapLocalData',
301
+ 'previousVersionSnapshot',
302
+ 'previousVersionAudit',
303
+ 'previousVersionDiscard',
304
+ 'query',
305
+ 'authoritySnapshot',
306
+ 'querySnapshot',
307
+ 'snapshotRead',
308
+ 'statusSnapshot',
309
+ 'conflicts',
310
+ 'rejections',
311
+ 'commitOutcome',
312
+ 'commitOutcomes',
313
+ 'resolveCommitOutcome',
314
+ 'pendingCommits',
315
+ 'subscriptions',
316
+ 'subscription',
317
+ 'connectRealtime',
318
+ 'disconnectRealtime',
319
+ 'setPresence',
320
+ 'presence',
321
+ 'uploadBlob',
322
+ 'fetchBlob',
323
+ ];
303
324
  const api = {
304
- securityLifecycle: () => requireClient().securityLifecycle(),
325
+ ...Object.fromEntries(coreMethods.map((method) => [
326
+ method,
327
+ (...args) => {
328
+ const running = requireClient();
329
+ return Reflect.apply(running[method], running, args);
330
+ },
331
+ ])),
305
332
  beginSecurityPreflight: async () => {
306
333
  if (backgroundTimer !== undefined)
307
334
  clearTimeout(backgroundTimer);
@@ -317,12 +344,9 @@ export function startSyncWorker(overrides = {}) {
317
344
  : {}),
318
345
  });
319
346
  },
320
- subscribe: (input) => requireClient().subscribe(input),
321
- unsubscribe: (id) => requireClient().unsubscribe(id),
322
347
  setWindow: async (base, units) => {
323
348
  await requireClient().setWindowCommand(base, units);
324
349
  },
325
- windowState: (base) => requireClient().windowState(base),
326
350
  mutate: (mutations) => {
327
351
  return requireClient().mutateCommand(mutations).value;
328
352
  },
@@ -332,11 +356,6 @@ export function startSyncWorker(overrides = {}) {
332
356
  const result = requireClient().patchCommand(table, rowId, partial, options);
333
357
  return result.value;
334
358
  },
335
- purgeLocalData: (input) => requireClient().purgeLocalData(input),
336
- rebootstrapLocalData: (input) => requireClient().rebootstrapLocalData(input),
337
- previousVersionSnapshot: (spec) => requireClient().previousVersionSnapshot(spec),
338
- previousVersionAudit: () => requireClient().previousVersionAudit(),
339
- previousVersionDiscard: () => requireClient().previousVersionDiscard(),
340
359
  sync: () => {
341
360
  const running = requireClient();
342
361
  return serializedSync(() => running.sync());
@@ -345,11 +364,7 @@ export function startSyncWorker(overrides = {}) {
345
364
  const running = requireClient();
346
365
  return serializedSync(() => running.syncUntilIdle(maxRounds));
347
366
  },
348
- query: (sql, params) => requireClient().query(sql, params),
349
- authoritySnapshot: () => requireClient().authoritySnapshot(),
350
- querySnapshot: (spec) => requireClient().querySnapshot(spec),
351
367
  localRevision: () => requireClient().localRevision,
352
- statusSnapshot: () => requireClient().statusSnapshot(),
353
368
  diagnosticsSnapshot: (request) => {
354
369
  const snapshot = requireClient().diagnosticsSnapshot(request);
355
370
  return withClientDiagnosticsHost(snapshot, {
@@ -359,20 +374,6 @@ export function startSyncWorker(overrides = {}) {
359
374
  realtime: snapshot.host.realtime,
360
375
  });
361
376
  },
362
- conflicts: () => requireClient().conflicts(),
363
- rejections: () => requireClient().rejections(),
364
- commitOutcome: (clientCommitId) => requireClient().commitOutcome(clientCommitId),
365
- commitOutcomes: (query) => requireClient().commitOutcomes(query),
366
- resolveCommitOutcome: (input) => requireClient().resolveCommitOutcome(input),
367
- pendingCommits: () => requireClient().pendingCommits(),
368
- subscriptions: () => requireClient().subscriptions(),
369
- subscription: (id) => requireClient().subscription(id),
370
- connectRealtime: () => requireClient().connectRealtime(),
371
- disconnectRealtime: () => requireClient().disconnectRealtime(),
372
- setPresence: (scopeKey, doc) => requireClient().setPresence(scopeKey, doc),
373
- presence: (scopeKey) => requireClient().presence(scopeKey),
374
- uploadBlob: (bytes, options) => requireClient().uploadBlob(bytes, options),
375
- fetchBlob: (blobIdOrRef) => requireClient().fetchBlob(blobIdOrRef),
376
377
  setHeaders: (next) => {
377
378
  headers = next;
378
379
  },
@@ -425,8 +426,6 @@ export function startSyncWorker(overrides = {}) {
425
426
  'previousVersionDiscard',
426
427
  ]);
427
428
  async function dispatch(message) {
428
- if (message.method === 'authoritySnapshot' && message.args.length)
429
- throw new ClientSyncError('client.authority_read_forbidden', 'authoritySnapshot accepts no arguments');
430
429
  const method = api[message.method];
431
430
  const invoke = () => method.apply(api, message.args);
432
431
  if (syncChainMethods.has(message.method)) {
@@ -25,7 +25,7 @@ import type { PromiseMethods } from './client.js';
25
25
  */
26
26
  import type { WakeReason } from '@syncular/core';
27
27
  import type { BlobRef, CachedBlob } from './blob.js';
28
- import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
28
+ import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SnapshotRead, SnapshotReadRequest, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleResult, WindowState } from './client.js';
29
29
  import type { SqlRow, SqlValue } from './database.js';
30
30
  import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot, type RealtimePolicy } from './diagnostics.js';
31
31
  import type { EncryptionKeyringConfig } from './encryption.js';
@@ -244,10 +244,11 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
244
244
  discarded: boolean;
245
245
  }>;
246
246
  sync(): Promise<SyncSummary>;
247
- syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
247
+ syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
248
248
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
249
249
  authoritySnapshot(): Promise<import('./authority.js').AuthoritySnapshot>;
250
250
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
251
+ snapshotRead<Row = SqlRow>(spec: SnapshotReadRequest): Promise<SnapshotRead<Row>>;
251
252
  localRevision(): Promise<LocalRevision>;
252
253
  statusSnapshot(): Promise<SyncStatusSnapshot>;
253
254
  diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
@@ -340,6 +340,9 @@ export class SyncClientHandle {
340
340
  querySnapshot(spec) {
341
341
  return this.#call('querySnapshot', [spec]);
342
342
  }
343
+ snapshotRead(spec) {
344
+ return this.#call('snapshotRead', [spec]);
345
+ }
343
346
  localRevision() {
344
347
  return this.#call('localRevision', []);
345
348
  }
@@ -19,7 +19,7 @@ import type { SyncProgress } from './progress.js';
19
19
  */
20
20
  import type { WakeReason } from '@syncular/core';
21
21
  import type { BlobRef, CachedBlob } from './blob.js';
22
- import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
22
+ import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleResult, WindowState } from './client.js';
23
23
  import type { SqlRow, SqlValue } from './database.js';
24
24
  import type { ClientDiagnosticsSnapshot, RealtimePolicy } from './diagnostics.js';
25
25
  import type { EncryptionKeyringConfig } from './encryption.js';
@@ -137,7 +137,7 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
137
137
  discarded: boolean;
138
138
  };
139
139
  sync(): Promise<SyncSummary>;
140
- syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
140
+ syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
141
141
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
142
142
  authoritySnapshot(): import('./authority.js').AuthoritySnapshot;
143
143
  querySnapshot(spec: QueryReadSpec): QuerySnapshot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.30.15",
3
+ "version": "0.31.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",
@@ -73,6 +73,14 @@
73
73
  "types": "./dist/worker-entry.d.ts",
74
74
  "default": "./dist/worker-entry.js"
75
75
  }
76
+ },
77
+ "./authority": {
78
+ "bun": "./src/authority.ts",
79
+ "browser": "./dist/authority.js",
80
+ "import": {
81
+ "types": "./dist/authority.d.ts",
82
+ "default": "./dist/authority.js"
83
+ }
76
84
  }
77
85
  },
78
86
  "files": [
@@ -89,10 +97,10 @@
89
97
  },
90
98
  "dependencies": {
91
99
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.30.15"
100
+ "@syncular/core": "0.31.0"
93
101
  },
94
102
  "devDependencies": {
95
- "@syncular/server": "0.30.15",
103
+ "@syncular/server": "0.31.0",
96
104
  "playwright": "1.63.0",
97
105
  "@electric-sql/pglite": "^0.5.4"
98
106
  }