@starci/hfs 4.7.28 → 4.7.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.29 - 2026-10-24
4
+ - Added: a new full app ships its test world (`scaffold/test-world.mjs`, `templates/be/test-world/`): the `@starci/test-world` declaration under `be/src/tests/world/`, an integration spec of readiness over the real database and one e2e spec per api app, so the integration, e2e and contract rows of the release gate run real specs.
5
+ - Added: `starci app upgrade [--plan]` on a full app without `be/src/tests/world/global-setup.ts` adds that world and the test devDependencies it lacks at canon pins, never overwriting a file (`HFS_UPGRADE_FILE_CONFLICT`); proven by `src/main.spec.mjs`.
6
+ - Changed: the bundled canon pins select zod 4.6.7 (GHSA-mrhx-c7xc-m3w5 is fixed in 4.6.6), @starci/cli 1.7.29, @starci/eslint-canon-be 3.4.37 and @starci/eslint-canon-fe 8.4.36.
7
+
3
8
  ## 4.7.28 - 2026-10-23
4
9
  - Added: `scripts/lib/sonar-new-code.mjs` joins the bundled runtime copy (the New Code definition `starci sonar project ensure` sets), proven by `src/sonar-new-code.spec.mjs`.
5
10
  - Fixed: the bundled gate-loosening detector no longer reads a quoted operand of a printed message as a ban-list row, nor the upkeep of an exception list the check stops reading as enforcement lost (`src/bypass-detector.spec.mjs`).
