@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +150 -136
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/cli-version-embedded.d.ts.map +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/cli-version-embedded.js.map +1 -1
- package/dist/commands/admin.d.ts +5 -5
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +10 -10
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +23 -1
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/dev.js +51 -51
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +0 -7
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +26 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/logs.d.ts +14 -1
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +33 -37
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +18 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/status.d.ts +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +33 -64
- package/dist/commands/status.js.map +1 -1
- package/dist/compose-local-server-image.d.ts +11 -0
- package/dist/compose-local-server-image.d.ts.map +1 -1
- package/dist/compose-local-server-image.js +18 -0
- package/dist/compose-local-server-image.js.map +1 -1
- package/dist/compose-services.d.ts +59 -0
- package/dist/compose-services.d.ts.map +1 -0
- package/dist/compose-services.js +160 -0
- package/dist/compose-services.js.map +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/dev-compose.d.ts +1 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +202 -30
- package/dist/dev-compose.js.map +1 -1
- package/dist/dev-ports.d.ts +24 -0
- package/dist/dev-ports.d.ts.map +1 -1
- package/dist/dev-ports.js +37 -0
- package/dist/dev-ports.js.map +1 -1
- package/dist/docker-postgres.d.ts +1 -1
- package/dist/docker-postgres.d.ts.map +1 -1
- package/dist/docker-postgres.js +2 -2
- package/dist/docker-postgres.js.map +1 -1
- package/dist/engine-floor.d.ts +50 -0
- package/dist/engine-floor.d.ts.map +1 -0
- package/dist/engine-floor.js +80 -0
- package/dist/engine-floor.js.map +1 -0
- package/dist/field-bounds.d.ts +68 -0
- package/dist/field-bounds.d.ts.map +1 -0
- package/dist/field-bounds.js +277 -0
- package/dist/field-bounds.js.map +1 -0
- package/dist/hooks-generator.d.ts +1 -1
- package/dist/hooks-generator.d.ts.map +1 -1
- package/dist/hooks-generator.js +78 -4
- package/dist/hooks-generator.js.map +1 -1
- package/dist/link.d.ts +1 -1
- package/dist/link.d.ts.map +1 -1
- package/dist/model-hooks.d.ts +44 -2
- package/dist/model-hooks.d.ts.map +1 -1
- package/dist/model-hooks.js +116 -12
- package/dist/model-hooks.js.map +1 -1
- package/dist/pgbouncer-userlist.d.ts +1 -1
- package/dist/pgbouncer-userlist.d.ts.map +1 -1
- package/dist/pgbouncer-userlist.js +1 -1
- package/dist/pgbouncer-userlist.js.map +1 -1
- package/dist/postgres-ctl.d.ts +5 -1
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +43 -4
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/project-config.d.ts +4 -4
- package/dist/resolve-target.d.ts +1 -1
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/schema-ast-v2.d.ts +38 -4
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +87 -4
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +41 -18
- package/dist/self-host-compose.js.map +1 -1
- package/dist/studio-dev-server.d.ts +8 -1
- package/dist/studio-dev-server.d.ts.map +1 -1
- package/dist/studio-dev-server.js +1 -2
- package/dist/studio-dev-server.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +309 -27
- package/dist/type-extractor.js.map +1 -1
- package/package.json +4 -3
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/admin.ts +10 -10
- package/src/commands/db.ts +27 -1
- package/src/commands/dev.ts +51 -51
- package/src/commands/doctor.ts +30 -0
- package/src/commands/init.ts +1 -1
- package/src/commands/logs.ts +34 -37
- package/src/commands/push.ts +29 -6
- package/src/commands/status.ts +34 -70
- package/src/compose-local-server-image.ts +17 -0
- package/src/compose-services.ts +174 -0
- package/src/config.ts +1 -1
- package/src/dev-compose.ts +226 -29
- package/src/dev-ports.ts +48 -0
- package/src/docker-postgres.ts +2 -2
- package/src/engine-floor.ts +113 -0
- package/src/field-bounds.ts +359 -0
- package/src/hooks-generator.ts +81 -4
- package/src/link.ts +1 -1
- package/src/model-hooks.ts +158 -12
- package/src/pgbouncer-userlist.ts +1 -1
- package/src/postgres-ctl.ts +44 -4
- package/src/project-config.ts +4 -4
- package/src/resolve-target.ts +1 -1
- package/src/schema-ast-v2.ts +114 -10
- package/src/self-host-compose.ts +41 -18
- package/src/studio-dev-server.ts +9 -3
- package/src/type-extractor.ts +374 -39
- package/tests/admin-ensure.test.ts +10 -10
- package/tests/compose-parsable-defaults.test.ts +60 -0
- package/tests/compose-services.test.ts +144 -0
- package/tests/dev-ports-owner.test.ts +40 -0
- package/tests/engine-floor.test.ts +102 -0
- package/tests/external-database-compose.test.ts +3 -3
- package/tests/field-bounds-matrix.test.ts +163 -0
- package/tests/field-bounds.test.ts +139 -0
- package/tests/field-validators.test.ts +139 -0
- package/tests/hooks-generator.test.ts +86 -0
- package/tests/local-server-image-env.test.ts +93 -0
- package/tests/model-constraints.test.ts +293 -0
- package/tests/model-hooks.test.ts +56 -0
- package/tests/port-in-use.test.ts +58 -0
- package/tests/readiness-poll.test.ts +84 -0
- package/tests/runtime-contract.test.ts +1 -1
- package/tests/type-extractor.test.ts +49 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/model-hooks.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
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(
|
|
198
|
-
delete
|
|
326
|
+
if (Object.keys(map).length === 0) {
|
|
327
|
+
delete manifest[key]
|
|
199
328
|
} else {
|
|
200
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
package/src/postgres-ctl.ts
CHANGED
|
@@ -168,23 +168,63 @@ export async function waitReady(opts: PgOptions, timeoutMs = 10_000): Promise<vo
|
|
|
168
168
|
// ---------------------------------------------------------------------------
|
|
169
169
|
|
|
170
170
|
/**
|
|
171
|
-
*
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/project-config.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 `
|
|
186
|
+
* Override `SUPATYPE_HOOK_SEND_EMAIL_*` in `.env` if needed.
|
|
187
187
|
*/
|
|
188
188
|
send_email_hook?: boolean
|
|
189
189
|
/**
|
package/src/resolve-target.ts
CHANGED
|
@@ -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
|
|
47
|
+
/** Cloud auth refresh token (mode === "cloud" only). */
|
|
48
48
|
refreshToken?: string | undefined
|
|
49
49
|
orgId?: string | undefined
|
|
50
50
|
link: ProjectLink | null
|
package/src/schema-ast-v2.ts
CHANGED
|
@@ -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:
|
|
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: {
|
|
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<
|
|
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<
|
|
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
|
-
/**
|
|
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:
|
|
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
|
|
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
|
|
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
|
}
|
package/src/self-host-compose.ts
CHANGED
|
@@ -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
|
-
//
|
|
301
|
-
const
|
|
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
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
|
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 {
|
package/src/studio-dev-server.ts
CHANGED
|
@@ -9,7 +9,14 @@ export interface StudioDevServerOptions {
|
|
|
9
9
|
cwd: string
|
|
10
10
|
studioOverride: string
|
|
11
11
|
pidDir: string
|
|
12
|
-
|
|
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.
|
|
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
|
},
|