| name | module-scaffold |
| description | Scaffold a new module from scratch with all required files and conventions. Use when creating a new module, adding a new entity with CRUD, or bootstrapping module features (API routes, backend pages, DI, ACL, events, search). Triggers on "create module", "new module", "scaffold module", "add module", "bootstrap module", "generate module". |
Module Scaffold
Create a new module with all required files following Open Mercato conventions. This skill generates the full module structure, wires it into the app, and runs required generators.
Table of Contents
- Gather Requirements
- Scaffold Structure
- Create Entity
- Create Validators
- Create API Routes
- Create Backend Pages
- Add Module Metadata
- Add ACL & Setup
- Add DI Registration
- Add Events
- Optional Features
- Wire & Verify
1. Gather Requirements
Before writing any code, ask the developer:
- Module name — plural, snake_case (e.g.,
tickets, fleet_vehicles, loyalty_points)
- Primary entity name — singular (e.g.,
ticket, fleet_vehicle, loyalty_point)
- Key fields — beyond standard columns, what data does this entity store?
- Relationships — does it reference entities from other modules? (FK IDs only, no ORM relations)
- Features needed:
If the developer provides a brief description, infer reasonable defaults and confirm. When key fields include names, emails, phones, addresses, free-text comments, or external API keys, treat the encryption checkbox as yes by default and confirm with the user rather than skipping it silently.
2. Scaffold Structure
Create the directory tree under src/modules/<module_id>/:
src/modules/<module_id>/
├── index.ts # Module metadata + feature exports
├── acl.ts # Feature-based permissions
├── setup.ts # Tenant init, role features
├── di.ts # Awilix DI registrations
├── events.ts # Typed event declarations (if needed)
├── encryption.ts # Tenant data encryption maps (only if entity has sensitive/GDPR fields)
├── data/
│ ├── entities.ts # MikroORM entity classes
│ └── validators.ts # Zod validation schemas
├── api/
│ ├── get/
│ │ └── <entities>.ts # GET /api/<module>/<entities> (list + detail)
│ ├── post/
│ │ └── <entities>.ts # POST /api/<module>/<entities>
│ ├── put/
│ │ └── <entities>.ts # PUT /api/<module>/<entities>
│ └── delete/
│ └── <entities>.ts # DELETE /api/<module>/<entities>
└── backend/
├── page.tsx # List page → /backend/<module>
├── <entities>/
│ ├── new.tsx # Create page → /backend/<module>/<entities>/new
│ └── [id].tsx # Edit page → /backend/<module>/<entities>/<id>
3. Create Entity
File: src/modules/<module_id>/data/entities.ts
Template
import { Entity, Index, PrimaryKey, Property } from '@mikro-orm/decorators/legacy'
import { v4 } from 'uuid'
@Entity({ tableName: '<entities>' })
export class <Entity> {
@PrimaryKey({ type: 'uuid' })
id: string = v4()
@Index()
@Property({ type: 'uuid' })
organization_id!: string
@Index()
@Property({ type: 'uuid' })
tenant_id!: string
@Property({ type: 'varchar', length: 255 })
name!: string
@Property({ type: 'boolean', default: true })
is_active: boolean = true
@Property({ type: 'timestamptz' })
created_at: Date = new Date()
@Property({ type: 'timestamptz', onUpdate: () => new Date() })
updated_at: Date = new Date()
@Property({ type: 'timestamptz', nullable: true })
deleted_at: Date | null = null
}
Entity Rules
- Table name: plural, snake_case — matches module ID
- PK: always
uuid with v4() default
- MUST include
organization_id + tenant_id with @Index()
- MUST include
created_at, updated_at, deleted_at, is_active
- Entity decorators MUST come from
@mikro-orm/decorators/legacy
- Cross-module references: store FK as
uuid field (e.g., customer_id) — never use ORM @ManyToOne
- Use
@Property({ type: 'jsonb' }) for flexible/nested data
- Use
@Property({ type: 'varchar', length: N }) for bounded strings
- Use
@Property({ type: 'text' }) for unbounded text
4. Create Validators
File: src/modules/<module_id>/data/validators.ts
Template
import { z } from 'zod'
export const create<Entity>Schema = z.object({
name: z.string().min(1).max(255),
})
export const update<Entity>Schema = create<Entity>Schema.partial().extend({
id: z.string().uuid(),
})
export type Create<Entity>Input = z.infer<typeof create<Entity>Schema>
export type Update<Entity>Input = z.infer<typeof update<Entity>Schema>
Rules
- Derive TypeScript types from zod via
z.infer<typeof schema> — never duplicate
- Create schema has all required fields; update schema is
.partial() with required id
- Never include
organization_id, tenant_id, created_at, updated_at — these are system-managed
5. Create API Routes
Use makeCrudRoute for standard CRUD. Each HTTP method lives in its own file.
GET Route
File: src/modules/<module_id>/api/get/<entities>.ts
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
import { <Entity> } from '../../data/entities'
const handler = makeCrudRoute({
entity: <Entity>,
entityId: '<module_id>.<entity>',
operations: ['list', 'detail'],
indexer: { entityType: '<module_id>.<entity>' },
})
export default handler
export const openApi = {
summary: 'List and retrieve <entities>',
tags: ['<Module Name>'],
}
POST Route
File: src/modules/<module_id>/api/post/<entities>.ts
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
import { <Entity> } from '../../data/entities'
import { create<Entity>Schema } from '../../data/validators'
const handler = makeCrudRoute({
entity: <Entity>,
entityId: '<module_id>.<entity>',
operations: ['create'],
schema: create<Entity>Schema,
})
export default handler
export const openApi = {
summary: 'Create a <entity>',
tags: ['<Module Name>'],
}
PUT Route
File: src/modules/<module_id>/api/put/<entities>.ts
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
import { <Entity> } from '../../data/entities'
import { update<Entity>Schema } from '../../data/validators'
const handler = makeCrudRoute({
entity: <Entity>,
entityId: '<module_id>.<entity>',
operations: ['update'],
schema: update<Entity>Schema,
})
export default handler
export const openApi = {
summary: 'Update a <entity>',
tags: ['<Module Name>'],
}
DELETE Route
File: src/modules/<module_id>/api/delete/<entities>.ts
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
import { <Entity> } from '../../data/entities'
const handler = makeCrudRoute({
entity: <Entity>,
entityId: '<module_id>.<entity>',
operations: ['delete'],
})
export default handler
export const openApi = {
summary: 'Delete a <entity>',
tags: ['<Module Name>'],
}
Rules
- Every API route MUST export
openApi for documentation generation
- Use
makeCrudRoute with indexer: { entityType } for query engine coverage
- Schema validation is automatic when
schema is provided
- Auth guards are applied automatically by the framework
6. Create Backend Pages
Use CrudForm and DataTable from @open-mercato/ui. See the backend-ui-design skill for full component reference.
Page Metadata & Sidebar Navigation
File: src/modules/<module_id>/backend/page.meta.ts
Icons MUST use components from lucide-react. Never use inline React.createElement('svg', ...) — it breaks after yarn generate.
For full field reference, settings pages, and anti-patterns, see references/navigation-patterns.md.
import { Trophy } from 'lucide-react'
export const metadata = {
requireAuth: true,
requireFeatures: ['<module_id>.view'],
pageTitle: '<Module Name>',
pageTitleKey: '<module_id>.nav.title',
pageGroup: '<Module Name>',
pageGroupKey: '<module_id>.nav.group',
pageOrder: 100,
icon: <Trophy className="size-4" />,
breadcrumb: [{ label: '<Module Name>', labelKey: '<module_id>.nav.title' }],
}
List Page
File: src/modules/<module_id>/backend/page.tsx
'use client'
import { DataTable } from '@open-mercato/ui/backend/DataTable'
import { useT } from '@open-mercato/shared/lib/i18n/context'
export default function <Module>ListPage() {
const t = useT()
return (
<DataTable
entityId="<module_id>.<entity>"
apiPath="<module_id>/<entities>"
title={t('<module_id>.list.title')}
createHref="/backend/<module_id>/<entities>/new"
columns={[
{ id: 'name', header: t('<module_id>.fields.name'), accessorKey: 'name' },
// Add more columns
]}
/>
)
}
export const metadata = {
requireAuth: true,
requireFeatures: ['<module_id>.view'],
pageTitle: '<Module Name>',
pageTitleKey: '<module_id>.nav.title',
pageGroup: '<Module Name>',
pageGroupKey: '<module_id>.nav.group',
pageOrder: 100,
}
Create Page
File: src/modules/<module_id>/backend/<entities>/new.tsx
'use client'
import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
import { useT } from '@open-mercato/shared/lib/i18n/context'
export default function Create<Entity>Page() {
const t = useT()
return (
<CrudForm
entityId="<module_id>.<entity>"
apiPath="<module_id>/<entities>"
mode="create"
title={t('<module_id>.create.title')}
fields={[
{ id: 'name', label: t('<module_id>.fields.name'), type: 'text', required: true },
// Add more fields
]}
backHref="/backend/<module_id>"
/>
)
}
export const metadata = {
requireAuth: true,
requireFeatures: ['<module_id>.create'],
pageTitle: 'Create <Entity>',
pageTitleKey: '<module_id>.create.title',
pageGroup: '<Module Name>',
pageGroupKey: '<module_id>.nav.group',
navHidden: true,
}
Edit Page
File: src/modules/<module_id>/backend/<entities>/[id].tsx
'use client'
import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
import { useT } from '@open-mercato/shared/lib/i18n/context'
export default function Edit<Entity>Page({ params }: { params: { id: string } }) {
const t = useT()
return (
<CrudForm
entityId="<module_id>.<entity>"
apiPath="<module_id>/<entities>"
mode="edit"
resourceId={params.id}
title={t('<module_id>.edit.title')}
fields={[
{ id: 'name', label: t('<module_id>.fields.name'), type: 'text', required: true },
// Add more fields
]}
backHref="/backend/<module_id>"
/>
)
}
export const metadata = {
requireAuth: true,
requireFeatures: ['<module_id>.update'],
pageTitle: 'Edit <Entity>',
pageTitleKey: '<module_id>.edit.title',
pageGroup: '<Module Name>',
pageGroupKey: '<module_id>.nav.group',
}
7. Add Module Metadata
File: src/modules/<module_id>/index.ts
import type { ModuleInfo } from '@open-mercato/shared/modules/registry'
export const metadata: ModuleInfo = {
name: '<module_id>',
title: '<Module Name>',
version: '0.1.0',
description: '<What this module does>',
}
export { features } from './acl'
8. Add ACL & Setup
ACL Features
File: src/modules/<module_id>/acl.ts
export const features = [
{ id: '<module_id>.view', title: 'View <entities>', module: '<module_id>' },
{ id: '<module_id>.create', title: 'Create <entities>', module: '<module_id>' },
{ id: '<module_id>.update', title: 'Update <entities>', module: '<module_id>' },
{ id: '<module_id>.delete', title: 'Delete <entities>', module: '<module_id>' },
]
Setup (Tenant Init + Default Roles)
File: src/modules/<module_id>/setup.ts
import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
export const setup: ModuleSetupConfig = {
defaultRoleFeatures: {
superadmin: ['<module_id>.view', '<module_id>.create', '<module_id>.update', '<module_id>.delete'],
admin: ['<module_id>.view', '<module_id>.create', '<module_id>.update', '<module_id>.delete'],
user: ['<module_id>.view'],
},
}
export default setup
Rules
- Feature IDs follow
<module_id>.<action> pattern
- MUST declare
defaultRoleFeatures for every feature in acl.ts
- Feature IDs are FROZEN once deployed — cannot rename without data migration
9. Add DI Registration
File: src/modules/<module_id>/di.ts
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
export function register(container: AppContainer): void {
}
10. Add Events
File: src/modules/<module_id>/events.ts
import { createModuleEvents } from '@open-mercato/shared/modules/events'
export const eventsConfig = createModuleEvents({
'<module_id>.<entity>.created': {
description: '<Entity> was created',
payload: { resourceId: 'string', name: 'string' },
},
'<module_id>.<entity>.updated': {
description: '<Entity> was updated',
payload: { resourceId: 'string' },
},
'<module_id>.<entity>.deleted': {
description: '<Entity> was deleted',
payload: { resourceId: 'string' },
},
} as const)
Event Rules
- Event IDs:
module.entity.action (singular entity, past tense action)
- Use dots as separators
- Payload fields are additive-only (FROZEN contract)
- Add
clientBroadcast: true to bridge events to browser via SSE
11. Optional Features
Search Configuration
File: src/modules/<module_id>/search.ts
import type { SearchModuleConfig } from '@open-mercato/shared/modules/search'
export const searchConfig: SearchModuleConfig = {
entities: {
'<module_id>.<entity>': {
fields: ['name'],
},
},
}
Translations
File: src/modules/<module_id>/translations.ts
export const translatableFields = {
'<entity>': ['name', 'description'],
}
CLI Commands
File: src/modules/<module_id>/cli.ts
export default function registerCli(program: any) {
program
.command('<module_id>:seed')
.description('Seed sample <entities>')
.action(async () => {
})
}
Encryption maps (sensitive / GDPR-relevant fields)
Mandatory when the entity stores PII, contact info, addresses, free-text notes about people, integration credentials, secrets, or anything subject to a data-processing agreement. Do NOT hand-roll AES, KMS calls, or "TODO encrypt later" stubs — the framework provides per-tenant DEKs and a declarative field-level map.
File: src/modules/<module_id>/encryption.ts
import type { ModuleEncryptionMap } from '@open-mercato/shared/modules/encryption'
export const defaultEncryptionMaps: ModuleEncryptionMap[] = [
{
entityId: '<module_id>:<entity>',
fields: [
{ field: 'first_name' },
{ field: 'last_name' },
{ field: 'phone' },
{ field: 'email', hashField: 'email_hash' },
],
},
]
export default defaultEncryptionMaps
Read paths — never em.find an encrypted column directly:
import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
const records = await findWithDecryption(em, '<Entity>', filter, undefined, { tenantId, organizationId })
const single = await findOneWithDecryption(em, '<Entity>', { id }, undefined, { tenantId, organizationId })
Apply to existing tenants after declaring or updating maps:
yarn mercato entities seed-encryption --tenant <tenantId> [--organization <orgId>]
New tenants pick up defaultEncryptionMaps automatically during auth:setup. Toggling the Encrypted flag for a field only applies to data written after the change — historical plaintext rows stay as they were until backfilled via yarn mercato entities rotate-encryption-key --tenant <tenantId> --org <organizationId> (without --old-key the command only encrypts plaintext and skips already-encrypted fields). Use yarn mercato entities decrypt-database to roll back. For end-to-end usage and admin UI flows see https://docs.open-mercato.dev/user-guide/encryption.
Tip: when email (or any other column) needs deterministic lookups while encrypted, declare a sibling hashField in the map and add a matching varchar column to the entity. The framework keeps the hash in sync on writes; queries can target the hash instead of the cleartext column.
12. Wire & Verify
Step 1: Register in modules.ts
Add to src/modules.ts:
{ id: '<module_id>', from: '@app' },
Step 2: Run Generators
yarn generate
yarn db:generate
Step 3: Review Migration
Check the generated migration file in src/modules/<module_id>/migrations/. Verify:
- Table name is correct (plural, snake_case)
- All columns present with correct types
- Indexes on
organization_id, tenant_id
- No unexpected changes
migrations/.snapshot-open-mercato.json was updated to the post-change schema
- Unrelated generated migrations were deleted from the diff
Step 4: Apply & Test
yarn db:migrate
yarn dev
Step 5: Verify
Self-Review Checklist
Rules
- MUST use plural, snake_case for module ID and folder name
- MUST include
organization_id and tenant_id on all tenant-scoped entities
- MUST include standard columns (
id, created_at, updated_at, deleted_at, is_active)
- MUST validate all inputs with zod schemas in
data/validators.ts
- MUST export
openApi from every API route
- MUST use
CrudForm for forms and DataTable for tables
- MUST include
pageGroup and pageGroupKey on list/root backend pages for sidebar grouping
- MUST use
as const on pageContext values (e.g., pageContext: 'settings' as const)
- MUST declare ACL features and wire them in
setup.ts defaultRoleFeatures
- MUST register module in
src/modules.ts with from: '@app'
- MUST run
yarn generate after creating module files
- MUST create or keep a scoped migration after creating/modifying entities and update
.snapshot-open-mercato.json
- MUST NOT commit unrelated migrations emitted by
yarn db:generate
- MUST NOT run
yarn db:migrate without explicit user confirmation
- MUST NOT create ORM relationships (
@ManyToOne, @OneToMany) to entities in other modules
- MUST NOT edit
.mercato/generated/* files manually
- MUST declare
<module>/encryption.ts exporting defaultEncryptionMaps whenever the entity stores sensitive / GDPR-relevant fields (PII, contact info, addresses, free-text notes about people, integration credentials, secrets) — and read those columns via findWithDecryption / findOneWithDecryption
- MUST NOT hand-roll AES/KMS calls or store "we'll encrypt this later" plaintext for sensitive columns — use the encryption-maps mechanism described in section 11 → Encryption maps