Skip to content

Commit 779c665

Browse files
committed
refactor(config): schema as single source of truth (sprint 1/5 for 3.0)
The 2.x assembleConfig listed every field into a withDefaults object; adding a field meant editing assembly.ts, schema.ts, types.ts and the builder. .aliases() got dropped that way in 2.5.1 because the four slipped out of sync with no test to catch it. Now: schema defines everything, assembleConfig is a 5-line transformer (normalize legacy pm2 input, parse). Defaults that were in the assembly (app, remotePath) moved into the schema. HooksConfigSchema swapped from z.function() to z.custom<HookFn>: z.function() wraps the user function and broke reference equality, so config.hooks.postDeploy was not the function the user passed. New schema-coverage test in builder.test.ts exercises every setter in one round-trip and asserts every field survives the parse. That is the test that would have caught .aliases(). All 192 unit tests pass. No public-API change.
1 parent f886839 commit 779c665

4 files changed

Lines changed: 86 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to `@devalade/shipnode` will be documented here.
44

55
## [Unreleased]
66

7+
### Changed (internal, towards 3.0)
8+
- **Schema is now the single source of truth for the config shape.** `assembleConfig` no longer enumerates every field into a `withDefaults` object — that pattern was how `.aliases()` got dropped in 2.5.1. The function is now a thin transformer: normalize the legacy `pm2 { name }` input onto canonical `pm2.apps`, then hand the whole partial to `ShipnodeConfigSchema.parse`. New fields added to the schema (and their builder methods) are preserved automatically. Schema defaults moved to the schema itself (`app: 'backend'`, `remotePath: '/var/www/app'`) so they are reachable without going through the assembler.
9+
- **`HooksConfigSchema` uses `z.custom<HookFn>` instead of `z.function()`.** `z.function()` wraps the user's function in a validator, breaking reference equality — `config.hooks.postDeploy === userFn` was false after parse. The custom-with-refine variant validates "is callable" while preserving the reference, which is what the deploy orchestrator needs to invoke the original hook. Aligned with zod 4's deprecation of `z.function()` for value validation.
10+
- **Schema-coverage test (`tests/unit/builder.test.ts`).** Every builder setter is exercised in a single round-trip; the resulting config is asserted to contain each field written by the builder. Prevents future drift between builder, schema, and assembly.
11+
712
## [2.5.2] - 2026-06-30
813

914
### Fixed

src/config/assembly.ts

Lines changed: 9 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,5 @@
1-
import type {
2-
ShipnodeConfig,
3-
SshConfig,
4-
HealthCheckConfig,
5-
Pm2App,
6-
Pm2Config,
7-
} from '../shared/types.js';
1+
import type { ShipnodeConfig, Pm2App, Pm2Config } from '../shared/types.js';
82
import { ShipnodeConfigSchema } from './schema.js';
9-
import { DEFAULTS } from '../shared/constants.js';
10-
11-
function defaultSshConfig(overrides?: Partial<SshConfig>): SshConfig {
12-
return {
13-
host: '',
14-
user: '',
15-
port: DEFAULTS.SSH_PORT,
16-
...overrides,
17-
};
18-
}
19-
20-
function defaultHealthCheckConfig(overrides?: Partial<HealthCheckConfig>): HealthCheckConfig {
21-
return {
22-
enabled: DEFAULTS.HEALTH_CHECK_ENABLED,
23-
path: DEFAULTS.HEALTH_CHECK_PATH,
24-
timeout: DEFAULTS.HEALTH_CHECK_TIMEOUT,
25-
retries: DEFAULTS.HEALTH_CHECK_RETRIES,
26-
startupDelay: DEFAULTS.HEALTH_CHECK_STARTUP_DELAY,
27-
...overrides,
28-
};
29-
}
303

