@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/CHANGELOG.md +35 -0
- package/README.md +22 -0
- package/README.ru.md +23 -0
- package/SPEC.md +1 -1
- package/package.json +1 -1
- package/src/bin.ts +39 -2
- package/src/cli.ts +204 -124
- package/src/config.ts +15 -15
- package/src/core/audit.ts +15 -11
- package/src/core/errors.ts +37 -7
- package/src/core/graph.ts +6 -6
- package/src/core/interval.ts +18 -18
- package/src/core/model.ts +76 -75
- package/src/core/sql.ts +21 -21
- package/src/discovery.ts +16 -8
- package/src/efmesh.ts +26 -15
- package/src/engine/adapter.ts +29 -12
- package/src/engine/duckdb.ts +7 -7
- package/src/engine/postgres.ts +17 -17
- package/src/error-text.ts +35 -0
- package/src/index.ts +12 -11
- package/src/init.ts +100 -28
- package/src/plan/audit-run.ts +21 -13
- package/src/plan/categorize.ts +7 -7
- package/src/plan/contract.ts +23 -19
- package/src/plan/diff.ts +33 -29
- package/src/plan/executor.ts +207 -125
- package/src/plan/explain.ts +29 -29
- package/src/plan/fingerprint.ts +35 -31
- package/src/plan/graph-html.ts +7 -7
- package/src/plan/janitor.ts +30 -25
- package/src/plan/lineage.ts +20 -16
- package/src/plan/lock.ts +27 -10
- package/src/plan/metrics.ts +4 -4
- package/src/plan/naming.ts +23 -23
- package/src/plan/planner.ts +107 -90
- package/src/plan/run.ts +27 -19
- package/src/plan/schedule.ts +46 -41
- package/src/plan/status.ts +15 -14
- package/src/state/postgres.ts +19 -19
- package/src/state/sqlite.ts +27 -27
- package/src/state/store.ts +67 -55
- package/src/testing/index.ts +27 -26
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
|
-
/**
|
|
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("
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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("
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
/**
|
|
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: "
|
|
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("
|
|
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"
|
|
202
|
+
["y", "yes"].includes((answer ?? "").trim().toLowerCase())
|
|
199
203
|
|
|
200
204
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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("
|
|
224
|
+
Flag.withDescription("machine-readable output (stable shape — a contract for CI)"),
|
|
221
225
|
)
|
|
222
226
|
|
|
223
227
|
const explainFlag = Flag.boolean("explain").pipe(
|
|
224
228
|
Flag.withDescription(
|
|
225
|
-
"
|
|
229
|
+
"for each change — which canonical AST nodes diverged and why the category is what it is",
|
|
226
230
|
),
|
|
227
231
|
)
|
|
228
232
|
|
|
229
233
|
/**
|
|
230
|
-
* JSON
|
|
231
|
-
* semver
|
|
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
|
|
243
|
+
// operator override (#5) and physical reuse — additive 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
|
-
//
|
|
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)}
|
|
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(
|
|
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:
|
|
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
|
-
? `
|
|
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(`
|
|
286
|
+
yield* Console.log(` why: ${action.explain.reason}`)
|
|
283
287
|
if (action.explain.diverged.length > 0) {
|
|
284
|
-
yield* Console.log(`
|
|
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(
|
|
298
|
-
yield* Console.log("
|
|
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("
|
|
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("
|
|
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
|
-
// (
|
|
346
|
-
//
|
|
347
|
+
// plan and apply — under 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
|
-
"
|
|
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("
|
|
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
|
-
?
|
|
381
|
-
: "
|
|
384
|
+
? `built: ${applied.built.join(", ")}`
|
|
385
|
+
: "no build needed (view-swap only)",
|
|
382
386
|
)
|
|
383
|
-
yield* Console.log(
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
//
|
|
425
|
-
//
|
|
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}:
|
|
433
|
+
`run "${blocked.env}": unapplied changes present — apply 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
|
-
?
|
|
442
|
-
: "
|
|
445
|
+
? `processed: ${applied.built.join(", ")}`
|
|
446
|
+
: "no new intervals",
|
|
443
447
|
)
|
|
444
448
|
}),
|
|
445
449
|
).pipe(
|
|
446
450
|
Command.withDescription(
|
|
447
|
-
"
|
|
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(
|
|
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}
|
|
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 ? ` (
|
|
474
|
+
entry.sampledPercent !== undefined ? ` (sample ${entry.sampledPercent}%)` : ""
|
|
471
475
|
yield* Console.log(
|
|
472
|
-
` ${clean ? "✓" : "≠"} ${entry.model} A=${entry.rowsA} B=${entry.rowsB}
|
|
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}
|
|
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(`
|
|
485
|
+
yield* Console.log(` columns only in A: ${entry.columnsOnlyInA.join(", ")}`)
|
|
482
486
|
}
|
|
483
487
|
if (entry.columnsOnlyInB !== undefined) {
|
|
484
|
-
yield* Console.log(`
|
|
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
|
-
"
|
|
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("
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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}
|
|
546
|
-
for (const name of diff.onlyInB) yield* Console.log(`> ${name}
|
|
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("
|
|
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("
|
|
569
|
+
Flag.withDescription("cron expression or nickname (@hourly, @daily, …)"),
|
|
566
570
|
),
|
|
567
571
|
remove: Flag.boolean("remove").pipe(
|
|
568
|
-
Flag.withDescription("
|
|
572
|
+
Flag.withDescription("unregister the environment from the OS scheduler"),
|
|
569
573
|
),
|
|
570
574
|
list: Flag.boolean("list").pipe(
|
|
571
|
-
Flag.withDescription("
|
|
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
|
-
"
|
|
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
|
|
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("
|
|
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
|
-
`#
|
|
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(
|
|
612
|
+
yield* Console.log(`unregistered: ${removed.title}`)
|
|
609
613
|
return
|
|
610
614
|
}
|
|
611
615
|
const registered = yield* registerSchedule(target, cron)
|
|
612
|
-
yield* Console.log(
|
|
613
|
-
yield* Console.log(
|
|
616
|
+
yield* Console.log(`registered: ${registered.title} — "${cron}" (OS scheduler)`)
|
|
617
|
+
yield* Console.log(`worker: ${registered.worker}`)
|
|
614
618
|
yield* Console.log(
|
|
615
|
-
"
|
|
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
|
-
"
|
|
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
|
-
?
|
|
654
|
-
: "
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
691
|
-
:
|
|
692
|
-
const failed = lag.failed > 0 ? ` ⚠ 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("
|
|
700
|
+
yield* Console.log("no run ticks yet")
|
|
697
701
|
} else {
|
|
698
|
-
yield* Console.log("
|
|
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}
|
|
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("
|
|
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 (default — all 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
|
-
//
|
|
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 audits — nothing 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}
|
|
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
|
|
763
|
+
yield* Console.log(`blocking audits of environment "${env}" are clean`)
|
|
760
764
|
}),
|
|
761
765
|
).pipe(
|
|
762
|
-
Command.withDescription("
|
|
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
|
|
777
|
-
: `state store:
|
|
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(
|
|
784
|
+
yield* Console.log(`backup of the old store: ${report.backup}`)
|
|
781
785
|
}
|
|
782
786
|
}),
|
|
783
|
-
).pipe(Command.withDescription("
|
|
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("
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
922
|
+
Command.withDescription("sqlmesh on bun, typescript and Effect"),
|
|
843
923
|
Command.withSubcommands([
|
|
844
924
|
initCommand,
|
|
845
925
|
planCommand,
|