@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,260 @@
1
+ /**
2
+ * Validate the lifecycle hooks a schema declares against the functions that exist.
3
+ *
4
+ * A hook names a function directory. If the name is wrong, the honest failure is at push time with
5
+ * the path we searched — not at runtime, where the symptom is a hook that never fires and a write
6
+ * that quietly succeeds unvalidated. That silence is the whole reason this check exists.
7
+ *
8
+ * Kept out of the extractor on purpose: resolving the functions directory needs the project config,
9
+ * and the extractor is a leaf that reads type syntax and nothing else.
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
12
+ import { join, relative } from "node:path"
13
+ import { generateHooksModule } from "./hooks-generator.js"
14
+
15
+ export interface DeclaredHook {
16
+ model: string
17
+ event: string
18
+ function: string
19
+ }
20
+
21
+ /** Every hook declared across the schema, in a stable order for reporting. */
22
+ export function declaredHooks(ast: unknown): DeclaredHook[] {
23
+ const models = (ast as { models?: unknown[] })?.models
24
+ if (!Array.isArray(models)) return []
25
+
26
+ const out: DeclaredHook[] = []
27
+ for (const model of models) {
28
+ const shaped = model as {
29
+ name?: string
30
+ annotations?: { platform?: { hooks?: Record<string, unknown> } }
31
+ }
32
+ const hooks = shaped.annotations?.platform?.hooks
33
+ if (typeof hooks !== "object" || hooks === null) continue
34
+
35
+ for (const [event, value] of Object.entries(hooks)) {
36
+ const fn = (value as { function?: unknown })?.function
37
+ if (typeof fn === "string" && fn.length > 0) {
38
+ out.push({ model: shaped.name ?? "?", event, function: fn })
39
+ }
40
+ }
41
+ }
42
+ return out.sort((a, b) => `${a.model}.${a.event}`.localeCompare(`${b.model}.${b.event}`))
43
+ }
44
+
45
+ /** Function directory names available to be used as hooks. */
46
+ function availableFunctions(functionsDir: string): string[] {
47
+ if (!existsSync(functionsDir)) return []
48
+ return readdirSync(functionsDir)
49
+ .filter((entry) => !entry.startsWith("_") && !entry.startsWith("."))
50
+ .filter((entry) => {
51
+ const full = join(functionsDir, entry)
52
+ // A function is a directory with an index.ts, matching how the worker discovers them.
53
+ return statSync(full).isDirectory() && existsSync(join(full, "index.ts"))
54
+ })
55
+ .sort()
56
+ }
57
+
58
+ /**
59
+ * Lines describing every hook whose function is missing, empty when all resolve.
60
+ *
61
+ * Returns the message rather than throwing so the caller decides how to fail: `push` stops, while
62
+ * `doctor` can report several problems at once.
63
+ */
64
+ export function validateModelHooks(
65
+ ast: unknown,
66
+ functionsDir: string,
67
+ cwd: string,
68
+ ): string[] {
69
+ const hooks = declaredHooks(ast)
70
+ if (hooks.length === 0) return []
71
+
72
+ const available = availableFunctions(functionsDir)
73
+ const known = new Set(available)
74
+ const missing = hooks.filter((hook) => !known.has(hook.function))
75
+ if (missing.length === 0) return []
76
+
77
+ const where = relative(cwd, functionsDir) || functionsDir
78
+ const lines = missing.map(
79
+ (hook) => ` ${hook.model}.${hook.event} → "${hook.function}" (no ${where}/${hook.function}/index.ts)`,
80
+ )
81
+ lines.push("")
82
+ lines.push(
83
+ available.length > 0
84
+ ? `Hooks found in ${where}: ${available.join(", ")}`
85
+ : `No hooks found in ${where}. Create one with: supatype hooks new <name>`,
86
+ )
87
+ return lines
88
+ }
89
+
90
+ /**
91
+ * Write `functions/_supatype/hooks.ts`, or remove a stale one when no hooks remain.
92
+ *
93
+ * Removal matters as much as writing: deleting the last hook from a schema should leave no typed
94
+ * module behind claiming tables are hooked, and a handler importing it should start failing to
95
+ * compile rather than sitting there dead.
96
+ *
97
+ * Returns the project-relative path written, or null when there was nothing to write.
98
+ */
99
+ export function writeHooksModule(cwd: string, functionsDir: string, ast: unknown): string | null {
100
+ const module = generateHooksModule(ast)
101
+ const dir = join(functionsDir, "_supatype")
102
+ const file = join(dir, "hooks.ts")
103
+
104
+ if (module === null) {
105
+ if (existsSync(file)) rmSync(file, { force: true })
106
+ return null
107
+ }
108
+
109
+ mkdirSync(dir, { recursive: true })
110
+ writeFileSync(file, module, "utf8")
111
+ return relative(cwd, file) || file
112
+ }
113
+
114
+ /** Per-table hook config, in the shape `proxy.RouteManifest` reads. */
115
+ export interface ManifestHookEntry {
116
+ function: string
117
+ timeout?: number
118
+ onUnavailable?: "reject" | "log"
119
+ }
120
+
121
+ /**
122
+ * The hook map for `.supatype/manifest.json`, keyed by **table name** — because that is what the
123
+ * server matches a request path against, not the model name.
124
+ *
125
+ * Defaults are resolved here rather than in the server: one place decides that a `before*` hook
126
+ * rejects when it cannot be reached and an `after*` hook only logs, so the two implementations
127
+ * cannot disagree about the safe direction.
128
+ */
129
+ export function manifestHooks(ast: unknown): Record<string, Record<string, ManifestHookEntry>> {
130
+ const models = (ast as { models?: unknown[] })?.models
131
+ if (!Array.isArray(models)) return {}
132
+
133
+ const out: Record<string, Record<string, ManifestHookEntry>> = {}
134
+ for (const model of models) {
135
+ const shaped = model as {
136
+ annotations?: {
137
+ db?: { tableName?: string }
138
+ platform?: { hooks?: Record<string, { function?: string; timeout?: number; onUnavailable?: string }> }
139
+ }
140
+ }
141
+ const table = shaped.annotations?.db?.tableName
142
+ const hooks = shaped.annotations?.platform?.hooks
143
+ if (typeof table !== "string" || table.length === 0) continue
144
+ if (typeof hooks !== "object" || hooks === null) continue
145
+
146
+ const entries: Record<string, ManifestHookEntry> = {}
147
+ for (const [event, value] of Object.entries(hooks)) {
148
+ const fn = value?.function
149
+ if (typeof fn !== "string" || fn.length === 0) continue
150
+ const onUnavailable =
151
+ value.onUnavailable === "reject" || value.onUnavailable === "log"
152
+ ? value.onUnavailable
153
+ : event.startsWith("before")
154
+ ? "reject"
155
+ : "log"
156
+ entries[event] = {
157
+ function: fn,
158
+ timeout: typeof value.timeout === "number" ? value.timeout : DEFAULT_HOOK_TIMEOUT_MS,
159
+ onUnavailable,
160
+ }
161
+ }
162
+ if (Object.keys(entries).length > 0) out[table] = entries
163
+ }
164
+ return out
165
+ }
166
+
167
+ /** Well below the 10s edge-function ceiling, so a hung hook fails fast instead of holding a slot. */
168
+ export const DEFAULT_HOOK_TIMEOUT_MS = 2000
169
+
170
+ /**
171
+ * Merge the hook map into an existing `.supatype/manifest.json`.
172
+ *
173
+ * **Only updates a manifest that is already there.** Creating one from scratch here would be a
174
+ * hazard: `functions_enabled` is a plain bool on the server's side, so a manifest carrying only
175
+ * hooks would read as functions *disabled* — the exact defect this repo fixed a commit ago, arriving
176
+ * by a different door. The compose path owns creation; this owns one key.
177
+ *
178
+ * Returns true when the file was rewritten.
179
+ */
180
+ export function syncManifestHooks(cwd: string, ast: unknown): boolean {
181
+ const manifestPath = join(cwd, ".supatype", "manifest.json")
182
+ if (!existsSync(manifestPath)) return false
183
+
184
+ let parsed: Record<string, unknown>
185
+ try {
186
+ parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>
187
+ } catch {
188
+ return false // Malformed: the server will complain about it far more clearly than we can here.
189
+ }
190
+ if (typeof parsed !== "object" || parsed === null) return false
191
+
192
+ const hooks = manifestHooks(ast)
193
+ const next = JSON.stringify(hooks)
194
+ const current = JSON.stringify(parsed["hooks"] ?? {})
195
+ if (next === current) return false
196
+
197
+ if (Object.keys(hooks).length === 0) {
198
+ delete parsed["hooks"]
199
+ } else {
200
+ parsed["hooks"] = hooks
201
+ }
202
+ writeFileSync(manifestPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8")
203
+ return true
204
+ }
205
+
206
+ export interface HooksReport {
207
+ declared: DeclaredHook[]
208
+ /** Hooks whose function directory is missing. */
209
+ missing: DeclaredHook[]
210
+ /** True when a manifest exists and says the functions subsystem is off. */
211
+ functionsDisabled: boolean
212
+ /** True when a manifest exists but carries no hook map, so the server has nothing to call. */
213
+ mapMissing: boolean
214
+ }
215
+
216
+ /**
217
+ * What `supatype doctor` needs to answer "will my hooks actually run?".
218
+ *
219
+ * Local facts only — the schema, the functions on disk, and the manifest the server reads. No probe
220
+ * of a running worker, so the answer is the same whether or not the stack is up, and a report that
221
+ * needs a stack is a report nobody runs before deploying.
222
+ *
223
+ * The case worth catching: hooks declared while `functions_enabled` is false. Nothing fails, no error
224
+ * appears, and every hook silently never fires.
225
+ */
226
+ export function hooksReport(cwd: string, functionsDir: string, ast: unknown): HooksReport {
227
+ const declared = declaredHooks(ast)
228
+ const manifestPath = join(cwd, ".supatype", "manifest.json")
229
+
230
+ let functionsDisabled = false
231
+ let mapMissing = false
232
+ if (declared.length > 0 && existsSync(manifestPath)) {
233
+ try {
234
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>
235
+ functionsDisabled = parsed["functions_enabled"] === false
236
+ mapMissing = parsed["hooks"] === undefined
237
+ } catch {
238
+ // Unparseable: the server reports that far better than a doctor line could.
239
+ }
240
+ }
241
+
242
+ const known = new Set(
243
+ existsSync(functionsDir)
244
+ ? readdirSync(functionsDir).filter(
245
+ (entry) =>
246
+ !entry.startsWith("_") &&
247
+ !entry.startsWith(".") &&
248
+ statSync(join(functionsDir, entry)).isDirectory() &&
249
+ existsSync(join(functionsDir, entry, "index.ts")),
250
+ )
251
+ : [],
252
+ )
253
+
254
+ return {
255
+ declared,
256
+ missing: declared.filter((hook) => !known.has(hook.function)),
257
+ functionsDisabled,
258
+ mapMissing,
259
+ }
260
+ }
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { spawnSync } from "node:child_process"
7
7
  import { existsSync, mkdirSync } from "node:fs"
8
- import { dirname, join } from "node:path"
8
+ import { dirname, join, resolve as resolvePath } from "node:path"
9
9
 
10
10
  export interface PgOptions {
11
11
  /** Absolute path to the directory containing pg_ctl, initdb, psql, etc. */
@@ -72,15 +72,42 @@ export function initdb(opts: PgOptions): void {
72
72
  * Start Postgres using pg_ctl.
73
73
  * Returns immediately once pg_ctl has handed off to the server process.
74
74
  */
75
+ /**
76
+ * Whether a native Postgres install carries the masking library.
77
+ *
78
+ * Asked of the filesystem rather than assumed from the provider, because a developer with an archive
79
+ * downloaded before the library was bundled has a native Postgres *without* it. Assuming otherwise
80
+ * would select tier 1 and their next push would be refused by the capability probe.
81
+ *
82
+ * Two layouts: Linux and macOS put extension libraries in `lib/`, the Windows archive in
83
+ * `lib/postgresql/`.
84
+ */
85
+ export function nativeMaskLibraryPresent(pgBinDir: string | null | undefined): boolean {
86
+ if (!pgBinDir) return false
87
+ const prefix = resolvePath(pgBinDir, "..")
88
+ return ["supatype_mask.so", "supatype_mask.dylib", "supatype_mask.dll"].some(
89
+ (lib) =>
90
+ existsSync(join(prefix, "lib", lib)) || existsSync(join(prefix, "lib", "postgresql", lib)),
91
+ )
92
+ }
93
+
75
94
  export function start(opts: PgOptions): void {
76
95
  const bin = pgBin(opts.pgBinDir, "pg_ctl")
77
96
  const logPath = opts.logPath ?? join(opts.dataDir, "postgres.log")
78
97
 
98
+ // `supatype_mask` is a planner hook, so it has to be preloaded — `CREATE EXTENSION` alone does
99
+ // nothing without it. Only when the archive actually carries the library: an install downloaded
100
+ // before it was bundled would fail to start with "could not access file".
101
+ //
102
+ // Native Postgres was started with no preloaded libraries at all until now, which is why field
103
+ // rules could not be enforced on the default `supatype dev`.
104
+ const preload = nativeMaskLibraryPresent(opts.pgBinDir) ? " -c shared_preload_libraries=supatype_mask" : ""
105
+
79
106
  const args = [
80
107
  "start",
81
108
  "-D", opts.dataDir,
82
109
  "-l", logPath,
83
- "-o", `-p ${opts.port}`,
110
+ "-o", `-p ${opts.port}${preload}`,
84
111
  "--wait",
85
112
  ]
86
113
 
@@ -31,8 +31,38 @@ export interface SupatypeProjectConfig {
31
31
  * Database backend.
32
32
  * "native" = supatype manages a native Postgres binary (downloaded from CDN).
33
33
  * "docker" = supatype runs supatype/postgres via Docker (includes all extensions).
34
+ *
35
+ * Omitted when `external` is set — there is no backend for Supatype to choose.
34
36
  */
35
- provider: "native" | "docker"
37
+ provider?: "native" | "docker"
38
+ /**
39
+ * Point the stack at a Postgres that already exists, instead of provisioning one.
40
+ *
41
+ * The presence of this block is the switch: with it, no `db` service is generated and every
42
+ * service connects here. Deliberately not an overload of `connection`, which means something
43
+ * narrower (a DSN for CLI commands) and would become the third setting meaning two things.
44
+ *
45
+ * **Self-host only.** On the cloud path the database is part of what is being provided, so the
46
+ * block is rejected rather than ignored.
47
+ */
48
+ external?: {
49
+ /**
50
+ * Postgres URL for every service in the stack.
51
+ *
52
+ * The role in it owns the schema and runs migrations. PostgREST connects as `authenticator`
53
+ * separately — see `supatype db check`, which reports what this database is missing.
54
+ */
55
+ url: string
56
+ /**
57
+ * Force realtime off.
58
+ *
59
+ * Realtime needs logical replication (`wal_level = logical`, a replication slot, and
60
+ * `wal2json`), which a managed provider may not offer. Left unset, the stack probes for the
61
+ * capability and records the answer, so this is only for overriding a probe that says yes when
62
+ * you would rather it did not run.
63
+ */
64
+ realtime?: boolean
65
+ }
36
66
  /**
37
67
  * Directory where Postgres stores its data files (provider=native).
38
68
  * Defaults to ~/.supatype/projects/{name}/data when omitted.
@@ -182,10 +212,36 @@ export interface SupatypeProjectConfig {
182
212
  path?: string
183
213
  /** Postgres schema name. Defaults to "public". */
184
214
  pg_schema?: string
215
+ /**
216
+ * Schemas the REST API exposes, in order (`PGRST_DB_SCHEMA`).
217
+ *
218
+ * Defaults to `pg_schema` plus the ones the stack needs for itself — `supatype` for Studio's
219
+ * views, `graphql_public`, `auth` — so setting `pg_schema` alone does the sensible thing.
220
+ *
221
+ * It used to be a hardcoded literal, which meant choosing a non-`public` `pg_schema` gave you
222
+ * a correct push and an API that answered `PGRST106` for everything: the engine had moved and
223
+ * PostgREST had not been told. State this explicitly when you need a different set — an extra
224
+ * schema of your own, or to stop exposing one.
225
+ */
226
+ api_schemas?: readonly string[]
185
227
  }
186
228
  functions?: {
187
229
  /** Path to edge functions directory, relative to `supatype.root` when not absolute. */
188
230
  path?: string
231
+ /**
232
+ * **Public functions** allowed to see the service-role key, which reads and writes past every
233
+ * access rule.
234
+ *
235
+ * Empty by default, and that default is the point: a function is a public endpoint anyone holding
236
+ * the anon key can invoke, so an ambient admin credential made every one of them able to read the
237
+ * whole database. Naming one here is a reviewable line in a diff; ambient privilege is not.
238
+ *
239
+ * **Model hooks are not listed here and do not need to be.** A hook is procedural: only the API
240
+ * server calls it, around a write the caller was already permitted to make, and the gateway
241
+ * refuses its route from outside — so there is no attacker to withhold it from, and the trust is
242
+ * the same a trigger already has.
243
+ */
244
+ serviceRole?: readonly string[]
189
245
  }
190
246
  output?: {
191
247
  /** Path for generated TypeScript types. */
@@ -350,9 +406,75 @@ export function validateProjectConfig(raw: unknown, filename: string): SupatypeP
350
406
  throw new Error(`${filename}: app section is required`)
351
407
  }
352
408
 
409
+ validateExternalDatabase(cfg, filename)
410
+
353
411
  return raw as SupatypeProjectConfig
354
412
  }
355
413
 
414
+ /**
415
+ * Rules for `database.external`, all of them errors rather than precedence.
416
+ *
417
+ * Every one of these is a case where two settings describe the same fact and the stack would have to
418
+ * pick. A silent winner is how you end up with a push that went somewhere other than where the
419
+ * services are reading, which looks like data loss and is not.
420
+ */
421
+ function validateExternalDatabase(cfg: Record<string, unknown>, filename: string): void {
422
+ const database = cfg["database"] as Record<string, unknown>
423
+ const external = database["external"]
424
+ if (external === undefined) return
425
+
426
+ if (typeof external !== "object" || external === null || Array.isArray(external)) {
427
+ throw new Error(`${filename}: database.external must be an object with a url`)
428
+ }
429
+
430
+ const url = (external as Record<string, unknown>)["url"]
431
+ if (typeof url !== "string" || url.trim().length === 0) {
432
+ throw new Error(
433
+ `${filename}: database.external.url is required — the Postgres URL every service connects to.\n` +
434
+ "Reading it from the environment keeps the password out of version control:\n" +
435
+ " database: { external: { url: process.env.DATABASE_URL! } }\n" +
436
+ "The project's .env is loaded before the config module, so DATABASE_URL there is enough.",
437
+ )
438
+ }
439
+ if (!/^postgres(ql)?:\/\//.test(url.trim())) {
440
+ throw new Error(
441
+ `${filename}: database.external.url must be a postgres:// or postgresql:// URL (got "${url.trim()}")`,
442
+ )
443
+ }
444
+
445
+ const realtime = (external as Record<string, unknown>)["realtime"]
446
+ if (realtime !== undefined && typeof realtime !== "boolean") {
447
+ throw new Error(`${filename}: database.external.realtime must be true or false`)
448
+ }
449
+
450
+ if (database["provider"] !== undefined) {
451
+ throw new Error(
452
+ `${filename}: database.provider ("${String(database["provider"])}") and database.external ` +
453
+ "cannot both be set — provider chooses a Postgres for Supatype to run, external says one " +
454
+ "already exists.\n" +
455
+ "Remove database.provider. The runtime stack is still chosen by the top-level `provider`.",
456
+ )
457
+ }
458
+
459
+ const server = cfg["server"] as Record<string, unknown> | undefined
460
+ if (server?.["mode"] === "managed") {
461
+ throw new Error(
462
+ `${filename}: database.external is not supported with server.mode "managed" — on the cloud ` +
463
+ "path the database is part of what is being provided.\n" +
464
+ "Use an external database with a self-hosted stack (server.mode \"dev\" or \"standalone\").",
465
+ )
466
+ }
467
+
468
+ const connection = cfg["connection"]
469
+ if (typeof connection === "string" && connection.trim() !== url.trim()) {
470
+ throw new Error(
471
+ `${filename}: connection and database.external.url are both set and disagree.\n` +
472
+ "database.external.url is what the whole stack uses, CLI commands included — remove " +
473
+ "`connection`.",
474
+ )
475
+ }
476
+ }
477
+
356
478
  /** Schema entry path (with fallback). */
357
479
  export function schemaPathFromProject(cfg: SupatypeProjectConfig, cwd: string): string {
358
480
  return resolve(projectRootFromConfig(cfg, cwd), cfg.schema?.path ?? "schema/index.ts")
@@ -373,6 +495,19 @@ export function functionsPathCandidatesFromProject(cfg: SupatypeProjectConfig, c
373
495
  return [resolve(root, "functions"), resolve(root, "supatype/functions")]
374
496
  }
375
497
 
498
+ /**
499
+ * Directory holding **model hooks** — procedural handlers the API calls around a write.
500
+ *
501
+ * Separate from `functions/` because the two have different trust models: a function is a public
502
+ * endpoint anyone with the anon key may invoke, while a hook is only ever called by the server. One
503
+ * worker serves both, and the gateway refuses the hook namespace from outside — so keeping them in
504
+ * separate directories is what makes that boundary structural rather than a list to maintain.
505
+ */
506
+ export function hooksPathFromProject(cfg: SupatypeProjectConfig, cwd: string): string {
507
+ const root = projectRootFromConfig(cfg, cwd)
508
+ return resolve(root, "hooks")
509
+ }
510
+
376
511
  /** Preferred default functions path (used when creating new functions). */
377
512
  export function preferredFunctionsPathFromProject(cfg: SupatypeProjectConfig, cwd: string): string {
378
513
  const candidates = functionsPathCandidatesFromProject(cfg, cwd)
@@ -425,6 +560,22 @@ export function resolveRuntimeProvider(cfg: SupatypeProjectConfig): "native" | "
425
560
  return cfg.provider ?? cfg.database.provider ?? "native"
426
561
  }
427
562
 
563
+ /**
564
+ * Routes entitled to the service-role key, as the worker's env expects them.
565
+ *
566
+ * Resolved here rather than in the compose template so there is one definition of the format.
567
+ *
568
+ * This does **not** check that the names exist — it cannot, since it has only the config. An earlier
569
+ * version of this comment claimed a typo was "visible in one place rather than silently granting
570
+ * nothing", which was false: nothing read the list except the two callers that turn it into an env var.
571
+ * `checkServiceRoleRoutes` in `service-role-check.ts` is what actually resolves the names, and `push`
572
+ * refuses on it.
573
+ */
574
+ export function serviceRoleRoutes(cfg: SupatypeProjectConfig): string[] {
575
+ const declared = cfg.functions?.serviceRole ?? []
576
+ return [...declared].map((entry) => entry.trim()).filter((entry) => entry.length > 0).sort()
577
+ }
578
+
428
579
  /** Kong gateway port when `provider: docker` (self-host compose dev). */
429
580
  export const COMPOSE_DEV_KONG_PORT = 18473
430
581
 
@@ -439,5 +590,88 @@ export function localDSN(cfg: SupatypeProjectConfig): string {
439
590
  * Prefers optional `connection` in config, then `DATABASE_URL` env, then a local default DSN.
440
591
  */
441
592
  export function connectionString(cfg: SupatypeProjectConfig): string {
442
- return cfg.connection ?? process.env["DATABASE_URL"] ?? localDSN(cfg)
593
+ return externalDatabaseUrl(cfg) ?? cfg.connection ?? process.env["DATABASE_URL"] ?? localDSN(cfg)
594
+ }
595
+
596
+ /** True when the project points at a Postgres it does not manage. */
597
+ export function usesExternalDatabase(cfg: SupatypeProjectConfig): boolean {
598
+ return externalDatabaseUrl(cfg) !== undefined
599
+ }
600
+
601
+ /**
602
+ * The external Postgres URL, or undefined for a managed one.
603
+ *
604
+ * Ahead of `connection` and `DATABASE_URL` in [`connectionString`] on purpose: a stated external
605
+ * database is the whole stack's database, and a CLI command that pushed somewhere else while the
606
+ * services read from here would look exactly like data loss.
607
+ */
608
+ export function externalDatabaseUrl(cfg: SupatypeProjectConfig): string | undefined {
609
+ const url = cfg.database.external?.url?.trim()
610
+ return url && url.length > 0 ? url : undefined
611
+ }
612
+
613
+ /**
614
+ * Whether realtime should run.
615
+ *
616
+ * `false` only when stated. An external database that cannot support logical replication is detected
617
+ * rather than declared — the capability record is what Studio and `doctor` read, so an operator who
618
+ * has not thought about it gets a truthful answer instead of a silent default.
619
+ */
620
+ export function realtimeEnabled(cfg: SupatypeProjectConfig): boolean {
621
+ return cfg.database.external?.realtime ?? true
622
+ }
623
+
624
+ /** The Postgres schema Supatype manages. */
625
+ export function pgSchema(cfg: SupatypeProjectConfig): string {
626
+ const declared = cfg.schema?.pg_schema?.trim()
627
+ return declared && declared.length > 0 ? declared : "public"
628
+ }
629
+
630
+ /**
631
+ * Schemas the stack exposes for its own sake, beyond the one Supatype manages.
632
+ *
633
+ * Dev used to omit `auth` while self-host exposed it, so the same request could work against a
634
+ * self-hosted stack and 404 locally. One list for both.
635
+ */
636
+ export const STACK_API_SCHEMAS = ["supatype", "graphql_public", "auth"] as const
637
+
638
+ /**
639
+ * Schemas to expose over REST, as `PGRST_DB_SCHEMA` wants them.
640
+ *
641
+ * The managed schema first, then what the stack needs for itself. Derived rather than hardcoded
642
+ * because the literal version silently ignored `pg_schema`: the engine would migrate into `app`
643
+ * while PostgREST kept serving `public`, so every request answered `PGRST106` and nothing in the
644
+ * output mentioned the setting that caused it.
645
+ *
646
+ * `api_schemas` replaces the whole list when stated — including the stack schemas, so dropping
647
+ * `supatype` from it is a supported way to stop exposing Studio's views. Order is preserved and
648
+ * duplicates removed: PostgREST serves the first entry as the default profile, so the managed
649
+ * schema has to lead.
650
+ */
651
+ export function apiSchemas(cfg: SupatypeProjectConfig, tier?: "none" | "extension" | "views"): string[] {
652
+ const explicit = cfg.schema?.api_schemas
653
+ // Tier-2 field masking serves from `api`, and the managed schema must come **off** the list: a
654
+ // client picks its schema per request with `Accept-Profile`, so leaving it exposed would let any
655
+ // caller read the unmasked table and make the mask opt-out. The API roles hold no privileges there
656
+ // under tier 2 either, so exposing it would only produce denials.
657
+ const managed = tier === "views" ? "api" : pgSchema(cfg)
658
+ const list = explicit && explicit.length > 0 ? explicit : [managed, ...STACK_API_SCHEMAS]
659
+
660
+ const seen = new Set<string>()
661
+ const out: string[] = []
662
+ for (const raw of list) {
663
+ const name = raw.trim()
664
+ if (name.length === 0 || seen.has(name)) continue
665
+ seen.add(name)
666
+ out.push(name)
667
+ }
668
+ return out
669
+ }
670
+
671
+ /** `PGRST_DB_SCHEMA` value: comma-separated, in order. */
672
+ export function apiSchemaList(
673
+ cfg: SupatypeProjectConfig,
674
+ tier?: "none" | "extension" | "views",
675
+ ): string {
676
+ return apiSchemas(cfg, tier).join(", ")
443
677
  }