@supatype/cli 0.2.1 → 0.3.0

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 (162) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +180 -143
  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/cache.d.ts +1 -1
  20. package/dist/commands/cache.js +3 -3
  21. package/dist/commands/cache.js.map +1 -1
  22. package/dist/commands/cloud.d.ts.map +1 -1
  23. package/dist/commands/cloud.js +2 -1
  24. package/dist/commands/cloud.js.map +1 -1
  25. package/dist/commands/deploy.d.ts.map +1 -1
  26. package/dist/commands/deploy.js +2 -1
  27. package/dist/commands/deploy.js.map +1 -1
  28. package/dist/commands/dev.d.ts.map +1 -1
  29. package/dist/commands/dev.js +61 -8
  30. package/dist/commands/dev.js.map +1 -1
  31. package/dist/commands/diff.d.ts.map +1 -1
  32. package/dist/commands/diff.js +2 -1
  33. package/dist/commands/diff.js.map +1 -1
  34. package/dist/commands/functions.d.ts.map +1 -1
  35. package/dist/commands/functions.js +6 -2
  36. package/dist/commands/functions.js.map +1 -1
  37. package/dist/commands/init.d.ts.map +1 -1
  38. package/dist/commands/init.js +13 -9
  39. package/dist/commands/init.js.map +1 -1
  40. package/dist/commands/migrate.d.ts.map +1 -1
  41. package/dist/commands/migrate.js +2 -1
  42. package/dist/commands/migrate.js.map +1 -1
  43. package/dist/commands/push.d.ts.map +1 -1
  44. package/dist/commands/push.js +52 -1
  45. package/dist/commands/push.js.map +1 -1
  46. package/dist/compose-services.d.ts.map +1 -1
  47. package/dist/compose-services.js +2 -1
  48. package/dist/compose-services.js.map +1 -1
  49. package/dist/dev-compose.d.ts.map +1 -1
  50. package/dist/dev-compose.js +18 -7
  51. package/dist/dev-compose.js.map +1 -1
  52. package/dist/engine-floor.d.ts +11 -1
  53. package/dist/engine-floor.d.ts.map +1 -1
  54. package/dist/engine-floor.js +49 -16
  55. package/dist/engine-floor.js.map +1 -1
  56. package/dist/functions-deno-types.d.ts +9 -0
  57. package/dist/functions-deno-types.d.ts.map +1 -1
  58. package/dist/functions-deno-types.js +45 -1
  59. package/dist/functions-deno-types.js.map +1 -1
  60. package/dist/functions-router-gen.d.ts.map +1 -1
  61. package/dist/functions-router-gen.js +113 -81
  62. package/dist/functions-router-gen.js.map +1 -1
  63. package/dist/kong-config.d.ts +1 -1
  64. package/dist/model-cache.d.ts +17 -0
  65. package/dist/model-cache.d.ts.map +1 -0
  66. package/dist/model-cache.js +45 -0
  67. package/dist/model-cache.js.map +1 -0
  68. package/dist/model-hooks.d.ts +5 -0
  69. package/dist/model-hooks.d.ts.map +1 -1
  70. package/dist/model-hooks.js +29 -1
  71. package/dist/model-hooks.js.map +1 -1
  72. package/dist/model-versioning.d.ts +80 -0
  73. package/dist/model-versioning.d.ts.map +1 -0
  74. package/dist/model-versioning.js +140 -0
  75. package/dist/model-versioning.js.map +1 -0
  76. package/dist/postgres-ctl.d.ts +55 -0
  77. package/dist/postgres-ctl.d.ts.map +1 -1
  78. package/dist/postgres-ctl.js +84 -8
  79. package/dist/postgres-ctl.js.map +1 -1
  80. package/dist/preview-config-check.d.ts +21 -0
  81. package/dist/preview-config-check.d.ts.map +1 -0
  82. package/dist/preview-config-check.js +47 -0
  83. package/dist/preview-config-check.js.map +1 -0
  84. package/dist/project-config.d.ts +182 -5
  85. package/dist/project-config.d.ts.map +1 -1
  86. package/dist/project-config.js +125 -8
  87. package/dist/project-config.js.map +1 -1
  88. package/dist/resolve-target.d.ts +9 -0
  89. package/dist/resolve-target.d.ts.map +1 -1
  90. package/dist/resolve-target.js.map +1 -1
  91. package/dist/rest-cache-admin.d.ts.map +1 -1
  92. package/dist/rest-cache-admin.js +6 -2
  93. package/dist/rest-cache-admin.js.map +1 -1
  94. package/dist/schema-ast-v2.d.ts +32 -1
  95. package/dist/schema-ast-v2.d.ts.map +1 -1
  96. package/dist/schema-ast-v2.js +14 -1
  97. package/dist/schema-ast-v2.js.map +1 -1
  98. package/dist/self-host-compose.d.ts +20 -1
  99. package/dist/self-host-compose.d.ts.map +1 -1
  100. package/dist/self-host-compose.js +181 -27
  101. package/dist/self-host-compose.js.map +1 -1
  102. package/dist/studio-admin-roles.d.ts +8 -1
  103. package/dist/studio-admin-roles.d.ts.map +1 -1
  104. package/dist/studio-admin-roles.js +16 -2
  105. package/dist/studio-admin-roles.js.map +1 -1
  106. package/dist/type-extractor.d.ts +7 -0
  107. package/dist/type-extractor.d.ts.map +1 -1
  108. package/dist/type-extractor.js +234 -3
  109. package/dist/type-extractor.js.map +1 -1
  110. package/package.json +2 -2
  111. package/src/api-config-cache.ts +142 -0
  112. package/src/cache-identity-scope.ts +92 -0
  113. package/src/cache-provider.ts +61 -0
  114. package/src/cli-version-embedded.ts +1 -1
  115. package/src/commands/add.ts +1 -1
  116. package/src/commands/cache.ts +3 -3
  117. package/src/commands/cloud.ts +2 -1
  118. package/src/commands/deploy.ts +2 -1
  119. package/src/commands/dev.ts +68 -7
  120. package/src/commands/diff.ts +2 -1
  121. package/src/commands/functions.ts +6 -2
  122. package/src/commands/init.ts +13 -9
  123. package/src/commands/migrate.ts +2 -1
  124. package/src/commands/push.ts +57 -1
  125. package/src/compose-services.ts +2 -1
  126. package/src/dev-compose.ts +18 -7
  127. package/src/engine-floor.ts +73 -16
  128. package/src/functions-deno-types.ts +48 -1
  129. package/src/functions-router-gen.ts +113 -81
  130. package/src/kong-config.ts +1 -1
  131. package/src/model-cache.ts +74 -0
  132. package/src/model-hooks.ts +26 -2
  133. package/src/model-versioning.ts +167 -0
  134. package/src/postgres-ctl.ts +103 -9
  135. package/src/preview-config-check.ts +52 -0
  136. package/src/project-config.ts +273 -11
  137. package/src/resolve-target.ts +11 -1
  138. package/src/rest-cache-admin.ts +8 -2
  139. package/src/schema-ast-v2.ts +47 -0
  140. package/src/self-host-compose.ts +204 -27
  141. package/src/studio-admin-roles.ts +16 -2
  142. package/src/type-extractor.ts +281 -2
  143. package/tests/api-config-cache-seed.test.ts +180 -0
  144. package/tests/cache-identity-scope.test.ts +61 -0
  145. package/tests/cache-provider-config.test.ts +132 -0
  146. package/tests/engine-floor.test.ts +43 -0
  147. package/tests/external-database-compose.test.ts +74 -3
  148. package/tests/field-masking-tier.test.ts +3 -3
  149. package/tests/fixtures/identity-scope-corpus.json +42 -0
  150. package/tests/functions-concurrency.test.ts +71 -0
  151. package/tests/init.test.ts +36 -2
  152. package/tests/model-cache-declaration.test.ts +229 -0
  153. package/tests/model-cache-manifest.test.ts +99 -0
  154. package/tests/model-hooks.test.ts +55 -0
  155. package/tests/model-versions.test.ts +229 -0
  156. package/tests/native-keyspace.test.ts +130 -0
  157. package/tests/preview-config-check.test.ts +104 -0
  158. package/tests/rest-cache-admin.test.ts +36 -0
  159. package/tests/runtime-contract.test.ts +176 -22
  160. package/tests/studio-admin-roles.test.ts +32 -0
  161. package/tests/type-extractor.test.ts +113 -0
  162. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, afterEach } from "vitest"
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { createServer } from "node:net"
6
+ import {
7
+ firstFreePort,
8
+ nativeKeyspaceLibraryPresent,
9
+ nativeMaskLibraryPresent,
10
+ pgServerOptions,
11
+ } from "../src/postgres-ctl.js"
12
+
13
+ /**
14
+ * Native `supatype dev` starts one Postgres, and what that Postgres can do
15
+ * depends on which libraries the downloaded archive happens to carry. An
16
+ * archive from before pg_keyspace was bundled, and the Windows archive that
17
+ * cannot carry it at all, are both ordinary states — and naming a library that
18
+ * is not there stops the server starting, which turns a missing cache into no
19
+ * database.
20
+ */
21
+ const roots: string[] = []
22
+
23
+ function install(libs: string[], layout: "unix" | "windows" = "unix"): string {
24
+ const root = mkdtempSync(join(tmpdir(), "supatype-pg-"))
25
+ roots.push(root)
26
+ const binDir = join(root, "bin")
27
+ const libDir = layout === "unix" ? join(root, "lib") : join(root, "lib", "postgresql")
28
+ mkdirSync(binDir, { recursive: true })
29
+ mkdirSync(libDir, { recursive: true })
30
+ for (const lib of libs) writeFileSync(join(libDir, lib), "")
31
+ return binDir
32
+ }
33
+
34
+ afterEach(() => {
35
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
36
+ })
37
+
38
+ describe("which libraries the archive carries", () => {
39
+ it("finds each library in both layouts", () => {
40
+ for (const layout of ["unix", "windows"] as const) {
41
+ const ext = layout === "unix" ? "so" : "dll"
42
+ const both = install([`pg_keyspace.${ext}`, `supatype_mask.${ext}`], layout)
43
+ expect(nativeKeyspaceLibraryPresent(both)).toBe(true)
44
+ expect(nativeMaskLibraryPresent(both)).toBe(true)
45
+ }
46
+ expect(nativeKeyspaceLibraryPresent(install(["pg_keyspace.dylib"]))).toBe(true)
47
+ })
48
+
49
+ it("says no for an archive without them, and for no archive at all", () => {
50
+ const bare = install([])
51
+ expect(nativeKeyspaceLibraryPresent(bare)).toBe(false)
52
+ expect(nativeMaskLibraryPresent(bare)).toBe(false)
53
+ for (const absent of [null, undefined, ""]) {
54
+ expect(nativeKeyspaceLibraryPresent(absent)).toBe(false)
55
+ }
56
+ })
57
+ })
58
+
59
+ describe("the options pg_ctl hands the server", () => {
60
+ const opts = (pgBinDir: string, extra: Record<string, unknown> = {}) => ({
61
+ pgBinDir,
62
+ dataDir: "/tmp/data",
63
+ port: 55432,
64
+ ...extra,
65
+ })
66
+
67
+ it("preloads nothing an archive does not carry", () => {
68
+ expect(pgServerOptions(opts(install([]), { keyspacePort: 6379 }))).toBe("-p 55432")
69
+ })
70
+
71
+ it("preloads the mask alone, as it did before the keyspace existed", () => {
72
+ const o = pgServerOptions(opts(install(["supatype_mask.so"]), { keyspacePort: 6379 }))
73
+ expect(o).toBe("-p 55432 -c shared_preload_libraries=supatype_mask")
74
+ })
75
+
76
+ // pg_keyspace refuses to serve when the mask loads before it, because RESP
77
+ // would hand back rows the mask never rewrote. The order is the safety
78
+ // property, not a style choice.
79
+ it("loads pg_keyspace before the mask, so the mask stays outermost", () => {
80
+ const o = pgServerOptions(opts(install(["pg_keyspace.so", "supatype_mask.so"]), { keyspacePort: 6379 }))
81
+ expect(o).toContain("-c shared_preload_libraries=pg_keyspace,supatype_mask")
82
+ })
83
+
84
+ it("sizes the keyspace for a laptop and keeps it ephemeral", () => {
85
+ const o = pgServerOptions(
86
+ opts(install(["pg_keyspace.so"]), { keyspacePort: 6380, keyspaceDatabase: "acme" }),
87
+ )
88
+ expect(o).toContain("-c pg_keyspace.port=6380")
89
+ expect(o).toContain("-c pg_keyspace.durability=ephemeral")
90
+ expect(o).toContain("-c pg_keyspace.keys=50000")
91
+ expect(o).toContain("-c pg_keyspace.ring_mb=8")
92
+ expect(o).toContain("-c pg_keyspace.rowcache_mb=1")
93
+ expect(o).toContain("-c pg_keyspace.database=acme")
94
+ })
95
+
96
+ // No port means the caller decided against RESP — an archive that has the
97
+ // library is not a reason to serve on a port nobody chose.
98
+ it("serves no keyspace without a port, even with the library present", () => {
99
+ const o = pgServerOptions(opts(install(["pg_keyspace.so", "supatype_mask.so"])))
100
+ expect(o).toBe("-p 55432 -c shared_preload_libraries=supatype_mask")
101
+ })
102
+ })
103
+
104
+ describe("choosing the keyspace port", () => {
105
+ it("takes the base port when it is free", async () => {
106
+ await expect(firstFreePort(6390, 5)).resolves.toBe(6390)
107
+ })
108
+
109
+ // 6379 is the first port a developer's own Redis, or a sidecar left running
110
+ // from a previous `supatype dev`, will be holding.
111
+ it("steps past a port something else is holding", async () => {
112
+ const held = createServer()
113
+ await new Promise<void>((done) => held.listen(6391, "127.0.0.1", done))
114
+ try {
115
+ await expect(firstFreePort(6391, 5)).resolves.toBe(6392)
116
+ } finally {
117
+ await new Promise<void>((done) => held.close(() => done()))
118
+ }
119
+ })
120
+
121
+ it("gives up rather than returning a port it could not get", async () => {
122
+ const held = createServer()
123
+ await new Promise<void>((done) => held.listen(6393, "127.0.0.1", done))
124
+ try {
125
+ await expect(firstFreePort(6393, 1)).resolves.toBeNull()
126
+ } finally {
127
+ await new Promise<void>((done) => held.close(() => done()))
128
+ }
129
+ })
130
+ })
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ modelsWithUnresolvablePreview,
4
+ unresolvablePreviewMessage,
5
+ } from "../src/preview-config-check.js"
6
+ import type { SupatypeProjectConfig } from "../src/project-config.js"
7
+
8
+ // A preview address may be a path, because with `app.mode` of `static` or `proxy` this deployment
9
+ // serves the project's app at `/` on the same origin as the API. Studio resolves the path against
10
+ // the origin it is loaded from, so the origin never has to be written down or kept in step.
11
+ //
12
+ // With `app.mode: "none"` nothing is served at `/`. The path then resolves against an origin that
13
+ // answers 404, and the failure lands in front of whoever was *sent* the link rather than whoever
14
+ // configured it. That is the worst possible place for it, so the push refuses instead.
15
+
16
+ function project(
17
+ mode: SupatypeProjectConfig["app"]["mode"],
18
+ livePreview?: Record<string, { url?: string; urlPattern?: string }>,
19
+ ): SupatypeProjectConfig {
20
+ return {
21
+ project: { name: "acme" },
22
+ database: { provider: "docker" },
23
+ server: { mode: "dev" },
24
+ app: { mode },
25
+ versions: { engine: "0", server: "0", postgres: "0", deno: "0" },
26
+ ...(livePreview !== undefined && { admin: { livePreview } }),
27
+ } as SupatypeProjectConfig
28
+ }
29
+
30
+ describe("preview addresses that could never open", () => {
31
+ it("refuses a path when nothing is served at /", () => {
32
+ expect(
33
+ modelsWithUnresolvablePreview(project("none", { Post: { urlPattern: "/preview/{slug}" } })),
34
+ ).toEqual(["Post"])
35
+ })
36
+
37
+ it("allows a path when this deployment serves the app", () => {
38
+ for (const mode of ["static", "proxy"] as const) {
39
+ expect(
40
+ modelsWithUnresolvablePreview(project(mode, { Post: { urlPattern: "/preview/{slug}" } })),
41
+ ).toEqual([])
42
+ }
43
+ })
44
+
45
+ it("allows an absolute URL in any mode, since it carries its own origin", () => {
46
+ expect(
47
+ modelsWithUnresolvablePreview(
48
+ project("none", { Post: { urlPattern: "https://example.com/p/{slug}" } }),
49
+ ),
50
+ ).toEqual([])
51
+ expect(
52
+ modelsWithUnresolvablePreview(project("none", { Post: { url: "https://example.com/p" } })),
53
+ ).toEqual([])
54
+ })
55
+
56
+ it("allows a protocol-relative URL, which already names a host", () => {
57
+ expect(
58
+ modelsWithUnresolvablePreview(project("none", { Post: { urlPattern: "//cdn.example/{s}" } })),
59
+ ).toEqual([])
60
+ })
61
+
62
+ it("says nothing about a model that configured no preview at all", () => {
63
+ // Not misconfigured, just not configured. Studio asks for the setting where it is needed; a
64
+ // push has no business refusing over a feature the project has not opted into.
65
+ expect(modelsWithUnresolvablePreview(project("none", { Post: {} }))).toEqual([])
66
+ expect(modelsWithUnresolvablePreview(project("none", { Post: { urlPattern: "" } }))).toEqual([])
67
+ expect(modelsWithUnresolvablePreview(project("none"))).toEqual([])
68
+ })
69
+
70
+ it("names every model at fault, in one go", () => {
71
+ // An operator fixing these one push at a time is an operator running the push four times.
72
+ const models = modelsWithUnresolvablePreview(
73
+ project("none", {
74
+ Post: { urlPattern: "/p/{slug}" },
75
+ Author: { urlPattern: "/a/{slug}" },
76
+ Page: { urlPattern: "https://ok.example/{slug}" },
77
+ }),
78
+ )
79
+ expect(models).toEqual(["Author", "Post"])
80
+ })
81
+
82
+ it("prefers the pattern over the url, which is what Studio does", () => {
83
+ // A project giving both gets the pattern. Checking the url instead would pass a config that
84
+ // then renders from the path and breaks.
85
+ expect(
86
+ modelsWithUnresolvablePreview(
87
+ project("none", { Post: { url: "https://example.com", urlPattern: "/p/{slug}" } }),
88
+ ),
89
+ ).toEqual(["Post"])
90
+ })
91
+ })
92
+
93
+ describe("what the refusal says", () => {
94
+ it("names the models and the mode that make it unresolvable", () => {
95
+ const message = unresolvablePreviewMessage(["Author", "Post"])
96
+ expect(message).toContain("Author, Post")
97
+ expect(message).toContain('app.mode is "none"')
98
+ })
99
+
100
+ it("reads correctly for a single model", () => {
101
+ expect(unresolvablePreviewMessage(["Post"])).toContain("that path")
102
+ expect(unresolvablePreviewMessage(["Post", "Author"])).toContain("those paths")
103
+ })
104
+ })
@@ -26,3 +26,39 @@ describe("listRestCacheEntries", () => {
26
26
  }
27
27
  })
