@supatype/cli 0.3.0 → 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.
Files changed (115) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +161 -154
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/api-config-cache.d.ts +20 -0
  5. package/dist/api-config-cache.d.ts.map +1 -1
  6. package/dist/api-config-cache.js +27 -0
  7. package/dist/api-config-cache.js.map +1 -1
  8. package/dist/augmentation-generator.d.ts.map +1 -1
  9. package/dist/augmentation-generator.js +63 -18
  10. package/dist/augmentation-generator.js.map +1 -1
  11. package/dist/cli-version-embedded.js +1 -1
  12. package/dist/client-generator.d.ts +3 -0
  13. package/dist/client-generator.d.ts.map +1 -0
  14. package/dist/client-generator.js +108 -0
  15. package/dist/client-generator.js.map +1 -0
  16. package/dist/commands/admin.d.ts.map +1 -1
  17. package/dist/commands/admin.js +21 -4
  18. package/dist/commands/admin.js.map +1 -1
  19. package/dist/commands/functions.d.ts.map +1 -1
  20. package/dist/commands/functions.js +2 -2
  21. package/dist/commands/functions.js.map +1 -1
  22. package/dist/commands/init.d.ts.map +1 -1
  23. package/dist/commands/init.js +7 -10
  24. package/dist/commands/init.js.map +1 -1
  25. package/dist/commands/keys.d.ts +35 -0
  26. package/dist/commands/keys.d.ts.map +1 -1
  27. package/dist/commands/keys.js +90 -6
  28. package/dist/commands/keys.js.map +1 -1
  29. package/dist/commands/push.d.ts.map +1 -1
  30. package/dist/commands/push.js +23 -13
  31. package/dist/commands/push.js.map +1 -1
  32. package/dist/commands/update.d.ts.map +1 -1
  33. package/dist/commands/update.js +19 -1
  34. package/dist/commands/update.js.map +1 -1
  35. package/dist/db-port.d.ts +36 -0
  36. package/dist/db-port.d.ts.map +1 -0
  37. package/dist/db-port.js +90 -0
  38. package/dist/db-port.js.map +1 -0
  39. package/dist/dev-compose.d.ts +13 -0
  40. package/dist/dev-compose.d.ts.map +1 -1
  41. package/dist/dev-compose.js +205 -18
  42. package/dist/dev-compose.js.map +1 -1
  43. package/dist/functions-context-refresh.d.ts +4 -0
  44. package/dist/functions-context-refresh.d.ts.map +1 -0
  45. package/dist/functions-context-refresh.js +26 -0
  46. package/dist/functions-context-refresh.js.map +1 -0
  47. package/dist/functions-deno-types.d.ts +9 -0
  48. package/dist/functions-deno-types.d.ts.map +1 -1
  49. package/dist/functions-deno-types.js +21 -1
  50. package/dist/functions-deno-types.js.map +1 -1
  51. package/dist/gitignore.d.ts +3 -1
  52. package/dist/gitignore.d.ts.map +1 -1
  53. package/dist/gitignore.js +28 -6
  54. package/dist/gitignore.js.map +1 -1
  55. package/dist/model-cache.d.ts +27 -0
  56. package/dist/model-cache.d.ts.map +1 -1
  57. package/dist/model-cache.js +61 -0
  58. package/dist/model-cache.js.map +1 -1
  59. package/dist/schema-ast-v2.d.ts.map +1 -1
  60. package/dist/schema-ast-v2.js +17 -8
  61. package/dist/schema-ast-v2.js.map +1 -1
  62. package/dist/self-host-compose.d.ts +10 -2
  63. package/dist/self-host-compose.d.ts.map +1 -1
  64. package/dist/self-host-compose.js +114 -9
  65. package/dist/self-host-compose.js.map +1 -1
  66. package/dist/strict.d.ts +29 -0
  67. package/dist/strict.d.ts.map +1 -0
  68. package/dist/strict.js +37 -0
  69. package/dist/strict.js.map +1 -0
  70. package/dist/studio-dev-server.d.ts +12 -2
  71. package/dist/studio-dev-server.d.ts.map +1 -1
  72. package/dist/studio-dev-server.js +12 -2
  73. package/dist/studio-dev-server.js.map +1 -1
  74. package/dist/type-extractor.d.ts.map +1 -1
  75. package/dist/type-extractor.js +8 -2
  76. package/dist/type-extractor.js.map +1 -1
  77. package/dist/type-generation.d.ts +14 -0
  78. package/dist/type-generation.d.ts.map +1 -1
  79. package/dist/type-generation.js +41 -2
  80. package/dist/type-generation.js.map +1 -1
  81. package/package.json +2 -2
  82. package/src/api-config-cache.ts +30 -0
  83. package/src/augmentation-generator.ts +75 -18
  84. package/src/cli-version-embedded.ts +1 -1
  85. package/src/client-generator.ts +122 -0
  86. package/src/commands/admin.ts +23 -6
  87. package/src/commands/functions.ts +5 -2
  88. package/src/commands/init.ts +10 -10
  89. package/src/commands/keys.ts +112 -7
  90. package/src/commands/push.ts +25 -15
  91. package/src/commands/update.ts +20 -1
  92. package/src/db-port.ts +99 -0
  93. package/src/dev-compose.ts +244 -19
  94. package/src/functions-context-refresh.ts +25 -0
  95. package/src/functions-deno-types.ts +24 -1
  96. package/src/gitignore.ts +31 -10
  97. package/src/model-cache.ts +48 -0
  98. package/src/schema-ast-v2.ts +17 -8
  99. package/src/self-host-compose.ts +115 -9
  100. package/src/strict.ts +40 -0
  101. package/src/studio-dev-server.ts +12 -2
  102. package/src/type-extractor.ts +8 -2
  103. package/src/type-generation.ts +47 -2
  104. package/tests/ast-derived-outputs.test.ts +92 -0
  105. package/tests/augmentation-generator.test.ts +127 -1
  106. package/tests/client-generator.test.ts +103 -0
  107. package/tests/db-port.test.ts +128 -0
  108. package/tests/external-database-compose.test.ts +8 -1
  109. package/tests/functions-context-refresh.test.ts +86 -0
  110. package/tests/gitignore-secrets.test.ts +90 -0
  111. package/tests/keys-write.test.ts +148 -0
  112. package/tests/runtime-contract.test.ts +115 -2
  113. package/tests/strict.test.ts +78 -0
  114. package/tests/type-extractor.test.ts +34 -0
  115. package/tsconfig.tsbuildinfo +1 -1
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
+ }
@@ -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"
@@ -21,12 +22,18 @@ import {
21
22
  type SupatypeProjectConfig,
22
23
  } from "./project-config.js"
