@opensaas/stack-core 0.37.0 → 0.39.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 (226) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +191 -0
  3. package/CLAUDE.md +42 -3
  4. package/dist/access/access-filter.d.ts +70 -113
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +183 -210
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +148 -188
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/declared-dependencies.d.ts +66 -26
  11. package/dist/access/declared-dependencies.d.ts.map +1 -1
  12. package/dist/access/declared-dependencies.js +62 -17
  13. package/dist/access/declared-dependencies.js.map +1 -1
  14. package/dist/access/declared-dependencies.test.d.ts +2 -0
  15. package/dist/access/declared-dependencies.test.d.ts.map +1 -0
  16. package/dist/access/declared-dependencies.test.js +226 -0
  17. package/dist/access/declared-dependencies.test.js.map +1 -0
  18. package/dist/access/depth-limits.d.ts +8 -7
  19. package/dist/access/depth-limits.d.ts.map +1 -1
  20. package/dist/access/depth-limits.js +8 -7
  21. package/dist/access/depth-limits.js.map +1 -1
  22. package/dist/access/engine.d.ts +0 -20
  23. package/dist/access/engine.d.ts.map +1 -1
  24. package/dist/access/engine.js +0 -25
  25. package/dist/access/engine.js.map +1 -1
  26. package/dist/access/errors.d.ts +61 -8
  27. package/dist/access/errors.d.ts.map +1 -1
  28. package/dist/access/errors.js +91 -12
  29. package/dist/access/errors.js.map +1 -1
  30. package/dist/access/field-access.d.ts +39 -1
  31. package/dist/access/field-access.d.ts.map +1 -1
  32. package/dist/access/field-access.js +108 -42
  33. package/dist/access/field-access.js.map +1 -1
  34. package/dist/access/field-access.test.js +229 -2
  35. package/dist/access/field-access.test.js.map +1 -1
  36. package/dist/access/field-transforms.d.ts +0 -27
  37. package/dist/access/field-transforms.d.ts.map +1 -1
  38. package/dist/access/field-visibility.d.ts +2 -5
  39. package/dist/access/field-visibility.d.ts.map +1 -1
  40. package/dist/access/field-visibility.js +89 -41
  41. package/dist/access/field-visibility.js.map +1 -1
  42. package/dist/access/index.d.ts +6 -3
  43. package/dist/access/index.d.ts.map +1 -1
  44. package/dist/access/index.js +12 -2
  45. package/dist/access/index.js.map +1 -1
  46. package/dist/access/query-validation.d.ts +121 -0
  47. package/dist/access/query-validation.d.ts.map +1 -0
  48. package/dist/access/query-validation.js +292 -0
  49. package/dist/access/query-validation.js.map +1 -0
  50. package/dist/access/relationship-count.d.ts +1 -1
  51. package/dist/access/relationship-count.d.ts.map +1 -1
  52. package/dist/access/relationship-count.js +2 -10
  53. package/dist/access/relationship-count.js.map +1 -1
  54. package/dist/access/relationship-label-filter.d.ts +23 -23
  55. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  56. package/dist/access/relationship-label-filter.js +5 -76
  57. package/dist/access/relationship-label-filter.js.map +1 -1
  58. package/dist/access/relationship-label-filter.test.js +19 -56
  59. package/dist/access/relationship-label-filter.test.js.map +1 -1
  60. package/dist/access/transaction-registry.d.ts +45 -0
  61. package/dist/access/transaction-registry.d.ts.map +1 -0
  62. package/dist/access/transaction-registry.js +37 -0
  63. package/dist/access/transaction-registry.js.map +1 -0
  64. package/dist/access/types.d.ts +55 -59
  65. package/dist/access/types.d.ts.map +1 -1
  66. package/dist/config/index.d.ts +9 -7
  67. package/dist/config/index.d.ts.map +1 -1
  68. package/dist/config/index.js +10 -18
  69. package/dist/config/index.js.map +1 -1
  70. package/dist/config/label.d.ts +6 -13
  71. package/dist/config/label.d.ts.map +1 -1
  72. package/dist/config/label.js +6 -13
  73. package/dist/config/label.js.map +1 -1
  74. package/dist/config/nav-count.d.ts +11 -19
  75. package/dist/config/nav-count.d.ts.map +1 -1
  76. package/dist/config/nav-count.js +11 -19
  77. package/dist/config/nav-count.js.map +1 -1
  78. package/dist/config/plugin-engine.d.ts +0 -10
  79. package/dist/config/plugin-engine.d.ts.map +1 -1
  80. package/dist/config/plugin-engine.js +1 -37
  81. package/dist/config/plugin-engine.js.map +1 -1
  82. package/dist/config/types.d.ts +94 -109
  83. package/dist/config/types.d.ts.map +1 -1
  84. package/dist/context/apply-defaults.d.ts +8 -31
  85. package/dist/context/apply-defaults.d.ts.map +1 -1
  86. package/dist/context/apply-defaults.js +12 -41
  87. package/dist/context/apply-defaults.js.map +1 -1
  88. package/dist/context/hook-pipeline.d.ts +4 -29
  89. package/dist/context/hook-pipeline.d.ts.map +1 -1
  90. package/dist/context/hook-pipeline.js +8 -41
  91. package/dist/context/hook-pipeline.js.map +1 -1
  92. package/dist/context/index.d.ts +4 -15
  93. package/dist/context/index.d.ts.map +1 -1
  94. package/dist/context/index.js +223 -215
  95. package/dist/context/index.js.map +1 -1
  96. package/dist/context/nested-operations.d.ts +7 -26
  97. package/dist/context/nested-operations.d.ts.map +1 -1
  98. package/dist/context/nested-operations.js +73 -171
  99. package/dist/context/nested-operations.js.map +1 -1
  100. package/dist/context/transaction-boundary.d.ts +36 -47
  101. package/dist/context/transaction-boundary.d.ts.map +1 -1
  102. package/dist/context/transaction-boundary.js +92 -79
  103. package/dist/context/transaction-boundary.js.map +1 -1
  104. package/dist/context/write-pipeline.d.ts +25 -64
  105. package/dist/context/write-pipeline.d.ts.map +1 -1
  106. package/dist/context/write-pipeline.js +81 -111
  107. package/dist/context/write-pipeline.js.map +1 -1
  108. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  109. package/dist/fields/format-prisma-default.js +4 -7
  110. package/dist/fields/format-prisma-default.js.map +1 -1
  111. package/dist/fields/index.d.ts +17 -2
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +136 -147
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/filter/collect.d.ts +26 -3
  116. package/dist/filter/collect.d.ts.map +1 -1
  117. package/dist/filter/collect.js +30 -12
  118. package/dist/filter/collect.js.map +1 -1
  119. package/dist/filter/filter.test.js +98 -15
  120. package/dist/filter/filter.test.js.map +1 -1
  121. package/dist/filter/index.d.ts +1 -0
  122. package/dist/filter/index.d.ts.map +1 -1
  123. package/dist/filter/index.js.map +1 -1
  124. package/dist/filter/map.d.ts.map +1 -1
  125. package/dist/filter/map.js +0 -5
  126. package/dist/filter/map.js.map +1 -1
  127. package/dist/filter/parse.d.ts.map +1 -1
  128. package/dist/filter/parse.js +2 -9
  129. package/dist/filter/parse.js.map +1 -1
  130. package/dist/filter/serialize.d.ts.map +1 -1
  131. package/dist/filter/serialize.js +2 -4
  132. package/dist/filter/serialize.js.map +1 -1
  133. package/dist/hooks/index.d.ts +4 -42
  134. package/dist/hooks/index.d.ts.map +1 -1
  135. package/dist/hooks/index.js +5 -59
  136. package/dist/hooks/index.js.map +1 -1
  137. package/dist/index.d.ts +3 -1
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +20 -4
  140. package/dist/index.js.map +1 -1
  141. package/dist/internal.d.ts +1 -0
  142. package/dist/internal.d.ts.map +1 -1
  143. package/dist/internal.js +4 -0
  144. package/dist/internal.js.map +1 -1
  145. package/dist/lib/case-utils.d.ts +0 -32
  146. package/dist/lib/case-utils.d.ts.map +1 -1
  147. package/dist/lib/case-utils.js +0 -32
  148. package/dist/lib/case-utils.js.map +1 -1
  149. package/dist/mcp/handler.d.ts +0 -4
  150. package/dist/mcp/handler.d.ts.map +1 -1
  151. package/dist/mcp/handler.js +12 -69
  152. package/dist/mcp/handler.js.map +1 -1
  153. package/dist/query/index.d.ts +55 -227
  154. package/dist/query/index.d.ts.map +1 -1
  155. package/dist/query/index.js +41 -117
  156. package/dist/query/index.js.map +1 -1
  157. package/dist/query/relationship-options.d.ts +1 -1
  158. package/dist/query/relationship-options.js +1 -1
  159. package/dist/utils/password.d.ts +0 -68
  160. package/dist/utils/password.d.ts.map +1 -1
  161. package/dist/utils/password.js +7 -76
  162. package/dist/utils/password.js.map +1 -1
  163. package/dist/validation/field-config.d.ts.map +1 -1
  164. package/dist/validation/field-config.js +0 -6
  165. package/dist/validation/field-config.js.map +1 -1
  166. package/dist/validation/schema.d.ts +0 -7
  167. package/dist/validation/schema.d.ts.map +1 -1
  168. package/dist/validation/schema.js +0 -10
  169. package/dist/validation/schema.js.map +1 -1
  170. package/package.json +1 -1
  171. package/src/access/access-filter.test.ts +205 -275
  172. package/src/access/access-filter.ts +236 -272
  173. package/src/access/declared-dependencies.test.ts +277 -0
  174. package/src/access/declared-dependencies.ts +117 -37
  175. package/src/access/depth-limits.ts +8 -7
  176. package/src/access/engine.ts +0 -25
  177. package/src/access/errors.ts +97 -12
  178. package/src/access/field-access.test.ts +312 -2
  179. package/src/access/field-access.ts +118 -42
  180. package/src/access/field-transforms.ts +8 -39
  181. package/src/access/field-visibility.ts +99 -45
  182. package/src/access/index.ts +17 -5
  183. package/src/access/query-validation.ts +359 -0
  184. package/src/access/relationship-count.ts +3 -11
  185. package/src/access/relationship-label-filter.test.ts +19 -68
  186. package/src/access/relationship-label-filter.ts +27 -104
  187. package/src/access/transaction-registry.ts +58 -0
  188. package/src/access/types.ts +83 -86
  189. package/src/config/index.ts +10 -22
  190. package/src/config/label.ts +6 -13
  191. package/src/config/nav-count.ts +11 -19
  192. package/src/config/plugin-engine.ts +1 -37
  193. package/src/config/types.ts +95 -110
  194. package/src/context/apply-defaults.ts +12 -44
  195. package/src/context/hook-pipeline.ts +12 -67
  196. package/src/context/index.ts +311 -276
  197. package/src/context/nested-operations.ts +94 -226
  198. package/src/context/transaction-boundary.ts +113 -109
  199. package/src/context/write-pipeline.ts +99 -155
  200. package/src/fields/format-prisma-default.ts +4 -7
  201. package/src/fields/index.ts +155 -147
  202. package/src/filter/collect.ts +48 -12
  203. package/src/filter/filter.test.ts +153 -15
  204. package/src/filter/index.ts +1 -0
  205. package/src/filter/map.ts +0 -5
  206. package/src/filter/parse.ts +2 -9
  207. package/src/filter/serialize.ts +2 -4
  208. package/src/hooks/index.ts +5 -59
  209. package/src/index.ts +23 -4
  210. package/src/internal.ts +5 -0
  211. package/src/lib/case-utils.ts +0 -32
  212. package/src/mcp/handler.ts +13 -71
  213. package/src/query/index.ts +82 -235
  214. package/src/query/relationship-options.ts +1 -1
  215. package/src/utils/password.ts +7 -76
  216. package/src/validation/field-config.ts +0 -6
  217. package/src/validation/schema.ts +0 -10
  218. package/tests/access-relationships.test.ts +18 -16
  219. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  220. package/tests/context.test.ts +727 -1
  221. package/tests/field-types.test.ts +222 -0
  222. package/tests/mcp-handler.test.ts +34 -0
  223. package/tests/needs-declared-dependencies.test.ts +7 -4
  224. package/tests/resolve-chain.test.ts +11 -11
  225. package/tests/transaction-boundary-outermost.test.ts +530 -0
  226. package/tsconfig.tsbuildinfo +1 -1
