@opensaas/stack-core 0.38.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 (204) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +123 -0
  3. package/CLAUDE.md +21 -0
  4. package/dist/access/access-filter.d.ts +46 -1
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +113 -4
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/declared-dependencies.d.ts.map +1 -1
  9. package/dist/access/declared-dependencies.js +4 -9
  10. package/dist/access/declared-dependencies.js.map +1 -1
  11. package/dist/access/engine.d.ts +0 -20
  12. package/dist/access/engine.d.ts.map +1 -1
  13. package/dist/access/engine.js +0 -25
  14. package/dist/access/engine.js.map +1 -1
  15. package/dist/access/errors.d.ts +49 -0
  16. package/dist/access/errors.d.ts.map +1 -1
  17. package/dist/access/errors.js +75 -0
  18. package/dist/access/errors.js.map +1 -1
  19. package/dist/access/field-access.d.ts +39 -1
  20. package/dist/access/field-access.d.ts.map +1 -1
  21. package/dist/access/field-access.js +108 -42
  22. package/dist/access/field-access.js.map +1 -1
  23. package/dist/access/field-access.test.js +229 -2
  24. package/dist/access/field-access.test.js.map +1 -1
  25. package/dist/access/field-transforms.d.ts +0 -27
  26. package/dist/access/field-transforms.d.ts.map +1 -1
  27. package/dist/access/field-visibility.d.ts +0 -4
  28. package/dist/access/field-visibility.d.ts.map +1 -1
  29. package/dist/access/field-visibility.js +13 -39
  30. package/dist/access/field-visibility.js.map +1 -1
  31. package/dist/access/index.d.ts +6 -2
  32. package/dist/access/index.d.ts.map +1 -1
  33. package/dist/access/index.js +12 -2
  34. package/dist/access/index.js.map +1 -1
  35. package/dist/access/query-validation.d.ts +121 -0
  36. package/dist/access/query-validation.d.ts.map +1 -0
  37. package/dist/access/query-validation.js +292 -0
  38. package/dist/access/query-validation.js.map +1 -0
  39. package/dist/access/relationship-count.d.ts.map +1 -1
  40. package/dist/access/relationship-count.js +2 -10
  41. package/dist/access/relationship-count.js.map +1 -1
  42. package/dist/access/relationship-label-filter.d.ts +23 -23
  43. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  44. package/dist/access/relationship-label-filter.js +5 -76
  45. package/dist/access/relationship-label-filter.js.map +1 -1
  46. package/dist/access/relationship-label-filter.test.js +19 -56
  47. package/dist/access/relationship-label-filter.test.js.map +1 -1
  48. package/dist/access/transaction-registry.d.ts +45 -0
  49. package/dist/access/transaction-registry.d.ts.map +1 -0
  50. package/dist/access/transaction-registry.js +37 -0
  51. package/dist/access/transaction-registry.js.map +1 -0
  52. package/dist/access/types.d.ts +55 -59
  53. package/dist/access/types.d.ts.map +1 -1
  54. package/dist/config/index.d.ts +9 -7
  55. package/dist/config/index.d.ts.map +1 -1
  56. package/dist/config/index.js +10 -18
  57. package/dist/config/index.js.map +1 -1
  58. package/dist/config/label.d.ts +6 -13
  59. package/dist/config/label.d.ts.map +1 -1
  60. package/dist/config/label.js +6 -13
  61. package/dist/config/label.js.map +1 -1
  62. package/dist/config/nav-count.d.ts +11 -19
  63. package/dist/config/nav-count.d.ts.map +1 -1
  64. package/dist/config/nav-count.js +11 -19
  65. package/dist/config/nav-count.js.map +1 -1
  66. package/dist/config/plugin-engine.d.ts +0 -10
  67. package/dist/config/plugin-engine.d.ts.map +1 -1
  68. package/dist/config/plugin-engine.js +1 -37
  69. package/dist/config/plugin-engine.js.map +1 -1
  70. package/dist/config/types.d.ts +94 -109
  71. package/dist/config/types.d.ts.map +1 -1
  72. package/dist/context/apply-defaults.d.ts +8 -31
  73. package/dist/context/apply-defaults.d.ts.map +1 -1
  74. package/dist/context/apply-defaults.js +12 -41
  75. package/dist/context/apply-defaults.js.map +1 -1
  76. package/dist/context/hook-pipeline.d.ts +4 -29
  77. package/dist/context/hook-pipeline.d.ts.map +1 -1
  78. package/dist/context/hook-pipeline.js +8 -41
  79. package/dist/context/hook-pipeline.js.map +1 -1
  80. package/dist/context/index.d.ts +4 -15
  81. package/dist/context/index.d.ts.map +1 -1
  82. package/dist/context/index.js +190 -193
  83. package/dist/context/index.js.map +1 -1
  84. package/dist/context/nested-operations.d.ts +7 -26
  85. package/dist/context/nested-operations.d.ts.map +1 -1
  86. package/dist/context/nested-operations.js +73 -171
  87. package/dist/context/nested-operations.js.map +1 -1
  88. package/dist/context/transaction-boundary.d.ts +36 -47
  89. package/dist/context/transaction-boundary.d.ts.map +1 -1
  90. package/dist/context/transaction-boundary.js +92 -79
  91. package/dist/context/transaction-boundary.js.map +1 -1
  92. package/dist/context/write-pipeline.d.ts +25 -64
  93. package/dist/context/write-pipeline.d.ts.map +1 -1
  94. package/dist/context/write-pipeline.js +81 -111
  95. package/dist/context/write-pipeline.js.map +1 -1
  96. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  97. package/dist/fields/format-prisma-default.js +4 -7
  98. package/dist/fields/format-prisma-default.js.map +1 -1
  99. package/dist/fields/index.d.ts +17 -2
  100. package/dist/fields/index.d.ts.map +1 -1
  101. package/dist/fields/index.js +136 -147
  102. package/dist/fields/index.js.map +1 -1
  103. package/dist/filter/collect.d.ts +26 -3
  104. package/dist/filter/collect.d.ts.map +1 -1
  105. package/dist/filter/collect.js +30 -12
  106. package/dist/filter/collect.js.map +1 -1
  107. package/dist/filter/filter.test.js +98 -15
  108. package/dist/filter/filter.test.js.map +1 -1
  109. package/dist/filter/index.d.ts +1 -0
  110. package/dist/filter/index.d.ts.map +1 -1
  111. package/dist/filter/index.js.map +1 -1
  112. package/dist/filter/map.d.ts.map +1 -1
  113. package/dist/filter/map.js +0 -5
  114. package/dist/filter/map.js.map +1 -1
  115. package/dist/filter/parse.d.ts.map +1 -1
  116. package/dist/filter/parse.js +2 -9
  117. package/dist/filter/parse.js.map +1 -1
  118. package/dist/filter/serialize.d.ts.map +1 -1
  119. package/dist/filter/serialize.js +2 -4
  120. package/dist/filter/serialize.js.map +1 -1
  121. package/dist/hooks/index.d.ts +4 -42
  122. package/dist/hooks/index.d.ts.map +1 -1
  123. package/dist/hooks/index.js +5 -59
  124. package/dist/hooks/index.js.map +1 -1
  125. package/dist/index.d.ts +3 -1
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +20 -4
  128. package/dist/index.js.map +1 -1
  129. package/dist/internal.d.ts +1 -0
  130. package/dist/internal.d.ts.map +1 -1
  131. package/dist/internal.js +4 -0
  132. package/dist/internal.js.map +1 -1
  133. package/dist/lib/case-utils.d.ts +0 -32
  134. package/dist/lib/case-utils.d.ts.map +1 -1
  135. package/dist/lib/case-utils.js +0 -32
  136. package/dist/lib/case-utils.js.map +1 -1
  137. package/dist/mcp/handler.d.ts +0 -4
  138. package/dist/mcp/handler.d.ts.map +1 -1
  139. package/dist/mcp/handler.js +12 -69
  140. package/dist/mcp/handler.js.map +1 -1
  141. package/dist/query/index.d.ts +26 -227
  142. package/dist/query/index.d.ts.map +1 -1
  143. package/dist/query/index.js +14 -117
  144. package/dist/query/index.js.map +1 -1
  145. package/dist/utils/password.d.ts +0 -68
  146. package/dist/utils/password.d.ts.map +1 -1
  147. package/dist/utils/password.js +7 -76
  148. package/dist/utils/password.js.map +1 -1
  149. package/dist/validation/field-config.d.ts.map +1 -1
  150. package/dist/validation/field-config.js +0 -6
  151. package/dist/validation/field-config.js.map +1 -1
  152. package/dist/validation/schema.d.ts +0 -7
  153. package/dist/validation/schema.d.ts.map +1 -1
  154. package/dist/validation/schema.js +0 -10
  155. package/dist/validation/schema.js.map +1 -1
  156. package/package.json +1 -1
  157. package/src/access/access-filter.ts +152 -5
  158. package/src/access/declared-dependencies.ts +4 -9
  159. package/src/access/engine.ts +0 -25
  160. package/src/access/errors.ts +81 -0
  161. package/src/access/field-access.test.ts +312 -2
  162. package/src/access/field-access.ts +118 -42
  163. package/src/access/field-transforms.ts +8 -39
  164. package/src/access/field-visibility.ts +16 -47
  165. package/src/access/index.ts +20 -2
  166. package/src/access/query-validation.ts +359 -0
  167. package/src/access/relationship-count.ts +2 -10
  168. package/src/access/relationship-label-filter.test.ts +19 -68
  169. package/src/access/relationship-label-filter.ts +27 -104
  170. package/src/access/transaction-registry.ts +58 -0
  171. package/src/access/types.ts +83 -86
  172. package/src/config/index.ts +10 -22
  173. package/src/config/label.ts +6 -13
  174. package/src/config/nav-count.ts +11 -19
  175. package/src/config/plugin-engine.ts +1 -37
  176. package/src/config/types.ts +95 -110
  177. package/src/context/apply-defaults.ts +12 -44
  178. package/src/context/hook-pipeline.ts +12 -67
  179. package/src/context/index.ts +263 -247
  180. package/src/context/nested-operations.ts +94 -226
  181. package/src/context/transaction-boundary.ts +113 -109
  182. package/src/context/write-pipeline.ts +99 -155
  183. package/src/fields/format-prisma-default.ts +4 -7
  184. package/src/fields/index.ts +155 -147
  185. package/src/filter/collect.ts +48 -12
  186. package/src/filter/filter.test.ts +153 -15
  187. package/src/filter/index.ts +1 -0
  188. package/src/filter/map.ts +0 -5
  189. package/src/filter/parse.ts +2 -9
  190. package/src/filter/serialize.ts +2 -4
  191. package/src/hooks/index.ts +5 -59
  192. package/src/index.ts +23 -4
  193. package/src/internal.ts +5 -0
  194. package/src/lib/case-utils.ts +0 -32
  195. package/src/mcp/handler.ts +13 -71
  196. package/src/query/index.ts +29 -235
  197. package/src/utils/password.ts +7 -76
  198. package/src/validation/field-config.ts +0 -6
  199. package/src/validation/schema.ts +0 -10
  200. package/tests/context.test.ts +700 -1
  201. package/tests/field-types.test.ts +222 -0
  202. package/tests/mcp-handler.test.ts +34 -0
  203. package/tests/transaction-boundary-outermost.test.ts +530 -0
  204. package/tsconfig.tsbuildinfo +1 -1
