@qninhdt/typespec-orm
v0.5.0
Published
TypeSpec decorator library for defining database ORM schemas - @table, @key, @index, @foreignKey and more.
Downloads
572
Maintainers
Readme
@qninhdt/typespec-orm
Shared TypeSpec ORM library used by all emitters in this repository.
This package is where the schema contract actually lives. It defines decorators, validation behavior, relation resolution, namespace normalization, selector filtering, and the normalized ORM graph consumed by the emitters.
What This Package Owns
@qninhdt/typespec-orm is responsible for:
- declaring the public decorators used in TypeSpec
- validating ORM-managed declarations
- resolving relation ownership and foreign keys
- expanding
@tableMixin - building the normalized namespace-aware model graph
- applying shared
include/excludeselection logic - surfacing shared diagnostics before emitters write files
It does not emit code by itself.
Installation
pnpm add -D @typespec/compiler @qninhdt/typespec-ormImporting The Library
import "@qninhdt/typespec-orm";
using Qninhdt.Orm;Core Concepts
Namespaces are required
@table, @data, and @tableMixin must be declared inside a namespace. Required referenced declarations must also be namespaced.
This matters because the shared ORM graph treats namespaces as the source of truth for:
- output paths
- package structure
- import calculation
- selector filtering
- dependency validation
@tableMixin
@tableMixin exists for reusable persisted field groups.
Mixins:
- are validated
- can inherit from other mixins
- can be composed into tables
- are never emitted as standalone tables
Field collisions between mixins or between a mixin and a child model are errors.
Shared normalization
Emitters do not re-discover tables independently anymore. They consume the normalized graph produced by the ORM core, which includes:
- kind: table, data, or mixin
- namespace and namespace path
- namespace-derived output directory
- dependencies on models, mixins, enums, and scalars
- selected models after filtering
Normalized Graph Contract
The normalized graph is the contract between this package and the emitters. Each normalized model includes:
- its ORM kind:
table,data, ormixin - full dotted declaration name
- namespace segments and a snake_case namespace path
- namespace-derived output directory and leaf package name
- resolved mixin sources
- hard and soft dependencies on models, mixins, enums, and scalars
That shared graph is what makes the emitters behave consistently. GORM, SQLModel, Zod, and DBML no longer perform their own disconnected model discovery passes.
Namespace Normalization Rules
Namespace handling is intentionally deterministic:
- namespace segments are preserved for selection and diagnostics
- output paths convert each segment with
camelToSnake - package or module leaf names come from the final normalized namespace segment
- root-level declarations are rejected instead of being assigned an implicit folder
Example:
namespace Demo.GamePlatform.Content.Stories;normalizes to:
- namespace:
Demo.GamePlatform.Content.Stories - namespace path:
["demo", "game_platform", "content", "stories"] - namespace directory:
demo/game_platform/content/stories - package leaf:
stories
Decorator Reference
Model decorators
@table(name?)Marks a model as a persisted table. Ifnameis omitted, the table name is derived from the model name.@tableMixinMarks a model as a reusable ORM mixin.@data(label?)Marks a model as a non-table data shape for forms and DTOs.
Column and persistence decorators
@map(columnName)Overrides the emitted column name.@index(name?)Adds a non-unique index.@uniqueAdds a unique constraint for the field.@check(name, expression)Adds a named database check constraint anchored to the property.@precision(precision, scale?)Adds numeric precision metadata.@autoIncrementMarks an integer field as auto-incrementing.@softDeleteMarks a datetime field as the soft-delete column.@autoCreateTimeMarks a datetime field as create timestamp metadata.@autoUpdateTimeMarks a datetime field as update timestamp metadata.@ignoreRemoves a property from persistence emitters.
Relation decorators
@foreignKey(localField, referencedField?)Declares the owning side of a relation using a local field and an optional referenced target field.@mappedBy(inverseProperty)Declares the inverse side of a relation.@manyToMany(joinTableName)Declares many-to-many shorthand on an array relation.@onDelete(action)Declares FK delete behavior.@onUpdate(action)Declares FK update behavior.
Form metadata decorators
@title(text)@placeholder(text)@@inputType(scalar, htmlType)
@@inputType targets a scalar. When applied to a field, use Field::type or the source scalar for lookup-typed fields:
@@inputType(CreateWorldForm.summary::type, "textarea");
@@inputType(Demo.Worlds.World.prompt::type, "textarea");Basic Example
import "@qninhdt/typespec-orm";
using Qninhdt.Orm;
namespace Demo.Shared;
@tableMixin
model Timestamped {
@key id: uuid;
@autoCreateTime createdAt: utcDateTime;
@autoUpdateTime updatedAt?: utcDateTime;
}
namespace Demo.Accounts;
@table
model User is Demo.Shared.Timestamped {
@unique
@maxLength(320)
@format("email")
email: string;
@check("users_credits_non_negative", "credits >= 0")
credits: int32 = 0;
@manyToMany("user_badges")
badges?: Badge[];
}
@table
model Badge is Demo.Shared.Timestamped {
@unique code: string;
@manyToMany("user_badges")
users?: User[];
}
namespace Demo.Worlds;
@table
model World is Demo.Shared.Timestamped {
ownerId: uuid;
slug: string;
@foreignKey("ownerId")
owner: Demo.Accounts.User;
}
namespace Demo.Forms;
@data("Create Invitation Form")
model CreateInvitationForm {
@title("Invitee Email")
@placeholder("[email protected]")
inviteeEmail: Demo.Accounts.User.email;
}Relation Semantics
Owned relations
Owned relations are declared on the navigation property:
authorId: uuid;
@foreignKey("authorId")
author: User;The optional second argument targets a non-id field:
organizationCode: string;
@foreignKey("organizationCode", "code")
organization: Organization;Inverse relations
@mappedBy("author")
posts: Post[];Many-to-many shorthand
@table
model User {
@key id: uuid;
@manyToMany("user_badges")
badges?: Badge[];
}
@table
model Badge {
@key id: uuid;
@manyToMany("user_badges")
users?: User[];
}Rules:
- the property must be an array of
@tablemodels - both sides must declare
@manyToMany - both sides must use the same join table name
- shorthand conflicts with an explicit table of the same name
- shorthand is for simple joins only; payload-column join tables should be modeled explicitly
Lookup Types And Property Reuse
This package supports source-property reuse patterns such as:
@data
model PublicUser {
email: Demo.GamePlatform.Accounts.User.email;
}That lets @data models and other consumers inherit the source property's underlying scalar type and constraints without duplicating the full column definition manually.
Use lookup types when you want:
- one source of truth for field-level constraints
- consistent frontend and backend validation
- shared field descriptions across namespaces
Avoid lookup types when the derived model needs materially different semantics; define a dedicated property instead.
Shared Selector Model
Emitters using this core support:
include:
- "Demo.Worlds"
- "Demo.Forms"
exclude:
- "Demo.Audit"Selectors can match:
- a namespace subtree
- a concrete declaration
There is no wildcard syntax. Selectors are matched by dotted-name prefix semantics.
Examples:
| Selector | Matches |
| -------------------------------------- | ------------------------------------------------------------------ |
| Demo.GamePlatform.Forms | the entire forms subtree |
| Demo.GamePlatform.Accounts.User | the User declaration specifically |
| Demo.GamePlatform.Content | all content namespaces below it |
| Demo.GamePlatform.Audit in exclude | removes the audit subtree even if Demo.GamePlatform was included |
Behavior:
excludewins overinclude- redundant selectors warn
- selecting a model while excluding a required dependency fails
Modeling Checklist
Before handing a schema to an emitter, it helps to check these rules:
- every emitted or referenced ORM-managed declaration has a namespace
- shared persisted fragments use
@tableMixin - ownership is explicit on foreign-key relations
- many-to-many shorthand is used only for simple join tables
- selector filters still include every required dependency
- names are stable enough for namespace-derived package paths
Diagnostics
Important diagnostics surfaced by the core include:
namespace-requiredduplicate-table-nameduplicate-column-namemixin-cyclemixin-field-conflictfiltered-dependencyunsupported-relation-shapeforeign-key-local-missingforeign-key-target-missingforeign-key-type-mismatchone-to-one-missing-uniquemany-to-many-not-arraymany-to-many-target-not-tablemany-to-many-missing-inversemany-to-many-conflicting-tablemany-to-many-conflicting-explicit-tableduplicate-constraint-name
Troubleshooting Common Diagnostics
namespace-required
The model, mixin, or required dependency lives in the global namespace. Move it under a namespace and recompile.
filtered-dependency
Your emitter selection rules included a model but excluded one of its dependencies. Either widen the include set or stop excluding the dependency.
mixin-cycle
Two or more mixins inherit from each other in a loop. Break the cycle by extracting the shared fields into a separate base mixin.
mixin-field-conflict
Two mixins, or a mixin plus a child model, define the same field name. Phase 1+ intentionally treats this as an error instead of allowing silent override behavior.
foreign-key-type-mismatch
The local field type does not line up with the referenced target field type. Make both sides use compatible scalars.
many-to-many-missing-inverse
Both sides of a shorthand many-to-many relation must opt in. Add the inverse relation with the same join-table name.
Guidance For Emitter Authors
If you are adding or maintaining an emitter in this repo:
- consume the normalized ORM graph instead of walking raw compiler state ad hoc
- treat namespace-derived output paths as canonical
- reuse shared selector behavior
- rely on shared relation resolution where possible
- do not silently downgrade unsupported persistence mappings
Limitations And Boundaries
- root-level emitted models are unsupported
- namespace-less dependencies are errors
- many-to-many shorthand does not support payload columns
- mixin field collisions are errors rather than override points
Made with heart by @qninhdt, with GPT-5.4 and Claude Opus 4.6.
