@avytheone/efmesh 0.1.0-beta.2 → 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.
@@ -8,7 +8,9 @@ import { EngineAdapter } from "../engine/adapter.ts"
8
8
  import { StateStore } from "../state/store.ts"
9
9
  import type { StateError } from "../state/store.ts"
10
10
  import { categorizeAstChange } from "./categorize.ts"
11
- import { FINGERPRINT_VERSION, fingerprintGraph } from "./fingerprint.ts"
11
+ import type { ChangeExplanation } from "./explain.ts"
12
+ import { dropsColumns, explainCategorized } from "./explain.ts"
13
+ import { FINGERPRINT_VERSION, fingerprintGraph, modelFingerprint } from "./fingerprint.ts"
12
14
  import { validateEnvName } from "./naming.ts"
13
15
 
14
16
  export class InvalidEnvironmentError extends Data.TaggedError("InvalidEnvironmentError")<{
@@ -21,6 +23,16 @@ export class ForwardOnlyError extends Data.TaggedError("ForwardOnlyError")<{
21
23
  readonly reason: string
22
24
  }> {}
23
25
 
26
+ /**
27
+ * Override категоризации не принимается (#5): модели нет в проекте или
28
+ * AST-дифф очевидно противоречит заявленному вердикту (удалённые колонки
29
+ * не бывают non-breaking — потомки читают их по именам).
30
+ */
31
+ export class ReclassifyError extends Data.TaggedError("ReclassifyError")<{
32
+ readonly model: string
33
+ readonly reason: string
34
+ }> {}
35
+
24
36
  /**
25
37
  * Снапшот посчитан другой версией алгоритма fingerprint (SPEC §4):
26
38
  * отпечатки разных версий несравнимы — план честно останавливается,
@@ -65,6 +77,14 @@ export interface PlanAction {
65
77
  /** Канонический AST тела (null у external) — сохраняется в снапшот. */
66
78
  readonly canonicalAst: string | null
67
79
  readonly change: ChangeCategory
80
+ /** Вердикт планировщика до override оператора (#5); журналируется. */
81
+ readonly reclassifiedFrom?: ChangeCategory
82
+ /**
83
+ * Почему категория такая (#4): разошедшиеся узлы канонического AST и
84
+ * причина словами. Есть у всех изменённых моделей, кроме added/removed —
85
+ * там сравнивать не с чем. Пути diverged — отладочная подсказка, не контракт.
86
+ */
87
+ readonly explain?: ChangeExplanation
68
88
  /**
69
89
  * Нужна ли сборка физики. false для unchanged/removed, external и для
70
90
  * снапшотов, уже собранных другим окружением, — тогда промоушен это
@@ -98,8 +118,30 @@ export interface PlanOptions {
98
118
  * «задним числом» не бывает по построению.
99
119
  */
100
120
  readonly forwardOnly?: ReadonlyArray<string>
121
+ /**
122
+ * Override категоризации (#5, SPEC §5.2): оператор, глядя на `--explain`,
123
+ * заявляет вердикт вместо планировщика. Управляет судьбой ПОТОМКОВ
124
+ * (non-breaking-родитель разрешает им реюз физики); саму модель от
125
+ * пересборки освобождает не он, а forwardOnly. Гвардрейл: очевидное
126
+ * противоречие AST (удалённые колонки → non-breaking) — ошибка.
127
+ * Применяется только к вердиктам breaking/non-breaking; на unchanged/
128
+ * added/removed/indirect молча не влияет.
129
+ */
130
+ readonly reclassify?: Readonly<Record<string, "breaking" | "non-breaking">>
101
131
  }
102
132
 
