@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,115 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseFilterQuery } from './parse.js'
3
+ import { serializeFilterQuery } from './serialize.js'
4
+ import type { FilterToken } from './types.js'
5
+
6
+ // ─────────────────────────────────────────────────────────────
7
+ // serializeFilterQuery — the pure tokens → query-string boundary
8
+ // (ADR-0017). The exact inverse of parseFilterQuery; this is the
9
+ // grammar the Filter builder UI produces into the `?search=` param.
10
+ // ─────────────────────────────────────────────────────────────
11
+
12
+ const token = (partial: Partial<FilterToken>): FilterToken => ({
13
+ field: null,
14
+ operator: 'eq',
15
+ value: '',
16
+ raw: '',
17
+ ...partial,
18
+ })
19
+
20
+ describe('serializeFilterQuery', () => {
21
+ it('serialises a bare free-text word', () => {
22
+ expect(serializeFilterQuery([token({ field: null, value: 'beta' })])).toBe('beta')
23
+ })
24
+
25
+ it('serialises a field:value token with the default eq operator', () => {
26
+ expect(serializeFilterQuery([token({ field: 'role', value: 'Editor' })])).toBe('role:Editor')
27
+ })
28
+
29
+ it('serialises each comparison operator as its prefix', () => {
30
+ expect(serializeFilterQuery([token({ field: 'orders', operator: 'gt', value: '5' })])).toBe(
31
+ 'orders:>5',
32
+ )
33
+ expect(serializeFilterQuery([token({ field: 'orders', operator: 'gte', value: '5' })])).toBe(
34
+ 'orders:>=5',
35
+ )
36
+ expect(serializeFilterQuery([token({ field: 'orders', operator: 'lt', value: '5' })])).toBe(
37
+ 'orders:<5',
38
+ )
39
+ expect(serializeFilterQuery([token({ field: 'orders', operator: 'lte', value: '5' })])).toBe(
40
+ 'orders:<=5',
41
+ )
42
+ })
43
+
44
+ it('quotes a field value containing whitespace', () => {
45
+ expect(serializeFilterQuery([token({ field: 'name', value: 'Ada Lovelace' })])).toBe(
46
+ 'name:"Ada Lovelace"',
47
+ )
48
+ })
49
+
50
+ it('quotes a bare word containing whitespace', () => {
51
+ expect(serializeFilterQuery([token({ field: null, value: 'multi word' })])).toBe('"multi word"')
52
+ })
53
+
54
+ it('quotes an eq value that would otherwise read as a comparison operator', () => {
55
+ // Without the guard, `field:>5` would re-parse as gt, not eq of ">5".
56
+ expect(serializeFilterQuery([token({ field: 'note', operator: 'eq', value: '>5' })])).toBe(
57
+ 'note:">5"',
58
+ )
59
+ })
60
+
61
+ it('does NOT quote a leading > on a bare word (parser keeps it literal)', () => {
62
+ expect(serializeFilterQuery([token({ field: null, value: '>5' })])).toBe('>5')
63
+ })
64
+
65
+ it('joins tokens with the grammar implicit AND (a single space)', () => {
66
+ expect(
67
+ serializeFilterQuery([
68
+ token({ field: 'role', value: 'Editor' }),
69
+ token({ field: 'orders', operator: 'gt', value: '5' }),
70
+ token({ field: null, value: 'beta' }),
71
+ ]),
72
+ ).toBe('role:Editor orders:>5 beta')
73
+ })
74
+
75
+ it('omits tokens with an empty value (no dangling field: or stray quotes)', () => {
76
+ expect(
77
+ serializeFilterQuery([
78
+ token({ field: 'role', value: '' }),
79
+ token({ field: null, value: '' }),
80
+ token({ field: 'name', value: 'Ada' }),
81
+ ]),
82
+ ).toBe('name:Ada')
83
+ })
84
+ })
85
+
86
+ // ─────────────────────────────────────────────────────────────
87
+ // Round-trip: parse ∘ serialize is stable, and serialize ∘ parse
88
+ // reproduces the meaning the engine assigns.
89
+ // ─────────────────────────────────────────────────────────────
90
+
91
+ describe('parse/serialize round-trip', () => {
92
+ const queries = [
93
+ 'beta',
94
+ 'role:Editor',
95
+ 'orders:>5',
96
+ 'orders:>=5',
97
+ 'orders:<10',
98
+ 'name:"Ada Lovelace"',
99
+ 'role:Editor orders:>5 name:"Ada Lovelace" beta',
100
+ '"multi word" gamma',
101
+ ]
102
+
103
+ it.each(queries)('serialize(parse(%s)) reproduces the query', (query) => {
104
+ expect(serializeFilterQuery(parseFilterQuery(query))).toBe(query)
105
+ })
106
+
107
+ it.each(queries)('parse is idempotent through a serialize round-trip: %s', (query) => {
108
+ const once = parseFilterQuery(query)
109
+ const twice = parseFilterQuery(serializeFilterQuery(once))
110
+ // Compare on the semantic fields (raw differs after re-serialisation).
111
+ const strip = (tokens: FilterToken[]) =>
112
+ tokens.map(({ field, operator, value }) => ({ field, operator, value }))
113
+ expect(strip(twice)).toEqual(strip(once))
114
+ })
115
+ })
@@ -0,0 +1,75 @@
1
+ import type { FilterOperator, FilterToken } from './types.js'
2
+
3
+ /**
4
+ * The comparison-operator prefix each {@link FilterOperator} serialises to. The
5
+ * exact inverse of the prefixes {@link parseFilterQuery} recognises (`eq` has no
6
+ * prefix — a plain `field:value`).
7
+ */
8
+ const OPERATOR_PREFIX: Record<FilterOperator, string> = {
9
+ eq: '',
10
+ gt: '>',
11
+ gte: '>=',
12
+ lt: '<',
13
+ lte: '<=',
14
+ }
15
+
16
+ /**
17
+ * Whether a value must be wrapped in double quotes to survive re-parsing.
18
+ *
19
+ * A value is quoted when it contains whitespace (so `parseFilterQuery` keeps it
20
+ * as one token) or an embedded quote. When `guardOperatorChars` is set (an `eq`
21
+ * field token, where a leading `>`/`<` would otherwise be read as a comparison
22
+ * operator), a value starting with `<`/`>` is also quoted so it round-trips as a
23
+ * literal value rather than an operator.
24
+ */
25
+ function quoteIfNeeded(value: string, guardOperatorChars: boolean): string {
26
+ const needsQuote =
27
+ /\s/.test(value) ||
28
+ value.includes('"') ||
29
+ (guardOperatorChars && (value.startsWith('>') || value.startsWith('<')))
30
+ return needsQuote ? `"${value}"` : value
31
+ }
32
+
33
+ /**
34
+ * Serialise {@link FilterToken}s back into a filter query string — the exact
35
+ * inverse of {@link parseFilterQuery} (ADR-0017). This is the grammar-producing
36
+ * half the Filter builder UI relies on: the builder turns UI state into tokens
37
+ * and serialises them into the `?search=` URL param the engine already consumes,
38
+ * so the same quoting and operator-prefix rules live next to the parser and can
39
+ * never drift.
40
+ *
41
+ * Pure and field-agnostic (no DB/Prisma imports). Tokens are joined with the
42
+ * grammar's implicit AND (a single space). A token with an empty value carries
43
+ * no filter and is omitted, so `serializeFilterQuery` never emits a dangling
44
+ * `field:` or a stray quote pair.
45
+ *
46
+ * @example
47
+ * serializeFilterQuery([
48
+ * { field: 'role', operator: 'eq', value: 'Editor', raw: '' },
49
+ * { field: 'orders', operator: 'gt', value: '5', raw: '' },
50
+ * { field: 'name', operator: 'eq', value: 'Ada Lovelace', raw: '' },
51
+ * { field: null, operator: 'eq', value: 'beta', raw: '' },
52
+ * ])
53
+ * // 'role:Editor orders:>5 name:"Ada Lovelace" beta'
54
+ */
55
+ export function serializeFilterQuery(tokens: FilterToken[]): string {
56
+ const parts: string[] = []
57
+
58
+ for (const token of tokens) {
59
+ // An empty value is not a filter — skip it rather than emit `field:` / `""`.
60
+ if (token.value === '') continue
61
+
62
+ if (token.field === null) {
63
+ // Bare free-text word. A leading `>`/`<` stays literal for bare words
64
+ // (the parser only treats them as operators after a `field:`), so no
65
+ // operator guard is needed here.
66
+ parts.push(quoteIfNeeded(token.value, false))
67
+ continue
68
+ }
69
+
70
+ const prefix = OPERATOR_PREFIX[token.operator]
71
+ parts.push(`${token.field}:${prefix}${quoteIfNeeded(token.value, prefix === '')}`)
72
+ }
73
+
74
+ return parts.join(' ')
75
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Filter engine types (ADR-0017).
3
+ *
4
+ * The admin UI's Filter builder turns a URL query string into scoped,
5
+ * server-executed filtering. The grammar is an AND-only URL contract:
6
+ * implicit-AND tokens, quoted multi-word values, comparison operators on
7
+ * numeric/date fields, and bare words as free text. Fields participate by
8
+ * declaring a {@link FilterSpec} (a self-contained field-builder method, peer
9
+ * of `getPrismaType` et al.); a field without one is not filterable.
10
+ *
11
+ * These types are the pure boundary — no Prisma / DB imports — so the
12
+ * `parse → tokens` and `tokens + specs → conditions` seam is unit-testable.
13
+ */
14
+
15
+ /**
16
+ * A Filter operator. `eq` is the default operator for a plain `field:value`
17
+ * token (each field's spec decides what equality means — `contains` for text,
18
+ * `equals` for a select); the comparisons are produced by the `>`/`>=`/`<`/`<=`
19
+ * prefixes on numeric and date tokens.
20
+ */
21
+ export type FilterOperator = 'eq' | 'gt' | 'gte' | 'lt' | 'lte'
22
+
23
+ /**
24
+ * One parsed unit of a filter query — a field, an operator, and a value.
25
+ *
26
+ * A bare free-text word parses to a token with `field: null`. `raw` is the
27
+ * original source substring, retained so a token that can't map to a condition
28
+ * degrades to free text without re-tokenising.
29
+ */
30
+ export interface FilterToken {
31
+ /** Field key the token targets, or `null` for a bare free-text word. */
32
+ field: string | null
33
+ /** The operator; `eq` for a plain `field:value`, comparisons for prefixed values. */
34
+ operator: FilterOperator
35
+ /** The unquoted value. */
36
+ value: string
37
+ /** The original source substring for this token (used when degrading to free text). */
38
+ raw: string
39
+ }
40
+
41
+ /**
42
+ * A Prisma `where` fragment produced by a Filter spec's `toCondition` mapper.
43
+ * Kept as an opaque record so this module carries no Prisma type dependency;
44
+ * the secured context ANDs it with the access filter when the fragment reaches
45
+ * `context.db.*`.
46
+ */
47
+ export type FilterCondition = Record<string, unknown>
48
+
49
+ /**
50
+ * Serializable description of what the suggestion dropdown may offer for a
51
+ * field's values. Structure only — never data-derived values for unbounded
52
+ * fields (ADR-0017): `none` for text/number/date, enumerated `options` for
53
+ * select/checkbox, and `relationship` for label search via the existing
54
+ * access-controlled lookup.
55
+ */
56
+ export type FilterValueSource =
57
+ | { kind: 'none' }
58
+ | { kind: 'enum'; options: Array<{ value: string; label: string }> }
59
+ | { kind: 'relationship'; listKey: string; many: boolean }
60
+
61
+ /**
62
+ * Marker key a to-many relationship's Filter spec emits for a count comparison
63
+ * (`orders:>5`). Prisma cannot express a relation-count comparison in a `where`
64
+ * (there is no `{ orders: { _count: { gt: 5 } } }`), so the pure spec can only
65
+ * emit a structured marker; `resolveRelationshipCountFilters` later turns each
66
+ * marker into an access-scoped `{ id: { in | notIn } }` before the query runs.
67
+ * Kept here (the pure boundary) so the field builder and the resolver agree on
68
+ * the shape without depending on each other.
69
+ */
70
+ export const RELATIONSHIP_COUNT_FILTER_KEY = '_countFilter' as const
71
+
72
+ /**
73
+ * The payload a {@link RELATIONSHIP_COUNT_FILTER_KEY} marker carries: the
74
+ * numeric comparison to apply to a to-many relationship's access-visible count.
75
+ */
76
+ export interface RelationshipCountFilterMarker {
77
+ operator: FilterOperator
78
+ value: number
79
+ }
80
+
81
+ /**
82
+ * A field's self-declared filtering capability. Returned by the optional
83
+ * `getFilterSpec` field-builder method.
84
+ */
85
+ export interface FilterSpec {
86
+ /** Operators this field supports. A token using any other operator degrades to free text. */
87
+ operators: FilterOperator[]
88
+ /**
89
+ * Whether this field participates in bare-word free-text search. Bare words
90
+ * are OR-matched across every free-text field via each field's `eq` mapping.
91
+ */
92
+ freeText?: boolean
93
+ /**
94
+ * Pure map from a supported `(operator, value)` to a Prisma `where` fragment,
95
+ * or `null` when the value can't be interpreted (e.g. a non-numeric value on
96
+ * a numeric field, or an unknown select option) — in which case the token
97
+ * degrades to free text. MUST stay free of DB/Prisma imports.
98
+ */
99
+ toCondition: (operator: FilterOperator, value: string) => FilterCondition | null
100
+ /** Serializable suggestion metadata for the client. */
101
+ suggestions: {
102
+ valueSource: FilterValueSource
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Collected, client-serializable suggestion metadata for one filterable field.
108
+ * The union of every list field's Filter spec suggestions; carries no
109
+ * functions, so it crosses the server/client boundary.
110
+ */
111
+ export interface FilterFieldSuggestion {
112
+ /** The field key. */
113
+ field: string
114
+ /** Operators the field supports. */
115
+ operators: FilterOperator[]
116
+ /** Whether the field participates in bare-word free-text search. */
117
+ freeText: boolean
118
+ /** How the suggestion dropdown may offer values for this field. */
119
+ valueSource: FilterValueSource
120
+ }
@@ -481,6 +481,15 @@ export async function executeFieldAfterTransactionHooks(
481
481
  /**
482
482
  * Execute field-level resolveInput hooks
483
483
  * Allows fields to transform their input values before database write
484
+ *
485
+ * NOTE (#789): multi-column fields (e.g. storage image()/file() in
486
+ * Keystone-parity mode) are NOT split here. This phase only resolves each
487
+ * field's value under its LOGICAL key, so that phases 2-3 (list/field
488
+ * `validate` → `validateFieldRules`) run against the same shape a
489
+ * single-column field would present — including an unrecognised/invalid value
490
+ * a field's `resolveInput` chose to pass through for validation to catch. The
491
+ * split into physical columns happens strictly AFTER validation passes; see
492
+ * {@link splitMultiColumnFields}.
484
493
  */
485
494
  export async function executeFieldResolveInputHooks(
486
495
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -497,69 +506,90 @@ export async function executeFieldResolveInputHooks(
497
506
  let result = { ...resolvedData }
498
507
 
499
508
  for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
500
- // Skip if field not in data
509
+ // Skip if field not in data, or if there's nothing to resolve
501
510
  if (!(fieldKey in result)) continue
511
+ if (!fieldConfig.hooks?.resolveInput) continue
502
512
 
503
- // A field's resolveInput produces its resolved value; for most fields that
504
- // value is stored back under the same key. Multi-column fields additionally
505
- // split that value across their physical columns below.
506
- let resolvedValue: unknown = result[fieldKey]
513
+ // Execute field hook
514
+ // Type assertion is safe here because hooks are typed correctly in field definitions
515
+ // and we're working with runtime values that match those types
516
+ const resolvedValue = await fieldConfig.hooks.resolveInput({
517
+ listKey,
518
+ fieldKey,
519
+ operation,
520
+ inputData,
521
+ item,
522
+ resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
523
+ context,
524
+ } as Parameters<typeof fieldConfig.hooks.resolveInput>[0])
525
+
526
+ // Create new object with updated field to avoid mutating the passed reference
527
+ result = { ...result, [fieldKey]: resolvedValue }
528
+ }
507
529
 
508
- if (fieldConfig.hooks?.resolveInput) {
509
- // Execute field hook
510
- // Type assertion is safe here because hooks are typed correctly in field definitions
511
- // and we're working with runtime values that match those types
512
- resolvedValue = await fieldConfig.hooks.resolveInput({
513
- listKey,
514
- fieldKey,
515
- operation,
516
- inputData,
517
- item,
518
- resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
519
- context,
520
- } as Parameters<typeof fieldConfig.hooks.resolveInput>[0])
521
- } else if (!fieldConfig.splitColumns) {
522
- // No resolveInput and not a multi-column field — nothing to do.
530
+ return result
531
+ }
532
+
533
+ /**
534
+ * Split multi-column fields' resolved LOGICAL values into their physical
535
+ * per-part columns (e.g. storage image()/file() in Keystone-parity mode — see
536
+ * ADR-0006).
537
+ *
538
+ * Runs AFTER `validateFieldRules` has passed (#789): a multi-column field's
539
+ * `getZodSchema` gets a genuine chance to reject an unrecognised/invalid
540
+ * logical value BEFORE it is split into `null`/`undefined` physical columns
541
+ * and silently written. Previously this split ran inline inside
542
+ * `executeFieldResolveInputHooks` (Phase 1.5, BEFORE validation), which let an
543
+ * unrecognised value bypass validation entirely.
544
+ *
545
+ * Preserves the field-level write-access gate exactly as before: the raw
546
+ * per-part column keys are not declared in `fieldConfigs`, so
547
+ * `filterWritableFields`'s undeclared-key reject cannot enforce this field's
548
+ * own write access — enforce it HERE, using the canonical field-access
549
+ * evaluator with the same arguments the write pipeline uses. A denied field
550
+ * drops its logical key and contributes NONE of its per-part columns (sudo
551
+ * bypasses via `checkFieldAccess`).
552
+ */
553
+ export async function splitMultiColumnFields(
554
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
555
+ inputData: Record<string, any>,
556
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
557
+ resolvedData: Record<string, any>,
558
+ fields: Record<string, FieldConfig>,
559
+ operation: 'create' | 'update',
560
+ context: AccessContext,
561
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
562
+ item?: any,
563
+ ): Promise<Record<string, unknown>> {
564
+ let result = { ...resolvedData }
565
+
566
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
567
+ if (!fieldConfig.splitColumns) continue
568
+ if (!(fieldKey in result)) continue
569
+
570
+ const resolvedValue = result[fieldKey]
571
+
572
+ const canWrite = await checkFieldAccess(fieldConfig.access, operation, {
573
+ session: context.session,
574
+ item,
575
+ context,
576
+ inputData,
577
+ })
578
+
579
+ // Drop the logical key (it is not a real column) regardless of outcome —
580
+ // a denied field must not leave its logical key behind either.
581
+ const next = { ...result }
582
+ delete next[fieldKey]
583
+ result = next
584
+
585
+ if (!canWrite) {
586
+ // Denied: write none of its per-part columns — exactly as
587
+ // filterWritableFields drops a denied single-column field.
523
588
  continue
524
589
  }
525
590
 
526
- if (fieldConfig.splitColumns) {
527
- // Multi-column field (e.g. storage image()/file() in Keystone-parity
528
- // mode): replace the single logical key with its per-part columns so the
529
- // write payload targets the live columns instead of a single one.
530
- //
531
- // The split removes the logical key from the payload BEFORE the
532
- // canonical writable-field filter (`filterWritableFields`) runs, and the
533
- // raw per-part column keys are not in `fieldConfigs` — so that later
534
- // filter cannot enforce this field's own write access. Enforce it HERE,
535
- // using the canonical field-access evaluator with the SAME arguments the
536
- // write pipeline uses. A single-column field denied by `update`/`create`
537
- // is simply omitted from the write; a denied multi-column field must
538
- // likewise contribute NONE of its per-part columns. (sudo bypasses via
539
- // `checkFieldAccess`.)
540
- const canWrite = await checkFieldAccess(fieldConfig.access, operation, {
541
- session: context.session,
542
- item,
543
- context,
544
- inputData,
545
- })
546
- if (!canWrite) {
547
- // Denied: drop the logical key and write none of its columns — exactly
548
- // as filterWritableFields drops a denied single-column field.
549
- const next = { ...result }
550
- delete next[fieldKey]
551
- result = next
552
- continue
553
- }
554
- const columns = fieldConfig.splitColumns(fieldKey, resolvedValue)
555
- // Drop the logical key (it is not a real column) and merge the columns.
556
- const next = { ...result, ...columns }
557
- delete next[fieldKey]
558
- result = next
559
- } else {
560
- // Create new object with updated field to avoid mutating the passed reference
561
- result = { ...result, [fieldKey]: resolvedValue }
562
- }
591
+ const columns = fieldConfig.splitColumns(fieldKey, resolvedValue)
592
+ result = { ...result, ...columns }
563
593
  }
564
594
 
565
595
  return result
package/src/index.ts CHANGED
@@ -21,6 +21,13 @@ export type {
21
21
  DatabaseConfig,
22
22
  FieldConfig,
23
23
  OperationAccess,
24
+ // Custom Bulk actions (issue #736) — declared per list in
25
+ // `ui.listView.bulkActions`; the handler runs server-side over the selected
26
+ // ids through the secured context.
27
+ BulkAction,
28
+ BulkActionContext,
29
+ BulkActionResult,
30
+ BulkActionVariant,
24
31
  } from './config/index.js'
25
32
 
26
33
  // Access control — the types a consumer writes against
@@ -48,6 +55,10 @@ export { getDbKey, getUrlKey, getListKeyFromUrl } from './lib/case-utils.js'
48
55
  // relationship cells, dropdown options, and page headings.
49
56
  export { getLabelFieldName, getItemLabel } from './config/label.js'
50
57
 
58
+ // Access-scoped nav counts — resolves per-list record counts (opt-in via
59
+ // `ui.navCount`) through the secured context for the admin chrome (issue #735).
60
+ export { resolveNavCounts, isListQueryStaticallyDenied } from './config/nav-count.js'
61
+
51
62
  // Validation error surfaced by write operations
52
63
  export { ValidationError } from './hooks/index.js'
53
64
 
@@ -72,3 +83,46 @@ export type { ResultOf, RelationSelector, QueryArgs } from './query/index.js'
72
83
  // op; also callable directly wherever a full context is already in hand.
73
84
  export { getRelationshipOptions } from './query/relationship-options.js'
74
85
  export type { RelationshipOption, RelationshipOptionsArgs } from './query/relationship-options.js'
86
+
87
+ // Filter engine (ADR-0017) — the admin UI's Filter builder. The pure seam
88
+ // (`parseFilterQuery`, `buildFilterWhere`) plus config-aware helpers that
89
+ // collect each field's Filter spec and compose a list's server-side `where`.
90
+ // The produced fragment is ANDed with the access filter through the secured
91
+ // context, so the filter can only ever narrow visibility.
92
+ export {
93
+ parseFilterQuery,
94
+ serializeFilterQuery,
95
+ buildFilterWhere,
96
+ collectFilterSpecs,
97
+ buildListFilterWhere,
98
+ collectFilterSuggestions,
99
+ RELATIONSHIP_COUNT_FILTER_KEY,
100
+ } from './filter/index.js'
101
+ export type {
102
+ FilterOperator,
103
+ FilterToken,
104
+ FilterCondition,
105
+ FilterSpec,
106
+ FilterValueSource,
107
+ FilterFieldSuggestion,
108
+ RelationshipCountFilterMarker,
109
+ } from './filter/index.js'
110
+
111
+ // Access-scoped to-many relationship counts for the admin list view (#732):
112
+ // build the filtered `_count` select for count cells/sort, and resolve the
113
+ // count Filter spec's markers into `{ id: { in } }` — all through the secured
114
+ // context, so counts never include related rows the session cannot read.
115
+ export {
116
+ buildRelationshipCountSelect,
117
+ resolveRelationshipCountFilters,
118
+ isToManyRelationshipField,
119
+ } from './access/relationship-count.js'
120
+
121
+ // Access-scoped to-one relationship label filters for the admin list view
122
+ // (#749): fold the related list's `query` access into a to-one relationship
123
+ // Filter spec's nested `is` clause so a session can never use a relationship
124
+ // filter token to distinguish rows by a related field it cannot itself read.
125
+ export {
126
+ resolveRelationshipLabelFilters,
127
+ isToOneRelationshipField,
128
+ } from './access/relationship-label-filter.js'
package/src/internal.ts CHANGED
@@ -36,6 +36,11 @@ export { pascalToCamel, pascalToKebab, kebabToPascal, kebabToCamel } from './lib
36
36
  // Zod schema helpers used internally for validation
37
37
  export { validateWithZod, generateZodSchema } from './validation/schema.js'
38
38
 
39
+ // Canonical field-level access evaluator, reused by @opensaas/stack-ui to decide
40
+ // whether a Relationship-table cell may show an inline-edit affordance (#737).
41
+ // This is the single field-access evaluator — the UI must not re-implement it.
42
+ export { checkFieldAccess } from './access/index.js'
43
+
39
44
  // Config-shape sub-types consumed by sibling packages (not part of the consumer surface)
40
45
  export type {
41
46
  DatabaseConfig,