@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,125 @@
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 { ADAPTER_KEY, readHookUpload } from "../src/hook-upload.js"
7
+
8
+ function project(): { cwd: string; hooks: string } {
9
+ const cwd = mkdtempSync(join(tmpdir(), "supatype-hook-upload-"))
10
+ const hooks = join(cwd, "hooks")
11
+ mkdirSync(hooks, { recursive: true })
12
+ return { cwd, hooks }
13
+ }
14
+
15
+ function writeHook(hooks: string, name: string, source: string, extra?: Record<string, string>): void {
16
+ const dir = join(hooks, name)
17
+ mkdirSync(dir, { recursive: true })
18
+ writeFileSync(join(dir, "index.ts"), source, "utf8")
19
+ for (const [file, contents] of Object.entries(extra ?? {})) {
20
+ writeFileSync(join(dir, file), contents, "utf8")
21
+ }
22
+ }
23
+
24
+ /** An AST shaped as the extractor produces, carrying one hooked model. */
25
+ function astWith(hooks: Record<string, string>): unknown {
26
+ return {
27
+ models: [
28
+ {
29
+ name: "Post",
30
+ fields: [],
31
+ annotations: {
32
+ db: { tableName: "posts" },
33
+ platform: {
34
+ hooks: Object.fromEntries(
35
+ Object.entries(hooks).map(([event, fn]) => [event, { function: fn }]),
36
+ ),
37
+ },
38
+ },
39
+ },
40
+ ],
41
+ }
42
+ }
43
+
44
+ const HANDLER = `import { hook, type BeforeChange } from "../_supatype/hooks.ts"
45
+
46
+ const moderate: BeforeChange<"posts"> = async () => ({})
47
+ export default hook(moderate)
48
+ `
49
+
50
+ describe("readHookUpload", () => {
51
+ it("is null when the schema names no hooks", () => {
52
+ const { cwd, hooks } = project()
53
+ expect(readHookUpload(cwd, hooks, astWith({}))).toBeNull()
54
+ })
55
+
56
+ it("rewrites the generated adapter import to the flattened name", () => {
57
+ // The nested path cannot survive flattening, and this is the specifier our own scaffold writes.
58
+ const { cwd, hooks } = project()
59
+ writeHook(hooks, "moderate-post", HANDLER)
60
+
61
+ const upload = readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" }))
62
+ expect(upload?.handlers).toHaveLength(1)
63
+ expect(upload?.handlers[0]?.source).toContain(`./${ADAPTER_KEY}`)
64
+ expect(upload?.handlers[0]?.source).not.toContain("../_supatype/hooks.ts")
65
+ })
66
+
67
+ it("uploads only the handlers the schema names", () => {
68
+ // An unnamed handler would sit on the worker holding the service-role key with nothing able to
69
+ // call it. Uploading it buys nothing and widens what runs with that credential.
70
+ const { cwd, hooks } = project()
71
+ writeHook(hooks, "moderate-post", HANDLER)
72
+ writeHook(hooks, "abandoned", HANDLER)
73
+
74
+ const upload = readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" }))
75
+ expect(upload?.handlers.map((h) => h.name)).toEqual(["moderate-post"])
76
+ })
77
+
78
+ it("carries the hook map alongside the sources", () => {
79
+ // Both halves in one payload: a map naming a handler whose source has not arrived is the failure
80
+ // this whole feature is meant to avoid.
81
+ const { cwd, hooks } = project()
82
+ writeHook(hooks, "moderate-post", HANDLER)
83
+
84
+ const upload = readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" }))
85
+ expect(upload?.map["posts"]?.["beforeChange"]?.function).toBe("moderate-post")
86
+ })
87
+
88
+ it("refuses a hook with a second file rather than dropping it", () => {
89
+ // Silent truncation would deploy successfully and fail at import — which reads to the caller as
90
+ // that table's writes being broken for no visible reason.
91
+ const { cwd, hooks } = project()
92
+ writeHook(hooks, "moderate-post", HANDLER, { "helpers.ts": "export const x = 1\n" })
93
+
94
+ expect(() => readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" })))
95
+ .toThrow(/more than one file \(helpers\.ts\)/)
96
+ })
97
+
98
+ it("refuses a relative import it cannot resolve after flattening", () => {
99
+ const { cwd, hooks } = project()
100
+ writeHook(hooks, "moderate-post", `import { x } from "./helpers.ts"\nexport default () => x\n`)
101
+
102
+ expect(() => readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" })))
103
+ .toThrow(/imports \.\/helpers\.ts/)
104
+ })
105
+
106
+ it("leaves bare specifiers and URLs alone", () => {
107
+ // Only relative paths depend on the layout. A hook is expected to import from npm: or https:.
108
+ const { cwd, hooks } = project()
109
+ writeHook(
110
+ hooks,
111
+ "moderate-post",
112
+ `import { z } from "npm:zod"\nimport { j } from "https://esm.sh/joi"\n` + HANDLER,
113
+ )
114
+
115
+ const upload = readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" }))
116
+ expect(upload?.handlers).toHaveLength(1)
117
+ })
118
+
119
+ it("names the missing handler when a declared hook has no source", () => {
120
+ const { cwd, hooks } = project()
121
+
122
+ expect(() => readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" })))
123
+ .toThrow(/moderate-post/)
124
+ })
125
+ })
@@ -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
+ })