hindsightdb 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.
- hindsight/__init__.py +251 -0
- hindsight/canonical.py +145 -0
- hindsight/cli/__init__.py +0 -0
- hindsight/cli/apply.py +93 -0
- hindsight/cli/audit.py +180 -0
- hindsight/cli/check.py +46 -0
- hindsight/cli/client.py +193 -0
- hindsight/cli/config.py +150 -0
- hindsight/cli/doctor.py +182 -0
- hindsight/cli/genesis.py +155 -0
- hindsight/cli/main.py +392 -0
- hindsight/cli/materialize.py +41 -0
- hindsight/cli/reconcile.py +259 -0
- hindsight/cli/scan.py +64 -0
- hindsight/cli/wal.py +120 -0
- hindsight/config.py +180 -0
- hindsight/contract.py +91 -0
- hindsight/keys.py +35 -0
- hindsight/outbox/__init__.py +1 -0
- hindsight/outbox/adapter.py +65 -0
- hindsight/outbox/catalog.py +111 -0
- hindsight/outbox/db.py +135 -0
- hindsight/outbox/shipper.py +328 -0
- hindsight/outbox/spec.py +43 -0
- hindsight/outbox/sql.py +190 -0
- hindsight/py.typed +0 -0
- hindsight/queue.py +95 -0
- hindsight/restore.py +104 -0
- hindsight/shipper.py +213 -0
- hindsight/sqlalchemy/__init__.py +4 -0
- hindsight/sqlalchemy/adapter.py +403 -0
- hindsight/sqlalchemy/manifest.py +89 -0
- hindsight/transport.py +52 -0
- hindsight/wal/__init__.py +25 -0
- hindsight/wal/pgoutput.py +372 -0
- hindsight/wal/reader.py +695 -0
- hindsight/wal/sql.py +137 -0
- hindsightdb-0.1.0.dist-info/METADATA +36 -0
- hindsightdb-0.1.0.dist-info/RECORD +43 -0
- hindsightdb-0.1.0.dist-info/WHEEL +5 -0
- hindsightdb-0.1.0.dist-info/entry_points.txt +2 -0
- hindsightdb-0.1.0.dist-info/licenses/LICENSE +201 -0
- hindsightdb-0.1.0.dist-info/top_level.txt +1 -0
hindsight/__init__.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Public API for Hindsight capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from hindsight.config import CaptureConfig
|
|
12
|
+
from hindsight.contract import SDK_VERSION
|
|
13
|
+
from hindsight.keys import parse_key, resolve_key, resolve_url
|
|
14
|
+
from hindsight.queue import RowQueue
|
|
15
|
+
from hindsight.shipper import Shipper
|
|
16
|
+
from hindsight.transport import Transport, UrllibTransport
|
|
17
|
+
|
|
18
|
+
__all__ = ["init", "shutdown", "flush", "stats", "Client", "CaptureConfig", "SDK_VERSION"]
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("hindsight")
|
|
21
|
+
_client: Client | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Client:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
slug: str,
|
|
29
|
+
transport: Transport,
|
|
30
|
+
cfg: CaptureConfig,
|
|
31
|
+
queue_size: int = 10_000,
|
|
32
|
+
batch_rows: int = 500,
|
|
33
|
+
batch_interval: float = 2.0,
|
|
34
|
+
spool_dir: str | None = None,
|
|
35
|
+
producer: str = "orm",
|
|
36
|
+
):
|
|
37
|
+
self.slug = slug
|
|
38
|
+
self.cfg = cfg
|
|
39
|
+
self.queue = RowQueue(queue_size)
|
|
40
|
+
self.shipper = Shipper(
|
|
41
|
+
transport,
|
|
42
|
+
slug,
|
|
43
|
+
self.queue,
|
|
44
|
+
producer=producer,
|
|
45
|
+
batch_rows=batch_rows,
|
|
46
|
+
batch_interval=batch_interval,
|
|
47
|
+
spool_dir=spool_dir,
|
|
48
|
+
)
|
|
49
|
+
self.adapter: Any = None
|
|
50
|
+
self.outbox: Any = None
|
|
51
|
+
self.manifest: dict[str, Any] | None = None
|
|
52
|
+
|
|
53
|
+
def set_metadata(self, metadata: Any) -> None:
|
|
54
|
+
from hindsight.sqlalchemy.manifest import build_manifest
|
|
55
|
+
|
|
56
|
+
self.manifest = build_manifest(metadata, self.cfg)
|
|
57
|
+
self.shipper.set_manifest(self.manifest)
|
|
58
|
+
|
|
59
|
+
def attach_sqlalchemy(
|
|
60
|
+
self, engine: Any, context_provider: Callable[[], dict[str, Any] | None] | None
|
|
61
|
+
) -> None:
|
|
62
|
+
from hindsight.sqlalchemy.adapter import SQLAlchemyAdapter
|
|
63
|
+
|
|
64
|
+
self.adapter = SQLAlchemyAdapter(
|
|
65
|
+
self.queue,
|
|
66
|
+
self.cfg,
|
|
67
|
+
engine=engine,
|
|
68
|
+
context_provider=context_provider,
|
|
69
|
+
on_manifest=self.set_metadata,
|
|
70
|
+
)
|
|
71
|
+
self.adapter.install()
|
|
72
|
+
|
|
73
|
+
def attach_outbox(
|
|
74
|
+
self,
|
|
75
|
+
dsn: str,
|
|
76
|
+
*,
|
|
77
|
+
engine: Any = None,
|
|
78
|
+
context_provider: Callable[[], dict[str, Any] | None] | None = None,
|
|
79
|
+
metadata: Any = None,
|
|
80
|
+
auto_install: bool = False,
|
|
81
|
+
batch_rows: int = 500,
|
|
82
|
+
batch_interval: float = 2.0,
|
|
83
|
+
) -> None:
|
|
84
|
+
from hindsight.outbox.adapter import OutboxAdapter
|
|
85
|
+
from hindsight.outbox.catalog import installed_triggers, introspect, manifest_from_catalog
|
|
86
|
+
from hindsight.outbox.db import Database, connector
|
|
87
|
+
from hindsight.outbox.shipper import OutboxShipper
|
|
88
|
+
from hindsight.outbox.spec import specs_from_manifest
|
|
89
|
+
from hindsight.outbox.sql import install_sql
|
|
90
|
+
|
|
91
|
+
db = Database(connector(dsn))
|
|
92
|
+
if metadata is not None:
|
|
93
|
+
from hindsight.sqlalchemy.manifest import build_manifest
|
|
94
|
+
|
|
95
|
+
self.manifest = build_manifest(metadata, self.cfg, mode="outbox")
|
|
96
|
+
else:
|
|
97
|
+
self.manifest = manifest_from_catalog(introspect(db), self.cfg)
|
|
98
|
+
self.shipper.set_manifest(self.manifest)
|
|
99
|
+
specs = specs_from_manifest(self.manifest, self.cfg)
|
|
100
|
+
if auto_install:
|
|
101
|
+
db.run(install_sql(specs))
|
|
102
|
+
have = installed_triggers(db)
|
|
103
|
+
missing = [s.name for s in specs if s.name not in have]
|
|
104
|
+
if missing:
|
|
105
|
+
log.warning(
|
|
106
|
+
"hindsight: %d captured tables have no trigger (run `hindsight apply`): %s",
|
|
107
|
+
len(missing),
|
|
108
|
+
", ".join(missing[:10]),
|
|
109
|
+
)
|
|
110
|
+
begin_ok = db.fetchrow("SELECT to_regprocedure('capture_begin(jsonb)') IS NOT NULL")
|
|
111
|
+
if begin_ok and begin_ok[0]:
|
|
112
|
+
self.adapter = OutboxAdapter(engine=engine, context_provider=context_provider)
|
|
113
|
+
self.adapter.install()
|
|
114
|
+
else:
|
|
115
|
+
log.warning("hindsight: capture_begin() is missing — actor attribution is off")
|
|
116
|
+
self.outbox = OutboxShipper(
|
|
117
|
+
db,
|
|
118
|
+
self.shipper,
|
|
119
|
+
[s.name for s in specs],
|
|
120
|
+
batch_rows=batch_rows,
|
|
121
|
+
interval=batch_interval,
|
|
122
|
+
max_rows=self.cfg.outbox_max_rows,
|
|
123
|
+
max_bytes=self.cfg.outbox_max_bytes,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def start(self) -> None:
|
|
127
|
+
if self.outbox is not None:
|
|
128
|
+
self.outbox.start()
|
|
129
|
+
else:
|
|
130
|
+
self.shipper.start()
|
|
131
|
+
|
|
132
|
+
def flush(self, timeout: float = 10.0) -> int:
|
|
133
|
+
import time
|
|
134
|
+
|
|
135
|
+
deadline = time.monotonic() + timeout
|
|
136
|
+
if self.outbox is not None:
|
|
137
|
+
return int(self.outbox.drain(deadline=deadline))
|
|
138
|
+
return self.shipper.flush(deadline=deadline)
|
|
139
|
+
|
|
140
|
+
def close(self, timeout: float = 5.0) -> None:
|
|
141
|
+
if self.adapter is not None:
|
|
142
|
+
self.adapter.uninstall()
|
|
143
|
+
if self.outbox is not None:
|
|
144
|
+
self.outbox.stop(timeout)
|
|
145
|
+
self.shipper.stop(timeout)
|
|
146
|
+
|
|
147
|
+
def stats(self) -> dict[str, Any]:
|
|
148
|
+
return {
|
|
149
|
+
"slug": self.slug,
|
|
150
|
+
"boot_id": self.shipper.boot_id,
|
|
151
|
+
"queue": self.queue.stats(),
|
|
152
|
+
"shipper": dict(self.shipper.stats),
|
|
153
|
+
"adapter": dict(self.adapter.stats) if self.adapter else {},
|
|
154
|
+
"outbox": dict(self.outbox.stats) if self.outbox else {},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def init(
|
|
159
|
+
engine: Any = None,
|
|
160
|
+
*,
|
|
161
|
+
key: str | None = None,
|
|
162
|
+
url: str | None = None,
|
|
163
|
+
config: str | CaptureConfig | None = None,
|
|
164
|
+
context_provider: Callable[[], dict[str, Any] | None] | None = None,
|
|
165
|
+
metadata: Any = None,
|
|
166
|
+
mode: str | None = None,
|
|
167
|
+
dsn: str | None = None,
|
|
168
|
+
auto_install: bool = False,
|
|
169
|
+
spool_dir: str | None = None,
|
|
170
|
+
queue_size: int = 10_000,
|
|
171
|
+
batch_rows: int = 500,
|
|
172
|
+
batch_interval: float = 2.0,
|
|
173
|
+
transport: Transport | None = None,
|
|
174
|
+
start: bool = True,
|
|
175
|
+
) -> Client | None:
|
|
176
|
+
"""Start capture, returning ``None`` when capture is unavailable."""
|
|
177
|
+
global _client
|
|
178
|
+
try:
|
|
179
|
+
raw_key = resolve_key(key)
|
|
180
|
+
if not raw_key:
|
|
181
|
+
log.warning("hindsight: no HINDSIGHT_KEY — capture is off")
|
|
182
|
+
return None
|
|
183
|
+
slug, raw_key = parse_key(raw_key)
|
|
184
|
+
cfg = config if isinstance(config, CaptureConfig) else CaptureConfig.load(config)
|
|
185
|
+
if mode:
|
|
186
|
+
cfg.mode = mode
|
|
187
|
+
if cfg.mode not in ("orm", "outbox"):
|
|
188
|
+
log.warning("hindsight: unknown mode %r; using orm", cfg.mode)
|
|
189
|
+
cfg.mode = "orm"
|
|
190
|
+
if _client is not None:
|
|
191
|
+
_client.close(0.0)
|
|
192
|
+
client = Client(
|
|
193
|
+
slug=slug,
|
|
194
|
+
transport=transport or UrllibTransport(resolve_url(url), raw_key),
|
|
195
|
+
cfg=cfg,
|
|
196
|
+
queue_size=queue_size,
|
|
197
|
+
batch_rows=batch_rows,
|
|
198
|
+
batch_interval=batch_interval,
|
|
199
|
+
spool_dir=spool_dir,
|
|
200
|
+
producer=cfg.mode,
|
|
201
|
+
)
|
|
202
|
+
if cfg.mode == "outbox":
|
|
203
|
+
from hindsight.outbox.db import dsn_of_engine
|
|
204
|
+
|
|
205
|
+
dsn = dsn or os.environ.get("HINDSIGHT_DSN") or dsn_of_engine(engine)
|
|
206
|
+
if not dsn:
|
|
207
|
+
raise RuntimeError("outbox mode needs dsn= (or HINDSIGHT_DSN, or an engine)")
|
|
208
|
+
client.attach_outbox(
|
|
209
|
+
dsn,
|
|
210
|
+
engine=engine,
|
|
211
|
+
context_provider=context_provider,
|
|
212
|
+
metadata=metadata,
|
|
213
|
+
auto_install=auto_install,
|
|
214
|
+
batch_rows=batch_rows,
|
|
215
|
+
batch_interval=batch_interval,
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
if metadata is not None:
|
|
219
|
+
client.set_metadata(metadata)
|
|
220
|
+
if engine is not None or metadata is not None:
|
|
221
|
+
client.attach_sqlalchemy(engine, context_provider)
|
|
222
|
+
if start:
|
|
223
|
+
client.start()
|
|
224
|
+
_client = client
|
|
225
|
+
atexit.register(_atexit)
|
|
226
|
+
log.info("hindsight: capturing for source %r (boot %s)", slug, client.shipper.boot_id)
|
|
227
|
+
return client
|
|
228
|
+
except Exception:
|
|
229
|
+
log.exception("hindsight: init failed — capture is off, the app is unaffected")
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _atexit() -> None:
|
|
234
|
+
shutdown(timeout=3.0)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def shutdown(timeout: float = 5.0) -> None:
|
|
238
|
+
global _client
|
|
239
|
+
if _client is not None:
|
|
240
|
+
try:
|
|
241
|
+
_client.close(timeout)
|
|
242
|
+
finally:
|
|
243
|
+
_client = None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def flush(timeout: float = 10.0) -> int:
|
|
247
|
+
return _client.flush(timeout) if _client else 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def stats() -> dict[str, Any]:
|
|
251
|
+
return _client.stats() if _client else {}
|
hindsight/canonical.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Type-aware normalization and hashing for captured rows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import decimal
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
_NUMERIC = ("int", "numeric", "decimal", "real", "double", "money", "serial", "float")
|
|
13
|
+
_TZ = ("timestamptz", "with time zone")
|
|
14
|
+
_JSON = ("json",)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def kind_of(pg_type: str) -> str:
|
|
18
|
+
"""Map a PostgreSQL type to its normalization family."""
|
|
19
|
+
t = (pg_type or "").lower().strip()
|
|
20
|
+
if t.endswith("[]") or t.startswith("_") or t.startswith("array"):
|
|
21
|
+
return "array"
|
|
22
|
+
if any(k in t for k in _JSON):
|
|
23
|
+
return "json"
|
|
24
|
+
if t.startswith("bool"):
|
|
25
|
+
return "bool"
|
|
26
|
+
if any(k in t for k in _TZ):
|
|
27
|
+
return "timestamptz"
|
|
28
|
+
if t.startswith("timestamp") or t.startswith("date") or t.startswith("time"):
|
|
29
|
+
return "datetime"
|
|
30
|
+
if t == "uuid":
|
|
31
|
+
return "uuid"
|
|
32
|
+
if any(k in t for k in _NUMERIC):
|
|
33
|
+
return "numeric"
|
|
34
|
+
return "text"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def element_type(pg_type: str) -> str:
|
|
38
|
+
t = (pg_type or "").lower().strip()
|
|
39
|
+
if t.endswith("[]"):
|
|
40
|
+
return t[:-2]
|
|
41
|
+
if t.startswith("_"):
|
|
42
|
+
return t[1:]
|
|
43
|
+
return "text"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _decimal(value: Any) -> str:
|
|
47
|
+
try:
|
|
48
|
+
d = decimal.Decimal(str(value))
|
|
49
|
+
except (decimal.InvalidOperation, ValueError):
|
|
50
|
+
return str(value)
|
|
51
|
+
if not d.is_finite():
|
|
52
|
+
return str(d)
|
|
53
|
+
d = d.normalize()
|
|
54
|
+
if d == 0:
|
|
55
|
+
return "0"
|
|
56
|
+
return format(d, "f")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_dt(value: Any) -> Any:
|
|
60
|
+
if isinstance(value, dt.datetime):
|
|
61
|
+
return value
|
|
62
|
+
if isinstance(value, str):
|
|
63
|
+
s = value.strip().replace(" ", "T", 1) if " " in value.strip() else value.strip()
|
|
64
|
+
if s.endswith("Z"):
|
|
65
|
+
s = s[:-1] + "+00:00"
|
|
66
|
+
try:
|
|
67
|
+
return dt.datetime.fromisoformat(s)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def canonical_value(value: Any, pg_type: str) -> Any:
|
|
74
|
+
"""Normalize a value using its PostgreSQL type."""
|
|
75
|
+
if value is None:
|
|
76
|
+
return None
|
|
77
|
+
kind = kind_of(pg_type)
|
|
78
|
+
if kind == "numeric":
|
|
79
|
+
if isinstance(value, bool):
|
|
80
|
+
return "1" if value else "0"
|
|
81
|
+
return _decimal(value)
|
|
82
|
+
if kind == "bool":
|
|
83
|
+
if isinstance(value, str):
|
|
84
|
+
return value.strip().lower() in ("t", "true", "1", "yes", "on")
|
|
85
|
+
return bool(value)
|
|
86
|
+
if kind == "timestamptz":
|
|
87
|
+
parsed = _parse_dt(value)
|
|
88
|
+
if parsed is None:
|
|
89
|
+
return str(value)
|
|
90
|
+
if parsed.tzinfo is None:
|
|
91
|
+
parsed = parsed.replace(tzinfo=dt.UTC)
|
|
92
|
+
return parsed.astimezone(dt.UTC).isoformat()
|
|
93
|
+
if kind == "datetime":
|
|
94
|
+
if isinstance(value, (dt.datetime, dt.date, dt.time)):
|
|
95
|
+
return value.isoformat()
|
|
96
|
+
return str(value).strip().replace(" ", "T", 1)
|
|
97
|
+
if kind == "uuid":
|
|
98
|
+
if isinstance(value, uuid.UUID):
|
|
99
|
+
return str(value)
|
|
100
|
+
return str(value).strip().lower()
|
|
101
|
+
if kind == "json":
|
|
102
|
+
if isinstance(value, str):
|
|
103
|
+
try:
|
|
104
|
+
return json.loads(value)
|
|
105
|
+
except ValueError:
|
|
106
|
+
return value
|
|
107
|
+
return value
|
|
108
|
+
if kind == "array":
|
|
109
|
+
if isinstance(value, str):
|
|
110
|
+
try:
|
|
111
|
+
value = json.loads(value)
|
|
112
|
+
except ValueError:
|
|
113
|
+
return value
|
|
114
|
+
if isinstance(value, (list, tuple)):
|
|
115
|
+
et = element_type(pg_type)
|
|
116
|
+
return [canonical_value(v, et) for v in value]
|
|
117
|
+
return value
|
|
118
|
+
if isinstance(value, (dict, list)):
|
|
119
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
|
120
|
+
if isinstance(value, bool):
|
|
121
|
+
return "true" if value else "false"
|
|
122
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
123
|
+
return bytes(value).hex()
|
|
124
|
+
return str(value)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def captured_columns(table: dict[str, Any]) -> dict[str, str]:
|
|
128
|
+
"""Return captured column names and types."""
|
|
129
|
+
cols = table.get("columns") or {}
|
|
130
|
+
return {
|
|
131
|
+
name: str((spec or {}).get("pg_type") or "text")
|
|
132
|
+
for name, spec in sorted(cols.items())
|
|
133
|
+
if (spec or {}).get("captured", True) and not name.startswith("_")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def canonical_row(values: dict[str, Any], columns: dict[str, str]) -> dict[str, Any]:
|
|
138
|
+
return {name: canonical_value(values.get(name), pg_type) for name, pg_type in columns.items()}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def row_hash(values: dict[str, Any], columns: dict[str, str]) -> str:
|
|
142
|
+
canonical = json.dumps(
|
|
143
|
+
canonical_row(values, columns), sort_keys=True, separators=(",", ":"), default=str
|
|
144
|
+
)
|
|
145
|
+
return hashlib.sha256(canonical.encode()).hexdigest()[:32]
|
|
File without changes
|
hindsight/cli/apply.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Install, validate, or resume outbox capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from hindsight.config import CaptureConfig
|
|
9
|
+
from hindsight.outbox.catalog import installed_triggers, introspect, manifest_from_catalog
|
|
10
|
+
from hindsight.outbox.db import Database, connector
|
|
11
|
+
from hindsight.outbox.spec import TableSpec, specs_from_manifest
|
|
12
|
+
from hindsight.outbox.sql import emit_migration, install_sql
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def open_db(dsn: str) -> Database:
|
|
16
|
+
return Database(connector(dsn))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_specs(
|
|
20
|
+
cfg: CaptureConfig, *, metadata: Any = None, db: Database | None = None
|
|
21
|
+
) -> list[TableSpec]:
|
|
22
|
+
if metadata is not None:
|
|
23
|
+
from hindsight.sqlalchemy.manifest import build_manifest
|
|
24
|
+
|
|
25
|
+
return specs_from_manifest(build_manifest(metadata, cfg, mode="outbox"), cfg)
|
|
26
|
+
if db is None:
|
|
27
|
+
raise ValueError("apply needs --metadata or --dsn")
|
|
28
|
+
return specs_from_manifest(manifest_from_catalog(introspect(db), cfg), cfg)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def apply(
|
|
32
|
+
cfg: CaptureConfig,
|
|
33
|
+
*,
|
|
34
|
+
metadata: Any = None,
|
|
35
|
+
dsn: str | None = None,
|
|
36
|
+
emit_migration_to: str | None = None,
|
|
37
|
+
down_revision: str | None = None,
|
|
38
|
+
dry_run: bool = False,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
db = open_db(dsn) if dsn else None
|
|
41
|
+
try:
|
|
42
|
+
specs = build_specs(cfg, metadata=metadata, db=db)
|
|
43
|
+
statements = install_sql(specs)
|
|
44
|
+
if emit_migration_to is not None:
|
|
45
|
+
text = emit_migration(specs, revision=secrets.token_hex(6), down_revision=down_revision)
|
|
46
|
+
if emit_migration_to != "-":
|
|
47
|
+
with open(emit_migration_to, "w") as fh:
|
|
48
|
+
fh.write(text)
|
|
49
|
+
return {"tables": [s.name for s in specs], "migration": text, "applied": False}
|
|
50
|
+
if dry_run or db is None:
|
|
51
|
+
return {"tables": [s.name for s in specs], "sql": statements, "applied": False}
|
|
52
|
+
db.run(statements)
|
|
53
|
+
return {
|
|
54
|
+
"tables": [s.name for s in specs],
|
|
55
|
+
"applied": True,
|
|
56
|
+
"triggers": installed_triggers(db),
|
|
57
|
+
}
|
|
58
|
+
finally:
|
|
59
|
+
if db is not None:
|
|
60
|
+
db.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resume(cfg: CaptureConfig, *, dsn: str, metadata: Any = None) -> dict[str, Any]:
|
|
64
|
+
from hindsight.outbox.shipper import read_state
|
|
65
|
+
from hindsight.outbox.shipper import resume as _resume
|
|
66
|
+
|
|
67
|
+
db = open_db(dsn)
|
|
68
|
+
try:
|
|
69
|
+
tables = [s.name for s in build_specs(cfg, metadata=metadata, db=db)]
|
|
70
|
+
previous = _resume(db, tables)
|
|
71
|
+
return {"tables": tables, "previous": previous, "state": read_state(db, "breaker")}
|
|
72
|
+
finally:
|
|
73
|
+
db.close()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def trigger_problems(cfg: CaptureConfig, *, dsn: str, metadata: Any = None) -> list[str]:
|
|
77
|
+
"""Return missing or disabled trigger problems."""
|
|
78
|
+
db = open_db(dsn)
|
|
79
|
+
try:
|
|
80
|
+
specs = build_specs(cfg, metadata=metadata, db=db)
|
|
81
|
+
have = installed_triggers(db)
|
|
82
|
+
out: list[str] = []
|
|
83
|
+
for s in specs:
|
|
84
|
+
if s.name not in have:
|
|
85
|
+
out.append(f"{s.name}: no zz_capture trigger (run `hindsight apply`)")
|
|
86
|
+
elif not have[s.name]:
|
|
87
|
+
out.append(f"{s.name}: trigger disabled (breaker tripped? `hindsight resume`)")
|
|
88
|
+
for name in have:
|
|
89
|
+
if name not in {s.name for s in specs} and not cfg.table_captured(name):
|
|
90
|
+
out.append(f"{name}: excluded in config but still has a trigger")
|
|
91
|
+
return out
|
|
92
|
+
finally:
|
|
93
|
+
db.close()
|
hindsight/cli/audit.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Import an existing audit table into Hindsight history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, fields
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from hindsight.cli.client import Session, Source, Throttle, ident, started
|
|
10
|
+
from hindsight.config import CaptureConfig
|
|
11
|
+
from hindsight.contract import Row, json_value, now_iso
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AuditColumns:
|
|
16
|
+
"""Audit-table column mapping."""
|
|
17
|
+
|
|
18
|
+
id: str = "id"
|
|
19
|
+
entity_type: str = "entity_type"
|
|
20
|
+
entity_id: str = "entity_id"
|
|
21
|
+
tenant: str | None = "provider_id"
|
|
22
|
+
action: str | None = "action"
|
|
23
|
+
actor_type: str | None = "actor_type"
|
|
24
|
+
actor_id: str | None = "actor_id"
|
|
25
|
+
actor_name: str | None = "actor_name"
|
|
26
|
+
changes: str = "changes"
|
|
27
|
+
created_at: str = "created_at"
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_overrides(cls, overrides: dict[str, str]) -> AuditColumns:
|
|
31
|
+
names = {f.name for f in fields(cls)}
|
|
32
|
+
bad = sorted(set(overrides) - names)
|
|
33
|
+
if bad:
|
|
34
|
+
raise ValueError(f"unknown audit column keys: {', '.join(bad)}")
|
|
35
|
+
return cls(**{k: (v or None) for k, v in overrides.items()}) # type: ignore[arg-type]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def delta_of(raw: Any) -> dict[str, dict[str, Any]]:
|
|
39
|
+
"""Normalize audit changes to the capture contract."""
|
|
40
|
+
if isinstance(raw, (str, bytes)):
|
|
41
|
+
try:
|
|
42
|
+
raw = json.loads(raw)
|
|
43
|
+
except ValueError:
|
|
44
|
+
return {}
|
|
45
|
+
if not isinstance(raw, dict):
|
|
46
|
+
return {}
|
|
47
|
+
out: dict[str, dict[str, Any]] = {}
|
|
48
|
+
for key, v in raw.items():
|
|
49
|
+
name = str(key)
|
|
50
|
+
if name.startswith("_"):
|
|
51
|
+
continue
|
|
52
|
+
if isinstance(v, dict) and ("to" in v or "from" in v):
|
|
53
|
+
entry = {k: json_value(v[k]) for k in ("from", "to") if k in v}
|
|
54
|
+
if "to" not in entry:
|
|
55
|
+
continue
|
|
56
|
+
out[name] = entry
|
|
57
|
+
elif isinstance(v, dict) and ("new" in v or "old" in v):
|
|
58
|
+
if "new" not in v:
|
|
59
|
+
continue
|
|
60
|
+
entry = {"to": json_value(v["new"])}
|
|
61
|
+
if "old" in v:
|
|
62
|
+
entry["from"] = json_value(v["old"])
|
|
63
|
+
out[name] = entry
|
|
64
|
+
elif isinstance(v, (list, tuple)) and len(v) == 2:
|
|
65
|
+
out[name] = {"from": json_value(v[0]), "to": json_value(v[1])}
|
|
66
|
+
else:
|
|
67
|
+
out[name] = {"to": json_value(v)}
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def import_audit(
|
|
72
|
+
cfg: CaptureConfig,
|
|
73
|
+
*,
|
|
74
|
+
dsn: str,
|
|
75
|
+
session: Session,
|
|
76
|
+
mapping: dict[str, str],
|
|
77
|
+
table: str = "audit_events",
|
|
78
|
+
columns: AuditColumns | None = None,
|
|
79
|
+
metadata: Any = None,
|
|
80
|
+
run_id: str | None = None,
|
|
81
|
+
force: bool = False,
|
|
82
|
+
rows_per_second: float | None = None,
|
|
83
|
+
batch_rows: int = 500,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
if not mapping:
|
|
86
|
+
raise ValueError("import-audit needs at least one --map entity_type=table")
|
|
87
|
+
run_id = run_id or session.boot_id.split("-", 1)[-1]
|
|
88
|
+
cols = columns or AuditColumns()
|
|
89
|
+
status = session.status()
|
|
90
|
+
if status.get("genesis") and not force:
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
"a genesis run is already recorded for this source; audit rows would land on "
|
|
93
|
+
"top of the current state. Import audit first on a fresh source, or --force."
|
|
94
|
+
)
|
|
95
|
+
source = Source.open(cfg, dsn, metadata=metadata)
|
|
96
|
+
unknown = sorted(set(mapping.values()) - set(source.manifest["tables"]))
|
|
97
|
+
if unknown:
|
|
98
|
+
source.close()
|
|
99
|
+
raise ValueError(f"--map targets are not captured tables: {', '.join(unknown)}")
|
|
100
|
+
session.require_same_manifest(source.manifest)
|
|
101
|
+
session.shipper.set_manifest(source.manifest)
|
|
102
|
+
optional = [cols.tenant, cols.action, cols.actor_type, cols.actor_id, cols.actor_name]
|
|
103
|
+
select = [cols.id, cols.entity_type, cols.entity_id, cols.changes, cols.created_at] + [
|
|
104
|
+
c for c in optional if c
|
|
105
|
+
]
|
|
106
|
+
placeholders = ", ".join(["%s"] * len(mapping))
|
|
107
|
+
sql = (
|
|
108
|
+
f"SELECT {', '.join(ident(c) for c in select)} FROM {ident(table)} "
|
|
109
|
+
f"WHERE {ident(cols.entity_type)} IN ({placeholders}) "
|
|
110
|
+
f"ORDER BY {ident(cols.created_at)}, {ident(cols.id)}"
|
|
111
|
+
)
|
|
112
|
+
throttle = Throttle(rows_per_second)
|
|
113
|
+
report: dict[str, Any] = {
|
|
114
|
+
"run_id": run_id,
|
|
115
|
+
"boot_id": session.boot_id,
|
|
116
|
+
"table": table,
|
|
117
|
+
"mapping": mapping,
|
|
118
|
+
"started_at": started(),
|
|
119
|
+
"rows": 0,
|
|
120
|
+
"shipped": 0,
|
|
121
|
+
"skipped_empty": 0,
|
|
122
|
+
"per_table": {t: 0 for t in mapping.values()},
|
|
123
|
+
}
|
|
124
|
+
seq = 0
|
|
125
|
+
batch: list[Row] = []
|
|
126
|
+
|
|
127
|
+
def ship() -> None:
|
|
128
|
+
nonlocal batch
|
|
129
|
+
if batch:
|
|
130
|
+
session.ship(batch)
|
|
131
|
+
throttle(len(batch))
|
|
132
|
+
report["shipped"] += len(batch)
|
|
133
|
+
batch = []
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
for raw in source.db.iter(sql, tuple(mapping), batch=batch_rows):
|
|
137
|
+
rec = dict(zip(select, raw, strict=True))
|
|
138
|
+
report["rows"] += 1
|
|
139
|
+
changes = delta_of(rec[cols.changes])
|
|
140
|
+
if not changes:
|
|
141
|
+
report["skipped_empty"] += 1
|
|
142
|
+
continue
|
|
143
|
+
target = mapping[str(rec[cols.entity_type])]
|
|
144
|
+
created = rec[cols.created_at]
|
|
145
|
+
when = created.isoformat() if hasattr(created, "isoformat") else str(created)
|
|
146
|
+
tenant = rec.get(cols.tenant) if cols.tenant else None
|
|
147
|
+
seq += 1
|
|
148
|
+
batch.append(
|
|
149
|
+
Row(
|
|
150
|
+
client_seq=seq,
|
|
151
|
+
table_name=target,
|
|
152
|
+
row_id=str(json_value(rec[cols.entity_id])),
|
|
153
|
+
op="A",
|
|
154
|
+
changes=changes,
|
|
155
|
+
source_time=when,
|
|
156
|
+
tx_id=f"audit:{json_value(rec[cols.id])}",
|
|
157
|
+
row_tenant_id=None if tenant is None else str(json_value(tenant)),
|
|
158
|
+
actor_type=(
|
|
159
|
+
str(rec.get(cols.actor_type) or "user") if cols.actor_type else "user"
|
|
160
|
+
),
|
|
161
|
+
actor_id=_text(rec.get(cols.actor_id)) if cols.actor_id else None,
|
|
162
|
+
actor_name=_text(rec.get(cols.actor_name)) if cols.actor_name else None,
|
|
163
|
+
correlation_id=_text(rec.get(cols.action)) if cols.action else None,
|
|
164
|
+
causation_id=f"audit-import-{run_id}",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
report["per_table"][target] += 1
|
|
168
|
+
if len(batch) >= batch_rows:
|
|
169
|
+
ship()
|
|
170
|
+
ship()
|
|
171
|
+
finally:
|
|
172
|
+
source.close()
|
|
173
|
+
report["finished_at"] = now_iso()
|
|
174
|
+
report["accepted"] = int(session.shipper.stats["accepted"])
|
|
175
|
+
report["duplicates"] = int(session.shipper.stats["duplicates"])
|
|
176
|
+
return report
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _text(value: Any) -> str | None:
|
|
180
|
+
return None if value is None else str(json_value(value))
|