@supatype/cli 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +144 -94
- package/.turbo/turbo-typecheck.log +1 -1
- package/deno.d.ts +52 -0
- package/dist/augmentation-generator.d.ts +22 -0
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +18 -4
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/binary-cache.d.ts +14 -0
- package/dist/binary-cache.d.ts.map +1 -1
- package/dist/binary-cache.js +33 -13
- package/dist/binary-cache.js.map +1 -1
- package/dist/cache-clean.d.ts +15 -0
- package/dist/cache-clean.d.ts.map +1 -0
- package/dist/cache-clean.js +76 -0
- package/dist/cache-clean.js.map +1 -0
- package/dist/cache-pins.d.ts +51 -0
- package/dist/cache-pins.d.ts.map +1 -0
- package/dist/cache-pins.js +184 -0
- package/dist/cache-pins.js.map +1 -0
- package/dist/cli-install-method.d.ts +19 -0
- package/dist/cli-install-method.d.ts.map +1 -0
- package/dist/cli-install-method.js +60 -0
- package/dist/cli-install-method.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/cloud-credentials.d.ts +34 -0
- package/dist/cloud-credentials.d.ts.map +1 -0
- package/dist/cloud-credentials.js +139 -0
- package/dist/cloud-credentials.js.map +1 -0
- package/dist/commands/admin.d.ts +11 -0
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +121 -19
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/cache.d.ts.map +1 -1
- package/dist/commands/cache.js +67 -71
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +32 -8
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/db.d.ts +1 -0
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +147 -1
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +50 -10
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +28 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +60 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/functions.d.ts +7 -0
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +40 -6
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +5 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/hooks.d.ts +12 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +118 -0
- package/dist/commands/hooks.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +41 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/internal.d.ts +6 -0
- package/dist/commands/internal.d.ts.map +1 -0
- package/dist/commands/internal.js +22 -0
- package/dist/commands/internal.js.map +1 -0
- package/dist/commands/link-helpers.d.ts.map +1 -1
- package/dist/commands/link-helpers.js +8 -2
- package/dist/commands/link-helpers.js.map +1 -1
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +110 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +110 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/self-update.d.ts +7 -0
- package/dist/commands/self-update.d.ts.map +1 -1
- package/dist/commands/self-update.js +18 -11
- package/dist/commands/self-update.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -3
- package/dist/config.js.map +1 -1
- package/dist/db-preflight.d.ts +78 -0
- package/dist/db-preflight.d.ts.map +1 -0
- package/dist/db-preflight.js +452 -0
- package/dist/db-preflight.js.map +1 -0
- package/dist/dev-compose.d.ts +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +127 -72
- package/dist/dev-compose.js.map +1 -1
- package/dist/env-file.d.ts +40 -2
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +132 -11
- package/dist/env-file.js.map +1 -1
- package/dist/field-masking-tier.d.ts +50 -0
- package/dist/field-masking-tier.d.ts.map +1 -0
- package/dist/field-masking-tier.js +91 -0
- package/dist/field-masking-tier.js.map +1 -0
- package/dist/functions-deno-types.d.ts +24 -0
- package/dist/functions-deno-types.d.ts.map +1 -0
- package/dist/functions-deno-types.js +155 -0
- package/dist/functions-deno-types.js.map +1 -0
- package/dist/hook-upload.d.ts +30 -0
- package/dist/hook-upload.d.ts.map +1 -0
- package/dist/hook-upload.js +86 -0
- package/dist/hook-upload.js.map +1 -0
- package/dist/hooks-generator.d.ts +8 -0
- package/dist/hooks-generator.d.ts.map +1 -0
- package/dist/hooks-generator.js +332 -0
- package/dist/hooks-generator.js.map +1 -0
- package/dist/link.d.ts +4 -0
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +2 -0
- package/dist/link.js.map +1 -1
- package/dist/local-secrets.d.ts +71 -0
- package/dist/local-secrets.d.ts.map +1 -0
- package/dist/local-secrets.js +100 -0
- package/dist/local-secrets.js.map +1 -0
- package/dist/model-hooks.d.ts +73 -0
- package/dist/model-hooks.d.ts.map +1 -0
- package/dist/model-hooks.js +211 -0
- package/dist/model-hooks.js.map +1 -0
- package/dist/postgres-ctl.d.ts +11 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +25 -2
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/project-config.d.ts +121 -1
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +150 -1
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +4 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js +69 -73
- package/dist/resolve-target.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +2 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +4 -2
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/schema-push-lock.d.ts.map +1 -1
- package/dist/schema-push-lock.js +43 -16
- package/dist/schema-push-lock.js.map +1 -1
- package/dist/self-host-compose.d.ts +24 -0
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +301 -69
- package/dist/self-host-compose.js.map +1 -1
- package/dist/service-role-check.d.ts +21 -0
- package/dist/service-role-check.d.ts.map +1 -0
- package/dist/service-role-check.js +85 -0
- package/dist/service-role-check.js.map +1 -0
- package/dist/target-client.d.ts +17 -0
- package/dist/target-client.d.ts.map +1 -1
- package/dist/target-client.js +69 -17
- package/dist/target-client.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +736 -32
- package/dist/type-extractor.js.map +1 -1
- package/dist/ui/runtime/command-chrome.js +1 -1
- package/dist/ui/runtime/command-chrome.js.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.js +3 -1
- package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
- package/package.json +4 -1
- package/scripts/generate-homebrew-formula.mjs +121 -0
- package/src/augmentation-generator.ts +18 -5
- package/src/binary-cache.ts +47 -15
- package/src/cache-clean.ts +81 -0
- package/src/cache-pins.ts +243 -0
- package/src/cli-install-method.ts +67 -0
- package/src/cli.ts +6 -0
- package/src/cloud-credentials.ts +183 -0
- package/src/commands/admin.ts +140 -25
- package/src/commands/cache.ts +90 -75
- package/src/commands/cloud.ts +32 -8
- package/src/commands/db.ts +162 -1
- package/src/commands/dev.ts +57 -8
- package/src/commands/doctor.ts +69 -3
- package/src/commands/functions.ts +42 -5
- package/src/commands/generate.ts +5 -1
- package/src/commands/hooks.ts +138 -0
- package/src/commands/init.ts +42 -8
- package/src/commands/internal.ts +23 -0
- package/src/commands/link-helpers.ts +9 -2
- package/src/commands/login.ts +127 -0
- package/src/commands/push.ts +124 -7
- package/src/commands/self-update.ts +85 -13
- package/src/config.ts +22 -3
- package/src/db-preflight.ts +575 -0
- package/src/dev-compose.ts +142 -73
- package/src/env-file.ts +157 -11
- package/src/field-masking-tier.ts +107 -0
- package/src/functions-deno-types.ts +188 -0
- package/src/hook-upload.ts +107 -0
- package/src/hooks-generator.ts +352 -0
- package/src/link.ts +7 -0
- package/src/local-secrets.ts +104 -0
- package/src/model-hooks.ts +260 -0
- package/src/postgres-ctl.ts +29 -2
- package/src/project-config.ts +236 -2
- package/src/resolve-target.ts +113 -68
- package/src/schema-ast-v2.ts +5 -2
- package/src/schema-push-lock.ts +56 -17
- package/src/self-host-compose.ts +344 -68
- package/src/service-role-check.ts +111 -0
- package/src/target-client.ts +105 -18
- package/src/type-extractor.ts +883 -28
- package/src/ui/runtime/command-chrome.ts +1 -1
- package/src/ui/runtime/stdin-after-ink.ts +3 -1
- package/tests/access-composition.test.ts +667 -0
- package/tests/admin-studio-membership.test.ts +121 -0
- package/tests/api-schemas.test.ts +83 -0
- package/tests/archive-name-alias.test.ts +42 -0
- package/tests/augmentation-generator.test.ts +28 -0
- package/tests/cache-clean.test.ts +206 -0
- package/tests/cli-help.test.ts +3 -0
- package/tests/cli-install-method.test.ts +66 -0
- package/tests/command-chrome-coverage.test.ts +31 -26
- package/tests/command-chrome-wrap.test.ts +1 -0
- package/tests/db-preflight.test.ts +72 -0
- package/tests/doctor-render.test.ts +62 -0
- package/tests/doctor-service-role.test.ts +55 -0
- package/tests/env-file.test.ts +121 -0
- package/tests/external-database-compose.test.ts +184 -0
- package/tests/external-database-config.test.ts +154 -0
- package/tests/field-masking-tier.test.ts +184 -0
- package/tests/functions-deno-types.test.ts +197 -0
- package/tests/homebrew-formula.test.ts +58 -0
- package/tests/hook-upload.test.ts +125 -0
- package/tests/hooks-generator.test.ts +212 -0
- package/tests/init.test.ts +3 -0
- package/tests/local-secrets.test.ts +92 -0
- package/tests/model-hooks.test.ts +322 -0
- package/tests/runtime-contract.test.ts +113 -1
- package/tests/service-role-check.test.ts +109 -0
- package/tests/service-role-routes.test.ts +51 -0
- package/tests/target-client-refresh.test.ts +94 -0
- package/tests/type-extractor.test.ts +187 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/tests/engine-distribution.test.ts +0 -418
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import {
|
|
6
|
+
fieldMaskingTier,
|
|
7
|
+
imageShipsMaskExtension,
|
|
8
|
+
nativeMaskLibraryPresent,
|
|
9
|
+
schemaHasFieldRules,
|
|
10
|
+
} from "../src/field-masking-tier.js"
|
|
11
|
+
import { apiSchemaList, validateProjectConfig, type SupatypeProjectConfig } from "../src/project-config.js"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Which mechanism will enforce per-column rules, decided without touching the database.
|
|
15
|
+
*
|
|
16
|
+
* The engine decides the same thing at push time by asking Postgres whether `supatype_mask` is
|
|
17
|
+
* installed. The CLI has to answer earlier, because it writes `PGRST_DB_SCHEMA` when it generates
|
|
18
|
+
* compose. It can, because the extension needs `shared_preload_libraries` and a compiled library on
|
|
19
|
+
* the host: exactly one path ships it.
|
|
20
|
+
*
|
|
21
|
+
* The rule is deliberately **not** "is the database external". A native `supatype dev` has no
|
|
22
|
+
* extension either, and calling that tier 1 would point PostgREST at a schema whose tables the API
|
|
23
|
+
* roles no longer hold privileges on — every request denied.
|
|
24
|
+
*/
|
|
25
|
+
const project = (database: unknown, rest: Record<string, unknown> = {}): SupatypeProjectConfig =>
|
|
26
|
+
validateProjectConfig(
|
|
27
|
+
{
|
|
28
|
+
project: { name: "acme" },
|
|
29
|
+
database,
|
|
30
|
+
server: { mode: "dev" },
|
|
31
|
+
app: { mode: "none" },
|
|
32
|
+
...rest,
|
|
33
|
+
},
|
|
34
|
+
"supatype.config.ts",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const withRules = {
|
|
38
|
+
models: [
|
|
39
|
+
{
|
|
40
|
+
annotations: {
|
|
41
|
+
db: { tableName: "employee" },
|
|
42
|
+
platform: { access: { fields: { salary: { read: { type: "private" } } } } },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
const withoutRules = {
|
|
48
|
+
models: [{ annotations: { db: { tableName: "employee" }, platform: { access: {} } } }],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("imageShipsMaskExtension", () => {
|
|
52
|
+
it("is true only for the Supatype Postgres image", () => {
|
|
53
|
+
expect(imageShipsMaskExtension(project({ provider: "docker" }))).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("is false for native, which is the default `supatype dev`", () => {
|
|
57
|
+
// The case that makes "is it external" the wrong rule.
|
|
58
|
+
expect(imageShipsMaskExtension(project({ provider: "native" }))).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("is false for an external database", () => {
|
|
62
|
+
expect(
|
|
63
|
+
imageShipsMaskExtension(project({ external: { url: "postgres://u@h:5432/d" } })),
|
|
64
|
+
).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("is false when the Postgres image is overridden", () => {
|
|
68
|
+
// Someone running plain `postgres:17` under `provider: docker` has no extension, whatever the
|
|
69
|
+
// provider says.
|
|
70
|
+
expect(imageShipsMaskExtension(project({ provider: "docker", image: "postgres:17" }))).toBe(
|
|
71
|
+
false,
|
|
72
|
+
)
|
|
73
|
+
expect(
|
|
74
|
+
imageShipsMaskExtension(project({ provider: "docker", image: "supatype/postgres:17-latest" })),
|
|
75
|
+
).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe("schemaHasFieldRules", () => {
|
|
80
|
+
it("sees a masked column", () => {
|
|
81
|
+
expect(schemaHasFieldRules(withRules)).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("is false without one, and for anything unexpected", () => {
|
|
85
|
+
expect(schemaHasFieldRules(withoutRules)).toBe(false)
|
|
86
|
+
expect(schemaHasFieldRules({ models: [] })).toBe(false)
|
|
87
|
+
expect(schemaHasFieldRules(null)).toBe(false)
|
|
88
|
+
expect(schemaHasFieldRules({})).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("is false for an empty fields map", () => {
|
|
92
|
+
// Present but empty means no column is masked, so nothing needs a view.
|
|
93
|
+
const empty = {
|
|
94
|
+
models: [{ annotations: { platform: { access: { fields: {} } } } }],
|
|
95
|
+
}
|
|
96
|
+
expect(schemaHasFieldRules(empty)).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe("fieldMaskingTier", () => {
|
|
101
|
+
it("is none when nothing is masked, whatever the database", () => {
|
|
102
|
+
// With no field rules the engine generates no views, so pointing the API at `api` would serve
|
|
103
|
+
// nothing at all.
|
|
104
|
+
expect(fieldMaskingTier(project({ provider: "native" }), withoutRules)).toBe("none")
|
|
105
|
+
expect(fieldMaskingTier(project({ provider: "docker" }), withoutRules)).toBe("none")
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("is extension on the image and views everywhere else", () => {
|
|
109
|
+
expect(fieldMaskingTier(project({ provider: "docker" }), withRules)).toBe("extension")
|
|
110
|
+
expect(fieldMaskingTier(project({ provider: "native" }), withRules)).toBe("views")
|
|
111
|
+
expect(
|
|
112
|
+
fieldMaskingTier(project({ external: { url: "postgres://u@h:5432/d" } }), withRules),
|
|
113
|
+
).toBe("views")
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("the exposed schema list", () => {
|
|
118
|
+
it("serves from api under tier 2, and drops the managed schema", () => {
|
|
119
|
+
// Both halves matter. A client picks its schema per request with `Accept-Profile`, so leaving the
|
|
120
|
+
// managed schema exposed would let any caller read the unmasked table.
|
|
121
|
+
const list = apiSchemaList(project({ provider: "native" }), "views")
|
|
122
|
+
expect(list).toBe("api, supatype, graphql_public, auth")
|
|
123
|
+
// Entries, not substrings: `graphql_public` legitimately contains "public".
|
|
124
|
+
expect(list.split(", ")).not.toContain("public")
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("is unchanged for tier 1 and for a schema with no field rules", () => {
|
|
128
|
+
for (const tier of ["extension", "none", undefined] as const) {
|
|
129
|
+
expect(apiSchemaList(project({ provider: "docker" }), tier)).toBe(
|
|
130
|
+
"public, supatype, graphql_public, auth",
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("still lets an explicit api_schemas win", () => {
|
|
136
|
+
// The escape hatch for the one case the offline rule cannot call: a self-managed Postgres where
|
|
137
|
+
// the operator installed the extension themselves.
|
|
138
|
+
const cfg = project({ external: { url: "postgres://u@h:5432/d" } }, {
|
|
139
|
+
schema: { api_schemas: ["public", "supatype"] },
|
|
140
|
+
})
|
|
141
|
+
expect(apiSchemaList(cfg, "views")).toBe("public, supatype")
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe("nativeMaskLibraryPresent", () => {
|
|
146
|
+
it("is false for an install without the library", () => {
|
|
147
|
+
// The case that must not be assumed away: an archive downloaded before the library was bundled.
|
|
148
|
+
// Claiming tier 1 for it would select the planner rewrite and the push would be refused.
|
|
149
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-pg-"))
|
|
150
|
+
mkdirSync(join(dir, "bin"), { recursive: true })
|
|
151
|
+
mkdirSync(join(dir, "lib", "postgresql"), { recursive: true })
|
|
152
|
+
expect(nativeMaskLibraryPresent(join(dir, "bin"))).toBe(false)
|
|
153
|
+
rmSync(dir, { recursive: true, force: true })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("finds the library in either archive layout", () => {
|
|
157
|
+
// Linux and macOS put extension libraries in `lib/`; the Windows archive uses `lib/postgresql/`.
|
|
158
|
+
for (const [subdir, lib] of [
|
|
159
|
+
[["lib"], "supatype_mask.so"],
|
|
160
|
+
[["lib"], "supatype_mask.dylib"],
|
|
161
|
+
[["lib", "postgresql"], "supatype_mask.dll"],
|
|
162
|
+
] as const) {
|
|
163
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-pg-"))
|
|
164
|
+
mkdirSync(join(dir, "bin"), { recursive: true })
|
|
165
|
+
mkdirSync(join(dir, ...subdir), { recursive: true })
|
|
166
|
+
writeFileSync(join(dir, ...subdir, lib), "")
|
|
167
|
+
expect(nativeMaskLibraryPresent(join(dir, "bin")), lib).toBe(true)
|
|
168
|
+
rmSync(dir, { recursive: true, force: true })
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("is false without an install directory at all", () => {
|
|
173
|
+
expect(nativeMaskLibraryPresent(null)).toBe(false)
|
|
174
|
+
expect(nativeMaskLibraryPresent(undefined)).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("selects the extension tier when the library is installed", () => {
|
|
178
|
+
// The point of the whole change: native dev enforces field rules the same way the image does.
|
|
179
|
+
expect(fieldMaskingTier(project({ provider: "native" }), withRules, true)).toBe("extension")
|
|
180
|
+
expect(fieldMaskingTier(project({ provider: "native" }), withRules, false)).toBe("views")
|
|
181
|
+
// Still nothing to enforce when no column is masked.
|
|
182
|
+
expect(fieldMaskingTier(project({ provider: "native" }), withoutRules, true)).toBe("none")
|
|
183
|
+
})
|
|
184
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { createRequire } from "node:module"
|
|
3
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { dirname, join } from "node:path"
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
7
|
+
import {
|
|
8
|
+
ensureFunctionsDenoTypes,
|
|
9
|
+
functionsDenoAmbientSource,
|
|
10
|
+
functionsTsConfigSource,
|
|
11
|
+
} from "../src/functions-deno-types.js"
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url)
|
|
14
|
+
|
|
15
|
+
function resolveTscBin(): string {
|
|
16
|
+
const typescriptEntry = require.resolve("typescript/package.json")
|
|
17
|
+
return join(dirname(typescriptEntry), "bin", "tsc")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function runTsc(projectDir: string): { status: number | null; stdout: string; stderr: string } {
|
|
21
|
+
const result = spawnSync(process.execPath, [resolveTscBin(), "-p", "tsconfig.json", "--pretty", "false"], {
|
|
22
|
+
cwd: projectDir,
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
})
|
|
25
|
+
return {
|
|
26
|
+
status: result.status,
|
|
27
|
+
stdout: result.stdout ?? "",
|
|
28
|
+
stderr: result.stderr ?? "",
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const HANDLER_WITH_DENO = `export default async function handler(req: Request): Promise<Response> {
|
|
33
|
+
const url = Deno.env.get("SUPATYPE_URL")
|
|
34
|
+
return new Response(JSON.stringify({ url, method: req.method }), {
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
describe("ensureFunctionsDenoTypes", () => {
|
|
41
|
+
let tmpRoot = ""
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
if (tmpRoot) rmSync(tmpRoot, { recursive: true, force: true })
|
|
45
|
+
tmpRoot = ""
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("writes deno.d.ts and tsconfig.json under functions/", () => {
|
|
49
|
+
tmpRoot = mkdtempSync(join(tmpdir(), "supatype-deno-types-"))
|
|
50
|
+
const functionsDir = join(tmpRoot, "functions")
|
|
51
|
+
|
|
52
|
+
const result = ensureFunctionsDenoTypes(tmpRoot, functionsDir)
|
|
53
|
+
|
|
54
|
+
expect(result.wroteDenoDts).toBe(true)
|
|
55
|
+
expect(result.wroteTsconfig).toBe(true)
|
|
56
|
+
expect(existsSync(join(functionsDir, "deno.d.ts"))).toBe(true)
|
|
57
|
+
expect(existsSync(join(functionsDir, "tsconfig.json"))).toBe(true)
|
|
58
|
+
expect(readFileSync(join(functionsDir, "deno.d.ts"), "utf8")).toContain("declare namespace Deno")
|
|
59
|
+
expect(readFileSync(join(functionsDir, "tsconfig.json"), "utf8")).toContain('"types": []')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("is idempotent when files already exist", () => {
|
|
63
|
+
tmpRoot = mkdtempSync(join(tmpdir(), "supatype-deno-types-"))
|
|
64
|
+
const functionsDir = join(tmpRoot, "functions")
|
|
65
|
+
mkdirSync(functionsDir, { recursive: true })
|
|
66
|
+
writeFileSync(join(functionsDir, "deno.d.ts"), "// custom\n", "utf8")
|
|
67
|
+
writeFileSync(join(functionsDir, "tsconfig.json"), "{}\n", "utf8")
|
|
68
|
+
|
|
69
|
+
const result = ensureFunctionsDenoTypes(tmpRoot, functionsDir)
|
|
70
|
+
|
|
71
|
+
expect(result.wroteDenoDts).toBe(false)
|
|
72
|
+
expect(result.wroteTsconfig).toBe(false)
|
|
73
|
+
expect(readFileSync(join(functionsDir, "deno.d.ts"), "utf8")).toBe("// custom\n")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("adds functions to root tsconfig exclude when possible", () => {
|
|
77
|
+
tmpRoot = mkdtempSync(join(tmpdir(), "supatype-deno-types-"))
|
|
78
|
+
writeFileSync(
|
|
79
|
+
join(tmpRoot, "tsconfig.json"),
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
compilerOptions: { strict: true },
|
|
82
|
+
include: ["**/*.ts"],
|
|
83
|
+
exclude: ["node_modules"],
|
|
84
|
+
}, null, 2),
|
|
85
|
+
"utf8",
|
|
86
|
+
)
|
|
87
|
+
const functionsDir = join(tmpRoot, "functions")
|
|
88
|
+
|
|
89
|
+
const result = ensureFunctionsDenoTypes(tmpRoot, functionsDir)
|
|
90
|
+
|
|
91
|
+
expect(result.rootExclude).toBe("updated")
|
|
92
|
+
const root = JSON.parse(readFileSync(join(tmpRoot, "tsconfig.json"), "utf8")) as {
|
|
93
|
+
exclude: string[]
|
|
94
|
+
}
|
|
95
|
+
expect(root.exclude).toContain("functions")
|
|
96
|
+
expect(root.exclude).toContain("node_modules")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("reports already when functions is already excluded", () => {
|
|
100
|
+
tmpRoot = mkdtempSync(join(tmpdir(), "supatype-deno-types-"))
|
|
101
|
+
writeFileSync(
|
|
102
|
+
join(tmpRoot, "tsconfig.json"),
|
|
103
|
+
JSON.stringify({ exclude: ["node_modules", "functions"] }, null, 2),
|
|
104
|
+
"utf8",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const result = ensureFunctionsDenoTypes(tmpRoot, join(tmpRoot, "functions"))
|
|
108
|
+
expect(result.rootExclude).toBe("already")
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("templates include Deno.env", () => {
|
|
112
|
+
expect(functionsDenoAmbientSource()).toContain("const env: DenoEnv")
|
|
113
|
+
expect(functionsTsConfigSource()).toContain("deno.d.ts")
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Manual / end-to-end check: real `tsc` against a scaffolded functions dir.
|
|
119
|
+
* Proves Deno.env is typed (no TS2304) once deno.d.ts + functions/tsconfig exist.
|
|
120
|
+
*/
|
|
121
|
+
describe("functions Deno types (tsc manual check)", () => {
|
|
122
|
+
let tmpRoot = ""
|
|
123
|
+
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
if (tmpRoot) rmSync(tmpRoot, { recursive: true, force: true })
|
|
126
|
+
tmpRoot = ""
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("tsc fails with Cannot find name Deno without ambient types", () => {
|
|
130
|
+
tmpRoot = mkdtempSync(join(tmpdir(), "supatype-deno-tsc-baseline-"))
|
|
131
|
+
const fnDir = join(tmpRoot, "hello")
|
|
132
|
+
mkdirSync(fnDir, { recursive: true })
|
|
133
|
+
writeFileSync(join(fnDir, "index.ts"), HANDLER_WITH_DENO, "utf8")
|
|
134
|
+
writeFileSync(
|
|
135
|
+
join(tmpRoot, "tsconfig.json"),
|
|
136
|
+
JSON.stringify(
|
|
137
|
+
{
|
|
138
|
+
compilerOptions: {
|
|
139
|
+
target: "ES2022",
|
|
140
|
+
lib: ["ES2022", "DOM"],
|
|
141
|
+
strict: true,
|
|
142
|
+
noEmit: true,
|
|
143
|
+
module: "ESNext",
|
|
144
|
+
moduleResolution: "bundler",
|
|
145
|
+
types: [],
|
|
146
|
+
},
|
|
147
|
+
include: ["./**/*.ts"],
|
|
148
|
+
},
|
|
149
|
+
null,
|
|
150
|
+
2,
|
|
151
|
+
),
|
|
152
|
+
"utf8",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const { status, stdout, stderr } = runTsc(tmpRoot)
|
|
156
|
+
const out = `${stdout}\n${stderr}`
|
|
157
|
+
expect(status).not.toBe(0)
|
|
158
|
+
expect(out).toMatch(/Cannot find name 'Deno'|TS2304/)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("tsc passes for Deno.env after ensureFunctionsDenoTypes", () => {
|
|
162
|
+
tmpRoot = mkdtempSync(join(tmpdir(), "supatype-deno-tsc-ok-"))
|
|
163
|
+
const functionsDir = join(tmpRoot, "functions")
|
|
164
|
+
mkdirSync(join(functionsDir, "hello"), { recursive: true })
|
|
165
|
+
writeFileSync(join(functionsDir, "hello", "index.ts"), HANDLER_WITH_DENO, "utf8")
|
|
166
|
+
writeFileSync(
|
|
167
|
+
join(tmpRoot, "tsconfig.json"),
|
|
168
|
+
JSON.stringify(
|
|
169
|
+
{
|
|
170
|
+
compilerOptions: {
|
|
171
|
+
target: "ES2022",
|
|
172
|
+
lib: ["ES2022", "DOM"],
|
|
173
|
+
strict: true,
|
|
174
|
+
noEmit: true,
|
|
175
|
+
module: "ESNext",
|
|
176
|
+
moduleResolution: "bundler",
|
|
177
|
+
},
|
|
178
|
+
include: ["**/*.ts"],
|
|
179
|
+
exclude: ["node_modules"],
|
|
180
|
+
},
|
|
181
|
+
null,
|
|
182
|
+
2,
|
|
183
|
+
),
|
|
184
|
+
"utf8",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const ensured = ensureFunctionsDenoTypes(tmpRoot, functionsDir)
|
|
188
|
+
expect(ensured.wroteDenoDts).toBe(true)
|
|
189
|
+
expect(ensured.wroteTsconfig).toBe(true)
|
|
190
|
+
expect(ensured.rootExclude).toBe("updated")
|
|
191
|
+
|
|
192
|
+
const { status, stdout, stderr } = runTsc(functionsDir)
|
|
193
|
+
const out = `${stdout}\n${stderr}`
|
|
194
|
+
expect(out, `tsc stderr/stdout:\n${out}`).not.toMatch(/Cannot find name 'Deno'|TS2304/)
|
|
195
|
+
expect(status, `tsc failed:\n${out}`).toBe(0)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { spawnSync } from "node:child_process"
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
6
|
+
|
|
7
|
+
let counter = 0
|
|
8
|
+
let tmpDir: string
|
|
9
|
+
|
|
10
|
+
const PLATFORMS = [
|
|
11
|
+
"supatype-cli-darwin-arm64",
|
|
12
|
+
"supatype-cli-darwin-amd64",
|
|
13
|
+
"supatype-cli-linux-arm64",
|
|
14
|
+
"supatype-cli-linux-amd64",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmpDir = join(tmpdir(), `homebrew-formula-${Date.now()}-${++counter}`)
|
|
19
|
+
mkdirSync(tmpDir, { recursive: true })
|
|
20
|
+
for (const name of PLATFORMS) {
|
|
21
|
+
writeFileSync(join(tmpDir, name), `stub-${name}\n`, "utf8")
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("generate-homebrew-formula.mjs", () => {
|
|
30
|
+
it("writes a Formula with versioned CDN URLs and sha256 blocks", () => {
|
|
31
|
+
const outPath = join(tmpDir, "supatype.rb")
|
|
32
|
+
const script = join(import.meta.dirname, "../scripts/generate-homebrew-formula.mjs")
|
|
33
|
+
const result = spawnSync(
|
|
34
|
+
process.execPath,
|
|
35
|
+
[script, "9.9.9", tmpDir],
|
|
36
|
+
{
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
env: { ...process.env, HOMEBREW_FORMULA_OUT: outPath },
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
expect(result.status, result.stderr).toBe(0)
|
|
43
|
+
const formula = readFileSync(outPath, "utf8")
|
|
44
|
+
expect(formula).toContain('version "9.9.9"')
|
|
45
|
+
expect(formula).toContain("https://releases.supatype.com/cli/v9.9.9/supatype-cli-darwin-arm64")
|
|
46
|
+
expect(formula).toMatch(/sha256 "[a-f0-9]{64}"/)
|
|
47
|
+
expect(formula).toContain('system bin/"supatype", "_postinstall"')
|
|
48
|
+
expect(formula).toContain('bin.install "supatype-cli-#{arch}" => "supatype"')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("fails when an artifact is missing", () => {
|
|
52
|
+
rmSync(join(tmpDir, "supatype-cli-linux-amd64"))
|
|
53
|
+
const script = join(import.meta.dirname, "../scripts/generate-homebrew-formula.mjs")
|
|
54
|
+
const result = spawnSync(process.execPath, [script, "1.0.0", tmpDir], { encoding: "utf8" })
|
|
55
|
+
expect(result.status).not.toBe(0)
|
|
56
|
+
expect(result.stderr).toContain("Missing artifact")
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
|
|
6
|
+
import { ADAPTER_KEY, readHookUpload } from "../src/hook-upload.js"
|
|
7
|
+
|
|
8
|
+
function project(): { cwd: string; hooks: string } {
|
|
9
|
+
const cwd = mkdtempSync(join(tmpdir(), "supatype-hook-upload-"))
|
|
10
|
+
const hooks = join(cwd, "hooks")
|
|
11
|
+
mkdirSync(hooks, { recursive: true })
|
|
12
|
+
return { cwd, hooks }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeHook(hooks: string, name: string, source: string, extra?: Record<string, string>): void {
|
|
16
|
+
const dir = join(hooks, name)
|
|
17
|
+
mkdirSync(dir, { recursive: true })
|
|
18
|
+
writeFileSync(join(dir, "index.ts"), source, "utf8")
|
|
19
|
+
for (const [file, contents] of Object.entries(extra ?? {})) {
|
|
20
|
+
writeFileSync(join(dir, file), contents, "utf8")
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An AST shaped as the extractor produces, carrying one hooked model. */
|
|
25
|
+
function astWith(hooks: Record<string, string>): unknown {
|
|
26
|
+
return {
|
|
27
|
+
models: [
|
|
28
|
+
{
|
|
29
|
+
name: "Post",
|
|
30
|
+
fields: [],
|
|
31
|
+
annotations: {
|
|
32
|
+
db: { tableName: "posts" },
|
|
33
|
+
platform: {
|
|
34
|
+
hooks: Object.fromEntries(
|
|
35
|
+
Object.entries(hooks).map(([event, fn]) => [event, { function: fn }]),
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const HANDLER = `import { hook, type BeforeChange } from "../_supatype/hooks.ts"
|
|
45
|
+
|
|
46
|
+
const moderate: BeforeChange<"posts"> = async () => ({})
|
|
47
|
+
export default hook(moderate)
|
|
48
|
+
`
|
|
49
|
+
|
|
50
|
+
describe("readHookUpload", () => {
|
|
51
|
+
it("is null when the schema names no hooks", () => {
|
|
52
|
+
const { cwd, hooks } = project()
|
|
53
|
+
expect(readHookUpload(cwd, hooks, astWith({}))).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("rewrites the generated adapter import to the flattened name", () => {
|
|
57
|
+
// The nested path cannot survive flattening, and this is the specifier our own scaffold writes.
|
|
58
|
+
const { cwd, hooks } = project()
|
|
59
|
+
writeHook(hooks, "moderate-post", HANDLER)
|
|
60
|
+
|
|
61
|
+
const upload = readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" }))
|
|
62
|
+
expect(upload?.handlers).toHaveLength(1)
|
|
63
|
+
expect(upload?.handlers[0]?.source).toContain(`./${ADAPTER_KEY}`)
|
|
64
|
+
expect(upload?.handlers[0]?.source).not.toContain("../_supatype/hooks.ts")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("uploads only the handlers the schema names", () => {
|
|
68
|
+
// An unnamed handler would sit on the worker holding the service-role key with nothing able to
|
|
69
|
+
// call it. Uploading it buys nothing and widens what runs with that credential.
|
|
70
|
+
const { cwd, hooks } = project()
|
|
71
|
+
writeHook(hooks, "moderate-post", HANDLER)
|
|
72
|
+
writeHook(hooks, "abandoned", HANDLER)
|
|
73
|
+
|
|
74
|
+
const upload = readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" }))
|
|
75
|
+
expect(upload?.handlers.map((h) => h.name)).toEqual(["moderate-post"])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("carries the hook map alongside the sources", () => {
|
|
79
|
+
// Both halves in one payload: a map naming a handler whose source has not arrived is the failure
|
|
80
|
+
// this whole feature is meant to avoid.
|
|
81
|
+
const { cwd, hooks } = project()
|
|
82
|
+
writeHook(hooks, "moderate-post", HANDLER)
|
|
83
|
+
|
|
84
|
+
const upload = readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" }))
|
|
85
|
+
expect(upload?.map["posts"]?.["beforeChange"]?.function).toBe("moderate-post")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("refuses a hook with a second file rather than dropping it", () => {
|
|
89
|
+
// Silent truncation would deploy successfully and fail at import — which reads to the caller as
|
|
90
|
+
// that table's writes being broken for no visible reason.
|
|
91
|
+
const { cwd, hooks } = project()
|
|
92
|
+
writeHook(hooks, "moderate-post", HANDLER, { "helpers.ts": "export const x = 1\n" })
|
|
93
|
+
|
|
94
|
+
expect(() => readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" })))
|
|
95
|
+
.toThrow(/more than one file \(helpers\.ts\)/)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("refuses a relative import it cannot resolve after flattening", () => {
|
|
99
|
+
const { cwd, hooks } = project()
|
|
100
|
+
writeHook(hooks, "moderate-post", `import { x } from "./helpers.ts"\nexport default () => x\n`)
|
|
101
|
+
|
|
102
|
+
expect(() => readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" })))
|
|
103
|
+
.toThrow(/imports \.\/helpers\.ts/)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("leaves bare specifiers and URLs alone", () => {
|
|
107
|
+
// Only relative paths depend on the layout. A hook is expected to import from npm: or https:.
|
|
108
|
+
const { cwd, hooks } = project()
|
|
109
|
+
writeHook(
|
|
110
|
+
hooks,
|
|
111
|
+
"moderate-post",
|
|
112
|
+
`import { z } from "npm:zod"\nimport { j } from "https://esm.sh/joi"\n` + HANDLER,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const upload = readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" }))
|
|
116
|
+
expect(upload?.handlers).toHaveLength(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("names the missing handler when a declared hook has no source", () => {
|
|
120
|
+
const { cwd, hooks } = project()
|
|
121
|
+
|
|
122
|
+
expect(() => readHookUpload(cwd, hooks, astWith({ beforeChange: "moderate-post" })))
|
|
123
|
+
.toThrow(/moderate-post/)
|
|
124
|
+
})
|
|
125
|
+
})
|