@catalyst-cloud/replicate 0.1.47 → 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.
- package/package.json +3 -3
- 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.
|
|
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.
|
|
28
|
+
"@catalyst-cloud/schema": "0.1.55"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@catalyst-cloud/typescript-config": "workspace:*",
|
|
32
|
-
"@types/node": "^26.
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
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). */
|