@supatype/cli 0.1.11 → 0.1.13

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 (68) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +136 -130
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/cli-version-embedded.js +1 -1
  5. package/dist/commands/db.d.ts.map +1 -1
  6. package/dist/commands/db.js +23 -1
  7. package/dist/commands/db.js.map +1 -1
  8. package/dist/commands/doctor.d.ts +0 -7
  9. package/dist/commands/doctor.d.ts.map +1 -1
  10. package/dist/commands/doctor.js +26 -0
  11. package/dist/commands/doctor.js.map +1 -1
  12. package/dist/commands/push.d.ts.map +1 -1
  13. package/dist/commands/push.js +15 -7
  14. package/dist/commands/push.js.map +1 -1
  15. package/dist/compose-local-server-image.d.ts +11 -0
  16. package/dist/compose-local-server-image.d.ts.map +1 -1
  17. package/dist/compose-local-server-image.js +18 -0
  18. package/dist/compose-local-server-image.js.map +1 -1
  19. package/dist/db-preflight.d.ts +23 -0
  20. package/dist/db-preflight.d.ts.map +1 -1
  21. package/dist/db-preflight.js +66 -1
  22. package/dist/db-preflight.js.map +1 -1
  23. package/dist/dev-compose.d.ts +1 -0
  24. package/dist/dev-compose.d.ts.map +1 -1
  25. package/dist/dev-compose.js +79 -10
  26. package/dist/dev-compose.js.map +1 -1
  27. package/dist/field-bounds.d.ts +68 -0
  28. package/dist/field-bounds.d.ts.map +1 -0
  29. package/dist/field-bounds.js +277 -0
  30. package/dist/field-bounds.js.map +1 -0
  31. package/dist/hooks-generator.d.ts +1 -1
  32. package/dist/hooks-generator.d.ts.map +1 -1
  33. package/dist/hooks-generator.js +78 -4
  34. package/dist/hooks-generator.js.map +1 -1
  35. package/dist/model-hooks.d.ts +44 -2
  36. package/dist/model-hooks.d.ts.map +1 -1
  37. package/dist/model-hooks.js +116 -12
  38. package/dist/model-hooks.js.map +1 -1
  39. package/dist/schema-ast-v2.d.ts +38 -4
  40. package/dist/schema-ast-v2.d.ts.map +1 -1
  41. package/dist/schema-ast-v2.js +87 -4
  42. package/dist/schema-ast-v2.js.map +1 -1
  43. package/dist/type-extractor.d.ts.map +1 -1
  44. package/dist/type-extractor.js +309 -27
  45. package/dist/type-extractor.js.map +1 -1
  46. package/package.json +4 -3
  47. package/src/cli-version-embedded.ts +1 -1
  48. package/src/commands/db.ts +27 -1
  49. package/src/commands/doctor.ts +30 -0
  50. package/src/commands/push.ts +26 -6
  51. package/src/compose-local-server-image.ts +17 -0
  52. package/src/db-preflight.ts +89 -1
  53. package/src/dev-compose.ts +96 -9
  54. package/src/field-bounds.ts +359 -0
  55. package/src/hooks-generator.ts +81 -4
  56. package/src/model-hooks.ts +158 -12
  57. package/src/schema-ast-v2.ts +114 -10
  58. package/src/type-extractor.ts +374 -39
  59. package/tests/db-preflight.test.ts +80 -0
  60. package/tests/field-bounds-matrix.test.ts +163 -0
  61. package/tests/field-bounds.test.ts +139 -0
  62. package/tests/field-validators.test.ts +139 -0
  63. package/tests/hooks-generator.test.ts +86 -0
  64. package/tests/local-server-image-env.test.ts +93 -0
  65. package/tests/model-constraints.test.ts +293 -0
  66. package/tests/model-hooks.test.ts +56 -0
  67. package/tests/type-extractor.test.ts +49 -0
  68. package/tsconfig.tsbuildinfo +1 -1
@@ -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", () => {
@@ -1322,4 +1322,53 @@ export type Media = Bucket<"media", {
1322
1322
  /Bucket "media": could not resolve its `access` rules/,
1323
1323
  )
1324
1324
  })
1325
+
1326
+ it("emits structured validation bounds alongside the compiled check constraint", () => {
1327
+ const dir = mkdtempSync(join(tmpdir(), "supatype-validation-"))
1328
+ dirs.push(dir)
1329
+ const schemaPath = join(dir, "schema.ts")
1330
+ writeFileSync(
1331
+ schemaPath,
1332
+ `
1333
+ import type {
1334
+ Model, Public, UUID, Int, Optional, MaxLength, MinLength, Between,
1335
+ } from "@supatype/types"
1336
+
1337
+ export type Review = Model<{
1338
+ id: UUID
1339
+ headline: MaxLength<string, 120>
1340
+ body: MinLength<MaxLength<string, 4000>, 20>
1341
+ rating: Between<Int, 1, 5>
1342
+ note: Optional<string>
1343
+ }, {
1344
+ access: { read: Public }
1345
+ }>
1346
+ `,
1347
+ "utf8",
1348
+ )
1349
+
1350
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
1351
+ const review = ast?.models.find((m) => m.name === "Review")
1352
+ expect(review).toBeDefined()
1353
+
1354
+ expect(review?.fields["headline"]).toMatchObject({
1355
+ check: 'char_length("{name}") <= 120',
1356
+ validation: { maxLength: 120 },
1357
+ })
1358
+
1359
+ // Stacked modifiers merge into one check and one validation object.
1360
+ expect(review?.fields["body"]).toMatchObject({
1361
+ validation: { maxLength: 4000, minLength: 20 },
1362
+ })
1363
+ expect(review?.fields["body"]?.["check"]).toContain('char_length("{name}") <= 4000')
1364
+ expect(review?.fields["body"]?.["check"]).toContain('char_length("{name}") >= 20')
1365
+
1366
+ expect(review?.fields["rating"]).toMatchObject({
1367
+ kind: "integer",
1368
+ validation: { min: 1, max: 5 },
1369
+ })
1370
+
1371
+ // A field with no constraint modifier carries no validation key at all.
1372
+ expect(review?.fields["note"]?.["validation"]).toBeUndefined()
1373
+ })
1325
1374
  })