Damat

16. Module capabilities

This is the full capability surface of a single Damat module — everything one module can do "to the fullest", for an author (human or AI) building one. A module is a self-contained vertical slice of a backend: its own models, migrations, service, config, optional workflows, and optional HTTP routes, packaged behind a module.json manifest so it can be developed in isolation and installed into any app.

A module imports each symbol from its real package, so the code fits unchanged when an app installs it. @damatjs/module itself carries only the contract/config/runtime/tooling.

import { defineModule, ModuleService } from "@damatjs/services";
import { getModule } from "@damatjs/framework";
import { model, columns } from "@damatjs/orm-model";
import {
  createStep, createWorkflow, executeStep, RetryPolicies, Effect,
} from "@damatjs/workflow-engine";
import type { RouteHandler, RouteValidator } from "@damatjs/framework/router";
import { z } from "@damatjs/deps/zod";

Scope of this chapter. Everything here lives inside one module. What a module deliberately cannot do — import other modules, form model relations across modules, or activate a connection itself (it may only ship a dormant link file) — is covered in "Out of scope for a module" and in Composing & linking modules.

Standard layout

my-module/
├── package.json          # depends on @damatjs/module only
├── module.config.ts      # optional standalone-app overrides
└── src/
    ├── module.json       # the manifest (the portability contract)
    ├── index.ts          # default-exports defineModule(...)
    ├── models/           # ORM model definitions
    ├── migrations/       # SQL migrations the module owns
    ├── workflows/        # workflow definitions (optional)
    ├── types/            # generated row types + zod schemas
    └── api/routes/       # file-based HTTP routes (optional)

The manifest's paths can override any of these; omit it for the standard layout above.


16.1 Models — the orm-model DSL

Models are defined with model(tableName, properties, options?) from @damatjs/orm-model. A model is pure metadata — a ModelDefinition that serializes to a TableSchema; it executes no SQL. Every model self-registers in a process-global table-name registry on construction (this is what makes string relation targets resolvable).

import { model, columns } from "@damatjs/orm-model";

export const UserModel = model(
  "users",
  {
    id: columns.id({ prefix: "usr" }).primaryKey(),
    email: columns.text().unique(),
    emailVerified: columns.boolean().default(false),
    name: columns.text().nullable(),
    accounts: columns.hasMany("accounts"),     // intra-module inverse
  },
  { schema: "public", name: "User" },          // options (both optional)
).indexes([columns.indexes().columns(["email"]).unique()]);

model() options: schema (PG schema for the table) and name (logical name; defaults to the table name). Property values may be a ColumnBuilder, a belongsTo, hasMany, or hasOne — nothing else.

Column groups and builders

Every method on the columns factory returns a fresh builder. The full set:

BuilderFactorySQL type / behavior
Idcolumns.id({ prefix })text PK, not null by default; with a prefix emits a generate_id('<prefix>') DB default
Booleancolumns.boolean()boolean
Integercolumns.integer()integer; chain .bigInt() / .smallInt() / .serial() / .bigSerial() / .smallSerial()
Numericcolumns.numeric(p?, s?)decimal; p→length, s→scale; also .precision(p) / .scale(s)
Realcolumns.real()real
Double precisioncolumns.doublePrecision()double precision
Moneycolumns.money()money (driver returns it as a string)
Textcolumns.text()text
Varcharcolumns.varchar(n?)character varying; passing n applies .length(n)
Charcolumns.char(n?)character; passing n applies .length(n)
Timestampcolumns.timestamp({ withTimezone? })timestamp; .withTimezone() / .withoutTimezone() / .defaultNow()now()
Datecolumns.date()date; .defaultNow()CURRENT_DATE
Timecolumns.time()time; tz toggle
Intervalcolumns.interval()interval
JSONcolumns.json({ binary? })json; .binary() switches to jsonb
JSONBcolumns.jsonb()jsonb
UUIDcolumns.uuid()uuid; .defaultGenerate()gen_random_uuid()
Byteacolumns.bytea()bytea (driver returns a Buffer)
Enumcolumns.enum(EnumBuilder)references a named PG enum (see below)
Vectorcolumns.vector(dims)real[] with fixed dimensions for embeddings; .dimensions(d) resizes