package/README.md CHANGED
@@ -61,6 +61,13 @@ unchanged lite tree through the full-edition view. The check is read-only; `--db
61
61
  comparison when the local stack is available. Applying `starci app upgrade --edition full` adds the full-only managed configuration,
62
62
  test layers and cli requirements without rewriting product code. Downgrade to lite is not supported.
63
63
 
64
+ A new full app ships its test world (`scaffold/test-world.mjs`): the `@starci/test-world` declaration under `be/src/tests/world/`
65
+ (the dev stack's Postgres per connection, the api apps, the cli app's migration runner as the migrate step), an integration spec of
66
+ readiness over the real database and one e2e spec per api app booted over the migrated database, beside the contract spec of the
67
+ be skeleton, so `test:integration`, `test:e2e` and `test:contract` of the release gate run real specs. On a full app that has no
68
+ `be/src/tests/world/global-setup.ts`, `starci app upgrade [--plan]` adds those files and the test devDependencies it lacks; a world
69
+ file that exists and differs refuses (`HFS_UPGRADE_FILE_CONFLICT`) and nothing is overwritten.
70
+
64
71
  ## Creating a service
65
72
 
66
73
  Every file of a spec-required role of `ruleParams.be.unitRoles` — a `*.service.ts`, a `*.cli.ts` of the cli feature root — owes exactly one colocated `<name>.<role>.spec.ts` (unit test standard), and any other file of a `ruleParams.be.logicRoles` role inside a `coverage: required` slot may carry one beside it. `starci app new` is the one way a service and its spec come into being together:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.28",
3
+ "version": "4.7.29",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,14 +29,14 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.4.36
32
+ version: 3.4.37
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.4.35
39
+ version: 8.4.36
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -76,14 +76,14 @@ pins:
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.7.28
79
+ version: 1.7.29
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.7.28
86
+ version: 4.7.29
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -336,10 +336,10 @@ pins:
336
336
  side: fe
337
337
  why: 'the zod resolver of react-hook-form (fe-stack forms); version read from the starci-academy package-lock on 2026-10-13.'
338
338
  'zod':
339
- version: 4.4.3
339
+ version: 4.6.7
340
340
  group: framework
341
341
  side: fe
342
- why: 'the schema library of front-end forms and boundary parsing (fe-stack forms); version read from the starci-academy package-lock on 2026-10-13.'
342
+ why: 'the schema library of front-end forms and boundary parsing (fe-stack forms), also an npm override of every app root; 4.6.7 (the registry latest on 2026-10-24) closes GHSA-mrhx-c7xc-m3w5 (high, fixed in 4.6.6), which npm audit --audit-level=high reported against 4.4.3.'
343
343
  '@reduxjs/toolkit':
344
344
  version: 2.11.2
345
345
  group: framework
@@ -1,7 +1,7 @@
1
1
  // GENERATED by scripts/cli/gen-catalog.mjs — do not edit
2
2
  export const CATALOG = {
3
3
  "schema": "starci/cli-catalog@1",
4
- "hash": "466ffd40fe4a003c976ac306bcb14639b1b57c947112e7899ef717a819e571f5",
4
+ "hash": "9ff398eeddb22776cc36e38b7206c0bfe03d3dec2c1e42614fabe349241b53ed",
5
5
  "global": [
6
6
  {
7
7
  "name": "json",
@@ -1648,7 +1648,7 @@ export const CATALOG = {
1648
1648
  "runtimeSide": "host"
1649
1649
  },
1650
1650
  "upgrade": {
1651
- "summary": "upgrade a lite app to the full edition (--edition full); a full app is already current",
1651
+ "summary": "upgrade a lite app to full (--edition full), or add the test world a full app lacks",
1652
1652
  "owner": "@starci/hfs",
1653
1653
  "flags": [
1654
1654
  {
@@ -1658,12 +1658,13 @@ export const CATALOG = {
1658
1658
  }
1659
1659
  ],
1660
1660
  "exit": {
1661
- "0": "already full",
1661
+ "0": "upgraded, completed, or the full app is already complete",
1662
1662
  "2": "refusal or bad usage"
1663
1663
  },
1664
1664
  "json": "none",
1665
1665
  "examples": [
1666
1666
  "starci app upgrade",
1667
+ "starci app upgrade --plan",
1667
1668
  "starci app upgrade --edition full --plan"
1668
1669
  ],
1669
1670
  "editions": [
@@ -1671,6 +1672,10 @@ export const CATALOG = {
1671
1672
  "lite"
1672
1673
  ],
1673
1674
  "impl": null,
1675
+ "conventions": [
1676
+ "a full app without be/src/tests/world/global-setup.ts gets the test world of a new full app and the test devDependencies it lacks",
1677
+ "a world file that exists and differs refuses HFS_UPGRADE_FILE_CONFLICT; nothing is overwritten"
1678
+ ],
1674
1679
  "runtimeSide": "host"
1675
1680
  }
1676
1681
  }
@@ -3204,6 +3209,11 @@ export const CATALOG = {
3204
3209
  "type": "string",
3205
3210
  "summary": "the optional commit body"
3206
3211
  },
3212
+ {
3213
+ "name": "body-file",
3214
+ "type": "string",
3215
+ "summary": "a file holding the commit body, or - for stdin; the way to pass a multi-line body"
3216
+ },
3207
3217
  {
3208
3218
  "name": "paths",
3209
3219
  "type": "list",
@@ -3233,7 +3243,8 @@ export const CATALOG = {
3233
3243
  "json": "starci/git-commit@1",
3234
3244
  "examples": [
3235
3245
  "starci git commit --type fix --scope cli --summary \"handle an empty index\"",
3236
- "starci git commit --type docs --summary \"explain backup refs\" --paths docs/git-governance.md --dry-run"
3246
+ "starci git commit --type docs --summary \"explain backup refs\" --paths docs/git-governance.md --dry-run",
3247
+ "starci git commit --type fix --summary \"keep the body\" --body-file -"
3237
3248
  ],
3238
3249
  "editions": [
3239
3250
  "full"
@@ -3252,7 +3263,8 @@ export const CATALOG = {
3252
3263
  "conventions": [
3253
3264
  "subjects use `type(scope): summary`; types are feat, fix, refactor, test, docs, chore, land and release",
3254
3265
  "keep one logical change in each commit and pass explicit paths instead of staging unrelated work",
3255
- "hooks run the L0 checks; never bypass them and never amend a pushed or landed commit"
3266
+ "hooks run the L0 checks; never bypass them and never amend a pushed or landed commit",
3267
+ "a multi-line body goes through --body-file (a file, or - for stdin): a command-shell launcher such as the Windows cmd shim ends an argument at its first newline"
3256
3268
  ],
3257
3269
  "runtimeSide": "host"
3258
3270
  },
package/scaffold/app.mjs CHANGED
@@ -21,6 +21,7 @@ import { dbTypesPath, generateDbTypes } from '../emit/db-types.mjs';
21
21
  import { TEMPLATES_DIR, appSource, imageFiles, render, renderTargets, writeTargets } from '../sync/index.mjs';
22
22
  import { ScaffoldError } from './service.mjs';
23
23
  import { SUPABASE_PORT_VARIABLES, supabasePortVars } from './supabase-ports.mjs';
24
+ import { testWorldFiles } from './test-world.mjs';
24
25
  import { FE_APP_SCRIPTS, PACKAGE_MANAGER, WORKSPACES, WORKSPACE_LINT, feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
25
26
  import { isLinkLike } from '../runtime/scripts/api/fs/is-link-like.mjs';
26
27
  import { unlinkOnly } from '../runtime/scripts/api/fs/lib.mjs';
@@ -685,6 +686,8 @@ export function scaffoldApp({ name, into, presets, edition = 'full', authCallbac
685
686
  { path: `fe/packages/${name}-${pkg}/tsconfig.build.json`, content: jsonText(fePackageBuildTsconfig()) },
686
687
  ])),
687
688
  ...['app', 'be', 'fe'].flatMap(scope => skeletonOf(scope, app, { sonarGate: sonarGateName(parseYaml(fs.readFileSync(SONAR_GATE_FILE, 'utf8')), app.edition), timestamp, registryVisibility, ...supabasePortVars(name) })),
689
+ // A full app has a test world (integration, e2e and contract are rows of its release gate); lite has none (ruling:R212/case-3, hosting amended by owner:lite-production-swarm).
690
+ ...(app.edition === 'lite' ? [] : testWorldFiles(app)),
688
691
  ];
689
692
  const writes = scaffoldWrites(root, original);
690
693
  try {
@@ -38,7 +38,7 @@ const words = name => name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z
38
38
  export const pascalOf = name => words(name).map(word => word[0].toUpperCase() + word.slice(1).toLowerCase()).join('');
39
39
  /** `InjectPrimaryEntityManager` -> `PRIMARY_ENTITY_MANAGER`. */
40
40
  const tokenNameOf = decorator => words(decorator.replace(/^Inject/, '')).map(word => word.toUpperCase()).join('_');
41
- const camelOf = name => { const pascal = pascalOf(name); return pascal[0].toLowerCase() + pascal.slice(1); };
41
+ export const camelOf = name => { const pascal = pascalOf(name); return pascal[0].toLowerCase() + pascal.slice(1); };
42
42
 
43
43
  const SERVICE_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
44
44
 
@@ -0,0 +1,125 @@
1
+ // test-world.mjs - the test world of a full app: the declaration of @starci/test-world under be/src/tests/world/ and one baseline
2
+ // real spec per world layer, rendered from templates/be/test-world/ for the api apps and connections hfs.json declares.
3
+ //
4
+ // A full app's release gate runs integration, e2e and contract (the managed test:* scripts), and the jest preset gives those
5
+ // projects only to an app that has be/src/tests/world/global-setup.ts. The scaffold writes these files into every new full app,
6
+ // and `starci app upgrade` adds the missing ones to an existing full app (never overwriting a file the app already has):
7
+ // - world/: the declaration (stack .starcistacks/dev, Postgres per connection, the api apps, the cli app's migration runner as
8
+ // the migrate step, a platform base for modules worlds), its spec entry and the library's global setup and teardown;
9
+ // - integration/readiness/: the readiness probe over the real database, ready, refused while the database is cut, ready again;
10
+ // - e2e/system-health/<app>-serves-once-migrated: each api app booted over the migrated database and called over HTTP;
11
+ // - fixtures/persistence/migrations.sql.ts: the ledger reads the e2e asserts.
12
+ // The contract layer is the app's own contract spec the be skeleton already ships (src/tests/contract/http-security/).
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { TEMPLATES_DIR } from '../sync/index.mjs';
16
+ import { ScaffoldError, camelOf, pascalOf } from './service.mjs';
17
+
18
+ const TEMPLATE_DIR = path.join(TEMPLATES_DIR, 'be', 'test-world');
19
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/u;
20
+
21
+ /** The file the jest preset reads to give an app its world projects; its presence means the app has a test world. */
22
+ export const WORLD_MARKER = 'be/src/tests/world/global-setup.ts';
23
+
24
+ const template = (file) => fs.readFileSync(path.join(TEMPLATE_DIR, ...file.split('/')), 'utf8').replace(/\r\n/g, '\n');
25
+
26
+ /** A template with its `{{name}}` slots filled; every slot must have a value and every value must be used. */
27
+ function fill(file, values) {
28
+ const used = new Set();
29
+ const text = template(file).replace(/\{\{([A-Za-z]+)\}\}/gu, (_, key) => {
30
+ if (!Object.hasOwn(values, key)) throw new ScaffoldError('HFS_SCAFFOLD_TEMPLATE_VARIABLE', `templates/be/test-world/${file} names {{${key}}}, which has no value`);
31
+ used.add(key);
32
+ return values[key];
33
+ });
34
+ const unused = Object.keys(values).filter((key) => !used.has(key));
35
+ if (unused.length) throw new ScaffoldError('HFS_SCAFFOLD_TEMPLATE_VARIABLE', `templates/be/test-world/${file} does not use ${unused.join(', ')}`);
36
+ return text;
37
+ }
38
+
39
+ /** `.name` for an identifier, `["name"]` otherwise: how a spec reads a record key. */
40
+ const access = (name) => (IDENTIFIER.test(name) ? `.${name}` : `[${JSON.stringify(name)}]`);
41
+ const constantOf = (name) => name.replace(/[^A-Za-z0-9]+/gu, '_').toUpperCase();
42
+ const union = (names) => names.map((name) => JSON.stringify(name)).join(' | ');
43
+ const migrationsConst = (connection) => `${constantOf(connection.name)}_APPLIED_MIGRATIONS`;
44
+
45
+ /** The api apps and connections of a full declaration, refused when the world has nothing to boot or migrate. */
46
+ function worldShape(app) {
47
+ const apis = app.sides.be.apps.filter((entry) => entry.kind === 'api');
48
+ const connections = app.sides.be.connections ?? [];
49
+ if (!apis.length) throw new ScaffoldError('HFS_SCAFFOLD_TEST_WORLD', 'the test world boots the api apps of hfs.json, and it declares none');
50
+ if (!connections.length) throw new ScaffoldError('HFS_SCAFFOLD_TEST_WORLD', 'the test world migrates the connections of hfs.json, and it declares none');
51
+ if (!app.sides.be.apps.some((entry) => entry.kind === 'cli' && entry.name === 'cli')) throw new ScaffoldError('HFS_SCAFFOLD_TEST_WORLD', 'the test world migrates through the cli app (be/apps/cli), and hfs.json declares none');
52
+ return { apis, connections };
53
+ }
54
+
55
+ function worldConfig({ apis, connections }) {
56
+ return fill('world/test-world.config.ts.tpl', {
57
+ appModuleImports: apis.map((api) => `import { AppModule as ${pascalOf(api.name)}App } from "../../../apps/${api.name}/src/app.module"`).join('\n'),
58
+ optionsImports: ['cliOptionsOf', ...apis.map((api) => `${camelOf(api.name)}OptionsOf`), 'migrateCli', 'platformBase'].sort().join(', '),
59
+ connectionDeclarations: connections.map((connection) => `{ name: ${JSON.stringify(connection.name)} }`).join(', '),
60
+ worldApps: apis.map((api) => ` ${IDENTIFIER.test(api.name) ? api.name : JSON.stringify(api.name)}: { module: ${pascalOf(api.name)}App, options: ${camelOf(api.name)}OptionsOf },`).join('\n'),
61
+ });
62
+ }
63
+
64
+ function worldOptions({ apis, connections }) {
65
+ const appOptions = apis.map((api) => [
66
+ '',
67
+ `/** The ${api.name} app, read the way its \`main.ts\` reads its environment, listening on the port the run reserved for it. */`,
68
+ `export const ${camelOf(api.name)}OptionsOf = (w: AppWiring): ${pascalOf(api.name)}Options =>`,
69
+ ` read${pascalOf(api.name)}Env(environmentOf(w, { PORT: String(w.apps${access(api.name)}.port) }))`,
70
+ '',
71
+ ].join('\n')).join('');
72
+ return fill('world/test-apps.options.ts.tpl', {
73
+ appEnvImports: apis.flatMap((api) => [
74
+ `import { read${pascalOf(api.name)}Env } from "../../../apps/${api.name}/src/${api.name}.env"`,
75
+ `import type { ${pascalOf(api.name)}Options } from "../../../apps/${api.name}/src/${api.name}.options"`,
76
+ ]).join('\n'),
77
+ appsUnion: union(apis.map((api) => api.name)),
78
+ connectionsUnion: union(connections.map((connection) => connection.name)),
79
+ environmentConnections: connections.map((connection) => ` ${connection.envPrefix}_URL: w.db${access(connection.name)}.url,`).join('\n'),
80
+ appOptions,
81
+ });
82
+ }
83
+
84
+ function migrationsFixture({ connections }) {
85
+ return fill('fixtures/migrations.sql.ts.tpl', {
86
+ migrationQueries: connections.map((connection) => [
87
+ '',
88
+ `/** The migrations the run applied to the ${connection.name} connection ($none), oldest first: the ledger the cli migrate step writes. */`,
89
+ `export const ${migrationsConst(connection)} = sql\`SELECT name FROM ${connection.name}_migrations ORDER BY id LIMIT 500\``,
90
+ '',
91
+ ].join('\n')).join(''),
92
+ });
93
+ }
94
+
95
+ /** The connection an api app's e2e reads back: the one it owns, else the first declared. */
96
+ const connectionOf = (api, connections) => connections.find((connection) => connection.owner === api.name) ?? connections[0];
97
+
98
+ function e2eSpec(api, connections) {
99
+ const connection = connectionOf(api, connections);
100
+ return fill('e2e/serves-once-migrated.e2e-spec.ts.tpl', {
101
+ app: api.name,
102
+ appAccess: access(api.name),
103
+ connection: connection.name,
104
+ connectionAccess: access(connection.name),
105
+ migrationsConst: migrationsConst(connection),
106
+ });
107
+ }
108
+
109
+ /**
110
+ * The test world files of the full app `app` (a resolved hfs declaration): [{ path, content }], app-relative, in a fixed order.
111
+ * Refused (HFS_SCAFFOLD_TEST_WORLD) when the declaration has no api app, no connection or no cli app to migrate through.
112
+ */
113
+ export function testWorldFiles(app) {
114
+ const shape = worldShape(app);
115
+ return [
116
+ { path: 'be/src/tests/world/test-world.config.ts', content: worldConfig(shape) },
117
+ { path: 'be/src/tests/world/test-apps.options.ts', content: worldOptions(shape) },
118
+ { path: 'be/src/tests/world/use-test-world.ts', content: fill('world/use-test-world.ts.tpl', {}) },
119
+ { path: WORLD_MARKER, content: fill('world/global-setup.ts.tpl', {}) },
120
+ { path: 'be/src/tests/world/global-teardown.ts', content: fill('world/global-teardown.ts.tpl', {}) },
121
+ { path: 'be/src/tests/fixtures/persistence/migrations.sql.ts', content: migrationsFixture(shape) },
122
+ { path: 'be/src/tests/integration/readiness/readiness.integration-spec.ts', content: fill('integration/readiness.integration-spec.ts.tpl', {}) },
123
+ ...shape.apis.map((api) => ({ path: `be/src/tests/e2e/system-health/${api.name}-serves-once-migrated.e2e-spec.ts`, content: e2eSpec(api, shape.connections) })),
124
+ ];
125
+ }
package/src/main.mjs CHANGED
@@ -107,7 +107,7 @@ starci app db migrate [--execute] [--local | --linked | --url-env <NAME>] [--jso
107
107
  starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--linked | --url-env <NAME>] [--json] [--cwd <dir>]
