@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +163 -161
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +77 -0
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +70 -15
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +3 -3
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +29 -19
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/seed.d.ts +27 -1
- package/dist/commands/seed.d.ts.map +1 -1
- package/dist/commands/seed.js +206 -33
- package/dist/commands/seed.js.map +1 -1
- package/dist/engine-client.d.ts +1 -0
- package/dist/engine-client.d.ts.map +1 -1
- package/dist/engine-client.js +47 -2
- package/dist/engine-client.js.map +1 -1
- package/dist/engine-floor.d.ts +27 -4
- package/dist/engine-floor.d.ts.map +1 -1
- package/dist/engine-floor.js +90 -9
- package/dist/engine-floor.js.map +1 -1
- package/dist/gitignore.d.ts.map +1 -1
- package/dist/gitignore.js +7 -0
- package/dist/gitignore.js.map +1 -1
- package/dist/hooks-generator.d.ts.map +1 -1
- package/dist/hooks-generator.js +18 -3
- package/dist/hooks-generator.js.map +1 -1
- package/dist/seed-connection.d.ts +60 -0
- package/dist/seed-connection.d.ts.map +1 -0
- package/dist/seed-connection.js +90 -0
- package/dist/seed-connection.js.map +1 -0
- package/dist/seed-ir.d.ts +66 -0
- package/dist/seed-ir.d.ts.map +1 -0
- package/dist/seed-ir.js +265 -0
- package/dist/seed-ir.js.map +1 -0
- package/dist/seed-runner.d.ts +44 -0
- package/dist/seed-runner.d.ts.map +1 -0
- package/dist/seed-runner.js +83 -0
- package/dist/seed-runner.js.map +1 -0
- package/dist/seed.d.ts +345 -0
- package/dist/seed.d.ts.map +1 -1
- package/dist/seed.js +138 -0
- package/dist/seed.js.map +1 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +14 -8
- package/dist/self-host-compose.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +73 -0
- package/dist/type-extractor.js.map +1 -1
- package/dist/type-generation.d.ts +29 -0
- package/dist/type-generation.d.ts.map +1 -1
- package/dist/type-generation.js +52 -0
- package/dist/type-generation.js.map +1 -1
- package/package.json +2 -2
- package/src/augmentation-generator.ts +73 -14
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/generate.ts +7 -3
- package/src/commands/init.ts +31 -21
- package/src/commands/seed.ts +294 -44
- package/src/engine-client.ts +52 -3
- package/src/engine-floor.ts +100 -9
- package/src/gitignore.ts +7 -0
- package/src/hooks-generator.ts +21 -6
- package/src/seed-connection.ts +146 -0
- package/src/seed-ir.ts +311 -0
- package/src/seed-runner.ts +109 -0
- package/src/seed.ts +408 -0
- package/src/self-host-compose.ts +14 -8
- package/src/type-extractor.ts +101 -0
- package/src/type-generation.ts +64 -0
- package/tests/access-composition.test.ts +123 -0
- package/tests/augmentation-generator.test.ts +149 -9
- package/tests/engine-floor.test.ts +93 -0
- package/tests/fixtures/seed_golden_ir.json +186 -0
- package/tests/runtime-contract.test.ts +31 -6
- package/tests/seed-connection.test.ts +158 -0
- package/tests/seed-ir.test.ts +436 -0
- 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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 "
|
|
252
|
+
return "StorageReference"
|
|
198
253
|
case "richText":
|
|
199
|
-
return "
|
|
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
|
-
|
|
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.
|
|
12
|
+
export const EMBEDDED_CLI_VERSION: string = "0.3.3"
|
package/src/commands/generate.ts
CHANGED
|
@@ -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 {
|
|
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 ??
|
|
18
|
-
const outputClientPath = config.output?.client ??
|
|
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)
|
package/src/commands/init.ts
CHANGED
|
@@ -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": "
|
|
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 {
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
-
|
|
1365
|
-
|
|
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
|
|
package/src/commands/seed.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
|
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
|
-
.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
}
|