@metaobjectsdev/metadata 0.24.0 → 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.
Files changed (36) hide show
  1. package/dist/core/identity/identity-definition.embedded.js +2 -2
  2. package/dist/core/identity/identity-definition.embedded.js.map +1 -1
  3. package/dist/core/index/index-definition.embedded.js +2 -2
  4. package/dist/core/index/index-definition.embedded.js.map +1 -1
  5. package/dist/core/vocabulary-rewrite-yaml.d.ts +21 -0
  6. package/dist/core/vocabulary-rewrite-yaml.d.ts.map +1 -0
  7. package/dist/core/vocabulary-rewrite-yaml.js +245 -0
  8. package/dist/core/vocabulary-rewrite-yaml.js.map +1 -0
  9. package/dist/errors.d.ts +1 -1
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +21 -2
  12. package/dist/errors.js.map +1 -1
  13. package/dist/loader/meta-data-loader.d.ts.map +1 -1
  14. package/dist/loader/meta-data-loader.js +4 -1
  15. package/dist/loader/meta-data-loader.js.map +1 -1
  16. package/dist/loader/validation-passes.d.ts +1 -0
  17. package/dist/loader/validation-passes.d.ts.map +1 -1
  18. package/dist/loader/validation-passes.js +261 -43
  19. package/dist/loader/validation-passes.js.map +1 -1
  20. package/dist/persistence/origin/origin-definition.embedded.js +5 -5
  21. package/dist/persistence/origin/origin-definition.embedded.js.map +1 -1
  22. package/dist/registry-manifest.d.ts +1 -1
  23. package/dist/registry-manifest.js +1 -1
  24. package/dist/vocabulary-rewrite.d.ts.map +1 -1
  25. package/dist/vocabulary-rewrite.js +12 -8
  26. package/dist/vocabulary-rewrite.js.map +1 -1
  27. package/package.json +6 -1
  28. package/src/core/identity/identity-definition.embedded.ts +2 -2
  29. package/src/core/index/index-definition.embedded.ts +2 -2
  30. package/src/core/vocabulary-rewrite-yaml.ts +267 -0
  31. package/src/errors.ts +21 -2
  32. package/src/loader/meta-data-loader.ts +5 -1
  33. package/src/loader/validation-passes.ts +320 -47
  34. package/src/persistence/origin/origin-definition.embedded.ts +5 -5
  35. package/src/registry-manifest.ts +1 -1
  36. package/src/vocabulary-rewrite.ts +12 -8
