@catalyst-cloud/replicate 0.1.48 → 0.1.49

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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/replicate.ts +95 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/replicate",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
4
4
  "type": "module",
5
5
  "description": "Runtime-agnostic replica write path — applyDelta / truncateReplica / cursor over a portable ReplicaWriteDb (ADR-0002). Shared by the host-sync bun:sqlite replica and the browser OPFS replica so the two apply paths can't drift.",
6
6
  "license": "MIT",
@@ -25,11 +25,11 @@
25
25
  "build": "tsc -p tsconfig.build.json"
26
26
  },
27
27
  "dependencies": {
28
- "@catalyst-cloud/schema": "0.1.54"
28
+ "@catalyst-cloud/schema": "0.1.55"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@catalyst-cloud/typescript-config": "workspace:*",
32
- "@types/node": "^26.3.0",
32
+ "@types/node": "^26.5.1",
33
33
  "typescript": "^7.0.2",
34
34
  "vitest": "^4.1.11"
35
35
  }
package/src/replicate.ts CHANGED
@@ -79,6 +79,16 @@ function quoteIdent(ident: string): string {
79
79
  return `"${ident.replace(/"/g, '""')}"`;
80
80
  }
81
81
 
82
+ /**
83
+ * Columns the REPLICA stamps itself rather than copying from the wire — the same self-stamped rule the
84
+ * mirror's upsert guard documents (apps/mirror/src/do/upsert.ts, CTC-279). `synced_at` is written by
85
+ * the replica's own apply loop, never by the mirror, so it carries no information about whether the
86
+ * SOURCE row changed: including it in the equal-timestamp content diff below would make the diff
87
+ * vacuously true for every delivery and turn every duplicate into a write (and a `changes > 0` report)
88
+ * — defeating the guard the diff exists to narrow. Excluded from the COMPARISON, not from the write.
89
+ */
90
+ const SELF_STAMPED_COLUMNS = new Set(["synced_at"]);
91
+
82
92
  function metaFor(entity: string): TableMeta | undefined {
83
93
  return (MIRROR_TABLE_META as Record<string, TableMeta>)[entity];
84
94
  }
