Skip to content

Business Objects

A Business Object (BO) is a managed entity that wraps a view with CRUD lifecycle, permission checks, and hooks. BOs are read-only by default — you must explicitly define actions to enable writes.

BOs are not exposed to HTTP directly. Any HTTP surface goes through a Projection — an explicit whitelist of actions and columns. BOs stay importable for custom action handlers, CLI scripts, and internal use; projections are the only thing wired to registerProjection in @pgbo/fastify.

Defining a BO

typescript
import { defineBO } from '@pgbo/core/bo'

const warehouseBO = defineBO(warehouseTable, {
  paramField: 'slug',
  actions: {
    create: {},
    update: {},
    delete: {},
  },
})

With no actions, the BO is read-only:

typescript
const readOnlyBO = defineBO(warehouseTable, {})
readOnlyBO.isReadOnly  // true

BO Name

bo.name defaults to the camelCase form of the root table/view name, matching pgbo's camelCase-everywhere convention on the query side. The SQL identifier (root.name) stays snake_case.

typescript
const bo = defineBO(stockJournalTable, { paramField: 'id' })
bo.name       // 'stockJournal'
bo.root.name  // 'stock_journal'

Override with config.name when needed:

typescript
const bo = defineBO(stockJournalTable, {
  name: 'stockJournalEntries',
  paramField: 'id',
})

List & Route Metadata

BOs carry list-level metadata so consuming apps don't need per-BO config duplication:

typescript
const areaBO = defineBO(areaTable, {
  paramField: 'id',
  orderBy: 'sortOrder',
  orderDir: 'asc',
  cacheTags: ['area', 'navigation'],
  virtualFields: [
    { key: 'childCount', kind: 'number', label: 'crud.childCount' },
  ],
  transformItems: async (rows, locale, db) => {
    // Custom post-processing after enrichCompositions
    return rows
  },
})
  • orderBy / orderDir — default sort for list queries
  • cacheTags — cache invalidation tags
  • virtualFields — fields populated by transformItems, merged into boMeta() output
  • transformItems — batch transform that runs after enrichCompositions

searchColumns and filterColumns are derived from the view's column annotations (.searchable(), .filterable()) — not from BO config. Read them from boMeta().fields.

Actions

Standard CRUD

BO methods are fully typed based on the root table/view:

  • create(db, ctx, data)data is InferInsert<typeof table> (required columns + optional defaults/nullables, plus composition keys)
  • update(db, ctx, data)data requires the paramField + any optional column updates
  • delete(db, ctx, data)data only needs the paramField
  • Return types are InferRow<typeof table> so callers get typed results
typescript
// Create — name is required (notNull, no default); extra keys caught at compile time
const created = await warehouseBO.create(db, ctx, {
  slug: 'new-wh', name: 'New Warehouse',
})

// Update (paramField identifies the record)
const updated = await warehouseBO.update(db, ctx, {
  slug: 'new-wh', name: 'Renamed',
})

// Delete
await warehouseBO.delete(db, ctx, { slug: 'new-wh' })

Permission Checks

typescript
const warehouseBO = defineBO(warehouseTable, {
  paramField: 'slug',
  actions: {
    create: {
      permission: (ctx) => ctx.role === 'admin',  // return false to deny
    },
    update: {
      permission: (ctx) => {
        if (ctx.role !== 'admin') return 'Admin access required'  // return string for error message
        return true
      },
    },
  },
})

Before/After Hooks

typescript
const warehouseBO = defineBO(warehouseTable, {
  paramField: 'slug',
  actions: {
    create: {
      before: async (ctx, data) => {
        // Validate or transform data before insert
        if (data.slug.includes(' ')) return 'Slug cannot contain spaces'
      },
      after: async (ctx, result) => {
        // Side effects after successful insert
        console.log('Created:', result.slug)
      },
    },
  },
})

Custom Actions

