@opensaas/stack-core 0.37.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +191 -0
- package/CLAUDE.md +42 -3
- package/dist/access/access-filter.d.ts +70 -113
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +183 -210
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +148 -188
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts +66 -26
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +62 -17
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/declared-dependencies.test.d.ts +2 -0
- package/dist/access/declared-dependencies.test.d.ts.map +1 -0
- package/dist/access/declared-dependencies.test.js +226 -0
- package/dist/access/declared-dependencies.test.js.map +1 -0
- package/dist/access/depth-limits.d.ts +8 -7
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +8 -7
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/engine.d.ts +0 -20
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +0 -25
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +61 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +91 -12
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +39 -1
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +108 -42
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +229 -2
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-transforms.d.ts +0 -27
- package/dist/access/field-transforms.d.ts.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -5
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +89 -41
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +6 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +12 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/query-validation.d.ts +121 -0
- package/dist/access/query-validation.d.ts.map +1 -0
- package/dist/access/query-validation.js +292 -0
- package/dist/access/query-validation.js.map +1 -0
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +2 -10
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-label-filter.d.ts +23 -23
- package/dist/access/relationship-label-filter.d.ts.map +1 -1
- package/dist/access/relationship-label-filter.js +5 -76
- package/dist/access/relationship-label-filter.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +19 -56
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/transaction-registry.d.ts +45 -0
- package/dist/access/transaction-registry.d.ts.map +1 -0
- package/dist/access/transaction-registry.js +37 -0
- package/dist/access/transaction-registry.js.map +1 -0
- package/dist/access/types.d.ts +55 -59
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +9 -7
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +10 -18
- package/dist/config/index.js.map +1 -1
- package/dist/config/label.d.ts +6 -13
- package/dist/config/label.d.ts.map +1 -1
- package/dist/config/label.js +6 -13
- package/dist/config/label.js.map +1 -1
- package/dist/config/nav-count.d.ts +11 -19
- package/dist/config/nav-count.d.ts.map +1 -1
- package/dist/config/nav-count.js +11 -19
- package/dist/config/nav-count.js.map +1 -1
- package/dist/config/plugin-engine.d.ts +0 -10
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +1 -37
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +94 -109
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +8 -31
- package/dist/context/apply-defaults.d.ts.map +1 -1
- package/dist/context/apply-defaults.js +12 -41
- package/dist/context/apply-defaults.js.map +1 -1
- package/dist/context/hook-pipeline.d.ts +4 -29
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +8 -41
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +4 -15
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +223 -215
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +7 -26
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +73 -171
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +36 -47
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +92 -79
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +25 -64
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +81 -111
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/format-prisma-default.d.ts.map +1 -1
- package/dist/fields/format-prisma-default.js +4 -7
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +17 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +136 -147
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +26 -3
- package/dist/filter/collect.d.ts.map +1 -1
- package/dist/filter/collect.js +30 -12
- package/dist/filter/collect.js.map +1 -1
- package/dist/filter/filter.test.js +98 -15
- package/dist/filter/filter.test.js.map +1 -1
- package/dist/filter/index.d.ts +1 -0
- package/dist/filter/index.d.ts.map +1 -1
- package/dist/filter/index.js.map +1 -1
- package/dist/filter/map.d.ts.map +1 -1
- package/dist/filter/map.js +0 -5
- package/dist/filter/map.js.map +1 -1
- package/dist/filter/parse.d.ts.map +1 -1
- package/dist/filter/parse.js +2 -9
- package/dist/filter/parse.js.map +1 -1
- package/dist/filter/serialize.d.ts.map +1 -1
- package/dist/filter/serialize.js +2 -4
- package/dist/filter/serialize.js.map +1 -1
- package/dist/hooks/index.d.ts +4 -42
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +5 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- 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/dist/lib/case-utils.d.ts +0 -32
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +0 -32
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/mcp/handler.d.ts +0 -4
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +12 -69
- package/dist/mcp/handler.js.map +1 -1
- package/dist/query/index.d.ts +55 -227
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +41 -117
- package/dist/query/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts +1 -1
- package/dist/query/relationship-options.js +1 -1
- package/dist/utils/password.d.ts +0 -68
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +7 -76
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/field-config.d.ts.map +1 -1
- package/dist/validation/field-config.js +0 -6
- package/dist/validation/field-config.js.map +1 -1
- package/dist/validation/schema.d.ts +0 -7
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +0 -10
- package/dist/validation/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +236 -272
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +117 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/engine.ts +0 -25
- package/src/access/errors.ts +97 -12
- package/src/access/field-access.test.ts +312 -2
- package/src/access/field-access.ts +118 -42
- package/src/access/field-transforms.ts +8 -39
- package/src/access/field-visibility.ts +99 -45
- package/src/access/index.ts +17 -5
- package/src/access/query-validation.ts +359 -0
- package/src/access/relationship-count.ts +3 -11
- package/src/access/relationship-label-filter.test.ts +19 -68
- package/src/access/relationship-label-filter.ts +27 -104
- package/src/access/transaction-registry.ts +58 -0
- package/src/access/types.ts +83 -86
- package/src/config/index.ts +10 -22
- package/src/config/label.ts +6 -13
- package/src/config/nav-count.ts +11 -19
- package/src/config/plugin-engine.ts +1 -37
- package/src/config/types.ts +95 -110
- package/src/context/apply-defaults.ts +12 -44
- package/src/context/hook-pipeline.ts +12 -67
- package/src/context/index.ts +311 -276
- package/src/context/nested-operations.ts +94 -226
- package/src/context/transaction-boundary.ts +113 -109
- package/src/context/write-pipeline.ts +99 -155
- package/src/fields/format-prisma-default.ts +4 -7
- package/src/fields/index.ts +155 -147
- package/src/filter/collect.ts +48 -12
- package/src/filter/filter.test.ts +153 -15
- package/src/filter/index.ts +1 -0
- package/src/filter/map.ts +0 -5
- package/src/filter/parse.ts +2 -9
- package/src/filter/serialize.ts +2 -4
- package/src/hooks/index.ts +5 -59
- package/src/index.ts +23 -4
- package/src/internal.ts +5 -0
- package/src/lib/case-utils.ts +0 -32
- package/src/mcp/handler.ts +13 -71
- package/src/query/index.ts +82 -235
- package/src/query/relationship-options.ts +1 -1
- package/src/utils/password.ts +7 -76
- package/src/validation/field-config.ts +0 -6
- package/src/validation/schema.ts +0 -10
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +727 -1
- package/tests/field-types.test.ts +222 -0
- package/tests/mcp-handler.test.ts +34 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tests/transaction-boundary-outermost.test.ts +530 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/filter/collect.ts
CHANGED
|
@@ -1,31 +1,65 @@
|
|
|
1
1
|
import type { ListConfig, OpenSaasConfig } from '../config/types.js'
|
|
2
|
+
import type { Session, AccessContext } from '../access/types.js'
|
|
3
|
+
import { isFieldReadableForPredicate } from '../access/field-access.js'
|
|
2
4
|
import { parseFilterQuery } from './parse.js'
|
|
3
5
|
import { buildFilterWhere } from './map.js'
|
|
4
6
|
import type { FilterCondition, FilterFieldSuggestion, FilterSpec } from './types.js'
|
|
5
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The session/context a field's `read` access is evaluated against — the same
|
|
10
|
+
* shape every other access-scoped admin-UI helper takes (e.g.
|
|
11
|
+
* `resolveRelationshipCountFilters`, `resolveRelationshipLabelFilters`).
|
|
12
|
+
*/
|
|
13
|
+
export type FilterAccessArgs = {
|
|
14
|
+
session: Session | null
|
|
15
|
+
context: AccessContext & { _isSudo?: boolean }
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
/**
|
|
7
19
|
* Resolve every field's {@link FilterSpec} for a list by delegating to each
|
|
8
20
|
* field's optional `getFilterSpec` method. A field without the method (or one
|
|
9
21
|
* whose method returns `undefined`) is simply not filterable — the absence
|
|
10
22
|
* degrades gracefully so third-party fields keep working.
|
|
11
23
|
*
|
|
24
|
+
* A field the session cannot READ is excluded here too (#915), evaluated the
|
|
25
|
+
* same predicate-time way `context.db.*`'s `findMany`/`count` now enforce
|
|
26
|
+
* (`isFieldReadableForPredicate` — no fetched row exists yet, so a
|
|
27
|
+
* row-dependent `read` rule resolves to "not filterable"). This is what keeps
|
|
28
|
+
* the admin UI from ever suggesting, autocompleting, or submitting a filter
|
|
29
|
+
* the engine is going to reject: excluding the spec here means a token
|
|
30
|
+
* naming that field degrades to free text (`buildFilterWhere`'s existing
|
|
31
|
+
* "unknown field" path) rather than reaching `context.db` at all.
|
|
32
|
+
*
|
|
12
33
|
* @param listConfig The list whose fields to inspect.
|
|
13
34
|
* @param listKey The list's key (passed through to each field's spec).
|
|
14
35
|
* @param config The full config (relationship specs resolve their target
|
|
15
36
|
* list's label field from it).
|
|
37
|
+
* @param args The session/context to evaluate field-level `read` access
|
|
38
|
+
* against.
|
|
16
39
|
*/
|
|
17
|
-
export function collectFilterSpecs(
|
|
40
|
+
export async function collectFilterSpecs(
|
|
18
41
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
19
42
|
listConfig: ListConfig<any>,
|
|
20
43
|
listKey: string,
|
|
21
44
|
config: OpenSaasConfig,
|
|
22
|
-
|
|
45
|
+
args: FilterAccessArgs,
|
|
46
|
+
): Promise<Record<string, FilterSpec>> {
|
|
23
47
|
const specs: Record<string, FilterSpec> = {}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
48
|
+
const filterableFields = Object.entries(listConfig.fields).filter(
|
|
49
|
+
([, field]) => typeof field.getFilterSpec === 'function',
|
|
50
|
+
)
|
|
51
|
+
// Each field's read-access check is independent of every other's — run
|
|
52
|
+
// them concurrently rather than serially, since a `read` rule that does
|
|
53
|
+
// async work (e.g. a DB lookup) would otherwise add per-field latency to
|
|
54
|
+
// every list-view page render (found in review of #925).
|
|
55
|
+
const readableFlags = await Promise.all(
|
|
56
|
+
filterableFields.map(([, field]) => isFieldReadableForPredicate(field.access, args)),
|
|
57
|
+
)
|
|
58
|
+
filterableFields.forEach(([fieldName, field], index) => {
|
|
59
|
+
if (!readableFlags[index]) return
|
|
60
|
+
const spec = field.getFilterSpec!(fieldName, listKey, config)
|
|
27
61
|
if (spec) specs[fieldName] = spec
|
|
28
|
-
}
|
|
62
|
+
})
|
|
29
63
|
return specs
|
|
30
64
|
}
|
|
31
65
|
|
|
@@ -38,14 +72,15 @@ export function collectFilterSpecs(
|
|
|
38
72
|
*
|
|
39
73
|
* @returns A `where` fragment, or `undefined` when the query filters nothing.
|
|
40
74
|
*/
|
|
41
|
-
export function buildListFilterWhere(
|
|
75
|
+
export async function buildListFilterWhere(
|
|
42
76
|
query: string,
|
|
43
77
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
44
78
|
listConfig: ListConfig<any>,
|
|
45
79
|
listKey: string,
|
|
46
80
|
config: OpenSaasConfig,
|
|
47
|
-
|
|
48
|
-
|
|
81
|
+
args: FilterAccessArgs,
|
|
82
|
+
): Promise<FilterCondition | undefined> {
|
|
83
|
+
const specs = await collectFilterSpecs(listConfig, listKey, config, args)
|
|
49
84
|
const tokens = parseFilterQuery(query)
|
|
50
85
|
return buildFilterWhere(tokens, specs)
|
|
51
86
|
}
|
|
@@ -56,13 +91,14 @@ export function buildListFilterWhere(
|
|
|
56
91
|
* search). Carries no functions, so it can cross the server/client boundary to
|
|
57
92
|
* drive the Filter builder's autocomplete.
|
|
58
93
|
*/
|
|
59
|
-
export function collectFilterSuggestions(
|
|
94
|
+
export async function collectFilterSuggestions(
|
|
60
95
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
61
96
|
listConfig: ListConfig<any>,
|
|
62
97
|
listKey: string,
|
|
63
98
|
config: OpenSaasConfig,
|
|
64
|
-
|
|
65
|
-
|
|
99
|
+
args: FilterAccessArgs,
|
|
100
|
+
): Promise<FilterFieldSuggestion[]> {
|
|
101
|
+
const specs = await collectFilterSpecs(listConfig, listKey, config, args)
|
|
66
102
|
return Object.entries(specs).map(([field, spec]) => ({
|
|
67
103
|
field,
|
|
68
104
|
operators: spec.operators,
|
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { parseFilterQuery } from './parse.js'
|
|
3
3
|
import { buildFilterWhere } from './map.js'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildListFilterWhere,
|
|
6
|
+
collectFilterSpecs,
|
|
7
|
+
collectFilterSuggestions,
|
|
8
|
+
type FilterAccessArgs,
|
|
9
|
+
} from './collect.js'
|
|
5
10
|
import type { FilterSpec } from './types.js'
|
|
6
11
|
import { list } from '../config/index.js'
|
|
7
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
text,
|
|
14
|
+
integer,
|
|
15
|
+
bigInt,
|
|
16
|
+
select,
|
|
17
|
+
checkbox,
|
|
18
|
+
timestamp,
|
|
19
|
+
relationship,
|
|
20
|
+
} from '../fields/index.js'
|
|
8
21
|
import type { OpenSaasConfig } from '../config/types.js'
|
|
22
|
+
import type { AccessContext } from '../access/types.js'
|
|
23
|
+
|
|
24
|
+
// A permissive session/context — no field on any list under test declares
|
|
25
|
+
// field-level `read` access unless a test says so, so every field is
|
|
26
|
+
// filterable/sortable by default (matching `checkFieldAccess`'s own
|
|
27
|
+
// allow-by-default for a field with no rule).
|
|
28
|
+
const noAccessArgs: FilterAccessArgs = {
|
|
29
|
+
session: null,
|
|
30
|
+
context: { _isSudo: false } as unknown as AccessContext,
|
|
31
|
+
}
|
|
9
32
|
|
|
10
33
|
// ─────────────────────────────────────────────────────────────
|
|
11
34
|
// parseFilterQuery — the pure query-string → tokens boundary (ADR-0017)
|
|
@@ -231,6 +254,7 @@ function makeConfig(): OpenSaasConfig {
|
|
|
231
254
|
fields: {
|
|
232
255
|
title: text(),
|
|
233
256
|
views: integer(),
|
|
257
|
+
occurredAtMs: bigInt(),
|
|
234
258
|
status: select({
|
|
235
259
|
options: [
|
|
236
260
|
{ label: 'Draft', value: 'draft' },
|
|
@@ -250,10 +274,10 @@ describe('core field Filter specs', () => {
|
|
|
250
274
|
const config = makeConfig()
|
|
251
275
|
const postConfig = config.lists.Post
|
|
252
276
|
|
|
253
|
-
it('collectFilterSpecs resolves a spec for every filterable core field', () => {
|
|
254
|
-
const collected = collectFilterSpecs(postConfig, 'Post', config)
|
|
277
|
+
it('collectFilterSpecs resolves a spec for every filterable core field', async () => {
|
|
278
|
+
const collected = await collectFilterSpecs(postConfig, 'Post', config, noAccessArgs)
|
|
255
279
|
expect(Object.keys(collected).sort()).toEqual(
|
|
256
|
-
['author', 'featured', 'publishedAt', 'status', 'title', 'views'].sort(),
|
|
280
|
+
['author', 'featured', 'occurredAtMs', 'publishedAt', 'status', 'title', 'views'].sort(),
|
|
257
281
|
)
|
|
258
282
|
})
|
|
259
283
|
|
|
@@ -270,6 +294,16 @@ describe('core field Filter specs', () => {
|
|
|
270
294
|
expect(spec.toCondition('eq', 'abc')).toBeNull()
|
|
271
295
|
})
|
|
272
296
|
|
|
297
|
+
it('bigInt supports comparisons, emits a bigint condition, and rejects non-integers', () => {
|
|
298
|
+
const spec = postConfig.fields.occurredAtMs.getFilterSpec!('occurredAtMs', 'Post', config)!
|
|
299
|
+
expect(spec.toCondition('gt', '9007199254740993')).toEqual({
|
|
300
|
+
occurredAtMs: { gt: 9007199254740993n },
|
|
301
|
+
})
|
|
302
|
+
expect(spec.toCondition('eq', '5')).toEqual({ occurredAtMs: { equals: 5n } })
|
|
303
|
+
expect(spec.toCondition('eq', 'abc')).toBeNull()
|
|
304
|
+
expect(spec.toCondition('eq', '1.5')).toBeNull()
|
|
305
|
+
})
|
|
306
|
+
|
|
273
307
|
it('select matches by value or label and resolves to the stored value', () => {
|
|
274
308
|
const spec = postConfig.fields.status.getFilterSpec!('status', 'Post', config)!
|
|
275
309
|
expect(spec.toCondition('eq', 'Published')).toEqual({ status: { equals: 'published' } })
|
|
@@ -309,23 +343,68 @@ describe('core field Filter specs', () => {
|
|
|
309
343
|
})
|
|
310
344
|
})
|
|
311
345
|
|
|
312
|
-
it('does not produce specs for password/json/virtual (absence degrades gracefully)', () => {
|
|
346
|
+
it('does not produce specs for password/json/virtual (absence degrades gracefully)', async () => {
|
|
313
347
|
// A list of non-filterable fields yields no specs at all.
|
|
314
|
-
const specsForUser = collectFilterSpecs(config.lists.User, 'User', config)
|
|
348
|
+
const specsForUser = await collectFilterSpecs(config.lists.User, 'User', config, noAccessArgs)
|
|
315
349
|
// User only has `name` (text) → exactly one spec, proving non-text absence.
|
|
316
350
|
expect(Object.keys(specsForUser)).toEqual(['name'])
|
|
317
351
|
})
|
|
352
|
+
|
|
353
|
+
it('excludes a field the session cannot read (#915)', async () => {
|
|
354
|
+
const gatedConfig: OpenSaasConfig = {
|
|
355
|
+
db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
|
|
356
|
+
lists: {
|
|
357
|
+
Organisation: list({
|
|
358
|
+
fields: {
|
|
359
|
+
name: text(),
|
|
360
|
+
billingAddress: text({ access: { read: () => false } }),
|
|
361
|
+
},
|
|
362
|
+
}),
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
const collected = await collectFilterSpecs(
|
|
366
|
+
gatedConfig.lists.Organisation,
|
|
367
|
+
'Organisation',
|
|
368
|
+
gatedConfig,
|
|
369
|
+
noAccessArgs,
|
|
370
|
+
)
|
|
371
|
+
expect(Object.keys(collected)).toEqual(['name'])
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('excludes a row-dependent read rule too — no row exists at predicate time (#915)', async () => {
|
|
375
|
+
const gatedConfig: OpenSaasConfig = {
|
|
376
|
+
db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
|
|
377
|
+
lists: {
|
|
378
|
+
Organisation: list({
|
|
379
|
+
fields: {
|
|
380
|
+
name: text(),
|
|
381
|
+
billingAddress: text({
|
|
382
|
+
access: { read: ({ item, session }) => item.ownerId === session?.userId },
|
|
383
|
+
}),
|
|
384
|
+
},
|
|
385
|
+
}),
|
|
386
|
+
},
|
|
387
|
+
}
|
|
388
|
+
const collected = await collectFilterSpecs(
|
|
389
|
+
gatedConfig.lists.Organisation,
|
|
390
|
+
'Organisation',
|
|
391
|
+
gatedConfig,
|
|
392
|
+
noAccessArgs,
|
|
393
|
+
)
|
|
394
|
+
expect(Object.keys(collected)).toEqual(['name'])
|
|
395
|
+
})
|
|
318
396
|
})
|
|
319
397
|
|
|
320
398
|
describe('buildListFilterWhere (end-to-end over a real list config)', () => {
|
|
321
399
|
const config = makeConfig()
|
|
322
400
|
|
|
323
|
-
it('composes parse + specs into a merged where', () => {
|
|
324
|
-
const where = buildListFilterWhere(
|
|
401
|
+
it('composes parse + specs into a merged where', async () => {
|
|
402
|
+
const where = await buildListFilterWhere(
|
|
325
403
|
'status:Published views:>10 author:"Ada" hello',
|
|
326
404
|
config.lists.Post,
|
|
327
405
|
'Post',
|
|
328
406
|
config,
|
|
407
|
+
noAccessArgs,
|
|
329
408
|
)
|
|
330
409
|
expect(where).toEqual({
|
|
331
410
|
AND: [
|
|
@@ -339,8 +418,35 @@ describe('buildListFilterWhere (end-to-end over a real list config)', () => {
|
|
|
339
418
|
})
|
|
340
419
|
})
|
|
341
420
|
|
|
342
|
-
it('returns undefined for an all-whitespace query', () => {
|
|
343
|
-
expect(
|
|
421
|
+
it('returns undefined for an all-whitespace query', async () => {
|
|
422
|
+
expect(
|
|
423
|
+
await buildListFilterWhere(' ', config.lists.Post, 'Post', config, noAccessArgs),
|
|
424
|
+
).toBeUndefined()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('a read-denied field degrades to free text instead of reaching the engine (#915)', async () => {
|
|
428
|
+
const gatedConfig: OpenSaasConfig = {
|
|
429
|
+
db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
|
|
430
|
+
lists: {
|
|
431
|
+
Organisation: list({
|
|
432
|
+
fields: {
|
|
433
|
+
name: text(),
|
|
434
|
+
billingAddress: text({ access: { read: () => false } }),
|
|
435
|
+
},
|
|
436
|
+
}),
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
// `billingAddress:12` never produces `{ billingAddress: { contains: '12' } }`
|
|
440
|
+
// — the spec is excluded, so the token degrades to a free-text search for
|
|
441
|
+
// "12" across `name` (the only remaining free-text field) instead.
|
|
442
|
+
const where = await buildListFilterWhere(
|
|
443
|
+
'billingAddress:12',
|
|
444
|
+
gatedConfig.lists.Organisation,
|
|
445
|
+
'Organisation',
|
|
446
|
+
gatedConfig,
|
|
447
|
+
noAccessArgs,
|
|
448
|
+
)
|
|
449
|
+
expect(where).toEqual({ name: { contains: '12' } })
|
|
344
450
|
})
|
|
345
451
|
})
|
|
346
452
|
|
|
@@ -379,17 +485,28 @@ describe('to-many relationship Filter spec (count comparisons — issue #732)',
|
|
|
379
485
|
expect(spec.suggestions.valueSource).toEqual({ kind: 'none' })
|
|
380
486
|
})
|
|
381
487
|
|
|
382
|
-
it('end-to-end: buildListFilterWhere carries a count marker for `posts:>5`', () => {
|
|
488
|
+
it('end-to-end: buildListFilterWhere carries a count marker for `posts:>5`', async () => {
|
|
383
489
|
const config = countConfig()
|
|
384
|
-
const where = buildListFilterWhere(
|
|
490
|
+
const where = await buildListFilterWhere(
|
|
491
|
+
'posts:>5',
|
|
492
|
+
config.lists.User,
|
|
493
|
+
'User',
|
|
494
|
+
config,
|
|
495
|
+
noAccessArgs,
|
|
496
|
+
)
|
|
385
497
|
expect(where).toEqual({ posts: { _countFilter: { operator: 'gt', value: 5 } } })
|
|
386
498
|
})
|
|
387
499
|
})
|
|
388
500
|
|
|
389
501
|
describe('collectFilterSuggestions', () => {
|
|
390
|
-
it('returns serializable, function-free suggestion metadata', () => {
|
|
502
|
+
it('returns serializable, function-free suggestion metadata', async () => {
|
|
391
503
|
const config = makeConfig()
|
|
392
|
-
const suggestions = collectFilterSuggestions(
|
|
504
|
+
const suggestions = await collectFilterSuggestions(
|
|
505
|
+
config.lists.Post,
|
|
506
|
+
'Post',
|
|
507
|
+
config,
|
|
508
|
+
noAccessArgs,
|
|
509
|
+
)
|
|
393
510
|
// No functions anywhere → JSON round-trips cleanly.
|
|
394
511
|
expect(JSON.parse(JSON.stringify(suggestions))).toEqual(suggestions)
|
|
395
512
|
const status = suggestions.find((s) => s.field === 'status')!
|
|
@@ -397,4 +514,25 @@ describe('collectFilterSuggestions', () => {
|
|
|
397
514
|
const author = suggestions.find((s) => s.field === 'author')!
|
|
398
515
|
expect(author.valueSource).toEqual({ kind: 'relationship', listKey: 'User', many: false })
|
|
399
516
|
})
|
|
517
|
+
|
|
518
|
+
it('excludes a read-denied field from the suggestion metadata (#915)', async () => {
|
|
519
|
+
const gatedConfig: OpenSaasConfig = {
|
|
520
|
+
db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
|
|
521
|
+
lists: {
|
|
522
|
+
Organisation: list({
|
|
523
|
+
fields: {
|
|
524
|
+
name: text(),
|
|
525
|
+
billingAddress: text({ access: { read: () => false } }),
|
|
526
|
+
},
|
|
527
|
+
}),
|
|
528
|
+
},
|
|
529
|
+
}
|
|
530
|
+
const suggestions = await collectFilterSuggestions(
|
|
531
|
+
gatedConfig.lists.Organisation,
|
|
532
|
+
'Organisation',
|
|
533
|
+
gatedConfig,
|
|
534
|
+
noAccessArgs,
|
|
535
|
+
)
|
|
536
|
+
expect(suggestions.map((s) => s.field)).toEqual(['name'])
|
|
537
|
+
})
|
|
400
538
|
})
|
package/src/filter/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { parseFilterQuery } from './parse.js'
|
|
|
13
13
|
export { serializeFilterQuery } from './serialize.js'
|
|
14
14
|
export { buildFilterWhere } from './map.js'
|
|
15
15
|
export { collectFilterSpecs, buildListFilterWhere, collectFilterSuggestions } from './collect.js'
|
|
16
|
+
export type { FilterAccessArgs } from './collect.js'
|
|
16
17
|
export { RELATIONSHIP_COUNT_FILTER_KEY } from './types.js'
|
|
17
18
|
export type {
|
|
18
19
|
FilterOperator,
|
package/src/filter/map.ts
CHANGED
|
@@ -25,7 +25,6 @@ export function buildFilterWhere(
|
|
|
25
25
|
const freeTextFields = Object.keys(specs).filter((field) => specs[field].freeText)
|
|
26
26
|
|
|
27
27
|
for (const token of tokens) {
|
|
28
|
-
// Bare free-text word.
|
|
29
28
|
if (token.field === null) {
|
|
30
29
|
if (token.value) freeTextWords.push(token.value)
|
|
31
30
|
continue
|
|
@@ -33,8 +32,6 @@ export function buildFilterWhere(
|
|
|
33
32
|
|
|
34
33
|
const spec = specs[token.field]
|
|
35
34
|
|
|
36
|
-
// Unknown field / no spec / unsupported operator / empty value →
|
|
37
|
-
// degrade to free text (search the value across free-text fields).
|
|
38
35
|
if (!spec || !spec.operators.includes(token.operator) || token.value === '') {
|
|
39
36
|
if (token.value) freeTextWords.push(token.value)
|
|
40
37
|
continue
|
|
@@ -42,7 +39,6 @@ export function buildFilterWhere(
|
|
|
42
39
|
|
|
43
40
|
const condition = spec.toCondition(token.operator, token.value)
|
|
44
41
|
if (condition === null) {
|
|
45
|
-
// Value couldn't be interpreted for this field → free text.
|
|
46
42
|
freeTextWords.push(token.value)
|
|
47
43
|
continue
|
|
48
44
|
}
|
|
@@ -50,7 +46,6 @@ export function buildFilterWhere(
|
|
|
50
46
|
andConditions.push(condition)
|
|
51
47
|
}
|
|
52
48
|
|
|
53
|
-
// Each free-text word: OR across every free-text field's `eq` mapping.
|
|
54
49
|
if (freeTextFields.length > 0) {
|
|
55
50
|
for (const word of freeTextWords) {
|
|
56
51
|
const orConditions = freeTextFields
|
package/src/filter/parse.ts
CHANGED
|
@@ -48,15 +48,11 @@ export function parseFilterQuery(query: string): FilterToken[] {
|
|
|
48
48
|
let i = 0
|
|
49
49
|
|
|
50
50
|
while (i < n) {
|
|
51
|
-
// Skip leading whitespace between tokens.
|
|
52
51
|
while (i < n && isWhitespace(query[i])) i++
|
|
53
52
|
if (i >= n) break
|
|
54
53
|
|
|
55
54
|
const start = i
|
|
56
55
|
|
|
57
|
-
// Optional `field:` prefix. A URL scheme (`http://…`) is not a field
|
|
58
|
-
// prefix: when the colon is immediately followed by `//`, keep the whole
|
|
59
|
-
// token as free text so pasted URLs are searched verbatim.
|
|
60
56
|
let field: string | null = null
|
|
61
57
|
const prefixMatch = FIELD_PREFIX_RE.exec(query.slice(i))
|
|
62
58
|
if (prefixMatch && !query.startsWith('//', i + prefixMatch[0].length)) {
|
|
@@ -64,8 +60,6 @@ export function parseFilterQuery(query: string): FilterToken[] {
|
|
|
64
60
|
i += prefixMatch[0].length
|
|
65
61
|
}
|
|
66
62
|
|
|
67
|
-
// Comparison operator prefix — only meaningful once a field is present.
|
|
68
|
-
// A leading `>`/`<` on a bare word is left as part of the free-text value.
|
|
69
63
|
let operator: FilterOperator = 'eq'
|
|
70
64
|
if (field) {
|
|
71
65
|
if (query.startsWith('>=', i)) {
|
|
@@ -83,15 +77,14 @@ export function parseFilterQuery(query: string): FilterToken[] {
|
|
|
83
77
|
}
|
|
84
78
|
}
|
|
85
79
|
|
|
86
|
-
// Value: quoted (spaces kept) or a run up to the next whitespace.
|
|
87
80
|
let value = ''
|
|
88
81
|
if (query[i] === '"') {
|
|
89
|
-
i++
|
|
82
|
+
i++
|
|
90
83
|
while (i < n && query[i] !== '"') {
|
|
91
84
|
value += query[i]
|
|
92
85
|
i++
|
|
93
86
|
}
|
|
94
|
-
if (i < n) i++
|
|
87
|
+
if (i < n) i++
|
|
95
88
|
} else {
|
|
96
89
|
while (i < n && !isWhitespace(query[i])) {
|
|
97
90
|
value += query[i]
|
package/src/filter/serialize.ts
CHANGED
|
@@ -56,13 +56,11 @@ export function serializeFilterQuery(tokens: FilterToken[]): string {
|
|
|
56
56
|
const parts: string[] = []
|
|
57
57
|
|
|
58
58
|
for (const token of tokens) {
|
|
59
|
-
// An empty value is not a filter — skip it rather than emit `field:` / `""`.
|
|
60
59
|
if (token.value === '') continue
|
|
61
60
|
|
|
62
61
|
if (token.field === null) {
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// operator guard is needed here.
|
|
62
|
+
// A leading `>`/`<` stays literal for a bare word (the parser only
|
|
63
|
+
// treats them as operators after a `field:`), so no operator guard here.
|
|
66
64
|
parts.push(quoteIfNeeded(token.value, false))
|
|
67
65
|
continue
|
|
68
66
|
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -4,9 +4,6 @@ import type { FieldConfig } from '../config/types.js'
|
|
|
4
4
|
import { validateWithZod } from '../validation/schema.js'
|
|
5
5
|
import { checkFieldAccess } from '../access/field-access.js'
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Validation error collection
|
|
9
|
-
*/
|
|
10
7
|
export class ValidationError extends Error {
|
|
11
8
|
public errors: string[]
|
|
12
9
|
public fieldErrors: Record<string, string>
|
|
@@ -19,10 +16,7 @@ export class ValidationError extends Error {
|
|
|
19
16
|
}
|
|
20
17
|
}
|
|
21
18
|
|
|
22
|
-
/**
|
|
23
|
-
* Database error with field-specific error information
|
|
24
|
-
* Used for Prisma errors like unique constraint violations
|
|
25
|
-
*/
|
|
19
|
+
/** Used for Prisma errors like unique constraint violations. */
|
|
26
20
|
export class DatabaseError extends Error {
|
|
27
21
|
public fieldErrors: Record<string, string>
|
|
28
22
|
public code?: string
|
|
@@ -35,10 +29,6 @@ export class DatabaseError extends Error {
|
|
|
35
29
|
}
|
|
36
30
|
}
|
|
37
31
|
|
|
38
|
-
/**
|
|
39
|
-
* Execute resolveInput hook
|
|
40
|
-
* Allows modification of input data before validation
|
|
41
|
-
*/
|
|
42
32
|
export async function executeResolveInput<
|
|
43
33
|
TOutput = Record<string, unknown>,
|
|
44
34
|
TCreateInput = Record<string, unknown>,
|
|
@@ -71,10 +61,7 @@ export async function executeResolveInput<
|
|
|
71
61
|
return result
|
|
72
62
|
}
|
|
73
63
|
|
|
74
|
-
/**
|
|
75
|
-
* Execute validate hook (supports both 'validate' and deprecated 'validateInput')
|
|
76
|
-
* Allows custom validation logic
|
|
77
|
-
*/
|
|
64
|
+
/** Supports both `validate` and the deprecated `validateInput` alias for backwards compatibility. */
|
|
78
65
|
export async function executeValidate<
|
|
79
66
|
TOutput = Record<string, unknown>,
|
|
80
67
|
TCreateInput = Record<string, unknown>,
|
|
@@ -105,7 +92,6 @@ export async function executeValidate<
|
|
|
105
92
|
context: AccessContext
|
|
106
93
|
},
|
|
107
94
|
): Promise<void> {
|
|
108
|
-
// Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
|
|
109
95
|
const validateHook = hooks?.validate || hooks?.validateInput
|
|
110
96
|
if (!validateHook) {
|
|
111
97
|
return
|
|
@@ -132,10 +118,7 @@ export async function executeValidate<
|
|
|
132
118
|
*/
|
|
133
119
|
export const executeValidateInput = executeValidate
|
|
134
120
|
|
|
135
|
-
/**
|
|
136
|
-
* Execute beforeOperation hook
|
|
137
|
-
* Runs before database operation (cannot modify data)
|
|
138
|
-
*/
|
|
121
|
+
/** Side effects only — cannot modify data before the database write. */
|
|
139
122
|
export async function executeBeforeOperation<
|
|
140
123
|
TOutput = Record<string, unknown>,
|
|
141
124
|
TCreateInput = Record<string, unknown>,
|
|
@@ -172,10 +155,6 @@ export async function executeBeforeOperation<
|
|
|
172
155
|
await hooks.beforeOperation(args as Parameters<typeof hooks.beforeOperation>[0])
|
|
173
156
|
}
|
|
174
157
|
|
|
175
|
-
/**
|
|
176
|
-
* Execute afterOperation hook
|
|
177
|
-
* Runs after database operation
|
|
178
|
-
*/
|
|
179
158
|
export async function executeAfterOperation<
|
|
180
159
|
TOutput = Record<string, unknown>,
|
|
181
160
|
TCreateInput = Record<string, unknown>,
|
|
@@ -410,7 +389,6 @@ export async function executeFieldAfterTransactionHooks(
|
|
|
410
389
|
isTopLevel: boolean,
|
|
411
390
|
originalItem?: Record<string, unknown>,
|
|
412
391
|
): Promise<void> {
|
|
413
|
-
// The persisted/pre-write rows are surfaced only for the top-level list.
|
|
414
392
|
const committedItem = outcome.status === 'committed' && isTopLevel ? outcome.item : undefined
|
|
415
393
|
const committedOriginalItem = isTopLevel ? originalItem : undefined
|
|
416
394
|
|
|
@@ -449,7 +427,6 @@ export async function executeFieldAfterTransactionHooks(
|
|
|
449
427
|
continue
|
|
450
428
|
}
|
|
451
429
|
|
|
452
|
-
// committed
|
|
453
430
|
if (operation === 'delete') {
|
|
454
431
|
await fieldConfig.hooks.afterTransaction({
|
|
455
432
|
...base,
|
|
@@ -479,9 +456,6 @@ export async function executeFieldAfterTransactionHooks(
|
|
|
479
456
|
}
|
|
480
457
|
|
|
481
458
|
/**
|
|
482
|
-
* Execute field-level resolveInput hooks
|
|
483
|
-
* Allows fields to transform their input values before database write
|
|
484
|
-
*
|
|
485
459
|
* NOTE (#789): multi-column fields (e.g. storage image()/file() in
|
|
486
460
|
* Keystone-parity mode) are NOT split here. This phase only resolves each
|
|
487
461
|
* field's value under its LOGICAL key, so that phases 2-3 (list/field
|
|
@@ -506,11 +480,9 @@ export async function executeFieldResolveInputHooks(
|
|
|
506
480
|
let result = { ...resolvedData }
|
|
507
481
|
|
|
508
482
|
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
509
|
-
// Skip if field not in data, or if there's nothing to resolve
|
|
510
483
|
if (!(fieldKey in result)) continue
|
|
511
484
|
if (!fieldConfig.hooks?.resolveInput) continue
|
|
512
485
|
|
|
513
|
-
// Execute field hook
|
|
514
486
|
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
515
487
|
// and we're working with runtime values that match those types
|
|
516
488
|
const resolvedValue = await fieldConfig.hooks.resolveInput({
|
|
@@ -595,10 +567,7 @@ export async function splitMultiColumnFields(
|
|
|
595
567
|
return result
|
|
596
568
|
}
|
|
597
569
|
|
|
598
|
-
/**
|
|
599
|
-
* Execute field-level validate hooks
|
|
600
|
-
* Allows fields to perform custom validation after resolveInput but before database write
|
|
601
|
-
*/
|
|
570
|
+
/** Runs after resolveInput and before the database write. */
|
|
602
571
|
export async function executeFieldValidateHooks(
|
|
603
572
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
604
573
|
inputData: Record<string, any> | undefined,
|
|
@@ -620,11 +589,10 @@ export async function executeFieldValidateHooks(
|
|
|
620
589
|
}
|
|
621
590
|
|
|
622
591
|
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
623
|
-
//
|
|
592
|
+
// validate / deprecated validateInput fallback — see executeValidate
|
|
624
593
|
const validateHook = fieldConfig.hooks?.validate ?? fieldConfig.hooks?.validateInput
|
|
625
594
|
if (!validateHook) continue
|
|
626
595
|
|
|
627
|
-
// Execute field hook
|
|
628
596
|
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
629
597
|
if (operation === 'delete') {
|
|
630
598
|
await validateHook({
|
|
@@ -647,7 +615,6 @@ export async function executeFieldValidateHooks(
|
|
|
647
615
|
addValidationError: addValidationError(fieldKey),
|
|
648
616
|
} as Parameters<typeof validateHook>[0])
|
|
649
617
|
} else {
|
|
650
|
-
// operation === 'update'
|
|
651
618
|
await validateHook({
|
|
652
619
|
listKey,
|
|
653
620
|
fieldKey,
|
|
@@ -666,10 +633,6 @@ export async function executeFieldValidateHooks(
|
|
|
666
633
|
}
|
|
667
634
|
}
|
|
668
635
|
|
|
669
|
-
/**
|
|
670
|
-
* Execute field-level beforeOperation hooks (side effects only)
|
|
671
|
-
* Allows fields to perform side effects before database write
|
|
672
|
-
*/
|
|
673
636
|
export async function executeFieldBeforeOperationHooks(
|
|
674
637
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
675
638
|
inputData: Record<string, any>,
|
|
@@ -683,13 +646,9 @@ export async function executeFieldBeforeOperationHooks(
|
|
|
683
646
|
item?: any,
|
|
684
647
|
): Promise<void> {
|
|
685
648
|
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
686
|
-
// Skip if no hooks defined
|
|
687
649
|
if (!fieldConfig.hooks?.beforeOperation) continue
|
|
688
|
-
// Skip if field not in data (for create/update)
|
|
689
650
|
if (operation !== 'delete' && !(fieldKey in resolvedData)) continue
|
|
690
651
|
|
|
691
|
-
// Execute field hook (side effects only, no return value used)
|
|
692
|
-
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
693
652
|
if (operation === 'delete') {
|
|
694
653
|
await fieldConfig.hooks.beforeOperation({
|
|
695
654
|
listKey,
|
|
@@ -708,7 +667,6 @@ export async function executeFieldBeforeOperationHooks(
|
|
|
708
667
|
context,
|
|
709
668
|
} as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
|
|
710
669
|
} else {
|
|
711
|
-
// operation === 'update'
|
|
712
670
|
await fieldConfig.hooks.beforeOperation({
|
|
713
671
|
listKey,
|
|
714
672
|
fieldKey,
|
|
@@ -722,10 +680,6 @@ export async function executeFieldBeforeOperationHooks(
|
|
|
722
680
|
}
|
|
723
681
|
}
|
|
724
682
|
|
|
725
|
-
/**
|
|
726
|
-
* Execute field-level afterOperation hooks (side effects only)
|
|
727
|
-
* Allows fields to perform side effects after database operations
|
|
728
|
-
*/
|
|
729
683
|
export async function executeFieldAfterOperationHooks(
|
|
730
684
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
731
685
|
item: any,
|
|
@@ -739,10 +693,8 @@ export async function executeFieldAfterOperationHooks(
|
|
|
739
693
|
originalItem?: any,
|
|
740
694
|
): Promise<void> {
|
|
741
695
|
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
742
|
-
// Skip if no hooks defined
|
|
743
696
|
if (!fieldConfig.hooks?.afterOperation) continue
|
|
744
697
|
|
|
745
|
-
// Execute field hook (side effects only, no return value used)
|
|
746
698
|
if (operation === 'delete') {
|
|
747
699
|
await fieldConfig.hooks.afterOperation({
|
|
748
700
|
listKey,
|
|
@@ -762,7 +714,6 @@ export async function executeFieldAfterOperationHooks(
|
|
|
762
714
|
context,
|
|
763
715
|
} as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
|
|
764
716
|
} else {
|
|
765
|
-
// operation === 'update'
|
|
766
717
|
await fieldConfig.hooks.afterOperation({
|
|
767
718
|
listKey,
|
|
768
719
|
fieldKey,
|
|
@@ -777,10 +728,6 @@ export async function executeFieldAfterOperationHooks(
|
|
|
777
728
|
}
|
|
778
729
|
}
|
|
779
730
|
|
|
780
|
-
/**
|
|
781
|
-
* Validate field-level validation rules using Zod
|
|
782
|
-
* Checks isRequired, length constraints, etc.
|
|
783
|
-
*/
|
|
784
731
|
export function validateFieldRules(
|
|
785
732
|
data: Record<string, unknown>,
|
|
786
733
|
fieldConfigs: Record<string, FieldConfig>,
|
|
@@ -792,7 +739,6 @@ export function validateFieldRules(
|
|
|
792
739
|
return { errors: [], fieldErrors: {} }
|
|
793
740
|
}
|
|
794
741
|
|
|
795
|
-
// Convert field errors to array of error messages
|
|
796
742
|
const errors = Object.entries(result.errors).map(([_field, message]) => message)
|
|
797
743
|
|
|
798
744
|
return { errors, fieldErrors: result.errors }
|