23
24
  import { syncManifestHooks, writeHooksModule } from "./model-hooks.js"
25
+ import { syncRowCacheEnv } from "./model-cache.js"
26
+ import { anonKeyEnvUpdates } from "./commands/keys.js"
24
27
  import { signJwt } from "./jwt.js"
25
28
  import { ensureDevDbPort, ensureKongPort } from "./dev-ports.js"
26
29
  import { handleComposeProjectRename } from "./compose-rename.js"
27
30
  import { recoverStaleDevSession, writeDevSessionLock } from "./dev-session-lock.js"
28
31
  import { endDevSession, startDevSession } from "./dev-session.js"
29
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"
30
37
  import {
31
38
  COMPOSE_PINNED_IMAGE_ENV_KEYS,
32
39
  composeDockerImageEnv,
@@ -35,6 +42,7 @@ import {
35
42
  runDockerCompose,
36
43
  schemaEngineImageForPush,
37
44
  writeSelfHostCompose,
45
+ selfHostComposePaths,
38
46
  type SelfHostComposePaths,
39
47
  } from "./self-host-compose.js"
40
48
  import type { DockerBrandOptions } from "./docker-runtime.js"
@@ -288,13 +296,13 @@ export function upsertDevComposeEnv(
288
296
  ...seedMissingLocalSecrets(cwd),
289
297
  // Project configuration, seeded not overwritten, see seedMissingDatabaseIdentity.
290
298
  ...seedMissingDatabaseIdentity(cwd),
291
- ANON_KEY: anonKey,
292
- SERVICE_ROLE_KEY: serviceRoleKey,
293
- PUBLIC_SUPATYPE_ANON_KEY: anonKey,
294
- VITE_SUPATYPE_ANON_KEY: anonKey,
295
- EXPO_PUBLIC_SUPATYPE_ANON_KEY: anonKey,
296
- PUBLIC_SUPATYPE_URL: apiUrl,
297
- EXPO_PUBLIC_SUPATYPE_URL: apiUrl,
299
+ // Shared with `supatype keys --write`, so a front end's prefix cannot be written by one and
300
+ // forgotten by the other. See `anonKeyEnvUpdates`.
301
+ //
302
+ // This list used to live here as well, and the two had already drifted: `dev` wrote
303
+ // PUBLIC_ and EXPO_PUBLIC_ URLs and no VITE_SUPATYPE_URL, so a Vite app whose `.env` had only
304
+ // ever been touched by `supatype dev` built against an empty string.
305
+ ...anonKeyEnvUpdates(anonKey, serviceRoleKey, apiUrl),
298
306
  SUPATYPE_KONG_PORT: String(kongPort),
299
307
  API_EXTERNAL_URL: apiUrl,
300
308
  SITE_URL: apiUrl,
@@ -371,7 +379,16 @@ async function waitComposeHealthy(paths: SelfHostComposePaths, cwd: string, maxM
371
379
  while (Date.now() < deadline) {
372
380
  const ready = spawnSync(
373
381
  "docker",
374
- [...baseArgs, "exec", "-T", "db", "pg_isready", "-U", "supatype_admin"],
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"],
375
392
  { cwd: composeDir, encoding: "utf8" },
376
393
  )
377
394
  if (ready.status === 0) return
@@ -591,15 +608,73 @@ async function refreshSchemaArtifacts(
591
608
  // validator silently not firing means a write the schema says is checked is accepted with a 201.
592
609
  const hooksModule = writeHooksModule(cwd, hooksPathFromProject(config, cwd), ast)
593
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
+ }
594
617
  if (syncManifestHooks(cwd, ast)) {
595
618
  console.log("[supatype] Hook and validator maps written to .supatype/manifest.json")
596
619
  }
620
+ // Written before the stack comes up, so a first run with a row cache declared starts with it on
621
+ // rather than needing a second `supatype dev`.
622
+ const rowCache = syncRowCacheEnv(cwd, ast)
623
+ if (rowCache !== null) {
624
+ console.log(`[supatype] Row cache switched ${rowCache} in .env (pg_keyspace Mode B).`)
625
+ }
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}`)
597
644
 
598
645
  try {
599
646
  await ensureEngine()
600
647
  } catch (err) {
601
- console.warn(
602
- `[supatype] Host engine unavailable, admin/types not refreshed: ${(err as Error).message}`,
648
+ // The host engine is a CDN download and this machine could not complete it. The schema is
649
+ // already applied, so fall back to the engine in compose rather than giving up: without
650
+ // admin-config.json Studio reports "No schema has been pushed yet" on a stack whose schema is
651
+ // entirely live, which reads as a failed push rather than a missing binary.
652
+ const paths = selfHostComposePaths(cwd)
653
+ const composeProject = composeProjectName(config.project.name)
654
+ const wrote = await generateViaComposeEngine(
655
+ paths,
656
+ cwd,
657
+ composeProject,
658
+ config,
659
+ ast,
660
+ adminConfigPath,
661
+ ).catch(() => false)
662
+ if (wrote) {
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,
670
+ )
671
+ return
672
+ }
673
+ degraded(
674
+ "Host engine unavailable, admin config and types not refreshed",
675
+ `${(err as Error).message}\n` +
676
+ "[supatype] Studio will report no schema until this succeeds, even though the schema is " +
677
+ "applied. Retry with the stack up, or run `supatype push`.",
603
678
  )
604
679
  return
605
680
  }
@@ -627,6 +702,23 @@ async function refreshSchemaArtifacts(
627
702
  }
628
703
  }
629
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
+
630
722
  try {
631
723
  const admin = withAdminRoles(await engineRequest<unknown>("/admin", { ast }), config)
632
724
  restoreSystemRelationTargets(admin, ast)
@@ -919,7 +1011,16 @@ export async function diffSchemaDocker(cwd: string, config: SupatypeProjectConfi
919
1011
  throw new Error(detail || `Engine schema diff failed (exit ${result.status})`)
920
1012
  }
921
1013
  if (!result.diff) {
922
- throw new Error("Engine diff returned no result")
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
+ )
923
1024
  }
924
1025
  return result.diff
925
1026
  }
@@ -1126,19 +1227,26 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
1126
1227
  }
1127
1228
  }
1128
1229
 
1129
- const pinnedRealtimeImage = readEnvValue(cwd, "SUPATYPE_REALTIME_IMAGE", "").trim()
1130
- if (pinnedRealtimeImage !== "") {
1131
- console.log("[supatype] Recreating realtime with pinned image...")
1132
- const rtStatus = runDockerCompose(
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(
1133
1241
  paths.composePath,
1134
- ["up", "-d", "--force-recreate", "--no-deps", "realtime"],
1242
+ ["up", "-d", "--force-recreate", "--no-deps", service],
1135
1243
  cwd,
1136
1244
  project,
1137
1245
  { quiet: true, brand: devBrand },
1138
1246
  )
1139
- if (rtStatus !== 0) {
1247
+ if (status !== 0) {
1140
1248
  endDevSession()
1141
- exitComposeFailed(rtStatus, "Could not recreate the realtime container.", devBrand)
1249
+ exitComposeFailed(status, `Could not recreate the ${service} container.`, devBrand)
1142
1250
  }
1143
1251
  }
1144
1252
 
@@ -1306,8 +1414,17 @@ function reconcileAuthenticatorPassword(
1306
1414
  },
1307
1415
  )
1308
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")
1309
1425
  console.warn(
1310
- "[supatype] Could not set the authenticator password; the REST API may answer 502.",
1426
+ `[supatype] Could not set the authenticator password, so the REST API will answer 502.\n` +
1427
+ (detail === "" ? "[supatype] docker gave no output." : detail),
1311
1428
  )
1312
1429
  }
1313
1430
  }
@@ -1333,3 +1450,111 @@ function grantAuthSchemaAccess(
1333
1450
  console.warn("[supatype] Could not grant service_role access to auth.users, Studio relation preview may fail.")
1334
1451
  }
1335
1452
  }
1453
+
1454
+ /**
1455
+ * Run one read-only generator in the in-compose schema-engine and return its stdout.
1456
+ *
1457
+ * The same image and the same bind mount the push just used, so if the schema could be applied
1458
+ * this can run. It exists because the host engine is a separate binary fetched from a CDN, and a
1459
+ * machine that cannot fetch it is not a machine that cannot generate: the schema is already
1460
+ * applied, the AST is already on disk, and the container that did it is one `docker compose run`
1461
+ * away.
1462
+ */
1463
+ async function runComposeEngineGenerator(
1464
+ paths: SelfHostComposePaths,
1465
+ cwd: string,
1466
+ composeProject: string,
1467
+ config: SupatypeProjectConfig,
1468
+ args: readonly string[],
1469
+ ): Promise<string | null> {
1470
+ const envFile = resolve(cwd, ".env")
1471
+ const composeArgs = ["compose", "--progress", "quiet"]
1472
+ if (composeProject) composeArgs.push("-p", composeProject)
1473
+ composeArgs.push("--project-directory", cwd)
1474
+ composeArgs.push("-f", paths.composePath)
1475
+ if (existsSync(envFile)) composeArgs.push("--env-file", envFile)
1476
+ composeArgs.push(
1477
+ "--profile",
1478
+ "tools",
1479
+ "run",
1480
+ "--rm",
1481
+ "-T",
1482
+ "schema-engine",
1483
+ ...args,
1484
+ "-i",
1485
+ "/project/.supatype/schema.ast.json",
1486
+ )
1487
+
1488
+ const env: NodeJS.ProcessEnv = { ...process.env, COMPOSE_PROGRESS: "quiet" }
1489
+ const engineImage = await schemaEngineImageForPush(config)
1490
+ if (engineImage) env.SUPATYPE_ENGINE_IMAGE = engineImage
1491
+
1492
+ const result = spawnSync("docker", composeArgs, {
1493
+ cwd,
1494
+ encoding: "utf8",
1495
+ maxBuffer: 20 * 1024 * 1024,
1496
+ env,
1497
+ })
1498
+ if ((result.status ?? 1) !== 0) return null
1499
+ return String(result.stdout ?? "")
1500
+ }
1501
+
1502
+ /**
1503
+ * Write `admin-config.json` and the generated types using the in-compose engine.
1504
+ *
1505
+ * The fallback for a host engine that could not be fetched. Without it, a `supatype dev` that
1506
+ * applied the schema perfectly well still left no `admin-config.json`, and Studio reads that file
1507
+ * to decide whether a schema exists: the stack came up, every table was live, the API served them,
1508
+ * and Studio said **"No schema has been pushed yet."** The warning that preceded it named the
1509
+ * download, not the consequence, so nothing connected the two.
1510
+ *
1511
+ * Returns true when the admin config was written, which is the file Studio actually needs.
1512
+ */
1513
+ export async function generateViaComposeEngine(
1514
+ paths: SelfHostComposePaths,
1515
+ cwd: string,
1516
+ composeProject: string,
1517
+ config: SupatypeProjectConfig,
1518
+ ast: unknown,
1519
+ adminConfigPath: string,
1520
+ ): Promise<boolean> {
1521
+ const typesPath = config.output?.types
1522
+ if (typeof typesPath === "string" && typesPath.trim().length > 0) {
1523
+ const out = await runComposeEngineGenerator(paths, cwd, composeProject, config, ["generate"])
1524
+ const marker = out?.indexOf("// Generated by supatype-engine") ?? -1
1525
+ if (out && out.includes("export type")) {
1526
+ const ts = (marker >= 0 ? out.slice(marker) : out).trimStart()
1527
+ const hostPath = join(cwd, typesPath)
1528
+ mkdirSync(dirname(hostPath), { recursive: true })
1529
+ writeFileSync(hostPath, ts)
1530
+ console.log(`[supatype] Types written to ${typesPath} (in-compose engine).`)
1531
+ }
1532
+ }
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
+
1550
+ const adminOut = await runComposeEngineGenerator(paths, cwd, composeProject, config, ["admin"])
1551
+ if (!adminOut) return false
1552
+ const parsed = parseEngineJsonOutput<unknown>(adminOut)
1553
+ if (parsed === null) return false
1554
+
1555
+ const admin = withAdminRoles(parsed, config)
1556
+ restoreSystemRelationTargets(admin, ast)
1557
+ writeFileSync(adminConfigPath, `${JSON.stringify(admin, null, 2)}\n`)
1558
+ console.log("[supatype] Admin config written to .supatype/admin-config.json (in-compose engine).")
1559
+ return true
1560
+ }
@@ -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
- * Regenerated by the CLI. Edits are overwritten.
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
- .env
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
- writeFileSync(
30
- gitignorePath,
31
- `.env\nnode_modules/\ndist/\n${SUPATYPE_GITIGNORE_BLOCK}`,
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) {
@@ -16,6 +16,7 @@
16
16
  * `hooks` has always taken this route for the same reason. Plan §13.1 said otherwise and said to
17
17
  * verify before relying on it; this is the verified path.
18
18
  */
19
+ import { readEnvValue, upsertEnvFile } from "./env-file.js"
19
20
  import type { ModelCacheAst } from "./schema-ast-v2.js"
20
21
 
21
22
  /** One table's cache declaration, as the manifest carries it. */
@@ -72,3 +73,50 @@ export function manifestCache(ast: unknown): Record<string, ManifestCacheEntry>
72
73
  }
73
74
  return out
74
75
  }
76
+
77
+ // ─── Row cache enablement ────────────────────────────────────────────────────
78
+
79
+ /** The two env names the Postgres image reads to turn Mode B on. */
80
+ export const ROWCACHE_DECODE_ENV = "SUPATYPE_KEYSPACE_ROWCACHE_DECODE"
81
+ export const ROWCACHE_READTHROUGH_ENV = "SUPATYPE_KEYSPACE_ROWCACHE_READTHROUGH"
82
+
83
+ /** Whether any model declares `cache: { rows: true }`. */
84
+ export function declaresRowCache(ast: unknown): boolean {
85
+ return Object.values(manifestCache(ast)).some((entry) => entry.rows === true)
86
+ }
87
+
88
+ /**
89
+ * Keep the row cache's two switches in `.env` matching what the schema declares.
90
+ *
91
+ * `cache: { rows: true }` registers a table with the row cache, and registration alone serves
92
+ * nothing: Mode B needs `pg_keyspace.rowcache_decode` for the invalidation worker and
93
+ * `rowcache_readthrough` to fill on a primary-key miss. Both are written by the image's entrypoint
94
+ * from these variables, before any server starts, so they cannot be switched at runtime and cannot
95
+ * be decided by the compose file alone: only a push knows whether any model declares `rows`.
96
+ *
97
+ * Without this the stack came up with the row-cache segment reserved, the tables registered, both
98
+ * switches off and nothing anywhere saying so. Studio's panel was the only thing that reported it,
99
+ * and it reported it as an operator's missing configuration rather than as a push that had not
100
+ * finished the job.
101
+ *
102
+ * Written to `.env` rather than baked into the compose file for the same reason `SUPATYPE_KONG_PORT`
103
+ * is: `self-host compose render` runs with no AST in hand, so a value the compose file hardcoded
104
+ * would be whatever the last render guessed.
105
+ *
106
+ * Returns the new state when it changed, and null when it did not. A change needs the database
107
+ * container recreated, which the caller is the one that can say.
108
+ */
109
+ export function syncRowCacheEnv(cwd: string, ast: unknown): "on" | "off" | null {
110
+ const want = declaresRowCache(ast)
111
+ const value = want ? "1" : "0"
112
+
113
+ const current = readEnvValue(cwd, ROWCACHE_DECODE_ENV, "")
114
+ const currentThrough = readEnvValue(cwd, ROWCACHE_READTHROUGH_ENV, "")
115
+ if (current === value && currentThrough === value) return null
116
+
117
+ upsertEnvFile(cwd, {
118
+ [ROWCACHE_DECODE_ENV]: value,
119
+ [ROWCACHE_READTHROUGH_ENV]: value,
120
+ })
121
+ return want ? "on" : "off"
122
+ }