@avytheone/efmesh 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { Effect } from "effect"
3
+ import { SeedReadError } from "../core/errors.ts"
4
+ import type { ModelGraph } from "../core/graph.ts"
5
+ import type { ModelKind } from "../core/model.ts"
6
+ import { columnNames } from "../core/model.ts"
7
+ import { render } from "../core/sql.ts"
8
+ import type { EngineError, SqlParseError } from "../engine/adapter.ts"
9
+ import { EngineAdapter } from "../engine/adapter.ts"
10
+
11
+ /**
12
+ * Fingerprint снапшота (SPEC §4): хэш канонизированного AST (родной парсер
13
+ * движка, переформатирование запроса fingerprint не меняет), метаданных,
14
+ * влияющих на данные, и fingerprint'ов прямых зависимостей (транзитивность).
15
+ *
16
+ * `batchSize`, `lookback`, `start` и `description` в fingerprint не входят:
17
+ * они меняют исполнение или объём истории, но не форму данных — недостающие
18
+ * интервалы учёт увидит сам.
19
+ */
20
+
21
+ /**
22
+ * Версия алгоритма fingerprint — КОНТРАКТ (SPEC §4). Fingerprint зависит от
23
+ * канонизации движка (json_serialize_sql DuckDB / libpg_query) и состава
24
+ * payload ниже: любое их изменение молча пере-фингерпринтит все модели
25
+ * пользователя и вынуждает полный ребилд склада. Поэтому: (1) канонизация
26
+ * заморожена golden-тестами (test/fingerprint-golden.test.ts) — красный
27
+ * тест при апгрейде DuckDB/libpg_query означает дрейф канона; (2) осознанная
28
+ * смена алгоритма = инкремент этой константы + история миграции, снапшоты
29
+ * другой версии план не сравнивает, а честно останавливается.
30
+ */
31
+ export const FINGERPRINT_VERSION = 1
32
+
33
+ const sha256 = (input: string): string => {
34
+ const hasher = new Bun.CryptoHasher("sha256")
35
+ hasher.update(input)
36
+ return hasher.digest("hex")
37
+ }
38
+
39
+ /** Canonical-рендер: ссылки — логические имена, границы — плейсхолдеры $start/$end. */
40
+ export const canonicalSql = (graph: ModelGraph, name: string): string => {
41
+ const model = graph.models.get(name)
42
+ if (model === undefined) throw new Error(`модели ${name} нет в графе`)
43
+ return render(model.fragment, { resolveRef: (ref) => ref })
44
+ }
45
+
46
+ /** Часть kind, влияющая на данные. Для seed — хэш содержимого файла: правка данных = новая версия. */
47
+ const kindPayload = (
48
+ model: { readonly name: { readonly full: string } },
49
+ kind: ModelKind,
50
+ ): Effect.Effect<unknown, SeedReadError> => {
51
+ switch (kind._tag) {
52
+ case "full":
53
+ case "view":
54
+ case "embedded":
55
+ return Effect.succeed({ _tag: kind._tag })
56
+ case "incrementalByTimeRange":
57
+ return Effect.succeed({
58
+ _tag: kind._tag,
59
+ timeColumn: kind.timeColumn,
60
+ interval: kind.interval,
61
+ })
62
+ case "incrementalByUniqueKey":
63
+ return Effect.succeed({ _tag: kind._tag, key: kind.key })
64
+ case "scdType2":
65
+ return Effect.succeed({
66
+ _tag: kind._tag,
67
+ key: kind.key,
68
+ validFrom: kind.validFrom,
69
+ validTo: kind.validTo,
70
+ })
71
+ case "external":
72
+ return Effect.succeed({ _tag: kind._tag, source: kind.source })
73
+ case "seed":
74
+ return Effect.try({
75
+ try: () => ({
76
+ _tag: kind._tag,
77
+ file: kind.file,
78
+ contentHash: sha256(readFileSync(kind.file, "utf8")),
79
+ }),
80
+ catch: (cause) =>
81
+ new SeedReadError({ model: model.name.full, file: kind.file, cause }),
82
+ })
83
+ }
84
+ }
85
+
86
+ export interface ModelVersion {
87
+ readonly fingerprint: string
88
+ /** Канонический AST тела; null у external. Хранится в снапшоте для категоризации (§5.2). */
89
+ readonly ast: string | null
90
+ }
91
+
92
+ /** Fingerprint всех моделей графа; транзитивность — через хэши родителей. */
93
+ export const fingerprintGraph = (
94
+ graph: ModelGraph,
95
+ ): Effect.Effect<
96
+ ReadonlyMap<string, ModelVersion>,
97
+ EngineError | SqlParseError | SeedReadError,
98
+ EngineAdapter
99
+ > =>
100
+ Effect.gen(function* () {
101
+ const engine = yield* EngineAdapter
102
+ const versions = new Map<string, ModelVersion>()
103
+ for (const name of graph.order) {
104
+ const model = graph.models.get(name)!
105
+ // у external и seed нет SQL — версия определяется источником/файлом и схемой
106
+ const ast =
107
+ model.kind._tag === "external" || model.kind._tag === "seed"
108
+ ? null
109
+ : yield* engine.canonicalize(canonicalSql(graph, name))
110
+ const parents = [...model.deps]
111
+ .sort()
112
+ .map((dep) => `${dep}=${versions.get(dep)!.fingerprint}`)
113
+ const payload = JSON.stringify({
114
+ ast,
115
+ kind: yield* kindPayload(model, model.kind),
116
+ grain: model.grain,
117
+ columns: columnNames(model),
118
+ // смена цели материализации = новая физика, потребители перечитают её
119
+ target: model.target,
120
+ parents,
121
+ })
122
+ versions.set(name, { fingerprint: sha256(payload), ast })
123
+ }
124
+ return versions
125
+ })
@@ -0,0 +1,157 @@
1
+ import type { ModelGraph } from "../core/graph.ts"
2
+
3
+ /**
4
+ * `efmesh graph --html` (SPEC §11): самодостаточная страница с DAG моделей —
5
+ * SVG без внешних зависимостей, слои по длиннейшему пути от корней,
6
+ * рёбра — кривые Безье, подсветка соседей по наведению.
7
+ */
8
+
9
+ const KIND_COLOR: Record<string, string> = {
10
+ external: "#8899aa",
11
+ seed: "#b08968",
12
+ view: "#5fa8d3",
13
+ embedded: "#9d8cd6",
14
+ full: "#4f772d",
15
+ incrementalByTimeRange: "#e07a5f",
16
+ incrementalByUniqueKey: "#d4a373",
17
+ scdType2: "#c05299",
18
+ }
19
+
20
+ const escapeHtml = (text: string): string =>
21
+ text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll(`"`, "&quot;")
22
+
23
+ const NODE_W = 210
24
+ const NODE_H = 46
25
+ const COL_GAP = 90
26
+ const ROW_GAP = 26
27
+ const PAD = 32
28
+
29
+ export const renderGraphHtml = (graph: ModelGraph): string => {
30
+ // слой = длиннейший путь от корней: родители всегда левее потомков
31
+ const depth = new Map<string, number>()
32
+ for (const name of graph.order) {
33
+ const model = graph.models.get(name)!
34
+ let level = 0
35
+ for (const dep of model.deps) level = Math.max(level, (depth.get(dep) ?? 0) + 1)
36
+ depth.set(name, level)
37
+ }
38
+ const columns = new Map<number, Array<string>>()
39
+ for (const name of graph.order) {
40
+ const level = depth.get(name)!
41
+ const column = columns.get(level) ?? []
42
+ column.push(name)
43
+ columns.set(level, column)
44
+ }
45
+
46
+ const position = new Map<string, { x: number; y: number }>()
47
+ for (const [level, names] of columns) {
48
+ names.forEach((name, index) => {
49
+ position.set(name, {
50
+ x: PAD + level * (NODE_W + COL_GAP),
51
+ y: PAD + index * (NODE_H + ROW_GAP),
52
+ })
53
+ })
54
+ }
55
+ const width = PAD * 2 + columns.size * (NODE_W + COL_GAP) - COL_GAP
56
+ const height =
57
+ PAD * 2 +
58
+ Math.max(...[...columns.values()].map((names) => names.length)) * (NODE_H + ROW_GAP) -
59
+ ROW_GAP
60
+
61
+ const edges: Array<string> = []
62
+ for (const name of graph.order) {
63
+ const model = graph.models.get(name)!
64
+ const to = position.get(name)!
65
+ for (const dep of model.deps) {
66
+ const from = position.get(dep)!
67
+ const x1 = from.x + NODE_W
68
+ const y1 = from.y + NODE_H / 2
69
+ const x2 = to.x
70
+ const y2 = to.y + NODE_H / 2
71
+ const bend = (x2 - x1) / 2
72
+ edges.push(
73
+ `<path class="edge" data-from="${escapeHtml(dep)}" data-to="${escapeHtml(name)}" d="M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}"/>`,
74
+ )
75
+ }
76
+ }
77
+
78
+ const nodes = graph.order.map((name) => {
79
+ const model = graph.models.get(name)!
80
+ const { x, y } = position.get(name)!
81
+ const color = KIND_COLOR[model.kind._tag] ?? "#666"
82
+ const title =
83
+ model.description !== undefined ? `${name} — ${model.description}` : name
84
+ return `<g class="node" data-name="${escapeHtml(name)}" transform="translate(${x}, ${y})">
85
+ <title>${escapeHtml(title)}</title>
86
+ <rect width="${NODE_W}" height="${NODE_H}" rx="8"/>
87
+ <rect width="4" height="${NODE_H}" rx="2" fill="${color}"/>
88
+ <text x="14" y="19" class="name">${escapeHtml(name)}</text>
89
+ <text x="14" y="36" class="kind" fill="${color}">${escapeHtml(model.kind._tag)}</text>
90
+ </g>`
91
+ })
92
+
93
+ const legend = Object.entries(KIND_COLOR)
94
+ .filter(([tag]) => [...graph.models.values()].some((model) => model.kind._tag === tag))
95
+ .map(
96
+ ([tag, color]) =>
97
+ `<span class="badge"><i style="background:${color}"></i>${escapeHtml(tag)}</span>`,
98
+ )
99
+ .join("")
100
+
101
+ return `<!doctype html>
102
+ <html lang="ru">
103
+ <head>
104
+ <meta charset="utf-8">
105
+ <title>efmesh — DAG моделей</title>
106
+ <style>
107
+ :root { color-scheme: light dark; }
108
+ body { font: 14px/1.4 system-ui, sans-serif; margin: 0; padding: 16px;
109
+ background: light-dark(#fafafa, #16181d); color: light-dark(#222, #ddd); }
110
+ h1 { font-size: 16px; margin: 0 0 4px; }
111
+ .legend { margin-bottom: 12px; }
112
+ .badge { display: inline-flex; align-items: center; gap: 5px; margin-right: 14px;
113
+ font-size: 12px; opacity: .85; }
114
+ .badge i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
115
+ svg { max-width: 100%; height: auto; }
116
+ .node rect:first-of-type { fill: light-dark(#fff, #23262e);
117
+ stroke: light-dark(#d5d5d5, #3a3f4a); }
118
+ .node .name { font-weight: 600; font-size: 13px; fill: light-dark(#222, #eee); }
119
+ .node .kind { font-size: 11px; }
120
+ .edge { fill: none; stroke: light-dark(#b9c0c9, #4a5160); stroke-width: 1.5; }
121
+ .node, .edge { transition: opacity .12s ease; }
122
+ svg.focused .node:not(.lit), svg.focused .edge:not(.lit) { opacity: .18; }
123
+ .edge.lit { stroke: light-dark(#e07a5f, #e6a08c); stroke-width: 2; }
124
+ </style>
125
+ </head>
126
+ <body>
127
+ <h1>efmesh — DAG моделей</h1>
128
+ <div class="legend">${legend}</div>
129
+ <svg id="dag" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
130
+ ${edges.join("\n")}
131
+ ${nodes.join("\n")}
132
+ </svg>
133
+ <script>
134
+ const svg = document.getElementById("dag")
135
+ const edges = [...svg.querySelectorAll(".edge")]
136
+ for (const node of svg.querySelectorAll(".node")) {
137
+ node.addEventListener("mouseenter", () => {
138
+ const name = node.dataset.name
139
+ svg.classList.add("focused")
140
+ node.classList.add("lit")
141
+ for (const edge of edges) {
142
+ if (edge.dataset.from !== name && edge.dataset.to !== name) continue
143
+ edge.classList.add("lit")
144
+ const other = edge.dataset.from === name ? edge.dataset.to : edge.dataset.from
145
+ svg.querySelector('.node[data-name="' + CSS.escape(other) + '"]')?.classList.add("lit")
146
+ }
147
+ })
148
+ node.addEventListener("mouseleave", () => {
149
+ svg.classList.remove("focused")
150
+ for (const lit of svg.querySelectorAll(".lit")) lit.classList.remove("lit")
151
+ })
152
+ }
153
+ </script>
154
+ </body>
155
+ </html>
156
+ `
157
+ }
@@ -0,0 +1,130 @@
1
+ import { rmSync } from "node:fs"
2
+ import { Clock, Effect } from "effect"
3
+ import { parseModelName } from "../core/model.ts"
4
+ import { EngineAdapter } from "../engine/adapter.ts"
5
+ import type { EngineError } from "../engine/adapter.ts"
6
+ import { StateStore } from "../state/store.ts"
7
+ import type { StateError } from "../state/store.ts"
8
+ import { janitorLockName, withStateLock, type LockHeldError, type LockOptions } from "./lock.ts"
9
+ import { ducklakeAttachSql, ducklakeRef, parquetPrefix, physicalRef } from "./naming.ts"
10
+
11
+ /**
12
+ * Уборка осиротевшей физики (SPEC §5.4): снапшоты, на которые не ссылается
13
+ * ни одно окружение и которые осиротели раньше, чем ttl назад, сносятся —
14
+ * таблица/view движка, parquet-префикс озера, запись снапшота и учёт
15
+ * интервалов.
16
+ *
17
+ * ttl отсчитывается от orphaned_at — отметки, которую промоушен ставит
18
+ * при потере последней ссылки и снимает при возврате (откат на старую
19
+ * версию обнуляет счётчик). Для записей без отметки (ни разу не
20
+ * промоутились — например, apply упал до промоушена) — от created_at.
21
+ * ttl по умолчанию 7 дней — достаточно, чтобы мгновенно откатиться
22
+ * переключением view.
23
+ */
24
+
25
+ export interface JanitorOptions extends LockOptions {
26
+ readonly ttlDays?: number
27
+ readonly lakePath?: string
28
+ /** DuckLake-каталог (SPEC §14.5) — чтобы снести и таблицы-на-fingerprint в нём. */
29
+ readonly ducklake?: { readonly catalog: string; readonly dataPath?: string }
30
+ /** «Сейчас» — инъекция для тестов. */
31
+ readonly now?: number
32
+ }
33
+
34
+ export interface JanitorReport {
35
+ /** Снесённые снапшоты в виде `имя@fp8`. */
36
+ readonly removed: ReadonlyArray<string>
37
+ /** Осиротевшие, но моложе ttl — остаются до следующего раза. */
38
+ readonly kept: ReadonlyArray<string>
39
+ }
40
+
41
+ const DAY_MS = 86_400_000
42
+
43
+ export const janitor = (
44
+ options?: JanitorOptions,
45
+ ): Effect.Effect<
46
+ JanitorReport,
47
+ EngineError | StateError | LockHeldError,
48
+ EngineAdapter | StateStore
49
+ > =>
50
+ Effect.gen(function* () {
51
+ const engine = yield* EngineAdapter
52
+ const store = yield* StateStore
53
+ const now = options?.now ?? (yield* Clock.currentTimeMillis)
54
+ const ttlMs = (options?.ttlDays ?? 7) * DAY_MS
55
+
56
+ // снапшот не хранит цель материализации — при настроенном каталоге
57
+ // таблица сносится и там, и в _efmesh (DROP IF EXISTS терпим к пустоте)
58
+ const ducklake = options?.ducklake
59
+ if (ducklake !== undefined && engine.dialect === "duckdb") {
60
+ yield* engine.execute(ducklakeAttachSql(ducklake))
61
+ }
62
+
63
+ const referenced = yield* store.listReferencedFingerprints()
64
+ const removed: Array<string> = []
65
+ const kept: Array<string> = []
66
+
67
+ const snapshots = yield* store.listSnapshots()
68
+ const deadline = new Date(now - ttlMs).toISOString()
69
+ const isDoomed = (snapshot: (typeof snapshots)[number]): boolean =>
70
+ !referenced.has(snapshot.fingerprint) &&
71
+ (snapshot.orphanedAt ?? snapshot.createdAt) <= deadline
72
+
73
+ // фаза 1 — транзакционный claim записей: снос состоится, только если
74
+ // снапшот ВСЁ ЕЩЁ не referenced и сирота (проверки атомарны с удалением);
75
+ // параллельный apply, воскресивший версию (upsert снимает orphaned_at),
76
+ // claim проиграет — и её физика не тронется (гонка F6)
77
+ const claimed: Array<(typeof snapshots)[number]> = []
78
+ for (const snapshot of snapshots) {
79
+ if (referenced.has(snapshot.fingerprint)) continue
80
+ const label = `${snapshot.name}@${snapshot.fingerprint.slice(0, 8)}`
81
+ if (!isDoomed(snapshot)) {
82
+ kept.push(label)
83
+ continue
84
+ }
85
+ const won = yield* store.deleteSnapshotIfDoomed(
86
+ snapshot.name,
87
+ snapshot.fingerprint,
88
+ deadline,
89
+ )
90
+ if (!won) {
91
+ kept.push(label)
92
+ continue
93
+ }
94
+ claimed.push(snapshot)
95
+ removed.push(label)
96
+ }
97
+
98
+ // фаза 2 — снос физики по СВЕЖЕМУ состоянию стора: физика делится между
99
+ // версиями (forward-only) и сносится, только если после claim'ов её не
100
+ // использует ни один выживший снапшот
101
+ const survivors = yield* store.listSnapshots()
102
+ const physicalInUse = new Set(survivors.map((snapshot) => snapshot.physicalFp))
103
+ const dropped = new Set<string>()
104
+ for (const snapshot of claimed) {
105
+ if (physicalInUse.has(snapshot.physicalFp) || dropped.has(snapshot.physicalFp)) continue
106
+ dropped.add(snapshot.physicalFp)
107
+ const name = parseModelName(snapshot.name)
108
+ const target = physicalRef(name, snapshot.physicalFp)
109
+ yield* engine.execute(
110
+ snapshot.kind === "view"
111
+ ? `DROP VIEW IF EXISTS ${target}`
112
+ : `DROP TABLE IF EXISTS ${target}`,
113
+ )
114
+ if (ducklake !== undefined && engine.dialect === "duckdb" && snapshot.kind !== "view") {
115
+ yield* engine.execute(
116
+ `DROP TABLE IF EXISTS ${ducklakeRef(name, snapshot.physicalFp)}`,
117
+ )
118
+ }
119
+ if (options?.lakePath !== undefined && !options.lakePath.startsWith("s3://")) {
120
+ const prefix = parquetPrefix(options.lakePath, name, snapshot.physicalFp)
121
+ yield* Effect.sync(() => rmSync(prefix, { recursive: true, force: true }))
122
+ }
123
+ }
124
+
125
+ return { removed, kept }
126
+ }).pipe(
127
+ // два janitor'а из разных процессов не должны наперегонки сносить одно и
128
+ // то же; от гонки janitor↔apply защищает ttl (окно на мгновенный откат)
129
+ withStateLock(janitorLockName, options?.lockTtlMs),
130
+ )
@@ -0,0 +1,143 @@
1
+ import { Data, Effect } from "effect"
2
+ import type { ModelGraph } from "../core/graph.ts"
3
+ import type { AnyModel } from "../core/model.ts"
4
+ import type { EngineError, SqlParseError } from "../engine/adapter.ts"
5
+ import { EngineAdapter } from "../engine/adapter.ts"
6
+ import { canonicalSql } from "./fingerprint.ts"
7
+
8
+ /**
9
+ * Колоночный lineage (SPEC §9.4): цепочка от колонки модели до сырьевых
10
+ * колонок external/seed-моделей. Точность best-effort — выражение колонки
11
+ * берётся из канонического AST (родной парсер движка), ссылки на колонки
12
+ * сопоставляются схемам родителей по имени, квалификаторы и алиасы CTE
13
+ * не разворачиваются. Граф моделей при этом точен всегда: зависимости
14
+ * известны из `ctx.ref`, не из парсинга текста.
15
+ */
16
+
17
+ export class LineageError extends Data.TaggedError("LineageError")<{
18
+ readonly model: string
19
+ readonly reason: string
20
+ }> {}
21
+
22
+ export interface LineageNode {
23
+ readonly model: string
24
+ readonly column: string
25
+ /** Вид модели — external/seed являются листьями (сырьё). */
26
+ readonly kind: string
27
+ readonly sources: ReadonlyArray<LineageNode>
28
+ }
29
+
30
+ /** Все COLUMN_REF в поддереве выражения — имена колонок без квалификаторов. */
31
+ const collectColumnRefs = (node: unknown, out: Set<string>): void => {
32
+ if (Array.isArray(node)) {
33
+ for (const item of node) collectColumnRefs(item, out)
34
+ return
35
+ }
36
+ if (node === null || typeof node !== "object") return
37
+ const record = node as Record<string, unknown>
38
+ if (record.class === "COLUMN_REF" && Array.isArray(record.column_names)) {
39
+ const names = record.column_names as ReadonlyArray<string>
40
+ if (names.length > 0) out.add(names[names.length - 1]!)
41
+ }
42
+ for (const value of Object.values(record)) collectColumnRefs(value, out)
43
+ }
44
+
45
+ const selectItems = (ast: unknown): ReadonlyArray<Record<string, unknown>> => {
46
+ const statements = (ast as { statements?: ReadonlyArray<{ node?: unknown }> }).statements
47
+ const node = statements?.[0]?.node as { select_list?: ReadonlyArray<unknown> } | undefined
48
+ return (node?.select_list ?? []) as ReadonlyArray<Record<string, unknown>>
49
+ }
50
+
51
+ /**
52
+ * Колонки, от которых зависит `column` в select_list: выражение с алиасом
53
+ * или одноимённый COLUMN_REF; `SELECT *` — сквозной проброс имени.
54
+ * undefined — выражение колонки не найдено (движковый сахар) — best-effort.
55
+ */
56
+ const sourceColumnsOf = (ast: unknown, column: string): ReadonlySet<string> | undefined => {
57
+ const items = selectItems(ast)
58
+ const named =
59
+ items.find((item) => item.alias === column) ??
60
+ items.find((item) => {
61
+ if (item.alias !== "" && item.alias !== undefined) return false
62
+ if (item.class !== "COLUMN_REF" || !Array.isArray(item.column_names)) return false
63
+ const names = item.column_names as ReadonlyArray<string>
64
+ return names[names.length - 1] === column
65
+ })
66
+ if (named !== undefined) {
67
+ const out = new Set<string>()
68
+ collectColumnRefs(named, out)
69
+ return out
70
+ }
71
+ if (items.some((item) => item.class === "STAR" || item.type === "STAR")) {
72
+ return new Set([column])
73
+ }
74
+ return undefined
75
+ }
76
+
77
+ export const lineage = (
78
+ graph: ModelGraph,
79
+ modelName: string,
80
+ column: string,
81
+ ): Effect.Effect<LineageNode, LineageError | EngineError | SqlParseError, EngineAdapter> =>
82
+ Effect.gen(function* () {
83
+ const engine = yield* EngineAdapter
84
+ const root = graph.models.get(modelName)
85
+ if (root === undefined) {
86
+ return yield* new LineageError({ model: modelName, reason: "модели нет в проекте" })
87
+ }
88
+ if (!(column in root.schema.fields)) {
89
+ return yield* new LineageError({
90
+ model: modelName,
91
+ reason: `колонки «${column}» нет в схеме`,
92
+ })
93
+ }
94
+
95
+ const asts = new Map<string, unknown>()
96
+ const astOf = (model: AnyModel): Effect.Effect<unknown, EngineError | SqlParseError> =>
97
+ Effect.gen(function* () {
98
+ const cached = asts.get(model.name.full)
99
+ if (cached !== undefined) return cached
100
+ const ast = JSON.parse(
101
+ yield* engine.canonicalize(canonicalSql(graph, model.name.full)),
102
+ ) as unknown
103
+ asts.set(model.name.full, ast)
104
+ return ast
105
+ })
106
+
107
+ const trace = (
108
+ model: AnyModel,
109
+ wanted: string,
110
+ ): Effect.Effect<LineageNode, EngineError | SqlParseError> =>
111
+ Effect.gen(function* () {
112
+ const leaf: LineageNode = {
113
+ model: model.name.full,
114
+ column: wanted,
115
+ kind: model.kind._tag,
116
+ sources: [],
117
+ }
118
+ // сырьё: дальше цепочка упирается во внешний мир
119
+ if (model.kind._tag === "external" || model.kind._tag === "seed" || model.deps.size === 0) {
120
+ return leaf
121
+ }
122
+ const columns = sourceColumnsOf(yield* astOf(model), wanted)
123
+ if (columns === undefined) return leaf
124
+ const sources: Array<LineageNode> = []
125
+ for (const source of columns) {
126
+ for (const parent of model.refs.values()) {
127
+ if (!(source in parent.schema.fields)) continue
128
+ sources.push(yield* trace(parent, source))
129
+ }
130
+ }
131
+ return { ...leaf, sources }
132
+ })
133
+
134
+ return yield* trace(root, column)
135
+ })
136
+
137
+ /** Плоская печать lineage-дерева для CLI. */
138
+ export const formatLineage = (node: LineageNode, indent = ""): ReadonlyArray<string> => {
139
+ const marker =
140
+ node.kind === "external" || node.kind === "seed" ? ` [${node.kind}]` : ""
141
+ const line = `${indent}${node.model}.${node.column}${marker}`
142
+ return [line, ...node.sources.flatMap((source) => formatLineage(source, `${indent} `))]
143
+ }
@@ -0,0 +1,37 @@
1
+ import { Data, Effect } from "effect"
2
+ import { StateStore, type StateError } from "../state/store.ts"
3
+
4
+ /**
5
+ * Межпроцессная блокировка через state store (SPEC §7, §14.6): мутации
6
+ * окружения — apply и run — идут под ОДНИМ локом `env:<имя>`, поэтому
7
+ * параллельные apply+apply и apply+run из разных процессов отсекаются.
8
+ * Протухший лок упавшего процесса перехватывается по ttl (учтена гонка
9
+ * в ту же миллисекунду: expires_at <= now).
10
+ */
11
+
12
+ export class LockHeldError extends Data.TaggedError("LockHeldError")<{
13
+ readonly name: string
14
+ }> {}
15
+
16
+ export interface LockOptions {
17
+ /** Сколько лок живёт без освобождения (упавший процесс); по умолчанию 1 час. */
18
+ readonly lockTtlMs?: number
19
+ }
20
+
21
+ /** Имя лока, под которым мутируется окружение (общий для apply и run). */
22
+ export const envLockName = (env: string): string => `env:${env}`
23
+
24
+ /** Лок janitor — глобальный: уборка физики не привязана к окружению. */
25
+ export const janitorLockName = "janitor"
26
+
27
+ export const withStateLock =
28
+ (name: string, ttlMs?: number) =>
29
+ <A, E, R>(
30
+ effect: Effect.Effect<A, E, R>,
31
+ ): Effect.Effect<A, E | LockHeldError | StateError, R | StateStore> =>
32
+ Effect.gen(function* () {
33
+ const store = yield* StateStore
34
+ const acquired = yield* store.acquireLock(name, ttlMs ?? 3_600_000)
35
+ if (!acquired) return yield* new LockHeldError({ name })
36
+ return yield* effect.pipe(Effect.ensuring(store.releaseLock(name).pipe(Effect.ignore)))
37
+ })
@@ -0,0 +1,14 @@
1
+ import { Metric } from "effect"
2
+
3
+ /** Наблюдаемость из коробки (SPEC §10): счётчики конвейера. */
4
+ export const intervalsDone = Metric.counter("efmesh_intervals_done_total", {
5
+ description: "Сколько интервалов досчитано и помечено done",
6
+ })
7
+
8
+ export const auditFailuresTotal = Metric.counter("efmesh_audit_failures_total", {
9
+ description: "Сколько blocking-аудитов провалилось",
10
+ })
11
+
12
+ export const snapshotsBuilt = Metric.counter("efmesh_snapshots_built_total", {
13
+ description: "Сколько снапшотов собрано (физика или бэкфилл)",
14
+ })