@syncular/client 0.30.13 → 0.30.15

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,13 @@ 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
+ Pass `authorityReads: defineAuthorityReads([{ table, columns, scopes }])` at
825
+ creation to enable the zero-argument `authoritySnapshot()`. It reads approved
826
+ plain authority columns and accepted bases with revision and persisted scope
827
+ coverage from one SQLite snapshot, including during security preflight.
828
+ Unaccepted local intent remains separate. The read changes no keys, transport,
829
+ subscriptions or lifecycle. See the [authority contract](../../docs/SPEC.md)
830
+ and [native configuration](../tauri/README.md#authority-evidence-before-activation).
package/dist/apply.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type OverlayScope } from './failed-overlay.js';
1
2
  /**
2
3
  * Local application of server data: `COMMIT` frames (§4.5), rows segments
3
4
  * (§5.2, §5.6), and the scope-matched delete shared by the §3.3 purge
@@ -8,7 +9,7 @@ import type { ClientDatabase } from './database.js';
8
9
  import type { EncryptionConfig } from './encryption.js';
9
10
  import { type CompiledClientSchema, type CompiledClientTable } from './schema.js';
10
11
  /** Lets the client wrap the physical write in its revision transaction. */
11
- export type ApplyTransaction = <T>(fn: () => T) => T;
12
+ export type ApplyTransaction = (fn: () => OverlayScope) => unknown;
12
13
  export declare function upsertLocalRow(db: ClientDatabase, table: CompiledClientTable, values: readonly RowValue[], syncVersion: number, serverWrite?: boolean): void;
13
14
  export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClientTable, rowId: string, serverWrite?: boolean): void;
14
15
  /**
@@ -23,7 +24,7 @@ export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClient
23
24
  * (`client.decrypt_failed`) aborts before any local write, so the commit
24
25
  * never half-applies.
25
26
  */
