@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
@@ -0,0 +1,85 @@
1
+ import type { AccessContext } from '../access/types.js'
2
+ import { getDbKey } from '../lib/case-utils.js'
3
+ import type { ListConfig, OpenSaasConfig, TypeInfo } from './types.js'
4
+
5
+ /**
6
+ * The minimal shape a `context.db[list]` delegate needs for a nav count. The
7
+ * access-controlled DB carries an `any` index signature, so this narrows the
8
+ * lookup to just the access-scoped `count` we call.
9
+ */
10
+ type CountDelegate = {
11
+ count?: (args?: { where?: unknown }) => Promise<number>
12
+ }
13
+
14
+ /**
15
+ * Whether a list's query access is **statically denied** — provably no session
16
+ * can read any row without evaluating a session-dependent function.
17
+ *
18
+ * Mirrors the list view's `canDeleteList` static check (issue #733): query
19
+ * access is statically denied when it is absent (deny-by-default, matching the
20
+ * access engine) or the literal boolean `false`. A function — or `true` —
21
+ * cannot be decided up front, so the count is fetched through the secured
22
+ * context, which re-applies the real rule per row.
23
+ *
24
+ * A statically-denied list must render no nav count at all: a `0` there would
25
+ * imply "this list is empty" when the truth is "you may see none of it".
26
+ */
27
+ export function isListQueryStaticallyDenied(
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
29
+ listConfig: ListConfig<any>,
30
+ ): boolean {
31
+ const queryAccess = listConfig.access?.operation?.query
32
+ if (queryAccess === undefined) return true
33
+ if (typeof queryAccess === 'boolean') return !queryAccess
34
+ return false
35
+ }
36
+
37
+ /**
38
+ * Resolve the access-scoped nav counts for the lists that opt in via
39
+ * `ui.navCount` (issue #735).
40
+ *
41
+ * The returned map is keyed by list key and contains an entry **only** for a
42
+ * list that:
43
+ *
44
+ * 1. opts in (`ui.navCount === true`) — no count query runs for any other list,
45
+ * 2. is not a singleton (a single-record list has no meaningful count), and
46
+ * 3. does not have statically-denied query access (see
47
+ * {@link isListQueryStaticallyDenied}).
48
+ *
49
+ * Each count is read through the secured `context.db`, whose `count` applies the
50
+ * access filter, so the number reflects exactly what the current session may
51
+ * see (a denied row is not counted). Counts are fetched concurrently.
52
+ */
53
+ export async function resolveNavCounts(
54
+ context: AccessContext,
55
+ config: OpenSaasConfig,
56
+ ): Promise<Record<string, number>> {
57
+ const counts: Record<string, number> = {}
58
+ const lists = config.lists ?? {}
59
+
60
+ const optedIn = Object.keys(lists).filter((listKey) => {
61
+ const listConfig = lists[listKey] as ListConfig<TypeInfo> | undefined
62
+ if (!listConfig) return false
63
+ if (listConfig.ui?.navCount !== true) return false
64
+ if (listConfig.isSingleton) return false
65
+ return !isListQueryStaticallyDenied(listConfig)
66
+ })
67
+
68
+ await Promise.all(
69
+ optedIn.map(async (listKey) => {
70
+ const delegate: CountDelegate | undefined = context.db?.[getDbKey(listKey)]
71
+ if (!delegate?.count) return
72
+ // A single list's count failing (a DB hiccup, a throwing access filter or
73
+ // hook) must not blank the whole admin chrome — `AdminUI` awaits this
74
+ // before rendering the shell. Degrade like the sibling `ListView`: log and
75
+ // omit just that badge. Other lists' counts are unaffected.
76
+ try {
77
+ counts[listKey] = await delegate.count()
78
+ } catch (error) {
79
+ console.error(`Failed to resolve nav count for ${listKey}:`, error)
80
+ }
81
+ }),
82
+ )
83
+
84
+ return counts
85
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AccessControl, FieldAccess } from '../access/types.js'
2
+ import type { FilterSpec } from '../filter/types.js'
2
3
  import type { z } from 'zod'
3
4
 
