@metaobjectsdev/metadata 0.23.1 → 0.23.2

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 (47) hide show
  1. package/dist/attr-schema-validate.d.ts.map +1 -1
  2. package/dist/attr-schema-validate.js +89 -11
  3. package/dist/attr-schema-validate.js.map +1 -1
  4. package/dist/core/attr/attr-constants.d.ts +2 -1
  5. package/dist/core/attr/attr-constants.d.ts.map +1 -1
  6. package/dist/core/attr/attr-constants.js +6 -0
  7. package/dist/core/attr/attr-constants.js.map +1 -1
  8. package/dist/core/attr/attr-definition.embedded.d.ts.map +1 -1
  9. package/dist/core/attr/attr-definition.embedded.js +6 -0
  10. package/dist/core/attr/attr-definition.embedded.js.map +1 -1
  11. package/dist/core/attr/meta-attr-int-map.d.ts +9 -0
  12. package/dist/core/attr/meta-attr-int-map.d.ts.map +1 -0
  13. package/dist/core/attr/meta-attr-int-map.js +50 -0
  14. package/dist/core/attr/meta-attr-int-map.js.map +1 -0
  15. package/dist/core/field/field-constants.d.ts +9 -0
  16. package/dist/core/field/field-constants.d.ts.map +1 -1
  17. package/dist/core/field/field-constants.js +9 -0
  18. package/dist/core/field/field-constants.js.map +1 -1
  19. package/dist/core/field/field-definition.embedded.d.ts.map +1 -1
  20. package/dist/core/field/field-definition.embedded.js +8 -0
  21. package/dist/core/field/field-definition.embedded.js.map +1 -1
  22. package/dist/core/query/query-constants.d.ts +30 -0
  23. package/dist/core/query/query-constants.d.ts.map +1 -1
  24. package/dist/core/query/query-constants.js +35 -1
  25. package/dist/core/query/query-constants.js.map +1 -1
  26. package/dist/core-types.d.ts +1 -0
  27. package/dist/core-types.d.ts.map +1 -1
  28. package/dist/core-types.js +1 -0
  29. package/dist/core-types.js.map +1 -1
  30. package/dist/errors.d.ts +1 -1
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js +6 -0
  33. package/dist/errors.js.map +1 -1
  34. package/dist/loader/validation-passes.d.ts.map +1 -1
  35. package/dist/loader/validation-passes.js +7 -3
  36. package/dist/loader/validation-passes.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/attr-schema-validate.ts +113 -10
  39. package/src/core/attr/attr-constants.ts +6 -0
  40. package/src/core/attr/attr-definition.embedded.ts +6 -0
  41. package/src/core/attr/meta-attr-int-map.ts +54 -0
  42. package/src/core/field/field-constants.ts +10 -0
  43. package/src/core/field/field-definition.embedded.ts +8 -0
  44. package/src/core/query/query-constants.ts +47 -1
  45. package/src/core-types.ts +1 -0
  46. package/src/errors.ts +6 -0
  47. package/src/loader/validation-passes.ts +7 -2
@@ -52,6 +52,7 @@ import {
52
52
  FIELD_ATTR_VALUES,
53
53
  FIELD_ATTR_COERCE_DEFAULT,
54
54
  FIELD_ATTR_DEFAULT,
55
+ FIELD_ATTR_INT_VALUE_MAP,
55
56
  ENUM_MEMBER_PATTERN,
56
57
  } from "./core/field/field-constants.js";
