(motir-core) The public-address store — a SET of addresses per public project with exactly one primary, retained aliases and the reserved-name set: migration + repository
The data layer for public addresses — the Prisma model, its migration and RLS, the reserved-name constant, and a single-operation repository. No service, no route, no UI here: this is the bottom slice of the 4-layer split (motir-core/CLAUDE.md), built exactly to what the ADR decided in Q2, Q3, Q6 and Q7.
What it models — a SET, never a singular
An address is one row in ONE table (PublicAddress, @@map("public_address")), and a project has MANY:
| kind | names | cardinality | who owns the row |
|---|---|---|---|
workspace_subdomain | <label>.<base> — the workspace's claimed subdomain; a project answers at /<identifier> under it | exactly one live per workspace | the workspace (workspaceId, projectId null) |
workspace_subdomain_alias | a previous label after a rename — kept forever, never released, always a redirect | N per workspace, capped by the ADR's rename count | the workspace |
custom_domain | a customer hostname answering for ONE project at its root | N per project, capped by the tier gate | the project (projectId, workspaceId for tenancy) |
Fields every row carries: hostname (globally @unique — a hostname resolves to exactly one owner, and an alias holds its name against re-claim by anyone else), kind, status, verificationToken (the _motir-verify TXT value, custom domains only), lastCheckedAt, issuedAt, failureReason, createdAt, updatedAt. The primary is a nullable Project.primaryAddressId (FK → PublicAddress, onDelete: SetNull): null means the ADR's Q6 default rule applies (the subdomain path when one is claimed, else motir.co/p/<identifier>); set means that custom domain is canonical. One FK cannot point at two rows, which is what makes exactly one primary a constraint rather than a convention.
The status enum IS the state checklist (PublicAddressStatus): active (a subdomain that resolves), alias (a retired label), unverified, verifying, pending_certificate, issued, failed, expired, revoked. Every later card — the job, the pane, the design — names what it does per value; add nothing the ADR did not name and drop nothing.
What ships
prisma/schema.prisma+ the migration: the model, the enum, the FK onProject, indexes on(workspaceId),(projectId),(status, lastCheckedAt)for the status job's sweep. RLS: the table takes the same policy shapeproject_key_aliascarries (read the migration that created it — not the model), and the ANONYMOUS public read the host contract will make must be admissible on thedbsingleton pathfindPublicByIdentifieruses; state the policy in the migration's comment.lib/publicAddresses/reservedNames.ts— the reserved-label set from the ADR's Q7 as aReadonlySet, plusisReservedLabel()and the label grammar (lowercase, digits, hyphen, no leading/trailing hyphen, the ADR's minimum length).lib/repositories/publicAddressRepository.ts— single-Prisma-op methods, requiredtxon every write:findByHostname,listForProject,listForWorkspace,findLiveSubdomainForWorkspace,createSubdomain(tx),retireSubdomainToAlias(tx)(writes the alias row and the new live row in the caller's transaction),createCustomDomain(tx),updateStatus(tx),setPrimary(tx)(onProject),remove(tx),listByStatusOlderThan(status, before, limit)for the job.- Concurrency at the boundary: the unique
hostnameis the race arbiter — a lost race surfaces asP2002and the repository rethrows a typedHostnameTakenError(theIdentifierTakenErrorprecedent), never a raw Prisma error. - Unit tests: the enum is total in every
Record<PublicAddressStatus, …>this card adds; the reserved-name grammar; a real-Postgres repository test for the unique race (two concurrent creates of one hostname → one wins, oneHostnameTakenError).
Boundary
No service or route (the subdomain service and the lifecycle own those), no DTO on the public contract (the host card), no entitlement change (the lifecycle card adds the kind). Nothing outside motir-core.
Acceptance criteria
- The migration creates
public_addresswith a globally uniquehostname, thekindandstatusenums exactly as enumerated above,Project.primaryAddressIdnullable withSetNull, and the three indexes;pnpm prisma migrate deployapplies cleanly on an empty database and on one holding existing projects (no data step required — every existing project has zero addresses). - The migration carries RLS policies for the table, and a test reads
pg_policiesforpublic_addressand asserts they exist — a reading of the catalog, not of the migration file. reservedNames.tsexports the ADR's Q7 set verbatim andisReservedLabelrejects every member, single characters, and labels below the minimum length; a test enumerates the set from the ADR's table.- Every repository write takes
tx; a concurrency test with two real connections proves oneHostnameTakenErrorand one success for the same hostname. - No
@prisma/clientimport outsidelib/repositories/; no route or component file in the diff.
Context refs
- the decision — Q2, Q3, Q6, Q7
motir-core/prisma/schema.prisma—Project(identifier,accessLevel,publicOverviewMd),ProjectKeyAlias(the retained-key precedent and its@@unique),Organization.slugmotir-core/lib/repositories/projectRepository.ts—findPublicByIdentifier(the anonymous read path this table must be readable on),listPublicmotir-core/lib/repositories/projectKeyAliasRepository.ts— the alias write this mirrorsmotir-core/CLAUDE.md— the 4-layer split, required-txwrites,P2002→ typed error, the ≥90% per-file floor