Skip to content

Commit 22124b7

Browse files
dmealingclaude
andcommitted
fix(library): iam/db shipped four indexes with one name, so it could not migrate
`libraries: ["iam/db"]` on 1.0.4 dies on the first `meta migrate`: ERR_DUPLICATE_SQL_NAME: "uqKey" <- index "uqKey" on table "iam_group_type" + index "uqKey" on table "iam_group" + ... "iam_role" + ... "iam_permission" Four entities each named their `identity.secondary` `uqKey`. That is unambiguous per entity and wrong per database, which is the scope index names actually have. They are now uqGroupTypeKey / uqGroupKey / uqRoleKey / uqPermissionKey. No adopter can be holding the old schema: the migrate that would have created it is the one that failed. Found by following docs/features/libraries.md end to end on published 1.0.4 in an empty project. The gate that let it ship: `shipped-library-verify` loaded each library and ran the requirement checks over it, and neither of those builds a schema. It now also builds a sqlite and a postgres schema for every db layer and asserts index names are distinct. It fails on both dialects without the rename. Also corrects the doc claim the same run disproved: opting into a core layer adds "zero tables and zero generated code". Zero tables is right; the entity generator, where it is wired, emits a type-only module per library entity — 10 of them for `["iam"]` — which the very next paragraph already said. The spec's worked example carried the same four `uqKey` lines; renamed there too, so the next library copied from it does not inherit the collision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4018869 commit 22124b7

9 files changed

Lines changed: 57 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
4848
recorded writing (`.gen-state/.hashes.json`) is now reported as ordinary stale output with
4949
the `meta gen` remedy. An edited one, or one with no recorded hash, is still convicted as
5050
before.
51+
- **`libraries: ["iam/db"]` could not migrate at all.** The library named four
52+
`identity.secondary` nodes `uqKey` — legal metadata, and unambiguous per entity, but
53+
index names are unique per DATABASE, so the first `meta migrate` in any project that
54+
opted in died on `ERR_DUPLICATE_SQL_NAME` before writing a table. They are now
55+
`uqGroupTypeKey`, `uqGroupKey`, `uqRoleKey` and `uqPermissionKey`. Nobody can have
56+
applied the old schema, because the migrate that would have created it is the one
57+
that failed. The shipped-library gate now builds a sqlite AND a postgres schema for
58+
every library db layer and asserts index names are distinct — loading and verifying,
59+
which is all it did before, never build a schema.
5160
- **A requirement whose implementing member was renamed no longer says to qualify the object.**
5261
Rename `dueDate` in the model and a live claim on `app::Task.dueDate` failed with
5362
`ERR_REQUIREMENT_DANGLING_REF`, then added *"An object named "Task" exists in: app::Task.

