@starci/hfs 4.9.9 → 4.9.11

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +14 -0
  3. package/db/drift.mjs +289 -0
  4. package/db/dump.mjs +1 -1
  5. package/db/entity-list.mjs +9 -0
  6. package/db/migrate.mjs +1 -1
  7. package/lint/run.mjs +12 -2
  8. package/package.json +5 -3
  9. package/release/gate.mjs +81 -18
  10. package/release/index.mjs +19 -8
  11. package/release/push.mjs +4 -4
  12. package/release/records.mjs +54 -0
  13. package/release/urgent.mjs +129 -0
  14. package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
  15. package/runtime/knowledge/hfs/rules.yaml +74 -3
  16. package/runtime/knowledge/hfs/slots.yaml +36 -3
  17. package/runtime/modules/kernel/failure-codes.yaml +21 -0
  18. package/runtime/scripts/hfs/app-wiring.mjs +23 -2
  19. package/runtime/scripts/hfs/check.mjs +2 -2
  20. package/runtime/scripts/hfs/manifest-shape.mjs +2 -1
  21. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  22. package/src/main.mjs +7 -4
  23. package/stack/cli.mjs +10 -2
  24. package/stack/deploy.mjs +21 -4
  25. package/stack/urgent-floor.mjs +147 -0
  26. package/sync/ci-commands.mjs +3 -1
  27. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
  28. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
  29. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
  30. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
  31. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  32. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
  33. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
  34. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
  35. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
  36. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
  37. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
  38. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
  39. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +18 -8
  40. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.9.11 - 2026-10-29