28
28
  })
29
+
30
+ /**
31
+ * The server answers 503 with "keyspace not configured" now and "valkey not
32
+ * configured" before the rename. A CLI is routinely newer than the deployment
33
+ * it is pointed at — and older than one, on a machine that has not updated —
34
+ * so both have to land on the same explanation.
35
+ */
36
+ describe("the message for a cache server that is not there", () => {
37
+ const listAgainst = async (body: string) => {
38
+ const { listRestCacheEntries } = await import("../src/rest-cache-admin.js")
39
+ const originalFetch = globalThis.fetch
40
+ globalThis.fetch = vi.fn(async () => new Response(body, { status: 503 }))
41
+ try {
42
+ return await listRestCacheEntries(process.cwd()).then(
43
+ () => null,
44
+ (e: unknown) => (e instanceof Error ? e.message : String(e)),
45
+ )
46
+ } finally {
47
+ globalThis.fetch = originalFetch
48
+ }
49
+ }
50
+
51
+ it("explains either spelling without naming a product the user may not run", async () => {
52
+ for (const body of ['{"error":"keyspace not configured"}', '{"error":"valkey not configured"}']) {
53
+ const message = await listAgainst(body)
54
+ expect(message).toContain("No cache server is configured")
55
+ expect(message).toContain("supatype dev")
56
+ }
57
+ })
58
+
59
+ it("does not claim that explanation for an unrelated 503", async () => {
60
+ const message = await listAgainst('{"error":"upstream timeout"}')
61
+ expect(message).not.toContain("No cache server is configured")
62
+ expect(message).toContain("upstream timeout")
63
+ })
64
+ })
@@ -22,6 +22,19 @@ const baseConfig: SupatypeProjectConfig = {
22
22
  },
