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