Skip to content

Commit 834500a

Browse files
dmealingclaude
andcommitted
fix(cli): the missing-FK-index advisory suggests a table-unique index name
The advisory suggested `by<Field>`, so every entity sharing an FK field got the same suggestion. Postgres index names are schema-wide: an adopter applying it verbatim for Citation.auditEntryId and Approval.auditEntryId had `meta migrate` fail with ERR_DUPLICATE_SQL_NAME. The suggestion is now `<entity>By<Field>`; a new test covers two entities with one field. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent c57a711 commit 834500a

3 files changed

Lines changed: 44 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ here.**
2121

2222
### Fixed
2323

24+
- **The missing-FK-index advisory suggests a name unique to the table.** It suggested
25+
`by<Field>`, so two entities with the same FK field (`Citation.auditEntryId`,
26+
`Approval.auditEntryId`) were both told `byAuditEntryId`. Postgres index names are schema-wide,
27+
and applying the advice verbatim failed `meta migrate` with `ERR_DUPLICATE_SQL_NAME`. The
28+
suggestion is now `<entity>By<Field>` (`citationByAuditEntryId`).
2429
- **Python `verify --codegen` no longer passes while checking nothing.** With no
2530
`--generators` it printed a note and exited 0. Since 1.0.4 there is no default suite, so a CI
2631
step written before then regenerated nothing and passed over committed output (one adopter's

‎server/typescript/packages/cli/src/lib/fk-index-advisory.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ export function scanForUnindexedForeignKeys(root: MetaData, opts: UnindexedFkOpt
9696
});
9797
const fields = ref !== undefined ? ref.fields : fk.columns;
9898
const label = ref !== undefined ? `${entity.name}.${ref.name}` : `${entity.name} (${fk.name})`;
99-
const indexName = `by${fields.map(pascal).join("")}`;
99+
// Entity-prefixed: an index name is schema-wide in Postgres, so a bare `by<Field>`
100+
// suggested the SAME name to every entity sharing the FK field, and applying the advice
101+
// verbatim failed `meta migrate` with ERR_DUPLICATE_SQL_NAME.
102+
const indexName = `${entity.name.charAt(0).toLowerCase()}${entity.name.slice(1)}By${fields.map(pascal).join("")}`;
100103
const fieldList = fields.length === 1 ? fields[0]! : `[${fields.join(", ")}]`;
101104
out.push({
102105
file: files[0] ?? "",

‎server/typescript/packages/cli/test/verify-fk-index-lint.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,38 @@ describe("meta verify — unindexed foreign keys (advisory)", () => {
136136
expect(help).not.toContain("hand-roll");
137137
});
138138
});
139+
140+
// The suggested name was `by<Field>`. Postgres index names are schema-wide, so two entities
141+
// with the same FK field were both told `byAuditEntryId`, and taking the advice verbatim made
142+
// `meta migrate` fail with ERR_DUPLICATE_SQL_NAME (found applying the advisory on an adopter).
143+
// The suggestion names the entity too.
144+
describe("meta verify — the suggested FK index name is unique per table", () => {
145+
test("two entities with the same FK field get different suggested names", async () => {
146+
const root = await mkdtemp(join(tmpdir(), "verify-fk-index-names-"));
147+
dirs.push(root);
148+
await mkdir(join(root, "metaobjects"), { recursive: true });
149+
const entity = (name: string, table: string) => ({ "object.entity": { name, children: [
150+
{ "source.rdb": { "@table": table } },
151+
{ "field.long": { name: "id" } },
152+
{ "field.long": { name: "auditEntryId", "@required": true } },
153+
{ "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } },
154+
{ "identity.reference": { name: "fkAudit", "@fields": ["auditEntryId"], "@references": "AuditEntry" } },
155+
] } });
156+
await writeFile(join(root, "metaobjects", "meta.audit.json"), JSON.stringify({ "metadata.root": {
157+
package: "audit",
158+
children: [
159+
{ "object.entity": { name: "AuditEntry", children: [
160+
{ "source.rdb": { "@table": "audit_entries" } },
161+
{ "field.long": { name: "id" } },
162+
{ "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } },
163+
] } },
164+
entity("Citation", "citations"),
165+
entity("Approval", "approvals"),
166+
],
167+
} }), "utf8");
168+
const { out } = await capture(() => verifyCommand([], root));
169+
expect(out).toContain("name: citationByAuditEntryId");
170+
expect(out).toContain("name: approvalByAuditEntryId");
171+
expect(out).not.toContain("name: byAuditEntryId");
172+
});
173+
});

0 commit comments

Comments
 (0)