feather-cdc-postgres 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,283 @@
1
+ """Source type → destination type, and drift classification (#3079 D7).
2
+
3
+ Constraints:
4
+ - an unknown OID becomes text but NEVER silently: every fallback emits a Problem(warning)
5
+ naming schema.table.column and the OID.
6
+ - the integer family compiles to plain BIGINT on both the snapshot and the CDC path — dlt
7
+ renders precision 32 as INTEGER, and one live table already carries both spellings.
8
+ - drift kinds are a closed set; `severity == "breaking"` is what the runtime pauses on.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, replace
13
+ from typing import Dict, List, Optional, Sequence, Tuple
14
+
15
+ from pg_replication.schema_types import _PG_TYPES, _to_dlt_column_type
16
+
17
+ from .discovery import SourceRelation
18
+ from .manifest import Problem
19
+
20
+ # How faithfully the destination type carries the source value.
21
+ EXACT = "exact"
22
+ WIDENED = "widened" # a strictly larger destination type; no value is lost
23
+ EXACT_AS_TEXT = "exact_as_text" # round-trips through its text rendering (uuid)
24
+ LOSSY_TEXT = "lossy_text" # text rendering only; arithmetic/ordering is not preserved
25
+ TEXT_FALLBACK = "text_fallback" # no declared mapping — reported, never silent
26
+
27
+ _MAPPING: Dict[int, str] = {
28
+ 16: EXACT, 17: EXACT, 20: EXACT, 21: WIDENED, 23: WIDENED, 25: EXACT, 26: WIDENED,
29
+ 114: EXACT, 700: WIDENED, 701: EXACT, 1042: EXACT, 1043: EXACT, 1082: EXACT,
30
+ 1083: EXACT, 1114: EXACT, 1184: EXACT, 1700: EXACT, 3802: EXACT,
31
+ 19: EXACT_AS_TEXT, 2950: EXACT_AS_TEXT,
32
+ 1186: LOSSY_TEXT, 1266: LOSSY_TEXT,
33
+ }
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class CompiledType:
38
+ dest_type: str # dlt data_type: bigint | text | double | decimal | date | time | timestamp | json | bool | binary
39
+ precision: Optional[int] = None
40
+ scale: Optional[int] = None
41
+ mapping: str = EXACT
42
+ timezone: Optional[bool] = None
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class CompiledColumn:
47
+ name: str
48
+ type_oid: int
49
+ typmod: int
50
+ not_null: bool
51
+ key_role: str # key | none
52
+ compiled: CompiledType
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Drift:
57
+ kind: str
58
+ column: Optional[str]
59
+ detail: str
60
+ severity: str # additive | warning | breaking | info
61
+
62
+
63
+ def compile_column(type_oid: int, typmod: int, not_null: bool = False, is_array: bool = False) -> CompiledType:
64
+ """One source column's destination type. `not_null` is carried for the caller's schema hint;
65
+ it never changes the type (a NOT NULL column still lands nullable after a key-only delete)."""
66
+ if is_array:
67
+ # pgoutput ships arrays as their text rendering ('{a,b}'); nothing downstream parses it.
68
+ return CompiledType(dest_type="text", mapping=TEXT_FALLBACK)
69
+ dlt_type = _to_dlt_column_type(type_oid, typmod)
70
+ mapping = _MAPPING.get(type_oid, TEXT_FALLBACK)
71
+ return CompiledType(
72
+ dest_type=str(dlt_type["data_type"]),
73
+ precision=dlt_type.get("precision"),
74
+ scale=dlt_type.get("scale"),
75
+ mapping=mapping,
76
+ timezone=dlt_type.get("timezone"),
77
+ )
78
+
79
+
80
+ def compile_relation(
81
+ relation: SourceRelation, key: Tuple[str, ...] = ()
82
+ ) -> Tuple[List[CompiledColumn], List[Problem]]:
83
+ """Compile every column of one relation; surface each fallback as a warning."""
84
+ cols: List[CompiledColumn] = []
85
+ problems: List[Problem] = []
86
+ for c in relation.columns:
87
+ ct = compile_column(c.type_oid, c.typmod, c.not_null, c.is_array)
88
+ if ct.mapping == TEXT_FALLBACK:
89
+ reason = "array type" if c.is_array else f"OID {c.type_oid} has no declared mapping"
90
+ problems.append(
91
+ Problem(
92
+ "type_fallback",
93
+ "warning",
94
+ f"{relation.schema}.{relation.table}.{c.name} ({c.type_name}): {reason} — "
95
+ "landing as text; add it to schema_registry._MAPPING to make the choice explicit",
96
+ )
97
+ )
98
+ cols.append(
99
+ CompiledColumn(
100
+ name=c.name,
101
+ type_oid=c.type_oid,
102
+ typmod=c.typmod,
103
+ not_null=c.not_null,
104
+ key_role="key" if c.name in key else "none",
105
+ compiled=ct,
106
+ )
107
+ )
108
+ return cols, problems
109
+
110
+
111
+ def known_oids() -> Tuple[int, ...]:
112
+ return tuple(sorted(_PG_TYPES))
113
+
114
+
115
+ # --------------------------------------------------------------------------- signatures
116
+
117
+
118
+ def project(relation: SourceRelation, exclude: Sequence[str] = ()) -> SourceRelation:
119
+ """The relation as the destination sees it: denied columns (#4346) removed. Every
120
+ registry row, signature and drift diff runs on this, so a deny is never a `drop_column`."""
121
+ if not exclude:
122
+ return relation
123
+ denied = set(exclude)
124
+ return replace(relation, columns=[c for c in relation.columns if c.name not in denied])
125
+
126
+
127
+ def project_signature(sig: dict, exclude: Sequence[str] = ()) -> dict:
128
+ """A STORED signature projected the same way — one written before the deny existed still
129
+ names the column, and diffing it raw would report the deny as a breaking drop."""
130
+ if not exclude or not sig:
131
+ return sig
132
+ denied = set(exclude)
133
+ return {**sig, "columns": {k: v for k, v in sig.get("columns", {}).items() if k not in denied}}
134
+
135
+
136
+ def signature(relation: SourceRelation, key: Tuple[str, ...] = (),
137
+ exclude: Sequence[str] = ()) -> dict:
138
+ """The inspectable shape a drift comparison runs on. Plain dicts so it can be stored in
139
+ `_cdc_runtime.schema_registry` and diffed across runs without unpickling anything."""
140
+ relation = project(relation, exclude)
141
+ cols, _ = compile_relation(relation, key)
142
+ return {
143
+ "schema": relation.schema,
144
+ "table": relation.table,
145
+ "key": list(key),
146
+ "replica_identity": relation.replica_identity,
147
+ # Not compared by classify_drift: it keys the reviewed shape to the relation's OID, so
148
+ # the decoder's column guard (#4346) follows a table rename.
149
+ "oid": relation.oid,
150
+ "columns": {
151
+ c.name: {
152
+ "type_oid": c.type_oid,
153
+ "typmod": c.typmod,
154
+ "not_null": c.not_null,
155
+ "dest_type": c.compiled.dest_type,
156
+ "precision": c.compiled.precision,
157
+ "scale": c.compiled.scale,
158
+ "mapping": c.compiled.mapping,
159
+ }
160
+ for c in cols
161
+ },
162
+ }
163
+
164
+
165
+ _INT_RANK = {21: 1, 23: 2, 20: 3}
166
+
167
+
168
+ def _varchar_len(oid: int, typmod: int) -> Optional[int]:
169
+ if oid in (1043, 1042) and typmod != -1:
170
+ return typmod - 4
171
+ return None
172
+
173
+
174
+ def _numeric_ps(typmod: int) -> Tuple[Optional[int], Optional[int]]:
175
+ if typmod == -1:
176
+ return None, None
177
+ return ((typmod - 4) >> 16) & 65535, (typmod - 4) & 65535
178
+
179
+
180
+ def _is_widen(old: dict, new: dict) -> bool:
181
+ o, n = old["type_oid"], new["type_oid"]
182
+ if o == n:
183
+ if o in (1043, 1042):
184
+ ol, nl = _varchar_len(o, old["typmod"]), _varchar_len(n, new["typmod"])
185
+ return ol is not None and (nl is None or nl > ol)
186
+ if o == 1700:
187
+ op, os_ = _numeric_ps(old["typmod"])
188
+ np, ns = _numeric_ps(new["typmod"])
189
+ if op is None:
190
+ return False
191
+ return np is None or (np >= op and ns == os_ and np > op)
192
+ return False
193
+ if o in _INT_RANK and n in _INT_RANK:
194
+ return _INT_RANK[n] > _INT_RANK[o]
195
+ if o in (1043, 1042) and n == 25: # varchar/bpchar -> text
196
+ return True
197
+ if o == 700 and n == 701: # float4 -> float8
198
+ return True
199
+ return False
200
+
201
+
202
+ def classify_drift(old_sig: dict, new_sig: dict) -> List[Drift]:
203
+ """Compare two signatures of the SAME relation. Ordered and deterministic."""
204
+ out: List[Drift] = []
205
+ old_cols: Dict[str, dict] = old_sig.get("columns", {})
206
+ new_cols: Dict[str, dict] = new_sig.get("columns", {})
207
+ dropped = sorted(set(old_cols) - set(new_cols))
208
+ added = sorted(set(new_cols) - set(old_cols))
209
+
210
+ # One dropped and one added of the same destination type is far more often a rename than a
211
+ # coincidental pair; report it as a rename so a human sees the real event, at warning level
212
+ # because the runtime cannot prove it (logical replication carries rows, not the DDL).
213
+ if len(dropped) == 1 and len(added) == 1 and old_cols[dropped[0]]["dest_type"] == new_cols[added[0]]["dest_type"]:
214
+ out.append(
215
+ Drift(
216
+ "rename_column",
217
+ added[0],
218
+ f"{dropped[0]!r} disappeared and {added[0]!r} appeared with the same type "
219
+ f"({new_cols[added[0]]['dest_type']}) — probably a rename",
220
+ "warning",
221
+ )
222
+ )
223
+ dropped, added = [], []
224
+
225
+ for name in added:
226
+ if new_cols[name]["not_null"]:
227
+ out.append(
228
+ Drift(
229
+ "add_not_null_column",
230
+ name,
231
+ "added NOT NULL — rows already in bronze keep NULL there; backfill or resnapshot to align",
232
+ "warning",
233
+ )
234
+ )
235
+ else:
236
+ out.append(Drift("add_nullable_column", name, "added nullable column", "additive"))
237
+ for name in dropped:
238
+ out.append(
239
+ Drift(
240
+ "drop_column",
241
+ name,
242
+ "dropped at the source — bronze keeps the column and its history (dlt never destroys one)",
243
+ "breaking",
244
+ )
245
+ )
246
+ for name in sorted(set(old_cols) & set(new_cols)):
247
+ o, n = old_cols[name], new_cols[name]
248
+ if o["type_oid"] == n["type_oid"] and o["typmod"] == n["typmod"]:
249
+ continue
250
+ if _is_widen(o, n):
251
+ out.append(
252
+ Drift("type_widen", name, f"OID {o['type_oid']}→{n['type_oid']} widened, applied automatically", "additive")
253
+ )
254
+ else:
255
+ out.append(
256
+ Drift(
257
+ "type_incompatible",
258
+ name,
259
+ f"OID {o['type_oid']}→{n['type_oid']} is not a proven-safe widening",
260
+ "breaking",
261
+ )
262
+ )
263
+
264
+ if list(old_sig.get("key", [])) != list(new_sig.get("key", [])):
265
+ out.append(
266
+ Drift(
267
+ "key_change",
268
+ None,
269
+ f"merge key {old_sig.get('key')} → {new_sig.get('key')}; a live key change double-loads "
270
+ "under dlt merge — the table is removed from the publication and needs `resnapshot`",
271
+ "breaking",
272
+ )
273
+ )
274
+ if old_sig.get("replica_identity") != new_sig.get("replica_identity"):
275
+ out.append(
276
+ Drift(
277
+ "replica_identity_change",
278
+ None,
279
+ f"REPLICA IDENTITY {old_sig.get('replica_identity')} → {new_sig.get('replica_identity')}",
280
+ "info",
281
+ )
282
+ )
283
+ return out
@@ -0,0 +1,347 @@
1
+ """Desired-state compiler: manifest + catalog + adapter → one deterministic relation set.
2
+
3
+ Constraints:
4
+ - deny is evaluated on the COMPILED set, not on the include list — an adapter or a wildcard
5
+ must not be able to smuggle a denied relation in (#3079 D12).
6
+ - ordering is sorted everywhere, so `selection_hash` is a function of the selection alone.
7
+ - a relation is always (schema, table); the target schema is derived per TABLE
8
+ (`destination.table_schemas`, then `schema_mapping`), never assumed equal (D-13).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import hashlib
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
17
+
18
+ from .adapters import USER_CREATED, AdapterRelation
19
+ from .discovery import SourceRelation
20
+ from .manifest import Manifest, Problem, Selection
21
+
22
+ # Key policies, in the order the compiler prefers them.
23
+ PRIMARY_KEY = "primary_key"
24
+ UNIQUE_INDEX = "unique_index"
25
+ APPEND_ONLY = "append_only"
26
+ # Rejected, never compiled: an all-column merge key cannot mirror an UPDATE — pgoutput emits
27
+ # only the new tuple, so the old row is never matched and both versions stay (#3079 review).
28
+ FULL_ROW = "full_row"
29
+
30
+ # Featherbase's own platform tables. Physical-schema-faithful bronze keeps them where they are;
31
+ # the class is what lets a consumer filter them out (#3079 D3).
32
+ _SOURCE_SYSTEM = frozenset(
33
+ {
34
+ "table_def", "column_def", "person", "identifier_system", "role", "has_role",
35
+ "permission", "data_scope",
36
+ "share", "user_event", "access_log", "activity_log", "version", "migration", "site",
37
+ }
38
+ )
39
+ _SOURCE_OPERATIONAL = frozenset({"background_job", "job_execution"})
40
+ # A selected column whose NAME looks secret-bearing and is not in `deny.columns` (#4346). A
41
+ # warning, never an error: the name is a hint, and `validate` must stay green on a false hit.
42
+ _SECRET_LIKE = re.compile(r"password|_hash$|token|secret", re.IGNORECASE)
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Relation:
47
+ schema: str
48
+ table: str
49
+ key: Tuple[str, ...]
50
+ key_policy: str
51
+ table_class: str
52
+ module: Optional[str] = None
53
+ target_schema: str = ""
54
+ key_index: Optional[str] = None # the unique index a UNIQUE_INDEX key came from
55
+ description: Optional[str] = None
56
+ origin: Optional[str] = None # adapters.USER_CREATED | NAMED_SYSTEM | None
57
+ # Columns that never leave the CDC process (`source.deny.columns`, #4346), sorted.
58
+ deny_columns: Tuple[str, ...] = ()
59
+
60
+ @property
61
+ def qualified(self) -> str:
62
+ return f"{self.schema}.{self.table}"
63
+
64
+ @property
65
+ def target(self) -> str:
66
+ return f"{self.target_schema or self.schema}.{self.table}"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class CompiledPlan:
71
+ relations: List[Relation] = field(default_factory=list)
72
+ selection_hash: str = ""
73
+ publication_add: List[Tuple[str, str]] = field(default_factory=list)
74
+ publication_drop: List[Tuple[str, str]] = field(default_factory=list)
75
+ replica_identity_alters: List[Tuple[str, str]] = field(default_factory=list)
76
+ denied_hits: List[str] = field(default_factory=list)
77
+ problems: List[Problem] = field(default_factory=list)
78
+
79
+
80
+ def _matches(qualified: str, schema: str, sel: Selection) -> bool:
81
+ if any(fnmatch.fnmatchcase(schema, pat) for pat in sel.schemas):
82
+ return True
83
+ return any(fnmatch.fnmatchcase(qualified, pat) for pat in sel.tables)
84
+
85
+
86
+ def _included(qualified: str, schema: str, sel: Selection) -> bool:
87
+ """Include is a conjunction: the schema must be allowed AND, when table patterns are given,
88
+ one must match. Table patterns alone (empty `schemas`) select by table pattern only."""
89
+ schema_ok = any(fnmatch.fnmatchcase(schema, pat) for pat in sel.schemas)
90
+ if not sel.tables:
91
+ return schema_ok
92
+ table_ok = any(fnmatch.fnmatchcase(qualified, pat) for pat in sel.tables)
93
+ return table_ok if not sel.schemas else (schema_ok and table_ok)
94
+
95
+
96
+ def table_class(m: Manifest, schema: str, table: str, origin: Optional[str] = None) -> str:
97
+ """First matching manifest rule wins; otherwise the defaults below. Loader-internal is
98
+ checked first so a broad application rule cannot claim `_dlt_loads`. A relation the
99
+ adapter offered as USER_CREATED is application data whatever it is called — a user's
100
+ table named `share` is not the platform's `share`."""
101
+ qualified = f"{schema}.{table}"
102
+ for rule in m.classification.rules:
103
+ if fnmatch.fnmatchcase(qualified, rule.match):
104
+ return rule.table_class
105
+ if table.startswith("_dlt_") or schema == "_cdc_runtime":
106
+ return "loader_internal"
107
+ if origin == USER_CREATED:
108
+ return "application"
109
+ if m.source_id == "control_plane" and schema == "control":
110
+ return "warehouse_control"
111
+ if table in _SOURCE_OPERATIONAL:
112
+ return "source_operational"
113
+ if table in _SOURCE_SYSTEM:
114
+ return "source_system"
115
+ return "application"
116
+
117
+
118
+ def _key_for(
119
+ m: Manifest, rel: SourceRelation, problems: List[Problem]
120
+ ) -> Tuple[Tuple[str, ...], str, Optional[str], bool]:
121
+ """(key, policy, index_name, keep) per D6."""
122
+ override = m.replication.key_overrides.get(rel.qualified)
123
+ if override == APPEND_ONLY:
124
+ return (), APPEND_ONLY, None, True
125
+ if override == FULL_ROW:
126
+ problems.append(
127
+ Problem(
128
+ "full_row_unsupported",
129
+ "error",
130
+ f"{rel.qualified}: full_row cannot mirror an UPDATE (the old row is never matched, "
131
+ "so both versions would stay) — declare append_only for a history, or add a key",
132
+ )
133
+ )
134
+ return (), FULL_ROW, None, False
135
+
136
+ if rel.primary_key:
137
+ return tuple(rel.primary_key), PRIMARY_KEY, None, True
138
+
139
+ # A unique index is a merge key only when it is also NOT NULL: dlt joins the destination to
140
+ # the staging table on key equality, and NULL = NULL is never true, so a nullable key row
141
+ # would insert a duplicate on every load instead of updating.
142
+ notnull = {c.name for c in rel.columns if c.not_null}
143
+ for idx in rel.unique_indexes:
144
+ if all(c in notnull for c in idx.columns):
145
+ if m.replication.replica_identity != "full" and rel.replica_identity not in ("f", "i"):
146
+ problems.append(
147
+ Problem(
148
+ "unique_key_needs_identity",
149
+ "warning",
150
+ f"{rel.qualified}: merging on unique index {idx.name!r}, but REPLICA IDENTITY is "
151
+ f"{rel.replica_identity!r} — DELETE will not carry those columns; set "
152
+ "replica_identity: full or ALTER ... REPLICA IDENTITY USING INDEX",
153
+ )
154
+ )
155
+ return tuple(idx.columns), UNIQUE_INDEX, idx.name, True
156
+
157
+ if m.replication.tables_without_key == APPEND_ONLY:
158
+ problems.append(
159
+ Problem(
160
+ "no_key_append_only",
161
+ "warning",
162
+ f"{rel.qualified}: no primary key and no NOT NULL unique index — loading append-only; "
163
+ "updates and deletes are not promised at the destination",
164
+ )
165
+ )
166
+ return (), APPEND_ONLY, None, True
167
+
168
+ problems.append(
169
+ Problem(
170
+ "no_key_rejected",
171
+ "error",
172
+ f"{rel.qualified}: no primary key and no NOT NULL unique index; set "
173
+ "replication.tables_without_key: append_only or add key_overrides for this table",
174
+ )
175
+ )
176
+ return (), APPEND_ONLY, None, False
177
+
178
+
179
+ def compile(
180
+ m: Manifest,
181
+ discovered: Sequence[SourceRelation],
182
+ adapter_relations: Optional[Sequence[AdapterRelation]] = None,
183
+ published: Optional[Set[Tuple[str, str]]] = None,
184
+ ) -> CompiledPlan:
185
+ """Compile the manifest against a discovered catalog. Pure — no connection, no mutation."""
186
+ problems: List[Problem] = []
187
+ by_qualified = {r.qualified: r for r in discovered}
188
+
189
+ adapter_allowed: Optional[Set[str]] = None
190
+ adapter_meta: Dict[str, AdapterRelation] = {}
191
+ if m.source.discovery is not None:
192
+ adapter_meta = {f"{a.schema}.{a.table}": a for a in (adapter_relations or [])}
193
+ adapter_allowed = set(adapter_meta)
194
+ for q in sorted(adapter_allowed - set(by_qualified)):
195
+ problems.append(
196
+ Problem("adapter_phantom", "warning", f"{q}: selected by the adapter but not physically present — skipped")
197
+ )
198
+
199
+ denied: List[str] = []
200
+ candidates: List[SourceRelation] = []
201
+ leaves_of: Dict[str, List[str]] = {}
202
+ for rel in discovered:
203
+ if rel.parent:
204
+ leaves_of.setdefault(rel.parent, []).append(rel.qualified)
205
+ for rel in sorted(discovered, key=lambda r: (r.schema, r.table)):
206
+ q = rel.qualified
207
+ if not _included(q, rel.schema, m.source.include):
208
+ continue
209
+ if _matches(q, rel.schema, m.source.exclude):
210
+ continue
211
+ if adapter_allowed is not None and q not in adapter_allowed:
212
+ continue
213
+ if rel.kind == "p":
214
+ # Leaves only: `pg_publication_tables` expands a parent to its leaves (so a parent
215
+ # in the desired set is "added" again every tick), and pgoutput emits leaf
216
+ # identity by default (so a parent's destination copy would stop receiving rows).
217
+ # The leaves are selected by the same globs; the parent is named so the operator
218
+ # can see what was skipped (#3079 review).
219
+ problems.append(
220
+ Problem(
221
+ "partitioned_parent_skipped",
222
+ "warning",
223
+ f"{q}: partitioned parent — not replicated as one relation; its partitions "
224
+ f"{', '.join(sorted(leaves_of.get(q, []))) or '(none yet)'} are selected "
225
+ "individually when the include globs match them",
226
+ )
227
+ )
228
+ continue
229
+ # Deny last, on what selection actually produced: a deny rule is a tripwire, so it must
230
+ # fire only when something really would have been replicated (D12).
231
+ if _matches(q, rel.schema, m.source.deny):
232
+ denied.append(q)
233
+ continue
234
+ candidates.append(rel)
235
+
236
+ for q in denied:
237
+ problems.append(
238
+ Problem("deny_hit", "error", f"{q} matches a deny rule but was selected — refusing to replicate it")
239
+ )
240
+
241
+ relations: List[Relation] = []
242
+ for rel in candidates:
243
+ key, policy, idx_name, keep = _key_for(m, rel, problems)
244
+ if not keep:
245
+ continue
246
+ meta = adapter_meta.get(rel.qualified)
247
+ deny_cols = _deny_columns_for(m, rel, key, problems)
248
+ if deny_cols is None:
249
+ continue
250
+ relations.append(
251
+ Relation(
252
+ schema=rel.schema,
253
+ table=rel.table,
254
+ key=key,
255
+ key_policy=policy,
256
+ table_class=table_class(m, rel.schema, rel.table, meta.origin if meta else None),
257
+ module=meta.module if meta else None,
258
+ target_schema=m.destination.target_schema(rel.schema, rel.table),
259
+ key_index=idx_name,
260
+ description=meta.description if meta else None,
261
+ origin=meta.origin if meta else None,
262
+ deny_columns=deny_cols,
263
+ )
264
+ )
265
+
266
+ seen_target: Dict[str, str] = {}
267
+ for r in relations:
268
+ prior = seen_target.get(r.target)
269
+ if prior:
270
+ problems.append(
271
+ Problem(
272
+ "target_collision",
273
+ "error",
274
+ f"{prior} and {r.qualified} both map to {m.destination.database}.{r.target}",
275
+ )
276
+ )
277
+ seen_target[r.target] = r.qualified
278
+
279
+ desired = {(r.schema, r.table) for r in relations}
280
+ add, drop = diff_publication(desired, published)
281
+ alters = (
282
+ sorted((r.schema, r.table) for r in relations if by_qualified[r.qualified].replica_identity != "f")
283
+ if m.replication.replica_identity == "full"
284
+ else []
285
+ )
286
+
287
+ return CompiledPlan(
288
+ relations=relations,
289
+ selection_hash=selection_hash(relations),
290
+ publication_add=add,
291
+ publication_drop=drop,
292
+ replica_identity_alters=alters,
293
+ denied_hits=denied,
294
+ problems=problems,
295
+ )
296
+
297
+
298
+ def _deny_columns_for(
299
+ m: Manifest, rel: SourceRelation, key: Tuple[str, ...], problems: List[Problem]
300
+ ) -> Optional[Tuple[str, ...]]:
301
+ """The relation's column projection (#4346), or None when it cannot be honoured: a denied
302
+ merge-key column would leave the mirror unable to merge, so the relation is refused. A name
303
+ the catalogue does not carry is a warning (it may be dropped, or not yet added) and is still
304
+ withheld, so a column added later can never flow before the manifest is re-read."""
305
+ denied = sorted(set(m.source.deny_columns.get(rel.qualified, [])))
306
+ present = {c.name for c in rel.columns}
307
+ for col in denied:
308
+ if col in key:
309
+ problems.append(Problem(
310
+ "deny_column_is_key", "error",
311
+ f"{rel.qualified}.{col} is part of the merge key and cannot be withheld — "
312
+ "the mirror could not merge without it"))
313
+ return None
314
+ if col not in present:
315
+ problems.append(Problem(
316
+ "deny_column_absent", "warning",
317
+ f"{rel.qualified}.{col} is denied but not in the source catalogue — still withheld "
318
+ "if it appears"))
319
+ for col in sorted(present - set(denied)):
320
+ if _SECRET_LIKE.search(col):
321
+ problems.append(Problem(
322
+ "secret_like_column", "warning",
323
+ f"{rel.qualified}.{col} looks secret-bearing and is replicated — add it to "
324
+ "source.deny.columns if it should never leave the source"))
325
+ return tuple(denied)
326
+
327
+
328
+ def selection_hash(relations: Iterable[Relation]) -> str:
329
+ """Stable over the selection AND its key policy — a table that silently changed from
330
+ merge to append-only must produce a different hash in the control plane. A column
331
+ projection (#4346) is part of the selection; it is appended only when present, so a
332
+ registration with no column deny keeps the hash it always had."""
333
+ lines = sorted(
334
+ f"{r.schema}.{r.table}|{','.join(r.key)}|{r.key_policy}"
335
+ + (f"|deny={','.join(r.deny_columns)}" if r.deny_columns else "")
336
+ for r in relations)
337
+ return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
338
+
339
+
340
+ def diff_publication(
341
+ desired: Set[Tuple[str, str]], published: Optional[Set[Tuple[str, str]]]
342
+ ) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]:
343
+ """(add, drop). `published is None` means the publication does not exist yet — everything
344
+ is an add and nothing can be dropped."""
345
+ if published is None:
346
+ return sorted(desired), []
347
+ return sorted(desired - published), sorted(published - desired)