@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.
@@ -1,14 +1,14 @@
1
1
  import type { ExternalSource, ModelName } from "../core/model.ts"
2
2
 
3
3
  /**
4
- * Раскладка объектов в движке (SPEC §2):
5
- * - физика: схема `_efmesh`, таблица `<схема>__<таблица>__<fp8>`;
6
- * - виртуалка: prod живёт в родных схемах моделей (`med.stays`),
7
- * остальные окруженияв префиксованных (`dev__med.stays`).
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 environmentsin prefixed ones (`dev__med.stays`).
8
8
  *
9
- * Схема именно `_efmesh`, не `efmesh`: DuckDB называет каталог по имени
10
- * файла базы, и для `efmesh.duckdb` ссылка `efmesh.x` становится
11
- * неоднозначной (каталог или схема) — Binder Error.
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
- * Раскладка parquet-озера (SPEC §3.3): `<lake>/<схема>/<таблица>/fp=<fp8>/…`,
35
- * у incremental внутрипартиции `interval=<ключ>/data.parquet`.
36
- * Интервал = партиция: пересчёт перезапись файлов партиции, источник
37
- * правды учёт интервалов, поэтому неатомарность перезаписи не страшна.
34
+ * Parquet-lake layout (SPEC §3.3): `<lake>/<schema>/<table>/fp=<fp8>/…`,
35
+ * for incremental insidepartitions `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
- * история читается с NULL).
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-цель (SPEC §14.5): каталог подключается под фиксированным
52
- * алиасом, физика таблица-на-fingerprint в нём (то же имя, что у
53
- * нативной физики, только каталог другой). Версионность остаётся нашей;
54
- * снапшоты/time travel DuckLake бонус. Потребители вне efmesh должны
55
- * сами сделать ATTACHview окружений ссылаются на каталог по алиасу.
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 themselvesthe 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 keyfilesystem-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
- * Во что рендерится ссылка на external-модель (SPEC §9.3): имя таблицы
82
- * (движка или ATTACH-базы) как есть, файлы/URLчерез read_*.
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/URLsvia 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"
@@ -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 { FINGERPRINT_VERSION, fingerprintGraph } from "./fingerprint.ts"
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
- /** Модель нельзя применить forward-only (SPEC §5.2). */
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
- * Снапшот посчитан другой версией алгоритма fingerprint (SPEC §4):
26
- * отпечатки разных версий несравнимы план честно останавливается,
27
- * а не показывает «всё breaking». Лечится миграцией той версии efmesh,
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
- /** Добавлены колонки в конец SELECT, остальное дерево нетронуто (SPEC §5.2). */
69
+ /** Columns appended to the end of the SELECT, the rest of the tree untouched (SPEC §5.2). */
42
70
  | "non-breaking"
43
- /** Собственный AST не менялсяверсия сдвинулась каскадом от родителя. */
71
+ /** The own AST did not change the version shifted by cascade from a parent. */
44
72
  | "indirect"
45
73
  /**
46
- * Физика и done-интервалы старой версии переиспользуются, история не
47
- * переигрываетсяновая логика действует с момента применения (SPEC §5.2).
48
- * Явный флаг пользователя или каскад от forward-only-родителей.
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
- /** Была в окружении, из проекта исчезла — view будет снесён при промоушене. */
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
- * Fingerprint, чьей физикой пользуется снапшот: обычно собственный,
60
- * при forward-only — унаследованный от предыдущей версии.
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
- /** Откуда наследуются физика и done-интервалы (fingerprint старой версии) при forward-only. */
91
+ /** Where the physics and done-intervals are inherited from (fingerprint of the old version) under forward-only. */
64
92
  readonly reusedFrom?: string
65
- /** Канонический AST тела (null у external) — сохраняется в снапшот. */
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
- * Нужна ли сборка физики. false для unchanged/removed, external и для
70
- * снапшотов, уже собранных другим окружением,тогда промоушен это
71
- * только view-swap.
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
- * Диапазоны к пересчёту у incrementalByTimeRange (слитые из недостающих
76
- * интервалов + lookback); у остальных видов пуст. Дыры бывают и у
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 у incrementalByUniqueKey: каждый apply перегоняет запрос (upsert по ключу). */
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
- /** В топологическом порядке; removed в конце. */
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
- /** «Сейчас» для расчёта интервалов; по умолчанию Clock. Инъекция для тестов. */
129
+ /** "Now" for computing intervals; defaults to Clock. Injection point for tests. */
93
130
  readonly now?: number
94
131
  /**
95
- * Модели, чьи изменения применить forward-only (SPEC §5.2): новая версия
96
- * наследует физическую таблицу и done-интервалы старой, история не
97
- * переигрывается. Только incrementalByTimeRange — у остальных видов
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 переиспользует физику и учёт интерваловприменим только к incrementalByTimeRange, вид модели ${model.kind._tag}`,
191
+ reason: `forward-only reuses physics and interval accountingapplicable only to incrementalByTimeRange, model kind is ${model.kind._tag}`,
132
192
  })
133
193
  }
134
194
  }
135
- const versions = yield* fingerprintGraph(graph)
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
- // категоризация по AST против последнего известного снапшота (SPEC §5.2);
153
- // старых записей без AST и external — консервативно breaking
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
- if (oldAst === "" || ast === null) change = "breaking"
164
- else if (oldAst === ast) change = "indirect" // версия сдвинута родителем/метаданными
165
- else change = categorizeAstChange(oldAst, ast)
166
- // forward-only: явный флаг пользователя — либо каскад: собственный AST
167
- // не менялся, а все изменившиеся родители сами forward-only (реюз
168
- // физики родителя означает, что и потомку нечего переигрывать)
169
- const cascaded =
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 не материализуетсяверсия участвует в diff, физики нет
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
- // покрытие привязано к fingerprint: новая версия = пустой учёт = полный
194
- // бэкфилл; forward-only наследует done-интервалы старой версии
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
- // поздно приезжающие данные: последние done-интервалы перечитываются
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
- // каждый apply сверяет запрос с физикой: upsert / SCD-версионирование
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
  })