@opensaas/stack-core 0.29.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.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +443 -0
  3. package/CLAUDE.md +4 -4
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +14 -1
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.js +50 -0
  8. package/dist/access/access-filter.test.js.map +1 -1
  9. package/dist/access/field-access.js +6 -6
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/multi-column-read-write.test.js +46 -12
  12. package/dist/access/multi-column-read-write.test.js.map +1 -1
  13. package/dist/access/relationship-count.d.ts +60 -0
  14. package/dist/access/relationship-count.d.ts.map +1 -0
  15. package/dist/access/relationship-count.js +233 -0
  16. package/dist/access/relationship-count.js.map +1 -0
  17. package/dist/access/relationship-count.test.d.ts +2 -0
  18. package/dist/access/relationship-count.test.d.ts.map +1 -0
  19. package/dist/access/relationship-count.test.js +185 -0
  20. package/dist/access/relationship-count.test.js.map +1 -0
  21. package/dist/access/relationship-label-filter.d.ts +45 -0
  22. package/dist/access/relationship-label-filter.d.ts.map +1 -0
  23. package/dist/access/relationship-label-filter.js +93 -0
  24. package/dist/access/relationship-label-filter.js.map +1 -0
  25. package/dist/access/relationship-label-filter.test.d.ts +2 -0
  26. package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
  27. package/dist/access/relationship-label-filter.test.js +126 -0
  28. package/dist/access/relationship-label-filter.test.js.map +1 -0
  29. package/dist/config/index.d.ts +1 -1
  30. package/dist/config/index.d.ts.map +1 -1
  31. package/dist/config/nav-count.d.ts +34 -0
  32. package/dist/config/nav-count.d.ts.map +1 -0
  33. package/dist/config/nav-count.js +71 -0
  34. package/dist/config/nav-count.js.map +1 -0
  35. package/dist/config/types.d.ts +305 -4
  36. package/dist/config/types.d.ts.map +1 -1
  37. package/dist/context/hook-pipeline.d.ts.map +1 -1
  38. package/dist/context/hook-pipeline.js +11 -1
  39. package/dist/context/hook-pipeline.js.map +1 -1
  40. package/dist/context/index.d.ts +28 -0
  41. package/dist/context/index.d.ts.map +1 -1
  42. package/dist/context/index.js +206 -1
  43. package/dist/context/index.js.map +1 -1
  44. package/dist/context/nested-operations.d.ts.map +1 -1
  45. package/dist/context/nested-operations.js +7 -1
  46. package/dist/context/nested-operations.js.map +1 -1
  47. package/dist/extend.d.ts +1 -0
  48. package/dist/extend.d.ts.map +1 -1
  49. package/dist/fields/index.d.ts +1 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +166 -0
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/filter/collect.d.ts +32 -0
  54. package/dist/filter/collect.d.ts.map +1 -0
  55. package/dist/filter/collect.js +60 -0
  56. package/dist/filter/collect.js.map +1 -0
  57. package/dist/filter/filter.test.d.ts +2 -0
  58. package/dist/filter/filter.test.d.ts.map +1 -0
  59. package/dist/filter/filter.test.js +348 -0
  60. package/dist/filter/filter.test.js.map +1 -0
  61. package/dist/filter/index.d.ts +7 -0
  62. package/dist/filter/index.d.ts.map +1 -0
  63. package/dist/filter/index.js +16 -0
  64. package/dist/filter/index.js.map +1 -0
  65. package/dist/filter/map.d.ts +18 -0
  66. package/dist/filter/map.d.ts.map +1 -0
  67. package/dist/filter/map.js +60 -0
  68. package/dist/filter/map.js.map +1 -0
  69. package/dist/filter/parse.d.ts +33 -0
  70. package/dist/filter/parse.d.ts.map +1 -0
  71. package/dist/filter/parse.js +103 -0
  72. package/dist/filter/parse.js.map +1 -0
  73. package/dist/filter/serialize.d.ts +25 -0
  74. package/dist/filter/serialize.d.ts.map +1 -0
  75. package/dist/filter/serialize.js +68 -0
  76. package/dist/filter/serialize.js.map +1 -0
  77. package/dist/filter/serialize.test.d.ts +2 -0
  78. package/dist/filter/serialize.test.d.ts.map +1 -0
  79. package/dist/filter/serialize.test.js +83 -0
  80. package/dist/filter/serialize.test.js.map +1 -0
  81. package/dist/filter/types.d.ts +122 -0
  82. package/dist/filter/types.d.ts.map +1 -0
  83. package/dist/filter/types.js +24 -0
  84. package/dist/filter/types.js.map +1 -0
  85. package/dist/hooks/index.d.ts +30 -0
  86. package/dist/hooks/index.d.ts.map +1 -1
  87. package/dist/hooks/index.js +79 -59
  88. package/dist/hooks/index.js.map +1 -1
  89. package/dist/index.d.ts +6 -1
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +19 -0
  92. package/dist/index.js.map +1 -1
  93. package/dist/internal.d.ts +1 -0
  94. package/dist/internal.d.ts.map +1 -1
  95. package/dist/internal.js +4 -0
  96. package/dist/internal.js.map +1 -1
  97. package/dist/mcp/handler.d.ts +11 -3
  98. package/dist/mcp/handler.d.ts.map +1 -1
  99. package/dist/mcp/handler.js +96 -20
  100. package/dist/mcp/handler.js.map +1 -1
  101. package/package.json +1 -1
  102. package/src/access/access-filter.test.ts +79 -0
  103. package/src/access/access-filter.ts +15 -4
  104. package/src/access/field-access.ts +6 -6
  105. package/src/access/multi-column-read-write.test.ts +68 -19
  106. package/src/access/relationship-count.test.ts +269 -0
  107. package/src/access/relationship-count.ts +311 -0
  108. package/src/access/relationship-label-filter.test.ts +177 -0
  109. package/src/access/relationship-label-filter.ts +140 -0
  110. package/src/config/index.ts +6 -0
  111. package/src/config/nav-count.ts +85 -0
  112. package/src/config/types.ts +319 -1
  113. package/src/context/hook-pipeline.ts +19 -0
  114. package/src/context/index.ts +286 -4
  115. package/src/context/nested-operations.ts +22 -0
  116. package/src/extend.ts +9 -0
  117. package/src/fields/index.ts +168 -0
  118. package/src/filter/collect.ts +72 -0
  119. package/src/filter/filter.test.ts +400 -0
  120. package/src/filter/index.ts +25 -0
  121. package/src/filter/map.ts +68 -0
  122. package/src/filter/parse.ts +106 -0
  123. package/src/filter/serialize.test.ts +115 -0
  124. package/src/filter/serialize.ts +75 -0
  125. package/src/filter/types.ts +120 -0
  126. package/src/hooks/index.ts +87 -57
  127. package/src/index.ts +54 -0
  128. package/src/internal.ts +5 -0
  129. package/src/mcp/handler.ts +118 -28
  130. package/tests/context.test.ts +690 -0
  131. package/tests/multi-column-validation-ordering.test.ts +305 -0
  132. package/tests/nav-count.test.ts +189 -0
  133. package/tsconfig.tsbuildinfo +1 -1
@@ -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'
@@ -64,7 +119,7 @@ function warnIfSelectIgnored(
64
119
  `[@opensaas/stack-core] \`select\` is ignored by context.db.${getDbKey(listName)}.${operation}() ` +
65
120
  `and the full (access-filtered) record is returned. ` +
66
121
  `Narrow a read with \`include\` or a fragment \`query\` instead. ` +
67
- `See https://stack.opensaas.au/docs/core-concepts/queries`,
122
+ `See https://stack.opensaas.au/docs/concepts/queries`,
68
123
  )
69
124
  }
70
125
 
@@ -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'
@@ -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
+ }