typescript
const docBO = defineBO(docTable, {
  paramField: 'id',
  actions: {
    reverse: {
      permission: (ctx) => hasPermission(ctx, 'MANAGE_STOCK'),
      handler: async (ctx, data) => {
        // Custom logic — framework doesn't do standard CRUD
        return await reverseDocument(data.id)
      },
    },
  },
})

await docBO.execute(db, 'reverse', ctx, { id: 123 })

Compositions

Compositions define deeply owned child entities. On create, the BO inserts the parent then the children:

typescript
const warehouseBO = defineBO(warehouseTable, {
  paramField: 'slug',
  actions: { create: {}, update: {}, delete: {} },
  compositions: {
    translations: {
      table: warehouseTranslationTable,
      parentKey: 'warehouseSlug',  // FK column on the child
    },
  },
})

// Create parent + children in one call
await warehouseBO.create(db, ctx, {
  slug: 'main',
  name: 'Main Warehouse',
  translations: [
    { locale: 'en', name: 'Main Warehouse' },
    { locale: 'de', name: 'Hauptlager' },
  ],
})

On delete, PostgreSQL FK cascades handle child cleanup automatically.

Auto-enrichment on Read

Compositions are also batch-loaded on reads via enrichCompositions():

typescript
import { enrichCompositions } from '@pgbo/core/bo'

const items = await db.from(menuGroupView).execute()
const enriched = await enrichCompositions(db, menuGroupBO, items)

// enriched[0]:
// {
//   id: 1, slug: 'nav',
//   translations: [{ locale: 'en', name: 'Navigation' }, ...],
//   pages: [{ menuGroupId: 1, pageSlug: 'home' }, ...],
// }

The function:

  1. Collects parent IDs from items using bo.paramField
  2. Runs one WHERE parent_key = ANY($1) query per composition (in parallel)
  3. Groups results and attaches as nested arrays
  4. Returns new objects — does not mutate the input
  5. Snake_case keys in child rows are converted to camelCase

Filtered compositions — cardinality, where, merge

Compositions default to returning every matching child as an array (cardinality: 'many'). Three options change this:

typescript
const areaBO = defineBO(areaTable, {
  paramField: 'id',
  compositions: {
    // The one translation row matching the caller's locale
    translation: {
      table: areaTranslationTable,
      parentKey: 'areaId',
      cardinality: 'one',
      where: { locale: '$locale' },
    },
    // Current contract — filter by validity window
    currentContract: {
      table: contractTable,
      parentKey: 'customerId',
      cardinality: 'one',
      where: {
        validFrom: { lte: '$now' },
        validTo:   { gte: '$now' },
      },
    },
    // Primary address — literal filter, no placeholder
    primaryAddress: {
      table: addressTable,
      parentKey: 'customerId',
      cardinality: 'one',
      where: { isPrimary: 'yes' },
    },
  },
})

cardinality'many' (default, array) or 'one' (single object or null).

where — a WHERE clause applied to the composition query. Supports the same operators as db.where() (lte, gte, ilike, any, in, etc.) plus context placeholders:

PlaceholderResolved to
$localectx.locale
$userIdctx.userId
$tenantIdctx.tenantId
$nownew Date()

Pass ctx via enrichCompositions(db, bo, items, { ctx }). If a placeholder references ctx data that's missing, enrichment throws — failing loud beats silently returning unfiltered results.

merge — with cardinality: 'one', lifts the matched child's fields onto the parent instead of attaching a nested object. Common for translations:

typescript
compositions: {
  translation: {
    table: areaTranslationTable,
    parentKey: 'areaId',
    cardinality: 'one',
    where: { locale: '$locale' },
    merge: ['name', 'description'],
  },
}

// Result: { id, slug, name, description } — no nested 'translation' object

A third composition shape resolves parent → link table → target entity in two batched queries. Useful for M2M patterns like product↔warehouse assignments where you want each product row to carry the list of warehouses it's assigned to, with target compositions (translations) resolved for free.

