@avytheone/efmesh 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@avytheone/efmesh",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "sqlmesh-like data transformation framework on TypeScript, Bun and Effect",
5
+ "license": "MIT",
6
+ "author": "Alexey Yakimanskiy",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/avytheone/efmesh.git"
10
+ },
11
+ "homepage": "https://github.com/avytheone/efmesh#readme",
12
+ "bugs": "https://github.com/avytheone/efmesh/issues",
13
+ "keywords": [
14
+ "data-engineering",
15
+ "sqlmesh",
16
+ "dbt",
17
+ "duckdb",
18
+ "postgres",
19
+ "effect",
20
+ "bun",
21
+ "data-transformation",
22
+ "incremental",
23
+ "lakehouse"
24
+ ],
25
+ "files": [
26
+ "src",
27
+ "SPEC.md",
28
+ "CHANGELOG.md",
29
+ "README.ru.md"
30
+ ],
31
+ "type": "module",
32
+ "module": "src/index.ts",
33
+ "bin": {
34
+ "efmesh": "src/bin.ts"
35
+ },
36
+ "exports": {
37
+ ".": "./src/index.ts",
38
+ "./testing": "./src/testing/index.ts"
39
+ },
40
+ "scripts": {
41
+ "check": "tsc --noEmit",
42
+ "test": "bun test"
43
+ },
44
+ "dependencies": {
45
+ "@duckdb/node-api": "1.5.4-r.1",
46
+ "@effect/platform-bun": "4.0.0-beta.98",
47
+ "libpg-query": "^17.7.3"
48
+ },
49
+ "peerDependencies": {
50
+ "effect": "4.0.0-beta.98"
51
+ },
52
+ "devDependencies": {
53
+ "@types/bun": "latest",
54
+ "effect": "4.0.0-beta.98",
55
+ "typescript": "^7.0.2"
56
+ }
57
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bun
2
+ import { BunRuntime, BunServices } from "@effect/platform-bun"
3
+ import { Effect } from "effect"
4
+ import { Command } from "effect/unstable/cli"
5
+ import { rootCommand } from "./cli.ts"
6
+ import { version } from "../package.json"
7
+
8
+ rootCommand.pipe(
9
+ Command.run({ version }),
10
+ Effect.provide(BunServices.layer),
11
+ BunRuntime.runMain,
12
+ )
package/src/cli.ts ADDED
@@ -0,0 +1,518 @@
1
+ import { writeFileSync } from "node:fs"
2
+ import * as NodePath from "node:path"
3
+ import { Console, Data, Effect, Layer } from "effect"
4
+ import { Argument, Command, Flag } from "effect/unstable/cli"
5
+ import type { EfmeshConfig } from "./config.ts"
6
+ import { buildGraph } from "./core/graph.ts"
7
+ import type { AnyModel } from "./core/model.ts"
8
+ import { discoverModels, DiscoveryError, DiscoveryConflictError } from "./discovery.ts"
9
+ import { Efmesh } from "./efmesh.ts"
10
+ import { scaffold } from "./init.ts"
11
+ import { DuckDBEngineLive } from "./engine/duckdb.ts"
12
+ import { PostgresEngineLive } from "./engine/postgres.ts"
13
+ import { PostgresStateLive } from "./state/postgres.ts"
14
+ import { auditEnvironment, EnvironmentAuditError } from "./plan/audit-run.ts"
15
+ import { diffEnvironments } from "./plan/diff.ts"
16
+ import { renderGraphHtml } from "./plan/graph-html.ts"
17
+ import { formatLineage, lineage, LineageError } from "./plan/lineage.ts"
18
+ import { janitor } from "./plan/janitor.ts"
19
+ import { fp8 } from "./plan/naming.ts"
20
+ import { envLockName, withStateLock } from "./plan/lock.ts"
21
+ import { applyPlan } from "./plan/executor.ts"
22
+ import { run } from "./plan/run.ts"
23
+ import { planChanges, type Plan } from "./plan/planner.ts"
24
+ import { migratePostgresState } from "./state/postgres.ts"
25
+ import { migrateSqliteState, SqliteStateLive } from "./state/sqlite.ts"
26
+
27
+ export class ConfigLoadError extends Data.TaggedError("ConfigLoadError")<{
28
+ readonly path: string
29
+ readonly reason: string
30
+ }> {}
31
+
32
+ /** Конфиг с уже собранным списком моделей: явные + найденные discovery. */
33
+ type LoadedConfig = EfmeshConfig & { readonly models: ReadonlyArray<AnyModel> }
34
+
35
+ const loadConfig = (
36
+ configPath: string,
37
+ ): Effect.Effect<LoadedConfig, ConfigLoadError | DiscoveryError | DiscoveryConflictError> =>
38
+ Effect.gen(function* () {
39
+ const absolute = NodePath.resolve(process.cwd(), configPath)
40
+ const config = yield* Effect.tryPromise({
41
+ try: async () => {
42
+ const module = (await import(absolute)) as { default?: EfmeshConfig }
43
+ if (
44
+ module.default === undefined ||
45
+ (!Array.isArray(module.default.models) && module.default.discovery === undefined)
46
+ ) {
47
+ throw new Error("конфиг должен экспортировать default с models и/или discovery")
48
+ }
49
+ return module.default
50
+ },
51
+ catch: (cause) => new ConfigLoadError({ path: configPath, reason: String(cause) }),
52
+ })
53
+ const explicit = config.models ?? []
54
+ if (config.discovery === undefined) return { ...config, models: explicit }
55
+ // маски — относительно конфига: проект переносим независимо от cwd
56
+ const discovered = yield* discoverModels(config.discovery, NodePath.dirname(absolute))
57
+ const seen = new Set(explicit)
58
+ const names = new Map(explicit.map((model) => [model.name.full, "models в конфиге"]))
59
+ const merged = [...explicit]
60
+ for (const model of discovered) {
61
+ if (seen.has(model)) continue
62
+ const already = names.get(model.name.full)
63
+ if (already !== undefined) {
64
+ return yield* new DiscoveryConflictError({ name: model.name.full, files: [already, "discovery"] })
65
+ }
66
+ names.set(model.name.full, "discovery")
67
+ merged.push(model)
68
+ }
69
+ return { ...config, models: merged }
70
+ })
71
+
72
+ /** Слои движка и состояния из конфига — общие для plan/apply. */
73
+ const configLayers = (config: EfmeshConfig) =>
74
+ Layer.mergeAll(
75
+ config.engine?.url !== undefined
76
+ ? PostgresEngineLive({
77
+ url: config.engine.url,
78
+ ...(config.engine.max !== undefined ? { max: config.engine.max } : {}),
79
+ })
80
+ : DuckDBEngineLive({ path: config.engine?.path ?? "efmesh.duckdb" }),
81
+ config.state?.url !== undefined
82
+ ? PostgresStateLive({ url: config.state.url })
83
+ : SqliteStateLive({ path: config.state?.path ?? "efmesh.state.sqlite" }),
84
+ )
85
+
86
+ const configFlag = Flag.string("config").pipe(
87
+ Flag.withDefault("efmesh.config.ts"),
88
+ Flag.withDescription("Путь к efmesh.config.ts"),
89
+ )
90
+
91
+ const CHANGE_MARK: Record<string, string> = {
92
+ added: "+",
93
+ breaking: "!",
94
+ "non-breaking": "~",
95
+ indirect: "↻",
96
+ "forward-only": "→",
97
+ removed: "-",
98
+ unchanged: "·",
99
+ }
100
+
101
+ const forwardOnlyFlag = Flag.string("forward-only").pipe(
102
+ Flag.withDefault(""),
103
+ Flag.withDescription(
104
+ "Модели через запятую: изменения применяются forward-only — физика и история переиспользуются",
105
+ ),
106
+ )
107
+
108
+ const jobsFlag = Flag.string("jobs").pipe(
109
+ Flag.withDefault(""),
110
+ Flag.withDescription(
111
+ "Сколько моделей строить одновременно (DAG-конкурентность; на DuckDB всегда 1)",
112
+ ),
113
+ )
114
+
115
+ const parseJobs = (value: string): number | undefined => {
116
+ const jobs = Number(value)
117
+ return value !== "" && Number.isFinite(jobs) && jobs >= 1 ? Math.floor(jobs) : undefined
118
+ }
119
+
120
+ const retriesFlag = Flag.string("retries").pipe(
121
+ Flag.withDefault(""),
122
+ Flag.withDescription(
123
+ "Сколько раз ретраить упавший батч бэкфилла (экспоненциальная пауза; по умолчанию 0)",
124
+ ),
125
+ )
126
+
127
+ const parseRetries = (value: string): { readonly attempts: number } | undefined => {
128
+ const attempts = Number(value)
129
+ return value !== "" && Number.isFinite(attempts) && attempts >= 1
130
+ ? { attempts: Math.floor(attempts) }
131
+ : undefined
132
+ }
133
+
134
+ const parseForwardOnly = (value: string): ReadonlyArray<string> | undefined => {
135
+ const names = value
136
+ .split(",")
137
+ .map((name) => name.trim())
138
+ .filter((name) => name !== "")
139
+ return names.length > 0 ? names : undefined
140
+ }
141
+
142
+ const yesFlag = Flag.boolean("yes").pipe(
143
+ Flag.withAlias("y"),
144
+ Flag.withDescription("Применить без подтверждения (не-TTY подтверждения не спрашивает)"),
145
+ )
146
+
147
+ /** y/yes/д/да — регистронезависимо; всё остальное (включая пусто) — отказ. */
148
+ export const isAffirmative = (answer: string | null): boolean =>
149
+ ["y", "yes", "д", "да"].includes((answer ?? "").trim().toLowerCase())
150
+
151
+ /**
152
+ * Exit-код «работа ждёт человека» (F6): план требует подтверждения в не-TTY
153
+ * или run упёрся в структурные изменения. Алертинг обязан отличать это
154
+ * штатное состояние от настоящих ошибок (код 1).
155
+ */
156
+ export const EXIT_AWAITING_HUMAN = 2
157
+
158
+ /**
159
+ * Судьба показанного плана (SPEC §5.1, ужесточено в F6): без изменений или
160
+ * с --yes — применять; изменения в TTY — спросить человека; изменения в
161
+ * не-TTY (CI, cron, пайп) — ОТКАЗ: молча применять план, который никто не
162
+ * видел, нельзя, нужен явный --yes.
163
+ */
164
+ export const decideApply = (
165
+ hasChanges: boolean,
166
+ yes: boolean,
167
+ tty: boolean,
168
+ ): "apply" | "ask" | "refuse" => (!hasChanges || yes ? "apply" : tty ? "ask" : "refuse")
169
+
170
+ const formatRange = (range: { readonly start: number; readonly end: number }): string =>
171
+ `[${new Date(range.start).toISOString().slice(0, 10)} … ${new Date(range.end).toISOString().slice(0, 10)})`
172
+
173
+ const printPlan = (plan: Plan) =>
174
+ Effect.gen(function* () {
175
+ yield* Console.log(`план для окружения «${plan.env}»:`)
176
+ for (const action of plan.actions) {
177
+ const mark = CHANGE_MARK[action.change] ?? "?"
178
+ const build = action.build ? " [сборка]" : ""
179
+ const backfill =
180
+ action.backfill.length > 0
181
+ ? ` бэкфилл ${action.backfill.map(formatRange).join(", ")}`
182
+ : ""
183
+ yield* Console.log(
184
+ ` ${mark} ${action.name} ${action.change} @${fp8(action.fingerprint)}${build}${backfill}`,
185
+ )
186
+ }
187
+ if (!plan.hasChanges) yield* Console.log(" изменений нет")
188
+ })
189
+
190
+ const initCommand = Command.make(
191
+ "init",
192
+ { dir: Argument.string("dir").pipe(Argument.withDefault(".")) },
193
+ ({ dir }) =>
194
+ Effect.gen(function* () {
195
+ const created = yield* scaffold(dir)
196
+ for (const file of created) yield* Console.log(`создан ${file}`)
197
+ yield* Console.log("дальше: bunx efmesh plan dev && bunx efmesh apply dev")
198
+ }),
199
+ ).pipe(Command.withDescription("Скаффолд проекта: конфиг, модели-пример, seed"))
200
+
201
+ const planCommand = Command.make(
202
+ "plan",
203
+ { env: Argument.string("env"), config: configFlag, forwardOnly: forwardOnlyFlag },
204
+ ({ config, env, forwardOnly }) =>
205
+ Effect.gen(function* () {
206
+ const loaded = yield* loadConfig(config)
207
+ const names = parseForwardOnly(forwardOnly)
208
+ const plan = yield* Efmesh.plan(env, loaded.models, {
209
+ ...(names !== undefined ? { forwardOnly: names } : {}),
210
+ }).pipe(Effect.provide(configLayers(loaded)))
211
+ yield* printPlan(plan)
212
+ }),
213
+ ).pipe(Command.withDescription("Показать diff проекта против окружения, ничего не меняя"))
214
+
215
+ const applyCommand = Command.make(
216
+ "apply",
217
+ {
218
+ env: Argument.string("env"),
219
+ config: configFlag,
220
+ forwardOnly: forwardOnlyFlag,
221
+ jobs: jobsFlag,
222
+ retries: retriesFlag,
223
+ yes: yesFlag,
224
+ },
225
+ ({ config, env, forwardOnly, jobs, retries, yes }) =>
226
+ Effect.gen(function* () {
227
+ const loaded = yield* loadConfig(config)
228
+ const names = parseForwardOnly(forwardOnly)
229
+ const modelConcurrency = parseJobs(jobs)
230
+ const retry = parseRetries(retries)
231
+ // план и применение — под одним слоем и одним межпроцессным локом:
232
+ // применяется ровно тот план, который показан и подтверждён, и никто
233
+ // (второй apply, cron с run) не вклинится между ними (SPEC §14.6);
234
+ // цена — лок держится и пока человек думает над подтверждением
235
+ yield* Effect.gen(function* () {
236
+ const graph = yield* buildGraph(loaded.models)
237
+ const plan = yield* planChanges(env, graph, {
238
+ ...(names !== undefined ? { forwardOnly: names } : {}),
239
+ })
240
+ yield* printPlan(plan)
241
+ const decision = decideApply(plan.hasChanges, yes, process.stdin.isTTY === true)
242
+ if (decision === "refuse") {
243
+ yield* Console.error(
244
+ "план меняет модели, а подтвердить некому (не-TTY): добавьте --yes",
245
+ )
246
+ yield* Effect.sync(() => {
247
+ process.exitCode = EXIT_AWAITING_HUMAN
248
+ })
249
+ return
250
+ }
251
+ if (
252
+ decision === "ask" &&
253
+ !isAffirmative(globalThis.prompt("применить план? [y/N]"))
254
+ ) {
255
+ yield* Console.log("применение отменено")
256
+ return
257
+ }
258
+ const applied = yield* applyPlan(plan, graph, {
259
+ ...(loaded.lake !== undefined ? { lakePath: loaded.lake.path } : {}),
260
+ ...(loaded.ducklake !== undefined ? { ducklake: loaded.ducklake } : {}),
261
+ ...(loaded.attach !== undefined ? { attach: loaded.attach } : {}),
262
+ ...(modelConcurrency !== undefined ? { modelConcurrency } : {}),
263
+ ...(retry !== undefined ? { retry } : {}),
264
+ })
265
+ yield* Console.log(
266
+ applied.built.length > 0
267
+ ? `собрано: ${applied.built.join(", ")}`
268
+ : "сборка не потребовалась (только view-swap)",
269
+ )
270
+ yield* Console.log(`окружение «${applied.plan.env}» промоутнуто`)
271
+ }).pipe(withStateLock(envLockName(env)), Effect.provide(configLayers(loaded)))
272
+ }),
273
+ ).pipe(Command.withDescription("Применить план: собрать физику и переключить view"))
274
+
275
+ const renderCommand = Command.make(
276
+ "render",
277
+ {
278
+ model: Argument.string("model"),
279
+ config: configFlag,
280
+ env: Flag.string("env").pipe(
281
+ Flag.withDefault(""),
282
+ Flag.withDescription("Рендер против view-слоя окружения вместо логических имён"),
283
+ ),
284
+ },
285
+ ({ config, env, model }) =>
286
+ Effect.gen(function* () {
287
+ const loaded = yield* loadConfig(config)
288
+ const sql = env === ""
289
+ ? yield* Efmesh.render(loaded.models, model)
290
+ : yield* Efmesh.renderFor(loaded.models, model, env)
291
+ yield* Console.log(sql.trim())
292
+ }),
293
+ ).pipe(Command.withDescription("Показать итоговый SQL модели"))
294
+
295
+ const runCommand = Command.make(
296
+ "run",
297
+ { env: Argument.string("env"), config: configFlag, jobs: jobsFlag, retries: retriesFlag },
298
+ ({ config, env, jobs, retries }) =>
299
+ Effect.gen(function* () {
300
+ const loaded = yield* loadConfig(config)
301
+ const modelConcurrency = parseJobs(jobs)
302
+ const retry = parseRetries(retries)
303
+ const applied = yield* run(env, loaded.models, {
304
+ ...(loaded.lake !== undefined ? { lakePath: loaded.lake.path } : {}),
305
+ ...(loaded.ducklake !== undefined ? { ducklake: loaded.ducklake } : {}),
306
+ ...(loaded.attach !== undefined ? { attach: loaded.attach } : {}),
307
+ ...(modelConcurrency !== undefined ? { modelConcurrency } : {}),
308
+ ...(retry !== undefined ? { retry } : {}),
309
+ }).pipe(
310
+ Effect.provide(configLayers(loaded)),
311
+ // структурные изменения — штатное «ждёт человека с apply», не сбой:
312
+ // алертинг различает по exit-коду 2 (F6)
313
+ Effect.catchTag("RunBlockedByChangesError", (blocked) =>
314
+ Effect.gen(function* () {
315
+ yield* Console.error(
316
+ `run ${blocked.env}: есть неприменённые изменения — нужен apply:\n ${blocked.changes.join("\n ")}`,
317
+ )
318
+ yield* Effect.sync(() => {
319
+ process.exitCode = EXIT_AWAITING_HUMAN
320
+ })
321
+ return undefined
322
+ }),
323
+ ),
324
+ )
325
+ if (applied === undefined) return
326
+ yield* Console.log(
327
+ applied.built.length > 0
328
+ ? `обработано: ${applied.built.join(", ")}`
329
+ : "новых интервалов нет",
330
+ )
331
+ }),
332
+ ).pipe(
333
+ Command.withDescription(
334
+ "Тик планировщика: догнать интервалы существующих версий (изменения — через apply)",
335
+ ),
336
+ )
337
+
338
+ const diffCommand = Command.make(
339
+ "diff",
340
+ { envA: Argument.string("envA"), envB: Argument.string("envB"), config: configFlag },
341
+ ({ config, envA, envB }) =>
342
+ Effect.gen(function* () {
343
+ const loaded = yield* loadConfig(config)
344
+ const diff = yield* diffEnvironments(envA, envB).pipe(
345
+ Effect.provide(configLayers(loaded)),
346
+ )
347
+ for (const name of diff.onlyInA) yield* Console.log(`< ${name} только в ${envA}`)
348
+ for (const name of diff.onlyInB) yield* Console.log(`> ${name} только в ${envB}`)
349
+ for (const entry of diff.different) {
350
+ yield* Console.log(`≠ ${entry.name} ${envA}@${entry.a} vs ${envB}@${entry.b}`)
351
+ }
352
+ if (diff.onlyInA.length + diff.onlyInB.length + diff.different.length === 0) {
353
+ yield* Console.log("окружения идентичны")
354
+ }
355
+ }),
356
+ ).pipe(Command.withDescription("Чем окружения отличаются (по state store)"))
357
+
358
+ const janitorCommand = Command.make(
359
+ "janitor",
360
+ {
361
+ config: configFlag,
362
+ ttl: Flag.string("ttl").pipe(
363
+ Flag.withDefault("7"),
364
+ Flag.withDescription("Сколько дней осиротевшая физика живёт до сноса"),
365
+ ),
366
+ },
367
+ ({ config, ttl }) =>
368
+ Effect.gen(function* () {
369
+ const loaded = yield* loadConfig(config)
370
+ const report = yield* janitor({
371
+ ttlDays: Number(ttl),
372
+ ...(loaded.lake !== undefined ? { lakePath: loaded.lake.path } : {}),
373
+ ...(loaded.ducklake !== undefined ? { ducklake: loaded.ducklake } : {}),
374
+ }).pipe(Effect.provide(configLayers(loaded)))
375
+ yield* Console.log(
376
+ report.removed.length > 0
377
+ ? `снесено: ${report.removed.join(", ")}`
378
+ : "осиротевшей физики старше ttl нет",
379
+ )
380
+ if (report.kept.length > 0) {
381
+ yield* Console.log(`осиротело, но моложе ttl: ${report.kept.join(", ")}`)
382
+ }
383
+ }),
384
+ ).pipe(Command.withDescription("Убрать физику, на которую не ссылается ни одно окружение"))
385
+
386
+ const auditCommand = Command.make(
387
+ "audit",
388
+ {
389
+ env: Argument.string("env"),
390
+ config: configFlag,
391
+ model: Flag.string("model").pipe(
392
+ Flag.withDefault(""),
393
+ Flag.withDescription("Только эти модели, через запятую (по умолчанию — все с аудитами)"),
394
+ ),
395
+ },
396
+ ({ config, env, model }) =>
397
+ Effect.gen(function* () {
398
+ const loaded = yield* loadConfig(config)
399
+ const only = parseForwardOnly(model)
400
+ const report = yield* auditEnvironment(env, loaded.models, only).pipe(
401
+ Effect.provide(configLayers(loaded)),
402
+ )
403
+ if (report.results.length === 0) {
404
+ yield* Console.log("аудитов нет — нечего проверять")
405
+ return
406
+ }
407
+ for (const result of report.results) {
408
+ const mark = result.violations === 0 ? "✓" : result.blocking ? "✗" : "⚠"
409
+ const tail =
410
+ result.violations > 0
411
+ ? ` ${result.violations} нарушений${result.blocking ? "" : " (warn)"}`
412
+ : ""
413
+ yield* Console.log(` ${mark} ${result.model} ${result.audit}${tail}`)
414
+ }
415
+ if (report.blockingViolations > 0) {
416
+ return yield* new EnvironmentAuditError({
417
+ env,
418
+ blockingViolations: report.blockingViolations,
419
+ })
420
+ }
421
+ yield* Console.log(`blocking-аудиты окружения «${env}» чисты`)
422
+ }),
423
+ ).pipe(
424
+ Command.withDescription("Прогнать аудиты по view-слою окружения, ничего не меняя"),
425
+ )
426
+
427
+ const migrateCommand = Command.make(
428
+ "migrate",
429
+ { config: configFlag },
430
+ ({ config }) =>
431
+ Effect.gen(function* () {
432
+ const loaded = yield* loadConfig(config)
433
+ const report = yield* (loaded.state?.url !== undefined
434
+ ? migratePostgresState({ url: loaded.state.url })
435
+ : migrateSqliteState({ path: loaded.state?.path ?? "efmesh.state.sqlite" }))
436
+ yield* Console.log(
437
+ report.from === report.to
438
+ ? `state store уже на версии ${report.to}`
439
+ : `state store: версия ${report.from} → ${report.to}`,
440
+ )
441
+ if (report.backup !== undefined) {
442
+ yield* Console.log(`копия старого стора: ${report.backup}`)
443
+ }
444
+ }),
445
+ ).pipe(Command.withDescription("Догнать схему state store до текущей версии"))
446
+
447
+ const graphCommand = Command.make(
448
+ "graph",
449
+ {
450
+ config: configFlag,
451
+ html: Flag.string("html").pipe(
452
+ Flag.withDefault(""),
453
+ Flag.withDescription("Записать DAG самодостаточной HTML-страницей по указанному пути"),
454
+ ),
455
+ },
456
+ ({ config, html }) =>
457
+ Effect.gen(function* () {
458
+ const loaded = yield* loadConfig(config)
459
+ const graph = yield* buildGraph(loaded.models)
460
+ if (html !== "") {
461
+ yield* Effect.sync(() => writeFileSync(html, renderGraphHtml(graph)))
462
+ yield* Console.log(`DAG записан: ${html}`)
463
+ return
464
+ }
465
+ for (const name of graph.order) {
466
+ const model = graph.models.get(name)!
467
+ const deps = model.deps.size > 0 ? ` ← ${[...model.deps].sort().join(", ")}` : ""
468
+ yield* Console.log(`${name} (${model.kind._tag})${deps}`)
469
+ }
470
+ }),
471
+ ).pipe(Command.withDescription("DAG моделей в топологическом порядке (или --html файл)"))
472
+
473
+ const lineageCommand = Command.make(
474
+ "lineage",
475
+ { target: Argument.string("model[.column]"), config: configFlag },
476
+ ({ config, target }) =>
477
+ Effect.gen(function* () {
478
+ const loaded = yield* loadConfig(config)
479
+ const segments = target.split(".")
480
+ if (segments.length < 2) {
481
+ return yield* new LineageError({
482
+ model: target,
483
+ reason: "ожидается <схема>.<таблица>[.<колонка>]",
484
+ })
485
+ }
486
+ const modelName = `${segments[0]}.${segments[1]}`
487
+ const graph = yield* buildGraph(loaded.models)
488
+ const model = graph.models.get(modelName)
489
+ if (model === undefined) {
490
+ return yield* new LineageError({ model: modelName, reason: "модели нет в проекте" })
491
+ }
492
+ const columns =
493
+ segments.length >= 3 ? [segments.slice(2).join(".")] : Object.keys(model.schema.fields)
494
+ for (const column of columns) {
495
+ const tree = yield* lineage(graph, modelName, column).pipe(
496
+ Effect.provide(configLayers(loaded)),
497
+ )
498
+ for (const line of formatLineage(tree)) yield* Console.log(line)
499
+ }
500
+ }),
501
+ ).pipe(Command.withDescription("Колоночный lineage до сырьевых колонок (best-effort)"))
502
+
503
+ export const rootCommand = Command.make("efmesh").pipe(
504
+ Command.withDescription("sqlmesh на bun, typescript и Effect"),
505
+ Command.withSubcommands([
506
+ initCommand,
507
+ planCommand,
508
+ applyCommand,
509
+ runCommand,
510
+ auditCommand,
511
+ renderCommand,
512
+ graphCommand,
513
+ lineageCommand,
514
+ diffCommand,
515
+ janitorCommand,
516
+ migrateCommand,
517
+ ]),
518
+ )
package/src/config.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { AnyModel } from "./core/model.ts"
2
+
3
+ /**
4
+ * Конфиг проекта — `efmesh.config.ts` (SPEC §11): типизированный TS-модуль,
5
+ * никакого YAML. CLI импортирует его и собирает слои движка и состояния.
6
+ */
7
+ export interface EfmeshConfig {
8
+ /** Модели значениями; можно вместе с discovery — дубликат имени = ошибка. */
9
+ readonly models?: ReadonlyArray<AnyModel>
10
+ /**
11
+ * Glob-маски файлов моделей относительно конфига (SPEC §12):
12
+ * все экспорты-модели найденных файлов попадают в проект.
13
+ */
14
+ readonly discovery?: string | ReadonlyArray<string>
15
+ readonly engine?: {
16
+ /** Путь к файлу DuckDB; по умолчанию `efmesh.duckdb` рядом с конфигом. */
17
+ readonly path?: string
18
+ /** postgres://… — движком становится Postgres (F3); path игнорируется. */
19
+ readonly url?: string
20
+ /** Размер пула соединений Postgres — параллелизм бэкфилла (SPEC §5.3). */
21
+ readonly max?: number
22
+ }
23
+ readonly state?: {
24
+ /** Путь к SQLite-файлу состояния; по умолчанию `efmesh.state.sqlite`. */
25
+ readonly path?: string
26
+ /** postgres://… — состояние в Postgres (схема efmesh_state, SPEC §6). */
27
+ readonly url?: string
28
+ }
29
+ readonly lake?: {
30
+ /** Корень parquet-озера (SPEC §3.3) — локальная директория или s3://…. */
31
+ readonly path: string
32
+ }
33
+ /** DuckLake-каталог для target: "ducklake" (SPEC §14.5). DuckDB-only. */
34
+ readonly ducklake?: {
35
+ /** Путь к SQLite-файлу каталога DuckLake. */
36
+ readonly catalog: string
37
+ /** Куда DuckLake кладёт parquet-данные; по умолчанию — рядом с каталогом. */
38
+ readonly dataPath?: string
39
+ }
40
+ /** ATTACH-базы по алиасам (SPEC §9.3): url + опции (`TYPE postgres` и т.п.). */
41
+ readonly attach?: Readonly<Record<string, { readonly url: string; readonly options?: string }>>
42
+ }
43
+
44
+ export const defineConfig = (config: EfmeshConfig): EfmeshConfig => config