@supatype/cli 0.3.0 → 0.3.1
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 +159 -160
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +21 -4
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/keys.d.ts +35 -0
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +90 -6
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +9 -0
- package/dist/commands/push.js.map +1 -1
- package/dist/dev-compose.d.ts +13 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +101 -9
- package/dist/dev-compose.js.map +1 -1
- package/dist/model-cache.d.ts +27 -0
- package/dist/model-cache.d.ts.map +1 -1
- package/dist/model-cache.js +61 -0
- package/dist/model-cache.js.map +1 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +47 -3
- package/dist/self-host-compose.js.map +1 -1
- package/package.json +2 -2
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/admin.ts +23 -6
- package/src/commands/keys.ts +112 -7
- package/src/commands/push.ts +11 -0
- package/src/dev-compose.ts +133 -8
- package/src/model-cache.ts +48 -0
- package/src/self-host-compose.ts +47 -3
- package/tests/keys-write.test.ts +148 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/commands/keys.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Command } from "commander"
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { resolve } from "node:path"
|
|
4
|
+
import { readEnvFile, upsertEnvFile } from "../env-file.js"
|
|
4
5
|
import { signJwt } from "../jwt.js"
|
|
5
6
|
import { error, plain } from "../ui/messages.js"
|
|
6
7
|
|
|
@@ -10,7 +11,9 @@ export function registerKeys(program: Command): void {
|
|
|
10
11
|
.description("Generate ANON_KEY and SERVICE_ROLE_KEY JWTs from your JWT_SECRET")
|
|
11
12
|
.option("--secret <secret>", "JWT secret (defaults to JWT_SECRET env var or value in .env)")
|
|
12
13
|
.option("--exp-years <years>", "Token expiry in years (default: 10)", "10")
|
|
13
|
-
.
|
|
14
|
+
.option("--write", "Write the keys into .env in the current directory instead of printing them")
|
|
15
|
+
.option("--force", "With --write, replace every key already in .env rather than filling blanks")
|
|
16
|
+
.action((opts: { secret?: string; expYears: string; write?: boolean; force?: boolean }) => {
|
|
14
17
|
const secret = opts.secret ?? resolveSecret()
|
|
15
18
|
if (!secret) {
|
|
16
19
|
error("JWT_SECRET not found. Set it in .env or pass --secret <value>")
|
|
@@ -23,19 +26,116 @@ export function registerKeys(program: Command): void {
|
|
|
23
26
|
process.exit(1)
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
const
|
|
27
|
-
const exp = now + expYears * 365 * 24 * 60 * 60
|
|
29
|
+
const { anonKey, serviceKey } = signKeyPair(secret, expYears)
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
if (opts.write) {
|
|
32
|
+
const written = writeKeysToEnv(
|
|
33
|
+
process.cwd(),
|
|
34
|
+
{ anonKey, serviceKey },
|
|
35
|
+
opts.force === true,
|
|
36
|
+
)
|
|
37
|
+
if (written.path === null) {
|
|
38
|
+
error("No .env here to write to. Create one, or drop --write to print the keys.")
|
|
39
|
+
process.exit(1)
|
|
40
|
+
}
|
|
41
|
+
plain(`\nKeys valid for ${expYears} years, written to ${written.path}:\n`)
|
|
42
|
+
// The names come back from the writer rather than being restated here, which is the whole
|
|
43
|
+
// point of there being one list.
|
|
44
|
+
for (const key of written.written) plain(` ${key}`)
|
|
45
|
+
if (written.kept.length > 0) {
|
|
46
|
+
plain(`\nLeft alone, because they already hold a value:\n`)
|
|
47
|
+
for (const key of written.kept) plain(` ${key}`)
|
|
48
|
+
plain("\nRe-run with --force to replace them.")
|
|
49
|
+
}
|
|
50
|
+
plain("\nDo not commit .env to source control.")
|
|
51
|
+
return
|
|
52
|
+
}
|
|
31
53
|
|
|
32
54
|
plain(`\nGenerated keys (valid for ${expYears} years):\n`)
|
|
33
55
|
plain("ANON_KEY=" + anonKey)
|
|
34
56
|
plain("SERVICE_ROLE_KEY=" + serviceKey)
|
|
35
|
-
plain("\nAdd these to your .env file. Do not commit .env to source control.")
|
|
57
|
+
plain("\nAdd these to your .env file, or re-run with --write. Do not commit .env to source control.")
|
|
36
58
|
})
|
|
37
59
|
}
|
|
38
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Every env name a minted key pair is written to, in one place.
|
|
63
|
+
*
|
|
64
|
+
* Shared with `supatype dev`, which writes the same pair when it starts a stack. They used to hold
|
|
65
|
+
* separate lists, so a front end's prefix could be written by one and forgotten by the other, and
|
|
66
|
+
* which names your `.env` ended up with depended on whichever command you happened to run last.
|
|
67
|
+
*
|
|
68
|
+
* The URL names are only included when there is a URL to write, because an empty
|
|
69
|
+
* `VITE_SUPATYPE_URL` is worse than an absent one: the client reads it, finds a blank string, and
|
|
70
|
+
* requests against the page origin without saying why.
|
|
71
|
+
*/
|
|
72
|
+
export function anonKeyEnvUpdates(
|
|
73
|
+
anonKey: string,
|
|
74
|
+
serviceKey: string,
|
|
75
|
+
apiUrl?: string | undefined,
|
|
76
|
+
): Record<string, string> {
|
|
77
|
+
return {
|
|
78
|
+
ANON_KEY: anonKey,
|
|
79
|
+
SERVICE_ROLE_KEY: serviceKey,
|
|
80
|
+
VITE_SUPATYPE_ANON_KEY: anonKey,
|
|
81
|
+
PUBLIC_SUPATYPE_ANON_KEY: anonKey,
|
|
82
|
+
EXPO_PUBLIC_SUPATYPE_ANON_KEY: anonKey,
|
|
83
|
+
...(apiUrl !== undefined && {
|
|
84
|
+
VITE_SUPATYPE_URL: apiUrl,
|
|
85
|
+
PUBLIC_SUPATYPE_URL: apiUrl,
|
|
86
|
+
EXPO_PUBLIC_SUPATYPE_URL: apiUrl,
|
|
87
|
+
}),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface WriteKeysResult {
|
|
92
|
+
/** The .env written, or null when there is none here. */
|
|
93
|
+
path: string | null
|
|
94
|
+
written: string[]
|
|
95
|
+
kept: string[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Write a minted pair into `dir`'s .env.
|
|
100
|
+
*
|
|
101
|
+
* Blanks only by default. An anon key already in `.env` is held by clients this command cannot
|
|
102
|
+
* reach, and reissuing it locks them out, so filling what is empty is the useful half and
|
|
103
|
+
* replacing what is not is a decision someone has to make out loud. `--force` is how they make it,
|
|
104
|
+
* and it rewrites every name this owns rather than only the empty ones.
|
|
105
|
+
*
|
|
106
|
+
* `--force` does not rewrite the file. `.env` also holds `JWT_SECRET`, which these keys are
|
|
107
|
+
* derived from, along with the database password and whatever else the project keeps there;
|
|
108
|
+
* replacing the file would leave a project whose keys cannot be regenerated and whose stack cannot
|
|
109
|
+
* start. Every line this command does not own is left exactly as it was, comments included.
|
|
110
|
+
*/
|
|
111
|
+
export function writeKeysToEnv(
|
|
112
|
+
dir: string,
|
|
113
|
+
keys: { anonKey: string; serviceKey: string },
|
|
114
|
+
force: boolean,
|
|
115
|
+
): WriteKeysResult {
|
|
116
|
+
const envPath = resolve(dir, ".env")
|
|
117
|
+
if (!existsSync(envPath)) return { path: null, written: [], kept: [] }
|
|
118
|
+
|
|
119
|
+
const existing = readEnvFile(dir)
|
|
120
|
+
const apiUrl = existing["PUBLIC_SUPATYPE_URL"] || existing["API_EXTERNAL_URL"] || undefined
|
|
121
|
+
const candidates = anonKeyEnvUpdates(keys.anonKey, keys.serviceKey, apiUrl)
|
|
122
|
+
|
|
123
|
+
const updates: Record<string, string> = {}
|
|
124
|
+
const kept: string[] = []
|
|
125
|
+
for (const [name, value] of Object.entries(candidates)) {
|
|
126
|
+
// Blank counts as absent: a template ships `ANON_KEY=` and that is exactly the line to fill.
|
|
127
|
+
const current = existing[name]?.trim() ?? ""
|
|
128
|
+
if (!force && current !== "") {
|
|
129
|
+
kept.push(name)
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
updates[name] = value
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (Object.keys(updates).length > 0) upsertEnvFile(dir, updates)
|
|
136
|
+
return { path: envPath, written: Object.keys(updates), kept }
|
|
137
|
+
}
|
|
138
|
+
|
|
39
139
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
40
140
|
|
|
41
141
|
/** Mint a long-lived anon + service_role JWT pair from a secret. */
|
|
@@ -91,6 +191,11 @@ function upsertEnvVar(content: string, key: string, value: string): string {
|
|
|
91
191
|
return `${content}${sep}${key}=${value}\n`
|
|
92
192
|
}
|
|
93
193
|
|
|
194
|
+
/** Whether `.env` declares `key` at all, with or without a value. */
|
|
195
|
+
function hasEnvLine(content: string, key: string): boolean {
|
|
196
|
+
return new RegExp(`^${key}=`, "m").test(content)
|
|
197
|
+
}
|
|
198
|
+
|
|
94
199
|
function readEnvVar(content: string, key: string): string | undefined {
|
|
95
200
|
const re = new RegExp(`^${key}=(.*)$`, "m")
|
|
96
201
|
const match = re.exec(content)
|
package/src/commands/push.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
validateModelValidators,
|
|
10
10
|
writeHooksModule,
|
|
11
11
|
} from "../model-hooks.js"
|
|
12
|
+
import { syncRowCacheEnv } from "../model-cache.js"
|
|
12
13
|
import { adapterEntry, readHookUpload } from "../hook-upload.js"
|
|
13
14
|
import { checkServiceRoleRoutes, serviceRoleProblemLines } from "../service-role-check.js"
|
|
14
15
|
import { fatalError } from "../ui/fatal.js"
|
|
@@ -295,6 +296,16 @@ async function generateTypesLocal(ast: unknown, config: SupatypeProjectConfig):
|
|
|
295
296
|
if (hooksPath !== null) info(`Hook handler types written to ${hooksPath}`)
|
|
296
297
|
// The server watches this file, so a changed hook takes effect without a restart.
|
|
297
298
|
if (syncManifestHooks(cwd, ast)) info("Hook map written to .supatype/manifest.json")
|
|
299
|
+
// The row cache is configured at postmaster start, so this is the one cache setting a push
|
|
300
|
+
// cannot make take effect on its own.
|
|
301
|
+
const rowCache = syncRowCacheEnv(cwd, ast)
|
|
302
|
+
if (rowCache !== null) {
|
|
303
|
+
info(
|
|
304
|
+
`Row cache switched ${rowCache} in .env. Recreate the database container for it to take ` +
|
|
305
|
+
"effect: the image writes pg_keyspace.conf at start, so a running Postgres keeps the " +
|
|
306
|
+
"setting it booted with.",
|
|
307
|
+
)
|
|
308
|
+
}
|
|
298
309
|
|
|
299
310
|
if (!config.output?.types && !config.output?.client) return
|
|
300
311
|
// The CLI writes these, it does not ask the engine to. Passing types_path and client_path and
|
package/src/dev-compose.ts
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
type SupatypeProjectConfig,
|
|
22
22
|
} from "./project-config.js"
|
|
23
23
|
import { syncManifestHooks, writeHooksModule } from "./model-hooks.js"
|
|
24
|
+
import { syncRowCacheEnv } from "./model-cache.js"
|
|
25
|
+
import { anonKeyEnvUpdates } from "./commands/keys.js"
|
|
24
26
|
import { signJwt } from "./jwt.js"
|
|
25
27
|
import { ensureDevDbPort, ensureKongPort } from "./dev-ports.js"
|
|
26
28
|
import { handleComposeProjectRename } from "./compose-rename.js"
|
|
@@ -35,6 +37,7 @@ import {
|
|
|
35
37
|
runDockerCompose,
|
|
36
38
|
schemaEngineImageForPush,
|
|
37
39
|
writeSelfHostCompose,
|
|
40
|
+
selfHostComposePaths,
|
|
38
41
|
type SelfHostComposePaths,
|
|
39
42
|
} from "./self-host-compose.js"
|
|
40
43
|
import type { DockerBrandOptions } from "./docker-runtime.js"
|
|
@@ -288,13 +291,13 @@ export function upsertDevComposeEnv(
|
|
|
288
291
|
...seedMissingLocalSecrets(cwd),
|
|
289
292
|
// Project configuration, seeded not overwritten, see seedMissingDatabaseIdentity.
|
|
290
293
|
...seedMissingDatabaseIdentity(cwd),
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
294
|
+
// Shared with `supatype keys --write`, so a front end's prefix cannot be written by one and
|
|
295
|
+
// forgotten by the other. See `anonKeyEnvUpdates`.
|
|
296
|
+
//
|
|
297
|
+
// This list used to live here as well, and the two had already drifted: `dev` wrote
|
|
298
|
+
// PUBLIC_ and EXPO_PUBLIC_ URLs and no VITE_SUPATYPE_URL, so a Vite app whose `.env` had only
|
|
299
|
+
// ever been touched by `supatype dev` built against an empty string.
|
|
300
|
+
...anonKeyEnvUpdates(anonKey, serviceRoleKey, apiUrl),
|
|
298
301
|
SUPATYPE_KONG_PORT: String(kongPort),
|
|
299
302
|
API_EXTERNAL_URL: apiUrl,
|
|
300
303
|
SITE_URL: apiUrl,
|
|
@@ -594,12 +597,42 @@ async function refreshSchemaArtifacts(
|
|
|
594
597
|
if (syncManifestHooks(cwd, ast)) {
|
|
595
598
|
console.log("[supatype] Hook and validator maps written to .supatype/manifest.json")
|
|
596
599
|
}
|
|
600
|
+
// Written before the stack comes up, so a first run with a row cache declared starts with it on
|
|
601
|
+
// rather than needing a second `supatype dev`.
|
|
602
|
+
const rowCache = syncRowCacheEnv(cwd, ast)
|
|
603
|
+
if (rowCache !== null) {
|
|
604
|
+
console.log(`[supatype] Row cache switched ${rowCache} in .env (pg_keyspace Mode B).`)
|
|
605
|
+
}
|
|
597
606
|
|
|
598
607
|
try {
|
|
599
608
|
await ensureEngine()
|
|
600
609
|
} catch (err) {
|
|
610
|
+
// The host engine is a CDN download and this machine could not complete it. The schema is
|
|
611
|
+
// already applied, so fall back to the engine in compose rather than giving up: without
|
|
612
|
+
// admin-config.json Studio reports "No schema has been pushed yet" on a stack whose schema is
|
|
613
|
+
// entirely live, which reads as a failed push rather than a missing binary.
|
|
614
|
+
const paths = selfHostComposePaths(cwd)
|
|
615
|
+
const composeProject = composeProjectName(config.project.name)
|
|
616
|
+
const wrote = await generateViaComposeEngine(
|
|
617
|
+
paths,
|
|
618
|
+
cwd,
|
|
619
|
+
composeProject,
|
|
620
|
+
config,
|
|
621
|
+
ast,
|
|
622
|
+
adminConfigPath,
|
|
623
|
+
).catch(() => false)
|
|
624
|
+
if (wrote) {
|
|
625
|
+
console.warn(
|
|
626
|
+
`[supatype] Host engine unavailable (${(err as Error).message}); used the in-compose ` +
|
|
627
|
+
"engine instead.",
|
|
628
|
+
)
|
|
629
|
+
return
|
|
630
|
+
}
|
|
601
631
|
console.warn(
|
|
602
|
-
`[supatype] Host engine unavailable, admin/types not refreshed: ${(err as Error).message}
|
|
632
|
+
`[supatype] Host engine unavailable, admin/types not refreshed: ${(err as Error).message}
|
|
633
|
+
` +
|
|
634
|
+
"[supatype] Studio will report no schema until this succeeds, even though the schema is " +
|
|
635
|
+
"applied. Retry with the stack up, or run `supatype push`.",
|
|
603
636
|
)
|
|
604
637
|
return
|
|
605
638
|
}
|
|
@@ -1333,3 +1366,95 @@ function grantAuthSchemaAccess(
|
|
|
1333
1366
|
console.warn("[supatype] Could not grant service_role access to auth.users, Studio relation preview may fail.")
|
|
1334
1367
|
}
|
|
1335
1368
|
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Run one read-only generator in the in-compose schema-engine and return its stdout.
|
|
1372
|
+
*
|
|
1373
|
+
* The same image and the same bind mount the push just used, so if the schema could be applied
|
|
1374
|
+
* this can run. It exists because the host engine is a separate binary fetched from a CDN, and a
|
|
1375
|
+
* machine that cannot fetch it is not a machine that cannot generate: the schema is already
|
|
1376
|
+
* applied, the AST is already on disk, and the container that did it is one `docker compose run`
|
|
1377
|
+
* away.
|
|
1378
|
+
*/
|
|
1379
|
+
async function runComposeEngineGenerator(
|
|
1380
|
+
paths: SelfHostComposePaths,
|
|
1381
|
+
cwd: string,
|
|
1382
|
+
composeProject: string,
|
|
1383
|
+
config: SupatypeProjectConfig,
|
|
1384
|
+
args: readonly string[],
|
|
1385
|
+
): Promise<string | null> {
|
|
1386
|
+
const envFile = resolve(cwd, ".env")
|
|
1387
|
+
const composeArgs = ["compose", "--progress", "quiet"]
|
|
1388
|
+
if (composeProject) composeArgs.push("-p", composeProject)
|
|
1389
|
+
composeArgs.push("--project-directory", cwd)
|
|
1390
|
+
composeArgs.push("-f", paths.composePath)
|
|
1391
|
+
if (existsSync(envFile)) composeArgs.push("--env-file", envFile)
|
|
1392
|
+
composeArgs.push(
|
|
1393
|
+
"--profile",
|
|
1394
|
+
"tools",
|
|
1395
|
+
"run",
|
|
1396
|
+
"--rm",
|
|
1397
|
+
"-T",
|
|
1398
|
+
"schema-engine",
|
|
1399
|
+
...args,
|
|
1400
|
+
"-i",
|
|
1401
|
+
"/project/.supatype/schema.ast.json",
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
const env: NodeJS.ProcessEnv = { ...process.env, COMPOSE_PROGRESS: "quiet" }
|
|
1405
|
+
const engineImage = await schemaEngineImageForPush(config)
|
|
1406
|
+
if (engineImage) env.SUPATYPE_ENGINE_IMAGE = engineImage
|
|
1407
|
+
|
|
1408
|
+
const result = spawnSync("docker", composeArgs, {
|
|
1409
|
+
cwd,
|
|
1410
|
+
encoding: "utf8",
|
|
1411
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
1412
|
+
env,
|
|
1413
|
+
})
|
|
1414
|
+
if ((result.status ?? 1) !== 0) return null
|
|
1415
|
+
return String(result.stdout ?? "")
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Write `admin-config.json` and the generated types using the in-compose engine.
|
|
1420
|
+
*
|
|
1421
|
+
* The fallback for a host engine that could not be fetched. Without it, a `supatype dev` that
|
|
1422
|
+
* applied the schema perfectly well still left no `admin-config.json`, and Studio reads that file
|
|
1423
|
+
* to decide whether a schema exists: the stack came up, every table was live, the API served them,
|
|
1424
|
+
* and Studio said **"No schema has been pushed yet."** The warning that preceded it named the
|
|
1425
|
+
* download, not the consequence, so nothing connected the two.
|
|
1426
|
+
*
|
|
1427
|
+
* Returns true when the admin config was written, which is the file Studio actually needs.
|
|
1428
|
+
*/
|
|
1429
|
+
export async function generateViaComposeEngine(
|
|
1430
|
+
paths: SelfHostComposePaths,
|
|
1431
|
+
cwd: string,
|
|
1432
|
+
composeProject: string,
|
|
1433
|
+
config: SupatypeProjectConfig,
|
|
1434
|
+
ast: unknown,
|
|
1435
|
+
adminConfigPath: string,
|
|
1436
|
+
): Promise<boolean> {
|
|
1437
|
+
const typesPath = config.output?.types
|
|
1438
|
+
if (typeof typesPath === "string" && typesPath.trim().length > 0) {
|
|
1439
|
+
const out = await runComposeEngineGenerator(paths, cwd, composeProject, config, ["generate"])
|
|
1440
|
+
const marker = out?.indexOf("// Generated by supatype-engine") ?? -1
|
|
1441
|
+
if (out && out.includes("export type")) {
|
|
1442
|
+
const ts = (marker >= 0 ? out.slice(marker) : out).trimStart()
|
|
1443
|
+
const hostPath = join(cwd, typesPath)
|
|
1444
|
+
mkdirSync(dirname(hostPath), { recursive: true })
|
|
1445
|
+
writeFileSync(hostPath, ts)
|
|
1446
|
+
console.log(`[supatype] Types written to ${typesPath} (in-compose engine).`)
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const adminOut = await runComposeEngineGenerator(paths, cwd, composeProject, config, ["admin"])
|
|
1451
|
+
if (!adminOut) return false
|
|
1452
|
+
const parsed = parseEngineJsonOutput<unknown>(adminOut)
|
|
1453
|
+
if (parsed === null) return false
|
|
1454
|
+
|
|
1455
|
+
const admin = withAdminRoles(parsed, config)
|
|
1456
|
+
restoreSystemRelationTargets(admin, ast)
|
|
1457
|
+
writeFileSync(adminConfigPath, `${JSON.stringify(admin, null, 2)}\n`)
|
|
1458
|
+
console.log("[supatype] Admin config written to .supatype/admin-config.json (in-compose engine).")
|
|
1459
|
+
return true
|
|
1460
|
+
}
|
package/src/model-cache.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* `hooks` has always taken this route for the same reason. Plan §13.1 said otherwise and said to
|
|
17
17
|
* verify before relying on it; this is the verified path.
|
|
18
18
|
*/
|
|
19
|
+
import { readEnvValue, upsertEnvFile } from "./env-file.js"
|
|
19
20
|
import type { ModelCacheAst } from "./schema-ast-v2.js"
|
|
20
21
|
|
|
21
22
|
/** One table's cache declaration, as the manifest carries it. */
|
|
@@ -72,3 +73,50 @@ export function manifestCache(ast: unknown): Record<string, ManifestCacheEntry>
|
|
|
72
73
|
}
|
|
73
74
|
return out
|
|
74
75
|
}
|
|
76
|
+
|
|
77
|
+
// ─── Row cache enablement ────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** The two env names the Postgres image reads to turn Mode B on. */
|
|
80
|
+
export const ROWCACHE_DECODE_ENV = "SUPATYPE_KEYSPACE_ROWCACHE_DECODE"
|
|
81
|
+
export const ROWCACHE_READTHROUGH_ENV = "SUPATYPE_KEYSPACE_ROWCACHE_READTHROUGH"
|
|
82
|
+
|
|
83
|
+
/** Whether any model declares `cache: { rows: true }`. */
|
|
84
|
+
export function declaresRowCache(ast: unknown): boolean {
|
|
85
|
+
return Object.values(manifestCache(ast)).some((entry) => entry.rows === true)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Keep the row cache's two switches in `.env` matching what the schema declares.
|
|
90
|
+
*
|
|
91
|
+
* `cache: { rows: true }` registers a table with the row cache, and registration alone serves
|
|
92
|
+
* nothing: Mode B needs `pg_keyspace.rowcache_decode` for the invalidation worker and
|
|
93
|
+
* `rowcache_readthrough` to fill on a primary-key miss. Both are written by the image's entrypoint
|
|
94
|
+
* from these variables, before any server starts, so they cannot be switched at runtime and cannot
|
|
95
|
+
* be decided by the compose file alone: only a push knows whether any model declares `rows`.
|
|
96
|
+
*
|
|
97
|
+
* Without this the stack came up with the row-cache segment reserved, the tables registered, both
|
|
98
|
+
* switches off and nothing anywhere saying so. Studio's panel was the only thing that reported it,
|
|
99
|
+
* and it reported it as an operator's missing configuration rather than as a push that had not
|
|
100
|
+
* finished the job.
|
|
101
|
+
*
|
|
102
|
+
* Written to `.env` rather than baked into the compose file for the same reason `SUPATYPE_KONG_PORT`
|
|
103
|
+
* is: `self-host compose render` runs with no AST in hand, so a value the compose file hardcoded
|
|
104
|
+
* would be whatever the last render guessed.
|
|
105
|
+
*
|
|
106
|
+
* Returns the new state when it changed, and null when it did not. A change needs the database
|
|
107
|
+
* container recreated, which the caller is the one that can say.
|
|
108
|
+
*/
|
|
109
|
+
export function syncRowCacheEnv(cwd: string, ast: unknown): "on" | "off" | null {
|
|
110
|
+
const want = declaresRowCache(ast)
|
|
111
|
+
const value = want ? "1" : "0"
|
|
112
|
+
|
|
113
|
+
const current = readEnvValue(cwd, ROWCACHE_DECODE_ENV, "")
|
|
114
|
+
const currentThrough = readEnvValue(cwd, ROWCACHE_READTHROUGH_ENV, "")
|
|
115
|
+
if (current === value && currentThrough === value) return null
|
|
116
|
+
|
|
117
|
+
upsertEnvFile(cwd, {
|
|
118
|
+
[ROWCACHE_DECODE_ENV]: value,
|
|
119
|
+
[ROWCACHE_READTHROUGH_ENV]: value,
|
|
120
|
+
})
|
|
121
|
+
return want ? "on" : "off"
|
|
122
|
+
}
|
package/src/self-host-compose.ts
CHANGED
|
@@ -402,18 +402,29 @@ ${studioService}
|
|
|
402
402
|
- "127.0.0.1:\${SUPATYPE_DEV_DB_PORT:-54329}:5432"
|
|
403
403
|
`
|
|
404
404
|
: ` ports:
|
|
405
|
-
- "5432:5432"
|
|
405
|
+
- "\${SUPATYPE_DB_PORT:-5432}:5432"
|
|
406
406
|
`
|
|
407
407
|
: ""
|
|
408
|
+
// Host ports, every one of them overridable.
|
|
409
|
+
//
|
|
410
|
+
// Kong's has been `${SUPATYPE_KONG_PORT:-18473}` for as long as `supatype dev` has picked a free
|
|
411
|
+
// one per project and written it back to .env. These three were fixed literals, so the second
|
|
412
|
+
// project on a machine could not start: Docker refuses the bind and compose reports only that
|
|
413
|
+
// the stack would not come up, naming no port. Two projects side by side is the normal case
|
|
414
|
+
// here (this repository ships eight examples), so a literal is the wrong default.
|
|
415
|
+
//
|
|
416
|
+
// Defaults are the previous literals, so a project with none of these set behaves exactly as
|
|
417
|
+
// before. Allocating them per project the way the Kong port is allocated is the follow-up; this
|
|
418
|
+
// makes a second stack possible rather than automatic.
|
|
408
419
|
const serverPorts = devLocal
|
|
409
420
|
? ""
|
|
410
421
|
: ` ports:
|
|
411
|
-
- "9999:9999"
|
|
422
|
+
- "\${SUPATYPE_SERVER_PORT:-9999}:9999"
|
|
412
423
|
`
|
|
413
424
|
const seaweedPorts = devLocal
|
|
414
425
|
? ""
|
|
415
426
|
: ` ports:
|
|
416
|
-
- "8333:8333"
|
|
427
|
+
- "\${SUPATYPE_SEAWEEDFS_PORT:-8333}:8333"
|
|
417
428
|
`
|
|
418
429
|
// One source for the credentials: the server is configured with them and the storage service is
|
|
419
430
|
// handed them, and a mismatch does not fail at start, it fails at the first upload.
|
|
@@ -560,6 +571,29 @@ ${dbDependency}`
|
|
|
560
571
|
SUPATYPE_KEYSPACE_KEYS: "200000"
|
|
561
572
|
SUPATYPE_KEYSPACE_RING_MB: "16"
|
|
562
573
|
SUPATYPE_KEYSPACE_ROWCACHE_MB: "64"
|
|
574
|
+
# Mode B, the row cache itself. The segment above is reserved either way; these two decide
|
|
575
|
+
# whether anything decodes into it or serves from it, and the image refuses readthrough
|
|
576
|
+
# without decode because that pair serves stale rows forever rather than merely wasting
|
|
577
|
+
# memory.
|
|
578
|
+
#
|
|
579
|
+
# From .env rather than a literal, because only a push can answer this: the switches follow
|
|
580
|
+
# whether any model declares \`cache: { rows: true }\`, and \`self-host compose render\` runs
|
|
581
|
+
# with no schema in hand. Default off, so a project that declares no row cache does not pin
|
|
582
|
+
# WAL behind a replication slot it never reads.
|
|
583
|
+
SUPATYPE_KEYSPACE_ROWCACHE_DECODE: "\${SUPATYPE_KEYSPACE_ROWCACHE_DECODE:-0}"
|
|
584
|
+
SUPATYPE_KEYSPACE_ROWCACHE_READTHROUGH: "\${SUPATYPE_KEYSPACE_ROWCACHE_READTHROUGH:-0}"
|
|
585
|
+
# Both decoders this stack runs, because the image's setting REPLACES the allowlist.
|
|
586
|
+
#
|
|
587
|
+
# Turning the row cache on makes the entrypoint write
|
|
588
|
+
# \`output_plugin_libraries = 'supacache_keys'\`, and PostgreSQL then refuses every other
|
|
589
|
+
# plugin. Realtime decodes with wal2json, so a project that declared \`cache: { rows: true }\`
|
|
590
|
+
# silently lost realtime: the service stayed up, answered /health/ready with 200, and logged
|
|
591
|
+
# \`library "wal2json" may not be used as an output plugin\` once a second while every
|
|
592
|
+
# subscription reported SUBSCRIBED and delivered nothing.
|
|
593
|
+
#
|
|
594
|
+
# Named here rather than left to the image because only this file knows both features are in
|
|
595
|
+
# the same stack.
|
|
596
|
+
SUPATYPE_KEYSPACE_OUTPUT_PLUGIN_LIBRARIES: "supacache_keys, wal2json"
|
|
563
597
|
`
|
|
564
598
|
: ""
|
|
565
599
|
|
|
@@ -705,7 +739,17 @@ ${dbDependency}
|
|
|
705
739
|
# stays visible instead of hiding in a crash loop.
|
|
706
740
|
restart: on-failure:5
|
|
707
741
|
${serverPorts} volumes:
|
|
742
|
+
# The project is read-only: the server reads the schema, the manifest and the functions, and
|
|
743
|
+
# has no business editing any of them.
|
|
708
744
|
- ${projectMount}:/project:ro
|
|
745
|
+
# .supatype is the exception, and only because one file in it is not project source.
|
|
746
|
+
# api-config.json is the operator's runtime state: which tables have caching switched on,
|
|
747
|
+
# the project TTL, max_rows. PATCH /admin/v1/config/rest writes it, which is what Studio's
|
|
748
|
+
# cache panel and the CLI's cache commands call. Under the read-only mount alone that PATCH
|
|
749
|
+
# fails with "read-only file system", so a cache a model declares can be read back as
|
|
750
|
+
# declared and never actually switched on. A narrower bind than making the whole project
|
|
751
|
+
# writable, because the rest of the tree keeps the guarantee.
|
|
752
|
+
- ${projectMount}/.supatype:/project/.supatype
|
|
709
753
|
working_dir: /project
|
|
710
754
|
environment:
|
|
711
755
|
SUPATYPE_MODE: ${devLocal ? "dev" : "standalone"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `supatype keys --write` writes the pair it mints.
|
|
3
|
+
*
|
|
4
|
+
* The command printed the keys and wrote nothing, while every README in this repository says it
|
|
5
|
+
* mints them into `.env`. Following the docs therefore produced a front end built with
|
|
6
|
+
* `anonKey: undefined` and a gateway refusing its every request, with nothing in the output saying
|
|
7
|
+
* so.
|
|
8
|
+
*
|
|
9
|
+
* Writing is opt-in rather than the default: a command that reads a secret and edits a file on
|
|
10
|
+
* sight is worse than one that has to be asked. What it writes is blanks only, because an anon key
|
|
11
|
+
* already in `.env` is held by clients this command cannot reach and reissuing it locks them out.
|
|
12
|
+
* `--force` is how someone says they meant to rotate.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
15
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"
|
|
16
|
+
import { tmpdir } from "node:os"
|
|
17
|
+
import { join } from "node:path"
|
|
18
|
+
import { anonKeyEnvUpdates, writeKeysToEnv } from "../src/commands/keys.js"
|
|
19
|
+
|
|
20
|
+
const PAIR = { anonKey: "anon.jwt.value", serviceKey: "service.jwt.value" }
|
|
21
|
+
|
|
22
|
+
function envValue(dir: string, key: string): string | undefined {
|
|
23
|
+
const line = readFileSync(join(dir, ".env"), "utf8")
|
|
24
|
+
.split("\n")
|
|
25
|
+
.find((l) => l.startsWith(`${key}=`))
|
|
26
|
+
return line?.slice(key.length + 1).trim()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("writeKeysToEnv", () => {
|
|
30
|
+
let dir: string
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
dir = mkdtempSync(join(tmpdir(), "supatype-keys-"))
|
|
34
|
+
})
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
rmSync(dir, { recursive: true, force: true })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("fills blank keys", () => {
|
|
40
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nANON_KEY=\nSERVICE_ROLE_KEY=\n")
|
|
41
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
42
|
+
|
|
43
|
+
expect(result.written).toContain("ANON_KEY")
|
|
44
|
+
expect(result.written).toContain("SERVICE_ROLE_KEY")
|
|
45
|
+
expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
|
|
46
|
+
expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("adds keys that are absent entirely", () => {
|
|
50
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\n")
|
|
51
|
+
writeKeysToEnv(dir, PAIR, false)
|
|
52
|
+
|
|
53
|
+
expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
|
|
54
|
+
expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("treats a declared-but-blank line as a blank to fill", () => {
|
|
58
|
+
// The templates ship `ANON_KEY=` with nothing after it, which is exactly the line to write.
|
|
59
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nVITE_SUPATYPE_ANON_KEY=\n")
|
|
60
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
61
|
+
|
|
62
|
+
expect(result.written).toContain("VITE_SUPATYPE_ANON_KEY")
|
|
63
|
+
expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("leaves a key that already holds a value, and says which", () => {
|
|
67
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nANON_KEY=in-use-by-clients\nSERVICE_ROLE_KEY=\n")
|
|
68
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
69
|
+
|
|
70
|
+
expect(result.kept).toContain("ANON_KEY")
|
|
71
|
+
expect(result.written).not.toContain("ANON_KEY")
|
|
72
|
+
expect(envValue(dir, "ANON_KEY")).toBe("in-use-by-clients")
|
|
73
|
+
expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("replaces every key it owns under --force, not only the blank ones", () => {
|
|
77
|
+
writeFileSync(
|
|
78
|
+
join(dir, ".env"),
|
|
79
|
+
"JWT_SECRET=s\nANON_KEY=old-anon\nSERVICE_ROLE_KEY=old-service\nVITE_SUPATYPE_ANON_KEY=old-vite\n",
|
|
80
|
+
)
|
|
81
|
+
const result = writeKeysToEnv(dir, PAIR, true)
|
|
82
|
+
|
|
83
|
+
expect(result.kept).toEqual([])
|
|
84
|
+
expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
|
|
85
|
+
expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
|
|
86
|
+
expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("does not rewrite the file, even under --force", () => {
|
|
90
|
+
// `.env` holds JWT_SECRET, which these keys are derived from, plus the database password and
|
|
91
|
+
// whatever else the project keeps there. Replacing the file would leave a project whose keys
|
|
92
|
+
// cannot be regenerated and whose stack cannot start.
|
|
93
|
+
writeFileSync(
|
|
94
|
+
join(dir, ".env"),
|
|
95
|
+
"# a comment someone wrote\nJWT_SECRET=s\nPOSTGRES_PASSWORD=pw\nANON_KEY=old\nCUSTOM_THING=mine\n",
|
|
96
|
+
)
|
|
97
|
+
writeKeysToEnv(dir, PAIR, true)
|
|
98
|
+
|
|
99
|
+
const content = readFileSync(join(dir, ".env"), "utf8")
|
|
100
|
+
expect(content).toContain("# a comment someone wrote")
|
|
101
|
+
expect(envValue(dir, "JWT_SECRET")).toBe("s")
|
|
102
|
+
expect(envValue(dir, "POSTGRES_PASSWORD")).toBe("pw")
|
|
103
|
+
expect(envValue(dir, "CUSTOM_THING")).toBe("mine")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("carries the API URL through when the project records one", () => {
|
|
107
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nPUBLIC_SUPATYPE_URL=http://localhost:18475\n")
|
|
108
|
+
writeKeysToEnv(dir, PAIR, true)
|
|
109
|
+
|
|
110
|
+
expect(envValue(dir, "VITE_SUPATYPE_URL")).toBe("http://localhost:18475")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("writes no URL names when the project records no URL", () => {
|
|
114
|
+
// An empty `VITE_SUPATYPE_URL` is worse than an absent one: the client reads it, finds a blank
|
|
115
|
+
// string, and requests against the page origin without saying why.
|
|
116
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\n")
|
|
117
|
+
writeKeysToEnv(dir, PAIR, false)
|
|
118
|
+
|
|
119
|
+
expect(readFileSync(join(dir, ".env"), "utf8")).not.toContain("VITE_SUPATYPE_URL")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("reports no .env rather than creating one", () => {
|
|
123
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
124
|
+
|
|
125
|
+
expect(result.path).toBeNull()
|
|
126
|
+
expect(result.written).toEqual([])
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe("anonKeyEnvUpdates", () => {
|
|
131
|
+
it("is the one list both `keys` and `dev` write from", () => {
|
|
132
|
+
// They held separate lists, so a front end's prefix could be written by one and forgotten by
|
|
133
|
+
// the other, and which names a project's .env ended up with depended on which command ran last.
|
|
134
|
+
const names = Object.keys(anonKeyEnvUpdates("a", "s"))
|
|
135
|
+
expect(names).toEqual([
|
|
136
|
+
"ANON_KEY",
|
|
137
|
+
"SERVICE_ROLE_KEY",
|
|
138
|
+
"VITE_SUPATYPE_ANON_KEY",
|
|
139
|
+
"PUBLIC_SUPATYPE_ANON_KEY",
|
|
140
|
+
"EXPO_PUBLIC_SUPATYPE_ANON_KEY",
|
|
141
|
+
])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("adds the URL names only when given a URL", () => {
|
|
145
|
+
expect(Object.keys(anonKeyEnvUpdates("a", "s", "http://x"))).toContain("VITE_SUPATYPE_URL")
|
|
146
|
+
expect(Object.keys(anonKeyEnvUpdates("a", "s"))).not.toContain("VITE_SUPATYPE_URL")
|
|
147
|
+
})
|
|
148
|
+
})
|