@avytheone/efmesh 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,24 @@
1
1
  /**
2
- * Объяснение категоризации (#4, SPEC §5.2): `plan --explain` показывает,
3
- * КАКИЕ узлы канонического AST разошлись и почему категория именно такая.
4
- * Пути идут по дереву канона движка (json_serialize_sql у DuckDB,
5
- * libpg_query у Postgres) — это отладочная подсказка, а не контракт:
6
- * форма путей меняется вместе с каноном и semver-события не образует.
7
- * Категорию считает categorize.ts; здесьтолько её обоснование.
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; hereonly its rationale.
8
8
  */
9
9
 
10
10
  import { topSelect } from "./categorize.ts"
11
11
 
12
12
  export interface ChangeExplanation {
13
- /** Пути разошедшихся узлов канонического AST (не больше MAX_DIVERGED). */
13
+ /** Paths of diverged canonical-AST nodes (no more than MAX_DIVERGED). */
14
14
  readonly diverged: ReadonlyArray<string>
15
- /** Почему категория именно такая. */
15
+ /** Why the category is what it is. */
16
16
  readonly reason: string
17
- /** Изменившиеся прямые родителиисточник каскада indirect/forward-only. */
17
+ /** Changed direct parentsthe source of the indirect/forward-only cascade. */
18
18
  readonly cascadeFrom?: ReadonlyArray<string>
19
19
  }
20
20
 
21
- /** Дальше точечные пути перестают помогатьлучше открыть render/diff. */
21
+ /** Beyond this, pointwise paths stop helping better to open render/diff. */
22
22
  const MAX_DIVERGED = 8
23
23
 
24
24
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -34,32 +34,32 @@ const walk = (before: unknown, after: unknown, path: string, out: Array<string>)
34
34
  const shared = Math.min(before.length, after.length)
35
35
  for (let i = 0; i < shared; i++) walk(before[i], after[i], `${path}[${i}]`, out)
36
36
  for (let i = shared; i < after.length && out.length < MAX_DIVERGED; i++) {
37
- out.push(`${path}[${i}] (добавлен)`)
37
+ out.push(`${path}[${i}] (added)`)
38
38
  }
39
39
  for (let i = shared; i < before.length && out.length < MAX_DIVERGED; i++) {
40
- out.push(`${path}[${i}] (удалён)`)
40
+ out.push(`${path}[${i}] (removed)`)
41
41
  }
42
42
  return
43
43
  }
44
44
  if (isRecord(before) && isRecord(after)) {
45
- // узел заменён выражением другого типаточечные пути внутри бессмысленны
45
+ // node replaced by an expression of another type pointwise paths inside are meaningless
46
46
  if (before["type"] !== after["type"]) {
47
- out.push(path === "" ? "(корень)" : path)
47
+ out.push(path === "" ? "(root)" : path)
48
48
  return
49
49
  }
50
50
  const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()
51
51
  for (const key of keys) walk(before[key], after[key], at(path, key), out)
52
52
  return
53
53
  }
54
- out.push(path === "" ? "(корень)" : path)
54
+ out.push(path === "" ? "(root)" : path)
55
55
  }
56
56
 
57
- /** Служебная обёртка стейтмента в пути не интереснарежем до тела запроса. */
57
+ /** The statement's boilerplate wrapper in the path is uninteresting trim to the query body. */
58
58
  const trimStatement = (path: string): string =>
59
59
  path.replace(/^statements\[0\]\.node\.?/, "").replace(/^stmts\[0\]\.stmt\.?/, "") ||
60
- "(корень)"
60
+ "(root)"
61
61
 