4
5
  /**
@@ -663,6 +664,29 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
663
664
  type: string
664
665
  optional: boolean
665
666
  }
667
+ /**
668
+ * Declare this field's filtering capability — its {@link FilterSpec} — for the
669
+ * admin UI's Filter builder (ADR-0017). Optional and additive: a field that
670
+ * omits it (like `password`, `json`, `virtual`, or a third-party field that
671
+ * hasn't adopted filtering) is simply not filterable and never suggested, so
672
+ * absence degrades gracefully everywhere.
673
+ *
674
+ * Self-contained, like {@link getPrismaType} and friends — the filter engine
675
+ * delegates to each field's spec rather than switching on field type. The
676
+ * returned `toCondition` mapper must stay pure (no DB/Prisma imports); its
677
+ * output is ANDed with the access filter through the secured context.
678
+ *
679
+ * @param fieldName The field's config key (closed over by the mapper).
680
+ * @param listKey The owning list's key.
681
+ * @param config The full config (e.g. relationship specs resolve their
682
+ * target list's label field from it).
683
+ * @returns The field's Filter spec, or `undefined` when not filterable.
684
+ */
685
+ getFilterSpec?: (
686
+ fieldName: string,
687
+ listKey: string,
688
+ config: OpenSaasConfig,
689
+ ) => FilterSpec | undefined
666
690
  /**
667
691
  * Get TypeScript imports needed for this field's type
668
692
  * @returns Array of import statements needed for the generated types file
@@ -814,9 +838,34 @@ export type PasswordField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConf
814
838
  }
815
839
  }
816
840
 
841
+ /**
842
+ * Badge-variant vocabulary a select option can map its value to for status-Cell
843
+ * rendering in the admin UI (issue #729). Mirrors the admin UI Badge primitive's
844
+ * variants; `secondary` is the neutral fallback used for unmapped options.
845
+ */
846
+ export type SelectOptionVariant =
847
+ 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'
848
+
849
+ /**
850
+ * A single choice in a `select` field.
851
+ *
852
+ * `label`/`value` are the long-standing shape; `ui` is additive, optional
853
+ * metadata (issue #729). `ui.variant` lets a value render as a coloured status
854
+ * badge in list-table Cells — an option without it renders with the neutral
855
+ * badge, so existing options keep working unchanged.
856
+ */
857
+ export type SelectOption = {
858
+ label: string
859
+ value: string
860
+ ui?: {
861
+ /** Badge variant used when this option's value renders as a status Cell. */
862
+ variant?: SelectOptionVariant
863
+ }
864
+ }
865
+
817
866
  export type SelectField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
818
867
  type: 'select'
819
- options: Array<{ label: string; value: string }>
868
+ options: Array<SelectOption>
820
869
  defaultValue?: string
