@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.
@@ -4,49 +4,53 @@ import { fileURLToPath } from "node:url"
4
4
  import { Data, Effect } from "effect"
5
5
 
6
6
  /**
7
- * `efmesh schedule` (#10): регистрация тика `run` в OS-шедулере через
8
- * Bun.cron (>=1.3.11): crontab на Linux, launchd на macOS, Task Scheduler
9
- * на Windows. Один заголовок = одна запись, повторная регистрация
10
- * перезаписывает на месте (идемпотентно).
7
+ * `efmesh schedule` (#10): registers a `run` tick in the OS scheduler via
8
+ * Bun.cron (>=1.3.11): crontab on Linux, launchd on macOS, Task Scheduler
9
+ * on Windows. One title = one entry; re-registration overwrites in place
10
+ * (idempotent).
11
11
  *
12
- * Честные оговорки (документированы в README/SPEC): cron не догоняет
13
- * пропущенные запуски (systemd Persistent=true строжеесть
14
- * --print-systemd), OS-уровень живёт в ЛОКАЛЬНОЙ таймзоне (TZ=UTC поверх),
15
- * на Linux нужен живой cron-демон семейство Arch без cronie не имеет
16
- * его вовсе. Наложение тиков безопасно по построению: run держит env-лок,
17
- * а «ждёт человека» = exit 2, не сбой.
12
+ * Honest caveats (documented in README/SPEC): cron does not catch up on
13
+ * missed runs (systemd Persistent=true is stricter hence --print-systemd),
14
+ * the OS level lives in the LOCAL timezone (TZ=UTC on top), on Linux a live
15
+ * cron daemon is requiredthe Arch family without cronie has none at all.
16
+ * Overlapping ticks are safe by construction: run holds the env lock, and
17
+ * "awaiting a human" = exit 2, not a failure.
18
18
  */
19
19
 
20
20
  export class ScheduleError extends Data.TaggedError("ScheduleError")<{
21
21
  readonly reason: string
22
- }> {}
22
+ }> {
23
+ override get message(): string {
24
+ return this.reason
25
+ }
26
+ }
23
27
 
24
- /** Всё, что нужно воркеру, абсолютными путями (crontab не знает cwd). */
28
+ /** Everything the worker needs, as absolute paths (crontab has no cwd). */
25
29
  export interface ScheduleTarget {
26
- /** Директория проекта (где лежит конфиг) — cwd тика. */
30
+ /** Project directory (where the config lives) — the tick's cwd. */
27
31
  readonly project: string
28
- /** Абсолютный путь конфига. */
32
+ /** Absolute path of the config. */
29
33
  readonly config: string
30
34
  readonly env: string
31
35
  }
32
36
 
33
- /** Заголовок Bun.cron: только [A-Za-z0-9_-]; включает проект и окружение. */
37
+ /** Bun.cron title: only [A-Za-z0-9_-]; includes the project and environment. */
34
38
  export const scheduleTitle = (target: Pick<ScheduleTarget, "project" | "env">): string =>
35
39
  `efmesh-${basename(target.project)}-${target.env}`.replaceAll(/[^A-Za-z0-9_-]/g, "-")
36
40
 
37
- /** Куда генерируется воркер: рядом с конфигом, в служебной .efmesh/. */
41
+ /** Where the worker is generated: next to the config, in the internal .efmesh/. */
38
42
  export const workerPath = (target: ScheduleTarget): string =>
39
43
  join(target.project, ".efmesh", `schedule-${target.env}.ts`)
40
44
 
41
- /** Бинарь CLI этого же пакетаворкер зовёт его, не гадая по npm-именам. */
45
+ /** CLI binary of this same package the worker calls it without guessing npm names. */
42
46
  const binPath = (): string => fileURLToPath(new URL("../bin.ts", import.meta.url))
43
47
 
44
48
  /**
45
- * Исходник воркера для Bun.cron: OS-шедулер исполняет scheduled(), тот
46
- * гоняет обычный `efmesh run` — та же семантика exit-кодов (2 = «ждёт
47
- * человека») и тот же журнал тиков в сторе (`efmesh status`).
49
+ * Worker source for Bun.cron: the OS scheduler runs scheduled(), which runs
50
+ * an ordinary `efmesh run` — the same exit-code semantics (2 = "awaiting a
51
+ * human") and the same tick journal in the store (`efmesh status`).
48
52
  */
