@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.
Files changed (143) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +150 -136
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/cli-version-embedded.d.ts.map +1 -1
  5. package/dist/cli-version-embedded.js +1 -1
  6. package/dist/cli-version-embedded.js.map +1 -1
  7. package/dist/commands/admin.d.ts +5 -5
  8. package/dist/commands/admin.d.ts.map +1 -1
  9. package/dist/commands/admin.js +10 -10
  10. package/dist/commands/admin.js.map +1 -1
  11. package/dist/commands/db.d.ts.map +1 -1
  12. package/dist/commands/db.js +23 -1
  13. package/dist/commands/db.js.map +1 -1
  14. package/dist/commands/dev.js +51 -51
  15. package/dist/commands/dev.js.map +1 -1
  16. package/dist/commands/doctor.d.ts +0 -7
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +26 -0
  19. package/dist/commands/doctor.js.map +1 -1
  20. package/dist/commands/init.js +1 -1
  21. package/dist/commands/logs.d.ts +14 -1
  22. package/dist/commands/logs.d.ts.map +1 -1
  23. package/dist/commands/logs.js +33 -37
  24. package/dist/commands/logs.js.map +1 -1
  25. package/dist/commands/push.d.ts.map +1 -1
  26. package/dist/commands/push.js +18 -7
  27. package/dist/commands/push.js.map +1 -1
  28. package/dist/commands/status.d.ts +1 -1
  29. package/dist/commands/status.d.ts.map +1 -1
  30. package/dist/commands/status.js +33 -64
  31. package/dist/commands/status.js.map +1 -1
  32. package/dist/compose-local-server-image.d.ts +11 -0
  33. package/dist/compose-local-server-image.d.ts.map +1 -1
  34. package/dist/compose-local-server-image.js +18 -0
  35. package/dist/compose-local-server-image.js.map +1 -1
  36. package/dist/compose-services.d.ts +59 -0
  37. package/dist/compose-services.d.ts.map +1 -0
  38. package/dist/compose-services.js +160 -0
  39. package/dist/compose-services.js.map +1 -0
  40. package/dist/config.d.ts +1 -1
  41. package/dist/config.d.ts.map +1 -1
  42. package/dist/dev-compose.d.ts +1 -0
  43. package/dist/dev-compose.d.ts.map +1 -1
  44. package/dist/dev-compose.js +202 -30
  45. package/dist/dev-compose.js.map +1 -1
  46. package/dist/dev-ports.d.ts +24 -0
  47. package/dist/dev-ports.d.ts.map +1 -1
  48. package/dist/dev-ports.js +37 -0
  49. package/dist/dev-ports.js.map +1 -1
  50. package/dist/docker-postgres.d.ts +1 -1
  51. package/dist/docker-postgres.d.ts.map +1 -1
  52. package/dist/docker-postgres.js +2 -2
  53. package/dist/docker-postgres.js.map +1 -1
  54. package/dist/engine-floor.d.ts +50 -0
  55. package/dist/engine-floor.d.ts.map +1 -0
  56. package/dist/engine-floor.js +80 -0
  57. package/dist/engine-floor.js.map +1 -0
  58. package/dist/field-bounds.d.ts +68 -0
  59. package/dist/field-bounds.d.ts.map +1 -0
  60. package/dist/field-bounds.js +277 -0
  61. package/dist/field-bounds.js.map +1 -0
  62. package/dist/hooks-generator.d.ts +1 -1
  63. package/dist/hooks-generator.d.ts.map +1 -1
  64. package/dist/hooks-generator.js +78 -4
  65. package/dist/hooks-generator.js.map +1 -1
  66. package/dist/link.d.ts +1 -1
  67. package/dist/link.d.ts.map +1 -1
  68. package/dist/model-hooks.d.ts +44 -2
  69. package/dist/model-hooks.d.ts.map +1 -1
  70. package/dist/model-hooks.js +116 -12
  71. package/dist/model-hooks.js.map +1 -1
  72. package/dist/pgbouncer-userlist.d.ts +1 -1
  73. package/dist/pgbouncer-userlist.d.ts.map +1 -1
  74. package/dist/pgbouncer-userlist.js +1 -1
  75. package/dist/pgbouncer-userlist.js.map +1 -1
  76. package/dist/postgres-ctl.d.ts +5 -1
  77. package/dist/postgres-ctl.d.ts.map +1 -1
  78. package/dist/postgres-ctl.js +43 -4
  79. package/dist/postgres-ctl.js.map +1 -1
  80. package/dist/project-config.d.ts +4 -4
  81. package/dist/resolve-target.d.ts +1 -1
  82. package/dist/resolve-target.d.ts.map +1 -1
  83. package/dist/schema-ast-v2.d.ts +38 -4
  84. package/dist/schema-ast-v2.d.ts.map +1 -1
  85. package/dist/schema-ast-v2.js +87 -4
  86. package/dist/schema-ast-v2.js.map +1 -1
  87. package/dist/self-host-compose.d.ts.map +1 -1
  88. package/dist/self-host-compose.js +41 -18
  89. package/dist/self-host-compose.js.map +1 -1
  90. package/dist/studio-dev-server.d.ts +8 -1
  91. package/dist/studio-dev-server.d.ts.map +1 -1
  92. package/dist/studio-dev-server.js +1 -2
  93. package/dist/studio-dev-server.js.map +1 -1
  94. package/dist/type-extractor.d.ts.map +1 -1
  95. package/dist/type-extractor.js +309 -27
  96. package/dist/type-extractor.js.map +1 -1
  97. package/package.json +4 -3
  98. package/src/cli-version-embedded.ts +1 -1
  99. package/src/commands/admin.ts +10 -10
  100. package/src/commands/db.ts +27 -1
  101. package/src/commands/dev.ts +51 -51
  102. package/src/commands/doctor.ts +30 -0
  103. package/src/commands/init.ts +1 -1
  104. package/src/commands/logs.ts +34 -37
  105. package/src/commands/push.ts +29 -6
  106. package/src/commands/status.ts +34 -70
  107. package/src/compose-local-server-image.ts +17 -0
  108. package/src/compose-services.ts +174 -0
  109. package/src/config.ts +1 -1
  110. package/src/dev-compose.ts +226 -29
  111. package/src/dev-ports.ts +48 -0
  112. package/src/docker-postgres.ts +2 -2
  113. package/src/engine-floor.ts +113 -0
  114. package/src/field-bounds.ts +359 -0
  115. package/src/hooks-generator.ts +81 -4
  116. package/src/link.ts +1 -1
  117. package/src/model-hooks.ts +158 -12
  118. package/src/pgbouncer-userlist.ts +1 -1
  119. package/src/postgres-ctl.ts +44 -4
  120. package/src/project-config.ts +4 -4
  121. package/src/resolve-target.ts +1 -1
  122. package/src/schema-ast-v2.ts +114 -10
  123. package/src/self-host-compose.ts +41 -18
  124. package/src/studio-dev-server.ts +9 -3
  125. package/src/type-extractor.ts +374 -39
  126. package/tests/admin-ensure.test.ts +10 -10
  127. package/tests/compose-parsable-defaults.test.ts +60 -0
  128. package/tests/compose-services.test.ts +144 -0
  129. package/tests/dev-ports-owner.test.ts +40 -0
  130. package/tests/engine-floor.test.ts +102 -0
  131. package/tests/external-database-compose.test.ts +3 -3
  132. package/tests/field-bounds-matrix.test.ts +163 -0
  133. package/tests/field-bounds.test.ts +139 -0
  134. package/tests/field-validators.test.ts +139 -0
  135. package/tests/hooks-generator.test.ts +86 -0
  136. package/tests/local-server-image-env.test.ts +93 -0
  137. package/tests/model-constraints.test.ts +293 -0
  138. package/tests/model-hooks.test.ts +56 -0
  139. package/tests/port-in-use.test.ts +58 -0
  140. package/tests/readiness-poll.test.ts +84 -0
  141. package/tests/runtime-contract.test.ts +1 -1
  142. package/tests/type-extractor.test.ts +49 -0
  143. 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", () => {