@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +256 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +378 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +153 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +552 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/index.ts +2 -0
- package/src/config/types.ts +426 -0
- package/src/context/index.ts +207 -37
- package/src/context/nested-operations.ts +969 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/config.test.ts +30 -0
- package/tests/context.test.ts +481 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
})
|
package/tests/access.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
expect(
|
|
434
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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 () => {
|
package/tests/config.test.ts
CHANGED
|
@@ -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
|
})
|