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
9 changes: 9 additions & 0 deletions .changeset/dev-datasource-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@objectstack/core": patch
---

fix(runtime): surface code-defined datasources at `GET /api/v1/datasources` and `GET /api/v1/meta/datasource` on the standalone / host-config boot path (ADR-0015 §18, follow-up to #2111).

A datasource declared in `defineStack({ datasources: [...] })` (e.g. the showcase's `showcase_external`) is stamped `origin: 'code'` and registered by `AppPlugin` via `metadata.registerInMemory('datasource', …)` — gated on `typeof metadata.registerInMemory === 'function'`. On the standalone / host-config path (`os dev`/`serve` for a config whose `plugins` are already instantiated — `isHostConfig` true — so no `MetadataPlugin` loads) the `metadata` service is an in-memory fallback that implemented `register`/`list`/`get` but **not** `registerInMemory`. The guard was therefore false, AppPlugin silently skipped the registration, and the datasource was absent from both REST surfaces (and Setup → Integrations → Datasources) even though the boot banner counted it and its federated objects were queryable.

Both in-memory `metadata` fallbacks (`@objectstack/core`'s `createMemoryMetadata` and `@objectstack/plugin-dev`'s dev stub) now implement `registerInMemory` (synchronous, no persistence — identical to `register` for these in-memory stores, matching `MetadataManager`'s signature). The read paths (`metadata.list`, datasource-admin `listDatasources`, and `protocol.getMetaItems` which merges `metadata.list`) were already correct; this restores the write-side registration they depend on. It also makes stack-declared security metadata (`roles`/`permissions`/`sharingRules`/`policies`, registered through the same guard) listable on this path.
13 changes: 13 additions & 0 deletions packages/core/src/fallbacks/memory-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ export function createMemoryMetadata() {
async register(type: string, name: string, data: any): Promise<void> {
getTypeMap(type).set(name, data);
},
// Mirror MetadataManager.registerInMemory (synchronous, no persistence).
// AppPlugin gates code-defined-datasource / stack-RBAC registration on
// `typeof metadata.registerInMemory === 'function'` (it must register
// GitOps-managed artefacts *listably* but never persist them). Without this
// method the guard was false on the host-config / standalone boot path —
// where this fallback (not MetadataPlugin) provides the `metadata` service —
// so `defineStack({ datasources })` entries silently never reached the
// registry and were absent from GET /api/v1/datasources and
// GET /api/v1/meta/datasource (ADR-0015 §18). This store is already
// in-memory only, so registerInMemory and register share an implementation.
registerInMemory(type: string, name: string, data: any): void {
getTypeMap(type).set(name, data);
},
async get(type: string, name: string): Promise<any> {
return getTypeMap(type).get(name);
},
Expand Down
12 changes: 12 additions & 0 deletions packages/plugins/plugin-dev/src/dev-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ function createMetadataStub() {
store.get(type)!.set(nameOrDef, data);
}
},
// Mirror MetadataManager.registerInMemory — AppPlugin gates code-defined
// datasource / stack-RBAC registration on its presence (see
// packages/core/src/fallbacks/memory-metadata.ts).
registerInMemory(type: string, nameOrDef: string | Record<string, any>, data?: unknown) {
if (!store.has(type)) store.set(type, new Map());
if (typeof nameOrDef === 'object' && nameOrDef !== null) {
const key = nameOrDef.name ?? nameOrDef.id ?? 'unknown';
store.get(type)!.set(key, nameOrDef);
} else {
store.get(type)!.set(nameOrDef, data);
}
},
get(type: string, name: string) { return store.get(type)?.get(name); },
list(type: string) { return [...(store.get(type)?.values() ?? [])]; },
unregister(type: string, name: string) { store.get(type)?.delete(name); },
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@objectstack/driver-mongodb": "workspace:*"
},
"devDependencies": {
"@objectstack/service-datasource": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
},
Expand Down
94 changes: 94 additions & 0 deletions packages/runtime/src/datasource-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Reproduction + regression (ADR-0015 §18, follow-up from #2111): a code-defined
// external datasource declared in `defineStack({ datasources: [...] })` — stamped
// `origin: 'code'` at compile time — must be VISIBLE through the runtime metadata
// surfaces on the standalone / config-load (`os dev`/`serve`) path:
//
// • datasource-admin `listDatasources()` -> backs GET /api/v1/datasources
// • `protocol.getMetaItems({ type: 'datasource' })` -> backs GET /api/v1/meta/datasource
// • the `metadata` service's `list('datasource')` -> the source both of the above read
//
// AppPlugin registers code datasources via `metadata.registerInMemory('datasource', ...)`
// at start(). This boots the HOST-CONFIG shape (instantiated plugins, NO
// MetadataPlugin) so the kernel auto-injects its in-memory `metadata` fallback —
// the exact shape `examples/app-showcase` boots under `os dev` (its config carries
// instantiated connector plugins, so `isHostConfig` is true and the lightweight
// assembler runs instead of createStandaloneStack/MetadataPlugin).
//
// Before the fix, the fallback (packages/core/src/fallbacks/memory-metadata.ts)
// lacked `registerInMemory`, so AppPlugin's
// `typeof metadata?.registerInMemory === 'function'` guard was false and the
// datasource registration was skipped entirely — both surfaces returned [].

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Runtime } from './runtime.js';
import { DriverPlugin } from './driver-plugin.js';
import { AppPlugin } from './app-plugin.js';

// A minimal compiled artifact carrying ONE code-defined external datasource
// (origin stamped by `defineDatasource` at compile time) plus a single object.
const ARTIFACT = {
manifest: { id: 'com.test.ds-visibility', name: 'DS Visibility', version: '1.0.0' },
objects: [{ name: 'note', label: 'Note', fields: { title: { type: 'text' } } }],
datasources: [
{
name: 'showcase_external',
label: 'External Analytics (SQLite)',
driver: 'sqlite',
schemaMode: 'external',
origin: 'code',
config: { filename: ':memory:' },
external: { allowWrites: false },
active: true,
},
],
};

const BOOT_TIMEOUT = 60_000;

describe('code-defined datasource visibility (ADR-0015 §18)', () => {
let kernel: ReturnType<Runtime['getKernel']>;

beforeAll(async () => {
const { ObjectQLPlugin } = await import('@objectstack/objectql');
const { InMemoryDriver } = await import('@objectstack/driver-memory');
const { DatasourceAdminServicePlugin } = await import('@objectstack/service-datasource');

// Host-config shape: NO MetadataPlugin — the kernel auto-injects its
// in-memory `metadata` fallback (CORE_FALLBACK_FACTORIES.metadata).
const runtime = new Runtime({ cluster: false });
kernel = runtime.getKernel();
await kernel.use(new DriverPlugin(new InMemoryDriver()));
await kernel.use(new ObjectQLPlugin());
await kernel.use(new AppPlugin(ARTIFACT));
await kernel.use(new DatasourceAdminServicePlugin({}));
await kernel.bootstrap();
}, BOOT_TIMEOUT);

afterAll(async () => {
try { await (kernel as any)?.stop?.(); } catch { /* noop */ }
});

it('metadata.list("datasource") surfaces the code datasource (shared source)', async () => {
const metadata = kernel.getService<{ list(t: string): Promise<any[]> }>('metadata');
const list = await metadata.list('datasource');
expect(list.map((d) => d?.name)).toContain('showcase_external');
expect(list.find((d) => d?.name === 'showcase_external')?.origin).toBe('code');
});

it('GET /api/v1/datasources backing: datasource-admin.listDatasources() includes the code datasource', async () => {
const admin = kernel.getService<{ listDatasources(): Promise<any[]> }>('datasource-admin');
const list = await admin.listDatasources();
const ds = list.find((d) => d?.name === 'showcase_external');
expect(ds).toBeDefined();
expect(ds?.origin).toBe('code');
});

it('GET /api/v1/meta/datasource backing: protocol.getMetaItems({type:"datasource"}) includes the code datasource', async () => {
const protocol = kernel.getService<{ getMetaItems(r: { type: string }): Promise<any> }>('protocol');
const res = await protocol.getMetaItems({ type: 'datasource' });
const items: any[] = Array.isArray(res) ? res : (res?.items ?? []);
expect(items.map((d) => d?.name)).toContain('showcase_external');
});
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading