@avytheone/efmesh 0.2.0 → 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.
package/src/cli.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { writeFileSync } from "node:fs"
2
2
  import * as NodePath from "node:path"
3
- import { Console, Data, Effect, Layer } from "effect"
3
+ import { Cause, Console, Data, Effect, Layer } from "effect"
4
4
  import { Argument, Command, Flag } from "effect/unstable/cli"
5
5
  import type { EfmeshConfig } from "./config.ts"
6
6
  import { buildGraph } from "./core/graph.ts"
@@ -40,9 +40,13 @@ import { migrateSqliteState, SqliteStateLive } from "./state/sqlite.ts"
40
40
  export class ConfigLoadError extends Data.TaggedError("ConfigLoadError")<{
41
41
  readonly path: string
42
42
  readonly reason: string
43
- }> {}
43
+ }> {
44
+ override get message(): string {
45
+ return `config ${this.path}: ${this.reason}`
46
+ }
47
+ }
44
48
 
45
- /** Конфиг с уже собранным списком моделей: явные + найденные discovery. */
49
+ /** Config with an already-assembled model list: explicit ones + discovery finds. */
46
50
  type LoadedConfig = EfmeshConfig & { readonly models: ReadonlyArray<AnyModel> }
47
51
 
48
52
  const loadConfig = (
@@ -57,7 +61,7 @@ const loadConfig = (
57
61
  module.default === undefined ||
58
62
  (!Array.isArray(module.default.models) && module.default.discovery === undefined)
59
63
  ) {
60
- throw new Error("конфиг должен экспортировать default с models и/или discovery")
64
+ throw new Error("config must export a default with models and/or discovery")
61
65
  }
62
66
  return module.default
63
67
  },
@@ -65,10 +69,10 @@ const loadConfig = (
65
69
  })
66
70
  const explicit = config.models ?? []
67
71
  if (config.discovery === undefined) return { ...config, models: explicit }
68
- // маски относительно конфига: проект переносим независимо от cwd
72
+ // globs are relative to the config: the project is portable regardless of cwd
69
73
  const discovered = yield* discoverModels(config.discovery, NodePath.dirname(absolute))
70
74
  const seen = new Set(explicit)
71
- const names = new Map(explicit.map((model) => [model.name.full, "models в конфиге"]))
75
+ const names = new Map(explicit.map((model) => [model.name.full, "config models"]))
72
76
  const merged = [...explicit]
73
77
  for (const model of discovered) {
74
78
  if (seen.has(model)) continue
@@ -82,7 +86,7 @@ const loadConfig = (
82
86
  return { ...config, models: merged }
83
87
  })
84
88
 
85
- /** Слои движка и состояния из конфигаобщие для plan/apply. */
89
+ /** Engine and state layers from the config shared by plan/apply. */
86
90
  const configLayers = (config: EfmeshConfig) =>
