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