@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.
package/README.md CHANGED
@@ -818,3 +818,29 @@ See [windowing](https://syncular.dev/concepts-windowing/) and
818
818
  Custom database adapters implementing `withSqliteImage` must return a promise and
819
819
  await their callback before detaching the image. The callback can await between
820
820
  committed import chunks; no write transaction spans that await.
821
+
822
+ ## Preflight authority reads
823
+
824
+ Import `defineAuthorityReads` from `@syncular/client/authority`. Pass
825
+ `authorityReads: defineAuthorityReads([{ table, columns, scopes }])` at
826
+ creation to enable the zero-argument `authoritySnapshot()`. It reads approved
827
+ plain authority columns and accepted bases with revision and persisted scope
828
+ coverage from one SQLite snapshot, including during security preflight.
829
+ Unaccepted local intent remains separate. The read changes no keys, transport,
830
+ subscriptions or lifecycle. See the [authority contract](../../docs/SPEC.md)
831
+ and [native configuration](../tauri/README.md#authority-evidence-before-activation).
832
+
833
+ Workers must opt in in their entry script:
834
+
835
+ ```ts
836
+ import { defineAuthorityReads } from '@syncular/client/authority';
837
+ import { startSyncWorker } from '@syncular/client/worker';
838
+
839
+ startSyncWorker({ createAuthorityReads: defineAuthorityReads });
840
+ ```
841
+
842
+ Pass the policy to `createSyncClientHandle({ authorityReads, ...config })` on
843
+ the main thread. Declarations without the injected worker factory fail with
844
+ `client.authority_read_forbidden` before opening storage. Ordinary workers
845
+ keep `startSyncWorker()` and bundle no authority reader. No dynamic import
846
+ changes the declared capability.
@@ -0,0 +1,43 @@
1
+ import type { ClientDatabase, SqlRow } from './database.js';
2
+ import type { CompiledClientSchema } from './schema.js';
3
+ /** Frozen at client creation. Scope selectors use the schema's variables. */
4
+ export interface AuthorityReadDeclaration {
5
+ readonly table: string;
6
+ readonly columns: readonly string[];
7
+ readonly scopes: Readonly<Record<string, readonly string[]>>;
8
+ }
9
+ export interface AuthoritySnapshot {
10
+ readonly revision: bigint;
11
+ readonly complete: boolean;
12
+ readonly tables: readonly {
13
+ readonly table: string;
14
+ readonly rows: readonly {
15
+ readonly values: SqlRow;
16
+ readonly version: number;
17
+ readonly hasLocalIntent: boolean;
18
+ }[];
19
+ /** Includes local-only creations; no intended values are disclosed. */
20
+ readonly localIntentRowIds: readonly string[];
21
+ readonly coverage: 'complete' | 'pending' | 'missing';
22
+ readonly scopes: AuthorityReadDeclaration['scopes'];
23
+ readonly persisted: readonly {
24
+ readonly requestedScopes: AuthorityReadDeclaration['scopes'];
25
+ readonly status: string;
26
+ readonly cursor: number;
27
+ readonly effectiveScopes: AuthorityReadDeclaration['scopes'] | null;
28
+ readonly complete: boolean;
29
+ }[];
30
+ }[];
31
+ }
32
+ /** Opt-in policy module; ordinary clients ship no authority reader. */
33
+ export declare class AuthorityReadPolicy {
34
+ #private;
35
+ get declarations(): readonly AuthorityReadDeclaration[];
36
+ constructor(declarations: readonly AuthorityReadDeclaration[]);
37
+ /** @internal */
38
+ validate(schema: CompiledClientSchema): void;
39
+ /** @internal */
40
+ snapshot(db: ClientDatabase, schema: CompiledClientSchema): AuthoritySnapshot;
41
+ }
42
+ /** Declare authority tables, plain columns and scope selectors at creation. */
43
+ export declare function defineAuthorityReads(declarations: readonly AuthorityReadDeclaration[]): AuthorityReadPolicy;
@@ -0,0 +1,257 @@
1
+ import { ClientSyncError } from './errors.js';
2
+ import { listOutbox, listOutboxBeforeImages } from './outbox.js';
3
+ import { RETAINED_ROWS } from './failed-overlay.js';
4
+ import { getLocalRevision, getMeta, loadSubscriptions } from './state.js';
5
+ import { fromSqlValue, quoteIdent, SYNC_VERSION_COLUMN } from './schema.js';
6
+ function validateAuthorityReads(schema, declarations) {
7
+ if (!Array.isArray(declarations))
8
+ throw new ClientSyncError('client.authority_read_forbidden', 'authorityReads must be a list');
9
+ const seen = new Set();
10
+ for (const read of declarations) {
11
+ if (!read ||
12
+ typeof read !== 'object' ||
13
+ !Array.isArray(read.columns) ||
14
+ !read.scopes ||
15
+ typeof read.scopes !== 'object' ||
16
+ Array.isArray(read.scopes))
17
+ throw new ClientSyncError('client.authority_read_forbidden', 'invalid authority declaration');
18
+ const table = schema.tables.get(read.table);
19
+ const plain = (name) => table?.columns.some((c) => c.name === name &&
20
+ name !== 'authority_version' &&
21
+ !name.startsWith('_sync') &&
22
+ !c.encrypted &&
23
+ ['string', 'integer', 'float', 'boolean', 'json'].includes(c.type));
24
+ if (Object.keys(read).sort().join() !== 'columns,scopes,table' ||
25
+ !table ||
26
+ seen.has(read.table) ||
27
+ !read.columns.length ||
28
+ new Set(read.columns).size !== read.columns.length ||
29
+ !read.columns.includes(table.primaryKey) ||
30
+ !read.columns.every(plain) ||
31
+ !Object.keys(read.scopes).length ||
32
+ !Object.entries(read.scopes).every(([variable, values]) => read.columns.includes(table.scopeColumnByVariable.get(variable) ?? '') &&
33
+ plain(table.scopeColumnByVariable.get(variable) ?? '') &&
34
+ Array.isArray(values) &&
35
+ values.length > 0 &&
36
+ values.every((v) => typeof v === 'string' && v.length > 0 && v !== '*'))) {
37
+ throw new ClientSyncError('client.authority_read_forbidden', 'authority reads require declared tables, plain columns, a primary key and nonempty plain scope selectors');
38
+ }
39
+ Object.freeze(read.columns);
40
+ for (const values of Object.values(read.scopes))
41
+ Object.freeze(values);
42
+ Object.freeze(read.scopes);
43
+ Object.freeze(read);
44
+ seen.add(read.table);
45
+ }
46
+ }
47
+ /** Read accepted bases without restoring or replaying the visible projection. */
48
+ function readAuthoritySnapshot(db, schema, declarations) {
49
+ if (!declarations.length)
50
+ throw new ClientSyncError('client.authority_read_forbidden', 'no authority reads were declared at client creation');
51
+ return db.transaction(() => {
52
+ const raw = getMeta(db, 'localRevision');
53
+ if (raw === undefined ||
54
+ !/^(0|[1-9][0-9]*)$/.test(raw) ||
55
+ BigInt(raw) > 18446744073709551615n)
56
+ throw new ClientSyncError('sync.local_corrupt', 'authority revision is missing or invalid');
57
+ const revision = getLocalRevision(db);
58
+ const subscriptions = loadSubscriptions(db);
59
+ const tables = declarations.map((read) => {
60
+ const table = schema.tables.get(read.table);
61
+ const columns = [
62
+ ...new Set([
63
+ ...read.columns,
64
+ ...Object.keys(read.scopes).map((v) => table.scopeColumnByVariable.get(v)),
65
+ ]),
66
+ ];
67
+ const scalar = (column, value) => {
68
+ if (value === null || typeof value === 'object')
69
+ throw new ClientSyncError('sync.local_corrupt', 'authority scalar is missing or invalid');
70
+ return String(fromSqlValue(table.columns[table.columnIndex.get(column)], value));
71
+ };
72
+ const matches = (values) => Object.entries(read.scopes).every(([v, held]) => {
73
+ const column = table.scopeColumnByVariable.get(v);
74
+ return (values[column] !== null &&
75
+ values[column] !== undefined &&
76
+ held.includes(scalar(column, values[column])));
77
+ });
78
+ const bases = new Map();
79
+ const intent = new Set();
80
+ const relevantIntent = new Set();
81
+ // FIFO before-images precede the earliest still-pending intent.
82
+ for (const commit of listOutbox(db)) {
83
+ const images = listOutboxBeforeImages(db, commit.clientCommitId);
84
+ for (const [idx, op] of commit.operations.entries())
85
+ if (op.table === read.table) {
86
+ intent.add(op.rowId);
87
+ if (op.values && matches(op.values))
88
+ relevantIntent.add(op.rowId);
89
+ if (bases.has(op.rowId))
90
+ continue;
91
+ const image = images.find((i) => i.opIndex === idx);
92
+ if (!image ||
93
+ (image.existed &&
94
+ (image.values === undefined || image.syncVersion === undefined)))
95
+ throw new ClientSyncError('sync.local_corrupt', 'authority intent before-image is missing');
96
+ bases.set(op.rowId, {
97
+ values: image.values ?? null,
98
+ version: image.syncVersion ?? -1,
99
+ });
100
+ }
101
+ }
102
+ // A retained/ACK base precedes pending edits stacked over that intent.
103
+ const retained = new Set();
104
+ for (const row of db.query(`SELECT id,base,version,intent FROM ${RETAINED_ROWS} WHERE tbl=? ORDER BY commit_seq,at,commit_id,idx`, [read.table])) {
105
+ const id = String(row.id);
106
+ if (row.base !== null && row.version === null)
107
+ throw new ClientSyncError('sync.local_corrupt', 'authority base version is missing');
108
+ intent.add(id);
109
+ if (row.intent !== null && matches(JSON.parse(String(row.intent))))
110
+ relevantIntent.add(id);
111
+ if (!retained.has(id))
112
+ bases.set(id, {
113
+ values: row.base === null ? null : JSON.parse(String(row.base)),
114
+ version: Number(row.version ?? -1),
115
+ });
116
+ retained.add(id);
117
+ }
118
+ const visible = db.query(`SELECT ${columns.map(quoteIdent).join(',')},${SYNC_VERSION_COLUMN} FROM ${quoteIdent(read.table)}`);
119
+ for (const row of visible) {
120
+ const id = scalar(table.primaryKey, row[table.primaryKey]);
121
+ if (!bases.has(id))
122
+ bases.set(id, {
123
+ values: row,
124
+ version: Number(row[SYNC_VERSION_COLUMN]),
125
+ });
126
+ }
127
+ const rows = [...bases]
128
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
129
+ .flatMap(([id, base]) => {
130
+ if (!Number.isSafeInteger(base.version) ||
131
+ base.version < -1 ||
132
+ (base.values &&
133
+ base.version >= 0 &&
134
+ !read.columns.every((c) => Object.hasOwn(base.values, c))))
135
+ throw new ClientSyncError('sync.local_corrupt', 'authority base evidence is incomplete');
136
+ if (!base.values || base.version < 0 || !matches(base.values))
137
+ return [];
138
+ return [
139
+ {
140
+ values: Object.fromEntries(read.columns.map((c) => [
141
+ c,
142
+ typeof base.values[c] === 'boolean'
143
+ ? base.values[c]
144
+ ? 1
145
+ : 0
146
+ : (base.values[c] ?? null),
147
+ ])),
148
+ version: base.version,
149
+ hasLocalIntent: intent.has(id),
150
+ },
151
+ ];
152
+ });
153
+ const persisted = subscriptions
154
+ .filter((s) => s.table === read.table)
155
+ .map((s) => {
156
+ const effective = s.effectiveScopes;
157
+ const complete = effective !== undefined &&
158
+ Object.keys(effective).length === Object.keys(read.scopes).length &&
159
+ s.status === 'active' &&
160
+ s.cursor >= 0 &&
161
+ s.bootstrapState === undefined &&
162
+ s.params === undefined;
163
+ return {
164
+ requestedScopes: Object.fromEntries(Object.keys(read.scopes).map((v) => [v, s.scopes[v] ?? []])),
165
+ status: s.status,
166
+ cursor: s.cursor,
167
+ effectiveScopes: effective === undefined
168
+ ? null
169
+ : Object.fromEntries(Object.keys(read.scopes).map((v) => [
170
+ v,
171
+ effective[v] ?? [],
172
+ ])),
173
+ complete,
174
+ };
175
+ });
176
+ const selectors = Object.entries(read.scopes);
177
+ const covered = (at, candidates) => at === selectors.length
178
+ ? candidates.length > 0
179
+ : selectors[at][1].every((value) => covered(at + 1, candidates.filter((p) => p.effectiveScopes?.[selectors[at][0]]?.some((held) => held === value || held === '*'))));
180
+ const coverage = covered(0, persisted.filter((p) => p.complete))
181
+ ? 'complete'
182
+ : persisted.some((p) => p.status === 'active' &&
183
+ selectors.every(([v, values]) => values.some((value) => p.requestedScopes[v]?.includes(value))))
184
+ ? 'pending'
185
+ : 'missing';
186
+ return {
187
+ table: read.table,
188
+ rows,
189
+ localIntentRowIds: [...intent]
190
+ .filter((id) => relevantIntent.has(id) ||
191
+ rows.some((row) => scalar(table.primaryKey, row.values[table.primaryKey]) ===
192
+ id) ||
193
+ visible.some((row) => scalar(table.primaryKey, row[table.primaryKey]) === id &&
194
+ matches(row)))
195
+ .sort(),
196
+ scopes: read.scopes,
197
+ coverage,
198
+ persisted,
199
+ };
200
+ });
201
+ return {
202
+ revision,
203
+ complete: tables.every((t) => t.coverage === 'complete'),
204
+ tables,
205
+ };
206
+ });
207
+ }
208
+ /** Opt-in policy module; ordinary clients ship no authority reader. */
209
+ export class AuthorityReadPolicy {
210
+ #declarations;
211
+ get declarations() {
212
+ return this.#declarations;
213
+ }
214
+ constructor(declarations) {
215
+ if (!Array.isArray(declarations))
216
+ throw new ClientSyncError('client.authority_read_forbidden', 'authorityReads must be a list');
217
+ this.#declarations = structuredClone(declarations);
218
+ for (const read of this.#declarations) {
219
+ if (!read ||
220
+ typeof read !== 'object' ||
221
+ !Array.isArray(read.columns) ||
222
+ !read.scopes ||
223
+ typeof read.scopes !== 'object' ||
224
+ Array.isArray(read.scopes))
225
+ throw new ClientSyncError('client.authority_read_forbidden', 'invalid authority declaration');
226
+ Object.freeze(read.columns);
227
+ for (const values of Object.values(read.scopes))
228
+ Object.freeze(values);
229
+ Object.freeze(read.scopes);
230
+ Object.freeze(read);
231
+ }
232
+ Object.freeze(this.#declarations);
233
+ Object.freeze(this);
234
+ }
235
+ /** @internal */
236
+ validate(schema) {
237
+ validateAuthorityReads(schema, this.declarations);
238
+ Object.freeze(this.declarations);
239
+ Object.freeze(this);
240
+ }
241
+ /** @internal */
242
+ snapshot(db, schema) {
243
+ validateAuthorityReads(schema, this.declarations);
244
+ try {
245
+ return readAuthoritySnapshot(db, schema, this.declarations);
246
+ }
247
+ catch (error) {
248
+ if (error instanceof SyntaxError)
249
+ throw new ClientSyncError('sync.local_corrupt', 'authority persisted evidence is invalid');
250
+ throw error;
251
+ }
252
+ }
253
+ }
254
+ /** Declare authority tables, plain columns and scope selectors at creation. */
255
+ export function defineAuthorityReads(declarations) {
256
+ return new AuthorityReadPolicy(declarations);
257
+ }
package/dist/client.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AuthorityReadPolicy, AuthoritySnapshot } from './authority.js';
1
2
  import { type SyncProgress, type SyncProgressListener } from './progress.js';
2
3
  /**
3
4
  * SyncClient implements the client side of SPEC.md §§3–8.
@@ -174,6 +175,7 @@ export interface SyncClientConfig {
174
175
  * hosts must not materialize key bytes before their preflight has passed.
175
176
  */
176
177
  readonly securityPreflight?: boolean;
178
+ readonly authorityReads?: AuthorityReadPolicy;
177
179
  /**
178
180
  * RFC 0005 previous-version context. A feature flag, default off: when
179
181
  * absent or `enabled: false`, a schema bump behaves exactly as 0.22.0 did
@@ -326,6 +328,8 @@ export declare class SyncClient {
326
328
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
327
329
  /** Current durable local observer revision (SPEC §7.5). */
328
330
  get localRevision(): LocalRevision;
331
+ /** Read the creation-time plain authority policy, including preflight. */
332
+ authoritySnapshot(): AuthoritySnapshot;
329
333
  /**
330
334
  * Read rows, window answerability, and revision from one SQLite snapshot.
331
335
  * Reactive integrations use this instead of composing `query()` and
package/dist/client.js CHANGED
@@ -130,6 +130,7 @@ function realtimeErrorCode(error) {
130
130
  }
131
131
  export class SyncClient {
132
132
  #config;
133
+ #authorityReads;
133
134
  #db;
134
135
  #schema;
135
136
  /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
@@ -237,6 +238,8 @@ export class SyncClient {
237
238
  this.#retainFailedCommits = config.retainFailedCommits === true;
238
239
  this.#db = config.database;
239
240
  this.#schema = compileClientSchema(config.schema);
241
+ this.#authorityReads = config.authorityReads;
242
+ this.#authorityReads?.validate(this.#schema);
240
243
  const realtimePolicy = config.realtimePolicy ?? 'optional';
241
244
  if (realtimePolicy !== 'required' &&
242
245
  realtimePolicy !== 'optional' &&
@@ -541,6 +544,7 @@ export class SyncClient {
541
544
  });
542
545
  }
543
546
  async close() {
547
+ this.#started = false;
544
548
  this.#emitSyncIntent({ kind: 'none' });
545
549
  this.#devtoolsUnregister?.();
546
550
  this.#devtoolsUnregister = undefined;
@@ -548,7 +552,6 @@ export class SyncClient {
548
552
  this.#abortPendingRound('client closed mid-round');
549
553
  await this.#lease?.release();
550
554
  this.#lease = undefined;
551
- this.#started = false;
552
555
  this.#progress.clear();
553
556
  this.#syncNeededListeners.clear();
554
557
  this.#syncIntentListeners.clear();
@@ -696,6 +699,13 @@ export class SyncClient {
696
699
  this.#requireStarted();
697
700
  return getLocalRevision(this.#db);
698
701
  }
702
+ /** Read the creation-time plain authority policy, including preflight. */
703
+ authoritySnapshot() {
704
+ this.#requireStarted();
705
+ if (!this.#authorityReads || arguments.length)
706
+ throw new ClientSyncError('client.authority_read_forbidden', 'authority read denied');
707
+ return this.#authorityReads.snapshot(this.#db, this.#schema);
708
+ }
699
709
  /**
700
710
  * Read rows, window answerability, and revision from one SQLite snapshot.
701
711
  * Reactive integrations use this instead of composing `query()` and
@@ -714,24 +724,13 @@ export class SyncClient {
714
724
  const missing = [];
715
725
  for (const requested of spec.coverage ?? []) {
716
726
  const baseKey = windowBaseKey(requested.base);
717
- const live = new Map(loadWindowUnits(this.#db, baseKey).map((entry) => [
718
- entry.unit,
719
- entry.subId,
720
- ]));
727
+ const state = this.windowState(requested.base);
721
728
  for (const unit of new Set(requested.units)) {
722
- const subId = live.get(unit);
723
729
  const ref = { baseKey, unit };
724
- if (subId === undefined) {
730
+ if (!state.units.includes(unit))
725
731
  missing.push(ref);
726
- continue;
727
- }
728
- const sub = getSubscription(this.#db, subId);
729
- if (sub === undefined ||
730
- sub.status !== 'active' ||
731
- sub.cursor < 0 ||
732
- sub.bootstrapState !== undefined) {
732
+ else if (state.pending.includes(unit))
733
733
  pending.push(ref);
734
- }
735
734
  }
736
735
  }
737
736
  return {
@@ -1044,7 +1043,7 @@ export class SyncClient {
1044
1043
  item.id.length === 0 ||
1045
1044
  typeof item.table !== 'string' ||
1046
1045
  item.table.length === 0) {
1047
- throw invalidRequest('diagnosticsSnapshot expected subscriptions require non-empty id and table strings');
1046
+ throw invalidRequest('diagnosticsSnapshot needs subscription id and table');
1048
1047
  }
1049
1048
  const registeredSubscription = subscriptions.get(item.id);
1050
1049
  if (registeredSubscription !== undefined &&
@@ -2154,7 +2153,7 @@ export class SyncClient {
2154
2153
  };
2155
2154
  }
2156
2155
  if (this.#schemaFloor !== undefined) {
2157
- throw invalidRequest('local rebootstrap cannot bypass an active schema-floor stop; update the application first');
2156
+ throw invalidRequest('local rebootstrap needs a schema-floor update');
2158
2157
  }
2159
2158
  const pending = listOutbox(this.#db);
2160
2159
  const resetSubscriptions = loadSubscriptions(this.#db).length;
@@ -3843,13 +3842,13 @@ export class SyncClient {
3843
3842
  }
3844
3843
  #requireStarted() {
3845
3844
  if (!this.#started) {
3846
- throw invalidRequest('call SyncClient.start() first');
3845
+ throw invalidRequest('call start() first');
3847
3846
  }
3848
3847
  }
3849
3848
  #requireActive() {
3850
3849
  this.#requireStarted();
3851
3850
  if (this.#securityLifecycle === 'preflight') {
3852
- throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'security preflight must complete before accessing protected data; call activateSecurity');
3851
+ throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'protected reads require activateSecurity');
3853
3852
  }
3854
3853
  }
3855
3854
  }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
10
10
  */
11
+ export type { AuthorityReadPolicy, AuthorityReadDeclaration, AuthoritySnapshot, } from './authority.js';
11
12
  export * from './apply.js';
12
13
  export * from './availability.js';
13
14
  export * from './blob.js';
package/dist/index.js CHANGED
@@ -1,13 +1,3 @@
1
- /**
2
- * @syncular/client is the TypeScript client protocol core.
3
- * SPEC.md is normative.
4
- *
5
- * Browser-safe root: database backends live behind subpath exports
6
- * (`./sqlite` for Node or Bun, `./wasm` for sqlite-wasm + OPFS); the
7
- * worker-side bootstrap lives behind `./worker`. The main-thread handle
8
- * (`worker-host`) and the RPC protocol types are root exports — they
9
- * import no SQLite.
10
- */
11
1
  export * from './apply.js';
12
2
  export * from './availability.js';
13
3
  export * from './blob.js';
@@ -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,18 +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
1
  import { SyncClient } from './client.js';
17
2
  import { withClientDiagnosticsHost, } from './diagnostics.js';
18
3
  import { encryptionConfigFromKeyring } from './encryption.js';
@@ -159,6 +144,8 @@ export function startSyncWorker(overrides = {}) {
159
144
  return client;
160
145
  }
161
146
  async function init(config) {
147
+ if (config.authorityReads !== undefined && !overrides.createAuthorityReads)
148
+ throw new ClientSyncError('client.authority_read_forbidden', 'authorityReads requires startSyncWorker({ createAuthorityReads })');
162
149
  if (client !== undefined) {
163
150
  throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
164
151
  }
@@ -233,6 +220,11 @@ export function startSyncWorker(overrides = {}) {
233
220
  ...(config.encryption !== undefined
234
221
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
235
222
  : {}),
223
+ ...(config.authorityReads !== undefined
224
+ ? {
225
+ authorityReads: overrides.createAuthorityReads(config.authorityReads),
226
+ }
227
+ : {}),
236
228
  ...(config.securityPreflight !== undefined
237
229
  ? { securityPreflight: config.securityPreflight }
238
230
  : {}),
@@ -296,8 +288,44 @@ export function startSyncWorker(overrides = {}) {
296
288
  consumeSyncIntent({ kind: 'interactive' });
297
289
  return { clientId: started.clientId };
298
290
  }
291
+ // Forward every argument to core methods, including authority's zero-argument check.
292
+ const coreMethods = [
293
+ 'securityLifecycle',
294
+ 'subscribe',
295
+ 'unsubscribe',
296
+ 'windowState',
297
+ 'purgeLocalData',
298
+ 'rebootstrapLocalData',
299
+ 'previousVersionSnapshot',
300
+ 'previousVersionAudit',
301
+ 'previousVersionDiscard',
302
+ 'query',
303
+ 'authoritySnapshot',
304
+ 'querySnapshot',
305
+ 'statusSnapshot',
306
+ 'conflicts',
307
+ 'rejections',
308
+ 'commitOutcome',
309
+ 'commitOutcomes',
310
+ 'resolveCommitOutcome',
311
+ 'pendingCommits',
312
+ 'subscriptions',
313
+ 'subscription',
314
+ 'connectRealtime',
315
+ 'disconnectRealtime',
316
+ 'setPresence',
317
+ 'presence',
318
+ 'uploadBlob',
319
+ 'fetchBlob',
320
+ ];
299
321
  const api = {
300
- securityLifecycle: () => requireClient().securityLifecycle(),
322
+ ...Object.fromEntries(coreMethods.map((method) => [
323
+ method,
324
+ (...args) => {
325
+ const running = requireClient();
326
+ return Reflect.apply(running[method], running, args);
327
+ },
328
+ ])),
301
329
  beginSecurityPreflight: async () => {
302
330
  if (backgroundTimer !== undefined)
303
331
  clearTimeout(backgroundTimer);
@@ -313,12 +341,9 @@ export function startSyncWorker(overrides = {}) {
313
341
  : {}),
314
342
  });
315
343
  },
316
- subscribe: (input) => requireClient().subscribe(input),
317
- unsubscribe: (id) => requireClient().unsubscribe(id),
318
344
  setWindow: async (base, units) => {
319
345
  await requireClient().setWindowCommand(base, units);
320
346
  },
321
- windowState: (base) => requireClient().windowState(base),
322
347
  mutate: (mutations) => {
323
348
  return requireClient().mutateCommand(mutations).value;
324
349
  },
@@ -328,11 +353,6 @@ export function startSyncWorker(overrides = {}) {
328
353
  const result = requireClient().patchCommand(table, rowId, partial, options);
329
354
  return result.value;
330
355
  },
331
- purgeLocalData: (input) => requireClient().purgeLocalData(input),
332
- rebootstrapLocalData: (input) => requireClient().rebootstrapLocalData(input),
333
- previousVersionSnapshot: (spec) => requireClient().previousVersionSnapshot(spec),
334
- previousVersionAudit: () => requireClient().previousVersionAudit(),
335
- previousVersionDiscard: () => requireClient().previousVersionDiscard(),
336
356
  sync: () => {
337
357
  const running = requireClient();
338
358
  return serializedSync(() => running.sync());
@@ -341,10 +361,7 @@ export function startSyncWorker(overrides = {}) {
341
361
  const running = requireClient();
342
362
  return serializedSync(() => running.syncUntilIdle(maxRounds));
343
363
  },
344
- query: (sql, params) => requireClient().query(sql, params),
345
- querySnapshot: (spec) => requireClient().querySnapshot(spec),
346
364
  localRevision: () => requireClient().localRevision,
347
- statusSnapshot: () => requireClient().statusSnapshot(),
348
365
  diagnosticsSnapshot: (request) => {
349
366
  const snapshot = requireClient().diagnosticsSnapshot(request);
350
367
  return withClientDiagnosticsHost(snapshot, {
@@ -354,20 +371,6 @@ export function startSyncWorker(overrides = {}) {
354
371
  realtime: snapshot.host.realtime,
355
372
  });
356
373
  },
357
- conflicts: () => requireClient().conflicts(),
358
- rejections: () => requireClient().rejections(),
359
- commitOutcome: (clientCommitId) => requireClient().commitOutcome(clientCommitId),
360
- commitOutcomes: (query) => requireClient().commitOutcomes(query),
361
- resolveCommitOutcome: (input) => requireClient().resolveCommitOutcome(input),
362
- pendingCommits: () => requireClient().pendingCommits(),
363
- subscriptions: () => requireClient().subscriptions(),
364
- subscription: (id) => requireClient().subscription(id),
365
- connectRealtime: () => requireClient().connectRealtime(),
366
- disconnectRealtime: () => requireClient().disconnectRealtime(),
367
- setPresence: (scopeKey, doc) => requireClient().setPresence(scopeKey, doc),
368
- presence: (scopeKey) => requireClient().presence(scopeKey),
369
- uploadBlob: (bytes, options) => requireClient().uploadBlob(bytes, options),
370
- fetchBlob: (blobIdOrRef) => requireClient().fetchBlob(blobIdOrRef),
371
374
  setHeaders: (next) => {
372
375
  headers = next;
373
376
  },
@@ -83,6 +83,8 @@ export interface SyncClientHandleConfig {
83
83
  readonly headers?: Readonly<Record<string, string>>;
84
84
  /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
85
85
  readonly encryption?: EncryptionKeyringConfig;
86
+ /** Fixed plain authority reads permitted before security activation. */
87
+ readonly authorityReads?: import('./authority.js').AuthorityReadPolicy;
86
88
  /** Open the worker-owned replica behind the fail-closed security gate. */
87
89
  readonly securityPreflight?: boolean;
88
90
  readonly transportEnabled?: boolean;
@@ -244,6 +246,7 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
244
246
  sync(): Promise<SyncSummary>;
245
247
  syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
246
248
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
249
+ authoritySnapshot(): Promise<import('./authority.js').AuthoritySnapshot>;
247
250
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
248
251
  localRevision(): Promise<LocalRevision>;
249
252
  statusSnapshot(): Promise<SyncStatusSnapshot>;