@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/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)
|
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
|
+
}
|
|
@@ -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,42 @@ 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
|
+
// 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",
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
/** The paths above, for a scaffold that composes its own `.gitignore` around them. */
|
|
37
|
+
export const SUPATYPE_GITIGNORE_PATHS: readonly string[] = SUPATYPE_IGNORED_PATHS
|
|
38
|
+
|
|
5
39
|
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
|
|
40
|
+
${SUPATYPE_IGNORED_PATHS.join("\n")}
|
|
11
41
|
`
|
|
12
42
|
|
|
13
43
|
export function isSupatypeGitignored(cwd: string): boolean {
|
|
@@ -26,11 +56,9 @@ export function ensureSupatypeGitignore(cwd: string, opts?: { silent?: boolean }
|
|
|
26
56
|
const next = content.endsWith("\n") ? content : `${content}\n`
|
|
27
57
|
writeFileSync(gitignorePath, `${next}\n${SUPATYPE_GITIGNORE_BLOCK}`, "utf8")
|
|
28
58
|
} else {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"utf8",
|
|
33
|
-
)
|
|
59
|
+
// `.env` is not repeated here: the block below already carries it, along with the
|
|
60
|
+
// `.env.local` patterns it does not cover.
|
|
61
|
+
writeFileSync(gitignorePath, `node_modules/\ndist/\n${SUPATYPE_GITIGNORE_BLOCK}`, "utf8")
|
|
34
62
|
}
|
|
35
63
|
|
|
36
64
|
if (!opts?.silent) {
|
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. */
|