@starci/hfs 4.9.11 → 4.10.0
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 +13 -0
- package/README.md +7 -9
- package/db/backup-receipt.mjs +10 -47
- package/db/drift.mjs +1 -1
- package/db/dump.mjs +11 -10
- package/db/entity-list.mjs +1 -1
- package/db/migrate.mjs +83 -259
- package/db/new.mjs +46 -0
- package/db/post-cutover.mjs +13 -17
- package/db/postgres.mjs +80 -0
- package/emit/contracts.mjs +5 -9
- package/package.json +1 -1
- package/release/gate.mjs +3 -15
- package/runtime/knowledge/hfs/canon-pins.yaml +44 -24
- package/runtime/knowledge/hfs/design-truths.yaml +1 -1
- package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
- package/runtime/knowledge/hfs/rules.yaml +177 -155
- package/runtime/knowledge/hfs/slots.yaml +164 -172
- package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
- package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
- package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
- package/runtime/knowledge/patterns/be/projections.yaml +7 -5
- package/runtime/knowledge/patterns/be/queues.yaml +8 -8
- package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
- package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
- package/runtime/knowledge/patterns/be/saga.yaml +3 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
- package/runtime/modules/kernel/command-policy.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +65 -115
- package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
- package/runtime/scripts/hfs/architecture/index.mjs +2 -3
- package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
- package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
- package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
- package/runtime/scripts/hfs/check.mjs +6 -6
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
- package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
- package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
- package/runtime/scripts/hfs/manifest-shape.mjs +4 -4
- package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
- package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
- package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
- package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
- package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database.mjs +36 -39
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
- package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
- package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +8 -2
- package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/runtime/scripts/lib/swarm-stack.mjs +7 -4
- package/scaffold/add-app.mjs +2 -10
- package/scaffold/add-cli-lite.mjs +6 -6
- package/scaffold/add-compose.mjs +1 -1
- package/scaffold/add-inbox-lite.mjs +4 -8
- package/scaffold/add-plan.mjs +1 -1
- package/scaffold/add-queue-lite.mjs +16 -24
- package/scaffold/add-table.mjs +34 -26
- package/scaffold/add.mjs +2 -2
- package/scaffold/app.mjs +19 -86
- package/scaffold/lite-exports.mjs +1 -1
- package/src/main.mjs +19 -20
- package/stack/target-binding.mjs +0 -60
- package/stack/urgent-floor.mjs +2 -2
- package/sync/index.mjs +32 -8
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
- package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
- package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
- package/templates/app/gitignore-lite +0 -3
- package/templates/app/package-scripts/db.json +9 -0
- package/templates/app/package-scripts/package.json +0 -1
- package/templates/app/package-scripts-lite/package.json +1 -1
- package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
- package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
- package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
- package/templates/app/skeleton-lite/README.md +11 -11
- package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
- package/templates/app/tool-config-lite/prettierignore +0 -1
- package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
- package/templates/be/inbox/database-client.ts.tpl +13 -4
- package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
- package/templates/be/inbox/platform/migration.sql.tpl +22 -0
- package/templates/be/inbox/service.spec.ts.tpl +2 -2
- package/templates/be/inbox/service.ts.tpl +1 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
- package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
- package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
- package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
- package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
- package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +6 -6
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
- package/templates/be/table/entities.index.ts.tpl +4 -0
- package/templates/be/table/entity.ts.tpl +21 -0
- package/templates/be/table/index.ts.tpl +1 -0
- package/templates/be/table/module.ts.tpl +1 -1
- package/templates/be/table/service.api.ts.tpl +10 -11
- package/templates/be/table/service.ts.tpl +5 -6
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
- package/db/local-migrate.mjs +0 -194
- package/db/reset.mjs +0 -125
- package/emit/db-types.mjs +0 -271
- package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
- package/runtime/scripts/api/supabase/database-push.mjs +0 -5
- package/runtime/scripts/api/supabase/database-query.mjs +0 -5
- package/runtime/scripts/api/supabase/lib.mjs +0 -55
- package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
- package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
- package/scaffold/supabase-ports.mjs +0 -26
- package/templates/app/package-scripts/supabase.json +0 -7
- package/templates/app/skeleton-lite/supabase/config.toml +0 -48
- package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
- package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
- package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
- package/templates/be/inbox/sql.ts.tpl +0 -16
- package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
- package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
- package/templates/be/table/rows.ts.tpl +0 -4
- package/templates/be/table/sql.api.ts.tpl +0 -7
- package/templates/be/table/sql.ts.tpl +0 -4
- package/templates/fe/auth/supabase-client.ts.tpl +0 -38
- package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
- /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
|
@@ -52,7 +52,7 @@ export function nested(inner, outer) {
|
|
|
52
52
|
const variantsOf = (slot) => braceVariants(slot.path).map(star);
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* The tracked slots of the be profile, in manifest order. A slot gated by a connection provider
|
|
55
|
+
* The tracked slots of the be profile, in manifest order. A slot gated by a connection provider holds files only in an
|
|
56
56
|
* app whose connection declares that provider (`providers`), so it takes no part in the scope of any other app.
|
|
57
57
|
*/
|
|
58
58
|
const beSlots = (manifest, providers) => manifest.slots.filter((slot) => slot.profiles.includes('be') && slot.tracked === 'tracked' && (slot.provider === undefined || providers.includes(slot.provider)));
|
|
@@ -4,9 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import pg from 'libpg-query';
|
|
5
5
|
import { invocationDir } from '../lib/roots.mjs';
|
|
6
6
|
import { sqlStringParts as names, walkSqlAst as walk, sqlParser } from '../lib/sql-ast.mjs';
|
|
7
|
-
import {
|
|
8
|
-
import { databaseQuery } from '../api/supabase/database-query.mjs';
|
|
9
|
-
import { migrationRepair } from '../api/supabase/migration-repair.mjs';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
10
8
|
import { migrationStamp, isMigrationName, databaseSqlTokens, databaseExecutableTokens } from './rules/database-migrations.mjs';
|
|
11
9
|
import { baselineMarkers, baselineSourceFindings } from './rules/database-baseline.mjs';
|
|
12
10
|
import { databaseQueueDeclarations } from './rules/database-queues.mjs';
|
|
@@ -16,10 +14,6 @@ import { dbPlanMain } from './db-plan-verb.mjs';
|
|
|
16
14
|
await pg.loadModule();
|
|
17
15
|
|
|
18
16
|
export class DbVerbError extends Error {}
|
|
19
|
-
const LINKED_PASSWORD_ENV = 'SUPABASE_DB_PASSWORD';
|
|
20
|
-
const TELEMETRY_DISABLED_ENV = 'SUPABASE_TELEMETRY_DISABLED';
|
|
21
|
-
const TELEMETRY_DEBUG_ENV = 'SUPABASE_TELEMETRY_DEBUG';
|
|
22
|
-
const CLI_DEBUG_ENV = 'SUPABASE_DEBUG';
|
|
23
17
|
|
|
24
18
|
/** Strict shared options; unknown flags never reach the database CLI. */
|
|
25
19
|
export function dbOptions(argv, allowed) {
|
|
@@ -28,7 +22,7 @@ export function dbOptions(argv, allowed) {
|
|
|
28
22
|
const [flag, inline] = argv[index].split(/=(.*)/s);
|
|
29
23
|
const name = flag.slice(2);
|
|
30
24
|
if (!flag.startsWith('--') || !allowed.includes(name) || Object.hasOwn(opts, name)) throw new DbVerbError('unknown or duplicate database option');
|
|
31
|
-
if (['apply', '
|
|
25
|
+
if (['apply', 'json'].includes(name)) {
|
|
32
26
|
if (inline !== undefined && !['true', 'false'].includes(inline)) throw new DbVerbError(`${flag} expects true or false`);
|
|
33
27
|
opts[name] = inline !== 'false';
|
|
34
28
|
} else {
|
|
@@ -48,7 +42,7 @@ export function dbAppRoot(cwd, opts) {
|
|
|
48
42
|
return root;
|
|
49
43
|
}
|
|
50
44
|
|
|
51
|
-
/** Immediate SQL files only, never secrets or
|
|
45
|
+
/** Immediate SQL files only, never secrets or credential files. */
|
|
52
46
|
export function dbSqlFiles(root, directory = MIGRATIONS_DIR) {
|
|
53
47
|
const dir = path.join(root, directory);
|
|
54
48
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -59,27 +53,50 @@ export function dbSqlFiles(root, directory = MIGRATIONS_DIR) {
|
|
|
59
53
|
}).sort((left, right) => left.name < right.name ? -1 : Number(left.name > right.name));
|
|
60
54
|
}
|
|
61
55
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
56
|
+
/** The database of `--url-env` (default DATABASE_URL): the URL without its password and the password for PGPASSWORD. */
|
|
57
|
+
function connectionOf(env, urlEnv) {
|
|
58
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(urlEnv)) throw new DbVerbError('--url-env must name an UPPER_CASE environment variable');
|
|
59
|
+
let url;
|
|
60
|
+
try { url = new URL(String(env[urlEnv] ?? '')); } catch { throw new DbVerbError(`${urlEnv} must hold a postgres connection URL`); }
|
|
61
|
+
if (!/^postgres(?:ql)?:$/.test(url.protocol)) throw new DbVerbError(`${urlEnv} must hold a postgres connection URL`);
|
|
62
|
+
const password = decodeURIComponent(url.password);
|
|
63
|
+
url.password = '';
|
|
64
|
+
return { url: url.toString(), env: { ...env, ...(password ? { PGPASSWORD: password } : {}) } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const HISTORY = 'supabase_migrations.schema_migrations';
|
|
68
|
+
const sqlLiteral = (value) => `'${String(value).replaceAll("'", "''")}'`;
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return
|
|
70
|
+
/** The psql and pg_dump invocation of one operation: a JSON rows query, a schema-only dump, or one history row applied or reverted. */
|
|
71
|
+
function commandOf(op, url) {
|
|
72
|
+
const psql = ['-X', '-q', '-v', 'ON_ERROR_STOP=1', '-d', url];
|
|
73
|
+
if (op.kind === 'query') return ['psql', [...psql, '-A', '-t', '-c', `select coalesce(json_agg(row_to_json(q)), '[]'::json) from (${op.sql.replace(/;\s*$/, '')}) q`]];
|
|
74
|
+
if (op.kind === 'dump') return ['pg_dump', ['--schema-only', '--schema', 'public', '--dbname', url]];
|
|
75
|
+
if (op.kind === 'repair') {
|
|
76
|
+
const change = op.status === 'applied'
|
|
77
|
+
? `insert into ${HISTORY} (version, name) values (${sqlLiteral(op.stamp)}, ${sqlLiteral(op.name ?? 'baseline')}) on conflict (version) do nothing`
|
|
78
|
+
: `delete from ${HISTORY} where version = ${sqlLiteral(op.stamp)}`;
|
|
79
|
+
return ['psql', [...psql, '-1', '-c', `create schema if not exists supabase_migrations; create table if not exists ${HISTORY} (version text primary key, statements text[], name text)`, '-c', change]];
|
|
80
|
+
}
|
|
81
|
+
throw new DbVerbError('unsupported database command');
|
|
69
82
|
}
|
|
70
83
|
|
|
71
|
-
|
|
84
|
+
/** Runs one database operation through psql or pg_dump (ports.runPostgres in specs); raw failures and output are private. */
|
|
85
|
+
export function runDbPostgres(root, op, ports = {}, urlEnv = 'DATABASE_URL') {
|
|
72
86
|
const env = ports.env ?? process.env;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (result?.error || !(result?.ok === true || result?.status === 0)) throw new DbVerbError('
|
|
87
|
+
const result = ports.runPostgres ? ports.runPostgres(root, op) : (() => {
|
|
88
|
+
const connection = connectionOf(env, urlEnv);
|
|
89
|
+
const [command, args] = commandOf(op, connection.url);
|
|
90
|
+
return spawnSync(command, args, { cwd: root, encoding: 'utf8', env: connection.env, timeout: 120_000, windowsHide: true });
|
|
91
|
+
})();
|
|
92
|
+
if (result?.error || !(result?.ok === true || result?.status === 0)) throw new DbVerbError('database command failed; inspect psql privately');
|
|
79
93
|
return String(result.stdout ?? '');
|
|
80
94
|
}
|
|
81
95
|
|
|
82
|
-
|
|
96
|
+
/** A history change: `stamp` recorded as applied (named `name`) or reverted. */
|
|
97
|
+
export const repairOp = (stamp, status, name) => ({ kind: 'repair', stamp, status, ...(name ? { name } : {}) });
|
|
98
|
+
/** The printed form of a history change. */
|
|
99
|
+
export const repairLine = (op) => `history ${op.status} ${op.stamp}${op.name ? ` ${op.name}` : ''}`;
|
|
83
100
|
export const dbFailure = (verb, error, stderr) => { stderr(`starci app db ${verb}: ${error instanceof DbVerbError ? error.message : 'operation failed; database output was withheld'}\n`); return 2; };
|
|
84
101
|
|
|
85
102
|
function baselinePlan(root, opts, now) {
|
|
@@ -114,7 +131,7 @@ function baselineFiles(root) {
|
|
|
114
131
|
else if (entry.isFile() && /\.(?:sql|[cm]?[jt]sx?)$/.test(entry.name)) files.push(file);
|
|
115
132
|
}
|
|
116
133
|
};
|
|
117
|
-
for (const directory of ['
|
|
134
|
+
for (const directory of ['db', 'be/src', 'be/apps', 'src', 'apps']) visit(directory);
|
|
118
135
|
return files;
|
|
119
136
|
}
|
|
120
137
|
|
|
@@ -257,20 +274,21 @@ function metadataSql(metadata) {
|
|
|
257
274
|
/** Numeric CLI exit code, injectable IO/clock/CLI; neither files nor history change without --apply. */
|
|
258
275
|
export function dbBaselineMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), now = () => new Date(), ...ports } = {}) {
|
|
259
276
|
try {
|
|
260
|
-
const opts = dbOptions(argv, ['cwd', 'stamp', 'apply', '
|
|
277
|
+
const opts = dbOptions(argv, ['cwd', 'stamp', 'apply', 'url-env']);
|
|
278
|
+
const urlEnv = opts['url-env'] ?? 'DATABASE_URL';
|
|
261
279
|
const root = dbAppRoot(cwd, opts);
|
|
262
280
|
const plan = baselinePlan(root, opts, now);
|
|
263
281
|
const files = baselineFiles(root);
|
|
264
282
|
if (baselineSourceFindings({ repoRoot: root, files, all: files, git: ports.git }).length) throw new DbVerbError('baseline sources and archives must match committed migration history');
|
|
265
283
|
const metadata = baselineMetadata(root, plan, files);
|
|
266
|
-
if (dbPlanMain(['--json',
|
|
267
|
-
const schema =
|
|
284
|
+
if (dbPlanMain(['--json', '--url-env', urlEnv], { ...ports, cwd: root, stdout: () => {}, stderr: () => {} }) !== 0) throw new DbVerbError('baseline requires a clean database plan with every migration already applied');
|
|
285
|
+
const schema = runDbPostgres(root, { kind: 'dump' }, ports, urlEnv);
|
|
268
286
|
if (!schema.trim() || baselineMarkers(schema).length || /\bvault\.(?:create_secret|update_secret)\s*\(|\bpassword\s+'[^']+'/i.test(schema)) throw new DbVerbError('schema dump is empty or contains unsupported secret/baseline statements');
|
|
269
|
-
const repairs = [...plan.active.map((entry) =>
|
|
287
|
+
const repairs = [...plan.active.map((entry) => repairOp(entry.stamp, 'reverted')), repairOp(plan.stamp, 'applied', 'baseline')];
|
|
270
288
|
stdout(`${opts.apply ? 'apply' : 'preview'} baseline ${plan.file}; archive ${plan.active.length} migrations\n`);
|
|
271
|
-
for (const
|
|
289
|
+
for (const op of repairs) stdout(`${repairLine(op)}\n`);
|
|
272
290
|
if (!opts.apply) return 0;
|
|
273
|
-
for (const
|
|
291
|
+
for (const op of repairs) runDbPostgres(root, op, ports, urlEnv);
|
|
274
292
|
fs.mkdirSync(path.join(root, MIGRATIONS_ARCHIVE_DIR), { recursive: true });
|
|
275
293
|
fs.writeFileSync(path.join(root, plan.file), `-- starci:baseline ${plan.last}\n${metadata.extensions}${metadata.roles}${schema.replace(/^\s+/, '')}\n${metadataSql(metadata)}`, { flag: 'wx' });
|
|
276
294
|
for (const entry of plan.active) fs.renameSync(path.join(root, entry.file), path.join(root, MIGRATIONS_ARCHIVE_DIR, entry.name));
|
|
@@ -1,27 +1,23 @@
|
|
|
1
|
-
// app db plan: compare migration-owned state without fetching commands
|
|
1
|
+
// app db plan: compare migration-owned state (history, pg_cron jobs, pgmq queues) without fetching commands.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { sha256 } from '../../engine/digest.mjs';
|
|
5
5
|
import pg from 'libpg-query';
|
|
6
|
-
import { parse as parseToml } from 'smol-toml';
|
|
7
6
|
import { invocationDir } from '../lib/roots.mjs';
|
|
8
7
|
import { sqlStringParts, walkSqlAst as walk, sqlParser, sqlCallArgument as argument, sqlStringLiteral as literal } from '../lib/sql-ast.mjs';
|
|
9
|
-
import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles,
|
|
8
|
+
import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbPostgres, dbFailure } from './db-baseline-verb.mjs';
|
|
10
9
|
|
|
11
10
|
await pg.loadModule();
|
|
12
11
|
|
|
13
|
-
//
|
|
14
|
-
// Contract: supabase/cli apps/cli/src/commands/db/query/{query.command,query.handler}.ts at the pinned CLI tag.
|
|
12
|
+
// Each inventory is one read-only psql query whose rows come back as JSON (db-baseline-verb.mjs runDbPostgres).
|
|
15
13
|
const PRESENCE_SQL = `select
|
|
16
14
|
pg_catalog.to_regclass('supabase_migrations.schema_migrations') is not null as migrations,
|
|
17
15
|
pg_catalog.to_regclass('cron.job') is not null as cron,
|
|
18
|
-
pg_catalog.to_regclass('pgmq.meta') is not null as queues
|
|
19
|
-
pg_catalog.to_regclass('vault.secrets') is not null as vault;`;
|
|
16
|
+
pg_catalog.to_regclass('pgmq.meta') is not null as queues;`;
|
|
20
17
|
const INVENTORIES = {
|
|
21
18
|
migrations: 'select version from supabase_migrations.schema_migrations order by version;',
|
|
22
19
|
cron: 'select jobname as name, schedule, pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(command, \'UTF8\')), \'hex\') as "commandHash", active from cron.job order by jobid;',
|
|
23
20
|
queues: 'select queue_name as name from pgmq.meta order by queue_name;',
|
|
24
|
-
vault: 'select name from vault.secrets order by name;',
|
|
25
21
|
};
|
|
26
22
|
const names = (nodes) => sqlStringParts(nodes).join('.');
|
|
27
23
|
const OWNERSHIP_CALLS = new Set(['pgmq.create', 'pgmq.drop_queue', 'cron.schedule', 'cron.unschedule', 'cron.schedule_in_database', 'cron.alter_job']);
|
|
@@ -99,91 +95,9 @@ function inventoryDo(state, node) {
|
|
|
99
95
|
for (const statement of statements) inventoryTree(state, statement);
|
|
100
96
|
}
|
|
101
97
|
|
|
102
|
-
function quotedEnd(text, start) {
|
|
103
|
-
const quote = text[start];
|
|
104
|
-
const delimiter = text.startsWith(quote.repeat(3), start) ? quote.repeat(3) : quote;
|
|
105
|
-
let cursor = start + delimiter.length;
|
|
106
|
-
while (cursor < text.length) {
|
|
107
|
-
if (quote === '"' && text[cursor] === '\\') { cursor += 2; continue; }
|
|
108
|
-
if (text.startsWith(delimiter, cursor)) {
|
|
109
|
-
let end = cursor + delimiter.length;
|
|
110
|
-
if (delimiter.length === 3) while (text[end] === quote) end += 1;
|
|
111
|
-
return end;
|
|
112
|
-
}
|
|
113
|
-
cursor += 1;
|
|
114
|
-
}
|
|
115
|
-
throw new DbVerbError('Supabase config contains an unterminated string');
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Advance over an assignment value without collecting, decoding or resolving its content.
|
|
119
|
-
function valueEnd(text, start) {
|
|
120
|
-
let cursor = start;
|
|
121
|
-
let depth = 0;
|
|
122
|
-
while (cursor < text.length) {
|
|
123
|
-
const character = text[cursor];
|
|
124
|
-
if (character === '"' || character === "'") { cursor = quotedEnd(text, cursor); continue; }
|
|
125
|
-
if (character === '#') {
|
|
126
|
-
cursor = text.indexOf('\n', cursor);
|
|
127
|
-
if (cursor < 0) return text.length;
|
|
128
|
-
if (depth === 0) return cursor + 1;
|
|
129
|
-
} else if (character === '[' || character === '{') depth += 1;
|
|
130
|
-
else if (character === ']' || character === '}') depth -= 1;
|
|
131
|
-
else if (character === '\n' && depth === 0) return cursor + 1;
|
|
132
|
-
cursor += 1;
|
|
133
|
-
}
|
|
134
|
-
return cursor;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function assignmentEnd(text, start) {
|
|
138
|
-
let cursor = start;
|
|
139
|
-
while (cursor < text.length && !['=', '\n', '#'].includes(text[cursor])) {
|
|
140
|
-
if (text[cursor] === '"' || text[cursor] === "'") cursor = quotedEnd(text, cursor);
|
|
141
|
-
else cursor += 1;
|
|
142
|
-
}
|
|
143
|
-
return cursor;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function vaultKeys(table, key) {
|
|
147
|
-
// The TOML parser sees only the table/key syntax and an invented empty value.
|
|
148
|
-
let metadata;
|
|
149
|
-
try { metadata = parseToml(`${table}\n${key} = ""`); }
|
|
150
|
-
catch { throw new DbVerbError('Supabase config keys could not be inventoried safely'); }
|
|
151
|
-
const vault = metadata.db?.vault ?? {};
|
|
152
|
-
if (typeof vault !== 'object' || Object.values(vault).some((value) => typeof value !== 'string')) throw new DbVerbError('Vault config requires individual named keys');
|
|
153
|
-
return Object.keys(vault);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function configToken(text, cursor, table) {
|
|
157
|
-
if (/\s/.test(text[cursor])) return { cursor: cursor + 1, table, keys: [] };
|
|
158
|
-
if (text[cursor] === '#' || text[cursor] === '[') {
|
|
159
|
-
const end = text.indexOf('\n', cursor);
|
|
160
|
-
const line = text.slice(cursor, end < 0 ? undefined : end);
|
|
161
|
-
return { cursor: end < 0 ? text.length : end + 1, table: text[cursor] === '[' ? line : table, keys: [] };
|
|
162
|
-
}
|
|
163
|
-
const end = assignmentEnd(text, cursor);
|
|
164
|
-
if (text[end] !== '=') throw new DbVerbError('Supabase config keys could not be inventoried safely');
|
|
165
|
-
return { cursor: valueEnd(text, end + 1), table, keys: vaultKeys(table, text.slice(cursor, end)) };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function configVaultNames(root) {
|
|
169
|
-
const file = path.join(root, 'supabase/config.toml');
|
|
170
|
-
if (!fs.existsSync(file)) return new Map();
|
|
171
|
-
const text = fs.readFileSync(file, 'utf8');
|
|
172
|
-
const configured = new Map();
|
|
173
|
-
let token = { cursor: 0, table: '' };
|
|
174
|
-
while (token.cursor < text.length) {
|
|
175
|
-
token = configToken(text, token.cursor, token.table);
|
|
176
|
-
for (const name of token.keys) {
|
|
177
|
-
if (configured.has(name)) throw new DbVerbError('Vault config names must be unique');
|
|
178
|
-
configured.set(name, null);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return configured;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
98
|
function repositoryState(root) {
|
|
185
99
|
const migrations = dbSqlFiles(root);
|
|
186
|
-
const state = { migrations: new Map(migrations.map((entry) => [entry.stamp, null])), cron: new Map(), queues: new Map()
|
|
100
|
+
const state = { migrations: new Map(migrations.map((entry) => [entry.stamp, null])), cron: new Map(), queues: new Map() };
|
|
187
101
|
if (state.migrations.size !== migrations.length) throw new DbVerbError('active migrations must have unique versions');
|
|
188
102
|
for (const entry of migrations) {
|
|
189
103
|
const tree = parseSql(fs.readFileSync(path.join(root, entry.file), 'utf8'));
|
|
@@ -197,22 +111,19 @@ function repositoryState(root) {
|
|
|
197
111
|
return state;
|
|
198
112
|
}
|
|
199
113
|
|
|
200
|
-
function queryRows(root, sql,
|
|
201
|
-
|
|
202
|
-
// Existing metadata is sufficient; never inspect the cache or credential files.
|
|
203
|
-
if (!local && !ports.runSupabase && !ports.run && !fs.existsSync(path.join(root, 'supabase/.temp/linked-project.json'))) throw new DbVerbError('link the existing project before running a read-only database plan');
|
|
204
|
-
const output = runDbSupabase(root, ['db', 'query', sql, local ? '--local' : '--linked', '--output', 'json', '--agent', 'no'], ports);
|
|
114
|
+
function queryRows(root, sql, urlEnv, ports) {
|
|
115
|
+
const output = runDbPostgres(root, { kind: 'query', sql }, ports, urlEnv);
|
|
205
116
|
let rows;
|
|
206
|
-
try { rows = JSON.parse(output); } catch { throw new DbVerbError('
|
|
207
|
-
if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) throw new DbVerbError('
|
|
117
|
+
try { rows = JSON.parse(output); } catch { throw new DbVerbError('the database returned unreadable inventory; database output was withheld'); }
|
|
118
|
+
if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) throw new DbVerbError('the database returned an invalid inventory; database output was withheld');
|
|
208
119
|
return rows;
|
|
209
120
|
}
|
|
210
121
|
|
|
211
|
-
function remoteState(root,
|
|
212
|
-
const rows = queryRows(root, PRESENCE_SQL,
|
|
213
|
-
if (rows.length !== 1 || Object.keys(INVENTORIES).some((key) => typeof rows[0][key] !== 'boolean')) throw new DbVerbError('
|
|
122
|
+
function remoteState(root, urlEnv, ports) {
|
|
123
|
+
const rows = queryRows(root, PRESENCE_SQL, urlEnv, ports);
|
|
124
|
+
if (rows.length !== 1 || Object.keys(INVENTORIES).some((key) => typeof rows[0][key] !== 'boolean')) throw new DbVerbError('the database returned invalid inventory availability');
|
|
214
125
|
const state = {};
|
|
215
|
-
for (const [key, sql] of Object.entries(INVENTORIES)) state[key] = rows[0][key] ? queryRows(root, sql,
|
|
126
|
+
for (const [key, sql] of Object.entries(INVENTORIES)) state[key] = rows[0][key] ? queryRows(root, sql, urlEnv, ports) : [];
|
|
216
127
|
return state;
|
|
217
128
|
}
|
|
218
129
|
|
|
@@ -222,8 +133,8 @@ function compare(expected, actual, kind) {
|
|
|
222
133
|
let changed = 0;
|
|
223
134
|
for (const row of actual) {
|
|
224
135
|
const key = kind === 'migrations' ? row.version : row.name;
|
|
225
|
-
if (key !== null && (typeof key !== 'string' || key.length === 0)) throw new DbVerbError('
|
|
226
|
-
if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{64}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('
|
|
136
|
+
if (key !== null && (typeof key !== 'string' || key.length === 0)) throw new DbVerbError('the database returned an invalid inventory identifier');
|
|
137
|
+
if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{64}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('the database returned invalid cron metadata');
|
|
227
138
|
if (!unmatched.delete(key)) unexpected += 1;
|
|
228
139
|
else if (kind === 'cron') {
|
|
229
140
|
const desired = expected.get(key);
|
|
@@ -236,10 +147,10 @@ function compare(expected, actual, kind) {
|
|
|
236
147
|
/** Numeric exit codes: 0 equal, 1 drift, 2 invalid/failed; all IO and CLI calls are injectable. */
|
|
237
148
|
export function dbPlanMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), ...ports } = {}) {
|
|
238
149
|
try {
|
|
239
|
-
const opts = dbOptions(argv, ['cwd', '
|
|
150
|
+
const opts = dbOptions(argv, ['cwd', 'url-env', 'json', 'apply']);
|
|
240
151
|
const root = dbAppRoot(cwd, opts);
|
|
241
152
|
const expected = repositoryState(root);
|
|
242
|
-
const actual = remoteState(root, opts
|
|
153
|
+
const actual = remoteState(root, opts['url-env'] ?? 'DATABASE_URL', ports);
|
|
243
154
|
const summaries = Object.fromEntries(Object.keys(INVENTORIES).map((kind) => [kind, compare(expected[kind], actual[kind], kind)]));
|
|
244
155
|
const drift = Object.values(summaries).some((item) => item.missing || item.unexpected || item.changed);
|
|
245
156
|
// --apply is accepted for shared verb ergonomics; a plan never mutates history, queues or jobs.
|
|
@@ -5,7 +5,7 @@ import { parseYaml } from '../../engine/yaml.mjs';
|
|
|
5
5
|
import { invocationDir } from '../lib/roots.mjs';
|
|
6
6
|
import { migrationStamp } from './rules/database-migrations.mjs';
|
|
7
7
|
import { MIGRATIONS_DIR } from './rules/database-constants.mjs';
|
|
8
|
-
import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles,
|
|
8
|
+
import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbPostgres, repairOp, repairLine, dbFailure } from './db-baseline-verb.mjs';
|
|
9
9
|
|
|
10
10
|
/** The map value of one old version: a new version, or { file, to, applied } for the colliding-versions case. */
|
|
11
11
|
function mapEntry(old, raw) {
|
|
@@ -39,16 +39,16 @@ function sourceOf(entry, active) {
|
|
|
39
39
|
* of the file that ran). No row means nothing ran; a row named for the other file means the one that stays ran. A history that cannot be read is
|
|
40
40
|
* not guessed: the entry has to say `applied`.
|
|
41
41
|
*/
|
|
42
|
-
function recordedAsApplied(root, source, old,
|
|
42
|
+
function recordedAsApplied(root, source, old, urlEnv, ports) {
|
|
43
43
|
const unreadable = new DbVerbError(`version ${old} is shared by several migrations and the history could not be read; say which one ran with "${old}": { file: <name>, applied: true|false }`);
|
|
44
44
|
let rows;
|
|
45
|
-
try { rows = JSON.parse(
|
|
45
|
+
try { rows = JSON.parse(runDbPostgres(root, { kind: 'query', sql: `select name from supabase_migrations.schema_migrations where version = '${old}'` }, ports, urlEnv)); } catch { throw unreadable; }
|
|
46
46
|
if (!Array.isArray(rows) || (rows.length > 0 && typeof rows[0]?.name !== 'string')) throw unreadable;
|
|
47
47
|
return rows.length > 0 && rows[0].name === source.name.slice(15).replace(/\.sql$/, '');
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** YAML maps use quoted UTC stamps as keys and a new version, or { file, to?, applied? }, as values (or a versions: mapping). */
|
|
51
|
-
function restampPlan(root, mapFile, {
|
|
51
|
+
function restampPlan(root, mapFile, { urlEnv = 'DATABASE_URL', ports = {} } = {}) {
|
|
52
52
|
if (!mapFile) throw new DbVerbError('restamp requires --map <restamp.yaml>');
|
|
53
53
|
let document;
|
|
54
54
|
try { document = parseYaml(fs.readFileSync(path.resolve(root, mapFile), 'utf8')); }
|
|
@@ -67,7 +67,7 @@ function restampPlan(root, mapFile, { local = false, ports = {} } = {}) {
|
|
|
67
67
|
if (occupied.has(next) || targets.has(next)) throw new DbVerbError('restamp targets must be unique unoccupied versions');
|
|
68
68
|
targets.add(next);
|
|
69
69
|
// a version two files share ran for at most one of them: the history says which, unless the entry does
|
|
70
|
-
const applied = entry.applied ?? (shared ? recordedAsApplied(root, source, old,
|
|
70
|
+
const applied = entry.applied ?? (shared ? recordedAsApplied(root, source, old, urlEnv, ports) : true);
|
|
71
71
|
return { old, next, source: source.file, target: `${MIGRATIONS_DIR}/${source.name.replace(old, next)}`, applied };
|
|
72
72
|
});
|
|
73
73
|
const moved = new Map(steps.map((step) => [step.source, step.next]));
|
|
@@ -79,16 +79,17 @@ function restampPlan(root, mapFile, { local = false, ports = {} } = {}) {
|
|
|
79
79
|
/** The complete map is validated before any repair or file mutation. */
|
|
80
80
|
export function dbRestampMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), ...ports } = {}) {
|
|
81
81
|
try {
|
|
82
|
-
const opts = dbOptions(argv, ['cwd', 'map', 'apply', '
|
|
82
|
+
const opts = dbOptions(argv, ['cwd', 'map', 'apply', 'url-env']);
|
|
83
|
+
const urlEnv = opts['url-env'] ?? 'DATABASE_URL';
|
|
83
84
|
const root = dbAppRoot(cwd, opts);
|
|
84
|
-
const steps = restampPlan(root, opts.map, {
|
|
85
|
-
const repairs = steps.filter((step) => step.applied).flatMap((step) => [
|
|
85
|
+
const steps = restampPlan(root, opts.map, { urlEnv, ports });
|
|
86
|
+
const repairs = steps.filter((step) => step.applied).flatMap((step) => [repairOp(step.old, 'reverted'), repairOp(step.next, 'applied', step.target.split('/').at(-1).slice(15).replace(/\.sql$/, ''))]);
|
|
86
87
|
stdout(`${opts.apply ? 'apply' : 'preview'} restamp ${steps.length} migrations\n`);
|
|
87
88
|
for (const step of steps) stdout(`${step.source} -> ${step.target}\n`);
|
|
88
|
-
for (const
|
|
89
|
+
for (const op of repairs) stdout(`${repairLine(op)}\n`);
|
|
89
90
|
if (!opts.apply) return 0;
|
|
90
91
|
// CLI failures never echo credentials and leave the local files intact for an explicit retry.
|
|
91
|
-
for (const
|
|
92
|
+
for (const op of repairs) runDbPostgres(root, op, ports, urlEnv);
|
|
92
93
|
for (const step of steps) {
|
|
93
94
|
const source = path.join(root, step.source);
|
|
94
95
|
const text = fs.readFileSync(source, 'utf8').replace(/^\s*--\s*starci:restamp\s+\d{14}\s+\d{14}\s*\r?\n/, '');
|
|
@@ -2,27 +2,23 @@
|
|
|
2
2
|
// against a loaded manifest remain in slots.mjs; this module mirrors modules/schemas/hfs-repo.schema.yaml only.
|
|
3
3
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
4
4
|
import { APP_KIND, connectionShapeProblems, NAME, PACKAGE_NAME, RUNTIME_KIND, SLOT_ID } from './manifest-shape.mjs';
|
|
5
|
+
import { dbFunctionsOk } from './rule-params-shape.mjs';
|
|
5
6
|
import { kindShapeProblems, patternShapeProblems } from './declaration-slots.mjs';
|
|
6
7
|
import { byCodeUnit } from '../lib/list.mjs';
|
|
7
8
|
|
|
8
|
-
/** Shape problems of the optional hfs.json `
|
|
9
|
-
const
|
|
9
|
+
/** Shape problems of the optional hfs.json `db` block of a lite app: the environment variable naming its database, its image, forced RLS and its application role. */
|
|
10
|
+
const isStringList = (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'string' && entry.trim());
|
|
10
11
|
|
|
11
|
-
function
|
|
12
|
-
const bad = [];
|
|
13
|
-
if (block.enableSignup !== undefined && typeof block.enableSignup !== 'boolean') bad.push('supabase.enableSignup must be true or false');
|
|
14
|
-
if (block.jwtExpiry !== undefined && !(Number.isInteger(block.jwtExpiry) && block.jwtExpiry > 0 && block.jwtExpiry <= 3600)) bad.push('supabase.jwtExpiry must be an integer of seconds, 3600 at most');
|
|
15
|
-
if (block.siteUrl !== undefined && (typeof block.siteUrl !== 'string' || !block.siteUrl.trim())) bad.push('supabase.siteUrl must be a URL string');
|
|
16
|
-
for (const key of ['redirectUrls', 'forceRls']) if (block[key] !== undefined && !isSupabaseStringList(block[key])) bad.push(`supabase.${key} must be a list of strings`);
|
|
17
|
-
return bad;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function supabaseBlockProblems(block) {
|
|
12
|
+
function dbBlockProblems(block) {
|
|
21
13
|
if (block === undefined) return [];
|
|
22
|
-
if (!isPlainObject(block)) return ['
|
|
14
|
+
if (!isPlainObject(block)) return ['db must be an object'];
|
|
23
15
|
const bad = [];
|
|
24
|
-
for (const key of Object.keys(block)) if (!['
|
|
25
|
-
|
|
16
|
+
for (const key of Object.keys(block)) if (!['urlEnv', 'image', 'forceRls', 'forceRlsExemptions', 'appRole'].includes(key)) bad.push(`db has unknown key ${key}`);
|
|
17
|
+
if (block.urlEnv !== undefined && (typeof block.urlEnv !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(block.urlEnv))) bad.push('db.urlEnv must name an UPPER_CASE environment variable');
|
|
18
|
+
if (block.image !== undefined && (typeof block.image !== 'string' || !/@sha256:[0-9a-f]{64}$/.test(block.image))) bad.push('db.image must be a digest-pinned image reference');
|
|
19
|
+
if (block.forceRls !== undefined && !isStringList(block.forceRls)) bad.push('db.forceRls must be a list of strings');
|
|
20
|
+
if (block.appRole !== undefined && (typeof block.appRole !== 'string' || !/^[a-z][a-z0-9_]*$/.test(block.appRole))) bad.push('db.appRole must name a role');
|
|
21
|
+
if (block.forceRlsExemptions !== undefined && !Array.isArray(block.forceRlsExemptions)) bad.push('db.forceRlsExemptions must be a list of { table, reason }');
|
|
26
22
|
return bad;
|
|
27
23
|
}
|
|
28
24
|
|
|
@@ -95,7 +91,8 @@ function declarationSideProblems(side, value, names) {
|
|
|
95
91
|
const bad = [];
|
|
96
92
|
const at = `sides.${side}`;
|
|
97
93
|
if (!isPlainObject(value)) return [`${at} must be an object`];
|
|
98
|
-
for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'queueOutbox'].includes(key)) bad.push(`${at} has unknown key ${key}`);
|
|
94
|
+
for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'queueOutbox', 'dbFunctions'].includes(key)) bad.push(`${at} has unknown key ${key}`);
|
|
95
|
+
if (value.dbFunctions !== undefined && (side !== 'be' || !dbFunctionsOk(value.dbFunctions))) bad.push(`${at}.dbFunctions belongs to the be side and lists {name, reason} database functions with unique names`);
|
|
99
96
|
bad.push(...appShapeProblems(value.apps, at, side, names));
|
|
100
97
|
if (isBadUniqueList(value.optionalSlots, (slot) => SLOT_ID.test(String(slot)))) bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
|
|
101
98
|
if (value.backing !== undefined && side !== 'be') bad.push('backing belongs to the be side');
|
|
@@ -127,13 +124,13 @@ function observeBlockProblems(observe) {
|
|
|
127
124
|
return bad;
|
|
128
125
|
}
|
|
129
126
|
|
|
130
|
-
const DECLARATION_KEYS = new Set(['hfs', 'kind', 'project', 'edition', '
|
|
127
|
+
const DECLARATION_KEYS = new Set(['hfs', 'kind', 'project', 'edition', 'db', 'sides', 'browser', 'lexicon', 'observe']);
|
|
131
128
|
const RUNTIME_DECLARATION_KEYS = new Set(['hfs', 'kind', 'project']);
|
|
132
129
|
|
|
133
130
|
function productDeclarationProblems(declaration, profiles) {
|
|
134
131
|
const bad = [];
|
|
135
132
|
for (const key of Object.keys(declaration)) if (!DECLARATION_KEYS.has(key)) bad.push(`unknown key ${key}`);
|
|
136
|
-
bad.push(...
|
|
133
|
+
bad.push(...dbBlockProblems(declaration.db), ...lexiconBlockProblems(declaration.lexicon), ...observeBlockProblems(declaration.observe));
|
|
137
134
|
if (declaration.browser !== undefined && declaration.browser !== true) bad.push('browser is `true` when the app has a browser journey (slot app.browser), and is left out otherwise');
|
|
138
135
|
if (declaration.kind !== APP_KIND) bad.push(`kind must be ${APP_KIND} (a product is one app repository with a be and an fe side) or ${RUNTIME_KIND} (the StarCi runtime repository)`);
|
|
139
136
|
if (!isPlainObject(declaration.sides) || Object.keys(declaration.sides).sort(byCodeUnit).join() !== profiles.join()) { bad.push('sides must declare exactly be and fe'); return bad; }
|
|
@@ -15,12 +15,12 @@ const ENV_PREFIX =/^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
|
|
|
15
15
|
const CONTEXT_ISOLATIONS = Object.freeze(["database", "schema"]);
|
|
16
16
|
/** The app kinds that may own a context: the services, never the migrate or cli apps that only run migrations. */
|
|
17
17
|
const CONTEXT_OWNER_KINDS = Object.freeze(["api", "worker"]);
|
|
18
|
-
/** The database
|
|
19
|
-
const CONNECTION_PROVIDERS = Object.freeze(["postgres"
|
|
18
|
+
/** The database engine a connection runs on. */
|
|
19
|
+
const CONNECTION_PROVIDERS = Object.freeze(["postgres"]);
|
|
20
20
|
/** The editions an app repository may declare in hfs.json (`full` when absent): the filter slots.mjs applies. */
|
|
21
21
|
export const EDITIONS = Object.freeze(["full", "lite"]);
|
|
22
|
-
/** What decides where schema changes live (ruleParams.<side>.schemaAuthority): TypeORM migrations under persistence/, or the
|
|
23
|
-
export const SCHEMA_AUTHORITIES = Object.freeze(["typeorm", "
|
|
22
|
+
/** What decides where schema changes live (ruleParams.<side>.schemaAuthority): TypeORM migrations under persistence/, or the SQL migration tree db/migrations. */
|
|
23
|
+
export const SCHEMA_AUTHORITIES = Object.freeze(["typeorm", "sql"]);
|
|
24
24
|
/**
|
|
25
25
|
* The shape problems of the `connections` of one side: a list of {name, envPrefix, owner, isolation}, the owner an api or worker app
|
|
26
26
|
* of the same side (a connection is a bounded context and one service owns it), isolation `database` or `schema`.
|
|
@@ -62,6 +62,9 @@ function beSpecificProblems(be, manifest) {
|
|
|
62
62
|
return bad;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** True for an absent list or a list of {name, reason} database functions, names unique. */
|
|
66
|
+
export const dbFunctionsOk = (list) => list === undefined || (Array.isArray(list) && list.every((entry) => isPlainObject(entry) && Object.keys(entry).sort().join() === 'name,reason' && /^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?$/.test(entry.name) && typeof entry.reason === 'string' && entry.reason.trim().length > 0) && new Set(list.map((entry) => entry.name)).size === list.length);
|
|
67
|
+
|
|
65
68
|
/** The shape problems of `m.ruleParams`. */
|
|
66
69
|
export function ruleParamsProblems(m) {
|
|
67
70
|
const bad = [];
|
|
@@ -71,7 +74,7 @@ export function ruleParamsProblems(m) {
|
|
|
71
74
|
// common holds the parameters both sides share; a side may restate one of them (a valid value) as an override, nothing else.
|
|
72
75
|
// Edition lite: a side's optional `lite` map overrides keys of the same side (never `lite` itself) when the app declares edition lite.
|
|
73
76
|
if (!sideOk(rp.common, []) || !fileLinesOk(rp.common.fileLines) || !blockOk(rp.common.duplicateBlock)) bad.push('ruleParams.common needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}; optional functionLines and featureFiles must be positive integers');
|
|
74
|
-
if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority'])) bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + ') and lite (overrides of the same keys)'); else bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
|
|
77
|
+
if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite', 'dbFunctions']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !dbFunctionsOk(rp.be.dbFunctions) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority', 'dbFunctions'])) bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + '), dbFunctions ([{name, reason}]) and lite (overrides of the same keys)'); else bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
|
|
75
78
|
bad.push(...beSpecificProblems(rp.be, m));
|
|
76
79
|
if (rp.fe !== undefined && (!sideOk(rp.fe, [], ['lite']) || !liteOk(rp.fe.lite, SHARED_KEYS))) bad.push('ruleParams.fe may only restate the shared parameters of ruleParams.common, and carry a lite override of them');
|
|
77
80
|
if (!dbParamsOk(rp.db)) bad.push('ruleParams.db may contain only extensions (a list of unique non-empty extension names) and queueOutbox ({table: <schema>.<table>, columns: [unique column names including queue]})');
|
|
@@ -48,7 +48,7 @@ export const A15_CODES = [
|
|
|
48
48
|
'HFS_WORKFLOW_CONTEXT',
|
|
49
49
|
'CI_STANDARD',
|
|
50
50
|
'CI_SECRET_SEALED', 'APP_SECRET_IN_CI', 'CI_DECLARATION_DRIFT',
|
|
51
|
-
'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BOOT_PROOF_TIMEOUT', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', '
|
|
51
|
+
'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BOOT_PROOF_TIMEOUT', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_CRON_OWNED', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_REDIS_PERSISTENCE', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
|
|
52
52
|
// codes-end
|
|
53
53
|
];
|
|
54
54
|
|
|
@@ -26,7 +26,7 @@ export function cliFindings({ files, repo, resolver }) {
|
|
|
26
26
|
const commandFiles = onBe.filter((file) => beResolver.classifyPath(file).slot === CLI_FEATURE_SLOT);
|
|
27
27
|
const findings = [];
|
|
28
28
|
const cliApps = be.apps.filter((app) => app.kind === CLI_KIND);
|
|
29
|
-
// Where the cli app is only optional (litePresence: schema migration is
|
|
29
|
+
// Where the cli app is only optional (litePresence: schema migration is `starci app db migrate`'s job) a connection no longer forces it; a tracked command still does.
|
|
30
30
|
const migrates = (be.connections ?? []).length > 0 && beResolver.slot(CLI_APP_SLOT)?.presence !== 'optional';
|
|
31
31
|
const needs = migrates || commandFiles.length > 0;
|
|
32
32
|
let why = 'it tracks commands under be/src/features/cli/';
|