interlaced 2.8.3__tar.gz → 2.8.6__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 (214) hide show
  1. {interlaced-2.8.3/src/interlaced.egg-info → interlaced-2.8.6}/PKG-INFO +1 -1
  2. {interlaced-2.8.3 → interlaced-2.8.6}/pyproject.toml +3 -2
  3. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/checks/builtin.py +19 -11
  4. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/operate.py +7 -0
  5. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/config/config.py +8 -0
  6. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/dsl/decorators.py +37 -3
  7. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/graph/project.py +2 -2
  8. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/differ.py +12 -1
  9. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/schedule.py +5 -1
  10. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/project.py +8 -3
  11. interlaced-2.8.6/src/interlace/scheduler/__init__.py +39 -0
  12. interlaced-2.8.6/src/interlace/scheduler/completion.py +132 -0
  13. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/scheduler/daemon.py +35 -3
  14. interlaced-2.8.6/src/interlace/scheduler/engine.py +229 -0
  15. interlaced-2.8.6/src/interlace/scheduler/triggers.py +237 -0
  16. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/app.py +5 -1
  17. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/lifespan.py +5 -0
  18. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/catalog.py +11 -3
  19. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/types.py +1 -1
  20. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/plan.js +4 -1
  21. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/system.js +1 -1
  22. interlaced-2.8.6/src/interlace/state/advisory.py +101 -0
  23. interlaced-2.8.6/src/interlace/state/conn.py +83 -0
  24. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/events.py +2 -2
  25. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/interval.py +21 -4
  26. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/locks.py +4 -4
  27. interlaced-2.8.6/src/interlace/state/pg.py +191 -0
  28. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/queue.py +8 -4
  29. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/snapshots.py +3 -1
  30. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/store.py +16 -9
  31. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/streaming/log.py +33 -11
  32. {interlaced-2.8.3 → interlaced-2.8.6/src/interlaced.egg-info}/PKG-INFO +1 -1
  33. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlaced.egg-info/SOURCES.txt +2 -0
  34. interlaced-2.8.3/src/interlace/scheduler/__init__.py +0 -17
  35. interlaced-2.8.3/src/interlace/scheduler/engine.py +0 -126
  36. interlaced-2.8.3/src/interlace/scheduler/triggers.py +0 -127
  37. interlaced-2.8.3/src/interlace/state/advisory.py +0 -87
  38. interlaced-2.8.3/src/interlace/state/conn.py +0 -40
  39. {interlaced-2.8.3 → interlaced-2.8.6}/LICENSE +0 -0
  40. {interlaced-2.8.3 → interlaced-2.8.6}/MANIFEST.in +0 -0
  41. {interlaced-2.8.3 → interlaced-2.8.6}/README.md +0 -0
  42. {interlaced-2.8.3 → interlaced-2.8.6}/setup.cfg +0 -0
  43. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/__init__.py +0 -0
  44. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cdc/__init__.py +0 -0
  45. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cdc/decode.py +0 -0
  46. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cdc/publish.py +0 -0
  47. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cdc/slot.py +0 -0
  48. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/checks/__init__.py +0 -0
  49. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/checks/runner.py +0 -0
  50. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/checks/spec.py +0 -0
  51. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/__init__.py +0 -0
  52. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/build.py +0 -0
  53. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/inspect.py +0 -0
  54. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/main.py +0 -0
  55. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/cli/support.py +0 -0
  56. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/config/__init__.py +0 -0
  57. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/connections.py +0 -0
  58. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/contracts.py +0 -0
  59. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/dsl/__init__.py +0 -0
  60. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/dsl/discovery.py +0 -0
  61. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/dsl/dynamic.py +0 -0
  62. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/dsl/sql_config.py +0 -0
  63. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/__init__.py +0 -0
  64. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/adbc.py +0 -0
  65. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/base.py +0 -0
  66. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/bigquery.py +0 -0
  67. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/duckdb.py +0 -0
  68. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/postgres.py +0 -0
  69. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/quack.py +0 -0
  70. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/redshift.py +0 -0
  71. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/registry.py +0 -0
  72. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/snowflake.py +0 -0
  73. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/engines/spark.py +0 -0
  74. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/exceptions.py +0 -0
  75. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/graph/__init__.py +0 -0
  76. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/graph/column_lineage.py +0 -0
  77. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/graph/dag.py +0 -0
  78. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/graph/selectors.py +0 -0
  79. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/inputs.py +0 -0
  80. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/inspect.py +0 -0
  81. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/__init__.py +0 -0
  82. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/canonicalize.py +0 -0
  83. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/dialect.py +0 -0
  84. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/fingerprint.py +0 -0
  85. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/layout.py +0 -0
  86. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/macros.py +0 -0
  87. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/relation.py +0 -0
  88. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/ir/vars.py +0 -0
  89. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/mcp_server.py +0 -0
  90. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/__init__.py +0 -0
  91. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/changes.py +0 -0
  92. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/ddl.py +0 -0
  93. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/drift.py +0 -0
  94. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/reconcile.py +0 -0
  95. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/physical/spec.py +0 -0
  96. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/__init__.py +0 -0
  97. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/annotate.py +0 -0
  98. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/apply.py +0 -0
  99. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/backfill.py +0 -0
  100. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/comment.py +0 -0
  101. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/delivery.py +0 -0
  102. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/files.py +0 -0
  103. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/fit.py +0 -0
  104. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/orchestrate.py +0 -0
  105. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/payload.py +0 -0
  106. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/plan.py +0 -0
  107. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/resolve.py +0 -0
  108. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/result.py +0 -0
  109. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/run.py +0 -0
  110. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/table_diff.py +0 -0
  111. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/plan/transfer.py +0 -0
  112. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/py.typed +0 -0
  113. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/query.py +0 -0
  114. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/runtime/__init__.py +0 -0
  115. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/runtime/handles.py +0 -0
  116. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/runtime/python_model.py +0 -0
  117. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/scaffold.py +0 -0
  118. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/scheduler/worker.py +0 -0
  119. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/__init__.py +0 -0
  120. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/auth.py +0 -0
  121. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/present.py +0 -0
  122. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/__init__.py +0 -0
  123. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/admin.py +0 -0
  124. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/environments.py +0 -0
  125. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/models.py +0 -0
  126. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/plan.py +0 -0
  127. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/runs.py +0 -0
  128. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/routes/streams.py +0 -0
  129. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/app.css +0 -0
  130. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/favicon.svg +0 -0
  131. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/index.html +0 -0
  132. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/api.js +0 -0
  133. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/app.js +0 -0
  134. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/dag.js +0 -0
  135. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/timeline.js +0 -0
  136. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/ui.js +0 -0
  137. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/checks.js +0 -0
  138. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/environments.js +0 -0
  139. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/lineage.js +0 -0
  140. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/models.js +0 -0
  141. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/overview.js +0 -0
  142. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/query.js +0 -0
  143. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/runs.js +0 -0
  144. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/service/ui/js/views/streams.js +0 -0
  145. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/sinks.py +0 -0
  146. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/sources/__init__.py +0 -0
  147. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/sources/auth.py +0 -0
  148. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/sources/rest.py +0 -0
  149. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/__init__.py +0 -0
  150. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/beat.py +0 -0
  151. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/cdc.py +0 -0
  152. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/checks.py +0 -0
  153. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/codec.py +0 -0
  154. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/janitor.py +0 -0
  155. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/keys.py +0 -0
  156. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/schema.py +0 -0
  157. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/snapshot.py +0 -0
  158. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/state/triggers.py +0 -0
  159. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/__init__.py +0 -0
  160. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/append.py +0 -0
  161. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/base.py +0 -0
  162. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/full_merge.py +0 -0
  163. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/hash_merge.py +0 -0
  164. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/incremental.py +0 -0
  165. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/merge.py +0 -0
  166. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/replace.py +0 -0
  167. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/replace_in_place.py +0 -0
  168. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/scd.py +0 -0
  169. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/strategies/view.py +0 -0
  170. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/streaming/__init__.py +0 -0
  171. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/streaming/materializer.py +0 -0
  172. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/streaming/schema.py +0 -0
  173. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/README.md +0 -0
  174. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/docker-compose.yml +0 -0
  175. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/init/seed.sql +0 -0
  176. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/interlace.yaml +0 -0
  177. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/models/orders.sql +0 -0
  178. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/models/orders_by_status.sql +0 -0
  179. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/models/orders_stream.py +0 -0
  180. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/push.py +0 -0
  181. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/cdc/template.yaml +0 -0
  182. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/README.md +0 -0
  183. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/generate.py +0 -0
  184. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/interlace.yaml +0 -0
  185. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/models/events.py +0 -0
  186. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
  187. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/models/events_by_type.sql +0 -0
  188. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/models/top_users.sql +0 -0
  189. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/models/user_spend.sql +0 -0
  190. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/events/template.yaml +0 -0
  191. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/github/README.md +0 -0
  192. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/github/interlace.yaml +0 -0
  193. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/github/models/github_issues.py +0 -0
  194. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
  195. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/github/template.yaml +0 -0
  196. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/README.md +0 -0
  197. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/docker-compose.yml +0 -0
  198. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/init/seed.sql +0 -0
  199. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/interlace.yaml +0 -0
  200. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/models/orders.py +0 -0
  201. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
  202. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/postgres/template.yaml +0 -0
  203. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/README.md +0 -0
  204. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/interlace.yaml +0 -0
  205. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
  206. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
  207. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
  208. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/templates/quickstart/template.yaml +0 -0
  209. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/testing/__init__.py +0 -0
  210. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlace/testing/golden.py +0 -0
  211. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlaced.egg-info/dependency_links.txt +0 -0
  212. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlaced.egg-info/entry_points.txt +0 -0
  213. {interlaced-2.8.3 → interlaced-2.8.6}/src/interlaced.egg-info/requires.txt +0 -0
  214. {interlaced-2.8.3 → interlaced-2.8.6}/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.8.3
