@opensaas/stack-core 0.30.0 → 0.31.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 +410 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +14 -1
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +50 -0
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/field-access.js +6 -6
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +46 -12
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.d.ts +60 -0
- package/dist/access/relationship-count.d.ts.map +1 -0
- package/dist/access/relationship-count.js +233 -0
- package/dist/access/relationship-count.js.map +1 -0
- package/dist/access/relationship-count.test.d.ts +2 -0
- package/dist/access/relationship-count.test.d.ts.map +1 -0
- package/dist/access/relationship-count.test.js +185 -0
- package/dist/access/relationship-count.test.js.map +1 -0
- package/dist/access/relationship-label-filter.d.ts +45 -0
- package/dist/access/relationship-label-filter.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.js +93 -0
- package/dist/access/relationship-label-filter.js.map +1 -0
- package/dist/access/relationship-label-filter.test.d.ts +2 -0
- package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.test.js +126 -0
- package/dist/access/relationship-label-filter.test.js.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/nav-count.d.ts +34 -0
- package/dist/config/nav-count.d.ts.map +1 -0
- package/dist/config/nav-count.js +71 -0
- package/dist/config/nav-count.js.map +1 -0
- package/dist/config/types.d.ts +305 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +11 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +28 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +205 -0
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +7 -1
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/extend.d.ts +1 -0
- package/dist/extend.d.ts.map +1 -1
- package/dist/fields/index.d.ts +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +166 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +32 -0
- package/dist/filter/collect.d.ts.map +1 -0
- package/dist/filter/collect.js +60 -0
- package/dist/filter/collect.js.map +1 -0
- package/dist/filter/filter.test.d.ts +2 -0
- package/dist/filter/filter.test.d.ts.map +1 -0
- package/dist/filter/filter.test.js +348 -0
- package/dist/filter/filter.test.js.map +1 -0
- package/dist/filter/index.d.ts +7 -0
- package/dist/filter/index.d.ts.map +1 -0
- package/dist/filter/index.js +16 -0
- package/dist/filter/index.js.map +1 -0
- package/dist/filter/map.d.ts +18 -0
- package/dist/filter/map.d.ts.map +1 -0
- package/dist/filter/map.js +60 -0
- package/dist/filter/map.js.map +1 -0
- package/dist/filter/parse.d.ts +33 -0
- package/dist/filter/parse.d.ts.map +1 -0
- package/dist/filter/parse.js +103 -0
- package/dist/filter/parse.js.map +1 -0
- package/dist/filter/serialize.d.ts +25 -0
- package/dist/filter/serialize.d.ts.map +1 -0
- package/dist/filter/serialize.js +68 -0
- package/dist/filter/serialize.js.map +1 -0
- package/dist/filter/serialize.test.d.ts +2 -0
- package/dist/filter/serialize.test.d.ts.map +1 -0
- package/dist/filter/serialize.test.js +83 -0
- package/dist/filter/serialize.test.js.map +1 -0
- package/dist/filter/types.d.ts +122 -0
- package/dist/filter/types.d.ts.map +1 -0
- package/dist/filter/types.js +24 -0
- package/dist/filter/types.js.map +1 -0
- package/dist/hooks/index.d.ts +30 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +79 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +79 -0
- package/src/access/access-filter.ts +15 -4
- package/src/access/field-access.ts +6 -6
- package/src/access/multi-column-read-write.test.ts +68 -19
- package/src/access/relationship-count.test.ts +269 -0
- package/src/access/relationship-count.ts +311 -0
- package/src/access/relationship-label-filter.test.ts +177 -0
- package/src/access/relationship-label-filter.ts +140 -0
- package/src/config/index.ts +6 -0
- package/src/config/nav-count.ts +85 -0
- package/src/config/types.ts +319 -1
- package/src/context/hook-pipeline.ts +19 -0
- package/src/context/index.ts +285 -3
- package/src/context/nested-operations.ts +22 -0
- package/src/extend.ts +9 -0
- package/src/fields/index.ts +168 -0
- package/src/filter/collect.ts +72 -0
- package/src/filter/filter.test.ts +400 -0
- package/src/filter/index.ts +25 -0
- package/src/filter/map.ts +68 -0
- package/src/filter/parse.ts +106 -0
- package/src/filter/serialize.test.ts +115 -0
- package/src/filter/serialize.ts +75 -0
- package/src/filter/types.ts +120 -0
- package/src/hooks/index.ts +87 -57
- package/src/index.ts +54 -0
- package/src/internal.ts +5 -0
- package/tests/context.test.ts +690 -0
- package/tests/multi-column-validation-ordering.test.ts +305 -0
- package/tests/nav-count.test.ts +189 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/context/index.ts
CHANGED
|
@@ -24,6 +24,61 @@ export type ServerActionProps =
|
|
|
24
24
|
| { listKey: string; action: 'create'; data: Record<string, unknown> }
|
|
25
25
|
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
|
|
26
26
|
| { listKey: string; action: 'delete'; id: string }
|
|
27
|
+
| { listKey: string; action: 'bulkDelete'; ids: string[] }
|
|
28
|
+
// Custom list-specific Bulk action (issue #736). `key` names an action
|
|
29
|
+
// declared in the list's `ui.listView.bulkActions`; the client only ever
|
|
30
|
+
// sends this serialisable `{ key, ids }` — the server-side `handler`
|
|
31
|
+
// (never serialised) is looked up by `key` and run with the rebuilt secured
|
|
32
|
+
// context over `ids`. Returns a distinct `{ bulkAction, message? }` shape so
|
|
33
|
+
// a redirect-on-`success` wrapper never hijacks it (same rationale as
|
|
34
|
+
// `bulkDelete`).
|
|
35
|
+
| { listKey: string; action: 'bulkAction'; key: string; ids: string[] }
|
|
36
|
+
// Relationship-table row removal (ADR-0018, #739). `listKey`/`id` target the
|
|
37
|
+
// RELATED row, so the related list's own access control + hooks apply (never
|
|
38
|
+
// the parent's). `mode: 'disconnect'` unlinks the row non-destructively by
|
|
39
|
+
// disconnecting its back-reference (`field`; `parentId` is the record being
|
|
40
|
+
// edited, needed only when that back-reference is to-many, e.g. a
|
|
41
|
+
// many-to-many join); `mode: 'delete'` truly deletes the row. Like
|
|
42
|
+
// `bulkDelete`, it returns a distinct `{ removed }` shape (never a
|
|
43
|
+
// single-op `success`) so a UI wrapper that redirects on `success` — the
|
|
44
|
+
// item-form pattern — does not hijack an in-place row removal.
|
|
45
|
+
| {
|
|
46
|
+
listKey: string
|
|
47
|
+
action: 'removeRelated'
|
|
48
|
+
mode: 'disconnect' | 'delete'
|
|
49
|
+
id: string
|
|
50
|
+
field?: string
|
|
51
|
+
parentId?: string
|
|
52
|
+
}
|
|
53
|
+
// Relationship-table inline cell edit (issue #737). `listKey`/`id` target the
|
|
54
|
+
// RELATED row and `field`/`value` a single scalar field on it, so the update
|
|
55
|
+
// runs through the related list's OWN operation- and field-level access +
|
|
56
|
+
// hooks/validation (never the parent's) — the same ADR-0018 boundary as
|
|
57
|
+
// `removeRelated`. It returns a distinct `{ updated }` shape (never a
|
|
58
|
+
// single-op `success`) so a UI wrapper that redirects on `success` — the
|
|
59
|
+
// item-form pattern — cannot hijack an in-place cell edit.
|
|
60
|
+
| {
|
|
61
|
+
listKey: string
|
|
62
|
+
action: 'updateRelated'
|
|
63
|
+
id: string
|
|
64
|
+
field: string
|
|
65
|
+
value: unknown
|
|
66
|
+
}
|
|
67
|
+
// Relationship-table pre-linked create (issue #738). `listKey` targets the
|
|
68
|
+
// RELATED list, so the related list's own create access control + hooks apply
|
|
69
|
+
// (never the parent's) — the same ADR-0018 boundary as `removeRelated`. The
|
|
70
|
+
// back-reference to the parent is set on the SERVER from `field`/`parentId`
|
|
71
|
+
// (never trusted from `data`), so the new row is linked to exactly the parent
|
|
72
|
+
// being edited. It returns a distinct `{ created }` shape (never a single-op
|
|
73
|
+
// `success`) so a UI wrapper that redirects on `success` — the item-form
|
|
74
|
+
// pattern — does not hijack an in-place create.
|
|
75
|
+
| {
|
|
76
|
+
listKey: string
|
|
77
|
+
action: 'createRelated'
|
|
78
|
+
data: Record<string, unknown>
|
|
79
|
+
field?: string
|
|
80
|
+
parentId?: string
|
|
81
|
+
}
|
|
27
82
|
| {
|
|
28
83
|
listKey: string
|
|
29
84
|
action: 'relationshipOptions'
|
|
@@ -409,11 +464,33 @@ export function getContext<
|
|
|
409
464
|
|
|
410
465
|
// Generic server action handler with discriminated union for type safety
|
|
411
466
|
// Returns a result object instead of throwing to work properly in Next.js production
|
|
412
|
-
async function serverAction(
|
|
413
|
-
props: ServerActionProps,
|
|
414
|
-
): Promise<
|
|
467
|
+
async function serverAction(props: ServerActionProps): Promise<
|
|
415
468
|
| { success: true; data: unknown }
|
|
416
469
|
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
470
|
+
// Bulk actions report a count rather than a single-op `success` flag: the
|
|
471
|
+
// shape is deliberately distinct so a UI wrapper that redirects on a
|
|
472
|
+
// single-item `success` (the item-form pattern) does not hijack a
|
|
473
|
+
// list-level bulk operation.
|
|
474
|
+
| { deleted: number; total: number }
|
|
475
|
+
// Relationship-table row removal reports `removed` (with an optional reason)
|
|
476
|
+
// rather than `success` — same distinct-shape rationale as `bulkDelete`, so
|
|
477
|
+
// an in-place removal never triggers a redirect-on-success wrapper.
|
|
478
|
+
| { removed: boolean; error?: string }
|
|
479
|
+
// Relationship-table pre-linked create reports `created` (with the new row's
|
|
480
|
+
// id, or an error + fieldErrors for the drawer) rather than `success` — same
|
|
481
|
+
// distinct-shape rationale, so an in-place create never triggers a
|
|
482
|
+
// redirect-on-success wrapper.
|
|
483
|
+
| { created: boolean; id?: string; error?: string; fieldErrors?: Record<string, string> }
|
|
484
|
+
// Custom Bulk action (issue #736) reports `bulkAction` with the handler's
|
|
485
|
+
// optional `message` (success) or an `error` (not found / denied / threw) —
|
|
486
|
+
// again a distinct shape from single-op `success`.
|
|
487
|
+
| { bulkAction: true; message?: string }
|
|
488
|
+
| { bulkAction: false; error: string }
|
|
489
|
+
// Relationship-table inline cell edit reports `updated` (with an optional
|
|
490
|
+
// reason + fieldErrors for the edited cell) rather than `success` — same
|
|
491
|
+
// distinct-shape rationale, so an in-place cell edit never triggers a
|
|
492
|
+
// redirect-on-success wrapper.
|
|
493
|
+
| { updated: boolean; error?: string; fieldErrors?: Record<string, string> }
|
|
417
494
|
> {
|
|
418
495
|
const dbKey = getDbKey(props.listKey)
|
|
419
496
|
const listConfig = config.lists[props.listKey]
|
|
@@ -431,6 +508,211 @@ export function getContext<
|
|
|
431
508
|
delete: (args: { where: { id: string } }) => Promise<unknown>
|
|
432
509
|
}
|
|
433
510
|
|
|
511
|
+
// Bulk delete: remove each id row-by-row through the secured context,
|
|
512
|
+
// honouring Silent failure — a denied (or missing) row returns `null` and is
|
|
513
|
+
// simply not counted. Returns "N of M" so partial denials are visible without
|
|
514
|
+
// revealing which rows were denied or why. One row's error never aborts the
|
|
515
|
+
// rest.
|
|
516
|
+
if (props.action === 'bulkDelete') {
|
|
517
|
+
let deleted = 0
|
|
518
|
+
for (const id of props.ids) {
|
|
519
|
+
try {
|
|
520
|
+
const result = await model.delete({ where: { id } })
|
|
521
|
+
if (result !== null && result !== undefined) deleted++
|
|
522
|
+
} catch {
|
|
523
|
+
// Skip this row (e.g. a DB constraint error); it is not counted.
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return { deleted, total: props.ids.length }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Custom Bulk action (issue #736). Look the declared action up by `key` and
|
|
530
|
+
// run its server-side `handler` over the selected ids with THIS secured
|
|
531
|
+
// context — the handler does its own row-by-row work through `context.db`,
|
|
532
|
+
// so per-id access control and hooks apply and denials stay Silent. The
|
|
533
|
+
// `hasAccess` visibility gate (if any) is re-checked here so a hidden action
|
|
534
|
+
// can never be invoked by a hand-crafted request; the client only ever sends
|
|
535
|
+
// the serialisable `{ key, ids }`, never the handler itself.
|
|
536
|
+
if (props.action === 'bulkAction') {
|
|
537
|
+
const bulkActions = listConfig.ui?.listView?.bulkActions ?? []
|
|
538
|
+
const action = bulkActions.find((a) => a.key === props.key)
|
|
539
|
+
if (!action) {
|
|
540
|
+
return {
|
|
541
|
+
bulkAction: false,
|
|
542
|
+
error: `Bulk action "${props.key}" not found on list "${props.listKey}"`,
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (action.hasAccess) {
|
|
546
|
+
const allowed = await action.hasAccess({
|
|
547
|
+
session: context.session,
|
|
548
|
+
context,
|
|
549
|
+
listKey: props.listKey,
|
|
550
|
+
})
|
|
551
|
+
if (!allowed) {
|
|
552
|
+
return { bulkAction: false, error: 'Access denied' }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const result = await action.handler({
|
|
557
|
+
listKey: props.listKey,
|
|
558
|
+
ids: props.ids,
|
|
559
|
+
context,
|
|
560
|
+
})
|
|
561
|
+
return { bulkAction: true, message: result?.message }
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (error instanceof ValidationError || error instanceof DatabaseError) {
|
|
564
|
+
return { bulkAction: false, error: error.message }
|
|
565
|
+
}
|
|
566
|
+
const dbError = parsePrismaError(error, listConfig)
|
|
567
|
+
// A recognised Prisma error carries a user-safe, translated message.
|
|
568
|
+
if (dbError instanceof DatabaseError) {
|
|
569
|
+
return { bulkAction: false, error: dbError.message }
|
|
570
|
+
}
|
|
571
|
+
// Anything else is an unexpected handler bug whose raw `.message` could
|
|
572
|
+
// leak internal detail to the client — log it server-side and return a
|
|
573
|
+
// generic client-facing message instead.
|
|
574
|
+
console.error(`Bulk action "${props.key}" on list "${props.listKey}" failed:`, error)
|
|
575
|
+
return { bulkAction: false, error: 'Action failed' }
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Relationship-table row removal (ADR-0018, #739). Runs on the RELATED row
|
|
580
|
+
// through the secured context, so the related list's access + hooks apply.
|
|
581
|
+
// Honours Silent failure: an access-denied operation returns `null`, which
|
|
582
|
+
// becomes `{ removed: false }` with a generic reason — never leaking whether
|
|
583
|
+
// the row was denied or absent.
|
|
584
|
+
if (props.action === 'removeRelated') {
|
|
585
|
+
try {
|
|
586
|
+
let result: unknown = null
|
|
587
|
+
if (props.mode === 'delete') {
|
|
588
|
+
result = await model.delete({ where: { id: props.id } })
|
|
589
|
+
} else {
|
|
590
|
+
// Disconnect: an UPDATE on the related list nulling its back-reference,
|
|
591
|
+
// never a delete — the row itself survives. A to-one back-reference
|
|
592
|
+
// disconnects with `true`; a to-many back-reference (many-to-many)
|
|
593
|
+
// disconnects the specific parent by id.
|
|
594
|
+
if (!props.field) {
|
|
595
|
+
return { removed: false, error: 'Missing back-reference field for disconnect' }
|
|
596
|
+
}
|
|
597
|
+
const backRefField = listConfig.fields[props.field]
|
|
598
|
+
const backRefIsMany =
|
|
599
|
+
!!backRefField && 'many' in backRefField && backRefField.many === true
|
|
600
|
+
const disconnectValue = backRefIsMany
|
|
601
|
+
? { disconnect: { id: props.parentId } }
|
|
602
|
+
: { disconnect: true }
|
|
603
|
+
result = await model.update({
|
|
604
|
+
where: { id: props.id },
|
|
605
|
+
data: { [props.field]: disconnectValue },
|
|
606
|
+
})
|
|
607
|
+
}
|
|
608
|
+
if (result === null || result === undefined) {
|
|
609
|
+
return { removed: false, error: 'Access denied or operation failed' }
|
|
610
|
+
}
|
|
611
|
+
return { removed: true }
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (error instanceof ValidationError || error instanceof DatabaseError) {
|
|
614
|
+
return { removed: false, error: error.message }
|
|
615
|
+
}
|
|
616
|
+
const dbError = parsePrismaError(error, listConfig)
|
|
617
|
+
return { removed: false, error: dbError.message }
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Relationship-table pre-linked create (ADR-0018, #738). Creates a row on
|
|
622
|
+
// the RELATED list through the secured context, so the related list's create
|
|
623
|
+
// access + hooks (and field-level access) apply — never the parent's. The
|
|
624
|
+
// back-reference to the parent is set here from `field`/`parentId` (a to-one
|
|
625
|
+
// back-ref connects a single parent; a to-many back-ref, e.g. many-to-many,
|
|
626
|
+
// connects the parent by id), so the client can never re-target the link.
|
|
627
|
+
// Honours Silent failure: an access-denied create returns `null`, surfaced
|
|
628
|
+
// as `{ created: false }` with a generic reason (no denied-vs-absent leak).
|
|
629
|
+
if (props.action === 'createRelated') {
|
|
630
|
+
try {
|
|
631
|
+
// Defensive guard (hardening; unreachable from the drawer, which always
|
|
632
|
+
// mounts on a valid back-reference and sends BOTH field and parentId).
|
|
633
|
+
// A lone field/parentId is a malformed direct call — reject it rather
|
|
634
|
+
// than silently skip back-reference injection and let a client-supplied
|
|
635
|
+
// data[field] slip through unguarded to the create.
|
|
636
|
+
if (!!props.field !== !!props.parentId) {
|
|
637
|
+
return {
|
|
638
|
+
created: false,
|
|
639
|
+
error: 'createRelated requires both field and parentId, or neither',
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const data: Record<string, unknown> = { ...props.data }
|
|
643
|
+
if (props.field && props.parentId) {
|
|
644
|
+
const backRefField = listConfig.fields[props.field]
|
|
645
|
+
// The back-reference must name a relationship field on this list; a
|
|
646
|
+
// non-relationship field would otherwise receive a nonsensical
|
|
647
|
+
// { connect } value. Also hardening — the drawer only ever passes a
|
|
648
|
+
// real relationship back-reference here.
|
|
649
|
+
if (!backRefField || backRefField.type !== 'relationship') {
|
|
650
|
+
return {
|
|
651
|
+
created: false,
|
|
652
|
+
error: `Field "${props.field}" on list "${props.listKey}" is not a relationship field`,
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const backRefIsMany = 'many' in backRefField && backRefField.many === true
|
|
656
|
+
// The back-reference is set on the SERVER from the trusted parentId,
|
|
657
|
+
// OVERWRITING any client-supplied data[field] spread in above, so a
|
|
658
|
+
// hostile client can never re-target the link (a to-many back-ref
|
|
659
|
+
// connects the parent by id).
|
|
660
|
+
data[props.field] = backRefIsMany
|
|
661
|
+
? { connect: [{ id: props.parentId }] }
|
|
662
|
+
: { connect: { id: props.parentId } }
|
|
663
|
+
}
|
|
664
|
+
const result = await model.create({ data })
|
|
665
|
+
if (result === null || result === undefined) {
|
|
666
|
+
return { created: false, error: 'Access denied or operation failed' }
|
|
667
|
+
}
|
|
668
|
+
const id =
|
|
669
|
+
typeof result === 'object' && result !== null && 'id' in result
|
|
670
|
+
? String((result as { id: unknown }).id)
|
|
671
|
+
: undefined
|
|
672
|
+
return { created: true, id }
|
|
673
|
+
} catch (error) {
|
|
674
|
+
if (error instanceof ValidationError || error instanceof DatabaseError) {
|
|
675
|
+
return { created: false, error: error.message, fieldErrors: error.fieldErrors }
|
|
676
|
+
}
|
|
677
|
+
const dbError = parsePrismaError(error, listConfig)
|
|
678
|
+
return {
|
|
679
|
+
created: false,
|
|
680
|
+
error: dbError.message,
|
|
681
|
+
fieldErrors: dbError instanceof DatabaseError ? dbError.fieldErrors : undefined,
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Relationship-table inline cell edit (ADR-0018, #737). Updates ONE scalar
|
|
687
|
+
// field on the RELATED row through the secured context, so the related list's
|
|
688
|
+
// operation- and field-level update access plus its hooks/validation apply —
|
|
689
|
+
// never the parent's. Honours Silent failure: an access-denied update returns
|
|
690
|
+
// `null`, surfaced as `{ updated: false }` with a generic reason (no
|
|
691
|
+
// denied-vs-absent leak); a validation/db error surfaces its message and
|
|
692
|
+
// fieldErrors so the cell can revert with a reason and show an inline error.
|
|
693
|
+
if (props.action === 'updateRelated') {
|
|
694
|
+
try {
|
|
695
|
+
const result = await model.update({
|
|
696
|
+
where: { id: props.id },
|
|
697
|
+
data: { [props.field]: props.value },
|
|
698
|
+
})
|
|
699
|
+
if (result === null || result === undefined) {
|
|
700
|
+
return { updated: false, error: 'Access denied or operation failed' }
|
|
701
|
+
}
|
|
702
|
+
return { updated: true }
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if (error instanceof ValidationError || error instanceof DatabaseError) {
|
|
705
|
+
return { updated: false, error: error.message, fieldErrors: error.fieldErrors }
|
|
706
|
+
}
|
|
707
|
+
const dbError = parsePrismaError(error, listConfig)
|
|
708
|
+
return {
|
|
709
|
+
updated: false,
|
|
710
|
+
error: dbError.message,
|
|
711
|
+
fieldErrors: dbError instanceof DatabaseError ? dbError.fieldErrors : undefined,
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
434
716
|
try {
|
|
435
717
|
if (props.action === 'relationshipOptions') {
|
|
436
718
|
const fieldConfig = listConfig.fields[props.field] as
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
executeFieldAfterOperationHooks,
|
|
13
13
|
executeFieldValidateHooks,
|
|
14
14
|
validateFieldRules,
|
|
15
|
+
splitMultiColumnFields,
|
|
15
16
|
ValidationError,
|
|
16
17
|
} from '../hooks/index.js'
|
|
17
18
|
import { getDbKey } from '../lib/case-utils.js'
|
|
@@ -306,6 +307,16 @@ async function processNestedCreate(
|
|
|
306
307
|
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
307
308
|
}
|
|
308
309
|
|
|
310
|
+
// 5.5 Split multi-column fields into physical columns (#789). Only
|
|
311
|
+
// reached once the logical value has passed validation above.
|
|
312
|
+
resolvedData = await splitMultiColumnFields(
|
|
313
|
+
item,
|
|
314
|
+
resolvedData,
|
|
315
|
+
relatedListConfig.fields,
|
|
316
|
+
'create',
|
|
317
|
+
context,
|
|
318
|
+
)
|
|
319
|
+
|
|
309
320
|
// 6. Filter writable fields
|
|
310
321
|
const filtered = await filterWritableFields(
|
|
311
322
|
resolvedData,
|
|
@@ -645,6 +656,17 @@ async function processNestedUpdate(
|
|
|
645
656
|
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
646
657
|
}
|
|
647
658
|
|
|
659
|
+
// Split multi-column fields into physical columns (#789). Only reached
|
|
660
|
+
// once the logical value has passed validation above.
|
|
661
|
+
resolvedData = await splitMultiColumnFields(
|
|
662
|
+
updateData,
|
|
663
|
+
resolvedData,
|
|
664
|
+
relatedListConfig.fields,
|
|
665
|
+
'update',
|
|
666
|
+
context,
|
|
667
|
+
originalItem,
|
|
668
|
+
)
|
|
669
|
+
|
|
648
670
|
// Filter writable fields
|
|
649
671
|
const filtered = await filterWritableFields(
|
|
650
672
|
resolvedData,
|
package/src/extend.ts
CHANGED
|
@@ -17,3 +17,12 @@ export type {
|
|
|
17
17
|
TypeDescriptor,
|
|
18
18
|
MultiColumnPrismaResult,
|
|
19
19
|
} from './config/index.js'
|
|
20
|
+
|
|
21
|
+
// Filter spec authoring — a field's optional `getFilterSpec` returns these
|
|
22
|
+
// (ADR-0017). Additive: a field without one is simply not filterable.
|
|
23
|
+
export type {
|
|
24
|
+
FilterSpec,
|
|
25
|
+
FilterOperator,
|
|
26
|
+
FilterCondition,
|
|
27
|
+
FilterValueSource,
|
|
28
|
+
} from './filter/index.js'
|
package/src/fields/index.ts
CHANGED
|
@@ -17,6 +17,20 @@ import type {
|
|
|
17
17
|
} from '../config/types.js'
|
|
18
18
|
import { hashPassword, isHashedPassword, HashedPassword } from '../utils/password.js'
|
|
19
19
|
import { formatPrismaDefault } from './format-prisma-default.js'
|
|
20
|
+
import { getLabelFieldName } from '../config/label.js'
|
|
21
|
+
import type { FilterOperator, FilterSpec } from '../filter/types.js'
|
|
22
|
+
import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Operators shared by numeric/date fields: plain equality plus the four
|
|
26
|
+
* comparisons. `eq` maps to Prisma's `equals`; the comparisons pass through.
|
|
27
|
+
*/
|
|
28
|
+
const COMPARISON_OPERATORS: FilterOperator[] = ['eq', 'gt', 'gte', 'lt', 'lte']
|
|
29
|
+
|
|
30
|
+
/** Map a Filter operator to its Prisma condition key (`eq` → `equals`). */
|
|
31
|
+
function prismaComparisonKey(operator: FilterOperator): string {
|
|
32
|
+
return operator === 'eq' ? 'equals' : operator
|
|
33
|
+
}
|
|
20
34
|
|
|
21
35
|
// Field-config types live here, alongside the builders that produce them.
|
|
22
36
|
// (The umbrella `FieldConfig` and authoring `BaseFieldConfig` stay on the root
|
|
@@ -30,6 +44,8 @@ export type {
|
|
|
30
44
|
CalendarDayField,
|
|
31
45
|
PasswordField,
|
|
32
46
|
SelectField,
|
|
47
|
+
SelectOption,
|
|
48
|
+
SelectOptionVariant,
|
|
33
49
|
RelationshipField,
|
|
34
50
|
JsonField,
|
|
35
51
|
VirtualField,
|
|
@@ -154,6 +170,16 @@ export function text<
|
|
|
154
170
|
optional: !isRequired,
|
|
155
171
|
}
|
|
156
172
|
},
|
|
173
|
+
// Text fields drive free-text search: a bare word (or a `field:value`) maps
|
|
174
|
+
// to a case-preserving `contains`. This is what replaces the old hard-coded
|
|
175
|
+
// `type === 'text'` search in the admin list view.
|
|
176
|
+
getFilterSpec: (fieldName: string): FilterSpec => ({
|
|
177
|
+
operators: ['eq'],
|
|
178
|
+
freeText: true,
|
|
179
|
+
toCondition: (operator, value) =>
|
|
180
|
+
operator === 'eq' ? { [fieldName]: { contains: value } } : null,
|
|
181
|
+
suggestions: { valueSource: { kind: 'none' } },
|
|
182
|
+
}),
|
|
157
183
|
}
|
|
158
184
|
}
|
|
159
185
|
|
|
@@ -231,6 +257,17 @@ export function integer<
|
|
|
231
257
|
optional: !isRequired,
|
|
232
258
|
}
|
|
233
259
|
},
|
|
260
|
+
// Integers support equality and comparisons (`orders:>5`). A non-integer
|
|
261
|
+
// value can't be interpreted, so its token degrades to free text.
|
|
262
|
+
getFilterSpec: (fieldName: string): FilterSpec => ({
|
|
263
|
+
operators: COMPARISON_OPERATORS,
|
|
264
|
+
toCondition: (operator, value) => {
|
|
265
|
+
const trimmed = value.trim()
|
|
266
|
+
if (!/^-?\d+$/.test(trimmed)) return null
|
|
267
|
+
return { [fieldName]: { [prismaComparisonKey(operator)]: Number(trimmed) } }
|
|
268
|
+
},
|
|
269
|
+
suggestions: { valueSource: { kind: 'none' } },
|
|
270
|
+
}),
|
|
234
271
|
}
|
|
235
272
|
}
|
|
236
273
|
|
|
@@ -397,6 +434,17 @@ export function decimal<
|
|
|
397
434
|
},
|
|
398
435
|
]
|
|
399
436
|
},
|
|
437
|
+
// Decimals compare like integers, but the value stays a string so Prisma's
|
|
438
|
+
// Decimal keeps full precision. A non-numeric value degrades to free text.
|
|
439
|
+
getFilterSpec: (fieldName: string): FilterSpec => ({
|
|
440
|
+
operators: COMPARISON_OPERATORS,
|
|
441
|
+
toCondition: (operator, value) => {
|
|
442
|
+
const trimmed = value.trim()
|
|
443
|
+
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return null
|
|
444
|
+
return { [fieldName]: { [prismaComparisonKey(operator)]: trimmed } }
|
|
445
|
+
},
|
|
446
|
+
suggestions: { valueSource: { kind: 'none' } },
|
|
447
|
+
}),
|
|
400
448
|
}
|
|
401
449
|
}
|
|
402
450
|
|
|
@@ -443,6 +491,27 @@ export function checkbox<
|
|
|
443
491
|
optional: options?.defaultValue === undefined,
|
|
444
492
|
}
|
|
445
493
|
},
|
|
494
|
+
// Checkboxes filter by equality against the two enumerated values. Anything
|
|
495
|
+
// other than true/false can't be interpreted and degrades to free text.
|
|
496
|
+
getFilterSpec: (fieldName: string): FilterSpec => ({
|
|
497
|
+
operators: ['eq'],
|
|
498
|
+
toCondition: (operator, value) => {
|
|
499
|
+
if (operator !== 'eq') return null
|
|
500
|
+
const normalized = value.trim().toLowerCase()
|
|
501
|
+
if (normalized === 'true') return { [fieldName]: { equals: true } }
|
|
502
|
+
if (normalized === 'false') return { [fieldName]: { equals: false } }
|
|
503
|
+
return null
|
|
504
|
+
},
|
|
505
|
+
suggestions: {
|
|
506
|
+
valueSource: {
|
|
507
|
+
kind: 'enum',
|
|
508
|
+
options: [
|
|
509
|
+
{ value: 'true', label: 'True' },
|
|
510
|
+
{ value: 'false', label: 'False' },
|
|
511
|
+
],
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
}),
|
|
446
515
|
}
|
|
447
516
|
}
|
|
448
517
|
|
|
@@ -508,6 +577,17 @@ export function timestamp<
|
|
|
508
577
|
optional: !hasDefault,
|
|
509
578
|
}
|
|
510
579
|
},
|
|
580
|
+
// Timestamps support equality and comparisons (`joined:>2024-01-01`). An
|
|
581
|
+
// unparseable date degrades to free text.
|
|
582
|
+
getFilterSpec: (fieldName: string): FilterSpec => ({
|
|
583
|
+
operators: COMPARISON_OPERATORS,
|
|
584
|
+
toCondition: (operator, value) => {
|
|
585
|
+
const date = new Date(value.trim())
|
|
586
|
+
if (Number.isNaN(date.getTime())) return null
|
|
587
|
+
return { [fieldName]: { [prismaComparisonKey(operator)]: date } }
|
|
588
|
+
},
|
|
589
|
+
suggestions: { valueSource: { kind: 'none' } },
|
|
590
|
+
}),
|
|
511
591
|
}
|
|
512
592
|
}
|
|
513
593
|
|
|
@@ -696,6 +776,20 @@ export function calendarDay<
|
|
|
696
776
|
optional: isNullable,
|
|
697
777
|
}
|
|
698
778
|
},
|
|
779
|
+
// Calendar days compare on the `YYYY-MM-DD` value (coerced to a UTC-midnight
|
|
780
|
+
// Date so it matches the `@db.Date` column). A malformed value degrades to
|
|
781
|
+
// free text.
|
|
782
|
+
getFilterSpec: (fieldName: string): FilterSpec => ({
|
|
783
|
+
operators: COMPARISON_OPERATORS,
|
|
784
|
+
toCondition: (operator, value) => {
|
|
785
|
+
const trimmed = value.trim()
|
|
786
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return null
|
|
787
|
+
const date = new Date(`${trimmed}T00:00:00.000Z`)
|
|
788
|
+
if (Number.isNaN(date.getTime())) return null
|
|
789
|
+
return { [fieldName]: { [prismaComparisonKey(operator)]: date } }
|
|
790
|
+
},
|
|
791
|
+
suggestions: { valueSource: { kind: 'none' } },
|
|
792
|
+
}),
|
|
699
793
|
}
|
|
700
794
|
}
|
|
701
795
|
|
|
@@ -1014,6 +1108,28 @@ export function select<
|
|
|
1014
1108
|
optional: !options.validation?.isRequired || options.defaultValue !== undefined,
|
|
1015
1109
|
}
|
|
1016
1110
|
},
|
|
1111
|
+
// Selects filter by equality against their enumerated options. The token
|
|
1112
|
+
// value is matched (case-insensitively) against option value or label and
|
|
1113
|
+
// resolved to the canonical stored value; an unknown option degrades to
|
|
1114
|
+
// free text.
|
|
1115
|
+
getFilterSpec: (fieldName: string): FilterSpec => ({
|
|
1116
|
+
operators: ['eq'],
|
|
1117
|
+
toCondition: (operator, value) => {
|
|
1118
|
+
if (operator !== 'eq') return null
|
|
1119
|
+
const needle = value.trim().toLowerCase()
|
|
1120
|
+
const match = options.options.find(
|
|
1121
|
+
(opt) => opt.value.toLowerCase() === needle || opt.label.toLowerCase() === needle,
|
|
1122
|
+
)
|
|
1123
|
+
if (!match) return null
|
|
1124
|
+
return { [fieldName]: { equals: match.value } }
|
|
1125
|
+
},
|
|
1126
|
+
suggestions: {
|
|
1127
|
+
valueSource: {
|
|
1128
|
+
kind: 'enum',
|
|
1129
|
+
options: options.options.map((opt) => ({ value: opt.value, label: opt.label })),
|
|
1130
|
+
},
|
|
1131
|
+
},
|
|
1132
|
+
}),
|
|
1017
1133
|
}
|
|
1018
1134
|
}
|
|
1019
1135
|
|
|
@@ -1344,6 +1460,58 @@ export function relationship<
|
|
|
1344
1460
|
config: OpenSaasConfig,
|
|
1345
1461
|
) => getPrismaRelation(field as RelationshipField, fieldName, listKey, config)
|
|
1346
1462
|
|
|
1463
|
+
// Relationships filter differently by cardinality (issue #732):
|
|
1464
|
+
// • to-one filters by the related Item's label — `author:"Ada Lovelace"`
|
|
1465
|
+
// becomes a nested `is` `contains` on the target list's Label field.
|
|
1466
|
+
// • to-many filters by the access-visible related COUNT with numeric
|
|
1467
|
+
// comparisons — `orders:>5`. Prisma cannot compare a relation count in a
|
|
1468
|
+
// `where`, so the mapper emits a structured count marker
|
|
1469
|
+
// (RELATIONSHIP_COUNT_FILTER_KEY) that `resolveRelationshipCountFilters`
|
|
1470
|
+
// later turns into an access-scoped `{ id: { in } }`.
|
|
1471
|
+
// The mapper stays pure in both cases (no DB lookup). Suggestions point a
|
|
1472
|
+
// to-one at the target list's label lookup; a to-many exposes no value source
|
|
1473
|
+
// (a numeric compare, like an integer field).
|
|
1474
|
+
field.getFilterSpec = (
|
|
1475
|
+
_fieldName: string,
|
|
1476
|
+
_listKey: string,
|
|
1477
|
+
config: OpenSaasConfig,
|
|
1478
|
+
): FilterSpec | undefined => {
|
|
1479
|
+
const { list: targetList } = parseRelationshipRef(field.ref)
|
|
1480
|
+
const relatedListConfig = config.lists[targetList]
|
|
1481
|
+
if (!relatedListConfig) return undefined
|
|
1482
|
+
|
|
1483
|
+
if (field.many === true) {
|
|
1484
|
+
return {
|
|
1485
|
+
operators: COMPARISON_OPERATORS,
|
|
1486
|
+
toCondition: (operator, value) => {
|
|
1487
|
+
const trimmed = value.trim()
|
|
1488
|
+
// A non-integer count comparison can't be interpreted → degrade to
|
|
1489
|
+
// free text (matching the integer field's behaviour).
|
|
1490
|
+
if (!/^-?\d+$/.test(trimmed)) return null
|
|
1491
|
+
return {
|
|
1492
|
+
[_fieldName]: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator, value: Number(trimmed) } },
|
|
1493
|
+
}
|
|
1494
|
+
},
|
|
1495
|
+
suggestions: { valueSource: { kind: 'none' } },
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const labelField = getLabelFieldName(relatedListConfig)
|
|
1500
|
+
const labelFieldConfig = relatedListConfig.fields[labelField]
|
|
1501
|
+
// A virtual label field has no queryable column, so `contains` can't run
|
|
1502
|
+
// against it — the relationship is then not filterable.
|
|
1503
|
+
if (labelFieldConfig?.virtual === true) return undefined
|
|
1504
|
+
|
|
1505
|
+
return {
|
|
1506
|
+
operators: ['eq'],
|
|
1507
|
+
toCondition: (operator, value) => {
|
|
1508
|
+
if (operator !== 'eq') return null
|
|
1509
|
+
return { [_fieldName]: { is: { [labelField]: { contains: value } } } }
|
|
1510
|
+
},
|
|
1511
|
+
suggestions: { valueSource: { kind: 'relationship', listKey: targetList, many: false } },
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1347
1515
|
return field
|
|
1348
1516
|
}
|
|
1349
1517
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ListConfig, OpenSaasConfig } from '../config/types.js'
|
|
2
|
+
import { parseFilterQuery } from './parse.js'
|
|
3
|
+
import { buildFilterWhere } from './map.js'
|
|
4
|
+
import type { FilterCondition, FilterFieldSuggestion, FilterSpec } from './types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve every field's {@link FilterSpec} for a list by delegating to each
|
|
8
|
+
* field's optional `getFilterSpec` method. A field without the method (or one
|
|
9
|
+
* whose method returns `undefined`) is simply not filterable — the absence
|
|
10
|
+
* degrades gracefully so third-party fields keep working.
|
|
11
|
+
*
|
|
12
|
+
* @param listConfig The list whose fields to inspect.
|
|
13
|
+
* @param listKey The list's key (passed through to each field's spec).
|
|
14
|
+
* @param config The full config (relationship specs resolve their target
|
|
15
|
+
* list's label field from it).
|
|
16
|
+
*/
|
|
17
|
+
export function collectFilterSpecs(
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
19
|
+
listConfig: ListConfig<any>,
|
|
20
|
+
listKey: string,
|
|
21
|
+
config: OpenSaasConfig,
|
|
22
|
+
): Record<string, FilterSpec> {
|
|
23
|
+
const specs: Record<string, FilterSpec> = {}
|
|
24
|
+
for (const [fieldName, field] of Object.entries(listConfig.fields)) {
|
|
25
|
+
if (typeof field.getFilterSpec !== 'function') continue
|
|
26
|
+
const spec = field.getFilterSpec(fieldName, listKey, config)
|
|
27
|
+
if (spec) specs[fieldName] = spec
|
|
28
|
+
}
|
|
29
|
+
return specs
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* End-to-end helper for the list view: parse a raw URL query, collect the
|
|
34
|
+
* list's Filter specs, and build the merged Prisma `where` fragment. The
|
|
35
|
+
* fragment is meant to be handed to `context.db.<list>.findMany`/`count`, where
|
|
36
|
+
* the secured context ANDs it with the access filter — so the filter can only
|
|
37
|
+
* ever narrow, never widen, what a session may see.
|
|
38
|
+
*
|
|
39
|
+
* @returns A `where` fragment, or `undefined` when the query filters nothing.
|
|
40
|
+
*/
|
|
41
|
+
export function buildListFilterWhere(
|
|
42
|
+
query: string,
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
44
|
+
listConfig: ListConfig<any>,
|
|
45
|
+
listKey: string,
|
|
46
|
+
config: OpenSaasConfig,
|
|
47
|
+
): FilterCondition | undefined {
|
|
48
|
+
const specs = collectFilterSpecs(listConfig, listKey, config)
|
|
49
|
+
const tokens = parseFilterQuery(query)
|
|
50
|
+
return buildFilterWhere(tokens, specs)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Collect the client-serializable suggestion metadata for a list's filterable
|
|
55
|
+
* fields (field names, operators, enumerated values / relationship label
|
|
56
|
+
* search). Carries no functions, so it can cross the server/client boundary to
|
|
57
|
+
* drive the Filter builder's autocomplete.
|
|
58
|
+
*/
|
|
59
|
+
export function collectFilterSuggestions(
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
61
|
+
listConfig: ListConfig<any>,
|
|
62
|
+
listKey: string,
|
|
63
|
+
config: OpenSaasConfig,
|
|
64
|
+
): FilterFieldSuggestion[] {
|
|
65
|
+
const specs = collectFilterSpecs(listConfig, listKey, config)
|
|
66
|
+
return Object.entries(specs).map(([field, spec]) => ({
|
|
67
|
+
field,
|
|
68
|
+
operators: spec.operators,
|
|
69
|
+
freeText: spec.freeText ?? false,
|
|
70
|
+
valueSource: spec.suggestions.valueSource,
|
|
71
|
+
}))
|
|
72
|
+
}
|