133
+ /**
134
+ * Виды, чью физику стоит наследовать при indirect-реюзе (#5): материализуемые
135
+ * таблицы. view/embedded материализации не имеют, seed пересобирается из
136
+ * файла дёшево и родителей не имеет.
137
+ */
138
+ const REUSABLE_KINDS: ReadonlySet<string> = new Set([
139
+ "full",
140
+ "incrementalByTimeRange",
141
+ "incrementalByUniqueKey",
142
+ "scdType2",
143
+ ])
144
+
103
145
  export const planChanges = (
104
146
  env: string,
105
147
  graph: ModelGraph,
@@ -108,6 +150,7 @@ export const planChanges = (
108
150
  Plan,
109
151
  | InvalidEnvironmentError
110
152
  | ForwardOnlyError
153
+ | ReclassifyError
111
154
  | FingerprintVersionError
112
155
  | StateError
113
156
  | EngineError
@@ -132,18 +175,33 @@ export const planChanges = (
132
175
  })
133
176
  }
134
177
  }
135
- const versions = yield* fingerprintGraph(graph)
178
+ const reclassify = options?.reclassify ?? {}
179
+ for (const flagged of Object.keys(reclassify)) {
180
+ if (!graph.models.has(flagged)) {
181
+ return yield* new ReclassifyError({ model: flagged, reason: "модели нет в проекте" })
182
+ }
183
+ }
184
+ // кэш канонизации (#8) — стор под рукой; его сбои глотаются:
185
+ // промах кэша — это просто честный пересчёт, а не ошибка плана
186
+ const versions = yield* fingerprintGraph(graph, {
187
+ get: (key) => store.getCanon(key).pipe(Effect.orElseSucceed(() => undefined)),
188
+ put: (key, canonical) => store.putCanon(key, canonical).pipe(Effect.ignore),
189
+ })
136
190
  const current = new Map(
137
191
  (yield* store.getEnvironment(env)).map((row) => [row.name, row.fingerprint]),
138
192
  )
139
193
 
140
194
  const actions: Array<PlanAction> = []
141
195
  const changeOf = new Map<string, ChangeCategory>()
196
+ // кто в этом плане наследует физику старой версии (indirect-реюз, #5)
197
+ const reusedPhysics = new Set<string>()
142
198
  for (const name of graph.order) {
143
199
  const model = graph.models.get(name)!
144
200
  const { fingerprint, ast } = versions.get(name)!
145
201
  const known = current.get(name)
146
202
  let change: ChangeCategory
203
+ let explain: ChangeExplanation | undefined
204
+ let reclassifiedFrom: ChangeCategory | undefined
147
205
  let reusedFrom: string | undefined
148
206
  let physicalFingerprint = fingerprint
149
207
  if (known === undefined) change = "added"
@@ -160,14 +218,83 @@ export const planChanges = (
160
218
  })
161
219
  }
162
220
  const oldAst = previous?.canonicalAst ?? ""
