@avytheone/efmesh 0.1.0-beta.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avytheone/efmesh",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.2.0",
4
4
  "description": "sqlmesh-like data transformation framework on TypeScript, Bun and Effect",
5
5
  "license": "MIT",
6
6
  "author": "Alexey Yakimanskiy",
@@ -30,6 +30,9 @@
30
30
  ],
31
31
  "type": "module",
32
32
  "module": "src/index.ts",
33
+ "engines": {
34
+ "bun": ">=1.3.11"
35
+ },
33
36
  "bin": {
34
37
  "efmesh": "src/bin.ts"
35
38
  },
package/src/cli.ts CHANGED
@@ -12,15 +12,28 @@ import { DuckDBEngineLive } from "./engine/duckdb.ts"
12
12
  import { PostgresEngineLive } from "./engine/postgres.ts"
13
13
  import { PostgresStateLive } from "./state/postgres.ts"
14
14
  import { auditEnvironment, EnvironmentAuditError } from "./plan/audit-run.ts"
15
- import { diffEnvironments } from "./plan/diff.ts"
15
+ import {
16
+ dataDiffEnvironments,
17
+ diffEnvironments,
18
+ type DataDiffReport,
19
+ } from "./plan/diff.ts"
20
+ import { EngineAdapter } from "./engine/adapter.ts"
21
+ import { ducklakeAttachSql } from "./plan/naming.ts"
22
+ import {
23
+ listSchedules,
24
+ registerSchedule,
25
+ removeSchedule,
26
+ systemdUnits,
27
+ } from "./plan/schedule.ts"
16
28
  import { renderGraphHtml } from "./plan/graph-html.ts"
17
29
  import { formatLineage, lineage, LineageError } from "./plan/lineage.ts"
30
+ import { environmentStatus } from "./plan/status.ts"
18
31
  import { janitor } from "./plan/janitor.ts"
19
32
  import { fp8 } from "./plan/naming.ts"
20
33
  import { envLockName, withStateLock } from "./plan/lock.ts"
21
34
  import { applyPlan } from "./plan/executor.ts"
22
35
  import { run } from "./plan/run.ts"
23
- import { planChanges, type Plan } from "./plan/planner.ts"
36
+ import { planChanges, ReclassifyError, type Plan } from "./plan/planner.ts"
24
37
  import { migratePostgresState } from "./state/postgres.ts"
25
38
  import { migrateSqliteState, SqliteStateLive } from "./state/sqlite.ts"
26
39
 
@@ -105,6 +118,13 @@ const forwardOnlyFlag = Flag.string("forward-only").pipe(
105
118
  ),
106
119
  )
107
120
 
