@supatype/cli 0.1.5 → 0.1.7

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 (247) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +143 -93
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/deno.d.ts +52 -0
  5. package/dist/augmentation-generator.d.ts +22 -0
  6. package/dist/augmentation-generator.d.ts.map +1 -1
  7. package/dist/augmentation-generator.js +43 -7
  8. package/dist/augmentation-generator.js.map +1 -1
  9. package/dist/binary-cache.d.ts +14 -0
  10. package/dist/binary-cache.d.ts.map +1 -1
  11. package/dist/binary-cache.js +33 -13
  12. package/dist/binary-cache.js.map +1 -1
  13. package/dist/cache-clean.d.ts +15 -0
  14. package/dist/cache-clean.d.ts.map +1 -0
  15. package/dist/cache-clean.js +76 -0
  16. package/dist/cache-clean.js.map +1 -0
  17. package/dist/cache-pins.d.ts +51 -0
  18. package/dist/cache-pins.d.ts.map +1 -0
  19. package/dist/cache-pins.js +184 -0
  20. package/dist/cache-pins.js.map +1 -0
  21. package/dist/cli-install-method.d.ts +19 -0
  22. package/dist/cli-install-method.d.ts.map +1 -0
  23. package/dist/cli-install-method.js +60 -0
  24. package/dist/cli-install-method.js.map +1 -0
  25. package/dist/cli.d.ts.map +1 -1
  26. package/dist/cli.js +6 -0
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloud-credentials.d.ts +34 -0
  29. package/dist/cloud-credentials.d.ts.map +1 -0
  30. package/dist/cloud-credentials.js +139 -0
  31. package/dist/cloud-credentials.js.map +1 -0
  32. package/dist/commands/admin.d.ts +11 -0
  33. package/dist/commands/admin.d.ts.map +1 -1
  34. package/dist/commands/admin.js +131 -22
  35. package/dist/commands/admin.js.map +1 -1
  36. package/dist/commands/cache.d.ts.map +1 -1
  37. package/dist/commands/cache.js +67 -71
  38. package/dist/commands/cache.js.map +1 -1
  39. package/dist/commands/cloud.d.ts.map +1 -1
  40. package/dist/commands/cloud.js +32 -8
  41. package/dist/commands/cloud.js.map +1 -1
  42. package/dist/commands/db.d.ts +1 -0
  43. package/dist/commands/db.d.ts.map +1 -1
  44. package/dist/commands/db.js +147 -1
  45. package/dist/commands/db.js.map +1 -1
  46. package/dist/commands/dev.d.ts.map +1 -1
  47. package/dist/commands/dev.js +50 -10
  48. package/dist/commands/dev.js.map +1 -1
  49. package/dist/commands/doctor.d.ts +28 -0
  50. package/dist/commands/doctor.d.ts.map +1 -1
  51. package/dist/commands/doctor.js +60 -3
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/functions.d.ts +7 -0
  54. package/dist/commands/functions.d.ts.map +1 -1
  55. package/dist/commands/functions.js +40 -6
  56. package/dist/commands/functions.js.map +1 -1
  57. package/dist/commands/generate.d.ts.map +1 -1
  58. package/dist/commands/generate.js +5 -1
  59. package/dist/commands/generate.js.map +1 -1
  60. package/dist/commands/hooks.d.ts +12 -0
  61. package/dist/commands/hooks.d.ts.map +1 -0
  62. package/dist/commands/hooks.js +118 -0
  63. package/dist/commands/hooks.js.map +1 -0
  64. package/dist/commands/init.d.ts.map +1 -1
  65. package/dist/commands/init.js +41 -7
  66. package/dist/commands/init.js.map +1 -1
  67. package/dist/commands/internal.d.ts +6 -0
  68. package/dist/commands/internal.d.ts.map +1 -0
  69. package/dist/commands/internal.js +22 -0
  70. package/dist/commands/internal.js.map +1 -0
  71. package/dist/commands/keys.d.ts.map +1 -1
  72. package/dist/commands/keys.js +2 -0
  73. package/dist/commands/keys.js.map +1 -1
  74. package/dist/commands/link-helpers.d.ts.map +1 -1
  75. package/dist/commands/link-helpers.js +8 -2
  76. package/dist/commands/link-helpers.js.map +1 -1
  77. package/dist/commands/login.d.ts +7 -0
  78. package/dist/commands/login.d.ts.map +1 -0
  79. package/dist/commands/login.js +110 -0
  80. package/dist/commands/login.js.map +1 -0
  81. package/dist/commands/push.d.ts.map +1 -1
  82. package/dist/commands/push.js +110 -7
  83. package/dist/commands/push.js.map +1 -1
  84. package/dist/commands/self-update.d.ts +7 -0
  85. package/dist/commands/self-update.d.ts.map +1 -1
  86. package/dist/commands/self-update.js +18 -11
  87. package/dist/commands/self-update.js.map +1 -1
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +18 -3
  90. package/dist/config.js.map +1 -1
  91. package/dist/db-preflight.d.ts +78 -0
  92. package/dist/db-preflight.d.ts.map +1 -0
  93. package/dist/db-preflight.js +452 -0
  94. package/dist/db-preflight.js.map +1 -0
  95. package/dist/dev-compose.d.ts +1 -1
  96. package/dist/dev-compose.d.ts.map +1 -1
  97. package/dist/dev-compose.js +129 -72
  98. package/dist/dev-compose.js.map +1 -1
  99. package/dist/env-file.d.ts +40 -2
  100. package/dist/env-file.d.ts.map +1 -1
  101. package/dist/env-file.js +132 -11
  102. package/dist/env-file.js.map +1 -1
  103. package/dist/field-masking-tier.d.ts +50 -0
  104. package/dist/field-masking-tier.d.ts.map +1 -0
  105. package/dist/field-masking-tier.js +91 -0
  106. package/dist/field-masking-tier.js.map +1 -0
  107. package/dist/functions-deno-types.d.ts +24 -0
  108. package/dist/functions-deno-types.d.ts.map +1 -0
  109. package/dist/functions-deno-types.js +155 -0
  110. package/dist/functions-deno-types.js.map +1 -0
  111. package/dist/hook-upload.d.ts +30 -0
  112. package/dist/hook-upload.d.ts.map +1 -0
  113. package/dist/hook-upload.js +86 -0
  114. package/dist/hook-upload.js.map +1 -0
  115. package/dist/hooks-generator.d.ts +8 -0
  116. package/dist/hooks-generator.d.ts.map +1 -0
  117. package/dist/hooks-generator.js +332 -0
  118. package/dist/hooks-generator.js.map +1 -0
  119. package/dist/link.d.ts +4 -0
  120. package/dist/link.d.ts.map +1 -1
  121. package/dist/link.js +2 -0
  122. package/dist/link.js.map +1 -1
  123. package/dist/local-secrets.d.ts +71 -0
  124. package/dist/local-secrets.d.ts.map +1 -0
  125. package/dist/local-secrets.js +100 -0
  126. package/dist/local-secrets.js.map +1 -0
  127. package/dist/model-hooks.d.ts +73 -0
  128. package/dist/model-hooks.d.ts.map +1 -0
  129. package/dist/model-hooks.js +211 -0
  130. package/dist/model-hooks.js.map +1 -0
  131. package/dist/postgres-ctl.d.ts +11 -0
  132. package/dist/postgres-ctl.d.ts.map +1 -1
  133. package/dist/postgres-ctl.js +25 -2
  134. package/dist/postgres-ctl.js.map +1 -1
  135. package/dist/project-config.d.ts +121 -1
  136. package/dist/project-config.d.ts.map +1 -1
  137. package/dist/project-config.js +150 -1
  138. package/dist/project-config.js.map +1 -1
  139. package/dist/resolve-target.d.ts +4 -0
  140. package/dist/resolve-target.d.ts.map +1 -1
  141. package/dist/resolve-target.js +69 -73
  142. package/dist/resolve-target.js.map +1 -1
  143. package/dist/schema-ast-v2.d.ts +2 -1
  144. package/dist/schema-ast-v2.d.ts.map +1 -1
  145. package/dist/schema-ast-v2.js +4 -2
  146. package/dist/schema-ast-v2.js.map +1 -1
  147. package/dist/schema-push-lock.d.ts.map +1 -1
  148. package/dist/schema-push-lock.js +43 -16
  149. package/dist/schema-push-lock.js.map +1 -1
  150. package/dist/self-host-compose.d.ts +24 -0
  151. package/dist/self-host-compose.d.ts.map +1 -1
  152. package/dist/self-host-compose.js +301 -69
  153. package/dist/self-host-compose.js.map +1 -1
  154. package/dist/service-role-check.d.ts +21 -0
  155. package/dist/service-role-check.d.ts.map +1 -0
  156. package/dist/service-role-check.js +85 -0
  157. package/dist/service-role-check.js.map +1 -0
  158. package/dist/target-client.d.ts +17 -0
  159. package/dist/target-client.d.ts.map +1 -1
  160. package/dist/target-client.js +69 -17
  161. package/dist/target-client.js.map +1 -1
  162. package/dist/type-extractor.d.ts.map +1 -1
  163. package/dist/type-extractor.js +736 -32
  164. package/dist/type-extractor.js.map +1 -1
  165. package/dist/ui/runtime/command-chrome.js +1 -1
  166. package/dist/ui/runtime/command-chrome.js.map +1 -1
  167. package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
  168. package/dist/ui/runtime/stdin-after-ink.js +3 -1
  169. package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
  170. package/package.json +4 -1
  171. package/scripts/generate-homebrew-formula.mjs +121 -0
  172. package/src/augmentation-generator.ts +46 -9
  173. package/src/binary-cache.ts +47 -15
  174. package/src/cache-clean.ts +81 -0
  175. package/src/cache-pins.ts +243 -0
  176. package/src/cli-install-method.ts +67 -0
  177. package/src/cli.ts +6 -0
  178. package/src/cloud-credentials.ts +183 -0
  179. package/src/commands/admin.ts +153 -28
  180. package/src/commands/cache.ts +90 -75
  181. package/src/commands/cloud.ts +32 -8
  182. package/src/commands/db.ts +162 -1
  183. package/src/commands/dev.ts +57 -8
  184. package/src/commands/doctor.ts +69 -3
  185. package/src/commands/functions.ts +42 -5
  186. package/src/commands/generate.ts +5 -1
  187. package/src/commands/hooks.ts +138 -0
  188. package/src/commands/init.ts +42 -8
  189. package/src/commands/internal.ts +23 -0
  190. package/src/commands/keys.ts +2 -0
  191. package/src/commands/link-helpers.ts +9 -2
  192. package/src/commands/login.ts +127 -0
  193. package/src/commands/push.ts +124 -7
  194. package/src/commands/self-update.ts +85 -13
  195. package/src/config.ts +22 -3
  196. package/src/db-preflight.ts +575 -0
  197. package/src/dev-compose.ts +144 -73
  198. package/src/env-file.ts +157 -11
  199. package/src/field-masking-tier.ts +107 -0
  200. package/src/functions-deno-types.ts +188 -0
  201. package/src/hook-upload.ts +107 -0
  202. package/src/hooks-generator.ts +352 -0
  203. package/src/link.ts +7 -0
  204. package/src/local-secrets.ts +104 -0
  205. package/src/model-hooks.ts +260 -0
  206. package/src/postgres-ctl.ts +29 -2
  207. package/src/project-config.ts +236 -2
  208. package/src/resolve-target.ts +113 -68
  209. package/src/schema-ast-v2.ts +5 -2
  210. package/src/schema-push-lock.ts +56 -17
  211. package/src/self-host-compose.ts +344 -68
  212. package/src/service-role-check.ts +111 -0
  213. package/src/target-client.ts +105 -18
  214. package/src/type-extractor.ts +883 -28
  215. package/src/ui/runtime/command-chrome.ts +1 -1
  216. package/src/ui/runtime/stdin-after-ink.ts +3 -1
  217. package/tests/access-composition.test.ts +667 -0
  218. package/tests/admin-studio-membership.test.ts +121 -0
  219. package/tests/api-schemas.test.ts +83 -0
  220. package/tests/archive-name-alias.test.ts +42 -0
  221. package/tests/augmentation-generator.test.ts +72 -4
  222. package/tests/cache-clean.test.ts +206 -0
  223. package/tests/cli-help.test.ts +3 -0
  224. package/tests/cli-install-method.test.ts +66 -0
  225. package/tests/command-chrome-coverage.test.ts +31 -26
  226. package/tests/command-chrome-wrap.test.ts +1 -0
  227. package/tests/db-preflight.test.ts +72 -0
  228. package/tests/doctor-render.test.ts +62 -0
  229. package/tests/doctor-service-role.test.ts +55 -0
  230. package/tests/env-file.test.ts +121 -0
  231. package/tests/external-database-compose.test.ts +184 -0
  232. package/tests/external-database-config.test.ts +154 -0
  233. package/tests/field-masking-tier.test.ts +184 -0
  234. package/tests/functions-deno-types.test.ts +197 -0
  235. package/tests/homebrew-formula.test.ts +58 -0
  236. package/tests/hook-upload.test.ts +125 -0
  237. package/tests/hooks-generator.test.ts +212 -0
  238. package/tests/init.test.ts +3 -0
  239. package/tests/local-secrets.test.ts +92 -0
  240. package/tests/model-hooks.test.ts +322 -0
  241. package/tests/runtime-contract.test.ts +113 -1
  242. package/tests/service-role-check.test.ts +109 -0
  243. package/tests/service-role-routes.test.ts +51 -0
  244. package/tests/target-client-refresh.test.ts +94 -0
  245. package/tests/type-extractor.test.ts +187 -0
  246. package/tsconfig.tsbuildinfo +1 -1
  247. package/tests/engine-distribution.test.ts +0 -418
