@avytheone/efmesh 0.1.0-beta.2 → 0.2.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.
@@ -8,8 +8,8 @@ import type { Interval } from "../core/interval.ts"
8
8
  import { intervalsWithin, splitIntoBatches, sqlTimestamp, toIso } from "../core/interval.ts"
9
9
  import { columnNames, type AnyModel } from "../core/model.ts"
10
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"
11
+ import { EngineAdapter, EngineError } from "../engine/adapter.ts"
12
+ import type { Engine, SqlParseError } from "../engine/adapter.ts"
13
13
  import { StateStore } from "../state/store.ts"
14
14
  import type { StateError, StateStoreShape } from "../state/store.ts"
15
15
  import { Metric } from "effect"
@@ -32,6 +32,7 @@ import {
32
32
  import type {
33
33
  FingerprintVersionError,
34
34
  ForwardOnlyError,
35
+ ReclassifyError,
35
36
  InvalidEnvironmentError,
36
37
  Plan,
37
38
  PlanAction,
@@ -39,19 +40,27 @@ import type {
39
40
 
40
41
  export interface AppliedPlan {
41
42
  readonly plan: Plan
42
- /** Имена моделей, для которых собиралась физика или гнался бэкфилл. */
43
+ /** Names of models for which physics was built or backfill was run. */
43
44
  readonly built: ReadonlyArray<string>
44
45
  }
45
46
 
46
- /** В проекте есть parquet-модели, но путь озера не задан в конфиге. */
47
+ /** The project has parquet models, but the lake path is not set in the config. */
47
48
  export class LakeNotConfiguredError extends Data.TaggedError("LakeNotConfiguredError")<{
48
49
  readonly model: string
49
- }> {}
50
+ }> {
51
+ override get message(): string {
52
+ return `model «${this.model}» targets parquet, but no lake path is configured`
53
+ }
54
+ }
50
55
 
51
- /** В проекте есть ducklake-модели, но каталог не задан в конфиге. */
56
+ /** The project has ducklake models, but the catalog is not set in the config. */
52
57
  export class DucklakeNotConfiguredError extends Data.TaggedError("DucklakeNotConfiguredError")<{
53
58
  readonly model: string
54
- }> {}
59
+ }> {
60
+ override get message(): string {
61
+ return `model «${this.model}» targets ducklake, but no catalog is configured`
62
+ }
63
+ }
55
64
 
56
65
  export type ApplyError =
57
66
  | GraphError
@@ -66,45 +75,46 @@ export type ApplyError =
66
75
  | AuditFailure
67
76
  | InvalidEnvironmentError
68
77
  | ForwardOnlyError
78
+ | ReclassifyError
69
79
  | FingerprintVersionError
70
80
  | EngineFeatureError
71
81
 
72
82
  export interface ApplyOptions {
73
- /** «Сейчас» для scdType2-версионирования; по умолчанию Clock. Инъекция для тестов. */
83
+ /** "Now" for scdType2 versioning; defaults to Clock. Injection point for tests. */
74
84
  readonly now?: number
75
- /** Корень parquet-озералокальная директория или s3://… (httpfs). */
85
+ /** Root of the parquet lake a local directory or s3://… (httpfs). */
76
86
  readonly lakePath?: string
77
- /** ATTACH-базы по алиасам (SPEC §9.3) — для export-моделей. */
87
+ /** ATTACH databases by alias (SPEC §9.3) — for export models. */
78
88
  readonly attach?: Readonly<Record<string, { readonly url: string; readonly options?: string }>>
79
- /** DuckLake-каталог для target: "ducklake" (SPEC §14.5). DuckDB-only. */
89
+ /** DuckLake catalog for target: "ducklake" (SPEC §14.5). DuckDB-only. */
80
90
  readonly ducklake?: { readonly catalog: string; readonly dataPath?: string }
81
91
  /**
82
- * Сколько батчей бэкфилла одной модели считать одновременно (SPEC §5.3).
83
- * Осмыслен только на движке с пулом соединений (Postgres); DuckDB держит
84
- * одно соединениетам бэкфилл последовательный независимо от значения.
92
+ * How many backfill batches of one model to compute concurrently (SPEC §5.3).
93
+ * Meaningful only on an engine with a connection pool (Postgres); DuckDB holds
94
+ * a single connection there backfill is sequential regardless of the value.
85
95
  */
86
96
  readonly concurrency?: number
87
97
  /**
88
- * Межмодельная DAG-конкурентность (SPEC §5.3): сколько моделей строить
89
- * одновременно. Модель стартует, как только готовы её родители из этого
90
- * плана независимые ветки DAG идут параллельно. Осмыслен на движке
91
- * с пулом (Postgres); DuckDB держит одно соединениетам модели строятся
92
- * последовательно, иначе чужие стейтменты вклинивались бы в BEGIN/COMMIT.
98
+ * Inter-model DAG concurrency (SPEC §5.3): how many models to build at once.
99
+ * A model starts as soon as its parents from this plan are ready —
100
+ * independent DAG branches run in parallel. Meaningful on an engine with a
101
+ * pool (Postgres); DuckDB holds a single connection there models build
102
+ * sequentially, otherwise foreign statements would wedge into a BEGIN/COMMIT.
93
103
  */
94
104
  readonly modelConcurrency?: number
95
105
  /**
96
- * Ретраи упавшего батча бэкфилла (SPEC §5.3): Schedule.exponential от
97
- * baseDelayMs (по умолчанию 500 мс), не больше attempts повторов. Батч
98
- * транзакционен (DELETE+INSERT в одной транзакции, COPY перезаписывает
99
- * партицию целиком) — повтор безопасен. Аудиты не ретраятся: провал
100
- * аудита детерминирован, это не транзиентный сбой.
106
+ * Retries of a failed backfill batch (SPEC §5.3): Schedule.exponential from
107
+ * baseDelayMs (500 ms by default), no more than attempts retries. A batch is
108
+ * transactional (DELETE+INSERT in one transaction, COPY overwrites the whole
109
+ * partition) — a retry is safe. Audits are not retried: an audit failure is
110
+ * deterministic, it is not a transient fault.
101
111
  */
102
112
  readonly retry?: { readonly attempts: number; readonly baseDelayMs?: number }
103
- /** Кто применяет планв журнал (SPEC §6); по умолчанию ОС-пользователь. */
113
+ /** Who applies the plan into the journal (SPEC §6); defaults to the OS user. */
104
114
  readonly appliedBy?: string
105
115
  }
106
116
 
107
- /** ОС-пользователь для журнала планов; в стерильных окружениях (CI) — ''. */
117
+ /** OS user for the plan journal; in sterile environments (CI) — ''. */
108
118
  const osUser = (): string => {
109
119
  try {
110
120
  return userInfo().username
@@ -113,26 +123,50 @@ const osUser = (): string => {
113
123
  }
114
124
  }
115
125
 
116
- /** Модель просит экспорт в ATTACH-алиас, которого нет в конфиге. */
126
+ /** The model asks to export to an ATTACH alias that is not in the config. */
117
127
  export class AttachNotConfiguredError extends Data.TaggedError("AttachNotConfiguredError")<{
118
128
  readonly model: string
119
129
  readonly attach: string
120
- }> {}
130
+ }> {
131
+ override get message(): string {
132
+ return `model «${this.model}» exports to ATTACH alias «${this.attach}», which is not in the config`
133
+ }
134
+ }
121
135
 
122
- /** Возможность DuckDB-федерации, недоступная на текущем движке (SPEC §9.3). */
136
+ /** A DuckDB federation feature unavailable on the current engine (SPEC §9.3). */
123
137
  export class EngineFeatureError extends Data.TaggedError("EngineFeatureError")<{
124
138
  readonly model: string
125
139
  readonly feature: string
126
140
  readonly dialect: string
127
- }> {}
141
+ }> {
142
+ override get message(): string {
143
+ return `model «${this.model}» uses ${this.feature}, unavailable on the ${this.dialect} engine (DuckDB only)`
144
+ }
145
+ }
128
146
 
