interlaced 2.5.0__tar.gz → 2.7.0__tar.gz

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.
Files changed (160) hide show
  1. {interlaced-2.5.0/src/interlaced.egg-info → interlaced-2.7.0}/PKG-INFO +2 -2
  2. {interlaced-2.5.0 → interlaced-2.7.0}/README.md +1 -1
  3. {interlaced-2.5.0 → interlaced-2.7.0}/pyproject.toml +2 -1
  4. interlaced-2.7.0/src/interlace/cdc/__init__.py +6 -0
  5. interlaced-2.7.0/src/interlace/cdc/decode.py +152 -0
  6. interlaced-2.7.0/src/interlace/cdc/publish.py +27 -0
  7. interlaced-2.7.0/src/interlace/cdc/slot.py +97 -0
  8. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/checks/builtin.py +66 -23
  9. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/cli/main.py +345 -78
  10. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/config/config.py +98 -1
  11. interlaced-2.7.0/src/interlace/connections.py +59 -0
  12. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/dsl/decorators.py +26 -4
  13. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/dsl/discovery.py +29 -4
  14. interlaced-2.7.0/src/interlace/dsl/dynamic.py +302 -0
  15. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/adbc.py +11 -3
  16. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/base.py +36 -0
  17. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/duckdb.py +19 -3
  18. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/quack.py +4 -1
  19. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/registry.py +4 -0
  20. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/spark.py +14 -3
  21. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/exceptions.py +12 -0
  22. interlaced-2.7.0/src/interlace/inputs.py +144 -0
  23. interlaced-2.7.0/src/interlace/inspect.py +320 -0
  24. interlaced-2.7.0/src/interlace/mcp_server.py +502 -0
  25. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/__init__.py +4 -0
  26. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/apply.py +78 -23
  27. interlaced-2.7.0/src/interlace/plan/comment.py +62 -0
  28. interlaced-2.7.0/src/interlace/plan/orchestrate.py +180 -0
  29. interlaced-2.7.0/src/interlace/plan/table_diff.py +302 -0
  30. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/project.py +41 -3
  31. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/runtime/python_model.py +6 -4
  32. interlaced-2.7.0/src/interlace/scheduler/engine.py +110 -0
  33. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/scheduler/triggers.py +47 -4
  34. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/scheduler/worker.py +83 -47
  35. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/app.py +571 -514
  36. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/auth.py +14 -9
  37. interlaced-2.7.0/src/interlace/service/types.py +425 -0
  38. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/app.css +28 -0
  39. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/api.js +7 -3
  40. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/ui.js +82 -1
  41. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/checks.js +37 -2
  42. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/lineage.js +22 -1
  43. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/models.js +190 -133
  44. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/plan.js +176 -29
  45. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/runs.js +16 -1
  46. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/system.js +41 -5
  47. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/sinks.py +30 -0
  48. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/store.py +130 -7
  49. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/streaming/log.py +91 -4
  50. interlaced-2.7.0/src/interlace/testing/__init__.py +5 -0
  51. interlaced-2.7.0/src/interlace/testing/golden.py +184 -0
  52. {interlaced-2.5.0 → interlaced-2.7.0/src/interlaced.egg-info}/PKG-INFO +2 -2
  53. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlaced.egg-info/SOURCES.txt +15 -0
  54. interlaced-2.5.0/src/interlace/scheduler/engine.py +0 -66
  55. {interlaced-2.5.0 → interlaced-2.7.0}/LICENSE +0 -0
  56. {interlaced-2.5.0 → interlaced-2.7.0}/MANIFEST.in +0 -0
  57. {interlaced-2.5.0 → interlaced-2.7.0}/setup.cfg +0 -0
  58. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/__init__.py +0 -0
  59. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/checks/__init__.py +0 -0
  60. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/checks/runner.py +0 -0
  61. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/checks/spec.py +0 -0
  62. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/cli/__init__.py +0 -0
  63. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/config/__init__.py +0 -0
  64. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/contracts.py +0 -0
  65. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/dsl/__init__.py +0 -0
  66. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/dsl/sql_config.py +0 -0
  67. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/__init__.py +0 -0
  68. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/bigquery.py +0 -0
  69. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/postgres.py +0 -0
  70. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/redshift.py +0 -0
  71. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/engines/snowflake.py +0 -0
  72. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/graph/__init__.py +0 -0
  73. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/graph/column_lineage.py +0 -0
  74. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/graph/dag.py +0 -0
  75. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/graph/project.py +0 -0
  76. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/graph/selectors.py +0 -0
  77. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/ir/__init__.py +0 -0
  78. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/ir/canonicalize.py +0 -0
  79. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/ir/fingerprint.py +0 -0
  80. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/ir/macros.py +0 -0
  81. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/ir/relation.py +0 -0
  82. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/__init__.py +0 -0
  83. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/annotate.py +0 -0
  84. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/ddl.py +0 -0
  85. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/drift.py +0 -0
  86. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/reconcile.py +0 -0
  87. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/physical/spec.py +0 -0
  88. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/differ.py +0 -0
  89. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/plan.py +0 -0
  90. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/resolve.py +0 -0
  91. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/plan/run.py +0 -0
  92. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/py.typed +0 -0
  93. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/query.py +0 -0
  94. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/runtime/__init__.py +0 -0
  95. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/runtime/handles.py +0 -0
  96. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/scaffold.py +0 -0
  97. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/scheduler/__init__.py +0 -0
  98. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/__init__.py +0 -0
  99. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/favicon.svg +0 -0
  100. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/index.html +0 -0
  101. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/app.js +0 -0
  102. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/dag.js +0 -0
  103. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/timeline.js +0 -0
  104. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/environments.js +0 -0
  105. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/overview.js +0 -0
  106. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/query.js +0 -0
  107. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/streams.js +0 -0
  108. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/sources/__init__.py +0 -0
  109. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/sources/auth.py +0 -0
  110. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/sources/rest.py +0 -0
  111. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/__init__.py +0 -0
  112. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/interval.py +0 -0
  113. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/janitor.py +0 -0
  114. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/locks.py +0 -0
  115. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/state/snapshot.py +0 -0
  116. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/__init__.py +0 -0
  117. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/append.py +0 -0
  118. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/base.py +0 -0
  119. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/full_merge.py +0 -0
  120. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/hash_merge.py +0 -0
  121. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/incremental.py +0 -0
  122. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/merge.py +0 -0
  123. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/replace.py +0 -0
  124. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/replace_in_place.py +0 -0
  125. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/scd.py +0 -0
  126. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/strategies/view.py +0 -0
  127. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/streaming/__init__.py +0 -0
  128. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/streaming/materializer.py +0 -0
  129. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/streaming/schema.py +0 -0
  130. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/README.md +0 -0
  131. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/generate.py +0 -0
  132. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/interlace.yaml +0 -0
  133. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/models/events.py +0 -0
  134. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
  135. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/models/events_by_type.sql +0 -0
  136. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/models/top_users.sql +0 -0
  137. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/models/user_spend.sql +0 -0
  138. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/events/template.yaml +0 -0
  139. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/github/README.md +0 -0
  140. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/github/interlace.yaml +0 -0
  141. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/github/models/github_issues.py +0 -0
  142. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
  143. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/github/template.yaml +0 -0
  144. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/README.md +0 -0
  145. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/docker-compose.yml +0 -0
  146. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/init/seed.sql +0 -0
  147. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/interlace.yaml +0 -0
  148. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/models/orders.py +0 -0
  149. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
  150. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/postgres/template.yaml +0 -0
  151. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/README.md +0 -0
  152. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/interlace.yaml +0 -0
  153. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
  154. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
  155. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
  156. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/template.yaml +0 -0
  157. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlaced.egg-info/dependency_links.txt +0 -0
  158. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlaced.egg-info/entry_points.txt +0 -0
  159. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlaced.egg-info/requires.txt +0 -0
  160. {interlaced-2.5.0 → interlaced-2.7.0}/src/interlaced.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: interlaced
