@supatype/cli 0.2.1 → 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 +193 -157
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/api-config-cache.d.ts +34 -0
- package/dist/api-config-cache.d.ts.map +1 -0
- package/dist/api-config-cache.js +123 -0
- package/dist/api-config-cache.js.map +1 -0
- package/dist/cache-identity-scope.d.ts +38 -0
- package/dist/cache-identity-scope.d.ts.map +1 -0
- package/dist/cache-identity-scope.js +70 -0
- package/dist/cache-identity-scope.js.map +1 -0
- package/dist/cache-provider.d.ts +30 -0
- package/dist/cache-provider.d.ts.map +1 -0
- package/dist/cache-provider.js +60 -0
- package/dist/cache-provider.js.map +1 -0
- package/dist/cli-version-embedded.js +1 -1
- package/dist/commands/add.js +1 -1
- package/dist/commands/add.js.map +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/cache.d.ts +1 -1
- package/dist/commands/cache.js +3 -3
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +2 -1
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +61 -8
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/diff.d.ts.map +1 -1
- package/dist/commands/diff.js +2 -1
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +6 -2
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +13 -9
- package/dist/commands/init.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/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +2 -1
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +61 -1
- package/dist/commands/push.js.map +1 -1
- package/dist/compose-services.d.ts.map +1 -1
- package/dist/compose-services.js +2 -1
- package/dist/compose-services.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 +119 -16
- package/dist/dev-compose.js.map +1 -1
- package/dist/engine-floor.d.ts +11 -1
- package/dist/engine-floor.d.ts.map +1 -1
- package/dist/engine-floor.js +49 -16
- package/dist/engine-floor.js.map +1 -1
- package/dist/functions-deno-types.d.ts +9 -0
- package/dist/functions-deno-types.d.ts.map +1 -1
- package/dist/functions-deno-types.js +45 -1
- package/dist/functions-deno-types.js.map +1 -1
- package/dist/functions-router-gen.d.ts.map +1 -1
- package/dist/functions-router-gen.js +113 -81
- package/dist/functions-router-gen.js.map +1 -1
- package/dist/kong-config.d.ts +1 -1
- package/dist/model-cache.d.ts +44 -0
- package/dist/model-cache.d.ts.map +1 -0
- package/dist/model-cache.js +106 -0
- package/dist/model-cache.js.map +1 -0
- package/dist/model-hooks.d.ts +5 -0
- package/dist/model-hooks.d.ts.map +1 -1
- package/dist/model-hooks.js +29 -1
- package/dist/model-hooks.js.map +1 -1
- package/dist/model-versioning.d.ts +80 -0
- package/dist/model-versioning.d.ts.map +1 -0
- package/dist/model-versioning.js +140 -0
- package/dist/model-versioning.js.map +1 -0
- package/dist/postgres-ctl.d.ts +55 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +84 -8
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/preview-config-check.d.ts +21 -0
- package/dist/preview-config-check.d.ts.map +1 -0
- package/dist/preview-config-check.js +47 -0
- package/dist/preview-config-check.js.map +1 -0
- package/dist/project-config.d.ts +182 -5
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +125 -8
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +9 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js.map +1 -1
- package/dist/rest-cache-admin.d.ts.map +1 -1
- package/dist/rest-cache-admin.js +6 -2
- package/dist/rest-cache-admin.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +32 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +14 -1
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/self-host-compose.d.ts +20 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +227 -29
- package/dist/self-host-compose.js.map +1 -1
- package/dist/studio-admin-roles.d.ts +8 -1
- package/dist/studio-admin-roles.d.ts.map +1 -1
- package/dist/studio-admin-roles.js +16 -2
- package/dist/studio-admin-roles.js.map +1 -1
- package/dist/type-extractor.d.ts +7 -0
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +234 -3
- package/dist/type-extractor.js.map +1 -1
- package/package.json +2 -2
- package/src/api-config-cache.ts +142 -0
- package/src/cache-identity-scope.ts +92 -0
- package/src/cache-provider.ts +61 -0
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/add.ts +1 -1
- package/src/commands/admin.ts +23 -6
- package/src/commands/cache.ts +3 -3
- package/src/commands/cloud.ts +2 -1
- package/src/commands/deploy.ts +2 -1
- package/src/commands/dev.ts +68 -7
- package/src/commands/diff.ts +2 -1
- package/src/commands/functions.ts +6 -2
- package/src/commands/init.ts +13 -9
- package/src/commands/keys.ts +112 -7
- package/src/commands/migrate.ts +2 -1
- package/src/commands/push.ts +68 -1
- package/src/compose-services.ts +2 -1
- package/src/dev-compose.ts +151 -15
- package/src/engine-floor.ts +73 -16
- package/src/functions-deno-types.ts +48 -1
- package/src/functions-router-gen.ts +113 -81
- package/src/kong-config.ts +1 -1
- package/src/model-cache.ts +122 -0
- package/src/model-hooks.ts +26 -2
- package/src/model-versioning.ts +167 -0
- package/src/postgres-ctl.ts +103 -9
- package/src/preview-config-check.ts +52 -0
- package/src/project-config.ts +273 -11
- package/src/resolve-target.ts +11 -1
- package/src/rest-cache-admin.ts +8 -2
- package/src/schema-ast-v2.ts +47 -0
- package/src/self-host-compose.ts +250 -29
- package/src/studio-admin-roles.ts +16 -2
- package/src/type-extractor.ts +281 -2
- package/tests/api-config-cache-seed.test.ts +180 -0
- package/tests/cache-identity-scope.test.ts +61 -0
- package/tests/cache-provider-config.test.ts +132 -0
- package/tests/engine-floor.test.ts +43 -0
- package/tests/external-database-compose.test.ts +74 -3
- package/tests/field-masking-tier.test.ts +3 -3
- package/tests/fixtures/identity-scope-corpus.json +42 -0
- package/tests/functions-concurrency.test.ts +71 -0
- package/tests/init.test.ts +36 -2
- package/tests/keys-write.test.ts +148 -0
- package/tests/model-cache-declaration.test.ts +229 -0
- package/tests/model-cache-manifest.test.ts +99 -0
- package/tests/model-hooks.test.ts +55 -0
- package/tests/model-versions.test.ts +229 -0
- package/tests/native-keyspace.test.ts +130 -0
- package/tests/preview-config-check.test.ts +104 -0
- package/tests/rest-cache-admin.test.ts +36 -0
- package/tests/runtime-contract.test.ts +176 -22
- package/tests/studio-admin-roles.test.ts +32 -0
- package/tests/type-extractor.test.ts +113 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest"
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { createServer } from "node:net"
|
|
6
|
+
import {
|
|
7
|
+
firstFreePort,
|
|
8
|
+
nativeKeyspaceLibraryPresent,
|
|
9
|
+
nativeMaskLibraryPresent,
|
|
10
|
+
pgServerOptions,
|
|
11
|
+
} from "../src/postgres-ctl.js"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Native `supatype dev` starts one Postgres, and what that Postgres can do
|
|
15
|
+
* depends on which libraries the downloaded archive happens to carry. An
|
|
16
|
+
* archive from before pg_keyspace was bundled, and the Windows archive that
|
|
17
|
+
* cannot carry it at all, are both ordinary states — and naming a library that
|
|
18
|
+
* is not there stops the server starting, which turns a missing cache into no
|
|
19
|
+
* database.
|
|
20
|
+
*/
|
|
21
|
+
const roots: string[] = []
|
|
22
|
+
|
|
23
|
+
function install(libs: string[], layout: "unix" | "windows" = "unix"): string {
|
|
24
|
+
const root = mkdtempSync(join(tmpdir(), "supatype-pg-"))
|
|
25
|
+
roots.push(root)
|
|
26
|
+
const binDir = join(root, "bin")
|
|
27
|
+
const libDir = layout === "unix" ? join(root, "lib") : join(root, "lib", "postgresql")
|
|
28
|
+
mkdirSync(binDir, { recursive: true })
|
|
29
|
+
mkdirSync(libDir, { recursive: true })
|
|
30
|
+
for (const lib of libs) writeFileSync(join(libDir, lib), "")
|
|
31
|
+
return binDir
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe("which libraries the archive carries", () => {
|
|
39
|
+
it("finds each library in both layouts", () => {
|
|
40
|
+
for (const layout of ["unix", "windows"] as const) {
|
|
41
|
+
const ext = layout === "unix" ? "so" : "dll"
|
|
42
|
+
const both = install([`pg_keyspace.${ext}`, `supatype_mask.${ext}`], layout)
|
|
43
|
+
expect(nativeKeyspaceLibraryPresent(both)).toBe(true)
|
|
44
|
+
expect(nativeMaskLibraryPresent(both)).toBe(true)
|
|
45
|
+
}
|
|
46
|
+
expect(nativeKeyspaceLibraryPresent(install(["pg_keyspace.dylib"]))).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("says no for an archive without them, and for no archive at all", () => {
|
|
50
|
+
const bare = install([])
|
|
51
|
+
expect(nativeKeyspaceLibraryPresent(bare)).toBe(false)
|
|
52
|
+
expect(nativeMaskLibraryPresent(bare)).toBe(false)
|
|
53
|
+
for (const absent of [null, undefined, ""]) {
|
|
54
|
+
expect(nativeKeyspaceLibraryPresent(absent)).toBe(false)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe("the options pg_ctl hands the server", () => {
|
|
60
|
+
const opts = (pgBinDir: string, extra: Record<string, unknown> = {}) => ({
|
|
61
|
+
pgBinDir,
|
|
62
|
+
dataDir: "/tmp/data",
|
|
63
|
+
port: 55432,
|
|
64
|
+
...extra,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("preloads nothing an archive does not carry", () => {
|
|
68
|
+
expect(pgServerOptions(opts(install([]), { keyspacePort: 6379 }))).toBe("-p 55432")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("preloads the mask alone, as it did before the keyspace existed", () => {
|
|
72
|
+
const o = pgServerOptions(opts(install(["supatype_mask.so"]), { keyspacePort: 6379 }))
|
|
73
|
+
expect(o).toBe("-p 55432 -c shared_preload_libraries=supatype_mask")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// pg_keyspace refuses to serve when the mask loads before it, because RESP
|
|
77
|
+
// would hand back rows the mask never rewrote. The order is the safety
|
|
78
|
+
// property, not a style choice.
|
|
79
|
+
it("loads pg_keyspace before the mask, so the mask stays outermost", () => {
|
|
80
|
+
const o = pgServerOptions(opts(install(["pg_keyspace.so", "supatype_mask.so"]), { keyspacePort: 6379 }))
|
|
81
|
+
expect(o).toContain("-c shared_preload_libraries=pg_keyspace,supatype_mask")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("sizes the keyspace for a laptop and keeps it ephemeral", () => {
|
|
85
|
+
const o = pgServerOptions(
|
|
86
|
+
opts(install(["pg_keyspace.so"]), { keyspacePort: 6380, keyspaceDatabase: "acme" }),
|
|
87
|
+
)
|
|
88
|
+
expect(o).toContain("-c pg_keyspace.port=6380")
|
|
89
|
+
expect(o).toContain("-c pg_keyspace.durability=ephemeral")
|
|
90
|
+
expect(o).toContain("-c pg_keyspace.keys=50000")
|
|
91
|
+
expect(o).toContain("-c pg_keyspace.ring_mb=8")
|
|
92
|
+
expect(o).toContain("-c pg_keyspace.rowcache_mb=1")
|
|
93
|
+
expect(o).toContain("-c pg_keyspace.database=acme")
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// No port means the caller decided against RESP — an archive that has the
|
|
97
|
+
// library is not a reason to serve on a port nobody chose.
|
|
98
|
+
it("serves no keyspace without a port, even with the library present", () => {
|
|
99
|
+
const o = pgServerOptions(opts(install(["pg_keyspace.so", "supatype_mask.so"])))
|
|
100
|
+
expect(o).toBe("-p 55432 -c shared_preload_libraries=supatype_mask")
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe("choosing the keyspace port", () => {
|
|
105
|
+
it("takes the base port when it is free", async () => {
|
|
106
|
+
await expect(firstFreePort(6390, 5)).resolves.toBe(6390)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// 6379 is the first port a developer's own Redis, or a sidecar left running
|
|
110
|
+
// from a previous `supatype dev`, will be holding.
|
|
111
|
+
it("steps past a port something else is holding", async () => {
|
|
112
|
+
const held = createServer()
|
|
113
|
+
await new Promise<void>((done) => held.listen(6391, "127.0.0.1", done))
|
|
114
|
+
try {
|
|
115
|
+
await expect(firstFreePort(6391, 5)).resolves.toBe(6392)
|
|
116
|
+
} finally {
|
|
117
|
+
await new Promise<void>((done) => held.close(() => done()))
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("gives up rather than returning a port it could not get", async () => {
|
|
122
|
+
const held = createServer()
|
|
123
|
+
await new Promise<void>((done) => held.listen(6393, "127.0.0.1", done))
|
|
124
|
+
try {
|
|
125
|
+
await expect(firstFreePort(6393, 1)).resolves.toBeNull()
|
|
126
|
+
} finally {
|
|
127
|
+
await new Promise<void>((done) => held.close(() => done()))
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
modelsWithUnresolvablePreview,
|
|
4
|
+
unresolvablePreviewMessage,
|
|
5
|
+
} from "../src/preview-config-check.js"
|
|
6
|
+
import type { SupatypeProjectConfig } from "../src/project-config.js"
|
|
7
|
+
|
|
8
|
+
// A preview address may be a path, because with `app.mode` of `static` or `proxy` this deployment
|
|
9
|
+
// serves the project's app at `/` on the same origin as the API. Studio resolves the path against
|
|
10
|
+
// the origin it is loaded from, so the origin never has to be written down or kept in step.
|
|
11
|
+
//
|
|
12
|
+
// With `app.mode: "none"` nothing is served at `/`. The path then resolves against an origin that
|
|
13
|
+
// answers 404, and the failure lands in front of whoever was *sent* the link rather than whoever
|
|
14
|
+
// configured it. That is the worst possible place for it, so the push refuses instead.
|
|
15
|
+
|
|
16
|
+
function project(
|
|
17
|
+
mode: SupatypeProjectConfig["app"]["mode"],
|
|
18
|
+
livePreview?: Record<string, { url?: string; urlPattern?: string }>,
|
|
19
|
+
): SupatypeProjectConfig {
|
|
20
|
+
return {
|
|
21
|
+
project: { name: "acme" },
|
|
22
|
+
database: { provider: "docker" },
|
|
23
|
+
server: { mode: "dev" },
|
|
24
|
+
app: { mode },
|
|
25
|
+
versions: { engine: "0", server: "0", postgres: "0", deno: "0" },
|
|
26
|
+
...(livePreview !== undefined && { admin: { livePreview } }),
|
|
27
|
+
} as SupatypeProjectConfig
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("preview addresses that could never open", () => {
|
|
31
|
+
it("refuses a path when nothing is served at /", () => {
|
|
32
|
+
expect(
|
|
33
|
+
modelsWithUnresolvablePreview(project("none", { Post: { urlPattern: "/preview/{slug}" } })),
|
|
34
|
+
).toEqual(["Post"])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("allows a path when this deployment serves the app", () => {
|
|
38
|
+
for (const mode of ["static", "proxy"] as const) {
|
|
39
|
+
expect(
|
|
40
|
+
modelsWithUnresolvablePreview(project(mode, { Post: { urlPattern: "/preview/{slug}" } })),
|
|
41
|
+
).toEqual([])
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("allows an absolute URL in any mode, since it carries its own origin", () => {
|
|
46
|
+
expect(
|
|
47
|
+
modelsWithUnresolvablePreview(
|
|
48
|
+
project("none", { Post: { urlPattern: "https://example.com/p/{slug}" } }),
|
|
49
|
+
),
|
|
50
|
+
).toEqual([])
|
|
51
|
+
expect(
|
|
52
|
+
modelsWithUnresolvablePreview(project("none", { Post: { url: "https://example.com/p" } })),
|
|
53
|
+
).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("allows a protocol-relative URL, which already names a host", () => {
|
|
57
|
+
expect(
|
|
58
|
+
modelsWithUnresolvablePreview(project("none", { Post: { urlPattern: "//cdn.example/{s}" } })),
|
|
59
|
+
).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("says nothing about a model that configured no preview at all", () => {
|
|
63
|
+
// Not misconfigured, just not configured. Studio asks for the setting where it is needed; a
|
|
64
|
+
// push has no business refusing over a feature the project has not opted into.
|
|
65
|
+
expect(modelsWithUnresolvablePreview(project("none", { Post: {} }))).toEqual([])
|
|
66
|
+
expect(modelsWithUnresolvablePreview(project("none", { Post: { urlPattern: "" } }))).toEqual([])
|
|
67
|
+
expect(modelsWithUnresolvablePreview(project("none"))).toEqual([])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("names every model at fault, in one go", () => {
|
|
71
|
+
// An operator fixing these one push at a time is an operator running the push four times.
|
|
72
|
+
const models = modelsWithUnresolvablePreview(
|
|
73
|
+
project("none", {
|
|
74
|
+
Post: { urlPattern: "/p/{slug}" },
|
|
75
|
+
Author: { urlPattern: "/a/{slug}" },
|
|
76
|
+
Page: { urlPattern: "https://ok.example/{slug}" },
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
expect(models).toEqual(["Author", "Post"])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("prefers the pattern over the url, which is what Studio does", () => {
|
|
83
|
+
// A project giving both gets the pattern. Checking the url instead would pass a config that
|
|
84
|
+
// then renders from the path and breaks.
|
|
85
|
+
expect(
|
|
86
|
+
modelsWithUnresolvablePreview(
|
|
87
|
+
project("none", { Post: { url: "https://example.com", urlPattern: "/p/{slug}" } }),
|
|
88
|
+
),
|
|
89
|
+
).toEqual(["Post"])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe("what the refusal says", () => {
|
|
94
|
+
it("names the models and the mode that make it unresolvable", () => {
|
|
95
|
+
const message = unresolvablePreviewMessage(["Author", "Post"])
|
|
96
|
+
expect(message).toContain("Author, Post")
|
|
97
|
+
expect(message).toContain('app.mode is "none"')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("reads correctly for a single model", () => {
|
|
101
|
+
expect(unresolvablePreviewMessage(["Post"])).toContain("that path")
|
|
102
|
+
expect(unresolvablePreviewMessage(["Post", "Author"])).toContain("those paths")
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -26,3 +26,39 @@ describe("listRestCacheEntries", () => {
|
|
|
26
26
|
}
|
|
27
27
|
})
|
|
28
28
|
})
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The server answers 503 with "keyspace not configured" now and "valkey not
|
|
32
|
+
* configured" before the rename. A CLI is routinely newer than the deployment
|
|
33
|
+
* it is pointed at — and older than one, on a machine that has not updated —
|
|
34
|
+
* so both have to land on the same explanation.
|
|
35
|
+
*/
|
|
36
|
+
describe("the message for a cache server that is not there", () => {
|
|
37
|
+
const listAgainst = async (body: string) => {
|
|
38
|
+
const { listRestCacheEntries } = await import("../src/rest-cache-admin.js")
|
|
39
|
+
const originalFetch = globalThis.fetch
|
|
40
|
+
globalThis.fetch = vi.fn(async () => new Response(body, { status: 503 }))
|
|
41
|
+
try {
|
|
42
|
+
return await listRestCacheEntries(process.cwd()).then(
|
|
43
|
+
() => null,
|
|
44
|
+
(e: unknown) => (e instanceof Error ? e.message : String(e)),
|
|
45
|
+
)
|
|
46
|
+
} finally {
|
|
47
|
+
globalThis.fetch = originalFetch
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
it("explains either spelling without naming a product the user may not run", async () => {
|
|
52
|
+
for (const body of ['{"error":"keyspace not configured"}', '{"error":"valkey not configured"}']) {
|
|
53
|
+
const message = await listAgainst(body)
|
|
54
|
+
expect(message).toContain("No cache server is configured")
|
|
55
|
+
expect(message).toContain("supatype dev")
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("does not claim that explanation for an unrelated 503", async () => {
|
|
60
|
+
const message = await listAgainst('{"error":"upstream timeout"}')
|
|
61
|
+
expect(message).not.toContain("No cache server is configured")
|
|
62
|
+
expect(message).toContain("upstream timeout")
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -22,6 +22,19 @@ const baseConfig: SupatypeProjectConfig = {
|
|
|
22
22
|
},
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The same project with no `versions.postgres` pin.
|
|
27
|
+
*
|
|
28
|
+
* The keyspace is served by the image, and an image pinned to a release older than the toggle
|
|
29
|
+
* ignores `SUPATYPE_KEYSPACE_ENABLED` and comes up with no RESP listener — which is not an error
|
|
30
|
+
* anywhere. So a pin resolves the default to Valkey, and the tests about the default have to say
|
|
31
|
+
* which world they are in rather than inheriting one by accident.
|
|
32
|
+
*/
|
|
33
|
+
const unpinnedPostgres = (config: SupatypeProjectConfig): SupatypeProjectConfig => {
|
|
34
|
+
const { postgres: _dropped, ...rest } = config.versions ?? {}
|
|
35
|
+
return { ...config, versions: rest }
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
describe("runtime contract", () => {
|
|
26
39
|
it("includes core route families", () => {
|
|
27
40
|
const paths = runtimeRouteSpec().flatMap((r) => r.paths)
|
|
@@ -568,6 +581,11 @@ export default defineConfig({
|
|
|
568
581
|
expect(compose).toContain("${SUPATYPE_SERVER_IMAGE:-${SUPATYPE_AUTH_IMAGE:-supatype/server:latest}}")
|
|
569
582
|
expect(compose).toContain("${SUPATYPE_STORAGE_IMAGE:-supatype/storage:latest}")
|
|
570
583
|
expect(compose).toContain("${SUPATYPE_STUDIO_IMAGE:-supatype/studio:latest}")
|
|
584
|
+
// SeaweedFS is the object store, and the only one: MinIO's community edition is archived and
|
|
585
|
+
// its image was withdrawn, so a stack that still named it could not start at all.
|
|
586
|
+
expect(compose).toContain(" seaweedfs:")
|
|
587
|
+
expect(compose).toContain("S3_ENDPOINT: http://seaweedfs:8333")
|
|
588
|
+
expect(compose).not.toContain("minio")
|
|
571
589
|
expect(compose).toContain("SUPATYPE_POSTGREST_URL: http://postgrest:3000")
|
|
572
590
|
expect(compose).toContain("unified gateway")
|
|
573
591
|
const kong = readFileSync(out.kongPath, "utf8")
|
|
@@ -579,32 +597,161 @@ export default defineConfig({
|
|
|
579
597
|
}
|
|
580
598
|
})
|
|
581
599
|
|
|
600
|
+
it("renders SeaweedFS with its identities and its own volume", () => {
|
|
601
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-seaweed-"))
|
|
602
|
+
try {
|
|
603
|
+
const out = writeSelfHostCompose(dir, { ...baseConfig, app: { mode: "none" } })
|
|
604
|
+
const compose = readFileSync(out.composePath, "utf8")
|
|
605
|
+
|
|
606
|
+
expect(compose).toContain(" seaweedfs:")
|
|
607
|
+
// The storage service has to be pointed at the server that is actually running. Getting this
|
|
608
|
+
// wrong renders a stack that starts cleanly and fails on the first upload.
|
|
609
|
+
expect(compose).toContain("S3_ENDPOINT: http://seaweedfs:8333")
|
|
610
|
+
expect(compose).toContain("-s3.config=/etc/seaweedfs/s3.json")
|
|
611
|
+
expect(compose).toContain(".supatype/self-host/s3.json:/etc/seaweedfs/s3.json:ro")
|
|
612
|
+
expect(compose).toContain(" storage-data:")
|
|
613
|
+
|
|
614
|
+
// Same credentials in both halves, checked rather than assumed: a mismatch here does not
|
|
615
|
+
// fail at start, it fails at the first upload, which is a much worse place to find it.
|
|
616
|
+
const identities = JSON.parse(readFileSync(out.s3ConfigPath, "utf8")) as {
|
|
617
|
+
identities: { name: string; credentials: { accessKey: string; secretKey: string }[] }[]
|
|
618
|
+
}
|
|
619
|
+
const credential = identities.identities[0]?.credentials[0]
|
|
620
|
+
expect(compose).toContain(`S3_ACCESS_KEY: ${credential?.accessKey}`)
|
|
621
|
+
expect(compose).toContain(`S3_SECRET_KEY: ${credential?.secretKey}`)
|
|
622
|
+
|
|
623
|
+
// No anonymous identity: a bucket is public because of its policy, not because the server is.
|
|
624
|
+
expect(identities.identities.map((i) => i.name)).not.toContain("anonymous")
|
|
625
|
+
} finally {
|
|
626
|
+
rmSync(dir, { recursive: true, force: true })
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
|
|
582
630
|
const tlsConfig: SupatypeProjectConfig = {
|
|
583
631
|
...baseConfig,
|
|
584
632
|
server: { mode: "standalone", domain: "api.example.com", tls: { email: "ops@example.com" } },
|
|
585
633
|
}
|
|
586
634
|
|
|
587
|
-
it("self-host compose renders Kong TLS
|
|
588
|
-
const compose = renderSelfHostCompose(tlsConfig)
|
|
589
|
-
expect(compose).toContain("\n valkey:\n")
|
|
590
|
-
expect(compose).toContain("valkey/valkey:8-alpine")
|
|
635
|
+
it("self-host compose renders Kong TLS with the certificate store in Postgres", () => {
|
|
636
|
+
const compose = renderSelfHostCompose(unpinnedPostgres(tlsConfig))
|
|
591
637
|
expect(compose).toContain('- "80:8000"')
|
|
592
638
|
expect(compose).toContain('- "443:8443"')
|
|
593
639
|
expect(compose).toContain("KONG_PROXY_LISTEN")
|
|
594
|
-
expect(compose).toContain("- valkey")
|
|
595
640
|
expect(compose).toContain("https://api.example.com")
|
|
596
|
-
expect(compose).toMatch(/^\s{2}valkey-data:/m)
|
|
597
641
|
expect(compose).not.toContain("HTTPS is off")
|
|
642
|
+
// The cert store is the db container, which Kong now depends on instead of a sidecar.
|
|
643
|
+
expect(compose).toContain("SUPATYPE_VALKEY_ADDR: db:6379")
|
|
644
|
+
expect(compose).not.toContain("\n valkey:\n")
|
|
598
645
|
})
|
|
599
646
|
|
|
600
|
-
it("self-host compose
|
|
601
|
-
const compose = renderSelfHostCompose(
|
|
647
|
+
it("self-host compose renders Kong TLS + Valkey where the project keeps the sidecar", () => {
|
|
648
|
+
const compose = renderSelfHostCompose({ ...tlsConfig, cache: { provider: "valkey" } })
|
|
649
|
+
expect(compose).toContain("\n valkey:\n")
|
|
650
|
+
expect(compose).toContain("valkey/valkey:8-alpine")
|
|
651
|
+
expect(compose).toContain("- valkey")
|
|
652
|
+
expect(compose).toMatch(/^\s{2}valkey-data:/m)
|
|
653
|
+
expect(compose).toContain('- "443:8443"')
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it("self-host compose includes Valkey and SUPATYPE_VALKEY_ADDR when it is the chosen provider", () => {
|
|
657
|
+
const compose = renderSelfHostCompose({ ...baseConfig, cache: { provider: "valkey" } })
|
|
602
658
|
expect(compose).toContain("\n valkey:\n")
|
|
603
659
|
expect(compose).toContain("valkey/valkey:8-alpine")
|
|
604
660
|
expect(compose).toContain("SUPATYPE_VALKEY_ADDR: valkey:6379")
|
|
605
661
|
expect(compose).toMatch(/^\s{2}valkey-data:/m)
|
|
606
662
|
})
|
|
607
663
|
|
|
664
|
+
it("self-host compose serves the keyspace from Postgres, which is what it does by default", () => {
|
|
665
|
+
// Asserted without a `cache` section at all: the default is the behaviour, and a test that
|
|
666
|
+
// only ever passed `provider: "pg_keyspace"` would go on passing after the default moved back.
|
|
667
|
+
const compose = renderSelfHostCompose(unpinnedPostgres(baseConfig))
|
|
668
|
+
// No second stateful service, and nothing left pointing at one.
|
|
669
|
+
expect(compose).not.toContain("\n valkey:\n")
|
|
670
|
+
expect(compose).not.toContain("valkey/valkey:8-alpine")
|
|
671
|
+
expect(compose).not.toMatch(/^\s{2}valkey-data:/m)
|
|
672
|
+
expect(compose).not.toContain(" valkey:\n condition: service_started")
|
|
673
|
+
expect(compose).toContain("SUPATYPE_VALKEY_ADDR: db:6379")
|
|
674
|
+
// Turned on from the environment, so the image owns its own preload list.
|
|
675
|
+
// A copy of that list here would go stale the first time it gains a library.
|
|
676
|
+
expect(compose).toContain('SUPATYPE_KEYSPACE_ENABLED: "1"')
|
|
677
|
+
expect(compose).not.toContain("shared_preload_libraries")
|
|
678
|
+
// Durable keys are rows in a database, and it has to be this one.
|
|
679
|
+
expect(compose).toContain("SUPATYPE_KEYSPACE_DATABASE: ${POSTGRES_DB:-supatype}")
|
|
680
|
+
// A cache by default, with certificates the one thing kept.
|
|
681
|
+
expect(compose).toContain("SUPATYPE_KEYSPACE_DURABILITY: ephemeral")
|
|
682
|
+
expect(compose).toContain('SUPATYPE_KEYSPACE_DURABILITY_OVERRIDES: "kong_acme:=durable"')
|
|
683
|
+
// Shared memory is reserved at postmaster start whether or not it is used,
|
|
684
|
+
// so an unsized keyspace is memory a small host does not have.
|
|
685
|
+
expect(compose).toContain('SUPATYPE_KEYSPACE_KEYS: "200000"')
|
|
686
|
+
expect(compose).toContain('SUPATYPE_KEYSPACE_RING_MB: "16"')
|
|
687
|
+
expect(compose).toContain('SUPATYPE_KEYSPACE_ROWCACHE_MB: "64"')
|
|
688
|
+
// Reachable to the compose network, not published: RESP on a public
|
|
689
|
+
// interface is an unauthenticated read of every cached response.
|
|
690
|
+
expect(compose).toMatch(/expose:\n\s+- "6379"/)
|
|
691
|
+
expect(compose).not.toMatch(/- "\d+:6379"/)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it("self-host compose keeps the certificates durable whether or not TLS is on today", () => {
|
|
695
|
+
// The override costs nothing while no key matches it, and turning TLS on
|
|
696
|
+
// later should not need a database restart to make certificates safe.
|
|
697
|
+
for (const tls of [false, true]) {
|
|
698
|
+
const compose = renderSelfHostCompose({
|
|
699
|
+
...baseConfig,
|
|
700
|
+
...(tls
|
|
701
|
+
? { server: { ...baseConfig.server, mode: "standalone" as const, domain: "example.test", tls: { email: "ops@example.test" } } }
|
|
702
|
+
: {}),
|
|
703
|
+
cache: { provider: "pg_keyspace" },
|
|
704
|
+
})
|
|
705
|
+
expect(compose).toContain('SUPATYPE_KEYSPACE_DURABILITY_OVERRIDES: "kong_acme:=durable"')
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it("self-host compose keeps Valkey where the project asks for it", () => {
|
|
710
|
+
// Selectable, not deprecated: a project that wrote this down keeps the sidecar, and the
|
|
711
|
+
// Postgres it runs beside is left without a keyspace rather than quietly serving one too.
|
|
712
|
+
const compose = renderSelfHostCompose({ ...baseConfig, cache: { provider: "valkey" } })
|
|
713
|
+
expect(compose).toContain("\n valkey:\n")
|
|
714
|
+
expect(compose).not.toContain("SUPATYPE_KEYSPACE_ENABLED")
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
it("self-host compose keeps Valkey for a project that pins its Postgres image", () => {
|
|
718
|
+
// The toggle is honoured by the image, from the release that introduced it. An older pinned
|
|
719
|
+
// image ignores it and starts with no RESP listener — a stack that comes up, and a cache that
|
|
720
|
+
// never hits. A pin says the image is fixed, and a default must not assume something about a
|
|
721
|
+
// fixed image it cannot check; `cache: { provider: "pg_keyspace" }` is how a project on a
|
|
722
|
+
// capable pin says so.
|
|
723
|
+
const compose = renderSelfHostCompose(baseConfig)
|
|
724
|
+
expect(compose).toContain("\n valkey:\n")
|
|
725
|
+
expect(compose).not.toContain("SUPATYPE_KEYSPACE_ENABLED")
|
|
726
|
+
|
|
727
|
+
const asked = renderSelfHostCompose({ ...baseConfig, cache: { provider: "pg_keyspace" } })
|
|
728
|
+
expect(asked).toContain('SUPATYPE_KEYSPACE_ENABLED: "1"')
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it("self-host compose gives an external database Valkey, because nothing else can run", () => {
|
|
732
|
+
// pg_keyspace is loaded through shared_preload_libraries, which is a property of a Postgres
|
|
733
|
+
// this stack starts. The default has to resolve by itself here: an absent `cache.provider`
|
|
734
|
+
// cannot mean the same thing beside a managed database and an unmanaged one.
|
|
735
|
+
const compose = renderSelfHostCompose({
|
|
736
|
+
...unpinnedPostgres(baseConfig),
|
|
737
|
+
database: { external: { url: "postgres://u:p@h:5432/d" } },
|
|
738
|
+
})
|
|
739
|
+
expect(compose).toContain("\n valkey:\n")
|
|
740
|
+
expect(compose).not.toContain("SUPATYPE_KEYSPACE_ENABLED")
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
it("self-host compose persists the named prefixes alongside the certificates", () => {
|
|
744
|
+
const compose = renderSelfHostCompose({
|
|
745
|
+
...baseConfig,
|
|
746
|
+
cache: { provider: "pg_keyspace", durablePrefixes: ["session:", "flag:"] },
|
|
747
|
+
})
|
|
748
|
+
expect(compose).toContain("SUPATYPE_KEYSPACE_DURABILITY: ephemeral")
|
|
749
|
+
// ACME first: a project cannot displace it by naming prefixes of its own.
|
|
750
|
+
expect(compose).toContain(
|
|
751
|
+
'SUPATYPE_KEYSPACE_DURABILITY_OVERRIDES: "kong_acme:=durable, session:=durable, flag:=durable"',
|
|
752
|
+
)
|
|
753
|
+
})
|
|
754
|
+
|
|
608
755
|
it("self-host compose stays plain HTTP with a discoverable hint when TLS is off", () => {
|
|
609
756
|
const compose = renderSelfHostCompose(baseConfig)
|
|
610
757
|
expect(compose).not.toContain('- "443:8443"')
|
|
@@ -617,23 +764,30 @@ export default defineConfig({
|
|
|
617
764
|
...baseConfig,
|
|
618
765
|
server: { mode: "standalone", domain: "api.example.com" },
|
|
619
766
|
})
|
|
620
|
-
expect(compose).toContain("\n valkey:\n")
|
|
621
767
|
expect(compose).not.toContain('- "443:8443"')
|
|
622
768
|
})
|
|
623
769
|
|
|
624
|
-
it("writeSelfHostCompose emits a Kong acme plugin
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
770
|
+
it("writeSelfHostCompose emits a Kong acme plugin pointed at whichever RESP server runs", () => {
|
|
771
|
+
// The certificates are the one thing in the keyspace that must survive a restart, so the host
|
|
772
|
+
// Kong is told about has to be the one the stack actually started. Both directions asserted:
|
|
773
|
+
// a mismatch here is a Kong that cannot store its certificate, found on the first renewal.
|
|
774
|
+
for (const { cache, host } of [
|
|
775
|
+
{ cache: undefined, host: "db" },
|
|
776
|
+
{ cache: { provider: "valkey" as const }, host: "valkey" },
|
|
777
|
+
]) {
|
|
778
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-tls-"))
|
|
779
|
+
try {
|
|
780
|
+
const out = writeSelfHostCompose(dir, { ...unpinnedPostgres(tlsConfig), ...(cache ? { cache } : {}) })
|
|
781
|
+
const kong = readFileSync(out.kongPath, "utf8")
|
|
782
|
+
expect(kong).toContain("name: acme")
|
|
783
|
+
expect(kong).toContain('account_email: "ops@example.com"')
|
|
784
|
+
expect(kong).toContain("tos_accepted: true")
|
|
785
|
+
expect(kong).toContain('- "api.example.com"')
|
|
786
|
+
expect(kong).toContain("storage: redis")
|
|
787
|
+
expect(kong).toContain(`host: "${host}"`)
|
|
788
|
+
} finally {
|
|
789
|
+
rmSync(dir, { recursive: true, force: true })
|
|
790
|
+
}
|
|
637
791
|
}
|
|
638
792
|
})
|
|
639
793
|
|
|
@@ -25,3 +25,35 @@ describe("studioAdminRoles", () => {
|
|
|
25
25
|
expect(merged).toEqual({ models: [], adminRoles: ["ops"] })
|
|
26
26
|
})
|
|
27
27
|
})
|
|
28
|
+
|
|
29
|
+
describe("where the project says its models render", () => {
|
|
30
|
+
const livePreview = {
|
|
31
|
+
Post: { url: "https://example.com", urlPattern: "https://example.com/preview/{slug}" },
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it("carries livePreview through to the admin config Studio reads", () => {
|
|
35
|
+
// Studio cannot derive this from the schema: it is a fact about a deployment, not a shape. It
|
|
36
|
+
// reaches Studio only if this merge puts it in the file.
|
|
37
|
+
const merged = withAdminRoles({ models: [] }, { ...baseConfig, admin: { livePreview } })
|
|
38
|
+
expect(merged["livePreview"]).toEqual(livePreview)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("says nothing at all when the project has not configured it", () => {
|
|
42
|
+
// Absent, not empty. Studio treats an absent entry as "this project has not said", which is
|
|
43
|
+
// what makes it ask for the setting instead of offering a share link that opens nowhere. An
|
|
44
|
+
// empty object would read as "configured, with no models".
|
|
45
|
+
const merged = withAdminRoles({ models: [] }, baseConfig)
|
|
46
|
+
expect(merged).not.toHaveProperty("livePreview")
|
|
47
|
+
|
|
48
|
+
const empty = withAdminRoles({ models: [] }, { ...baseConfig, admin: { livePreview: {} } })
|
|
49
|
+
expect(empty).not.toHaveProperty("livePreview")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("keeps the roles alongside it, since one merge writes both", () => {
|
|
53
|
+
const merged = withAdminRoles(
|
|
54
|
+
{ models: [] },
|
|
55
|
+
{ ...baseConfig, admin: { roles: ["ops"], livePreview } },
|
|
56
|
+
)
|
|
57
|
+
expect(merged).toEqual({ models: [], adminRoles: ["ops"], livePreview })
|
|
58
|
+
})
|
|
59
|
+
})
|