@avytheone/efmesh 0.1.0-beta.2 → 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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Categorization explanation (#4, SPEC §5.2): `plan --explain` shows WHICH
3
+ * nodes of the canonical AST diverged and why the category is what it is.
4
+ * Paths follow the engine's canon tree (json_serialize_sql on DuckDB,
5
+ * libpg_query on Postgres) — this is a debugging hint, not a contract:
6
+ * the shape of the paths moves with the canon and is not a semver event.
7
+ * The category itself is computed by categorize.ts; here — only its rationale.
8
+ */
9
+
10
+ import { topSelect } from "./categorize.ts"
11
+
12
+ export interface ChangeExplanation {
13
+ /** Paths of diverged canonical-AST nodes (no more than MAX_DIVERGED). */
14
+ readonly diverged: ReadonlyArray<string>
15
+ /** Why the category is what it is. */
16
+ readonly reason: string
17
+ /** Changed direct parents — the source of the indirect/forward-only cascade. */
18
+ readonly cascadeFrom?: ReadonlyArray<string>
19
+ }
20
+
21
+ /** Beyond this, pointwise paths stop helping — better to open render/diff. */
22
+ const MAX_DIVERGED = 8
23
+
24
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
25
+ typeof value === "object" && value !== null && !Array.isArray(value)
26
+
27
+ const at = (path: string, segment: string): string =>
28
+ path === "" ? segment : `${path}.${segment}`
29
+
30
+ const walk = (before: unknown, after: unknown, path: string, out: Array<string>): void => {
31
+ if (out.length >= MAX_DIVERGED) return
32
+ if (JSON.stringify(before) === JSON.stringify(after)) return
33
+ if (Array.isArray(before) && Array.isArray(after)) {
34
+ const shared = Math.min(before.length, after.length)
35
+ for (let i = 0; i < shared; i++) walk(before[i], after[i], `${path}[${i}]`, out)
36
+ for (let i = shared; i < after.length && out.length < MAX_DIVERGED; i++) {
37
+ out.push(`${path}[${i}] (added)`)
38
+ }
39
+ for (let i = shared; i < before.length && out.length < MAX_DIVERGED; i++) {
40
+ out.push(`${path}[${i}] (removed)`)
41
+ }
42
+ return
43
+ }
44
+ if (isRecord(before) && isRecord(after)) {
45
+ // node replaced by an expression of another type — pointwise paths inside are meaningless
46
+ if (before["type"] !== after["type"]) {
47
+ out.push(path === "" ? "(root)" : path)
48
+ return
49
+ }
50
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()
51
+ for (const key of keys) walk(before[key], after[key], at(path, key), out)
52
+ return
53
+ }
54
+ out.push(path === "" ? "(root)" : path)
55
+ }
56
+
57
+ /** The statement's boilerplate wrapper in the path is uninteresting — trim to the query body. */
58
+ const trimStatement = (path: string): string =>
59
+ path.replace(/^statements\[0\]\.node\.?/, "").replace(/^stmts\[0\]\.stmt\.?/, "") ||
60
+ "(root)"
61
+
62
+ /** Divergence paths of two canonical ASTs (JSON strings); garbage input — empty. */
63
+ export const divergedPaths = (oldAst: string, newAst: string): ReadonlyArray<string> => {
64
+ try {
65
+ const out: Array<string> = []
66
+ walk(JSON.parse(oldAst), JSON.parse(newAst), "", out)
67
+ return out.map(trimStatement)
68
+ } catch {
69
+ return []
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Override guardrail (#5): the new top SELECT has fewer columns — they were
75
+ * removed, descendants read them by name, so «non-breaking» plainly contradicts
76
+ * the AST. An unparseable canon does not count as a contradiction: the decision
77
+ * is the operator's, the override is explicit and journaled.
78
+ */
79
+ export const dropsColumns = (oldAst: string, newAst: string): boolean => {
80
+ const before = topSelect(oldAst)
81
+ const after = topSelect(newAst)
82
+ if (before === null || after === null) return false
83
+ return after.list.length < before.list.length
84
+ }
85
+
86
+ /**
87
+ * Justifies the categorizeAstChange verdict by the same rules that produced
88
+ * it (SPEC §5.2): non-breaking — only a suffix of the top SELECT.
89
+ */
90
+ export const explainCategorized = (
91
+ oldAst: string,
92
+ newAst: string,
93
+ change: "breaking" | "non-breaking",
94
+ ): ChangeExplanation => {
95
+ const diverged = divergedPaths(oldAst, newAst)
96
+ if (change === "non-breaking") {
97
+ return {
98
+ diverged,
99
+ reason:
100
+ "columns were appended to the end of the top SELECT, the rest of the tree is untouched — consumers read by name, no rebuild needed",
101
+ }
102
+ }
103
+ const before = topSelect(oldAst)
104
+ const after = topSelect(newAst)
105
+ const reason =
106
+ before === null || after === null
107
+ ? "canonical AST of an unexpected shape — conservatively breaking"
108
+ : before.rest !== after.rest
109
+ ? "the tree diverged outside the SELECT list (FROM/WHERE/JOIN/GROUP BY/modifiers)"
110
+ : after.list.length < before.list.length
111
+ ? "columns were removed from SELECT — descendants insert by position, physics must be rebuilt"
112
+ : "the SELECT list changed not only at its tail — editing or reordering columns breaks descendants' positions"
113
+ return { diverged, reason }
114
+ }
@@ -9,24 +9,26 @@ import type { EngineError, SqlParseError } from "../engine/adapter.ts"
9
9
  import { EngineAdapter } from "../engine/adapter.ts"
10
10
 
11
11
  /**
12
- * Fingerprint снапшота (SPEC §4): хэш канонизированного AST (родной парсер
13
- * движка, переформатирование запроса fingerprint не меняет), метаданных,
14
- * влияющих на данные, и fingerprint'ов прямых зависимостей (транзитивность).
12
+ * Snapshot fingerprint (SPEC §4): hash of the canonicalized AST (engine's
13
+ * native parser reformatting a query does not change the fingerprint),
14
+ * of the metadata that affects data, and of the fingerprints of direct
15
+ * dependencies (transitivity).
15
16
  *
16
- * `batchSize`, `lookback`, `start` и `description` в fingerprint не входят:
17
- * они меняют исполнение или объём истории, но не форму данных недостающие
18
- * интервалы учёт увидит сам.
17
+ * `batchSize`, `lookback`, `start` and `description` do not enter the
18
+ * fingerprint: they change execution or the amount of history, not the shape
19
+ * of the data — the interval ledger will notice missing intervals on its own.
19
20
  */
20
21
 
21
22
  /**
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
- * другой версии план не сравнивает, а честно останавливается.
23
+ * Fingerprint algorithm versiona CONTRACT (SPEC §4). The fingerprint
24
+ * depends on the engine canonicalization (DuckDB json_serialize_sql /
25
+ * libpg_query) and on the composition of the payload below: any change to
26
+ * them silently re-fingerprints all of a user's models and forces a full
27
+ * rebuild of the warehouse. Therefore: (1) canonicalization is frozen by
28
+ * golden tests (test/fingerprint-golden.test.ts) a red test on a
29
+ * DuckDB/libpg_query upgrade means canon drift; (2) a deliberate change of
30
+ * algorithm = increment of this constant + a migration history; the plan does
31
+ * not compare snapshots of a different version, it honestly stops instead.
30
32
  */
31
33
  export const FINGERPRINT_VERSION = 1
32
34
 
@@ -36,14 +38,16 @@ const sha256 = (input: string): string => {
36
38
  return hasher.digest("hex")
37
39
  }
38
40
 
39
- /** Canonical-рендер: ссылки логические имена, границы плейсхолдеры $start/$end. */
41
+ /** Canonical render: refs are logical names, bounds are $start/$end placeholders. */
40
42
  export const canonicalSql = (graph: ModelGraph, name: string): string => {
41
43
  const model = graph.models.get(name)
42
- if (model === undefined) throw new Error(`модели ${name} нет в графе`)
44
+ // invariant: callers pass a name already known to the graph (the facade
45
+ // guards user input with UnknownModelError before reaching here)
46
+ if (model === undefined) throw new Error(`invariant violated: model «${name}» is not in the graph`)
43
47
  return render(model.fragment, { resolveRef: (ref) => ref })
44
48
  }
45
49
 
46
- /** Часть kind, влияющая на данные. Для seed — хэш содержимого файла: правка данных = новая версия. */
50
+ /** Part of kind that affects data. For seed — hash of the file contents: editing the data = a new version. */
47
51
  const kindPayload = (
48
52
  model: { readonly name: { readonly full: string } },
49
53
  kind: ModelKind,
@@ -85,13 +89,61 @@ const kindPayload = (
85
89
 
86
90
  export interface ModelVersion {
87
91
  readonly fingerprint: string
88
- /** Канонический AST тела; null у external. Хранится в снапшоте для категоризации (§5.2). */
92
+ /** Canonical AST of the body; null for external. Stored in the snapshot for categorization (§5.2). */
89
93
  readonly ast: string | null
90
94
  }
91
95
 
92
- /** Fingerprint всех моделей графа; транзитивность — через хэши родителей. */
96
+ /**
97
+ * Canonicalization cache (#8): a repeated plan consists almost entirely of
98
+ * json_serialize_sql round-trips over unchanged models. The cache is not
99
+ * data: get/put must be infallible (a miss/failure = recompute).
100
+ */
101
+ export interface CanonCache {
102
+ readonly get: (key: string) => Effect.Effect<string | undefined>
103
+ readonly put: (key: string, canonical: string) => Effect.Effect<void>
104
+ }
105
+
106
+ /** Cache key: algorithm version + dialect + source — a canon upgrade is not masked. */
107
+ export const canonCacheKey = (dialect: string, source: string): string =>
108
+ sha256(`${FINGERPRINT_VERSION}:${dialect}:${source}`)
109
+
110
+ /** The model in the scope the fingerprint needs: kind, data-shape metadata, target. */
111
+ type FingerprintableModel = Parameters<typeof columnNames>[0] & {
112
+ readonly name: { readonly full: string }
113
+ readonly kind: ModelKind
114
+ readonly grain: ReadonlyArray<string> | undefined
115
+ readonly target: string | undefined
116
+ }
117
+
118
+ /**
119
+ * Fingerprint of a single model from a ready AST and parent signatures
120
+ * (`name=fingerprint`, sorted). The planner needs it to check the
121
+ * "version shifted by parents ONLY" case (#5): a recompute with the old
122
+ * signatures must yield the old fingerprint — otherwise the metadata diverged
123
+ * too, and the physics cannot be reused.
124
+ */
125
+ export const modelFingerprint = (
126
+ model: FingerprintableModel,
127
+ ast: string | null,
128
+ parents: ReadonlyArray<string>,
129
+ ): Effect.Effect<string, SeedReadError> =>
130
+ Effect.gen(function* () {
131
+ const payload = JSON.stringify({
132
+ ast,
133
+ kind: yield* kindPayload(model, model.kind),
134
+ grain: model.grain,
135
+ columns: columnNames(model),
136
+ // a change of materialization target = new physics, consumers re-read it
137
+ target: model.target,
138
+ parents,
139
+ })
140
+ return sha256(payload)
141
+ })
142
+
143
+ /** Fingerprint of all models in the graph; transitivity via parent hashes. */
93
144
  export const fingerprintGraph = (
94
145
  graph: ModelGraph,
146
+ cache?: CanonCache,
95
147
  ): Effect.Effect<
96
148
  ReadonlyMap<string, ModelVersion>,
97
149
  EngineError | SqlParseError | SeedReadError,
@@ -99,27 +151,28 @@ export const fingerprintGraph = (
99
151
  > =>
100
152
  Effect.gen(function* () {
101
153
  const engine = yield* EngineAdapter
154
+ const canonicalize = (source: string): Effect.Effect<string, EngineError | SqlParseError> =>
155
+ Effect.gen(function* () {
156
+ if (cache === undefined) return yield* engine.canonicalize(source)
157
+ const key = canonCacheKey(engine.dialect, source)
158
+ const hit = yield* cache.get(key)
159
+ if (hit !== undefined) return hit
160
+ const canon = yield* engine.canonicalize(source)
161
+ yield* cache.put(key, canon)
162
+ return canon
163
+ })
102
164
  const versions = new Map<string, ModelVersion>()
103
165
  for (const name of graph.order) {
104
166
  const model = graph.models.get(name)!
105
- // у external и seed нет SQL — версия определяется источником/файлом и схемой
167
+ // external and seed have no SQL — the version is determined by source/file and schema
106
168
  const ast =
107
169
  model.kind._tag === "external" || model.kind._tag === "seed"
108
170
  ? null
109
- : yield* engine.canonicalize(canonicalSql(graph, name))
171
+ : yield* canonicalize(canonicalSql(graph, name))
110
172
  const parents = [...model.deps]
111
173
  .sort()
112
174
  .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 })
175
+ versions.set(name, { fingerprint: yield* modelFingerprint(model, ast, parents), ast })
123
176
  }
124
177
  return versions
125
178
  })
@@ -1,9 +1,9 @@
1
1
  import type { ModelGraph } from "../core/graph.ts"
2
2
 
3
3
  /**
4
- * `efmesh graph --html` (SPEC §11): самодостаточная страница с DAG моделей
5
- * SVG без внешних зависимостей, слои по длиннейшему пути от корней,
6
- * рёбра кривые Безье, подсветка соседей по наведению.
4
+ * `efmesh graph --html` (SPEC §11): a self-contained page with the model DAG —
5
+ * SVG with no external dependencies, layers by the longest path from the
6
+ * roots, edges are Bézier curves, neighbors highlighted on hover.
7
7
  */
8
8
 
9
9
  const KIND_COLOR: Record<string, string> = {
@@ -27,7 +27,7 @@ const ROW_GAP = 26
27
27
  const PAD = 32
28
28
 
29
29
  export const renderGraphHtml = (graph: ModelGraph): string => {
30
- // слой = длиннейший путь от корней: родители всегда левее потомков
30
+ // layer = longest path from the roots: parents are always left of descendants
31
31
  const depth = new Map<string, number>()
32
32
  for (const name of graph.order) {
33
33
  const model = graph.models.get(name)!
@@ -99,10 +99,10 @@ export const renderGraphHtml = (graph: ModelGraph): string => {
99
99
  .join("")
100
100
 
101
101
  return `<!doctype html>
102
- <html lang="ru">
102
+ <html lang="en">
103
103
  <head>
104
104
  <meta charset="utf-8">
105
- <title>efmesh — DAG моделей</title>
105
+ <title>efmesh — model DAG</title>
106
106
  <style>
107
107
  :root { color-scheme: light dark; }
108
108
  body { font: 14px/1.4 system-ui, sans-serif; margin: 0; padding: 16px;
@@ -124,7 +124,7 @@ export const renderGraphHtml = (graph: ModelGraph): string => {
124
124
  </style>
125
125
  </head>
126
126
  <body>
127
- <h1>efmesh — DAG моделей</h1>
127
+ <h1>efmesh — model DAG</h1>
128
128
  <div class="legend">${legend}</div>
129
129
  <svg id="dag" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
130
130
  ${edges.join("\n")}
@@ -9,32 +9,32 @@ import { janitorLockName, withStateLock, type LockHeldError, type LockOptions }
9
9
  import { ducklakeAttachSql, ducklakeRef, parquetPrefix, physicalRef } from "./naming.ts"
10
10
 
11
11
  /**
12
- * Уборка осиротевшей физики (SPEC §5.4): снапшоты, на которые не ссылается
13
- * ни одно окружение и которые осиротели раньше, чем ttl назад, сносятся
14
- * таблица/view движка, parquet-префикс озера, запись снапшота и учёт
15
- * интервалов.
12
+ * Cleanup of orphaned physical storage (SPEC §5.4): snapshots referenced by
13
+ * no environment and orphaned longer than ttl ago are removed the engine
14
+ * table/view, the lake parquet prefix, the snapshot record and the interval
15
+ * bookkeeping.
16
16
  *
17
- * ttl отсчитывается от orphaned_at — отметки, которую промоушен ставит
18
- * при потере последней ссылки и снимает при возврате (откат на старую
19
- * версию обнуляет счётчик). Для записей без отметки (ни разу не
20
- * промоутились например, apply упал до промоушена) — от created_at.
21
- * ttl по умолчанию 7 днейдостаточно, чтобы мгновенно откатиться
22
- * переключением view.
17
+ * ttl is measured from orphaned_at — the mark a promotion sets when the last
18
+ * reference is lost and clears on return (rolling back to an old version
19
+ * resets the counter). For records without the mark (never promoted — e.g.
20
+ * an apply that crashed before promotion) — from created_at.
21
+ * ttl defaults to 7 daysenough to roll back instantly by switching the
22
+ * view.
23
23
  */
24
24
 
25
25
  export interface JanitorOptions extends LockOptions {
26
26
  readonly ttlDays?: number
27
27
  readonly lakePath?: string
28
- /** DuckLake-каталог (SPEC §14.5) — чтобы снести и таблицы-на-fingerprint в нём. */
28
+ /** DuckLake catalog (SPEC §14.5) — to also drop the fingerprint tables inside it. */
29
29
  readonly ducklake?: { readonly catalog: string; readonly dataPath?: string }
30
- /** «Сейчас»инъекция для тестов. */
30
+ /** «Now»injected for tests. */
31
31
  readonly now?: number
32
32
  }
33
33
 
34
34
  export interface JanitorReport {
35
- /** Снесённые снапшоты в виде `имя@fp8`. */
35
+ /** Removed snapshots as `name@fp8`. */
36
36
  readonly removed: ReadonlyArray<string>
37
- /** Осиротевшие, но моложе ttl — остаются до следующего раза. */
37
+ /** Orphaned but younger than ttl — kept until next time. */
38
38
  readonly kept: ReadonlyArray<string>
39
39
  }
40
40
 
@@ -53,8 +53,9 @@ export const janitor = (
53
53
  const now = options?.now ?? (yield* Clock.currentTimeMillis)
54
54
  const ttlMs = (options?.ttlDays ?? 7) * DAY_MS
55
55
 
56
- // снапшот не хранит цель материализациипри настроенном каталоге
57
- // таблица сносится и там, и в _efmesh (DROP IF EXISTS терпим к пустоте)
56
+ // a snapshot does not store its materialization target with a catalog
57
+ // configured, the table is dropped both there and in _efmesh (DROP IF EXISTS
58
+ // tolerates absence)
58
59
  const ducklake = options?.ducklake
59
60
  if (ducklake !== undefined && engine.dialect === "duckdb") {
60
61
  yield* engine.execute(ducklakeAttachSql(ducklake))
@@ -70,10 +71,11 @@ export const janitor = (
70
71
  !referenced.has(snapshot.fingerprint) &&
71
72
  (snapshot.orphanedAt ?? snapshot.createdAt) <= deadline
72
73
 
73
- // фаза 1 — транзакционный claim записей: снос состоится, только если
74
- // снапшот ВСЁ ЕЩЁ не referenced и сирота (проверки атомарны с удалением);
75
- // параллельный apply, воскресивший версию (upsert снимает orphaned_at),
76
- // claim проиграет и её физика не тронется (гонка F6)
74
+ // phase 1 — transactional claim of records: removal happens only if the
75
+ // snapshot is STILL not referenced and an orphan (the checks are atomic
76
+ // with the delete); against a parallel apply that resurrected the version
77
+ // (upsert clears orphaned_at) the claim loses and its physical storage
78
+ // is left untouched (F6 race)
77
79
  const claimed: Array<(typeof snapshots)[number]> = []
78
80
  for (const snapshot of snapshots) {
79
81
  if (referenced.has(snapshot.fingerprint)) continue
@@ -95,9 +97,9 @@ export const janitor = (
95
97
  removed.push(label)
96
98
  }
97
99
 
98
- // фаза 2 — снос физики по СВЕЖЕМУ состоянию стора: физика делится между
99
- // версиями (forward-only) и сносится, только если после claim'ов её не
100
- // использует ни один выживший снапшот
100
+ // phase 2 — drop physical storage by the FRESH store state: storage is
101
+ // shared between versions (forward-only) and dropped only if, after the
102
+ // claims, no surviving snapshot uses it
101
103
  const survivors = yield* store.listSnapshots()
102
104
  const physicalInUse = new Set(survivors.map((snapshot) => snapshot.physicalFp))
103
105
  const dropped = new Set<string>()
@@ -106,6 +108,9 @@ export const janitor = (
106
108
  dropped.add(snapshot.physicalFp)
107
109
  const name = parseModelName(snapshot.name)
108
110
  const target = physicalRef(name, snapshot.physicalFp)
111
+ yield* Effect.logDebug("dropping orphaned physics").pipe(
112
+ Effect.annotateLogs({ model: snapshot.name, physical: snapshot.physicalFp.slice(0, 8) }),
113
+ )
109
114
  yield* engine.execute(
110
115
  snapshot.kind === "view"
111
116
  ? `DROP VIEW IF EXISTS ${target}`
@@ -124,7 +129,7 @@ export const janitor = (
124
129
 
125
130
  return { removed, kept }
126
131
  }).pipe(
127
- // два janitor'а из разных процессов не должны наперегонки сносить одно и
128
- // то же; от гонки janitor↔apply защищает ttl (окно на мгновенный откат)
132
+ // two janitors from different processes must not race to remove the same
133
+ // thing; the janitor↔apply race is guarded by ttl (the window for an instant rollback)
129
134
  withStateLock(janitorLockName, options?.lockTtlMs),
130
135
  )
@@ -6,28 +6,32 @@ import { EngineAdapter } from "../engine/adapter.ts"
6
6
  import { canonicalSql } from "./fingerprint.ts"
7
7
 
8
8
  /**
9
- * Колоночный lineage (SPEC §9.4): цепочка от колонки модели до сырьевых
10
- * колонок external/seed-моделей. Точность best-effort — выражение колонки
11
- * берётся из канонического AST (родной парсер движка), ссылки на колонки
12
- * сопоставляются схемам родителей по имени, квалификаторы и алиасы CTE
13
- * не разворачиваются. Граф моделей при этом точен всегда: зависимости
14
- * известны из `ctx.ref`, не из парсинга текста.
9
+ * Column-level lineage (SPEC §9.4): the chain from a model's column down to
10
+ * the raw columns of external/seed models. Precision is best-effort — a
11
+ * column's expression is taken from the canonical AST (the engine's native
12
+ * parser), column references are matched to parents' schemas by name,
13
+ * qualifiers and CTE aliases are not resolved. The model graph itself is
14
+ * always exact: dependencies are known from `ctx.ref`, not from text parsing.
15
15
  */
16
16
 
17
17
  export class LineageError extends Data.TaggedError("LineageError")<{
18
18
  readonly model: string
19
19
  readonly reason: string
20
- }> {}
20
+ }> {
21
+ override get message(): string {
22
+ return `lineage «${this.model}»: ${this.reason}`
23
+ }
24
+ }
21
25
 
22
26
  export interface LineageNode {
23
27
  readonly model: string
24
28
  readonly column: string
25
- /** Вид модели — external/seed являются листьями (сырьё). */
29
+ /** Model kind — external/seed are leaves (raw sources). */
26
30
  readonly kind: string
27
31
  readonly sources: ReadonlyArray<LineageNode>
28
32
  }
29
33
 
30
- /** Все COLUMN_REF в поддереве выраженияимена колонок без квалификаторов. */
34
+ /** All COLUMN_REFs in the expression subtree column names without qualifiers. */
31
35
  const collectColumnRefs = (node: unknown, out: Set<string>): void => {
32
36
  if (Array.isArray(node)) {
33
37
  for (const item of node) collectColumnRefs(item, out)
@@ -49,9 +53,9 @@ const selectItems = (ast: unknown): ReadonlyArray<Record<string, unknown>> => {
49
53
  }
50
54
 
51
55
  /**
52
- * Колонки, от которых зависит `column` в select_list: выражение с алиасом
53
- * или одноимённый COLUMN_REF; `SELECT *` — сквозной проброс имени.
54
- * undefined — выражение колонки не найдено (движковый сахар) — best-effort.
56
+ * The columns `column` depends on in select_list: an aliased expression or a
57
+ * same-named COLUMN_REF; `SELECT *` — a pass-through of the name.
58
+ * undefined — the column's expression was not found (engine sugar) — best-effort.
55
59
  */
56
60
  const sourceColumnsOf = (ast: unknown, column: string): ReadonlySet<string> | undefined => {
57
61
  const items = selectItems(ast)
@@ -83,12 +87,12 @@ export const lineage = (
83
87
  const engine = yield* EngineAdapter
84
88
  const root = graph.models.get(modelName)
85
89
  if (root === undefined) {
86
- return yield* new LineageError({ model: modelName, reason: "модели нет в проекте" })
90
+ return yield* new LineageError({ model: modelName, reason: "model is not in the project" })
87
91
  }
88
92
  if (!(column in root.schema.fields)) {
89
93
  return yield* new LineageError({
90
94
  model: modelName,
91
- reason: `колонки «${column}» нет в схеме`,
95
+ reason: `column «${column}» is not in the schema`,
92
96
  })
93
97
  }
94
98
 
@@ -115,7 +119,7 @@ export const lineage = (
115
119
  kind: model.kind._tag,
116
120
  sources: [],
117
121
  }
118
- // сырьё: дальше цепочка упирается во внешний мир
122
+ // raw source: beyond here the chain hits the outside world
119
123
  if (model.kind._tag === "external" || model.kind._tag === "seed" || model.deps.size === 0) {
120
124
  return leaf
121
125
  }
@@ -134,7 +138,7 @@ export const lineage = (
134
138
  return yield* trace(root, column)
135
139
  })
136
140
 
137
- /** Плоская печать lineage-дерева для CLI. */
141
+ /** Flat printout of the lineage tree for the CLI. */
138
142
  export const formatLineage = (node: LineageNode, indent = ""): ReadonlyArray<string> => {
139
143
  const marker =
140
144
  node.kind === "external" || node.kind === "seed" ? ` [${node.kind}]` : ""
package/src/plan/lock.ts CHANGED
@@ -2,26 +2,30 @@ import { Data, Effect } from "effect"
2
2
  import { StateStore, type StateError } from "../state/store.ts"
3
3
 
4
4
  /**
5
- * Межпроцессная блокировка через state store (SPEC §7, §14.6): мутации
6
- * окружения — apply и run — идут под ОДНИМ локом `env:<имя>`, поэтому
7
- * параллельные apply+apply и apply+run из разных процессов отсекаются.
8
- * Протухший лок упавшего процесса перехватывается по ttl (учтена гонка
9
- * в ту же миллисекунду: expires_at <= now).
5
+ * Cross-process lock via the state store (SPEC §7, §14.6): environment
6
+ * mutations — apply and run — go under ONE lock `env:<name>`, so parallel
7
+ * apply+apply and apply+run from different processes are cut off. A stale
8
+ * lock of a crashed process is reclaimed by ttl (the same-millisecond race is
9
+ * accounted for: expires_at <= now).
10
10
  */
11
11
 
12
12
  export class LockHeldError extends Data.TaggedError("LockHeldError")<{
13
13
  readonly name: string
14
- }> {}
14
+ }> {
15
+ override get message(): string {
16
+ return `lock «${this.name}» is held by another apply/run process`
17
+ }
18
+ }
15
19
 
16
20
  export interface LockOptions {
17
- /** Сколько лок живёт без освобождения (упавший процесс); по умолчанию 1 час. */
21
+ /** How long the lock lives without being released (crashed process); by default 1 hour. */
18
22
  readonly lockTtlMs?: number
19
23
  }
20
24
 
21
- /** Имя лока, под которым мутируется окружение (общий для apply и run). */
25
+ /** Name of the lock under which an environment is mutated (shared by apply and run). */
22
26
  export const envLockName = (env: string): string => `env:${env}`
23
27
 
24
- /** Лок janitor глобальный: уборка физики не привязана к окружению. */
28
+ /** The janitor lock is global: physical-storage cleanup is not tied to an environment. */
25
29
  export const janitorLockName = "janitor"
26
30
 
27
31
  export const withStateLock =
@@ -33,5 +37,18 @@ export const withStateLock =
33
37
  const store = yield* StateStore
34
38
  const acquired = yield* store.acquireLock(name, ttlMs ?? 3_600_000)
35
39
  if (!acquired) return yield* new LockHeldError({ name })
36
- return yield* effect.pipe(Effect.ensuring(store.releaseLock(name).pipe(Effect.ignore)))
40
+ // lock lifecycle is an internal detail — DEBUG only, keyed by lock name (#14)
41
+ yield* Effect.logDebug("lock acquired").pipe(Effect.annotateLogs("lock", name))
42
+ return yield* effect.pipe(
43
+ Effect.ensuring(
44
+ store
45
+ .releaseLock(name)
46
+ .pipe(
47
+ Effect.andThen(
48
+ Effect.logDebug("lock released").pipe(Effect.annotateLogs("lock", name)),
49
+ ),
50
+ Effect.ignore,
51
+ ),
52
+ ),
53
+ )
37
54
  })
@@ -1,14 +1,14 @@
1
1
  import { Metric } from "effect"
2
2
 
3
- /** Наблюдаемость из коробки (SPEC §10): счётчики конвейера. */
3
+ /** Observability out of the box (SPEC §10): pipeline counters. */
4
4
  export const intervalsDone = Metric.counter("efmesh_intervals_done_total", {
5
- description: "Сколько интервалов досчитано и помечено done",
5
+ description: "how many intervals were computed and marked done",
6
6
  })
7
7
 
8
8
  export const auditFailuresTotal = Metric.counter("efmesh_audit_failures_total", {
9
- description: "Сколько blocking-аудитов провалилось",
9
+ description: "how many blocking audits failed",
10
10
  })
11
11
 
12
12
  export const snapshotsBuilt = Metric.counter("efmesh_snapshots_built_total", {
13
- description: "Сколько снапшотов собрано (физика или бэкфилл)",
13
+ description: "how many snapshots were built (physics or backfill)",
14
14
  })