3
- Version: 2.5.0
3
+ Version: 2.7.0
4
4
  Summary: Python and SQL models in one DAG. Transformation, orchestration and durable streaming in one process, on DuckDB and Postgres.
5
5
  Author-email: Mark <mark@interlace.sh>
6
6
  License-Expression: MIT
@@ -272,7 +272,7 @@ fast lane when possible). Contract: `docs/architecture/MULTI_ENGINE.md`.
272
272
  with column-level tracing, models, plan/apply with SQL diffs, live runs, query console,
273
273
  streams, checks, environments, and system — live over SSE;
274
274
  - the **HTTP API** (Litestar + msgspec, OpenAPI at `/schema/scalar`) with the same surface as
275
- the CLI: plan/apply, runs, checks, streams, engines, schedules, lineage, query, gc;
275
+ the CLI: plan/apply, runs, checks, streams, engines, schedules, lineage, query, gc, reset;
276
276
  - the **scheduler**: cron/interval triggers enqueue onto a **durable run queue** (leases,
277
277
  retries, cooperative cancellation — `interlace cancel <id>` or `POST /runs/{id}/cancel`);
278
278
  - **stream ingestion** and retention.
@@ -206,7 +206,7 @@ fast lane when possible). Contract: `docs/architecture/MULTI_ENGINE.md`.
206
206
  with column-level tracing, models, plan/apply with SQL diffs, live runs, query console,
