|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#21889, ADR-0028] An import over a CODE-DEFINED datasource is held to the |
| 4 | +// namespace of the package that declares the datasource, over the real |
| 5 | +// showcase composition. |
| 6 | +// |
| 7 | +// ## What was broken |
| 8 | +// |
| 9 | +// The showcase declares `showcase_external` in its own package, whose |
| 10 | +// `manifest.namespace` is `showcase`. `POST |
| 11 | +// /api/v1/datasources/showcase_external/external/tables/orders/import` with an |
| 12 | +// explicit `name` that carried no prefix answered `201` and persisted an |
| 13 | +// unprefixed federated object, and the draft door answered the bare remote |
| 14 | +// table name with its `TODO(namespace)` note. Two links were missing: |
| 15 | +// `AppPlugin` registered the code-defined datasource with no `_packageId`, and |
| 16 | +// the federation service then looked the package record up on the `metadata` |
| 17 | +// service, which holds none in any composition. The datasource is now stamped |
| 18 | +// with the package body that declares it, and the namespace is read from the |
| 19 | +// engine registry, the store the publish gate reads. |
| 20 | +// |
| 21 | +// ## What each case pins |
| 22 | +// |
| 23 | +// The ruling's two pins: an unprefixed import name is refused with ADR-0028's |
| 24 | +// message and saves nothing, and the draft door answers the prefixed name with |
| 25 | +// no `TODO(namespace)` note. Then the controls that show the stamp moved |
| 26 | +// nothing else: a prefixed import is saved and serves the remote rows, the |
| 27 | +// host `default` datasource carries no package, the admin service keeps |
| 28 | +// refusing edits to a code-defined datasource and lists it as before, and |
| 29 | +// validate and the existing federated objects answer as before. |
| 30 | +// |
| 31 | +// The verify harness does not mount the federation service, so this file |
| 32 | +// mounts `ExternalDatasourceServicePlugin` itself, as `objectstack dev` and |
| 33 | +// `serve` do. The working directory is a temporary one because the showcase's |
| 34 | +// external datasource and its fixture both name a cwd-relative SQLite file. |
| 35 | + |
| 36 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 37 | +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; |
| 38 | +import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource'; |
| 39 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 40 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 41 | +import { tmpdir } from 'node:os'; |
| 42 | +import { join } from 'node:path'; |
| 43 | + |
| 44 | +/** The showcase's code-defined external datasource (`showcase-external.datasource.ts`). */ |
| 45 | +const DATASOURCE = 'showcase_external'; |
| 46 | +/** The package that declares it, and that package's ADR-0028 namespace. */ |
| 47 | +const PACKAGE_ID = 'com.example.showcase'; |
| 48 | +const NAMESPACE = 'showcase'; |
| 49 | +/** An explicit import name with no namespace prefix: the defect's shape. */ |
| 50 | +const UNPREFIXED = 'dogfood_ext_order_21889'; |
| 51 | +const PREFIXED = `${NAMESPACE}_${UNPREFIXED}`; |
| 52 | + |
| 53 | +const ext = `/datasources/${DATASOURCE}/external`; |
| 54 | + |
| 55 | +interface Envelope { |
| 56 | + success?: boolean; |
| 57 | + error?: { code?: string; message?: string }; |
| 58 | + item?: Record<string, unknown>; |
| 59 | + data?: unknown; |
| 60 | + records?: Array<Record<string, unknown>>; |
| 61 | +} |
| 62 | + |
| 63 | +describe('an import over a code-defined datasource is held to its package\'s namespace (showcase)', () => { |
| 64 | + let stack: VerifyStack; |
| 65 | + let token: string; |
| 66 | + let prevCwd: string; |
| 67 | + let dir: string; |
| 68 | + |
| 69 | + const call = async (method: string, path: string, body?: unknown) => { |
| 70 | + const res = await stack.apiAs(token, method, path, body); |
| 71 | + const json = (await res.json().catch(() => ({}))) as Envelope; |
| 72 | + return { status: res.status, json }; |
| 73 | + }; |
| 74 | + |
| 75 | + beforeAll(async () => { |
| 76 | + prevCwd = process.cwd(); |
| 77 | + dir = mkdtempSync(join(tmpdir(), 'dogfood-21889-')); |
| 78 | + process.chdir(dir); |
| 79 | + // Provision the remote tables, exactly as `os dev` does at boot (the |
| 80 | + // harness imports only the stack's default export, so `onEnable` never |
| 81 | + // runs on its own). |
| 82 | + await onEnable({ logger: { info() {}, warn() {} } } as never); |
| 83 | + stack = await bootStack(showcaseStack, { |
| 84 | + databaseFile: join(dir, 'showcase.db'), |
| 85 | + extraPlugins: [new ExternalDatasourceServicePlugin()], |
| 86 | + }); |
| 87 | + token = await stack.signIn(); |
| 88 | + }, 180_000); |
| 89 | + |
| 90 | + afterAll(async () => { |
| 91 | + await stack?.stop(); |
| 92 | + if (prevCwd) process.chdir(prevCwd); |
| 93 | + if (dir) rmSync(dir, { recursive: true, force: true }); |
| 94 | + }); |
| 95 | + |
| 96 | + it('premise: the code-defined datasource carries the provenance of the package that declares it', async () => { |
| 97 | + const version = (showcaseStack as unknown as { manifest?: { version?: string } }).manifest?.version; |
| 98 | + expect(version).toMatch(/^\d+\.\d+\.\d+/); |
| 99 | + |
| 100 | + const read = await call('GET', `/meta/datasource/${DATASOURCE}`); |
| 101 | + expect(read.status, JSON.stringify(read.json)).toBe(200); |
| 102 | + expect(read.json.item).toMatchObject({ |
| 103 | + origin: 'code', |
| 104 | + _packageId: PACKAGE_ID, |
| 105 | + _packageVersion: version, |
| 106 | + _provenance: 'package', |
| 107 | + }); |
| 108 | + }); |
| 109 | + |
| 110 | + it('control: validate and the code-defined federated objects answer as before, ahead of any import', async () => { |
| 111 | + const validated = await call('POST', `${ext}/validate`); |
| 112 | + expect(validated.status, JSON.stringify(validated.json)).toBe(200); |
| 113 | + const report = validated.json.data as { ok?: boolean; results?: Array<{ object: string }> }; |
| 114 | + expect(report.ok).toBe(true); |
| 115 | + expect(report.results?.map((r) => r.object).sort()).toEqual(['showcase_ext_customer', 'showcase_ext_order']); |
| 116 | + |
| 117 | + const rows = await call('GET', '/data/showcase_ext_order'); |
| 118 | + expect(rows.status, JSON.stringify(rows.json)).toBe(200); |
| 119 | + expect(rows.json.records).toHaveLength(4); |
| 120 | + }); |
| 121 | + |
| 122 | + it('an explicit import name without the prefix is refused with ADR-0028\'s message, and nothing is saved', async () => { |
| 123 | + const refused = await call('POST', `${ext}/tables/orders/import`, { name: UNPREFIXED }); |
| 124 | + |
| 125 | + expect(refused.status, JSON.stringify(refused.json)).toBe(400); |
| 126 | + expect(refused.json.error?.code).toBe('EXTERNAL_IMPORT_ERROR'); |
| 127 | + expect(refused.json.error?.message).toContain('missing the package namespace prefix'); |
| 128 | + expect(refused.json.error?.message).toContain(`'${PREFIXED}'`); |
| 129 | + |
| 130 | + const stored = await call('GET', `/meta/object/${UNPREFIXED}`); |
| 131 | + expect(stored.status, JSON.stringify(stored.json)).toBe(404); |
| 132 | + }); |
| 133 | + |
| 134 | + it('the draft door answers the prefixed name, with no TODO(namespace) note', async () => { |
| 135 | + const drafted = await call('POST', `${ext}/tables/orders/draft`, {}); |
| 136 | + |
| 137 | + expect(drafted.status, JSON.stringify(drafted.json)).toBe(200); |
| 138 | + const draft = (drafted.json.data as { draft?: { name?: string; source?: string } } | undefined)?.draft; |
| 139 | + expect(draft?.name).toBe(`${NAMESPACE}_orders`); |
| 140 | + expect(draft?.source).not.toContain('TODO(namespace)'); |
| 141 | + }); |
| 142 | + |
| 143 | + it('control: a prefixed import name is saved and serves the remote rows', async () => { |
| 144 | + const imported = await call('POST', `${ext}/tables/orders/import`, { name: PREFIXED }); |
| 145 | + expect(imported.status, JSON.stringify(imported.json)).toBe(201); |
| 146 | + |
| 147 | + const rows = await call('GET', `/data/${PREFIXED}`); |
| 148 | + expect(rows.status, JSON.stringify(rows.json)).toBe(200); |
| 149 | + expect(rows.json.records).toHaveLength(4); |
| 150 | + }); |
| 151 | + |
| 152 | + it('control: the host `default` datasource carries no package, so no namespace is demanded on it', async () => { |
| 153 | + const read = await call('GET', '/meta/datasource/default'); |
| 154 | + expect(read.status, JSON.stringify(read.json)).toBe(200); |
| 155 | + expect(read.json.item).not.toHaveProperty('_packageId'); |
| 156 | + expect(read.json.item).not.toHaveProperty('_provenance'); |
| 157 | + }); |
| 158 | + |
| 159 | + it('control: the admin service still refuses to edit or remove a code-defined datasource, and lists no envelope key', async () => { |
| 160 | + // The harness mounts no `/api/v1/datasources` admin routes (the CLI's |
| 161 | + // `serve` does), so this reads the service those routes relay: a refusal |
| 162 | + // here is the door's `400 DATASOURCE_ADMIN_ERROR`. The refusals key on |
| 163 | + // `origin`, which the stamp leaves as it was. |
| 164 | + const admin = stack.kernel.getService<{ |
| 165 | + updateDatasource(name: string, patch: Record<string, unknown>): Promise<unknown>; |
| 166 | + removeDatasource(name: string): Promise<void>; |
| 167 | + listDatasources(): Promise<Array<Record<string, unknown>>>; |
| 168 | + }>('datasource-admin'); |
| 169 | + |
| 170 | + await expect(admin.updateDatasource(DATASOURCE, { label: 'Renamed 21889' })).rejects.toBeInstanceOf(Error); |
| 171 | + await expect(admin.removeDatasource(DATASOURCE)).rejects.toBeInstanceOf(Error); |
| 172 | + |
| 173 | + const entry = (await admin.listDatasources()).find((d) => d.name === DATASOURCE); |
| 174 | + expect(entry).toMatchObject({ origin: 'code', label: 'External Analytics (SQLite)' }); |
| 175 | + expect(entry).not.toHaveProperty('_packageId'); |
| 176 | + }); |
| 177 | +}); |
0 commit comments