@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/src/outbox.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { BLOB_COMMIT_REFS } from './blob';
2
+ export const OUTBOX_TABLE = '_syncular_outbox';
3
+ export const OUTBOX_IMAGES = '_syncular_outbox_before_images';
4
+ import { overlayScopePredicate, type OverlayScope } from './failed-overlay';
1
5
  /**
2
6
  * The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
3
7
  * outbox rule): local mutations are persisted in a schema-agnostic JSON
@@ -68,7 +72,7 @@ export function appendOutboxCommit(
68
72
  );
69
73
  }
70
74
  db.exec(
71
- 'INSERT INTO _syncular_outbox(client_commit_id,created_at_ms,operations)VALUES(?,?,?)',
75
+ `INSERT INTO ${OUTBOX_TABLE}(client_commit_id,created_at_ms,operations)VALUES(?,?,?)`,
72
76
  [clientCommitId, nowMs, JSON.stringify(operations)],
73
77
  );
74
78
  replaceOutboxBeforeImages(db, clientCommitId, beforeImages);
@@ -77,14 +81,14 @@ export function appendOutboxCommit(
77
81
  /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
78
82
  export function listOutbox(
79
83
  db: ClientDatabase,
80
- rowKeys?: readonly string[],
84
+ scope?: OverlayScope,
81
85
  ): OutboxCommit[] {
82
86
  return db
83
87
  .query(
84
- rowKeys === undefined
85
- ? 'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox ORDER BY seq ASC'
86
- : "SELECT * FROM _syncular_outbox WHERE EXISTS(SELECT 1 FROM json_each(operations) WHERE json_array(json_extract(value,'$.table'),json_extract(value,'$.rowId')) IN(SELECT value FROM json_each(?))) ORDER BY seq",
87
- rowKeys && [JSON.stringify(rowKeys)],
88
+ scope === undefined
89
+ ? `SELECT * FROM ${OUTBOX_TABLE} ORDER BY seq ASC`
90
+ : `SELECT * FROM ${OUTBOX_TABLE} WHERE EXISTS(SELECT 1 FROM json_each(operations) WHERE ${overlayScopePredicate("json_extract(value,'$.table')", "json_extract(value,'$.rowId')")}) ORDER BY seq`,
91
+ scope && [JSON.stringify(scope)],
88
92
  )
89
93
  .map(decodeOutboxRow);
90
94
  }
@@ -106,7 +110,7 @@ export function* iterateOutbox(
106
110
  let afterSeq = 0;
107
111
  while (afterSeq < throughSeq) {
108
112
  const rows = db.query(
109
- 'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32',
113
+ `SELECT * FROM ${OUTBOX_TABLE} WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`,
110
114
  [afterSeq, throughSeq],
111
115
  );
112
116
  if (rows.length === 0) return;
@@ -120,7 +124,7 @@ export function* iterateOutbox(
120
124
 
121
125
  /** Routine status reads never load operation bodies. */
122
126
  export function countOutbox(db: ClientDatabase): number {
123
- return db.query('SELECT COUNT(*)AS count FROM _syncular_outbox')[0]!
127
+ return db.query(`SELECT COUNT(*)AS count FROM ${OUTBOX_TABLE}`)[0]!
124
128
  .count as number;
125
129
  }
126
130
 
@@ -128,14 +132,13 @@ export function deleteOutboxCommit(
128
132
  db: ClientDatabase,
129
133
  clientCommitId: string,
130
134
  ): void {
131
- db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id=?', [
135
+ db.exec(`DELETE FROM ${BLOB_COMMIT_REFS} WHERE commit_id=?`, [
132
136
  clientCommitId,
133
137
  ]);
134
- db.exec(
135
- 'DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=?',
136
- [clientCommitId],
137
- );
138
- db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id=?', [
138
+ db.exec(`DELETE FROM ${OUTBOX_IMAGES} WHERE client_commit_id=?`, [
139
+ clientCommitId,
140
+ ]);
141
+ db.exec(`DELETE FROM ${OUTBOX_TABLE} WHERE client_commit_id=?`, [
139
142
  clientCommitId,
140
143
  ]);
141
144
  }
