@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,91 @@
1
+ import type { ExternalSource, ModelName } from "../core/model.ts"
2
+
3
+ /**
4
+ * Раскладка объектов в движке (SPEC §2):
5
+ * - физика: схема `_efmesh`, таблица `<схема>__<таблица>__<fp8>`;
6
+ * - виртуалка: prod живёт в родных схемах моделей (`med.stays`),
7
+ * остальные окружения — в префиксованных (`dev__med.stays`).
8
+ *
9
+ * Схема именно `_efmesh`, не `efmesh`: DuckDB называет каталог по имени
10
+ * файла базы, и для `efmesh.duckdb` ссылка `efmesh.x` становится
11
+ * неоднозначной (каталог или схема) — Binder Error.
12
+ */
13
+
14
+ export const PROD_ENV = "prod"
15
+
16
+ const ENV_NAME = /^[a-z][a-z0-9_]*$/
17
+
18
+ export const validateEnvName = (env: string): boolean => ENV_NAME.test(env)
19
+
20
+ export const fp8 = (fingerprint: string): string => fingerprint.slice(0, 8)
21
+
22
+ export const physicalSchema = "_efmesh"
23
+
24
+ export const physicalTable = (name: ModelName, fingerprint: string): string =>
25
+ `${name.schema}__${name.table}__${fp8(fingerprint)}`
26
+
27
+ export const physicalRef = (name: ModelName, fingerprint: string): string =>
28
+ `"${physicalSchema}"."${physicalTable(name, fingerprint)}"`
29
+
30
+ export const envSchema = (env: string, modelSchema: string): string =>
31
+ env === PROD_ENV ? modelSchema : `${env}__${modelSchema}`
32
+
33
+ /**
34
+ * Раскладка parquet-озера (SPEC §3.3): `<lake>/<схема>/<таблица>/fp=<fp8>/…`,
35
+ * у incremental внутри — партиции `interval=<ключ>/data.parquet`.
36
+ * Интервал = партиция: пересчёт — перезапись файлов партиции, источник
37
+ * правды — учёт интервалов, поэтому неатомарность перезаписи не страшна.
38
+ */
39
+ export const parquetPrefix = (lakePath: string, name: ModelName, fingerprint: string): string =>
40
+ `${lakePath.replace(/\/+$/, "")}/${name.schema}/${name.table}/fp=${fp8(fingerprint)}`
41
+
42
+ /**
43
+ * union_by_name: партиции одного префикса могут отличаться схемой после
44
+ * forward-only-эволюции (новые колонки появляются только в новых файлах —
45
+ * история читается с NULL).
46
+ */
47
+ export const parquetRef = (lakePath: string, name: ModelName, fingerprint: string): string =>
48
+ `read_parquet('${parquetPrefix(lakePath, name, fingerprint).replaceAll(`'`, `''`)}/**/*.parquet', union_by_name=true)`
49
+
50
+ /**
51
+ * DuckLake-цель (SPEC §14.5): каталог подключается под фиксированным
52
+ * алиасом, физика — таблица-на-fingerprint в нём (то же имя, что у
53
+ * нативной физики, только каталог другой). Версионность остаётся нашей;
54
+ * снапшоты/time travel DuckLake — бонус. Потребители вне efmesh должны
55
+ * сами сделать ATTACH — view окружений ссылаются на каталог по алиасу.
56
+ */
57
+ export const ducklakeAlias = "_efmesh_ducklake"
58
+
59
+ export const ducklakeRef = (name: ModelName, fingerprint: string): string =>
60
+ `"${ducklakeAlias}"."${physicalTable(name, fingerprint)}"`
61
+
62
+ export const ducklakeAttachSql = (config: {
63
+ readonly catalog: string
64
+ readonly dataPath?: string
65
+ }): string =>
66
+ `ATTACH IF NOT EXISTS 'ducklake:sqlite:${config.catalog.replaceAll(`'`, `''`)}' AS "${ducklakeAlias}"${
67
+ config.dataPath !== undefined
68
+ ? ` (DATA_PATH '${config.dataPath.replaceAll(`'`, `''`)}')`
69
+ : ""
70
+ }`
71
+
72
+ /** Ключ партиции интервала — безопасен для файловых систем (без двоеточий). */
73
+ export const intervalKey = (unit: "day" | "hour", startMs: number): string => {
74
+ const iso = new Date(startMs).toISOString()
75
+ return unit === "day" ? iso.slice(0, 10) : `${iso.slice(0, 10)}T${iso.slice(11, 13)}`
76
+ }
77
+
78
+ const READERS = { parquet: "read_parquet", csv: "read_csv", json: "read_json" } as const
79
+
80
+ /**
81
+ * Во что рендерится ссылка на external-модель (SPEC §9.3): имя таблицы
82
+ * (движка или ATTACH-базы) как есть, файлы/URL — через read_*.
83
+ * external не материализуется, потребители читают источник напрямую.
84
+ */
85
+ export const externalSourceRef = (source: ExternalSource): string =>
86
+ source._tag === "table"
87
+ ? source.table
88
+ : `${READERS[source.format]}('${source.path.replaceAll(`'`, `''`)}')`
89
+
90
+ export const viewRef = (env: string, name: ModelName): string =>
91
+ `"${envSchema(env, name.schema)}"."${name.table}"`
@@ -0,0 +1,252 @@
1
+ import { Clock, Data, Effect } from "effect"
2
+ import type { SeedReadError } from "../core/errors.ts"
3
+ import type { ModelGraph } from "../core/graph.ts"
4
+ import type { Interval } from "../core/interval.ts"
5
+ import { enumerateIntervals, fromIso, mergeIntervals, missingIntervals } from "../core/interval.ts"
6
+ import type { EngineError, SqlParseError } from "../engine/adapter.ts"
7
+ import { EngineAdapter } from "../engine/adapter.ts"
8
+ import { StateStore } from "../state/store.ts"
9
+ import type { StateError } from "../state/store.ts"
10
+ import { categorizeAstChange } from "./categorize.ts"
11
+ import { FINGERPRINT_VERSION, fingerprintGraph } from "./fingerprint.ts"
12
+ import { validateEnvName } from "./naming.ts"
13
+
14
+ export class InvalidEnvironmentError extends Data.TaggedError("InvalidEnvironmentError")<{
15
+ readonly env: string
16
+ }> {}
17
+
18
+ /** Модель нельзя применить forward-only (SPEC §5.2). */
19
+ export class ForwardOnlyError extends Data.TaggedError("ForwardOnlyError")<{
20
+ readonly model: string
21
+ readonly reason: string
22
+ }> {}
23
+
24
+ /**
25
+ * Снапшот посчитан другой версией алгоритма fingerprint (SPEC §4):
26
+ * отпечатки разных версий несравнимы — план честно останавливается,
27
+ * а не показывает «всё breaking». Лечится миграцией той версии efmesh,
28
+ * которая сменила алгоритм.
29
+ */
30
+ export class FingerprintVersionError extends Data.TaggedError("FingerprintVersionError")<{
31
+ readonly model: string
32
+ readonly found: number
33
+ readonly wanted: number
34
+ }> {}
35
+
36
+ export type ChangeCategory =
37
+ /** Модели не было в окружении. */
38
+ | "added"
39
+ /** Смысловая правка запроса или метаданных: модель и потомки пересобираются. */
40
+ | "breaking"
41
+ /** Добавлены колонки в конец SELECT, остальное дерево нетронуто (SPEC §5.2). */
42
+ | "non-breaking"
43
+ /** Собственный AST не менялся — версия сдвинулась каскадом от родителя. */
44
+ | "indirect"
45
+ /**
46
+ * Физика и done-интервалы старой версии переиспользуются, история не
47
+ * переигрывается — новая логика действует с момента применения (SPEC §5.2).
48
+ * Явный флаг пользователя или каскад от forward-only-родителей.
49
+ */
50
+ | "forward-only"
51
+ /** Была в окружении, из проекта исчезла — view будет снесён при промоушене. */
52
+ | "removed"
53
+ | "unchanged"
54
+
55
+ export interface PlanAction {
56
+ readonly name: string
57
+ readonly fingerprint: string
58
+ /**
59
+ * Fingerprint, чьей физикой пользуется снапшот: обычно собственный,
60
+ * при forward-only — унаследованный от предыдущей версии.
61
+ */
62
+ readonly physicalFingerprint: string
63
+ /** Откуда наследуются физика и done-интервалы (fingerprint старой версии) при forward-only. */
64
+ readonly reusedFrom?: string
65
+ /** Канонический AST тела (null у external) — сохраняется в снапшот. */
66
+ readonly canonicalAst: string | null
67
+ readonly change: ChangeCategory
68
+ /**
69
+ * Нужна ли сборка физики. false для unchanged/removed, external и для
70
+ * снапшотов, уже собранных другим окружением, — тогда промоушен это
71
+ * только view-swap.
72
+ */
73
+ readonly build: boolean
74
+ /**
75
+ * Диапазоны к пересчёту у incrementalByTimeRange (слитые из недостающих
76
+ * интервалов + lookback); у остальных видов пуст. Дыры бывают и у
77
+ * unchanged-модели — время идёт, появляются новые интервалы.
78
+ */
79
+ readonly backfill: ReadonlyArray<Interval>
80
+ /** true у incrementalByUniqueKey: каждый apply перегоняет запрос (upsert по ключу). */
81
+ readonly refresh: boolean
82
+ }
83
+
84
+ export interface Plan {
85
+ readonly env: string
86
+ /** В топологическом порядке; removed — в конце. */
87
+ readonly actions: ReadonlyArray<PlanAction>
88
+ readonly hasChanges: boolean
89
+ }
90
+
91
+ export interface PlanOptions {
92
+ /** «Сейчас» для расчёта интервалов; по умолчанию — Clock. Инъекция для тестов. */
93
+ readonly now?: number
94
+ /**
95
+ * Модели, чьи изменения применить forward-only (SPEC §5.2): новая версия
96
+ * наследует физическую таблицу и done-интервалы старой, история не
97
+ * переигрывается. Только incrementalByTimeRange — у остальных видов
98
+ * «задним числом» не бывает по построению.
99
+ */
100
+ readonly forwardOnly?: ReadonlyArray<string>
101
+ }
102
+
103
+ export const planChanges = (
104
+ env: string,
105
+ graph: ModelGraph,
106
+ options?: PlanOptions,
107
+ ): Effect.Effect<
108
+ Plan,
109
+ | InvalidEnvironmentError
110
+ | ForwardOnlyError
111
+ | FingerprintVersionError
112
+ | StateError
113
+ | EngineError
114
+ | SqlParseError
115
+ | SeedReadError,
116
+ StateStore | EngineAdapter
117
+ > =>
118
+ Effect.gen(function* () {
119
+ if (!validateEnvName(env)) return yield* new InvalidEnvironmentError({ env })
120
+ const store = yield* StateStore
121
+ const now = options?.now ?? (yield* Clock.currentTimeMillis)
122
+ const forwardOnly = new Set(options?.forwardOnly ?? [])
123
+ for (const flagged of forwardOnly) {
124
+ const model = graph.models.get(flagged)
125
+ if (model === undefined) {
126
+ return yield* new ForwardOnlyError({ model: flagged, reason: "модели нет в проекте" })
127
+ }
128
+ if (model.kind._tag !== "incrementalByTimeRange") {
129
+ return yield* new ForwardOnlyError({
130
+ model: flagged,
131
+ reason: `forward-only переиспользует физику и учёт интервалов — применим только к incrementalByTimeRange, вид модели — ${model.kind._tag}`,
132
+ })
133
+ }
134
+ }
135
+ const versions = yield* fingerprintGraph(graph)
136
+ const current = new Map(
137
+ (yield* store.getEnvironment(env)).map((row) => [row.name, row.fingerprint]),
138
+ )
139
+
140
+ const actions: Array<PlanAction> = []
141
+ const changeOf = new Map<string, ChangeCategory>()
142
+ for (const name of graph.order) {
143
+ const model = graph.models.get(name)!
144
+ const { fingerprint, ast } = versions.get(name)!
145
+ const known = current.get(name)
146
+ let change: ChangeCategory
147
+ let reusedFrom: string | undefined
148
+ let physicalFingerprint = fingerprint
149
+ if (known === undefined) change = "added"
150
+ else if (known === fingerprint) change = "unchanged"
151
+ else {
152
+ // категоризация по AST против последнего известного снапшота (SPEC §5.2);
153
+ // старых записей без AST и external — консервативно breaking
154
+ const previous = yield* store.getSnapshot(name, known)
155
+ if (previous !== undefined && previous.fingerprintVersion !== FINGERPRINT_VERSION) {
156
+ return yield* new FingerprintVersionError({
157
+ model: name,
158
+ found: previous.fingerprintVersion,
159
+ wanted: FINGERPRINT_VERSION,
160
+ })
161
+ }
162
+ const oldAst = previous?.canonicalAst ?? ""
163
+ if (oldAst === "" || ast === null) change = "breaking"
164
+ else if (oldAst === ast) change = "indirect" // версия сдвинута родителем/метаданными
165
+ else change = categorizeAstChange(oldAst, ast)
166
+ // forward-only: явный флаг пользователя — либо каскад: собственный AST
167
+ // не менялся, а все изменившиеся родители сами forward-only (реюз
168
+ // физики родителя означает, что и потомку нечего переигрывать)
169
+ const cascaded =
170
+ change === "indirect" &&
171
+ model.kind._tag === "incrementalByTimeRange" &&
172
+ [...model.deps].every((dep) => {
173
+ const parent = changeOf.get(dep)
174
+ return parent === undefined || parent === "unchanged" || parent === "forward-only"
175
+ })
176
+ if (previous !== undefined && (forwardOnly.has(name) || cascaded)) {
177
+ change = "forward-only"
178
+ reusedFrom = known
179
+ physicalFingerprint = previous.physicalFp
180
+ }
181
+ }
182
+ changeOf.set(name, change)
183
+ // external не материализуется — версия участвует в diff, физики нет
184
+ const existing = yield* store.getSnapshot(name, fingerprint)
185
+ if (existing !== undefined) physicalFingerprint = existing.physicalFp
186
+ const alreadyBuilt =
187
+ model.kind._tag === "external" || change === "unchanged" || existing !== undefined
188
+
189
+ let backfill: ReadonlyArray<Interval> = []
190
+ if (model.kind._tag === "incrementalByTimeRange") {
191
+ const kind = model.kind
192
+ const wanted = enumerateIntervals(kind.interval, fromIso(kind.start), now)
193
+ // покрытие привязано к fingerprint: новая версия = пустой учёт = полный
194
+ // бэкфилл; forward-only наследует done-интервалы старой версии —
195
+ // пересчитывается только то, чего не было
196
+ const inherited =
197
+ reusedFrom === undefined ? [] : yield* store.listIntervals(reusedFrom)
198
+ const doneByStart = new Map<number, Interval>()
199
+ for (const record of [...(yield* store.listIntervals(fingerprint)), ...inherited]) {
200
+ if (record.status !== "done") continue
201
+ doneByStart.set(fromIso(record.startTs), {
202
+ start: fromIso(record.startTs),
203
+ end: fromIso(record.endTs),
204
+ })
205
+ }
206
+ const done = [...doneByStart.values()]
207
+ const missing = [...missingIntervals(wanted, done)]
208
+ if (kind.lookback > 0) {
209
+ // поздно приезжающие данные: последние done-интервалы перечитываются
210
+ const tail = done.sort((a, b) => a.start - b.start).slice(-kind.lookback)
211
+ missing.push(...tail)
212
+ }
213
+ backfill = mergeIntervals(missing.sort((a, b) => a.start - b.start))
214
+ }
215
+
216
+ actions.push({
217
+ name,
218
+ fingerprint,
219
+ physicalFingerprint,
220
+ ...(reusedFrom !== undefined ? { reusedFrom } : {}),
221
+ canonicalAst: ast,
222
+ change,
223
+ build: !alreadyBuilt,
224
+ backfill,
225
+ // каждый apply сверяет запрос с физикой: upsert / SCD-версионирование
226
+ refresh:
227
+ model.kind._tag === "incrementalByUniqueKey" || model.kind._tag === "scdType2",
228
+ })
229
+ }
230
+ for (const [name, fingerprint] of current) {
231
+ if (!graph.models.has(name)) {
232
+ actions.push({
233
+ name,
234
+ fingerprint,
235
+ physicalFingerprint: fingerprint,
236
+ canonicalAst: null,
237
+ change: "removed",
238
+ build: false,
239
+ backfill: [],
240
+ refresh: false,
241
+ })
242
+ }
243
+ }
244
+
245
+ return {
246
+ env,
247
+ actions,
248
+ hasChanges: actions.some(
249
+ (a) => a.change !== "unchanged" || a.backfill.length > 0 || a.refresh,
250
+ ),
251
+ }
252
+ }).pipe(Effect.withSpan("efmesh.plan", { attributes: { env } }))
@@ -0,0 +1,70 @@
1
+ import { Data, Effect, Schedule } from "effect"
2
+ import { buildGraph } from "../core/graph.ts"
3
+ import type { AnyModel } from "../core/model.ts"
4
+ import { EngineAdapter } from "../engine/adapter.ts"
5
+ import { StateStore } from "../state/store.ts"
6
+ import { applyPlan, type AppliedPlan, type ApplyError, type ApplyOptions } from "./executor.ts"
7
+ import { envLockName, withStateLock, type LockHeldError, type LockOptions } from "./lock.ts"
8
+ import { planChanges, type PlanOptions } from "./planner.ts"
9
+
10
+ /**
11
+ * `run` — тик планировщика (SPEC §7): догоняет интервалы и upsert'ы
12
+ * СУЩЕСТВУЮЩИХ версий, никогда не применяет изменения моделей — это
13
+ * работа plan/apply с человеком у руля. Идемпотентен и безопасен для
14
+ * cron/systemd: параллельный запуск отсекается блокировкой в state store —
15
+ * той же `env:<имя>`, что у apply (SPEC §14.6), поэтому run не вклинится
16
+ * в чужое применение и наоборот.
17
+ */
18
+
19
+ export class RunBlockedByChangesError extends Data.TaggedError("RunBlockedByChangesError")<{
20
+ readonly env: string
21
+ readonly changes: ReadonlyArray<string>
22
+ }> {}
23
+
24
+ export type RunError = ApplyError | LockHeldError | RunBlockedByChangesError
25
+
26
+ export interface RunOptions extends PlanOptions, ApplyOptions, LockOptions {}
27
+
28
+ export const run = (
29
+ env: string,
30
+ models: Iterable<AnyModel>,
31
+ options?: RunOptions,
32
+ ): Effect.Effect<AppliedPlan, RunError, EngineAdapter | StateStore> =>
33
+ Effect.gen(function* () {
34
+ const graph = yield* buildGraph(models)
35
+ const plan = yield* planChanges(env, graph, options)
36
+ const structural = plan.actions
37
+ .filter((a) => a.change !== "unchanged")
38
+ .map((a) => `${a.name}: ${a.change}`)
39
+ if (structural.length > 0) {
40
+ return yield* new RunBlockedByChangesError({ env, changes: structural })
41
+ }
42
+ return yield* applyPlan(plan, graph, options)
43
+ }).pipe(withStateLock(envLockName(env), options?.lockTtlMs))
44
+
45
+ /**
46
+ * Долгоживущий планировщик для встраивания в Effect-приложение (SPEC §7):
47
+ * тики по Schedule, ошибка тика логируется и не роняет демона
48
+ * (кроме занятого лока — это штатно, лог потише).
49
+ */
50
+ export const daemon = (
51
+ env: string,
52
+ models: Iterable<AnyModel>,
53
+ schedule: Schedule.Schedule<unknown>,
54
+ options?: RunOptions,
55
+ ): Effect.Effect<never, never, EngineAdapter | StateStore> =>
56
+ run(env, models, options).pipe(
57
+ Effect.tap((applied) =>
58
+ applied.built.length > 0
59
+ ? Effect.logInfo(`run ${env}: обработано ${applied.built.join(", ")}`)
60
+ : Effect.void,
61
+ ),
62
+ Effect.catchTag("LockHeldError", () =>
63
+ Effect.logDebug(`run ${env}: лок занят другим процессом, пропуск тика`),
64
+ ),
65
+ Effect.catchCause((cause) => Effect.logError(`run ${env}: тик упал`, cause)),
66
+ Effect.schedule(schedule),
67
+ Effect.andThen(Effect.never),
68
+ )
69
+
70
+ export const Runner = { run, daemon } as const