@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,396 @@
1
+ import { SQL } from "bun"
2
+ import { Clock, Effect, Layer } from "effect"
3
+ import type {
4
+ EnvironmentRecord,
5
+ IntervalRecord,
6
+ MigrationReport,
7
+ PlanRecord,
8
+ SnapshotRecord,
9
+ StateStoreShape,
10
+ } from "./store.ts"
11
+ import { STATE_VERSION, StateError, StateSchemaError, StateStore } from "./store.ts"
12
+
13
+ /**
14
+ * State store в Postgres (SPEC §6, F3) — для командной/прод-работы:
15
+ * состояние переживает конкурентные запуски из разных процессов и машин.
16
+ * Схема `efmesh_state`, семантика один в один с bun:sqlite-реализацией;
17
+ * временные метки — ISO UTC текстом (лексикографически сортируемы),
18
+ * как и в SQLite: содержимое стора переносимо между бэкендами.
19
+ */
20
+
21
+ const SCHEMA = `
22
+ CREATE SCHEMA IF NOT EXISTS efmesh_state;
23
+ CREATE TABLE IF NOT EXISTS efmesh_state.snapshots (
24
+ name TEXT NOT NULL,
25
+ fingerprint TEXT NOT NULL,
26
+ rendered_sql TEXT NOT NULL,
27
+ canonical_ast TEXT NOT NULL DEFAULT '',
28
+ physical_fp TEXT NOT NULL DEFAULT '',
29
+ kind TEXT NOT NULL,
30
+ fingerprint_version INTEGER NOT NULL DEFAULT 1,
31
+ created_at TEXT NOT NULL,
32
+ orphaned_at TEXT,
33
+ PRIMARY KEY (name, fingerprint)
34
+ );
35
+ CREATE TABLE IF NOT EXISTS efmesh_state.environments (
36
+ env TEXT NOT NULL,
37
+ name TEXT NOT NULL,
38
+ fingerprint TEXT NOT NULL,
39
+ promoted_at TEXT NOT NULL,
40
+ PRIMARY KEY (env, name)
41
+ );
42
+ CREATE TABLE IF NOT EXISTS efmesh_state.plans (
43
+ id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
44
+ env TEXT NOT NULL,
45
+ summary TEXT NOT NULL,
46
+ applied_at TEXT NOT NULL,
47
+ applied_by TEXT NOT NULL DEFAULT ''
48
+ );
49
+ CREATE TABLE IF NOT EXISTS efmesh_state.intervals (
50
+ snapshot_fp TEXT NOT NULL,
51
+ start_ts TEXT NOT NULL,
52
+ end_ts TEXT NOT NULL,
53
+ status TEXT NOT NULL,
54
+ updated_at TEXT NOT NULL,
55
+ PRIMARY KEY (snapshot_fp, start_ts)
56
+ );
57
+ CREATE TABLE IF NOT EXISTS efmesh_state.locks (
58
+ name TEXT PRIMARY KEY,
59
+ acquired_at TEXT NOT NULL,
60
+ expires_at TEXT NOT NULL
61
+ );
62
+ `
63
+
64
+ export interface PostgresStateOptions {
65
+ /** postgres://… или unix-сокет через ?host=/путь. */
66
+ readonly url: string
67
+ /** Размер пула; состоянию хватает пары соединений. */
68
+ readonly max?: number
69
+ }
70
+
71
+ const regclass = async (pool: SQL, table: string): Promise<boolean> => {
72
+ const rows = (await pool.unsafe(
73
+ `SELECT to_regclass('efmesh_state.${table}') AS r`,
74
+ )) as ReadonlyArray<{ r: string | null }>
75
+ return rows[0]?.r != null
76
+ }
77
+
78
+ /** 0 — стор без таблицы meta (создан до появления версионирования, F0–F3). */
79
+ const readVersion = async (pool: SQL): Promise<number> => {
80
+ if (!(await regclass(pool, "meta"))) return 0
81
+ const rows = (await pool.unsafe(
82
+ `SELECT version FROM efmesh_state.meta`,
83
+ )) as ReadonlyArray<{ version: number }>
84
+ return rows[0]?.version ?? 0
85
+ }
86
+
87
+ /** Догоняет схему до STATE_VERSION (см. sqlite-реализацию — семантика та же). */
88
+ const applyMigrations = async (pool: SQL): Promise<void> => {
89
+ await pool.unsafe(SCHEMA)
90
+ await pool.unsafe(`
91
+ ALTER TABLE efmesh_state.snapshots ADD COLUMN IF NOT EXISTS canonical_ast TEXT NOT NULL DEFAULT '';
92
+ ALTER TABLE efmesh_state.snapshots ADD COLUMN IF NOT EXISTS orphaned_at TEXT;
93
+ ALTER TABLE efmesh_state.snapshots ADD COLUMN IF NOT EXISTS physical_fp TEXT NOT NULL DEFAULT '';
94
+ ALTER TABLE efmesh_state.plans ADD COLUMN IF NOT EXISTS applied_by TEXT NOT NULL DEFAULT '';
95
+ ALTER TABLE efmesh_state.snapshots ADD COLUMN IF NOT EXISTS fingerprint_version INTEGER NOT NULL DEFAULT 1;
96
+ CREATE TABLE IF NOT EXISTS efmesh_state.meta (version INTEGER NOT NULL);
97
+ `)
98
+ await pool.begin(async (tx) => {
99
+ await tx.unsafe(`DELETE FROM efmesh_state.meta`)
100
+ await tx.unsafe(`INSERT INTO efmesh_state.meta (version) VALUES ($1)`, [STATE_VERSION])
101
+ })
102
+ }
103
+
104
+ /** `efmesh migrate`: явное обновление схемы существующего стора. */
105
+ export const migratePostgresState = (
106
+ options: PostgresStateOptions,
107
+ ): Effect.Effect<MigrationReport, StateError> =>
108
+ Effect.tryPromise({
109
+ try: async () => {
110
+ const pool = new SQL({ url: options.url, max: 1 })
111
+ try {
112
+ const from = await readVersion(pool)
113
+ await applyMigrations(pool)
114
+ return { from, to: STATE_VERSION }
115
+ } finally {
116
+ await pool.end()
117
+ }
118
+ },
119
+ catch: (cause) => new StateError({ operation: "migrate", cause }),
120
+ })
121
+
122
+ const SNAPSHOT_COLUMNS = `
123
+ name, fingerprint, rendered_sql AS "renderedSql",
124
+ canonical_ast AS "canonicalAst", kind, created_at AS "createdAt",
125
+ orphaned_at AS "orphanedAt",
126
+ fingerprint_version AS "fingerprintVersion",
127
+ CASE WHEN physical_fp = '' THEN fingerprint ELSE physical_fp END AS "physicalFp"
128
+ `
129
+
130
+ export const PostgresStateLive = (
131
+ options: PostgresStateOptions,
132
+ ): Layer.Layer<StateStore, StateError | StateSchemaError> =>
133
+ Layer.effect(
134
+ StateStore,
135
+ Effect.gen(function* () {
136
+ const sql = yield* Effect.acquireRelease(
137
+ Effect.tryPromise({
138
+ try: async () => new SQL({ url: options.url, max: options.max ?? 4 }),
139
+ catch: (cause) => new StateError({ operation: "open", cause }),
140
+ }),
141
+ (pool) => Effect.promise(() => pool.end()).pipe(Effect.ignore),
142
+ )
143
+ // свежий стор бутстрапится на текущую версию; существующий со схемой
144
+ // старше требует явного `efmesh migrate` (SPEC §6)
145
+ const fresh = yield* Effect.tryPromise({
146
+ try: async () => !(await regclass(sql, "snapshots")),
147
+ catch: (cause) => new StateError({ operation: "open", cause }),
148
+ })
149
+ if (fresh) {
150
+ yield* Effect.tryPromise({
151
+ try: () => applyMigrations(sql),
152
+ catch: (cause) => new StateError({ operation: "migrate", cause }),
153
+ })
154
+ } else {
155
+ const version = yield* Effect.tryPromise({
156
+ try: () => readVersion(sql),
157
+ catch: (cause) => new StateError({ operation: "open", cause }),
158
+ })
159
+ if (version !== STATE_VERSION) {
160
+ return yield* new StateSchemaError({ found: version, wanted: STATE_VERSION })
161
+ }
162
+ }
163
+
164
+ const attempt = <A>(operation: string, body: () => Promise<A>) =>
165
+ Effect.tryPromise({
166
+ try: body,
167
+ catch: (cause) => new StateError({ operation, cause }),
168
+ })
169
+
170
+ const isoNow = Clock.currentTimeMillis.pipe(
171
+ Effect.map((ms) => new Date(ms).toISOString()),
172
+ )
173
+
174
+ const service: StateStoreShape = {
175
+ upsertSnapshot: (snapshot) =>
176
+ isoNow.pipe(
177
+ Effect.flatMap((now) =>
178
+ attempt("upsertSnapshot", async () => {
179
+ await sql.unsafe(
180
+ // воскрешение снимает сиротство и освежает created_at — как в sqlite (гонка F6)
181
+ `INSERT INTO efmesh_state.snapshots
182
+ (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
+ [
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
+
201
+ getSnapshot: (name, fingerprint) =>
202
+ attempt("getSnapshot", async () => {
203
+ const rows = (await sql.unsafe(
204
+ `SELECT ${SNAPSHOT_COLUMNS} FROM efmesh_state.snapshots
205
+ WHERE name = $1 AND fingerprint = $2`,
206
+ [name, fingerprint],
207
+ )) as ReadonlyArray<SnapshotRecord>
208
+ return rows[0]
209
+ }),
210
+
211
+ listReferencedFingerprints: () =>
212
+ attempt("listReferencedFingerprints", async () => {
213
+ const rows = (await sql.unsafe(
214
+ `SELECT DISTINCT fingerprint FROM efmesh_state.environments`,
215
+ )) as ReadonlyArray<{ fingerprint: string }>
216
+ return new Set(rows.map((row) => row.fingerprint))
217
+ }),
218
+
219
+ listSnapshots: () =>
220
+ attempt("listSnapshots", async () => {
221
+ return (await sql.unsafe(
222
+ `SELECT ${SNAPSHOT_COLUMNS} FROM efmesh_state.snapshots ORDER BY name, created_at`,
223
+ )) as ReadonlyArray<SnapshotRecord>
224
+ }),
225
+
226
+ deleteSnapshot: (name, fingerprint) =>
227
+ attempt("deleteSnapshot", () =>
228
+ sql.begin(async (tx) => {
229
+ await tx.unsafe(
230
+ `DELETE FROM efmesh_state.snapshots WHERE name = $1 AND fingerprint = $2`,
231
+ [name, fingerprint],
232
+ )
233
+ await tx.unsafe(`DELETE FROM efmesh_state.intervals WHERE snapshot_fp = $1`, [
234
+ fingerprint,
235
+ ])
236
+ }),
237
+ ).pipe(Effect.asVoid),
238
+
239
+ deleteSnapshotIfDoomed: (name, fingerprint, deadline) =>
240
+ attempt("deleteSnapshotIfDoomed", () =>
241
+ sql.begin(async (tx) => {
242
+ const deleted = (await tx.unsafe(
243
+ `DELETE FROM efmesh_state.snapshots
244
+ WHERE name = $1 AND fingerprint = $2
245
+ AND COALESCE(orphaned_at, created_at) <= $3
246
+ AND NOT EXISTS (
247
+ SELECT 1 FROM efmesh_state.environments e WHERE e.fingerprint = $2
248
+ )
249
+ RETURNING 1`,
250
+ [name, fingerprint, deadline],
251
+ )) as ReadonlyArray<unknown>
252
+ if (deleted.length === 0) return false
253
+ await tx.unsafe(`DELETE FROM efmesh_state.intervals WHERE snapshot_fp = $1`, [
254
+ fingerprint,
255
+ ])
256
+ return true
257
+ }) as Promise<boolean>,
258
+ ),
259
+
260
+ getEnvironment: (env) =>
261
+ attempt("getEnvironment", async () => {
262
+ return (await sql.unsafe(
263
+ `SELECT env, name, fingerprint, promoted_at AS "promotedAt"
264
+ FROM efmesh_state.environments WHERE env = $1 ORDER BY name`,
265
+ [env],
266
+ )) as ReadonlyArray<EnvironmentRecord>
267
+ }),
268
+
269
+ promote: (env, entries) =>
270
+ isoNow.pipe(
271
+ Effect.flatMap((now) =>
272
+ attempt("promote", () =>
273
+ sql.begin(async (tx) => {
274
+ // живость снапшотов — в той же транзакции (гонка F6):
275
+ // janitor унёс версию → громкая ошибка, view не трогается
276
+ for (const entry of entries) {
277
+ if (entry.requireSnapshot !== true) continue
278
+ const alive = (await tx.unsafe(
279
+ `SELECT 1 FROM efmesh_state.snapshots WHERE name = $1 AND fingerprint = $2`,
280
+ [entry.name, entry.fingerprint],
281
+ )) as ReadonlyArray<unknown>
282
+ if (alive.length === 0) {
283
+ throw new Error(
284
+ `промоушен ${env}: снапшот ${entry.name}@${entry.fingerprint.slice(0, 8)} исчез из стора (снесён janitor?) — повторите apply`,
285
+ )
286
+ }
287
+ }
288
+ await tx.unsafe(`DELETE FROM efmesh_state.environments WHERE env = $1`, [env])
289
+ for (const entry of entries) {
290
+ await tx.unsafe(
291
+ `INSERT INTO efmesh_state.environments (env, name, fingerprint, promoted_at)
292
+ VALUES ($1, $2, $3, $4)`,
293
+ [env, entry.name, entry.fingerprint, now],
294
+ )
295
+ }
296
+ // учёт сиротства — как в sqlite-реализации (SPEC §5.4)
297
+ await tx.unsafe(
298
+ `UPDATE efmesh_state.snapshots SET orphaned_at = $1
299
+ WHERE orphaned_at IS NULL
300
+ AND fingerprint NOT IN (SELECT fingerprint FROM efmesh_state.environments)`,
301
+ [now],
302
+ )
303
+ await tx.unsafe(
304
+ `UPDATE efmesh_state.snapshots SET orphaned_at = NULL
305
+ WHERE orphaned_at IS NOT NULL
306
+ AND fingerprint IN (SELECT fingerprint FROM efmesh_state.environments)`,
307
+ )
308
+ }),
309
+ ).pipe(Effect.asVoid),
310
+ ),
311
+ ),
312
+
313
+ recordPlan: (env, summary, appliedBy) =>
314
+ isoNow.pipe(
315
+ Effect.flatMap((now) =>
316
+ attempt("recordPlan", async () => {
317
+ await sql.unsafe(
318
+ `INSERT INTO efmesh_state.plans (env, summary, applied_at, applied_by) VALUES ($1, $2, $3, $4)`,
319
+ [env, summary, now, appliedBy],
320
+ )
321
+ }),
322
+ ),
323
+ ),
324
+
325
+ listPlans: (env) =>
326
+ attempt("listPlans", async () => {
327
+ return (await sql.unsafe(
328
+ `SELECT id, env, summary, applied_at AS "appliedAt", applied_by AS "appliedBy"
329
+ FROM efmesh_state.plans WHERE env = $1 ORDER BY id`,
330
+ [env],
331
+ )) as ReadonlyArray<PlanRecord>
332
+ }),
333
+
334
+ acquireLock: (name, ttlMs) =>
335
+ Clock.currentTimeMillis.pipe(
336
+ Effect.flatMap((nowMs) =>
337
+ attempt("acquireLock", () =>
338
+ sql.begin(async (tx) => {
339
+ const now = new Date(nowMs).toISOString()
340
+ const expires = new Date(nowMs + ttlMs).toISOString()
341
+ // протухший лок упавшего процесса перехватывается;
342
+ // <= — лок, истёкший в момент T, свободен с T
343
+ await tx.unsafe(
344
+ `DELETE FROM efmesh_state.locks WHERE name = $1 AND expires_at <= $2`,
345
+ [name, now],
346
+ )
347
+ const rows = (await tx.unsafe(
348
+ `INSERT INTO efmesh_state.locks (name, acquired_at, expires_at)
349
+ VALUES ($1, $2, $3)
350
+ ON CONFLICT (name) DO NOTHING
351
+ RETURNING name`,
352
+ [name, now, expires],
353
+ )) as ReadonlyArray<unknown>
354
+ return rows.length > 0
355
+ }),
356
+ ),
357
+ ),
358
+ ),
359
+
360
+ releaseLock: (name) =>
361
+ attempt("releaseLock", async () => {
362
+ await sql.unsafe(`DELETE FROM efmesh_state.locks WHERE name = $1`, [name])
363
+ }),
364
+
365
+ markIntervals: (snapshotFp, intervals, status) =>
366
+ isoNow.pipe(
367
+ Effect.flatMap((now) =>
368
+ attempt("markIntervals", () =>
369
+ sql.begin(async (tx) => {
370
+ for (const interval of intervals) {
371
+ await tx.unsafe(
372
+ `INSERT INTO efmesh_state.intervals (snapshot_fp, start_ts, end_ts, status, updated_at)
373
+ VALUES ($1, $2, $3, $4, $5)
374
+ ON CONFLICT (snapshot_fp, start_ts)
375
+ DO UPDATE SET end_ts = $3, status = $4, updated_at = $5`,
376
+ [snapshotFp, interval.startTs, interval.endTs, status, now],
377
+ )
378
+ }
379
+ }),
380
+ ).pipe(Effect.asVoid),
381
+ ),
382
+ ),
383
+
384
+ listIntervals: (snapshotFp) =>
385
+ attempt("listIntervals", async () => {
386
+ return (await sql.unsafe(
387
+ `SELECT snapshot_fp AS "snapshotFp", start_ts AS "startTs", end_ts AS "endTs",
388
+ status, updated_at AS "updatedAt"
389
+ FROM efmesh_state.intervals WHERE snapshot_fp = $1 ORDER BY start_ts`,
390
+ [snapshotFp],
391
+ )) as ReadonlyArray<IntervalRecord>
392
+ }),
393
+ }
394
+ return service
395
+ }),
396
+ )