@avytheone/efmesh 0.1.0-beta.1 → 0.2.0

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.
package/SPEC.md CHANGED
@@ -1,84 +1,85 @@
1
- # EFMESH — спецификация
1
+ # EFMESH — specification
2
2
 
3
- **efmesh** фреймворк трансформации данных в духе sqlmesh, но на TypeScript, Bun и Effect v4.
4
- Не порт sqlmesh, а перенос его *идей* на другую почву: там, где sqlmesh опирается на Python-макросы
5
- и Jinja, efmesh опирается на систему типов TypeScript и на Effect как рантайм.
3
+ **efmesh** is a data transformation framework in the spirit of sqlmesh, but on TypeScript, Bun and Effect v4.
4
+ It is not a port of sqlmesh, but a transfer of its *ideas* onto different ground: where sqlmesh leans on Python macros
5
+ and Jinja, efmesh leans on the TypeScript type system and on Effect as its runtime.
6
6
 
7
- Это архитектурный документ: решения, инварианты и открытые вопросы.
8
- Знакомство с проектом и справка пользователя в [README](./README.md),
9
- история изменений в [CHANGELOG](./CHANGELOG.md).
7
+ This is an architecture document: decisions, invariants and open questions.
8
+ An introduction to the project and the user's guide are in the [README](./README.md),
9
+ the change history is in the [CHANGELOG](./CHANGELOG.md).
10
10
 
11
- Статус: v0.2 — реализация прошла фазы F0–F5 (§13), версия пакета
12
- 0.1.0-beta.1; реализованное помечено в тексте уточнениями «*Реализация Fn*».
11
+ Status: v0.2 — implementation has passed phases F0–F6 (§13), published as
12
+ `@avytheone/efmesh` (npm, dist-tag `beta`); what is implemented is marked
13
+ in the text with "*Implemented in Fn*" notes.
13
14
 
14
15
  ---
15
16
 
