Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
f6f904c
docs(spec): design int-backed field.enum values via @intValueMap
dmealing Jul 23, 2026
c86cd20
fix(migrate-ts): FK refColumns resolve target @column override; scope…
dmealing Jul 23, 2026
2b60d00
docs(plan): implementation plan for field.enum @intValueMap (metamode…
dmealing Jul 23, 2026
888f0e2
docs(plan): implementation plan for field.enum @intValueMap (TS persi…
dmealing Jul 23, 2026
e9420a6
docs(plan): implementation plan for field.enum @intValueMap (C# persi…
dmealing Jul 23, 2026
f481266
docs(plan): implementation plan for field.enum @intValueMap (Java + K…
dmealing Jul 23, 2026
446dc4f
docs(plan): implementation plan for field.enum @intValueMap (Python p…
dmealing Jul 23, 2026
9844225
feat(metadata): add attr.intMap — a generic object-with-integer-value…
dmealing Jul 24, 2026
3f83a47
feat(metadata): field.enum @intValueMap — explicit per-member int val…
dmealing Jul 24, 2026
3760e12
test(conformance): add cross-port fixtures for field.enum @intValueMap
dmealing Jul 24, 2026
11bb85e
test(registry-conformance): register field.enum @intValueMap (attr.in…
dmealing Jul 24, 2026
b118425
feat(csharp): field.enum @intValueMap — explicit per-member int value…
dmealing Jul 24, 2026
4e02fc5
feat(java): field.enum @intValueMap — explicit per-member int values …
dmealing Jul 24, 2026
9a26f56
feat(python): field.enum @intValueMap — explicit per-member int value…
dmealing Jul 24, 2026
11b4486
fix(python-loader): validate @intValueMap when @values is inherited v…
dmealing Jul 24, 2026
66e1be5
fix(metadata): enforce 32-bit range on field.enum @intValueMap in TS/…
dmealing Jul 24, 2026
b956fc4
test(conformance): add enum-int-backed-inherited-values fixture
dmealing Jul 24, 2026
67e85ab
fix(loader): reject own @intValueMap against a shared enum (#246 int-…
dmealing Aug 11, 2026
a7a7102
fix(codegen): @provided is declaration-layer — read it own-only in TS…
dmealing Aug 12, 2026
a87cd4e
fix(codegen-kotlin): name a chained abstract enum for its OWN declara…
dmealing Aug 12, 2026
7460758
docs(plan): amend the TS int-backed-enum persistence plan for the pos…
dmealing Aug 12, 2026
32a2249
feat(migrate-ts): int-backed field.enum persists as an integer column…
dmealing Aug 12, 2026
f884355
feat(codegen-ts): int-backed enum maps to an integer Drizzle column (…
dmealing Aug 13, 2026
17b342c
docs(design): record the downstream-consumer provenance for int-backe…
dmealing Aug 13, 2026
eca1f77
feat(codegen-ts): int-backed enum codec as a Drizzle customType (Task 5)
dmealing Aug 13, 2026
9e17072
test(integration): int-backed enum against a REAL Postgres — apply, c…
dmealing Aug 13, 2026
7f03fcd
test(integration): the GENERATED int-backed enum codec, against real …
dmealing Aug 14, 2026
66fb0b4
feat(all-ports): the filter-operator band is field-level -- an int-ba…
dmealing Aug 14, 2026
19e03d6
fix(codegen-ts): a view @filter on an int-backed enum lowers to the I…
dmealing Aug 14, 2026
f6044fb
docs(plan): record the Task 8 outcome -- and the view-DDL blocker it …
dmealing Aug 14, 2026
1e66c70
test(integration): TPH + int-backed enums against real Postgres (Task 9)
dmealing Aug 15, 2026
1c95662
docs(plan): record the Task 9 outcome -- TPH needs no int-backed-enum…
dmealing Aug 15, 2026
c18058a
fix(integration-tests): write every generated module before importing…
dmealing Aug 15, 2026
f1e35c8
no-mistakes(document): Documented int-backed enum storage via @intVal…
dmealing Aug 16, 2026
f23b813
docs(design): fix a heading inserted mid-list by the no-mistakes docu…
dmealing Aug 16, 2026
d956251
fix(migrate-ts): restore FK refColumns @column-override resolution lo…
dmealing Aug 16, 2026
0618673
chore(conformance): re-regenerate the registry-coverage baseline afte…
dmealing Aug 16, 2026
a4d3c1c
Merge remote-tracking branch 'origin/main' into feat/int-backed-enum-…
dmealing Aug 16, 2026
6359a3b
feat(python): int-backed enum persistence codec in ObjectManager
dmealing Aug 16, 2026
4989a61
feat(java): int-backed enum persistence in OMDB's EnumCodec
dmealing Aug 16, 2026
9f9f3ad
feat(kotlin): int-backed enum columns emit customEnumeration
dmealing Aug 16, 2026
fab7d89
feat(csharp,conformance): int-backed enum EF conversion + the cross-p…
dmealing Aug 16, 2026
84d8da1
chore(python): sync uv.lock to the 0.23.1 version bump
dmealing Aug 16, 2026
f2aad5f
refactor(int-enum): simplifier pass over the four ports
dmealing Aug 16, 2026
4d94464
fix(int-enum): review findings — C# shared-enum CS0426, Python filter…
dmealing Aug 16, 2026
d1c168f
feat(all-ports): @intValueMap is scalar-only — reject isArray at load
dmealing Aug 17, 2026
34e8530
feat(all-ports): an unmapped stored int throws on read, not a pseudo-…
dmealing Aug 17, 2026
7c9b65a
docs(int-enum): correct every source that still describes the pre-D7 …
dmealing Aug 17, 2026
0508e45
fix(docs,migrate-ts): the remap hazard, measured instead of reasoned …
dmealing Aug 17, 2026
71ccfcc
fix(integration-tests-kotlin): the AllTypes oracle was missing intEnu…
dmealing Aug 17, 2026
32be043
docs(versioning): registry vocabulary does not force a MINOR
dmealing Aug 17, 2026
ad33bc1
fix(runtime-ts): the ObjectManager had no int-backed enum codec at all
dmealing Aug 17, 2026
44bb77a
docs(changelog): TS has TWO persistence seams, and only one had the c…
dmealing Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/skills/releasing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ done
actually on npm (`npm view <pkg> dist-tags`). Patch bump unless a public API
changed; this repo bumps the whole set in lockstep (pre-1.0).

**Registry vocabulary does not force a MINOR** — a new *attribute* is a PATCH, a new
top-level *type* is a MINOR, and a new *subtype* is a PATCH when inert. See
`docs/RELEASING.md` → "The vocabulary rule" before choosing the level; the old
"any registry addition ⇒ MINOR" reading spent two minors on changes no adopter
could observe.

## Phase 2 — Build fresh (the stale-`dist` trap)

`dist/` is gitignored, `bun publish` does NOT rebuild, and `main` points at `dist/`
Expand Down
4 changes: 3 additions & 1 deletion .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
set -uo pipefail

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

# Legitimate matches to ignore (e.g. the published npm author email).
ALLOW='doug@dougmealing\.com'
Expand Down
140 changes: 140 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,146 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

### Added — int-backed `field.enum` storage via `@intValueMap` (all five ports)

**This is a PATCH, not a MINOR** — and the reasoning is itself a change, so it is worth
stating. The old policy read "any registry addition ⇒ MINOR", which spent `0.22.0` and
`0.23.0` on changes a project could not observe at all. `expected-registry.json` is an
**internal** gate: five ports byte-matching one manifest is how we stop the ports drifting
from each other, and it says nothing about whether an adopter's project changes. Vocabulary
now sorts by what it can do to a consumer — a new **attribute** is a PATCH, a new top-level
**type** is a MINOR, and a new **subtype** is a PATCH when nothing but authoring it can reach
it. This line adds one attribute (`@intValueMap`) and one inert attr subtype (`attr.intMap`),
so: PATCH. See `docs/RELEASING.md` → "The vocabulary rule" and ADR-0035 Amendment 1.

Purely additive on its own terms too: a `field.enum` that declares no `@intValueMap` is
byte-identical to before, string-backed as always.

`@intValueMap` is an optional `{memberSymbol: int}` map on `field.enum` that declares each
member's **stored integer**. The column becomes `integer` with an integer `CHECK` instead of
`varchar` with a string one — while the wire format, the generated enum type, and every
runtime return value stay the **member symbol**, unchanged. The provenance is an
integer-coded column an adopter already has: the map is how you say "1 means LOW here",
rather than accepting whatever ordinal a language happens to assign. This is why it is a map
and not a second array parallel to `@values` — a positional array would silently re-map every
member the day someone reorders `@values`.

The loader enforces the map's content identically everywhere: keys must equal `@values`
exactly (no missing, no extra), every value must be a 32-bit integer, and no two members may
share one. Both halves are read RESOLVING, so a field that `extends` a shared abstract enum
inherits the members *and* their mapping — and an own `@intValueMap` declared against a
shared enum is rejected for the same reason an own `@values` is (#246's twin: one shared enum
type has one mapping).

Persistence ships in every port: EF Core `HasConversion` (C#), OMDB's `JdbcFieldCodec`
(Java), Exposed `customEnumeration` (Kotlin), `ObjectManager` coercion (Python), and — in
TypeScript — **both** seams, since TS has two: a Drizzle `customType` for generated code and
`ObjectManager` read/write/filter coercion for the metadata-driven runtime. The second was
missing until the corpus caught it: generated code worked while `om.create()` bound the member
symbol straight into the integer column and Postgres rejected the statement. All gated
cross-port by the `AllTypes` round-trip corpus against real Postgres.

Two decisions are worth naming because each closes a way the feature could have shipped
half-true:

- **Int-backing is scalar-only.** `@intValueMap` together with `isArray: true` is a load
error — `ERR_ENUM_INT_VALUE_MAP_ARRAY`, in every port. The original design said an
array-of-enum composed unchanged; it does not. Int-backing is a persistence-layer CODEC and
every port's codec seam is scalar by construction: Python bound the symbol LIST into an
`integer[]`, Java and Kotlin emitted a scalar codec, and TypeScript's sqlite branch
serialized the array as JSON text before the enum case was ever reached — storing symbols.
Only TS/Postgres and C# composed, and **two ports composing while four silently get it
wrong is not a feature** — it is the `field.byte`/`short`/`class` mistake, vocabulary that
reads as supported and is not. Rejecting it at LOAD delivers the guarantee that was
actually missing: identical behaviour in every port. An array-of-enum stays string-backed.
- **A stored integer that maps to no member THROWS on read**, in every port. The row holds
data the model says is impossible — a hand-written `INSERT`, or a member removed without a
migration — and neither alternative is honest: surfacing the raw integer hands the caller a
"member" that is not one, and is not even representable in C#, Kotlin or TypeScript, which
type the property as a closed enum; returning null hides the corruption behind a nullable
column. C# reaches this through a generated static helper called from the provider→model
lambda — CS8188 bans a throw-*expression* inside an expression tree, but a method CALL is
legal there. The WRITE side is deliberately left to the database: an unmapped symbol binds
unchanged, so the column type and its `CHECK` reject it.

**Adopter-visible beyond the new attribute:** the filter-operator band is now decided
**per field**, not per subtype, so an int-backed `field.enum` no longer offers `like` — the
column holds integers, and `LIKE` against one is a type error, not a query. A projection's
`@filter` over an int-backed enum lowers to the integer literal rather than the symbol.

Migration safety is unchanged and deliberate: adding or removing `@intValueMap` on a field
that already has a column is a cross-kind `change-column-type`, which `meta migrate` already
blocks by default and requires an explicit `allow.typeChange` to pass. There is no
auto-recast — the tool will not rewrite your data behind a metadata edit.

**Do not RE-map a member's integer on a populated table.** Nothing understands that change:
the column holds bare integers, and neither introspection nor the committed snapshot records
which member an integer stood for. A remap changes the rendered `CHECK` list, so it trips the
blocked `drop-check` and `meta migrate` refuses — but that refusal is incidental (dropping a
`CHECK` is destructive), and once allowed the migration only refreshes the constraint and
never touches your rows. Swap two members' integers and the new `CHECK` admits the same set,
applies cleanly, and every stored row has quietly changed meaning. Reorder `@values` to
compensate and the diff is empty outright. Treat a remap as the same two-step backfill a
backing-mode change needs.

Design: `docs/superpowers/specs/2026-07-23-int-backed-enum-values-design.md`. Adopter view:
[`docs/features/field-types.md`](docs/features/field-types.md).

### Changed — registry vocabulary no longer forces a MINOR (policy)

`docs/RELEASING.md`'s versioning table said "PATCH (MINOR if it adds registry vocabulary —
cross-port conformance surface)", and ADR-0035's cadence bullet listed "a newly-supported
vocab member" among the MINOR triggers. Read literally, that made **any** registry addition a
MINOR — the exact churn ADR-0035 was written to prevent. `0.22.0` and `0.23.0` were both cut
MINOR for additions a project declaring no `requirement.*` nodes could not observe at all,
each changelog saying so in its own opening paragraph. Four registries move per cut here, so a
wasted minor is not free — and a minor spent on an unobservable change is a gate you no longer
have when something real needs it. Corrected to sort by consumer impact: **attribute ⇒ PATCH,
top-level type ⇒ MINOR, subtype ⇒ PATCH when inert** (nothing but authoring it can reach it;
MINOR when it narrows something previously permitted, changes existing metadata's meaning, or
headlines a release on purpose). Recorded as ADR-0035 Amendment 1; the post-1.0 compat promise
is untouched — a *breaking* vocabulary change still requires a MAJOR.

### Fixed — an FK into a table whose key carries `@column` phantom-diffed forever (npm)

`buildForeignKeys` resolved a target FK field's PHYSICAL column by applying the naming
strategy to its raw logical name, so a target primary key with an explicit `@column` override
(`id` → `"Id"`) made **every** foreign key into that table diff on every run — expected the
naming-strategy name, actual the override, nothing an adopter could do to converge. It now
resolves through the target entity's own field, which is how `fkCols` already handled the
source side; the two halves simply disagreed.

### Fixed — views in a table-less schema were excluded from the diff entirely (npm)

**Generated-output change — the first `meta migrate` after upgrading may emit view changes
that were always due.** `declaredSchemas` was built from `expected.tables` only, so a model
declaring views in a schema with no table of its own (an API/read-model schema beside an
all-`public` entity model) never brought that schema into scope — and a schema out of scope is
excluded from *both* sides of the diff. Its views were never compared, so a genuine missing or
extra view, or real drift inside an opaque `@sql` body, went undetected rather than reported.
View schemas now join the scope set. Same shape as 0.21.6's `ON DELETE` fix: a PATCH that
surfaces drift which was already there.

### Fixed — a chained abstract `field.enum` emitted a broken Kotlin type (Maven)

`KotlinTypeMapper.enumTypeName` named a chained abstract enum after the TOP-MOST root of the
`extends` chain (via `resolveSuperRoot`) while its own FR-019 arm resolved the shared
declaration from the IMMEDIATE super — the rule TS, C#, Java and Python all use. Not a rival
model, a split-brain: the two halves disagreed about which declaration is "the type", and on a
chained declaration (a root abstract `Money extends` a root abstract `@provided Currency`)
that produced a flatly broken emit. Naming now uses the immediate super per ADR-0026 §2 (a
materialized type is named for its own declaration), so a chain yields one type per
declaration, each carrying the members it inherits. Non-chained output is byte-identical —
with no further super, the root walk already returned the immediate super. `resolveSuperRoot`
had exactly one caller and is deleted. The chained alias stays LEGAL rather than being
rejected: it cannot mutate the vocabulary it inherits (a chained declaration carrying its own
`@values` — or its own `@intValueMap` — already errors `ERR_ENUM_EXTENDS_VALUES_CONFLICT`),
and banning it would carve an enum-only hole in ADR-0029's general `extends` grammar to delete
a provably harmless construct. Newly gated by `enum-abstract-chained-extends` (positive) and
`error-enum-chained-extends-values-conflict` (negative), plus the chained declaration restored
to the `shared-provided-enum` codegen corpus all five ports load — the decl-level #246 check
had been code-only in every port with no fixture behind it.

## [0.23.1] — npm `0.23.1` · PyPI `0.23.1` · NuGet `0.23.1` · Maven `7.23.1`

A coordinated **PATCH** across all four registries. Every one of them carries a real changed
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ _Last refreshed 2026-08-16._
- **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.

**Cross-port conformance corpora** (every port runs the shared corpus):
- 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.
- 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.
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
- 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".)
- 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.
Expand Down
4 changes: 2 additions & 2 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l`.

| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
|---|---|---|---|---|---|---|
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 271 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 286 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
| [`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 |
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
Expand Down Expand Up @@ -69,7 +69,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.

## Fixture-to-doc mapping

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

| Fixture prefix | Feature doc |
|---|---|
Expand Down
Loading
Loading