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 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))