@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,101 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import type { GraphError, ModelGraph } from "../core/graph.ts"
|
|
3
|
+
import { buildGraph } from "../core/graph.ts"
|
|
4
|
+
import type { AnyModel } from "../core/model.ts"
|
|
5
|
+
import { render } from "../core/sql.ts"
|
|
6
|
+
import { EngineAdapter } from "../engine/adapter.ts"
|
|
7
|
+
import type { EngineError } from "../engine/adapter.ts"
|
|
8
|
+
import { externalSourceRef, viewRef } from "./naming.ts"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Автономный прогон аудитов (SPEC §8, F4): проверяет то, что окружение
|
|
12
|
+
* отдаёт потребителям СЕЙЧАС — view-слой целиком, не свежезагруженный
|
|
13
|
+
* интервал, как в apply. Ловит деградацию задним числом: поздние данные
|
|
14
|
+
* через lookback, руками поправленную физику, дрейф external-источников.
|
|
15
|
+
*
|
|
16
|
+
* Ничего не меняет и не помечает; отчёт целиком — упавший blocking-аудит
|
|
17
|
+
* не прячет следующие за ним.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface AuditRunResult {
|
|
21
|
+
readonly model: string
|
|
22
|
+
readonly audit: string
|
|
23
|
+
readonly blocking: boolean
|
|
24
|
+
readonly violations: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AuditRunReport {
|
|
28
|
+
readonly results: ReadonlyArray<AuditRunResult>
|
|
29
|
+
/** Суммарные нарушения blocking-аудитов — не ноль ⇒ окружению нельзя верить. */
|
|
30
|
+
readonly blockingViolations: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Запрошенной модели нет в проекте (или у неё нет аудитов — нечего гонять). */
|
|
34
|
+
export class AuditTargetError extends Data.TaggedError("AuditTargetError")<{
|
|
35
|
+
readonly model: string
|
|
36
|
+
readonly reason: string
|
|
37
|
+
}> {}
|
|
38
|
+
|
|
39
|
+
/** Итог для CLI: blocking-аудиты окружения нарушены. */
|
|
40
|
+
export class EnvironmentAuditError extends Data.TaggedError("EnvironmentAuditError")<{
|
|
41
|
+
readonly env: string
|
|
42
|
+
readonly blockingViolations: number
|
|
43
|
+
}> {}
|
|
44
|
+
|
|
45
|
+
const selfFor = (graph: ModelGraph, env: string, model: AnyModel): string => {
|
|
46
|
+
const resolve = (ref: string): string => {
|
|
47
|
+
const source = graph.models.get(ref)!
|
|
48
|
+
if (source.kind._tag === "external") return externalSourceRef(source.kind.source)
|
|
49
|
+
if (source.kind._tag === "embedded") {
|
|
50
|
+
return `(${render(source.fragment, { resolveRef: resolve })})`
|
|
51
|
+
}
|
|
52
|
+
return viewRef(env, source.name)
|
|
53
|
+
}
|
|
54
|
+
// embedded не материализуется — аудируется подзапрос против view окружения
|
|
55
|
+
if (model.kind._tag === "embedded") {
|
|
56
|
+
return `(${render(model.fragment, { resolveRef: resolve })})`
|
|
57
|
+
}
|
|
58
|
+
return viewRef(env, model.name)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const auditEnvironment = (
|
|
62
|
+
env: string,
|
|
63
|
+
models: Iterable<AnyModel>,
|
|
64
|
+
only?: ReadonlyArray<string>,
|
|
65
|
+
): Effect.Effect<AuditRunReport, GraphError | AuditTargetError | EngineError, EngineAdapter> =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const engine = yield* EngineAdapter
|
|
68
|
+
const graph = yield* buildGraph(models)
|
|
69
|
+
for (const name of only ?? []) {
|
|
70
|
+
if (!graph.models.has(name)) {
|
|
71
|
+
return yield* new AuditTargetError({ model: name, reason: "модели нет в проекте" })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const wanted = only === undefined ? undefined : new Set(only)
|
|
75
|
+
|
|
76
|
+
const results: Array<AuditRunResult> = []
|
|
77
|
+
for (const name of graph.order) {
|
|
78
|
+
if (wanted !== undefined && !wanted.has(name)) continue
|
|
79
|
+
const model = graph.models.get(name)!
|
|
80
|
+
if (model.audits.length === 0) continue
|
|
81
|
+
const self = selfFor(graph, env, model)
|
|
82
|
+
for (const auditDef of model.audits) {
|
|
83
|
+
const violations = yield* engine.query(
|
|
84
|
+
render(auditDef.fragment, { resolveRef: (ref) => ref, self }),
|
|
85
|
+
)
|
|
86
|
+
results.push({
|
|
87
|
+
model: name,
|
|
88
|
+
audit: auditDef.name,
|
|
89
|
+
blocking: auditDef.blocking,
|
|
90
|
+
violations: violations.length,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
results,
|
|
97
|
+
blockingViolations: results
|
|
98
|
+
.filter((result) => result.blocking)
|
|
99
|
+
.reduce((sum, result) => sum + result.violations, 0),
|
|
100
|
+
}
|
|
101
|
+
}).pipe(Effect.withSpan("efmesh.audit", { attributes: { env } }))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Категоризация изменения по каноническим AST (SPEC §5.2): non-breaking —
|
|
3
|
+
* это добавление колонок В КОНЕЦ верхнего SELECT при полностью неизменном
|
|
4
|
+
* остальном дереве (потребители выбирают по именам, INSERT потомков — по
|
|
5
|
+
* позициям, поэтому только суффикс безопасен). Всё прочее — breaking.
|
|
6
|
+
* При любой неожиданной структуре — консервативно breaking.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
type AstNode = Record<string, unknown>
|
|
10
|
+
|
|
11
|
+
const topSelect = (astJson: string): { list: ReadonlyArray<string>; rest: string } | null => {
|
|
12
|
+
try {
|
|
13
|
+
const ast = JSON.parse(astJson) as {
|
|
14
|
+
readonly statements?: ReadonlyArray<{ readonly node?: AstNode }>
|
|
15
|
+
}
|
|
16
|
+
const node = ast.statements?.[0]?.node
|
|
17
|
+
if (node === undefined || !Array.isArray(node["select_list"])) return null
|
|
18
|
+
const list = (node["select_list"] as ReadonlyArray<unknown>).map((item) =>
|
|
19
|
+
JSON.stringify(item),
|
|
20
|
+
)
|
|
21
|
+
const { select_list: _list, ...rest } = node
|
|
22
|
+
return { list, rest: JSON.stringify(rest) }
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const categorizeAstChange = (
|
|
29
|
+
oldAst: string,
|
|
30
|
+
newAst: string,
|
|
31
|
+
): "breaking" | "non-breaking" => {
|
|
32
|
+
const before = topSelect(oldAst)
|
|
33
|
+
const after = topSelect(newAst)
|
|
34
|
+
if (before === null || after === null) return "breaking"
|
|
35
|
+
if (before.rest !== after.rest) return "breaking"
|
|
36
|
+
if (after.list.length <= before.list.length) return "breaking"
|
|
37
|
+
// старый select_list — точный префикс нового
|
|
38
|
+
return before.list.every((item, index) => item === after.list[index])
|
|
39
|
+
? "non-breaking"
|
|
40
|
+
: "breaking"
|
|
41
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import type { AnyModel } from "../core/model.ts"
|
|
3
|
+
import type { Engine, EngineError } from "../engine/adapter.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Контракт схемы (SPEC §3.2): объявленная Schema — не документация.
|
|
7
|
+
* Перед сборкой снапшота efmesh делает DESCRIBE запроса (движок отдаёт
|
|
8
|
+
* имена и типы, не выполняя его) и сверяет с объявлением. Дрейф типов
|
|
9
|
+
* ловится до бэкфилла, а не после.
|
|
10
|
+
*
|
|
11
|
+
* Сверка по семействам типов: точное соответствие TS-типов типам движка
|
|
12
|
+
* невозможно (Number — это и INTEGER, и DOUBLE), а имена проверяются
|
|
13
|
+
* строго. Nullability DESCRIBE не отдаёт — это территория аудитов (§14.2).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export class SchemaMismatchError extends Data.TaggedError("SchemaMismatchError")<{
|
|
17
|
+
readonly model: string
|
|
18
|
+
readonly problems: ReadonlyArray<string>
|
|
19
|
+
}> {}
|
|
20
|
+
|
|
21
|
+
export type TypeFamily = "text" | "numeric" | "boolean" | "temporal" | "any"
|
|
22
|
+
|
|
23
|
+
/** Семейство, ожидаемое от поля Effect Schema (по AST). Реюз: testModel рендерит фикстуры по нему. */
|
|
24
|
+
export const familyOfAst = (ast: unknown): TypeFamily => {
|
|
25
|
+
const node = ast as {
|
|
26
|
+
readonly _tag: string
|
|
27
|
+
readonly types?: ReadonlyArray<unknown>
|
|
28
|
+
readonly annotations?: { readonly typeConstructor?: { readonly _tag?: string } }
|
|
29
|
+
}
|
|
30
|
+
switch (node._tag) {
|
|
31
|
+
case "String":
|
|
32
|
+
return "text"
|
|
33
|
+
case "Number":
|
|
34
|
+
case "BigInt":
|
|
35
|
+
return "numeric"
|
|
36
|
+
case "Boolean":
|
|
37
|
+
return "boolean"
|
|
38
|
+
case "Declaration": {
|
|
39
|
+
const constructorTag = node.annotations?.typeConstructor?._tag ?? ""
|
|
40
|
+
return constructorTag.startsWith("effect/DateTime") ? "temporal" : "any"
|
|
41
|
+
}
|
|
42
|
+
case "Union": {
|
|
43
|
+
// NullOr(X) и подобные: единственное распознанное семейство среди членов
|
|
44
|
+
const families = new Set(
|
|
45
|
+
(node.types ?? [])
|
|
46
|
+
.map(familyOfAst)
|
|
47
|
+
.filter((family) => family !== "any"),
|
|
48
|
+
)
|
|
49
|
+
return families.size === 1 ? [...families][0]! : "any"
|
|
50
|
+
}
|
|
51
|
+
default:
|
|
52
|
+
return "any"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Семейство фактического типа DuckDB из DESCRIBE. */
|
|
57
|
+
const familyOfEngineType = (engineType: string): TypeFamily => {
|
|
58
|
+
const base = engineType.toUpperCase()
|
|
59
|
+
if (base === "VARCHAR" || base.startsWith("VARCHAR(")) return "text"
|
|
60
|
+
if (base === "BOOLEAN") return "boolean"
|
|
61
|
+
if (
|
|
62
|
+
/^(U?(TINY|SMALL|BIG|HUGE)INT|INTEGER|UINTEGER|FLOAT|DOUBLE|REAL)$/.test(base) ||
|
|
63
|
+
base.startsWith("DECIMAL")
|
|
64
|
+
) {
|
|
65
|
+
return "numeric"
|
|
66
|
+
}
|
|
67
|
+
if (base === "DATE" || base === "TIME" || base.startsWith("TIMESTAMP")) return "temporal"
|
|
68
|
+
return "any"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Сверяет объявленную схему модели с фактическим результатом запроса.
|
|
73
|
+
* `renderedSql` — исполнимый рендер тела (ссылки уже в физике/источниках).
|
|
74
|
+
* `managed` — колонки, которые efmesh ведёт сам (scdType2: valid_from/
|
|
75
|
+
* valid_to): в схеме объявлены — потребители их видят, — но запрос
|
|
76
|
+
* их не возвращает.
|
|
77
|
+
*/
|
|
78
|
+
export const checkContract = (
|
|
79
|
+
engine: Engine,
|
|
80
|
+
model: AnyModel,
|
|
81
|
+
renderedSql: string,
|
|
82
|
+
managed?: ReadonlySet<string>,
|
|
83
|
+
): Effect.Effect<void, EngineError | SchemaMismatchError> =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
const actual = yield* engine.describe(renderedSql)
|
|
86
|
+
const actualByName = new Map(actual.map((column) => [column.name, column.type]))
|
|
87
|
+
const declared = (
|
|
88
|
+
Object.entries(model.schema.fields) as ReadonlyArray<[string, { readonly ast: unknown }]>
|
|
89
|
+
).filter(([name]) => !(managed?.has(name) ?? false))
|
|
90
|
+
|
|
91
|
+
const problems: Array<string> = []
|
|
92
|
+
for (const [name, field] of declared) {
|
|
93
|
+
const engineType = actualByName.get(name)
|
|
94
|
+
if (engineType === undefined) {
|
|
95
|
+
problems.push(`колонка «${name}» объявлена в схеме, но запрос её не возвращает`)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
const expected = familyOfAst(field.ast)
|
|
99
|
+
const got = familyOfEngineType(engineType)
|
|
100
|
+
if (expected !== "any" && got !== "any" && expected !== got) {
|
|
101
|
+
problems.push(`колонка «${name}»: схема ждёт ${expected}, запрос отдаёт ${engineType}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const declaredNames = new Set(declared.map(([name]) => name))
|
|
105
|
+
for (const column of actual) {
|
|
106
|
+
if (!declaredNames.has(column.name)) {
|
|
107
|
+
problems.push(`запрос возвращает колонку «${column.name}», которой нет в схеме`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (problems.length > 0) {
|
|
112
|
+
return yield* new SchemaMismatchError({ model: model.name.full, problems })
|
|
113
|
+
}
|
|
114
|
+
})
|
package/src/plan/diff.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { StateStore } from "../state/store.ts"
|
|
3
|
+
import type { StateError } from "../state/store.ts"
|
|
4
|
+
|
|
5
|
+
/** Чем окружения отличаются (SPEC §11: `efmesh diff <envA> <envB>`). */
|
|
6
|
+
export interface EnvDiff {
|
|
7
|
+
/** Модель есть только в A. */
|
|
8
|
+
readonly onlyInA: ReadonlyArray<string>
|
|
9
|
+
readonly onlyInB: ReadonlyArray<string>
|
|
10
|
+
/** Разные версии: имя + fp8 обеих сторон. */
|
|
11
|
+
readonly different: ReadonlyArray<{ readonly name: string; readonly a: string; readonly b: string }>
|
|
12
|
+
readonly same: ReadonlyArray<string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const diffEnvironments = (
|
|
16
|
+
envA: string,
|
|
17
|
+
envB: string,
|
|
18
|
+
): Effect.Effect<EnvDiff, StateError, StateStore> =>
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const store = yield* StateStore
|
|
21
|
+
const a = new Map((yield* store.getEnvironment(envA)).map((r) => [r.name, r.fingerprint]))
|
|
22
|
+
const b = new Map((yield* store.getEnvironment(envB)).map((r) => [r.name, r.fingerprint]))
|
|
23
|
+
|
|
24
|
+
const onlyInA: Array<string> = []
|
|
25
|
+
const different: Array<{ name: string; a: string; b: string }> = []
|
|
26
|
+
const same: Array<string> = []
|
|
27
|
+
for (const [name, fpA] of a) {
|
|
28
|
+
const fpB = b.get(name)
|
|
29
|
+
if (fpB === undefined) onlyInA.push(name)
|
|
30
|
+
else if (fpA === fpB) same.push(name)
|
|
31
|
+
else different.push({ name, a: fpA.slice(0, 8), b: fpB.slice(0, 8) })
|
|
32
|
+
}
|
|
33
|
+
const onlyInB = [...b.keys()].filter((name) => !a.has(name))
|
|
34
|
+
|
|
35
|
+
return { onlyInA, onlyInB, different, same }
|
|
36
|
+
})
|