821
870
  db?: {
822
871
  /**
@@ -880,6 +929,93 @@ export type SelectField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig
880
929
  }
881
930
  }
882
931
 
932
+ /**
933
+ * Item-view configuration for a single to-many relationship field — the
934
+ * per-relationship overrides for the admin item view's Relationship table
935
+ * (issue #734). All values are plain, serialisable data so they can cross the
936
+ * server→client boundary via the existing field-config serialisation.
937
+ */
938
+ export type RelationshipItemViewConfig = {
939
+ /**
940
+ * How this to-many relationship is presented on the owning record's item
941
+ * view:
942
+ * - `'table'` (default): a read-only Relationship table of related rows.
943
+ * - `'picker'`: demote it back to the compact relationship picker inside the
944
+ * details card (the pre-#734 behaviour).
945
+ *
946
+ * @default 'table'
947
+ */
948
+ displayMode?: 'table' | 'picker'
949
+ /**
950
+ * The related list's fields to show as Relationship-table columns, in order.
951
+ *
952
+ * When omitted, the columns default to the related list's own column
953
+ * curation (`ui.listView.initialColumns`, else all non-system fields) minus
954
+ * the back-reference field that points at the parent record.
955
+ *
956
+ * @example
957
+ * ```typescript
958
+ * posts: relationship({
959
+ * ref: 'Post.author',
960
+ * many: true,
961
+ * ui: { itemView: { columns: ['title', 'status'] } },
962
+ * })
963
+ * ```
964
+ */
965
+ columns?: string[]
966
+ /**
967
+ * The maximum number of related rows to fetch and render in this
968
+ * Relationship table (issue #752). The item view fetches related rows
969
+ * through the secured context bounded by this `take`, so a record with many
970
+ * related rows never loads/renders every one on edit-page open. The totals
971
+ * footer still shows the full access-scoped total ("showing N of M").
972
+ *
973
+ * When omitted, a sensible default cap applies (the UI's
974
+ * `DEFAULT_ITEM_VIEW_TAKE`). Must be a positive integer; non-positive or
975
+ * non-integer values fall back to the default.
976
+ *
977
+ * @example
978
+ * ```typescript
979
+ * sessions: relationship({
980
+ * ref: 'Session.user',
981
+ * many: true,
982
+ * ui: { itemView: { take: 5 } },
983
+ * })
984
+ * ```
985
+ */
986
+ take?: number
987
+ /**
988
+ * The numeric columns whose values are summed in the Relationship table's
989
+ * totals footer. The row count is always shown; sums appear only for the
990
+ * columns listed here, each formatted by that column's Cell.
991
+ *
992
+ * @example
993
+ * ```typescript
994
+ * lineItems: relationship({
995
+ * ref: 'LineItem.order',
996
+ * many: true,
997
+ * ui: { itemView: { sum: ['amount'] } },
998
+ * })
999
+ * ```
1000
+ */
1001
+ sum?: string[]
1002
+ /**
1003
+ * How a row's ✕ control removes a related row from this table (ADR-0018).
1004
+ *
1005
+ * - `'disconnect'` (default): non-destructively unlinks the related row from
1006
+ * this record — the row itself is untouched and still appears on its own
1007
+ * list. Gated on the related list's update access. Hidden statically when
1008
+ * the schema makes disconnect impossible (a required foreign key on the
1009
+ * related side, i.e. the back-reference declares `db.isNullable: false`).
1010
+ * - `'delete'`: opts into truly deleting the related row, behind a
1011
+ * confirmation, gated on the related list's delete access.
1012
+ * - `'none'`: hides the removal control entirely.
1013
+ *
1014
+ * @default 'disconnect'
1015
+ */
1016
+ removeAction?: 'disconnect' | 'delete' | 'none'
1017
+ }
1018
+
883
1019
  export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
884
1020
  BaseFieldConfig<TTypeInfo> & {
885
1021
  type: 'relationship'
@@ -1006,6 +1142,13 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
1006
1142
  }
1007
1143
  ui?: {
1008
1144
  displayMode?: 'select' | 'cards'
1145
+ /**
1146
+ * Item-view (Relationship table) overrides for this to-many relationship
1147
+ * — columns, summed columns, and demotion to the compact picker (issue
1148
+ * #734). Ignored for single (`many: false`) relationships, which always
1149
+ * render inside the details card.
1150
+ */
1151
+ itemView?: RelationshipItemViewConfig
1009
1152
  }
1010
1153
  /**
1011
1154
  * Get the complete Prisma schema contribution for this relationship field.
@@ -1731,6 +1874,13 @@ export type ListUIConfig = {
1731
1874
  * Keystone's `ui.listView`.
1732
1875
  */
1733
1876
  listView?: ListViewUIConfig
1877
+ /**
1878
+ * Item-view (record edit page) configuration for this list — controls the
1879
+ * placement/order of the shape-derived Relationship-table sections (issue
1880
+ * #734). When omitted, sections appear in field-declaration order and the
1881
+ * layout is derived purely from the number of to-many relationships.
1882
+ */
1883
+ itemView?: ItemViewUIConfig
1734
1884
  /**
1735
1885
  * The field used to represent a row as a single label — in relationship
1736
1886
  * cells, dropdown options, and page headings. Must reference a declared,
@@ -1745,6 +1895,141 @@ export type ListUIConfig = {
1745
1895
  * ```
1746
1896
  */
1747
1897
  labelField?: string
1898
+ /**
1899
+ * Opt this list into an access-scoped record count next to its Admin chrome
1900
+ * nav item (issue #735). Default `false` — no count query runs for lists that
1901
+ * don't opt in. The count is fetched through the secured context, so it only
1902
+ * ever reflects what the current session may see; a list whose query access
1903
+ * is statically denied renders no count rather than a misleading zero.
1904
+ *
1905
+ * @example
1906
+ * ```typescript
1907
+ * ui: { navCount: true }
1908
+ * ```
1909
+ */
1910
+ navCount?: boolean
1911
+ /**
1912
+ * Opt this list's label column into an initials-avatar Cell (issue #735) — an
1913
+ * initials bubble whose text and colour derive deterministically from the row,
1914
+ * rendered ahead of the emphasized {@link getItemLabel Item label}. Default
1915
+ * `false` (text-only label). A per-field cell override (`ui.cell`) on the
1916
+ * label field still wins.
1917
+ *
1918
+ * @example
1919
+ * ```typescript
1920
+ * ui: { avatar: true }
1921
+ * ```
1922
+ */
1923
+ avatar?: boolean
1924
+ }
1925
+
1926
+ /**
1927
+ * Item-view (record edit page) configuration, mirroring the `ui.listView`
1928
+ * shape but for the shape-derived item layout (issue #734).
1929
+ *
1930
+ * The layout itself — single card / two-column split / stacked — is derived
1931
+ * from the number of to-many relationships rendered as Relationship tables and
1932
+ * needs no configuration. This block only reorders those sections; the
1933
+ * per-relationship column/sum/picker overrides live on the relationship field
1934
+ * (`ui.itemView`, see {@link RelationshipItemViewConfig}).
1935
+ */
1936
+ export type ItemViewUIConfig = {
1937
+ /**
1938
+ * The order of the Relationship-table sections, by their to-many
1939
+ * relationship field name. Listed fields come first in this order; any
1940
+ * to-many relationship not listed keeps its declaration order after them.
1941
+ *
1942
+ * @example
1943
+ * ```typescript
1944
+ * ui: { itemView: { order: ['orders', 'reviews'] } }
1945
+ * ```
1946
+ */
1947
+ order?: string[]
1948
+ }
1949
+
1950
+ /**
1951
+ * A button variant for a custom Bulk action, matching the admin UI's Button
1952
+ * variants. Serializable — it crosses to the client selection bar as a plain
1953
+ * string.
1954
+ */
1955
+ export type BulkActionVariant =
1956
+ 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
1957
+
1958
+ /**
1959
+ * Arguments passed to a custom Bulk action's server-side `handler` (issue #736).
1960
+ *
1961
+ * The handler runs entirely server-side — it is NEVER serialized to the client —
1962
+ * and must do all of its work through the SECURED `context` (never raw Prisma),
1963
+ * so per-row access control still applies and a denied row is a Silent failure.
1964
+ */
1965
+ export interface BulkActionContext {
1966
+ /** The list the action was invoked on (PascalCase list key). */
1967
+ listKey: string
1968
+ /** The explicit set of selected row ids the admin chose. */
1969
+ ids: string[]
1970
+ /**
1971
+ * The access-scoped context. All database work MUST go through `context.db`
1972
+ * so the list's access control and hooks are enforced for every id.
1973
+ */
1974
+ context: import('../access/types.js').AccessContext
1975
+ }
1976
+
1977
+ /**
1978
+ * The outcome a Bulk action `handler` returns. The optional `message` is shown
1979
+ * in the selection bar's status line once the action completes; returning
1980
+ * nothing yields a generic completion message.
1981
+ */
1982
+ export interface BulkActionResult {
1983
+ /** Human-readable outcome shown to the admin (e.g. "Published 3 of 5"). */
1984
+ message?: string
1985
+ }
1986
+
1987
+ /**
1988
+ * A list-specific custom Bulk action declared in `ui.listView.bulkActions`
1989
+ * (issue #736). Rendered as a button in the list view's selection bar, in
1990
+ * declaration order, alongside the built-in Delete.
1991
+ *
1992
+ * SERIALISATION BOUNDARY: only `key`, `label`, `variant` and `destructive`
1993
+ * cross to the client (as plain data). `handler` and `hasAccess` are
1994
+ * server-side functions and never leave the server — the client sends `key`
1995
+ * back through the generic server action, which looks the handler up and runs
1996
+ * it with the freshly-rebuilt secured context.
1997
+ */
1998
+ export interface BulkAction {
1999
+ /**
2000
+ * Stable identifier the client echoes back to dispatch this action
2001
+ * server-side. Must be unique within the list.
2002
+ */
2003
+ key: string
2004
+ /** Button label shown in the selection bar. */
2005
+ label: string
2006
+ /** Button variant (defaults to `'outline'`). */
2007
+ variant?: BulkActionVariant
2008
+ /**
2009
+ * When true, the admin is asked to confirm before the action runs, reusing
2010
+ * the same confirmation affordance as the built-in Delete.
2011
+ */
2012
+ destructive?: boolean
2013
+ /**
2014
+ * Optional visibility gate, evaluated server-side against the session before
2015
+ * the button is offered (user story 14 — "only see actions I may perform").
2016
+ * Returning `false` hides the button.
2017
+ *
2018
+ * This ONLY controls visibility; the `handler` always runs through the
2019
+ * secured context, so per-row access is enforced regardless. It is also
2020
+ * re-checked server-side on dispatch, so a hidden action cannot be invoked.
2021
+ */
2022
+ hasAccess?: (args: {
2023
+ session: import('../access/types.js').Session | null
2024
+ context: import('../access/types.js').AccessContext
2025
+ listKey: string
2026
+ }) => boolean | Promise<boolean>
2027
+ /**
2028
+ * The server-side handler. Receives the selected ids and the secured
2029
+ * `context`; do all work through `context.db`. Returns an optional
2030
+ * `{ message }` shown in the selection bar when the action completes.
2031
+ */
2032
+ handler: (args: BulkActionContext) => BulkActionResult | void | Promise<BulkActionResult | void>
1748
2033
  }