@@ -3,6 +3,7 @@ import type {
3
3
  TextField,
4
4
  IntegerField,
5
5
  DecimalField,
6
+ BigIntField,
6
7
  CheckboxField,
7
8
  TimestampField,
8
9
  CalendarDayField,
@@ -21,13 +22,9 @@ import { getLabelFieldName } from '../config/label.js'
21
22
  import type { FilterOperator, FilterSpec } from '../filter/types.js'
22
23
  import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js'
23
24
 
24
- /**
25
- * Operators shared by numeric/date fields: plain equality plus the four
26
- * comparisons. `eq` maps to Prisma's `equals`; the comparisons pass through.
27
- */
25
+ /** Operators shared by numeric/date fields' `getFilterSpec`. */
28
26
  const COMPARISON_OPERATORS: FilterOperator[] = ['eq', 'gt', 'gte', 'lt', 'lte']
29
27
 
30
- /** Map a Filter operator to its Prisma condition key (`eq` → `equals`). */
31
28
  function prismaComparisonKey(operator: FilterOperator): string {
32
29
  return operator === 'eq' ? 'equals' : operator
33
30
  }
@@ -39,6 +36,7 @@ export type {
39
36
  TextField,
40
37
  IntegerField,
41
38
  DecimalField,
39
+ BigIntField,
42
40
  CheckboxField,
43
41
  TimestampField,
44
42
  CalendarDayField,
@@ -53,9 +51,6 @@ export type {
53
51
  MultiColumnPrismaResult,
54
52
  } from '../config/types.js'
55
53
 
56
- /**
57
- * Format field name for display in error messages
58
- */
59
54
  function formatFieldName(fieldName: string): string {
60
55
  return fieldName
61
56
  .replace(/([A-Z])/g, ' $1')
@@ -117,12 +112,10 @@ export function text<
117
112
  const isNullable = db?.isNullable ?? !isRequired
118
113
  let modifiers = ''
119
114
 
120
- // Optional modifier
121
115
  if (isNullable) {
122
116
  modifiers += '?'
123
117
  }
124
118
 
125
- // Native type modifier (e.g., @db.Text)
126
119
  if (db?.nativeType) {
127
120
  modifiers += ` @db.${db.nativeType}`
128
121
  }
@@ -151,7 +144,6 @@ export function text<
151
144
  modifiers += ' @unique'
152
145
  }
153
146
 
154
- // Map modifier
155
147
  if (db?.map) {
156
148
  modifiers += ` @map("${db.map}")`
157
149
  }
@@ -171,9 +163,6 @@ export function text<
171
163
  optional: !isRequired,
172
164
  }
173
165
  },
