@opensaas/stack-core 0.23.0 → 0.25.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 (77) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +256 -0
  3. package/dist/access/access-filter.d.ts +39 -0
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +121 -0
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/field-access.d.ts +1 -0
  8. package/dist/access/field-access.d.ts.map +1 -1
  9. package/dist/access/field-access.js +79 -4
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/field-access.test.js +213 -0
  12. package/dist/access/field-access.test.js.map +1 -1
  13. package/dist/access/index.d.ts +1 -1
  14. package/dist/access/index.d.ts.map +1 -1
  15. package/dist/access/index.js +1 -1
  16. package/dist/access/index.js.map +1 -1
  17. package/dist/access/types.d.ts +39 -0
  18. package/dist/access/types.d.ts.map +1 -1
  19. package/dist/config/index.d.ts +1 -1
  20. package/dist/config/index.d.ts.map +1 -1
  21. package/dist/config/types.d.ts +378 -0
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/context/index.d.ts +19 -1
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +153 -26
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/context/nested-operations.d.ts +59 -3
  28. package/dist/context/nested-operations.d.ts.map +1 -1
  29. package/dist/context/nested-operations.js +552 -129
  30. package/dist/context/nested-operations.js.map +1 -1
  31. package/dist/context/transaction-boundary.d.ts +91 -0
  32. package/dist/context/transaction-boundary.d.ts.map +1 -0
  33. package/dist/context/transaction-boundary.js +329 -0
  34. package/dist/context/transaction-boundary.js.map +1 -0
  35. package/dist/context/write-pipeline.d.ts +15 -1
  36. package/dist/context/write-pipeline.d.ts.map +1 -1
  37. package/dist/context/write-pipeline.js +173 -10
  38. package/dist/context/write-pipeline.js.map +1 -1
  39. package/dist/fields/calendar-day.test.d.ts +2 -0
  40. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  41. package/dist/fields/calendar-day.test.js +120 -0
  42. package/dist/fields/calendar-day.test.js.map +1 -0
  43. package/dist/fields/index.d.ts +18 -2
  44. package/dist/fields/index.d.ts.map +1 -1
  45. package/dist/fields/index.js +93 -17
  46. package/dist/fields/index.js.map +1 -1
  47. package/dist/hooks/index.d.ts +116 -0
  48. package/dist/hooks/index.d.ts.map +1 -1
  49. package/dist/hooks/index.js +154 -0
  50. package/dist/hooks/index.js.map +1 -1
  51. package/dist/validation/schema.test.js +222 -1
  52. package/dist/validation/schema.test.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/access/access-filter.ts +156 -0
  55. package/src/access/field-access.test.ts +255 -0
  56. package/src/access/field-access.ts +91 -5
  57. package/src/access/index.ts +1 -1
  58. package/src/access/types.ts +45 -0
  59. package/src/config/index.ts +2 -0
  60. package/src/config/types.ts +426 -0
  61. package/src/context/index.ts +207 -37
  62. package/src/context/nested-operations.ts +969 -143
  63. package/src/context/transaction-boundary.ts +440 -0
  64. package/src/context/write-pipeline.ts +234 -13
  65. package/src/fields/calendar-day.test.ts +140 -0
  66. package/src/fields/index.ts +96 -16
  67. package/src/hooks/index.ts +265 -0
  68. package/src/validation/schema.test.ts +266 -1
  69. package/tests/access.test.ts +24 -16
  70. package/tests/config.test.ts +30 -0
  71. package/tests/context.test.ts +481 -0
  72. package/tests/field-types.test.ts +17 -3
  73. package/tests/nested-access-and-hooks.test.ts +1130 -54
  74. package/tests/nested-operation-registry.test.ts +28 -3
  75. package/tests/nested-write-hooks.test.ts +864 -0
  76. package/tests/transaction-boundary-hooks.test.ts +465 -0
  77. package/tsconfig.tsbuildinfo +1 -1
@@ -16,8 +16,15 @@ import {
16
16
  ValidationError,
17
17
  } from '../hooks/index.js'
18
18
  import { hookPipeline } from './hook-pipeline.js'
19
- import { processNestedOperations } from './nested-operations.js'
19
+ import { processNestedOperations, runAfterTasks } from './nested-operations.js'
20
+ import type { AfterTask } from './nested-operations.js'
21
+ import { enumerateInvolvedLists, runWithTransactionBoundary } from './transaction-boundary.js'
20
22
  import { getDbKey } from '../lib/case-utils.js'
23
+ // NOTE: `index.ts` imports from this module too — this is an intentional cyclic
24
+ // dependency. It is safe because `buildDbDelegate` is only INVOKED at write
25
+ // time (never during module evaluation), so by the time it runs the export is
26
+ // fully initialised.
27
+ import { buildDbDelegate } from './index.js'
21
28
 
