@catalyst-cloud/replicate 0.1.2 → 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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/replicate.ts +39 -19
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.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",
@@ -25,7 +25,7 @@
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
31
  "@catalyst-cloud/typescript-config": "workspace:*",
package/src/replicate.ts CHANGED
@@ -51,18 +51,21 @@ 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;
67
70
  }
68
71
 
@@ -74,6 +77,21 @@ function metaFor(entity: string): TableMeta | undefined {
74
77
  return (MIRROR_TABLE_META as Record<string, TableMeta>)[entity];
75
78
  }
76
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
+
77
95
  /**
78
96
  * Apply ONE change-feed record to the replica. Returns true iff a row was actually written (a stale
79
97
  * upsert rejected by the updated_at guard, or a delete of an already-absent row, returns false). Never
@@ -102,17 +120,19 @@ function applyUpsert<B>(
102
120
  ): boolean {
103
121
  const table = change.entity;
104
122
  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;
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))) {
111
133
  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
- }
134
+ opts?.onDroppedColumns?.(table, dropped);
135
+ cols = cols.filter((c) => known.has(c));
116
136
  }
117
137
  if (cols.length === 0) return false; // malformed wire upsert — skip rather than emit invalid SQL.
118
138