@supatype/cli 0.1.12 → 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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +138 -132
  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/dev-compose.d.ts +1 -0
  20. package/dist/dev-compose.d.ts.map +1 -1
  21. package/dist/dev-compose.js +79 -10
  22. package/dist/dev-compose.js.map +1 -1
  23. package/dist/field-bounds.d.ts +68 -0
  24. package/dist/field-bounds.d.ts.map +1 -0
  25. package/dist/field-bounds.js +277 -0
  26. package/dist/field-bounds.js.map +1 -0
  27. package/dist/hooks-generator.d.ts +1 -1
  28. package/dist/hooks-generator.d.ts.map +1 -1
  29. package/dist/hooks-generator.js +78 -4
  30. package/dist/hooks-generator.js.map +1 -1
  31. package/dist/model-hooks.d.ts +44 -2
  32. package/dist/model-hooks.d.ts.map +1 -1
  33. package/dist/model-hooks.js +116 -12
  34. package/dist/model-hooks.js.map +1 -1
  35. package/dist/schema-ast-v2.d.ts +38 -4
  36. package/dist/schema-ast-v2.d.ts.map +1 -1
  37. package/dist/schema-ast-v2.js +87 -4
  38. package/dist/schema-ast-v2.js.map +1 -1
  39. package/dist/type-extractor.d.ts.map +1 -1
  40. package/dist/type-extractor.js +309 -27
  41. package/dist/type-extractor.js.map +1 -1
  42. package/package.json +4 -3
  43. package/src/cli-version-embedded.ts +1 -1
  44. package/src/commands/db.ts +27 -1
  45. package/src/commands/doctor.ts +30 -0
  46. package/src/commands/push.ts +26 -6
  47. package/src/compose-local-server-image.ts +17 -0
  48. package/src/dev-compose.ts +96 -9
  49. package/src/field-bounds.ts +359 -0
  50. package/src/hooks-generator.ts +81 -4
  51. package/src/model-hooks.ts +158 -12
  52. package/src/schema-ast-v2.ts +114 -10
  53. package/src/type-extractor.ts +374 -39
  54. package/tests/field-bounds-matrix.test.ts +163 -0
  55. package/tests/field-bounds.test.ts +139 -0
  56. package/tests/field-validators.test.ts +139 -0
  57. package/tests/hooks-generator.test.ts +86 -0
  58. package/tests/local-server-image-env.test.ts +93 -0
  59. package/tests/model-constraints.test.ts +293 -0
  60. package/tests/model-hooks.test.ts +56 -0
  61. package/tests/type-extractor.test.ts +49 -0
  62. package/tsconfig.tsbuildinfo +1 -1
@@ -164,16 +164,127 @@ export function manifestHooks(ast: unknown): Record<string, Record<string, Manif
164
164
  return out
165
165
  }
166
166
 
