Skip to main content

Entity Extensions and Custom Fields

This doc outlines how modules extend each other’s data (without forking schemas) and how users add custom fields at runtime.

Goals

  • Keep modules isolated and upgrade-safe.
  • Allow one module to add data to another module’s entity via separate extension entities.
  • Allow end users to define custom fields (text/multiline/integer/float/boolean/select) per entity and filter by them.

Module-to-Module Extensions

  • Instead of modifying core entities, create a new entity in your module that links to the base entity.
  • Declare the link in src/modules/<module>/data/extensions.ts using a small DSL (Medusa-like):
import { defineLink, entityId, linkable } from '@/modules/dsl'

// Option A: refer using explicit entityId()
export default [
defineLink(
entityId('auth', 'user'),
entityId('my_module', 'user_profile'),
{ join: { baseKey: 'id', extensionKey: 'user_id' }, cardinality: 'one-to-one', description: 'Adds profile fields to users' }
)
]

// Option B: use linkable() helper (similar to Medusa’s Module.linkable)
const Auth = { linkable: linkable('auth', ['user']) }
const My = { linkable: linkable('my_module', ['user_profile']) }
export const extensions = [
defineLink(Auth.linkable.user, My.linkable.user_profile, { join: { baseKey: 'id', extensionKey: 'user_id' } })
]
  • Keep the extension entity in data/entities.ts of your module, with a FK to the base entity id.
  • Generators include entityExtensions in modules.generated.ts for discovery.

Custom Fields (EAV)

  • Core module entities ships two tables:
    • custom_field_defs — definitions (per entity id, organization, and tenant)
    • custom_field_values — values (per entity record, organization, and tenant)

– Declare initial field sets in module-level ce.ts by attaching a fields array to an entity entry. data/fields.ts is no longer supported.

// src/modules/<module>/ce.ts
export const entities = [
{
id: '<module>:<entity>',
label: 'My Entity',
showInSidebar: true, // optional: default sidebar visibility
fields: [
{ key: 'priority', kind: 'integer', label: 'Priority', defaultValue: 3, filterable: true, formEditable: true },
{ key: 'severity', kind: 'select', label: 'Severity', options: ['low','medium','high'], defaultValue: 'medium', filterable: true, formEditable: true },
],
},
]

Notes:

  • The generator merges entities[].fields into Module.customFieldSets automatically, so yarn mercato entities install will seed them.
  • You can target ANY entity id (including system/core entities) to extend them with additional fields. For example:
export const entities = [
{ id: 'auth:user', fields: [ { key: 'nickname', kind: 'text', label: 'Nickname', formEditable: true } ] },
]
  • Installer behaviour:
    • If the id matches a generated system entity (from generated/entities.ids.generated.ts), it only seeds or updates field definitions. The base entity stays marked as “system”.
    • If the id is not a system entity, the installer automatically creates/updates a corresponding row in custom_entities so the entity shows up in the UI and can store records.
    • All entries are processed per tenant by default; mark an entry with global: true to install it once with tenant_id = null.

Declaring fields via DSL (legacy) — deprecated

import { defineFields, entityId, cf } from '@/modules/dsl'

export default [
defineFields(entityId('directory','organization'), [
cf.select('industry', ['SaaS','Retail','Agency'], { filterable: true }),
cf.boolean('vip', { filterable: true }),
], 'my_module')
]
  • Users will manage custom fields via an admin UI (next task). The UI will:
    • Let users pick an entity and define fields.
    • Enforce tenant scoping and basic validations.
    • Generate filters and form controls dynamically.

Seeding definitions from modules

Custom entity definitions and field sets declared in ce.ts are applied by the entities CLI. The command is idempotent and uses checksums to skip unchanged configurations.

yarn mercato entities install

Key flags:

FlagDescription
--tenant <id>Restrict sync to a specific tenant. Without this flag, every tenant in the database is processed.
--globalOnly install global definitions (sets tenant_id = null).
--no-globalSkip global scope (default is to include both global and tenant-specific).
--forceIgnore cached checksums and reapply definitions even if nothing changed.
--dry-runShow what would change without writing to the database.

The CLI runs quickly thanks to checksum caching stored under custom-entity:* cache tags. Use --force (or the reinstall subcommand) after editing ce.ts if you need to bypass the cache immediately.

Why not migrations? Migrations are module-scoped, run in isolation, and should alter schema deterministically. Field sets aggregate across all enabled modules at the app level and may target specific tenants; executing them in each module’s migration would cause duplication, ordering problems, and environment coupling. Use the CLI to seed or re-seed idempotently whenever modules change.

Multi-tenant

  • Custom fields and values are tenant-scoped for querying: the query layer filters by tenant_id only. organization_id is not applied to custom field discovery or value joins.
  • Virtual/custom entities are filtered by tenant_id and, when provided, additionally by organization_id.

Validation and Types

  • Base entities continue to use zod validators and MikroORM classes.
  • Custom fields are validated dynamically based on their kind and options.

Migrations

  • Add your extension entity as normal.
  • The entities module migrations are generated like any other module.

Declaring virtual entities from module code

When you declare a new entity in ce.ts, yarn mercato entities install automatically ensures a matching row exists in custom_entities (unless the entity id is already part of the generated system enum). In advanced scenarios you may still register entities manually—for example, during a bespoke bootstrap flow or runtime provisioning. Use the helper provided by the core module:

import { upsertCustomEntity } from '@open-mercato/core/modules/entities/lib/register'

// inside a CLI command, module init, or DI registrar where you have an EM
await upsertCustomEntity(em, 'example:calendar_entity', {
label: 'Calendar Entity',
description: 'Events and availability',
// optional scope
organizationId: null,
tenantId: null,
})

If the entity exists, label/description are updated; if not, it is created. After registering, admins can define fields for it under Backend → Data designer → System/User Entities.

Registering from DI (on boot)

// src/modules/<module>/di.ts
import type { AppContainer } from '@/lib/di/container'
import { upsertCustomEntity } from '@open-mercato/core/modules/entities/lib/register'

let registered = false
export function register(container: AppContainer) {
if (registered) return
registered = true
;(async () => {
const em = container.resolve('em') as any
await upsertCustomEntity(em, '<module>:<entity>', {
label: 'My Entity',
description: 'Declared from DI on boot',
// Optionally scope to org/tenant
organizationId: null,
tenantId: null,
})
})().catch(() => {})
}

Note: DI registrars may run per request container; the helper is idempotent and safe to call multiple times.