4
+
5
+ - Added: `starci app db drift [--url-env <NAME>] [--json]` (`db/drift.mjs`, owner ruling 2026-10-29: lite tables are mapped by TypeORM entities while `db/migrations/*.sql` stays the only DDL and `synchronize` is false). It builds TypeORM's metadata from `be/src/platform/database/entities.ts` with the app's own typescript, typeorm and pg (a `DataSource` that never connects), applies `db/bootstrap.sql` and every active migration to a scratch container of the app's `components.db` image (or the empty database `--url-env` names), and reports every missing table or column, type mismatch and nullability mismatch as `BE_ENTITY_SCHEMA_DRIFT` (R333 in the bundled rule catalog, with its failure-code entry). No Docker, no app packages or an unreadable target is no verdict (`DB_DRIFT_NEEDS_DATABASE`, exit 2), never a green; a migration that does not apply to an empty database is `DB_DRIFT_MIGRATION_FAILED`. An app with an entity list gets a `db-drift` row in its release gate (default or declared gates) and `npx starci app db drift` after `npm run db:lint` in its managed CI's build job. Specs: `src/db-drift.spec.mjs` (real TypeORM metadata over a fixture app, stubbed schema) and `src/db-drift-docker.spec.mjs` (a real postgres:17-alpine scratch database where Docker runs, else the no-verdict answer); typeorm 0.3.31 and pg 8.23.1 are dev dependencies for them.
6
+
7
+ - Changed: the bundled slot manifest gives the model libraries their owners in `ruleParams.be.infraOwners` (`@starci/ai-router` to `integrations/ai-gateway`, `@langchain/*` to `integrations/ai`; `openai`, `langchain`, `@langchain/langgraph`, `@langchain/community` and the provider SDKs nowhere) and lists R326 and R327 on `be.integrations`; the bundled rule catalog carries R326 `BE_AI_KEY_CUSTODY` and R327 `BE_AI_GATEWAY_CLIENT`; the bundled canon pins name hfs 4.9.11, eslint-canon-be 3.6.8, eslint-canon-fe 9.4.9, the Grammar family 0.15.10 (grammar, eslint-grammar-core, eslint-grammar-offset-pop), and pin `@langchain/core` 1.3.1 and `@langchain/openai` 1.8.0.
8
+ - Added: the urgent release (owner ruling 2026-10-29, "deploy gấp không cần ci cd gì hết"). `starci app release push --urgent --reason "<text>" [--execute]` runs only the fast local checks among the release gates (`app-check`, `typecheck`, `lint`, `test`; the managed `npm run lint` narrowed to `starci app lint --changed` over the source files changed since the previous release tag), reusing a green row the gate already recorded for HEAD (same command, unchanged log; every gate row now lands in `<git common dir>/starci-release/<head12>/rows.json`), then tags HEAD (annotated), pushes the default branch and the tag in one atomic push through the hook (dry run first, no candidate CI) and writes `<head>.urgent.json` naming the role, the reason, each check with its verdict and the commit (`release/urgent.mjs`, `release/records.mjs`). A missing reason is `RELEASE_URGENT_REASON_MISSING`, a red check `RELEASE_URGENT_CHECK_RED` (nothing tagged or pushed).
9
+ - Added: `starci app release gate --backfill <tag> [--execute]` runs the full gate on the urgent release's own commit (checked out at HEAD; `RELEASE_BACKFILL_TAG_UNKNOWN`, `RELEASE_BACKFILL_NOT_ON_TAG`, `RELEASE_BACKFILL_NOT_URGENT` otherwise). Green writes its L4 record (`promotedFrom` the urgent record) and marks the urgent record promoted, with no new tag and no push; red records the verdict on the urgent record and files one fix ticket through the CLI's ticket queue (`RELEASE_BACKFILL_RED`), changing nothing on the remote or on prod.
10
+ - Added: the safety floor of `starci app stack deploy --urgent [--db-url-env <NAME>] [--backup-out <dir>]` (`stack/urgent-floor.mjs`), every failed step a refusal with its own code: the pushed urgent record of `--source-sha` (`STACK_URGENT_NO_RECORD`, `STACK_URGENT_NOT_PUSHED`), a declared database (`STACK_URGENT_NO_DATABASE`), the full public verify (`STACK_URGENT_SKIP_PUBLIC`), a fresh full database backup encrypted to the app's recipients (`STACK_URGENT_BACKUP_FAILED`) whose receipt matches the archive (`STACK_URGENT_BACKUP_UNVERIFIED`) and which a test decrypt with `SOPS_AGE_KEY_FILE` opens with every part (`STACK_URGENT_DECRYPT_UNAVAILABLE`, `STACK_URGENT_DECRYPT_FAILED`), an accepted previous release to roll back to (`STACK_URGENT_NO_ROLLBACK_TARGET`), `db migrate` previewed then executed (`STACK_URGENT_MIGRATE_PREVIEW_RED`, `STACK_URGENT_MIGRATE_FAILED`), then the release with its readiness verify and automatic rollback (`STACK_URGENT_RED_ROLLED_BACK`, `STACK_URGENT_ROLLBACK_FAILED`, `STACK_URGENT_DEPLOY_NOT_STARTED`). A refused floor exits 2 after printing the steps that led to it. `db dump` and `db migrate` export `dumpDatabase` and `migrateDatabase` for it.
11
+ - Added: slot manifest 3.8.0 adds the optional `fe.dev-server-generated` slot (`apps/*/AGENTS.md`, which `next dev` rewrites) and the slot field `rewrittenBy`; the release gate's clean-tree check (`dirtyPaths`, now over `--untracked-files=all`) passes over an untracked file such a slot owns, and over nothing else.
12
+ - Changed: the ESLint child of `starci app lint` and `starci app check` runs with `--max-old-space-size` from `STARCI_ESLINT_HEAP_MB`, which the CLI sets from the eslint memory of ops/liveness.yaml (8192); unset, it keeps node's default.
13
+ - Changed: the lite boot proof's worker has its own budget from its spawn (`STARCI_BOOT_PROOF_TIMEOUT_MS`, which the CLI sets from `heavy.bootProofTimeoutSeconds`), is retried once when it runs out, and a second timeout is `BOOT_PROOF_TIMEOUT` (R272, a warning: no verdict on a loaded host), never `BE_APP_BOOT_PROOF_MISSING`.
14
+ - Changed: the managed `stack-deploy.yml` passes the workflow token (`GH_TOKEN`, `actions: read`) to the deploy step, which reads the CI verdict of the tag's commit a production deploy waits for.
15
+
16
+ ## 4.9.10 - 2026-10-29
17
+
18
+ - Fixed: the back-end skeleton's `OriginGuard` (`platform/http-security`, BE-AUTH-5) refuses a state-changing request (`POST`, `PUT`, `PATCH`, `DELETE`) that names no usable origin, with `HTTP_SECURITY_ORIGIN_REJECTED` (403). It admitted a request with neither `Origin` nor `Referer`, which a session-cookie mutation must never be. `Referer` stands in only when `Origin` is absent, so a foreign or `null` `Origin` is never rescued by an allowed `Referer`; safe methods are not judged.
19
+ - Added: the `ServerToServer` marker of `platform/http-security` exempts a door that proves its caller itself from the origin check. `@Public({ reason: PublicReason.SignedWebhook })` (signature verified first, BE-WEBHOOK-2) and `@Credential({ kind })` (a header credential `CredentialGuard` authenticates) apply it, in full and lite, so the generated webhook doors keep answering providers that send no `Origin`. Both decorators, and `@RateLimit`, are typed with the new `DoorDecorator` of `platform/composition`.
20
+ - Added: `@RateLimit(tier, { redirect })` (BE-AUTH-4): a redirect-only door (an OAuth callback answering `303 /sign-in?auth=error`) names its error redirect, and a caller over the limit gets that 303 from `RateLimitGuard` instead of a 429 body. The redirect must be a same-site path (one leading `/`), refused with `HTTP_SECURITY_REQUEST_INVALID` when the decorator is applied. `ErrorsFilter` leaves a response a guard already sent as it is.
21
+ - Changed: the unit specs of `OriginGuard`, `RateLimitGuard` and `ErrorsFilter` cover the new paths at 100 per file; the bundled canon pins name hfs 4.9.10, eslint-canon-be 3.6.7, eslint-canon-fe 9.4.8, eslint-grammar-core 0.15.9 and eslint-grammar-offset-pop 0.15.9.
22
+
3
23
  ## 4.9.9 - 2026-10-29
4
24
 
