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 saasframe 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 saasframe 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.

External ID Mapping (UMES Phase L)

The integrations module provides a built-in entity and enricher for tracking external system IDs alongside internal records.

SyncExternalIdMapping entity

SyncExternalIdMapping (table: sync_external_id_mappings) stores the link between an internal entity record and its counterpart in an external system.

ColumnTypeDescription
idUUID PKAuto-generated
integration_idtextIdentifies the integration (e.g. sync_shopify)
internal_entity_typetextTarget entity type (e.g. catalog.product)
internal_entity_idUUIDInternal record ID
external_idtextID in the external system
sync_statustextsynced, pending, error, or not_synced
last_synced_attimestampLast successful sync time
organization_idUUIDTenant-scoped
tenant_idUUIDTenant-scoped

Entity source: packages/core/src/modules/integrations/data/entities.ts

External ID mapping enricher

The externalIdMappingEnricher response enricher adds an _integrations namespace to any entity's API response. It targets all entities (targetEntity: '*') and uses batch queries via enrichMany to prevent N+1.

Enriched response shape:

{
"id": "abc-123",
"name": "Example Product",
"_integrations": {
"sync_shopify": {
"externalId": "shp_prod_123",
"externalUrl": "https://admin.shopify.com/store/demo/products/shp_prod_123",
"lastSyncedAt": "2026-02-28T10:00:00.000Z",
"syncStatus": "synced"
},
"gateway_stripe": {
"externalId": "cus_abc456",
"syncStatus": "pending"
}
}
}

The externalUrl field is populated when the integration registers a buildExternalUrl callback.

Integration registry

Register integration definitions at module bootstrap so the enricher and UI widgets can resolve metadata:

import { registerIntegration } from '@saasframe/shared/modules/integrations/types'

registerIntegration({
id: 'sync_shopify',
title: 'Shopify',
icon: 'shopify',
buildExternalUrl: (externalId) =>
`https://admin.shopify.com/store/demo/products/${externalId}`,
})

Available helpers:

FunctionDescription
registerIntegration(def)Register a single integration definition
registerIntegrations(defs)Register multiple definitions at once
getIntegration(id)Get definition by ID
getAllIntegrations()List all registered definitions
getIntegrationTitle(id)Get title, falling back to the raw ID

Types: IntegrationDefinition, ExternalIdEnrichment, ExternalIdMapping from @saasframe/shared/modules/integrations/types.

See also: Integration enhancements, Widget injection

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 saasframe 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 '@saasframe/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 '@saasframe/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.