@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.
Files changed (219) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +7 -9
  3. package/db/backup-receipt.mjs +10 -47
  4. package/db/drift.mjs +1 -1
  5. package/db/dump.mjs +11 -10
  6. package/db/entity-list.mjs +1 -1
  7. package/db/migrate.mjs +83 -259
  8. package/db/new.mjs +46 -0
  9. package/db/post-cutover.mjs +13 -17
  10. package/db/postgres.mjs +80 -0
  11. package/emit/contracts.mjs +5 -9
  12. package/package.json +1 -1
  13. package/release/gate.mjs +3 -15
  14. package/runtime/knowledge/hfs/canon-pins.yaml +44 -24
  15. package/runtime/knowledge/hfs/design-truths.yaml +1 -1
  16. package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
  17. package/runtime/knowledge/hfs/rules.yaml +177 -155
  18. package/runtime/knowledge/hfs/slots.yaml +164 -172
  19. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  20. package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
  21. package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
  22. package/runtime/knowledge/patterns/be/projections.yaml +7 -5
  23. package/runtime/knowledge/patterns/be/queues.yaml +8 -8
  24. package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
  25. package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
  26. package/runtime/knowledge/patterns/be/saga.yaml +3 -3
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
  28. package/runtime/modules/kernel/command-policy.yaml +1 -1
  29. package/runtime/modules/kernel/failure-codes.yaml +65 -115
  30. package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
  31. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  32. package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
  33. package/runtime/scripts/hfs/architecture/index.mjs +2 -3
  34. package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
  35. package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
  36. package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
  37. package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
  38. package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
  39. package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
  40. package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
  41. package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
  42. package/runtime/scripts/hfs/check.mjs +6 -6
  43. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  44. package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
  45. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
  46. package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
  47. package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
  48. package/runtime/scripts/hfs/manifest-shape.mjs +4 -4
  49. package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
  50. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  51. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  52. package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
  53. package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
  54. package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
  55. package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
  56. package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
  57. package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
  58. package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
  59. package/runtime/scripts/hfs/rules/database.mjs +36 -39
  60. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  61. package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
  62. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
  63. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
  64. package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
  65. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  66. package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
  67. package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
  68. package/runtime/scripts/hfs/slots.mjs +8 -2
  69. package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
  70. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  71. package/runtime/scripts/lib/swarm-stack.mjs +7 -4
  72. package/scaffold/add-app.mjs +2 -10
  73. package/scaffold/add-cli-lite.mjs +6 -6
  74. package/scaffold/add-compose.mjs +1 -1
  75. package/scaffold/add-inbox-lite.mjs +4 -8
  76. package/scaffold/add-plan.mjs +1 -1
  77. package/scaffold/add-queue-lite.mjs +16 -24
  78. package/scaffold/add-table.mjs +34 -26
  79. package/scaffold/add.mjs +2 -2
  80. package/scaffold/app.mjs +19 -86
  81. package/scaffold/lite-exports.mjs +1 -1
  82. package/src/main.mjs +19 -20
  83. package/stack/target-binding.mjs +0 -60
  84. package/stack/urgent-floor.mjs +2 -2
  85. package/sync/index.mjs +32 -8
  86. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
  87. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
  88. package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
  89. package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
  90. package/templates/app/gitignore-lite +0 -3
  91. package/templates/app/package-scripts/db.json +9 -0
  92. package/templates/app/package-scripts/package.json +0 -1
  93. package/templates/app/package-scripts-lite/package.json +1 -1
  94. package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
  95. package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
  96. package/templates/app/skeleton/README.md +1 -1
  97. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
  98. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
  99. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
  100. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
  101. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
  102. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
  103. package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
  104. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
  105. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
  106. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
  107. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
  108. package/templates/app/skeleton-lite/README.md +11 -11
  109. package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
  110. package/templates/app/tool-config-lite/prettierignore +0 -1
  111. package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
  112. package/templates/be/inbox/database-client.ts.tpl +13 -4
  113. package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
  114. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
  115. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
  116. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
  117. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
  118. package/templates/be/inbox/platform/migration.sql.tpl +22 -0
  119. package/templates/be/inbox/service.spec.ts.tpl +2 -2
  120. package/templates/be/inbox/service.ts.tpl +1 -2
  121. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
  122. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
  123. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
  124. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
  125. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
  126. package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
  127. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
  128. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
  129. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
  130. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
  131. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
  132. package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
  133. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
  134. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
  135. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
  136. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
  137. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
  138. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
  139. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
  140. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
  141. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
  142. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
  143. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
  144. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +6 -6
  145. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
  146. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
  147. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
  148. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
  149. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
  150. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
  151. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
  152. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
  153. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
  154. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
  155. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
  156. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
  157. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
  158. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
  159. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
  160. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
  161. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
  162. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
  163. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
  164. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
  165. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
  166. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
  167. package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
  168. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
  169. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  170. package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
  171. package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
  172. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
  173. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
  174. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
  175. package/templates/be/table/entities.index.ts.tpl +4 -0
  176. package/templates/be/table/entity.ts.tpl +21 -0
  177. package/templates/be/table/index.ts.tpl +1 -0
  178. package/templates/be/table/module.ts.tpl +1 -1
  179. package/templates/be/table/service.api.ts.tpl +10 -11
  180. package/templates/be/table/service.ts.tpl +5 -6
  181. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
  182. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
  183. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
  184. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
  185. package/db/local-migrate.mjs +0 -194
  186. package/db/reset.mjs +0 -125
  187. package/emit/db-types.mjs +0 -271
  188. package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
  189. package/runtime/scripts/api/supabase/database-push.mjs +0 -5
  190. package/runtime/scripts/api/supabase/database-query.mjs +0 -5
  191. package/runtime/scripts/api/supabase/lib.mjs +0 -55
  192. package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
  193. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
  194. package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
  195. package/scaffold/supabase-ports.mjs +0 -26
  196. package/templates/app/package-scripts/supabase.json +0 -7
  197. package/templates/app/skeleton-lite/supabase/config.toml +0 -48
  198. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
  199. package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
  200. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
  201. package/templates/be/inbox/sql.ts.tpl +0 -16
  202. package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
  203. package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
  204. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
  205. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
  206. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
  207. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
  208. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
  209. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
  210. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
  211. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
  212. package/templates/be/table/rows.ts.tpl +0 -4
  213. package/templates/be/table/sql.api.ts.tpl +0 -7
  214. package/templates/be/table/sql.ts.tpl +0 -4
  215. package/templates/fe/auth/supabase-client.ts.tpl +0 -38
  216. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
  217. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
  218. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
  219. /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 (be.integrations.supabase) holds files only in an
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 { databaseDump } from '../api/supabase/database-dump.mjs';
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', 'local', 'json'].includes(name)) {
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 Supabase's temporary credential files. */
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
- /** Supabase adapter uses supabaseSpawn's (repoRoot, args, options) contract; raw failures are private. */
63
- const DB_CALLS = { 'db dump': databaseDump, 'db query': databaseQuery, 'migration repair': migrationRepair };
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
- function runDatabaseCommand(root, args, options) {
66
- const call = DB_CALLS[args.slice(0, 2).join(' ')];
67
- if (!call) throw new DbVerbError('unsupported database command');
68
- return call(root, args.slice(2), options);
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
- export function runDbSupabase(root, args, ports = {}) {
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
- // The linked CLI can mint a temporary remote login when no existing password is supplied.
74
- // Check custody presence only; the value stays within the CLI environment.
75
- if (args.includes('--linked') && !ports.runSupabase && !ports.run && (typeof env[LINKED_PASSWORD_ENV] !== 'string' || env[LINKED_PASSWORD_ENV].length === 0)) throw new DbVerbError('linked database operations require an existing SUPABASE_DB_PASSWORD in the CLI environment');
76
- const cliEnv = { ...env, [TELEMETRY_DISABLED_ENV]: '1', [TELEMETRY_DEBUG_ENV]: '0', [CLI_DEBUG_ENV]: '0' };
77
- const result = (ports.runSupabase ?? runDatabaseCommand)(root, args, { env: cliEnv, ...(ports.run ? { run: ports.run } : {}) });
78
- if (result?.error || !(result?.ok === true || result?.status === 0)) throw new DbVerbError('Supabase command failed; inspect the CLI privately');
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
- export const repairArgs = (stamp, status, local) => ['migration', 'repair', stamp, '--status', status, local ? '--local' : '--linked'];
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 ['supabase', 'be/src', 'be/apps', 'src', 'apps']) visit(directory);
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', 'local']);
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', ...(opts.local ? ['--local'] : [])], { ...ports, cwd: root, stdout: () => {}, stderr: () => {} }) !== 0) throw new DbVerbError('baseline requires a clean database plan with all local migrations already applied');
267
- const schema = runDbSupabase(root, ['db', 'dump', opts.local ? '--local' : '--linked'], ports);
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) => repairArgs(entry.stamp, 'reverted', opts.local)), repairArgs(plan.stamp, 'applied', opts.local)];
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 args of repairs) stdout(`supabase ${args.join(' ')}\n`);
289
+ for (const op of repairs) stdout(`${repairLine(op)}\n`);
272
290
  if (!opts.apply) return 0;
