@supatype/cli 0.3.1 → 0.3.3

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 (148) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +168 -158
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +77 -0
  5. package/dist/api-config-cache.d.ts +20 -0
  6. package/dist/api-config-cache.d.ts.map +1 -1
  7. package/dist/api-config-cache.js +27 -0
  8. package/dist/api-config-cache.js.map +1 -1
  9. package/dist/augmentation-generator.d.ts.map +1 -1
  10. package/dist/augmentation-generator.js +126 -26
  11. package/dist/augmentation-generator.js.map +1 -1
  12. package/dist/cli-version-embedded.js +1 -1
  13. package/dist/client-generator.d.ts +3 -0
  14. package/dist/client-generator.d.ts.map +1 -0
  15. package/dist/client-generator.js +108 -0
  16. package/dist/client-generator.js.map +1 -0
  17. package/dist/commands/functions.d.ts.map +1 -1
  18. package/dist/commands/functions.js +2 -2
  19. package/dist/commands/functions.js.map +1 -1
  20. package/dist/commands/generate.d.ts.map +1 -1
  21. package/dist/commands/generate.js +3 -3
  22. package/dist/commands/generate.js.map +1 -1
  23. package/dist/commands/init.d.ts.map +1 -1
  24. package/dist/commands/init.js +36 -29
  25. package/dist/commands/init.js.map +1 -1
  26. package/dist/commands/push.d.ts.map +1 -1
  27. package/dist/commands/push.js +14 -13
  28. package/dist/commands/push.js.map +1 -1
  29. package/dist/commands/seed.d.ts +27 -1
  30. package/dist/commands/seed.d.ts.map +1 -1
  31. package/dist/commands/seed.js +206 -33
  32. package/dist/commands/seed.js.map +1 -1
  33. package/dist/commands/update.d.ts.map +1 -1
  34. package/dist/commands/update.js +19 -1
  35. package/dist/commands/update.js.map +1 -1
  36. package/dist/db-port.d.ts +36 -0
  37. package/dist/db-port.d.ts.map +1 -0
  38. package/dist/db-port.js +90 -0
  39. package/dist/db-port.js.map +1 -0
  40. package/dist/dev-compose.d.ts.map +1 -1
  41. package/dist/dev-compose.js +108 -13
  42. package/dist/dev-compose.js.map +1 -1
  43. package/dist/engine-client.d.ts +1 -0
  44. package/dist/engine-client.d.ts.map +1 -1
  45. package/dist/engine-client.js +47 -2
  46. package/dist/engine-client.js.map +1 -1
  47. package/dist/engine-floor.d.ts +27 -4
  48. package/dist/engine-floor.d.ts.map +1 -1
  49. package/dist/engine-floor.js +90 -9
  50. package/dist/engine-floor.js.map +1 -1
  51. package/dist/functions-context-refresh.d.ts +4 -0
  52. package/dist/functions-context-refresh.d.ts.map +1 -0
  53. package/dist/functions-context-refresh.js +26 -0
  54. package/dist/functions-context-refresh.js.map +1 -0
  55. package/dist/functions-deno-types.d.ts +9 -0
  56. package/dist/functions-deno-types.d.ts.map +1 -1
  57. package/dist/functions-deno-types.js +21 -1
  58. package/dist/functions-deno-types.js.map +1 -1
  59. package/dist/gitignore.d.ts +3 -1
  60. package/dist/gitignore.d.ts.map +1 -1
  61. package/dist/gitignore.js +35 -6
  62. package/dist/gitignore.js.map +1 -1
  63. package/dist/hooks-generator.d.ts.map +1 -1
  64. package/dist/hooks-generator.js +18 -3
  65. package/dist/hooks-generator.js.map +1 -1
  66. package/dist/schema-ast-v2.d.ts.map +1 -1
  67. package/dist/schema-ast-v2.js +17 -8
  68. package/dist/schema-ast-v2.js.map +1 -1
  69. package/dist/seed-connection.d.ts +60 -0
  70. package/dist/seed-connection.d.ts.map +1 -0
  71. package/dist/seed-connection.js +90 -0
  72. package/dist/seed-connection.js.map +1 -0
  73. package/dist/seed-ir.d.ts +66 -0
  74. package/dist/seed-ir.d.ts.map +1 -0
  75. package/dist/seed-ir.js +265 -0
  76. package/dist/seed-ir.js.map +1 -0
  77. package/dist/seed-runner.d.ts +44 -0
  78. package/dist/seed-runner.d.ts.map +1 -0
  79. package/dist/seed-runner.js +83 -0
  80. package/dist/seed-runner.js.map +1 -0
  81. package/dist/seed.d.ts +345 -0
  82. package/dist/seed.d.ts.map +1 -1
  83. package/dist/seed.js +138 -0
  84. package/dist/seed.js.map +1 -1
  85. package/dist/self-host-compose.d.ts +10 -2
  86. package/dist/self-host-compose.d.ts.map +1 -1
  87. package/dist/self-host-compose.js +73 -6
  88. package/dist/self-host-compose.js.map +1 -1
  89. package/dist/strict.d.ts +29 -0
  90. package/dist/strict.d.ts.map +1 -0
  91. package/dist/strict.js +37 -0
  92. package/dist/strict.js.map +1 -0
  93. package/dist/studio-dev-server.d.ts +12 -2
  94. package/dist/studio-dev-server.d.ts.map +1 -1
  95. package/dist/studio-dev-server.js +12 -2
  96. package/dist/studio-dev-server.js.map +1 -1
  97. package/dist/type-extractor.d.ts.map +1 -1
  98. package/dist/type-extractor.js +81 -2
  99. package/dist/type-extractor.js.map +1 -1
  100. package/dist/type-generation.d.ts +43 -0
  101. package/dist/type-generation.d.ts.map +1 -1
  102. package/dist/type-generation.js +93 -2
  103. package/dist/type-generation.js.map +1 -1
  104. package/package.json +2 -2
  105. package/src/api-config-cache.ts +30 -0
  106. package/src/augmentation-generator.ts +142 -26
  107. package/src/cli-version-embedded.ts +1 -1
  108. package/src/client-generator.ts +122 -0
  109. package/src/commands/functions.ts +5 -2
  110. package/src/commands/generate.ts +7 -3
  111. package/src/commands/init.ts +41 -31
  112. package/src/commands/push.ts +14 -15
  113. package/src/commands/seed.ts +294 -44
  114. package/src/commands/update.ts +20 -1
  115. package/src/db-port.ts +99 -0
  116. package/src/dev-compose.ts +116 -16
  117. package/src/engine-client.ts +52 -3
  118. package/src/engine-floor.ts +100 -9
  119. package/src/functions-context-refresh.ts +25 -0
  120. package/src/functions-deno-types.ts +24 -1
  121. package/src/gitignore.ts +38 -10
  122. package/src/hooks-generator.ts +21 -6
  123. package/src/schema-ast-v2.ts +17 -8
  124. package/src/seed-connection.ts +146 -0
  125. package/src/seed-ir.ts +311 -0
  126. package/src/seed-runner.ts +109 -0
  127. package/src/seed.ts +408 -0
  128. package/src/self-host-compose.ts +74 -6
  129. package/src/strict.ts +40 -0
  130. package/src/studio-dev-server.ts +12 -2
  131. package/src/type-extractor.ts +109 -2
  132. package/src/type-generation.ts +111 -2
  133. package/tests/access-composition.test.ts +123 -0
  134. package/tests/ast-derived-outputs.test.ts +92 -0
  135. package/tests/augmentation-generator.test.ts +273 -7
  136. package/tests/client-generator.test.ts +103 -0
  137. package/tests/db-port.test.ts +128 -0
  138. package/tests/engine-floor.test.ts +93 -0
  139. package/tests/external-database-compose.test.ts +8 -1
  140. package/tests/fixtures/seed_golden_ir.json +186 -0
  141. package/tests/functions-context-refresh.test.ts +86 -0
  142. package/tests/gitignore-secrets.test.ts +90 -0
  143. package/tests/runtime-contract.test.ts +146 -8
  144. package/tests/seed-connection.test.ts +158 -0
  145. package/tests/seed-ir.test.ts +436 -0
  146. package/tests/strict.test.ts +78 -0
  147. package/tests/type-extractor.test.ts +34 -0
  148. package/tsconfig.tsbuildinfo +1 -1
