@supatype/cli 0.1.11 → 0.1.13
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 +136 -130
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/cli-version-embedded.js +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/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/push.d.ts.map +1 -1
- package/dist/commands/push.js +15 -7
- package/dist/commands/push.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/db-preflight.d.ts +23 -0
- package/dist/db-preflight.d.ts.map +1 -1
- package/dist/db-preflight.js +66 -1
- package/dist/db-preflight.js.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 +79 -10
- package/dist/dev-compose.js.map +1 -1
- 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/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/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/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/db.ts +27 -1
- package/src/commands/doctor.ts +30 -0
- package/src/commands/push.ts +26 -6
- package/src/compose-local-server-image.ts +17 -0
- package/src/db-preflight.ts +89 -1
- package/src/dev-compose.ts +96 -9
- package/src/field-bounds.ts +359 -0
- package/src/hooks-generator.ts +81 -4
- package/src/model-hooks.ts +158 -12
- package/src/schema-ast-v2.ts +114 -10
- package/src/type-extractor.ts +374 -39
- package/tests/db-preflight.test.ts +80 -0
- 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/type-extractor.test.ts +49 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/db-preflight.ts
CHANGED
|
@@ -113,6 +113,16 @@ async function probeLogicalDecoding(q: QueryFn): Promise<CheckResult> {
|
|
|
113
113
|
|
|
114
114
|
const REQUIRED_ROLES = ["anon", "authenticated", "service_role", "authenticator"] as const
|
|
115
115
|
|
|
116
|
+
/** The three roles PostgREST switches to per request. `authenticator` only connects and switches. */
|
|
117
|
+
const API_ROLES = ["anon", "authenticated", "service_role"] as const
|
|
118
|
+
|
|
119
|
+
/** One row per API role that exists, for the schema named in the query. */
|
|
120
|
+
export interface AuthSchemaUsageRow {
|
|
121
|
+
rolname: string | null
|
|
122
|
+
has_usage: boolean | null
|
|
123
|
+
owner: string
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
/**
|
|
117
127
|
* Stands in for the `authenticator` password when the operator has not supplied one.
|
|
118
128
|
*
|
|
@@ -309,7 +319,7 @@ export async function runPreflight(
|
|
|
309
319
|
WHERE g.rolname = 'authenticator'`,
|
|
310
320
|
)
|
|
311
321
|
const held = new Set(memberships.map((m) => m.rolname))
|
|
312
|
-
const needed =
|
|
322
|
+
const needed = API_ROLES.filter((r) => !held.has(r))
|
|
313
323
|
results.push({
|
|
314
324
|
id: "authenticator-memberships",
|
|
315
325
|
title: "authenticator can reach the three API roles",
|
|
@@ -322,6 +332,19 @@ export async function runPreflight(
|
|
|
322
332
|
})
|
|
323
333
|
}
|
|
324
334
|
|
|
335
|
+
// USAGE on the auth schema, asked by OID so a role that does not exist cannot raise
|
|
336
|
+
// `role "x" does not exist` the way the name-taking overload would.
|
|
337
|
+
const authUsage = await q<AuthSchemaUsageRow>(
|
|
338
|
+
`SELECT r.rolname,
|
|
339
|
+
has_schema_privilege(r.oid, n.oid, 'USAGE') AS has_usage,
|
|
340
|
+
pg_get_userbyid(n.nspowner) AS owner
|
|
341
|
+
FROM pg_namespace n
|
|
342
|
+
LEFT JOIN pg_roles r ON r.rolname = ANY($1)
|
|
343
|
+
WHERE n.nspname = 'auth'`,
|
|
344
|
+
[API_ROLES as unknown as string[]],
|
|
345
|
+
)
|
|
346
|
+
results.push(authSchemaUsageCheck(authUsage, privs))
|
|
347
|
+
|
|
325
348
|
// ── Extensions ─────────────────────────────────────────────────────────────
|
|
326
349
|
const installed = new Set(
|
|
327
350
|
(await q<{ extname: string }>("SELECT extname FROM pg_extension")).map((r) => r.extname),
|
|
@@ -456,6 +479,71 @@ export async function runPreflight(
|
|
|
456
479
|
return { results, worst: worstOf(results) }
|
|
457
480
|
}
|
|
458
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Can the API roles reach the auth helpers when a query calls them?
|
|
484
|
+
*
|
|
485
|
+
* Postgres evaluates a policy's `USING` clause with the table owner's privileges, so row-level
|
|
486
|
+
* security works without this grant and looks like proof the schema is fine. Field masking does
|
|
487
|
+
* not: `supatype_mask` rewrites a masked column into `CASE WHEN can_read_t__c(t) …` in the target
|
|
488
|
+
* list, which runs as the caller and reaches `auth.role()` directly. Studio's per-record
|
|
489
|
+
* `can_<op>_<table>` calls have the same shape.
|
|
490
|
+
*
|
|
491
|
+
* `supatype/postgres` grants this at initdb, so a missing grant means a database Supatype did not
|
|
492
|
+
* bootstrap. Kept a `degrade` rather than a `fail`: two named features stop working and the rest
|
|
493
|
+
* of the stack is unaffected.
|
|
494
|
+
*/
|
|
495
|
+
export function authSchemaUsageCheck(
|
|
496
|
+
rows: AuthSchemaUsageRow[],
|
|
497
|
+
privs: { current_user: string; is_super: boolean },
|
|
498
|
+
): CheckResult {
|
|
499
|
+
const base = { id: "auth-schema-usage", title: "USAGE on schema auth (API roles)" }
|
|
500
|
+
|
|
501
|
+
// No auth schema yet: the first push creates it and grants on it, so there is nothing to fix.
|
|
502
|
+
if (rows.length === 0) {
|
|
503
|
+
return {
|
|
504
|
+
...base,
|
|
505
|
+
severity: "pass",
|
|
506
|
+
detail: "schema auth does not exist yet; the first push creates it and grants usage",
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const owner = rows[0]!.owner
|
|
511
|
+
const present = rows.filter((r) => r.rolname !== null)
|
|
512
|
+
|
|
513
|
+
// The roles check above owns this finding; repeating it here as a privilege problem would send
|
|
514
|
+
// the operator after the wrong fix.
|
|
515
|
+
if (present.length === 0) {
|
|
516
|
+
return { ...base, severity: "pass", detail: "no API roles exist on this server yet" }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const lacking = present.filter((r) => !r.has_usage).map((r) => r.rolname!)
|
|
520
|
+
if (lacking.length === 0) {
|
|
521
|
+
return {
|
|
522
|
+
...base,
|
|
523
|
+
severity: "pass",
|
|
524
|
+
detail: `granted to ${present.map((r) => r.rolname).join(", ")} (schema owned by "${owner}")`,
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// A grant from a role that owns neither the schema nor the server is discarded with a WARNING
|
|
529
|
+
// rather than an error, so `--fix` would report "Applied" having changed nothing. That is the
|
|
530
|
+
// same fault as a privilege generated and never applied, so hand it to the operator instead.
|
|
531
|
+
const canGrant = owner === privs.current_user || privs.is_super
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
...base,
|
|
535
|
+
severity: "degrade",
|
|
536
|
+
detail: `not granted to ${lacking.join(", ")} (schema owned by "${owner}")`,
|
|
537
|
+
impact:
|
|
538
|
+
"Field-level access is unavailable: a push declaring `access.fields` refuses rather than " +
|
|
539
|
+
"applying, and a masked column read by one of these roles fails with 42501 instead of " +
|
|
540
|
+
"masking. Studio's per-record permission checks fail the same way. Row-level security is " +
|
|
541
|
+
"unaffected, because Postgres evaluates policies with the table owner's privileges.",
|
|
542
|
+
remedy: `GRANT USAGE ON SCHEMA auth TO ${lacking.map(ident).join(", ")};`,
|
|
543
|
+
...(!canGrant && { remedyNeedsOperator: true }),
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
459
547
|
function extensionCheck(
|
|
460
548
|
name: string,
|
|
461
549
|
installed: Set<string>,
|
package/src/dev-compose.ts
CHANGED
|
@@ -14,10 +14,12 @@ import {
|
|
|
14
14
|
connectionString,
|
|
15
15
|
projectRootFromConfig,
|
|
16
16
|
resolveRuntimeProvider,
|
|
17
|
+
hooksPathFromProject,
|
|
17
18
|
schemaPathFromProject,
|
|
18
19
|
usesExternalDatabase,
|
|
19
20
|
type SupatypeProjectConfig,
|
|
20
21
|
} from "./project-config.js"
|
|
22
|
+
import { syncManifestHooks, writeHooksModule } from "./model-hooks.js"
|
|
21
23
|
import { signJwt } from "./jwt.js"
|
|
22
24
|
import { ensureDevDbPort, ensureKongPort } from "./dev-ports.js"
|
|
23
25
|
import { handleComposeProjectRename } from "./compose-rename.js"
|
|
@@ -37,11 +39,16 @@ import {
|
|
|
37
39
|
import type { DockerBrandOptions } from "./docker-runtime.js"
|
|
38
40
|
import { hasEngineOverride } from "./binary-cache.js"
|
|
39
41
|
import { STUDIO_DEV_PORT, startStudioViteDevServer } from "./studio-dev-server.js"
|
|
40
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
ensureLocalServerDockerImage,
|
|
44
|
+
usesLocalServerImage,
|
|
45
|
+
LOCAL_SERVER_DOCKER_IMAGE,
|
|
46
|
+
} from "./compose-local-server-image.js"
|
|
41
47
|
import { ensureEngine, engineRequest, type DiffResult } from "./engine-client.js"
|
|
42
48
|
import { writeSchemaSourcePushArtifacts, type SchemaSourcePushArtifacts } from "./schema-sources.js"
|
|
43
49
|
import { readEnvValue, upsertEnvFile } from "./env-file.js"
|
|
44
50
|
import {
|
|
51
|
+
devAuthenticatorPassword,
|
|
45
52
|
devJwtSecret,
|
|
46
53
|
devPostgresPassword,
|
|
47
54
|
seedMissingDatabaseIdentity,
|
|
@@ -219,14 +226,13 @@ async function startComposeDatabase(
|
|
|
219
226
|
await waitComposeHealthy(paths, cwd, timeoutMs, project)
|
|
220
227
|
}
|
|
221
228
|
|
|
222
|
-
function upsertDevComposeEnv(
|
|
229
|
+
export function upsertDevComposeEnv(
|
|
223
230
|
cwd: string,
|
|
224
231
|
config: SupatypeProjectConfig,
|
|
225
232
|
anonKey: string,
|
|
226
233
|
serviceRoleKey: string,
|
|
227
234
|
kongPort: number,
|
|
228
235
|
devDbPort?: number,
|
|
229
|
-
localServerImage?: string,
|
|
230
236
|
): void {
|
|
231
237
|
const apiUrl = `http://localhost:${kongPort}`
|
|
232
238
|
const imagePins = composeDockerImageEnv(config)
|
|
@@ -261,7 +267,6 @@ function upsertDevComposeEnv(
|
|
|
261
267
|
SITE_URL: apiUrl,
|
|
262
268
|
GOTRUE_MAILER_AUTOCONFIRM: "true",
|
|
263
269
|
...imagePins,
|
|
264
|
-
...(localServerImage !== undefined && { SUPATYPE_SERVER_IMAGE: localServerImage }),
|
|
265
270
|
}
|
|
266
271
|
// Never for an external database: this URL describes the `db` container, which that project does
|
|
267
272
|
// not have. Writing it overwrote the operator's own DATABASE_URL, the value the whole stack and
|
|
@@ -278,10 +283,25 @@ function upsertDevComposeEnv(
|
|
|
278
283
|
updates.DATABASE_URL =
|
|
279
284
|
`postgresql://${dbUser}:${devPostgresPassword(cwd)}@localhost:${devDbPort}/${dbName}?sslmode=disable`
|
|
280
285
|
}
|
|
281
|
-
|
|
286
|
+
// `SUPATYPE_SERVER_IMAGE` is written here, not passed in, and it is deliberately *not* marked
|
|
287
|
+
// managed. The managed marker means "this value came from `versions` in the config and is mine to
|
|
288
|
+
// clean up when the pin goes away". The locally built image comes from `overrides`, so removing
|
|
289
|
+
// it as an unpinned managed key was wrong: any `.env` write that did not know about the local
|
|
290
|
+
// image deleted it, and compose then recreated the server from the published image.
|
|
291
|
+
const wantsLocalServer = usesLocalServerImage(cwd, config)
|
|
292
|
+
if (wantsLocalServer) updates.SUPATYPE_SERVER_IMAGE = LOCAL_SERVER_DOCKER_IMAGE
|
|
293
|
+
|
|
294
|
+
const managedImageKeys = COMPOSE_PINNED_IMAGE_ENV_KEYS.filter(
|
|
295
|
+
(key) => !(wantsLocalServer && key === "SUPATYPE_SERVER_IMAGE"),
|
|
296
|
+
)
|
|
297
|
+
const removeImageKeys = managedImageKeys.filter((key) => !(key in imagePins))
|
|
282
298
|
upsertEnvFile(cwd, updates, {
|
|
283
299
|
removeManaged: removeImageKeys,
|
|
284
|
-
managed:
|
|
300
|
+
managed: managedImageKeys,
|
|
301
|
+
// A local image left in `.env` after the project stopped asking for one would keep pointing
|
|
302
|
+
// compose at a stale build, and it carries no marker for `removeManaged` to act on.
|
|
303
|
+
...(!wantsLocalServer &&
|
|
304
|
+
!("SUPATYPE_SERVER_IMAGE" in imagePins) && { remove: ["SUPATYPE_SERVER_IMAGE"] }),
|
|
285
305
|
})
|
|
286
306
|
}
|
|
287
307
|
|
|
@@ -293,9 +313,8 @@ function ensureDevComposeEnv(
|
|
|
293
313
|
serviceRoleKey: string,
|
|
294
314
|
kongPort: number,
|
|
295
315
|
devDbPort?: number,
|
|
296
|
-
localServerImage?: string,
|
|
297
316
|
): void {
|
|
298
|
-
upsertDevComposeEnv(cwd, config, anonKey, serviceRoleKey, kongPort, devDbPort
|
|
317
|
+
upsertDevComposeEnv(cwd, config, anonKey, serviceRoleKey, kongPort, devDbPort)
|
|
299
318
|
}
|
|
300
319
|
|
|
301
320
|
async function waitComposeHealthy(paths: SelfHostComposePaths, cwd: string, maxMs: number, composeProject: string): Promise<void> {
|
|
@@ -441,6 +460,20 @@ async function refreshSchemaArtifacts(
|
|
|
441
460
|
const supatypeDir = join(cwd, ".supatype")
|
|
442
461
|
const adminConfigPath = join(supatypeDir, "admin-config.json")
|
|
443
462
|
|
|
463
|
+
// Before the engine gate below: these are read straight off the AST, so a push whose engine is
|
|
464
|
+
// unavailable should still leave the server the right maps. The server watches this file, so a
|
|
465
|
+
// hook or validator added to the schema takes effect without restarting the stack.
|
|
466
|
+
//
|
|
467
|
+
// `dev` used to skip both entirely, because only the `direct`/`local` push branch wrote them.
|
|
468
|
+
// Every project running on Compose therefore had a manifest with no `hooks` and no `validators`
|
|
469
|
+
// key, and the server, having nothing to call, ran neither. A hook silently not firing is bad; a
|
|
470
|
+
// validator silently not firing means a write the schema says is checked is accepted with a 201.
|
|
471
|
+
const hooksModule = writeHooksModule(cwd, hooksPathFromProject(config, cwd), ast)
|
|
472
|
+
if (hooksModule !== null) console.log(`[supatype] Hook handler types written to ${hooksModule}`)
|
|
473
|
+
if (syncManifestHooks(cwd, ast)) {
|
|
474
|
+
console.log("[supatype] Hook and validator maps written to .supatype/manifest.json")
|
|
475
|
+
}
|
|
476
|
+
|
|
444
477
|
try {
|
|
445
478
|
await ensureEngine()
|
|
446
479
|
} catch (err) {
|
|
@@ -848,7 +881,7 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
|
|
|
848
881
|
const devBrand = { intro: "Local development" }
|
|
849
882
|
const localServerImage = await ensureLocalServerDockerImage(cwd, config, devBrand)
|
|
850
883
|
|
|
851
|
-
ensureDevComposeEnv(cwd, config, anonKey, serviceRoleKey, kongPort, devDbPort
|
|
884
|
+
ensureDevComposeEnv(cwd, config, anonKey, serviceRoleKey, kongPort, devDbPort)
|
|
852
885
|
|
|
853
886
|
console.log(`[supatype] provider docker, starting self-host Compose stack (project ${project}, gateway :${kongPort})...`)
|
|
854
887
|
const paths = writeSelfHostCompose(cwd, config, { devLocal: true })
|
|
@@ -901,6 +934,10 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
|
|
|
901
934
|
// Settle before DDL: pg_isready can pass slightly before the instance is stable.
|
|
902
935
|
await new Promise((r) => setTimeout(r, 3000))
|
|
903
936
|
|
|
937
|
+
if (!usesExternalDatabase(config)) {
|
|
938
|
+
reconcileAuthenticatorPassword(paths, cwd, project)
|
|
939
|
+
}
|
|
940
|
+
|
|
904
941
|
// A: apply schema before realtime (and the rest of the stack) starts decoding WAL.
|
|
905
942
|
const schemaPath = schemaPathFromProject(config, cwd)
|
|
906
943
|
{
|
|
@@ -1104,6 +1141,56 @@ function astHasSystemAuthRelation(ast: unknown): boolean {
|
|
|
1104
1141
|
return false
|
|
1105
1142
|
}
|
|
1106
1143
|
|
|
1144
|
+
/**
|
|
1145
|
+
* Set `authenticator`'s password to the one `.env` holds, every time the stack starts.
|
|
1146
|
+
*
|
|
1147
|
+
* The Postgres image passwords that role from `AUTHENTICATOR_PASSWORD` in its init scripts, which
|
|
1148
|
+
* run once, on an empty data directory. So the value the role actually has is whatever `.env` said
|
|
1149
|
+
* the day the volume was created, and `.env` can move afterwards. When the two diverge PostgREST
|
|
1150
|
+
* cannot log in, exits, and every REST request answers 502 with the real reason visible only in a
|
|
1151
|
+
* container log the developer has no reason to read.
|
|
1152
|
+
*
|
|
1153
|
+
* Reconciling here makes `.env` the answer to what the password is, rather than the volume's
|
|
1154
|
+
* birthday. It is idempotent, and it runs before the schema push so the API is already reachable by
|
|
1155
|
+
* the time the stack reports ready.
|
|
1156
|
+
*/
|
|
1157
|
+
function reconcileAuthenticatorPassword(
|
|
1158
|
+
paths: SelfHostComposePaths,
|
|
1159
|
+
cwd: string,
|
|
1160
|
+
composeProject: string,
|
|
1161
|
+
): void {
|
|
1162
|
+
const composeDir = dirname(paths.composePath)
|
|
1163
|
+
const owner = readEnvValue(cwd, "POSTGRES_USER", "supatype_admin")
|
|
1164
|
+
const database = readEnvValue(cwd, "POSTGRES_DB", "supatype")
|
|
1165
|
+
const result = spawnSync(
|
|
1166
|
+
"docker",
|
|
1167
|
+
[
|
|
1168
|
+
"compose", "-p", composeProject, "-f", paths.composePath,
|
|
1169
|
+
"exec", "-T",
|
|
1170
|
+
"-e", `PGPASSWORD=${devPostgresPassword(cwd)}`,
|
|
1171
|
+
"-e", `SUPATYPE_AUTHENTICATOR_PASSWORD=${devAuthenticatorPassword(cwd)}`,
|
|
1172
|
+
"db", "psql", "-v", "ON_ERROR_STOP=1", "-U", owner, "-d", database,
|
|
1173
|
+
],
|
|
1174
|
+
{
|
|
1175
|
+
cwd: composeDir,
|
|
1176
|
+
encoding: "utf8",
|
|
1177
|
+
timeout: 10_000,
|
|
1178
|
+
// `\getenv` reads the value from the container's environment, so the password is never a
|
|
1179
|
+
// `docker exec` argument, which any process listing on the machine would show.
|
|
1180
|
+
input: [
|
|
1181
|
+
"\\getenv pw SUPATYPE_AUTHENTICATOR_PASSWORD",
|
|
1182
|
+
"ALTER ROLE authenticator WITH LOGIN PASSWORD :'pw';",
|
|
1183
|
+
"",
|
|
1184
|
+
].join("\n"),
|
|
1185
|
+
},
|
|
1186
|
+
)
|
|
1187
|
+
if (result.status !== 0) {
|
|
1188
|
+
console.warn(
|
|
1189
|
+
"[supatype] Could not set the authenticator password; the REST API may answer 502.",
|
|
1190
|
+
)
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1107
1194
|
function grantAuthSchemaAccess(
|
|
1108
1195
|
paths: SelfHostComposePaths,
|
|
1109
1196
|
cwd: string,
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a declared bound means for each field kind, and the SQL it compiles to.
|
|
3
|
+
*
|
|
4
|
+
* **This table is the mechanism.** Bounds used to be compiled inside the modifier cases of
|
|
5
|
+
* `type-extractor.ts`, which meant `MaxLength` became `char_length(col)` whatever the column turned
|
|
6
|
+
* out to be: `char_length(text[])` does not exist, so the RFC's own `tags: MaxLength<string[], 10>`
|
|
7
|
+
* produced SQL that fails `CREATE TABLE`. Worse, nine of twelve engine field structs had nowhere to
|
|
8
|
+
* put a `check`, so the constraint was dropped by serde with no error anywhere in the chain.
|
|
9
|
+
*
|
|
10
|
+
* A table keyed by kind fixes the class of bug rather than the instances: a kind absent from
|
|
11
|
+
* {@link BOUNDS_BY_KIND} throws, so a new field kind cannot be added without answering "what does a
|
|
12
|
+
* bound mean here", and every answer is either an expression or a refusal with a named alternative.
|
|
13
|
+
* There is no third outcome, which is what "no bound is ever silent" means in practice.
|
|
14
|
+
*/
|
|
15
|
+
import { FIELD_KINDS, type FieldKind, type FieldValidation } from "./schema-ast-v2.js"
|
|
16
|
+
|
|
17
|
+
/** Bounds as declared on the type, before anything knows what column they will land on. */
|
|
18
|
+
export interface DeclaredBounds {
|
|
19
|
+
maxLength?: number
|
|
20
|
+
minLength?: number
|
|
21
|
+
maxItems?: number
|
|
22
|
+
minItems?: number
|
|
23
|
+
min?: number | string
|
|
24
|
+
max?: number | string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What "length" counts, per storage. */
|
|
28
|
+
type LengthForm = "chars" | "octets" | "richText"
|
|
29
|
+
/** What "items" counts, per storage. */
|
|
30
|
+
type ItemsForm = "array" | "jsonbArray"
|
|
31
|
+
/** What a range compares against. Temporal forms name the cast, which is never `numeric`. */
|
|
32
|
+
type RangeForm = "numeric" | "timestamptz" | "date" | "timestamp" | "interval"
|
|
33
|
+
|
|
34
|
+
type BoundFamily = "length" | "items" | "range"
|
|
35
|
+
|
|
36
|
+
interface KindBounds {
|
|
37
|
+
length?: LengthForm
|
|
38
|
+
items?: ItemsForm
|
|
39
|
+
range?: RangeForm
|
|
40
|
+
/** Where to send someone whose bound this kind refuses. */
|
|
41
|
+
instead?: Partial<Record<BoundFamily, string>>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const TEXTUAL: KindBounds = {
|
|
45
|
+
length: "chars",
|
|
46
|
+
instead: {
|
|
47
|
+
items: "text has characters, not items; use MaxLength/MinLength",
|
|
48
|
+
range: "text is not ordered numerically; use a model-level constraint if you need a comparison",
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const NUMERIC: KindBounds = {
|
|
53
|
+
range: "numeric",
|
|
54
|
+
instead: {
|
|
55
|
+
length: "a number has no length; use Between to bound its value",
|
|
56
|
+
items: "a number has no items; use Between to bound its value",
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const temporal = (range: RangeForm): KindBounds => ({
|
|
61
|
+
range,
|
|
62
|
+
instead: {
|
|
63
|
+
length: "a date has no length; use Between with ISO-8601 string bounds",
|
|
64
|
+
items: "a date has no items; use Between with ISO-8601 string bounds",
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const NO_BOUNDS = (why: string): KindBounds => ({
|
|
69
|
+
instead: { length: why, items: why, range: why },
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What each bound means for each kind.
|
|
74
|
+
*
|
|
75
|
+
* `Record<FieldKind, KindBounds>` is the whole mechanism: it is **exhaustive by the compiler**, so
|
|
76
|
+
* adding a kind to `FIELD_KINDS` fails the build here until someone says what `MaxLength`,
|
|
77
|
+
* `MaxItems` and `Between` do for it. Composite kinds (`timestamps`, `publishable`, `softDelete`)
|
|
78
|
+
* expand into real columns before a bound could apply, so they carry none, but they still have to
|
|
79
|
+
* say so.
|
|
80
|
+
*/
|
|
81
|
+
const BOUNDS_BY_KIND: Record<FieldKind, KindBounds> = {
|
|
82
|
+
text: TEXTUAL,
|
|
83
|
+
email: TEXTUAL,
|
|
84
|
+
url: TEXTUAL,
|
|
85
|
+
slug: TEXTUAL,
|
|
86
|
+
color: TEXTUAL,
|
|
87
|
+
xml: TEXTUAL,
|
|
88
|
+
ip: TEXTUAL,
|
|
89
|
+
cidr: TEXTUAL,
|
|
90
|
+
macaddr: TEXTUAL,
|
|
91
|
+
tsQuery: TEXTUAL,
|
|
92
|
+
tsVector: TEXTUAL,
|
|
93
|
+
|
|
94
|
+
richText: {
|
|
95
|
+
length: "richText",
|
|
96
|
+
instead: {
|
|
97
|
+
items: "rich text is measured in characters of plain text; use MaxLength/MinLength",
|
|
98
|
+
range: "rich text is not ordered; use MaxLength/MinLength",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
bytes: {
|
|
103
|
+
length: "octets",
|
|
104
|
+
instead: {
|
|
105
|
+
items: "a binary column has octets, not items; use MaxLength/MinLength",
|
|
106
|
+
range: "a binary column is not ordered; use MaxLength/MinLength",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
integer: NUMERIC,
|
|
111
|
+
smallInt: NUMERIC,
|
|
112
|
+
bigInt: NUMERIC,
|
|
113
|
+
float: NUMERIC,
|
|
114
|
+
serial: NUMERIC,
|
|
115
|
+
bigSerial: NUMERIC,
|
|
116
|
+
decimal: NUMERIC,
|
|
117
|
+
money: NUMERIC,
|
|
118
|
+
|
|
119
|
+
datetime: temporal("timestamptz"),
|
|
120
|
+
timestamp: temporal("timestamp"),
|
|
121
|
+
date: temporal("date"),
|
|
122
|
+
interval: temporal("interval"),
|
|
123
|
+
|
|
124
|
+
array: {
|
|
125
|
+
items: "array",
|
|
126
|
+
instead: {
|
|
127
|
+
length: "an array has items, not characters; use MaxItems/MinItems",
|
|
128
|
+
range: "an array is not ordered; use MaxItems/MinItems",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
blocks: {
|
|
133
|
+
items: "jsonbArray",
|
|
134
|
+
instead: {
|
|
135
|
+
length: "blocks are counted, not measured; use MaxItems/MinItems",
|
|
136
|
+
range: "blocks are not ordered; use MaxItems/MinItems",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// `json` is decided per field, not per kind: `JSON<Item[]>` takes item bounds and `JSON<{...}>`
|
|
141
|
+
// takes none. {@link boundsForKind} applies that, which is why the entry here is the object case.
|
|
142
|
+
json: NO_BOUNDS(
|
|
143
|
+
"a JSON object has no single measure; bound a sub-field with a model-level constraint, " +
|
|
144
|
+
"or declare the field as JSON<T[]> to bound its element count",
|
|
145
|
+
),
|
|
146
|
+
button: NO_BOUNDS("a button is a composite value; bound a sub-field with a model-level constraint"),
|
|
147
|
+
|
|
148
|
+
enum: NO_BOUNDS("the union already constrains the permitted values"),
|
|
149
|
+
boolean: NO_BOUNDS("a boolean has two values and needs no bound"),
|
|
150
|
+
uuid: NO_BOUNDS("a UUID is fixed width"),
|
|
151
|
+
image: NO_BOUNDS("size and type limits belong on the bucket: fileSizeLimit and allowedMimeTypes"),
|
|
152
|
+
file: NO_BOUNDS("size and type limits belong on the bucket: fileSizeLimit and allowedMimeTypes"),
|
|
153
|
+
geo: NO_BOUNDS("a geometry is not measured this way"),
|
|
154
|
+
vector: NO_BOUNDS("the dimension is already fixed by the type, as Vector<N>"),
|
|
155
|
+
relation: NO_BOUNDS("bound the column on the model this relation points at"),
|
|
156
|
+
custom: NO_BOUNDS("a plugin field declares its own storage; bounds are the plugin's to define"),
|
|
157
|
+
|
|
158
|
+
timestamps: NO_BOUNDS("a composite expands into columns before a bound could apply"),
|
|
159
|
+
publishable: NO_BOUNDS("a composite expands into columns before a bound could apply"),
|
|
160
|
+
softDelete: NO_BOUNDS("a composite expands into columns before a bound could apply"),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Re-exported for tests. Completeness is now the compiler's job, not a test's: this exists so the
|
|
165
|
+
* matrix can assert it covers every kind, which is a different question from whether every kind is
|
|
166
|
+
* classified.
|
|
167
|
+
*/
|
|
168
|
+
export const CLASSIFIED_KINDS: readonly FieldKind[] = FIELD_KINDS
|
|
169
|
+
|
|
170
|
+
/** `JSON<T[]>` counts elements; `JSON<{...}>` takes no bound. Anything else follows its kind. */
|
|
171
|
+
function boundsForKind(kind: FieldKind, jsonIsArray: boolean): KindBounds {
|
|
172
|
+
const entry = BOUNDS_BY_KIND[kind]
|
|
173
|
+
if (kind === "json" && jsonIsArray) {
|
|
174
|
+
return {
|
|
175
|
+
items: "jsonbArray",
|
|
176
|
+
instead: {
|
|
177
|
+
length: "a JSON array has items, not characters; use MaxItems/MinItems",
|
|
178
|
+
range: "a JSON array is not ordered; use MaxItems/MinItems",
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return entry
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** How a column is measured, once the kind has decided. */
|
|
186
|
+
export type MeasureForm = LengthForm | ItemsForm
|
|
187
|
+
|
|
188
|
+
export interface MeasureResolution {
|
|
189
|
+
/** How to measure, when this kind can be measured this way. */
|
|
190
|
+
form?: MeasureForm
|
|
191
|
+
/** Why it cannot, and what to use instead. Present exactly when `form` is not. */
|
|
192
|
+
instead?: string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* How a kind is measured, for one measure family.
|
|
197
|
+
*
|
|
198
|
+
* **The single answer for both paths.** `MaxLength<T, N>` on a field and `Length<"col">` inside a
|
|
199
|
+
* model constraint have to agree about what "length" means for a given column, or the same schema
|
|
200
|
+
* gets `char_length` in one place and `cardinality` in the other. A second table for the constraint
|
|
201
|
+
* path is how `char_length(text[])` would come back, in a new file, having been fixed once already.
|
|
202
|
+
*/
|
|
203
|
+
export function measureFormFor(
|
|
204
|
+
kind: FieldKind,
|
|
205
|
+
measure: "length" | "items",
|
|
206
|
+
options: { jsonIsArray?: boolean } = {},
|
|
207
|
+
): MeasureResolution {
|
|
208
|
+
const entry = boundsForKind(kind, options.jsonIsArray === true)
|
|
209
|
+
const form = measure === "length" ? entry.length : entry.items
|
|
210
|
+
if (form !== undefined) return { form }
|
|
211
|
+
return { instead: entry.instead?.[measure] ?? `a ${kind} field cannot be measured that way` }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const COLUMN = '"{name}"'
|
|
215
|
+
|
|
216
|
+
function lengthExpr(form: LengthForm): string {
|
|
217
|
+
switch (form) {
|
|
218
|
+
case "chars":
|
|
219
|
+
return `char_length(${COLUMN})`
|
|
220
|
+
case "octets":
|
|
221
|
+
return `octet_length(${COLUMN})`
|
|
222
|
+
case "richText":
|
|
223
|
+
return `char_length(_supatype.richtext_text(${COLUMN}))`
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* `jsonb_array_length` raises `cannot get array length of a non-array` at insert time, so the type
|
|
229
|
+
* guard is part of the constraint rather than an assumption about what callers send.
|
|
230
|
+
*/
|
|
231
|
+
function itemsClause(form: ItemsForm, comparisons: string[]): string {
|
|
232
|
+
if (form === "array") {
|
|
233
|
+
return comparisons.map((c) => `cardinality(${COLUMN}) ${c}`).join(" AND ")
|
|
234
|
+
}
|
|
235
|
+
const guarded = comparisons.map((c) => `jsonb_array_length(${COLUMN}) ${c}`).join(" AND ")
|
|
236
|
+
return `jsonb_typeof(${COLUMN}) = 'array' AND ${guarded}`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function rangeLiteral(form: RangeForm, value: number | string): string {
|
|
240
|
+
if (form === "numeric") return String(value)
|
|
241
|
+
const cast = form === "timestamptz" ? "timestamptz" : form
|
|
242
|
+
return `'${String(value).replace(/'/g, "''")}'::${cast}`
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** ISO-8601 date, date-time or a Postgres interval. Validated here so a bad literal is a CLI error. */
|
|
246
|
+
function isTemporalLiteral(form: RangeForm, value: string): boolean {
|
|
247
|
+
if (form === "interval") return /^\s*\d+\s+[a-z]+(\s+\d+\s+[a-z]+)*\s*$/i.test(value)
|
|
248
|
+
return !Number.isNaN(Date.parse(value))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface BoundsCompileResult {
|
|
252
|
+
check?: string
|
|
253
|
+
validation?: FieldValidation
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function refuse(field: string, modifier: string, kind: FieldKind, hint: string | undefined): never {
|
|
257
|
+
const tail = hint ? ` ${hint}.` : ""
|
|
258
|
+
throw new Error(
|
|
259
|
+
`Field "${field}": ${modifier} is not supported on a ${kind} field.${tail}`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Compile declared bounds against the kind they landed on.
|
|
265
|
+
*
|
|
266
|
+
* Throws rather than dropping. A bound that cannot be honoured is a mistake in the schema, and the
|
|
267
|
+
* failure it used to produce, silence, is the one failure this must not have.
|
|
268
|
+
*/
|
|
269
|
+
export function compileBounds(
|
|
270
|
+
field: string,
|
|
271
|
+
kind: FieldKind,
|
|
272
|
+
bounds: DeclaredBounds,
|
|
273
|
+
options: { jsonIsArray?: boolean } = {},
|
|
274
|
+
): BoundsCompileResult {
|
|
275
|
+
const entry = boundsForKind(kind, options.jsonIsArray === true)
|
|
276
|
+
const clauses: string[] = []
|
|
277
|
+
const validation: FieldValidation = {}
|
|
278
|
+
|
|
279
|
+
const { maxLength, minLength } = bounds
|
|
280
|
+
if (maxLength !== undefined || minLength !== undefined) {
|
|
281
|
+
const resolved = measureFormFor(kind, "length", options)
|
|
282
|
+
if (resolved.form === undefined) {
|
|
283
|
+
refuse(field, maxLength !== undefined ? "MaxLength" : "MinLength", kind, resolved.instead)
|
|
284
|
+
}
|
|
285
|
+
const expr = lengthExpr(resolved.form as LengthForm)
|
|
286
|
+
if (maxLength !== undefined) {
|
|
287
|
+
clauses.push(`${expr} <= ${maxLength}`)
|
|
288
|
+
validation.maxLength = maxLength
|
|
289
|
+
}
|
|
290
|
+
if (minLength !== undefined) {
|
|
291
|
+
clauses.push(`${expr} >= ${minLength}`)
|
|
292
|
+
validation.minLength = minLength
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const { maxItems, minItems } = bounds
|
|
297
|
+
const itemsResolved = measureFormFor(kind, "items", options)
|
|
298
|
+
if (maxItems !== undefined || minItems !== undefined) {
|
|
299
|
+
if (itemsResolved.form === undefined) {
|
|
300
|
+
refuse(field, maxItems !== undefined ? "MaxItems" : "MinItems", kind, itemsResolved.instead)
|
|
301
|
+
}
|
|
302
|
+
const comparisons: string[] = []
|
|
303
|
+
if (maxItems !== undefined) {
|
|
304
|
+
comparisons.push(`<= ${maxItems}`)
|
|
305
|
+
validation.maxItems = maxItems
|
|
306
|
+
}
|
|
307
|
+
if (minItems !== undefined) {
|
|
308
|
+
comparisons.push(`>= ${minItems}`)
|
|
309
|
+
validation.minItems = minItems
|
|
310
|
+
}
|
|
311
|
+
clauses.push(itemsClause(itemsResolved.form as ItemsForm, comparisons))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const { min, max } = bounds
|
|
315
|
+
if (min !== undefined || max !== undefined) {
|
|
316
|
+
if (!entry.range) refuse(field, "Between", kind, entry.instead?.range)
|
|
317
|
+
for (const [bound, comparison] of [[min, ">="], [max, "<="]] as const) {
|
|
318
|
+
if (bound === undefined) continue
|
|
319
|
+
assertRangeShape(field, kind, entry.range, bound)
|
|
320
|
+
clauses.push(`${COLUMN} ${comparison} ${rangeLiteral(entry.range, bound)}`)
|
|
321
|
+
}
|
|
322
|
+
if (min !== undefined) validation.min = min
|
|
323
|
+
if (max !== undefined) validation.max = max
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Parenthesised only when there is something to bind, matching `mergeCheckConstraint`. Gratuitous
|
|
327
|
+
// parentheses are not cosmetic here: the differ compares this text against what Postgres hands
|
|
328
|
+
// back from `pg_get_constraintdef`, so every avoidable difference is a false "changed" on push.
|
|
329
|
+
return {
|
|
330
|
+
...(clauses.length > 0 && { check: joinClauses(clauses) }),
|
|
331
|
+
...(Object.keys(validation).length > 0 && { validation }),
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function joinClauses(clauses: string[]): string {
|
|
336
|
+
const [only] = clauses
|
|
337
|
+
if (clauses.length === 1 && only !== undefined) return only
|
|
338
|
+
return clauses.map((c) => `(${c})`).join(" AND ")
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** A number bounds a number and a string bounds a date. Crossing them is a mistake, not a cast. */
|
|
342
|
+
function assertRangeShape(field: string, kind: FieldKind, form: RangeForm, bound: number | string): void {
|
|
343
|
+
const isNumeric = form === "numeric"
|
|
344
|
+
if (isNumeric && typeof bound !== "number") {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`Field "${field}": Between on a ${kind} field takes numbers, but "${bound}" is a string.`,
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
if (!isNumeric && typeof bound !== "string") {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`Field "${field}": Between on a ${kind} field takes ISO-8601 string bounds, but ${bound} is a number.`,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
if (!isNumeric && !isTemporalLiteral(form, bound as string)) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
`Field "${field}": Between bound "${bound}" is not a valid ${form === "interval" ? "interval" : "ISO-8601 date"}.`,
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
}
|