@syncular/client 0.30.14 → 0.30.16

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.
@@ -332,6 +332,11 @@ export class SyncClientHandle {
332
332
  query(sql, params) {
333
333
  return this.#call('query', [sql, params]);
334
334
  }
335
+ authoritySnapshot() {
336
+ if (arguments.length)
337
+ throw new ClientSyncError('client.authority_read_forbidden', 'authoritySnapshot accepts no arguments');
338
+ return this.#call('authoritySnapshot', []);
339
+ }
335
340
  querySnapshot(spec) {
336
341
  return this.#call('querySnapshot', [spec]);
337
342
  }
@@ -569,6 +574,9 @@ function buildInitConfig(config, headers = config.headers) {
569
574
  ...(config.transportEnabled !== undefined
570
575
  ? { transportEnabled: config.transportEnabled }
571
576
  : {}),
577
+ ...(config.authorityReads !== undefined
578
+ ? { authorityReads: config.authorityReads.declarations }
579
+ : {}),
572
580
  ...(config.securityPreflight !== undefined
573
581
  ? { securityPreflight: config.securityPreflight }
574
582
  : {}),
@@ -77,6 +77,7 @@ export interface WorkerInitConfig {
77
77
  /** Portable raw keyring installed inside the worker-owned client core. */
78
78
  readonly encryption?: EncryptionKeyringConfig;
79
79
  /** Open the worker-owned replica behind the fail-closed security gate. */
80
+ readonly authorityReads?: readonly import('./authority.js').AuthorityReadDeclaration[];
80
81
  readonly securityPreflight?: boolean;
81
82
  readonly transportEnabled?: boolean;
82
83
  readonly clientId?: string;
@@ -138,6 +139,7 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
138
139
  sync(): Promise<SyncSummary>;
139
140
  syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
140
141
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
142
+ authoritySnapshot(): import('./authority.js').AuthoritySnapshot;
141
143
  querySnapshot(spec: QueryReadSpec): QuerySnapshot;
142
144
  localRevision(): LocalRevision;
143
145
  pendingCommits(): OutboxCommit[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.30.14",
3
+ "version": "0.30.16",
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.14"
100
+ "@syncular/core": "0.30.16"
93
101
  },
94
102
  "devDependencies": {
95
- "@syncular/server": "0.30.14",
103
+ "@syncular/server": "0.30.16",
96
104
  "playwright": "1.63.0",
97
105
  "@electric-sql/pglite": "^0.5.4"
98
106
  }
@@ -0,0 +1,414 @@
1
+ import type { ClientDatabase, SqlRow, SqlValue } from './database';
2
+ import { ClientSyncError } from './errors';
3
+ import { listOutbox, listOutboxBeforeImages } from './outbox';
4
+ import { RETAINED_ROWS } from './failed-overlay';
5
+ import { getLocalRevision, getMeta, loadSubscriptions } from './state';
6
+ import { fromSqlValue, quoteIdent, SYNC_VERSION_COLUMN } from './schema';
7
+ import type { CompiledClientSchema, JsonRowValue } from './schema';
8
+
9
+ /** Frozen at client creation. Scope selectors use the schema's variables. */
10
+ export interface AuthorityReadDeclaration {
11
+ readonly table: string;
12
+ readonly columns: readonly string[];
13
+ readonly scopes: Readonly<Record<string, readonly string[]>>;
14
+ }
15
+ export interface AuthoritySnapshot {
16
+ readonly revision: bigint;
17
+ readonly complete: boolean;
18
+ readonly tables: readonly {
19
+ readonly table: string;
20
+ readonly rows: readonly {
21
+ readonly values: SqlRow;
22
+ readonly version: number;
23
+ readonly hasLocalIntent: boolean;
24
+ }[];
25
+ /** Includes local-only creations; no intended values are disclosed. */
26
+ readonly localIntentRowIds: readonly string[];
27
+ readonly coverage: 'complete' | 'pending' | 'missing';
28
+ readonly scopes: AuthorityReadDeclaration['scopes'];
29
+ readonly persisted: readonly {
30
+ readonly requestedScopes: AuthorityReadDeclaration['scopes'];
31
+ readonly status: string;
32
+ readonly cursor: number;
33
+ readonly effectiveScopes: AuthorityReadDeclaration['scopes'] | null;
34
+ readonly complete: boolean;
35
+ }[];
36
+ }[];
37
+ }
38
+
39
+ function validateAuthorityReads(
40
+ schema: CompiledClientSchema,
41
+ declarations: readonly AuthorityReadDeclaration[],
42
+ ): void {
43
+ if (!Array.isArray(declarations))
44
+ throw new ClientSyncError(
45
+ 'client.authority_read_forbidden',
46
+ 'authorityReads must be a list',
47
+ );
48
+ const seen = new Set<string>();
49
+ for (const read of declarations) {
50
+ if (
51
+ !read ||
52
+ typeof read !== 'object' ||
53
+ !Array.isArray(read.columns) ||
54
+ !read.scopes ||
55
+ typeof read.scopes !== 'object' ||
56
+ Array.isArray(read.scopes)
57
+ )
58
+ throw new ClientSyncError(
59
+ 'client.authority_read_forbidden',
60
+ 'invalid authority declaration',
61
+ );
62
+ const table = schema.tables.get(read.table);
63
+ const plain = (name: string) =>
64
+ table?.columns.some(
65
+ (c) =>
66
+ c.name === name &&
67
+ name !== 'authority_version' &&
68
+ !name.startsWith('_sync') &&
69
+ !c.encrypted &&
70
+ ['string', 'integer', 'float', 'boolean', 'json'].includes(c.type),
71
+ );
72
+ if (
73
+ Object.keys(read).sort().join() !== 'columns,scopes,table' ||
74
+ !table ||
75
+ seen.has(read.table) ||
76
+ !read.columns.length ||
77
+ new Set(read.columns).size !== read.columns.length ||
78
+ !read.columns.includes(table.primaryKey) ||
79
+ !read.columns.every(plain) ||
80
+ !Object.keys(read.scopes).length ||
81
+ !Object.entries(read.scopes).every(
82
+ ([variable, values]) =>
83
+ read.columns.includes(
84
+ table.scopeColumnByVariable.get(variable) ?? '',
85
+ ) &&
86
+ plain(table.scopeColumnByVariable.get(variable) ?? '') &&
87
+ Array.isArray(values) &&
88
+ values.length > 0 &&
89
+ values.every(
90
+ (v) => typeof v === 'string' && v.length > 0 && v !== '*',
91
+ ),
92
+ )
93
+ ) {
94
+ throw new ClientSyncError(
95
+ 'client.authority_read_forbidden',
96
+ 'authority reads require declared tables, plain columns, a primary key and nonempty plain scope selectors',
97
+ );
98
+ }
99
+ Object.freeze(read.columns);
100
+ for (const values of Object.values(read.scopes)) Object.freeze(values);
101
+ Object.freeze(read.scopes);
102
+ Object.freeze(read);
103
+ seen.add(read.table);
104
+ }
105
+ }
106
+
107
+ /** Read accepted bases without restoring or replaying the visible projection. */
108
+ function readAuthoritySnapshot(
109
+ db: ClientDatabase,
110
+ schema: CompiledClientSchema,
111
+ declarations: readonly AuthorityReadDeclaration[],
112
+ ): AuthoritySnapshot {
113
+ if (!declarations.length)
114
+ throw new ClientSyncError(
115
+ 'client.authority_read_forbidden',
116
+ 'no authority reads were declared at client creation',
117
+ );
118
+ return db.transaction(() => {
119
+ const raw = getMeta(db, 'localRevision');
120
+ if (
121
+ raw === undefined ||
122
+ !/^(0|[1-9][0-9]*)$/.test(raw) ||
123
+ BigInt(raw) > 18446744073709551615n
124
+ )
125
+ throw new ClientSyncError(
126
+ 'sync.local_corrupt',
127
+ 'authority revision is missing or invalid',
128
+ );
129
+ const revision = getLocalRevision(db);
130
+ const subscriptions = loadSubscriptions(db);
131
+ const tables = declarations.map((read) => {
132
+ const table = schema.tables.get(read.table)!;
133
+ const columns = [
134
+ ...new Set([
135
+ ...read.columns,
136
+ ...Object.keys(read.scopes).map((v) =>
137
+ table.scopeColumnByVariable.get(v)!,
138
+ ),
139
+ ]),
140
+ ];
141
+ const scalar = (
142
+ column: string,
143
+ value: SqlValue | JsonRowValue,
144
+ ): string => {
145
+ if (value === null || typeof value === 'object')
146
+ throw new ClientSyncError(
147
+ 'sync.local_corrupt',
148
+ 'authority scalar is missing or invalid',
149
+ );
150
+ return String(
151
+ fromSqlValue(table.columns[table.columnIndex.get(column)!]!, value),
152
+ );
153
+ };
154
+ const matches = (values: Record<string, SqlValue | JsonRowValue>) =>
155
+ Object.entries(read.scopes).every(([v, held]) => {
156
+ const column = table.scopeColumnByVariable.get(v)!;
157
+ return (
158
+ values[column] !== null &&
159
+ values[column] !== undefined &&
160
+ held.includes(scalar(column, values[column]!))
161
+ );
162
+ });
163
+ const bases = new Map<
164
+ string,
165
+ {
166
+ values: Record<string, JsonRowValue | SqlValue> | null;
167
+ version: number;
168
+ }
169
+ >();
170
+ const intent = new Set<string>();
171
+ const relevantIntent = new Set<string>();
172
+ // FIFO before-images precede the earliest still-pending intent.
173
+ for (const commit of listOutbox(db)) {
174
+ const images = listOutboxBeforeImages(db, commit.clientCommitId);
175
+ for (const [idx, op] of commit.operations.entries())
176
+ if (op.table === read.table) {
177
+ intent.add(op.rowId);
178
+ if (op.values && matches(op.values)) relevantIntent.add(op.rowId);
179
+ if (bases.has(op.rowId)) continue;
180
+ const image = images.find((i) => i.opIndex === idx);
181
+ if (
182
+ !image ||
183
+ (image.existed &&
184
+ (image.values === undefined || image.syncVersion === undefined))
185
+ )
186
+ throw new ClientSyncError(
187
+ 'sync.local_corrupt',
188
+ 'authority intent before-image is missing',
189
+ );
190
+ bases.set(op.rowId, {
191
+ values: image.values ?? null,
192
+ version: image.syncVersion ?? -1,
193
+ });
194
+ }
195
+ }
196
+ // A retained/ACK base precedes pending edits stacked over that intent.
197
+ const retained = new Set<string>();
198
+ for (const row of db.query(
199
+ `SELECT id,base,version,intent FROM ${RETAINED_ROWS} WHERE tbl=? ORDER BY commit_seq,at,commit_id,idx`,
200
+ [read.table],
201
+ )) {
202
+ const id = String(row.id);
203
+ if (row.base !== null && row.version === null)
204
+ throw new ClientSyncError(
205
+ 'sync.local_corrupt',
206
+ 'authority base version is missing',
207
+ );
208
+ intent.add(id);
209
+ if (row.intent !== null && matches(JSON.parse(String(row.intent))))
210
+ relevantIntent.add(id);
211
+ if (!retained.has(id))
212
+ bases.set(id, {
213
+ values: row.base === null ? null : JSON.parse(String(row.base)),
214
+ version: Number(row.version ?? -1),
215
+ });
216
+ retained.add(id);
217
+ }
218
+ const visible = db.query(
219
+ `SELECT ${columns.map(quoteIdent).join(',')},${SYNC_VERSION_COLUMN} FROM ${quoteIdent(read.table)}`,
220
+ );
221
+ for (const row of visible) {
222
+ const id = scalar(table.primaryKey, row[table.primaryKey]!);
223
+ if (!bases.has(id))
224
+ bases.set(id, {
225
+ values: row,
226
+ version: Number(row[SYNC_VERSION_COLUMN]),
227
+ });
228
+ }
229
+ const rows = [...bases]
230
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
231
+ .flatMap(([id, base]) => {
232
+ if (
233
+ !Number.isSafeInteger(base.version) ||
234
+ base.version < -1 ||
235
+ (base.values &&
236
+ base.version >= 0 &&
237
+ !read.columns.every((c) => Object.hasOwn(base.values!, c)))
238
+ )
239
+ throw new ClientSyncError(
240
+ 'sync.local_corrupt',
241
+ 'authority base evidence is incomplete',
242
+ );
243
+ if (!base.values || base.version < 0 || !matches(base.values))
244
+ return [];
245
+ return [
246
+ {
247
+ values: Object.fromEntries(
248
+ read.columns.map((c) => [
249
+ c,
250
+ typeof base.values![c] === 'boolean'
251
+ ? base.values![c]
252
+ ? 1
253
+ : 0
254
+ : (base.values![c] ?? null),
255
+ ]),
256
+ ) as SqlRow,
257
+ version: base.version,
258
+ hasLocalIntent: intent.has(id),
259
+ },
260
+ ];
261
+ });
262
+ const persisted = subscriptions
263
+ .filter((s) => s.table === read.table)
264
+ .map((s) => {
265
+ const effective = s.effectiveScopes;
266
+ const complete =
267
+ effective !== undefined &&
268
+ Object.keys(effective).length === Object.keys(read.scopes).length &&
269
+ s.status === 'active' &&
270
+ s.cursor >= 0 &&
271
+ s.bootstrapState === undefined &&
272
+ s.params === undefined;
273
+ return {
274
+ requestedScopes: Object.fromEntries(
275
+ Object.keys(read.scopes).map((v) => [v, s.scopes[v] ?? []]),
276
+ ),
277
+ status: s.status,
278
+ cursor: s.cursor,
279
+ effectiveScopes:
280
+ effective === undefined
281
+ ? null
282
+ : Object.fromEntries(
283
+ Object.keys(read.scopes).map((v) => [
284
+ v,
285
+ effective[v] ?? [],
286
+ ]),
287
+ ),
288
+ complete,
289
+ };
290
+ });
291
+ const selectors = Object.entries(read.scopes);
292
+ const covered = (at: number, candidates: typeof persisted): boolean =>
293
+ at === selectors.length
294
+ ? candidates.length > 0
295
+ : selectors[at]![1].every((value) =>
296
+ covered(
297
+ at + 1,
298
+ candidates.filter((p) =>
299
+ p.effectiveScopes?.[selectors[at]![0]]?.some(
300
+ (held) => held === value || held === '*',
301
+ ),
302
+ ),
303
+ ),
304
+ );
305
+ const coverage = covered(
306
+ 0,
307
+ persisted.filter((p) => p.complete),
308
+ )
309
+ ? 'complete'
310
+ : persisted.some(
311
+ (p) =>
312
+ p.status === 'active' &&
313
+ selectors.every(([v, values]) =>
314
+ values.some((value) => p.requestedScopes[v]?.includes(value)),
315
+ ),
316
+ )
317
+ ? 'pending'
318
+ : 'missing';
319
+ return {
320
+ table: read.table,
321
+ rows,
322
+ localIntentRowIds: [...intent]
323
+ .filter(
324
+ (id) =>
325
+ relevantIntent.has(id) ||
326
+ rows.some(
327
+ (row) =>
328
+ scalar(table.primaryKey, row.values[table.primaryKey]!) ===
329
+ id,
330
+ ) ||
331
+ visible.some(
332
+ (row) =>
333
+ scalar(table.primaryKey, row[table.primaryKey]!) === id &&
334
+ matches(row),
335
+ ),
336
+ )
337
+ .sort(),
338
+ scopes: read.scopes,
339
+ coverage,
340
+ persisted,
341
+ } as AuthoritySnapshot['tables'][number];
342
+ });
343
+ return {
344
+ revision,
345
+ complete: tables.every((t) => t.coverage === 'complete'),
346
+ tables,
347
+ };
348
+ });
349
+ }
350
+
351
+ /** Opt-in policy module; ordinary clients ship no authority reader. */
352
+ export class AuthorityReadPolicy {
353
+ readonly #declarations: readonly AuthorityReadDeclaration[];
354
+ get declarations(): readonly AuthorityReadDeclaration[] {
355
+ return this.#declarations;
356
+ }
357
+ constructor(declarations: readonly AuthorityReadDeclaration[]) {
358
+ if (!Array.isArray(declarations))
359
+ throw new ClientSyncError(
360
+ 'client.authority_read_forbidden',
361
+ 'authorityReads must be a list',
362
+ );
363
+ this.#declarations = structuredClone(declarations);
364
+ for (const read of this.#declarations) {
365
+ if (
366
+ !read ||
367
+ typeof read !== 'object' ||
368
+ !Array.isArray(read.columns) ||
369
+ !read.scopes ||
370
+ typeof read.scopes !== 'object' ||
371
+ Array.isArray(read.scopes)
372
+ )
373
+ throw new ClientSyncError(
374
+ 'client.authority_read_forbidden',
375
+ 'invalid authority declaration',
376
+ );
377
+ Object.freeze(read.columns);
378
+ for (const values of Object.values(read.scopes)) Object.freeze(values);
379
+ Object.freeze(read.scopes);
380
+ Object.freeze(read);
381
+ }
382
+ Object.freeze(this.#declarations);
383
+ Object.freeze(this);
384
+ }
385
+ /** @internal */
386
+ validate(schema: CompiledClientSchema): void {
387
+ validateAuthorityReads(schema, this.declarations);
388
+ Object.freeze(this.declarations);
389
+ Object.freeze(this);
390
+ }
391
+ /** @internal */
392
+ snapshot(
393
+ db: ClientDatabase,
394
+ schema: CompiledClientSchema,
395
+ ): AuthoritySnapshot {
396
+ validateAuthorityReads(schema, this.declarations);
397
+ try {
398
+ return readAuthoritySnapshot(db, schema, this.declarations);
399
+ } catch (error) {
400
+ if (error instanceof SyntaxError)
401
+ throw new ClientSyncError(
402
+ 'sync.local_corrupt',
403
+ 'authority persisted evidence is invalid',
404
+ );
405
+ throw error;
406
+ }
407
+ }
408
+ }
409
+ /** Declare authority tables, plain columns and scope selectors at creation. */
410
+ export function defineAuthorityReads(
411
+ declarations: readonly AuthorityReadDeclaration[],
412
+ ): AuthorityReadPolicy {
413
+ return new AuthorityReadPolicy(declarations);
414
+ }
package/src/client.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { BLOB_COMMIT_REFS } from './blob';
2
+ import type { AuthorityReadPolicy, AuthoritySnapshot } from './authority';
2
3
  import { hasUniqueIndex } from './schema';
3
4
  import { OUTCOMES_TABLE } from './outcomes';
4
5
  import { OUTBOX_TABLE } from './outbox';
@@ -424,6 +425,7 @@ export interface SyncClientConfig {
424
425
  * hosts must not materialize key bytes before their preflight has passed.
425
426
  */
426
427
  readonly securityPreflight?: boolean;
428
+ readonly authorityReads?: AuthorityReadPolicy;
427
429
  /**
428
430
  * RFC 0005 previous-version context. A feature flag, default off: when
429
431
  * absent or `enabled: false`, a schema bump behaves exactly as 0.22.0 did
@@ -746,6 +748,7 @@ export type ClientSnapshotReader = {
746
748
 
747
749
  export class SyncClient {
748
750
  readonly #config: SyncClientConfig;
751
+ readonly #authorityReads: AuthorityReadPolicy | undefined;
749
752
  readonly #db: ClientDatabase;
750
753
  readonly #schema: CompiledClientSchema;
751
754
  /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
@@ -860,6 +863,8 @@ export class SyncClient {
860
863
  this.#retainFailedCommits = config.retainFailedCommits === true;
861
864
  this.#db = config.database;
862
865
  this.#schema = compileClientSchema(config.schema);
866
+ this.#authorityReads = config.authorityReads;
867
+ this.#authorityReads?.validate(this.#schema);
863
868
  const realtimePolicy = config.realtimePolicy ?? 'optional';
864
869
  if (
865
870
  realtimePolicy !== 'required' &&
@@ -1232,6 +1237,7 @@ export class SyncClient {
1232
1237
  }
1233
1238
 
1234
1239
  async close(): Promise<void> {
1240
+ this.#started = false;
1235
1241
  this.#emitSyncIntent({ kind: 'none' });
1236
1242
  this.#devtoolsUnregister?.();
1237
1243
  this.#devtoolsUnregister = undefined;
@@ -1239,7 +1245,6 @@ export class SyncClient {
1239
1245
  this.#abortPendingRound('client closed mid-round');
1240
1246
  await this.#lease?.release();
1241
1247
  this.#lease = undefined;
1242
- this.#started = false;
1243
1248
  this.#progress.clear();
1244
1249
  this.#syncNeededListeners.clear();
1245
1250
  this.#syncIntentListeners.clear();
@@ -1398,6 +1403,17 @@ export class SyncClient {
1398
1403
  return getLocalRevision(this.#db);
1399
1404
  }
1400
1405
 
1406
+ /** Read the creation-time plain authority policy, including preflight. */
1407
+ authoritySnapshot(): AuthoritySnapshot {
1408
+ this.#requireStarted();
1409
+ if (!this.#authorityReads || arguments.length)
1410
+ throw new ClientSyncError(
1411
+ 'client.authority_read_forbidden',
1412
+ 'authority read denied',
1413
+ );
1414
+ return this.#authorityReads.snapshot(this.#db, this.#schema);
1415
+ }
1416
+
1401
1417
  /**
1402
1418
  * Read rows, window answerability, and revision from one SQLite snapshot.
1403
1419
  * Reactive integrations use this instead of composing `query()` and
@@ -1418,28 +1434,11 @@ export class SyncClient {
1418
1434
  const missing: WindowUnitRef[] = [];
1419
1435
  for (const requested of spec.coverage ?? []) {
1420
1436
  const baseKey = windowBaseKey(requested.base);
1421
- const live = new Map(
1422
- loadWindowUnits(this.#db, baseKey).map((entry) => [
1423
- entry.unit,
1424
- entry.subId,
1425
- ]),
1426
- );
1437
+ const state = this.windowState(requested.base);
1427
1438
  for (const unit of new Set(requested.units)) {
1428
- const subId = live.get(unit);
1429
1439
  const ref = { baseKey, unit };
1430
- if (subId === undefined) {
1431
- missing.push(ref);
1432
- continue;
1433
- }
1434
- const sub = getSubscription(this.#db, subId);
1435
- if (
1436
- sub === undefined ||
1437
- sub.status !== 'active' ||
1438
- sub.cursor < 0 ||
1439
- sub.bootstrapState !== undefined
1440
- ) {
1441
- pending.push(ref);
1442
- }
1440
+ if (!state.units.includes(unit)) missing.push(ref);
1441
+ else if (state.pending.includes(unit)) pending.push(ref);
1443
1442
  }
1444
1443
  }
1445
1444
  return {
@@ -1817,7 +1816,7 @@ export class SyncClient {
1817
1816
  item.table.length === 0
1818
1817
  ) {
1819
1818
  throw invalidRequest(
1820
- 'diagnosticsSnapshot expected subscriptions require non-empty id and table strings',
1819
+ 'diagnosticsSnapshot needs subscription id and table',
1821
1820
  );
1822
1821
  }
1823
1822
  const registeredSubscription = subscriptions.get(item.id);
@@ -3170,9 +3169,7 @@ export class SyncClient {
3170
3169
  };
3171
3170
  }
3172
3171
  if (this.#schemaFloor !== undefined) {
3173
- throw invalidRequest(
3174
- 'local rebootstrap cannot bypass an active schema-floor stop; update the application first',
3175
- );
3172
+ throw invalidRequest('local rebootstrap needs a schema-floor update');
3176
3173
  }
3177
3174
 
3178
3175
  const pending = listOutbox(this.#db);
@@ -5331,7 +5328,7 @@ export class SyncClient {
5331
5328
 
5332
5329
  #requireStarted(): void {
5333
5330
  if (!this.#started) {
5334
- throw invalidRequest('call SyncClient.start() first');
5331
+ throw invalidRequest('call start() first');
5335
5332
  }
5336
5333
  }
5337
5334
 
@@ -5340,7 +5337,7 @@ export class SyncClient {
5340
5337
  if (this.#securityLifecycle === 'preflight') {
5341
5338
  throw new ClientSyncError(
5342
5339
  SECURITY_PREFLIGHT_REQUIRED_CODE,
5343
- 'security preflight must complete before accessing protected data; call activateSecurity',
5340
+ 'protected reads require activateSecurity',
5344
5341
  );
5345
5342
  }
5346
5343
  }
package/src/index.ts CHANGED
@@ -8,6 +8,11 @@
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
10
10
  */
11
+ export type {
12
+ AuthorityReadPolicy,
13
+ AuthorityReadDeclaration,
14
+ AuthoritySnapshot,
15
+ } from './authority';
11
16
  export * from './apply';
12
17
  export * from './availability';
13
18
  export * from './blob';