174
- // Text fields drive free-text search: a bare word (or a `field:value`) maps
175
- // to a case-preserving `contains`. This is what replaces the old hard-coded
176
- // `type === 'text'` search in the admin list view.
177
166
  getFilterSpec: (fieldName: string): FilterSpec => ({
178
167
  operators: ['eq'],
179
168
  freeText: true,
@@ -223,24 +212,19 @@ export function integer<
223
212
  const isNullable = db?.isNullable ?? !isRequired
224
213
  let modifiers = ''
225
214
 
226
- // Optional modifier
227
215
  if (isNullable) {
228
216
  modifiers += '?'
229
217
  }
230
218
 
231
- // Native type modifier (e.g., @db.SmallInt, @db.BigInt)
232
219
  if (db?.nativeType) {
233
220
  modifiers += ` @db.${db.nativeType}`
234
221
  }
235
222
 
236
- // Default value if provided (bare numeric literal). Independent of the
237
- // nullable `?` modifier above — the default never overwrites nullability.
238
223
  const defaultLiteral = formatPrismaDefault(options?.defaultValue, 'integer')
239
224
  if (defaultLiteral !== undefined) {
240
225
  modifiers += ` @default(${defaultLiteral})`
241
226
  }
242
227
 
243
- // Map modifier
244
228
  if (db?.map) {
245
229
  modifiers += ` @map("${db.map}")`
246
230
  }
@@ -258,8 +242,7 @@ export function integer<
258
242
  optional: !isRequired,
259
243
  }
260
244
  },
