@supatype/cli 0.2.1 → 0.3.1

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 (173) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +193 -157
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/api-config-cache.d.ts +34 -0
  5. package/dist/api-config-cache.d.ts.map +1 -0
  6. package/dist/api-config-cache.js +123 -0
  7. package/dist/api-config-cache.js.map +1 -0
  8. package/dist/cache-identity-scope.d.ts +38 -0
  9. package/dist/cache-identity-scope.d.ts.map +1 -0
  10. package/dist/cache-identity-scope.js +70 -0
  11. package/dist/cache-identity-scope.js.map +1 -0
  12. package/dist/cache-provider.d.ts +30 -0
  13. package/dist/cache-provider.d.ts.map +1 -0
  14. package/dist/cache-provider.js +60 -0
  15. package/dist/cache-provider.js.map +1 -0
  16. package/dist/cli-version-embedded.js +1 -1
  17. package/dist/commands/add.js +1 -1
  18. package/dist/commands/add.js.map +1 -1
  19. package/dist/commands/admin.d.ts.map +1 -1
  20. package/dist/commands/admin.js +21 -4
  21. package/dist/commands/admin.js.map +1 -1
  22. package/dist/commands/cache.d.ts +1 -1
  23. package/dist/commands/cache.js +3 -3
  24. package/dist/commands/cache.js.map +1 -1
  25. package/dist/commands/cloud.d.ts.map +1 -1
  26. package/dist/commands/cloud.js +2 -1
  27. package/dist/commands/cloud.js.map +1 -1
  28. package/dist/commands/deploy.d.ts.map +1 -1
  29. package/dist/commands/deploy.js +2 -1
  30. package/dist/commands/deploy.js.map +1 -1
  31. package/dist/commands/dev.d.ts.map +1 -1
  32. package/dist/commands/dev.js +61 -8
  33. package/dist/commands/dev.js.map +1 -1
  34. package/dist/commands/diff.d.ts.map +1 -1
  35. package/dist/commands/diff.js +2 -1
  36. package/dist/commands/diff.js.map +1 -1
  37. package/dist/commands/functions.d.ts.map +1 -1
  38. package/dist/commands/functions.js +6 -2
  39. package/dist/commands/functions.js.map +1 -1
  40. package/dist/commands/init.d.ts.map +1 -1
  41. package/dist/commands/init.js +13 -9
  42. package/dist/commands/init.js.map +1 -1
  43. package/dist/commands/keys.d.ts +35 -0
  44. package/dist/commands/keys.d.ts.map +1 -1
  45. package/dist/commands/keys.js +90 -6
  46. package/dist/commands/keys.js.map +1 -1
  47. package/dist/commands/migrate.d.ts.map +1 -1
  48. package/dist/commands/migrate.js +2 -1
  49. package/dist/commands/migrate.js.map +1 -1
  50. package/dist/commands/push.d.ts.map +1 -1
  51. package/dist/commands/push.js +61 -1
  52. package/dist/commands/push.js.map +1 -1
  53. package/dist/compose-services.d.ts.map +1 -1
  54. package/dist/compose-services.js +2 -1
  55. package/dist/compose-services.js.map +1 -1
  56. package/dist/dev-compose.d.ts +13 -0
  57. package/dist/dev-compose.d.ts.map +1 -1
  58. package/dist/dev-compose.js +119 -16
  59. package/dist/dev-compose.js.map +1 -1
  60. package/dist/engine-floor.d.ts +11 -1
  61. package/dist/engine-floor.d.ts.map +1 -1
  62. package/dist/engine-floor.js +49 -16
  63. package/dist/engine-floor.js.map +1 -1
  64. package/dist/functions-deno-types.d.ts +9 -0
  65. package/dist/functions-deno-types.d.ts.map +1 -1
  66. package/dist/functions-deno-types.js +45 -1
  67. package/dist/functions-deno-types.js.map +1 -1
  68. package/dist/functions-router-gen.d.ts.map +1 -1
  69. package/dist/functions-router-gen.js +113 -81
  70. package/dist/functions-router-gen.js.map +1 -1
  71. package/dist/kong-config.d.ts +1 -1
  72. package/dist/model-cache.d.ts +44 -0
  73. package/dist/model-cache.d.ts.map +1 -0
  74. package/dist/model-cache.js +106 -0
  75. package/dist/model-cache.js.map +1 -0
  76. package/dist/model-hooks.d.ts +5 -0
  77. package/dist/model-hooks.d.ts.map +1 -1
  78. package/dist/model-hooks.js +29 -1
  79. package/dist/model-hooks.js.map +1 -1
  80. package/dist/model-versioning.d.ts +80 -0
  81. package/dist/model-versioning.d.ts.map +1 -0
  82. package/dist/model-versioning.js +140 -0
  83. package/dist/model-versioning.js.map +1 -0
  84. package/dist/postgres-ctl.d.ts +55 -0
  85. package/dist/postgres-ctl.d.ts.map +1 -1
  86. package/dist/postgres-ctl.js +84 -8
  87. package/dist/postgres-ctl.js.map +1 -1
  88. package/dist/preview-config-check.d.ts +21 -0
  89. package/dist/preview-config-check.d.ts.map +1 -0
  90. package/dist/preview-config-check.js +47 -0
  91. package/dist/preview-config-check.js.map +1 -0
  92. package/dist/project-config.d.ts +182 -5
  93. package/dist/project-config.d.ts.map +1 -1
  94. package/dist/project-config.js +125 -8
  95. package/dist/project-config.js.map +1 -1
  96. package/dist/resolve-target.d.ts +9 -0
  97. package/dist/resolve-target.d.ts.map +1 -1
  98. package/dist/resolve-target.js.map +1 -1
  99. package/dist/rest-cache-admin.d.ts.map +1 -1
  100. package/dist/rest-cache-admin.js +6 -2
  101. package/dist/rest-cache-admin.js.map +1 -1
  102. package/dist/schema-ast-v2.d.ts +32 -1
  103. package/dist/schema-ast-v2.d.ts.map +1 -1
  104. package/dist/schema-ast-v2.js +14 -1
  105. package/dist/schema-ast-v2.js.map +1 -1
  106. package/dist/self-host-compose.d.ts +20 -1
  107. package/dist/self-host-compose.d.ts.map +1 -1
  108. package/dist/self-host-compose.js +227 -29
  109. package/dist/self-host-compose.js.map +1 -1
  110. package/dist/studio-admin-roles.d.ts +8 -1
  111. package/dist/studio-admin-roles.d.ts.map +1 -1
  112. package/dist/studio-admin-roles.js +16 -2
  113. package/dist/studio-admin-roles.js.map +1 -1
  114. package/dist/type-extractor.d.ts +7 -0
  115. package/dist/type-extractor.d.ts.map +1 -1
  116. package/dist/type-extractor.js +234 -3
  117. package/dist/type-extractor.js.map +1 -1
  118. package/package.json +2 -2
  119. package/src/api-config-cache.ts +142 -0
  120. package/src/cache-identity-scope.ts +92 -0
  121. package/src/cache-provider.ts +61 -0
  122. package/src/cli-version-embedded.ts +1 -1
  123. package/src/commands/add.ts +1 -1
  124. package/src/commands/admin.ts +23 -6
  125. package/src/commands/cache.ts +3 -3
  126. package/src/commands/cloud.ts +2 -1
  127. package/src/commands/deploy.ts +2 -1
  128. package/src/commands/dev.ts +68 -7
  129. package/src/commands/diff.ts +2 -1
  130. package/src/commands/functions.ts +6 -2
  131. package/src/commands/init.ts +13 -9
  132. package/src/commands/keys.ts +112 -7
  133. package/src/commands/migrate.ts +2 -1
  134. package/src/commands/push.ts +68 -1
  135. package/src/compose-services.ts +2 -1
  136. package/src/dev-compose.ts +151 -15
  137. package/src/engine-floor.ts +73 -16
  138. package/src/functions-deno-types.ts +48 -1
  139. package/src/functions-router-gen.ts +113 -81
  140. package/src/kong-config.ts +1 -1
  141. package/src/model-cache.ts +122 -0
  142. package/src/model-hooks.ts +26 -2
  143. package/src/model-versioning.ts +167 -0
  144. package/src/postgres-ctl.ts +103 -9
  145. package/src/preview-config-check.ts +52 -0
  146. package/src/project-config.ts +273 -11
  147. package/src/resolve-target.ts +11 -1
  148. package/src/rest-cache-admin.ts +8 -2
  149. package/src/schema-ast-v2.ts +47 -0
  150. package/src/self-host-compose.ts +250 -29
  151. package/src/studio-admin-roles.ts +16 -2
  152. package/src/type-extractor.ts +281 -2
  153. package/tests/api-config-cache-seed.test.ts +180 -0
  154. package/tests/cache-identity-scope.test.ts +61 -0
  155. package/tests/cache-provider-config.test.ts +132 -0
  156. package/tests/engine-floor.test.ts +43 -0
  157. package/tests/external-database-compose.test.ts +74 -3
  158. package/tests/field-masking-tier.test.ts +3 -3
  159. package/tests/fixtures/identity-scope-corpus.json +42 -0
  160. package/tests/functions-concurrency.test.ts +71 -0
  161. package/tests/init.test.ts +36 -2
  162. package/tests/keys-write.test.ts +148 -0
  163. package/tests/model-cache-declaration.test.ts +229 -0
  164. package/tests/model-cache-manifest.test.ts +99 -0
  165. package/tests/model-hooks.test.ts +55 -0
  166. package/tests/model-versions.test.ts +229 -0
  167. package/tests/native-keyspace.test.ts +130 -0
  168. package/tests/preview-config-check.test.ts +104 -0
  169. package/tests/rest-cache-admin.test.ts +36 -0
  170. package/tests/runtime-contract.test.ts +176 -22
  171. package/tests/studio-admin-roles.test.ts +32 -0
  172. package/tests/type-extractor.test.ts +113 -0
  173. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Whether an access rule's outcome varies by *who* is asking.