22
29
  /**
23
30
  * Write Pipeline — the single module that runs the canonical, secured write
@@ -57,10 +64,14 @@ export interface PrismaModel {
57
64
  findUnique: (args: { where: Record<string, unknown> }) => Promise<Record<string, unknown> | null>
58
65
  findFirst: (args: { where: Record<string, unknown> }) => Promise<Record<string, unknown> | null>
59
66
  count: () => Promise<number>
60
- create: (args: { data: Record<string, unknown> }) => Promise<Record<string, unknown>>
67
+ create: (args: {
68
+ data: Record<string, unknown>
69
+ include?: Record<string, unknown>
70
+ }) => Promise<Record<string, unknown>>
61
71
  update: (args: {
62
72
  where: Record<string, unknown>
63
73
  data: Record<string, unknown>
74
+ include?: Record<string, unknown>
64
75
  }) => Promise<Record<string, unknown>>
65
76
  delete: (args: { where: Record<string, unknown> }) => Promise<Record<string, unknown>>
66
77
  }
@@ -95,8 +106,14 @@ export interface WriteStrategy {
95
106
  /**
96
107
  * Axis 3: execute the database write and return the persisted/deleted row.
97
108
  * `data` is the fully-resolved write payload (empty object for delete).
109
+ * `include` (create/update) asks the DB to return nested relations so nested
110
+ * `afterOperation` can recover its persisted `item`; delete ignores it.
98
111
  */
99
- persist(model: PrismaModel, data: Record<string, unknown>): Promise<Record<string, unknown>>
112
+ persist(
113
+ model: PrismaModel,
114
+ data: Record<string, unknown>,
115
+ include?: Record<string, unknown>,
116
+ ): Promise<Record<string, unknown>>
100
117
  }
101
118
 
102
119
  /**
@@ -112,6 +129,43 @@ function getModel<TPrisma extends PrismaClientLike>(
112
129
  return (prisma as any)[getDbKey(listName)] as PrismaModel
113
130
  }
114
131
 
132
+ /**
133
+ * Minimal shape of a Prisma interactive-transaction-capable client.
134
+ *
135
+ * The transaction client `tx` is dynamically typed exactly like the model
136
+ * surface above (model names are generated at runtime), so the cast is kept
137
+ * localized and commented per the house rules.
138
+ */
139
+ interface TransactionCapable {
140
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- $transaction callback receives a dynamically-typed tx client
141
+ $transaction?: (fn: (tx: any) => Promise<unknown>) => Promise<unknown>
142
+ }
143
+
144
+ /**
145
+ * Run `fn` inside ONE interactive transaction (ADR-0010: every write is
146
+ * transactional, so the hook contract does not depend on whether a write
147
+ * happened to be nested). The transaction client `tx` is passed to `fn` and
148
+ * used as the persistence target for the parent + all nested writes, so they
149
+ * are atomic and a throwing hook rolls the whole write back.
150
+ *
151
+ * If the client does not expose `$transaction` (e.g. a test mock), `fn` runs
152
+ * directly against the client — the hook ordering and arguments are identical;
153
+ * only the rollback guarantee is provided by the real transaction.
154
+ */
155
+ async function runInTransaction<TPrisma extends PrismaClientLike>(
156
+ prisma: TPrisma,
157
+ fn: (tx: TPrisma) => Promise<Record<string, unknown> | null>,
158
+ ): Promise<Record<string, unknown> | null> {
159
+ const client = prisma as unknown as TransactionCapable
160
+ if (typeof client.$transaction === 'function') {
161
+ return (await client.$transaction(async (tx) => fn(tx as TPrisma))) as Record<
162
+ string,
163
+ unknown
164
+ > | null
165
+ }
166
+ return fn(prisma)
167
+ }
168
+
115
169
  /**
116
170
  * Check if a list is configured as a singleton.
117
171
  */
@@ -134,6 +188,16 @@ export interface WritePipelineArgs<TPrisma extends PrismaClientLike> {
134
188
  inputData: Record<string, unknown> | undefined
135
189
  /** The per-operation strategy supplying the three variation axes. */
136
190
  strategy: WriteStrategy
191
+ /**
192
+ * The target resolution computed ONCE before the transaction opened (#590).
193
+ *
194
+ * The transaction-boundary bracket resolves the top-level target + access
195
+ * before opening the transaction (both to gate silent-failures without firing
196
+ * boundary hooks, and to supply `originalItem` to `beforeTransaction`). To
197
+ * avoid a second resolution inside the transaction, the in-transaction body
198
+ * reuses this result instead of calling `strategy.resolveTarget` again.
199
+ */
200
+ preResolvedTarget?: TargetResolution
137
201
  }
138
202
 
139
203
  /**
@@ -165,14 +229,120 @@ export interface WritePipelineArgs<TPrisma extends PrismaClientLike> {
165
229
  export async function runWritePipeline<TPrisma extends PrismaClientLike>(
166
230
  args: WritePipelineArgs<TPrisma>,
167
231
  ): Promise<Record<string, unknown> | null> {
168
- const { listName, listConfig, prisma, context, config, inputData, strategy } = args
232
+ const { prisma, listName, listConfig, context, config, inputData, strategy } = args
233
+
234
+ // ── Pre-transaction access gate (#590) ──────────────────────────────────────
235
+ // Resolve the TOP-LEVEL target + operation-level access OUTSIDE the
236
+ // transaction first, using the NON-transactional client. A
237
+ // denied/missing/filter-non-match target short-circuits to `null` (silent
238
+ // failure) WITHOUT firing any transaction-boundary hooks — a denied write
239
+ // opens no transaction and takes no external action, so it must not run
240
+ // beforeTransaction/afterTransaction. This resolution also yields the
241
+ // top-level `originalItem` the boundary hooks receive for update/delete.
242
+ // The result is passed into the transaction as `preResolvedTarget` and REUSED
243
+ // there (the in-transaction resolveTarget does NOT re-run), so the target is
244
+ // read exactly once — #569's resolveTarget call-count semantics are preserved.
245
+ const gate = await strategy.resolveTarget(getModel(prisma, listName))
246
+ if (gate.status === 'denied') {
247
+ return null
248
+ }
249
+
250
+ // ── Enumerate involved lists from the input tree (no DB reads) ──────────────
251
+ const involvedLists = enumerateInvolvedLists({
252
+ listName,
253
+ listConfig,
254
+ operation: strategy.operation,
255
+ inputData,
256
+ topLevelOriginalItem: gate.originalItem,
257
+ config,
258
+ })
259
+
260
+ // ── Bracket the transaction with beforeTransaction/afterTransaction (#590) ──
261
+ // beforeTransaction runs before the transaction opens; afterTransaction runs
262
+ // after it settles (commit or rollback), per the symmetric-bracket rule.
263
+ return runWithTransactionBoundary({
264
+ involvedLists,
265
+ context,
266
+ runTransaction: () =>
267
+ // ADR-0010: every write runs inside ONE interactive transaction. The
268
+ // parent and ALL nested writes share this transaction's client `tx` as
269
+ // their persistence target, so they are atomic and a throwing
270
+ // `beforeOperation`/`afterOperation` (or validation) rolls the whole write
271
+ // back. `runWriteInTransaction` resolves the target row, runs the full
272
+ // hook pipeline, persists, and runs nested + own `afterOperation` — all
273
+ // against `tx`.
274
+ runInTransaction(prisma, (tx) =>
275
+ runWriteInTransaction({
276
+ ...args,
277
+ prisma: tx,
278
+ // Reuse the pre-transaction target resolution (computed above) so the
279
+ // target is read exactly once (#569 call-count semantics preserved).
280
+ preResolvedTarget: gate,
281
+ // ADR-0010 atomicity: hooks that write via `context.db` must hit the
282
+ // SAME transaction, or those writes would commit independently and
283
+ // survive a rollback. Rebind the context's `db` (and `prisma`) to the
284
+ // transaction client `tx` so before/afterOperation `context.db` writes
285
+ // participate in — and roll back with — this write's transaction.
286
+ context: bindContextToTransaction(args, tx),
287
+ }),
288
+ ),
289
+ })
290
+ }
291
+
292
+ /**
293
+ * Build an {@link AccessContext} whose `db`/`prisma` target the transaction
294
+ * client `tx`, so any `context.db` write a hook performs runs inside this
295
+ * write's transaction and rolls back with it (ADR-0010).
296
+ *
297
+ * The access-controlled `db` delegates capture their Prisma client at
298
+ * construction, so the request-time `context.db` is bound to the ORIGINAL
299
+ * client. We rebuild the delegates against `tx` via {@link buildDbDelegate},
300
+ * reusing the request context's `session`, `storage`, `plugins`, `_isSudo`, and
301
+ * the shared `_resolveOutputCounter` reference (so resolveOutput depth tracking
302
+ * is preserved). Plugin runtimes are NOT re-executed; the existing
303
+ * `plugins` object is reused as-is.
304
+ */
305
+ function bindContextToTransaction<TPrisma extends PrismaClientLike>(
306
+ args: WritePipelineArgs<TPrisma>,
307
+ tx: TPrisma,
308
+ ): AccessContext<TPrisma> {
309
+ const { context, config } = args
310
+ const txContext: AccessContext<TPrisma> = {
311
+ session: context.session,
312
+ prisma: tx,
313
+ db: context.db,
314
+ storage: context.storage,
315
+ plugins: context.plugins,
316
+ _isSudo: context._isSudo,
317
+ _resolveOutputCounter: context._resolveOutputCounter,
318
+ }
319
+ // Rebuild the db delegate against `tx`, pointing back at `txContext` so hooks
320
+ // reached through it also see the transactional context.
321
+ txContext.db = buildDbDelegate(config, tx, txContext)
322
+ return txContext
323
+ }
324
+
325
+ /**
326
+ * The body of one secured write, executed against the transaction client `tx`
327
+ * (passed in as `args.prisma`). Returns `null` for the silent-failure cases and
328
+ * the Field-Visibility-filtered row otherwise. Any throw here propagates out of
329
+ * `runInTransaction` and rolls the transaction back.
330
+ */
331
+ async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
332
+ args: WritePipelineArgs<TPrisma>,
333
+ ): Promise<Record<string, unknown> | null> {
334
+ const { listName, listConfig, prisma: tx, context, config, inputData, strategy } = args
169
335
  const { operation } = strategy
170
- const model = getModel(prisma, listName)
336
+ const model = getModel(tx, listName)
171
337
 
172
338
  // ── Phase 1: resolve target + operation-level access ──────────────────────
173
339
  // Short-circuits to `null` (silent failure) for missing target, denied
174
340
  // access, or filter non-match — before any hook side effects or the DB call.
175
- const resolution = await strategy.resolveTarget(model)
341
+ // The transaction-boundary bracket (#590) already resolved this once before
342
+ // opening the transaction; reuse that result rather than reading the target
343
+ // twice. (When invoked without the bracket — e.g. a direct unit test — fall
344
+ // back to resolving here against the tx model.)
345
+ const resolution = args.preResolvedTarget ?? (await strategy.resolveTarget(model))
176
346
  if (resolution.status === 'denied') {
177
347
  return null
178
348
  }
@@ -214,12 +384,24 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
214
384
  })
215
385
 
216
386
  // ── Phase 5.5: process nested relationship operations ───────────────────────
217
- const data = await processNestedOperations(
387
+ // This runs each nested record's resolveInput/validate/field-rules AND its
388
+ // `beforeOperation` (inside this transaction), returning the transformed
389
+ // payload plus deferred `afterOperation` tasks and the relation fields to
390
+ // `include` so those tasks can recover their persisted `item`. All nested DB
391
+ // reads/persistence go through `tx`.
392
+ const { data, afterTasks, includeFields } = await processNestedOperations(
218
393
  filteredData,
219
394
  listConfig.fields,
220
395
  config,
221
- { ...context, prisma },
396
+ { ...context, prisma: tx },
222
397
  writeOp,
398
+ listName,
399
+ originalItem,
400
+ // Pass the enclosing write's `inputData` (the SAME value the Phase-5
401
+ // `filterWritableFields` call above uses) so the connect-site owning-field
402
+ // gate evaluates item-/inputData-dependent field rules identically to Phase 5
403
+ // and the two cannot diverge into a spurious connect denial (#588 finding).
404
+ input,
223
405
  )
224
406
 
225
407
  // ── Phase 6: field-level beforeOperation (side effects only) ────────────────
@@ -255,7 +437,10 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
255
437
  )
256
438
 
257
439
  // ── Phase 8: DB write ───────────────────────────────────────────────────────
258
- const item = await strategy.persist(model, data)
440
+ // Ask the DB to return the nested relations that have deferred
441
+ // `afterOperation` tasks so they can recover their persisted `item`.
442
+ const include = buildIncludeFromFields(includeFields)
443
+ const item = await strategy.persist(model, data, include)
259
444
 
260
445
  // ── Phase 9: list-level afterOperation ──────────────────────────────────────
261
446
  await executeAfterOperation(
@@ -293,6 +478,12 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
293
478
  originalItem, // undefined for create, original row for update
294
479
  )
295
480
 
481
+ // ── Phase 10.5: nested afterOperation (deferred, in this transaction) ────────
482
+ // Each nested create/update/delete's `afterOperation` fires now, with the
483
+ // persisted nested row recovered from the parent's included relations. A throw
484
+ // here rolls the whole transaction back (parent write included).
485
+ await runNestedAfterTasks(afterTasks, item)
486
+
296
487
  // ── Phase 11: Field Visibility (filter readable fields + resolveOutput) ─────
297
488
  return filterReadableFields(
298
489
  item,
@@ -307,6 +498,36 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
307
498
  )
308
499
  }
309
500
 
501
+ /**
502
+ * Build a Prisma `include` from the set of relation field names that have
503
+ * deferred nested `afterOperation` tasks, so the parent write returns those
504
+ * relations and the tasks can recover their persisted `item`.
505
+ */
506
+ function buildIncludeFromFields(includeFields: Set<string>): Record<string, unknown> | undefined {
507
+ if (includeFields.size === 0) return undefined
508
+ const include: Record<string, unknown> = {}
509
+ for (const field of includeFields) {
510
+ include[field] = true
511
+ }
512
+ return include
513
+ }
514
+
515
+ /**
516
+ * Run the deferred nested `afterOperation` tasks against the persisted parent
517
+ * row. The persisted parent is always a record here; the `?? {}` is only a
518
+ * type-narrowing guard. Each task recovers its OWN nested row from `item` by
519
+ * id-diff (create) or known id (update); if a created row cannot be recovered
520
+ * the task THROWS rather than firing `afterOperation` with a fabricated item
521
+ * (see `recoverCreatedRows`/the create task in `nested-operations.ts`).
522
+ */
523
+ async function runNestedAfterTasks(
524
+ afterTasks: AfterTask[],
525
+ item: Record<string, unknown>,
526
+ ): Promise<void> {
527
+ if (afterTasks.length === 0) return
528
+ await runAfterTasks(afterTasks, item ?? {})
529
+ }
530
+
310
531
  /**
311
532
  * The delete tail of the pipeline: skips the input-shaping phases and runs only
312
533
  * validate/field-validate before the DB delete, then the after-hooks. Returns
@@ -434,10 +655,10 @@ export function createWriteStrategy(
434
655
 
435
656
  return { status: 'ok', originalItem: undefined }
436
657
  },
437
- async persist(model, data) {
658
+ async persist(model, data, include) {
438
659
  // Singleton lists use Int @id with value always 1 (matching Keystone 6).
439
660
  const createData = singleton ? { id: 1, ...data } : data
440
- return model.create({ data: createData })
661
+ return model.create(include ? { data: createData, include } : { data: createData })
441
662
  },
442
663
  }
443
664
  }
@@ -504,8 +725,8 @@ export function updateWriteStrategy(
504
725
  operation: 'update',
505
726
  runInputPhases: true,
506
727
  resolveTarget: resolveExistingTarget(listConfig, context, where, 'update'),
507
- async persist(model, data) {
508
- return model.update({ where, data })
728
+ async persist(model, data, include) {
729
+ return model.update(include ? { where, data, include } : { where, data })
509
730
  },
510
731
  }
511
732
  }
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, expectTypeOf } from 'vitest'
2
+ import { calendarDay } from './index.js'
3
+ import { generateZodSchema, validateWithZod } from '../validation/schema.js'
4
+ import type { FieldConfig } from '../config/types.js'
5
+
6
+ /**
7
+ * calendarDay is a YYYY-MM-DD string end-to-end (Keystone's CalendarDay
8
+ * scalar). Type, validation, and runtime read value must all agree on `string`.
9
+ * See issue #571.
10
+ */
11
+ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
12
+ describe('getTypeScriptType', () => {
13
+ it('returns string (not Date), driving the entity + input types', () => {
14
+ const field = calendarDay()
15
+ expect(field.getTypeScriptType?.()).toEqual({ type: 'string', optional: true })
16
+ })
17
+
18
+ it('is non-optional when required and not nullable', () => {
19
+ const field = calendarDay({ validation: { isRequired: true } })
20
+ expect(field.getTypeScriptType?.()).toEqual({ type: 'string', optional: false })
21
+ })
22
+
23
+ it('type-level: the declared type is the literal "string"', () => {
24
+ const field = calendarDay()
25
+ const tsType = field.getTypeScriptType?.()
26
+ // The entity/read type and the standalone generated CreateInput/UpdateInput
27
+ // types are emitted from this literal, so asserting it is exactly 'string'
28
+ // pins those types to `string`. (At the context.db write path a Date is
29
+ // rejected at runtime by validation, not at compile time — tracked in #599.)
30
+ expectTypeOf(tsType).toEqualTypeOf<{ type: string; optional: boolean } | undefined>()
31
+ if (tsType) {
32
+ expectTypeOf(tsType.type).toEqualTypeOf<string>()
33
+ expect(tsType.type).toBe('string')
34
+ // @ts-expect-error - the runtime type is 'string', never 'Date'
35
+ const _notDate: 'Date' = tsType.type
36
+ void _notDate
37
+ }
38
+ })
39
+ })
40
+
41
+ describe('getPrismaType (unchanged — stays DateTime @db.Date)', () => {
42
+ it('keeps DateTime storage with @db.Date on non-sqlite providers', () => {
43
+ const field = calendarDay({ validation: { isRequired: true } })
44
+ const prisma = field.getPrismaType?.('startsOn', 'postgresql')
45
+ expect(prisma?.type).toBe('DateTime')
46
+ expect(prisma?.modifiers).toContain('@db.Date')
47
+ })
48
+
49
+ it('omits @db.Date on sqlite (TEXT fallback)', () => {
50
+ const field = calendarDay({ validation: { isRequired: true } })
51
+ const prisma = field.getPrismaType?.('startsOn', 'sqlite')
52
+ expect(prisma?.type).toBe('DateTime')
53
+ expect(prisma?.modifiers ?? '').not.toContain('@db.Date')
54
+ })
55
+ })
56
+
57
+ describe('write validation (string-only)', () => {
58
+ const fields: Record<string, FieldConfig> = {
59
+ startsOn: calendarDay({ validation: { isRequired: true } }),
60
+ }
61
+
62
+ it('accepts a valid YYYY-MM-DD string on create', () => {
63
+ const result = validateWithZod({ startsOn: '2025-01-15' }, fields, 'create')
64
+ expect(result.success).toBe(true)
65
+ })
66
+
67
+ it('rejects a malformed string with a clear message', () => {
68
+ const result = validateWithZod({ startsOn: '15/01/2025' }, fields, 'create')
69
+ expect(result.success).toBe(false)
70
+ if (!result.success) {
71
+ expect(result.errors).toHaveProperty('startsOn')
72
+ expect(result.errors.startsOn).toMatch(/YYYY-MM-DD/)
73
+ }
74
+ })
75
+
76
+ it('rejects a Date instance at runtime (not a string)', () => {
77
+ // A typed caller cannot reach here (input type is `string`), but the
78
+ // validator is string-only as a runtime backstop.
79
+ const result = validateWithZod(
80
+ { startsOn: new Date('2025-01-15') } as unknown as Record<string, unknown>,
81
+ fields,
82
+ 'create',
83
+ )
84
+ expect(result.success).toBe(false)
85
+ })
86
+
87
+ it('zod schema for the field validates the YYYY-MM-DD shape', () => {
88
+ const schema = generateZodSchema(fields, 'create')
89
+ expect(schema.safeParse({ startsOn: '2025-12-31' }).success).toBe(true)
90
+ expect(schema.safeParse({ startsOn: 'nope' }).success).toBe(false)
91
+ })
92
+ })
93
+
94
+ describe('read transform (resolveOutput returns a YYYY-MM-DD string)', () => {
95
+ // The read pipeline calls fieldConfig.hooks.resolveOutput({ value, ... }).
96
+ // We exercise that hook directly with the value shapes Prisma can return.
97
+ function readValue(value: unknown): unknown {
98
+ const field = calendarDay()
99
+ const hook = field.hooks?.resolveOutput
100
+ if (!hook) throw new Error('calendarDay must define a resolveOutput hook')
101
+ // Cast to the runtime call shape used by field-visibility.ts.
102
+ return (hook as unknown as (args: { value: unknown }) => unknown)({ value })
103
+ }
104
+
105
+ it('formats a Date (Postgres/MySQL @db.Date) to YYYY-MM-DD', () => {
106
+ expect(readValue(new Date('2025-01-15T00:00:00.000Z'))).toBe('2025-01-15')
107
+ })
108
+
109
+ it('is timezone-safe — a late-UTC Date does not drift a day', () => {
110
+ // 23:59:59Z is the same UTC calendar day; UTC-based formatting keeps it.
111
+ expect(readValue(new Date('2025-01-15T23:59:59.999Z'))).toBe('2025-01-15')
112
+ })
113
+
114
+ it('passes through an already-formatted string (SQLite TEXT)', () => {
115
+ expect(readValue('2025-01-15')).toBe('2025-01-15')
116
+ })
117
+
118
+ it('takes the date-only prefix of a full ISO string (SQLite TEXT)', () => {
119
+ expect(readValue('2025-01-15T00:00:00.000Z')).toBe('2025-01-15')
120
+ })
121
+
122
+ it('passes null/undefined through unchanged', () => {
123
+ expect(readValue(null)).toBeNull()
124
+ expect(readValue(undefined)).toBeUndefined()
125
+ })
126
+ })
127
+
128
+ describe('user-provided hooks are preserved', () => {
129
+ it('merges a user resolveOutput over the default (last wins)', () => {
130
+ const field = calendarDay({
131
+ hooks: {
132
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test hook
133
+ resolveOutput: ({ value }: { value: any }) => `custom:${value}`,
134
+ },
135
+ })
136
+ const hook = field.hooks?.resolveOutput as unknown as (args: { value: unknown }) => unknown
137
+ expect(hook({ value: '2025-01-15' })).toBe('custom:2025-01-15')
138
+ })
139
+ })
140
+ })
@@ -84,7 +84,7 @@ export function text<
84
84
  : withMin
85
85
 
86
86
  if (isRequired && operation === 'update') {
87
- return z.union([withMax, z.undefined()])
87
+ return withMax.optional()
88
88
  }
89
89
 
90
90
  return !isRequired ? withMax.optional().nullable() : withMax
@@ -514,11 +514,23 @@ export function timestamp<
514
514
  /**
515
515
  * Calendar Day field - date only (no time) in ISO8601 format
516
516
  *
517
+ * Mirrors Keystone's `CalendarDay` scalar: the wire format through
518
+ * `context.db.*` is a `YYYY-MM-DD` **string** in both directions (read and
519
+ * write). The field's TypeScript type — entity, `CreateInput`, and
520
+ * `UpdateInput` — is `string`, so passing a `Date` is a compile-time error.
521
+ *
517
522
  * **Features:**
518
523
  * - Stores date values only (no time component)
519
524
  * - PostgreSQL/MySQL: Uses native DATE type via @db.Date
520
525
  * - SQLite: Uses String representation
521
- * - Accepts ISO8601 date strings (YYYY-MM-DD format)
526
+ * - **Writes:** accept only a `YYYY-MM-DD` string; a malformed string or a
527
+ * `Date` is rejected at runtime by validation (a `ValidationError`). Genuine
528
+ * compile-time rejection at the `context.db` call site is tracked in #599.
529
+ * - **Reads:** always return a `YYYY-MM-DD` string. Even though the underlying
530
+ * `@db.Date` column hands Prisma a `Date`, a `resolveOutput` transform
531
+ * normalises it back to a `YYYY-MM-DD` string so the runtime value matches
532
+ * the declared `string` type. UTC components are used to avoid timezone
533
+ * off-by-one errors.
522
534
  * - Optional validation for required fields
523
535
  * - Database column mapping and nullability control
524
536
  * - Index support (boolean or 'unique')
@@ -539,13 +551,17 @@ export function timestamp<
539
551
  * })
540
552
  * }
541
553
  *
542
- * // Creating with date values
554
+ * // Creating with date values — pass YYYY-MM-DD strings (NOT Date objects)
543
555
  * const event = await context.db.event.create({
544
556
  * data: {
545
557
  * startDate: '2025-01-15',
546
558
  * endDate: '2025-01-20'
547
559
  * }
548
560
  * })
561
+ *
562
+ * // Reading — values come back as YYYY-MM-DD strings
563
+ * const e = await context.db.event.findUnique({ where: { id } })
564
+ * e?.startDate // => '2025-01-15' (a string, not a Date)
549
565
  * ```
550
566
  *
551
567
  * @param options - Field configuration options
@@ -557,6 +573,19 @@ export function calendarDay<
557
573
  return {
558
574
  type: 'calendarDay',
559
575
  ...options,
576
+ // Reads: the underlying @db.Date column hands Prisma a Date (or a TEXT
577
+ // string under the SQLite fallback). Normalise to a YYYY-MM-DD string so the
578
+ // runtime value matches the declared `string` type. UTC components are used
579
+ // so the formatting never drifts a day in non-UTC timezones.
580
+ // Cast hooks to any since field builders are generic and can't know the
581
+ // specific TFieldKey (same pattern as password()).
582
+ hooks: {
583
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
584
+ resolveOutput: ({ value }: { value: any }) => formatCalendarDay(value),
585
+ // Merge with user-provided hooks if any
586
+ ...options?.hooks,
587
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hook object needs type assertion for field builder
588
+ } as any,
560
589
  getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
561
590
  const validation = options?.validation
562
591
  const isRequired = validation?.isRequired
@@ -574,8 +603,8 @@ export function calendarDay<
574
603
  if (isRequired && operation === 'create') {
575
604
  return dateSchema
576
605
  } else if (isRequired && operation === 'update') {
577
- // Required in update mode: can be undefined for partial updates
578
- return z.union([dateSchema, z.undefined()])
606
+ // Required in update mode: omitted keys pass; present values must be valid
607
+ return dateSchema.optional()
579
608
  } else {
580
609
  return dateSchema.optional().nullable()
581
610
  }
@@ -628,14 +657,49 @@ export function calendarDay<
628
657
  const isRequired = validation?.isRequired
629
658
  const isNullable = db?.isNullable ?? !isRequired
630
659
 
660
+ // calendarDay is a YYYY-MM-DD string end-to-end (Keystone's CalendarDay
661
+ // scalar). Returning 'string' here makes the entity/read type and the
662
+ // standalone generated CreateInput/UpdateInput types `string`. At the
663
+ // context.db write path a Date is still rejected at runtime by validation
664
+ // (the generated db method `data` type derives from Prisma's `Date | string`
665
+ // input — making it a compile-time error is tracked in #599).
631
666
  return {
632
- type: 'Date',
667
+ type: 'string',
633
668
  optional: isNullable,
634
669
  }
635
670
  },
636
671
  }
637
672
  }
638
673
 
674
+ /**
675
+ * Format a stored calendar-day value to a `YYYY-MM-DD` string.
676
+ *
677
+ * Handles the value being a `Date` (Postgres/MySQL `@db.Date`), an already
678
+ * formatted string (SQLite TEXT fallback), or null/undefined. Dates are
679
+ * formatted from their UTC components so the result never drifts a day in
680
+ * non-UTC timezones.
681
+ */
682
+ function formatCalendarDay(value: unknown): string | null | undefined {
683
+ if (value === null || value === undefined) {
684
+ return value as null | undefined
685
+ }
686
+
687
+ if (value instanceof Date) {
688
+ // toISOString() is UTC; the YYYY-MM-DD prefix is timezone-safe.
689
+ return value.toISOString().slice(0, 10)
690
+ }
691
+
692
+ if (typeof value === 'string') {
693
+ // SQLite stores DateTime as TEXT. The value may already be YYYY-MM-DD or a
694
+ // full ISO timestamp — take the date-only prefix either way.
695
+ return value.slice(0, 10)
696
+ }
697
+
698
+ // Any other shape is unexpected for a @db.Date column; surface it untouched
699
+ // by returning undefined so callers see the field as absent rather than wrong.
700
+ return undefined
701
+ }
702
+
639
703
  /**
640
704
  * Password field (automatically hashed using bcrypt)
641
705
  *
@@ -752,13 +816,13 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
752
816
  message: `${formatFieldName(fieldName)} is required`,
753
817
  })
754
818
  } else if (isRequired && operation === 'update') {
755
- // Required in update mode: if provided, reject empty strings
756
- return z.union([
757
- z.string().min(1, {
819
+ // Required in update mode: omitted keys pass; if provided, reject empty strings
820
+ return z
821
+ .string()
822
+ .min(1, {
758
823
  message: `${formatFieldName(fieldName)} is required`,
759
- }),
760
- z.undefined(),
761
- ])
824
+ })
825
+ .optional()
762
826
  } else {
763
827
  // Not required: can be undefined or any string
764
828
  return z
@@ -1312,11 +1376,27 @@ export function json<
1312
1376
  const baseSchema = z.unknown()
1313
1377
 
1314
1378
  if (isRequired && operation === 'create') {
1315
- // Required in create mode: value must be provided
1316
- return baseSchema
1379
+ // Required in create mode: a value must be provided and it must be
1380
+ // non-null (issue #604 — a required json field means non-null). A bare
1381
+ // z.unknown() is treated as optional inside z.object(), so an omitted
1382
+ // key would silently pass; the refinement makes the key genuinely
1383
+ // required by rejecting undefined (which also covers an absent key) and
1384
+ // rejecting a present null, while still accepting any other present
1385
+ // JSON value (object, array, primitive, including falsy 0/""/false).
1386
+ return baseSchema.refine((value) => value !== undefined && value !== null, {
1387
+ message: `${formatFieldName(fieldName)} is required`,
1388
+ })
1317
1389
  } else if (isRequired && operation === 'update') {
1318
- // Required in update mode: can be undefined for partial updates
1319
- return z.union([baseSchema, z.undefined()])
1390
+ // Required in update mode: omitted keys still pass (issue #570 — partial
1391
+ // updates may leave the field untouched), but a present null is rejected
1392
+ // (issue #604 — required json means non-null). The `.refine()` runs
1393
+ // before `.optional()` short-circuits on undefined: absent/undefined
1394
+ // passes, a present null is rejected, and other present values pass.
1395
+ return baseSchema
1396
+ .refine((value) => value !== null, {
1397
+ message: `${formatFieldName(fieldName)} is required`,
1398
+ })
1399
+ .optional()
1320
1400
  } else {
1321
1401
  // Not required: can be undefined or null
1322
1402
  return baseSchema.optional().nullable()