ktx/scripts/installed-live-database-smoke.mjs
Andrey Avtomonov b00c1a11a9
feat: merge ingest and scan
* docs: add CLI component reuse guidance

* docs: add unified ingest ux design

* Refine unified ingest UX design after adversarial review iteration 1

* Refine unified ingest UX design after adversarial review iteration 2

* Refine unified ingest UX design after adversarial review iteration 3

* feat(cli): route public connection ingest command

* feat(cli): hide standalone scan from public help

* feat(cli): plan public ingest depth and query history

* feat(cli): execute public database ingest facets

* feat(ingest): read connection query history config

* fix(cli): use public ingest wording

* fix(config): stop generating ingest adapter allow lists

* docs: document public ingest command

* test: align ingest surface expectations

* docs: add unified ingest public CLI surface plan

* feat(cli): preflight deep public ingest readiness

* feat(setup): store query history in connection context

* feat(setup): store database context depth

* feat(setup): verify context readiness by database depth

* fix(setup): keep context build foreground only

* fix(config): reject reserved ingest connection ids

* test: close unified ingest v1 expectations

* docs: add unified ingest v1 closure plan

* fix(ingest): bypass adapter allow-list for public source ingest

* fix(ingest): honor query history window intent

* fix(ingest): hide scan internals from public database ingest

* feat(ingest): use foreground view for interactive public ingest

* fix(setup): use schema context and query history wording

* test(cli): verify unified ingest public output

* docs: add unified ingest v1 public output closure plan

* fix(setup): forward query history flags

* fix(setup): prompt for postgres query history

* fix(status): report query history readiness

* fix(ingest): remove legacy public guidance

* fix(ingest): polish foreground retry copy

* docs(examples): use unified query history wording

* chore(ingest): finish public query history cleanup

* docs: add unified ingest v1 query history status cleanup plan

* test(docs): cover unified ingest public docs

* docs: align ingest CLI reference with unified UX

* docs: update context build guides for unified ingest

* docs: update setup and primary source ingest wording

* docs: stop advertising adapter-backed example ingest

* docs: close unified ingest public docs gaps

* docs: add unified ingest v1 docs site closure plan

* fix: render unified ingest foreground warnings

* fix: explain query history schema order

* fix: add public ingest retry guidance

* fix: align setup next steps with unified ingest

* fix: remove scan wording from demo progress

* test: verify unified ingest ux closure

* docs: add unified ingest v1 foreground and retry closure plan

* fix(cli): preserve query-history pull config in public ingest

* fix(cli): omit hidden commands from docs command tree

* test(cli): close unified ingest final public surface checks

* docs: add unified ingest v1 final public surface closure plan

* fix(cli): use public source labels in ingest reports

* fix(cli): suppress low-level public ingest output

* test(cli): verify unified ingest public plain output

* docs: add unified ingest v1 public plain output closure plan

* fix(cli): add public ingest copy sanitizers

* fix(cli): sanitize public ingest progress copy

* fix(cli): rename setup schema scope prompt

* docs(plan): add progress copy closure; test: align setup back-nav fixture

Adds the iter9 plan and updates the setup back-navigation test fixture
to pass disableQueryHistory plus listSchemas/listTables stubs that the
unified ingest setup step now requires.

* docs(plan): add final ux labels plan with narrowed label scans

* fix(cli): aggregate unsupported query-history warnings

* fix(cli): align setup database labels

* test(cli): fix setup database test type-check

* fix(cli): remove primary-source wording from setup output

* test(cli): verify unified ingest setup closure

* docs(plan): add unified ingest v1 verification copy closure plan

* fix(cli): remove top-level scan command

* fix(cli): remove legacy ingest and wiki commands

* Merge scan into ingest flow

* feat(cli): split ingest progress into per-phase rows, rename work units to tasks

Each database target in the unified ingest dashboard now renders one row per
real subprocess (Schema, then Query history when enabled) instead of a single
combined bar. Each phase has its own monotonic 0-100% bar so the progress
never snaps back to zero when historic-sql starts after scan completes.
Completed phases keep their final bar, summary, and elapsed time visible as
an inline audit trail; queued and skipped phases are shown explicitly.

Also rename user-facing "work units" / "Failed work units" to "tasks" /
"Failed tasks" in ingest output and parseIngestSummary. The parser still
accepts the legacy "Work units:" wording in captured output for backward
compat. Internal memory-flow event names and type fields are left alone.

* Fix test harness failures

* Fix CI smoke checks

---------

Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-14 01:43:06 +02:00

338 lines
10 KiB
JavaScript