@@ -0,0 +1,267 @@
1
+ // server/typescript/packages/metadata/src/core/vocabulary-rewrite-yaml.ts
2
+ //
3
+ // The YAML arm of the raw-document rewriter behind `meta upgrade`.
4
+ //
5
+ // WHY IT IS A SEPARATE MODULE. `vocabulary-rewrite.ts` is reachable from `src/index.ts`, so
6
+ // it may not import `yaml` — that package is Node-only and would land in the browser bundle.
7
+ // This file carries the `yaml` dependency and is reachable ONLY through its own package
8
+ // subpath, which `meta upgrade` dynamic-imports. Same split, and same reason, as
9
+ // `yaml-positions.ts` / `yaml-positions-walker.ts` (see that file's header).
10
+ //
11
+ // WHY IT IS PARSER-DRIVEN WHERE THE JSON ARM IS REGEX-DRIVEN. A hand-rolled YAML mode was
12
+ // tried once and shipped a file-corrupting bug: a multi-item block sequence lost every item
13
+ // but the first, because a hand-written value scanner stops at a newline, and the dominant
14
+ // in-repo flow style (`{ name: x, readOnly: true }`) was not matched at all — so the rename
15
+ // silently did nothing. Both failures are the same failure: YAML's value extent is not
16
+ // derivable by scanning. Here the PARSER reports it. `pair.value.range` covers a four-line
17
+ // block sequence and a one-line flow mapping alike, so neither case is a special case.
18
+ //
19
+ // STILL SURGICAL, NOT PARSE-AND-REPRINT. `doc.toString()` would reflow an adopter's file —
20
+ // line width, quote style, indentation of flow collections — and hand them a diff whose real
21
+ // changes are invisible inside it. So the parse is used only to LOCATE spans; every edit is a
22
+ // span replacement on the original text, and any region not deliberately changed comes back
23
+ // byte-identical. That is the same guarantee the JSON arm makes, by the same means.
24
+ //
25
+ // SIGIL-FREE, PER ADR-0006. YAML authoring writes bare attribute keys (`violation:`) and the
26
+ // desugar re-adds the `@` when lowering to canonical JSON. So a rename emits a BARE key here
27
+ // where the JSON arm emits `"@name"`. A leading `@` is still matched on input — an author who
28
+ // wrote one gets it fixed rather than skipped — but is never introduced.
29
+
30
+ import { LineCounter, isMap, isSeq, parseDocument, type Node, type Pair } from "yaml";
31
+ import {
32
+ RETIRED_VOCABULARY,
33
+ note,
34
+ scopeMatches,
35
+ type RetiredEntry,
36
+ } from "../retired-vocabulary.js";
37
+ import type { RewriteChange, RewriteRefusal, RewriteOpts, RewriteResult } from "../vocabulary-rewrite.js";
38
+
39
+ /**
40
+ * A rewrite result that can also report "I could not read this file".
41
+ *
42
+ * A YAML document that does not parse yields no changes and no refusals, which is
43
+ * indistinguishable from a clean one — and a fixer that reports a file it could not open as
44
+ * clean is the exact defect this arm was written to remove (#339). The flag makes the caller
45
+ * say so out loud.
46
+ */
47
+ export interface YamlRewriteResult extends RewriteResult {
48
+ readonly unparseable: boolean;
49
+ }
50
+
51
+ /** A canonical node key: `<type>.<subType>`. Identical to the JSON arm's scope shape. */
52
+ const TYPE_KEY = /^[a-z][A-Za-z0-9]*\.[A-Za-z0-9_*]+$/;
53
+
54
+ /** `0.24.0` → `[0,24,0]`, for an ordered comparison rather than a string one. */
55
+ function parts(v: string): number[] {
56
+ return v.split(".").map((n) => Number.parseInt(n, 10) || 0);
57
+ }
58
+
59
+ function atOrBefore(a: string, b: string): boolean {
60
+ const [x, y] = [parts(a), parts(b)];
61
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
62
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
63
+ if (d !== 0) return d < 0;
64
+ }
65
+ return true;
66
+ }
67
+
68
+ /** The plain string a mapping key carries, or undefined when it is not a plain scalar. */
69
+ function keyText(key: unknown): string | undefined {
70
+ const v = (key as { value?: unknown } | null)?.value;
71
+ return typeof v === "string" ? v : undefined;
72
+ }
73
+
74
+ /** Source offsets of a pair's key and of the end of its value. */
75
+ function pairSpan(pair: Pair): { keyStart: number; keyEnd: number; valueEnd: number } | undefined {
76
+ const k = pair.key as { range?: [number, number, number] } | null;
77
+ const v = pair.value as { range?: [number, number, number] } | null;
78
+ if (k?.range === undefined) return undefined;
79
+ return {
80
+ keyStart: k.range[0],
81
+ keyEnd: k.range[1],
82
+ // A valueless key (`verifiedBy:` with nothing after it) still has to be removable.
83
+ valueEnd: v?.range?.[1] ?? k.range[1],
84
+ };
85
+ }
86
+
87
+ /**
88
+ * The span to delete so that removing a pair leaves loadable YAML.
89
+ *
90
+ * Two shapes, and they need opposite treatment — which is precisely what the previous
91
+ * hand-rolled attempt got wrong by handling only one.
92
+ */
93
+ function dropSpan(
94
+ source: string,
95
+ span: { keyStart: number; valueEnd: number },
96
+ flow: boolean,
97
+ ): { start: number; end: number } | undefined {
98
+ let { keyStart: start } = span;
99
+ let end = span.valueEnd;
100
+
101
+ if (flow) {
102
+ // `{ a: 1, readOnly: true }` — take a trailing comma if there is one, else a preceding
103
+ // one, so the mapping ends up with neither a dangling nor a doubled separator.
104
+ //
105
+ // The probe must not commit: scanning forward over the spaces and THEN finding `}`
106
+ // rather than `,` would leave `end` past the space that separates the survivor from the
107
+ // brace, silently reformatting `{ name: x }` into `{ name: x}`.
108
+ let probe = end;
109
+ while (probe < source.length && /[ \t]/.test(source[probe] ?? "")) probe++;
110
+ if (source[probe] === ",") {
111
+ end = probe + 1;
112
+ while (end < source.length && /[ \t]/.test(source[end] ?? "")) end++;
113
+ } else {
114
+ let back = start;
115
+ while (back > 0 && /\s/.test(source[back - 1] ?? "")) back--;
116
+ if (source[back - 1] === ",") start = back - 1;
117
+ }
118
+ return { start, end };
119
+ }
120
+
121
+ // Block mapping — the pair owns whole lines. Absorb its indentation and its line
122
+ // terminator, so removal leaves neither a ragged line nor a blank one.
123
+ while (start > 0 && /[ \t]/.test(source[start - 1] ?? "")) start--;
124
+
125
+ // A pair that is the first key of a block SEQUENCE item (`- verifiedBy: x`) shares its
126
+ // line with the `-`. Deleting it would strand the dash and silently change the sequence's
127
+ // shape, so this refuses rather than guesses — the caller reports it as needing a hand.
128
+ if (source[start - 1] === "-") return undefined;
129
+
130
+ // A multi-line value (a block sequence) already ends ON the newline that closes its last
131
+ // item, so the terminator is spent. Consuming another one here would delete the FOLLOWING
132
+ // key — which is the multi-item-sequence corruption this arm exists to avoid, arriving by
133
+ // a different route.
134
+ if (end === 0 || source[end - 1] !== "\n") {
135
+ while (end < source.length && /[ \t]/.test(source[end] ?? "")) end++;
136
+ // A trailing comment on the key's own line goes with the key it annotates.
137
+ if (source[end] === "#") while (end < source.length && source[end] !== "\n") end++;
138
+ if (source[end] === "\n") end++;
139
+ }
140
+ return { start, end };
141
+ }
142
+
143
+ /** Visit every mapping pair with the `<type>.<subType>` scope governing it. */
144
+ function eachPair(
145
+ node: unknown,
146
+ scope: string | undefined,
147
+ visit: (pair: Pair, scope: string | undefined, flow: boolean) => void,
148
+ ): void {
149
+ if (isMap(node)) {
150
+ const flow = node.flow === true;
151
+ for (const pair of node.items) {
152
+ const k = keyText(pair.key);
153
+ visit(pair, scope, flow);
154
+ // A type key scopes its own BODY, not itself — so the pair above is reported under the
155
+ // enclosing scope while its value descends under this one.
156
+ const inner = k !== undefined && TYPE_KEY.test(k) ? k : scope;
157
+ if (pair.value != null) eachPair(pair.value, inner, visit);
158
+ }
159
+ return;
160
+ }
161
+ if (isSeq(node)) {
162
+ for (const item of node.items) eachPair(item as Node, scope, visit);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Rewrite retired vocabulary in one raw YAML metadata document.
168
+ *
169
+ * Pure: no filesystem, no loader, no registry. Mirrors `rewriteDocument`'s contract exactly —
170
+ * same result shape, same scoping rule, same refusal policy — so `meta upgrade` reports a
171
+ * YAML estate and a JSON estate identically.
172
+ */
173
+ export function rewriteYamlDocument(source: string, opts: RewriteOpts = {}): YamlRewriteResult {
174
+ const changes: RewriteChange[] = [];
175
+ const refusals: RewriteRefusal[] = [];
176
+ const edits: { start: number; end: number; text: string }[] = [];
177
+
178
+ const lineCounter = new LineCounter();
179
+ const doc = parseDocument(source, { lineCounter, keepSourceTokens: true });
180
+ // A document we cannot parse is a document we must not edit. Reporting nothing here is
181
+ // correct: `meta verify` owns malformed YAML, and guessing at spans in a broken file is
182
+ // how a fixer corrupts one.
183
+ if (doc.errors.length > 0 || doc.contents == null) {
184
+ return { text: source, changes, refusals, unparseable: true };
185
+ }
186
+
187
+ const lineOf = (offset: number): number => lineCounter.linePos(offset).line;
188
+ const inWindow = (e: RetiredEntry): boolean =>
189
+ opts.maxVersion === undefined || atOrBefore(e.since, opts.maxVersion);
190
+
191
+ eachPair(doc.contents, undefined, (pair, scope, flow) => {
192
+ const key = keyText(pair.key);
193
+ if (key === undefined) return;
194
+ const span = pairSpan(pair);
195
+ if (span === undefined) return;
196
+
197
+ // A retired SUBTYPE has no attribute to rewrite — the node itself has to be re-modelled,
198
+ // which is the adopter's judgment. Reporting it is what keeps `meta upgrade` from exiting
199
+ // 0 on a document that still will not load.
200
+ if (TYPE_KEY.test(key)) {
201
+ for (const entry of RETIRED_VOCABULARY) {
202
+ if (entry.isSubTypeRetirement !== true || !inWindow(entry)) continue;
203
+ if (`${entry.type}.${entry.subType}` !== key) continue;
204
+ refusals.push({ ...note(entry), subject: key, line: lineOf(span.keyStart) });
205
+ }
206
+ return;
207
+ }
208
+
209
+ // Sigil-free authoring is the norm, but an authored `@` must not make a retirement
210
+ // invisible.
211
+ const bare = key.startsWith("@") ? key.slice(1) : key;
212
+ const raw = source.slice(span.keyEnd, span.valueEnd).replace(/^\s*:\s*/, "").trim();
213
+ const line = lineOf(span.keyStart);
214
+
215
+ for (const entry of RETIRED_VOCABULARY) {
216
+ if (entry.attr !== bare || !inWindow(entry)) continue;
217
+ if (scope === undefined || !scopeMatches(entry, scope)) continue;
218
+
219
+ // A VALUE-scoped retirement only fires on the retired values — the same attribute with
220
+ // a live value must come through untouched.
221
+ if (entry.attrValues !== undefined && !entry.attrValues.some((v) => raw === v || raw === `"${v}"` || raw === `'${v}'`)) {
222
+ continue;
223
+ }
224
+
225
+ const refuse = (): void => {
226
+ refusals.push({ ...note(entry), subject: `@${bare}`, ...(raw !== "" ? { value: raw } : {}), line });
227
+ };
228
+ const drop = (): void => {
229
+ const d = dropSpan(source, span, flow);
230
+ // Undeletable in place (a sequence item's leading key) — report it instead of
231
+ // producing YAML that parses as something else.
232
+ if (d === undefined) {
233
+ refuse();
234
+ return;
235
+ }
236
+ edits.push({ ...d, text: "" });
237
+ changes.push({ attr: bare, from: bare, to: "(removed)", line });
238
+ };
239
+
240
+ const rw = entry.rewrite;
241
+ if (rw === undefined) refuse();
242
+ else if (rw.kind === "renameAttr") {
243
+ // Preserve the author's quoting style; YAML keys are usually bare, but a quoted key
244
+ // must stay quoted or the surrounding style stops being self-consistent.
245
+ const rawKey = source.slice(span.keyStart, span.keyEnd);
246
+ const q = rawKey[0] === '"' || rawKey[0] === "'" ? rawKey[0] : "";
247
+ edits.push({ start: span.keyStart, end: span.keyEnd, text: `${q}${rw.to}${q}` });
248
+ changes.push({ attr: bare, from: bare, to: rw.to, line });
249
+ } else if (rw.kind === "dropAttr") drop();
250
+ else if (raw === String(rw.fromValue) || raw === `"${rw.fromValue}"` || raw === `'${rw.fromValue}'`) {
251
+ const valText = typeof rw.toValue === "string" ? String(rw.toValue) : JSON.stringify(rw.toValue);
252
+ edits.push({ start: span.keyStart, end: span.keyEnd, text: rw.toAttr });
253
+ edits.push({ start: span.keyEnd, end: span.valueEnd, text: `: ${valText}` });
254
+ changes.push({ attr: bare, from: `${bare}: ${raw}`, to: `${rw.toAttr}: ${valText}`, line });
255
+ } else if (rw.otherwise === "drop") drop();
256
+ else refuse();
257
+ }
258
+ });
259
+
260
+ // Applied right-to-left against the ORIGINAL text: rewriting incrementally would invalidate
261
+ // every later offset.
262
+ edits.sort((a, b) => b.start - a.start);
263
+ let text = source;
264
+ for (const e of edits) text = text.slice(0, e.start) + e.text + text.slice(e.end);
265
+
266
+ return { text, changes, refusals, unparseable: false };
267
+ }
package/src/errors.ts CHANGED
@@ -159,6 +159,23 @@ export const ERROR_CODES = [
159
159
  // SP-H Unit9 — @filterable: true on a field subtype with no filter-operator
160
160
  // band (e.g. field.object). Would silently generate an empty-ops filter.
161
161
  "ERR_FILTERABLE_UNSUPPORTED_SUBTYPE",
162
+ // #335 Half B — @sortable: true on an array field or a subtype with no
163
+ // filter-operator band (e.g. field.object). Would silently emit a sort
164
+ // entry over a column no dialect can ORDER BY.
165
+ "ERR_SORTABLE_UNSUPPORTED_SUBTYPE",
166
+ // #335 Half A — a whole-object @agg:collect (no @of; the carrying field.object
167
+ // rolls related rows up as its declared @objectRef value object) is malformed:
168
+ // carrier is not a field.object with @objectRef, @via absent, @distinct declared
169
+ // (refused — a no-op whenever the value object carries the primary key), an
170
+ // @orderBy key not on the @via TERMINAL entity, or a member's declared type
171
+ // disagreeing with the matched terminal field's. Distinct from ERR_INVALID_ORIGIN
172
+ // so a fixture can tell this arm from a loader that still requires @of.
173
+ "ERR_COLLECT_WHOLE_OBJECT",
174
+ // #335 Half A — a whole-object @agg:collect's value-object member has no
175
+ // matching field (by name) on the @via terminal entity. The lowering
176
+ // projects exactly the declared members; failing open here is how #270
177
+ // turned a curated value object into the full entity.
178
+ "ERR_COLLECT_MEMBER_UNRESOLVED",
162
179
  // ADR-0023 — a registration was attempted against a registry sealed after its
163
180
  // agreed metamodel-provider bootstrap. Codegen cannot invent metamodel attrs.
164
181
  "ERR_REGISTRY_SEALED",
@@ -174,8 +191,10 @@ export const ERROR_CODES = [
174
191
  // clash / required-child cycle / conflicting attr redefinition. The detail names
175
192
  // which of the six checks fired and the offending type(s).
176
193
  "ERR_INVALID_METAMODEL_CONSTRAINT",
177
- // index.lookup field-resolution: @fields is empty or names a field that does
178
- // not exist on the owning entity's effective (resolved via extends) field set.
194
+ // Index-key resolution for index.lookup AND identity.secondary (#342) the key is
195
+ // @fields XOR @expr: neither declared, BOTH declared (@expr is used INSTEAD of
196
+ // @fields), whichever is declared supplies no key, or a named field does not exist
197
+ // on the owning entity's effective (resolved via extends) field set.
179
198
  "ERR_INVALID_INDEX",
180
199
  // #195 — origin.computed @expr: the expression tree's inferred root type does
181
200
  // not equal the carrying field's declared field.<subType>. A computed column's
@@ -18,7 +18,7 @@ import { ParseError } from "../errors.js";
18
18
  import type { LoaderWarning } from "../source.js";
19
19
  import { codeSource, resolvedSource } from "../source.js";
20
20
  import { parseJson } from "../parser-json.js";
21
- import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateIndexLookupFields, validateProjectionFilter } from "./validation-passes.js";
21
+ import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateIndexLookupFields, validateProjectionFilter } from "./validation-passes.js";
22
22
  import { runRegisteredValidation } from "./validation-registry.js";
23
23
  import { validateSourceRoles } from "../persistence/source/validate-source-roles.js";
24
24
  import { validateSourceEscapes } from "../persistence/source/validate-source-escapes.js";
@@ -578,6 +578,10 @@ export class MetaDataLoader {
578
578
  // (would silently generate a filter that rejects every request).
579
579
  errors.push(...validateFilterableHasSupportedOps(root));
580
580
 
581
+ // #335 Half B — @sortable on an array field or a subtype with no operator
582
+ // band → error (would silently emit a sort entry no dialect can execute).
583
+ errors.push(...validateSortableHasSupportedSubtype(root));
584
+
581
585
  // Sixth pass: origin path validation — validates passthrough.@from,
582
586
  // aggregate.@of, and .@via relationship chains.
583
587
  errors.push(...validateOriginPaths(root));