5
25
  - Fixed: the back-end skeleton's `RateLimitGuard` counts in a store every replica shares (BE-AUTH-4), not a process-local map. Each hit is one upsert of the caller's window (tier and address) in the primary database, answering its hits; a hit that opens a fresh window prunes up to 100 ended windows (`FOR UPDATE SKIP LOCKED`). `platform/http-security` owns the table `http_security_rate_windows`: in full through its `persistence/` entity and migration (`httpSecurityEntities`, `httpSecurityMigrations`, registered on the primary connection by the api and cli apps), in lite in the Supabase baseline with forced RLS and `app_be` policies. The lite starter now ships `platform/database/database.sql.ts` (with `DATABASE_IDENTIFIER_REJECTED`) and exports `sql`, `InjectPrimaryEntityManager` and `PRIMARY_ENTITY_MANAGER`, which the guard and its spec consume; a lite starter leaves out a platform capability's TypeORM schema files. Needs `@starci/eslint-canon-be` 3.6.5, which admits `http-security` as a persistence capability.
package/README.md CHANGED
@@ -20,6 +20,7 @@ starci app upgrade [--plan]
20
20
  starci app stack <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
21
21
  starci app explain <path> [--cwd <dir>] [--json]
22
22
  starci app emit [--cwd <dir>]
23
+ starci app db drift [--url-env <NAME>] [--json] [--cwd <dir>]
23
24
  starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
24
25
  starci app new spec <file>.service.ts [--cwd <dir>]
25
26
  starci app new image [--cwd <dir>]
@@ -80,6 +81,19 @@ The lite additions are deliberately narrow:
80
81
  `starci app emit` continues to emit the enabled back-end snapshots and also writes `supabase/types/database.types.ts` when
81
82
  the `app.supabase.types` slot is enabled. The local Supabase stack must be running for generation.
82
83
 
84
+ `starci app db drift` holds a lite app's TypeORM entities to the schema its migrations build (R333, `BE_ENTITY_SCHEMA_DRIFT`; `db/drift.mjs`).
85
+ It loads the entity list `be/src/platform/database/entities.ts` with the app's own `typescript`, `typeorm` and `pg` (its `be/tsconfig.json`
86
+ path aliases resolved) and builds TypeORM's metadata through a postgres `DataSource` with `synchronize` false that never connects. It applies
87
+ `db/bootstrap.sql`, when present, and every active `db/migrations/*.sql`, each file in one transaction, to a scratch database: a throwaway
88
+ container of the image `.starcistacks/application-stacks.yaml` declares for `components.db`, or with `--url-env <NAME>` the database that
89
+ variable names, which must hold no relation in `public` yet (`DB_DRIFT_TARGET_NOT_EMPTY`). It reads the result from
90
+ `information_schema.columns` and `pg_catalog` and reports each entity column whose table or column is missing, whose type differs once
91
+ both are spelled as `format_type` names (an enum matches an enum of the name the entity gives), or whose nullability differs; a column no
92
+ entity maps is fine. Exit 0 is green, 1 is drift or a migration that does not apply to an empty database (`DB_DRIFT_MIGRATION_FAILED`),
93
+ and 2 is no verdict: a host without Docker or without the app's packages gets `DB_DRIFT_NEEDS_DATABASE` (needs a database), never a
94
+ green. An app with an entity list runs it as the `db-drift` row of `starci app release gate` and in the database step of its managed CI's
95
+ build job.
96
+
83
97
  Input bounds stay with the backend DTO: `MaxLength`, `ArrayMaxSize`, `Min` and `Max` become GraphQL `@inputBounds`
84
98
  input-field directives or JSON Schema constraints on the REST operation table's DTO properties. Codegen derives
85
99
  `INPUT_BOUNDS` beside GraphQL wire types and `REST_INPUT_BOUNDS` under the REST API owner's `__generated__/` directory.