207
207
  streams, checks, environments, and system — live over SSE;
208
208
  - the **HTTP API** (Litestar + msgspec, OpenAPI at `/schema/scalar`) with the same surface as
209
- the CLI: plan/apply, runs, checks, streams, engines, schedules, lineage, query, gc;
209
+ the CLI: plan/apply, runs, checks, streams, engines, schedules, lineage, query, gc, reset;
210
210
  - the **scheduler**: cron/interval triggers enqueue onto a **durable run queue** (leases,
211
211
  retries, cooperative cancellation — `interlace cancel <id>` or `POST /runs/{id}/cancel`);
212
212
  - **stream ingestion** and retention.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "interlaced"
7
- version = "2.5.0"
7
+ version = "2.7.0"
8
8
  description = "Python and SQL models in one DAG. Transformation, orchestration and durable streaming in one process, on DuckDB and Postgres."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -143,6 +143,7 @@ module = [
143
143
  "adbc_driver_bigquery.*", # optional 'adbc-bigquery' extra — absent in the base env
144
144
  "pyspark.*", # optional 'spark' extra — absent in the base env
145
145
  "delta.*", # optional 'spark' extra (delta-spark) — absent in the base env
146
+ "psycopg.*", # optional 'postgres' extra — absent in the base env
146
147
  ]
147
148
  ignore_missing_imports = true
148
149
 
