@avytheone/efmesh 0.1.0-beta.2 → 0.2.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.
@@ -8,13 +8,13 @@ import type { EngineError } from "../engine/adapter.ts"
8
8
  import { externalSourceRef, viewRef } from "./naming.ts"
9
9
 
10
10
  /**
11
- * Автономный прогон аудитов (SPEC §8, F4): проверяет то, что окружение
12
- * отдаёт потребителям СЕЙЧАС — view-слой целиком, не свежезагруженный
13
- * интервал, как в apply. Ловит деградацию задним числом: поздние данные
14
- * через lookback, руками поправленную физику, дрейф external-источников.
11
+ * Standalone audit run (SPEC §8, F4): checks what the environment serves to
12
+ * consumers RIGHT NOWthe whole view-layer, not the freshly loaded interval
13
+ * as in apply. Catches degradation after the fact: late data via lookback,
14
+ * physical storage edited by hand, drift of external sources.
15
15
  *
16
- * Ничего не меняет и не помечает; отчёт целикомупавший blocking-аудит
17
- * не прячет следующие за ним.
16
+ * Changes and marks nothing; the report is completea failed blocking audit
17
+ * does not hide the ones that follow it.
18
18
  */
19
19
 
20
20
  export interface AuditRunResult {
@@ -26,21 +26,29 @@ export interface AuditRunResult {
26
26
 
27
27
  export interface AuditRunReport {
28
28
  readonly results: ReadonlyArray<AuditRunResult>
29
- /** Суммарные нарушения blocking-аудитовне ноль окружению нельзя верить. */
29
+ /** Total blocking-audit violations nonzerothe environment cannot be trusted. */
30
30
  readonly blockingViolations: number
31
31
  }
32
32
 
33
- /** Запрошенной модели нет в проекте (или у неё нет аудитов нечего гонять). */
33
+ /** The requested model is not in the project (or has no auditsnothing to run). */
34
34
  export class AuditTargetError extends Data.TaggedError("AuditTargetError")<{
35
35
  readonly model: string
36
36
  readonly reason: string
37
- }> {}
37
+ }> {
38
+ override get message(): string {
39
+ return `audit target «${this.model}»: ${this.reason}`
40
+ }
41
+ }
38
42
 
39
- /** Итог для CLI: blocking-аудиты окружения нарушены. */
43
+ /** Result for the CLI: the environment's blocking audits are violated. */
40
44
  export class EnvironmentAuditError extends Data.TaggedError("EnvironmentAuditError")<{
41
45
  readonly env: string
42
46
  readonly blockingViolations: number
43
- }> {}
47
+ }> {
48
+ override get message(): string {
49
+ return `environment «${this.env}»: ${this.blockingViolations} blocking audit violation(s)`
50
+ }
51
+ }
44
52
 
