@syncular/client 0.30.13 → 0.30.14

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.
@@ -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,