1749
2034
 
1750
2035
  /**
@@ -1783,6 +2068,39 @@ export type ListViewUIConfig = {
1783
2068
  /** The sort direction. */
1784
2069
  direction: 'asc' | 'desc'
1785
2070
  }
2071
+ /**
2072
+ * Custom list-specific Bulk actions shown in the list view's selection bar,
2073
+ * in declaration order, alongside the built-in Delete (issue #736). Each
2074
+ * action's `handler` runs server-side over the selected ids through the
2075
+ * secured context; only serializable metadata (`key`/`label`/`variant`/
2076
+ * `destructive`) crosses to the client.
2077
+ *
2078
+ * @example
2079
+ * ```typescript
2080
+ * ui: {
2081
+ * listView: {
2082
+ * bulkActions: [
2083
+ * {
2084
+ * key: 'publish',
2085
+ * label: 'Publish',
2086
+ * handler: async ({ ids, context }) => {
2087
+ * let n = 0
2088
+ * for (const id of ids) {
2089
+ * const r = await context.db.post.update({
2090
+ * where: { id },
2091
+ * data: { status: 'published' },
2092
+ * })
2093
+ * if (r) n++
2094
+ * }
2095
+ * return { message: `Published ${n} of ${ids.length}` }
2096
+ * },
2097
+ * },
2098
+ * ],
2099
+ * },
2100
+ * }
2101
+ * ```
2102
+ */
2103
+ bulkActions?: BulkAction[]
1786
2104
  }