273
- for (const args of repairs) runDbSupabase(root, args, ports);
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 or Vault values.
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, runDbSupabase, dbFailure } from './db-baseline-verb.mjs';
8
+ import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbPostgres, dbFailure } from './db-baseline-verb.mjs';
10
9
 
11
10
  await pg.loadModule();
12
11
 
13
- // The pinned Supabase CLI uses positional SQL, --output json and --agent no for a plain rows array.
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(), vault: configVaultNames(root) };
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, local, ports) {
201
- // The pinned CLI otherwise creates this cache in its linked-query finalizer.
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('Supabase returned unreadable inventory; database output was withheld'); }
207
- if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) throw new DbVerbError('Supabase returned an invalid inventory; database output was withheld');
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, local, ports) {
212
- const rows = queryRows(root, PRESENCE_SQL, local, ports);
213
- if (rows.length !== 1 || Object.keys(INVENTORIES).some((key) => typeof rows[0][key] !== 'boolean')) throw new DbVerbError('Supabase returned invalid inventory availability');
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, local, ports) : [];
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('Supabase returned an invalid inventory identifier');
226
- if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{64}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('Supabase returned invalid cron metadata');
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', 'local', 'json', 'apply']);
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.local, ports);
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, runDbSupabase, repairArgs, dbFailure } from './db-baseline-verb.mjs';
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, local, ports) {
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(runDbSupabase(root, ['db', 'query', `select name from supabase_migrations.schema_migrations where version = '${old}'`, local ? '--local' : '--linked', '--output', 'json', '--agent', 'no'], ports)); } catch { throw unreadable; }
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, { local = false, ports = {} } = {}) {
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, local, ports) : true);
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', 'local']);
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, { local: opts.local, ports });
85
- const repairs = steps.filter((step) => step.applied).flatMap((step) => [repairArgs(step.old, 'reverted', opts.local), repairArgs(step.next, 'applied', opts.local)]);
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 args of repairs) stdout(`supabase ${args.join(' ')}\n`);
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 args of repairs) runDbSupabase(root, args, ports);
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 `supabase` block whose auth posture DB_CONFIG_POLICY checks. */
9
- const isSupabaseStringList = (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'string' && entry.trim());
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 supabaseValueProblems(block) {
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 ['supabase must be an object'];
14
+ if (!isPlainObject(block)) return ['db must be an object'];
23
15
  const bad = [];
24
- for (const key of Object.keys(block)) if (!['enableSignup', 'jwtExpiry', 'siteUrl', 'redirectUrls', 'forceRls'].includes(key)) bad.push(`supabase has unknown key ${key}`);
25
- bad.push(...supabaseValueProblems(block));
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', 'supabase', 'sides', 'browser', 'lexicon', 'observe']);
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(...supabaseBlockProblems(declaration.supabase), ...lexiconBlockProblems(declaration.lexicon), ...observeBlockProblems(declaration.observe));
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 engines a connection may run on: a self-described Postgres, or the Postgres of a Supabase project. */
19
- const CONNECTION_PROVIDERS = Object.freeze(["postgres", "supabase"]);
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 Supabase migration tree. */
23
- export const SCHEMA_AUTHORITIES = Object.freeze(["typeorm", "supabase"]);
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', 'DB_API_EXPOSED', '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_SERVICE_ROLE_SCOPE', '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',
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 the Supabase CLI's job) a connection no longer forces it; a tracked command still does.
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/';