163
- if (oldAst === "" || ast === null) change = "breaking"
164
- else if (oldAst === ast) change = "indirect" // версия сдвинута родителем/метаданными
165
- else change = categorizeAstChange(oldAst, ast)
221
+ const changedParents = [...model.deps].filter((dep) => {
222
+ const parent = changeOf.get(dep)
223
+ return parent !== undefined && parent !== "unchanged"
224
+ })
225
+ if (oldAst === "" || ast === null) {
226
+ change = "breaking"
227
+ explain = {
228
+ diverged: [],
229
+ reason:
230
+ ast === null
231
+ ? "у модели нет SQL-тела (external/seed) — версию сдвинули источник/файл, схема или родители; сравнивать AST не с чем"
232
+ : "у прошлого снапшота не сохранён канонический AST — сравнивать не с чем, консервативно breaking",
233
+ }
234
+ } else if (oldAst === ast) {
235
+ change = "indirect" // версия сдвинута родителем/метаданными
236
+ explain =
237
+ changedParents.length > 0
238
+ ? {
239
+ diverged: [],
240
+ reason: `собственный AST не менялся — версию сдвинул каскад от родителей: ${changedParents.join(", ")}`,
241
+ cascadeFrom: changedParents,
242
+ }
243
+ : {
244
+ diverged: [],
245
+ reason:
246
+ "собственный AST не менялся — разошлись метаданные (kind/grain/columns/target)",
247
+ }
248
+ } else {
249
+ change = categorizeAstChange(oldAst, ast)
250
+ explain = explainCategorized(oldAst, ast, change)
251
+ }
252
+ // override оператора (#5): вердикт заявлен флагом поверх --explain;
253
+ // планировщик проверяет только очевидное противоречие AST
254
+ const override = reclassify[name]
255
+ if (
256
+ override !== undefined &&
257
+ (change === "breaking" || change === "non-breaking") &&
258
+ change !== override
259
+ ) {
260
+ if (override === "non-breaking" && (oldAst === "" || ast === null)) {
261
+ return yield* new ReclassifyError({
262
+ model: name,
263
+ reason:
264
+ "канонического AST нет — проверить вердикт нечем, override не принимается",
265
+ })
266
+ }
267
+ if (override === "non-breaking" && ast !== null && dropsColumns(oldAst, ast)) {
268
+ return yield* new ReclassifyError({
269
+ model: name,
270
+ reason:
271
+ "в новом SELECT колонок меньше — потомки читают удалённые колонки по именам, non-breaking противоречит AST",
272
+ })
273
+ }
274
+ reclassifiedFrom = change
275
+ explain = {
276
+ diverged: explain?.diverged ?? [],
277
+ reason: `override оператора: ${change} → ${override}; вердикт планировщика: ${explain?.reason ?? "—"}`,
278
+ }
279
+ change = override
280
+ }
281
+ // «версию сдвинули ТОЛЬКО родители»: отпечаток со старыми подписями
282
+ // родителей обязан дать known — иначе вместе с родителями разошлись
283
+ // и метаданные (kind/grain/columns/target), наследовать физику нельзя
284
+ const parentsOnly =
285
+ change === "indirect" &&
286
+ previous !== undefined &&
287
+ [...model.deps].every((dep) => current.has(dep)) &&
288
+ (yield* modelFingerprint(
289
+ model,
290
+ ast,
291
+ [...model.deps].sort().map((dep) => `${dep}=${current.get(dep)!}`),
292
+ )) === known
166
293
  // forward-only: явный флаг пользователя — либо каскад: собственный AST
167
294
  // не менялся, а все изменившиеся родители сами forward-only (реюз
168
295
  // физики родителя означает, что и потомку нечего переигрывать)
169
296
  const cascaded =
170
- change === "indirect" &&
297
+ parentsOnly &&
171
298
  model.kind._tag === "incrementalByTimeRange" &&
