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:
- CRUD API (almost always yes)
- Backend admin pages (almost always yes)
- Frontend public pages
- Search indexing
- Event publishing
- Background workers
- CLI commands
- Custom fields support
- Sensitive / GDPR-relevant fields (PII, contact info, addresses, free-text notes about people, integration credentials, secrets) — if yes, an
encryption.tsdeclaringdefaultEncryptionMapsis mandatory; see section 11 → Encryption maps
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>' }) // plural, snake_case
export class <Entity> {
@PrimaryKey({ type: 'uuid' })
id: string = v4()
@Index()
@Property({ type: 'uuid' })
organization_id!: string
@Index()
@Property({ type: 'uuid' })
tenant_id!: string
// --- Domain fields ---
@Property({ type: 'varchar', length: 255 })
name!: string
// Add domain-specific fields here
// Use appropriate types: varchar, text, int, float, boolean, uuid, jsonb, date
// --- Standard columns ---
@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
uuidwithv4()default - MUST include
organization_id+tenant_idwith@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
uuidfield (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),
// Add domain fields matching entity
})
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 requiredid - 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
openApifor documentation generation - Use
makeCrudRoutewithindexer: { entityType }for query engine coverage - Schema validation is automatic when
schemais 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>', // Sidebar section name
pageGroupKey: '<module_id>.nav.group', // i18n key — items with same key grouped together
pageOrder: 100, // Sort within group (lower = higher)
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
defaultRoleFeaturesfor every feature inacl.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 {
// Register module services here using Awilix
// Example:
// import { asFunction } from 'awilix'
// container.register({
// <module_id>Service: asFunction(createService).scoped(),
// })
}
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: trueto 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'], // Fields to index for fulltext search
// Additional search config as needed
},
},
}
Translations
File: src/modules/<module_id>/translations.ts
export const translatableFields = {
'<entity>': ['name', 'description'], // Fields that support i18n
}
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 () => {
// Implementation
})
}
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>', // matches data/entities.ts table id, colon-separated
fields: [
{ field: 'first_name' },
{ field: 'last_name' },
{ field: 'phone' },
// Add a hashField for deterministic equality lookups (e.g. login by email):
{ 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'
// Signature: (em, entityName, where, options?, scope?) — MikroORM FindOptions in slot 4
// (pass `undefined` when none), decryption scope in slot 5.
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
hashFieldin the map and add a matchingvarcharcolumn 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 # Discover module files, update .mercato/generated/
yarn db:generate # Probe/create migration for the new entity
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.jsonwas updated to the post-change schema- Unrelated generated migrations were deleted from the diff
Step 4: Apply & Test
yarn db:migrate # Apply migration only after explicit user confirmation
yarn dev # Start dev server
Step 5: Verify
- Module appears in admin sidebar (if menu item added)
- List page loads at
/backend/<module_id> - Create form works at
/backend/<module_id>/<entities>/new - Edit form loads existing record
- Delete works from list page
- ACL features appear in role management
Self-Review Checklist
- Module ID is plural, snake_case
- Entity class has
organization_id,tenant_id, standard columns - Validators use zod with
z.inferfor types - All API routes export
openApi - Backend pages use
CrudFormandDataTable - Sidebar icon uses
lucide-reactcomponent (not inline SVG /React.createElement) -
page.meta.tsincludespageGroup+pageGroupKeyfor sidebar grouping -
page.meta.tsincludespageOrderfor sort position - All related pages share the same
pageGroupKey - Settings pages (if any) have
pageContext: 'settings' as constandnavHidden: true - ACL features declared and wired in
setup.ts - Module registered in
src/modules.tswithfrom: '@app' -
yarn generaterun after creating files - Migration SQL is scoped to this entity and
.snapshot-open-mercato.jsonis updated - No
anytypes - No hardcoded user-facing strings
- No direct ORM relationships to other modules
Rules
- MUST use plural, snake_case for module ID and folder name
- MUST include
organization_idandtenant_idon 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
openApifrom every API route - MUST use
CrudFormfor forms andDataTablefor tables - MUST include
pageGroupandpageGroupKeyon list/root backend pages for sidebar grouping - MUST use
as constonpageContextvalues (e.g.,pageContext: 'settings' as const) - MUST declare ACL features and wire them in
setup.tsdefaultRoleFeatures - MUST register module in
src/modules.tswithfrom: '@app' - MUST run
yarn generateafter 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:migratewithout 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.tsexportingdefaultEncryptionMapswhenever the entity stores sensitive / GDPR-relevant fields (PII, contact info, addresses, free-text notes about people, integration credentials, secrets) — and read those columns viafindWithDecryption/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