@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,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
|
+
)
|