49
- export const workerSource = (target: ScheduleTarget): string => `// сгенерировано \`efmesh schedule\` — не редактируйте: перерегистрация перезапишет
53
+ export const workerSource = (target: ScheduleTarget): string => `// generated by \`efmesh schedule\` — do not edit: re-registration overwrites it
50
54
  export default {
51
55
  async scheduled() {
52
56
  const proc = Bun.spawn(
@@ -58,7 +62,7 @@ export default {
58
62
  }
59
63
  `
60
64
 
61
- /** Никнеймы cron → OnCalendar systemd; произвольные выражения не переводятся. */
65
+ /** cron nicknames systemd OnCalendar; arbitrary expressions are not translated. */
62
66
  export const cronToOnCalendar = (cron: string): string | undefined =>
63
67
  (
64
68
  {
@@ -73,8 +77,9 @@ export const cronToOnCalendar = (cron: string): string | undefined =>
73
77
  )[cron.trim()]
74
78
 
75
79
  /**
76
- * systemd-фоллбэк (--print-systemd): у cron нет догона пропущенных запусков
77
- * и на Arch-семействе нет демона — user-таймер с Persistent=true честнее.
80
+ * systemd fallback (--print-systemd): cron has no catch-up for missed runs
81
+ * and the Arch family has no daemon a user timer with Persistent=true is
82
+ * more honest.
78
83
  */
79
84
  export const systemdUnits = (
80
85
  target: ScheduleTarget,
@@ -91,13 +96,13 @@ Description=efmesh run ${target.env} (${basename(target.project)})
91
96
  Type=oneshot
92
97
  WorkingDirectory=${target.project}
93
98
  ExecStart=${process.execPath} ${binPath()} run ${target.env} --config ${target.config}
94
- # exit 2 = «ждёт человека» (нужен apply) — намеренно failure юнита: видно в алертах
99
+ # exit 2 = "awaiting a human" (apply needed) — deliberately a unit failure: visible in alerts
95
100
  `,
96
101
  timer: `[Unit]
97
- Description=efmesh run ${target.env} — таймер
102
+ Description=efmesh run ${target.env} — timer
98
103
 
99
104
  [Timer]
100
- OnCalendar=${calendar ?? `hourly # TODO: переведите «${cron}» в OnCalendar вручную`}
105
+ OnCalendar=${calendar ?? `hourly # TODO: translate "${cron}" into OnCalendar by hand`}
101
106
  Persistent=true
102
107
 
103
108
  [Install]
@@ -106,28 +111,28 @@ WantedBy=timers.target
106
111
  }
107
112
  }
108
113
 
109
- /** Валидация выражения тем же парсером, который будет его исполнять. */
114
+ /** Validate the expression with the same parser that will execute it. */
110
115
  export const validateCron = (cron: string): Effect.Effect<void, ScheduleError> =>
111
116
  Effect.gen(function* () {
112
117
  const next = yield* Effect.try({
113
118
  try: () => Bun.cron.parse(cron),
114
- catch: () => new ScheduleError({ reason: `не разбирается cron-выражение «${cron}»` }),
119
+ catch: () => new ScheduleError({ reason: `cannot parse cron expression "${cron}"` }),
115
120
  })
116
121
  if (next === null) {
117
122
  return yield* new ScheduleError({
118
- reason: `cron-выражение «${cron}» никогда не сработает`,
123
+ reason: `cron expression "${cron}" will never fire`,
119
124
  })
120
125
  }
121
126
  })
122
127
 
123
- /** Linux без crontab-бинаря (Arch-семейство) — честная ошибка с рецептом. */
128
+ /** Linux without a crontab binary (Arch family) — an honest error with a recipe. */
124
129
  const requireCrontab = (): Effect.Effect<void, ScheduleError> =>
125
130
  Effect.gen(function* () {
126
131
  if (process.platform !== "linux" || Bun.which("crontab") !== null) return
127
132
  return yield* new ScheduleError({
128
133
  reason:
129
- "на этой машине нет crontab (Arch-семейство не ставит cron-демона): " +
130
- "установите cronie ЛИБО используйте systemd-таймер — efmesh schedule <env> --print-systemd",
134
+ "this machine has no crontab (the Arch family ships no cron daemon): " +
135
+ "install cronie OR use a systemd timer — efmesh schedule <env> --print-systemd",
131
136
  })
132
137
  })
133
138
 
@@ -139,7 +144,7 @@ export const registerSchedule = (
139
144
  yield* validateCron(cron)
140
145
  yield* requireCrontab()
141
146
  if (!existsSync(target.config)) {
142
- return yield* new ScheduleError({ reason: `конфига нет: ${target.config}` })
147
+ return yield* new ScheduleError({ reason: `config not found: ${target.config}` })
143
148
  }
144
149
  const worker = workerPath(target)
145
150
  yield* Effect.try({
@@ -148,13 +153,13 @@ export const registerSchedule = (
148
153
  writeFileSync(worker, workerSource(target))
149
154
  },
150
155
  catch: (cause) =>
151
- new ScheduleError({ reason: `воркер не записался: ${String(cause)}` }),
156
+ new ScheduleError({ reason: `worker was not written: ${String(cause)}` }),
152
157
  })
153
158
  const title = scheduleTitle(target)
154
159
  yield* Effect.tryPromise({
155
160
  try: () => Bun.cron(worker, cron, title),
156
161
  catch: (cause) =>
157
- new ScheduleError({ reason: `Bun.cron не зарегистрировал: ${String(cause)}` }),
162
+ new ScheduleError({ reason: `Bun.cron did not register: ${String(cause)}` }),
158
163
  })
159
164
  return { title, worker }
160
165
  })