typescript
const productBO = defineBO(productTable, {
  paramField: 'wku',
  compositions: {
    warehouses: {
      linkTable: productWarehouseTable,       // junction table
      linkParentKey: 'wku',                    // FK on link → parent
      linkTargetKey: 'warehouseSlug',          // FK on link → target
      target: warehouseBO,                     // BO → its compositions run
      columns: ['slug', 'name'],               // narrow exposed target fields
    },
    activeWarehouses: {
      linkTable: productWarehouseTable,
      linkParentKey: 'wku',
      linkTargetKey: 'warehouseSlug',
      target: warehouseBO,
      linkWhere: { archived: { isNull: true } }, // filter on the link rows
      where: { active: true },                   // filter on the target rows
    },
  },
})

On read, enrichCompositions runs three batches per parent group:

  1. SELECT linkParentKey, linkTargetKey FROM linkTable WHERE linkParentKey = ANY($1) [AND linkWhere]
  2. SELECT * FROM target WHERE target.paramField = ANY(targetKeys) [AND where]
  3. If target is a BO, run its own compositions (translations, etc.)

Attached as an array under the composition name: product.warehouses: [{ slug, name }, ...] with locale-resolved names when the target is a translated BO.

Writes are not yet supported — replace/add/remove semantics for M2M payloads are a follow-up. Passing link data in bo.create() input is currently ignored.

Nested Sub-Children

Compositions can declare their own children for multi-level loading:

typescript
const roleBO = defineBO(roleView, {
  paramField: 'id',
  compositions: {
    fragments: {
      table: roleFragmentTable,
      parentKey: 'roleId',
      children: {
        values: {
          table: roleFragmentValueTable,
          parentKey: 'roleFragmentId',
        },
      },
    },
  },
})

const enriched = await enrichCompositions(db, roleBO, items)
// enriched[0]:
// {
//   id: 1, slug: 'admin',
//   fragments: [
//     { fragmentSlug: 'MANAGE_STOCK', values: [
//       { fieldSlug: 'ACTION', value: '*' },
//       { fieldSlug: 'WAREHOUSE', value: '*' },
//     ]},
//   ],
// }

enrichCompositions recursively loads each children level. Sub-children use the child table's primary key to collect IDs for the next level.

Associations

Associations are references to other entities (the referenced entity has its own lifecycle). Declare them with the FK column and, optionally, a target entity so pgbo can batch-enrich parent rows on read.

typescript
const productBO = defineBO(productTable, {
  paramField: 'sku',
  actions: { create: {}, update: {}, delete: {} },
  associations: {
    // Metadata only — FK is known, no enrichment
    warehouse: { foreignKey: 'warehouseSlug' },
  },
})

Auto-enrichment on read — merge + prefix

Lift fields from the target onto each parent row:

typescript
const pageBO = defineBO(pageTable, {
  paramField: 'id',
  associations: {
    area: {
      foreignKey: 'areaId',
      target: areaBO,              // BO → its compositions (e.g. translation) run
      merge: ['name'],             // pull the resolved area.name
      prefix: 'area',              // → parent.areaName
    },
  },
})

// Caller-locale translation resolves automatically when the target BO
// has a composition like { locale: '$locale', merge: ['name'] }:
await enrichAssociations(db, pageBO, pages, { ctx: { locale: 'de' } })
// → each page gets .areaName = 'Navigation DE' / 'Verwaltung' / ...

Attach — attach + columns

Attach the target as a nested object, optionally narrowed to specific fields:

typescript
associations: {
  owner: {
    foreignKey: 'ownerId',
    target: userBO,
    attach: 'owner',
    columns: ['id', 'email'],     // → parent.owner = { id, email }
  },
}

If the FK is null, parent.<attach> is set to null. If columns is omitted, the full target row is attached.