129
- /** Несколько стейтментов одной транзакцией движка; откат при любой ошибке. */
147
+ /**
148
+ * Names the culprit on an engine failure raised while building a model: the
149
+ * adapter cannot know which model a statement belongs to, so the executor
150
+ * attaches it here (#13). Only fills in a model that is not already set, and
151
+ * only for EngineError — every other error in the channel already carries the
152
+ * model in a typed field.
153
+ */
154
+ const attachModel =
155
+ (model: string) =>
156
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
157
+ Effect.mapError(effect, (error) =>
158
+ error instanceof EngineError && error.model === undefined
159
+ ? (new EngineError({ sql: error.sql, cause: error.cause, model }) as E & EngineError)
160
+ : error,
161
+ )
162
+
163
+ /** Several statements in one engine transaction; rollback on any error. */
130
164
  const transactional = (
131
165
  engine: Engine,
132
166
  statements: ReadonlyArray<string>,
133
167
  ): Effect.Effect<void, EngineError> => engine.transaction(statements)
134
168
 
135
- /** Ретраи транзиентного сбоя записи батча; без retry в опцияхкак было. */
169
+ /** Retries of a transient batch-write failure; without retry in the options as before. */
136
170
  const withBatchRetry =
137
171
  (retry: ApplyOptions["retry"]) =>
138
172
  <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
@@ -143,16 +177,25 @@ const withBatchRetry =
143
177
  schedule: Schedule.exponential(retry.baseDelayMs ?? 500),
144
178
  })
145
179
 
146
- /** Для s3://-путей mkdir не нужен (и невозможен) — httpfs пишет напрямую. */
180
+ /** For s3:// paths mkdir is not needed (and impossible) — httpfs writes directly. */
147
181
  const ensureDir = (path: string): Effect.Effect<void> =>
148
182
  path.startsWith("s3://")
149
183
  ? Effect.void
150
184
  : Effect.sync(() => mkdirSync(path, { recursive: true }))
151
185
 