@@ -176,10 +186,46 @@ function applyUpsert<B>(
176
186
  const setClause = nonPkCols
177
187
  .map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`)
178
188
  .join(", ");
179
- // Last-write-wins by updated_at — mirrors the DO's guard so out-of-order deltas can't regress.
180
- const guard = hasUpdatedAt
181
- ? ` WHERE excluded.updated_at > ${quoteIdent(table)}.updated_at`
182
- : "";
189
+ // Last-write-wins by updated_at — mirrors the DO's guard (apps/mirror/src/do/upsert.ts) so
190
+ // out-of-order deltas can't regress.
191
+ //
192
+ // CTC-1885 — the guard now carries the mirror's EQUAL-TIMESTAMP content-diff arm, ported from that
193
+ // file's CTC-673 rule, because a delta's `updated_at` is the PROVIDER's event time and does not
194
+ // necessarily advance when the row changes. The exact case: CTC-1885's rename cascade re-stamps
195
+ // `issues.state` / `team_workflow_mapping.linear_state_name` while DELIBERATELY preserving the
196
+ // source `updated_at` (advancing it would corrupt freshness, board ranking, and last-write-wins
197
+ // ordering). Under the old strict `>` alone, that correction was dropped here forever — the stored
198
+ // row's own updated_at never moves either, so no later delta could ever repair it. Parity with the
199
+ // mirror writer means a correction the mirror accepted lands on the replica too.
200
+ let guard = "";
201
+ if (hasUpdatedAt) {
202
+ // (1) strictly newer always wins — unchanged, and still the load-bearing drop of a stale
203
+ // out-of-order delivery;
204
+ // (2) OR equal timestamp AND at least one non-PK SOURCE column arrives non-null and different
205
+ // (a byte-identical redelivery stays a no-op; an incoming null is never a difference — a
206
+ // list-shaped feed omitting a field must not erase the resolved value the row holds);
207
+ // (3) the equal-timestamp arm NEVER fires on a stored tombstone — a redelivered pre-delete
208
+ // upsert carrying `removed_at: null` must not resurrect a deleted object the provider can
209
+ // no longer correct.
210
+ // `IS` / `IS NOT` (not `=` / `<>`) so every comparison is NULL-safe. `updated_at` itself is
211
+ // excluded from the diff: inside the equal-timestamp arm the two are equal by construction.
212
+ // Self-stamped columns are excluded for the reason in SELF_STAMPED_COLUMNS's doc.
213
+ const diffCols = nonPkCols.filter((c) => !SELF_STAMPED_COLUMNS.has(c) && c !== "updated_at");
214
+ const sameStampDiff = diffCols
215
+ .map(
216
+ (c) =>
217
+ `(excluded.${quoteIdent(c)} IS NOT NULL AND ${quoteIdent(table)}.${quoteIdent(c)} IS NOT excluded.${quoteIdent(c)})`,
218
+ )
219
+ .join(" OR ");
220
+ const tombstoneGuard = cols.includes("removed_at")
221
+ ? ` AND ${quoteIdent(table)}.removed_at IS NULL`
222
+ : "";
223
+ const sameStampButChanged =
224
+ diffCols.length > 0
225
+ ? ` OR (excluded.updated_at IS ${quoteIdent(table)}.updated_at${tombstoneGuard} AND (${sameStampDiff}))`
226
+ : "";
227
+ guard = ` WHERE excluded.updated_at > ${quoteIdent(table)}.updated_at${sameStampButChanged}`;
228
+ }
183
229
  conflictClause = `ON CONFLICT(${conflictTarget}) DO UPDATE SET ${setClause}${guard}`;
184
230
  }
185
231
 
@@ -236,11 +282,56 @@ function pkValuesFor<B>(
236
282
  /**
237
283
  * Wipe every replica entity table (but NOT the host-only `sync_meta` cursor table). Called before
238
284
  * replaying a fresh /snapshot so a resync can't leave orphaned rows the new snapshot no longer contains.
285
+ *
286
+ * CTC-3051: a resync also repairs every FTS5 search index over a table it wipes, in three steps.
287
+ * 1. `rebuild` each index from its table while the rows are still there, so the index equals the
288
+ * table whatever drift it held.
289
+ * 2. Delete the rows. Each delete trigger's FTS5 'delete' carries the row's current text, and after
290
+ * step 1 it matches the entry exactly.
291
+ * 3. `delete-all` each index, which leaves it empty even if a trigger was missing or wrong.
292
+ *
293
+ * ⛔ THE REBUILD MUST RUN BEFORE THE ROWS GO. An index missing a row's entry (an interrupted first
294
+ * build, a lost trigger write) or holding stale text for it (an `INSERT OR REPLACE`) makes that row's
295
+ * 'delete' raise "database disk image is malformed", which rolls the DELETE back, and every retry of
296
+ * the resync then fails the same way. `delete-all` first does not help: the 'delete' against an
297
+ * emptied index throws too (measured in node:sqlite and bun:sqlite).
298
+ *
299
+ * The rebuild reads every row of each indexed table once. A replica's reads are local and unbilled,
300
+ * and the resync is about to rewrite every row anyway. The snapshot's inserts re-index each row through
301
+ * the insert trigger, and the caller then runs read-model's `rebuildSearchIndex` once the snapshot has
302
+ * applied.
239
303
  */
240
304
  export function truncateReplica<B>(db: ReplicaWriteDb<B>): void {
305
+ const indexes = searchIndexesOver(db, new Set(Object.keys(MIRROR_TABLE_META)));
306
+ for (const fts of indexes) {
307
+ db.run(`INSERT INTO ${quoteIdent(fts)}(${quoteIdent(fts)}) VALUES ('rebuild')`);
308
+ }
241
309
  for (const entity of Object.keys(MIRROR_TABLE_META)) {
242
310
  db.run(`DELETE FROM ${quoteIdent(entity)}`);
243
311
  }
312
+ for (const fts of indexes) {
313
+ db.run(`INSERT INTO ${quoteIdent(fts)}(${quoteIdent(fts)}) VALUES ('delete-all')`);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * The external-content FTS5 tables in this store whose content table is in `tables`, read from
319
+ * sqlite_master (the read-model defines them; this package does not import it, so a store built by
320
+ * any read-model version is handled by what it holds). One row back: `ReplicaWriteDb.get` returns
321
+ * only the first row, so the names come as one JSON array.
322
+ */
323
+ function searchIndexesOver<B>(db: ReplicaWriteDb<B>, tables: ReadonlySet<string>): string[] {
324
+ const row = db.get(
325
+ `SELECT json_group_array(json_array(name, sql)) AS j FROM sqlite_master
326
+ WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%' AND lower(sql) LIKE '%using fts5%'`,
327
+ );
328
+ const pairs = JSON.parse(String(row?.["j"] ?? "[]")) as [string, string][];
329
+ return pairs
330
+ .filter(([, ddl]) => {
331
+ const content = /content\s*=\s*'([^']*)'/i.exec(ddl)?.[1];
332
+ return content !== undefined && tables.has(content);
333
+ })
334
+ .map(([name]) => name);
244
335
  }
245
336
 
246
337
  /** Read the persisted change-feed cursor (or null if a snapshot has never completed). */