@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
package/src/engine-client.ts
CHANGED
|
@@ -149,6 +149,7 @@ export async function engineHealth(): Promise<boolean> {
|
|
|
149
149
|
* /introspect → engine introspect
|
|
150
150
|
* /validate → engine validate
|
|
151
151
|
* /admin → engine admin (admin-config JSON on stdout)
|
|
152
|
+
* /seed → engine seed (result document JSON on stdout)
|
|
152
153
|
*/
|
|
153
154
|
export async function engineRequest<T = unknown>(
|
|
154
155
|
endpoint: string,
|
|
@@ -171,6 +172,19 @@ export async function engineRequest<T = unknown>(
|
|
|
171
172
|
writeFileSync(gzPath, Buffer.from(body["schema_sources_gz_base64"], "base64"))
|
|
172
173
|
cleanup.push(gzPath)
|
|
173
174
|
}
|
|
175
|
+
// One file per seed document. The engine takes `--ir` once per document rather than one
|
|
176
|
+
// combined blob, so a failure can name the file it was in.
|
|
177
|
+
const irPaths: string[] = []
|
|
178
|
+
const documents = body["ir_documents"]
|
|
179
|
+
if (Array.isArray(documents)) {
|
|
180
|
+
documents.forEach((document, index) => {
|
|
181
|
+
const irPath = join(tmpDir, `ir-${Date.now()}-${index}.json`)
|
|
182
|
+
writeFileSync(irPath, typeof document === "string" ? document : JSON.stringify(document))
|
|
183
|
+
cleanup.push(irPath)
|
|
184
|
+
irPaths.push(irPath)
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
174
188
|
if (body["schema_sources_manifest"] !== undefined) {
|
|
175
189
|
manifestPath = join(tmpDir, `manifest-${Date.now()}.json`)
|
|
176
190
|
writeFileSync(manifestPath, JSON.stringify(body["schema_sources_manifest"]))
|
|
@@ -180,6 +194,7 @@ export async function engineRequest<T = unknown>(
|
|
|
180
194
|
const args = endpointToArgs(endpoint, body, reqFile, {
|
|
181
195
|
...(gzPath !== undefined ? { gzPath } : {}),
|
|
182
196
|
...(manifestPath !== undefined ? { manifestPath } : {}),
|
|
197
|
+
...(irPaths.length > 0 ? { irPaths } : {}),
|
|
183
198
|
})
|
|
184
199
|
|
|
185
200
|
const result = spawnSync(bin, args, {
|
|
@@ -191,7 +206,16 @@ export async function engineRequest<T = unknown>(
|
|
|
191
206
|
try { unlinkSync(f) } catch { /* ignore */ }
|
|
192
207
|
}
|
|
193
208
|
|
|
194
|
-
|
|
209
|
+
// A failed seed is an answer, not a crash.
|
|
210
|
+
//
|
|
211
|
+
// `engine seed` exits 1 when the seed itself failed and prints the result document on
|
|
212
|
+
// stdout: which file, what it wrote before it stopped, and the error in the schema's own
|
|
213
|
+
// terms. Treating a non-zero exit as "no output" threw all of that away and reported
|
|
214
|
+
// `Engine /seed failed (exit 1): (no output)`, which is worse than the raw Postgres error
|
|
215
|
+
// it replaced. Anything without a document to read still throws.
|
|
216
|
+
const reportsFailureAsData = endpoint === "/seed" && (result.stdout?.trim().length ?? 0) > 0
|
|
217
|
+
|
|
218
|
+
if (result.status !== 0 && !reportsFailureAsData) {
|
|
195
219
|
const stderr = result.stderr?.trim() || "(no output)"
|
|
196
220
|
throw new EngineError(
|
|
197
221
|
`Engine ${endpoint} failed (exit ${result.status}): ${stderr}`,
|
|
@@ -221,7 +245,7 @@ function endpointToArgs(
|
|
|
221
245
|
endpoint: string,
|
|
222
246
|
body: Record<string, unknown>,
|
|
223
247
|
reqFile: string,
|
|
224
|
-
sources?: { gzPath?: string; manifestPath?: string },
|
|
248
|
+
sources?: { gzPath?: string; manifestPath?: string; irPaths?: string[] },
|
|
225
249
|
): string[] {
|
|
226
250
|
const dbUrl = (body["database_url"] as string | undefined) ?? ""
|
|
227
251
|
const schema = (body["schema"] as string | undefined) ?? "public"
|
|
@@ -259,7 +283,32 @@ function endpointToArgs(
|
|
|
259
283
|
|
|
260
284
|
case "/generate": {
|
|
261
285
|
const lang = (body["lang"] as string | undefined) ?? "typescript"
|
|
262
|
-
|
|
286
|
+
const artifact = (body["artifact"] as string | undefined) ?? "types"
|
|
287
|
+
return ["generate", "--input", reqFile, "--lang", lang, "--artifact", artifact]
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
case "/seed": {
|
|
291
|
+
// `--status` reads the ledger and needs no schema, so it does not take `--input`:
|
|
292
|
+
// passing one would make the command fail on a project whose schema has moved on.
|
|
293
|
+
if (body["status"] === true) {
|
|
294
|
+
return ["seed", "--database-url", dbUrl, "--status"]
|
|
295
|
+
}
|
|
296
|
+
const documents = (sources?.irPaths ?? []).flatMap((path) => ["--ir", path])
|
|
297
|
+
const environment =
|
|
298
|
+
typeof body["environment"] === "string" ? ["--environment", body["environment"]] : []
|
|
299
|
+
return [
|
|
300
|
+
"seed",
|
|
301
|
+
"--input",
|
|
302
|
+
reqFile,
|
|
303
|
+
...documents,
|
|
304
|
+
"--database-url",
|
|
305
|
+
dbUrl,
|
|
306
|
+
"--schema",
|
|
307
|
+
schema,
|
|
308
|
+
...environment,
|
|
309
|
+
...(body["atomic"] === true ? ["--atomic"] : []),
|
|
310
|
+
...(body["run_once"] === true ? ["--run-once"] : []),
|
|
311
|
+
]
|
|
263
312
|
}
|
|
264
313
|
|
|
265
314
|
case "/introspect":
|
package/src/engine-floor.ts
CHANGED
|
@@ -36,6 +36,27 @@ export const ENGINE_MIN_FOR_BOUNDS = "0.2.0"
|
|
|
36
36
|
*/
|
|
37
37
|
export const ENGINE_MIN_FOR_VERSIONS = "0.3.0"
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* First engine release that normalises a rich text column's contents (schema-engine v0.4.0).
|
|
41
|
+
*
|
|
42
|
+
* Silent in the same way `versions` is, and for a reason worth naming. The generated types accept
|
|
43
|
+
* a plain string on the way in and promise a Lexical document on the way out, and a trigger is
|
|
44
|
+
* what makes the second half true for every writer. An older engine emits no trigger and rejects
|
|
45
|
+
* nothing, so the string is stored exactly as it was written and every later read returns
|
|
46
|
+
* something the types say cannot happen.
|
|
47
|
+
*/
|
|
48
|
+
export const ENGINE_MIN_FOR_RICHTEXT_TRIGGER = "0.4.0"
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* First engine release that has a `seed` subcommand (schema-engine v0.4.0).
|
|
52
|
+
*
|
|
53
|
+
* Unconditional, where everything else in this file is conditional on the schema. There is no way
|
|
54
|
+
* to write a seed that does not need the subcommand, so there is nothing to read the project for
|
|
55
|
+
* and nothing to point at. An older engine answers the request with a usage error about an
|
|
56
|
+
* unrecognised argument, which names no part of the project and reads like a broken install.
|
|
57
|
+
*/
|
|
58
|
+
export const ENGINE_MIN_FOR_SEED = "0.4.0"
|
|
59
|
+
|
|
39
60
|
/**
|
|
40
61
|
* Compare two dotted versions numerically. Returns <0, 0 or >0.
|
|
41
62
|
*
|
|
@@ -89,6 +110,17 @@ export function boundsRequiringHelpers(ast: ExtractedSchemaAstV2): string[] {
|
|
|
89
110
|
return [...fieldsWithBounds(ast.models), ...modelsWithConstraints(ast.models)]
|
|
90
111
|
}
|
|
91
112
|
|
|
113
|
+
/** Rich text fields, which need a trigger only a new enough engine emits. */
|
|
114
|
+
export function fieldsNeedingRichTextTrigger(ast: ExtractedSchemaAstV2): string[] {
|
|
115
|
+
const found: string[] = []
|
|
116
|
+
for (const model of ast.models) {
|
|
117
|
+
for (const [fieldName, field] of Object.entries(model.fields)) {
|
|
118
|
+
if (field.kind === "richText") found.push(`${model.name}.${fieldName}`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return found
|
|
122
|
+
}
|
|
123
|
+
|
|
92
124
|
/** Models declaring `versions`, which needs a whole layer only a new enough engine emits. */
|
|
93
125
|
export function modelsRequiringVersions(ast: ExtractedSchemaAstV2): string[] {
|
|
94
126
|
return ast.models
|
|
@@ -134,6 +166,17 @@ const ENGINE_REQUIREMENTS: EngineRequirement[] = [
|
|
|
134
166
|
`push reports success and you get a model with no history, no drafts and an editor that ` +
|
|
135
167
|
`saves into nothing. That silence is the reason this refuses.`,
|
|
136
168
|
},
|
|
169
|
+
{
|
|
170
|
+
since: ENGINE_MIN_FOR_RICHTEXT_TRIGGER,
|
|
171
|
+
declaredBy: fieldsNeedingRichTextTrigger,
|
|
172
|
+
feature: "rich text columns",
|
|
173
|
+
consequence:
|
|
174
|
+
`Only engine ${ENGINE_MIN_FOR_RICHTEXT_TRIGGER}+ emits the trigger that turns a plain ` +
|
|
175
|
+
`string written into one of these columns into a Lexical document. The generated types ` +
|
|
176
|
+
`accept a string on the way in and promise a document on the way out, and the trigger is ` +
|
|
177
|
+
`the only thing that makes the second half true. Without it the string is stored as it ` +
|
|
178
|
+
`was written and a later read returns what the types say is impossible.`,
|
|
179
|
+
},
|
|
137
180
|
]
|
|
138
181
|
|
|
139
182
|
/**
|
|
@@ -142,13 +185,37 @@ const ENGINE_REQUIREMENTS: EngineRequirement[] = [
|
|
|
142
185
|
* Takes the pin from `versions.engine`, or undefined when unpinned. See the note above on why
|
|
143
186
|
* that is the right source rather than the binary's own `--version`.
|
|
144
187
|
*/
|
|
188
|
+
/**
|
|
189
|
+
* Whether a pin can be compared at all.
|
|
190
|
+
*
|
|
191
|
+
* Unpinned resolves to latest, and `local` points at a build whose version the config does not
|
|
192
|
+
* know. Neither can be compared, and neither is the case that breaks.
|
|
193
|
+
*/
|
|
194
|
+
function comparable(pin: string | undefined): pin is string {
|
|
195
|
+
return pin !== undefined && pin !== "local"
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The parts every refusal here shares: what is needed, and what to do about it.
|
|
200
|
+
*
|
|
201
|
+
* One builder rather than one per check, because the remedy is the only part of this message
|
|
202
|
+
* anyone acts on, and a second copy is a second place for it to go stale.
|
|
203
|
+
*/
|
|
204
|
+
function refusal(parts: { needs: string; since: string; pinned: string; body: string }): string {
|
|
205
|
+
return (
|
|
206
|
+
`${parts.needs}, which needs schema-engine ${parts.since} or newer, and this project ` +
|
|
207
|
+
`pins ${parts.pinned}.\n\n` +
|
|
208
|
+
`${parts.body}\n\n` +
|
|
209
|
+
`Raise or remove the pin in supatype.config.ts:\n` +
|
|
210
|
+
` versions: { engine: "${parts.since}" } // or omit it to track latest`
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
145
214
|
export function assertEngineSupportsSchema(
|
|
146
215
|
ast: ExtractedSchemaAstV2,
|
|
147
216
|
pinnedEngineVersion: string | undefined,
|
|
148
217
|
): void {
|
|
149
|
-
|
|
150
|
-
// know. Neither can be compared, and neither is the case that breaks.
|
|
151
|
-
if (pinnedEngineVersion === undefined || pinnedEngineVersion === "local") return
|
|
218
|
+
if (!comparable(pinnedEngineVersion)) return
|
|
152
219
|
|
|
153
220
|
for (const requirement of ENGINE_REQUIREMENTS) {
|
|
154
221
|
if (compareVersions(pinnedEngineVersion, requirement.since) >= 0) continue
|
|
@@ -159,12 +226,36 @@ export function assertEngineSupportsSchema(
|
|
|
159
226
|
const shown = needed.slice(0, 3).join(", ")
|
|
160
227
|
const more = needed.length > 3 ? `, and ${needed.length - 3} more` : ""
|
|
161
228
|
throw new Error(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
`
|
|
167
|
-
|
|
229
|
+
refusal({
|
|
230
|
+
needs: `This schema declares ${requirement.feature}`,
|
|
231
|
+
since: requirement.since,
|
|
232
|
+
pinned: pinnedEngineVersion,
|
|
233
|
+
body: ` Declared on: ${shown}${more}\n\n${requirement.consequence}`,
|
|
234
|
+
}),
|
|
168
235
|
)
|
|
169
236
|
}
|
|
170
237
|
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Why the pinned engine cannot run a seed, or undefined when it can.
|
|
241
|
+
*
|
|
242
|
+
* Returns rather than throws, because the seed command answers in exit codes and this is a `2`:
|
|
243
|
+
* the run could not start, alongside a missing builder or an unreachable database, rather than a
|
|
244
|
+
* `1` where the seed itself was wrong.
|
|
245
|
+
*/
|
|
246
|
+
export function seedUnsupportedByPinnedEngine(
|
|
247
|
+
pinnedEngineVersion: string | undefined,
|
|
248
|
+
): string | undefined {
|
|
249
|
+
if (!comparable(pinnedEngineVersion)) return undefined
|
|
250
|
+
if (compareVersions(pinnedEngineVersion, ENGINE_MIN_FOR_SEED) >= 0) return undefined
|
|
251
|
+
|
|
252
|
+
return refusal({
|
|
253
|
+
needs: "Seeding is done by the engine",
|
|
254
|
+
since: ENGINE_MIN_FOR_SEED,
|
|
255
|
+
pinned: pinnedEngineVersion,
|
|
256
|
+
body:
|
|
257
|
+
`An engine older than ${ENGINE_MIN_FOR_SEED} has no \`seed\` subcommand. It would answer ` +
|
|
258
|
+
`this run with a usage error about an unrecognised argument, which names nothing about ` +
|
|
259
|
+
`your project and reads like a broken install.`,
|
|
260
|
+
})
|
|
261
|
+
}
|
package/src/gitignore.ts
CHANGED
|
@@ -24,6 +24,13 @@ const SUPATYPE_IGNORED_PATHS = [
|
|
|
24
24
|
"supatype.local.config.ts",
|
|
25
25
|
"supatype.local.config.js",
|
|
26
26
|
"supatype.local.config.mjs",
|
|
27
|
+
// Generated from schema/index.ts by `supatype generate`, which needs no database and no
|
|
28
|
+
// running stack, so a clone can always rebuild it. Listed here rather than only in the
|
|
29
|
+
// `init` template so projects that already exist pick it up too.
|
|
30
|
+
//
|
|
31
|
+
// The default path. A project that moves `output.client` moves this with it, and would
|
|
32
|
+
// need its own line: there is no pattern that follows a value from the config file.
|
|
33
|
+
"supatype/generated/seed.ts",
|
|
27
34
|
] as const
|
|
28
35
|
|
|
29
36
|
/** The paths above, for a scaffold that composes its own `.gitignore` around them. */
|
package/src/hooks-generator.ts
CHANGED
|
@@ -83,12 +83,18 @@ export function generateHooksModule(ast: unknown): string | null {
|
|
|
83
83
|
// })
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* Lexical editor state as JSON
|
|
86
|
+
* Lexical editor state as JSON.
|
|
87
87
|
*
|
|
88
88
|
* Structural rather than imported: the precise \`SerializedEditorState\` lives in
|
|
89
89
|
* \`@supatype/types/lexical\`, which a Deno function has no way to resolve.
|
|
90
|
+
*
|
|
91
|
+
* A hook writing to a rich text column may hand back a plain string instead; a trigger
|
|
92
|
+
* normalises it into one of these, so what a later read returns is always the document.
|
|
90
93
|
*/
|
|
91
|
-
export type RichTextValue = { readonly root: { readonly children: readonly unknown[] } }
|
|
94
|
+
export type RichTextValue = { readonly root: { readonly children: readonly unknown[] } }
|
|
95
|
+
|
|
96
|
+
/** A stored file, and the bucket it went to. */
|
|
97
|
+
export type StorageReference = { readonly bucket: string; readonly path: string }
|
|
92
98
|
|
|
93
99
|
export interface HookTables {
|
|
94
100
|
${tableEntries}
|
|
@@ -412,11 +418,20 @@ function json(body: unknown, status: number): Response {
|
|
|
412
418
|
* column emitted a module that failed to compile in the user's editor. Caught by compiling the
|
|
413
419
|
* generated file in a test rather than by a reader reporting it.
|
|
414
420
|
*/
|
|
421
|
+
/**
|
|
422
|
+
* The shared type names, as a Deno module can spell them.
|
|
423
|
+
*
|
|
424
|
+
* `RichText` resolves through `@supatype/types/lexical`, which a Deno function has no way to
|
|
425
|
+
* reach, so the module declares a structural stand-in and the generated rows point at that.
|
|
426
|
+
* `StorageReference` needs no import and is declared here as itself.
|
|
427
|
+
*/
|
|
415
428
|
function denoSafe(source: string): string {
|
|
416
|
-
return source
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
429
|
+
return source
|
|
430
|
+
.replaceAll("RichText", "RichTextValue")
|
|
431
|
+
.replaceAll(
|
|
432
|
+
'import("@supatype/types/lexical").SerializedEditorState',
|
|
433
|
+
"RichTextValue",
|
|
434
|
+
)
|
|
420
435
|
}
|
|
421
436
|
|
|
422
437
|
/** Re-indent a generated type literal so the emitted file reads as if it were written by hand. */
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which database a seed run targets, and how that was decided.
|
|
3
|
+
*
|
|
4
|
+
* Its own file so the failure is testable without spawning anything. The failure is the
|
|
5
|
+
* point: the old seed template carried a hardcoded fallback DSN, so a project with no
|
|
6
|
+
* `.env` did not fail, it silently tried `localhost:5432` with a guessed password and
|
|
7
|
+
* reported a connection error that named none of the things it had looked at. Someone
|
|
8
|
+
* reading that has no way to tell "I have not set DATABASE_URL" from "the stack is not
|
|
9
|
+
* running".
|
|
10
|
+
*
|
|
11
|
+
* So every source is recorded whether it was found or not, and the error prints the lot.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync } from "node:fs"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
import { readEnvFile } from "./env-file.js"
|
|
17
|
+
import {
|
|
18
|
+
externalDatabaseUrl,
|
|
19
|
+
localDSN,
|
|
20
|
+
projectRootFromConfig,
|
|
21
|
+
type SupatypeProjectConfig,
|
|
22
|
+
} from "./project-config.js"
|
|
23
|
+
|
|
24
|
+
/** One place a connection string could have come from. */
|
|
25
|
+
export interface DsnSource {
|
|
26
|
+
/** How it would be described to the person running the command. */
|
|
27
|
+
name: string
|
|
28
|
+
/** What was found there, or why nothing was. */
|
|
29
|
+
detail: string
|
|
30
|
+
found: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ResolvedDsn {
|
|
34
|
+
dsn: string
|
|
35
|
+
/** The `name` of the source it came from. */
|
|
36
|
+
source: string
|
|
37
|
+
/** Every source, in the order they were considered. */
|
|
38
|
+
tried: readonly DsnSource[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Nothing named a database, with every source tried. */
|
|
42
|
+
export class DsnNotFound extends Error {
|
|
43
|
+
constructor(readonly tried: readonly DsnSource[]) {
|
|
44
|
+
super(
|
|
45
|
+
`No database connection string for this seed run.\n${tried
|
|
46
|
+
.map((source) => ` ${source.found ? "found" : "not set"} ${source.name}: ${source.detail}`)
|
|
47
|
+
.join("\n")}`,
|
|
48
|
+
)
|
|
49
|
+
this.name = "DsnNotFound"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ResolveOptions {
|
|
54
|
+
/** `--connection`, which beats everything because it was typed just now. */
|
|
55
|
+
connection?: string | undefined
|
|
56
|
+
/**
|
|
57
|
+
* Whether to fall back to the DSN derived from the project name.
|
|
58
|
+
*
|
|
59
|
+
* True for a project whose database Supatype manages, where the derived value is right by
|
|
60
|
+
* construction. A project pointed at someone else's Postgres has no such default, and
|
|
61
|
+
* inventing one would send the seed to a database nobody named.
|
|
62
|
+
*/
|
|
63
|
+
allowDerived?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the DSN, in the order the rest of the CLI already uses.
|
|
68
|
+
*
|
|
69
|
+
* `.env` is loaded first but ranks below a real environment variable, which is how every
|
|
70
|
+
* other dotenv reader behaves and how Compose resolves the same names.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveSeedDsn(
|
|
73
|
+
cwd: string,
|
|
74
|
+
config: SupatypeProjectConfig,
|
|
75
|
+
options: ResolveOptions = {},
|
|
76
|
+
): ResolvedDsn {
|
|
77
|
+
const root = projectRootFromConfig(config, cwd)
|
|
78
|
+
const envPath = join(root, ".env")
|
|
79
|
+
const fromFile = readEnvFile(root)
|
|
80
|
+
const tried: DsnSource[] = []
|
|
81
|
+
|
|
82
|
+
const candidate = (name: string, value: string | undefined, absent: string): string | undefined => {
|
|
83
|
+
const trimmed = value?.trim()
|
|
84
|
+
if (trimmed !== undefined && trimmed.length > 0) {
|
|
85
|
+
tried.push({ name, detail: redact(trimmed), found: true })
|
|
86
|
+
return trimmed
|
|
87
|
+
}
|
|
88
|
+
tried.push({ name, detail: absent, found: false })
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const flag = candidate("--connection", options.connection, "not passed")
|
|
93
|
+
if (flag !== undefined) return { dsn: flag, source: "--connection", tried }
|
|
94
|
+
|
|
95
|
+
// Ahead of the rest on purpose: a stated external database is the whole stack's database,
|
|
96
|
+
// and seeding somewhere else while the services read from here would look like data loss.
|
|
97
|
+
const external = candidate(
|
|
98
|
+
"database.external.url in supatype.config.ts",
|
|
99
|
+
externalDatabaseUrl(config),
|
|
100
|
+
"not declared",
|
|
101
|
+
)
|
|
102
|
+
if (external !== undefined) {
|
|
103
|
+
return { dsn: external, source: "database.external.url in supatype.config.ts", tried }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const declared = candidate("connection in supatype.config.ts", config.connection, "not declared")
|
|
107
|
+
if (declared !== undefined) {
|
|
108
|
+
return { dsn: declared, source: "connection in supatype.config.ts", tried }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fromEnv = candidate("DATABASE_URL in the environment", process.env["DATABASE_URL"], "not set")
|
|
112
|
+
if (fromEnv !== undefined) {
|
|
113
|
+
return { dsn: fromEnv, source: "DATABASE_URL in the environment", tried }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fileLabel = `DATABASE_URL in ${envPath}`
|
|
117
|
+
const fileValue = candidate(
|
|
118
|
+
fileLabel,
|
|
119
|
+
fromFile["DATABASE_URL"],
|
|
120
|
+
existsSync(envPath) ? "the file has no DATABASE_URL" : "no .env file",
|
|
121
|
+
)
|
|
122
|
+
if (fileValue !== undefined) return { dsn: fileValue, source: fileLabel, tried }
|
|
123
|
+
|
|
124
|
+
if (options.allowDerived === true) {
|
|
125
|
+
const derived = localDSN(config)
|
|
126
|
+
tried.push({ name: "the project's own local database", detail: redact(derived), found: true })
|
|
127
|
+
return { dsn: derived, source: "the project's own local database", tried }
|
|
128
|
+
}
|
|
129
|
+
tried.push({
|
|
130
|
+
name: "the project's own local database",
|
|
131
|
+
detail: "this project points at a database it does not manage, so there is no default",
|
|
132
|
+
found: false,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
throw new DsnNotFound(tried)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A connection string with its password removed.
|
|
140
|
+
*
|
|
141
|
+
* Every one of these reaches a terminal, and often a CI log. Printing a DSN to explain where
|
|
142
|
+
* it came from should not be how a password ends up somewhere it is kept.
|
|
143
|
+
*/
|
|
144
|
+
export function redact(dsn: string): string {
|
|
145
|
+
return dsn.replace(/(:\/\/[^:/@]+:)[^@]*(@)/, "$1***$2")
|
|
146
|
+
}
|