@@ -146,7 +149,7 @@ export function listOutboxBeforeImages(
146
149
  ): OutboxBeforeImage[] {
147
150
  return db
148
151
  .query(
149
- 'SELECT op_index,existed,sync_version,values_json,delivery_seq FROM _syncular_outbox_before_images WHERE client_commit_id=? ORDER BY op_index',
152
+ `SELECT op_index,existed,sync_version,values_json,delivery_seq FROM ${OUTBOX_IMAGES} WHERE client_commit_id=? ORDER BY op_index`,
150
153
  [clientCommitId],
151
154
  )
152
155
  .map((row) => ({
@@ -175,7 +178,7 @@ export function replaceOutboxBeforeImages(
175
178
  ): void {
176
179
  for (const image of replacements) {
177
180
  db.exec(
178
- 'INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?) ON CONFLICT(client_commit_id,op_index)DO UPDATE SET existed=excluded.existed,sync_version=excluded.sync_version,values_json=excluded.values_json',
181
+ `INSERT INTO ${OUTBOX_IMAGES}(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?) ON CONFLICT(client_commit_id,op_index)DO UPDATE SET existed=excluded.existed,sync_version=excluded.sync_version,values_json=excluded.values_json`,
179
182
  [
180
183
  clientCommitId,
181
184
  image.opIndex,
package/src/outcomes.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export const OUTCOMES_TABLE = '_syncular_commit_outcomes';
2
+ const SELECT_OUTCOME = `SELECT * FROM ${OUTCOMES_TABLE}`;
1
3
  import { RETAINED_ROWS } from './failed-overlay';
2
4
  /**
3
5
  * Durable per-client commit outcomes.
@@ -233,7 +235,7 @@ export function recordCommitOutcome(
233
235
  outcome: Omit<CommitOutcome, 'sequence' | 'resolution'>,
234
236
  ): CommitOutcome {
235
237
  db.exec(
236
- "INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')",
238
+ `INSERT INTO ${OUTCOMES_TABLE}(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')`,
237
239
  [
238
240
  outcome.clientCommitId,
239
241
  outcome.status,
@@ -251,10 +253,9 @@ export function commitOutcome(
251
253
  db: ClientDatabase,
252
254
  clientCommitId: string,
253
255
  ): CommitOutcome | undefined {
254
- const row = db.query(
255
- 'SELECT seq,client_commit_id,status,recorded_at_ms,results,operations,resolution,resolved_at_ms,replacement_client_commit_id FROM _syncular_commit_outcomes WHERE client_commit_id=?',
256
- [clientCommitId],
257
- )[0];
256
+ const row = db.query(`${SELECT_OUTCOME} WHERE client_commit_id=?`, [
257
+ clientCommitId,
258
+ ])[0];
258
259
  return row === undefined ? undefined : parseOutcome(db, row);
259
260
  }
260
261
 
@@ -273,7 +274,7 @@ export function listCommitOutcomes(
273
274
  ? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
274
275
  : '';
275
276
  const rows = db.query(
276
- `SELECT seq,client_commit_id,status,recorded_at_ms,results,operations,resolution,resolved_at_ms,replacement_client_commit_id FROM _syncular_commit_outcomes ${where} ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`,
277
+ `${SELECT_OUTCOME} ${where} ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`,
277
278
  limit === undefined ? [] : [limit],
278
279
  );
279
280
  return rows.map((row) => parseOutcome(db, row));
@@ -285,7 +286,7 @@ export function persistCommitOutcomeResolution(
285
286
  nowMs: number,
286
287
  ): CommitOutcome | undefined {
287
288
  db.exec(
288
- "UPDATE _syncular_commit_outcomes SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'",
289
+ `UPDATE ${OUTCOMES_TABLE} SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'`,
289
290
  [
290
291
  input.resolution,
291
292
  nowMs,
@@ -310,17 +311,16 @@ export function pruneCommitOutcomes(
310
311
  'outcome retention maxEntries must be a positive safe integer',
311
312
  );
312
313
  }
313
- const count = db.query(
314
- 'SELECT COUNT(*) AS count FROM _syncular_commit_outcomes',
315
- )[0]?.count as number | undefined;
314
+ const count = db.query(`SELECT COUNT(*) AS count FROM ${OUTCOMES_TABLE}`)[0]
315
+ ?.count as number | undefined;
316
316
  const excess = Math.max(0, (count ?? 0) - maxEntries);
317
317
  if (excess === 0) return 0;
318
318
  const candidates = db.query(
319
- "SELECT seq FROM _syncular_commit_outcomes WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?",
319
+ `SELECT seq FROM ${OUTCOMES_TABLE} WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?`,
320
320
  [excess],
321
321
  );
322
322
  for (const candidate of candidates) {
323
- db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq=?', [
323
+ db.exec(`DELETE FROM ${OUTCOMES_TABLE} WHERE seq=?`, [
324
324
  candidate.seq as number,
325
325
  ]);
326
326
  }
@@ -42,7 +42,7 @@ import {
42
42
  quoteIdent,
43
43
  rowValueToJson,
44
44
  } from './schema';
45
- import { getMeta, setMeta } from './state';
45
+ import { getMeta, setMeta, META_TABLE } from './state';
46
46
 
47
47
  /** `_syncular_meta` key holding the persisted {@link LocalSchemaDescriptor}. */
48
48
  const LOCAL_SCHEMA_DESCRIPTOR_KEY = 'localSchemaDescriptor';
@@ -724,7 +724,7 @@ export function writePreviousVersionRefusal(
724
724
 
725
725
  /** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
726
726
  export function clearPreviousVersionRefusal(db: ClientDatabase): void {
727
- db.exec('DELETE FROM _syncular_meta WHERE key=?', [
727
+ db.exec(`DELETE FROM ${META_TABLE} WHERE key=?`, [
728
728
  PREVIOUS_VERSION_CONTEXT_KEY,
729
729
  ]);
730
730
  }
@@ -892,7 +892,7 @@ export function writePreviousVersionAudit(
892
892
 
893
893
  /** Delete the advisory audit. Part of a discard, never of a normal bump. */
894
894
  export function clearPreviousVersionAudit(db: ClientDatabase): void {
895
- db.exec('DELETE FROM _syncular_meta WHERE key=?', [
895
+ db.exec(`DELETE FROM ${META_TABLE} WHERE key=?`, [
896
896
  PREVIOUS_VERSION_AUDIT_KEY,
897
897
  ]);
898
898
  }
package/src/schema.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import { BLOB_COMMIT_REFS } from './blob';
2
+ import { PENDING_EVICTIONS } from './window';
3
+ import { WINDOWS_TABLE } from './window';
4
+ import { OUTBOX_TABLE, OUTBOX_IMAGES } from './outbox';
5
+ import { OUTCOMES_TABLE } from './outcomes';
6
+ import { SUBSCRIPTIONS_TABLE, META_TABLE } from './state';
1
7
  import { ensureFailedOverlaySchema } from './failed-overlay';
2
8
  /**
3
9
  * Client schema IR (SPEC.md §2.4, §3.1) — the same shape the server
@@ -265,6 +271,10 @@ export function stripSyncColumns(rows: SqlRow[]): SqlRow[] {
265
271
  });
266
272
  }
267
273
 
274
+ export function hasUniqueIndex(table: CompiledClientTable): boolean {
275
+ return table.indexes.some((index) => index.unique);
276
+ }
277
+
268
278
  export function matchesLocalScopes(
269
279
  table: CompiledClientTable,
270
280
  scopes: Readonly<Record<string, readonly string[]>>,
@@ -531,64 +541,60 @@ export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
531
541
  ensureFailedOverlaySchema(db);
532
542
  const createTable = (name: string, columns: string) =>
533
543
  db.exec(`CREATE TABLE IF NOT EXISTS ${name}(${columns})`);
534
- createTable('_syncular_meta', 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
544
+ createTable(META_TABLE, 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
535
545
  createTable(
536
- '_syncular_blob_commit_refs',
546
+ BLOB_COMMIT_REFS,
537
547
  'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)',
538
548
  );
539
549
  db.exec(
540
- 'CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id)',
550
+ `CREATE INDEX IF NOT EXISTS ${BLOB_COMMIT_REFS}_body ON ${BLOB_COMMIT_REFS}(blob_id)`,
541
551
  );
542
552
  db.exec(
543
- "INSERT OR IGNORE INTO _syncular_meta(key,value)VALUES('localRevision','0')",
553
+ `INSERT OR IGNORE INTO ${META_TABLE}(key,value)VALUES('localRevision','0')`,
544
554
  );
545
555
  createTable(
546
- '_syncular_outbox',
556
+ OUTBOX_TABLE,
547
557
  'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL',
548
558
  );
549
559
  createTable(
550
- '_syncular_outbox_before_images',
560
+ OUTBOX_IMAGES,
551
561
  '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)',
552
562
  );
553
563
  if (
554
564
  !db
555
- .query('PRAGMA table_info(_syncular_outbox_before_images)')
565
+ .query(`PRAGMA table_info(${OUTBOX_IMAGES})`)
556
566
  .some((row) => row.name === 'delivery_seq')
557
567
  )
558
- db.exec(
559
- 'ALTER TABLE _syncular_outbox_before_images ADD COLUMN delivery_seq INTEGER',
560
- );
568
+ db.exec(`ALTER TABLE ${OUTBOX_IMAGES} ADD COLUMN delivery_seq INTEGER`);
561
569
  createTable(
562
- '_syncular_commit_outcomes',
570
+ OUTCOMES_TABLE,
563
571
  "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",
564
572
  );
565
573
  // Outcomes created before durable aggregate recovery retain no commit
566
574
  // envelope. New failed outcomes store it in the protected client DB.
567
575
  try {
568
- db.exec(
569
- 'ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations TEXT',
570
- );
576
+ db.exec(`ALTER TABLE ${OUTCOMES_TABLE} ADD COLUMN operations TEXT`);
571
577
  } catch {
572
578
  // column already exists — the CREATE above included it
573
579
  }
574
580
  db.exec(
575
- 'CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq ON _syncular_commit_outcomes(resolution,seq)',
581
+ `CREATE INDEX IF NOT EXISTS ${OUTCOMES_TABLE}_resolution_seq ON ${OUTCOMES_TABLE}(resolution,seq)`,
576
582
  );
577
583
  createTable(
578
- '_syncular_subscriptions',
584
+ SUBSCRIPTIONS_TABLE,
579
585
  "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",
580
586
  );
581
587
  // §4.8 window registry: which units (scope values) of a window base are
582
588
  // live locally — the completeness oracle (I3) and the shrink driver
583
589
  // (a unit's omission from the next pull unregisters it, §4.1).
584
590
  createTable(
585
- '_syncular_windows',
591
+ WINDOWS_TABLE,
586
592
  'base TEXT NOT NULL,unit TEXT NOT NULL,sub_id TEXT NOT NULL,PRIMARY KEY(base,unit)',
587
593
  );
588
594
  // §4.8 E1: units that left the window but still had outbox-pinned rows.
589
595
  // Retried when the outbox drains; cancelled if the unit re-enters.
590
596
  createTable(
591
- '_syncular_window_pending_evict',
597
+ PENDING_EVICTIONS,
592
598
  'sub_id TEXT PRIMARY KEY,tbl TEXT NOT NULL,effective_scopes TEXT NOT NULL',
593
599
  );
594
600
  });
package/src/state.ts CHANGED
@@ -1,3 +1,7 @@
1
+ export const META_TABLE = '_syncular_meta';
2
+ import { PENDING_EVICTIONS } from './window';
3
+ import { WINDOWS_TABLE } from './window';
4
+ export const SUBSCRIPTIONS_TABLE = '_syncular_subscriptions';
1
5
  /**
2
6
  * Durable client sync state: per-subscription cursor, bootstrap resume
3
7
  * token (round-tripped opaquely, §4.7), and the last-echoed effective
@@ -58,7 +62,7 @@ function rowToRecord(row: Record<string, unknown>): SubscriptionRecord {
58
62
 
59
63
  export function loadSubscriptions(db: ClientDatabase): SubscriptionRecord[] {
60
64
  return db
61
- .query('SELECT * FROM _syncular_subscriptions ORDER BY rowid ASC')
65
+ .query(`SELECT * FROM ${SUBSCRIPTIONS_TABLE} ORDER BY rowid ASC`)
62
66
  .map(rowToRecord);
63
67
  }
64
68
 
@@ -66,7 +70,7 @@ export function getSubscription(
66
70
  db: ClientDatabase,
67
71
  id: string,
68
72
  ): SubscriptionRecord | undefined {
69
- const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id=?', [
73
+ const row = db.query(`SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [
70
74
  id,
71
75
  ])[0];
72
76
  return row === undefined ? undefined : rowToRecord(row);
@@ -77,7 +81,7 @@ export function saveSubscription(
77
81
  record: SubscriptionRecord,
78
82
  ): void {
79
83
  db.exec(
80
- 'INSERT OR REPLACE INTO _syncular_subscriptions(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)',
84
+ `INSERT OR REPLACE INTO ${SUBSCRIPTIONS_TABLE}(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)`,
81
85
  [
82
86
  record.id,
83
87
  record.table,
@@ -95,7 +99,7 @@ export function saveSubscription(
95
99
  }
96
100
 
97
101
  export function deleteSubscription(db: ClientDatabase, id: string): void {
98
- db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
102
+ db.exec(`DELETE FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id]);
99
103
  }
100
104
 
101
105
  /**
@@ -107,7 +111,7 @@ export function deleteSubscription(db: ClientDatabase, id: string): void {
107
111
  */
108
112
  export function resetSubscriptionsForBump(db: ClientDatabase): void {
109
113
  db.exec(
110
- "UPDATE _syncular_subscriptions SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL",
114
+ `UPDATE ${SUBSCRIPTIONS_TABLE} SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL`,
111
115
  );
112
116
  }
113
117
 
@@ -184,7 +188,7 @@ export function pruneUnknownSubscriptions(
184
188
  // Shrinking a pinned window already removed its registration. Its deferred
185
189
  // eviction still carries the old scope declaration and needs the same fence.
186
190
  for (const row of db.query(
187
- 'SELECT sub_id,tbl,effective_scopes FROM _syncular_window_pending_evict',
191
+ `SELECT sub_id,tbl,effective_scopes FROM ${PENDING_EVICTIONS}`,
188
192
  )) {
189
193
  if (
190
194
  !compatible({
@@ -197,25 +201,21 @@ export function pruneUnknownSubscriptions(
197
201
  if (staleIds.size === 0) return retained;
198
202
  db.transaction(() => {
199
203
  for (const id of staleIds) {
200
- db.exec('DELETE FROM _syncular_windows WHERE sub_id=?', [id]);
201
- db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id=?', [
202
- id,
203
- ]);
204
- db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
204
+ db.exec(`DELETE FROM ${WINDOWS_TABLE} WHERE sub_id=?`, [id]);
205
+ db.exec(`DELETE FROM ${PENDING_EVICTIONS} WHERE sub_id=?`, [id]);
206
+ db.exec(`DELETE FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id]);
205
207
  }
206
208
  });
207
209
  return retained;
208
210
  }
209
211
 
210
212
  export function getMeta(db: ClientDatabase, key: string): string | undefined {
211
- const row = db.query('SELECT value FROM _syncular_meta WHERE key=?', [
212
- key,
213
- ])[0];
213
+ const row = db.query(`SELECT value FROM ${META_TABLE} WHERE key=?`, [key])[0];
214
214
  return row === undefined ? undefined : (row.value as string);
215
215
  }
216
216
 
217
217
  export function setMeta(db: ClientDatabase, key: string, value: string): void {
218
- db.exec('INSERT OR REPLACE INTO _syncular_meta(key,value)VALUES(?,?)', [
218
+ db.exec(`INSERT OR REPLACE INTO ${META_TABLE}(key,value)VALUES(?,?)`, [
219
219
  key,
220
220
  value,
221
221
  ]);
package/src/window.ts 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
  *
@@ -151,7 +153,7 @@ export function loadWindowUnits(
151
153
  ): WindowUnit[] {
152
154
  return db
153
155
  .query(
154
- 'SELECT unit, sub_id FROM _syncular_windows WHERE base = ? ORDER BY unit ASC',
156
+ `SELECT unit,sub_id FROM ${WINDOWS_TABLE} WHERE base=? ORDER BY unit`,
155
157
  [baseKey],
156
158
  )
157
159
  .map((row) => ({ unit: row.unit as string, subId: row.sub_id as string }));
@@ -163,7 +165,7 @@ export function getWindowUnitBySubId(
163
165
  subId: string,
164
166
  ): RegisteredWindowUnit | undefined {
165
167
  const row = db.query(
166
- `SELECT base, unit, sub_id FROM _syncular_windows
168
+ `SELECT base, unit, sub_id FROM ${WINDOWS_TABLE}
167
169
  WHERE sub_id = ? LIMIT 1`,
168
170
  [subId],
169
171
  )[0];
@@ -183,7 +185,7 @@ export function insertWindowUnit(
183
185
  subId: string,
184
186
  ): void {
185
187
  db.exec(
186
- 'INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?, ?, ?)',
188
+ `INSERT OR REPLACE INTO ${WINDOWS_TABLE}(base,unit,sub_id)VALUES(?,?,?)`,
187
189
  [baseKey, unit, subId],
188
190
  );
189
191
  }
@@ -193,7 +195,7 @@ export function deleteWindowUnit(
193
195
  baseKey: string,
194
196
  unit: string,
195
197
  ): void {
196
- db.exec('DELETE FROM _syncular_windows WHERE base = ? AND unit = ?', [
198
+ db.exec(`DELETE FROM ${WINDOWS_TABLE} WHERE base=? AND unit=?`, [
197
199
  baseKey,
198
200
  unit,
199
201
  ]);
@@ -206,10 +208,10 @@ export function isUnitLive(
206
208
  unit: string,
207
209
  ): boolean {
208
210
  return (
209
- db.query(
210
- 'SELECT 1 FROM _syncular_windows WHERE base = ? AND unit = ? LIMIT 1',
211
- [baseKey, unit],
212
- ).length > 0
211
+ db.query(`SELECT 1 FROM ${WINDOWS_TABLE} WHERE base=? AND unit=? LIMIT 1`, [
212
+ baseKey,
213
+ unit,
214
+ ]).length > 0
213
215
  );
214
216
  }
215
217
 
@@ -230,23 +232,19 @@ export function savePendingEviction(
230
232
  effective: ScopeMap,
231
233
  ): void {
232
234
  db.exec(
233
- `INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
235
+ `INSERT OR REPLACE INTO ${PENDING_EVICTIONS}(sub_id, tbl, effective_scopes)
234
236
  VALUES (?, ?, ?)`,
235
237
  [subId, table, JSON.stringify(effective)],
236
238
  );
237
239
  }
238
240
 
239
241
  export function deletePendingEviction(db: ClientDatabase, subId: string): void {
240
- db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?', [
241
- subId,
242
- ]);
242
+ db.exec(`DELETE FROM ${PENDING_EVICTIONS} WHERE sub_id = ?`, [subId]);
243
243
  }
244
244
 
245
245
  export function loadPendingEvictions(db: ClientDatabase): PendingEviction[] {
246
246
  return db
247
- .query(
248
- 'SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict',
249
- )
247
+ .query(`SELECT sub_id, tbl, effective_scopes FROM ${PENDING_EVICTIONS}`)
250
248
  .map((row) => ({
251
249
  subId: row.sub_id as string,
252
250
  table: row.tbl as string,
@@ -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';
16
17
  import { SyncClient } from './client';
17
18
  import type { ClientDatabase } from './database';
18
19
  import {
@@ -323,6 +324,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
323
324
  ...(config.encryption !== undefined
324
325
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
325
326
  : {}),
327
+ ...(config.authorityReads !== undefined
328
+ ? { authorityReads: defineAuthorityReads(config.authorityReads) }
329
+ : {}),
326
330
  ...(config.securityPreflight !== undefined
327
331
  ? { securityPreflight: config.securityPreflight }
328
332
  : {}),
@@ -441,6 +445,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
441
445
  return serializedSync(() => running.syncUntilIdle(maxRounds));
442
446
  },
443
447
  query: (sql, params) => requireClient().query(sql, params),
448
+ authoritySnapshot: () => requireClient().authoritySnapshot(),
444
449
  querySnapshot: (spec) => requireClient().querySnapshot(spec),
445
450
  localRevision: () => requireClient().localRevision,
446
451
  statusSnapshot: () => requireClient().statusSnapshot(),
@@ -520,6 +525,11 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
520
525
  ]);
521
526
 
522
527
  async function dispatch(message: WorkerCallMessage): Promise<unknown> {
528
+ if (message.method === 'authoritySnapshot' && message.args.length)
529
+ throw new ClientSyncError(
530
+ 'client.authority_read_forbidden',
531
+ 'authoritySnapshot accepts no arguments',
532
+ );
523
533
  const method = api[message.method] as (...args: unknown[]) => unknown;
524
534
  const invoke = () => method.apply(api, message.args as unknown[]);
525
535
  if (syncChainMethods.has(message.method)) {
@@ -192,6 +192,8 @@ export interface SyncClientHandleConfig {
192
192
  readonly headers?: Readonly<Record<string, string>>;
193
193
  /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
194
194
  readonly encryption?: EncryptionKeyringConfig;
195
+ /** Fixed plain authority reads permitted before security activation. */
196
+ readonly authorityReads?: import('./authority').AuthorityReadPolicy;
195
197
  /** Open the worker-owned replica behind the fail-closed security gate. */
196
198
  readonly securityPreflight?: boolean;
197
199
  readonly transportEnabled?: boolean;
@@ -656,6 +658,15 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
656
658
  return this.#call('query', [sql, params]);
657
659
  }
658
660
 
661
+ authoritySnapshot(): Promise<import('./authority').AuthoritySnapshot> {
662
+ if (arguments.length)
663
+ throw new ClientSyncError(
664
+ 'client.authority_read_forbidden',
665
+ 'authoritySnapshot accepts no arguments',
666
+ );
667
+ return this.#call('authoritySnapshot', []);
668
+ }
669
+
659
670
  querySnapshot<Row = SqlRow>(
660
671
  spec: QueryReadSpec,
661
672
  ): Promise<QuerySnapshot<Row>> {
@@ -951,6 +962,9 @@ function buildInitConfig(
951
962
  ...(config.transportEnabled !== undefined
952
963
  ? { transportEnabled: config.transportEnabled }
953
964
  : {}),
965
+ ...(config.authorityReads !== undefined
966
+ ? { authorityReads: config.authorityReads.declarations }
967
+ : {}),
954
968
  ...(config.securityPreflight !== undefined
955
969
  ? { securityPreflight: config.securityPreflight }
956
970
  : {}),
@@ -111,6 +111,7 @@ export interface WorkerInitConfig {
111
111
  /** Portable raw keyring installed inside the worker-owned client core. */
112
112
  readonly encryption?: EncryptionKeyringConfig;
113
113
  /** Open the worker-owned replica behind the fail-closed security gate. */
114
+ readonly authorityReads?: readonly import('./authority').AuthorityReadDeclaration[];
114
115
  readonly securityPreflight?: boolean;
115
116
  readonly transportEnabled?: boolean;
116
117
  readonly clientId?: string;
@@ -186,6 +187,7 @@ export interface WorkerApi extends Omit<
186
187
  sync(): Promise<SyncSummary>;
187
188
  syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
188
189
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
190
+ authoritySnapshot(): import('./authority').AuthoritySnapshot;
189
191
  querySnapshot(spec: QueryReadSpec): QuerySnapshot;
190
192
  localRevision(): LocalRevision;
191
193
  pendingCommits(): OutboxCommit[];