@supatype/cli 0.1.6 → 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 (243) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +144 -94
  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 +18 -4
  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 +121 -19
  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/link-helpers.d.ts.map +1 -1
  72. package/dist/commands/link-helpers.js +8 -2
  73. package/dist/commands/link-helpers.js.map +1 -1
  74. package/dist/commands/login.d.ts +7 -0
  75. package/dist/commands/login.d.ts.map +1 -0
  76. package/dist/commands/login.js +110 -0
  77. package/dist/commands/login.js.map +1 -0
  78. package/dist/commands/push.d.ts.map +1 -1
  79. package/dist/commands/push.js +110 -7
  80. package/dist/commands/push.js.map +1 -1
  81. package/dist/commands/self-update.d.ts +7 -0
  82. package/dist/commands/self-update.d.ts.map +1 -1
  83. package/dist/commands/self-update.js +18 -11
  84. package/dist/commands/self-update.js.map +1 -1
  85. package/dist/config.d.ts.map +1 -1
  86. package/dist/config.js +18 -3
  87. package/dist/config.js.map +1 -1
  88. package/dist/db-preflight.d.ts +78 -0
  89. package/dist/db-preflight.d.ts.map +1 -0
  90. package/dist/db-preflight.js +452 -0
  91. package/dist/db-preflight.js.map +1 -0
  92. package/dist/dev-compose.d.ts +1 -1
  93. package/dist/dev-compose.d.ts.map +1 -1
  94. package/dist/dev-compose.js +127 -72
  95. package/dist/dev-compose.js.map +1 -1
  96. package/dist/env-file.d.ts +40 -2
  97. package/dist/env-file.d.ts.map +1 -1
  98. package/dist/env-file.js +132 -11
  99. package/dist/env-file.js.map +1 -1
  100. package/dist/field-masking-tier.d.ts +50 -0
  101. package/dist/field-masking-tier.d.ts.map +1 -0
  102. package/dist/field-masking-tier.js +91 -0
  103. package/dist/field-masking-tier.js.map +1 -0
  104. package/dist/functions-deno-types.d.ts +24 -0
  105. package/dist/functions-deno-types.d.ts.map +1 -0
  106. package/dist/functions-deno-types.js +155 -0
  107. package/dist/functions-deno-types.js.map +1 -0
  108. package/dist/hook-upload.d.ts +30 -0
  109. package/dist/hook-upload.d.ts.map +1 -0
  110. package/dist/hook-upload.js +86 -0
  111. package/dist/hook-upload.js.map +1 -0
  112. package/dist/hooks-generator.d.ts +8 -0
  113. package/dist/hooks-generator.d.ts.map +1 -0
  114. package/dist/hooks-generator.js +332 -0
  115. package/dist/hooks-generator.js.map +1 -0
  116. package/dist/link.d.ts +4 -0
  117. package/dist/link.d.ts.map +1 -1
  118. package/dist/link.js +2 -0
  119. package/dist/link.js.map +1 -1
  120. package/dist/local-secrets.d.ts +71 -0
  121. package/dist/local-secrets.d.ts.map +1 -0
  122. package/dist/local-secrets.js +100 -0
  123. package/dist/local-secrets.js.map +1 -0
  124. package/dist/model-hooks.d.ts +73 -0
  125. package/dist/model-hooks.d.ts.map +1 -0
  126. package/dist/model-hooks.js +211 -0
  127. package/dist/model-hooks.js.map +1 -0
  128. package/dist/postgres-ctl.d.ts +11 -0
  129. package/dist/postgres-ctl.d.ts.map +1 -1
  130. package/dist/postgres-ctl.js +25 -2
  131. package/dist/postgres-ctl.js.map +1 -1
  132. package/dist/project-config.d.ts +121 -1
  133. package/dist/project-config.d.ts.map +1 -1
  134. package/dist/project-config.js +150 -1
  135. package/dist/project-config.js.map +1 -1
  136. package/dist/resolve-target.d.ts +4 -0
  137. package/dist/resolve-target.d.ts.map +1 -1
  138. package/dist/resolve-target.js +69 -73
  139. package/dist/resolve-target.js.map +1 -1
  140. package/dist/schema-ast-v2.d.ts +2 -1
  141. package/dist/schema-ast-v2.d.ts.map +1 -1
  142. package/dist/schema-ast-v2.js +4 -2
  143. package/dist/schema-ast-v2.js.map +1 -1
  144. package/dist/schema-push-lock.d.ts.map +1 -1
  145. package/dist/schema-push-lock.js +43 -16
  146. package/dist/schema-push-lock.js.map +1 -1
  147. package/dist/self-host-compose.d.ts +24 -0
  148. package/dist/self-host-compose.d.ts.map +1 -1
  149. package/dist/self-host-compose.js +301 -69
  150. package/dist/self-host-compose.js.map +1 -1
  151. package/dist/service-role-check.d.ts +21 -0
  152. package/dist/service-role-check.d.ts.map +1 -0
  153. package/dist/service-role-check.js +85 -0
  154. package/dist/service-role-check.js.map +1 -0
  155. package/dist/target-client.d.ts +17 -0
  156. package/dist/target-client.d.ts.map +1 -1
  157. package/dist/target-client.js +69 -17
  158. package/dist/target-client.js.map +1 -1
  159. package/dist/type-extractor.d.ts.map +1 -1
  160. package/dist/type-extractor.js +736 -32
  161. package/dist/type-extractor.js.map +1 -1
  162. package/dist/ui/runtime/command-chrome.js +1 -1
  163. package/dist/ui/runtime/command-chrome.js.map +1 -1
  164. package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
  165. package/dist/ui/runtime/stdin-after-ink.js +3 -1
  166. package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
  167. package/package.json +4 -1
  168. package/scripts/generate-homebrew-formula.mjs +121 -0
  169. package/src/augmentation-generator.ts +18 -5
  170. package/src/binary-cache.ts +47 -15
  171. package/src/cache-clean.ts +81 -0
  172. package/src/cache-pins.ts +243 -0
  173. package/src/cli-install-method.ts +67 -0
  174. package/src/cli.ts +6 -0
  175. package/src/cloud-credentials.ts +183 -0
  176. package/src/commands/admin.ts +140 -25
  177. package/src/commands/cache.ts +90 -75
  178. package/src/commands/cloud.ts +32 -8
  179. package/src/commands/db.ts +162 -1
  180. package/src/commands/dev.ts +57 -8
  181. package/src/commands/doctor.ts +69 -3
  182. package/src/commands/functions.ts +42 -5
  183. package/src/commands/generate.ts +5 -1
  184. package/src/commands/hooks.ts +138 -0
  185. package/src/commands/init.ts +42 -8
  186. package/src/commands/internal.ts +23 -0
  187. package/src/commands/link-helpers.ts +9 -2
  188. package/src/commands/login.ts +127 -0
  189. package/src/commands/push.ts +124 -7
  190. package/src/commands/self-update.ts +85 -13
  191. package/src/config.ts +22 -3
  192. package/src/db-preflight.ts +575 -0
  193. package/src/dev-compose.ts +142 -73
  194. package/src/env-file.ts +157 -11
  195. package/src/field-masking-tier.ts +107 -0
  196. package/src/functions-deno-types.ts +188 -0
  197. package/src/hook-upload.ts +107 -0
  198. package/src/hooks-generator.ts +352 -0
  199. package/src/link.ts +7 -0
  200. package/src/local-secrets.ts +104 -0
  201. package/src/model-hooks.ts +260 -0
  202. package/src/postgres-ctl.ts +29 -2
  203. package/src/project-config.ts +236 -2
  204. package/src/resolve-target.ts +113 -68
  205. package/src/schema-ast-v2.ts +5 -2
  206. package/src/schema-push-lock.ts +56 -17
  207. package/src/self-host-compose.ts +344 -68
  208. package/src/service-role-check.ts +111 -0
  209. package/src/target-client.ts +105 -18
  210. package/src/type-extractor.ts +883 -28
  211. package/src/ui/runtime/command-chrome.ts +1 -1
  212. package/src/ui/runtime/stdin-after-ink.ts +3 -1
  213. package/tests/access-composition.test.ts +667 -0
  214. package/tests/admin-studio-membership.test.ts +121 -0
  215. package/tests/api-schemas.test.ts +83 -0
  216. package/tests/archive-name-alias.test.ts +42 -0
  217. package/tests/augmentation-generator.test.ts +28 -0
  218. package/tests/cache-clean.test.ts +206 -0
  219. package/tests/cli-help.test.ts +3 -0
  220. package/tests/cli-install-method.test.ts +66 -0
  221. package/tests/command-chrome-coverage.test.ts +31 -26
  222. package/tests/command-chrome-wrap.test.ts +1 -0
  223. package/tests/db-preflight.test.ts +72 -0
  224. package/tests/doctor-render.test.ts +62 -0
  225. package/tests/doctor-service-role.test.ts +55 -0
  226. package/tests/env-file.test.ts +121 -0
  227. package/tests/external-database-compose.test.ts +184 -0
  228. package/tests/external-database-config.test.ts +154 -0
  229. package/tests/field-masking-tier.test.ts +184 -0
  230. package/tests/functions-deno-types.test.ts +197 -0
  231. package/tests/homebrew-formula.test.ts +58 -0
  232. package/tests/hook-upload.test.ts +125 -0
  233. package/tests/hooks-generator.test.ts +212 -0
  234. package/tests/init.test.ts +3 -0
  235. package/tests/local-secrets.test.ts +92 -0
  236. package/tests/model-hooks.test.ts +322 -0
  237. package/tests/runtime-contract.test.ts +113 -1
  238. package/tests/service-role-check.test.ts +109 -0
  239. package/tests/service-role-routes.test.ts +51 -0
  240. package/tests/target-client-refresh.test.ts +94 -0
  241. package/tests/type-extractor.test.ts +187 -0
  242. package/tsconfig.tsbuildinfo +1 -1
  243. package/tests/engine-distribution.test.ts +0 -418