@@ -2,9 +2,6 @@ import type { AccessControl, FieldAccess } from '../access/types.js'
2
2
  import type { FilterSpec } from '../filter/types.js'
3
3
  import type { z } from 'zod'
4
4
 
5
- /**
6
- * Field configuration types
7
- */
8
5
  export type FieldType =
9
6
  'text' | 'integer' | 'checkbox' | 'timestamp' | 'password' | 'select' | 'relationship' | string // Allow custom field types from third-party packages
10
7
 
@@ -12,10 +9,7 @@ export type FieldType =
12
9
  * Field-level hook argument types (exported for user annotations)
13
10
  */
14
11
 
15
- /**
16
- * Arguments for field-level resolveInput hook
17
- * Used to transform field values before database write
18
- */
12
+ /** Arguments for {@link FieldHooks.resolveInput}. */
19
13
  export type FieldResolveInputHookArgs<
20
14
  TTypeInfo extends TypeInfo,
21
15
  TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
@@ -39,10 +33,7 @@ export type FieldResolveInputHookArgs<
39
33
  context: import('../access/types.js').AccessContext
40
34
  }
41
35
 
42
- /**
43
- * Arguments for field-level validate hook
44
- * Used for custom validation logic
45
- */
36
+ /** Arguments for {@link FieldHooks.validate} (and its deprecated `validateInput` alias). */
46
37
  export type FieldValidateHookArgs<
47
38
  TTypeInfo extends TypeInfo,
48
39
  TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
@@ -76,10 +67,7 @@ export type FieldValidateHookArgs<
76
67
  addValidationError: (msg: string) => void
77
68
  }
78
69
 
79
- /**
80
- * Arguments for field-level beforeOperation hook
81
- * Used for side effects before database write
82
- */
70
+ /** Arguments for {@link FieldHooks.beforeOperation}. */
83
71
  export type FieldBeforeOperationHookArgs<
84
72
  TTypeInfo extends TypeInfo,
85
73
  TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
@@ -109,10 +97,7 @@ export type FieldBeforeOperationHookArgs<
109
97
  context: import('../access/types.js').AccessContext
110
98
  }
111
99
 
112
- /**
113
- * Arguments for field-level afterOperation hook
114
- * Used for side effects after database operation
115
- */
100
+ /** Arguments for {@link FieldHooks.afterOperation}. */
116
101
  export type FieldAfterOperationHookArgs<
117
102
  TTypeInfo extends TypeInfo,
118
103
  TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
@@ -263,10 +248,7 @@ export type FieldAfterTransactionHookArgs<
263
248
  context: import('../access/types.js').AccessContext
264
249
  }
265
250
 
266
- /**
267
- * Arguments for field-level resolveOutput hook
268
- * Used to transform field values after database read
269
- */
251
+ /** Arguments for {@link FieldHooks.resolveOutput}. */
270
252
  export type FieldResolveOutputHookArgs<
271
253
  TTypeInfo extends TypeInfo,
272
254
  TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
@@ -507,10 +489,10 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
507
489
  defaultValue?: unknown
508
490
  hooks?: FieldHooks<TTypeInfo>
509
491
  /**
510
- * Marks this field as virtual - not stored in database
511
- * Virtual fields use resolveInput/resolveOutput hooks for computation
512
- * They are excluded from Prisma schema and input types
513
- * Only computed when explicitly selected/included in queries
492
+ * Marks this field as virtual not stored in database, computed via
493
+ * `resolveInput`/`resolveOutput` hooks, and excluded from the Prisma
494
+ * schema and input types. Computed whenever the read is going to return
495
+ * it (ADR-0027) not gated behind an explicit `include`/selection.
514
496
  */
515
497
  virtual?: boolean
516
498
  /**
@@ -877,6 +859,25 @@ export type DecimalField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfi
877
859
  isIndexed?: boolean | 'unique'
878
860
  }
879
861
 
862
+ /**
863
+ * 64-bit integer field (Prisma `BigInt`, TypeScript `bigint`) — for values that
864
+ * overflow `integer()`'s 32-bit `Int` (e.g. a millisecond epoch).
865
+ *
866
+ * Wire representation (ADR-0029): `bigint` in application code, a decimal
867
+ * string over MCP — `bigint` is not JSON-serialisable, so the MCP handler
868
+ * renders it as a string rather than throwing.
869
+ */
870
+ export type BigIntField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
871
+ type: 'bigInt'
872
+ defaultValue?: bigint | number | string
873
+ validation?: {
874
+ isRequired?: boolean
875
+ min?: bigint
876
+ max?: bigint
877
+ }
878
+ isIndexed?: boolean | 'unique'
879
+ }
880
+
880
881
  export type CheckboxField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
881
882
  type: 'checkbox'
882
883
  }
@@ -1358,20 +1359,8 @@ export type VirtualField<TTypeInfo extends TypeInfo> = BaseFieldConfig<TTypeInfo
1358
1359
  */
1359
1360
  export type FieldConfig = BaseFieldConfig<TypeInfo>
1360
1361
 
1361
- /**
1362
- * List configuration types
1363
- */
1364
-
1365
- /**
1366
- * Utility type to inject TypeInfo into a single field config
1367
- * Extracts TInput and TOutput from BaseFieldConfig and reconstructs with new TypeInfo
1368
- */
1369
1362
  type WithTypeInfo<TTypeInfo extends TypeInfo> = BaseFieldConfig<TTypeInfo>
1370
1363
 
1371
- /**
1372
- * Utility type to transform all fields in a record to inject TypeInfo
1373
- * Maps over each field and applies WithTypeInfo transformation
1374
- */
1375
1364
  export type FieldsWithTypeInfo<TTypeInfo extends TypeInfo> = {
1376
1365
  [key: string]: WithTypeInfo<TTypeInfo>
1377
1366
  }
@@ -1547,8 +1536,7 @@ export type ListAccessControl<T = any> =
1547
1536
  }
1548
1537
 
1549
1538
  /**
1550
- * Hook arguments for resolveInput hook
1551
- * Uses discriminated union to provide proper types based on operation
1539
+ * Hook arguments for the list-level `resolveInput` hook.
1552
1540
  * - create: resolvedData is CreateInput, item is undefined
1553
1541
  * - update: resolvedData is UpdateInput, item is the existing record
1554
1542
  */
@@ -1575,8 +1563,7 @@ export type ResolveInputHookArgs<
1575
1563
  }
1576
1564
 
1577
1565
  /**
1578
- * Hook arguments for validate hook (renamed from validateInput for Keystone compatibility)
1579
- * Uses discriminated union to provide proper types based on operation
1566
+ * Hook arguments for the list-level `validate` hook (renamed from `validateInput` for Keystone compatibility).
1580
1567
  * - create: resolvedData is CreateInput, item is undefined
1581
1568
  * - update: resolvedData is UpdateInput, item is the existing record
1582
1569
  * - delete: item is the item being deleted
@@ -1613,8 +1600,7 @@ export type ValidateHookArgs<
1613
1600
  }
1614
1601
 
1615
1602
  /**
1616
- * Hook arguments for beforeOperation hook
1617
- * Uses discriminated union to provide proper types based on operation
1603
+ * Hook arguments for the list-level `beforeOperation` hook.
1618
1604
  * - create: has inputData and resolvedData, no item
1619
1605
  * - update: has inputData, resolvedData, and item
1620
1606
  * - delete: has item only
@@ -1647,8 +1633,7 @@ export type BeforeOperationHookArgs<
1647
1633
  }
1648
1634
 
1649
1635
  /**
1650
- * Hook arguments for afterOperation hook
1651
- * Uses discriminated union to provide proper types based on operation
1636
+ * Hook arguments for the list-level `afterOperation` hook.
1652
1637
  * - create: has item, inputData, and resolvedData, no originalItem
1653
1638
  * - update: has item, originalItem, inputData, and resolvedData
1654
1639
  * - delete: has originalItem only
@@ -1848,14 +1833,20 @@ export type ListIndexFieldRef =
1848
1833
  }
1849
1834
 
1850
1835
  /**
1851
- * A model-level composite `@@unique`/`@@index` constraint spanning two or
1852
- * more of a list's own fields. See {@link ListConfig.db}'s `indexes` for the
1853
- * full explanation and examples (#864).
1836
+ * A model-level `@@unique`/`@@index` constraint spanning one or more of a
1837
+ * list's own fields. See {@link ListConfig.db}'s `indexes` for the full
1838
+ * explanation and examples (#864, #918).
1854
1839
  */