261
- // Integers support equality and comparisons (`orders:>5`). A non-integer
262
- // value can't be interpreted, so its token degrades to free text.
245
+ // A non-integer token can't be interpreted, so it degrades to free text.
263
246
  getFilterSpec: (fieldName: string): FilterSpec => ({
264
247
  operators: COMPARISON_OPERATORS,
265
248
  toCondition: (operator, value) => {
@@ -327,8 +310,6 @@ export function decimal<
327
310
  scale,
328
311
  ...options,
329
312
  getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
330
- // Decimal values can be provided as strings or numbers
331
- // Prisma will convert them to Decimal instances
332
313
  const baseSchema = z.union(
333
314
  [
334
315
  z.string({
@@ -345,7 +326,6 @@ export function decimal<
345
326
 
346
327
  let schema = baseSchema
347
328
 
348
- // Add min validation if specified
349
329
  if (options?.validation?.min !== undefined) {
350
330
  const minValue = parseFloat(options.validation.min)
351
331
  schema = schema.refine(
@@ -359,7 +339,6 @@ export function decimal<
359
339
  )
360
340
  }
361
341
 
362
- // Add max validation if specified
363
342
  if (options?.validation?.max !== undefined) {
364
343
  const maxValue = parseFloat(options.validation.max)
365
344
  schema = schema.refine(
@@ -385,27 +364,22 @@ export function decimal<
385
364
 
386
365
  let modifiers = ''
387
366
 
388
- // Optional modifier
389
367
  if (isNullable) {
390
368
  modifiers += '?'
391
369
  }
392
370
 
393
- // Precision and scale
394
371
  modifiers += ` @db.Decimal(${precision}, ${scale})`
395
372
 
396
- // Default value if provided
397
373
  if (options?.defaultValue !== undefined) {
398
374
  modifiers += ` @default(${options.defaultValue})`
399
375
  }
400
376
 
401
- // Database mapping
402
377
  if (db?.map) {
403
378
  modifiers += ` @map("${db.map}")`
404
379
  }
405
380
 
406
- // Unique modifier. A non-unique index has no field-level form in Prisma,
407
- // so it is requested out-of-line via `index` below and emitted by the
408
- // generator as `@@index([...])` on the model.
381
+ // Unique modifier non-unique index routes through `index` below,
382
+ // same as `text()`'s getPrismaType.
409
383
  if (options?.isIndexed === 'unique') {
410
384
  modifiers += ' @unique'
411
385
  }
@@ -450,6 +424,133 @@ export function decimal<
450
424
  }
451
425
  }
452
426
 
427
+ /**
428
+ * 64-bit integer field for values that overflow `integer()`'s 32-bit `Int`
429
+ * (e.g. a millisecond epoch). Prisma `BigInt`, TypeScript `bigint`.
430
+ *
431
+ * Accepts `bigint`, an integer `number`, or a numeric `string` on write and
432
+ * always coerces to `bigint`. A `number` above `Number.MAX_SAFE_INTEGER` is
433
+ * rejected rather than coerced — it has already lost precision before this
434
+ * field sees it, so accepting it would reintroduce the exact defect this
435
+ * field exists to prevent. Pass a `bigint` or a string for values beyond that
436
+ * range.
437
+ *
438
+ * Wire representation (ADR-0029): `bigint` in application code, a decimal
439
+ * string over MCP.
440
+ */
441
+ export function bigInt<
442
+ TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
443
+ >(options?: Omit<BigIntField<TTypeInfo>, 'type'>): BigIntField<TTypeInfo> {
444
+ return {
445
+ type: 'bigInt',
446
+ ...options,
447
+ getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
448
+ const validation = options?.validation
449
+ const isRequired = validation?.isRequired
450
+
451
+ const coerced = z
452
+ .union([z.bigint(), z.number(), z.string()], {
453
+ message: `${formatFieldName(fieldName)} must be a bigint, an integer number, or a numeric string`,
454
+ })
455
+ .transform((val, ctx) => {
456
+ if (typeof val === 'bigint') return val
457
+
458
+ if (typeof val === 'number') {
459
+ if (!Number.isInteger(val)) {
460
+ ctx.addIssue(`${formatFieldName(fieldName)} must be an integer`)
461
+ return z.NEVER
462
+ }
463
+ if (!Number.isSafeInteger(val)) {
464
+ ctx.addIssue(
465
+ `${formatFieldName(fieldName)} exceeds Number.MAX_SAFE_INTEGER — pass a bigint or a numeric string instead`,
466
+ )
467
+ return z.NEVER
468
+ }
469
+ return BigInt(val)
470
+ }
471
+
472
+ const trimmed = val.trim()
473
+ if (!/^-?\d+$/.test(trimmed)) {
474
+ ctx.addIssue(`${formatFieldName(fieldName)} must be an integer`)
475
+ return z.NEVER
476
+ }
477
+ return BigInt(trimmed)
478
+ })
479
+
480
+ let schema = coerced
481
+
482
+ if (validation?.min !== undefined) {
483
+ const min = validation.min
484
+ schema = schema.refine((val) => val >= min, {
485
+ message: `${formatFieldName(fieldName)} must be at least ${min}`,
486
+ })
487
+ }
488
+
489
+ if (validation?.max !== undefined) {
490
+ const max = validation.max
491
+ schema = schema.refine((val) => val <= max, {
492
+ message: `${formatFieldName(fieldName)} must be at most ${max}`,
493
+ })
494
+ }
495
+
496
+ return !isRequired || operation === 'update' ? schema.optional().nullable() : schema
497
+ },
498
+ getPrismaType: (_fieldName: string) => {
499
+ const validation = options?.validation
500
+ const db = options?.db
501
+ const isRequired = validation?.isRequired
502
+ const isNullable = db?.isNullable ?? !isRequired
503
+ let modifiers = ''
504
+
505
+ if (isNullable) {
506
+ modifiers += '?'
507
+ }
508
+
509
+ if (db?.nativeType) {
510
+ modifiers += ` @db.${db.nativeType}`
511
+ }
512
+
513
+ if (options?.defaultValue !== undefined) {
514
+ modifiers += ` @default(${options.defaultValue})`
515
+ }
516
+
517
+ if (db?.map) {
518
+ modifiers += ` @map("${db.map}")`
519
+ }
520
+
521
+ // Unique modifier — non-unique index routes through `index` below,
522
+ // same as `text()`'s getPrismaType.
523
+ if (options?.isIndexed === 'unique') {
524
+ modifiers += ' @unique'
525
+ }
526
+
527
+ return {
528
+ type: 'BigInt',
529
+ modifiers: modifiers.trimStart() || undefined,
530
+ index: options?.isIndexed === true ? true : undefined,
531
+ }
532
+ },
533
+ getTypeScriptType: () => {
534
+ const isRequired = options?.validation?.isRequired
535
+
536
+ return {
537
+ type: 'bigint',
538
+ optional: !isRequired,
539
+ }
540
+ },
541
+ // A non-integer token degrades to free text.
542
+ getFilterSpec: (fieldName: string): FilterSpec => ({
543
+ operators: COMPARISON_OPERATORS,
544
+ toCondition: (operator, value) => {
545
+ const trimmed = value.trim()
546
+ if (!/^-?\d+$/.test(trimmed)) return null
547
+ return { [fieldName]: { [prismaComparisonKey(operator)]: BigInt(trimmed) } }
548
+ },
549
+ suggestions: { valueSource: { kind: 'none' } },
550
+ }),
551
+ }
552
+ }
553
+
453
554
  /**
454
555
  * Checkbox (boolean) field
455
556
  */
@@ -467,8 +568,9 @@ export function checkbox<
467
568
  const hasDefault = options?.defaultValue !== undefined
468
569
  let modifiers = ''
469
570
 
470
- // Nullable modifier - checkbox fields are non-nullable by default (must be true or false)
471
- // Use db.isNullable: true to allow NULL values in the database
571
+ // Checkboxes are non-nullable by default (must be true or false), unlike
572
+ // the other scalar fields' nullable-unless-required default set
573
+ // db.isNullable: true to allow NULL.
472
574
  if (db?.isNullable === true) {
473
575
  modifiers += '?'
474
576
  }
@@ -477,7 +579,6 @@ export function checkbox<
477
579
  modifiers += ` @default(${options.defaultValue})`
478
580
  }
479
581
 
480
- // Map modifier
481
582
  if (db?.map) {
482
583
  modifiers += ` @map("${db.map}")`
483
584
  }
@@ -493,8 +594,7 @@ export function checkbox<
493
594
  optional: options?.defaultValue === undefined,
494
595
  }
495
596
  },
496
- // Checkboxes filter by equality against the two enumerated values. Anything
497
- // other than true/false can't be interpreted and degrades to free text.
597
+ // Anything other than true/false degrades to free text.
498
598
  getFilterSpec: (fieldName: string): FilterSpec => ({
499
599
  operators: ['eq'],
500
600
  toCondition: (operator, value) => {
@@ -537,27 +637,22 @@ export function timestamp<
537
637
  'kind' in options.defaultValue &&
538
638
  options.defaultValue.kind === 'now'
539
639
 
540
- // Nullability: explicit db.isNullable overrides the default (nullable unless @default(now()))
541
640
  const isNullable = db?.isNullable ?? !hasDefaultNow
542
641
 
543
642
  let modifiers = ''
544
643
 
545
- // Optional modifier
546
644
  if (isNullable) {
547
645
  modifiers += '?'
548
646
  }
549
647
 
550
- // Default value
551
648
  if (hasDefaultNow) {
552
649
  modifiers += ' @default(now())'
553
650
  }
554
651
 
555
- // Native type modifier (e.g., @db.Timestamptz for PostgreSQL)
556
652
  if (db?.nativeType) {
557
653
  modifiers += ` @db.${db.nativeType}`
558
654
  }
559
655
 
560
- // Map modifier
561
656
  if (db?.map) {
562
657
  modifiers += ` @map("${db.map}")`
563
658
  }
@@ -579,8 +674,7 @@ export function timestamp<
579
674
  optional: !hasDefault,
580
675
  }
581
676
  },
582
- // Timestamps support equality and comparisons (`joined:>2024-01-01`). An
583
- // unparseable date degrades to free text.
677
+ // An unparseable date degrades to free text.
584
678
  getFilterSpec: (fieldName: string): FilterSpec => ({
585
679
  operators: COMPARISON_OPERATORS,
586
680
  toCondition: (operator, value) => {
@@ -659,23 +753,11 @@ export function calendarDay<
659
753
  return {
660
754
  type: 'calendarDay',
661
755
  ...options,
662
- // Writes: the write pipeline runs field resolveInput BEFORE zod
663
- // validation (Hook Pipeline: field resolveInput built-in field rules),
664
- // so this is the only point a YYYY-MM-DD string can be turned into
665
- // something Prisma's `@db.Date` write validator accepts Prisma 7
666
- // rejects a bare date string there (#621). Convert a valid string to a
667
- // UTC-midnight Date; leave anything else (a Date already, null/undefined,
668
- // or a malformed string) untouched so the zod schema below still rejects
669
- // malformed input with a clear message. Reads resolvedData[fieldKey]
670
- // (not raw inputData) so a list-level resolveInput that injects a default
671
- // for an omitted key is still coerced instead of being overwritten.
672
- //
673
- // Reads: the underlying @db.Date column hands Prisma a Date (or a TEXT
674
- // string under the SQLite fallback). Normalise to a YYYY-MM-DD string so the
675
- // runtime value matches the declared `string` type. UTC components are used
676
- // so the formatting never drifts a day in non-UTC timezones.
677
- // Cast hooks to any since field builders are generic and can't know the
678
- // specific TFieldKey (same pattern as password()).
756
+ // Hook Pipeline runs field resolveInput before zod validation — the only
757
+ // point a YYYY-MM-DD string can be turned into what Prisma's `@db.Date`
758
+ // write validator accepts (#621). Reads resolvedData[fieldKey], not raw
759
+ // inputData, so a list-level resolveInput's injected default for an
760
+ // omitted key is still coerced rather than overwritten.
679
761
  hooks: {
680
762
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
681
763
  resolveInput: ({ resolvedData, fieldKey }: { resolvedData: any; fieldKey: string }) => {
@@ -688,7 +770,6 @@ export function calendarDay<
688
770
  },
689
771
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
690
772
  resolveOutput: ({ value }: { value: any }) => formatCalendarDay(value),
691
- // Merge with user-provided hooks if any
692
773
  ...options?.hooks,
693
774
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hook object needs type assertion for field builder
694
775
  } as any,
@@ -696,10 +777,9 @@ export function calendarDay<
696
777
  const validation = options?.validation
697
778
  const isRequired = validation?.isRequired
698
779
 
699
- // Accept ISO8601 date strings (YYYY-MM-DD) in the shape a caller passes,
700
- // or a `Date` the shape resolveInput above turns a valid string into
701
- // before this schema runs. Malformed strings fall through resolveInput
702
- // untouched and still fail the regex here with a clear message.
780
+ // Accepts a `Date` because resolveInput above already converted a valid
781
+ // string to one before this schema runs; a malformed string falls
782
+ // through resolveInput untouched and fails the regex here instead.
703
783
  const stringSchema = z
704
784
  .string({
705
785
  message: `${formatFieldName(fieldName)} must be a valid date in ISO8601 format (YYYY-MM-DD)`,
@@ -713,7 +793,6 @@ export function calendarDay<
713
793
  if (isRequired && operation === 'create') {
714
794
  return dateSchema
715
795
  } else if (isRequired && operation === 'update') {
716
- // Required in update mode: omitted keys pass; present values must be valid
717
796
  return dateSchema.optional()
718
797
  } else {
719
798
  return dateSchema.optional().nullable()
@@ -727,31 +806,26 @@ export function calendarDay<
727
806
 
728
807
  let modifiers = ''
729
808
 
730
- // Optional modifier
731
809
  if (isNullable) {
732
810
  modifiers += '?'
733
811
  }
734
812
 
735
- // Add @db.Date attribute for date-only storage
736
- // Only for PostgreSQL/MySQL - SQLite doesn't support native DATE type
737
- // SQLite will use TEXT for DateTime fields
813
+ // SQLite has no native DATE type and falls back to TEXT for DateTime
814
+ // columns, so @db.Date only applies on PostgreSQL/MySQL.
738
815
  if (provider && provider.toLowerCase() !== 'sqlite') {
739
816
  modifiers += ' @db.Date'
740
817
  }
741
818
 
742
- // Default value if provided
743
819
  if (options?.defaultValue !== undefined) {
744
820
  modifiers += ` @default("${options.defaultValue}")`
745
821
  }
746
822
 
747
- // Database mapping
748
823
  if (db?.map) {
749
824
  modifiers += ` @map("${db.map}")`
750
825
  }
751
826
 
752
- // Unique modifier. A non-unique index has no field-level form in Prisma,
753
- // so it is requested out-of-line via `index` below and emitted by the
754
- // generator as `@@index([...])` on the model.
827
+ // Unique modifier non-unique index routes through `index` below,
828
+ // same as `text()`'s getPrismaType.
755
829
  if (options?.isIndexed === 'unique') {
756
830
  modifiers += ' @unique'
757
831
  }
@@ -768,12 +842,6 @@ export function calendarDay<
768
842
  const isRequired = validation?.isRequired
769
843
  const isNullable = db?.isNullable ?? !isRequired
770
844
 
771
- // calendarDay is a YYYY-MM-DD string end-to-end (Keystone's CalendarDay
772
- // scalar). Returning 'string' here makes the entity/read type and the
773
- // standalone generated CreateInput/UpdateInput types `string`. At the
774
- // context.db write path a Date is still rejected at runtime by validation
775
- // (the generated db method `data` type derives from Prisma's `Date | string`
776
- // input — making it a compile-time error is tracked in #599).
777
845
  return {
778
846
  type: 'string',
779
847
  optional: isNullable,
@@ -884,46 +952,37 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
884
952
  ...options,
885
953
  resultExtension: {
886
954
  outputType: "import('@opensaas/stack-core/internal').HashedPassword",
887
- // No compute - delegates to resolveOutput hook
888
955
  },
889
956
  ui: {
890
957
  ...options?.ui,
891
958
  valueForClientSerialization: ({ value }) => ({ isSet: !!value }),
892
959
  },
893
- // Cast hooks to any since field builders are generic and can't know the specific TFieldKey
894
960
  hooks: {
895
- // Hash password before writing to database
896
961
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
897
962
  resolveInput: async ({ inputData, fieldKey }: { inputData: any; fieldKey: string }) => {
898
- // Skip if undefined or null (allows partial updates)
899
963
  const inputValue = inputData[fieldKey]
900
964
  if (inputValue === undefined || inputValue === null) {
901
965
  return inputValue
902
966
  }
903
967
 
904
- // Skip if not a string
905
968
  if (typeof inputValue !== 'string' || inputValue.length === 0) {
906
969
  return inputValue
907
970
  }
908
971
 
909
- // Skip if already hashed (idempotent)
972
+ // Idempotent: skip re-hashing a value that's already a hash.
910
973
  if (isHashedPassword(inputValue)) {
911
974
  return inputValue
912
975
  }
913
976
 
914
- // Hash the password
915
977
  return (await hashPassword(inputValue)).toString()
916
978
  },
917
- // Wrap password with HashedPassword class after reading from database
918
979
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
919
980
  resolveOutput: ({ value }: { value: any }) => {
920
- // Only wrap string values (hashed passwords)
921
981
  if (typeof value === 'string' && value.length > 0) {
922
982
  return new HashedPassword(value)
923
983
  }
924
984
  return undefined
925
985
  },
926
- // Merge with user-provided hooks if any
927
986
  ...options?.hooks,
928
987
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hook object needs type assertion for field builder
929
988
  } as any,
@@ -932,7 +991,6 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
932
991
  const isRequired = validation?.isRequired
933
992
 
934
993
  if (isRequired && operation === 'create') {
935
- // Required in create mode: reject undefined and empty strings
936
994
  return z
937
995
  .string({
938
996
  message: `${formatFieldName(fieldName)} must be text`,
@@ -941,7 +999,6 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
941
999
  message: `${formatFieldName(fieldName)} is required`,
942
1000
  })
943
1001
  } else if (isRequired && operation === 'update') {
944
- // Required in update mode: omitted keys pass; if provided, reject empty strings
945
1002
  return z
946
1003
  .string()
947
1004
  .min(1, {
@@ -949,7 +1006,6 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
949
1006
  })
950
1007
  .optional()
951
1008
  } else {
952
- // Not required: can be undefined or any string
953
1009
  return z
954
1010
  .string({
955
1011
  message: `${formatFieldName(fieldName)} must be text`,
@@ -965,17 +1021,14 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
965
1021
  const isNullable = db?.isNullable ?? !isRequired
966
1022
  let modifiers = ''
967
1023
 
968
- // Optional modifier
969
1024
  if (isNullable) {
970
1025
  modifiers += '?'
971
1026
  }
972
1027
 
973
- // Native type modifier (e.g., @db.Text)
974
1028
  if (db?.nativeType) {
975
1029
  modifiers += ` @db.${db.nativeType}`
976
1030
  }
977
1031
 
978
- // Map modifier
979
1032
  if (db?.map) {
980
1033
  modifiers += ` @map("${db.map}")`
981
1034
  }
@@ -1055,7 +1108,6 @@ export function select<
1055
1108
  const isNullable = options.db?.isNullable ?? (!isRequired && !hasDefault)
1056
1109
  let modifiers = ''
1057
1110
 
1058
- // Optional modifier
1059
1111
  if (isNullable) {
1060
1112
  modifiers += '?'
1061
1113
  }
@@ -1068,12 +1120,11 @@ export function select<
1068
1120
  const derivedEnumName = listName ? `${listName}${capitalizedField}` : capitalizedField
1069
1121
  const enumName = options.db?.enumName ?? derivedEnumName
1070
1122
 
1071
- // Add default value if provided (no quotes for enum values)
1123
+ // No quotes for enum default values (unlike the string branch below).
1072
1124
  if (hasDefault) {
1073
1125
  modifiers += ` @default(${options.defaultValue})`
1074
1126
  }
1075
1127
 
1076
- // Map modifier
1077
1128
  if (options.db?.map) {
1078
1129
  modifiers += ` @map("${options.db.map}")`
1079
1130
  }
@@ -1087,12 +1138,10 @@ export function select<
1087
1138
 
1088
1139
  // String type (default)
1089
1140
 
1090
- // Add default value if provided
1091
1141
  if (hasDefault) {
1092
1142
  modifiers += ` @default("${options.defaultValue}")`
1093
1143
  }
1094
1144
 
1095
- // Map modifier
1096
1145
  if (options.db?.map) {
1097
1146
  modifiers += ` @map("${options.db.map}")`
1098
1147
  }
@@ -1159,9 +1208,6 @@ function parseRelationshipRef(ref: string): { list: string; field?: string } {
1159
1208
  }
1160
1209
  }
1161
1210
 
1162
- /**
1163
- * Check if a relationship is one-to-one (bidirectional with both sides having many: false).
1164
- */
1165
1211
  function isOneToOneRelationship(
1166
1212
  fieldName: string,
1167
1213
  field: RelationshipField,
@@ -1194,10 +1240,6 @@ function isOneToOneRelationship(
1194
1240
  return !(targetFieldConfig as RelationshipField).many
1195
1241
  }
1196
1242
 
1197
- /**
1198
- * Determine if this side of a relationship should store the foreign key.
1199
- * For one-to-one relationships, only one side stores the foreign key.
1200
- */
1201
1243
  function shouldHaveForeignKey(
1202
1244
  listKey: string,
1203
1245
  fieldName: string,
@@ -1248,9 +1290,6 @@ function shouldHaveForeignKey(
1248
1290
  return fieldName.localeCompare(targetField) < 0
1249
1291
  }
1250
1292
 
1251
- /**
1252
- * Check whether a many relationship is a true many-to-many (both sides many).
1253
- */
1254
1293
  function isManyToMany(
1255
1294
  fieldName: string,
1256
1295
  field: RelationshipField,
@@ -1324,13 +1363,9 @@ function computeManyToManyRelationName(
1324
1363
  return `${listKey}_${fieldName}`
1325
1364
  }
1326
1365
 
1327
- // Default Prisma naming - no explicit relation name needed
1328
1366
  return undefined
1329
1367
  }
1330
1368
 
1331
- /**
1332
- * Build the Prisma schema contribution for a relationship field.
1333
- */
1334
1369
  function getPrismaRelation(
1335
1370
  field: RelationshipField,
1336
1371
  fieldName: string,
@@ -1444,7 +1479,6 @@ export function relationship<
1444
1479
  throw new Error('Relationship field must have a ref')
1445
1480
  }
1446
1481
 
1447
- // Validate ref format: 'ListName.fieldName' or 'ListName'
1448
1482
  const refParts = options.ref.split('.')
1449
1483
  if (refParts.length !== 1 && refParts.length !== 2) {
1450
1484
  throw new Error(
@@ -1452,9 +1486,7 @@ export function relationship<
1452
1486
  )
1453
1487
  }
1454
1488
 
1455
- // Validate db.foreignKey usage
1456
1489
  if (options.db?.foreignKey !== undefined) {
1457
- // Can only be used on single relationships (not many)
1458
1490
  if (options.many) {
1459
1491
  throw new Error(
1460
1492
  'db.foreignKey can only be used on single relationships (many: false or undefined). ' +
@@ -1462,7 +1494,6 @@ export function relationship<
1462
1494
  )
1463
1495
  }
1464
1496
 
1465
- // Can only be used on bidirectional relationships (with target field)
1466
1497
  if (refParts.length === 1) {
1467
1498
  throw new Error(
1468
1499
  'db.foreignKey can only be used on bidirectional relationships (ref: "ListName.fieldName"). ' +
@@ -1471,9 +1502,6 @@ export function relationship<
1471
1502
  }
1472
1503
  }
1473
1504
 
1474
- // Validate db.isNullable usage: only the FK-owning (single) side of a
1475
- // relationship has a column to make non-nullable — the many side always
1476
- // generates an array field with no nullability of its own.
1477
1505
  if (options.db?.isNullable !== undefined && options.many) {
1478
1506
  throw new Error(
1479
1507
  'db.isNullable can only be used on single relationships (many: false or undefined). ' +
@@ -1601,7 +1629,6 @@ export function json<
1601
1629
  const validation = options?.validation
1602
1630
  const isRequired = validation?.isRequired
1603
1631
 
1604
- // Accept any valid JSON value
1605
1632
  const baseSchema = z.unknown()
1606
1633
 
1607
1634
  if (isRequired && operation === 'create') {
@@ -1627,7 +1654,6 @@ export function json<
1627
1654
  })
1628
1655
  .optional()
1629
1656
  } else {
1630
- // Not required: can be undefined or null
1631
1657
  return baseSchema.optional().nullable()
1632
1658
  }
1633
1659
  },
@@ -1638,25 +1664,19 @@ export function json<
1638
1664
  const isNullable = db?.isNullable ?? !isRequired
1639
1665
  let modifiers = ''
1640
1666
 
1641
- // Optional modifier
1642
1667
  if (isNullable) {
1643
1668
  modifiers += '?'
1644
1669
  }
1645
1670
 
1646
- // Native type modifier
1647
1671
  if (db?.nativeType) {
1648
1672
  modifiers += ` @db.${db.nativeType}`
1649
1673
  }
1650
1674
 
1651
- // Default value if provided. Uses Keystone's JSON-literal form: canonical
1652
- // (space-free) JSON wrapped in escaped double quotes. Independent of the
1653
- // nullable `?` modifier above — the default never overwrites nullability.
1654
1675
  const defaultLiteral = formatPrismaDefault(options?.defaultValue, 'json')
1655
1676
  if (defaultLiteral !== undefined) {
1656
1677
  modifiers += ` @default(${defaultLiteral})`
1657
1678
  }
1658
1679
 
1659
- // Map modifier
1660
1680
  if (db?.map) {
1661
1681
  modifiers += ` @map("${db.map}")`
1662
1682
  }
@@ -1689,21 +1709,14 @@ function typeDescriptorToString(descriptor: import('../config/types.js').TypeDes
1689
1709
  return descriptor
1690
1710
  }
1691
1711
 
1692
- // Extract type name from constructor or use provided name
1693
1712
  const typeName = descriptor.name || descriptor.value.name
1694
1713
 
1695
- // Generate import string
1696
1714
  return `import('${descriptor.from}').${typeName}`
1697
1715
  }
1698
1716
 
1699
- /**
1700
- * Extract TypeScript imports from a TypeDescriptor
1701
- * Returns array of import statements needed for type generation
1702
- */
1703
1717
  function typeDescriptorToImports(
1704
1718
  descriptor: import('../config/types.js').TypeDescriptor,
1705
1719
  ): Array<{ names: string[]; from: string; typeOnly?: boolean }> {
1706
- // If it's a string, check if it's an import string
1707
1720
  if (typeof descriptor === 'string') {
1708
1721
  const importMatch = descriptor.match(/import\('([^']+)'\)\.(\w+)/)
1709
1722
  if (importMatch) {
@@ -1804,7 +1817,6 @@ export function virtual<TTypeInfo extends import('../config/types.js').TypeInfo>
1804
1817
  type: import('../config/types.js').TypeDescriptor
1805
1818
  },
1806
1819
  ): VirtualField<TTypeInfo> {
1807
- // Validate that resolveOutput is provided
1808
1820
  if (!options.hooks?.resolveOutput) {
1809
1821
  throw new Error(
1810
1822
  'Virtual fields must provide a resolveOutput hook to compute their value. ' +
@@ -1812,7 +1824,6 @@ export function virtual<TTypeInfo extends import('../config/types.js').TypeInfo>
1812
1824
  )
1813
1825
  }
1814
1826
 
1815
- // Convert type descriptor to string
1816
1827
  const outputType = typeDescriptorToString(options.type)
1817
1828
  const imports = typeDescriptorToImports(options.type)
1818
1829
 
@@ -1823,19 +1834,16 @@ export function virtual<TTypeInfo extends import('../config/types.js').TypeInfo>
1823
1834
  virtual: true,
1824
1835
  outputType,
1825
1836
  ...rest,
1826
- // Virtual fields don't create database columns
1827
- // Return undefined to signal generator to skip this field
1837
+ // undefined signals the generator to skip creating a database column.
1828
1838
  getPrismaType: undefined,
1829
- // Virtual fields appear in output types with their specified type
1830
1839
  getTypeScriptType: () => {
1831
1840
  return {
1832
1841
  type: outputType,
1833
- optional: false, // Virtual fields always compute a value
1842
+ optional: false, // A virtual field always computes a value.
1834
1843
  }
1835
1844
  },
1836
- // Add import statements if needed
1837
1845
  getTypeScriptImports: imports.length > 0 ? () => imports : undefined,
1838
- // Virtual fields never validate input (they don't accept database input)
1846
+ // Virtual fields don't accept database input, so validation always fails.
1839
1847
  getZodSchema: () => {
1840
1848
  return z.never()
1841
1849
  },