@supatype/cli 0.1.12 → 0.2.0

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 (143) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +150 -136
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/cli-version-embedded.d.ts.map +1 -1
  5. package/dist/cli-version-embedded.js +1 -1
  6. package/dist/cli-version-embedded.js.map +1 -1
  7. package/dist/commands/admin.d.ts +5 -5
  8. package/dist/commands/admin.d.ts.map +1 -1
  9. package/dist/commands/admin.js +10 -10
  10. package/dist/commands/admin.js.map +1 -1
  11. package/dist/commands/db.d.ts.map +1 -1
  12. package/dist/commands/db.js +23 -1
  13. package/dist/commands/db.js.map +1 -1
  14. package/dist/commands/dev.js +51 -51
  15. package/dist/commands/dev.js.map +1 -1
  16. package/dist/commands/doctor.d.ts +0 -7
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +26 -0
  19. package/dist/commands/doctor.js.map +1 -1
  20. package/dist/commands/init.js +1 -1
  21. package/dist/commands/logs.d.ts +14 -1
  22. package/dist/commands/logs.d.ts.map +1 -1
  23. package/dist/commands/logs.js +33 -37
  24. package/dist/commands/logs.js.map +1 -1
  25. package/dist/commands/push.d.ts.map +1 -1
  26. package/dist/commands/push.js +18 -7
  27. package/dist/commands/push.js.map +1 -1
  28. package/dist/commands/status.d.ts +1 -1
  29. package/dist/commands/status.d.ts.map +1 -1
  30. package/dist/commands/status.js +33 -64
  31. package/dist/commands/status.js.map +1 -1
  32. package/dist/compose-local-server-image.d.ts +11 -0
  33. package/dist/compose-local-server-image.d.ts.map +1 -1
  34. package/dist/compose-local-server-image.js +18 -0
  35. package/dist/compose-local-server-image.js.map +1 -1
  36. package/dist/compose-services.d.ts +59 -0
  37. package/dist/compose-services.d.ts.map +1 -0
  38. package/dist/compose-services.js +160 -0
  39. package/dist/compose-services.js.map +1 -0
  40. package/dist/config.d.ts +1 -1
  41. package/dist/config.d.ts.map +1 -1
  42. package/dist/dev-compose.d.ts +1 -0
  43. package/dist/dev-compose.d.ts.map +1 -1
  44. package/dist/dev-compose.js +202 -30
  45. package/dist/dev-compose.js.map +1 -1
  46. package/dist/dev-ports.d.ts +24 -0
  47. package/dist/dev-ports.d.ts.map +1 -1
  48. package/dist/dev-ports.js +37 -0
  49. package/dist/dev-ports.js.map +1 -1
  50. package/dist/docker-postgres.d.ts +1 -1
  51. package/dist/docker-postgres.d.ts.map +1 -1
  52. package/dist/docker-postgres.js +2 -2
  53. package/dist/docker-postgres.js.map +1 -1
  54. package/dist/engine-floor.d.ts +50 -0
  55. package/dist/engine-floor.d.ts.map +1 -0
  56. package/dist/engine-floor.js +80 -0
  57. package/dist/engine-floor.js.map +1 -0
  58. package/dist/field-bounds.d.ts +68 -0
  59. package/dist/field-bounds.d.ts.map +1 -0
  60. package/dist/field-bounds.js +277 -0
  61. package/dist/field-bounds.js.map +1 -0
  62. package/dist/hooks-generator.d.ts +1 -1
  63. package/dist/hooks-generator.d.ts.map +1 -1
  64. package/dist/hooks-generator.js +78 -4
  65. package/dist/hooks-generator.js.map +1 -1
  66. package/dist/link.d.ts +1 -1
  67. package/dist/link.d.ts.map +1 -1
  68. package/dist/model-hooks.d.ts +44 -2
  69. package/dist/model-hooks.d.ts.map +1 -1
  70. package/dist/model-hooks.js +116 -12
  71. package/dist/model-hooks.js.map +1 -1
  72. package/dist/pgbouncer-userlist.d.ts +1 -1
  73. package/dist/pgbouncer-userlist.d.ts.map +1 -1
  74. package/dist/pgbouncer-userlist.js +1 -1
  75. package/dist/pgbouncer-userlist.js.map +1 -1
  76. package/dist/postgres-ctl.d.ts +5 -1
  77. package/dist/postgres-ctl.d.ts.map +1 -1
  78. package/dist/postgres-ctl.js +43 -4
  79. package/dist/postgres-ctl.js.map +1 -1
  80. package/dist/project-config.d.ts +4 -4
  81. package/dist/resolve-target.d.ts +1 -1
  82. package/dist/resolve-target.d.ts.map +1 -1
  83. package/dist/schema-ast-v2.d.ts +38 -4
  84. package/dist/schema-ast-v2.d.ts.map +1 -1
  85. package/dist/schema-ast-v2.js +87 -4
  86. package/dist/schema-ast-v2.js.map +1 -1
  87. package/dist/self-host-compose.d.ts.map +1 -1
  88. package/dist/self-host-compose.js +41 -18
  89. package/dist/self-host-compose.js.map +1 -1
  90. package/dist/studio-dev-server.d.ts +8 -1
  91. package/dist/studio-dev-server.d.ts.map +1 -1
  92. package/dist/studio-dev-server.js +1 -2
  93. package/dist/studio-dev-server.js.map +1 -1
  94. package/dist/type-extractor.d.ts.map +1 -1
  95. package/dist/type-extractor.js +309 -27
  96. package/dist/type-extractor.js.map +1 -1
  97. package/package.json +4 -3
  98. package/src/cli-version-embedded.ts +1 -1
  99. package/src/commands/admin.ts +10 -10
  100. package/src/commands/db.ts +27 -1
  101. package/src/commands/dev.ts +51 -51
  102. package/src/commands/doctor.ts +30 -0
  103. package/src/commands/init.ts +1 -1
  104. package/src/commands/logs.ts +34 -37
  105. package/src/commands/push.ts +29 -6
  106. package/src/commands/status.ts +34 -70
  107. package/src/compose-local-server-image.ts +17 -0
  108. package/src/compose-services.ts +174 -0
  109. package/src/config.ts +1 -1
  110. package/src/dev-compose.ts +226 -29
  111. package/src/dev-ports.ts +48 -0
  112. package/src/docker-postgres.ts +2 -2
  113. package/src/engine-floor.ts +113 -0
  114. package/src/field-bounds.ts +359 -0
  115. package/src/hooks-generator.ts +81 -4
  116. package/src/link.ts +1 -1
  117. package/src/model-hooks.ts +158 -12
  118. package/src/pgbouncer-userlist.ts +1 -1
  119. package/src/postgres-ctl.ts +44 -4
  120. package/src/project-config.ts +4 -4
  121. package/src/resolve-target.ts +1 -1
  122. package/src/schema-ast-v2.ts +114 -10
  123. package/src/self-host-compose.ts +41 -18
  124. package/src/studio-dev-server.ts +9 -3
  125. package/src/type-extractor.ts +374 -39
  126. package/tests/admin-ensure.test.ts +10 -10
  127. package/tests/compose-parsable-defaults.test.ts +60 -0
  128. package/tests/compose-services.test.ts +144 -0
  129. package/tests/dev-ports-owner.test.ts +40 -0
  130. package/tests/engine-floor.test.ts +102 -0
  131. package/tests/external-database-compose.test.ts +3 -3
  132. package/tests/field-bounds-matrix.test.ts +163 -0
  133. package/tests/field-bounds.test.ts +139 -0
  134. package/tests/field-validators.test.ts +139 -0
  135. package/tests/hooks-generator.test.ts +86 -0
  136. package/tests/local-server-image-env.test.ts +93 -0
  137. package/tests/model-constraints.test.ts +293 -0
  138. package/tests/model-hooks.test.ts +56 -0
  139. package/tests/port-in-use.test.ts +58 -0
  140. package/tests/readiness-poll.test.ts +84 -0
  141. package/tests/runtime-contract.test.ts +1 -1
  142. package/tests/type-extractor.test.ts +49 -0
  143. package/tsconfig.tsbuildinfo +1 -1
