Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-starter-proof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rawsql-ts/ztd-cli": patch
---

Tighten the starter first-run experience by keeping the generated smoke QuerySpec typecheckable, wrapping aggregate Postgres connection failures with concise recovery steps, and extending publish artifact verification to run starter typecheck, DB-free smoke tests, and ztd-config before release.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { loadSqlResource } from '#features/_shared/loadSqlResource.js';

const smokeSqlResource = loadSqlResource(dirname(fileURLToPath(import.meta.url)), 'smoke.sql');

export interface SmokeQueryParams {
export interface SmokeQueryParams extends Record<string, unknown> {
user_id: number;
}

Expand Down
48 changes: 41 additions & 7 deletions packages/ztd-cli/templates/tests/support/ztd/verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ export async function verifyQuerySpecZtdCase<BeforeDb extends FixtureTree, Input
}
});

const result = execute(createQuerySpecExecutor(testkitClient, trace), querySpecCase.input);
await expect(result).resolves.toEqual(querySpecCase.output);
const result = await execute(createQuerySpecExecutor(testkitClient, trace), querySpecCase.input);
expect(result).toEqual(querySpecCase.output);
if (trace.length === 0) {
throw new Error(
`ZTD verifier did not execute any SQL for case "${querySpecCase.name}". Check the query boundary and fixture setup before accepting the case.`
Expand Down Expand Up @@ -165,8 +165,8 @@ export async function verifyQuerySpecTraditionalCase<BeforeDb extends FixtureTre

try {
client = await createPhysicalQuerySpecExecutor(pool, defaults, querySpecCase.beforeDb, trace);
const result = execute(client, querySpecCase.input);
await expect(result).resolves.toEqual(querySpecCase.output);
const result = await execute(client, querySpecCase.input);
expect(result).toEqual(querySpecCase.output);
if (querySpecCase.afterDb) {
await client.assertAfterDb(querySpecCase.afterDb);
}
Expand Down Expand Up @@ -224,7 +224,7 @@ function wrapStarterDbFailureIfHelpful(error: unknown, context: string, connecti
return error;
}

const originalMessage = error instanceof Error ? error.message : String(error);
const originalMessage = describeStarterDbOriginalError(error);
const wrapped = new Error(
[
`The starter Postgres database was not reachable while running ${context}.`,
Expand All @@ -234,21 +234,55 @@ function wrapStarterDbFailureIfHelpful(error: unknown, context: string, connecti
'',
'Next steps:',
'1. Start the bundled database with `docker compose up -d`.',
'2. If port 5432 is already in use, set another `ZTD_DB_PORT` in `.env` and rerun `docker compose up -d`.',
'2. If the configured host port is already in use, set another `ZTD_DB_PORT` in `.env` and rerun `docker compose up -d`.',
'3. Wait until Postgres is ready, then rerun `npx vitest run`.',
'',
'If Docker reports `all predefined address pools have been fully subnetted`, fix Docker networking first; changing `ZTD_DB_PORT` alone will not recover that error.'
].join('\n')
);
(wrapped as Error & { cause?: unknown }).cause = error;
return wrapped;
}

function describeStarterDbOriginalError(error: unknown): string {
if (
typeof error === 'object' &&
error !== null &&
'errors' in error &&
Array.isArray((error as { errors?: unknown }).errors)
) {
const messages = (error as { errors: unknown[] }).errors
.map((innerError) => (innerError instanceof Error ? innerError.message : String(innerError)))
.filter((message) => message.trim().length > 0);

if (messages.length > 0) {
return messages.join('; ');
}
}

if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}

return String(error);
}

function isStarterDbConnectionFailure(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false;
}

if (
'errors' in error &&
Array.isArray((error as { errors?: unknown }).errors) &&
(error as { errors: unknown[] }).errors.some((innerError) => isStarterDbConnectionFailure(innerError))
) {
return true;
}

if ('cause' in error && isStarterDbConnectionFailure((error as { cause?: unknown }).cause)) {
return true;
}

const code = 'code' in error ? String((error as { code?: unknown }).code) : '';
if (['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', '28P01', '3D000'].includes(code)) {
return true;
Expand Down
3 changes: 3 additions & 0 deletions packages/ztd-cli/tests/init.command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ test('init starter bootstraps compose, starter DDL, and smoke tests without visi
expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'smoke.sql'))).toContain('where user_id = :user_id::integer');
expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'boundary.ts'))).toContain('executeSmokeQuerySpec');
expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'boundary.ts'))).toContain('loadSqlResource');
expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'boundary.ts'))).toContain(
'export interface SmokeQueryParams extends Record<string, unknown>'
);
expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'setup-env.ts'))).toContain('ZTD_DB_PORT');
expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'setup-env.ts'))).toContain('ZTD_DB_URL');
expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'postgres-testkit.ts'))).toContain('createPostgresTestkitClient');
Expand Down
6 changes: 5 additions & 1 deletion packages/ztd-cli/tests/publishWorkflow.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ test('standalone pnpm proof apps use the installed ztd bin helper instead of pnp
publishedPackageModeScript.indexOf('function verifyPnpmAdapterInstall(packages) {'),
);
expect(starterSection).toContain('runInstalledZtdCli(appDir,');
expect(starterSection).not.toContain('"exec"');
expect(starterSection).not.toContain('"exec",\n "ztd"');
expect(starterSection).toContain('"tsc", "--noEmit", "-p", "tsconfig.json"');
expect(starterSection).toContain('"src/features/smoke/tests/smoke.boundary.test.ts"');
expect(starterSection).toContain('"src/features/smoke/tests/smoke.validation.test.ts"');
expect(starterSection).toContain('runInstalledZtdCli(appDir, ["ztd-config"])');