314
// Loose input shape: also accepts the pre-multi-process `pm2: { name, instances, maxMemory }`
325
// + top-level `backend: { port }` form, which assembleConfig folds into pm2.apps[0].
@@ -62,36 +35,14 @@ function normalizePm2(
6235
/**
6336
* Assemble a partial config into a fully-validated ShipnodeConfig.
6437
*
65-
* Single boundary between user intent and trusted config. Accepts both the legacy
66-
* `pm2: { name } + backend: { port }` shape and the canonical `pm2: { apps: [...] }`
67-
* shape; emits the canonical shape only.
38+
* The schema is the single source of truth: all defaults, validation, and refinements
39+
* live there. assembleConfig only does what the schema cannot — normalize the legacy
40+
* pm2/backend input shape onto canonical pm2.apps — then hands the rest to zod parse.
41+
* Every field declared in the schema is preserved automatically; adding a new field
42+
* to the schema (and its corresponding builder method) requires no change here.
6843
*/
6944
export function assembleConfig(partial: AssembleInput): ShipnodeConfig {
70-
const pm2 = normalizePm2(partial.pm2, partial.backend);
71-
72-
const withDefaults = {
73-
app: partial.app ?? 'backend',
74-
ssh: partial.ssh ?? defaultSshConfig(),
75-
remotePath: partial.remotePath ?? '/var/www/app',
76-
pm2,
77-
domain: partial.domain,
78-
keepReleases: partial.keepReleases ?? DEFAULTS.KEEP_RELEASES,
79-
healthCheck: defaultHealthCheckConfig(partial.healthCheck),
80-
envFile: partial.envFile ?? DEFAULTS.ENV_FILE,
81-
nodeVersion: partial.nodeVersion ?? DEFAULTS.NODE_VERSION,
82-
pkgManager: partial.pkgManager,
83-
installCommand: partial.installCommand,
84-
sharedDirs: partial.sharedDirs,
85-
sharedFiles: partial.sharedFiles,
86-
buildDir: partial.buildDir,
87-
appRoot: partial.appRoot,
88-
database: partial.database,
89-
redis: partial.redis,
90-
backup: partial.backup,
91-
cloudflare: partial.cloudflare,
92-
hooks: partial.hooks,
93-
aliases: partial.aliases,
94-
};
95-
96-
return ShipnodeConfigSchema.parse(withDefaults) as ShipnodeConfig;
45+
const { backend, ...rest } = partial;
46+
const pm2 = normalizePm2(partial.pm2, backend);
47+
return ShipnodeConfigSchema.parse({ ...rest, pm2 }) as ShipnodeConfig;
9748
}

src/config/schema.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod';
22
import { isValidIpOrHostname, isValidDomain, isValidPm2Name } from '../domain/validation/ip.js';
3+
import type { HookFn } from '../shared/types.js';
34

45
export const SshConfigSchema = z.object({
56
host: z.string().refine(isValidIpOrHostname, 'Must be a valid IP address or hostname'),
@@ -76,15 +77,22 @@ export const CloudflareConfigSchema = z.object({
7677
bootstrapSshHost: z.string().optional(),
7778
}).optional();
7879

80+
// z.custom<HookFn> rather than z.function(): we only need a runtime "is it callable" check;
81+
// z.function() wraps the user's function in a validator-wrapper, breaking reference equality
82+
// and making `hooks.postDeploy === userFn` false after parse. The wrap is deprecated in zod 4
83+
// anyway. We validate callable-ness with a refine.
84+
const HookFnSchema = z
85+
.custom<HookFn>((fn) => typeof fn === 'function', { message: 'hook must be a function' });
86+
7987
export const HooksConfigSchema = z.object({
80-
preDeploy: z.function().args(z.any()).returns(z.promise(z.void()).or(z.void())).optional(),
81-
postDeploy: z.function().args(z.any()).returns(z.promise(z.void()).or(z.void())).optional(),
88+
preDeploy: HookFnSchema.optional(),
89+
postDeploy: HookFnSchema.optional(),
8290
}).optional();
8391

8492
export const ShipnodeConfigSchema = z.object({
85-
app: z.enum(['backend', 'frontend']),
93+
app: z.enum(['backend', 'frontend']).default('backend'),
8694
ssh: SshConfigSchema,
87-
remotePath: z.string().min(1, 'Remote path is required'),
95+
remotePath: z.string().min(1, 'Remote path is required').default('/var/www/app'),
8896
pm2: Pm2ConfigSchema.optional(),
8997
domain: z.string().refine(isValidDomain, 'Must be a valid domain (no protocol)').optional(),
9098
keepReleases: z.number().int().min(1).default(5),

tests/unit/builder.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,66 @@ import { describe, it, expect, vi } from 'vitest';
22
import { shipnode, ShipnodeBuilder } from '../../src/config/builder.js';
33

44
describe('ShipnodeBuilder', () => {
5+
// Schema-coverage regression: every setter on the builder must produce a field that
6+
// survives the round-trip through assembleConfig + zod parse. This is what would
7+
// have caught the .aliases() drop (and would catch any future field that gets added
8+
// to the builder but forgotten in the schema or vice-versa).
9+
it('every setter writes a field that survives the parse round-trip', () => {
10+
const preDeploy = vi.fn();
11+
const postDeploy = vi.fn();
12+
const config = new ShipnodeBuilder()
13+
.backend()
14+
.ssh({ host: '192.168.1.1', user: 'deploy', port: 2222, identityFile: '/k/id' })
15+
.deployTo('/var/www/myapp')
16+
.pm2('myapp', { instances: 2, maxMemory: '1G' })
17+
.port(3333)
18+
.worker({ name: 'mailer', command: 'node dist/mailer.js', instances: 1, maxMemory: '512M', env: { Q: 'mail' } })
19+
.domain('api.example.com')
20+
.keepReleases(10)
21+
.sharedDirs(['storage', 'uploads'])
22+
.sharedFiles(['.htpasswd'])
23+
.healthCheck('/healthz', { timeout: 60, retries: 5, startupDelay: 10 })
24+
.envFile('.env.production')
25+
.nodeVersion('22')
26+
.pkgManager('pnpm', { installCommand: 'pnpm install --frozen-lockfile' })
27+
.buildDir('build')
28+
.appRoot('apps/backend')
29+
.database({ type: 'postgres', host: 'localhost', port: 5432, name: 'db', user: 'u', password: 'p' })
30+
.redis({ host: 'localhost', port: 6379, password: 'rp' })
31+
.backup({ s3Bucket: 'backups', s3Prefix: 'prod', schedule: 'daily', retentionDays: 30 })
32+
.cloudflare({ zone: 'example.com', appHostname: 'api.example.com', tunnelName: 't', lockdownFirewall: true })
33+
.preDeploy(preDeploy)
34+
.postDeploy(postDeploy)
35+
.aliases({ migrate: 'pnpm db:apply', seed: 'pnpm db:seed' })
36+
.build();
37+
38+
expect(config.app).toBe('backend');
39+
expect(config.ssh).toEqual({ host: '192.168.1.1', user: 'deploy', port: 2222, identityFile: '/k/id' });
40+
expect(config.remotePath).toBe('/var/www/myapp');
41+
expect(config.pm2?.apps).toHaveLength(2);
42+
expect(config.pm2?.apps[0]).toMatchObject({ name: 'myapp', port: 3333, instances: 2, maxMemory: '1G' });
43+
expect(config.pm2?.apps[1]).toMatchObject({ name: 'mailer', command: 'node dist/mailer.js', instances: 1, maxMemory: '512M', env: { Q: 'mail' } });
44+
expect(config.domain).toBe('api.example.com');
45+
expect(config.keepReleases).toBe(10);
46+
expect(config.sharedDirs).toEqual(['storage', 'uploads']);
47+
expect(config.sharedFiles).toEqual(['.htpasswd']);
48+
expect(config.healthCheck).toEqual({ enabled: true, path: '/healthz', timeout: 60, retries: 5, startupDelay: 10 });
49+
expect(config.envFile).toBe('.env.production');
50+
expect(config.nodeVersion).toBe('22');
51+
expect(config.pkgManager).toBe('pnpm');
52+
expect(config.installCommand).toBe('pnpm install --frozen-lockfile');
53+
expect(config.buildDir).toBe('build');
54+
expect(config.appRoot).toBe('apps/backend');
55+
expect(config.database).toMatchObject({ type: 'postgres', host: 'localhost', port: 5432, name: 'db', user: 'u', password: 'p' });
56+
expect(config.redis).toEqual({ host: 'localhost', port: 6379, password: 'rp' });
57+
expect(config.backup).toMatchObject({ s3Bucket: 'backups', s3Prefix: 'prod', schedule: 'daily', retentionDays: 30 });
58+
expect(config.cloudflare).toMatchObject({ zone: 'example.com', appHostname: 'api.example.com', tunnelName: 't', lockdownFirewall: true });
59+
expect(config.hooks?.preDeploy).toBe(preDeploy);
60+
expect(config.hooks?.postDeploy).toBe(postDeploy);
61+
expect(config.aliases).toEqual({ migrate: 'pnpm db:apply', seed: 'pnpm db:seed' });
62+
});
63+
64+
565
it('builds a minimal backend config', () => {
666
const config = new ShipnodeBuilder()
767
.backend()

0 commit comments

Comments
 (0)