@@ -0,0 +1,6 @@
1
+ """Postgres logical replication into a durable ``@stream``."""
2
+
3
+ from interlace.cdc.decode import Change, decode_message
4
+ from interlace.cdc.publish import confirm_flushed, publish_changes
5
+
6
+ __all__ = ["Change", "confirm_flushed", "decode_message", "publish_changes"]
@@ -0,0 +1,152 @@
1
+ """Decode ``pgoutput`` logical-replication messages into row changes.
2
+
3
+ The slot reader passes each message's WAL start as ``lsn``. Relation messages
4
+ update ``relations`` and return None. Insert, update, and delete return a
5
+ :class:`Change` whose ``row`` is text values keyed by the relation's column names.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import struct
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+
15
+ @dataclass
16
+ class Relation:
17
+ namespace: str
18
+ name: str
19
+ columns: list[str]
20
+
21
+ @property
22
+ def qualified(self) -> str:
23
+ return f"{self.namespace}.{self.name}"
24
+
25
+
26
+ @dataclass
27
+ class Change:
28
+ """One row change. ``kind`` is ``insert``, ``update``, or ``delete``."""
29
+
30
+ lsn: str
31
+ kind: str
32
+ table: str
33
+ row: dict[str, Any]
34
+ before: dict[str, Any] | None = None
35
+
36
+
37
+ @dataclass
38
+ class _Cursor:
39
+ data: bytes
40
+ pos: int = 0
41
+
42
+ def byte(self) -> bytes:
43
+ value = self.data[self.pos : self.pos + 1]
44
+ self.pos += 1
45
+ return value
46
+
47
+ def i16(self) -> int:
48
+ value = struct.unpack_from("!h", self.data, self.pos)[0]
49
+ self.pos += 2
50
+ return int(value)
51
+
52
+ def i32(self) -> int:
53
+ value = struct.unpack_from("!i", self.data, self.pos)[0]
54
+ self.pos += 4
55
+ return int(value)
56
+
57
+ def i64(self) -> int:
58
+ value = struct.unpack_from("!q", self.data, self.pos)[0]
59
+ self.pos += 8
60
+ return int(value)
61
+
62
+ def text(self) -> str:
63
+ end = self.data.index(b"\x00", self.pos)
64
+ value = self.data[self.pos : end].decode()
65
+ self.pos = end + 1
66
+ return value
67
+
68
+ def tuple(self) -> list[str | None]:
69
+ values: list[str | None] = []
70
+ for _ in range(self.i16()):
71
+ kind = self.byte()
72
+ if kind in (b"n", b"u"):
73
+ values.append(None)
74
+ continue
75
+ if kind != b"t":
76
+ raise ValueError(f"unsupported pgoutput tuple marker {kind!r}")
77
+ length = self.i32()
78
+ values.append(self.data[self.pos : self.pos + length].decode())
79
+ self.pos += length
80
+ return values
81
+
82
+
83
+ def decode_message(payload: bytes, relations: dict[int, Relation], lsn: str) -> Change | None:
84
+ """Decode one ``pgoutput`` message. Begin, commit, and relation messages return None."""
85
+ if not payload:
86
+ return None
87
+ cur = _Cursor(payload)
88
+ kind = cur.byte()
89
+ if kind == b"R":
90
+ oid = cur.i32()
91
+ namespace = cur.text()
92
+ name = cur.text()
93
+ cur.byte() # replica identity
94
+ columns: list[str] = []
95
+ for _ in range(cur.i16()):
96
+ cur.byte() # flags
97
+ columns.append(cur.text())
98
+ cur.i32() # type oid
99
+ cur.i32() # typemod
100
+ relations[oid] = Relation(namespace, name, columns)
101
+ return None
102
+ if kind == b"I":
103
+ return _row_change(cur, relations, lsn, "insert")
104
+ if kind == b"U":
105
+ return _update(cur, relations, lsn)
106
+ if kind == b"D":
107
+ return _row_change(cur, relations, lsn, "delete")
108
+ return None # begin, commit, truncate, type, origin: no row
109
+
110
+
111
+ def _relation(cur: _Cursor, relations: dict[int, Relation]) -> Relation:
112
+ oid = cur.i32()
113
+ found = relations.get(oid)
114
+ if found is None:
115
+ raise ValueError(f"pgoutput message for unknown relation oid {oid}")
116
+ return found
117
+
118
+
119
+ def _as_row(relation: Relation, values: list[str | None]) -> dict[str, Any]:
120
+ return dict(zip(relation.columns, values, strict=False))
121
+
122
+
123
+ def _row_change(cur: _Cursor, relations: dict[int, Relation], lsn: str, kind: str) -> Change:
124
+ relation = _relation(cur, relations)
125
+ cur.byte() # 'N' for insert, 'K' or 'O' for delete
126
+ return Change(lsn=lsn, kind=kind, table=relation.qualified, row=_as_row(relation, cur.tuple()))
127
+
128
+
129
+ def _update(cur: _Cursor, relations: dict[int, Relation], lsn: str) -> Change:
130
+ relation = _relation(cur, relations)
131
+ marker = cur.byte()
132
+ before: dict[str, Any] | None = None
133
+ if marker in (b"K", b"O"):
134
+ before = _as_row(relation, cur.tuple())
135
+ marker = cur.byte()
136
+ if marker != b"N":
137
+ raise ValueError(f"pgoutput update expected a new tuple, got {marker!r}")
138
+ return Change(
139
+ lsn=lsn,
140
+ kind="update",
141
+ table=relation.qualified,
142
+ row=_as_row(relation, cur.tuple()),
143
+ before=before,
144
+ )
145
+
146
+
147
+ def payload_of(change: Change) -> dict[str, Any]:
148
+ """Stream payload: the row, plus ``_change`` so a merge model can drop deletes."""
149
+ body = dict(change.row)
150
+ body["_change"] = change.kind
151
+ body["_table"] = change.table
152
+ return body
@@ -0,0 +1,27 @@
1
+ """Append decoded changes to a stream, and confirm their LSN only after a flush."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from interlace.cdc.decode import Change, payload_of
6
+ from interlace.state.store import SqliteStateStore
7
+ from interlace.streaming.log import Event, StreamLog
8
+
9
+
10
+ async def publish_changes(log: StreamLog, store: SqliteStateStore, stream: str, changes: list[Change]) -> list[int]:
11
+ """Append ``changes`` to ``stream``. A repeated LSN is deduped by the log.
12
+
13
+ The LSN is recorded against the log offset and is not confirmed here.
14
+ """
15
+ if not changes:
16
+ return []
17
+ result = await log.append(
18
+ stream,
19
+ [Event(payload=payload_of(change), idempotency_key=f"cdc:{change.lsn}") for change in changes],
20
+ )
21
+ await store.cdc_note_pending(stream, list(zip(result.offsets, (change.lsn for change in changes), strict=True)))
22
+ return result.offsets
23
+
24
+
25
+ async def confirm_flushed(store: SqliteStateStore, stream: str, watermark: int) -> str | None:
26
+ """Advance the stored LSN up to the offset ``flush_streams`` has committed."""
27
+ return await store.cdc_advance(stream, watermark)
@@ -0,0 +1,97 @@
1
+ """Read a Postgres logical replication slot with ``psycopg`` and decode ``pgoutput``.
2
+
3
+ The connection is opened only when CDC is configured. Feedback to the slot
4
+ (which lets Postgres drop WAL) is sent by :meth:`SlotReader.feedback` after
5
+ :func:`interlace.cdc.publish.confirm_flushed` has stored the LSN — never when
6
+ the message is merely appended to the stream log.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import cast
12
+
13
+ from interlace.cdc.decode import Change, Relation, decode_message
14
+ from interlace.config.config import CdcConfig
15
+ from interlace.exceptions import ConfigurationError
16
+
17
+
18
+ class SlotReader:
19
+ """One replication connection for one slot. ``poll`` does not advance the slot."""
20
+
21
+ def __init__(self, dsn: str, source: CdcConfig) -> None:
22
+ self._dsn = dsn
23
+ self._source = source
24
+ self._conn: object | None = None
25
+ self._cur: object | None = None
26
+ self._relations: dict[int, Relation] = {}
27
+ self._started = False
28
+ self._tables = set(source.tables)
29
+
30
+ def poll(self, confirmed_lsn: str | None, *, limit: int = 100) -> list[Change]:
31
+ """Read up to ``limit`` decoded row changes. Blocks briefly when the slot is idle."""
32
+ cursor = self._cursor(confirmed_lsn)
33
+ changes: list[Change] = []
34
+ while len(changes) < limit:
35
+ message = self._read_one(cursor)
36
+ if message is None:
37
+ break
38
+ payload = getattr(message, "payload", b"")
39
+ data = payload if isinstance(payload, bytes) else bytes(payload)
40
+ lsn = str(getattr(message, "data_start", ""))
41
+ change = decode_message(data, self._relations, lsn)
42
+ if change is None or not self._wanted(change.table):
43
+ continue
44
+ changes.append(change)
45
+ return changes
46
+
47
+ def feedback(self, lsn: str) -> None:
48
+ """Tell the slot that ``lsn`` is flushed. Safe to call only after confirm_flushed."""
49
+ cursor = self._cur
50
+ if cursor is None or not lsn:
51
+ return
52
+ send = getattr(cursor, "send_feedback", None)
53
+ if send is not None:
54
+ send(flush_lsn=lsn, force=True)
55
+
56
+ def close(self) -> None:
57
+ conn = self._conn
58
+ self._cur = None
59
+ self._conn = None
60
+ self._started = False
61
+ close = getattr(conn, "close", None)
62
+ if close is not None:
63
+ close()
64
+
65
+ def _wanted(self, table: str) -> bool:
66
+ if table in self._tables:
67
+ return True
68
+ return any(item == table.rsplit(".", 1)[-1] for item in self._tables)
69
+
70
+ def _cursor(self, confirmed_lsn: str | None) -> object:
71
+ if self._cur is not None:
72
+ return self._cur
73
+ try:
74
+ import psycopg # optional postgres extra
75
+ except ImportError as exc:
76
+ raise ConfigurationError("CDC needs the postgres extra (psycopg). Install interlaced[postgres].") from exc
77
+ conn = psycopg.connect(self._dsn, autocommit=True, replication="database")
78
+ cursor = conn.cursor()
79
+ options = {"proto_version": "1", "publication_names": self._source.publication}
80
+ start = confirmed_lsn if confirmed_lsn else None
81
+ cursor.start_replication(slot_name=self._source.slot, decode=False, start_lsn=start, options=options)
82
+ self._conn = conn
83
+ self._cur = cursor
84
+ self._started = True
85
+ return cursor
86
+
87
+ def _read_one(self, cursor: object) -> object | None:
88
+ read = getattr(cursor, "read_message", None)
89
+ if read is None:
90
+ raise ConfigurationError("this psycopg build has no replication cursor")
91
+ try:
92
+ return cast(object | None, read(timeout=0.2))
93
+ except Exception as exc:
94
+ # psycopg raises a timeout-ish error or returns None when the slot is idle
95
+ if type(exc).__name__ in {"ReplicationTimeout", "QueryCanceled"} or "timeout" in str(exc).lower():
96
+ return None
97
+ raise
@@ -16,7 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import re
18
18
  from collections.abc import Callable
19
- from typing import Any
19
+ from typing import Any, cast
20
20
 
21
21
  from sqlglot import exp, parse_one
22
22
 
@@ -65,33 +65,23 @@ def _interval(max_age: str) -> exp.Interval:
65
65
  return exp.Interval(this=exp.Literal.string(match.group(1)), unit=exp.Var(this=_AGE_UNITS[match.group(2)]))
66
66
 
67
67
 
68
- def build_check_query(
69
- spec: CheckSpec, table: TableRef, model: str, dialect: str, resolve: ResolveTable
70
- ) -> exp.Expression:
71
- """Compile ``spec`` against ``table`` into a query returning ``failures``."""
68
+ def _row_predicate(spec: CheckSpec, model: str, dialect: str, resolve: ResolveTable) -> exp.Expression | None:
69
+ """A WHERE predicate that is true for a violating row.
70
+
71
+ ``None`` for checks that are not a per-row filter (``unique`` counts groups,
72
+ ``row_count`` / ``freshness`` judge the table, ``sql`` is its own query)."""
72
73
  cols = [exp.column(c) for c in spec.columns]
73
74
  params: dict[str, Any] = spec.params
74
75
 
75
76
  if spec.type == "not_null":
76
77
  _require(spec, model, columns=1)
77
- condition: exp.Expression = exp.or_(*[col.is_(exp.null()) for col in cols])
78
- return _count_where(table, condition)
79
-
80
- if spec.type == "unique":
81
- _require(spec, model, columns=1)
82
- grouped = (
83
- exp.select(*cols)
84
- .from_(_table(table))
85
- .group_by(*cols)
86
- .having(exp.GT(this=exp.Count(this=exp.Star()), expression=exp.Literal.number(1)))
87
- )
88
- return exp.select(exp.alias_(exp.Count(this=exp.Star()), _FAILURES)).from_(grouped.subquery("dup"))
78
+ return exp.or_(*[col.is_(exp.null()) for col in cols])
89
79
 
90
80
  if spec.type == "accepted_values":
91
81
  _require(spec, model, columns=1, params=("values",))
92
82
  values = [exp.Literal.string(v) if isinstance(v, str) else exp.Literal.number(v) for v in params["values"]]
93
83
  col = cols[0]
94
- return _count_where(table, exp.and_(col.is_(exp.null()).not_(), exp.In(this=col, expressions=values).not_()))
84
+ return exp.and_(col.is_(exp.null()).not_(), exp.In(this=col, expressions=values).not_())
95
85
 
96
86
  if spec.type == "range":
97
87
  _require(spec, model, columns=1)
@@ -103,25 +93,47 @@ def build_check_query(
103
93
  bounds.append(exp.LT(this=col, expression=exp.Literal.number(params["min"])))
104
94
  if "max" in params:
105
95
  bounds.append(exp.GT(this=col, expression=exp.Literal.number(params["max"])))
106
- return _count_where(table, exp.or_(*bounds))
96
+ return exp.or_(*bounds)
107
97
 
108
98
  if spec.type == "pattern":
109
99
  _require(spec, model, columns=1, params=("regex",))
110
100
  col = cols[0]
111
101
  matches = exp.RegexpLike(this=col, expression=exp.Literal.string(str(params["regex"])))
112
- return _count_where(table, exp.and_(col.is_(exp.null()).not_(), matches.not_()))
102
+ return exp.and_(col.is_(exp.null()).not_(), matches.not_())
113
103
 
114
104
  if spec.type == "expression":
115
105
  _require(spec, model, params=("expression",))
116
- predicate = _parse_expr(str(params["expression"]), dialect)
117
- return _count_where(table, exp.paren(predicate).not_())
106
+ return cast(exp.Expression, exp.paren(_parse_expr(str(params["expression"]), dialect)).not_())
118
107
 
119
108
  if spec.type == "relationships":
120
109
  _require(spec, model, columns=1, params=("to", "field"))
121
110
  col = cols[0]
122
111
  parent = exp.select(exp.column(str(params["field"]))).from_(_table(resolve(str(params["to"]))))
123
112
  orphan = exp.In(this=col, query=exp.Subquery(this=parent)).not_()
124
- return _count_where(table, exp.and_(col.is_(exp.null()).not_(), orphan))
113
+ return exp.and_(col.is_(exp.null()).not_(), orphan)
114
+
115
+ return None
116
+
117
+
118
+ def build_check_query(
119
+ spec: CheckSpec, table: TableRef, model: str, dialect: str, resolve: ResolveTable
120
+ ) -> exp.Expression:
121
+ """Compile ``spec`` against ``table`` into a query returning ``failures``."""
122
+ cols = [exp.column(c) for c in spec.columns]
123
+ params: dict[str, Any] = spec.params
124
+ predicate = _row_predicate(spec, model, dialect, resolve)
125
+ if predicate is not None:
126
+ return _count_where(table, predicate)
127
+
128
+ if spec.type == "unique":
129
+ _require(spec, model, columns=1)
130
+ grouped = (
131
+ exp.select(*cols)
132
+ .from_(_table(table))
133
+ .group_by(*cols)
134
+ .having(exp.GT(this=exp.Count(this=exp.Star()), expression=exp.Literal.number(1)))
135
+ )
136
+ return exp.select(exp.alias_(exp.Count(this=exp.Star()), _FAILURES)).from_(grouped.subquery("dup"))
125
137
 
126
138
  if spec.type == "row_count":
127
139
  if "min" not in params and "max" not in params:
@@ -150,3 +162,34 @@ def build_check_query(
150
162
  )
151
163
 
152
164
  raise DefinitionError(f"unknown check type {spec.type!r} on {model!r}")
165
+
166
+
167
+ def build_failing_rows(
168
+ spec: CheckSpec, table: TableRef, model: str, dialect: str, resolve: ResolveTable
169
+ ) -> exp.Expression | None:
170
+ """The rows a check rejected, or ``None`` when the check has no row set.
171
+
172
+ ``row_count`` and ``freshness`` judge the whole table. ``unique`` returns the
173
+ duplicate rows (the failure *count* stays the number of duplicate groups).
174
+ A ``sql`` check's query is already the failing rows."""
175
+ if spec.type in ("row_count", "freshness"):
176
+ return None
177
+ if spec.type == "sql":
178
+ _require(spec, model, params=("query",))
179
+ sql = str(spec.params["query"]).replace("{table}", _table(table).sql(dialect=dialect))
180
+ return _parse_expr(sql, dialect)
181
+ if spec.type == "unique":
182
+ _require(spec, model, columns=1)
183
+ cols = [exp.column(c) for c in spec.columns]
184
+ grouped = (
185
+ exp.select(*cols)
186
+ .from_(_table(table))
187
+ .group_by(*cols)
188
+ .having(exp.GT(this=exp.Count(this=exp.Star()), expression=exp.Literal.number(1)))
189
+ )
190
+ key: exp.Expression = exp.Tuple(expressions=cols) if len(cols) > 1 else cols[0]
191
+ return exp.select(exp.Star()).from_(_table(table)).where(exp.In(this=key, query=exp.Subquery(this=grouped)))
192
+ predicate = _row_predicate(spec, model, dialect, resolve)
193
+ if predicate is None:
194
+ return None
195
+ return exp.select(exp.Star()).from_(_table(table)).where(predicate)