167
+
168
+ /** One declared per-field validator. `event` is the field it checks, so reporting reads uniformly. */
169
+ export interface DeclaredValidator {
170
+ model: string
171
+ field: string
172
+ function: string
173
+ }
174
+
175
+ /** Every per-field validator declared across the schema, in a stable order for reporting. */
176
+ export function declaredValidators(ast: unknown): DeclaredValidator[] {
177
+ const models = (ast as { models?: unknown[] })?.models
178
+ if (!Array.isArray(models)) return []
179
+
180
+ const out: DeclaredValidator[] = []
181
+ for (const model of models) {
182
+ const shaped = model as {
183
+ name?: string
184
+ annotations?: { platform?: { validate?: Record<string, unknown> } }
185
+ }
186
+ const validators = shaped.annotations?.platform?.validate
187
+ if (typeof validators !== "object" || validators === null) continue
188
+
189
+ for (const [field, value] of Object.entries(validators)) {
190
+ const fn = (value as { function?: unknown })?.function
191
+ if (typeof fn === "string" && fn.length > 0) {
192
+ out.push({ model: shaped.name ?? "?", field, function: fn })
193
+ }
194
+ }
195
+ }
196
+ return out.sort((a, b) => `${a.model}.${a.field}`.localeCompare(`${b.model}.${b.field}`))
197
+ }
198
+
199
+ /**
200
+ * The validator map for `.supatype/manifest.json`, keyed by **table** then **column**.
201
+ *
202
+ * `onUnavailable` is written explicitly as `reject` rather than left to the server's default. The
203
+ * server does default that way, but its policy matches exact event names, and a validator that
204
+ * silently accepted a value because a new event name was missing from a switch is precisely the
205
+ * failure found when that path was built. Saying it here means neither side has to be right alone.
206
+ */
207
+ export function manifestValidators(ast: unknown): Record<string, Record<string, ManifestHookEntry>> {
208
+ const models = (ast as { models?: unknown[] })?.models
209
+ if (!Array.isArray(models)) return {}
210
+
211
+ const out: Record<string, Record<string, ManifestHookEntry>> = {}
212
+ for (const model of models) {
213
+ const shaped = model as {
214
+ annotations?: {
215
+ db?: { tableName?: string }
216
+ platform?: { validate?: Record<string, { function?: string; timeout?: number }> }
217
+ }
218
+ }
219
+ const table = shaped.annotations?.db?.tableName
220
+ const validators = shaped.annotations?.platform?.validate
221
+ if (typeof table !== "string" || table.length === 0) continue
222
+ if (typeof validators !== "object" || validators === null) continue
223
+
224
+ const entries: Record<string, ManifestHookEntry> = {}
225
+ for (const [field, value] of Object.entries(validators)) {
226
+ const fn = value?.function
227
+ if (typeof fn !== "string" || fn.length === 0) continue
228
+ entries[field] = {
229
+ function: fn,
230
+ timeout: typeof value.timeout === "number" ? value.timeout : DEFAULT_HOOK_TIMEOUT_MS,
231
+ onUnavailable: "reject",
232
+ }
233
+ }
234
+ if (Object.keys(entries).length > 0) out[table] = entries
235
+ }
236
+ return out
237
+ }
238
+
239
+ /**
240
+ * Validators naming a function that does not exist, as lines for a push failure.
241
+ *
242
+ * Shares `availableFunctions` with the hook check, so "what counts as a function" cannot come to
243
+ * mean two things.
244
+ */
245
+ export function validateModelValidators(
246
+ ast: unknown,
247
+ functionsDir: string,
248
+ cwd: string,
249
+ ): string[] {
250
+ const validators = declaredValidators(ast)
251
+ if (validators.length === 0) return []
252
+
253
+ const available = availableFunctions(functionsDir)
254
+ const known = new Set(available)
255
+ const missing = validators.filter((entry) => !known.has(entry.function))
256
+ if (missing.length === 0) return []
257
+
258
+ const where = relative(cwd, functionsDir) || functionsDir
259
+ const lines = missing.map(
260
+ (entry) =>
261
+ ` ${entry.model}.${entry.field} → "${entry.function}" (no ${where}/${entry.function}/index.ts)`,
262
+ )
263
+ lines.push("")
264
+ lines.push(
265
+ available.length > 0
266
+ ? `Functions found in ${where}: ${available.join(", ")}`
267
+ : `No functions found in ${where}. Create one with: supatype hooks new <name>`,
268
+ )
269
+ return lines
270
+ }
271
+
167
272
  /** Well below the 10s edge-function ceiling, so a hung hook fails fast instead of holding a slot. */
168
273
  export const DEFAULT_HOOK_TIMEOUT_MS = 2000
169
274
 
170
275
  /**
171
- * Merge the hook map into an existing `.supatype/manifest.json`.
276
+ * Merge the hook and validator maps into an existing `.supatype/manifest.json`.
277
+ *
278
+ * Both keys are written here rather than in two functions, because they fail together and for the
279
+ * same reason: each is a map the server reads to decide what to call around a write, and a manifest
280
+ * carrying a stale one calls the wrong thing or nothing at all. A validator that is never called is
281
+ * the worse half of that: the schema says the field is checked, and no error appears anywhere,
282
+ * because the write simply succeeds.
172
283
  *
173
284
  * **Only updates a manifest that is already there.** Creating one from scratch here would be a
174
285
  * hazard: `functions_enabled` is a plain bool on the server's side, so a manifest carrying only
175
286
  * hooks would read as functions *disabled*, the exact defect this repo fixed a commit ago, arriving
176
- * by a different door. The compose path owns creation; this owns one key.
287
+ * by a different door. The compose path owns creation; this owns two keys.
177
288
  *
178
289
  * Returns true when the file was rewritten.
179
290
  */
@@ -189,17 +300,34 @@ export function syncManifestHooks(cwd: string, ast: unknown): boolean {
189
300
  }
190
301
  if (typeof parsed !== "object" || parsed === null) return false
191
302
 
