@catalyst-cloud/replicate 0.1.2 → 0.1.4
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 +66 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalyst-cloud/replicate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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,10 +25,10 @@
|
|
|
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.3"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@catalyst-cloud/typescript-config": "
|
|
31
|
+
"@catalyst-cloud/typescript-config": "0.0.0",
|
|
32
32
|
"@types/node": "^25.9.3",
|
|
33
33
|
"typescript": "^5.6.3",
|
|
34
34
|
"vitest": "^2.1.8"
|
package/src/replicate.ts
CHANGED
|
@@ -51,19 +51,28 @@ export interface ReplicaChange {
|
|
|
51
51
|
* `INSERT INTO <table> (…, <new_col>, …)` and SQLite throws `no such column` (SQLITE_ERROR / errno:1),
|
|
52
52
|
* leaving that row permanently stale in the replica.
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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.
|
|
59
60
|
*/
|
|
60
61
|
export interface ApplyOptions {
|
|
61
|
-
/**
|
|
62
|
-
*
|
|
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. */
|
|
63
65
|
knownColumns?: ReadonlySet<string>;
|
|
64
|
-
/** Reported (per apply
|
|
65
|
-
* can warn-once / surface the cloud→client
|
|
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. */
|
|
66
69
|
onDroppedColumns?: (table: string, dropped: readonly string[]) => void;
|
|
70
|
+
/**
|
|
71
|
+
* CTC-393 — reported when `applyDelta` receives a delta for an entity this client's BUNDLED schema
|
|
72
|
+
* does not recognize AT ALL (the whole table is unknown, not merely a missing column). Never fires
|
|
73
|
+
* when the entity resolves. See {@link applyDelta}'s own doc for why this replaces a throw.
|
|
74
|
+
*/
|
|
75
|
+
onUnknownEntity?: (entity: string) => void;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
function quoteIdent(ident: string): string {
|
|
@@ -74,10 +83,40 @@ function metaFor(entity: string): TableMeta | undefined {
|
|
|
74
83
|
return (MIRROR_TABLE_META as Record<string, TableMeta>)[entity];
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
/**
|
|
87
|
+
* CTC-127: per-entity known-column Sets, built ONCE from the BUNDLED schema (`TableMeta.columns`, the
|
|
88
|
+
* same getTableConfig-derived columns the replica's `applyMigrations` migrates the DB to). This is the
|
|
89
|
+
* AUTOMATIC default `knownColumns` for `applyUpsert` — so a mirror emitting a column this client's
|
|
90
|
+
* schema doesn't have yet is handled with ZERO per-caller wiring (no call site can forget it). A client
|
|
91
|
+
* bundles its `@catalyst-cloud/replicate` and `@catalyst-cloud/schema` together, so this set is exactly
|
|
92
|
+
* "the columns this client knows".
|
|
93
|
+
*/
|
|
94
|
+
const KNOWN_COLUMNS: Record<string, ReadonlySet<string>> = Object.fromEntries(
|
|
95
|
+
Object.entries(MIRROR_TABLE_META).map(([entity, m]) => [
|
|
96
|
+
entity,
|
|
97
|
+
new Set((m as TableMeta).columns),
|
|
98
|
+
]),
|
|
99
|
+
);
|
|
100
|
+
|
|
77
101
|
/**
|
|
78
102
|
* Apply ONE change-feed record to the replica. Returns true iff a row was actually written (a stale
|
|
79
|
-
* upsert rejected by the updated_at guard, or a delete of an already-absent row, returns false).
|
|
80
|
-
*
|
|
103
|
+
* upsert rejected by the updated_at guard, or a delete of an already-absent row, returns false).
|
|
104
|
+
*
|
|
105
|
+
* ⛔ CTC-393 — NEVER THROWS, including on an unknown entity. It used to: an entity this client's
|
|
106
|
+
* bundled schema does not recognize AT ALL (the mirror started emitting a table this client predates
|
|
107
|
+
* — the exact shape of a NEW replicated entity rolling out to a fleet that upgrades independently)
|
|
108
|
+
* threw, and on the CURRENT SDK caller (catalyst-replica.ts) that throw propagates out of the SAME
|
|
109
|
+
* `engine.transaction(...)` callback that also calls `setCursor` — so the transaction rolls back,
|
|
110
|
+
* the cursor never advances past that seq, and the daemon's transport holds every LATER frame behind
|
|
111
|
+
* the hole (CTL-1402's own contiguity guarantee: "never delivers a frame beyond deliveredSeq+1").
|
|
112
|
+
* One new entity type permanently wedges an ENTIRE replica, silently, with no resync path — the
|
|
113
|
+
* exact review finding (CTC-393 / the closed PR #290) this fix closes.
|
|
114
|
+
*
|
|
115
|
+
* An unknown entity now returns `false` (nothing written — there is nowhere to put it) and reports
|
|
116
|
+
* via `opts.onUnknownEntity`, mirroring `onDroppedColumns`'s existing graceful-degrade posture. NO
|
|
117
|
+
* CALLER CHANGE IS REQUIRED for safety: `onUnknownEntity` is optional, so the EXISTING SDK code path
|
|
118
|
+
* (which doesn't set it) already gets the correct behavior — `setCursor` runs, the cursor advances,
|
|
119
|
+
* `recordApplyResult("skipped", ...)` fires — the moment this package version is on the fleet.
|
|
81
120
|
*/
|
|
82
121
|
export function applyDelta<B>(
|
|
83
122
|
db: ReplicaWriteDb<B>,
|
|
@@ -86,7 +125,10 @@ export function applyDelta<B>(
|
|
|
86
125
|
opts?: ApplyOptions,
|
|
87
126
|
): boolean {
|
|
88
127
|
const meta = metaFor(change.entity);
|
|
89
|
-
if (!meta)
|
|
128
|
+
if (!meta) {
|
|
129
|
+
opts?.onUnknownEntity?.(change.entity);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
90
132
|
// A delete keys on PK columns only (always present in the local schema), so it needs no
|
|
91
133
|
// forward-compat column filtering — only the upsert path binds the full wire row.
|
|
92
134
|
if (change.op === "delete") return applyDelete(db, change, meta, toBindable);
|
|
@@ -102,17 +144,19 @@ function applyUpsert<B>(
|
|
|
102
144
|
): boolean {
|
|
103
145
|
const table = change.entity;
|
|
104
146
|
let cols = Object.keys(change.row);
|
|
105
|
-
// CTC-127 forward-compat:
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
147
|
+
// CTC-127 forward-compat (AUTOMATIC): drop any row key the local schema lacks — a column the mirror
|
|
148
|
+
// added ahead of this client's bundled schema. Without this the INSERT would name a nonexistent
|
|
149
|
+
// column and SQLite throws `no such column` (errno:1), stranding the row stale. `knownColumns`
|
|
150
|
+
// DEFAULTS to the bundled schema's columns (KNOWN_COLUMNS), so this protects EVERY caller with no
|
|
151
|
+
// wiring; an explicit `opts.knownColumns` OVERRIDES it (e.g. PRAGMA-accurate columns). `cols.every`
|
|
152
|
+
// short-circuits allocation-free in the common no-drift case. The `known.size > 0` guard falls back
|
|
153
|
+
// to legacy bind-all if the map is somehow empty (schema version skew) — the SAFE direction (never
|
|
154
|
+
// strips a row to PK-only). PK columns are always local, so the conflict target is never dropped.
|
|
155
|
+
const known = opts?.knownColumns ?? KNOWN_COLUMNS[table];
|
|
156
|
+
if (known && known.size > 0 && !cols.every((c) => known.has(c))) {
|
|
111
157
|
const dropped = cols.filter((c) => !known.has(c));
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
cols = cols.filter((c) => known.has(c));
|
|
115
|
-
}
|
|
158
|
+
opts?.onDroppedColumns?.(table, dropped);
|
|
159
|
+
cols = cols.filter((c) => known.has(c));
|
|
116
160
|
}
|
|
117
161
|
if (cols.length === 0) return false; // malformed wire upsert — skip rather than emit invalid SQL.
|
|
118
162
|
|