@avytheone/efmesh 0.1.0-beta.1

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 ADDED
@@ -0,0 +1,641 @@
1
+ # EFMESH — спецификация
2
+
3
+ **efmesh** — фреймворк трансформации данных в духе sqlmesh, но на TypeScript, Bun и Effect v4.
4
+ Не порт sqlmesh, а перенос его *идей* на другую почву: там, где sqlmesh опирается на Python-макросы
5
+ и Jinja, efmesh опирается на систему типов TypeScript и на Effect как рантайм.
6
+
7
+ Это архитектурный документ: решения, инварианты и открытые вопросы.
8
+ Знакомство с проектом и справка пользователя — в [README](./README.md),
9
+ история изменений — в [CHANGELOG](./CHANGELOG.md).
10
+
11
+ Статус: v0.2 — реализация прошла фазы F0–F5 (§13), версия пакета
12
+ 0.1.0-beta.1; реализованное помечено в тексте уточнениями «*Реализация Fn*».
13
+
14
+ ---
15
+
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).
49
+
50
+ ---
51
+
52
+ ## 2. Понятийное ядро
53
+
54
+ Пять понятий, всё остальное — производные:
55
+
56
+ | Понятие | Что это |
57
+ |---|---|
58
+ | **Модель** | Именованная трансформация: SQL-запрос + метаданные (вид, cron, схема, аудиты). Материализуется в таблицу или view. |
59
+ | **Снапшот** | Версия модели, идентифицируемая fingerprint'ом — хэшем канонизированного SQL и метаданных. Каждому снапшоту соответствует своя физическая таблица. |
60
+ | **Интервал** | Полуинтервал времени `[start, end)`, для которого данные снапшота посчитаны. Учёт заполненных интервалов — основа инкрементальности и бэкфилла. |
61
+ | **Окружение** | Именованный набор view (`dev`, `prod`, `feature_x`), указывающих на физические таблицы снапшотов. Виртуальное: создание окружения — это создание view, не копирование данных. |
62
+ | **План** | Diff между локальными определениями моделей и состоянием окружения + перечень интервалов к пересчёту. Просмотрел → применил. |
63
+
64
+ Ключевая механика (взята у sqlmesh без изменений, потому что она правильная):
65
+
66
+ ```
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 = родные схемы)
71
+ ```
72
+
73
+ Не изменил модель — dev и prod смотрят на **одну и ту же** физическую таблицу.
74
+ Изменил — в dev появляется новая физическая таблица, prod не тронут.
75
+ Промоушен в prod — переключение view, без копирования данных и без пересчёта.
76
+
77
+ ---
78
+
79
+ ## 3. Определение модели
80
+
81
+ Модель — TS-модуль, экспортирующий результат `defineModel`:
82
+
83
+ ```ts
84
+ import { defineModel, kind, audit } from "efmesh"
85
+ import { Schema } from "effect"
86
+ import { moves } from "./moves"
87
+
88
+ export const stays = defineModel({
89
+ name: "med.stays",
90
+ kind: kind.incrementalByTimeRange({ timeColumn: "moved_at" }),
91
+ cron: "@daily",
92
+ grain: ["stay_id"], // логический первичный ключ
93
+ schema: Schema.Struct({
94
+ stay_id: Schema.String,
95
+ dept: Schema.String,
96
+ moved_at: Schema.DateTimeUtc,
97
+ duration: Schema.Number,
98
+ }),
99
+ audits: [audit.notNull("stay_id"), audit.unique("stay_id")],
100
+ description: "Пребывания пациента в отделении, склеенные из движений",
101
+ }, (ctx) => ctx.sql`
102
+ SELECT
103
+ ${ctx.cols(moves, "move_id", "dept")},
104
+ moved_at,
105
+ extract(epoch FROM lead(moved_at) OVER w - moved_at) AS duration
106
+ FROM ${ctx.ref(moves)}
107
+ WHERE moved_at >= ${ctx.start} AND moved_at < ${ctx.end}
108
+ WINDOW w AS (PARTITION BY case_id ORDER BY moved_at)
109
+ `)
110
+ ```
111
+
112
+ Что здесь важно:
113
+
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`.
126
+
127
+ ### 3.1 Виды моделей (kind)
128
+
129
+ | Вид | Семантика | Фаза |
130
+ |---|---|---|
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` и будет пересчитан.
172
+
173
+ ---
174
+
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».
206
+
207
+ ---
208
+
209
+ ## 5. План: diff и применение
210
+
211
+ ### 5.1 Что делает `efmesh plan <env>`
212
+
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
+ пайпы) едет без вопроса.*
225
+
226
+ ### 5.2 Категории изменений
227
+
228
+ | Категория | Как определяется | Что пересчитывается |
229
+ |---|---|---|
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 —
262
+ `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
+ использующим её снапшотом.
278
+
279
+ ---
280
+
281
+ ## 6. Состояние
282
+
283
+ Всё состояние — в state store (по умолчанию — SQLite-файл через `bun:sqlite` рядом
284
+ с проектом; для командной/прод-работы — схема `efmesh_state` в Postgres,
285
+ `state: { url: "postgres://…" }` в конфиге; семантика и раскладка идентичны,
286
+ метки времени — ISO UTC текстом в обоих бэкендах). Сам DuckDB
287
+ на роль state store не годится: он однописательский, а состояние должно переживать
288
+ конкурентные запуски из разных процессов. Таблицы:
289
+
290
+ ```
291
+ snapshots (name, fingerprint, definition_json, created_at)
292
+ intervals (snapshot_fp, start_ts, end_ts, status: done|failed, updated_at)
293
+ environments (env, name, snapshot_fp, promoted_at)
294
+ plans (id, env, summary_json, applied_at, applied_by) -- журнал аудита
295
+ meta (version) -- версия схемы (F4)
296
+ ```
297
+
298
+ `applied_by` (F5, версия схемы 2) — кто применил план: `ApplyOptions.appliedBy`
299
+ или ОС-пользователь; записи доверсионного журнала читаются с пустым автором.
300
+
301
+ Схема стора версионирована (F4): свежий стор бутстрапится на текущую
302
+ `STATE_VERSION` сам; существующий со схемой старше (включая доверсионные)
303
+ открытие не проходит — `StateSchemaError` с подсказкой. Догоняет явный
304
+ `efmesh migrate` — молча менять чужие данные при открытии нельзя.
305
+
306
+ Инварианты:
307
+
308
+ - Учёт интервалов — единственный источник правды о том, что посчитано.
309
+ Физическая таблица без записей в `intervals` считается пустой.
310
+ - Запись в state store — только через транзакции state store; efmesh никогда
311
+ не смешивает в одной транзакции данные и состояние, если движок данных
312
+ и state store — разные базы (двухфазности нет, есть идемпотентность:
313
+ пересчёт интервала безопасен всегда).
314
+
315
+ ---
316
+
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` внутри вашего рантайма.
338
+
339
+ ---
340
+
341
+ ## 8. Качество: аудиты и тесты
342
+
343
+ **Аудит** — SQL-предикат над результатом, выполняется после загрузки интервала.
344
+ Возвращает нарушающие строки; непустой результат = `AuditFailure`.
345
+
346
+ ```ts
347
+ audits: [
348
+ audit.notNull("stay_id"),
349
+ audit.unique("stay_id"),
350
+ audit.accepted("dept", ["ОРИТ", "терапия", "хирургия"]),
351
+ audit.custom("положительная длительность", (ctx) => ctx.sql`
352
+ SELECT * FROM ${ctx.self} WHERE duration < 0
353
+ `),
354
+ ]
355
+ ```
356
+
357
+ Аудит бывает `blocking` (по умолчанию: интервал помечается failed, view не
358
+ промоутится) и `warn` (метрика + лог, конвейер едет дальше).
359
+
360
+ Помимо прогона в apply, есть автономный `efmesh audit <env>` (F4): проверяет
361
+ то, что окружение отдаёт потребителям СЕЙЧАС — view-слой целиком, а не
362
+ свежезагруженный интервал. Ловит деградацию задним числом (поздние данные,
363
+ правку физики мимо efmesh, дрейф external); ничего не меняет и не помечает,
364
+ отчёт целиком, ненулевой exit при blocking-нарушениях.
365
+
366
+ **Тест** — юнит-тест запроса на фикстурах, живёт в `bun test`:
367
+
368
+ ```ts
369
+ import { testModel } from "efmesh/testing"
370
+
371
+ testModel(stays, {
372
+ 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" },
375
+ ]},
376
+ interval: ["2026-01-01", "2026-01-03"],
377
+ expect: [{ stay_id: "1", dept: "ОРИТ", duration: 86400 }],
378
+ })
379
+ ```
380
+
381
+ Механика: `ctx.ref` в тестовом режиме рендерится в CTE с `VALUES` из фикстур
382
+ (валидированных через Schema модели-источника!), запрос выполняется на
383
+ одноразовом in-memory DuckDB — мгновенно, без docker и внешней инфраструктуры, —
384
+ результат сравнивается с ожидаемым. Фикстуры с невалидными по Schema данными
385
+ не компилируются — тест не может врать о форме входа.
386
+
387
+ ---
388
+
389
+ ## 9. Движки и парсинг SQL
390
+
391
+ ### 9.1 Адаптер движка — Effect-сервис
392
+
393
+ ```ts
394
+ interface Engine {
395
+ readonly dialect: "duckdb" | "postgres"
396
+ readonly query: (sql: string) => Effect<Rows, EngineError>
397
+ readonly execute: (sql: string) => Effect<void, EngineError>
398
+ /** Набор стейтментов одной транзакцией: на пуле соединений BEGIN/COMMIT
399
+ отдельными execute разъехались бы по разным соединениям. */
400
+ 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
403
+ }
404
+ ```
405
+
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.
452
+
453
+ ### 9.4 Lineage
454
+
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
+ имён, граф моделей точен всегда — приблизителен только колоночный уровень.
462
+
463
+ ---
464
+
465
+ ## 10. Effect-архитектура
466
+
467
+ Слои (снизу вверх):
468
+
469
+ ```
470
+ EngineAdapter (DuckDB | Postgres) StateStore (SQLite | PG)
471
+ └──────────────┬──────────────────────┘
472
+ Snapshotter (fingerprint, канонизация)
473
+ IntervalLedger (учёт интервалов)
474
+
475
+ Planner (diff, категоризация, план)
476
+ Executor (бэкфилл: Stream + DAG-конкурентность)
477
+ Auditor
478
+
479
+ Efmesh (фасад: plan / apply / run / test)
480
+
481
+ CLI (тонкая обёртка) | ваш Effect-код (библиотечное встраивание)
482
+ ```
483
+
484
+ Принципы:
485
+
486
+ - **Типизированные ошибки** на каждом уровне: `ParseError | SchemaMismatchError |
487
+ PlanConflictError | AuditFailure | IntervalFailure | EngineError`. Никаких
488
+ `throw` — вся обработка через канал ошибок Effect.
489
+ - **Один владелец жизненного цикла**: ресурсы (пулы соединений, WASM-инстанс
490
+ парсера) — `Scope`/`Layer`, teardown гарантирован.
491
+ - **Наблюдаемость из коробки**: span на каждую модель/интервал, `Metric` —
492
+ `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.
507
+
508
+ ---
509
+
510
+ ## 11. CLI
511
+
512
+ ```
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> — чем окружения отличаются
520
+ efmesh lineage <model[.column]>
521
+ efmesh graph [--html] — DAG моделей
522
+ efmesh janitor [--ttl 7] — уборка осиротевших физических таблиц
523
+ efmesh migrate — догнать схему state store до текущей версии
524
+ ```
525
+
526
+ Конфиг — `efmesh.config.ts` (не YAML): типизирован, собирает `Layer` движка
527
+ и state store, задаёт окружения, `start` проекта, ttl, конкурентность.
528
+
529
+ ---
530
+
531
+ ## 12. Структура проекта пользователя
532
+
533
+ ```
534
+ my-warehouse/
535
+ efmesh.config.ts
536
+ models/
537
+ sources.ts — external-модели (сырьё) с их Schema
538
+ med/
539
+ moves.ts
540
+ stays.ts
541
+ seeds/
542
+ departments.csv
543
+ tests/
544
+ stays.test.ts — bun test
545
+ ```
546
+
547
+ Discovery моделей — по glob из конфига + проверка, что каждый экспорт
548
+ `defineModel` достижим; дубликаты имён — ошибка загрузки. *Реализовано (F5):
549
+ `discovery: glob | glob[]` в конфиге (маски относительно конфига), все
550
+ экспорты-модели найденных файлов попадают в проект; реэкспорт того же
551
+ объекта — не дубликат, два разных определения с одним именем —
552
+ `DiscoveryConflictError` с обоими файлами; совместимо с явным `models`.*
553
+
554
+ ---
555
+
556
+ ## 13. Фазы
557
+
558
+ - **F0 — скелет (вертикальный срез).** `defineModel`, `full`/`view`, рендер и
559
+ `ctx.ref`, DuckDB-адаптер (нативные таблицы), state store на `bun:sqlite`,
560
+ `plan`/`apply` без категоризации (всё breaking), физический+виртуальный слой,
561
+ `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.
591
+
592
+ ---
593
+
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 осиротевшей физики.
626
+
627
+ ---
628
+
629
+ ## 15. Сравнение (шпаргалка)
630
+
631
+ | | dbt | sqlmesh | efmesh |
632
+ |---|---|---|---|
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-библиотека |