@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
@@ -1,194 +0,0 @@
1
- // Explicit-local migration applies only the app's active SQL chain through the Supabase API owner.
2
- // Supabase CLI v2.119.0: db/push, migration-pending, migration-list and db-config.toml-read (read 2026-10-18).
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
- import { createHash } from 'node:crypto';
6
- import { parseEnv } from 'node:util';
7
- import { parse as parseToml } from 'smol-toml';
8
- import { databasePush } from '../runtime/scripts/api/supabase/database-push.mjs';
9
- import { isMigrationName, migrationStamp } from '../runtime/scripts/hfs/rules/database-migrations.mjs';
10
- import { CONFIG_FILE, MIGRATIONS_DIR } from '../runtime/scripts/hfs/rules/database-constants.mjs';
11
- import { failure, refusal } from '../stack/errors.mjs';
12
- import { assertLocalDatabase, boundSupabaseLauncher, nativeDatabaseEnvironment } from '../stack/target-binding.mjs';
13
-
14
- const configError = (reason) => refusal('DB_CONFIG_INVALID', reason);
15
- const encrypted = (value) => typeof value === 'string' && value.includes('encrypted:');
16
- const byteOrder = (left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right));
17
-
18
- /** Never quote a parser/tool diagnostic: config, env and child streams can contain credentials. */
19
- function readFile(file, optional = false) {
20
- try {
21
- const stat = fs.lstatSync(file);
22
- if (!stat.isFile() || stat.isSymbolicLink()) throw configError('local migration inputs must be regular files');
23
- return fs.readFileSync(file, 'utf8');
24
- } catch (error) {
25
- if (optional && error.code === 'ENOENT') return null;
26
- throw configError('local migration inputs must be readable regular files');
27
- }
28
- }
29
-
30
- function requireDirectory(directory) {
31
- try {
32
- const stat = fs.lstatSync(directory);
33
- if (stat.isDirectory() && !stat.isSymbolicLink()) return;
34
- } catch { /* The refusal below withholds filesystem diagnostics. */ }
35
- throw configError('local migration requires the existing app directories, without links');
36
- }
37
-
38
- function requirePlainConfig(value) {
39
- if (encrypted(value)) throw configError('local migration does not decrypt encrypted configuration');
40
- if (value && typeof value === 'object') for (const item of Object.values(value)) requirePlainConfig(item);
41
- }
42
-
43
- /** The pinned CLI searches these exact files; interpolation/escapes are held rather than partially interpreted. */
44
- function projectEnvironment(root, ambient) {
45
- const selected = ambient.SUPABASE_ENV || 'development';
46
- if (!/^[A-Za-z0-9_-]+$/.test(selected)) throw configError('local migration requires an unambiguous Supabase environment selector');
47
- const names = [`.env.${selected}.local`, ...(selected === 'test' ? [] : ['.env.local']), `.env.${selected}`, '.env'];
48
- const values = {};
49
- const files = [];
50
- for (const dir of [path.join(root, 'supabase'), root]) {
51
- for (const name of names) {
52
- const file = path.join(dir, name);
53
- const text = readFile(file, true);
54
- files.push([file, text]);
55
- if (text === null) continue;
56
- // Pinned godotenv expansion and double-quote unescaping can synthesize the encryption prefix.
57
- if (encrypted(text) || /[$\\]/.test(text)) throw configError('local migration requires literal unencrypted dotenv inputs');
58
- // Keep only the common literal assignment grammar shared by Node and the pinned CLI.
59
- for (const line of text.split(/\r?\n/)) {
60
- if (/^\s*(?:#.*)?$/.test(line)) continue;
61
- if (!/^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^'"#\r\n]*)(?:\s+#.*)?\s*$/.test(line)) {
62
- throw configError('local migration requires literal single-line dotenv assignments');
63
- }
64
- }
65
- let parsed;
66
- try { parsed = parseEnv(text); } catch { throw configError('local migration dotenv inputs are not readable assignments'); }
67
- for (const [key, value] of Object.entries(parsed)) {
68
- if (!Object.hasOwn(ambient, key) && !Object.hasOwn(values, key)) values[key] = value;
69
- }
70
- }
71
- }
72
- return { values: { ...values, ...ambient }, files };
73
- }
74
-
75
- function expanded(value, env) {
76
- if (typeof value !== 'string') return value;
77
- const reference = /^env\(([A-Za-z_][A-Za-z0-9_]*)\)$/.exec(value);
78
- const result = reference ? env[reference[1]] : value;
79
- if (typeof result !== 'string' || result.startsWith('env(')) throw configError('local migration configuration has an unresolved environment reference');
80
- return result;
81
- }
82
-
83
- const overridden = (name, value, env) => expanded(typeof env[name] === 'string' && env[name] !== '' ? env[name] : value, env);
84
-
85
- function targetIdentity(config, env) {
86
- const project = overridden('SUPABASE_PROJECT_ID', config.project_id, env);
87
- const portValue = overridden('SUPABASE_DB_PORT', config.db?.port, env);
88
- const port = typeof portValue === 'string' && /^\d+$/.test(portValue) ? Number(portValue) : portValue;
89
- const enabled = overridden('SUPABASE_DB_MIGRATIONS_ENABLED', config.db?.migrations?.enabled ?? true, env);
90
- if (typeof project !== 'string' || !/^[A-Za-z0-9_-]+$/.test(project) || !Number.isInteger(port) || port < 1 || port > 65535) {
91
- throw configError('local migration requires an explicit local project identity and database port');
92
- }
93
- if (enabled !== true && !(typeof enabled === 'string' && /^(?:1|t|T|true|TRUE|True)$/.test(enabled))) {
94
- throw configError('local migration requires migrations to be explicitly enabled by the effective configuration');
95
- }
96
- return { project, port };
97
- }
98
-
99
- /** Snapshot only in memory; no plaintext configuration, values or hashes reach the result. */
100
- function inputs(ctx, nativeTarget = null) {
101
- const root = path.resolve(ctx.cwd);
102
- requireDirectory(root);
103
- requireDirectory(path.join(root, 'supabase'));
104
- requireDirectory(path.join(root, MIGRATIONS_DIR));
105
- const appText = readFile(path.join(root, 'hfs.json'));
106
- let app;
107
- try { app = JSON.parse(appText); } catch { throw configError('local migration requires a readable app declaration'); }
108
- if (app?.kind !== 'app' || !['full', 'lite'].includes(app.edition ?? 'full')) throw configError('local migration requires a lite or full app');
109
- const configText = readFile(path.join(root, CONFIG_FILE));
110
- let config;
111
- try { config = parseToml(configText); } catch { throw configError('local migration requires valid Supabase TOML'); }
112
- requirePlainConfig(config);
113
- for (const value of Object.values(ctx.env)) if (encrypted(value)) throw configError('local migration does not decrypt encrypted environment values');
114
- const { values: env, files: envFiles } = projectEnvironment(root, ctx.env);
115
- if (nativeTarget && (config.experimental?.stack === true
116
- || env.SUPABASE_EXPERIMENTAL_STACK && !/^(?:0|false)$/i.test(env.SUPABASE_EXPERIMENTAL_STACK)
117
- || env.SUPABASE_CLI_BINARY_OVERRIDE)) throw configError('the bound local target requires the declared legacy Supabase backend and installed launcher');
118
- const launcher = nativeTarget ? boundSupabaseLauncher(root) : null;
119
- const target = targetIdentity(config, env);
120
- let names;
121
- try { names = fs.readdirSync(path.join(root, MIGRATIONS_DIR)).sort(byteOrder); } catch { throw configError('local migrations are not readable'); }
122
- // Match the pinned CLI's unanchored first-entry legacy-init skip, including nested names.
123
- const legacy = /([0-9]{14})_init\.sql/.exec(names[0] ?? '');
124
- if (legacy && Number(legacy[1]) < 20211209000000) throw refusal('DB_STAMP_ORDER', 'the local chain contains a legacy init migration the installed CLI skips');
125
- const migrations = [];
126
- const stamps = new Set();
127
- for (const name of names.filter((entry) => entry.endsWith('.sql'))) {
128
- const stamp = name.slice(0, name.indexOf('_'));
129
- if (!isMigrationName(name) || migrationStamp(stamp) === null) throw refusal('DB_MIGRATION_NAME', 'local migrations require valid UTC-stamped filenames');
130
- if (stamps.has(stamp)) throw refusal('DB_STAMP_COLLISION', 'local migrations contain a duplicate timestamp');
131
- stamps.add(stamp);
132
- migrations.push([name, readFile(path.join(root, MIGRATIONS_DIR, name))]);
133
- }
134
- const digest = createHash('sha256').update(JSON.stringify({ root, appText, configText, envFiles, env, target, migrations })).digest('hex');
135
- return { root, target, names: migrations.map(([name]) => name), digest, nativeTarget, launcher };
136
- }
137
-
138
- function unchanged(ctx, snapshot) {
139
- const current = inputs(ctx, snapshot.nativeTarget);
140
- if (current.digest !== snapshot.digest) throw failure('DB_FINAL_MISMATCH', 'local migration inputs changed during the operation');
141
- return current;
142
- }
143
-
144
- /** Only the specific Supabase owner can spawn; failures and malformed JSON never expose child output. */
145
- function push(ctx, snapshot, dryRun) {
146
- const env = snapshot.nativeTarget ? nativeDatabaseEnvironment(ctx.env, snapshot.nativeTarget,
147
- { 'SUPABASE_SERVICES_HOSTNAME': '127.0.0.1', 'SUPABASE_EXPERIMENTAL_STACK': '0' }) : ctx.env;
148
- const run = snapshot.nativeTarget ? (file, args, options) => {
149
- if (file !== process.execPath || args[0] !== snapshot.launcher) throw failure('DB_CLI_MISSING', 'the bound database launcher cannot fall back to a global tool');
150
- return ctx.spawn(file, args, options);
151
- } : ctx.spawn;
152
- const result = databasePush(snapshot.root, ['--local', '--skip-vault', '--workdir', snapshot.root, '--output-format', 'json',
153
- ...(snapshot.nativeTarget ? ['--profile', 'supabase'] : []), ...(dryRun ? ['--dry-run'] : ['--yes'])], { env, run });
154
- if (result?.error || result?.signal || result?.status !== 0) throw failure('DB_CLI_FAILED', 'the local Supabase migration command failed; database output was withheld');
155
- let payload;
156
- try { payload = JSON.parse(result.stdout); } catch { throw failure('DB_CLI_FAILED', 'the local migration command returned unreadable JSON'); }
157
- if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.hasOwn(payload, 'error') || payload._tag === 'Error' || payload.dryRun !== dryRun || typeof payload.upToDate !== 'boolean'
158
- || !Array.isArray(payload.migrations) || !payload.migrations.every((name) => typeof name === 'string')
159
- || !Array.isArray(payload.seeds) || payload.seeds.length !== 0 || !Array.isArray(payload.roles) || payload.roles.length !== 0) {
160
- throw failure('DB_PUSH_STRAY', 'the local migration command returned an unexpected result');
161
- }
162
- const pending = payload.migrations;
163
- const expected = snapshot.names.slice(snapshot.names.length - pending.length);
164
- if (pending.length > snapshot.names.length || pending.some((name, index) => name !== expected[index]) || payload.upToDate !== (pending.length === 0)) {
165
- throw failure('DB_PUSH_STRAY', 'the local migration result is not an ordered suffix of the authored chain');
166
- }
167
- return pending;
168
- }
169
-
170
- /** Preview by default; the caller admits execute only after the existing database arming check. No history repair, reset or linked fallback. */
171
- export function migrateLocalDatabase(ctx, { execute = false, nativeTarget = null } = {}) {
172
- const snapshot = inputs(ctx, nativeTarget);
173
- assertLocalDatabase(nativeTarget, snapshot.target);
174
- const pending = push(ctx, snapshot, true);
175
- unchanged(ctx, snapshot);
176
- let applied = [];
177
- if (execute && pending.length > 0) {
178
- const fresh = push(ctx, snapshot, true);
179
- unchanged(ctx, snapshot);
180
- if (fresh.some((name, index) => name !== pending[index]) || fresh.length !== pending.length) throw failure('DB_FINAL_MISMATCH', 'the local migration plan changed before apply');
181
- applied = push(ctx, snapshot, false);
182
- unchanged(ctx, snapshot);
183
- if (applied.length !== fresh.length || applied.some((name, index) => name !== fresh[index])) {
184
- throw failure('DB_FINAL_MISMATCH', 'the applied local migrations differ from the reviewed pending plan');
185
- }
186
- if (push(ctx, snapshot, true).length !== 0) throw failure('DB_FINAL_MISMATCH', 'the local migration version history is not fully applied');
187
- unchanged(ctx, snapshot);
188
- }
189
- return {
190
- ok: true,
191
- lines: [`target local project ${snapshot.target.project}`, `pending SQL ${pending.join(', ') || 'none'}`, execute && applied.length ? `applied ${applied.length} migration(s); local version history is current` : 'nothing applied'],
192
- data: { target: 'local', pending, pushed: applied, executed: execute && applied.length > 0, done: pending.length === 0 || execute },
193
- };
194
- }
package/db/reset.mjs DELETED
@@ -1,125 +0,0 @@
1
- // reset.mjs - linked reset requires explicit custody choices, a recent verified encrypted backup and the migration arming rule.
2
- import fs from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { runVerb } from '../release/index.mjs';
6
- import { requireArmed } from './migrate.mjs';
7
- import { failure, refusal } from '../stack/errors.mjs';
8
- import { backupSession, backupArgs, backupRecipients, backupTool, encryptFile, queryRows, validateReceipt, runCli } from './backup-receipt.mjs';
9
-
10
- /** Metadata fingerprints allow verification without printing or retaining rows, object keys or auth identities in the result. */
11
- function inventory(session, schema) {
12
- const tables = queryRows(session, `select tablename from pg_catalog.pg_tables where schemaname = '${schema}' and tablename not in ('schema_migrations', 'migrations') order by tablename;`);
13
- return tables.map(({ tablename }) => {
14
- if (typeof tablename !== 'string' || !/^[a-z_][a-z0-9_]*$/u.test(tablename)) throw failure('DB_RESET_VERIFY_FAILED', 'invalid preservation table inventory');
15
- const rows = queryRows(session, `begin read only; select count(*)::text as count, md5(coalesce(string_agg(md5(row_to_json(t)::text), '' order by md5(row_to_json(t)::text)), '')) as hash from "${schema}"."${tablename}" t; commit;`);
16
- if (rows.length !== 1 || !/^\d+$/u.test(String(rows[0].count)) || !/^[a-f0-9]{32}$/u.test(rows[0].hash ?? '')) throw failure('DB_RESET_VERIFY_FAILED', 'invalid preservation fingerprint');
17
- return { table: tablename, count: String(rows[0].count), hash: rows[0].hash };
18
- });
19
- }
20
-
21
- /** age reads identity bytes on stdin or the configured identity file; neither identity nor SQL is placed in argv or diagnostics. */
22
- function decryptPart(ctx, encrypted, plaintext) {
23
- const key = ctx.env.SOPS_AGE_KEY;
24
- const identity = key ? '-' : ctx.env.SOPS_AGE_KEY_FILE;
25
- if (!identity) throw refusal('DB_RESET_IDENTITY_MISSING', '--auth/--storage keep needs SOPS_AGE_KEY or SOPS_AGE_KEY_FILE to restore the temporary encrypted part');
26
- backupTool(ctx, 'age', ['--decrypt', '--identity', identity, '-o', plaintext, encrypted], key ? { input: `${key}\n` } : {});
27
- }
28
-
29
- function preservePart(session, scratch, schema, recipients) {
30
- const before = inventory(session, schema);
31
- const plain = path.join(scratch, `${schema}.sql`);
32
- const encrypted = `${plain}.age`;
33
- runCli(session, ['db', 'dump', '--data-only', '--schema', schema, '--exclude', `${schema}.schema_migrations,${schema}.migrations`, '--file', plain, ...backupArgs(session)]);
34
- encryptFile(session.ctx, plain, encrypted, recipients);
35
- fs.rmSync(plain, { force: true });
36
- // Prove the selected identity can restore this exact part before the destructive call.
37
- decryptPart(session.ctx, encrypted, plain);
38
- fs.rmSync(plain, { force: true });
39
- return { schema, before, encrypted, plain };
40
- }
41
-
42
- function restorePart(session, part) {
43
- // Supabase retains some managed tables. Replace their data to avoid duplicate users, buckets or migration-independent metadata.
44
- decryptPart(session.ctx, part.encrypted, part.plain);
45
- const tables = part.before.map((row) => `"${part.schema}"."${row.table}"`);
46
- const source = fs.readFileSync(part.plain, 'utf8');
47
- fs.writeFileSync(part.plain, `begin;\nset constraints all deferred;\n${tables.length ? `truncate ${tables.join(', ')} cascade;\n` : ''}${source}\ncommit;\n`, { mode: 0o600 });
48
- try { runCli(session, ['db', 'query', '--file', part.plain, '--output', 'json', '--agent', 'no', ...backupArgs(session)]); }
49
- finally { fs.rmSync(part.plain, { force: true }); }
50
- if (JSON.stringify(inventory(session, part.schema)) !== JSON.stringify(part.before)) throw failure('DB_RESET_VERIFY_FAILED', `${part.schema} data differs after restore; use the full backup receipt for recovery`);
51
- }
52
-
53
- function expectedHead(session) {
54
- const dir = path.join(session.ctx.cwd, session.database.migrationsDir ?? 'supabase/migrations');
55
- let expected;
56
- try { expected = fs.readdirSync(dir).filter((name) => /^\d{14}_[A-Za-z0-9_-]+\.sql$/u.test(name)).map((name) => name.slice(0, 14)).sort(); }
57
- catch { throw refusal('DB_MIGRATIONS_MISSING', 'migration directory is not readable'); }
58
- if (!expected.length || new Set(expected).size !== expected.length) throw refusal('DB_MIGRATIONS_MISSING', 'migration versions must be present and unique');
59
- return expected;
60
- }
61
-
62
- function verifyHead(session) {
63
- const expected = expectedHead(session);
64
- const actual = queryRows(session, 'select version from supabase_migrations.schema_migrations order by version;').map((row) => row.version);
65
- if (JSON.stringify(expected) !== JSON.stringify(actual)) throw failure('DB_RESET_HEAD_MISMATCH', 'reset migration history differs from repository migration versions');
66
- return expected;
67
- }
68
-
69
- /** Dry run validates custody and prints a plan; execute preserves chosen managed data, resets, restores and verifies the exact migration head. */
70
- function resetDatabase(ctx, options = {}) {
71
- if (options.linked !== true || options.local || options.urlEnv !== undefined) throw refusal('DB_RESET_LINKED_REQUIRED', 'reset requires --linked; use npm run db:reset for a local database');
72
- if (!['keep', 'drop'].includes(options.auth) || !['keep', 'drop'].includes(options.storage)) throw refusal('DB_RESET_CHOICE_MISSING', 'explicit --auth keep|drop and --storage keep|drop are required');
73
- const session = backupSession(ctx, options);
74
- const receipt = validateReceipt(session, options.backup ? path.resolve(ctx.cwd, options.backup) : undefined);
75
- const lines = [`target: linked project ${session.target.ref}`, `backup: verified encrypted receipt from ${receipt.at}`,
76
- options.auth === 'keep' ? 'auth: encrypt all auth data tables before reset, restore in a transaction and verify' : 'auth: users and identities are deleted',
77
- options.storage === 'keep' ? 'storage: preserve and verify buckets and objects metadata' : 'storage: empty storage.objects metadata; files in the storage backend are not deleted by SQL',
78
- 'reset: supabase db reset --linked; verify migration versions against repository'];
79
- if (!options.execute) return { ok: true, lines: [...lines, 'dry run: nothing changed'], data: { executed: false, auth: options.auth, storage: options.storage, ref: session.target.ref } };
80
- requireArmed(ctx);
81
- // Refuse unsupported CLI file execution before resetting: SQL containing identity data must never travel in argv.
82
- if (options.auth === 'keep' || options.storage === 'keep') {
83
- const help = runCli(session, ['db', 'query', '--help']).text;
84
- if (!help.includes('--file')) throw refusal('DB_RESET_QUERY_FILE_UNSUPPORTED', 'the Supabase CLI must support db query --file for transactional restore');
85
- }
86
- expectedHead(session);
87
- const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-db-reset-'));
88
- const parts = [];
89
- let resetStarted = false;
90
- let complete = false;
91
- try {
92
- const keep = ['auth', 'storage'].filter((schema) => options[schema] === 'keep');
93
- const recipients = keep.length ? backupRecipients(ctx.cwd) : [];
94
- for (const schema of keep) parts.push(preservePart(session, scratch, schema, recipients));
95
- resetStarted = true;
96
- runCli(session, ['db', 'reset', '--yes', ...backupArgs(session)]);
97
- for (const part of parts) restorePart(session, part);
98
- if (options.auth === 'drop') {
99
- const rows = queryRows(session, 'select (select count(*) from auth.users)::text as users, (select count(*) from auth.identities)::text as identities;');
100
- if (rows.length !== 1 || String(rows[0].users) !== '0' || String(rows[0].identities) !== '0') throw failure('DB_RESET_VERIFY_FAILED', 'auth users or identities remain after reset');
101
- }
102
- if (options.storage === 'drop') {
103
- runCli(session, ['db', 'query', 'begin; delete from storage.objects; commit;', '--output', 'json', '--agent', 'no', ...backupArgs(session)]);
104
- const rows = queryRows(session, 'select count(*)::text as count from storage.objects;');
105
- if (rows.length !== 1 || String(rows[0].count) !== '0') throw failure('DB_RESET_VERIFY_FAILED', 'storage.objects metadata was not emptied');
106
- }
107
- const versions = verifyHead(session);
108
- complete = true;
109
- return { ok: true, lines: [...lines, 'reset completed; managed data choices and migration head verified'], data: { executed: true, auth: options.auth, storage: options.storage, ref: session.target.ref, versions } };
110
- } finally {
111
- // Preserve encrypted recovery parts after a reset/restore failure; remove every plaintext file on every exit.
112
- for (const name of fs.readdirSync(scratch)) if (!name.endsWith('.age')) fs.rmSync(path.join(scratch, name), { force: true });
113
- if (complete || !resetStarted) fs.rmSync(scratch, { recursive: true, force: true });
114
- else ctx.stderr(`DB_RESET_RECOVERY_PARTS: encrypted recovery parts retained at ${scratch}\n`);
115
- }
116
- }
117
-
118
- export function dbResetMain(argv, io = {}) {
119
- return runVerb({ label: 'starci app db reset', name: 'db reset', code: 'DB_USAGE', io, argv,
120
- spec: { boolean: ['--linked', '--execute', '--json', '--local'], value: ['--auth', '--storage', '--backup', '--url-env'] },
121
- run: (ctx, flags, positional) => {
122
- if (positional.length) throw refusal('DB_USAGE', 'reset takes flags only');
123
- return resetDatabase(ctx, { linked: flags.linked, local: flags.local, urlEnv: flags['url-env'], execute: flags.execute, auth: flags.auth, storage: flags.storage, backup: flags.backup });
124
- } });
125
- }
package/emit/db-types.mjs DELETED
@@ -1,271 +0,0 @@
1
- /**
2
- * `supabase/types/database.types.ts` (slot `app.supabase.types`): the Database types the Supabase CLI generates from an app's
3
- * local stack, written by `npm run contract:emit` (`starci app emit`) beside the be contract snapshots and judged for drift
4
- * by DB_TYPES_DRIFT (L09, scripts/hfs/rules/database.mjs) - the rule compares the committed file with the text `emitTypes()`
5
- * produces, and `dbTypesEmitter` below is that callback.
6
- *
7
- * The text is `supabase gen types typescript --local` run at the app root against the running local stack, narrowed to the
8
- * schemas `supabase/config.toml` declares under [api]; the CLI reaches Docker, so nothing here runs inside a check that has
9
- * no stack. `run` is the command runner - `(file, args, {cwd})` answering the spawnSync result `{status, stdout, stderr}` -
10
- * the default spawns the `supabase` binary with an argument array, never a shell string and never printing env; a spec's fake
11
- * keeps Docker out of the suite. Before generation, `migration list --local` must prove that the database's applied versions
12
- * equal the migration filename stamps (HFS_EMIT_DB_TYPES_STALE otherwise). Unreadable history or failed generation throws HFS_EMIT_DB_TYPES_FAILED.
13
- */
14
- import { createHash } from 'node:crypto';
15
- import { spawnSync } from 'node:child_process';
16
- import fs from 'node:fs';
17
- import path from 'node:path';
18
- import { parse as parseToml } from 'smol-toml';
19
- import { bundledPins } from '../runtime/scripts/hfs/canon-pins.mjs';
20
-
21
- /** The repository-relative path of the generated types (the path of slot `app.supabase.types`). */
22
- export const dbTypesPath = 'supabase/types/database.types.ts';
23
-
24
- /** The folder of the app's migrations, and the first line of the committed types: the digest of the migrations they were generated from. */
25
- const MIGRATIONS_DIR = 'supabase/migrations';
26
- const TYPES_STAMP = '// migrations-sha256: ';
27
-
28
- /**
29
- * The sha256 of the app's migrations, hex: every `supabase/migrations/*.sql` by name, each as name, NUL, its text with LF line ends,
30
- * NUL. The offline codegen hook of a lite app (templates/app/codegen-lite) computes the same digest and refuses types stamped with another.
31
- */
32
- function migrationsDigest(root) {
33
- const dir = path.join(root, MIGRATIONS_DIR);
34
- const hash = createHash('sha256');
35
- for (const name of (fs.existsSync(dir) ? fs.readdirSync(dir) : []).filter((file) => file.endsWith('.sql')).sort()) {
36
- hash.update(`${name}\0${fs.readFileSync(path.join(dir, name), 'utf8').replace(/\r\n/g, '\n')}\0`);
37
- }
38
- return hash.digest('hex');
39
- }
40
-
41
- /** The slot that owns the generated types; enabled by a connection declaring provider supabase. */
42
- const DB_TYPES_SLOT = 'app.supabase.types';
43
-
44
- /** The config file whose [api] schemas narrow the generation. */
45
- const CONFIG_FILE = 'supabase/config.toml';
46
- const SUPABASE_CLI = 'supabase';
47
-
48
- /** A types emit refuses unreadable history or failed generation with HFS_EMIT_DB_TYPES_FAILED, and unequal applied versions with HFS_EMIT_DB_TYPES_STALE. */
49
- class DbTypesError extends Error {
50
- constructor(message, details = {}, code = 'HFS_EMIT_DB_TYPES_FAILED') {
51
- super(`${code}: ${message}`);
52
- this.name = 'DbTypesError';
53
- this.code = code;
54
- this.details = details;
55
- }
56
- }
57
-
58
- const spawn = (file, args, options = {}) => spawnSync(file, args, { encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024, ...options });
59
-
60
- /**
61
- * The one spawn: an argument array, a hidden window, a bound buffer, never a shell. Specs inject a fake of the same shape. The
62
- * `supabase` binary is the one on PATH; a machine without it runs the CLI at the canon pin through npx (the pin of knowledge/hfs/canon-pins.yaml).
63
- */
64
- const withNpxFallback = (run, platform) => (file, args, options = {}) => {
65
- const first = run(file, args, options);
66
- if (file !== SUPABASE_CLI || first.error?.code !== 'ENOENT') return first;
67
- const version = bundledPins()[SUPABASE_CLI]?.version;
68
- if (!version) return first;
69
- return platform === 'win32'
70
- ? run('cmd.exe', ['/d', '/s', '/c', 'npx', '--yes', `${SUPABASE_CLI}@${version}`, ...args], options)
71
- : run('npx', ['--yes', `${SUPABASE_CLI}@${version}`, ...args], options);
72
- };
73
-
74
- /** The first meaningful line of a failure output, for the error a human acts on. */
75
- const firstLine = (text) => String(text ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0];
76
-
77
- const START_TAIL_LINES = 12;
78
- const START_LINE_CHARS = 300;
79
- /** The last lines (bounded) of a failed `supabase start`: its cause (42501 permission denied to alter role) sits after the first progress lines, so the first line alone hid it. */
80
- const startDetail = (started) => {
81
- const text = [started?.stderr, started?.stdout, started?.error?.message ?? started?.error].map((part) => String(part ?? '')).find((part) => part.trim()) ?? '';
82
- const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => (line.length > START_LINE_CHARS ? `${line.slice(0, START_LINE_CHARS)}...` : line));
83
- return lines.length ? lines.slice(-START_TAIL_LINES).join(' | ') : 'no output';
84
- };
85
-
86
- /**
87
- * The schemas `supabase/config.toml` exposes under [api] (`[]` when the file or the list is absent - the CLI then reads its
88
- * own defaults). A config that does not parse is a refusal: L08 reads the same file and the emit must not guess.
89
- */
90
- function apiSchemas(root) {
91
- const file = path.join(root, CONFIG_FILE);
92
- if (!fs.existsSync(file)) return [];
93
- let toml;
94
- try { toml = parseToml(fs.readFileSync(file, 'utf8')); } catch (error) {
95
- throw new DbTypesError(`${CONFIG_FILE} is not readable valid TOML (${firstLine(error?.message ?? error) ?? 'unreadable'}); the types scope comes from its [api] schemas`, { file: CONFIG_FILE });
96
- }
97
- return (toml?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
98
- }
99
-
100
- /** CLI JSON and table rows use the remote field for the database history even with --local; local only describes files. */
101
- function historyVersion(value) {
102
- if (value == null || value === '') return null;
103
- if (typeof value !== 'string' || !/^\d+$/.test(value)) {
104
- throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
105
- }
106
- return value;
107
- }
108
-
109
- /** JSON must explicitly carry a migrations array; an empty array proves an empty database history. */
110
- function jsonAppliedVersions(text) {
111
- let history;
112
- try { history = JSON.parse(text); } catch {
113
- throw new DbTypesError('the local migration history contains unreadable JSON', { file: dbTypesPath });
114
- }
115
- if (!Array.isArray(history?.migrations)) {
116
- throw new DbTypesError('the local migration history has no migrations array', { file: dbTypesPath });
117
- }
118
- const versions = new Set();
119
- for (const row of history.migrations) {
120
- if (!row || !Object.hasOwn(row, 'local') || !Object.hasOwn(row, 'remote')) {
121
- throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
122
- }
123
- historyVersion(row.local);
124
- const applied = historyVersion(row.remote);
125
- if (applied) versions.add(applied);
126
- }
127
- return versions;
128
- }
129
-
130
- /** Optional outer borders do not add columns; pretty output may wrap each table cell in Markdown backticks. */
131
- const historyCells = (line, bordered) => {
132
- const row = bordered ? line.trim().replace(/^\|/, '').replace(/\|$/, '') : line;
133
- return row.split('|').map((cell) => cell.trim().replace(/^`(.*)`$/, '$1'));
134
- };
135
-
136
- /** A recognized header, including a header without rows, proves a table history; malformed rows still refuse. */
137
- function tableAppliedVersions(lines, header) {
138
- const versions = new Set();
139
- const bordered = /^\s*\|/.test(lines[header]);
140
- const width = historyCells(lines[header], bordered).length;
141
- for (const line of lines.slice(header + 1)) {
142
- if (/^[\s|+-]+$/.test(line)) continue;
143
- if (!line.includes('|')) continue;
144
- const cells = historyCells(line, bordered);
145
- if (cells.length !== width) {
146
- throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
147
- }
148
- historyVersion(cells[0]);
149
- const applied = historyVersion(cells[1]);
150
- if (applied) versions.add(applied);
151
- }
152
- return versions;
153
- }
154
-
155
- /** Read either CLI stream, stripping terminal colors, and require an explicit JSON history or Local | Remote [| Time] header. */
156
- function appliedVersions(result) {
157
- for (const output of [result.stdout, result.stderr]) {
158
- const text = String(output ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replaceAll('\u2502', '|').trim();
159
- const lines = text.split(/\r?\n/);
160
- const json = lines.findIndex((line) => line.trimStart().startsWith('{'));
161
- if (json >= 0) return jsonAppliedVersions(lines.slice(json).join('\n'));
162
- const header = lines.findIndex((line) => /^\s*\|?\s*Local\s*\|\s*Remote\s*(?:\|\s*Time(?:\s*\(UTC\))?\s*)?\|?\s*$/i.test(line));
163
- if (header >= 0) return tableAppliedVersions(lines, header);
164
- }
165
- throw new DbTypesError('the local migration history is unreadable; expected migrations JSON or the Local | Remote migration list', { file: dbTypesPath });
166
- }
167
-
168
- /** Every applied local version must equal a migration filename stamp, including gaps and extra versions even when the heads match. */
169
- function requireCurrentDatabase(root, run) {
170
- const dir = path.join(root, MIGRATIONS_DIR);
171
- const expected = new Set();
172
- for (const name of (fs.existsSync(dir) ? fs.readdirSync(dir) : []).filter((file) => file.endsWith('.sql'))) {
173
- const stamp = /^(\d+)(?:_|\.sql$)/.exec(name)?.[1];
174
- if (!stamp) throw new DbTypesError(`${MIGRATIONS_DIR} contains a SQL file without a migration version`, { file: dbTypesPath });
175
- expected.add(stamp);
176
- }
177
- let result;
178
- try { result = run(SUPABASE_CLI, ['migration', 'list', '--local'], { cwd: root }); } catch {
179
- throw new DbTypesError('supabase migration list --local could not run; the local migration history must be readable before emitting types', { file: dbTypesPath });
180
- }
181
- if (result?.error || result?.signal || result?.status !== 0) {
182
- throw new DbTypesError('supabase migration list --local failed; the local migration history must be readable before emitting types', { file: dbTypesPath });
183
- }
184
- const applied = appliedVersions(result);
185
- if (applied.size !== expected.size || [...expected].some((version) => !applied.has(version))) {
186
- const head = (versions) => [...versions].sort().at(-1) ?? 'none';
187
- throw new DbTypesError(`the local database is at ${head(applied)}, the migrations folder at ${head(expected)}; inspect "starci app db plan --local", preview "starci app db migrate --local", apply the reviewed local plan with --execute and STARCI_DB_ARMED=1, then run "starci app emit"`, { file: dbTypesPath }, 'HFS_EMIT_DB_TYPES_STALE');
188
- }
189
- }
190
-
191
- /**
192
- * The freshly generated text of `dbTypesPath` for the app at `root`: `supabase gen types typescript --local` (the running
193
- * local stack), narrowed to the [api] schemas of supabase/config.toml. A failing CLI, a spawn error and an empty answer all
194
- * raise DbTypesError; the first stderr line names the cause.
195
- */
196
- function emitDbTypesWithRunner({ root, run }) {
197
- const schemas = apiSchemas(root);
198
- requireCurrentDatabase(root, run);
199
- const args = ['gen', 'types', 'typescript', '--local', ...(schemas.length ? ['--schema', schemas.join(',')] : [])];
200
- const command = `${SUPABASE_CLI} ${args.join(' ')}`;
201
- let result;
202
- try {
203
- result = run(SUPABASE_CLI, args, { cwd: root });
204
- } catch (error) {
205
- throw new DbTypesError(`\`${command}\` could not run: ${firstLine(error?.message ?? error) ?? 'unknown failure'}; the local Supabase stack must be up (supabase start)`, { file: dbTypesPath });
206
- }
207
- const stdout = String(result?.stdout ?? '');
208
- const stderr = String(result?.stderr ?? '');
209
- if (result?.error || result?.status !== 0 || !stdout.trim()) {
210
- const cause = firstLine(stderr) ?? firstLine(result?.error?.message ?? result?.error) ?? 'no output';
211
- throw new DbTypesError(`\`${command}\` failed${result?.status !== undefined && result?.status !== null ? ` (exit ${result.status})` : ''}: ${cause}; the local Supabase stack must be up (supabase start)`, { file: dbTypesPath, stderr });
212
- }
213
- // The CLI pads blank lines with spaces; the committed file carries none (git diff --check refuses trailing whitespace).
214
- return `${TYPES_STAMP}${migrationsDigest(root)}\n${stdout.replace(/[ \t]+$/gm, '')}`;
215
- }
216
-
217
- export function emitDbTypes({ root, run = spawn, platform = process.platform } = {}) {
218
- return emitDbTypesWithRunner({ root, run: withNpxFallback(run, platform) });
219
- }
220
-
221
- /**
222
- * The types of an app that has no stack yet (a fresh scaffold): start the app's own local stack (its project id and ports from
223
- * supabase/config.toml; every migration of supabase/migrations is applied on start), emit the types, and stop the stack again when
224
- * this call started it. The slow, Docker-reaching path `starci app scaffold --edition lite` takes; `emitDbTypes` alone is the fast one for a stack that is up.
225
- */
226
- export function generateDbTypes({ root, run = spawn, platform = process.platform } = {}) {
227
- const execute = withNpxFallback(run, platform);
228
- const started = execute(SUPABASE_CLI, ['start', '-x', 'studio,mailpit,logflare,vector,edge-runtime,imgproxy,supavisor'], { cwd: root });
229
- const already = /already (?:running|started)/i.test(`${started?.stderr ?? ''}${started?.stdout ?? ''}`);
230
- if (started?.error || (started?.status !== 0 && !already)) {
231
- throw new DbTypesError(`\`${SUPABASE_CLI} start\` failed${started?.status != null ? ` (exit ${started.status})` : ''}: ${startDetail(started)} (the last ${START_TAIL_LINES} output lines at most; with no cause among them, Docker must be running)`, { file: dbTypesPath });
232
- }
233
- try {
234
- return emitDbTypesWithRunner({ root, run: execute });
235
- } finally {
236
- if (!already) execute(SUPABASE_CLI, ['stop', '--no-backup'], { cwd: root });
237
- }
238
- }
239
-
240
- /**
241
- * Writes `emitDbTypes` to `<root>/<dbTypesPath>`, creating `supabase/types/` when needed. Answers `{ path, changed }`:
242
- * `changed` is false when the committed text already equals the generated one, so a re-run is a no-op on disk.
243
- */
244
- export function writeDbTypes({ root, run } = {}) {
245
- const text = emitDbTypes({ root, run });
246
- const target = path.join(root, dbTypesPath);
247
- const committed = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
248
- const changed = committed !== text;
249
- if (changed) {
250
- fs.mkdirSync(path.dirname(target), { recursive: true });
251
- fs.writeFileSync(target, text);
252
- }
253
- return { path: dbTypesPath, changed };
254
- }
255
-
256
- /**
257
- * Whether the slot `app.supabase.types` is enabled for the app `resolver` views (a connection of the app declares
258
- * provider: supabase). Read through the slot view, never through a path check: a side-scope resolver holds no app slots
259
- * and answers false, exactly like an app without a supabase connection.
260
- */
261
- export function appHasDbTypes(resolver) {
262
- const slot = typeof resolver?.slot === 'function' ? resolver.slot(DB_TYPES_SLOT) : null;
263
- return slot != null && typeof resolver?.slotEnabled === 'function' && resolver.slotEnabled(slot) === true;
264
- }
265
-
266
- /**
267
- * The `emitTypes` callback checkDatabase (L09 DB_TYPES_DRIFT) expects: `({repoRoot, declaration}) -> text`, `repoRoot`
268
- * being the app root under check; the bound `root` is the fallback for a caller that already knows it. The declared
269
- * `supabase` block plays no part: the schemas come from supabase/config.toml on disk.
270
- */
271
- export const dbTypesEmitter = ({ root = null, run } = {}) => async ({ repoRoot } = {}) => emitDbTypes({ root: repoRoot ?? root, run });
@@ -1,5 +0,0 @@
1
- // database-dump.mjs - schema dump for an app's local or linked Supabase database.
2
- import { supabaseCall } from './lib.mjs';
3
-
4
- /** The spawn result of supabase db dump; arguments and runner options remain with the caller. */
5
- export function databaseDump(...args) { return supabaseCall(['db', 'dump'], ...args); }
@@ -1,5 +0,0 @@
1
- // database-push.mjs - pending SQL migration push for an app's selected Supabase database.
2
- import { supabaseCall } from './lib.mjs';
3
-
4
- /** The spawn result of supabase db push; target, preview and runner options remain with the owning caller. */
5
- export function databasePush(...args) { return supabaseCall(['db', 'push'], ...args); }
@@ -1,5 +0,0 @@
1
- // database-query.mjs - database query for an app's local or linked Supabase database.
2
- import { supabaseCall } from './lib.mjs';
3
-
4
- /** The spawn result of supabase db query; arguments and runner options remain with the caller. */
5
- export function databaseQuery(...args) { return supabaseCall(['db', 'query'], ...args); }