192
- const hooks = manifestHooks(ast)
193
- const next = JSON.stringify(hooks)
194
- const current = JSON.stringify(parsed["hooks"] ?? {})
195
- if (next === current) return false
303
+ const changedHooks = applyManifestMap(parsed, "hooks", manifestHooks(ast))
304
+ const changedValidators = applyManifestMap(parsed, "validators", manifestValidators(ast))
305
+ if (!changedHooks && !changedValidators) return false
306
+
307
+ writeFileSync(manifestPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8")
308
+ return true
309
+ }
310
+
311
+ /**
312
+ * Set one manifest key to `map`, or remove it when the schema declares none.
313
+ *
314
+ * Removing rather than writing `{}` matters: the server distinguishes "no map" from "an empty map"
315
+ * when it decides whether the manifest predates the feature, and an empty object left behind by a
316
+ * schema that no longer declares any is not the same statement.
317
+ */
318
+ function applyManifestMap(
319
+ manifest: Record<string, unknown>,
320
+ key: string,
321
+ map: Record<string, Record<string, ManifestHookEntry>>,
322
+ ): boolean {
323
+ const next = JSON.stringify(map)
324
+ if (next === JSON.stringify(manifest[key] ?? {})) return false
196
325
 
197
- if (Object.keys(hooks).length === 0) {
198
- delete parsed["hooks"]
326
+ if (Object.keys(map).length === 0) {
327
+ delete manifest[key]
199
328
  } else {
200
- parsed["hooks"] = hooks
329
+ manifest[key] = map
201
330
  }
202
- writeFileSync(manifestPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8")
203
331
  return true
204
332
  }
205
333
 
@@ -211,6 +339,18 @@ export interface HooksReport {
211
339
  functionsDisabled: boolean
212
340
  /** True when a manifest exists but carries no hook map, so the server has nothing to call. */
213
341
  mapMissing: boolean
342
+ /** Field validators declared across the schema. */
343
+ validators: DeclaredValidator[]
344
+ /**
345
+ * Validators whose function directory is missing.
346
+ *
347
+ * Reported apart from `missing` because the consequence is different and worth saying plainly: a
348
+ * missing hook is a lifecycle step that will not run, a missing validator is a field written
349
+ * unchecked.
350
+ */
351
+ validatorsMissing: DeclaredValidator[]
352
+ /** True when validators are declared and the manifest carries no validator map. */
353
+ validatorMapMissing: boolean
214
354
  }
215
355
 
216
356
  /**
@@ -225,15 +365,18 @@ export interface HooksReport {
225
365
  */
226
366
  export function hooksReport(cwd: string, functionsDir: string, ast: unknown): HooksReport {
227
367
  const declared = declaredHooks(ast)
368
+ const validators = declaredValidators(ast)
228
369
  const manifestPath = join(cwd, ".supatype", "manifest.json")
229
370
 
230
371
  let functionsDisabled = false
231
372
  let mapMissing = false
232
- if (declared.length > 0 && existsSync(manifestPath)) {
373
+ let validatorMapMissing = false
374
+ if ((declared.length > 0 || validators.length > 0) && existsSync(manifestPath)) {
233
375
  try {
234
376
  const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>
235
377
  functionsDisabled = parsed["functions_enabled"] === false
236
- mapMissing = parsed["hooks"] === undefined
378
+ mapMissing = declared.length > 0 && parsed["hooks"] === undefined
379
+ validatorMapMissing = validators.length > 0 && parsed["validators"] === undefined
237
380
  } catch {
238
381
  // Unparseable: the server reports that far better than a doctor line could.
239
382
  }
@@ -256,5 +399,8 @@ export function hooksReport(cwd: string, functionsDir: string, ast: unknown): Ho
256
399
  missing: declared.filter((hook) => !known.has(hook.function)),
257
400
  functionsDisabled,
258
401
  mapMissing,
402
+ validators,
403
+ validatorsMissing: validators.filter((entry) => !known.has(entry.function)),
404
+ validatorMapMissing,
259
405
  }
260
406
  }
@@ -3,8 +3,80 @@
3
3
  * Parsers build {@link ParsedField}; only `emitField` / `emitModel` / `emitSchema` produce JSON.
4
4
  */
5
5
 
6
+ import type { FieldValidation } from "@supatype/types"
7
+
8
+ // Re-exported so consumers of these AST types need no second import for the one field that is
9
+ // declared elsewhere: the bound contract lives beside the modifiers that compile into it.
10
+ export type { FieldValidation }
11
+
6
12
  export const AST_VERSION = 2 as const
7
13
 
14
+ /**
15
+ * Every field kind the extractor can produce. **The registry, not a list.**
16
+ *
17
+ * A kind used to exist the moment someone wrote `scalar("newThing")` in the extractor's type switch,
18
+ * with nothing anywhere enumerating the set. That is how nine kinds came to accept a declared bound
19
+ * and silently enforce nothing: no code and no test could ask "what are all the kinds", so each was
20
+ * handled wherever someone happened to look.
21
+ *
22
+ * Naming a kind here is now the only way to create one, because {@link scalar} takes a `FieldKind`.
23
+ * Anything keyed by `Record<FieldKind, T>` is then exhaustive by the compiler rather than by
24
+ * somebody remembering: `BOUNDS_BY_KIND` in `field-bounds.ts` is the first such consumer, so adding
25
+ * a kind here fails the build until it is classified there.
26
+ */
27
+ export const FIELD_KINDS = [
28
+ // Text-shaped
29
+ "text",
30
+ "email",
31
+ "url",
32
+ "slug",
33
+ "color",
34
+ "xml",
35
+ "ip",
36
+ "cidr",
37
+ "macaddr",
38
+ "tsQuery",
39
+ "tsVector",
40
+ "richText",
41
+ "bytes",
42
+ // Numeric
43
+ "integer",
44
+ "smallInt",
45
+ "bigInt",
46
+ "float",
47
+ "serial",
48
+ "bigSerial",
49
+ "decimal",
50
+ "money",
51
+ // Temporal
52
+ "datetime",
53
+ "timestamp",
54
+ "date",
55
+ "interval",
56
+ // Collections and structured values
57
+ "array",
58
+ "blocks",
59
+ "json",
60
+ "button",
61
+ "enum",
62
+ // Fixed-shape scalars
63
+ "boolean",
64
+ "uuid",
65
+ // Storage, spatial, plugin
66
+ "image",
67
+ "file",
68
+ "geo",
69
+ "vector",
70
+ "relation",
71
+ "custom",
72
+ // Composites, expanded into real columns before they reach a table
73
+ "timestamps",
74
+ "publishable",
75
+ "softDelete",
76
+ ] as const
77
+
78
+ export type FieldKind = (typeof FIELD_KINDS)[number]
79
+
8
80
  export type DefaultAst =
9
81
  | { kind: "value"; value: string | number | boolean | null }
10
82
  | { kind: "now" }
@@ -49,6 +121,15 @@ export interface KernelFieldFacts {
49
121
  dimensions?: number
50
122
  blocks?: BlockDefinitionAst[]
51
123
  check?: string
124
+ validation?: FieldValidation
125
+ /**
126
+ * `JSON<T[]>` rather than `JSON<{...}>`, read from the declared type argument.
127
+ *
128
+ * Persisted because a model constraint naming this column with `ItemCount<>` has to resolve the
129
+ * same measure the field's own `MaxItems` would, and by then the type node is long gone. The
130
+ * engine never reads it: it receives the measure already resolved.
131
+ */
132
+ jsonArray?: boolean
52
133
  precision?: number
53
134
  scale?: number
54
135
  references?: string
@@ -64,7 +145,7 @@ export interface KernelFieldFacts {
64
145
 
65
146
  /** Internal parse result: not serialized. */
66
147
  export interface ParsedField {
67
- kind: string
148
+ kind: FieldKind
68
149
  kernel: KernelFieldFacts
69
150
  db: DbFieldAnnotations
70
151
  platform: PlatformFieldAnnotations
@@ -87,8 +168,12 @@ export interface ModelAstV2 {
87
168
  fields: Record<string, FieldAstV2>
88
169
  options: Record<string, unknown>
89
170
  annotations: {
90
- db: { tableName: string; indexes: unknown[] }
91
- platform: { access: Record<string, unknown>; hooks?: Record<string, unknown> }
171
+ db: { tableName: string; indexes: unknown[]; constraints?: unknown[] }
172
+ platform: {
173
+ access: Record<string, unknown>
174
+ hooks?: Record<string, unknown>
175
+ validate?: Record<string, unknown>
176
+ }
92
177
  }
93
178
  }
94
179
 
@@ -110,7 +195,7 @@ export interface ExtractedSchemaAstV2 {
110
195
  defaultLocale?: string
111
196
  }
112
197
 
113
- const DEFAULT_DB_BY_KIND: Record<string, Partial<DbFieldAnnotations>> = {
198
+ const DEFAULT_DB_BY_KIND: Partial<Record<FieldKind, Partial<DbFieldAnnotations>>> = {
114
199
  text: { pgType: "TEXT" },
115
200
  richText: { pgType: "JSONB" },
116
201
  integer: { pgType: "INTEGER" },
@@ -147,7 +232,7 @@ const DEFAULT_DB_BY_KIND: Record<string, Partial<DbFieldAnnotations>> = {
147
232
  blocks: { pgType: "JSONB" },
148
233
  }
149
234
 
150
- const DEFAULT_PLATFORM_BY_KIND: Record<string, Partial<PlatformFieldAnnotations>> = {
235
+ const DEFAULT_PLATFORM_BY_KIND: Partial<Record<FieldKind, Partial<PlatformFieldAnnotations>>> = {
151
236
  richText: { editor: "rich" },
152
237
  }
153
238
 
@@ -165,9 +250,15 @@ function stripUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
165
250
  return out as Partial<T>
166
251
  }
167
252
 
168
- /** Start a parsed field with kind defaults for db/platform namespaces. */
253
+ /**
254
+ * Start a parsed field with kind defaults for db/platform namespaces.
255
+ *
256
+ * Takes a plain `string` rather than a `FieldKind` because it is also called with a kind read back
257
+ * off the wire, where the value came from JSON and carries no compile-time guarantee. The optional
258
+ * lookup plus the fallback is what makes an unrecognised one safe.
259
+ */
169
260
  export function defaultPgTypeForKind(kind: string): string {
170
- return DEFAULT_DB_BY_KIND[kind]?.pgType ?? "TEXT"
261
+ return DEFAULT_DB_BY_KIND[kind as FieldKind]?.pgType ?? "TEXT"
171
262
  }
172
263
 
173
264
  /** Flat wire shape for fields nested inside `blocks` definitions (engine FieldAst serde). */
@@ -195,6 +286,7 @@ export function emitBlockNestedField(field: FieldAstV2): FieldAstV2 {
195
286
  if (field.srid !== undefined) wire.srid = field.srid
196
287
  if (field.dimensions !== undefined) wire.dimensions = field.dimensions
197
288
  if (field.check !== undefined) wire.check = field.check
289
+ if (field.validation !== undefined) wire.validation = field.validation
198
290
  if (field.precision !== undefined) wire.precision = field.precision
199
291
  if (field.scale !== undefined) wire.scale = field.scale
200
292
  if (field.sources !== undefined) wire.sources = field.sources
@@ -207,7 +299,7 @@ export function emitBlockNestedField(field: FieldAstV2): FieldAstV2 {
207
299
  }
208
300
 
209
301
  export function scalar(
210
- kind: string,
302
+ kind: FieldKind,
211
303
  extra?: {
212
304
  kernel?: Partial<KernelFieldFacts>
213
305
  db?: Partial<DbFieldAnnotations>
@@ -267,6 +359,8 @@ export function emitField(parsed: ParsedField): FieldAstV2 {
267
359
  }))
268
360
  }
269
361
  if (kernel.check !== undefined) wire.check = kernel.check
362
+ if (kernel.validation !== undefined) wire.validation = kernel.validation
363
+ if (kernel.jsonArray === true) wire.jsonArray = true
270
364
  if (kernel.precision !== undefined) wire.precision = kernel.precision
271
365
  if (kernel.scale !== undefined) wire.scale = kernel.scale
272
366
  if (kernel.references !== undefined) wire.references = kernel.references
@@ -295,16 +389,26 @@ export function emitModel(
295
389
  access: Record<string, unknown>,
296
390
  indexes: unknown[] = [],
297
391
  hooks: Record<string, unknown> = {},
392
+ constraints: unknown[] = [],
393
+ validators: Record<string, unknown> = {},
298
394
  ): ModelAstV2 {
299
395
  return {
300
396
  name,
301
397
  fields,
302
398
  options,
303
399
  annotations: {
304
- db: { tableName, indexes },
400
+ // Constraints sit in `db` beside `indexes`: both are things Postgres holds, unlike `access`
401
+ // and `hooks`, which the API layer enforces.
402
+ db: { tableName, indexes, ...(constraints.length > 0 && { constraints }) },
305
403
  // Hooks sit in `platform` beside `access`: they are an API-layer concern, not a column one,
306
404
  // supatype-server reads them, Postgres never sees them.
307
- platform: { access, ...(Object.keys(hooks).length > 0 && { hooks }) },
405
+ // Validators sit in `platform` beside `hooks`: both are enforced by the API layer on the
406
+ // write path, and neither is something Postgres knows about.
407
+ platform: {
408
+ access,
409
+ ...(Object.keys(hooks).length > 0 && { hooks }),
410
+ ...(Object.keys(validators).length > 0 && { validate: validators }),
411
+ },
308
412
  },
309
413
  }
310
414
  }