@supatype/cli 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +168 -158
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +77 -0
  5. package/dist/api-config-cache.d.ts +20 -0
  6. package/dist/api-config-cache.d.ts.map +1 -1
  7. package/dist/api-config-cache.js +27 -0
  8. package/dist/api-config-cache.js.map +1 -1
  9. package/dist/augmentation-generator.d.ts.map +1 -1
  10. package/dist/augmentation-generator.js +126 -26
  11. package/dist/augmentation-generator.js.map +1 -1
  12. package/dist/cli-version-embedded.js +1 -1
  13. package/dist/client-generator.d.ts +3 -0
  14. package/dist/client-generator.d.ts.map +1 -0
  15. package/dist/client-generator.js +108 -0
  16. package/dist/client-generator.js.map +1 -0
  17. package/dist/commands/functions.d.ts.map +1 -1
  18. package/dist/commands/functions.js +2 -2
  19. package/dist/commands/functions.js.map +1 -1
  20. package/dist/commands/generate.d.ts.map +1 -1
  21. package/dist/commands/generate.js +3 -3
  22. package/dist/commands/generate.js.map +1 -1
  23. package/dist/commands/init.d.ts.map +1 -1
  24. package/dist/commands/init.js +36 -29
  25. package/dist/commands/init.js.map +1 -1
  26. package/dist/commands/push.d.ts.map +1 -1
  27. package/dist/commands/push.js +14 -13
  28. package/dist/commands/push.js.map +1 -1
  29. package/dist/commands/seed.d.ts +27 -1
  30. package/dist/commands/seed.d.ts.map +1 -1
  31. package/dist/commands/seed.js +206 -33
  32. package/dist/commands/seed.js.map +1 -1
  33. package/dist/commands/update.d.ts.map +1 -1
  34. package/dist/commands/update.js +19 -1
  35. package/dist/commands/update.js.map +1 -1
  36. package/dist/db-port.d.ts +36 -0
  37. package/dist/db-port.d.ts.map +1 -0
  38. package/dist/db-port.js +90 -0
  39. package/dist/db-port.js.map +1 -0
  40. package/dist/dev-compose.d.ts.map +1 -1
  41. package/dist/dev-compose.js +108 -13
  42. package/dist/dev-compose.js.map +1 -1
  43. package/dist/engine-client.d.ts +1 -0
  44. package/dist/engine-client.d.ts.map +1 -1
  45. package/dist/engine-client.js +47 -2
  46. package/dist/engine-client.js.map +1 -1
  47. package/dist/engine-floor.d.ts +27 -4
  48. package/dist/engine-floor.d.ts.map +1 -1
  49. package/dist/engine-floor.js +90 -9
  50. package/dist/engine-floor.js.map +1 -1
  51. package/dist/functions-context-refresh.d.ts +4 -0
  52. package/dist/functions-context-refresh.d.ts.map +1 -0
  53. package/dist/functions-context-refresh.js +26 -0
  54. package/dist/functions-context-refresh.js.map +1 -0
  55. package/dist/functions-deno-types.d.ts +9 -0
  56. package/dist/functions-deno-types.d.ts.map +1 -1
  57. package/dist/functions-deno-types.js +21 -1
  58. package/dist/functions-deno-types.js.map +1 -1
  59. package/dist/gitignore.d.ts +3 -1
  60. package/dist/gitignore.d.ts.map +1 -1
  61. package/dist/gitignore.js +35 -6
  62. package/dist/gitignore.js.map +1 -1
  63. package/dist/hooks-generator.d.ts.map +1 -1
  64. package/dist/hooks-generator.js +18 -3
  65. package/dist/hooks-generator.js.map +1 -1
  66. package/dist/schema-ast-v2.d.ts.map +1 -1
  67. package/dist/schema-ast-v2.js +17 -8
  68. package/dist/schema-ast-v2.js.map +1 -1
  69. package/dist/seed-connection.d.ts +60 -0
  70. package/dist/seed-connection.d.ts.map +1 -0
  71. package/dist/seed-connection.js +90 -0
  72. package/dist/seed-connection.js.map +1 -0
  73. package/dist/seed-ir.d.ts +66 -0
  74. package/dist/seed-ir.d.ts.map +1 -0
  75. package/dist/seed-ir.js +265 -0
  76. package/dist/seed-ir.js.map +1 -0
  77. package/dist/seed-runner.d.ts +44 -0
  78. package/dist/seed-runner.d.ts.map +1 -0
  79. package/dist/seed-runner.js +83 -0
  80. package/dist/seed-runner.js.map +1 -0
  81. package/dist/seed.d.ts +345 -0
  82. package/dist/seed.d.ts.map +1 -1
  83. package/dist/seed.js +138 -0
  84. package/dist/seed.js.map +1 -1
  85. package/dist/self-host-compose.d.ts +10 -2
  86. package/dist/self-host-compose.d.ts.map +1 -1
  87. package/dist/self-host-compose.js +73 -6
  88. package/dist/self-host-compose.js.map +1 -1
  89. package/dist/strict.d.ts +29 -0
  90. package/dist/strict.d.ts.map +1 -0
  91. package/dist/strict.js +37 -0
  92. package/dist/strict.js.map +1 -0
  93. package/dist/studio-dev-server.d.ts +12 -2
  94. package/dist/studio-dev-server.d.ts.map +1 -1
  95. package/dist/studio-dev-server.js +12 -2
  96. package/dist/studio-dev-server.js.map +1 -1
  97. package/dist/type-extractor.d.ts.map +1 -1
  98. package/dist/type-extractor.js +81 -2
  99. package/dist/type-extractor.js.map +1 -1
  100. package/dist/type-generation.d.ts +43 -0
  101. package/dist/type-generation.d.ts.map +1 -1
  102. package/dist/type-generation.js +93 -2
  103. package/dist/type-generation.js.map +1 -1
  104. package/package.json +2 -2
  105. package/src/api-config-cache.ts +30 -0
  106. package/src/augmentation-generator.ts +142 -26
  107. package/src/cli-version-embedded.ts +1 -1
  108. package/src/client-generator.ts +122 -0
  109. package/src/commands/functions.ts +5 -2
  110. package/src/commands/generate.ts +7 -3
  111. package/src/commands/init.ts +41 -31
  112. package/src/commands/push.ts +14 -15
  113. package/src/commands/seed.ts +294 -44
  114. package/src/commands/update.ts +20 -1
  115. package/src/db-port.ts +99 -0
  116. package/src/dev-compose.ts +116 -16
  117. package/src/engine-client.ts +52 -3
  118. package/src/engine-floor.ts +100 -9
  119. package/src/functions-context-refresh.ts +25 -0
  120. package/src/functions-deno-types.ts +24 -1
  121. package/src/gitignore.ts +38 -10
  122. package/src/hooks-generator.ts +21 -6
  123. package/src/schema-ast-v2.ts +17 -8
  124. package/src/seed-connection.ts +146 -0
  125. package/src/seed-ir.ts +311 -0
  126. package/src/seed-runner.ts +109 -0
  127. package/src/seed.ts +408 -0
  128. package/src/self-host-compose.ts +74 -6
  129. package/src/strict.ts +40 -0
  130. package/src/studio-dev-server.ts +12 -2
  131. package/src/type-extractor.ts +109 -2
  132. package/src/type-generation.ts +111 -2
  133. package/tests/access-composition.test.ts +123 -0
  134. package/tests/ast-derived-outputs.test.ts +92 -0
  135. package/tests/augmentation-generator.test.ts +273 -7
  136. package/tests/client-generator.test.ts +103 -0
  137. package/tests/db-port.test.ts +128 -0
  138. package/tests/engine-floor.test.ts +93 -0
  139. package/tests/external-database-compose.test.ts +8 -1
  140. package/tests/fixtures/seed_golden_ir.json +186 -0
  141. package/tests/functions-context-refresh.test.ts +86 -0
  142. package/tests/gitignore-secrets.test.ts +90 -0
  143. package/tests/runtime-contract.test.ts +146 -8
  144. package/tests/seed-connection.test.ts +158 -0
  145. package/tests/seed-ir.test.ts +436 -0
  146. package/tests/strict.test.ts +78 -0
  147. package/tests/type-extractor.test.ts +34 -0
  148. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,186 @@