@@ -0,0 +1,212 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { execFileSync } from "node:child_process"
5
+ import { afterEach, describe, expect, it } from "vitest"
6
+ import { generateHooksModule } from "../src/hooks-generator.js"
7
+ import { extractSchemaAstFromTypes } from "../src/type-extractor.js"
8
+
9
+ const dirs: string[] = []
10
+
11
+ afterEach(() => {
12
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
13
+ })
14
+
15
+ function astFor(schema: string): unknown {
16
+ const dir = mkdtempSync(join(tmpdir(), "supatype-hooksgen-"))
17
+ dirs.push(dir)
18
+ const schemaPath = join(dir, "schema.ts")
19
+ writeFileSync(schemaPath, schema, "utf8")
20
+ return extractSchemaAstFromTypes(schemaPath, dir)
21
+ }
22
+
23
+ const HOOKED_SCHEMA = `
24
+ import type { Model, Optional, RichText, Timestamp, UUID } from "@supatype/types"
25
+
26
+ export type Post = Model<{
27
+ id: UUID
28
+ title: string
29
+ body: RichText
30
+ views: Optional<number>
31
+ created_at: Timestamp
32
+ }, {
33
+ tableName: "posts"
34
+ hooks: { beforeChange: "moderate-post"; afterDelete: "purge-post" }
35
+ }>
36
+
37
+ export type Comment = Model<{ id: UUID; body: string }, { tableName: "comments" }>
38
+ `
39
+
40
+ describe("generateHooksModule", () => {
41
+ it("returns null when nothing declares a hook", () => {
42
+ const ast = astFor(`
43
+ import type { Model, UUID } from "@supatype/types"
44
+
45
+ export type Post = Model<{ id: UUID }, { tableName: "posts" }>
46
+ `)
47
+ // A generated file nobody imports is a file somebody eventually edits.
48
+ expect(generateHooksModule(ast)).toBeNull()
49
+ })
50
+
51
+ it("includes only the tables that declare a hook", () => {
52
+ const module = generateHooksModule(astFor(HOOKED_SCHEMA))
53
+ expect(module).not.toBeNull()
54
+ expect(module).toContain('"posts": {')
55
+ // `HookedTable` is what makes `BeforeChange<"comments">` a compile error, so an unhooked table
56
+ // must not appear at all.
57
+ expect(module).not.toContain('"comments": {')
58
+ })
59
+
60
+ it("carries the model's real columns into Row, Insert and Update", () => {
61
+ const module = generateHooksModule(astFor(HOOKED_SCHEMA)) ?? ""
62
+ expect(module).toContain("title: string")
63
+ // `created_at` is server-generated, so it is optional on insert but present on a row.
64
+ expect(module).toMatch(/Insert: \{[\s\S]*created_at\?:/)
65
+ expect(module).toMatch(/Row: \{[\s\S]*created_at: string/)
66
+ // Every column is optional on a patch.
67
+ expect(module).toMatch(/Update: \{[\s\S]*title\?: string/)
68
+ })
69
+ })
70
+
71
+ describe("the generated module compiles", () => {
72
+ /**
73
+ * The generator emits TypeScript, so the only test that really proves it works is a compiler.
74
+ * A missing brace or a type that does not resolve would otherwise reach a user's editor — and this
75
+ * file is imported by their hook, so a broken emit breaks their function rather than our build.
76
+ */
77
+ it("typechecks under strict mode, together with a handler that uses it", () => {
78
+ const dir = mkdtempSync(join(tmpdir(), "supatype-hookscompile-"))
79
+ dirs.push(dir)
80
+
81
+ const module = generateHooksModule(astFor(HOOKED_SCHEMA))
82
+ expect(module).not.toBeNull()
83
+ writeFileSync(join(dir, "hooks.ts"), module ?? "", "utf8")
84
+
85
+ // `supatype functions new` writes a `functions/deno.d.ts`, which is where a real project gets
86
+ // this. The generated module uses `Deno.env` to find the API, so the fixture needs it too.
87
+ writeFileSync(
88
+ join(dir, "deno.d.ts"),
89
+ `declare namespace Deno {
90
+ const env: { get(key: string): string | undefined }
91
+ }
92
+ `,
93
+ "utf8",
94
+ )
95
+
96
+ // A handler written the way the docs will show it, including the narrowing that the
97
+ // discriminated union is there to provide.
98
+ writeFileSync(
99
+ join(dir, "handler.ts"),
100
+ `import { hook, type AfterDelete, type BeforeChange } from "./hooks.ts"
101
+
102
+ const moderate: BeforeChange<"posts"> = async (ctx) => {
103
+ if (ctx.operation === "insert") {
104
+ if (ctx.rows.some((r) => r.title.trim() === "")) return { reject: "A post needs a title" }
105
+ return { rows: ctx.rows.map((r) => ({ ...r, title: r.title.trim() })) }
106
+ }
107
+ const { rows, truncated } = await ctx.previous()
108
+ if (truncated) return { reject: { message: "Too many rows", status: 409 } }
109
+ if (rows.length === 0) return {}
110
+ return { patch: { ...ctx.patch, title: ctx.patch.title ?? rows[0]!.title } }
111
+ }
112
+
113
+ const purge: AfterDelete<"posts"> = async (ctx) => {
114
+ void ctx.filter
115
+ }
116
+
117
+ export default hook(moderate)
118
+ export const alsoPurge = hook(purge)
119
+ `,
120
+ "utf8",
121
+ )
122
+
123
+ writeFileSync(
124
+ join(dir, "tsconfig.json"),
125
+ JSON.stringify({
126
+ compilerOptions: {
127
+ strict: true,
128
+ exactOptionalPropertyTypes: true,
129
+ noEmit: true,
130
+ target: "ES2022",
131
+ module: "Preserve",
132
+ moduleResolution: "bundler",
133
+ allowImportingTsExtensions: true,
134
+ lib: ["ES2022", "DOM"],
135
+ types: [],
136
+ },
137
+ include: ["*.ts"],
138
+ }),
139
+ "utf8",
140
+ )
141
+
142
+ const tsc = join(__dirname, "..", "..", "..", "node_modules", "typescript", "bin", "tsc")
143
+ let output = ""
144
+ try {
145
+ execFileSync(process.execPath, [tsc, "-p", join(dir, "tsconfig.json")], {
146
+ encoding: "utf8",
147
+ stdio: ["ignore", "pipe", "pipe"],
148
+ })
149
+ } catch (err) {
150
+ const failure = err as { stdout?: string; stderr?: string }
151
+ output = `${failure.stdout ?? ""}${failure.stderr ?? ""}`
152
+ }
153
+ expect(output).toBe("")
154
+ })
155
+
156
+ it("rejects a handler for a table that declares no hook", () => {
157
+ // The point of `HookedTable`: naming an unhooked table must not silently produce a handler that
158
+ // never runs.
159
+ const dir = mkdtempSync(join(tmpdir(), "supatype-hooksneg-"))
160
+ dirs.push(dir)
161
+ writeFileSync(join(dir, "hooks.ts"), generateHooksModule(astFor(HOOKED_SCHEMA)) ?? "", "utf8")
162
+ // `supatype functions new` writes a `functions/deno.d.ts`, which is where a real project gets
163
+ // this. The generated module uses `Deno.env` to find the API, so the fixture needs it too.
164
+ writeFileSync(
165
+ join(dir, "deno.d.ts"),
166
+ `declare namespace Deno {
167
+ const env: { get(key: string): string | undefined }
168
+ }
169
+ `,
170
+ "utf8",
171
+ )
172
+
173
+ writeFileSync(
174
+ join(dir, "handler.ts"),
175
+ `import type { BeforeChange } from "./hooks.ts"
176
+ const nope: BeforeChange<"comments"> = async () => ({})
177
+ export default nope
178
+ `,
179
+ "utf8",
180
+ )
181
+ writeFileSync(
182
+ join(dir, "tsconfig.json"),
183
+ JSON.stringify({
184
+ compilerOptions: {
185
+ strict: true,
186
+ noEmit: true,
187
+ target: "ES2022",
188
+ module: "Preserve",
189
+ moduleResolution: "bundler",
190
+ allowImportingTsExtensions: true,
191
+ lib: ["ES2022", "DOM"],
192
+ types: [],
193
+ },
194
+ include: ["*.ts"],
195
+ }),
196
+ "utf8",
197
+ )
198
+
199
+ const tsc = join(__dirname, "..", "..", "..", "node_modules", "typescript", "bin", "tsc")
200
+ let output = ""
201
+ try {
202
+ execFileSync(process.execPath, [tsc, "-p", join(dir, "tsconfig.json")], {
203
+ encoding: "utf8",
204
+ stdio: ["ignore", "pipe", "pipe"],
205
+ })
206
+ } catch (err) {
207
+ const failure = err as { stdout?: string; stderr?: string }
208
+ output = `${failure.stdout ?? ""}${failure.stderr ?? ""}`
209
+ }
210
+ expect(output).toContain("comments")
211
+ })
212
+ })
@@ -289,6 +289,9 @@ describe("scaffold()", () => {
289
289
  expect(existsSync(join(tmpRoot, "functions/hello/index.ts"))).toBe(true)
290
290
  expect(existsSync(join(tmpRoot, "functions/_shared/README.md"))).toBe(true)
291
291
  expect(existsSync(join(tmpRoot, "functions/.env.local"))).toBe(true)
292
+ expect(existsSync(join(tmpRoot, "functions/deno.d.ts"))).toBe(true)
293
+ expect(existsSync(join(tmpRoot, "functions/tsconfig.json"))).toBe(true)
294
+ expect(readFileSync(join(tmpRoot, "functions/hello/index.ts"), "utf8")).toContain("Deno.env.get")
292
295
  const pkg = readFileSync(join(tmpRoot, "package.json"), "utf8")
293
296
  expect(pkg).toContain("supatype functions serve")
294
297
  })
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { tmpdir } from "node:os"
5
+ import {
6
+ FALLBACK_AUTHENTICATOR_PASSWORD,
7
+ FALLBACK_JWT_SECRET,
8
+ FALLBACK_POSTGRES_PASSWORD,
9
+ devAuthenticatorPassword,
10
+ devJwtSecret,
11
+ devPostgresPassword,
12
+ secretFingerprint,
13
+ } from "../src/local-secrets.js"
14
+ import { upsertEnvFile } from "../src/env-file.js"
15
+
16
+ // The bug these exist to prevent: `supatype dev` used to pin POSTGRES_PASSWORD and JWT_SECRET
17
+ // to published constants in `.env` on every run — the same `.env` a self-host deployment reads.
18
+ // A generated secret survived until the developer's next `dev`, then reverted silently, and the
19
+ // deployment went out with a secret anyone can look up.
20
+
21
+ function project(env?: string): string {
22
+ const dir = mkdtempSync(join(tmpdir(), "supatype-secrets-"))
23
+ if (env !== undefined) writeFileSync(join(dir, ".env"), env, "utf8")
24
+ return dir
25
+ }
26
+
27
+ describe("resolving local secrets", () => {
28
+ it("prefers the project's own values", () => {
29
+ const dir = project("JWT_SECRET=mine\nPOSTGRES_PASSWORD=pg-mine\nAUTHENTICATOR_PASSWORD=auth-mine\n")
30
+ expect(devJwtSecret(dir)).toBe("mine")
31
+ expect(devPostgresPassword(dir)).toBe("pg-mine")
32
+ expect(devAuthenticatorPassword(dir)).toBe("auth-mine")
33
+ })
34
+
35
+ // A project with no `.env` still has to start, so the constants remain — as fallbacks only.
36
+ it("falls back when the project has none", () => {
37
+ const dir = project()
38
+ expect(devJwtSecret(dir)).toBe(FALLBACK_JWT_SECRET)
39
+ expect(devPostgresPassword(dir)).toBe(FALLBACK_POSTGRES_PASSWORD)
40
+ expect(devAuthenticatorPassword(dir)).toBe(FALLBACK_AUTHENTICATOR_PASSWORD)
41
+ })
42
+ })
43
+
44
+ describe("the dev env write does not clobber secrets", () => {
45
+ // This is the regression. `upsertEnvFile` replaces any key handed to it, so the guarantee has
46
+ // to be that the dev path never hands it these — asserted here at the level that matters:
47
+ // write what dev writes, then check the secrets are byte-identical.
48
+ it("leaves generated secrets untouched across repeated writes", () => {
49
+ const dir = project(
50
+ "JWT_SECRET=generated-secret-value\nPOSTGRES_PASSWORD=generated-pg-value\nAUTHENTICATOR_PASSWORD=generated-auth-value\n",
51
+ )
52
+ const before = readFileSync(join(dir, ".env"), "utf8")
53
+
54
+ // The keys `upsertDevComposeEnv` legitimately owns: derived values and local ports.
55
+ for (let run = 0; run < 2; run++) {
56
+ upsertEnvFile(dir, {
57
+ POSTGRES_USER: "supatype_admin",
58
+ POSTGRES_DB: "supatype",
59
+ AUTHENTICATOR_PASSWORD: devAuthenticatorPassword(dir),
60
+ ANON_KEY: `anon-${run}`,
61
+ SERVICE_ROLE_KEY: `service-${run}`,
62
+ SUPATYPE_KONG_PORT: "18473",
63
+ })
64
+ }
65
+
66
+ const after = readFileSync(join(dir, ".env"), "utf8")
67
+ const value = (content: string, key: string): string | undefined =>
68
+ content.match(new RegExp(`^${key}=(.*)$`, "m"))?.[1]
69
+
70
+ for (const key of ["JWT_SECRET", "POSTGRES_PASSWORD", "AUTHENTICATOR_PASSWORD"]) {
71
+ expect(value(after, key), `${key} must survive a dev run`).toBe(value(before, key))
72
+ }
73
+ // Derived keys are still expected to move.
74
+ expect(value(after, "ANON_KEY")).toBe("anon-1")
75
+ })
76
+ })
77
+
78
+ describe("secretFingerprint", () => {
79
+ // The ready panel used to echo the JWT secret in full. Harmless for a published constant,
80
+ // wrong once it is the project's own.
81
+ it("does not reveal the secret", () => {
82
+ const secret = "super-secret-value-that-must-not-be-printed"
83
+ const tag = secretFingerprint(secret)
84
+ expect(secret).not.toContain(tag)
85
+ expect(tag).toMatch(/^[0-9a-f]{8}$/)
86
+ })
87
+
88
+ it("is stable and distinguishes secrets", () => {
89
+ expect(secretFingerprint("a")).toBe(secretFingerprint("a"))
90
+ expect(secretFingerprint("a")).not.toBe(secretFingerprint("b"))
91
+ })
92
+ })
@@ -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
+ })