|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#22835 · ADR-0131 D6] The datasource-admin plugin reads ENVIRONMENT rows |
| 4 | +// only, over the real showcase composition across a restart. Its reads of |
| 5 | +// stored `datasource` rows carried no organization predicate, so a row stored |
| 6 | +// in a legacy organization's layer was restored, served by both doors and |
| 7 | +// pooled, while the boot report named it as served by no read. |
| 8 | +// |
| 9 | +// Pinned: neither door serves the legacy row and no pool opens from it; the |
| 10 | +// environment row is served; the boot report still names the legacy row, and |
| 11 | +// it stays at rest; the by-name reads behind the plugin's writes never reach |
| 12 | +// it. The routes are mounted the way `packages/cli/src/commands/serve.ts` does. |
| 13 | + |
| 14 | +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; |
| 15 | +import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource'; |
| 16 | +import { type VerifyStack } from '@objectstack/verify'; |
| 17 | +import { bootShowcase } from './showcase-boot.js'; |
| 18 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 19 | +import { tmpdir } from 'node:os'; |
| 20 | +import { join } from 'node:path'; |
| 21 | + |
| 22 | +/** A fixture datasource stored in a legacy organization's layer. */ |
| 23 | +const LEGACY = 'dogfood_legacy_ds_22835'; |
| 24 | +const LEGACY_ORG = 'org_dogfood_legacy_22835'; |
| 25 | +const LEGACY_LABEL = 'Legacy organization row 22835'; |
| 26 | +/** A fixture datasource stored in the environment's layer: the control. */ |
| 27 | +const ENV = 'dogfood_env_ds_22835'; |
| 28 | + |
| 29 | +const SYS = { isSystem: true, positions: [], permissions: [] }; |
| 30 | + |
| 31 | +type Row = Record<string, unknown>; |
| 32 | +interface Answer { status: number; json: Record<string, any> } |
| 33 | + |
| 34 | +describe('[#22835] the datasource-admin plugin reads environment rows only (showcase, restart)', () => { |
| 35 | + let stack: VerifyStack; |
| 36 | + let token: string; |
| 37 | + let dir: string; |
| 38 | + /** Every `console.warn` line the measured boot printed. */ |
| 39 | + let bootWarnings: string[] = []; |
| 40 | + /** The legacy row as it was stored, before the measured boot. */ |
| 41 | + let plantedLegacy: Row | undefined; |
| 42 | + |
| 43 | + const routes = () => ({ |
| 44 | + name: 'dogfood.datasource-admin-routes', |
| 45 | + version: '1.0.0', |
| 46 | + optionalDependencies: ['com.objectstack.server.hono'], |
| 47 | + init: async (ctx: { getService?: (name: string) => unknown }) => { |
| 48 | + const httpServer = ctx.getService?.('http.server') ?? ctx.getService?.('http-server'); |
| 49 | + registerDatasourceAdminRoutes(httpServer as never, ctx as never, '/api/v1'); |
| 50 | + }, |
| 51 | + }); |
| 52 | + const boot = async () => { |
| 53 | + stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] }); |
| 54 | + token = await stack.signIn(); |
| 55 | + }; |
| 56 | + const ql = () => stack.kernel.getService('objectql') as { |
| 57 | + find(object: string, query: unknown): Promise<Row[]>; |
| 58 | + insert(object: string, row: Row, options: unknown): Promise<unknown>; |
| 59 | + }; |
| 60 | + const storedRows = (name: string) => ql().find('sys_metadata', { where: { type: 'datasource', name }, context: SYS }); |
| 61 | + const call = async (method: string, path: string, body?: unknown): Promise<Answer> => { |
| 62 | + const res = await stack.apiAs(token, method, path, body); |
| 63 | + return { status: res.status, json: (await res.json().catch(() => ({}))) as Record<string, any> }; |
| 64 | + }; |
| 65 | + const adminNames = async () => { |
| 66 | + const listed = await call('GET', '/datasources'); |
| 67 | + expect(listed.status, JSON.stringify(listed.json)).toBe(200); |
| 68 | + return (listed.json.data?.datasources ?? []).map((d: Row) => d.name); |
| 69 | + }; |
| 70 | + const metaNames = async () => { |
| 71 | + const listed = await call('GET', '/meta/datasource'); |
| 72 | + expect(listed.status, JSON.stringify(listed.json)).toBe(200); |
| 73 | + const data = listed.json.data ?? listed.json; |
| 74 | + return ((Array.isArray(data) ? data : data?.items) ?? []).map((d: Row) => d?.name); |
| 75 | + }; |
| 76 | + const pooled = (name: string) => { |
| 77 | + const connection = stack.kernel.getService('datasource-connection') as { |
| 78 | + listConnectionStates(): Array<{ name: string; status: string }>; |
| 79 | + }; |
| 80 | + return connection.listConnectionStates().some((s) => s.name === name); |
| 81 | + }; |
| 82 | + const legacyRows = async () => (await storedRows(LEGACY)).filter((r) => r.organization_id === LEGACY_ORG); |
| 83 | + const environmentRows = async (name: string) => (await storedRows(name)).filter((r) => r.organization_id == null); |
| 84 | + |
| 85 | + beforeAll(async () => { |
| 86 | + dir = mkdtempSync(join(tmpdir(), 'dogfood-22835-')); |
| 87 | + await boot(); |
| 88 | + // The rows an earlier release left, written with system context the way |
| 89 | + // that release's store held them: one in a legacy organization's layer, |
| 90 | + // one in the environment's. |
| 91 | + const now = new Date().toISOString(); |
| 92 | + for (const [name, organizationId, label] of [ |
| 93 | + [LEGACY, LEGACY_ORG, LEGACY_LABEL], |
| 94 | + [ENV, null, 'Environment row 22835'], |
| 95 | + ] as const) { |
| 96 | + await ql().insert('sys_metadata', { |
| 97 | + type: 'datasource', name, organization_id: organizationId, package_id: null, scope: 'platform', |
| 98 | + state: 'active', version: 1, checksum: null, created_at: now, updated_at: now, |
| 99 | + metadata: JSON.stringify({ name, label, driver: 'sqlite', config: { filename: join(dir, `${name}.db`) } }), |
| 100 | + }, { context: SYS }); |
| 101 | + } |
| 102 | + [plantedLegacy] = await legacyRows(); |
| 103 | + await stack.stop(); |
| 104 | + |
| 105 | + const warn = vi.spyOn(console, 'warn'); |
| 106 | + try { |
| 107 | + await boot(); |
| 108 | + } finally { |
| 109 | + bootWarnings = warn.mock.calls.map((args) => args.map(String).join(' ')); |
| 110 | + warn.mockRestore(); |
| 111 | + } |
| 112 | + }, 240_000); |
| 113 | + |
| 114 | + afterAll(async () => { |
| 115 | + await stack?.stop(); |
| 116 | + if (dir) rmSync(dir, { recursive: true, force: true }); |
| 117 | + }); |
| 118 | + |
| 119 | + it('premise: both rows are at rest, and the boot report names the legacy one', async () => { |
| 120 | + expect(plantedLegacy).toBeDefined(); |
| 121 | + expect(await environmentRows(ENV)).toHaveLength(1); |
| 122 | + const report = bootWarnings.filter((line) => line.includes('[metadata_org_scoped_unserved]')); |
| 123 | + expect(report).toHaveLength(1); |
| 124 | + expect(report[0]).toContain(`${LEGACY}@${LEGACY_ORG}`); |
| 125 | + }); |
| 126 | + |
| 127 | + it('neither door serves the legacy row, and no pool is opened from it; the environment row is served', async () => { |
| 128 | + expect((await call('GET', `/datasources/${LEGACY}`)).status).toBe(404); |
| 129 | + expect((await call('GET', `/meta/datasource/${LEGACY}`)).status).toBe(404); |
| 130 | + expect(await adminNames()).not.toContain(LEGACY); |
| 131 | + expect(await metaNames()).not.toContain(LEGACY); |
| 132 | + expect(pooled(LEGACY)).toBe(false); |
| 133 | + |
| 134 | + expect((await call('GET', `/datasources/${ENV}`)).status).toBe(200); |
| 135 | + expect((await call('GET', `/meta/datasource/${ENV}`)).status).toBe(200); |
| 136 | + expect(await adminNames()).toContain(ENV); |
| 137 | + expect(await metaNames()).toContain(ENV); |
| 138 | + expect(pooled(ENV)).toBe(true); |
| 139 | + }); |
| 140 | + |
| 141 | + it('the writes\' by-name reads never reach the legacy row, and the row is kept as it was', async () => { |
| 142 | + // An admin create under the legacy row's name writes an environment row of |
| 143 | + // its own; its remove takes that row away again. |
| 144 | + const created = await call('POST', '/datasources', { |
| 145 | + name: LEGACY, label: 'Admin create 22835', driver: 'sqlite', config: { filename: join(dir, 'admin.db') }, |
| 146 | + }); |
| 147 | + expect(created.status, JSON.stringify(created.json)).toBe(201); |
| 148 | + expect(await environmentRows(LEGACY)).toHaveLength(1); |
| 149 | + expect((await call('DELETE', `/datasources/${LEGACY}`)).status).toBe(204); |
| 150 | + expect(await environmentRows(LEGACY)).toHaveLength(0); |
| 151 | + |
| 152 | + // A `/meta` save under the name: the admin door serves the saved body. |
| 153 | + const saved = await call('PUT', `/meta/datasource/${LEGACY}`, { |
| 154 | + name: LEGACY, label: 'Meta save 22835', driver: 'sqlite', config: { filename: join(dir, 'meta.db') }, |
| 155 | + }); |
| 156 | + expect(saved.status, JSON.stringify(saved.json)).toBe(200); |
| 157 | + const served = await call('GET', `/datasources/${LEGACY}`); |
| 158 | + expect(served.status, JSON.stringify(served.json)).toBe(200); |
| 159 | + expect(served.json.data?.datasource?.label).toBe('Meta save 22835'); |
| 160 | + |
| 161 | + // Neither deleted nor rewritten. |
| 162 | + expect(await legacyRows()).toEqual([plantedLegacy]); |
| 163 | + }); |
| 164 | +}); |
0 commit comments