@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.
@@ -1,7 +1,7 @@
1
1
  import { ClientSyncError } from './errors.js';
2
2
  import { INVALID_HOST_RESPONSE_CODE } from './local-rebootstrap.js';
3
3
  import { fromSqlValue, jsonToRowValue, localColumnType, quoteIdent, rowValueToJson, } from './schema.js';
4
- import { getMeta, setMeta } from './state.js';
4
+ import { getMeta, setMeta, META_TABLE } from './state.js';
5
5
  /** `_syncular_meta` key holding the persisted {@link LocalSchemaDescriptor}. */
6
6
  const LOCAL_SCHEMA_DESCRIPTOR_KEY = 'localSchemaDescriptor';
7
7
  /**
@@ -441,7 +441,7 @@ export function writePreviousVersionRefusal(db, reason, measurement) {
441
441
  }
442
442
  /** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
443
443
  export function clearPreviousVersionRefusal(db) {
444
- db.exec('DELETE FROM _syncular_meta WHERE key=?', [
444
+ db.exec(`DELETE FROM ${META_TABLE} WHERE key=?`, [
445
445
  PREVIOUS_VERSION_CONTEXT_KEY,
446
446
  ]);
447
447
  }
@@ -575,7 +575,7 @@ export function writePreviousVersionAudit(db, audit) {
575
575
  }
576
576
  /** Delete the advisory audit. Part of a discard, never of a normal bump. */
