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".
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.
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.ts declaring defaultEncryptionMaps is 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/
│ └── <entities>/
│ └── route.ts # All HTTP methods in one file: GET, POST, PUT, DELETE
└── backend/
├── page.tsx # List page → /backend/<module>
├── <entities>/
│ ├── new.tsx # Create page → /backend/<module>/<entities>/new
│ └── [id].tsx # Edit page → /backend/<module>/<entities>/<id>
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. The updated_at column is what OSS optimistic locking (default ON) compares — keep it on every user-editable entity, and make your CRUD GET/list responses return updatedAt so the UI can send the expected version.
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
All HTTP methods MUST live in a single api/<entities>/route.ts file
MUST export metadata — missing it silently breaks route-level auth guards
MUST export openApi for documentation generation
MUST use makeCrudRoute with indexer: { entityType } for query engine coverage
Use orm, list, create, update, del keys — entity/entityId/operations/schema at root level are not valid
6. Create Backend Pages
Use CrudForm and DataTable from @open-mercato/ui. See the om-backend-ui-design skill for full component reference.
Optimistic locking (default ON).CrudForm in edit mode auto-derives the expected-version header from initialValues.updatedAt and applies it to both save and delete — so pass the loaded record's updatedAt into initialValues. For custom (non-CrudForm) list-row deletes or dialog mutations, wrap the call with withScopedApiRequestHeaders(buildOptimisticLockHeader(record.updatedAt), () => deleteCrud(...)) and surface the 409 with surfaceRecordConflict(err, t) from @open-mercato/ui/backend/conflicts. Never leave a mutating edit/delete UI without a version header — concurrent edits would silently overwrite.
Feature IDs follow <module_id>.<entity>.<action> (view / manage per entity, not global create/update/delete)
Add export default features — the generator reads .default ?? .features with an empty fallback, so the named export alone works, but adding the default export ensures both import styles resolve cleanly
MUST declare defaultRoleFeatures for every feature in acl.ts
Feature IDs are FROZEN once deployed — cannot rename without data migration
After adding features run yarn mercato auth sync-role-acls so existing tenants receive the grants
9. Add DI Registration
File: src/modules/<module_id>/di.ts
importtype { AppContainer } from'@open-mercato/shared/lib/di/container'exportfunctionregister(container: AppContainer): void {
// Register module services here using Awilix// Example:// import { asFunction } from 'awilix'// container.register({// <module_id>Service: asFunction(createService).scoped(),// })
}
createModuleEvents takes { moduleId, events } — NOT a flat keyed object. Using the old keyed-object shape crashes /login at startup because the generated events registry cannot read the module
Event IDs: module.entity.action (singular entity, past tense action, dots as separators)
Declare label, entity, and category on each event — they populate the workflow trigger UI
Add clientBroadcast: true to an event definition to bridge it to the browser via SSE
Event ID contracts are FROZEN once deployed — adding new events is safe; renaming or removing is a breaking change
11. Optional Features
Search Configuration
File: src/modules/<module_id>/search.ts
importtype { SearchModuleConfig } from'@open-mercato/shared/modules/search'exportconstsearchConfig: 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
exportconst translatableFields = {
'<entity>': ['name', 'description'], // Fields that support i18n
}
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.
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 # 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.json was 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: Run Post-Scaffold Validation Gate
After every structural module change, run in order before committing:
# 1. Re-emit generated registries with the new module
yarn generate
# 2. Purge stale structural cache (nav, module-graph fingerprints)
yarn mercato configs cache structural --all-tenants
# 3. Grant ACL features declared in acl.ts to existing roles
yarn mercato auth sync-role-acls
# 4. Type-check all files — catches API mismatches before they reach runtime
yarn typecheck
Why this matters: A malformed events.ts (for example, using the old keyed-object shape for createModuleEvents) will crash /login and every other page because generated registries import all active module files at startup. A bad scaffold can make the whole admin inaccessible. Running yarn typecheck after yarn generate catches this before it ships.
Step 6: 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
/login still loads after structural changes
Self-Review Checklist
Module ID is plural, snake_case
Entity class has organization_id, tenant_id, standard columns
Validators use zod with z.infer for types
API routes live in api/<entities>/route.ts (not api/get/, api/post/, etc.)
makeCrudRoute uses { metadata, orm, list, create, update, del } — not { entity, entityId, operations, schema }
API route exports metadata, named { GET, POST, PUT, DELETE }, and openApi
DataTable receives explicit data, isLoading, error, pagination — not apiPath or createHref
CrudForm uses onSubmit with createCrud/updateCrud and onDelete with deleteCrud — not apiPath, mode, or resourceId
events.ts uses createModuleEvents({ moduleId, events: [...] }) array shape — not a keyed object
events.ts has export default eventsConfig
acl.ts exports features (named export is sufficient; default export is recommended for broad import compatibility)
ACL feature IDs use <module>.<entity>.view / <module>.<entity>.manage pattern
setup.ts grants every feature in acl.ts to at least admin and superadmin
Migration SQL is scoped to this entity and .snapshot-open-mercato.json is updated
No any types
No hardcoded user-facing strings
No direct ORM relationships to other modules
/login still loads after all changes
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 place all HTTP method handlers in a single api/<entities>/route.ts — not separate api/get/, api/post/ files
MUST use makeCrudRoute with { metadata, orm, list, create, update, del } — not { entity, entityId, operations, schema }
MUST export metadata, named method handlers { GET, POST, PUT, DELETE }, and openApi from every route file
MUST use CrudForm with explicit onSubmit / onDelete handlers — not apiPath, mode, or resourceId props
MUST use DataTable with explicit data, isLoading, error, pagination — not apiPath, createHref, or extensionTableId
MUST use createModuleEvents({ moduleId, events: [...] }) array shape — NEVER the old keyed-object { 'id': { description, payload } } shape
MUST add export default eventsConfig in events.ts
MUST export features from acl.ts (named export is sufficient; adding export default features is recommended for broad import compatibility)
MUST use <module>.<entity>.view / <module>.<entity>.manage feature ID pattern
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.tsdefaultRoleFeatures
MUST register module in src/modules.ts with from: '@app'
MUST run the post-scaffold validation gate after creating module files: yarn generate → yarn mercato configs cache structural --all-tenants → yarn mercato auth sync-role-acls → yarn typecheck
MUST verify /login still loads after every structural change
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