@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,65 +1,75 @@
1
1
  import { Context, Data, Effect } from "effect"
2
+ import { causeText } from "../error-text.ts"
2
3
 
3
4
  export class StateError extends Data.TaggedError("StateError")<{
4
5
  readonly operation: string
5
6
  readonly cause: unknown
6
- }> {}
7
+ }> {
8
+ override get message(): string {
9
+ return `state store: ${this.operation} failed — ${causeText(this.cause)}`
10
+ }
11
+ }
7
12
 
8
13
  /**
9
- * Текущая версия схемы state store. Свежий стор бутстрапится сразу
10
- * на неё; стор со схемой старше т.ч. созданный до появления версии)
11
- * открытие не проходитданные догоняет явный `efmesh migrate`.
12
- * 1 базовая раскладка (F4), 2 — applied_by в журнале планов (F5),
13
- * 3fingerprint_version в снапшотах (F6), 4журнал тиков run (0.2.0),
14
- * 5кэш канонизации canon_cache (0.2.0, #8).
14
+ * Current schema version of the state store. A fresh store bootstraps
15
+ * directly at it; a store with an older schema (including one created
16
+ * before versioning existed) refuses to open data catches up via an
17
+ * explicit `efmesh migrate`.
18
+ * 1base layout (F4), 2applied_by in the plan journal (F5),
19
+ * 3fingerprint_version in snapshots (F6), 4 — run tick journal (0.2.0),
20
+ * 5 — canonicalization cache canon_cache (0.2.0, #8).
15
21
  */
16
22
  export const STATE_VERSION = 5
17
23
 
18
- /** Схема стора не совпадает с ожидаемой бинарёмнужен `efmesh migrate`. */
24
+ /** Store schema doesn't match the binary's expectation — `efmesh migrate` is needed. */
19
25
  export class StateSchemaError extends Data.TaggedError("StateSchemaError")<{
20
26
  readonly found: number
21
27
  readonly wanted: number
22
- }> {}
28
+ }> {
29
+ override get message(): string {
30
+ return `state store schema is v${this.found}, but this efmesh expects v${this.wanted} — run \`efmesh migrate\``
31
+ }
32
+ }
23
33
 
24
- /** Итог миграции для CLI. */
34
+ /** Migration outcome for the CLI. */
25
35
  export interface MigrationReport {
26
36
  readonly from: number
27
37
  readonly to: number
28
- /** Куда сложена копия стора перед апгрейдом схемы (SQLite; F6). */
38
+ /** Where the store's copy was stashed before the schema upgrade (SQLite; F6). */
29
39
  readonly backup?: string
30
40
  }
31
41
 
32
- /** Версия модели, известная state store (SPEC §6). */
42
+ /** A model version known to the state store (SPEC §6). */
33
43
  export interface SnapshotRecord {
34
44
  readonly name: string
35
45
  readonly fingerprint: string
36
- /** Canonical-рендер SQL — для diff-показа и отладки. */
46
+ /** Canonical SQL rendering for diff display and debugging. */
37
47
  readonly renderedSql: string
38
- /** Канонический AST тела (JSON) — для категоризации изменений (SPEC §5.2). */
48
+ /** Canonical AST of the body (JSON) — for categorizing changes (SPEC §5.2). */
39
49
  readonly canonicalAst: string
40
50
  /**
41
- * Fingerprint, чьей физической таблицей/префиксом пользуется снапшот.
42
- * Обычно равен собственному; при forward-only (SPEC §5.2) наследуется
43
- * от предыдущей версии: физика переиспользуется, история не переигрывается.
51
+ * Fingerprint whose physical table/prefix this snapshot uses.
52
+ * Usually its own; under forward-only (SPEC §5.2) it is inherited
53
+ * from the previous version: physics is reused, history is not replayed.
44
54
  */
45
55
  readonly physicalFp: string
46
56
  readonly kind: string
47
57
  /**
48
- * Версия алгоритма fingerprint, которым посчитан снапшот (SPEC §4).
49
- * План сравнивает только одноверсионные отпечатки; иная версия
50
- * громкая остановка, не тихий «всё breaking».
58
+ * Version of the fingerprint algorithm used to compute this snapshot (SPEC §4).
59
+ * Plan only compares same-version fingerprints; a different version is
60
+ * a loud stop, not a silent "everything is breaking".
51
61
  */
52
62
  readonly fingerprintVersion: number
53
63
  readonly createdAt: string
54
64
  /**
55
- * Когда снапшот перестал быть указан хоть одним окружением (ISO UTC);
56
- * null на него ссылаются. Ставится и снимается при промоушене,
57
- * ttl janitor'а отсчитывается отсюда (SPEC §5.4).
65
+ * When the snapshot stopped being pointed to by any environment (ISO UTC);
66
+ * null means it is still referenced. Set and cleared on promotion,
67
+ * the janitor's ttl is counted from here (SPEC §5.4).
58
68
  */
59
69
  readonly orphanedAt: string | null
60
70
  }
