@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/CHANGELOG.md +139 -89
- package/README.md +61 -9
- package/README.ru.md +57 -5
- package/SPEC.md +542 -459
- package/package.json +4 -1
- package/src/cli.ts +352 -12
- package/src/efmesh.ts +2 -0
- package/src/index.ts +32 -2
- package/src/plan/categorize.ts +4 -1
- package/src/plan/diff.ts +221 -1
- package/src/plan/executor.ts +20 -0
- package/src/plan/explain.ts +114 -0
- package/src/plan/fingerprint.ts +60 -11
- package/src/plan/planner.ts +175 -6
- package/src/plan/run.ts +50 -12
- package/src/plan/schedule.ts +212 -0
- package/src/plan/status.ts +95 -0
- package/src/state/postgres.ts +49 -0
- package/src/state/sqlite.ts +47 -0
- package/src/state/store.ts +33 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avytheone/efmesh",
|
|
3
|
-
"version": "0.
|
|
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 {
|
|
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
|
-
{
|
|
204
|
-
|
|
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
|
-
{
|
|
341
|
-
|
|
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(
|
|
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 {
|
|
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 {
|
|
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,
|
package/src/plan/categorize.ts
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
type AstNode = Record<string, unknown>
|
|
10
10
|
|
|
11
|
-
|
|
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 }>
|