1855
1840
  export type ListIndex = {
1856
1841
  /**
1857
1842
  * The fields participating in this index/constraint, in declaration order.
1858
1843
  * OpenSaaS field names, not raw database column names.
1844
+ *
1845
+ * One or more fields — a named single-column constraint is as legitimate
1846
+ * as a composite one, and is the form to reach for when a live table's
1847
+ * constraint carries a name Prisma wouldn't derive on its own. Field-level
1848
+ * `isIndexed` remains the sugar for the unnamed single-column case; this
1849
+ * is the full form when a name or a sort direction is needed.
1859
1850
  */
1860
1851
  fields: ListIndexFieldRef[]
1861
1852
  /**
@@ -1949,31 +1940,40 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
1949
1940
  */
1950
1941
  timestamps?: boolean
1951
1942
  /**
1952
- * Model-level composite `@@unique`/`@@index` constraints spanning two or
1953
- * more of this list's own fields (#864).
1943
+ * Model-level `@@unique`/`@@index` constraints spanning one or more of
1944
+ * this list's own fields (#864, #918). See the [reference
1945
+ * docs](https://stack.opensaas.au/docs/reference/config-api#dbindexes)
1946
+ * for the full explanation, arity, and error conditions.
1954
1947
  *
1955
- * Field-level `isIndexed` (on a scalar or relationship field) can only
1956
- * ever produce a single-column index it has no way to express a
1957
- * constraint or index that spans more than one column. `db.indexes` is
1958
- * that multi-column case: each entry names two or more of the list's own
1959
- * OpenSaaS field names, not raw database column names. The generator
1960
- * resolves each to its underlying Prisma column a scalar field's own
1961
- * name (its Prisma field name is unaffected by `db.map`), or a
1962
- * relationship field's foreign key column (`<field>Id`) when this side
1963
- * owns it.
1948
+ * Field-level `isIndexed` (on a scalar or relationship field) is the
1949
+ * sugar for the unnamed single-column case. `db.indexes` is the full
1950
+ * form reach for it when a constraint needs a `name` (Prisma's `map:`,
1951
+ * for adopting an existing live constraint), a `sort` direction, or spans
1952
+ * more than one column. Arity is incidental: each entry names one or
1953
+ * more of the list's own OpenSaaS field names, not raw database column
1954
+ * names. The generator resolves each to its underlying Prisma column a
1955
+ * scalar field's own name (its Prisma field name is unaffected by
1956
+ * `db.map`), or a relationship field's foreign key column (`<field>Id`)
1957
+ * when this side owns it.
1964
1958
  *
1965
- * A composite **unique** is the load-bearing case: it's the
1966
- * database-level backstop for a business rule two concurrent writes could
1967
- * otherwise both slip past (e.g. "one booking per student per
1968
- * production") something a hook's existence check cannot close on its
1969
- * own, and can't be retrofitted once duplicate rows exist. A composite
1970
- * **index** (non-unique) is the equivalent performance-only case (a hot
1971
- * multi-column lookup path).
1959
+ * A **unique** entry is the load-bearing case: it's the database-level
1960
+ * backstop for a business rule concurrent writes could otherwise both
1961
+ * slip past (e.g. "one booking per student per production", or a
1962
+ * single-column unique a live table already enforces under a name Prisma
1963
+ * wouldn't derive) something a hook's existence check cannot close on
1964
+ * its own, and can't be retrofitted once duplicate rows exist. An
1965
+ * **index** (non-unique) entry is the equivalent performance-only case
1966
+ * (a hot lookup path).
1972
1967
  *
1973
- * An entry naming a field the list doesn't have, a virtual field, a
1974
- * to-many relationship, or the non-FK side of a one-to-one relationship
1975
- * fails `pnpm generate` with an error naming the list, the entry, and the
1976
- * bad field it is never silently dropped or emitted as invalid Prisma.
1968
+ * Two conditions fail `pnpm generate` with an error naming the list and
1969
+ * the entry: an empty `fields` array, and a single-field entry that
1970
+ * indexes the exact column a field-level `isIndexed` on the same list
1971
+ * already indexes (the error also names that field and its `isIndexed`
1972
+ * value — one of the two should be removed). An entry naming a field the
1973
+ * list doesn't have, a virtual field, a to-many relationship, or the
1974
+ * non-FK side of a one-to-one relationship fails the same way, naming
1975
+ * the bad field too — no entry is ever silently dropped or emitted as
1976
+ * invalid Prisma.
1977
1977
  *
1978
1978
  * @example One audition per student per production (composite unique)
1979
1979
  * ```typescript
@@ -2007,12 +2007,22 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
2007
2007
  * })
2008
2008
  * // Generates: @@index([identifier, createdAt(sort: Desc)], map: "AuthVerification_identifier_createdAt_idx")
2009
2009
  * ```
2010
+ *
2011
+ * @example Naming a single-column unique constraint (adopting a live table's existing name)
2012
+ * ```typescript
2013
+ * RateLimit: list({
2014
+ * fields: { key: text() }, // no isIndexed here — db.indexes owns this column instead
2015
+ * db: {
2016
+ * indexes: [{ fields: ['key'], unique: true, name: 'RateLimit_key_key' }],
2017
+ * },
2018
+ * })
2019
+ * // Generates: @@unique([key], map: "RateLimit_key_key")
2020
+ * // Setting key's own isIndexed too would duplicate this constraint and
2021
+ * // fail generation — one of the two must own the column.
2022
+ * ```
2010
2023
  */
2011
2024
  indexes?: ListIndex[]
2012
2025
  }
2013
- /**
2014
- * MCP server configuration for this list
2015
- */
2016
2026
  mcp?: ListMcpConfig