57
58
  import {
@@ -309,6 +310,19 @@ function validateNode(
309
310
  // only own @values need checking here (mirrors the own-attrs-only policy of
310
311
  // Checks 2+3 above — inherited attrs were validated on the declaring node).
311
312
  if (node.type === TYPE_FIELD && node.subType === FIELD_SUBTYPE_ENUM) {
313
+ // #246: the shared-enum super, if any — a root-level abstract field.enum (one
314
+ // whose parent is the metadata root, not an object). FR-019 materializes such a
315
+ // declaration ONCE per port as a single named type, so anything a consuming
316
+ // field re-declares that is part of the shared TYPE's contract is a conflict.
317
+ // Immediate-super-only, matching codegen's resolveSharedEnumDecl (enum-shared.ts)
318
+ // so the validator and the collapse agree on what "shared" means.
319
+ const sharedSuper =
320
+ node.superData !== undefined &&
321
+ node.superData.isAbstract &&
322
+ node.superData.parent?.type === TYPE_METADATA
323
+ ? node.superData
324
+ : undefined;
325
+
312
326
  // ADR-0039: own — validates the @values DECLARED on this node; a concrete enum
313
327
  // extending an abstract one inherits already-validated @values (own-attrs-only).
314
328
  const rawValues = node.ownAttrs().get(FIELD_ATTR_VALUES);
@@ -347,18 +361,16 @@ function validateNode(
347
361
  }
348
362
  }
349
363
 
350
- // #246: a field.enum extending a shared package-level abstract enum
351
- // (a root-level abstract field one whose parent is the metadata root,
352
- // not an object) that ALSO declares its own @values is a conflict: one
353
- // shared enum type has one member set, so codegen's shared-enum collapse
354
- // would silently drop this field's own @values in favor of the shared
355
- // type's. Own-attrs-only (matches the rest of Check 4): only fires when
356
- // THIS node declares @values itself, not when it merely inherits.
357
- const sup = node.superData;
358
- if (sup !== undefined && sup.isAbstract && sup.parent?.type === TYPE_METADATA) {
364
+ // #246: a field.enum extending a shared package-level abstract enum that
365
+ // ALSO declares its own @values is a conflict: one shared enum type has one
366
+ // member set, so codegen's shared-enum collapse would silently drop this
367
+ // field's own @values in favor of the shared type's. Own-attrs-only
368
+ // (matches the rest of Check 4): only fires when THIS node declares
369
+ // @values itself, not when it merely inherits.
370
+ if (sharedSuper !== undefined) {
359
371
  errors.push(
360
372
  new ParseError(
361
- `${nodeLabel(node)} extends shared abstract enum '${nodeLabel(sup)}' AND declares its own ` +
373
+ `${nodeLabel(node)} extends shared abstract enum '${nodeLabel(sharedSuper)}' AND declares its own ` +
362
374
  `'@${FIELD_ATTR_VALUES}' — a shared enum's member set is owned by the shared declaration; ` +
363
375
  `remove the own '@${FIELD_ATTR_VALUES}' to inherit it, or extend a non-shared enum instead.`,
364
376
  { code: "ERR_ENUM_EXTENDS_VALUES_CONFLICT", source: node.source },
@@ -399,6 +411,97 @@ function validateNode(
399
411
  }
400
412
  }
401
413
  }
414
+
415
+ // --- Check 5a: @intValueMap is scalar-only (design D7) ---
416
+ //
417
+ // Int-backing is a persistence-layer CODEC, and no port implements it
418
+ // element-wise over an array column: OMDB's EnumCodec and Kotlin's
419
+ // customEnumeration are scalar by construction, Python would bind the symbol
420
+ // list straight into an integer[], and TS's sqlite branch serializes an array
421
+ // as JSON text before the enum case is ever reached. Two ports that DO compose
422
+ // (TS/Postgres via .array(), C# via PrimitiveCollection) are not a feature —
423
+ // shipping a claim four ports silently get wrong is the field.byte/short/class
424
+ // mistake. Rejected at LOAD so it fails identically everywhere.
425
+ //
426
+ // BOTH reads are RESOLVING, unlike Check 5b below: the illegal thing is the
427
+ // EFFECTIVE combination. Post-#246 the map must live on the shared abstract
428
+ // declaration, so the field that inherits it is exactly where isArray gets
429
+ // declared — an own-only read would see the two halves on different nodes and
430
+ // never fire.
431
+ if (node.attrs().get(FIELD_ATTR_INT_VALUE_MAP) !== undefined && node.resolvedIsArray()) {
432
+ errors.push(
433
+ new ParseError(
434
+ `${nodeLabel(node)} declares '@${FIELD_ATTR_INT_VALUE_MAP}' with isArray=true; ` +
435
+ `int-backing is scalar-only — an array-of-enum persists its member symbols. ` +
436
+ `Remove '@${FIELD_ATTR_INT_VALUE_MAP}', or make the field scalar.`,
437
+ { code: "ERR_ENUM_INT_VALUE_MAP_ARRAY", source: node.source },
438
+ ),
439
+ );
440
+ }
441
+
442
+ // --- Check 5b: field.enum @intValueMap content rules ---
443
+ //
444
+ // Optional. Own-only (mirrors Checks 4/5's own-attrs-only policy) — an
445
+ // inherited @intValueMap is validated on its declaring node. The generic
446
+ // "is this an object of integers" shape check already ran via IntMapAttr
447
+ // (attr subtype `intMap`); this validates the field.enum-SPECIFIC
448
+ // semantics: key-set-equals-@values, and no two members share a value.
449
+ const rawIntValueMap = node.ownAttrs().get(FIELD_ATTR_INT_VALUE_MAP);
450
+ if (rawIntValueMap !== undefined && typeof rawIntValueMap === "object" && rawIntValueMap !== null) {
451
+ // #246 (int-backed twin): the symbol→int mapping is a property of the enum
452
+ // VOCABULARY, not of one column that uses it — it is @values' numeric half.
453
+ // A shared enum is materialized once as a single type, so a per-field map
454
+ // would give one logical type N storage encodings (and, where a port emits
455
+ // per-TYPE codec artifacts, two same-named declarations). Same remedy as the
456
+ // @values half: declare it on the shared declaration and inherit it.
457
+ if (sharedSuper !== undefined) {
458
+ errors.push(
459
+ new ParseError(
460
+ `${nodeLabel(node)} extends shared abstract enum '${nodeLabel(sharedSuper)}' AND declares its own ` +
461
+ `'@${FIELD_ATTR_INT_VALUE_MAP}' — a shared enum's integer backing is owned by the shared ` +
462
+ `declaration; move '@${FIELD_ATTR_INT_VALUE_MAP}' onto '${nodeLabel(sharedSuper)}' to inherit it, ` +
463
+ `or extend a non-shared enum instead.`,
464
+ { code: "ERR_ENUM_EXTENDS_VALUES_CONFLICT", source: node.source },
465
+ ),
466
+ );
467
+ }
468
+ const map = rawIntValueMap as Record<string, number>;
469
+ const effectiveValues = node.attrs().get(FIELD_ATTR_VALUES);
470
+ const declaredMembers: string[] = Array.isArray(effectiveValues) ? effectiveValues : [];
471
+ const memberSet = new Set(declaredMembers);
472
+ const mapKeys = Object.keys(map);
473
+ const keySet = new Set(mapKeys);
474
+
475
+ const missing = declaredMembers.filter((m) => !keySet.has(m));
476
+ const extra = mapKeys.filter((k) => !memberSet.has(k));
477
+ if (missing.length > 0 || extra.length > 0) {
478
+ errors.push(
479
+ new ParseError(
480
+ `${nodeLabel(node)} attribute '@${FIELD_ATTR_INT_VALUE_MAP}' keys must exactly match '@${FIELD_ATTR_VALUES}' members` +
481
+ (missing.length > 0 ? ` (missing: ${missing.join(", ")})` : "") +
482
+ (extra.length > 0 ? ` (unknown: ${extra.join(", ")})` : "") + ".",
483
+ { code: "ERR_BAD_ATTR_VALUE", source: node.source },
484
+ ),
485
+ );
486
+ }
487
+
488
+ const seenValues = new Map<number, string>();
489
+ for (const [member, value] of Object.entries(map)) {
490
+ if (typeof value !== "number" || !Number.isInteger(value)) continue; // IntMapAttr already reported this
491
+ const owner = seenValues.get(value);
492
+ if (owner !== undefined) {
493
+ errors.push(
494
+ new ParseError(
495
+ `${nodeLabel(node)} attribute '@${FIELD_ATTR_INT_VALUE_MAP}' members '${owner}' and '${member}' ` +
496
+ `share the same value ${value}; every member must have a unique int.`,
497
+ { code: "ERR_BAD_ATTR_VALUE", source: node.source },
498
+ ),
499
+ );
500
+ } else {
501
+ seenValues.set(value, member);
502
+ }
503
+ }
504
+ }
402
505
  }
403
506
 
404
507
  // --- Check 6 (R6 Plan 2b): @dbColumnType (logical subtype × value) pairing ---
@@ -17,6 +17,11 @@ export const ATTR_SUBTYPE_FILTER = "filter";
17
17
  // #195 — a structured expression tree over a base entity's own fields (backs
18
18
  // origin.computed). Object-shaped; a closed node grammar (see meta-attr-expression.ts).
19
19
  export const ATTR_SUBTYPE_EXPRESSION = "expression";
20
+ // An object-shaped attr whose values are all integers (e.g. field.enum's
21
+ // @intValueMap: {memberSymbol: int}). Generic shape check only — semantic
22
+ // rules specific to a consumer (key-set membership, uniqueness) are that
23
+ // consumer's own content-rule validation, not this attr's.
24
+ export const ATTR_SUBTYPE_INT_MAP = "intMap";
20
25
 
21
26
  /**
22
27
  * The retired `stringarray` array attr subtype. It is NO LONGER a registered
@@ -41,6 +46,7 @@ export const ATTR_SUBTYPES = [
41
46
  ATTR_SUBTYPE_PROPERTIES,
42
47
  ATTR_SUBTYPE_FILTER,
43
48
  ATTR_SUBTYPE_EXPRESSION,
49
+ ATTR_SUBTYPE_INT_MAP,
44
50
  ] as const;
45
51
  export type AttrSubType =
46
52
  | (typeof ATTR_SUBTYPES)[number]
@@ -27,6 +27,12 @@ export const ATTR_DEFINITION: ProviderDefinition = {
27
27
  "dataType": "int",
28
28
  "description": "A 32-bit-integer-valued metadata attribute. Coerces to and validates as a number."
29
29
  },
30
+ {
31
+ "type": "attr",
32
+ "subType": "intMap",
33
+ "dataType": "object",
34
+ "description": "An object-shaped attribute whose values are all integers (e.g. field.enum's @intValueMap: {memberSymbol: int}). Generic shape check only; a consumer field type layers its own semantic rules (key-set membership, uniqueness) in its own content-rule validation."
35
+ },
30
36
  {
31
37
  "type": "attr",
32
38
  "subType": "long",
@@ -0,0 +1,54 @@
1
+ // IntMapAttr — attr subtype `intMap`. Object-shaped value whose members must
2
+ // all be integers (e.g. field.enum's @intValueMap). No desugar; validates
3
+ // shape (object, not array) and every value's type (integer). A consumer's
4
+ // own semantic rules (key-set membership, uniqueness) are validated by that
5
+ // consumer, not here — mirrors how StringArrayAttr validates shape while
6
+ // field.enum's own content-rule pass validates its @values semantics.
7
+
8
+ import { MetaAttr, type ValueError, runtimeTypeName } from "./meta-attr.js";
9
+ import { type AttrValue } from "../../shared/meta-data.js";
10
+ import { DATA_TYPE_OBJECT, type DataType } from "../../data-type.js";
11
+ import { registerAttrClass } from "../../attr-class-map.js";
12
+ import { ATTR_SUBTYPE_INT_MAP } from "./attr-constants.js";
13
+
14
+ // 32-bit signed integer bounds (inclusive) — the eventual DB column for an
15
+ // int-backed enum is a 32-bit Postgres/SQLite `integer` (design doc D5).
16
+ const INT32_MIN = -2147483648;
17
+ const INT32_MAX = 2147483647;
18
+
19
+ export class IntMapAttr extends MetaAttr {
20
+ override get dataType(): DataType {
21
+ return DATA_TYPE_OBJECT;
22
+ }
23
+
24
+ override coerce(raw: unknown): AttrValue {
25
+ return raw as AttrValue;
26
+ }
27
+
28
+ override validateValue(value: AttrValue): ValueError[] {
29
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
30
+ return [{ message: `attribute '@${this.name}' must be of type 'intMap' but got ${runtimeTypeName(value)}` }];
31
+ }
32
+ const errors: ValueError[] = [];
33
+ for (const [key, member] of Object.entries(value as Record<string, unknown>)) {
34
+ if (typeof member !== "number" || !Number.isInteger(member)) {
35
+ errors.push({
36
+ message: `attribute '@${this.name}' member '${key}' has value '${String(member)}' which is not an integer`,
37
+ });
38
+ } else if (member < INT32_MIN || member > INT32_MAX) {
39
+ // The eventual DB column for an int-backed enum is a 32-bit Postgres/
40
+ // SQLite `integer` (design doc D5; matches field.int's existing
41
+ // 32-bit mapping in expected-schema.ts) — mirrors Java's
42
+ // IntMapAttribute#setValueAsString bound check exactly (inclusive at
43
+ // both ends) so a value that could never be persisted fails at load
44
+ // time, not silently, on every port.
45
+ errors.push({
46
+ message: `attribute '@${this.name}' member '${key}' has value '${member}' which is outside the 32-bit signed integer range`,
47
+ });
48
+ }
49
+ }
50
+ return errors;
51
+ }
52
+ }
53
+
54
+ registerAttrClass(ATTR_SUBTYPE_INT_MAP, IntMapAttr);
@@ -166,6 +166,16 @@ export const FIELD_ATTR_CURRENCY_DEFAULT = "USD";
166
166
  /** Member symbols of an enum-subtype field. Required, string array. */
167
167
  export const FIELD_ATTR_VALUES = "values";
168
168
 
169
+ /**
170
+ * Optional per-member explicit integer value ({memberSymbol: int}) switching
171
+ * this enum field's DB persistence from string+CHECK to integer+CHECK. Keys
172
+ * must exactly match @values; values must be unique integers. The generated
173
+ * native type and wire format are UNCHANGED in every language — this is a
174
+ * persistence-layer-only concern (docs/superpowers/specs/2026-07-23-int-backed-
175
+ * enum-values-design.md).
176
+ */
177
+ export const FIELD_ATTR_INT_VALUE_MAP = "intValueMap";
178
+
169
179
  /**
170
180
  * Pattern every enum member must satisfy: a legal identifier in all target
171
181
  * languages (TS union member, Java/C#/Python enum member). Ensures symbol ==
@@ -279,6 +279,14 @@ export const FIELD_DEFINITION: ProviderDefinition = {
279
279
  "min": 0,
280
280
  "max": 1,
281
281
  "description": "FR-019: marks an abstract package-level field.enum as externally provided — codegen references the type (resolved via per-port codegen config) instead of materializing it. Default false. Not a field attr — it lives on the type declaration."
282
+ },
283
+ {
284
+ "type": "attr",
285
+ "subType": "intMap",
286
+ "name": "intValueMap",
287
+ "min": 0,
288
+ "max": 1,
289
+ "description": "Optional per-member int values ({member: int}) switching this enum field's DB persistence from string+CHECK to integer+CHECK. Keys must exactly match @values; values must be unique integers. The generated native type and wire format are unchanged in every language."
282
290
  }
283
291
  ]
284
292
  },
@@ -1,4 +1,4 @@
1
- import { FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET } from "../field/field-constants.js";
1
+ import { FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, FIELD_ATTR_INT_VALUE_MAP } from "../field/field-constants.js";
2
2
 
3
3
  // Query concern constants — filter operators, sort order values.
4
4
  //
@@ -67,6 +67,52 @@ export function opsForSubType(subType: string): readonly FilterOp[] {
67
67
  return OPS_BY_SUBTYPE[subType] ?? [];
68
68
  }
69
69
 
70
+ /** The int-backed-enum band: the enum band minus `like`. Hoisted so the narrowing
71
+ * is one named constant rather than a filter re-derived at every call. */
72
+ const OPS_ENUM_INT_BACKED: readonly FilterOp[] = [
73
+ FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_IN, FILTER_OP_IS_NULL,
74
+ ];
75
+
76
+ /**
77
+ * The structural shape `opsForField` needs. Declared here rather than importing
78
+ * `MetaField`: `query-constants` is foundational and `core/field` imports it, so a
79
+ * type import back the other way would close a cycle.
80
+ */
81
+ export interface FilterOpBandField {
82
+ readonly subType: string;
83
+ attr(name: string): unknown;
84
+ }
85
+
86
+ /**
87
+ * The filter-operator band for a FIELD — the entry point every consumer that has a
88
+ * field in hand must use (loader validation, generated allowlists, generated client
89
+ * filter types, the cross-port `field.filter-ops` capability).
90
+ *
91
+ * Identical to {@link opsForSubType} except for ONE case: an int-backed `field.enum`
92
+ * (one declaring `@intValueMap`, design D5) persists as an INTEGER column, so `like`
93
+ * — a substring match — is dropped. `eq`/`ne`/`in` survive because the member symbol
94
+ * encodes to its integer before it reaches SQL; `like` has no such encoding, and an
95
+ * unencoded `LIKE 'DRAFT'` against an integer column is a request-time type error.
96
+ *
97
+ * `opsForSubType` cannot express this: it only ever sees the subtype `"enum"`. It is
98
+ * deliberately left unchanged for the one caller that genuinely has no field — the
99
+ * expression grammar's declared operand type.
100
+ *
101
+ * ADR-0039: the `@intValueMap` read is RESOLVING. Post-#246 the map lives on a shared
102
+ * root-level abstract declaration and consuming fields INHERIT it, so an own-only read
103
+ * would see `undefined` on exactly the shape adopters are steered toward and wrongly
104
+ * keep `like` in the band.
105
+ */
106
+ export function opsForField(field: FilterOpBandField): readonly FilterOp[] {
107
+ if (field.subType === FIELD_SUBTYPE_ENUM && isIntBacked(field)) return OPS_ENUM_INT_BACKED;
108
+ return opsForSubType(field.subType);
109
+ }
110
+
111
+ function isIntBacked(field: FilterOpBandField): boolean {
112
+ const raw = field.attr(FIELD_ATTR_INT_VALUE_MAP);
113
+ return raw !== undefined && raw !== null && typeof raw === "object";
114
+ }
115
+
70
116
  // ---------------------------------------------------------------------------
71
117
  // Sort order values (used by @sortableDefaultOrder on fields and
72
118
  // @defaultSortOrder on dataGrid layouts)
package/src/core-types.ts CHANGED
@@ -20,6 +20,7 @@ import "./core/attr/meta-attr-stringarray.js";
20
20
  import "./core/attr/meta-attr-filter.js";
21
21
  import "./core/attr/meta-attr-properties.js";
22
22
  import "./core/attr/meta-attr-expression.js";
23
+ import "./core/attr/meta-attr-int-map.js";
23
24
  import {
24
25
  MetaValidator,
25
26
  MetaRequiredValidator,
package/src/errors.ts CHANGED
@@ -202,6 +202,12 @@ export const ERROR_CODES = [
202
202
  // own @values would be silently dropped in codegen. Remove the own @values
203
203
  // to inherit the shared set, or extend a concrete (non-shared) enum instead.
204
204
  "ERR_ENUM_EXTENDS_VALUES_CONFLICT",
205
+ // A field.enum carries @intValueMap together with isArray=true. Int-backing is
206
+ // a persistence-layer codec and no port implements it element-wise over an
207
+ // array column, so the combination would silently persist member SYMBOLS into
208
+ // an integer array. An array-of-enum stays string-backed: drop @intValueMap,
209
+ // or make the field scalar.
210
+ "ERR_ENUM_INT_VALUE_MAP_ARRAY",
205
211
  "ERR_UNKNOWN",
206
212
  ] as const;
207
213
 
@@ -130,6 +130,7 @@ import {
130
130
  FILTER_COMPOSE_OR,
131
131
  FILTER_COMPOSE_AND,
132
132
  opsForSubType,
133
+ opsForField,
133
134
  } from "../core/query/query-constants.js";
134
135
 
135
136
  // ---------------------------------------------------------------------------
@@ -1684,7 +1685,9 @@ export function validateDataGridFilterValues(root: MetaData): ParseError[] {
1684
1685
  for (const f of effective.filter((c) => c.type === TYPE_FIELD)) {
1685
1686
  // ADR-0039: resolving — a concrete field may inherit @filterable via extends.
1686
1687
  if (f.attr(FIELD_ATTR_FILTERABLE) === true) {
1687
- allow.set(f.name, opsForSubType(f.subType));
1688
+ // opsForField, not opsForSubType — an int-backed field.enum (@intValueMap)
1689
+ // stores as an integer, so `like` is not in its band.
1690
+ allow.set(f.name, opsForField(f));
1688
1691
  }
1689
1692
  }
1690
1693
  for (const layout of effective.filter(
@@ -2034,7 +2037,9 @@ export function validateProjectionFilter(root: MetaData): ParseError[] {
2034
2037
  origin !== undefined &&
2035
2038
  origin.subType !== ORIGIN_SUBTYPE_PASSTHROUGH &&
2036
2039
  origin.subType !== ORIGIN_SUBTYPE_COMPUTED;
2037
- fields.set(f.name, { derived, ops: opsForSubType(f.subType) });
2040
+ // opsForField, not opsForSubType an int-backed field.enum (@intValueMap)
2041
+ // stores as an integer, so `like` is not in its band.
2042
+ fields.set(f.name, { derived, ops: opsForField(f) });
2038
2043
  }
2039
2044
  checkProjectionFilterRefs(filter as Record<string, unknown>, fields, obj.name, obj.source, errors);
2040
2045
  }