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