@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,144 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { logsArgs } from "../src/commands/logs.js"
3
+ import { composeArgs, parseComposePs } from "../src/compose-services.js"
4
+
5
+ /**
6
+ * `supatype status` and `supatype logs` each carried a hardcoded list of container
7
+ * names. Both lists were wrong: they named `supatype-postgres` and `supatype-kong`,
8
+ * which the generated compose file does not create, and omitted `server`, `storage`,
9
+ * `functions-worker`, `schema-engine` and `valkey`, which it does. The file sets no
10
+ * `container_name`, so Docker names containers `<project>-<service>-<n>` and no
11
+ * guessed name could ever have matched.
12
+ *
13
+ * The fix is to ask Compose. These tests cover the parsing and argument seams, which
14
+ * are the parts that can be wrong without Docker present.
15
+ */
16
+
17
+ const ctx = {
18
+ composePath: "/proj/.supatype/self-host/docker-compose.yml",
19
+ projectRoot: "/proj",
20
+ projectName: "supatype-demo",
21
+ }
22
+
23
+ describe("parseComposePs", () => {
24
+ // Compose v2 emits newline-delimited objects.
25
+ it("reads the NDJSON form", () => {
26
+ const stdout = [
27
+ JSON.stringify({ Service: "server", Name: "supatype-demo-server-1", State: "running", Status: "Up 2 minutes" }),
28
+ JSON.stringify({ Service: "db", Name: "supatype-demo-db-1", State: "exited", Status: "Exited (1)" }),
29
+ ].join("\n")
30
+
31
+ expect(parseComposePs(stdout)).toEqual([
32
+ { service: "db", container: "supatype-demo-db-1", state: "exited", ports: "", status: "Exited (1)" },
33
+ { service: "server", container: "supatype-demo-server-1", state: "running", ports: "", status: "Up 2 minutes" },
34
+ ])
35
+ })
36
+
37
+ // Other versions emit one array. Which you get depends on the Docker installed.
38
+ it("reads the array form", () => {
39
+ const stdout = JSON.stringify([{ Service: "studio", Name: "supatype-demo-studio-1", State: "running" }])
40
+ expect(parseComposePs(stdout).map((s) => s.service)).toEqual(["studio"])
41
+ })
42
+
43
+ it("sorts by service so the report is stable between runs", () => {
44
+ const stdout = ["valkey", "db", "server"].map((s) => JSON.stringify({ Service: s, State: "running" })).join("\n")
45
+ expect(parseComposePs(stdout).map((s) => s.service)).toEqual(["db", "server", "valkey"])
46
+ })
47
+
48
+ // Compose prefixes its own progress lines on some versions, and prints nothing
49
+ // at all when the project has never been started.
50
+ it("survives noise and emptiness", () => {
51
+ expect(parseComposePs("")).toEqual([])
52
+ expect(parseComposePs(" \n")).toEqual([])
53
+ expect(parseComposePs("[not json")).toEqual([])
54
+ expect(parseComposePs('pulling...\n{"Service":"db","State":"running"}')).toHaveLength(1)
55
+ })
56
+
57
+ // A row without a service name cannot be reported against, and a missing state
58
+ // must not read as running.
59
+ it("drops rows with no service and does not invent a state", () => {
60
+ const stdout = [
61
+ JSON.stringify({ Name: "orphan", State: "running" }),
62
+ JSON.stringify({ Service: "db" }),
63
+ "null",
64
+ ].join("\n")
65
+ const got = parseComposePs(stdout)
66
+ expect(got).toHaveLength(1)
67
+ expect(got[0]).toMatchObject({ service: "db", state: "unknown", container: "", ports: "" })
68
+ })
69
+
70
+ describe("ports", () => {
71
+ it("takes the string form as given", () => {
72
+ const stdout = JSON.stringify({ Service: "db", State: "running", Publishers: "0.0.0.0:5432->5432/tcp" })
73
+ expect(parseComposePs(stdout)[0]?.ports).toBe("0.0.0.0:5432->5432/tcp")
74
+ })
75
+
76
+ // Compose lists an entry per protocol and per host interface, so the same
77
+ // mapping arrives several times over.
78
+ it("renders the structured form, de-duplicated", () => {
79
+ const stdout = JSON.stringify({
80
+ Service: "db",
81
+ State: "running",
82
+ Publishers: [
83
+ { URL: "0.0.0.0", TargetPort: 5432, PublishedPort: 5432, Protocol: "tcp" },
84
+ { URL: "::", TargetPort: 5432, PublishedPort: 5432, Protocol: "tcp" },
85
+ { URL: "0.0.0.0", TargetPort: 9000, PublishedPort: 19000, Protocol: "tcp" },
86
+ ],
87
+ })
88
+ expect(parseComposePs(stdout)[0]?.ports).toBe("5432->5432, 19000->9000")
89
+ })
90
+
91
+ // An unpublished port is reachable only from inside the network, so reporting
92
+ // it would tell the developer to try an address that will refuse.
93
+ it("omits ports that are not published to the host", () => {
94
+ const stdout = JSON.stringify({
95
+ Service: "valkey",
96
+ State: "running",
97
+ Publishers: [{ URL: "", TargetPort: 6379, PublishedPort: 0, Protocol: "tcp" }, null, "nonsense"],
98
+ })
99
+ expect(parseComposePs(stdout)[0]?.ports).toBe("")
100
+ })
101
+ })
102
+ })
103
+
104
+ describe("composeArgs", () => {
105
+ // Without --project-directory, Compose resolves relative build contexts and
106
+ // env_file paths against .supatype/self-host/ rather than the project root.
107
+ it("names the project, the project directory and the file", () => {
108
+ expect(composeArgs(ctx)).toEqual([
109
+ "compose",
110
+ "-p",
111
+ "supatype-demo",
112
+ "--project-directory",
113
+ "/proj",
114
+ "-f",
115
+ "/proj/.supatype/self-host/docker-compose.yml",
116
+ ])
117
+ })
118
+ })
119
+
120
+ describe("logsArgs", () => {
121
+ it("tails the whole stack by default", () => {
122
+ expect(logsArgs(["compose"], { since: "5m", follow: true })).toEqual([
123
+ "compose",
124
+ "logs",
125
+ "--tail",
126
+ "100",
127
+ "--follow",
128
+ "--since",
129
+ "5m",
130
+ ])
131
+ })
132
+
133
+ // The service name is the Compose service, and it has to come last: Compose
134
+ // reads trailing arguments as the services to tail.
135
+ it("puts the service last", () => {
136
+ const args = logsArgs(["compose"], { service: "server", follow: false })
137
+ expect(args.at(-1)).toBe("server")
138
+ expect(args).not.toContain("--follow")
139
+ })
140
+
141
+ it("omits --since when there is none", () => {
142
+ expect(logsArgs(["compose"], {})).not.toContain("--since")
143
+ })
144
+ })
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { composeProjectOwnsPort } from "../src/dev-ports.js"
3
+
4
+ /**
5
+ * The decision behind the port guard, without Docker.
6
+ *
7
+ * `supatype self-host compose up -d` followed by `supatype push` was impossible: the second
8
+ * command saw its own gateway on the port and refused. The fix has to accept our own stack while
9
+ * still refusing a stranger's, because proceeding against another project's gateway would push a
10
+ * schema into the wrong database.
11
+ */
12
+ describe("composeProjectOwnsPort", () => {
13
+ it("accepts a port published by this project", () => {
14
+ expect(composeProjectOwnsPort("supatype-validation\n", "supatype-validation")).toBe(true)
15
+ })
16
+
17
+ it("refuses a port published by a different project", () => {
18
+ expect(composeProjectOwnsPort("someone-elses-stack\n", "supatype-validation")).toBe(false)
19
+ })
20
+
21
+ it("refuses when any publisher is a different project", () => {
22
+ const output = "supatype-validation\nsomeone-elses-stack\n"
23
+ expect(composeProjectOwnsPort(output, "supatype-validation")).toBe(false)
24
+ })
25
+
26
+ it("refuses when nothing reports a compose project", () => {
27
+ // A container with no compose label: something is on the port, and it is not ours.
28
+ expect(composeProjectOwnsPort("\n\n", "supatype-validation")).toBe(false)
29
+ expect(composeProjectOwnsPort("", "supatype-validation")).toBe(false)
30
+ })
31
+
32
+ it("tolerates the whitespace docker pads its columns with", () => {
33
+ expect(composeProjectOwnsPort(" supatype-validation \n", "supatype-validation")).toBe(true)
34
+ })
35
+
36
+ it("does not match on a prefix", () => {
37
+ // `supatype-validation-2` is a different project, and a substring check would accept it.
38
+ expect(composeProjectOwnsPort("supatype-validation-2\n", "supatype-validation")).toBe(false)
39
+ })
40
+ })
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ assertEngineSupportsSchema,
4
+ boundsRequiringHelpers,
5
+ compareVersions,
6
+ ENGINE_MIN_FOR_BOUNDS,
7
+ } from "../src/engine-floor.js"
8
+ import type { ExtractedSchemaAstV2, FieldAstV2, ModelAstV2 } from "../src/schema-ast-v2.js"
9
+
10
+ function field(overrides: Partial<FieldAstV2> = {}): FieldAstV2 {
11
+ return {
12
+ kind: "text",
13
+ annotations: { db: { pgType: "TEXT" }, platform: {} },
14
+ ...overrides,
15
+ } as FieldAstV2
16
+ }
17
+
18
+ function model(name: string, fields: Record<string, FieldAstV2>, constraints?: unknown[]): ModelAstV2 {
19
+ return {
20
+ name,
21
+ fields,
22
+ options: {},
23
+ annotations: {
24
+ db: { tableName: name.toLowerCase(), indexes: [], ...(constraints && { constraints }) },
25
+ platform: { access: {} },
26
+ },
27
+ }
28
+ }
29
+
30
+ function schema(models: ModelAstV2[]): ExtractedSchemaAstV2 {
31
+ return { astVersion: 2, models } as ExtractedSchemaAstV2
32
+ }
33
+
34
+ const WITH_BOUND = schema([model("Post", { title: field({ validation: { maxLength: 80 } }) })])
35
+ const WITHOUT_BOUND = schema([model("Post", { title: field() })])
36
+
37
+ describe("compareVersions", () => {
38
+ it("orders by each numeric part, not lexically", () => {
39
+ expect(compareVersions("0.10.0", "0.9.0")).toBeGreaterThan(0)
40
+ expect(compareVersions("0.2.0", "0.2.0")).toBe(0)
41
+ expect(compareVersions("0.1.9", "0.2.0")).toBeLessThan(0)
42
+ })
43
+
44
+ it("ignores a leading v and a pre-release suffix", () => {
45
+ // A release candidate of the engine that creates the helpers does create them.
46
+ expect(compareVersions("v0.2.0", "0.2.0")).toBe(0)
47
+ expect(compareVersions("0.2.0-rc.1", "0.2.0")).toBe(0)
48
+ })
49
+ })
50
+
51
+ describe("boundsRequiringHelpers", () => {
52
+ it("names the fields that declare a bound", () => {
53
+ expect(boundsRequiringHelpers(WITH_BOUND)).toEqual(["Post.title"])
54
+ })
55
+
56
+ it("names models carrying a constraint", () => {
57
+ const ast = schema([model("Event", { start: field() }, [{ expr: "start < finish" }])])
58
+ expect(boundsRequiringHelpers(ast)).toEqual(["Event"])
59
+ })
60
+
61
+ it("is empty for a schema that declares neither", () => {
62
+ expect(boundsRequiringHelpers(WITHOUT_BOUND)).toEqual([])
63
+ })
64
+ })
65
+
66
+ describe("assertEngineSupportsSchema", () => {
67
+ it("refuses a bound schema on an engine below the floor", () => {
68
+ expect(() => assertEngineSupportsSchema(WITH_BOUND, "0.1.9")).toThrow(/schema-engine 0\.2\.0 or newer/)
69
+ })
70
+
71
+ it("names the pin and the field, so the message is actionable", () => {
72
+ try {
73
+ assertEngineSupportsSchema(WITH_BOUND, "0.1.9")
74
+ expect.unreachable("should have refused")
75
+ } catch (err) {
76
+ const message = err instanceof Error ? err.message : String(err)
77
+ expect(message).toContain("pins 0.1.9")
78
+ expect(message).toContain("Post.title")
79
+ expect(message).toContain("versions: { engine:")
80
+ }
81
+ })
82
+
83
+ // The three cases where refusing would be worse than the failure it prevents: an old pin is
84
+ // fine without bounds, an unpinned project resolves to latest, and `local` cannot be compared.
85
+ it("allows an old pin when the schema declares no bounds", () => {
86
+ expect(() => assertEngineSupportsSchema(WITHOUT_BOUND, "0.1.9")).not.toThrow()
87
+ })
88
+
89
+ it("allows an unpinned project", () => {
90
+ expect(() => assertEngineSupportsSchema(WITH_BOUND, undefined)).not.toThrow()
91
+ })
92
+
93
+ it("allows a local override, whose version the config does not know", () => {
94
+ expect(() => assertEngineSupportsSchema(WITH_BOUND, "local")).not.toThrow()
95
+ })
96
+
97
+ it("allows the floor itself and anything above it", () => {
98
+ expect(() => assertEngineSupportsSchema(WITH_BOUND, ENGINE_MIN_FOR_BOUNDS)).not.toThrow()
99
+ expect(() => assertEngineSupportsSchema(WITH_BOUND, "0.3.1")).not.toThrow()
100
+ expect(() => assertEngineSupportsSchema(WITH_BOUND, "1.0.0")).not.toThrow()
101
+ })
102
+ })
@@ -53,7 +53,7 @@ describe("external database: compose", () => {
53
53
 
54
54
  it("points every database consumer at the one URL", () => {
55
55
  const compose = renderSelfHostCompose(external())
56
- // storage, realtime, control-plane, server (SUPATYPE_SQL_DATABASE_URL), and GoTrue.
56
+ // storage, realtime, control-plane, server (SUPATYPE_SQL_DATABASE_URL), and auth.
57
57
  const references = compose.match(/\$\{DATABASE_URL:\?/g) ?? []
58
58
  expect(references.length).toBe(5)
59
59
  })
@@ -85,9 +85,9 @@ describe("external database: compose", () => {
85
85
  expect(compose).toContain("@db.example.com:5432/app?sslmode=require")
86
86
  })
87
87
 
88
- it("appends GoTrue's search_path with the right separator", () => {
88
+ it("appends the auth search_path with the right separator", () => {
89
89
  // A URL that already has a query string needs `&`; a second `?` produces a DSN that fails to
90
- // parse, and GoTrue would exit rather than run its auth migrations.
90
+ // parse, and the service would exit rather than run its auth migrations.
91
91
  expect(renderSelfHostCompose(external())).toContain('${DATABASE_URL:?DATABASE_URL is missing from .env, required by database.external}?search_path=auth"')
92
92
  expect(
93
93
  renderSelfHostCompose(
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { compileBounds } from "../src/field-bounds.js"
3
+ import type { FieldKind } from "../src/schema-ast-v2.js"
4
+
5
+ /**
6
+ * Every field kind, against every bound family, asserted to do exactly one of two things.
7
+ *
8
+ * This is the test whose absence caused the whole defect. Nine of twelve field kinds accepted a
9
+ * declared bound and enforced nothing, and no test noticed, because no test enumerated the kinds:
10
+ * each was handled wherever someone happened to look.
11
+ *
12
+ * `MATRIX` is typed `Record<FieldKind, KindRow>`, so **completeness is the compiler's job**: adding
13
+ * a kind to `FIELD_KINDS` fails the build in three places at once, here, in `BOUNDS_BY_KIND`, and
14
+ * at any `scalar()` call using a name the registry does not know. No assertion can rot into
15
+ * vacuous, because there is nothing to keep in sync by hand.
16
+ *
17
+ * Every cell is either an expression or a refusal. **A cell that is silently empty is the bug**, so
18
+ * there is no way to express one.
19
+ */
20
+
21
+ type Cell = { sql: string } | { refused: RegExp }
22
+
23
+ interface KindRow {
24
+ /** `MaxLength<T, 5>` */
25
+ length: Cell
26
+ /** `MaxItems<T, 5>` */
27
+ items: Cell
28
+ /** `Between<T, ...>`, using whichever bound shape the kind accepts. */
29
+ range: Cell
30
+ /** The literal to bound with, when the kind takes a string rather than a number. */
31
+ rangeBound?: string
32
+ }
33
+
34
+ const COL = '"{name}"'
35
+ const refusedAs = (alternative: string): Cell => ({ refused: new RegExp(alternative) })
36
+
37
+ const textual = (): KindRow => ({
38
+ length: { sql: `char_length(${COL}) <= 5` },
39
+ items: refusedAs("MaxItems is not supported"),
40
+ range: refusedAs("Between is not supported"),
41
+ })
42
+
43
+ const numeric = (): KindRow => ({
44
+ length: refusedAs("MaxLength is not supported"),
45
+ items: refusedAs("MaxItems is not supported"),
46
+ range: { sql: `${COL} <= 5` },
47
+ })
48
+
49
+ // An interval is bounded by a duration, not by a date. The matrix keeps them distinct because the
50
+ // extractor validates the literal shape per cast and would otherwise reject one of them at push time.
51
+ const temporal = (cast: string, bound = "2026-12-31"): KindRow => ({
52
+ length: refusedAs("MaxLength is not supported"),
53
+ items: refusedAs("MaxItems is not supported"),
54
+ range: { sql: `${COL} <= '${bound}'::${cast}` },
55
+ rangeBound: bound,
56
+ })
57
+
58
+ const unbounded = (why: string): KindRow => ({
59
+ length: refusedAs(why),
60
+ items: refusedAs(why),
61
+ range: refusedAs(why),
62
+ })
63
+
64
+ const collection = (measure: string): KindRow => ({
65
+ length: refusedAs("MaxItems"),
66
+ items: { sql: measure },
67
+ range: refusedAs("Between is not supported"),
68
+ })
69
+
70
+ const MATRIX: Record<FieldKind, KindRow> = {
71
+ text: textual(),
72
+ email: textual(),
73
+ url: textual(),
74
+ slug: textual(),
75
+ color: textual(),
76
+ xml: textual(),
77
+ ip: textual(),
78
+ cidr: textual(),
79
+ macaddr: textual(),
80
+ tsQuery: textual(),
81
+ tsVector: textual(),
82
+
83
+ richText: {
84
+ length: { sql: `char_length(_supatype.richtext_text(${COL})) <= 5` },
85
+ items: refusedAs("MaxItems is not supported"),
86
+ range: refusedAs("Between is not supported"),
87
+ },
88
+ bytes: {
89
+ length: { sql: `octet_length(${COL}) <= 5` },
90
+ items: refusedAs("MaxItems is not supported"),
91
+ range: refusedAs("Between is not supported"),
92
+ },
93
+
94
+ integer: numeric(),
95
+ smallInt: numeric(),
96
+ bigInt: numeric(),
97
+ float: numeric(),
98
+ serial: numeric(),
99
+ bigSerial: numeric(),
100
+ decimal: numeric(),
101
+ money: numeric(),
102
+
103
+ datetime: temporal("timestamptz"),
104
+ timestamp: temporal("timestamp"),
105
+ date: temporal("date"),
106
+ interval: temporal("interval", "30 days"),
107
+
108
+ array: collection(`cardinality(${COL}) <= 5`),
109
+ blocks: collection(`jsonb_typeof(${COL}) = 'array' AND jsonb_array_length(${COL}) <= 5`),
110
+ json: unbounded("JSON object has no single measure"),
111
+ button: unbounded("composite value"),
112
+
113
+ enum: unbounded("union already constrains"),
114
+ boolean: unbounded("boolean has two values"),
115
+ uuid: unbounded("fixed width"),
116
+ image: unbounded("fileSizeLimit"),
117
+ file: unbounded("fileSizeLimit"),
118
+ geo: unbounded("not measured this way"),
119
+ vector: unbounded("dimension is already fixed"),
120
+ relation: unbounded("bound the column on the model"),
121
+ custom: unbounded("plugin field declares its own storage"),
122
+ timestamps: unbounded("composite expands into columns"),
123
+ publishable: unbounded("composite expands into columns"),
124
+ softDelete: unbounded("composite expands into columns"),
125
+ }
126
+
127
+ function assertCell(kind: FieldKind, family: string, cell: Cell, run: () => { check?: string }): void {
128
+ if ("refused" in cell) {
129
+ expect(
130
+ run,
131
+ `${kind}.${family} must refuse rather than silently accept a bound it cannot honour`,
132
+ ).toThrow(cell.refused)
133
+ return
134
+ }
135
+ const { check } = run()
136
+ expect(check, `${kind}.${family} must compile to its own expression`).toBe(cell.sql)
137
+ }
138
+
139
+ describe("bounds matrix", () => {
140
+ for (const [kind, row] of Object.entries(MATRIX) as Array<[FieldKind, KindRow]>) {
141
+ describe(kind, () => {
142
+ it("length", () => {
143
+ assertCell(kind, "length", row.length, () => compileBounds("f", kind, { maxLength: 5 }))
144
+ })
145
+
146
+ it("items", () => {
147
+ assertCell(kind, "items", row.items, () => compileBounds("f", kind, { maxItems: 5 }))
148
+ })
149
+
150
+ it("range", () => {
151
+ const max = row.rangeBound ?? 5
152
+ assertCell(kind, "range", row.range, () => compileBounds("f", kind, { max }))
153
+ })
154
+ })
155
+ }
156
+
157
+ it("treats JSON as a collection only when its type argument is an array", () => {
158
+ // The one cell the kind alone cannot decide: the CLI reads it from the declared type.
159
+ expect(compileBounds("f", "json", { maxItems: 5 }, { jsonIsArray: true }).check).toBe(
160
+ `jsonb_typeof(${COL}) = 'array' AND jsonb_array_length(${COL}) <= 5`,
161
+ )
162
+ })
163
+ })
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { compileBounds } from "../src/field-bounds.js"
3
+ import type { FieldKind } from "../src/schema-ast-v2.js"
4
+
5
+ // The bug this module exists to prevent: a declared bound that reaches neither Postgres nor Studio
6
+ // and raises nothing. Every assertion here is therefore either "this exact SQL" or "this throws".
7
+ // A kind that silently returns no check is the failure mode, so there is no test that permits one.
8
+
9
+ describe("compileBounds: length", () => {
10
+ it("counts characters for text-like kinds", () => {
11
+ expect(compileBounds("title", "text", { maxLength: 120 })).toEqual({
12
+ check: 'char_length("{name}") <= 120',
13
+ validation: { maxLength: 120 },
14
+ })
15
+ expect(compileBounds("email", "email", { minLength: 5 }).check).toBe(
16
+ 'char_length("{name}") >= 5',
17
+ )
18
+ })
19
+
20
+ it("counts octets for a binary column, not characters", () => {
21
+ expect(compileBounds("blob", "bytes", { maxLength: 1024 }).check).toBe(
22
+ 'octet_length("{name}") <= 1024',
23
+ )
24
+ })
25
+
26
+ it("counts plain-text characters for rich text, through the managed helper", () => {
27
+ expect(compileBounds("body", "richText", { maxLength: 320 }).check).toBe(
28
+ 'char_length(_supatype.richtext_text("{name}")) <= 320',
29
+ )
30
+ })
31
+
32
+ it("merges both ends into one constraint", () => {
33
+ expect(compileBounds("body", "text", { maxLength: 4000, minLength: 20 })).toEqual({
34
+ check: '(char_length("{name}") <= 4000) AND (char_length("{name}") >= 20)',
35
+ validation: { maxLength: 4000, minLength: 20 },
36
+ })
37
+ })
38
+ })
39
+
40
+ describe("compileBounds: items", () => {
41
+ it("uses cardinality for a real array, never char_length", () => {
42
+ // char_length(text[]) does not exist: the old generator's output failed CREATE TABLE.
43
+ expect(compileBounds("tags", "array", { maxItems: 10 }).check).toBe(
44
+ 'cardinality("{name}") <= 10',
45
+ )
46
+ })
47
+
48
+ it("guards jsonb_array_length with a type check", () => {
49
+ // Unguarded, an object value raises "cannot get array length of a non-array" at insert.
50
+ expect(compileBounds("sections", "blocks", { minItems: 1 }).check).toBe(
51
+ 'jsonb_typeof("{name}") = \'array\' AND jsonb_array_length("{name}") >= 1',
52
+ )
53
+ })
54
+
55
+ it("accepts item bounds on JSON only when the declared type argument is an array", () => {
56
+ expect(compileBounds("items", "json", { minItems: 1 }, { jsonIsArray: true }).check).toBe(
57
+ 'jsonb_typeof("{name}") = \'array\' AND jsonb_array_length("{name}") >= 1',
58
+ )
59
+ expect(() => compileBounds("meta", "json", { minItems: 1 })).toThrow(
60
+ /MinItems is not supported on a json field.*JSON<T\[\]>/s,
61
+ )
62
+ })
63
+ })
64
+
65
+ describe("compileBounds: range", () => {
66
+ it("compares a numeric column directly, with no cast", () => {
67
+ expect(compileBounds("score", "integer", { min: 1, max: 5 })).toEqual({
68
+ check: '("{name}" >= 1) AND ("{name}" <= 5)',
69
+ validation: { min: 1, max: 5 },
70
+ })
71
+ })
72
+
73
+ it("money and decimal are NUMERIC columns, so they need no cast either", () => {
74
+ expect(compileBounds("price", "money", { min: 0 }).check).toBe('"{name}" >= 0')
75
+ expect(compileBounds("rate", "decimal", { max: 100 }).check).toBe('"{name}" <= 100')
76
+ })
77
+
78
+ it("casts a temporal bound to the column's own type", () => {
79
+ expect(compileBounds("startsAt", "datetime", { min: "2026-01-01T00:00:00Z" }).check).toBe(
80
+ `"{name}" >= '2026-01-01T00:00:00Z'::timestamptz`,
81
+ )
82
+ expect(compileBounds("day", "date", { max: "2026-12-31" }).check).toBe(
83
+ `"{name}" <= '2026-12-31'::date`,
84
+ )
85
+ expect(compileBounds("window", "interval", { max: "30 days" }).check).toBe(
86
+ `"{name}" <= '30 days'::interval`,
87
+ )
88
+ })
89
+
90
+ it("refuses a string bound on a number and a number bound on a date", () => {
91
+ expect(() => compileBounds("score", "integer", { min: "1" })).toThrow(
92
+ /takes numbers, but "1" is a string/,
93
+ )
94
+ expect(() => compileBounds("day", "date", { min: 20260101 })).toThrow(
95
+ /takes ISO-8601 string bounds, but 20260101 is a number/,
96
+ )
97
+ })
98
+
99
+ it("refuses a temporal bound that is not a date, before it reaches a migration", () => {
100
+ expect(() => compileBounds("day", "date", { max: "next tuesday" })).toThrow(
101
+ /is not a valid ISO-8601 date/,
102
+ )
103
+ })
104
+ })
105
+
106
+ describe("compileBounds: refusals name the alternative", () => {
107
+ const cases: Array<[string, FieldKind, Parameters<typeof compileBounds>[2], RegExp]> = [
108
+ ["tags", "array", { maxLength: 10 }, /MaxLength is not supported on a array field.*MaxItems/s],
109
+ ["body", "richText", { maxItems: 3 }, /MaxItems is not supported on a richText field/],
110
+ ["score", "integer", { maxLength: 3 }, /MaxLength is not supported on a integer field/],
111
+ ["title", "text", { min: 1 }, /Between is not supported on a text field/],
112
+ ["status", "enum", { maxLength: 5 }, /union already constrains the permitted values/],
113
+ ["cover", "image", { maxLength: 5 }, /fileSizeLimit and allowedMimeTypes/],
114
+ ["embedding", "vector", { max: 1 }, /dimension is already fixed by the type/],
115
+ ["author", "relation", { maxLength: 5 }, /bound the column on the model this relation points at/],
116
+ ["flag", "boolean", { min: 0 }, /a boolean has two values/],
117
+ ]
118
+
119
+ for (const [field, kind, bounds, expected] of cases) {
120
+ it(`${kind} refuses and says where to go instead`, () => {
121
+ expect(() => compileBounds(field, kind, bounds)).toThrow(expected)
122
+ })
123
+ }
124
+
125
+ it("names the field, so the error is actionable in a large schema", () => {
126
+ expect(() => compileBounds("setupItems", "json", { maxLength: 3 })).toThrow(/Field "setupItems"/)
127
+ })
128
+ })
129
+
130
+ describe("compileBounds: coverage is enforced, not assumed", () => {
131
+ // Completeness moved from a runtime throw to the type system: `BOUNDS_BY_KIND` is
132
+ // `Record<FieldKind, ...>` and `scalar()` takes a `FieldKind`, so an unclassified kind cannot be
133
+ // written down, let alone reach here. Asserting the old throw would mean calling `compileBounds`
134
+ // with a kind that no longer typechecks, which is the point.
135
+
136
+ it("returns nothing when nothing was declared", () => {
137
+ expect(compileBounds("x", "text", {})).toEqual({})
138
+ })
139
+ })