Skip to content

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.

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