@@ -14,7 +14,10 @@ import {
14
14
  ensureComponentBinaries,
15
15
  reportComponentBinaryFailures,
16
16
  } from "../ensure-component-binaries.js"
17
- import { ensureFunctionsDenoTypes } from "../functions-deno-types.js"
17
+ import {
18
+ ensureFunctionsDenoTypes,
19
+ sharedFunctionsReadmeSource,
20
+ } from "../functions-deno-types.js"
18
21
  import { mergeSupatypePackageJson } from "../init-package-json.js"
19
22
  import { cliPackageVersion } from "../cli-package-version.js"
20
23
  import {
@@ -23,6 +26,7 @@ import {
23
26
  type InitDependencyVersions,
24
27
  } from "../init-dependency-versions.js"
25
28
  import { detectProjectSetup, type DetectedProjectSetup } from "../init-project-detect.js"
29
+ import { SUPATYPE_GITIGNORE_PATHS } from "../gitignore.js"
26
30
 
27
31
  // ─── Options model ─────────────────────────────────────────────────────────--
28
32
 
@@ -789,7 +793,7 @@ function scaffoldHelloFunction(
789
793
  ): void {
790
794
  write("functions/hello/index.ts", helloFunctionTemplate())
791
795
  if (!existsSync(join(dir, "functions/_shared/README.md"))) {
792
- write("functions/_shared/README.md", sharedFunctionsReadme())
796
+ write("functions/_shared/README.md", sharedFunctionsReadmeSource())
793
797
  }
794
798
  if (!existsSync(join(dir, "functions/.env.local"))) {
795
799
  write("functions/.env.local", functionsEnvLocalTemplate())
@@ -808,7 +812,12 @@ function packageJsonTemplate(opts: ScaffoldOptions, deps: InitDependencyVersions
808
812
  const scripts: string[] = [
809
813
  ` "dev": "supatype dev"`,
810
814
  ` "push": "supatype push"`,
811
- ` "seed": "tsx seed.ts"`,
815
+ ` "seed": "supatype seed"`,
816
+ // The generated seed builder is gitignored, so a fresh clone has to produce it before
817
+ // anything can import it. `generate` is fully offline, which is the condition that makes
818
+ // gitignoring generated code reasonable rather than a trap; without this hook it would be
819
+ // one, and the failure would be a missing module rather than anything naming the remedy.
820
+ ` "prepare": "supatype generate"`,
812
821
  ]
813
822
  if (opts.app.viteDevUrl) {
814
823
  scripts.push(` "vite": "vite"`)
@@ -1343,28 +1352,33 @@ S3_SECRET_KEY=`
1343
1352
  }
1344
1353
 
1345
1354
  function seedTemplate(projectName: string): string {
1346
- return `import { sql } from "@supatype/cli/seed"
1347
-
1348
- // Connect using DATABASE_URL from environment
1349
- const db = sql(
1350
- process.env["DATABASE_URL"] ??
1351
- "postgresql://supatype_admin:postgres@localhost:5432/${projectName}",
1352
- )
1353
-
1354
- async function seed() {
1355
- console.log("Seeding ${projectName}...")
1356
-
1357
- // TODO: insert seed data
1358
- // await db\`INSERT INTO profile (id, display_name) VALUES ('...', 'Admin')\`
1355
+ return `import type { SeedContext } from "@supatype/cli/seed"
1356
+
1357
+ /**
1358
+ * Seed data for ${projectName}. Run it with \`supatype seed\`.
1359
+ *
1360
+ * The calls below are collected rather than executed. They are ordered by their foreign keys,
1361
+ * batched into as few statements as they allow, and applied in one transaction: so there is no
1362
+ * connection to open, no ordering to work out by hand, and a failure leaves nothing behind.
1363
+ *
1364
+ * Usually written as \`{ db, expr, log }\`; taken whole here so the example can stay commented.
1365
+ */
1366
+ export default async function seed(ctx: SeedContext) {
1367
+ ctx.log("seeding ${projectName}")
1368
+
1369
+ // \`upsert\` is the one to reach for. Seeds get re-run, and naming the row by a key it already
1370
+ // has is what makes that safe, without an id pasted in from somewhere.
1371
+ //
1372
+ // ctx.db.profile.upsert({
1373
+ // where: { id: "00000000-0000-4000-8000-000000000001" },
1374
+ // data: { display_name: "Admin" },
1375
+ // })
1359
1376
 
1360
- await db.end()
1361
- console.log("Done.")
1377
+ // Times the database works out, so a fixture calendar is still ahead of whenever an
1378
+ // environment is seeded rather than fixed to whenever this file was written:
1379
+ //
1380
+ // starts_at: ctx.expr.startOf("day", { days: 30, hours: 9 })
1362
1381
  }
1363
-
1364
- seed().catch((e) => {
1365
- console.error(e)
1366
- process.exit(1)
1367
- })
1368
1382
  `
1369
1383
  }
1370
1384
 
@@ -1396,22 +1410,18 @@ export default async function handler(req: Request, ctx: FunctionContext): Promi
1396
1410
  `
1397
1411
  }
1398
1412
 
1399
- function sharedFunctionsReadme(): string {
1400
- return "# Shared Code\n\nFiles in `_shared/` are available to all functions via relative imports.\nThis directory is not deployed as a function.\n\nExample: `import { sendEmail } from '../_shared/email.ts'`\n"
1401
- }
1402
1413
 
1403
1414
  function functionsEnvLocalTemplate(): string {
1404
1415
  return "# Local environment variables for edge functions\n# These are NOT committed to git\n# Set production env vars via: npx supatype functions env set KEY=value\n"
1405
1416
  }
1406
1417
 
1407
1418
  function gitignoreTemplate(): string {
1408
- return `.env
1419
+ // Supatype's own paths come from `SUPATYPE_GITIGNORE_PATHS` rather than being written out again
1420
+ // here. This template and that list were two copies of the same thing and had already diverged;
1421
+ // the copy that drifts is found when a key reaches a public repository.
1422
+ return `${SUPATYPE_GITIGNORE_PATHS.join("\n")}
1409
1423
  node_modules/
1410
1424
  dist/
1411
- .supatype/
1412
- supatype.local.config.ts
1413
- supatype.local.config.js
1414
- supatype.local.config.mjs
1415
1425
  # Generated by supatype push (legacy paths, prefer output.types in config)
1416
1426
  src/types/supatype.d.ts
1417
1427
  src/lib/supatype.ts
@@ -33,7 +33,8 @@ import type { ExtractedSchemaAstV2 } from "../schema-ast-v2.js"
33
33
  import { ensureFirstAdminUser } from "./admin.js"
34
34
  import { withAdminRoles } from "../studio-admin-roles.js"
35
35
  import { restoreSystemRelationTargets } from "../restore-system-relation-targets.js"
36
- import { freeTierCacheNote, seedApiConfigCache } from "../api-config-cache.js"
36
+ import { cacheSeedingNotes, freeTierCacheNote } from "../api-config-cache.js"
37
+ import { refreshFunctionsContext } from "../functions-context-refresh.js"
37
38
  import type { SupatypeProjectConfig } from "../project-config.js"
38
39
  import {
39
40
  resolveTarget,
@@ -265,20 +266,7 @@ async function deployHooksToTarget(
265
266
  * Never an error, and never a rewrite of an entry that already exists. See `seedApiConfigCache`.
266
267
  */
267
268
  function reportCacheSeeding(cwd: string, ast: unknown): void {
268
- const result = seedApiConfigCache(cwd, ast)
269
- if (result === null) return
270
-
271
- if (result.seeded.length > 0) {
272
- info(`Server cache enabled for ${result.seeded.join(", ")} in .supatype/api-config.json`)
273
- }
274
- if (result.ttlIsOff) {
275
- // A note rather than a fix. Zero is an off switch someone may have chosen, and a push that
276
- // turned caching on project-wide would be overriding a decision rather than filling in a blank.
277
- info(
278
- `${result.declared.length} table(s) declare a cache, but cache_max_ttl is 0 — nothing is ` +
279
- `cached until it is set, under API → REST → Settings or in .supatype/api-config.json.`,
280
- )
281
- }
269
+ for (const note of cacheSeedingNotes(cwd, ast)) info(note)
282
270
  }
283
271
 
284
272
  /** The generated adapter, flattened to the name handlers were rewritten to import. */
@@ -294,6 +282,17 @@ async function generateTypesLocal(ast: unknown, config: SupatypeProjectConfig):
294
282
  const cwd = process.cwd()
295
283
  const hooksPath = writeHooksModule(cwd, hooksPathFromProject(config, cwd), ast)
296
284
  if (hooksPath !== null) info(`Hook handler types written to ${hooksPath}`)
285
+
286
+ // `_shared/context.ts` too, for the same reason the hook module is here.
287
+ //
288
+ // It was written by `init` and `functions new` and by nothing else, so a project that predates
289
+ // the two-argument handler contract never received it: its functions had no `FunctionContext` to
290
+ // import, while the file itself claims "Regenerated by the CLI. Edits are overwritten." That
291
+ // claim was only true if you scaffolded again. `tests/integration` had four functions and no
292
+ // context module at all.
293
+ if (refreshFunctionsContext(cwd, config)) {
294
+ info("Function context type written to functions/_shared/context.ts")
295
+ }
297
296
  // The server watches this file, so a changed hook takes effect without a restart.
298
297
  if (syncManifestHooks(cwd, ast)) info("Hook map written to .supatype/manifest.json")
299
298
  // The row cache is configured at postmaster start, so this is the one cache setting a push
@@ -1,15 +1,33 @@
1
+ /**
2
+ * `supatype seed`: find the seed files, collect what they describe, hand it to the engine.
3
+ *
4
+ * Discovery and the Cloud guard are what this command already did. The rest is new only in
5
+ * that it collects rather than executes: no connection handling, no SQL, no transaction, no
6
+ * error mapping, because those are the engine's now and six more language runners should not
7
+ * each reimplement them and disagree.
8
+ *
9
+ * Both module shapes are supported. A default-exported function is the new contract and is
10
+ * handed a context; a module with no default export is the old one, which does its work on
11
+ * import and is told so by name. Nothing here removes the old shape.
12
+ */
13
+
1
14
  import type { Command } from "commander"
2
15
  import { existsSync, readdirSync } from "node:fs"
3
- import { join, resolve } from "node:path"
4
- import { isLinkedToCloudProject } from "../binary-cache.js"
5
- import { loadConfig } from "../config.js"
6
- import { projectRootFromConfig } from "../project-config.js"
7
- import { runTsFile } from "../tsx-runner.js"
8
- import { error, info } from "../ui/messages.js"
16
+ import { join, relative, resolve, sep } from "node:path"
17
+ import { isLinkedToCloudProject, pinnedVersion } from "../binary-cache.js"
18
+ import { loadConfig, loadSchemaAst, type SupatypeConfig } from "../config.js"
19
+ import { engineRequest, ensureEngine } from "../engine-client.js"
20
+ import { seedUnsupportedByPinnedEngine } from "../engine-floor.js"
21
+ import { pgSchema, projectRootFromConfig, schemaPathFromProject } from "../project-config.js"
22
+ import { DsnNotFound, redact, resolveSeedDsn } from "../seed-connection.js"
23
+ import { collectSeed, SeedCollectionFailed } from "../seed-runner.js"
24
+ import { seedBuilderPathWithDefaults } from "../type-generation.js"
25
+ import type { SeedConfig, SeedIr } from "../seed.js"
26
+ import { error, info, success, warn } from "../ui/messages.js"
9
27
 
10
28
  const SEED_EXT = /\.(ts|mts|tsx)$/
11
29
 
12
- /** Seed entries under `seeds/`, sorted by filename (Phase 10.6 C19). */
30
+ /** Seed entries under `seeds/`, sorted by filename. */
13
31
  export function discoverSeedsDir(cwd: string, seedsDir: string): string[] {
14
32
  if (!existsSync(seedsDir)) return []
15
33
  const names = readdirSync(seedsDir).filter((n) => SEED_EXT.test(n))
@@ -17,6 +35,21 @@ export function discoverSeedsDir(cwd: string, seedsDir: string): string[] {
17
35
  return names.map((n) => join(seedsDir, n))
18
36
  }
19
37
 
38
+ /** One file's worth of collected IR, with what that file asked for. */
39
+ interface Prepared {
40
+ file: string
41
+ document: SeedIr
42
+ config: SeedConfig
43
+ }
44
+
45
+ export interface SeedFlags {
46
+ force: boolean
47
+ atomic: boolean
48
+ status: boolean
49
+ connection?: string
50
+ environment?: string
51
+ }
52
+
20
53
  export function registerSeed(program: Command): void {
21
54
  program
22
55
  .command("seed [file]")
@@ -28,42 +61,259 @@ export function registerSeed(program: Command): void {
28
61
  "Allow running when the project is linked to Supatype Cloud (dangerous)",
29
62
  false,
30
63
  )
31
- .action(async (file: string | undefined, opts: { force: boolean }) => {
32
- const cwd = process.cwd()
33
- const config = loadConfig(cwd)
34
- if (isLinkedToCloudProject(cwd, config) && !opts.force) {
35
- error(
36
- "This project is linked to Supatype Cloud. Refusing to run seeds locally.\n" +
37
- " Pass --force only if you intend to target this linked project (advanced).",
38
- )
39
- process.exit(1)
40
- }
41
-
42
- const root = projectRootFromConfig(config, cwd)
43
- const seedsDir = join(root, "seeds")
44
-
45
- let paths: string[]
46
- if (file !== undefined && file.trim() !== "") {
47
- paths = [resolve(cwd, file)]
48
- } else {
49
- paths = discoverSeedsDir(cwd, seedsDir)
50
- if (paths.length === 0) {
51
- paths = [resolve(root, "seed.ts")]
52
- }
53
- }
54
-
55
- const missing = paths.filter((p) => !existsSync(p))
56
- if (missing.length > 0) {
57
- error(`Seed file(s) not found:\n ${missing.join("\n ")}`)
58
- process.exit(1)
59
- }
60
-
61
- for (const seedFile of paths) {
62
- info(`Running ${seedFile}...`)
63
- const result = runTsFile(seedFile, { cwd, stdio: "inherit" })
64
- if (result.exitCode !== 0) {
65
- process.exit(result.exitCode)
66
- }
67
- }
64
+ .option("--atomic", "Run every seed file in one transaction", false)
65
+ .option("--status", "Show what has been applied, and what has changed since", false)
66
+ .option("--connection <dsn>", "Database to seed, instead of the resolved one")
67
+ .option("--environment <name>", "Environment name, for an `environment` condition")
68
+ .action(async (file: string | undefined, opts: SeedFlags) => {
69
+ const code = await runSeed(file, opts)
70
+ if (code !== 0) process.exit(code)
71
+ })
72
+ }
73
+
74
+ /**
75
+ * Separated from the action so the exit code is a value rather than a side effect.
76
+ *
77
+ * `0` applied, `1` a seed failed, `2` the run could not start. The split is what lets CI
78
+ * retry one and not the other.
79
+ */
80
+ export async function runSeed(file: string | undefined, opts: SeedFlags): Promise<number> {
81
+ const cwd = process.cwd()
82
+ const config = loadConfig(cwd)
83
+
84
+ if (isLinkedToCloudProject(cwd, config) && !opts.force) {
85
+ error(
86
+ "This project is linked to Supatype Cloud. Refusing to run seeds locally.\n" +
87
+ " Pass --force only if you intend to target this linked project (advanced).",
88
+ )
89
+ return 2
90
+ }
91
+
92
+ // Before the connection is resolved and before a builder is looked for: an engine that cannot
93
+ // seed makes both of those pointless, and the pin is the cheapest thing here to read.
94
+ const tooOld = seedUnsupportedByPinnedEngine(pinnedVersion("engine", config))
95
+ if (tooOld !== undefined) {
96
+ error(tooOld)
97
+ return 2
98
+ }
99
+
100
+ let dsn: string
101
+ try {
102
+ dsn = resolveSeedDsn(cwd, config, {
103
+ connection: opts.connection,
104
+ allowDerived: true,
105
+ }).dsn
106
+ } catch (e) {
107
+ error(e instanceof DsnNotFound ? e.message : String(e))
108
+ return 2
109
+ }
110
+
111
+ await ensureEngine()
112
+ if (opts.status) return await reportStatus(dsn)
113
+
114
+ const paths = seedPaths(file, cwd, config)
115
+ const missing = paths.filter((p) => !existsSync(p))
116
+ if (missing.length > 0) {
117
+ error(`Seed file(s) not found:\n ${missing.join("\n ")}`)
118
+ return 2
119
+ }
120
+
121
+ const builderPath = locateBuilder(cwd, config)
122
+ if (builderPath === undefined) return 2
123
+
124
+ const prepared: Prepared[] = []
125
+ for (const path of paths) {
126
+ const label = toPosix(relative(cwd, path))
127
+ let collected
128
+ try {
129
+ collected = collectSeed(path, {
130
+ cwd,
131
+ builderPath,
132
+ environment: opts.environment,
133
+ })
134
+ } catch (e) {
135
+ error(e instanceof SeedCollectionFailed ? e.message : String(e))
136
+ return 1
137
+ }
138
+
139
+ if (collected.stdout.trim().length > 0) {
140
+ for (const line of collected.stdout.trimEnd().split(/\r?\n/)) info(` ${label}: ${line}`)
141
+ }
142
+ if (collected.result.shape === "legacy") {
143
+ warn(
144
+ `${label} has no default export, so it ran on import.\n` +
145
+ " Export a default function to have its writes collected, ordered and applied in one transaction.",
146
+ )
147
+ continue
148
+ }
149
+ prepared.push({
150
+ file: label,
151
+ document: collected.result.ir,
152
+ config: collected.result.config,
68
153
  })
154
+ }
155
+
156
+ if (prepared.length === 0) {
157
+ info("No seed operations to apply.")
158
+ return 0
159
+ }
160
+
161
+ return await apply(prepared, { cwd, config, dsn, opts })
162
+ }
163
+
164
+ // --- Discovery ---
165
+
166
+ function seedPaths(file: string | undefined, cwd: string, config: SupatypeConfig): string[] {
167
+ if (file !== undefined && file.trim() !== "") return [resolve(cwd, file)]
168
+ const root = projectRootFromConfig(config, cwd)
169
+ const fromDir = discoverSeedsDir(cwd, join(root, "seeds"))
170
+ return fromDir.length > 0 ? fromDir : [resolve(root, "seed.ts")]
171
+ }
172
+
173
+ /**
174
+ * The generated builder, which the runtime needs for the manifest and the fingerprint.
175
+ *
176
+ * Absent is a setup problem with a one-line remedy rather than a crash: the builder is
177
+ * gitignored by design, so a fresh clone that has not run `generate` is the ordinary case.
178
+ */
179
+ function locateBuilder(cwd: string, config: SupatypeConfig): string | undefined {
180
+ const relativePath = seedBuilderPathWithDefaults(config.output)
181
+ const path = resolve(cwd, relativePath)
182
+ if (!existsSync(path)) {
183
+ error(
184
+ `The generated seed builder is missing: ${toPosix(relativePath)}\n` +
185
+ " Run `supatype generate`. It needs no database and no running stack.",
186
+ )
187
+ return undefined
188
+ }
189
+ return path
190
+ }
191
+
192
+ // --- Applying ---
193
+
194
+ interface ApplyOptions {
195
+ cwd: string
196
+ config: SupatypeConfig
197
+ dsn: string
198
+ opts: SeedFlags
199
+ }
200
+
201
+ async function apply(prepared: Prepared[], options: ApplyOptions): Promise<number> {
202
+ const { cwd, config, dsn, opts } = options
203
+ const ast = loadSchemaAst(schemaPathFromProject(config, cwd), cwd)
204
+
205
+ // The engine takes one `--run-once` for the run, so a file asking for it decides for all
206
+ // of them. Said out loud rather than generalised silently, because the difference is
207
+ // whether work someone expected to happen happens.
208
+ const askedRunOnce = prepared.filter((one) => one.config.runOnce === true)
209
+ if (askedRunOnce.length > 0 && askedRunOnce.length !== prepared.length) {
210
+ warn(
211
+ "Some seed files declare `runOnce` and others do not, and the run takes one setting.\n" +
212
+ ` Applying it to all of them, because ${askedRunOnce
213
+ .map((one) => one.file)
214
+ .join(", ")} asked for it.`,
215
+ )
216
+ }
217
+
218
+ const result = await engineRequest<SeedResult>("/seed", {
219
+ ast,
220
+ ir_documents: prepared.map((one) => JSON.stringify({ ...one.document, file: one.file })),
221
+ database_url: dsn,
222
+ schema: pgSchema(config),
223
+ atomic: opts.atomic,
224
+ run_once: askedRunOnce.length > 0,
225
+ ...(opts.environment !== undefined ? { environment: opts.environment } : {}),
226
+ })
227
+
228
+ return report(result, prepared.length, dsn)
229
+ }
230
+
231
+ interface SeedResult {
232
+ ok: boolean
233
+ atomic?: boolean
234
+ files?: Array<{
235
+ file: string
236
+ status: string
237
+ written?: Record<string, number>
238
+ skippedGroups?: string[]
239
+ }>
240
+ error?: {
241
+ kind: string
242
+ message?: string
243
+ remedy?: string | null
244
+ file?: string
245
+ path?: string
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Print what happened to every file, then the outcome.
251
+ *
252
+ * Every file, not just the one that failed: without `--atomic` a failure in the third of
253
+ * five leaves the first two committed, and a report that mentioned only the failure would
254
+ * leave the reader to guess at the rest.
255
+ */
256
+ function report(result: SeedResult, count: number, dsn: string): number {
257
+ for (const file of result.files ?? []) {
258
+ const rows = Object.entries(file.written ?? {})
259
+ .map(([model, written]) => `${model} ${written}`)
260
+ .join(", ")
261
+ info(` ${file.file.padEnd(32)} ${file.status}${rows.length > 0 ? ` (${rows})` : ""}`)
262
+ for (const skipped of file.skippedGroups ?? []) {
263
+ info(` condition was false, skipped: ${skipped}`)
264
+ }
265
+ }
266
+
267
+ if (result.ok) {
268
+ success(`Seeded ${count} file(s) into ${redact(dsn)}.`)
269
+ return 0
270
+ }
271
+
272
+ const failure = result.error
273
+ if (failure === undefined) {
274
+ error("The seed failed, and the engine said nothing about why.")
275
+ return 1
276
+ }
277
+
278
+ const where = [failure.file, failure.path].filter((part) => part !== undefined && part !== "")
279
+ error(
280
+ (failure.message ?? failure.kind) +
281
+ (where.length > 0 ? `\n at ${where.join(" ")}` : "") +
282
+ (failure.remedy !== undefined && failure.remedy !== null ? `\n ${failure.remedy}` : ""),
283
+ )
284
+ // Nothing ran because nothing could: the environment, not the seed.
285
+ return failure.kind === "connection" || failure.kind === "document" ? 2 : 1
286
+ }
287
+
288
+ async function reportStatus(dsn: string): Promise<number> {
289
+ interface StatusEntry {
290
+ file: string
291
+ appliedAt: string
292
+ status: string
293
+ durationMs: number
294
+ drifted: boolean
295
+ }
296
+ const result = await engineRequest<{ files?: StatusEntry[] }>("/seed", {
297
+ database_url: dsn,
298
+ status: true,
299
+ })
300
+
301
+ const files = result.files ?? []
302
+ if (files.length === 0) {
303
+ info(`No seeds have been applied to ${redact(dsn)}.`)
304
+ return 0
305
+ }
306
+
307
+ for (const entry of files) {
308
+ info(
309
+ ` ${entry.file.padEnd(32)} ${entry.status.padEnd(12)} ${entry.appliedAt} ` +
310
+ `${entry.durationMs}ms${entry.drifted ? " changed since it was applied" : ""}`,
311
+ )
312
+ }
313
+ return 0
314
+ }
315
+
316
+ /** Reported with forward slashes, so the message reads the same on every platform. */
317
+ function toPosix(path: string): string {
318
+ return path.split(sep).join("/")
69
319
  }
@@ -23,6 +23,21 @@ function resolveConfigFile(cwd: string): string | null {
23
23
  return null
24
24
  }
25
25
 
26
+ /**
27
+ * New images alone are not a finished upgrade.
28
+ *
29
+ * Components carry schema with them: grants, policies and the rest are generated by the engine and
30
+ * applied by `push`, not baked into an image. Pulling a newer storage service onto a database that
31
+ * predates its grants leaves every private bucket read refused, and the only clue is in the
32
+ * service's log. The remedy is one command, so it is named here rather than left to be discovered.
33
+ */
34
+ function pushReminder(): void {
35
+ plain("")
36
+ info("Run `supatype push` to apply schema changes the new components expect.")
37
+ plain(" Components ship code; grants, policies and functions come from your schema.")
38
+ plain(" Skipping it can leave storage reads and other rules refused until it runs.")
39
+ }
40
+
26
41
  export function registerUpdate(program: Command): void {
27
42
  program
28
43
  .command("update")
@@ -37,7 +52,9 @@ export function registerUpdate(program: Command): void {
37
52
 
38
53
  if (provider === "docker") {
39
54
  if (opts.check) {
40
- info("Docker provider: run without --check to pull compose images (supatype self-host compose pull).")
55
+ // There is no `self-host compose pull` subcommand; the earlier text named one and a reader
56
+ // following it got "unknown command". `update` is what pulls.
57
+ info("Docker provider: run `supatype update` without --check to pull the compose images.")
41
58
  return
42
59
  }
43
60
  const paths = writeSelfHostCompose(cwd, config, { devLocal: true })
@@ -54,6 +71,7 @@ export function registerUpdate(program: Command): void {
54
71
  exitComposeFailed(status, "Could not pull Compose images.", brand)
55
72
  }
56
73
  info("Compose images updated.")
74
+ pushReminder()
57
75
  return
58
76
  }
59
77
 
@@ -149,5 +167,6 @@ export function registerUpdate(program: Command): void {
149
167
 
150
168
  writeFileSync(configPath, text, "utf8")
151
169
  info(`${basename(configPath)} updated.`)
170
+ pushReminder()
152
171
  })
153
172
  }
package/src/db-port.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Keep `DATABASE_URL` pointing at the port Postgres is actually published on.
3
+ *
4
+ * `SUPATYPE_DB_PORT` moves the host side of the compose port mapping, so a second project on one
5
+ * machine can avoid a clash. `DATABASE_URL` is what every host-side script uses: seeds, one-off
6
+ * psql, `supatype admin create-user --connection`. `init` writes the URL with 5432 in it and
7
+ * nothing has ever reconciled the two.
8
+ *
9
+ * The result is a project that starts cleanly, serves every request, and fails the first time
10
+ * someone runs `npm run seed`:
11
+ *
12
+ * AggregateError [ECONNREFUSED]: connect ECONNREFUSED 127.0.0.1:5432
13
+ *
14
+ * with the database listening on 5433 and nothing connecting the error to the port variable the
15
+ * person set half an hour earlier. The generated `.env` even carries a comment conceding the
16
+ * assumption: "Self-host compose uses the same DATABASE_URL when Postgres is published on
17
+ * localhost:5432."
18
+ *
19
+ * `SUPATYPE_DB_PORT` is the source of truth, because it is what compose binds. The URL follows it.
20
+ */
21
+ import { readEnvFile, upsertEnvFile } from "./env-file.js"
22
+
23
+ /** The `.env` key that moves the published Postgres port for a self-host stack. */
24
+ export const DB_PORT_ENV = "SUPATYPE_DB_PORT"
25
+
26
+ /**
27
+ * The `.env` key that moves it for a dev-local stack, and which takes precedence.
28
+ *
29
+ * Two variables, two deployment shapes: an ordinary self-host stack publishes Postgres at
30
+ * `SUPATYPE_DB_PORT`, and a project with `overrides.engine` publishes it at
31
+ * `SUPATYPE_DEV_DB_PORT` so the host-side engine binary can reach it. Its presence is what marks
32
+ * the second shape, so it wins where both are set.
33
+ *
34
+ * Learned the hard way. The first cut of this file read only `SUPATYPE_DB_PORT`, so on a project
35
+ * with `SUPATYPE_DEV_DB_PORT=54330` it rewrote a correct `DATABASE_URL` to 5432, where nothing was
36
+ * listening. It fixed the bug it was written for and reintroduced it through the other door, which
37
+ * is the exact failure this file exists to prevent.
38
+ */
39
+ export const DEV_DB_PORT_ENV = "SUPATYPE_DEV_DB_PORT"
40
+
41
+ /** What compose binds when neither variable is set, and what `init` writes into the URL. */
42
+ export const DEFAULT_DB_PORT = 5432
43
+
44
+ /** Hosts whose port is ours to correct. Anything else is somebody else's database. */
45
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"])
46
+
47
+ /** The port Postgres is published on for this project, from `.env`. */
48
+ export function publishedDbPort(env: Record<string, string>): number {
49
+ // Dev-local first: its presence is what says this project publishes for a host-side engine.
50
+ for (const key of [DEV_DB_PORT_ENV, DB_PORT_ENV]) {
51
+ const raw = (env[key] ?? "").trim()
52
+ if (raw === "") continue
53
+ const port = Number(raw)
54
+ // A non-numeric or out-of-range value is the operator's to fix, and compose reports it far
55
+ // better than we can. Falling through keeps this from inventing a port nothing listens on.
56
+ if (Number.isInteger(port) && port > 0 && port < 65536) return port
57
+ }
58
+ return DEFAULT_DB_PORT
59
+ }
60
+
61
+ /** What changed, for the caller to report. */
62
+ export interface DbPortSync {
63
+ from: string
64
+ to: string
65
+ }
66
+
67
+ /**
68
+ * Rewrite `DATABASE_URL`'s port to match `SUPATYPE_DB_PORT`, and report it.
69
+ *
70
+ * Returns null when there is nothing to do, which is the common case.
71
+ *
72
+ * Only touches a URL pointing at this machine. A project whose `DATABASE_URL` names a managed
73
+ * Postgres elsewhere has a port that is nothing to do with our compose mapping, and rewriting it
74
+ * would repoint the whole project at a database that does not exist.
75
+ */
76
+ export function syncDatabaseUrlPort(cwd: string): DbPortSync | null {
77
+ const env = readEnvFile(cwd)
78
+ const raw = (env["DATABASE_URL"] ?? "").trim()
79
+ if (raw === "") return null
80
+
81
+ let url: URL
82
+ try {
83
+ url = new URL(raw)
84
+ } catch {
85
+ // Malformed, so not ours to rewrite: a partial edit here would turn a typo into a URL that
86
+ // parses and points somewhere wrong, which is harder to spot than the typo.
87
+ return null
88
+ }
89
+
90
+ if (!LOCAL_HOSTS.has(url.hostname)) return null
91
+
92
+ const wanted = String(publishedDbPort(env))
93
+ const current = url.port === "" ? String(DEFAULT_DB_PORT) : url.port
94
+ if (current === wanted) return null
95
+
96
+ url.port = wanted
97
+ upsertEnvFile(cwd, { DATABASE_URL: url.toString() })
98
+ return { from: current, to: wanted }
99
+ }