@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
@@ -256,7 +256,7 @@ export const DEFAULT_SERVER_DOCKER_IMAGE = "supatype/server:latest"
256
256
  * Run `supatype-server migrate` on the Postgres container network (loopback trust).
257
257
  * Used on Windows + database.provider docker, host-published :5432 breaks libpq TLS there.
258
258
  */
259
- export function runGotrueMigrationsViaDocker(
259
+ export function runAuthMigrationsViaDocker(
260
260
  pgContainerName: string,
261
261
  serverImage: string,
262
262
  migrateEnv: Record<string, string>,
@@ -276,7 +276,7 @@ export function runGotrueMigrationsViaDocker(
276
276
  if (result.status !== 0) {
277
277
  const detail = (result.stderr ?? result.stdout ?? "").trim()
278
278
  throw new Error(
279
- `GoTrue migrations failed in Docker (exit ${result.status ?? "unknown"})` +
279
+ `auth migrations failed in Docker (exit ${result.status ?? "unknown"})` +
280
280
  (detail ? `:\n${detail}` : ""),
281
281
  )
282
282
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The engine version a schema needs, checked before the engine is asked to apply it.
3
+ *
4
+ * Field bounds and model constraints compile to CHECK constraints that call helper functions in
5
+ * the `_supatype` schema, and only the engine creates those helpers. A CLI that emits the call
6
+ * against an engine that does not create the function produces this, three times, and then gives
7
+ * up:
8
+ *
9
+ * Failed to apply migration
10
+ * Caused by: error returned from database:
11
+ * function _supatype.richtext_text(jsonb) does not exist
12
+ *
13
+ * Which says nothing about the cause. A project on `latest` is fine; the exposed case is a pin,
14
+ * `versions: { engine: "0.1.9" }`, where the CLI moved and the engine did not.
15
+ *
16
+ * The check is conditional on the schema, not a flat floor: a schema that declares no bounds and
17
+ * no model constraints works on an older engine, and refusing it would break projects for a
18
+ * feature they do not use.
19
+ *
20
+ * It reads the pin rather than asking a binary for its version, because the pin is what both paths
21
+ * resolve from: the native provider downloads that version, and the docker provider tags the
22
+ * compose image with it. An unpinned project resolves to latest, which is at or above the floor by
23
+ * definition, so there is nothing to check.
24
+ */
25
+ import type { ExtractedSchemaAstV2, ModelAstV2 } from "./schema-ast-v2.js"
26
+
27
+ /** First engine release that creates the `_supatype` constraint helpers (schema-engine v0.2.0). */
28
+ export const ENGINE_MIN_FOR_BOUNDS = "0.2.0"
29
+
30
+ /**
31
+ * Compare two dotted versions numerically. Returns <0, 0 or >0.
32
+ *
33
+ * Pre-release suffixes are dropped before comparing, so `0.2.0-rc.1` counts as `0.2.0`. That is
34
+ * deliberate: a release candidate of the engine that creates the helpers does create them, and
35
+ * refusing it would send someone testing a pre-release down a false trail.
36
+ */
37
+ export function compareVersions(a: string, b: string): number {
38
+ const parts = (v: string): number[] =>
39
+ v
40
+ .trim()
41
+ .replace(/^v/, "")
42
+ .split("-")[0]!
43
+ .split(".")
44
+ .map((n) => Number.parseInt(n, 10))
45
+ .map((n) => (Number.isNaN(n) ? 0 : n))
46
+ const left = parts(a)
47
+ const right = parts(b)
48
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
49
+ const diff = (left[i] ?? 0) - (right[i] ?? 0)
50
+ if (diff !== 0) return diff
51
+ }
52
+ return 0
53
+ }
54
+
55
+ /** Field paths in the schema that declare a bound, as `model.field`, for naming them in an error. */
56
+ function fieldsWithBounds(models: ModelAstV2[]): string[] {
57
+ const found: string[] = []
58
+ for (const model of models) {
59
+ for (const [fieldName, field] of Object.entries(model.fields)) {
60
+ if (Object.keys(field.validation ?? {}).length > 0) found.push(`${model.name}.${fieldName}`)
61
+ }
62
+ }
63
+ return found
64
+ }
65
+
66
+ /** Models declaring a constraint, which compiles to a table-level CHECK. */
67
+ function modelsWithConstraints(models: ModelAstV2[]): string[] {
68
+ return models
69
+ .filter((model) => (model.annotations.db.constraints ?? []).length > 0)
70
+ .map((model) => model.name)
71
+ }
72
+
73
+ /**
74
+ * Everything in the schema that needs {@link ENGINE_MIN_FOR_BOUNDS}, or an empty array.
75
+ *
76
+ * Exported for the error message and for tests: a check nobody can see the input of is a check
77
+ * that gets deleted the first time it is inconvenient.
78
+ */
79
+ export function boundsRequiringHelpers(ast: ExtractedSchemaAstV2): string[] {
80
+ return [...fieldsWithBounds(ast.models), ...modelsWithConstraints(ast.models)]
81
+ }
82
+
83
+ /**
84
+ * Refuse a push whose schema needs helpers this engine does not create.
85
+ *
86
+ * Takes the pin from `versions.engine`, or undefined when unpinned. See the note above on why
87
+ * that is the right source rather than the binary's own `--version`.
88
+ */
89
+ export function assertEngineSupportsSchema(
90
+ ast: ExtractedSchemaAstV2,
91
+ pinnedEngineVersion: string | undefined,
92
+ ): void {
93
+ // Unpinned resolves to latest, and `local` points at a build whose version the config does not
94
+ // know. Neither can be compared, and neither is the case that breaks.
95
+ if (pinnedEngineVersion === undefined || pinnedEngineVersion === "local") return
96
+ if (compareVersions(pinnedEngineVersion, ENGINE_MIN_FOR_BOUNDS) >= 0) return
97
+
98
+ const needed = boundsRequiringHelpers(ast)
99
+ if (needed.length === 0) return
100
+
101
+ const shown = needed.slice(0, 3).join(", ")
102
+ const more = needed.length > 3 ? `, and ${needed.length - 3} more` : ""
103
+ throw new Error(
104
+ `This schema declares bounds that need schema-engine ${ENGINE_MIN_FOR_BOUNDS} or newer, ` +
105
+ `and this project pins ${pinnedEngineVersion}.\n\n` +
106
+ ` Declared on: ${shown}${more}\n\n` +
107
+ `Bounds compile to CHECK constraints that call helpers in the _supatype schema, and only ` +
108
+ `engine ${ENGINE_MIN_FOR_BOUNDS}+ creates them. Applying this would fail inside Postgres ` +
109
+ `with "function _supatype.richtext_text(jsonb) does not exist".\n\n` +
110
+ `Raise or remove the pin in supatype.config.ts:\n` +
111
+ ` versions: { engine: "${ENGINE_MIN_FOR_BOUNDS}" } // or omit it to track latest`,
112
+ )
113
+ }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * What a declared bound means for each field kind, and the SQL it compiles to.
3
+ *
4
+ * **This table is the mechanism.** Bounds used to be compiled inside the modifier cases of
5
+ * `type-extractor.ts`, which meant `MaxLength` became `char_length(col)` whatever the column turned
6
+ * out to be: `char_length(text[])` does not exist, so the RFC's own `tags: MaxLength<string[], 10>`
7
+ * produced SQL that fails `CREATE TABLE`. Worse, nine of twelve engine field structs had nowhere to
8
+ * put a `check`, so the constraint was dropped by serde with no error anywhere in the chain.
9
+ *
10
+ * A table keyed by kind fixes the class of bug rather than the instances: a kind absent from
11
+ * {@link BOUNDS_BY_KIND} throws, so a new field kind cannot be added without answering "what does a
12
+ * bound mean here", and every answer is either an expression or a refusal with a named alternative.
13
+ * There is no third outcome, which is what "no bound is ever silent" means in practice.
14
+ */
15
+ import { FIELD_KINDS, type FieldKind, type FieldValidation } from "./schema-ast-v2.js"
16
+
17
+ /** Bounds as declared on the type, before anything knows what column they will land on. */
18
+ export interface DeclaredBounds {
19
+ maxLength?: number
20
+ minLength?: number
21
+ maxItems?: number
22
+ minItems?: number
23
+ min?: number | string
24
+ max?: number | string
25
+ }
26
+
27
+ /** What "length" counts, per storage. */
28
+ type LengthForm = "chars" | "octets" | "richText"
29
+ /** What "items" counts, per storage. */
30
+ type ItemsForm = "array" | "jsonbArray"
31
+ /** What a range compares against. Temporal forms name the cast, which is never `numeric`. */
32
+ type RangeForm = "numeric" | "timestamptz" | "date" | "timestamp" | "interval"
33
+
34
+ type BoundFamily = "length" | "items" | "range"
35
+
36
+ interface KindBounds {
37
+ length?: LengthForm
38
+ items?: ItemsForm
39
+ range?: RangeForm
40
+ /** Where to send someone whose bound this kind refuses. */
41
+ instead?: Partial<Record<BoundFamily, string>>
42
+ }
43
+
44
+ const TEXTUAL: KindBounds = {
45
+ length: "chars",
46
+ instead: {
47
+ items: "text has characters, not items; use MaxLength/MinLength",
48
+ range: "text is not ordered numerically; use a model-level constraint if you need a comparison",
49
+ },
50
+ }
51
+
52
+ const NUMERIC: KindBounds = {
53
+ range: "numeric",
54
+ instead: {
55
+ length: "a number has no length; use Between to bound its value",
56
+ items: "a number has no items; use Between to bound its value",
57
+ },
58
+ }
59
+
60
+ const temporal = (range: RangeForm): KindBounds => ({
61
+ range,
62
+ instead: {
63
+ length: "a date has no length; use Between with ISO-8601 string bounds",
64
+ items: "a date has no items; use Between with ISO-8601 string bounds",
65
+ },
66
+ })
67
+
68
+ const NO_BOUNDS = (why: string): KindBounds => ({
69
+ instead: { length: why, items: why, range: why },
70
+ })
71
+
72
+ /**
73
+ * What each bound means for each kind.
74
+ *
75
+ * `Record<FieldKind, KindBounds>` is the whole mechanism: it is **exhaustive by the compiler**, so
76
+ * adding a kind to `FIELD_KINDS` fails the build here until someone says what `MaxLength`,
77
+ * `MaxItems` and `Between` do for it. Composite kinds (`timestamps`, `publishable`, `softDelete`)
78
+ * expand into real columns before a bound could apply, so they carry none, but they still have to
79
+ * say so.
80
+ */
81
+ const BOUNDS_BY_KIND: Record<FieldKind, KindBounds> = {
82
+ text: TEXTUAL,
83
+ email: TEXTUAL,
84
+ url: TEXTUAL,
85
+ slug: TEXTUAL,
86
+ color: TEXTUAL,
87
+ xml: TEXTUAL,
88
+ ip: TEXTUAL,
89
+ cidr: TEXTUAL,
90
+ macaddr: TEXTUAL,
91
+ tsQuery: TEXTUAL,
92
+ tsVector: TEXTUAL,
93
+
94
+ richText: {
95
+ length: "richText",
96
+ instead: {
97
+ items: "rich text is measured in characters of plain text; use MaxLength/MinLength",
98
+ range: "rich text is not ordered; use MaxLength/MinLength",
99
+ },
100
+ },
101
+
102
+ bytes: {
103
+ length: "octets",
104
+ instead: {
105
+ items: "a binary column has octets, not items; use MaxLength/MinLength",
106
+ range: "a binary column is not ordered; use MaxLength/MinLength",
107
+ },
108
+ },
109
+
110
+ integer: NUMERIC,
111
+ smallInt: NUMERIC,
112
+ bigInt: NUMERIC,
113
+ float: NUMERIC,
114
+ serial: NUMERIC,
115
+ bigSerial: NUMERIC,
116
+ decimal: NUMERIC,
117
+ money: NUMERIC,
118
+
119
+ datetime: temporal("timestamptz"),
120
+ timestamp: temporal("timestamp"),
121
+ date: temporal("date"),
122
+ interval: temporal("interval"),
123
+
124
+ array: {
125
+ items: "array",
126
+ instead: {
127
+ length: "an array has items, not characters; use MaxItems/MinItems",
128
+ range: "an array is not ordered; use MaxItems/MinItems",
129
+ },
130
+ },
131
+
132
+ blocks: {
133
+ items: "jsonbArray",
134
+ instead: {
135
+ length: "blocks are counted, not measured; use MaxItems/MinItems",
136
+ range: "blocks are not ordered; use MaxItems/MinItems",
137
+ },
138
+ },
139
+
140
+ // `json` is decided per field, not per kind: `JSON<Item[]>` takes item bounds and `JSON<{...}>`
141
+ // takes none. {@link boundsForKind} applies that, which is why the entry here is the object case.
142
+ json: NO_BOUNDS(
143
+ "a JSON object has no single measure; bound a sub-field with a model-level constraint, " +
144
+ "or declare the field as JSON<T[]> to bound its element count",
145
+ ),
146
+ button: NO_BOUNDS("a button is a composite value; bound a sub-field with a model-level constraint"),
147
+
148
+ enum: NO_BOUNDS("the union already constrains the permitted values"),
149
+ boolean: NO_BOUNDS("a boolean has two values and needs no bound"),
150
+ uuid: NO_BOUNDS("a UUID is fixed width"),
151
+ image: NO_BOUNDS("size and type limits belong on the bucket: fileSizeLimit and allowedMimeTypes"),
152
+ file: NO_BOUNDS("size and type limits belong on the bucket: fileSizeLimit and allowedMimeTypes"),
153
+ geo: NO_BOUNDS("a geometry is not measured this way"),
154
+ vector: NO_BOUNDS("the dimension is already fixed by the type, as Vector<N>"),
155
+ relation: NO_BOUNDS("bound the column on the model this relation points at"),
156
+ custom: NO_BOUNDS("a plugin field declares its own storage; bounds are the plugin's to define"),
157
+
158
+ timestamps: NO_BOUNDS("a composite expands into columns before a bound could apply"),
159
+ publishable: NO_BOUNDS("a composite expands into columns before a bound could apply"),
160
+ softDelete: NO_BOUNDS("a composite expands into columns before a bound could apply"),
161
+ }
162
+
163
+ /**
164
+ * Re-exported for tests. Completeness is now the compiler's job, not a test's: this exists so the
165
+ * matrix can assert it covers every kind, which is a different question from whether every kind is
166
+ * classified.
167
+ */
168
+ export const CLASSIFIED_KINDS: readonly FieldKind[] = FIELD_KINDS
169
+
170
+ /** `JSON<T[]>` counts elements; `JSON<{...}>` takes no bound. Anything else follows its kind. */
171
+ function boundsForKind(kind: FieldKind, jsonIsArray: boolean): KindBounds {
172
+ const entry = BOUNDS_BY_KIND[kind]
173
+ if (kind === "json" && jsonIsArray) {
174
+ return {
175
+ items: "jsonbArray",
176
+ instead: {
177
+ length: "a JSON array has items, not characters; use MaxItems/MinItems",
178
+ range: "a JSON array is not ordered; use MaxItems/MinItems",
179
+ },
180
+ }
181
+ }
182
+ return entry
183
+ }
184
+
185
+ /** How a column is measured, once the kind has decided. */
186
+ export type MeasureForm = LengthForm | ItemsForm
187
+
188
+ export interface MeasureResolution {
189
+ /** How to measure, when this kind can be measured this way. */
190
+ form?: MeasureForm
191
+ /** Why it cannot, and what to use instead. Present exactly when `form` is not. */
192
+ instead?: string
193
+ }
194
+
195
+ /**
196
+ * How a kind is measured, for one measure family.
197
+ *
198
+ * **The single answer for both paths.** `MaxLength<T, N>` on a field and `Length<"col">` inside a
199
+ * model constraint have to agree about what "length" means for a given column, or the same schema
200
+ * gets `char_length` in one place and `cardinality` in the other. A second table for the constraint
201
+ * path is how `char_length(text[])` would come back, in a new file, having been fixed once already.
202
+ */
203
+ export function measureFormFor(
204
+ kind: FieldKind,
205
+ measure: "length" | "items",
206
+ options: { jsonIsArray?: boolean } = {},
207
+ ): MeasureResolution {
208
+ const entry = boundsForKind(kind, options.jsonIsArray === true)
209
+ const form = measure === "length" ? entry.length : entry.items
210
+ if (form !== undefined) return { form }
211
+ return { instead: entry.instead?.[measure] ?? `a ${kind} field cannot be measured that way` }
212
+ }
213
+
214
+ const COLUMN = '"{name}"'
215
+
216
+ function lengthExpr(form: LengthForm): string {
217
+ switch (form) {
218
+ case "chars":
219
+ return `char_length(${COLUMN})`
220
+ case "octets":
221
+ return `octet_length(${COLUMN})`
222
+ case "richText":
223
+ return `char_length(_supatype.richtext_text(${COLUMN}))`
224
+ }
225
+ }
226
+
227
+ /**
228
+ * `jsonb_array_length` raises `cannot get array length of a non-array` at insert time, so the type
229
+ * guard is part of the constraint rather than an assumption about what callers send.
230
+ */
231
+ function itemsClause(form: ItemsForm, comparisons: string[]): string {
232
+ if (form === "array") {
233
+ return comparisons.map((c) => `cardinality(${COLUMN}) ${c}`).join(" AND ")
234
+ }
235
+ const guarded = comparisons.map((c) => `jsonb_array_length(${COLUMN}) ${c}`).join(" AND ")
236
+ return `jsonb_typeof(${COLUMN}) = 'array' AND ${guarded}`
237
+ }
238
+
239
+ function rangeLiteral(form: RangeForm, value: number | string): string {
240
+ if (form === "numeric") return String(value)
241
+ const cast = form === "timestamptz" ? "timestamptz" : form
242
+ return `'${String(value).replace(/'/g, "''")}'::${cast}`
243
+ }
244
+
245
+ /** ISO-8601 date, date-time or a Postgres interval. Validated here so a bad literal is a CLI error. */
246
+ function isTemporalLiteral(form: RangeForm, value: string): boolean {
247
+ if (form === "interval") return /^\s*\d+\s+[a-z]+(\s+\d+\s+[a-z]+)*\s*$/i.test(value)
248
+ return !Number.isNaN(Date.parse(value))
249
+ }
250
+
251
+ export interface BoundsCompileResult {
252
+ check?: string
253
+ validation?: FieldValidation
254
+ }
255
+
256
+ function refuse(field: string, modifier: string, kind: FieldKind, hint: string | undefined): never {
257
+ const tail = hint ? ` ${hint}.` : ""
258
+ throw new Error(
259
+ `Field "${field}": ${modifier} is not supported on a ${kind} field.${tail}`,
260
+ )
261
+ }
262
+
263
+ /**
264
+ * Compile declared bounds against the kind they landed on.
265
+ *
266
+ * Throws rather than dropping. A bound that cannot be honoured is a mistake in the schema, and the
267
+ * failure it used to produce, silence, is the one failure this must not have.
268
+ */
269
+ export function compileBounds(
270
+ field: string,
271
+ kind: FieldKind,
272
+ bounds: DeclaredBounds,
273
+ options: { jsonIsArray?: boolean } = {},
274
+ ): BoundsCompileResult {
275
+ const entry = boundsForKind(kind, options.jsonIsArray === true)
276
+ const clauses: string[] = []
277
+ const validation: FieldValidation = {}
278
+
279
+ const { maxLength, minLength } = bounds
280
+ if (maxLength !== undefined || minLength !== undefined) {
281
+ const resolved = measureFormFor(kind, "length", options)
282
+ if (resolved.form === undefined) {
283
+ refuse(field, maxLength !== undefined ? "MaxLength" : "MinLength", kind, resolved.instead)
284
+ }
285
+ const expr = lengthExpr(resolved.form as LengthForm)
286
+ if (maxLength !== undefined) {
287
+ clauses.push(`${expr} <= ${maxLength}`)
288
+ validation.maxLength = maxLength
289
+ }
290
+ if (minLength !== undefined) {
291
+ clauses.push(`${expr} >= ${minLength}`)
292
+ validation.minLength = minLength
293
+ }
294
+ }
295
+
296
+ const { maxItems, minItems } = bounds
297
+ const itemsResolved = measureFormFor(kind, "items", options)
298
+ if (maxItems !== undefined || minItems !== undefined) {
299
+ if (itemsResolved.form === undefined) {
300
+ refuse(field, maxItems !== undefined ? "MaxItems" : "MinItems", kind, itemsResolved.instead)
301
+ }
302
+ const comparisons: string[] = []
303
+ if (maxItems !== undefined) {
304
+ comparisons.push(`<= ${maxItems}`)
305
+ validation.maxItems = maxItems
306
+ }
307
+ if (minItems !== undefined) {
308
+ comparisons.push(`>= ${minItems}`)
309
+ validation.minItems = minItems
310
+ }
311
+ clauses.push(itemsClause(itemsResolved.form as ItemsForm, comparisons))
312
+ }
313
+
314
+ const { min, max } = bounds
315
+ if (min !== undefined || max !== undefined) {
316
+ if (!entry.range) refuse(field, "Between", kind, entry.instead?.range)
317
+ for (const [bound, comparison] of [[min, ">="], [max, "<="]] as const) {
318
+ if (bound === undefined) continue
319
+ assertRangeShape(field, kind, entry.range, bound)
320
+ clauses.push(`${COLUMN} ${comparison} ${rangeLiteral(entry.range, bound)}`)
321
+ }
322
+ if (min !== undefined) validation.min = min
323
+ if (max !== undefined) validation.max = max
324
+ }
325
+
326
+ // Parenthesised only when there is something to bind, matching `mergeCheckConstraint`. Gratuitous
327
+ // parentheses are not cosmetic here: the differ compares this text against what Postgres hands
328
+ // back from `pg_get_constraintdef`, so every avoidable difference is a false "changed" on push.
329
+ return {
330
+ ...(clauses.length > 0 && { check: joinClauses(clauses) }),
331
+ ...(Object.keys(validation).length > 0 && { validation }),
332
+ }
333
+ }
334
+
335
+ function joinClauses(clauses: string[]): string {
336
+ const [only] = clauses
337
+ if (clauses.length === 1 && only !== undefined) return only
338
+ return clauses.map((c) => `(${c})`).join(" AND ")
339
+ }
340
+
341
+ /** A number bounds a number and a string bounds a date. Crossing them is a mistake, not a cast. */
342
+ function assertRangeShape(field: string, kind: FieldKind, form: RangeForm, bound: number | string): void {
343
+ const isNumeric = form === "numeric"
344
+ if (isNumeric && typeof bound !== "number") {
345
+ throw new Error(
346
+ `Field "${field}": Between on a ${kind} field takes numbers, but "${bound}" is a string.`,
347
+ )
348
+ }
349
+ if (!isNumeric && typeof bound !== "string") {
350
+ throw new Error(
351
+ `Field "${field}": Between on a ${kind} field takes ISO-8601 string bounds, but ${bound} is a number.`,
352
+ )
353
+ }
354
+ if (!isNumeric && !isTemporalLiteral(form, bound as string)) {
355
+ throw new Error(
356
+ `Field "${field}": Between bound "${bound}" is not a valid ${form === "interval" ? "interval" : "ISO-8601 date"}.`,
357
+ )
358
+ }
359
+ }
@@ -28,16 +28,21 @@ function hookedModels(ast: unknown): HookedModel[] {
28
28
 
29
29
  return models
30
30
  .filter((model) => {
31
- const hooks = (model as { annotations?: { platform?: { hooks?: unknown } } }).annotations
32
- ?.platform?.hooks
33
- return typeof hooks === "object" && hooks !== null && Object.keys(hooks).length > 0
31
+ const platform = (
32
+ model as { annotations?: { platform?: { hooks?: unknown; validate?: unknown } } }
33
+ ).annotations?.platform
34
+ const declares = (value: unknown): boolean =>
35
+ typeof value === "object" && value !== null && Object.keys(value).length > 0
36
+ // Validators need the same row types hooks do, so a model declaring only validators must
37
+ // still appear here or `FieldValidator<"products", ...>` would not compile.
38
+ return declares(platform?.hooks) || declares(platform?.validate)
34
39
  })
35
40
  .map((model) => ({ table: resolveTableName(model), fields: model.fields }))
36
41
  .sort((a, b) => a.table.localeCompare(b.table))
37
42
  }
38
43
 
39
44
  /**
40
- * The module source, or `null` when no model declares a hook.
45
+ * The module source, or `null` when no model declares a hook or a field validator.
41
46
  *
42
47
  * Returning null rather than an empty module keeps a `_supatype/hooks.ts` from appearing in projects
43
48
  * that have no hooks, a generated file nobody imports is a file somebody eventually edits.
@@ -169,6 +174,78 @@ export type AfterDelete<T extends HookedTable> = (
169
174
  ctx: AfterDeleteContext<T>,
170
175
  ) => void | Promise<void>
171
176
 
177
+
178
+ /**
179
+ * A per-field validator: one field's value, and a verdict about it.
180
+ *
181
+ * \`FieldValidator<"products", "setup_items">\` types \`value\` as that column's real type, so a
182
+ * validator cannot quietly be written against the wrong shape.
183
+ *
184
+ * Return \`true\` to accept, or a message to refuse. The message reaches the caller attached to the
185
+ * field name, which is what lets a form put it on the input rather than in a banner: the whole
186
+ * reason for declaring this instead of putting the same logic in a \`beforeChange\` hook.
187
+ */
188
+ export type FieldValidatorContext<T extends HookedTable, F extends keyof HookTables[T]["Row"]> =
189
+ HookBase & {
190
+ readonly operation: "insert" | "update"
191
+ readonly field: F
192
+ readonly value: HookTables[T]["Row"][F]
193
+ }
194
+
195
+ export type FieldVerdict = true | string
196
+
197
+ export type FieldValidator<T extends HookedTable, F extends keyof HookTables[T]["Row"]> = (
198
+ ctx: FieldValidatorContext<T, F>,
199
+ ) => FieldVerdict | Promise<FieldVerdict>
200
+
201
+ interface WireValidatorContext {
202
+ table?: string
203
+ operation?: string
204
+ field?: string
205
+ value?: unknown
206
+ user?: unknown
207
+ requestId?: string
208
+ }
209
+
210
+ /**
211
+ * Wrap a typed validator as the request handler the worker expects.
212
+ *
213
+ * A refusal is **422 with the field named**, not a bare 400: the request was understood and one
214
+ * value was rejected. A thrown error is deliberately *not* turned into a refusal, because a broken
215
+ * validator has not decided anything: it becomes a 500, which the server reads as unavailable and
216
+ * refuses the write on, rather than as the validator saying no.
217
+ */
218
+ export function fieldValidator(
219
+ handler: (ctx: never) => FieldVerdict | Promise<FieldVerdict>,
220
+ ): (req: Request) => Promise<Response> {
221
+ return async (req: Request): Promise<Response> => {
222
+ if (req.method !== "POST") {
223
+ return json({ message: "A validator is invoked with POST" }, 405)
224
+ }
225
+
226
+ let wire: WireValidatorContext
227
+ try {
228
+ wire = (await req.json()) as WireValidatorContext
229
+ } catch {
230
+ return json({ message: "Validator payload was not JSON" }, 400)
231
+ }
232
+
233
+ const field = wire.field ?? ""
234
+ const ctx = {
235
+ table: wire.table ?? "",
236
+ operation: wire.operation === "update" ? "update" : "insert",
237
+ field,
238
+ value: wire.value,
239
+ user: (wire.user ?? null) as never,
240
+ requestId: wire.requestId ?? "",
241
+ }
242
+
243
+ const verdict = await handler(ctx as never)
244
+ if (verdict === true) return json({}, 200)
245
+ return json({ field, message: String(verdict), error: String(verdict) }, 422)
246
+ }
247
+ }
248
+
172
249
  /** Every handler a single function may serve, when one function covers a model's whole lifecycle. */
173
250
  export interface HookHandlers<T extends HookedTable> {
174
251
  readonly beforeChange?: BeforeChange<T>
package/src/link.ts CHANGED
@@ -23,7 +23,7 @@ export interface ProjectLink {
23
23
  defaultEnvironment: string
24
24
  /** Access token (cloud user JWT) or self-host SERVICE_ROLE_KEY. */
25
25
  token?: string
26
- /** Cloud GoTrue refresh token, used to renew short-lived access JWTs. */
26
+ /** Cloud auth refresh token, used to renew short-lived access JWTs. */
27
27
  refreshToken?: string
28
28
  orgId?: string | undefined
29
29
  cloudApiUrl?: string