@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,760 @@
|
|
|
1
|
+
import { mkdirSync, renameSync } from "node:fs"
|
|
2
|
+
import { userInfo } from "node:os"
|
|
3
|
+
import { Clock, Data, Deferred, Effect, Schedule } from "effect"
|
|
4
|
+
import { AuditFailure } from "../core/audit.ts"
|
|
5
|
+
import type { SeedReadError } from "../core/errors.ts"
|
|
6
|
+
import type { GraphError, ModelGraph } from "../core/graph.ts"
|
|
7
|
+
import type { Interval } from "../core/interval.ts"
|
|
8
|
+
import { intervalsWithin, splitIntoBatches, sqlTimestamp, toIso } from "../core/interval.ts"
|
|
9
|
+
import { columnNames, type AnyModel } from "../core/model.ts"
|
|
10
|
+
import { quoteIdent, render } from "../core/sql.ts"
|
|
11
|
+
import { EngineAdapter } from "../engine/adapter.ts"
|
|
12
|
+
import type { Engine, EngineError, SqlParseError } from "../engine/adapter.ts"
|
|
13
|
+
import { StateStore } from "../state/store.ts"
|
|
14
|
+
import type { StateError, StateStoreShape } from "../state/store.ts"
|
|
15
|
+
import { Metric } from "effect"
|
|
16
|
+
import { checkContract, SchemaMismatchError } from "./contract.ts"
|
|
17
|
+
import { FINGERPRINT_VERSION } from "./fingerprint.ts"
|
|
18
|
+
import { auditFailuresTotal, intervalsDone, snapshotsBuilt } from "./metrics.ts"
|
|
19
|
+
import {
|
|
20
|
+
ducklakeAttachSql,
|
|
21
|
+
ducklakeRef,
|
|
22
|
+
envSchema,
|
|
23
|
+
externalSourceRef,
|
|
24
|
+
intervalKey,
|
|
25
|
+
parquetPrefix,
|
|
26
|
+
parquetRef,
|
|
27
|
+
physicalRef,
|
|
28
|
+
physicalSchema,
|
|
29
|
+
physicalTable,
|
|
30
|
+
viewRef,
|
|
31
|
+
} from "./naming.ts"
|
|
32
|
+
import type {
|
|
33
|
+
FingerprintVersionError,
|
|
34
|
+
ForwardOnlyError,
|
|
35
|
+
InvalidEnvironmentError,
|
|
36
|
+
Plan,
|
|
37
|
+
PlanAction,
|
|
38
|
+
} from "./planner.ts"
|
|
39
|
+
|
|
40
|
+
export interface AppliedPlan {
|
|
41
|
+
readonly plan: Plan
|
|
42
|
+
/** Имена моделей, для которых собиралась физика или гнался бэкфилл. */
|
|
43
|
+
readonly built: ReadonlyArray<string>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** В проекте есть parquet-модели, но путь озера не задан в конфиге. */
|
|
47
|
+
export class LakeNotConfiguredError extends Data.TaggedError("LakeNotConfiguredError")<{
|
|
48
|
+
readonly model: string
|
|
49
|
+
}> {}
|
|
50
|
+
|
|
51
|
+
/** В проекте есть ducklake-модели, но каталог не задан в конфиге. */
|
|
52
|
+
export class DucklakeNotConfiguredError extends Data.TaggedError("DucklakeNotConfiguredError")<{
|
|
53
|
+
readonly model: string
|
|
54
|
+
}> {}
|
|
55
|
+
|
|
56
|
+
export type ApplyError =
|
|
57
|
+
| GraphError
|
|
58
|
+
| StateError
|
|
59
|
+
| EngineError
|
|
60
|
+
| SqlParseError
|
|
61
|
+
| SeedReadError
|
|
62
|
+
| SchemaMismatchError
|
|
63
|
+
| LakeNotConfiguredError
|
|
64
|
+
| DucklakeNotConfiguredError
|
|
65
|
+
| AttachNotConfiguredError
|
|
66
|
+
| AuditFailure
|
|
67
|
+
| InvalidEnvironmentError
|
|
68
|
+
| ForwardOnlyError
|
|
69
|
+
| FingerprintVersionError
|
|
70
|
+
| EngineFeatureError
|
|
71
|
+
|
|
72
|
+
export interface ApplyOptions {
|
|
73
|
+
/** «Сейчас» для scdType2-версионирования; по умолчанию — Clock. Инъекция для тестов. */
|
|
74
|
+
readonly now?: number
|
|
75
|
+
/** Корень parquet-озера — локальная директория или s3://… (httpfs). */
|
|
76
|
+
readonly lakePath?: string
|
|
77
|
+
/** ATTACH-базы по алиасам (SPEC §9.3) — для export-моделей. */
|
|
78
|
+
readonly attach?: Readonly<Record<string, { readonly url: string; readonly options?: string }>>
|
|
79
|
+
/** DuckLake-каталог для target: "ducklake" (SPEC §14.5). DuckDB-only. */
|
|
80
|
+
readonly ducklake?: { readonly catalog: string; readonly dataPath?: string }
|
|
81
|
+
/**
|
|
82
|
+
* Сколько батчей бэкфилла одной модели считать одновременно (SPEC §5.3).
|
|
83
|
+
* Осмыслен только на движке с пулом соединений (Postgres); DuckDB держит
|
|
84
|
+
* одно соединение — там бэкфилл последовательный независимо от значения.
|
|
85
|
+
*/
|
|
86
|
+
readonly concurrency?: number
|
|
87
|
+
/**
|
|
88
|
+
* Межмодельная DAG-конкурентность (SPEC §5.3): сколько моделей строить
|
|
89
|
+
* одновременно. Модель стартует, как только готовы её родители из этого
|
|
90
|
+
* плана — независимые ветки DAG идут параллельно. Осмыслен на движке
|
|
91
|
+
* с пулом (Postgres); DuckDB держит одно соединение — там модели строятся
|
|
92
|
+
* последовательно, иначе чужие стейтменты вклинивались бы в BEGIN/COMMIT.
|
|
93
|
+
*/
|
|
94
|
+
readonly modelConcurrency?: number
|
|
95
|
+
/**
|
|
96
|
+
* Ретраи упавшего батча бэкфилла (SPEC §5.3): Schedule.exponential от
|
|
97
|
+
* baseDelayMs (по умолчанию 500 мс), не больше attempts повторов. Батч
|
|
98
|
+
* транзакционен (DELETE+INSERT в одной транзакции, COPY перезаписывает
|
|
99
|
+
* партицию целиком) — повтор безопасен. Аудиты не ретраятся: провал
|
|
100
|
+
* аудита детерминирован, это не транзиентный сбой.
|
|
101
|
+
*/
|
|
102
|
+
readonly retry?: { readonly attempts: number; readonly baseDelayMs?: number }
|
|
103
|
+
/** Кто применяет план — в журнал (SPEC §6); по умолчанию ОС-пользователь. */
|
|
104
|
+
readonly appliedBy?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** ОС-пользователь для журнала планов; в стерильных окружениях (CI) — ''. */
|
|
108
|
+
const osUser = (): string => {
|
|
109
|
+
try {
|
|
110
|
+
return userInfo().username
|
|
111
|
+
} catch {
|
|
112
|
+
return process.env["USER"] ?? ""
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Модель просит экспорт в ATTACH-алиас, которого нет в конфиге. */
|
|
117
|
+
export class AttachNotConfiguredError extends Data.TaggedError("AttachNotConfiguredError")<{
|
|
118
|
+
readonly model: string
|
|
119
|
+
readonly attach: string
|
|
120
|
+
}> {}
|
|
121
|
+
|
|
122
|
+
/** Возможность DuckDB-федерации, недоступная на текущем движке (SPEC §9.3). */
|
|
123
|
+
export class EngineFeatureError extends Data.TaggedError("EngineFeatureError")<{
|
|
124
|
+
readonly model: string
|
|
125
|
+
readonly feature: string
|
|
126
|
+
readonly dialect: string
|
|
127
|
+
}> {}
|
|
128
|
+
|
|
129
|
+
/** Несколько стейтментов одной транзакцией движка; откат при любой ошибке. */
|
|
130
|
+
const transactional = (
|
|
131
|
+
engine: Engine,
|
|
132
|
+
statements: ReadonlyArray<string>,
|
|
133
|
+
): Effect.Effect<void, EngineError> => engine.transaction(statements)
|
|
134
|
+
|
|
135
|
+
/** Ретраи транзиентного сбоя записи батча; без retry в опциях — как было. */
|
|
136
|
+
const withBatchRetry =
|
|
137
|
+
(retry: ApplyOptions["retry"]) =>
|
|
138
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
139
|
+
retry === undefined || retry.attempts <= 0
|
|
140
|
+
? effect
|
|
141
|
+
: Effect.retry(effect, {
|
|
142
|
+
times: retry.attempts,
|
|
143
|
+
schedule: Schedule.exponential(retry.baseDelayMs ?? 500),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
/** Для s3://-путей mkdir не нужен (и невозможен) — httpfs пишет напрямую. */
|
|
147
|
+
const ensureDir = (path: string): Effect.Effect<void> =>
|
|
148
|
+
path.startsWith("s3://")
|
|
149
|
+
? Effect.void
|
|
150
|
+
: Effect.sync(() => mkdirSync(path, { recursive: true }))
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Прогон аудитов модели (SPEC §8): `self` — физика снапшота или подзапрос
|
|
154
|
+
* только что загруженного интервала. Запрос аудита возвращает нарушения:
|
|
155
|
+
* blocking → AuditFailure, warn → лог и дальше.
|
|
156
|
+
*/
|
|
157
|
+
const runAudits = (
|
|
158
|
+
engine: Engine,
|
|
159
|
+
model: AnyModel,
|
|
160
|
+
self: string,
|
|
161
|
+
): Effect.Effect<void, EngineError | AuditFailure> =>
|
|
162
|
+
Effect.gen(function* () {
|
|
163
|
+
for (const auditDef of model.audits) {
|
|
164
|
+
const violations = yield* engine.query(
|
|
165
|
+
render(auditDef.fragment, { resolveRef: (ref) => ref, self }),
|
|
166
|
+
)
|
|
167
|
+
if (violations.length === 0) continue
|
|
168
|
+
if (auditDef.blocking) {
|
|
169
|
+
yield* Metric.update(auditFailuresTotal, 1)
|
|
170
|
+
return yield* new AuditFailure({
|
|
171
|
+
model: model.name.full,
|
|
172
|
+
audit: auditDef.name,
|
|
173
|
+
violations: violations.length,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
yield* Effect.logWarning(
|
|
177
|
+
`аудит ${auditDef.name} модели ${model.name.full}: ${violations.length} нарушений (warn)`,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Эволюция унаследованной таблицы при forward-only (SPEC §5.2): колонки,
|
|
184
|
+
* появившиеся в новом запросе, добавляются через ALTER (история получает
|
|
185
|
+
* NULL — она не переигрывается), удаление колонок реюзом не выражается —
|
|
186
|
+
* это честный breaking с пересборкой.
|
|
187
|
+
*/
|
|
188
|
+
const evolveTableForForwardOnly = (
|
|
189
|
+
engine: Engine,
|
|
190
|
+
model: AnyModel,
|
|
191
|
+
target: string,
|
|
192
|
+
emptyBody: string,
|
|
193
|
+
): Effect.Effect<void, EngineError | SchemaMismatchError> =>
|
|
194
|
+
Effect.gen(function* () {
|
|
195
|
+
const wanted = yield* engine.describe(emptyBody)
|
|
196
|
+
const actual = yield* engine.describe(`SELECT * FROM ${target}`)
|
|
197
|
+
const have = new Set(actual.map((column) => column.name))
|
|
198
|
+
const wantedNames = new Set(wanted.map((column) => column.name))
|
|
199
|
+
const vanished = actual.filter((column) => !wantedNames.has(column.name))
|
|
200
|
+
if (vanished.length > 0) {
|
|
201
|
+
return yield* new SchemaMismatchError({
|
|
202
|
+
model: model.name.full,
|
|
203
|
+
problems: vanished.map(
|
|
204
|
+
(column) =>
|
|
205
|
+
`forward-only: колонка «${column.name}» есть в унаследованной физике, но исчезла из запроса — удаление колонок требует обычного (breaking) применения`,
|
|
206
|
+
),
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
for (const column of wanted) {
|
|
210
|
+
if (have.has(column.name)) continue
|
|
211
|
+
yield* engine.execute(
|
|
212
|
+
`ALTER TABLE ${target} ADD COLUMN ${quoteIdent(column.name)} ${column.type}`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Бэкфилл incrementalByTimeRange в таблицу (SPEC §5.3): каждый диапазон
|
|
219
|
+
* плана режется на батчи ≤ batchSize; батч — транзакция DELETE-диапазона +
|
|
220
|
+
* INSERT, после успеха его интервалы помечаются done. Упавший батч
|
|
221
|
+
* помечается failed и прерывает apply; уже отмеченное не пересчитывается
|
|
222
|
+
* при повторе — бэкфилл продолжается с места остановки.
|
|
223
|
+
*/
|
|
224
|
+
const backfillIntoTable = (
|
|
225
|
+
engine: Engine,
|
|
226
|
+
store: StateStoreShape,
|
|
227
|
+
model: AnyModel,
|
|
228
|
+
action: PlanAction,
|
|
229
|
+
target: string,
|
|
230
|
+
resolveRef: (ref: string) => string,
|
|
231
|
+
concurrency: number,
|
|
232
|
+
retry: ApplyOptions["retry"],
|
|
233
|
+
): Effect.Effect<void, EngineError | StateError | AuditFailure> =>
|
|
234
|
+
Effect.gen(function* () {
|
|
235
|
+
if (model.kind._tag !== "incrementalByTimeRange") return
|
|
236
|
+
const kind = model.kind
|
|
237
|
+
// вставка по именам: после forward-only-эволюции порядок колонок физики
|
|
238
|
+
// может отличаться от запроса; контракт схемы гарантирует совпадение имён
|
|
239
|
+
const columns = columnNames(model).map(quoteIdent).join(", ")
|
|
240
|
+
|
|
241
|
+
const runBatch = (batch: Interval) =>
|
|
242
|
+
Effect.gen(function* () {
|
|
243
|
+
const marks = intervalsWithin(batch, kind.interval).map((interval: Interval) => ({
|
|
244
|
+
startTs: toIso(interval.start),
|
|
245
|
+
endTs: toIso(interval.end),
|
|
246
|
+
}))
|
|
247
|
+
const start = sqlTimestamp(batch.start)
|
|
248
|
+
const end = sqlTimestamp(batch.end)
|
|
249
|
+
const body = render(model.fragment, { resolveRef, interval: { start, end } })
|
|
250
|
+
const markFailed = () =>
|
|
251
|
+
store.markIntervals(action.fingerprint, marks, "failed").pipe(Effect.ignore)
|
|
252
|
+
yield* transactional(engine, [
|
|
253
|
+
`DELETE FROM ${target} WHERE ${quoteIdent(kind.timeColumn)} >= ${start} AND ${quoteIdent(kind.timeColumn)} < ${end}`,
|
|
254
|
+
`INSERT INTO ${target} (${columns}) SELECT ${columns} FROM (${body}) q`,
|
|
255
|
+
]).pipe(withBatchRetry(retry), Effect.tapError(markFailed))
|
|
256
|
+
// аудит свежезагруженного интервала — до отметки done (SPEC §8)
|
|
257
|
+
yield* runAudits(
|
|
258
|
+
engine,
|
|
259
|
+
model,
|
|
260
|
+
`(SELECT * FROM ${target} WHERE ${quoteIdent(kind.timeColumn)} >= ${start} AND ${quoteIdent(kind.timeColumn)} < ${end})`,
|
|
261
|
+
).pipe(Effect.tapError(markFailed))
|
|
262
|
+
yield* store.markIntervals(action.fingerprint, marks, "done")
|
|
263
|
+
yield* Metric.update(intervalsDone, marks.length)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// пул соединений (Postgres) → батчи параллельно: интервалы не пересекаются,
|
|
267
|
+
// каждый — своя транзакция; DuckDB (одно соединение) — последовательно
|
|
268
|
+
const batches = action.backfill.flatMap((range) =>
|
|
269
|
+
splitIntoBatches(range, kind.interval, kind.batchSize),
|
|
270
|
+
)
|
|
271
|
+
yield* Effect.forEach(batches, runBatch, {
|
|
272
|
+
concurrency: engine.dialect === "duckdb" ? 1 : Math.max(1, concurrency),
|
|
273
|
+
discard: true,
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Бэкфилл в parquet-озеро (SPEC §3.3): интервал = партиция, пересчёт —
|
|
279
|
+
* перезапись файла партиции. Транзакция не нужна: недописанная партиция
|
|
280
|
+
* не помечена done и будет перезаписана при повторе.
|
|
281
|
+
*/
|
|
282
|
+
const backfillIntoParquet = (
|
|
283
|
+
engine: Engine,
|
|
284
|
+
store: StateStoreShape,
|
|
285
|
+
model: AnyModel,
|
|
286
|
+
action: PlanAction,
|
|
287
|
+
prefix: string,
|
|
288
|
+
resolveRef: (ref: string) => string,
|
|
289
|
+
retry: ApplyOptions["retry"],
|
|
290
|
+
): Effect.Effect<void, EngineError | StateError | AuditFailure> =>
|
|
291
|
+
Effect.gen(function* () {
|
|
292
|
+
if (model.kind._tag !== "incrementalByTimeRange") return
|
|
293
|
+
const kind = model.kind
|
|
294
|
+
for (const range of action.backfill) {
|
|
295
|
+
for (const interval of intervalsWithin(range, kind.interval)) {
|
|
296
|
+
const partition = `${prefix}/interval=${intervalKey(kind.interval, interval.start)}`
|
|
297
|
+
yield* ensureDir(partition)
|
|
298
|
+
const body = render(model.fragment, {
|
|
299
|
+
resolveRef,
|
|
300
|
+
interval: { start: sqlTimestamp(interval.start), end: sqlTimestamp(interval.end) },
|
|
301
|
+
})
|
|
302
|
+
const mark = [{ startTs: toIso(interval.start), endTs: toIso(interval.end) }]
|
|
303
|
+
const markFailed = () =>
|
|
304
|
+
store.markIntervals(action.fingerprint, mark, "failed").pipe(Effect.ignore)
|
|
305
|
+
// локально COPY пишет во временный файл, rename атомарен (POSIX):
|
|
306
|
+
// kill посреди записи не оставит битую партицию, а lookback-пересчёт
|
|
307
|
+
// не подсунет читателю view недописанный файл; s3 — прямая запись
|
|
308
|
+
// (rename нет; недописанный ключ не помечен done и перезапишется)
|
|
309
|
+
const target = `${partition}/data.parquet`
|
|
310
|
+
const writePath = partition.startsWith("s3://") ? target : `${target}.tmp`
|
|
311
|
+
yield* engine
|
|
312
|
+
.execute(`COPY (${body}) TO '${writePath.replaceAll(`'`, `''`)}' (FORMAT PARQUET)`)
|
|
313
|
+
.pipe(withBatchRetry(retry), Effect.tapError(markFailed))
|
|
314
|
+
if (writePath !== target) {
|
|
315
|
+
yield* Effect.sync(() => renameSync(writePath, target))
|
|
316
|
+
}
|
|
317
|
+
const file = target.replaceAll(`'`, `''`)
|
|
318
|
+
// аудит записанной партиции — до отметки done; провал = не done → перезапись
|
|
319
|
+
yield* runAudits(engine, model, `(SELECT * FROM read_parquet('${file}'))`).pipe(
|
|
320
|
+
Effect.tapError(markFailed),
|
|
321
|
+
)
|
|
322
|
+
yield* store.markIntervals(action.fingerprint, mark, "done")
|
|
323
|
+
yield* Metric.update(intervalsDone, 1)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Применяет план (SPEC §5): в топологическом порядке собирает недостающую
|
|
330
|
+
* физику и догоняет интервалы (ссылки в SQL резолвятся в физические таблицы
|
|
331
|
+
* ЭТОГО плана, не во view окружения — середина apply не видна снаружи),
|
|
332
|
+
* затем промоушен — пересоздание view + транзакционная запись набора
|
|
333
|
+
* в state store.
|
|
334
|
+
*/
|
|
335
|
+
export const applyPlan = (
|
|
336
|
+
plan: Plan,
|
|
337
|
+
graph: ModelGraph,
|
|
338
|
+
options?: ApplyOptions,
|
|
339
|
+
): Effect.Effect<AppliedPlan, ApplyError, EngineAdapter | StateStore> =>
|
|
340
|
+
Effect.gen(function* () {
|
|
341
|
+
const engine = yield* EngineAdapter
|
|
342
|
+
const store = yield* StateStore
|
|
343
|
+
const lakePath = options?.lakePath
|
|
344
|
+
const nowMs = options?.now ?? (yield* Clock.currentTimeMillis)
|
|
345
|
+
|
|
346
|
+
// физика модели — то, что увидят её потребители и view окружения;
|
|
347
|
+
// ключ — physicalFingerprint: при forward-only это физика старой версии
|
|
348
|
+
const physicalFpOf = new Map(plan.actions.map((a) => [a.name, a.physicalFingerprint]))
|
|
349
|
+
const physicalFor = (model: AnyModel, fingerprint: string): string => {
|
|
350
|
+
if (model.kind._tag === "external") return externalSourceRef(model.kind.source)
|
|
351
|
+
// embedded не материализуется — подставляется потребителю подзапросом,
|
|
352
|
+
// его собственные ссылки резолвятся рекурсивно (DAG ацикличен)
|
|
353
|
+
if (model.kind._tag === "embedded") return `(${render(model.fragment, { resolveRef })})`
|
|
354
|
+
if (model.target === "parquet") {
|
|
355
|
+
if (lakePath === undefined) throw new LakeNotConfiguredError({ model: model.name.full })
|
|
356
|
+
return parquetRef(lakePath, model.name, fingerprint)
|
|
357
|
+
}
|
|
358
|
+
return tableRef(model, fingerprint)
|
|
359
|
+
}
|
|
360
|
+
// нативная физика или таблица DuckLake-каталога — по цели модели
|
|
361
|
+
const tableRef = (model: AnyModel, fingerprint: string): string =>
|
|
362
|
+
model.target === "ducklake"
|
|
363
|
+
? ducklakeRef(model.name, fingerprint)
|
|
364
|
+
: physicalRef(model.name, fingerprint)
|
|
365
|
+
const resolveRef = (ref: string): string => {
|
|
366
|
+
const model = graph.models.get(ref)
|
|
367
|
+
const fingerprint = physicalFpOf.get(ref)
|
|
368
|
+
if (model === undefined || fingerprint === undefined) {
|
|
369
|
+
throw new Error(`ссылка на модель вне плана: ${ref}`)
|
|
370
|
+
}
|
|
371
|
+
return physicalFor(model, fingerprint)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// parquet-модели без озера — падение до любых действий
|
|
375
|
+
for (const action of plan.actions) {
|
|
376
|
+
const model = graph.models.get(action.name)
|
|
377
|
+
if (model === undefined) continue
|
|
378
|
+
if (model.target === "parquet" && lakePath === undefined) {
|
|
379
|
+
return yield* new LakeNotConfiguredError({ model: model.name.full })
|
|
380
|
+
}
|
|
381
|
+
if (model.target === "ducklake" && options?.ducklake === undefined) {
|
|
382
|
+
return yield* new DucklakeNotConfiguredError({ model: model.name.full })
|
|
383
|
+
}
|
|
384
|
+
// DuckDB-федерация (SPEC §9.3) на других движках не выражается —
|
|
385
|
+
// честная ошибка до любых действий
|
|
386
|
+
if (engine.dialect !== "duckdb") {
|
|
387
|
+
const feature =
|
|
388
|
+
model.target === "parquet"
|
|
389
|
+
? "target: parquet"
|
|
390
|
+
: model.target === "ducklake"
|
|
391
|
+
? "target: ducklake"
|
|
392
|
+
: model.kind._tag === "seed"
|
|
393
|
+
? "seed (read_csv/read_json)"
|
|
394
|
+
: model.kind._tag === "external" && model.kind.source._tag === "files"
|
|
395
|
+
? "external по файлам/URL (read_*)"
|
|
396
|
+
: model.export !== undefined
|
|
397
|
+
? "export в ATTACH-базу"
|
|
398
|
+
: undefined
|
|
399
|
+
if (feature !== undefined) {
|
|
400
|
+
return yield* new EngineFeatureError({
|
|
401
|
+
model: model.name.full,
|
|
402
|
+
feature,
|
|
403
|
+
dialect: engine.dialect,
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 1. Физика + бэкфилл — DAG-конкурентно (SPEC §5.3): модель стартует,
|
|
410
|
+
// как только готовы её родители из этого плана (Deferred-гейты, без
|
|
411
|
+
// волновых барьеров); независимые ветки идут параллельно на движке
|
|
412
|
+
// с пулом; упавший родитель не открывает гейт — потомки не строятся
|
|
413
|
+
yield* engine.execute(`CREATE SCHEMA IF NOT EXISTS "${physicalSchema}"`)
|
|
414
|
+
// ducklake-каталог нужен и для сборки, и для чистого view-swap —
|
|
415
|
+
// view окружений ссылаются на таблицы каталога по алиасу
|
|
416
|
+
if (options?.ducklake !== undefined && engine.dialect === "duckdb") {
|
|
417
|
+
yield* engine.execute(ducklakeAttachSql(options.ducklake))
|
|
418
|
+
}
|
|
419
|
+
const working = plan.actions.filter(
|
|
420
|
+
(action) =>
|
|
421
|
+
(action.build || action.backfill.length > 0 || action.refresh) &&
|
|
422
|
+
graph.models.get(action.name)?.kind._tag !== "external",
|
|
423
|
+
)
|
|
424
|
+
const gates = new Map<string, Deferred.Deferred<void>>()
|
|
425
|
+
for (const action of working) gates.set(action.name, yield* Deferred.make<void>())
|
|
426
|
+
|
|
427
|
+
const buildOne = (action: PlanAction): Effect.Effect<void, ApplyError> =>
|
|
428
|
+
Effect.gen(function* () {
|
|
429
|
+
const model = graph.models.get(action.name)!
|
|
430
|
+
switch (model.kind._tag) {
|
|
431
|
+
case "external":
|
|
432
|
+
return
|
|
433
|
+
case "embedded": {
|
|
434
|
+
// физики нет — но контракт и аудиты версии проверяются здесь,
|
|
435
|
+
// чтобы потребители не унесли в себя сломанный подзапрос
|
|
436
|
+
const body = render(model.fragment, { resolveRef })
|
|
437
|
+
yield* checkContract(engine, model, body)
|
|
438
|
+
yield* runAudits(engine, model, `(${body})`)
|
|
439
|
+
yield* store.upsertSnapshot({
|
|
440
|
+
name: action.name,
|
|
441
|
+
fingerprint: action.fingerprint,
|
|
442
|
+
physicalFp: action.physicalFingerprint,
|
|
443
|
+
canonicalAst: action.canonicalAst ?? "",
|
|
444
|
+
renderedSql: render(model.fragment, { resolveRef: (ref) => ref }),
|
|
445
|
+
kind: model.kind._tag,
|
|
446
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
447
|
+
})
|
|
448
|
+
break
|
|
449
|
+
}
|
|
450
|
+
case "seed": {
|
|
451
|
+
const reader = model.kind.format === "csv" ? "read_csv" : "read_json"
|
|
452
|
+
const body = `SELECT * FROM ${reader}('${model.kind.file.replaceAll(`'`, `''`)}')`
|
|
453
|
+
yield* checkContract(engine, model, body)
|
|
454
|
+
const target = tableRef(model, action.physicalFingerprint)
|
|
455
|
+
yield* engine.execute(`CREATE OR REPLACE TABLE ${target} AS ${body}`)
|
|
456
|
+
yield* runAudits(engine, model, target)
|
|
457
|
+
yield* store.upsertSnapshot({
|
|
458
|
+
name: action.name,
|
|
459
|
+
fingerprint: action.fingerprint,
|
|
460
|
+
physicalFp: action.physicalFingerprint,
|
|
461
|
+
canonicalAst: "",
|
|
462
|
+
renderedSql: body,
|
|
463
|
+
kind: model.kind._tag,
|
|
464
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
465
|
+
})
|
|
466
|
+
break
|
|
467
|
+
}
|
|
468
|
+
case "incrementalByUniqueKey": {
|
|
469
|
+
const body = render(model.fragment, { resolveRef })
|
|
470
|
+
yield* checkContract(engine, model, body)
|
|
471
|
+
const target = tableRef(model, action.physicalFingerprint)
|
|
472
|
+
yield* engine.execute(
|
|
473
|
+
`CREATE TABLE IF NOT EXISTS ${target} AS SELECT * FROM (${body}) q LIMIT 0`,
|
|
474
|
+
)
|
|
475
|
+
// upsert: строки с ключами из свежего запроса заменяются, остальные живут
|
|
476
|
+
const keys = model.kind.key.map(quoteIdent).join(", ")
|
|
477
|
+
yield* transactional(engine, [
|
|
478
|
+
`DELETE FROM ${target} WHERE (${keys}) IN (SELECT ${keys} FROM (${body}) q)`,
|
|
479
|
+
`INSERT INTO ${target} ${body}`,
|
|
480
|
+
])
|
|
481
|
+
yield* runAudits(engine, model, target)
|
|
482
|
+
yield* store.upsertSnapshot({
|
|
483
|
+
name: action.name,
|
|
484
|
+
fingerprint: action.fingerprint,
|
|
485
|
+
physicalFp: action.physicalFingerprint,
|
|
486
|
+
canonicalAst: action.canonicalAst ?? "",
|
|
487
|
+
renderedSql: render(model.fragment, { resolveRef: (ref) => ref }),
|
|
488
|
+
kind: model.kind._tag,
|
|
489
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
490
|
+
})
|
|
491
|
+
break
|
|
492
|
+
}
|
|
493
|
+
case "scdType2": {
|
|
494
|
+
const scd = model.kind
|
|
495
|
+
const body = render(model.fragment, { resolveRef })
|
|
496
|
+
const managed = new Set([scd.validFrom, scd.validTo])
|
|
497
|
+
yield* checkContract(engine, model, body, managed)
|
|
498
|
+
const target = tableRef(model, action.physicalFingerprint)
|
|
499
|
+
const from = quoteIdent(scd.validFrom)
|
|
500
|
+
const to = quoteIdent(scd.validTo)
|
|
501
|
+
yield* engine.execute(
|
|
502
|
+
`CREATE TABLE IF NOT EXISTS ${target} AS
|
|
503
|
+
SELECT q.*, CAST(NULL AS TIMESTAMP) AS ${from}, CAST(NULL AS TIMESTAMP) AS ${to}
|
|
504
|
+
FROM (${body}) q LIMIT 0`,
|
|
505
|
+
)
|
|
506
|
+
const cols = columnNames(model)
|
|
507
|
+
.filter((column) => !managed.has(column))
|
|
508
|
+
.map(quoteIdent)
|
|
509
|
+
const tableName = quoteIdent(physicalTable(model.name, action.physicalFingerprint))
|
|
510
|
+
const ts = sqlTimestamp(nowMs)
|
|
511
|
+
const sameAsOuter = cols
|
|
512
|
+
.map((column) => `q.${column} IS NOT DISTINCT FROM ${tableName}.${column}`)
|
|
513
|
+
.join(" AND ")
|
|
514
|
+
const sameAsOpen = cols
|
|
515
|
+
.map((column) => `t.${column} IS NOT DISTINCT FROM q.${column}`)
|
|
516
|
+
.join(" AND ")
|
|
517
|
+
// SCD2-сверка (SPEC §3.1): открытая строка без идентичной строки в
|
|
518
|
+
// запросе закрывается (изменилась или исчезла); строка запроса без
|
|
519
|
+
// идентичной открытой строки вставляется новой открытой версией.
|
|
520
|
+
// Идентичные пары не трогаются — их valid_from не дрожит.
|
|
521
|
+
yield* transactional(engine, [
|
|
522
|
+
`UPDATE ${target} SET ${to} = ${ts}
|
|
523
|
+
WHERE ${to} IS NULL
|
|
524
|
+
AND NOT EXISTS (SELECT 1 FROM (${body}) q WHERE ${sameAsOuter})`,
|
|
525
|
+
`INSERT INTO ${target} (${cols.join(", ")}, ${from}, ${to})
|
|
526
|
+
SELECT ${cols.map((column) => `q.${column}`).join(", ")}, ${ts}, NULL
|
|
527
|
+
FROM (${body}) q
|
|
528
|
+
WHERE NOT EXISTS (
|
|
529
|
+
SELECT 1 FROM ${target} t WHERE t.${to} IS NULL AND ${sameAsOpen}
|
|
530
|
+
)`,
|
|
531
|
+
])
|
|
532
|
+
yield* runAudits(engine, model, target)
|
|
533
|
+
yield* store.upsertSnapshot({
|
|
534
|
+
name: action.name,
|
|
535
|
+
fingerprint: action.fingerprint,
|
|
536
|
+
physicalFp: action.physicalFingerprint,
|
|
537
|
+
canonicalAst: action.canonicalAst ?? "",
|
|
538
|
+
renderedSql: render(model.fragment, { resolveRef: (ref) => ref }),
|
|
539
|
+
kind: model.kind._tag,
|
|
540
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
541
|
+
})
|
|
542
|
+
break
|
|
543
|
+
}
|
|
544
|
+
case "view":
|
|
545
|
+
case "full": {
|
|
546
|
+
const body = render(model.fragment, { resolveRef })
|
|
547
|
+
// контракт схемы (SPEC §3.2): дрейф типов ловится до сборки
|
|
548
|
+
yield* checkContract(engine, model, body)
|
|
549
|
+
if (model.kind._tag === "full" && model.target === "parquet") {
|
|
550
|
+
const prefix = parquetPrefix(lakePath!, model.name, action.physicalFingerprint)
|
|
551
|
+
yield* ensureDir(prefix)
|
|
552
|
+
yield* engine.execute(
|
|
553
|
+
`COPY (${body}) TO '${prefix.replaceAll(`'`, `''`)}/data.parquet' (FORMAT PARQUET)`,
|
|
554
|
+
)
|
|
555
|
+
} else {
|
|
556
|
+
const target = tableRef(model, action.physicalFingerprint)
|
|
557
|
+
if (model.kind._tag === "view") {
|
|
558
|
+
yield* engine.execute(`CREATE OR REPLACE VIEW ${target} AS ${body}`)
|
|
559
|
+
} else if (engine.dialect === "duckdb") {
|
|
560
|
+
yield* engine.execute(`CREATE OR REPLACE TABLE ${target} AS ${body}`)
|
|
561
|
+
} else {
|
|
562
|
+
// у Postgres нет CREATE OR REPLACE TABLE — атомарно через транзакцию
|
|
563
|
+
yield* transactional(engine, [
|
|
564
|
+
`DROP TABLE IF EXISTS ${target}`,
|
|
565
|
+
`CREATE TABLE ${target} AS ${body}`,
|
|
566
|
+
])
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// аудиты собранного снапшота — до промоушена (SPEC §8)
|
|
570
|
+
yield* runAudits(engine, model, physicalFor(model, action.physicalFingerprint))
|
|
571
|
+
yield* store.upsertSnapshot({
|
|
572
|
+
name: action.name,
|
|
573
|
+
fingerprint: action.fingerprint,
|
|
574
|
+
physicalFp: action.physicalFingerprint,
|
|
575
|
+
canonicalAst: action.canonicalAst ?? "",
|
|
576
|
+
renderedSql: body,
|
|
577
|
+
kind: model.kind._tag,
|
|
578
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
579
|
+
})
|
|
580
|
+
break
|
|
581
|
+
}
|
|
582
|
+
case "incrementalByTimeRange": {
|
|
583
|
+
const zero = sqlTimestamp(0)
|
|
584
|
+
const emptyBody = render(model.fragment, {
|
|
585
|
+
resolveRef,
|
|
586
|
+
interval: { start: zero, end: zero },
|
|
587
|
+
})
|
|
588
|
+
yield* checkContract(engine, model, emptyBody)
|
|
589
|
+
// forward-only: done-интервалы старой версии наследуются до бэкфилла —
|
|
590
|
+
// сделанное не переигрывается, пересчитывается только недостающее
|
|
591
|
+
if (action.reusedFrom !== undefined) {
|
|
592
|
+
const inherited = (yield* store.listIntervals(action.reusedFrom))
|
|
593
|
+
.filter((record) => record.status === "done")
|
|
594
|
+
.map((record) => ({ startTs: record.startTs, endTs: record.endTs }))
|
|
595
|
+
yield* store.markIntervals(action.fingerprint, inherited, "done")
|
|
596
|
+
}
|
|
597
|
+
if (model.target === "parquet") {
|
|
598
|
+
const prefix = parquetPrefix(lakePath!, model.name, action.physicalFingerprint)
|
|
599
|
+
yield* store.upsertSnapshot({
|
|
600
|
+
name: action.name,
|
|
601
|
+
fingerprint: action.fingerprint,
|
|
602
|
+
physicalFp: action.physicalFingerprint,
|
|
603
|
+
canonicalAst: action.canonicalAst ?? "",
|
|
604
|
+
renderedSql: render(model.fragment, { resolveRef: (ref) => ref }),
|
|
605
|
+
kind: model.kind._tag,
|
|
606
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
607
|
+
})
|
|
608
|
+
yield* backfillIntoParquet(
|
|
609
|
+
engine,
|
|
610
|
+
store,
|
|
611
|
+
model,
|
|
612
|
+
action,
|
|
613
|
+
prefix,
|
|
614
|
+
resolveRef,
|
|
615
|
+
options?.retry,
|
|
616
|
+
)
|
|
617
|
+
} else {
|
|
618
|
+
// пустой скелет с формой запроса; при resume уже существует
|
|
619
|
+
const target = tableRef(model, action.physicalFingerprint)
|
|
620
|
+
yield* engine.execute(
|
|
621
|
+
`CREATE TABLE IF NOT EXISTS ${target} AS SELECT * FROM (${emptyBody}) q LIMIT 0`,
|
|
622
|
+
)
|
|
623
|
+
// forward-only на живой таблице: колонки, появившиеся в запросе,
|
|
624
|
+
// добавляются к унаследованной физике (история получает NULL);
|
|
625
|
+
// исчезнувшие из запроса — сигнал, что реюз невозможен
|
|
626
|
+
if (action.change === "forward-only") {
|
|
627
|
+
yield* evolveTableForForwardOnly(engine, model, target, emptyBody)
|
|
628
|
+
}
|
|
629
|
+
yield* store.upsertSnapshot({
|
|
630
|
+
name: action.name,
|
|
631
|
+
fingerprint: action.fingerprint,
|
|
632
|
+
physicalFp: action.physicalFingerprint,
|
|
633
|
+
canonicalAst: action.canonicalAst ?? "",
|
|
634
|
+
renderedSql: render(model.fragment, { resolveRef: (ref) => ref }),
|
|
635
|
+
kind: model.kind._tag,
|
|
636
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
637
|
+
})
|
|
638
|
+
yield* backfillIntoTable(
|
|
639
|
+
engine,
|
|
640
|
+
store,
|
|
641
|
+
model,
|
|
642
|
+
action,
|
|
643
|
+
target,
|
|
644
|
+
resolveRef,
|
|
645
|
+
options?.concurrency ?? 4,
|
|
646
|
+
options?.retry,
|
|
647
|
+
)
|
|
648
|
+
}
|
|
649
|
+
break
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
const runOne = (action: PlanAction): Effect.Effect<void, ApplyError> =>
|
|
655
|
+
Effect.gen(function* () {
|
|
656
|
+
const model = graph.models.get(action.name)!
|
|
657
|
+
// ждём только родителей, которые строит этот же план; остальные готовы
|
|
658
|
+
yield* Effect.forEach(
|
|
659
|
+
[...model.deps].flatMap((dep) => {
|
|
660
|
+
const gate = gates.get(dep)
|
|
661
|
+
return gate === undefined ? [] : [gate]
|
|
662
|
+
}),
|
|
663
|
+
(gate) => Deferred.await(gate),
|
|
664
|
+
{ discard: true },
|
|
665
|
+
)
|
|
666
|
+
yield* buildOne(action)
|
|
667
|
+
yield* Metric.update(snapshotsBuilt, 1)
|
|
668
|
+
yield* Deferred.succeed(gates.get(action.name)!, undefined)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
// working в топологическом порядке, а forEach стартует элементы по
|
|
672
|
+
// порядку — у самого раннего незавершённого элемента родители всегда
|
|
673
|
+
// уже готовы, поэтому ожидание гейтов не выедает слоты до дедлока
|
|
674
|
+
yield* Effect.forEach(working, runOne, {
|
|
675
|
+
concurrency:
|
|
676
|
+
engine.dialect === "duckdb" ? 1 : Math.max(1, options?.modelConcurrency ?? 4),
|
|
677
|
+
discard: true,
|
|
678
|
+
})
|
|
679
|
+
const built = working.map((action) => action.name)
|
|
680
|
+
|
|
681
|
+
// 2. Промоушен: view-слой окружения
|
|
682
|
+
for (const action of plan.actions) {
|
|
683
|
+
if (action.change === "unchanged") continue
|
|
684
|
+
if (action.change === "removed") {
|
|
685
|
+
// имя модели из state store; схему восстанавливаем из полного имени
|
|
686
|
+
const [schema, table] = action.name.split(".") as [string, string]
|
|
687
|
+
yield* engine.execute(
|
|
688
|
+
`DROP VIEW IF EXISTS "${envSchema(plan.env, schema)}"."${table}"`,
|
|
689
|
+
)
|
|
690
|
+
continue
|
|
691
|
+
}
|
|
692
|
+
const model = graph.models.get(action.name)!
|
|
693
|
+
// view-слоя у external и embedded нет — они не материализуются
|
|
694
|
+
if (model.kind._tag === "external" || model.kind._tag === "embedded") continue
|
|
695
|
+
yield* engine.execute(
|
|
696
|
+
`CREATE SCHEMA IF NOT EXISTS "${envSchema(plan.env, model.name.schema)}"`,
|
|
697
|
+
)
|
|
698
|
+
yield* engine.execute(
|
|
699
|
+
`CREATE OR REPLACE VIEW ${viewRef(plan.env, model.name)} AS SELECT * FROM ${physicalFor(model, action.physicalFingerprint)}`,
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// 3. Экспорт наружу (SPEC §9.3): после аудитов и промоушена — наружу
|
|
704
|
+
// не уезжает непроверенное; готовая витрина пишется в ATTACH-базу
|
|
705
|
+
for (const action of plan.actions) {
|
|
706
|
+
if (action.change === "removed") continue
|
|
707
|
+
const model = graph.models.get(action.name)!
|
|
708
|
+
if (model.export === undefined) continue
|
|
709
|
+
// экспорт освежается, когда есть что везти: сборка/бэкфилл/refresh
|
|
710
|
+
if (!action.build && action.backfill.length === 0 && !action.refresh) continue
|
|
711
|
+
const attach = options?.attach?.[model.export.attach]
|
|
712
|
+
if (attach === undefined) {
|
|
713
|
+
return yield* new AttachNotConfiguredError({
|
|
714
|
+
model: model.name.full,
|
|
715
|
+
attach: model.export.attach,
|
|
716
|
+
})
|
|
717
|
+
}
|
|
718
|
+
yield* engine.execute(
|
|
719
|
+
`ATTACH IF NOT EXISTS '${attach.url.replaceAll(`'`, `''`)}' AS ${quoteIdent(model.export.attach)}${attach.options !== undefined ? ` (${attach.options})` : ""}`,
|
|
720
|
+
)
|
|
721
|
+
const [exportSchema, exportTable] = model.export.table.includes(".")
|
|
722
|
+
? (model.export.table.split(".") as [string, string])
|
|
723
|
+
: ["main", model.export.table]
|
|
724
|
+
yield* engine.execute(
|
|
725
|
+
`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(model.export.attach)}.${quoteIdent(exportSchema)}`,
|
|
726
|
+
)
|
|
727
|
+
yield* engine.execute(
|
|
728
|
+
`CREATE OR REPLACE TABLE ${quoteIdent(model.export.attach)}.${quoteIdent(exportSchema)}.${quoteIdent(exportTable)} AS SELECT * FROM ${physicalFor(model, action.physicalFingerprint)}`,
|
|
729
|
+
)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// 4. Состояние окружения + журнал; requireSnapshot — защита от гонки
|
|
733
|
+
// с janitor (F6): у материализуемых моделей снапшот обязан быть жив
|
|
734
|
+
// в момент промоушена, иначе view указал бы на снесённую физику
|
|
735
|
+
yield* store.promote(
|
|
736
|
+
plan.env,
|
|
737
|
+
plan.actions
|
|
738
|
+
.filter((a) => a.change !== "removed")
|
|
739
|
+
.map((a) => ({
|
|
740
|
+
name: a.name,
|
|
741
|
+
fingerprint: a.fingerprint,
|
|
742
|
+
requireSnapshot: graph.models.get(a.name)?.kind._tag !== "external",
|
|
743
|
+
})),
|
|
744
|
+
)
|
|
745
|
+
yield* store.recordPlan(
|
|
746
|
+
plan.env,
|
|
747
|
+
JSON.stringify({
|
|
748
|
+
actions: plan.actions.map((a) => ({
|
|
749
|
+
name: a.name,
|
|
750
|
+
change: a.change,
|
|
751
|
+
fingerprint: a.fingerprint.slice(0, 8),
|
|
752
|
+
build: a.build,
|
|
753
|
+
backfill: a.backfill.map((r) => `[${toIso(r.start)}, ${toIso(r.end)})`),
|
|
754
|
+
})),
|
|
755
|
+
}),
|
|
756
|
+
options?.appliedBy ?? osUser(),
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
return { plan, built }
|
|
760
|
+
}).pipe(Effect.withSpan("efmesh.apply", { attributes: { env: plan.env } }))
|