152
186
  /**
153
- * Прогон аудитов модели (SPEC §8): `self` физика снапшота или подзапрос
154
- * только что загруженного интервала. Запрос аудита возвращает нарушения:
155
- * blocking AuditFailure, warn лог и дальше.
187
+ * The rendered statement about to run — DEBUG only (#14): SQL is a firehose and
188
+ * would drown the info lifecycle a human watches; an operator or agent asks for
189
+ * it with `--log-level debug`. The model/env are inherited from the enclosing
190
+ * per-model log scope, so only `sql` is annotated here.
191
+ */
192
+ const logSql = (sql: string): Effect.Effect<void> =>
193
+ Effect.logDebug("rendered SQL").pipe(Effect.annotateLogs("sql", sql))
194
+
195
+ /**
196
+ * Runs a model's audits (SPEC §8): `self` is the snapshot's physics or a
197
+ * subquery of the just-loaded interval. The audit query returns violations:
198
+ * blocking → AuditFailure, warn → log and continue.
156
199
  */
157
200
  const runAudits = (
158
201
  engine: Engine,
@@ -174,16 +217,16 @@ const runAudits = (
174
217
  })
175
218
  }
176
219
  yield* Effect.logWarning(
177
- `аудит ${auditDef.name} модели ${model.name.full}: ${violations.length} нарушений (warn)`,
220
+ `audit ${auditDef.name} of model ${model.name.full}: ${violations.length} violations (warn)`,
178
221
  )
179
222
  }
180
223
  })
181
224
 
182
225
  /**
183
- * Эволюция унаследованной таблицы при forward-only (SPEC §5.2): колонки,
184
- * появившиеся в новом запросе, добавляются через ALTER (история получает
185
- * NULL она не переигрывается), удаление колонок реюзом не выражается
186
- * это честный breaking с пересборкой.
226
+ * Evolution of an inherited table under forward-only (SPEC §5.2): columns
227
+ * that appeared in the new query are added via ALTER (history gets NULL — it
228
+ * is not replayed); dropping columns cannot be expressed by reuse that is
229
+ * an honest breaking change with a rebuild.
187
230
  */
188
231
  const evolveTableForForwardOnly = (
189
232
  engine: Engine,
@@ -202,7 +245,7 @@ const evolveTableForForwardOnly = (
202
245
  model: model.name.full,
203
246
  problems: vanished.map(
204
247
  (column) =>
205
- `forward-only: колонка «${column.name}» есть в унаследованной физике, но исчезла из запросаудаление колонок требует обычного (breaking) применения`,
248
+ `forward-only: column «${column.name}» exists in the inherited physics but vanished from the query dropping columns requires a regular (breaking) apply`,
206
249
  ),
207
250
  })
208
251
  }
@@ -215,11 +258,11 @@ const evolveTableForForwardOnly = (
215
258
  })
216
259
 
217
260
  /**
218
- * Бэкфилл incrementalByTimeRange в таблицу (SPEC §5.3): каждый диапазон
219
- * плана режется на батчи ≤ batchSize; батч транзакция DELETE-диапазона +
220
- * INSERT, после успеха его интервалы помечаются done. Упавший батч
221
- * помечается failed и прерывает apply; уже отмеченное не пересчитывается
222
- * при повторебэкфилл продолжается с места остановки.
261
+ * Backfill of incrementalByTimeRange into a table (SPEC §5.3): each range of
262
+ * the plan is cut into batches ≤ batchSize; a batch is a transaction of a
263
+ * range DELETE + INSERT, and after success its intervals are marked done. A
264
+ * failed batch is marked failed and aborts apply; what is already marked is
265
+ * not recomputed on retry backfill resumes from where it stopped.
223
266
  */
