@opensaas/stack-core 0.38.0 → 0.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +125 -0
- package/CLAUDE.md +21 -0
- package/dist/access/access-filter.d.ts +46 -1
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +113 -4
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +4 -9
- package/dist/access/declared-dependencies.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 +49 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +75 -0
- 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 +0 -4
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +13 -39
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +6 -2
- 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.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 +190 -193
- 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 +26 -227
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +14 -117
- package/dist/query/index.js.map +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.ts +152 -5
- package/src/access/declared-dependencies.ts +4 -9
- package/src/access/engine.ts +0 -25
- package/src/access/errors.ts +81 -0
- 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 +16 -47
- package/src/access/index.ts +20 -2
- package/src/access/query-validation.ts +359 -0
- package/src/access/relationship-count.ts +2 -10
- 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 +263 -247
- 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 +29 -235
- package/src/utils/password.ts +7 -76
- package/src/validation/field-config.ts +0 -6
- package/src/validation/schema.ts +0 -10
- package/tests/context.test.ts +700 -1
- package/tests/field-types.test.ts +222 -0
- package/tests/mcp-handler.test.ts +34 -0
- package/tests/transaction-boundary-outermost.test.ts +530 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/context/index.ts
CHANGED
|
@@ -5,8 +5,11 @@ import {
|
|
|
5
5
|
mergeFilters,
|
|
6
6
|
filterReadableFields,
|
|
7
7
|
buildAccessScopedInclude,
|
|
8
|
+
buildAccessScopedWhere,
|
|
8
9
|
stripVirtualFieldsFromInclude,
|
|
9
10
|
foldDeclaredDependencies,
|
|
11
|
+
validateQueryKeys,
|
|
12
|
+
validateQueryFieldReadAccess,
|
|
10
13
|
} from '../access/index.js'
|
|
11
14
|
import type { DeclaredOnlyTree } from '../access/index.js'
|
|
12
15
|
import { ValidationError, DatabaseError } from '../hooks/index.js'
|
|
@@ -21,29 +24,31 @@ import {
|
|
|
21
24
|
updateWriteStrategy,
|
|
22
25
|
deleteWriteStrategy,
|
|
23
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'
|
|
24
30
|
|
|
25
31
|
export type ServerActionProps =
|
|
26
32
|
| { listKey: string; action: 'create'; data: Record<string, unknown> }
|
|
27
33
|
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
|
|
28
34
|
| { listKey: string; action: 'delete'; id: string }
|
|
29
35
|
| { listKey: string; action: 'bulkDelete'; ids: string[] }
|
|
30
|
-
// Custom list-specific
|
|
31
|
-
// declared in the list's `ui.listView.bulkActions`; the client only
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// `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.
|
|
37
42
|
| { listKey: string; action: 'bulkAction'; key: string; ids: string[] }
|
|
38
43
|
// Relationship-table row removal (ADR-0018, #739). `listKey`/`id` target the
|
|
39
|
-
// RELATED row, so the related list's own access control
|
|
40
|
-
// the parent's
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// many-to-many join); `mode: 'delete'`
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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.
|
|
47
52
|
| {
|
|
48
53
|
listKey: string
|
|
49
54
|
action: 'removeRelated'
|
|
@@ -52,13 +57,10 @@ export type ServerActionProps =
|
|
|
52
57
|
field?: string
|
|
53
58
|
parentId?: string
|
|
54
59
|
}
|
|
55
|
-
// Relationship-table inline cell edit (issue #737)
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// `removeRelated`. It returns a distinct `{ updated }` shape (never a
|
|
60
|
-
// single-op `success`) so a UI wrapper that redirects on `success` — the
|
|
61
|
-
// 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.
|
|
62
64
|
| {
|
|
63
65
|
listKey: string
|
|
64
66
|
action: 'updateRelated'
|
|
@@ -66,14 +68,12 @@ export type ServerActionProps =
|
|
|
66
68
|
field: string
|
|
67
69
|
value: unknown
|
|
68
70
|
}
|
|
69
|
-
// Relationship-table pre-linked create (issue #738)
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// `success`) so a UI wrapper that redirects on `success` — the item-form
|
|
76
|
-
// 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.
|
|
77
77
|
| {
|
|
78
78
|
listKey: string
|
|
79
79
|
action: 'createRelated'
|
|
@@ -90,21 +90,11 @@ export type ServerActionProps =
|
|
|
90
90
|
selectedIds?: string[]
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
* Tracks which (listName, operation) pairs have already warned about an ignored
|
|
95
|
-
* `select` argument, so a misused read op warns once rather than on every call.
|
|
96
|
-
*/
|
|
97
93
|
const selectWarnings = new Set<string>()
|
|
98
94
|
|
|
99
95
|
/**
|
|
100
|
-
* Warn
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* `context.db` reads never apply Prisma `select` semantics — narrowing is done
|
|
104
|
-
* via `include` or a fragment `query`. The op still runs and returns the full,
|
|
105
|
-
* access-filtered result, so this is a visible no-op rather than an error.
|
|
106
|
-
*
|
|
107
|
-
* 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.
|
|
108
98
|
*/
|
|
109
99
|
function warnIfSelectIgnored(
|
|
110
100
|
args: { select?: unknown } | undefined,
|
|
@@ -125,9 +115,6 @@ function warnIfSelectIgnored(
|
|
|
125
115
|
)
|
|
126
116
|
}
|
|
127
117
|
|
|
128
|
-
/**
|
|
129
|
-
* Check if a list is configured as a singleton
|
|
130
|
-
*/
|
|
131
118
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
132
119
|
function isSingletonList(listConfig: ListConfig<any>): boolean {
|
|
133
120
|
return !!listConfig.isSingleton
|
|
@@ -135,25 +122,17 @@ function isSingletonList(listConfig: ListConfig<any>): boolean {
|
|
|
135
122
|
|
|
136
123
|
/**
|
|
137
124
|
* Compute the set of single-field unique selectors a `findUnique` `where` may be
|
|
138
|
-
* keyed by, derived from what the list config exposes at runtime
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* - Any field declared `isIndexed: 'unique'` in the config (e.g. `text({ isIndexed: 'unique' })`).
|
|
143
|
-
* - For a `relationship` field declared `isIndexed: 'unique'`, the foreign-key
|
|
144
|
-
* column name (`<field>Id`) — that is the column Prisma marks `@unique`, so the
|
|
145
|
-
* unique `where` is keyed by `<field>Id`, not the relation field itself.
|
|
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.
|
|
146
129
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* one, while never falsely rejecting a valid single-field unique lookup. If a
|
|
154
|
-
* project legitimately needs a compound-unique lookup, that path is not covered
|
|
155
|
-
* here and would need explicit config support; the safe escape hatch for any
|
|
156
|
-
* 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).
|
|
157
136
|
*/
|
|
158
137
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
159
138
|
function getUniqueWhereKeys(listConfig: ListConfig<any>): Set<string> {
|
|
@@ -199,10 +178,6 @@ function assertUniqueWhere(
|
|
|
199
178
|
}
|
|
200
179
|
}
|
|
201
180
|
|
|
202
|
-
/**
|
|
203
|
-
* Check if auto-create is enabled for a singleton list
|
|
204
|
-
* Defaults to true if not explicitly set to false
|
|
205
|
-
*/
|
|
206
181
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
207
182
|
function shouldAutoCreate(listConfig: ListConfig<any>): boolean {
|
|
208
183
|
if (!listConfig.isSingleton) return false
|
|
@@ -210,22 +185,15 @@ function shouldAutoCreate(listConfig: ListConfig<any>): boolean {
|
|
|
210
185
|
return listConfig.isSingleton.autoCreate !== false
|
|
211
186
|
}
|
|
212
187
|
|
|
213
|
-
/**
|
|
214
|
-
* Extract default values from field configs
|
|
215
|
-
* Used to auto-create singleton records with sensible defaults
|
|
216
|
-
*/
|
|
217
188
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
218
189
|
function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
|
|
219
190
|
const data: Record<string, unknown> = {}
|
|
220
191
|
|
|
221
192
|
for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
222
|
-
// Skip virtual fields - they're not stored in database
|
|
223
193
|
if (fieldConfig.virtual) continue
|
|
224
194
|
|
|
225
|
-
// Skip system fields (id, createdAt, updatedAt)
|
|
226
195
|
if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt') continue
|
|
227
196
|
|
|
228
|
-
// Add default value if present
|
|
229
197
|
if ('defaultValue' in fieldConfig && fieldConfig.defaultValue !== undefined) {
|
|
230
198
|
data[fieldKey] = fieldConfig.defaultValue
|
|
231
199
|
}
|
|
@@ -234,12 +202,8 @@ function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
|
|
|
234
202
|
return data
|
|
235
203
|
}
|
|
236
204
|
|
|
237
|
-
/**
|
|
238
|
-
* Parse Prisma error and convert to user-friendly DatabaseError
|
|
239
|
-
*/
|
|
240
205
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
241
206
|
function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
242
|
-
// Check if it's a Prisma error
|
|
243
207
|
if (
|
|
244
208
|
error &&
|
|
245
209
|
typeof error === 'object' &&
|
|
@@ -249,15 +213,13 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
249
213
|
) {
|
|
250
214
|
const prismaError = error as { code: string; meta?: { target?: string[] }; message?: string }
|
|
251
215
|
|
|
252
|
-
//
|
|
216
|
+
// P2002 is Prisma's unique constraint violation code.
|
|
253
217
|
if (prismaError.code === 'P2002') {
|
|
254
218
|
const target = prismaError.meta?.target
|
|
255
219
|
const fieldErrors: Record<string, string> = {}
|
|
256
220
|
|
|
257
221
|
if (target && Array.isArray(target)) {
|
|
258
|
-
// Get field names from the constraint target
|
|
259
222
|
for (const fieldName of target) {
|
|
260
|
-
// Get the field config to get a better label
|
|
261
223
|
const fieldConfig = listConfig.fields[fieldName]
|
|
262
224
|
const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
|
|
263
225
|
|
|
@@ -268,7 +230,6 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
268
230
|
}
|
|
269
231
|
}
|
|
270
232
|
|
|
271
|
-
// Create a user-friendly general message
|
|
272
233
|
const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ')
|
|
273
234
|
return new DatabaseError(
|
|
274
235
|
`${fieldLabels} must be unique. The value you entered is already in use.`,
|
|
@@ -280,7 +241,6 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
280
241
|
return new DatabaseError('A record with this value already exists', {}, prismaError.code)
|
|
281
242
|
}
|
|
282
243
|
|
|
283
|
-
// Handle other Prisma errors - return generic message
|
|
284
244
|
return new DatabaseError(
|
|
285
245
|
prismaError.message || 'A database error occurred',
|
|
286
246
|
{},
|
|
@@ -288,22 +248,16 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
288
248
|
)
|
|
289
249
|
}
|
|
290
250
|
|
|
291
|
-
// Not a Prisma error, return as-is if it's already an Error
|
|
292
251
|
if (error instanceof Error) {
|
|
293
252
|
return error
|
|
294
253
|
}
|
|
295
254
|
|
|
296
|
-
// Unknown error type
|
|
297
255
|
return new Error('An unknown error occurred')
|
|
298
256
|
}
|
|
299
257
|
|
|
300
258
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* Mirrors Prisma's `TransactionIsolationLevel`. The level passed to
|
|
304
|
-
* {@link StackContext.transaction} is forwarded to the underlying interactive
|
|
305
|
-
* transaction; provider support varies (e.g. `Serializable` is supported by
|
|
306
|
-
* PostgreSQL — required for the concurrency-sensitive capacity-gate pattern).
|
|
259
|
+
* Mirrors Prisma's `TransactionIsolationLevel`. Provider support varies —
|
|
260
|
+
* e.g. `Serializable` requires PostgreSQL.
|
|
307
261
|
*/
|
|
308
262
|
export type TransactionIsolationLevel =
|
|
309
263
|
'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable' | 'Snapshot'
|
|
@@ -379,13 +333,34 @@ export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLik
|
|
|
379
333
|
}
|
|
380
334
|
|
|
381
335
|
/**
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
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.
|
|
388
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
|
+
|
|
389
364
|
export function getContext<
|
|
390
365
|
TConfig extends OpenSaasConfig,
|
|
391
366
|
TPrisma extends PrismaClientLike = PrismaClientLike,
|
|
@@ -398,13 +373,14 @@ export function getContext<
|
|
|
398
373
|
// Internal: when rebuilding the context against a transaction client, reuse the
|
|
399
374
|
// already-initialised plugin services rather than re-running plugin runtimes.
|
|
400
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,
|
|
401
380
|
): StackContext<TPrisma> {
|
|
402
|
-
//
|
|
403
|
-
// Type is intentionally broad to allow dynamic model access
|
|
381
|
+
// Broad type to allow dynamic model access; populated by populateDbDelegate below.
|
|
404
382
|
const db: Record<string, unknown> = {}
|
|
405
383
|
|
|
406
|
-
// Create context with db reference (will be populated below)
|
|
407
|
-
// Storage utilities can be provided via parameter or use default stubs
|
|
408
384
|
const context: AccessContext<TPrisma> = {
|
|
409
385
|
session,
|
|
410
386
|
prisma: prisma as TPrisma,
|
|
@@ -436,15 +412,13 @@ export function getContext<
|
|
|
436
412
|
plugins: _sharedPlugins ?? {},
|
|
437
413
|
_isSudo,
|
|
438
414
|
_resolveOutputChain: [],
|
|
415
|
+
_transactionOwner,
|
|
439
416
|
}
|
|
440
417
|
|
|
441
|
-
// Create access-controlled operations for each list, populating `db` in place.
|
|
442
418
|
populateDbDelegate(db, config, prisma, context)
|
|
443
419
|
|
|
444
|
-
// Execute plugin runtime functions and populate context.plugins.
|
|
445
420
|
// Skipped when reusing shared plugins (transaction rebind) so runtimes — and
|
|
446
421
|
// any side effects they carry — run exactly once per top-level context.
|
|
447
|
-
// Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
|
|
448
422
|
if (!_sharedPlugins) {
|
|
449
423
|
const pluginsToExecute = config._plugins || config.plugins || []
|
|
450
424
|
for (const plugin of pluginsToExecute) {
|
|
@@ -458,40 +432,24 @@ export function getContext<
|
|
|
458
432
|
)
|
|
459
433
|
} catch (error) {
|
|
460
434
|
console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
|
|
461
|
-
// Continue with other plugins even if one fails
|
|
462
435
|
}
|
|
463
436
|
}
|
|
464
437
|
}
|
|
465
438
|
}
|
|
466
439
|
|
|
467
|
-
//
|
|
468
|
-
//
|
|
440
|
+
// Returns a result object instead of throwing — required for server actions
|
|
441
|
+
// to work in Next.js production builds.
|
|
469
442
|
async function serverAction(props: ServerActionProps): Promise<
|
|
470
443
|
| { success: true; data: unknown }
|
|
471
444
|
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// 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.
|
|
476
448
|
| { deleted: number; total: number }
|
|
477
|
-
// Relationship-table row removal reports `removed` (with an optional reason)
|
|
478
|
-
// rather than `success` — same distinct-shape rationale as `bulkDelete`, so
|
|
479
|
-
// an in-place removal never triggers a redirect-on-success wrapper.
|
|
480
449
|
| { removed: boolean; error?: string }
|
|
481
|
-
// Relationship-table pre-linked create reports `created` (with the new row's
|
|
482
|
-
// id, or an error + fieldErrors for the drawer) rather than `success` — same
|
|
483
|
-
// distinct-shape rationale, so an in-place create never triggers a
|
|
484
|
-
// redirect-on-success wrapper.
|
|
485
450
|
| { created: boolean; id?: string; error?: string; fieldErrors?: Record<string, string> }
|
|
486
|
-
// Custom Bulk action (issue #736) reports `bulkAction` with the handler's
|
|
487
|
-
// optional `message` (success) or an `error` (not found / denied / threw) —
|
|
488
|
-
// again a distinct shape from single-op `success`.
|
|
489
451
|
| { bulkAction: true; message?: string }
|
|
490
452
|
| { bulkAction: false; error: string }
|
|
491
|
-
// Relationship-table inline cell edit reports `updated` (with an optional
|
|
492
|
-
// reason + fieldErrors for the edited cell) rather than `success` — same
|
|
493
|
-
// distinct-shape rationale, so an in-place cell edit never triggers a
|
|
494
|
-
// redirect-on-success wrapper.
|
|
495
453
|
| { updated: boolean; error?: string; fieldErrors?: Record<string, string> }
|
|
496
454
|
> {
|
|
497
455
|
const dbKey = getDbKey(props.listKey)
|
|
@@ -578,8 +536,7 @@ export function getContext<
|
|
|
578
536
|
}
|
|
579
537
|
}
|
|
580
538
|
|
|
581
|
-
//
|
|
582
|
-
// through the secured context, so the related list's access + hooks apply.
|
|
539
|
+
// Runs on the RELATED row (ADR-0018 boundary — see ServerActionProps above).
|
|
583
540
|
// Honours Silent failure: an access-denied operation returns `null`, which
|
|
584
541
|
// becomes `{ removed: false }` with a generic reason — never leaking whether
|
|
585
542
|
// the row was denied or absent.
|
|
@@ -620,14 +577,13 @@ export function getContext<
|
|
|
620
577
|
}
|
|
621
578
|
}
|
|
622
579
|
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
// 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).
|
|
631
587
|
if (props.action === 'createRelated') {
|
|
632
588
|
try {
|
|
633
589
|
// Defensive guard (hardening; unreachable from the drawer, which always
|
|
@@ -685,11 +641,9 @@ export function getContext<
|
|
|
685
641
|
}
|
|
686
642
|
}
|
|
687
643
|
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
// never the parent's. Honours Silent failure: an access-denied update returns
|
|
692
|
-
// `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
|
|
693
647
|
// denied-vs-absent leak); a validation/db error surfaces its message and
|
|
694
648
|
// fieldErrors so the cell can revert with a reason and show an inline error.
|
|
695
649
|
if (props.action === 'updateRelated') {
|
|
@@ -764,7 +718,6 @@ export function getContext<
|
|
|
764
718
|
data: result,
|
|
765
719
|
}
|
|
766
720
|
} catch (error) {
|
|
767
|
-
// Handle ValidationError (has fieldErrors)
|
|
768
721
|
if (error instanceof ValidationError) {
|
|
769
722
|
return {
|
|
770
723
|
success: false,
|
|
@@ -773,7 +726,6 @@ export function getContext<
|
|
|
773
726
|
}
|
|
774
727
|
}
|
|
775
728
|
|
|
776
|
-
// Handle DatabaseError (has fieldErrors)
|
|
777
729
|
if (error instanceof DatabaseError) {
|
|
778
730
|
return {
|
|
779
731
|
success: false,
|
|
@@ -782,7 +734,6 @@ export function getContext<
|
|
|
782
734
|
}
|
|
783
735
|
}
|
|
784
736
|
|
|
785
|
-
// Parse and convert Prisma errors to user-friendly DatabaseError
|
|
786
737
|
const dbError = parsePrismaError(error, listConfig)
|
|
787
738
|
if (dbError instanceof DatabaseError) {
|
|
788
739
|
return {
|
|
@@ -792,7 +743,6 @@ export function getContext<
|
|
|
792
743
|
}
|
|
793
744
|
}
|
|
794
745
|
|
|
795
|
-
// Generic error fallback
|
|
796
746
|
return {
|
|
797
747
|
success: false,
|
|
798
748
|
error: dbError.message,
|
|
@@ -800,34 +750,77 @@ export function getContext<
|
|
|
800
750
|
}
|
|
801
751
|
}
|
|
802
752
|
|
|
803
|
-
//
|
|
804
|
-
// but still executes all hooks and validation
|
|
753
|
+
// Bypasses access control; hooks and validation still run.
|
|
805
754
|
function sudo(): StackContext<TPrisma> {
|
|
806
|
-
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
|
+
)
|
|
807
766
|
}
|
|
808
767
|
|
|
809
|
-
// Interactive, hook-firing transaction (#614).
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
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.
|
|
815
779
|
function transaction<T>(
|
|
816
780
|
fn: (txContext: StackContext<TPrisma>) => Promise<T>,
|
|
817
781
|
options?: TransactionOptions,
|
|
818
782
|
): Promise<T> {
|
|
819
|
-
|
|
820
|
-
if (typeof client.$transaction !== 'function') {
|
|
821
|
-
// No interactive transaction available — either a plain client/mock or we
|
|
822
|
-
// are already inside a transaction (a Prisma tx client exposes no
|
|
823
|
-
// `$transaction`). Run directly: hook/access semantics are identical and
|
|
824
|
-
// atomicity is provided by any enclosing transaction.
|
|
783
|
+
if (context._transactionOwner) {
|
|
825
784
|
return fn(returned)
|
|
826
785
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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)
|
|
831
824
|
}
|
|
832
825
|
|
|
833
826
|
const returned: StackContext<TPrisma> = {
|
|
@@ -864,7 +857,6 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
864
857
|
for (const [listName, listConfig] of Object.entries(config.lists)) {
|
|
865
858
|
const dbKey = getDbKey(listName)
|
|
866
859
|
|
|
867
|
-
// Create base operations
|
|
868
860
|
const createOp = createCreate(listName, listConfig, prisma, context, config)
|
|
869
861
|
const findManyOp = createFindMany(listName, listConfig, prisma, context, config)
|
|
870
862
|
const updateOp = createUpdate(listName, listConfig, prisma, context, config)
|
|
@@ -875,7 +867,7 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
875
867
|
create: createOp,
|
|
876
868
|
update: updateOp,
|
|
877
869
|
delete: createDelete(listName, listConfig, prisma, context, config),
|
|
878
|
-
count: createCount(listName, listConfig, prisma, context),
|
|
870
|
+
count: createCount(listName, listConfig, prisma, context, config),
|
|
879
871
|
createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
|
|
880
872
|
updateMany: createUpdateMany(
|
|
881
873
|
listName,
|
|
@@ -888,7 +880,6 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
888
880
|
),
|
|
889
881
|
}
|
|
890
882
|
|
|
891
|
-
// Add get() method for singleton lists
|
|
892
883
|
if (isSingletonList(listConfig)) {
|
|
893
884
|
operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
|
|
894
885
|
}
|
|
@@ -981,9 +972,6 @@ async function resolveReadInclude(
|
|
|
981
972
|
return { include, declaredOnly: folded.declaredOnly, selection: undefined }
|
|
982
973
|
}
|
|
983
974
|
|
|
984
|
-
/**
|
|
985
|
-
* Create findUnique operation with access control
|
|
986
|
-
*/
|
|
987
975
|
function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
988
976
|
listName: string,
|
|
989
977
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1003,19 +991,15 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1003
991
|
// `select` is not honoured — accepted only so the no-op can be made visible.
|
|
1004
992
|
select?: Record<string, unknown>
|
|
1005
993
|
}) => {
|
|
1006
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
1007
994
|
warnIfSelectIgnored(args, listName, 'findUnique')
|
|
1008
995
|
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
// `Prisma.<List>FindUniqueArgs`, so its `where` stays Prisma's
|
|
1015
|
-
// `<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.
|
|
1016
1001
|
assertUniqueWhere(args.where, getUniqueWhereKeys(listConfig), listName)
|
|
1017
1002
|
|
|
1018
|
-
// Check query access (skip if sudo mode)
|
|
1019
1003
|
let where: Record<string, unknown> = args.where
|
|
1020
1004
|
if (!context._isSudo) {
|
|
1021
1005
|
const queryAccess = listConfig.access?.operation?.query
|
|
@@ -1028,7 +1012,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1028
1012
|
return null
|
|
1029
1013
|
}
|
|
1030
1014
|
|
|
1031
|
-
// Merge access filter with where clause
|
|
1032
1015
|
const mergedWhere = mergeFilters(args.where, accessResult)
|
|
1033
1016
|
if (mergedWhere === null) {
|
|
1034
1017
|
return null
|
|
@@ -1036,9 +1019,8 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1036
1019
|
where = mergedWhere
|
|
1037
1020
|
}
|
|
1038
1021
|
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
1041
|
-
// 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.
|
|
1042
1024
|
const fragment = isFragment(args.query) ? args.query : null
|
|
1043
1025
|
|
|
1044
1026
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
@@ -1062,7 +1044,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1062
1044
|
// `undefined`), only the ones a fragment named otherwise.
|
|
1063
1045
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
1064
1046
|
|
|
1065
|
-
// Execute query with optimized includes
|
|
1066
1047
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
1067
1048
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1068
1049
|
const model = (prisma as any)[getDbKey(listName)]
|
|
@@ -1075,7 +1056,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1075
1056
|
return null
|
|
1076
1057
|
}
|
|
1077
1058
|
|
|
1078
|
-
// Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
1079
1059
|
// Pass sudo flag through context to skip field-level access checks
|
|
1080
1060
|
const filtered = await filterReadableFields(
|
|
1081
1061
|
item,
|
|
@@ -1091,7 +1071,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1091
1071
|
selection,
|
|
1092
1072
|
)
|
|
1093
1073
|
|
|
1094
|
-
// When a fragment is provided, pick only the requested fields from the result
|
|
1095
1074
|
if (fragment) {
|
|
1096
1075
|
return pickFields(filtered, fragment._fields)
|
|
1097
1076
|
}
|
|
@@ -1100,9 +1079,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1100
1079
|
}
|
|
1101
1080
|
}
|
|
1102
1081
|
|
|
1103
|
-
/**
|
|
1104
|
-
* Create findMany operation with access control
|
|
1105
|
-
*/
|
|
1106
1082
|
function createFindMany<TPrisma extends PrismaClientLike>(
|
|
1107
1083
|
listName: string,
|
|
1108
1084
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1122,10 +1098,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1122
1098
|
// `select` is not honoured — accepted only so the no-op can be made visible.
|
|
1123
1099
|
select?: Record<string, unknown>
|
|
1124
1100
|
}) => {
|
|
1125
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
1126
1101
|
warnIfSelectIgnored(args, listName, 'findMany')
|
|
1127
1102
|
|
|
1128
|
-
//
|
|
1103
|
+
// Singleton misuse throws rather than silently returning `[]` — unlike an
|
|
1104
|
+
// access denial, this is a caller-shape error.
|
|
1129
1105
|
if (isSingletonList(listConfig)) {
|
|
1130
1106
|
throw new ValidationError(
|
|
1131
1107
|
[`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`],
|
|
@@ -1133,7 +1109,8 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1133
1109
|
)
|
|
1134
1110
|
}
|
|
1135
1111
|
|
|
1136
|
-
// 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.
|
|
1137
1114
|
let where: Record<string, unknown> | undefined = args?.where
|
|
1138
1115
|
if (!context._isSudo) {
|
|
1139
1116
|
const queryAccess = listConfig.access?.operation?.query
|
|
@@ -1146,15 +1123,56 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1146
1123
|
return []
|
|
1147
1124
|
}
|
|
1148
1125
|
|
|
1149
|
-
//
|
|
1150
|
-
|
|
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)
|
|
1151
1170
|
if (mergedWhere === null) {
|
|
1152
1171
|
return []
|
|
1153
1172
|
}
|
|
1154
1173
|
where = mergedWhere
|
|
1155
1174
|
}
|
|
1156
1175
|
|
|
1157
|
-
// When a query fragment is provided, build include from fragment fields
|
|
1158
1176
|
const fragment = isFragment(args?.query) ? args.query : null
|
|
1159
1177
|
|
|
1160
1178
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
@@ -1169,16 +1187,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1169
1187
|
config,
|
|
1170
1188
|
)
|
|
1171
1189
|
|
|
1172
|
-
//
|
|
1173
|
-
// `
|
|
1174
|
-
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
1175
|
-
// (#628). Below, `filterReadableFields` computes a virtual field's value
|
|
1176
|
-
// exactly when `selection` says the read is going to return it (ADR-0027)
|
|
1177
|
-
// — every one of them for a bare/`include`-based read (`selection` is
|
|
1178
|
-
// `undefined`), only the ones a fragment named otherwise.
|
|
1190
|
+
// Strips virtual keys from `include` before the Prisma call — see the
|
|
1191
|
+
// `createFindUnique` comment above for why (#628, ADR-0027).
|
|
1179
1192
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
1180
1193
|
|
|
1181
|
-
// Execute query with optimized includes
|
|
1182
1194
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
1183
1195
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1184
1196
|
const model = (prisma as any)[getDbKey(listName)]
|
|
@@ -1190,7 +1202,6 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1190
1202
|
include,
|
|
1191
1203
|
})
|
|
1192
1204
|
|
|
1193
|
-
// Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
|
|
1194
1205
|
// Pass sudo flag through context to skip field-level access checks
|
|
1195
1206
|
const filtered = await Promise.all(
|
|
1196
1207
|
items.map((item: Record<string, unknown>) =>
|
|
@@ -1210,7 +1221,6 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1210
1221
|
),
|
|
1211
1222
|
)
|
|
1212
1223
|
|
|
1213
|
-
// When a fragment is provided, pick only the requested fields from each result
|
|
1214
1224
|
if (fragment) {
|
|
1215
1225
|
return filtered.map((item: Record<string, unknown>) => pickFields(item, fragment._fields))
|
|
1216
1226
|
}
|
|
@@ -1244,9 +1254,6 @@ function createFindFirst(findManyOp: ReturnType<typeof createFindMany>) {
|
|
|
1244
1254
|
}
|
|
1245
1255
|
}
|
|
1246
1256
|
|
|
1247
|
-
/**
|
|
1248
|
-
* Create create operation with access control and hooks
|
|
1249
|
-
*/
|
|
1250
1257
|
function createCreate<TPrisma extends PrismaClientLike>(
|
|
1251
1258
|
listName: string,
|
|
1252
1259
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1270,10 +1277,8 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
1270
1277
|
}
|
|
1271
1278
|
}
|
|
1272
1279
|
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
* Runs create in a loop to ensure all hooks and access control are executed for each item
|
|
1276
|
-
*/
|
|
1280
|
+
// Runs create in a loop (not Prisma's native createMany) so every item still
|
|
1281
|
+
// gets its own hooks and access control.
|
|
1277
1282
|
function createCreateMany<TPrisma extends PrismaClientLike>(
|
|
1278
1283
|
listName: string,
|
|
1279
1284
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1296,9 +1301,6 @@ function createCreateMany<TPrisma extends PrismaClientLike>(
|
|
|
1296
1301
|
}
|
|
1297
1302
|
}
|
|
1298
1303
|
|
|
1299
|
-
/**
|
|
1300
|
-
* Create update operation with access control and hooks
|
|
1301
|
-
*/
|
|
1302
1304
|
function createUpdate<TPrisma extends PrismaClientLike>(
|
|
1303
1305
|
listName: string,
|
|
1304
1306
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1322,10 +1324,8 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
1322
1324
|
}
|
|
1323
1325
|
}
|
|
1324
1326
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
* Runs findMany to get records, then update in a loop to ensure all hooks and access control are executed
|
|
1328
|
-
*/
|
|
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.
|
|
1329
1329
|
function createUpdateMany<TPrisma extends PrismaClientLike>(
|
|
1330
1330
|
listName: string,
|
|
1331
1331
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1339,10 +1339,8 @@ function createUpdateMany<TPrisma extends PrismaClientLike>(
|
|
|
1339
1339
|
updateFn: any,
|
|
1340
1340
|
) {
|
|
1341
1341
|
return async (args: { where?: Record<string, unknown>; data: Record<string, unknown> }) => {
|
|
1342
|
-
// First, find all matching records (respects access control)
|
|
1343
1342
|
const items = await findManyFn({ where: args.where })
|
|
1344
1343
|
|
|
1345
|
-
// Then update each one individually (runs hooks and access control for each)
|
|
1346
1344
|
const results = []
|
|
1347
1345
|
for (const item of items) {
|
|
1348
1346
|
const result = await updateFn({ where: { id: item.id }, data: args.data })
|
|
@@ -1353,9 +1351,6 @@ function createUpdateMany<TPrisma extends PrismaClientLike>(
|
|
|
1353
1351
|
}
|
|
1354
1352
|
}
|
|
1355
1353
|
|
|
1356
|
-
/**
|
|
1357
|
-
* Create delete operation with access control and hooks
|
|
1358
|
-
*/
|
|
1359
1354
|
function createDelete<TPrisma extends PrismaClientLike>(
|
|
1360
1355
|
listName: string,
|
|
1361
1356
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1379,18 +1374,17 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
1379
1374
|
}
|
|
1380
1375
|
}
|
|
1381
1376
|
|
|
1382
|
-
/**
|
|
1383
|
-
* Create count operation with access control
|
|
1384
|
-
*/
|
|
1385
1377
|
function createCount<TPrisma extends PrismaClientLike>(
|
|
1386
1378
|
listName: string,
|
|
1387
1379
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
1388
1380
|
listConfig: ListConfig<any>,
|
|
1389
1381
|
prisma: TPrisma,
|
|
1390
1382
|
context: AccessContext<TPrisma>,
|
|
1383
|
+
config: OpenSaasConfig,
|
|
1391
1384
|
) {
|
|
1392
1385
|
return async (args?: { where?: Record<string, unknown> }) => {
|
|
1393
|
-
// 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.
|
|
1394
1388
|
let where: Record<string, unknown> | undefined = args?.where
|
|
1395
1389
|
if (!context._isSudo) {
|
|
1396
1390
|
const queryAccess = listConfig.access?.operation?.query
|
|
@@ -1403,15 +1397,50 @@ function createCount<TPrisma extends PrismaClientLike>(
|
|
|
1403
1397
|
return 0
|
|
1404
1398
|
}
|
|
1405
1399
|
|
|
1406
|
-
//
|
|
1407
|
-
|
|
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)
|
|
1408
1438
|
if (mergedWhere === null) {
|
|
1409
1439
|
return 0
|
|
1410
1440
|
}
|
|
1411
1441
|
where = mergedWhere
|
|
1412
1442
|
}
|
|
1413
1443
|
|
|
1414
|
-
// Execute count
|
|
1415
1444
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
1416
1445
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1417
1446
|
const model = (prisma as any)[getDbKey(listName)]
|
|
@@ -1423,10 +1452,6 @@ function createCount<TPrisma extends PrismaClientLike>(
|
|
|
1423
1452
|
}
|
|
1424
1453
|
}
|
|
1425
1454
|
|
|
1426
|
-
/**
|
|
1427
|
-
* Create get operation for singleton lists
|
|
1428
|
-
* Returns the single record, or auto-creates it if enabled
|
|
1429
|
-
*/
|
|
1430
1455
|
function createGet<TPrisma extends PrismaClientLike>(
|
|
1431
1456
|
listName: string,
|
|
1432
1457
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -1444,15 +1469,12 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1444
1469
|
// `select` is not honoured — accepted only so the no-op can be made visible.
|
|
1445
1470
|
select?: Record<string, unknown>
|
|
1446
1471
|
}) => {
|
|
1447
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
1448
1472
|
warnIfSelectIgnored(args, listName, 'get')
|
|
1449
1473
|
|
|
1450
|
-
// First try to find the existing record
|
|
1451
1474
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
1452
1475
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1453
1476
|
const model = (prisma as any)[getDbKey(listName)]
|
|
1454
1477
|
|
|
1455
|
-
// Check query access (skip if sudo mode)
|
|
1456
1478
|
let where: Record<string, unknown> = {}
|
|
1457
1479
|
if (!context._isSudo) {
|
|
1458
1480
|
const queryAccess = listConfig.access?.operation?.query
|
|
@@ -1465,15 +1487,15 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1465
1487
|
return null
|
|
1466
1488
|
}
|
|
1467
1489
|
|
|
1468
|
-
//
|
|
1490
|
+
// A singleton has no per-record `where`, so the access filter (if any) is
|
|
1491
|
+
// the whole `where`.
|
|
1469
1492
|
if (accessResult && typeof accessResult === 'object') {
|
|
1470
1493
|
where = accessResult
|
|
1471
1494
|
}
|
|
1472
1495
|
}
|
|
1473
1496
|
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
// 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.
|
|
1477
1499
|
const fragment = isFragment(args?.query) ? args.query : null
|
|
1478
1500
|
|
|
1479
1501
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
@@ -1491,15 +1513,12 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1491
1513
|
// Virtual fields have no database column and must never reach Prisma (#628).
|
|
1492
1514
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
1493
1515
|
|
|
1494
|
-
// Try to find the record
|
|
1495
1516
|
const item = await model.findFirst({
|
|
1496
1517
|
where,
|
|
1497
1518
|
include,
|
|
1498
1519
|
})
|
|
1499
1520
|
|
|
1500
|
-
// If record exists, return it
|
|
1501
1521
|
if (item) {
|
|
1502
|
-
// Filter readable fields and apply resolveOutput hooks
|
|
1503
1522
|
const filtered = await filterReadableFields(
|
|
1504
1523
|
item,
|
|
1505
1524
|
listConfig.fields,
|
|
@@ -1513,20 +1532,17 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1513
1532
|
declaredOnly,
|
|
1514
1533
|
selection,
|
|
1515
1534
|
)
|
|
1516
|
-
// When a fragment is provided, pick only the requested fields from the result
|
|
1517
1535
|
if (fragment) {
|
|
1518
1536
|
return pickFields(filtered, fragment._fields)
|
|
1519
1537
|
}
|
|
1520
1538
|
return filtered
|
|
1521
1539
|
}
|
|
1522
1540
|
|
|
1523
|
-
// If no record and auto-create is enabled, create it
|
|
1524
1541
|
if (shouldAutoCreate(listConfig)) {
|
|
1525
1542
|
const defaultData = getDefaultData(listConfig)
|
|
1526
1543
|
return await createFn({ data: defaultData })
|
|
1527
1544
|
}
|
|
1528
1545
|
|
|
1529
|
-
// No record and auto-create is disabled
|
|
1530
1546
|
return null
|
|
1531
1547
|
}
|
|
1532
1548
|
}
|