Skip to content

Commit ee6e9ff

Browse files
authored
Merge pull request #291 from metaobjectsdev/feat/int-backed-enum-values
Int-backed field.enum (@intValueMap) — metamodel + persistence, all five ports
2 parents b3eb355 + 44bb77a commit ee6e9ff

144 files changed

Lines changed: 9637 additions & 243 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/skills/releasing/SKILL.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ done
6666
actually on npm (`npm view <pkg> dist-tags`). Patch bump unless a public API
6767
changed; this repo bumps the whole set in lockstep (pre-1.0).
6868

69+
**Registry vocabulary does not force a MINOR** — a new *attribute* is a PATCH, a new
70+
top-level *type* is a MINOR, and a new *subtype* is a PATCH when inert. See
71+
`docs/RELEASING.md` → "The vocabulary rule" before choosing the level; the old
72+
"any registry addition ⇒ MINOR" reading spent two minors on changes no adopter
73+
could observe.
74+
6975
## Phase 2 — Build fresh (the stale-`dist` trap)
7076

7177
`dist/` is gitignored, `bun publish` does NOT rebuild, and `main` points at `dist/`

‎.githooks/pre-commit‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
set -uo pipefail
1919

2020
# Generic, non-sensitive structural patterns — safe to commit (name no project):
21-
PATTERNS='/home/[A-Za-z0-9._-]+/|~/Development'
21+
# /Users/ is the macOS home shape. It was missing until a plan doc authored on a Mac
22+
# committed an absolute developer home path that this hook scanned and passed.
23+
PATTERNS='/home/[A-Za-z0-9._-]+/|/Users/[A-Za-z0-9._-]+/|~/Development'
2224

2325
# Legitimate matches to ignore (e.g. the published npm author email).
2426
ALLOW='doug@dougmealing\.com'

