@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/context/index.ts
CHANGED
|
@@ -4,17 +4,19 @@ import {
|
|
|
4
4
|
checkAccess,
|
|
5
5
|
mergeFilters,
|
|
6
6
|
filterReadableFields,
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
buildAccessScopedInclude,
|
|
8
|
+
buildAccessScopedWhere,
|
|
9
9
|
stripVirtualFieldsFromInclude,
|
|
10
10
|
foldDeclaredDependencies,
|
|
11
|
+
validateQueryKeys,
|
|
12
|
+
validateQueryFieldReadAccess,
|
|
11
13
|
} from '../access/index.js'
|
|
12
14
|
import type { DeclaredOnlyTree } from '../access/index.js'
|
|
13
15
|
import { ValidationError, DatabaseError } from '../hooks/index.js'
|
|
14
16
|
import { getDbKey } from '../lib/case-utils.js'
|
|
15
17
|
import type { PrismaClientLike } from '../access/types.js'
|
|
16
|
-
import { buildInclude, pickFields, isFragment } from '../query/index.js'
|
|
17
|
-
import type { FieldSelection } from '../query/index.js'
|
|
18
|
+
import { buildInclude, pickFields, isFragment, buildFieldSelectionScope } from '../query/index.js'
|
|
19
|
+
import type { FieldSelection, FieldSelectionScope } from '../query/index.js'
|
|
18
20
|
import { getRelationshipOptions } from '../query/relationship-options.js'
|
|
19
21
|
import {
|
|
20
22
|
runWritePipeline,
|
|
@@ -22,29 +24,31 @@ import {
|
|
|
22
24
|
updateWriteStrategy,
|
|
23
25
|
deleteWriteStrategy,
|
|
24
26
|
} from './write-pipeline.js'
|
|
27
|
+
import { AfterTransactionError } from './transaction-boundary.js'
|
|
28
|
+
import { TransactionRegistry } from '../access/transaction-registry.js'
|
|
29
|
+
import type { TransactionSettleOutcome } from '../access/transaction-registry.js'
|
|
25
30
|
|
|
26
31
|
export type ServerActionProps =
|
|
27
32
|
| { listKey: string; action: 'create'; data: Record<string, unknown> }
|
|
28
33
|
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
|
|
29
34
|
| { listKey: string; action: 'delete'; id: string }
|
|
30
35
|
| { listKey: string; action: 'bulkDelete'; ids: string[] }
|
|
31
|
-
// Custom list-specific
|
|
32
|
-
// declared in the list's `ui.listView.bulkActions`; the client only
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// `bulkDelete`).
|
|
36
|
+
// Custom list-specific bulk action (issue #736). `key` names an action
|
|
37
|
+
// declared in the list's `ui.listView.bulkActions`; the client sends only
|
|
38
|
+
// this serialisable `{ key, ids }` and the server-side `handler` (never
|
|
39
|
+
// serialised) is looked up by `key`. Returns a distinct `{ bulkAction,
|
|
40
|
+
// message? }` shape, never `success`, so a redirect-on-`success` wrapper
|
|
41
|
+
// does not hijack it.
|
|
38
42
|
| { listKey: string; action: 'bulkAction'; key: string; ids: string[] }
|
|
39
43
|
// Relationship-table row removal (ADR-0018, #739). `listKey`/`id` target the
|
|
40
|
-
// RELATED row, so the related list's own access control
|
|
41
|
-
// the parent's
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// many-to-many join); `mode: 'delete'`
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
44
|
+
// RELATED row, so the related list's own access control and hooks apply —
|
|
45
|
+
// never the parent's. The other relationship-table actions below share this
|
|
46
|
+
// boundary. `mode: 'disconnect'` unlinks the row by disconnecting its
|
|
47
|
+
// back-reference (`field`; `parentId` is needed only when that back-reference
|
|
48
|
+
// is to-many, e.g. a many-to-many join) without deleting it; `mode: 'delete'`
|
|
49
|
+
// truly deletes the row. Returns a distinct `{ removed }` shape, never
|
|
50
|
+
// `success`, so a UI wrapper that redirects on `success` does not hijack an
|
|
51
|
+
// in-place row removal.
|
|
48
52
|
| {
|
|
49
53
|
listKey: string
|
|
50
54
|
action: 'removeRelated'
|
|
@@ -53,13 +57,10 @@ export type ServerActionProps =
|
|
|
53
57
|
field?: string
|
|
54
58
|
parentId?: string
|
|
55
59
|
}
|
|
56
|
-
// Relationship-table inline cell edit (issue #737)
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// `removeRelated`. It returns a distinct `{ updated }` shape (never a
|
|
61
|
-
// single-op `success`) so a UI wrapper that redirects on `success` — the
|
|
62
|
-
// item-form pattern — cannot hijack an in-place cell edit.
|
|
60
|
+
// Relationship-table inline cell edit (issue #737); same ADR-0018 boundary
|
|
61
|
+
// as `removeRelated` — `listKey`/`id` target the RELATED row. Returns a
|
|
62
|
+
// distinct `{ updated }` shape, never `success`, so a UI wrapper that
|
|
63
|
+
// redirects on `success` does not hijack an in-place cell edit.
|
|
63
64
|
| {
|
|
64
65
|
listKey: string
|
|
65
66
|
action: 'updateRelated'
|
|
@@ -67,14 +68,12 @@ export type ServerActionProps =
|
|
|
67
68
|
field: string
|
|
68
69
|
value: unknown
|
|
69
70
|
}
|
|
70
|
-
// Relationship-table pre-linked create (issue #738)
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// `success`) so a UI wrapper that redirects on `success` — the item-form
|
|
77
|
-
// pattern — does not hijack an in-place create.
|
|
71
|
+
// Relationship-table pre-linked create (issue #738); same ADR-0018 boundary
|
|
72
|
+
// as `removeRelated` — `listKey` targets the RELATED list. The back-reference
|
|
73
|
+
// to the parent is set on the SERVER from `field`/`parentId`, never trusted
|
|
74
|
+
// from `data`, so a hostile client cannot re-target the link. Returns a
|
|
75
|
+
// distinct `{ created }` shape, never `success`, so a UI wrapper that
|
|
76
|
+
// redirects on `success` does not hijack an in-place create.
|
|
78
77
|
| {
|
|
79
78
|
listKey: string
|
|
80
79
|
action: 'createRelated'
|
|
@@ -91,21 +90,11 @@ export type ServerActionProps =
|
|
|
91
90
|
selectedIds?: string[]
|
|
92
91
|
}
|
|
93
92
|
|
|
94
|
-
/**
|
|
95
|
-
* Tracks which (listName, operation) pairs have already warned about an ignored
|
|
96
|
-
* `select` argument, so a misused read op warns once rather than on every call.
|
|
97
|
-
*/
|
|
98
93
|
const selectWarnings = new Set<string>()
|
|
99
94
|
|
|
100
95
|
/**
|
|
101
|
-
* Warn
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* `context.db` reads never apply Prisma `select` semantics — narrowing is done
|
|
105
|
-
* via `include` or a fragment `query`. The op still runs and returns the full,
|
|
106
|
-
* access-filtered result, so this is a visible no-op rather than an error.
|
|
107
|
-
*
|
|
108
|
-
* Centralised here so every affected read op shares one implementation.
|
|
96
|
+
* Warn once per (list, operation) when a caller passes `select` to a read op
|
|
97
|
+
* that ignores it. See "Narrowing Reads" in packages/core/CLAUDE.md.
|
|
109
98
|
*/
|
|
110
99
|
function warnIfSelectIgnored(
|
|
111
100
|
args: { select?: unknown } | undefined,
|
|
@@ -126,9 +115,6 @@ function warnIfSelectIgnored(
|
|
|
126
115
|
)
|
|
127
116
|
}
|
|
128
117
|
|
|
129
|
-
/**
|
|
130
|
-
* Check if a list is configured as a singleton
|
|
131
|
-
*/
|
|
132
118
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
133
119
|
function isSingletonList(listConfig: ListConfig<any>): boolean {
|
|
134
120
|
return !!listConfig.isSingleton
|
|
@@ -136,25 +122,17 @@ function isSingletonList(listConfig: ListConfig<any>): boolean {
|
|
|
136
122
|
|
|
137
123
|
/**
|
|
138
124
|
* Compute the set of single-field unique selectors a `findUnique` `where` may be
|
|
139
|
-
* keyed by, derived from what the list config exposes at runtime
|
|
125
|
+
* keyed by, derived from what the list config exposes at runtime: `id`, plus any
|
|
126
|
+
* field declared `isIndexed: 'unique'` — for a `relationship` field, this is the
|
|
127
|
+
* foreign-key column name (`<field>Id`), since that's the column Prisma marks
|
|
128
|
+
* `@unique`, not the relation field itself.
|
|
140
129
|
*
|
|
141
|
-
* The
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* -
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* Chosen rule (documented intentionally): the config does NOT expose compound
|
|
149
|
-
* (`@@unique`) keys at runtime — there is no list-level unique declaration in the
|
|
150
|
-
* config API — so we cannot validate compound `<Model>_<a>_<b>` selectors. We
|
|
151
|
-
* therefore enforce the tractable subset: `where` must contain EXACTLY ONE
|
|
152
|
-
* recognised single-field unique key and NO other keys. This rejects non-unique
|
|
153
|
-
* filters (the bug in #567) and rejects extra non-unique keys alongside a unique
|
|
154
|
-
* one, while never falsely rejecting a valid single-field unique lookup. If a
|
|
155
|
-
* project legitimately needs a compound-unique lookup, that path is not covered
|
|
156
|
-
* here and would need explicit config support; the safe escape hatch for any
|
|
157
|
-
* non-unique single-row lookup is `findFirst` (see #565).
|
|
130
|
+
* The config exposes no list-level compound (`@@unique`) declaration, so this
|
|
131
|
+
* cannot validate a compound `<Model>_<a>_<b>` selector — `where` must contain
|
|
132
|
+
* exactly one recognised single-field unique key and no others. This rejects
|
|
133
|
+
* non-unique filters (#567) without ever rejecting a valid single-field unique
|
|
134
|
+
* lookup; a compound-unique or otherwise non-unique lookup should use
|
|
135
|
+
* `findFirst` instead (see #565).
|
|
158
136
|
*/
|
|
159
137
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
160
138
|
function getUniqueWhereKeys(listConfig: ListConfig<any>): Set<string> {
|
|
@@ -200,10 +178,6 @@ function assertUniqueWhere(
|
|
|
200
178
|
}
|
|
201
179
|
}
|
|
202
180
|
|
|
203
|
-
/**
|
|
204
|
-
* Check if auto-create is enabled for a singleton list
|
|
205
|
-
* Defaults to true if not explicitly set to false
|
|
206
|
-
*/
|
|
207
181
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
208
182
|
function shouldAutoCreate(listConfig: ListConfig<any>): boolean {
|
|
209
183
|
if (!listConfig.isSingleton) return false
|
|
@@ -211,22 +185,15 @@ function shouldAutoCreate(listConfig: ListConfig<any>): boolean {
|
|
|
211
185
|
return listConfig.isSingleton.autoCreate !== false
|
|
212
186
|
}
|
|
213
187
|
|
|
214
|
-
/**
|
|
215
|
-
* Extract default values from field configs
|
|
216
|
-
* Used to auto-create singleton records with sensible defaults
|
|
217
|
-
*/
|
|
218
188
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
219
189
|
function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
|
|
220
190
|
const data: Record<string, unknown> = {}
|
|
221
191
|
|
|
222
192
|
for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
223
|
-
// Skip virtual fields - they're not stored in database
|
|
224
193
|
if (fieldConfig.virtual) continue
|
|
225
194
|
|
|
226
|
-
// Skip system fields (id, createdAt, updatedAt)
|
|
227
195
|
if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt') continue
|
|
228
196
|
|
|
229
|
-
// Add default value if present
|
|
230
197
|
if ('defaultValue' in fieldConfig && fieldConfig.defaultValue !== undefined) {
|
|
231
198
|
data[fieldKey] = fieldConfig.defaultValue
|
|
232
199
|
}
|
|
@@ -235,12 +202,8 @@ function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
|
|
|
235
202
|
return data
|
|
236
203
|
}
|
|
237
204
|
|
|
238
|
-
/**
|
|
239
|
-
* Parse Prisma error and convert to user-friendly DatabaseError
|
|
240
|
-
*/
|
|
241
205
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
242
206
|
function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
243
|
-
// Check if it's a Prisma error
|
|
244
207
|
if (
|
|
245
208
|
error &&
|
|
246
209
|
typeof error === 'object' &&
|
|
@@ -250,15 +213,13 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
250
213
|
) {
|
|
251
214
|
const prismaError = error as { code: string; meta?: { target?: string[] }; message?: string }
|
|
252
215
|
|
|
253
|
-
//
|
|
216
|
+
// P2002 is Prisma's unique constraint violation code.
|
|
254
217
|
if (prismaError.code === 'P2002') {
|
|
255
218
|
const target = prismaError.meta?.target
|
|
256
219
|
const fieldErrors: Record<string, string> = {}
|
|
257
220
|
|
|
258
221
|
if (target && Array.isArray(target)) {
|
|
259
|
-
// Get field names from the constraint target
|
|
260
222
|
for (const fieldName of target) {
|
|
261
|
-
// Get the field config to get a better label
|
|
262
223
|
const fieldConfig = listConfig.fields[fieldName]
|
|
263
224
|
const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
|
|
264
225
|
|
|
@@ -269,7 +230,6 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
269
230
|
}
|
|
270
231
|
}
|
|
271
232
|
|
|
272
|
-
// Create a user-friendly general message
|
|
273
233
|
const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ')
|
|
274
234
|
return new DatabaseError(
|
|
275
235
|
`${fieldLabels} must be unique. The value you entered is already in use.`,
|
|
@@ -281,7 +241,6 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
281
241
|
return new DatabaseError('A record with this value already exists', {}, prismaError.code)
|
|
282
242
|
}
|
|
283
243
|
|
|
284
|
-
// Handle other Prisma errors - return generic message
|
|
285
244
|
return new DatabaseError(
|
|
286
245
|
prismaError.message || 'A database error occurred',
|
|
287
246
|
{},
|
|
@@ -289,22 +248,16 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
289
248
|
)
|
|
290
249
|
}
|
|
291
250
|
|
|
292
|
-
// Not a Prisma error, return as-is if it's already an Error
|
|
293
251
|
if (error instanceof Error) {
|
|
294
252
|
return error
|
|
295
253
|
}
|
|
296
254
|
|
|
297
|
-
// Unknown error type
|
|
298
255
|
return new Error('An unknown error occurred')
|
|
299
256
|
}
|
|
300
257
|
|
|
301
258
|
/**
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* Mirrors Prisma's `TransactionIsolationLevel`. The level passed to
|
|
305
|
-
* {@link StackContext.transaction} is forwarded to the underlying interactive
|
|
306
|
-
* transaction; provider support varies (e.g. `Serializable` is supported by
|
|
307
|
-
* PostgreSQL — required for the concurrency-sensitive capacity-gate pattern).
|
|
259
|
+
* Mirrors Prisma's `TransactionIsolationLevel`. Provider support varies —
|
|
260
|
+
* e.g. `Serializable` requires PostgreSQL.
|
|
308
261
|
*/
|
|
309
262
|
export type TransactionIsolationLevel =
|
|
310
263
|
'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable' | 'Snapshot'
|
|
@@ -380,13 +333,34 @@ export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLik
|
|
|
380
333
|
}
|
|
381
334
|
|
|
382
335
|
/**
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
336
|
+
* Drain a `context.transaction()` owner's deferral registry once its callback
|
|
337
|
+
* (and any real underlying transaction) has settled (ADR-0028). A transaction/
|
|
338
|
+
* callback error always wins — compensators still all run, but their errors
|
|
339
|
+
* are discarded in favor of re-surfacing the original, matching the Write
|
|
340
|
+
* Pipeline's `txError` precedence — otherwise any deferred `afterTransaction`
|
|
341
|
+
* errors reject with {@link AfterTransactionError} even though the callback
|
|
342
|
+
* succeeded and the transaction committed.
|
|
389
343
|
*/
|
|
344
|
+
async function settleTransactionOwner<T>(
|
|
345
|
+
settled: Promise<T>,
|
|
346
|
+
registry: TransactionRegistry,
|
|
347
|
+
): Promise<T> {
|
|
348
|
+
const errors: unknown[] = []
|
|
349
|
+
let result: T
|
|
350
|
+
try {
|
|
351
|
+
result = await settled
|
|
352
|
+
} catch (err) {
|
|
353
|
+
const outcome: TransactionSettleOutcome = { status: 'rolled-back', error: err }
|
|
354
|
+
await registry.drain(outcome, errors)
|
|
355
|
+
throw err
|
|
356
|
+
}
|
|
357
|
+
await registry.drain({ status: 'committed' }, errors)
|
|
358
|
+
if (errors.length > 0) {
|
|
359
|
+
throw new AfterTransactionError(errors)
|
|
360
|
+
}
|
|
361
|
+
return result
|
|
362
|
+
}
|
|
363
|
+
|
|
390
364
|
export function getContext<
|
|
391
365
|
TConfig extends OpenSaasConfig,
|
|
392
366
|
TPrisma extends PrismaClientLike = PrismaClientLike,
|
|
@@ -399,13 +373,14 @@ export function getContext<
|
|
|
399
373
|
// Internal: when rebuilding the context against a transaction client, reuse the
|
|
400
374
|
// already-initialised plugin services rather than re-running plugin runtimes.
|
|
401
375
|
_sharedPlugins?: Record<string, unknown>,
|
|
376
|
+
// Internal (ADR-0028, #899): when rebuilding the context for a transaction
|
|
377
|
+
// owner's callback body, carry the deferral registry so writes reached
|
|
378
|
+
// through this context join it instead of firing afterTransaction eagerly.
|
|
379
|
+
_transactionOwner?: TransactionRegistry,
|
|
402
380
|
): StackContext<TPrisma> {
|
|
403
|
-
//
|
|
404
|
-
// Type is intentionally broad to allow dynamic model access
|
|
381
|
+
// Broad type to allow dynamic model access; populated by populateDbDelegate below.
|
|
405
382
|
const db: Record<string, unknown> = {}
|
|
406
383
|
|
|
407
|
-
// Create context with db reference (will be populated below)
|
|
408
|
-
// Storage utilities can be provided via parameter or use default stubs
|
|
409
384
|
const context: AccessContext<TPrisma> = {
|
|
410
385
|
session,
|
|
411
386
|
prisma: prisma as TPrisma,
|
|
@@ -437,15 +412,13 @@ export function getContext<
|
|
|
437
412
|
plugins: _sharedPlugins ?? {},
|
|
438
413
|
_isSudo,
|
|
439
414
|
_resolveOutputChain: [],
|
|
415
|
+
_transactionOwner,
|
|
440
416
|
}
|
|
441
417
|
|
|
442
|
-
// Create access-controlled operations for each list, populating `db` in place.
|
|
443
418
|
populateDbDelegate(db, config, prisma, context)
|
|
444
419
|
|
|
445
|
-
// Execute plugin runtime functions and populate context.plugins.
|
|
446
420
|
// Skipped when reusing shared plugins (transaction rebind) so runtimes — and
|
|
447
421
|
// any side effects they carry — run exactly once per top-level context.
|
|
448
|
-
// Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
|
|
449
422
|
if (!_sharedPlugins) {
|
|
450
423
|
const pluginsToExecute = config._plugins || config.plugins || []
|
|
451
424
|
for (const plugin of pluginsToExecute) {
|
|
@@ -459,40 +432,24 @@ export function getContext<
|
|
|
459
432
|
)
|
|
460
433
|
} catch (error) {
|
|
461
434
|
console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
|
|
462
|
-
// Continue with other plugins even if one fails
|
|
463
435
|
}
|
|
464
436
|
}
|
|
465
437
|
}
|
|
466
438
|
}
|
|
467
439
|
|
|
468
|
-
//
|
|
469
|
-
//
|
|
440
|
+
// Returns a result object instead of throwing — required for server actions
|
|
441
|
+
// to work in Next.js production builds.
|
|
470
442
|
async function serverAction(props: ServerActionProps): Promise<
|
|
471
443
|
| { success: true; data: unknown }
|
|
472
444
|
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
// list-level bulk operation.
|
|
445
|
+
// The distinct shapes below (never `success`) mirror the ServerActionProps
|
|
446
|
+
// variants above — see their comments for the redirect-on-`success`
|
|
447
|
+
// footgun each one avoids.
|
|
477
448
|
| { deleted: number; total: number }
|
|
478
|
-
// Relationship-table row removal reports `removed` (with an optional reason)
|
|
479
|
-
// rather than `success` — same distinct-shape rationale as `bulkDelete`, so
|
|
480
|
-
// an in-place removal never triggers a redirect-on-success wrapper.
|
|
481
449
|
| { removed: boolean; error?: string }
|
|
482
|
-
// Relationship-table pre-linked create reports `created` (with the new row's
|
|
483
|
-
// id, or an error + fieldErrors for the drawer) rather than `success` — same
|
|
484
|
-
// distinct-shape rationale, so an in-place create never triggers a
|
|
485
|
-
// redirect-on-success wrapper.
|
|
486
450
|
| { created: boolean; id?: string; error?: string; fieldErrors?: Record<string, string> }
|
|
487
|
-
// Custom Bulk action (issue #736) reports `bulkAction` with the handler's
|
|
488
|
-
// optional `message` (success) or an `error` (not found / denied / threw) —
|
|
489
|
-
// again a distinct shape from single-op `success`.
|
|
490
451
|
| { bulkAction: true; message?: string }
|
|
491
452
|
| { bulkAction: false; error: string }
|
|
492
|
-
// Relationship-table inline cell edit reports `updated` (with an optional
|
|
493
|
-
// reason + fieldErrors for the edited cell) rather than `success` — same
|
|
494
|
-
// distinct-shape rationale, so an in-place cell edit never triggers a
|
|
495
|
-
// redirect-on-success wrapper.
|
|
496
453
|
| { updated: boolean; error?: string; fieldErrors?: Record<string, string> }
|
|
497
454
|
> {
|
|
498
455
|
const dbKey = getDbKey(props.listKey)
|
|
@@ -579,8 +536,7 @@ export function getContext<
|
|
|
579
536
|
}
|
|
580
537
|
}
|
|
581
538
|
|
|
582
|
-
//
|
|
583
|
-
// through the secured context, so the related list's access + hooks apply.
|
|
539
|
+
// Runs on the RELATED row (ADR-0018 boundary — see ServerActionProps above).
|
|
584
540
|
// Honours Silent failure: an access-denied operation returns `null`, which
|
|
585
541
|
// becomes `{ removed: false }` with a generic reason — never leaking whether
|
|
586
542
|
// the row was denied or absent.
|
|
@@ -621,14 +577,13 @@ export function getContext<
|
|
|
621
577
|
}
|
|
622
578
|
}
|
|
623
579
|
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
// as `{ created: false }` with a generic reason (no denied-vs-absent leak).
|
|
580
|
+
// Runs on the RELATED list (ADR-0018 boundary — see ServerActionProps above).
|
|
581
|
+
// The back-reference to the parent is set here from `field`/`parentId` (a
|
|
582
|
+
// to-one back-ref connects a single parent; a to-many back-ref, e.g.
|
|
583
|
+
// many-to-many, connects the parent by id), so the client can never
|
|
584
|
+
// re-target the link. Honours Silent failure: an access-denied create
|
|
585
|
+
// returns `null`, surfaced as `{ created: false }` with a generic reason
|
|
586
|
+
// (no denied-vs-absent leak).
|
|
632
587
|
if (props.action === 'createRelated') {
|
|
633
588
|
try {
|
|
634
589
|
// Defensive guard (hardening; unreachable from the drawer, which always
|
|
@@ -686,11 +641,9 @@ export function getContext<
|
|
|
686
641
|
}
|
|
687
642
|
}
|
|
688
643
|
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
// never the parent's. Honours Silent failure: an access-denied update returns
|
|
693
|
-
// `null`, surfaced as `{ updated: false }` with a generic reason (no
|
|
644
|
+
// Updates ONE scalar field on the RELATED row (ADR-0018 boundary — see
|
|
645
|
+
// ServerActionProps above). Honours Silent failure: an access-denied update
|
|
646
|
+
// returns `null`, surfaced as `{ updated: false }` with a generic reason (no
|
|
694
647
|
// denied-vs-absent leak); a validation/db error surfaces its message and
|
|
695
648
|
// fieldErrors so the cell can revert with a reason and show an inline error.
|
|
696
649
|
if (props.action === 'updateRelated') {
|
|
@@ -765,7 +718,6 @@ export function getContext<
|
|
|
765
718
|
data: result,
|
|
766
719
|
}
|
|
767
720
|
} catch (error) {
|
|
768
|
-
// Handle ValidationError (has fieldErrors)
|
|
769
721
|
if (error instanceof ValidationError) {
|
|
770
722
|
return {
|
|
771
723
|
success: false,
|
|
@@ -774,7 +726,6 @@ export function getContext<
|
|
|
774
726
|
}
|
|
775
727
|
}
|
|
776
728
|
|
|
777
|
-
// Handle DatabaseError (has fieldErrors)
|
|
778
729
|
if (error instanceof DatabaseError) {
|
|
779
730
|
return {
|
|
780
731
|
success: false,
|
|
@@ -783,7 +734,6 @@ export function getContext<
|
|
|
783
734
|
}
|
|
784
735
|
}
|
|
785
736
|
|
|
786
|
-
// Parse and convert Prisma errors to user-friendly DatabaseError
|
|
787
737
|
const dbError = parsePrismaError(error, listConfig)
|
|
788
738
|
if (dbError instanceof DatabaseError) {
|
|
789
739
|
return {
|
|
@@ -793,7 +743,6 @@ export function getContext<
|
|
|
793
743
|
}
|
|
794
744
|
}
|
|
795
745
|
|
|
796
|
-
// Generic error fallback
|
|
797
746
|
return {
|
|
798
747
|
success: false,
|
|
799
748
|
error: dbError.message,
|
|
@@ -801,34 +750,77 @@ export function getContext<
|
|
|
801
750
|
}
|
|
802
751
|
}
|
|
803
752
|
|
|
804
|
-
//
|
|
805
|
-
// but still executes all hooks and validation
|
|
753
|
+
// Bypasses access control; hooks and validation still run.
|
|
806
754
|
function sudo(): StackContext<TPrisma> {
|
|
807
|
-
return getContext(
|
|
755
|
+
return getContext(
|
|
756
|
+
config,
|
|
757
|
+
prisma,
|
|
758
|
+
session,
|
|
759
|
+
context.storage,
|
|
760
|
+
true,
|
|
761
|
+
undefined,
|
|
762
|
+
// ADR-0028: a sudo write issued from inside an owned transaction (e.g.
|
|
763
|
+
// `tx.sudo().db.x.create()`) must still defer to that owner.
|
|
764
|
+
context._transactionOwner,
|
|
765
|
+
)
|
|
808
766
|
}
|
|
809
767
|
|
|
810
|
-
// Interactive, hook-firing transaction (#614).
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
768
|
+
// Interactive, hook-firing transaction (#614). See the `transaction` doc on
|
|
769
|
+
// `StackContext` above for the atomicity/isolation/retry contract.
|
|
770
|
+
//
|
|
771
|
+
// This call OWNS a deferral registry for its callback's writes (ADR-0028):
|
|
772
|
+
// it always observes when its own callback settles — resolve/reject — even
|
|
773
|
+
// when the underlying client cannot open a real interactive transaction, so
|
|
774
|
+
// every `txContext.db.*` write defers its transaction-boundary bracket here
|
|
775
|
+
// instead of firing eagerly, and this call flushes them with the real
|
|
776
|
+
// outcome once the callback (and any real transaction) has settled. A
|
|
777
|
+
// `transaction()` nested inside another joins the outer owner's queue
|
|
778
|
+
// rather than creating a second one.
|
|
816
779
|
function transaction<T>(
|
|
817
780
|
fn: (txContext: StackContext<TPrisma>) => Promise<T>,
|
|
818
781
|
options?: TransactionOptions,
|
|
819
782
|
): Promise<T> {
|
|
820
|
-
|
|
821
|
-
if (typeof client.$transaction !== 'function') {
|
|
822
|
-
// No interactive transaction available — either a plain client/mock or we
|
|
823
|
-
// are already inside a transaction (a Prisma tx client exposes no
|
|
824
|
-
// `$transaction`). Run directly: hook/access semantics are identical and
|
|
825
|
-
// atomicity is provided by any enclosing transaction.
|
|
783
|
+
if (context._transactionOwner) {
|
|
826
784
|
return fn(returned)
|
|
827
785
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
786
|
+
|
|
787
|
+
const registry = new TransactionRegistry()
|
|
788
|
+
const client = prisma as unknown as TransactionCapable<TPrisma>
|
|
789
|
+
|
|
790
|
+
const settled =
|
|
791
|
+
typeof client.$transaction !== 'function'
|
|
792
|
+
? // No interactive transaction available (plain client/mock, or already
|
|
793
|
+
// inside one — see `TransactionCapable` above). Run directly: hook/
|
|
794
|
+
// access semantics are identical, atomicity comes from the enclosing
|
|
795
|
+
// transaction.
|
|
796
|
+
fn(
|
|
797
|
+
getContext(
|
|
798
|
+
config,
|
|
799
|
+
prisma,
|
|
800
|
+
session,
|
|
801
|
+
context.storage,
|
|
802
|
+
_isSudo,
|
|
803
|
+
context.plugins,
|
|
804
|
+
registry,
|
|
805
|
+
),
|
|
806
|
+
)
|
|
807
|
+
: (client.$transaction(
|
|
808
|
+
(tx) =>
|
|
809
|
+
fn(
|
|
810
|
+
getContext(
|
|
811
|
+
config,
|
|
812
|
+
tx,
|
|
813
|
+
session,
|
|
814
|
+
context.storage,
|
|
815
|
+
_isSudo,
|
|
816
|
+
context.plugins,
|
|
817
|
+
registry,
|
|
818
|
+
),
|
|
819
|
+
),
|
|
820
|
+
options,
|
|
821
|
+
) as Promise<T>)
|
|
822
|
+
|
|
823
|
+
return settleTransactionOwner(settled, registry)
|
|
832
824
|
}
|
|
833
825
|
|
|
834
826
|
const returned: StackContext<TPrisma> = {
|
|
@@ -865,7 +857,6 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
865
857
|
for (const [listName, listConfig] of Object.entries(config.lists)) {
|
|
866
858
|
const dbKey = getDbKey(listName)
|
|
867
859
|
|
|
868
|
-
// Create base operations
|
|
869
860
|
const createOp = createCreate(listName, listConfig, prisma, context, config)
|
|
870
861
|
const findManyOp = createFindMany(listName, listConfig, prisma, context, config)
|
|
871
862
|
const updateOp = createUpdate(listName, listConfig, prisma, context, config)
|
|
@@ -876,7 +867,7 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
876
867
|
create: createOp,
|
|
877
868
|
update: updateOp,
|
|
878
869
|
delete: createDelete(listName, listConfig, prisma, context, config),
|
|
879
|
-
count: createCount(listName, listConfig, prisma, context),
|
|
870
|
+
count: createCount(listName, listConfig, prisma, context, config),
|
|
880
871
|
createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
|
|
881
872
|
updateMany: createUpdateMany(
|
|
882
873
|
listName,
|
|
@@ -889,7 +880,6 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
889
880
|
),
|
|
890
881
|
}
|
|
891
882
|
|
|
892
|
-
// Add get() method for singleton lists
|
|
893
883
|
if (isSingletonList(listConfig)) {
|
|
894
884
|
operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
|
|
895
885
|
}
|
|
@@ -921,11 +911,19 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
921
911
|
*
|
|
922
912
|
* A fragment's own `include` and a sudo caller's `include` are folded and
|
|
923
913
|
* used as-is, matching their existing (unmerged) treatment. A non-sudo
|
|
924
|
-
* caller include is folded and then
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
914
|
+
* caller include is folded and then scoped by `buildAccessScopedInclude`
|
|
915
|
+
* (ADR-0026) — caller-directed, so a relation named nowhere in the folded
|
|
916
|
+
* tree never has its list's `query` access evaluated at all. A bare read
|
|
917
|
+
* stays on the exact ADR-0024 path — `include: undefined`, no related
|
|
918
|
+
* `query` access evaluated — unless folding actually added something, which
|
|
919
|
+
* only happens when a field on this list declares `needs`.
|
|
920
|
+
*
|
|
921
|
+
* Also returns the `FieldSelectionScope` a fragment's own field selection
|
|
922
|
+
* produces (ADR-0027), so the caller can pass it to `filterReadableFields`
|
|
923
|
+
* and make computation itself projection-aware, not only the fold above.
|
|
924
|
+
* `undefined` for every non-fragment path: a caller `include` (sudo or not)
|
|
925
|
+
* and a bare read both mean "compute every field," matching what they
|
|
926
|
+
* already fetch.
|
|
929
927
|
*/
|
|
930
928
|
async function resolveReadInclude(
|
|
931
929
|
callerInclude: Record<string, unknown> | undefined,
|
|
@@ -935,43 +933,45 @@ async function resolveReadInclude(
|
|
|
935
933
|
listConfig: ListConfig<any>,
|
|
936
934
|
context: AccessContext & { _isSudo?: boolean },
|
|
937
935
|
config: OpenSaasConfig,
|
|
938
|
-
): Promise<{
|
|
936
|
+
): Promise<{
|
|
937
|
+
include: Record<string, unknown> | undefined
|
|
938
|
+
declaredOnly: DeclaredOnlyTree
|
|
939
|
+
selection: FieldSelectionScope | undefined
|
|
940
|
+
}> {
|
|
939
941
|
if (fragmentFields !== undefined) {
|
|
940
942
|
const fragmentInclude = buildInclude(fragmentFields) ?? undefined
|
|
941
|
-
|
|
943
|
+
const selection = buildFieldSelectionScope(fragmentFields)
|
|
944
|
+
const folded = foldDeclaredDependencies(
|
|
945
|
+
fragmentInclude,
|
|
946
|
+
listConfig.fields,
|
|
947
|
+
config,
|
|
948
|
+
listName,
|
|
949
|
+
[listName],
|
|
950
|
+
selection,
|
|
951
|
+
)
|
|
952
|
+
return { ...folded, selection }
|
|
942
953
|
}
|
|
943
954
|
|
|
944
955
|
if (context._isSudo) {
|
|
945
|
-
|
|
956
|
+
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
|
|
957
|
+
return { ...folded, selection: undefined }
|
|
946
958
|
}
|
|
947
959
|
|
|
948
|
-
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config)
|
|
960
|
+
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
|
|
949
961
|
if (!folded.include) {
|
|
950
|
-
return folded
|
|
962
|
+
return { ...folded, selection: undefined }
|
|
951
963
|
}
|
|
952
964
|
|
|
953
|
-
const
|
|
954
|
-
listConfig.fields,
|
|
955
|
-
{ session: context.session, context },
|
|
956
|
-
config,
|
|
957
|
-
0,
|
|
958
|
-
// Seed the cycle guard with the root list so a relationship cycle back
|
|
959
|
-
// to it (self-referential or longer) stops re-descending.
|
|
960
|
-
[listName],
|
|
961
|
-
)
|
|
962
|
-
const include = mergeIncludeWithAccessControl(
|
|
965
|
+
const include = await buildAccessScopedInclude(
|
|
963
966
|
folded.include,
|
|
964
|
-
accessControlledInclude,
|
|
965
967
|
listConfig.fields,
|
|
968
|
+
{ session: context.session, context },
|
|
966
969
|
config,
|
|
967
970
|
listName,
|
|
968
971
|
)
|
|
969
|
-
return { include, declaredOnly: folded.declaredOnly }
|
|
972
|
+
return { include, declaredOnly: folded.declaredOnly, selection: undefined }
|
|
970
973
|
}
|
|
971
974
|
|
|
972
|
-
/**
|
|
973
|
-
* Create findUnique operation with access control
|
|
974
|
-
*/
|
|
975
975
|
function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
976
976
|
listName: string,
|
|
977
977
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -991,19 +991,15 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
991
991
|
// `select` is not honoured — accepted only so the no-op can be made visible.
|
|
992
992
|
select?: Record<string, unknown>
|
|
993
993
|
}) => {
|
|
994
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
995
994
|
warnIfSelectIgnored(args, listName, 'findUnique')
|
|
996
995
|
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
// `Prisma.<List>FindUniqueArgs`, so its `where` stays Prisma's
|
|
1003
|
-
// `<List>WhereUniqueInput` — this runtime guard backstops untyped callers.
|
|
996
|
+
// Runs first, before the access check below — a non-unique `where` is a
|
|
997
|
+
// caller-shape error (see `assertUniqueWhere`), not an access denial. The
|
|
998
|
+
// generated `<List>FindUniqueArgs` only Omits `select`/`include` from
|
|
999
|
+
// Prisma's own type, so `where` stays `<List>WhereUniqueInput` — this
|
|
1000
|
+
// runtime guard backstops untyped callers.
|
|
1004
1001
|
assertUniqueWhere(args.where, getUniqueWhereKeys(listConfig), listName)
|
|
1005
1002
|
|
|
1006
|
-
// Check query access (skip if sudo mode)
|
|
1007
1003
|
let where: Record<string, unknown> = args.where
|
|
1008
1004
|
if (!context._isSudo) {
|
|
1009
1005
|
const queryAccess = listConfig.access?.operation?.query
|
|
@@ -1016,7 +1012,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1016
1012
|
return null
|
|
1017
1013
|
}
|
|
1018
1014
|
|
|
1019
|
-
// Merge access filter with where clause
|
|
1020
1015
|
const mergedWhere = mergeFilters(args.where, accessResult)
|
|
1021
1016
|
if (mergedWhere === null) {
|
|
1022
1017
|
return null
|
|
@@ -1024,15 +1019,14 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1024
1019
|
where = mergedWhere
|
|
1025
1020
|
}
|
|
1026
1021
|
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
// filterReadableFields; the fragment then narrows to only the requested fields.
|
|
1022
|
+
// Access control still runs via filterReadableFields even though a
|
|
1023
|
+
// fragment drives `include`; the fragment only narrows which fields come back.
|
|
1030
1024
|
const fragment = isFragment(args.query) ? args.query : null
|
|
1031
1025
|
|
|
1032
1026
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1033
1027
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1034
1028
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1035
|
-
let { include, declaredOnly } = await resolveReadInclude(
|
|
1029
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(
|
|
1036
1030
|
args.include,
|
|
1037
1031
|
fragment ? fragment._fields : undefined,
|
|
1038
1032
|
listName,
|
|
@@ -1044,11 +1038,12 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1044
1038
|
// Virtual fields have no database column. Whichever path produced
|
|
1045
1039
|
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
1046
1040
|
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
1047
|
-
// (#628).
|
|
1048
|
-
// `
|
|
1041
|
+
// (#628). Below, `filterReadableFields` computes a virtual field's value
|
|
1042
|
+
// exactly when `selection` says the read is going to return it (ADR-0027)
|
|
1043
|
+
// — every one of them for a bare/`include`-based read (`selection` is
|
|
1044
|
+
// `undefined`), only the ones a fragment named otherwise.
|
|
1049
1045
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
1050
1046
|
|
|
1051
|
-
// Execute query with optimized includes
|
|
1052
1047
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
1053
1048
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1054
1049
|
const model = (prisma as any)[getDbKey(listName)]
|
|
@@ -1061,7 +1056,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1061
1056
|
return null
|
|
1062
1057
|
}
|
|
1063
1058
|
|
|
1064
|
-
// Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
1065
1059
|
// Pass sudo flag through context to skip field-level access checks
|
|
1066
1060
|
const filtered = await filterReadableFields(
|
|
1067
1061
|
item,
|
|
@@ -1074,9 +1068,9 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1074
1068
|
0,
|
|
1075
1069
|
listName,
|
|
1076
1070
|
declaredOnly,
|
|
1071
|
+
selection,
|
|
1077
1072
|
)
|
|
1078
1073
|
|
|
1079
|
-
// When a fragment is provided, pick only the requested fields from the result
|
|
1080
1074
|
if (fragment) {
|
|
1081
1075
|
return pickFields(filtered, fragment._fields)
|
|
1082
1076
|
}
|
|
@@ -1085,9 +1079,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1085
1079
|
}
|
|
1086
1080
|
}
|
|
1087
1081
|
|
|
1088
|
-
/**
|
|
1089
|
-
* Create findMany operation with access control
|
|
1090
|
-
*/
|
|
1091
1082
|
function createFindMany<TPrisma extends PrismaClientLike>(
|
|
1092
1083
|
listName: string,
|
|
1093
1084
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1107,10 +1098,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1107
1098
|
// `select` is not honoured — accepted only so the no-op can be made visible.
|
|
1108
1099
|
select?: Record<string, unknown>
|
|
1109
1100
|
}) => {
|
|
1110
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
1111
1101
|
warnIfSelectIgnored(args, listName, 'findMany')
|
|
1112
1102
|
|
|
1113
|
-
//
|
|
1103
|
+
// Singleton misuse throws rather than silently returning `[]` — unlike an
|
|
1104
|
+
// access denial, this is a caller-shape error.
|
|
1114
1105
|
if (isSingletonList(listConfig)) {
|
|
1115
1106
|
throw new ValidationError(
|
|
1116
1107
|
[`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`],
|
|
@@ -1118,7 +1109,8 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1118
1109
|
)
|
|
1119
1110
|
}
|
|
1120
1111
|
|
|
1121
|
-
// Check query access (skip if sudo mode)
|
|
1112
|
+
// Check query access first (skip if sudo mode) — this MUST run before the
|
|
1113
|
+
// #912/#915 where/orderBy validation below. See the comment there for why.
|
|
1122
1114
|
let where: Record<string, unknown> | undefined = args?.where
|
|
1123
1115
|
if (!context._isSudo) {
|
|
1124
1116
|
const queryAccess = listConfig.access?.operation?.query
|
|
@@ -1131,21 +1123,62 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1131
1123
|
return []
|
|
1132
1124
|
}
|
|
1133
1125
|
|
|
1134
|
-
//
|
|
1135
|
-
|
|
1126
|
+
// #912 — reject a `where`/`orderBy` key the list config doesn't declare
|
|
1127
|
+
// (e.g. a Prisma-generated back-relation), and #915 — reject one naming
|
|
1128
|
+
// a field this session cannot READ (closing a probe via a `count()`
|
|
1129
|
+
// that varies with the withheld value, or an `orderBy` that leaks
|
|
1130
|
+
// relative ordering). Both run only now that the caller is known to
|
|
1131
|
+
// have SOME access to the list (`accessResult !== false`): the thrown
|
|
1132
|
+
// errors name the offending key, and running them before the access
|
|
1133
|
+
// check above would let a caller with ZERO access to the list learn a
|
|
1134
|
+
// field's name and read-gating status from the error message alone —
|
|
1135
|
+
// turning the validation itself into the kind of oracle #915 closes.
|
|
1136
|
+
// `sudo` bypasses this whole branch, matching the write path.
|
|
1137
|
+
validateQueryKeys({
|
|
1138
|
+
where: args?.where,
|
|
1139
|
+
orderBy: args?.orderBy,
|
|
1140
|
+
listConfig,
|
|
1141
|
+
listName,
|
|
1142
|
+
config,
|
|
1143
|
+
isSudo: false,
|
|
1144
|
+
})
|
|
1145
|
+
await validateQueryFieldReadAccess({
|
|
1146
|
+
where: args?.where,
|
|
1147
|
+
orderBy: args?.orderBy,
|
|
1148
|
+
listConfig,
|
|
1149
|
+
listName,
|
|
1150
|
+
session: context.session,
|
|
1151
|
+
context,
|
|
1152
|
+
isSudo: false,
|
|
1153
|
+
})
|
|
1154
|
+
|
|
1155
|
+
// #916 — scope every relation filter nested in `where`
|
|
1156
|
+
// (`some`/`every`/`none`/`is`/`isNot`) by the RELATED list's own `query`
|
|
1157
|
+
// access, recursing through every hop of a chain — the `where`
|
|
1158
|
+
// counterpart to how `include` is already scoped below via
|
|
1159
|
+
// `buildAccessScopedInclude`. Runs after the checks above for the same
|
|
1160
|
+
// ordering reason: only once the caller is known to have SOME access to
|
|
1161
|
+
// THIS list.
|
|
1162
|
+
const scopedWhere = args?.where
|
|
1163
|
+
? ((await buildAccessScopedWhere(args.where, listConfig, listName, config, {
|
|
1164
|
+
session: context.session,
|
|
1165
|
+
context,
|
|
1166
|
+
})) as Record<string, unknown>)
|
|
1167
|
+
: args?.where
|
|
1168
|
+
|
|
1169
|
+
const mergedWhere = mergeFilters(scopedWhere, accessResult)
|
|
1136
1170
|
if (mergedWhere === null) {
|
|
1137
1171
|
return []
|
|
1138
1172
|
}
|
|
1139
1173
|
where = mergedWhere
|
|
1140
1174
|
}
|
|
1141
1175
|
|
|
1142
|
-
// When a query fragment is provided, build include from fragment fields
|
|
1143
1176
|
const fragment = isFragment(args?.query) ? args.query : null
|
|
1144
1177
|
|
|
1145
1178
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1146
1179
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1147
1180
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1148
|
-
let { include, declaredOnly } = await resolveReadInclude(
|
|
1181
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(
|
|
1149
1182
|
args?.include,
|
|
1150
1183
|
fragment ? fragment._fields : undefined,
|
|
1151
1184
|
listName,
|
|
@@ -1154,14 +1187,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1154
1187
|
config,
|
|
1155
1188
|
)
|
|
1156
1189
|
|
|
1157
|
-
//
|
|
1158
|
-
// `
|
|
1159
|
-
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
1160
|
-
// (#628). The virtual value is still computed unconditionally below by
|
|
1161
|
-
// `filterReadableFields`, independent of what was requested here.
|
|
1190
|
+
// Strips virtual keys from `include` before the Prisma call — see the
|
|
1191
|
+
// `createFindUnique` comment above for why (#628, ADR-0027).
|
|
1162
1192
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
1163
1193
|
|
|
1164
|
-
// Execute query with optimized includes
|
|
1165
1194
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
1166
1195
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1167
1196
|
const model = (prisma as any)[getDbKey(listName)]
|
|
@@ -1173,7 +1202,6 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1173
1202
|
include,
|
|
1174
1203
|
})
|
|
1175
1204
|
|
|
1176
|
-
// Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
|
|
1177
1205
|
// Pass sudo flag through context to skip field-level access checks
|
|
1178
1206
|
const filtered = await Promise.all(
|
|
1179
1207
|
items.map((item: Record<string, unknown>) =>
|
|
@@ -1188,11 +1216,11 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1188
1216
|
0,
|
|
1189
1217
|
listName,
|
|
1190
1218
|
declaredOnly,
|
|
1219
|
+
selection,
|
|
1191
1220
|
),
|
|
1192
1221
|
),
|
|
1193
1222
|
)
|
|
1194
1223
|
|
|
1195
|
-
// When a fragment is provided, pick only the requested fields from each result
|
|
1196
1224
|
if (fragment) {
|
|
1197
1225
|
return filtered.map((item: Record<string, unknown>) => pickFields(item, fragment._fields))
|
|
1198
1226
|
}
|
|
@@ -1226,9 +1254,6 @@ function createFindFirst(findManyOp: ReturnType<typeof createFindMany>) {
|
|
|
1226
1254
|
}
|
|
1227
1255
|
}
|
|
1228
1256
|
|
|
1229
|
-
/**
|
|
1230
|
-
* Create create operation with access control and hooks
|
|
1231
|
-
*/
|
|
1232
1257
|
function createCreate<TPrisma extends PrismaClientLike>(
|
|
1233
1258
|
listName: string,
|
|
1234
1259
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1252,10 +1277,8 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
1252
1277
|
}
|
|
1253
1278
|
}
|
|
1254
1279
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
* Runs create in a loop to ensure all hooks and access control are executed for each item
|
|
1258
|
-
*/
|
|
1280
|
+
// Runs create in a loop (not Prisma's native createMany) so every item still
|
|
1281
|
+
// gets its own hooks and access control.
|
|
1259
1282
|
function createCreateMany<TPrisma extends PrismaClientLike>(
|
|
1260
1283
|
listName: string,
|
|
1261
1284
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1278,9 +1301,6 @@ function createCreateMany<TPrisma extends PrismaClientLike>(
|
|
|
1278
1301
|
}
|
|
1279
1302
|
}
|
|
1280
1303
|
|
|
1281
|
-
/**
|
|
1282
|
-
* Create update operation with access control and hooks
|
|
1283
|
-
*/
|
|
1284
1304
|
function createUpdate<TPrisma extends PrismaClientLike>(
|
|
1285
1305
|
listName: string,
|
|
1286
1306
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1304,10 +1324,8 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
1304
1324
|
}
|
|
1305
1325
|
}
|
|
1306
1326
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
* Runs findMany to get records, then update in a loop to ensure all hooks and access control are executed
|
|
1310
|
-
*/
|
|
1327
|
+
// Finds matching records, then updates each individually (not Prisma's native
|
|
1328
|
+
// updateMany) so every item still gets its own hooks and access control.
|
|
1311
1329
|
function createUpdateMany<TPrisma extends PrismaClientLike>(
|
|
1312
1330
|
listName: string,
|
|
1313
1331
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1321,10 +1339,8 @@ function createUpdateMany<TPrisma extends PrismaClientLike>(
|
|
|
1321
1339
|
updateFn: any,
|
|
1322
1340
|
) {
|
|
1323
1341
|
return async (args: { where?: Record<string, unknown>; data: Record<string, unknown> }) => {
|
|
1324
|
-
// First, find all matching records (respects access control)
|
|
1325
1342
|
const items = await findManyFn({ where: args.where })
|
|
1326
1343
|
|
|
1327
|
-
// Then update each one individually (runs hooks and access control for each)
|
|
1328
1344
|
const results = []
|
|
1329
1345
|
for (const item of items) {
|
|
1330
1346
|
const result = await updateFn({ where: { id: item.id }, data: args.data })
|
|
@@ -1335,9 +1351,6 @@ function createUpdateMany<TPrisma extends PrismaClientLike>(
|
|
|
1335
1351
|
}
|
|
1336
1352
|
}
|
|
1337
1353
|
|
|
1338
|
-
/**
|
|
1339
|
-
* Create delete operation with access control and hooks
|
|
1340
|
-
*/
|
|
1341
1354
|
function createDelete<TPrisma extends PrismaClientLike>(
|
|
1342
1355
|
listName: string,
|
|
1343
1356
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1361,18 +1374,17 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
1361
1374
|
}
|
|
1362
1375
|
}
|
|
1363
1376
|
|
|
1364
|
-
/**
|
|
1365
|
-
* Create count operation with access control
|
|
1366
|
-
*/
|
|
1367
1377
|
function createCount<TPrisma extends PrismaClientLike>(
|
|
1368
1378
|
listName: string,
|
|
1369
1379
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
1370
1380
|
listConfig: ListConfig<any>,
|
|
1371
1381
|
prisma: TPrisma,
|
|
1372
1382
|
context: AccessContext<TPrisma>,
|
|
1383
|
+
config: OpenSaasConfig,
|
|
1373
1384
|
) {
|
|
1374
1385
|
return async (args?: { where?: Record<string, unknown> }) => {
|
|
1375
|
-
// Check query access (skip if sudo mode)
|
|
1386
|
+
// Check query access first (skip if sudo mode) — this MUST run before the
|
|
1387
|
+
// #912/#915 where validation below. See the comment there for why.
|
|
1376
1388
|
let where: Record<string, unknown> | undefined = args?.where
|
|
1377
1389
|
if (!context._isSudo) {
|
|
1378
1390
|
const queryAccess = listConfig.access?.operation?.query
|
|
@@ -1385,15 +1397,50 @@ function createCount<TPrisma extends PrismaClientLike>(
|
|
|
1385
1397
|
return 0
|
|
1386
1398
|
}
|
|
1387
1399
|
|
|
1388
|
-
//
|
|
1389
|
-
|
|
1400
|
+
// #912 — reject a `where` key the list config doesn't declare (e.g. a
|
|
1401
|
+
// Prisma-generated back-relation), and #915 — reject one naming a field
|
|
1402
|
+
// this session cannot READ. `count` leaks the most cleanly of any read
|
|
1403
|
+
// op — a bare count answers a predicate with no rows returned at all —
|
|
1404
|
+
// so it gets the same reject, not a lesser one. Both run only now that
|
|
1405
|
+
// the caller is known to have SOME access to the list (`accessResult
|
|
1406
|
+
// !== false`) — see the identical comment in `createFindMany` for why
|
|
1407
|
+
// that ordering matters: running them before the access check would
|
|
1408
|
+
// let a fully-denied caller learn a field's name and read-gating
|
|
1409
|
+
// status from the thrown error alone. `sudo` bypasses this whole
|
|
1410
|
+
// branch, matching the write path.
|
|
1411
|
+
validateQueryKeys({
|
|
1412
|
+
where: args?.where,
|
|
1413
|
+
listConfig,
|
|
1414
|
+
listName,
|
|
1415
|
+
config,
|
|
1416
|
+
isSudo: false,
|
|
1417
|
+
})
|
|
1418
|
+
await validateQueryFieldReadAccess({
|
|
1419
|
+
where: args?.where,
|
|
1420
|
+
listConfig,
|
|
1421
|
+
listName,
|
|
1422
|
+
session: context.session,
|
|
1423
|
+
context,
|
|
1424
|
+
isSudo: false,
|
|
1425
|
+
})
|
|
1426
|
+
|
|
1427
|
+
// #916 — scope every relation filter nested in `where` by the RELATED
|
|
1428
|
+
// list's own `query` access. See the identical comment in
|
|
1429
|
+
// `createFindMany` for why this runs here, in this order.
|
|
1430
|
+
const scopedWhere = args?.where
|
|
1431
|
+
? ((await buildAccessScopedWhere(args.where, listConfig, listName, config, {
|
|
1432
|
+
session: context.session,
|
|
1433
|
+
context,
|
|
1434
|
+
})) as Record<string, unknown>)
|
|
1435
|
+
: args?.where
|
|
1436
|
+
|
|
1437
|
+
const mergedWhere = mergeFilters(scopedWhere, accessResult)
|
|
1390
1438
|
if (mergedWhere === null) {
|
|
1391
1439
|
return 0
|
|
1392
1440
|
}
|
|
1393
1441
|
where = mergedWhere
|
|
1394
1442
|
}
|
|
1395
1443
|
|
|
1396
|
-
// Execute count
|
|
1397
1444
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
1398
1445
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1399
1446
|
const model = (prisma as any)[getDbKey(listName)]
|
|
@@ -1405,10 +1452,6 @@ function createCount<TPrisma extends PrismaClientLike>(
|
|
|
1405
1452
|
}
|
|
1406
1453
|
}
|
|
1407
1454
|
|
|
1408
|
-
/**
|
|
1409
|
-
* Create get operation for singleton lists
|
|
1410
|
-
* Returns the single record, or auto-creates it if enabled
|
|
1411
|
-
*/
|
|
1412
1455
|
function createGet<TPrisma extends PrismaClientLike>(
|
|
1413
1456
|
listName: string,
|
|
1414
1457
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1426,15 +1469,12 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1426
1469
|
// `select` is not honoured — accepted only so the no-op can be made visible.
|
|
1427
1470
|
select?: Record<string, unknown>
|
|
1428
1471
|
}) => {
|
|
1429
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
1430
1472
|
warnIfSelectIgnored(args, listName, 'get')
|
|
1431
1473
|
|
|
1432
|
-
// First try to find the existing record
|
|
1433
1474
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
1434
1475
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1435
1476
|
const model = (prisma as any)[getDbKey(listName)]
|
|
1436
1477
|
|
|
1437
|
-
// Check query access (skip if sudo mode)
|
|
1438
1478
|
let where: Record<string, unknown> = {}
|
|
1439
1479
|
if (!context._isSudo) {
|
|
1440
1480
|
const queryAccess = listConfig.access?.operation?.query
|
|
@@ -1447,21 +1487,21 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1447
1487
|
return null
|
|
1448
1488
|
}
|
|
1449
1489
|
|
|
1450
|
-
//
|
|
1490
|
+
// A singleton has no per-record `where`, so the access filter (if any) is
|
|
1491
|
+
// the whole `where`.
|
|
1451
1492
|
if (accessResult && typeof accessResult === 'object') {
|
|
1452
1493
|
where = accessResult
|
|
1453
1494
|
}
|
|
1454
1495
|
}
|
|
1455
1496
|
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
// filterReadableFields; the fragment then narrows to only the requested fields.
|
|
1497
|
+
// Access control still runs via filterReadableFields even though a
|
|
1498
|
+
// fragment drives `include`; the fragment only narrows which fields come back.
|
|
1459
1499
|
const fragment = isFragment(args?.query) ? args.query : null
|
|
1460
1500
|
|
|
1461
1501
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1462
1502
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1463
1503
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1464
|
-
let { include, declaredOnly } = await resolveReadInclude(
|
|
1504
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(
|
|
1465
1505
|
args?.include,
|
|
1466
1506
|
fragment ? fragment._fields : undefined,
|
|
1467
1507
|
listName,
|
|
@@ -1473,15 +1513,12 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1473
1513
|
// Virtual fields have no database column and must never reach Prisma (#628).
|
|
1474
1514
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
1475
1515
|
|
|
1476
|
-
// Try to find the record
|
|
1477
1516
|
const item = await model.findFirst({
|
|
1478
1517
|
where,
|
|
1479
1518
|
include,
|
|
1480
1519
|
})
|
|
1481
1520
|
|
|
1482
|
-
// If record exists, return it
|
|
1483
1521
|
if (item) {
|
|
1484
|
-
// Filter readable fields and apply resolveOutput hooks
|
|
1485
1522
|
const filtered = await filterReadableFields(
|
|
1486
1523
|
item,
|
|
1487
1524
|
listConfig.fields,
|
|
@@ -1493,21 +1530,19 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1493
1530
|
0,
|
|
1494
1531
|
listName,
|
|
1495
1532
|
declaredOnly,
|
|
1533
|
+
selection,
|
|
1496
1534
|
)
|
|
1497
|
-
// When a fragment is provided, pick only the requested fields from the result
|
|
1498
1535
|
if (fragment) {
|
|
1499
1536
|
return pickFields(filtered, fragment._fields)
|
|
1500
1537
|
}
|
|
1501
1538
|
return filtered
|
|
1502
1539
|
}
|
|
1503
1540
|
|
|
1504
|
-
// If no record and auto-create is enabled, create it
|
|
1505
1541
|
if (shouldAutoCreate(listConfig)) {
|
|
1506
1542
|
const defaultData = getDefaultData(listConfig)
|
|
1507
1543
|
return await createFn({ data: defaultData })
|
|
1508
1544
|
}
|
|
1509
1545
|
|
|
1510
|
-
// No record and auto-create is disabled
|
|
1511
1546
|
return null
|
|
1512
1547
|
}
|
|
1513
1548
|
}
|