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:
@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.
Extensions
Section titled “Extensions”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:
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 formconst product = await this.bom.get<Product>("master/Product", id); // a read, by nameA 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
Section titled “Composition”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()— aDiscoveryServicesweep 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:
- 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.
- Check references. Every reference field — on a business object and in every
operation’s
paramsandreturns— must target a composed business object, and a reference path must resolve tohasManycollections. - Declare. Each provider’s declaration is derived from its instance. A determination’s field paths resolve against the extended business object.
- 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.
- 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).
- Round-trip. The declarations are serialized with
toMetaWireand parsed back, so a declaration that does not survive the wire fails the boot, not the browser.
Meta and TenantMeta
Section titled “Meta and TenantMeta”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
Section titled “MetaService”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’sTenantMeta. It throws unlessgetprimed 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.
Delivery: one endpoint
Section titled “Delivery: one endpoint”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.