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.
- feather_cdc_postgres/__init__.py +6 -0
- feather_cdc_postgres/__main__.py +9 -0
- feather_cdc_postgres/adapters/__init__.py +44 -0
- feather_cdc_postgres/adapters/featherbase.py +126 -0
- feather_cdc_postgres/cli.py +247 -0
- feather_cdc_postgres/destination.py +640 -0
- feather_cdc_postgres/discovery.py +216 -0
- feather_cdc_postgres/drift.py +207 -0
- feather_cdc_postgres/generate_dbt.py +106 -0
- feather_cdc_postgres/lifecycle.py +79 -0
- feather_cdc_postgres/manifest.py +587 -0
- feather_cdc_postgres/ownership.py +103 -0
- feather_cdc_postgres/publication.py +157 -0
- feather_cdc_postgres/runner.py +1399 -0
- feather_cdc_postgres/schema_registry.py +283 -0
- feather_cdc_postgres/selectors.py +347 -0
- feather_cdc_postgres/snapshot.py +448 -0
- feather_cdc_postgres/stream.py +162 -0
- feather_cdc_postgres/telemetry.py +172 -0
- feather_cdc_postgres-0.1.0.dist-info/METADATA +239 -0
- feather_cdc_postgres-0.1.0.dist-info/RECORD +30 -0
- feather_cdc_postgres-0.1.0.dist-info/WHEEL +4 -0
- feather_cdc_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- pg_replication/LICENSE +201 -0
- pg_replication/README.md +100 -0
- pg_replication/__init__.py +106 -0
- pg_replication/decoders.py +428 -0
- pg_replication/exceptions.py +6 -0
- pg_replication/helpers.py +1405 -0
- pg_replication/schema_types.py +156 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""feather_cdc_postgres — declarative PostgreSQL → MotherDuck CDC runtime (#3079).
|
|
2
|
+
|
|
3
|
+
One code artifact, one isolated service instance per registration (`<source_id>.yml`). The
|
|
4
|
+
registrations are the DEPLOYER's, not this package's. Mechanism: slot-based logical replication
|
|
5
|
+
(pgoutput) drained cron-shaped into a bronze database, source namespaces preserved.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Discovery adapters: source-specific POLICY that narrows the generic set of relations.
|
|
2
|
+
|
|
3
|
+
An adapter may only answer "which of the source's relations are in scope, and what does the
|
|
4
|
+
source call them" — it never supplies types, keys, or load behaviour. That keeps the CDC
|
|
5
|
+
mechanism single-implementation (#3079 D4); a fork of the mechanism per source is the failure
|
|
6
|
+
this design exists to prevent.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Callable, Dict, List, NamedTuple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# How an adapter came to offer a relation. `user_created` = the source's own users declared
|
|
14
|
+
# it (always application data); `named_system` = one of the source's platform tables the
|
|
15
|
+
# manifest names explicitly. None = the adapter applied no such distinction.
|
|
16
|
+
USER_CREATED = "user_created"
|
|
17
|
+
NAMED_SYSTEM = "named_system"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AdapterRelation(NamedTuple):
|
|
21
|
+
"""(schema, table, module) — plus a description the source itself owns, when it has one,
|
|
22
|
+
and the `origin` the adapter selected it under (see USER_CREATED / NAMED_SYSTEM)."""
|
|
23
|
+
|
|
24
|
+
schema: str
|
|
25
|
+
table: str
|
|
26
|
+
module: str | None = None
|
|
27
|
+
description: str | None = None
|
|
28
|
+
origin: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
AdapterFn = Callable[[str, Dict[str, Any]], List[AdapterRelation]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get(name: str) -> AdapterFn:
|
|
35
|
+
"""Resolve an adapter by manifest name. Unknown names fail loudly at validate time."""
|
|
36
|
+
if name == "featherbase":
|
|
37
|
+
from .featherbase import select as featherbase_select
|
|
38
|
+
|
|
39
|
+
return featherbase_select
|
|
40
|
+
raise KeyError(name)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def known() -> tuple:
|
|
44
|
+
return ("featherbase",)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Featherbase catalog adapter — `table_def` decides which relations exist (#2209, #3079 D4/D-13).
|
|
2
|
+
|
|
3
|
+
Constraints (each one is a live behaviour of Featherbase, not a preference):
|
|
4
|
+
- platform physical name = lower(Table name) with spaces → underscores
|
|
5
|
+
('Access Log' → access_log) in the catalog schema;
|
|
6
|
+
- current app Tables name their physical schema/relation in the catalog;
|
|
7
|
+
- only kind in (table, sub_table) — settings Tables are EAV rows, not relations;
|
|
8
|
+
- data_source IS NULL — a row with one is a reflection of an external source, not ours;
|
|
9
|
+
- a declared Table with no physical relation yet is SKIPPED, never an error;
|
|
10
|
+
- `options.system_tables` given ⇒ two-way rule: `table_def."system" = true` rows only when
|
|
11
|
+
named there, `false` rows (user-created) always; absent ⇒ everything, catalog included.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict, List
|
|
16
|
+
|
|
17
|
+
import psycopg
|
|
18
|
+
|
|
19
|
+
from . import NAMED_SYSTEM, USER_CREATED, AdapterRelation
|
|
20
|
+
|
|
21
|
+
# The catalog itself. Descriptions match the retired scripts/generate_featherbase_sources.py, which this
|
|
22
|
+
# adapter supersedes — the generated dbt file must not churn on the switch.
|
|
23
|
+
CATALOG_TABLES: Dict[str, str] = {
|
|
24
|
+
"table_def": "Featherbase catalog — one row per declared Table (the generation input for this file).",
|
|
25
|
+
"column_def": "Featherbase catalog — one row per declared Column.",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def select(dsn: str, options: Dict[str, Any] | None = None) -> List[AdapterRelation]:
|
|
30
|
+
"""Relations Featherbase's own catalog declares. `options.modules` is an allow-list of
|
|
31
|
+
module names; null/absent means every module. `options.schema` defaults to public.
|
|
32
|
+
`options.system_tables` (list of physical names) switches on the two-way rule: a platform
|
|
33
|
+
table (`"system" = true`) is offered ONLY when named there — `table_def`/`column_def`
|
|
34
|
+
included — and a user-created table (`"system" = false`) is always offered."""
|
|
35
|
+
options = options or {}
|
|
36
|
+
schema = str(options.get("schema") or "public")
|
|
37
|
+
raw_modules = options.get("modules")
|
|
38
|
+
modules = None if raw_modules is None else {str(m) for m in raw_modules}
|
|
39
|
+
raw_system = options.get("system_tables")
|
|
40
|
+
system_tables = None if raw_system is None else {str(t) for t in raw_system}
|
|
41
|
+
|
|
42
|
+
with psycopg.connect(dsn, connect_timeout=10) as conn:
|
|
43
|
+
conn.read_only = True
|
|
44
|
+
cols = {
|
|
45
|
+
r[0]
|
|
46
|
+
for r in conn.execute(
|
|
47
|
+
"select column_name from information_schema.columns "
|
|
48
|
+
"where table_schema = %s and table_name = 'table_def'",
|
|
49
|
+
(schema,),
|
|
50
|
+
).fetchall()
|
|
51
|
+
}
|
|
52
|
+
if not cols:
|
|
53
|
+
raise RuntimeError(f"{schema}.table_def not found — is this a Featherbase database?")
|
|
54
|
+
if system_tables is not None and "system" not in cols:
|
|
55
|
+
# Without the flag every row would read as user-created and the allow-list would
|
|
56
|
+
# admit every platform table — the opposite of what the option promises.
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
f"{schema}.table_def has no \"system\" column — options.system_tables needs it"
|
|
59
|
+
)
|
|
60
|
+
physical_cols = {"physical_schema", "physical_relation"}
|
|
61
|
+
if physical_cols & cols and not physical_cols <= cols:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"{schema}.table_def needs both physical_schema and physical_relation"
|
|
64
|
+
)
|
|
65
|
+
has_physical = physical_cols <= cols
|
|
66
|
+
desc = '"description"' if "description" in cols else "null"
|
|
67
|
+
system = '"system"' if "system" in cols else "false"
|
|
68
|
+
physical_schema = "physical_schema" if has_physical else "null"
|
|
69
|
+
physical_relation = "physical_relation" if has_physical else "null"
|
|
70
|
+
rows = conn.execute(
|
|
71
|
+
f'select "name", module, data_source, kind, {desc}, {system}, '
|
|
72
|
+
f'{physical_schema}, {physical_relation} from "{schema}".table_def'
|
|
73
|
+
).fetchall()
|
|
74
|
+
physical = {
|
|
75
|
+
(r[0], r[1])
|
|
76
|
+
for r in conn.execute(
|
|
77
|
+
"select schemaname, tablename from pg_tables"
|
|
78
|
+
).fetchall()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
out: Dict[tuple[str, str], AdapterRelation] = {}
|
|
82
|
+
for name, module, data_source, kind, description, is_system, physical_schema, physical_relation in rows:
|
|
83
|
+
derived_table = str(name).lower().replace(" ", "_")
|
|
84
|
+
if kind not in ("table", "sub_table"):
|
|
85
|
+
continue
|
|
86
|
+
if data_source is not None:
|
|
87
|
+
continue
|
|
88
|
+
if derived_table in CATALOG_TABLES:
|
|
89
|
+
continue # the catalog's own rows are handled below
|
|
90
|
+
if modules is not None and (module or "") not in modules:
|
|
91
|
+
continue
|
|
92
|
+
relation_schema = schema
|
|
93
|
+
relation_table = derived_table
|
|
94
|
+
if has_physical and not bool(is_system):
|
|
95
|
+
if physical_schema and physical_relation:
|
|
96
|
+
relation_schema = str(physical_schema)
|
|
97
|
+
relation_table = str(physical_relation)
|
|
98
|
+
elif "." in str(name):
|
|
99
|
+
# #4113: Featherbase's tableRelation() — a qualified (app-owned) Table is never
|
|
100
|
+
# located by its spelling; a plain one lives at the platform default above.
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
f"{schema}.table_def row {name!r} has no "
|
|
103
|
+
"physical_schema/physical_relation"
|
|
104
|
+
)
|
|
105
|
+
if (relation_schema, relation_table) not in physical:
|
|
106
|
+
continue # declared but not materialised yet
|
|
107
|
+
origin = None
|
|
108
|
+
if system_tables is not None:
|
|
109
|
+
if bool(is_system) and relation_table not in system_tables:
|
|
110
|
+
continue
|
|
111
|
+
origin = NAMED_SYSTEM if bool(is_system) else USER_CREATED
|
|
112
|
+
out[(relation_schema, relation_table)] = AdapterRelation(
|
|
113
|
+
relation_schema, relation_table, module, description or None, origin
|
|
114
|
+
)
|
|
115
|
+
for tbl, description in CATALOG_TABLES.items():
|
|
116
|
+
if (schema, tbl) not in physical:
|
|
117
|
+
continue
|
|
118
|
+
if system_tables is None:
|
|
119
|
+
out[(schema, tbl)] = AdapterRelation(
|
|
120
|
+
schema, tbl, None, description
|
|
121
|
+
) # unconditional (legacy)
|
|
122
|
+
elif tbl in system_tables:
|
|
123
|
+
out[(schema, tbl)] = AdapterRelation(
|
|
124
|
+
schema, tbl, None, description, NAMED_SYSTEM
|
|
125
|
+
)
|
|
126
|
+
return [out[k] for k in sorted(out)]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""`python -m feather_cdc_postgres <command> <manifest>` (#3079 D1).
|
|
2
|
+
|
|
3
|
+
Constraints:
|
|
4
|
+
- `validate` and `plan` MUTATE NOTHING — they open read-only connections and print. Anything
|
|
5
|
+
that would change either database belongs to `run`.
|
|
6
|
+
- no environment VALUE is ever printed; only the NAME of the variable is echoed.
|
|
7
|
+
- `run`/`status`/`resnapshot` import the runtime lazily, so the compiler half stays usable
|
|
8
|
+
(and testable) without dlt credentials or a destination.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import pathlib
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from . import discovery, generate_dbt, manifest as mf
|
|
20
|
+
from .adapters import AdapterRelation, get as get_adapter, known as known_adapters
|
|
21
|
+
from .selectors import CompiledPlan, compile as compile_selection
|
|
22
|
+
|
|
23
|
+
def _env(name: str) -> Optional[str]:
|
|
24
|
+
val = os.environ.get(name)
|
|
25
|
+
return val or None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve(m: mf.Manifest) -> Tuple[Optional[str], List[mf.Problem]]:
|
|
29
|
+
"""The source DSN, or a problem naming the variable that is unset — never its value."""
|
|
30
|
+
dsn = _env(m.source.dsn_env)
|
|
31
|
+
if not dsn:
|
|
32
|
+
return None, [mf.Problem("env_not_set", "error", f"environment variable {m.source.dsn_env} is not set")]
|
|
33
|
+
return dsn, []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _adapter_relations(m: mf.Manifest, dsn: str) -> Tuple[List[AdapterRelation], List[mf.Problem]]:
|
|
37
|
+
if m.source.discovery is None:
|
|
38
|
+
return [], []
|
|
39
|
+
try:
|
|
40
|
+
fn = get_adapter(m.source.discovery.adapter)
|
|
41
|
+
except KeyError:
|
|
42
|
+
return [], [
|
|
43
|
+
mf.Problem(
|
|
44
|
+
"adapter_unknown",
|
|
45
|
+
"error",
|
|
46
|
+
f"discovery adapter {m.source.discovery.adapter!r} is unknown (known: {', '.join(known_adapters())})",
|
|
47
|
+
)
|
|
48
|
+
]
|
|
49
|
+
return list(fn(dsn, m.source.discovery.options)), []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _compile(m: mf.Manifest, *, with_publication: bool) -> Tuple[Optional[CompiledPlan], List[mf.Problem], Dict]:
|
|
53
|
+
problems = list(mf.validate_static(m))
|
|
54
|
+
dsn, dsn_problems = _resolve(m)
|
|
55
|
+
problems += dsn_problems
|
|
56
|
+
if dsn is None:
|
|
57
|
+
return None, problems, {}
|
|
58
|
+
relations = discovery.discover(dsn)
|
|
59
|
+
adapter_rels, adapter_problems = _adapter_relations(m, dsn)
|
|
60
|
+
problems += adapter_problems
|
|
61
|
+
published = discovery.publication_relations(dsn, m.replication.publication) if with_publication else None
|
|
62
|
+
plan = compile_selection(m, relations, adapter_rels, published)
|
|
63
|
+
extra: Dict = {}
|
|
64
|
+
if with_publication:
|
|
65
|
+
extra["publication_exists"] = published is not None
|
|
66
|
+
extra["slot"] = discovery.slot_state(dsn, m.replication.slot)
|
|
67
|
+
return plan, problems + list(plan.problems), extra
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _emit(payload: Dict, lines: List[str], as_json: bool) -> None:
|
|
71
|
+
text = json.dumps(payload, indent=2, default=str) if as_json else "\n".join(lines)
|
|
72
|
+
print(mf.redact(text))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _problem_lines(problems: List[mf.Problem]) -> List[str]:
|
|
76
|
+
if not problems:
|
|
77
|
+
return ["problems: none"]
|
|
78
|
+
return ["problems:"] + [f" {p}" for p in problems]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _relation_lines(plan: CompiledPlan) -> List[str]:
|
|
82
|
+
width = max((len(r.qualified) for r in plan.relations), default=10)
|
|
83
|
+
out = [f"relations ({len(plan.relations)}):"]
|
|
84
|
+
for r in plan.relations:
|
|
85
|
+
key = ",".join(r.key) or "-"
|
|
86
|
+
idx = f" via {r.key_index}" if r.key_index else ""
|
|
87
|
+
origin = f" origin={r.origin}" if r.origin else ""
|
|
88
|
+
# The column projection (#4346): these never leave the CDC process.
|
|
89
|
+
deny = f" deny={','.join(r.deny_columns)}" if r.deny_columns else ""
|
|
90
|
+
out.append(
|
|
91
|
+
f" {r.qualified:<{width}} -> {r.target:<{width}} {r.key_policy:<12} key={key}{idx} "
|
|
92
|
+
f"class={r.table_class}{origin}{deny}"
|
|
93
|
+
)
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_validate(args) -> int:
|
|
98
|
+
m = mf.load(args.manifest)
|
|
99
|
+
plan, problems, _ = _compile(m, with_publication=False)
|
|
100
|
+
lines = [f"manifest: {args.manifest}", f"source_id: {m.source_id}", f"dsn from: ${m.source.dsn_env}"]
|
|
101
|
+
payload: Dict = {"source_id": m.source_id, "problems": [p.__dict__ for p in problems]}
|
|
102
|
+
if plan is not None:
|
|
103
|
+
lines += _relation_lines(plan) + [f"selection_hash: {plan.selection_hash}"]
|
|
104
|
+
payload["relations"] = [r.__dict__ for r in plan.relations]
|
|
105
|
+
payload["selection_hash"] = plan.selection_hash
|
|
106
|
+
lines += _problem_lines(problems)
|
|
107
|
+
_emit(payload, lines, args.json)
|
|
108
|
+
return 1 if mf.errors(problems) else 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_plan(args) -> int:
|
|
112
|
+
m = mf.load(args.manifest)
|
|
113
|
+
plan, problems, extra = _compile(m, with_publication=True)
|
|
114
|
+
lines = [f"manifest: {args.manifest}", f"source_id: {m.source_id}", "PLAN — nothing is mutated by this command"]
|
|
115
|
+
payload: Dict = {"source_id": m.source_id, "problems": [p.__dict__ for p in problems], **extra}
|
|
116
|
+
if plan is not None:
|
|
117
|
+
lines += _relation_lines(plan)
|
|
118
|
+
lines.append(f"publication {m.replication.publication}: " + ("exists" if extra.get("publication_exists") else "MISSING — would be created"))
|
|
119
|
+
lines.append(f" add ({len(plan.publication_add)}): " + (", ".join(f"{s}.{t}" for s, t in plan.publication_add) or "-"))
|
|
120
|
+
lines.append(f" drop ({len(plan.publication_drop)}): " + (", ".join(f"{s}.{t}" for s, t in plan.publication_drop) or "-"))
|
|
121
|
+
lines.append(
|
|
122
|
+
f"replica identity ALTERs ({len(plan.replica_identity_alters)}): "
|
|
123
|
+
+ (", ".join(f"{s}.{t}" for s, t in plan.replica_identity_alters) or "-")
|
|
124
|
+
)
|
|
125
|
+
lines.append("denied hits: " + (", ".join(plan.denied_hits) or "-"))
|
|
126
|
+
lines.append(f"selection_hash: {plan.selection_hash}")
|
|
127
|
+
slot = extra.get("slot") or {}
|
|
128
|
+
lines.append(
|
|
129
|
+
f"slot {m.replication.slot}: "
|
|
130
|
+
+ ("absent — would be created" if not slot.get("exists") else
|
|
131
|
+
f"wal_status={slot.get('wal_status')} lag_bytes={slot.get('lag_bytes')} active={slot.get('active')}")
|
|
132
|
+
+ f" · server max_slot_wal_keep_size={slot.get('max_slot_wal_keep_size_bytes') or 'unlimited'}"
|
|
133
|
+
)
|
|
134
|
+
payload.update(
|
|
135
|
+
relations=[r.__dict__ for r in plan.relations],
|
|
136
|
+
publication_add=plan.publication_add,
|
|
137
|
+
publication_drop=plan.publication_drop,
|
|
138
|
+
replica_identity_alters=plan.replica_identity_alters,
|
|
139
|
+
denied_hits=plan.denied_hits,
|
|
140
|
+
selection_hash=plan.selection_hash,
|
|
141
|
+
)
|
|
142
|
+
lines += _problem_lines(problems)
|
|
143
|
+
_emit(payload, lines, args.json)
|
|
144
|
+
return 1 if mf.errors(problems) else 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_generate_dbt(args) -> int:
|
|
148
|
+
m = mf.load(args.manifest)
|
|
149
|
+
plan, problems, _ = _compile(m, with_publication=False)
|
|
150
|
+
if plan is None or mf.errors(problems):
|
|
151
|
+
_emit({"problems": [p.__dict__ for p in problems]}, _problem_lines(problems), args.json)
|
|
152
|
+
return 1
|
|
153
|
+
out = pathlib.Path(args.out)
|
|
154
|
+
generate_dbt.write(m, plan, out)
|
|
155
|
+
_emit(
|
|
156
|
+
{"out": str(out), "tables": len(plan.relations)},
|
|
157
|
+
[f"wrote {out} — {len(plan.relations)} tables + _dlt_loads"],
|
|
158
|
+
args.json,
|
|
159
|
+
)
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _runner():
|
|
164
|
+
"""The runtime half (#3079 Phase 2). Contract expected here:
|
|
165
|
+
|
|
166
|
+
runner.run(manifest: Manifest, *, dev: bool, manifest_path: str) -> int
|
|
167
|
+
runner.status(manifest: Manifest) -> int
|
|
168
|
+
runner.resnapshot(manifest: Manifest, *, tables, yes: bool, dev: bool) -> int
|
|
169
|
+
"""
|
|
170
|
+
from . import runner # noqa: PLC0415 # TODO(runner): owned by the runtime half
|
|
171
|
+
|
|
172
|
+
return runner
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cmd_run(args) -> int:
|
|
176
|
+
return int(_runner().run(mf.load(args.manifest), dev=args.dev, manifest_path=args.manifest) or 0)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_status(args) -> int:
|
|
180
|
+
return int(_runner().status(mf.load(args.manifest)) or 0)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cmd_resnapshot(args) -> int:
|
|
184
|
+
return int(_runner().resnapshot(mf.load(args.manifest), tables=args.table or None,
|
|
185
|
+
yes=args.yes, dev=args.dev) or 0)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
189
|
+
p = argparse.ArgumentParser(prog="feather_cdc_postgres", description="Declarative PostgreSQL → MotherDuck CDC runtime")
|
|
190
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
191
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
192
|
+
|
|
193
|
+
for name, fn, helptext in (
|
|
194
|
+
("validate", cmd_validate, "static + read-only checks; prints the compiled relation set"),
|
|
195
|
+
("plan", cmd_plan, "validate + every change a run would make; mutates nothing"),
|
|
196
|
+
):
|
|
197
|
+
s = sub.add_parser(name, help=helptext)
|
|
198
|
+
s.add_argument("manifest")
|
|
199
|
+
s.set_defaults(func=fn)
|
|
200
|
+
|
|
201
|
+
s = sub.add_parser("generate-dbt", help="write dbt source declarations for this registration")
|
|
202
|
+
s.add_argument("manifest")
|
|
203
|
+
# REQUIRED, and it has no default on purpose: the default used to be
|
|
204
|
+
# `<repo root>/dbt_runner/dbt/models/<source_id>/...`, resolved four parents up from this
|
|
205
|
+
# file. That is one warehouse's directory layout, and a runtime installed from a wheel sits
|
|
206
|
+
# in site-packages, where four parents up is somebody's virtualenv. The caller owns the path.
|
|
207
|
+
s.add_argument("--out", required=True, help="output path for the generated sources YAML")
|
|
208
|
+
s.set_defaults(func=cmd_generate_dbt)
|
|
209
|
+
|
|
210
|
+
s = sub.add_parser("run", help="reconcile, snapshot and drain one tick")
|
|
211
|
+
s.add_argument("manifest")
|
|
212
|
+
s.add_argument("--dev", action="store_true", help="local destination, no control-plane writes")
|
|
213
|
+
s.set_defaults(func=cmd_run)
|
|
214
|
+
|
|
215
|
+
s = sub.add_parser("status", help="lifecycle, checkpoint and slot health")
|
|
216
|
+
s.add_argument("manifest")
|
|
217
|
+
s.set_defaults(func=cmd_status)
|
|
218
|
+
|
|
219
|
+
s = sub.add_parser("resnapshot", help="re-baseline selected tables")
|
|
220
|
+
s.add_argument("manifest")
|
|
221
|
+
# `--tables` is the spelling the drift remedy text prints; both names, one destination.
|
|
222
|
+
s.add_argument("--table", "--tables", dest="table", action="append",
|
|
223
|
+
help="schema.table (repeatable); default every relation")
|
|
224
|
+
s.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
|
|
225
|
+
s.add_argument("--dev", action="store_true", help="local destination, no control-plane writes")
|
|
226
|
+
s.set_defaults(func=cmd_resnapshot)
|
|
227
|
+
return p
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
231
|
+
args = build_parser().parse_args(argv)
|
|
232
|
+
try:
|
|
233
|
+
return args.func(args)
|
|
234
|
+
except mf.ManifestError as exc:
|
|
235
|
+
print(f"[error] {mf.redact(str(exc))}", file=sys.stderr)
|
|
236
|
+
return 2
|
|
237
|
+
except Exception as exc: # noqa: BLE001
|
|
238
|
+
# Anything else — an unreachable source, a bad adapter, a duckdb error. A raw traceback
|
|
239
|
+
# would print psycopg's `connection to server at ...` line (and dlt's config exceptions
|
|
240
|
+
# echo the value they resolved) straight into Railway's log; redact() is the only thing
|
|
241
|
+
# standing between that and a DSN in a log line. SystemExit/KeyboardInterrupt are not
|
|
242
|
+
# Exception, so `sys.exit("FATAL: …")` from the runtime still propagates untouched.
|
|
243
|
+
message = mf.redact(f"{type(exc).__name__}: {exc}")
|
|
244
|
+
print(f"[error] {message}", file=sys.stderr)
|
|
245
|
+
if getattr(args, "json", False):
|
|
246
|
+
print(json.dumps({"error": type(exc).__name__, "message": message}, indent=2))
|
|
247
|
+
return 1
|