@supatype/cli 0.3.2 → 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 (83) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +163 -161
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +77 -0
  5. package/dist/augmentation-generator.d.ts.map +1 -1
  6. package/dist/augmentation-generator.js +70 -15
  7. package/dist/augmentation-generator.js.map +1 -1
  8. package/dist/cli-version-embedded.js +1 -1
  9. package/dist/commands/generate.d.ts.map +1 -1
  10. package/dist/commands/generate.js +3 -3
  11. package/dist/commands/generate.js.map +1 -1
  12. package/dist/commands/init.d.ts.map +1 -1
  13. package/dist/commands/init.js +29 -19
  14. package/dist/commands/init.js.map +1 -1
  15. package/dist/commands/seed.d.ts +27 -1
  16. package/dist/commands/seed.d.ts.map +1 -1
  17. package/dist/commands/seed.js +206 -33
  18. package/dist/commands/seed.js.map +1 -1
  19. package/dist/engine-client.d.ts +1 -0
  20. package/dist/engine-client.d.ts.map +1 -1
  21. package/dist/engine-client.js +47 -2
  22. package/dist/engine-client.js.map +1 -1
  23. package/dist/engine-floor.d.ts +27 -4
  24. package/dist/engine-floor.d.ts.map +1 -1
  25. package/dist/engine-floor.js +90 -9
  26. package/dist/engine-floor.js.map +1 -1
  27. package/dist/gitignore.d.ts.map +1 -1
  28. package/dist/gitignore.js +7 -0
  29. package/dist/gitignore.js.map +1 -1
  30. package/dist/hooks-generator.d.ts.map +1 -1
  31. package/dist/hooks-generator.js +18 -3
  32. package/dist/hooks-generator.js.map +1 -1
  33. package/dist/seed-connection.d.ts +60 -0
  34. package/dist/seed-connection.d.ts.map +1 -0
  35. package/dist/seed-connection.js +90 -0
  36. package/dist/seed-connection.js.map +1 -0
  37. package/dist/seed-ir.d.ts +66 -0
  38. package/dist/seed-ir.d.ts.map +1 -0
  39. package/dist/seed-ir.js +265 -0
  40. package/dist/seed-ir.js.map +1 -0
  41. package/dist/seed-runner.d.ts +44 -0
  42. package/dist/seed-runner.d.ts.map +1 -0
  43. package/dist/seed-runner.js +83 -0
  44. package/dist/seed-runner.js.map +1 -0
  45. package/dist/seed.d.ts +345 -0
  46. package/dist/seed.d.ts.map +1 -1
  47. package/dist/seed.js +138 -0
  48. package/dist/seed.js.map +1 -1
  49. package/dist/self-host-compose.d.ts.map +1 -1
  50. package/dist/self-host-compose.js +14 -8
  51. package/dist/self-host-compose.js.map +1 -1
  52. package/dist/type-extractor.d.ts.map +1 -1
  53. package/dist/type-extractor.js +73 -0
  54. package/dist/type-extractor.js.map +1 -1
  55. package/dist/type-generation.d.ts +29 -0
  56. package/dist/type-generation.d.ts.map +1 -1
  57. package/dist/type-generation.js +52 -0
  58. package/dist/type-generation.js.map +1 -1
  59. package/package.json +2 -2
  60. package/src/augmentation-generator.ts +73 -14
  61. package/src/cli-version-embedded.ts +1 -1
  62. package/src/commands/generate.ts +7 -3
  63. package/src/commands/init.ts +31 -21
  64. package/src/commands/seed.ts +294 -44
  65. package/src/engine-client.ts +52 -3
  66. package/src/engine-floor.ts +100 -9
  67. package/src/gitignore.ts +7 -0
  68. package/src/hooks-generator.ts +21 -6
  69. package/src/seed-connection.ts +146 -0
  70. package/src/seed-ir.ts +311 -0
  71. package/src/seed-runner.ts +109 -0
  72. package/src/seed.ts +408 -0
  73. package/src/self-host-compose.ts +14 -8
  74. package/src/type-extractor.ts +101 -0
  75. package/src/type-generation.ts +64 -0
  76. package/tests/access-composition.test.ts +123 -0
  77. package/tests/augmentation-generator.test.ts +149 -9
  78. package/tests/engine-floor.test.ts +93 -0
  79. package/tests/fixtures/seed_golden_ir.json +186 -0
  80. package/tests/runtime-contract.test.ts +31 -6
  81. package/tests/seed-connection.test.ts +158 -0
  82. package/tests/seed-ir.test.ts +436 -0
  83. package/tsconfig.tsbuildinfo +1 -1