26
- export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => readonly string[] | undefined) => unknown): Promise<void>;
27
+ export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => OverlayScope) => unknown): Promise<void>;
27
28
  /**
28
29
  * §5.2: the segment's column table must match the generated schema for
29
30
  * (table, schemaVersion) — order, names, types, nullability. A mismatch is
package/dist/apply.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { hasUniqueIndex } from './schema.js';
2
+ import { OUTBOX_TABLE, OUTBOX_IMAGES, countOutbox } from './outbox.js';
1
3
  import { RETAINED_ROWS } from './failed-overlay.js';
2
4
  import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
3
5
  /**
@@ -76,13 +78,13 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
76
78
  });
77
79
  }
78
80
  transaction(() => {
79
- const rowKeys = frame.tables.some((name) => schema.tables.get(name)?.indexes.some((index) => index.unique))
80
- ? undefined
81
- : frame.changes.map((change) => JSON.stringify([frame.tables[change.tableIndex], change.rowId]));
82
- restoreFailedBases(db, schema, false, rowKeys);
81
+ const scope = resolved.map((change, index) => JSON.stringify(hasUniqueIndex(change.table)
82
+ ? [change.table.name]
83
+ : [change.table.name, frame.changes[index].rowId]));
84
+ restoreFailedBases(db, schema, scope);
83
85
  for (const [index, change] of resolved.entries()) {
84
86
  const rowId = frame.changes[index].rowId;
85
- db.exec("UPDATE _syncular_outbox_before_images SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM _syncular_outbox,json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)", [frame.commitSeq, change.table.name, rowId]);
87
+ db.exec(`UPDATE ${OUTBOX_IMAGES} SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM ${OUTBOX_TABLE},json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)`, [frame.commitSeq, change.table.name, rowId]);
86
88
  db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [change.table.name, rowId, frame.commitSeq]);
87
89
  if (change.op === 'delete') {
88
90
  deleteLocalRow(db, change.table, change.rowId);
@@ -91,7 +93,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
91
93
  upsertLocalRow(db, change.table, change.values, change.rowVersion);
92
94
  }
93
95
  }
94
- return rowKeys;
96
+ return scope;
95
97
  });
96
98
  }
97
99
  /**
@@ -147,12 +149,27 @@ export function localScopePredicate(table, effective) {
147
149
  .join(' AND ');
148
150
  return { sql, params };
149
151
  }
152
+ function scopedRows(db, table, scope) {
153
+ return scope
154
+ ? db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${scope.sql}`, scope.params)
155
+ : [];
156
+ }
157
+ function bootstrapScope(db, table, effective) {
158
+ const rows = scopedRows(db, table, localScopePredicate(table, effective));
159
+ rows.push(...db
160
+ .query(`SELECT id,base,intent FROM ${RETAINED_ROWS} WHERE tbl=?`, [
161
+ table.name,
162
+ ])
163
+ .filter((row) => [row.base, row.intent].some((json) => json !== null &&
164
+ matchesLocalScopes(table, effective, JSON.parse(json)))));
165
+ return rows.map((row) => JSON.stringify([table.name, String(row.id)]));
166
+ }
150
167
  export function deleteScopedRows(db, table, effective) {
151
168
  const scope = localScopePredicate(table, effective);
152
169
  if (!scope)
153
170
  return;
154
171
  const { sql, params } = scope;
155
- for (const row of db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`, params)) {
172
+ for (const row of scopedRows(db, table, scope)) {
156
173
  retainedBaseWrite(db, table, String(row.id));
157
174
  }
158
175
  db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
@@ -287,18 +304,37 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
287
304
  predicates.push(`${primaryKey} <= ?`);
288
305
  params.push(boundary);
289
306
  }
307
+ const replay = countOutbox(db) ||
308
+ db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? LIMIT 1`, [
309
+ table.name,
310
+ ]).length;
311
+ const rows = replay
312
+ ? db.query(`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`, params)
313
+ : [];
290
314
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
291
- restoreFailedBases(db, schema);
315
+ const scope = !replay
316
+ ? []
317
+ : hasUniqueIndex(table)
318
+ ? [JSON.stringify([table.name])]
319
+ : [
320
+ ...(after === undefined && options.clearFirst
321
+ ? bootstrapScope(db, table, options.effective)
322
+ : []),
323
+ ...rows.map((row) => JSON.stringify([table.name, String(row[table.primaryKey])])),
324
+ ];
325
+ const protectedRows = restoreFailedBases(db, schema, scope);
292
326
  if (after === undefined && options.clearFirst)
293
327
  deleteScopedRows(db, table, options.effective);
294
328
  db.exec(`INSERT INTO ${quoteIdent(table.name)}(${insertNames.join(', ')})SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')} FROM ${source} WHERE ${predicates.join(' AND ') || 'true'} ON CONFLICT(${primaryKey})DO UPDATE SET ${updates}`, params);
295
329
  applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
296
- for (const row of db.query(`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`, params)) {
297
- retainedBaseWrite(db, table, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
298
- column.name,
299
- rowValueToJson(row[column.name]),
300
- ])), Number(row._syncular_version));
301
- }
330
+ if (protectedRows)
331
+ for (const row of rows) {
332
+ retainedBaseWrite(db, table, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
333
+ column.name,
334
+ rowValueToJson(row[column.name]),
335
+ ])), Number(row._syncular_version));
336
+ }
337
+ return scope;
302
338
  });
303
339
  options.onProgress?.(applied);
304
340
  await yieldToHost();
@@ -347,7 +383,18 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
347
383
  if (!clearThisBlock && rows.length === 0)
348
384
  continue;
349
385
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
350
- const protectedRows = restoreFailedBases(db, schema);
386
+ const scope = hasUniqueIndex(table)
387
+ ? [JSON.stringify([table.name])]
388
+ : [
389
+ ...(clearThisBlock
390
+ ? bootstrapScope(db, table, options.effective)
391
+ : []),
392
+ ...rows.map((row) => JSON.stringify([
393
+ table.name,
394
+ String(row.values[table.primaryKeyIndex]),
395
+ ])),
396
+ ];
397
+ const protectedRows = restoreFailedBases(db, schema, scope);
351
398
  if (clearThisBlock) {
352
399
  deleteScopedRows(db, table, options.effective);
353
400
  }
@@ -357,6 +404,7 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
357
404
  if (applied % 1024 === 0)
358
405
  options.onProgress?.(applied);
359
406
  }
407
+ return scope;
360
408
  });
361
409
  options.onProgress?.(applied);
362
410
  await yieldToHost();
@@ -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/blob.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare const BLOB_COMMIT_REFS = "_syncular_blob_commit_refs";
1
2
  /**
2
3
  * Client-side blob cache + transport (SPEC.md §5.9.7).
3
4
  *
package/dist/blob.js CHANGED
@@ -1,3 +1,4 @@
1
+ export const BLOB_COMMIT_REFS = '_syncular_blob_commit_refs';
1
2
  /**
2
3
  * Client-side blob cache + transport (SPEC.md §5.9.7).
3
4
  *
@@ -70,7 +71,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
70
71
  if (total <= maxBytes)
71
72
  return [];
72
73
  // Oldest eligible body first, with blob ID as a stable tie.
73
- const candidates = db.query(`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM _syncular_blob_commit_refs)AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`);
74
+ const candidates = db.query(`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`);
74
75
  const evicted = [];
75
76
  db.transaction(() => {
76
77
  for (const row of candidates) {
@@ -86,7 +87,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
86
87
  }
87
88
  export function listPendingUploads(db) {
88
89
  return db
89
- .query('SELECT blob_id,media_type FROM(SELECT blob_id,media_type,created_at_ms FROM _syncular_blob_uploads UNION ALL SELECT DISTINCT r.blob_id,b.media_type,9223372036854775807 AS created_at_ms FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id=r.blob_id WHERE NOT EXISTS(SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id=r.blob_id))ORDER BY created_at_ms,blob_id')
90
+ .query(`SELECT blob_id,media_type FROM(SELECT blob_id,media_type,created_at_ms FROM _syncular_blob_uploads UNION ALL SELECT DISTINCT r.blob_id,b.media_type,9223372036854775807 AS created_at_ms FROM ${BLOB_COMMIT_REFS} r LEFT JOIN _syncular_blobs b ON b.blob_id=r.blob_id WHERE NOT EXISTS(SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id=r.blob_id))ORDER BY created_at_ms,blob_id`)
90
91
  .map((row) => {
91
92
  if (typeof row.blob_id !== 'string' ||
92
93
  (row.media_type !== null && typeof row.media_type !== 'string')) {
@@ -138,6 +139,6 @@ function visibleBlobIdsSql(schema) {
138
139
  : selects.join(' UNION ');
139
140
  }
140
141
  export function deleteUnreferencedCachedBlobs(db, schema) {
141
- db.exec(`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM _syncular_blob_commit_refs)AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`);
142
+ db.exec(`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`);
142
143
  }
143
144
  export { parseBlobRef, serializeBlobRef };
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