@opensaas/stack-core 0.30.0 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +412 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +14 -1
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +50 -0
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/field-access.js +6 -6
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +46 -12
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.d.ts +60 -0
- package/dist/access/relationship-count.d.ts.map +1 -0
- package/dist/access/relationship-count.js +233 -0
- package/dist/access/relationship-count.js.map +1 -0
- package/dist/access/relationship-count.test.d.ts +2 -0
- package/dist/access/relationship-count.test.d.ts.map +1 -0
- package/dist/access/relationship-count.test.js +185 -0
- package/dist/access/relationship-count.test.js.map +1 -0
- package/dist/access/relationship-label-filter.d.ts +45 -0
- package/dist/access/relationship-label-filter.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.js +93 -0
- package/dist/access/relationship-label-filter.js.map +1 -0
- package/dist/access/relationship-label-filter.test.d.ts +2 -0
- package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.test.js +126 -0
- package/dist/access/relationship-label-filter.test.js.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/nav-count.d.ts +34 -0
- package/dist/config/nav-count.d.ts.map +1 -0
- package/dist/config/nav-count.js +71 -0
- package/dist/config/nav-count.js.map +1 -0
- package/dist/config/types.d.ts +305 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +11 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +28 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +205 -0
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +7 -1
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/extend.d.ts +1 -0
- package/dist/extend.d.ts.map +1 -1
- package/dist/fields/index.d.ts +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +166 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +32 -0
- package/dist/filter/collect.d.ts.map +1 -0
- package/dist/filter/collect.js +60 -0
- package/dist/filter/collect.js.map +1 -0
- package/dist/filter/filter.test.d.ts +2 -0
- package/dist/filter/filter.test.d.ts.map +1 -0
- package/dist/filter/filter.test.js +348 -0
- package/dist/filter/filter.test.js.map +1 -0
- package/dist/filter/index.d.ts +7 -0
- package/dist/filter/index.d.ts.map +1 -0
- package/dist/filter/index.js +16 -0
- package/dist/filter/index.js.map +1 -0
- package/dist/filter/map.d.ts +18 -0
- package/dist/filter/map.d.ts.map +1 -0
- package/dist/filter/map.js +60 -0
- package/dist/filter/map.js.map +1 -0
- package/dist/filter/parse.d.ts +33 -0
- package/dist/filter/parse.d.ts.map +1 -0
- package/dist/filter/parse.js +103 -0
- package/dist/filter/parse.js.map +1 -0
- package/dist/filter/serialize.d.ts +25 -0
- package/dist/filter/serialize.d.ts.map +1 -0
- package/dist/filter/serialize.js +68 -0
- package/dist/filter/serialize.js.map +1 -0
- package/dist/filter/serialize.test.d.ts +2 -0
- package/dist/filter/serialize.test.d.ts.map +1 -0
- package/dist/filter/serialize.test.js +83 -0
- package/dist/filter/serialize.test.js.map +1 -0
- package/dist/filter/types.d.ts +122 -0
- package/dist/filter/types.d.ts.map +1 -0
- package/dist/filter/types.js +24 -0
- package/dist/filter/types.js.map +1 -0
- package/dist/hooks/index.d.ts +30 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +79 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +79 -0
- package/src/access/access-filter.ts +15 -4
- package/src/access/field-access.ts +6 -6
- package/src/access/multi-column-read-write.test.ts +68 -19
- package/src/access/relationship-count.test.ts +269 -0
- package/src/access/relationship-count.ts +311 -0
- package/src/access/relationship-label-filter.test.ts +177 -0
- package/src/access/relationship-label-filter.ts +140 -0
- package/src/config/index.ts +6 -0
- package/src/config/nav-count.ts +85 -0
- package/src/config/types.ts +319 -1
- package/src/context/hook-pipeline.ts +19 -0
- package/src/context/index.ts +285 -3
- package/src/context/nested-operations.ts +22 -0
- package/src/extend.ts +9 -0
- package/src/fields/index.ts +168 -0
- package/src/filter/collect.ts +72 -0
- package/src/filter/filter.test.ts +400 -0
- package/src/filter/index.ts +25 -0
- package/src/filter/map.ts +68 -0
- package/src/filter/parse.ts +106 -0
- package/src/filter/serialize.test.ts +115 -0
- package/src/filter/serialize.ts +75 -0
- package/src/filter/types.ts +120 -0
- package/src/hooks/index.ts +87 -57
- package/src/index.ts +54 -0
- package/src/internal.ts +5 -0
- package/tests/context.test.ts +690 -0
- package/tests/multi-column-validation-ordering.test.ts +305 -0
- package/tests/nav-count.test.ts +189 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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,
|