@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.
@@ -10,6 +10,9 @@ export declare function retainedBaseWrite(db: ClientDatabase, table: CompiledCli
10
10
  export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[], commitSeq?: number): void;
11
11
  /** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
12
12
  export declare function uniqueConflicts(db: ClientDatabase, table: CompiledClientTable, values: Readonly<Record<string, RowValue>>): NonNullable<RetainedCommitRow['uniqueConflicts']>;
13
- export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean, rowKeys?: readonly string[]): boolean;
14
- export declare function failedOverlayCommits(db: ClientDatabase, acknowledged?: boolean, rowKeys?: readonly string[]): OutboxCommit[];
15
- export declare function dropFailedRows(db: ClientDatabase, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
13
+ /** Exact row keys are JSON [table,id]; table scopes include unique-key peers. */
14
+ export type OverlayScope = readonly string[];
15
+ export declare function overlayScopePredicate(table: string, id: string): string;
16
+ export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, scope?: OverlayScope): boolean;
17
+ export declare function failedOverlayCommits(db: ClientDatabase, acknowledged?: boolean, scope?: OverlayScope, schema?: CompiledClientSchema): OutboxCommit[];
18
+ export declare function dropFailedRows(db: ClientDatabase, schema: CompiledClientSchema, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
@@ -1,3 +1,6 @@
1
+ import { hasUniqueIndex } from './schema.js';
2
+ import { OUTCOMES_TABLE } from './outcomes.js';
3
+ import { OUTBOX_TABLE } from './outbox.js';
1
4
  export const RETAINED_ROWS = '_syncular_failed_rows';
2
5
  import { ClientSyncError } from './errors.js';
3
6
  import { mapRowValues, fromSqlValue, jsonToRowValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
@@ -17,7 +20,7 @@ export function retainedBaseWrite(db, table, rowId, values, version) {
17
20
  table.name,
18
21
  rowId,
19
22
  ]);
20
- if (!table.indexes.some((index) => index.unique))
23
+ if (!hasUniqueIndex(table))
21
24
  return;
22
25
  for (const retained of db.query(`SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`, [table.name])) {
23
26
  const prior = retained.unique_conflicts === null
@@ -87,28 +90,54 @@ export function uniqueConflicts(db, table, values) {
87
90
  serverVersion: Number(row[SYNC_VERSION_COLUMN]),
88
91
  })));
89
92
  }