1
+ {
2
+ "irVersion": 1,
3
+ "schemaFingerprint": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
4
+ "operations": [
5
+ {
6
+ "op": "upsert",
7
+ "model": "Profile",
8
+ "id": "alex",
9
+ "where": {
10
+ "id": "a0000000-0000-4000-8000-000000000001"
11
+ },
12
+ "data": {
13
+ "display_name": "Alex Rivera",
14
+ "full_name": "Alex Rivera"
15
+ }
16
+ },
17
+ {
18
+ "op": "upsert",
19
+ "model": "Organiser",
20
+ "id": "northern",
21
+ "where": {
22
+ "slug": "northern-lights-events"
23
+ },
24
+ "data": {
25
+ "name": "Northern Lights Events",
26
+ "contact_email": "hello@northernlights.test",
27
+ "owner": {
28
+ "connect": {
29
+ "id": {
30
+ "$ref": "alex.id"
31
+ }
32
+ }
33
+ },
34
+ "events": {
35
+ "create": [
36
+ {
37
+ "title": "Southwest Dev Conference",
38
+ "status": "published",
39
+ "starts_at": {
40
+ "kind": "after",
41
+ "base": {
42
+ "kind": "startOf",
43
+ "unit": "day"
44
+ },
45
+ "interval": {
46
+ "days": 30,
47
+ "hours": 9
48
+ }
49
+ },
50
+ "ends_at": {
51
+ "kind": "after",
52
+ "base": {
53
+ "kind": "startOf",
54
+ "unit": "day"
55
+ },
56
+ "interval": {
57
+ "days": 30,
58
+ "hours": 18
59
+ }
60
+ },
61
+ "created_at": {
62
+ "kind": "now"
63
+ },
64
+ "ticket_types": {
65
+ "createMany": [
66
+ {
67
+ "name": "Early Bird",
68
+ "price": {
69
+ "$numeric": "75.0000"
70
+ },
71
+ "quantity": 100
72
+ },
73
+ {
74
+ "name": "Standard",
75
+ "price": {
76
+ "$numeric": "125.0000"
77
+ },
78
+ "quantity": 250
79
+ }
80
+ ]
81
+ }
82
+ }
83
+ ]
84
+ }
85
+ }
86
+ },
87
+ {
88
+ "op": "createMany",
89
+ "model": "Venue",
90
+ "rows": [
91
+ {
92
+ "name": "The Exchange",
93
+ "city": "Bristol",
94
+ "capacity": 450,
95
+ "country": {
96
+ "$default": true
97
+ },
98
+ "organiser": {
99
+ "connect": {
100
+ "slug": "northern-lights-events"
101
+ }
102
+ }
103
+ },
104
+ {
105
+ "name": "Archway Studios",
106
+ "city": "London",
107
+ "capacity": 180,
108
+ "country": {
109
+ "$default": true
110
+ },
111
+ "organiser": {
112
+ "connect": {
113
+ "slug": "northern-lights-events"
114
+ }
115
+ }
116
+ }
117
+ ]
118
+ },
119
+ {
120
+ "op": "group",
121
+ "if": {
122
+ "kind": "notExists",
123
+ "model": "SiteSettings"
124
+ },
125
+ "operations": [
126
+ {
127
+ "op": "create",
128
+ "model": "SiteSettings",
129
+ "data": {
130
+ "site_name": "Launch Test Tickets",
131
+ "default_currency": "GBP"
132
+ }
133
+ }
134
+ ]
135
+ },
136
+ {
137
+ "op": "group",
138
+ "if": {
139
+ "kind": "exists",
140
+ "model": "Event",
141
+ "where": {
142
+ "type": "compare",
143
+ "op": "eq",
144
+ "left": {
145
+ "kind": "column",
146
+ "name": "status"
147
+ },
148
+ "right": {
149
+ "kind": "literal",
150
+ "value": "published"
151
+ }
152
+ }
153
+ },
154
+ "operations": [
155
+ {
156
+ "op": "create",
157
+ "model": "PromoCode",
158
+ "data": {
159
+ "code": "EARLY20",
160
+ "discount_kind": "percentage",
161
+ "discount_value": 20,
162
+ "valid_until": {
163
+ "kind": "after",
164
+ "base": {
165
+ "kind": "now"
166
+ },
167
+ "interval": {
168
+ "days": 25
169
+ }
170
+ },
171
+ "event": {
172
+ "connect": {
173
+ "slug": "southwest-dev-conference"
174
+ }
175
+ }
176
+ }
177
+ }
178
+ ]
179
+ },
180
+ {
181
+ "op": "sql",
182
+ "text": "ANALYZE event",
183
+ "params": []
184
+ }
185
+ ]
186
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `functions/_shared/context.ts` reaches a project that already exists.
3
+ *
4
+ * The module was written by `init` and `supatype functions new`, and by nothing else. So it arrived
5
+ * with new projects and new functions, and never arrived at a project that already had functions
6
+ * when the two-argument handler contract landed: those keep `(req)` handlers with no
7
+ * `FunctionContext` to import, while the generated file itself asserts
8
+ *
9
+ * Regenerated by the CLI. Edits are overwritten.
10
+ *
11
+ * which was true only if you scaffolded again. `tests/integration` in this repo had four functions
12
+ * and no context module at all, which is how it was found.
13
+ */
14
+ import { describe, expect, it, beforeEach, afterEach } from "vitest"
15
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
16
+ import { join } from "node:path"
17
+ import { tmpdir } from "node:os"
18
+ import { refreshFunctionsContext } from "../src/functions-context-refresh.js"
19
+ import type { SupatypeProjectConfig } from "../src/project-config.js"
20
+
21
+ const config: SupatypeProjectConfig = {
22
+ project: { name: "ctx" },
23
+ database: { provider: "docker" },
24
+ } as SupatypeProjectConfig
25
+
26
+ let dir: string
27
+
28
+ beforeEach(() => {
29
+ dir = mkdtempSync(join(tmpdir(), "supatype-ctx-"))
30
+ })
31
+
32
+ afterEach(() => {
33
+ rmSync(dir, { recursive: true, force: true })
34
+ })
35
+
36
+ /** A project with functions but no `_shared`, which is what a pre-migration project looks like. */
37
+ function projectWithFunctions(): string {
38
+ const functions = join(dir, "functions")
39
+ mkdirSync(functions, { recursive: true })
40
+ writeFileSync(
41
+ join(functions, "ping.ts"),
42
+ "export default function ping(_req: Request) { return new Response('ok') }\n",
43
+ "utf8",
44
+ )
45
+ return functions
46
+ }
47
+
48
+ describe("a project that already has functions", () => {
49
+ it("gets the context module written", () => {
50
+ const functions = projectWithFunctions()
51
+ expect(existsSync(join(functions, "_shared", "context.ts"))).toBe(false)
52
+
53
+ expect(refreshFunctionsContext(dir, config)).toBe(true)
54
+ expect(existsSync(join(functions, "_shared", "context.ts"))).toBe(true)
55
+ })
56
+
57
+ it("writes a module that actually declares the contract", () => {
58
+ // Not merely that a file appeared. The point is the type handlers import.
59
+ projectWithFunctions()
60
+ refreshFunctionsContext(dir, config)
61
+ const source = readFileSync(join(dir, "functions", "_shared", "context.ts"), "utf8")
62
+ expect(source).toContain("FunctionContext")
63
+ expect(source).toContain("executionId")
64
+ })
65
+
66
+ it("rewrites a stale copy rather than leaving it", () => {
67
+ // The file says edits are overwritten, so they have to be.
68
+ const functions = projectWithFunctions()
69
+ mkdirSync(join(functions, "_shared"), { recursive: true })
70
+ writeFileSync(join(functions, "_shared", "context.ts"), "// stale\n", "utf8")
71
+
72
+ refreshFunctionsContext(dir, config)
73
+ const source = readFileSync(join(functions, "_shared", "context.ts"), "utf8")
74
+ expect(source).not.toBe("// stale\n")
75
+ expect(source).toContain("FunctionContext")
76
+ })
77
+ })
78
+
79
+ describe("a project with no functions", () => {
80
+ it("is left alone", () => {
81
+ // Writing `_shared/` into a project with no functions would create the thing it describes out
82
+ // of nothing, and leave a directory nobody asked for in the diff.
83
+ expect(refreshFunctionsContext(dir, config)).toBe(false)
84
+ expect(existsSync(join(dir, "functions"))).toBe(false)
85
+ })
86
+ })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The `.gitignore` we write has to cover the secrets we write.
3
+ *
4
+ * `supatype functions new` writes `functions/.env.local` with "These are NOT committed to git" at
5
+ * the top of it, and nothing made that true: `.env` matches a file named exactly `.env`, and both
6
+ * `.env.local` and `.env.<function>.local` were tracked.
7
+ *
8
+ * Asserted through `git check-ignore` rather than by reading the patterns, because the question is
9
+ * what git does with them and a pattern that looks right is how this got shipped.
10
+ */
11
+
12
+ import { describe, expect, it, beforeEach, afterEach } from "vitest"
13
+ import { execFileSync } from "node:child_process"
14
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
15
+ import { join } from "node:path"
16
+ import { tmpdir } from "node:os"
17
+ import { SUPATYPE_GITIGNORE_BLOCK, SUPATYPE_GITIGNORE_PATHS } from "../src/gitignore.js"
18
+
19
+ let dir: string
20
+
21
+ beforeEach(() => {
22
+ dir = mkdtempSync(join(tmpdir(), "supatype-ignore-"))
23
+ execFileSync("git", ["init", "-q", "."], { cwd: dir })
24
+ })
25
+
26
+ afterEach(() => {
27
+ rmSync(dir, { recursive: true, force: true })
28
+ })
29
+
30
+ /** What git itself says about a path, which is the only answer that counts. */
31
+ function ignored(relPath: string): boolean {
32
+ try {
33
+ execFileSync("git", ["check-ignore", "-q", relPath], { cwd: dir })
34
+ return true
35
+ } catch {
36
+ return false
37
+ }
38
+ }
39
+
40
+ function writeIgnore(contents: string): void {
41
+ writeFileSync(join(dir, ".gitignore"), contents, "utf8")
42
+ mkdirSync(join(dir, "functions"), { recursive: true })
43
+ mkdirSync(join(dir, "src"), { recursive: true })
44
+ for (const f of [
45
+ ".env",
46
+ ".env.local",
47
+ "functions/.env.local",
48
+ "functions/.env.checkout.local",
49
+ "src/index.ts",
50
+ "src/env.local.ts",
51
+ ]) {
52
+ writeFileSync(join(dir, f), "x", "utf8")
53
+ }
54
+ }
55
+
56
+ describe("the block written into an existing .gitignore", () => {
57
+ beforeEach(() => writeIgnore(SUPATYPE_GITIGNORE_BLOCK))
58
+
59
+ it("ignores the plain env file", () => {
60
+ expect(ignored(".env")).toBe(true)
61
+ })
62
+
63
+ it("ignores the function env files, at the root and nested", () => {
64
+ // The ones `supatype functions new` writes and the worker reads.
65
+ expect(ignored(".env.local")).toBe(true)
66
+ expect(ignored("functions/.env.local")).toBe(true)
67
+ expect(ignored("functions/.env.checkout.local")).toBe(true)
68
+ })
69
+
70
+ it("does not swallow source files", () => {
71
+ // `.env.*.local` is a glob, and a careless widening of it catches real code.
72
+ expect(ignored("src/index.ts")).toBe(false)
73
+ expect(ignored("src/env.local.ts")).toBe(false)
74
+ })
75
+ })
76
+
77
+ describe("the two emitters", () => {
78
+ it("agree, because they read one list", () => {
79
+ // They were separate copies and had already drifted. The copy that drifts is the one found
80
+ // when a key reaches a public repository.
81
+ for (const path of SUPATYPE_GITIGNORE_PATHS) {
82
+ expect(SUPATYPE_GITIGNORE_BLOCK).toContain(path)
83
+ }
84
+ })
85
+
86
+ it("carries the env patterns `.env` alone does not cover", () => {
87
+ expect(SUPATYPE_GITIGNORE_PATHS).toContain(".env.local")
88
+ expect(SUPATYPE_GITIGNORE_PATHS).toContain(".env.*.local")
89
+ })
90
+ })
@@ -9,6 +9,22 @@ import { updateAppConfigInProject, updateServerConfigInProject } from "../src/ap
9
9
  import type { SupatypeProjectConfig } from "../src/project-config.js"
10
10
  import { DENO_RELEASE_PIN } from "../src/release-pins.js"
11
11
 
12
+ /**
13
+ * One service's block from a rendered compose file.
14
+ *
15
+ * Compose is asserted as text here rather than parsed, because the CLI ships no YAML parser and a
16
+ * test is not worth a dependency. Slicing to a single service is what keeps that honest: service
17
+ * names and `condition: service_healthy` both recur, so a whole-document substring match would
18
+ * pass on another service's dependency.
19
+ */
20
+ function serviceBlock(compose: string, name: string): string {
21
+ const lines = compose.split("\n")
22
+ const start = lines.indexOf(` ${name}:`)
23
+ if (start === -1) return ""
24
+ const after = lines.slice(start + 1).findIndex((l) => /^ {2}[a-z][a-z0-9-]*:$/.test(l))
25
+ return lines.slice(start, after === -1 ? lines.length : start + 1 + after).join("\n")
26
+ }
27
+
12
28
  const baseConfig: SupatypeProjectConfig = {
13
29
  project: { name: "acme" },
14
30
  database: { provider: "docker" },
@@ -260,22 +276,47 @@ describe("runtime contract", () => {
260
276
  expect(compose).not.toContain("- ./kong.yml:/etc/kong/kong.yml:ro")
261
277
  })
262
278
 
263
- it("devLocal compose omits host-published db and server ports", () => {
279
+ it("devLocal compose omits the production ports", () => {
264
280
  const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
265
281
  expect(compose).not.toContain('"5432:5432"')
266
282
  expect(compose).not.toContain('"9999:9999"')
267
283
  expect(compose).toContain("${SUPATYPE_KONG_PORT:-18473}:8000")
268
- expect(compose).not.toContain("SUPATYPE_DEV_DB_PORT")
269
284
  })
270
285
 
271
- it("devLocal compose publishes db to host when overrides.engine is set", () => {
272
- const compose = renderSelfHostCompose(
286
+ /**
287
+ * The database is reachable from this machine, always.
288
+ *
289
+ * It used to be published in dev only when something was known to need it, which meant a
290
+ * fresh project had a database nothing on the host could reach: `supatype seed` resolves a
291
+ * connection string and the engine connects over TCP, so the first seed got ECONNREFUSED
292
+ * and the remedy was an environment variable nobody had reason to know about.
293
+ */
294
+ it("devLocal compose publishes the database on loopback", () => {
295
+ const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
296
+ expect(compose).toContain("127.0.0.1:${SUPATYPE_DEV_DB_PORT:-54329}:5432")
297
+ })
298
+
299
+ /** Loopback, so it is reachable from here and from nowhere else. */
300
+ it("devLocal never publishes the database beyond this machine", () => {
301
+ const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
302
+ const dbPorts = compose
303
+ .split("\n")
304
+ .filter((line) => line.includes(":5432"))
305
+ .filter((line) => line.trim().startsWith("-"))
306
+ expect(dbPorts.length).toBeGreaterThan(0)
307
+ for (const line of dbPorts) expect(line).toContain("127.0.0.1:")
308
+ })
309
+
310
+ it("an engine override no longer decides whether the database is published", () => {
311
+ const withOverride = renderSelfHostCompose(
273
312
  { ...baseConfig, overrides: { engine: "/tmp/supatype-engine" } },
274
313
  process.cwd(),
275
314
  { devLocal: true },
276
315
  )
277
- expect(compose).toContain("127.0.0.1:${SUPATYPE_DEV_DB_PORT:-54329}:5432")
278
- expect(compose).not.toContain('"5432:5432"')
316
+ const without = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
317
+ expect(withOverride).toContain("127.0.0.1:${SUPATYPE_DEV_DB_PORT:-54329}:5432")
318
+ expect(without).toContain("127.0.0.1:${SUPATYPE_DEV_DB_PORT:-54329}:5432")
319
+ expect(withOverride).not.toContain('"5432:5432"')
279
320
  })
280
321
 
281
322
  it("devLocal proxy upstream rewrites localhost to host.docker.internal", () => {
@@ -375,9 +416,52 @@ describe("runtime contract", () => {
375
416
  }
376
417
  })
377
418
 
378
- it("devLocal compose enables STUDIO_OPEN_DEV on supatype-server", () => {
419
+ it("maps host.docker.internal, so a host proxy works on Linux too", () => {
420
+ // Docker Desktop resolves this name; Linux does not unless it is mapped. A project proxying
421
+ // the site or Studio to something on the host therefore worked on macOS and Windows and
422
+ // failed on Linux, with the gateway reaching nothing and no error naming the cause.
379
423
  const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
380
- expect(compose).toContain('STUDIO_OPEN_DEV: "1"')
424
+ for (const service of ["server", "kong"]) {
425
+ expect(serviceBlock(compose, service), service).toContain(
426
+ '- "host.docker.internal:host-gateway"',
427
+ )
428
+ }
429
+ })
430
+
431
+ it("publishes the database when a port is asked for", () => {
432
+ // In dev the port is published only when something on the host needs it, which is normally a
433
+ // host engine build. Naming a port is the same request: without this the number was honoured
434
+ // and the port never existed, so anything connecting over TCP got ECONNREFUSED.
435
+ const dir = mkdtempSync(join(tmpdir(), "supatype-dbport-"))
436
+ try {
437
+ writeFileSync(join(dir, ".env"), "SUPATYPE_DEV_DB_PORT=54329\n")
438
+ const compose = renderSelfHostCompose(baseConfig, dir, { devLocal: true })
439
+ // The number stays a compose variable so Docker resolves it; what changes is that the
440
+ // ports block exists at all.
441
+ expect(serviceBlock(compose, "db")).toContain("ports:")
442
+ expect(serviceBlock(compose, "db")).toContain("SUPATYPE_DEV_DB_PORT")
443
+ } finally {
444
+ rmSync(dir, { recursive: true, force: true })
445
+ }
446
+ })
447
+
448
+ it("leaves the database unpublished when nothing asked for it", () => {
449
+ const dir = mkdtempSync(join(tmpdir(), "supatype-dbport-"))
450
+ try {
451
+ writeFileSync(join(dir, ".env"), "ANON_KEY=x\n")
452
+ expect(serviceBlock(renderSelfHostCompose(baseConfig, dir, { devLocal: true }), "db"))
453
+ .not.toContain("5432:5432")
454
+ } finally {
455
+ rmSync(dir, { recursive: true, force: true })
456
+ }
457
+ })
458
+
459
+ it("devLocal compose enables STUDIO_OPEN_DEV on supatype-server, and lets it be turned off", () => {
460
+ const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
461
+ // Defaulted rather than hardcoded. `supatype dev` still opens Studio without a sign-in, and
462
+ // the browser suite can set STUDIO_OPEN_DEV=0 to drive the gate a deployment actually has.
463
+ // Held as a literal "1" there was no way to test Studio with its authorization on at all.
464
+ expect(compose).toContain('STUDIO_OPEN_DEV: "${STUDIO_OPEN_DEV:-1}"')
381
465
  })
382
466
 
383
467
  it("production self-host compose does not enable STUDIO_OPEN_DEV", () => {
@@ -597,6 +681,60 @@ export default defineConfig({
597
681
  }
598
682
  })
599
683
 
684
+ it("offers functions a database URL, off unless the operator sets one", () => {
685
+ // `ctx.dbUrl` is read from SUPATYPE_DB_URL by the worker, and the generated compose never set
686
+ // it, so the field was permanently undefined on self-host: a function reaching for it got no
687
+ // value and nothing to explain why.
688
+ const dir = mkdtempSync(join(tmpdir(), "supatype-compose-"))
689
+ try {
690
+ const compose = readFileSync(
691
+ writeSelfHostCompose(dir, { ...baseConfig, app: { mode: "none" } }).composePath,
692
+ "utf8",
693
+ )
694
+ const worker = serviceBlock(compose, "functions-worker")
695
+
696
+ // Passed through, so the capability is reachable at all.
697
+ expect(worker).toContain("SUPATYPE_DB_URL:")
698
+ // Empty by default. The worker treats an empty value as absent, so `ctx.dbUrl` stays
699
+ // undefined until somebody opts in.
700
+ expect(worker).toContain("SUPATYPE_DB_URL: ${SUPATYPE_FUNCTIONS_DB_URL:-}")
701
+ // And its own variable, not the project's owner DSN. Interpolating DATABASE_URL here would
702
+ // give every function a connection that bypasses access rules, masking and hooks.
703
+ expect(worker).not.toContain("SUPATYPE_DB_URL: ${DATABASE_URL")
704
+ } finally {
705
+ rmSync(dir, { recursive: true, force: true })
706
+ }
707
+ })
708
+
709
+ it("starts storage only once the object store is answering", () => {
710
+ // seaweedfs had no healthcheck and nothing depended on it, so compose started it alongside
711
+ // storage. Storage then accepted a bucket creation before seaweedfs was listening, every
712
+ // bucket in the schema failed with `connect ECONNREFUSED <ip>:8333`, and the metadata row was
713
+ // written anyway so a later push looked fine. Measured on a live stack: seaweedfs started
714
+ // twelve minutes after storage with RestartCount 0.
715
+ //
716
+ // It lands on the first push after a stack comes up, which is the first push anyone runs.
717
+ const dir = mkdtempSync(join(tmpdir(), "supatype-compose-"))
718
+ try {
719
+ const out = writeSelfHostCompose(dir, { ...baseConfig, app: { mode: "none" } })
720
+ const compose = readFileSync(out.composePath, "utf8")
721
+
722
+ // Sliced to one service, because `seaweedfs` and `service_healthy` both appear elsewhere in
723
+ // this file and a whole-document substring match would pass on either.
724
+ const storage = serviceBlock(compose, "storage")
725
+ expect(storage).toContain("depends_on:")
726
+ expect(storage).toMatch(/seaweedfs:\s*\n\s*condition: service_healthy/)
727
+ // Still waits for the database. The fix must not swap one dependency for another.
728
+ expect(storage).toMatch(/db:\s*\n\s*condition: service_healthy/)
729
+
730
+ // `service_healthy` is only meaningful if the service defines health. Without it compose
731
+ // refuses to start the stack at all, which is a worse failure than the race.
732
+ expect(serviceBlock(compose, "seaweedfs")).toContain("healthcheck:")
733
+ } finally {
734
+ rmSync(dir, { recursive: true, force: true })
735
+ }
736
+ })
737
+
600
738
  it("renders SeaweedFS with its identities and its own volume", () => {
601
739
  const dir = mkdtempSync(join(tmpdir(), "supatype-seaweed-"))
602
740
  try {