@supatype/cli 0.1.13 → 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 +151 -143
- 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/dev.js +51 -51
- package/dist/commands/dev.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 +3 -0
- 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-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.map +1 -1
- package/dist/dev-compose.js +123 -20
- 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/link.d.ts +1 -1
- package/dist/link.d.ts.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/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/package.json +2 -2
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/admin.ts +10 -10
- package/src/commands/dev.ts +51 -51
- package/src/commands/init.ts +1 -1
- package/src/commands/logs.ts +34 -37
- package/src/commands/push.ts +3 -0
- package/src/commands/status.ts +34 -70
- package/src/compose-services.ts +174 -0
- package/src/config.ts +1 -1
- package/src/dev-compose.ts +130 -20
- package/src/dev-ports.ts +48 -0
- package/src/docker-postgres.ts +2 -2
- package/src/engine-floor.ts +113 -0
- package/src/link.ts +1 -1
- 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/self-host-compose.ts +41 -18
- package/src/studio-dev-server.ts +9 -3
- 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/port-in-use.test.ts +58 -0
- package/tests/readiness-poll.test.ts +84 -0
- package/tests/runtime-contract.test.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the local stack is actually running, asked of Docker rather than guessed.
|
|
3
|
+
*
|
|
4
|
+
* `status` and `logs` each used to carry a hardcoded list of container names.
|
|
5
|
+
* Both lists were wrong: they named containers the compose file does not create
|
|
6
|
+
* (`supatype-postgres`, `supatype-kong`), omitted every service it does create
|
|
7
|
+
* (`server`, `storage`, `functions-worker`, `schema-engine`, `valkey`), and used
|
|
8
|
+
* a naming scheme the compose file never applies, since it sets no
|
|
9
|
+
* `container_name` and Docker therefore names containers
|
|
10
|
+
* `<project>-<service>-<n>`.
|
|
11
|
+
*
|
|
12
|
+
* A list of services in the source is a list that goes stale the next time the
|
|
13
|
+
* stack changes shape. `docker compose ps` cannot.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from "node:child_process"
|
|
16
|
+
import { existsSync } from "node:fs"
|
|
17
|
+
import { loadConfig } from "./config.js"
|
|
18
|
+
import { composeProjectName, selfHostComposePaths } from "./self-host-compose.js"
|
|
19
|
+
|
|
20
|
+
/** Where the local stack's compose file is, and what project name it runs under. */
|
|
21
|
+
export interface ComposeContext {
|
|
22
|
+
composePath: string
|
|
23
|
+
projectRoot: string
|
|
24
|
+
projectName: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One service in the local stack. */
|
|
28
|
+
export interface ComposeService {
|
|
29
|
+
/** The compose service name, which is what `logs --service` takes. */
|
|
30
|
+
service: string
|
|
31
|
+
/** The container Docker created for it, if any. */
|
|
32
|
+
container: string
|
|
33
|
+
/** Docker's own word: running, exited, created, restarting, and so on. */
|
|
34
|
+
state: string
|
|
35
|
+
/** Published ports, as Docker reports them. */
|
|
36
|
+
ports: string
|
|
37
|
+
/** How long it has been up, as Docker reports it. */
|
|
38
|
+
status: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* composeContext locates the generated compose file for this project.
|
|
43
|
+
*
|
|
44
|
+
* Returns undefined when the stack has never been generated, which is the
|
|
45
|
+
* ordinary case in a project that only uses `supatype dev`.
|
|
46
|
+
*/
|
|
47
|
+
export function composeContext(cwd: string): ComposeContext | undefined {
|
|
48
|
+
const paths = selfHostComposePaths(cwd)
|
|
49
|
+
if (!existsSync(paths.composePath)) return undefined
|
|
50
|
+
|
|
51
|
+
let projectName = "supatype"
|
|
52
|
+
try {
|
|
53
|
+
const config = loadConfig(cwd)
|
|
54
|
+
if (config) projectName = composeProjectName(config.project.name)
|
|
55
|
+
} catch {
|
|
56
|
+
// A project whose config will not load can still have a running stack, and
|
|
57
|
+
// reporting on it is more useful than refusing to.
|
|
58
|
+
}
|
|
59
|
+
return { composePath: paths.composePath, projectRoot: cwd, projectName }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* composeServices asks Docker what the stack is running.
|
|
64
|
+
*
|
|
65
|
+
* `--all` so a stopped service is reported as stopped rather than vanishing,
|
|
66
|
+
* which is the question `supatype status` is usually asked.
|
|
67
|
+
*/
|
|
68
|
+
export function composeServices(ctx: ComposeContext): ComposeService[] {
|
|
69
|
+
const result = spawnSync("docker", [...composeArgs(ctx), "ps", "--all", "--format", "json"], {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
timeout: 15_000,
|
|
72
|
+
})
|
|
73
|
+
if (result.status !== 0 || !result.stdout) return []
|
|
74
|
+
return parseComposePs(result.stdout)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* parseComposePs reads `docker compose ps --format json`.
|
|
79
|
+
*
|
|
80
|
+
* Compose v2 emits one JSON object per line; some versions emit a single JSON
|
|
81
|
+
* array instead. Both are accepted, because which one you get depends on the
|
|
82
|
+
* Docker version the developer happens to have.
|
|
83
|
+
*/
|
|
84
|
+
export function parseComposePs(stdout: string): ComposeService[] {
|
|
85
|
+
const trimmed = stdout.trim()
|
|
86
|
+
if (trimmed === "") return []
|
|
87
|
+
|
|
88
|
+
const rows: unknown[] = []
|
|
89
|
+
if (trimmed.startsWith("[")) {
|
|
90
|
+
try {
|
|
91
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
92
|
+
if (Array.isArray(parsed)) rows.push(...parsed)
|
|
93
|
+
} catch {
|
|
94
|
+
return []
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
for (const line of trimmed.split("\n")) {
|
|
98
|
+
if (line.trim() === "") continue
|
|
99
|
+
try {
|
|
100
|
+
rows.push(JSON.parse(line))
|
|
101
|
+
} catch {
|
|
102
|
+
// A line that is not JSON is progress noise, not a service.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const services: ComposeService[] = []
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
if (typeof row !== "object" || row === null) continue
|
|
110
|
+
const record = row as Record<string, unknown>
|
|
111
|
+
const service = typeof record["Service"] === "string" ? record["Service"] : ""
|
|
112
|
+
if (service === "") continue
|
|
113
|
+
services.push({
|
|
114
|
+
service,
|
|
115
|
+
container: typeof record["Name"] === "string" ? record["Name"] : "",
|
|
116
|
+
state: typeof record["State"] === "string" ? record["State"] : "unknown",
|
|
117
|
+
ports: typeof record["Publishers"] === "string" ? record["Publishers"] : formatPorts(record["Publishers"]),
|
|
118
|
+
status: typeof record["Status"] === "string" ? record["Status"] : "",
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
services.sort((a, b) => a.service.localeCompare(b.service))
|
|
122
|
+
return services
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* formatPorts renders the structured Publishers array Compose emits, keeping
|
|
127
|
+
* only the ports actually reachable from the host.
|
|
128
|
+
*/
|
|
129
|
+
function formatPorts(publishers: unknown): string {
|
|
130
|
+
if (!Array.isArray(publishers)) return ""
|
|
131
|
+
const published: string[] = []
|
|
132
|
+
for (const entry of publishers) {
|
|
133
|
+
if (typeof entry !== "object" || entry === null) continue
|
|
134
|
+
const record = entry as Record<string, unknown>
|
|
135
|
+
const host = record["PublishedPort"]
|
|
136
|
+
const target = record["TargetPort"]
|
|
137
|
+
if (typeof host === "number" && host > 0 && typeof target === "number") {
|
|
138
|
+
published.push(`${host}->${target}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return [...new Set(published)].join(", ")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* composeArgs is the invocation prefix every compose call needs.
|
|
146
|
+
*
|
|
147
|
+
* The compose file lives under `.supatype/self-host/`, so the project directory
|
|
148
|
+
* has to be given explicitly: without it Compose resolves relative build
|
|
149
|
+
* contexts and env files against the file's own directory rather than the
|
|
150
|
+
* project root.
|
|
151
|
+
*/
|
|
152
|
+
export function composeArgs(ctx: ComposeContext): string[] {
|
|
153
|
+
return ["compose", "-p", ctx.projectName, "--project-directory", ctx.projectRoot, "-f", ctx.composePath]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* composeServiceNames lists the services the compose file defines.
|
|
158
|
+
*
|
|
159
|
+
* This is what `logs --service` should be validated against, not the running
|
|
160
|
+
* containers: a service that failed to start is exactly the one whose logs you
|
|
161
|
+
* want, and it has no container.
|
|
162
|
+
*/
|
|
163
|
+
export function composeServiceNames(ctx: ComposeContext): string[] {
|
|
164
|
+
const result = spawnSync("docker", [...composeArgs(ctx), "config", "--services"], {
|
|
165
|
+
encoding: "utf8",
|
|
166
|
+
timeout: 15_000,
|
|
167
|
+
})
|
|
168
|
+
if (result.status !== 0 || !result.stdout) return []
|
|
169
|
+
return result.stdout
|
|
170
|
+
.split("\n")
|
|
171
|
+
.map((line) => line.trim())
|
|
172
|
+
.filter((line) => line !== "")
|
|
173
|
+
.sort()
|
|
174
|
+
}
|
package/src/config.ts
CHANGED
package/src/dev-compose.ts
CHANGED
|
@@ -144,7 +144,7 @@ export async function ensureDockerDbPublishedForHostEngine(
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
const project = composeProjectName(config.project.name)
|
|
147
|
-
const kongPort = await resolveKongPort(cwd)
|
|
147
|
+
const kongPort = await resolveKongPort(cwd, project)
|
|
148
148
|
const devDbPort = await resolveDevDbPort(cwd)
|
|
149
149
|
|
|
150
150
|
const now = Math.floor(Date.now() / 1000)
|
|
@@ -192,8 +192,40 @@ export async function resolveHostEngineDatabaseUrl(
|
|
|
192
192
|
return connectionString(config)
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
/**
|
|
196
|
+
* `email.provider` and `email.smtp` for the compose server service.
|
|
197
|
+
*
|
|
198
|
+
* The native dev path has mapped these since it existed; the compose path never
|
|
199
|
+
* did, so a project that asked for smtp got the auth service's noop client and
|
|
200
|
+
* no message at all. Console is the documented default, and it at least says
|
|
201
|
+
* that a message would have been sent.
|
|
202
|
+
*/
|
|
203
|
+
function composeMailerEnv(config: SupatypeProjectConfig): Record<string, string> {
|
|
204
|
+
const email = config.email
|
|
205
|
+
const out: Record<string, string> = {
|
|
206
|
+
// Mailer.MailerProvider, so the doubled word is the real name.
|
|
207
|
+
SUPATYPE_MAILER_MAILER_PROVIDER: email?.provider ?? "console",
|
|
208
|
+
}
|
|
209
|
+
const smtp = email?.smtp
|
|
210
|
+
if (email?.provider !== "smtp" || smtp === undefined) return out
|
|
211
|
+
|
|
212
|
+
if (smtp.host !== undefined && smtp.host !== "") out.SUPATYPE_SMTP_HOST = smtp.host
|
|
213
|
+
if (smtp.port !== undefined) out.SUPATYPE_SMTP_PORT = String(smtp.port)
|
|
214
|
+
if (smtp.user !== undefined && smtp.user !== "") out.SUPATYPE_SMTP_USER = smtp.user
|
|
215
|
+
if (smtp.pass !== undefined && smtp.pass !== "") out.SUPATYPE_SMTP_PASS = smtp.pass
|
|
216
|
+
if (smtp.admin_email !== undefined && smtp.admin_email !== "") {
|
|
217
|
+
out.SUPATYPE_SMTP_ADMIN_EMAIL = smtp.admin_email
|
|
218
|
+
}
|
|
219
|
+
if (smtp.sender_name !== undefined && smtp.sender_name !== "") {
|
|
220
|
+
out.SUPATYPE_SMTP_SENDER_NAME = smtp.sender_name
|
|
221
|
+
}
|
|
222
|
+
return out
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function resolveKongPort(cwd: string, composeProject?: string): Promise<number> {
|
|
226
|
+
// The compose project is passed so an already-running stack of *this* project counts as
|
|
227
|
+
// available rather than as a collision.
|
|
228
|
+
return ensureKongPort(cwd, { context: "dev", ...(composeProject !== undefined && { composeProject }) })
|
|
197
229
|
}
|
|
198
230
|
|
|
199
231
|
/**
|
|
@@ -265,7 +297,8 @@ export function upsertDevComposeEnv(
|
|
|
265
297
|
SUPATYPE_KONG_PORT: String(kongPort),
|
|
266
298
|
API_EXTERNAL_URL: apiUrl,
|
|
267
299
|
SITE_URL: apiUrl,
|
|
268
|
-
|
|
300
|
+
SUPATYPE_MAILER_AUTOCONFIRM: "true",
|
|
301
|
+
...composeMailerEnv(config),
|
|
269
302
|
...imagePins,
|
|
270
303
|
}
|
|
271
304
|
// Never for an external database: this URL describes the `db` container, which that project does
|
|
@@ -390,21 +423,98 @@ function dumpComposeDbLogs(
|
|
|
390
423
|
console.error(tail)
|
|
391
424
|
}
|
|
392
425
|
|
|
393
|
-
|
|
394
|
-
|
|
426
|
+
/**
|
|
427
|
+
* The gateway as the CLI reaches it, over IPv4.
|
|
428
|
+
*
|
|
429
|
+
* Not `localhost`. Docker publishes on `0.0.0.0`, so the IPv4 address is the one that is certainly
|
|
430
|
+
* bound, while `localhost` resolves to `::1` first on Windows and macOS. Docker Desktop does forward
|
|
431
|
+
* IPv6 through a relay, and when that relay is unhealthy it accepts the connection and then never
|
|
432
|
+
* answers, so every readiness poll hangs rather than failing.
|
|
433
|
+
*
|
|
434
|
+
* The URLs written to `.env` and printed for the operator stay on `localhost`: those are for a
|
|
435
|
+
* browser, where the name is the friendlier thing to see and the browser will fall back on its own.
|
|
436
|
+
*/
|
|
437
|
+
function loopbackBase(port: number): string {
|
|
438
|
+
return `http://127.0.0.1:${port}`
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* `fetch` with a deadline, because the built-in has none.
|
|
443
|
+
*
|
|
444
|
+
* A poll loop bounded by `maxSec` iterations is only bounded if each attempt can end. Against a
|
|
445
|
+
* socket that accepts and never responds, an un-timed `fetch` never settles, the loop never reaches
|
|
446
|
+
* its second iteration, and a wait advertised as 120 seconds runs until someone kills it. That is
|
|
447
|
+
* how `supatype push` came to hang indefinitely after reporting the schema applied.
|
|
448
|
+
*/
|
|
449
|
+
async function fetchWithin(url: string, ms: number, init?: RequestInit): Promise<Response> {
|
|
450
|
+
return await fetch(url, { ...init, signal: AbortSignal.timeout(ms) })
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Print what the stack is doing when the gateway never comes up.
|
|
455
|
+
*
|
|
456
|
+
* A 503 through the gateway means Kong is running and something behind it is
|
|
457
|
+
* not, and until now the only thing said was that the gateway was not ready.
|
|
458
|
+
* That sends people to look at Kong, which is almost never the problem, and in
|
|
459
|
+
* CI the containers are gone by the time anything else can inspect them.
|
|
460
|
+
*/
|
|
461
|
+
function reportUnhealthyStack(composePath: string, cwd: string, project: string): void {
|
|
462
|
+
const capture = (args: string[]): string => {
|
|
463
|
+
const result = spawnSync("docker", ["compose", "-p", project, "--project-directory", cwd, "-f", composePath, ...args], {
|
|
464
|
+
encoding: "utf8",
|
|
465
|
+
cwd,
|
|
466
|
+
})
|
|
467
|
+
return `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
console.error("")
|
|
471
|
+
console.error("[supatype] The gateway is up but a service behind it is not. Container state:")
|
|
472
|
+
const state = capture(["ps", "-a"])
|
|
473
|
+
console.error(state === "" ? " (no containers)" : state)
|
|
474
|
+
|
|
475
|
+
// The two the gateway health check depends on, and db because both need it.
|
|
476
|
+
for (const service of ["server", "realtime", "db"]) {
|
|
477
|
+
const logs = capture(["logs", "--no-color", "--tail", "40", service])
|
|
478
|
+
if (logs === "") continue
|
|
479
|
+
console.error("")
|
|
480
|
+
console.error(`[supatype] last 40 lines from ${service}:`)
|
|
481
|
+
console.error(logs)
|
|
482
|
+
}
|
|
483
|
+
console.error("")
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function waitKongReady(
|
|
487
|
+
kongPort: number,
|
|
488
|
+
maxSec: number,
|
|
489
|
+
stack?: { composePath: string; cwd: string; project: string },
|
|
490
|
+
): Promise<void> {
|
|
491
|
+
const base = loopbackBase(kongPort)
|
|
492
|
+
// Remembered so the failure can name the unhealthy upstream. Blaming "the
|
|
493
|
+
// Kong gateway" sent more than one investigation after Kong when Kong was
|
|
494
|
+
// fine and a service behind it was not.
|
|
495
|
+
let lastAuth = "no response"
|
|
496
|
+
let lastRealtime = "no response"
|
|
395
497
|
for (let i = 0; i < maxSec; i++) {
|
|
396
498
|
try {
|
|
397
499
|
const [auth, realtime] = await Promise.all([
|
|
398
|
-
|
|
399
|
-
|
|
500
|
+
fetchWithin(`${base}/auth/v1/health`, 2000),
|
|
501
|
+
fetchWithin(`${base}/realtime/v1/health`, 2000),
|
|
400
502
|
])
|
|
503
|
+
lastAuth = `HTTP ${auth.status}`
|
|
504
|
+
lastRealtime = `HTTP ${realtime.status}`
|
|
401
505
|
if (auth.ok && realtime.ok) return
|
|
402
|
-
} catch {
|
|
403
|
-
|
|
506
|
+
} catch (err) {
|
|
507
|
+
lastAuth = lastRealtime = err instanceof Error ? err.message : String(err)
|
|
404
508
|
}
|
|
405
509
|
await new Promise((r) => setTimeout(r, 1000))
|
|
406
510
|
}
|
|
407
|
-
|
|
511
|
+
if (stack !== undefined) {
|
|
512
|
+
reportUnhealthyStack(stack.composePath, stack.cwd, stack.project)
|
|
513
|
+
}
|
|
514
|
+
throw new Error(
|
|
515
|
+
`Gateway at ${base} did not become ready within ${maxSec}s ` +
|
|
516
|
+
`(auth/v1/health: ${lastAuth}, realtime/v1/health: ${lastRealtime})`,
|
|
517
|
+
)
|
|
408
518
|
}
|
|
409
519
|
|
|
410
520
|
/** Kong may be up while server → storage is still starting (503 or upstream errors). */
|
|
@@ -413,11 +523,11 @@ async function waitStorageApiReady(
|
|
|
413
523
|
serviceRoleKey: string,
|
|
414
524
|
maxSec: number,
|
|
415
525
|
): Promise<void> {
|
|
416
|
-
const url =
|
|
526
|
+
const url = `${loopbackBase(kongPort)}/storage/v1/bucket`
|
|
417
527
|
const headers = { Authorization: `Bearer ${serviceRoleKey}` }
|
|
418
528
|
for (let i = 0; i < maxSec; i++) {
|
|
419
529
|
try {
|
|
420
|
-
const res = await
|
|
530
|
+
const res = await fetchWithin(url, 2000, { headers })
|
|
421
531
|
if (res.ok) return
|
|
422
532
|
const body = await res.text()
|
|
423
533
|
const kongUpstreamDown = body.includes("invalid response was received from the upstream server")
|
|
@@ -440,7 +550,7 @@ async function provisionDockerStorageBuckets(
|
|
|
440
550
|
kongPort: number,
|
|
441
551
|
serviceRoleKey: string,
|
|
442
552
|
): Promise<void> {
|
|
443
|
-
await provisionBucketsFromAst(ast,
|
|
553
|
+
await provisionBucketsFromAst(ast, `${loopbackBase(kongPort)}/storage/v1`, serviceRoleKey)
|
|
444
554
|
}
|
|
445
555
|
|
|
446
556
|
let _lastPushedAst: string | null = null
|
|
@@ -767,7 +877,7 @@ export async function diffSchemaDocker(cwd: string, config: SupatypeProjectConfi
|
|
|
767
877
|
})
|
|
768
878
|
}
|
|
769
879
|
|
|
770
|
-
const kongPort = await resolveKongPort(cwd)
|
|
880
|
+
const kongPort = await resolveKongPort(cwd, project)
|
|
771
881
|
const now = Math.floor(Date.now() / 1000)
|
|
772
882
|
const jwtBase = { iss: "supatype", iat: now, exp: now + 315_360_000 }
|
|
773
883
|
const anonKey = signJwt({ ...jwtBase, role: "anon" }, devJwtSecret(cwd))
|
|
@@ -813,7 +923,7 @@ export async function pushSchemaDocker(cwd: string, config: SupatypeProjectConfi
|
|
|
813
923
|
throw new Error("pushSchemaDocker requires provider: docker")
|
|
814
924
|
}
|
|
815
925
|
const project = composeProjectName(config.project.name)
|
|
816
|
-
const kongPort = await resolveKongPort(cwd)
|
|
926
|
+
const kongPort = await resolveKongPort(cwd, project)
|
|
817
927
|
// No dev db port for an external database: `ensureDevDbPort` allocates a host port for the `db`
|
|
818
928
|
// container *and persists a matching DATABASE_URL*, which overwrote the operator's own URL, the
|
|
819
929
|
// one the whole stack and every CLI command reads.
|
|
@@ -845,7 +955,7 @@ export async function pushSchemaDocker(cwd: string, config: SupatypeProjectConfi
|
|
|
845
955
|
if (upGateway !== 0) {
|
|
846
956
|
exitComposeFailed(upGateway, "Could not start the Compose gateway stack.", pushBrand)
|
|
847
957
|
}
|
|
848
|
-
await waitKongReady(kongPort, 120)
|
|
958
|
+
await waitKongReady(kongPort, 120, { composePath: paths.composePath, cwd, project })
|
|
849
959
|
await waitStorageApiReady(kongPort, serviceRoleKey, 90)
|
|
850
960
|
await provisionDockerStorageBuckets(ast, kongPort, serviceRoleKey)
|
|
851
961
|
|
|
@@ -864,7 +974,7 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
|
|
|
864
974
|
// Per-project compose name + port isolate this project from any other Supatype
|
|
865
975
|
// stack on the machine (own containers, volumes, network, and gateway port).
|
|
866
976
|
const project = composeProjectName(config.project.name)
|
|
867
|
-
const kongPort = await resolveKongPort(cwd)
|
|
977
|
+
const kongPort = await resolveKongPort(cwd, project)
|
|
868
978
|
// No dev db port for an external database: `ensureDevDbPort` allocates a host port for the `db`
|
|
869
979
|
// container *and persists a matching DATABASE_URL*, which overwrote the operator's own URL, the
|
|
870
980
|
// one the whole stack and every CLI command reads.
|
|
@@ -1022,7 +1132,7 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
|
|
|
1022
1132
|
}
|
|
1023
1133
|
|
|
1024
1134
|
console.log("[supatype] Waiting for API gateway...")
|
|
1025
|
-
await waitKongReady(kongPort, 120)
|
|
1135
|
+
await waitKongReady(kongPort, 120, { composePath: paths.composePath, cwd, project })
|
|
1026
1136
|
console.log("[supatype] Waiting for storage API...")
|
|
1027
1137
|
await waitStorageApiReady(kongPort, serviceRoleKey, 90)
|
|
1028
1138
|
|
|
@@ -1062,7 +1172,7 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
|
|
|
1062
1172
|
cwd,
|
|
1063
1173
|
studioOverride,
|
|
1064
1174
|
pidDir,
|
|
1065
|
-
|
|
1175
|
+
anonKey,
|
|
1066
1176
|
proxyTarget: `http://localhost:${kongPort}`,
|
|
1067
1177
|
viteSupatypeUrl: `http://localhost:${STUDIO_DEV_PORT}`,
|
|
1068
1178
|
basePath: "/studio/",
|
package/src/dev-ports.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* availability so multiple projects and port collisions are surfaced clearly.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { spawnSync } from "node:child_process"
|
|
7
8
|
import { CLACK_CANCEL, isCancel, p } from "./ui/clack.js"
|
|
8
9
|
import { COMPOSE_DEV_KONG_PORT } from "./project-config.js"
|
|
9
10
|
import { isPortInUse } from "./postgres-ctl.js"
|
|
@@ -14,6 +15,41 @@ import { fatalError } from "./ui/fatal.js"
|
|
|
14
15
|
const MIN_PORT = 1024
|
|
15
16
|
const MAX_PORT = 65535
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Which compose project publishes a host port, from `docker ps` output.
|
|
20
|
+
*
|
|
21
|
+
* Separated from the spawn so the decision is testable: the question "is this port held by my own
|
|
22
|
+
* stack" is the whole point of the check, and a check that can only be exercised by starting Docker
|
|
23
|
+
* is a check nobody exercises.
|
|
24
|
+
*
|
|
25
|
+
* Docker prints one line per matching container, and a container with no compose label prints an
|
|
26
|
+
* empty line. Any line naming a different project means someone else holds the port.
|
|
27
|
+
*/
|
|
28
|
+
export function composeProjectOwnsPort(dockerPsOutput: string, project: string): boolean {
|
|
29
|
+
const owners = dockerPsOutput
|
|
30
|
+
.split("\n")
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter((line) => line.length > 0)
|
|
33
|
+
return owners.length > 0 && owners.every((owner) => owner === project)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* True when the container publishing `port` belongs to this project's compose stack.
|
|
38
|
+
*
|
|
39
|
+
* Asks Docker rather than probing the port over HTTP. A health check would answer just as happily
|
|
40
|
+
* for a *different* project's gateway on the same port, and proceeding there would push a schema
|
|
41
|
+
* into the wrong database. Refusing is the safe answer for a stranger; only our own stack is fine.
|
|
42
|
+
*/
|
|
43
|
+
export function portHeldByComposeProject(port: number, project: string): boolean {
|
|
44
|
+
const result = spawnSync(
|
|
45
|
+
"docker",
|
|
46
|
+
["ps", "--filter", `publish=${port}`, "--format", '{{.Label "com.docker.compose.project"}}'],
|
|
47
|
+
{ encoding: "utf8" },
|
|
48
|
+
)
|
|
49
|
+
if (result.status !== 0 || typeof result.stdout !== "string") return false
|
|
50
|
+
return composeProjectOwnsPort(result.stdout, project)
|
|
51
|
+
}
|
|
52
|
+
|
|
17
53
|
export function isValidHostPort(port: number): boolean {
|
|
18
54
|
return Number.isInteger(port) && port >= MIN_PORT && port <= MAX_PORT
|
|
19
55
|
}
|
|
@@ -62,6 +98,11 @@ export interface EnsureKongPortOptions {
|
|
|
62
98
|
interactive?: boolean
|
|
63
99
|
/** init wizard: slightly different copy */
|
|
64
100
|
context?: "dev" | "init"
|
|
101
|
+
/**
|
|
102
|
+
* Compose project for this directory. When given, a port published by that project is accepted
|
|
103
|
+
* rather than refused: that is this project's own stack, not a stranger's.
|
|
104
|
+
*/
|
|
105
|
+
composeProject?: string
|
|
65
106
|
}
|
|
66
107
|
|
|
67
108
|
/**
|
|
@@ -81,6 +122,13 @@ export async function ensureKongPort(
|
|
|
81
122
|
if (persisted !== null) {
|
|
82
123
|
if (!(await isPortInUse(persisted))) return persisted
|
|
83
124
|
|
|
125
|
+
// Occupied by this project's own stack is not a conflict, it is the normal state after
|
|
126
|
+
// `supatype self-host compose up -d`. Refusing here made a hand-run `up -d` followed by
|
|
127
|
+
// `push` impossible, and it stopped the validation soak, which does exactly that.
|
|
128
|
+
if (opts.composeProject !== undefined && portHeldByComposeProject(persisted, opts.composeProject)) {
|
|
129
|
+
return persisted
|
|
130
|
+
}
|
|
131
|
+
|
|
84
132
|
if (!interactive) {
|
|
85
133
|
fatalError(`Port ${persisted} is already in use (SUPATYPE_KONG_PORT in .env).`, [
|
|
86
134
|
"Stop the other service or set a different SUPATYPE_KONG_PORT.",
|
package/src/docker-postgres.ts
CHANGED
|
@@ -256,7 +256,7 @@ export const DEFAULT_SERVER_DOCKER_IMAGE = "supatype/server:latest"
|
|
|
256
256
|
* Run `supatype-server migrate` on the Postgres container network (loopback trust).
|
|
257
257
|
* Used on Windows + database.provider docker, host-published :5432 breaks libpq TLS there.
|
|
258
258
|
*/
|
|
259
|
-
export function
|
|
259
|
+
export function runAuthMigrationsViaDocker(
|
|
260
260
|
pgContainerName: string,
|
|
261
261
|
serverImage: string,
|
|
262
262
|
migrateEnv: Record<string, string>,
|
|
@@ -276,7 +276,7 @@ export function runGotrueMigrationsViaDocker(
|
|
|
276
276
|
if (result.status !== 0) {
|
|
277
277
|
const detail = (result.stderr ?? result.stdout ?? "").trim()
|
|
278
278
|
throw new Error(
|
|
279
|
-
`
|
|
279
|
+
`auth migrations failed in Docker (exit ${result.status ?? "unknown"})` +
|
|
280
280
|
(detail ? `:\n${detail}` : ""),
|
|
281
281
|
)
|
|
282
282
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine version a schema needs, checked before the engine is asked to apply it.
|
|
3
|
+
*
|
|
4
|
+
* Field bounds and model constraints compile to CHECK constraints that call helper functions in
|
|
5
|
+
* the `_supatype` schema, and only the engine creates those helpers. A CLI that emits the call
|
|
6
|
+
* against an engine that does not create the function produces this, three times, and then gives
|
|
7
|
+
* up:
|
|
8
|
+
*
|
|
9
|
+
* Failed to apply migration
|
|
10
|
+
* Caused by: error returned from database:
|
|
11
|
+
* function _supatype.richtext_text(jsonb) does not exist
|
|
12
|
+
*
|
|
13
|
+
* Which says nothing about the cause. A project on `latest` is fine; the exposed case is a pin,
|
|
14
|
+
* `versions: { engine: "0.1.9" }`, where the CLI moved and the engine did not.
|
|
15
|
+
*
|
|
16
|
+
* The check is conditional on the schema, not a flat floor: a schema that declares no bounds and
|
|
17
|
+
* no model constraints works on an older engine, and refusing it would break projects for a
|
|
18
|
+
* feature they do not use.
|
|
19
|
+
*
|
|
20
|
+
* It reads the pin rather than asking a binary for its version, because the pin is what both paths
|
|
21
|
+
* resolve from: the native provider downloads that version, and the docker provider tags the
|
|
22
|
+
* compose image with it. An unpinned project resolves to latest, which is at or above the floor by
|
|
23
|
+
* definition, so there is nothing to check.
|
|
24
|
+
*/
|
|
25
|
+
import type { ExtractedSchemaAstV2, ModelAstV2 } from "./schema-ast-v2.js"
|
|
26
|
+
|
|
27
|
+
/** First engine release that creates the `_supatype` constraint helpers (schema-engine v0.2.0). */
|
|
28
|
+
export const ENGINE_MIN_FOR_BOUNDS = "0.2.0"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compare two dotted versions numerically. Returns <0, 0 or >0.
|
|
32
|
+
*
|
|
33
|
+
* Pre-release suffixes are dropped before comparing, so `0.2.0-rc.1` counts as `0.2.0`. That is
|
|
34
|
+
* deliberate: a release candidate of the engine that creates the helpers does create them, and
|
|
35
|
+
* refusing it would send someone testing a pre-release down a false trail.
|
|
36
|
+
*/
|
|
37
|
+
export function compareVersions(a: string, b: string): number {
|
|
38
|
+
const parts = (v: string): number[] =>
|
|
39
|
+
v
|
|
40
|
+
.trim()
|
|
41
|
+
.replace(/^v/, "")
|
|
42
|
+
.split("-")[0]!
|
|
43
|
+
.split(".")
|
|
44
|
+
.map((n) => Number.parseInt(n, 10))
|
|
45
|
+
.map((n) => (Number.isNaN(n) ? 0 : n))
|
|
46
|
+
const left = parts(a)
|
|
47
|
+
const right = parts(b)
|
|
48
|
+
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
49
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0)
|
|
50
|
+
if (diff !== 0) return diff
|
|
51
|
+
}
|
|
52
|
+
return 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Field paths in the schema that declare a bound, as `model.field`, for naming them in an error. */
|
|
56
|
+
function fieldsWithBounds(models: ModelAstV2[]): string[] {
|
|
57
|
+
const found: string[] = []
|
|
58
|
+
for (const model of models) {
|
|
59
|
+
for (const [fieldName, field] of Object.entries(model.fields)) {
|
|
60
|
+
if (Object.keys(field.validation ?? {}).length > 0) found.push(`${model.name}.${fieldName}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return found
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Models declaring a constraint, which compiles to a table-level CHECK. */
|
|
67
|
+
function modelsWithConstraints(models: ModelAstV2[]): string[] {
|
|
68
|
+
return models
|
|
69
|
+
.filter((model) => (model.annotations.db.constraints ?? []).length > 0)
|
|
70
|
+
.map((model) => model.name)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Everything in the schema that needs {@link ENGINE_MIN_FOR_BOUNDS}, or an empty array.
|
|
75
|
+
*
|
|
76
|
+
* Exported for the error message and for tests: a check nobody can see the input of is a check
|
|
77
|
+
* that gets deleted the first time it is inconvenient.
|
|
78
|
+
*/
|
|
79
|
+
export function boundsRequiringHelpers(ast: ExtractedSchemaAstV2): string[] {
|
|
80
|
+
return [...fieldsWithBounds(ast.models), ...modelsWithConstraints(ast.models)]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Refuse a push whose schema needs helpers this engine does not create.
|
|
85
|
+
*
|
|
86
|
+
* Takes the pin from `versions.engine`, or undefined when unpinned. See the note above on why
|
|
87
|
+
* that is the right source rather than the binary's own `--version`.
|
|
88
|
+
*/
|
|
89
|
+
export function assertEngineSupportsSchema(
|
|
90
|
+
ast: ExtractedSchemaAstV2,
|
|
91
|
+
pinnedEngineVersion: string | undefined,
|
|
92
|
+
): void {
|
|
93
|
+
// Unpinned resolves to latest, and `local` points at a build whose version the config does not
|
|
94
|
+
// know. Neither can be compared, and neither is the case that breaks.
|
|
95
|
+
if (pinnedEngineVersion === undefined || pinnedEngineVersion === "local") return
|
|
96
|
+
if (compareVersions(pinnedEngineVersion, ENGINE_MIN_FOR_BOUNDS) >= 0) return
|
|
97
|
+
|
|
98
|
+
const needed = boundsRequiringHelpers(ast)
|
|
99
|
+
if (needed.length === 0) return
|
|
100
|
+
|
|
101
|
+
const shown = needed.slice(0, 3).join(", ")
|
|
102
|
+
const more = needed.length > 3 ? `, and ${needed.length - 3} more` : ""
|
|
103
|
+
throw new Error(
|
|
104
|
+
`This schema declares bounds that need schema-engine ${ENGINE_MIN_FOR_BOUNDS} or newer, ` +
|
|
105
|
+
`and this project pins ${pinnedEngineVersion}.\n\n` +
|
|
106
|
+
` Declared on: ${shown}${more}\n\n` +
|
|
107
|
+
`Bounds compile to CHECK constraints that call helpers in the _supatype schema, and only ` +
|
|
108
|
+
`engine ${ENGINE_MIN_FOR_BOUNDS}+ creates them. Applying this would fail inside Postgres ` +
|
|
109
|
+
`with "function _supatype.richtext_text(jsonb) does not exist".\n\n` +
|
|
110
|
+
`Raise or remove the pin in supatype.config.ts:\n` +
|
|
111
|
+
` versions: { engine: "${ENGINE_MIN_FOR_BOUNDS}" } // or omit it to track latest`,
|
|
112
|
+
)
|
|
113
|
+
}
|
package/src/link.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface ProjectLink {
|
|
|
23
23
|
defaultEnvironment: string
|
|
24
24
|
/** Access token (cloud user JWT) or self-host SERVICE_ROLE_KEY. */
|
|
25
25
|
token?: string
|
|
26
|
-
/** Cloud
|
|
26
|
+
/** Cloud auth refresh token, used to renew short-lived access JWTs. */
|
|
27
27
|
refreshToken?: string
|
|
28
28
|
orgId?: string | undefined
|
|
29
29
|
cloudApiUrl?: string
|
|
@@ -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.
|