577
577
  export function clearPreviousVersionAudit(db) {
578
- db.exec('DELETE FROM _syncular_meta WHERE key=?', [
578
+ db.exec(`DELETE FROM ${META_TABLE} WHERE key=?`, [
579
579
  PREVIOUS_VERSION_AUDIT_KEY,
580
580
  ]);
581
581
  }
package/dist/schema.d.ts CHANGED
@@ -93,6 +93,7 @@ export declare const OPTIMISTIC_VERSION = -1;
93
93
  * pass through this filter.
94
94
  */
95
95
  export declare function stripSyncColumns(rows: SqlRow[]): SqlRow[];
96
+ export declare function hasUniqueIndex(table: CompiledClientTable): boolean;
96
97
  export declare function matchesLocalScopes(table: CompiledClientTable, scopes: Readonly<Record<string, readonly string[]>>, values: Readonly<Record<string, JsonRowValue>>): boolean;
97
98
  export declare function quoteIdent(name: string): string;
98
99
  /**
package/dist/schema.js CHANGED
@@ -1,3 +1,9 @@
1
+ import { BLOB_COMMIT_REFS } from './blob.js';
2
+ import { PENDING_EVICTIONS } from './window.js';
3
+ import { WINDOWS_TABLE } from './window.js';
4
+ import { OUTBOX_TABLE, OUTBOX_IMAGES } from './outbox.js';
5
+ import { OUTCOMES_TABLE } from './outcomes.js';
6
+ import { SUBSCRIPTIONS_TABLE, META_TABLE } from './state.js';
1
7
  import { ensureFailedOverlaySchema } from './failed-overlay.js';
2
8
  import { invalidRequest } from './errors.js';
3
9
  import { snakeToCamel } from './naming.js';
@@ -156,6 +162,9 @@ export function stripSyncColumns(rows) {
156
162
  return copy;
157
163
  });
158
164
  }
165
+ export function hasUniqueIndex(table) {
166
+ return table.indexes.some((index) => index.unique);
167
+ }
159
168
  export function matchesLocalScopes(table, scopes, values) {
160
169
  const entries = Object.entries(scopes);
161
170
  return (entries.length > 0 &&
@@ -361,34 +370,34 @@ export function ensureLocalBookkeepingSchema(db) {
361
370
  db.transaction(() => {
362
371
  ensureFailedOverlaySchema(db);
363
372
  const createTable = (name, columns) => db.exec(`CREATE TABLE IF NOT EXISTS ${name}(${columns})`);
364
- createTable('_syncular_meta', 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
365
- createTable('_syncular_blob_commit_refs', 'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)');
366
- db.exec('CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id)');
367
- db.exec("INSERT OR IGNORE INTO _syncular_meta(key,value)VALUES('localRevision','0')");
368
- createTable('_syncular_outbox', 'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL');
369
- createTable('_syncular_outbox_before_images', 'client_commit_id TEXT NOT NULL,op_index INTEGER NOT NULL,existed INTEGER NOT NULL CHECK(existed IN (0,1)),sync_version INTEGER,values_json TEXT,PRIMARY KEY(client_commit_id,op_index)');
373
+ createTable(META_TABLE, 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
374
+ createTable(BLOB_COMMIT_REFS, 'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)');
375
+ db.exec(`CREATE INDEX IF NOT EXISTS ${BLOB_COMMIT_REFS}_body ON ${BLOB_COMMIT_REFS}(blob_id)`);
376
+ db.exec(`INSERT OR IGNORE INTO ${META_TABLE}(key,value)VALUES('localRevision','0')`);
377
+ createTable(OUTBOX_TABLE, 'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL');
378
+ createTable(OUTBOX_IMAGES, 'client_commit_id TEXT NOT NULL,op_index INTEGER NOT NULL,existed INTEGER NOT NULL CHECK(existed IN (0,1)),sync_version INTEGER,values_json TEXT,PRIMARY KEY(client_commit_id,op_index)');
370
379
  if (!db
371
- .query('PRAGMA table_info(_syncular_outbox_before_images)')
380
+ .query(`PRAGMA table_info(${OUTBOX_IMAGES})`)
372
381
  .some((row) => row.name === 'delivery_seq'))
373
- db.exec('ALTER TABLE _syncular_outbox_before_images ADD COLUMN delivery_seq INTEGER');
374
- createTable('_syncular_commit_outcomes', "seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,status TEXT NOT NULL CHECK(status IN ('applied','cached','conflict','rejected')),recorded_at_ms INTEGER NOT NULL,results TEXT NOT NULL,operations TEXT,resolution TEXT NOT NULL DEFAULT 'active' CHECK(resolution IN ('active','resolved_keep_server','superseded','dismissed')),resolved_at_ms INTEGER,replacement_client_commit_id TEXT");
382
+ db.exec(`ALTER TABLE ${OUTBOX_IMAGES} ADD COLUMN delivery_seq INTEGER`);
383
+ createTable(OUTCOMES_TABLE, "seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,status TEXT NOT NULL CHECK(status IN ('applied','cached','conflict','rejected')),recorded_at_ms INTEGER NOT NULL,results TEXT NOT NULL,operations TEXT,resolution TEXT NOT NULL DEFAULT 'active' CHECK(resolution IN ('active','resolved_keep_server','superseded','dismissed')),resolved_at_ms INTEGER,replacement_client_commit_id TEXT");
375
384
  // Outcomes created before durable aggregate recovery retain no commit
376
385
  // envelope. New failed outcomes store it in the protected client DB.
377
386
  try {
378
- db.exec('ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations TEXT');
387
+ db.exec(`ALTER TABLE ${OUTCOMES_TABLE} ADD COLUMN operations TEXT`);
379
388
  }
380
389
  catch {
381
390
  // column already exists — the CREATE above included it
382
391
  }
383
- db.exec('CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq ON _syncular_commit_outcomes(resolution,seq)');
384
- createTable('_syncular_subscriptions', "id TEXT PRIMARY KEY,tbl TEXT NOT NULL,requested_scopes TEXT NOT NULL,params TEXT,cursor INTEGER NOT NULL DEFAULT -1,bootstrap_state TEXT,effective_scopes TEXT,status TEXT NOT NULL DEFAULT 'active',reason_code TEXT");
392
+ db.exec(`CREATE INDEX IF NOT EXISTS ${OUTCOMES_TABLE}_resolution_seq ON ${OUTCOMES_TABLE}(resolution,seq)`);
393
+ createTable(SUBSCRIPTIONS_TABLE, "id TEXT PRIMARY KEY,tbl TEXT NOT NULL,requested_scopes TEXT NOT NULL,params TEXT,cursor INTEGER NOT NULL DEFAULT -1,bootstrap_state TEXT,effective_scopes TEXT,status TEXT NOT NULL DEFAULT 'active',reason_code TEXT");
385
394
  // §4.8 window registry: which units (scope values) of a window base are
386
395
  // live locally — the completeness oracle (I3) and the shrink driver
387
396
  // (a unit's omission from the next pull unregisters it, §4.1).
388
- createTable('_syncular_windows', 'base TEXT NOT NULL,unit TEXT NOT NULL,sub_id TEXT NOT NULL,PRIMARY KEY(base,unit)');
397
+ createTable(WINDOWS_TABLE, 'base TEXT NOT NULL,unit TEXT NOT NULL,sub_id TEXT NOT NULL,PRIMARY KEY(base,unit)');
389
398
  // §4.8 E1: units that left the window but still had outbox-pinned rows.
390
399
  // Retried when the outbox drains; cancelled if the unit re-enters.
391
- createTable('_syncular_window_pending_evict', 'sub_id TEXT PRIMARY KEY,tbl TEXT NOT NULL,effective_scopes TEXT NOT NULL');
400
+ createTable(PENDING_EVICTIONS, 'sub_id TEXT PRIMARY KEY,tbl TEXT NOT NULL,effective_scopes TEXT NOT NULL');
392
401
  });
393
402
  }
394
403
  /** Create both application and protected bookkeeping tables. */
package/dist/state.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export declare const META_TABLE = "_syncular_meta";
2
+ export declare const SUBSCRIPTIONS_TABLE = "_syncular_subscriptions";
1
3
  /**
2
4
  * Durable client sync state: per-subscription cursor, bootstrap resume
3
5
  * token (round-tripped opaquely, §4.7), and the last-echoed effective
package/dist/state.js CHANGED
@@ -1,3 +1,7 @@
1
+ export const META_TABLE = '_syncular_meta';
2
+ import { PENDING_EVICTIONS } from './window.js';
3
+ import { WINDOWS_TABLE } from './window.js';
4
+ export const SUBSCRIPTIONS_TABLE = '_syncular_subscriptions';
1
5
  export const LOCAL_REVISION_KEY = 'localRevision';
2
6
  const MAX_U64 = 18446744073709551615n;
3
7
  function rowToRecord(row) {
@@ -23,17 +27,17 @@ function rowToRecord(row) {
23
27
  }
24
28
  export function loadSubscriptions(db) {
25
29
  return db
26
- .query('SELECT * FROM _syncular_subscriptions ORDER BY rowid ASC')
30
+ .query(`SELECT * FROM ${SUBSCRIPTIONS_TABLE} ORDER BY rowid ASC`)
27
31
  .map(rowToRecord);
28
32
  }
29
33
  export function getSubscription(db, id) {
30
- const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id=?', [
34
+ const row = db.query(`SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [
31
35
  id,
32
36
  ])[0];
33
37
  return row === undefined ? undefined : rowToRecord(row);
34
38
  }
35
39
  export function saveSubscription(db, record) {
36
- db.exec('INSERT OR REPLACE INTO _syncular_subscriptions(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)', [
40
+ db.exec(`INSERT OR REPLACE INTO ${SUBSCRIPTIONS_TABLE}(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)`, [
37
41
  record.id,
38
42
  record.table,
39
43
  JSON.stringify(record.scopes),
@@ -48,7 +52,7 @@ export function saveSubscription(db, record) {
48
52
  ]);
49
53
  }
50
54
  export function deleteSubscription(db, id) {
51
- db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
55
+ db.exec(`DELETE FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id]);
52
56
  }
53
57
  /**
54
58
  * §7.4.3 reset: keep compatible subscription REGISTRATIONS (id, table,
@@ -58,7 +62,7 @@ export function deleteSubscription(db, id) {
58
62
  * subscriptions the app still wants. Caller owns the transaction.
59
63
  */
60
64
  export function resetSubscriptionsForBump(db) {
61
- db.exec("UPDATE _syncular_subscriptions SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL");
65
+ db.exec(`UPDATE ${SUBSCRIPTIONS_TABLE} SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL`);
62
66
  }
63
67
  /** Persist declarations separately from the previous-version row descriptor. */
64
68
  export function saveSubscriptionScopeSchema(db, schema) {
@@ -108,7 +112,7 @@ export function pruneUnknownSubscriptions(db, subscriptions, schema, bump = fals
108
112
  .map((record) => record.id));
109
113
  // Shrinking a pinned window already removed its registration. Its deferred
110
114
  // eviction still carries the old scope declaration and needs the same fence.
111
- for (const row of db.query('SELECT sub_id,tbl,effective_scopes FROM _syncular_window_pending_evict')) {
115
+ for (const row of db.query(`SELECT sub_id,tbl,effective_scopes FROM ${PENDING_EVICTIONS}`)) {
112
116
  if (!compatible({
113
117
  table: String(row.tbl),
114
118
  scopes: JSON.parse(String(row.effective_scopes)),
@@ -119,23 +123,19 @@ export function pruneUnknownSubscriptions(db, subscriptions, schema, bump = fals
119
123
  return retained;
120
124
  db.transaction(() => {
121
125
  for (const id of staleIds) {
122
- db.exec('DELETE FROM _syncular_windows WHERE sub_id=?', [id]);
123
- db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id=?', [
124
- id,
125
- ]);
126
- db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
126
+ db.exec(`DELETE FROM ${WINDOWS_TABLE} WHERE sub_id=?`, [id]);
127
+ db.exec(`DELETE FROM ${PENDING_EVICTIONS} WHERE sub_id=?`, [id]);
128
+ db.exec(`DELETE FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id]);
127
129
  }
128
130
  });
129
131
  return retained;
130
132
  }
131
133
  export function getMeta(db, key) {
132
- const row = db.query('SELECT value FROM _syncular_meta WHERE key=?', [
133
- key,
134
- ])[0];
134
+ const row = db.query(`SELECT value FROM ${META_TABLE} WHERE key=?`, [key])[0];
135
135
  return row === undefined ? undefined : row.value;
136
136
  }
137
137
  export function setMeta(db, key, value) {
138
- db.exec('INSERT OR REPLACE INTO _syncular_meta(key,value)VALUES(?,?)', [
138
+ db.exec(`INSERT OR REPLACE INTO ${META_TABLE}(key,value)VALUES(?,?)`, [
139
139
  key,
140
140
  value,
141
141
  ]);
package/dist/window.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export declare const PENDING_EVICTIONS = "_syncular_window_pending_evict";
2
+ export declare const WINDOWS_TABLE = "_syncular_windows";
1
3
  /**
2
4
  * Windowed subscriptions (SPEC.md §4.8).
3
5
  *
package/dist/window.js CHANGED
@@ -1,3 +1,5 @@
1
+ export const PENDING_EVICTIONS = '_syncular_window_pending_evict';
2
+ export const WINDOWS_TABLE = '_syncular_windows';
1
3
  /**
2
4
  * Windowed subscriptions (SPEC.md §4.8).
3
5
  *
@@ -88,12 +90,12 @@ export async function deriveSubId(base, unit) {
88
90
  /** Live units for a base, ordered by unit value. */
89
91
  export function loadWindowUnits(db, baseKey) {
90
92
  return db
91
- .query('SELECT unit, sub_id FROM _syncular_windows WHERE base = ? ORDER BY unit ASC', [baseKey])
93
+ .query(`SELECT unit,sub_id FROM ${WINDOWS_TABLE} WHERE base=? ORDER BY unit`, [baseKey])
92
94
  .map((row) => ({ unit: row.unit, subId: row.sub_id }));
93
95
  }
94
96
  /** Registry lookup used to emit exact completion changes for a sub id. */
95
97
  export function getWindowUnitBySubId(db, subId) {
96
- const row = db.query(`SELECT base, unit, sub_id FROM _syncular_windows
98
+ const row = db.query(`SELECT base, unit, sub_id FROM ${WINDOWS_TABLE}
97
99
  WHERE sub_id = ? LIMIT 1`, [subId])[0];
98
100
  return row === undefined
99
101
  ? undefined
@@ -104,30 +106,31 @@ export function getWindowUnitBySubId(db, subId) {
104
106
  };
105
107
  }
106
108
  export function insertWindowUnit(db, baseKey, unit, subId) {
107
- db.exec('INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?, ?, ?)', [baseKey, unit, subId]);
109
+ db.exec(`INSERT OR REPLACE INTO ${WINDOWS_TABLE}(base,unit,sub_id)VALUES(?,?,?)`, [baseKey, unit, subId]);
108
110
  }
109
111
  export function deleteWindowUnit(db, baseKey, unit) {
110
- db.exec('DELETE FROM _syncular_windows WHERE base = ? AND unit = ?', [
112
+ db.exec(`DELETE FROM ${WINDOWS_TABLE} WHERE base=? AND unit=?`, [
111
113
  baseKey,
112
114
  unit,
113
115
  ]);
114
116
  }
115
117
  /** Is a single scope value windowed-in for this base? (the oracle, I3) */
116
118
  export function isUnitLive(db, baseKey, unit) {
117
- return (db.query('SELECT 1 FROM _syncular_windows WHERE base = ? AND unit = ? LIMIT 1', [baseKey, unit]).length > 0);
119
+ return (db.query(`SELECT 1 FROM ${WINDOWS_TABLE} WHERE base=? AND unit=? LIMIT 1`, [
120
+ baseKey,
121
+ unit,
122
+ ]).length > 0);
118
123
  }
119
124
  export function savePendingEviction(db, subId, table, effective) {
120
- db.exec(`INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
125
+ db.exec(`INSERT OR REPLACE INTO ${PENDING_EVICTIONS}(sub_id, tbl, effective_scopes)
121
126
  VALUES (?, ?, ?)`, [subId, table, JSON.stringify(effective)]);
122
127
  }
123
128
  export function deletePendingEviction(db, subId) {
124
- db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?', [
125
- subId,
126
- ]);
129
+ db.exec(`DELETE FROM ${PENDING_EVICTIONS} WHERE sub_id = ?`, [subId]);
127
130
  }
128
131
  export function loadPendingEvictions(db) {
129
132
  return db
130
- .query('SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict')
133
+ .query(`SELECT sub_id, tbl, effective_scopes FROM ${PENDING_EVICTIONS}`)
131
134
  .map((row) => ({
132
135
  subId: row.sub_id,
133
136
  table: row.tbl,
@@ -13,6 +13,7 @@
13
13
  * RPC-driven and auto-driven sync rounds serialize on one queue because
14
14
  * the core owns exactly one loop.
15
15
  */
16
+ import { defineAuthorityReads } from './authority.js';
16
17
  import { SyncClient } from './client.js';
17
18
  import { withClientDiagnosticsHost, } from './diagnostics.js';
18
19
  import { encryptionConfigFromKeyring } from './encryption.js';
@@ -233,6 +234,9 @@ export function startSyncWorker(overrides = {}) {
233
234
  ...(config.encryption !== undefined
234
235
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
235
236
  : {}),
237
+ ...(config.authorityReads !== undefined
238
+ ? { authorityReads: defineAuthorityReads(config.authorityReads) }
239
+ : {}),
236
240
  ...(config.securityPreflight !== undefined
237
241
  ? { securityPreflight: config.securityPreflight }
238
242
  : {}),
@@ -342,6 +346,7 @@ export function startSyncWorker(overrides = {}) {
342
346
  return serializedSync(() => running.syncUntilIdle(maxRounds));
343
347
  },
344
348
  query: (sql, params) => requireClient().query(sql, params),
349
+ authoritySnapshot: () => requireClient().authoritySnapshot(),
345
350
  querySnapshot: (spec) => requireClient().querySnapshot(spec),
346
351
  localRevision: () => requireClient().localRevision,
347
352
  statusSnapshot: () => requireClient().statusSnapshot(),
@@ -420,6 +425,8 @@ export function startSyncWorker(overrides = {}) {
420
425
  'previousVersionDiscard',
421
426
  ]);
422
427
  async function dispatch(message) {
428
+ if (message.method === 'authoritySnapshot' && message.args.length)
429
+ throw new ClientSyncError('client.authority_read_forbidden', 'authoritySnapshot accepts no arguments');
423
430
  const method = api[message.method];
424
431
  const invoke = () => method.apply(api, message.args);
425
432
  if (syncChainMethods.has(message.method)) {
@@ -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>;
@@ -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.13",
3
+ "version": "0.30.15",
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",
@@ -89,10 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.30.13"
92
+ "@syncular/core": "0.30.15"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.13",
95
+ "@syncular/server": "0.30.15",
96
96
  "playwright": "1.63.0",
97
97
  "@electric-sql/pglite": "^0.5.4"
98
98
  }
package/src/apply.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { RETAINED_ROWS } from './failed-overlay';
1
+ import { hasUniqueIndex } from './schema';
2
+ import { OUTBOX_TABLE, OUTBOX_IMAGES, countOutbox } from './outbox';
3
+ import { RETAINED_ROWS, type OverlayScope } from './failed-overlay';
2
4
  import { retainedBaseWrite, restoreFailedBases } from './failed-overlay';
3
5
  /**
4
6
  * Local application of server data: `COMMIT` frames (§4.5), rows segments
@@ -28,7 +30,7 @@ import {
28
30
  } from './schema';
29
31
 
30
32
  /** Lets the client wrap the physical write in its revision transaction. */
31
- export type ApplyTransaction = <T>(fn: () => T) => T;
33
+ export type ApplyTransaction = (fn: () => OverlayScope) => unknown;
32
34
 
33
35
  function upsertSql(table: CompiledClientTable): string {
34
36
  const names = [
@@ -96,8 +98,7 @@ export async function applyCommitFrame(
96
98
  schema: CompiledClientSchema,
97
99
  frame: CommitFrame,
98
100
  encryption?: EncryptionConfig,
99
- transaction: (fn: () => readonly string[] | undefined) => unknown = (fn) =>
100
- db.transaction(fn),
101
+ transaction: (fn: () => OverlayScope) => unknown = (fn) => db.transaction(fn),
101
102
  ): Promise<void> {
102
103
  type Resolved =
103
104
  | { op: 'delete'; table: CompiledClientTable; rowId: string }
@@ -142,18 +143,18 @@ export async function applyCommitFrame(
142
143
  });
143
144
  }
144
145
  transaction(() => {
145
- const rowKeys = frame.tables.some((name) =>
146
- schema.tables.get(name)?.indexes.some((index) => index.unique),
147
- )
148
- ? undefined
149
- : frame.changes.map((change) =>
150
- JSON.stringify([frame.tables[change.tableIndex], change.rowId]),
151
- );
152
- restoreFailedBases(db, schema, false, rowKeys);
146
+ const scope = resolved.map((change, index) =>
147
+ JSON.stringify(
148
+ hasUniqueIndex(change.table)
149
+ ? [change.table.name]
150
+ : [change.table.name, frame.changes[index]!.rowId],
151
+ ),
152
+ );
153
+ restoreFailedBases(db, schema, scope);
153
154
  for (const [index, change] of resolved.entries()) {
154
155
  const rowId = frame.changes[index]!.rowId;
155
156
  db.exec(
156
- "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')=?)",
157
+ `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')=?)`,
157
158
  [frame.commitSeq, change.table.name, rowId],
158
159
  );
159
160
  db.exec(
@@ -166,7 +167,7 @@ export async function applyCommitFrame(
166
167
  upsertLocalRow(db, change.table, change.values, change.rowVersion);
167
168
  }
168
169
  }
169
- return rowKeys;
170
+ return scope;
170
171
  });
171
172
  }
172
173
 
@@ -238,6 +239,41 @@ export function localScopePredicate(
238
239
  return { sql, params };
239
240
  }
240
241
 
242
+ function scopedRows(
243
+ db: ClientDatabase,
244
+ table: CompiledClientTable,
245
+ scope: ReturnType<typeof localScopePredicate>,
246
+ ) {
247
+ return scope
248
+ ? db.query(
249
+ `SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${scope.sql}`,
250
+ scope.params,
251
+ )
252
+ : [];
253
+ }
254
+
255
+ function bootstrapScope(
256
+ db: ClientDatabase,
257
+ table: CompiledClientTable,
258
+ effective: ScopeMap,
259
+ ): string[] {
260
+ const rows = scopedRows(db, table, localScopePredicate(table, effective));
261
+ rows.push(
262
+ ...db
263
+ .query(`SELECT id,base,intent FROM ${RETAINED_ROWS} WHERE tbl=?`, [
264
+ table.name,
265
+ ])
266
+ .filter((row) =>
267
+ [row.base, row.intent].some(
268
+ (json) =>
269
+ json !== null &&
270
+ matchesLocalScopes(table, effective, JSON.parse(json as string)),
271
+ ),
272
+ ),
273
+ );
274
+ return rows.map((row) => JSON.stringify([table.name, String(row.id)]));
275
+ }
276
+
241
277
  export function deleteScopedRows(
242
278
  db: ClientDatabase,
243
279
  table: CompiledClientTable,
@@ -246,10 +282,7 @@ export function deleteScopedRows(
246
282
  const scope = localScopePredicate(table, effective);
247
283
  if (!scope) return;
248
284
  const { sql, params } = scope;
249
- for (const row of db.query(
250
- `SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`,
251
- params,
252
- )) {
285
+ for (const row of scopedRows(db, table, scope)) {
253
286
  retainedBaseWrite(db, table, String(row.id));
254
287
  }
255
288
  db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
@@ -451,8 +484,31 @@ export async function applySqliteSegment(
451
484
  predicates.push(`${primaryKey} <= ?`);
452
485
  params.push(boundary);
453
486
  }
487
+ const replay =
488
+ countOutbox(db) ||
489
+ db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? LIMIT 1`, [
490
+ table.name,
491
+ ]).length;
492
+ const rows = replay
493
+ ? db.query(
494
+ `SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`,
495
+ params,
496
+ )
497
+ : [];
454
498
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
455
- restoreFailedBases(db, schema);
499
+ const scope = !replay
500
+ ? []
501
+ : hasUniqueIndex(table)
502
+ ? [JSON.stringify([table.name])]
503
+ : [
504
+ ...(after === undefined && options.clearFirst
505
+ ? bootstrapScope(db, table, options.effective)
506
+ : []),
507
+ ...rows.map((row) =>
508
+ JSON.stringify([table.name, String(row[table.primaryKey])]),
509
+ ),
510
+ ];
511
+ const protectedRows = restoreFailedBases(db, schema, scope);
456
512
  if (after === undefined && options.clearFirst)
457
513
  deleteScopedRows(db, table, options.effective);
458
514
  db.exec(
@@ -460,23 +516,22 @@ export async function applySqliteSegment(
460
516
  params,
461
517
  );
462
518
  applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
463
- for (const row of db.query(
464
- `SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`,
465
- params,
466
- )) {
467
- retainedBaseWrite(
468
- db,
469
- table,
470
- String(row[table.primaryKey]),
471
- Object.fromEntries(
472
- table.columns.map((column) => [
473
- column.name,
474
- rowValueToJson(row[column.name] as RowValue),
475
- ]),
476
- ),
477
- Number(row._syncular_version),
478
- );
479
- }
519
+ if (protectedRows)
520
+ for (const row of rows) {
521
+ retainedBaseWrite(
522
+ db,
523
+ table,
524
+ String(row[table.primaryKey]),
525
+ Object.fromEntries(
526
+ table.columns.map((column) => [
527
+ column.name,
528
+ rowValueToJson(row[column.name] as RowValue),
529
+ ]),
530
+ ),
531
+ Number(row._syncular_version),
532
+ );
533
+ }
534
+ return scope;
480
535
  });
481
536
  options.onProgress?.(applied);
482
537
  await yieldToHost();
@@ -537,7 +592,20 @@ export async function applyRowsSegment(
537
592
  first = false;
538
593
  if (!clearThisBlock && rows.length === 0) continue;
539
594
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
540
- const protectedRows = restoreFailedBases(db, schema);
595
+ const scope: OverlayScope = hasUniqueIndex(table)
596
+ ? [JSON.stringify([table.name])]
597
+ : [
598
+ ...(clearThisBlock
599
+ ? bootstrapScope(db, table, options.effective)
600
+ : []),
601
+ ...rows.map((row) =>
602
+ JSON.stringify([
603
+ table.name,
604
+ String(row.values[table.primaryKeyIndex]),
605
+ ]),
606
+ ),
607
+ ];
608
+ const protectedRows = restoreFailedBases(db, schema, scope);
541
609
  if (clearThisBlock) {
542
610
  deleteScopedRows(db, table, options.effective);
543
611
  }
@@ -546,6 +614,7 @@ export async function applyRowsSegment(
546
614
  applied += 1;
547
615
  if (applied % 1024 === 0) options.onProgress?.(applied);
548
616
  }
617
+ return scope;
549
618
  });
550
619
  options.onProgress?.(applied);
551
620
  await yieldToHost();