diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index cf4b8b405d1..c4bc2c800f4 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -305,6 +305,11 @@ Checks that need no connection can still run in the constructor, which fails even earlier — that is where `MongoDBDriver` puts its [tenancy guard](#multi-tenancy-not-supported). +A driver need not front a database at all: a plugin can publish one over a REST API or +any other system, and an `external` object bound to it is served from there on every +read. The recipe, the rules such a driver keeps and a test template are in +[Virtual objects](/docs/data-modeling/virtual-objects). + ## PostgreSQL (via `@objectstack/driver-sql`) ```bash diff --git a/content/docs/data-modeling/external-datasources.mdx b/content/docs/data-modeling/external-datasources.mdx index 0200c5b9e1c..087bc144708 100644 --- a/content/docs/data-modeling/external-datasources.mdx +++ b/content/docs/data-modeling/external-datasources.mdx @@ -17,7 +17,9 @@ auto-connected, validated at boot, and queryable — with no application code.** This guide is about reading/writing the *live* remote tables (federation). To *copy* rows into ObjectStack-owned tables instead, see seed data / data sync. For the plain multi-datasource routing of **managed** databases ObjectStack owns, see -[Database Drivers](/docs/data-modeling/drivers). +[Database Drivers](/docs/data-modeling/drivers). When the remote is not a database a +built-in driver reaches — a REST API, a planning engine, a device gateway — serve the +object from your own plugin driver instead: [Virtual objects](/docs/data-modeling/virtual-objects). ## 1. Declare the datasource @@ -305,6 +307,8 @@ default allows all connects (subject to the gating above). - [Database Drivers](/docs/data-modeling/drivers) — managed multi-datasource routing, [read-only via database privileges](/docs/data-modeling/drivers#read-only-grant-it-at-the-database-not-in-metadata), and [why the platform does not route read replicas](/docs/data-modeling/drivers#read-replicas-the-platform-does-not-route). +- [Virtual objects](/docs/data-modeling/virtual-objects) — the same binding served by + a plugin driver you write, for a remote that is not a database. - [Datasource reference](/docs/references/data/datasource) — every `defineDatasource` field. - The `examples/app-showcase` `showcase_external` datasource — a runnable end-to-end demo. - ADR-0015 (federation spec) and ADR-0062 (external-datasource runtime). diff --git a/content/docs/data-modeling/index.mdx b/content/docs/data-modeling/index.mdx index 01951ef07da..4fe1afd22a6 100644 --- a/content/docs/data-modeling/index.mdx +++ b/content/docs/data-modeling/index.mdx @@ -60,6 +60,7 @@ That one definition is enough to get a persisted table, CRUD + query endpoints, + diff --git a/content/docs/data-modeling/meta.json b/content/docs/data-modeling/meta.json index 0a2e03a6616..dd135dad62c 100644 --- a/content/docs/data-modeling/meta.json +++ b/content/docs/data-modeling/meta.json @@ -17,6 +17,7 @@ "import-mappings", "external-datasources", "drivers", + "virtual-objects", "analytics" ] } diff --git a/content/docs/data-modeling/virtual-objects.mdx b/content/docs/data-modeling/virtual-objects.mdx new file mode 100644 index 00000000000..6af687b6fb1 --- /dev/null +++ b/content/docs/data-modeling/virtual-objects.mdx @@ -0,0 +1,541 @@ +--- +title: "Virtual objects — rows from your server, shown in any view" +navTitle: Virtual Objects (Plugin Driver) +description: Serve an object's rows from your own server through a plugin driver, so grids, gantts, forms, permissions, formulas and hooks work over data ObjectStack never stores. +--- + +A **virtual object** is an ordinary object whose rows your own code answers on every +read. ObjectStack stores none of them: a **plugin driver** — a class implementing the +driver contract, published by a plugin — receives each query and asks the system that +really holds the data. Every view, form, permission set, formula and hook works on it the +way it works on a stored object, and it takes no new metadata at all: a datasource with +`schemaMode: 'external'` and an object bound to it, the same two declarations +[federation](/docs/data-modeling/external-datasources) uses. The difference is who +answers: there, a built-in SQL driver reads a remote database; here, your driver reads +whatever you put behind it — a REST API, a planning engine, a device gateway. + +Everything on this page is what the platform's reference test boots and asserts +(`packages/runtime/src/plugin-driver-external-object.spike.test.ts`, with the gantt half +in `ui/packages/plugin-gantt/src/ObjectGantt.pluginDriverTree.test.tsx`). Read them when +this page and the runtime seem to disagree. + +## Virtual, or a real object? + +Make it virtual when a copy would be **wrong**, and real when a copy is merely +**convenient**: + +| The data is… | Choose | Why | +| :--- | :--- | :--- | +| **Computed** by the remote when asked — a plan, a price, an availability | virtual | a stored copy is yesterday's answer to today's question | +| **Must not land** — a system of record you may not copy (residency, licence, audit, personal data) | virtual | no copy exists to leak, drift or fall out of retention | +| **Real-time** — state that changes faster than any sync could follow | virtual | the row is read at the moment the page is drawn | +| **Slow-changing reference data** — codes, catalogues, registries that change monthly | a real object fed by a [scheduled pull](/docs/automation/jobs#pulling-a-mapping) | indexes, every query operator, search, reports, and pages that keep working while the remote is down | + +If the remote is a SQL database you can reach, you need no driver of your own — use a +built-in one through [External datasources](/docs/data-modeling/external-datasources). + +A virtual object pays for its freshness on every request: each list read is a round +trip to the remote, and the remote's query power is the object's query power. That is +the trade the table above decides. + +## The recipe + +The worked example is a port's berth plan: the terminal's scheduling system owns the +plan and its dependencies, planners reschedule slots on a gantt, a locked plan cannot be +moved, and only a slot's own planner may move it. Five pieces, the first two code and the +rest metadata. + +### 1. The driver + +The driver translates the engine's query into a request the remote understands, and +refuses what it cannot translate. The transport — your HTTP client — is passed in, which +is also what makes the driver testable ([below](#pin-the-shapes-your-driver-receives)). + +{/* os:check */} +```typescript +import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts'; + +type Row = Record; + +/** One condition your remote API can run. Grow it only as far as the API does. */ +export type RemoteCondition = { field: string; op: 'eq' | 'ne' | 'in'; value: unknown }; + +export interface RemoteListRequest { + filter: RemoteCondition[]; + sort: Array<{ field: string; desc: boolean }>; + limit?: number; + offset?: number; + fields?: string[]; +} + +/** The remote system, as your driver sees it. Back it with your HTTP client. */ +export interface PortScheduleTransport { + list(resource: string, request: RemoteListRequest): Promise; + count(resource: string, filter: RemoteCondition[]): Promise; + patch(resource: string, id: string, changes: Row): Promise; +} + +/** + * Per object: the remote resource, and the columns the remote really has. + * The driver is handed the OBJECT name, never `external.remoteName`. + */ +const RESOURCES: Record; writable: ReadonlySet }> = { + berth_slot: { + resource: 'slots', + columns: new Set(['id', 'name', 'parent', 'start_date', 'end_date', 'depends_on', 'planner', 'plan_locked', 'archived', 'plan_note']), + writable: new Set(['start_date', 'end_date', 'plan_note']), + }, +}; + +const OPERATORS: Record = { $eq: 'eq', $ne: 'ne', $in: 'in' }; + +function refuse(what: string): never { + throw new Error(`port_schedule driver: the remote cannot run ${what}`); +} + +function resourceOf(object: string) { + return RESOURCES[object] ?? refuse(`object '${object}' (no remote resource is mapped)`); +} + +/** Translate the whole `where`, or refuse. Never drop a condition. */ +function toFilter(object: string, where: unknown, out: RemoteCondition[] = []): RemoteCondition[] { + if (where === undefined || where === null) return out; + if (typeof where !== 'object' || Array.isArray(where)) refuse(`a where of shape ${JSON.stringify(where)}`); + const { columns } = resourceOf(object); + for (const [key, cond] of Object.entries(where as Row)) { + if (key === '$and') { + for (const part of cond as unknown[]) toFilter(object, part, out); + continue; + } + if (key.startsWith('$')) refuse(`the combinator ${key}`); + if (!columns.has(key)) refuse(`a filter on '${key}', which ${object} does not carry`); + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + for (const [op, value] of Object.entries(cond as Row)) { + out.push({ field: key, op: OPERATORS[op] ?? refuse(`the operator ${op} on '${key}'`), value }); + } + continue; + } + out.push({ field: key, op: 'eq', value: cond }); + } + return out; +} + +function toListRequest(object: string, query: DriverQuery): RemoteListRequest { + const request: RemoteListRequest = { + filter: toFilter(object, query.where), + sort: (query.orderBy ?? []).map((s) => ({ field: s.field, desc: s.order === 'desc' })), + }; + if (typeof query.limit === 'number') request.limit = query.limit; + if (typeof query.offset === 'number') request.offset = query.offset; + if (Array.isArray(query.fields)) request.fields = ['id', ...query.fields.filter((f): f is string => typeof f === 'string' && f !== 'id')]; + return request; +} + +const notSupported = (what: string) => async (): Promise => refuse(what); + +export function createPortScheduleDriver(transport: PortScheduleTransport): IDataDriver { + return { + name: 'port_schedule', // = the datasource name; the plugin registers `driver.port_schedule` + version: '1.0.0', + supports: { transactionsUnsupported: true }, + + async connect() { /* open a session, check the remote answers; throw to refuse the boot */ }, + async disconnect() {}, + async checkHealth() { return true; }, + + async find(object, query) { + return transport.list(resourceOf(object).resource, toListRequest(object, query)); + }, + async findOne(object, query) { + const rows = await transport.list(resourceOf(object).resource, { ...toListRequest(object, query), limit: 1 }); + return rows[0] ?? null; + }, + async count(object, query) { + return transport.count(resourceOf(object).resource, toFilter(object, query?.where)); + }, + async update(object, id, data) { + const { resource, writable } = resourceOf(object); + // The engine adds `id`, `updated_at` and `updated_by` to the patch; send + // only what the remote owns. + const changes = Object.fromEntries(Object.entries(data).filter(([k]) => writable.has(k))); + return transport.patch(resource, String(id), changes); + }, + + // Not offered by this remote: refuse by name, never pretend. + execute: notSupported('execute'), + create: notSupported('create'), + upsert: notSupported('upsert'), + delete: notSupported('delete'), + bulkCreate: notSupported('bulkCreate'), + bulkUpdate: notSupported('bulkUpdate'), + bulkDelete: notSupported('bulkDelete'), + beginTransaction: notSupported('beginTransaction'), + commit: notSupported('commit'), + rollback: notSupported('rollback'), + dropTable: notSupported('dropTable'), + async syncSchema() { /* external: the platform runs no DDL; nothing to do */ }, + }; +} +``` + +**A driver cannot opt out of correctness.** The rules it keeps: + +- **Apply every condition, sort and page it receives.** The engine hands the driver + the view's filter, the user's search, the sort and the page size, and serves whatever + comes back as the answer. A condition the driver drops widens the result silently — a + user sees rows the filter excluded, and nothing on the page says so. An engine-side + floor for drivers that cannot push a condition down is planned + ([#27277](https://github.com/objectstack-ai/objectstack/issues/27277)); until it lands + the whole obligation is the driver's. +- **Refuse by name what the remote cannot run** — an operator, a combinator, a column it + does not carry — and fail when the remote fails. Throw; ⛔ never return an empty + array, which reads exactly like "no rows match". A thrown driver error answers the + caller `500 INTERNAL_ERROR` today; a dedicated refusal for driver failures is planned + with the floor above. +- **Send the remote only the columns it owns.** `update` receives the patch plus any + field a hook stamped, plus `id`, `updated_at` and `updated_by`, which the engine adds on + its own and no remote column declares. +- **Declare `supports.transactionsUnsupported: true`** for a transport with no + transactions (any REST API): the engine reads the bit before it reaches for one. +- **Throw from `connect()` to refuse the boot** when the remote is unusable — the + operator then reads your message instead of a failure per request + ([Writing a driver](/docs/data-modeling/drivers#writing-a-driver-connect-is-where-you-refuse-to-start)). + +### 2. The plugin + +A plugin publishes the driver as the service `driver.` during `init`. That is the +whole server half of the integration. + +```typescript +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { IDataDriver } from '@objectstack/spec/contracts'; + +export function portScheduleDriverPlugin(driver: IDataDriver): Plugin { + return { + name: 'com.example.driver-port-schedule', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService(`driver.${driver.name}`, driver); + }, + }; +} +``` + +Mount it in the stack's `plugins` like any other plugin +([Register with the kernel](/docs/plugins/development#step-6-register-with-the-kernel)): +`plugins: [portScheduleDriverPlugin(createPortScheduleDriver(myTransport))]`. Register the +service in `init`, not `start`: the engine adopts `driver.*` services, and the datasource +connect step looks the driver up, once every plugin has initialised. + +### 3. The datasource, the object, the hook, the views + +{/* os:check */} +```typescript +import { defineStack, defineView } from '@objectstack/spec'; + +const BerthViews = defineView({ + list: { + label: 'All slots', + type: 'grid', + data: { provider: 'object', object: 'berth_slot' }, + columns: ['name', 'start_date', 'end_date'], + }, + listViews: { + plan: { + label: 'Berth plan', + type: 'gantt', + data: { provider: 'object', object: 'berth_slot' }, + columns: ['name'], + filter: [{ field: 'archived', operator: 'equals', value: false }], + sort: [{ field: 'start_date', order: 'asc' }], + gantt: { + startDateField: 'start_date', + endDateField: 'end_date', + titleField: 'name', + parentField: 'parent', + lockField: 'locked', + dependenciesField: 'depends_on', + }, + }, + }, +}); + +export default defineStack({ + manifest: { id: 'com.example.port', name: 'Port operations', version: '1.0.0', type: 'app' }, + datasources: [ + { + name: 'port_schedule', + label: 'Terminal schedule (plugin driver)', + driver: 'port_schedule', // the service suffix: the plugin published `driver.port_schedule` + schemaMode: 'external', + config: {}, // the driver was built by your code; nothing to configure here + external: { + allowWrites: true, + // A driver without `introspectSchema` cannot be validated at boot; + // `checkOnBoot: false` is what lets it come up. + validation: { onMismatch: 'fail', checkOnBoot: false }, + }, + }, + ], + objects: [ + { + name: 'berth_slot', + label: 'Berth slot', + datasource: 'port_schedule', + external: { remoteName: 'slots', writable: true }, + // Remote rows carry no `owner_id` / `created_by`: who may edit is the + // hook's decision. See "Choose the sharing model on purpose" below. + sharingModel: 'public_read_write', + fields: { + name: { type: 'text', label: 'Name' }, + parent: { type: 'lookup', reference: 'berth_slot', label: 'Parent' }, + start_date: { type: 'date', label: 'Start' }, + end_date: { type: 'date', label: 'End' }, + depends_on: { type: 'text', label: 'Depends on' }, + planner: { type: 'text', label: 'Planner' }, + plan_locked: { type: 'boolean', label: 'Plan locked' }, + archived: { type: 'boolean', label: 'Archived' }, + plan_note: { type: 'text', label: 'Plan note' }, + // Per caller: locked once the plan is, and for anyone but the slot's planner. + locked: { + type: 'formula', + label: 'Locked', + returnType: 'boolean', + expression: 'record.plan_locked || record.planner != current_user.id', + }, + }, + }, + ], + views: [BerthViews], + hooks: [ + { + name: 'berth_slot_plan_guard', + object: 'berth_slot', + events: ['beforeUpdate'], + refusals: { + PLAN_LOCKED: { message: 'This plan is locked; unlock it before rescheduling.', status: 409 }, + NOT_PLANNER: { message: 'Only the planner of this slot may reschedule it.', status: 403 }, + }, + body: { + language: 'js', + source: + "if (ctx.previous && ctx.previous.plan_locked === true) throw Object.assign(new Error(), { code: 'PLAN_LOCKED' });\n" + + "if (ctx.previous && ctx.previous.planner !== ctx.user?.id) throw Object.assign(new Error(), { code: 'NOT_PLANNER' });\n" + + "ctx.input.plan_note = 'checked';\n", + capabilities: [], + timeoutMs: 5000, + }, + }, + ], + permissions: [ + { + name: 'berth_planner', + label: 'Berth planner', + objects: { berth_slot: { allowRead: true, allowEdit: true } }, + }, + ], +}); +``` + +What each piece does on a plugin driver: + +- **The datasource names the driver by its service suffix.** Keep four names equal: + the driver's `name`, the service suffix, the datasource `name` and its `driver`. The + connect step finds the driver already registered and leaves it alone — it builds + nothing and connects it once. +- **Writes are a double opt-in**, exactly as for federation: + `external.allowWrites` on the datasource and `external.writable` on the object + ([Writes](/docs/data-modeling/external-datasources#5-writes-double-opt-in)). +- **The formula is the UI's lock; the hook is the rule.** `locked` evaluates per caller + on every read — the planner reads `false` on an open slot, an administrator `true` — and + the gantt offers no drag grips on a locked bar. A PATCH does not consult a formula, so + the `beforeUpdate` hook enforces the same rule: its refusal answers with the declared + message and status, and the driver is never asked to write. It reads the pre-image + (`ctx.previous`) through your driver, and may stamp fields (`plan_note`) that then reach + `update`. See [Before hook](/docs/automation/hooks#before-hook) and + [Hook bodies](/docs/automation/hook-bodies). +- **Permission sets apply as on any object.** A caller whose sets grant nothing on + `berth_slot` is refused `403` on a list read before your driver is asked anything. On a + PATCH the write door loads the record first, so the remote sees one by-id read for a + caller it then refuses. +- **The gantt is an ordinary list view** ([Gantt](/docs/ui/views#gantt)): `parentField` + builds the summary-and-children tree, `dependenciesField` draws the links, and + `lockField` reads the per-caller formula. A drag on an unlocked bar issues the + `PATCH /data/berth_slot/:id` above. + + +On the default sharing model, **every member edit is refused** (`403`), even for a +member whose permission set grants `allowEdit`. Before a write, the platform's ownership +checks ask the remote for `owner_id` and for `created_by` equal to the caller — columns a +remote row does not carry — so every row reads as someone else's +([#27332](https://github.com/objectstack-ai/objectstack/issues/27332)). Declare +`sharingModel: 'public_read_write'` and decide who may edit in `beforeUpdate`, as above. +Serving an owner column the checks can read is the other way out; the reference test +does not cover it, so pin it in your own test if you take it. + + +### Credentials + +⛔ Never put a credential in the stack — not in `config`, not in a hook body, not in an +object. On a plugin driver, also know what `external.credentialsRef` +([Credentials](/docs/data-modeling/external-datasources#4-credentials)) does **not** do: +it is resolved when the platform builds the driver from the datasource, and a driver your +plugin registered is already built, so the connect step skips it before credential +resolution. A `credentialsRef` on this datasource is not resolved, not handed to your +driver, and does not fail the boot when no secret store is configured. A plugin is +configured by the host that composes it, through the options it passes to the plugin; +keep the credential in the host's secret handling and out of metadata and the +repository. + +## What your driver receives + +Measured on the reference test, request by request: + +| The platform does | Your driver is called with | +| :--- | :--- | +| Boots | `connect()` once. No `syncSchema`, no `introspectSchema` (`checkOnBoot: false`). | +| Serves a list (`GET /data/berth_slot` with the view's filter, sort and page size) | `find(object, { where, orderBy, limit })`, then `count(object, { where })` for the total | +| Serves a record (`GET /data/berth_slot/:id`) | `findOne(object, { where: { id }, limit: 1 })` | +| Writes (`PATCH /data/berth_slot/:id`) | several by-id reads (the pre-image for hooks and checks), then `update(object, id, patch)` with the extra keys above | +| Refuses a caller with no grant on a list read | nothing | + +Two details catch every first driver: + +- **`object` is the object name** (`berth_slot`), never `external.remoteName` (`slots`). + The mapping from object to remote resource is the driver's. +- **`object` also rides inside the query** (`{ where, …, object: 'berth_slot' }`) + although `DriverQuery` omits it from its type. Read the object name from argument one; + do not forward the query object to the remote as-is. + +The filter arrives in the engine's `where` shape: equality as a bare value +(`{ archived: false }`), operators as `{ field: { $op: value } }`, and combinators such +as `$and`. The console lowers the view's +`[{ field: 'archived', operator: 'equals', value: false }]` to +`?filter=["archived","=",false]`, which arrives as `{ archived: false }`. + +## Pin the shapes your driver receives + +A driver is correct when, for every query shape the engine sends, the remote is asked for +exactly the right thing — and asked for nothing when the driver cannot honour the shape. +Pin that in a test of the driver alone, with a recording transport in place of the +network. The shapes below are the ones the reference test measured; add one for every +view filter your app ships. + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { createPortScheduleDriver, type PortScheduleTransport } from './port-schedule-driver'; + +/** A transport that records what the remote is asked, and answers nothing in particular. */ +function recordingTransport() { + return { + list: vi.fn(async () => [{ id: 's_1', name: 'Berth 4 — MSC Aurora' }]), + count: vi.fn(async () => 1), + patch: vi.fn(async (_resource: string, id: string, changes: Record) => ({ id, ...changes })), + } satisfies PortScheduleTransport; +} + +describe('port_schedule driver — the shapes the engine sends, and what the remote is asked', () => { + it('a list view read: every filter, the sort and the page size reach the remote', async () => { + const remote = recordingTransport(); + const driver = createPortScheduleDriver(remote); + // The DriverQuery a list view with a filter, a sort and a page size produces. + // `object` rides inside it although the type omits it. + await driver.find('berth_slot', { + where: { archived: false }, + orderBy: [{ field: 'start_date', order: 'asc' }], + limit: 50, + object: 'berth_slot', + } as never); + expect(remote.list).toHaveBeenCalledWith('slots', { + filter: [{ field: 'archived', op: 'eq', value: false }], + sort: [{ field: 'start_date', desc: false }], + limit: 50, + }); + }); + + it('the total: count carries the same filter', async () => { + const remote = recordingTransport(); + await createPortScheduleDriver(remote).count('berth_slot', { object: 'berth_slot', where: { archived: false } } as never); + expect(remote.count).toHaveBeenCalledWith('slots', [{ field: 'archived', op: 'eq', value: false }]); + }); + + it('a by-id read (record page, pre-image for a hook) asks for one row', async () => { + const remote = recordingTransport(); + const row = await createPortScheduleDriver(remote).findOne('berth_slot', { where: { id: 's_1' }, object: 'berth_slot', limit: 1 } as never); + expect(remote.list).toHaveBeenCalledWith('slots', { filter: [{ field: 'id', op: 'eq', value: 's_1' }], sort: [], limit: 1 }); + expect(row).toMatchObject({ id: 's_1' }); + }); + + it('an update forwards only the columns the remote owns', async () => { + const remote = recordingTransport(); + // The patch, the hook's stamp, and what the engine adds on its own. + await createPortScheduleDriver(remote).update('berth_slot', 's_1', { + start_date: '2026-11-03', end_date: '2026-11-07', plan_note: 'checked', + id: 's_1', updated_at: '2026-11-01T08:00:00.000Z', updated_by: 'usr_planner', + }); + expect(remote.patch).toHaveBeenCalledWith('slots', 's_1', { start_date: '2026-11-03', end_date: '2026-11-07', plan_note: 'checked' }); + }); + + it('a condition the remote cannot run is refused by name — never dropped, never an empty answer', async () => { + const remote = recordingTransport(); + const driver = createPortScheduleDriver(remote); + await expect(driver.find('berth_slot', { where: { $or: [{ archived: false }, { planner: 'u1' }] } } as never)).rejects.toThrow(/\$or/); + await expect(driver.find('berth_slot', { where: { start_date: { $gte: '2026-11-01' } } } as never)).rejects.toThrow(/\$gte/); + // The default sharing model asks for ownership columns no remote row carries. + await expect(driver.findOne('berth_slot', { where: { $and: [{ id: 's_1' }, { created_by: 'u1' }] } } as never)).rejects.toThrow(/created_by/); + expect(remote.list).not.toHaveBeenCalled(); + }); +}); +``` + +When the remote gains an operator, the refusal case is the one that changes: move the +operator from "refused" to a shape case, then teach `OPERATORS` the mapping. To see the +same shapes arrive through a booted stack — HTTP door, permissions, formula, hook — +start from the reference test named at the top of this page. + +## Five business examples + +**Port berth scheduling — computed, real-time.** The worked example above. The +terminal's planning system recomputes the plan as vessels slip; the platform is the +plan's view and its edit door. A gantt over the tree, a per-caller lock formula, a +`beforeUpdate` guard, writes through `update` only. + +**SAP sales orders — must not land.** The ERP is the system of record, and orders may not +be copied out of it. The driver reads orders through the ERP's own query API and writes +the few fields users may change through its update API; the ERP's own validation answers +a write it rejects, so let that error reach the caller rather than retrying around it. +Refuse `create` and `delete` until the integration deliberately offers them, and keep +the object's `writable` and the datasource's `allowWrites` as narrow as the API. + +**HR directory — must not land.** People's records stay in the HR system, so a leaver +disappears from every page the moment HR removes them, with no copy left to purge. Read +only: leave `allowWrites` off, grant read by permission set, and choose `public_read` — +an owner-keyed sharing model judges records by ownership columns an HR row does not +carry (the callout above). + +**Equipment live status — real-time.** Cranes, reefers, gates: the state a device +reports now, read from the gateway when the page is drawn; a copy would be stale before +it landed. Read only, small pages, and a `find` that throws when the gateway does not +answer, so a dead gateway reads as an error and never as "no alarms". + +**Counter-example: vessel and port codes — a real object.** UN/LOCODE port codes, vessel +registries, cost centres change monthly and are read by every page, filtered, joined and +reported on. Make them a real object filled by a scheduled pull — a `mapping` whose +`connectorSource` names the API, and a `job` whose `pull` runs it +([Pulling a mapping](/docs/automation/jobs#pulling-a-mapping)) — and they get indexes, +every query operator and search, and stay readable when the source is down. There is no +separate data-sync metadata type: a sync is defined on its target, as that mapping. + +## See also + +- [External datasources](/docs/data-modeling/external-datasources) — the datasource and + `external` binding this page builds on, federation over SQL, the write gates and + credentials. +- [Database drivers](/docs/data-modeling/drivers) — the built-in drivers and the + driver-startup contract. +- [Formulas](/docs/data-modeling/formulas) — `current_user` and the formula bindings. +- [Hooks](/docs/automation/hooks) and [Hook bodies](/docs/automation/hook-bodies) — the + `beforeUpdate` guard. +- [Views](/docs/ui/views) — the gantt and every other list view. +- ADR-0015 (federation spec) — the plugin driver is its Tier A: a driver, zero protocol + change. diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json index b80506ebf1e..810521ac7d0 100644 --- a/scripts/docs-audit/handwritten-docs.json +++ b/scripts/docs-audit/handwritten-docs.json @@ -92,6 +92,7 @@ "content/docs/data-modeling/seed-data.mdx", "content/docs/data-modeling/validation-rules.mdx", "content/docs/data-modeling/validation.mdx", + "content/docs/data-modeling/virtual-objects.mdx", "content/docs/deployment/app-dependencies.mdx", "content/docs/deployment/backup-restore.mdx", "content/docs/deployment/cli.mdx",