3
+ Version: 2.8.6
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "interlaced"
7
- version = "2.8.3"
7
+ version = "2.8.6"
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"
@@ -61,7 +61,8 @@ service = [
61
61
  "msgspec>=0.18,<1.0", # the wire types (litestar serialises msgspec structs natively)
62
62
  ]
63
63
  # Remote engines via Arrow-native ADBC transfer. postgres (+ redshift, same wire)
64
- # are the tested transport; snowflake/bigquery are ALPHA (wired, not run live).
64
+ # are the tested transport. snowflake and bigquery are ALPHA: snowflake has been
65
+ # exercised against one account, outside CI; bigquery has not been run live.
65
66
  adbc = [
66
67
  "adbc-driver-manager>=1.2,<2.0",
67
68
  "adbc-driver-postgresql>=1.2,<2.0",
@@ -14,7 +14,6 @@ NULLs as pattern/range failures, which conflated the two).
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- import re
18
17
  from collections.abc import Callable
19
18
  from typing import Any, cast
20
19
 
@@ -24,10 +23,9 @@ from interlace.checks.spec import CheckSpec
24
23
  from interlace.exceptions import DefinitionError
25
24
  from interlace.ir.canonicalize import as_query
26
25
  from interlace.ir.relation import TableRef
26
+ from interlace.state.interval import as_grain
27
27
 