‎docs/features/libraries.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ A library ships its **core model**, its **DB persistence** and (where it has one
3131
contract that already shipped.** `meta migrate` skips an object with no writable source
3232
and codegen emits no route, queries, hooks, grid or form for one (both citing #248:
3333
persistability derives from source presence, never from the object subtype). So
34-
`libraries: ["iam"]` adds **zero tables and zero generated code**. The design is
35-
present and resolvable; the schema is a second, separate decision.
34+
`libraries: ["iam"]` adds **zero tables, and no persistence, API or client code**. The
35+
design is present and resolvable; the schema is a second, separate decision. The entity
36+
generator, if you have wired it, does emit one type-only module per library entity —
37+
which is the next paragraph, and the whole of what the core layer costs you.
3638

3739
A sourceless object still gets a type-only interface, so `extends` and references work
3840
against it from day one.

‎docs/superpowers/specs/2026-09-13-fr-043-feature-and-nfr-packages-design.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,7 @@ metadata:
589589
- field.string: { name: name, required: true, maxLength: 120 }
590590
- field.string: { name: description, maxLength: 500 }
591591
- identity.primary: { name: pk, fields: [id], generation: uuid }
592-
- identity.secondary: { name: uqKey, fields: [key] }
592+
- identity.secondary: { name: uqGroupTypeKey, fields: [key] }
593593

594594
- object.entity:
595595
name: Group
@@ -602,7 +602,7 @@ metadata:
602602
- field.string: { name: name, required: true, maxLength: 120, filterable: true }
603603
- field.string: { name: description, maxLength: 500 }
604604
- identity.primary: { name: pk, fields: [id], generation: uuid }
605-
- identity.secondary: { name: uqKey, fields: [key] }
605+
- identity.secondary: { name: uqGroupKey, fields: [key] }
606606
- identity.reference: { name: fkGroupType, fields: [groupTypeId], references: GroupType, onDelete: restrict }
607607
- identity.reference: { name: fkParent, fields: [parentId], references: Group, onDelete: restrict }
608608
- index.lookup: { name: ixParent, fields: [parentId] }
@@ -617,7 +617,7 @@ metadata:
617617
- field.string: { name: description, maxLength: 500 }
618618
- field.uuid: { name: groupTypeId, description: "When set, this role may be held only within groups of this type; absent means grantable anywhere." }
619619
- identity.primary: { name: pk, fields: [id], generation: uuid }
620-
- identity.secondary: { name: uqKey, fields: [key] }
620+
- identity.secondary: { name: uqRoleKey, fields: [key] }
621621
- identity.reference: { name: fkGroupType, fields: [groupTypeId], references: GroupType, onDelete: restrict }
622622
- relationship.association: { name: permissions, objectRef: Permission, cardinality: many, through: RolePermission }
623623

@@ -629,7 +629,7 @@ metadata:
629629
- field.string: { name: key, required: true, maxLength: 128, description: "Stable <resource>:<action> key the application checks against." }
630630
- field.string: { name: description, maxLength: 500 }
631631
- identity.primary: { name: pk, fields: [id], generation: uuid }
632-
- identity.secondary: { name: uqKey, fields: [key] }
632+
- identity.secondary: { name: uqPermissionKey, fields: [key] }
633633

634634
# ---- grant surface: every grant is a row, addressed by its participants ----
635635

‎library/iam/model.yaml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ metadata:
6464
- field.string: { name: name, required: true, maxLength: 120 }
6565
- field.string: { name: description, maxLength: 500 }
6666
- identity.primary: { name: pk, fields: [id], generation: uuid }
67-
- identity.secondary: { name: uqKey, fields: [key] }
67+
- identity.secondary: { name: uqGroupTypeKey, fields: [key] }
6868

6969
- object.entity:
7070
name: Group
@@ -78,7 +78,7 @@ metadata:
7878
- field.string: { name: name, required: true, maxLength: 120 }
7979
- field.string: { name: description, maxLength: 500 }
8080
- identity.primary: { name: pk, fields: [id], generation: uuid }
81-
- identity.secondary: { name: uqKey, fields: [key] }
81+
- identity.secondary: { name: uqGroupKey, fields: [key] }
8282
- identity.reference: { name: fkGroupType, fields: [groupTypeId], references: GroupType, onDelete: restrict }
8383
- identity.reference: { name: fkParent, fields: [parentId], references: Group, onDelete: restrict }
8484

@@ -92,7 +92,7 @@ metadata:
9292
- field.string: { name: description, maxLength: 500 }
9393
- field.uuid: { name: groupTypeId, description: "When set, this role may be held only within groups of this type; absent means grantable anywhere." }
9494
- identity.primary: { name: pk, fields: [id], generation: uuid }
95-
- identity.secondary: { name: uqKey, fields: [key] }
95+
- identity.secondary: { name: uqRoleKey, fields: [key] }
9696
- identity.reference: { name: fkGroupType, fields: [groupTypeId], references: GroupType, onDelete: restrict }
9797
- relationship.association: { name: permissions, objectRef: Permission, cardinality: many, through: RolePermission }
9898

@@ -104,7 +104,7 @@ metadata:
104104
- field.string: { name: key, required: true, maxLength: 128, description: "Stable <resource>:<action> key the application checks against." }
105105
- field.string: { name: description, maxLength: 500 }
106106
- identity.primary: { name: pk, fields: [id], generation: uuid }
107-
- identity.secondary: { name: uqKey, fields: [key] }
107+
- identity.secondary: { name: uqPermissionKey, fields: [key] }
108108

109109
# ---- grant surface: every grant is a row, addressed by its participants ----
110110
#

‎server/csharp/MetaObjects/Library/EmbeddedLibrary.cs‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎server/java/metadata/src/main/java/com/metaobjects/library/EmbeddedLibrary.java‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎server/python/src/metaobjects/library/embedded_library.py‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎server/typescript/packages/cli/test/shipped-library-verify.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
checkRequirements, summariseRequirements, scanRequirements,
2424
} from "../src/lib/requirement-check.js";
2525
import { lintRequirements } from "../src/lib/requirement-lint.js";
26+
import { buildExpectedSchema } from "@metaobjectsdev/migrate-ts";
2627

2728
const TOKENS = knownLibraryTokens();
2829

@@ -84,3 +85,34 @@ describe("every shipped library verifies standalone (FR-043 §8 item 2)", () =>
8485
});
8586
}
8687
});
88+
89+
// A library's DB layer has to produce a SCHEMA, not just load and verify. `iam/db`
90+
// shipped in 1.0.4 naming four `identity.secondary` nodes `uqKey` — legal metadata,
91+
// and legal per-entity — but index names are unique per DATABASE, so the first
92+
// `meta migrate` in any project that opted in died on ERR_DUPLICATE_SQL_NAME before
93+
// writing a table. The gates above could not see it: loading and verifying never
94+
// build a schema. Both dialects, because the collision is in the shared naming pass.
95+
describe("every shipped library's db layer builds a schema (both dialects)", () => {
96+
const DB_TOKENS = TOKENS.filter((t) => t.includes("/db"));
97+
98+
test("there is a db layer to gate", () => {
99+
expect(DB_TOKENS.length).toBeGreaterThan(0);
100+
expect(DB_TOKENS).toContain("iam/db");
101+
});
102+
103+
for (const token of DB_TOKENS) {
104+
for (const dialect of ["sqlite", "postgres"] as const) {
105+
test(`\`${token}\` builds a ${dialect} schema with no duplicate SQL name`, async () => {
106+
const result = await new MetaDataLoader({ strict: true }).load(librarySources([token]));
107+
expect(result.errors, `${token} errors`).toEqual([]);
108+
const snapshot = buildExpectedSchema(result.root, { dialect });
109+
expect(snapshot.tables.length).toBeGreaterThan(0);
110+
// State the invariant the engine enforces, so a regression names itself here
111+
// rather than in an adopter's first migrate.
112+
const indexNames = snapshot.tables.flatMap((t) => (t.indexes ?? []).map((i) => i.name));
113+
expect(new Set(indexNames).size, `duplicate index name in ${token}: ${indexNames.join(", ")}`)
114+
.toBe(indexNames.length);
115+
});
116+
}
117+
}
118+
});

0 commit comments

Comments
 (0)