@supatype/cli 0.3.0 → 0.3.2
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 +161 -154
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/api-config-cache.d.ts +20 -0
- package/dist/api-config-cache.d.ts.map +1 -1
- package/dist/api-config-cache.js +27 -0
- package/dist/api-config-cache.js.map +1 -1
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +63 -18
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/client-generator.d.ts +3 -0
- package/dist/client-generator.d.ts.map +1 -0
- package/dist/client-generator.js +108 -0
- package/dist/client-generator.js.map +1 -0
- 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/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +2 -2
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +7 -10
- 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/push.d.ts.map +1 -1
- package/dist/commands/push.js +23 -13
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +19 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/db-port.d.ts +36 -0
- package/dist/db-port.d.ts.map +1 -0
- package/dist/db-port.js +90 -0
- package/dist/db-port.js.map +1 -0
- package/dist/dev-compose.d.ts +13 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +205 -18
- package/dist/dev-compose.js.map +1 -1
- package/dist/functions-context-refresh.d.ts +4 -0
- package/dist/functions-context-refresh.d.ts.map +1 -0
- package/dist/functions-context-refresh.js +26 -0
- package/dist/functions-context-refresh.js.map +1 -0
- 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 +21 -1
- package/dist/functions-deno-types.js.map +1 -1
- package/dist/gitignore.d.ts +3 -1
- package/dist/gitignore.d.ts.map +1 -1
- package/dist/gitignore.js +28 -6
- package/dist/gitignore.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/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +17 -8
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/self-host-compose.d.ts +10 -2
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +114 -9
- package/dist/self-host-compose.js.map +1 -1
- package/dist/strict.d.ts +29 -0
- package/dist/strict.d.ts.map +1 -0
- package/dist/strict.js +37 -0
- package/dist/strict.js.map +1 -0
- package/dist/studio-dev-server.d.ts +12 -2
- package/dist/studio-dev-server.d.ts.map +1 -1
- package/dist/studio-dev-server.js +12 -2
- package/dist/studio-dev-server.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +8 -2
- package/dist/type-extractor.js.map +1 -1
- package/dist/type-generation.d.ts +14 -0
- package/dist/type-generation.d.ts.map +1 -1
- package/dist/type-generation.js +41 -2
- package/dist/type-generation.js.map +1 -1
- package/package.json +2 -2
- package/src/api-config-cache.ts +30 -0
- package/src/augmentation-generator.ts +75 -18
- package/src/cli-version-embedded.ts +1 -1
- package/src/client-generator.ts +122 -0
- package/src/commands/admin.ts +23 -6
- package/src/commands/functions.ts +5 -2
- package/src/commands/init.ts +10 -10
- package/src/commands/keys.ts +112 -7
- package/src/commands/push.ts +25 -15
- package/src/commands/update.ts +20 -1
- package/src/db-port.ts +99 -0
- package/src/dev-compose.ts +244 -19
- package/src/functions-context-refresh.ts +25 -0
- package/src/functions-deno-types.ts +24 -1
- package/src/gitignore.ts +31 -10
- package/src/model-cache.ts +48 -0
- package/src/schema-ast-v2.ts +17 -8
- package/src/self-host-compose.ts +115 -9
- package/src/strict.ts +40 -0
- package/src/studio-dev-server.ts +12 -2
- package/src/type-extractor.ts +8 -2
- package/src/type-generation.ts +47 -2
- package/tests/ast-derived-outputs.test.ts +92 -0
- package/tests/augmentation-generator.test.ts +127 -1
- package/tests/client-generator.test.ts +103 -0
- package/tests/db-port.test.ts +128 -0
- package/tests/external-database-compose.test.ts +8 -1
- package/tests/functions-context-refresh.test.ts +86 -0
- package/tests/gitignore-secrets.test.ts +90 -0
- package/tests/keys-write.test.ts +148 -0
- package/tests/runtime-contract.test.ts +115 -2
- package/tests/strict.test.ts +78 -0
- package/tests/type-extractor.test.ts +34 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -9,6 +9,22 @@ import { updateAppConfigInProject, updateServerConfigInProject } from "../src/ap
|
|
|
9
9
|
import type { SupatypeProjectConfig } from "../src/project-config.js"
|
|
10
10
|
import { DENO_RELEASE_PIN } from "../src/release-pins.js"
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* One service's block from a rendered compose file.
|
|
14
|
+
*
|
|
15
|
+
* Compose is asserted as text here rather than parsed, because the CLI ships no YAML parser and a
|
|
16
|
+
* test is not worth a dependency. Slicing to a single service is what keeps that honest: service
|
|
17
|
+
* names and `condition: service_healthy` both recur, so a whole-document substring match would
|
|
18
|
+
* pass on another service's dependency.
|
|
19
|
+
*/
|
|
20
|
+
function serviceBlock(compose: string, name: string): string {
|
|
21
|
+
const lines = compose.split("\n")
|
|
22
|
+
const start = lines.indexOf(` ${name}:`)
|
|
23
|
+
if (start === -1) return ""
|
|
24
|
+
const after = lines.slice(start + 1).findIndex((l) => /^ {2}[a-z][a-z0-9-]*:$/.test(l))
|
|
25
|
+
return lines.slice(start, after === -1 ? lines.length : start + 1 + after).join("\n")
|
|
26
|
+
}
|
|
27
|
+
|
|
12
28
|
const baseConfig: SupatypeProjectConfig = {
|
|
13
29
|
project: { name: "acme" },
|
|
14
30
|
database: { provider: "docker" },
|
|
@@ -375,9 +391,52 @@ describe("runtime contract", () => {
|
|
|
375
391
|
}
|
|
376
392
|
})
|
|
377
393
|
|
|
378
|
-
it("
|
|
394
|
+
it("maps host.docker.internal, so a host proxy works on Linux too", () => {
|
|
395
|
+
// Docker Desktop resolves this name; Linux does not unless it is mapped. A project proxying
|
|
396
|
+
// the site or Studio to something on the host therefore worked on macOS and Windows and
|
|
397
|
+
// failed on Linux, with the gateway reaching nothing and no error naming the cause.
|
|
379
398
|
const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
|
|
380
|
-
|
|
399
|
+
for (const service of ["server", "kong"]) {
|
|
400
|
+
expect(serviceBlock(compose, service), service).toContain(
|
|
401
|
+
'- "host.docker.internal:host-gateway"',
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it("publishes the database when a port is asked for", () => {
|
|
407
|
+
// In dev the port is published only when something on the host needs it, which is normally a
|
|
408
|
+
// host engine build. Naming a port is the same request: without this the number was honoured
|
|
409
|
+
// and the port never existed, so anything connecting over TCP got ECONNREFUSED.
|
|
410
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-dbport-"))
|
|
411
|
+
try {
|
|
412
|
+
writeFileSync(join(dir, ".env"), "SUPATYPE_DEV_DB_PORT=54329\n")
|
|
413
|
+
const compose = renderSelfHostCompose(baseConfig, dir, { devLocal: true })
|
|
414
|
+
// The number stays a compose variable so Docker resolves it; what changes is that the
|
|
415
|
+
// ports block exists at all.
|
|
416
|
+
expect(serviceBlock(compose, "db")).toContain("ports:")
|
|
417
|
+
expect(serviceBlock(compose, "db")).toContain("SUPATYPE_DEV_DB_PORT")
|
|
418
|
+
} finally {
|
|
419
|
+
rmSync(dir, { recursive: true, force: true })
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it("leaves the database unpublished when nothing asked for it", () => {
|
|
424
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-dbport-"))
|
|
425
|
+
try {
|
|
426
|
+
writeFileSync(join(dir, ".env"), "ANON_KEY=x\n")
|
|
427
|
+
expect(serviceBlock(renderSelfHostCompose(baseConfig, dir, { devLocal: true }), "db"))
|
|
428
|
+
.not.toContain("5432:5432")
|
|
429
|
+
} finally {
|
|
430
|
+
rmSync(dir, { recursive: true, force: true })
|
|
431
|
+
}
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it("devLocal compose enables STUDIO_OPEN_DEV on supatype-server, and lets it be turned off", () => {
|
|
435
|
+
const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
|
|
436
|
+
// Defaulted rather than hardcoded. `supatype dev` still opens Studio without a sign-in, and
|
|
437
|
+
// the browser suite can set STUDIO_OPEN_DEV=0 to drive the gate a deployment actually has.
|
|
438
|
+
// Held as a literal "1" there was no way to test Studio with its authorization on at all.
|
|
439
|
+
expect(compose).toContain('STUDIO_OPEN_DEV: "${STUDIO_OPEN_DEV:-1}"')
|
|
381
440
|
})
|
|
382
441
|
|
|
383
442
|
it("production self-host compose does not enable STUDIO_OPEN_DEV", () => {
|
|
@@ -597,6 +656,60 @@ export default defineConfig({
|
|
|
597
656
|
}
|
|
598
657
|
})
|
|
599
658
|
|
|
659
|
+
it("offers functions a database URL, off unless the operator sets one", () => {
|
|
660
|
+
// `ctx.dbUrl` is read from SUPATYPE_DB_URL by the worker, and the generated compose never set
|
|
661
|
+
// it, so the field was permanently undefined on self-host: a function reaching for it got no
|
|
662
|
+
// value and nothing to explain why.
|
|
663
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-compose-"))
|
|
664
|
+
try {
|
|
665
|
+
const compose = readFileSync(
|
|
666
|
+
writeSelfHostCompose(dir, { ...baseConfig, app: { mode: "none" } }).composePath,
|
|
667
|
+
"utf8",
|
|
668
|
+
)
|
|
669
|
+
const worker = serviceBlock(compose, "functions-worker")
|
|
670
|
+
|
|
671
|
+
// Passed through, so the capability is reachable at all.
|
|
672
|
+
expect(worker).toContain("SUPATYPE_DB_URL:")
|
|
673
|
+
// Empty by default. The worker treats an empty value as absent, so `ctx.dbUrl` stays
|
|
674
|
+
// undefined until somebody opts in.
|
|
675
|
+
expect(worker).toContain("SUPATYPE_DB_URL: ${SUPATYPE_FUNCTIONS_DB_URL:-}")
|
|
676
|
+
// And its own variable, not the project's owner DSN. Interpolating DATABASE_URL here would
|
|
677
|
+
// give every function a connection that bypasses access rules, masking and hooks.
|
|
678
|
+
expect(worker).not.toContain("SUPATYPE_DB_URL: ${DATABASE_URL")
|
|
679
|
+
} finally {
|
|
680
|
+
rmSync(dir, { recursive: true, force: true })
|
|
681
|
+
}
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
it("starts storage only once the object store is answering", () => {
|
|
685
|
+
// seaweedfs had no healthcheck and nothing depended on it, so compose started it alongside
|
|
686
|
+
// storage. Storage then accepted a bucket creation before seaweedfs was listening, every
|
|
687
|
+
// bucket in the schema failed with `connect ECONNREFUSED <ip>:8333`, and the metadata row was
|
|
688
|
+
// written anyway so a later push looked fine. Measured on a live stack: seaweedfs started
|
|
689
|
+
// twelve minutes after storage with RestartCount 0.
|
|
690
|
+
//
|
|
691
|
+
// It lands on the first push after a stack comes up, which is the first push anyone runs.
|
|
692
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-compose-"))
|
|
693
|
+
try {
|
|
694
|
+
const out = writeSelfHostCompose(dir, { ...baseConfig, app: { mode: "none" } })
|
|
695
|
+
const compose = readFileSync(out.composePath, "utf8")
|
|
696
|
+
|
|
697
|
+
// Sliced to one service, because `seaweedfs` and `service_healthy` both appear elsewhere in
|
|
698
|
+
// this file and a whole-document substring match would pass on either.
|
|
699
|
+
const storage = serviceBlock(compose, "storage")
|
|
700
|
+
expect(storage).toContain("depends_on:")
|
|
701
|
+
expect(storage).toMatch(/seaweedfs:\s*\n\s*condition: service_healthy/)
|
|
702
|
+
// Still waits for the database. The fix must not swap one dependency for another.
|
|
703
|
+
expect(storage).toMatch(/db:\s*\n\s*condition: service_healthy/)
|
|
704
|
+
|
|
705
|
+
// `service_healthy` is only meaningful if the service defines health. Without it compose
|
|
706
|
+
// refuses to start the stack at all, which is a worse failure than the race.
|
|
707
|
+
expect(serviceBlock(compose, "seaweedfs")).toContain("healthcheck:")
|
|
708
|
+
} finally {
|
|
709
|
+
rmSync(dir, { recursive: true, force: true })
|
|
710
|
+
}
|
|
711
|
+
})
|
|
712
|
+
|
|
600
713
|
it("renders SeaweedFS with its identities and its own volume", () => {
|
|
601
714
|
const dir = mkdtempSync(join(tmpdir(), "supatype-seaweed-"))
|
|
602
715
|
try {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict mode, which decides whether a degraded path is survivable.
|
|
3
|
+
*
|
|
4
|
+
* The behaviour under test is the *difference* between the two modes, so each case asserts both
|
|
5
|
+
* halves. A test that only checked the throw would pass against an implementation that threw
|
|
6
|
+
* always, which would break every interactive fallback the CLI has on purpose.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
|
10
|
+
import { degraded, strict } from "../src/strict.js"
|
|
11
|
+
|
|
12
|
+
const KEY = "SUPATYPE_STRICT"
|
|
13
|
+
let original: string | undefined
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
original = process.env[KEY]
|
|
17
|
+
delete process.env[KEY]
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (original === undefined) delete process.env[KEY]
|
|
22
|
+
else process.env[KEY] = original
|
|
23
|
+
vi.restoreAllMocks()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe("strict()", () => {
|
|
27
|
+
it("is off when the variable is unset", () => {
|
|
28
|
+
expect(strict()).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("is on only for exactly \"1\"", () => {
|
|
32
|
+
process.env[KEY] = "1"
|
|
33
|
+
expect(strict()).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("is off for other truthy-looking values", () => {
|
|
37
|
+
// Deliberately narrow. A flag that accepts "true", "yes" and "on" is a flag whose meaning has
|
|
38
|
+
// to be looked up, and this one turns warnings into build failures.
|
|
39
|
+
for (const value of ["true", "yes", "on", "0", ""]) {
|
|
40
|
+
process.env[KEY] = value
|
|
41
|
+
expect(strict(), `value ${JSON.stringify(value)}`).toBe(false)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe("degraded()", () => {
|
|
47
|
+
it("warns and returns when strict is off, so an interactive run continues", () => {
|
|
48
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
49
|
+
expect(() => degraded("Host engine unavailable", "CDN refused")).not.toThrow()
|
|
50
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
51
|
+
expect(warn.mock.calls[0]?.[0]).toContain("Host engine unavailable")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("throws when strict is on, so CI cannot pass over it", () => {
|
|
55
|
+
process.env[KEY] = "1"
|
|
56
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
57
|
+
expect(() => degraded("Host engine unavailable", "CDN refused")).toThrow(
|
|
58
|
+
/Host engine unavailable/,
|
|
59
|
+
)
|
|
60
|
+
// Not warned as well: a run that fails should not also print the line that says it recovered.
|
|
61
|
+
expect(warn).not.toHaveBeenCalled()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("names the capability and the cause, because the assertion that fails names neither", () => {
|
|
65
|
+
process.env[KEY] = "1"
|
|
66
|
+
// The failure this exists to prevent read "expected validator refusal, got 200". The cause was
|
|
67
|
+
// an engine download refused twenty lines earlier, and nothing in the failure said so.
|
|
68
|
+
expect(() => degraded("Host engine unavailable", "no minisign public key configured")).toThrow(
|
|
69
|
+
/no minisign public key configured/,
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("says which flag made it fatal", () => {
|
|
74
|
+
process.env[KEY] = "1"
|
|
75
|
+
// Otherwise the reader's first move is to work out why a warning became an error.
|
|
76
|
+
expect(() => degraded("Bucket unreachable", "ECONNREFUSED")).toThrow(/SUPATYPE_STRICT=1/)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -1485,3 +1485,37 @@ export type Event = Model<{
|
|
|
1485
1485
|
expect(event?.annotations.platform.searchFields).toBeUndefined()
|
|
1486
1486
|
})
|
|
1487
1487
|
})
|
|
1488
|
+
|
|
1489
|
+
describe("the pg type an asset column claims", () => {
|
|
1490
|
+
it("says JSONB, like the engine and the live column", () => {
|
|
1491
|
+
// The extractor annotated image and file as TEXT, while this package's own DEFAULT_DB_BY_KIND,
|
|
1492
|
+
// the engine's default, and the actual column in every pushed project all say JSONB. The
|
|
1493
|
+
// engine honours an explicit annotation, so this was one code path away from creating a text
|
|
1494
|
+
// column and then writing a JSON object into it.
|
|
1495
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-asset-"))
|
|
1496
|
+
dirs.push(dir)
|
|
1497
|
+
const schemaPath = join(dir, "schema.ts")
|
|
1498
|
+
writeFileSync(
|
|
1499
|
+
schemaPath,
|
|
1500
|
+
`
|
|
1501
|
+
import type { Model, UUID, ImageAsset, FileAsset, Optional, Public } from "@supatype/types"
|
|
1502
|
+
|
|
1503
|
+
export type Speaker = Model<{
|
|
1504
|
+
id: UUID
|
|
1505
|
+
headshot: Optional<ImageAsset>
|
|
1506
|
+
handout: Optional<FileAsset>
|
|
1507
|
+
}, {
|
|
1508
|
+
access: { read: Public }
|
|
1509
|
+
}>
|
|
1510
|
+
`,
|
|
1511
|
+
"utf8",
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
const ast = extractSchemaAstFromTypes(schemaPath, dir)
|
|
1515
|
+
const speaker = ast?.models.find((m) => m.name === "Speaker")
|
|
1516
|
+
const fields = speaker?.fields as Record<string, { annotations?: { db?: { pgType?: string } } }>
|
|
1517
|
+
|
|
1518
|
+
expect(fields["headshot"]?.annotations?.db?.pgType).toBe("JSONB")
|
|
1519
|
+
expect(fields["handout"]?.annotations?.db?.pgType).toBe("JSONB")
|
|
1520
|
+
})
|
|
1521
|
+
})
|