package/db/drift.mjs ADDED
@@ -0,0 +1,289 @@
1
+ // drift.mjs - `starci app db drift`: the TypeORM entities of an app against the schema its SQL migrations build.
2
+ // The migrations (db/migrations/*.sql, after db/bootstrap.sql when present) are the only DDL and `synchronize` is false, so an entity only
3
+ // maps tables that already exist and can drift from them. The verb builds TypeORM's metadata from the app's entity list
4
+ // (be/src/platform/database/entities.ts, compiled in-process with the app's own typescript and loaded with the app's own typeorm) through a
5
+ // DataSource that never connects, applies the migrations to a scratch database (a throwaway container of the image the app's
6
+ // .starcistacks declares for its db component, or the empty database named by --url-env), reads that schema from
7
+ // information_schema.columns and pg_catalog, and reports every entity column whose table or column is missing, whose type differs (both
8
+ // normalised to PostgreSQL's format_type names) or whose nullability differs, under BE_ENTITY_SCHEMA_DRIFT. Database columns no entity maps
9
+ // are fine. Exit 0 is green, 1 is drift (or a migration that does not apply to an empty database), 2 is a refusal: no verdict, which is
10
+ // also what a host without Docker gets ("needs a database"), never a green.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import Module, { createRequire } from 'node:module';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { runVerb, readDeclaration } from '../release/index.mjs';
16
+ import { failure, refusal } from '../stack/errors.mjs';
17
+ import { firstLine, tool } from '../stack/context.mjs';
18
+ import { ENTITY_LIST, hasEntityList } from './entity-list.mjs';
19
+
20
+ export const DRIFT_CODE = 'BE_ENTITY_SCHEMA_DRIFT';
21
+ export const MIGRATIONS_DIR = 'db/migrations';
22
+ export const BOOTSTRAP_FILE = 'db/bootstrap.sql';
23
+ const NEEDS_DATABASE = 'DB_DRIFT_NEEDS_DATABASE';
24
+ const KEY = /^[A-Z][A-Z0-9_]*$/u;
25
+ const MIGRATION = /^\d{14}_[a-z0-9][a-z0-9_-]*\.sql$/u;
26
+ const READY_TIMEOUT_MS = 120_000;
27
+ const READY_POLL_MS = 500;
28
+
29
+ const needsDatabase = (reason) => refusal(NEEDS_DATABASE, `needs a database: ${reason}`);
30
+
31
+ /* ---- the entity side */
32
+
33
+ /** The be/tsconfig.json `paths` as [prefix, absolute target prefix] pairs (only `name/*` -> `dir/*` forms, the canon's). */
34
+ function aliases(ts, root) {
35
+ const file = path.join(root, 'be', 'tsconfig.json');
36
+ if (!fs.existsSync(file)) return [];
37
+ const { config } = ts.readConfigFile(file, ts.sys.readFile);
38
+ const options = config?.compilerOptions ?? {};
39
+ const base = path.resolve(path.dirname(file), options.baseUrl ?? '.');
40
+ return Object.entries(options.paths ?? {}).flatMap(([pattern, targets]) => (pattern.endsWith('/*') && targets?.[0]?.endsWith('/*')
41
+ ? [[pattern.slice(0, -1), path.resolve(base, targets[0].slice(0, -1))]] : []));
42
+ }
43
+
44
+ /** The source file a relative or aliased specifier names (exact, .ts, .js -> .ts, /index.ts), or null for a package. */
45
+ function sourceFile(specifier, from, pairs) {
46
+ const alias = pairs.find(([prefix]) => specifier.startsWith(prefix));
47
+ const target = alias ? path.join(alias[1], specifier.slice(alias[0].length))
48
+ : specifier.startsWith('.') ? path.resolve(path.dirname(from), specifier) : null;
49
+ if (!target) return null;
50
+ const candidates = [target, `${target}.ts`, target.replace(/\.js$/u, '.ts'), path.join(target, 'index.ts')];
51
+ return candidates.find((file) => fs.existsSync(file) && fs.statSync(file).isFile()) ?? null;
52
+ }
53
+
54
+ /**
55
+ * Requires the entity list with a `.ts` loader that transpiles each source file with the app's typescript (CommonJS, legacy decorators and
56
+ * their design metadata, as Nest compiles them) and points every relative or aliased require at its source file. The previous loader is
57
+ * restored afterwards.
58
+ */
59
+ function requireEntityList(ts, appRequire, root) {
60
+ const pairs = aliases(ts, root);
61
+ const previous = Module._extensions['.ts'];
62
+ Module._extensions['.ts'] = (module, filename) => {
63
+ const { outputText } = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { fileName: filename, compilerOptions: {
64
+ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, experimentalDecorators: true, emitDecoratorMetadata: true,
65
+ esModuleInterop: true, useDefineForClassFields: false } });
66
+ const code = outputText.replace(/require\("([^"]+)"\)/gu, (call, specifier) => {
67
+ const file = sourceFile(specifier, filename, pairs);
68
+ return file ? `require(${JSON.stringify(file)})` : call;
69
+ });
70
+ module._compile(code, filename);
71
+ };
72
+ try { return appRequire(path.join(root, ENTITY_LIST)); }
73
+ finally {
74
+ if (previous) Module._extensions['.ts'] = previous;
75
+ else delete Module._extensions['.ts'];
76
+ }
77
+ }
78
+
79
+ /** The entity classes an entity-list module exports: every class, directly or inside an exported array, once. */
80
+ const entityClasses = (exports) => [...new Set(Object.values(exports ?? {}).flat(Infinity).filter((value) => typeof value === 'function'))];
81
+
82
+ /** PostgreSQL spellings TypeORM's normalizeType passes through unchanged, mapped to the name format_type answers. */
83
+ const PG_ALIASES = new Map([['serial', 'integer'], ['serial4', 'integer'], ['bigserial', 'bigint'], ['serial8', 'bigint'], ['smallserial', 'smallint'],
84
+ ['serial2', 'smallint'], ['bool', 'boolean'], ['int', 'integer'], ['float', 'double precision'], ['dec', 'numeric']]);
85
+
86
+ /** One entity column as the comparison reads it: { entity, schema, table, column, type, enumName, nullable, view }. */
87
+ function entityColumn(driver, metadata, column) {
88
+ const base = String(driver.normalizeType(column)).toLowerCase();
89
+ const type = `${PG_ALIASES.get(base) ?? base}${column.isArray ? '[]' : ''}`;
90
+ return { entity: metadata.name, schema: metadata.schema ?? driver.options.schema ?? 'public', table: metadata.tableName, column: column.databaseName,
91
+ type, enumName: column.enumName ?? null, nullable: column.isNullable === true, view: metadata.tableType === 'view' };
92
+ }
93
+
94
+ /**
95
+ * The columns of every entity the app's entity list names, from TypeORM's own metadata: a postgres DataSource with synchronize false is
96
+ * built and its metadata derived (buildMetadatas), never initialized, so no connection is opened. typeorm, its pg driver and typescript are
97
+ * the app's (resolved from be/); an app without them gets no verdict.
98
+ */
99
+ export async function loadEntityColumns(appRoot) {
100
+ const root = path.resolve(appRoot);
101
+ const appRequire = createRequire(path.join(root, 'be', 'package.json'));
102
+ const load = (name) => {
103
+ try { return appRequire(name); } catch { throw needsDatabase(`the app's ${name} is not installed (npm ci first)`); }
104
+ };
105
+ const ts = load('typescript');
106
+ load('reflect-metadata');
107
+ const { DataSource } = load('typeorm');
108
+ load('pg');
109
+ const entities = entityClasses(requireEntityList(ts, appRequire, root));
110
+ if (entities.length === 0) return [];
111
+ const source = new DataSource({ type: 'postgres', synchronize: false, migrationsRun: false, entities });
112
+ try { await source.buildMetadatas(); } catch (error) { throw refusal('DB_DRIFT_ENTITIES_INVALID', `TypeORM refuses the entity list: ${firstLine(error.message)}`); }
113
+ return source.entityMetadatas.flatMap((metadata) => metadata.columns.filter((column) => !column.isVirtual && !column.isVirtualProperty)
114
+ .map((column) => entityColumn(source.driver, metadata, column)));
115
+ }
116
+
117
+ /* ---- the schema side */
118
+
119
+ /** Every column of the scratch schema with its format_type spelling (enums as `enum`, domains as their base type) and nullability. */
120
+ export const SCHEMA_SQL = `select c.table_schema as "schema", c.table_name as "table", c.column_name as "column", c.is_nullable = 'YES' as "nullable",
121
+ case when t.typtype = 'e' then 'enum' when e.typtype = 'e' then 'enum[]' when t.typtype = 'd' then pg_catalog.format_type(t.typbasetype, null)
122
+ else pg_catalog.format_type(a.atttypid, null) end as "type",
123
+ case when t.typtype = 'e' then t.typname when e.typtype = 'e' then e.typname end as "enumName",
124
+ cl.relkind in ('v', 'm') as "view"
125
+ from information_schema.columns c
126
+ join pg_catalog.pg_namespace n on n.nspname = c.table_schema
127
+ join pg_catalog.pg_class cl on cl.relnamespace = n.oid and cl.relname = c.table_name
128
+ join pg_catalog.pg_attribute a on a.attrelid = cl.oid and a.attname = c.column_name and a.attnum > 0 and not a.attisdropped
129
+ join pg_catalog.pg_type t on t.oid = a.atttypid
130
+ left join pg_catalog.pg_type e on e.oid = t.typelem and t.typcategory = 'A'
131
+ where c.table_schema not in ('pg_catalog', 'information_schema')`;
132
+
133
+ const PUBLIC_RELATIONS_SQL = `select count(*)::int as "count" from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace
134
+ where n.nspname = 'public' and c.relkind in ('r', 'p', 'v', 'm', 'f')`;
135
+
136
+ /** The SQL files the scratch database receives, in order: db/bootstrap.sql when present, then the active migrations by stamp. */
137
+ export function migrationFiles(root) {
138
+ const dir = path.join(root, MIGRATIONS_DIR);
139
+ if (!fs.existsSync(dir)) throw refusal('DB_DRIFT_NO_MIGRATIONS', `${MIGRATIONS_DIR}/ is missing: the migrations are the schema the entities are checked against`);
140
+ const migrations = fs.readdirSync(dir).filter((name) => MIGRATION.test(name)).sort().map((name) => `${MIGRATIONS_DIR}/${name}`);
141
+ return [...(fs.existsSync(path.join(root, BOOTSTRAP_FILE)) ? [BOOTSTRAP_FILE] : []), ...migrations];
142
+ }
143
+
144
+ /** The image of the app's db component (.starcistacks/application-stacks.yaml components.db.image), the one the scratch container runs. */
145
+ export function scratchImage(root) {
146
+ const image = readDeclaration(root)?.components?.db?.image;
147
+ if (typeof image !== 'string' || image.trim() === '') throw refusal('DB_DRIFT_NO_IMAGE', 'declare components.db.image in .starcistacks/application-stacks.yaml (the scratch database runs the app\'s own db image) or pass --url-env');
148
+ return image.trim();
149
+ }
150
+
151
+ /** Starts a throwaway container of `image` on a loopback port; returns { connection, stop }. Docker missing or failing is no verdict. */
152
+ function startScratch(ctx, image) {
153
+ const docker = (args, env) => tool(ctx, 'docker', args, { env: { ...ctx.env, ...env } });
154
+ const probe = docker(['version', '--format', '{{.Server.Version}}']);
155
+ if (probe.error || probe.status !== 0) throw needsDatabase('Docker is not available on this host; start it, or pass --url-env naming an empty database');
156
+ const password = randomBytes(18).toString('hex');
157
+ const name = `starci-db-drift-${randomBytes(6).toString('hex')}`;
158
+ const run = docker(['run', '-d', '--rm', '--name', name, '--label', 'starci.db-drift=1', '-e', 'POSTGRES_PASSWORD', '-p', '127.0.0.1::5432', image], { POSTGRES_PASSWORD: password });
159
+ const stop = () => { docker(['rm', '-f', name]); };
160
+ if (run.status !== 0) { stop(); throw needsDatabase(`docker could not start ${image}: ${firstLine(run.stderr)}`); }
161
+ const port = docker(['port', name, '5432/tcp']);
162
+ const match = /:(\d+)\s*$/mu.exec(String(port.stdout ?? '').split('\n')[0]);
163
+ if (port.status !== 0 || !match) { stop(); throw needsDatabase('docker published no port for the scratch database'); }
164
+ return { connection: { host: '127.0.0.1', port: Number(match[1]), user: 'postgres', password, database: 'postgres' }, stop, how: `scratch container of ${image}` };
165
+ }
166
+
167
+ /** Connects with the app's pg, retrying while a fresh container is still initialising; gives up after READY_TIMEOUT_MS. */
168
+ async function connect(ctx, Client, connection, retry) {
169
+ const started = ctx.now();
170
+ for (;;) {
171
+ const client = new Client(connection);
172
+ client.on('error', () => {});
173
+ try {
174
+ await client.connect();
175
+ await client.query('select 1');
176
+ return client;
177
+ } catch (error) {
178
+ await client.end().catch(() => {});
179
+ if (!retry || ctx.now() - started > READY_TIMEOUT_MS) throw needsDatabase(`the database did not accept a connection (${firstLine(error.message)})`);
180
+ await ctx.sleep(READY_POLL_MS);
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * The columns of the migrated schema: a scratch database (a container of the app's db image, or the empty database `urlEnv` names) receives
187
+ * db/bootstrap.sql and every active migration, each file in one transaction, then is read with SCHEMA_SQL. A file that does not apply to an
188
+ * empty database is a red result of its own (DB_DRIFT_MIGRATION_FAILED). The container is removed in every case.
189
+ */
190
+ export async function migratedSchema(ctx, appRoot, { urlEnv } = {}) {
191
+ const root = path.resolve(appRoot);
192
+ const files = migrationFiles(root);
193
+ let Client;
194
+ try { ({ Client } = createRequire(path.join(root, 'be', 'package.json'))('pg')); } catch { throw needsDatabase('the app\'s pg is not installed (npm ci first)'); }
195
+ let scratch;
196
+ let secrets = [];
197
+ if (urlEnv !== undefined) {
198
+ if (!KEY.test(urlEnv)) throw refusal('DB_USAGE', '--url-env must name an UPPER_CASE environment variable');
199
+ const value = ctx.env[urlEnv];
200
+ if (typeof value !== 'string' || !/^postgres(?:ql)?:\/\//u.test(value)) throw needsDatabase(`${urlEnv} holds no postgres connection URL`);
201
+ secrets = [value];
202
+ scratch = { connection: { connectionString: value }, stop: () => {}, how: `the database ${urlEnv} names` };
203
+ } else scratch = startScratch(ctx, scratchImage(root));
204
+ const scrub = (text) => secrets.reduce((out, secret) => out.replaceAll(secret, '[value]'), String(text));
205
+ let client;
206
+ try {
207
+ client = await connect(ctx, Client, scratch.connection, urlEnv === undefined);
208
+ if (urlEnv !== undefined) {
209
+ const { rows: [{ count }] } = await client.query(PUBLIC_RELATIONS_SQL);
210
+ if (count > 0) throw refusal('DB_DRIFT_TARGET_NOT_EMPTY', `the database ${urlEnv} names already holds ${count} relation(s) in public; drift migrates an empty scratch database itself`);
211
+ }
212
+ for (const file of files) {
213
+ try { await client.query(fs.readFileSync(path.join(root, file), 'utf8')); }
214
+ catch (error) { throw failure('DB_DRIFT_MIGRATION_FAILED', `${file} does not apply to an empty database: ${scrub(firstLine(error.message))}`); }
215
+ }
216
+ const { rows } = await client.query(SCHEMA_SQL);
217
+ return { columns: rows.map((row) => ({ ...row, type: String(row.type).toLowerCase() })), files, how: scratch.how };
218
+ } finally {
219
+ await client?.end().catch(() => {});
220
+ scratch.stop();
221
+ }
222
+ }
223
+
224
+ /* ---- the comparison */
225
+
226
+ const key = (...parts) => parts.join('\u0000');
227
+
228
+ /**
229
+ * The drift findings of `entityColumns` against `schemaColumns` ({schema, table, column, type, enumName, nullable, view} each): a missing
230
+ * table once per entity, then per column a missing column, a type mismatch (an enum matches an enum, by name when the entity names one) and,
231
+ * outside views, a nullability mismatch. Schema columns no entity maps are not findings.
232
+ */
233
+ export function compareSchema(entityColumns, schemaColumns) {
234
+ const tables = new Set(schemaColumns.map((column) => key(column.schema, column.table)));
235
+ const columns = new Map(schemaColumns.map((column) => [key(column.schema, column.table, column.column), column]));
236
+ const findings = [];
237
+ const missingTables = new Set();
238
+ const found = (column, kind, message, extra = {}) => findings.push({ code: DRIFT_CODE, level: 'error', kind, entity: column.entity,
239
+ schema: column.schema, table: column.table, column: column.column, message: `${column.entity} ${column.schema}.${column.table}${kind === 'missing-table' ? '' : `.${column.column}`}: ${message}`, ...extra });
240
+ for (const column of entityColumns) {
241
+ const table = key(column.schema, column.table);
242
+ if (!tables.has(table)) {
243
+ if (!missingTables.has(table)) found(column, 'missing-table', 'the table is missing from the migrated schema');
244
+ missingTables.add(table);
245
+ continue;
246
+ }
247
+ const actual = columns.get(key(column.schema, column.table, column.column));
248
+ if (!actual) { found(column, 'missing-column', 'the column is missing from the migrated schema'); continue; }
249
+ const sameEnum = column.type === actual.type && actual.type.startsWith('enum') && (!column.enumName || column.enumName === actual.enumName);
250
+ const typeMatches = actual.type.startsWith('enum') ? sameEnum : column.type === actual.type;
251
+ if (!typeMatches) {
252
+ const name = (type, enumName) => (type.startsWith('enum') && enumName ? `${type} ${enumName}` : type);
253
+ found(column, 'type-mismatch', `type mismatch: entity ${name(column.type, column.enumName)}, schema ${name(actual.type, actual.enumName)}`,
254
+ { expected: column.type, actual: actual.type });
255
+ }
256
+ if (!actual.view && !column.view && column.nullable !== actual.nullable) {
257
+ found(column, 'nullable-mismatch', `nullability mismatch: entity ${column.nullable ? 'nullable' : 'not null'}, schema ${actual.nullable ? 'nullable' : 'not null'}`,
258
+ { expected: column.nullable, actual: actual.nullable });
259
+ }
260
+ }
261
+ return findings;
262
+ }
263
+
264
+ /**
265
+ * `starci app db drift`. options: { urlEnv, entityColumns(root), schema(ctx, root, { urlEnv }) }, the last two seams for specs. An app with
266
+ * no entity list is green with nothing to compare.
267
+ */
268
+ export async function driftCheck(ctx, options = {}) {
269
+ const root = path.resolve(ctx.cwd);
270
+ if (!hasEntityList(root)) return { ok: true, lines: [`no entity list (${ENTITY_LIST}): nothing to compare`], data: { entities: 0, columns: 0, findings: [] } };
271
+ const entityColumns = await (options.entityColumns ?? loadEntityColumns)(root);
272
+ const schema = await (options.schema ?? migratedSchema)(ctx, root, { urlEnv: options.urlEnv });
273
+ const findings = compareSchema(entityColumns, schema.columns);
274
+ const entities = new Set(entityColumns.map((column) => column.entity)).size;
275
+ const head = `${entities} entit${entities === 1 ? 'y' : 'ies'}, ${entityColumns.length} column(s) against ${schema.files?.length ?? 0} SQL file(s) applied to ${schema.how ?? 'the schema'}`;
276
+ const lines = [head, ...findings.map((finding) => `${DRIFT_CODE} ${finding.message}`),
277
+ findings.length === 0 ? 'green: every entity column matches the migrated schema' : `red: ${findings.length} drift finding(s)`];
278
+ return { ok: findings.length === 0, lines, data: { entities, columns: entityColumns.length, files: schema.files ?? [], findings } };
279
+ }
280
+
281
+ /** Entry of the verb: `starci app db drift [--url-env NAME] [--json] [--cwd <dir>]`. */
282
+ export function dbDriftMain(argv, io = {}) {
283
+ return runVerb({ label: 'starci app db drift', name: 'db drift', code: 'DB_USAGE', io, argv,
284
+ spec: { boolean: ['--json'], value: ['--url-env'] },
285
+ run: (ctx, flags, positional) => {
286
+ if (positional.length) throw refusal('DB_USAGE', 'drift takes flags only');
287
+ return driftCheck(ctx, { urlEnv: flags['url-env'] });
288
+ } });
289
+ }
package/db/dump.mjs CHANGED
@@ -31,7 +31,7 @@ export function archiveParts(ctx, scratch, parts) {
31
31
  }
32
32
 
33
33
  /** Writes only ciphertext and its receipt permanently; a receipt is published only after header, size and hash checks. */
34
- function dumpDatabase(ctx, options = {}) {
34
+ export function dumpDatabase(ctx, options = {}) {
35
35
  if (!options.out) throw refusal('DB_USAGE', '--out is required');
36
36
  const out = outsideRepository(ctx.cwd, path.resolve(ctx.cwd, options.out));
37
37
  const session = backupSession(ctx, options);
@@ -0,0 +1,9 @@
1
+ // entity-list.mjs - where an app keeps its TypeORM entity list, read by `starci app db drift` and by the release gate that runs it.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /** The app's one entity list: it re-exports the arrays each capability's persistence/entities/index.ts exports. */
6
+ export const ENTITY_LIST = 'be/src/platform/database/entities.ts';
7
+
8
+ /** True when the app at `root` keeps an entity list: the apps whose entities `starci app db drift` checks and whose release gate runs it. */
9
+ export const hasEntityList = (root) => fs.existsSync(path.join(root, ENTITY_LIST));
package/db/migrate.mjs CHANGED
@@ -386,7 +386,7 @@ function applyPlan(session, plan) {
386
386
  * `starci app db migrate`. options: { execute, linked, urlEnv }. Dry run reads the remote history once and prints the plan; --execute (with
387
387
  * STARCI_DB_ARMED=1) applies it. Already baseline + forward: `nothing to do`, no mutating call, ok.
388
388
  */
389
- async function migrateDatabase(ctx, decl, options = {}) {
389
+ export async function migrateDatabase(ctx, decl, options = {}) {
390
390
  if (options.nativeTarget) boundSupabaseLauncher(ctx.cwd);
391
391
  const model = buildModel(ctx.cwd, decl);
392
392
  const session = { ctx, model, target: resolveTarget(ctx, decl, options) };
package/lint/run.mjs CHANGED
@@ -85,6 +85,15 @@ export function parseLintArgs(argv) {
85
85
  /** The typed ESLint run reads nothing above the app root: its TypeScript `sys` is bound by this preload (3.3, bound-sys.cjs). */
86
86
  const BOUND_ENV = 'STARCI_LINT_BOUND';
87
87
  const linterBoundArgs = () => ['--require', fileURLToPath(new URL('./bound-sys.cjs', import.meta.url))];
88
+ /**
89
+ * The heap of the ESLint child in MiB: the starci CLI sets it from its one owner, the eslint memory of ops/liveness.yaml `heavy` (a typed whole-side
90
+ * lint of a large app outgrows node's default heap). Unset, ESLint runs on node's default.
91
+ */
92
+ export const ESLINT_HEAP_ENV = 'STARCI_ESLINT_HEAP_MB';
93
+ export function eslintHeapArgs(env = process.env) {
94
+ const mb = Number(env[ESLINT_HEAP_ENV]);
95
+ return Number.isInteger(mb) && mb > 0 ? [`--max-old-space-size=${mb}`] : [];
96
+ }
88
97
 
89
98
  /** The child `node` ran with `args` from `cwd`: its exit status, signal, spawn error and the text of both streams, once it closes. */
90
99
  function runNode(args, options) {
@@ -103,7 +112,7 @@ function runNode(args, options) {
103
112
  }
104
113
 
105
114
  /** A linter's json results through the app's own install, run from `cwd` (a side folder): `{ results }` or `{ error }`. The child runs while the caller goes on. */
106
- async function runLinter({ cwd, pkg, bin, args, bound = null }) {
115
+ export async function runLinter({ cwd, pkg, bin, args, bound = null }) {
107
116
  let entry;
108
117
  try {
109
118
  const manifest = createRequire(path.join(cwd, 'package.json')).resolve(`${pkg}/package.json`);
@@ -112,7 +121,8 @@ async function runLinter({ cwd, pkg, bin, args, bound = null }) {
112
121
  } catch {
113
122
  return { error: `${pkg} is not installed for ${cwd}` };
114
123
  }
115
- const run = await runNode([...(bound ? linterBoundArgs() : []), entry, ...args], { cwd, ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) });
124
+ const heap = pkg === 'eslint' ? eslintHeapArgs() : [];
125
+ const run = await runNode([...heap, ...(bound ? linterBoundArgs() : []), entry, ...args], { cwd, ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) });
116
126
  // ESLint prints its report on stdout; stylelint uses stderr. JSON does not prove a completed child: only exits 0/1
117
127
  // without a spawn error or signal are measurable, and exit 1 must carry findings.
118
128
  let results;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.9.9",
3
+ "version": "4.9.11",
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",
@@ -26,20 +26,22 @@
26
26
  "smol-toml": "1.9.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@typescript-eslint/eslint-plugin": "8.71.0",
30
- "@typescript-eslint/parser": "8.71.0",
31
29
  "@graphql-codegen/cli": "7.4.6",
32
30
  "@graphql-codegen/client-preset": "6.2.3",
33
31
  "@nestjs/common": "11.2.5",
34
32
  "@nestjs/core": "11.2.5",
35
33
  "@nestjs/graphql": "13.4.5",
36
34
  "@nestjs/testing": "11.2.5",
35
+ "@typescript-eslint/eslint-plugin": "8.71.0",
36
+ "@typescript-eslint/parser": "8.71.0",
37
37
  "class-validator": "0.15.1",
38
38
  "eslint": "10.11.0",
39
39
  "graphql": "16.14.2",
40
+ "pg": "8.23.1",
40
41
  "prettier": "3.9.6",
41
42
  "reflect-metadata": "0.2.2",
42
43
  "rxjs": "7.8.2",
44
+ "typeorm": "0.3.31",
43
45
  "typescript": "5.9.3",
44
46
  "zod": "4.6.7"
45
47
  },