@@ -0,0 +1,322 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { afterEach, describe, expect, it } from "vitest"
5
+ import {
6
+ DEFAULT_HOOK_TIMEOUT_MS,
7
+ declaredHooks,
8
+ manifestHooks,
9
+ syncManifestHooks,
10
+ hooksReport,
11
+ validateModelHooks,
12
+ writeHooksModule,
13
+ } from "../src/model-hooks.js"
14
+ import { extractSchemaAstFromTypes } from "../src/type-extractor.js"
15
+
16
+ const dirs: string[] = []
17
+
18
+ afterEach(() => {
19
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
20
+ })
21
+
22
+ function project(schema: string): { dir: string; ast: unknown } {
23
+ const dir = mkdtempSync(join(tmpdir(), "supatype-hooks-"))
24
+ dirs.push(dir)
25
+ const schemaPath = join(dir, "schema.ts")
26
+ writeFileSync(schemaPath, schema, "utf8")
27
+ return { dir, ast: extractSchemaAstFromTypes(schemaPath, dir) }
28
+ }
29
+
30
+ /** A function the worker would discover: a directory with an index.ts. */
31
+ function addFunction(dir: string, name: string): void {
32
+ mkdirSync(join(dir, "functions", name), { recursive: true })
33
+ writeFileSync(join(dir, "functions", name, "index.ts"), "export default () => new Response()", "utf8")
34
+ }
35
+
36
+ describe("model hooks — declaration", () => {
37
+ it("extracts both the shorthand and the options form", () => {
38
+ const { ast } = project(`
39
+ import type { Model, UUID } from "@supatype/types"
40
+
41
+ export type Post = Model<{ id: UUID; title: string }, {
42
+ tableName: "posts"
43
+ hooks: {
44
+ beforeChange: "moderate-post"
45
+ afterChange: { function: "index-post"; timeout: 5000; onUnavailable: "log" }
46
+ }
47
+ }>
48
+ `)
49
+ expect(declaredHooks(ast)).toEqual([
50
+ { model: "Post", event: "afterChange", function: "index-post" },
51
+ { model: "Post", event: "beforeChange", function: "moderate-post" },
52
+ ])
53
+
54
+ const models = (ast as { models: { annotations: { platform: { hooks?: Record<string, unknown> } } }[] }).models
55
+ expect(models[0]?.annotations.platform.hooks).toEqual({
56
+ beforeChange: { function: "moderate-post" },
57
+ afterChange: { function: "index-post", timeout: 5000, onUnavailable: "log" },
58
+ })
59
+ })
60
+
61
+ it("carries no hooks annotation when a model declares none", () => {
62
+ const { ast } = project(`
63
+ import type { Model, UUID } from "@supatype/types"
64
+
65
+ export type Post = Model<{ id: UUID }, { tableName: "posts" }>
66
+ `)
67
+ const models = (ast as { models: { annotations: { platform: Record<string, unknown> } }[] }).models
68
+ expect(models[0]?.annotations.platform["hooks"]).toBeUndefined()
69
+ expect(declaredHooks(ast)).toEqual([])
70
+ })
71
+
72
+ it("ignores an event name that is not a lifecycle hook", () => {
73
+ // `beforeRead` was dropped from the design. Extracting it would put a hook in the manifest that
74
+ // the server has no point at which to call, which reads as "declared and silently ignored".
75
+ const { ast } = project(`
76
+ import type { Model, UUID } from "@supatype/types"
77
+
78
+ export type Post = Model<{ id: UUID }, {
79
+ tableName: "posts"
80
+ hooks: { beforeRead: "nope"; beforeChange: "yes" }
81
+ }>
82
+ `)
83
+ expect(declaredHooks(ast).map((h) => h.event)).toEqual(["beforeChange"])
84
+ })
85
+ })
86
+
87
+ describe("model hooks — push-time validation", () => {
88
+ const schema = `
89
+ import type { Model, UUID } from "@supatype/types"
90
+
91
+ export type Post = Model<{ id: UUID }, {
92
+ tableName: "posts"
93
+ hooks: { beforeChange: "moderate-post" }
94
+ }>
95
+ `
96
+
97
+ it("passes when the named function exists", () => {
98
+ const { dir, ast } = project(schema)
99
+ addFunction(dir, "moderate-post")
100
+ expect(validateModelHooks(ast, join(dir, "functions"), dir)).toEqual([])
101
+ })
102
+
103
+ it("reports the hook, the path searched, and what is available", () => {
104
+ // The failure this prevents: a typo'd name extracts cleanly, reaches the manifest, never fires,
105
+ // and the write it was meant to validate succeeds looking perfectly normal.
106
+ const { dir, ast } = project(schema)
107
+ addFunction(dir, "moderate-posts") // note the plural
108
+ const problems = validateModelHooks(ast, join(dir, "functions"), dir)
109
+
110
+ expect(problems.join("\n")).toContain("Post.beforeChange")
111
+ expect(problems.join("\n")).toContain('"moderate-post"')
112
+ expect(problems.join("\n")).toContain("moderate-posts")
113
+ })
114
+
115
+ it("says how to create one when the hooks directory is empty", () => {
116
+ const { dir, ast } = project(schema)
117
+ mkdirSync(join(dir, "hooks"), { recursive: true })
118
+ expect(validateModelHooks(ast, join(dir, "hooks"), dir).join("\n")).toContain(
119
+ "supatype hooks new",
120
+ )
121
+ })
122
+
123
+ it("does not require a functions directory when no hooks are declared", () => {
124
+ const { dir, ast } = project(`
125
+ import type { Model, UUID } from "@supatype/types"
126
+
127
+ export type Post = Model<{ id: UUID }, { tableName: "posts" }>
128
+ `)
129
+ expect(validateModelHooks(ast, join(dir, "functions"), dir)).toEqual([])
130
+ })
131
+
132
+ it("ignores a file and an underscore directory when listing functions", () => {
133
+ // Matches how the worker discovers handlers: `_shared` is shared code, not a function.
134
+ const { dir, ast } = project(schema)
135
+ mkdirSync(join(dir, "functions", "_shared"), { recursive: true })
136
+ writeFileSync(join(dir, "functions", "_shared", "index.ts"), "export const x = 1", "utf8")
137
+ writeFileSync(join(dir, "functions", "readme.md"), "notes", "utf8")
138
+
139
+ const problems = validateModelHooks(ast, join(dir, "functions"), dir).join("\n")
140
+ expect(problems).toContain("No hooks found")
141
+ expect(problems).not.toContain("_shared")
142
+ })
143
+ })
144
+
145
+ describe("model hooks — manifest map", () => {
146
+ const schema = `
147
+ import type { Model, UUID } from "@supatype/types"
148
+
149
+ export type Post = Model<{ id: UUID }, {
150
+ tableName: "posts"
151
+ hooks: { beforeChange: "moderate-post"; afterChange: { function: "index-post"; timeout: 9000 } }
152
+ }>
153
+ `
154
+
155
+ it("keys by table name and fills the safe default per phase", () => {
156
+ const { ast } = project(schema)
157
+ // Keyed by table, not model: the server matches a request path.
158
+ expect(manifestHooks(ast)).toEqual({
159
+ posts: {
160
+ beforeChange: {
161
+ function: "moderate-post",
162
+ timeout: DEFAULT_HOOK_TIMEOUT_MS,
163
+ // Unreachable before a write must not let the write through...
164
+ onUnavailable: "reject",
165
+ },
166
+ afterChange: {
167
+ function: "index-post",
168
+ timeout: 9000,
169
+ // ...but after one there is nothing left to fail.
170
+ onUnavailable: "log",
171
+ },
172
+ },
173
+ })
174
+ })
175
+
176
+ it("merges into an existing manifest and leaves its other keys alone", () => {
177
+ const { dir, ast } = project(schema)
178
+ mkdirSync(join(dir, ".supatype"), { recursive: true })
179
+ writeFileSync(
180
+ join(dir, ".supatype", "manifest.json"),
181
+ JSON.stringify({ schema: "tenant_7", functions_enabled: true }),
182
+ "utf8",
183
+ )
184
+
185
+ expect(syncManifestHooks(dir, ast)).toBe(true)
186
+ const manifest = JSON.parse(
187
+ readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
188
+ ) as Record<string, unknown>
189
+ expect(manifest["schema"]).toBe("tenant_7")
190
+ expect(manifest["functions_enabled"]).toBe(true)
191
+ expect(Object.keys(manifest["hooks"] as object)).toEqual(["posts"])
192
+ })
193
+
194
+ it("never creates a manifest from scratch", () => {
195
+ // `functions_enabled` is a plain bool on the server's side, so a manifest carrying only hooks
196
+ // would read as functions *disabled* — the defect fixed one commit earlier, by another door.
197
+ const { dir, ast } = project(schema)
198
+ expect(syncManifestHooks(dir, ast)).toBe(false)
199
+ expect(existsSync(join(dir, ".supatype", "manifest.json"))).toBe(false)
200
+ })
201
+
202
+ it("removes the hook map when the last hook goes away", () => {
203
+ const { dir, ast } = project(`
204
+ import type { Model, UUID } from "@supatype/types"
205
+
206
+ export type Post = Model<{ id: UUID }, { tableName: "posts" }>
207
+ `)
208
+ mkdirSync(join(dir, ".supatype"), { recursive: true })
209
+ writeFileSync(
210
+ join(dir, ".supatype", "manifest.json"),
211
+ JSON.stringify({ functions_enabled: true, hooks: { posts: { beforeChange: { function: "gone" } } } }),
212
+ "utf8",
213
+ )
214
+
215
+ expect(syncManifestHooks(dir, ast)).toBe(true)
216
+ const manifest = JSON.parse(
217
+ readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
218
+ ) as Record<string, unknown>
219
+ expect(manifest["hooks"]).toBeUndefined()
220
+ })
221
+ })
222
+
223
+ describe("model hooks — generated module on disk", () => {
224
+ it("removes a stale module when the last hook is deleted", () => {
225
+ // Otherwise a typed module sits there claiming tables are hooked, and a handler importing it
226
+ // keeps compiling while never running.
227
+ const { dir, ast } = project(`
228
+ import type { Model, UUID } from "@supatype/types"
229
+
230
+ export type Post = Model<{ id: UUID }, { tableName: "posts" }>
231
+ `)
232
+ const functionsDir = join(dir, "functions")
233
+ mkdirSync(join(functionsDir, "_supatype"), { recursive: true })
234
+ writeFileSync(join(functionsDir, "_supatype", "hooks.ts"), "export type Stale = true", "utf8")
235
+
236
+ expect(writeHooksModule(dir, functionsDir, ast)).toBeNull()
237
+ expect(existsSync(join(functionsDir, "_supatype", "hooks.ts"))).toBe(false)
238
+ })
239
+
240
+ it("writes the module beside the functions that import it", () => {
241
+ const { dir, ast } = project(`
242
+ import type { Model, UUID } from "@supatype/types"
243
+
244
+ export type Post = Model<{ id: UUID }, { tableName: "posts"; hooks: { beforeChange: "x" } }>
245
+ `)
246
+ const functionsDir = join(dir, "functions")
247
+ const written = writeHooksModule(dir, functionsDir, ast)
248
+ expect(written).toContain(join("functions", "_supatype", "hooks.ts"))
249
+ expect(readFileSync(join(functionsDir, "_supatype", "hooks.ts"), "utf8")).toContain("HookedTable")
250
+ })
251
+ })
252
+
253
+ describe("model hooks — doctor report", () => {
254
+ const schema = `
255
+ import type { Model, UUID } from "@supatype/types"
256
+
257
+ export type Post = Model<{ id: UUID }, {
258
+ tableName: "posts"
259
+ hooks: { beforeChange: "moderate-post" }
260
+ }>
261
+ `
262
+
263
+ it("reports nothing for a schema with no hooks", () => {
264
+ const { dir, ast } = project(`
265
+ import type { Model, UUID } from "@supatype/types"
266
+
267
+ export type Post = Model<{ id: UUID }, { tableName: "posts" }>
268
+ `)
269
+ expect(hooksReport(dir, join(dir, "functions"), ast).declared).toEqual([])
270
+ })
271
+
272
+ it("marks a hook whose function is absent", () => {
273
+ const { dir, ast } = project(schema)
274
+ const report = hooksReport(dir, join(dir, "functions"), ast)
275
+ expect(report.declared).toHaveLength(1)
276
+ expect(report.missing).toHaveLength(1)
277
+ })
278
+
279
+ it("catches functions being switched off, which makes every hook inert silently", () => {
280
+ const { dir, ast } = project(schema)
281
+ addFunction(dir, "moderate-post")
282
+ mkdirSync(join(dir, ".supatype"), { recursive: true })
283
+ writeFileSync(
284
+ join(dir, ".supatype", "manifest.json"),
285
+ JSON.stringify({ functions_enabled: false, hooks: { posts: {} } }),
286
+ "utf8",
287
+ )
288
+
289
+ const report = hooksReport(dir, join(dir, "functions"), ast)
290
+ expect(report.missing).toEqual([])
291
+ expect(report.functionsDisabled).toBe(true)
292
+ })
293
+
294
+ it("catches a manifest with no hook map, so the server has nothing to call", () => {
295
+ const { dir, ast } = project(schema)
296
+ addFunction(dir, "moderate-post")
297
+ mkdirSync(join(dir, ".supatype"), { recursive: true })
298
+ writeFileSync(
299
+ join(dir, ".supatype", "manifest.json"),
300
+ JSON.stringify({ functions_enabled: true }),
301
+ "utf8",
302
+ )
303
+
304
+ const report = hooksReport(dir, join(dir, "functions"), ast)
305
+ expect(report.mapMissing).toBe(true)
306
+ expect(report.functionsDisabled).toBe(false)
307
+ })
308
+
309
+ it("is clean when the function exists and the manifest agrees", () => {
310
+ const { dir, ast } = project(schema)
311
+ addFunction(dir, "moderate-post")
312
+ mkdirSync(join(dir, ".supatype"), { recursive: true })
313
+ writeFileSync(
314
+ join(dir, ".supatype", "manifest.json"),
315
+ JSON.stringify({ functions_enabled: true, hooks: { posts: { beforeChange: { function: "moderate-post" } } } }),
316
+ "utf8",
317
+ )
318
+
319
+ const report = hooksReport(dir, join(dir, "functions"), ast)
320
+ expect(report).toMatchObject({ missing: [], functionsDisabled: false, mapMissing: false })
321
+ })
322
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
3
  import { join } from "node:path"
4
4
  import { tmpdir } from "node:os"
5
5
  import { runtimeRouteSpec } from "../src/runtime-routes.js"
@@ -89,6 +89,25 @@ describe("runtime contract", () => {
89
89
  expect(first).toBe(second)
90
90
  })
91
91
 
92
+ it("self-host compose exposes the managed schema over REST, not a hardcoded public", () => {
93
+ // The literal here was the reason a non-default pg_schema pushed correctly and then answered
94
+ // PGRST106 on every request: the engine had moved and PostgREST was never told.
95
+ const compose = renderSelfHostCompose({
96
+ ...baseConfig,
97
+ schema: { pg_schema: "app" },
98
+ })
99
+ expect(compose).toContain('PGRST_DB_SCHEMA: "app, supatype, graphql_public, auth"')
100
+ expect(compose).not.toContain('PGRST_DB_SCHEMA: "public,')
101
+ })
102
+
103
+ it("self-host compose honours an explicit api_schemas list verbatim", () => {
104
+ const compose = renderSelfHostCompose({
105
+ ...baseConfig,
106
+ schema: { pg_schema: "app", api_schemas: ["app", "reporting"] },
107
+ })
108
+ expect(compose).toContain('PGRST_DB_SCHEMA: "app, reporting"')
109
+ })
110
+
92
111
  it("self-host compose does not inject a synthetic app-proxy service", () => {
93
112
  const compose = renderSelfHostCompose({ ...baseConfig, app: { mode: "proxy", upstream: "http://app:3000" } })
94
113
  expect(compose).not.toContain("ghcr.io/supatype/app-proxy")
@@ -109,6 +128,49 @@ describe("runtime contract", () => {
109
128
  expect(compose).toContain("supatype/schema-engine:latest")
110
129
  })
111
130
 
131
+ // PostgREST must connect as `authenticator`, never as POSTGRES_USER. POSTGRES_USER is
132
+ // `supatype_admin`, a superuser, and a superuser session may SET ROLE to any role in the
133
+ // cluster — so a request whose JWT named one got it. Verified against a live stack: a
134
+ // token with `role: "supatype_admin"` returned every row of an RLS-protected table.
135
+ it("self-host compose connects PostgREST as authenticator, not the superuser", () => {
136
+ const compose = renderSelfHostCompose(baseConfig)
137
+ expect(compose).toContain("PGRST_DB_URI: postgresql://authenticator:")
138
+ expect(compose).not.toMatch(/PGRST_DB_URI:.*POSTGRES_USER/)
139
+ expect(compose).not.toMatch(/PGRST_DB_URI:.*supatype_admin/)
140
+ })
141
+
142
+ // A separate credential, so rotating the operator's POSTGRES_PASSWORD cannot take the REST
143
+ // API down with it. Unset is a hard compose error rather than an empty password.
144
+ it("gives PostgREST its own credential, not POSTGRES_PASSWORD", () => {
145
+ const compose = renderSelfHostCompose(baseConfig)
146
+ expect(compose).toMatch(/PGRST_DB_URI:.*\$\{AUTHENTICATOR_PASSWORD:\?/)
147
+ expect(compose).not.toMatch(/PGRST_DB_URI:.*POSTGRES_PASSWORD/)
148
+ expect(compose).toMatch(/AUTHENTICATOR_PASSWORD: \$\{AUTHENTICATOR_PASSWORD:\?/)
149
+ })
150
+
151
+ // `supatype dev --provider docker` renders *this* file, so a `${VAR:?}` with no default
152
+ // takes local dev down before anything starts — `docker compose` refuses to interpolate and
153
+ // exits 1. Adding one therefore means wiring it into `upsertDevComposeEnv` as well. This
154
+ // list is the reminder; if it fails, do that rather than just updating the list.
155
+ it("requires only variables the dev path also sets", () => {
156
+ const compose = renderSelfHostCompose(baseConfig)
157
+ const required = [...compose.matchAll(/\$\{([A-Z0-9_]+):\?/g)].map((m) => m[1])
158
+ expect([...new Set(required)].sort()).toEqual([
159
+ "AUTHENTICATOR_PASSWORD",
160
+ "JWT_SECRET",
161
+ "POSTGRES_PASSWORD",
162
+ ])
163
+ })
164
+
165
+ // The whole point of requiring them: a service must never fall back to a secret published in
166
+ // this repository. That default used to reach PostgREST, storage and GoTrue.
167
+ it("never defaults a secret to a published constant", () => {
168
+ const compose = renderSelfHostCompose(baseConfig)
169
+ expect(compose).not.toContain("super-secret-jwt-token")
170
+ expect(compose).not.toMatch(/\$\{JWT_SECRET:-/)
171
+ expect(compose).not.toMatch(/\$\{POSTGRES_PASSWORD:-/)
172
+ })
173
+
112
174
  it("composeDockerImageEnv maps pinned versions to SUPATYPE_*_IMAGE", () => {
113
175
  expect(composeDockerImageEnv(baseConfig)).toEqual({
114
176
  SUPATYPE_ENGINE_IMAGE: "supatype/schema-engine:v0.4.2",
@@ -251,6 +313,55 @@ describe("runtime contract", () => {
251
313
  }
252
314
  })
253
315
 
316
+ it("manifest switches functions on, because the compose file always runs a worker", () => {
317
+ // The generated compose file starts a `functions-worker` and hands the server
318
+ // SUPATYPE_FUNCTIONS_WORKER_URL, while the manifest beside it said functions were disabled. The
319
+ // server reads the manifest, so every function 404'd in a stack that was running a worker for
320
+ // them. Docker `supatype dev` renders this same file, so one flag broke both paths.
321
+ const dir = mkdtempSync(join(tmpdir(), "supatype-fn-manifest-"))
322
+ try {
323
+ writeSelfHostCompose(dir, baseConfig)
324
+ const compose = readFileSync(join(dir, ".supatype", "self-host", "docker-compose.yml"), "utf8")
325
+ expect(compose).toContain(" functions-worker:")
326
+ expect(compose).toContain("SUPATYPE_FUNCTIONS_WORKER_URL: http://functions-worker:8001")
327
+
328
+ const manifest = JSON.parse(
329
+ readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
330
+ ) as Record<string, unknown>
331
+ expect(manifest["functions_enabled"]).toBe(true)
332
+ expect(manifest["functions_worker_url"]).toBe("http://functions-worker:8001")
333
+ } finally {
334
+ rmSync(dir, { recursive: true, force: true })
335
+ }
336
+ })
337
+
338
+ it("repairs a manifest written by an older CLI without touching its other keys", () => {
339
+ // Regenerating the compose file is the obvious fix to try, so it has to work: without the
340
+ // repair, only brand-new projects get functions and an existing stack stays broken.
341
+ const dir = mkdtempSync(join(tmpdir(), "supatype-fn-repair-"))
342
+ try {
343
+ mkdirSync(join(dir, ".supatype"), { recursive: true })
344
+ writeFileSync(
345
+ join(dir, ".supatype", "manifest.json"),
346
+ JSON.stringify({ schema: "tenant_7", postgrest_url: "http://pg:3000", functions_enabled: false }),
347
+ "utf8",
348
+ )
349
+
350
+ writeSelfHostCompose(dir, baseConfig)
351
+
352
+ const manifest = JSON.parse(
353
+ readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
354
+ ) as Record<string, unknown>
355
+ expect(manifest["functions_enabled"]).toBe(true)
356
+ expect(manifest["functions_worker_url"]).toBe("http://functions-worker:8001")
357
+ // Values `push` put there survive: the schema is not "public" and must stay as found.
358
+ expect(manifest["schema"]).toBe("tenant_7")
359
+ expect(manifest["postgrest_url"]).toBe("http://pg:3000")
360
+ } finally {
361
+ rmSync(dir, { recursive: true, force: true })
362
+ }
363
+ })
364
+
254
365
  it("devLocal compose enables STUDIO_OPEN_DEV on supatype-server", () => {
255
366
  const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
256
367
  expect(compose).toContain('STUDIO_OPEN_DEV: "1"')
@@ -276,6 +387,7 @@ describe("runtime contract", () => {
276
387
  expect(compose).toContain("\n functions-worker:\n")
277
388
  expect(compose).toContain("SUPATYPE_FUNCTIONS_WORKER_URL: http://functions-worker:8001")
278
389
  expect(compose).toContain("SUPATYPE_FUNCTIONS_ROOT: /project/functions")
390
+ expect(compose).toContain("SUPATYPE_URL: http://kong:8000")
279
391
  expect(compose).not.toContain("deploy/functions")
280
392
  expect(compose).not.toContain("supatype-functions")
281
393
  })
@@ -0,0 +1,109 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { describe, expect, it } from "vitest"
5
+
6
+ import { checkServiceRoleRoutes, serviceRoleProblemLines } from "../src/service-role-check.js"
7
+ import type { SupatypeProjectConfig } from "../src/project-config.js"
8
+ import { DENO_RELEASE_PIN } from "../src/release-pins.js"
9
+
10
+ function config(serviceRole?: string[]): SupatypeProjectConfig {
11
+ return {
12
+ project: { name: "p" },
13
+ database: { provider: "docker" },
14
+ server: { mode: "dev" },
15
+ app: { mode: "none" },
16
+ versions: { engine: "0.4.2", server: "0.1.0", postgres: "17.2", deno: DENO_RELEASE_PIN },
17
+ ...(serviceRole !== undefined && { functions: { serviceRole } }),
18
+ } as SupatypeProjectConfig
19
+ }
20
+
21
+ /** A project whose functions/ holds the named directories, each with an index.ts. */
22
+ function projectWith(names: string[], bareFiles: string[] = []): string {
23
+ const cwd = mkdtempSync(join(tmpdir(), "supatype-svcrole-check-"))
24
+ const dir = join(cwd, "functions")
25
+ mkdirSync(dir, { recursive: true })
26
+ for (const name of names) {
27
+ mkdirSync(join(dir, name), { recursive: true })
28
+ writeFileSync(join(dir, name, "index.ts"), "export default () => new Response()\n", "utf8")
29
+ }
30
+ for (const file of bareFiles) {
31
+ writeFileSync(join(dir, `${file}.ts`), "export default () => new Response()\n", "utf8")
32
+ }
33
+ return cwd
34
+ }
35
+
36
+ describe("checkServiceRoleRoutes", () => {
37
+ it("says nothing when a project grants nothing", () => {
38
+ const problems = checkServiceRoleRoutes(config(), projectWith([]))
39
+ expect(problems.errors).toEqual([])
40
+ expect(problems.warnings).toEqual([])
41
+ })
42
+
43
+ it("accepts a name that matches a function directory", () => {
44
+ const problems = checkServiceRoleRoutes(config(["send-email"]), projectWith(["send-email"]))
45
+ expect(problems.errors).toEqual([])
46
+ })
47
+
48
+ it("accepts a bare <name>.ts function, which the worker also serves", () => {
49
+ const problems = checkServiceRoleRoutes(config(["audit"]), projectWith([], ["audit"]))
50
+ expect(problems.errors).toEqual([])
51
+ })
52
+
53
+ it("reports a typo, which would otherwise grant nothing in silence", () => {
54
+ // The failure this exists for: the grant fails closed, so the function reads no key at runtime in a
55
+ // deploy that reported success.
56
+ const problems = checkServiceRoleRoutes(config(["send-emial"]), projectWith(["send-email"]))
57
+ expect(problems.errors).toHaveLength(1)
58
+ expect(problems.errors[0]).toContain("send-emial")
59
+ // Structural too, so `doctor` can mark the entry without parsing the printed line back apart.
60
+ expect(problems.missing).toEqual(["send-emial"])
61
+ })
62
+
63
+ it("reports a name whose function was renamed away", () => {
64
+ const problems = checkServiceRoleRoutes(config(["send-email"]), projectWith(["mailer"]))
65
+ expect(problems.errors).toHaveLength(1)
66
+ })
67
+
68
+ it("warns rather than fails on a hook entry", () => {
69
+ // It was the documented form once, and the worker grants hooks whatever the list says — so it is
70
+ // harmless, and worth saying because a reader would assume the line is what grants.
71
+ const problems = checkServiceRoleRoutes(config(["hooks/moderate-post"]), projectWith([]))
72
+ expect(problems.errors).toEqual([])
73
+ expect(problems.warnings).toHaveLength(1)
74
+ expect(problems.warnings[0]).toContain("not needed")
75
+ })
76
+
77
+ it("ignores an index-less directory, matching how the worker discovers routes", () => {
78
+ const cwd = projectWith([])
79
+ mkdirSync(join(cwd, "functions", "half-written"), { recursive: true })
80
+ const problems = checkServiceRoleRoutes(config(["half-written"]), cwd)
81
+ expect(problems.errors).toHaveLength(1)
82
+ })
83
+ })
84
+
85
+ describe("serviceRoleProblemLines", () => {
86
+ it("is empty when nothing is wrong", () => {
87
+ expect(serviceRoleProblemLines({ errors: [], missing: [], warnings: [], available: ["a"] })).toEqual([])
88
+ })
89
+
90
+ it("lists what could have been named instead", () => {
91
+ const lines = serviceRoleProblemLines({
92
+ errors: [' "x" — no functions/x/index.ts'],
93
+ missing: ["x"],
94
+ warnings: [],
95
+ available: ["audit", "send-email"],
96
+ })
97
+ expect(lines.join("\n")).toContain("Functions found: audit, send-email")
98
+ })
99
+
100
+ it("says so when there are no functions at all", () => {
101
+ const lines = serviceRoleProblemLines({
102
+ errors: [' "x" — nope'],
103
+ missing: ["x"],
104
+ warnings: [],
105
+ available: [],
106
+ })
107
+ expect(lines.join("\n")).toContain("No functions found")
108
+ })
109
+ })
@@ -0,0 +1,51 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { describe, expect, it } from "vitest"
5
+
6
+ import { serviceRoleRoutesFor } from "../src/commands/functions.js"
7
+ import { DENO_RELEASE_PIN } from "../src/release-pins.js"
8
+
9
+ /** A config the loader accepts, plus whatever the test is actually about. */
10
+ function projectWith(extra: Record<string, unknown>): string {
11
+ const cwd = mkdtempSync(join(tmpdir(), "supatype-svcrole-"))
12
+ mkdirSync(cwd, { recursive: true })
13
+ const body = {
14
+ project: { name: "p" },
15
+ database: { provider: "docker" },
16
+ server: { mode: "dev" },
17
+ app: { mode: "none" },
18
+ versions: { engine: "0.4.2", server: "0.1.0", postgres: "17.2", deno: DENO_RELEASE_PIN },
19
+ schema: { path: "./schema/index.ts" },
20
+ ...extra,
21
+ }
22
+ writeFileSync(join(cwd, "supatype.config.ts"), `export default ${JSON.stringify(body)}\n`, "utf8")
23
+ return cwd
24
+ }
25
+
26
+ describe("serviceRoleRoutesFor", () => {
27
+ it("reads the declared list", () => {
28
+ const cwd = projectWith({ functions: { serviceRole: ["send-email"] } })
29
+ expect(serviceRoleRoutesFor(cwd)).toEqual(["send-email"])
30
+ })
31
+
32
+ it("is an empty list when a project declares none", () => {
33
+ // Distinct from the case below: this project *has* said something, and it said nobody. The platform
34
+ // stores that as a real revocation.
35
+ expect(serviceRoleRoutesFor(projectWith({}))).toEqual([])
36
+ })
37
+
38
+ it("is undefined when the config cannot be read at all", () => {
39
+ // Not []: the platform replaces its stored list wholesale whenever the field is present, so an
40
+ // unreadable config sending [] would revoke every grant the project has.
41
+ expect(serviceRoleRoutesFor(mkdtempSync(join(tmpdir(), "supatype-svcrole-none-")))).toBeUndefined()
42
+ })
43
+
44
+ it("sends what the project declared, hook entries included", () => {
45
+ // Filtering hook routes out is the platform's job, not this one's: a hook is granted the key by the
46
+ // worker anyway, and dropping the entry here would hide a mistake in the config rather than making
47
+ // it harmless. Sorted, so an unchanged config produces an unchanged request.
48
+ const cwd = projectWith({ functions: { serviceRole: ["send-email", "hooks/moderate-post"] } })
49
+ expect(serviceRoleRoutesFor(cwd)).toEqual(["hooks/moderate-post", "send-email"])
50
+ })
51
+ })