There is no first-class geometric / network / range column factory even though the underlying ColumnType supports those SQL types — most apps never need them.

Column modifiers

Every column builder shares the base modifier set:

ModifierEffect
.primaryKey()mark column as primary key
.unique()UNIQUE column
.nullable()allow NULL (columns are NOT NULL by default)
.default(value)literal default; strings are auto single-quoted, numbers/booleans bare
.defaultRaw(expr)raw SQL default, no quoting (e.g. now(), gen_random_uuid())
.array()wrap as an array column
.fieldName(name)DB column name when it differs from the property name

Type-specific helpers extend these: .defaultNow() (timestamp/date), .defaultGenerate() (uuid), .length(n) (varchar/char), .precision()/.scale() (numeric), the integer width/serial chain, and .dimensions(d) (vector). Use .defaultRaw() (or the typed .defaultNow() / .defaultGenerate()) for SQL expression defaults — .default() would single-quote a string and produce 'now()'.

Enums

Declare a named PG enum once with EnumBuilder, then reference it by name from columns. Register the enum at module level so its CREATE TYPE is described:

import { EnumBuilder } from "@damatjs/orm-model";

const OrderStatus = new EnumBuilder(["pending", "shipped", "delivered"]).name("order_status");

const Order = model("orders", {
  id: columns.id({ prefix: "ord" }).primaryKey(),
  status: columns.enum(OrderStatus),
});

columns.enum(...) keeps only the enum's name on the column (like a real PG column pointing at a CREATE TYPE); the enum values live on the module-level EnumSchema. If you forget to register the enum at module level, the type name still appears on the column but no CREATE TYPE is emitted.

Auto columns: .timestamps() and .softDelete()

A ModelDefinition enables both by default (_timestamps = true, _softDelete = true). They are fluent toggles:

model("orders", { ... })
  .timestamps(false)            // skip created_at / updated_at
  .softDelete(false)            // skip deleted_at
  .softDelete(true, "removed_at"); // custom soft-delete column name

When emitting the table schema:

  • Timestamps append created_at (date, NOT NULL, default now()) and updated_at (date, nullable) — each only if a column of that name isn't already defined (the check tolerates camelCase createdAt / updatedAt).
  • Soft delete appends the deleted-at column (default deleted_at, date, nullable) if absent.

If you define your own richer createdAt/updatedAt, your definition wins and the auto column is skipped. The auto columns are typed date, not timestamp with time zone.

Indexes and constraints

Attach with .indexes([...]) and .constrain([...]). Both replace the model's array on each call (they do not append). Builders come from columns.indexes(name?) / columns.constrains(name?):

model("products", { ... })
  .indexes([
    columns.indexes("idx_products_total").columns(["total"]).type("btree"),
    columns.indexes().columns(["status", "createdAt"]),   // auto-named
    columns.indexes().columns(["sku"]).unique().where("deleted_at IS NULL"),
  ])
  .constrain([
    columns.constrains("products_total_pos").check("total > 0"),
    columns.constrains().columns(["sku"]).unique(),         // auto-named
    columns.constrains().primaryKey().columns(["a", "b"]),  // composite PK
  ]);

IndexBuilder: .columns(), .unique(), .type(indexType) (default btree), .where(condition) (partial index), .concurrently(). ConstraintBuilder picks its kind by method — .unique(), .primaryKey(), .check(condition), .exclude(expressions) (with .indexType(), default gist) — plus .columns(), .where(), and .deferrable(initiallyDeferred?). A constraint with no declared kind throws at schema time; primaryKey() appends a _pkey suffix to the resolved name, so pass the base name.

Relations — intra-module only, by table name

A relation has two sides:

  • Owning side — belongsTo(target). The only relation that creates DB artifacts: it adds the FK column(s) and a foreign-key constraint to this table.
  • Inverse side — hasMany(target) (1:N) / hasOne(target) (1:1). Pure ORM metadata; no column, no FK.
