@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
@@ -214,6 +214,271 @@ export async function executeAfterOperation<
214
214
  await hooks.afterOperation(args as Parameters<typeof hooks.afterOperation>[0])
215
215
  }
216
216
 
217
+ /**
218
+ * Execute list-level beforeTransaction hook (#590 / ADR-0010).
219
+ *
220
+ * Transaction-boundary hook: runs OUTSIDE the write's transaction, BEFORE it
221
+ * opens. A throw here aborts the write (the transaction never opens). The
222
+ * arguments mirror `beforeOperation` minus `resolvedData` (no input-shaping has
223
+ * run yet at the transaction boundary).
224
+ */
225
+ export async function executeBeforeTransaction<
226
+ TOutput = Record<string, unknown>,
227
+ TCreateInput = Record<string, unknown>,
228
+ TUpdateInput = Record<string, unknown>,
229
+ >(
230
+ hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
231
+ args:
232
+ | {
233
+ listKey: string
234
+ operation: 'create'
235
+ inputData: TCreateInput
236
+ context: AccessContext
237
+ }
238
+ | {
239
+ listKey: string
240
+ operation: 'update'
241
+ inputData: TUpdateInput
242
+ item: TOutput | undefined
243
+ context: AccessContext
244
+ }
245
+ | {
246
+ listKey: string
247
+ operation: 'delete'
248
+ item: TOutput | undefined
249
+ context: AccessContext
250
+ },
251
+ ): Promise<void> {
252
+ if (!hooks?.beforeTransaction) {
253
+ return
254
+ }
255
+ await hooks.beforeTransaction(args as Parameters<typeof hooks.beforeTransaction>[0])
256
+ }
257
+
258
+ /**
259
+ * Execute list-level afterTransaction hook (#590 / ADR-0010).
260
+ *
261
+ * Transaction-boundary hook: runs OUTSIDE the write's transaction, AFTER it
262
+ * settles, and ALWAYS runs when the paired `beforeTransaction` ran. The
263
+ * `status` discriminant tells the hook whether the write committed (persisted
264
+ * `item` present) or rolled back (no `item`, `error` present).
265
+ */
266
+ export async function executeAfterTransaction<
267
+ TOutput = Record<string, unknown>,
268
+ TCreateInput = Record<string, unknown>,
269
+ TUpdateInput = Record<string, unknown>,
270
+ >(
271
+ hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
272
+ args:
273
+ | {
274
+ listKey: string
275
+ operation: 'create'
276
+ status: 'committed'
277
+ inputData: TCreateInput
278
+ item: TOutput
279
+ context: AccessContext
280
+ }
281
+ | {
282
+ listKey: string
283
+ operation: 'create'
284
+ status: 'rolled-back'
285
+ inputData: TCreateInput
286
+ error: unknown
287
+ context: AccessContext
288
+ }
289
+ | {
290
+ listKey: string
291
+ operation: 'update'
292
+ status: 'committed'
293
+ inputData: TUpdateInput
294
+ originalItem: TOutput
295
+ item: TOutput
296
+ context: AccessContext
297
+ }
298
+ | {
299
+ listKey: string
300
+ operation: 'update'
301
+ status: 'rolled-back'
302
+ inputData: TUpdateInput
303
+ originalItem: TOutput | undefined
304
+ error: unknown
305
+ context: AccessContext
306
+ }
307
+ | {
308
+ listKey: string
309
+ operation: 'delete'
310
+ status: 'committed'
311
+ originalItem: TOutput
312
+ context: AccessContext
313
+ }
314
+ | {
315
+ listKey: string
316
+ operation: 'delete'
317
+ status: 'rolled-back'
318
+ originalItem: TOutput | undefined
319
+ error: unknown
320
+ context: AccessContext
321
+ },
322
+ ): Promise<void> {
323
+ if (!hooks?.afterTransaction) {
324
+ return
325
+ }
326
+ await hooks.afterTransaction(args as Parameters<typeof hooks.afterTransaction>[0])
327
+ }
328
+
329
+ /**
330
+ * Execute field-level beforeTransaction hooks (#590 / ADR-0010).
331
+ *
332
+ * Runs each field's `beforeTransaction` (side effects only). Like the list-level
333
+ * hook, these run OUTSIDE the transaction before it opens; a throw aborts the
334
+ * write. For create/update the field is only invoked when it appears in
335
+ * `inputData` (mirroring the field beforeOperation gate); for delete all fields
336
+ * with the hook run against the existing `item`.
337
+ */
338
+ export async function executeFieldBeforeTransactionHooks(
339
+ inputData: Record<string, unknown> | undefined,
340
+ fields: Record<string, FieldConfig>,
341
+ operation: 'create' | 'update' | 'delete',
342
+ context: AccessContext,
343
+ listKey: string,
344
+ item?: Record<string, unknown>,
345
+ ): Promise<void> {
346
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
347
+ if (!fieldConfig.hooks?.beforeTransaction) continue
348
+ if (operation !== 'delete' && !(inputData && fieldKey in inputData)) continue
349
+
350
+ if (operation === 'delete') {
351
+ await fieldConfig.hooks.beforeTransaction({
352
+ listKey,
353
+ fieldKey,
354
+ operation: 'delete',
355
+ item,
356
+ context,
357
+ } as Parameters<typeof fieldConfig.hooks.beforeTransaction>[0])
358
+ } else if (operation === 'create') {
359
+ await fieldConfig.hooks.beforeTransaction({
360
+ listKey,
361
+ fieldKey,
362
+ operation: 'create',
363
+ inputData,
364
+ context,
365
+ } as Parameters<typeof fieldConfig.hooks.beforeTransaction>[0])
366
+ } else {
367
+ await fieldConfig.hooks.beforeTransaction({
368
+ listKey,
369
+ fieldKey,
370
+ operation: 'update',
371
+ inputData,
372
+ item,
373
+ context,
374
+ } as Parameters<typeof fieldConfig.hooks.beforeTransaction>[0])
375
+ }
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Outcome of a settled write transaction, passed to afterTransaction hooks.
381
+ *
382
+ * - `committed`: the write persisted; the persisted `item` (and, for
383
+ * update/delete, `originalItem`) is available.
384
+ * - `rolled-back`: the write was aborted/rolled back; NO persisted `item` —
385
+ * only `inputData`/`originalItem` and the `error` that caused the rollback,
386
+ * so hooks can compensate.
387
+ */
388
+ export type TransactionOutcome =
389
+ | { status: 'committed'; item: Record<string, unknown> }
390
+ | { status: 'rolled-back'; error: unknown }
391
+
392
+ /**
393
+ * Execute field-level afterTransaction hooks (#590 / ADR-0010).
394
+ *
395
+ * Runs each field's `afterTransaction` (side effects only) with the settled
396
+ * transaction outcome. On commit the field receives the persisted `item`/
397
+ * `originalItem` — but ONLY for the top-level list (`isTopLevel`); for nested
398
+ * lists they are `undefined`, since `outcome.item` is the top-level persisted
399
+ * row and handing it to a nested field's hook would be unsound. On rollback the
400
+ * field receives the `error` and NO `item`. Unlike the field `afterOperation`
401
+ * gate, EVERY field with the hook runs (compensation must not depend on the
402
+ * field appearing in the payload).
403
+ */
404
+ export async function executeFieldAfterTransactionHooks(
405
+ outcome: TransactionOutcome,
406
+ inputData: Record<string, unknown> | undefined,
407
+ fields: Record<string, FieldConfig>,
408
+ operation: 'create' | 'update' | 'delete',
409
+ context: AccessContext,
410
+ listKey: string,
411
+ isTopLevel: boolean,
412
+ originalItem?: Record<string, unknown>,
413
+ ): Promise<void> {
414
+ // The persisted/pre-write rows are surfaced only for the top-level list.
415
+ const committedItem = outcome.status === 'committed' && isTopLevel ? outcome.item : undefined
416
+ const committedOriginalItem = isTopLevel ? originalItem : undefined
417
+
418
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
419
+ if (!fieldConfig.hooks?.afterTransaction) continue
420
+
421
+ const base = { listKey, fieldKey, context }
422
+
423
+ if (outcome.status === 'rolled-back') {
424
+ if (operation === 'delete') {
425
+ await fieldConfig.hooks.afterTransaction({
426
+ ...base,
427
+ operation: 'delete',
428
+ status: 'rolled-back',
429
+ originalItem,
430
+ error: outcome.error,
431
+ } as Parameters<typeof fieldConfig.hooks.afterTransaction>[0])
432
+ } else if (operation === 'create') {
433
+ await fieldConfig.hooks.afterTransaction({
434
+ ...base,
435
+ operation: 'create',
436
+ status: 'rolled-back',
437
+ inputData,
438
+ error: outcome.error,
439
+ } as Parameters<typeof fieldConfig.hooks.afterTransaction>[0])
440
+ } else {
441
+ await fieldConfig.hooks.afterTransaction({
442
+ ...base,
443
+ operation: 'update',
444
+ status: 'rolled-back',
445
+ inputData,
446
+ originalItem,
447
+ error: outcome.error,
448
+ } as Parameters<typeof fieldConfig.hooks.afterTransaction>[0])
449
+ }
450
+ continue
451
+ }
452
+
453
+ // committed
454
+ if (operation === 'delete') {
455
+ await fieldConfig.hooks.afterTransaction({
456
+ ...base,
457
+ operation: 'delete',
458
+ status: 'committed',
459
+ originalItem: committedOriginalItem,
460
+ } as Parameters<typeof fieldConfig.hooks.afterTransaction>[0])
461
+ } else if (operation === 'create') {
462
+ await fieldConfig.hooks.afterTransaction({
463
+ ...base,
464
+ operation: 'create',
465
+ status: 'committed',
466
+ inputData,
467
+ item: committedItem,
468
+ } as Parameters<typeof fieldConfig.hooks.afterTransaction>[0])
469
+ } else {
470
+ await fieldConfig.hooks.afterTransaction({
471
+ ...base,
472
+ operation: 'update',
473
+ status: 'committed',
474
+ inputData,
475
+ originalItem: committedOriginalItem,
476
+ item: committedItem,
477
+ } as Parameters<typeof fieldConfig.hooks.afterTransaction>[0])
478
+ }
479
+ }
480
+ }
481
+
217
482
  /**
218
483
  * Execute field-level resolveInput hooks
219
484
  * Allows fields to transform their input values before database write
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { generateZodSchema, validateWithZod } from './schema.js'
3
3
  import type { FieldConfig } from '../config/types.js'
4
- import { text, integer, select } from '../fields/index.js'
4
+ import { text, integer, select, calendarDay, json, password } from '../fields/index.js'
5
5
 
6
6
  describe('Zod Schema Generation', () => {
7
7
  describe('generateZodSchema', () => {
@@ -219,4 +219,269 @@ describe('Zod Schema Generation', () => {
219
219
  expect(result.success).toBe(true)
220
220
  })
221
221
  })
222
+
223
+ // Regression: issue #570
224
+ // Under zod 4.4, `z.union([schema, z.undefined()])` rejects a MISSING key,
225
+ // so partial updates that omit a required-on-create field used to throw a
226
+ // ValidationError before the DB write. Update-shapes must use key-optionality
227
+ // (`.optional()`) so validation only checks the keys actually present.
228
+ describe('omitted required field on update (issue #570)', () => {
229
+ it('passes when a required text field is omitted while another field is present', () => {
230
+ const fields: Record<string, FieldConfig> = {
231
+ name: text({ validation: { isRequired: true } }),
232
+ bio: text(),
233
+ }
234
+
235
+ const result = validateWithZod({ bio: 'hello' }, fields, 'update')
236
+ expect(result.success).toBe(true)
237
+ })
238
+
239
+ it('still enforces present-value rules for a required text field on update', () => {
240
+ const fields: Record<string, FieldConfig> = {
241
+ name: text({ validation: { isRequired: true } }),
242
+ }
243
+
244
+ // Empty string must still be rejected when the key IS present
245
+ const result = validateWithZod({ name: '' }, fields, 'update')
246
+ expect(result.success).toBe(false)
247
+ if (!result.success) {
248
+ expect(result.errors).toHaveProperty('name')
249
+ }
250
+ })
251
+
252
+ it('still enforces length rules for a required text field present on update', () => {
253
+ const fields: Record<string, FieldConfig> = {
254
+ title: text({ validation: { isRequired: true, length: { min: 5 } } }),
255
+ }
256
+
257
+ const result = validateWithZod({ title: 'Hi' }, fields, 'update')
258
+ expect(result.success).toBe(false)
259
+ if (!result.success) {
260
+ expect(result.errors.title).toContain('at least 5 characters')
261
+ }
262
+ })
263
+
264
+ it('keeps required text fields required on create', () => {
265
+ const fields: Record<string, FieldConfig> = {
266
+ name: text({ validation: { isRequired: true } }),
267
+ }
268
+
269
+ const result = validateWithZod({}, fields, 'create')
270
+ expect(result.success).toBe(false)
271
+ if (!result.success) {
272
+ expect(result.errors).toHaveProperty('name')
273
+ }
274
+ })
275
+
276
+ it('allows an omitted required calendarDay field on update', () => {
277
+ const fields: Record<string, FieldConfig> = {
278
+ startsOn: calendarDay({ validation: { isRequired: true } }),
279
+ label: text(),
280
+ }
281
+
282
+ const result = validateWithZod({ label: 'x' }, fields, 'update')
283
+ expect(result.success).toBe(true)
284
+ })
285
+
286
+ it('still rejects an invalid calendarDay value when present on update', () => {
287
+ const fields: Record<string, FieldConfig> = {
288
+ startsOn: calendarDay({ validation: { isRequired: true } }),
289
+ }
290
+
291
+ const result = validateWithZod({ startsOn: 'not-a-date' }, fields, 'update')
292
+ expect(result.success).toBe(false)
293
+ if (!result.success) {
294
+ expect(result.errors).toHaveProperty('startsOn')
295
+ }
296
+ })
297
+
298
+ it('allows an omitted required json field on update', () => {
299
+ const fields: Record<string, FieldConfig> = {
300
+ meta: json({ validation: { isRequired: true } }),
301
+ label: text(),
302
+ }
303
+
304
+ const result = validateWithZod({ label: 'x' }, fields, 'update')
305
+ expect(result.success).toBe(true)
306
+ })
307
+
308
+ it('allows an omitted required password field on update', () => {
309
+ const fields: Record<string, FieldConfig> = {
310
+ secret: password({ validation: { isRequired: true } }),
311
+ label: text(),
312
+ }
313
+
314
+ const result = validateWithZod({ label: 'x' }, fields, 'update')
315
+ expect(result.success).toBe(true)
316
+ })
317
+
318
+ it('still rejects an empty required password value when present on update', () => {
319
+ const fields: Record<string, FieldConfig> = {
320
+ secret: password({ validation: { isRequired: true } }),
321
+ }
322
+
323
+ const result = validateWithZod({ secret: '' }, fields, 'update')
324
+ expect(result.success).toBe(false)
325
+ if (!result.success) {
326
+ expect(result.errors).toHaveProperty('secret')
327
+ }
328
+ })
329
+ })
330
+
331
+ // Regression: issue #597
332
+ // A bare `z.unknown()` is treated as optional inside `z.object(...)`, so a
333
+ // required-on-create json field used to pass when the key was omitted. The
334
+ // create branch now refines the schema to reject undefined/absent keys while
335
+ // still accepting any present non-null JSON value (object, array, primitive).
336
+ // A present null is rejected by the issue #604 tightening below.
337
+ describe('required json on create (issue #597)', () => {
338
+ it('rejects an omitted required json field on create (key absent)', () => {
339
+ const fields: Record<string, FieldConfig> = {
340
+ meta: json({ validation: { isRequired: true } }),
341
+ label: text(),
342
+ }
343
+
344
+ const result = validateWithZod({ label: 'x' }, fields, 'create')
345
+ expect(result.success).toBe(false)
346
+ if (!result.success) {
347
+ expect(result.errors).toHaveProperty('meta')
348
+ expect(result.errors.meta).toContain('is required')
349
+ }
350
+ })
351
+
352
+ it('rejects an explicit undefined required json field on create', () => {
353
+ const fields: Record<string, FieldConfig> = {
354
+ meta: json({ validation: { isRequired: true } }),
355
+ }
356
+
357
+ const result = validateWithZod({ meta: undefined }, fields, 'create')
358
+ expect(result.success).toBe(false)
359
+ if (!result.success) {
360
+ expect(result.errors).toHaveProperty('meta')
361
+ }
362
+ })
363
+
364
+ it('accepts a present object value for a required json field on create', () => {
365
+ const fields: Record<string, FieldConfig> = {
366
+ meta: json({ validation: { isRequired: true } }),
367
+ }
368
+
369
+ const result = validateWithZod({ meta: { a: 1 } }, fields, 'create')
370
+ expect(result.success).toBe(true)
371
+ })
372
+
373
+ it('accepts a present array value for a required json field on create', () => {
374
+ const fields: Record<string, FieldConfig> = {
375
+ meta: json({ validation: { isRequired: true } }),
376
+ }
377
+
378
+ const result = validateWithZod({ meta: [] }, fields, 'create')
379
+ expect(result.success).toBe(true)
380
+ })
381
+
382
+ it('accepts a present primitive (0) value for a required json field on create', () => {
383
+ const fields: Record<string, FieldConfig> = {
384
+ meta: json({ validation: { isRequired: true } }),
385
+ }
386
+
387
+ const result = validateWithZod({ meta: 0 }, fields, 'create')
388
+ expect(result.success).toBe(true)
389
+ })
390
+
391
+ it('allows an omitted non-required json field on create', () => {
392
+ const fields: Record<string, FieldConfig> = {
393
+ meta: json(),
394
+ label: text(),
395
+ }
396
+
397
+ const result = validateWithZod({ label: 'x' }, fields, 'create')
398
+ expect(result.success).toBe(true)
399
+ })
400
+ })
401
+
402
+ // Regression: issue #604
403
+ // A required json field means non-null. A present `null` must be rejected at
404
+ // the validation layer (with a clear message) instead of surfacing later as a
405
+ // DB NOT NULL violation. Omission on update must still pass (#570), and
406
+ // omission on create must still be rejected (#597). Present non-null values
407
+ // — including falsy 0/""/false — are accepted. The Prisma column stays NOT
408
+ // NULL; only validation behaviour changes.
409
+ describe('required json is non-null (issue #604)', () => {
410
+ it('rejects a present null for a required json field on create', () => {
411
+ const fields: Record<string, FieldConfig> = {
412
+ meta: json({ validation: { isRequired: true } }),
413
+ }
414
+
415
+ const result = validateWithZod({ meta: null }, fields, 'create')
416
+ expect(result.success).toBe(false)
417
+ if (!result.success) {
418
+ expect(result.errors).toHaveProperty('meta')
419
+ expect(result.errors.meta).toContain('is required')
420
+ }
421
+ })
422
+
423
+ it('rejects a present null for a required json field on update', () => {
424
+ const fields: Record<string, FieldConfig> = {
425
+ meta: json({ validation: { isRequired: true } }),
426
+ }
427
+
428
+ const result = validateWithZod({ meta: null }, fields, 'update')
429
+ expect(result.success).toBe(false)
430
+ if (!result.success) {
431
+ expect(result.errors).toHaveProperty('meta')
432
+ expect(result.errors.meta).toContain('is required')
433
+ }
434
+ })
435
+
436
+ it('still allows an omitted required json field on update (preserves #570)', () => {
437
+ const fields: Record<string, FieldConfig> = {
438
+ meta: json({ validation: { isRequired: true } }),
439
+ label: text(),
440
+ }
441
+
442
+ const result = validateWithZod({ label: 'x' }, fields, 'update')
443
+ expect(result.success).toBe(true)
444
+ })
445
+
446
+ it('accepts a present object value for a required json field on update', () => {
447
+ const fields: Record<string, FieldConfig> = {
448
+ meta: json({ validation: { isRequired: true } }),
449
+ }
450
+
451
+ const result = validateWithZod({ meta: { a: 1 } }, fields, 'update')
452
+ expect(result.success).toBe(true)
453
+ })
454
+
455
+ it('accepts a present falsy non-null value for a required json field on update', () => {
456
+ const fields: Record<string, FieldConfig> = {
457
+ meta: json({ validation: { isRequired: true } }),
458
+ }
459
+
460
+ expect(validateWithZod({ meta: 0 }, fields, 'update').success).toBe(true)
461
+ expect(validateWithZod({ meta: '' }, fields, 'update').success).toBe(true)
462
+ expect(validateWithZod({ meta: false }, fields, 'update').success).toBe(true)
463
+ })
464
+
465
+ it('accepts present falsy non-null values for a required json field on create', () => {
466
+ const fields: Record<string, FieldConfig> = {
467
+ meta: json({ validation: { isRequired: true } }),
468
+ }
469
+
470
+ expect(validateWithZod({ meta: 0 }, fields, 'create').success).toBe(true)
471
+ expect(validateWithZod({ meta: '' }, fields, 'create').success).toBe(true)
472
+ expect(validateWithZod({ meta: false }, fields, 'create').success).toBe(true)
473
+ })
474
+
475
+ it('accepts an omitted or present null value for a non-required json field', () => {
476
+ const fields: Record<string, FieldConfig> = {
477
+ meta: json(),
478
+ label: text(),
479
+ }
480
+
481
+ expect(validateWithZod({ label: 'x' }, fields, 'create').success).toBe(true)
482
+ expect(validateWithZod({ meta: null }, fields, 'create').success).toBe(true)
483
+ expect(validateWithZod({ label: 'x' }, fields, 'update').success).toBe(true)
484
+ expect(validateWithZod({ meta: null }, fields, 'update').success).toBe(true)
485
+ })
486
+ })
222
487
  })
@@ -9,6 +9,7 @@ import {
9
9
  isPrismaFilter,
10
10
  } from '../src/access/index.js'
11
11
  import type { AccessControl, FieldAccess, AccessContext } from '../src/access/types.js'
12
+ import { ValidationError } from '../src/hooks/index.js'
12
13
 
13
14
  describe('Access Control', () => {
14
15
  const mockContext: AccessContext = {
@@ -406,7 +407,9 @@ describe('Access Control', () => {
406
407
  expect(result.name).toBe('John')
407
408
  })
408
409
 
409
- it('should filter out fields with denied write access', async () => {
410
+ // #568: a denied write field must THROW (Keystone fail-loud parity), rather
411
+ // than being silently stripped. Updated from the old silent-strip contract.
412
+ it('should throw when a field with denied write access is supplied', async () => {
410
413
  const data = {
411
414
  name: 'John',
412
415
  email: 'john@example.com',
@@ -424,16 +427,21 @@ describe('Access Control', () => {
424
427
  },
425
428
  }
426
429
 
427
- const result = await filterWritableFields(data, fieldConfigs, 'create', {
428
- session: null,
429
- context: mockContext,
430
- })
431
-
432
- expect(result.name).toBe('John')
433
- expect(result.email).toBe('john@example.com')
434
- expect(result.role).toBeUndefined()
430
+ await expect(
431
+ filterWritableFields(data, fieldConfigs, 'create', {
432
+ session: null,
433
+ context: mockContext,
434
+ }),
435
+ ).rejects.toThrow(ValidationError)
436
+ await expect(
437
+ filterWritableFields(data, fieldConfigs, 'create', {
438
+ session: null,
439
+ context: mockContext,
440
+ }),
441
+ ).rejects.toThrow(/role/)
435
442
  })
436
443
 
444
+ // #568: create-allowed / update-denied — create passes, update THROWS.
437
445
  it('should respect different access for create vs update', async () => {
438
446
  const data = {
439
447
  email: 'john@example.com',
@@ -457,13 +465,13 @@ describe('Access Control', () => {
457
465
 
458
466
  expect(createResult.email).toBe('john@example.com')
459
467
 
460
- // Deny on update
461
- const updateResult = await filterWritableFields(data, fieldConfigs, 'update', {
462
- session: null,
463
- context: mockContext,
464
- })
465
-
466
- expect(updateResult.email).toBeUndefined()
468
+ // Deny on update — now throws instead of silently dropping the field.
469
+ await expect(
470
+ filterWritableFields(data, fieldConfigs, 'update', {
471
+ session: null,
472
+ context: mockContext,
473
+ }),
474
+ ).rejects.toThrow(/email/)
467
475
  })
468
476
 
469
477
  it('should pass item to field access on update', async () => {
@@ -208,5 +208,35 @@ describe('config helpers', () => {
208
208
  expect(testList.hooks).toBeDefined()
209
209
  expect(testList.hooks?.resolveInput).toBeDefined()
210
210
  })
211
+
212
+ it('should accept ui.listView config (initialColumns + initialSort)', () => {
213
+ const testList = list({
214
+ fields: {
215
+ title: { type: 'text' },
216
+ status: { type: 'text' },
217
+ createdAt: { type: 'timestamp' },
218
+ },
219
+ ui: {
220
+ listView: {
221
+ initialColumns: ['title', 'status'],
222
+ initialSort: { field: 'createdAt', direction: 'desc' },
223
+ },
224
+ },
225
+ })
226
+
227
+ expect(testList.ui?.listView?.initialColumns).toEqual(['title', 'status'])
228
+ expect(testList.ui?.listView?.initialSort).toEqual({
229
+ field: 'createdAt',
230
+ direction: 'desc',
231
+ })
232
+ })
233
+
234
+ it('should leave ui undefined when not configured', () => {
235
+ const testList = list({
236
+ fields: { title: { type: 'text' } },
237
+ })
238
+
239
+ expect(testList.ui).toBeUndefined()
240
+ })
211
241
  })
212
242
  })