121
+ const reclassifyFlag = Flag.string("reclassify").pipe(
122
+ Flag.withDefault(""),
123
+ Flag.withDescription(
124
+ "Override категоризации (#5): «модель=breaking|non-breaking[,…]» поверх --explain; журналируется с applied_by",
125
+ ),
126
+ )
127
+
108
128
  const jobsFlag = Flag.string("jobs").pipe(
109
129
  Flag.withDefault(""),
110
130
  Flag.withDescription(
@@ -139,6 +159,35 @@ const parseForwardOnly = (value: string): ReadonlyArray<string> | undefined => {
139
159
  return names.length > 0 ? names : undefined
140
160
  }
141
161
 
162
+ /** `модель=breaking|non-breaking[,…]` → запись для PlanOptions.reclassify (#5). */
163
+ export const parseReclassify = (
164
+ value: string,
165
+ ): Effect.Effect<Readonly<Record<string, "breaking" | "non-breaking">> | undefined, ReclassifyError> =>
166
+ Effect.gen(function* () {
167
+ const entries = value
168
+ .split(",")
169
+ .map((entry) => entry.trim())
170
+ .filter((entry) => entry !== "")
171
+ if (entries.length === 0) return undefined
172
+ const parsed: Record<string, "breaking" | "non-breaking"> = {}
173
+ for (const entry of entries) {
174
+ const [model, category, ...extra] = entry.split("=")
175
+ if (
176
+ model === undefined ||
177
+ model === "" ||
178
+ extra.length > 0 ||
179
+ (category !== "breaking" && category !== "non-breaking")
180
+ ) {
181
+ return yield* new ReclassifyError({
182
+ model: entry,
183
+ reason: "ожидается «модель=breaking» или «модель=non-breaking»",
184
+ })
185
+ }
186
+ parsed[model] = category
187
+ }
188
+ return parsed
189
+ })
190
+
142
191
  const yesFlag = Flag.boolean("yes").pipe(
143
192
  Flag.withAlias("y"),
144
193
  Flag.withDescription("Применить без подтверждения (не-TTY подтверждения не спрашивает)"),
@@ -167,22 +216,74 @@ export const decideApply = (
167
216
  tty: boolean,
168
217
  ): "apply" | "ask" | "refuse" => (!hasChanges || yes ? "apply" : tty ? "ask" : "refuse")
169
218
 
219
+ const jsonFlag = Flag.boolean("json").pipe(
220
+ Flag.withDescription("Машиночитаемый вывод (стабильная форма — контракт для CI)"),
221
+ )
222
+
223
+ const explainFlag = Flag.boolean("explain").pipe(
224
+ Flag.withDescription(
225
+ "К каждому изменению — какие узлы канонического AST разошлись и почему категория такая",
226
+ ),
227
+ )
228
+
229
+ /**
230
+ * JSON-форма плана (#3) — КОНТРАКТ для CI и ботов: изменения формы —
231
+ * semver-события пакета. Интервалы — ISO UTC, не epoch ms.
232
+ */
233
+ export const planToJson = (plan: Plan): unknown => ({
234
+ env: plan.env,
235
+ hasChanges: plan.hasChanges,
236
+ actions: plan.actions.map((action) => ({
237
+ name: action.name,
238
+ change: action.change,
239
+ // override оператора (#5) и реюз физики — аддитивные поля контракта
240
+ ...(action.reclassifiedFrom !== undefined
241
+ ? { reclassifiedFrom: action.reclassifiedFrom }
242
+ : {}),
243
+ ...(action.reusedFrom !== undefined ? { reusedFrom: action.reusedFrom } : {}),
244
+ fingerprint: action.fingerprint,
245
+ build: action.build,
246
+ backfill: action.backfill.map((range) => ({
247
+ start: new Date(range.start).toISOString(),
248
+ end: new Date(range.end).toISOString(),
249
+ })),
250
+ // причина категории (#4); diverged-пути — отладочная подсказка, не контракт
251
+ ...(action.explain !== undefined ? { explain: action.explain } : {}),
252
+ })),
253
+ })
254
+
255
+ const printJson = (payload: unknown) => Console.log(JSON.stringify(payload, null, 2))
256
+
170
257
  const formatRange = (range: { readonly start: number; readonly end: number }): string =>
171
258
  `[${new Date(range.start).toISOString().slice(0, 10)} … ${new Date(range.end).toISOString().slice(0, 10)})`
172
259
 
173
- const printPlan = (plan: Plan) =>
260
+ const printPlan = (plan: Plan, explain = false) =>
174
261
  Effect.gen(function* () {
175
262
  yield* Console.log(`план для окружения «${plan.env}»:`)
176
263
  for (const action of plan.actions) {
177
264
  const mark = CHANGE_MARK[action.change] ?? "?"
265
+ const overridden =
266
+ action.reclassifiedFrom !== undefined
267
+ ? ` [override: было ${action.reclassifiedFrom}]`
268
+ : ""
269
+ const reused =
270
+ action.change === "indirect" && action.reusedFrom !== undefined
271
+ ? " [физика реюзается]"
272
+ : ""
178
273
  const build = action.build ? " [сборка]" : ""
179
274
  const backfill =
180
275
  action.backfill.length > 0
181
276
  ? ` бэкфилл ${action.backfill.map(formatRange).join(", ")}`
182
277
  : ""
183
278
  yield* Console.log(
184
- ` ${mark} ${action.name} ${action.change} @${fp8(action.fingerprint)}${build}${backfill}`,
279
+ ` ${mark} ${action.name} ${action.change} @${fp8(action.fingerprint)}${overridden}${reused}${build}${backfill}`,
185
280
  )
281
+ if (explain && action.explain !== undefined) {
282
+ yield* Console.log(` почему: ${action.explain.reason}`)
283
+ if (action.explain.diverged.length > 0) {
284
+ yield* Console.log(` разошлось: ${action.explain.diverged.join(", ")}`)
285
+ }
286
+ }
186
287
  }
187
288
  if (!plan.hasChanges) yield* Console.log(" изменений нет")
188
289
  })
@@ -200,15 +301,24 @@ const initCommand = Command.make(
200
301
 
201
302
  const planCommand = Command.make(
202
303
  "plan",
203
- { env: Argument.string("env"), config: configFlag, forwardOnly: forwardOnlyFlag },
204
- ({ config, env, forwardOnly }) =>
304
+ {
305
+ env: Argument.string("env"),
306
+ config: configFlag,
307
+ forwardOnly: forwardOnlyFlag,
308
+ reclassify: reclassifyFlag,
309
+ json: jsonFlag,
310
+ explain: explainFlag,
311
+ },
312
+ ({ config, env, explain, forwardOnly, json, reclassify }) =>
205
313
  Effect.gen(function* () {
206
314
  const loaded = yield* loadConfig(config)
207
315
  const names = parseForwardOnly(forwardOnly)
316
+ const overrides = yield* parseReclassify(reclassify)
208
317
  const plan = yield* Efmesh.plan(env, loaded.models, {
209
318
  ...(names !== undefined ? { forwardOnly: names } : {}),
319
+ ...(overrides !== undefined ? { reclassify: overrides } : {}),
210
320
  }).pipe(Effect.provide(configLayers(loaded)))
211
- yield* printPlan(plan)
321
+ yield* json ? printJson(planToJson(plan)) : printPlan(plan, explain)
212
322
  }),
213
323
  ).pipe(Command.withDescription("Показать diff проекта против окружения, ничего не меняя"))
214
324
 
@@ -218,14 +328,16 @@ const applyCommand = Command.make(
218
328
  env: Argument.string("env"),
219
329
  config: configFlag,
220
330
  forwardOnly: forwardOnlyFlag,
331
+ reclassify: reclassifyFlag,
221
332
  jobs: jobsFlag,
222
333
  retries: retriesFlag,
223
334
  yes: yesFlag,
224
335
  },
225
- ({ config, env, forwardOnly, jobs, retries, yes }) =>
336
+ ({ config, env, forwardOnly, jobs, reclassify, retries, yes }) =>
226
337
  Effect.gen(function* () {
227
338
  const loaded = yield* loadConfig(config)
228
339
  const names = parseForwardOnly(forwardOnly)
340
+ const overrides = yield* parseReclassify(reclassify)
229
341
  const modelConcurrency = parseJobs(jobs)
230
342
  const retry = parseRetries(retries)
231
343
  // план и применение — под одним слоем и одним межпроцессным локом:
@@ -236,6 +348,7 @@ const applyCommand = Command.make(
236
348
  const graph = yield* buildGraph(loaded.models)
237
349
  const plan = yield* planChanges(env, graph, {
238
350
  ...(names !== undefined ? { forwardOnly: names } : {}),
351
+ ...(overrides !== undefined ? { reclassify: overrides } : {}),
239
352
  })
240
353
  yield* printPlan(plan)
241
354
  const decision = decideApply(plan.hasChanges, yes, process.stdin.isTTY === true)
@@ -335,15 +448,100 @@ const runCommand = Command.make(
335
448
  ),
336
449
  )
337
450
 
451
+ const printDataDiff = (report: DataDiffReport) =>
452
+ Effect.gen(function* () {
453
+ yield* Console.log(`данные ${report.envA} ↔ ${report.envB}:`)
454
+ if (report.models.length === 0) {
455
+ yield* Console.log(" общих материализуемых моделей нет")
456
+ return
457
+ }
458
+ for (const entry of report.models) {
459
+ if (entry.key === undefined) {
460
+ yield* Console.log(
461
+ ` · ${entry.model} A=${entry.rowsA} B=${entry.rowsB} без ключа (задайте grain) — только счётчики`,
462
+ )
463
+ } else {
464
+ const clean =
465
+ entry.onlyInA === 0 &&
466
+ entry.onlyInB === 0 &&
467
+ (entry.columns?.length ?? 0) === 0 &&
468
+ entry.rowsA === entry.rowsB
469
+ const sampled =
470
+ entry.sampledPercent !== undefined ? ` (выборка ${entry.sampledPercent}%)` : ""
471
+ 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}`,
473
+ )
474
+ for (const drift of entry.columns ?? []) {
475
+ yield* Console.log(
476
+ ` ${drift.column}: ${drift.mismatches} из ${entry.matched} (${(drift.rate * 100).toFixed(2)}%)`,
477
+ )
478
+ }
479
+ }
480
+ if (entry.columnsOnlyInA !== undefined) {
481
+ yield* Console.log(` колонки только в A: ${entry.columnsOnlyInA.join(", ")}`)
482
+ }
483
+ if (entry.columnsOnlyInB !== undefined) {
484
+ yield* Console.log(` колонки только в B: ${entry.columnsOnlyInB.join(", ")}`)
485
+ }
486
+ }
487
+ })
488
+
338
489
  const diffCommand = Command.make(
339
490
  "diff",
340
- { envA: Argument.string("envA"), envB: Argument.string("envB"), config: configFlag },
341
- ({ config, envA, envB }) =>
491
+ {
492
+ envA: Argument.string("envA"),
493
+ envB: Argument.string("envB"),
494
+ config: configFlag,
495
+ data: Flag.boolean("data").pipe(
496
+ Flag.withDescription(
497
+ "Сравнить ДАННЫЕ view-слоёв: счётчики строк, пересечение по ключу, расхождения по колонкам",
498
+ ),
499
+ ),
500
+ model: Flag.string("model").pipe(
501
+ Flag.withDefault(""),
502
+ Flag.withDescription("Только эти модели, через запятую (для --data)"),
503
+ ),
504
+ sample: Flag.string("sample").pipe(
505
+ Flag.withDefault(""),
506
+ Flag.withDescription(
507
+ "Процент 1–99: сравнивать детерминированную долю ключей (md5-бакеты; для --data)",
508
+ ),
509
+ ),
510
+ json: jsonFlag,
511
+ },
512
+ ({ config, data, envA, envB, json, model, sample }) =>
342
513
  Effect.gen(function* () {
343
514
  const loaded = yield* loadConfig(config)
515
+ if (data) {
516
+ const only = parseForwardOnly(model)
517
+ const percent = sample === "" ? undefined : Number(sample)
518
+ if (percent !== undefined && !(Number.isFinite(percent) && percent >= 1 && percent <= 99)) {
519
+ yield* Console.error("--sample ожидает процент от 1 до 99")
520
+ return yield* Effect.sync(() => {
521
+ process.exitCode = 1
522
+ })
523
+ }
524
+ const report = yield* Effect.gen(function* () {
525
+ // ducklake-витрины видны через ATTACH — как делает apply
526
+ if (loaded.ducklake !== undefined) {
527
+ const engine = yield* EngineAdapter
528
+ yield* engine.execute(ducklakeAttachSql(loaded.ducklake))
529
+ }
530
+ return yield* dataDiffEnvironments(envA, envB, loaded.models, {
531
+ ...(only !== undefined ? { models: only } : {}),
532
+ ...(percent !== undefined ? { samplePercent: percent } : {}),
533
+ })
534
+ }).pipe(Effect.provide(configLayers(loaded)))
535
+ yield* json ? printJson(report) : printDataDiff(report)
536
+ return
537
+ }
344
538
  const diff = yield* diffEnvironments(envA, envB).pipe(
345
539
  Effect.provide(configLayers(loaded)),
346
540
  )
541
+ if (json) {
542
+ yield* printJson(diff)
543
+ return
544
+ }
347
545
  for (const name of diff.onlyInA) yield* Console.log(`< ${name} только в ${envA}`)
348
546
  for (const name of diff.onlyInB) yield* Console.log(`> ${name} только в ${envB}`)
349
547
  for (const entry of diff.different) {
@@ -353,7 +551,85 @@ const diffCommand = Command.make(
353
551
  yield* Console.log("окружения идентичны")
354
552
  }
355
553
  }),
356
- ).pipe(Command.withDescription("Чем окружения отличаются (по state store)"))
554
+ ).pipe(
555
+ Command.withDescription("Чем окружения отличаются: версии (state store) или --data (данные)"),
556
+ )
557
+
558
+ const scheduleCommand = Command.make(
559
+ "schedule",
560
+ {
561
+ env: Argument.string("env").pipe(Argument.withDefault("")),
562
+ config: configFlag,
563
+ cron: Flag.string("cron").pipe(
564
+ Flag.withDefault("@hourly"),
565
+ Flag.withDescription("Cron-выражение или никнейм (@hourly, @daily, …)"),
566
+ ),
567
+ remove: Flag.boolean("remove").pipe(
568
+ Flag.withDescription("Снять регистрацию окружения из OS-шедулера"),
569
+ ),
570
+ list: Flag.boolean("list").pipe(
571
+ Flag.withDescription("Показать efmesh-записи OS-шедулера"),
572
+ ),
573
+ printSystemd: Flag.boolean("print-systemd").pipe(
574
+ Flag.withDescription(
575
+ "Напечатать systemd user-юниты вместо cron (Persistent=true догоняет пропуски; спасение без cron-демона)",
576
+ ),
577
+ ),
578
+ },
579
+ ({ config, cron, env, list, printSystemd, remove }) =>
580
+ Effect.gen(function* () {
581
+ if (list) {
582
+ const entries = yield* listSchedules()
583
+ if (entries.length === 0) yield* Console.log("efmesh-записей в OS-шедулере нет")
584
+ for (const entry of entries) yield* Console.log(` ${entry}`)
585
+ return
586
+ }
587
+ if (env === "") {
588
+ yield* Console.error("нужно окружение: efmesh schedule <env> [--cron …]")
589
+ return yield* Effect.sync(() => {
590
+ process.exitCode = 1
591
+ })
592
+ }
593
+ const configAbs = NodePath.resolve(process.cwd(), config)
594
+ const target = { project: NodePath.dirname(configAbs), config: configAbs, env }
595
+ if (printSystemd) {
596
+ const units = systemdUnits(target, cron)
597
+ yield* Console.log(`# ~/.config/systemd/user/${units.name}.service`)
598
+ yield* Console.log(units.service)
599
+ yield* Console.log(`# ~/.config/systemd/user/${units.name}.timer`)
600
+ yield* Console.log(units.timer)
601
+ yield* Console.log(
602
+ `# включить: systemctl --user daemon-reload && systemctl --user enable --now ${units.name}.timer`,
603
+ )
604
+ return
605
+ }
606
+ if (remove) {
607
+ const removed = yield* removeSchedule(target)
608
+ yield* Console.log(`снято: ${removed.title}`)
609
+ return
610
+ }
611
+ const registered = yield* registerSchedule(target, cron)
612
+ yield* Console.log(`зарегистрировано: ${registered.title} — «${cron}» (OS-шедулер)`)
613
+ yield* Console.log(`воркер: ${registered.worker}`)
614
+ yield* Console.log(
615
+ "журнал тиков: efmesh status " + env + "; NB: cron не догоняет пропущенные запуски — строже systemd-таймер (--print-systemd)",
616
+ )
617
+ }).pipe(
618
+ // reason — самое ценное (рецепт для оператора): наружу словами, не стектрейсом
619
+ Effect.catchTag("ScheduleError", (error) =>
620
+ Effect.gen(function* () {
621
+ yield* Console.error(`schedule: ${error.reason}`)
622
+ yield* Effect.sync(() => {
623
+ process.exitCode = 1
624
+ })
625
+ }),
626
+ ),
627
+ ),
628
+ ).pipe(
629
+ Command.withDescription(
630
+ "Зарегистрировать run <env> в OS-шедулере (Bun.cron: crontab/launchd/Task Scheduler)",
631
+ ),
632
+ )
357
633
 
358
634
  const janitorCommand = Command.make(
359
635
  "janitor",
@@ -383,6 +659,56 @@ const janitorCommand = Command.make(
383
659
  }),
384
660
  ).pipe(Command.withDescription("Убрать физику, на которую не ссылается ни одно окружение"))
385
661
 
662
+ const statusCommand = Command.make(
663
+ "status",
664
+ { env: Argument.string("env"), config: configFlag, json: jsonFlag },
665
+ ({ config, env, json }) =>
666
+ Effect.gen(function* () {
667
+ const loaded = yield* loadConfig(config)
668
+ const report = yield* environmentStatus(env, loaded.models).pipe(
669
+ Effect.provide(configLayers(loaded)),
670
+ )
671
+ if (json) {
672
+ yield* printJson(report)
673
+ return
674
+ }
675
+ if (report.models === 0) {
676
+ yield* Console.log(`окружение «${env}» не существует — его создаст первый apply`)
677
+ return
678
+ }
679
+ yield* Console.log(
680
+ `окружение «${env}»: моделей ${report.models}, промоушен ${report.promotedAt}, схема стора v${report.storeVersion}`,
681
+ )
682
+ if (report.lastPlan !== null) {
683
+ yield* Console.log(
684
+ `последний план: ${report.lastPlan.appliedAt} (${report.lastPlan.appliedBy || "неизвестно"})`,
685
+ )
686
+ }
687
+ for (const lag of report.lag) {
688
+ const state =
689
+ lag.missing === 0
690
+ ? `догнано до ${lag.doneUpTo}`
691
+ : `отстаёт на ${lag.missing} интервал(ов), догнано до ${lag.doneUpTo ?? "—"}`
692
+ const failed = lag.failed > 0 ? ` ⚠ failed-интервалов: ${lag.failed}` : ""
693
+ yield* Console.log(` ${lag.missing === 0 ? "✓" : "…"} ${lag.model} ${state}${failed}`)
694
+ }
695
+ if (report.ticks.length === 0) {
696
+ yield* Console.log("тиков run ещё не было")
697
+ } else {
698
+ yield* Console.log("последние тики run:")
699
+ for (const tick of report.ticks) {
700
+ const mark = tick.outcome === "ok" ? "✓" : tick.outcome === "error" ? "✗" : "…"
701
+ const ms = Date.parse(tick.finishedAt) - Date.parse(tick.startedAt)
702
+ yield* Console.log(
703
+ ` ${mark} ${tick.startedAt} ${tick.outcome} (${ms} мс)${tick.detail !== "" ? ` ${tick.detail}` : ""}`,
704
+ )
705
+ }
706
+ }
707
+ }),
708
+ ).pipe(
709
+ Command.withDescription("Что происходит в окружении: промоушен, отставание, тики run"),
710
+ )
711
+
386
712
  const auditCommand = Command.make(
387
713
  "audit",
388
714
  {
@@ -392,14 +718,26 @@ const auditCommand = Command.make(
392
718
  Flag.withDefault(""),
393
719
  Flag.withDescription("Только эти модели, через запятую (по умолчанию — все с аудитами)"),
394
720
  ),
721
+ json: jsonFlag,
395
722
  },
396
- ({ config, env, model }) =>
723
+ ({ config, env, model, json }) =>
397
724
  Effect.gen(function* () {
398
725
  const loaded = yield* loadConfig(config)
399
726
  const only = parseForwardOnly(model)
400
727
  const report = yield* auditEnvironment(env, loaded.models, only).pipe(
401
728
  Effect.provide(configLayers(loaded)),
402
729
  )
730
+ if (json) {
731
+ // отчёт целиком; exit-код по blocking сохраняется — stdout чистый JSON
732
+ yield* printJson(report)
733
+ if (report.blockingViolations > 0) {
734
+ return yield* new EnvironmentAuditError({
735
+ env,
736
+ blockingViolations: report.blockingViolations,
737
+ })
738
+ }
739
+ return
740
+ }
403
741
  if (report.results.length === 0) {
404
742
  yield* Console.log("аудитов нет — нечего проверять")
405
743
  return
@@ -507,11 +845,13 @@ export const rootCommand = Command.make("efmesh").pipe(
507
845
  planCommand,
508
846
  applyCommand,
509
847
  runCommand,
848
+ statusCommand,
510
849
  auditCommand,
511
850
  renderCommand,
512
851
  graphCommand,
513
852
  lineageCommand,
514
853
  diffCommand,
854
+ scheduleCommand,
515
855
  janitorCommand,
516
856
  migrateCommand,
517
857
  ]),
package/src/efmesh.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  planChanges,
17
17
  type FingerprintVersionError,
18
18
  type ForwardOnlyError,
19
+ type ReclassifyError,
19
20
  type InvalidEnvironmentError,
20
21
  type Plan,
21
22
  type PlanOptions,
@@ -42,6 +43,7 @@ export const Efmesh = {
42
43
  | StateError
43
44
  | InvalidEnvironmentError
44
45
  | ForwardOnlyError
46
+ | ReclassifyError
45
47
  | FingerprintVersionError
46
48
  | EngineError
47
49
  | SqlParseError
package/src/index.ts CHANGED
@@ -41,8 +41,32 @@ export {
41
41
  type AuditRunReport,
42
42
  type AuditRunResult,
43
43
  } from "./plan/audit-run.ts"
44
- export { diffEnvironments, type EnvDiff } from "./plan/diff.ts"
44
+ export {
45
+ dataDiffEnvironments,
46
+ DataDiffError,
47
+ diffEnvironments,
48
+ type ColumnDrift,
49
+ type DataDiffOptions,
50
+ type DataDiffReport,
51
+ type EnvDiff,
52
+ type ModelDataDiff,
53
+ } from "./plan/diff.ts"
54
+ export {
55
+ environmentStatus,
56
+ type ModelLag,
57
+ type StatusOptions,
58
+ type StatusReport,
59
+ } from "./plan/status.ts"
45
60
  export { formatLineage, lineage, LineageError, type LineageNode } from "./plan/lineage.ts"
61
+ export {
62
+ listSchedules,
63
+ registerSchedule,
64
+ removeSchedule,
65
+ ScheduleError,
66
+ scheduleTitle,
67
+ systemdUnits,
68
+ type ScheduleTarget,
69
+ } from "./plan/schedule.ts"
46
70
 
47
71
  // — план и применение: типы результата и опций —
48
72
  export type {
@@ -51,7 +75,13 @@ export type {
51
75
  PlanAction,
52
76
  PlanOptions,
53
77
  } from "./plan/planner.ts"
54
- export { FingerprintVersionError, ForwardOnlyError, InvalidEnvironmentError } from "./plan/planner.ts"
78
+ export type { ChangeExplanation } from "./plan/explain.ts"
79
+ export {
80
+ FingerprintVersionError,
81
+ ForwardOnlyError,
82
+ InvalidEnvironmentError,
83
+ ReclassifyError,
84
+ } from "./plan/planner.ts"
55
85
  export type { AppliedPlan, ApplyError, ApplyOptions } from "./plan/executor.ts"
56
86
  export {
57
87
  AttachNotConfiguredError,
@@ -8,7 +8,10 @@
8
8
 
9
9
  type AstNode = Record<string, unknown>
10
10
 
11
- const topSelect = (astJson: string): { list: ReadonlyArray<string>; rest: string } | null => {
11
+ /** Верхний SELECT канона: список колонок + остальное дерево (для explain.ts). */
12
+ export const topSelect = (
13
+ astJson: string,
14
+ ): { list: ReadonlyArray<string>; rest: string } | null => {
12
15
  try {
13
16
  const ast = JSON.parse(astJson) as {
14
17
  readonly statements?: ReadonlyArray<{ readonly node?: AstNode }>