@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.
- package/CHANGELOG.md +136 -92
- 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/src/plan/planner.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
})
|