1787
2105
 
1788
2106
  /**
@@ -6,6 +6,7 @@ import {
6
6
  executeFieldResolveInputHooks,
7
7
  executeFieldValidateHooks,
8
8
  validateFieldRules,
9
+ splitMultiColumnFields,
9
10
  ValidationError,
10
11
  } from '../hooks/index.js'
11
12
  import { applyCreateDefaults } from './apply-defaults.js'
@@ -66,12 +67,17 @@ export interface HookPipeline {
66
67
  * → list `validate`
67
68
  * → field `validate`
68
69
  * → built-in field rules (`validateFieldRules`)
70
+ * → split multi-column fields (`splitMultiColumnFields`)
69
71
  *
70
72
  * Contract preserved exactly:
71
73
  * - `resolvedData` starts as `inputData` and is threaded through each phase;
72
74
  * - validate hooks report failures via `addValidationError` → THROW
73
75
  * `ValidationError` (never silent);
74
76
  * - built-in field rule failures THROW `ValidationError`;
77
+ * - a multi-column field (e.g. storage image()/file() in Keystone-parity
78
+ * mode) is validated under its LOGICAL key BEFORE it is split into
79
+ * physical columns (#789) — an unrecognised value throws instead of being
80
+ * silently split into null/undefined columns;
75
81
  * - on success returns the transformed `resolvedData`.
76
82
  */
77
83
  async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResult> {
@@ -160,6 +166,19 @@ async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResu
160
166
  throw new ValidationError(validation.errors, validation.fieldErrors)
161
167
  }
162
168
 
169
+ // ── Phase 4: split multi-column fields into physical columns ──────────────
170
+ // Only reached once the logical value has passed validation (#789). Replaces
171
+ // each multi-column field's logical key with its per-part columns, gated by
172
+ // the field's own write access (see `splitMultiColumnFields`).
173
+ resolvedData = await splitMultiColumnFields(
174
+ inputData,
175
+ resolvedData,
176
+ listConfig.fields,
177
+ operation,
178
+ context,
179
+ item,
180
+ )
181
+
163
182
  return { resolvedData }
164
183
  }
165
184