28
28
  _FAILURES = "failures"
29
- _AGE_RE = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$")
30
- _AGE_UNITS = {"s": "SECOND", "m": "MINUTE", "h": "HOUR", "d": "DAY", "w": "WEEK"}
31
29
 
32
30
  ResolveTable = Callable[[str], TableRef]
33
31
  """Maps an upstream model name to its physical table (for ``relationships``)."""
@@ -59,11 +57,24 @@ def _parse_expr(sql: str, dialect: str) -> exp.Expr:
59
57
  return parse_one(sql, read=dialect)
60
58
 
61
59
 
60
+ def sql_interval(amount: int, unit: str) -> exp.Interval:
61
+ """``amount`` of a SQL unit (``HOUR``, ``DAY``, …) as an interval expression."""
62
+ return exp.Interval(this=exp.Literal.string(str(amount)), unit=exp.Var(this=unit))
63
+
64
+
65
+ def max_is_stale(column: exp.Expr, interval: exp.Expr) -> exp.Expr:
66
+ """``max(column)`` is null or older than ``interval``. An empty table counts."""
67
+ newest = exp.Max(this=column)
68
+ threshold = exp.Sub(this=exp.CurrentTimestamp(), expression=interval)
69
+ return exp.or_(exp.LT(this=newest, expression=threshold), newest.copy().is_(exp.null()))
70
+
71
+
62
72
  def _interval(max_age: str) -> exp.Interval:
63
- match = _AGE_RE.fullmatch(str(max_age))
64
- if match is None:
65
- raise DefinitionError(f"invalid max_age {max_age!r}; expected like '2h', '30m', '1d'")
66
- return exp.Interval(this=exp.Literal.string(match.group(1)), unit=exp.Var(this=_AGE_UNITS[match.group(2)]))
73
+ try:
74
+ parsed = as_grain(str(max_age))
75
+ except ValueError as exc:
76
+ raise DefinitionError(f"invalid max_age {max_age!r}; expected like '2h', '30m', '1d'") from exc
77
+ return sql_interval(parsed.amount, parsed.sql_unit)
67
78
 
68
79
 
69
80
  def _row_predicate(spec: CheckSpec, model: str, dialect: str, resolve: ResolveTable) -> exp.Expr | None:
