@starci/hfs 4.7.5 → 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.
@@ -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: `starci app db migrate [--execute] [--linked | --url-env NAME] [--json]`. `io` is the app IO of src/main.mjs. */
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) => migrateDatabase(ctx, loadDatabase(ctx.cwd), { execute: flags.execute === true, linked: flags.linked === true, urlEnv: flags['url-env'] }),
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)}; run "npm run db:reset" then emit again`, { file: dbTypesPath }, 'HFS_EMIT_DB_TYPES_STALE');
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.5",
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",
@@ -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.11
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.11
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.5
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.5
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": "1d8f6ce139e98259571df0aee8deccce139214f6aea6fc86ade937ca2dfb445d",
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 or repair the local Supabase database instead of the linked project"
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 the optional database block of application-stacks.yaml; both are dry runs without --execute",
478
- "migrate is idempotent: baseline plus forward on the remote prints nothing to do; held-back migrations are never applied by it",
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-local Supabase CLI first, then PATH, retaining a PATH lookup fallback for useful spawn errors. */
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 for an app root; options retain the injectable spawn seam used by focused specs. */
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
- } = {}) => run(supabase ?? resolveSupabase(appRoot, { env }), args, {
35
- cwd: appRoot,
36
- encoding: 'utf8',
37
- env,
38
- timeout,
39
- windowsHide: true,
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.checker.getSymbolAtLocation(node);
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 direct = state.translators.get(checker.getSymbolAtLocation(receiver));
116
- if (direct !== undefined) return direct;
117
- return valuesOf(state, receiver).flatMap(leaf => {
118
- // A translator factory called in place as an argument: f(useTranslations("admin")).
119
- if (ts.isCallExpression(leaf)) return translatorNamespace(state, leaf, translationFactories(ts, leaf.getSourceFile())) ?? [];
120
- return ts.isIdentifier(leaf) ? state.translators.get(checker.getSymbolAtLocation(leaf)) ?? [] : [];
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 (prefix !== null && hasLiteral(template)) state.templates.push([`${prefix}${template.head.text}`, ...template.templateSpans.map(span => span.literal.text)]);
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(parts => parts.some(Boolean) && (() => {
284
- const tail = candidate.name;
285
- let offset = 0;
286
- for (const [index, part] of parts.entries()) {
287
- const next = tail.indexOf(part, offset);
288
- if (next < 0 || index === 0 && next !== 0) return false;
289
- offset = next + part.length;
290
- }
291
- return tail.endsWith(parts.at(-1));
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>]