@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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. 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.2",
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.0"
28
+ "@catalyst-cloud/schema": "^0.1.3"
29
29
  },
30
30
  "devDependencies": {
31
- "@catalyst-cloud/typescript-config": "workspace:*",
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
- * When `knownColumns` is supplied (the columns the LOCAL table actually has), `applyUpsert` DROPS any
55
- * row key not in the set — storing what it can and ignoring what it can't, so a server ahead of the
56
- * client degrades gracefully instead of poisoning the apply. Client-ahead is already safe (a column
57
- * the server hasn't started emitting is simply absent from the row → not set). Omit `knownColumns` for
58
- * the legacy behavior (bind every key; a drifted column throws).
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
- /** The columns the LOCAL replica table has, for the delta's `entity`. Row keys outside this set are
62
- * dropped before the INSERT. PK columns are always local, so they are never dropped. */
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) with the row keys dropped because the local table lacks them, so the caller
65
- * can warn-once / surface the cloud→client schema drift. Never fires when nothing was dropped. */
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). Never
80
- * throws on a well-formed record; throws only on an unknown entity (malformed wire data).
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) throw new Error(`applyDelta: unknown entity ${String(change.entity)}`);
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: when the caller passes the local table's columns, drop any row key the
106
- // local schema lacks (a mirror ahead of this client emits columns a migration added). Dropping them
107
- // stores what we can instead of throwing `no such column`. PK columns are always local, so this can
108
- // never strip the conflict target. Reported so the caller can warn-once about the drift.
109
- if (opts?.knownColumns) {
110
- const known = opts.knownColumns;
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
- if (dropped.length > 0) {
113
- opts.onDroppedColumns?.(table, dropped);
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