@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
@@ -13,7 +13,7 @@
13
13
  import type { MetaData } from "../shared/meta-data.js";
14
14
  import type { MetaObject } from "../core/object/meta-object.js";
15
15
  import type { MetaReferenceIdentity } from "../core/identity/meta-identity.js";
16
- import { ParseError } from "../errors.js";
16
+ import { ParseError, type ErrorCode } from "../errors.js";
17
17
  import { resolveObjectRef, didYouMeanHint } from "../naming-refs.js";
18
18
  import { PACKAGE_SEPARATOR, CHILD_REF_SEPARATOR } from "../shared/structural.js";
19
19
  import { resolvedSource, type ErrorSource } from "../source.js";
@@ -32,6 +32,8 @@ import {
32
32
  INDEX_SUBTYPE_LOOKUP,
33
33
  INDEX_ATTR_FIELDS,
34
34
  } from "../core/index/index-constants.js";
35
+ import { IDENTITY_SUBTYPE_SECONDARY } from "../core/identity/identity-constants.js";
36
+ import type { MetaIdentity } from "../core/identity/meta-identity.js";
35
37
  import { MetaIndex } from "../core/index/meta-index.js";
36
38
  import {
37
39
  TEMPLATE_ATTR_PAYLOAD_REF,
@@ -59,6 +61,7 @@ import {
59
61
  } from "../presentation/layout/layout-constants.js";
60
62
  import {
61
63
  FIELD_ATTR_FILTERABLE,
64
+ FIELD_ATTR_SORTABLE,
62
65
  FIELD_ATTR_OBJECT_REF,
63
66
  FIELD_ATTR_STORAGE,
64
67
  STORAGE_FLATTENED,
@@ -81,7 +84,7 @@ import {
81
84
  FIELD_SUBTYPE_TIMESTAMP,
82
85
  FIELD_SUBTYPE_UUID,
83
86
  } from "../core/field/field-constants.js";
84
- import { FIELD_ATTR_DB_INDEXED } from "../persistence/db/db-constants.js";
87
+ import { FIELD_ATTR_DB_INDEXED, IDENTITY_ATTR_EXPR } from "../persistence/db/db-constants.js";
85
88
  import {
86
89
  IDENTITY_ATTR_FIELDS,
87
90
  IDENTITY_SUBTYPE_REFERENCE,
@@ -441,6 +444,26 @@ export function validateFilterableHasSupportedOps(root: MetaData): ParseError[]
441
444
  for (const field of obj.children().filter((c) => c.type === TYPE_FIELD)) {
442
445
  // ADR-0039: resolving — a concrete field may inherit @filterable via extends.
443
446
  if (field.attr(FIELD_ATTR_FILTERABLE) !== true) continue;
447
+
448
+ // #335 Half B — an ARRAY field has no operator band either. Every FR-009
449
+ // operator (eq/ne/gt/gte/lt/lte/in/like/isNull) is a scalar comparison;
450
+ // none applies to a collection column. The allowlist template does not
451
+ // consult isArray and falls through to the "string" band, so this
452
+ // previously emitted a `like` rule against a text[] column — SQL that
453
+ // cannot execute. Same reason as the subtype check below, so same code.
454
+ // ADR-0039: resolvedIsArray(), never the own `isArray` flag.
455
+ if (field.resolvedIsArray()) {
456
+ errors.push(
457
+ new ParseError(
458
+ `Field "${obj.name}.${field.name}" has @filterable: true but is an array ` +
459
+ `(isArray: true). No filter operator applies to a collection column. ` +
460
+ `Remove @filterable from this field.`,
461
+ { code: "ERR_FILTERABLE_UNSUPPORTED_SUBTYPE", source: field.source },
462
+ ),
463
+ );
464
+ continue;
465
+ }
466
+
444
467
  if (opsForSubType(field.subType).length > 0) continue;
445
468
  errors.push(
446
469
  new ParseError(
@@ -455,6 +478,40 @@ export function validateFilterableHasSupportedOps(root: MetaData): ParseError[]
455
478
  return errors;
456
479
  }
457
480
 
481
+ // ---------------------------------------------------------------------------
482
+ // @sortable on a subtype or shape that cannot be ordered (#335 Half B)
483
+ // ---------------------------------------------------------------------------
484
+ // @sortable defaults FROM @filterable, so it is independently set only when
485
+ // explicit — and nothing validated it, while @filterable has had a hard error
486
+ // since SP-H Unit9. A @sortable JSON or array column emits a sort entry over a
487
+ // column no dialect can ORDER BY meaningfully. → ERR_SORTABLE_UNSUPPORTED_SUBTYPE.
488
+
489
+ export function validateSortableHasSupportedSubtype(root: MetaData): ParseError[] {
490
+ const errors: ParseError[] = [];
491
+ // ADR-0039: root has no super; children()==ownChildren() but resolving is the default.
492
+ for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) {
493
+ // children() — inherited @sortable fields (via extends:/super:) are visible.
494
+ for (const field of obj.children().filter((c) => c.type === TYPE_FIELD)) {
495
+ // ADR-0039: resolving — a concrete field may inherit @sortable via extends.
496
+ if (field.attr(FIELD_ATTR_SORTABLE) !== true) continue;
497
+ // ADR-0039: resolvedIsArray(), never the own `isArray` flag.
498
+ const isArray = field.resolvedIsArray();
499
+ if (!isArray && opsForSubType(field.subType).length > 0) continue;
500
+ errors.push(
501
+ new ParseError(
502
+ `Field "${obj.name}.${field.name}" has @sortable: true but ` +
503
+ (isArray
504
+ ? `is an array (isArray: true) — a collection column has no ordering.`
505
+ : `its subtype "${field.subType}" cannot be ordered.`) +
506
+ ` Remove @sortable from this field.`,
507
+ { code: "ERR_SORTABLE_UNSUPPORTED_SUBTYPE", source: field.source },
508
+ ),
509
+ );
510
+ }
511
+ }
512
+ return errors;
513
+ }
514
+
458
515
  // ---------------------------------------------------------------------------
459
516
  // Origin path validation
460
517
  //
@@ -592,10 +649,25 @@ function _validateFromPath(
592
649
  return { entity: sourceObj, field: sourceField };
593
650
  }
594
651
 
652
+ /** A fully-walked `@via` path: the relationship hop nodes in path order, and
653
+ * the TERMINAL entity reached after the last hop. Both fall out of one walk,
654
+ * so they are returned together — recovering the terminal with a second walk
655
+ * means maintaining a second copy of the ADR-0042 package-resolution rule.
656
+ * Shape mirrors `_validateFromPath`'s `ResolvedFromTarget`, which returns a
657
+ * pair for the same reason. */
658
+ interface WalkedViaPath {
659
+ hops: MetaData[];
660
+ terminal: MetaData;
661
+ }
662
+
595
663
  /**
596
- * Validate an explicit `@via` "Entity.rel[.rel...]" path. Returns the walked
597
- * relationship hop nodes (in path order) on full success — FR-024 B5 runs the
598
- * cardinality checks over them — or undefined when any error was pushed.
664
+ * Validate an explicit `@via` "Entity.rel[.rel...]" path. On full success
665
+ * returns the walked relationship hop nodes (in path order) — FR-024 B5 runs
666
+ * the cardinality checks over them — together with the terminal entity node
667
+ * (#335: a whole-object `@agg:collect` has no `@of` entity, so `@orderBy` keys
668
+ * and value-object members resolve against the terminal instead). Returns
669
+ * undefined when any error was pushed, so `terminal` is defined exactly when
670
+ * `hops` is.
599
671
  */
600
672
  function _validateViaPath(
601
673
  viaAttr: string,
@@ -604,7 +676,7 @@ function _validateViaPath(
604
676
  fieldName: string,
605
677
  originSource: ErrorSource,
606
678
  errors: ParseError[],
607
- ): MetaData[] | undefined {
679
+ ): WalkedViaPath | undefined {
608
680
  const projectionName = projection.name;
609
681
  // FR5d — referrer is `<projection-FQN>::<fieldName>`.
610
682
  const referrer = `${projection.fqn()}::${fieldName}`;
@@ -706,7 +778,8 @@ function _validateViaPath(
706
778
  hops.push(rel);
707
779
  currentObj = nextObj;
708
780
  }
709
- return hops;
781
+ // currentObj is the terminal: every earlier exit returned undefined.
782
+ return { hops, terminal: currentObj };
710
783
  }
711
784
 
712
785
  // ---------------------------------------------------------------------------
@@ -956,6 +1029,58 @@ function _checkAggregateCardinality(
956
1029
  }
957
1030
  }
958
1031
 
1032
+ /**
1033
+ * #335 — a whole-object `@agg:collect` projects EXACTLY the declared value
1034
+ * object's members, each matched by NAME against the `@via` terminal entity.
1035
+ *
1036
+ * Two rules, both fail-closed:
1037
+ * - a member with no matching field on the terminal is unresolvable. Failing
1038
+ * OPEN here is how #270 turned a curated value object into the full entity,
1039
+ * invisible in a diff because the metadata still read as curated.
1040
+ * - a matched member must agree on BOTH type axes (#185 type-preserving
1041
+ * doctrine), so a scalar member cannot bind an array field or vice versa.
1042
+ *
1043
+ * Both refusals carry a whole-object-specific code — ERR_COLLECT_MEMBER_UNRESOLVED
1044
+ * for the unmatched member, ERR_COLLECT_WHOLE_OBJECT for the type disagreement.
1045
+ * The latter is deliberately NOT the scalar arm's ERR_INVALID_ORIGIN: a loader
1046
+ * that still requires @of rejects this metadata with ERR_INVALID_ORIGIN too, so
1047
+ * sharing the code would make a corpus fixture pass on a port that implements
1048
+ * nothing (the corpus compares only code + source, never message text).
1049
+ */
1050
+ function _checkCollectMembers(
1051
+ refTarget: MetaData,
1052
+ terminal: MetaData,
1053
+ obj: MetaData,
1054
+ field: MetaData,
1055
+ src: ErrorSource,
1056
+ errors: ParseError[],
1057
+ ): void {
1058
+ // ADR-0039: resolving — a value object may inherit members via extends, and
1059
+ // the terminal entity may inherit fields; own-only would silently skip
1060
+ // inherited members, which is exactly the #270 bug class this guards.
1061
+ const terminalFields = terminal.children().filter((c) => c.type === TYPE_FIELD);
1062
+ for (const member of refTarget.children().filter((c) => c.type === TYPE_FIELD)) {
1063
+ const match = terminalFields.find((f) => f.name === member.name);
1064
+ if (match === undefined) {
1065
+ errors.push(new ParseError(
1066
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: value-object member ` +
1067
+ `'${member.name}' has no matching field on '${terminal.name}' — a whole-object ` +
1068
+ `rollup projects exactly the declared members.`,
1069
+ { code: "ERR_COLLECT_MEMBER_UNRESOLVED", source: src }));
1070
+ continue;
1071
+ }
1072
+ const memberLabel = _typeLabel(member);
1073
+ const matchLabel = _typeLabel(match);
1074
+ if (memberLabel !== matchLabel) {
1075
+ errors.push(new ParseError(
1076
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: value-object member ` +
1077
+ `'${member.name}' is ${memberLabel} but '${terminal.name}.${match.name}' ` +
1078
+ `is ${matchLabel} — a whole-object rollup preserves each member's type.`,
1079
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1080
+ }
1081
+ }
1082
+ }
1083
+
959
1084
  /**
960
1085
  * FR-024 B6 (spec §4; ADR-0029 decision 7) — extends/origin agreement.
961
1086
  *
@@ -1021,6 +1146,17 @@ function _checkExtendsOriginAgreement(
1021
1146
  * FR-015 stored-proc parameter refs the retired ERR_PARAMETER_REF_PASSTHROUGH_
1022
1147
  * TYPE_MISMATCH used to cover).
1023
1148
  */
1149
+ /**
1150
+ * Both type axes in one comparable token. Subtype names never contain "[]", so
1151
+ * equal labels ⇔ same subType AND same array-ness. Nullability is deliberately
1152
+ * NOT judged — an outer-join view legitimately widens NOT NULL.
1153
+ * ADR-0039: resolvedIsArray(), never the own `isArray` flag — a field may
1154
+ * inherit its array-ness via extends.
1155
+ */
1156
+ function _typeLabel(field: MetaData): string {
1157
+ return `field.${field.subType}${field.resolvedIsArray() ? "[]" : ""}`;
1158
+ }
1159
+
1024
1160
  function _checkPassthroughType(
1025
1161
  field: MetaData,
1026
1162
  fromField: MetaData,
@@ -1031,11 +1167,8 @@ function _checkPassthroughType(
1031
1167
  errors: ParseError[],
1032
1168
  ): void {
1033
1169
  if (convert) return; // deliberate type change acknowledged
1034
- // Compare both axes at once via the type-label: subtype names never contain
1035
- // "[]", so equal labels ⇔ same subType AND same array-ness (nullability is
1036
- // deliberately not judged — an outer-join view legitimately widens NOT NULL).
1037
- const declared = `field.${field.subType}${field.resolvedIsArray() ? "[]" : ""}`;
1038
- const source = `field.${fromField.subType}${fromField.resolvedIsArray() ? "[]" : ""}`;
1170
+ const declared = _typeLabel(field);
1171
+ const source = _typeLabel(fromField);
1039
1172
  if (declared === source) return;
1040
1173
  errors.push(
1041
1174
  new ParseError(
@@ -1057,6 +1190,10 @@ function _checkPassthroughType(
1057
1190
  * and carries no vocabulary. Shared by `@agg:collect` (element order) and
1058
1191
  * `origin.first` (row selection). A missing related entity means a prior error
1059
1192
  * already fired — skip silently.
1193
+ *
1194
+ * `code` lets the whole-object `@agg:collect` arm report ERR_COLLECT_WHOLE_OBJECT
1195
+ * instead; it defaults to ERR_INVALID_ORIGIN so the scalar `@of` and `origin.first`
1196
+ * call sites keep their existing envelope byte-for-byte.
1060
1197
  */
1061
1198
  function _validateOrderByKeys(
1062
1199
  orderBy: unknown,
@@ -1066,6 +1203,7 @@ function _validateOrderByKeys(
1066
1203
  label: string,
1067
1204
  originSource: ErrorSource,
1068
1205
  errors: ParseError[],
1206
+ code: ErrorCode = "ERR_INVALID_ORIGIN",
1069
1207
  ): void {
1070
1208
  if (!Array.isArray(orderBy) || relatedEntity === undefined) return;
1071
1209
  for (const raw of orderBy) {
@@ -1079,14 +1217,14 @@ function _validateOrderByKeys(
1079
1217
  errors.push(
1080
1218
  new ParseError(
1081
1219
  `${label} on ${obj.name}.${fieldName}: @orderBy key "${raw}" — no such field "${key}" on ${relatedEntity.name}.`,
1082
- { code: "ERR_INVALID_ORIGIN", source: originSource },
1220
+ { code, source: originSource },
1083
1221
  ),
1084
1222
  );
1085
1223
  } else if (dir !== undefined && !(SORT_ORDER_VALUES as readonly string[]).includes(dir)) {
1086
1224
  errors.push(
1087
1225
  new ParseError(
1088
1226
  `${label} on ${obj.name}.${fieldName}: @orderBy key "${raw}" — direction must be one of ${SORT_ORDER_VALUES.join("|")}.`,
1089
- { code: "ERR_INVALID_ORIGIN", source: originSource },
1227
+ { code, source: originSource },
1090
1228
  ),
1091
1229
  );
1092
1230
  }
@@ -1156,9 +1294,9 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1156
1294
  // ADR-0039: own — origin.* never inherits (ADR-0029).
1157
1295
  const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
1158
1296
  if (typeof via === "string" && via !== "") {
1159
- const hops = _validateViaPath(via, root, obj, field.name, origin.source, errors);
1160
- if (hops !== undefined) {
1161
- _checkPassthroughCardinality(hops, obj, field.name, origin.source, errors);
1297
+ const walked = _validateViaPath(via, root, obj, field.name, origin.source, errors);
1298
+ if (walked !== undefined) {
1299
+ _checkPassthroughCardinality(walked.hops, obj, field.name, origin.source, errors);
1162
1300
  }
1163
1301
  } else if (fromTarget !== undefined && !isValueHost) {
1164
1302
  // FR-024 §6 — no @via: derive the base entity; a @from targeting
@@ -1243,17 +1381,89 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1243
1381
  `origin.aggregate @agg:${String(agg)} on ${obj.name}.${field.name}: requires an explicit @via (a quantifier has no @of to infer the path from).`,
1244
1382
  { code: "ERR_INVALID_ORIGIN", source: src }));
1245
1383
  } else {
1246
- const hops = _validateViaPath(via, root, obj, field.name, src, errors);
1247
- if (hops !== undefined) _checkAggregateCardinality(hops, obj, field.name, src, errors);
1384
+ const walked = _validateViaPath(via, root, obj, field.name, src, errors);
1385
+ if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
1248
1386
  }
1249
1387
  continue;
1250
1388
  }
1251
1389
 
1252
- // --- count/sum/avg/min/max/collect: @of REQUIRED ---
1390
+ // --- @of: REQUIRED for count/sum/avg/min/max; OPTIONAL for collect ---
1391
+ // #335 — an @of-absent collect is a WHOLE-OBJECT rollup: collect the
1392
+ // related rows as an array of the field's declared @objectRef value
1393
+ // object rather than an array of one scalar column.
1253
1394
  if (!ofPresent) {
1254
- errors.push(new ParseError(
1255
- `origin.aggregate on ${obj.name}.${field.name}: missing @of.`,
1256
- { code: "ERR_INVALID_ORIGIN", source: src }));
1395
+ if (!isCollect) {
1396
+ errors.push(new ParseError(
1397
+ `origin.aggregate on ${obj.name}.${field.name}: missing @of.`,
1398
+ { code: "ERR_INVALID_ORIGIN", source: src }));
1399
+ continue;
1400
+ }
1401
+ // Whole-object rollup. The carrying field must be a field.object
1402
+ // naming a value object, and @via must be explicit (there is no @of
1403
+ // entity to infer the single-hop relation from).
1404
+ // ADR-0039: resolving — @objectRef may be inherited via extends.
1405
+ const objectRef = field.attr(FIELD_ATTR_OBJECT_REF);
1406
+ if (field.subType !== FIELD_SUBTYPE_OBJECT || typeof objectRef !== "string" || objectRef === "") {
1407
+ errors.push(new ParseError(
1408
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @of is omitted, so this is a ` +
1409
+ `whole-object rollup — the carrying field must be a field.object declaring @objectRef ` +
1410
+ `(add @of to collect a single column instead).`,
1411
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1412
+ continue;
1413
+ }
1414
+ // #210's value-only rule is PAYLOAD-scoped and never reaches a
1415
+ // projection-hosted field, so this branch enforces it itself.
1416
+ // Without it an @objectRef to an entity silently rolls up the FULL
1417
+ // entity — the #270 shape, this time baked into DDL.
1418
+ // ADR-0042 — a bare @objectRef resolves in the DECLARING owner's
1419
+ // package (an inherited field resolves in the package that
1420
+ // declared it) — same rule _checkNestedPayloadRefsValueOnly uses.
1421
+ const refOwner = field.parent ?? obj;
1422
+ const refPkg = refOwner.package ?? refOwner.fileDefaultPackage ?? "";
1423
+ const refTarget = resolveObjectRef(root, objectRef, refPkg).node;
1424
+ if (refTarget !== undefined && refTarget.subType !== OBJECT_SUBTYPE_VALUE) {
1425
+ errors.push(new ParseError(
1426
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @objectRef '${objectRef}' ` +
1427
+ `resolves to ${TYPE_OBJECT}.${refTarget.subType} — a whole-object rollup must target an ` +
1428
+ `object.value (#210, ADR-0028).`,
1429
+ { code: "ERR_SUBTYPE_RULE_VIOLATION", source: src }));
1430
+ continue;
1431
+ }
1432
+ // ADR-0039: own — origin.* never inherits (ADR-0029).
1433
+ const viaAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
1434
+ if (typeof viaAttr !== "string" || viaAttr === "") {
1435
+ errors.push(new ParseError(
1436
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @via is required on a ` +
1437
+ `whole-object rollup — there is no @of entity to infer the relationship from.`,
1438
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1439
+ continue;
1440
+ }
1441
+ // @distinct is refused on the object form. It is NOT an engine limit
1442
+ // (both engines dedupe JSON objects); it is a guaranteed no-op
1443
+ // whenever the value object carries the entity's primary key, which
1444
+ // is the common case, and a silent no-op is worse than a refusal.
1445
+ if (hasDistinct) {
1446
+ errors.push(new ParseError(
1447
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @distinct is not supported on a ` +
1448
+ `whole-object rollup (it is a no-op whenever the value object carries the primary key).`,
1449
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1450
+ continue;
1451
+ }
1452
+ // One walk yields both the hops (cardinality) and the terminal
1453
+ // entity (@orderBy keys, member resolution). An invalid @via
1454
+ // (e.g. single-segment "A") returns undefined having already
1455
+ // pushed its own error, so everything downstream is skipped and
1456
+ // no second, misleadingly-scoped error is emitted.
1457
+ const via = _validateViaPath(viaAttr, root, obj, field.name, src, errors);
1458
+ if (via !== undefined) {
1459
+ _checkAggregateCardinality(via.hops, obj, field.name, src, errors);
1460
+ // @orderBy keys resolve against the @via TERMINAL entity, not @of.
1461
+ _validateOrderByKeys(orderBy, via.terminal, obj, field.name, "origin.aggregate @agg:collect", src, errors,
1462
+ "ERR_COLLECT_WHOLE_OBJECT");
1463
+ if (refTarget !== undefined) {
1464
+ _checkCollectMembers(refTarget, via.terminal, obj, field, src, errors);
1465
+ }
1466
+ }
1257
1467
  continue;
1258
1468
  }
1259
1469
  // NOTE (FR-024 B6): NO extends/origin agreement check on aggregates —
@@ -1274,9 +1484,9 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1274
1484
  // ADR-0039: own — origin.* never inherits (ADR-0029).
1275
1485
  const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
1276
1486
  if (typeof via === "string" && via !== "") {
1277
- const hops = _validateViaPath(via, root, obj, field.name, src, errors);
1278
- if (hops !== undefined) {
1279
- _checkAggregateCardinality(hops, obj, field.name, src, errors);
1487
+ const walked = _validateViaPath(via, root, obj, field.name, src, errors);
1488
+ if (walked !== undefined) {
1489
+ _checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
1280
1490
  }
1281
1491
  continue;
1282
1492
  }
@@ -1369,8 +1579,8 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1369
1579
  // @via — explicit (validated + cardinality) or single-hop-unique inferred.
1370
1580
  const via = origin.ownAttr(ORIGIN_FIRST_ATTR_VIA);
1371
1581
  if (typeof via === "string" && via !== "") {
1372
- const hops = _validateViaPath(via, root, obj, field.name, src, errors);
1373
- if (hops !== undefined) _checkAggregateCardinality(hops, obj, field.name, src, errors);
1582
+ const walked = _validateViaPath(via, root, obj, field.name, src, errors);
1583
+ if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
1374
1584
  } else if (ofTarget !== undefined) {
1375
1585
  // (A value host never reaches here — the #210 assembly-origin
1376
1586
  // check above already rejected origin.first on a value.)
@@ -1887,12 +2097,32 @@ export function validateRelationships(root: MetaData): ParseError[] {
1887
2097
  // (defaultValidationRegistry → a declarative reference descriptor with dottedFieldPath).
1888
2098
 
1889
2099
  // ---------------------------------------------------------------------------
1890
- // index.lookup @fields resolution (Task 3)
2100
+ // Index-key resolution for index.lookup AND identity.secondary (#342)
2101
+ //
2102
+ // An index declares its key EXACTLY ONE of two ways: plain columns (@fields) or
2103
+ // a key expression (@expr, e.g. `lower(email)` / `(payload->>'device_id')`).
2104
+ // The registry has always said so — @expr is described as "Used INSTEAD of
2105
+ // @fields" — and `migrate-ts` has always implemented it that way
2106
+ // (`columns: expr ? [] : cols`, expected-schema.ts). Only the LOADER disagreed,
2107
+ // requiring @fields unconditionally, which made an expression index
2108
+ // unreachable: omitting @fields failed to load, and the one spelling that DID
2109
+ // load (@fields AND @expr) had its @fields silently discarded by the engine.
2110
+ //
2111
+ // So the two rules below are one rule — the key is @fields XOR @expr:
2112
+ // - NEITHER: nothing declares the key.
2113
+ // - BOTH: contradictory, and previously half-honored. Rejected rather than
2114
+ // given a precedence rule, because an accepted-but-half-ignored declaration
2115
+ // is exactly the silent-wrong-output the sealed strict registry exists to
2116
+ // prevent (cf. ERR_SQL_BODY_WITH_UNMANAGED — @sql vs @unmanaged is the same
2117
+ // "two mutually exclusive non-default states of one axis" shape).
2118
+ //
2119
+ // Applies to identity.secondary too: per ADR-0040 uniqueness lives in the TYPE,
2120
+ // so identity.secondary IS a unique index and keys itself identically. Both
2121
+ // carry @expr from the same db provider, and migrate-ts branches on @expr for
2122
+ // both — the loader was the only tier treating them differently.
1891
2123
  //
1892
- // Every index.lookup on an entity must name at least one field, and every
1893
- // named field must exist in the entity's EFFECTIVE (resolved) field set.
1894
- // ADR-0039: use children() / MetaIndex.fields() — never own* — so that a
1895
- // field inherited via extends still resolves correctly.
2124
+ // ADR-0039: children() / MetaIndex.fields() never own* so a field inherited
2125
+ // via extends still resolves.
1896
2126
  // ---------------------------------------------------------------------------
1897
2127
 
1898
2128
  export function validateIndexLookupFields(root: MetaData): ParseError[] {
@@ -1903,34 +2133,77 @@ export function validateIndexLookupFields(root: MetaData): ParseError[] {
1903
2133
  const effectiveFieldNames = new Set(
1904
2134
  obj.children().filter((c) => c.type === TYPE_FIELD).map((f) => f.name),
1905
2135
  );
1906
- for (const node of obj.children().filter(
1907
- (c) => c.type === TYPE_INDEX && c.subType === INDEX_SUBTYPE_LOOKUP,
1908
- )) {
1909
- // MetaIndex.fields() uses the resolving attr() accessor per ADR-0039.
1910
- const idx = node as MetaIndex;
1911
- const fields = idx.fields();
1912
-
1913
- // Rule 1: must have at least one field.
1914
- if (fields.length === 0) {
2136
+ const keyed = obj.children().filter(
2137
+ (c) =>
2138
+ (c.type === TYPE_INDEX && c.subType === INDEX_SUBTYPE_LOOKUP) ||
2139
+ (c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_SECONDARY),
2140
+ );
2141
+ for (const node of keyed) {
2142
+ const label = `${node.type}.${node.subType}`;
2143
+ // PRESENCE vs CONTENT are two different questions here, and conflating them
2144
+ // is a bug in both directions:
2145
+ //
2146
+ // - The CONTRADICTION check needs PRESENCE. `@fields: []` alongside @expr
2147
+ // is still a declaration of both, and keying it on non-emptiness let the
2148
+ // total-discard spelling load clean while `@fields: ["x"]` + @expr was
2149
+ // refused — the rule missing exactly the case it exists to catch.
2150
+ // - The KEY-RESOLUTION check needs normalized CONTENT, via the guarded
2151
+ // accessor. Reading the raw attr with a cast meant a scalar `@fields: 5`
2152
+ // threw an uncaught TypeError out of load() in TS while the other three
2153
+ // ports reported a clean error.
2154
+ //
2155
+ // The guarded accessor is the fix for the second and the OBSTACLE for the
2156
+ // first — it collapses absent, scalar and explicit `[]` to the same `[]` —
2157
+ // so the two questions are asked separately and never routed through one
2158
+ // predicate. ADR-0039: both reads resolve through extends.
2159
+ const hasFieldsAttr = node.attr(IDENTITY_ATTR_FIELDS) !== undefined;
2160
+ // MetaIndex.fields() / MetaIdentity.fields are the same guarded read
2161
+ // (`Array.isArray(f) ? f : []`); never re-hand-roll it.
2162
+ const fields =
2163
+ node instanceof MetaIndex ? node.fields() : (node as MetaIdentity).fields;
2164
+ const exprRaw = node.attr(IDENTITY_ATTR_EXPR);
2165
+ const hasExpr = typeof exprRaw === "string" && exprRaw.trim().length > 0;
2166
+
2167
+ // Rule 1a: exactly one of @fields / @expr may be DECLARED.
2168
+ if (hasFieldsAttr && hasExpr) {
1915
2169
  errors.push(
1916
2170
  new ParseError(
1917
- `index.lookup "${idx.name}" on "${obj.name}" has no @${INDEX_ATTR_FIELDS}; ` +
1918
- `at least one field is required`,
1919
- { code: "ERR_INVALID_INDEX", source: idx.source },
2171
+ `${label} "${node.name}" on "${obj.name}" declares BOTH ` +
2172
+ `@${INDEX_ATTR_FIELDS} and @${IDENTITY_ATTR_EXPR}; they are the two ` +
2173
+ `mutually exclusive ways to key an index. @${IDENTITY_ATTR_EXPR} is used ` +
2174
+ `INSTEAD of @${INDEX_ATTR_FIELDS} — drop one. ` +
2175
+ `(Declaring both previously loaded but silently discarded ` +
2176
+ `@${INDEX_ATTR_FIELDS}.)`,
2177
+ { code: "ERR_INVALID_INDEX", source: node.source },
1920
2178
  ),
1921
2179
  );
1922
2180
  continue;
1923
2181
  }
1924
2182
 
2183
+ // Rule 1b: whichever is declared must actually supply a key.
2184
+ if (fields.length === 0 && !hasExpr) {
2185
+ errors.push(
2186
+ new ParseError(
2187
+ `${label} "${node.name}" on "${obj.name}" declares no key: ` +
2188
+ `it must have @${INDEX_ATTR_FIELDS} (one or more columns) or ` +
2189
+ `@${IDENTITY_ATTR_EXPR} (a key expression)`,
2190
+ { code: "ERR_INVALID_INDEX", source: node.source },
2191
+ ),
2192
+ );
2193
+ continue;
2194
+ }
1925
2195
  // Rule 2: every named field must resolve against the entity's effective field set.
2196
+ // An expression index has no @fields to resolve — @expr is raw SQL over the
2197
+ // physical columns, deliberately not parsed here (ADR-0023 keeps the grammar
2198
+ // closed only where the loader owns it).
1926
2199
  for (const fieldName of fields) {
1927
2200
  if (!effectiveFieldNames.has(fieldName)) {
1928
2201
  errors.push(
1929
2202
  new ParseError(
1930
- `index.lookup "${idx.name}" on "${obj.name}" references field "${fieldName}" ` +
2203
+ `${label} "${node.name}" on "${obj.name}" references field "${fieldName}" ` +
1931
2204
  `which does not exist on "${obj.name}". ` +
1932
2205
  `Available fields: ${[...effectiveFieldNames].join(", ") || "(none)"}`,
1933
- { code: "ERR_INVALID_INDEX", source: idx.source },
2206
+ { code: "ERR_INVALID_INDEX", source: node.source },
1934
2207
  ),
1935
2208
  );
1936
2209
  }
@@ -49,9 +49,9 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
49
49
  {
50
50
  "type": "origin",
51
51
  "subType": "aggregate",
52
- "description": "A value reduced from the related row-set reached along a relationship path (@via) from the base entity: count/sum/avg/min/max over a column (@of); any/all predicate quantifiers over a @filter; or collect (an array rollup of @of).",
52
+ "description": "A value reduced from the related row-set reached along a relationship path (@via) from the base entity: count/sum/avg/min/max over a column (@of); any/all predicate quantifiers over a @filter; or collect (an array rollup of the @of column, or of the carrying field.object's declared @objectRef value object when @of is omitted).",
53
53
  "whenToUse": "A projection needs a value derived by reducing related rows — a count/sum/avg/min/max, a 'did any/every related row match' flag, or an array of collected values. Declare it instead of hand-writing the aggregate query — it stays consistent and regenerates.",
54
- "rules": "@via may be omitted only when exactly one single-hop relationship leads from the base entity to the @of entity (single-hop-unique inference; FR-024, ADR-0029). Multi-hop paths must always be stated explicitly. @of is required for count/sum/avg/min/max/collect and forbidden for any/all (which quantify over rows via @filter, not a column). @filter is required for any/all. The field must be isArray:true for collect and isArray:false for every other @agg. @distinct and @orderBy are collect-only.",
54
+ "rules": "@via may be omitted only when exactly one single-hop relationship leads from the base entity to the @of entity (single-hop-unique inference; FR-024, ADR-0029). Multi-hop paths must always be stated explicitly. @of is required for count/sum/avg/min/max and forbidden for any/all (which quantify over rows via @filter, not a column). @filter is required for any/all. The field must be isArray:true for collect and isArray:false for every other @agg. @distinct and @orderBy are collect-only. On @agg:collect @of is OPTIONAL: omitting it declares a WHOLE-OBJECT rollup, which collects each related row as the carrying field's declared @objectRef value object instead of one scalar column. A whole-object rollup requires a field.object carrying @objectRef, requires that @objectRef to name an object.value, requires an explicit @via (there is no @of entity to infer the path from), and refuses @distinct. Its @orderBy keys resolve against the @via TERMINAL entity, not the head or a middle hop. Its value-object members bind to the terminal entity's fields BY NAME — member name == terminal field name, deliberately NOT extends, so one value object stays collectable from two different entities — and every member must match a terminal field agreeing on BOTH field.<subType> and array-ness.",
55
55
  "children": [
56
56
  {
57
57
  "type": "attr",
@@ -69,7 +69,7 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
69
69
  "all",
70
70
  "collect"
71
71
  ],
72
- "description": "The reducing function applied over the related row-set: count/sum/avg/min/max (numeric/ordinal reduces over @of); any/all (predicate quantifiers over @filter — @of forbidden; empty set → any=false, all=true); collect (array rollup of @of the field must be isArray)."
72
+ "description": "The reducing function applied over the related row-set: count/sum/avg/min/max (numeric/ordinal reduces over @of); any/all (predicate quantifiers over @filter — @of forbidden; empty set → any=false, all=true); collect (array rollup of the @of column, or of the carrying field.object's declared @objectRef value object when @of is omitted; the field must be isArray)."
73
73
  },
74
74
  {
75
75
  "type": "attr",
@@ -77,7 +77,7 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
77
77
  "name": "of",
78
78
  "min": 0,
79
79
  "max": 1,
80
- "description": "Dotted Entity.field reference identifying the column being aggregated (e.g. 'Week.durationMinutes'). Required for count/sum/avg/min/max/collect; forbidden for any/all (which quantify over rows via @filter, not a column)."
80
+ "description": "Dotted Entity.field reference identifying the column being aggregated (e.g. 'Week.durationMinutes'). Required for count/sum/avg/min/max; OPTIONAL for collect, where absent means a whole-object rollup of the field's declared @objectRef value object; forbidden for any/all (which quantify over rows via @filter, not a column)."
81
81
  },
82
82
  {
83
83
  "type": "attr",
@@ -101,7 +101,7 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
101
101
  "name": "distinct",
102
102
  "min": 0,
103
103
  "max": 1,
104
- "description": "Set (collect-only) to dedupe collected values (set semantics)."
104
+ "description": "Set (collect-only) to dedupe collected values (set semantics). Not supported on a whole-object collect (@of omitted): it is a guaranteed no-op whenever the value object carries the primary key, and a silent no-op is worse than a refusal."
105
105
  },
106
106
  {
107
107
  "type": "attr",
@@ -112,7 +112,7 @@ interface ManifestType {
112
112
  * constant read `"0.10"`). Bump with that script — never by hand — so the manifest and
113
113
  * all four port constants move together.
114
114
  */
115
- export const METAMODEL_VERSION = "0.10";
115
+ export const METAMODEL_VERSION = "0.12";
116
116
 
117
117
  /** The full canonical manifest. All collections are sorted for byte-stability. */
118
118
  interface RegistryManifest {
@@ -27,14 +27,18 @@
27
27
  // `scopeRanges` below recovers the enclosing `"<type>.<subType>"` for each occurrence, so
28
28
  // one pass over the document answers both correctly.
29
29
  //
30
- // CANONICAL JSON ONLY. YAML authoring is real (ADR-0006) but is not rewritable here: a
31
- // correct YAML editor needs the `yaml` package's CST, and this module is reachable from
32
- // `src/index.ts`, which the browser-safety test forbids from importing it. A hand-rolled
33
- // YAML mode was tried and shipped a file-corrupting bug a multi-item block sequence lost
34
- // every item but the first, because the value scanner stops at a newline while the
35
- // dominant in-repo authoring style (flow mappings, `{ name: x, readOnly: true }`) was not
36
- // matched at all, so the rename silently did nothing. `meta upgrade` refuses YAML by name
37
- // instead; a refusal an adopter can act on beats a success they cannot trust.
30
+ // CANONICAL JSON ONLY YAML lives in `core/vocabulary-rewrite-yaml.ts`, not here. This
31
+ // module is reachable from `src/index.ts`, which may not import the Node-only `yaml`
32
+ // package, so the YAML arm sits behind its own package subpath and `meta upgrade`
33
+ // dynamic-imports it. The split is a bundling constraint, not a difference in contract: both
34
+ // arms return the same result shape, scope every occurrence the same way, and refuse the
35
+ // same retirements.
36
+ //
37
+ // The reason YAML gets a parser and this arm does not: a hand-rolled YAML mode was tried
38
+ // here first and shipped a file-corrupting bug — a multi-item block sequence lost every item
39
+ // but the first, because a scanner stops at a newline — while the dominant authoring style
40
+ // (flow mappings, `{ name: x, readOnly: true }`) was not matched at all. YAML's value extent
41
+ // is not derivable by scanning; JSON's is.
38
42
  //
39
43
  // IT REFUSES WHAT IT CANNOT KNOW. A retirement with no `rewrite` (`@status: abandoned`) is
40
44
  // reported, never guessed at. Deleting the node, retyping it, and fixing the residue it