|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#21788, ADR-0015 Addendum] "Import as Object" keeps what it answers `201` |
| 4 | +// for — over the real showcase composition, across a cold boot on one |
| 5 | +// database file. |
| 6 | +// |
| 7 | +// ## What was broken |
| 8 | +// |
| 9 | +// `POST /datasources/:name/external/tables/:remote/import` persisted the |
| 10 | +// generated object with `metadata.register('object', …)`, which held it in the |
| 11 | +// metadata service's memory only: no `sys_metadata` row, no engine schema |
| 12 | +// sync, no external-object registration with the driver. Measured on the |
| 13 | +// showcase before the fix: an import under a name that differs from its remote |
| 14 | +// table answered `201` and then `500 DATABASE_ERROR` on its first read (the |
| 15 | +// SQL driver resolved the table by the object's name — `no such table`), an |
| 16 | +// import whose name equals the remote table answered `200` only because the |
| 17 | +// two names coincide, and after a restart on the same database both answered |
| 18 | +// `404 OBJECT_NOT_FOUND`. `PUT /meta/object/:name` with the same binding was |
| 19 | +// durable and served the rows. The import now saves through that door's save. |
| 20 | +// |
| 21 | +// ## Why a booted stack |
| 22 | +// |
| 23 | +// The seam pins sit beside the plugin |
| 24 | +// (`packages/services/service-datasource/src/__tests__/external-import-saves-through-metadata-door.test.ts`). |
| 25 | +// What they cannot answer is whether, on the stack an operator runs, the save |
| 26 | +// maps the object onto its remote table and lands a row the next boot binds. |
| 27 | +// Only a read through the data door and a restart on the same file show that. |
| 28 | +// |
| 29 | +// The verify harness does not mount the federation service, so this file |
| 30 | +// mounts `ExternalDatasourceServicePlugin` itself, as `objectstack dev` and |
| 31 | +// `serve` do. The working directory is a temporary one because the showcase's |
| 32 | +// external datasource and its fixture both name a cwd-relative SQLite file: |
| 33 | +// this file's remote database is its own, not one a parallel file writes. |
| 34 | + |
| 35 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 36 | +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; |
| 37 | +import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource'; |
| 38 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 39 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 40 | +import { tmpdir } from 'node:os'; |
| 41 | +import { join } from 'node:path'; |
| 42 | + |
| 43 | +/** The showcase's external datasource and its two remote tables (`external-fixture.ts`). */ |
| 44 | +const DATASOURCE = 'showcase_external'; |
| 45 | +/** Imported under a name that differs from its remote table — the defect's shape. */ |
| 46 | +const RENAMED = 'dogfood_ext_cust_21788'; |
| 47 | +/** Imported under the remote table's own name — the shape that hid the defect. */ |
| 48 | +const SAME_NAME = 'orders'; |
| 49 | + |
| 50 | +const importPath = (remote: string) => `/datasources/${DATASOURCE}/external/tables/${remote}/import`; |
| 51 | + |
| 52 | +function recordsOf(json: unknown): Array<Record<string, unknown>> { |
| 53 | + const body = json as { records?: unknown[] } | undefined; |
| 54 | + return (body?.records ?? []) as Array<Record<string, unknown>>; |
| 55 | +} |
| 56 | + |
| 57 | +async function boot(databaseFile: string): Promise<VerifyStack> { |
| 58 | + return bootStack(showcaseStack, { databaseFile, extraPlugins: [new ExternalDatasourceServicePlugin()] }); |
| 59 | +} |
| 60 | + |
| 61 | +describe('Import as Object persists like the metadata door (showcase, cold boot)', () => { |
| 62 | + let stack: VerifyStack; |
| 63 | + let token: string; |
| 64 | + let prevCwd: string; |
| 65 | + let dir: string; |
| 66 | + let dbFile: string; |
| 67 | + |
| 68 | + const call = async (method: string, path: string, body?: unknown) => { |
| 69 | + const res = await stack.apiAs(token, method, path, body); |
| 70 | + const json: unknown = await res.json().catch(() => ({})); |
| 71 | + return { status: res.status, json }; |
| 72 | + }; |
| 73 | + |
| 74 | + beforeAll(async () => { |
| 75 | + prevCwd = process.cwd(); |
| 76 | + dir = mkdtempSync(join(tmpdir(), 'dogfood-21788-')); |
| 77 | + process.chdir(dir); |
| 78 | + dbFile = join(dir, 'showcase.db'); |
| 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 boot(dbFile); |
| 84 | + token = await stack.signIn(); |
| 85 | + }, 180_000); |
| 86 | + |
| 87 | + afterAll(async () => { |
| 88 | + await stack?.stop(); |
| 89 | + if (prevCwd) process.chdir(prevCwd); |
| 90 | + if (dir) rmSync(dir, { recursive: true, force: true }); |
| 91 | + }); |
| 92 | + |
| 93 | + it('an object imported under a name that differs from its remote table serves the remote rows', async () => { |
| 94 | + const imported = await call('POST', importPath('customers'), { name: RENAMED }); |
| 95 | + expect(imported.status, JSON.stringify(imported.json)).toBe(201); |
| 96 | + |
| 97 | + const read = await call('GET', `/data/${RENAMED}`); |
| 98 | + expect(read.status, JSON.stringify(read.json)).toBe(200); |
| 99 | + expect(recordsOf(read.json).map((r) => r.name)).toEqual( |
| 100 | + expect.arrayContaining(['Aurora Labs', 'Borealis GmbH', 'Cyan Pacific']), |
| 101 | + ); |
| 102 | + }); |
| 103 | + |
| 104 | + it('control: an object imported under its remote table\'s own name serves the remote rows', async () => { |
| 105 | + const imported = await call('POST', importPath('orders'), { name: SAME_NAME }); |
| 106 | + expect(imported.status, JSON.stringify(imported.json)).toBe(201); |
| 107 | + |
| 108 | + const read = await call('GET', `/data/${SAME_NAME}`); |
| 109 | + expect(read.status, JSON.stringify(read.json)).toBe(200); |
| 110 | + expect(recordsOf(read.json)).toHaveLength(4); |
| 111 | + }); |
| 112 | + |
| 113 | + it('after a cold boot on the same database file, both imported objects still serve their rows', async () => { |
| 114 | + await stack.stop(); |
| 115 | + stack = await boot(dbFile); |
| 116 | + token = await stack.signIn(); |
| 117 | + |
| 118 | + // Independent facts about one restart, so each is reported on its own. |
| 119 | + const renamed = await call('GET', `/data/${RENAMED}`); |
| 120 | + expect.soft(renamed.status, JSON.stringify(renamed.json)).toBe(200); |
| 121 | + expect.soft(recordsOf(renamed.json)).toHaveLength(3); |
| 122 | + |
| 123 | + const sameName = await call('GET', `/data/${SAME_NAME}`); |
| 124 | + expect.soft(sameName.status, JSON.stringify(sameName.json)).toBe(200); |
| 125 | + expect.soft(recordsOf(sameName.json)).toHaveLength(4); |
| 126 | + }, 180_000); |
| 127 | +}); |
0 commit comments