@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +136 -130
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/cli-version-embedded.js +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/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/push.d.ts.map +1 -1
- package/dist/commands/push.js +15 -7
- package/dist/commands/push.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/db-preflight.d.ts +23 -0
- package/dist/db-preflight.d.ts.map +1 -1
- package/dist/db-preflight.js +66 -1
- package/dist/db-preflight.js.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 +79 -10
- package/dist/dev-compose.js.map +1 -1
- 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/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/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/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/db.ts +27 -1
- package/src/commands/doctor.ts +30 -0
- package/src/commands/push.ts +26 -6
- package/src/compose-local-server-image.ts +17 -0
- package/src/db-preflight.ts +89 -1
- package/src/dev-compose.ts +96 -9
- package/src/field-bounds.ts +359 -0
- package/src/hooks-generator.ts +81 -4
- package/src/model-hooks.ts +158 -12
- package/src/schema-ast-v2.ts +114 -10
- package/src/type-extractor.ts +374 -39
- package/tests/db-preflight.test.ts +80 -0
- 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/type-extractor.test.ts +49 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
})
|
|
@@ -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
|
+
})
|