#!/usr/bin/env node
import { execFile } from 'node:child_process';
import { once } from 'node:events';
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import {
npmSmokePackageJson,
npmSmokePnpmWorkspaceYaml,
packageArtifactLayout,
} from './package-artifacts.mjs';
const POSTGRES_IMAGE = process.env.KTX_ARTIFACT_POSTGRES_IMAGE ?? 'postgres:16-alpine';
const POSTGRES_USER = 'ktx';
const POSTGRES_PASSWORD = 'postgres'; // pragma: allowlist secret
const POSTGRES_DB = 'warehouse';
export function smokeContainerName(pid = process.pid, now = Date.now()) {
return `ktx-live-db-smoke-${pid}-${now}`;
}
export function buildPostgresUrl(hostPort) {
return `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${hostPort}/${POSTGRES_DB}`; // pragma: allowlist secret
}
export function buildDockerRunArgs({ containerName, hostPort, image = POSTGRES_IMAGE }) {
return [
'run',
'--rm',
'-d',
'--name',
containerName,
'-e',
`POSTGRES_PASSWORD=${POSTGRES_PASSWORD}`,
'-e',
`POSTGRES_USER=${POSTGRES_USER}`,
'-e',
`POSTGRES_DB=${POSTGRES_DB}`,
'-p',
`127.0.0.1:${hostPort}:5432`,
image,
];
}
export function buildPostgresReadyArgs(containerName) {
return [
'exec',
containerName,
'psql',
'-U',
POSTGRES_USER,
'-d',
POSTGRES_DB,
'-v',
'ON_ERROR_STOP=1',
'-c',
'SELECT 1;',
];
}
export function buildSeedSql() {
return [
'DROP TABLE IF EXISTS orders;',
'DROP TABLE IF EXISTS customers;',
'CREATE TABLE customers (',
' id integer PRIMARY KEY,',
' name text NOT NULL',
');',
"COMMENT ON TABLE customers IS 'Customers captured by the artifact smoke';",
"COMMENT ON COLUMN customers.name IS 'Customer display name';",
'CREATE TABLE orders (',
' id integer PRIMARY KEY,',
' customer_id integer NOT NULL REFERENCES customers(id),',
' status text NOT NULL,',
' amount integer NOT NULL',
');',
"COMMENT ON TABLE orders IS 'Orders captured by the artifact smoke';",
"COMMENT ON COLUMN orders.amount IS 'Order amount in cents';",
"INSERT INTO customers (id, name) VALUES (1, 'Acme'), (2, 'Globex');",
"INSERT INTO orders (id, customer_id, status, amount) VALUES (10, 1, 'paid', 2000), (11, 2, 'open', 3500);",
'',
].join('\n');
}
export function buildKtxYaml(postgresUrl) {
return [
'project: artifact-live-database',
'connections:',
' warehouse:',
' driver: postgres',
` url: "${postgresUrl}"`,
'storage:',
' state: sqlite',
' search: sqlite-fts5',
'',
].join('\n');
}
export function buildLiveDatabaseIngestArgs(projectDir, _databaseIntrospectionUrl, connectionId = 'warehouse') {
return [
'exec',
'ktx',
'ingest',
connectionId,
'--project-dir',
projectDir,
'--fast',
'--no-input',
];
}
async function run(command, args, options = {}) {
process.stdout.write(`$ ${command} ${args.join(' ')}\n`);
return new Promise((resolve) => {
const child = execFile(
command,
args,
{
cwd: options.cwd,
env: options.env ?? process.env,
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 20,
timeout: options.timeout ?? 60_000,
},
(error, stdout, stderr) => {
if (stdout) {
process.stdout.write(stdout);
}
if (stderr) {
process.stderr.write(stderr);
}
resolve({
code: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
stdout,
stderr: stderr || (error instanceof Error ? error.message : ''),
});
},
);
if (options.input !== undefined) {
child.stdin?.end(options.input);
}
});
}
function requireSuccess(label, result) {
if (result.code !== 0) {
throw new Error(
`${label} failed with code ${result.code}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
}
}
function requireOutput(label, result, pattern) {
if (!pattern.test(result.stdout)) {
throw new Error(`${label} output did not match ${pattern}\nstdout:\n${result.stdout}`);
}
}
function getRunId(stdout) {
const match = stdout.match(/^Run: (.+)$/m);
if (!match) {
throw new Error(`ingest output did not include a run id\nstdout:\n${stdout}`);
}
return match[1];
}
async function requireDocker() {
const result = await run('docker', ['info'], { timeout: 20_000 });
if (result.code !== 0) {
throw new Error(
'Docker is required for the installed live-database artifact smoke. Start Docker and rerun `pnpm run artifacts:live-db-smoke`.',
);
}
}
async function getAvailablePort() {
const server = createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
throw new Error('expected TCP server address');
}
const port = address.port;
server.close();
await once(server, 'close');
return port;
}
async function startPostgresContainer(containerName, hostPort) {
await requireDocker();
const result = await run('docker', buildDockerRunArgs({ containerName, hostPort }), { timeout: 120_000 });
requireSuccess('docker run postgres', result);
}
async function stopPostgresContainer(containerName) {
await run('docker', ['rm', '-f', containerName], { timeout: 30_000 });
}
async function waitForPostgres(containerName) {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const result = await run('docker', buildPostgresReadyArgs(containerName), { timeout: 10_000 });
if (result.code === 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Postgres container ${containerName}`);
}
async function seedPostgres(containerName) {
const result = await run(
'docker',
['exec', '-i', containerName, 'psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-v', 'ON_ERROR_STOP=1'],
{ input: buildSeedSql(), timeout: 30_000 },
);
requireSuccess('seed postgres catalog', result);
}
function managedRuntimeEnv(cleanInstallDir) {
return {
...process.env,
KTX_RUNTIME_ROOT: join(cleanInstallDir, 'managed-runtime'),
};
}
function parseDaemonBaseUrl(stdout) {
const match = stdout.match(/^url: (http:\/\/127\.0\.0\.1:\d+)$/m);
if (!match) {
throw new Error(`Daemon URL was not printed by runtime start:\n${stdout}`);
}
return match[1];
}
async function startDaemon(cleanInstallDir) {
const result = await run('pnpm', ['exec', 'ktx', 'dev', 'runtime', 'start'], {
cwd: cleanInstallDir,
env: managedRuntimeEnv(cleanInstallDir),
timeout: 120_000,
});
requireSuccess('ktx dev runtime start', result);
return parseDaemonBaseUrl(result.stdout);
}
async function stopDaemon(cleanInstallDir) {
await run('pnpm', ['exec', 'ktx', 'dev', 'runtime', 'stop'], {
cwd: cleanInstallDir,
env: managedRuntimeEnv(cleanInstallDir),
timeout: 30_000,
});
}
async function assertPathExists(path, label) {
try {
await access(path);
} catch {
throw new Error(`Missing ${label}: ${path}`);
}
}
async function prepareCleanInstall(layout, cleanInstallDir) {
await assertPathExists(layout.contextTarball, '@ktx/context tarball');
await assertPathExists(layout.cliTarball, '@ktx/cli tarball');
await mkdir(cleanInstallDir, { recursive: true });
await writeFile(join(cleanInstallDir, 'package.json'), `${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`);
await writeFile(join(cleanInstallDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml());
await run('pnpm', ['install'], { cwd: cleanInstallDir, timeout: 120_000 }).then((result) =>
requireSuccess('pnpm install clean artifact project', result),
);
await run('pnpm', ['exec', 'ktx', 'dev', 'runtime', 'install', '--yes'], {
cwd: cleanInstallDir,
env: managedRuntimeEnv(cleanInstallDir),
timeout: 120_000,
}).then((result) => requireSuccess('install managed runtime', result));
}
async function main() {
const layout = packageArtifactLayout();
const root = await mkdtemp(join(tmpdir(), 'ktx-live-db-artifact-smoke-'));
const containerName = smokeContainerName();
let cleanInstallDir;
let daemonStarted = false;
try {
const postgresPort = await getAvailablePort();
const postgresUrl = buildPostgresUrl(postgresPort);
cleanInstallDir = join(root, 'npm-clean-install');
const projectDir = join(root, 'project');
await startPostgresContainer(containerName, postgresPort);
await waitForPostgres(containerName);
await seedPostgres(containerName);
await prepareCleanInstall(layout, cleanInstallDir);
await mkdir(projectDir, { recursive: true });
const init = await run('pnpm', ['exec', 'ktx', 'init', projectDir, '--name', 'artifact-live-database'], {
cwd: cleanInstallDir,
timeout: 30_000,
});
requireSuccess('ktx init', init);
await writeFile(join(projectDir, 'ktx.yaml'), buildKtxYaml(postgresUrl), 'utf8');
const databaseIntrospectionUrl = await startDaemon(cleanInstallDir);
daemonStarted = true;
const ingestRun = await run('pnpm', buildLiveDatabaseIngestArgs(projectDir, databaseIntrospectionUrl), {
cwd: cleanInstallDir,
env: managedRuntimeEnv(cleanInstallDir),
timeout: 120_000,
});
requireSuccess('ktx ingest warehouse --fast', ingestRun);
requireOutput('ktx ingest warehouse --fast', ingestRun, /Ingest finished/);
requireOutput('ktx ingest warehouse --fast', ingestRun, /Database schema/);
const runId = getRunId(ingestRun.stdout);
await assertPathExists(join(projectDir, '.ktx', 'db.sqlite'), 'SQLite local ingest state');
process.stdout.write(`Installed live-database artifact smoke passed: ${runId}\n`);
} finally {
if (daemonStarted && cleanInstallDir) {
await stopDaemon(cleanInstallDir);
}
await stopPostgresContainer(containerName);
await rm(root, { recursive: true, force: true });
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
try {
await main();
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
process.exitCode = 1;
}
}