3
+ *
4
+ * This decides whether a model may declare `cache: { public: true }`. A public cache entry is
5
+ * global — one response served to every caller — so on a table whose read rule varies by caller it
6
+ * serves one user's rows to another.
7
+ *
8
+ * **Row-dependence is not the same question, and getting that wrong in either direction is a bug.**
9
+ * `Lte<"published_at", Now>` varies by row and by time but not by caller, so a shared entry is
10
+ * exactly right for it: that is the case public caching exists to serve. Refusing it would make the
11
+ * feature useless. Allowing `Owner<"user_id">` would make it a data leak.
12
+ *
13
+ * ## This is the second implementation of one rule
14
+ *
15
+ * supatype-server already answers this at runtime, in
16
+ * `internal/studiobootstrap.IsIdentityDependent`, to decide whether a response may be cached under
17
+ * a shared scope. This one answers it at *push* time, where the error can name the model and the
18
+ * rule instead of a request quietly not being cached.
19
+ *
20
+ * Two implementations of one rule drift, and the drift is silent: the push says "shareable" and the
21
+ * runtime says "not", or worse the other way round. `tests/fixtures/identity-scope-corpus.json` is
22
+ * the defence — a shared corpus of rules and expected answers that both sides run against. Change
23
+ * this file and you change that corpus; the Go side reads the same cases.
24
+ */
25
+
26
+ /** A rule node, in the shape the extractor emits and the server parses. */
27
+ export interface AccessRuleNode {
28
+ type?: string
29
+ left?: AccessOperand
30
+ right?: AccessOperand
31
+ operand?: AccessOperand
32
+ rules?: AccessRuleNode[]
33
+ rule?: AccessRuleNode
34
+ }
35
+
36
+ export interface AccessOperand {
37
+ kind?: string
38
+ }
39
+
40
+ /**
41
+ * The operands whose value differs per caller. Columns, literals and the temporal operands
42
+ * (`Now` and friends) are the same for everyone, so they are not here.
43
+ */
44
+ const IDENTITY_OPERANDS = new Set(["authUid", "authRole", "claim"])
45
+
46
+ function operandIsIdentityDependent(o: AccessOperand | undefined): boolean {
47
+ if (!o) return false
48
+ return IDENTITY_OPERANDS.has(o.kind ?? "")
49
+ }
50
+
51
+ export function isIdentityDependent(rule: AccessRuleNode | undefined | null): boolean {
52
+ // No rule at all is not a caller-varying rule. An absent `access.read` means the table's
53
+ // readability is decided elsewhere, and treating that as identity-dependent would refuse public
54
+ // caching on every model that never wrote one.
55
+ if (!rule) return false
56
+
57
+ switch (rule.type) {
58
+ case "public":
59
+ case "private":
60
+ return false
61
+
62
+ // "Any authenticated user" still distinguishes signed-in from anonymous, so a shared entry
63
+ // could serve a member's view to a stranger.
64
+ case "authenticated":
65
+ case "role":
66
+ case "owner":
67
+ return true
68
+
69
+ // Membership is per caller by construction.
70
+ case "in":
71
+ case "exists":
72
+ return true
73
+
74
+ case "compare":
75
+ return operandIsIdentityDependent(rule.left) || operandIsIdentityDependent(rule.right)
76
+
77
+ case "nullCheck":
78
+ return operandIsIdentityDependent(rule.operand)
79
+
80
+ case "any":
81
+ case "all":
82
+ return (rule.rules ?? []).some(isIdentityDependent)
83
+
84
+ case "not":
85
+ return rule.rule ? isIdentityDependent(rule.rule) : false
86
+
87
+ default:
88
+ // An unrecognised rule is assumed to depend on identity. Guessing the other way would share
89
+ // a cache entry across callers on the strength of not recognising something.
90
+ return true
91
+ }
92
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Which RESP server this stack runs: one decision, in one place.
3
+ *
4
+ * The response cache and Kong's ACME certificates share a RESP server. Since P1 that can be
5
+ * pg_keyspace inside the Postgres container — one stateful service instead of two, no second
6
+ * image to keep patched, and a cache whose memory is accounted for in the database's own
7
+ * reservation. That is now the default rather than the option.
8
+ *
9
+ * **Valkey stays selectable**, and stays the answer for `database.external`: pg_keyspace is loaded
10
+ * through `shared_preload_libraries`, which is a property of a Postgres this stack starts. Against
11
+ * a database Supatype does not manage there is nothing to configure, so the fallback is not a
12
+ * preference — it is the only thing that can run.
13
+ *
14
+ * A resolver rather than a default written into the config type, because the answer depends on
15
+ * more than one field: the same absent `cache.provider` means pg_keyspace beside a managed
16
+ * database and Valkey beside an external one. A default baked into the parser would have to be
17
+ * either wrong for one of them or applied twice, and applied twice is how the compose file and the
18
+ * native path end up disagreeing about which server is running — a cache that silently never hits
19
+ * and, with TLS on, a Kong that cannot store its certificate.
20
+ */
21
+ import { pinnedVersion } from "./binary-cache.js"
22
+ import type { SupatypeProjectConfig } from "./project-config.js"
23
+
24
+ export type CacheProvider = "valkey" | "pg_keyspace"
25
+
26
+ /**
27
+ * The provider this project runs, explicit or resolved.
28
+ *
29
+ * An explicit `cache.provider` is honoured exactly — including `"valkey"`, which is how a project
30
+ * keeps the sidecar it has. `validateProjectConfig` has already refused the one explicit
31
+ * combination that cannot run (pg_keyspace against `database.external`), so this never overrides
32
+ * what someone wrote.
33
+ *
34
+ * The two things that resolve an absent one to Valkey are both cases where this cannot know that a
35
+ * keyspace would work:
36
+ *
37
+ * - **`database.external`**: pg_keyspace is loaded through `shared_preload_libraries`, and there
38
+ * is no container here to set that on.
39
+ * - **A pinned `versions.postgres`**: the keyspace is served by the image, and the entrypoint
40
+ * honours `SUPATYPE_KEYSPACE_ENABLED` only from the release that introduced it. An older image
41
+ * ignores the variable and starts with no RESP listener, which is not an error anywhere — the
42
+ * stack comes up and the cache never hits. A pin says this project's image is fixed, and a
43
+ * default must not assume something about a fixed image it cannot check. Unpinned tracks
44
+ * latest, which carries the toggle by definition.
45
+ *
46
+ * A project on a pin that *does* carry the toggle says `cache: { provider: "pg_keyspace" }` and
47
+ * gets it — which is also the only instruction anyone needs, and is better than a version floor
48
+ * here that would have to be right about a release number in another repository.
49
+ */
50
+ export function cacheProvider(config: SupatypeProjectConfig): CacheProvider {
51
+ const declared = config.cache?.provider
52
+ if (declared === "valkey" || declared === "pg_keyspace") return declared
53
+ if (config.database?.external) return "valkey"
54
+ if (pinnedVersion("postgres", config) !== undefined) return "valkey"
55
+ return "pg_keyspace"
56
+ }
57
+
58
+ /** Whether the RESP keyspace is served by the Postgres this stack starts. */
59
+ export function keyspaceInPostgres(config: SupatypeProjectConfig): boolean {
60
+ return cacheProvider(config) === "pg_keyspace"
61
+ }
@@ -9,4 +9,4 @@
9
9
  // Annotated `string`, not left to inference: once a release stamps a value here, the
10
10
  // inferred literal type makes the `!== ""` test in cliPackageVersion() a compile error
11
11
  // (TS2367, no overlap), so the build would only fail during a release.
12
- export const EMBEDDED_CLI_VERSION: string = "0.2.1"
12
+ export const EMBEDDED_CLI_VERSION: string = "0.3.1"
@@ -101,5 +101,5 @@ function printDomainNextSteps(cwd: string, domain: string): void {
101
101
  `Platform URL: https://${domain}`,
102
102
  ])