@@ -38,21 +38,44 @@ function fieldServerGenerated(meta: Record<string, unknown>): boolean {
38
38
 
39
39
  export function generateClientAugmentation(ast: unknown): string {
40
40
  const parsed = ast as AstShape
41
- const models = [...(parsed.models ?? [])]
42
- .map((model) => ({ model, tableName: resolveTableName(model) }))
43
- .sort((a, b) => a.tableName.localeCompare(b.tableName))
41
+ // In the order the schema declares them. The AST preserves that order, and it is the order
42
+ // the author reads their own schema in.
43
+ const models = [...(parsed.models ?? [])].map((model) => ({
44
+ model,
45
+ tableName: resolveTableName(model),
46
+ }))
47
+
48
+ const kinds = new Set<string>()
49
+ for (const { model } of models) {
50
+ for (const meta of Object.values(model.fields ?? {})) {
51
+ if (typeof meta["kind"] === "string") kinds.add(meta["kind"])
52
+ }
53
+ }
44
54
 
45
55
  const lines: string[] = []
46
56
  lines.push("// Generated by supatype CLI, do not edit manually.")
47
57
  lines.push("")
58
+ if (kinds.has("richText")) {
59
+ lines.push("import type { SerializedEditorState } from \"@supatype/types/lexical\"")
60
+ lines.push("")
61
+ lines.push("export type RichText = SerializedEditorState")
62
+ lines.push("")
63
+ }
64
+ if (kinds.has("image") || kinds.has("file")) {
65
+ lines.push("export type StorageReference = {")
66
+ lines.push(" bucket: string")
67
+ lines.push(" path: string")
68
+ lines.push("}")
69
+ lines.push("")
70
+ }
48
71
  lines.push("declare module \"@supatype/client\" {")
49
72
  lines.push(" interface SupatypeModels {")
50
73
 
51
74
  for (const { model, tableName } of models) {
52
75
  lines.push(` ${quoteKey(tableName)}: {`)
53
- lines.push(` Row: ${generateRowType(model.fields)}`)
54
- lines.push(` Insert: ${generateInsertType(model.fields)}`)
55
- lines.push(` Update: ${generateUpdateType(model.fields)}`)
76
+ lines.push(` Row: ${indent(generateRowType(model.fields), 6)}`)
77
+ lines.push(` Insert: ${indent(generateInsertType(model.fields), 6)}`)
78
+ lines.push(` Update: ${indent(generateUpdateType(model.fields), 6)}`)
56
79
  lines.push(" }")
57
80
  }
58
81
 
@@ -65,6 +88,15 @@ export function generateClientAugmentation(ast: unknown): string {
65
88
  return lines.join("\n")
66
89
  }
67
90
 
91
+ /** Place a generated object body inside the block that holds it. */
92
+ function indent(source: string, spaces: number): string {
93
+ const pad = " ".repeat(spaces)
94
+ return source
95
+ .split("\n")
96
+ .map((line, i) => (i === 0 ? line : `${pad}${line}`))
97
+ .join("\n")
98
+ }
99
+
68
100
  function toSnakeCase(name: string): string {
69
101
  return name.replace(/([A-Z])/g, "_$1").replace(/^_/, "").toLowerCase()
70
102
  }
@@ -110,7 +142,6 @@ function columnsOf(
110
142
  .filter((entry): entry is { column: string; ts: string; meta: Record<string, unknown> } =>
111
143
  entry !== null,
112
144
  )
113
- .sort((a, b) => a.column.localeCompare(b.column))
114
145
  }
115
146
 
116
147
  export function generateRowType(fields: Record<string, Record<string, unknown>>): string {
@@ -120,8 +151,30 @@ export function generateRowType(fields: Record<string, Record<string, unknown>>)
120
151
  return `{\n${body}\n}`
121
152
  }
122
153
 
154
+ /**
155
+ * What a column accepts on the way in, where that is wider than what it returns.
156
+ *
157
+ * A rich text column holds a Lexical document, and the engine emits a trigger that normalises a
158
+ * plain string into one on insert and update. So writing prose is a string and reading it is
159
+ * always a document, and this is the only place the three shapes differ by more than
160
+ * optionality. Flattening them back would either demand hand-built Lexical JSON from every
161
+ * caller, or claim a read might return a string, which after the trigger it cannot.
162
+ */
163
+ function widenForWrites(ts: string, meta: Record<string, unknown>): string {
164
+ // Rebuilt rather than edited. The widening has to land on the value the trigger normalises,
165
+ // and for a localized column that is each locale's value, not the map holding them. Splicing
166
+ // ` | string` into the finished string put it outside the locale wrapper, which said a bare
167
+ // string was a valid write to the column; the trigger would then store it as a single document
168
+ // where a locale map belongs, and the Row type says that cannot happen.
169
+ return meta["kind"] === "richText" ? toTsType(meta, true) : ts
170
+ }
171
+
123
172
  function insertColumnOptionalOnInsert(meta: Record<string, unknown>): boolean {
124
- return meta["default"] !== undefined || fieldServerGenerated(meta)
173
+ // `slug` has neither a declared default nor a `serverGenerated` flag, but the engine emits
174
+ // a trigger that fills it, which is the same thing from an insert's point of view.
175
+ return (
176
+ meta["default"] !== undefined || fieldServerGenerated(meta) || meta["kind"] === "slug"
177
+ )
125
178
  }
126
179
 
127
180
  export function generateInsertType(fields: Record<string, Record<string, unknown>>): string {
@@ -130,7 +183,7 @@ export function generateInsertType(fields: Record<string, Record<string, unknown
130
183
  const body = columns
131
184
  .map(({ column, ts, meta }) => {
132
185
  const required = meta["required"] === true && !insertColumnOptionalOnInsert(meta)
133
- return ` ${quoteKey(column)}${required ? "" : "?"}: ${ts}`
186
+ return ` ${quoteKey(column)}${required ? "" : "?"}: ${widenForWrites(ts, meta)}`
134
187
  })
135
188
  .join("\n")
136
189
  return `{\n${body}\n}`
@@ -139,11 +192,13 @@ export function generateInsertType(fields: Record<string, Record<string, unknown
139
192
  export function generateUpdateType(fields: Record<string, Record<string, unknown>>): string {
140
193
  const columns = columnsOf(fields)
141
194
  if (columns.length === 0) return "Record<string, unknown>"
142
- const body = columns.map(({ column, ts }) => ` ${quoteKey(column)}?: ${ts}`).join("\n")
195
+ const body = columns
196
+ .map(({ column, ts, meta }) => ` ${quoteKey(column)}?: ${widenForWrites(ts, meta)}`)
197
+ .join("\n")
143
198
  return `{\n${body}\n}`
144
199
  }
145
200
 
146
- function toTsType(meta: Record<string, unknown>): string {
201
+ function toTsType(meta: Record<string, unknown>, prose = false): string {
147
202
  const kind = typeof meta["kind"] === "string" ? meta["kind"] : "json"
148
203
  const required = meta["required"] === true
149
204
  const base = (() => {
@@ -194,9 +249,9 @@ function toTsType(meta: Record<string, unknown>): string {
194
249
  // derived at read time instead.
195
250
  case "image":
196
251
  case "file":
197
- return "{ bucket: string; path: string }"
252
+ return "StorageReference"
198
253
  case "richText":
199
- return "(import(\"@supatype/types/lexical\").SerializedEditorState | string)"
254
+ return "RichText"
200
255
  case "enum": {
201
256
  const values = Array.isArray(meta["values"]) ? meta["values"] as unknown[] : []
202
257
  const quoted = values
@@ -211,7 +266,11 @@ function toTsType(meta: Record<string, unknown>): string {
211
266
  // A localized column is JSONB holding a locale map, `{"en": ..., "fr": ...}`, not the bare
212
267
  // value. `page.title` is exactly this and was typed `string`, so calling a string method on it
213
268
  // compiled and then failed against real data.
214
- const shaped = meta["localized"] === true ? `{ [locale: string]: ${base} }` : base
269
+ // Prose, which a trigger normalises into a document on the way in. Applied to the element,
270
+ // before the locale wrapper goes on, so a localized column reads `{ [locale: string]: RichText
271
+ // | string }` rather than claiming the whole map could be a string.
272
+ const written = prose && meta["kind"] === "richText" ? `${base} | string` : base
273
+ const shaped = meta["localized"] === true ? `{ [locale: string]: ${written} }` : written
215
274
  return required ? shaped : `${shaped} | null`
216
275
  }
217
276
 
@@ -9,4 +9,4 @@
9
9
  // Annotated `string`, not left to inference: once a release stamps a value here, the
10
10
  // inferred literal type makes the `!== ""` test in cliPackageVersion() a compile error
11
11
  // (TS2367, no overlap), so the build would only fail during a release.
12
- export const EMBEDDED_CLI_VERSION: string = "0.3.2"
12
+ export const EMBEDDED_CLI_VERSION: string = "0.3.3"
@@ -2,7 +2,11 @@ import type { Command } from "commander"
2
2
  import { loadConfig, loadSchemaAst } from "../config.js"
3
3
  import { hooksPathFromProject, schemaPathFromProject } from "../project-config.js"
4
4
  import { writeHooksModule } from "../model-hooks.js"
5
- import { writeGeneratedTypes } from "../type-generation.js"
5
+ import {
6
+ DEFAULT_CLIENT_PATH,
7
+ DEFAULT_TYPES_PATH,
8
+ writeGeneratedTypes,
9
+ } from "../type-generation.js"
6
10
  import { error, info } from "../ui/messages.js"
7
11
 
8
12
  export function registerGenerate(program: Command): void {
@@ -14,8 +18,8 @@ export function registerGenerate(program: Command): void {
14
18
  const cwd = process.cwd()
15
19
  const config = loadConfig(cwd)
16
20
  const schemaPath = schemaPathFromProject(config, cwd)
17
- const outputTypesPath = config.output?.types ?? "types/database.ts"
18
- const outputClientPath = config.output?.client ?? "supatype/generated/index.d.ts"
21
+ const outputTypesPath = config.output?.types ?? DEFAULT_TYPES_PATH
22
+ const outputClientPath = config.output?.client ?? DEFAULT_CLIENT_PATH
19
23
 
20
24
  info("Loading schema...")
21
25
  const ast = loadSchemaAst(schemaPath, cwd)
@@ -812,7 +812,12 @@ function packageJsonTemplate(opts: ScaffoldOptions, deps: InitDependencyVersions
812
812
  const scripts: string[] = [
813
813
  ` "dev": "supatype dev"`,
814
814
  ` "push": "supatype push"`,
815
- ` "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"`,
816
821
  ]
817
822
  if (opts.app.viteDevUrl) {
818
823
  scripts.push(` "vite": "vite"`)
@@ -1347,28 +1352,33 @@ S3_SECRET_KEY=`
1347
1352
  }
1348
1353
 
1349
1354
  function seedTemplate(projectName: string): string {
1350
- return `import { sql } from "@supatype/cli/seed"
1351
-
1352
- // Connect using DATABASE_URL from environment
1353
- const db = sql(
1354
- process.env["DATABASE_URL"] ??
1355
- "postgresql://supatype_admin:postgres@localhost:5432/${projectName}",
1356
- )
1357
-
1358
- async function seed() {
1359
- console.log("Seeding ${projectName}...")
1360
-
1361
- // TODO: insert seed data
1362
- // 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
+ // })
1363
1376
 
1364
- await db.end()
1365
- 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 })
1366
1381
  }
1367
-
1368
- seed().catch((e) => {
1369
- console.error(e)
1370
- process.exit(1)
1371
- })
1372
1382
  `
1373
1383
  }
1374
1384
 
@@ -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
  }