@starci/hfs 4.7.4 → 4.7.6
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 +3 -0
- package/db/local-migrate.mjs +181 -0
- package/db/migrate.mjs +11 -3
- package/emit/db-types.mjs +1 -1
- package/package.json +4 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
- package/runtime/packages/cli/src/catalog.generated.mjs +9 -4
- package/runtime/scripts/api/supabase/database-push.mjs +5 -0
- package/runtime/scripts/api/supabase/lib.mjs +23 -12
- package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +15 -2
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +13 -7
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +14 -11
- package/src/main.mjs +1 -1
- package/sync/index.mjs +2 -1
- package/templates/app/quality-config/codecov.yml +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.5 - 2026-10-18
|
|
4
|
+
- Fixed: Codecov ignores the same slot-derived coverage complement as Sonar, including imported config, module and persistence files in shard reports. Project, patch and component targets remain at 100% on every measured logic file.
|
|
5
|
+
|
|
3
6
|
## 4.7.4 - 2026-10-18
|
|
4
7
|
- Fixed: the managed Sonar configuration uses `sonar.javascript.node.maxspace`, the analyzer's supported Node heap property, with the existing 6144 MiB CI Node budget. This leaves memory for Java and the OS on an 8 GB runner; `sonar.nodejs.maxspace` did not apply the configured limit.
|
|
5
8
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Explicit-local migration applies only the app's active SQL chain through the Supabase API owner.
|
|
2
|
+
// Supabase CLI v2.119.0: db/push, migration-pending, migration-list and db-config.toml-read (read 2026-10-18).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { parseEnv } from 'node:util';
|
|
7
|
+
import { parse as parseToml } from 'smol-toml';
|
|
8
|
+
import { databasePush } from '../runtime/scripts/api/supabase/database-push.mjs';
|
|
9
|
+
import { isMigrationName, migrationStamp } from '../runtime/scripts/hfs/rules/database-migrations.mjs';
|
|
10
|
+
import { CONFIG_FILE, MIGRATIONS_DIR } from '../runtime/scripts/hfs/rules/database-constants.mjs';
|
|
11
|
+
import { failure, refusal } from '../stack/errors.mjs';
|
|
12
|
+
|
|
13
|
+
const configError = (reason) => refusal('DB_CONFIG_INVALID', reason);
|
|
14
|
+
const encrypted = (value) => typeof value === 'string' && value.includes('encrypted:');
|
|
15
|
+
const byteOrder = (left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right));
|
|
16
|
+
|
|
17
|
+
/** Never quote a parser/tool diagnostic: config, env and child streams can contain credentials. */
|
|
18
|
+
function readFile(file, optional = false) {
|
|
19
|
+
try {
|
|
20
|
+
const stat = fs.lstatSync(file);
|
|
21
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw configError('local migration inputs must be regular files');
|
|
22
|
+
return fs.readFileSync(file, 'utf8');
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (optional && error.code === 'ENOENT') return null;
|
|
25
|
+
throw configError('local migration inputs must be readable regular files');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requireDirectory(directory) {
|
|
30
|
+
try {
|
|
31
|
+
const stat = fs.lstatSync(directory);
|
|
32
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) return;
|
|
33
|
+
} catch { /* The refusal below withholds filesystem diagnostics. */ }
|
|
34
|
+
throw configError('local migration requires the existing app directories, without links');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function requirePlainConfig(value) {
|
|
38
|
+
if (encrypted(value)) throw configError('local migration does not decrypt encrypted configuration');
|
|
39
|
+
if (value && typeof value === 'object') for (const item of Object.values(value)) requirePlainConfig(item);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The pinned CLI searches these exact files; interpolation/escapes are held rather than partially interpreted. */
|
|
43
|
+
function projectEnvironment(root, ambient) {
|
|
44
|
+
const selected = ambient.SUPABASE_ENV || 'development';
|
|
45
|
+
if (!/^[A-Za-z0-9_-]+$/.test(selected)) throw configError('local migration requires an unambiguous Supabase environment selector');
|
|
46
|
+
const names = [`.env.${selected}.local`, ...(selected === 'test' ? [] : ['.env.local']), `.env.${selected}`, '.env'];
|
|
47
|
+
const values = {};
|
|
48
|
+
const files = [];
|
|
49
|
+
for (const dir of [path.join(root, 'supabase'), root]) {
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
const file = path.join(dir, name);
|
|
52
|
+
const text = readFile(file, true);
|
|
53
|
+
files.push([file, text]);
|
|
54
|
+
if (text === null) continue;
|
|
55
|
+
// Pinned godotenv expansion and double-quote unescaping can synthesize the encryption prefix.
|
|
56
|
+
if (encrypted(text) || /[$\\]/.test(text)) throw configError('local migration requires literal unencrypted dotenv inputs');
|
|
57
|
+
// Keep only the common literal assignment grammar shared by Node and the pinned CLI.
|
|
58
|
+
for (const line of text.split(/\r?\n/)) {
|
|
59
|
+
if (/^\s*(?:#.*)?$/.test(line)) continue;
|
|
60
|
+
if (!/^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^'"#\r\n]*)(?:\s+#.*)?\s*$/.test(line)) {
|
|
61
|
+
throw configError('local migration requires literal single-line dotenv assignments');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let parsed;
|
|
65
|
+
try { parsed = parseEnv(text); } catch { throw configError('local migration dotenv inputs are not readable assignments'); }
|
|
66
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
67
|
+
if (!Object.hasOwn(ambient, key) && !Object.hasOwn(values, key)) values[key] = value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { values: { ...values, ...ambient }, files };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function expanded(value, env) {
|
|
75
|
+
if (typeof value !== 'string') return value;
|
|
76
|
+
const reference = /^env\(([A-Za-z_][A-Za-z0-9_]*)\)$/.exec(value);
|
|
77
|
+
const result = reference ? env[reference[1]] : value;
|
|
78
|
+
if (typeof result !== 'string' || result.startsWith('env(')) throw configError('local migration configuration has an unresolved environment reference');
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const overridden = (name, value, env) => expanded(typeof env[name] === 'string' && env[name] !== '' ? env[name] : value, env);
|
|
83
|
+
|
|
84
|
+
function targetIdentity(config, env) {
|
|
85
|
+
const project = overridden('SUPABASE_PROJECT_ID', config.project_id, env);
|
|
86
|
+
const portValue = overridden('SUPABASE_DB_PORT', config.db?.port, env);
|
|
87
|
+
const port = typeof portValue === 'string' && /^\d+$/.test(portValue) ? Number(portValue) : portValue;
|
|
88
|
+
const enabled = overridden('SUPABASE_DB_MIGRATIONS_ENABLED', config.db?.migrations?.enabled ?? true, env);
|
|
89
|
+
if (typeof project !== 'string' || !/^[A-Za-z0-9_-]+$/.test(project) || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
90
|
+
throw configError('local migration requires an explicit local project identity and database port');
|
|
91
|
+
}
|
|
92
|
+
if (enabled !== true && !(typeof enabled === 'string' && /^(?:1|t|T|true|TRUE|True)$/.test(enabled))) {
|
|
93
|
+
throw configError('local migration requires migrations to be explicitly enabled by the effective configuration');
|
|
94
|
+
}
|
|
95
|
+
return { project, port };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Snapshot only in memory; no plaintext configuration, values or hashes reach the result. */
|
|
99
|
+
function inputs(ctx) {
|
|
100
|
+
const root = path.resolve(ctx.cwd);
|
|
101
|
+
requireDirectory(root);
|
|
102
|
+
requireDirectory(path.join(root, 'supabase'));
|
|
103
|
+
requireDirectory(path.join(root, MIGRATIONS_DIR));
|
|
104
|
+
const appText = readFile(path.join(root, 'hfs.json'));
|
|
105
|
+
let app;
|
|
106
|
+
try { app = JSON.parse(appText); } catch { throw configError('local migration requires a readable app declaration'); }
|
|
107
|
+
if (app?.kind !== 'app' || !['full', 'lite'].includes(app.edition ?? 'full')) throw configError('local migration requires a lite or full app');
|
|
108
|
+
const configText = readFile(path.join(root, CONFIG_FILE));
|
|
109
|
+
let config;
|
|
110
|
+
try { config = parseToml(configText); } catch { throw configError('local migration requires valid Supabase TOML'); }
|
|
111
|
+
requirePlainConfig(config);
|
|
112
|
+
for (const value of Object.values(ctx.env)) if (encrypted(value)) throw configError('local migration does not decrypt encrypted environment values');
|
|
113
|
+
const { values: env, files: envFiles } = projectEnvironment(root, ctx.env);
|
|
114
|
+
const target = targetIdentity(config, env);
|
|
115
|
+
let names;
|
|
116
|
+
try { names = fs.readdirSync(path.join(root, MIGRATIONS_DIR)).sort(byteOrder); } catch { throw configError('local migrations are not readable'); }
|
|
117
|
+
// Match the pinned CLI's unanchored first-entry legacy-init skip, including nested names.
|
|
118
|
+
const legacy = /([0-9]{14})_init\.sql/.exec(names[0] ?? '');
|
|
119
|
+
if (legacy && Number(legacy[1]) < 20211209000000) throw refusal('DB_STAMP_ORDER', 'the local chain contains a legacy init migration the installed CLI skips');
|
|
120
|
+
const migrations = [];
|
|
121
|
+
const stamps = new Set();
|
|
122
|
+
for (const name of names.filter((entry) => entry.endsWith('.sql'))) {
|
|
123
|
+
const stamp = name.slice(0, name.indexOf('_'));
|
|
124
|
+
if (!isMigrationName(name) || migrationStamp(stamp) === null) throw refusal('DB_MIGRATION_NAME', 'local migrations require valid UTC-stamped filenames');
|
|
125
|
+
if (stamps.has(stamp)) throw refusal('DB_STAMP_COLLISION', 'local migrations contain a duplicate timestamp');
|
|
126
|
+
stamps.add(stamp);
|
|
127
|
+
migrations.push([name, readFile(path.join(root, MIGRATIONS_DIR, name))]);
|
|
128
|
+
}
|
|
129
|
+
const digest = createHash('sha256').update(JSON.stringify({ root, appText, configText, envFiles, env, target, migrations })).digest('hex');
|
|
130
|
+
return { root, target, names: migrations.map(([name]) => name), digest };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function unchanged(ctx, snapshot) {
|
|
134
|
+
const current = inputs(ctx);
|
|
135
|
+
if (current.digest !== snapshot.digest) throw failure('DB_FINAL_MISMATCH', 'local migration inputs changed during the operation');
|
|
136
|
+
return current;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Only the specific Supabase owner can spawn; failures and malformed JSON never expose child output. */
|
|
140
|
+
function push(ctx, snapshot, dryRun) {
|
|
141
|
+
const result = databasePush(snapshot.root, ['--local', '--skip-vault', '--workdir', snapshot.root, '--output-format', 'json', ...(dryRun ? ['--dry-run'] : ['--yes'])], { env: ctx.env, run: ctx.spawn });
|
|
142
|
+
if (result?.error || result?.signal || result?.status !== 0) throw failure('DB_CLI_FAILED', 'the local Supabase migration command failed; database output was withheld');
|
|
143
|
+
let payload;
|
|
144
|
+
try { payload = JSON.parse(result.stdout); } catch { throw failure('DB_CLI_FAILED', 'the local migration command returned unreadable JSON'); }
|
|
145
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.hasOwn(payload, 'error') || payload._tag === 'Error' || payload.dryRun !== dryRun || typeof payload.upToDate !== 'boolean'
|
|
146
|
+
|| !Array.isArray(payload.migrations) || !payload.migrations.every((name) => typeof name === 'string')
|
|
147
|
+
|| !Array.isArray(payload.seeds) || payload.seeds.length !== 0 || !Array.isArray(payload.roles) || payload.roles.length !== 0) {
|
|
148
|
+
throw failure('DB_PUSH_STRAY', 'the local migration command returned an unexpected result');
|
|
149
|
+
}
|
|
150
|
+
const pending = payload.migrations;
|
|
151
|
+
const expected = snapshot.names.slice(snapshot.names.length - pending.length);
|
|
152
|
+
if (pending.length > snapshot.names.length || pending.some((name, index) => name !== expected[index]) || payload.upToDate !== (pending.length === 0)) {
|
|
153
|
+
throw failure('DB_PUSH_STRAY', 'the local migration result is not an ordered suffix of the authored chain');
|
|
154
|
+
}
|
|
155
|
+
return pending;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Preview by default; the caller admits execute only after the existing database arming check. No history repair, reset or linked fallback. */
|
|
159
|
+
export function migrateLocalDatabase(ctx, { execute = false } = {}) {
|
|
160
|
+
const snapshot = inputs(ctx);
|
|
161
|
+
const pending = push(ctx, snapshot, true);
|
|
162
|
+
unchanged(ctx, snapshot);
|
|
163
|
+
let applied = [];
|
|
164
|
+
if (execute && pending.length > 0) {
|
|
165
|
+
const fresh = push(ctx, snapshot, true);
|
|
166
|
+
unchanged(ctx, snapshot);
|
|
167
|
+
if (fresh.some((name, index) => name !== pending[index]) || fresh.length !== pending.length) throw failure('DB_FINAL_MISMATCH', 'the local migration plan changed before apply');
|
|
168
|
+
applied = push(ctx, snapshot, false);
|
|
169
|
+
unchanged(ctx, snapshot);
|
|
170
|
+
if (applied.length !== fresh.length || applied.some((name, index) => name !== fresh[index])) {
|
|
171
|
+
throw failure('DB_FINAL_MISMATCH', 'the applied local migrations differ from the reviewed pending plan');
|
|
172
|
+
}
|
|
173
|
+
if (push(ctx, snapshot, true).length !== 0) throw failure('DB_FINAL_MISMATCH', 'the local migration version history is not fully applied');
|
|
174
|
+
unchanged(ctx, snapshot);
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
lines: [`target local project ${snapshot.target.project}`, `pending SQL ${pending.join(', ') || 'none'}`, execute && applied.length ? `applied ${applied.length} migration(s); local version history is current` : 'nothing applied'],
|
|
179
|
+
data: { target: 'local', pending, pushed: applied, executed: execute && applied.length > 0, done: pending.length === 0 || execute },
|
|
180
|
+
};
|
|
181
|
+
}
|
package/db/migrate.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { STACKS_DIR } from '../stack/config.mjs';
|
|
|
17
17
|
import { firstLine, tool } from '../stack/context.mjs';
|
|
18
18
|
import { approvalMissing, failure, refusal } from '../stack/errors.mjs';
|
|
19
19
|
import { readDeclaration, runVerb } from '../release/index.mjs';
|
|
20
|
+
import { migrateLocalDatabase } from './local-migrate.mjs';
|
|
20
21
|
|
|
21
22
|
const DATABASE_CODE = 'DB_CONFIG_INVALID';
|
|
22
23
|
|
|
@@ -353,12 +354,19 @@ async function migrateDatabase(ctx, decl, options = {}) {
|
|
|
353
354
|
return { ok: true, lines: [...lines, `applied: ${pushed.length} forward migration(s) pushed; the remote history now holds ${final.size} version(s). Held back: ${model.held.join(', ') || 'nothing'}.`], data: dataOf(model, plan, remote, { executed: true, pushed, remoteCountAfter: final.size }) };
|
|
354
355
|
}
|
|
355
356
|
|
|
356
|
-
const SPEC = { boolean: ['--execute', '--json', '--linked'], value: ['--url-env'] };
|
|
357
|
+
const SPEC = { boolean: ['--execute', '--json', '--linked', '--local'], value: ['--url-env'] };
|
|
357
358
|
|
|
358
|
-
/** Entry of the verb:
|
|
359
|
+
/** Entry of the verb: explicit local applies active SQL; linked/url-env retains the declared cutover plan. Execute requires database arming. */
|
|
359
360
|
export function dbMigrateMain(argv, io = {}) {
|
|
360
361
|
return runVerb({
|
|
361
362
|
label: 'starci app db migrate', name: 'db migrate', code: 'DB_USAGE', io, argv, spec: SPEC,
|
|
362
|
-
run: (ctx, flags) =>
|
|
363
|
+
run: (ctx, flags) => {
|
|
364
|
+
if (flags.local) {
|
|
365
|
+
if (flags.linked || flags['url-env'] !== undefined) throw refusal('DB_USAGE', '--local excludes --linked and --url-env');
|
|
366
|
+
if (flags.execute) requireArmed(ctx);
|
|
367
|
+
return migrateLocalDatabase(ctx, { execute: flags.execute === true });
|
|
368
|
+
}
|
|
369
|
+
return migrateDatabase(ctx, loadDatabase(ctx.cwd), { execute: flags.execute === true, linked: flags.linked === true, urlEnv: flags['url-env'] });
|
|
370
|
+
},
|
|
363
371
|
});
|
|
364
372
|
}
|
package/emit/db-types.mjs
CHANGED
|
@@ -185,7 +185,7 @@ function requireCurrentDatabase(root, run) {
|
|
|
185
185
|
const applied = appliedVersions(result);
|
|
186
186
|
if (applied.size !== expected.size || [...expected].some((version) => !applied.has(version))) {
|
|
187
187
|
const head = (versions) => [...versions].sort().at(-1) ?? 'none';
|
|
188
|
-
throw new DbTypesError(`the local database is at ${head(applied)}, the migrations folder at ${head(expected)};
|
|
188
|
+
throw new DbTypesError(`the local database is at ${head(applied)}, the migrations folder at ${head(expected)}; inspect "starci app db plan --local", preview "starci app db migrate --local", apply the reviewed local plan with --execute and STARCI_DB_ARMED=1, then run "starci app emit"`, { file: dbTypesPath }, 'HFS_EMIT_DB_TYPES_STALE');
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/hfs",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.6",
|
|
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",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"libpg-query": "18.1.5",
|
|
15
15
|
"smol-toml": "1.9.0"
|
|
16
16
|
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "5.9.3"
|
|
19
|
+
},
|
|
17
20
|
"files": [
|
|
18
21
|
"LICENSE",
|
|
19
22
|
"src/main.mjs",
|
|
@@ -22,21 +22,21 @@ pins:
|
|
|
22
22
|
source: packages/heroicons/package.json
|
|
23
23
|
why: 'StarCi custom Heroicons-compatible cuts; eslint-canon-fe (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
|
|
24
24
|
'@starci/grammar':
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.10.0
|
|
26
26
|
group: starci
|
|
27
27
|
install: registry
|
|
28
28
|
side: fe
|
|
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.
|
|
32
|
+
version: 3.4.12
|
|
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.
|
|
39
|
+
version: 8.4.12
|
|
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.
|
|
79
|
+
version: 1.7.6
|
|
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.
|
|
86
|
+
version: 4.7.6
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -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": "
|
|
4
|
+
"hash": "27e8713cd03ca41190945705f7d4ae7a6fbd17a17d04c4c06ba7c8e2c4c520bc",
|
|
5
5
|
"global": [
|
|
6
6
|
{
|
|
7
7
|
"name": "json",
|
|
@@ -403,7 +403,7 @@ export const CATALOG = {
|
|
|
403
403
|
{
|
|
404
404
|
"name": "local",
|
|
405
405
|
"type": "boolean",
|
|
406
|
-
"summary": "inspect
|
|
406
|
+
"summary": "inspect local state; migrate applies only pending active SQL against the app's existing local stack"
|
|
407
407
|
},
|
|
408
408
|
{
|
|
409
409
|
"name": "execute",
|
|
@@ -452,6 +452,8 @@ export const CATALOG = {
|
|
|
452
452
|
"starci app db plan --json",
|
|
453
453
|
"starci app db plan --local",
|
|
454
454
|
"starci app db migrate --linked",
|
|
455
|
+
"starci app db migrate --local",
|
|
456
|
+
"starci app db migrate --local --execute",
|
|
455
457
|
"starci app db migrate --execute",
|
|
456
458
|
"starci app db post-cutover --green-at 2026-10-10T00:00:00Z"
|
|
457
459
|
],
|
|
@@ -474,8 +476,11 @@ export const CATALOG = {
|
|
|
474
476
|
"reset keeps encrypted temporary recovery parts after a reset/restore failure and reports their directory; plaintext is always deleted",
|
|
475
477
|
"baseline accepts --stamp; restamp requires --map; both preview without --apply",
|
|
476
478
|
"plan is always read-only and supports --json; it compares migrations, cron, queues and Vault names",
|
|
477
|
-
"migrate and post-cutover read
|
|
478
|
-
"migrate
|
|
479
|
+
"linked/url-env migrate and post-cutover read application-stacks.yaml's database block and preview without --execute",
|
|
480
|
+
"linked/url-env migrate skips an applied baseline and forward chain; held-back migrations belong to post-cutover",
|
|
481
|
+
"migrate --local uses the app workdir/config, previews pending active SQL and refuses history gaps or unknown versions",
|
|
482
|
+
"local migrate --execute needs STARCI_DB_ARMED=1; no history repair, baseline marking, reset, seed/role/Vault work or linked fallback",
|
|
483
|
+
"local migrate refuses encrypted config/env values and dotenv interpolation or escapes before any child; it never decrypts",
|
|
479
484
|
"post-cutover applies held-back migrations after the read-only pg_proc preflight, an open time gate and STARCI_DB_ARMED=1"
|
|
480
485
|
],
|
|
481
486
|
"runtimeSide": "host"
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// database-push.mjs - pending SQL migration push for an app's selected Supabase database.
|
|
2
|
+
import { supabaseCall } from './lib.mjs';
|
|
3
|
+
|
|
4
|
+
/** The spawn result of supabase db push; target, preview and runner options remain with the owning caller. */
|
|
5
|
+
export function databasePush(...args) { return supabaseCall(['db', 'push'], ...args); }
|
|
@@ -7,37 +7,48 @@ const candidateNames = (platform) => platform === 'win32'
|
|
|
7
7
|
? ['supabase.exe', 'supabase', 'supabase.cmd']
|
|
8
8
|
: ['supabase'];
|
|
9
9
|
|
|
10
|
-
/** Resolve the app
|
|
10
|
+
/** Resolve the app's installed npm launcher before executable discovery; npm command shims cannot run without a shell. */
|
|
11
11
|
function resolveSupabase(appRoot, { env = process.env, platform = process.platform, exists = fs.existsSync } = {}) {
|
|
12
|
+
const launcher = path.join(appRoot, 'node_modules', 'supabase', 'dist', 'supabase.js');
|
|
13
|
+
if (exists(launcher)) return { file: process.execPath, args: [launcher] };
|
|
12
14
|
const names = candidateNames(platform);
|
|
13
15
|
const local = path.join(appRoot, 'node_modules', '.bin');
|
|
14
16
|
for (const name of names) {
|
|
15
17
|
const file = path.join(local, name);
|
|
16
|
-
if (exists(file)) return file;
|
|
18
|
+
if (exists(file)) return { file, args: [] };
|
|
17
19
|
}
|
|
18
20
|
const delimiter = platform === 'win32' ? ';' : ':';
|
|
19
21
|
for (const directory of String(env.PATH ?? '').split(delimiter).filter(Boolean)) {
|
|
20
22
|
for (const name of names) {
|
|
21
23
|
const file = path.join(directory, name);
|
|
22
|
-
if (exists(file)) return file;
|
|
24
|
+
if (exists(file)) return { file, args: [] };
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
|
-
return 'supabase';
|
|
27
|
+
return { file: 'supabase', args: [] };
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
/** Run one Supabase call
|
|
30
|
+
/** Run one shell-free Supabase call at the app root; an unusable command shim returns an ENOEXEC spawn failure. */
|
|
29
31
|
export const supabaseSpawn = (appRoot, args, {
|
|
30
32
|
env = process.env,
|
|
31
33
|
supabase,
|
|
32
34
|
timeout = 60_000,
|
|
33
35
|
run = spawnSync,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
env,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
})
|
|
36
|
+
platform = process.platform,
|
|
37
|
+
exists = fs.existsSync,
|
|
38
|
+
} = {}) => {
|
|
39
|
+
const command = supabase === undefined || supabase === null ? resolveSupabase(appRoot, { env, platform, exists }) : { file: supabase, args: [] };
|
|
40
|
+
if (/\.(?:cmd|bat)$/iu.test(command.file)) return {
|
|
41
|
+
status: null, stdout: '', stderr: '', signal: null,
|
|
42
|
+
error: Object.assign(new Error('The Supabase command shim requires a shell; use an executable or the app-local npm launcher.'), { code: 'ENOEXEC' }),
|
|
43
|
+
};
|
|
44
|
+
return run(command.file, [...command.args, ...args], {
|
|
45
|
+
cwd: appRoot,
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
env,
|
|
48
|
+
timeout,
|
|
49
|
+
windowsHide: true,
|
|
50
|
+
});
|
|
51
|
+
};
|
|
41
52
|
|
|
42
53
|
/** Invoke one Supabase command prefix with caller arguments and runner options. */
|
|
43
54
|
export const supabaseCall = (prefix, appRoot, args = [], options = {}) =>
|
|
@@ -6,14 +6,15 @@ import { unwrapCatalogExpression as unwrap } from '../../lib/ts-analysis.mjs';
|
|
|
6
6
|
const ARRAY_CALLBACK = /^(?:map|flatMap|forEach|filter|find|some|every)$/u;
|
|
7
7
|
|
|
8
8
|
export const symbolAt = (state, node) => {
|
|
9
|
-
const symbol = state.
|
|
9
|
+
const symbol = state.ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node
|
|
10
|
+
? state.checker.getShorthandAssignmentValueSymbol(node.parent) : state.checker.getSymbolAtLocation(node);
|
|
10
11
|
return symbol?.flags === state.ts.SymbolFlags.Alias ? state.checker.getAliasedSymbol(symbol) : symbol;
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
/** The expression bound to `name` in an object literal or a JSX attribute list. */
|
|
14
15
|
function propertyValues(state, owner, name) {
|
|
15
16
|
const { ts } = state;
|
|
16
|
-
if (ts.isObjectLiteralExpression(owner)) return owner.properties.filter(entry => ts.isPropertyAssignment(entry) && nameOf(ts, entry.name) === name).map(entry => entry.initializer);
|
|
17
|
+
if (ts.isObjectLiteralExpression(owner)) return owner.properties.filter(entry => (ts.isPropertyAssignment(entry) || ts.isShorthandPropertyAssignment(entry)) && nameOf(ts, entry.name) === name).map(entry => ts.isShorthandPropertyAssignment(entry) ? entry.name : entry.initializer);
|
|
17
18
|
if (!ts.isJsxAttributes(owner)) return [];
|
|
18
19
|
const attributes = owner.properties.filter(entry => ts.isJsxAttribute(entry) && entry.name.text === name && entry.initializer);
|
|
19
20
|
return attributes.map(entry => ts.isJsxExpression(entry.initializer) ? entry.initializer.expression : entry.initializer).filter(Boolean);
|
|
@@ -72,6 +73,17 @@ function declarationValues(state, declaration, seen) {
|
|
|
72
73
|
return declaration.initializer ? valuesOf(state, declaration.initializer, seen) : [];
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/** Resolve local function and memo callback returns; an unknown call remains its own leaf. */
|
|
77
|
+
function callValues(state, node, seen) {
|
|
78
|
+
const { ts } = state;
|
|
79
|
+
const declaration = symbolAt(state, node.expression)?.valueDeclaration;
|
|
80
|
+
const target = declaration && ts.isVariableDeclaration(declaration) ? unwrap(ts, declaration.initializer) : declaration;
|
|
81
|
+
const memo = ts.isIdentifier(node.expression) && node.expression.text === 'useMemo' ? unwrap(ts, node.arguments[0]) : null;
|
|
82
|
+
const fn = memo ?? target;
|
|
83
|
+
return fn && (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn) || ts.isFunctionDeclaration(fn))
|
|
84
|
+
? resolveAll(state, returnedExpressions(ts, fn), seen) : [node];
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
/** Leaf expressions `expression` may evaluate to. A translator variable and any unresolvable expression is its own leaf. */
|
|
76
88
|
export function valuesOf(state, expression, visited = new Set()) {
|
|
77
89
|
const { ts } = state;
|
|
@@ -79,6 +91,7 @@ export function valuesOf(state, expression, visited = new Set()) {
|
|
|
79
91
|
if (!node || visited.has(node)) return [];
|
|
80
92
|
const seen = new Set([...visited, node]);
|
|
81
93
|
if (ts.isConditionalExpression(node)) return [...valuesOf(state, node.whenTrue, seen), ...valuesOf(state, node.whenFalse, seen)];
|
|
94
|
+
if (ts.isCallExpression(node)) return callValues(state, node, seen);
|
|
82
95
|
if (ts.isPropertyAccessExpression(node)) return resolveAll(state, valuesOf(state, node.expression, seen).flatMap(owner => propertyValues(state, owner, node.name.text)), seen);
|
|
83
96
|
if (ts.isElementAccessExpression(node)) return resolveAll(state, tableValues(state, node, seen), seen);
|
|
84
97
|
if (!ts.isIdentifier(node)) return [node];
|
|
@@ -112,13 +112,19 @@ export function translationBindings(state, sources) {
|
|
|
112
112
|
/** Namespaces of the translators a receiver can be: declared here, or handed in through arguments and props. */
|
|
113
113
|
function receiverNamespaces(state, receiver) {
|
|
114
114
|
const { ts, checker } = state;
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
115
|
+
const resolving = state.resolvingReceivers ??= new Set();
|
|
116
|
+
if (resolving.has(receiver)) return [];
|
|
117
|
+
resolving.add(receiver);
|
|
118
|
+
try {
|
|
119
|
+
const direct = state.translators.get(checker.getSymbolAtLocation(receiver));
|
|
120
|
+
if (direct !== undefined) return direct;
|
|
121
|
+
return valuesOf(state, receiver).flatMap(leaf => {
|
|
122
|
+
// A translator factory called in place as an argument: f(useTranslations("admin")).
|
|
123
|
+
if (ts.isCallExpression(leaf)) return translatorNamespace(state, leaf, translationFactories(ts, leaf.getSourceFile())) ?? [];
|
|
124
|
+
if (isFunction(ts, leaf)) return forwardedNamespaces(state, leaf);
|
|
125
|
+
return ts.isIdentifier(leaf) ? state.translators.get(symbolAt(state, leaf)) ?? [] : [];
|
|
126
|
+
});
|
|
127
|
+
} finally { resolving.delete(receiver); }
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
function handedIn(state, receiver) {
|
|
@@ -216,7 +216,7 @@ function indirectArguments(state, argument, prefix, seen) {
|
|
|
216
216
|
|
|
217
217
|
function templateArguments(state, template, prefix) {
|
|
218
218
|
// A literal head or tail makes the template a key pattern; a template of holes only proves nothing.
|
|
219
|
-
if (
|
|
219
|
+
if (hasLiteral(template)) state.templates.push({ prefix, parts: [`${prefix ?? ''}${template.head.text}`, ...template.templateSpans.map(span => span.literal.text)] });
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
function catalogArguments(state, node, prefix, seen = new Set()) {
|
|
@@ -280,16 +280,19 @@ function looseRead(state, name) {
|
|
|
280
280
|
function catalogRead(state, candidate, liveCodes) {
|
|
281
281
|
if (candidate.name.startsWith('errors.') && liveCodes.has(candidate.name.slice(7))) return true;
|
|
282
282
|
if (state.catalogReads.has(candidate.name) || looseRead(state, candidate.name)) return true;
|
|
283
|
-
return state.templates.some(
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
283
|
+
return state.templates.some(({ prefix, parts }) => {
|
|
284
|
+
const segments = candidate.name.split('.');
|
|
285
|
+
const tails = prefix === null ? segments.map((_, index) => segments.slice(index).join('.')) : [candidate.name];
|
|
286
|
+
return tails.some(tail => {
|
|
287
|
+
let offset = 0;
|
|
288
|
+
for (const [index, part] of parts.entries()) {
|
|
289
|
+
const next = tail.indexOf(part, offset);
|
|
290
|
+
if (next < 0 || index === 0 && next !== 0) return false;
|
|
291
|
+
offset = next + part.length;
|
|
292
|
+
}
|
|
293
|
+
return tail.endsWith(parts.at(-1));
|
|
294
|
+
});
|
|
295
|
+
});
|
|
293
296
|
}
|
|
294
297
|
|
|
295
298
|
/** The same rule applies to lite and full. Analyze all tracked consumers before filtering findings to a fast-check scope. */
|
package/src/main.mjs
CHANGED
|
@@ -103,7 +103,7 @@ starci app db restamp --map <restamp.yaml> [--local] [--apply] [--cwd <dir>]
|
|
|
103
103
|
starci app db plan [--local] [--json] [--cwd <dir>]
|
|
104
104
|
starci app db dump (--linked | --local | --url-env <NAME>) --out <dir> [--schema-only] [--json] [--cwd <dir>]
|
|
105
105
|
starci app db reset --linked --auth keep|drop --storage keep|drop --backup <receipt.json> [--execute] [--json] [--cwd <dir>]
|
|
106
|
-
starci app db migrate [--execute] [--linked | --url-env <NAME>] [--json] [--cwd <dir>]
|
|
106
|
+
starci app db migrate [--execute] [--local | --linked | --url-env <NAME>] [--json] [--cwd <dir>]
|
|
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>]
|
package/sync/index.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// and are listed in this module: the marked blocks of the root .gitignore and README.md, and .starciwork/.gitignore, which lives inside the
|
|
11
11
|
// .starciwork directory slot. Every file is rendered with the app's hfs.json (both sides and their apps) and, for the Sonar
|
|
12
12
|
// exclusions, the jest preset the app installs for its be side and the one coverage scope derived from the slot manifest
|
|
13
|
-
// (scripts/hfs/coverage-scope.mjs: the be/jest.config.js scope, sonar.coverage.exclusions and the codecov.yml paths and
|
|
13
|
+
// (scripts/hfs/coverage-scope.mjs: the be/jest.config.js scope, sonar.coverage.exclusions and the codecov.yml paths, components and exclusions). `--check` compares the sha256 of the rendered content with the
|
|
14
14
|
// file on disk and fails on any drift; `--write` rewrites the drifted files. `.gitignore` and README.md are shared files: only the marked
|
|
15
15
|
// block is managed and the app's own lines around it are left alone. The root package.json is managed by its `scripts` block
|
|
16
16
|
// only (mode scripts, compared as parsed JSON): the rest of the file (dependencies, the npm workspaces fe/apps/* and fe/packages/*,
|
|
@@ -328,6 +328,7 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
328
328
|
// The coverage scope is the same in both editions: lite measures exactly what full measures (ruling:R212/case-1, amended by ruling:R212/case-4).
|
|
329
329
|
jestCoverage: scope === 'be' ? jestCoverageSource(jestCoverage(manifest, providers)) : '',
|
|
330
330
|
coverageExclusions: scope === APP_SCOPE ? sonarCoverageExclusions(manifest, providers).join(',') : '',
|
|
331
|
+
codecovExclusions: scope === APP_SCOPE ? sonarCoverageExclusions(manifest, providers).map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
|
|
331
332
|
codecovPaths: scope === APP_SCOPE ? codecovPaths(manifest, providers).map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
|
|
332
333
|
codecovComponents: scope === APP_SCOPE ? componentsYaml(coverageComponents(manifest, { ...source, apps: app.sides.be.apps, providers })) : '',
|
|
333
334
|
codecovTarget: quality.codecov.target,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{{header}}
|
|
2
2
|
# The be unit run's lcov ({{lcovReport}}), uploaded by the managed CI workflow. Coverage is the logic of be/src/modules/**: the status
|
|
3
|
-
# paths are the measured roots of the one coverage scope (derived from the slot manifest's `coverage` field; its complement is
|
|
4
|
-
# sonar.coverage.exclusions and its roots
|
|
3
|
+
# paths are the measured roots of the one coverage scope (derived from the slot manifest's `coverage` field; its complement is shared by ignore and
|
|
4
|
+
# sonar.coverage.exclusions and its roots by be/jest.config.js), held at {{codecovTarget}} on the project and on the patch, and each component (one per
|
|
5
5
|
# service app, plus platform) is held at {{codecovTarget}} too. fe/ and be/src/features are outside coverage (features are thin by rule; a front end has no tests).
|
|
6
6
|
codecov:
|
|
7
7
|
require_ci_to_pass: true
|
|
@@ -31,4 +31,4 @@ component_management:
|
|
|
31
31
|
individual_components:
|
|
32
32
|
{{codecovComponents}}
|
|
33
33
|
ignore:
|
|
34
|
-
|
|
34
|
+
{{codecovExclusions}}
|