Target types

  • target: ViewDef — simple FK lookup, no target compositions run
  • target: BusinessObjectDef — target's compositions run after load (so $locale-filtered translations resolve). Root can be either a view or a table.

Behaviour inside registerProjection

registerProjection from @pgbo/fastify automatically calls enrichAssociations with the request ctx for both list and detail routes. If you're using the BO programmatically, call enrichAssociations(db, bo, items, { ctx }) explicitly.

Associations with neither merge nor attach are metadata-only — no target query happens.

Value Helps

Link dropdown sources to a BO. Each entry must be a view(...).vh({ key, display }) — any other shape throws at defineBO() time.

typescript
const warehouseValueHelp = view('warehouse_vh')
  .from(warehouseTable)
  .columns({ slug: col('slug'), name: col('name') })
  .vh({ key: 'slug', display: 'name' })

const productBO = defineBO(productTable, {
  paramField: 'sku',
  actions: { create: {} },
  valueHelps: {
    warehouse: warehouseValueHelp,
  },
})

See the Value Help Views section of the schema reference for the full semantics (JOINs, translated labels, auth restrictions).

Projections

A projection is the HTTP surface layered on top of a BO. The BO holds the data model and write logic; the projection declares exactly what is reachable over HTTP — which actions are whitelisted, which columns are visible, and what root-level WHERE applies to every query.

typescript
import { defineBO, defineProjection } from '@pgbo/core/bo'

const areaBO = defineBO(areaTable, {
  paramField: 'id',
  actions: {
    create: {}, update: {}, delete: {},
    rebuildCache: { handler: async () => { /* ... */ } },
    internalExport: { handler: async () => { /* ... */ } },
  },
})

// Public read-only surface
const areaPublic = defineProjection(areaBO, {
  name: 'areaPublic',
  actions: { read: true },
  columns: ['id', 'slug', 'name'],  // internal columns hidden
})

// Admin surface — CRUD + one custom action, but NOT internalExport
const areaAdmin = defineProjection(areaBO, {
  name: 'areaAdmin',
  actions: { read: true, create: true, update: true, delete: true, rebuildCache: true },
})

Explicit action whitelist

Only actions listed with true produce routes. Standard keys:

KeyRoutes registered
readGET {prefix}, GET {prefix}/:param, GET /bo/{name}
createPOST {prefix}
updatePUT {prefix}/:param
deleteDELETE {prefix}/:param
<custom>POST /bo/{name}/{custom}

Actions not mentioned in the whitelist produce no routes. Accidentally adding an action to the BO does not expose it — it must be explicitly whitelisted on every projection.

Column narrowing

When columns is set, responses and the metadata endpoint return only those fields. Fields absent from the projection are invisible to the consumer.

Root WHERE

where applies to every list, detail, update, and delete through the projection. Rows that don't match are invisible — detail returns 404, updates/deletes on out-of-scope rows also return 404.

typescript
const activeCustomer = defineProjection(customerBO, {
  name: 'activeCustomer',
  actions: { read: true, update: true },
  where: { status: 'ACTIVE' },
})

Registering the projection with Fastify

typescript
import { registerProjection } from '@pgbo/fastify'

registerProjection(app, db, {
  projection: areaPublic,           // → routes under /bo/areaPublic
  view: areaView,
  extractContext: req => ({ app, db, locale: 'en' }),
})

registerProjection(app, db, {
  projection: areaAdmin,            // → routes under /bo/areaAdmin
  view: areaView,
  extractContext: /* ... */,
})

URL layout is locked to /bo/{projection.name} (issue #44) — no per-projection prefix knob. Multiple projections over the same BO coexist by picking different names. For API versioning or tenant prefixes, use Fastify's encapsulation: app.register(routes, { prefix: '/v1' }).

Validation

defineProjection throws at definition time if:

  • A whitelisted action doesn't exist on the underlying BO
  • A listed column doesn't exist on the BO's root table/view

Fail-fast prevents broken projections from shipping.

Released under the MIT License.