diff --git a/.changeset/dev-datasource-visibility.md b/.changeset/dev-datasource-visibility.md new file mode 100644 index 0000000000..ebb5f30ec6 --- /dev/null +++ b/.changeset/dev-datasource-visibility.md @@ -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. diff --git a/packages/core/src/fallbacks/memory-metadata.ts b/packages/core/src/fallbacks/memory-metadata.ts index 970ac6f12b..1d0fcdb0aa 100644 --- a/packages/core/src/fallbacks/memory-metadata.ts +++ b/packages/core/src/fallbacks/memory-metadata.ts @@ -25,6 +25,19 @@ export function createMemoryMetadata() { async register(type: string, name: string, data: any): Promise { 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 { return getTypeMap(type).get(name); }, diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index cea8b2bb45..f33e25528e 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -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, 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); }, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 3479042d8b..c84c991b51 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -42,6 +42,7 @@ "@objectstack/driver-mongodb": "workspace:*" }, "devDependencies": { + "@objectstack/service-datasource": "workspace:*", "typescript": "^6.0.3", "vitest": "^4.1.9" }, diff --git a/packages/runtime/src/datasource-visibility.test.ts b/packages/runtime/src/datasource-visibility.test.ts new file mode 100644 index 0000000000..7e2520a83d --- /dev/null +++ b/packages/runtime/src/datasource-visibility.test.ts @@ -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; + + 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 }>('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 }>('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 }>('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'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aa2784eeb..6d98408613 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1699,6 +1699,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@objectstack/service-datasource': + specifier: workspace:* + version: link:../services/service-datasource typescript: specifier: ^6.0.3 version: 6.0.3