224
267
  const backfillIntoTable = (
225
268
  engine: Engine,
@@ -234,11 +277,18 @@ const backfillIntoTable = (
234
277
  Effect.gen(function* () {
235
278
  if (model.kind._tag !== "incrementalByTimeRange") return
236
279
  const kind = model.kind
237
- // вставка по именам: после forward-only-эволюции порядок колонок физики
238
- // может отличаться от запроса; контракт схемы гарантирует совпадение имён
280
+ // insert by name: after forward-only evolution the physics' column order
281
+ // may differ from the query; the schema contract guarantees the names match
239
282
  const columns = columnNames(model).map(quoteIdent).join(", ")
240
283
 
241
- const runBatch = (batch: Interval) =>
284
+ // connection pool (Postgres) → batches in parallel: intervals do not
285
+ // overlap, each is its own transaction; DuckDB (single connection) — sequentially
286
+ const batches = action.backfill.flatMap((range) =>
287
+ splitIntoBatches(range, kind.interval, kind.batchSize),
288
+ )
289
+ const total = batches.length
290
+
291
+ const runBatch = (batch: Interval, index: number) =>
242
292
  Effect.gen(function* () {
243
293
  const marks = intervalsWithin(batch, kind.interval).map((interval: Interval) => ({
244
294
  startTs: toIso(interval.start),
@@ -247,13 +297,19 @@ const backfillIntoTable = (
247
297
  const start = sqlTimestamp(batch.start)
248
298
  const end = sqlTimestamp(batch.end)
249
299
  const body = render(model.fragment, { resolveRef, interval: { start, end } })
300
+ // info lifecycle: a human watching a long backfill sees n-of-m progress;
301
+ // interval bounds are structured (annotation), not baked into the message
302
+ yield* Effect.logInfo(`backfill batch ${index + 1} of ${total}`).pipe(
303
+ Effect.annotateLogs("interval", `[${toIso(batch.start)}, ${toIso(batch.end)})`),
304
+ )
305
+ yield* logSql(body)
250
306
  const markFailed = () =>
251
307
  store.markIntervals(action.fingerprint, marks, "failed").pipe(Effect.ignore)
252
308
  yield* transactional(engine, [
253
309
  `DELETE FROM ${target} WHERE ${quoteIdent(kind.timeColumn)} >= ${start} AND ${quoteIdent(kind.timeColumn)} < ${end}`,
254
310
  `INSERT INTO ${target} (${columns}) SELECT ${columns} FROM (${body}) q`,
255
311
  ]).pipe(withBatchRetry(retry), Effect.tapError(markFailed))
256
- // аудит свежезагруженного интерваладо отметки done (SPEC §8)
312
+ // audit of the freshly loaded interval before marking done (SPEC §8)
257
313
  yield* runAudits(
258
314
  engine,
259
315
  model,
@@ -263,11 +319,6 @@ const backfillIntoTable = (
263
319
  yield* Metric.update(intervalsDone, marks.length)
264
320
  })
265
321
 
266
- // пул соединений (Postgres) → батчи параллельно: интервалы не пересекаются,
267
- // каждый — своя транзакция; DuckDB (одно соединение) — последовательно
268
- const batches = action.backfill.flatMap((range) =>
269
- splitIntoBatches(range, kind.interval, kind.batchSize),
270
- )
271
322
  yield* Effect.forEach(batches, runBatch, {
272
323
  concurrency: engine.dialect === "duckdb" ? 1 : Math.max(1, concurrency),
273
324
  discard: true,
@@ -275,9 +326,9 @@ const backfillIntoTable = (
275
326
  })
276
327
 
277
328
  /**
278
- * Бэкфилл в parquet-озеро (SPEC §3.3): интервал = партиция, пересчёт —
279
- * перезапись файла партиции. Транзакция не нужна: недописанная партиция
280
- * не помечена done и будет перезаписана при повторе.
329
+ * Backfill into the parquet lake (SPEC §3.3): interval = partition, a
330
+ * recompute is a rewrite of the partition file. No transaction is needed: an
331
+ * unfinished partition is not marked done and will be overwritten on retry.
281
332
  */
282
333
  const backfillIntoParquet = (
283
334
  engine: Engine,
@@ -291,21 +342,32 @@ const backfillIntoParquet = (
291
342
  Effect.gen(function* () {
292
343
  if (model.kind._tag !== "incrementalByTimeRange") return
293
344
  const kind = model.kind
294
- for (const range of action.backfill) {
295
- for (const interval of intervalsWithin(range, kind.interval)) {
345
+ // interval = partition; flat list so progress reads "n of m" across all ranges
346
+ const intervals = action.backfill.flatMap((range) =>
347
+ intervalsWithin(range, kind.interval),
348
+ )
349
+ const total = intervals.length
350
+ yield* Effect.forEach(
351
+ intervals,
352
+ (interval, index) =>
353
+ Effect.gen(function* () {
296
354
  const partition = `${prefix}/interval=${intervalKey(kind.interval, interval.start)}`
297
355
  yield* ensureDir(partition)
298
356
  const body = render(model.fragment, {
299
357
  resolveRef,
300
358
  interval: { start: sqlTimestamp(interval.start), end: sqlTimestamp(interval.end) },
301
359
  })
360
+ yield* Effect.logInfo(`backfill partition ${index + 1} of ${total}`).pipe(
361
+ Effect.annotateLogs("interval", `[${toIso(interval.start)}, ${toIso(interval.end)})`),
362
+ )
363
+ yield* logSql(body)
302
364
  const mark = [{ startTs: toIso(interval.start), endTs: toIso(interval.end) }]
303
365
  const markFailed = () =>
304
366
  store.markIntervals(action.fingerprint, mark, "failed").pipe(Effect.ignore)
305
- // локально COPY пишет во временный файл, rename атомарен (POSIX):
306
- // kill посреди записи не оставит битую партицию, а lookback-пересчёт
307
- // не подсунет читателю view недописанный файл; s3 — прямая запись
308
- // (rename нет; недописанный ключ не помечен done и перезапишется)
367
+ // locally COPY writes to a temp file, rename is atomic (POSIX): a kill
368
+ // mid-write leaves no broken partition, and a lookback recompute does
369
+ // not hand the view reader an unfinished file; s3 — direct write
370
+ // (no rename; the unfinished key is not marked done and gets overwritten)
309
371
  const target = `${partition}/data.parquet`
310
372
  const writePath = partition.startsWith("s3://") ? target : `${target}.tmp`
311
373
  yield* engine
@@ -315,22 +377,23 @@ const backfillIntoParquet = (
315
377
  yield* Effect.sync(() => renameSync(writePath, target))
316
378
  }
317
379
  const file = target.replaceAll(`'`, `''`)
318
- // аудит записанной партициидо отметки done; провал = не done → перезапись
380
+ // audit of the written partition before marking done; failure = not done → rewrite
319
381
  yield* runAudits(engine, model, `(SELECT * FROM read_parquet('${file}'))`).pipe(
320
382
  Effect.tapError(markFailed),
321
383
  )
322
384
  yield* store.markIntervals(action.fingerprint, mark, "done")
323
385
  yield* Metric.update(intervalsDone, 1)
324
- }
325
- }
386
+ }),
387
+ { discard: true },
388
+ )
326
389
  })
327
390
 
328
391
  /**
329
- * Применяет план (SPEC §5): в топологическом порядке собирает недостающую
330
- * физику и догоняет интервалы (ссылки в SQL резолвятся в физические таблицы
331
- * ЭТОГО плана, не во view окружениясередина apply не видна снаружи),
332
- * затем промоушенпересоздание view + транзакционная запись набора
333
- * в state store.
392
+ * Applies the plan (SPEC §5): in topological order it builds the missing
393
+ * physics and catches up intervals (refs in SQL resolve to the physical
394
+ * tables of THIS plan, not to the environment's views the middle of apply
395
+ * is not visible from outside), then promotion recreating the views + a
396
+ * transactional write of the set into the state store.
334
397
  */
335
398
  export const applyPlan = (
336
399
  plan: Plan,
@@ -343,13 +406,13 @@ export const applyPlan = (
343
406
  const lakePath = options?.lakePath
344
407
  const nowMs = options?.now ?? (yield* Clock.currentTimeMillis)
345
408
 
346
- // физика моделито, что увидят её потребители и view окружения;
347
- // ключ physicalFingerprint: при forward-only это физика старой версии
409
+ // a model's physics what its consumers and the environment views will
410
+ // see; the key is physicalFingerprint: under forward-only it is the old version's physics
348
411
  const physicalFpOf = new Map(plan.actions.map((a) => [a.name, a.physicalFingerprint]))
349
412
  const physicalFor = (model: AnyModel, fingerprint: string): string => {
350
413
  if (model.kind._tag === "external") return externalSourceRef(model.kind.source)
351
- // embedded не материализуетсяподставляется потребителю подзапросом,
352
- // его собственные ссылки резолвятся рекурсивно (DAG ацикличен)
414
+ // embedded is not materialized it is inlined into the consumer as a
415
+ // subquery, its own refs resolve recursively (the DAG is acyclic)
353
416
  if (model.kind._tag === "embedded") return `(${render(model.fragment, { resolveRef })})`
354
417
  if (model.target === "parquet") {
355
418
  if (lakePath === undefined) throw new LakeNotConfiguredError({ model: model.name.full })
@@ -357,7 +420,7 @@ export const applyPlan = (
357
420
  }
358
421
  return tableRef(model, fingerprint)
359
422
  }
360
- // нативная физика или таблица DuckLake-каталогапо цели модели
423
+ // native physics or a DuckLake-catalog table by the model's target
361
424
  const tableRef = (model: AnyModel, fingerprint: string): string =>
362
425
  model.target === "ducklake"
363
426
  ? ducklakeRef(model.name, fingerprint)
@@ -366,12 +429,14 @@ export const applyPlan = (
366
429
  const model = graph.models.get(ref)
367
430
  const fingerprint = physicalFpOf.get(ref)
368
431
  if (model === undefined || fingerprint === undefined) {
369
- throw new Error(`ссылка на модель вне плана: ${ref}`)
432
+ // invariant: deps and the plan are built from the same graph, so every
433
+ // ref resolves — a miss is a defect in efmesh, not user-recoverable
434
+ throw new Error(`invariant violated: reference to a model «${ref}» outside the plan`)
370
435
  }
371
436
  return physicalFor(model, fingerprint)
372
437
  }
373
438
 
374
- // parquet-модели без озерападение до любых действий
439
+ // parquet models without a lake fail before any actions
375
440
  for (const action of plan.actions) {
376
441
  const model = graph.models.get(action.name)
377
442
  if (model === undefined) continue
@@ -381,8 +446,8 @@ export const applyPlan = (
381
446
  if (model.target === "ducklake" && options?.ducklake === undefined) {
382
447
  return yield* new DucklakeNotConfiguredError({ model: model.name.full })
383
448
  }
384
- // DuckDB-федерация (SPEC §9.3) на других движках не выражается
385
- // честная ошибка до любых действий
449
+ // DuckDB federation (SPEC §9.3) cannot be expressed on other engines
450
+ // an honest error before any actions
386
451
  if (engine.dialect !== "duckdb") {
387
452
  const feature =
388
453
  model.target === "parquet"
@@ -392,9 +457,9 @@ export const applyPlan = (
392
457
  : model.kind._tag === "seed"
393
458
  ? "seed (read_csv/read_json)"
394
459
  : model.kind._tag === "external" && model.kind.source._tag === "files"
395
- ? "external по файлам/URL (read_*)"
460
+ ? "external over files/URL (read_*)"
396
461
  : model.export !== undefined
397
- ? "export в ATTACH-базу"
462
+ ? "export to an ATTACH database"
398
463
  : undefined
399
464
  if (feature !== undefined) {
400
465
  return yield* new EngineFeatureError({
@@ -406,13 +471,13 @@ export const applyPlan = (
406
471
  }
407
472
  }
408
473
 
409
- // 1. Физика + бэкфилл — DAG-конкурентно (SPEC §5.3): модель стартует,
410
- // как только готовы её родители из этого плана (Deferred-гейты, без
411
- // волновых барьеров); независимые ветки идут параллельно на движке
412
- // с пулом; упавший родитель не открывает гейтпотомки не строятся
474
+ // 1. Physics + backfill — DAG-concurrent (SPEC §5.3): a model starts as
475
+ // soon as its parents from this plan are ready (Deferred gates, no wave
476
+ // barriers); independent branches run in parallel on an engine with a
477
+ // pool; a failed parent does not open its gate descendants are not built
413
478
  yield* engine.execute(`CREATE SCHEMA IF NOT EXISTS "${physicalSchema}"`)
414
- // ducklake-каталог нужен и для сборки, и для чистого view-swap
415
- // view окружений ссылаются на таблицы каталога по алиасу
479
+ // the ducklake catalog is needed both for building and for a pure
480
+ // view-swap environment views reference catalog tables by alias
416
481
  if (options?.ducklake !== undefined && engine.dialect === "duckdb") {
417
482
  yield* engine.execute(ducklakeAttachSql(options.ducklake))
418
483
  }
@@ -431,9 +496,10 @@ export const applyPlan = (
431
496
  case "external":
432
497
  return
433
498
  case "embedded": {
434
- // физики нетно контракт и аудиты версии проверяются здесь,
435
- // чтобы потребители не унесли в себя сломанный подзапрос
499
+ // there is no physics but the contract and the version's audits are
500
+ // checked here so consumers do not carry a broken subquery into themselves
436
501
  const body = render(model.fragment, { resolveRef })
502
+ yield* logSql(body)
437
503
  yield* checkContract(engine, model, body)
438
504
  yield* runAudits(engine, model, `(${body})`)
439
505
  yield* store.upsertSnapshot({
@@ -450,6 +516,7 @@ export const applyPlan = (
450
516
  case "seed": {
451
517
  const reader = model.kind.format === "csv" ? "read_csv" : "read_json"
452
518
  const body = `SELECT * FROM ${reader}('${model.kind.file.replaceAll(`'`, `''`)}')`
519
+ yield* logSql(body)
453
520
  yield* checkContract(engine, model, body)
454
521
  const target = tableRef(model, action.physicalFingerprint)
455
522
  yield* engine.execute(`CREATE OR REPLACE TABLE ${target} AS ${body}`)
@@ -467,12 +534,13 @@ export const applyPlan = (
467
534
  }
468
535
  case "incrementalByUniqueKey": {
469
536
  const body = render(model.fragment, { resolveRef })
537
+ yield* logSql(body)
470
538
  yield* checkContract(engine, model, body)
471
539
  const target = tableRef(model, action.physicalFingerprint)
472
540
  yield* engine.execute(
473
541
  `CREATE TABLE IF NOT EXISTS ${target} AS SELECT * FROM (${body}) q LIMIT 0`,
474
542
  )
475
- // upsert: строки с ключами из свежего запроса заменяются, остальные живут
543
+ // upsert: rows with keys from the fresh query are replaced, the rest live on
476
544
  const keys = model.kind.key.map(quoteIdent).join(", ")
477
545
  yield* transactional(engine, [
478
546
  `DELETE FROM ${target} WHERE (${keys}) IN (SELECT ${keys} FROM (${body}) q)`,
@@ -493,6 +561,7 @@ export const applyPlan = (
493
561
  case "scdType2": {
494
562
  const scd = model.kind
495
563
  const body = render(model.fragment, { resolveRef })
564
+ yield* logSql(body)
496
565
  const managed = new Set([scd.validFrom, scd.validTo])
497
566
  yield* checkContract(engine, model, body, managed)
498
567
  const target = tableRef(model, action.physicalFingerprint)
@@ -514,10 +583,10 @@ export const applyPlan = (
514
583
  const sameAsOpen = cols
515
584
  .map((column) => `t.${column} IS NOT DISTINCT FROM q.${column}`)
516
585
  .join(" AND ")
517
- // SCD2-сверка (SPEC §3.1): открытая строка без идентичной строки в
518
- // запросе закрывается (изменилась или исчезла); строка запроса без
519
- // идентичной открытой строки вставляется новой открытой версией.
520
- // Идентичные пары не трогаютсяих valid_from не дрожит.
586
+ // SCD2 reconciliation (SPEC §3.1): an open row with no identical row
587
+ // in the query is closed (it changed or vanished); a query row with
588
+ // no identical open row is inserted as a new open version. Identical
589
+ // pairs are left alonetheir valid_from does not jitter.
521
590
  yield* transactional(engine, [
522
591
  `UPDATE ${target} SET ${to} = ${ts}
523
592
  WHERE ${to} IS NULL
@@ -544,8 +613,25 @@ export const applyPlan = (
544
613
  case "view":
545
614
  case "full": {
546
615
  const body = render(model.fragment, { resolveRef })
547
- // контракт схемы (SPEC §3.2): дрейф типов ловится до сборки
616
+ yield* logSql(body)
617
+ // schema contract (SPEC §3.2): type drift is caught before building
548
618
  yield* checkContract(engine, model, body)
619
+ // indirect reuse (#5): the data is identical by construction (parents
620
+ // non-breaking/forward-only, own body unchanged) — the rebuild is
621
+ // skipped, audits run against the inherited physics
622
+ if (model.kind._tag === "full" && action.reusedFrom !== undefined) {
623
+ yield* runAudits(engine, model, physicalFor(model, action.physicalFingerprint))
624
+ yield* store.upsertSnapshot({
625
+ name: action.name,
626
+ fingerprint: action.fingerprint,
627
+ physicalFp: action.physicalFingerprint,
628
+ canonicalAst: action.canonicalAst ?? "",
629
+ renderedSql: body,
630
+ kind: model.kind._tag,
631
+ fingerprintVersion: FINGERPRINT_VERSION,
632
+ })
633
+ break
634
+ }
549
635
  if (model.kind._tag === "full" && model.target === "parquet") {
550
636
  const prefix = parquetPrefix(lakePath!, model.name, action.physicalFingerprint)
551
637
  yield* ensureDir(prefix)
@@ -559,14 +645,14 @@ export const applyPlan = (
559
645
  } else if (engine.dialect === "duckdb") {
560
646
  yield* engine.execute(`CREATE OR REPLACE TABLE ${target} AS ${body}`)
561
647
  } else {
562
- // у Postgres нет CREATE OR REPLACE TABLE — атомарно через транзакцию
648
+ // Postgres has no CREATE OR REPLACE TABLE — atomically via a transaction
563
649
  yield* transactional(engine, [
564
650
  `DROP TABLE IF EXISTS ${target}`,
565
651
  `CREATE TABLE ${target} AS ${body}`,
566
652
  ])
567
653
  }
568
654
  }
569
- // аудиты собранного снапшотадо промоушена (SPEC §8)
655
+ // audits of the built snapshot before promotion (SPEC §8)
570
656
  yield* runAudits(engine, model, physicalFor(model, action.physicalFingerprint))
571
657
  yield* store.upsertSnapshot({
572
658
  name: action.name,
@@ -586,8 +672,8 @@ export const applyPlan = (
586
672
  interval: { start: zero, end: zero },
587
673
  })
588
674
  yield* checkContract(engine, model, emptyBody)
589
- // forward-only: done-интервалы старой версии наследуются до бэкфилла
590
- // сделанное не переигрывается, пересчитывается только недостающее
675
+ // forward-only: the old version's done-intervals are inherited before
676
+ // backfill what is done is not replayed, only the missing is recomputed
591
677
  if (action.reusedFrom !== undefined) {
592
678
  const inherited = (yield* store.listIntervals(action.reusedFrom))
593
679
  .filter((record) => record.status === "done")
@@ -615,14 +701,14 @@ export const applyPlan = (
615
701
  options?.retry,
616
702
  )
617
703
  } else {
618
- // пустой скелет с формой запроса; при resume уже существует
704
+ // an empty skeleton with the query's shape; on resume it already exists
619
705
  const target = tableRef(model, action.physicalFingerprint)
620
706
  yield* engine.execute(
621
707
  `CREATE TABLE IF NOT EXISTS ${target} AS SELECT * FROM (${emptyBody}) q LIMIT 0`,
622
708
  )
623
- // forward-only на живой таблице: колонки, появившиеся в запросе,
624
- // добавляются к унаследованной физике (история получает NULL);
625
- // исчезнувшие из запроса сигнал, что реюз невозможен
709
+ // forward-only on a live table: columns that appeared in the query
710
+ // are added to the inherited physics (history gets NULL); ones that
711
+ // vanished from the query signal that reuse is impossible
626
712
  if (action.change === "forward-only") {
627
713
  yield* evolveTableForForwardOnly(engine, model, target, emptyBody)
628
714
  }
@@ -654,7 +740,7 @@ export const applyPlan = (
654
740
  const runOne = (action: PlanAction): Effect.Effect<void, ApplyError> =>
655
741
  Effect.gen(function* () {
656
742
  const model = graph.models.get(action.name)!
657
- // ждём только родителей, которые строит этот же план; остальные готовы
743
+ // wait only for parents that this same plan builds; the rest are ready
658
744
  yield* Effect.forEach(
659
745
  [...model.deps].flatMap((dep) => {
660
746
  const gate = gates.get(dep)
@@ -663,14 +749,24 @@ export const applyPlan = (
663
749
  (gate) => Deferred.await(gate),
664
750
  { discard: true },
665
751
  )
666
- yield* buildOne(action)
752
+ const startedAt = yield* Clock.currentTimeMillis
753
+ yield* Effect.logInfo("build start")
754
+ yield* buildOne(action).pipe(attachModel(action.name))
755
+ yield* Effect.logInfo(
756
+ `build done in ${(yield* Clock.currentTimeMillis) - startedAt} ms`,
757
+ )
667
758
  yield* Metric.update(snapshotsBuilt, 1)
668
759
  yield* Deferred.succeed(gates.get(action.name)!, undefined)
669
- })
760
+ }).pipe(
761
+ // one structured log scope per model: model/env/change flow into every
762
+ // line the build emits (SQL at debug, backfill progress at info), so a
763
+ // machine reader can group them without parsing the message text (#14)
764
+ Effect.annotateLogs({ model: action.name, env: plan.env, change: action.change }),
765
+ )
670
766
 
671
- // working в топологическом порядке, а forEach стартует элементы по
672
- // порядку у самого раннего незавершённого элемента родители всегда
673
- // уже готовы, поэтому ожидание гейтов не выедает слоты до дедлока
767
+ // working is in topological order, and forEach starts elements in order —
768
+ // the earliest unfinished element always has its parents ready, so waiting
769
+ // on gates does not eat up slots into a deadlock
674
770
  yield* Effect.forEach(working, runOne, {
675
771
  concurrency:
676
772
  engine.dialect === "duckdb" ? 1 : Math.max(1, options?.modelConcurrency ?? 4),
@@ -678,11 +774,11 @@ export const applyPlan = (
678
774
  })
679
775
  const built = working.map((action) => action.name)
680
776
 
681
- // 2. Промоушен: view-слой окружения
777
+ // 2. Promotion: the environment's view layer
682
778
  for (const action of plan.actions) {
683
779
  if (action.change === "unchanged") continue
684
780
  if (action.change === "removed") {
685
- // имя модели из state store; схему восстанавливаем из полного имени
781
+ // the model name comes from the state store; the schema is recovered from the full name
686
782
  const [schema, table] = action.name.split(".") as [string, string]
687
783
  yield* engine.execute(
688
784
  `DROP VIEW IF EXISTS "${envSchema(plan.env, schema)}"."${table}"`,
@@ -690,7 +786,7 @@ export const applyPlan = (
690
786
  continue
691
787
  }
692
788
  const model = graph.models.get(action.name)!
693
- // view-слоя у external и embedded нетони не материализуются
789
+ // external and embedded have no view layer they are not materialized
694
790
  if (model.kind._tag === "external" || model.kind._tag === "embedded") continue
695
791
  yield* engine.execute(
696
792
  `CREATE SCHEMA IF NOT EXISTS "${envSchema(plan.env, model.name.schema)}"`,
@@ -700,13 +796,13 @@ export const applyPlan = (
700
796
  )
701
797
  }
702
798
 
703
- // 3. Экспорт наружу (SPEC §9.3): после аудитов и промоушенанаружу
704
- // не уезжает непроверенное; готовая витрина пишется в ATTACH-базу
799
+ // 3. Export outward (SPEC §9.3): after audits and promotionnothing
800
+ // unverified leaves; the finished mart is written to the ATTACH database
705
801
  for (const action of plan.actions) {
706
802
  if (action.change === "removed") continue
707
803
  const model = graph.models.get(action.name)!
708
804
  if (model.export === undefined) continue
709
- // экспорт освежается, когда есть что везти: сборка/бэкфилл/refresh
805
+ // the export is refreshed when there is something to ship: build/backfill/refresh
710
806
  if (!action.build && action.backfill.length === 0 && !action.refresh) continue
711
807
  const attach = options?.attach?.[model.export.attach]
712
808
  if (attach === undefined) {
@@ -729,9 +825,9 @@ export const applyPlan = (
729
825
  )
730
826
  }
731
827
 
732
- // 4. Состояние окружения + журнал; requireSnapshot защита от гонки
733
- // с janitor (F6): у материализуемых моделей снапшот обязан быть жив
734
- // в момент промоушена, иначе view указал бы на снесённую физику
828
+ // 4. Environment state + journal; requireSnapshot guards against the race
829
+ // with the janitor (F6): for materialized models the snapshot must be alive
830
+ // at the moment of promotion, otherwise the view would point at removed physics
735
831
  yield* store.promote(
736
832
  plan.env,
737
833
  plan.actions
@@ -748,6 +844,8 @@ export const applyPlan = (
748
844
  actions: plan.actions.map((a) => ({
749
845
  name: a.name,
750
846
  change: a.change,
847
+ // operator override (#5) in the journal: it is visible who declared what
848
+ ...(a.reclassifiedFrom !== undefined ? { reclassifiedFrom: a.reclassifiedFrom } : {}),
751
849
  fingerprint: a.fingerprint.slice(0, 8),
752
850
  build: a.build,
753
851
  backfill: a.backfill.map((r) => `[${toIso(r.start)}, ${toIso(r.end)})`),
@@ -756,5 +854,9 @@ export const applyPlan = (
756
854
  options?.appliedBy ?? osUser(),
757
855
  )
758
856
 
857
+ yield* Effect.logInfo(
858
+ built.length > 0 ? `promoted, built ${built.join(", ")}` : "promoted (view-swap only)",
859
+ ).pipe(Effect.annotateLogs("env", plan.env))
860
+
759
861
  return { plan, built }
760
862
  }).pipe(Effect.withSpan("efmesh.apply", { attributes: { env: plan.env } }))