@supatype/cli 0.1.12 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +150 -136
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/cli-version-embedded.d.ts.map +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/cli-version-embedded.js.map +1 -1
- package/dist/commands/admin.d.ts +5 -5
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +10 -10
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +23 -1
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/dev.js +51 -51
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +0 -7
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +26 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/logs.d.ts +14 -1
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +33 -37
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +18 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/status.d.ts +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +33 -64
- package/dist/commands/status.js.map +1 -1
- package/dist/compose-local-server-image.d.ts +11 -0
- package/dist/compose-local-server-image.d.ts.map +1 -1
- package/dist/compose-local-server-image.js +18 -0
- package/dist/compose-local-server-image.js.map +1 -1
- package/dist/compose-services.d.ts +59 -0
- package/dist/compose-services.d.ts.map +1 -0
- package/dist/compose-services.js +160 -0
- package/dist/compose-services.js.map +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/dev-compose.d.ts +1 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +202 -30
- package/dist/dev-compose.js.map +1 -1
- package/dist/dev-ports.d.ts +24 -0
- package/dist/dev-ports.d.ts.map +1 -1
- package/dist/dev-ports.js +37 -0
- package/dist/dev-ports.js.map +1 -1
- package/dist/docker-postgres.d.ts +1 -1
- package/dist/docker-postgres.d.ts.map +1 -1
- package/dist/docker-postgres.js +2 -2
- package/dist/docker-postgres.js.map +1 -1
- package/dist/engine-floor.d.ts +50 -0
- package/dist/engine-floor.d.ts.map +1 -0
- package/dist/engine-floor.js +80 -0
- package/dist/engine-floor.js.map +1 -0
- package/dist/field-bounds.d.ts +68 -0
- package/dist/field-bounds.d.ts.map +1 -0
- package/dist/field-bounds.js +277 -0
- package/dist/field-bounds.js.map +1 -0
- package/dist/hooks-generator.d.ts +1 -1
- package/dist/hooks-generator.d.ts.map +1 -1
- package/dist/hooks-generator.js +78 -4
- package/dist/hooks-generator.js.map +1 -1
- package/dist/link.d.ts +1 -1
- package/dist/link.d.ts.map +1 -1
- package/dist/model-hooks.d.ts +44 -2
- package/dist/model-hooks.d.ts.map +1 -1
- package/dist/model-hooks.js +116 -12
- package/dist/model-hooks.js.map +1 -1
- package/dist/pgbouncer-userlist.d.ts +1 -1
- package/dist/pgbouncer-userlist.d.ts.map +1 -1
- package/dist/pgbouncer-userlist.js +1 -1
- package/dist/pgbouncer-userlist.js.map +1 -1
- package/dist/postgres-ctl.d.ts +5 -1
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +43 -4
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/project-config.d.ts +4 -4
- package/dist/resolve-target.d.ts +1 -1
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/schema-ast-v2.d.ts +38 -4
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +87 -4
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +41 -18
- package/dist/self-host-compose.js.map +1 -1
- package/dist/studio-dev-server.d.ts +8 -1
- package/dist/studio-dev-server.d.ts.map +1 -1
- package/dist/studio-dev-server.js +1 -2
- package/dist/studio-dev-server.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +309 -27
- package/dist/type-extractor.js.map +1 -1
- package/package.json +4 -3
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/admin.ts +10 -10
- package/src/commands/db.ts +27 -1
- package/src/commands/dev.ts +51 -51
- package/src/commands/doctor.ts +30 -0
- package/src/commands/init.ts +1 -1
- package/src/commands/logs.ts +34 -37
- package/src/commands/push.ts +29 -6
- package/src/commands/status.ts +34 -70
- package/src/compose-local-server-image.ts +17 -0
- package/src/compose-services.ts +174 -0
- package/src/config.ts +1 -1
- package/src/dev-compose.ts +226 -29
- package/src/dev-ports.ts +48 -0
- package/src/docker-postgres.ts +2 -2
- package/src/engine-floor.ts +113 -0
- package/src/field-bounds.ts +359 -0
- package/src/hooks-generator.ts +81 -4
- package/src/link.ts +1 -1
- package/src/model-hooks.ts +158 -12
- package/src/pgbouncer-userlist.ts +1 -1
- package/src/postgres-ctl.ts +44 -4
- package/src/project-config.ts +4 -4
- package/src/resolve-target.ts +1 -1
- package/src/schema-ast-v2.ts +114 -10
- package/src/self-host-compose.ts +41 -18
- package/src/studio-dev-server.ts +9 -3
- package/src/type-extractor.ts +374 -39
- package/tests/admin-ensure.test.ts +10 -10
- package/tests/compose-parsable-defaults.test.ts +60 -0
- package/tests/compose-services.test.ts +144 -0
- package/tests/dev-ports-owner.test.ts +40 -0
- package/tests/engine-floor.test.ts +102 -0
- package/tests/external-database-compose.test.ts +3 -3
- package/tests/field-bounds-matrix.test.ts +163 -0
- package/tests/field-bounds.test.ts +139 -0
- package/tests/field-validators.test.ts +139 -0
- package/tests/hooks-generator.test.ts +86 -0
- package/tests/local-server-image-env.test.ts +93 -0
- package/tests/model-constraints.test.ts +293 -0
- package/tests/model-hooks.test.ts +56 -0
- package/tests/port-in-use.test.ts +58 -0
- package/tests/readiness-poll.test.ts +84 -0
- package/tests/runtime-contract.test.ts +1 -1
- package/tests/type-extractor.test.ts +49 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
5
|
+
import {
|
|
6
|
+
declaredValidators,
|
|
7
|
+
hooksReport,
|
|
8
|
+
manifestValidators,
|
|
9
|
+
validateModelValidators,
|
|
10
|
+
} from "../src/model-hooks.js"
|
|
11
|
+
import { extractSchemaAstFromTypes } from "../src/type-extractor.js"
|
|
12
|
+
|
|
13
|
+
// A per-field validator that silently never fires is the failure this feature cannot have: the
|
|
14
|
+
// schema would say the field is checked and nothing would be checking it. So the tests here are
|
|
15
|
+
// mostly about the ways that could happen, and each one fails the push instead.
|
|
16
|
+
|
|
17
|
+
const dirs: string[] = []
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function extract(meta: string, fields = " id: UUID\n sku: string\n setupItems: JSON<{ label: string }[]>"): unknown {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-validators-"))
|
|
25
|
+
dirs.push(dir)
|
|
26
|
+
const schemaPath = join(dir, "schema.ts")
|
|
27
|
+
writeFileSync(
|
|
28
|
+
schemaPath,
|
|
29
|
+
`
|
|
30
|
+
import type { JSON, Model, Public, UUID } from "@supatype/types"
|
|
31
|
+
|
|
32
|
+
export type Product = Model<{
|
|
33
|
+
${fields}
|
|
34
|
+
}, {
|
|
35
|
+
access: { read: Public }
|
|
36
|
+
${meta}
|
|
37
|
+
}>
|
|
38
|
+
`,
|
|
39
|
+
"utf8",
|
|
40
|
+
)
|
|
41
|
+
return extractSchemaAstFromTypes(schemaPath, dir)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A functions directory containing the named handlers. */
|
|
45
|
+
function functionsDir(...names: string[]): string {
|
|
46
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-fns-"))
|
|
47
|
+
dirs.push(dir)
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
mkdirSync(join(dir, name), { recursive: true })
|
|
50
|
+
writeFileSync(join(dir, name, "index.ts"), "export default () => new Response()", "utf8")
|
|
51
|
+
}
|
|
52
|
+
return dir
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("declaring a field validator", () => {
|
|
56
|
+
it("extracts the field and its function", () => {
|
|
57
|
+
const ast = extract(' validate: { setupItems: "check-items" }')
|
|
58
|
+
expect(declaredValidators(ast)).toEqual([
|
|
59
|
+
{ model: "Product", field: "setupItems", function: "check-items" },
|
|
60
|
+
])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("accepts per-validator options, as hooks do", () => {
|
|
64
|
+
const ast = extract(' validate: { sku: { function: "check-sku", timeout: 3000 } }')
|
|
65
|
+
const manifest = manifestValidators(ast)
|
|
66
|
+
expect(manifest["product"]?.["sku"]).toEqual({
|
|
67
|
+
function: "check-sku",
|
|
68
|
+
timeout: 3000,
|
|
69
|
+
onUnavailable: "reject",
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("writes onUnavailable explicitly rather than relying on the server's default", () => {
|
|
74
|
+
// The server does default to refusing, but its policy matches exact event names, and a value
|
|
75
|
+
// missing from that switch is how an unreachable validator came to accept writes once already.
|
|
76
|
+
const ast = extract(' validate: { setupItems: "check-items" }')
|
|
77
|
+
expect(manifestValidators(ast)["product"]?.["setupItems"]?.onUnavailable).toBe("reject")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("refuses a validator naming a field the model does not have", () => {
|
|
81
|
+
// Caught here, where the message can list the real fields, rather than becoming a manifest entry
|
|
82
|
+
// for a column that will never appear in a write.
|
|
83
|
+
expect(() => extract(' validate: { noSuchField: "check-it" }')).toThrow(
|
|
84
|
+
/names "noSuchField", which is not a field on this model.*id, sku, setupItems/s,
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("refuses a validate block that is not an object", () => {
|
|
89
|
+
expect(() => extract(" validate: string")).toThrow(/`validate` must be an object/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("emits nothing when a model declares none", () => {
|
|
93
|
+
const ast = extract("")
|
|
94
|
+
expect(declaredValidators(ast)).toEqual([])
|
|
95
|
+
expect(manifestValidators(ast)).toEqual({})
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("push refuses a validator whose function is missing", () => {
|
|
100
|
+
it("names the field and where the function should be", () => {
|
|
101
|
+
const ast = extract(' validate: { setupItems: "check-items" }')
|
|
102
|
+
const problems = validateModelValidators(ast, functionsDir("something-else"), process.cwd())
|
|
103
|
+
|
|
104
|
+
expect(problems.join("\n")).toMatch(/Product\.setupItems → "check-items"/)
|
|
105
|
+
expect(problems.join("\n")).toMatch(/something-else/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("says nothing when every function exists", () => {
|
|
109
|
+
const ast = extract(' validate: { setupItems: "check-items" }')
|
|
110
|
+
expect(validateModelValidators(ast, functionsDir("check-items"), process.cwd())).toEqual([])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe("doctor reports what will and will not run", () => {
|
|
115
|
+
it("separates a missing validator from a missing hook", () => {
|
|
116
|
+
const cwd = mkdtempSync(join(tmpdir(), "supatype-doctor-"))
|
|
117
|
+
dirs.push(cwd)
|
|
118
|
+
const ast = extract(' validate: { setupItems: "check-items", sku: "check-sku" }')
|
|
119
|
+
|
|
120
|
+
const report = hooksReport(cwd, functionsDir("check-sku"), ast)
|
|
121
|
+
|
|
122
|
+
expect(report.validators).toHaveLength(2)
|
|
123
|
+
expect(report.validatorsMissing.map((v) => v.field)).toEqual(["setupItems"])
|
|
124
|
+
// A validator problem is not a hook problem: the fix and the consequence differ.
|
|
125
|
+
expect(report.missing).toEqual([])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("notices a manifest with no validator map, which means nothing is being checked", () => {
|
|
129
|
+
const cwd = mkdtempSync(join(tmpdir(), "supatype-doctor-"))
|
|
130
|
+
dirs.push(cwd)
|
|
131
|
+
mkdirSync(join(cwd, ".supatype"), { recursive: true })
|
|
132
|
+
writeFileSync(join(cwd, ".supatype", "manifest.json"), JSON.stringify({ hooks: {} }), "utf8")
|
|
133
|
+
|
|
134
|
+
const ast = extract(' validate: { setupItems: "check-items" }')
|
|
135
|
+
const report = hooksReport(cwd, functionsDir("check-items"), ast)
|
|
136
|
+
|
|
137
|
+
expect(report.validatorMapMissing).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -210,3 +210,89 @@ export default nope
|
|
|
210
210
|
expect(output).toContain("comments")
|
|
211
211
|
})
|
|
212
212
|
})
|
|
213
|
+
|
|
214
|
+
describe("field validators", () => {
|
|
215
|
+
const VALIDATED_SCHEMA = `
|
|
216
|
+
import type { JSON, Model, Public, UUID } from "@supatype/types"
|
|
217
|
+
|
|
218
|
+
export type Product = Model<{
|
|
219
|
+
id: UUID
|
|
220
|
+
sku: string
|
|
221
|
+
setupItems: JSON<{ label: string }[]>
|
|
222
|
+
}, {
|
|
223
|
+
access: { read: Public }
|
|
224
|
+
validate: { setupItems: "check-setup-items", sku: { function: "check-sku", timeout: 3000 } }
|
|
225
|
+
}>
|
|
226
|
+
`
|
|
227
|
+
|
|
228
|
+
it("generates the module for a model that declares only validators", () => {
|
|
229
|
+
// Validators need the same row types hooks do. A model declaring only validators used to produce
|
|
230
|
+
// no module at all, which would make `FieldValidator<"product", ...>` fail to resolve.
|
|
231
|
+
const module = generateHooksModule(astFor(VALIDATED_SCHEMA))
|
|
232
|
+
expect(module).not.toBeNull()
|
|
233
|
+
expect(module).toContain("FieldValidator")
|
|
234
|
+
expect(module).toContain("export function fieldValidator(")
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("typechecks with a validator written the way the docs will show it", () => {
|
|
238
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-validatorcompile-"))
|
|
239
|
+
dirs.push(dir)
|
|
240
|
+
|
|
241
|
+
const module = generateHooksModule(astFor(VALIDATED_SCHEMA))
|
|
242
|
+
writeFileSync(join(dir, "hooks.ts"), module ?? "", "utf8")
|
|
243
|
+
writeFileSync(
|
|
244
|
+
join(dir, "deno.d.ts"),
|
|
245
|
+
`declare namespace Deno {
|
|
246
|
+
const env: { get(key: string): string | undefined }
|
|
247
|
+
}
|
|
248
|
+
`,
|
|
249
|
+
"utf8",
|
|
250
|
+
)
|
|
251
|
+
writeFileSync(
|
|
252
|
+
join(dir, "handler.ts"),
|
|
253
|
+
`import { fieldValidator, type FieldValidator } from "./hooks.ts"
|
|
254
|
+
|
|
255
|
+
// The value is the column's real type, so this narrows without a cast.
|
|
256
|
+
const checkItems: FieldValidator<"product", "setupItems"> = (ctx) => {
|
|
257
|
+
if (!Array.isArray(ctx.value) || ctx.value.length === 0) {
|
|
258
|
+
return "At least one setup item is required."
|
|
259
|
+
}
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export default fieldValidator(checkItems)
|
|
264
|
+
`,
|
|
265
|
+
"utf8",
|
|
266
|
+
)
|
|
267
|
+
writeFileSync(
|
|
268
|
+
join(dir, "tsconfig.json"),
|
|
269
|
+
JSON.stringify({
|
|
270
|
+
compilerOptions: {
|
|
271
|
+
strict: true,
|
|
272
|
+
exactOptionalPropertyTypes: true,
|
|
273
|
+
noEmit: true,
|
|
274
|
+
target: "ES2022",
|
|
275
|
+
module: "ESNext",
|
|
276
|
+
moduleResolution: "Bundler",
|
|
277
|
+
allowImportingTsExtensions: true,
|
|
278
|
+
lib: ["ES2022", "DOM"],
|
|
279
|
+
},
|
|
280
|
+
include: ["*.ts"],
|
|
281
|
+
}),
|
|
282
|
+
"utf8",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
const tsc = join(__dirname, "..", "..", "..", "node_modules", "typescript", "bin", "tsc")
|
|
286
|
+
let output = ""
|
|
287
|
+
try {
|
|
288
|
+
execFileSync(process.execPath, [tsc, "-p", join(dir, "tsconfig.json")], {
|
|
289
|
+
encoding: "utf8",
|
|
290
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
291
|
+
})
|
|
292
|
+
} catch (err) {
|
|
293
|
+
const failure = err as { stdout?: string; stderr?: string }
|
|
294
|
+
output = `${failure.stdout ?? ""}${failure.stderr ?? ""}`
|
|
295
|
+
}
|
|
296
|
+
expect(output, "the generated validator surface must compile").toBe("")
|
|
297
|
+
})
|
|
298
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
5
|
+
import { syncComposeImagePins, upsertDevComposeEnv } from "../src/dev-compose.js"
|
|
6
|
+
import {
|
|
7
|
+
LOCAL_SERVER_DOCKER_IMAGE,
|
|
8
|
+
usesLocalServerImage,
|
|
9
|
+
} from "../src/compose-local-server-image.js"
|
|
10
|
+
import type { SupatypeProjectConfig } from "../src/project-config.js"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `overrides.server` has to survive the next `.env` write, whichever command makes it.
|
|
14
|
+
*
|
|
15
|
+
* The local image reaches compose through one variable, and several code paths rewrite the file
|
|
16
|
+
* that holds it. When only one of them knew the local image existed, the others removed the key as
|
|
17
|
+
* an unpinned managed pin: `dev` started the local server, the next `push` deleted the variable,
|
|
18
|
+
* and compose recreated the container from the published image. Nothing failed, so the symptom was
|
|
19
|
+
* a contributor testing their own build and running someone else's.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const dirs: string[] = []
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
/** A project whose `overrides.server` points at a binary beside a Dockerfile. */
|
|
29
|
+
function project(versions?: SupatypeProjectConfig["versions"]): {
|
|
30
|
+
cwd: string
|
|
31
|
+
config: SupatypeProjectConfig
|
|
32
|
+
} {
|
|
33
|
+
const cwd = mkdtempSync(join(tmpdir(), "supatype-local-image-"))
|
|
34
|
+
dirs.push(cwd)
|
|
35
|
+
const serverRoot = join(cwd, "server-src")
|
|
36
|
+
mkdirSync(serverRoot, { recursive: true })
|
|
37
|
+
writeFileSync(join(serverRoot, "Dockerfile"), "FROM scratch\n", "utf8")
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
cwd,
|
|
41
|
+
config: {
|
|
42
|
+
project: { name: "demo" },
|
|
43
|
+
...(versions !== undefined && { versions }),
|
|
44
|
+
overrides: { server: join(serverRoot, "supatype-server.exe") },
|
|
45
|
+
} as SupatypeProjectConfig,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const envOf = (cwd: string): string => readFileSync(join(cwd, ".env"), "utf8")
|
|
50
|
+
|
|
51
|
+
describe("the locally built server image", () => {
|
|
52
|
+
it("is recognised from config alone, so no caller has to be told about it", () => {
|
|
53
|
+
const { cwd, config } = project({ server: "local" })
|
|
54
|
+
expect(usesLocalServerImage(cwd, config)).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("is not claimed when the override points nowhere near a Dockerfile", () => {
|
|
58
|
+
const { cwd, config } = project({ server: "local" })
|
|
59
|
+
const stray = { ...config, overrides: { server: join(cwd, "nope", "server.exe") } }
|
|
60
|
+
expect(usesLocalServerImage(cwd, stray as SupatypeProjectConfig)).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("is not claimed when versions.server names a release", () => {
|
|
64
|
+
const { cwd, config } = project({ server: "1.2.3" })
|
|
65
|
+
expect(usesLocalServerImage(cwd, config)).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("survives an image-pin sync that knows nothing about it", () => {
|
|
69
|
+
// The exact sequence that broke: `dev` writes the local image, then another command rewrites
|
|
70
|
+
// `.env` to reconcile pins from `versions`. The second command has no local image of its own,
|
|
71
|
+
// so the first must not leave the key looking like a pin the second is entitled to remove.
|
|
72
|
+
const { cwd, config } = project({ server: "local" })
|
|
73
|
+
writeFileSync(join(cwd, ".env"), "JWT_SECRET=x\n", "utf8")
|
|
74
|
+
|
|
75
|
+
upsertDevComposeEnv(cwd, config, "anon-key", "service-key", 54321)
|
|
76
|
+
expect(envOf(cwd)).toContain(`SUPATYPE_SERVER_IMAGE=${LOCAL_SERVER_DOCKER_IMAGE}`)
|
|
77
|
+
|
|
78
|
+
syncComposeImagePins(cwd, { project: { name: "demo" } } as SupatypeProjectConfig)
|
|
79
|
+
expect(envOf(cwd)).toContain(`SUPATYPE_SERVER_IMAGE=${LOCAL_SERVER_DOCKER_IMAGE}`)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("is cleared once the project stops asking for a local build", () => {
|
|
83
|
+
// Otherwise compose keeps starting a stale `local-dev` image that nothing rebuilds.
|
|
84
|
+
const { cwd, config } = project({ server: "local" })
|
|
85
|
+
writeFileSync(join(cwd, ".env"), "JWT_SECRET=x\n", "utf8")
|
|
86
|
+
upsertDevComposeEnv(cwd, config, "anon-key", "service-key", 54321)
|
|
87
|
+
|
|
88
|
+
const released = { ...config, versions: { server: "1.2.3" } } as SupatypeProjectConfig
|
|
89
|
+
upsertDevComposeEnv(cwd, released, "anon-key", "service-key", 54321)
|
|
90
|
+
|
|
91
|
+
expect(envOf(cwd)).not.toContain(LOCAL_SERVER_DOCKER_IMAGE)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
5
|
+
import { extractSchemaAstFromTypes } from "../src/type-extractor.js"
|
|
6
|
+
|
|
7
|
+
// Model-level constraints reuse the access-rule vocabulary deliberately: a constraint is an access
|
|
8
|
+
// rule with a narrower operand set. That reuse is the point, and it is also the risk, because the
|
|
9
|
+
// wider set is one import away. Everything a CHECK cannot evaluate has to be refused at push time,
|
|
10
|
+
// where the message can name the node, rather than at CREATE TABLE where Postgres names nothing.
|
|
11
|
+
|
|
12
|
+
const dirs: string[] = []
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function extract(body: string, prelude = ""): ReturnType<typeof extractSchemaAstFromTypes> {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-constraints-"))
|
|
20
|
+
dirs.push(dir)
|
|
21
|
+
const schemaPath = join(dir, "schema.ts")
|
|
22
|
+
writeFileSync(
|
|
23
|
+
schemaPath,
|
|
24
|
+
`
|
|
25
|
+
import type {
|
|
26
|
+
All, Any, AuthUid, Block, Blocks, Bytea, DateTime, Eq, Gte, IsNull, ItemCount, JSON, Length,
|
|
27
|
+
Literal, Lte, Matches, Model, NotNull, Now, Optional, Public, RichText, Role, UUID,
|
|
28
|
+
} from "@supatype/types"
|
|
29
|
+
|
|
30
|
+
${prelude}
|
|
31
|
+
|
|
32
|
+
${body}
|
|
33
|
+
`,
|
|
34
|
+
"utf8",
|
|
35
|
+
)
|
|
36
|
+
return extractSchemaAstFromTypes(schemaPath, dir)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const constraintsOf = (ast: ReturnType<typeof extractSchemaAstFromTypes>): unknown[] =>
|
|
40
|
+
(ast?.models[0]?.annotations.db.constraints ?? []) as unknown[]
|
|
41
|
+
|
|
42
|
+
describe("model constraints", () => {
|
|
43
|
+
it("extracts a cross-column comparison, which no field modifier can express", () => {
|
|
44
|
+
const ast = extract(`
|
|
45
|
+
export type Event = Model<{
|
|
46
|
+
id: UUID
|
|
47
|
+
starts_at: DateTime
|
|
48
|
+
ends_at: DateTime
|
|
49
|
+
}, {
|
|
50
|
+
access: { read: Public }
|
|
51
|
+
constraints: [Lte<"starts_at", "ends_at">]
|
|
52
|
+
}>`)
|
|
53
|
+
|
|
54
|
+
expect(constraintsOf(ast)).toEqual([
|
|
55
|
+
{
|
|
56
|
+
type: "compare",
|
|
57
|
+
op: "lte",
|
|
58
|
+
left: { kind: "column", name: "starts_at" },
|
|
59
|
+
right: { kind: "column", name: "ends_at" },
|
|
60
|
+
},
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("extracts the item-count and length operands", () => {
|
|
65
|
+
const ast = extract(`
|
|
66
|
+
export type Product = Model<{
|
|
67
|
+
id: UUID
|
|
68
|
+
setup_items: JSON<{ label: string }[]>
|
|
69
|
+
sku: string
|
|
70
|
+
}, {
|
|
71
|
+
access: { read: Public }
|
|
72
|
+
constraints: [Gte<ItemCount<"setup_items">, Literal<1>>, Lte<Length<"sku">, Literal<32>>]
|
|
73
|
+
}>`)
|
|
74
|
+
|
|
75
|
+
expect(constraintsOf(ast)).toMatchObject([
|
|
76
|
+
{ type: "compare", op: "gte", left: { kind: "itemCount", column: "setup_items" } },
|
|
77
|
+
{ type: "compare", op: "lte", left: { kind: "length", column: "sku" } },
|
|
78
|
+
])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("extracts a pattern as a pattern, never as an expression", () => {
|
|
82
|
+
const ast = extract(`
|
|
83
|
+
export type Part = Model<{
|
|
84
|
+
id: UUID
|
|
85
|
+
sku: string
|
|
86
|
+
}, {
|
|
87
|
+
access: { read: Public }
|
|
88
|
+
constraints: [Matches<"sku", "^[A-Z]{3}$">]
|
|
89
|
+
}>`)
|
|
90
|
+
|
|
91
|
+
expect(constraintsOf(ast)).toEqual([
|
|
92
|
+
{ type: "matches", column: "sku", pattern: "^[A-Z]{3}$" },
|
|
93
|
+
])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("extracts combinators, so a constraint can be more than one comparison", () => {
|
|
97
|
+
const ast = extract(`
|
|
98
|
+
export type Page = Model<{
|
|
99
|
+
id: UUID
|
|
100
|
+
status: "draft" | "published"
|
|
101
|
+
published_at: Optional<DateTime>
|
|
102
|
+
}, {
|
|
103
|
+
access: { read: Public }
|
|
104
|
+
constraints: [Any<[Eq<"status", Literal<"draft">>, NotNull<"published_at">]>]
|
|
105
|
+
}>`)
|
|
106
|
+
|
|
107
|
+
const [rule] = constraintsOf(ast) as Array<{ type: string; rules: unknown[] }>
|
|
108
|
+
expect(rule?.type).toBe("any")
|
|
109
|
+
expect(rule?.rules).toHaveLength(2)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe("model constraints: what a CHECK cannot evaluate is refused", () => {
|
|
114
|
+
const cases: Array<[string, string, RegExp]> = [
|
|
115
|
+
["the caller", "Eq<\"owner_id\", AuthUid>", /cannot see who is writing/],
|
|
116
|
+
["the caller's role", "Role<\"admin\">", /cannot see the caller's role/],
|
|
117
|
+
["the clock", "Lte<\"starts_at\", Now>", /cannot read the clock/],
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
for (const [what, rule, expected] of cases) {
|
|
121
|
+
it(`refuses ${what}, naming why`, () => {
|
|
122
|
+
expect(() =>
|
|
123
|
+
extract(`
|
|
124
|
+
export type Thing = Model<{
|
|
125
|
+
id: UUID
|
|
126
|
+
owner_id: UUID
|
|
127
|
+
status: string
|
|
128
|
+
starts_at: DateTime
|
|
129
|
+
}, {
|
|
130
|
+
access: { read: Public }
|
|
131
|
+
constraints: [${rule}]
|
|
132
|
+
}>`),
|
|
133
|
+
).toThrow(expected)
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
it("refuses one nested inside a combinator, not only at the top level", () => {
|
|
138
|
+
expect(() =>
|
|
139
|
+
extract(`
|
|
140
|
+
export type Thing = Model<{
|
|
141
|
+
id: UUID
|
|
142
|
+
owner_id: UUID
|
|
143
|
+
status: string
|
|
144
|
+
}, {
|
|
145
|
+
access: { read: Public }
|
|
146
|
+
constraints: [All<[Eq<"status", Literal<"live">>, Eq<"owner_id", AuthUid>]>]
|
|
147
|
+
}>`),
|
|
148
|
+
).toThrow(/cannot see who is writing/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("names the model and which constraint, so a large schema stays navigable", () => {
|
|
152
|
+
expect(() =>
|
|
153
|
+
extract(`
|
|
154
|
+
export type Thing = Model<{
|
|
155
|
+
id: UUID
|
|
156
|
+
a: DateTime
|
|
157
|
+
b: DateTime
|
|
158
|
+
owner_id: UUID
|
|
159
|
+
}, {
|
|
160
|
+
access: { read: Public }
|
|
161
|
+
constraints: [Lte<"a", "b">, Eq<"owner_id", AuthUid>]
|
|
162
|
+
}>`),
|
|
163
|
+
).toThrow(/Model "Thing": constraint 2/)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("refuses a constraints value that is not a tuple", () => {
|
|
167
|
+
expect(() =>
|
|
168
|
+
extract(`
|
|
169
|
+
export type Thing = Model<{ id: UUID }, {
|
|
170
|
+
access: { read: Public }
|
|
171
|
+
constraints: string
|
|
172
|
+
}>`),
|
|
173
|
+
).toThrow(/`constraints` must be a tuple/)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe("model constraints: absence", () => {
|
|
178
|
+
it("emits no key when a model declares none", () => {
|
|
179
|
+
const ast = extract(`
|
|
180
|
+
export type Plain = Model<{ id: UUID }, { access: { read: Public } }>`)
|
|
181
|
+
expect(ast?.models[0]?.annotations.db.constraints).toBeUndefined()
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe("the constraint operands stay out of access rules", () => {
|
|
186
|
+
// They share a parser, so nothing syntactic stops one appearing in an `access` block. The engine's
|
|
187
|
+
// RLS renderer has no case for them, so it would emit a policy that does not say what was written.
|
|
188
|
+
it("refuses Length in an access rule, naming where it belongs", () => {
|
|
189
|
+
expect(() =>
|
|
190
|
+
extract(`
|
|
191
|
+
export type Doc = Model<{ id: UUID; title: string }, {
|
|
192
|
+
access: { read: Gte<Length<"title">, Literal<5>> }
|
|
193
|
+
}>`),
|
|
194
|
+
).toThrow(/`Length<>` is not supported in an `access` rule.*belongs in `constraints`/s)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("refuses Matches in an access rule too", () => {
|
|
198
|
+
expect(() =>
|
|
199
|
+
extract(`
|
|
200
|
+
export type Doc = Model<{ id: UUID; sku: string }, {
|
|
201
|
+
access: { read: Matches<"sku", "^[A-Z]{3}$"> }
|
|
202
|
+
}>`),
|
|
203
|
+
).toThrow(/not supported in an `access` rule/)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe("constraint measures resolve to the same form the modifiers use", () => {
|
|
208
|
+
it("resolves Length per storage, not per hope", () => {
|
|
209
|
+
const ast = extract(`
|
|
210
|
+
export type Doc = Model<{
|
|
211
|
+
id: UUID
|
|
212
|
+
title: string
|
|
213
|
+
body: RichText
|
|
214
|
+
blob: Bytea
|
|
215
|
+
}, {
|
|
216
|
+
access: { read: Public }
|
|
217
|
+
constraints: [
|
|
218
|
+
Lte<Length<"title">, Literal<10>>,
|
|
219
|
+
Lte<Length<"body">, Literal<10>>,
|
|
220
|
+
Lte<Length<"blob">, Literal<10>>,
|
|
221
|
+
]
|
|
222
|
+
}>`)
|
|
223
|
+
|
|
224
|
+
expect(constraintsOf(ast)).toMatchObject([
|
|
225
|
+
{ left: { kind: "length", column: "title", form: "chars" } },
|
|
226
|
+
{ left: { kind: "length", column: "body", form: "richText" } },
|
|
227
|
+
{ left: { kind: "length", column: "blob", form: "octets" } },
|
|
228
|
+
])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("tells a real array from a JSON array, which JSONB alone cannot", () => {
|
|
232
|
+
const ast = extract(`
|
|
233
|
+
export type Doc = Model<{
|
|
234
|
+
id: UUID
|
|
235
|
+
tags: string[]
|
|
236
|
+
refs: JSON<{ id: string }[]>
|
|
237
|
+
sections: Blocks<Note>
|
|
238
|
+
}, {
|
|
239
|
+
access: { read: Public }
|
|
240
|
+
constraints: [
|
|
241
|
+
Lte<ItemCount<"tags">, Literal<3>>,
|
|
242
|
+
Gte<ItemCount<"refs">, Literal<1>>,
|
|
243
|
+
Gte<ItemCount<"sections">, Literal<1>>,
|
|
244
|
+
]
|
|
245
|
+
}>`, "type Note = Block<\"note\", { text: string }>")
|
|
246
|
+
|
|
247
|
+
expect(constraintsOf(ast)).toMatchObject([
|
|
248
|
+
{ left: { kind: "itemCount", column: "tags", form: "array" } },
|
|
249
|
+
{ left: { kind: "itemCount", column: "refs", form: "jsonbArray" } },
|
|
250
|
+
{ left: { kind: "itemCount", column: "sections", form: "jsonbArray" } },
|
|
251
|
+
])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it("refuses a measure the column cannot take, naming the alternative", () => {
|
|
255
|
+
expect(() =>
|
|
256
|
+
extract(`
|
|
257
|
+
export type Doc = Model<{ id: UUID; tags: string[] }, {
|
|
258
|
+
access: { read: Public }
|
|
259
|
+
constraints: [Lte<Length<"tags">, Literal<3>>]
|
|
260
|
+
}>`),
|
|
261
|
+
).toThrow(/an array has items, not characters; use MaxItems\/MinItems/)
|
|
262
|
+
|
|
263
|
+
expect(() =>
|
|
264
|
+
extract(`
|
|
265
|
+
export type Doc = Model<{ id: UUID; title: string }, {
|
|
266
|
+
access: { read: Public }
|
|
267
|
+
constraints: [Lte<ItemCount<"title">, Literal<3>>]
|
|
268
|
+
}>`),
|
|
269
|
+
).toThrow(/text has characters, not items/)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("refuses a measure over a column that is not a field", () => {
|
|
273
|
+
// Composite columns like `created_at` exist in the table and not in `fields`, so the measure
|
|
274
|
+
// cannot be resolved. Saying so beats emitting SQL against a kind nobody classified.
|
|
275
|
+
expect(() =>
|
|
276
|
+
extract(`
|
|
277
|
+
export type Doc = Model<{ id: UUID }, {
|
|
278
|
+
access: { read: Public }
|
|
279
|
+
constraints: [Lte<Length<"created_at">, Literal<3>>]
|
|
280
|
+
}>`),
|
|
281
|
+
).toThrow(/is not a field on this model/)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("resolves a measure nested inside a combinator", () => {
|
|
285
|
+
const ast = extract(`
|
|
286
|
+
export type Doc = Model<{ id: UUID; title: string }, {
|
|
287
|
+
access: { read: Public }
|
|
288
|
+
constraints: [Any<[Lte<Length<"title">, Literal<5>>, IsNull<"title">]>]
|
|
289
|
+
}>`)
|
|
290
|
+
const [rule] = constraintsOf(ast) as Array<{ rules: Array<{ left?: { form?: string } }> }>
|
|
291
|
+
expect(rule?.rules[0]?.left?.form).toBe("chars")
|
|
292
|
+
})
|
|
293
|
+
})
|
|
@@ -218,6 +218,62 @@ export type Post = Model<{ id: UUID }, { tableName: "posts" }>
|
|
|
218
218
|
) as Record<string, unknown>
|
|
219
219
|
expect(manifest["hooks"]).toBeUndefined()
|
|
220
220
|
})
|
|
221
|
+
|
|
222
|
+
it("writes the validator map, which is what makes a validator run at all", () => {
|
|
223
|
+
// The map was built and unit-tested for a release in which nothing wrote it to the manifest.
|
|
224
|
+
// The server therefore had no validator to call, and a write that should have been refused was
|
|
225
|
+
// accepted with a 201 and no error anywhere: the one failure this feature cannot have.
|
|
226
|
+
const { dir, ast } = project(`
|
|
227
|
+
import type { JSON, Model, UUID } from "@supatype/types"
|
|
228
|
+
|
|
229
|
+
export type Post = Model<{
|
|
230
|
+
id: UUID
|
|
231
|
+
items: JSON<{ minutes: number }[]>
|
|
232
|
+
}, {
|
|
233
|
+
tableName: "posts"
|
|
234
|
+
validate: { items: "check-items" }
|
|
235
|
+
}>
|
|
236
|
+
`)
|
|
237
|
+
mkdirSync(join(dir, ".supatype"), { recursive: true })
|
|
238
|
+
writeFileSync(
|
|
239
|
+
join(dir, ".supatype", "manifest.json"),
|
|
240
|
+
JSON.stringify({ functions_enabled: true }),
|
|
241
|
+
"utf8",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
expect(syncManifestHooks(dir, ast)).toBe(true)
|
|
245
|
+
const manifest = JSON.parse(
|
|
246
|
+
readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
|
|
247
|
+
) as Record<string, unknown>
|
|
248
|
+
const validators = manifest["validators"] as Record<string, Record<string, unknown>>
|
|
249
|
+
expect(validators?.["posts"]?.["items"]).toMatchObject({
|
|
250
|
+
function: "check-items",
|
|
251
|
+
onUnavailable: "reject",
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("removes the validator map when the last validator goes away", () => {
|
|
256
|
+
const { dir, ast } = project(`
|
|
257
|
+
import type { Model, UUID } from "@supatype/types"
|
|
258
|
+
|
|
259
|
+
export type Post = Model<{ id: UUID }, { tableName: "posts" }>
|
|
260
|
+
`)
|
|
261
|
+
mkdirSync(join(dir, ".supatype"), { recursive: true })
|
|
262
|
+
writeFileSync(
|
|
263
|
+
join(dir, ".supatype", "manifest.json"),
|
|
264
|
+
JSON.stringify({
|
|
265
|
+
functions_enabled: true,
|
|
266
|
+
validators: { posts: { items: { function: "gone" } } },
|
|
267
|
+
}),
|
|
268
|
+
"utf8",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
expect(syncManifestHooks(dir, ast)).toBe(true)
|
|
272
|
+
const manifest = JSON.parse(
|
|
273
|
+
readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
|
|
274
|
+
) as Record<string, unknown>
|
|
275
|
+
expect(manifest["validators"]).toBeUndefined()
|
|
276
|
+
})
|
|
221
277
|
})
|
|
222
278
|
|
|
223
279
|
describe("model hooks: generated module on disk", () => {
|