@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,88 @@
|
|
|
1
|
+
import { Data, type Schema } from "effect"
|
|
2
|
+
import type { SelfValue, SqlFragment, SqlLiteral } from "./sql.ts"
|
|
3
|
+
import { quoteIdent, sql } from "./sql.ts"
|
|
4
|
+
|
|
5
|
+
/** Blocking-аудит нашёл нарушения: снапшот/интервал не считается годным. */
|
|
6
|
+
export class AuditFailure extends Data.TaggedError("AuditFailure")<{
|
|
7
|
+
readonly model: string
|
|
8
|
+
readonly audit: string
|
|
9
|
+
readonly violations: number
|
|
10
|
+
}> {}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Аудит (SPEC §8) — SQL-предикат над результатом модели: запрос возвращает
|
|
14
|
+
* НАРУШАЮЩИЕ строки, непустой результат = провал. `ctx.self` рендерится
|
|
15
|
+
* в физику снапшота (у incremental — в подзапрос обработанного интервала,
|
|
16
|
+
* аудит проверяет то, что только что загрузили, а не всю историю).
|
|
17
|
+
*
|
|
18
|
+
* blocking-аудит (по умолчанию) роняет apply: интервал помечается failed,
|
|
19
|
+
* view не промоутится. warn — лог + конвейер едет дальше.
|
|
20
|
+
*/
|
|
21
|
+
export interface Audit {
|
|
22
|
+
readonly name: string
|
|
23
|
+
readonly blocking: boolean
|
|
24
|
+
/** Запрос нарушений; содержит узел Self. */
|
|
25
|
+
readonly fragment: SqlFragment
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AuditCtx {
|
|
29
|
+
readonly sql: typeof sql
|
|
30
|
+
readonly self: SelfValue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SELF: SelfValue = { _tag: "SelfValue" }
|
|
34
|
+
|
|
35
|
+
/** Билдеры типизированы колонками модели через параметр Fields. */
|
|
36
|
+
export const audit = {
|
|
37
|
+
notNull: <Fields extends Schema.Struct.Fields>(
|
|
38
|
+
column: Extract<keyof Fields, string>,
|
|
39
|
+
): Audit => ({
|
|
40
|
+
name: `not_null(${column})`,
|
|
41
|
+
blocking: true,
|
|
42
|
+
fragment: sql`SELECT * FROM ${SELF} WHERE ${idents(column)} IS NULL`,
|
|
43
|
+
}),
|
|
44
|
+
|
|
45
|
+
unique: <Fields extends Schema.Struct.Fields>(
|
|
46
|
+
...columns: ReadonlyArray<Extract<keyof Fields, string>>
|
|
47
|
+
): Audit => ({
|
|
48
|
+
name: `unique(${columns.join(", ")})`,
|
|
49
|
+
blocking: true,
|
|
50
|
+
fragment: sql`
|
|
51
|
+
SELECT ${idents(...columns)}, count(*) AS duplicates
|
|
52
|
+
FROM ${SELF}
|
|
53
|
+
GROUP BY ${idents(...columns)}
|
|
54
|
+
HAVING count(*) > 1`,
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
accepted: <Fields extends Schema.Struct.Fields>(
|
|
58
|
+
column: Extract<keyof Fields, string>,
|
|
59
|
+
values: ReadonlyArray<SqlLiteral>,
|
|
60
|
+
): Audit => ({
|
|
61
|
+
name: `accepted(${column})`,
|
|
62
|
+
blocking: true,
|
|
63
|
+
fragment: sql`
|
|
64
|
+
SELECT * FROM ${SELF}
|
|
65
|
+
WHERE ${idents(column)} IS NOT NULL
|
|
66
|
+
AND ${idents(column)} NOT IN (${valuesList(values)})`,
|
|
67
|
+
}),
|
|
68
|
+
|
|
69
|
+
custom: (name: string, body: (ctx: AuditCtx) => SqlFragment): Audit => ({
|
|
70
|
+
name,
|
|
71
|
+
blocking: true,
|
|
72
|
+
fragment: body({ sql, self: SELF }),
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
/** Понизить аудит до предупреждения: лог вместо провала. */
|
|
76
|
+
warn: (base: Audit): Audit => ({ ...base, blocking: false }),
|
|
77
|
+
} as const
|
|
78
|
+
|
|
79
|
+
const idents = (...names: ReadonlyArray<string>): SqlFragment => ({
|
|
80
|
+
_tag: "SqlFragment",
|
|
81
|
+
nodes: [{ _tag: "Text", text: names.map(quoteIdent).join(", ") }],
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const valuesList = (values: ReadonlyArray<SqlLiteral>): SqlFragment =>
|
|
85
|
+
values.reduce<SqlFragment>(
|
|
86
|
+
(acc, value, index) => (index === 0 ? sql`${value}` : sql`${acc}, ${value}`),
|
|
87
|
+
{ _tag: "SqlFragment", nodes: [] },
|
|
88
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Data } from "effect"
|
|
2
|
+
|
|
3
|
+
/** Некорректная конфигурация модели: битое имя, self-ref и т.п. Бросается на этапе загрузки модуля. */
|
|
4
|
+
export class ModelDefinitionError extends Data.TaggedError("ModelDefinitionError")<{
|
|
5
|
+
readonly model: string
|
|
6
|
+
readonly reason: string
|
|
7
|
+
}> {}
|
|
8
|
+
|
|
9
|
+
export class DuplicateModelError extends Data.TaggedError("DuplicateModelError")<{
|
|
10
|
+
readonly name: string
|
|
11
|
+
}> {}
|
|
12
|
+
|
|
13
|
+
export class UnknownDependencyError extends Data.TaggedError("UnknownDependencyError")<{
|
|
14
|
+
readonly model: string
|
|
15
|
+
readonly dependency: string
|
|
16
|
+
}> {}
|
|
17
|
+
|
|
18
|
+
export class DagCycleError extends Data.TaggedError("DagCycleError")<{
|
|
19
|
+
readonly cycle: ReadonlyArray<string>
|
|
20
|
+
}> {}
|
|
21
|
+
|
|
22
|
+
/** Файл seed-модели не читается — fingerprint и сборка невозможны. */
|
|
23
|
+
export class SeedReadError extends Data.TaggedError("SeedReadError")<{
|
|
24
|
+
readonly model: string
|
|
25
|
+
readonly file: string
|
|
26
|
+
readonly cause: unknown
|
|
27
|
+
}> {}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { DagCycleError, DuplicateModelError, UnknownDependencyError } from "./errors.ts"
|
|
3
|
+
import type { AnyModel } from "./model.ts"
|
|
4
|
+
|
|
5
|
+
export interface ModelGraph {
|
|
6
|
+
readonly models: ReadonlyMap<string, AnyModel>
|
|
7
|
+
/** Топологический порядок: родители раньше детей. */
|
|
8
|
+
readonly order: ReadonlyArray<string>
|
|
9
|
+
/** Прямые потомки: кто ссылается на данную модель. */
|
|
10
|
+
readonly dependents: ReadonlyMap<string, ReadonlySet<string>>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type GraphError = DuplicateModelError | UnknownDependencyError | DagCycleError
|
|
14
|
+
|
|
15
|
+
/** Собирает DAG из набора моделей: дубликаты, неизвестные зависимости, циклы — типизированные ошибки. */
|
|
16
|
+
export const buildGraph = (
|
|
17
|
+
input: Iterable<AnyModel>,
|
|
18
|
+
): Effect.Effect<ModelGraph, GraphError> =>
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const models = new Map<string, AnyModel>()
|
|
21
|
+
for (const model of input) {
|
|
22
|
+
if (models.has(model.name.full)) {
|
|
23
|
+
return yield* new DuplicateModelError({ name: model.name.full })
|
|
24
|
+
}
|
|
25
|
+
models.set(model.name.full, model)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const dependents = new Map<string, Set<string>>()
|
|
29
|
+
for (const name of models.keys()) dependents.set(name, new Set())
|
|
30
|
+
for (const model of models.values()) {
|
|
31
|
+
for (const dep of model.deps) {
|
|
32
|
+
if (!models.has(dep)) {
|
|
33
|
+
return yield* new UnknownDependencyError({ model: model.name.full, dependency: dep })
|
|
34
|
+
}
|
|
35
|
+
dependents.get(dep)!.add(model.name.full)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Кан: считаем входящие степени (число зависимостей), снимаем нулевые.
|
|
40
|
+
const inDegree = new Map<string, number>()
|
|
41
|
+
for (const [name, model] of models) inDegree.set(name, model.deps.size)
|
|
42
|
+
// сортировка очереди — чтобы порядок был детерминированным между запусками
|
|
43
|
+
const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([n]) => n).sort()
|
|
44
|
+
const order: Array<string> = []
|
|
45
|
+
while (queue.length > 0) {
|
|
46
|
+
const name = queue.shift()!
|
|
47
|
+
order.push(name)
|
|
48
|
+
for (const child of [...dependents.get(name)!].sort()) {
|
|
49
|
+
const left = inDegree.get(child)! - 1
|
|
50
|
+
inDegree.set(child, left)
|
|
51
|
+
if (left === 0) queue.push(child)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (order.length !== models.size) {
|
|
56
|
+
const cycle = [...models.keys()].filter((n) => !order.includes(n))
|
|
57
|
+
return yield* new DagCycleError({ cycle })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { models, order, dependents }
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/** Все транзитивные потомки модели (для breaking-каскада в плане). */
|
|
64
|
+
export const transitiveDependents = (graph: ModelGraph, name: string): ReadonlySet<string> => {
|
|
65
|
+
const out = new Set<string>()
|
|
66
|
+
const stack = [name]
|
|
67
|
+
while (stack.length > 0) {
|
|
68
|
+
for (const child of graph.dependents.get(stack.pop()!) ?? []) {
|
|
69
|
+
if (!out.has(child)) {
|
|
70
|
+
out.add(child)
|
|
71
|
+
stack.push(child)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Интервальная арифметика (SPEC §2, §5.3): полуинтервалы `[start, end)`
|
|
3
|
+
* времени UTC, выровненные по зерну модели. Учёт заполненных интервалов —
|
|
4
|
+
* основа инкрементальности и бэкфилла; здесь только чистая математика,
|
|
5
|
+
* состояние живёт в state store.
|
|
6
|
+
*
|
|
7
|
+
* Внутреннее представление — epoch millis: зёрна day/hour в UTC не зависят
|
|
8
|
+
* от календаря, деление с округлением вниз даёт выравнивание.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type IntervalUnit = "day" | "hour"
|
|
12
|
+
|
|
13
|
+
export interface Interval {
|
|
14
|
+
readonly start: number
|
|
15
|
+
readonly end: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const unitMillis: Record<IntervalUnit, number> = {
|
|
19
|
+
day: 86_400_000,
|
|
20
|
+
hour: 3_600_000,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const floorTo = (unit: IntervalUnit, ms: number): number => {
|
|
24
|
+
const step = unitMillis[unit]
|
|
25
|
+
return Math.floor(ms / step) * step
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Все завершённые интервалы зерна от `startMs` до `nowMs`:
|
|
30
|
+
* начало выравнивается вниз, последний интервал — тот, что уже закончился
|
|
31
|
+
* (`end <= floor(now)` — недописанное «сегодня» не считается).
|
|
32
|
+
*/
|
|
33
|
+
export const enumerateIntervals = (
|
|
34
|
+
unit: IntervalUnit,
|
|
35
|
+
startMs: number,
|
|
36
|
+
nowMs: number,
|
|
37
|
+
): ReadonlyArray<Interval> => {
|
|
38
|
+
const step = unitMillis[unit]
|
|
39
|
+
const from = floorTo(unit, startMs)
|
|
40
|
+
const to = floorTo(unit, nowMs)
|
|
41
|
+
const intervals: Array<Interval> = []
|
|
42
|
+
for (let start = from; start + step <= to; start += step) {
|
|
43
|
+
intervals.push({ start, end: start + step })
|
|
44
|
+
}
|
|
45
|
+
return intervals
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `wanted` минус `covered` (сравнение по `start`; интервалы одного зерна). */
|
|
49
|
+
export const missingIntervals = (
|
|
50
|
+
wanted: ReadonlyArray<Interval>,
|
|
51
|
+
covered: ReadonlyArray<Interval>,
|
|
52
|
+
): ReadonlyArray<Interval> => {
|
|
53
|
+
const done = new Set(covered.map((i) => i.start))
|
|
54
|
+
return wanted.filter((i) => !done.has(i.start))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Смежные интервалы сливаются в непрерывные диапазоны (вход — отсортированный). */
|
|
58
|
+
export const mergeIntervals = (
|
|
59
|
+
intervals: ReadonlyArray<Interval>,
|
|
60
|
+
): ReadonlyArray<Interval> => {
|
|
61
|
+
const merged: Array<Interval> = []
|
|
62
|
+
for (const interval of intervals) {
|
|
63
|
+
const last = merged[merged.length - 1]
|
|
64
|
+
if (last !== undefined && last.end === interval.start) {
|
|
65
|
+
merged[merged.length - 1] = { start: last.start, end: interval.end }
|
|
66
|
+
} else {
|
|
67
|
+
merged.push({ ...interval })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return merged
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Режет диапазон на батчи не длиннее `batchSize` интервалов зерна.
|
|
75
|
+
* Батч — единица исполнения (один DELETE+INSERT), интервал — единица учёта.
|
|
76
|
+
*/
|
|
77
|
+
export const splitIntoBatches = (
|
|
78
|
+
range: Interval,
|
|
79
|
+
unit: IntervalUnit,
|
|
80
|
+
batchSize: number,
|
|
81
|
+
): ReadonlyArray<Interval> => {
|
|
82
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) {
|
|
83
|
+
throw new RangeError(`batchSize должен быть целым ≥ 1, получен ${batchSize}`)
|
|
84
|
+
}
|
|
85
|
+
const step = unitMillis[unit] * batchSize
|
|
86
|
+
const batches: Array<Interval> = []
|
|
87
|
+
for (let start = range.start; start < range.end; start += step) {
|
|
88
|
+
batches.push({ start, end: Math.min(start + step, range.end) })
|
|
89
|
+
}
|
|
90
|
+
return batches
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Интервалы зерна внутри диапазона (для поинтервальной отметки done после батча). */
|
|
94
|
+
export const intervalsWithin = (
|
|
95
|
+
range: Interval,
|
|
96
|
+
unit: IntervalUnit,
|
|
97
|
+
): ReadonlyArray<Interval> => {
|
|
98
|
+
const step = unitMillis[unit]
|
|
99
|
+
const intervals: Array<Interval> = []
|
|
100
|
+
for (let start = range.start; start < range.end; start += step) {
|
|
101
|
+
intervals.push({ start, end: Math.min(start + step, range.end) })
|
|
102
|
+
}
|
|
103
|
+
return intervals
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** ISO-8601 UTC — формат хранения границ в state store (сортируется лексикографически). */
|
|
107
|
+
export const toIso = (ms: number): string => new Date(ms).toISOString()
|
|
108
|
+
|
|
109
|
+
export const fromIso = (iso: string): number => {
|
|
110
|
+
const ms = Date.parse(iso)
|
|
111
|
+
if (Number.isNaN(ms)) throw new RangeError(`не ISO-время: ${iso}`)
|
|
112
|
+
return ms
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Литерал для подстановки границы интервала в SQL DuckDB. */
|
|
116
|
+
export const sqlTimestamp = (ms: number): string => {
|
|
117
|
+
const iso = new Date(ms).toISOString()
|
|
118
|
+
return `TIMESTAMP '${iso.slice(0, 10)} ${iso.slice(11, 19)}'`
|
|
119
|
+
}
|