Skip to content

The meta pipeline

Every @Plugin contributes business objects, extensions, and providers — the determinations, validators, actions and functions that run against them. The server composes all of it per tenant into a TenantMeta: the declarations, each paired with its implementation. The declarations alone are Meta, and Meta is exactly what the browser receives from GET /api/meta. Code never leaves the server.

Declaring: the plugin names its business objects

Section titled “Declaring: the plugin names its business objects”

Business objects and extensions are plain values listed on the plugin; everything that runs is a provider:

domains/inventory/src/plugin.ts
@Plugin({
name: "inventory",
businessObjects: [GoodsMovementSchema, LocationSchema, LotSchema],
providers: [LineBaseQuantity, MovementHasLines, StockOverview, StockService],
})
export class InventoryPlugin {}

A determination, validator, action or function is a class that extends the base its factory builds (Determination.on(Schema, …), Action.root(…)), carries the matching marker decorator, and is listed in providers. The base holds the declaration — name, business object, description, inputs and outputs, params and returns. See Registration.

The description on a business object, field, action, function and determination is the one place domain knowledge is written down. The AI agent reads it on demand through its describe tools, the forms show a field’s as the info tooltip on its label and every has-many header (see Forms), and the docs quote it. The framework’s own prompt carries no domain knowledge, and there is no other channel into it: a rule that holds for every business object belongs in the framework prompt, a gap the product has belongs on What OnERP doesn’t do yet, and everything else is a description on the def it is about. apps/api’s meta-completeness.test.ts fails on a business object, field or operation without one.

defineExtension adds fields to another domain’s business object, addressed by the business object and a dotted path to the hasMany or struct they land in (absent for the root). The extending plugin lists it under extensions:

domains/eu-vat/src/document/extensions.ts
export const euVatOrderExtension = defineExtension(SalesOrderSchema, euVatDocumentHeaderFields);
export const euVatOrderLineExtension = defineExtension(SalesOrderSchema, "items", euVatDocumentLineFields);

An extension is runtime data and nothing else: composition merges its fields into the business object, and no type merges anywhere. Type what you hold. The definition a domain imports stays exactly what that domain declared; a consumer that wants the extended fields typed writes the intersection from the field factories:

type Product = BusinessObjectDef<
"master/Product",
typeof ProductSchema.fields & EuVatProductFields
>;
const f = useForm<Product>(); // a form
const product = await this.bom.get<Product>("master/Product", id); // a read, by name

A rule on an extended business object types itself the same way: Determination.on<BusinessObjectDef, VatDocumentDef>(schema, …) binds to the sales schema and reads eu-vat’s fields.

Composition reads two shapes. A SchemaLayer is businessObjects and extensions — what a @Plugin declares and all a tenant may add. Providers is the determinations, validators, actions and functions Nest discovered, one set for every tenant, so a tenant layer carries no code by type. PluginRegistry (in CoreModule) produces both:

  • PluginRegistry.layer() — every plugin’s business objects and extensions.
  • PluginRegistry.providers() — a DiscoveryService sweep for each of the four provider decorators.

TenantSource.load(tenantId) returns one tenant’s SchemaLayer. StaticTenantSource is the implementation today, bound to TENANT_SOURCE from the tenantSource option of CoreModule.

A tenant layer is checked first: every business object under custom/, every extension field under custom_. Then composeTenantMeta(providers, pluginLayer, tenantLayer) builds the TenantMeta in one pure pass, and each step fails loud:

  1. Merge. Extensions are merged into their business objects. An extension of an unknown business object, an unknown path, or a field that already exists throws.
  2. Check references. Every reference field — on a business object and in every operation’s params and returns — must target a composed business object, and a reference path must resolve to hasMany collections.
  3. Declare. Each provider’s declaration is derived from its instance. A determination’s field paths resolve against the extended business object.
  4. Reject duplicates. A business object, rule, action or function identifier that appears twice — in one layer or across layers — throws. A tenant never overrides a plugin.
  5. Order. Each business object’s determinations are sorted by their dependency graph; a non-convergent cycle or two unordered rules writing one field throws (see Determinations).
  6. Round-trip. The declarations are serialized with toMetaWire and parsed back, so a declaration that does not survive the wire fails the boot, not the browser.

Meta (from @onerp/meta) holds declarations only and is immutable. declarations holds all of them; a reader asks with list and get:

Method Returns
listBusinessObjects() every composed business object, extensions merged
getBusinessObject(name) one business object; throws when absent
listDeterminations(businessObject) the object’s determination declarations, in execution order
listValidators(businessObject) the object’s validator declarations
listActions(businessObject?) / listFunctions(businessObject?) the operations bound to that object, or the root ones when omitted

TenantMeta (from @onerp/core) is one tenant’s composed meta: meta: Meta, plus accessors that return each declaration meta lists paired with its implementation as Implemented<Declaration, Implementation> — { declaration, implementation }:

  • rules(businessObject) → { determinations, validators }, what the mutator runs — determinations on every write and preview, validators when a save leaves draft;
  • actions(businessObject), action(name, businessObject?), function(name, businessObject?) — what the dispatch seams run.

MetaService is the one place the runtime reads meta through. It composes the base in onModuleInit, so a plugin set that does not compose fails the boot. CoreModule is global, so its onModuleInit runs before any plugin’s, and a plugin’s own onModuleInit may call get.

  • get(tenantId) composes the tenant’s meta — the base itself when the tenant layer is empty — caches it, and single-flights concurrent calls.
  • current() returns the current tenant’s TenantMeta. It throws unless get primed that tenant first; the request, job and workflow entry points do that.
  • resolve(ref) returns the composed business object for a schema or name — the definition with every extension merged.

GET /api/meta is authenticated and tenant-scoped. It answers toMetaWire(meta) — every business object with its extensions merged, and every determination, validator, action and function declaration. createOnerpClient fetches it once through fetchJson and parses it with parseMeta, and the result is client.meta, a Meta. The browser cannot tell a plugin’s declaration from a tenant’s: there is one shape and no origin on it. Because the endpoint requires an authenticated session and an active workspace, <RequireWorkspace> — which loads it — is mounted inside the authenticated route tree, keyed by user and workspace.

The browser runs no rules. A form sends its edits to the preview endpoint and renders the record the server computed; see Determinations.

The browser never value-imports domain code

Section titled “The browser never value-imports domain code”

domainImportFencePlugin fails resolution of any @onerp/<package> value import in the browser bundle that isn’t a declared runtime dependency of @onerp/react (@onerp/client, @onerp/meta, @onerp/catalogs, @onerp/ui) or of the app hosting it — the host’s own @onerp/* dependencies widen the allowlist. It ships from @onerp/react/vite and is mounted by both apps/web’s configs and @onerp/react’s own — the framework is held to the same rule. The domain packages are listed only as devDependencies, so their types resolve for import type but a value import is rejected at resolve time: a domain package’s entry pulls in its @Plugin module and every service behind it. The fix is always a runtime lookup against something already loaded — useFunction<SomeFunction>("name", args) names a function by string and types it against the class through import type, and useMeta().getBusinessObject(...) reaches a schema the same way.