const adapterSection = publishedPackageModeScript.slice(
publishedPackageModeScript.indexOf('function verifyPnpmAdapterInstall(packages) {'),
Expand Down
37 changes: 37 additions & 0 deletions packages/ztd-cli/tests/ztdVerifier.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,43 @@ test('verifyQuerySpecTraditionalCase adds starter recovery steps when Postgres i
).rejects.toThrow(/localhost:55433[\s\S]*docker compose up -d/);
});

test('verifyQuerySpecZtdCase wraps AggregateError connection failures with starter recovery steps', async () => {
process.env.ZTD_DB_URL = 'postgres://ztd:ztd@localhost:15432/ztd';
const aggregateError = new AggregateError(
[
Object.assign(new Error('connect ECONNREFUSED ::1:15432'), {
code: 'ECONNREFUSED',
address: '::1',
port: 15432
}),
Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:15432'), {
code: 'ECONNREFUSED',
address: '127.0.0.1',
port: 15432
})
],
''
);
createPostgresTestkitClientMock.mockReturnValue({
query: vi.fn().mockRejectedValue(aggregateError),
close: closeMock
});

const { verifyQuerySpecZtdCase } = await import('../templates/tests/support/ztd/verifier');

await expect(
verifyQuerySpecZtdCase(
{
name: 'aggregate-db-down',
beforeDb: {},
input: {},
output: { ok: true }
},
(client) => client.query('select 1', {})
)
).rejects.toThrow(/localhost:15432[\s\S]*docker compose up -d[\s\S]*ZTD_DB_PORT/);
});

test('verifyQuerySpecTraditionalCase physically prepares fixtures and returns traditional evidence', async () => {
const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-verifier-project-'));
tempDirs.push(rootDir);
Expand Down
9 changes: 9 additions & 0 deletions scripts/verify-published-package-mode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,15 @@ function verifyPnpmStarterPath(packages) {
// Rebind workspace packages to the freshly packed tarballs so the scaffold install exercises the published manifests.
fs.writeFileSync(path.join(appDir, "package.json"), `${JSON.stringify(scaffoldPackageJson, null, 2)}\n`, "utf8");
runIn(appDir, PNPM, ["install", "--no-frozen-lockfile"]);
runIn(appDir, PNPM, ["exec", "tsc", "--noEmit", "-p", "tsconfig.json"]);
runIn(appDir, PNPM, [
"exec",
"vitest",
"run",
"src/features/smoke/tests/smoke.boundary.test.ts",
"src/features/smoke/tests/smoke.validation.test.ts",
]);
runInstalledZtdCli(appDir, ["ztd-config"]);

return appDir;
}
Expand Down
Loading