@catalyst-cloud/replicate 0.1.1 → 0.1.3
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 +4 -8
- package/{dist/index.js → src/index.ts} +2 -0
- package/src/replicate.ts +242 -0
- package/dist/index.d.ts +0 -2
- package/dist/replicate.d.ts +0 -41
- package/dist/replicate.js +0 -112
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalyst-cloud/replicate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
@@ -10,17 +10,13 @@
|
|
|
10
10
|
"directory": "packages/replicate"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
|
-
"
|
|
13
|
+
"src"
|
|
14
14
|
],
|
|
15
15
|
"publishConfig": {
|
|
16
16
|
"access": "public"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
|
-
".":
|
|
20
|
-
"types": "./dist/index.d.ts",
|
|
21
|
-
"import": "./dist/index.js",
|
|
22
|
-
"default": "./dist/index.js"
|
|
23
|
-
}
|
|
19
|
+
".": "./src/index.ts"
|
|
24
20
|
},
|
|
25
21
|
"scripts": {
|
|
26
22
|
"typecheck": "tsc --noEmit",
|
|
@@ -29,7 +25,7 @@
|
|
|
29
25
|
"build": "tsc -p tsconfig.build.json"
|
|
30
26
|
},
|
|
31
27
|
"dependencies": {
|
|
32
|
-
"@catalyst-cloud/schema": "^0.1.
|
|
28
|
+
"@catalyst-cloud/schema": "^0.1.3"
|
|
33
29
|
},
|
|
34
30
|
"devDependencies": {
|
|
35
31
|
"@catalyst-cloud/typescript-config": "workspace:*",
|
|
@@ -4,4 +4,6 @@
|
|
|
4
4
|
// lands a change-feed record into a local SQLite replica over the portable `ReplicaWriteDb` handle, so
|
|
5
5
|
// the IDENTICAL apply logic runs unchanged in the host-sync bun:sqlite replica and the browser OPFS
|
|
6
6
|
// wasm replica — collapsing the two hand-maintained `apply.ts` twins into one source of truth.
|
|
7
|
+
|
|
7
8
|
export { applyDelta, truncateReplica, getCursor, setCursor } from "./replicate.js";
|
|
9
|
+
export type { ReplicaWriteDb, ReplicaChange, ToBindable, ApplyOptions } from "./replicate.js";
|
package/src/replicate.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// @catalyst-cloud/replicate — the runtime-agnostic replica WRITE path (ADR-0002). The write
|
|
2
|
+
// counterpart to @catalyst-cloud/read-model: one `applyDelta` (+ cursor + truncate) that lands a
|
|
3
|
+
// change-feed record into a local SQLite replica, schema-driven from the one Drizzle SSOT
|
|
4
|
+
// (@catalyst-cloud/schema MIRROR_TABLE_META). The host-sync bun:sqlite replica and the browser OPFS
|
|
5
|
+
// wasm replica BOTH route through this — collapsing the two hand-maintained `apply.ts` twins into one,
|
|
6
|
+
// so they can never drift. Dependency-free / runtime-agnostic by design (NO bun:sqlite / node imports);
|
|
7
|
+
// the runtime engine adapts to the portable `ReplicaWriteDb` handle, exactly as the read-model's
|
|
8
|
+
// `SqlExecutor` is adapted per runtime.
|
|
9
|
+
//
|
|
10
|
+
// WIRE CONTRACT (shared — see apps/mirror/src/do/changefeed.ts):
|
|
11
|
+
// • op:"upsert" → row is the FULL normalized DO row; INSERT … ON CONFLICT(pk) DO UPDATE, last-write-
|
|
12
|
+
// wins by updated_at where present (DO NOTHING for a pure-join row like issue_labels).
|
|
13
|
+
// • op:"delete" → soft-delete (set removed_at) on tables with a removed_at column, else hard-delete;
|
|
14
|
+
// the PK is recovered from the row's PK columns or by splitting entityId on ':'.
|
|
15
|
+
|
|
16
|
+
import { MIRROR_TABLE_META, type TableMeta } from "@catalyst-cloud/schema";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The portable replica WRITE seam — the write counterpart to read-model's `SqlExecutor`. bun:sqlite
|
|
20
|
+
* (host-sync) and sqlite-wasm (browser OPFS) each satisfy it via a thin adapter. Generic over the
|
|
21
|
+
* bindable type `B` because the two engines coerce binary/bigint differently (bun binds Uint8Array /
|
|
22
|
+
* bigint; wasm binds ArrayBuffer / number) — the runtime supplies its own `toBindable` so every bound
|
|
23
|
+
* value is already a `B`.
|
|
24
|
+
*/
|
|
25
|
+
export interface ReplicaWriteDb<B> {
|
|
26
|
+
/** Execute a parameterized mutation; return the number of rows changed (sqlite3_changes()). */
|
|
27
|
+
run(sql: string, ...bindings: B[]): number;
|
|
28
|
+
/** Run a single-row query; return the first row as an object, or undefined. */
|
|
29
|
+
get(sql: string, ...bindings: B[]): Record<string, B> | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Coerce ONE wire JSON value to the runtime's bindable scalar `B` (booleans → 0/1, etc.). Runtime-
|
|
33
|
+
* specific (binary/bigint differ per engine), so it is injected rather than baked in. */
|
|
34
|
+
export type ToBindable<B> = (value: unknown) => B;
|
|
35
|
+
|
|
36
|
+
/** One change-feed record (a /snapshot line or a /changes row). accountId/seq are the caller's concern.
|
|
37
|
+
* `entity` is widened to string here (the runtime callers pass their stricter EntityName). */
|
|
38
|
+
export interface ReplicaChange {
|
|
39
|
+
entity: string;
|
|
40
|
+
op: "upsert" | "delete";
|
|
41
|
+
/** Full normalized row for "upsert"; for "delete" may be `{}` or carry just the PK columns. */
|
|
42
|
+
row: Record<string, unknown>;
|
|
43
|
+
/** The change_log.entity_id — the PK (composite PKs joined with ':'). Required to apply a delete. */
|
|
44
|
+
entityId?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Forward-compatibility options for applying a delta (CTC-127). The mirror (server) and the replica
|
|
49
|
+
* (client) upgrade INDEPENDENTLY — the server is often AHEAD, emitting a normalized row with columns a
|
|
50
|
+
* migration added that this client's schema doesn't have yet. Without this, `applyUpsert` would
|
|
51
|
+
* `INSERT INTO <table> (…, <new_col>, …)` and SQLite throws `no such column` (SQLITE_ERROR / errno:1),
|
|
52
|
+
* leaving that row permanently stale in the replica.
|
|
53
|
+
*
|
|
54
|
+
* `applyUpsert` DROPS any row key the client's schema lacks — storing what it can and ignoring what it
|
|
55
|
+
* can't, so a server ahead of the client degrades gracefully instead of poisoning the apply.
|
|
56
|
+
* Client-ahead is already safe (a column the server hasn't started emitting is simply absent from the
|
|
57
|
+
* row → not set). This filtering is AUTOMATIC: `knownColumns` DEFAULTS to the bundled schema's columns
|
|
58
|
+
* (`TableMeta.columns`), so no caller has to wire anything — these options are purely for OVERRIDE
|
|
59
|
+
* (a caller that wants PRAGMA-accurate columns) and for the `onDroppedColumns` drift signal.
|
|
60
|
+
*/
|
|
61
|
+
export interface ApplyOptions {
|
|
62
|
+
/** OVERRIDE the automatic default (the bundled schema's columns) with the columns the LOCAL table
|
|
63
|
+
* actually has, for the delta's `entity`. Row keys outside the set are dropped before the INSERT; PK
|
|
64
|
+
* columns are always local, so they are never dropped. Omit to use the bundled-schema default. */
|
|
65
|
+
knownColumns?: ReadonlySet<string>;
|
|
66
|
+
/** Reported (per apply, under the default OR an explicit `knownColumns`) with the row keys dropped
|
|
67
|
+
* because the client's schema lacks them, so the caller can warn-once / surface the cloud→client
|
|
68
|
+
* schema drift. Never fires when nothing was dropped. */
|
|
69
|
+
onDroppedColumns?: (table: string, dropped: readonly string[]) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function quoteIdent(ident: string): string {
|
|
73
|
+
return `"${ident.replace(/"/g, '""')}"`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function metaFor(entity: string): TableMeta | undefined {
|
|
77
|
+
return (MIRROR_TABLE_META as Record<string, TableMeta>)[entity];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* CTC-127: per-entity known-column Sets, built ONCE from the BUNDLED schema (`TableMeta.columns`, the
|
|
82
|
+
* same getTableConfig-derived columns the replica's `applyMigrations` migrates the DB to). This is the
|
|
83
|
+
* AUTOMATIC default `knownColumns` for `applyUpsert` — so a mirror emitting a column this client's
|
|
84
|
+
* schema doesn't have yet is handled with ZERO per-caller wiring (no call site can forget it). A client
|
|
85
|
+
* bundles its `@catalyst-cloud/replicate` and `@catalyst-cloud/schema` together, so this set is exactly
|
|
86
|
+
* "the columns this client knows".
|
|
87
|
+
*/
|
|
88
|
+
const KNOWN_COLUMNS: Record<string, ReadonlySet<string>> = Object.fromEntries(
|
|
89
|
+
Object.entries(MIRROR_TABLE_META).map(([entity, m]) => [
|
|
90
|
+
entity,
|
|
91
|
+
new Set((m as TableMeta).columns),
|
|
92
|
+
]),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Apply ONE change-feed record to the replica. Returns true iff a row was actually written (a stale
|
|
97
|
+
* upsert rejected by the updated_at guard, or a delete of an already-absent row, returns false). Never
|
|
98
|
+
* throws on a well-formed record; throws only on an unknown entity (malformed wire data).
|
|
99
|
+
*/
|
|
100
|
+
export function applyDelta<B>(
|
|
101
|
+
db: ReplicaWriteDb<B>,
|
|
102
|
+
change: ReplicaChange,
|
|
103
|
+
toBindable: ToBindable<B>,
|
|
104
|
+
opts?: ApplyOptions,
|
|
105
|
+
): boolean {
|
|
106
|
+
const meta = metaFor(change.entity);
|
|
107
|
+
if (!meta) throw new Error(`applyDelta: unknown entity ${String(change.entity)}`);
|
|
108
|
+
// A delete keys on PK columns only (always present in the local schema), so it needs no
|
|
109
|
+
// forward-compat column filtering — only the upsert path binds the full wire row.
|
|
110
|
+
if (change.op === "delete") return applyDelete(db, change, meta, toBindable);
|
|
111
|
+
return applyUpsert(db, change, meta, toBindable, opts);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function applyUpsert<B>(
|
|
115
|
+
db: ReplicaWriteDb<B>,
|
|
116
|
+
change: ReplicaChange,
|
|
117
|
+
meta: TableMeta,
|
|
118
|
+
toBindable: ToBindable<B>,
|
|
119
|
+
opts?: ApplyOptions,
|
|
120
|
+
): boolean {
|
|
121
|
+
const table = change.entity;
|
|
122
|
+
let cols = Object.keys(change.row);
|
|
123
|
+
// CTC-127 forward-compat (AUTOMATIC): drop any row key the local schema lacks — a column the mirror
|
|
124
|
+
// added ahead of this client's bundled schema. Without this the INSERT would name a nonexistent
|
|
125
|
+
// column and SQLite throws `no such column` (errno:1), stranding the row stale. `knownColumns`
|
|
126
|
+
// DEFAULTS to the bundled schema's columns (KNOWN_COLUMNS), so this protects EVERY caller with no
|
|
127
|
+
// wiring; an explicit `opts.knownColumns` OVERRIDES it (e.g. PRAGMA-accurate columns). `cols.every`
|
|
128
|
+
// short-circuits allocation-free in the common no-drift case. The `known.size > 0` guard falls back
|
|
129
|
+
// to legacy bind-all if the map is somehow empty (schema version skew) — the SAFE direction (never
|
|
130
|
+
// strips a row to PK-only). PK columns are always local, so the conflict target is never dropped.
|
|
131
|
+
const known = opts?.knownColumns ?? KNOWN_COLUMNS[table];
|
|
132
|
+
if (known && known.size > 0 && !cols.every((c) => known.has(c))) {
|
|
133
|
+
const dropped = cols.filter((c) => !known.has(c));
|
|
134
|
+
opts?.onDroppedColumns?.(table, dropped);
|
|
135
|
+
cols = cols.filter((c) => known.has(c));
|
|
136
|
+
}
|
|
137
|
+
if (cols.length === 0) return false; // malformed wire upsert — skip rather than emit invalid SQL.
|
|
138
|
+
|
|
139
|
+
const pkSet = new Set<string>(meta.pk);
|
|
140
|
+
const nonPkCols = cols.filter((c) => !pkSet.has(c));
|
|
141
|
+
const hasUpdatedAt = cols.includes("updated_at");
|
|
142
|
+
|
|
143
|
+
const colList = cols.map(quoteIdent).join(", ");
|
|
144
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
145
|
+
const conflictTarget = meta.pk.map(quoteIdent).join(", ");
|
|
146
|
+
|
|
147
|
+
let conflictClause: string;
|
|
148
|
+
if (nonPkCols.length === 0) {
|
|
149
|
+
// Pure join/edge row (issue_labels): PK is the whole row — no-op on conflict.
|
|
150
|
+
conflictClause = `ON CONFLICT(${conflictTarget}) DO NOTHING`;
|
|
151
|
+
} else {
|
|
152
|
+
const setClause = nonPkCols
|
|
153
|
+
.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`)
|
|
154
|
+
.join(", ");
|
|
155
|
+
// Last-write-wins by updated_at — mirrors the DO's guard so out-of-order deltas can't regress.
|
|
156
|
+
const guard = hasUpdatedAt
|
|
157
|
+
? ` WHERE excluded.updated_at > ${quoteIdent(table)}.updated_at`
|
|
158
|
+
: "";
|
|
159
|
+
conflictClause = `ON CONFLICT(${conflictTarget}) DO UPDATE SET ${setClause}${guard}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const sql = `INSERT INTO ${quoteIdent(table)} (${colList}) VALUES (${placeholders}) ${conflictClause}`;
|
|
163
|
+
return db.run(sql, ...cols.map((c) => toBindable(change.row[c]))) > 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function applyDelete<B>(
|
|
167
|
+
db: ReplicaWriteDb<B>,
|
|
168
|
+
change: ReplicaChange,
|
|
169
|
+
meta: TableMeta,
|
|
170
|
+
toBindable: ToBindable<B>,
|
|
171
|
+
): boolean {
|
|
172
|
+
const table = change.entity;
|
|
173
|
+
|
|
174
|
+
const pkVals = pkValuesFor(change, meta, toBindable);
|
|
175
|
+
if (!pkVals) return false; // can't locate the row → nothing to do.
|
|
176
|
+
|
|
177
|
+
const where = meta.pk.map((c) => `${quoteIdent(c)} = ?`).join(" AND ");
|
|
178
|
+
|
|
179
|
+
if (meta.softDelete) {
|
|
180
|
+
// Soft-delete: set removed_at, but only if currently live (idempotent re-delete is a no-op).
|
|
181
|
+
return (
|
|
182
|
+
db.run(
|
|
183
|
+
`UPDATE ${quoteIdent(table)} SET removed_at = ? WHERE ${where} AND removed_at IS NULL`,
|
|
184
|
+
toBindable(Date.now()),
|
|
185
|
+
...pkVals,
|
|
186
|
+
) > 0
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Hard-delete (join/edge/GitHub tables have no removed_at).
|
|
191
|
+
return db.run(`DELETE FROM ${quoteIdent(table)} WHERE ${where}`, ...pkVals) > 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Resolve the PK column values for a delete from the row's PK fields first, then entityId, else null.
|
|
195
|
+
* Every value is routed through `toBindable` so the result is uniformly `B[]`. */
|
|
196
|
+
function pkValuesFor<B>(
|
|
197
|
+
change: ReplicaChange,
|
|
198
|
+
meta: TableMeta,
|
|
199
|
+
toBindable: ToBindable<B>,
|
|
200
|
+
): B[] | null {
|
|
201
|
+
const fromRow = meta.pk.map((c) => change.row[c]);
|
|
202
|
+
if (fromRow.every((v) => v !== undefined && v !== null)) {
|
|
203
|
+
return fromRow.map(toBindable);
|
|
204
|
+
}
|
|
205
|
+
if (change.entityId != null) {
|
|
206
|
+
const parts = change.entityId.split(":");
|
|
207
|
+
if (parts.length === meta.pk.length) return parts.map((p) => toBindable(p));
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Wipe every replica entity table (but NOT the host-only `sync_meta` cursor table). Called before
|
|
214
|
+
* replaying a fresh /snapshot so a resync can't leave orphaned rows the new snapshot no longer contains.
|
|
215
|
+
*/
|
|
216
|
+
export function truncateReplica<B>(db: ReplicaWriteDb<B>): void {
|
|
217
|
+
for (const entity of Object.keys(MIRROR_TABLE_META)) {
|
|
218
|
+
db.run(`DELETE FROM ${quoteIdent(entity)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Read the persisted change-feed cursor (or null if a snapshot has never completed). */
|
|
223
|
+
export function getCursor<B>(db: ReplicaWriteDb<B>): number | null {
|
|
224
|
+
const row = db.get("SELECT value FROM sync_meta WHERE key = 'cursor'");
|
|
225
|
+
if (!row) return null;
|
|
226
|
+
const n = Number(row["value"]);
|
|
227
|
+
return Number.isFinite(n) ? n : null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Persist the change-feed cursor (the last applied change_log.seq). */
|
|
231
|
+
export function setCursor<B>(
|
|
232
|
+
db: ReplicaWriteDb<B>,
|
|
233
|
+
cursor: number,
|
|
234
|
+
toBindable: ToBindable<B>,
|
|
235
|
+
): void {
|
|
236
|
+
db.run(
|
|
237
|
+
"INSERT INTO sync_meta (key, value) VALUES (?, ?) " +
|
|
238
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
239
|
+
toBindable("cursor"),
|
|
240
|
+
toBindable(String(cursor)),
|
|
241
|
+
);
|
|
242
|
+
}
|
package/dist/index.d.ts
DELETED
package/dist/replicate.d.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The portable replica WRITE seam — the write counterpart to read-model's `SqlExecutor`. bun:sqlite
|
|
3
|
-
* (host-sync) and sqlite-wasm (browser OPFS) each satisfy it via a thin adapter. Generic over the
|
|
4
|
-
* bindable type `B` because the two engines coerce binary/bigint differently (bun binds Uint8Array /
|
|
5
|
-
* bigint; wasm binds ArrayBuffer / number) — the runtime supplies its own `toBindable` so every bound
|
|
6
|
-
* value is already a `B`.
|
|
7
|
-
*/
|
|
8
|
-
export interface ReplicaWriteDb<B> {
|
|
9
|
-
/** Execute a parameterized mutation; return the number of rows changed (sqlite3_changes()). */
|
|
10
|
-
run(sql: string, ...bindings: B[]): number;
|
|
11
|
-
/** Run a single-row query; return the first row as an object, or undefined. */
|
|
12
|
-
get(sql: string, ...bindings: B[]): Record<string, B> | undefined;
|
|
13
|
-
}
|
|
14
|
-
/** Coerce ONE wire JSON value to the runtime's bindable scalar `B` (booleans → 0/1, etc.). Runtime-
|
|
15
|
-
* specific (binary/bigint differ per engine), so it is injected rather than baked in. */
|
|
16
|
-
export type ToBindable<B> = (value: unknown) => B;
|
|
17
|
-
/** One change-feed record (a /snapshot line or a /changes row). accountId/seq are the caller's concern.
|
|
18
|
-
* `entity` is widened to string here (the runtime callers pass their stricter EntityName). */
|
|
19
|
-
export interface ReplicaChange {
|
|
20
|
-
entity: string;
|
|
21
|
-
op: "upsert" | "delete";
|
|
22
|
-
/** Full normalized row for "upsert"; for "delete" may be `{}` or carry just the PK columns. */
|
|
23
|
-
row: Record<string, unknown>;
|
|
24
|
-
/** The change_log.entity_id — the PK (composite PKs joined with ':'). Required to apply a delete. */
|
|
25
|
-
entityId?: string;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Apply ONE change-feed record to the replica. Returns true iff a row was actually written (a stale
|
|
29
|
-
* upsert rejected by the updated_at guard, or a delete of an already-absent row, returns false). Never
|
|
30
|
-
* throws on a well-formed record; throws only on an unknown entity (malformed wire data).
|
|
31
|
-
*/
|
|
32
|
-
export declare function applyDelta<B>(db: ReplicaWriteDb<B>, change: ReplicaChange, toBindable: ToBindable<B>): boolean;
|
|
33
|
-
/**
|
|
34
|
-
* Wipe every replica entity table (but NOT the host-only `sync_meta` cursor table). Called before
|
|
35
|
-
* replaying a fresh /snapshot so a resync can't leave orphaned rows the new snapshot no longer contains.
|
|
36
|
-
*/
|
|
37
|
-
export declare function truncateReplica<B>(db: ReplicaWriteDb<B>): void;
|
|
38
|
-
/** Read the persisted change-feed cursor (or null if a snapshot has never completed). */
|
|
39
|
-
export declare function getCursor<B>(db: ReplicaWriteDb<B>): number | null;
|
|
40
|
-
/** Persist the change-feed cursor (the last applied change_log.seq). */
|
|
41
|
-
export declare function setCursor<B>(db: ReplicaWriteDb<B>, cursor: number, toBindable: ToBindable<B>): void;
|
package/dist/replicate.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
// @catalyst-cloud/replicate — the runtime-agnostic replica WRITE path (ADR-0002). The write
|
|
2
|
-
// counterpart to @catalyst-cloud/read-model: one `applyDelta` (+ cursor + truncate) that lands a
|
|
3
|
-
// change-feed record into a local SQLite replica, schema-driven from the one Drizzle SSOT
|
|
4
|
-
// (@catalyst-cloud/schema MIRROR_TABLE_META). The host-sync bun:sqlite replica and the browser OPFS
|
|
5
|
-
// wasm replica BOTH route through this — collapsing the two hand-maintained `apply.ts` twins into one,
|
|
6
|
-
// so they can never drift. Dependency-free / runtime-agnostic by design (NO bun:sqlite / node imports);
|
|
7
|
-
// the runtime engine adapts to the portable `ReplicaWriteDb` handle, exactly as the read-model's
|
|
8
|
-
// `SqlExecutor` is adapted per runtime.
|
|
9
|
-
//
|
|
10
|
-
// WIRE CONTRACT (shared — see apps/mirror/src/do/changefeed.ts):
|
|
11
|
-
// • op:"upsert" → row is the FULL normalized DO row; INSERT … ON CONFLICT(pk) DO UPDATE, last-write-
|
|
12
|
-
// wins by updated_at where present (DO NOTHING for a pure-join row like issue_labels).
|
|
13
|
-
// • op:"delete" → soft-delete (set removed_at) on tables with a removed_at column, else hard-delete;
|
|
14
|
-
// the PK is recovered from the row's PK columns or by splitting entityId on ':'.
|
|
15
|
-
import { MIRROR_TABLE_META } from "@catalyst-cloud/schema";
|
|
16
|
-
function quoteIdent(ident) {
|
|
17
|
-
return `"${ident.replace(/"/g, '""')}"`;
|
|
18
|
-
}
|
|
19
|
-
function metaFor(entity) {
|
|
20
|
-
return MIRROR_TABLE_META[entity];
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Apply ONE change-feed record to the replica. Returns true iff a row was actually written (a stale
|
|
24
|
-
* upsert rejected by the updated_at guard, or a delete of an already-absent row, returns false). Never
|
|
25
|
-
* throws on a well-formed record; throws only on an unknown entity (malformed wire data).
|
|
26
|
-
*/
|
|
27
|
-
export function applyDelta(db, change, toBindable) {
|
|
28
|
-
const meta = metaFor(change.entity);
|
|
29
|
-
if (!meta)
|
|
30
|
-
throw new Error(`applyDelta: unknown entity ${String(change.entity)}`);
|
|
31
|
-
if (change.op === "delete")
|
|
32
|
-
return applyDelete(db, change, meta, toBindable);
|
|
33
|
-
return applyUpsert(db, change, meta, toBindable);
|
|
34
|
-
}
|
|
35
|
-
function applyUpsert(db, change, meta, toBindable) {
|
|
36
|
-
const table = change.entity;
|
|
37
|
-
const cols = Object.keys(change.row);
|
|
38
|
-
if (cols.length === 0)
|
|
39
|
-
return false; // malformed wire upsert — skip rather than emit invalid SQL.
|
|
40
|
-
const pkSet = new Set(meta.pk);
|
|
41
|
-
const nonPkCols = cols.filter((c) => !pkSet.has(c));
|
|
42
|
-
const hasUpdatedAt = cols.includes("updated_at");
|
|
43
|
-
const colList = cols.map(quoteIdent).join(", ");
|
|
44
|
-
const placeholders = cols.map(() => "?").join(", ");
|
|
45
|
-
const conflictTarget = meta.pk.map(quoteIdent).join(", ");
|
|
46
|
-
let conflictClause;
|
|
47
|
-
if (nonPkCols.length === 0) {
|
|
48
|
-
// Pure join/edge row (issue_labels): PK is the whole row — no-op on conflict.
|
|
49
|
-
conflictClause = `ON CONFLICT(${conflictTarget}) DO NOTHING`;
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
const setClause = nonPkCols
|
|
53
|
-
.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`)
|
|
54
|
-
.join(", ");
|
|
55
|
-
// Last-write-wins by updated_at — mirrors the DO's guard so out-of-order deltas can't regress.
|
|
56
|
-
const guard = hasUpdatedAt
|
|
57
|
-
? ` WHERE excluded.updated_at > ${quoteIdent(table)}.updated_at`
|
|
58
|
-
: "";
|
|
59
|
-
conflictClause = `ON CONFLICT(${conflictTarget}) DO UPDATE SET ${setClause}${guard}`;
|
|
60
|
-
}
|
|
61
|
-
const sql = `INSERT INTO ${quoteIdent(table)} (${colList}) VALUES (${placeholders}) ${conflictClause}`;
|
|
62
|
-
return db.run(sql, ...cols.map((c) => toBindable(change.row[c]))) > 0;
|
|
63
|
-
}
|
|
64
|
-
function applyDelete(db, change, meta, toBindable) {
|
|
65
|
-
const table = change.entity;
|
|
66
|
-
const pkVals = pkValuesFor(change, meta, toBindable);
|
|
67
|
-
if (!pkVals)
|
|
68
|
-
return false; // can't locate the row → nothing to do.
|
|
69
|
-
const where = meta.pk.map((c) => `${quoteIdent(c)} = ?`).join(" AND ");
|
|
70
|
-
if (meta.softDelete) {
|
|
71
|
-
// Soft-delete: set removed_at, but only if currently live (idempotent re-delete is a no-op).
|
|
72
|
-
return (db.run(`UPDATE ${quoteIdent(table)} SET removed_at = ? WHERE ${where} AND removed_at IS NULL`, toBindable(Date.now()), ...pkVals) > 0);
|
|
73
|
-
}
|
|
74
|
-
// Hard-delete (join/edge/GitHub tables have no removed_at).
|
|
75
|
-
return db.run(`DELETE FROM ${quoteIdent(table)} WHERE ${where}`, ...pkVals) > 0;
|
|
76
|
-
}
|
|
77
|
-
/** Resolve the PK column values for a delete from the row's PK fields first, then entityId, else null.
|
|
78
|
-
* Every value is routed through `toBindable` so the result is uniformly `B[]`. */
|
|
79
|
-
function pkValuesFor(change, meta, toBindable) {
|
|
80
|
-
const fromRow = meta.pk.map((c) => change.row[c]);
|
|
81
|
-
if (fromRow.every((v) => v !== undefined && v !== null)) {
|
|
82
|
-
return fromRow.map(toBindable);
|
|
83
|
-
}
|
|
84
|
-
if (change.entityId != null) {
|
|
85
|
-
const parts = change.entityId.split(":");
|
|
86
|
-
if (parts.length === meta.pk.length)
|
|
87
|
-
return parts.map((p) => toBindable(p));
|
|
88
|
-
}
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Wipe every replica entity table (but NOT the host-only `sync_meta` cursor table). Called before
|
|
93
|
-
* replaying a fresh /snapshot so a resync can't leave orphaned rows the new snapshot no longer contains.
|
|
94
|
-
*/
|
|
95
|
-
export function truncateReplica(db) {
|
|
96
|
-
for (const entity of Object.keys(MIRROR_TABLE_META)) {
|
|
97
|
-
db.run(`DELETE FROM ${quoteIdent(entity)}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
/** Read the persisted change-feed cursor (or null if a snapshot has never completed). */
|
|
101
|
-
export function getCursor(db) {
|
|
102
|
-
const row = db.get("SELECT value FROM sync_meta WHERE key = 'cursor'");
|
|
103
|
-
if (!row)
|
|
104
|
-
return null;
|
|
105
|
-
const n = Number(row["value"]);
|
|
106
|
-
return Number.isFinite(n) ? n : null;
|
|
107
|
-
}
|
|
108
|
-
/** Persist the change-feed cursor (the last applied change_log.seq). */
|
|
109
|
-
export function setCursor(db, cursor, toBindable) {
|
|
110
|
-
db.run("INSERT INTO sync_meta (key, value) VALUES (?, ?) " +
|
|
111
|
-
"ON CONFLICT(key) DO UPDATE SET value = excluded.value", toBindable("cursor"), toBindable(String(cursor)));
|
|
112
|
-
}
|