61
71
 
62
- /** Строка окружения: логическое имяснапшот, на который указывает view. */
72
+ /** Environment row: logical namesnapshot the view points to. */
63
73
  export interface EnvironmentRecord {
64
74
  readonly env: string
65
75
  readonly name: string
@@ -72,15 +82,15 @@ export interface PlanRecord {
72
82
  readonly env: string
73
83
  readonly summary: string
74
84
  readonly appliedAt: string
75
- /** Кто применил план (ОС-пользователь или ApplyOptions.appliedBy); '' у записей до v2. */
85
+ /** Who applied the plan (OS user or ApplyOptions.appliedBy); '' for records predating v2. */
76
86
  readonly appliedBy: string
77
87
  }
78
88
 
79
89
  /**
80
- * Запись журнала тиков run (SPEC §7, issue #2): упавший в три часа ночи
81
- * cron-тик должен быть дебажим задним числом. outcome:
82
- * ok — тик прошёл; awaiting-human — есть неприменённые изменения (exit 2);
83
- * lock-held — окружение занято другим процессом; error — настоящий сбой.
90
+ * Run tick journal entry (SPEC §7, issue #2): a cron tick that fails at
91
+ * three in the morning must be debuggable after the fact. outcome:
92
+ * ok — tick succeeded; awaiting-human — there are unapplied changes (exit 2);
93
+ * lock-held — the environment is held by another process; error — an actual failure.
84
94
  */
85
95
  export interface RunRecord {
86
96
  readonly id: number
@@ -88,14 +98,14 @@ export interface RunRecord {
88
98
  readonly startedAt: string
89
99
  readonly finishedAt: string
90
100
  readonly outcome: "ok" | "awaiting-human" | "lock-held" | "error"
91
- /** ok: JSON-массив собранных моделей; awaiting-human: список изменений; error: тег ошибки. */
101
+ /** ok: JSON array of collected models; awaiting-human: list of changes; error: error tag. */
92
102
  readonly detail: string
93
103
  }
94
104
 
95
105
  /**
96
- * Учёт заполненных интервалов снапшота (SPEC §6) — единственный источник
97
- * правды о том, что посчитано: физическая таблица без записей здесь
98
- * считается пустой. Границы ISO UTC (сортируются лексикографически).
106
+ * Bookkeeping of a snapshot's filled intervals (SPEC §6) — the single source
107
+ * of truth for what has been computed: a physical table with no records here
108
+ * is considered empty. Bounds are ISO UTC (sorted lexicographically).
99
109
  */
100
110
  export interface IntervalRecord {
101
111
  readonly snapshotFp: string
@@ -106,7 +116,7 @@ export interface IntervalRecord {
106
116
  }
107
117
 
108
118
  export interface StateStoreShape {
109
- /** Идемпотентно: (name, fingerprint) уникальны, повторная запись no-op. */
119
+ /** Idempotent: (name, fingerprint) is unique, a repeated write is a no-op. */
110
120
  readonly upsertSnapshot: (
111
121
  snapshot: Omit<SnapshotRecord, "createdAt" | "orphanedAt">,
112
122
  ) => Effect.Effect<void, StateError>
@@ -114,17 +124,18 @@ export interface StateStoreShape {
114
124
  name: string,
115
125
  fingerprint: string,
116
126
  ) => Effect.Effect<SnapshotRecord | undefined, StateError>
117
- /** Все снапшоты, на которые ссылается хоть одно окружение,для janitor. */
127
+ /** All snapshots referenced by at least one environmentfor the janitor. */
118
128
  readonly listReferencedFingerprints: () => Effect.Effect<ReadonlySet<string>, StateError>
119
129
  readonly listSnapshots: () => Effect.Effect<ReadonlyArray<SnapshotRecord>, StateError>
120
- /** Удаляет запись снапшота и его учёт интервалов (физику убирает janitor). */
130
+ /** Deletes the snapshot record and its interval bookkeeping (the janitor removes the physics). */
121
131
  readonly deleteSnapshot: (name: string, fingerprint: string) => Effect.Effect<void, StateError>
122
132
  /**
123
- * Транзакционный «claim» сироты janitor'ом (SPEC §5.4, F6): запись и учёт
124
- * интервалов удаляются, только если снапшот ВСЁ ЕЩЁ не referenced ни одним
125
- * окружением и осиротел не позже deadline (ISO UTC) обе проверки в одной
126
- * транзакции с удалением, поэтому параллельный apply, воскресивший версию,
127
- * не даст её унести. true снесено, false состояние изменилось.
133
+ * Transactional "claim" of an orphan by the janitor (SPEC §5.4, F6): the
134
+ * record and its interval bookkeeping are deleted only if the snapshot is
135
+ * STILL not referenced by any environment and orphaned no later than the
136
+ * deadline (ISO UTC) both checks run in the same transaction as the
137
+ * delete, so a concurrent apply that revived the version can't lose it to
138
+ * removal. true — removed, false — state changed underneath.
128
139
  */
129
140
  readonly deleteSnapshotIfDoomed: (
130
141
  name: string,
@@ -135,10 +146,10 @@ export interface StateStoreShape {
135
146
  env: string,
136
147
  ) => Effect.Effect<ReadonlyArray<EnvironmentRecord>, StateError>
137
148
  /**
138
- * Транзакционно заменяет весь набор окружения. Записи с
139
- * `requireSnapshot: true` проверяются на живость снапшота в той же
140
- * транзакции: если janitor успел его унести, промоушен громко падает
141
- * view никогда не переключается на снесённую физику.
149
+ * Transactionally replaces the environment's whole set. Entries with
150
+ * `requireSnapshot: true` are checked for the snapshot's liveness in the
151
+ * same transaction: if the janitor already removed it, promotion fails
152
+ * loudly — the view never switches to demolished physics.
142
153
  */
143
154
  readonly promote: (
144
155
  env: string,
@@ -148,35 +159,36 @@ export interface StateStoreShape {
148
159
  readonly requireSnapshot?: boolean
149
160
  }>,
150
161
  ) => Effect.Effect<void, StateError>
151
- /** Журнал применённых планов. */
162
+ /** Journal of applied plans. */
152
163
  readonly recordPlan: (
153
164
  env: string,
154
165
  summary: string,
155
166
  appliedBy: string,
156
167
  ) => Effect.Effect<void, StateError>
157
168
  /**
158
- * Кэш канонизации (#8): ключ уже включает диалект и FINGERPRINT_VERSION
159
- * смена алгоритма или движка не может отдать протухший канон. Кэш не
160
- * данные: промах или сбой безопасны, вызывающий обязан их глотать.
169
+ * Canonicalization cache (#8): the key already includes the dialect and
170
+ * FINGERPRINT_VERSION a change in algorithm or engine can never hand back
171
+ * a stale canon. The cache is not data: a miss or failure is safe, the
172
+ * caller must swallow it.
161
173
  */
162
174
  readonly getCanon: (key: string) => Effect.Effect<string | undefined, StateError>
163
175
  readonly putCanon: (key: string, canonical: string) => Effect.Effect<void, StateError>
164
- /** Журнал тиков run (SPEC §7): исход каждого тика, включая неуспешные. */
176
+ /** Run tick journal (SPEC §7): the outcome of every tick, including unsuccessful ones. */
165
177
  readonly recordRun: (record: Omit<RunRecord, "id">) => Effect.Effect<void, StateError>
166
- /** Последние тики окружения, свежие первыми. */
178
+ /** The environment's most recent ticks, newest first. */
167
179
  readonly listRuns: (
168
180
  env: string,
169
181
  limit: number,
170
182
  ) => Effect.Effect<ReadonlyArray<RunRecord>, StateError>
171
183
  readonly listPlans: (env: string) => Effect.Effect<ReadonlyArray<PlanRecord>, StateError>
172
184
  /**
173
- * Межпроцессная блокировка (SPEC §7): true — получена, false — держит
174
- * другой процесс. Протухшие (expires) локи перехватываютсяупавший
175
- * процесс не оставляет вечный замок.
185
+ * Cross-process lock (SPEC §7): true — acquired, false — held by another
186
+ * process. Stale (expired) locks are reclaimed a crashed process
187
+ * doesn't leave a lock held forever.
176
188
  */
177
189
  readonly acquireLock: (name: string, ttlMs: number) => Effect.Effect<boolean, StateError>
178
190
  readonly releaseLock: (name: string) => Effect.Effect<void, StateError>
179
- /** Транзакционный upsert интервалов снапшота (повторная отметка обновление статуса). */
191
+ /** Transactional upsert of snapshot intervals (re-marking updates the status). */
180
192
  readonly markIntervals: (
181
193
  snapshotFp: string,
182
194
  intervals: ReadonlyArray<{ readonly startTs: string; readonly endTs: string }>,
@@ -7,22 +7,22 @@ import { DuckDBEngineLive } from "../engine/duckdb.ts"
7
7
  import { familyOfAst, type TypeFamily } from "../plan/contract.ts"
8
8
 
9
9
  /**
10
- * Юнит-тест модели на фикстурах (SPEC §8): `ctx.ref` рендерится в CTE
11
- * с VALUES из фикстур, валидированных через Schema модели-источника
12
- * (модель помнит источники значениями — model.refs), запрос выполняется
13
- * на одноразовом in-memory DuckDB, результат сравнивается с ожидаемым.
14
- * Живёт в bun test:
10
+ * Unit-tests a model against fixtures (SPEC §8): `ctx.ref` is rendered into a
11
+ * CTE with VALUES from the fixtures, validated against the source model's
12
+ * Schema (the model remembers its sources by value — model.refs); the query
13
+ * runs on a throwaway in-memory DuckDB, and the result is compared to the
14
+ * expectation. Lives in bun test:
15
15
  *
16
16
  * test("stays", () => testModel(stays, { inputs: {...}, expect: [...] }))
17
17
  */
18
18
 
19
19
  export interface TestModelSpec {
20
- /** Фикстуры по полным именам моделей-источников; нужны для всех deps. */
20
+ /** Fixtures keyed by source models' full names; required for every dep. */
21
21
  readonly inputs?: Readonly<Record<string, ReadonlyArray<Record<string, unknown>>>>
22
- /** Границы интервала [start, end) — обязательны для incremental-моделей. */
22
+ /** Interval bounds [start, end) — required for incremental models. */
23
23
  readonly interval?: readonly [string, string]
24
24
  readonly expect: ReadonlyArray<Record<string, unknown>>
25
- /** Сравнивать порядок строк; по умолчанию порядок не важен. */
25
+ /** Whether row order matters; unordered by default. */
26
26
  readonly strictOrder?: boolean
27
27
  }
28
28
 
@@ -34,7 +34,7 @@ const DUCK_TYPE: Record<TypeFamily, string> = {
34
34
  any: "VARCHAR",
35
35
  }
36
36
 
37
- /** Литерал фикстуры по семейству колонки (ISO-строки времени → TIMESTAMP). */
37
+ /** Fixture literal by column family (ISO time strings → TIMESTAMP). */
38
38
  const fixtureLiteral = (value: unknown, family: TypeFamily): string => {
39
39
  if (value === null || value === undefined) return "NULL"
40
40
  if (family === "temporal") return sqlTimestamp(fromIso(String(value)))
@@ -46,14 +46,15 @@ const fixtureLiteral = (value: unknown, family: TypeFamily): string => {
46
46
  ) {
47
47
  return escapeLiteral(value)
48
48
  }
49
- throw new Error(`фикстура содержит нерендерируемое значение: ${String(value)}`)
49
+ throw new Error(`fixture contains a non-renderable value: ${String(value)}`)
50
50
  }
51
51
 
52
52
  /**
53
- * Фикстура не может врать о форме входа: значения сверяются с семействами
54
- * типов Schema источника (строки времени ISO; Schema.DateTimeUtc в v4
55
- * декодирует готовый DateTime.Utc, не строку, поэтому проверка по семейству,
56
- * не через decode), лишние ключиопечаткиотвергаются.
53
+ * A fixture cannot lie about the shape of its input: values are checked
54
+ * against the source Schema's type families (time strings are ISO; in v4
55
+ * Schema.DateTimeUtc decodes to a ready DateTime.Utc, not a string, so the
56
+ * check is by family rather than via decode), and extra keys typosare
57
+ * rejected.
57
58
  */
58
59
  const validateFixture = (
59
60
  source: AnyModel,
@@ -63,7 +64,7 @@ const validateFixture = (
63
64
  const known = new Set(fields.map(([name]) => name))
64
65
  for (const key of Object.keys(row)) {
65
66
  if (!known.has(key)) {
66
- throw new Error(`фикстура ${source.name.full}: лишняя колонка «${key}»`)
67
+ throw new Error(`fixture ${source.name.full}: extra column «${key}»`)
67
68
  }
68
69
  }
69
70
  for (const [name, field] of fields) {
@@ -82,13 +83,13 @@ const validateFixture = (
82
83
  : true
83
84
  if (!ok) {
84
85
  throw new Error(
85
- `фикстура ${source.name.full}: колонка «${name}» ждёт ${family}, получено ${JSON.stringify(value)}`,
86
+ `fixture ${source.name.full}: column «${name}» expects ${family}, got ${JSON.stringify(value)}`,
86
87
  )
87
88
  }
88
89
  }
89
90
  }
90
91
 
91
- /** CTE источника: VALUES из фикстур или пустой SELECT с типами схемы. */
92
+ /** Source CTE: VALUES from fixtures, or an empty SELECT typed by the schema. */
92
93
  const fixtureCte = (source: AnyModel, rows: ReadonlyArray<Record<string, unknown>>): string => {
93
94
  const fields = Object.entries(source.schema.fields) as ReadonlyArray<
94
95
  [string, { readonly ast: unknown }]
@@ -109,7 +110,7 @@ const fixtureCte = (source: AnyModel, rows: ReadonlyArray<Record<string, unknown
109
110
  return `SELECT * FROM (VALUES ${tuples.join(", ")}) AS t(${columns})`
110
111
  }
111
112
 
112
- /** DuckDB-значениясравнимые примитивы (bigint → number, объекты временистрока). */
113
+ /** DuckDB values comparable primitives (bigint → number, time objectsstring). */
113
114
  const normalize = (row: Record<string, unknown>): Record<string, unknown> =>
114
115
  Object.fromEntries(
115
116
  Object.entries(row).map(([key, value]) => {
@@ -130,7 +131,7 @@ const canonical = (
130
131
  return strictOrder ? normalized : [...normalized].sort()
131
132
  }
132
133
 
133
- /** Прогоняет модель на фикстурах, возвращает строкидля нестандартных проверок. */
134
+ /** Runs the model against fixtures, returning rows for non-standard checks. */
134
135
  export const runModel = async (
135
136
  model: AnyModel,
136
137
  spec: Pick<TestModelSpec, "inputs" | "interval">,
@@ -138,16 +139,16 @@ export const runModel = async (
138
139
  const inputs = spec.inputs ?? {}
139
140
  for (const dep of model.deps) {
140
141
  if (!(dep in inputs)) {
141
- throw new Error(`нет фикстуры для источника ${dep} (deps модели ${model.name.full})`)
142
+ throw new Error(`no fixture for source ${dep} (deps of model ${model.name.full})`)
142
143
  }
143
144
  }
144
145
  for (const name of Object.keys(inputs)) {
145
146
  if (!model.deps.has(name)) {
146
- throw new Error(`фикстура «${name}» не является источником модели ${model.name.full}`)
147
+ throw new Error(`fixture «${name}» is not a source of model ${model.name.full}`)
147
148
  }
148
149
  }
149
150
  if (usesBounds(model.fragment) && spec.interval === undefined) {
150
- throw new Error(`модель ${model.name.full} использует ctx.start/ctx.end — укажи interval`)
151
+ throw new Error(`model ${model.name.full} uses ctx.start/ctx.end — provide interval`)
151
152
  }
152
153
 
153
154
  const ctes = [...model.deps]
@@ -174,7 +175,7 @@ export const runModel = async (
174
175
  )
175
176
  }
176
177
 
177
- /** Прогоняет модель на фикстурах и сверяет результат с ожиданием. */
178
+ /** Runs the model against fixtures and checks the result against the expectation. */
178
179
  export const testModel = async (model: AnyModel, spec: TestModelSpec): Promise<void> => {
179
180
  const rows = await runModel(model, spec)
180
181
  const got = canonical(rows, spec.strictOrder ?? false)
@@ -182,10 +183,10 @@ export const testModel = async (model: AnyModel, spec: TestModelSpec): Promise<v
182
183
  if (got.length !== want.length || got.some((row, index) => row !== want[index])) {
183
184
  throw new Error(
184
185
  [
185
- `результат модели ${model.name.full} не совпал с ожиданием:`,
186
- `— получено (${got.length}):`,
186
+ `result of model ${model.name.full} did not match the expectation:`,
187
+ `— got (${got.length}):`,
187
188
  ...got.map((row) => ` ${row}`),
188
- `— ожидалось (${want.length}):`,
189
+ `— expected (${want.length}):`,
189
190
  ...want.map((row) => ` ${row}`),
190
191
  ].join("\n"),
191
192
  )