@@ -168,14 +173,14 @@ export const removeSchedule = (
168
173
  try: () => Bun.cron.remove(title),
169
174
  catch: (cause) => new ScheduleError({ reason: `Bun.cron.remove: ${String(cause)}` }),
170
175
  })
171
- // воркер больше никем не исполняетсяприбираем молча
176
+ // the worker is no longer executed by anyone clean it up silently
172
177
  yield* Effect.sync(() => rmSync(workerPath(target), { force: true }))
173
178
  return { title }
174
179
  })
175
180
 
176
181
  /**
177
- * Список efmesh-регистрацийпо платформенным следам Bun.cron: маркеры
178
- * `# bun-cron: <title>` в crontab (Linux) / plist'ы launchd (macOS).
182
+ * List of efmesh registrations by Bun.cron's platform traces: `# bun-cron:
183
+ * <title>` markers in crontab (Linux) / launchd plists (macOS).
179
184
  */
180
185
  export const listSchedules = (): Effect.Effect<ReadonlyArray<string>, ScheduleError> =>
181
186
  Effect.gen(function* () {
@@ -185,7 +190,7 @@ export const listSchedules = (): Effect.Effect<ReadonlyArray<string>, ScheduleEr
185
190
  try: async () => {
186
191
  const proc = Bun.spawn(["crontab", "-l"], { stdout: "pipe", stderr: "ignore" })
187
192
  const text = await proc.stdout.text()
188
- await proc.exited // пустой crontab выходит с 1 — это не ошибка
193
+ await proc.exited // an empty crontab exits with 1 — that is not an error
189
194
  return text
190
195
  },
191
196
  catch: (cause) => new ScheduleError({ reason: `crontab -l: ${String(cause)}` }),
@@ -207,6 +212,6 @@ export const listSchedules = (): Effect.Effect<ReadonlyArray<string>, ScheduleEr
207
212
  return out.split("\n").filter((line) => line.includes("bun.cron.efmesh-"))
208
213
  }
209
214
  return yield* new ScheduleError({
210
- reason: `список на ${process.platform} смотрите средствами ОС (Task Scheduler: schtasks /query)`,
215
+ reason: `list on ${process.platform} via the OS tools (Task Scheduler: schtasks /query)`,
211
216
  })
212
217
  })
@@ -6,39 +6,40 @@ import { STATE_VERSION, StateStore } from "../state/store.ts"
6
6
  import type { PlanRecord, RunRecord, StateError } from "../state/store.ts"
7
7
 
8
8
  /**
9
- * `efmesh status <env>` (issue #1): одна команда на вопрос «что вообще
10
- * происходит»для оператора ночного cron и для автора, забывшего
11
- * команды. Только чтение: стор открытверсия схемы уже совпала.
9
+ * `efmesh status <env>` (issue #1): one command for the question «what is
10
+ * even going on» for the nightly-cron operator and for the author who
11
+ * forgot the commands. Read-only: if the store opened its schema version
12
+ * already matched.
12
13
  */