const Category = model("categories", {
  id: columns.id({ prefix: "cat" }).primaryKey(),
  products: columns.hasMany("products").mappedBy("category"),  // inverse, no column
});

const Product = model("products", {
  id: columns.id({ prefix: "prd" }).primaryKey(),
  // owning side — creates the `category_id` FK column on `products`
  category: columns.belongsTo("categories").onDelete("CASCADE").indexed(),
});

Targets are table-name strings within the same module. The FK column defaults to <targetTable>_id referencing id; no import of the target model is needed, and circular references between tables are a non-issue. (A direct model reference or a () => Model thunk also work and resolve identically, but the string form is the normal one.)

belongsTo fluent API: .link({ name?, foreignKey?, reference? }), .nullable() (also defaults ON DELETE SET NULL), .unique() (makes it 1:1), .indexed() (btree index per FK column), .onDelete(action), .onUpdate(action), .match(type), .deferrable(initially?), .constraint({ ... }). hasMany / hasOne expose .mappedBy(prop).

Validate wiring early — broken relations surface as a single readable error with copy-pasteable fixes:

import { assertValidRelations } from "@damatjs/orm-model";
assertValidRelations([Category, Product]);  // throws RelationValidationError on a bad graph

Relations stay within one module. A module models its own tables and the relationships among them. It does not reference another module's tables in a model relation — a cross-module connection is a separate link, activated by the backend owner (ch. 17). @damatjs/module exposes no defineLink; a module that ships a dormant link imports it from @damatjs/framework.

Generated types

ModelDefinition.toTsType() emits the base row interface (columns + belongsTo FK columns; inverse relations omitted). The richer per-table files — NewX insert types, UpdateX patch types, and zod schemas — are produced by the module codegen step (see 16.4). Type mapping follows what the pg driver actually returns at runtime: bigintbigint, moneystring, byteaBuffer, timestamp/dateDate, json/jsonbunknown, etc.

Full detail: column builders, relations, indexes & constraints, schema & model, type inference.


16.2 Service — auto-CRUD, domain methods, transactions, credentials

ModuleService({ models, credentialsSchema? }) from @damatjs/services is a class factory. Given a map of model key → ModelDefinition, it returns an abstract base class with one camelCased accessor per model, plus transaction(), em, and getModels. You subclass it.

import { ModuleService } from "@damatjs/services";
import { z } from "@damatjs/deps/zod";
import { UserModel, AccountModel } from "./models";

const models = { user: UserModel, account: AccountModel };

export class UserModuleService extends ModuleService({
  models,
  credentialsSchema: z.object({ apiKey: z.string() }),
}) {
  // domain methods on top of the generated accessors:
  async createWithAccount(email: string) {
    return this.transaction(async () => {
      const user = await this.user.create({ data: { email } });
      await this.account.create({ data: { user_id: user.id } });
      return user;
    });
  }
}

Accessor names are camelCased from the model key (accountservice.account, Verificationservice.verification). Note: toCamelCase only lowercases the first character — it does not fully convert snake_case/kebab-case. Choose model keys that read as JS identifiers (account, apiKey).

Generated CRUD per model (ModelMethods)

Each accessor exposes the full surface:

MethodSignature (abridged)Behavior
create({ data, returning? }) => Promise<T>insert one row
createMany({ data: T[], returning? }) => Promise<T[]>insert many
upsert({ data, onConflict, updateColumns?, set?, returning? }) => Promise<T>insert, or update on conflict
upsertMany({ data: T[], onConflict, … }) => Promise<T[]>bulk insert-or-update
find(opts?) => Promise<T | null>one row (select, where, orderBy, include)
findById(id, opts?) => Promise<T | null>one row by primary key
findOne(where, opts?) => Promise<T | null>one row by filter
findMany(opts?) => Promise<T[]>many rows (+ skip, take, include)
update({ where, data, returning? }) => Promise<T[]>update matching rows
updateOne({ where, data, returning? }) => Promise<T | null>update and return the single affected row
delete({ where, returning?, cascade? }) => Promise<number>hard delete; cascade: true removes related rows in a transaction
softDelete({ where, returning?, cascade? }) => Promise<T[]>set the model's deleted-at column to now; cascade: true recurses
restore({ where, returning? }) => Promise<T[]>clear the deleted-at column (set to null)
count({ where? }) => Promise<number>row count
exists({ where }) => Promise<boolean>existence check
getModelDefinition() => ModelDefinitionintrospection

