@syncular/client 0.30.12 → 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.
@@ -1,3 +1,6 @@
1
+ import { hasUniqueIndex } from './schema';
2
+ import { OUTCOMES_TABLE } from './outcomes';
3
+ import { OUTBOX_TABLE } from './outbox';
1
4
  export const RETAINED_ROWS = '_syncular_failed_rows';
2
5
  import { ClientSyncError } from './errors';
3
6
  import type { ClientDatabase } from './database';
@@ -48,7 +51,7 @@ export function retainedBaseWrite(
48
51
  table.name,
49
52
  rowId,
50
53
  ]);
51
- if (!table.indexes.some((index) => index.unique)) return;
54
+ if (!hasUniqueIndex(table)) return;
52
55
  for (const retained of db.query(
53
56
  `SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`,
54
57
  [table.name],
@@ -164,90 +167,51 @@ export function uniqueConflicts(
164
167
  );
165
168
  }
166
169
 
167
- function retainedRows(db: ClientDatabase, rowKeys?: readonly string[]) {
170
+ /** Exact row keys are JSON [table,id]; table scopes include unique-key peers. */
171
+ export type OverlayScope = readonly string[];
172
+
173
+ export function overlayScopePredicate(table: string, id: string): string {
174
+ return `?1 IS NULL OR json_array(${table},${id}) IN(SELECT value FROM json_each(?1)) OR json_array(${table}) IN(SELECT value FROM json_each(?1))`;
175
+ }
176
+
177
+ function retainedRows(
178
+ db: ClientDatabase,
179
+ scope?: OverlayScope,
180
+ acknowledged?: boolean,
181
+ ) {
168
182
  return db.query(
169
- `SELECT * FROM ${RETAINED_ROWS} WHERE ?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1)) ORDER BY commit_seq,at,commit_id,idx`,
170
- [rowKeys ? JSON.stringify(rowKeys) : null],
183
+ `SELECT * FROM ${RETAINED_ROWS} WHERE (${overlayScopePredicate('tbl', 'id')})${acknowledged === undefined ? '' : ` AND commit_seq IS${acknowledged ? ' NOT' : ''} NULL`} ORDER BY commit_seq,at,commit_id,idx`,
184
+ [scope ? JSON.stringify(scope) : null],
171
185
  );
172
186
  }
173
187
 
188
+ function retainedTable(
189
+ schema: CompiledClientSchema,
190
+ name: string,
191
+ ): CompiledClientTable {
192
+ const table = schema.tables.get(name);
193
+ if (!table)
194
+ throw new ClientSyncError(
195
+ 'sync.unknown_table',
196
+ 'retained table is unknown',
197
+ );
198
+ return table;
199
+ }
200
+
174
201
  export function restoreFailedBases(
175
202
  db: ClientDatabase,
176
203
  schema: CompiledClientSchema,
177
- absentIntent = false,
178
- rowKeys?: readonly string[],
204
+ scope?: OverlayScope,
179
205
  ): boolean {
180
206
  const seen = new Set<string>();
181
- for (const row of retainedRows(db, rowKeys)) {
207
+ for (const row of retainedRows(db, scope)) {
182
208
  const key = JSON.stringify([row.tbl, row.id]);
183
- if (!absentIntent && seen.has(key)) continue;
209
+ if (seen.has(key)) continue;
184
210
  seen.add(key);
185
- const table = schema.tables.get(row.tbl as string);
186
- if (!table)
187
- throw new ClientSyncError(
188
- 'sync.unknown_table',
189
- 'retained table is unknown',
190
- );
191
- const json = absentIntent ? row.intent : row.base;
192
- if (absentIntent && row.intent !== null) {
193
- const values: Record<string, JsonRowValue> = JSON.parse(
194
- row.intent as string,
195
- );
196
- const conflicts = uniqueConflicts(
197
- db,
198
- table,
199
- mapRowValues(values, jsonToRowValue),
200
- );
201
- // Hydrate a pre-0.30.11 failure after its winner was already imported.
202
- // Pending and retained overlays cannot supply authoritative row evidence.
203
- if (row.commit_seq === null && row.unique_conflicts === null) {
204
- const authorized = conflicts.filter(
205
- (conflict) =>
206
- conflict.serverVersion >= 0 &&
207
- !db.query(
208
- `SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? UNION ALL SELECT 1 FROM _syncular_outbox,json_each(operations)WHERE json_extract(json_each.value,'$.table')=? AND json_extract(json_each.value,'$.rowId')=? LIMIT 1`,
209
- [table.name, conflict.rowId, table.name, conflict.rowId],
210
- ).length,
211
- );
212
- if (authorized.length)
213
- db.exec(
214
- `UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
215
- [
216
- JSON.stringify(
217
- authorized.map((conflict) => ({
218
- ...conflict,
219
- serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
220
- })),
221
- ),
222
- row.commit_id as string,
223
- row.idx as number,
224
- ],
225
- );
226
- }
227
- if (conflicts.length) continue;
228
- }
229
- if (
230
- absentIntent &&
231
- (json === null ||
232
- db.query(
233
- `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
234
- [row.id as string],
235
- ).length)
236
- )
237
- continue;
238
- if (json === null) deleteLocalRow(db, table, row.id as string, false);
211
+ const table = retainedTable(schema, row.tbl as string);
212
+ if (row.base === null) deleteLocalRow(db, table, row.id as string, false);
239
213
  else {
240
- const values: Record<string, JsonRowValue> = JSON.parse(String(json));
241
- if (absentIntent) {
242
- const operation: OutboxOperation = JSON.parse(row.op as string);
243
- if (
244
- row.commit_seq === null &&
245
- !table.columns.every((column) =>
246
- Object.hasOwn(operation.values ?? {}, column.name),
247
- )
248
- )
249
- continue;
250
- }
214
+ const values: Record<string, JsonRowValue> = JSON.parse(String(row.base));
251
215
  upsertLocalRow(
252
216
  db,
253
217
  table,
@@ -265,7 +229,8 @@ export function restoreFailedBases(
265
229
  export function failedOverlayCommits(
266
230
  db: ClientDatabase,
267
231
  acknowledged = false,
268
- rowKeys?: readonly string[],
232
+ scope?: OverlayScope,
233
+ schema?: CompiledClientSchema,
269
234
  ): OutboxCommit[] {
270
235
  const commits = new Map<
271
236
  string,
@@ -276,10 +241,55 @@ export function failedOverlayCommits(
276
241
  operations: OutboxOperation[];
277
242
  }
278
243
  >();
279
- for (const row of retainedRows(db, rowKeys)) {
280
- if ((row.commit_seq !== null) !== acknowledged) continue;
244
+ for (const row of retainedRows(db, scope, acknowledged)) {
281
245
  const id = row.commit_id as string;
282
- const operation: OutboxOperation = JSON.parse(row.op as string);
246
+ let operation: OutboxOperation = JSON.parse(row.op as string);
247
+ if (schema && operation.op === 'upsert') {
248
+ const table = retainedTable(schema, operation.table);
249
+ if (acknowledged) {
250
+ if (
251
+ !db.query(
252
+ `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
253
+ [operation.rowId],
254
+ ).length
255
+ )
256
+ operation = {
257
+ ...operation,
258
+ values: JSON.parse(row.intent as string),
259
+ };
260
+ } else if (row.unique_conflicts === null && row.intent !== null) {
261
+ // Hydrate legacy failure evidence from authoritative unique-key peers.
262
+ const values: Record<string, JsonRowValue> = JSON.parse(
263
+ row.intent as string,
264
+ );
265
+ const authorized = uniqueConflicts(
266
+ db,
267
+ table,
268
+ mapRowValues(values, jsonToRowValue),
269
+ ).filter(
270
+ (conflict) =>
271
+ conflict.serverVersion >= 0 &&
272
+ !db.query(
273
+ `SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? UNION ALL SELECT 1 FROM ${OUTBOX_TABLE},json_each(operations)WHERE json_extract(json_each.value,'$.table')=? AND json_extract(json_each.value,'$.rowId')=? LIMIT 1`,
274
+ [table.name, conflict.rowId, table.name, conflict.rowId],
275
+ ).length,
276
+ );
277
+ if (authorized.length)
278
+ db.exec(
279
+ `UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
280
+ [
281
+ JSON.stringify(
282
+ authorized.map((conflict) => ({
283
+ ...conflict,
284
+ serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
285
+ })),
286
+ ),
287
+ row.commit_id as string,
288
+ row.idx as number,
289
+ ],
290
+ );
291
+ }
292
+ }
283
293
  const prior = commits.get(id);
284
294
  if (prior) prior.operations.push(operation);
285
295
  else
@@ -295,6 +305,7 @@ export function failedOverlayCommits(
295
305
 
296
306
  export function dropFailedRows(
297
307
  db: ClientDatabase,
308
+ schema: CompiledClientSchema,
298
309
  matches: (
299
310
  table: string,
300
311
  values: Readonly<Record<string, JsonRowValue>>,
@@ -312,10 +323,17 @@ export function dropFailedRows(
312
323
  ...failedOverlayCommits(db),
313
324
  ...failedOverlayCommits(db, true),
314
325
  ].filter((commit) => ids.has(commit.clientCommitId));
326
+ restoreFailedBases(
327
+ db,
328
+ schema,
329
+ dropped.flatMap((commit) =>
330
+ commit.operations.map((op) => JSON.stringify([op.table, op.rowId])),
331
+ ),
332
+ );
315
333
  for (const id of ids) {
316
334
  db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
317
335
  db.exec(
318
- "UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?",
336
+ `UPDATE ${OUTCOMES_TABLE} SET operations=NULL,results='[]' WHERE client_commit_id=?`,
319
337
  [id],
320
338
  );
321
339
  }
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
  ]);