‎CHANGELOG.md‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,146 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Added — int-backed `field.enum` storage via `@intValueMap` (all five ports)
11+
12+
**This is a PATCH, not a MINOR** — and the reasoning is itself a change, so it is worth
13+
stating. The old policy read "any registry addition ⇒ MINOR", which spent `0.22.0` and
14+
`0.23.0` on changes a project could not observe at all. `expected-registry.json` is an
15+
**internal** gate: five ports byte-matching one manifest is how we stop the ports drifting
16+
from each other, and it says nothing about whether an adopter's project changes. Vocabulary
17+
now sorts by what it can do to a consumer — a new **attribute** is a PATCH, a new top-level
18+
**type** is a MINOR, and a new **subtype** is a PATCH when nothing but authoring it can reach
19+
it. This line adds one attribute (`@intValueMap`) and one inert attr subtype (`attr.intMap`),
20+
so: PATCH. See `docs/RELEASING.md` → "The vocabulary rule" and ADR-0035 Amendment 1.
21+
22+
Purely additive on its own terms too: a `field.enum` that declares no `@intValueMap` is
23+
byte-identical to before, string-backed as always.
24+
25+
`@intValueMap` is an optional `{memberSymbol: int}` map on `field.enum` that declares each
26+
member's **stored integer**. The column becomes `integer` with an integer `CHECK` instead of
27+
`varchar` with a string one — while the wire format, the generated enum type, and every
28+
runtime return value stay the **member symbol**, unchanged. The provenance is an
29+
integer-coded column an adopter already has: the map is how you say "1 means LOW here",
30+
rather than accepting whatever ordinal a language happens to assign. This is why it is a map
31+
and not a second array parallel to `@values` — a positional array would silently re-map every
32+
member the day someone reorders `@values`.
33+
34+
The loader enforces the map's content identically everywhere: keys must equal `@values`
35+
exactly (no missing, no extra), every value must be a 32-bit integer, and no two members may
36+
share one. Both halves are read RESOLVING, so a field that `extends` a shared abstract enum
37+
inherits the members *and* their mapping — and an own `@intValueMap` declared against a
38+
shared enum is rejected for the same reason an own `@values` is (#246's twin: one shared enum
39+
type has one mapping).
40+
41+
Persistence ships in every port: EF Core `HasConversion` (C#), OMDB's `JdbcFieldCodec`
42+
(Java), Exposed `customEnumeration` (Kotlin), `ObjectManager` coercion (Python), and — in
43+
TypeScript — **both** seams, since TS has two: a Drizzle `customType` for generated code and
44+
`ObjectManager` read/write/filter coercion for the metadata-driven runtime. The second was
45+
missing until the corpus caught it: generated code worked while `om.create()` bound the member
46+
symbol straight into the integer column and Postgres rejected the statement. All gated
47+
cross-port by the `AllTypes` round-trip corpus against real Postgres.
48+
49+
Two decisions are worth naming because each closes a way the feature could have shipped
50+
half-true:
51+
52+
- **Int-backing is scalar-only.** `@intValueMap` together with `isArray: true` is a load
53+
error — `ERR_ENUM_INT_VALUE_MAP_ARRAY`, in every port. The original design said an
54+
array-of-enum composed unchanged; it does not. Int-backing is a persistence-layer CODEC and
55+
every port's codec seam is scalar by construction: Python bound the symbol LIST into an
56+
`integer[]`, Java and Kotlin emitted a scalar codec, and TypeScript's sqlite branch
57+
serialized the array as JSON text before the enum case was ever reached — storing symbols.
58+
Only TS/Postgres and C# composed, and **two ports composing while four silently get it
59+
wrong is not a feature** — it is the `field.byte`/`short`/`class` mistake, vocabulary that
60+
reads as supported and is not. Rejecting it at LOAD delivers the guarantee that was
61+
actually missing: identical behaviour in every port. An array-of-enum stays string-backed.
62+
- **A stored integer that maps to no member THROWS on read**, in every port. The row holds
63+
data the model says is impossible — a hand-written `INSERT`, or a member removed without a
64+
migration — and neither alternative is honest: surfacing the raw integer hands the caller a
65+
"member" that is not one, and is not even representable in C#, Kotlin or TypeScript, which
66+
type the property as a closed enum; returning null hides the corruption behind a nullable
67+
column. C# reaches this through a generated static helper called from the provider→model
68+
lambda — CS8188 bans a throw-*expression* inside an expression tree, but a method CALL is
69+
legal there. The WRITE side is deliberately left to the database: an unmapped symbol binds
70+
unchanged, so the column type and its `CHECK` reject it.
71+
72+
**Adopter-visible beyond the new attribute:** the filter-operator band is now decided
73+
**per field**, not per subtype, so an int-backed `field.enum` no longer offers `like` — the
74+
column holds integers, and `LIKE` against one is a type error, not a query. A projection's
75+
`@filter` over an int-backed enum lowers to the integer literal rather than the symbol.
76+
77+
Migration safety is unchanged and deliberate: adding or removing `@intValueMap` on a field
78+
that already has a column is a cross-kind `change-column-type`, which `meta migrate` already
79+
blocks by default and requires an explicit `allow.typeChange` to pass. There is no
80+
auto-recast — the tool will not rewrite your data behind a metadata edit.
81+
82+
**Do not RE-map a member's integer on a populated table.** Nothing understands that change:
83+
the column holds bare integers, and neither introspection nor the committed snapshot records
84+
which member an integer stood for. A remap changes the rendered `CHECK` list, so it trips the
85+
blocked `drop-check` and `meta migrate` refuses — but that refusal is incidental (dropping a
86+
`CHECK` is destructive), and once allowed the migration only refreshes the constraint and
87+
never touches your rows. Swap two members' integers and the new `CHECK` admits the same set,
88+
applies cleanly, and every stored row has quietly changed meaning. Reorder `@values` to
89+
compensate and the diff is empty outright. Treat a remap as the same two-step backfill a
90+
backing-mode change needs.
91+
92+
Design: `docs/superpowers/specs/2026-07-23-int-backed-enum-values-design.md`. Adopter view:
93+
[`docs/features/field-types.md`](docs/features/field-types.md).
94+
95+
### Changed — registry vocabulary no longer forces a MINOR (policy)
96+
97+
`docs/RELEASING.md`'s versioning table said "PATCH (MINOR if it adds registry vocabulary —
98+
cross-port conformance surface)", and ADR-0035's cadence bullet listed "a newly-supported
99+
vocab member" among the MINOR triggers. Read literally, that made **any** registry addition a
100+
MINOR — the exact churn ADR-0035 was written to prevent. `0.22.0` and `0.23.0` were both cut
101+
MINOR for additions a project declaring no `requirement.*` nodes could not observe at all,
102+
each changelog saying so in its own opening paragraph. Four registries move per cut here, so a
103+
wasted minor is not free — and a minor spent on an unobservable change is a gate you no longer
104+
have when something real needs it. Corrected to sort by consumer impact: **attribute ⇒ PATCH,
105+
top-level type ⇒ MINOR, subtype ⇒ PATCH when inert** (nothing but authoring it can reach it;
106+
MINOR when it narrows something previously permitted, changes existing metadata's meaning, or
107+
headlines a release on purpose). Recorded as ADR-0035 Amendment 1; the post-1.0 compat promise
108+
is untouched — a *breaking* vocabulary change still requires a MAJOR.
109+
110+
### Fixed — an FK into a table whose key carries `@column` phantom-diffed forever (npm)
111+
112+
`buildForeignKeys` resolved a target FK field's PHYSICAL column by applying the naming
113+
strategy to its raw logical name, so a target primary key with an explicit `@column` override
114+
(`id` → `"Id"`) made **every** foreign key into that table diff on every run — expected the
115+
naming-strategy name, actual the override, nothing an adopter could do to converge. It now
116+
resolves through the target entity's own field, which is how `fkCols` already handled the
117+
source side; the two halves simply disagreed.
118+
119+
### Fixed — views in a table-less schema were excluded from the diff entirely (npm)
120+
121+
**Generated-output change — the first `meta migrate` after upgrading may emit view changes
122+
that were always due.** `declaredSchemas` was built from `expected.tables` only, so a model
123+
declaring views in a schema with no table of its own (an API/read-model schema beside an
124+
all-`public` entity model) never brought that schema into scope — and a schema out of scope is
125+
excluded from *both* sides of the diff. Its views were never compared, so a genuine missing or
126+
extra view, or real drift inside an opaque `@sql` body, went undetected rather than reported.
127+
View schemas now join the scope set. Same shape as 0.21.6's `ON DELETE` fix: a PATCH that
128+
surfaces drift which was already there.
129+
130+
### Fixed — a chained abstract `field.enum` emitted a broken Kotlin type (Maven)
131+
132+
`KotlinTypeMapper.enumTypeName` named a chained abstract enum after the TOP-MOST root of the
133+
`extends` chain (via `resolveSuperRoot`) while its own FR-019 arm resolved the shared
134+
declaration from the IMMEDIATE super — the rule TS, C#, Java and Python all use. Not a rival
135+
model, a split-brain: the two halves disagreed about which declaration is "the type", and on a
136+
chained declaration (a root abstract `Money extends` a root abstract `@provided Currency`)
137+
that produced a flatly broken emit. Naming now uses the immediate super per ADR-0026 §2 (a
138+
materialized type is named for its own declaration), so a chain yields one type per
139+
declaration, each carrying the members it inherits. Non-chained output is byte-identical —
140+
with no further super, the root walk already returned the immediate super. `resolveSuperRoot`
141+
had exactly one caller and is deleted. The chained alias stays LEGAL rather than being
142+
rejected: it cannot mutate the vocabulary it inherits (a chained declaration carrying its own
143+
`@values` — or its own `@intValueMap` — already errors `ERR_ENUM_EXTENDS_VALUES_CONFLICT`),
144+
and banning it would carve an enum-only hole in ADR-0029's general `extends` grammar to delete
145+
a provably harmless construct. Newly gated by `enum-abstract-chained-extends` (positive) and
146+
`error-enum-chained-extends-values-conflict` (negative), plus the chained declaration restored
147+
to the `shared-provided-enum` codegen corpus all five ports load — the decl-level #246 check
148+
had been code-only in every port with no fixture behind it.
149+
10150
## [0.23.1] — npm `0.23.1` · PyPI `0.23.1` · NuGet `0.23.1` · Maven `7.23.1`
11151

12152
A coordinated **PATCH** across all four registries. Every one of them carries a real changed

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ _Last refreshed 2026-08-16._
5050
- **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.
5151

5252
**Cross-port conformance corpora** (every port runs the shared corpus):
53-
- Metamodel: `fixtures/conformance/` (271 fixtures; 19 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
53+
- Metamodel: `fixtures/conformance/` (286 fixtures; 19 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
5454
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
5555
- Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".)
5656
- API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed.

‎docs/CONFORMANCE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l`.
2525

2626
| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
2727
|---|---|---|---|---|---|---|
28-
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 271 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
28+
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 286 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
2929
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 15 | 15 / 15 | 14 / 15 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 14 / 15 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 15 / 15 |
3030
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
3131
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
@@ -69,7 +69,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
6969

7070
## Fixture-to-doc mapping
7171

72-
### `fixtures/conformance/` — metamodel loader + canonical serializer (271)
72+
### `fixtures/conformance/` — metamodel loader + canonical serializer (286)
7373

7474
| Fixture prefix | Feature doc |
7575
|---|---|

0 commit comments

Comments
 (0)