@metaobjectsdev/codegen-ts 0.23.2 → 0.24.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/dist/generator.d.ts +9 -0
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js.map +1 -1
- package/dist/generators/api-field-shape.js +1 -1
- package/dist/generators/api-field-shape.js.map +1 -1
- package/dist/generators/api-model.d.ts.map +1 -1
- package/dist/generators/api-model.js +71 -42
- package/dist/generators/api-model.js.map +1 -1
- package/dist/generators/docs-data-builder.d.ts.map +1 -1
- package/dist/generators/docs-data-builder.js +36 -1
- package/dist/generators/docs-data-builder.js.map +1 -1
- package/dist/generators/docs-data.d.ts +14 -0
- package/dist/generators/docs-data.d.ts.map +1 -1
- package/dist/generators/docs-file.d.ts.map +1 -1
- package/dist/generators/docs-file.js +13 -4
- package/dist/generators/docs-file.js.map +1 -1
- package/dist/generators/extractor-file.d.ts.map +1 -1
- package/dist/generators/extractor-file.js +7 -11
- package/dist/generators/extractor-file.js.map +1 -1
- package/dist/generators/index.d.ts +4 -0
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +5 -0
- package/dist/generators/index.js.map +1 -1
- package/dist/generators/output-parser-file.d.ts.map +1 -1
- package/dist/generators/output-parser-file.js +12 -7
- package/dist/generators/output-parser-file.js.map +1 -1
- package/dist/generators/output-prompt-file.d.ts.map +1 -1
- package/dist/generators/output-prompt-file.js +14 -24
- package/dist/generators/output-prompt-file.js.map +1 -1
- package/dist/generators/requirement-tests.d.ts +44 -0
- package/dist/generators/requirement-tests.d.ts.map +1 -0
- package/dist/generators/requirement-tests.js +127 -0
- package/dist/generators/requirement-tests.js.map +1 -0
- package/dist/generators/requirements-file.d.ts +9 -0
- package/dist/generators/requirements-file.d.ts.map +1 -0
- package/dist/generators/requirements-file.js +53 -0
- package/dist/generators/requirements-file.js.map +1 -0
- package/dist/generators/requirements-markdown.d.ts +10 -0
- package/dist/generators/requirements-markdown.d.ts.map +1 -0
- package/dist/generators/requirements-markdown.js +71 -0
- package/dist/generators/requirements-markdown.js.map +1 -0
- package/dist/generators/requirements-toon.d.ts +3 -0
- package/dist/generators/requirements-toon.d.ts.map +1 -0
- package/dist/generators/requirements-toon.js +47 -0
- package/dist/generators/requirements-toon.js.map +1 -0
- package/dist/generators/requirements-view.d.ts +32 -0
- package/dist/generators/requirements-view.d.ts.map +1 -0
- package/dist/generators/requirements-view.js +64 -0
- package/dist/generators/requirements-view.js.map +1 -0
- package/dist/generators/trace-helper-file.d.ts.map +1 -1
- package/dist/generators/trace-helper-file.js +19 -11
- package/dist/generators/trace-helper-file.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -1
- package/dist/metaobjects-config.d.ts +12 -20
- package/dist/metaobjects-config.d.ts.map +1 -1
- package/dist/metaobjects-config.js +5 -1
- package/dist/metaobjects-config.js.map +1 -1
- package/dist/orphan-sweep.d.ts +38 -0
- package/dist/orphan-sweep.d.ts.map +1 -0
- package/dist/orphan-sweep.js +129 -0
- package/dist/orphan-sweep.js.map +1 -0
- package/dist/overwrite-policy.d.ts +97 -3
- package/dist/overwrite-policy.d.ts.map +1 -1
- package/dist/overwrite-policy.js +293 -81
- package/dist/overwrite-policy.js.map +1 -1
- package/dist/projection/build-projection-views.d.ts +8 -0
- package/dist/projection/build-projection-views.d.ts.map +1 -1
- package/dist/projection/build-projection-views.js +7 -4
- package/dist/projection/build-projection-views.js.map +1 -1
- package/dist/projection/extract-view-spec.d.ts.map +1 -1
- package/dist/projection/extract-view-spec.js +70 -4
- package/dist/projection/extract-view-spec.js.map +1 -1
- package/dist/projection/view-ddl-emit.d.ts.map +1 -1
- package/dist/projection/view-ddl-emit.js +52 -0
- package/dist/projection/view-ddl-emit.js.map +1 -1
- package/dist/projection/view-spec.d.ts +15 -0
- package/dist/projection/view-spec.d.ts.map +1 -1
- package/dist/reconcile-orphans.d.ts +80 -0
- package/dist/reconcile-orphans.d.ts.map +1 -0
- package/dist/reconcile-orphans.js +61 -0
- package/dist/reconcile-orphans.js.map +1 -0
- package/dist/render-engine/embedded-templates.generated.js +1 -1
- package/dist/render-engine/embedded-templates.generated.js.map +1 -1
- package/dist/requirement-walk.d.ts +57 -0
- package/dist/requirement-walk.d.ts.map +1 -0
- package/dist/requirement-walk.js +94 -0
- package/dist/requirement-walk.js.map +1 -0
- package/dist/runner.d.ts +35 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +217 -21
- package/dist/runner.js.map +1 -1
- package/dist/templates/extractor.d.ts +4 -4
- package/dist/templates/extractor.d.ts.map +1 -1
- package/dist/templates/extractor.js +18 -22
- package/dist/templates/extractor.js.map +1 -1
- package/dist/templates/find-inbound.d.ts +44 -0
- package/dist/templates/find-inbound.d.ts.map +1 -0
- package/dist/templates/find-inbound.js +69 -0
- package/dist/templates/find-inbound.js.map +1 -0
- package/dist/templates/inferred-types.d.ts.map +1 -1
- package/dist/templates/inferred-types.js +8 -4
- package/dist/templates/inferred-types.js.map +1 -1
- package/dist/templates/output-format-spec-emitter.d.ts.map +1 -1
- package/dist/templates/output-format-spec-emitter.js +7 -4
- package/dist/templates/output-format-spec-emitter.js.map +1 -1
- package/dist/templates/output-parser.d.ts +8 -3
- package/dist/templates/output-parser.d.ts.map +1 -1
- package/dist/templates/output-parser.js +71 -31
- package/dist/templates/output-parser.js.map +1 -1
- package/dist/templates/output-prompt.d.ts +6 -5
- package/dist/templates/output-prompt.d.ts.map +1 -1
- package/dist/templates/output-prompt.js +26 -37
- package/dist/templates/output-prompt.js.map +1 -1
- package/dist/templates/queries.js +1 -1
- package/dist/templates/queries.js.map +1 -1
- package/dist/templates/requirement-test.d.ts +12 -0
- package/dist/templates/requirement-test.d.ts.map +1 -0
- package/dist/templates/requirement-test.js +120 -0
- package/dist/templates/requirement-test.js.map +1 -0
- package/dist/templates/zod-validators.d.ts.map +1 -1
- package/dist/templates/zod-validators.js +32 -15
- package/dist/templates/zod-validators.js.map +1 -1
- package/package.json +9 -8
- package/src/generator.ts +9 -0
- package/src/generators/api-field-shape.ts +1 -1
- package/src/generators/api-model.ts +69 -44
- package/src/generators/docs-data-builder.ts +37 -0
- package/src/generators/docs-data.ts +15 -0
- package/src/generators/docs-file.ts +13 -4
- package/src/generators/extractor-file.ts +7 -11
- package/src/generators/index.ts +11 -0
- package/src/generators/output-parser-file.ts +12 -7
- package/src/generators/output-prompt-file.ts +14 -27
- package/src/generators/requirement-tests.ts +203 -0
- package/src/generators/requirements-file.ts +71 -0
- package/src/generators/requirements-markdown.ts +72 -0
- package/src/generators/requirements-toon.ts +64 -0
- package/src/generators/requirements-view.ts +93 -0
- package/src/generators/trace-helper-file.ts +20 -10
- package/src/index.ts +47 -1
- package/src/metaobjects-config.ts +17 -22
- package/src/orphan-sweep.ts +178 -0
- package/src/overwrite-policy.ts +362 -89
- package/src/projection/build-projection-views.ts +15 -4
- package/src/projection/extract-view-spec.ts +62 -3
- package/src/projection/view-ddl-emit.ts +54 -0
- package/src/projection/view-spec.ts +25 -0
- package/src/reconcile-orphans.ts +136 -0
- package/src/reference/barrel.ts +4 -0
- package/src/reference/entity.ts +4 -0
- package/src/reference/queries.ts +4 -0
- package/src/reference/routes.ts +4 -0
- package/src/render-engine/embedded-templates.generated.ts +1 -1
- package/src/requirement-walk.ts +124 -0
- package/src/runner.ts +266 -27
- package/src/templates/extractor.ts +19 -24
- package/src/templates/find-inbound.ts +96 -0
- package/src/templates/inferred-types.ts +8 -4
- package/src/templates/output-format-spec-emitter.ts +7 -4
- package/src/templates/output-parser.ts +76 -34
- package/src/templates/output-prompt.ts +29 -42
- package/src/templates/queries.ts +1 -1
- package/src/templates/requirement-test.ts +140 -0
- package/src/templates/zod-validators.ts +32 -15
- package/templates/docs/entity-page.md.mustache +8 -0
package/src/overwrite-policy.ts
CHANGED
|
@@ -14,14 +14,23 @@
|
|
|
14
14
|
// (current, .gen-state snapshot, fresh tmpfile). Exit 0 → clean merge,
|
|
15
15
|
// advance .gen-state to fresh content. Exit > 0 → leave conflict markers
|
|
16
16
|
// in the output file, do NOT advance .gen-state; status "conflict".
|
|
17
|
-
// 4. If .gen-state
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// 4. If the .gen-state BODY is absent but the file exists, consult the
|
|
18
|
+
// committed hash manifest: identical fresh content → "unchanged"; the file
|
|
19
|
+
// still hashes to what we recorded writing → safe to overwrite; anything
|
|
20
|
+
// else (edited, or no record at all) → "refused", naming the file. The
|
|
21
|
+
// `baseline: "fresh"` flag opts into "overwrite from fresh and re-baseline".
|
|
21
22
|
//
|
|
22
23
|
// Integrity (caveat 2 from the spike): we keep a sha-256 of each canonical
|
|
23
|
-
// snapshot at `.gen-state/.hashes.json`.
|
|
24
|
-
//
|
|
24
|
+
// snapshot at `.gen-state/.hashes.json`.
|
|
25
|
+
//
|
|
26
|
+
// THE MANIFEST IS COMMITTED; THE BODIES ARE NOT. That split is what makes step 4
|
|
27
|
+
// possible. A hash per path is small and reviewable, where a second full copy of
|
|
28
|
+
// all generated output is neither — and a hash is already sufficient to tell
|
|
29
|
+
// "nobody touched this" from "somebody edited this", which is the only thing
|
|
30
|
+
// steps 4 and the orphan-delete path need to know. The cost, stated: without a
|
|
31
|
+
// body there is no base to merge against, so a diverged file on a fresh clone is
|
|
32
|
+
// REFUSED rather than merged. That is a smaller loss than it sounds, because the
|
|
33
|
+
// behaviour it replaces was a silent overwrite.
|
|
25
34
|
|
|
26
35
|
import {
|
|
27
36
|
existsSync,
|
|
@@ -29,6 +38,7 @@ import {
|
|
|
29
38
|
writeFileSync,
|
|
30
39
|
mkdirSync,
|
|
31
40
|
copyFileSync,
|
|
41
|
+
rmSync,
|
|
32
42
|
} from "node:fs";
|
|
33
43
|
import { dirname, join, isAbsolute, relative, resolve } from "node:path";
|
|
34
44
|
import { spawnSync } from "node:child_process";
|
|
@@ -42,7 +52,13 @@ export type WriteStatus =
|
|
|
42
52
|
| "merged"
|
|
43
53
|
| "conflict"
|
|
44
54
|
| "refused"
|
|
45
|
-
| "skipped"
|
|
55
|
+
| "skipped"
|
|
56
|
+
/** FR-038 §8 — deleted because it was generated by a previous run, is no longer
|
|
57
|
+
* generated, and was never edited by hand. Reported as a file outcome rather
|
|
58
|
+
* than a warning because a deletion is exactly as consequential as a write,
|
|
59
|
+
* and a run summary that lists writes but hides deletions is how a silent
|
|
60
|
+
* deletion happens. */
|
|
61
|
+
| "removed";
|
|
46
62
|
|
|
47
63
|
/**
|
|
48
64
|
* "overwrite" — default; three-way merge if .gen-state exists, else write-if-
|
|
@@ -75,8 +91,9 @@ export interface DecideAndWriteOpts {
|
|
|
75
91
|
export interface WriteResult {
|
|
76
92
|
path: string;
|
|
77
93
|
status: WriteStatus;
|
|
78
|
-
/** Present when status is "conflict" — human-readable
|
|
79
|
-
*
|
|
94
|
+
/** Present when status is "conflict" or "refused" — human-readable reason,
|
|
95
|
+
* naming what the user must do. A refusal nobody can act on gets the file
|
|
96
|
+
* deleted by hand, which is the outcome refusing exists to prevent. */
|
|
80
97
|
conflictHint?: string;
|
|
81
98
|
}
|
|
82
99
|
|
|
@@ -105,7 +122,128 @@ function loadHashes(genStateDir: string): HashesFile {
|
|
|
105
122
|
|
|
106
123
|
function saveHashes(genStateDir: string, hashes: HashesFile): void {
|
|
107
124
|
mkdirSync(genStateDir, { recursive: true });
|
|
108
|
-
|
|
125
|
+
// Keys SORTED, because this file is committed. Insertion order would make the
|
|
126
|
+
// diff — and any merge conflict between two people who both regenerated —
|
|
127
|
+
// depend on which entity happened to generate first, which is noise nobody can
|
|
128
|
+
// review.
|
|
129
|
+
const sorted: HashesFile = {};
|
|
130
|
+
for (const key of Object.keys(hashes).sort()) sorted[key] = hashes[key]!;
|
|
131
|
+
writeFileSync(join(genStateDir, HASHES_FILE), JSON.stringify(sorted, null, 2) + "\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// FR-038 §8 — readers for orphan reconciliation.
|
|
136
|
+
//
|
|
137
|
+
// `.hashes.json`'s key set is the ONLY record of what a previous run wrote, so
|
|
138
|
+
// it is also the only way to notice that a file we used to generate is no longer
|
|
139
|
+
// generated. These three functions expose that record without widening the write
|
|
140
|
+
// path: nothing here decides anything (see reconcile-orphans.ts) and nothing here
|
|
141
|
+
// touches an output file (see orphan-sweep.ts).
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every snapshot key recorded under `genStateDir` — in a runner-driven project,
|
|
146
|
+
* the project-relative path of every file some previous `meta gen` wrote.
|
|
147
|
+
*
|
|
148
|
+
* Empty for a gen-state directory that does not exist yet, which is what makes a
|
|
149
|
+
* first run, an ephemeral test run and `verify --codegen`'s throwaway root all
|
|
150
|
+
* reconcile nothing.
|
|
151
|
+
*/
|
|
152
|
+
export function listGeneratedPaths(genStateDir: string): string[] {
|
|
153
|
+
return Object.keys(loadHashes(genStateDir));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* sha-256 of `content`, hex — the same function that produces `.hashes.json`.
|
|
158
|
+
*
|
|
159
|
+
* Exported so a caller can ask the one question the manifest exists to answer
|
|
160
|
+
* ("is this file byte-for-byte what we recorded writing?") without needing the
|
|
161
|
+
* snapshot body, which is what makes the answer available on a fresh clone.
|
|
162
|
+
*/
|
|
163
|
+
export function contentHash(content: string): string {
|
|
164
|
+
return sha256(content);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The hash we recorded when we last wrote `relPath`, or undefined if we have no
|
|
169
|
+
* record of ever writing it.
|
|
170
|
+
*
|
|
171
|
+
* This is the COMMITTED half of `.gen-state`. The snapshot bodies stay ignored —
|
|
172
|
+
* they are a second full copy of all generated output — but a hash per path is
|
|
173
|
+
* small enough to commit and review, and it is sufficient to distinguish "nobody
|
|
174
|
+
* touched this" from "somebody edited this", which is the only distinction the
|
|
175
|
+
* overwrite and delete decisions actually need.
|
|
176
|
+
*/
|
|
177
|
+
export function readGeneratedHash(
|
|
178
|
+
genStateDir: string,
|
|
179
|
+
relPath: string,
|
|
180
|
+
): string | undefined {
|
|
181
|
+
return loadHashes(genStateDir)[relPath];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* True when the file at `relPath` is byte-for-byte what we recorded writing.
|
|
186
|
+
*
|
|
187
|
+
* FAILS CLOSED: with no recorded hash we cannot prove anything, so the answer is
|
|
188
|
+
* false. Both the write path and the orphan-delete path ask this one question of
|
|
189
|
+
* this one piece of evidence — before this existed they answered the same
|
|
190
|
+
* uncertainty in opposite directions inside a single feature, refusing to DELETE
|
|
191
|
+
* a hand-edited file while silently OVERWRITING one.
|
|
192
|
+
*/
|
|
193
|
+
export function isPristineGenerated(
|
|
194
|
+
genStateDir: string,
|
|
195
|
+
relPath: string,
|
|
196
|
+
current: string,
|
|
197
|
+
): boolean {
|
|
198
|
+
const recorded = readGeneratedHash(genStateDir, relPath);
|
|
199
|
+
return recorded !== undefined && recorded === sha256(current);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The snapshot of what we last wrote to `relPath`, or undefined when there is no
|
|
204
|
+
* trustworthy one.
|
|
205
|
+
*
|
|
206
|
+
* HASH-CHECKED on purpose. A caller comparing this against the file on disk is
|
|
207
|
+
* asking "is the output still exactly what I wrote?" — and a snapshot that fails
|
|
208
|
+
* its own hash cannot answer that. Returning the stale text would let a
|
|
209
|
+
* reconciling caller conclude "untouched" and delete a file it cannot vouch for,
|
|
210
|
+
* so a tampered snapshot reads as absent and the caller fails closed.
|
|
211
|
+
*/
|
|
212
|
+
export function readGeneratedSnapshot(
|
|
213
|
+
genStateDir: string,
|
|
214
|
+
relPath: string,
|
|
215
|
+
): string | undefined {
|
|
216
|
+
return readSnapshotChecked(genStateDir, relPath)?.text;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Drop both halves of the record for paths this run no longer generates — each
|
|
221
|
+
* snapshot file and its `.hashes.json` entry.
|
|
222
|
+
*
|
|
223
|
+
* BOTH halves, or the next run sees the path again in `listGeneratedPaths` and
|
|
224
|
+
* re-decides an orphan that has already been dealt with. A no-op for a path that was
|
|
225
|
+
* never generated.
|
|
226
|
+
*
|
|
227
|
+
* Batched deliberately: one manifest read and one write for the whole set. A per-path
|
|
228
|
+
* variant re-reads, re-sorts and rewrites the entire manifest every call, so clearing k
|
|
229
|
+
* orphans rewrote it k times — on a project with hundreds of generated files that makes
|
|
230
|
+
* bookkeeping the dominant cost of `meta gen`, for an identical result.
|
|
231
|
+
*/
|
|
232
|
+
export function forgetGeneratedPaths(
|
|
233
|
+
genStateDir: string,
|
|
234
|
+
relPaths: Iterable<string>,
|
|
235
|
+
): void {
|
|
236
|
+
const hashes = loadHashes(genStateDir);
|
|
237
|
+
let changed = false;
|
|
238
|
+
for (const relPath of relPaths) {
|
|
239
|
+
const snapshot = snapshotPath(genStateDir, relPath);
|
|
240
|
+
if (existsSync(snapshot)) rmSync(snapshot, { force: true });
|
|
241
|
+
if (relPath in hashes) {
|
|
242
|
+
delete hashes[relPath];
|
|
243
|
+
changed = true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (changed) saveHashes(genStateDir, hashes);
|
|
109
247
|
}
|
|
110
248
|
|
|
111
249
|
const ENGINE_FILE = ".engine.json";
|
|
@@ -274,6 +412,159 @@ function defaultOutputRelPath(outputPath: string): string {
|
|
|
274
412
|
return sha256(resolve(outputPath)).slice(0, 32);
|
|
275
413
|
}
|
|
276
414
|
|
|
415
|
+
/** Where this call's gen-state lives. Shared by the write path and the preview so
|
|
416
|
+
* the two cannot resolve it differently — a preview that consults a different
|
|
417
|
+
* manifest than the write would be the same disagreement-between-two-answers bug
|
|
418
|
+
* this whole change exists to remove. */
|
|
419
|
+
function resolveGenStateDir(opts: DecideAndWriteOpts): string {
|
|
420
|
+
if (opts.genStateDir === undefined) return join(tmpdir(), "meta-gen-state-fallback");
|
|
421
|
+
return isAbsolute(opts.genStateDir) ? opts.genStateDir : resolve(opts.genStateDir);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** True when this project has a hash manifest at all — as opposed to a manifest
|
|
425
|
+
* that simply has no entry for some path.
|
|
426
|
+
*
|
|
427
|
+
* The distinction drives the upgrade message: a project with NO manifest predates
|
|
428
|
+
* the manifest being committed, so its refusals are one fixable configuration
|
|
429
|
+
* problem rather than N independent hand edits, and it deserves one instruction
|
|
430
|
+
* instead of a wall of per-file warnings. */
|
|
431
|
+
export function hasHashManifest(genStateDir: string): boolean {
|
|
432
|
+
return existsSync(join(genStateDir, HASHES_FILE));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Normalize the legacy `MergeStrategy` string shorthand into an options object. */
|
|
436
|
+
function normalizeOpts(
|
|
437
|
+
optsOrStrategy: DecideAndWriteOpts | MergeStrategy,
|
|
438
|
+
): DecideAndWriteOpts {
|
|
439
|
+
return typeof optsOrStrategy === "string" ? { strategy: optsOrStrategy } : optsOrStrategy;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The single decision tree `decideAndWrite` and `previewWriteStatus` both classify
|
|
444
|
+
* through — the fix for the risk named at the top of this file. Two functions that
|
|
445
|
+
* each re-derive "which case applies" from scratch can drift out of step in their
|
|
446
|
+
* branch ORDER, and when they do, the preview lies. Every branch that decides WHICH
|
|
447
|
+
* case an input falls into lives here, exactly once. `decideAndWrite` executes the
|
|
448
|
+
* case (the write, the merge, the snapshot advance — see its own comments for the
|
|
449
|
+
* "how"); `previewWriteStatus` maps the case straight to a `WriteStatus` with no
|
|
450
|
+
* side effects. Neither function repeats the ordering, so neither can disagree with
|
|
451
|
+
* the other about it.
|
|
452
|
+
*/
|
|
453
|
+
type WriteCase =
|
|
454
|
+
| { kind: "new" }
|
|
455
|
+
| { kind: "skip" }
|
|
456
|
+
| { kind: "no-snapshot-fresh-unchanged" }
|
|
457
|
+
| { kind: "no-snapshot-fresh-overwrite" }
|
|
458
|
+
| { kind: "no-snapshot-unchanged" }
|
|
459
|
+
| { kind: "no-snapshot-pristine-overwrite" }
|
|
460
|
+
| { kind: "no-snapshot-refused"; hasRecord: boolean }
|
|
461
|
+
| { kind: "snapshot-unchanged" }
|
|
462
|
+
| { kind: "snapshot-merge-required"; snapshotText: string };
|
|
463
|
+
|
|
464
|
+
function classifyWrite(
|
|
465
|
+
path: string,
|
|
466
|
+
content: string,
|
|
467
|
+
opts: DecideAndWriteOpts,
|
|
468
|
+
): WriteCase {
|
|
469
|
+
// First-time write — file doesn't exist.
|
|
470
|
+
if (!existsSync(path)) return { kind: "new" };
|
|
471
|
+
|
|
472
|
+
if ((opts.strategy ?? "overwrite") === "skip-existing") return { kind: "skip" };
|
|
473
|
+
|
|
474
|
+
// File exists. Load the canonical snapshot if any.
|
|
475
|
+
const genStateDir = resolveGenStateDir(opts);
|
|
476
|
+
const relPath = opts.outputRelPath ?? defaultOutputRelPath(path);
|
|
477
|
+
const snapshot = readSnapshotChecked(genStateDir, relPath);
|
|
478
|
+
const current = readFileSync(path, "utf-8");
|
|
479
|
+
|
|
480
|
+
// First-time regen on a pre-existing file (no snapshot) — caveat 3. `baseline`
|
|
481
|
+
// only ever applies here: BaselineMode's own doc comment says so ("When
|
|
482
|
+
// .gen-state is absent but the file exists"), and a snapshot body — handled
|
|
483
|
+
// below — always gets the real three-way merge regardless of baseline.
|
|
484
|
+
if (snapshot === undefined) {
|
|
485
|
+
if ((opts.baseline ?? "default") === "fresh") {
|
|
486
|
+
// Opt-in escape hatch: overwrite and seed the snapshot from fresh.
|
|
487
|
+
return current === content
|
|
488
|
+
? { kind: "no-snapshot-fresh-unchanged" }
|
|
489
|
+
: { kind: "no-snapshot-fresh-overwrite" };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// No snapshot BODY. That is the normal state, not an edge case: the bodies
|
|
493
|
+
// are gitignored, so every fresh clone and every CI runner arrives here.
|
|
494
|
+
//
|
|
495
|
+
// Previously this branch wrote the fresh content unconditionally, which meant
|
|
496
|
+
// the documented promise that hand edits survive regeneration was false in
|
|
497
|
+
// precisely the situation adopters spend most of their time in — and the CLI
|
|
498
|
+
// labelled the replacement `NEW`.
|
|
499
|
+
//
|
|
500
|
+
// The committed hash manifest answers the only question that matters. Note
|
|
501
|
+
// this is the SAME question, asked of the same evidence, as the orphan-delete
|
|
502
|
+
// path: see `isPristineGenerated`.
|
|
503
|
+
if (current === content) return { kind: "no-snapshot-unchanged" };
|
|
504
|
+
|
|
505
|
+
if (isPristineGenerated(genStateDir, relPath, current)) {
|
|
506
|
+
// Byte-for-byte what we last wrote, so replacing it loses nothing. This is
|
|
507
|
+
// the common fresh-clone case (a formatter or engine bump moved the output)
|
|
508
|
+
// and it must not refuse, or a clean checkout would stall on every file.
|
|
509
|
+
return { kind: "no-snapshot-pristine-overwrite" };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Either somebody edited it (hash mismatch) or we have no record of writing
|
|
513
|
+
// it at all (no hash). Both are unprovable, so fail closed.
|
|
514
|
+
return {
|
|
515
|
+
kind: "no-snapshot-refused",
|
|
516
|
+
hasRecord: readGeneratedHash(genStateDir, relPath) !== undefined,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Snapshot exists — a real three-way merge decides this one.
|
|
521
|
+
// Fast path: nothing changed.
|
|
522
|
+
if (current === content && snapshot.text === content) {
|
|
523
|
+
return { kind: "snapshot-unchanged" };
|
|
524
|
+
}
|
|
525
|
+
return { kind: "snapshot-merge-required", snapshotText: snapshot.text };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* What `decideAndWrite` WOULD do, touching nothing. Backs `meta gen --dry-run`.
|
|
530
|
+
*
|
|
531
|
+
* Exact for every outcome the hash manifest decides, because those are pure
|
|
532
|
+
* comparisons — `classifyWrite` above is the single source for which one applies.
|
|
533
|
+
* Deliberately COARSE in one place: with a snapshot body present the result depends
|
|
534
|
+
* on whether `git merge-file` comes back clean or conflicted, which cannot be known
|
|
535
|
+
* without performing the merge — so that case reports `overwrite`, meaning "this
|
|
536
|
+
* file will be rewritten", which is true either way.
|
|
537
|
+
*
|
|
538
|
+
* The reason this exists as its own function rather than a flag on
|
|
539
|
+
* `decideAndWrite`: a preview must be incapable of writing, and the cheapest way to
|
|
540
|
+
* guarantee that is to give it no write in its body at all — mapping a `WriteCase`
|
|
541
|
+
* to a status touches no file and no manifest.
|
|
542
|
+
*/
|
|
543
|
+
export function previewWriteStatus(
|
|
544
|
+
path: string,
|
|
545
|
+
content: string,
|
|
546
|
+
optsOrStrategy: DecideAndWriteOpts | MergeStrategy = {},
|
|
547
|
+
): WriteStatus {
|
|
548
|
+
const kase = classifyWrite(path, content, normalizeOpts(optsOrStrategy));
|
|
549
|
+
switch (kase.kind) {
|
|
550
|
+
case "new":
|
|
551
|
+
return "new";
|
|
552
|
+
case "skip":
|
|
553
|
+
return "skipped";
|
|
554
|
+
case "no-snapshot-fresh-unchanged":
|
|
555
|
+
case "no-snapshot-unchanged":
|
|
556
|
+
case "snapshot-unchanged":
|
|
557
|
+
return "unchanged";
|
|
558
|
+
case "no-snapshot-fresh-overwrite":
|
|
559
|
+
case "no-snapshot-pristine-overwrite":
|
|
560
|
+
return "overwrite";
|
|
561
|
+
case "no-snapshot-refused":
|
|
562
|
+
return "refused";
|
|
563
|
+
case "snapshot-merge-required":
|
|
564
|
+
return "overwrite";
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
277
568
|
/**
|
|
278
569
|
* The main entry point. Backward-compatible with the rc.11 signature: passing
|
|
279
570
|
* a `MergeStrategy` string as the third argument continues to work; passing
|
|
@@ -284,99 +575,81 @@ export function decideAndWrite(
|
|
|
284
575
|
content: string,
|
|
285
576
|
optsOrStrategy: DecideAndWriteOpts | MergeStrategy = {},
|
|
286
577
|
): WriteResult {
|
|
287
|
-
const opts
|
|
288
|
-
|
|
289
|
-
? { strategy: optsOrStrategy }
|
|
290
|
-
: optsOrStrategy;
|
|
291
|
-
const strategy: MergeStrategy = opts.strategy ?? "overwrite";
|
|
292
|
-
const baseline: BaselineMode = opts.baseline ?? "default";
|
|
293
|
-
const genStateDir =
|
|
294
|
-
opts.genStateDir !== undefined
|
|
295
|
-
? (isAbsolute(opts.genStateDir)
|
|
296
|
-
? opts.genStateDir
|
|
297
|
-
: resolve(opts.genStateDir))
|
|
298
|
-
: join(tmpdir(), "meta-gen-state-fallback");
|
|
578
|
+
const opts = normalizeOpts(optsOrStrategy);
|
|
579
|
+
const genStateDir = resolveGenStateDir(opts);
|
|
299
580
|
const relPath = opts.outputRelPath ?? defaultOutputRelPath(path);
|
|
581
|
+
const kase = classifyWrite(path, content, opts);
|
|
300
582
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (strategy === "skip-existing") {
|
|
310
|
-
return { path, status: "skipped" };
|
|
311
|
-
}
|
|
583
|
+
switch (kase.kind) {
|
|
584
|
+
case "new":
|
|
585
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
586
|
+
writeFileSync(path, content);
|
|
587
|
+
advanceSnapshot(genStateDir, relPath, content);
|
|
588
|
+
return { path, status: "new" };
|
|
312
589
|
|
|
313
|
-
|
|
314
|
-
|
|
590
|
+
case "skip":
|
|
591
|
+
return { path, status: "skipped" };
|
|
315
592
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
593
|
+
case "no-snapshot-fresh-unchanged":
|
|
594
|
+
case "no-snapshot-unchanged":
|
|
595
|
+
// Identical content needs no write, in either mode — just seed/advance the
|
|
596
|
+
// snapshot so the file is recognisable as ours next time.
|
|
597
|
+
advanceSnapshot(genStateDir, relPath, content);
|
|
598
|
+
return { path, status: "unchanged" };
|
|
319
599
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (current === content) {
|
|
323
|
-
advanceSnapshot(genStateDir, relPath, content);
|
|
324
|
-
return { path, status: "unchanged" };
|
|
325
|
-
}
|
|
600
|
+
case "no-snapshot-fresh-overwrite":
|
|
601
|
+
case "no-snapshot-pristine-overwrite":
|
|
326
602
|
writeFileSync(path, content);
|
|
327
603
|
advanceSnapshot(genStateDir, relPath, content);
|
|
328
604
|
return { path, status: "overwrite" };
|
|
329
|
-
}
|
|
330
605
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
606
|
+
case "no-snapshot-refused":
|
|
607
|
+
// Deliberately does NOT advance the snapshot or the hash: a refusal that
|
|
608
|
+
// records the current content would make the file look pristine next run and
|
|
609
|
+
// turn this into a silent overwrite one run later.
|
|
610
|
+
return {
|
|
611
|
+
path,
|
|
612
|
+
status: "refused",
|
|
613
|
+
conflictHint: kase.hasRecord
|
|
614
|
+
? "this file has been edited since it was generated — it was NOT " +
|
|
615
|
+
"overwritten. Move your edits into a non-generated file, or re-run " +
|
|
616
|
+
"with --baseline=fresh to discard them and adopt fresh output."
|
|
617
|
+
: "no record of generating this file, and its content differs from fresh " +
|
|
618
|
+
"output — it was NOT overwritten. Move it aside, or re-run with " +
|
|
619
|
+
"--baseline=fresh to overwrite it and adopt fresh output as the baseline.",
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
case "snapshot-unchanged":
|
|
340
623
|
return { path, status: "unchanged" };
|
|
341
|
-
}
|
|
342
|
-
writeFileSync(path, content);
|
|
343
|
-
advanceSnapshot(genStateDir, relPath, content);
|
|
344
|
-
return { path, status: "overwrite" };
|
|
345
|
-
}
|
|
346
624
|
|
|
347
|
-
|
|
348
|
-
|
|
625
|
+
case "snapshot-merge-required": {
|
|
626
|
+
const baseTmp = writeTmpfile(kase.snapshotText);
|
|
627
|
+
const freshTmp = writeTmpfile(content);
|
|
349
628
|
|
|
350
|
-
|
|
351
|
-
if (current === content && snapshot.text === content) {
|
|
352
|
-
return { path, status: "unchanged" };
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const baseTmp = writeTmpfile(snapshot.text);
|
|
356
|
-
const freshTmp = writeTmpfile(content);
|
|
629
|
+
const outcome = runGitMergeFile(path, baseTmp, freshTmp);
|
|
357
630
|
|
|
358
|
-
|
|
631
|
+
if (outcome.exitCode === 0) {
|
|
632
|
+
// Clean merge — advance the canonical snapshot to fresh.
|
|
633
|
+
advanceSnapshot(genStateDir, relPath, content);
|
|
634
|
+
// Distinguish "user had no changes vs canonical" from "merge integrated
|
|
635
|
+
// edits". The fresh-equals-snapshot case is `snapshot-unchanged` above —
|
|
636
|
+
// so if the merged result equals fresh we report a plain overwrite,
|
|
637
|
+
// otherwise it's a merge that pulled in user edits.
|
|
638
|
+
return {
|
|
639
|
+
path,
|
|
640
|
+
status: outcome.mergedContent === content ? "overwrite" : "merged",
|
|
641
|
+
};
|
|
642
|
+
}
|
|
359
643
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
644
|
+
// Conflict — do NOT advance the snapshot. The output now contains diff3
|
|
645
|
+
// markers from git merge-file.
|
|
646
|
+
return {
|
|
647
|
+
path,
|
|
648
|
+
status: "conflict",
|
|
649
|
+
conflictHint:
|
|
650
|
+
"merge conflict — resolve `<<<<<<<` markers and re-run `meta gen` to " +
|
|
651
|
+
"advance the canonical state.",
|
|
652
|
+
};
|
|
369
653
|
}
|
|
370
|
-
return { path, status: "merged" };
|
|
371
654
|
}
|
|
372
|
-
|
|
373
|
-
// Conflict — do NOT advance the snapshot. The output now contains diff3
|
|
374
|
-
// markers from git merge-file.
|
|
375
|
-
return {
|
|
376
|
-
path,
|
|
377
|
-
status: "conflict",
|
|
378
|
-
conflictHint:
|
|
379
|
-
"merge conflict — resolve `<<<<<<<` markers and re-run `meta gen` to " +
|
|
380
|
-
"advance the canonical state.",
|
|
381
|
-
};
|
|
382
655
|
}
|
|
@@ -53,6 +53,14 @@ export interface ExpectedView {
|
|
|
53
53
|
name: string;
|
|
54
54
|
schema?: string;
|
|
55
55
|
sql: string;
|
|
56
|
+
/**
|
|
57
|
+
* `resolutionKey()` of the object that declared this view — the projection, or the
|
|
58
|
+
* write-through entity hosting its own read view. migrate-ts records it as
|
|
59
|
+
* PROVENANCE (never onto the view descriptor, never into the committed snapshot) so
|
|
60
|
+
* a per-command `migrate.scope` can decide ownership on the declaring FQN rather
|
|
61
|
+
* than on the physical view name, which no naming strategy can reverse.
|
|
62
|
+
*/
|
|
63
|
+
fqn: string;
|
|
56
64
|
/**
|
|
57
65
|
* Physical tables this view reads (base + every joined table). The migrate-ts
|
|
58
66
|
* diff uses this to recreate the view when one of its source tables undergoes a
|
|
@@ -199,6 +207,7 @@ function emitViewFor(
|
|
|
199
207
|
sql: body,
|
|
200
208
|
dependsOn,
|
|
201
209
|
columns,
|
|
210
|
+
fqn: host.resolutionKey(),
|
|
202
211
|
...(schema !== undefined ? { schema } : {}),
|
|
203
212
|
});
|
|
204
213
|
}
|
|
@@ -240,6 +249,7 @@ function emitSqlView(
|
|
|
240
249
|
name: source.physicalName, // FR-016 four-step physical name
|
|
241
250
|
sql: source.sqlBody!, // verbatim — never parsed, never re-wrapped
|
|
242
251
|
dependsOn,
|
|
252
|
+
fqn: host.resolutionKey(),
|
|
243
253
|
// columns OMITTED → "unknown" → gated drop+create fail-safe.
|
|
244
254
|
...(schema !== undefined ? { schema } : {}),
|
|
245
255
|
});
|
|
@@ -305,10 +315,11 @@ function collectViewColumns(
|
|
|
305
315
|
|
|
306
316
|
const out: ExpectedViewColumn[] = [];
|
|
307
317
|
for (const c of spec.selectSpec.columns) {
|
|
308
|
-
// #195: the
|
|
309
|
-
// not resolve to a single (table, column)
|
|
310
|
-
// an expression, first is a correlated
|
|
311
|
-
// result types are richer than the
|
|
318
|
+
// #195/#335: the five non-scalar origin column kinds (predicateAgg / collectAgg /
|
|
319
|
+
// collectObjectAgg / computed / first) do not resolve to a single (table, column)
|
|
320
|
+
// SqlType via the prefix rule — computed is an expression, first is a correlated
|
|
321
|
+
// subquery, and the array/jsonb/boolean aggregate result types are richer than the
|
|
322
|
+
// OR-REPLACE prefix check models. Per this module's
|
|
312
323
|
// fail-safe doctrine (unknown → drop+create, never a wrong-but-confident replace),
|
|
313
324
|
// an unknown column drops the whole list so migrate routes through a gated
|
|
314
325
|
// drop+create. Precise native typing is a later phase.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
TYPE_FIELD,
|
|
3
|
+
FIELD_ATTR_OBJECT_REF,
|
|
3
4
|
TYPE_IDENTITY,
|
|
4
5
|
TYPE_ORIGIN,
|
|
5
6
|
TYPE_RELATIONSHIP,
|
|
@@ -886,6 +887,59 @@ function buildSelectSpec(
|
|
|
886
887
|
continue;
|
|
887
888
|
}
|
|
888
889
|
|
|
890
|
+
// #335 — @of ABSENT on collect is a WHOLE-OBJECT rollup. This arm sits ABOVE
|
|
891
|
+
// the `if (!of_) continue;` guard on purpose: that guard used to swallow this
|
|
892
|
+
// shape, so the metadata LOADED and the column was silently dropped from the
|
|
893
|
+
// CREATE VIEW while the generated type still declared the field.
|
|
894
|
+
// The related entity comes from @via's terminal hop, not from @of — exactly as
|
|
895
|
+
// the any/all arm above resolves it.
|
|
896
|
+
const wholeObjectCollect =
|
|
897
|
+
agg === AGG_COLLECT && origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF) === undefined;
|
|
898
|
+
if (wholeObjectCollect) {
|
|
899
|
+
// Every `continue` below is a loader-guaranteed impossibility (the load fails
|
|
900
|
+
// with ERR_COLLECT_WHOLE_OBJECT / ERR_SUBTYPE_RULE_VIOLATION /
|
|
901
|
+
// ERR_COLLECT_MEMBER_UNRESOLVED before codegen runs); they are defence for a
|
|
902
|
+
// caller that reached codegen without loading, never a silent-drop path.
|
|
903
|
+
const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA) as string | undefined;
|
|
904
|
+
if (!via) continue;
|
|
905
|
+
// ADR-0039: resolving — @objectRef may be inherited via extends.
|
|
906
|
+
const objectRef = field.attr(FIELD_ATTR_OBJECT_REF) as string | undefined;
|
|
907
|
+
if (!objectRef) continue;
|
|
908
|
+
const relatedName = viaTerminalEntity(via, root, projPkg);
|
|
909
|
+
if (!relatedName) continue;
|
|
910
|
+
const relatedEntity = resolveEntityRef(root, relatedName, projPkg);
|
|
911
|
+
if (!relatedEntity) continue;
|
|
912
|
+
const sourceAlias = findAliasInTree(joinTree, relatedEntity.resolutionKey());
|
|
913
|
+
if (sourceAlias === undefined) continue;
|
|
914
|
+
const joinedPk = primaryKeyColumn(relatedEntity, ctx);
|
|
915
|
+
if (joinedPk === undefined) continue;
|
|
916
|
+
// ADR-0042 — a bare @objectRef resolves in the projection's package.
|
|
917
|
+
const vo = resolveObjectRef(root, objectRef, projPkg).node;
|
|
918
|
+
if (!vo) continue;
|
|
919
|
+
// ADR-0039: resolving — a value object may inherit members via extends, and the
|
|
920
|
+
// terminal entity may inherit fields. The VO's member list IS the exposure: a
|
|
921
|
+
// field the terminal has but the VO omits is deliberately not projected (#270).
|
|
922
|
+
const members: { memberName: string; sourceColumn: string }[] = [];
|
|
923
|
+
for (const m of vo.children().filter((c): c is MetaField => c.type === TYPE_FIELD)) {
|
|
924
|
+
const target = relatedEntity.fields().find((f) => f.name === m.name);
|
|
925
|
+
if (target === undefined) continue; // loader: ERR_COLLECT_MEMBER_UNRESOLVED
|
|
926
|
+
members.push({ memberName: m.name, sourceColumn: sourceColumnNameFor(target, ctx) });
|
|
927
|
+
}
|
|
928
|
+
if (members.length === 0) continue;
|
|
929
|
+
columns.push({
|
|
930
|
+
kind: "collectObjectAgg",
|
|
931
|
+
fieldName: field.name,
|
|
932
|
+
dbColAlias: dbCol,
|
|
933
|
+
sourceAlias,
|
|
934
|
+
joinedPkColumn: joinedPk,
|
|
935
|
+
members,
|
|
936
|
+
// @orderBy resolves against the @via TERMINAL entity, not @of — there is no
|
|
937
|
+
// @of entity. Empty ⇒ the emitter's default of related-PK ascending.
|
|
938
|
+
orderBy: resolveOrderByKeys(origin.ownAttr(ORIGIN_ATTR_ORDER_BY), relatedEntity, ctx),
|
|
939
|
+
});
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
|
|
889
943
|
// collect + the scalar reduces (count/sum/avg/min/max) all name @of.
|
|
890
944
|
const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF) as string | undefined;
|
|
891
945
|
if (!of_) continue;
|
|
@@ -1003,6 +1057,9 @@ function buildSelectSpec(
|
|
|
1003
1057
|
function isInflationSensitive(c: SelectColumn): boolean {
|
|
1004
1058
|
if (c.kind === "aggregate") return c.agg === "sum" || c.agg === "avg";
|
|
1005
1059
|
if (c.kind === "collectAgg") return !c.distinct;
|
|
1060
|
+
// #335 — a whole-object collect is ALWAYS non-distinct (the loader refuses @distinct
|
|
1061
|
+
// on this form), so it is unconditionally inflation-sensitive.
|
|
1062
|
+
if (c.kind === "collectObjectAgg") return true;
|
|
1006
1063
|
return false;
|
|
1007
1064
|
}
|
|
1008
1065
|
|
|
@@ -1015,10 +1072,12 @@ function countManyBranches(joinTree: JoinTree): number {
|
|
|
1015
1072
|
}
|
|
1016
1073
|
|
|
1017
1074
|
function buildGroupBy(spec: SelectSpec): string[] {
|
|
1018
|
-
// predicateAgg (bool_or/bool_and)
|
|
1019
|
-
// force GROUP BY too; computed/first are
|
|
1075
|
+
// predicateAgg (bool_or/bool_and), collectAgg (array_agg) and collectObjectAgg
|
|
1076
|
+
// (jsonb_agg) are real aggregates and force GROUP BY too; computed/first are
|
|
1077
|
+
// scalar-per-row and never grouped.
|
|
1020
1078
|
const hasAgg = spec.columns.some(
|
|
1021
|
-
(c) => c.kind === "aggregate" || c.kind === "predicateAgg"
|
|
1079
|
+
(c) => c.kind === "aggregate" || c.kind === "predicateAgg"
|
|
1080
|
+
|| c.kind === "collectAgg" || c.kind === "collectObjectAgg",
|
|
1022
1081
|
);
|
|
1023
1082
|
if (!hasAgg) return [];
|
|
1024
1083
|
return spec.columns
|