@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.
- package/CHANGELOG.md +171 -92
- package/README.md +83 -9
- package/README.ru.md +80 -5
- package/SPEC.md +542 -459
- package/package.json +4 -1
- package/src/bin.ts +39 -2
- package/src/cli.ts +505 -85
- package/src/config.ts +15 -15
- package/src/core/audit.ts +15 -11
- package/src/core/errors.ts +37 -7
- package/src/core/graph.ts +6 -6
- package/src/core/interval.ts +18 -18
- package/src/core/model.ts +76 -75
- package/src/core/sql.ts +21 -21
- package/src/discovery.ts +16 -8
- package/src/efmesh.ts +28 -15
- package/src/engine/adapter.ts +29 -12
- package/src/engine/duckdb.ts +7 -7
- package/src/engine/postgres.ts +17 -17
- package/src/error-text.ts +35 -0
- package/src/index.ts +44 -13
- package/src/init.ts +100 -28
- package/src/plan/audit-run.ts +21 -13
- package/src/plan/categorize.ts +10 -7
- package/src/plan/contract.ts +23 -19
- package/src/plan/diff.ts +228 -4
- package/src/plan/executor.ts +223 -121
- package/src/plan/explain.ts +114 -0
- package/src/plan/fingerprint.ts +84 -31
- package/src/plan/graph-html.ts +7 -7
- package/src/plan/janitor.ts +30 -25
- package/src/plan/lineage.ts +20 -16
- package/src/plan/lock.ts +27 -10
- package/src/plan/metrics.ts +4 -4
- package/src/plan/naming.ts +23 -23
- package/src/plan/planner.ts +238 -52
- package/src/plan/run.ts +72 -26
- package/src/plan/schedule.ts +217 -0
- package/src/plan/status.ts +96 -0
- package/src/state/postgres.ts +68 -19
- package/src/state/sqlite.ts +74 -27
- package/src/state/store.ts +88 -45
- package/src/testing/index.ts +27 -26
package/src/plan/naming.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { ExternalSource, ModelName } from "../core/model.ts"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
4
|
+
* Object layout in the engine (SPEC §2):
|
|
5
|
+
* - physical: schema `_efmesh`, table `<schema>__<table>__<fp8>`;
|
|
6
|
+
* - virtual: prod lives in the models' native schemas (`med.stays`),
|
|
7
|
+
* other environments — in prefixed ones (`dev__med.stays`).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* The schema is `_efmesh`, not `efmesh`, precisely because: DuckDB names the
|
|
10
|
+
* catalog after the database file name, and for `efmesh.duckdb` the reference
|
|
11
|
+
* `efmesh.x` becomes ambiguous (catalog or schema) — a Binder Error.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
export const PROD_ENV = "prod"
|
|
@@ -31,28 +31,28 @@ export const envSchema = (env: string, modelSchema: string): string =>
|
|
|
31
31
|
env === PROD_ENV ? modelSchema : `${env}__${modelSchema}`
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* Parquet-lake layout (SPEC §3.3): `<lake>/<schema>/<table>/fp=<fp8>/…`,
|
|
35
|
+
* for incremental inside — partitions `interval=<key>/data.parquet`.
|
|
36
|
+
* Interval = partition: a recompute rewrites the partition's files, the
|
|
37
|
+
* source of truth is the interval bookkeeping, so a non-atomic rewrite is harmless.
|
|
38
38
|
*/
|
|
39
39
|
export const parquetPrefix = (lakePath: string, name: ModelName, fingerprint: string): string =>
|
|
40
40
|
`${lakePath.replace(/\/+$/, "")}/${name.schema}/${name.table}/fp=${fp8(fingerprint)}`
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* union_by_name:
|
|
44
|
-
* forward-only
|
|
45
|
-
*
|
|
43
|
+
* union_by_name: partitions of one prefix may differ in schema after
|
|
44
|
+
* forward-only evolution (new columns appear only in new files — history is
|
|
45
|
+
* read with NULL).
|
|
46
46
|
*/
|
|
47
47
|
export const parquetRef = (lakePath: string, name: ModelName, fingerprint: string): string =>
|
|
48
48
|
`read_parquet('${parquetPrefix(lakePath, name, fingerprint).replaceAll(`'`, `''`)}/**/*.parquet', union_by_name=true)`
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* DuckLake
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
51
|
+
* DuckLake target (SPEC §14.5): the catalog is attached under a fixed alias,
|
|
52
|
+
* the physical storage is a fingerprint table in it (the same name as native
|
|
53
|
+
* physical storage, only a different catalog). Versioning stays ours;
|
|
54
|
+
* DuckLake snapshots/time travel are a bonus. Consumers outside efmesh must
|
|
55
|
+
* ATTACH it themselves — the environments' views reference the catalog by alias.
|
|
56
56
|
*/
|
|
57
57
|
export const ducklakeAlias = "_efmesh_ducklake"
|
|
58
58
|
|
|
@@ -69,7 +69,7 @@ export const ducklakeAttachSql = (config: {
|
|
|
69
69
|
: ""
|
|
70
70
|
}`
|
|
71
71
|
|
|
72
|
-
/**
|
|
72
|
+
/** Interval partition key — filesystem-safe (no colons). */
|
|
73
73
|
export const intervalKey = (unit: "day" | "hour", startMs: number): string => {
|
|
74
74
|
const iso = new Date(startMs).toISOString()
|
|
75
75
|
return unit === "day" ? iso.slice(0, 10) : `${iso.slice(0, 10)}T${iso.slice(11, 13)}`
|
|
@@ -78,9 +78,9 @@ export const intervalKey = (unit: "day" | "hour", startMs: number): string => {
|
|
|
78
78
|
const READERS = { parquet: "read_parquet", csv: "read_csv", json: "read_json" } as const
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
*
|
|
82
|
-
* (
|
|
83
|
-
* external
|
|
81
|
+
* What a reference to an external model renders to (SPEC §9.3): the table name
|
|
82
|
+
* (of the engine or an ATTACH database) as is, files/URLs — via read_*.
|
|
83
|
+
* external is not materialized, consumers read the source directly.
|
|
84
84
|
*/
|
|
85
85
|
export const externalSourceRef = (source: ExternalSource): string =>
|
|
86
86
|
source._tag === "table"
|
package/src/plan/planner.ts
CHANGED
|
@@ -8,47 +8,75 @@ import { EngineAdapter } from "../engine/adapter.ts"
|
|
|
8
8
|
import { StateStore } from "../state/store.ts"
|
|
9
9
|
import type { StateError } from "../state/store.ts"
|
|
10
10
|
import { categorizeAstChange } from "./categorize.ts"
|
|
11
|
-
import {
|
|
11
|
+
import type { ChangeExplanation } from "./explain.ts"
|
|
12
|
+
import { dropsColumns, explainCategorized } from "./explain.ts"
|
|
13
|
+
import { FINGERPRINT_VERSION, fingerprintGraph, modelFingerprint } from "./fingerprint.ts"
|
|
12
14
|
import { validateEnvName } from "./naming.ts"
|
|
13
15
|
|
|
14
16
|
export class InvalidEnvironmentError extends Data.TaggedError("InvalidEnvironmentError")<{
|
|
15
17
|
readonly env: string
|
|
16
|
-
}> {
|
|
18
|
+
}> {
|
|
19
|
+
override get message(): string {
|
|
20
|
+
return `invalid environment name «${this.env}» — use latin letters, digits and _`
|
|
21
|
+
}
|
|
22
|
+
}
|
|
17
23
|
|
|
18
|
-
/**
|
|
24
|
+
/** The model cannot be applied forward-only (SPEC §5.2). */
|
|
19
25
|
export class ForwardOnlyError extends Data.TaggedError("ForwardOnlyError")<{
|
|
20
26
|
readonly model: string
|
|
21
27
|
readonly reason: string
|
|
22
|
-
}> {
|
|
28
|
+
}> {
|
|
29
|
+
override get message(): string {
|
|
30
|
+
return `model «${this.model}» cannot be applied forward-only: ${this.reason}`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A categorization override is rejected (#5): the model is not in the project
|
|
36
|
+
* or the AST diff obviously contradicts the claimed verdict (removed columns
|
|
37
|
+
* are never non-breaking — descendants read them by name).
|
|
38
|
+
*/
|
|
39
|
+
export class ReclassifyError extends Data.TaggedError("ReclassifyError")<{
|
|
40
|
+
readonly model: string
|
|
41
|
+
readonly reason: string
|
|
42
|
+
}> {
|
|
43
|
+
override get message(): string {
|
|
44
|
+
return `reclassify «${this.model}»: ${this.reason}`
|
|
45
|
+
}
|
|
46
|
+
}
|
|
23
47
|
|
|
24
48
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
49
|
+
* The snapshot was computed by a different version of the fingerprint
|
|
50
|
+
* algorithm (SPEC §4): fingerprints of different versions are incomparable —
|
|
51
|
+
* the plan honestly stops instead of showing "everything breaking". Cured by
|
|
52
|
+
* migrating to the version of efmesh that changed the algorithm.
|
|
29
53
|
*/
|
|
30
54
|
export class FingerprintVersionError extends Data.TaggedError("FingerprintVersionError")<{
|
|
31
55
|
readonly model: string
|
|
32
56
|
readonly found: number
|
|
33
57
|
readonly wanted: number
|
|
34
|
-
}> {
|
|
58
|
+
}> {
|
|
59
|
+
override get message(): string {
|
|
60
|
+
return `model «${this.model}» was fingerprinted by algorithm v${this.found}, but this efmesh expects v${this.wanted}`
|
|
61
|
+
}
|
|
62
|
+
}
|
|
35
63
|
|
|
36
64
|
export type ChangeCategory =
|
|
37
|
-
/**
|
|
65
|
+
/** The model was not in the environment. */
|
|
38
66
|
| "added"
|
|
39
|
-
/**
|
|
67
|
+
/** A meaningful edit of the query or metadata: the model and its descendants rebuild. */
|
|
40
68
|
| "breaking"
|
|
41
|
-
/**
|
|
69
|
+
/** Columns appended to the end of the SELECT, the rest of the tree untouched (SPEC §5.2). */
|
|
42
70
|
| "non-breaking"
|
|
43
|
-
/**
|
|
71
|
+
/** The own AST did not change — the version shifted by cascade from a parent. */
|
|
44
72
|
| "indirect"
|
|
45
73
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
74
|
+
* The physics and done-intervals of the old version are reused, history is
|
|
75
|
+
* not replayed — the new logic takes effect from the moment of application
|
|
76
|
+
* (SPEC §5.2). An explicit user flag or a cascade from forward-only parents.
|
|
49
77
|
*/
|
|
50
78
|
| "forward-only"
|
|
51
|
-
/**
|
|
79
|
+
/** Was in the environment, vanished from the project — the view is dropped at promotion. */
|
|
52
80
|
| "removed"
|
|
53
81
|
| "unchanged"
|
|
54
82
|
|
|
@@ -56,50 +84,81 @@ export interface PlanAction {
|
|
|
56
84
|
readonly name: string
|
|
57
85
|
readonly fingerprint: string
|
|
58
86
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
87
|
+
* The fingerprint whose physics the snapshot uses: usually its own, and
|
|
88
|
+
* under forward-only — inherited from the previous version.
|
|
61
89
|
*/
|
|
62
90
|
readonly physicalFingerprint: string
|
|
63
|
-
/**
|
|
91
|
+
/** Where the physics and done-intervals are inherited from (fingerprint of the old version) under forward-only. */
|
|
64
92
|
readonly reusedFrom?: string
|
|
65
|
-
/**
|
|
93
|
+
/** Canonical AST of the body (null for external) — saved into the snapshot. */
|
|
66
94
|
readonly canonicalAst: string | null
|
|
67
95
|
readonly change: ChangeCategory
|
|
96
|
+
/** The planner's verdict before the operator override (#5); journaled. */
|
|
97
|
+
readonly reclassifiedFrom?: ChangeCategory
|
|
98
|
+
/**
|
|
99
|
+
* Why the category is what it is (#4): the diverged nodes of the canonical
|
|
100
|
+
* AST and the reason in words. Present on all changed models except
|
|
101
|
+
* added/removed — there is nothing to compare there. The diverged paths are
|
|
102
|
+
* a debugging hint, not a contract.
|
|
103
|
+
*/
|
|
104
|
+
readonly explain?: ChangeExplanation
|
|
68
105
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
106
|
+
* Whether physics needs building. false for unchanged/removed, external and
|
|
107
|
+
* for snapshots already built by another environment — then promotion is
|
|
108
|
+
* only a view-swap.
|
|
72
109
|
*/
|
|
73
110
|
readonly build: boolean
|
|
74
111
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* unchanged
|
|
112
|
+
* Ranges to recompute for incrementalByTimeRange (merged from missing
|
|
113
|
+
* intervals + lookback); empty for other kinds. Gaps happen even for an
|
|
114
|
+
* unchanged model — time passes, new intervals appear.
|
|
78
115
|
*/
|
|
79
116
|
readonly backfill: ReadonlyArray<Interval>
|
|
80
|
-
/** true
|
|
117
|
+
/** true for incrementalByUniqueKey: every apply re-runs the query (upsert by key). */
|
|
81
118
|
readonly refresh: boolean
|
|
82
119
|
}
|
|
83
120
|
|
|
84
121
|
export interface Plan {
|
|
85
122
|
readonly env: string
|
|
86
|
-
/**
|
|
123
|
+
/** In topological order; removed comes last. */
|
|
87
124
|
readonly actions: ReadonlyArray<PlanAction>
|
|
88
125
|
readonly hasChanges: boolean
|
|
89
126
|
}
|
|
90
127
|
|
|
91
128
|
export interface PlanOptions {
|
|
92
|
-
/**
|
|
129
|
+
/** "Now" for computing intervals; defaults to Clock. Injection point for tests. */
|
|
93
130
|
readonly now?: number
|
|
94
131
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
132
|
+
* Models whose changes to apply forward-only (SPEC §5.2): the new version
|
|
133
|
+
* inherits the physical table and done-intervals of the old one, history is
|
|
134
|
+
* not replayed. Only incrementalByTimeRange — other kinds have no
|
|
135
|
+
* "retroactive" notion by construction.
|
|
99
136
|
*/
|
|
100
137
|
readonly forwardOnly?: ReadonlyArray<string>
|
|
138
|
+
/**
|
|
139
|
+
* Categorization override (#5, SPEC §5.2): the operator, looking at
|
|
140
|
+
* `--explain`, states the verdict instead of the planner. It governs the
|
|
141
|
+
* fate of DESCENDANTS (a non-breaking parent lets them reuse the physics);
|
|
142
|
+
* the model itself is freed from rebuilding not by this but by forwardOnly.
|
|
143
|
+
* Guardrail: an obvious AST contradiction (removed columns → non-breaking)
|
|
144
|
+
* is an error. Applies only to breaking/non-breaking verdicts; on unchanged/
|
|
145
|
+
* added/removed/indirect it silently has no effect.
|
|
146
|
+
*/
|
|
147
|
+
readonly reclassify?: Readonly<Record<string, "breaking" | "non-breaking">>
|
|
101
148
|
}
|
|
102
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Kinds whose physics is worth inheriting on indirect reuse (#5):
|
|
152
|
+
* materialized tables. view/embedded have no materialization; seed rebuilds
|
|
153
|
+
* cheaply from a file and has no parents.
|
|
154
|
+
*/
|
|
155
|
+
const REUSABLE_KINDS: ReadonlySet<string> = new Set([
|
|
156
|
+
"full",
|
|
157
|
+
"incrementalByTimeRange",
|
|
158
|
+
"incrementalByUniqueKey",
|
|
159
|
+
"scdType2",
|
|
160
|
+
])
|
|
161
|
+
|
|
103
162
|
export const planChanges = (
|
|
104
163
|
env: string,
|
|
105
164
|
graph: ModelGraph,
|
|
@@ -108,6 +167,7 @@ export const planChanges = (
|
|
|
108
167
|
Plan,
|
|
109
168
|
| InvalidEnvironmentError
|
|
110
169
|
| ForwardOnlyError
|
|
170
|
+
| ReclassifyError
|
|
111
171
|
| FingerprintVersionError
|
|
112
172
|
| StateError
|
|
113
173
|
| EngineError
|
|
@@ -123,34 +183,49 @@ export const planChanges = (
|
|
|
123
183
|
for (const flagged of forwardOnly) {
|
|
124
184
|
const model = graph.models.get(flagged)
|
|
125
185
|
if (model === undefined) {
|
|
126
|
-
return yield* new ForwardOnlyError({ model: flagged, reason: "
|
|
186
|
+
return yield* new ForwardOnlyError({ model: flagged, reason: "model is not in the project" })
|
|
127
187
|
}
|
|
128
188
|
if (model.kind._tag !== "incrementalByTimeRange") {
|
|
129
189
|
return yield* new ForwardOnlyError({
|
|
130
190
|
model: flagged,
|
|
131
|
-
reason: `forward-only
|
|
191
|
+
reason: `forward-only reuses physics and interval accounting — applicable only to incrementalByTimeRange, model kind is ${model.kind._tag}`,
|
|
132
192
|
})
|
|
133
193
|
}
|
|
134
194
|
}
|
|
135
|
-
const
|
|
195
|
+
const reclassify = options?.reclassify ?? {}
|
|
196
|
+
for (const flagged of Object.keys(reclassify)) {
|
|
197
|
+
if (!graph.models.has(flagged)) {
|
|
198
|
+
return yield* new ReclassifyError({ model: flagged, reason: "model is not in the project" })
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// canonicalization cache (#8) — the store is at hand; its failures are
|
|
202
|
+
// swallowed: a cache miss is just an honest recompute, not a plan error
|
|
203
|
+
const versions = yield* fingerprintGraph(graph, {
|
|
204
|
+
get: (key) => store.getCanon(key).pipe(Effect.orElseSucceed(() => undefined)),
|
|
205
|
+
put: (key, canonical) => store.putCanon(key, canonical).pipe(Effect.ignore),
|
|
206
|
+
})
|
|
136
207
|
const current = new Map(
|
|
137
208
|
(yield* store.getEnvironment(env)).map((row) => [row.name, row.fingerprint]),
|
|
138
209
|
)
|
|
139
210
|
|
|
140
211
|
const actions: Array<PlanAction> = []
|
|
141
212
|
const changeOf = new Map<string, ChangeCategory>()
|
|
213
|
+
// who in this plan inherits the physics of the old version (indirect reuse, #5)
|
|
214
|
+
const reusedPhysics = new Set<string>()
|
|
142
215
|
for (const name of graph.order) {
|
|
143
216
|
const model = graph.models.get(name)!
|
|
144
217
|
const { fingerprint, ast } = versions.get(name)!
|
|
145
218
|
const known = current.get(name)
|
|
146
219
|
let change: ChangeCategory
|
|
220
|
+
let explain: ChangeExplanation | undefined
|
|
221
|
+
let reclassifiedFrom: ChangeCategory | undefined
|
|
147
222
|
let reusedFrom: string | undefined
|
|
148
223
|
let physicalFingerprint = fingerprint
|
|
149
224
|
if (known === undefined) change = "added"
|
|
150
225
|
else if (known === fingerprint) change = "unchanged"
|
|
151
226
|
else {
|
|
152
|
-
//
|
|
153
|
-
//
|
|
227
|
+
// categorization by AST against the last known snapshot (SPEC §5.2);
|
|
228
|
+
// old records without an AST and external — conservatively breaking
|
|
154
229
|
const previous = yield* store.getSnapshot(name, known)
|
|
155
230
|
if (previous !== undefined && previous.fingerprintVersion !== FINGERPRINT_VERSION) {
|
|
156
231
|
return yield* new FingerprintVersionError({
|
|
@@ -160,14 +235,83 @@ export const planChanges = (
|
|
|
160
235
|
})
|
|
161
236
|
}
|
|
162
237
|
const oldAst = previous?.canonicalAst ?? ""
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
238
|
+
const changedParents = [...model.deps].filter((dep) => {
|
|
239
|
+
const parent = changeOf.get(dep)
|
|
240
|
+
return parent !== undefined && parent !== "unchanged"
|
|
241
|
+
})
|
|
242
|
+
if (oldAst === "" || ast === null) {
|
|
243
|
+
change = "breaking"
|
|
244
|
+
explain = {
|
|
245
|
+
diverged: [],
|
|
246
|
+
reason:
|
|
247
|
+
ast === null
|
|
248
|
+
? "model has no SQL body (external/seed) — the version was shifted by source/file, schema or parents; there is no AST to compare against"
|
|
249
|
+
: "the previous snapshot did not store a canonical AST — nothing to compare against, conservatively breaking",
|
|
250
|
+
}
|
|
251
|
+
} else if (oldAst === ast) {
|
|
252
|
+
change = "indirect" // version shifted by a parent/metadata
|
|
253
|
+
explain =
|
|
254
|
+
changedParents.length > 0
|
|
255
|
+
? {
|
|
256
|
+
diverged: [],
|
|
257
|
+
reason: `own AST did not change — the version was shifted by a cascade from parents: ${changedParents.join(", ")}`,
|
|
258
|
+
cascadeFrom: changedParents,
|
|
259
|
+
}
|
|
260
|
+
: {
|
|
261
|
+
diverged: [],
|
|
262
|
+
reason:
|
|
263
|
+
"own AST did not change — metadata diverged (kind/grain/columns/target)",
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
change = categorizeAstChange(oldAst, ast)
|
|
267
|
+
explain = explainCategorized(oldAst, ast, change)
|
|
268
|
+
}
|
|
269
|
+
// operator override (#5): the verdict is stated by a flag on top of
|
|
270
|
+
// --explain; the planner checks only an obvious AST contradiction
|
|
271
|
+
const override = reclassify[name]
|
|
272
|
+
if (
|
|
273
|
+
override !== undefined &&
|
|
274
|
+
(change === "breaking" || change === "non-breaking") &&
|
|
275
|
+
change !== override
|
|
276
|
+
) {
|
|
277
|
+
if (override === "non-breaking" && (oldAst === "" || ast === null)) {
|
|
278
|
+
return yield* new ReclassifyError({
|
|
279
|
+
model: name,
|
|
280
|
+
reason:
|
|
281
|
+
"no canonical AST — nothing to check the verdict against, override is not accepted",
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
if (override === "non-breaking" && ast !== null && dropsColumns(oldAst, ast)) {
|
|
285
|
+
return yield* new ReclassifyError({
|
|
286
|
+
model: name,
|
|
287
|
+
reason:
|
|
288
|
+
"the new SELECT has fewer columns — descendants read the removed columns by name, non-breaking contradicts the AST",
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
reclassifiedFrom = change
|
|
292
|
+
explain = {
|
|
293
|
+
diverged: explain?.diverged ?? [],
|
|
294
|
+
reason: `operator override: ${change} → ${override}; planner verdict: ${explain?.reason ?? "—"}`,
|
|
295
|
+
}
|
|
296
|
+
change = override
|
|
297
|
+
}
|
|
298
|
+
// "version shifted by parents ONLY": a fingerprint with the old parent
|
|
299
|
+
// signatures must yield known — otherwise the metadata diverged along
|
|
300
|
+
// with the parents (kind/grain/columns/target) and physics cannot be inherited
|
|
301
|
+
const parentsOnly =
|
|
170
302
|
change === "indirect" &&
|
|
303
|
+
previous !== undefined &&
|
|
304
|
+
[...model.deps].every((dep) => current.has(dep)) &&
|
|
305
|
+
(yield* modelFingerprint(
|
|
306
|
+
model,
|
|
307
|
+
ast,
|
|
308
|
+
[...model.deps].sort().map((dep) => `${dep}=${current.get(dep)!}`),
|
|
309
|
+
)) === known
|
|
310
|
+
// forward-only: an explicit user flag — or a cascade: the own AST did
|
|
311
|
+
// not change and all changed parents are themselves forward-only (reuse
|
|
312
|
+
// of a parent's physics means the descendant has nothing to replay either)
|
|
313
|
+
const cascaded =
|
|
314
|
+
parentsOnly &&
|
|
171
315
|
model.kind._tag === "incrementalByTimeRange" &&
|
|
172
316
|
[...model.deps].every((dep) => {
|
|
173
317
|
const parent = changeOf.get(dep)
|
|
@@ -177,10 +321,50 @@ export const planChanges = (
|
|
|
177
321
|
change = "forward-only"
|
|
178
322
|
reusedFrom = known
|
|
179
323
|
physicalFingerprint = previous.physicalFp
|
|
324
|
+
explain = forwardOnly.has(name)
|
|
325
|
+
? {
|
|
326
|
+
diverged: explain?.diverged ?? [],
|
|
327
|
+
reason: `forward-only by flag: physics and done intervals are inherited from @${known.slice(0, 8)}, history is not replayed`,
|
|
328
|
+
}
|
|
329
|
+
: {
|
|
330
|
+
diverged: [],
|
|
331
|
+
reason:
|
|
332
|
+
"own AST did not change, all changed parents are forward-only — physics is reused by cascade",
|
|
333
|
+
cascadeFrom: changedParents,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// indirect reuse (#5, sqlmesh's "indirect non-breaking" class): the own
|
|
337
|
+
// body did not change, the version was shifted only by parents, and each
|
|
338
|
+
// changed parent guarantees the same data in the old columns (non-breaking
|
|
339
|
+
// — strictly a suffix; forward-only and reuse — literally the same
|
|
340
|
+
// physics) — the descendant has nothing to replay: physics and ledger are
|
|
341
|
+
// inherited, scdType2 does not lose its accumulated row history
|
|
342
|
+
if (
|
|
343
|
+
change === "indirect" &&
|
|
344
|
+
parentsOnly &&
|
|
345
|
+
REUSABLE_KINDS.has(model.kind._tag) &&
|
|
346
|
+
changedParents.length > 0 &&
|
|
347
|
+
changedParents.every((dep) => {
|
|
348
|
+
const parent = changeOf.get(dep)!
|
|
349
|
+
return (
|
|
350
|
+
parent === "non-breaking" ||
|
|
351
|
+
parent === "forward-only" ||
|
|
352
|
+
(parent === "indirect" && reusedPhysics.has(dep))
|
|
353
|
+
)
|
|
354
|
+
})
|
|
355
|
+
) {
|
|
356
|
+
reusedFrom = known
|
|
357
|
+
physicalFingerprint = previous!.physicalFp
|
|
358
|
+
reusedPhysics.add(name)
|
|
359
|
+
explain = {
|
|
360
|
+
diverged: [],
|
|
361
|
+
reason: `changed parents do not touch existing data (non-breaking/forward-only) — physics and accounting are inherited from @${known.slice(0, 8)}, no rebuild`,
|
|
362
|
+
cascadeFrom: changedParents,
|
|
363
|
+
}
|
|
180
364
|
}
|
|
181
365
|
}
|
|
182
366
|
changeOf.set(name, change)
|
|
183
|
-
// external
|
|
367
|
+
// external is not materialized — the version participates in the diff, there is no physics
|
|
184
368
|
const existing = yield* store.getSnapshot(name, fingerprint)
|
|
185
369
|
if (existing !== undefined) physicalFingerprint = existing.physicalFp
|
|
186
370
|
const alreadyBuilt =
|
|
@@ -190,9 +374,9 @@ export const planChanges = (
|
|
|
190
374
|
if (model.kind._tag === "incrementalByTimeRange") {
|
|
191
375
|
const kind = model.kind
|
|
192
376
|
const wanted = enumerateIntervals(kind.interval, fromIso(kind.start), now)
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
377
|
+
// coverage is keyed to the fingerprint: a new version = empty ledger =
|
|
378
|
+
// full backfill; forward-only inherits the done-intervals of the old
|
|
379
|
+
// version — only what was missing is recomputed
|
|
196
380
|
const inherited =
|
|
197
381
|
reusedFrom === undefined ? [] : yield* store.listIntervals(reusedFrom)
|
|
198
382
|
const doneByStart = new Map<number, Interval>()
|
|
@@ -206,7 +390,7 @@ export const planChanges = (
|
|
|
206
390
|
const done = [...doneByStart.values()]
|
|
207
391
|
const missing = [...missingIntervals(wanted, done)]
|
|
208
392
|
if (kind.lookback > 0) {
|
|
209
|
-
//
|
|
393
|
+
// late-arriving data: the last done-intervals are re-read
|
|
210
394
|
const tail = done.sort((a, b) => a.start - b.start).slice(-kind.lookback)
|
|
211
395
|
missing.push(...tail)
|
|
212
396
|
}
|
|
@@ -220,9 +404,11 @@ export const planChanges = (
|
|
|
220
404
|
...(reusedFrom !== undefined ? { reusedFrom } : {}),
|
|
221
405
|
canonicalAst: ast,
|
|
222
406
|
change,
|
|
407
|
+
...(reclassifiedFrom !== undefined ? { reclassifiedFrom } : {}),
|
|
408
|
+
...(explain !== undefined ? { explain } : {}),
|
|
223
409
|
build: !alreadyBuilt,
|
|
224
410
|
backfill,
|
|
225
|
-
//
|
|
411
|
+
// every apply reconciles the query with the physics: upsert / SCD versioning
|
|
226
412
|
refresh:
|
|
227
413
|
model.kind._tag === "incrementalByUniqueKey" || model.kind._tag === "scdType2",
|
|
228
414
|
})
|