45
53
  const selfFor = (graph: ModelGraph, env: string, model: AnyModel): string => {
46
54
  const resolve = (ref: string): string => {
@@ -51,7 +59,7 @@ const selfFor = (graph: ModelGraph, env: string, model: AnyModel): string => {
51
59
  }
52
60
  return viewRef(env, source.name)
53
61
  }
54
- // embedded не материализуетсяаудируется подзапрос против view окружения
62
+ // embedded is not materialized the subquery is audited against the environment's view
55
63
  if (model.kind._tag === "embedded") {
56
64
  return `(${render(model.fragment, { resolveRef: resolve })})`
57
65
  }
@@ -68,7 +76,7 @@ export const auditEnvironment = (
68
76
  const graph = yield* buildGraph(models)
69
77
  for (const name of only ?? []) {
70
78
  if (!graph.models.has(name)) {
71
- return yield* new AuditTargetError({ model: name, reason: "модели нет в проекте" })
79
+ return yield* new AuditTargetError({ model: name, reason: "model is not in the project" })
72
80
  }
73
81
  }
74
82
  const wanted = only === undefined ? undefined : new Set(only)
@@ -1,14 +1,17 @@
1
1
  /**
2
- * Категоризация изменения по каноническим AST (SPEC §5.2): non-breaking
3
- * это добавление колонок В КОНЕЦ верхнего SELECT при полностью неизменном
4
- * остальном дереве (потребители выбирают по именам, INSERT потомков по
5
- * позициям, поэтому только суффикс безопасен). Всё прочее breaking.
6
- * При любой неожиданной структуре консервативно breaking.
2
+ * Change categorization by canonical ASTs (SPEC §5.2): non-breaking is adding
3
+ * columns TO THE END of the top SELECT with the rest of the tree entirely
4
+ * unchanged (consumers select by name, descendants' INSERT is by position, so
5
+ * only a suffix is safe). Everything else is breaking. On any unexpected
6
+ * structureconservatively breaking.
7
7
  */
8
8
 
9
9
  type AstNode = Record<string, unknown>
10
10
 
11
- const topSelect = (astJson: string): { list: ReadonlyArray<string>; rest: string } | null => {
11
+ /** The canon's top SELECT: the column list + the rest of the tree (for explain.ts). */
12
+ export const topSelect = (
13
+ astJson: string,
14
+ ): { list: ReadonlyArray<string>; rest: string } | null => {
12
15
  try {
13
16
  const ast = JSON.parse(astJson) as {
14
17
  readonly statements?: ReadonlyArray<{ readonly node?: AstNode }>
@@ -34,7 +37,7 @@ export const categorizeAstChange = (
34
37
  if (before === null || after === null) return "breaking"
35
38
  if (before.rest !== after.rest) return "breaking"
36
39
  if (after.list.length <= before.list.length) return "breaking"
37
- // старый select_list точный префикс нового
40
+ // the old select_list is an exact prefix of the new one
38
41
  return before.list.every((item, index) => item === after.list[index])
39
42
  ? "non-breaking"
40
43
  : "breaking"
@@ -3,24 +3,28 @@ import type { AnyModel } from "../core/model.ts"
3
3
  import type { Engine, EngineError } from "../engine/adapter.ts"
4
4
 
5
5
  /**
6
- * Контракт схемы (SPEC §3.2): объявленная Schema не документация.
7
- * Перед сборкой снапшота efmesh делает DESCRIBE запроса (движок отдаёт
8
- * имена и типы, не выполняя его) и сверяет с объявлением. Дрейф типов
9
- * ловится до бэкфилла, а не после.
6
+ * Schema contract (SPEC §3.2): the declared Schema is not documentation.
7
+ * Before building a snapshot efmesh runs a DESCRIBE of the query (the engine
8
+ * returns names and types without executing it) and checks it against the
9
+ * declaration. Type drift is caught before the backfill, not after.
10
10
  *
11
- * Сверка по семействам типов: точное соответствие TS-типов типам движка
12
- * невозможно (Number это и INTEGER, и DOUBLE), а имена проверяются
13
- * строго. Nullability DESCRIBE не отдаётэто территория аудитов (§14.2).
11
+ * Checked by type families: an exact match of TS types to engine types is
12
+ * impossible (Number is both INTEGER and DOUBLE), while names are checked
13
+ * strictly. DESCRIBE does not report nullability that is audit territory (§14.2).
14
14
  */
15
15
 
16
16
  export class SchemaMismatchError extends Data.TaggedError("SchemaMismatchError")<{
17
17
  readonly model: string
18
18
  readonly problems: ReadonlyArray<string>
19
- }> {}
19
+ }> {
20
+ override get message(): string {
21
+ return `schema contract of model «${this.model}»: ${this.problems.join("; ")}`
22
+ }
23
+ }
20
24
 
21
25
  export type TypeFamily = "text" | "numeric" | "boolean" | "temporal" | "any"
22
26
 
23
- /** Семейство, ожидаемое от поля Effect Schema (по AST). Реюз: testModel рендерит фикстуры по нему. */
27
+ /** Family expected from an Effect Schema field (by AST). Reuse: testModel renders fixtures from it. */
24
28
  export const familyOfAst = (ast: unknown): TypeFamily => {
25
29
  const node = ast as {
26
30
  readonly _tag: string
@@ -40,7 +44,7 @@ export const familyOfAst = (ast: unknown): TypeFamily => {
40
44
  return constructorTag.startsWith("effect/DateTime") ? "temporal" : "any"
41
45
  }
42
46
  case "Union": {
43
- // NullOr(X) и подобные: единственное распознанное семейство среди членов
47
+ // NullOr(X) and the like: the single recognized family among the members
44
48
  const families = new Set(
45
49
  (node.types ?? [])
46
50
  .map(familyOfAst)
@@ -53,7 +57,7 @@ export const familyOfAst = (ast: unknown): TypeFamily => {
53
57
  }
54
58
  }
55
59
 
56
- /** Семейство фактического типа DuckDB из DESCRIBE. */
60
+ /** Family of an actual DuckDB type from DESCRIBE. */
57
61
  const familyOfEngineType = (engineType: string): TypeFamily => {
58
62
  const base = engineType.toUpperCase()
59
63
  if (base === "VARCHAR" || base.startsWith("VARCHAR(")) return "text"
@@ -69,11 +73,11 @@ const familyOfEngineType = (engineType: string): TypeFamily => {
69
73
  }
70
74
 
71
75
  /**
72
- * Сверяет объявленную схему модели с фактическим результатом запроса.
73
- * `renderedSql` — исполнимый рендер тела (ссылки уже в физике/источниках).
74
- * `managed` — колонки, которые efmesh ведёт сам (scdType2: valid_from/
75
- * valid_to): в схеме объявлены потребители их видят, но запрос
76
- * их не возвращает.
76
+ * Checks the model's declared schema against the query's actual result.
77
+ * `renderedSql` — the executable render of the body (references already point
78
+ * at physical storage/sources). `managed` — columns efmesh maintains itself
79
+ * (scdType2: valid_from/valid_to): declared in the schemaconsumers see
80
+ * them but the query does not return them.
77
81
  */
78
82
  export const checkContract = (
79
83
  engine: Engine,
@@ -92,19 +96,19 @@ export const checkContract = (
92
96
  for (const [name, field] of declared) {
93
97
  const engineType = actualByName.get(name)
94
98
  if (engineType === undefined) {
95
- problems.push(`колонка «${name}» объявлена в схеме, но запрос её не возвращает`)
99
+ problems.push(`column «${name}» is declared in the schema but the query does not return it`)
96
100
  continue
97
101
  }
98
102
  const expected = familyOfAst(field.ast)
99
103
  const got = familyOfEngineType(engineType)
100
104
  if (expected !== "any" && got !== "any" && expected !== got) {
101
- problems.push(`колонка «${name}»: схема ждёт ${expected}, запрос отдаёт ${engineType}`)
105
+ problems.push(`column «${name}»: schema expects ${expected}, query returns ${engineType}`)
102
106
  }
103
107
  }
104
108
  const declaredNames = new Set(declared.map(([name]) => name))
105
109
  for (const column of actual) {
106
110
  if (!declaredNames.has(column.name)) {
107
- problems.push(`запрос возвращает колонку «${column.name}», которой нет в схеме`)
111
+ problems.push(`query returns column «${column.name}» which is not in the schema`)
108
112
  }
109
113
  }
110
114
 
package/src/plan/diff.ts CHANGED
@@ -1,13 +1,20 @@
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
 
5
- /** Чем окружения отличаются (SPEC §11: `efmesh diff <envA> <envB>`). */
12
+ /** How two environments differ (SPEC §11: `efmesh diff <envA> <envB>`). */
6
13
  export interface EnvDiff {
7
- /** Модель есть только в A. */
14
+ /** Model present only in A. */
8
15
  readonly onlyInA: ReadonlyArray<string>
9
16
  readonly onlyInB: ReadonlyArray<string>
10
- /** Разные версии: имя + fp8 обеих сторон. */
17
+ /** Differing versions: name + fp8 of both sides. */
11
18
  readonly different: ReadonlyArray<{ readonly name: string; readonly a: string; readonly b: string }>
12
19
  readonly same: ReadonlyArray<string>
13
20
  }
@@ -34,3 +41,220 @@ export const diffEnvironments = (
34
41
 
35
42
  return { onlyInA, onlyInB, different, same }
36
43
  })
44
+
45
+ /**
46
+ * DATA comparison of two environments (#6, sqlmesh's table_diff class): row
47
+ * counts, intersection by key, per-model column-mismatch rates — between the
48
+ * A and B view-layers of one database. On DuckDB-class data this is cheap;
49
+ * for large tables — a deterministic sample by md5 of the key: both sides are
50
+ * filtered by the same buckets, so the sample is aligned and key pairs are
51
+ * not lost.
52
+ */
53
+
54
+ export interface ColumnDrift {
55
+ readonly column: string
56
+ /** How many matched keys diverged in this column. */
57
+ readonly mismatches: number
58
+ /** Fraction of matched, 0..1. */
59
+ readonly rate: number
60
+ }
61
+
62
+ export interface ModelDataDiff {
63
+ readonly model: string
64
+ /** Full row counts (not affected by sampling). */
65
+ readonly rowsA: number
66
+ readonly rowsB: number
67
+ /** Matching key: grain or a kind's key (uniqueKey/scdType2). */
68
+ readonly key?: ReadonlyArray<string>
69
+ /** Below — only when there is a key; under sampling, counts over selected buckets. */
70
+ readonly onlyInA?: number
71
+ readonly onlyInB?: number
72
+ readonly matched?: number
73
+ readonly columns?: ReadonlyArray<ColumnDrift>
74
+ /** Columns present on only one side — schema drift between environments. */
75
+ readonly columnsOnlyInA?: ReadonlyArray<string>
76
+ readonly columnsOnlyInB?: ReadonlyArray<string>
77
+ /** Percentage of the key's md5 buckets compared; no field — full comparison. */
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
+ /** Only these models; by default — all materializable ones from both environments. */
89
+ readonly models?: ReadonlyArray<string>
90
+ /** 1–99: compare a fraction of keys (md5 buckets, aligned across sides). */
91
+ readonly samplePercent?: number
92
+ }
93
+
94
+ export class DataDiffError extends Data.TaggedError("DataDiffError")<{
95
+ readonly model: string
96
+ readonly reason: string
97
+ }> {
98
+ override get message(): string {
99
+ return `data diff of model «${this.model}»: ${this.reason}`
100
+ }
101
+ }
102
+
103
+ /** Kinds with a view-layer in the environment — their data is comparable. */
104
+ const COMPARABLE_KINDS: ReadonlySet<string> = new Set([
105
+ "full",
106
+ "view",
107
+ "incrementalByTimeRange",
108
+ "incrementalByUniqueKey",
109
+ "scdType2",
110
+ "seed",
111
+ ])
112
+
113
+ const keyOf = (model: AnyModel): ReadonlyArray<string> | undefined => {
114
+ if (model.grain !== undefined && model.grain.length > 0) return model.grain
115
+ if (model.kind._tag === "incrementalByUniqueKey" || model.kind._tag === "scdType2") {
116
+ return model.kind.key
117
+ }
118
+ return undefined
119
+ }
120
+
121
+ /**
122
+ * Deterministic sampling filter: md5 of the concatenated key, first two
123
+ * hex chars = 256 buckets. Works on both DuckDB and Postgres, identically
124
+ * on both sides of the diff.
125
+ */
126
+ const samplePredicate = (key: ReadonlyArray<string>, percent: number): string => {
127
+ const buckets = Math.max(1, Math.min(255, Math.floor((256 * percent) / 100)))
128
+ const threshold = buckets.toString(16).padStart(2, "0")
129
+ return `substr(md5(concat_ws('|', ${key.map(quoteIdent).join(", ")})), 1, 2) < '${threshold}'`
130
+ }
131
+
132
+ const asCount = (value: unknown): number => Number(value ?? 0)
133
+
134
+ export const dataDiffEnvironments = (
135
+ envA: string,
136
+ envB: string,
137
+ models: Iterable<AnyModel>,
138
+ options?: DataDiffOptions,
139
+ ): Effect.Effect<
140
+ DataDiffReport,
141
+ GraphError | StateError | EngineError | DataDiffError,
142
+ StateStore | EngineAdapter
143
+ > =>
144
+ Effect.gen(function* () {
145
+ const store = yield* StateStore
146
+ const engine = yield* EngineAdapter
147
+ const graph = yield* buildGraph(models)
148
+ const inA = new Set((yield* store.getEnvironment(envA)).map((row) => row.name))
149
+ const inB = new Set((yield* store.getEnvironment(envB)).map((row) => row.name))
150
+ for (const name of options?.models ?? []) {
151
+ if (!graph.models.has(name)) {
152
+ return yield* new DataDiffError({ model: name, reason: "model is not in the project" })
153
+ }
154
+ if (!inA.has(name) || !inB.has(name)) {
155
+ return yield* new DataDiffError({
156
+ model: name,
157
+ reason: `model is not in environment "${inA.has(name) ? envB : envA}"`,
158
+ })
159
+ }
160
+ }
161
+ const wanted = options?.models === undefined ? undefined : new Set(options.models)
162
+ const percent = options?.samplePercent
163
+
164
+ const reports: Array<ModelDataDiff> = []
165
+ for (const name of graph.order) {
166
+ if (wanted !== undefined && !wanted.has(name)) continue
167
+ if (!inA.has(name) || !inB.has(name)) continue
168
+ const model = graph.models.get(name)!
169
+ if (!COMPARABLE_KINDS.has(model.kind._tag)) continue
170
+ const refA = viewRef(envA, model.name)
171
+ const refB = viewRef(envB, model.name)
172
+
173
+ const rowsA = asCount(
174
+ (yield* engine.query(`SELECT count(*) AS n FROM ${refA}`))[0]?.["n"],
175
+ )
176
+ const rowsB = asCount(
177
+ (yield* engine.query(`SELECT count(*) AS n FROM ${refB}`))[0]?.["n"],
178
+ )
179
+
180
+ // actual columns of both sides: environments may point at different
181
+ // model versions — only common ones are compared, schema drift is recorded
182
+ const colsA = (yield* engine.describe(`SELECT * FROM ${refA}`)).map((c) => c.name)
183
+ const colsB = (yield* engine.describe(`SELECT * FROM ${refB}`)).map((c) => c.name)
184
+ const setB = new Set(colsB)
185
+ const setA = new Set(colsA)
186
+ const common = colsA.filter((column) => setB.has(column))
187
+ const columnsOnlyInA = colsA.filter((column) => !setB.has(column))
188
+ const columnsOnlyInB = colsB.filter((column) => !setA.has(column))
189
+
190
+ const key = keyOf(model)
191
+ const commonSet = new Set(common)
192
+ if (key === undefined || !key.every((column) => commonSet.has(column))) {
193
+ // nothing to match on (no grain/key, or the key is not on both sides) —
194
+ // honest row counts without pairing
195
+ reports.push({
196
+ model: name,
197
+ rowsA,
198
+ rowsB,
199
+ ...(columnsOnlyInA.length > 0 ? { columnsOnlyInA } : {}),
200
+ ...(columnsOnlyInB.length > 0 ? { columnsOnlyInB } : {}),
201
+ })
202
+ continue
203
+ }
204
+
205
+ const keySet = new Set(key)
206
+ const compared = common.filter((column) => !keySet.has(column))
207
+ const sample = percent === undefined ? "" : ` WHERE ${samplePredicate(key, percent)}`
208
+ const pairs = compared
209
+ .map(
210
+ (column, index) =>
211
+ `, a.${quoteIdent(column)} AS a_${index}, b.${quoteIdent(column)} AS b_${index}`,
212
+ )
213
+ .join("")
214
+ const mismatches = compared
215
+ .map(
216
+ (_, index) =>
217
+ `, count(*) FILTER (WHERE in_a AND in_b AND (a_${index} IS DISTINCT FROM b_${index})) AS mm_${index}`,
218
+ )
219
+ .join("")
220
+ const keyList = key.map(quoteIdent).join(", ")
221
+ const [row] = yield* engine.query(`
222
+ SELECT
223
+ count(*) FILTER (WHERE NOT in_b) AS only_a,
224
+ count(*) FILTER (WHERE NOT in_a) AS only_b,
225
+ count(*) FILTER (WHERE in_a AND in_b) AS matched
226
+ ${mismatches}
227
+ FROM (
228
+ SELECT coalesce(a.in_a, FALSE) AS in_a, coalesce(b.in_b, FALSE) AS in_b${pairs}
229
+ FROM (SELECT TRUE AS in_a, * FROM ${refA}${sample}) a
230
+ FULL OUTER JOIN (SELECT TRUE AS in_b, * FROM ${refB}${sample}) b
231
+ USING (${keyList})
232
+ ) j
233
+ `)
234
+ const matched = asCount(row?.["matched"])
235
+ const columns = compared
236
+ .map((column, index) => {
237
+ const drifted = asCount(row?.[`mm_${index}`])
238
+ return {
239
+ column,
240
+ mismatches: drifted,
241
+ rate: matched === 0 ? 0 : drifted / matched,
242
+ }
243
+ })
244
+ .filter((drift) => drift.mismatches > 0)
245
+ reports.push({
246
+ model: name,
247
+ rowsA,
248
+ rowsB,
249
+ key,
250
+ onlyInA: asCount(row?.["only_a"]),
251
+ onlyInB: asCount(row?.["only_b"]),
252
+ matched,
253
+ columns,
254
+ ...(columnsOnlyInA.length > 0 ? { columnsOnlyInA } : {}),
255
+ ...(columnsOnlyInB.length > 0 ? { columnsOnlyInB } : {}),
256
+ ...(percent !== undefined ? { sampledPercent: percent } : {}),
257
+ })
258
+ }
259
+ return { envA, envB, models: reports }
260
+ }).pipe(Effect.withSpan("efmesh.diff.data", { attributes: { envA, envB } }))