@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.
@@ -0,0 +1,760 @@
1
+ import { mkdirSync, renameSync } from "node:fs"
2
+ import { userInfo } from "node:os"
3
+ import { Clock, Data, Deferred, Effect, Schedule } from "effect"
4
+ import { AuditFailure } from "../core/audit.ts"
5
+ import type { SeedReadError } from "../core/errors.ts"
6
+ import type { GraphError, ModelGraph } from "../core/graph.ts"
7
+ import type { Interval } from "../core/interval.ts"
8
+ import { intervalsWithin, splitIntoBatches, sqlTimestamp, toIso } from "../core/interval.ts"
9
+ import { columnNames, type AnyModel } from "../core/model.ts"
10
+ import { quoteIdent, render } from "../core/sql.ts"
11
+ import { EngineAdapter } from "../engine/adapter.ts"
12
+ import type { Engine, EngineError, SqlParseError } from "../engine/adapter.ts"
13
+ import { StateStore } from "../state/store.ts"
14
+ import type { StateError, StateStoreShape } from "../state/store.ts"
15
+ import { Metric } from "effect"
16
+ import { checkContract, SchemaMismatchError } from "./contract.ts"
17
+ import { FINGERPRINT_VERSION } from "./fingerprint.ts"
18
+ import { auditFailuresTotal, intervalsDone, snapshotsBuilt } from "./metrics.ts"
19
+ import {
20
+ ducklakeAttachSql,
21
+ ducklakeRef,
22
+ envSchema,
23
+ externalSourceRef,
24
+ intervalKey,
25
+ parquetPrefix,
26
+ parquetRef,
27
+ physicalRef,
28
+ physicalSchema,
29
+ physicalTable,
30
+ viewRef,
31
+ } from "./naming.ts"
32
+ import type {
33
+ FingerprintVersionError,
34
+ ForwardOnlyError,
35
+ InvalidEnvironmentError,
36
+ Plan,
37
+ PlanAction,
38
+ } from "./planner.ts"
39
+
40
+ export interface AppliedPlan {
41
+ readonly plan: Plan
42
+ /** Имена моделей, для которых собиралась физика или гнался бэкфилл. */
43
+ readonly built: ReadonlyArray<string>
44
+ }
45
+
46
+ /** В проекте есть parquet-модели, но путь озера не задан в конфиге. */
47
+ export class LakeNotConfiguredError extends Data.TaggedError("LakeNotConfiguredError")<{
48
+ readonly model: string
49
+ }> {}
50
+
51
+ /** В проекте есть ducklake-модели, но каталог не задан в конфиге. */
52
+ export class DucklakeNotConfiguredError extends Data.TaggedError("DucklakeNotConfiguredError")<{
53
+ readonly model: string
54
+ }> {}
55
+
56
+ export type ApplyError =
57
+ | GraphError
58
+ | StateError
59
+ | EngineError
60
+ | SqlParseError
61
+ | SeedReadError
62
+ | SchemaMismatchError
63
+ | LakeNotConfiguredError
64
+ | DucklakeNotConfiguredError
65
+ | AttachNotConfiguredError
66
+ | AuditFailure
67
+ | InvalidEnvironmentError
68
+ | ForwardOnlyError
69
+ | FingerprintVersionError
70
+ | EngineFeatureError
71
+
72
+ export interface ApplyOptions {
73
+ /** «Сейчас» для scdType2-версионирования; по умолчанию — Clock. Инъекция для тестов. */
74
+ readonly now?: number
75
+ /** Корень parquet-озера — локальная директория или s3://… (httpfs). */
76
+ readonly lakePath?: string
77
+ /** ATTACH-базы по алиасам (SPEC §9.3) — для export-моделей. */
78
+ readonly attach?: Readonly<Record<string, { readonly url: string; readonly options?: string }>>
79
+ /** DuckLake-каталог для target: "ducklake" (SPEC §14.5). DuckDB-only. */
80
+ readonly ducklake?: { readonly catalog: string; readonly dataPath?: string }
81
+ /**
82
+ * Сколько батчей бэкфилла одной модели считать одновременно (SPEC §5.3).
83
+ * Осмыслен только на движке с пулом соединений (Postgres); DuckDB держит
84
+ * одно соединение — там бэкфилл последовательный независимо от значения.
85
+ */
86
+ readonly concurrency?: number
87
+ /**
88
+ * Межмодельная DAG-конкурентность (SPEC §5.3): сколько моделей строить
89
+ * одновременно. Модель стартует, как только готовы её родители из этого
90
+ * плана — независимые ветки DAG идут параллельно. Осмыслен на движке
91
+ * с пулом (Postgres); DuckDB держит одно соединение — там модели строятся
92
+ * последовательно, иначе чужие стейтменты вклинивались бы в BEGIN/COMMIT.
93
+ */
94
+ readonly modelConcurrency?: number
95
+ /**
96
+ * Ретраи упавшего батча бэкфилла (SPEC §5.3): Schedule.exponential от
97
+ * baseDelayMs (по умолчанию 500 мс), не больше attempts повторов. Батч
98
+ * транзакционен (DELETE+INSERT в одной транзакции, COPY перезаписывает
99
+ * партицию целиком) — повтор безопасен. Аудиты не ретраятся: провал
100
+ * аудита детерминирован, это не транзиентный сбой.
101
+ */
102
+ readonly retry?: { readonly attempts: number; readonly baseDelayMs?: number }
103
+ /** Кто применяет план — в журнал (SPEC §6); по умолчанию ОС-пользователь. */
104
+ readonly appliedBy?: string
105
+ }
106
+
107
+ /** ОС-пользователь для журнала планов; в стерильных окружениях (CI) — ''. */
108
+ const osUser = (): string => {
109
+ try {
110
+ return userInfo().username
111
+ } catch {
112
+ return process.env["USER"] ?? ""
113
+ }
114
+ }
115
+
116
+ /** Модель просит экспорт в ATTACH-алиас, которого нет в конфиге. */
117
+ export class AttachNotConfiguredError extends Data.TaggedError("AttachNotConfiguredError")<{
118
+ readonly model: string
119
+ readonly attach: string
120
+ }> {}
121
+
122
+ /** Возможность DuckDB-федерации, недоступная на текущем движке (SPEC §9.3). */
123
+ export class EngineFeatureError extends Data.TaggedError("EngineFeatureError")<{
124
+ readonly model: string
125
+ readonly feature: string
126
+ readonly dialect: string
127
+ }> {}
128
+
129
+ /** Несколько стейтментов одной транзакцией движка; откат при любой ошибке. */
130
+ const transactional = (
131
+ engine: Engine,
132
+ statements: ReadonlyArray<string>,
133
+ ): Effect.Effect<void, EngineError> => engine.transaction(statements)
134
+
135
+ /** Ретраи транзиентного сбоя записи батча; без retry в опциях — как было. */
136
+ const withBatchRetry =
137
+ (retry: ApplyOptions["retry"]) =>
138
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
139
+ retry === undefined || retry.attempts <= 0
140
+ ? effect
141
+ : Effect.retry(effect, {
142
+ times: retry.attempts,
143
+ schedule: Schedule.exponential(retry.baseDelayMs ?? 500),
144
+ })
145
+
146
+ /** Для s3://-путей mkdir не нужен (и невозможен) — httpfs пишет напрямую. */
147
+ const ensureDir = (path: string): Effect.Effect<void> =>
148
+ path.startsWith("s3://")
149
+ ? Effect.void
150
+ : Effect.sync(() => mkdirSync(path, { recursive: true }))
151
+
152
+ /**
153
+ * Прогон аудитов модели (SPEC §8): `self` — физика снапшота или подзапрос
154
+ * только что загруженного интервала. Запрос аудита возвращает нарушения:
155
+ * blocking → AuditFailure, warn → лог и дальше.
156
+ */
157
+ const runAudits = (
158
+ engine: Engine,
159
+ model: AnyModel,
160
+ self: string,
161
+ ): Effect.Effect<void, EngineError | AuditFailure> =>
162
+ Effect.gen(function* () {
163
+ for (const auditDef of model.audits) {
164
+ const violations = yield* engine.query(
165
+ render(auditDef.fragment, { resolveRef: (ref) => ref, self }),
166
+ )
167
+ if (violations.length === 0) continue
168
+ if (auditDef.blocking) {
169
+ yield* Metric.update(auditFailuresTotal, 1)
170
+ return yield* new AuditFailure({
171
+ model: model.name.full,
172
+ audit: auditDef.name,
173
+ violations: violations.length,
174
+ })
175
+ }
176
+ yield* Effect.logWarning(
177
+ `аудит ${auditDef.name} модели ${model.name.full}: ${violations.length} нарушений (warn)`,
178
+ )
179
+ }
180
+ })
181
+
182
+ /**
183
+ * Эволюция унаследованной таблицы при forward-only (SPEC §5.2): колонки,
184
+ * появившиеся в новом запросе, добавляются через ALTER (история получает
185
+ * NULL — она не переигрывается), удаление колонок реюзом не выражается —
186
+ * это честный breaking с пересборкой.
187
+ */
188
+ const evolveTableForForwardOnly = (
189
+ engine: Engine,
190
+ model: AnyModel,
191
+ target: string,
192
+ emptyBody: string,
193
+ ): Effect.Effect<void, EngineError | SchemaMismatchError> =>
194
+ Effect.gen(function* () {
195
+ const wanted = yield* engine.describe(emptyBody)
196
+ const actual = yield* engine.describe(`SELECT * FROM ${target}`)
197
+ const have = new Set(actual.map((column) => column.name))
198
+ const wantedNames = new Set(wanted.map((column) => column.name))
199
+ const vanished = actual.filter((column) => !wantedNames.has(column.name))
200
+ if (vanished.length > 0) {
201
+ return yield* new SchemaMismatchError({
202
+ model: model.name.full,
203
+ problems: vanished.map(
204
+ (column) =>
205
+ `forward-only: колонка «${column.name}» есть в унаследованной физике, но исчезла из запроса — удаление колонок требует обычного (breaking) применения`,
206
+ ),
207
+ })
208
+ }
209
+ for (const column of wanted) {
210
+ if (have.has(column.name)) continue
211
+ yield* engine.execute(
212
+ `ALTER TABLE ${target} ADD COLUMN ${quoteIdent(column.name)} ${column.type}`,
213
+ )
214
+ }
215
+ })
216
+
217
+ /**
218
+ * Бэкфилл incrementalByTimeRange в таблицу (SPEC §5.3): каждый диапазон
219
+ * плана режется на батчи ≤ batchSize; батч — транзакция DELETE-диапазона +
220
+ * INSERT, после успеха его интервалы помечаются done. Упавший батч
221
+ * помечается failed и прерывает apply; уже отмеченное не пересчитывается
222
+ * при повторе — бэкфилл продолжается с места остановки.
223
+ */
224
+ const backfillIntoTable = (
225
+ engine: Engine,
226
+ store: StateStoreShape,
227
+ model: AnyModel,
228
+ action: PlanAction,
229
+ target: string,
230
+ resolveRef: (ref: string) => string,
231
+ concurrency: number,
232
+ retry: ApplyOptions["retry"],
233
+ ): Effect.Effect<void, EngineError | StateError | AuditFailure> =>
234
+ Effect.gen(function* () {
235
+ if (model.kind._tag !== "incrementalByTimeRange") return
236
+ const kind = model.kind
237
+ // вставка по именам: после forward-only-эволюции порядок колонок физики
238
+ // может отличаться от запроса; контракт схемы гарантирует совпадение имён
239
+ const columns = columnNames(model).map(quoteIdent).join(", ")
240
+
241
+ const runBatch = (batch: Interval) =>
242
+ Effect.gen(function* () {
243
+ const marks = intervalsWithin(batch, kind.interval).map((interval: Interval) => ({
244
+ startTs: toIso(interval.start),
245
+ endTs: toIso(interval.end),
246
+ }))
247
+ const start = sqlTimestamp(batch.start)
248
+ const end = sqlTimestamp(batch.end)
249
+ const body = render(model.fragment, { resolveRef, interval: { start, end } })
250
+ const markFailed = () =>
251
+ store.markIntervals(action.fingerprint, marks, "failed").pipe(Effect.ignore)
252
+ yield* transactional(engine, [
253
+ `DELETE FROM ${target} WHERE ${quoteIdent(kind.timeColumn)} >= ${start} AND ${quoteIdent(kind.timeColumn)} < ${end}`,
254
+ `INSERT INTO ${target} (${columns}) SELECT ${columns} FROM (${body}) q`,
255
+ ]).pipe(withBatchRetry(retry), Effect.tapError(markFailed))
256
+ // аудит свежезагруженного интервала — до отметки done (SPEC §8)
257
+ yield* runAudits(
258
+ engine,
259
+ model,
260
+ `(SELECT * FROM ${target} WHERE ${quoteIdent(kind.timeColumn)} >= ${start} AND ${quoteIdent(kind.timeColumn)} < ${end})`,
261
+ ).pipe(Effect.tapError(markFailed))
262
+ yield* store.markIntervals(action.fingerprint, marks, "done")
263
+ yield* Metric.update(intervalsDone, marks.length)
264
+ })
265
+
266
+ // пул соединений (Postgres) → батчи параллельно: интервалы не пересекаются,
267
+ // каждый — своя транзакция; DuckDB (одно соединение) — последовательно
268
+ const batches = action.backfill.flatMap((range) =>
269
+ splitIntoBatches(range, kind.interval, kind.batchSize),
270
+ )
271
+ yield* Effect.forEach(batches, runBatch, {
272
+ concurrency: engine.dialect === "duckdb" ? 1 : Math.max(1, concurrency),
273
+ discard: true,
274
+ })
275
+ })
276
+
277
+ /**
278
+ * Бэкфилл в parquet-озеро (SPEC §3.3): интервал = партиция, пересчёт —
279
+ * перезапись файла партиции. Транзакция не нужна: недописанная партиция
280
+ * не помечена done и будет перезаписана при повторе.
281
+ */
282
+ const backfillIntoParquet = (
283
+ engine: Engine,
284
+ store: StateStoreShape,
285
+ model: AnyModel,
286
+ action: PlanAction,
287
+ prefix: string,
288
+ resolveRef: (ref: string) => string,
289
+ retry: ApplyOptions["retry"],
290
+ ): Effect.Effect<void, EngineError | StateError | AuditFailure> =>
291
+ Effect.gen(function* () {
292
+ if (model.kind._tag !== "incrementalByTimeRange") return
293
+ const kind = model.kind
294
+ for (const range of action.backfill) {
295
+ for (const interval of intervalsWithin(range, kind.interval)) {
296
+ const partition = `${prefix}/interval=${intervalKey(kind.interval, interval.start)}`
297
+ yield* ensureDir(partition)
298
+ const body = render(model.fragment, {
299
+ resolveRef,
300
+ interval: { start: sqlTimestamp(interval.start), end: sqlTimestamp(interval.end) },
301
+ })
302
+ const mark = [{ startTs: toIso(interval.start), endTs: toIso(interval.end) }]
303
+ const markFailed = () =>
304
+ store.markIntervals(action.fingerprint, mark, "failed").pipe(Effect.ignore)
305
+ // локально COPY пишет во временный файл, rename атомарен (POSIX):
306
+ // kill посреди записи не оставит битую партицию, а lookback-пересчёт
307
+ // не подсунет читателю view недописанный файл; s3 — прямая запись
308
+ // (rename нет; недописанный ключ не помечен done и перезапишется)
309
+ const target = `${partition}/data.parquet`
310
+ const writePath = partition.startsWith("s3://") ? target : `${target}.tmp`
311
+ yield* engine
312
+ .execute(`COPY (${body}) TO '${writePath.replaceAll(`'`, `''`)}' (FORMAT PARQUET)`)
313
+ .pipe(withBatchRetry(retry), Effect.tapError(markFailed))
314
+ if (writePath !== target) {
315
+ yield* Effect.sync(() => renameSync(writePath, target))
316
+ }
317
+ const file = target.replaceAll(`'`, `''`)
318
+ // аудит записанной партиции — до отметки done; провал = не done → перезапись
319
+ yield* runAudits(engine, model, `(SELECT * FROM read_parquet('${file}'))`).pipe(
320
+ Effect.tapError(markFailed),
321
+ )
322
+ yield* store.markIntervals(action.fingerprint, mark, "done")
323
+ yield* Metric.update(intervalsDone, 1)
324
+ }
325
+ }
326
+ })
327
+
328
+ /**
329
+ * Применяет план (SPEC §5): в топологическом порядке собирает недостающую
330
+ * физику и догоняет интервалы (ссылки в SQL резолвятся в физические таблицы
331
+ * ЭТОГО плана, не во view окружения — середина apply не видна снаружи),
332
+ * затем промоушен — пересоздание view + транзакционная запись набора
333
+ * в state store.
334
+ */
335
+ export const applyPlan = (
336
+ plan: Plan,
337
+ graph: ModelGraph,
338
+ options?: ApplyOptions,
339
+ ): Effect.Effect<AppliedPlan, ApplyError, EngineAdapter | StateStore> =>
340
+ Effect.gen(function* () {
341
+ const engine = yield* EngineAdapter
342
+ const store = yield* StateStore
343
+ const lakePath = options?.lakePath
344
+ const nowMs = options?.now ?? (yield* Clock.currentTimeMillis)
345
+
346
+ // физика модели — то, что увидят её потребители и view окружения;
347
+ // ключ — physicalFingerprint: при forward-only это физика старой версии
348
+ const physicalFpOf = new Map(plan.actions.map((a) => [a.name, a.physicalFingerprint]))
349
+ const physicalFor = (model: AnyModel, fingerprint: string): string => {
350
+ if (model.kind._tag === "external") return externalSourceRef(model.kind.source)
351
+ // embedded не материализуется — подставляется потребителю подзапросом,
352
+ // его собственные ссылки резолвятся рекурсивно (DAG ацикличен)
353
+ if (model.kind._tag === "embedded") return `(${render(model.fragment, { resolveRef })})`
354
+ if (model.target === "parquet") {
355
+ if (lakePath === undefined) throw new LakeNotConfiguredError({ model: model.name.full })
356
+ return parquetRef(lakePath, model.name, fingerprint)
357
+ }
358
+ return tableRef(model, fingerprint)
359
+ }
360
+ // нативная физика или таблица DuckLake-каталога — по цели модели
361
+ const tableRef = (model: AnyModel, fingerprint: string): string =>
362
+ model.target === "ducklake"
363
+ ? ducklakeRef(model.name, fingerprint)
364
+ : physicalRef(model.name, fingerprint)
365
+ const resolveRef = (ref: string): string => {
366
+ const model = graph.models.get(ref)
367
+ const fingerprint = physicalFpOf.get(ref)
368
+ if (model === undefined || fingerprint === undefined) {
369
+ throw new Error(`ссылка на модель вне плана: ${ref}`)
370
+ }
371
+ return physicalFor(model, fingerprint)
372
+ }
373
+
374
+ // parquet-модели без озера — падение до любых действий
375
+ for (const action of plan.actions) {
376
+ const model = graph.models.get(action.name)
377
+ if (model === undefined) continue
378
+ if (model.target === "parquet" && lakePath === undefined) {
379
+ return yield* new LakeNotConfiguredError({ model: model.name.full })
380
+ }
381
+ if (model.target === "ducklake" && options?.ducklake === undefined) {
382
+ return yield* new DucklakeNotConfiguredError({ model: model.name.full })
383
+ }
384
+ // DuckDB-федерация (SPEC §9.3) на других движках не выражается —
385
+ // честная ошибка до любых действий
386
+ if (engine.dialect !== "duckdb") {
387
+ const feature =
388
+ model.target === "parquet"
389
+ ? "target: parquet"
390
+ : model.target === "ducklake"
391
+ ? "target: ducklake"
392
+ : model.kind._tag === "seed"
393
+ ? "seed (read_csv/read_json)"
394
+ : model.kind._tag === "external" && model.kind.source._tag === "files"
395
+ ? "external по файлам/URL (read_*)"
396
+ : model.export !== undefined
397
+ ? "export в ATTACH-базу"
398
+ : undefined
399
+ if (feature !== undefined) {
400
+ return yield* new EngineFeatureError({
401
+ model: model.name.full,
402
+ feature,
403
+ dialect: engine.dialect,
404
+ })
405
+ }
406
+ }
407
+ }
408
+
409
+ // 1. Физика + бэкфилл — DAG-конкурентно (SPEC §5.3): модель стартует,
410
+ // как только готовы её родители из этого плана (Deferred-гейты, без
411
+ // волновых барьеров); независимые ветки идут параллельно на движке
412
+ // с пулом; упавший родитель не открывает гейт — потомки не строятся
413
+ yield* engine.execute(`CREATE SCHEMA IF NOT EXISTS "${physicalSchema}"`)
414
+ // ducklake-каталог нужен и для сборки, и для чистого view-swap —
415
+ // view окружений ссылаются на таблицы каталога по алиасу
416
+ if (options?.ducklake !== undefined && engine.dialect === "duckdb") {
417
+ yield* engine.execute(ducklakeAttachSql(options.ducklake))
418
+ }
419
+ const working = plan.actions.filter(
420
+ (action) =>
421
+ (action.build || action.backfill.length > 0 || action.refresh) &&
422
+ graph.models.get(action.name)?.kind._tag !== "external",
423
+ )
424
+ const gates = new Map<string, Deferred.Deferred<void>>()
425
+ for (const action of working) gates.set(action.name, yield* Deferred.make<void>())
426
+
427
+ const buildOne = (action: PlanAction): Effect.Effect<void, ApplyError> =>
428
+ Effect.gen(function* () {
429
+ const model = graph.models.get(action.name)!
430
+ switch (model.kind._tag) {
431
+ case "external":
432
+ return
433
+ case "embedded": {
434
+ // физики нет — но контракт и аудиты версии проверяются здесь,
435
+ // чтобы потребители не унесли в себя сломанный подзапрос
436
+ const body = render(model.fragment, { resolveRef })
437
+ yield* checkContract(engine, model, body)
438
+ yield* runAudits(engine, model, `(${body})`)
439
+ yield* store.upsertSnapshot({
440
+ name: action.name,
441
+ fingerprint: action.fingerprint,
442
+ physicalFp: action.physicalFingerprint,
443
+ canonicalAst: action.canonicalAst ?? "",
444
+ renderedSql: render(model.fragment, { resolveRef: (ref) => ref }),
445
+ kind: model.kind._tag,
446
+ fingerprintVersion: FINGERPRINT_VERSION,
447
+ })
448
+ break
449
+ }
450
+ case "seed": {
451
+ const reader = model.kind.format === "csv" ? "read_csv" : "read_json"
452
+ const body = `SELECT * FROM ${reader}('${model.kind.file.replaceAll(`'`, `''`)}')`
453
+ yield* checkContract(engine, model, body)
454
+ const target = tableRef(model, action.physicalFingerprint)
455
+ yield* engine.execute(`CREATE OR REPLACE TABLE ${target} AS ${body}`)
456
+ yield* runAudits(engine, model, target)
457
+ yield* store.upsertSnapshot({
458
+ name: action.name,
459
+ fingerprint: action.fingerprint,
460
+ physicalFp: action.physicalFingerprint,
461
+ canonicalAst: "",
462
+ renderedSql: body,
463
+ kind: model.kind._tag,
464
+ fingerprintVersion: FINGERPRINT_VERSION,
465
+ })
466
+ break
467
+ }
468
+ case "incrementalByUniqueKey": {
469
+ const body = render(model.fragment, { resolveRef })
470
+ yield* checkContract(engine, model, body)
471
+ const target = tableRef(model, action.physicalFingerprint)
472
+ yield* engine.execute(
473
+ `CREATE TABLE IF NOT EXISTS ${target} AS SELECT * FROM (${body}) q LIMIT 0`,
474
+ )
475
+ // upsert: строки с ключами из свежего запроса заменяются, остальные живут
476
+ const keys = model.kind.key.map(quoteIdent).join(", ")
477
+ yield* transactional(engine, [
478
+ `DELETE FROM ${target} WHERE (${keys}) IN (SELECT ${keys} FROM (${body}) q)`,
479
+ `INSERT INTO ${target} ${body}`,
480
+ ])
481
+ yield* runAudits(engine, model, target)
482
+ yield* store.upsertSnapshot({
483
+ name: action.name,
484
+ fingerprint: action.fingerprint,
485
+ physicalFp: action.physicalFingerprint,
486
+ canonicalAst: action.canonicalAst ?? "",
487
+ renderedSql: render(model.fragment, { resolveRef: (ref) => ref }),
488
+ kind: model.kind._tag,
489
+ fingerprintVersion: FINGERPRINT_VERSION,
490
+ })
491
+ break
492
+ }
493
+ case "scdType2": {
494
+ const scd = model.kind
495
+ const body = render(model.fragment, { resolveRef })
496
+ const managed = new Set([scd.validFrom, scd.validTo])
497
+ yield* checkContract(engine, model, body, managed)
498
+ const target = tableRef(model, action.physicalFingerprint)
499
+ const from = quoteIdent(scd.validFrom)
500
+ const to = quoteIdent(scd.validTo)
501
+ yield* engine.execute(
502
+ `CREATE TABLE IF NOT EXISTS ${target} AS
503
+ SELECT q.*, CAST(NULL AS TIMESTAMP) AS ${from}, CAST(NULL AS TIMESTAMP) AS ${to}
504
+ FROM (${body}) q LIMIT 0`,
505
+ )
506
+ const cols = columnNames(model)
507
+ .filter((column) => !managed.has(column))
508
+ .map(quoteIdent)
509
+ const tableName = quoteIdent(physicalTable(model.name, action.physicalFingerprint))
510
+ const ts = sqlTimestamp(nowMs)
511
+ const sameAsOuter = cols
512
+ .map((column) => `q.${column} IS NOT DISTINCT FROM ${tableName}.${column}`)
513
+ .join(" AND ")
514
+ const sameAsOpen = cols
515
+ .map((column) => `t.${column} IS NOT DISTINCT FROM q.${column}`)
516
+ .join(" AND ")
517
+ // SCD2-сверка (SPEC §3.1): открытая строка без идентичной строки в
518
+ // запросе закрывается (изменилась или исчезла); строка запроса без
519
+ // идентичной открытой строки вставляется новой открытой версией.
520
+ // Идентичные пары не трогаются — их valid_from не дрожит.
521
+ yield* transactional(engine, [
522
+ `UPDATE ${target} SET ${to} = ${ts}
523
+ WHERE ${to} IS NULL
524
+ AND NOT EXISTS (SELECT 1 FROM (${body}) q WHERE ${sameAsOuter})`,
525
+ `INSERT INTO ${target} (${cols.join(", ")}, ${from}, ${to})
526
+ SELECT ${cols.map((column) => `q.${column}`).join(", ")}, ${ts}, NULL
527
+ FROM (${body}) q
528
+ WHERE NOT EXISTS (
529
+ SELECT 1 FROM ${target} t WHERE t.${to} IS NULL AND ${sameAsOpen}
530
+ )`,
531
+ ])
532
+ yield* runAudits(engine, model, target)
533
+ yield* store.upsertSnapshot({
534
+ name: action.name,
535
+ fingerprint: action.fingerprint,
536
+ physicalFp: action.physicalFingerprint,
537
+ canonicalAst: action.canonicalAst ?? "",
538
+ renderedSql: render(model.fragment, { resolveRef: (ref) => ref }),
539
+ kind: model.kind._tag,
540
+ fingerprintVersion: FINGERPRINT_VERSION,
541
+ })
542
+ break
543
+ }
544
+ case "view":
545
+ case "full": {
546
+ const body = render(model.fragment, { resolveRef })
547
+ // контракт схемы (SPEC §3.2): дрейф типов ловится до сборки
548
+ yield* checkContract(engine, model, body)
549
+ if (model.kind._tag === "full" && model.target === "parquet") {
550
+ const prefix = parquetPrefix(lakePath!, model.name, action.physicalFingerprint)
551
+ yield* ensureDir(prefix)
552
+ yield* engine.execute(
553
+ `COPY (${body}) TO '${prefix.replaceAll(`'`, `''`)}/data.parquet' (FORMAT PARQUET)`,
554
+ )
555
+ } else {
556
+ const target = tableRef(model, action.physicalFingerprint)
557
+ if (model.kind._tag === "view") {
558
+ yield* engine.execute(`CREATE OR REPLACE VIEW ${target} AS ${body}`)
559
+ } else if (engine.dialect === "duckdb") {
560
+ yield* engine.execute(`CREATE OR REPLACE TABLE ${target} AS ${body}`)
561
+ } else {
562
+ // у Postgres нет CREATE OR REPLACE TABLE — атомарно через транзакцию
563
+ yield* transactional(engine, [
564
+ `DROP TABLE IF EXISTS ${target}`,
565
+ `CREATE TABLE ${target} AS ${body}`,
566
+ ])
567
+ }
568
+ }
569
+ // аудиты собранного снапшота — до промоушена (SPEC §8)
570
+ yield* runAudits(engine, model, physicalFor(model, action.physicalFingerprint))
571
+ yield* store.upsertSnapshot({
572
+ name: action.name,
573
+ fingerprint: action.fingerprint,
574
+ physicalFp: action.physicalFingerprint,
575
+ canonicalAst: action.canonicalAst ?? "",
576
+ renderedSql: body,
577
+ kind: model.kind._tag,
578
+ fingerprintVersion: FINGERPRINT_VERSION,
579
+ })
580
+ break
581
+ }
582
+ case "incrementalByTimeRange": {
583
+ const zero = sqlTimestamp(0)
584
+ const emptyBody = render(model.fragment, {
585
+ resolveRef,
586
+ interval: { start: zero, end: zero },
587
+ })
588
+ yield* checkContract(engine, model, emptyBody)
589
+ // forward-only: done-интервалы старой версии наследуются до бэкфилла —
590
+ // сделанное не переигрывается, пересчитывается только недостающее
591
+ if (action.reusedFrom !== undefined) {
592
+ const inherited = (yield* store.listIntervals(action.reusedFrom))
593
+ .filter((record) => record.status === "done")
594
+ .map((record) => ({ startTs: record.startTs, endTs: record.endTs }))
595
+ yield* store.markIntervals(action.fingerprint, inherited, "done")
596
+ }
597
+ if (model.target === "parquet") {
598
+ const prefix = parquetPrefix(lakePath!, model.name, action.physicalFingerprint)
599
+ yield* store.upsertSnapshot({
600
+ name: action.name,
601
+ fingerprint: action.fingerprint,
602
+ physicalFp: action.physicalFingerprint,
603
+ canonicalAst: action.canonicalAst ?? "",
604
+ renderedSql: render(model.fragment, { resolveRef: (ref) => ref }),
605
+ kind: model.kind._tag,
606
+ fingerprintVersion: FINGERPRINT_VERSION,
607
+ })
608
+ yield* backfillIntoParquet(
609
+ engine,
610
+ store,
611
+ model,
612
+ action,
613
+ prefix,
614
+ resolveRef,
615
+ options?.retry,
616
+ )
617
+ } else {
618
+ // пустой скелет с формой запроса; при resume уже существует
619
+ const target = tableRef(model, action.physicalFingerprint)
620
+ yield* engine.execute(
621
+ `CREATE TABLE IF NOT EXISTS ${target} AS SELECT * FROM (${emptyBody}) q LIMIT 0`,
622
+ )
623
+ // forward-only на живой таблице: колонки, появившиеся в запросе,
624
+ // добавляются к унаследованной физике (история получает NULL);
625
+ // исчезнувшие из запроса — сигнал, что реюз невозможен
626
+ if (action.change === "forward-only") {
627
+ yield* evolveTableForForwardOnly(engine, model, target, emptyBody)
628
+ }
629
+ yield* store.upsertSnapshot({
630
+ name: action.name,
631
+ fingerprint: action.fingerprint,
632
+ physicalFp: action.physicalFingerprint,
633
+ canonicalAst: action.canonicalAst ?? "",
634
+ renderedSql: render(model.fragment, { resolveRef: (ref) => ref }),
635
+ kind: model.kind._tag,
636
+ fingerprintVersion: FINGERPRINT_VERSION,
637
+ })
638
+ yield* backfillIntoTable(
639
+ engine,
640
+ store,
641
+ model,
642
+ action,
643
+ target,
644
+ resolveRef,
645
+ options?.concurrency ?? 4,
646
+ options?.retry,
647
+ )
648
+ }
649
+ break
650
+ }
651
+ }
652
+ })
653
+
654
+ const runOne = (action: PlanAction): Effect.Effect<void, ApplyError> =>
655
+ Effect.gen(function* () {
656
+ const model = graph.models.get(action.name)!
657
+ // ждём только родителей, которые строит этот же план; остальные готовы
658
+ yield* Effect.forEach(
659
+ [...model.deps].flatMap((dep) => {
660
+ const gate = gates.get(dep)
661
+ return gate === undefined ? [] : [gate]
662
+ }),
663
+ (gate) => Deferred.await(gate),
664
+ { discard: true },
665
+ )
666
+ yield* buildOne(action)
667
+ yield* Metric.update(snapshotsBuilt, 1)
668
+ yield* Deferred.succeed(gates.get(action.name)!, undefined)
669
+ })
670
+
671
+ // working в топологическом порядке, а forEach стартует элементы по
672
+ // порядку — у самого раннего незавершённого элемента родители всегда
673
+ // уже готовы, поэтому ожидание гейтов не выедает слоты до дедлока
674
+ yield* Effect.forEach(working, runOne, {
675
+ concurrency:
676
+ engine.dialect === "duckdb" ? 1 : Math.max(1, options?.modelConcurrency ?? 4),
677
+ discard: true,
678
+ })
679
+ const built = working.map((action) => action.name)
680
+
681
+ // 2. Промоушен: view-слой окружения
682
+ for (const action of plan.actions) {
683
+ if (action.change === "unchanged") continue
684
+ if (action.change === "removed") {
685
+ // имя модели из state store; схему восстанавливаем из полного имени
686
+ const [schema, table] = action.name.split(".") as [string, string]
687
+ yield* engine.execute(
688
+ `DROP VIEW IF EXISTS "${envSchema(plan.env, schema)}"."${table}"`,
689
+ )
690
+ continue
691
+ }
692
+ const model = graph.models.get(action.name)!
693
+ // view-слоя у external и embedded нет — они не материализуются
694
+ if (model.kind._tag === "external" || model.kind._tag === "embedded") continue
695
+ yield* engine.execute(
696
+ `CREATE SCHEMA IF NOT EXISTS "${envSchema(plan.env, model.name.schema)}"`,
697
+ )
698
+ yield* engine.execute(
699
+ `CREATE OR REPLACE VIEW ${viewRef(plan.env, model.name)} AS SELECT * FROM ${physicalFor(model, action.physicalFingerprint)}`,
700
+ )
701
+ }
702
+
703
+ // 3. Экспорт наружу (SPEC §9.3): после аудитов и промоушена — наружу
704
+ // не уезжает непроверенное; готовая витрина пишется в ATTACH-базу
705
+ for (const action of plan.actions) {
706
+ if (action.change === "removed") continue
707
+ const model = graph.models.get(action.name)!
708
+ if (model.export === undefined) continue
709
+ // экспорт освежается, когда есть что везти: сборка/бэкфилл/refresh
710
+ if (!action.build && action.backfill.length === 0 && !action.refresh) continue
711
+ const attach = options?.attach?.[model.export.attach]
712
+ if (attach === undefined) {
713
+ return yield* new AttachNotConfiguredError({
714
+ model: model.name.full,
715
+ attach: model.export.attach,
716
+ })
717
+ }
718
+ yield* engine.execute(
719
+ `ATTACH IF NOT EXISTS '${attach.url.replaceAll(`'`, `''`)}' AS ${quoteIdent(model.export.attach)}${attach.options !== undefined ? ` (${attach.options})` : ""}`,
720
+ )
721
+ const [exportSchema, exportTable] = model.export.table.includes(".")
722
+ ? (model.export.table.split(".") as [string, string])
723
+ : ["main", model.export.table]
724
+ yield* engine.execute(
725
+ `CREATE SCHEMA IF NOT EXISTS ${quoteIdent(model.export.attach)}.${quoteIdent(exportSchema)}`,
726
+ )
727
+ yield* engine.execute(
728
+ `CREATE OR REPLACE TABLE ${quoteIdent(model.export.attach)}.${quoteIdent(exportSchema)}.${quoteIdent(exportTable)} AS SELECT * FROM ${physicalFor(model, action.physicalFingerprint)}`,
729
+ )
730
+ }
731
+
732
+ // 4. Состояние окружения + журнал; requireSnapshot — защита от гонки
733
+ // с janitor (F6): у материализуемых моделей снапшот обязан быть жив
734
+ // в момент промоушена, иначе view указал бы на снесённую физику
735
+ yield* store.promote(
736
+ plan.env,
737
+ plan.actions
738
+ .filter((a) => a.change !== "removed")
739
+ .map((a) => ({
740
+ name: a.name,
741
+ fingerprint: a.fingerprint,
742
+ requireSnapshot: graph.models.get(a.name)?.kind._tag !== "external",
743
+ })),
744
+ )
745
+ yield* store.recordPlan(
746
+ plan.env,
747
+ JSON.stringify({
748
+ actions: plan.actions.map((a) => ({
749
+ name: a.name,
750
+ change: a.change,
751
+ fingerprint: a.fingerprint.slice(0, 8),
752
+ build: a.build,
753
+ backfill: a.backfill.map((r) => `[${toIso(r.start)}, ${toIso(r.end)})`),
754
+ })),
755
+ }),
756
+ options?.appliedBy ?? osUser(),
757
+ )
758
+
759
+ return { plan, built }
760
+ }).pipe(Effect.withSpan("efmesh.apply", { attributes: { env: plan.env } }))