13
14
 
14
15
  export interface ModelLag {
15
16
  readonly model: string
16
- /** Конец последнего done-интервала (ISO); null — ещё ничего не посчитано. */
17
+ /** End of the last done interval (ISO); null — nothing computed yet. */
17
18
  readonly doneUpTo: string | null
18
- /** Сколько интервалов не хватает до «сейчас». 0 — догнано. */
19
+ /** How many intervals are missing up to «now». 0 — caught up. */
19
20
  readonly missing: number
20
- /** Интервалы, помеченные failed,застрявший бэкфилл виден сразу. */
21
+ /** Intervals marked failed — a stuck backfill is visible at once. */
21
22
  readonly failed: number
22
23
  }
23
24
 
24
25
  export interface StatusReport {
25
26
  readonly env: string
26
27
  readonly storeVersion: number
27
- /** Строк в окружении; 0 — окружение не существует (ни разу не применялось). */
28
+ /** Rows in the environment; 0 — the environment does not exist (never applied). */
28
29
  readonly models: number
29
- /** Последний промоушен (ISO); null — окружения нет. */
30
+ /** Last promotion (ISO); null — no environment. */
30
31
  readonly promotedAt: string | null
31
32
  readonly lastPlan: PlanRecord | null
32
- /** Последние тики run, свежие первыми. */
33
+ /** Latest run ticks, freshest first. */
33
34
  readonly ticks: ReadonlyArray<RunRecord>
34
- /** Отставание incremental-моделей окружения. */
35
+ /** Lag of the environment's incremental models. */
35
36
  readonly lag: ReadonlyArray<ModelLag>
36
37
  }
37
38
 
38
39
  export interface StatusOptions {
39
- /** «Сейчас» для расчёта отставания; по умолчанию — Clock. Инъекция для тестов. */
40
+ /** «Now» for computing lag; by default — Clock. Injected for tests. */
40
41
  readonly now?: number
41
- /** Сколько последних тиков показать; по умолчанию 5. */
42
+ /** How many latest ticks to show; by default 5. */
42
43
  readonly ticks?: number
43
44
  }
44
45
 
