@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.
- package/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/README.ru.md +203 -0
- package/SPEC.md +641 -0
- package/package.json +57 -0
- package/src/bin.ts +12 -0
- package/src/cli.ts +518 -0
- package/src/config.ts +44 -0
- package/src/core/audit.ts +88 -0
- package/src/core/errors.ts +27 -0
- package/src/core/graph.ts +76 -0
- package/src/core/interval.ts +119 -0
- package/src/core/model.ts +475 -0
- package/src/core/sql.ts +200 -0
- package/src/discovery.ts +77 -0
- package/src/efmesh.ts +94 -0
- package/src/engine/adapter.ts +52 -0
- package/src/engine/duckdb.ts +117 -0
- package/src/engine/postgres.ts +119 -0
- package/src/index.ts +114 -0
- package/src/init.ts +88 -0
- package/src/plan/audit-run.ts +101 -0
- package/src/plan/categorize.ts +41 -0
- package/src/plan/contract.ts +114 -0
- package/src/plan/diff.ts +36 -0
- package/src/plan/executor.ts +760 -0
- package/src/plan/fingerprint.ts +125 -0
- package/src/plan/graph-html.ts +157 -0
- package/src/plan/janitor.ts +130 -0
- package/src/plan/lineage.ts +143 -0
- package/src/plan/lock.ts +37 -0
- package/src/plan/metrics.ts +14 -0
- package/src/plan/naming.ts +91 -0
- package/src/plan/planner.ts +252 -0
- package/src/plan/run.ts +70 -0
- package/src/state/postgres.ts +396 -0
- package/src/state/sqlite.ts +407 -0
- package/src/state/store.ts +161 -0
- package/src/testing/index.ts +193 -0
|
@@ -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
|
+
) {}
|