With cascade: true, delete / softDelete walk the model's hasMany / hasOne relations depth-first inside one transaction, honouring each relation's rule.onDelete (CASCADE recurses, SET NULL nulls the child FK, RESTRICT / NO ACTION throw when children exist), with a visited-set guarding cycles.

FindOptions carries select, where, orderBy ([{ column, direction }]), skip, take, and include (relation names to eager-load). Relation loading matches include names against the model's relation metadata:

  • belongsTo — reads the FK column off the record and fetches the related row by id.
  • hasMany / hasOne — fetches related rows by the conventional <name>_id link (collection for hasMany, single row for hasOne).

Writes are validated. create / createMany / update build (and cache) a zod schema from the model's columns and .parse the payload — they throw ZodError on type-mismatched data. Auto-generated, defaulted, and nullable columns are optional; updates validate in partial mode. Columns with no single JS representation (json/jsonb, bytea, ranges, …) map to z.any() and are effectively unchecked.

Transactions

this.transaction(cb, options?) runs cb inside one DB transaction. It rebinds every model's methods to the transactional entity manager for the duration of the callback, so all CRUD inside is atomic. A nested transaction() joins the outer one rather than opening a new transaction. Always wrap multi-row invariants (create user + account, decrement stock + write ledger) in a transaction.

Adding domain methods

Subclass and add methods freely; use the generated accessors, this.transaction, and — for raw work — this.em (the PgEntityManager) or service.<model>.getModelDefinition() for introspection. Adding a model to the models map makes a new accessor and registration appear automatically.

Credentials schema and the config loader

credentialsSchema is an optional zod object validated in the service constructor. The module loads its credentials from process.env with a loader function; defineModule wires the two together. A common pattern keeps the schema and loader in config/:

// config/schema.ts
export const schema = z.object({
  apiKey: z.string(),
  baseUrl: z.string().url().optional(),
});

// config/load.ts
export const load = (env: NodeJS.ProcessEnv) => ({
  apiKey: env.MY_API_KEY ?? "",
  baseUrl: env.MY_BASE_URL,
});

Inside the service, read validated credentials via this.credentials.

Detail: ModuleService & CRUD, defineModule.


16.3 defineModule — the module entry point

index.ts default-exports defineModule(name, { service, credentials }). This is the object the harness, runtime, and framework consume.

import { defineModule } from "@damatjs/module";
import { UserModuleService } from "./service";
import { load } from "./config/load";

export default defineModule("user", {
  service: UserModuleService,
  credentials: load,           // (env) => creds
});
  • Credentials are parsed eagerly at defineModule time (so a validating loader throws at import time, not first use — keep it pure).
  • The service is constructed lazily on first property access (or via init()), because the service constructor requires the pool to be initialized first.
  • init() always rebuilds the instance against the current pool — that is how the harness/framework rebind after pool setup.

16.4 Migrations and codegen (module-local)

A module owns its schema. Migrations live in the module's migrations/ dir and are diffed from the models, not hand-written. Codegen produces row types and zod schemas into types/, refreshes the typed registry.ts, and scaffolds-once a per-table CRUD slice (steps, workflows, and api/routes). Both run on the standalone package, no damat.config.ts required:

damat module migration:create   # diff models -> a new SQL migration (only if changed)
damat module codegen             # row types + zod + registry, and scaffold-once CRUD

