Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/verify-skip-external-readonly.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions examples/app-showcase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
23 changes: 22 additions & 1 deletion examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -124,6 +127,13 @@ export default defineStack({
// database at `<project>/.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],
Expand All @@ -137,7 +147,7 @@ export default defineStack({
},

// Data
objects: Object.values(objects),
objects: [...Object.values(objects), ExternalCustomer, ExternalOrder],

// UI
apps: [ShowcaseApp],
Expand Down Expand Up @@ -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<void> => {
await setupShowcaseExternalDatasource(ctx as Parameters<typeof setupShowcaseExternalDatasource>[0]);
};
3 changes: 2 additions & 1 deletion examples/app-showcase/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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:*"
},
Expand Down
113 changes: 113 additions & 0 deletions examples/app-showcase/src/datasources/external-fixture.ts
Original file line number Diff line number Diff line change
@@ -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<void> };
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<void> {
// 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<void>;
disconnect: () => Promise<void>;
initObjects: (objs: unknown[]) => Promise<void>;
count: (object: string, query: unknown) => Promise<number>;
bulkCreate: (object: string, rows: unknown[]) => Promise<unknown>;
};
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<void> };
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)');
}
Original file line number Diff line number Diff line change
@@ -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,
// });
26 changes: 26 additions & 0 deletions examples/app-showcase/src/objects/external/customer.object.ts
Original file line number Diff line number Diff line change
@@ -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 }),
},
});
4 changes: 4 additions & 0 deletions examples/app-showcase/src/objects/external/index.ts
Original file line number Diff line number Diff line change
@@ -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';
24 changes: 24 additions & 0 deletions examples/app-showcase/src/objects/external/order.object.ts
Original file line number Diff line number Diff line change
@@ -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' }),
},
});
33 changes: 33 additions & 0 deletions examples/app-showcase/test/external-datasource.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading