@opensaas/stack-core 0.38.0 → 0.39.1

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 +125 -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
@@ -3,18 +3,11 @@ import { hashPassword, isHashedPassword, HashedPassword } from '../utils/passwor
3
3
  import { formatPrismaDefault } from './format-prisma-default.js';
4
4
  import { getLabelFieldName } from '../config/label.js';
5
5
  import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js';
6
- /**
7
- * Operators shared by numeric/date fields: plain equality plus the four
8
- * comparisons. `eq` maps to Prisma's `equals`; the comparisons pass through.
9
- */
6
+ /** Operators shared by numeric/date fields' `getFilterSpec`. */
10
7
  const COMPARISON_OPERATORS = ['eq', 'gt', 'gte', 'lt', 'lte'];
11
- /** Map a Filter operator to its Prisma condition key (`eq` → `equals`). */
12
8
  function prismaComparisonKey(operator) {
13
9
  return operator === 'eq' ? 'equals' : operator;
14
10
  }
15
- /**
16
- * Format field name for display in error messages
17
- */
18
11
  function formatFieldName(fieldName) {
19
12
  return fieldName
20
13
  .replace(/([A-Z])/g, ' $1')
@@ -59,11 +52,9 @@ export function text(options) {
59
52
  const isRequired = validation?.isRequired;
60
53
  const isNullable = db?.isNullable ?? !isRequired;
61
54
  let modifiers = '';
62
- // Optional modifier
63
55
  if (isNullable) {
64
56
  modifiers += '?';
65
57
  }
66
- // Native type modifier (e.g., @db.Text)
67
58
  if (db?.nativeType) {
68
59
  modifiers += ` @db.${db.nativeType}`;
69
60
  }
@@ -88,7 +79,6 @@ export function text(options) {
88
79
  if (options?.isIndexed === 'unique') {
89
80
  modifiers += ' @unique';
90
81
  }
91
- // Map modifier
92
82
  if (db?.map) {
93
83
  modifiers += ` @map("${db.map}")`;
94
84
  }
@@ -106,9 +96,6 @@ export function text(options) {
106
96
  optional: !isRequired,
107
97
  };
108
98
  },
109
- // Text fields drive free-text search: a bare word (or a `field:value`) maps
110
- // to a case-preserving `contains`. This is what replaces the old hard-coded
111
- // `type === 'text'` search in the admin list view.
112
99
  getFilterSpec: (fieldName) => ({
113
100
  operators: ['eq'],
114
101
  freeText: true,
@@ -148,21 +135,16 @@ export function integer(options) {
148
135
  const isRequired = validation?.isRequired;
149
136
  const isNullable = db?.isNullable ?? !isRequired;
150
137
  let modifiers = '';
151
- // Optional modifier
152
138
  if (isNullable) {
153
139
  modifiers += '?';
154
140
  }
155
- // Native type modifier (e.g., @db.SmallInt, @db.BigInt)
156
141
  if (db?.nativeType) {
157
142
  modifiers += ` @db.${db.nativeType}`;
158
143
  }
159
- // Default value if provided (bare numeric literal). Independent of the
160
- // nullable `?` modifier above — the default never overwrites nullability.
161
144
  const defaultLiteral = formatPrismaDefault(options?.defaultValue, 'integer');
162
145
  if (defaultLiteral !== undefined) {
163
146
  modifiers += ` @default(${defaultLiteral})`;
164
147
  }
165
- // Map modifier
166
148
  if (db?.map) {
167
149
  modifiers += ` @map("${db.map}")`;
168
150
  }
@@ -178,8 +160,7 @@ export function integer(options) {
178
160
  optional: !isRequired,
179
161
  };
180
162
  },
181
- // Integers support equality and comparisons (`orders:>5`). A non-integer
182
- // value can't be interpreted, so its token degrades to free text.
163
+ // A non-integer token can't be interpreted, so it degrades to free text.
183
164
  getFilterSpec: (fieldName) => ({
184
165
  operators: COMPARISON_OPERATORS,
185
166
  toCondition: (operator, value) => {
@@ -244,8 +225,6 @@ export function decimal(options) {
244
225
  scale,
245
226
  ...options,
246
227
  getZodSchema: (fieldName, operation) => {
247
- // Decimal values can be provided as strings or numbers
248
- // Prisma will convert them to Decimal instances
249
228
  const baseSchema = z.union([
250
229
  z.string({
251
230
  message: `${formatFieldName(fieldName)} must be a decimal value (string or number)`,
@@ -257,7 +236,6 @@ export function decimal(options) {
257
236
  message: `${formatFieldName(fieldName)} must be a decimal value`,
258
237
  });
259
238
  let schema = baseSchema;
260
- // Add min validation if specified
261
239
  if (options?.validation?.min !== undefined) {
262
240
  const minValue = parseFloat(options.validation.min);
263
241
  schema = schema.refine((val) => {
@@ -267,7 +245,6 @@ export function decimal(options) {
267
245
  message: `${formatFieldName(fieldName)} must be at least ${options.validation.min}`,
268
246
  });
269
247
  }
270
- // Add max validation if specified
271
248
  if (options?.validation?.max !== undefined) {
272
249
  const maxValue = parseFloat(options.validation.max);
273
250
  schema = schema.refine((val) => {
@@ -287,23 +264,18 @@ export function decimal(options) {
287
264
  const isRequired = validation?.isRequired;
288
265
  const isNullable = db?.isNullable ?? !isRequired;
289
266
  let modifiers = '';
290
- // Optional modifier
291
267
  if (isNullable) {
292
268
  modifiers += '?';
293
269
  }
294
- // Precision and scale
295
270
  modifiers += ` @db.Decimal(${precision}, ${scale})`;
296
- // Default value if provided
297
271
  if (options?.defaultValue !== undefined) {
298
272
  modifiers += ` @default(${options.defaultValue})`;
299
273
  }
300
- // Database mapping
301
274
  if (db?.map) {
302
275
  modifiers += ` @map("${db.map}")`;
303
276
  }
304
- // Unique modifier. A non-unique index has no field-level form in Prisma,
305
- // so it is requested out-of-line via `index` below and emitted by the
306
- // generator as `@@index([...])` on the model.
277
+ // Unique modifier non-unique index routes through `index` below,
278
+ // same as `text()`'s getPrismaType.
307
279
  if (options?.isIndexed === 'unique') {
308
280
  modifiers += ' @unique';
309
281
  }
@@ -346,6 +318,116 @@ export function decimal(options) {
346
318
  }),
347
319
  };
348
320
  }
321
+ /**
322
+ * 64-bit integer field for values that overflow `integer()`'s 32-bit `Int`
323
+ * (e.g. a millisecond epoch). Prisma `BigInt`, TypeScript `bigint`.
324
+ *
325
+ * Accepts `bigint`, an integer `number`, or a numeric `string` on write and
326
+ * always coerces to `bigint`. A `number` above `Number.MAX_SAFE_INTEGER` is
327
+ * rejected rather than coerced — it has already lost precision before this
328
+ * field sees it, so accepting it would reintroduce the exact defect this
329
+ * field exists to prevent. Pass a `bigint` or a string for values beyond that
330
+ * range.
331
+ *
332
+ * Wire representation (ADR-0029): `bigint` in application code, a decimal
333
+ * string over MCP.
334
+ */
335
+ export function bigInt(options) {
336
+ return {
337
+ type: 'bigInt',
338
+ ...options,
339
+ getZodSchema: (fieldName, operation) => {
340
+ const validation = options?.validation;
341
+ const isRequired = validation?.isRequired;
342
+ const coerced = z
343
+ .union([z.bigint(), z.number(), z.string()], {
344
+ message: `${formatFieldName(fieldName)} must be a bigint, an integer number, or a numeric string`,
345
+ })
346
+ .transform((val, ctx) => {
347
+ if (typeof val === 'bigint')
348
+ return val;
349
+ if (typeof val === 'number') {
350
+ if (!Number.isInteger(val)) {
351
+ ctx.addIssue(`${formatFieldName(fieldName)} must be an integer`);
352
+ return z.NEVER;
353
+ }
354
+ if (!Number.isSafeInteger(val)) {
355
+ ctx.addIssue(`${formatFieldName(fieldName)} exceeds Number.MAX_SAFE_INTEGER — pass a bigint or a numeric string instead`);
356
+ return z.NEVER;
357
+ }
358
+ return BigInt(val);
359
+ }
360
+ const trimmed = val.trim();
361
+ if (!/^-?\d+$/.test(trimmed)) {
362
+ ctx.addIssue(`${formatFieldName(fieldName)} must be an integer`);
363
+ return z.NEVER;
364
+ }
365
+ return BigInt(trimmed);
366
+ });
367
+ let schema = coerced;
368
+ if (validation?.min !== undefined) {
369
+ const min = validation.min;
370
+ schema = schema.refine((val) => val >= min, {
371
+ message: `${formatFieldName(fieldName)} must be at least ${min}`,
372
+ });
373
+ }
374
+ if (validation?.max !== undefined) {
375
+ const max = validation.max;
376
+ schema = schema.refine((val) => val <= max, {
377
+ message: `${formatFieldName(fieldName)} must be at most ${max}`,
378
+ });
379
+ }
380
+ return !isRequired || operation === 'update' ? schema.optional().nullable() : schema;
381
+ },
382
+ getPrismaType: (_fieldName) => {
383
+ const validation = options?.validation;
384
+ const db = options?.db;
385
+ const isRequired = validation?.isRequired;
386
+ const isNullable = db?.isNullable ?? !isRequired;
387
+ let modifiers = '';
388
+ if (isNullable) {
389
+ modifiers += '?';
390
+ }
391
+ if (db?.nativeType) {
392
+ modifiers += ` @db.${db.nativeType}`;
393
+ }
394
+ if (options?.defaultValue !== undefined) {
395
+ modifiers += ` @default(${options.defaultValue})`;
396
+ }
397
+ if (db?.map) {
398
+ modifiers += ` @map("${db.map}")`;
399
+ }
400
+ // Unique modifier — non-unique index routes through `index` below,
401
+ // same as `text()`'s getPrismaType.
402
+ if (options?.isIndexed === 'unique') {
403
+ modifiers += ' @unique';
404
+ }
405
+ return {
406
+ type: 'BigInt',
407
+ modifiers: modifiers.trimStart() || undefined,
408
+ index: options?.isIndexed === true ? true : undefined,
409
+ };
410
+ },
411
+ getTypeScriptType: () => {
412
+ const isRequired = options?.validation?.isRequired;
413
+ return {
414
+ type: 'bigint',
415
+ optional: !isRequired,
416
+ };
417
+ },
418
+ // A non-integer token degrades to free text.
419
+ getFilterSpec: (fieldName) => ({
420
+ operators: COMPARISON_OPERATORS,
421
+ toCondition: (operator, value) => {
422
+ const trimmed = value.trim();
423
+ if (!/^-?\d+$/.test(trimmed))
424
+ return null;
425
+ return { [fieldName]: { [prismaComparisonKey(operator)]: BigInt(trimmed) } };
426
+ },
427
+ suggestions: { valueSource: { kind: 'none' } },
428
+ }),
429
+ };
430
+ }
349
431
  /**
350
432
  * Checkbox (boolean) field
351
433
  */
@@ -360,15 +442,15 @@ export function checkbox(options) {
360
442
  const db = options?.db;
361
443
  const hasDefault = options?.defaultValue !== undefined;
362
444
  let modifiers = '';
363
- // Nullable modifier - checkbox fields are non-nullable by default (must be true or false)
364
- // Use db.isNullable: true to allow NULL values in the database
445
+ // Checkboxes are non-nullable by default (must be true or false), unlike
446
+ // the other scalar fields' nullable-unless-required default set
447
+ // db.isNullable: true to allow NULL.
365
448
  if (db?.isNullable === true) {
366
449
  modifiers += '?';
367
450
  }
368
451
  if (hasDefault) {
369
452
  modifiers += ` @default(${options.defaultValue})`;
370
453
  }
371
- // Map modifier
372
454
  if (db?.map) {
373
455
  modifiers += ` @map("${db.map}")`;
374
456
  }
@@ -383,8 +465,7 @@ export function checkbox(options) {
383
465
  optional: options?.defaultValue === undefined,
384
466
  };
385
467
  },
386
- // Checkboxes filter by equality against the two enumerated values. Anything
387
- // other than true/false can't be interpreted and degrades to free text.
468
+ // Anything other than true/false degrades to free text.
388
469
  getFilterSpec: (fieldName) => ({
389
470
  operators: ['eq'],
390
471
  toCondition: (operator, value) => {
@@ -425,22 +506,17 @@ export function timestamp(options) {
425
506
  typeof options.defaultValue === 'object' &&
426
507
  'kind' in options.defaultValue &&
427
508
  options.defaultValue.kind === 'now';
428
- // Nullability: explicit db.isNullable overrides the default (nullable unless @default(now()))
429
509
  const isNullable = db?.isNullable ?? !hasDefaultNow;
430
510
  let modifiers = '';
431
- // Optional modifier
432
511
  if (isNullable) {
433
512
  modifiers += '?';
434
513
  }
435
- // Default value
436
514
  if (hasDefaultNow) {
437
515
  modifiers += ' @default(now())';
438
516
  }
439
- // Native type modifier (e.g., @db.Timestamptz for PostgreSQL)
440
517
  if (db?.nativeType) {
441
518
  modifiers += ` @db.${db.nativeType}`;
442
519
  }
443
- // Map modifier
444
520
  if (db?.map) {
445
521
  modifiers += ` @map("${db.map}")`;
446
522
  }
@@ -459,8 +535,7 @@ export function timestamp(options) {
459
535
  optional: !hasDefault,
460
536
  };
461
537
  },
462
- // Timestamps support equality and comparisons (`joined:>2024-01-01`). An
463
- // unparseable date degrades to free text.
538
+ // An unparseable date degrades to free text.
464
539
  getFilterSpec: (fieldName) => ({
465
540
  operators: COMPARISON_OPERATORS,
466
541
  toCondition: (operator, value) => {
@@ -537,23 +612,11 @@ export function calendarDay(options) {
537
612
  return {
538
613
  type: 'calendarDay',
539
614
  ...options,
540
- // Writes: the write pipeline runs field resolveInput BEFORE zod
541
- // validation (Hook Pipeline: field resolveInput built-in field rules),
542
- // so this is the only point a YYYY-MM-DD string can be turned into
543
- // something Prisma's `@db.Date` write validator accepts Prisma 7
544
- // rejects a bare date string there (#621). Convert a valid string to a
545
- // UTC-midnight Date; leave anything else (a Date already, null/undefined,
546
- // or a malformed string) untouched so the zod schema below still rejects
547
- // malformed input with a clear message. Reads resolvedData[fieldKey]
548
- // (not raw inputData) so a list-level resolveInput that injects a default
549
- // for an omitted key is still coerced instead of being overwritten.
550
- //
551
- // Reads: the underlying @db.Date column hands Prisma a Date (or a TEXT
552
- // string under the SQLite fallback). Normalise to a YYYY-MM-DD string so the
553
- // runtime value matches the declared `string` type. UTC components are used
554
- // so the formatting never drifts a day in non-UTC timezones.
555
- // Cast hooks to any since field builders are generic and can't know the
556
- // specific TFieldKey (same pattern as password()).
615
+ // Hook Pipeline runs field resolveInput before zod validation — the only
616
+ // point a YYYY-MM-DD string can be turned into what Prisma's `@db.Date`
617
+ // write validator accepts (#621). Reads resolvedData[fieldKey], not raw
618
+ // inputData, so a list-level resolveInput's injected default for an
619
+ // omitted key is still coerced rather than overwritten.
557
620
  hooks: {
558
621
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
559
622
  resolveInput: ({ resolvedData, fieldKey }) => {
@@ -567,17 +630,15 @@ export function calendarDay(options) {
567
630
  },
568
631
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
569
632
  resolveOutput: ({ value }) => formatCalendarDay(value),
570
- // Merge with user-provided hooks if any
571
633
  ...options?.hooks,
572
634
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hook object needs type assertion for field builder
573
635
  },
574
636
  getZodSchema: (fieldName, operation) => {
575
637
  const validation = options?.validation;
576
638
  const isRequired = validation?.isRequired;
577
- // Accept ISO8601 date strings (YYYY-MM-DD) in the shape a caller passes,
578
- // or a `Date` the shape resolveInput above turns a valid string into
579
- // before this schema runs. Malformed strings fall through resolveInput
580
- // untouched and still fail the regex here with a clear message.
639
+ // Accepts a `Date` because resolveInput above already converted a valid
640
+ // string to one before this schema runs; a malformed string falls
641
+ // through resolveInput untouched and fails the regex here instead.
581
642
  const stringSchema = z
582
643
  .string({
583
644
  message: `${formatFieldName(fieldName)} must be a valid date in ISO8601 format (YYYY-MM-DD)`,
@@ -590,7 +651,6 @@ export function calendarDay(options) {
590
651
  return dateSchema;
591
652
  }
592
653
  else if (isRequired && operation === 'update') {
593
- // Required in update mode: omitted keys pass; present values must be valid
594
654
  return dateSchema.optional();
595
655
  }
596
656
  else {
@@ -603,27 +663,22 @@ export function calendarDay(options) {
603
663
  const isRequired = validation?.isRequired;
604
664
  const isNullable = db?.isNullable ?? !isRequired;
605
665
  let modifiers = '';
606
- // Optional modifier
607
666
  if (isNullable) {
608
667
  modifiers += '?';
609
668
  }
610
- // Add @db.Date attribute for date-only storage
611
- // Only for PostgreSQL/MySQL - SQLite doesn't support native DATE type
612
- // SQLite will use TEXT for DateTime fields
669
+ // SQLite has no native DATE type and falls back to TEXT for DateTime
670
+ // columns, so @db.Date only applies on PostgreSQL/MySQL.
613
671
  if (provider && provider.toLowerCase() !== 'sqlite') {
614
672
  modifiers += ' @db.Date';
615
673
  }
616
- // Default value if provided
617
674
  if (options?.defaultValue !== undefined) {
618
675
  modifiers += ` @default("${options.defaultValue}")`;
619
676
  }
620
- // Database mapping
621
677
  if (db?.map) {
622
678
  modifiers += ` @map("${db.map}")`;
623
679
  }
624
- // Unique modifier. A non-unique index has no field-level form in Prisma,
625
- // so it is requested out-of-line via `index` below and emitted by the
626
- // generator as `@@index([...])` on the model.
680
+ // Unique modifier non-unique index routes through `index` below,
681
+ // same as `text()`'s getPrismaType.
627
682
  if (options?.isIndexed === 'unique') {
628
683
  modifiers += ' @unique';
629
684
  }
@@ -638,12 +693,6 @@ export function calendarDay(options) {
638
693
  const db = options?.db;
639
694
  const isRequired = validation?.isRequired;
640
695
  const isNullable = db?.isNullable ?? !isRequired;
641
- // calendarDay is a YYYY-MM-DD string end-to-end (Keystone's CalendarDay
642
- // scalar). Returning 'string' here makes the entity/read type and the
643
- // standalone generated CreateInput/UpdateInput types `string`. At the
644
- // context.db write path a Date is still rejected at runtime by validation
645
- // (the generated db method `data` type derives from Prisma's `Date | string`
646
- // input — making it a compile-time error is tracked in #599).
647
696
  return {
648
697
  type: 'string',
649
698
  optional: isNullable,
@@ -749,43 +798,34 @@ export function password(options) {
749
798
  ...options,
750
799
  resultExtension: {
751
800
  outputType: "import('@opensaas/stack-core/internal').HashedPassword",
752
- // No compute - delegates to resolveOutput hook
753
801
  },
754
802
  ui: {
755
803
  ...options?.ui,
756
804
  valueForClientSerialization: ({ value }) => ({ isSet: !!value }),
757
805
  },
758
- // Cast hooks to any since field builders are generic and can't know the specific TFieldKey
759
806
  hooks: {
760
- // Hash password before writing to database
761
807
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
762
808
  resolveInput: async ({ inputData, fieldKey }) => {
763
- // Skip if undefined or null (allows partial updates)
764
809
  const inputValue = inputData[fieldKey];
765
810
  if (inputValue === undefined || inputValue === null) {
766
811
  return inputValue;
767
812
  }
768
- // Skip if not a string
769
813
  if (typeof inputValue !== 'string' || inputValue.length === 0) {
770
814
  return inputValue;
771
815
  }
772
- // Skip if already hashed (idempotent)
816
+ // Idempotent: skip re-hashing a value that's already a hash.
773
817
  if (isHashedPassword(inputValue)) {
774
818
  return inputValue;
775
819
  }
776
- // Hash the password
777
820
  return (await hashPassword(inputValue)).toString();
778
821
  },
779
- // Wrap password with HashedPassword class after reading from database
780
822
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
781
823
  resolveOutput: ({ value }) => {
782
- // Only wrap string values (hashed passwords)
783
824
  if (typeof value === 'string' && value.length > 0) {
784
825
  return new HashedPassword(value);
785
826
  }
786
827
  return undefined;
787
828
  },
788
- // Merge with user-provided hooks if any
789
829
  ...options?.hooks,
790
830
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hook object needs type assertion for field builder
791
831
  },
@@ -793,7 +833,6 @@ export function password(options) {
793
833
  const validation = options?.validation;
794
834
  const isRequired = validation?.isRequired;
795
835
  if (isRequired && operation === 'create') {
796
- // Required in create mode: reject undefined and empty strings
797
836
  return z
798
837
  .string({
799
838
  message: `${formatFieldName(fieldName)} must be text`,
@@ -803,7 +842,6 @@ export function password(options) {
803
842
  });
804
843
  }
805
844
  else if (isRequired && operation === 'update') {
806
- // Required in update mode: omitted keys pass; if provided, reject empty strings
807
845
  return z
808
846
  .string()
809
847
  .min(1, {
@@ -812,7 +850,6 @@ export function password(options) {
812
850
  .optional();
813
851
  }
814
852
  else {
815
- // Not required: can be undefined or any string
816
853
  return z
817
854
  .string({
818
855
  message: `${formatFieldName(fieldName)} must be text`,
@@ -827,15 +864,12 @@ export function password(options) {
827
864
  const isRequired = validation?.isRequired;
828
865
  const isNullable = db?.isNullable ?? !isRequired;
829
866
  let modifiers = '';
830
- // Optional modifier
831
867
  if (isNullable) {
832
868
  modifiers += '?';
833
869
  }
834
- // Native type modifier (e.g., @db.Text)
835
870
  if (db?.nativeType) {
836
871
  modifiers += ` @db.${db.nativeType}`;
837
872
  }
838
- // Map modifier
839
873
  if (db?.map) {
840
874
  modifiers += ` @map("${db.map}")`;
841
875
  }
@@ -900,7 +934,6 @@ export function select(options) {
900
934
  // (mirroring text/integer) so the default never overwrites the `?`.
901
935
  const isNullable = options.db?.isNullable ?? (!isRequired && !hasDefault);
902
936
  let modifiers = '';
903
- // Optional modifier
904
937
  if (isNullable) {
905
938
  modifiers += '?';
906
939
  }
@@ -911,11 +944,10 @@ export function select(options) {
911
944
  const capitalizedField = fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
912
945
  const derivedEnumName = listName ? `${listName}${capitalizedField}` : capitalizedField;
913
946
  const enumName = options.db?.enumName ?? derivedEnumName;
914
- // Add default value if provided (no quotes for enum values)
947
+ // No quotes for enum default values (unlike the string branch below).
915
948
  if (hasDefault) {
916
949
  modifiers += ` @default(${options.defaultValue})`;
917
950
  }
918
- // Map modifier
919
951
  if (options.db?.map) {
920
952
  modifiers += ` @map("${options.db.map}")`;
921
953
  }
@@ -926,11 +958,9 @@ export function select(options) {
926
958
  };
927
959
  }
928
960
  // String type (default)
929
- // Add default value if provided
930
961
  if (hasDefault) {
931
962
  modifiers += ` @default("${options.defaultValue}")`;
932
963
  }
933
- // Map modifier
934
964
  if (options.db?.map) {
935
965
  modifiers += ` @map("${options.db.map}")`;
936
966
  }
@@ -995,9 +1025,6 @@ function parseRelationshipRef(ref) {
995
1025
  throw new Error(`Invalid relationship ref: ${ref}`);
996
1026
  }
997
1027
  }
998
- /**
999
- * Check if a relationship is one-to-one (bidirectional with both sides having many: false).
1000
- */
1001
1028
  function isOneToOneRelationship(fieldName, field, config) {
1002
1029
  const { list: targetList, field: targetField } = parseRelationshipRef(field.ref);
1003
1030
  if (!targetField) {
@@ -1019,10 +1046,6 @@ function isOneToOneRelationship(fieldName, field, config) {
1019
1046
  }
1020
1047
  return !targetFieldConfig.many;
1021
1048
  }
1022
- /**
1023
- * Determine if this side of a relationship should store the foreign key.
1024
- * For one-to-one relationships, only one side stores the foreign key.
1025
- */
1026
1049
  function shouldHaveForeignKey(listKey, fieldName, field, config) {
1027
1050
  const { list: targetList, field: targetField } = parseRelationshipRef(field.ref);
1028
1051
  if (!targetField) {
@@ -1056,9 +1079,6 @@ function shouldHaveForeignKey(listKey, fieldName, field, config) {
1056
1079
  // Self-referential: use field name ordering
1057
1080
  return fieldName.localeCompare(targetField) < 0;
1058
1081
  }
1059
- /**
1060
- * Check whether a many relationship is a true many-to-many (both sides many).
1061
- */
1062
1082
  function isManyToMany(fieldName, field, config) {
1063
1083
  if (!field.many) {
1064
1084
  return false;
@@ -1111,12 +1131,8 @@ function computeManyToManyRelationName(listKey, fieldName, field, config) {
1111
1131
  }
1112
1132
  return `${listKey}_${fieldName}`;
1113
1133
  }
1114
- // Default Prisma naming - no explicit relation name needed
1115
1134
  return undefined;
1116
1135
  }
1117
- /**
1118
- * Build the Prisma schema contribution for a relationship field.
1119
- */
1120
1136
  function getPrismaRelation(field, fieldName, listKey, config) {
1121
1137
  const { list: targetList, field: targetField } = parseRelationshipRef(field.ref);
1122
1138
  const paddedName = fieldName.padEnd(12);
@@ -1203,27 +1219,20 @@ export function relationship(options) {
1203
1219
  if (!options.ref) {
1204
1220
  throw new Error('Relationship field must have a ref');
1205
1221
  }
1206
- // Validate ref format: 'ListName.fieldName' or 'ListName'
1207
1222
  const refParts = options.ref.split('.');
1208
1223
  if (refParts.length !== 1 && refParts.length !== 2) {
1209
1224
  throw new Error(`Invalid relationship ref format: "${options.ref}". Expected format: "ListName.fieldName" or "ListName"`);
1210
1225
  }
1211
- // Validate db.foreignKey usage
1212
1226
  if (options.db?.foreignKey !== undefined) {
1213
- // Can only be used on single relationships (not many)
1214
1227
  if (options.many) {
1215
1228
  throw new Error('db.foreignKey can only be used on single relationships (many: false or undefined). ' +
1216
1229
  'Many-side of a relationship never stores the foreign key.');
1217
1230
  }
1218
- // Can only be used on bidirectional relationships (with target field)
1219
1231
  if (refParts.length === 1) {
1220
1232
  throw new Error('db.foreignKey can only be used on bidirectional relationships (ref: "ListName.fieldName"). ' +
1221
1233
  'List-only refs (ref: "ListName") always create foreign keys automatically.');
1222
1234
  }
1223
1235
  }
1224
- // Validate db.isNullable usage: only the FK-owning (single) side of a
1225
- // relationship has a column to make non-nullable — the many side always
1226
- // generates an array field with no nullability of its own.
1227
1236
  if (options.db?.isNullable !== undefined && options.many) {
1228
1237
  throw new Error('db.isNullable can only be used on single relationships (many: false or undefined). ' +
1229
1238
  'Many-side of a relationship has no foreign key column to make non-nullable.');
@@ -1333,7 +1342,6 @@ export function json(options) {
1333
1342
  getZodSchema: (fieldName, operation) => {
1334
1343
  const validation = options?.validation;
1335
1344
  const isRequired = validation?.isRequired;
1336
- // Accept any valid JSON value
1337
1345
  const baseSchema = z.unknown();
1338
1346
  if (isRequired && operation === 'create') {
1339
1347
  // Required in create mode: a value must be provided and it must be
@@ -1360,7 +1368,6 @@ export function json(options) {
1360
1368
  .optional();
1361
1369
  }
1362
1370
  else {
1363
- // Not required: can be undefined or null
1364
1371
  return baseSchema.optional().nullable();
1365
1372
  }
1366
1373
  },
@@ -1370,22 +1377,16 @@ export function json(options) {
1370
1377
  const isRequired = validation?.isRequired;
1371
1378
  const isNullable = db?.isNullable ?? !isRequired;
1372
1379
  let modifiers = '';
1373
- // Optional modifier
1374
1380
  if (isNullable) {
1375
1381
  modifiers += '?';
1376
1382
  }
1377
- // Native type modifier
1378
1383
  if (db?.nativeType) {
1379
1384
  modifiers += ` @db.${db.nativeType}`;
1380
1385
  }
1381
- // Default value if provided. Uses Keystone's JSON-literal form: canonical
1382
- // (space-free) JSON wrapped in escaped double quotes. Independent of the
1383
- // nullable `?` modifier above — the default never overwrites nullability.
1384
1386
  const defaultLiteral = formatPrismaDefault(options?.defaultValue, 'json');
1385
1387
  if (defaultLiteral !== undefined) {
1386
1388
  modifiers += ` @default(${defaultLiteral})`;
1387
1389
  }
1388
- // Map modifier
1389
1390
  if (db?.map) {
1390
1391
  modifiers += ` @map("${db.map}")`;
1391
1392
  }
@@ -1414,17 +1415,10 @@ function typeDescriptorToString(descriptor) {
1414
1415
  if (typeof descriptor === 'string') {
1415
1416
  return descriptor;
1416
1417
  }
1417
- // Extract type name from constructor or use provided name
1418
1418
  const typeName = descriptor.name || descriptor.value.name;
1419
- // Generate import string
1420
1419
  return `import('${descriptor.from}').${typeName}`;
1421
1420
  }
1422
- /**
1423
- * Extract TypeScript imports from a TypeDescriptor
1424
- * Returns array of import statements needed for type generation
1425
- */
1426
1421
  function typeDescriptorToImports(descriptor) {
1427
- // If it's a string, check if it's an import string
1428
1422
  if (typeof descriptor === 'string') {
1429
1423
  const importMatch = descriptor.match(/import\('([^']+)'\)\.(\w+)/);
1430
1424
  if (importMatch) {
@@ -1519,12 +1513,10 @@ function typeDescriptorToImports(descriptor) {
1519
1513
  * @returns Virtual field configuration
1520
1514
  */
1521
1515
  export function virtual(options) {
1522
- // Validate that resolveOutput is provided
1523
1516
  if (!options.hooks?.resolveOutput) {
1524
1517
  throw new Error('Virtual fields must provide a resolveOutput hook to compute their value. ' +
1525
1518
  'Example: hooks: { resolveOutput: ({ item }) => computeValue(item) }');
1526
1519
  }
1527
- // Convert type descriptor to string
1528
1520
  const outputType = typeDescriptorToString(options.type);
1529
1521
  const imports = typeDescriptorToImports(options.type);
1530
1522
  const { type: _, ...rest } = options;
@@ -1533,19 +1525,16 @@ export function virtual(options) {
1533
1525
  virtual: true,
1534
1526
  outputType,
1535
1527
  ...rest,
1536
- // Virtual fields don't create database columns
1537
- // Return undefined to signal generator to skip this field
1528
+ // undefined signals the generator to skip creating a database column.
1538
1529
  getPrismaType: undefined,
1539
- // Virtual fields appear in output types with their specified type
1540
1530
  getTypeScriptType: () => {
1541
1531
  return {
1542
1532
  type: outputType,
1543
- optional: false, // Virtual fields always compute a value
1533
+ optional: false, // A virtual field always computes a value.
1544
1534
  };
1545
1535
  },
1546
- // Add import statements if needed
1547
1536
  getTypeScriptImports: imports.length > 0 ? () => imports : undefined,
1548
- // Virtual fields never validate input (they don't accept database input)
1537
+ // Virtual fields don't accept database input, so validation always fails.
1549
1538
  getZodSchema: () => {
1550
1539
  return z.never();
1551
1540
  },