Under the hood:

  • createModuleMigration(packageDir) locates the module dir, reads the manifest, and diffs the models against the last schema snapshot. It returns { hasChanges, filePath? }; nothing is written when models already match.
  • generateModuleTypes(packageDir, logger) builds the module schema, generates a file map (row types + New*/Update* + zod), and writes it into the manifest's paths.types dir (default ./types), plus the typed registry.ts. Re-running overwrites the generated types/ — treat it as generated output, never hand-edit it. It then scaffolds-once a per-table CRUD slice (steps, workflows, and api/routes); existing files are left untouched (writeOnce), so your edits to the scaffold survive re-runs.

Neither touches the database; they diff against the snapshot and read model files. Applying migrations is the harness/runtime's job (below) or bun damat-orm migrate:up once the module is installed into an app.

Detail: module tooling.


16.5 Workflows inside a module

A module may include workflows when a process spans multiple side-effecting steps that must succeed as a unit or roll back cleanly (saga / compensating transactions), needs per-step retries with backoff and per-attempt timeouts, or needs mutual exclusion across instances. They build on @damatjs/workflow-engine, re-exported from the authoring surface. The engine is in-process (no persisted journal, no resume-after-crash) — skip workflows when a single function with try/catch covers the need.

import { createStep, createWorkflow, executeStep, StepResponse, RetryPolicies, Effect } from "@damatjs/module";

const createOrder = createStep<{ items: string[] }, Order, string>(
  "create-order",
  async (input, ctx, signal) => {
    const order = await orderService.create(input, { signal });
    return new StepResponse(order, order.id);   // output downstream; id is the rollback payload
  },
  async (orderId, ctx) => { await orderService.cancel(orderId); },  // compensation (undo)
  { retry: RetryPolicies.standard, timeoutMs: 10_000 },
);

const placeOrder = createWorkflow(
  "place-order",
  (input: { items: string[] }, ctx) =>
    Effect.gen(function* () {
      const order = yield* executeStep(createOrder, input, ctx);
      const payment = yield* executeStep(chargeCard, { orderId: order.id }, ctx);
      return { order, payment };
    }),
  { timeoutMs: 60_000 },
);

const result = await placeOrder.execute({ items: ["sku-1"] });
if (result.success) { /* result.result, result.durationMs */ }
else { /* result.error.code, result.compensated */ }

Steps

createStep<I, O, C>(name, invoke, compensate?, config?). invoke is (input, ctx, signal?) => Promise<StepResponse<O, C>> — return new StepResponse(output, compensateInput?), and forward signal into fetch/db calls so timeouts actually cancel work. compensate is (compensateInput, ctx) => Promise<void> — it gets only the payload from the StepResponse (or undefined), not the input or output — registered after the step succeeds and run (in reverse order) only if a later step fails. When C excludes undefined, supplying the payload is required at compile time. StepConfig: timeoutMs (per-attempt, default 30s), retry (a RetryPolicy), idempotent (default true; false disables retries for the step), description.

Retry

maxAttempts counts retries on top of the first try (so maxAttempts: 2 runs 3 times). Default is no retries. Presets: RetryPolicies.none / once / standard / aggressive / patient, or a custom policy with initialDelayMs, maxDelayMs, backoffMultiplier, and an isRetryable(error) predicate (receives the original error). By default everything retries except errors named ValidationError. A step marked idempotent: false is never retried — its first failure goes straight to the failure/compensation path.

Workflows, results, control flow

createWorkflow(name, definition, config?) returns { name, config, execute, executeWithLock }. WorkflowConfig: timeoutMs (whole-workflow, default 5 min) and defaultStepConfig (layered under each step). execute / executeWithLock never reject for ordinary failures — they resolve to a WorkflowResult discriminated union; branch on result.success. A failure carries error (always a WorkflowError), compensated, and compensationsFailed. Errors carry a programmatic code: WORKFLOW_TIMEOUT, WORKFLOW_FAILED, STEP_EXECUTION_FAILED, STEP_TIMEOUT, MAX_RETRIES_EXCEEDED, WORKFLOW_LOCKED.

