From 9e245d3b63966db65beec5f18bba005ab8dc282c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:07:08 +0800 Subject: [PATCH 1/2] feat(app-showcase): add an end-to-end external datasource + federated object example (#2111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A code-defined EXTERNAL datasource (ADR-0015) over a second, read-only SQLite file — no external server required, so `os dev` just works. Demonstrates the full federation path the runtime datasource-admin UI exposes. - src/datasources/showcase-external.datasource.ts — schemaMode:'external', read-only, onMismatch:'warn' (a fixture hiccup never bricks the showcase boot). Includes a commented Postgres+secret variant for the masked-secret widget. - src/objects/external/{customer,order}.object.ts — federated objects whose names (showcase_ext_customer/order) deliberately differ from their remote tables (customers/orders), bound via external.remoteName — exercises the remote-table remap fixed in the companion driver change. - src/datasources/external-fixture.ts — the onEnable hook: idempotently provisions the fixture sqlite (tables + seed rows), registers a live read-only driver under the datasource name, and registers the federated objects' read metadata (DDL-free, via ql.syncObjectSchema). - objectstack.config.ts wires the datasource + objects + onEnable; package.json adds @objectstack/driver-sql; README documents the flow and the Setup → Datasources sync UI; test/external-datasource.test.ts smoke-asserts the binding. Depends on the ADR-0015 federation read-path fix (honoring external.remoteName). Co-Authored-By: Claude Opus 4.8 --- examples/app-showcase/README.md | 28 +++++ examples/app-showcase/objectstack.config.ts | 23 +++- examples/app-showcase/package.json | 3 +- .../src/datasources/external-fixture.ts | 113 ++++++++++++++++++ .../showcase-external.datasource.ts | 55 +++++++++ .../src/objects/external/customer.object.ts | 26 ++++ .../src/objects/external/index.ts | 4 + .../src/objects/external/order.object.ts | 24 ++++ .../test/external-datasource.test.ts | 33 +++++ pnpm-lock.yaml | 3 + 10 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 examples/app-showcase/src/datasources/external-fixture.ts create mode 100644 examples/app-showcase/src/datasources/showcase-external.datasource.ts create mode 100644 examples/app-showcase/src/objects/external/customer.object.ts create mode 100644 examples/app-showcase/src/objects/external/index.ts create mode 100644 examples/app-showcase/src/objects/external/order.object.ts create mode 100644 examples/app-showcase/test/external-datasource.test.ts diff --git a/examples/app-showcase/README.md b/examples/app-showcase/README.md index cb3db50ea6..d14f2c59aa 100644 --- a/examples/app-showcase/README.md +++ b/examples/app-showcase/README.md @@ -122,3 +122,31 @@ app-showcase/ When you add a new variant to the platform, the coverage test will go red and point at the gap. Add a field/view/widget that uses it, reference it in `COVERAGE`, and the test goes green again. + +## External datasource federation (ADR-0015) + +The showcase ships a **code-defined external datasource** that demonstrates the +full federation path with **no external server** — `os dev` just works. + +- `src/datasources/showcase-external.datasource.ts` — a second, read-only SQLite + database (`schemaMode: 'external'`), separate from the managed standalone DB. +- `src/objects/external/{customer,order}.object.ts` — federated objects + (`showcase_ext_customer`, `showcase_ext_order`) bound to the remote tables + `customers` / `orders` via `external.remoteName`. The object names deliberately + differ from the table names to show the remote-table remap. +- `src/datasources/external-fixture.ts` — the stack's `onEnable` hook. It + idempotently provisions the fixture SQLite file (tables + seed rows), registers + a live read-only driver under the datasource name, and registers the federated + objects' read metadata. + +**See it:** + +- `GET /api/v1/datasources` and `GET /api/v1/meta/datasource` include + `showcase_external`. +- `GET /api/v1/data/showcase_ext_customer` returns the seeded rows — queried live + from the external table `customers`. +- Sign in as a platform admin → **Setup → Integrations → Datasources** to see it + listed, and run the runtime **Sync objects** wizard against it. + +To showcase a masked **secret** credential field, uncomment the Postgres variant +in `showcase-external.datasource.ts` and point it at a real warehouse. diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 55d84d1a5f..799821c5bf 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -12,6 +12,9 @@ import { } from '@objectstack/cloud-connection'; import * as objects from './src/objects/index.js'; +import { ShowcaseExternalDatasource } from './src/datasources/showcase-external.datasource.js'; +import { ExternalCustomer, ExternalOrder } from './src/objects/external/index.js'; +import { setupShowcaseExternalDatasource } from './src/datasources/external-fixture.js'; import { TaskViews, ProjectViews, InquiryViews } from './src/views/index.js'; import { ShowcaseApp } from './src/apps/index.js'; import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js'; @@ -124,6 +127,13 @@ export default defineStack({ // database at `/.objectstack/data/standalone.db`, so data and // AI-authored metadata survive restarts (a `:memory:` datasource would wipe // everything on every restart, which makes local app-building unusable). + // + // External-datasource federation demo (ADR-0015): a second, read-only SQLite + // file declared as a code-defined external datasource. It appears in + // Setup → Datasources and its federated objects (below) are queryable via + // REST. The fixture file + live driver are wired by `onEnable` (bottom of + // this file). `os dev` needs no extra setup. + datasources: [ShowcaseExternalDatasource], // i18n translations: [ShowcaseTranslationBundle], @@ -137,7 +147,7 @@ export default defineStack({ }, // Data - objects: Object.values(objects), + objects: [...Object.values(objects), ExternalCustomer, ExternalOrder], // UI apps: [ShowcaseApp], @@ -172,3 +182,14 @@ export default defineStack({ // Seed data data: ShowcaseSeedData, }); + +/** + * Runtime wiring for the external-datasource federation demo (ADR-0015). + * + * Code (fixture provisioning + live external-driver registration) can't live in + * the declarative artifact, so it runs here. The AppPlugin invokes `onEnable` at + * boot (Phase 2), before the external-validation gate fires on `kernel:ready`. + */ +export const onEnable = async (ctx: unknown): Promise => { + await setupShowcaseExternalDatasource(ctx as Parameters[0]); +}; diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index f3a0fe7ffa..0d96c70c4b 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,7 +1,7 @@ { "name": "@objectstack/example-showcase", "version": "0.2.0", - "description": "Kitchen-sink showcase workspace — exercises every metadata type, every view type, every chart type, and the major end-to-end capability chains (security, automation, AI). Built for demonstration, debugging, and coverage-driven verification.", + "description": "Kitchen-sink showcase workspace \u2014 exercises every metadata type, every view type, every chart type, and the major end-to-end capability chains (security, automation, AI). Built for demonstration, debugging, and coverage-driven verification.", "license": "Apache-2.0", "private": true, "main": "./objectstack.config.ts", @@ -24,6 +24,7 @@ "@objectstack/cloud-connection": "workspace:*", "@objectstack/connector-rest": "workspace:*", "@objectstack/connector-slack": "workspace:*", + "@objectstack/driver-sql": "workspace:*", "@objectstack/runtime": "workspace:*", "@objectstack/spec": "workspace:*" }, diff --git a/examples/app-showcase/src/datasources/external-fixture.ts b/examples/app-showcase/src/datasources/external-fixture.ts new file mode 100644 index 0000000000..a33a91ae3d --- /dev/null +++ b/examples/app-showcase/src/datasources/external-fixture.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { SqlDriver } from '@objectstack/driver-sql'; + +/** + * Runtime wiring for the external-datasource federation demo (ADR-0015). + * + * This is CODE, so it can't live in the declarative artifact — it runs from the + * stack's `onEnable` hook, which the AppPlugin invokes at boot (Phase 2), before + * the external-validation gate fires on `kernel:ready` (Phase 3). + * + * It does three things, all zero-config so `os dev` "just works": + * 1. Idempotently provisions the "remote" SQLite file with `customers` / + * `orders` tables + a little seed data (a MANAGED driver — DDL allowed). + * 2. Registers a read-only `schemaMode: 'external'` driver for that same file + * under the datasource name `showcase_external`, so ObjectQL can route + * queries to it. (Declared datasources are surfaced in the metadata + * registry for Setup → Datasources, but a live driver must be registered + * for the federated objects to be queryable in standalone mode.) + * 3. Registers the federated objects' read metadata (DDL-free) so coercion + + * the remote-table mapping exist immediately. + */ + +// Same relative path as the datasource config — resolved against the project +// cwd by better-sqlite3. `connect()` creates the parent dir if missing. +const EXTERNAL_DB_FILE = '.objectstack/data/showcase_external.db'; + +const CUSTOMER_TABLE = { + name: 'customers', + fields: { + id: { type: 'text' }, + name: { type: 'text' }, + email: { type: 'text' }, + region: { type: 'text' }, + lifetime_value: { type: 'number' }, + }, +}; + +const ORDER_TABLE = { + name: 'orders', + fields: { + id: { type: 'text' }, + customer_id: { type: 'text' }, + amount: { type: 'number' }, + status: { type: 'text' }, + placed_on: { type: 'date' }, + }, +}; + +const CUSTOMER_ROWS = [ + { id: 'c1', name: 'Aurora Labs', email: 'ap@aurora.example', region: 'NA', lifetime_value: 480000 }, + { id: 'c2', name: 'Borealis GmbH', email: 'billing@borealis.example', region: 'EU', lifetime_value: 312000 }, + { id: 'c3', name: 'Cyan Pacific', email: 'accounts@cyan.example', region: 'APAC', lifetime_value: 95000 }, +]; + +const ORDER_ROWS = [ + { id: 'o1', customer_id: 'c1', amount: 12000, status: 'paid', placed_on: '2026-01-12' }, + { id: 'o2', customer_id: 'c1', amount: 8400, status: 'paid', placed_on: '2026-02-03' }, + { id: 'o3', customer_id: 'c2', amount: 21500, status: 'pending', placed_on: '2026-02-20' }, + { id: 'o4', customer_id: 'c3', amount: 3300, status: 'paid', placed_on: '2026-03-01' }, +]; + +/** Stack `onEnable` payload (subset we use). */ +interface OnEnableContext { + ql: { syncObjectSchema?: (name: string) => Promise }; + drivers: { register: (driver: unknown) => void }; + logger?: { info?: (msg: string, meta?: unknown) => void; warn?: (msg: string, meta?: unknown) => void }; +} + +export async function setupShowcaseExternalDatasource(ctx: OnEnableContext): Promise { + // 1. Provision the remote fixture with a MANAGED driver (DDL allowed). Idempotent. + const fixture = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: EXTERNAL_DB_FILE }, + useNullAsDefault: true, + }) as unknown as { + name: string; + connect: () => Promise; + disconnect: () => Promise; + initObjects: (objs: unknown[]) => Promise; + count: (object: string, query: unknown) => Promise; + bulkCreate: (object: string, rows: unknown[]) => Promise; + }; + fixture.name = 'showcase_external_fixture'; + await fixture.connect(); + try { + await fixture.initObjects([CUSTOMER_TABLE, ORDER_TABLE]); + if ((await fixture.count('customers', {})) === 0) { + await fixture.bulkCreate('customers', CUSTOMER_ROWS); + await fixture.bulkCreate('orders', ORDER_ROWS); + } + } finally { + await fixture.disconnect(); + } + + // 2. Register the read-only EXTERNAL driver under the datasource name. + const ext = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: EXTERNAL_DB_FILE }, + useNullAsDefault: true, + schemaMode: 'external', + } as never) as unknown as { name: string; connect: () => Promise }; + ext.name = 'showcase_external'; // MUST equal the datasource name (ObjectQL routes by driver name). + await ext.connect(); + ctx.drivers.register(ext); + + // 3. Register read metadata for the federated objects (DDL-free; ADR-0015). + // Needed because the driver is registered after the boot schema-sync. + await ctx.ql.syncObjectSchema?.('showcase_ext_customer'); + await ctx.ql.syncObjectSchema?.('showcase_ext_order'); + + ctx.logger?.info?.('[showcase] external datasource "showcase_external" ready — federation demo (ADR-0015)'); +} diff --git a/examples/app-showcase/src/datasources/showcase-external.datasource.ts b/examples/app-showcase/src/datasources/showcase-external.datasource.ts new file mode 100644 index 0000000000..9e40a22bd9 --- /dev/null +++ b/examples/app-showcase/src/datasources/showcase-external.datasource.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineDatasource } from '@objectstack/spec/data'; + +/** + * A code-defined EXTERNAL datasource (ADR-0015 federation) — a *second*, + * read-only SQLite file separate from the managed standalone DB. It exists so + * the showcase can demonstrate the full federation path with **no external + * server**: `os dev` provisions the fixture file at boot (see + * `external-fixture.ts`), then federated objects bound to its tables are + * queryable through the normal ObjectQL/REST surface. + * + * `schemaMode: 'external'` ⇒ ObjectStack never runs DDL here (it's a guest in a + * database it does not own). `onMismatch: 'warn'` keeps a fixture hiccup from + * bricking the whole showcase boot — the rest of the demo still loads. + * + * It also shows up in **Setup → Integrations → Datasources** and via + * `GET /api/v1/meta/datasource`, where an admin can run the runtime + * "Sync objects" wizard against it. + */ +export const ShowcaseExternalDatasource = defineDatasource({ + name: 'showcase_external', + label: 'External Analytics (SQLite)', + driver: 'sqlite', + schemaMode: 'external', + // Relative path → resolved against the project cwd by better-sqlite3, the + // same place the fixture writes it. Sits next to the managed standalone.db. + config: { filename: '.objectstack/data/showcase_external.db' }, + external: { + label: 'External Analytics DB — read-only federation demo (ADR-0015)', + allowWrites: false, + validation: { onMismatch: 'warn', checkOnBoot: true }, + }, + active: true, +}); + +// ── Optional secret demo (commented) ──────────────────────────────────────── +// A Postgres variant exercising a `secret` credential field. Uncomment and +// point at a real warehouse to see the MASKED secret widget in +// Setup → Datasources (objectui#1853). Left commented so the default example +// needs no external server. +// +// export const ShowcaseWarehouseDatasource = defineDatasource({ +// name: 'showcase_warehouse', +// label: 'Analytics Warehouse (Postgres)', +// driver: 'postgres', +// schemaMode: 'external', +// config: { host: 'localhost', port: 5432, database: 'analytics', user: 'readonly' }, +// external: { +// allowWrites: false, +// credentialsRef: 'secret:warehouse/password', +// validation: { onMismatch: 'fail', checkOnBoot: true }, +// }, +// active: false, +// }); diff --git a/examples/app-showcase/src/objects/external/customer.object.ts b/examples/app-showcase/src/objects/external/customer.object.ts new file mode 100644 index 0000000000..182766799d --- /dev/null +++ b/examples/app-showcase/src/objects/external/customer.object.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * A customer row federated LIVE from the external SQLite database + * (`showcase_external`). The object name (`showcase_ext_customer`) deliberately + * differs from the remote table (`customers`) — the binding is expressed by + * `external.remoteName`, exercising ADR-0015's remote-table remap on the read + * path. Read-only: the datasource sets `allowWrites: false`. + */ +export const ExternalCustomer = ObjectSchema.create({ + name: 'showcase_ext_customer', + label: 'External Customer', + pluralLabel: 'External Customers', + icon: 'database', + description: 'A customer federated from the external analytics DB (ADR-0015). Bound to remote table `customers` via external.remoteName.', + datasource: 'showcase_external', + external: { remoteName: 'customers' }, + fields: { + name: Field.text({ label: 'Name', searchable: true }), + email: Field.text({ label: 'Email' }), + region: Field.text({ label: 'Region' }), + lifetime_value: Field.currency({ label: 'Lifetime Value', scale: 2 }), + }, +}); diff --git a/examples/app-showcase/src/objects/external/index.ts b/examples/app-showcase/src/objects/external/index.ts new file mode 100644 index 0000000000..d307a6707d --- /dev/null +++ b/examples/app-showcase/src/objects/external/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export { ExternalCustomer } from './customer.object.js'; +export { ExternalOrder } from './order.object.js'; diff --git a/examples/app-showcase/src/objects/external/order.object.ts b/examples/app-showcase/src/objects/external/order.object.ts new file mode 100644 index 0000000000..e8e5f521ed --- /dev/null +++ b/examples/app-showcase/src/objects/external/order.object.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * An order row federated from the external SQLite database. Object name + * (`showcase_ext_order`) ≠ remote table (`orders`); bound via + * `external.remoteName`. Read-only. + */ +export const ExternalOrder = ObjectSchema.create({ + name: 'showcase_ext_order', + label: 'External Order', + pluralLabel: 'External Orders', + icon: 'shopping-cart', + description: 'An order federated from the external analytics DB (ADR-0015). Bound to remote table `orders` via external.remoteName.', + datasource: 'showcase_external', + external: { remoteName: 'orders' }, + fields: { + customer_id: Field.text({ label: 'Customer ID' }), + amount: Field.currency({ label: 'Amount', scale: 2 }), + status: Field.text({ label: 'Status' }), + placed_on: Field.date({ label: 'Placed On' }), + }, +}); diff --git a/examples/app-showcase/test/external-datasource.test.ts b/examples/app-showcase/test/external-datasource.test.ts new file mode 100644 index 0000000000..238ad97381 --- /dev/null +++ b/examples/app-showcase/test/external-datasource.test.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Smoke test for the external-datasource federation example (ADR-0015). + * Asserts the datasource + federated objects are declared correctly — the + * remote-table remap (`object.name !== external.remoteName`) is the whole point. + * The live read path is covered by the driver-level integration test + * (packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts). + */ + +import { describe, it, expect } from 'vitest'; +import { ShowcaseExternalDatasource } from '../src/datasources/showcase-external.datasource.js'; +import { ExternalCustomer, ExternalOrder } from '../src/objects/external/index.js'; + +describe('showcase external datasource (ADR-0015 federation)', () => { + it('is an external, read-only datasource that degrades gracefully', () => { + expect(ShowcaseExternalDatasource.name).toBe('showcase_external'); + expect(ShowcaseExternalDatasource.schemaMode).toBe('external'); + expect(ShowcaseExternalDatasource.external?.allowWrites).toBe(false); + // A fixture hiccup must not brick the whole showcase boot. + expect(ShowcaseExternalDatasource.external?.validation?.onMismatch).toBe('warn'); + }); + + it('federated objects bind to remote tables via remoteName (name != table)', () => { + expect(ExternalCustomer.datasource).toBe('showcase_external'); + expect(ExternalCustomer.external?.remoteName).toBe('customers'); + expect(ExternalCustomer.name).not.toBe(ExternalCustomer.external?.remoteName); + + expect(ExternalOrder.datasource).toBe('showcase_external'); + expect(ExternalOrder.external?.remoteName).toBe('orders'); + expect(ExternalOrder.name).not.toBe(ExternalOrder.external?.remoteName); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cc53ce647..7aa2784eeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: '@objectstack/connector-slack': specifier: workspace:* version: link:../../packages/connectors/connector-slack + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../../packages/plugins/driver-sql '@objectstack/runtime': specifier: workspace:* version: link:../../packages/runtime From 4c509cc2305395b375d100b20e48ab4813323543 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:56:09 +0800 Subject: [PATCH 2/2] fix(verify): skip read-only federated (external) objects in CRUD verification `objectstack verify` probe-inserts into every object; a read-only external object (ADR-0015) correctly rejects the insert via the write gate, which verify reported as a create-failed runtime failure (breaking the Dogfood Regression Gate once the showcase gained federated objects). deriveCrudCases now marks external objects that aren't fully write-opted-in (datasource.external.allowWrites && object.external.writable) as `blocked`/skipped. Adds derive.test.ts covering the skip + the write-opted-in and managed pass-through cases. Co-Authored-By: Claude Opus 4.8 --- .changeset/verify-skip-external-readonly.md | 13 ++++++ packages/verify/src/derive.test.ts | 45 +++++++++++++++++++++ packages/verify/src/derive.ts | 16 ++++++++ 3 files changed, 74 insertions(+) create mode 100644 .changeset/verify-skip-external-readonly.md create mode 100644 packages/verify/src/derive.test.ts diff --git a/.changeset/verify-skip-external-readonly.md b/.changeset/verify-skip-external-readonly.md new file mode 100644 index 0000000000..8206c9da60 --- /dev/null +++ b/.changeset/verify-skip-external-readonly.md @@ -0,0 +1,13 @@ +--- +"@objectstack/verify": patch +--- + +fix(verify): skip read-only federated (external) objects in CRUD verification. + +`objectstack verify` probe-inserts a record into every object. A federated object +on an external datasource is read-only unless BOTH the datasource and the object +opt into writes (ADR-0015 write gate), so that insert is correctly rejected — +which `verify` was reporting as a `create-failed` runtime failure. `deriveCrudCases` +now marks such objects `blocked` (skipped), matching the write gate's double +opt-in rule, so the dogfood gate stays honest while supporting external datasource +example apps. diff --git a/packages/verify/src/derive.test.ts b/packages/verify/src/derive.test.ts new file mode 100644 index 0000000000..b4c6c9000a --- /dev/null +++ b/packages/verify/src/derive.test.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { deriveCrudCases } from './derive'; + +describe('deriveCrudCases — federated (external) objects (ADR-0015)', () => { + it('blocks a read-only external object so verify never probe-inserts it', () => { + const config = { + datasources: [{ name: 'wh', schemaMode: 'external', external: { allowWrites: false } }], + objects: [ + { name: 'wh_order', datasource: 'wh', external: { remoteName: 'orders' }, fields: { amount: { type: 'number' } } }, + ], + }; + const c = deriveCrudCases(config).find((x) => x.object === 'wh_order'); + expect(c?.blocked).toMatch(/external read-only/); + }); + + it('blocks when the object opts in but the datasource does not', () => { + const config = { + datasources: [{ name: 'wh', schemaMode: 'external', external: { allowWrites: false } }], + objects: [ + { name: 'wh_order', datasource: 'wh', external: { remoteName: 'orders', writable: true }, fields: { amount: { type: 'number' } } }, + ], + }; + const c = deriveCrudCases(config).find((x) => x.object === 'wh_order'); + expect(c?.blocked).toMatch(/external read-only/); + }); + + it('does NOT block a fully write-opted-in external object (datasource + object)', () => { + const config = { + datasources: [{ name: 'wh', schemaMode: 'external', external: { allowWrites: true } }], + objects: [ + { name: 'wh_order', datasource: 'wh', external: { remoteName: 'orders', writable: true }, fields: { amount: { type: 'number' } } }, + ], + }; + const c = deriveCrudCases(config).find((x) => x.object === 'wh_order'); + expect(c?.blocked).toBeFalsy(); + }); + + it('leaves managed objects unaffected', () => { + const config = { objects: [{ name: 'task', fields: { title: { type: 'text' } } }] }; + const c = deriveCrudCases(config).find((x) => x.object === 'task'); + expect(c?.blocked).toBeFalsy(); + }); +}); diff --git a/packages/verify/src/derive.ts b/packages/verify/src/derive.ts index c50952c9d7..2fb96e3e80 100644 --- a/packages/verify/src/derive.ts +++ b/packages/verify/src/derive.ts @@ -129,6 +129,8 @@ export function deriveCrudCases(config: any): CrudCase[] { const objects: any[] = config?.objects ?? []; const byName = new Map(); for (const o of objects) if (o?.name) byName.set(o.name, o); + const dsByName = new Map(); + for (const ds of (config?.datasources ?? [])) if (ds?.name) dsByName.set(ds.name, ds); const drafts = new Map(); @@ -140,6 +142,20 @@ export function deriveCrudCases(config: any): CrudCase[] { name: obj.name, body: {}, asserts: [], skippedFields: [], relationalRefs: [], requiredTargets: [], }; + // Federated (external) objects are read-only unless BOTH the datasource and + // the object opt into writes (ADR-0015 write gate). The CRUD probe insert is + // correctly rejected by the gate for them, so mark the case blocked (skipped) + // rather than letting a guaranteed-rejected insert surface as a failure. + if (obj.external) { + const ds = obj.datasource ? dsByName.get(obj.datasource) : undefined; + const writable = ds?.external?.allowWrites === true && obj.external?.writable === true; + if (!writable) { + d.blocked = `external read-only object (federated datasource "${obj.datasource ?? 'default'}"; no inserts)`; + drafts.set(obj.name, d); + continue; + } + } + for (const [name, f] of Object.entries(fields)) { const type = String((f as any)?.type ?? '').toLowerCase(); const isRequired = !!(f as any)?.required;