108
108
  starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--json] [--cwd <dir>]
109
109
  starci app release push [--execute] [--remote <name>] [--json] [--cwd <dir>]
110
- starci app upgrade --edition full [--plan] [--cwd <dir>]
110
+ starci app upgrade [--edition full] [--plan] [--cwd <dir>]
111
111
  starci app stack <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
112
112
  starci app stack <prepare|doctor|connect|disconnect|build-args|render|deploy|converge|verify|rollback|prune|backup|jobs> --env <env> [--execute] [--json] [--cwd <dir>]
113
113
  starci app explain <path> [--cwd <dir>] [--json]
@@ -0,0 +1,32 @@
1
+ import { appliedMigrations, {{migrationsConst}} } from "../../fixtures/persistence/migrations.sql"
2
+ import { useTestWorld } from "../../world/use-test-world"
3
+
4
+ /**
5
+ * system-health: the real {{app}} app booted over the database the run migrated through the cli, called over HTTP. It answers
6
+ * liveness and readiness while its ledger holds the applied migrations; readiness answers 503 while the database is cut and
7
+ * liveness stays up.
8
+ */
9
+ describe("system-health: the {{app}} app serves once its database is migrated (e2e)", () => {
10
+ const world = useTestWorld({ apps: ["{{app}}"] })
11
+
12
+ it("answers liveness and readiness over the migrated {{connection}} database", async () => {
13
+ const live = await world.apps{{appAccess}}.api.get("/health/live")
14
+ const ready = await world.apps{{appAccess}}.api.get("/health/ready")
15
+ const applied = await appliedMigrations(world.db{{connectionAccess}}, {{migrationsConst}})
16
+
17
+ expect(live.status).toBe(200)
18
+ expect(ready.status).toBe(200)
19
+ expect(ready.body).toEqual({ ok: true })
20
+ expect(applied.length).toBeGreaterThan(0)
21
+ })
22
+
23
+ it("answers 503 on readiness while the database is cut, and liveness stays up", async () => {
24
+ await world.interruptDatabase(async () => {
25
+ const ready = await world.apps{{appAccess}}.api.get("/health/ready")
26
+ const live = await world.apps{{appAccess}}.api.get("/health/live")
27
+
28
+ expect(ready.status).toBe(503)
29
+ expect(live.status).toBe(200)
30
+ })
31
+ })
32
+ })
@@ -0,0 +1,14 @@
1
+ import type { EntityManager } from "typeorm"
2
+ import { sql } from "@modules/platform/database"
3
+
4
+ /** One row of a connection's migration ledger: the name of a migration the cli migrate step applied. */
5
+ export interface AppliedMigrationRow {
6
+ /** The migration class name. */
7
+ readonly name: string
8
+ }
9
+ {{migrationQueries}}
10
+ /** Reads the ledger of one connection with one of the queries above. */
11
+ export const appliedMigrations = (
12
+ manager: EntityManager,
13
+ query: ReturnType<typeof sql>,
14
+ ): Promise<ReadonlyArray<AppliedMigrationRow>> => manager.query(query)
@@ -0,0 +1,28 @@
1
+ import { HttpSecurityModule, ReadinessService } from "@modules/platform/http-security"
2
+ import { httpSecurityOf } from "../../world/test-apps.options"
3
+ import { useTestWorld } from "../../world/use-test-world"
4
+
5
+ /**
6
+ * readiness: the required-dependency probe over the real primary database of the run, no HTTP door of ours. It answers ready
7
+ * while the database commits a transaction, refuses while the database is cut and answers ready again once it is back.
8
+ */
9
+ describe("readiness: the required database (integration)", () => {
10
+ const world = useTestWorld({
11
+ modules: [(w) => HttpSecurityModule.register(httpSecurityOf(w))],
12
+ })
13
+
14
+ const readiness = (): ReadinessService => world.resolve(ReadinessService)
15
+
16
+ it("reports ready while the real primary database commits a transaction", async () => {
17
+ await expect(readiness().probeRequired()).resolves.toEqual({ ok: true })
18
+ })
19
+
20
+ it("refuses readiness while the database is cut, then reports ready again", async () => {
21
+ await world.interruptDatabase(async () => {
22
+ await expect(readiness().probeRequired()).rejects.toMatchObject({
23
+ code: "HTTP_SECURITY_REQUIRED_DEPENDENCY_UNAVAILABLE",
24
+ })
25
+ })
26
+ await expect(readiness().probeRequired()).resolves.toEqual({ ok: true })
27
+ })
28
+ })
@@ -0,0 +1 @@
1
+ export { default } from "@starci/test-world/global-setup"
@@ -0,0 +1 @@
1
+ export { default } from "@starci/test-world/global-teardown"
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The typed options the test world hands to the real apps, the migrate step and the platform base of a modules world: what the
3
+ * `main.ts` of each app parses from the environment of a deployment, built from the wiring of the run. The databases are the real
4
+ * ones the library runs; the migrate step is the cli app's own migration runner, the one `cli migrate run` calls.
5
+ */
6
+ import type { DynamicModule } from "@nestjs/common"
7
+ import { NestFactory } from "@nestjs/core"
8
+ import type { WorldWiring } from "@starci/test-world"
9
+ import { ClockModule } from "@modules/platform/clock"
10
+ import { EnvSource } from "@modules/platform/config"
11
+ import { DatabaseModule, MigrationRunnerService } from "@modules/platform/database"
12
+ import { parseHttpSecurityConfig } from "@modules/platform/http-security"
13
+ import type { HttpSecurityOptions } from "@modules/platform/http-security"
14
+ import { LoggingModule } from "@modules/platform/logging"
15
+ import { RetryModule } from "@modules/platform/retry"
16
+ import { AppModule as CliApp } from "../../../apps/cli/src/app.module"
17
+ import { readCliEnv } from "../../../apps/cli/src/cli.env"
18
+ import { createCliAppOptions } from "../../../apps/cli/src/cli.options"
19
+ import type { CliAppOptions } from "../../../apps/cli/src/cli.options"
20
+ {{appEnvImports}}
21
+
22
+ /** The wiring of the world: its api apps and its database connections. */
23
+ export type AppWiring = WorldWiring<{{appsUnion}}, {{connectionsUnion}}>
24
+
25
+ /** The one browser origin the apps of the world accept. */
26
+ export const ALLOWED_ORIGIN = "http://localhost:3000"
27
+
28
+ /** The environment a deployment hands every app of the run: its mode, its origin allowlist and the URL of every connection. */
29
+ const environmentOf = (w: AppWiring, extra: Readonly<Record<string, string>> = {}): EnvSource =>
30
+ new EnvSource({
31
+ NODE_ENV: "test",
32
+ HTTP_SECURITY_ALLOWED_ORIGINS: ALLOWED_ORIGIN,
33
+ {{environmentConnections}}
34
+ ...extra,
35
+ })
36
+ {{appOptions}}
37
+ /** The origin allowlist and rate limits of the run, read the way the api apps read them. */
38
+ export const httpSecurityOf = (w: AppWiring): HttpSecurityOptions => parseHttpSecurityConfig(environmentOf(w))
39
+
40
+ /** The cli app over the run's databases, read the way its `main.ts` reads its environment. */
41
+ export const cliOptionsOf = (w: AppWiring): CliAppOptions => createCliAppOptions(readCliEnv(environmentOf(w)))
42
+
43
+ /** The migrate step of the run: the cli app composed over the run's databases, running the migrations of every connection. */
44
+ export const migrateCli = async (options: CliAppOptions): Promise<void> => {
45
+ const cli = await NestFactory.createApplicationContext(CliApp.register(options), { logger: false })
46
+ try {
47
+ await cli.get(MigrationRunnerService).run()
48
+ } finally {
49
+ await cli.close()
50
+ }
51
+ }
52
+
53
+ /** The platform base of a modules world: the clock, the timer, the logger and the database over the run's connections. */
54
+ export const platformBase = (w: AppWiring): ReadonlyArray<DynamicModule> => [
55
+ ClockModule.register({ isGlobal: true }),
56
+ RetryModule.register({ isGlobal: true }),
57
+ LoggingModule.register({ isGlobal: true }),
58
+ DatabaseModule.register({
59
+ isGlobal: true,
60
+ connections: [{ ...cliOptionsOf(w).database, entities: [], migrations: [] }],
61
+ }),
62
+ ]
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The declaration of the test world: selection and overrides only. The service list and the image versions come from the stack
3
+ * definition (`.starcistacks/dev`); the library runs the Postgres of every connection real behind toxiproxy, migrates it once per
4
+ * run through the cli app and boots the api apps in process.
5
+ */
6
+ import { defineTestWorld } from "@starci/test-world"
7
+ {{appModuleImports}}
8
+ import { {{optionsImports}} } from "./test-apps.options"
9
+
10
+ export const { useTestWorld, useSandbox } = defineTestWorld({
11
+ stack: ".starcistacks/dev",
12
+ stacks: {
13
+ postgresql: { connections: [{{connectionDeclarations}}] },
14
+ },
15
+ apps: {
16
+ {{worldApps}}
17
+ },
18
+ migrate: { module: migrateCli, options: cliOptionsOf },
19
+ modules: { base: platformBase },
20
+ })
@@ -0,0 +1,2 @@
1
+ /** The entry every spec imports: the `useTestWorld` and `useSandbox` of the declaration in `test-world.config.ts`. */
2
+ export { useSandbox, useTestWorld } from "./test-world.config"
package/upgrade/index.mjs CHANGED
@@ -1,4 +1,6 @@
1
- // starci app upgrade --edition full: the additive edition migration from a lite app to the full HFS tree.
1
+ // starci app upgrade --edition full: the additive edition migration from a lite app to the full HFS tree. On a full app,
2
+ // `starci app upgrade` completes it instead: it adds the test world (scaffold/test-world.mjs) the app lacks, with the world's
3
+ // devDependencies at canon pins, and never rewrites a file the app already has.
2
4
  //
