Skip to content

Commit c684f50

Browse files
dmealingclaude
andauthored
feat!: index.* type + identity.secondary key-purity (@unique removed) — all 5 ports (#143)
* docs(design): index.* type + identity.secondary key-purity Splits the overloaded identity.secondary (which had accreted the full DB-index vocab fields/unique/expr/using/orders/where) by behavior per ADR-0037: identity.secondary = unique alternate key (drop @unique); new index.lookup = non-unique retrieval structure (cross-target — an index is a universal IR concept). Physical RDB tuning (@using/@expr/@where/@orders) stays an RDB-scoped escape (like @dbColumnType), never a subtype axis. fulltext/vector/spatial reserved on the axis, not built (YAGNI + 1.0 freeze). Grounded in two production adopters' real idx_* usage. Supersedes PR #142. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * docs(design): finalize index.* decisions — index.lookup, @unique removed (no back-compat), keep @orders, docs in scope Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * docs(plan): index.* type + identity.secondary key-purity implementation plan 13 tasks: ADR → TS reference (vocab/validation/registry/codegen/fixtures) → C#/Java/Kotlin/Python fan-out → migration → docs → integrate/gate/merge/close-#142. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * docs(adr): ADR-0040 — index.* type + identity.secondary key-purity * feat(metadata): index.lookup type + drop @unique from identity.secondary (TS) Registers the new index.lookup type as the TS reference vocabulary: - TYPE_INDEX / INDEX constants in base-types.ts and index-constants.ts - MetaIndex class with fields() accessor (ADR-0039 resolving) - index.lookup registered with core @fields attr; physical attrs (@using/@expr/@where/@orders) contributed by the db provider - identity.secondary @unique removed — it now always means "unique constraint"; use index.lookup for non-unique query-performance indexes - object.base child rules updated to include index (6 structural types) - All conformance fixtures, drift gates, registry manifest, metamodel docs, and coverage snapshot regenerated/updated - 5 pre-existing tests migrated to the new form (removed @unique usage) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * fix(index): resolve two test regressions from Task 2 review Critical 1: remove @unique from error-identity-name-required fixture input. The attr was incidental; strict load now rejects it with ERR_UNKNOWN_ATTR alongside ERR_IDENTITY_NAME_REQUIRED, breaking the conformance assertion. Critical 2: bump BASE_TYPES toHaveLength(12 → 13) in index.test.ts and add TYPE_INDEX to the toContain assertions (TYPE_INDEX was added to BASE_TYPES when index.lookup was registered but the test was not updated). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * feat(metadata): index.lookup field-resolution validation (TS) Adds `validateIndexLookupFields` pass to the loader: - Empty @fields on index.lookup → ERR_INVALID_INDEX - @fields naming a non-existent field → ERR_INVALID_INDEX - Fields inherited via extends resolve correctly (ADR-0039 resolving accessor) Also registers ERR_INVALID_INDEX in errors.ts and the cross-port conformance fixture (fixtures/conformance/ERROR-CODES.json). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(conformance): note index.lookup + identity.secondary key-purity in registry README Manifest itself (index.lookup registered, @unique dropped from identity.secondary) landed in Task 2. Task 4 README note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * feat(codegen-ts): emit index.lookup as non-unique index (Drizzle + DDL) - identity.secondary always emits uniqueIndex() in Drizzle schema; unique: true hardcoded in expected-schema (uniqueness is in the type, no bool) - index.lookup emits non-unique index() in Drizzle schema via obj.lookupIndexes() + MetaIndex.fields() (ADR-0039); emits CREATE INDEX (no UNIQUE) in expected-schema, honoring @orders/@using/@where/@expr - MetaObject.lookupIndexes(): MetaIndex[] accessor added (TYPE_INDEX filter on children(), mirrors secondaryIdentities() pattern) - Cleanup: remove MetaIdentity.unique getter + IDENTITY_ATTR_UNIQUE constant (now genuinely unused after Task 2 dropped @unique from the embedded definition); remove 4 dead tests that authored @unique:false via setAttr - Migrate expression-index.test.ts + index-partial-ordered.test.ts from identity.secondary @unique:false to index.lookup (correct type now) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * feat(conformance): index-lookup metadata + persistence fixtures Add new fixtures/conformance/index-lookup/ with an object.entity carrying a unique identity.secondary, a single-field index.lookup, and a composite index.lookup with @orders (asc/asc/desc). expected.json generated from the TS loader (canonicalSerialize). Migrate fixtures/persistence-conformance/canonical/meta.fitness.json: - Drop @unique:true from identity.secondary byTitle (now implicit; @unique is ERR_UNKNOWN_ATTR on identity.secondary post-ADR-0040) - Add composite index.lookup idx_programs_title_status on [title, status] Regenerate schema.postgres.sql via gen:schema; the DDL now emits both CREATE UNIQUE INDEX (identity.secondary) and CREATE INDEX (index.lookup, non-unique). schema-artifact drift test passes; Testcontainers query tests pass (27/27). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * test(conformance): normalize index-lookup expected.json trailing newline (cross-port byte-compare) * feat(python): index.lookup + drop identity.secondary @unique - New `index` type + `index.lookup` subtype: TYPE_INDEX in base_types, MetaIndex class (ADR-0039 resolving .fields()), index_constants.py - spec/metamodel/index.json (canonical spec) + embedded Python copy - identity.secondary: drop @unique attr (secondary is always unique; use index.lookup for non-unique indexes) — spec + Python embedded copies - db_provider: extend index.lookup with @orders/@where/@expr/@using (same physical attrs as identity.secondary) - validation_passes: _validate_index_lookup_fields (ERR_INVALID_INDEX) + fix allowed_values check for array-valued attrs (checks each element) - errors.py: ERR_INVALID_INDEX - SPEC_FILES updated with index.json; object.json embedded copy synced (adds index child rule to object.base) - test_index_lookup.py: 6 tests (load, @orders, @unique rejected ×2, missing-fields, unknown-field) - Registry-conformance + all metadata-conformance fixtures green (index-lookup, index-lookup-basic, flattened-kitchen-sink) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * feat(csharp): index.lookup + drop identity.secondary @unique - New type=index, subtype=lookup: IndexConstants, IndexSchema, MetaIndex (Fields() uses resolving Attr() per ADR-0039), registered in CoreTypes with canonical description/whenToUse, Wildcard(TYPE_INDEX) in objectRules - DbProvider extends index.lookup with @orders/@expr/@where/@using - identity.secondary: removed @unique from schema/constants/MetaIdentity.Unique (secondary is always unique; non-unique indexes use index.lookup) - ERR_INVALID_INDEX added; ValidateIndexLookupFields wired into loader - Fixed array-valued allowedValues Check 3 (each element vs. whole array) - SpecMetamodel sync: object.json (index.* child), identity.json, db.json - IndexLookupTests: 6 tests (happy-path + @unique rejected + ERR_INVALID_INDEX) - registry-conformance: 680/680 pass; codegen: 265/265 pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * feat(jvm): index.lookup + remove identity.secondary @unique (metadata) Add the index.* type family to the JVM metadata module: - New com.metaobjects.index.{Index, LookupIndex, IndexTypesMetaDataProvider} - index.lookup: non-unique query-performance index; @fields (required array) contributed by IndexTypesMetaDataProvider; @orders/@expr/@where/@using contributed by CoreDBMetaDataProvider.extendType (mirroring the db provider extends blocks in spec/metamodel/db.json) - Remove @unique from identity.secondary (uniqueness is intrinsic to secondary identity; no @unique ever on index.* types — ADR brief constraint) - ERR_INVALID_INDEX: empty @fields or unknown field → thrown by new validateIndexLookupFields pass in ValidationPhase - Allow index.* as optional child of object.base (MetaObject.registerTypes) - Register IndexTypesMetaDataProvider in ServiceLoader SPI and RegistryManifest - Fix extendType() to wire the registry into the TypeDefinitionBuilder so auto-generated array constraints (isArray marker) survive the rebuild - Add spec/metamodel/index.json (type-level docs + fields attr doc); register it in SpecMetamodelReader.SPEC_FILES (16 files); registry-conformance green - 6 new IndexLookupTest cases; 1077/1077 metadata tests green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * feat(codegen-kotlin): emit index.lookup as Exposed non-unique index; identity.secondary always unique * fix(csharp): embed spec/metamodel/index.json + SPEC_FILES 15->16 (FR-033 embed gate) Closes the C# spec-embed gap the parallel fan-out surfaced: C#'s byte-identity embed gate enumerated 15 files (no index.json), a false-green. Canonical spec/metamodel/index.json is single-line (matches sibling identity.json). * docs(metamodel): index.lookup + drop identity.secondary @unique (spec, docs, skills) Add index.lookup authoring guidance to metaobjects-authoring SKILL.md: - Identities table: clarify identity.secondary always enforces uniqueness (uniqueness is the type, not a @unique attr) - New 'Indexes (non-unique)' section: index.lookup for query-performance indexes, choose-by-what-the-constraint-is table, @fields/@orders/@expr/@using/@where attrs, example snippets, placement guidance - Regenerate agent-context conformance fixtures (4 stacks, 5 files total) Metamodel-docs fixtures (fixtures/metamodel-docs/expected/) were already correct from earlier port work: identity.md has no @unique attr row, index.md is complete, INDEX.md and providers.md include index.lookup. Conformance gate: 1 pass. All gates green: metamodel-docs conformance (1), agent-context conformance (4), vocabulary-drift (4), capability-grounding (5) — 14 total. * docs(migrate): identity.secondary → index.lookup migration guide + no-op diff test - docs/features/migrations/identity-secondary-to-index-lookup.md: mechanical rewrite rule (non-unique → index.lookup; unique → keep identity.secondary, drop @unique), Python rewrite script, no-DDL-churn guarantee, ERR_UNKNOWN_ATTR diagnosis - server/typescript/packages/migrate-ts/test/index-migration-noop.test.ts: four tests proving the vocabulary move produces zero migration SQL; documents that @unique:false on identity.secondary is ERR_UNKNOWN_ATTR under strict load - fixtures/conformance/index-lookup-extends/: shared cross-port fixture gating index.lookup whose @fields references an extends-inherited field (ADR-0039 resolving); expected.json loader-generated 2595 pass, 11 skip, 0 fail across 2606 tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * fix(migrate-ts): index-migration-noop test — views: [] not 'as const' (SchemaSnapshot type) * fix(index): @fields required on index.lookup — align registry, docs, and all 5 ports The registry and authoring skill claimed @fields "may be omitted when @expr provided" (an expr-only functional index), but the loader in all 5 ports has always required ≥1 field (ERR_INVALID_INDEX on empty). The expression-index test confirms @fields always accompanies @expr@expr is the key expression *derived from* those fields, not a replacement. Changes (all byte-gated artefacts updated consistently): - spec/metamodel/index.json: min 0 → 1, description corrected - Byte-identical embed copies updated (C# SpecMetamodel, Python spec_metamodel) - expected-registry.json: index.lookup @fields required: false → true + description - TS index-definition.embedded.ts: min 0 → 1 - C# IndexSchema.cs: Required: false → true + doc comment - Python core_types.py: required=False → True - Java LookupIndex.java: optionalAttributeWithConstraints → requiredAttributeWithConstraints - Authoring skill: remove expr-only example; show @fields with @expr (real pattern) - Conformance fixtures regenerated: metamodel-docs/expected + agent-context-conformance/* Gates: TS 2234/0, C# 15/0, Python 94/0, Java 5/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n --------- Co-authored-by: Doug Mealing <noreply@anthropic.com>
1 parent 4686f16 commit c684f50

120 files changed

Lines changed: 4106 additions & 231 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.

.superpowers/sdd/task-7-report.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Task 7 Report — C# port: index.lookup + drop identity.secondary @unique
2+
3+
## Status: COMPLETE
4+
5+
## Files Created
6+
7+
- `server/csharp/MetaObjects/Core/Index/IndexConstants.cs` — INDEX_SUBTYPE_LOOKUP, INDEX_ATTR_FIELDS constants
8+
- `server/csharp/MetaObjects/Core/Index/IndexSchema.cs` — AttrSchema for @fields on index.lookup
9+
- `server/csharp/MetaObjects/Meta/MetaIndex.cs` — MetaIndex node class with Fields() (ADR-0039 resolving accessor)
10+
- `server/csharp/MetaObjects.Codegen.Tests/IndexLookupTests.cs` — 6 unit tests (happy-path, @unique rejected, ERR_INVALID_INDEX)
11+
12+
## Files Modified
13+
14+
- `server/csharp/MetaObjects/Shared/BaseTypes.cs` — added TYPE_INDEX = "index"
15+
- `server/csharp/MetaObjects/GlobalUsings.cs` — added `global using static MetaObjects.Core.Index.IndexConstants`
16+
- `server/csharp/MetaObjects/CoreTypes.cs` — registered index.lookup (TypeDefinition with description+whenToUse), added Wildcard(TYPE_INDEX) to objectRules, added IndexClassMap
17+
- `server/csharp/MetaObjects/Persistence/Db/DbProvider.cs` — extend index.lookup with @orders/@where/@expr/@using; noted @unique intentionally not extended onto identity.secondary
18+
- `server/csharp/MetaObjects/Core/Identity/IdentityConstants.cs` — removed IDENTITY_ATTR_UNIQUE constant
19+
- `server/csharp/MetaObjects/Core/Identity/IdentitySchema.cs` — removed @unique from SecondaryIdentityAttrs
20+
- `server/csharp/MetaObjects/Meta/MetaIdentity.cs` — removed Unique property
21+
- `server/csharp/MetaObjects/Errors.cs` — added ERR_INVALID_INDEX
22+
- `server/csharp/MetaObjects/Loader/ValidationPasses.cs` — added ValidateIndexLookupFields; also fixed array-valued allowedValues check (Check 3) to validate each element rather than the whole array (bug discovered: @orders ["asc","desc"] was triggering ERR_BAD_ATTR_VALUE)
23+
- `server/csharp/MetaObjects/Loader/MetaDataLoader.cs` — wired ValidateIndexLookupFields into load pipeline
24+
- `server/csharp/MetaObjects/SpecMetamodel/*.json` — copied updated spec files (object.json now has index.* child rule)
25+
26+
## Key Fix: allowedValues array element validation
27+
28+
The existing C# Check 3 (allowedValues membership) compared the entire array value against allowed values, which caused `ERR_BAD_ATTR_VALUE` for `@orders: ["asc", "desc"]` on `index.lookup`. Fixed to validate each element individually, matching the TS reference behavior.
29+
30+
## Test Results
31+
32+
- **MetaObjects.Conformance.Tests**: Passed 680/680 (0 failed)
33+
- registry-conformance: PASS (index.lookup registered with correct description/whenToUse/attrs; identity.secondary without @unique)
34+
- metadata conformance index-lookup: PASS
35+
- metadata conformance index-lookup-basic: PASS
36+
- **MetaObjects.Codegen.Tests**: Passed 265/265, Skipped 1 (expected)
37+
- IndexLookupTests (6 tests): all PASS
38+
39+
## EF codegen check
40+
41+
Confirmed: no EF codegen code reads `identity.secondary` uniqueness. The only consumer of `MetaIdentity.Unique` was `MetaIdentity.cs` itself, and that property is removed. No EF model changes needed.

.superpowers/sdd/task-9-report.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Task 9 Report: Kotlin Codegen (Exposed) — index.lookup + identity.secondary
2+
3+
## Summary
4+
5+
`KotlinExposedTableGenerator` now emits `index.lookup` children as non-unique Exposed indexes,
6+
and `identity.secondary` was already (correctly) always emitting `uniqueIndex(...)` — no
7+
`isUniqueKey()` call existed in the generator; Task 8 had already removed it from the metadata class
8+
before this task started.
9+
10+
## Files Changed
11+
12+
### `server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt`
13+
14+
1. **Added import**: `import com.metaobjects.index.LookupIndex`
15+
2. **Extended init-block emission** (lines ~483–502): The existing secondaries-only `init` block was
16+
expanded to also iterate `entity.getChildren(LookupIndex::class.java, true)` (ADR-0039 resolving
17+
accessor — `includeParentData=true` so inherited lookup indexes from abstract base entities are
18+
visible). For each non-empty lookup index, emits `index("name", false, col1, col2, ...)`. The
19+
combined `init` block is only emitted when at least one secondary or lookup index is present.
20+
Views are excluded (same guard as secondaries: `if (isView) emptyList()`).
21+
22+
### `server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGeneratorTest.kt`
23+
24+
Three new tests added in the `// === index.lookup coverage` section:
25+
26+
- **`single index lookup emits non-unique Exposed index`**: single-field `index.lookup`
27+
`index("idx_orders_status", false, status)` inside `init { }`.
28+
- **`composite index lookup emits multi-column non-unique index`**: two-field `index.lookup`
29+
`index("idx_events_tenant_type", false, tenantId, eventType)`.
30+
- **`entity with both identity secondary and index lookup shares one init block`**: mixed entity with
31+
one `identity.secondary` and one `index.lookup` → exactly one `init { }` block containing both
32+
`uniqueIndex(...)` and `index(..., false, ...)`.
33+
34+
## Test Results
35+
36+
```
37+
Tests run: 269, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS
38+
KotlinExposedTableGeneratorTest: 37 tests (was 34 before, +3 new)
39+
```
40+
41+
All 269 codegen-kotlin tests pass.
42+
43+
## Golden / Snapshot Changes
44+
45+
None. All assertions are behavioral (string containment in generated output), not snapshot files.
46+
47+
## Implementation Notes
48+
49+
- `identity.secondary` was already always emitting `uniqueIndex(...)` — no `isUniqueKey()` call
50+
was present in the generator (Task 8 had already removed it from `SecondaryIdentity`). The
51+
generator logic was correct; only the `index.lookup` fan-out was missing.
52+
- `@orders` (per-field sort direction on lookup indexes) is not emitted: the standard Exposed
53+
`Table.index(name, isUnique, *columns)` API does not support per-column sort direction in the
54+
version used by this project. The brief's "if the generator supports column ordering" clause
55+
applies — Exposed does not, so it is deferred.
56+
- No named constants were needed beyond the already-imported Java constants (`LookupIndex`,
57+
`Index.ATTR_FIELDS` is accessed via `idx.fields` which calls `getFields()` on the Java class
58+
using the resolving accessor per ADR-0039).
59+
60+
## Concerns
61+
62+
None. The implementation is minimal, correct, and backward-compatible (entities without
63+
`index.lookup` children emit identical output to before).

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ Preserve the following contracts exactly across all language ports:
430430
- Source subtypes: `rdb` (paradigm; ADR-0007). The pre-v2 `dbTable`/`dbView` subtypes are RETIRED — `source.rdb` + `@kind: table|view|materializedView|storedProc|tableFunction` is the form, with read-only-ness derived from `@kind`. Multi-source via `@role` (exactly one `primary` per object). Source physical name = `@table` (NOT `@name`); field physical name = `@column` (renamed from `@dbColumn`). Referential actions on relationships: `@onDelete` / `@onUpdate`.
431431
- Origin subtypes: `passthrough`, `aggregate`
432432
- Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side). `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N → `ERR_INVALID_RELATIONSHIP`.
433+
- Index subtypes: `index.lookup` (non-unique retrieval index; uniqueness is encoded in the **type**: `identity.secondary` = unique alternate key, `index.lookup` = non-unique; `@unique` is REMOVED from `identity.secondary``ERR_UNKNOWN_ATTR` on any legacy `@unique`). RDB-physical escapes `@using`/`@expr`/`@where`/`@orders` are registered by the db provider on **both** `identity.secondary` and `index.lookup`. `index.fulltext` / `index.vector` / `index.spatial` are reserved on the subtype axis — documented, NOT registered (YAGNI + 1.0 vocab freeze). See [ADR-0040](spec/decisions/ADR-0040-index-type-and-secondary-key-purity.md).
433434
- Layout subtypes: `dataGrid`
434435
- API subtypes: `api.base` / `api.operational` (request/response surface; subtype axis = interaction model, NEVER protocol — protocol lives in `binding.*` per operation: `rest` now, `messaging`/`grpc` reserved). Children: `operation.query` (outputRef → `object.projection`) / `operation.command` (inputRef → `object.value`, may also outputRef). Derived CRUD (FR-008/009) stays the zero-config default; declared `api` extends it. Org-tier modeling (application/service/network/deployment) stays OUT of core — provider SPI, FQN references. See [ADR-0030](spec/decisions/ADR-0030-declared-api-surface-and-org-tier-boundary.md). (FR-024 declared-API — planned; not yet in `expected-registry.json`; the remaining third of FR-024 after the projection/value taxonomy + validation parity.)
435436
- Currency attrs: `@currency` (ISO 4217), `@locale` (BCP 47)

agent-context/skills/metaobjects-authoring/SKILL.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ The `[]` key-suffix declares an array field: `field.long[]: weekIds` lowers to
384384
| Subtype | Purpose | Key attrs |
385385
|---|---|---|
386386
| `identity.primary` | the PK field(s) | `@fields`, `@generation` |
387-
| `identity.secondary` | a unique secondary index | `@fields` (or `@expr` for a functional index) |
387+
| `identity.secondary` | a unique alternate key (always enforces uniqueness — uniqueness is the type, not a `@unique` attr) | `@fields` (or `@expr` for a functional index) |
388388
| `identity.reference` | an inbound FK from this entity to another | `@fields`, `@references`, `@enforce` |
389389

390390
`@generation` on a primary controls value generation (e.g. `increment`).
@@ -407,7 +407,7 @@ PK needs the explicit dotted form `@references: "pkg::Target.fieldA,fieldB"`.
407407
exist (previously such references loaded silently). So every `@references` /
408408
`@objectRef` you author must name a real entity.
409409

410-
A `identity.secondary` can index an **expression** instead of plain columns: use
410+
An `identity.secondary` can index an **expression** instead of plain columns: use
411411
`@expr` (e.g. `"lower(email)"`) in place of `@fields`, optionally with `@using` (the
412412
index method — `gin` / `gist` / `hash`; default `btree`) and `@where` (a partial-index
413413
predicate).
@@ -419,6 +419,30 @@ predicate).
419419
{ "identity.reference": { "name": "fkAuthor", "@fields": ["authorId"], "@references": "Author", "@enforce": true } }
420420
```
421421

422+
## Indexes (non-unique)
423+
424+
Use `index.lookup` for a **non-unique** DB index added purely for query performance — it
425+
does NOT enforce uniqueness. Choose the right construct by what the constraint IS:
426+
427+
| Need | Construct |
428+
|---|---|
429+
| Unique alternate key (e.g. email, slug) | `identity.secondary` — uniqueness is the type |
430+
| Query-performance index, no uniqueness | `index.lookup` |
431+
432+
`@fields` names the indexed columns and is **required** (at least one). The db provider
433+
contributes physical-tuning attrs: `@orders` (per-column sort direction), `@using` (access
434+
method — `gin`/`gist`/`hash`; default `btree`), `@expr` (key expression derived from
435+
`@fields`, e.g. for a functional index), and `@where` (partial-index predicate).
436+
437+
```json
438+
{ "index.lookup": { "name": "byCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } }
439+
{ "index.lookup": { "name": "byStatusCreatedAt", "@fields": ["status", "createdAt"] } }
440+
{ "index.lookup": { "name": "byEmailCI", "@fields": ["email"], "@expr": "lower(email)" } }
441+
```
442+
443+
`index.lookup` is a sibling of `identity.*` — declare it as a direct child of an `object.entity`,
444+
at the same level as fields and identities.
445+
422446
## Relationships
423447

424448
`relationship.composition` is the "this entity owns / aggregates instances of
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# Migrating `identity.secondary @unique:false``index.lookup`
2+
3+
`index.lookup` is the correct declaration for a **non-unique query-performance
4+
index**. The `@unique` attribute on `identity.secondary` was always wrong (the
5+
subtype is inherently unique — `@unique:false` was an unsupported override that
6+
had no effect on DDL) and is now rejected by the loader with
7+
`ERR_UNKNOWN_ATTR`.
8+
9+
This guide explains the mechanical rewrite and confirms that the physical
10+
database schema is **unchanged** — no migration SQL is emitted.
11+
12+
---
13+
14+
## Rewrite rule
15+
16+
### Non-unique index (the common case)
17+
18+
`identity.secondary` with `@unique: false` (or any intent to produce a plain,
19+
non-unique index) → **replace with `index.lookup`** and drop the `@unique`
20+
attribute.
21+
22+
**Before:**
23+
24+
```json
25+
{
26+
"identity.secondary": {
27+
"name": "idx_orders_session",
28+
"@fields": ["sessionId"],
29+
"@unique": false
30+
}
31+
}
32+
```
33+
34+
**After:**
35+
36+
```json
37+
{
38+
"index.lookup": {
39+
"name": "idx_orders_session",
40+
"@fields": ["sessionId"]
41+
}
42+
}
43+
```
44+
45+
All physical index attributes (`@orders`, `@using`, `@where`, `@expr`) are
46+
supported on `index.lookup` and carry over unchanged.
47+
48+
### Unique index (stays `identity.secondary`)
49+
50+
`identity.secondary` with `@unique: true` or no `@unique` attribute → **keep
51+
`identity.secondary`** and drop the now-invalid `@unique` attribute (uniqueness
52+
is guaranteed by the subtype itself and never needs to be declared explicitly).
53+
54+
**Before:**
55+
56+
```json
57+
{
58+
"identity.secondary": {
59+
"name": "uq_users_email",
60+
"@fields": ["email"],
61+
"@unique": true
62+
}
63+
}
64+
```
65+
66+
**After:**
67+
68+
```json
69+
{
70+
"identity.secondary": {
71+
"name": "uq_users_email",
72+
"@fields": ["email"]
73+
}
74+
}
75+
```
76+
77+
---
78+
79+
## Mechanical rewrite script
80+
81+
The transformation is safe to apply automatically. The script below rewrites
82+
`.json` metadata files in place. Review the diff with `git diff` before
83+
committing.
84+
85+
```bash
86+
#!/usr/bin/env bash
87+
# Rewrite non-unique identity.secondary → index.lookup in MetaObjects metadata
88+
# JSON files. Operates on all meta.*.json files under ./metaobjects/ (adjust
89+
# the glob for your layout).
90+
#
91+
# IMPORTANT: this is a line-oriented heuristic. Review the output with
92+
# `git diff` and run `meta verify` to confirm zero errors before committing.
93+
94+
set -euo pipefail
95+
96+
find metaobjects -name 'meta.*.json' -print0 | while IFS= read -r -d '' file; do
97+
# Step 1: replace identity.secondary entries that contain "@unique": false
98+
# with index.lookup (remove the @unique line).
99+
# This uses Python for reliable multi-line JSON key surgery.
100+
python3 - "$file" <<'PYEOF'
101+
import json, sys, re
102+
103+
path = sys.argv[1]
104+
with open(path) as f:
105+
text = f.read()
106+
107+
data = json.loads(text)
108+
109+
def transform(node):
110+
if not isinstance(node, dict):
111+
return node
112+
result = {}
113+
for key, val in node.items():
114+
if key == "identity.secondary" and isinstance(val, dict):
115+
unique = val.get("@unique")
116+
# Non-unique intent: @unique explicitly false → rewrite to index.lookup
117+
if unique is False:
118+
new_val = {k: v for k, v in val.items() if k != "@unique"}
119+
result["index.lookup"] = new_val
120+
else:
121+
# Unique (true or absent): keep as identity.secondary, drop @unique
122+
new_val = {k: v for k, v in val.items() if k != "@unique"}
123+
result["identity.secondary"] = new_val
124+
elif isinstance(val, (dict, list)):
125+
result[key] = transform(val)
126+
else:
127+
result[key] = val
128+
return result
129+
130+
def transform_list(node):
131+
if isinstance(node, list):
132+
return [transform_list(item) if isinstance(item, list)
133+
else transform(item) if isinstance(item, dict)
134+
else item
135+
for item in node]
136+
return transform(node)
137+
138+
out = json.dumps(transform_list(data), indent=2)
139+
with open(path, "w") as f:
140+
f.write(out + "\n")
141+
print(f" rewrote: {path}")
142+
PYEOF
143+
done
144+
145+
echo "Done. Run 'meta verify' to confirm zero loader errors."
146+
```
147+
148+
---
149+
150+
## No DDL churn — the physical schema is unchanged
151+
152+
The physical index produced by `index.lookup` is byte-identical to the non-unique
153+
index that the old `identity.secondary @unique:false` would have produced. The
154+
`meta migrate` diff engine recognises this and emits **no migration SQL** — the
155+
`CREATE INDEX` statement already exists with the correct name, columns, and
156+
ordering.
157+
158+
If you run `meta migrate` after the rewrite and see an unexpected `DROP INDEX` /
159+
`CREATE INDEX` pair, verify that:
160+
161+
1. The index `name` is identical in both forms.
162+
2. The `@fields` list is unchanged (field names, not column names — the column
163+
naming strategy is applied by the toolchain, not by the metadata author).
164+
3. Any `@orders`, `@using`, `@where`, or `@expr` values are carried over verbatim.
165+
166+
---
167+
168+
## Diagnosing the `ERR_UNKNOWN_ATTR` error
169+
170+
If `meta verify` or `meta gen` reports:
171+
172+
```
173+
ERR_UNKNOWN_ATTR: "@unique" is not a registered attribute for "identity.secondary"
174+
```
175+
176+
a metadata file still contains `@unique` on an `identity.secondary` node. The
177+
rewrite script above eliminates all occurrences; if the error persists after
178+
running it, grep for any remaining occurrences:
179+
180+
```bash
181+
grep -r '"@unique"' metaobjects/
182+
```
183+
184+
Each hit is a remaining `@unique` that must be either removed (for
185+
`identity.secondary`, where uniqueness is the subtype default) or relocated to an
186+
`index.lookup` node (which does not accept `@unique` either — non-uniqueness is
187+
its subtype default).

0 commit comments

Comments
 (0)