87
91
  Layer.mergeAll(
88
92
  config.engine?.url !== undefined
@@ -98,7 +102,7 @@ const configLayers = (config: EfmeshConfig) =>
98
102
 
99
103
  const configFlag = Flag.string("config").pipe(
100
104
  Flag.withDefault("efmesh.config.ts"),
101
- Flag.withDescription("Путь к efmesh.config.ts"),
105
+ Flag.withDescription("path to efmesh.config.ts"),
102
106
  )
103
107
 
104
108
  const CHANGE_MARK: Record<string, string> = {
@@ -114,21 +118,21 @@ const CHANGE_MARK: Record<string, string> = {
114
118
  const forwardOnlyFlag = Flag.string("forward-only").pipe(
115
119
  Flag.withDefault(""),
116
120
  Flag.withDescription(
117
- "Модели через запятую: изменения применяются forward-only — физика и история переиспользуются",
121
+ "comma-separated models: changes apply forward-only — physics and history are reused",
118
122
  ),
119
123
  )
120
124
 
121
125
  const reclassifyFlag = Flag.string("reclassify").pipe(
122
126
  Flag.withDefault(""),
123
127
  Flag.withDescription(
124
- "Override категоризации (#5): «модель=breaking|non-breaking[,…]» поверх --explain; журналируется с applied_by",
128
+ 'override categorization (#5): "model=breaking|non-breaking[,…]" on top of --explain; journaled with applied_by',
125
129
  ),
126
130
  )
127
131
 
128
132
  const jobsFlag = Flag.string("jobs").pipe(
129
133
  Flag.withDefault(""),
130
134
  Flag.withDescription(
131
- "Сколько моделей строить одновременно (DAG-конкурентность; на DuckDB всегда 1)",
135
+ "how many models to build at once (DAG concurrency; always 1 on DuckDB)",
132
136
  ),
133
137
  )
134
138
 
@@ -140,7 +144,7 @@ const parseJobs = (value: string): number | undefined => {
140
144
  const retriesFlag = Flag.string("retries").pipe(
141
145
  Flag.withDefault(""),
142
146
  Flag.withDescription(
143
- "Сколько раз ретраить упавший батч бэкфилла (экспоненциальная пауза; по умолчанию 0)",
147
+ "how many times to retry a failed backfill batch (exponential backoff; default 0)",
144
148
  ),
145
149
  )
146
150
 
@@ -159,7 +163,7 @@ const parseForwardOnly = (value: string): ReadonlyArray<string> | undefined => {
159
163
  return names.length > 0 ? names : undefined
160
164
  }
161
165
 
162
- /** `модель=breaking|non-breaking[,…]` → запись для PlanOptions.reclassify (#5). */
166
+ /** `model=breaking|non-breaking[,…]` → record for PlanOptions.reclassify (#5). */
163
167
  export const parseReclassify = (
164
168
  value: string,
165
169
  ): Effect.Effect<Readonly<Record<string, "breaking" | "non-breaking">> | undefined, ReclassifyError> =>
@@ -180,7 +184,7 @@ export const parseReclassify = (
180
184
  ) {
181
185
  return yield* new ReclassifyError({
182
186
  model: entry,
183
- reason: "ожидается «модель=breaking» или «модель=non-breaking»",
187
+ reason: 'expected "model=breaking" or "model=non-breaking"',
184
188
  })
185
189
  }
186
190
  parsed[model] = category
@@ -190,25 +194,25 @@ export const parseReclassify = (
190
194
 
191
195
  const yesFlag = Flag.boolean("yes").pipe(
192
196
  Flag.withAlias("y"),
193
- Flag.withDescription("Применить без подтверждения (не-TTY подтверждения не спрашивает)"),
197
+ Flag.withDescription("apply without confirmation (non-TTY never asks)"),
194
198
  )
195
199
 
196
- /** y/yes/д/да регистронезависимо; всё остальное (включая пусто) отказ. */
200
+ /** Accepts y/yes, case-insensitive; anything else (including empty) is a refusal. */
197
201
  export const isAffirmative = (answer: string | null): boolean =>
198
- ["y", "yes", "д", "да"].includes((answer ?? "").trim().toLowerCase())
202
+ ["y", "yes"].includes((answer ?? "").trim().toLowerCase())
199
203
 
200
204
  /**
201
- * Exit-код «работа ждёт человека» (F6): план требует подтверждения в не-TTY
202
- * или run упёрся в структурные изменения. Алертинг обязан отличать это
203
- * штатное состояние от настоящих ошибок (код 1).
205
+ * The "work awaits a human" exit code (F6): the plan needs confirmation in a
206
+ * non-TTY, or run hit structural changes. Alerting must distinguish this
207
+ * normal state from real errors (code 1).
204
208
  */
205
209
  export const EXIT_AWAITING_HUMAN = 2
206
210
 
207
211
  /**
208
- * Судьба показанного плана (SPEC §5.1, ужесточено в F6): без изменений или
209
- * с --yes — применять; изменения в TTY — спросить человека; изменения в
210
- * не-TTY (CI, cron, пайп) — ОТКАЗ: молча применять план, который никто не
211
- * видел, нельзя, нужен явный --yes.
212
+ * The fate of a shown plan (SPEC §5.1, tightened in F6): no changes or
213
+ * --yes — apply; changes in a TTY — ask the human; changes in a
214
+ * non-TTY (CI, cron, pipe) — REFUSE: silently applying a plan nobody
215
+ * saw is forbidden, an explicit --yes is required.
212
216
  */
213
217
  export const decideApply = (
214
218
  hasChanges: boolean,
@@ -217,18 +221,18 @@ export const decideApply = (
217
221
  ): "apply" | "ask" | "refuse" => (!hasChanges || yes ? "apply" : tty ? "ask" : "refuse")
218
222
 
219
223
  const jsonFlag = Flag.boolean("json").pipe(
220
- Flag.withDescription("Машиночитаемый вывод (стабильная формаконтракт для CI)"),
224
+ Flag.withDescription("machine-readable output (stable shapea contract for CI)"),
221
225
  )
222
226
 
223
227
  const explainFlag = Flag.boolean("explain").pipe(
224
228
  Flag.withDescription(
225
- "К каждому изменениюкакие узлы канонического AST разошлись и почему категория такая",
229
+ "for each changewhich canonical AST nodes diverged and why the category is what it is",
226
230
  ),
227
231
  )
228
232
 
229
233
  /**
230
- * JSON-форма плана (#3) — КОНТРАКТ для CI и ботов: изменения формы —
231
- * semver-события пакета. Интервалы ISO UTC, не epoch ms.
234
+ * JSON shape of the plan (#3) — a CONTRACT for CI and bots: shape changes
235
+ * are package semver events. Intervals are ISO UTC, not epoch ms.
232
236
  */
233
237
  export const planToJson = (plan: Plan): unknown => ({
234
238
  env: plan.env,
@@ -236,7 +240,7 @@ export const planToJson = (plan: Plan): unknown => ({
236
240
  actions: plan.actions.map((action) => ({
237
241
  name: action.name,
238
242
  change: action.change,
239
- // override оператора (#5) и реюз физикиаддитивные поля контракта
243
+ // operator override (#5) and physical reuseadditive contract fields
240
244
  ...(action.reclassifiedFrom !== undefined
241
245
  ? { reclassifiedFrom: action.reclassifiedFrom }
242
246
  : {}),
@@ -247,7 +251,7 @@ export const planToJson = (plan: Plan): unknown => ({
247
251
  start: new Date(range.start).toISOString(),
248
252
  end: new Date(range.end).toISOString(),
249
253
  })),
250
- // причина категории (#4); diverged-пути отладочная подсказка, не контракт
254
+ // category reason (#4); diverged paths are a debug hint, not a contract
251
255
  ...(action.explain !== undefined ? { explain: action.explain } : {}),
252
256
  })),
253
257
  })
@@ -255,37 +259,37 @@ export const planToJson = (plan: Plan): unknown => ({
255
259
  const printJson = (payload: unknown) => Console.log(JSON.stringify(payload, null, 2))
256
260
 
257
261
  const formatRange = (range: { readonly start: number; readonly end: number }): string =>
258
- `[${new Date(range.start).toISOString().slice(0, 10)} ${new Date(range.end).toISOString().slice(0, 10)})`
262
+ `[${new Date(range.start).toISOString().slice(0, 10)}, ${new Date(range.end).toISOString().slice(0, 10)})`
259
263
 
260
264
  const printPlan = (plan: Plan, explain = false) =>
261
265
  Effect.gen(function* () {
262
- yield* Console.log(`план для окружения «${plan.env}»:`)
266
+ yield* Console.log(`plan for environment "${plan.env}":`)
263
267
  for (const action of plan.actions) {
264
268
  const mark = CHANGE_MARK[action.change] ?? "?"
265
269
  const overridden =
266
270
  action.reclassifiedFrom !== undefined
267
- ? ` [override: было ${action.reclassifiedFrom}]`
271
+ ? ` [override: was ${action.reclassifiedFrom}]`
268
272
  : ""
269
273
  const reused =
270
274
  action.change === "indirect" && action.reusedFrom !== undefined
271
- ? " [физика реюзается]"
275
+ ? " [physics reused]"
272
276
  : ""
273
- const build = action.build ? " [сборка]" : ""
277
+ const build = action.build ? " [build]" : ""
274
278
  const backfill =
275
279
  action.backfill.length > 0
276
- ? ` бэкфилл ${action.backfill.map(formatRange).join(", ")}`
280
+ ? ` backfill ${action.backfill.map(formatRange).join(", ")}`
277
281
  : ""
278
282
  yield* Console.log(
279
283
  ` ${mark} ${action.name} ${action.change} @${fp8(action.fingerprint)}${overridden}${reused}${build}${backfill}`,
280
284
  )
281
285
  if (explain && action.explain !== undefined) {
282
- yield* Console.log(` почему: ${action.explain.reason}`)
286
+ yield* Console.log(` why: ${action.explain.reason}`)
283
287
  if (action.explain.diverged.length > 0) {
284
- yield* Console.log(` разошлось: ${action.explain.diverged.join(", ")}`)
288
+ yield* Console.log(` diverged: ${action.explain.diverged.join(", ")}`)
285
289
  }
286
290
  }
287
291
  }
288
- if (!plan.hasChanges) yield* Console.log(" изменений нет")
292
+ if (!plan.hasChanges) yield* Console.log(" no changes")
289
293
  })
290
294
 
291
295
  const initCommand = Command.make(
@@ -294,10 +298,10 @@ const initCommand = Command.make(
294
298
  ({ dir }) =>
295
299
  Effect.gen(function* () {
296
300
  const created = yield* scaffold(dir)
297
- for (const file of created) yield* Console.log(`создан ${file}`)
298
- yield* Console.log("дальше: bunx efmesh plan dev && bunx efmesh apply dev")
301
+ for (const file of created) yield* Console.log(`created ${file}`)
302
+ yield* Console.log("next: bunx efmesh plan dev && bunx efmesh apply dev")
299
303
  }),
300
- ).pipe(Command.withDescription("Скаффолд проекта: конфиг, модели-пример, seed"))
304
+ ).pipe(Command.withDescription("scaffold a project: config, example models, seed"))
301
305
 
302
306
  const planCommand = Command.make(
303
307
  "plan",
@@ -320,7 +324,7 @@ const planCommand = Command.make(
320
324
  }).pipe(Effect.provide(configLayers(loaded)))
321
325
  yield* json ? printJson(planToJson(plan)) : printPlan(plan, explain)
322
326
  }),
323
- ).pipe(Command.withDescription("Показать diff проекта против окружения, ничего не меняя"))
327
+ ).pipe(Command.withDescription("show the project diff against an environment, changing nothing"))
324
328
 
325
329
  const applyCommand = Command.make(
326
330
  "apply",
@@ -340,10 +344,10 @@ const applyCommand = Command.make(
340
344
  const overrides = yield* parseReclassify(reclassify)
341
345
  const modelConcurrency = parseJobs(jobs)
342
346
  const retry = parseRetries(retries)
343
- // план и применениепод одним слоем и одним межпроцессным локом:
344
- // применяется ровно тот план, который показан и подтверждён, и никто
345
- // (второй apply, cron с run) не вклинится между ними (SPEC §14.6);
346
- // ценалок держится и пока человек думает над подтверждением
347
+ // plan and applyunder one layer and one cross-process lock:
348
+ // exactly the plan that was shown and confirmed gets applied, and no one
349
+ // (a second apply, cron with run) wedges in between them (SPEC §14.6);
350
+ // the cost the lock is held even while the human ponders confirmation
347
351
  yield* Effect.gen(function* () {
348
352
  const graph = yield* buildGraph(loaded.models)
349
353
  const plan = yield* planChanges(env, graph, {
@@ -354,7 +358,7 @@ const applyCommand = Command.make(
354
358
  const decision = decideApply(plan.hasChanges, yes, process.stdin.isTTY === true)
355
359
  if (decision === "refuse") {
356
360
  yield* Console.error(
357
- "план меняет модели, а подтвердить некому (не-TTY): добавьте --yes",
361
+ "the plan changes models but there is no one to confirm (non-TTY): add --yes",
358
362
  )
359
363
  yield* Effect.sync(() => {
360
364
  process.exitCode = EXIT_AWAITING_HUMAN
@@ -363,9 +367,9 @@ const applyCommand = Command.make(
363
367
  }
364
368
  if (
365
369
  decision === "ask" &&
366
- !isAffirmative(globalThis.prompt("применить план? [y/N]"))
370
+ !isAffirmative(globalThis.prompt("apply the plan? [y/N]"))
367
371
  ) {
368
- yield* Console.log("применение отменено")
372
+ yield* Console.log("apply cancelled")
369
373
  return
370
374
  }
371
375
  const applied = yield* applyPlan(plan, graph, {
@@ -377,13 +381,13 @@ const applyCommand = Command.make(
377
381
  })
378
382
  yield* Console.log(
379
383
  applied.built.length > 0
380
- ? `собрано: ${applied.built.join(", ")}`
381
- : "сборка не потребовалась (только view-swap)",
384
+ ? `built: ${applied.built.join(", ")}`
385
+ : "no build needed (view-swap only)",
382
386
  )
383
- yield* Console.log(`окружение «${applied.plan.env}» промоутнуто`)
387
+ yield* Console.log(`environment "${applied.plan.env}" promoted`)
384
388
  }).pipe(withStateLock(envLockName(env)), Effect.provide(configLayers(loaded)))
385
389
  }),
386
- ).pipe(Command.withDescription("Применить план: собрать физику и переключить view"))
390
+ ).pipe(Command.withDescription("apply the plan: build physics and swap views"))
387
391
 
388
392
  const renderCommand = Command.make(
389
393
  "render",
@@ -392,7 +396,7 @@ const renderCommand = Command.make(
392
396
  config: configFlag,
393
397
  env: Flag.string("env").pipe(
394
398
  Flag.withDefault(""),
395
- Flag.withDescription("Рендер против view-слоя окружения вместо логических имён"),
399
+ Flag.withDescription("render against an environment's view layer instead of logical names"),
396
400
  ),
397
401
  },
398
402
  ({ config, env, model }) =>
@@ -403,7 +407,7 @@ const renderCommand = Command.make(
403
407
  : yield* Efmesh.renderFor(loaded.models, model, env)
404
408
  yield* Console.log(sql.trim())
405
409
  }),
406
- ).pipe(Command.withDescription("Показать итоговый SQL модели"))
410
+ ).pipe(Command.withDescription("show a model's final SQL"))
407
411
 
408
412
  const runCommand = Command.make(
409
413
  "run",
@@ -421,12 +425,12 @@ const runCommand = Command.make(
421
425
  ...(retry !== undefined ? { retry } : {}),
422
426
  }).pipe(
423
427
  Effect.provide(configLayers(loaded)),
424
- // структурные изменения штатное «ждёт человека с apply», не сбой:
425
- // алертинг различает по exit-коду 2 (F6)
428
+ // structural changes are the normal "awaits a human with apply", not a failure:
429
+ // alerting tells it apart by exit code 2 (F6)
426
430
  Effect.catchTag("RunBlockedByChangesError", (blocked) =>
427
431
  Effect.gen(function* () {
428
432
  yield* Console.error(
429
- `run ${blocked.env}: есть неприменённые изменениянужен apply:\n ${blocked.changes.join("\n ")}`,
433
+ `run "${blocked.env}": unapplied changes presentapply needed:\n ${blocked.changes.join("\n ")}`,
430
434
  )
431
435
  yield* Effect.sync(() => {
432
436
  process.exitCode = EXIT_AWAITING_HUMAN
@@ -438,27 +442,27 @@ const runCommand = Command.make(
438
442
  if (applied === undefined) return
439
443
  yield* Console.log(
440
444
  applied.built.length > 0
441
- ? `обработано: ${applied.built.join(", ")}`
442
- : "новых интервалов нет",
445
+ ? `processed: ${applied.built.join(", ")}`
446
+ : "no new intervals",
443
447
  )
444
448
  }),
445
449
  ).pipe(
446
450
  Command.withDescription(
447
- "Тик планировщика: догнать интервалы существующих версий (изменения через apply)",
451
+ "scheduler tick: catch up intervals of existing versions (changes go through apply)",
448
452
  ),
449
453
  )
450
454
 
451
455
  const printDataDiff = (report: DataDiffReport) =>
452
456
  Effect.gen(function* () {
453
- yield* Console.log(`данные ${report.envA} ↔ ${report.envB}:`)
457
+ yield* Console.log(`data "${report.envA}""${report.envB}":`)
454
458
  if (report.models.length === 0) {
455
- yield* Console.log(" общих материализуемых моделей нет")
459
+ yield* Console.log(" no shared materializable models")
456
460
  return
457
461
  }
458
462
  for (const entry of report.models) {
459
463
  if (entry.key === undefined) {
460
464
  yield* Console.log(
461
- ` · ${entry.model} A=${entry.rowsA} B=${entry.rowsB} без ключа (задайте grain) — только счётчики`,
465
+ ` · ${entry.model} A=${entry.rowsA} B=${entry.rowsB} no key (set a grain) — counts only`,
462
466
  )
463
467
  } else {
464
468
  const clean =
@@ -467,21 +471,21 @@ const printDataDiff = (report: DataDiffReport) =>
467
471
  (entry.columns?.length ?? 0) === 0 &&
468
472
  entry.rowsA === entry.rowsB
469
473
  const sampled =
470
- entry.sampledPercent !== undefined ? ` (выборка ${entry.sampledPercent}%)` : ""
474
+ entry.sampledPercent !== undefined ? ` (sample ${entry.sampledPercent}%)` : ""
471
475
  yield* Console.log(
472
- ` ${clean ? "✓" : "≠"} ${entry.model} A=${entry.rowsA} B=${entry.rowsB} ключ (${entry.key.join(", ")}): только в A ${entry.onlyInA}, только в B ${entry.onlyInB}, совпало ${entry.matched}${sampled}`,
476
+ ` ${clean ? "✓" : "≠"} ${entry.model} A=${entry.rowsA} B=${entry.rowsB} key (${entry.key.join(", ")}): only in A ${entry.onlyInA}, only in B ${entry.onlyInB}, matched ${entry.matched}${sampled}`,
473
477
  )
474
478
  for (const drift of entry.columns ?? []) {
475
479
  yield* Console.log(
476
- ` ${drift.column}: ${drift.mismatches} из ${entry.matched} (${(drift.rate * 100).toFixed(2)}%)`,
480
+ ` ${drift.column}: ${drift.mismatches} of ${entry.matched} (${(drift.rate * 100).toFixed(2)}%)`,
477
481
  )
478
482
  }
479
483
  }
480
484
  if (entry.columnsOnlyInA !== undefined) {
481
- yield* Console.log(` колонки только в A: ${entry.columnsOnlyInA.join(", ")}`)
485
+ yield* Console.log(` columns only in A: ${entry.columnsOnlyInA.join(", ")}`)
482
486
  }
483
487
  if (entry.columnsOnlyInB !== undefined) {
484
- yield* Console.log(` колонки только в B: ${entry.columnsOnlyInB.join(", ")}`)
488
+ yield* Console.log(` columns only in B: ${entry.columnsOnlyInB.join(", ")}`)
485
489
  }
486
490
  }
487
491
  })
@@ -494,17 +498,17 @@ const diffCommand = Command.make(
494
498
  config: configFlag,
495
499
  data: Flag.boolean("data").pipe(
496
500
  Flag.withDescription(
497
- "Сравнить ДАННЫЕ view-слоёв: счётчики строк, пересечение по ключу, расхождения по колонкам",
501
+ "compare view-layer DATA: row counts, key intersection, per-column divergences",
498
502
  ),
499
503
  ),
500
504
  model: Flag.string("model").pipe(
501
505
  Flag.withDefault(""),
502
- Flag.withDescription("Только эти модели, через запятую (для --data)"),
506
+ Flag.withDescription("only these models, comma-separated (for --data)"),
503
507
  ),
504
508
  sample: Flag.string("sample").pipe(
505
509
  Flag.withDefault(""),
506
510
  Flag.withDescription(
507
- "Процент 1–99: сравнивать детерминированную долю ключей (md5-бакеты; для --data)",
511
+ "percent 1–99: compare a deterministic fraction of keys (md5 buckets; for --data)",
508
512
  ),
509
513
  ),
510
514
  json: jsonFlag,
@@ -516,13 +520,13 @@ const diffCommand = Command.make(
516
520
  const only = parseForwardOnly(model)
517
521
  const percent = sample === "" ? undefined : Number(sample)
518
522
  if (percent !== undefined && !(Number.isFinite(percent) && percent >= 1 && percent <= 99)) {
519
- yield* Console.error("--sample ожидает процент от 1 до 99")
523
+ yield* Console.error("--sample expects a percent from 1 to 99")
520
524
  return yield* Effect.sync(() => {
521
525
  process.exitCode = 1
522
526
  })
523
527
  }
524
528
  const report = yield* Effect.gen(function* () {
525
- // ducklake-витрины видны через ATTACH — как делает apply
529
+ // ducklake marts are visible via ATTACH — the same way apply does it
526
530
  if (loaded.ducklake !== undefined) {
527
531
  const engine = yield* EngineAdapter
528
532
  yield* engine.execute(ducklakeAttachSql(loaded.ducklake))
@@ -542,17 +546,17 @@ const diffCommand = Command.make(
542
546
  yield* printJson(diff)
543
547
  return
544
548
  }
545
- for (const name of diff.onlyInA) yield* Console.log(`< ${name} только в ${envA}`)
546
- for (const name of diff.onlyInB) yield* Console.log(`> ${name} только в ${envB}`)
549
+ for (const name of diff.onlyInA) yield* Console.log(`< ${name} only in ${envA}`)
550
+ for (const name of diff.onlyInB) yield* Console.log(`> ${name} only in ${envB}`)
547
551
  for (const entry of diff.different) {
548
552
  yield* Console.log(`≠ ${entry.name} ${envA}@${entry.a} vs ${envB}@${entry.b}`)
549
553
  }
550
554
  if (diff.onlyInA.length + diff.onlyInB.length + diff.different.length === 0) {
551
- yield* Console.log("окружения идентичны")
555
+ yield* Console.log("environments are identical")
552
556
  }
553
557
  }),
554
558
  ).pipe(
555
- Command.withDescription("Чем окружения отличаются: версии (state store) или --data (данные)"),
559
+ Command.withDescription("how environments differ: versions (state store) or --data (row data)"),
556
560
  )
557
561
 
558
562
  const scheduleCommand = Command.make(
@@ -562,17 +566,17 @@ const scheduleCommand = Command.make(
562
566
  config: configFlag,
563
567
  cron: Flag.string("cron").pipe(
564
568
  Flag.withDefault("@hourly"),
565
- Flag.withDescription("Cron-выражение или никнейм (@hourly, @daily, …)"),
569
+ Flag.withDescription("cron expression or nickname (@hourly, @daily, …)"),
566
570
  ),
567
571
  remove: Flag.boolean("remove").pipe(
568
- Flag.withDescription("Снять регистрацию окружения из OS-шедулера"),
572
+ Flag.withDescription("unregister the environment from the OS scheduler"),
569
573
  ),
570
574
  list: Flag.boolean("list").pipe(
571
- Flag.withDescription("Показать efmesh-записи OS-шедулера"),
575
+ Flag.withDescription("list efmesh entries in the OS scheduler"),
572
576
  ),
573
577
  printSystemd: Flag.boolean("print-systemd").pipe(
574
578
  Flag.withDescription(
575
- "Напечатать systemd user-юниты вместо cron (Persistent=true догоняет пропуски; спасение без cron-демона)",
579
+ "print systemd user units instead of cron (Persistent=true catches up misses; a lifeline without a cron daemon)",
576
580
  ),
577
581
  ),
578
582
  },
@@ -580,12 +584,12 @@ const scheduleCommand = Command.make(
580
584
  Effect.gen(function* () {
581
585
  if (list) {
582
586
  const entries = yield* listSchedules()
583
- if (entries.length === 0) yield* Console.log("efmesh-записей в OS-шедулере нет")
587
+ if (entries.length === 0) yield* Console.log("no efmesh entries in the OS scheduler")
584
588
  for (const entry of entries) yield* Console.log(` ${entry}`)
585
589
  return
586
590
  }
587
591
  if (env === "") {
588
- yield* Console.error("нужно окружение: efmesh schedule <env> [--cron …]")
592
+ yield* Console.error("environment required: efmesh schedule <env> [--cron …]")
589
593
  return yield* Effect.sync(() => {
590
594
  process.exitCode = 1
591
595
  })
@@ -599,23 +603,23 @@ const scheduleCommand = Command.make(
599
603
  yield* Console.log(`# ~/.config/systemd/user/${units.name}.timer`)
600
604
  yield* Console.log(units.timer)
601
605
  yield* Console.log(
602
- `# включить: systemctl --user daemon-reload && systemctl --user enable --now ${units.name}.timer`,
606
+ `# enable: systemctl --user daemon-reload && systemctl --user enable --now ${units.name}.timer`,
603
607
  )
604
608
  return
605
609
  }
606
610
  if (remove) {
607
611
  const removed = yield* removeSchedule(target)
608
- yield* Console.log(`снято: ${removed.title}`)
612
+ yield* Console.log(`unregistered: ${removed.title}`)
609
613
  return
610
614
  }
611
615
  const registered = yield* registerSchedule(target, cron)
612
- yield* Console.log(`зарегистрировано: ${registered.title} — «${cron}» (OS-шедулер)`)
613
- yield* Console.log(`воркер: ${registered.worker}`)
616
+ yield* Console.log(`registered: ${registered.title} — "${cron}" (OS scheduler)`)
617
+ yield* Console.log(`worker: ${registered.worker}`)
614
618
  yield* Console.log(
615
- "журнал тиков: efmesh status " + env + "; NB: cron не догоняет пропущенные запускистроже systemd-таймер (--print-systemd)",
619
+ "tick journal: efmesh status " + env + "; NB: cron does not catch up missed runs a systemd timer is stricter (--print-systemd)",
616
620
  )
617
621
  }).pipe(
618
- // reason самое ценное (рецепт для оператора): наружу словами, не стектрейсом
622
+ // reason is the most valuable part (a recipe for the operator): surface it in words, not a stacktrace
619
623
  Effect.catchTag("ScheduleError", (error) =>
620
624
  Effect.gen(function* () {
621
625
  yield* Console.error(`schedule: ${error.reason}`)
@@ -627,7 +631,7 @@ const scheduleCommand = Command.make(
627
631
  ),
628
632
  ).pipe(
629
633
  Command.withDescription(
630
- "Зарегистрировать run <env> в OS-шедулере (Bun.cron: crontab/launchd/Task Scheduler)",
634
+ "register run <env> in the OS scheduler (Bun.cron: crontab/launchd/Task Scheduler)",
631
635
  ),
632
636
  )
633
637
 
@@ -637,7 +641,7 @@ const janitorCommand = Command.make(
637
641
  config: configFlag,
638
642
  ttl: Flag.string("ttl").pipe(
639
643
  Flag.withDefault("7"),
640
- Flag.withDescription("Сколько дней осиротевшая физика живёт до сноса"),
644
+ Flag.withDescription("how many days orphaned physics lives before removal"),
641
645
  ),
642
646
  },
643
647
  ({ config, ttl }) =>
@@ -650,14 +654,14 @@ const janitorCommand = Command.make(
650
654
  }).pipe(Effect.provide(configLayers(loaded)))
651
655
  yield* Console.log(
652
656
  report.removed.length > 0
653
- ? `снесено: ${report.removed.join(", ")}`
654
- : "осиротевшей физики старше ttl нет",
657
+ ? `removed: ${report.removed.join(", ")}`
658
+ : "no orphaned physics older than ttl",
655
659
  )
656
660
  if (report.kept.length > 0) {
657
- yield* Console.log(`осиротело, но моложе ttl: ${report.kept.join(", ")}`)
661
+ yield* Console.log(`orphaned but younger than ttl: ${report.kept.join(", ")}`)
658
662
  }
659
663
  }),
660
- ).pipe(Command.withDescription("Убрать физику, на которую не ссылается ни одно окружение"))
664
+ ).pipe(Command.withDescription("remove physics no environment references"))
661
665
 
662
666
  const statusCommand = Command.make(
663
667
  "status",
@@ -673,40 +677,40 @@ const statusCommand = Command.make(
673
677
  return
674
678
  }
675
679
  if (report.models === 0) {
676
- yield* Console.log(`окружение «${env}» не существуетего создаст первый apply`)
680
+ yield* Console.log(`environment "${env}" does not exist the first apply creates it`)
677
681
  return
678
682
  }
679
683
  yield* Console.log(
680
- `окружение «${env}»: моделей ${report.models}, промоушен ${report.promotedAt}, схема стора v${report.storeVersion}`,
684
+ `environment "${env}": models ${report.models}, promoted ${report.promotedAt}, store schema v${report.storeVersion}`,
681
685
  )
682
686
  if (report.lastPlan !== null) {
683
687
  yield* Console.log(
684
- `последний план: ${report.lastPlan.appliedAt} (${report.lastPlan.appliedBy || "неизвестно"})`,
688
+ `last plan: ${report.lastPlan.appliedAt} (${report.lastPlan.appliedBy || "unknown"})`,
685
689
  )
686
690
  }
687
691
  for (const lag of report.lag) {
688
692
  const state =
689
693
  lag.missing === 0
690
- ? `догнано до ${lag.doneUpTo}`
691
- : `отстаёт на ${lag.missing} интервал(ов), догнано до ${lag.doneUpTo ?? "—"}`
692
- const failed = lag.failed > 0 ? ` ⚠ failed-интервалов: ${lag.failed}` : ""
694
+ ? `caught up to ${lag.doneUpTo}`
695
+ : `behind by ${lag.missing} interval(s), caught up to ${lag.doneUpTo ?? "—"}`
696
+ const failed = lag.failed > 0 ? ` ⚠ failed intervals: ${lag.failed}` : ""
693
697
  yield* Console.log(` ${lag.missing === 0 ? "✓" : "…"} ${lag.model} ${state}${failed}`)
694
698
  }
695
699
  if (report.ticks.length === 0) {
696
- yield* Console.log("тиков run ещё не было")
700
+ yield* Console.log("no run ticks yet")
697
701
  } else {
698
- yield* Console.log("последние тики run:")
702
+ yield* Console.log("recent run ticks:")
699
703
  for (const tick of report.ticks) {
700
704
  const mark = tick.outcome === "ok" ? "✓" : tick.outcome === "error" ? "✗" : "…"
701
705
  const ms = Date.parse(tick.finishedAt) - Date.parse(tick.startedAt)
702
706
  yield* Console.log(
703
- ` ${mark} ${tick.startedAt} ${tick.outcome} (${ms} мс)${tick.detail !== "" ? ` ${tick.detail}` : ""}`,
707
+ ` ${mark} ${tick.startedAt} ${tick.outcome} (${ms} ms)${tick.detail !== "" ? ` ${tick.detail}` : ""}`,
704
708
  )
705
709
  }
706
710
  }
707
711
  }),
708
712
  ).pipe(
709
- Command.withDescription("Что происходит в окружении: промоушен, отставание, тики run"),
713
+ Command.withDescription("what is happening in an environment: promotion, lag, run ticks"),
710
714
  )
711
715
 
712
716
  const auditCommand = Command.make(
@@ -716,7 +720,7 @@ const auditCommand = Command.make(
716
720
  config: configFlag,
717
721
  model: Flag.string("model").pipe(
718
722
  Flag.withDefault(""),
719
- Flag.withDescription("Только эти модели, через запятую (по умолчанию все с аудитами)"),
723
+ Flag.withDescription("only these models, comma-separated (defaultall with audits)"),
720
724
  ),
721
725
  json: jsonFlag,
722
726
  },
@@ -728,7 +732,7 @@ const auditCommand = Command.make(
728
732
  Effect.provide(configLayers(loaded)),
729
733
  )
730
734
  if (json) {
731
- // отчёт целиком; exit-код по blocking сохраняется — stdout чистый JSON
735
+ // the whole report; the blocking-based exit code is preserved — stdout is pure JSON
732
736
  yield* printJson(report)
733
737
  if (report.blockingViolations > 0) {
734
738
  return yield* new EnvironmentAuditError({
@@ -739,14 +743,14 @@ const auditCommand = Command.make(
739
743
  return
740
744
  }
741
745
  if (report.results.length === 0) {
742
- yield* Console.log("аудитов нетнечего проверять")
746
+ yield* Console.log("no auditsnothing to check")
743
747
  return
744
748
  }
745
749
  for (const result of report.results) {
746
750
  const mark = result.violations === 0 ? "✓" : result.blocking ? "✗" : "⚠"
747
751
  const tail =
748
752
  result.violations > 0
749
- ? ` ${result.violations} нарушений${result.blocking ? "" : " (warn)"}`
753
+ ? ` ${result.violations} violations${result.blocking ? "" : " (warn)"}`
750
754
  : ""
751
755
  yield* Console.log(` ${mark} ${result.model} ${result.audit}${tail}`)
752
756
  }
@@ -756,10 +760,10 @@ const auditCommand = Command.make(
756
760
  blockingViolations: report.blockingViolations,
757
761
  })
758
762
  }
759
- yield* Console.log(`blocking-аудиты окружения «${env}» чисты`)
763
+ yield* Console.log(`blocking audits of environment "${env}" are clean`)
760
764
  }),
761
765
  ).pipe(
762
- Command.withDescription("Прогнать аудиты по view-слою окружения, ничего не меняя"),
766
+ Command.withDescription("run audits over an environment's view layer, changing nothing"),
763
767
  )
764
768
 
765
769
  const migrateCommand = Command.make(
@@ -773,14 +777,14 @@ const migrateCommand = Command.make(
773
777
  : migrateSqliteState({ path: loaded.state?.path ?? "efmesh.state.sqlite" }))
774
778
  yield* Console.log(
775
779
  report.from === report.to
776
- ? `state store уже на версии ${report.to}`
777
- : `state store: версия ${report.from} → ${report.to}`,
780
+ ? `state store already at version ${report.to}`
781
+ : `state store: version ${report.from} → ${report.to}`,
778
782
  )
779
783
  if (report.backup !== undefined) {
780
- yield* Console.log(`копия старого стора: ${report.backup}`)
784
+ yield* Console.log(`backup of the old store: ${report.backup}`)
781
785
  }
782
786
  }),
783
- ).pipe(Command.withDescription("Догнать схему state store до текущей версии"))
787
+ ).pipe(Command.withDescription("bring the state store schema up to the current version"))
784
788
 
785
789
  const graphCommand = Command.make(
786
790
  "graph",
@@ -788,7 +792,7 @@ const graphCommand = Command.make(
788
792
  config: configFlag,
789
793
  html: Flag.string("html").pipe(
790
794
  Flag.withDefault(""),
791
- Flag.withDescription("Записать DAG самодостаточной HTML-страницей по указанному пути"),
795
+ Flag.withDescription("write the DAG as a self-contained HTML page at the given path"),
792
796
  ),
793
797
  },
794
798
  ({ config, html }) =>
@@ -797,7 +801,7 @@ const graphCommand = Command.make(
797
801
  const graph = yield* buildGraph(loaded.models)
798
802
  if (html !== "") {
799
803
  yield* Effect.sync(() => writeFileSync(html, renderGraphHtml(graph)))
800
- yield* Console.log(`DAG записан: ${html}`)
804
+ yield* Console.log(`DAG written: ${html}`)
801
805
  return
802
806
  }
803
807
  for (const name of graph.order) {
@@ -806,7 +810,7 @@ const graphCommand = Command.make(
806
810
  yield* Console.log(`${name} (${model.kind._tag})${deps}`)
807
811
  }
808
812
  }),
809
- ).pipe(Command.withDescription("DAG моделей в топологическом порядке (или --html файл)"))
813
+ ).pipe(Command.withDescription("the model DAG in topological order (or an --html file)"))
810
814
 
811
815
  const lineageCommand = Command.make(
812
816
  "lineage",
@@ -818,14 +822,14 @@ const lineageCommand = Command.make(
818
822
  if (segments.length < 2) {
819
823
  return yield* new LineageError({
820
824
  model: target,
821
- reason: "ожидается <схема>.<таблица>[.<колонка>]",
825
+ reason: "expected <schema>.<table>[.<column>]",
822
826
  })
823
827
  }
824
828
  const modelName = `${segments[0]}.${segments[1]}`
825
829
  const graph = yield* buildGraph(loaded.models)
826
830
  const model = graph.models.get(modelName)
827
831
  if (model === undefined) {
828
- return yield* new LineageError({ model: modelName, reason: "модели нет в проекте" })
832
+ return yield* new LineageError({ model: modelName, reason: "model is not in the project" })
829
833
  }
830
834
  const columns =
831
835
  segments.length >= 3 ? [segments.slice(2).join(".")] : Object.keys(model.schema.fields)
@@ -836,10 +840,86 @@ const lineageCommand = Command.make(
836
840
  for (const line of formatLineage(tree)) yield* Console.log(line)
837
841
  }
838
842
  }),
839
- ).pipe(Command.withDescription("Колоночный lineage до сырьевых колонок (best-effort)"))
843
+ ).pipe(Command.withDescription("column lineage down to raw columns (best-effort)"))
844
+
845
+ /**
846
+ * Actionable next step for a failure, when one exists — the recipe the
847
+ * `schedule` command already prints for a missing cron daemon, generalized to
848
+ * every error that has one obvious cure (#13). No entry ⇒ the message already
849
+ * says everything; we do not invent filler advice.
850
+ */
851
+ const FAILURE_HINTS: Readonly<Record<string, (error: Record<string, unknown>) => string>> = {
852
+ StateSchemaError: () => "run `efmesh migrate` to bring the state store up to date",
853
+ FingerprintVersionError: () => "run `efmesh migrate` or upgrade efmesh, then re-apply",
854
+ LockHeldError: (error) =>
855
+ `wait for the other apply/run to finish, or clear the stale lock «${String(error["name"])}»`,
856
+ LakeNotConfiguredError: () => "add `lake: { path: … }` to efmesh.config.ts",
857
+ DucklakeNotConfiguredError: () => "add `ducklake: { catalog: … }` to efmesh.config.ts",
858
+ AttachNotConfiguredError: (error) =>
859
+ `add «${String(error["attach"])}» to \`attach\` in efmesh.config.ts`,
860
+ ConfigLoadError: () =>
861
+ "check the --config path and that it default-exports defineConfig({ … })",
862
+ RunBlockedByChangesError: (error) => `run \`efmesh apply ${String(error["env"])}\``,
863
+ }
864
+
865
+ /** First line of a rendered failure: the tag names the class, the message the culprit + cause. */
866
+ const errorHeadline = (error: unknown): string => {
867
+ if (typeof error === "object" && error !== null && "_tag" in error) {
868
+ const record = error as Record<string, unknown>
869
+ const message = record["message"]
870
+ const detail = typeof message === "string" && message !== "" ? message : "(no detail)"
871
+ return `${String(record["_tag"])}: ${detail}`
872
+ }
873
+ if (error instanceof Error) {
874
+ return `${error.name}: ${error.message !== "" ? error.message : "(no detail)"}`
875
+ }
876
+ return String(error)
877
+ }
878
+
879
+ /**
880
+ * The single failure renderer (#13): cause first (the tagged error's derived
881
+ * message names the culprit), an actionable hint where one exists, and the
882
+ * Effect fiber trace ONLY under `--log-level debug` — an operator or agent
883
+ * sees one screen with the real cause, not a stack over an empty message. The
884
+ * exit code stays a frozen contract (0/1/2): the caller sets it, not this.
885
+ */
886
+ export const renderFailure = (
887
+ cause: Cause.Cause<unknown>,
888
+ options?: { readonly debug?: boolean },
889
+ ): string => {
890
+ const error = Cause.squash(cause)
891
+ const lines = [errorHeadline(error)]
892
+ const tag =
893
+ typeof error === "object" && error !== null && "_tag" in error
894
+ ? String((error as Record<string, unknown>)["_tag"])
895
+ : ""
896
+ const hint = FAILURE_HINTS[tag]?.(error as Record<string, unknown>)
897
+ if (hint !== undefined) lines.push(` → ${hint}`)
898
+ if (options?.debug === true) {
899
+ lines.push("", "── trace (--log-level debug) ──", Cause.pretty(cause))
900
+ } else {
901
+ lines.push(" (re-run with --log-level debug for the full trace)")
902
+ }
903
+ return lines.join("\n")
904
+ }
905
+
906
+ /**
907
+ * Whether the run asked for the full trace. Reused from the already-parsed
908
+ * global `--log-level` flag (values trace/debug/all mean "show me everything")
909
+ * so there is one knob, not a second bespoke verbosity flag.
910
+ */
911
+ export const wantsTrace = (argv: ReadonlyArray<string>): boolean => {
912
+ const verbose = new Set(["trace", "debug", "all"])
913
+ for (let index = 0; index < argv.length; index++) {
914
+ const arg = argv[index]!
915
+ if (arg.startsWith("--log-level=")) return verbose.has(arg.slice("--log-level=".length))
916
+ if (arg === "--log-level") return verbose.has((argv[index + 1] ?? "").toLowerCase())
917
+ }
918
+ return false
919
+ }
840
920
 
841
921
  export const rootCommand = Command.make("efmesh").pipe(
842
- Command.withDescription("sqlmesh на bun, typescript и Effect"),
922
+ Command.withDescription("sqlmesh on bun, typescript and Effect"),
843
923
  Command.withSubcommands([
844
924
  initCommand,
845
925
  planCommand,