90
- function retainedRows(db, rowKeys) {
91
- return db.query(`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`, [rowKeys ? JSON.stringify(rowKeys) : null]);
93
+ export function overlayScopePredicate(table, id) {
94
+ 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))`;
92
95
  }
93
- export function restoreFailedBases(db, schema, absentIntent = false, rowKeys) {
96
+ function retainedRows(db, scope, acknowledged) {
97
+ return db.query(`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`, [scope ? JSON.stringify(scope) : null]);
98
+ }
99
+ function retainedTable(schema, name) {
100
+ const table = schema.tables.get(name);
101
+ if (!table)
102
+ throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
103
+ return table;
104
+ }
105
+ export function restoreFailedBases(db, schema, scope) {
94
106
  const seen = new Set();
95
- for (const row of retainedRows(db, rowKeys)) {
107
+ for (const row of retainedRows(db, scope)) {
96
108
  const key = JSON.stringify([row.tbl, row.id]);
97
- if (!absentIntent && seen.has(key))
109
+ if (seen.has(key))
98
110
  continue;
99
111
  seen.add(key);
100
- const table = schema.tables.get(row.tbl);
101
- if (!table)
102
- throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
103
- const json = absentIntent ? row.intent : row.base;
104
- if (absentIntent && row.intent !== null) {
105
- const values = JSON.parse(row.intent);
106
- const conflicts = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue));
107
- // Hydrate a pre-0.30.11 failure after its winner was already imported.
108
- // Pending and retained overlays cannot supply authoritative row evidence.
109
- if (row.commit_seq === null && row.unique_conflicts === null) {
110
- const authorized = conflicts.filter((conflict) => conflict.serverVersion >= 0 &&
111
- !db.query(`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`, [table.name, conflict.rowId, table.name, conflict.rowId]).length);
112
+ const table = retainedTable(schema, row.tbl);
113
+ if (row.base === null)
114
+ deleteLocalRow(db, table, row.id, false);
115
+ else {
116
+ const values = JSON.parse(String(row.base));
117
+ upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
118
+ }
119
+ }
120
+ return seen.size > 0;
121
+ }
122
+ export function failedOverlayCommits(db, acknowledged = false, scope, schema) {
123
+ const commits = new Map();
124
+ for (const row of retainedRows(db, scope, acknowledged)) {
125
+ const id = row.commit_id;
126
+ let operation = JSON.parse(row.op);
127
+ if (schema && operation.op === 'upsert') {
128
+ const table = retainedTable(schema, operation.table);
129
+ if (acknowledged) {
130
+ if (!db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId]).length)
131
+ operation = {
132
+ ...operation,
133
+ values: JSON.parse(row.intent),
134
+ };
135
+ }
136
+ else if (row.unique_conflicts === null && row.intent !== null) {
137
+ // Hydrate legacy failure evidence from authoritative unique-key peers.
138
+ const values = JSON.parse(row.intent);
139
+ const authorized = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue)).filter((conflict) => conflict.serverVersion >= 0 &&
140
+ !db.query(`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`, [table.name, conflict.rowId, table.name, conflict.rowId]).length);
112
141
  if (authorized.length)
113
142
  db.exec(`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`, [
114
143
  JSON.stringify(authorized.map((conflict) => ({
@@ -119,35 +148,7 @@ export function restoreFailedBases(db, schema, absentIntent = false, rowKeys) {
119
148
  row.idx,
120
149
  ]);
121
150
  }
122
- if (conflicts.length)
123
- continue;
124
- }
125
- if (absentIntent &&
126
- (json === null ||
127
- db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [row.id]).length))
128
- continue;
129
- if (json === null)
130
- deleteLocalRow(db, table, row.id, false);
131
- else {
132
- const values = JSON.parse(String(json));
133
- if (absentIntent) {
134
- const operation = JSON.parse(row.op);
135
- if (row.commit_seq === null &&
136
- !table.columns.every((column) => Object.hasOwn(operation.values ?? {}, column.name)))
137
- continue;
138
- }
139
- upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
140
151
  }
141
- }
142
- return seen.size > 0;
143
- }
144
- export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
145
- const commits = new Map();
146
- for (const row of retainedRows(db, rowKeys)) {
147
- if ((row.commit_seq !== null) !== acknowledged)
148
- continue;
149
- const id = row.commit_id;
150
- const operation = JSON.parse(row.op);
151
152
  const prior = commits.get(id);
152
153
  if (prior)
153
154
  prior.operations.push(operation);
@@ -161,7 +162,7 @@ export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
161
162
  }
162
163
  return [...commits.values()];
163
164
  }
164
- export function dropFailedRows(db, matches) {
165
+ export function dropFailedRows(db, schema, matches) {
165
166
  const ids = new Set();
166
167
  for (const row of retainedRows(db)) {
167
168
  for (const json of [row.intent, row.base]) {
@@ -176,9 +177,10 @@ export function dropFailedRows(db, matches) {
176
177
  ...failedOverlayCommits(db),
177
178
  ...failedOverlayCommits(db, true),
178
179
  ].filter((commit) => ids.has(commit.clientCommitId));
180
+ restoreFailedBases(db, schema, dropped.flatMap((commit) => commit.operations.map((op) => JSON.stringify([op.table, op.rowId]))));
179
181
  for (const id of ids) {
180
182
  db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
181
- db.exec("UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?", [id]);
183
+ db.exec(`UPDATE ${OUTCOMES_TABLE} SET operations=NULL,results='[]' WHERE client_commit_id=?`, [id]);
182
184
  }
183
185
  return dropped;
184
186
  }
package/dist/outbox.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export declare const OUTBOX_TABLE = "_syncular_outbox";
2
+ export declare const OUTBOX_IMAGES = "_syncular_outbox_before_images";
3
+ import { type OverlayScope } from './failed-overlay.js';
1
4
  /**
2
5
  * The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
3
6
  * outbox rule): local mutations are persisted in a schema-agnostic JSON
@@ -40,7 +43,7 @@ export interface OutboxBeforeImage {
40
43
  }
41
44
  export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
42
45
  /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
43
- export declare function listOutbox(db: ClientDatabase, rowKeys?: readonly string[]): OutboxCommit[];
46
+ export declare function listOutbox(db: ClientDatabase, scope?: OverlayScope): OutboxCommit[];
44
47
  /** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
45
48
  export declare function iterateOutbox(db: ClientDatabase, throughSeq: number): Generator<OutboxCommit>;
46
49
  /** Routine status reads never load operation bodies. */
package/dist/outbox.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { BLOB_COMMIT_REFS } from './blob.js';
2
+ export const OUTBOX_TABLE = '_syncular_outbox';
3
+ export const OUTBOX_IMAGES = '_syncular_outbox_before_images';
4
+ import { overlayScopePredicate } from './failed-overlay.js';
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
@@ -12,15 +16,15 @@ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, before
12
16
  if (operations.length === 0) {
13
17
  throw new ClientSyncError('sync.empty_commit', 'a local commit must carry at least one operation (§6.1)');
14
18
  }
15
- db.exec('INSERT INTO _syncular_outbox(client_commit_id,created_at_ms,operations)VALUES(?,?,?)', [clientCommitId, nowMs, JSON.stringify(operations)]);
19
+ db.exec(`INSERT INTO ${OUTBOX_TABLE}(client_commit_id,created_at_ms,operations)VALUES(?,?,?)`, [clientCommitId, nowMs, JSON.stringify(operations)]);
16
20
  replaceOutboxBeforeImages(db, clientCommitId, beforeImages);
17
21
  }
18
22
  /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
19
- export function listOutbox(db, rowKeys) {
23
+ export function listOutbox(db, scope) {
20
24
  return db
21
- .query(rowKeys === undefined
22
- ? 'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox ORDER BY seq ASC'
23
- : "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", rowKeys && [JSON.stringify(rowKeys)])
25
+ .query(scope === undefined
26
+ ? `SELECT * FROM ${OUTBOX_TABLE} ORDER BY seq ASC`
27
+ : `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`, scope && [JSON.stringify(scope)])
24
28
  .map(decodeOutboxRow);
25
29
  }
26
30
  function decodeOutboxRow(row) {
@@ -35,7 +39,7 @@ function decodeOutboxRow(row) {
35
39
  export function* iterateOutbox(db, throughSeq) {
36
40
  let afterSeq = 0;
37
41
  while (afterSeq < throughSeq) {
38
- const rows = db.query('SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32', [afterSeq, throughSeq]);
42
+ const rows = db.query(`SELECT * FROM ${OUTBOX_TABLE} WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`, [afterSeq, throughSeq]);
39
43
  if (rows.length === 0)
40
44
  return;
41
45
  for (const row of rows) {
@@ -47,21 +51,23 @@ export function* iterateOutbox(db, throughSeq) {
47
51
  }
48
52
  /** Routine status reads never load operation bodies. */
49
53
  export function countOutbox(db) {
50
- return db.query('SELECT COUNT(*)AS count FROM _syncular_outbox')[0]
54
+ return db.query(`SELECT COUNT(*)AS count FROM ${OUTBOX_TABLE}`)[0]
51
55
  .count;
52
56
  }
53
57
  export function deleteOutboxCommit(db, clientCommitId) {
54
- db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id=?', [
58
+ db.exec(`DELETE FROM ${BLOB_COMMIT_REFS} WHERE commit_id=?`, [
55
59
  clientCommitId,
56
60
  ]);
57
- db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=?', [clientCommitId]);
58
- db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id=?', [
61
+ db.exec(`DELETE FROM ${OUTBOX_IMAGES} WHERE client_commit_id=?`, [
62
+ clientCommitId,
63
+ ]);
64
+ db.exec(`DELETE FROM ${OUTBOX_TABLE} WHERE client_commit_id=?`, [
59
65
  clientCommitId,
60
66
  ]);
61
67
  }
62
68
  export function listOutboxBeforeImages(db, clientCommitId) {
63
69
  return db
64
- .query('SELECT op_index,existed,sync_version,values_json,delivery_seq FROM _syncular_outbox_before_images WHERE client_commit_id=? ORDER BY op_index', [clientCommitId])
70
+ .query(`SELECT op_index,existed,sync_version,values_json,delivery_seq FROM ${OUTBOX_IMAGES} WHERE client_commit_id=? ORDER BY op_index`, [clientCommitId])
65
71
  .map((row) => ({
66
72
  opIndex: row.op_index,
67
73
  existed: row.existed === 1,
@@ -80,7 +86,7 @@ export function listOutboxBeforeImages(db, clientCommitId) {
80
86
  }
81
87
  export function replaceOutboxBeforeImages(db, clientCommitId, replacements) {
82
88
  for (const image of replacements) {
83
- db.exec('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', [
89
+ db.exec(`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`, [
84
90
  clientCommitId,
85
91
  image.opIndex,
86
92
  image.existed ? 1 : 0,
@@ -1,3 +1,4 @@
1
+ export declare const OUTCOMES_TABLE = "_syncular_commit_outcomes";
1
2
  /**
2
3
  * Durable per-client commit outcomes.
3
4
  *
package/dist/outcomes.js 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.js';
2
4
  import { ClientSyncError } from './errors.js';
3
5
  import { mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
@@ -74,7 +76,7 @@ function parseOutcome(db, row) {
74
76
  };
75
77
  }
76
78
  export function recordCommitOutcome(db, outcome) {
77
- db.exec("INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')", [
79
+ db.exec(`INSERT INTO ${OUTCOMES_TABLE}(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')`, [
78
80
  outcome.clientCommitId,
79
81
  outcome.status,
80
82
  outcome.recordedAtMs,
@@ -86,7 +88,9 @@ export function recordCommitOutcome(db, outcome) {
86
88
  return commitOutcome(db, outcome.clientCommitId);
87
89
  }
88
90
  export function commitOutcome(db, clientCommitId) {
89
- const row = db.query('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=?', [clientCommitId])[0];
91
+ const row = db.query(`${SELECT_OUTCOME} WHERE client_commit_id=?`, [
92
+ clientCommitId,
93
+ ])[0];
90
94
  return row === undefined ? undefined : parseOutcome(db, row);
91
95
  }
92
96
  export function listCommitOutcomes(db, query = {}) {
@@ -97,11 +101,11 @@ export function listCommitOutcomes(db, query = {}) {
97
101
  const where = query.activeOnly
98
102
  ? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
99
103
  : '';
100
- const rows = db.query(`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 ?'}`, limit === undefined ? [] : [limit]);
104
+ const rows = db.query(`${SELECT_OUTCOME} ${where} ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
101
105
  return rows.map((row) => parseOutcome(db, row));
102
106
  }
103
107
  export function persistCommitOutcomeResolution(db, input, nowMs) {
104
- db.exec("UPDATE _syncular_commit_outcomes SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'", [
108
+ db.exec(`UPDATE ${OUTCOMES_TABLE} SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'`, [
105
109
  input.resolution,
106
110
  nowMs,
107
111
  input.replacementClientCommitId ?? null,
@@ -117,13 +121,14 @@ export function pruneCommitOutcomes(db, maxEntries) {
117
121
  if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
118
122
  throw new ClientSyncError('sync.invalid_request', 'outcome retention maxEntries must be a positive safe integer');
119
123
  }
120
- const count = db.query('SELECT COUNT(*) AS count FROM _syncular_commit_outcomes')[0]?.count;
124
+ const count = db.query(`SELECT COUNT(*) AS count FROM ${OUTCOMES_TABLE}`)[0]
125
+ ?.count;
121
126
  const excess = Math.max(0, (count ?? 0) - maxEntries);
122
127
  if (excess === 0)
123
128
  return 0;
124
- const candidates = db.query("SELECT seq FROM _syncular_commit_outcomes WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?", [excess]);
129
+ const candidates = db.query(`SELECT seq FROM ${OUTCOMES_TABLE} WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?`, [excess]);
125
130
  for (const candidate of candidates) {
126
- db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq=?', [
131
+ db.exec(`DELETE FROM ${OUTCOMES_TABLE} WHERE seq=?`, [
127
132
  candidate.seq,
128
133
  ]);
129
134
  }
@@ -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,