3
5
  // The upgrade is deliberately assembled from the same full-edition render and scaffold templates as a new full app. It never
4
6
  // rewrites an existing product source file. A dry run returns the exact ordered operations; apply performs those operations and
@@ -14,6 +16,7 @@ import {
14
16
  } from "../runtime/scripts/hfs/slots.mjs";
15
17
  import { managedScriptNames } from "../runtime/scripts/hfs/architecture/managed-scripts.mjs";
16
18
  import { LOCK_STEP, npmLock, scaffoldWorkSeeds } from "../scaffold/app.mjs";
19
+ import { WORLD_MARKER, testWorldFiles } from "../scaffold/test-world.mjs";
17
20
  import { byCodeUnit } from "../report/order.mjs";
18
21
  import {
19
22
  TEMPLATES_DIR,
@@ -306,13 +309,54 @@ function additionStep(root, file, authored) {
306
309
  return null;
307
310
  }
308
311
 
312
+ /**
313
+ * The steps that complete an existing full app: every test world file it lacks when it has no world (an existing file that is not
314
+ * the scaffold's refuses, nothing is overwritten) and the full test toolchain devDependencies it lacks, at canon pins.
315
+ */
316
+ function fullCompletion(root, declaration) {
317
+ const steps = [];
318
+ if (!fs.existsSync(path.join(root, ...WORLD_MARKER.split("/")))) {
319
+ for (const file of testWorldFiles(declaration)) {
320
+ const step = additionStep(root, file, new Map());
321
+ if (step) steps.push({ ...step, why: "Add the full test world and its baseline integration and e2e specs." });
322
+ }
323
+ }
324
+ const current = readJson(root, "package.json");
325
+ const missing = FULL_DEV_DEPENDENCIES.filter(
326
+ (name) => current.devDependencies?.[name] === undefined && current.dependencies?.[name] === undefined,
327
+ );
328
+ if (missing.length) {
329
+ const pins = parseYaml(fs.readFileSync(PINS_FILE, "utf8")).pins;
330
+ const added = pinnedPackage(root, pins).devDependencies;
331
+ const devDependencies = Object.fromEntries(
332
+ Object.entries({ ...current.devDependencies, ...Object.fromEntries(missing.map((name) => [name, added[name]])) })
333
+ .sort(([a], [b]) => a.localeCompare(b)),
334
+ );
335
+ steps.push({
336
+ op: "rewrite-managed",
337
+ path: "package.json",
338
+ why: `Add the test world toolchain the full app lacks (${missing.join(", ")}) at canon pins.`,
339
+ content: jsonText({ ...current, devDependencies }),
340
+ kind: "file",
341
+ });
342
+ steps.push({ op: "rewrite-managed", path: "package-lock.json", why: `Resolve the added pinned dependencies with ${LOCK_STEP}.`, kind: "lock" });
343
+ }
344
+ return { managed: [], steps, dependenciesChanged: missing.length > 0 };
345
+ }
346
+
309
347
  async function model({ root, to, presets, manifest }) {
310
- if (to !== "full")
348
+ if (to !== undefined && to !== "full")
311
349
  throw new UpgradeError(
312
350
  "HFS_UPGRADE_TARGET_INVALID",
313
351
  "upgrade supports only --edition full",
314
352
  );
315
353
  const declaration = loadHfs(root, manifest);
354
+ if (declaration?.edition !== "lite") return fullCompletion(root, declaration);
355
+ if (to === undefined)
356
+ throw new UpgradeError(
357
+ "HFS_UPGRADE_USAGE",
358
+ "a lite app upgrades with starci app upgrade --edition full",
359
+ );
316
360
  const hadCli = declaration.sides.be.apps.some(
317
361
  (app) => app.kind === "cli" && app.name === "cli",
318
362
  );
@@ -449,7 +493,7 @@ async function upgradeEdition({
449
493
  if (!locked.ok)
450
494
  throw new UpgradeError(
451
495
  "HFS_UPGRADE_LOCK_FAILED",
452
- `\`${LOCK_STEP}\` could not resolve the upgraded lockfile (${locked.detail}); the lite app was restored`,
496
+ `\`${LOCK_STEP}\` could not resolve the upgraded lockfile (${locked.detail}); the app was restored`,
453
497
  );
454
498
  }
455
499
  } catch (error) {
@@ -474,11 +518,6 @@ function upgradeArguments(argv) {
474
518
  } else if (arg === "--plan") plan = true;
475
519
  else throw new UpgradeError("HFS_UPGRADE_USAGE", `unknown argument ${arg}`);
476
520
  }
477
- if (to === undefined)
478
- throw new UpgradeError(
479
- "HFS_UPGRADE_USAGE",
480
- "starci app upgrade needs --edition full",
481
- );
482
521
  return { root, to, plan };
483
522
  }
484
523
 
@@ -491,7 +530,7 @@ export async function upgradeMain(
491
530
  const steps = await upgradeEdition({ root, to, plan, presets });
492
531
  for (const step of steps) stdout(`${step.op} ${step.path}: ${step.why}\n`);
493
532
  stdout(
494
- `starci app upgrade --edition full${plan ? " --plan" : ""}: ${steps.length} step${steps.length === 1 ? "" : "s"} ${plan ? "planned; no file written" : "applied"}\n`,
533
+ `starci app upgrade${to ? ` --edition ${to}` : ""}${plan ? " --plan" : ""}: ${steps.length} step${steps.length === 1 ? "" : "s"} ${plan ? "planned; no file written" : "applied"}${steps.length === 0 ? " (the full app is complete)" : ""}\n`,
495
534
  );
496
535
  return 0;
497
536
  }