2017
2027
  /**
2018
2028
  * Restricts this list to a single record (singleton pattern)
@@ -2319,9 +2329,6 @@ export type ListConfigInput<TTypeInfo extends TypeInfo> = Omit<ListConfig<TTypeI
2319
2329
  access?: ListAccessControl<TTypeInfo['item']>
2320
2330
  }
2321
2331
 
2322
- /**
2323
- * Database configuration
2324
- */
2325
2332
  export type DatabaseConfig = {
2326
2333
  provider: 'postgresql' | 'mysql' | 'sqlite'
2327
2334
  /**
@@ -2540,9 +2547,6 @@ export type DatabaseConfig = {
2540
2547
  }
2541
2548
  }
2542
2549
 
2543
- /**
2544
- * Session configuration
2545
- */
2546
2550
  export type SessionConfig = {
2547
2551
  // Uses `any` return type because session structure is user-defined and varies per application
2548
2552
  // The stack doesn't enforce a specific session shape - users can use NextAuth, Clerk, etc.
@@ -2550,9 +2554,6 @@ export type SessionConfig = {
2550
2554
  getSession: () => Promise<any>
2551
2555
  }
2552
2556
 
2553
- /**
2554
- * Theme preset options
2555
- */
2556
2557
  export type ThemePreset = 'modern' | 'classic' | 'neon'
2557
2558
 
2558
2559
  /**
@@ -2648,14 +2649,8 @@ export type ThemeConfig = {
2648
2649
  shadows?: ThemeShadows
2649
2650
  }
2650
2651
 
2651
- /**
2652
- * UI configuration
2653
- */
2654
2652
  export type UIConfig = {
2655
2653
  basePath?: string
2656
- /**
2657
- * Theme configuration for the admin UI
2658
- */
2659
2654
  theme?: ThemeConfig
2660
2655
  }
2661
2656
 
@@ -2698,18 +2693,12 @@ export type McpCustomTool = {
2698
2693
  * Unique name for the tool
2699
2694
  */
2700
2695
  name: string
2701
- /**
2702
- * Description of what the tool does
2703
- */
2704
2696
  description: string
2705
2697
  /**
2706
2698
  * Input schema (Zod schema)
2707
2699
  */
2708
2700
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2709
2701
  inputSchema: any
2710
- /**
2711
- * Handler function that executes the tool
2712
- */
2713
2702
  handler: (args: {
2714
2703
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2715
2704
  input: any
@@ -2717,9 +2706,6 @@ export type McpCustomTool = {
2717
2706
  }) => Promise<unknown>
2718
2707
  }
2719
2708
 
2720
- /**
2721
- * List-level MCP configuration
2722
- */
2723
2709
  export type ListMcpConfig = {
2724
2710
  /**
2725
2711
  * Enable MCP tools for this list
@@ -2790,9 +2776,6 @@ export type McpAuthConfig =
2790
2776
  * Authentication type - custom auth provider
2791
2777
  */
2792
2778
  type: string
2793
- /**
2794
- * Additional auth-specific configuration
2795
- */
2796
2779
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Allows custom auth provider configuration
2797
2780
  [key: string]: any
2798
2781
  }
@@ -2920,15 +2903,17 @@ export type PluginContext = {
2920
2903
 
2921
2904
  /**
2922
2905
  * Add a new list to the config
2923
- * Throws error if list already exists (unless merge strategy used)
2906
+ * Throws error if list already exists
2924
2907
  */
2925
2908
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API must accept any list config
2926
2909
  addList: (name: string, listConfig: ListConfig<any>) => void
2927
2910
 
2928
2911
  /**
2929
- * Extend an existing list with additional fields, hooks, or access control
2930
- * Deep merges fields, hooks, and access control
2931
- * Throws error if list doesn't exist
2912
+ * Extend an existing list with additional fields, hooks, or MCP config.
2913
+ * Merges fields, hooks, and MCP config. Throws if the list doesn't exist,
2914
+ * or if the extension sets operation-level `access` — access control
2915
+ * belongs to whoever created the list, never a plugin extending it
2916
+ * (ADR-0013).
2932
2917
  */
2933
2918
  extendList: (
2934
2919
  name: string,
@@ -3047,10 +3032,6 @@ export type Plugin = {
3047
3032
  }
3048
3033
  }
3049
3034
 
3050
- /**
3051
- * Main configuration type
3052
- * Using interface instead of type to allow module augmentation
3053
- */
3054
3035
  /**
3055
3036
  * Configurable generator output locations.
3056
3037
  *
@@ -3112,6 +3093,10 @@ export interface OutputConfig {
3112
3093
  buildTarget?: 'node'
3113
3094
  }
3114
3095
 
3096
+ /**
3097
+ * Main configuration type.
3098
+ * Uses an interface, not a type alias, so it can be extended via module augmentation.
3099
+ */
3115
3100
  export interface OpenSaasConfig {
3116
3101
  db: DatabaseConfig
3117
3102
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Config must accept any list configuration
@@ -1,63 +1,34 @@
1
1
  import type { FieldConfig } from '../config/types.js'
2
2
 
3
3
  /**
4
- * Apply field `defaultValue`s to omitted inputs on CREATE the runtime half of
5
- * the resolve-then-validate ordering (Keystone 6 parity, issue #615).
4
+ * Fills a field's `defaultValue` into `resolvedData` for CREATE when the field
5
+ * was omitted, run after resolveInput hooks and before validation. Shared by
6
+ * the top-level create path (Hook Pipeline) and the nested-relation create path.
6
7
  *
7
- * A field's `defaultValue` is otherwise only realised as a Prisma `@default(...)`
8
- * applied by the database at write time, which is AFTER the write pipeline's
9
- * validation phase has already run. That ordering means a required-with-default
10
- * field (e.g. `select({ validation: { isRequired: true }, defaultValue: 'X' })`)
11
- * fails `isRequired` validation on an omitted input even though a default exists.
12
- *
13
- * This helper closes that gap: in the resolve phase (after `resolveInput` hooks,
14
- * before validation) it fills `resolvedData[field]` with the field's
15
- * `defaultValue` ONLY when the field was OMITTED (value is `undefined`). It is a
16
- * SINGLE shared mechanism used by both the top-level create path (Hook Pipeline)
17
- * and the nested-relation create path.
18
- *
19
- * Guard rails (each acceptance-criteria-driven):
20
- * - CREATE only. Update never injects defaults for omitted fields (the caller
21
- * only invokes this for `operation === 'create'`).
22
- * - Explicitly-provided values are preserved. A key present in `resolvedData`
23
- * — INCLUDING an explicit `null` — is left untouched; only `undefined`
24
- * (omitted) keys are filled.
25
- * - Virtual, system (`id`/`createdAt`/`updatedAt`) and relationship fields are
26
- * skipped — they have no scalar `defaultValue` to inject and relationships
27
- * carry connect/create payloads rather than literal defaults.
28
- * - The timestamp `{ kind: 'now' }` sentinel is NOT injected: it is not a
29
- * literal value but a request for the DB-level `@default(now())`, which still
30
- * applies at write time. Injecting the sentinel object would corrupt the
31
- * payload. (A concrete `Date` default is a real literal and IS injected.)
32
- *
33
- * The function mutates and returns `resolvedData` (consistent with the other
34
- * resolve-phase helpers that thread `resolvedData` through the pipeline).
8
+ * Prisma only realises `defaultValue` as `@default(...)` at the database write,
9
+ * which is after this pipeline's validation phase so without this, a
10
+ * required field with a default (e.g. `select({ validation: { isRequired: true },
11
+ * defaultValue: 'X' })`) fails `isRequired` on an omitted input. See issue #615.
35
12
  */
36
13
  export function applyCreateDefaults(
37
14
  resolvedData: Record<string, unknown>,
38
15
  fieldConfigs: Record<string, FieldConfig>,
39
16
  ): Record<string, unknown> {
40
17
  for (const [fieldKey, fieldConfig] of Object.entries(fieldConfigs)) {
41
- // Skip virtual fields — not stored in the database.
42
18
  if (fieldConfig.virtual) continue
43
-
44
- // Skip system fields — always managed by the framework/DB.
45
19
  if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt') continue
46
-
47
- // Skip relationships — they carry connect/create payloads, not literal defaults.
20
+ // Relationships carry connect/create payloads, not literal defaults.
48
21
  if (fieldConfig.type === 'relationship') continue
49
-
50
- // No declared default → nothing to inject.
51
22
  if (!('defaultValue' in fieldConfig) || fieldConfig.defaultValue === undefined) continue
52
23
 
53
- // Only fill OMITTED keys. An explicitly-provided value (including explicit
54
- // `null`) is preserved and must not be overwritten by the default.
24
+ // Only fill OMITTED (`undefined`) keys an explicitly-provided value,
25
+ // including explicit `null`, must survive untouched.
55
26
  if (resolvedData[fieldKey] !== undefined) continue
56
27
 
57
28
  const defaultValue = fieldConfig.defaultValue
58
29
 
59
- // The timestamp `{ kind: 'now' }` sentinel is a DB-level `@default(now())`
60
- // request, not a literal leave it for Prisma to apply at write time.
30
+ // `{ kind: 'now' }` requests the DB-level `@default(now())`, not a literal
31
+ // valueinjecting the sentinel object itself would corrupt the payload.
61
32
  if (isNowSentinel(defaultValue)) continue
62
33
 
63
34
  resolvedData[fieldKey] = defaultValue
@@ -66,9 +37,6 @@ export function applyCreateDefaults(
66
37
  return resolvedData
67
38
  }
68
39
 
69
- /**
70
- * Detect the timestamp `{ kind: 'now' }` default sentinel.
71
- */
72
40
  function isNowSentinel(value: unknown): boolean {
73
41
  return (
74
42
  typeof value === 'object' &&
@@ -12,78 +12,33 @@ import {
12
12
  import { applyCreateDefaults } from './apply-defaults.js'
13
13
 
14
14
  /**
15
- * Hook Pipeline the single module that runs the transform+validate span of a
16
- * write: list `resolveInput` field `resolveInput` list `validate` field
17
- * `validate` built-in field rules (`validateFieldRules`). It owns the order of
18
- * these phases and the threading of `resolvedData` through them, in one place.
19
- *
20
- * It is THE place where input is shaped and validated; it throws
21
- * {@link ValidationError} on failure exactly as before (validate hooks via
22
- * `addValidationError`, then `validateFieldRules`) — validation is never silent.
23
- *
24
- * Side-effect hooks (`beforeOperation`/`afterOperation`), operation-level access,
25
- * writable-field filtering, nested operations, persistence and Field Visibility
26
- * are deliberately OUT of this span — they stay in the Write Pipeline. See the
27
- * "Hook Pipeline" and "Write Pipeline" glossary terms in CONTEXT.md.
15
+ * The transform+validate span of a write. See the "Hook Pipeline" and "Write
16
+ * Pipeline" glossary terms in CONTEXT.md for the full phase order and the
17
+ * boundary with the Write Pipeline (which owns side-effect hooks, access,
18
+ * writable-field filtering, nested operations, persistence, Field Visibility).
28
19
  */
29
20
 
30
- /**
31
- * Arguments for one transform+validate span. Only the create/update operations
32
- * run this span (delete skips the input-shaping phases entirely).
33
- */
34
21
  export interface HookPipelineArgs {
35
22
  operation: 'create' | 'update'
36
23
  listName: string
37
24
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
38
25
  listConfig: ListConfig<any>
39
- /** The original input data for the write. */
40
26
  inputData: Record<string, unknown>
41
- /** The existing row for update; `undefined` for create. */
42
27
  item: Record<string, unknown> | undefined
43
28
  context: AccessContext
44
29
  }
45
30
 
46
- /**
47
- * Result of a transform+validate span: the fully-resolved write data after the
48
- * resolveInput hooks have run and all validation has passed.
49
- */
50
31
  export interface HookPipelineResult {
51
32
  resolvedData: Record<string, unknown>
52
33
  }
53
34
 
54
- /**
55
- * The transform+validate span, owning order + `resolvedData` threading.
56
- */
57
35
  export interface HookPipeline {
58
36
  run(args: HookPipelineArgs): Promise<HookPipelineResult>
59
37
  }
60
38
 
61
- /**
62
- * Run the transform+validate span once.
63
- *
64
- * Phase order (owned here, in one place):
65
- * list `resolveInput`
66
- * → field `resolveInput`
67
- * → list `validate`
68
- * → field `validate`
69
- * → built-in field rules (`validateFieldRules`)
70
- * → split multi-column fields (`splitMultiColumnFields`)
71
- *
72
- * Contract preserved exactly:
73
- * - `resolvedData` starts as `inputData` and is threaded through each phase;
74
- * - validate hooks report failures via `addValidationError` → THROW
75
- * `ValidationError` (never silent);
76
- * - built-in field rule failures THROW `ValidationError`;
77
- * - a multi-column field (e.g. storage image()/file() in Keystone-parity
78
- * mode) is validated under its LOGICAL key BEFORE it is split into
79
- * physical columns (#789) — an unrecognised value throws instead of being
80
- * silently split into null/undefined columns;
81
- * - on success returns the transformed `resolvedData`.
82
- */
83
39
  async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResult> {
84
40
  const { operation, listName, listConfig, inputData, item, context } = args
85
41
 
86
- // ── Phase 1: list-level resolveInput ──────────────────────────────────────
87
42
  let resolvedData = await executeResolveInput(
88
43
  listConfig.hooks,
89
44
  operation === 'create'
@@ -105,7 +60,6 @@ async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResu
105
60
  },
106
61
  )
107
62
 
108
- // ── Phase 1.5: field-level resolveInput (e.g. hash passwords) ──────────────
109
63
  resolvedData = await executeFieldResolveInputHooks(
110
64
  inputData,
111
65
  resolvedData,
@@ -116,17 +70,13 @@ async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResu
116
70
  item,
117
71
  )
118
72
 
119
- // ── Phase 1.75: apply field defaults to omitted inputs (CREATE only) ───────
120
- // Resolve-then-validate (Keystone parity, #615): a field declaring a
121
- // `defaultValue` is filled into `resolvedData` here AFTER resolveInput hooks
122
- // and BEFORE validation — but only when the field was OMITTED, so a
123
- // required-with-default field passes `isRequired` instead of failing it.
124
- // Update is untouched (no default injection on update).
73
+ // Must run after resolveInput hooks and before validation, so a
74
+ // required-with-default field passes `isRequired` on an omitted input
75
+ // instead of failing it (see applyCreateDefaults, issue #615).
125
76
  if (operation === 'create') {
126
77
  resolvedData = applyCreateDefaults(resolvedData, listConfig.fields)
127
78
  }
128
79
 
129
- // ── Phase 2: list-level validate ──────────────────────────────────────────
130
80
  await executeValidate(
131
81
  listConfig.hooks,
132
82
  operation === 'create'
@@ -148,7 +98,6 @@ async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResu
148
98
  },
149
99
  )
150
100
 
151
- // ── Phase 2.5: field-level validate ───────────────────────────────────────
152
101
  await executeFieldValidateHooks(
153
102
  inputData,
154
103
  resolvedData,
@@ -159,17 +108,16 @@ async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResu
159
108
  item,
160
109
  )
161
110
 
162
- // ── Phase 3: built-in field rules (isRequired, length, etc.) ──────────────
163
- // Validation failures THROW (validation is not silent).
111
+ // Unlike an access-denied `context.db` operation, a validation failure
112
+ // throws rather than returning null/[] — validation is never silent.
164
113
  const validation = validateFieldRules(resolvedData, listConfig.fields, operation)
165
114
  if (validation.errors.length > 0) {
166
115
  throw new ValidationError(validation.errors, validation.fieldErrors)
167
116
  }
168
117
 
169
- // ── Phase 4: split multi-column fields into physical columns ──────────────
170
- // Only reached once the logical value has passed validation (#789). Replaces
171
- // each multi-column field's logical key with its per-part columns, gated by
172
- // the field's own write access (see `splitMultiColumnFields`).
118
+ // Runs only once the logical value has passed validation above (#789) — a
119
+ // multi-column field validated after splitting could see an unrecognised
120
+ // value silently become null/undefined columns instead of throwing.
173
121
  resolvedData = await splitMultiColumnFields(
174
122
  inputData,
175
123
  resolvedData,
@@ -182,9 +130,6 @@ async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResu
182
130
  return { resolvedData }
183
131
  }
184
132
 
185
- /**
186
- * The default Hook Pipeline instance used by the Write Pipeline.
187
- */
188
133
  export const hookPipeline: HookPipeline = {
189
134
  run: runHookPipeline,
190
135
  }