@@ -164,16 +164,127 @@ export function manifestHooks(ast: unknown): Record<string, Record<string, Manif
164
164
  return out
165
165
  }
166
166
 
167
+
168
+ /** One declared per-field validator. `event` is the field it checks, so reporting reads uniformly. */
169
+ export interface DeclaredValidator {
170
+ model: string
171
+ field: string
172
+ function: string
173
+ }
174
+
175
+ /** Every per-field validator declared across the schema, in a stable order for reporting. */
176
+ export function declaredValidators(ast: unknown): DeclaredValidator[] {
177
+ const models = (ast as { models?: unknown[] })?.models
178
+ if (!Array.isArray(models)) return []
179
+
180
+ const out: DeclaredValidator[] = []
181
+ for (const model of models) {
182
+ const shaped = model as {
183
+ name?: string
184
+ annotations?: { platform?: { validate?: Record<string, unknown> } }
185
+ }
186
+ const validators = shaped.annotations?.platform?.validate
187
+ if (typeof validators !== "object" || validators === null) continue
188
+
189
+ for (const [field, value] of Object.entries(validators)) {
190
+ const fn = (value as { function?: unknown })?.function
191
+ if (typeof fn === "string" && fn.length > 0) {
192
+ out.push({ model: shaped.name ?? "?", field, function: fn })
193
+ }
194
+ }
195
+ }
196
+ return out.sort((a, b) => `${a.model}.${a.field}`.localeCompare(`${b.model}.${b.field}`))
197
+ }
198
+
199
+ /**
200
+ * The validator map for `.supatype/manifest.json`, keyed by **table** then **column**.
201
+ *
202
+ * `onUnavailable` is written explicitly as `reject` rather than left to the server's default. The
203
+ * server does default that way, but its policy matches exact event names, and a validator that
204
+ * silently accepted a value because a new event name was missing from a switch is precisely the
205
+ * failure found when that path was built. Saying it here means neither side has to be right alone.
206
+ */
207
+ export function manifestValidators(ast: unknown): Record<string, Record<string, ManifestHookEntry>> {
208
+ const models = (ast as { models?: unknown[] })?.models
209
+ if (!Array.isArray(models)) return {}
210
+
211
+ const out: Record<string, Record<string, ManifestHookEntry>> = {}
212
+ for (const model of models) {
213
+ const shaped = model as {
214
+ annotations?: {
215
+ db?: { tableName?: string }
216
+ platform?: { validate?: Record<string, { function?: string; timeout?: number }> }
217
+ }
218
+ }
219
+ const table = shaped.annotations?.db?.tableName
220
+ const validators = shaped.annotations?.platform?.validate
221
+ if (typeof table !== "string" || table.length === 0) continue
222
+ if (typeof validators !== "object" || validators === null) continue
223
+
224
+ const entries: Record<string, ManifestHookEntry> = {}
225
+ for (const [field, value] of Object.entries(validators)) {
226
+ const fn = value?.function
227
+ if (typeof fn !== "string" || fn.length === 0) continue
228
+ entries[field] = {
229
+ function: fn,
230
+ timeout: typeof value.timeout === "number" ? value.timeout : DEFAULT_HOOK_TIMEOUT_MS,
231
+ onUnavailable: "reject",
232
+ }
233
+ }
234
+ if (Object.keys(entries).length > 0) out[table] = entries
235
+ }
236
+ return out
237
+ }
238
+
239
+ /**
240
+ * Validators naming a function that does not exist, as lines for a push failure.
241
+ *
242
+ * Shares `availableFunctions` with the hook check, so "what counts as a function" cannot come to
243
+ * mean two things.
244
+ */
245
+ export function validateModelValidators(
246
+ ast: unknown,
247
+ functionsDir: string,
248
+ cwd: string,
249
+ ): string[] {
250
+ const validators = declaredValidators(ast)
251
+ if (validators.length === 0) return []
252
+
253
+ const available = availableFunctions(functionsDir)
254
+ const known = new Set(available)
255
+ const missing = validators.filter((entry) => !known.has(entry.function))
256
+ if (missing.length === 0) return []
257
+
258
+ const where = relative(cwd, functionsDir) || functionsDir
259
+ const lines = missing.map(
260
+ (entry) =>
261
+ ` ${entry.model}.${entry.field} → "${entry.function}" (no ${where}/${entry.function}/index.ts)`,
262
+ )
263
+ lines.push("")
264
+ lines.push(
265
+ available.length > 0
266
+ ? `Functions found in ${where}: ${available.join(", ")}`
267
+ : `No functions found in ${where}. Create one with: supatype hooks new <name>`,
268
+ )
269
+ return lines
270
+ }
271
+
167
272
  /** Well below the 10s edge-function ceiling, so a hung hook fails fast instead of holding a slot. */
