@avytheone/efmesh 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,407 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { copyFileSync, existsSync } from "node:fs"
3
+ import { Clock, Effect, Layer } from "effect"
4
+ import type {
5
+ EnvironmentRecord,
6
+ IntervalRecord,
7
+ MigrationReport,
8
+ PlanRecord,
9
+ SnapshotRecord,
10
+ StateStoreShape,
11
+ } from "./store.ts"
12
+ import { STATE_VERSION, StateError, StateSchemaError, StateStore } from "./store.ts"
13
+
14
+ const SCHEMA = `
15
+ CREATE TABLE IF NOT EXISTS snapshots (
16
+ name TEXT NOT NULL,
17
+ fingerprint TEXT NOT NULL,
18
+ rendered_sql TEXT NOT NULL,
19
+ canonical_ast TEXT NOT NULL DEFAULT '',
20
+ physical_fp TEXT NOT NULL DEFAULT '',
21
+ kind TEXT NOT NULL,
22
+ fingerprint_version INTEGER NOT NULL DEFAULT 1,
23
+ created_at TEXT NOT NULL,
24
+ orphaned_at TEXT,
25
+ PRIMARY KEY (name, fingerprint)
26
+ );
27
+ CREATE TABLE IF NOT EXISTS environments (
28
+ env TEXT NOT NULL,
29
+ name TEXT NOT NULL,
30
+ fingerprint TEXT NOT NULL,
31
+ promoted_at TEXT NOT NULL,
32
+ PRIMARY KEY (env, name)
33
+ );
34
+ CREATE TABLE IF NOT EXISTS plans (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ env TEXT NOT NULL,
37
+ summary TEXT NOT NULL,
38
+ applied_at TEXT NOT NULL,
39
+ applied_by TEXT NOT NULL DEFAULT ''
40
+ );
41
+ CREATE TABLE IF NOT EXISTS intervals (
42
+ snapshot_fp TEXT NOT NULL,
43
+ start_ts TEXT NOT NULL,
44
+ end_ts TEXT NOT NULL,
45
+ status TEXT NOT NULL,
46
+ updated_at TEXT NOT NULL,
47
+ PRIMARY KEY (snapshot_fp, start_ts)
48
+ );
49
+ CREATE TABLE IF NOT EXISTS locks (
50
+ name TEXT PRIMARY KEY,
51
+ acquired_at TEXT NOT NULL,
52
+ expires_at TEXT NOT NULL
53
+ );
54
+ `
55
+
56
+ export interface SqliteStateOptions {
57
+ /** Путь к файлу состояния; по умолчанию in-memory (тесты). */
58
+ readonly path?: string
59
+ }
60
+
61
+ const isoNow = Clock.currentTimeMillis.pipe(Effect.map((ms) => new Date(ms).toISOString()))
62
+
63
+ const tableExists = (db: Database, name: string): boolean =>
64
+ db
65
+ .query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1`)
66
+ .get(name) !== null
67
+
68
+ /** 0 — стор без таблицы meta (создан до появления версионирования, F0–F3). */
69
+ const readVersion = (db: Database): number => {
70
+ if (!tableExists(db, "meta")) return 0
71
+ const row = db.query(`SELECT version FROM meta`).get() as { version: number } | null
72
+ return row?.version ?? 0
73
+ }
74
+
75
+ /**
76
+ * Догоняет схему до STATE_VERSION. Версия 1 = базовая раскладка F4,
77
+ * версия 2 = applied_by в журнале планов (F5); ALTER-ы ниже подхватывают
78
+ * сторы, созданные до соответствующих колонок (canonical_ast — F2,
79
+ * orphaned_at/physical_fp — F3, applied_by — F5), — на новом сторе
80
+ * они no-op через try/catch. Будущие версии — новые записи здесь же.
81
+ */
82
+ const applyMigrations = (db: Database): void => {
83
+ db.exec(SCHEMA)
84
+ for (const alter of [
85
+ `ALTER TABLE snapshots ADD COLUMN canonical_ast TEXT NOT NULL DEFAULT ''`,
86
+ `ALTER TABLE snapshots ADD COLUMN orphaned_at TEXT`,
87
+ `ALTER TABLE snapshots ADD COLUMN physical_fp TEXT NOT NULL DEFAULT ''`,
88
+ `ALTER TABLE plans ADD COLUMN applied_by TEXT NOT NULL DEFAULT ''`,
89
+ `ALTER TABLE snapshots ADD COLUMN fingerprint_version INTEGER NOT NULL DEFAULT 1`,
90
+ ]) {
91
+ try {
92
+ db.exec(alter)
93
+ } catch {
94
+ // колонка уже есть
95
+ }
96
+ }
97
+ db.exec(`CREATE TABLE IF NOT EXISTS meta (version INTEGER NOT NULL)`)
98
+ db.exec(`DELETE FROM meta`)
99
+ db.query(`INSERT INTO meta (version) VALUES (?1)`).run(STATE_VERSION)
100
+ }
101
+
102
+ /**
103
+ * `efmesh migrate`: явное обновление схемы существующего стора.
104
+ * Перед апгрейдом файл копируется в `<path>.backup-v<from>` — откат
105
+ * на старую версию efmesh иначе односторонний (F6).
106
+ */
107
+ export const migrateSqliteState = (
108
+ options?: SqliteStateOptions,
109
+ ): Effect.Effect<MigrationReport, StateError> =>
110
+ Effect.try({
111
+ try: () => {
112
+ const path = options?.path ?? ":memory:"
113
+ const db = new Database(path, { create: true })
114
+ try {
115
+ const from = readVersion(db)
116
+ let backup: string | undefined
117
+ if (path !== ":memory:" && from !== STATE_VERSION && existsSync(path)) {
118
+ backup = `${path}.backup-v${from}`
119
+ copyFileSync(path, backup)
120
+ }
121
+ applyMigrations(db)
122
+ return { from, to: STATE_VERSION, ...(backup !== undefined ? { backup } : {}) }
123
+ } finally {
124
+ db.close()
125
+ }
126
+ },
127
+ catch: (cause) => new StateError({ operation: "migrate", cause }),
128
+ })
129
+
130
+ export const SqliteStateLive = (
131
+ options?: SqliteStateOptions,
132
+ ): Layer.Layer<StateStore, StateError | StateSchemaError> =>
133
+ Layer.effect(
134
+ StateStore,
135
+ Effect.gen(function* () {
136
+ const db = yield* Effect.acquireRelease(
137
+ Effect.try({
138
+ try: () => {
139
+ const db = new Database(options?.path ?? ":memory:", { create: true })
140
+ db.exec("PRAGMA journal_mode = WAL;")
141
+ return db
142
+ },
143
+ catch: (cause) => new StateError({ operation: "open", cause }),
144
+ }),
145
+ (db) => Effect.sync(() => db.close()),
146
+ )
147
+ // свежий стор бутстрапится на текущую версию; существующий со схемой
148
+ // старше требует явного `efmesh migrate` — молча менять чужие данные
149
+ // при открытии нельзя (SPEC §6)
150
+ const fresh = yield* Effect.try({
151
+ try: () => !tableExists(db, "snapshots"),
152
+ catch: (cause) => new StateError({ operation: "open", cause }),
153
+ })
154
+ if (fresh) {
155
+ yield* Effect.try({
156
+ try: () => applyMigrations(db),
157
+ catch: (cause) => new StateError({ operation: "migrate", cause }),
158
+ })
159
+ } else {
160
+ const version = yield* Effect.try({
161
+ try: () => readVersion(db),
162
+ catch: (cause) => new StateError({ operation: "open", cause }),
163
+ })
164
+ if (version !== STATE_VERSION) {
165
+ return yield* new StateSchemaError({ found: version, wanted: STATE_VERSION })
166
+ }
167
+ }
168
+
169
+ const attempt = <A>(operation: string, body: () => A) =>
170
+ Effect.try({ try: body, catch: (cause) => new StateError({ operation, cause }) })
171
+
172
+ const service: StateStoreShape = {
173
+ upsertSnapshot: (snapshot) =>
174
+ isoNow.pipe(
175
+ Effect.flatMap((now) =>
176
+ attempt("upsertSnapshot", () => {
177
+ // воскрешение версии (повторный apply старого fingerprint)
178
+ // снимает сиротство И освежает created_at СРАЗУ: между upsert
179
+ // и промоушеном janitor не сочтёт её обречённой ни по
180
+ // orphaned_at, ни по старому created_at (гонка F6)
181
+ db.query(
182
+ `INSERT INTO snapshots (name, fingerprint, rendered_sql, canonical_ast, physical_fp, kind, fingerprint_version, created_at)
183
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
184
+ ON CONFLICT (name, fingerprint)
185
+ DO UPDATE SET orphaned_at = NULL, created_at = excluded.created_at`,
186
+ ).run(
187
+ snapshot.name,
188
+ snapshot.fingerprint,
189
+ snapshot.renderedSql,
190
+ snapshot.canonicalAst,
191
+ snapshot.physicalFp,
192
+ snapshot.kind,
193
+ snapshot.fingerprintVersion,
194
+ now,
195
+ )
196
+ }),
197
+ ),
198
+ ),
199
+
200
+ getSnapshot: (name, fingerprint) =>
201
+ attempt("getSnapshot", () => {
202
+ const row = db
203
+ .query(
204
+ `SELECT name, fingerprint, rendered_sql AS renderedSql,
205
+ canonical_ast AS canonicalAst, kind, created_at AS createdAt,
206
+ orphaned_at AS orphanedAt, fingerprint_version AS fingerprintVersion,
207
+ CASE WHEN physical_fp = '' THEN fingerprint ELSE physical_fp END AS physicalFp
208
+ FROM snapshots WHERE name = ?1 AND fingerprint = ?2`,
209
+ )
210
+ .get(name, fingerprint) as SnapshotRecord | null
211
+ return row ?? undefined
212
+ }),
213
+
214
+ listReferencedFingerprints: () =>
215
+ attempt("listReferencedFingerprints", () => {
216
+ const rows = db
217
+ .query(`SELECT DISTINCT fingerprint FROM environments`)
218
+ .all() as ReadonlyArray<{ fingerprint: string }>
219
+ return new Set(rows.map((r) => r.fingerprint))
220
+ }),
221
+
222
+ listSnapshots: () =>
223
+ attempt("listSnapshots", () => {
224
+ return db
225
+ .query(
226
+ `SELECT name, fingerprint, rendered_sql AS renderedSql,
227
+ canonical_ast AS canonicalAst, kind, created_at AS createdAt,
228
+ orphaned_at AS orphanedAt, fingerprint_version AS fingerprintVersion,
229
+ CASE WHEN physical_fp = '' THEN fingerprint ELSE physical_fp END AS physicalFp
230
+ FROM snapshots ORDER BY name, created_at`,
231
+ )
232
+ .all() as ReadonlyArray<SnapshotRecord>
233
+ }),
234
+
235
+ deleteSnapshot: (name, fingerprint) =>
236
+ attempt("deleteSnapshot", () => {
237
+ const remove = db.transaction(() => {
238
+ db.query(`DELETE FROM snapshots WHERE name = ?1 AND fingerprint = ?2`).run(
239
+ name,
240
+ fingerprint,
241
+ )
242
+ db.query(`DELETE FROM intervals WHERE snapshot_fp = ?1`).run(fingerprint)
243
+ })
244
+ remove()
245
+ }),
246
+
247
+ deleteSnapshotIfDoomed: (name, fingerprint, deadline) =>
248
+ attempt("deleteSnapshotIfDoomed", () => {
249
+ const claim = db.transaction((): boolean => {
250
+ const referenced =
251
+ db
252
+ .query(`SELECT 1 FROM environments WHERE fingerprint = ?1 LIMIT 1`)
253
+ .get(fingerprint) !== null
254
+ if (referenced) return false
255
+ const result = db
256
+ .query(
257
+ `DELETE FROM snapshots
258
+ WHERE name = ?1 AND fingerprint = ?2
259
+ AND COALESCE(orphaned_at, created_at) <= ?3`,
260
+ )
261
+ .run(name, fingerprint, deadline)
262
+ if (result.changes === 0) return false
263
+ db.query(`DELETE FROM intervals WHERE snapshot_fp = ?1`).run(fingerprint)
264
+ return true
265
+ })
266
+ return claim() as boolean
267
+ }),
268
+
269
+ getEnvironment: (env) =>
270
+ attempt("getEnvironment", () => {
271
+ return db
272
+ .query(
273
+ `SELECT env, name, fingerprint, promoted_at AS promotedAt
274
+ FROM environments WHERE env = ?1 ORDER BY name`,
275
+ )
276
+ .all(env) as ReadonlyArray<EnvironmentRecord>
277
+ }),
278
+
279
+ promote: (env, entries) =>
280
+ isoNow.pipe(
281
+ Effect.flatMap((now) =>
282
+ attempt("promote", () => {
283
+ const replace = db.transaction(() => {
284
+ // живость снапшотов — в той же транзакции: если janitor
285
+ // успел унести версию, промоушен громко падает, а view
286
+ // не переключается на снесённую физику (гонка F6)
287
+ const alive = db.query(
288
+ `SELECT 1 FROM snapshots WHERE name = ?1 AND fingerprint = ?2`,
289
+ )
290
+ for (const entry of entries) {
291
+ if (entry.requireSnapshot === true && alive.get(entry.name, entry.fingerprint) === null) {
292
+ throw new Error(
293
+ `промоушен ${env}: снапшот ${entry.name}@${entry.fingerprint.slice(0, 8)} исчез из стора (снесён janitor?) — повторите apply`,
294
+ )
295
+ }
296
+ }
297
+ db.query(`DELETE FROM environments WHERE env = ?1`).run(env)
298
+ const insert = db.query(
299
+ `INSERT INTO environments (env, name, fingerprint, promoted_at)
300
+ VALUES (?1, ?2, ?3, ?4)`,
301
+ )
302
+ for (const entry of entries) {
303
+ insert.run(env, entry.name, entry.fingerprint, now)
304
+ }
305
+ // ссылочность меняется только здесь — тут же и учёт сиротства:
306
+ // потерявшие последнюю ссылку получают отметку, вернувшиеся
307
+ // (откат на старую версию) — теряют её (SPEC §5.4)
308
+ db.query(
309
+ `UPDATE snapshots SET orphaned_at = ?1
310
+ WHERE orphaned_at IS NULL
311
+ AND fingerprint NOT IN (SELECT fingerprint FROM environments)`,
312
+ ).run(now)
313
+ db.query(
314
+ `UPDATE snapshots SET orphaned_at = NULL
315
+ WHERE orphaned_at IS NOT NULL
316
+ AND fingerprint IN (SELECT fingerprint FROM environments)`,
317
+ ).run()
318
+ })
319
+ replace()
320
+ }),
321
+ ),
322
+ ),
323
+
324
+ recordPlan: (env, summary, appliedBy) =>
325
+ isoNow.pipe(
326
+ Effect.flatMap((now) =>
327
+ attempt("recordPlan", () => {
328
+ db.query(
329
+ `INSERT INTO plans (env, summary, applied_at, applied_by) VALUES (?1, ?2, ?3, ?4)`,
330
+ ).run(env, summary, now, appliedBy)
331
+ }),
332
+ ),
333
+ ),
334
+
335
+ listPlans: (env) =>
336
+ attempt("listPlans", () => {
337
+ return db
338
+ .query(
339
+ `SELECT id, env, summary, applied_at AS appliedAt, applied_by AS appliedBy
340
+ FROM plans WHERE env = ?1 ORDER BY id`,
341
+ )
342
+ .all(env) as ReadonlyArray<PlanRecord>
343
+ }),
344
+
345
+ acquireLock: (name, ttlMs) =>
346
+ Clock.currentTimeMillis.pipe(
347
+ Effect.flatMap((nowMs) =>
348
+ attempt("acquireLock", () => {
349
+ const now = new Date(nowMs).toISOString()
350
+ const expires = new Date(nowMs + ttlMs).toISOString()
351
+ const acquire = db.transaction(() => {
352
+ // протухший лок упавшего процесса перехватывается;
353
+ // <= — лок, истёкший в момент T, свободен с T (ttl=0 в ту же мс)
354
+ db.query(`DELETE FROM locks WHERE name = ?1 AND expires_at <= ?2`).run(name, now)
355
+ const result = db
356
+ .query(
357
+ `INSERT INTO locks (name, acquired_at, expires_at)
358
+ VALUES (?1, ?2, ?3)
359
+ ON CONFLICT (name) DO NOTHING`,
360
+ )
361
+ .run(name, now, expires)
362
+ return result.changes > 0
363
+ })
364
+ return acquire()
365
+ }),
366
+ ),
367
+ ),
368
+
369
+ releaseLock: (name) =>
370
+ attempt("releaseLock", () => {
371
+ db.query(`DELETE FROM locks WHERE name = ?1`).run(name)
372
+ }),
373
+
374
+ markIntervals: (snapshotFp, intervals, status) =>
375
+ isoNow.pipe(
376
+ Effect.flatMap((now) =>
377
+ attempt("markIntervals", () => {
378
+ const upsert = db.query(
379
+ `INSERT INTO intervals (snapshot_fp, start_ts, end_ts, status, updated_at)
380
+ VALUES (?1, ?2, ?3, ?4, ?5)
381
+ ON CONFLICT (snapshot_fp, start_ts)
382
+ DO UPDATE SET end_ts = ?3, status = ?4, updated_at = ?5`,
383
+ )
384
+ const all = db.transaction(() => {
385
+ for (const interval of intervals) {
386
+ upsert.run(snapshotFp, interval.startTs, interval.endTs, status, now)
387
+ }
388
+ })
389
+ all()
390
+ }),
391
+ ),
392
+ ),
393
+
394
+ listIntervals: (snapshotFp) =>
395
+ attempt("listIntervals", () => {
396
+ return db
397
+ .query(
398
+ `SELECT snapshot_fp AS snapshotFp, start_ts AS startTs, end_ts AS endTs,
399
+ status, updated_at AS updatedAt
400
+ FROM intervals WHERE snapshot_fp = ?1 ORDER BY start_ts`,
401
+ )
402
+ .all(snapshotFp) as ReadonlyArray<IntervalRecord>
403
+ }),
404
+ }
405
+ return service
406
+ }),
407
+ )
@@ -0,0 +1,161 @@
1
+ import { Context, Data, Effect } from "effect"
2
+
3
+ export class StateError extends Data.TaggedError("StateError")<{
4
+ readonly operation: string
5
+ readonly cause: unknown
6
+ }> {}
7
+
8
+ /**
9
+ * Текущая версия схемы state store. Свежий стор бутстрапится сразу
10
+ * на неё; стор со схемой старше (в т.ч. созданный до появления версии)
11
+ * открытие не проходит — данные догоняет явный `efmesh migrate`.
12
+ * 1 — базовая раскладка (F4), 2 — applied_by в журнале планов (F5),
13
+ * 3 — fingerprint_version в снапшотах (F6).
14
+ */
15
+ export const STATE_VERSION = 3
16
+
17
+ /** Схема стора не совпадает с ожидаемой бинарём — нужен `efmesh migrate`. */
18
+ export class StateSchemaError extends Data.TaggedError("StateSchemaError")<{
19
+ readonly found: number
20
+ readonly wanted: number
21
+ }> {}
22
+
23
+ /** Итог миграции для CLI. */
24
+ export interface MigrationReport {
25
+ readonly from: number
26
+ readonly to: number
27
+ /** Куда сложена копия стора перед апгрейдом схемы (SQLite; F6). */
28
+ readonly backup?: string
29
+ }
30
+
31
+ /** Версия модели, известная state store (SPEC §6). */
32
+ export interface SnapshotRecord {
33
+ readonly name: string
34
+ readonly fingerprint: string
35
+ /** Canonical-рендер SQL — для diff-показа и отладки. */
36
+ readonly renderedSql: string
37
+ /** Канонический AST тела (JSON) — для категоризации изменений (SPEC §5.2). */
38
+ readonly canonicalAst: string
39
+ /**
40
+ * Fingerprint, чьей физической таблицей/префиксом пользуется снапшот.
41
+ * Обычно равен собственному; при forward-only (SPEC §5.2) — наследуется
42
+ * от предыдущей версии: физика переиспользуется, история не переигрывается.
43
+ */
44
+ readonly physicalFp: string
45
+ readonly kind: string
46
+ /**
47
+ * Версия алгоритма fingerprint, которым посчитан снапшот (SPEC §4).
48
+ * План сравнивает только одноверсионные отпечатки; иная версия —
49
+ * громкая остановка, не тихий «всё breaking».
50
+ */
51
+ readonly fingerprintVersion: number
52
+ readonly createdAt: string
53
+ /**
54
+ * Когда снапшот перестал быть указан хоть одним окружением (ISO UTC);
55
+ * null — на него ссылаются. Ставится и снимается при промоушене,
56
+ * ttl janitor'а отсчитывается отсюда (SPEC §5.4).
57
+ */
58
+ readonly orphanedAt: string | null
59
+ }
60
+
61
+ /** Строка окружения: логическое имя → снапшот, на который указывает view. */
62
+ export interface EnvironmentRecord {
63
+ readonly env: string
64
+ readonly name: string
65
+ readonly fingerprint: string
66
+ readonly promotedAt: string
67
+ }
68
+
69
+ export interface PlanRecord {
70
+ readonly id: number
71
+ readonly env: string
72
+ readonly summary: string
73
+ readonly appliedAt: string
74
+ /** Кто применил план (ОС-пользователь или ApplyOptions.appliedBy); '' у записей до v2. */
75
+ readonly appliedBy: string
76
+ }
77
+
78
+ /**
79
+ * Учёт заполненных интервалов снапшота (SPEC §6) — единственный источник
80
+ * правды о том, что посчитано: физическая таблица без записей здесь
81
+ * считается пустой. Границы — ISO UTC (сортируются лексикографически).
82
+ */
83
+ export interface IntervalRecord {
84
+ readonly snapshotFp: string
85
+ readonly startTs: string
86
+ readonly endTs: string
87
+ readonly status: "done" | "failed"
88
+ readonly updatedAt: string
89
+ }
90
+
91
+ export interface StateStoreShape {
92
+ /** Идемпотентно: (name, fingerprint) уникальны, повторная запись — no-op. */
93
+ readonly upsertSnapshot: (
94
+ snapshot: Omit<SnapshotRecord, "createdAt" | "orphanedAt">,
95
+ ) => Effect.Effect<void, StateError>
96
+ readonly getSnapshot: (
97
+ name: string,
98
+ fingerprint: string,
99
+ ) => Effect.Effect<SnapshotRecord | undefined, StateError>
100
+ /** Все снапшоты, на которые ссылается хоть одно окружение, — для janitor. */
101
+ readonly listReferencedFingerprints: () => Effect.Effect<ReadonlySet<string>, StateError>
102
+ readonly listSnapshots: () => Effect.Effect<ReadonlyArray<SnapshotRecord>, StateError>
103
+ /** Удаляет запись снапшота и его учёт интервалов (физику убирает janitor). */
104
+ readonly deleteSnapshot: (name: string, fingerprint: string) => Effect.Effect<void, StateError>
105
+ /**
106
+ * Транзакционный «claim» сироты janitor'ом (SPEC §5.4, F6): запись и учёт
107
+ * интервалов удаляются, только если снапшот ВСЁ ЕЩЁ не referenced ни одним
108
+ * окружением и осиротел не позже deadline (ISO UTC) — обе проверки в одной
109
+ * транзакции с удалением, поэтому параллельный apply, воскресивший версию,
110
+ * не даст её унести. true — снесено, false — состояние изменилось.
111
+ */
112
+ readonly deleteSnapshotIfDoomed: (
113
+ name: string,
114
+ fingerprint: string,
115
+ deadline: string,
116
+ ) => Effect.Effect<boolean, StateError>
117
+ readonly getEnvironment: (
118
+ env: string,
119
+ ) => Effect.Effect<ReadonlyArray<EnvironmentRecord>, StateError>
120
+ /**
121
+ * Транзакционно заменяет весь набор окружения. Записи с
122
+ * `requireSnapshot: true` проверяются на живость снапшота в той же
123
+ * транзакции: если janitor успел его унести, промоушен громко падает —
124
+ * view никогда не переключается на снесённую физику.
125
+ */
126
+ readonly promote: (
127
+ env: string,
128
+ entries: ReadonlyArray<{
129
+ readonly name: string
130
+ readonly fingerprint: string
131
+ readonly requireSnapshot?: boolean
132
+ }>,
133
+ ) => Effect.Effect<void, StateError>
134
+ /** Журнал применённых планов. */
135
+ readonly recordPlan: (
136
+ env: string,
137
+ summary: string,
138
+ appliedBy: string,
139
+ ) => Effect.Effect<void, StateError>
140
+ readonly listPlans: (env: string) => Effect.Effect<ReadonlyArray<PlanRecord>, StateError>
141
+ /**
142
+ * Межпроцессная блокировка (SPEC §7): true — получена, false — держит
143
+ * другой процесс. Протухшие (expires) локи перехватываются — упавший
144
+ * процесс не оставляет вечный замок.
145
+ */
146
+ readonly acquireLock: (name: string, ttlMs: number) => Effect.Effect<boolean, StateError>
147
+ readonly releaseLock: (name: string) => Effect.Effect<void, StateError>
148
+ /** Транзакционный upsert интервалов снапшота (повторная отметка — обновление статуса). */
149
+ readonly markIntervals: (
150
+ snapshotFp: string,
151
+ intervals: ReadonlyArray<{ readonly startTs: string; readonly endTs: string }>,
152
+ status: IntervalRecord["status"],
153
+ ) => Effect.Effect<void, StateError>
154
+ readonly listIntervals: (
155
+ snapshotFp: string,
156
+ ) => Effect.Effect<ReadonlyArray<IntervalRecord>, StateError>
157
+ }
158
+
159
+ export class StateStore extends Context.Service<StateStore, StateStoreShape>()(
160
+ "efmesh/StateStore",
161
+ ) {}