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,587 @@
1
+ """Registration manifest: parse, hold, validate statically (#3079 D1/D12).
2
+
3
+ Constraints this file enforces and callers rely on:
4
+ - a manifest NEVER carries a secret value — `dsn_env`/`token_env` are env-var NAMES, and
5
+ nothing here (or in `plan`) may print an env value; use `redact()` on anything unsure.
6
+ - `api_version` is exact; an unknown version is rejected rather than best-effort parsed.
7
+ - field names are the contract with the runtime half (plan Appendix B) — renaming one breaks
8
+ `runner.py`/`stream.py`, which are owned by another module.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import os
14
+ import pathlib
15
+ import re
16
+ from dataclasses import dataclass, field, replace
17
+ from typing import Any, Dict, List, Optional, Union
18
+
19
+ import yaml
20
+
21
+ API_VERSION = "pg-md-cdc/v1"
22
+
23
+ _IDENT = re.compile(r"^[a-z][a-z0-9_]*$")
24
+ _PG_IDENT = re.compile(r"^[a-z_][a-z0-9_$]*$")
25
+ _ENV_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$")
26
+ _QUALIFIED = re.compile(r"^[^.]+\.[^.]+$")
27
+
28
+ # Which deployment environment a registration belongs to (#4340). It must equal the Railway
29
+ # environment the worker runs in, so a dev instance copied from production's settings refuses
30
+ # to start production's registration (and the reverse) instead of mixing the two.
31
+ PRODUCTION = "production"
32
+ ENVIRONMENTS = (PRODUCTION, "dev", "staging")
33
+ RAILWAY_ENVIRONMENT_VAR = "RAILWAY_ENVIRONMENT_NAME"
34
+
35
+ TABLE_CLASSES = (
36
+ "application",
37
+ "source_system",
38
+ "source_operational",
39
+ "loader_internal",
40
+ "warehouse_control",
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Problem:
46
+ """One validation finding. `code` is stable — tests and the CLI exit status key on it."""
47
+
48
+ code: str
49
+ severity: str # "error" | "warning"
50
+ message: str
51
+
52
+ def __str__(self) -> str: # pragma: no cover - formatting only
53
+ return f"[{self.severity}] {self.code}: {self.message}"
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Selection:
58
+ schemas: List[str] = field(default_factory=lambda: ["*"])
59
+ tables: List[str] = field(default_factory=list) # "schema.table" fnmatch globs
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Discovery:
64
+ adapter: str
65
+ options: Dict[str, Any] = field(default_factory=dict)
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Source:
70
+ dsn_env: str
71
+ include: Selection = field(default_factory=Selection)
72
+ exclude: Selection = field(default_factory=lambda: Selection(schemas=[], tables=[]))
73
+ deny: Selection = field(default_factory=lambda: Selection(schemas=[], tables=[]))
74
+ discovery: Optional[Discovery] = None
75
+ # `deny.columns` (#4346): exact "schema.table" -> column names that never leave the CDC
76
+ # process. A projection, not a publication column list: under REPLICA IDENTITY FULL a list
77
+ # must cover every column, so leaving one out makes UPDATE/DELETE fail at the SOURCE.
78
+ deny_columns: Dict[str, List[str]] = field(default_factory=dict)
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class TableSchema:
83
+ """One per-table routing rule: relations matching `match` land in target `schema`."""
84
+
85
+ match: str # "schema.table" fnmatch glob
86
+ schema: str
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Destination:
91
+ token_env: str
92
+ database: str
93
+ schema_mapping: Union[str, Dict[str, str]] = "preserve_source"
94
+ # Per-table target schemas, first match wins; a relation no rule matches falls back to
95
+ # `schema_mapping`. Lets one source schema fan out into several bronze schemas (D-13).
96
+ table_schemas: List[TableSchema] = field(default_factory=list)
97
+ pipeline_name: Optional[str] = None
98
+ retirement_policy: str = "freeze" # freeze | archive
99
+ # The MotherDuck account `token_env`'s token must sign in as (#4427). Required: run and
100
+ # resnapshot check `current_user()` against it before creating or writing anything.
101
+ owner: str = ""
102
+
103
+ def target_schema(self, source_schema: str, table: Optional[str] = None) -> str:
104
+ if table is not None:
105
+ qualified = f"{source_schema}.{table}"
106
+ for rule in self.table_schemas:
107
+ if fnmatch.fnmatchcase(qualified, rule.match):
108
+ return rule.schema
109
+ if self.schema_mapping == "preserve_source":
110
+ return source_schema
111
+ if isinstance(self.schema_mapping, dict):
112
+ return self.schema_mapping.get(source_schema, source_schema)
113
+ return source_schema
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class Replication:
118
+ slot: str
119
+ publication: str
120
+ cadence_minutes: int = 5
121
+ tables_without_key: str = "reject" # reject | append_only
122
+ replica_identity: str = "default" # full | default
123
+ slot_loss_policy: str = "resnapshot" # resnapshot | pause
124
+ target_batch_size: int = 1000
125
+ key_overrides: Dict[str, str] = field(default_factory=dict) # "schema.table" -> append_only
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class Control:
130
+ extractor: str
131
+ availability_policy: str = "pause_if_control_unreachable"
132
+ event_durability: str = "direct_postgres" # direct_postgres | persistent_local_wal
133
+ wal_dir: Optional[str] = None
134
+ # "schema.table" globs whose row counts this drainer must NOT reconcile: relations it
135
+ # writes to DURING the run can never tie source-now against destination-now.
136
+ reconcile_exclude: List[str] = field(default_factory=list)
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class Rule:
141
+ match: str # "schema.table" fnmatch glob
142
+ table_class: str
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class Classification:
147
+ rules: List[Rule] = field(default_factory=list) # first match wins
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class Manifest:
152
+ api_version: str
153
+ source_id: str
154
+ source: Source
155
+ destination: Destination
156
+ replication: Replication
157
+ control: Control
158
+ classification: Classification = field(default_factory=Classification)
159
+ environment: str = "production"
160
+ path: Optional[str] = None
161
+
162
+
163
+ # --------------------------------------------------------------------------- parsing
164
+
165
+
166
+ class ManifestError(ValueError):
167
+ """Malformed enough that no dataclass can be built — unknown key, wrong YAML shape."""
168
+
169
+
170
+ def _sel(raw: Any, *, default_schemas: List[str]) -> Selection:
171
+ if raw is None:
172
+ return Selection(schemas=list(default_schemas), tables=[])
173
+ if not isinstance(raw, dict):
174
+ raise ManifestError(f"expected a mapping for a selection block, got {type(raw).__name__}")
175
+ _reject_unknown(raw, {"schemas", "tables"}, "selection")
176
+ return Selection(
177
+ schemas=list(raw.get("schemas") or default_schemas),
178
+ tables=list(raw.get("tables") or []),
179
+ )
180
+
181
+
182
+ def _deny_columns(raw: Any) -> Dict[str, List[str]]:
183
+ """`source.deny.columns`: a mapping of "schema.table" to a list of column names. Shape
184
+ only — names and keys are policed by validate_static so every finding is listed at once."""
185
+ if raw is None:
186
+ return {}
187
+ if not isinstance(raw, dict):
188
+ raise ManifestError("source.deny.columns must be a mapping of 'schema.table' to a list of columns")
189
+ out: Dict[str, List[str]] = {}
190
+ for rel, cols in raw.items():
191
+ if isinstance(cols, str) or not isinstance(cols, list):
192
+ raise ManifestError(f"source.deny.columns[{rel}] must be a list of column names")
193
+ out[str(rel)] = [str(c) for c in cols]
194
+ return out
195
+
196
+
197
+ def _reject_unknown(raw: Dict[str, Any], allowed: set, where: str) -> None:
198
+ extra = sorted(set(raw) - allowed)
199
+ if extra:
200
+ raise ManifestError(f"unknown key(s) in {where}: {', '.join(extra)}")
201
+
202
+
203
+ def parse(raw: Dict[str, Any], *, path: Optional[str] = None) -> Manifest:
204
+ """Build a Manifest from already-loaded YAML. Shape errors raise; policy errors are
205
+ reported by validate_static so the CLI can list them all at once."""
206
+ if not isinstance(raw, dict):
207
+ raise ManifestError("manifest must be a YAML mapping")
208
+ _reject_unknown(
209
+ raw,
210
+ {"api_version", "source_id", "environment", "source", "destination", "replication", "control",
211
+ "classification"},
212
+ "manifest",
213
+ )
214
+ src = raw.get("source") or {}
215
+ _reject_unknown(src, {"dsn_env", "include", "exclude", "deny", "discovery"}, "source")
216
+ deny_raw = src.get("deny")
217
+ deny_columns_raw = None
218
+ if isinstance(deny_raw, dict) and "columns" in deny_raw:
219
+ deny_columns_raw = deny_raw["columns"]
220
+ deny_raw = {k: v for k, v in deny_raw.items() if k != "columns"}
221
+ disc_raw = src.get("discovery")
222
+ discovery = None
223
+ if disc_raw:
224
+ _reject_unknown(disc_raw, {"adapter", "options"}, "source.discovery")
225
+ discovery = Discovery(adapter=str(disc_raw.get("adapter", "")), options=dict(disc_raw.get("options") or {}))
226
+
227
+ dest = raw.get("destination") or {}
228
+ _reject_unknown(
229
+ dest,
230
+ {"token_env", "owner", "database", "schema_mapping", "table_schemas", "pipeline_name",
231
+ "retirement_policy"},
232
+ "destination",
233
+ )
234
+ table_schemas = []
235
+ for entry in dest.get("table_schemas") or []:
236
+ if not isinstance(entry, dict):
237
+ raise ManifestError("destination.table_schemas entries must be mappings {match, schema}")
238
+ _reject_unknown(entry, {"match", "schema"}, "destination.table_schemas[]")
239
+ table_schemas.append(TableSchema(match=str(entry.get("match", "")), schema=str(entry.get("schema", ""))))
240
+ rep = raw.get("replication") or {}
241
+ _reject_unknown(
242
+ rep,
243
+ {
244
+ "slot",
245
+ "publication",
246
+ "cadence_minutes",
247
+ "tables_without_key",
248
+ "replica_identity",
249
+ "slot_loss_policy",
250
+ "target_batch_size",
251
+ "key_overrides",
252
+ },
253
+ "replication",
254
+ )
255
+ ctl = raw.get("control") or {}
256
+ _reject_unknown(
257
+ ctl,
258
+ {"extractor", "availability_policy", "event_durability", "wal_dir", "reconcile_exclude"},
259
+ "control",
260
+ )
261
+ cls_raw = raw.get("classification") or {}
262
+ _reject_unknown(cls_raw, {"rules"}, "classification")
263
+ rules = [Rule(match=str(r["match"]), table_class=str(r["table_class"])) for r in (cls_raw.get("rules") or [])]
264
+
265
+ return Manifest(
266
+ api_version=str(raw.get("api_version", "")),
267
+ source_id=str(raw.get("source_id", "")),
268
+ source=Source(
269
+ dsn_env=str(src.get("dsn_env", "")),
270
+ include=_sel(src.get("include"), default_schemas=["*"]),
271
+ exclude=_sel(src.get("exclude"), default_schemas=[]),
272
+ deny=_sel(deny_raw, default_schemas=[]),
273
+ discovery=discovery,
274
+ deny_columns=_deny_columns(deny_columns_raw),
275
+ ),
276
+ destination=Destination(
277
+ token_env=str(dest.get("token_env", "")),
278
+ database=str(dest.get("database", "")),
279
+ schema_mapping=dest.get("schema_mapping", "preserve_source"),
280
+ table_schemas=table_schemas,
281
+ pipeline_name=dest.get("pipeline_name"),
282
+ retirement_policy=str(dest.get("retirement_policy", "freeze")),
283
+ owner=str(dest.get("owner") or ""),
284
+ ),
285
+ replication=Replication(
286
+ slot=str(rep.get("slot", "")),
287
+ publication=str(rep.get("publication", "")),
288
+ cadence_minutes=int(rep.get("cadence_minutes", 5)),
289
+ tables_without_key=str(rep.get("tables_without_key", "reject")),
290
+ replica_identity=str(rep.get("replica_identity", "default")),
291
+ slot_loss_policy=str(rep.get("slot_loss_policy", "resnapshot")),
292
+ target_batch_size=int(rep.get("target_batch_size", 1000)),
293
+ key_overrides={str(k): str(v) for k, v in (rep.get("key_overrides") or {}).items()},
294
+ ),
295
+ control=Control(
296
+ extractor=str(ctl.get("extractor", "")),
297
+ availability_policy=str(ctl.get("availability_policy", "pause_if_control_unreachable")),
298
+ event_durability=str(ctl.get("event_durability", "direct_postgres")),
299
+ wal_dir=ctl.get("wal_dir"),
300
+ reconcile_exclude=[str(x) for x in (ctl.get("reconcile_exclude") or [])],
301
+ ),
302
+ classification=Classification(rules=rules),
303
+ environment=str(raw.get("environment", "production")),
304
+ path=path,
305
+ )
306
+
307
+
308
+ def load(path: str) -> Manifest:
309
+ """Read one registration file. Never resolves the referenced env vars."""
310
+ with open(path, "r", encoding="utf-8") as fh:
311
+ raw = yaml.safe_load(fh)
312
+ return parse(raw, path=str(path))
313
+
314
+
315
+ def pipeline_name(m: Manifest, target_schema: str) -> str:
316
+ """dlt pipeline name for one TARGET schema (= one dlt dataset). An explicit `pipeline_name`
317
+ pins the whole registration to one pipeline — only unambiguous with a single target schema."""
318
+ if m.destination.pipeline_name:
319
+ return m.destination.pipeline_name
320
+ return f"{m.source_id}_cdc__{target_schema}"
321
+
322
+
323
+ # --------------------------------------------------------------------------- redaction
324
+
325
+ _SECRETISH = re.compile(r"(?i)(password|token|secret|dsn|postgres(ql)?://\S+)")
326
+
327
+
328
+ def redact(text: str, *, env: Optional[Dict[str, str]] = None) -> str:
329
+ """Blank out anything that looks like a credential before it reaches stdout or an event.
330
+
331
+ Two passes, because both failure modes have bitten: (1) a DSN pasted into a manifest by
332
+ mistake, (2) an env VALUE interpolated into a message. Values of the env vars a manifest
333
+ names are matched literally, so a token that looks like an ordinary word is still removed.
334
+ """
335
+ env = os.environ if env is None else env
336
+ out = text
337
+ for value in sorted((v for v in env.values() if v and len(v) >= 8), key=len, reverse=True):
338
+ if value in out:
339
+ out = out.replace(value, "***redacted***")
340
+ out = re.sub(r"(?i)\b(postgres(?:ql)?|mysql)://[^\s'\"]+", r"\1://***redacted***", out)
341
+ return out
342
+
343
+
344
+ def redact_problems(problems: List[Problem]) -> List[Problem]:
345
+ return [replace(p, message=redact(p.message)) for p in problems]
346
+
347
+
348
+ # --------------------------------------------------------------------------- validation
349
+
350
+
351
+ def _looks_like_a_value(s: str) -> bool:
352
+ """An env-var REFERENCE that actually contains a value. Cheap and deliberately broad —
353
+ a false positive costs one manifest edit; a false negative leaks a DSN into a log."""
354
+ return "://" in s or "=" in s or " " in s or s != s.strip()
355
+
356
+
357
+ def validate_static(m: Manifest) -> List[Problem]:
358
+ """Everything checkable without touching either database. Ordered, so output is stable."""
359
+ p: List[Problem] = []
360
+ err = lambda code, msg: p.append(Problem(code, "error", msg)) # noqa: E731
361
+ warn = lambda code, msg: p.append(Problem(code, "warning", msg)) # noqa: E731
362
+
363
+ if m.api_version != API_VERSION:
364
+ err("api_version", f"api_version must be {API_VERSION!r}, got {m.api_version!r}")
365
+ if not _IDENT.match(m.source_id or ""):
366
+ err("source_id", f"source_id must be a lowercase identifier, got {m.source_id!r}")
367
+ _check_enum(p, "environment", m.environment, ENVIRONMENTS)
368
+ # A non-production mirror may share its MotherDuck owner with production (the dev Featherbase
369
+ # CDC does, Siraj 23-Oct-2026), so the token alone does not keep it out of production bronze.
370
+ # Its database must carry its environment's name; the runtime also refuses any database a
371
+ # production registration owns (runner._refuse_wrong_environment).
372
+ if m.environment != PRODUCTION and not m.destination.database.endswith(f"_{m.environment}"):
373
+ err("environment_database",
374
+ f"a {m.environment!r} registration must write a database ending in "
375
+ f"'_{m.environment}', got {m.destination.database!r}")
376
+
377
+ for label, name in (("source.dsn_env", m.source.dsn_env), ("destination.token_env", m.destination.token_env)):
378
+ if not name:
379
+ err("env_ref_missing", f"{label} is required")
380
+ elif _looks_like_a_value(name):
381
+ err("env_ref_is_a_value", f"{label} must be an environment-variable NAME, not a value")
382
+ elif not _ENV_NAME.match(name):
383
+ err("env_ref_name", f"{label}={name!r} is not a valid environment-variable name")
384
+
385
+ for label, name in (("replication.slot", m.replication.slot), ("replication.publication", m.replication.publication)):
386
+ if not name:
387
+ err("pg_identifier", f"{label} is required")
388
+ elif not _PG_IDENT.match(name) or len(name) > 63:
389
+ err("pg_identifier", f"{label}={name!r} is not a valid unquoted PostgreSQL identifier")
390
+
391
+ if not m.destination.database:
392
+ err("destination_database", "destination.database is required")
393
+ else:
394
+ why = database_name_problem(m.destination.database)
395
+ if why:
396
+ err("destination_database", why)
397
+ why = owner_problem(m.destination.owner)
398
+ if why:
399
+ err("destination_owner", why)
400
+ _check_enum(p, "destination.retirement_policy", m.destination.retirement_policy, ("freeze", "archive"))
401
+
402
+ sm = m.destination.schema_mapping
403
+ if sm != "preserve_source":
404
+ if not isinstance(sm, dict):
405
+ err("schema_mapping", "destination.schema_mapping must be 'preserve_source' or a mapping")
406
+ else:
407
+ seen: Dict[str, str] = {}
408
+ for src_schema, tgt in sorted(sm.items()):
409
+ if tgt in seen:
410
+ err(
411
+ "schema_mapping_collision",
412
+ f"schemas {seen[tgt]!r} and {src_schema!r} both map to target schema {tgt!r}",
413
+ )
414
+ seen[tgt] = src_schema
415
+ _check_target_schema(p, f"destination.schema_mapping[{src_schema}]", tgt)
416
+ for i, rule in enumerate(m.destination.table_schemas):
417
+ if not _QUALIFIED.match(rule.match):
418
+ err("table_schema_match", f"destination.table_schemas[{i}].match {rule.match!r} must be a 'schema.table' glob")
419
+ _check_target_schema(p, f"destination.table_schemas[{i}].schema", rule.schema)
420
+ if m.destination.table_schemas and m.destination.pipeline_name:
421
+ n_targets = len({r.schema for r in m.destination.table_schemas})
422
+ if n_targets > 1:
423
+ err(
424
+ "pipeline_name_ambiguous",
425
+ "destination.pipeline_name pins ONE dlt pipeline, but table_schemas name "
426
+ f"{n_targets} target schemas — one pipeline cannot load several datasets",
427
+ )
428
+
429
+ _check_enum(p, "replication.tables_without_key", m.replication.tables_without_key, ("reject", "append_only"))
430
+ _check_enum(p, "replication.replica_identity", m.replication.replica_identity, ("full", "default"))
431
+ _check_enum(p, "replication.slot_loss_policy", m.replication.slot_loss_policy, ("resnapshot", "pause"))
432
+ if m.replication.cadence_minutes <= 0:
433
+ err("cadence", "replication.cadence_minutes must be positive")
434
+ if m.replication.target_batch_size <= 0:
435
+ err("batch_size", "replication.target_batch_size must be positive")
436
+ for rel, policy in sorted(m.replication.key_overrides.items()):
437
+ if not _QUALIFIED.match(rel):
438
+ err("key_override_name", f"key_overrides key {rel!r} must be 'schema.table'")
439
+ if policy == "full_row":
440
+ err(
441
+ "full_row_unsupported",
442
+ f"replication.key_overrides[{rel}]=full_row cannot mirror an UPDATE: pgoutput emits "
443
+ "only the new tuple, so an all-column merge key never matches the old row and both "
444
+ "versions stay — declare append_only for a history, or give the table a key",
445
+ )
446
+ else:
447
+ _check_enum(p, f"replication.key_overrides[{rel}]", policy, ("append_only",))
448
+
449
+ for label, sel in (("include", m.source.include), ("exclude", m.source.exclude), ("deny", m.source.deny)):
450
+ for t in sel.tables:
451
+ if not _QUALIFIED.match(t):
452
+ err("pattern_qualified", f"source.{label}.tables entry {t!r} must be 'schema.table'")
453
+
454
+ for rel, cols in sorted(m.source.deny_columns.items()):
455
+ if not _QUALIFIED.match(rel) or any(ch in rel for ch in "*?[]"):
456
+ err("deny_column_key", f"source.deny.columns key {rel!r} must be an exact 'schema.table' (no globs)")
457
+ if not cols:
458
+ err("deny_column_name", f"source.deny.columns[{rel}] is empty — name the column(s) to withhold")
459
+ for col in cols:
460
+ if not _PG_IDENT.match(col) or len(col) > 63:
461
+ err("deny_column_name",
462
+ f"source.deny.columns[{rel}] entry {col!r} is not a valid unquoted column name")
463
+
464
+ if m.source.discovery is not None and not m.source.discovery.adapter:
465
+ err("adapter", "source.discovery.adapter is required when a discovery block is present")
466
+
467
+ if not m.control.extractor:
468
+ err("extractor", "control.extractor is required")
469
+ if m.control.availability_policy != "pause_if_control_unreachable":
470
+ err(
471
+ "availability_policy",
472
+ f"availability_policy {m.control.availability_policy!r} is not implemented in this runtime",
473
+ )
474
+ _check_enum(
475
+ p, "control.event_durability", m.control.event_durability, ("direct_postgres", "persistent_local_wal")
476
+ )
477
+ for pat in m.control.reconcile_exclude:
478
+ if not _QUALIFIED.match(pat):
479
+ err("reconcile_exclude_pattern",
480
+ f"control.reconcile_exclude entry {pat!r} must be a 'schema.table' glob")
481
+ if m.control.event_durability == "persistent_local_wal":
482
+ if not m.control.wal_dir:
483
+ err("wal_dir_missing", "event_durability=persistent_local_wal requires control.wal_dir")
484
+ elif not _on_a_mount(m.control.wal_dir):
485
+ err(
486
+ "wal_dir_not_persistent",
487
+ f"control.wal_dir={m.control.wal_dir!r} is not on a mount point — a local WAL on "
488
+ "ephemeral container storage is not cross-run crash recovery (D9)",
489
+ )
490
+
491
+ for rule in m.classification.rules:
492
+ if not _QUALIFIED.match(rule.match):
493
+ err("rule_pattern", f"classification rule match {rule.match!r} must be 'schema.table'")
494
+ if rule.table_class not in TABLE_CLASSES:
495
+ err("rule_class", f"unknown table_class {rule.table_class!r} (known: {', '.join(TABLE_CLASSES)})")
496
+
497
+ if m.replication.replica_identity == "full":
498
+ warn(
499
+ "replica_identity_full_cost",
500
+ "replica_identity: full writes the whole old row into WAL on every UPDATE/DELETE and "
501
+ "takes a brief ACCESS EXCLUSIVE lock per ALTER — `plan` lists the ALTERs before any run",
502
+ )
503
+ return redact_problems(p)
504
+
505
+
506
+ RESERVED_TARGET_SCHEMAS = ("_cdc_runtime",)
507
+
508
+
509
+ def _check_target_schema(p: List[Problem], label: str, name: str) -> None:
510
+ """A target schema is a dlt dataset name AND a MotherDuck schema: an unquoted identifier,
511
+ and never the runtime's own metadata schema."""
512
+ if not _PG_IDENT.match(name or "") or len(name) > 63:
513
+ p.append(Problem("target_schema_identifier", "error",
514
+ f"{label}={name!r} is not a valid unquoted schema identifier"))
515
+ elif name in RESERVED_TARGET_SCHEMAS or name.endswith("_staging"):
516
+ p.append(Problem("target_schema_reserved", "error",
517
+ f"{label}={name!r} is reserved (runtime metadata / dlt merge staging)"))
518
+
519
+
520
+ def _check_enum(p: List[Problem], label: str, value: str, allowed: tuple) -> None:
521
+ if value not in allowed:
522
+ p.append(Problem("enum", "error", f"{label}={value!r} must be one of {', '.join(allowed)}"))
523
+
524
+
525
+ def _on_a_mount(path: str) -> bool:
526
+ """True when `path` (or an existing ancestor other than '/') is a mount point. A declared
527
+ volume that resolves to the container root filesystem is exactly the case D9 rejects."""
528
+ cur = os.path.abspath(path)
529
+ while True:
530
+ if os.path.ismount(cur):
531
+ return cur != os.sep
532
+ parent = os.path.dirname(cur)
533
+ if parent == cur:
534
+ return False
535
+ cur = parent
536
+
537
+
538
+ def environment_mismatch(m: Manifest, environ: Optional[Dict[str, str]] = None) -> Optional[str]:
539
+ """Why this registration must not run here, or None (#4340).
540
+
541
+ Railway names every deployment's environment in RAILWAY_ENVIRONMENT_NAME. When it is set and
542
+ is not the registration's `environment`, the worker is in the wrong place — typically a dev
543
+ instance whose settings were copied from production. Unset (a laptop, a test) means no check.
544
+ """
545
+ env = os.environ if environ is None else environ
546
+ here = (env.get(RAILWAY_ENVIRONMENT_VAR) or "").strip()
547
+ if here and here != m.environment:
548
+ return (f"registration {m.source_id!r} is for environment {m.environment!r} but this "
549
+ f"worker runs in Railway environment {here!r} — refusing to touch any database")
550
+ return None
551
+
552
+
553
+ def database_name_problem(database: str) -> Optional[str]:
554
+ """Why `database` is not a bare identifier, or None (#4340).
555
+
556
+ The destination connects to `md:<database>`, which also accepts connection parameters and
557
+ other syntax, so `bronze_featherbase?session_hint=x_dev` would pass a suffix check and still
558
+ open bronze_featherbase. Only a full-string lowercase identifier is a database name here.
559
+ """
560
+ if not re.fullmatch(r"[a-z][a-z0-9_]*", database or "") or len(database) > 63:
561
+ return (f"destination.database must be a bare identifier ([a-z][a-z0-9_]*), "
562
+ f"got {database!r}")
563
+ return None
564
+
565
+
566
+ def owner_problem(owner: str) -> Optional[str]:
567
+ """Why `owner` is not a usable MotherDuck account name, or None (#4427)."""
568
+ if not owner:
569
+ return ("destination.owner is required: the MotherDuck account the token must sign in "
570
+ "as, as recorded in your warehouse's layer access registry")
571
+ if not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.@+-]*", owner) or len(owner) > 255:
572
+ return f"destination.owner must be a MotherDuck account name, got {owner!r}"
573
+ return None
574
+
575
+
576
+ def production_databases(registrations_dir: str) -> Dict[str, str]:
577
+ """{destination database: source_id} for every production registration in the directory."""
578
+ out: Dict[str, str] = {}
579
+ for path in sorted(pathlib.Path(registrations_dir).glob("*.yml")):
580
+ other = load(str(path))
581
+ if other.environment == PRODUCTION:
582
+ out[other.destination.database] = other.source_id
583
+ return out
584
+
585
+
586
+ def errors(problems: List[Problem]) -> List[Problem]:
587
+ return [x for x in problems if x.severity == "error"]