@@ -147,10 +158,7 @@ def build_check_query(spec: CheckSpec, table: TableRef, model: str, dialect: str
147
158
 
148
159
  if spec.type == "freshness":
149
160
  _require(spec, model, columns=1, params=("max_age",))
150
- threshold = exp.Sub(this=exp.CurrentTimestamp(), expression=_interval(str(params["max_age"])))
151
- newest = exp.Max(this=cols[0])
152
- stale = exp.or_(exp.LT(this=newest, expression=threshold), newest.copy().is_(exp.null())) # no rows = stale
153
- return _flag(table, stale)
161
+ return _flag(table, max_is_stale(cols[0], _interval(str(params["max_age"]))))
154
162
 
155
163
  if spec.type == "sql":
156
164
  _require(spec, model, params=("query",))
@@ -285,6 +285,11 @@ def serve(
285
285
  True, "--scheduler/--no-scheduler", help="Run the scheduler loop in this process (combined daemon)."
286
286
  ),
287
287
  interval: float = typer.Option(60.0, "--interval", help="Seconds between scheduler ticks."),
288
+ apply_on_start: bool = typer.Option(
289
+ True,
290
+ "--apply/--no-apply",
291
+ help="Apply the project once at startup. --no-apply serves the warehouse as it is.",
292
+ ),
288
293
  allow_open: bool = typer.Option(
289
294
  False,
290
295
  "--allow-open",
@@ -294,6 +299,7 @@ def serve(
294
299
  """Run the interlace daemon: HTTP API + scheduler in one process (requires the `service` extra).
295
300
 
296
301
  Use --no-scheduler for an API-only process (run `interlace scheduler` separately).
302
+ The project is applied once before the loops start; pass --no-apply to skip that.
297
303
  A `cdc:` block in the project config is read here: each Postgres slot appends into its stream.
298
304
  """
299
305
  try:
@@ -339,6 +345,7 @@ def serve(
339
345
  quack_token=token or None,
340
346
  scheduler=scheduler,
341
347
  scheduler_interval=interval,
348
+ apply_on_start=apply_on_start,
342
349
  )
343
350
  config = uvicorn.Config(
344
351
  app,
@@ -250,6 +250,9 @@ class ProjectConfig(BaseModel):
250
250
  default_engine: str = "default"
251
251
  engines: dict[str, EngineConfig] = Field(default_factory=dict)
252
252
  state_path: str = ".interlace/state.db" # SQLite control-plane database
253
+ # When set, snapshots, the queue, and the event log live in this Postgres
254
+ # database (schema ``interlace``) instead of ``state_path``. Not the warehouse.
255
+ state_url: str | None = None
253
256
  # Optional NDJSON mirror of the operator event log. Unset writes nowhere.
254
257
  # One JSON line is appended after each SQLite commit (CLI and daemon share it).
255
258
  event_log_path: str | None = None
@@ -275,6 +278,11 @@ class ProjectConfig(BaseModel):
275
278
  secrets: dict[str, SecretConfig] = Field(default_factory=dict)
276
279
  quack_token: str | None = None # token for quack: databases (or INTERLACE_QUACK_TOKEN)
277
280
  stream_path: str = ".interlace/streams.db" # durable stream log (SQLite WAL)
281
+ # When set, the stream log lives in this Postgres database (schema
282
+ # ``interlace_streams``) instead of ``stream_path``. A commit is durable
283
+ # under Postgres ``synchronous_commit`` (the default), the same promise as
284
+ # the SQLite WAL fsync.
285
+ stream_url: str | None = None
278
286
  # Databases to ATTACH to the warehouse engine at open: alias -> DuckDB attach
279
287
  # URI/path (a .duckdb file, "postgres:...", "sqlite:...", ...). Models can read
280
288
  # them and table exports can write to them as <alias>.<schema>.<table>.
@@ -9,7 +9,7 @@ Compilation, planning, and execution happen later over the registry. UK spelling
9
9
  from __future__ import annotations
10
10
 
11
11
  import re
12
- from collections.abc import Callable, Sequence
12
+ from collections.abc import Callable, Mapping, Sequence
13
13
  from contextvars import ContextVar
14
14
  from dataclasses import dataclass, field
15
15
  from typing import Any
@@ -30,6 +30,37 @@ from interlace.strategies import named_strategy
30
30
 
31
31
  ModelFn = Callable[..., Any]
32
32
 
33
+
34
+ def normalize_schedule(schedule: Mapping[str, Any] | None, *, model: str) -> dict[str, str] | None:
35
+ """Schedules are string values. ``fresh: {column, within}`` becomes ``"column within"``.
36
+ ``after: [a, b]`` becomes ``"a,b"``."""
37
+ if not schedule:
38
+ return None
39
+ normalised: dict[str, str] = {}
40
+ for key, value in schedule.items():
41
+ if key == "fresh" and isinstance(value, dict):
42
+ column = value.get("column")
43
+ window = value.get("within")
44
+ if not isinstance(column, str) or not column.strip():
45
+ raise DefinitionError(f"model {model!r}: fresh.column must name a timestamp column")
46
+ if not isinstance(window, str) or not window.strip():
47
+ raise DefinitionError(f"model {model!r}: fresh.within must be a window like '2h'")
48
+ normalised[key] = f"{column.strip()} {window.strip()}"
49
+ continue
50
+ if key == "after" and isinstance(value, (list, tuple)):
51
+ names = [str(item).strip() for item in value]
52
+ if not names or any(not name for name in names):
53
+ raise DefinitionError(f"model {model!r}: after must name a model, or a list of models")
54
+ normalised[key] = ",".join(names)
55
+ continue
56
+ if key == "after" and not isinstance(value, str):
57
+ raise DefinitionError(f"model {model!r}: after must name a model, or a list of models")
58
+ if not isinstance(value, str):
59
+ raise DefinitionError(f"model {model!r}: schedule {key!r} must be a string")
60
+ normalised[key] = value
61
+ return normalised
62
+
63
+
33
64
  # Set while a Python model function is running. Registrations then replace a
34
65
  # model this run already created, and are recorded for the apply that builds them.
35
66
  dynamic_owner: ContextVar[str | None] = ContextVar("interlace_dynamic_owner", default=None)
@@ -130,7 +161,9 @@ class ModelDef:
130
161
  path: str | None = None # output path for materialise: file
131
162
  format: str | None = None # csv | parquet | json for materialise: file
132
163
  environments: tuple[str, ...] = ("prod",) # which environments actually deliver a terminal model
133
- schedule: dict[str, str] | None = None # cron, every, watch (glob), or webhook name
164
+ # cron, every, watch, on_change, fresh, or webhook. ``fresh`` may be the
165
+ # mapping ``{column, within}`` at construction; __post_init__ stores "column within".
166
+ schedule: dict[str, Any] | None = None
134
167
  checks: tuple[CheckSpec, ...] = () # data-quality checks; error severity gates promotion
135
168
  indexes: tuple[IndexSpec, ...] = () # physical indexes; not part of the data fingerprint
136
169
  constraints: tuple[ConstraintSpec, ...] = () # physical constraints; engine-enforced, not checks
@@ -151,6 +184,7 @@ class ModelDef:
151
184
  self.constraints = parse_constraints(self.constraints, self.name)
152
185
  self.schema_policy = parse_schema_policy(self.schema_policy, self.name)
153
186
  validate_physical_allowed(self.name, self.materialise, self.indexes, self.constraints, self.schema_policy)
187
+ self.schedule = normalize_schedule(self.schedule, model=self.name)
154
188
 
155
189
  @property
156
190
  def is_terminal(self) -> bool:
@@ -242,7 +276,7 @@ def model(
242
276
  owner: str | None = None,
243
277
  description: str | None = None,
244
278
  columns: dict[str, str | None] | Sequence[str] | None = None,
245
- schedule: dict[str, str] | None = None,
279
+ schedule: dict[str, Any] | None = None,
246
280
  checks: Sequence[dict[str, Any] | CheckSpec] | None = None,
247
281
  indexes: Sequence[dict[str, Any] | IndexSpec] | None = None,
248
282
  constraints: Sequence[dict[str, Any] | ConstraintSpec] | None = None,
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
  import inspect
14
14
  from collections.abc import Iterable, Mapping
15
15
  from dataclasses import dataclass, field
16
- from typing import Any
16
+ from typing import Any, cast
17
17
 
18
18
  from sqlglot import exp
19
19
 
@@ -308,7 +308,7 @@ def compile_models( # noqa: C901
308
308
  cursor=definition.cursor,
309
309
  interval=definition.interval,
310
310
  tags=definition.tags,
311
- schedule=definition.schedule,
311
+ schedule=cast(dict[str, str] | None, definition.schedule), # __post_init__ stores strings
312
312
  columns=definition.columns,
313
313
  target=definition.target,
314
314
  path=definition.path,
@@ -338,6 +338,8 @@ async def diff( # noqa: C901
338
338
  plan = Plan(environment=environment)
339
339
  impact: dict[str, str] = {} # changed models only: "semantic" | "additive" | "clean"
340
340
  touched: dict[str, frozenset[str] | None] = {} # semantic models: provably-changed columns (None = all)
341
+ # downstream models: the upstream columns that forced the rebuild
342
+ indirect: dict[str, frozenset[str]] = {}
341
343
 
342
344
  previous_snapshots = await state.get_snapshots(
343
345
  (name, fingerprint)
@@ -410,6 +412,8 @@ async def diff( # noqa: C901
410
412
  consumed = _consumed_columns(model.ast, dep)
411
413
  if dep_touched is not None and consumed is not None and not (consumed & dep_touched):
412
414
  continue # column-pruned: reads only provably-unchanged columns
415
+ if dep_touched is not None and consumed is not None:
416
+ indirect[model.name] = consumed & dep_touched
413
417
  verdict = "semantic"
414
418
  break
415
419
  if dep_impact == "additive" and _selects_star(model.ast):
@@ -436,8 +440,15 @@ async def diff( # noqa: C901
436
440
  )
437
441
  if inherit:
438
442
  category = ChangeCategory.FORWARD_ONLY
443
+ shown = added
444
+ if impact.get(model.name) == "semantic":
445
+ own = touched.get(model.name)
446
+ if own:
447
+ shown = tuple(sorted(own))
448
+ elif model.name in indirect:
449
+ shown = tuple(sorted(indirect[model.name]))
439
450
  plan.changes.append(
440
- ModelChange(model.name, ChangeType.MODIFIED, category, previous_fingerprint, model.fingerprint, added)
451
+ ModelChange(model.name, ChangeType.MODIFIED, category, previous_fingerprint, model.fingerprint, shown)
441
452
  )
442
453
  if inherit: # copy-on-write: history seeds the NEW table; checks gate before views move
443
454
  snapshot = replace(
@@ -25,7 +25,8 @@ from interlace.ir.relation import TableRef
25
25
  from interlace.plan.backfill import run_backfill
26
26
  from interlace.plan.plan import BackfillTask, Plan
27
27
  from interlace.plan.result import ApplyResult, ProgressCallback, build_detail, failure_detail
28
- from interlace.state.store import StateStore
28
+ from interlace.scheduler.completion import after_waiters, wake_after
29
+ from interlace.state.store import SqliteStateStore, StateStore
29
30
 
30
31
  logger = logging.getLogger("interlace.apply")
31
32
 
@@ -123,6 +124,7 @@ async def schedule_builds( # noqa: C901
123
124
  deps.add(ref)
124
125
  blocking[name] = deps
125
126
 
127
+ waiting = after_waiters(compiled)
126
128
  finished = {name: asyncio.Event() for name in per_model}
127
129
  build_slots = asyncio.Semaphore(max(1, parallelism))
128
130
 
@@ -163,6 +165,8 @@ async def schedule_builds( # noqa: C901
163
165
  finished[name].set()
164
166
  if on_progress is not None:
165
167
  on_progress(name, "done", build_detail(result, name))
168
+ if isinstance(state, SqliteStateStore):
169
+ await wake_after(state, compiled, name, building=set(per_model), waiters=waiting)
166
170
 
167
171
  # Copied into each build task at creation, so a Python model that registers
168
172
  # models (including from a worker thread) appends to this same list.
@@ -375,15 +375,20 @@ class Project:
375
375
  )
376
376
 
377
377
  async def open_state(self) -> SqliteStateStore:
378
- path = self.root / self.config.state_path
379
- path.parent.mkdir(parents=True, exist_ok=True)
380
- store = await SqliteStateStore.open(path)
378
+ if self.config.state_url:
379
+ store = await SqliteStateStore.open_postgres(self.config.state_url)
380
+ else:
381
+ path = self.root / self.config.state_path
382
+ path.parent.mkdir(parents=True, exist_ok=True)
383
+ store = await SqliteStateStore.open(path)
381
384
  if self.config.event_log_path:
382
385
  target = Path(self.config.event_log_path)
383
386
  store.event_log_path = str(target if target.is_absolute() else self.root / target)
384
387
  return store
385
388
 
386
389
  async def open_stream_log(self) -> SqliteStreamLog:
390
+ if self.config.stream_url:
391
+ return await SqliteStreamLog.open_postgres(self.config.stream_url)
387
392
  path = self.root / self.config.stream_path
388
393
  path.parent.mkdir(parents=True, exist_ok=True)
389
394
  return await SqliteStreamLog.open(path)
@@ -0,0 +1,39 @@
1
+ """Orchestration: triggers, the trigger engine, and the worker (queue = state store).
2
+
3
+ Submodules import each other. This package does not load them up front: importing
4
+ ``interlace.scheduler.completion`` must not pull in the worker, which imports the
5
+ apply path that imports completion.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ from typing import Any
12
+
13
+ __all__ = [
14
+ "CronTrigger",
15
+ "IntervalTrigger",
16
+ "RunRequest",
17
+ "Trigger",
18
+ "TriggerEngine",
19
+ "build_triggers",
20
+ "drain",
21
+ ]
22
+
23
+ _EXPORTS: dict[str, tuple[str, str]] = {
24
+ "CronTrigger": ("interlace.scheduler.triggers", "CronTrigger"),
25
+ "IntervalTrigger": ("interlace.scheduler.triggers", "IntervalTrigger"),
26
+ "RunRequest": ("interlace.scheduler.triggers", "RunRequest"),
27
+ "Trigger": ("interlace.scheduler.triggers", "Trigger"),
28
+ "TriggerEngine": ("interlace.scheduler.engine", "TriggerEngine"),
29
+ "build_triggers": ("interlace.scheduler.engine", "build_triggers"),
30
+ "drain": ("interlace.scheduler.worker", "drain"),
31
+ }
32
+
33
+
34
+ def __getattr__(name: str) -> Any:
35
+ found = _EXPORTS.get(name)
36
+ if found is None:
37
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
38
+ module_name, attr = found
39
+ return getattr(importlib.import_module(module_name), attr)
@@ -0,0 +1,132 @@
1
+ """Run a model when another model finishes.
2
+
3
+ ``schedule: {after: …}`` is not a clock tick. Apply calls :func:`wake_after` once
4
+ a build reaches ``model.done``. The trigger engine only validates the graph.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+
11
+ from interlace.exceptions import DefinitionError
12
+ from interlace.graph.project import CompiledProject
13
+ from interlace.state.store import SqliteStateStore
14
+
15
+ _SCHEDULE_KINDS = ("cron", "every", "watch", "on_change", "fresh", "after", "webhook")
16
+
17
+
18
+ def schedule_kind(model: str, schedule: dict[str, str]) -> tuple[str, str]:
19
+ """The one schedule key and its value.
20
+
21
+ Cron, interval, file watch, table change, freshness, upstream completion, or webhook.
22
+ """
23
+ present = [key for key in _SCHEDULE_KINDS if key in schedule]
24
+ if len(present) != 1:
25
+ raise DefinitionError(
26
+ f"model {model!r}: schedule needs exactly one of {', '.join(_SCHEDULE_KINDS)}",
27
+ details={"schedule": schedule},
28
+ )
29
+ return present[0], schedule[present[0]]
30
+
31
+
32
+ def scheduled_closure(project: CompiledProject, models: list[str]) -> list[str]:
33
+ """The triggered models plus every model downstream of them.
34
+
35
+ A cron, interval, file watch, table change, freshness, upstream completion, or webhook
36
+ means that model's inputs changed. Downstream snapshots would otherwise keep the
37
+ previous build. An explicit ``run --select`` is not expanded here: ``model``,
38
+ ``model+``, and ``+model`` stay exactly what was asked for.
39
+ """
40
+ chosen: set[str] = set()
41
+ for name in models:
42
+ chosen.add(name)
43
+ if name in project.models:
44
+ chosen |= project.graph.descendants(name)
45
+ return sorted(chosen)
46
+
47
+
48
+ def after_waiters(project: CompiledProject) -> dict[str, list[str]]:
49
+ """Map an upstream model to the models that run when it finishes.
50
+
51
+ ``schedule: {after: raw}`` or ``{after: [raw, staging]}`` (stored as
52
+ ``"raw,staging"``). A model cannot name itself, and the ``after`` edges
53
+ cannot cycle — otherwise two models would enqueue each other forever.
54
+ """
55
+ found: dict[str, list[str]] = {}
56
+ for model in project.models.values():
57
+ if not model.schedule or "after" not in model.schedule:
58
+ continue
59
+ kind, value = schedule_kind(model.name, model.schedule)
60
+ if kind != "after":
61
+ continue
62
+ names = list(dict.fromkeys(part.strip() for part in value.split(",") if part.strip()))
63
+ if not names:
64
+ raise DefinitionError(f"model {model.name!r}: after must name a model")
65
+ if model.name in names:
66
+ raise DefinitionError(f"model {model.name!r}: after cannot name itself")
67
+ missing = [name for name in names if name not in project.models]
68
+ if missing:
69
+ raise DefinitionError(
70
+ f"model {model.name!r}: after names unknown models: {', '.join(missing)}",
71
+ details={"missing": missing},
72
+ )
73
+ for name in names:
74
+ found.setdefault(name, []).append(model.name)
75
+ _reject_after_cycles(found)
76
+ return found
77
+
78
+
79
+ def _reject_after_cycles(waiters: dict[str, list[str]]) -> None:
80
+ visiting: set[str] = set()
81
+ visited: set[str] = set()
82
+
83
+ def walk(node: str) -> None:
84
+ if node in visited:
85
+ return
86
+ if node in visiting:
87
+ raise DefinitionError(f"schedule after has a cycle through {node!r}")
88
+ visiting.add(node)
89
+ for nxt in waiters.get(node, ()):
90
+ walk(nxt)
91
+ visiting.remove(node)
92
+ visited.add(node)
93
+
94
+ for node in list(waiters):
95
+ walk(node)
96
+
97
+
98
+ async def wake_after(
99
+ store: SqliteStateStore,
100
+ project: CompiledProject,
101
+ upstream: str,
102
+ *,
103
+ building: set[str],
104
+ waiters: dict[str, list[str]] | None = None,
105
+ stamp: str | None = None,
106
+ ) -> int:
107
+ """Enqueue models waiting on ``upstream`` after it reaches ``model.done``.
108
+
109
+ ``building`` is the set already in this apply, so a model the plan is
110
+ constructing is not queued again. One stamp shared by the waiters of a
111
+ single finish: a retry of the same finish dedupes, a later finish does not.
112
+ Returns how many runs were newly enqueued.
113
+ """
114
+ table = after_waiters(project) if waiters is None else waiters
115
+ pending = [name for name in table.get(upstream, ()) if name not in building]
116
+ if not pending:
117
+ return 0
118
+ order = {model.name: index for index, model in enumerate(project.ordered())}
119
+ pending.sort(key=lambda name: order.get(name, 0))
120
+ token = stamp if stamp is not None else str(time.time_ns())
121
+ covered: set[str] = set(building)
122
+ enqueued = 0
123
+ for name in pending:
124
+ if name in covered:
125
+ continue
126
+ selector = scheduled_closure(project, [name])
127
+ key = f"after:{name}:{upstream}:{token}"
128
+ if await store.enqueue_run(key, selector, None, 0):
129
+ enqueued += 1
130
+ await store.append_event("run.enqueued", entity=key, payload={"models": selector, "after": upstream})
131
+ covered.update(selector)
132
+ return enqueued
@@ -205,6 +205,35 @@ async def flusher_loop(state: Any, *, flush_interval: float) -> None:
205
205
  await asyncio.sleep(1.0)
206
206
 
207
207
 
208
+ async def startup_apply(state: Any) -> None:
209
+ """Apply the project once, before the scheduler starts.
210
+
211
+ A breaking or blocked plan is logged and the process still serves. Any other
212
+ failure propagates: a warehouse that cannot be opened should not pretend to
213
+ be up.
214
+ """
215
+ from interlace.exceptions import PlanError
216
+ from interlace.plan.orchestrate import plan_and_apply
217
+
218
+ try:
219
+ _plan, result = await plan_and_apply(
220
+ state.compiled,
221
+ environment=state.environment,
222
+ project=state.project,
223
+ engines=state.engines,
224
+ state=state.store,
225
+ lock_owner=state.lock_owner,
226
+ connections=state.connections,
227
+ on_compiled=lambda fresh: publish_compiled(state, fresh),
228
+ )
229
+ except PlanError as exc:
230
+ logger.error("startup apply refused: %s", exc.message)
231
+ return
232
+ if result is None:
233
+ return
234
+ logger.info("startup apply built %s", ", ".join(result.built) or "nothing")
235
+
236
+
208
237
  async def scheduler_loop(
209
238
  state: Any,
210
239
  *,
@@ -220,9 +249,12 @@ async def scheduler_loop(
220
249
  try:
221
250
  await reload_if_stale(state)
222
251
  compiled = state.compiled
223
- await TriggerEngine(build_triggers(compiled, root=state.project.root), state.store, compiled).tick(
224
- datetime.now()
225
- )
252
+ await TriggerEngine(
253
+ build_triggers(compiled, root=state.project.root, environment=state.environment),
254
+ state.store,
255
+ compiled,
256
+ engines=state.engines,
257
+ ).tick(datetime.now())
226
258
  if asyncio.get_running_loop().time() >= next_trim:
227
259
  await state.store.trim_logs()
228
260
  next_trim = asyncio.get_running_loop().time() + 6 * 3600