172
299
  [...model.deps].every((dep) => {
173
300
  const parent = changeOf.get(dep)
@@ -177,6 +304,46 @@ export const planChanges = (
177
304
  change = "forward-only"
178
305
  reusedFrom = known
179
306
  physicalFingerprint = previous.physicalFp
307
+ explain = forwardOnly.has(name)
308
+ ? {
309
+ diverged: explain?.diverged ?? [],
310
+ reason: `forward-only по флагу: физика и done-интервалы наследуются от @${known.slice(0, 8)}, история не переигрывается`,
311
+ }
312
+ : {
313
+ diverged: [],
314
+ reason:
315
+ "собственный AST не менялся, все изменившиеся родители forward-only — физика реюзается каскадом",
316
+ cascadeFrom: changedParents,
317
+ }
318
+ }
319
+ // indirect-реюз (#5, класс sqlmesh «indirect non-breaking»): своё тело
320
+ // не менялось, версию сдвинули только родители, и каждый изменившийся
321
+ // родитель гарантирует те же данные в старых колонках (non-breaking —
322
+ // строго суффикс; forward-only и реюз — буквально та же физика) —
323
+ // потомку нечего переигрывать: физика и учёт наследуются, scdType2
324
+ // не теряет накопленную историю строк
325
+ if (
326
+ change === "indirect" &&
327
+ parentsOnly &&
328
+ REUSABLE_KINDS.has(model.kind._tag) &&
329
+ changedParents.length > 0 &&
330
+ changedParents.every((dep) => {
331
+ const parent = changeOf.get(dep)!
332
+ return (
333
+ parent === "non-breaking" ||
334
+ parent === "forward-only" ||
335
+ (parent === "indirect" && reusedPhysics.has(dep))
336
+ )
337
+ })
338
+ ) {
339
+ reusedFrom = known
340
+ physicalFingerprint = previous!.physicalFp
341
+ reusedPhysics.add(name)
342
+ explain = {
343
+ diverged: [],
344
+ reason: `изменившиеся родители не трогают существующие данные (non-breaking/forward-only) — физика и учёт наследуются от @${known.slice(0, 8)}, пересборки нет`,
345
+ cascadeFrom: changedParents,
346
+ }
180
347
  }
181
348
  }
182
349
  changeOf.set(name, change)
@@ -220,6 +387,8 @@ export const planChanges = (
220
387
  ...(reusedFrom !== undefined ? { reusedFrom } : {}),
221
388
  canonicalAst: ast,
222
389
  change,
390
+ ...(reclassifiedFrom !== undefined ? { reclassifiedFrom } : {}),
391
+ ...(explain !== undefined ? { explain } : {}),
223
392
  build: !alreadyBuilt,
224
393
  backfill,
225
394
  // каждый apply сверяет запрос с физикой: upsert / SCD-версионирование
package/src/plan/run.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { Data, Effect, Schedule } from "effect"
1
+ import { Clock, Data, Effect, Schedule } from "effect"
2
2
  import { buildGraph } from "../core/graph.ts"
3
3
  import type { AnyModel } from "../core/model.ts"
4
4
  import { EngineAdapter } from "../engine/adapter.ts"
5
- import { StateStore } from "../state/store.ts"
5
+ import { StateStore, type RunRecord } from "../state/store.ts"
6
6
  import { applyPlan, type AppliedPlan, type ApplyError, type ApplyOptions } from "./executor.ts"
7
7
  import { envLockName, withStateLock, type LockHeldError, type LockOptions } from "./lock.ts"
8
8
  import { planChanges, type PlanOptions } from "./planner.ts"
@@ -25,22 +25,60 @@ export type RunError = ApplyError | LockHeldError | RunBlockedByChangesError
25
25
 
26
26
  export interface RunOptions extends PlanOptions, ApplyOptions, LockOptions {}
27
27
 
28
+ /** Исход тика для журнала: ошибка → категория + деталь. */
29
+ const classify = (error: RunError): Pick<RunRecord, "outcome" | "detail"> => {
30
+ switch (error._tag) {
31
+ case "RunBlockedByChangesError":
32
+ return { outcome: "awaiting-human", detail: error.changes.join("; ") }
33
+ case "LockHeldError":
34
+ return { outcome: "lock-held", detail: error.name }
35
+ default:
36
+ return { outcome: "error", detail: error._tag }
37
+ }
38
+ }
39
+
28
40
  export const run = (
29
41
  env: string,
30
42
  models: Iterable<AnyModel>,
31
43
  options?: RunOptions,
32
44
  ): Effect.Effect<AppliedPlan, RunError, EngineAdapter | StateStore> =>
33
45
  Effect.gen(function* () {
34
- const graph = yield* buildGraph(models)
35
- const plan = yield* planChanges(env, graph, options)
36
- const structural = plan.actions
37
- .filter((a) => a.change !== "unchanged")
38
- .map((a) => `${a.name}: ${a.change}`)
39
- if (structural.length > 0) {
40
- return yield* new RunBlockedByChangesError({ env, changes: structural })
41
- }
42
- return yield* applyPlan(plan, graph, options)
43
- }).pipe(withStateLock(envLockName(env), options?.lockTtlMs))
46
+ const store = yield* StateStore
47
+ const startedAt = new Date(yield* Clock.currentTimeMillis).toISOString()
48
+ // журнал тиков (SPEC §7, #2): исход пишется ВСЕГДА, включая неуспех —
49
+ // упавший в три часа ночи cron-тик дебажится задним числом; сбой самой
50
+ // записи не маскирует настоящий исход (лог + ignore)
51
+ const journal = (entry: Pick<RunRecord, "outcome" | "detail">) =>
52
+ Clock.currentTimeMillis.pipe(
53
+ Effect.flatMap((now) =>
54
+ store.recordRun({
55
+ env,
56
+ startedAt,
57
+ finishedAt: new Date(now).toISOString(),
58
+ ...entry,
59
+ }),
60
+ ),
61
+ Effect.catchCause((cause) => Effect.logWarning("журнал тиков недоступен", cause)),
62
+ )
63
+
64
+ return yield* Effect.gen(function* () {
65
+ const graph = yield* buildGraph(models)
66
+ const plan = yield* planChanges(env, graph, options)
67
+ const structural = plan.actions
68
+ .filter((a) => a.change !== "unchanged")
69
+ .map((a) => `${a.name}: ${a.change}`)
70
+ if (structural.length > 0) {
71
+ return yield* new RunBlockedByChangesError({ env, changes: structural })
72
+ }
73
+ return yield* applyPlan(plan, graph, options)
74
+ }).pipe(
75
+ withStateLock(envLockName(env), options?.lockTtlMs),
76
+ Effect.tap((applied) =>
77
+ journal({ outcome: "ok", detail: JSON.stringify(applied.built) }),
78
+ ),
79
+ Effect.tapError((error) => journal(classify(error))),
80
+ )
81
+ })
44
82
 