Control-flow helpers compose inside the generator: runStep (alias of executeStep), skipStep(value), parallel(...effects) (unbounded concurrency), when(cond, step, input, ctx, default), ifElse(cond, ifTrue, ifFalse, input, ctx).

Locking

executeWithLock(input, lockConfig?, metadata?) acquires a Redis-backed distributed lock so two runs sharing a lockId cannot run concurrently across processes. WorkflowLockConfig: lockId, ttlMs (default 5 min), maxRetries, retryDelayMs, autoExtend (heartbeat-extends the TTL while running; default on). On contention it returns a WorkflowFailure with code WORKFLOW_LOCKED (it does not throw). Locking requires Redis to be initialized (the framework does this when redisUrl is configured; the module runtime does it when REDIS_URL is set).

Keep workflows inside the module (workflows/) so the module stays self-contained.

Detail: steps, workflows, retry, locking, control flow, errors.


16.6 HTTP routes inside a module

A module may serve its own HTTP routes from api/routes/ using the framework's file-based router. The folder structure maps to URL paths; each route lives in a route.ts:

src/api/routes/
  users/route.ts             ->  /users          (mounted at /api/users)
  users/[userId]/route.ts    ->  /users/:userId  (mounted at /api/users/:userId)
  auth/[...auth]/route.ts    ->  /auth/*          (mounted at /api/auth/*)

Dynamic segments use [userId]:userId; catch-all uses [...rest] → Hono *. A route.ts may export any of GET, POST, PUT, PATCH, DELETE (each a RouteHandler), plus optional middleware, validators, config, and configs.

import type { RouteHandler } from "@damatjs/module";
// (defineRoute / response live in @damatjs/framework/router)

export const GET: RouteHandler = async (c) => {
  return c.json({ success: true, data: { users: [] } });
};

export const POST: RouteHandler = async (c) => {
  const body = await c.req.json();
  return c.json({ success: true, data: { id: "1", ...body } }, 201);
};

RouteHandler is (c) => Promise<Response> | Response. defineRoute<P>(handler) wraps a handler to receive typed route params as a second argument. Per-route you can attach middleware (applied to all methods), validators (per-method zod for body/query/params/json), and config/configs for per-method rateLimit and auth (session / apiKey / flexible / none). Only the five verbs above are wired; OPTIONS/HEAD are handled at the middleware layer. If api/routes is absent the module still boots — it just serves no routes.

In handlers, reach the module's service through the framework registry (getModule(...)) once installed in an app, or directly under the standalone runtime.

Detail: router.


16.7 The standalone harness — dev & tests

A module is developed and tested on its own, before any app exists. Two modes, both from @damatjs/module.

withModule / bootModule — service-level (no HTTP)

bootModule(module, options?) wires the same infrastructure the framework uses (ConnectionManager + PoolManager), applies the module's own migrations, calls module.init(), and hands back the live service. withModule(module, options, fn) boots, runs fn(booted), and always tears down — the convenience wrapper for tests:

import { withModule } from "@damatjs/module";
import userModule from "./index";

await withModule(userModule, { moduleDir: import.meta.dir }, async ({ service }) => {
  const user = await service.user.create({ data: { email: "a@b.co" } });
  expect(await service.user.exists({ where: { email: "a@b.co" } })).toBe(true);
});

BootModuleOptions: databaseUrl (default process.env.DATABASE_URL), database (full pool config, takes precedence), moduleDir (abs path to the dir with module.json + migrations — required to apply migrations), migrate (default true when moduleDir is set), logger. BootedModule exposes service, pool, connection, manifest, and teardown() — always call teardown (or use withModule). The harness requires a real Postgres; gate tests with describe.skipIf(!process.env.DATABASE_URL). It owns the process (one module at a time), so boot/teardown sequentially.

startModuleApp — run the module as a live HTTP app

startModuleApp(options?) runs one module package as a full HTTP app — the framework stack (middleware, file-based routes from api/routes, health checks) with just this module registered. This is what damat module dev boots, and what in-process API tests start with port: 0:

import { startModuleApp } from "@damatjs/module";

const app = await startModuleApp({ port: 0 });  // ephemeral port for tests
// app.app (Hono), app.server, app.port, app.manifest
await app.stop();                                // stop server + run shutdown handlers

It reads module.json (from src/ or the package root) and the author's module.config.ts, builds a full AppConfig, initializes services, applies migrations when DATABASE_URL is set, and serves over api/routes. Port precedence: options.portPORT env → module.config.ts http.port7654 default.

Detail: harness, runtime.


16.8 Packaging — module.json

The manifest is the portability contract damat module add reads to copy the module in, register it, sync env vars, and install npm packages. It sits next to index.ts.

{
  "name": "user",                 // required: module id, registry key, default dir name (kebab-case)
  "version": "0.2.0",             // semver (required to publish)
  "description": "Auth, sessions and accounts.",
  "author": "Abel <a@b.co>",      // string OR { name, email?, url? } — declared, not the verified owner

  "env": [                        // env vars the credentials loader reads; drives .env.example sync
    { "name": "BETTER_AUTH_SECRET", "required": true,
      "description": "Min 32-char secret", "example": "change-me-min-32-characters-long" }
  ],
  "packages": { "better-auth": "^1.4.18" },   // npm deps the host app installs (name -> semver range)

  "pairsWith": ["organization"],  // NON-BINDING hint: modules this pairs well with (never enforced)
  // "modules": ["organization"], // RARE: a hard dependency — prefer pairsWith

  "paths": {                      // layout overrides (omit for the standard layout)
    "entry": "./index.ts", "models": "./models", "migrations": "./migrations",
    "workflows": "./workflows", "types": "./types"
  },
  "registry": {                   // publishing metadata (optional today)
    "namespace": "damatjs", "keywords": ["auth", "users"],
    "license": "MIT", "repository": "…", "homepage": "…"
  }
}
FieldTypeRequiredNotes
namestringModule id; registry key, default install dir, and migration namespace; kebab-case (/^[a-z][a-z0-9-]*$/)
versionstringSemver; required to publish
descriptionstringShown on install and in the registry
authorstring | object"Name <email> (url)" or { name, email?, url? }; declared provenance, not the verified owner
envModuleEnvVar[]Each { name, required?, description?, example? }; drives .env.example
packagesRecord<string,string>npm deps → semver range, installed into the host app on add
pairsWithstring[]Non-binding hint — a comment to the backend owner; never enforced or installed. Prefer this to express relationships
modulesstring[]Rare. Hard dependency on other modules (install only warns if missing). Reach for pairsWith instead
pathsobjectOverrides for entry/models/migrations/workflows/types
registryobjectnamespace, keywords, license, repository, homepage

Validation is hand-rolled (CLI-friendly errors) and permissive about unknown keys. damat module validate runs both tiers: errors block installing (missing entry, broken manifest, declared-but-missing dirs); warnings block publishing (missing version/description/author/license/namespace; models without migrations). Author your module warning-free so it's registry-ready before the hosted registry ships.

Full field reference: MODULES.md, manifest internals.


Out of scope for a module

A module is a single-purpose unit; it does not decide what it is plugged into. The following are deliberately not a module's job — they belong to the backend owner (see Composing & linking modules):

  • No model-level cross-module relations. A module never forms a real, validated model relationship to another module's tables. Relations are intra-module only, by table name. A module may ship a dormant defineLink file under links/ (imported from @damatjs/framework) for the owner to activate by migrating — but that creates nothing on its own and @damatjs/module itself omits link helpers.
  • No importing other modules. A module stays self-contained; it does not import or reach into a sibling module's models, service, or internals — not even the one a shipped link names (the link is just string references, resolved by the app).
  • No declaring what's needed or plugged in. A module does not wire itself into an app, mount its own routes globally, or assert what surrounds it. Installation and composition (damat.config.ts, links, route mounting) are the app's work.

The one thing a module may do to suggest a relationship is leave a pairsWith hint in its manifest (or describe it in description). That is a comment for the backend owner — never enforced, never auto-installed. Use it instead of a hard modules dependency wherever possible.