@supatype/cli 0.3.1 → 0.3.2
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 +169 -161
- package/.turbo/turbo-typecheck.log +1 -1
- 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 +63 -18
- 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/init.d.ts.map +1 -1
- package/dist/commands/init.js +7 -10
- 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/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/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 +28 -6
- package/dist/gitignore.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/self-host-compose.d.ts +10 -2
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +67 -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 +8 -2
- package/dist/type-extractor.js.map +1 -1
- package/dist/type-generation.d.ts +14 -0
- package/dist/type-generation.d.ts.map +1 -1
- package/dist/type-generation.js +41 -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 +75 -18
- 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/init.ts +10 -10
- package/src/commands/push.ts +14 -15
- package/src/commands/update.ts +20 -1
- package/src/db-port.ts +99 -0
- package/src/dev-compose.ts +116 -16
- package/src/functions-context-refresh.ts +25 -0
- package/src/functions-deno-types.ts +24 -1
- package/src/gitignore.ts +31 -10
- package/src/schema-ast-v2.ts +17 -8
- package/src/self-host-compose.ts +68 -6
- package/src/strict.ts +40 -0
- package/src/studio-dev-server.ts +12 -2
- package/src/type-extractor.ts +8 -2
- package/src/type-generation.ts +47 -2
- package/tests/ast-derived-outputs.test.ts +92 -0
- package/tests/augmentation-generator.test.ts +127 -1
- package/tests/client-generator.test.ts +103 -0
- package/tests/db-port.test.ts +128 -0
- package/tests/external-database-compose.test.ts +8 -1
- package/tests/functions-context-refresh.test.ts +86 -0
- package/tests/gitignore-secrets.test.ts +90 -0
- package/tests/runtime-contract.test.ts +115 -2
- package/tests/strict.test.ts +78 -0
- package/tests/type-extractor.test.ts +34 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/dev-compose.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* `supatype dev` when `provider: docker`, full self-host Compose stack (Kong gateway).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { writeAstDerivedOutputs } from "./type-generation.js"
|
|
5
6
|
import { withPublishing } from "./model-versioning.js"
|
|
6
7
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
7
8
|
import { homedir } from "node:os"
|
|
@@ -29,6 +30,10 @@ import { handleComposeProjectRename } from "./compose-rename.js"
|
|
|
29
30
|
import { recoverStaleDevSession, writeDevSessionLock } from "./dev-session-lock.js"
|
|
30
31
|
import { endDevSession, startDevSession } from "./dev-session.js"
|
|
31
32
|
import { ensureDevApiConfig } from "./ensure-dev-api-config.js"
|
|
33
|
+
import { cacheSeedingNotes } from "./api-config-cache.js"
|
|
34
|
+
import { refreshFunctionsContext } from "./functions-context-refresh.js"
|
|
35
|
+
import { DB_PORT_ENV, syncDatabaseUrlPort } from "./db-port.js"
|
|
36
|
+
import { degraded } from "./strict.js"
|
|
32
37
|
import {
|
|
33
38
|
COMPOSE_PINNED_IMAGE_ENV_KEYS,
|
|
34
39
|
composeDockerImageEnv,
|
|
@@ -374,7 +379,16 @@ async function waitComposeHealthy(paths: SelfHostComposePaths, cwd: string, maxM
|
|
|
374
379
|
while (Date.now() < deadline) {
|
|
375
380
|
const ready = spawnSync(
|
|
376
381
|
"docker",
|
|
377
|
-
|
|
382
|
+
// Over TCP, deliberately, not the Unix socket.
|
|
383
|
+
//
|
|
384
|
+
// On a first run the Postgres entrypoint starts a temporary server with `listen_addresses=''`
|
|
385
|
+
// to run the init scripts, then shuts it down and starts the real one. That temporary server
|
|
386
|
+
// answers on the socket, so a socket `pg_isready` reports ready during init and the very next
|
|
387
|
+
// thing to connect meets "the database system is shutting down". It is why the initial push
|
|
388
|
+
// failed three times in a row on a clean machine while a warm one was fine.
|
|
389
|
+
//
|
|
390
|
+
// TCP is the distinction the entrypoint itself draws: the init server does not listen on it.
|
|
391
|
+
[...baseArgs, "exec", "-T", "db", "pg_isready", "-h", "127.0.0.1", "-U", "supatype_admin"],
|
|
378
392
|
{ cwd: composeDir, encoding: "utf8" },
|
|
379
393
|
)
|
|
380
394
|
if (ready.status === 0) return
|
|
@@ -594,6 +608,12 @@ async function refreshSchemaArtifacts(
|
|
|
594
608
|
// validator silently not firing means a write the schema says is checked is accepted with a 201.
|
|
595
609
|
const hooksModule = writeHooksModule(cwd, hooksPathFromProject(config, cwd), ast)
|
|
596
610
|
if (hooksModule !== null) console.log(`[supatype] Hook handler types written to ${hooksModule}`)
|
|
611
|
+
// And the context module, on the same terms. `init` and `functions new` wrote it and nothing
|
|
612
|
+
// else did, so a project that already had functions when the two-argument contract landed never
|
|
613
|
+
// received one: its handlers had no `FunctionContext` to import.
|
|
614
|
+
if (refreshFunctionsContext(cwd, config)) {
|
|
615
|
+
console.log("[supatype] Function context type written to functions/_shared/context.ts")
|
|
616
|
+
}
|
|
597
617
|
if (syncManifestHooks(cwd, ast)) {
|
|
598
618
|
console.log("[supatype] Hook and validator maps written to .supatype/manifest.json")
|
|
599
619
|
}
|
|
@@ -604,6 +624,24 @@ async function refreshSchemaArtifacts(
|
|
|
604
624
|
console.log(`[supatype] Row cache switched ${rowCache} in .env (pg_keyspace Mode B).`)
|
|
605
625
|
}
|
|
606
626
|
|
|
627
|
+
// SUPATYPE_DB_PORT moves what compose binds; DATABASE_URL is what seeds and psql use. Nothing
|
|
628
|
+
// reconciled them, so a project moved off 5432 to avoid a clash came up perfectly and then
|
|
629
|
+
// failed on `npm run seed` with ECONNREFUSED against a port nothing was listening on.
|
|
630
|
+
const dbPort = syncDatabaseUrlPort(cwd)
|
|
631
|
+
if (dbPort !== null) {
|
|
632
|
+
console.log(
|
|
633
|
+
`[supatype] DATABASE_URL port ${dbPort.from} -> ${dbPort.to} in .env, ` +
|
|
634
|
+
`to match ${DB_PORT_ENV}.`,
|
|
635
|
+
)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// The declaration is a ceiling and the allowlist is what is actually on, and only `push` ever
|
|
639
|
+
// seeded the allowlist. So a stack brought up with `supatype dev` alone had every model's
|
|
640
|
+
// `cache` block declared and nothing switched on: `.cache({ server: true })` answered BYPASS,
|
|
641
|
+
// and the only way to find out why was to open Studio and tick a box the schema had already
|
|
642
|
+
// justified.
|
|
643
|
+
for (const note of cacheSeedingNotes(cwd, ast)) console.log(`[supatype] ${note}`)
|
|
644
|
+
|
|
607
645
|
try {
|
|
608
646
|
await ensureEngine()
|
|
609
647
|
} catch (err) {
|
|
@@ -622,15 +660,19 @@ async function refreshSchemaArtifacts(
|
|
|
622
660
|
adminConfigPath,
|
|
623
661
|
).catch(() => false)
|
|
624
662
|
if (wrote) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
663
|
+
// Reported through `degraded` rather than warned: the in-compose engine writes the admin
|
|
664
|
+
// config but never reaches the type generation below, so a run that takes this path has
|
|
665
|
+
// stale generated types and says so only in a line that scrolls past. In CI that is the
|
|
666
|
+
// difference between testing the host engine path and never touching it.
|
|
667
|
+
degraded(
|
|
668
|
+
"Host engine unavailable, used the in-compose engine instead",
|
|
669
|
+
(err as Error).message,
|
|
628
670
|
)
|
|
629
671
|
return
|
|
630
672
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
` +
|
|
673
|
+
degraded(
|
|
674
|
+
"Host engine unavailable, admin config and types not refreshed",
|
|
675
|
+
`${(err as Error).message}\n` +
|
|
634
676
|
"[supatype] Studio will report no schema until this succeeds, even though the schema is " +
|
|
635
677
|
"applied. Retry with the stack up, or run `supatype push`.",
|
|
636
678
|
)
|
|
@@ -660,6 +702,23 @@ async function refreshSchemaArtifacts(
|
|
|
660
702
|
}
|
|
661
703
|
}
|
|
662
704
|
|
|
705
|
+
// Needs no engine, so it is written whether or not the types step above ran or succeeded. This
|
|
706
|
+
// is what `dev` never did: a project developed entirely through `supatype dev` received neither
|
|
707
|
+
// `index.d.ts`, which is what makes `createClient` typed without a generic, nor the field kinds
|
|
708
|
+
// the client reads to keep bigInt, decimal and money columns exact.
|
|
709
|
+
try {
|
|
710
|
+
for (const message of writeAstDerivedOutputs({
|
|
711
|
+
cwd,
|
|
712
|
+
ast,
|
|
713
|
+
...(config.output?.types !== undefined && { typesPath: config.output.types }),
|
|
714
|
+
...(config.output?.client !== undefined && { clientPath: config.output.client }),
|
|
715
|
+
})) {
|
|
716
|
+
console.log(`[supatype] ${message}`)
|
|
717
|
+
}
|
|
718
|
+
} catch (err) {
|
|
719
|
+
console.warn(`[supatype] Generated output failed: ${(err as Error).message}`)
|
|
720
|
+
}
|
|
721
|
+
|
|
663
722
|
try {
|
|
664
723
|
const admin = withAdminRoles(await engineRequest<unknown>("/admin", { ast }), config)
|
|
665
724
|
restoreSystemRelationTargets(admin, ast)
|
|
@@ -952,7 +1011,16 @@ export async function diffSchemaDocker(cwd: string, config: SupatypeProjectConfi
|
|
|
952
1011
|
throw new Error(detail || `Engine schema diff failed (exit ${result.status})`)
|
|
953
1012
|
}
|
|
954
1013
|
if (!result.diff) {
|
|
955
|
-
|
|
1014
|
+
// The engine exited 0 and produced nothing this could parse, which is a different failure from
|
|
1015
|
+
// the engine exiting non-zero and is the harder one to act on. "Engine diff returned no result"
|
|
1016
|
+
// named only the symptom, and running the same compose command by hand gave a clear error in
|
|
1017
|
+
// one go, so the message was the only thing standing between the reader and the answer.
|
|
1018
|
+
const output = (filterComposeNoise(result.output) || result.output || "").trim()
|
|
1019
|
+
throw new Error(
|
|
1020
|
+
output === ""
|
|
1021
|
+
? "The schema engine exited 0 and printed nothing. Run `supatype self-host compose run --rm schema-engine diff` to see what it does."
|
|
1022
|
+
: `The schema engine exited 0 but its output could not be parsed as a diff:\n${output}`,
|
|
1023
|
+
)
|
|
956
1024
|
}
|
|
957
1025
|
return result.diff
|
|
958
1026
|
}
|
|
@@ -1159,19 +1227,26 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
|
|
|
1159
1227
|
}
|
|
1160
1228
|
}
|
|
1161
1229
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1230
|
+
// A service whose image is pinned in `.env` is recreated once the stack is up, so a contributor
|
|
1231
|
+
// can point at a build from this checkout rather than the published tag.
|
|
1232
|
+
//
|
|
1233
|
+
// Storage was missing from this list, which is why a storage bug survived: there was no way to
|
|
1234
|
+
// run the suite against a local storage build, so every integration run tested the last release
|
|
1235
|
+
// no matter what the working tree said.
|
|
1236
|
+
for (const service of ["realtime", "storage"] as const) {
|
|
1237
|
+
const variable = `SUPATYPE_${service.toUpperCase()}_IMAGE`
|
|
1238
|
+
if (readEnvValue(cwd, variable, "").trim() === "") continue
|
|
1239
|
+
console.log(`[supatype] Recreating ${service} with pinned image...`)
|
|
1240
|
+
const status = runDockerCompose(
|
|
1166
1241
|
paths.composePath,
|
|
1167
|
-
["up", "-d", "--force-recreate", "--no-deps",
|
|
1242
|
+
["up", "-d", "--force-recreate", "--no-deps", service],
|
|
1168
1243
|
cwd,
|
|
1169
1244
|
project,
|
|
1170
1245
|
{ quiet: true, brand: devBrand },
|
|
1171
1246
|
)
|
|
1172
|
-
if (
|
|
1247
|
+
if (status !== 0) {
|
|
1173
1248
|
endDevSession()
|
|
1174
|
-
exitComposeFailed(
|
|
1249
|
+
exitComposeFailed(status, `Could not recreate the ${service} container.`, devBrand)
|
|
1175
1250
|
}
|
|
1176
1251
|
}
|
|
1177
1252
|
|
|
@@ -1339,8 +1414,17 @@ function reconcileAuthenticatorPassword(
|
|
|
1339
1414
|
},
|
|
1340
1415
|
)
|
|
1341
1416
|
if (result.status !== 0) {
|
|
1417
|
+
// The cause, not just the consequence. This warned that REST "may answer 502" and said nothing
|
|
1418
|
+
// about why, so when the supabucks e2e then failed its only API assertion with a 502, the
|
|
1419
|
+
// warning twenty lines earlier read as unrelated noise. A message that predicts a failure and
|
|
1420
|
+
// withholds its reason costs more than one that says nothing.
|
|
1421
|
+
const detail = [result.stderr, result.error?.message]
|
|
1422
|
+
.map((s) => (s ?? "").trim())
|
|
1423
|
+
.filter((s) => s.length > 0)
|
|
1424
|
+
.join("\n")
|
|
1342
1425
|
console.warn(
|
|
1343
|
-
|
|
1426
|
+
`[supatype] Could not set the authenticator password, so the REST API will answer 502.\n` +
|
|
1427
|
+
(detail === "" ? "[supatype] docker gave no output." : detail),
|
|
1344
1428
|
)
|
|
1345
1429
|
}
|
|
1346
1430
|
}
|
|
@@ -1447,6 +1531,22 @@ export async function generateViaComposeEngine(
|
|
|
1447
1531
|
}
|
|
1448
1532
|
}
|
|
1449
1533
|
|
|
1534
|
+
// The same AST-derived files as the host path. This is the fallback used when the host engine
|
|
1535
|
+
// could not be fetched, and these need no engine at all, so there is no reason for a project on
|
|
1536
|
+
// this path to end up with fewer generated files than one on the other.
|
|
1537
|
+
try {
|
|
1538
|
+
for (const message of writeAstDerivedOutputs({
|
|
1539
|
+
cwd,
|
|
1540
|
+
ast,
|
|
1541
|
+
...(config.output?.types !== undefined && { typesPath: config.output.types }),
|
|
1542
|
+
...(config.output?.client !== undefined && { clientPath: config.output.client }),
|
|
1543
|
+
})) {
|
|
1544
|
+
console.log(`[supatype] ${message} (in-compose engine).`)
|
|
1545
|
+
}
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
console.warn(`[supatype] Generated output failed: ${(err as Error).message}`)
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1450
1550
|
const adminOut = await runComposeEngineGenerator(paths, cwd, composeProject, config, ["admin"])
|
|
1451
1551
|
if (!adminOut) return false
|
|
1452
1552
|
const parsed = parseEngineJsonOutput<unknown>(adminOut)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep `functions/_shared/context.ts` current on a project that already exists.
|
|
3
|
+
*
|
|
4
|
+
* The module is generated by `ensureFunctionsDenoTypes`, which `init` and `supatype functions new`
|
|
5
|
+
* call and nothing else did. So it reached new projects and new functions, and never reached a
|
|
6
|
+
* project that already had functions when the two-argument handler contract landed: those keep
|
|
7
|
+
* `(req)` handlers with no `FunctionContext` to import, while the generated file asserts
|
|
8
|
+
* "Regenerated by the CLI. Edits are overwritten."
|
|
9
|
+
*
|
|
10
|
+
* A claim about a file nothing maintains is worse than no claim, because it tells people not to
|
|
11
|
+
* edit it and not to expect it to change.
|
|
12
|
+
*
|
|
13
|
+
* Only when the project has a functions directory: writing `_shared/` into a project that has no
|
|
14
|
+
* functions would create the thing it describes out of nothing.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from "node:fs"
|
|
17
|
+
import { ensureFunctionsDenoTypes } from "./functions-deno-types.js"
|
|
18
|
+
import { preferredFunctionsPathFromProject, type SupatypeProjectConfig } from "./project-config.js"
|
|
19
|
+
|
|
20
|
+
/** Rewrite the context module. True when this project has functions and one was written. */
|
|
21
|
+
export function refreshFunctionsContext(cwd: string, config: SupatypeProjectConfig): boolean {
|
|
22
|
+
const functionsDir = preferredFunctionsPathFromProject(config, cwd)
|
|
23
|
+
if (!existsSync(functionsDir)) return false
|
|
24
|
+
return ensureFunctionsDenoTypes(cwd, functionsDir).wroteContextModule
|
|
25
|
+
}
|
|
@@ -83,6 +83,23 @@ declare namespace Deno {
|
|
|
83
83
|
* directory, but `deno check` builds its own type graph and does not look at either, so a function
|
|
84
84
|
* naming an ambient type fails the check the runtime itself would run. An import works in both.
|
|
85
85
|
*/
|
|
86
|
+
/**
|
|
87
|
+
* The `_shared/README.md` both scaffolds write.
|
|
88
|
+
*
|
|
89
|
+
* One definition because there were two, identical, as string literals in `init.ts` and
|
|
90
|
+
* `functions.ts`. It lives here rather than in either because this module already owns what goes
|
|
91
|
+
* into `_shared/`, and because the next edit to this text would otherwise change it in one scaffold
|
|
92
|
+
* and not the other.
|
|
93
|
+
*/
|
|
94
|
+
export function sharedFunctionsReadmeSource(): string {
|
|
95
|
+
return (
|
|
96
|
+
"# Shared Code\n\n" +
|
|
97
|
+
"Files in `_shared/` are available to all functions via relative imports.\n" +
|
|
98
|
+
"This directory is not deployed as a function.\n\n" +
|
|
99
|
+
"Example: `import { sendEmail } from '../_shared/email.ts'`\n"
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
86
103
|
export function functionsContextModuleSource(): string {
|
|
87
104
|
return `/**
|
|
88
105
|
* What a function is told about the call it is serving.
|
|
@@ -94,7 +111,13 @@ export function functionsContextModuleSource(): string {
|
|
|
94
111
|
*
|
|
95
112
|
* Passing them in removes the global, so functions run concurrently.
|
|
96
113
|
*
|
|
97
|
-
*
|
|
114
|
+
* Rewritten by supatype init, supatype functions new, supatype push and supatype dev.
|
|
115
|
+
* Edits are overwritten.
|
|
116
|
+
*
|
|
117
|
+
* Named explicitly because "regenerated by the CLI" was true of only the first two: a project that
|
|
118
|
+
* already had functions when this contract arrived never received the module at all, so its
|
|
119
|
+
* handlers had no type to import while this line told its author the file was maintained. Say
|
|
120
|
+
* which commands, so the claim can be checked rather than believed.
|
|
98
121
|
*/
|
|
99
122
|
export interface FunctionContext {
|
|
100
123
|
/** Unique to this invocation. Every log line it writes carries the same value. */
|
package/src/gitignore.ts
CHANGED
|
@@ -2,12 +2,35 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
|
2
2
|
import { resolve } from "node:path"
|
|
3
3
|
|
|
4
4
|
export const SUPATYPE_GITIGNORE_MARKER = "# Supatype, local runtime (contains secrets in link.json)"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every path Supatype writes that holds a secret, in one place.
|
|
8
|
+
*
|
|
9
|
+
* One list because there were two emitters, this one and `init`'s own template, and they had
|
|
10
|
+
* already drifted apart on content. Two lists of the same thing drift again, and the half that
|
|
11
|
+
* drifts is discovered by a key reaching a public repository.
|
|
12
|
+
*
|
|
13
|
+
* `.env.local` and `.env.*.local` are here because `.env` does not cover them. That pattern matches
|
|
14
|
+
* a file named exactly `.env`, while `supatype functions new` writes `functions/.env.local` with
|
|
15
|
+
* "These are NOT committed to git" at the top of it, and the worker reads
|
|
16
|
+
* `.env.<function>.local` beside that. Verified with `git check-ignore`: under `.env` alone both
|
|
17
|
+
* were tracked; with these two they are ignored at any depth, and no source file is caught.
|
|
18
|
+
*/
|
|
19
|
+
const SUPATYPE_IGNORED_PATHS = [
|
|
20
|
+
".env",
|
|
21
|
+
".env.local",
|
|
22
|
+
".env.*.local",
|
|
23
|
+
".supatype/",
|
|
24
|
+
"supatype.local.config.ts",
|
|
25
|
+
"supatype.local.config.js",
|
|
26
|
+
"supatype.local.config.mjs",
|
|
27
|
+
] as const
|
|
28
|
+
|
|
29
|
+
/** The paths above, for a scaffold that composes its own `.gitignore` around them. */
|
|
30
|
+
export const SUPATYPE_GITIGNORE_PATHS: readonly string[] = SUPATYPE_IGNORED_PATHS
|
|
31
|
+
|
|
5
32
|
export const SUPATYPE_GITIGNORE_BLOCK = `${SUPATYPE_GITIGNORE_MARKER}
|
|
6
|
-
.
|
|
7
|
-
.supatype/
|
|
8
|
-
supatype.local.config.ts
|
|
9
|
-
supatype.local.config.js
|
|
10
|
-
supatype.local.config.mjs
|
|
33
|
+
${SUPATYPE_IGNORED_PATHS.join("\n")}
|
|
11
34
|
`
|
|
12
35
|
|
|
13
36
|
export function isSupatypeGitignored(cwd: string): boolean {
|
|
@@ -26,11 +49,9 @@ export function ensureSupatypeGitignore(cwd: string, opts?: { silent?: boolean }
|
|
|
26
49
|
const next = content.endsWith("\n") ? content : `${content}\n`
|
|
27
50
|
writeFileSync(gitignorePath, `${next}\n${SUPATYPE_GITIGNORE_BLOCK}`, "utf8")
|
|
28
51
|
} else {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"utf8",
|
|
33
|
-
)
|
|
52
|
+
// `.env` is not repeated here: the block below already carries it, along with the
|
|
53
|
+
// `.env.local` patterns it does not cover.
|
|
54
|
+
writeFileSync(gitignorePath, `node_modules/\ndist/\n${SUPATYPE_GITIGNORE_BLOCK}`, "utf8")
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
if (!opts?.silent) {
|
package/src/schema-ast-v2.ts
CHANGED
|
@@ -227,6 +227,15 @@ export interface ExtractedSchemaAstV2 {
|
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The Postgres type each field kind maps to, as published in the AST.
|
|
232
|
+
*
|
|
233
|
+
* A contract, not a hint: consumers generate parsers and casts from it. `money` and `decimal` said
|
|
234
|
+
* `TEXT` here while the engine creates `NUMERIC(19,4)` and `NUMERIC`, so anyone reading this wrote
|
|
235
|
+
* code that rejected every value the column returns. The engine's own `default_pg_type` agreed
|
|
236
|
+
* with the mistake for `money`, and was already right for `decimal`, which is what made it look
|
|
237
|
+
* considered.
|
|
238
|
+
*/
|
|
230
239
|
const DEFAULT_DB_BY_KIND: Partial<Record<FieldKind, Partial<DbFieldAnnotations>>> = {
|
|
231
240
|
text: { pgType: "TEXT" },
|
|
232
241
|
richText: { pgType: "JSONB" },
|
|
@@ -244,17 +253,17 @@ const DEFAULT_DB_BY_KIND: Partial<Record<FieldKind, Partial<DbFieldAnnotations>>
|
|
|
244
253
|
slug: { pgType: "TEXT" },
|
|
245
254
|
enum: { pgType: "TEXT" },
|
|
246
255
|
json: { pgType: "JSONB" },
|
|
247
|
-
decimal: { pgType: "
|
|
256
|
+
decimal: { pgType: "NUMERIC" },
|
|
248
257
|
bytes: { pgType: "BYTEA" },
|
|
249
258
|
serial: { pgType: "SERIAL" },
|
|
250
259
|
bigSerial: { pgType: "BIGSERIAL" },
|
|
251
|
-
money: { pgType: "
|
|
252
|
-
ip: { pgType: "
|
|
253
|
-
cidr: { pgType: "
|
|
254
|
-
macaddr: { pgType: "
|
|
255
|
-
xml: { pgType: "
|
|
256
|
-
tsQuery: { pgType: "
|
|
257
|
-
tsVector: { pgType: "
|
|
260
|
+
money: { pgType: "NUMERIC(19,4)" },
|
|
261
|
+
ip: { pgType: "INET" },
|
|
262
|
+
cidr: { pgType: "CIDR" },
|
|
263
|
+
macaddr: { pgType: "MACADDR" },
|
|
264
|
+
xml: { pgType: "XML" },
|
|
265
|
+
tsQuery: { pgType: "TSQUERY" },
|
|
266
|
+
tsVector: { pgType: "TSVECTOR" },
|
|
258
267
|
color: { pgType: "TEXT" },
|
|
259
268
|
array: { pgType: "ARRAY" },
|
|
260
269
|
image: { pgType: "JSONB" },
|
package/src/self-host-compose.ts
CHANGED
|
@@ -17,7 +17,8 @@ import {
|
|
|
17
17
|
import { hasEngineOverride, hasStudioOverride, pinnedVersion, fetchLatestVersion, VERSION_PIN_LOCAL } from "./binary-cache.js"
|
|
18
18
|
import { buildKongDeclarative } from "./kong-config.js"
|
|
19
19
|
import { keyspaceInPostgres } from "./cache-provider.js"
|
|
20
|
-
import {
|
|
20
|
+
import { STUDIO_DEV_PORT } from "./studio-dev-server.js"
|
|
21
|
+
import { hasEnvValue, readEnvFile } from "./env-file.js"
|
|
21
22
|
import { fieldMaskingTierFromProject, type FieldMaskingTier } from "./field-masking-tier.js"
|
|
22
23
|
import { projectHasVersionedModels } from "./model-versioning.js"
|
|
23
24
|
|
|
@@ -285,8 +286,16 @@ function postgrestDatabaseUrl(config: SupatypeProjectConfig): string {
|
|
|
285
286
|
return `postgresql://authenticator:${password}@${parsed.hostname}${port}${parsed.pathname}${parsed.search}`
|
|
286
287
|
}
|
|
287
288
|
|
|
288
|
-
/**
|
|
289
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Host Vite dev server as seen from Kong inside Docker Compose.
|
|
291
|
+
*
|
|
292
|
+
* Derived from STUDIO_DEV_PORT rather than repeating the number, because the two are one decision.
|
|
293
|
+
* Held separately they drift, and the drift is invisible: Vite binds the new port, Kong keeps
|
|
294
|
+
* proxying to the old one, and `/studio/` serves whatever else happens to be listening there. On
|
|
295
|
+
* the machine this was found, that was an unrelated Next.js app, and every Studio view failed with
|
|
296
|
+
* a missing sign-in form rather than anything naming a port.
|
|
297
|
+
*/
|
|
298
|
+
export const COMPOSE_STUDIO_HOST_URL = `http://host.docker.internal:${STUDIO_DEV_PORT}`
|
|
290
299
|
|
|
291
300
|
/** Studio container: always Docker Hub unless SUPATYPE_STUDIO_IMAGE is set in .env. */
|
|
292
301
|
function studioServiceBlock(): string {
|
|
@@ -395,7 +404,12 @@ ${studioService}
|
|
|
395
404
|
: ` - server
|
|
396
405
|
- studio
|
|
397
406
|
- control-plane`
|
|
398
|
-
|
|
407
|
+
// In dev the database is published only when something on the host needs to reach it, which is
|
|
408
|
+
// normally a host engine build. A project that names a port is asking for one too: without this,
|
|
409
|
+
// `SUPATYPE_DEV_DB_PORT` was honoured for the number and ignored for whether the port existed,
|
|
410
|
+
// so a seed connecting over TCP got ECONNREFUSED and nothing said why.
|
|
411
|
+
const dbPortRequested = hasEnvValue(cwd, "SUPATYPE_DEV_DB_PORT")
|
|
412
|
+
const publishDbToHost = !devLocal || hasEngineOverride(config) || dbPortRequested
|
|
399
413
|
const dbPorts = publishDbToHost
|
|
400
414
|
? devLocal
|
|
401
415
|
? ` ports:
|
|
@@ -437,6 +451,15 @@ ${studioService}
|
|
|
437
451
|
volumes:
|
|
438
452
|
- storage-data:/data
|
|
439
453
|
- ${SEAWEED_CONFIG_MOUNT}:/etc/seaweedfs/s3.json:ro
|
|
454
|
+
healthcheck:
|
|
455
|
+
# Any HTTP status line means the S3 endpoint is listening, which is the whole question.
|
|
456
|
+
# Success cannot be "HTTP 200": an unauthenticated GET on the root answers 403 by design,
|
|
457
|
+
# because the anonymous identity is deliberately absent from s3.json.
|
|
458
|
+
test: ["CMD-SHELL", "wget -q -S -O /dev/null http://127.0.0.1:8333 2>&1 | grep -q 'HTTP/'"]
|
|
459
|
+
interval: 3s
|
|
460
|
+
timeout: 3s
|
|
461
|
+
retries: 20
|
|
462
|
+
start_period: 5s
|
|
440
463
|
${seaweedPorts}`
|
|
441
464
|
const kongTlsEnv = tlsEnabled
|
|
442
465
|
? ` KONG_PROXY_LISTEN: "0.0.0.0:8000, 0.0.0.0:8443 ssl"
|
|
@@ -491,6 +514,21 @@ ${keyspaceInPg ? "" : " valkey-data:\n"}`
|
|
|
491
514
|
`
|
|
492
515
|
const dbDependency = external ? "" : ` depends_on:\n${dbDependencyClause}`
|
|
493
516
|
|
|
517
|
+
// Storage waits for the object store, not only the database.
|
|
518
|
+
//
|
|
519
|
+
// seaweedfs had no healthcheck and nothing depended on it, so compose started it alongside
|
|
520
|
+
// storage rather than before it. Storage would then accept a bucket creation before seaweedfs
|
|
521
|
+
// was listening, and every bucket in the schema failed with `connect ECONNREFUSED <ip>:8333`
|
|
522
|
+
// while the metadata row was written anyway. Measured on a live stack: seaweedfs started twelve
|
|
523
|
+
// minutes after storage with RestartCount 0, so it was ordered late rather than crashing.
|
|
524
|
+
//
|
|
525
|
+
// A later push succeeds, which is exactly what made it read as an intermittent mystery. It
|
|
526
|
+
// lands on the first push after a stack comes up, which is the first push a new user ever runs.
|
|
527
|
+
const storageDependency = ` depends_on:
|
|
528
|
+
${dbDependencyClause} seaweedfs:
|
|
529
|
+
condition: service_healthy
|
|
530
|
+
`
|
|
531
|
+
|
|
494
532
|
// Realtime, omitted entirely when the project has turned it off.
|
|
495
533
|
//
|
|
496
534
|
// Not started-and-disabled: the service degrades gracefully on its own (it reports the reason on
|
|
@@ -676,7 +714,7 @@ ${dbDependency}
|
|
|
676
714
|
S3_ACCESS_KEY: ${OBJECT_STORE_ACCESS_KEY}
|
|
677
715
|
S3_SECRET_KEY: ${OBJECT_STORE_SECRET_KEY}
|
|
678
716
|
S3_FORCE_PATH_STYLE: "true"
|
|
679
|
-
${
|
|
717
|
+
${storageDependency}
|
|
680
718
|
functions-worker:
|
|
681
719
|
image: \${SUPATYPE_FUNCTIONS_WORKER_IMAGE:-supatype/functions-worker:latest}
|
|
682
720
|
expose:
|
|
@@ -705,6 +743,18 @@ ${dbDependency}
|
|
|
705
743
|
# each one able to read past every access rule in the schema.
|
|
706
744
|
SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY:-}
|
|
707
745
|
SUPATYPE_SERVICE_ROLE_ROUTES: "${serviceRoleRoutes(config).join(",")}"
|
|
746
|
+
# A direct database connection for functions, off unless asked for.
|
|
747
|
+
#
|
|
748
|
+
# The worker reads SUPATYPE_DB_URL and exposes it as \`ctx.dbUrl\`, and nothing here ever set
|
|
749
|
+
# it, so the field was permanently undefined on self-host and a function reaching for it got
|
|
750
|
+
# no value and no explanation.
|
|
751
|
+
#
|
|
752
|
+
# Deliberately its own variable rather than the project's DATABASE_URL. That one is the owner
|
|
753
|
+
# DSN every service already uses, and wiring it through by default would hand every function
|
|
754
|
+
# a connection that bypasses access rules, field masking and model hooks, none of which live
|
|
755
|
+
# in the database. Naming a separate variable makes it a decision: set it to the owner URL
|
|
756
|
+
# and accept that, or to a role you restricted yourself.
|
|
757
|
+
SUPATYPE_DB_URL: \${SUPATYPE_FUNCTIONS_DB_URL:-}
|
|
708
758
|
STRIPE_SECRET_KEY: \${STRIPE_SECRET_KEY:-}
|
|
709
759
|
STRIPE_WEBHOOK_SECRET: \${STRIPE_WEBHOOK_SECRET:-}
|
|
710
760
|
SITE_URL: \${SITE_URL:-\${API_EXTERNAL_URL:-${externalUrlFallback}}}
|
|
@@ -730,6 +780,12 @@ ${realtimeBlock}
|
|
|
730
780
|
${dbDependency}
|
|
731
781
|
server:
|
|
732
782
|
image: \${SUPATYPE_SERVER_IMAGE:-\${SUPATYPE_AUTH_IMAGE:-supatype/server:latest}}
|
|
783
|
+
# host.docker.internal is a Docker Desktop name. On Linux it does not resolve unless it is
|
|
784
|
+
# mapped, so a project proxying the site or Studio to something on the host worked on macOS
|
|
785
|
+
# and Windows and failed on Linux with nothing reaching the app. host-gateway is Docker's own
|
|
786
|
+
# alias for the host, and needs 20.10, which this stack already requires.
|
|
787
|
+
extra_hosts:
|
|
788
|
+
- "host.docker.internal:host-gateway"
|
|
733
789
|
# The server runs its migrations at boot on a connection of their own,
|
|
734
790
|
# and that path does not wait out a database that is still in recovery:
|
|
735
791
|
# it exits. Waiting for db to report healthy is not enough, because
|
|
@@ -798,7 +854,7 @@ ${appEnv}
|
|
|
798
854
|
SUPATYPE_SMTP_ADMIN_EMAIL: \${SUPATYPE_SMTP_ADMIN_EMAIL:-}
|
|
799
855
|
SUPATYPE_SMTP_SENDER_NAME: \${SUPATYPE_SMTP_SENDER_NAME:-}
|
|
800
856
|
SUPATYPE_DISABLE_SIGNUP: \${DISABLE_SIGNUP:-false}
|
|
801
|
-
${devLocal ? " STUDIO_OPEN_DEV: \"1\"\n" : ""}
|
|
857
|
+
${devLocal ? " STUDIO_OPEN_DEV: \"${STUDIO_OPEN_DEV:-1}\"\n" : ""}
|
|
802
858
|
depends_on:
|
|
803
859
|
${dbDependencyClause}${keyspaceInPg ? "" : " valkey:\n condition: service_started\n"} postgrest:
|
|
804
860
|
condition: service_started
|
|
@@ -819,6 +875,12 @@ ${objectStoreBlock}
|
|
|
819
875
|
working_dir: /project
|
|
820
876
|
${dbDependency}${studioBlock}${valkeyBlock}${tlsHintComment} kong:
|
|
821
877
|
image: kong:3.6
|
|
878
|
+
# host.docker.internal is a Docker Desktop name. On Linux it does not resolve unless it is
|
|
879
|
+
# mapped, so a project proxying the site or Studio to something on the host worked on macOS
|
|
880
|
+
# and Windows and failed on Linux with nothing reaching the app. host-gateway is Docker's own
|
|
881
|
+
# alias for the host, and needs 20.10, which this stack already requires.
|
|
882
|
+
extra_hosts:
|
|
883
|
+
- "host.docker.internal:host-gateway"
|
|
822
884
|
environment:
|
|
823
885
|
KONG_DATABASE: "off"
|
|
824
886
|
KONG_DECLARATIVE_CONFIG: /etc/kong/kong.yml
|
package/src/strict.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict mode: a degraded path is a failure rather than a warning.
|
|
3
|
+
*
|
|
4
|
+
* Every fallback in this CLI exists because giving up would serve a person at a terminal worse
|
|
5
|
+
* than carrying on: the in-compose engine when the CDN cannot be reached, SHA256-only verification
|
|
6
|
+
* when no key is embedded, a bucket that could not be reached from the host but works from inside
|
|
7
|
+
* the network. Each is the right default interactively.
|
|
8
|
+
*
|
|
9
|
+
* In CI the trade runs the other way. A run that limps reports success, and the thing it quietly
|
|
10
|
+
* skipped is precisely the thing nobody tests again.
|
|
11
|
+
*
|
|
12
|
+
* This is not hypothetical. On 2026-09-24 three integration jobs failed on assertions about
|
|
13
|
+
* validators and served SPAs, and the cause in all three was an engine download refused twenty
|
|
14
|
+
* lines earlier and warned about. The same fallback made a local from-zero run look like a clean
|
|
15
|
+
* pass while the host engine path was never exercised at all.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** True when the caller has asked for degraded paths to fail. */
|
|
19
|
+
export function strict(): boolean {
|
|
20
|
+
return process.env["SUPATYPE_STRICT"] === "1"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Report a path that worked less well than intended.
|
|
25
|
+
*
|
|
26
|
+
* Throws under `SUPATYPE_STRICT=1`, so a CI job cannot pass over it. Warns otherwise, because
|
|
27
|
+
* interactively a working stack and a note beats an abort.
|
|
28
|
+
*
|
|
29
|
+
* `what` names the capability that degraded, not the error: the reader needs to know which feature
|
|
30
|
+
* they are now without. `detail` carries the cause.
|
|
31
|
+
*/
|
|
32
|
+
export function degraded(what: string, detail: string): void {
|
|
33
|
+
const message = `${what}: ${detail}`
|
|
34
|
+
if (strict()) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`${message}\nSUPATYPE_STRICT=1 is set, so a degraded path fails rather than warns.`,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
console.warn(`[supatype] ${message}`)
|
|
40
|
+
}
|
package/src/studio-dev-server.ts
CHANGED
|
@@ -2,8 +2,18 @@ import { existsSync } from "node:fs"
|
|
|
2
2
|
import { join, resolve } from "node:path"
|
|
3
3
|
import { ProcessManager } from "./process-manager.js"
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Vite dev server port when `overrides.studio` is set.
|
|
7
|
+
*
|
|
8
|
+
* Overridable because it is bound with `--strictPort`, so anything else already on 3002 does not
|
|
9
|
+
* make Vite pick another port, it makes Studio fail to start and be restarted every thirty seconds
|
|
10
|
+
* for the life of the session. 3002 is an ordinary port for a local app to be sitting on, and the
|
|
11
|
+
* only signal is a Vite stack trace in among the compose output.
|
|
12
|
+
*
|
|
13
|
+
* Only the `overrides.studio` path is affected, so this is a contributor's papercut rather than a
|
|
14
|
+
* user's: an ordinary `supatype dev` serves Studio from its container.
|
|
15
|
+
*/
|
|
16
|
+
export const STUDIO_DEV_PORT = Number(process.env["SUPATYPE_STUDIO_DEV_PORT"]) || 3002
|
|
7
17
|
|
|
8
18
|
export interface StudioDevServerOptions {
|
|
9
19
|
cwd: string
|
package/src/type-extractor.ts
CHANGED
|
@@ -1055,7 +1055,10 @@ function parseScalarType(
|
|
|
1055
1055
|
const assetOpts = parseAssetFieldOptions(typeNode.typeArguments?.[1], sourceFile)
|
|
1056
1056
|
return attachStorageFieldMeta(
|
|
1057
1057
|
scalar("file", {
|
|
1058
|
-
|
|
1058
|
+
// JSONB, not TEXT. The column holds { bucket, path }, this package's own
|
|
1059
|
+
// DEFAULT_DB_BY_KIND says JSONB, and the engine honours an explicit annotation, so
|
|
1060
|
+
// TEXT here was one code path from a text column with a JSON object written into it.
|
|
1061
|
+
db: { pgType: "JSONB" },
|
|
1059
1062
|
kernel: { bucket, ...(assetOpts.localized && { localized: true }) },
|
|
1060
1063
|
}),
|
|
1061
1064
|
bucket,
|
|
@@ -1067,7 +1070,10 @@ function parseScalarType(
|
|
|
1067
1070
|
const assetOpts = parseAssetFieldOptions(typeNode.typeArguments?.[1], sourceFile)
|
|
1068
1071
|
return attachStorageFieldMeta(
|
|
1069
1072
|
scalar("image", {
|
|
1070
|
-
|
|
1073
|
+
// JSONB, not TEXT. The column holds { bucket, path }, this package's own
|
|
1074
|
+
// DEFAULT_DB_BY_KIND says JSONB, and the engine honours an explicit annotation, so
|
|
1075
|
+
// TEXT here was one code path from a text column with a JSON object written into it.
|
|
1076
|
+
db: { pgType: "JSONB" },
|
|
1071
1077
|
kernel: { bucket, ...(assetOpts.localized && { localized: true }) },
|
|
1072
1078
|
}),
|
|
1073
1079
|
bucket,
|