45
83
  /**
46
84
  * Долгоживущий планировщик для встраивания в Effect-приложение (SPEC §7):
@@ -0,0 +1,212 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
2
+ import { basename, dirname, join } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import { Data, Effect } from "effect"
5
+
6
+ /**
7
+ * `efmesh schedule` (#10): регистрация тика `run` в OS-шедулере через
8
+ * Bun.cron (>=1.3.11): crontab на Linux, launchd на macOS, Task Scheduler
9
+ * на Windows. Один заголовок = одна запись, повторная регистрация
10
+ * перезаписывает на месте (идемпотентно).
11
+ *
12
+ * Честные оговорки (документированы в README/SPEC): cron не догоняет
13
+ * пропущенные запуски (systemd Persistent=true строже — есть
14
+ * --print-systemd), OS-уровень живёт в ЛОКАЛЬНОЙ таймзоне (TZ=UTC поверх),
15
+ * на Linux нужен живой cron-демон — семейство Arch без cronie не имеет
16
+ * его вовсе. Наложение тиков безопасно по построению: run держит env-лок,
17
+ * а «ждёт человека» = exit 2, не сбой.
18
+ */
19
+
20
+ export class ScheduleError extends Data.TaggedError("ScheduleError")<{
21
+ readonly reason: string
22
+ }> {}
23
+
24
+ /** Всё, что нужно воркеру, — абсолютными путями (crontab не знает cwd). */
25
+ export interface ScheduleTarget {
26
+ /** Директория проекта (где лежит конфиг) — cwd тика. */
27
+ readonly project: string
28
+ /** Абсолютный путь конфига. */
29
+ readonly config: string
30
+ readonly env: string
31
+ }
32
+
33
+ /** Заголовок Bun.cron: только [A-Za-z0-9_-]; включает проект и окружение. */
34
+ export const scheduleTitle = (target: Pick<ScheduleTarget, "project" | "env">): string =>
35
+ `efmesh-${basename(target.project)}-${target.env}`.replaceAll(/[^A-Za-z0-9_-]/g, "-")
36
+
37
+ /** Куда генерируется воркер: рядом с конфигом, в служебной .efmesh/. */
38
+ export const workerPath = (target: ScheduleTarget): string =>
39
+ join(target.project, ".efmesh", `schedule-${target.env}.ts`)
40
+
41
+ /** Бинарь CLI этого же пакета — воркер зовёт его, не гадая по npm-именам. */
42
+ const binPath = (): string => fileURLToPath(new URL("../bin.ts", import.meta.url))
43
+
44
+ /**
45
+ * Исходник воркера для Bun.cron: OS-шедулер исполняет scheduled(), тот
46
+ * гоняет обычный `efmesh run` — та же семантика exit-кодов (2 = «ждёт
47
+ * человека») и тот же журнал тиков в сторе (`efmesh status`).
48
+ */
49
+ export const workerSource = (target: ScheduleTarget): string => `// сгенерировано \`efmesh schedule\` — не редактируйте: перерегистрация перезапишет
50
+ export default {
51
+ async scheduled() {
52
+ const proc = Bun.spawn(
53
+ [${JSON.stringify(process.execPath)}, ${JSON.stringify(binPath())}, "run", ${JSON.stringify(target.env)}, "--config", ${JSON.stringify(target.config)}],
54
+ { cwd: ${JSON.stringify(target.project)}, stdout: "inherit", stderr: "inherit" },
55
+ )
56
+ process.exitCode = await proc.exited
57
+ },
58
+ }
59
+ `
60
+
61
+ /** Никнеймы cron → OnCalendar systemd; произвольные выражения не переводятся. */
62
+ export const cronToOnCalendar = (cron: string): string | undefined =>
63
+ (
64
+ {
65
+ "@hourly": "hourly",
66
+ "@daily": "daily",
67
+ "@midnight": "daily",
68
+ "@weekly": "weekly",
69
+ "@monthly": "monthly",
70
+ "@yearly": "yearly",
71
+ "@annually": "yearly",
72
+ } as Record<string, string>
73
+ )[cron.trim()]
74
+
75
+ /**
76
+ * systemd-фоллбэк (--print-systemd): у cron нет догона пропущенных запусков
77
+ * и на Arch-семействе нет демона — user-таймер с Persistent=true честнее.
78
+ */
79
+ export const systemdUnits = (
80
+ target: ScheduleTarget,
81
+ cron: string,
82
+ ): { readonly service: string; readonly timer: string; readonly name: string } => {
83
+ const name = scheduleTitle(target)
84
+ const calendar = cronToOnCalendar(cron)
85
+ return {
86
+ name,
87
+ service: `[Unit]
88
+ Description=efmesh run ${target.env} (${basename(target.project)})
89
+
90
+ [Service]
91
+ Type=oneshot
92
+ WorkingDirectory=${target.project}
93
+ ExecStart=${process.execPath} ${binPath()} run ${target.env} --config ${target.config}
94
+ # exit 2 = «ждёт человека» (нужен apply) — намеренно failure юнита: видно в алертах
95
+ `,
96
+ timer: `[Unit]
97
+ Description=efmesh run ${target.env} — таймер
98
+
99
+ [Timer]
100
+ OnCalendar=${calendar ?? `hourly # TODO: переведите «${cron}» в OnCalendar вручную`}
101
+ Persistent=true
102
+
103
+ [Install]
104
+ WantedBy=timers.target
105
+ `,
106
+ }
107
+ }
108
+
109
+ /** Валидация выражения тем же парсером, который будет его исполнять. */
110
+ export const validateCron = (cron: string): Effect.Effect<void, ScheduleError> =>
111
+ Effect.gen(function* () {
112
+ const next = yield* Effect.try({
113
+ try: () => Bun.cron.parse(cron),
114
+ catch: () => new ScheduleError({ reason: `не разбирается cron-выражение «${cron}»` }),
115
+ })
116
+ if (next === null) {
117
+ return yield* new ScheduleError({
118
+ reason: `cron-выражение «${cron}» никогда не сработает`,
119
+ })
120
+ }
121
+ })
122
+
123
+ /** Linux без crontab-бинаря (Arch-семейство) — честная ошибка с рецептом. */
124
+ const requireCrontab = (): Effect.Effect<void, ScheduleError> =>
125
+ Effect.gen(function* () {
126
+ if (process.platform !== "linux" || Bun.which("crontab") !== null) return
127
+ return yield* new ScheduleError({
128
+ reason:
129
+ "на этой машине нет crontab (Arch-семейство не ставит cron-демона): " +
130
+ "установите cronie ЛИБО используйте systemd-таймер — efmesh schedule <env> --print-systemd",
131
+ })
132
+ })
133
+
134
+ export const registerSchedule = (
135
+ target: ScheduleTarget,
136
+ cron: string,
137
+ ): Effect.Effect<{ readonly title: string; readonly worker: string }, ScheduleError> =>
138
+ Effect.gen(function* () {
139
+ yield* validateCron(cron)
140
+ yield* requireCrontab()
141
+ if (!existsSync(target.config)) {
142
+ return yield* new ScheduleError({ reason: `конфига нет: ${target.config}` })
143
+ }
144
+ const worker = workerPath(target)
145
+ yield* Effect.try({
146
+ try: () => {
147
+ mkdirSync(dirname(worker), { recursive: true })
148
+ writeFileSync(worker, workerSource(target))
149
+ },
150
+ catch: (cause) =>
151
+ new ScheduleError({ reason: `воркер не записался: ${String(cause)}` }),
152
+ })
153
+ const title = scheduleTitle(target)
154
+ yield* Effect.tryPromise({
155
+ try: () => Bun.cron(worker, cron, title),
156
+ catch: (cause) =>
157
+ new ScheduleError({ reason: `Bun.cron не зарегистрировал: ${String(cause)}` }),
158
+ })
159
+ return { title, worker }
160
+ })
161
+
162
+ export const removeSchedule = (
163
+ target: ScheduleTarget,
164
+ ): Effect.Effect<{ readonly title: string }, ScheduleError> =>
165
+ Effect.gen(function* () {
166
+ const title = scheduleTitle(target)
167
+ yield* Effect.tryPromise({
168
+ try: () => Bun.cron.remove(title),
169
+ catch: (cause) => new ScheduleError({ reason: `Bun.cron.remove: ${String(cause)}` }),
170
+ })
171
+ // воркер больше никем не исполняется — прибираем молча
172
+ yield* Effect.sync(() => rmSync(workerPath(target), { force: true }))
173
+ return { title }
174
+ })
175
+
176
+ /**
177
+ * Список efmesh-регистраций — по платформенным следам Bun.cron: маркеры
178
+ * `# bun-cron: <title>` в crontab (Linux) / plist'ы launchd (macOS).
179
+ */
180
+ export const listSchedules = (): Effect.Effect<ReadonlyArray<string>, ScheduleError> =>
181
+ Effect.gen(function* () {
182
+ if (process.platform === "linux") {
183
+ if (Bun.which("crontab") === null) return []
184
+ const out = yield* Effect.tryPromise({
185
+ try: async () => {
186
+ const proc = Bun.spawn(["crontab", "-l"], { stdout: "pipe", stderr: "ignore" })
187
+ const text = await proc.stdout.text()
188
+ await proc.exited // пустой crontab выходит с 1 — это не ошибка
189
+ return text
190
+ },
191
+ catch: (cause) => new ScheduleError({ reason: `crontab -l: ${String(cause)}` }),
192
+ })
193
+ return out
194
+ .split("\n")
195
+ .filter((line) => line.includes("bun-cron") && line.includes("efmesh-"))
196
+ }
197
+ if (process.platform === "darwin") {
198
+ const out = yield* Effect.tryPromise({
199
+ try: async () => {
200
+ const proc = Bun.spawn(["launchctl", "list"], { stdout: "pipe", stderr: "ignore" })
201
+ const text = await proc.stdout.text()
202
+ await proc.exited
203
+ return text
204
+ },
205
+ catch: (cause) => new ScheduleError({ reason: `launchctl list: ${String(cause)}` }),
206
+ })
207
+ return out.split("\n").filter((line) => line.includes("bun.cron.efmesh-"))
208
+ }
209
+ return yield* new ScheduleError({
210
+ reason: `список на ${process.platform} смотрите средствами ОС (Task Scheduler: schtasks /query)`,
211
+ })
212
+ })
@@ -0,0 +1,95 @@
1
+ import { Clock, Effect } from "effect"
2
+ import { buildGraph, type GraphError } from "../core/graph.ts"
3
+ import { enumerateIntervals, fromIso, missingIntervals, toIso } from "../core/interval.ts"
4
+ import type { AnyModel } from "../core/model.ts"
5
+ import { STATE_VERSION, StateStore } from "../state/store.ts"
6
+ import type { PlanRecord, RunRecord, StateError } from "../state/store.ts"
7
+
8
+ /**
9
+ * `efmesh status <env>` (issue #1): одна команда на вопрос «что вообще
10
+ * происходит» — для оператора ночного cron и для автора, забывшего
11
+ * команды. Только чтение: стор открыт → версия схемы уже совпала.
12
+ */
13
+
14
+ export interface ModelLag {
15
+ readonly model: string
16
+ /** Конец последнего done-интервала (ISO); null — ещё ничего не посчитано. */
17
+ readonly doneUpTo: string | null
18
+ /** Сколько интервалов не хватает до «сейчас». 0 — догнано. */
19
+ readonly missing: number
20
+ /** Интервалы, помеченные failed, — застрявший бэкфилл виден сразу. */
21
+ readonly failed: number
22
+ }
23
+
24
+ export interface StatusReport {
25
+ readonly env: string
26
+ readonly storeVersion: number
27
+ /** Строк в окружении; 0 — окружение не существует (ни разу не применялось). */
28
+ readonly models: number
29
+ /** Последний промоушен (ISO); null — окружения нет. */
30
+ readonly promotedAt: string | null
31
+ readonly lastPlan: PlanRecord | null
32
+ /** Последние тики run, свежие первыми. */
33
+ readonly ticks: ReadonlyArray<RunRecord>
34
+ /** Отставание incremental-моделей окружения. */
35
+ readonly lag: ReadonlyArray<ModelLag>
36
+ }
37
+
38
+ export interface StatusOptions {
39
+ /** «Сейчас» для расчёта отставания; по умолчанию — Clock. Инъекция для тестов. */
40
+ readonly now?: number
41
+ /** Сколько последних тиков показать; по умолчанию 5. */
42
+ readonly ticks?: number
43
+ }
44
+
45
+ export const environmentStatus = (
46
+ env: string,
47
+ models: Iterable<AnyModel>,
48
+ options?: StatusOptions,
49
+ ): Effect.Effect<StatusReport, GraphError | StateError, StateStore> =>
50
+ Effect.gen(function* () {
51
+ const store = yield* StateStore
52
+ const graph = yield* buildGraph(models)
53
+ const now = options?.now ?? (yield* Clock.currentTimeMillis)
54
+
55
+ const rows = yield* store.getEnvironment(env)
56
+ const promotedAt =
57
+ rows.length === 0
58
+ ? null
59
+ : rows.map((row) => row.promotedAt).reduce((a, b) => (a > b ? a : b))
60
+
61
+ const plans = yield* store.listPlans(env)
62
+ const lastPlan = plans.at(-1) ?? null
63
+ const ticks = yield* store.listRuns(env, options?.ticks ?? 5)
64
+
65
+ // отставание — по УКАЗАТЕЛЯМ окружения (что реально отдаётся
66
+ // потребителям), а не по локальным fingerprint'ам проекта
67
+ const lag: Array<ModelLag> = []
68
+ for (const row of rows) {
69
+ const model = graph.models.get(row.name)
70
+ if (model === undefined || model.kind._tag !== "incrementalByTimeRange") continue
71
+ const kind = model.kind
72
+ const records = yield* store.listIntervals(row.fingerprint)
73
+ const done = records
74
+ .filter((record) => record.status === "done")
75
+ .map((record) => ({ start: fromIso(record.startTs), end: fromIso(record.endTs) }))
76
+ const wanted = enumerateIntervals(kind.interval, fromIso(kind.start), now)
77
+ const missing = missingIntervals(wanted, done)
78
+ lag.push({
79
+ model: row.name,
80
+ doneUpTo: done.length === 0 ? null : toIso(Math.max(...done.map((i) => i.end))),
81
+ missing: missing.length,
82
+ failed: records.filter((record) => record.status === "failed").length,
83
+ })
84
+ }
85
+
86
+ return {
87
+ env,
88
+ storeVersion: STATE_VERSION,
89
+ models: rows.length,
90
+ promotedAt,
91
+ lastPlan,
92
+ ticks,
93
+ lag,
94
+ }
95
+ })