23
23
  }
24
24
 
25
+ /**
26
+ * The same project with no `versions.postgres` pin.
27
+ *
28
+ * The keyspace is served by the image, and an image pinned to a release older than the toggle
29
+ * ignores `SUPATYPE_KEYSPACE_ENABLED` and comes up with no RESP listener — which is not an error
30
+ * anywhere. So a pin resolves the default to Valkey, and the tests about the default have to say
31
+ * which world they are in rather than inheriting one by accident.
32
+ */
33
+ const unpinnedPostgres = (config: SupatypeProjectConfig): SupatypeProjectConfig => {
34
+ const { postgres: _dropped, ...rest } = config.versions ?? {}
35
+ return { ...config, versions: rest }
36
+ }
37
+
25
38
  describe("runtime contract", () => {
26
39
  it("includes core route families", () => {
27
40
  const paths = runtimeRouteSpec().flatMap((r) => r.paths)
@@ -568,6 +581,11 @@ export default defineConfig({
568
581
  expect(compose).toContain("${SUPATYPE_SERVER_IMAGE:-${SUPATYPE_AUTH_IMAGE:-supatype/server:latest}}")
569
582
  expect(compose).toContain("${SUPATYPE_STORAGE_IMAGE:-supatype/storage:latest}")
570
583
  expect(compose).toContain("${SUPATYPE_STUDIO_IMAGE:-supatype/studio:latest}")
584
+ // SeaweedFS is the object store, and the only one: MinIO's community edition is archived and
585
+ // its image was withdrawn, so a stack that still named it could not start at all.
586
+ expect(compose).toContain(" seaweedfs:")
587
+ expect(compose).toContain("S3_ENDPOINT: http://seaweedfs:8333")
588
+ expect(compose).not.toContain("minio")
571
589
  expect(compose).toContain("SUPATYPE_POSTGREST_URL: http://postgrest:3000")
572
590
  expect(compose).toContain("unified gateway")
573
591
  const kong = readFileSync(out.kongPath, "utf8")
@@ -579,32 +597,161 @@ export default defineConfig({
579
597
  }
580
598
  })
581
599
 
600
+ it("renders SeaweedFS with its identities and its own volume", () => {
601
+ const dir = mkdtempSync(join(tmpdir(), "supatype-seaweed-"))
602
+ try {
603
+ const out = writeSelfHostCompose(dir, { ...baseConfig, app: { mode: "none" } })
604
+ const compose = readFileSync(out.composePath, "utf8")
605
+
606
+ expect(compose).toContain(" seaweedfs:")
607
+ // The storage service has to be pointed at the server that is actually running. Getting this
608
+ // wrong renders a stack that starts cleanly and fails on the first upload.
609
+ expect(compose).toContain("S3_ENDPOINT: http://seaweedfs:8333")
610
+ expect(compose).toContain("-s3.config=/etc/seaweedfs/s3.json")
611
+ expect(compose).toContain(".supatype/self-host/s3.json:/etc/seaweedfs/s3.json:ro")
612
+ expect(compose).toContain(" storage-data:")
613
+
614
+ // Same credentials in both halves, checked rather than assumed: a mismatch here does not
615
+ // fail at start, it fails at the first upload, which is a much worse place to find it.
616
+ const identities = JSON.parse(readFileSync(out.s3ConfigPath, "utf8")) as {
617
+ identities: { name: string; credentials: { accessKey: string; secretKey: string }[] }[]
618
+ }
619
+ const credential = identities.identities[0]?.credentials[0]
620
+ expect(compose).toContain(`S3_ACCESS_KEY: ${credential?.accessKey}`)
621
+ expect(compose).toContain(`S3_SECRET_KEY: ${credential?.secretKey}`)
622
+
623
+ // No anonymous identity: a bucket is public because of its policy, not because the server is.
624
+ expect(identities.identities.map((i) => i.name)).not.toContain("anonymous")
625
+ } finally {
626
+ rmSync(dir, { recursive: true, force: true })
627
+ }
628
+ })
629
+
582
630
  const tlsConfig: SupatypeProjectConfig = {
583
631
  ...baseConfig,
584
632
  server: { mode: "standalone", domain: "api.example.com", tls: { email: "ops@example.com" } },
585
633
  }
586
634
 
587
- it("self-host compose renders Kong TLS + Valkey when standalone domain + email are set", () => {
588
- const compose = renderSelfHostCompose(tlsConfig)
589
- expect(compose).toContain("\n valkey:\n")
590
- expect(compose).toContain("valkey/valkey:8-alpine")
635
+ it("self-host compose renders Kong TLS with the certificate store in Postgres", () => {
636
+ const compose = renderSelfHostCompose(unpinnedPostgres(tlsConfig))
591
637
  expect(compose).toContain('- "80:8000"')
592
638
  expect(compose).toContain('- "443:8443"')
593
639
  expect(compose).toContain("KONG_PROXY_LISTEN")
594
- expect(compose).toContain("- valkey")
595
640
  expect(compose).toContain("https://api.example.com")
596
- expect(compose).toMatch(/^\s{2}valkey-data:/m)
597
641
  expect(compose).not.toContain("HTTPS is off")
642
+ // The cert store is the db container, which Kong now depends on instead of a sidecar.
643
+ expect(compose).toContain("SUPATYPE_VALKEY_ADDR: db:6379")
644
+ expect(compose).not.toContain("\n valkey:\n")
598
645
  })
599
646
 
600
- it("self-host compose always includes Valkey and SUPATYPE_VALKEY_ADDR on server", () => {
601
- const compose = renderSelfHostCompose(baseConfig)
647
+ it("self-host compose renders Kong TLS + Valkey where the project keeps the sidecar", () => {
648
+ const compose = renderSelfHostCompose({ ...tlsConfig, cache: { provider: "valkey" } })
649
+ expect(compose).toContain("\n valkey:\n")
650
+ expect(compose).toContain("valkey/valkey:8-alpine")
651
+ expect(compose).toContain("- valkey")
652
+ expect(compose).toMatch(/^\s{2}valkey-data:/m)
653
+ expect(compose).toContain('- "443:8443"')
654
+ })
655
+
656
+ it("self-host compose includes Valkey and SUPATYPE_VALKEY_ADDR when it is the chosen provider", () => {
657
+ const compose = renderSelfHostCompose({ ...baseConfig, cache: { provider: "valkey" } })
602
658
  expect(compose).toContain("\n valkey:\n")
603
659
  expect(compose).toContain("valkey/valkey:8-alpine")
604
660
  expect(compose).toContain("SUPATYPE_VALKEY_ADDR: valkey:6379")
605
661
  expect(compose).toMatch(/^\s{2}valkey-data:/m)
606
662
  })
607
663
 
664
+ it("self-host compose serves the keyspace from Postgres, which is what it does by default", () => {
665
+ // Asserted without a `cache` section at all: the default is the behaviour, and a test that
666
+ // only ever passed `provider: "pg_keyspace"` would go on passing after the default moved back.
667
+ const compose = renderSelfHostCompose(unpinnedPostgres(baseConfig))
668
+ // No second stateful service, and nothing left pointing at one.
669
+ expect(compose).not.toContain("\n valkey:\n")
670
+ expect(compose).not.toContain("valkey/valkey:8-alpine")
671
+ expect(compose).not.toMatch(/^\s{2}valkey-data:/m)
672
+ expect(compose).not.toContain(" valkey:\n condition: service_started")
673
+ expect(compose).toContain("SUPATYPE_VALKEY_ADDR: db:6379")
674
+ // Turned on from the environment, so the image owns its own preload list.
675
+ // A copy of that list here would go stale the first time it gains a library.
676
+ expect(compose).toContain('SUPATYPE_KEYSPACE_ENABLED: "1"')
677
+ expect(compose).not.toContain("shared_preload_libraries")
678
+ // Durable keys are rows in a database, and it has to be this one.
679
+ expect(compose).toContain("SUPATYPE_KEYSPACE_DATABASE: ${POSTGRES_DB:-supatype}")
680
+ // A cache by default, with certificates the one thing kept.
681
+ expect(compose).toContain("SUPATYPE_KEYSPACE_DURABILITY: ephemeral")
682
+ expect(compose).toContain('SUPATYPE_KEYSPACE_DURABILITY_OVERRIDES: "kong_acme:=durable"')
683
+ // Shared memory is reserved at postmaster start whether or not it is used,
684
+ // so an unsized keyspace is memory a small host does not have.
685
+ expect(compose).toContain('SUPATYPE_KEYSPACE_KEYS: "200000"')
686
+ expect(compose).toContain('SUPATYPE_KEYSPACE_RING_MB: "16"')
687
+ expect(compose).toContain('SUPATYPE_KEYSPACE_ROWCACHE_MB: "64"')
688
+ // Reachable to the compose network, not published: RESP on a public
689
+ // interface is an unauthenticated read of every cached response.
690
+ expect(compose).toMatch(/expose:\n\s+- "6379"/)
691
+ expect(compose).not.toMatch(/- "\d+:6379"/)
692
+ })
693
+
694
+ it("self-host compose keeps the certificates durable whether or not TLS is on today", () => {
695
+ // The override costs nothing while no key matches it, and turning TLS on
696
+ // later should not need a database restart to make certificates safe.
697
+ for (const tls of [false, true]) {
698
+ const compose = renderSelfHostCompose({
699
+ ...baseConfig,
700
+ ...(tls
701
+ ? { server: { ...baseConfig.server, mode: "standalone" as const, domain: "example.test", tls: { email: "ops@example.test" } } }
702
+ : {}),
703
+ cache: { provider: "pg_keyspace" },
704
+ })
705
+ expect(compose).toContain('SUPATYPE_KEYSPACE_DURABILITY_OVERRIDES: "kong_acme:=durable"')
706
+ }
707
+ })
708
+
709
+ it("self-host compose keeps Valkey where the project asks for it", () => {
710
+ // Selectable, not deprecated: a project that wrote this down keeps the sidecar, and the
711
+ // Postgres it runs beside is left without a keyspace rather than quietly serving one too.
712
+ const compose = renderSelfHostCompose({ ...baseConfig, cache: { provider: "valkey" } })
713
+ expect(compose).toContain("\n valkey:\n")
714
+ expect(compose).not.toContain("SUPATYPE_KEYSPACE_ENABLED")
715
+ })
716
+
717
+ it("self-host compose keeps Valkey for a project that pins its Postgres image", () => {
718
+ // The toggle is honoured by the image, from the release that introduced it. An older pinned
719
+ // image ignores it and starts with no RESP listener — a stack that comes up, and a cache that
720
+ // never hits. A pin says the image is fixed, and a default must not assume something about a
721
+ // fixed image it cannot check; `cache: { provider: "pg_keyspace" }` is how a project on a
722
+ // capable pin says so.
723
+ const compose = renderSelfHostCompose(baseConfig)
724
+ expect(compose).toContain("\n valkey:\n")
725
+ expect(compose).not.toContain("SUPATYPE_KEYSPACE_ENABLED")
726
+
727
+ const asked = renderSelfHostCompose({ ...baseConfig, cache: { provider: "pg_keyspace" } })
728
+ expect(asked).toContain('SUPATYPE_KEYSPACE_ENABLED: "1"')
729
+ })
730
+
731
+ it("self-host compose gives an external database Valkey, because nothing else can run", () => {
732
+ // pg_keyspace is loaded through shared_preload_libraries, which is a property of a Postgres
733
+ // this stack starts. The default has to resolve by itself here: an absent `cache.provider`
734
+ // cannot mean the same thing beside a managed database and an unmanaged one.
735
+ const compose = renderSelfHostCompose({
736
+ ...unpinnedPostgres(baseConfig),
737
+ database: { external: { url: "postgres://u:p@h:5432/d" } },
738
+ })
739
+ expect(compose).toContain("\n valkey:\n")
740
+ expect(compose).not.toContain("SUPATYPE_KEYSPACE_ENABLED")
741
+ })
742
+
743
+ it("self-host compose persists the named prefixes alongside the certificates", () => {
744
+ const compose = renderSelfHostCompose({
745
+ ...baseConfig,
746
+ cache: { provider: "pg_keyspace", durablePrefixes: ["session:", "flag:"] },
747
+ })
748
+ expect(compose).toContain("SUPATYPE_KEYSPACE_DURABILITY: ephemeral")
749
+ // ACME first: a project cannot displace it by naming prefixes of its own.
750
+ expect(compose).toContain(
751
+ 'SUPATYPE_KEYSPACE_DURABILITY_OVERRIDES: "kong_acme:=durable, session:=durable, flag:=durable"',
752
+ )
753
+ })
754
+
608
755
  it("self-host compose stays plain HTTP with a discoverable hint when TLS is off", () => {
609
756
  const compose = renderSelfHostCompose(baseConfig)
610
757
  expect(compose).not.toContain('- "443:8443"')
@@ -617,23 +764,30 @@ export default defineConfig({
617
764
  ...baseConfig,
618
765
  server: { mode: "standalone", domain: "api.example.com" },
619
766
  })
620
- expect(compose).toContain("\n valkey:\n")
621
767
  expect(compose).not.toContain('- "443:8443"')
622
768
  })
623
769
 
624
- it("writeSelfHostCompose emits a Kong acme plugin backed by Valkey when TLS is on", () => {
625
- const dir = mkdtempSync(join(tmpdir(), "supatype-tls-"))
626
- try {
627
- const out = writeSelfHostCompose(dir, tlsConfig)
628
- const kong = readFileSync(out.kongPath, "utf8")
629
- expect(kong).toContain("name: acme")
630
- expect(kong).toContain('account_email: "ops@example.com"')
631
- expect(kong).toContain("tos_accepted: true")
632
- expect(kong).toContain('- "api.example.com"')
633
- expect(kong).toContain("storage: redis")
634
- expect(kong).toContain('host: "valkey"')
635
- } finally {
636
- rmSync(dir, { recursive: true, force: true })
770
+ it("writeSelfHostCompose emits a Kong acme plugin pointed at whichever RESP server runs", () => {
771
+ // The certificates are the one thing in the keyspace that must survive a restart, so the host
772
+ // Kong is told about has to be the one the stack actually started. Both directions asserted:
773
+ // a mismatch here is a Kong that cannot store its certificate, found on the first renewal.
774
+ for (const { cache, host } of [
775
+ { cache: undefined, host: "db" },
776
+ { cache: { provider: "valkey" as const }, host: "valkey" },
777
+ ]) {
778
+ const dir = mkdtempSync(join(tmpdir(), "supatype-tls-"))
779
+ try {
780
+ const out = writeSelfHostCompose(dir, { ...unpinnedPostgres(tlsConfig), ...(cache ? { cache } : {}) })
781
+ const kong = readFileSync(out.kongPath, "utf8")
782
+ expect(kong).toContain("name: acme")
783
+ expect(kong).toContain('account_email: "ops@example.com"')
784
+ expect(kong).toContain("tos_accepted: true")
785
+ expect(kong).toContain('- "api.example.com"')
786
+ expect(kong).toContain("storage: redis")
787
+ expect(kong).toContain(`host: "${host}"`)
788
+ } finally {
789
+ rmSync(dir, { recursive: true, force: true })
790
+ }
637
791
  }
638
792
  })
639
793
 
@@ -25,3 +25,35 @@ describe("studioAdminRoles", () => {
25
25
  expect(merged).toEqual({ models: [], adminRoles: ["ops"] })
26
26
  })
27
27
  })
28
+
29
+ describe("where the project says its models render", () => {
30
+ const livePreview = {
31
+ Post: { url: "https://example.com", urlPattern: "https://example.com/preview/{slug}" },
32
+ }
33
+
34
+ it("carries livePreview through to the admin config Studio reads", () => {
35
+ // Studio cannot derive this from the schema: it is a fact about a deployment, not a shape. It
36
+ // reaches Studio only if this merge puts it in the file.
37
+ const merged = withAdminRoles({ models: [] }, { ...baseConfig, admin: { livePreview } })
38
+ expect(merged["livePreview"]).toEqual(livePreview)
39
+ })
40
+
41
+ it("says nothing at all when the project has not configured it", () => {
42
+ // Absent, not empty. Studio treats an absent entry as "this project has not said", which is
43
+ // what makes it ask for the setting instead of offering a share link that opens nowhere. An
44
+ // empty object would read as "configured, with no models".
45
+ const merged = withAdminRoles({ models: [] }, baseConfig)
46
+ expect(merged).not.toHaveProperty("livePreview")
47
+
48
+ const empty = withAdminRoles({ models: [] }, { ...baseConfig, admin: { livePreview: {} } })
49
+ expect(empty).not.toHaveProperty("livePreview")
50
+ })
51
+
52
+ it("keeps the roles alongside it, since one merge writes both", () => {
53
+ const merged = withAdminRoles(
54
+ { models: [] },
55
+ { ...baseConfig, admin: { roles: ["ops"], livePreview } },
56
+ )
57
+ expect(merged).toEqual({ models: [], adminRoles: ["ops"], livePreview })
58
+ })
59
+ })