16
- ## 1. Зачем
17
-
18
- dbt и sqlmesh решают реальную задачуверсионируемые SQL-трансформации с зависимостями,
19
- инкрементальностью и дешёвыми dev-окружениями. Но оба живут в Python-экосистеме:
20
- шаблоны на Jinja, макросы на Python, типов нет, интеграция в TypeScript-бэкенд через subprocess.
21
-
22
- efmesh делает то же самое для команды, которая уже живёт в TypeScript и Effect:
23
-
24
- - **Модель это TypeScript-модуль.** Никакого Jinja. Макрос обычная функция.
25
- Ссылка на другую модель импорт: переименовал модель компилятор покажет все места.
26
- - **Схема модели это Effect Schema.** Колонки типизированы, ссылки на колонки
27
- проверяются на этапе компиляции, а соответствие объявленной схемы фактическому
28
- результату запроса на этапе плана.
29
- - **Всё Effect.** Типизированные ошибки, Layer для подмены движков, Stream для бэкфилла,
30
- Metric/Tracer из коробки. efmesh можно встроить в существующее Effect-приложение
31
- как библиотеку — CLI лишь тонкая обёртка.
32
- - **Bun.** Мгновенный старт CLI, `bun test` для тестов моделей, `bun build --compile`
33
- для единого бинарника.
34
- - **DuckDB как первый движок.** Не просто «локальная аналитика»: httpfs и ATTACH
35
- превращают его в лёгкую федерацию (parquet на S3, чужой Postgres, JSON по HTTPS),
36
- а материализация в parquet-файлы даёт озеро без отдельной инфраструктуры (§3.3, §9.3).
37
-
38
- ### Не-цели
39
-
40
- - Тяжёлый ингест (EL): очереди, CDC, ретраи по чужим APIне наша забота.
41
- Но лёгкое чтение внешнего мира через федерацию DuckDB (parquet/S3, ATTACH-базы,
42
- JSON по HTTPS) законный источник для `external`-моделей: сырьё не обязано
43
- заранее лежать «в базе».
44
- - Оркестрация общего назначения: не замена Airflow/Temporal. Есть свой планировщик
45
- по cron, но он про интервалы моделей, не про произвольные пайплайны.
46
- - BI и визуализация. Максимум — `efmesh graph --html` для отладки DAG.
47
- - Транспиляция между диалектами SQL (киллер-фича sqlglot). Честно признаём:
48
- повторить sqlglot в TS нереалистично. Диалект свойство проекта, не модели (§9).
17
+ ## 1. Why
18
+
19
+ dbt and sqlmesh solve a real problem versioned SQL transformations with dependencies,
20
+ incrementality and cheap dev environments. But both live in the Python ecosystem:
21
+ Jinja templates, Python macros, no types, integration into a TypeScript backend through a subprocess.
22
+
23
+ efmesh does the same for a team that already lives in TypeScript and Effect:
24
+
25
+ - **A model is a TypeScript module.** No Jinja. A macro is an ordinary function.
26
+ A reference to another model is an import: rename the model and the compiler shows you every place.
27
+ - **A model's schema is an Effect Schema.** Columns are typed, references to columns
28
+ are checked at compile time, and the agreement between the declared schema and the actual
29
+ query result is checked at plan time.
30
+ - **Everything is Effect.** Typed errors, Layer for swapping engines, Stream for backfill,
31
+ Metric/Tracer out of the box. efmesh can be embedded into an existing Effect application
32
+ as a library the CLI is only a thin wrapper.
33
+ - **Bun.** Instant CLI startup, `bun test` for model tests, `bun build --compile`
34
+ for a single binary.
35
+ - **DuckDB as the first engine.** Not just "local analytics": httpfs and ATTACH
36
+ turn it into a lightweight federation (parquet on S3, someone else's Postgres, JSON over HTTPS),
37
+ and materialization into parquet files gives you a lake without separate infrastructure (§3.3, §9.3).
38
+
39
+ ### Non-goals
40
+
41
+ - Heavy ingest (EL): queues, CDC, retries against third-party APIsnot our concern.
42
+ But lightweight reading of the outside world through DuckDB federation (parquet/S3, ATTACH databases,
43
+ JSON over HTTPS) is a legitimate source for `external` models: the raw material does not have to
44
+ already sit "in the database".
45
+ - General-purpose orchestration: not a replacement for Airflow/Temporal. There is our own scheduler
46
+ by cron, but it is about model intervals, not arbitrary pipelines.
47
+ - BI and visualization. At most — `efmesh graph --html` for debugging the DAG.
48
+ - Transpilation between SQL dialects (sqlglot's killer feature). We admit it honestly:
49
+ reproducing sqlglot in TS is unrealistic. Dialect is a property of the project, not the model (§9).
49
50
 
50
51
  ---
51
52
 
52
- ## 2. Понятийное ядро
53
+ ## 2. Conceptual core
53
54
 
54
- Пять понятий, всё остальное производные:
55
+ Five concepts, everything else is derived:
55
56
 
56
- | Понятие | Что это |
57
+ | Concept | What it is |
57
58
  |---|---|
58
- | **Модель** | Именованная трансформация: SQL-запрос + метаданные (вид, cron, схема, аудиты). Материализуется в таблицу или view. |
59
- | **Снапшот** | Версия модели, идентифицируемая fingerprint'омхэшем канонизированного SQL и метаданных. Каждому снапшоту соответствует своя физическая таблица. |
60
- | **Интервал** | Полуинтервал времени `[start, end)`, для которого данные снапшота посчитаны. Учёт заполненных интервалов основа инкрементальности и бэкфилла. |
61
- | **Окружение** | Именованный набор view (`dev`, `prod`, `feature_x`), указывающих на физические таблицы снапшотов. Виртуальное: создание окружения это создание view, не копирование данных. |
62
- | **План** | Diff между локальными определениями моделей и состоянием окружения + перечень интервалов к пересчёту. Просмотрелприменил. |
59
+ | **Model** | A named transformation: a SQL query + metadata (kind, cron, schema, audits). Materialized into a table or view. |
60
+ | **Snapshot** | A version of a model, identified by a fingerprint — a hash of the canonicalized SQL and metadata. Each snapshot has its own physical table. |
61
+ | **Interval** | A half-open time interval `[start, end)` for which the snapshot's data has been computed. Tracking filled intervals is the basis of incrementality and backfill. |
62
+ | **Environment** | A named set of views (`dev`, `prod`, `feature_x`) pointing at the physical tables of snapshots. Virtual: creating an environment means creating views, not copying data. |
63
+ | **Plan** | The diff between local model definitions and the state of an environment + the list of intervals to recompute. Reviewed applied. |
63
64
 
64
- Ключевая механика (взята у sqlmesh без изменений, потому что она правильная):
65
+ The key mechanic (taken from sqlmesh unchanged, because it is right):
65
66
 
66
67
  ```
67
- физический слой: _efmesh.med__stays__a3f9c1 ← таблица по fingerprint
68
- lake/med/stays/fp=a3f9c1/…/*.parquet ← или parquet-партиции (§3.3)
69
- виртуальный слой: dev__med.stays → view → физика a3f9c1
70
- med.stays → view → физика 88b2e0 (prod = родные схемы)
68
+ physical layer: _efmesh.med__stays__a3f9c1 ← table by fingerprint
69
+ lake/med/stays/fp=a3f9c1/…/*.parquet ← or parquet partitions (§3.3)
70
+ virtual layer: dev__med.stays → view → physical a3f9c1
71
+ med.stays → view → physical 88b2e0 (prod = native schemas)
71
72
  ```
72
73
 
73
- Не изменил модель — dev и prod смотрят на **одну и ту же** физическую таблицу.
74
- Изменилв dev появляется новая физическая таблица, prod не тронут.
75
- Промоушен в prod переключение view, без копирования данных и без пересчёта.
74
+ If you did not change a model — dev and prod look at the **same** physical table.
75
+ If you did a new physical table appears in dev, prod is untouched.
76
+ Promotion to prod is a view swap, with no data copying and no recomputation.
76
77
 
77
78
  ---
78
79
 
79
- ## 3. Определение модели
80
+ ## 3. Model definition
80
81
 
81
- Модель TS-модуль, экспортирующий результат `defineModel`:
82
+ A model is a TS module exporting the result of `defineModel`:
82
83
 
83
84
  ```ts
84
85
  import { defineModel, kind, audit } from "efmesh"
@@ -89,7 +90,7 @@ export const stays = defineModel({
89
90
  name: "med.stays",
90
91
  kind: kind.incrementalByTimeRange({ timeColumn: "moved_at" }),
91
92
  cron: "@daily",
92
- grain: ["stay_id"], // логический первичный ключ
93
+ grain: ["stay_id"], // logical primary key
93
94
  schema: Schema.Struct({
94
95
  stay_id: Schema.String,
95
96
  dept: Schema.String,
@@ -97,7 +98,7 @@ export const stays = defineModel({
97
98
  duration: Schema.Number,
98
99
  }),
99
100
  audits: [audit.notNull("stay_id"), audit.unique("stay_id")],
100
- description: "Пребывания пациента в отделении, склеенные из движений",
101
+ description: "Patient stays in a department, stitched together from movements",
101
102
  }, (ctx) => ctx.sql`
102
103
  SELECT
103
104
  ${ctx.cols(moves, "move_id", "dept")},
@@ -109,432 +110,494 @@ export const stays = defineModel({
109
110
  `)
110
111
  ```
111
112
 
112
- Что здесь важно:
113
+ What matters here:
113
114
 
114
- - **`ctx.ref(moves)`** — ссылка на модель как на значение, не строку.
115
- Рендерится в имя view текущего окружения (или в CTE в тестах).
116
- Из этих ссылок efmesh собирает DAG — никакого парсинга имён из текста запроса
117
- для построения зависимостей не нужно.
118
- - **`ctx.cols(moves, ...)`** — колонки проверяются компилятором против `moves.schema`.
119
- Опечатка в имени колонки = ошибка типов, а не падение в проде.
120
- - **`ctx.start` / `ctx.end`** — границы обрабатываемого интервала. Подставляются
121
- как *bound parameters* при выполнении, а в текст канонизированного SQL попадают
122
- как плейсхолдерыпоэтому fingerprint не зависит от конкретных дат.
123
- - **Тело чистая функция.** Рендер должен быть детерминированным: `Date.now()`,
124
- `Math.random()`, чтение env внутри тела запрещены (ломают стабильность fingerprint).
125
- Всё изменчивое приходит через `ctx`.
115
+ - **`ctx.ref(moves)`** — a reference to a model as a value, not a string.
116
+ It renders into the view name of the current environment (or into a CTE in tests).
117
+ From these references efmesh assembles the DAG — no parsing of names out of the query
118
+ text is needed to build dependencies.
119
+ - **`ctx.cols(moves, ...)`** — columns are checked by the compiler against `moves.schema`.
120
+ A typo in a column name is a type error, not a production failure.
121
+ - **`ctx.start` / `ctx.end`** — the bounds of the interval being processed. They are substituted
122
+ as *bound parameters* at execution time, and into the canonicalized SQL text they go
123
+ as placeholdersso the fingerprint does not depend on concrete dates.
124
+ - **The body is a pure function.** The render must be deterministic: `Date.now()`,
125
+ `Math.random()`, reading env inside the body are forbidden (they break fingerprint stability).
126
+ Everything mutable arrives through `ctx`.
126
127
 
127
- ### 3.1 Виды моделей (kind)
128
+ ### 3.1 Model kinds (kind)
128
129
 
129
- | Вид | Семантика | Фаза |
130
+ | Kind | Semantics | Phase |
130
131
  |---|---|---|
131
- | `full` | Полный пересчёт таблицы при каждом запуске | F0 |
132
- | `view` | Не материализуется, только view поверх запроса | F0 |
133
- | `incrementalByTimeRange({ timeColumn, batchSize?, lookback? })` | Пересчёт по интервалам времени; `lookback` — сколько прошлых интервалов пересчитывать заново (поздно приезжающие данные) | F1 |
134
- | `incrementalByUniqueKey({ key })` | Upsert по ключу | F2 |
135
- | `seed({ file })` | Данные из CSV/JSON-файла, валидируются через `schema` | F2 |
136
- | `external` | Внешний источник (сырьё): таблица движка, parquet/CSV/JSON по пути или URL, таблица ATTACH-базы. Не материализуется, но участвует в DAG и lineage, схема объявляется | F1 |
137
- | `embedded` | Подставляется в потребителей как CTE, без материализации | F3 |
138
- | `scdType2({ key, validFrom, validTo })` | Медленно меняющиеся измерения | F3 |
139
-
140
- ### 3.2 Проверка контракта схемы
141
-
142
- Объявленная `schema` не документация, а контракт. Перед сборкой каждого
143
- снапшота (на apply, когда физика родителей уже существует) efmesh выполняет
144
- `DESCRIBE <запрос>` (DuckDB отдаёт имена и типы, не выполняя запрос), сравнивает
145
- фактические типы колонок с объявленными и падает с `SchemaMismatchError`, если
146
- разошлись. Имена сверяются строго (недостающие и лишние колонки), типыпо
147
- семействам (`Schema.Number` покрывает и INTEGER, и DOUBLE; `DateTimeUtc` —
148
- все TIMESTAMP-варианты). Это ловит дрейф типов до бэкфилла, а не после.
149
-
150
- ### 3.3 Цель материализации
151
-
152
- Куда складывать физический слой свойство модели (по умолчанию берётся из конфига):
153
-
154
- - **`target: "table"`** — нативная таблица движка.
155
- - **`target: "parquet"`** — озеро: снапшот материализуется в parquet-файлы
156
- (`<lake>/med/stays/fp=a3f9c1/interval=2026-01-01/*.parquet`), view поверх
157
- `read_parquet('…/fp=a3f9c1/**')`. Путь озера локальная директория или S3 (httpfs).
158
- - **`target: "ducklake"`** *(F4, §14.5)* — таблица-на-fingerprint в DuckLake-каталоге
159
- (`ATTACH 'ducklake:sqlite:<catalog>'` под алиасом `_efmesh_ducklake`,
160
- конфиг `ducklake: { catalog, dataPath? }`). Версионность остаётся нашей
161
- (fingerprint + учёт интервалов); снапшоты и time travel DuckLake бонус,
162
- не второй владелец истории. DELETE+INSERT, ALTER (forward-only) и
163
- транзакции работают в каталоге как есть; janitor чистит и его. DuckDB-only.
164
- Потребители вне efmesh обязаны сами сделать ATTACH view окружений
165
- ссылаются на таблицы каталога по алиасу.
166
-
167
- Инкрементальность efmesh ложится на parquet-озеро без единой новой концепции:
168
- **интервал = партиция**. Пересчёт интервала запись файлов его партиции заново;
169
- учёт интервалов (§6) и здесь остаётся источником правды, поэтому неатомарность
170
- перезаписи префикса на S3 не страшнанедописанный интервал просто не помечен
171
- `done` и будет пересчитан.
132
+ | `full` | Full recomputation of the table on every run | F0 |
133
+ | `view` | Not materialized, only a view over the query | F0 |
134
+ | `incrementalByTimeRange({ timeColumn, batchSize?, lookback? })` | Recomputation by time intervals; `lookback` — how many past intervals to recompute (late-arriving data) | F1 |
135
+ | `incrementalByUniqueKey({ key })` | Upsert by key | F2 |
136
+ | `seed({ file })` | Data from a CSV/JSON file, validated via `schema` | F2 |
137
+ | `external` | An external source (raw material): an engine table, a parquet/CSV/JSON file by path or URL, a table in an ATTACH database. Not materialized, but participates in the DAG and lineage, its schema is declared | F1 |
138
+ | `embedded` | Inlined into consumers as a CTE, without materialization | F3 |
139
+ | `scdType2({ key, validFrom, validTo })` | Slowly changing dimensions | F3 |
140
+
141
+ ### 3.2 Schema contract check
142
+
143
+ The declared `schema` is not documentation but a contract. Before building each
144
+ snapshot (at apply, when the physical storage of the parents already exists) efmesh runs
145
+ `DESCRIBE <query>` (DuckDB returns names and types without executing the query), compares
146
+ the actual column types with the declared ones and fails with `SchemaMismatchError` if
147
+ they diverge. Names are checked strictly (missing and extra columns), typesby
148
+ families (`Schema.Number` covers both INTEGER and DOUBLE; `DateTimeUtc` — all
149
+ TIMESTAMP variants). This catches type drift before the backfill, not after.
150
+
151
+ ### 3.3 Materialization target
152
+
153
+ Where to put the physical layer is a property of the model (by default taken from the config):
154
+
155
+ - **`target: "table"`** — a native engine table.
156
+ - **`target: "parquet"`** — a lake: the snapshot is materialized into parquet files
157
+ (`<lake>/med/stays/fp=a3f9c1/interval=2026-01-01/*.parquet`), a view over
158
+ `read_parquet('…/fp=a3f9c1/**')`. The lake path is a local directory or S3 (httpfs).
159
+ - **`target: "ducklake"`** *(F4, §14.5)* — a table-per-fingerprint in a DuckLake catalog
160
+ (`ATTACH 'ducklake:sqlite:<catalog>'` under the alias `_efmesh_ducklake`,
161
+ config `ducklake: { catalog, dataPath? }`). Versioning stays ours
162
+ (fingerprint + interval tracking); DuckLake's snapshots and time travel are a bonus,
163
+ not a second owner of history. DELETE+INSERT, ALTER (forward-only) and
164
+ transactions work in the catalog as is; the janitor cleans it too. DuckDB-only.
165
+ Consumers outside efmesh must ATTACH themselvesthe environments' views
166
+ reference the catalog's tables by alias.
167
+
168
+ efmesh's incrementality maps onto a parquet lake without a single new concept:
169
+ **an interval = a partition**. Recomputing an interval means rewriting its partition's files;
170
+ interval tracking (§6) remains the source of truth here too, so the non-atomicity
171
+ of overwriting an S3 prefix is not scary an under-written interval is simply not marked
172
+ `done` and will be recomputed.
172
173
 
173
174
  ---
174
175
 
175
- ## 4. Снапшоты и fingerprint
176
-
177
- Fingerprint снапшота = хэш от:
178
-
179
- 1. **канонизированного SQL** — запрос рендерится с плейсхолдерами вместо интервалов,
180
- парсится в AST, AST нормализуется (регистр ключевых слов, пробелы, порядок
181
- неупорядоченных элементов) и депарсится обратно. Переформатирование запроса
182
- не меняет fingerprint;
183
- 2. метаданных, влияющих на данные: `kind`, `grain`, `timeColumn`, `schema`;
184
- 3. fingerprint'ов прямых зависимостей (транзитивность: изменение родителя
185
- меняет версию потомкаесли изменение breaking, см. §5.2).
186
-
187
- `cron`, `description`, `owner`, аудиты в fingerprint **не входят**их изменение
188
- это metadata-only change без пересчёта.
189
-
190
- Физическая таблица: `_efmesh.<schema>__<name>__<fp8>`, где `fp8` первые
191
- 8 символов fingerprint. Служебная схема именно `_efmesh` с подчёркиванием:
192
- DuckDB называет каталог по имени файла базы, и для `efmesh.duckdb` ссылка
193
- `efmesh.x` неоднозначна (каталог или схема) — Binder Error.
194
-
195
- **Стабильность fingerprint контракт (F6).** Отпечаток зависит от
196
- канонизации движка (json_serialize_sql DuckDB / libpg_query) и состава
197
- payload: их изменение молча пере-фингерпринтит все модели пользователя и
198
- вынуждает полный ребилд склада. Поэтому: (1) канонизация заморожена
199
- golden-тестами (`test/fingerprint-golden.test.ts`) — красный тест при
200
- апгрейде `@duckdb/node-api`/`libpg-query` означает дрейф канона, и это
201
- повод для решения, а не для обновления хэшей; (2) снапшот несёт
202
- `fingerprint_version` (версия схемы стора 3); (3) осознанная смена
203
- алгоритма = инкремент `FINGERPRINT_VERSION` + история миграцииплан,
204
- встретив снапшот другой версии, честно останавливается
205
- (`FingerprintVersionError`), а не показывает «всё breaking».
176
+ ## 4. Snapshots and fingerprint
177
+
178
+ A snapshot's fingerprint = a hash of:
179
+
180
+ 1. **the canonicalized SQL** — the query is rendered with placeholders instead of intervals,
181
+ parsed into an AST, the AST is normalized (keyword case, whitespace, order of
182
+ unordered elements) and deparsed back. Reformatting the query
183
+ does not change the fingerprint;
184
+ 2. metadata affecting the data: `kind`, `grain`, `timeColumn`, `schema`;
185
+ 3. the fingerprints of direct dependencies (transitivity: changing a parent
186
+ changes the child's version if the change is breaking, see §5.2).
187
+
188
+ `cron`, `description`, `owner`, audits are **not** part of the fingerprint changing them
189
+ is a metadata-only change without recomputation.
190
+
191
+ Physical table: `_efmesh.<schema>__<name>__<fp8>`, where `fp8` is the first
192
+ 8 characters of the fingerprint. The service schema is exactly `_efmesh` with an underscore:
193
+ DuckDB names the catalog after the database file name, and for `efmesh.duckdb` the reference
194
+ `efmesh.x` is ambiguous (catalog or schema) — a Binder Error.
195
+
196
+ **Fingerprint stability is a contract (F6).** The fingerprint depends on
197
+ the engine's canonicalization (DuckDB's json_serialize_sql / libpg_query) and the
198
+ composition of the payload: changing them silently re-fingerprints all of the user's models and
199
+ forces a full rebuild of the warehouse. Therefore: (1) canonicalization is frozen
200
+ by golden tests (`test/fingerprint-golden.test.ts`) — a red test on an
201
+ upgrade of `@duckdb/node-api`/`libpg-query` means canon drift, and that is
202
+ grounds for a decision, not for updating the hashes; (2) a snapshot carries
203
+ `fingerprint_version` (state store schema version 3); (3) a deliberate change
204
+ of the algorithm = an increment of `FINGERPRINT_VERSION` + a migration history a plan,
205
+ on encountering a snapshot of another version, honestly stops
206
+ (`FingerprintVersionError`), rather than showing "everything breaking".
206
207
 
207
208
  ---
208
209
 
209
- ## 5. План: diff и применение
210
+ ## 5. Plan: diff and application
210
211
 
211
- ### 5.1 Что делает `efmesh plan <env>`
212
+ ### 5.1 What `efmesh plan <env>` does
212
213
 
213
- 1. Загружает все модели проекта (импорт TS-модулей через Bun), строит DAG.
214
- 2. Считает fingerprint каждой модели, сравнивает с состоянием окружения.
215
- 3. Категоризирует изменения (§5.2).
216
- 4. Считает недостающие интервалы: для новых снапшотовот `start` проекта
217
- (или модели) до текущего момента; для существующихдыры в учёте.
218
- 5. Показывает человеку: какие модели изменились, как категоризировано,
219
- сколько интервалов будет пересчитано, текстовый diff SQL.
220
- 6. `efmesh apply` показывает тот же план и применяет его: создаёт физические
221
- таблицы, гонит бэкфилл, переключает view. *Реализация F4: в TTY план
222
- применяется только после явного «y» (y/yes/д/да; `--yes`/`-y` пропускает
223
- вопрос), применяется ровно показанный план без пересчёта; не-TTY (CI,
224
- пайпы) едет без вопроса.*
214
+ 1. Loads all project models (importing TS modules via Bun), builds the DAG.
215
+ 2. Computes each model's fingerprint, compares it with the environment's state.
216
+ 3. Categorizes the changes (§5.2).
217
+ 4. Computes the missing intervals: for new snapshots from the project's `start`
218
+ (or the model's) to the current moment; for existing ones the holes in tracking.
219
+ 5. Shows the human: which models changed, how they were categorized,
220
+ how many intervals will be recomputed, a textual SQL diff.
221
+ 6. `efmesh apply` shows the same plan and applies it: creates physical
222
+ tables, runs the backfill, swaps the views. *Implemented in F4: in a TTY the plan
223
+ is applied only after an explicit "y" (y/yes/д/да; `--yes`/`-y` skips the
224
+ question), exactly the shown plan is applied without recomputation; non-TTY (CI,
225
+ pipes) proceeds without asking.*
225
226
 
226
- ### 5.2 Категории изменений
227
+ ### 5.2 Change categories
227
228
 
228
- | Категория | Как определяется | Что пересчитывается |
229
+ | Category | How it is determined | What gets recomputed |
229
230
  |---|---|---|
230
- | **breaking** | По diff AST: удалили/переименовали колонку, изменили выражение существующей, изменили WHERE/JOIN | Модель + все потомки |
231
- | **non-breaking** | Добавили колонку, добавили независимый CTE | Только сама модель |
232
- | **forward-only** | Явный флаг пользователя (`--forward-only <model>,…`) или каскад: собственный AST не менялся, а все изменившиеся родители сами forward-only | Ничего задним числом: новая версия наследует физическую таблицу и done-интервалы старой (`physical_fp` снапшота), новые колонки добавляются `ALTER`-ом (история получает NULL), удаление колонок реюзом не выражаетсяошибка. Только `incrementalByTimeRange` — у остальных видов «задним числом» не бывает по построению |
233
- | **metadata-only** | Изменились поля вне fingerprint | Ничего |
234
-
235
- Категоризация автоматическая (сравнение AST-выражений по колонкам), с возможностью
236
- ручного override в диалоге плана. При сомнении efmesh консервативен: не смог
237
- доказать non-breaking значит breaking. *Уточнение F2/F3: non-breaking
238
- распознаётся как строго суффиксное расширение select_list при нетронутом
239
- остальном дереве (INSERT потомков позиционный); реюз физики не автоматика
240
- non-breaking, а явный forward-only.*
241
-
242
- ### 5.3 Бэкфилл
243
-
244
- - Интервалы к пересчёту группируются в батчи (`batchSize` модели).
245
- - Исполнение параллельные батчи в пределах модели (`concurrency`, по
246
- умолчанию 4): интервалы не пересекаются, каждый батч своя транзакция
247
- на своём соединении пула. Осмыслено на Postgres (F3); DuckDB держит одно
248
- соединение — там бэкфилл последовательный, транзакции сериализуются
249
- семафором адаптера.
250
- - **Межмодельная DAG-конкурентность (F4):** сборка и бэкфилл моделей идут
251
- не последовательным топологическим циклом, а по готовности каждая
252
- модель плана получает Deferred-гейт и стартует, как только готовы её
253
- родители из этого же плана; независимые ветки строятся параллельно
254
- (`modelConcurrency`, по умолчанию 4; CLI `--jobs`). Упавший родитель
255
- не открывает гейт потомки не строятся. На DuckDB конкурентность
256
- честно = 1: одно соединение, чужие стейтменты вклинивались бы в
257
- BEGIN/COMMIT транзакции.
258
- - Каждый интервал транзакция: `DELETE` диапазона + `INSERT` (или `MERGE` для
259
- unique-key). Упавший интервал не отравляет соседей; ретраи через `Schedule`
260
- с экспоненциальной паузой, после исчерпания интервал помечается `failed`,
261
- план завершается ошибкой с перечнем. *Реализация F5: opt-in
231
+ | **breaking** | By the AST diff: dropped/renamed a column, changed an existing expression, changed WHERE/JOIN | The model + all descendants |
232
+ | **non-breaking** | Added a column, added an independent CTE | Only the model itself |
233
+ | **forward-only** | An explicit user flag (`--forward-only <model>,…`) or a cascade: the model's own AST did not change, and all changed parents are themselves forward-only | Nothing retroactively: the new version inherits the physical table and done-intervals of the old one (the snapshot's `physical_fp`), new columns are added via `ALTER` (history gets NULL), dropping columns cannot be expressed by reuse an error. Only `incrementalByTimeRange` — for the other kinds "retroactively" does not exist by construction |
234
+ | **metadata-only** | Fields outside the fingerprint changed | Nothing |
235
+
236
+ **Indirect physics reuse (0.2.0, #5 the sqlmesh "indirect non-breaking"
237
+ class).** A descendant whose own AST did not change (indirect) inherits the
238
+ physical table and interval accounting of its previous version when it is
239
+ safe by construction: (1) the version was moved ONLY by parents — verified by
240
+ recomputing the fingerprint with the parents' old fingerprints, which must
241
+ reproduce the old one (so a simultaneous metadata drift of kind/grain/
242
+ columns/target disables reuse); (2) every changed direct parent guarantees
243
+ identical data in the existing columns: non-breaking (strictly suffix
244
+ columns), forward-only, or an ancestor that itself reuses physics. Applies to
245
+ materialized kinds (full, incrementalByTimeRange, incrementalByUniqueKey,
246
+ scdType2)scdType2 keeps its accumulated row history instead of losing it
247
+ to a rebuild. Reused physics is shared with the old version: refresh-style
248
+ kinds mutate it in place before promotion the same trade-off forward-only
249
+ already makes.
250
+
251
+ Categorization is automatic (comparison of AST expressions by column), with a
252
+ manual override as a flag, not a dialog (0.2.0, #5): `--reclassify
253
+ model=breaking|non-breaking` on `plan`/`apply` states the operator's verdict
254
+ on top of `--explain`, is journaled with `applied_by`
255
+ (`PlanAction.reclassifiedFrom`), and thereby governs whether descendants may
256
+ reuse physics. It does not exempt the model itself from a rebuild — that is
257
+ `--forward-only`'s job. Guard rail: an override that plainly contradicts the
258
+ AST (dropped columns declared non-breaking) is refused. It only applies to
259
+ breaking/non-breaking verdicts; on unchanged/added/removed/indirect it is
260
+ silently inert. When in doubt efmesh is conservative: if it could not
261
+ prove non-breaking then it is breaking. *Clarification in F2/F3: non-breaking
262
+ is recognized as a strictly suffix extension of the select_list with the rest of
263
+ the tree untouched (the descendants' INSERT is positional); physical reuse is not
264
+ automatic non-breaking, but an explicit forward-only.*
265
+
266
+ The verdict is explainable (`plan --explain`, 0.2.0 #4): every changed model
267
+ carries which canonical-AST nodes diverged (paths like `where_clause`,
268
+ `select_list[2] (added)`) and why the category followed — cascade sources for
269
+ indirect, inherited physics for forward-only. The AST paths follow the
270
+ engine's canon and are a debugging hint, not a versioned contract.
271
+
272
+ ### 5.3 Backfill
273
+
274
+ - Intervals to recompute are grouped into batches (the model's `batchSize`).
275
+ - Execution — parallel batches within a model (`concurrency`, default 4):
276
+ intervals do not overlap, each batch is its own transaction
277
+ on its own pool connection. Meaningful on Postgres (F3); DuckDB holds one
278
+ connection — there the backfill is sequential, transactions are serialized
279
+ by the adapter's semaphore.
280
+ - **Inter-model DAG concurrency (F4):** building and backfilling models proceed
281
+ not as a sequential topological loop, but by readiness — each
282
+ model in the plan gets a Deferred gate and starts as soon as its
283
+ parents from this same plan are ready; independent branches are built in parallel
284
+ (`modelConcurrency`, default 4; CLI `--jobs`). A failed parent
285
+ does not open the gate — descendants are not built. On DuckDB the concurrency is
286
+ honestly = 1: one connection, other statements would wedge into the
287
+ BEGIN/COMMIT of a transaction.
288
+ - Each interval is a transaction: `DELETE` of the range + `INSERT` (or `MERGE` for
289
+ unique-key). A failed interval does not poison its neighbors; retries via `Schedule`
290
+ with exponential backoff, after exhaustion — the interval is marked `failed`,
291
+ the plan ends with an error listing them. *Implemented in F5: opt-in —
262
292
  `ApplyOptions.retry {attempts, baseDelayMs}` / CLI `--retries N`;
263
- ретраится только транзакционная запись батча (повтор безопасен),
264
- аудиты не ретраятсяих провал детерминирован.*
265
- - Прогресс пишется в state store поинтервально: прерванный бэкфилл продолжается
266
- с места остановки, а не с нуля.
267
-
268
- ### 5.4 Промоушен и janitor
269
-
270
- Промоушен окружения транзакционная замена набора view (`CREATE OR REPLACE VIEW`).
271
- Физика (таблицы и parquet-префиксы), на которую больше не ссылается ни одно окружение, живёт ещё
272
- `ttl` (по умолчанию 7 днейчтобы можно было мгновенно откатиться переключением
273
- view) и затем убираются командой `efmesh janitor`. ttl отсчитывается от
274
- `orphaned_at` — отметки, которую промоушен ставит при потере последней ссылки
275
- и снимает при возврате (откат обнуляет счётчик). Физика, разделяемая
276
- несколькими версиями (forward-only), сносится только с последним
277
- использующим её снапшотом.
293
+ only the transactional write of a batch is retried (a repeat is safe),
294
+ audits are not retried their failure is deterministic.*
295
+ - Progress is written to the state store per interval: an interrupted backfill resumes
296
+ from where it stopped, not from scratch.
297
+
298
+ ### 5.4 Promotion and janitor
299
+
300
+ Promoting an environment is a transactional swap of a set of views (`CREATE OR REPLACE VIEW`).
301
+ Physical storage (tables and parquet prefixes) that no environment references any more lives another
302
+ `ttl` (default 7 daysso you can instantly roll back by swapping the
303
+ views) and is then removed by the `efmesh janitor` command. The ttl is counted from
304
+ `orphaned_at` — a mark that promotion sets when the last reference is lost
305
+ and clears when it returns (a rollback resets the counter). Physical storage shared by
306
+ several versions (forward-only) is removed only together with the last
307
+ snapshot using it.
278
308
 
279
309
  ---
280
310
 
281
- ## 6. Состояние
311
+ ## 6. State
282
312
 
283
- Всё состояние в state store (по умолчанию — SQLite-файл через `bun:sqlite` рядом
284
- с проектом; для командной/прод-работысхема `efmesh_state` в Postgres,
285
- `state: { url: "postgres://…" }` в конфиге; семантика и раскладка идентичны,
286
- метки времени ISO UTC текстом в обоих бэкендах). Сам DuckDB
287
- на роль state store не годится: он однописательский, а состояние должно переживать
288
- конкурентные запуски из разных процессов. Таблицы:
313
+ All state is in the state store (by defaulta SQLite file via `bun:sqlite` next
314
+ to the project; for team/production work the `efmesh_state` schema in Postgres,
315
+ `state: { url: "postgres://…" }` in the config; the semantics and layout are identical,
316
+ timestamps are ISO UTC text in both backends). DuckDB itself
317
+ is not fit for the state store role: it is single-writer, and the state must survive
318
+ concurrent runs from different processes. Tables:
289
319
 
290
320
  ```
291
321
  snapshots (name, fingerprint, definition_json, created_at)
292
322
  intervals (snapshot_fp, start_ts, end_ts, status: done|failed, updated_at)
293
323
  environments (env, name, snapshot_fp, promoted_at)
294
- plans (id, env, summary_json, applied_at, applied_by) -- журнал аудита
295
- meta (version) -- версия схемы (F4)
324
+ plans (id, env, summary_json, applied_at, applied_by) -- audit journal
325
+ runs (id, env, started_at, finished_at, outcome, detail) -- run tick journal (0.2.0)
326
+ meta (version) -- schema version (F4)
296
327
  ```
297
328
 
298
- `applied_by` (F5, версия схемы 2) — кто применил план: `ApplyOptions.appliedBy`
299
- или ОС-пользователь; записи доверсионного журнала читаются с пустым автором.
329
+ `applied_by` (F5, schema version 2) — who applied the plan: `ApplyOptions.appliedBy`
330
+ or the OS user; pre-versioned journal records are read with an empty author.
300
331
 
301
- Схема стора версионирована (F4): свежий стор бутстрапится на текущую
302
- `STATE_VERSION` сам; существующий со схемой старше (включая доверсионные)
303
- открытие не проходит — `StateSchemaError` с подсказкой. Догоняет явный
304
- `efmesh migrate` — молча менять чужие данные при открытии нельзя.
332
+ The store schema is versioned (F4): a fresh store bootstraps itself to the current
333
+ `STATE_VERSION`; an existing one with an older schema (including pre-versioned) fails
334
+ to open — `StateSchemaError` with a hint. An explicit
335
+ `efmesh migrate` catches it up silently changing someone else's data on open is not allowed.
305
336
 
306
- Инварианты:
337
+ Invariants:
307
338
 
308
- - Учёт интервалов единственный источник правды о том, что посчитано.
309
- Физическая таблица без записей в `intervals` считается пустой.
310
- - Запись в state store — только через транзакции state store; efmesh никогда
311
- не смешивает в одной транзакции данные и состояние, если движок данных
312
- и state store разные базы (двухфазности нет, есть идемпотентность:
313
- пересчёт интервала безопасен всегда).
339
+ - Interval tracking is the single source of truth about what has been computed.
340
+ A physical table with no records in `intervals` is considered empty.
341
+ - Writing to the state store — only through state store transactions; efmesh never
342
+ mixes data and state in one transaction if the data engine
343
+ and the state store are different databases (there is no two-phase commit, there is idempotency:
344
+ recomputing an interval is always safe).
314
345
 
315
346
  ---
316
347
 
317
- ## 7. Запуск по расписанию
318
-
319
- `efmesh run <env>` — один тик планировщика:
320
-
321
- 1. Для каждой модели, у которой наступил новый интервал,посчитать его
322
- (плюс `lookback` прошлых). *Уточнение F2: отдельного `cron`-поля нет
323
- роль расписания играет зерно интервала (`interval: "day" | "hour"`);
324
- `run` никогда не применяет изменения моделей (это plan/apply с человеком
325
- у руля) и отказывается работать при структурных изменениях.*
326
- 2. Порядок и конкурентностькак в бэкфилле (§5.3).
327
- 3. Аудитыпосле каждого посчитанного интервала (§8).
328
-
329
- `run` идемпотентен и безопасен для cron/systemd-таймера: параллельный запуск
330
- отсекается блокировкой в state store — таблица `locks` с ttl (протухший лок
331
- упавшего процесса перехватывается), одинаково в SQLite и Postgres.
332
- *Уточнение F5 (§14.6 закрыт): лок один на окружение (`env:<имя>`) и общий
333
- для `run` и `apply` — мутации окружения из разных процессов взаимно
334
- исключаются; у `janitor` свой глобальный лок.*
335
- Долгоживущий демон не нужен,
336
- но для встраивания в Effect-приложение есть `Runner.daemon` `Effect`, крутящий
337
- тики по `Schedule.cron` внутри вашего рантайма.
348
+ ## 7. Scheduled runs
349
+
350
+ `efmesh run <env>` — a single scheduler tick:
351
+
352
+ 1. For each model that has a new interval due compute it
353
+ (plus `lookback` past ones). *Clarification in F2: there is no separate `cron` field
354
+ the role of the schedule is played by the interval's grain (`interval: "day" | "hour"`);
355
+ `run` never applies model changes (that is plan/apply with a human
356
+ at the wheel) and refuses to work when there are structural changes.*
357
+ 2. Order and concurrencyas in the backfill (§5.3).
358
+ 3. Auditsafter each computed interval (§8).
359
+
360
+ `run` is idempotent and safe for a cron/systemd timer: a parallel run
361
+ is cut off by a lock in the state store — a `locks` table with a ttl (a stale lock
362
+ of a crashed process is reclaimed), the same in SQLite and Postgres.
363
+ *Clarification in F5 (§14.6 closed): the lock is one per environment (`env:<name>`) and shared
364
+ between `run` and `apply` — mutations of an environment from different processes mutually
365
+ exclude each other; the `janitor` has its own global lock.*
366
+
367
+ *Implemented in 0.2.0 (#1, #2):* every run tick writes its outcome to the
368
+ `runs` journal in the state store — `ok` (with the list of built models),
369
+ `awaiting-human` (unapplied changes), `lock-held`, `error` (the error tag) —
370
+ including unsuccessful ones: a 3 a.m. cron failure is debuggable after the
371
+ fact. `efmesh status <env>` reads it back: the last applied plan, interval
372
+ lag per incremental model (computed against the environment's pointers —
373
+ what consumers actually see), failed intervals, recent ticks. A journal
374
+ write failure never masks the tick's real outcome (logged and ignored).
375
+ A long-lived daemon is not needed,
376
+ but for embedding into an Effect application there is `Runner.daemon` — an `Effect` that spins
377
+ ticks by `Schedule.cron` inside your runtime.
338
378
 
339
379
  ---
340
380
 
341
- ## 8. Качество: аудиты и тесты
381
+ ## 8. Quality: audits and tests
342
382
 
343
- **Аудит** SQL-предикат над результатом, выполняется после загрузки интервала.
344
- Возвращает нарушающие строки; непустой результат = `AuditFailure`.
383
+ An **audit** is a SQL predicate over the result, run after an interval is loaded.
384
+ It returns the violating rows; a non-empty result = `AuditFailure`.
345
385
 
346
386
  ```ts
347
387
  audits: [
348
388
  audit.notNull("stay_id"),
349
389
  audit.unique("stay_id"),
350
- audit.accepted("dept", ["ОРИТ", "терапия", "хирургия"]),
351
- audit.custom("положительная длительность", (ctx) => ctx.sql`
390
+ audit.accepted("dept", ["ICU", "therapy", "surgery"]),
391
+ audit.custom("positive duration", (ctx) => ctx.sql`
352
392
  SELECT * FROM ${ctx.self} WHERE duration < 0
353
393
  `),
354
394
  ]
355
395
  ```
356
396
 
357
- Аудит бывает `blocking` (по умолчанию: интервал помечается failed, view не
358
- промоутится) и `warn` (метрика + лог, конвейер едет дальше).
397
+ An audit is either `blocking` (default: the interval is marked failed, the view is not
398
+ promoted) or `warn` (a metric + a log, the pipeline moves on).
359
399
 
360
- Помимо прогона в apply, есть автономный `efmesh audit <env>` (F4): проверяет
361
- то, что окружение отдаёт потребителям СЕЙЧАС — view-слой целиком, а не
362
- свежезагруженный интервал. Ловит деградацию задним числом (поздние данные,
363
- правку физики мимо efmesh, дрейф external); ничего не меняет и не помечает,
364
- отчёт целиком, ненулевой exit при blocking-нарушениях.
400
+ Besides running at apply, there is a standalone `efmesh audit <env>` (F4): it checks
401
+ what the environment serves to consumers RIGHT NOW the whole view layer, not the
402
+ freshly loaded interval. It catches after-the-fact degradation (late data,
403
+ edits to physical storage bypassing efmesh, external drift); it changes and marks nothing,
404
+ the report is complete, a non-zero exit on blocking violations.
365
405
 
366
- **Тест** юнит-тест запроса на фикстурах, живёт в `bun test`:
406
+ A **test** is a unit test of the query on fixtures, living in `bun test`:
367
407
 
368
408
  ```ts
369
409
  import { testModel } from "efmesh/testing"
370
410
 
371
411
  testModel(stays, {
372
412
  inputs: { [moves.name]: [
373
- { move_id: "1", case_id: "c1", dept: "ОРИТ", moved_at: "2026-01-01T10:00Z" },
374
- { move_id: "2", case_id: "c1", dept: "терапия", moved_at: "2026-01-02T10:00Z" },
413
+ { move_id: "1", case_id: "c1", dept: "ICU", moved_at: "2026-01-01T10:00Z" },
414
+ { move_id: "2", case_id: "c1", dept: "therapy", moved_at: "2026-01-02T10:00Z" },
375
415
  ]},
376
416
  interval: ["2026-01-01", "2026-01-03"],
377
- expect: [{ stay_id: "1", dept: "ОРИТ", duration: 86400 }],
417
+ expect: [{ stay_id: "1", dept: "ICU", duration: 86400 }],
378
418
  })
379
419
  ```
380
420
 
381
- Механика: `ctx.ref` в тестовом режиме рендерится в CTE с `VALUES` из фикстур
382
- (валидированных через Schema модели-источника!), запрос выполняется на
383
- одноразовом in-memory DuckDB — мгновенно, без docker и внешней инфраструктуры,
384
- результат сравнивается с ожидаемым. Фикстуры с невалидными по Schema данными
385
- не компилируютсятест не может врать о форме входа.
421
+ The mechanics: `ctx.ref` in test mode renders into a CTE with `VALUES` from the fixtures
422
+ (validated via the source model's Schema!), the query runs on a
423
+ throwaway in-memory DuckDB — instantly, without docker or external infrastructure and the
424
+ result is compared with the expected one. Fixtures with data invalid by Schema
425
+ do not compile a test cannot lie about the shape of the input.
386
426
 
387
427
  ---
388
428
 
389
- ## 9. Движки и парсинг SQL
429
+ ## 9. Engines and SQL parsing
390
430
 
391
- ### 9.1 Адаптер движка — Effect-сервис
431
+ ### 9.1 The engine adapter an Effect service
392
432
 
393
433
  ```ts
394
434
  interface Engine {
395
435
  readonly dialect: "duckdb" | "postgres"
396
436
  readonly query: (sql: string) => Effect<Rows, EngineError>
397
437
  readonly execute: (sql: string) => Effect<void, EngineError>
398
- /** Набор стейтментов одной транзакцией: на пуле соединений BEGIN/COMMIT
399
- отдельными execute разъехались бы по разным соединениям. */
438
+ /** A set of statements in one transaction: on a connection pool, BEGIN/COMMIT
439
+ via separate execute calls would scatter across different connections. */
400
440
  readonly transaction: (statements: ReadonlyArray<string>) => Effect<void, EngineError>
401
- readonly describe: (sql: string) => Effect<ColumnTypes, EngineError> // для §3.2
402
- readonly canonicalize: (sql: string) => Effect<string, EngineError | SqlParseError> // для §9.2
441
+ readonly describe: (sql: string) => Effect<ColumnTypes, EngineError> // for §3.2
442
+ readonly canonicalize: (sql: string) => Effect<string, EngineError | SqlParseError> // for §9.2
403
443
  }
404
444
  ```
405
445
 
406
- Подключается через `Layer`. Первый движок **DuckDB** (поверх `@duckdb/node-api`),
407
- второй **Postgres** (F3, поверх встроенного `Bun.SQL` с пулом;
408
- `describe` — временный view + `pg_attribute` на одном соединении).
409
- DuckDB-специфика (seed, parquet-цель, external по файлам/URL, export в
410
- ATTACH) на других движках честно падает `EngineFeatureError` до любых
411
- действий. DuckDB выбран первым не только за скорость и нулевую
412
- инфраструктуру: httpfs, ATTACH и parquet делают его одновременно движком,
413
- федератором и озером (§9.3). State store отдельный сервис с тем же паттерном.
414
-
415
- ### 9.2 Парсинг
416
-
417
- Для канонизации (§4), diff-категоризации (§5.2) и lineage нужен настоящий парсер
418
- и это обязанность адаптера: каждый движок парсит свой диалект сам, самодельного
419
- «парсера всех SQL» в efmesh нет.
420
-
421
- - **DuckDB** отдаёт AST собственным парсером: `json_serialize_sql()` /
422
- `json_deserialize_sql()`. Канонизация round-trip сериализации: стопроцентная
423
- точность диалекта, включая его расширения (`read_parquet`, `GROUP BY ALL`,
424
- list-типы), ноль сторонних зависимостей. Ограничение: сериализуются только
425
- SELECT-стейтментыа тело модели ровно им и является.
426
- - **Postgres** (F3, реализовано) — libpg_query в WASM, парсер самого Postgres:
427
- дерево разбора с вычищенными `location` формат-инвариантно; плейсхолдеры
428
- `$start`/`$end` канонического рендера детерминированно замещаются `$1`/`$2`
429
- (bare `$имя` не синтаксис Postgres).
430
-
431
- Транспиляции между диалектами **нет** (не-цель): проект пишется под диалект
432
- своего движка. Это честное сужение относительно sqlmesh (sqlglot) в обмен
433
- на точность и простоту.
434
-
435
- ### 9.3 Федерация: чтение и запись наружу
436
-
437
- DuckDB закрывает три сценария, ради которых он и выбран первым движком:
438
-
439
- - **Озеро на parquet.** Цель материализации `parquet` (§3.3): физический слой
440
- файлы локально или на S3, view поверх `read_parquet`. Склад без склада.
441
- - **Чтение чужих систем.** `external`-модели поверх `read_parquet`/`read_csv`/
442
- `read_json` — в том числе по HTTPS, так что «дёрнуть REST API, отдающий JSON»
443
- ложится сюда же,и поверх таблиц ATTACH-баз (Postgres, MySQL, SQLite).
444
- Про детерминизм: внешний источник меняется между запусками, это нормально
445
- (как любое сырьё) — в fingerprint входит только *определение* источника,
446
- не его содержимое; свежесть управляется интервалами и cron.
447
- - **Запись наружу.** Экспорт результата модели в ATTACH-базунапример, готовая
448
- витрина уезжает в рабочий Postgres приложения:
449
- `export: { attach: "app_pg", table: "public.stays" }`. Экспорт выполняется
450
- после аудитов и только для промоутнутого снапшотанаружу не может уехать
451
- непроверенное. Фаза F2.
446
+ It is wired in through a `Layer`. The first engine is **DuckDB** (on top of `@duckdb/node-api`),
447
+ the second is **Postgres** (F3, on top of the built-in `Bun.SQL` with a pool;
448
+ `describe` — a temporary view + `pg_attribute` on one connection).
449
+ DuckDB specifics (seed, parquet target, external by files/URL, export to
450
+ ATTACH) fail honestly on other engines with `EngineFeatureError` before any
451
+ action. DuckDB was chosen first not only for speed and zero
452
+ infrastructure: httpfs, ATTACH and parquet make it simultaneously an engine,
453
+ a federator and a lake (§9.3). The state store is a separate service with the same pattern.
454
+
455
+ ### 9.2 Parsing
456
+
457
+ For canonicalization (§4), diff categorization (§5.2) and lineage a real parser is needed
458
+ and that is the adapter's responsibility: each engine parses its own dialect itself,
459
+ there is no home-grown "parser of all SQL" in efmesh.
460
+
461
+ - **DuckDB** returns an AST with its own parser: `json_serialize_sql()` /
462
+ `json_deserialize_sql()`. Canonicalization is a round-trip of serialization: hundred-percent
463
+ dialect accuracy, including its extensions (`read_parquet`, `GROUP BY ALL`,
464
+ list types), zero third-party dependencies. Limitation: only
465
+ SELECT statements are serialized and a model's body is exactly that.
466
+ - **Postgres** (F3, implemented) — libpg_query in WASM, the parser of Postgres itself:
467
+ a parse tree with `location`s stripped out is format-invariant; the placeholders
468
+ `$start`/`$end` of the canonical render are deterministically replaced with `$1`/`$2`
469
+ (bare `$name` is not Postgres syntax).
470
+
471
+ There is **no** transpilation between dialects (a non-goal): a project is written for the dialect
472
+ of its engine. This is an honest narrowing relative to sqlmesh (sqlglot) in exchange
473
+ for accuracy and simplicity.
474
+
475
+ ### 9.3 Federation: reading and writing outward
476
+
477
+ DuckDB covers the three scenarios for which it was chosen as the first engine:
478
+
479
+ - **A lake on parquet.** The `parquet` materialization target (§3.3): the physical layer is
480
+ files locally or on S3, a view over `read_parquet`. A warehouse without a warehouse.
481
+ - **Reading other systems.** `external` models over `read_parquet`/`read_csv`/
482
+ `read_json` — including over HTTPS, so "hit a REST API returning JSON"
483
+ fits here tooand over the tables of ATTACH databases (Postgres, MySQL, SQLite).
484
+ On determinism: an external source changes between runs, and that is normal
485
+ (like any raw material) — only the *definition* of the source enters the fingerprint,
486
+ not its content; freshness is governed by intervals and cron.
487
+ - **Writing outward.** Exporting a model's result into an ATTACH database for example, a finished
488
+ mart heads into the application's working Postgres:
489
+ `export: { attach: "app_pg", table: "public.stays" }`. The export runs
490
+ after the audits and only for a promoted snapshot nothing unverified can head
491
+ outward. Phase F2.
452
492
 
453
493
  ### 9.4 Lineage
454
494
 
455
- Из AST + разрешённых `ctx.ref` строится колоночный lineage (F3, реализовано):
456
- `efmesh lineage med.stays.duration` → цепочка до сырьевых колонок
457
- `external`/`seed`-моделей. Точность best-effort: выражение колонки берётся
458
- из канонического AST, `COLUMN_REF` сопоставляются схемам родителей по имени
459
- (квалификаторы и алиасы CTE не разворачиваются), `SELECT *` — сквозной
460
- проброс. Так как зависимости моделей известны из `ref`, а не из парсинга
461
- имён, граф моделей точен всегдаприблизителен только колоночный уровень.
495
+ From the AST + resolved `ctx.ref`s a column-level lineage is built (F3, implemented):
496
+ `efmesh lineage med.stays.duration` → the chain down to the raw columns
497
+ of `external`/`seed` models. Accuracy is best-effort: a column's expression is taken
498
+ from the canonical AST, `COLUMN_REF`s are matched to the parents' schemas by name
499
+ (qualifiers and CTE aliases are not unfolded), `SELECT *` — a pass-through
500
+ propagation. Since model dependencies are known from `ref`, not from parsing
501
+ names, the model graph is always exact only the column level is approximate.
462
502
 
463
503
  ---
464
504
 
465
- ## 10. Effect-архитектура
505
+ ## 10. The Effect architecture
466
506
 
467
- Слои (снизу вверх):
507
+ Layers (bottom to top):
468
508
 
469
509
  ```
470
510
  EngineAdapter (DuckDB | Postgres) StateStore (SQLite | PG)
471
511
  └──────────────┬──────────────────────┘
472
- Snapshotter (fingerprint, канонизация)
473
- IntervalLedger (учёт интервалов)
512
+ Snapshotter (fingerprint, canonicalization)
513
+ IntervalLedger (interval tracking)
474
514
 
475
- Planner (diff, категоризация, план)
476
- Executor (бэкфилл: Stream + DAG-конкурентность)
515
+ Planner (diff, categorization, plan)
516
+ Executor (backfill: Stream + DAG concurrency)
477
517
  Auditor
478
518
 
479
- Efmesh (фасад: plan / apply / run / test)
519
+ Efmesh (facade: plan / apply / run / test)
480
520
 
481
- CLI (тонкая обёртка) | ваш Effect-код (библиотечное встраивание)
521
+ CLI (thin wrapper) | your Effect code (library embedding)
482
522
  ```
483
523
 
484
- Принципы:
524
+ Principles:
485
525
 
486
- - **Типизированные ошибки** на каждом уровне: `ParseError | SchemaMismatchError |
487
- PlanConflictError | AuditFailure | IntervalFailure | EngineError`. Никаких
488
- `throw` — вся обработка через канал ошибок Effect.
489
- - **Один владелец жизненного цикла**: ресурсы (пулы соединений, WASM-инстанс
490
- парсера) — `Scope`/`Layer`, teardown гарантирован.
491
- - **Наблюдаемость из коробки**: span на каждую модель/интервал, `Metric` —
526
+ - **Typed errors** at every level: `ParseError | SchemaMismatchError |
527
+ PlanConflictError | AuditFailure | IntervalFailure | EngineError`. No
528
+ `throw`sall handling goes through Effect's error channel.
529
+ - **A single lifecycle owner**: resources (connection pools, the parser's WASM
530
+ instance) — `Scope`/`Layer`, teardown is guaranteed.
531
+ - **Observability out of the box**: a span per model/interval, `Metric`s
492
532
  `efmesh_intervals_done_total`, `efmesh_interval_duration`, `efmesh_audit_failures_total`.
493
- - **Библиотека прежде CLI.** `Efmesh.plan(env)` обычный `Effect`, который можно
494
- запустить внутри любого приложения, снабдив слоями. CLI собирается на
495
- Effect CLI-модуле и компилируется `bun build --compile` в один бинарник.
496
- *Уточнение F5: бинарник собирается и работает (`--external` на чужие
497
- платформы `@duckdb/node-bindings-*`), но standalone-исполняемые Bun
498
- не резолвят bare-импорт `"efmesh"` из рантайм-загружаемого
499
- `efmesh.config.ts` даже при живом node_modules — ограничение Bun,
500
- не наше. Дистрибуция беты пакетом (`bun add -d efmesh`), бинарник
501
- подождёт апстрима.*
502
-
503
- Целевая версия **Effect v4**: один пакет `effect`, сервисы через
504
- `ServiceMap`/`Layer`, `Schema` из коробки. Пока v4 в движении, API-поверхность
505
- efmesh закладывается на стабильное подмножество (Effect/Layer/Schema/Stream/
506
- Schedule/Metric/Scope), точечные адаптациипри финализации v4.
533
+ - **Library before CLI.** `Efmesh.plan(env)` is an ordinary `Effect` that can be
534
+ run inside any application by supplying it with layers. The CLI is built on
535
+ the Effect CLI module and is compiled with `bun build --compile` into a single binary.
536
+ *Clarification in F5: the binary builds and works (`--external` for other
537
+ platforms' `@duckdb/node-bindings-*`), but standalone Bun executables
538
+ do not resolve the bare import `"efmesh"` from a runtime-loaded
539
+ `efmesh.config.ts` even with a live node_modules — a Bun limitation,
540
+ not ours. Beta distribution is by package (`bun add -d efmesh`), the binary
541
+ waits for upstream.*
542
+
543
+ The target version is **Effect v4**: one `effect` package, services via
544
+ `ServiceMap`/`Layer`, `Schema` out of the box. While v4 is in motion, efmesh's API
545
+ surface is laid on the stable subset (Effect/Layer/Schema/Stream/
546
+ Schedule/Metric/Scope), with point adaptations at the finalization of v4.
507
547
 
508
548
  ---
509
549
 
510
550
  ## 11. CLI
511
551
 
512
552
  ```
513
- efmesh init [dir] — скаффолд проекта (конфиг, модели-пример, seed)
514
- efmesh plan <env> [--forward-only <model>,…]
515
- efmesh apply <env> [--yes] [--jobs N] [--retries N] [--forward-only …] — план + подтверждение + применение
516
- efmesh run <env> [--jobs N] [--retries N] — тик планировщика
517
- efmesh audit <env> [--model a,b] — аудиты view-слоя окружения, ничего не меняя
518
- efmesh render <model> [--env] — показать итоговый SQL (для отладки)
519
- efmesh diff <envA> <envB> чем окружения отличаются
553
+ efmesh init [dir] — scaffold a project (config, example models, seed)
554
+ efmesh plan <env> [--forward-only <model>,…] [--reclassify m=cat,…] [--explain] [--json]
555
+ efmesh apply <env> [--yes] [--jobs N] [--retries N] [--forward-only …] [--reclassify …]plan + confirmation + application
556
+ efmesh run <env> [--jobs N] [--retries N] — a scheduler tick
557
+ efmesh audit <env> [--model a,b] [--json] audits of the environment's view layer, changing nothing
558
+ efmesh render <model> [--env] — show the final SQL (for debugging)
559
+ efmesh diff <envA> <envB> [--data [--model a,b] [--sample P] [--json]] how the environments differ
560
+ efmesh status <env> [--json] — last plan, interval lag, recent run ticks
520
561
  efmesh lineage <model[.column]>
521
- efmesh graph [--html] — DAG моделей
522
- efmesh janitor [--ttl 7] — уборка осиротевших физических таблиц
523
- efmesh migrate — догнать схему state store до текущей версии
562
+ efmesh graph [--html] — the model DAG
563
+ efmesh janitor [--ttl 7] — cleanup of orphaned physical tables
564
+ efmesh migrate — catch the state store schema up to the current version
565
+ efmesh schedule <env> [--cron '@hourly'] [--remove] [--list] [--print-systemd]
524
566
  ```
525
567
 
526
- Конфиг — `efmesh.config.ts` (не YAML): типизирован, собирает `Layer` движка
527
- и state store, задаёт окружения, `start` проекта, ttl, конкурентность.
568
+ `schedule` (0.2.0, #10) registers the `run` tick in the OS scheduler via
569
+ `Bun.cron` (>= 1.3.11; `engines.bun` pins it): crontab on Linux, launchd on
570
+ macOS, Task Scheduler on Windows. A generated worker in `.efmesh/` shells out
571
+ to this package's own CLI with absolute paths (no npm-name guessing), so the
572
+ tick keeps the `run` semantics: env lock, exit 2 = awaiting a human, the tick
573
+ journal. Idempotent by title `efmesh-<project>-<env>`. Caveats: OS cron uses
574
+ the local timezone and never catches up on missed runs; Arch-family Linux
575
+ ships no cron daemon — the command detects a missing `crontab` and points to
576
+ `--print-systemd`, which emits user units with `Persistent=true` instead.
577
+
578
+ The config is `efmesh.config.ts` (not YAML): typed, it assembles the `Layer` of the engine
579
+ and the state store, defines environments, the project's `start`, the ttl, concurrency.
580
+
581
+ `diff --data` (0.2.0, #6 — the sqlmesh table_diff class) compares the DATA of
582
+ two environments' view layers: full row counts, key overlap (grain, or the
583
+ kind's key for uniqueKey/scdType2) via a FULL OUTER JOIN with presence
584
+ markers, and per-column mismatch counts among matched keys. Models without a
585
+ key get honest row counts only; columns existing on one side only are
586
+ reported as schema drift. `--sample P` (1–99) compares a deterministic share
587
+ of keys — both sides are filtered by the same md5 buckets of the key, so the
588
+ sample stays aligned and produces no false only-in rows (row counts remain
589
+ full). Works on DuckDB and Postgres; ducklake marts are visible via the same
590
+ ATTACH `apply` uses.
528
591
 
529
592
  ---
530
593
 
531
- ## 12. Структура проекта пользователя
594
+ ## 12. The user's project structure
532
595
 
533
596
  ```
534
597
  my-warehouse/
535
598
  efmesh.config.ts
536
599
  models/
537
- sources.ts — external-модели (сырьё) с их Schema
600
+ sources.ts — external models (raw material) with their Schema
538
601
  med/
539
602
  moves.ts
540
603
  stays.ts
@@ -544,98 +607,118 @@ my-warehouse/
544
607
  stays.test.ts — bun test
545
608
  ```
546
609
 
547
- Discovery моделейпо glob из конфига + проверка, что каждый экспорт
548
- `defineModel` достижим; дубликаты имён ошибка загрузки. *Реализовано (F5):
549
- `discovery: glob | glob[]` в конфиге (маски относительно конфига), все
550
- экспорты-модели найденных файлов попадают в проект; реэкспорт того же
551
- объекта не дубликат, два разных определения с одним именем
552
- `DiscoveryConflictError` с обоими файлами; совместимо с явным `models`.*
610
+ Model discoveryby glob from the config + a check that each `defineModel`
611
+ export is reachable; duplicate names are a load error. *Implemented (F5):
612
+ `discovery: glob | glob[]` in the config (masks relative to the config), all
613
+ model exports of the found files enter the project; re-exporting the same
614
+ object is not a duplicate, two different definitions with one name
615
+ `DiscoveryConflictError` with both files; compatible with an explicit `models`.*
553
616
 
554
617
  ---
555
618
 
556
- ## 13. Фазы
619
+ ## 13. Phases
557
620
 
558
- - **F0 — скелет (вертикальный срез).** `defineModel`, `full`/`view`, рендер и
559
- `ctx.ref`, DuckDB-адаптер (нативные таблицы), state store на `bun:sqlite`,
560
- `plan`/`apply` без категоризации (всё breaking), физический+виртуальный слой,
621
+ - **F0 — the skeleton (vertical slice).** `defineModel`, `full`/`view`, render and
622
+ `ctx.ref`, the DuckDB adapter (native tables), the state store on `bun:sqlite`,
623
+ `plan`/`apply` without categorization (everything breaking), the physical+virtual layer,
561
624
  `render`, `graph`.
562
- Стоп-условие: две связанные модели проходят plan→apply→изменение→plan→apply,
563
- prod не пересчитывается при промоушене.
564
- - **F1 — инкрементальность и озеро.** `incrementalByTimeRange`, учёт интервалов,
565
- бэкфилл со Stream и DAG-конкурентностью, fingerprint по канонизированному AST
566
- (`json_serialize_sql`), `external` (таблицы, файлы/URL, ATTACH-чтение),
567
- контракт схемы (§3.2), цель материализации `parquet` — локально и на S3 (httpfs),
568
- прерывание/продолжение бэкфилла.
569
- - **F2 — качество и эксплуатация.** Аудиты, `testModel` (in-memory DuckDB),
570
- `run` по cron + блокировка + `Runner.daemon`, категоризация
571
- breaking/non-breaking по AST, `janitor` (таблицы и parquet-префиксы), `diff`,
572
- метрики/спаны, `seed`, `incrementalByUniqueKey`, экспорт в ATTACH-базы (§9.3).
573
- - **F3 — широта.** Postgres-адаптер (libpg_query, state store в PG,
574
- пулпараллельные батчи бэкфилла), lineage по колонкам, `forward-only`
575
- (+ orphaned_at для janitor), `scdType2`, `embedded`, `graph --html`,
576
- сырые `.sql`-модели (`defineSqlModel`, §14.1 закрыт). *Построено.*
577
- - **F4 — эксплуатационная зрелость.** Межмодельная DAG-конкурентность
578
- apply (Deferred-гейты, `--jobs`, §5.3), `target: "ducklake"` (§14.5
579
- закрыт), автономный `efmesh audit` (§8), `efmesh init`, версия схемы
580
- state store + `efmesh migrate` (§6), интерактивное подтверждение плана
581
- (§5.1). *Построено.*
582
- - **F5 — бета-гейт.** Межпроцессный лок на `apply` — общий env-лок с `run`
583
- (§14.6 закрыт), discovery моделей по glob (§12), ретраи батча бэкфилла
584
- со `Schedule.exponential` (§5.3), `applied_by` в журнале планов (версия
585
- схемы стора 2), решение по nullability (§14.2 закрыт: контракт имена
586
- и типы, NULL дело аудита), версия 0.1.0-beta; одиночный бинарник
587
- уперся в ограничение Bun (§10) — дистрибуция пакетом. *Построено.*
588
- Дальше (кандидаты): override категоризации в диалоге плана (§5.2),
589
- интервалы не по времени (§14.3), lock и на janitor↔apply гонку
590
- жёстче ttl-смягчения, публикация в registry.
625
+ Stop condition: two related models pass plan→apply→change→plan→apply,
626
+ prod is not recomputed on promotion.
627
+ - **F1 — incrementality and the lake.** `incrementalByTimeRange`, interval tracking,
628
+ backfill with Stream and DAG concurrency, fingerprint over the canonicalized AST
629
+ (`json_serialize_sql`), `external` (tables, files/URLs, ATTACH reading),
630
+ the schema contract (§3.2), the `parquet` materialization target locally and on S3 (httpfs),
631
+ interruption/resumption of backfill.
632
+ - **F2 — quality and operations.** Audits, `testModel` (in-memory DuckDB),
633
+ `run` by cron + a lock + `Runner.daemon`, breaking/non-breaking categorization
634
+ by AST, `janitor` (tables and parquet prefixes), `diff`,
635
+ metrics/spans, `seed`, `incrementalByUniqueKey`, export to ATTACH databases (§9.3).
636
+ - **F3 — breadth.** The Postgres adapter (libpg_query, state store in PG,
637
+ a pool parallel backfill batches), column lineage, `forward-only`
638
+ (+ orphaned_at for the janitor), `scdType2`, `embedded`, `graph --html`,
639
+ raw `.sql` models (`defineSqlModel`, §14.1 closed). *Built.*
640
+ - **F4 — operational maturity.** Inter-model DAG concurrency for
641
+ apply (Deferred gates, `--jobs`, §5.3), `target: "ducklake"` (§14.5
642
+ closed), a standalone `efmesh audit` (§8), `efmesh init`, a state store schema
643
+ version + `efmesh migrate` (§6), interactive plan confirmation
644
+ (§5.1). *Built.*
645
+ - **F5 — the beta gate.** A cross-process lock on `apply` — a shared env lock with `run`
646
+ (§14.6 closed), model discovery by glob (§12), backfill batch retries
647
+ with `Schedule.exponential` (§5.3), `applied_by` in the plan journal (store
648
+ schema version 2), a decision on nullability (§14.2 closed: the contract is names
649
+ and types, NULL is the audit's job), version 0.1.0-beta; the single binary
650
+ hit a Bun limitation (§10) — distribution by package. *Built.*
651
+ - **F6 the beta gate, part 2 (before the quiet publish).** Pinning effect
652
+ (peerDependency + drift CI), fingerprint as a contract
653
+ (`FINGERPRINT_VERSION`, golden tests, store schema 3), transactionally
654
+ closing the janitor↔apply race (claim + a liveness check in promotion),
655
+ atomic parquet partitions, a store backup before migrate, non-TTY apply
656
+ requiring `--yes` (exit code 2 = "awaiting a human"), a whitelist of the public
657
+ API, an English README. *Built. Published: npm
658
+ `@avytheone/efmesh` (dist-tag beta) + github.com/avytheone/efmesh,
659
+ releases by tag via Trusted Publishing (OIDC, provenance).*
660
+ - **0.2.0 — "operator and team".** Theme: efmesh in the hands of a non-author —
661
+ the operator of the nightly cron and a team with CI. (1) `efmesh status <env>`:
662
+ the last apply/tick, interval lag, store version; (2) a journal of
663
+ `run` ticks in the store (outcome, duration — schema v4); (3) `--json`
664
+ on plan/audit/status for CI and bots; (4) `plan --explain` — which AST
665
+ node changed and why that category; (5) a categorization override by
666
+ flag on top of explain (not interactive — testable and works in CI);
667
+ (6) `diff --data` — a comparison of the data of two environments (cheap on
668
+ DuckDB); (7) an integration test of lock reclaim under kill -9;
669
+ (8) a canonicalization cache by the model text hash (a repeat plan on 2000
670
+ models — 0.6 s, almost entirely canonicalize). The project one is in GitHub
671
+ Issues (milestone "0.2.0 — operator & team"); SPEC remains an
672
+ architecture document. Deferred: non-time-based intervals (§14.3) —
673
+ by the first real consumer.
591
674
 
592
675
  ---
593
676
 
594
- ## 14. Открытые вопросы
595
-
596
- 1. **Сырые `.sql`-файлы.** *Закрыт (F3):* `defineSqlModel({ file, refs })` —
597
- тело в `.sql` с `@ref(имя)`/`@start`/`@end`, зависимости объявляются
598
- значениями в `refs` (каждый `@ref` обязан быть объявлен, лишние ошибка),
599
- поэтому DAG, fingerprint и testModel работают как у обычных моделей.
600
- Типизация колонок по-прежнему теряетсячестная цена миграции.
601
- 2. **Nullability в контракте.** *Закрыт (F5) решением:* `DESCRIBE <запрос>`
602
- в DuckDB даёт имена и типы, но не nullability — контракт проверяет
603
- имена и типы (семейства), обещание «не NULL» выражается аудитом
604
- `audit.notNull` (blocking по умолчанию). `Schema.NullOr` в модели
605
- документация формы данных, не рантайм-гарантия.
606
- 3. **Интервалы не по времени.** sqlmesh умеет `INCREMENTAL_BY_PARTITION`.
607
- Нужно ли обобщать учёт с временных интервалов до произвольных партиций
608
- решить по первому реальному потребителю.
609
- 4. **Мульти-движок в одном проекте.** ATTACH закрывает типовой случай без второго
610
- адаптера (читать из PG, экспортировать в PG, считая всё в DuckDB); полноценный
611
- мульти-движок вне scope, но `EngineAdapter`-слой на модель этому не противоречит.
612
- 5. **DuckLake.** *Закрыт (F4):* реализована третья цель материализации
613
- `target: "ducklake"` (§3.3) — таблица-на-fingerprint в ATTACH-каталоге,
614
- версионность остаётся нашей, снапшоты/time travel DuckLake бонус.
615
- Ровно по выводам пробы 2026-07-15: DuckLake не замена нашей
616
- версионности (второй владелец истории), а дополнительное место
617
- физики; DELETE+INSERT, ALTER и транзакции работают в каталоге как есть.
618
- SQLite-каталог не добавляет инфраструктуры; мультипроцессность
619
- честно потребует Postgres-каталогаотложено до реального потребителя.
620
- 6. **Конкурентность записи DuckDB.** *Закрыт (F5):* блокировка в state
621
- store покрывает и apply — `run` и `apply` окружения идут под одним
622
- локом `env:<имя>` (протухший перехватывается по ttl), у `janitor` свой
623
- глобальный лок; в CLI лок охватывает план→подтверждение→применение.
624
- Гонку janitor↔apply (воскрешение осиротевшего снапшота в момент сноса)
625
- смягчает ttl осиротевшей физики.
677
+ ## 14. Open questions
678
+
679
+ 1. **Raw `.sql` files.** *Closed (F3):* `defineSqlModel({ file, refs })` —
680
+ the body in a `.sql` file with `@ref(name)`/`@start`/`@end`, dependencies declared
681
+ by values in `refs` (each `@ref` must be declared, extra ones are an error),
682
+ so the DAG, fingerprint and testModel work as for ordinary models.
683
+ Column typing is still lost the honest price of migration.
684
+ 2. **Nullability in the contract.** *Closed (F5) by a decision:* `DESCRIBE <query>`
685
+ in DuckDB gives names and types, but not nullability — the contract checks
686
+ names and types (families), the promise "not NULL" is expressed by the
687
+ `audit.notNull` audit (blocking by default). `Schema.NullOr` in a model is
688
+ documentation of the data's shape, not a runtime guarantee.
689
+ 3. **Non-time-based intervals.** sqlmesh can do `INCREMENTAL_BY_PARTITION`.
690
+ Whether to generalize tracking from time intervals to arbitrary partitions
691
+ to be decided by the first real consumer.
692
+ 4. **Multi-engine in one project.** ATTACH covers the typical case without a second
693
+ adapter (read from PG, export to PG, computing everything in DuckDB); a full-fledged
694
+ multi-engine is out of scope, but the per-model `EngineAdapter` layer does not contradict it.
695
+ 5. **DuckLake.** *Closed (F4):* the third materialization target
696
+ `target: "ducklake"` (§3.3) is implemented a table-per-fingerprint in an ATTACH catalog,
697
+ versioning stays ours, DuckLake's snapshots/time travel are a bonus.
698
+ Exactly per the conclusions of the 2026-07-15 probe: DuckLake is not a replacement for our
699
+ versioning (a second owner of history), but an additional place for
700
+ physical storage; DELETE+INSERT, ALTER and transactions work in the catalog as is.
701
+ A SQLite catalog adds no infrastructure; multiprocess use
702
+ would honestly require a Postgres catalog deferred until a real consumer.
703
+ 6. **DuckDB write concurrency.** *Closed (F5):* the lock in the state
704
+ store covers apply too — `run` and `apply` of an environment go under one
705
+ lock `env:<name>` (a stale one is reclaimed by ttl), the `janitor` has its own
706
+ global lock; in the CLI the lock spans plan→confirmation→application.
707
+ The janitor↔apply race (resurrecting an orphaned snapshot at the moment of removal)
708
+ is mitigated by the ttl of the orphaned physical storage.
626
709
 
627
710
  ---
628
711
 
629
- ## 15. Сравнение (шпаргалка)
712
+ ## 15. Comparison (cheat sheet)
630
713
 
631
714
  | | dbt | sqlmesh | efmesh |
632
715
  |---|---|---|---|
633
- | Язык моделей | SQL + Jinja | SQL + Jinja/Python | SQL внутри TypeScript |
634
- | Зависимости | `ref('строка')` | парсинг SQL | импорт модуля, проверен компилятором |
635
- | Типизация колонок | нет | contracts (рантайм) | Effect Schema (компайл-тайм + план) |
636
- | Версионирование | нет (state-less) | снапшоты + fingerprint | снапшоты + fingerprint |
637
- | Dev-окружения | копии таблиц | виртуальные (view) | виртуальные (view) |
638
- | Инкрементальность | самописный `is_incremental()` | интервалы, автоучёт | интервалы, автоучёт |
639
- | Мульти-диалект | да | да (sqlglot) | нетдиалект движка (DuckDB первым) |
640
- | Озеро на parquet | адаптеры | адаптеры | родное: target `parquet`, интервал=партиция |
641
- | Встраивание | subprocess | Python API | Effect-библиотека |
716
+ | Model language | SQL + Jinja | SQL + Jinja/Python | SQL inside TypeScript |
717
+ | Dependencies | `ref('string')` | SQL parsing | module import, checked by the compiler |
718
+ | Column typing | none | contracts (runtime) | Effect Schema (compile-time + plan) |
719
+ | Versioning | none (state-less) | snapshots + fingerprint | snapshots + fingerprint |
720
+ | Dev environments | table copies | virtual (views) | virtual (views) |
721
+ | Incrementality | hand-rolled `is_incremental()` | intervals, auto-tracked | intervals, auto-tracked |
722
+ | Multi-dialect | yes | yes (sqlglot) | nothe engine's dialect (DuckDB first) |
723
+ | Parquet lake | adapters | adapters | native: `target: "parquet"`, interval = partition |
724
+ | Embedding | subprocess | Python API | Effect library |