@@ -62,8 +63,8 @@ export const environmentStatus = (
62
63
  const lastPlan = plans.at(-1) ?? null
63
64
  const ticks = yield* store.listRuns(env, options?.ticks ?? 5)
64
65
 
65
- // отставаниепо УКАЗАТЕЛЯМ окружения (что реально отдаётся
66
- // потребителям), а не по локальным fingerprint'ам проекта
66
+ // lagby the environment's POINTERS (what is actually served to
67
+ // consumers), not by the project's local fingerprints
67
68
  const lag: Array<ModelLag> = []
68
69
  for (const row of rows) {
69
70
  const model = graph.models.get(row.name)
@@ -12,11 +12,11 @@ import type {
12
12
  import { STATE_VERSION, StateError, StateSchemaError, StateStore } from "./store.ts"
13
13
 
14
14
  /**
15
- * State store в Postgres (SPEC §6, F3) — для командной/прод-работы:
16
- * состояние переживает конкурентные запуски из разных процессов и машин.
17
- * Схема `efmesh_state`, семантика один в один с bun:sqlite-реализацией;
18
- * временные метки ISO UTC текстом (лексикографически сортируемы),
19
- * как и в SQLite: содержимое стора переносимо между бэкендами.
15
+ * State store on Postgres (SPEC §6, F3) — for team/production work: state
16
+ * survives concurrent runs from different processes and machines.
17
+ * Schema `efmesh_state`, semantics identical to the bun:sqlite implementation;
18
+ * timestamps are ISO UTC text (lexicographically sortable), same as in
19
+ * SQLite: the store's contents are portable between backends.
20
20
  */
21
21
 
22
22
  const SCHEMA = `
@@ -75,9 +75,9 @@ CREATE TABLE IF NOT EXISTS efmesh_state.locks (
75
75
  `
76
76
 
77
77
  export interface PostgresStateOptions {
78
- /** postgres://… или unix-сокет через ?host=/путь. */
78
+ /** postgres://… or a unix socket via ?host=/path. */
79
79
  readonly url: string
80
- /** Размер пула; состоянию хватает пары соединений. */
80
+ /** Pool size; a couple of connections are enough for state. */
81
81
  readonly max?: number
82
82
  }
83
83
 
@@ -88,7 +88,7 @@ const regclass = async (pool: SQL, table: string): Promise<boolean> => {
88
88
  return rows[0]?.r != null
89
89
  }
90
90
 
91
- /** 0 — стор без таблицы meta (создан до появления версионирования, F0–F3). */
91
+ /** 0 — a store with no meta table (created before versioning existed, F0–F3). */
92
92
  const readVersion = async (pool: SQL): Promise<number> => {
93
93
  if (!(await regclass(pool, "meta"))) return 0
94
94
  const rows = (await pool.unsafe(
@@ -97,7 +97,7 @@ const readVersion = async (pool: SQL): Promise<number> => {
97
97
  return rows[0]?.version ?? 0
98
98
  }
99
99
 
100
- /** Догоняет схему до STATE_VERSION (см. sqlite-реализациюсемантика та же). */
100
+ /** Catches the schema up to STATE_VERSION (see the sqlite implementation same semantics). */
101
101
  const applyMigrations = async (pool: SQL): Promise<void> => {
102
102
  await pool.unsafe(SCHEMA)
103
103
  await pool.unsafe(`
@@ -114,7 +114,7 @@ const applyMigrations = async (pool: SQL): Promise<void> => {
114
114
  })
115
115
  }
116
116
 
117
- /** `efmesh migrate`: явное обновление схемы существующего стора. */
117
+ /** `efmesh migrate`: an explicit schema upgrade of an existing store. */
118
118
  export const migratePostgresState = (
119
119
  options: PostgresStateOptions,
120
120
  ): Effect.Effect<MigrationReport, StateError> =>
@@ -153,8 +153,8 @@ export const PostgresStateLive = (
153
153
  }),
154
154
  (pool) => Effect.promise(() => pool.end()).pipe(Effect.ignore),
155
155
  )
156
- // свежий стор бутстрапится на текущую версию; существующий со схемой
157
- // старше требует явного `efmesh migrate` (SPEC §6)
156
+ // a fresh store bootstraps at the current version; an existing store
157
+ // with an older schema requires an explicit `efmesh migrate` (SPEC §6)
158
158
  const fresh = yield* Effect.tryPromise({
159
159
  try: async () => !(await regclass(sql, "snapshots")),
160
160
  catch: (cause) => new StateError({ operation: "open", cause }),
@@ -190,7 +190,7 @@ export const PostgresStateLive = (
190
190
  Effect.flatMap((now) =>
191
191
  attempt("upsertSnapshot", async () => {
192
192
  await sql.unsafe(
193
- // воскрешение снимает сиротство и освежает created_at — как в sqlite (гонка F6)
193
+ // reviving clears orphan status and refreshes created_at — same as sqlite (race, F6)
194
194
  `INSERT INTO efmesh_state.snapshots
195
195
  (name, fingerprint, rendered_sql, canonical_ast, physical_fp, kind, fingerprint_version, created_at)
196
196
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
@@ -284,8 +284,8 @@ export const PostgresStateLive = (
284
284
  Effect.flatMap((now) =>
285
285
  attempt("promote", () =>
286
286
  sql.begin(async (tx) => {
287
- // живость снапшотовв той же транзакции (гонка F6):
288
- // janitor унёс версиюгромкая ошибка, view не трогается
287
+ // snapshot livenessin the same transaction (race, F6):
288
+ // janitor removed the version loud error, the view is left alone
289
289
  for (const entry of entries) {
290
290
  if (entry.requireSnapshot !== true) continue
291
291
  const alive = (await tx.unsafe(
@@ -294,7 +294,7 @@ export const PostgresStateLive = (
294
294
  )) as ReadonlyArray<unknown>
295
295
  if (alive.length === 0) {
296
296
  throw new Error(
297
- `промоушен ${env}: снапшот ${entry.name}@${entry.fingerprint.slice(0, 8)} исчез из стора (снесён janitor?) — повторите apply`,
297
+ `promotion "${env}": snapshot ${entry.name}@${entry.fingerprint.slice(0, 8)} vanished from the store (removed by janitor?) — retry apply`,
298
298
  )
299
299
  }
300
300
  }
@@ -306,7 +306,7 @@ export const PostgresStateLive = (
306
306
  [env, entry.name, entry.fingerprint, now],
307
307
  )
308
308
  }
309
- // учёт сиротствакак в sqlite-реализации (SPEC §5.4)
309
+ // orphan bookkeepingsame as the sqlite implementation (SPEC §5.4)
310
310
  await tx.unsafe(
311
311
  `UPDATE efmesh_state.snapshots SET orphaned_at = $1
312
312
  WHERE orphaned_at IS NULL
@@ -387,8 +387,8 @@ export const PostgresStateLive = (
387
387
  sql.begin(async (tx) => {
388
388
  const now = new Date(nowMs).toISOString()
389
389
  const expires = new Date(nowMs + ttlMs).toISOString()
390
- // протухший лок упавшего процесса перехватывается;
391
- // <= — лок, истёкший в момент T, свободен с T
390
+ // a stale lock from a crashed process is reclaimed;
391
+ // <= — a lock that expires at instant T is free as of T
392
392
  await tx.unsafe(
393
393
  `DELETE FROM efmesh_state.locks WHERE name = $1 AND expires_at <= $2`,
394
394
  [name, now],
@@ -67,7 +67,7 @@ CREATE TABLE IF NOT EXISTS locks (
67
67
  `
68
68
 
69
69
  export interface SqliteStateOptions {
70
- /** Путь к файлу состояния; по умолчанию in-memory (тесты). */
70
+ /** Path to the state file; defaults to in-memory (tests). */
71
71
  readonly path?: string
72
72
  }
73
73
 
@@ -78,7 +78,7 @@ const tableExists = (db: Database, name: string): boolean =>
78
78
  .query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1`)
79
79
  .get(name) !== null
80
80
 
81
- /** 0 — стор без таблицы meta (создан до появления версионирования, F0–F3). */
81
+ /** 0 — a store with no meta table (created before versioning existed, F0–F3). */
82
82
  const readVersion = (db: Database): number => {
83
83
  if (!tableExists(db, "meta")) return 0
84
84
  const row = db.query(`SELECT version FROM meta`).get() as { version: number } | null
@@ -86,11 +86,11 @@ const readVersion = (db: Database): number => {
86
86
  }
87
87
 
88
88
  /**
89
- * Догоняет схему до STATE_VERSION. Версия 1 = базовая раскладка F4,
90
- * версия 2 = applied_by в журнале планов (F5); ALTER-ы ниже подхватывают
91
- * сторы, созданные до соответствующих колонок (canonical_ast — F2,
92
- * orphaned_at/physical_fp — F3, applied_by — F5),на новом сторе
93
- * они no-op через try/catch. Будущие версии новые записи здесь же.
89
+ * Catches the schema up to STATE_VERSION. Version 1 = base layout F4,
90
+ * version 2 = applied_by in the plan journal (F5); the ALTERs below pick up
91
+ * stores created before the corresponding columns existed (canonical_ast —
92
+ * F2, orphaned_at/physical_fp — F3, applied_by — F5) — on a fresh store
93
+ * they are a no-op via try/catch. Future versions get new entries right here.
94
94
  */
95
95
  const applyMigrations = (db: Database): void => {
96
96
  db.exec(SCHEMA)
@@ -104,7 +104,7 @@ const applyMigrations = (db: Database): void => {
104
104
  try {
105
105
  db.exec(alter)
106
106
  } catch {
107
- // колонка уже есть
107
+ // column already exists
108
108
  }
109
109
  }
110
110
  db.exec(`CREATE TABLE IF NOT EXISTS meta (version INTEGER NOT NULL)`)
@@ -113,9 +113,9 @@ const applyMigrations = (db: Database): void => {
113
113
  }
114
114
 
115
115
  /**
116
- * `efmesh migrate`: явное обновление схемы существующего стора.
117
- * Перед апгрейдом файл копируется в `<path>.backup-v<from>` — откат
118
- * на старую версию efmesh иначе односторонний (F6).
116
+ * `efmesh migrate`: an explicit schema upgrade of an existing store.
117
+ * Before the upgrade, the file is copied to `<path>.backup-v<from>` —
118
+ * otherwise rolling back to an older efmesh version would be a one-way trip (F6).
119
119
  */
120
120
  export const migrateSqliteState = (
121
121
  options?: SqliteStateOptions,
@@ -157,9 +157,9 @@ export const SqliteStateLive = (
157
157
  }),
158
158
  (db) => Effect.sync(() => db.close()),
159
159
  )
160
- // свежий стор бутстрапится на текущую версию; существующий со схемой
161
- // старше требует явного `efmesh migrate` — молча менять чужие данные
162
- // при открытии нельзя (SPEC §6)
160
+ // a fresh store bootstraps at the current version; an existing store
161
+ // with an older schema requires an explicit `efmesh migrate` — silently
162
+ // rewriting someone else's data on open is not allowed (SPEC §6)
163
163
  const fresh = yield* Effect.try({
164
164
  try: () => !tableExists(db, "snapshots"),
165
165
  catch: (cause) => new StateError({ operation: "open", cause }),
@@ -187,10 +187,10 @@ export const SqliteStateLive = (
187
187
  isoNow.pipe(
188
188
  Effect.flatMap((now) =>
189
189
  attempt("upsertSnapshot", () => {
190
- // воскрешение версии (повторный apply старого fingerprint)
191
- // снимает сиротство И освежает created_at СРАЗУ: между upsert
192
- // и промоушеном janitor не сочтёт её обречённой ни по
193
- // orphaned_at, ни по старому created_at (гонка F6)
190
+ // reviving a version (a repeated apply of an old fingerprint)
191
+ // clears orphan status AND refreshes created_at IMMEDIATELY: between
192
+ // the upsert and promotion, the janitor won't judge it doomed by
193
+ // either orphaned_at or a stale created_at (race, F6)
194
194
  db.query(
195
195
  `INSERT INTO snapshots (name, fingerprint, rendered_sql, canonical_ast, physical_fp, kind, fingerprint_version, created_at)
196
196
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
@@ -294,16 +294,16 @@ export const SqliteStateLive = (
294
294
  Effect.flatMap((now) =>
295
295
  attempt("promote", () => {
296
296
  const replace = db.transaction(() => {
297
- // живость снапшотовв той же транзакции: если janitor
298
- // успел унести версию, промоушен громко падает, а view
299
- // не переключается на снесённую физику (гонка F6)
297
+ // snapshot livenessin the same transaction: if the janitor
298
+ // already removed the version, promotion fails loudly, and the
299
+ // view never switches to demolished physics (race, F6)
300
300
  const alive = db.query(
301
301
  `SELECT 1 FROM snapshots WHERE name = ?1 AND fingerprint = ?2`,
302
302
  )
303
303
  for (const entry of entries) {
304
304
  if (entry.requireSnapshot === true && alive.get(entry.name, entry.fingerprint) === null) {
305
305
  throw new Error(
306
- `промоушен ${env}: снапшот ${entry.name}@${entry.fingerprint.slice(0, 8)} исчез из стора (снесён janitor?) — повторите apply`,
306
+ `promotion "${env}": snapshot ${entry.name}@${entry.fingerprint.slice(0, 8)} vanished from the store (removed by janitor?) — retry apply`,
307
307
  )
308
308
  }
309
309
  }
@@ -315,9 +315,9 @@ export const SqliteStateLive = (
315
315
  for (const entry of entries) {
316
316
  insert.run(env, entry.name, entry.fingerprint, now)
317
317
  }
318
- // ссылочность меняется только здесьтут же и учёт сиротства:
319
- // потерявшие последнюю ссылку получают отметку, вернувшиеся
320
- // (откат на старую версию) теряют её (SPEC §5.4)
318
+ // referencing changes only hereso orphan bookkeeping happens
319
+ // right here too: those losing their last reference get marked,
320
+ // those returning (rollback to an old version) lose the mark (SPEC §5.4)
321
321
  db.query(
322
322
  `UPDATE snapshots SET orphaned_at = ?1
323
323
  WHERE orphaned_at IS NULL
@@ -396,8 +396,8 @@ export const SqliteStateLive = (
396
396
  const now = new Date(nowMs).toISOString()
397
397
  const expires = new Date(nowMs + ttlMs).toISOString()
398
398
  const acquire = db.transaction(() => {
399
- // протухший лок упавшего процесса перехватывается;
400
- // <= — лок, истёкший в момент T, свободен с T (ttl=0 в ту же мс)
399
+ // a stale lock from a crashed process is reclaimed;
400
+ // <= — a lock that expires at instant T is free as of T (ttl=0 in the same ms)
401
401
  db.query(`DELETE FROM locks WHERE name = ?1 AND expires_at <= ?2`).run(name, now)
402
402
  const result = db
403
403
  .query(