@avytheone/efmesh 0.1.0-beta.1 → 0.2.0

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/src/plan/diff.ts CHANGED
@@ -1,4 +1,11 @@
1
- import { Effect } from "effect"
1
+ import { Data, Effect } from "effect"
2
+ import type { GraphError } from "../core/graph.ts"
3
+ import { buildGraph } from "../core/graph.ts"
4
+ import type { AnyModel } from "../core/model.ts"
5
+ import { quoteIdent } from "../core/sql.ts"
6
+ import { EngineAdapter } from "../engine/adapter.ts"
7
+ import type { EngineError } from "../engine/adapter.ts"
8
+ import { viewRef } from "./naming.ts"
2
9
  import { StateStore } from "../state/store.ts"
3
10
  import type { StateError } from "../state/store.ts"
4
11
 
@@ -34,3 +41,216 @@ export const diffEnvironments = (
34
41
 
35
42
  return { onlyInA, onlyInB, different, same }
36
43
  })
44
+
45
+ /**
46
+ * Сравнение ДАННЫХ двух окружений (#6, класс table_diff sqlmesh): счётчики
47
+ * строк, пересечение по ключу, помодельные доли расхождений по колонкам —
48
+ * между view-слоями A и B одной базы. На DuckDB-классе данных это дёшево;
49
+ * для больших таблиц — детерминированная выборка по md5 ключа: обе стороны
50
+ * фильтруются одинаковыми бакетами, поэтому выборка выровнена и пары
51
+ * ключей не теряются.
52
+ */
53
+
54
+ export interface ColumnDrift {
55
+ readonly column: string
56
+ /** Сколько сопоставленных ключей разошлись в этой колонке. */
57
+ readonly mismatches: number
58
+ /** Доля от matched, 0..1. */
59
+ readonly rate: number
60
+ }
61
+
62
+ export interface ModelDataDiff {
63
+ readonly model: string
64
+ /** Полные счётчики строк (не задеты выборкой). */
65
+ readonly rowsA: number
66
+ readonly rowsB: number
67
+ /** Ключ сопоставления: grain или ключ вида (uniqueKey/scdType2). */
68
+ readonly key?: ReadonlyArray<string>
69
+ /** Дальше — только при ключе; при выборке счётчики по выбранным бакетам. */
70
+ readonly onlyInA?: number
71
+ readonly onlyInB?: number
72
+ readonly matched?: number
73
+ readonly columns?: ReadonlyArray<ColumnDrift>
74
+ /** Колонки только одной стороны — дрейф схемы между окружениями. */
75
+ readonly columnsOnlyInA?: ReadonlyArray<string>
76
+ readonly columnsOnlyInB?: ReadonlyArray<string>
77
+ /** Процент md5-бакетов ключа в сравнении; нет поля — сравнение полное. */
78
+ readonly sampledPercent?: number
79
+ }
80
+
81
+ export interface DataDiffReport {
82
+ readonly envA: string
83
+ readonly envB: string
84
+ readonly models: ReadonlyArray<ModelDataDiff>
85
+ }
86
+
87
+ export interface DataDiffOptions {
88
+ /** Только эти модели; по умолчанию — все материализуемые из обоих окружений. */
89
+ readonly models?: ReadonlyArray<string>
90
+ /** 1–99: сравнивать долю ключей (md5-бакеты, выровнено между сторонами). */
91
+ readonly samplePercent?: number
92
+ }
93
+
94
+ export class DataDiffError extends Data.TaggedError("DataDiffError")<{
95
+ readonly model: string
96
+ readonly reason: string
97
+ }> {}
98
+
99
+ /** Виды с view-слоем в окружении — их данные есть с чем сравнивать. */
100
+ const COMPARABLE_KINDS: ReadonlySet<string> = new Set([
101
+ "full",
102
+ "view",
103
+ "incrementalByTimeRange",
104
+ "incrementalByUniqueKey",
105
+ "scdType2",
106
+ "seed",
107
+ ])
108
+
109
+ const keyOf = (model: AnyModel): ReadonlyArray<string> | undefined => {
110
+ if (model.grain !== undefined && model.grain.length > 0) return model.grain
111
+ if (model.kind._tag === "incrementalByUniqueKey" || model.kind._tag === "scdType2") {
112
+ return model.kind.key
113
+ }
114
+ return undefined
115
+ }
116
+
117
+ /**
118
+ * Детерминированный фильтр выборки: md5 от склейки ключа, первые два
119
+ * hex-символа = 256 бакетов. Работает и на DuckDB, и на Postgres,
120
+ * одинаково на обеих сторонах diff'а.
121
+ */
122
+ const samplePredicate = (key: ReadonlyArray<string>, percent: number): string => {
123
+ const buckets = Math.max(1, Math.min(255, Math.floor((256 * percent) / 100)))
124
+ const threshold = buckets.toString(16).padStart(2, "0")
125
+ return `substr(md5(concat_ws('|', ${key.map(quoteIdent).join(", ")})), 1, 2) < '${threshold}'`
126
+ }
127
+
128
+ const asCount = (value: unknown): number => Number(value ?? 0)
129
+
130
+ export const dataDiffEnvironments = (
131
+ envA: string,
132
+ envB: string,
133
+ models: Iterable<AnyModel>,
134
+ options?: DataDiffOptions,
135
+ ): Effect.Effect<
136
+ DataDiffReport,
137
+ GraphError | StateError | EngineError | DataDiffError,
138
+ StateStore | EngineAdapter
139
+ > =>
140
+ Effect.gen(function* () {
141
+ const store = yield* StateStore
142
+ const engine = yield* EngineAdapter
143
+ const graph = yield* buildGraph(models)
144
+ const inA = new Set((yield* store.getEnvironment(envA)).map((row) => row.name))
145
+ const inB = new Set((yield* store.getEnvironment(envB)).map((row) => row.name))
146
+ for (const name of options?.models ?? []) {
147
+ if (!graph.models.has(name)) {
148
+ return yield* new DataDiffError({ model: name, reason: "модели нет в проекте" })
149
+ }
150
+ if (!inA.has(name) || !inB.has(name)) {
151
+ return yield* new DataDiffError({
152
+ model: name,
153
+ reason: `модели нет в окружении ${inA.has(name) ? envB : envA}`,
154
+ })
155
+ }
156
+ }
157
+ const wanted = options?.models === undefined ? undefined : new Set(options.models)
158
+ const percent = options?.samplePercent
159
+
160
+ const reports: Array<ModelDataDiff> = []
161
+ for (const name of graph.order) {
162
+ if (wanted !== undefined && !wanted.has(name)) continue
163
+ if (!inA.has(name) || !inB.has(name)) continue
164
+ const model = graph.models.get(name)!
165
+ if (!COMPARABLE_KINDS.has(model.kind._tag)) continue
166
+ const refA = viewRef(envA, model.name)
167
+ const refB = viewRef(envB, model.name)
168
+
169
+ const rowsA = asCount(
170
+ (yield* engine.query(`SELECT count(*) AS n FROM ${refA}`))[0]?.["n"],
171
+ )
172
+ const rowsB = asCount(
173
+ (yield* engine.query(`SELECT count(*) AS n FROM ${refB}`))[0]?.["n"],
174
+ )
175
+
176
+ // реальные колонки обеих сторон: окружения могут указывать на разные
177
+ // версии модели — сравниваются только общие, дрейф схемы фиксируется
178
+ const colsA = (yield* engine.describe(`SELECT * FROM ${refA}`)).map((c) => c.name)
179
+ const colsB = (yield* engine.describe(`SELECT * FROM ${refB}`)).map((c) => c.name)
180
+ const setB = new Set(colsB)
181
+ const setA = new Set(colsA)
182
+ const common = colsA.filter((column) => setB.has(column))
183
+ const columnsOnlyInA = colsA.filter((column) => !setB.has(column))
184
+ const columnsOnlyInB = colsB.filter((column) => !setA.has(column))
185
+
186
+ const key = keyOf(model)
187
+ const commonSet = new Set(common)
188
+ if (key === undefined || !key.every((column) => commonSet.has(column))) {
189
+ // сопоставлять нечем (нет grain/ключа или ключ не на обеих сторонах) —
190
+ // честные счётчики строк без пары
191
+ reports.push({
192
+ model: name,
193
+ rowsA,
194
+ rowsB,
195
+ ...(columnsOnlyInA.length > 0 ? { columnsOnlyInA } : {}),
196
+ ...(columnsOnlyInB.length > 0 ? { columnsOnlyInB } : {}),
197
+ })
198
+ continue
199
+ }
200
+
201
+ const keySet = new Set(key)
202
+ const compared = common.filter((column) => !keySet.has(column))
203
+ const sample = percent === undefined ? "" : ` WHERE ${samplePredicate(key, percent)}`
204
+ const pairs = compared
205
+ .map(
206
+ (column, index) =>
207
+ `, a.${quoteIdent(column)} AS a_${index}, b.${quoteIdent(column)} AS b_${index}`,
208
+ )
209
+ .join("")
210
+ const mismatches = compared
211
+ .map(
212
+ (_, index) =>
213
+ `, count(*) FILTER (WHERE in_a AND in_b AND (a_${index} IS DISTINCT FROM b_${index})) AS mm_${index}`,
214
+ )
215
+ .join("")
216
+ const keyList = key.map(quoteIdent).join(", ")
217
+ const [row] = yield* engine.query(`
218
+ SELECT
219
+ count(*) FILTER (WHERE NOT in_b) AS only_a,
220
+ count(*) FILTER (WHERE NOT in_a) AS only_b,
221
+ count(*) FILTER (WHERE in_a AND in_b) AS matched
222
+ ${mismatches}
223
+ FROM (
224
+ SELECT coalesce(a.in_a, FALSE) AS in_a, coalesce(b.in_b, FALSE) AS in_b${pairs}
225
+ FROM (SELECT TRUE AS in_a, * FROM ${refA}${sample}) a
226
+ FULL OUTER JOIN (SELECT TRUE AS in_b, * FROM ${refB}${sample}) b
227
+ USING (${keyList})
228
+ ) j
229
+ `)
230
+ const matched = asCount(row?.["matched"])
231
+ const columns = compared
232
+ .map((column, index) => {
233
+ const drifted = asCount(row?.[`mm_${index}`])
234
+ return {
235
+ column,
236
+ mismatches: drifted,
237
+ rate: matched === 0 ? 0 : drifted / matched,
238
+ }
239
+ })
240
+ .filter((drift) => drift.mismatches > 0)
241
+ reports.push({
242
+ model: name,
243
+ rowsA,
244
+ rowsB,
245
+ key,
246
+ onlyInA: asCount(row?.["only_a"]),
247
+ onlyInB: asCount(row?.["only_b"]),
248
+ matched,
249
+ columns,
250
+ ...(columnsOnlyInA.length > 0 ? { columnsOnlyInA } : {}),
251
+ ...(columnsOnlyInB.length > 0 ? { columnsOnlyInB } : {}),
252
+ ...(percent !== undefined ? { sampledPercent: percent } : {}),
253
+ })
254
+ }
255
+ return { envA, envB, models: reports }
256
+ }).pipe(Effect.withSpan("efmesh.diff.data", { attributes: { envA, envB } }))
@@ -32,6 +32,7 @@ import {
32
32
  import type {
33
33
  FingerprintVersionError,
34
34
  ForwardOnlyError,
35
+ ReclassifyError,
35
36
  InvalidEnvironmentError,
36
37
  Plan,
37
38
  PlanAction,
@@ -66,6 +67,7 @@ export type ApplyError =
66
67
  | AuditFailure
67
68
  | InvalidEnvironmentError
68
69
  | ForwardOnlyError
70
+ | ReclassifyError
69
71
  | FingerprintVersionError
70
72
  | EngineFeatureError
71
73
 
@@ -546,6 +548,22 @@ export const applyPlan = (
546
548
  const body = render(model.fragment, { resolveRef })
547
549
  // контракт схемы (SPEC §3.2): дрейф типов ловится до сборки
548
550
  yield* checkContract(engine, model, body)
551
+ // indirect-реюз (#5): данные идентичны по построению (родители
552
+ // non-breaking/forward-only, своё тело не менялось) — пересборка
553
+ // пропускается, аудиты гоняются по унаследованной физике
554
+ if (model.kind._tag === "full" && action.reusedFrom !== undefined) {
555
+ yield* runAudits(engine, model, physicalFor(model, action.physicalFingerprint))
556
+ yield* store.upsertSnapshot({
557
+ name: action.name,
558
+ fingerprint: action.fingerprint,
559
+ physicalFp: action.physicalFingerprint,
560
+ canonicalAst: action.canonicalAst ?? "",
561
+ renderedSql: body,
562
+ kind: model.kind._tag,
563
+ fingerprintVersion: FINGERPRINT_VERSION,
564
+ })
565
+ break
566
+ }
549
567
  if (model.kind._tag === "full" && model.target === "parquet") {
550
568
  const prefix = parquetPrefix(lakePath!, model.name, action.physicalFingerprint)
551
569
  yield* ensureDir(prefix)
@@ -748,6 +766,8 @@ export const applyPlan = (
748
766
  actions: plan.actions.map((a) => ({
749
767
  name: a.name,
750
768
  change: a.change,
769
+ // override оператора (#5) в журнале: видно, кто и что заявил
770
+ ...(a.reclassifiedFrom !== undefined ? { reclassifiedFrom: a.reclassifiedFrom } : {}),
751
771
  fingerprint: a.fingerprint.slice(0, 8),
752
772
  build: a.build,
753
773
  backfill: a.backfill.map((r) => `[${toIso(r.start)}, ${toIso(r.end)})`),
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Объяснение категоризации (#4, SPEC §5.2): `plan --explain` показывает,
3
+ * КАКИЕ узлы канонического AST разошлись и почему категория именно такая.
4
+ * Пути идут по дереву канона движка (json_serialize_sql у DuckDB,
5
+ * libpg_query у Postgres) — это отладочная подсказка, а не контракт:
6
+ * форма путей меняется вместе с каноном и semver-события не образует.
7
+ * Категорию считает categorize.ts; здесь — только её обоснование.
8
+ */
9
+
10
+ import { topSelect } from "./categorize.ts"
11
+
12
+ export interface ChangeExplanation {
13
+ /** Пути разошедшихся узлов канонического AST (не больше MAX_DIVERGED). */
14
+ readonly diverged: ReadonlyArray<string>
15
+ /** Почему категория именно такая. */
16
+ readonly reason: string
17
+ /** Изменившиеся прямые родители — источник каскада indirect/forward-only. */
18
+ readonly cascadeFrom?: ReadonlyArray<string>
19
+ }
20
+
21
+ /** Дальше точечные пути перестают помогать — лучше открыть render/diff. */
22
+ const MAX_DIVERGED = 8
23
+
24
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
25
+ typeof value === "object" && value !== null && !Array.isArray(value)
26
+
27
+ const at = (path: string, segment: string): string =>
28
+ path === "" ? segment : `${path}.${segment}`
29
+
30
+ const walk = (before: unknown, after: unknown, path: string, out: Array<string>): void => {
31
+ if (out.length >= MAX_DIVERGED) return
32
+ if (JSON.stringify(before) === JSON.stringify(after)) return
33
+ if (Array.isArray(before) && Array.isArray(after)) {
34
+ const shared = Math.min(before.length, after.length)
35
+ for (let i = 0; i < shared; i++) walk(before[i], after[i], `${path}[${i}]`, out)
36
+ for (let i = shared; i < after.length && out.length < MAX_DIVERGED; i++) {
37
+ out.push(`${path}[${i}] (добавлен)`)
38
+ }
39
+ for (let i = shared; i < before.length && out.length < MAX_DIVERGED; i++) {
40
+ out.push(`${path}[${i}] (удалён)`)
41
+ }
42
+ return
43
+ }
44
+ if (isRecord(before) && isRecord(after)) {
45
+ // узел заменён выражением другого типа — точечные пути внутри бессмысленны
46
+ if (before["type"] !== after["type"]) {
47
+ out.push(path === "" ? "(корень)" : path)
48
+ return
49
+ }
50
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()
51
+ for (const key of keys) walk(before[key], after[key], at(path, key), out)
52
+ return
53
+ }
54
+ out.push(path === "" ? "(корень)" : path)
55
+ }
56
+
57
+ /** Служебная обёртка стейтмента в пути не интересна — режем до тела запроса. */
58
+ const trimStatement = (path: string): string =>
59
+ path.replace(/^statements\[0\]\.node\.?/, "").replace(/^stmts\[0\]\.stmt\.?/, "") ||
60
+ "(корень)"
61
+
62
+ /** Пути расхождения двух канонических AST (JSON-строки); мусор на входе — пусто. */
63
+ export const divergedPaths = (oldAst: string, newAst: string): ReadonlyArray<string> => {
64
+ try {
65
+ const out: Array<string> = []
66
+ walk(JSON.parse(oldAst), JSON.parse(newAst), "", out)
67
+ return out.map(trimStatement)
68
+ } catch {
69
+ return []
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Гвардрейл override'а (#5): в новом верхнем SELECT колонок меньше — они
75
+ * удалены, потомки читают их по именам, «non-breaking» очевидно противоречит
76
+ * AST. Непарсибельный канон противоречием не считается: решение — за
77
+ * оператором, override явный и журналируется.
78
+ */
79
+ export const dropsColumns = (oldAst: string, newAst: string): boolean => {
80
+ const before = topSelect(oldAst)
81
+ const after = topSelect(newAst)
82
+ if (before === null || after === null) return false
83
+ return after.list.length < before.list.length
84
+ }
85
+
86
+ /**
87
+ * Обоснование вердикта categorizeAstChange теми же правилами, которыми он
88
+ * вынесен (SPEC §5.2): non-breaking — только суффикс верхнего SELECT.
89
+ */
90
+ export const explainCategorized = (
91
+ oldAst: string,
92
+ newAst: string,
93
+ change: "breaking" | "non-breaking",
94
+ ): ChangeExplanation => {
95
+ const diverged = divergedPaths(oldAst, newAst)
96
+ if (change === "non-breaking") {
97
+ return {
98
+ diverged,
99
+ reason:
100
+ "колонки добавлены в конец верхнего SELECT, остальное дерево нетронуто — потребители читают по именам, пересборка не нужна",
101
+ }
102
+ }
103
+ const before = topSelect(oldAst)
104
+ const after = topSelect(newAst)
105
+ const reason =
106
+ before === null || after === null
107
+ ? "канонический AST неожиданной формы — консервативно breaking"
108
+ : before.rest !== after.rest
109
+ ? "дерево разошлось вне списка SELECT (FROM/WHERE/JOIN/GROUP BY/модификаторы)"
110
+ : after.list.length < before.list.length
111
+ ? "колонки удалены из SELECT — потомки вставляют по позициям, физику надо пересобирать"
112
+ : "список SELECT изменён не только хвостом — правка или перестановка колонок ломает позиции потомков"
113
+ return { diverged, reason }
114
+ }
@@ -89,9 +89,57 @@ export interface ModelVersion {
89
89
  readonly ast: string | null
90
90
  }
91
91
 
92
+ /**
93
+ * Кэш канонизации (#8): повторный plan почти целиком состоит из
94
+ * json_serialize_sql-раундтрипов по неизменённым моделям. Кэш — не
95
+ * данные: get/put обязаны быть безошибочными (промах/сбой = пересчёт).
96
+ */
97
+ export interface CanonCache {
98
+ readonly get: (key: string) => Effect.Effect<string | undefined>
99
+ readonly put: (key: string, canonical: string) => Effect.Effect<void>
100
+ }
101
+
102
+ /** Ключ кэша: версия алгоритма + диалект + исходник — апгрейд канона не маскируется. */
103
+ export const canonCacheKey = (dialect: string, source: string): string =>
104
+ sha256(`${FINGERPRINT_VERSION}:${dialect}:${source}`)
105
+
106
+ /** Модель в объёме, нужном fingerprint'у: kind, метаданные формы данных, цель. */
107
+ type FingerprintableModel = Parameters<typeof columnNames>[0] & {
108
+ readonly name: { readonly full: string }
109
+ readonly kind: ModelKind
110
+ readonly grain: ReadonlyArray<string> | undefined
111
+ readonly target: string | undefined
112
+ }
113
+
114
+ /**
115
+ * Fingerprint одной модели из готового AST и подписей родителей
116
+ * (`имя=fingerprint`, отсортированы). Нужен планировщику для проверки
117
+ * «версию сдвинули ТОЛЬКО родители» (#5): пересчёт со старыми подписями
118
+ * обязан дать старый fingerprint — иначе разошлись ещё и метаданные,
119
+ * и физику реюзать нельзя.
120
+ */
121
+ export const modelFingerprint = (
122
+ model: FingerprintableModel,
123
+ ast: string | null,
124
+ parents: ReadonlyArray<string>,
125
+ ): Effect.Effect<string, SeedReadError> =>
126
+ Effect.gen(function* () {
127
+ const payload = JSON.stringify({
128
+ ast,
129
+ kind: yield* kindPayload(model, model.kind),
130
+ grain: model.grain,
131
+ columns: columnNames(model),
132
+ // смена цели материализации = новая физика, потребители перечитают её
133
+ target: model.target,
134
+ parents,
135
+ })
136
+ return sha256(payload)
137
+ })
138
+
92
139
  /** Fingerprint всех моделей графа; транзитивность — через хэши родителей. */
93
140
  export const fingerprintGraph = (
94
141
  graph: ModelGraph,
142
+ cache?: CanonCache,
95
143
  ): Effect.Effect<
96
144
  ReadonlyMap<string, ModelVersion>,
97
145
  EngineError | SqlParseError | SeedReadError,
@@ -99,6 +147,16 @@ export const fingerprintGraph = (
99
147
  > =>
100
148
  Effect.gen(function* () {
101
149
  const engine = yield* EngineAdapter
150
+ const canonicalize = (source: string): Effect.Effect<string, EngineError | SqlParseError> =>
151
+ Effect.gen(function* () {
152
+ if (cache === undefined) return yield* engine.canonicalize(source)
153
+ const key = canonCacheKey(engine.dialect, source)
154
+ const hit = yield* cache.get(key)
155
+ if (hit !== undefined) return hit
156
+ const canon = yield* engine.canonicalize(source)
157
+ yield* cache.put(key, canon)
158
+ return canon
159
+ })
102
160
  const versions = new Map<string, ModelVersion>()
103
161
  for (const name of graph.order) {
104
162
  const model = graph.models.get(name)!
@@ -106,20 +164,11 @@ export const fingerprintGraph = (
106
164
  const ast =
107
165
  model.kind._tag === "external" || model.kind._tag === "seed"
108
166
  ? null
109
- : yield* engine.canonicalize(canonicalSql(graph, name))
167
+ : yield* canonicalize(canonicalSql(graph, name))
110
168
  const parents = [...model.deps]
111
169
  .sort()
112
170
  .map((dep) => `${dep}=${versions.get(dep)!.fingerprint}`)
113
- const payload = JSON.stringify({
114
- ast,
115
- kind: yield* kindPayload(model, model.kind),
116
- grain: model.grain,
117
- columns: columnNames(model),
118
- // смена цели материализации = новая физика, потребители перечитают её
119
- target: model.target,
120
- parents,
121
- })
122
- versions.set(name, { fingerprint: sha256(payload), ast })
171
+ versions.set(name, { fingerprint: yield* modelFingerprint(model, ast, parents), ast })
123
172
  }
124
173
  return versions
125
174
  })