103
103
  plain(" App, REST, Auth, Storage, Realtime, Functions, and Studio, one HTTPS domain.")
104
- plain(" Certificates persist in the valkey-data volume.\n")
104
+ plain(" Certificates outlive restarts — in the keyspace's durable store, or the valkey-data volume.\n")
105
105
  }
@@ -363,12 +363,7 @@ export async function ensureFirstAdminWithQuery(
363
363
 
364
364
  const credentials = await resolveAdminCredentials(options, cwd)
365
365
  if (!credentials) {
366
- if (!isInteractive()) {
367
- info(
368
- "No admin users found. Set SUPATYPE_ADMIN_EMAIL / SUPATYPE_ADMIN_PASSWORD in .env, " +
369
- "or run: supatype admin create-user",
370
- )
371
- }
366
+ if (!isInteractive()) info(missingAdminCredentialsHint(cwd, options))
372
367
  return
373
368
  }
374
369
 
@@ -397,6 +392,28 @@ function logFirstAdminCreated(email: string, role: string): void {
397
392
  info("Log in at /admin after starting the dev server.")
398
393
  }
399
394
 
395
+ /**
396
+ * What to say when there is no admin and no credentials to make one with.
397
+ *
398
+ * Naming both variables is wrong in the common case. `clearAdminSeedPassword` retires the seed
399
+ * password once it has been used, so a project that has ever created an admin keeps the email and
400
+ * has no password, and a later run against a reset database then reports both as missing. That
401
+ * reads as "you never set these" when in fact this tool consumed one of them, which is a dead end
402
+ * for anyone who knows they set it.
403
+ */
404
+ function missingAdminCredentialsHint(cwd: string, options: EnsureFirstAdminOptions): string {
405
+ const email = options.email ?? readEnvValue(cwd, ADMIN_EMAIL_ENV, "").trim()
406
+ const create = "or run: supatype admin create-user"
407
+ if (!email) {
408
+ return `No admin users found. Set ${ADMIN_EMAIL_ENV} / ${ADMIN_PASSWORD_ENV} in .env, ${create}`
409
+ }
410
+ return (
411
+ `No admin users found. .env has ${ADMIN_EMAIL_ENV}="${email}" but no ${ADMIN_PASSWORD_ENV}: ` +
412
+ `it is a one-time seed and is removed once an admin has been created. ` +
413
+ `Set ${ADMIN_PASSWORD_ENV} again to seed one, ${create}`
414
+ )
415
+ }
416
+
400
417
  async function resolveAdminCredentials(
401
418
  options: EnsureFirstAdminOptions,
402
419
  cwd: string,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * supatype cache: list and clean cached component binaries + REST Valkey cache.
2
+ * supatype cache: list and clean cached component binaries + the REST response cache.
3
3
  */
4
4
 
5
5
  import type { Command } from "commander"
@@ -135,7 +135,7 @@ export function registerCache(program: Command): void {
135
135
 
136
136
  const rest = cache
137
137
  .command("rest")
138
- .description("Manage REST API response cache in Valkey")
138
+ .description("Manage the server-side REST API response cache")
139
139
 
140
140
  rest
141
141
  .command("list")
@@ -170,7 +170,7 @@ export function registerCache(program: Command): void {
170
170
 
171
171
  rest
172
172
  .command("get <key>")
173
- .description("Show one cache entry (full Valkey key)")
173
+ .description("Show one cache entry (full key)")
174
174
  .option("--json", "Output JSON")
175
175
  .action(async (key: string, opts: { json?: boolean }) => {
176
176
  const cwd = process.cwd()
@@ -1,3 +1,4 @@
1
+ import { withPublishing } from "../model-versioning.js"
1
2
  import type { Command } from "commander"
2
3
  import { readFileSync, writeFileSync, existsSync } from "node:fs"
3
4
  import { resolve } from "node:path"
@@ -91,7 +92,7 @@ export async function pushSchemaToLinkedProject(
91
92
  process.exit(1)
92
93
  }
93
94
 
94
- const ast = loadSchemaAst(schemaPathFromProject(config, cwd), cwd)
95
+ const ast = withPublishing(loadSchemaAst(schemaPathFromProject(config, cwd), cwd), config)
95
96
  info(`Pushing schema to ${target.mode} project ${target.projectRef} (${target.environment})...`)
96
97
 
97
98
  const result = await targetSchemaPush(target, ast, {
@@ -11,6 +11,7 @@
11
11
  * supatype deploy logs <version>, show build logs
12
12
  */
13
13
 
14
+ import { withPublishing } from "../model-versioning.js"
14
15
  import type { Command } from "commander"
15
16
  import { existsSync, readdirSync, statSync, createReadStream, mkdirSync, cpSync } from "node:fs"
16
17
  import { join, relative } from "node:path"
@@ -74,7 +75,7 @@ export function registerDeploy(program: Command): void {
74
75
 
75
76
  // Step 1: Schema push (unless --app-only or --skip-build, or already done via cloud.json)
76
77
  if (!opts.appOnly && !opts.skipBuild && !schemaDone) {
77
- const ast = loadSchemaAst(schemaPathFromProject(config, cwd), cwd)
78
+ const ast = withPublishing(loadSchemaAst(schemaPathFromProject(config, cwd), cwd), config)
78
79
 
79
80
  if (opts.local) {
80
81
  step("Schema Push (local)")
@@ -10,6 +10,7 @@
10
10
  * Deno is not provisioned by the CLI on those paths.
11
11
  */
12
12
 
13
+ import { keyspaceInPostgres } from "../cache-provider.js"
13
14
  import type { Command } from "commander"
14
15
  import { spawnSync } from "node:child_process"
15
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
@@ -26,6 +27,7 @@ import {
26
27
  } from "../project-config.js"
27
28
  import { discoverTsFunctionsInDir, writeDevFunctionsRouter } from "../functions-router-gen.js"
28
29
  import { fieldMaskingTierFromProject } from "../field-masking-tier.js"
30
+ import { projectHasVersionedModels } from "../model-versioning.js"
29
31
  import { signJwt } from "../jwt.js"
30
32
  import {
31
33
  devAuthenticatorPassword,
@@ -55,9 +57,13 @@ import { registerDevShutdown } from "../dev-shutdown.js"
55
57
  import { patchRouteManifest } from "../route-manifest.js"
56
58
  import { resolveRealtimeLaunch } from "../realtime-launch.js"
57
59
  import { writeAppViteEnv } from "../app-vite-env.js"
58
- import { ensureValkeySidecar, stopValkeySidecar } from "../valkey-sidecar.js"
60
+ import { ensureValkeySidecar, probeTcp, stopValkeySidecar } from "../valkey-sidecar.js"
59
61
  import {
62
+ firstFreePort,
60
63
  initdb,
64
+ KEYSPACE_PORT_BASE,
65
+ KEYSPACE_PORT_SPAN,
66
+ nativeKeyspaceLibraryPresent,
61
67
  nativeMaskLibraryPresent,
62
68
  start as pgStart,
63
69
  stop as pgStop,
@@ -193,13 +199,46 @@ export function registerDev(program: Command): void {
193
199
  // pgBinDir is set on the native path and used to add DLL search path for
194
200
  // PostgREST on Windows (PostgREST links against libpq + SSL from MinGW).
195
201
  let pgBinDir: string | null = null
202
+ // The port the native Postgres serves RESP on, or null when this archive
203
+ // has no pg_keyspace and the Valkey sidecar is what the cache talks to.
204
+ let keyspacePort: number | null = null
196
205
 
197
206
  {
198
207
  // native: resolve pg bin dir and manage with pg_ctl
199
208
  pgBinDir = await resolvePgBinDir(config)
200
209
  const dataDir = config.database.data_dir ?? join(stateRoot, "data")
201
210
  mkdirSync(dataDir, { recursive: true })
202
- const pgOpts = { pgBinDir, dataDir, port: pgPort, logPath: join(logsDir, "postgres.log") }
211
+ // The RESP keyspace, when this archive carries it: one process instead
212
+ // of a Postgres and a Valkey container. The port is chosen rather than
213
+ // fixed because 6379 is the first thing a developer's own Redis or a
214
+ // leftover sidecar takes, and a keyspace that cannot bind its port
215
+ // fails inside the postmaster log where nobody is looking.
216
+ //
217
+ // `cache.provider: "valkey"` is honoured here too, and this is the only place it could
218
+ // be: the native path asks the archive what it carries rather than the config what it
219
+ // wants, so a project that deliberately kept the sidecar would otherwise find Postgres
220
+ // serving RESP anyway and the sidecar never started.
221
+ const wantsPgKeyspace = keyspaceInPostgres(config)
222
+ keyspacePort =
223
+ wantsPgKeyspace && nativeKeyspaceLibraryPresent(pgBinDir)
224
+ ? await firstFreePort(KEYSPACE_PORT_BASE, KEYSPACE_PORT_SPAN)
225
+ : null
226
+ if (wantsPgKeyspace && nativeKeyspaceLibraryPresent(pgBinDir) && keyspacePort === null) {
227
+ console.warn(
228
+ `[supatype] ⚠ No free port in ${KEYSPACE_PORT_BASE}-${KEYSPACE_PORT_BASE + KEYSPACE_PORT_SPAN - 1} ` +
229
+ "for the Postgres keyspace — starting without it.",
230
+ )
231
+ }
232
+
233
+ const pgOpts = {
234
+ pgBinDir,
235
+ dataDir,
236
+ port: pgPort,
237
+ logPath: join(logsDir, "postgres.log"),
238
+ ...(keyspacePort !== null
239
+ ? { keyspacePort, keyspaceDatabase: projectName }
240
+ : {}),
241
+ }
203
242
 
204
243
  console.log("[supatype] Initialising Postgres data directory...")
205
244
  initdb(pgOpts)
@@ -399,7 +438,27 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticate
399
438
  ? "ses"
400
439
  : "smtp"
401
440
 
402
- const valkeySidecar = ensureValkeySidecar(projectName)
441
+ // Postgres serves RESP itself when the archive had pg_keyspace and the
442
+ // port came up. The probe is not ceremony: shared memory limits, a
443
+ // preload ordering mistake or a port taken between the check and the
444
+ // start all leave a Postgres that is running and a keyspace that is not,
445
+ // and the difference is a cache that silently never hits.
446
+ const nativeKeyspaceAddr =
447
+ keyspacePort !== null && (await probeTcp("127.0.0.1", keyspacePort))
448
+ ? `127.0.0.1:${keyspacePort}`
449
+ : null
450
+ if (keyspacePort !== null && nativeKeyspaceAddr === null) {
451
+ console.warn(
452
+ `[supatype] ⚠ Postgres has pg_keyspace but is not serving RESP on :${keyspacePort} — ` +
453
+ "see logs/postgres.log. Falling back to the Valkey sidecar.",
454
+ )
455
+ }
456
+ if (nativeKeyspaceAddr) {
457
+ console.log(`[supatype] Keyspace served by Postgres (${nativeKeyspaceAddr}).`)
458
+ }
459
+ const valkeySidecar = nativeKeyspaceAddr
460
+ ? { addr: nativeKeyspaceAddr, containerName: null, started: false }
461
+ : ensureValkeySidecar(projectName)
403
462
 
404
463
  const serverEnv: Record<string, string> = {
405
464
  // supatype-server outer layer
@@ -513,12 +572,14 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticate
513
572
  // a non-default pg_schema pushed correctly and then answered PGRST106 on every request.
514
573
  // Native dev has no masking extension either, so a schema with field rules is served from
515
574
  // `api` here too: the case an "is the database external" rule would have got wrong.
516
- PGRST_DB_SCHEMA: apiSchemaList(
517
- config,
575
+ PGRST_DB_SCHEMA: apiSchemaList(config, {
518
576
  // The installed archive decides: one downloaded before the masking library was bundled
519
577
  // has no extension, so its field rules are enforced by views instead.
520
- fieldMaskingTierFromProject(cwd, config, nativeMaskLibraryPresent(pgBinDir)),
521
- ),
578
+ tier: fieldMaskingTierFromProject(cwd, config, nativeMaskLibraryPresent(pgBinDir)),
579
+ // A versioned model adds the generated `draft` schema, or `.draft()` would answer
580
+ // PGRST106 against a stack that had migrated the views into place.
581
+ drafts: projectHasVersionedModels(cwd, config),
582
+ }),
522
583
  // Parity with self-host, which has always set this. Unqualified names in column defaults
523
584
  // (`uuid_generate_v4()`) resolve here, and with a non-public pg_schema the request schema
524
585
  // alone does not reach them.
@@ -1,3 +1,4 @@
1
+ import { withPublishing } from "../model-versioning.js"
1
2
  import type { Command } from "commander"
2
3
  import { loadConfig, loadSchemaAst } from "../config.js"
3
4
  import { resolveRuntimeProvider, schemaPathFromProject } from "../project-config.js"
@@ -19,7 +20,7 @@ export function registerDiff(program: Command): void {
19
20
  const pgSchema = schemaPgSchema(cwd)
20
21
 
21
22
  const ast = await withSpinner("Loading schema", async () =>
22
- loadSchemaAst(schemaPathFromProject(config, cwd), cwd),
23
+ withPublishing(loadSchemaAst(schemaPathFromProject(config, cwd), cwd), config),
23
24
  )
24
25
 
25
26
  const linked = loadProjectLink(cwd)
@@ -141,9 +141,13 @@ function scaffoldFunction(cwd: string, name: string): void {
141
141
  const indexContent = `// ${name}, Supatype Edge Function
142
142
  // Docs: https://supatype.com/docs/edge-functions
143
143
 
144
- export default async function handler(req: Request): Promise<Response> {
144
+ import type { FunctionContext } from "../_shared/context.ts"
145
+
146
+ export default async function handler(req: Request, ctx: FunctionContext): Promise<Response> {
145
147
  const { method } = req
146
- const url = Deno.env.get("SUPATYPE_URL")
148
+ // From the context, not the environment. Per-invocation values are passed in, which is what lets
149
+ // the worker run your functions concurrently instead of one at a time.
150
+ const { url } = ctx
147
151
 
148
152
  // Example: read request body for POST requests
149
153
  if (method === "POST") {
@@ -70,7 +70,7 @@ const STORAGE_PROVIDER_OPTIONS: {
70
70
  label: string
71
71
  hint: string
72
72
  }[] = [
73
- { value: "local", label: "Local", hint: "storage you host yourself (MinIO)" },
73
+ { value: "local", label: "Local", hint: "storage you host yourself (SeaweedFS)" },
74
74
  { value: "s3", label: "S3", hint: "external bucket (AWS S3 or compatible)" },
75
75
  ]
76
76
 
@@ -1164,7 +1164,7 @@ function storageConfigLines(
1164
1164
  if (storageProduction === "s3" && storageLocal !== "s3") {
1165
1165
  lines.push(` // Production storage: external S3 bucket, set production S3_* in .env`)
1166
1166
  } else if (storageProduction === "local" && storageLocal === "s3") {
1167
- lines.push(` // Production storage: MinIO on your server (included in self-host compose)`)
1167
+ lines.push(` // Production storage: SeaweedFS on your server (included in self-host compose)`)
1168
1168
  }
1169
1169
  return lines
1170
1170
  }
@@ -1305,7 +1305,7 @@ S3_SECRET_KEY=`
1305
1305
  }
1306
1306
  return `${localStorageEnvSection("local")}
1307
1307
 
1308
- # Production storage (MinIO on your server)
1308
+ # Production storage (SeaweedFS on your server)
1309
1309
  # Included in the self-host compose stack, no extra configuration needed.`
1310
1310
  }
1311
1311
 
@@ -1323,8 +1323,8 @@ S3_BUCKET=
1323
1323
  S3_ACCESS_KEY=
1324
1324
  S3_SECRET_KEY=`
1325
1325
  }
1326
- return `# Storage (local development, MinIO)
1327
- S3_ENDPOINT=http://localhost:9000
1326
+ return `# Storage (local development, SeaweedFS)
1327
+ S3_ENDPOINT=http://localhost:8333
1328
1328
  S3_ACCESS_KEY=supatype
1329
1329
  S3_SECRET_KEY=supatype-secret`
1330
1330
  }
@@ -1338,7 +1338,7 @@ S3_BUCKET=
1338
1338
  S3_ACCESS_KEY=
1339
1339
  S3_SECRET_KEY=`
1340
1340
  }
1341
- return `# Storage (production, MinIO on your server)
1341
+ return `# Storage (production, SeaweedFS on your server)
1342
1342
  # Included in the self-host compose stack, no extra configuration needed.`
1343
1343
  }
1344
1344
 
@@ -1372,9 +1372,13 @@ function helloFunctionTemplate(): string {
1372
1372
  return `// hello: Supatype Edge Function
1373
1373
  // Docs: https://supatype.com/docs/edge-functions
1374
1374
 
1375
- export default async function handler(req: Request): Promise<Response> {
1375
+ import type { FunctionContext } from "../_shared/context.ts"
1376
+
1377
+ export default async function handler(req: Request, ctx: FunctionContext): Promise<Response> {
1376
1378
  const { method } = req
1377
- const url = Deno.env.get("SUPATYPE_URL")
1379
+ // From the context, not the environment. Per-invocation values are passed in, which is what lets
1380
+ // the worker run your functions concurrently instead of one at a time.
1381
+ const { url } = ctx
1378
1382
 
1379
1383
  if (method === "POST") {
1380
1384
  const body = await req.json()
@@ -1507,7 +1511,7 @@ function printNextSteps(args: {
1507
1511
  selfHostSteps.push("supatype self-host compose up -d # Kong provisions HTTPS automatically")
1508
1512
  selfHostSteps.push(`Your Supatype platform goes live at https://${domain}`)
1509
1513
  selfHostSteps.push(
1510
- "Your app, REST, Auth, Storage, Realtime, Functions, and Studio, all behind one HTTPS domain (certs persist in valkey-data)",
1514
+ "Your app, REST, Auth, Storage, Realtime, Functions, and Studio, all behind one HTTPS domain (certificates outlive restarts)",
1511
1515
  )
1512
1516
  } else {
1513
1517
  selfHostSteps.push(
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander"
2
- import { readFileSync, existsSync, writeFileSync } from "node:fs"
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
3
3
  import { resolve } from "node:path"
4
+ import { readEnvFile, upsertEnvFile } from "../env-file.js"
4
5
  import { signJwt } from "../jwt.js"
5
6
  import { error, plain } from "../ui/messages.js"
6
7
 
@@ -10,7 +11,9 @@ export function registerKeys(program: Command): void {
10
11
  .description("Generate ANON_KEY and SERVICE_ROLE_KEY JWTs from your JWT_SECRET")
11
12
  .option("--secret <secret>", "JWT secret (defaults to JWT_SECRET env var or value in .env)")
12
13
  .option("--exp-years <years>", "Token expiry in years (default: 10)", "10")
13
- .action((opts: { secret?: string; expYears: string }) => {
14
+ .option("--write", "Write the keys into .env in the current directory instead of printing them")
15
+ .option("--force", "With --write, replace every key already in .env rather than filling blanks")
16
+ .action((opts: { secret?: string; expYears: string; write?: boolean; force?: boolean }) => {
14
17
  const secret = opts.secret ?? resolveSecret()
15
18
  if (!secret) {
16
19
  error("JWT_SECRET not found. Set it in .env or pass --secret <value>")
@@ -23,19 +26,116 @@ export function registerKeys(program: Command): void {
23
26
  process.exit(1)
24
27
  }
25
28
 
26
- const now = Math.floor(Date.now() / 1000)
27
- const exp = now + expYears * 365 * 24 * 60 * 60
29
+ const { anonKey, serviceKey } = signKeyPair(secret, expYears)
28
30
 
29
- const anonKey = signJwt({ iss: "supatype", role: "anon", iat: now, exp }, secret)
30
- const serviceKey = signJwt({ iss: "supatype", role: "service_role", iat: now, exp }, secret)
31
+ if (opts.write) {
32
+ const written = writeKeysToEnv(
33
+ process.cwd(),
34
+ { anonKey, serviceKey },
35
+ opts.force === true,
36
+ )
37
+ if (written.path === null) {
38
+ error("No .env here to write to. Create one, or drop --write to print the keys.")
39
+ process.exit(1)
40
+ }
41
+ plain(`\nKeys valid for ${expYears} years, written to ${written.path}:\n`)
42
+ // The names come back from the writer rather than being restated here, which is the whole
43
+ // point of there being one list.
44
+ for (const key of written.written) plain(` ${key}`)
45
+ if (written.kept.length > 0) {
46
+ plain(`\nLeft alone, because they already hold a value:\n`)
47
+ for (const key of written.kept) plain(` ${key}`)
48
+ plain("\nRe-run with --force to replace them.")
49
+ }
50
+ plain("\nDo not commit .env to source control.")
51
+ return
52
+ }
31
53
 
32
54
  plain(`\nGenerated keys (valid for ${expYears} years):\n`)
33
55
  plain("ANON_KEY=" + anonKey)
34
56
  plain("SERVICE_ROLE_KEY=" + serviceKey)
35
- plain("\nAdd these to your .env file. Do not commit .env to source control.")
57
+ plain("\nAdd these to your .env file, or re-run with --write. Do not commit .env to source control.")
36
58
  })
37
59
  }
38
60
 
61
+ /**
62
+ * Every env name a minted key pair is written to, in one place.
63
+ *
64
+ * Shared with `supatype dev`, which writes the same pair when it starts a stack. They used to hold
65
+ * separate lists, so a front end's prefix could be written by one and forgotten by the other, and
66
+ * which names your `.env` ended up with depended on whichever command you happened to run last.
67
+ *
68
+ * The URL names are only included when there is a URL to write, because an empty
69
+ * `VITE_SUPATYPE_URL` is worse than an absent one: the client reads it, finds a blank string, and
70
+ * requests against the page origin without saying why.
71
+ */
72
+ export function anonKeyEnvUpdates(
73
+ anonKey: string,
74
+ serviceKey: string,
75
+ apiUrl?: string | undefined,
76
+ ): Record<string, string> {
77
+ return {
78
+ ANON_KEY: anonKey,
79
+ SERVICE_ROLE_KEY: serviceKey,
80
+ VITE_SUPATYPE_ANON_KEY: anonKey,
81
+ PUBLIC_SUPATYPE_ANON_KEY: anonKey,
82
+ EXPO_PUBLIC_SUPATYPE_ANON_KEY: anonKey,
83
+ ...(apiUrl !== undefined && {
84
+ VITE_SUPATYPE_URL: apiUrl,
85
+ PUBLIC_SUPATYPE_URL: apiUrl,
86
+ EXPO_PUBLIC_SUPATYPE_URL: apiUrl,
87
+ }),
88
+ }
89
+ }
90
+
91
+ export interface WriteKeysResult {
92
+ /** The .env written, or null when there is none here. */
93
+ path: string | null
94
+ written: string[]
95
+ kept: string[]
96
+ }
97
+
98
+ /**
99
+ * Write a minted pair into `dir`'s .env.
100
+ *
101
+ * Blanks only by default. An anon key already in `.env` is held by clients this command cannot
102
+ * reach, and reissuing it locks them out, so filling what is empty is the useful half and
103
+ * replacing what is not is a decision someone has to make out loud. `--force` is how they make it,
104
+ * and it rewrites every name this owns rather than only the empty ones.
105
+ *
106
+ * `--force` does not rewrite the file. `.env` also holds `JWT_SECRET`, which these keys are
107
+ * derived from, along with the database password and whatever else the project keeps there;
108
+ * replacing the file would leave a project whose keys cannot be regenerated and whose stack cannot
109
+ * start. Every line this command does not own is left exactly as it was, comments included.
110
+ */
111
+ export function writeKeysToEnv(
112
+ dir: string,
113
+ keys: { anonKey: string; serviceKey: string },
114
+ force: boolean,
115
+ ): WriteKeysResult {
116
+ const envPath = resolve(dir, ".env")
117
+ if (!existsSync(envPath)) return { path: null, written: [], kept: [] }
118
+
119
+ const existing = readEnvFile(dir)
120
+ const apiUrl = existing["PUBLIC_SUPATYPE_URL"] || existing["API_EXTERNAL_URL"] || undefined
121
+ const candidates = anonKeyEnvUpdates(keys.anonKey, keys.serviceKey, apiUrl)
122
+
123
+ const updates: Record<string, string> = {}
124
+ const kept: string[] = []
125
+ for (const [name, value] of Object.entries(candidates)) {
126
+ // Blank counts as absent: a template ships `ANON_KEY=` and that is exactly the line to fill.
127
+ const current = existing[name]?.trim() ?? ""
128
+ if (!force && current !== "") {
129
+ kept.push(name)
130
+ continue
131
+ }
132
+ updates[name] = value
133
+ }
134
+
135
+ if (Object.keys(updates).length > 0) upsertEnvFile(dir, updates)
136
+ return { path: envPath, written: Object.keys(updates), kept }
137
+ }
138
+
39
139
  // ─── Helpers ─────────────────────────────────────────────────────────────────
40
140
 
41
141
  /** Mint a long-lived anon + service_role JWT pair from a secret. */
@@ -91,6 +191,11 @@ function upsertEnvVar(content: string, key: string, value: string): string {
91
191
  return `${content}${sep}${key}=${value}\n`
92
192
  }
93
193
 
194
+ /** Whether `.env` declares `key` at all, with or without a value. */
195
+ function hasEnvLine(content: string, key: string): boolean {
196
+ return new RegExp(`^${key}=`, "m").test(content)
197
+ }
198
+
94
199
  function readEnvVar(content: string, key: string): string | undefined {
95
200
  const re = new RegExp(`^${key}=(.*)$`, "m")
96
201
  const match = re.exec(content)