Persistence — meta-synced tables, hand-typed tables, frozen migrations
Every tenant is its own Postgres schema: the unit of work hands out a database already
scoped with withSchema, and every migration runs with search_path pinned there — so
no table name on this page is qualified. Inside that schema OnERP writes two very
different kinds of table, and they get opposite treatment.
Business object tables come from meta, never from a migration
Section titled “Business object tables come from meta, never from a migration”SchemaManager.sync creates and alters them on every tenant bootstrap, straight from the
meta that tenant has defined, before any migration runs. That is also why Database is
Kysely<any>: table names, column names and types are all resolved at runtime, so there
is no static schema to type against.
The sync only ever adds — create-table-if-not-exists, add-column-if-missing. A rename, a drop or a data backfill is beyond it and still takes a migration, which is the only reason a domain migration ever names a business object table.
The any stops at the repository: domain code reads and writes business objects through
BusinessObjectManager, not raw SQL. The exception is the classical tier — an area
built as ordinary SQL and NestJS rather than on meta primitives, today inventory’s stock
ledger — where joining a business object table, and the casts below, are the design.
One table per collection, columns for a struct
Section titled “One table per collection, columns for a struct”A business object’s root is a table named after it (sales_SalesOrder), and every
hasMany is a child table named after its path (sales_SalesOrder_items) whose rows
carry their own id and a cascading parentId. A struct is not a table: it is a
value part with no identity, so its members are columns of the owning row, prefixed by
the slot (shipToAddress_city; a nested struct chains the prefix), beside one boolean
presence column named after the slot itself (shipToAddress). The presence column
is what lets null and {} both round-trip — an address with every member blank is
still an address. A write sets presence and every member; null clears them all. A read
hands back the member object when presence is true, null otherwise. Filters, search
and sort on address.city are plain column predicates, no join.
Two migration tracks
Section titled “Two migration tracks”- System — framework-owned, against
public, once per database as a deploy step: the tenant registry and the authorization tables. Domains never register here. - Tenant — run for every tenant. This is what
@Plugin({ migrations })feeds, alongside the framework’s own tenant-scoped modules (audit, file storage).
Either way a migration stays Kysely<any> and imports no runtime code. Typing one
against the tables it creates is circular, and its body is a frozen record of a DDL
event: names are inlined as string literals, so a later rename cannot rewrite what
already ran against existing databases.
A domain’s own infrastructure tables are hand-typed
Section titled “A domain’s own infrastructure tables are hand-typed”Ledgers, projections, and denormalized views a domain owns through hand-written
migrations are not business objects, are not in meta, and deserve real types. Each
owning domain declares a <Domain>DB interface next to the tables it describes — see
domains/inventory/src/db.ts — using Kysely’s ColumnType and Generated helpers where
insert and select shapes differ:
/** pg `numeric(18,6)` round-trips as a string; Decimal accepts strings in. */type Numeric = ColumnType<string, string, string>;
interface InventoryLedgerTable { createdAt: Generated<Date>; id: string; quantity: Numeric;}
export interface InventoryDB { inventory_ledger: InventoryLedgerTable;}Reaching that view from the untyped unit-of-work database is a cast. Reads take it once,
in a private getter — a single as is enough because Database is Kysely<any>, which
is bidirectionally assignable:
private db(): Kysely<InventoryDB> { return this.uowStore.current().db as Kysely<InventoryDB>;}Writes cannot reuse it: uow.onCommit hands its callback a Transaction<any>, not
the Kysely, so the cast repeats per callback — or moves onto the signature of the
helper the callback calls. There is deliberately no typedDb() helper for either; if
Database is ever tightened away from any, these casts start failing, which is the
right signal at the right moment.