62
- /** Пути расхождения двух канонических AST (JSON-строки); мусор на входе пусто. */
62
+ /** Divergence paths of two canonical ASTs (JSON strings); garbage inputempty. */
63
63
  export const divergedPaths = (oldAst: string, newAst: string): ReadonlyArray<string> => {
64
64
  try {
65
65
  const out: Array<string> = []
@@ -71,10 +71,10 @@ export const divergedPaths = (oldAst: string, newAst: string): ReadonlyArray<str
71
71
  }
72
72
 
73
73
  /**
74
- * Гвардрейл override'а (#5): в новом верхнем SELECT колонок меньшеони
75
- * удалены, потомки читают их по именам, «non-breaking» очевидно противоречит
76
- * AST. Непарсибельный канон противоречием не считается: решение за
77
- * оператором, override явный и журналируется.
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
78
  */
79
79
  export const dropsColumns = (oldAst: string, newAst: string): boolean => {
80
80
  const before = topSelect(oldAst)
@@ -84,8 +84,8 @@ export const dropsColumns = (oldAst: string, newAst: string): boolean => {
84
84
  }
85
85
 
86
86
  /**
87
- * Обоснование вердикта categorizeAstChange теми же правилами, которыми он
88
- * вынесен (SPEC §5.2): non-breaking — только суффикс верхнего SELECT.
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
89
  */
90
90
  export const explainCategorized = (
91
91
  oldAst: string,
@@ -97,18 +97,18 @@ export const explainCategorized = (
97
97
  return {
98
98
  diverged,
99
99
  reason:
100
- "колонки добавлены в конец верхнего SELECT, остальное дерево нетронутопотребители читают по именам, пересборка не нужна",
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
101
  }
102
102
  }
103
103
  const before = topSelect(oldAst)
104
104
  const after = topSelect(newAst)
105
105
  const reason =
106
106
  before === null || after === null
107
- ? "канонический AST неожиданной формыконсервативно breaking"
107
+ ? "canonical AST of an unexpected shape conservatively breaking"
108
108
  : before.rest !== after.rest
109
- ? "дерево разошлось вне списка SELECT (FROM/WHERE/JOIN/GROUP BY/модификаторы)"
109
+ ? "the tree diverged outside the SELECT list (FROM/WHERE/JOIN/GROUP BY/modifiers)"
110
110
  : after.list.length < before.list.length
111
- ? "колонки удалены из SELECT — потомки вставляют по позициям, физику надо пересобирать"
112
- : "список SELECT изменён не только хвостомправка или перестановка колонок ломает позиции потомков"
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
113
  return { diverged, reason }
114
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,25 +89,25 @@ 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
96
  /**
93
- * Кэш канонизации (#8): повторный plan почти целиком состоит из
94
- * json_serialize_sql-раундтрипов по неизменённым моделям. Кэш не
95
- * данные: get/put обязаны быть безошибочными (промах/сбой = пересчёт).
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).
96
100
  */
97
101
  export interface CanonCache {
98
102
  readonly get: (key: string) => Effect.Effect<string | undefined>
99
103
  readonly put: (key: string, canonical: string) => Effect.Effect<void>
100
104
  }
101
105
 
102
- /** Ключ кэша: версия алгоритма + диалект + исходникапгрейд канона не маскируется. */
106
+ /** Cache key: algorithm version + dialect + sourcea canon upgrade is not masked. */
103
107
  export const canonCacheKey = (dialect: string, source: string): string =>
104
108
  sha256(`${FINGERPRINT_VERSION}:${dialect}:${source}`)
105
109
 
106
- /** Модель в объёме, нужном fingerprint'у: kind, метаданные формы данных, цель. */
110
+ /** The model in the scope the fingerprint needs: kind, data-shape metadata, target. */
107
111
  type FingerprintableModel = Parameters<typeof columnNames>[0] & {
108
112
  readonly name: { readonly full: string }
109
113
  readonly kind: ModelKind
@@ -112,11 +116,11 @@ type FingerprintableModel = Parameters<typeof columnNames>[0] & {
112
116
  }
113
117
 
114
118
  /**
115
- * Fingerprint одной модели из готового AST и подписей родителей
116
- * (`имя=fingerprint`, отсортированы). Нужен планировщику для проверки
117
- * «версию сдвинули ТОЛЬКО родители» (#5): пересчёт со старыми подписями
118
- * обязан дать старый fingerprint — иначе разошлись ещё и метаданные,
119
- * и физику реюзать нельзя.
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.
120
124
  */
121
125
  export const modelFingerprint = (
122
126
  model: FingerprintableModel,
@@ -129,14 +133,14 @@ export const modelFingerprint = (
129
133
  kind: yield* kindPayload(model, model.kind),
130
134
  grain: model.grain,
131
135
  columns: columnNames(model),
132
- // смена цели материализации = новая физика, потребители перечитают её
136
+ // a change of materialization target = new physics, consumers re-read it
133
137
  target: model.target,
134
138
  parents,
135
139
  })
136
140
  return sha256(payload)
137
141
  })
138
142
 
139
- /** Fingerprint всех моделей графа; транзитивность через хэши родителей. */
143
+ /** Fingerprint of all models in the graph; transitivity via parent hashes. */
140
144
  export const fingerprintGraph = (
141
145
  graph: ModelGraph,
142
146
  cache?: CanonCache,
@@ -160,7 +164,7 @@ export const fingerprintGraph = (
160
164
  const versions = new Map<string, ModelVersion>()
161
165
  for (const name of graph.order) {
162
166
  const model = graph.models.get(name)!
163
- // у external и seed нет SQL — версия определяется источником/файлом и схемой
167
+ // external and seed have no SQL — the version is determined by source/file and schema
164
168
  const ast =
165
169
  model.kind._tag === "external" || model.kind._tag === "seed"
166
170
  ? null
@@ -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
  })