|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#24772] Setup → Datasources: what Test and Sync answer for the platform's |
| 5 | + * PRIMARY datasource (`default`) and for a driver that cannot introspect. |
| 6 | + * |
| 7 | + * The page lists `default` as connected. That is true: `DefaultDatasourcePlugin` |
| 8 | + * connects it through `DatasourceConnectionService` with `asDefault`, and |
| 9 | + * ADR-0062 keeps its driver under the driver's NATURAL name, so the engine's |
| 10 | + * registry has no entry called `default`. Test and Sync both introspected by |
| 11 | + * the datasource name and answered "has no registered driver to introspect": |
| 12 | + * Test `200 { ok: false }`, Sync `400`. A connected `memory` datasource got |
| 13 | + * the same `ok: false` from Test, because its driver has no `introspectSchema`. |
| 14 | + * |
| 15 | + * The seat's ruling (route D) is what these pins hold: |
| 16 | + * |
| 17 | + * - Test answers truthfully for both. The primary datasource's driver is |
| 18 | + * found by the resolver `DatasourceConnectionService.disconnect` already |
| 19 | + * applies (`registeredDriverName`), and a driver that cannot introspect says |
| 20 | + * so instead of failing. |
| 21 | + * - Sync on `default` keeps refusing with the SAME status and code, and now |
| 22 | + * gives the true reason: the primary datasource is the platform's managed |
| 23 | + * storage, not a federation source (ADR-0015 §1). It never introspects the |
| 24 | + * platform database. |
| 25 | + * - Nothing else moves: a managed runtime datasource still tests and syncs |
| 26 | + * as before, and a datasource with no live driver still answers `ok: false`. |
| 27 | + * |
| 28 | + * The composition is the real one, end to end: a real `ObjectQL` engine (its |
| 29 | + * source, through this package's vitest alias), the real connection service |
| 30 | + * and driver factory, the real `ExternalDatasourceServicePlugin` wiring, and |
| 31 | + * the real admin routes over a Hono server. The SQL and memory drivers are |
| 32 | + * imported by the factory as packages, so they are their built `dist/`. |
| 33 | + */ |
| 34 | + |
| 35 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 36 | +import { ObjectQL } from '@objectstack/objectql'; |
| 37 | +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; |
| 38 | +import type { IDataDriver } from '@objectstack/spec/contracts'; |
| 39 | +import { |
| 40 | + DatasourceConnectionService, |
| 41 | + DEFAULT_DATASOURCE_NAME, |
| 42 | + registeredDriverName, |
| 43 | +} from '../datasource-connection-service.js'; |
| 44 | +import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; |
| 45 | +import { ExternalDatasourceServicePlugin } from '../plugin.js'; |
| 46 | +import { ExternalDatasourceService } from '../external-datasource-service.js'; |
| 47 | +import { DatasourceAdminService } from '../datasource-admin-service.js'; |
| 48 | +import { registerDatasourceAdminRoutes } from '../admin-routes.js'; |
| 49 | +import { ENTITLED_CREDENTIAL, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js'; |
| 50 | + |
| 51 | +const teardowns: Array<() => Promise<void>> = []; |
| 52 | +afterEach(async () => { |
| 53 | + for (const t of teardowns.splice(0)) await t(); |
| 54 | +}); |
| 55 | + |
| 56 | +type Knex = { schema: { createTable(n: string, cb: (t: never) => void): Promise<void> } }; |
| 57 | +type Table = { string(n: string): { primary(): void } }; |
| 58 | + |
| 59 | +/** |
| 60 | + * The page's composition: the primary datasource connected `asDefault` (the |
| 61 | + * `DefaultDatasourcePlugin.init()` call, verbatim), a managed runtime SQLite |
| 62 | + * datasource like the one the tester created, and a runtime `memory` one. |
| 63 | + */ |
| 64 | +async function boot() { |
| 65 | + const ql = new ObjectQL(); |
| 66 | + const connection = new DatasourceConnectionService({ |
| 67 | + factory: () => createDefaultDatasourceDriverFactory({}), |
| 68 | + engine: () => ql as never, |
| 69 | + }); |
| 70 | + teardowns.push(() => connection.disconnectAll()); |
| 71 | + |
| 72 | + await connection.connect( |
| 73 | + { name: 'default', label: 'Default', driver: 'sqlite', config: { filename: ':memory:' }, origin: 'code', bootCritical: true }, |
| 74 | + { asDefault: true, context: { origin: 'code', trigger: 'declared-auto' } }, |
| 75 | + ); |
| 76 | + await connection.connect( |
| 77 | + { name: 'warehouse', driver: 'sqlite', config: { filename: ':memory:' }, origin: 'runtime' }, |
| 78 | + { context: { origin: 'runtime', trigger: 'runtime-admin' } }, |
| 79 | + ); |
| 80 | + await connection.connect( |
| 81 | + { name: 'scratch', driver: 'memory', config: {}, origin: 'runtime' }, |
| 82 | + { context: { origin: 'runtime', trigger: 'runtime-admin' } }, |
| 83 | + ); |
| 84 | + |
| 85 | + const primary = ql.getDriverByName(ql.getDefaultDriverName()!) as IDataDriver & { getKnex(): Knex }; |
| 86 | + await primary.getKnex().schema.createTable('acct', (t) => (t as unknown as Table).string('id').primary()); |
| 87 | + const warehouse = ql.getDriverByName('warehouse') as IDataDriver & { getKnex(): Knex }; |
| 88 | + await warehouse.getKnex().schema.createTable('orders', (t) => (t as unknown as Table).string('id').primary()); |
| 89 | + |
| 90 | + const services = new Map<string, unknown>([['data', ql]]); |
| 91 | + await new ExternalDatasourceServicePlugin().init({ |
| 92 | + getService: (n: string) => { |
| 93 | + if (!services.has(n)) throw new Error(`no service: ${n}`); |
| 94 | + return services.get(n); |
| 95 | + }, |
| 96 | + registerService: (n: string, s: unknown) => void services.set(n, s), |
| 97 | + } as never); |
| 98 | + const external = services.get('external-datasource') as ExternalDatasourceService; |
| 99 | + |
| 100 | + const admin = new DatasourceAdminService({ |
| 101 | + listDatasourceRecords: async () => [ |
| 102 | + { name: 'default', driver: 'sqlite', origin: 'code', config: {} }, |
| 103 | + { name: 'warehouse', driver: 'sqlite', origin: 'runtime', config: {} }, |
| 104 | + { name: 'scratch', driver: 'memory', origin: 'runtime', config: {} }, |
| 105 | + ], |
| 106 | + connectionStates: () => connection.listConnectionStates(), |
| 107 | + } as never); |
| 108 | + |
| 109 | + const server = new HonoHttpServer(0); |
| 110 | + const authService = createSessionAuthService(); |
| 111 | + const grantsEngine = createGrantsEngine(); |
| 112 | + registerDatasourceAdminRoutes( |
| 113 | + server, |
| 114 | + { |
| 115 | + getService: (n: string) => { |
| 116 | + if (n === 'auth') return authService; |
| 117 | + if (n === 'objectql' || n === 'data') return grantsEngine; |
| 118 | + if (n === 'external-datasource') return external; |
| 119 | + if (n === 'datasource-admin') return admin; |
| 120 | + throw new Error(`no service: ${n}`); |
| 121 | + }, |
| 122 | + } as never, |
| 123 | + '/api/v1', |
| 124 | + ); |
| 125 | + const app = server.getRawApp(); |
| 126 | + const call = async (method: 'GET' | 'POST', path: string, body?: unknown) => { |
| 127 | + const res = await app.fetch( |
| 128 | + new Request(`http://local/api/v1/datasources${path}`, { |
| 129 | + method, |
| 130 | + headers: { authorization: ENTITLED_CREDENTIAL, 'content-type': 'application/json' }, |
| 131 | + ...(method === 'POST' ? { body: JSON.stringify(body ?? {}) } : {}), |
| 132 | + }), |
| 133 | + ); |
| 134 | + return { status: res.status, body: (await res.json()) as any }; |
| 135 | + }; |
| 136 | + |
| 137 | + return { ql, connection, external, primary, call }; |
| 138 | +} |
| 139 | + |
| 140 | +/** The refusal a service method threw: its envelope fields (ADR-0112). */ |
| 141 | +async function thrown(answer: Promise<unknown>): Promise<Error & { code?: string; status?: number }> { |
| 142 | + const outcome = await answer.then( |
| 143 | + (value) => ({ value }), |
| 144 | + (error: unknown) => ({ error }), |
| 145 | + ); |
| 146 | + expect(outcome).not.toHaveProperty('value'); |
| 147 | + return (outcome as { error: Error & { code?: string; status?: number } }).error; |
| 148 | +} |
| 149 | + |
| 150 | +const NOT_A_FEDERATION_SOURCE = "Datasource 'default' is the platform's managed primary datasource, not a federation source"; |
| 151 | + |
| 152 | +describe('the primary datasource: Test agrees with the "connected" the page lists', () => { |
| 153 | + it('Test answers ok through the live driver, and does not introspect the platform database', async () => { |
| 154 | + const { call, primary } = await boot(); |
| 155 | + const introspect = vi.spyOn(primary, 'introspectSchema' as never); |
| 156 | + |
| 157 | + const list = await call('GET', ''); |
| 158 | + expect(list.status).toBe(200); |
| 159 | + expect(list.body.data.datasources.find((d: { name: string }) => d.name === 'default').status).toBe('ok'); |
| 160 | + |
| 161 | + const test = await call('POST', '/default/test'); |
| 162 | + expect(test.status).toBe(200); |
| 163 | + expect(test.body.data).toMatchObject({ ok: true }); |
| 164 | + expect(typeof test.body.data.latencyMs).toBe('number'); |
| 165 | + expect(test.body.data).not.toHaveProperty('error'); |
| 166 | + // The primary datasource is not a federation source: Test asks the driver |
| 167 | + // whether it is connected, never lists the platform's tables to find out. |
| 168 | + expect(test.body.data).not.toHaveProperty('tableCount'); |
| 169 | + expect(introspect).not.toHaveBeenCalled(); |
| 170 | + }); |
| 171 | +}); |
| 172 | + |
| 173 | +describe('the primary datasource: Sync refuses as before, with the true reason', () => { |
| 174 | + it('remote-tables and object-draft keep 400 EXTERNAL_DATASOURCE_ERROR, and never introspect it', async () => { |
| 175 | + const { call, primary } = await boot(); |
| 176 | + const introspect = vi.spyOn(primary, 'introspectSchema' as never); |
| 177 | + |
| 178 | + const tables = await call('GET', '/default/remote-tables'); |
| 179 | + expect(tables.status).toBe(400); |
| 180 | + expect(tables.body.error.code).toBe('EXTERNAL_DATASOURCE_ERROR'); |
| 181 | + expect(tables.body.error.message).toContain(NOT_A_FEDERATION_SOURCE); |
| 182 | + expect(tables.body.error.message).not.toContain('no registered driver'); |
| 183 | + |
| 184 | + const draft = await call('POST', '/default/object-draft', { table: 'acct' }); |
| 185 | + expect(draft.status).toBe(400); |
| 186 | + expect(draft.body.error.code).toBe('EXTERNAL_DATASOURCE_ERROR'); |
| 187 | + expect(draft.body.error.message).toContain(NOT_A_FEDERATION_SOURCE); |
| 188 | + |
| 189 | + expect(introspect).not.toHaveBeenCalled(); |
| 190 | + }); |
| 191 | + |
| 192 | + it('the import and the catalog refresh refuse it with the code their doors serve', async () => { |
| 193 | + const { external } = await boot(); |
| 194 | + |
| 195 | + expect(await thrown(external.listRemoteTables('default'))).toMatchObject({ code: 'EXTERNAL_DATASOURCE_ERROR', status: 400 }); |
| 196 | + expect(await thrown(external.generateObjectDraft('default', 'acct'))).toMatchObject({ code: 'EXTERNAL_DATASOURCE_ERROR', status: 400 }); |
| 197 | + expect(await thrown(external.refreshCatalog('default'))).toMatchObject({ code: 'EXTERNAL_DATASOURCE_ERROR', status: 400 }); |
| 198 | + const imported = await thrown(external.importObject('default', 'acct')); |
| 199 | + expect(imported).toMatchObject({ code: 'EXTERNAL_IMPORT_ERROR', status: 400 }); |
| 200 | + expect(imported.message).toContain(NOT_A_FEDERATION_SOURCE); |
| 201 | + }); |
| 202 | +}); |
| 203 | + |
| 204 | +describe('a driver with no introspectSchema', () => { |
| 205 | + it('Test answers connected and says introspection is unsupported, instead of ok: false', async () => { |
| 206 | + const { call } = await boot(); |
| 207 | + |
| 208 | + const test = await call('POST', '/scratch/test'); |
| 209 | + expect(test.status).toBe(200); |
| 210 | + expect(test.body.data).toMatchObject({ ok: true, introspection: 'unsupported' }); |
| 211 | + expect(test.body.data).not.toHaveProperty('error'); |
| 212 | + }); |
| 213 | + |
| 214 | + it('a registered driver that fails its health check is not reported connected', async () => { |
| 215 | + const service = (checkHealth: () => Promise<boolean>) => |
| 216 | + new ExternalDatasourceService({ |
| 217 | + introspect: async () => { |
| 218 | + throw new Error('introspect must not be reached'); |
| 219 | + }, |
| 220 | + liveDriver: () => ({ checkHealth }), |
| 221 | + getDatasource: async () => undefined, |
| 222 | + getObject: async () => undefined, |
| 223 | + listObjects: async () => [], |
| 224 | + }); |
| 225 | + |
| 226 | + const unhealthy = await service(async () => false).testConnection('scratch'); |
| 227 | + expect(unhealthy.ok).toBe(false); |
| 228 | + expect(unhealthy.error).toContain('health check'); |
| 229 | + |
| 230 | + const throwing = await service(async () => { |
| 231 | + throw new Error('ECONNREFUSED'); |
| 232 | + }).testConnection('scratch'); |
| 233 | + expect(throwing).toEqual({ ok: false, error: 'ECONNREFUSED' }); |
| 234 | + }); |
| 235 | +}); |
| 236 | + |
| 237 | +describe('nothing else moves', () => { |
| 238 | + it('a managed runtime datasource tests and syncs exactly as before', async () => { |
| 239 | + const { call } = await boot(); |
| 240 | + |
| 241 | + const test = await call('POST', '/warehouse/test'); |
| 242 | + expect(test.status).toBe(200); |
| 243 | + expect(test.body.data).toMatchObject({ ok: true, tableCount: 1 }); |
| 244 | + expect(test.body.data).not.toHaveProperty('introspection'); |
| 245 | + |
| 246 | + const tables = await call('GET', '/warehouse/remote-tables'); |
| 247 | + expect(tables.status).toBe(200); |
| 248 | + expect(tables.body.data.tables.map((t: { name: string }) => t.name)).toEqual(['orders']); |
| 249 | + }); |
| 250 | + |
| 251 | + it('Sync on a driver that cannot introspect still refuses with the engine reason', async () => { |
| 252 | + const { call } = await boot(); |
| 253 | + |
| 254 | + const tables = await call('GET', '/scratch/remote-tables'); |
| 255 | + expect(tables.status).toBe(400); |
| 256 | + expect(tables.body.error.code).toBe('EXTERNAL_DATASOURCE_ERROR'); |
| 257 | + expect(tables.body.error.message).toContain('does not support introspectSchema()'); |
| 258 | + }); |
| 259 | + |
| 260 | + it('a datasource with no live driver still answers ok: false', async () => { |
| 261 | + const { call } = await boot(); |
| 262 | + |
| 263 | + const test = await call('POST', '/ghost/test'); |
| 264 | + expect(test.status).toBe(200); |
| 265 | + expect(test.body.data.ok).toBe(false); |
| 266 | + expect(test.body.data.error).toContain("Datasource 'ghost' has no registered driver"); |
| 267 | + }); |
| 268 | +}); |
| 269 | + |
| 270 | +describe('registeredDriverName — the one spelling of the ADR-0062 default-driver rule', () => { |
| 271 | + const engine = { getDefaultDriverName: () => 'com.objectstack.driver.sql' }; |
| 272 | + |
| 273 | + it('resolves the reserved name to the default driver, and every other name to itself', () => { |
| 274 | + expect(DEFAULT_DATASOURCE_NAME).toBe('default'); |
| 275 | + expect(registeredDriverName(engine, 'default')).toBe('com.objectstack.driver.sql'); |
| 276 | + expect(registeredDriverName(engine, 'warehouse')).toBe('warehouse'); |
| 277 | + }); |
| 278 | + |
| 279 | + it('honours an explicit flag either way, as disconnect() passes its own', () => { |
| 280 | + expect(registeredDriverName(engine, 'default', false)).toBe('default'); |
| 281 | + expect(registeredDriverName(engine, 'analytics', true)).toBe('com.objectstack.driver.sql'); |
| 282 | + expect(registeredDriverName(undefined, 'default')).toBeUndefined(); |
| 283 | + }); |
| 284 | +}); |
0 commit comments