168
273
  export const DEFAULT_HOOK_TIMEOUT_MS = 2000
169
274
 
170
275
  /**
171
- * Merge the hook map into an existing `.supatype/manifest.json`.
276
+ * Merge the hook and validator maps into an existing `.supatype/manifest.json`.
277
+ *
278
+ * Both keys are written here rather than in two functions, because they fail together and for the
279
+ * same reason: each is a map the server reads to decide what to call around a write, and a manifest
280
+ * carrying a stale one calls the wrong thing or nothing at all. A validator that is never called is
281
+ * the worse half of that: the schema says the field is checked, and no error appears anywhere,
282
+ * because the write simply succeeds.
172
283
  *
173
284
  * **Only updates a manifest that is already there.** Creating one from scratch here would be a
174
285
  * hazard: `functions_enabled` is a plain bool on the server's side, so a manifest carrying only
175
286
  * 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.
287
+ * by a different door. The compose path owns creation; this owns two keys.
177
288
  *
178
289
  * Returns true when the file was rewritten.
179
290
  */
@@ -189,17 +300,34 @@ export function syncManifestHooks(cwd: string, ast: unknown): boolean {
189
300
  }
190
301
  if (typeof parsed !== "object" || parsed === null) return false
191
302
 
192
- const hooks = manifestHooks(ast)
193
- const next = JSON.stringify(hooks)
194
- const current = JSON.stringify(parsed["hooks"] ?? {})
195
- if (next === current) return false
303
+ const changedHooks = applyManifestMap(parsed, "hooks", manifestHooks(ast))
304
+ const changedValidators = applyManifestMap(parsed, "validators", manifestValidators(ast))
305
+ if (!changedHooks && !changedValidators) return false
306
+
307
+ writeFileSync(manifestPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8")
308
+ return true
309
+ }
310
+
311
+ /**
312
+ * Set one manifest key to `map`, or remove it when the schema declares none.
313
+ *
314
+ * Removing rather than writing `{}` matters: the server distinguishes "no map" from "an empty map"
315
+ * when it decides whether the manifest predates the feature, and an empty object left behind by a
316
+ * schema that no longer declares any is not the same statement.
317
+ */
318
+ function applyManifestMap(
319
+ manifest: Record<string, unknown>,
320
+ key: string,
321
+ map: Record<string, Record<string, ManifestHookEntry>>,
322
+ ): boolean {
323
+ const next = JSON.stringify(map)
324
+ if (next === JSON.stringify(manifest[key] ?? {})) return false
196
325
 
197
- if (Object.keys(hooks).length === 0) {
198
- delete parsed["hooks"]
326
+ if (Object.keys(map).length === 0) {
327
+ delete manifest[key]
199
328
  } else {
200
- parsed["hooks"] = hooks
329
+ manifest[key] = map
201
330
  }
202
- writeFileSync(manifestPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8")
203
331
  return true
204
332
  }
205
333
 
@@ -211,6 +339,18 @@ export interface HooksReport {
211
339
  functionsDisabled: boolean
212
340
  /** True when a manifest exists but carries no hook map, so the server has nothing to call. */
213
341
  mapMissing: boolean
342
+ /** Field validators declared across the schema. */
343
+ validators: DeclaredValidator[]
344
+ /**
345
+ * Validators whose function directory is missing.
346
+ *
347
+ * Reported apart from `missing` because the consequence is different and worth saying plainly: a
348
+ * missing hook is a lifecycle step that will not run, a missing validator is a field written
349
+ * unchecked.
350
+ */
351
+ validatorsMissing: DeclaredValidator[]
352
+ /** True when validators are declared and the manifest carries no validator map. */
353
+ validatorMapMissing: boolean
214
354
  }
215
355
 
216
356
  /**
@@ -225,15 +365,18 @@ export interface HooksReport {
225
365
  */
226
366
  export function hooksReport(cwd: string, functionsDir: string, ast: unknown): HooksReport {
227
367
  const declared = declaredHooks(ast)
368
+ const validators = declaredValidators(ast)
228
369
  const manifestPath = join(cwd, ".supatype", "manifest.json")
229
370
 
230
371
  let functionsDisabled = false
231
372
  let mapMissing = false
232
- if (declared.length > 0 && existsSync(manifestPath)) {
373
+ let validatorMapMissing = false
374
+ if ((declared.length > 0 || validators.length > 0) && existsSync(manifestPath)) {
233
375
  try {
234
376
  const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>
235
377
  functionsDisabled = parsed["functions_enabled"] === false
236
- mapMissing = parsed["hooks"] === undefined
378
+ mapMissing = declared.length > 0 && parsed["hooks"] === undefined
379
+ validatorMapMissing = validators.length > 0 && parsed["validators"] === undefined
237
380
  } catch {
238
381
  // Unparseable: the server reports that far better than a doctor line could.
239
382
  }
@@ -256,5 +399,8 @@ export function hooksReport(cwd: string, functionsDir: string, ast: unknown): Ho
256
399
  missing: declared.filter((hook) => !known.has(hook.function)),
257
400
  functionsDisabled,
258
401
  mapMissing,
402
+ validators,
403
+ validatorsMissing: validators.filter((entry) => !known.has(entry.function)),
404
+ validatorMapMissing,
259
405
  }
260
406
  }
@@ -5,7 +5,7 @@ export function pgbouncerMd5Hash(password: string, username: string): string {
5
5
  return "md5" + createHash("md5").update(password + username).digest("hex")
6
6
  }
7
7
 
8
- /** Two roles used by local compose: superuser pools (GoTrue, engine) and PostgREST. */
8
+ /** Two roles used by local compose: superuser pools (auth, engine) and PostgREST. */
9
9
  export function pgbouncerUserlistContent(pgPassword: string): string {
10
10
  return `# PgBouncer md5 userlist, matches supatype-cloud transaction pool (auth_type = md5).
11
11
  # Regenerated by supatype dev --local / init from POSTGRES_PASSWORD.
@@ -168,23 +168,63 @@ export async function waitReady(opts: PgOptions, timeoutMs = 10_000): Promise<vo
168
168
  // ---------------------------------------------------------------------------
169
169
 
170
170
  /**
171
- * Returns true if a TCP listener is already bound to port on 127.0.0.1.
171
+ * Can this port be bound, on the addresses that matter?
172
+ *
173
+ * - `0.0.0.0` is what Docker publishes on and what compose has to bind.
174
+ * - `127.0.0.1` catches a listener bound to loopback only, which a wildcard bind can be allowed
175
+ * to sit alongside on some platforms.
172
176
  */
173
- export async function isPortInUse(port: number): Promise<boolean> {
177
+ async function bindFails(port: number, host: string): Promise<boolean> {
174
178
  const { createServer } = await import("node:net")
175
179
  return new Promise((resolve) => {
176
180
  const server = createServer()
177
181
  server.once("error", (err: NodeJS.ErrnoException) => {
178
- // EADDRINUSE: something is listening. EACCES, Windows excluded/reserved port range.
182
+ // EADDRINUSE: something is listening. EACCES: Windows excluded/reserved port range.
179
183
  resolve(err.code === "EADDRINUSE" || err.code === "EACCES")
180
184
  })
181
185
  server.once("listening", () => {
182
186
  server.close(() => resolve(false))
183
187
  })
184
- server.listen(port, "127.0.0.1")
188
+ server.listen(port, host)
185
189
  })
186
190
  }
187
191
 
192
+ /**
193
+ * Is something answering on this port?
194
+ *
195
+ * Binding is not enough on its own. Docker Desktop publishes through a proxy that does not hold a
196
+ * bind a normal socket can see, so `bindFails` succeeds while nginx on that port answers HTTP 200.
197
+ * Measured on Windows: bind says free, curl says 200. `supatype dev` believed the bind, handed the
198
+ * port to compose, and compose failed with "Bind for 0.0.0.0:5432 failed: port is already
199
+ * allocated". Connecting is what notices.
200
+ */
201
+ async function connectSucceeds(port: number, timeoutMs = 400): Promise<boolean> {
202
+ const { connect } = await import("node:net")
203
+ return new Promise((resolve) => {
204
+ const socket = connect({ port, host: "127.0.0.1" })
205
+ const done = (answer: boolean): void => {
206
+ socket.destroy()
207
+ resolve(answer)
208
+ }
209
+ socket.setTimeout(timeoutMs)
210
+ socket.once("connect", () => done(true))
211
+ socket.once("timeout", () => done(false))
212
+ socket.once("error", () => done(false))
213
+ })
214
+ }
215
+
216
+ /**
217
+ * True when the port cannot be used for a new listener.
218
+ *
219
+ * Either signal is enough. Reporting a port busy when it is usable costs an increment in
220
+ * `findNextFreePort`; reporting it free when it is not costs a failed `docker compose up`, which is
221
+ * the failure this exists to prevent.
222
+ */
223
+ export async function isPortInUse(port: number): Promise<boolean> {
224
+ if (await connectSucceeds(port)) return true
225
+ return (await bindFails(port, "0.0.0.0")) || (await bindFails(port, "127.0.0.1"))
226
+ }
227
+
188
228
  // ---------------------------------------------------------------------------
189
229
  // Helpers
190
230
  // ---------------------------------------------------------------------------
@@ -157,13 +157,13 @@ export interface SupatypeProjectConfig {
157
157
  /**
158
158
  * Email delivery provider.
159
159
  * "console" = log to stdout (default for dev)
160
- * "smtp" = SMTP (set `smtp` below and/or GOTRUE_SMTP_* in `.env`)
160
+ * "smtp" = SMTP (set `smtp` below and/or SUPATYPE_SMTP_* in `.env`)
161
161
  * "resend" = Resend API (requires RESEND_API_KEY, RESEND_FROM)
162
162
  * "ses" = AWS SES v2 (ambient credentials, requires SES_FROM)
163
163
  */
164
164
  provider: "console" | "smtp" | "resend" | "ses"
165
165
  /**
166
- * SMTP settings for provider=smtp (merged into process env as GOTRUE_SMTP_*).
166
+ * SMTP settings for provider=smtp (merged into process env as SUPATYPE_SMTP_*).
167
167
  * Omitted keys can still be set via `.env` / shell.
168
168
  */
169
169
  smtp?: {
@@ -181,9 +181,9 @@ export interface SupatypeProjectConfig {
181
181
  /** From address for SES (provider=ses, or set SES_FROM env var). */
182
182
  ses_from?: string
183
183
  /**
184
- * When true, `supatype dev` enables the GoTrue send-email HTTP hook pointing at this
184
+ * When true, `supatype dev` enables the send-email HTTP hook pointing at this
185
185
  * server's POST `/internal/v0hooks/send-email` (signed delivery, dev-only secret).
186
- * Override `GOTRUE_HOOK_SEND_EMAIL_*` in `.env` if needed.
186
+ * Override `SUPATYPE_HOOK_SEND_EMAIL_*` in `.env` if needed.
187
187
  */
188
188
  send_email_hook?: boolean
189
189
  /**
@@ -44,7 +44,7 @@ export interface DeployTarget {
44
44
  apiBaseUrl: string
45
45
  apiPrefix: "/api/v1" | "/platform/v1"
46
46
  token?: string | undefined
47
- /** Cloud GoTrue refresh token (mode === "cloud" only). */
47
+ /** Cloud auth refresh token (mode === "cloud" only). */
48
48
  refreshToken?: string | undefined
49
49
  orgId?: string | undefined
50
50
  link: ProjectLink | null
@@ -3,8 +3,80 @@
3
3
  * Parsers build {@link ParsedField}; only `emitField` / `emitModel` / `emitSchema` produce JSON.
4
4
  */
5
5
 
6
+ import type { FieldValidation } from "@supatype/types"
7
+
8
+ // Re-exported so consumers of these AST types need no second import for the one field that is
9
+ // declared elsewhere: the bound contract lives beside the modifiers that compile into it.
10
+ export type { FieldValidation }
11
+
6
12
  export const AST_VERSION = 2 as const
7
13
 
14
+ /**
15
+ * Every field kind the extractor can produce. **The registry, not a list.**
16
+ *
17
+ * A kind used to exist the moment someone wrote `scalar("newThing")` in the extractor's type switch,
18
+ * with nothing anywhere enumerating the set. That is how nine kinds came to accept a declared bound
19
+ * and silently enforce nothing: no code and no test could ask "what are all the kinds", so each was
20
+ * handled wherever someone happened to look.
21
+ *
22
+ * Naming a kind here is now the only way to create one, because {@link scalar} takes a `FieldKind`.
23
+ * Anything keyed by `Record<FieldKind, T>` is then exhaustive by the compiler rather than by
24
+ * somebody remembering: `BOUNDS_BY_KIND` in `field-bounds.ts` is the first such consumer, so adding
25
+ * a kind here fails the build until it is classified there.
26
+ */
27
+ export const FIELD_KINDS = [
28
+ // Text-shaped
29
+ "text",
30
+ "email",
31
+ "url",
32
+ "slug",
33
+ "color",
34
+ "xml",
35
+ "ip",
36
+ "cidr",
37
+ "macaddr",
38
+ "tsQuery",
39
+ "tsVector",
40
+ "richText",
41
+ "bytes",
42
+ // Numeric
43
+ "integer",
44
+ "smallInt",
45
+ "bigInt",
46
+ "float",
47
+ "serial",
48
+ "bigSerial",
49
+ "decimal",
50
+ "money",
51
+ // Temporal
52
+ "datetime",
53
+ "timestamp",
54
+ "date",
55
+ "interval",
56
+ // Collections and structured values
57
+ "array",
58
+ "blocks",
59
+ "json",
60
+ "button",
61
+ "enum",
62
+ // Fixed-shape scalars
63
+ "boolean",
64
+ "uuid",
65
+ // Storage, spatial, plugin
66
+ "image",
67
+ "file",
68
+ "geo",
69
+ "vector",
70
+ "relation",
71
+ "custom",
72
+ // Composites, expanded into real columns before they reach a table
73
+ "timestamps",
74
+ "publishable",
75
+ "softDelete",
76
+ ] as const
77
+
78
+ export type FieldKind = (typeof FIELD_KINDS)[number]
79
+
8
80
  export type DefaultAst =
9
81
  | { kind: "value"; value: string | number | boolean | null }
10
82
  | { kind: "now" }
@@ -49,6 +121,15 @@ export interface KernelFieldFacts {
49
121
  dimensions?: number
50
122
  blocks?: BlockDefinitionAst[]
51
123
  check?: string
124
+ validation?: FieldValidation
125
+ /**
126
+ * `JSON<T[]>` rather than `JSON<{...}>`, read from the declared type argument.
127
+ *
128
+ * Persisted because a model constraint naming this column with `ItemCount<>` has to resolve the
129
+ * same measure the field's own `MaxItems` would, and by then the type node is long gone. The
130
+ * engine never reads it: it receives the measure already resolved.
131
+ */
132
+ jsonArray?: boolean
52
133
  precision?: number
53
134
  scale?: number
54
135
  references?: string
@@ -64,7 +145,7 @@ export interface KernelFieldFacts {
64
145
 
65
146
  /** Internal parse result: not serialized. */
66
147
  export interface ParsedField {
67
- kind: string
148
+ kind: FieldKind
68
149
  kernel: KernelFieldFacts
69
150
  db: DbFieldAnnotations
70
151
  platform: PlatformFieldAnnotations
@@ -87,8 +168,12 @@ export interface ModelAstV2 {
87
168
  fields: Record<string, FieldAstV2>
88
169
  options: Record<string, unknown>
89
170
  annotations: {
90
- db: { tableName: string; indexes: unknown[] }
91
- platform: { access: Record<string, unknown>; hooks?: Record<string, unknown> }
171
+ db: { tableName: string; indexes: unknown[]; constraints?: unknown[] }
172
+ platform: {
173
+ access: Record<string, unknown>
174
+ hooks?: Record<string, unknown>
175
+ validate?: Record<string, unknown>
176
+ }
92
177
  }
93
178
  }
94
179
 
@@ -110,7 +195,7 @@ export interface ExtractedSchemaAstV2 {
110
195
  defaultLocale?: string
111
196
  }
112
197
 
113
- const DEFAULT_DB_BY_KIND: Record<string, Partial<DbFieldAnnotations>> = {
198
+ const DEFAULT_DB_BY_KIND: Partial<Record<FieldKind, Partial<DbFieldAnnotations>>> = {
114
199
  text: { pgType: "TEXT" },
115
200
  richText: { pgType: "JSONB" },
116
201
  integer: { pgType: "INTEGER" },
@@ -147,7 +232,7 @@ const DEFAULT_DB_BY_KIND: Record<string, Partial<DbFieldAnnotations>> = {
147
232
  blocks: { pgType: "JSONB" },
148
233
  }
149
234
 
150
- const DEFAULT_PLATFORM_BY_KIND: Record<string, Partial<PlatformFieldAnnotations>> = {
235
+ const DEFAULT_PLATFORM_BY_KIND: Partial<Record<FieldKind, Partial<PlatformFieldAnnotations>>> = {
151
236
  richText: { editor: "rich" },
152
237
  }
153
238
 
@@ -165,9 +250,15 @@ function stripUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
165
250
  return out as Partial<T>
166
251
  }
167
252
 
168
- /** Start a parsed field with kind defaults for db/platform namespaces. */
253
+ /**
254
+ * Start a parsed field with kind defaults for db/platform namespaces.
255
+ *
256
+ * Takes a plain `string` rather than a `FieldKind` because it is also called with a kind read back
257
+ * off the wire, where the value came from JSON and carries no compile-time guarantee. The optional
258
+ * lookup plus the fallback is what makes an unrecognised one safe.
259
+ */
169
260
  export function defaultPgTypeForKind(kind: string): string {
170
- return DEFAULT_DB_BY_KIND[kind]?.pgType ?? "TEXT"
261
+ return DEFAULT_DB_BY_KIND[kind as FieldKind]?.pgType ?? "TEXT"
171
262
  }
172
263
 
173
264
  /** Flat wire shape for fields nested inside `blocks` definitions (engine FieldAst serde). */
@@ -195,6 +286,7 @@ export function emitBlockNestedField(field: FieldAstV2): FieldAstV2 {
195
286
  if (field.srid !== undefined) wire.srid = field.srid
196
287
  if (field.dimensions !== undefined) wire.dimensions = field.dimensions
197
288
  if (field.check !== undefined) wire.check = field.check
289
+ if (field.validation !== undefined) wire.validation = field.validation
198
290
  if (field.precision !== undefined) wire.precision = field.precision
199
291
  if (field.scale !== undefined) wire.scale = field.scale
200
292
  if (field.sources !== undefined) wire.sources = field.sources
@@ -207,7 +299,7 @@ export function emitBlockNestedField(field: FieldAstV2): FieldAstV2 {
207
299
  }
208
300
 
209
301
  export function scalar(
210
- kind: string,
302
+ kind: FieldKind,
211
303
  extra?: {
212
304
  kernel?: Partial<KernelFieldFacts>
213
305
  db?: Partial<DbFieldAnnotations>
@@ -267,6 +359,8 @@ export function emitField(parsed: ParsedField): FieldAstV2 {
267
359
  }))
268
360
  }
269
361
  if (kernel.check !== undefined) wire.check = kernel.check
362
+ if (kernel.validation !== undefined) wire.validation = kernel.validation
363
+ if (kernel.jsonArray === true) wire.jsonArray = true
270
364
  if (kernel.precision !== undefined) wire.precision = kernel.precision
271
365
  if (kernel.scale !== undefined) wire.scale = kernel.scale
272
366
  if (kernel.references !== undefined) wire.references = kernel.references
@@ -295,16 +389,26 @@ export function emitModel(
295
389
  access: Record<string, unknown>,
296
390
  indexes: unknown[] = [],
297
391
  hooks: Record<string, unknown> = {},
392
+ constraints: unknown[] = [],
393
+ validators: Record<string, unknown> = {},
298
394
  ): ModelAstV2 {
299
395
  return {
300
396
  name,
301
397
  fields,
302
398
  options,
303
399
  annotations: {
304
- db: { tableName, indexes },
400
+ // Constraints sit in `db` beside `indexes`: both are things Postgres holds, unlike `access`
401
+ // and `hooks`, which the API layer enforces.
402
+ db: { tableName, indexes, ...(constraints.length > 0 && { constraints }) },
305
403
  // Hooks sit in `platform` beside `access`: they are an API-layer concern, not a column one,
306
404
  // supatype-server reads them, Postgres never sees them.
307
- platform: { access, ...(Object.keys(hooks).length > 0 && { hooks }) },
405
+ // Validators sit in `platform` beside `hooks`: both are enforced by the API layer on the
406
+ // write path, and neither is something Postgres knows about.
407
+ platform: {
408
+ access,
409
+ ...(Object.keys(hooks).length > 0 && { hooks }),
410
+ ...(Object.keys(validators).length > 0 && { validate: validators }),
411
+ },
308
412
  },
309
413
  }
310
414
  }
@@ -297,11 +297,11 @@ export function renderSelfHostCompose(
297
297
  const kongMount = kongMountPath(cwd)
298
298
  const external = usesExternalDatabase(config)
299
299
  const ownerUrl = ownerDatabaseUrl(config)
300
- // GoTrue's driver wants the `postgres://` spelling; an external URL is used as given.
301
- const gotrueUrl = external ? ownerUrl : ownerDatabaseUrl(config, "postgres")
300
+ // the auth driver wants the `postgres://` spelling; an external URL is used as given.
301
+ const authUrl = external ? ownerUrl : ownerDatabaseUrl(config, "postgres")
302
302
  // An external URL may already carry query parameters (`?sslmode=require` is common on managed
303
303
  // providers), and appending a second `?` produces a DSN that fails to parse.
304
- const gotrueSearchPathSeparator = externalDatabaseUrl(config)?.includes("?") ? "&" : "?"
304
+ const authSearchPathSeparator = externalDatabaseUrl(config)?.includes("?") ? "&" : "?"
305
305
  const postgrestUrl = postgrestDatabaseUrl(config)
306
306
  const devLocal = options?.devLocal === true
307
307
  const tlsEnabled = selfHostTlsEnabled(config, devLocal)
@@ -546,6 +546,14 @@ ${realtimeBlock}
546
546
  ${dbDependency}
547
547
  server:
548
548
  image: \${SUPATYPE_SERVER_IMAGE:-\${SUPATYPE_AUTH_IMAGE:-supatype/server:latest}}
549
+ # The server runs its migrations at boot on a connection of their own,
550
+ # and that path does not wait out a database that is still in recovery:
551
+ # it exits. Waiting for db to report healthy is not enough, because
552
+ # Postgres says healthy before it will accept these connections, and
553
+ # on a slow host the gap is wide enough to lose it for good. Bounded
554
+ # rather than unlimited, so a real misconfiguration still stops and
555
+ # stays visible instead of hiding in a crash loop.
556
+ restart: on-failure:5
549
557
  ${serverPorts} volumes:
550
558
  - ${projectMount}:/project:ro
551
559
  working_dir: /project
@@ -567,20 +575,35 @@ ${realtimeServerEnv}
567
575
  SUPATYPE_CONTROL_PLANE_URL: http://control-plane:8080
568
576
  SUPATYPE_VALKEY_ADDR: valkey:6379
569
577
  ${appEnv}
570
- GOTRUE_API_HOST: 0.0.0.0
571
- GOTRUE_API_PORT: 9999
572
- API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
573
- GOTRUE_API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
574
- GOTRUE_DB_DRIVER: postgres
575
- GOTRUE_DB_DATABASE_URL: "${gotrueUrl}${gotrueSearchPathSeparator}search_path=auth"
576
- GOTRUE_SITE_URL: \${SITE_URL:-${siteUrlFallback}}
577
- GOTRUE_JWT_SECRET: \${JWT_SECRET:?JWT_SECRET is missing from .env}
578
- GOTRUE_JWT_EXP: 3600
579
- GOTRUE_JWT_AUD: authenticated
580
- GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
581
- GOTRUE_JWT_ADMIN_ROLES: service_role,supatype_admin
582
- GOTRUE_MAILER_AUTOCONFIRM: \${GOTRUE_MAILER_AUTOCONFIRM:-true}
583
- GOTRUE_DISABLE_SIGNUP: \${DISABLE_SIGNUP:-false}
578
+ SUPATYPE_API_HOST: 0.0.0.0
579
+ SUPATYPE_API_PORT: 9999
580
+ SUPATYPE_API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
581
+ SUPATYPE_DB_DRIVER: postgres
582
+ SUPATYPE_DB_DATABASE_URL: "${authUrl}${authSearchPathSeparator}search_path=auth"
583
+ SUPATYPE_SITE_URL: \${SITE_URL:-${siteUrlFallback}}
584
+ SUPATYPE_JWT_SECRET: \${JWT_SECRET:?JWT_SECRET is missing from .env}
585
+ SUPATYPE_JWT_EXP: 3600
586
+ SUPATYPE_JWT_AUD: authenticated
587
+ SUPATYPE_JWT_DEFAULT_GROUP_NAME: authenticated
588
+ SUPATYPE_JWT_ADMIN_ROLES: service_role,supatype_admin
589
+ SUPATYPE_MAILER_AUTOCONFIRM: \${SUPATYPE_MAILER_AUTOCONFIRM:-true}
590
+ # email.provider and email.smtp are config, and nothing used to carry
591
+ # them here: with no provider and no SMTP host the auth service falls
592
+ # through to its noop client, so every message was dropped in silence and
593
+ # a project asking for smtp got the same nothing as one asking for console.
594
+ # The name really is MAILER_MAILER: the field is Mailer.MailerProvider.
595
+ SUPATYPE_MAILER_MAILER_PROVIDER: \${SUPATYPE_MAILER_MAILER_PROVIDER:-console}
596
+ SUPATYPE_SMTP_HOST: \${SUPATYPE_SMTP_HOST:-}
597
+ # 587, not empty: this one is an int on the server, and compose
598
+ # substitutes an unset variable as "", which fails to parse and takes
599
+ # the whole service down on boot. 587 is the server's own default, so
600
+ # leaving it unset now behaves exactly as it would with no value at all.
601
+ SUPATYPE_SMTP_PORT: \${SUPATYPE_SMTP_PORT:-587}
602
+ SUPATYPE_SMTP_USER: \${SUPATYPE_SMTP_USER:-}
603
+ SUPATYPE_SMTP_PASS: \${SUPATYPE_SMTP_PASS:-}
604
+ SUPATYPE_SMTP_ADMIN_EMAIL: \${SUPATYPE_SMTP_ADMIN_EMAIL:-}
605
+ SUPATYPE_SMTP_SENDER_NAME: \${SUPATYPE_SMTP_SENDER_NAME:-}
606
+ SUPATYPE_DISABLE_SIGNUP: \${DISABLE_SIGNUP:-false}
584
607
  ${devLocal ? " STUDIO_OPEN_DEV: \"1\"\n" : ""}
585
608
  depends_on:
586
609
  ${dbDependencyClause} valkey:
@@ -697,7 +720,7 @@ function ensureProjectFunctionsDir(cwd: string, config: SupatypeProjectConfig):
697
720
  *
698
721
  * The CLI resolves the URL from config; Compose substitutes `.env` at up-time. If the two disagree,
699
722
  * `push` migrates one database while the services serve another, which reads as data loss and
700
- * isn't. It also makes the generated GoTrue DSN wrong, since whether to append `search_path` with
723
+ * isn't. It also makes the generated auth DSN wrong, since whether to append `search_path` with
701
724
  * `?` or `&` is decided from the config URL's query string.
702
725
  */
703
726
  function assertExternalUrlMatchesEnv(cwd: string, config: SupatypeProjectConfig): void {
@@ -9,7 +9,14 @@ export interface StudioDevServerOptions {
9
9
  cwd: string
10
10
  studioOverride: string
11
11
  pidDir: string
12
- serviceRoleKey: string
12
+ /**
13
+ * The anon key, and only the anon key. Studio in the browser is an untrusted
14
+ * client: privileged calls go through /studio/proxy, which holds the service
15
+ * role key server-side and applies membership, role permissions and the audit
16
+ * trail. Studio refuses a service role key handed to the browser and says so
17
+ * loudly, which is what this used to trigger on every dev start.
18
+ */
19
+ anonKey: string
13
20
  /**
14
21
  * Where Vite proxies API requests (Kong gateway port for compose dev, or
15
22
  * supatype-server port for native `supatype dev`).
@@ -45,8 +52,7 @@ export function startStudioViteDevServer(opts: StudioDevServerOptions): ProcessM
45
52
  env: {
46
53
  VITE_SUPATYPE_URL: opts.viteSupatypeUrl,
47
54
  SUPATYPE_PROXY_TARGET: opts.proxyTarget,
48
- VITE_SUPATYPE_ANON_KEY: opts.serviceRoleKey,
49
- VITE_SUPATYPE_SERVICE_ROLE_KEY: opts.serviceRoleKey,
55
+ VITE_SUPATYPE_ANON_KEY: opts.anonKey,
50
56
  VITE_BASE_PATH: basePath,
51
57
  },
52
58
  },