@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +168 -158
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +77 -0
- package/dist/api-config-cache.d.ts +20 -0
- package/dist/api-config-cache.d.ts.map +1 -1
- package/dist/api-config-cache.js +27 -0
- package/dist/api-config-cache.js.map +1 -1
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +126 -26
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/client-generator.d.ts +3 -0
- package/dist/client-generator.d.ts.map +1 -0
- package/dist/client-generator.js +108 -0
- package/dist/client-generator.js.map +1 -0
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +2 -2
- package/dist/commands/functions.js.map +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 +36 -29
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +14 -13
- package/dist/commands/push.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/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +19 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/db-port.d.ts +36 -0
- package/dist/db-port.d.ts.map +1 -0
- package/dist/db-port.js +90 -0
- package/dist/db-port.js.map +1 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +108 -13
- package/dist/dev-compose.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/functions-context-refresh.d.ts +4 -0
- package/dist/functions-context-refresh.d.ts.map +1 -0
- package/dist/functions-context-refresh.js +26 -0
- package/dist/functions-context-refresh.js.map +1 -0
- package/dist/functions-deno-types.d.ts +9 -0
- package/dist/functions-deno-types.d.ts.map +1 -1
- package/dist/functions-deno-types.js +21 -1
- package/dist/functions-deno-types.js.map +1 -1
- package/dist/gitignore.d.ts +3 -1
- package/dist/gitignore.d.ts.map +1 -1
- package/dist/gitignore.js +35 -6
- 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/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +17 -8
- package/dist/schema-ast-v2.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 +10 -2
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +73 -6
- package/dist/self-host-compose.js.map +1 -1
- package/dist/strict.d.ts +29 -0
- package/dist/strict.d.ts.map +1 -0
- package/dist/strict.js +37 -0
- package/dist/strict.js.map +1 -0
- package/dist/studio-dev-server.d.ts +12 -2
- package/dist/studio-dev-server.d.ts.map +1 -1
- package/dist/studio-dev-server.js +12 -2
- package/dist/studio-dev-server.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +81 -2
- package/dist/type-extractor.js.map +1 -1
- package/dist/type-generation.d.ts +43 -0
- package/dist/type-generation.d.ts.map +1 -1
- package/dist/type-generation.js +93 -2
- package/dist/type-generation.js.map +1 -1
- package/package.json +2 -2
- package/src/api-config-cache.ts +30 -0
- package/src/augmentation-generator.ts +142 -26
- package/src/cli-version-embedded.ts +1 -1
- package/src/client-generator.ts +122 -0
- package/src/commands/functions.ts +5 -2
- package/src/commands/generate.ts +7 -3
- package/src/commands/init.ts +41 -31
- package/src/commands/push.ts +14 -15
- package/src/commands/seed.ts +294 -44
- package/src/commands/update.ts +20 -1
- package/src/db-port.ts +99 -0
- package/src/dev-compose.ts +116 -16
- package/src/engine-client.ts +52 -3
- package/src/engine-floor.ts +100 -9
- package/src/functions-context-refresh.ts +25 -0
- package/src/functions-deno-types.ts +24 -1
- package/src/gitignore.ts +38 -10
- package/src/hooks-generator.ts +21 -6
- package/src/schema-ast-v2.ts +17 -8
- 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 +74 -6
- package/src/strict.ts +40 -0
- package/src/studio-dev-server.ts +12 -2
- package/src/type-extractor.ts +109 -2
- package/src/type-generation.ts +111 -2
- package/tests/access-composition.test.ts +123 -0
- package/tests/ast-derived-outputs.test.ts +92 -0
- package/tests/augmentation-generator.test.ts +273 -7
- package/tests/client-generator.test.ts +103 -0
- package/tests/db-port.test.ts +128 -0
- package/tests/engine-floor.test.ts +93 -0
- package/tests/external-database-compose.test.ts +8 -1
- package/tests/fixtures/seed_golden_ir.json +186 -0
- package/tests/functions-context-refresh.test.ts +86 -0
- package/tests/gitignore-secrets.test.ts +90 -0
- package/tests/runtime-contract.test.ts +146 -8
- package/tests/seed-connection.test.ts +158 -0
- package/tests/seed-ir.test.ts +436 -0
- package/tests/strict.test.ts +78 -0
- package/tests/type-extractor.test.ts +34 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/commands/init.ts
CHANGED
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
ensureComponentBinaries,
|
|
15
15
|
reportComponentBinaryFailures,
|
|
16
16
|
} from "../ensure-component-binaries.js"
|
|
17
|
-
import {
|
|
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",
|
|
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": "
|
|
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 {
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
-
|
|
1361
|
-
|
|
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
|
-
|
|
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
|
package/src/commands/push.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
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
|
}
|
package/src/commands/update.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|