@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/dist/context/index.js
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
|
-
import { checkAccess, mergeFilters, filterReadableFields,
|
|
1
|
+
import { checkAccess, mergeFilters, filterReadableFields, buildAccessScopedInclude, buildAccessScopedWhere, stripVirtualFieldsFromInclude, foldDeclaredDependencies, validateQueryKeys, validateQueryFieldReadAccess, } from '../access/index.js';
|
|
2
2
|
import { ValidationError, DatabaseError } from '../hooks/index.js';
|
|
3
3
|
import { getDbKey } from '../lib/case-utils.js';
|
|
4
|
-
import { buildInclude, pickFields, isFragment } from '../query/index.js';
|
|
4
|
+
import { buildInclude, pickFields, isFragment, buildFieldSelectionScope } from '../query/index.js';
|
|
5
5
|
import { getRelationshipOptions } from '../query/relationship-options.js';
|
|
6
6
|
import { runWritePipeline, createWriteStrategy, updateWriteStrategy, deleteWriteStrategy, } from './write-pipeline.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* `select` argument, so a misused read op warns once rather than on every call.
|
|
10
|
-
*/
|
|
7
|
+
import { AfterTransactionError } from './transaction-boundary.js';
|
|
8
|
+
import { TransactionRegistry } from '../access/transaction-registry.js';
|
|
11
9
|
const selectWarnings = new Set();
|
|
12
10
|
/**
|
|
13
|
-
* Warn
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `context.db` reads never apply Prisma `select` semantics — narrowing is done
|
|
17
|
-
* via `include` or a fragment `query`. The op still runs and returns the full,
|
|
18
|
-
* access-filtered result, so this is a visible no-op rather than an error.
|
|
19
|
-
*
|
|
20
|
-
* Centralised here so every affected read op shares one implementation.
|
|
11
|
+
* Warn once per (list, operation) when a caller passes `select` to a read op
|
|
12
|
+
* that ignores it. See "Narrowing Reads" in packages/core/CLAUDE.md.
|
|
21
13
|
*/
|
|
22
14
|
function warnIfSelectIgnored(args, listName, operation) {
|
|
23
15
|
if (!args || args.select === undefined)
|
|
@@ -31,34 +23,23 @@ function warnIfSelectIgnored(args, listName, operation) {
|
|
|
31
23
|
`Narrow a read with \`include\` or a fragment \`query\` instead. ` +
|
|
32
24
|
`See https://stack.opensaas.au/docs/concepts/queries`);
|
|
33
25
|
}
|
|
34
|
-
/**
|
|
35
|
-
* Check if a list is configured as a singleton
|
|
36
|
-
*/
|
|
37
26
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
38
27
|
function isSingletonList(listConfig) {
|
|
39
28
|
return !!listConfig.isSingleton;
|
|
40
29
|
}
|
|
41
30
|
/**
|
|
42
31
|
* Compute the set of single-field unique selectors a `findUnique` `where` may be
|
|
43
|
-
* keyed by, derived from what the list config exposes at runtime
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* - Any field declared `isIndexed: 'unique'` in the config (e.g. `text({ isIndexed: 'unique' })`).
|
|
48
|
-
* - For a `relationship` field declared `isIndexed: 'unique'`, the foreign-key
|
|
49
|
-
* column name (`<field>Id`) — that is the column Prisma marks `@unique`, so the
|
|
50
|
-
* unique `where` is keyed by `<field>Id`, not the relation field itself.
|
|
32
|
+
* keyed by, derived from what the list config exposes at runtime: `id`, plus any
|
|
33
|
+
* field declared `isIndexed: 'unique'` — for a `relationship` field, this is the
|
|
34
|
+
* foreign-key column name (`<field>Id`), since that's the column Prisma marks
|
|
35
|
+
* `@unique`, not the relation field itself.
|
|
51
36
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* one, while never falsely rejecting a valid single-field unique lookup. If a
|
|
59
|
-
* project legitimately needs a compound-unique lookup, that path is not covered
|
|
60
|
-
* here and would need explicit config support; the safe escape hatch for any
|
|
61
|
-
* non-unique single-row lookup is `findFirst` (see #565).
|
|
37
|
+
* The config exposes no list-level compound (`@@unique`) declaration, so this
|
|
38
|
+
* cannot validate a compound `<Model>_<a>_<b>` selector — `where` must contain
|
|
39
|
+
* exactly one recognised single-field unique key and no others. This rejects
|
|
40
|
+
* non-unique filters (#567) without ever rejecting a valid single-field unique
|
|
41
|
+
* lookup; a compound-unique or otherwise non-unique lookup should use
|
|
42
|
+
* `findFirst` instead (see #565).
|
|
62
43
|
*/
|
|
63
44
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
64
45
|
function getUniqueWhereKeys(listConfig) {
|
|
@@ -95,10 +76,6 @@ function assertUniqueWhere(where, uniqueKeys, listName) {
|
|
|
95
76
|
throw new ValidationError([message], {});
|
|
96
77
|
}
|
|
97
78
|
}
|
|
98
|
-
/**
|
|
99
|
-
* Check if auto-create is enabled for a singleton list
|
|
100
|
-
* Defaults to true if not explicitly set to false
|
|
101
|
-
*/
|
|
102
79
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
103
80
|
function shouldAutoCreate(listConfig) {
|
|
104
81
|
if (!listConfig.isSingleton)
|
|
@@ -107,47 +84,34 @@ function shouldAutoCreate(listConfig) {
|
|
|
107
84
|
return true;
|
|
108
85
|
return listConfig.isSingleton.autoCreate !== false;
|
|
109
86
|
}
|
|
110
|
-
/**
|
|
111
|
-
* Extract default values from field configs
|
|
112
|
-
* Used to auto-create singleton records with sensible defaults
|
|
113
|
-
*/
|
|
114
87
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
115
88
|
function getDefaultData(listConfig) {
|
|
116
89
|
const data = {};
|
|
117
90
|
for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
118
|
-
// Skip virtual fields - they're not stored in database
|
|
119
91
|
if (fieldConfig.virtual)
|
|
120
92
|
continue;
|
|
121
|
-
// Skip system fields (id, createdAt, updatedAt)
|
|
122
93
|
if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt')
|
|
123
94
|
continue;
|
|
124
|
-
// Add default value if present
|
|
125
95
|
if ('defaultValue' in fieldConfig && fieldConfig.defaultValue !== undefined) {
|
|
126
96
|
data[fieldKey] = fieldConfig.defaultValue;
|
|
127
97
|
}
|
|
128
98
|
}
|
|
129
99
|
return data;
|
|
130
100
|
}
|
|
131
|
-
/**
|
|
132
|
-
* Parse Prisma error and convert to user-friendly DatabaseError
|
|
133
|
-
*/
|
|
134
101
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
135
102
|
function parsePrismaError(error, listConfig) {
|
|
136
|
-
// Check if it's a Prisma error
|
|
137
103
|
if (error &&
|
|
138
104
|
typeof error === 'object' &&
|
|
139
105
|
'code' in error &&
|
|
140
106
|
'meta' in error &&
|
|
141
107
|
typeof error.code === 'string') {
|
|
142
108
|
const prismaError = error;
|
|
143
|
-
//
|
|
109
|
+
// P2002 is Prisma's unique constraint violation code.
|
|
144
110
|
if (prismaError.code === 'P2002') {
|
|
145
111
|
const target = prismaError.meta?.target;
|
|
146
112
|
const fieldErrors = {};
|
|
147
113
|
if (target && Array.isArray(target)) {
|
|
148
|
-
// Get field names from the constraint target
|
|
149
114
|
for (const fieldName of target) {
|
|
150
|
-
// Get the field config to get a better label
|
|
151
115
|
const fieldConfig = listConfig.fields[fieldName];
|
|
152
116
|
const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
|
|
153
117
|
if (fieldConfig) {
|
|
@@ -157,39 +121,54 @@ function parsePrismaError(error, listConfig) {
|
|
|
157
121
|
fieldErrors[fieldName] = `This value is already in use`;
|
|
158
122
|
}
|
|
159
123
|
}
|
|
160
|
-
// Create a user-friendly general message
|
|
161
124
|
const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ');
|
|
162
125
|
return new DatabaseError(`${fieldLabels} must be unique. The value you entered is already in use.`, fieldErrors, prismaError.code);
|
|
163
126
|
}
|
|
164
127
|
return new DatabaseError('A record with this value already exists', {}, prismaError.code);
|
|
165
128
|
}
|
|
166
|
-
// Handle other Prisma errors - return generic message
|
|
167
129
|
return new DatabaseError(prismaError.message || 'A database error occurred', {}, prismaError.code);
|
|
168
130
|
}
|
|
169
|
-
// Not a Prisma error, return as-is if it's already an Error
|
|
170
131
|
if (error instanceof Error) {
|
|
171
132
|
return error;
|
|
172
133
|
}
|
|
173
|
-
// Unknown error type
|
|
174
134
|
return new Error('An unknown error occurred');
|
|
175
135
|
}
|
|
176
136
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
137
|
+
* Drain a `context.transaction()` owner's deferral registry once its callback
|
|
138
|
+
* (and any real underlying transaction) has settled (ADR-0028). A transaction/
|
|
139
|
+
* callback error always wins — compensators still all run, but their errors
|
|
140
|
+
* are discarded in favor of re-surfacing the original, matching the Write
|
|
141
|
+
* Pipeline's `txError` precedence — otherwise any deferred `afterTransaction`
|
|
142
|
+
* errors reject with {@link AfterTransactionError} even though the callback
|
|
143
|
+
* succeeded and the transaction committed.
|
|
183
144
|
*/
|
|
145
|
+
async function settleTransactionOwner(settled, registry) {
|
|
146
|
+
const errors = [];
|
|
147
|
+
let result;
|
|
148
|
+
try {
|
|
149
|
+
result = await settled;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
const outcome = { status: 'rolled-back', error: err };
|
|
153
|
+
await registry.drain(outcome, errors);
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
await registry.drain({ status: 'committed' }, errors);
|
|
157
|
+
if (errors.length > 0) {
|
|
158
|
+
throw new AfterTransactionError(errors);
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
184
162
|
export function getContext(config, prisma, session, storage, _isSudo = false,
|
|
185
163
|
// Internal: when rebuilding the context against a transaction client, reuse the
|
|
186
164
|
// already-initialised plugin services rather than re-running plugin runtimes.
|
|
187
|
-
_sharedPlugins
|
|
188
|
-
|
|
189
|
-
|
|
165
|
+
_sharedPlugins,
|
|
166
|
+
// Internal (ADR-0028, #899): when rebuilding the context for a transaction
|
|
167
|
+
// owner's callback body, carry the deferral registry so writes reached
|
|
168
|
+
// through this context join it instead of firing afterTransaction eagerly.
|
|
169
|
+
_transactionOwner) {
|
|
170
|
+
// Broad type to allow dynamic model access; populated by populateDbDelegate below.
|
|
190
171
|
const db = {};
|
|
191
|
-
// Create context with db reference (will be populated below)
|
|
192
|
-
// Storage utilities can be provided via parameter or use default stubs
|
|
193
172
|
const context = {
|
|
194
173
|
session,
|
|
195
174
|
prisma: prisma,
|
|
@@ -213,13 +192,11 @@ _sharedPlugins) {
|
|
|
213
192
|
plugins: _sharedPlugins ?? {},
|
|
214
193
|
_isSudo,
|
|
215
194
|
_resolveOutputChain: [],
|
|
195
|
+
_transactionOwner,
|
|
216
196
|
};
|
|
217
|
-
// Create access-controlled operations for each list, populating `db` in place.
|
|
218
197
|
populateDbDelegate(db, config, prisma, context);
|
|
219
|
-
// Execute plugin runtime functions and populate context.plugins.
|
|
220
198
|
// Skipped when reusing shared plugins (transaction rebind) so runtimes — and
|
|
221
199
|
// any side effects they carry — run exactly once per top-level context.
|
|
222
|
-
// Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
|
|
223
200
|
if (!_sharedPlugins) {
|
|
224
201
|
const pluginsToExecute = config._plugins || config.plugins || [];
|
|
225
202
|
for (const plugin of pluginsToExecute) {
|
|
@@ -231,13 +208,12 @@ _sharedPlugins) {
|
|
|
231
208
|
}
|
|
232
209
|
catch (error) {
|
|
233
210
|
console.error(`Error executing runtime for plugin "${plugin.name}":`, error);
|
|
234
|
-
// Continue with other plugins even if one fails
|
|
235
211
|
}
|
|
236
212
|
}
|
|
237
213
|
}
|
|
238
214
|
}
|
|
239
|
-
//
|
|
240
|
-
//
|
|
215
|
+
// Returns a result object instead of throwing — required for server actions
|
|
216
|
+
// to work in Next.js production builds.
|
|
241
217
|
async function serverAction(props) {
|
|
242
218
|
const dbKey = getDbKey(props.listKey);
|
|
243
219
|
const listConfig = config.lists[props.listKey];
|
|
@@ -317,8 +293,7 @@ _sharedPlugins) {
|
|
|
317
293
|
return { bulkAction: false, error: 'Action failed' };
|
|
318
294
|
}
|
|
319
295
|
}
|
|
320
|
-
//
|
|
321
|
-
// through the secured context, so the related list's access + hooks apply.
|
|
296
|
+
// Runs on the RELATED row (ADR-0018 boundary — see ServerActionProps above).
|
|
322
297
|
// Honours Silent failure: an access-denied operation returns `null`, which
|
|
323
298
|
// becomes `{ removed: false }` with a generic reason — never leaking whether
|
|
324
299
|
// the row was denied or absent.
|
|
@@ -359,14 +334,13 @@ _sharedPlugins) {
|
|
|
359
334
|
return { removed: false, error: dbError.message };
|
|
360
335
|
}
|
|
361
336
|
}
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
// as `{ created: false }` with a generic reason (no denied-vs-absent leak).
|
|
337
|
+
// Runs on the RELATED list (ADR-0018 boundary — see ServerActionProps above).
|
|
338
|
+
// The back-reference to the parent is set here from `field`/`parentId` (a
|
|
339
|
+
// to-one back-ref connects a single parent; a to-many back-ref, e.g.
|
|
340
|
+
// many-to-many, connects the parent by id), so the client can never
|
|
341
|
+
// re-target the link. Honours Silent failure: an access-denied create
|
|
342
|
+
// returns `null`, surfaced as `{ created: false }` with a generic reason
|
|
343
|
+
// (no denied-vs-absent leak).
|
|
370
344
|
if (props.action === 'createRelated') {
|
|
371
345
|
try {
|
|
372
346
|
// Defensive guard (hardening; unreachable from the drawer, which always
|
|
@@ -423,11 +397,9 @@ _sharedPlugins) {
|
|
|
423
397
|
};
|
|
424
398
|
}
|
|
425
399
|
}
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
// never the parent's. Honours Silent failure: an access-denied update returns
|
|
430
|
-
// `null`, surfaced as `{ updated: false }` with a generic reason (no
|
|
400
|
+
// Updates ONE scalar field on the RELATED row (ADR-0018 boundary — see
|
|
401
|
+
// ServerActionProps above). Honours Silent failure: an access-denied update
|
|
402
|
+
// returns `null`, surfaced as `{ updated: false }` with a generic reason (no
|
|
431
403
|
// denied-vs-absent leak); a validation/db error surfaces its message and
|
|
432
404
|
// fieldErrors so the cell can revert with a reason and show an inline error.
|
|
433
405
|
if (props.action === 'updateRelated') {
|
|
@@ -498,7 +470,6 @@ _sharedPlugins) {
|
|
|
498
470
|
};
|
|
499
471
|
}
|
|
500
472
|
catch (error) {
|
|
501
|
-
// Handle ValidationError (has fieldErrors)
|
|
502
473
|
if (error instanceof ValidationError) {
|
|
503
474
|
return {
|
|
504
475
|
success: false,
|
|
@@ -506,7 +477,6 @@ _sharedPlugins) {
|
|
|
506
477
|
fieldErrors: error.fieldErrors,
|
|
507
478
|
};
|
|
508
479
|
}
|
|
509
|
-
// Handle DatabaseError (has fieldErrors)
|
|
510
480
|
if (error instanceof DatabaseError) {
|
|
511
481
|
return {
|
|
512
482
|
success: false,
|
|
@@ -514,7 +484,6 @@ _sharedPlugins) {
|
|
|
514
484
|
fieldErrors: error.fieldErrors,
|
|
515
485
|
};
|
|
516
486
|
}
|
|
517
|
-
// Parse and convert Prisma errors to user-friendly DatabaseError
|
|
518
487
|
const dbError = parsePrismaError(error, listConfig);
|
|
519
488
|
if (dbError instanceof DatabaseError) {
|
|
520
489
|
return {
|
|
@@ -523,34 +492,44 @@ _sharedPlugins) {
|
|
|
523
492
|
fieldErrors: dbError.fieldErrors,
|
|
524
493
|
};
|
|
525
494
|
}
|
|
526
|
-
// Generic error fallback
|
|
527
495
|
return {
|
|
528
496
|
success: false,
|
|
529
497
|
error: dbError.message,
|
|
530
498
|
};
|
|
531
499
|
}
|
|
532
500
|
}
|
|
533
|
-
//
|
|
534
|
-
// but still executes all hooks and validation
|
|
501
|
+
// Bypasses access control; hooks and validation still run.
|
|
535
502
|
function sudo() {
|
|
536
|
-
return getContext(config, prisma, session, context.storage, true
|
|
503
|
+
return getContext(config, prisma, session, context.storage, true, undefined,
|
|
504
|
+
// ADR-0028: a sudo write issued from inside an owned transaction (e.g.
|
|
505
|
+
// `tx.sudo().db.x.create()`) must still defer to that owner.
|
|
506
|
+
context._transactionOwner);
|
|
537
507
|
}
|
|
538
|
-
// Interactive, hook-firing transaction (#614).
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
508
|
+
// Interactive, hook-firing transaction (#614). See the `transaction` doc on
|
|
509
|
+
// `StackContext` above for the atomicity/isolation/retry contract.
|
|
510
|
+
//
|
|
511
|
+
// This call OWNS a deferral registry for its callback's writes (ADR-0028):
|
|
512
|
+
// it always observes when its own callback settles — resolve/reject — even
|
|
513
|
+
// when the underlying client cannot open a real interactive transaction, so
|
|
514
|
+
// every `txContext.db.*` write defers its transaction-boundary bracket here
|
|
515
|
+
// instead of firing eagerly, and this call flushes them with the real
|
|
516
|
+
// outcome once the callback (and any real transaction) has settled. A
|
|
517
|
+
// `transaction()` nested inside another joins the outer owner's queue
|
|
518
|
+
// rather than creating a second one.
|
|
544
519
|
function transaction(fn, options) {
|
|
545
|
-
|
|
546
|
-
if (typeof client.$transaction !== 'function') {
|
|
547
|
-
// No interactive transaction available — either a plain client/mock or we
|
|
548
|
-
// are already inside a transaction (a Prisma tx client exposes no
|
|
549
|
-
// `$transaction`). Run directly: hook/access semantics are identical and
|
|
550
|
-
// atomicity is provided by any enclosing transaction.
|
|
520
|
+
if (context._transactionOwner) {
|
|
551
521
|
return fn(returned);
|
|
552
522
|
}
|
|
553
|
-
|
|
523
|
+
const registry = new TransactionRegistry();
|
|
524
|
+
const client = prisma;
|
|
525
|
+
const settled = typeof client.$transaction !== 'function'
|
|
526
|
+
? // No interactive transaction available (plain client/mock, or already
|
|
527
|
+
// inside one — see `TransactionCapable` above). Run directly: hook/
|
|
528
|
+
// access semantics are identical, atomicity comes from the enclosing
|
|
529
|
+
// transaction.
|
|
530
|
+
fn(getContext(config, prisma, session, context.storage, _isSudo, context.plugins, registry))
|
|
531
|
+
: client.$transaction((tx) => fn(getContext(config, tx, session, context.storage, _isSudo, context.plugins, registry)), options);
|
|
532
|
+
return settleTransactionOwner(settled, registry);
|
|
554
533
|
}
|
|
555
534
|
const returned = {
|
|
556
535
|
db: db,
|
|
@@ -579,7 +558,6 @@ _sharedPlugins) {
|
|
|
579
558
|
export function populateDbDelegate(target, config, prisma, context) {
|
|
580
559
|
for (const [listName, listConfig] of Object.entries(config.lists)) {
|
|
581
560
|
const dbKey = getDbKey(listName);
|
|
582
|
-
// Create base operations
|
|
583
561
|
const createOp = createCreate(listName, listConfig, prisma, context, config);
|
|
584
562
|
const findManyOp = createFindMany(listName, listConfig, prisma, context, config);
|
|
585
563
|
const updateOp = createUpdate(listName, listConfig, prisma, context, config);
|
|
@@ -590,11 +568,10 @@ export function populateDbDelegate(target, config, prisma, context) {
|
|
|
590
568
|
create: createOp,
|
|
591
569
|
update: updateOp,
|
|
592
570
|
delete: createDelete(listName, listConfig, prisma, context, config),
|
|
593
|
-
count: createCount(listName, listConfig, prisma, context),
|
|
571
|
+
count: createCount(listName, listConfig, prisma, context, config),
|
|
594
572
|
createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
|
|
595
573
|
updateMany: createUpdateMany(listName, listConfig, prisma, context, config, findManyOp, updateOp),
|
|
596
574
|
};
|
|
597
|
-
// Add get() method for singleton lists
|
|
598
575
|
if (isSingletonList(listConfig)) {
|
|
599
576
|
operations.get = createGet(listName, listConfig, prisma, context, config, createOp);
|
|
600
577
|
}
|
|
@@ -619,51 +596,51 @@ export function buildDbDelegate(config, prisma, context) {
|
|
|
619
596
|
*
|
|
620
597
|
* A fragment's own `include` and a sudo caller's `include` are folded and
|
|
621
598
|
* used as-is, matching their existing (unmerged) treatment. A non-sudo
|
|
622
|
-
* caller include is folded and then
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
599
|
+
* caller include is folded and then scoped by `buildAccessScopedInclude`
|
|
600
|
+
* (ADR-0026) — caller-directed, so a relation named nowhere in the folded
|
|
601
|
+
* tree never has its list's `query` access evaluated at all. A bare read
|
|
602
|
+
* stays on the exact ADR-0024 path — `include: undefined`, no related
|
|
603
|
+
* `query` access evaluated — unless folding actually added something, which
|
|
604
|
+
* only happens when a field on this list declares `needs`.
|
|
605
|
+
*
|
|
606
|
+
* Also returns the `FieldSelectionScope` a fragment's own field selection
|
|
607
|
+
* produces (ADR-0027), so the caller can pass it to `filterReadableFields`
|
|
608
|
+
* and make computation itself projection-aware, not only the fold above.
|
|
609
|
+
* `undefined` for every non-fragment path: a caller `include` (sudo or not)
|
|
610
|
+
* and a bare read both mean "compute every field," matching what they
|
|
611
|
+
* already fetch.
|
|
627
612
|
*/
|
|
628
613
|
async function resolveReadInclude(callerInclude, fragmentFields, listName,
|
|
629
614
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
630
615
|
listConfig, context, config) {
|
|
631
616
|
if (fragmentFields !== undefined) {
|
|
632
617
|
const fragmentInclude = buildInclude(fragmentFields) ?? undefined;
|
|
633
|
-
|
|
618
|
+
const selection = buildFieldSelectionScope(fragmentFields);
|
|
619
|
+
const folded = foldDeclaredDependencies(fragmentInclude, listConfig.fields, config, listName, [listName], selection);
|
|
620
|
+
return { ...folded, selection };
|
|
634
621
|
}
|
|
635
622
|
if (context._isSudo) {
|
|
636
|
-
|
|
623
|
+
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName);
|
|
624
|
+
return { ...folded, selection: undefined };
|
|
637
625
|
}
|
|
638
|
-
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config);
|
|
626
|
+
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName);
|
|
639
627
|
if (!folded.include) {
|
|
640
|
-
return folded;
|
|
628
|
+
return { ...folded, selection: undefined };
|
|
641
629
|
}
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
// to it (self-referential or longer) stops re-descending.
|
|
645
|
-
[listName]);
|
|
646
|
-
const include = mergeIncludeWithAccessControl(folded.include, accessControlledInclude, listConfig.fields, config, listName);
|
|
647
|
-
return { include, declaredOnly: folded.declaredOnly };
|
|
630
|
+
const include = await buildAccessScopedInclude(folded.include, listConfig.fields, { session: context.session, context }, config, listName);
|
|
631
|
+
return { include, declaredOnly: folded.declaredOnly, selection: undefined };
|
|
648
632
|
}
|
|
649
|
-
/**
|
|
650
|
-
* Create findUnique operation with access control
|
|
651
|
-
*/
|
|
652
633
|
function createFindUnique(listName,
|
|
653
634
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
654
635
|
listConfig, prisma, context, config) {
|
|
655
636
|
return async (args) => {
|
|
656
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
657
637
|
warnIfSelectIgnored(args, listName, 'findUnique');
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
// `Prisma.<List>FindUniqueArgs`, so its `where` stays Prisma's
|
|
664
|
-
// `<List>WhereUniqueInput` — this runtime guard backstops untyped callers.
|
|
638
|
+
// Runs first, before the access check below — a non-unique `where` is a
|
|
639
|
+
// caller-shape error (see `assertUniqueWhere`), not an access denial. The
|
|
640
|
+
// generated `<List>FindUniqueArgs` only Omits `select`/`include` from
|
|
641
|
+
// Prisma's own type, so `where` stays `<List>WhereUniqueInput` — this
|
|
642
|
+
// runtime guard backstops untyped callers.
|
|
665
643
|
assertUniqueWhere(args.where, getUniqueWhereKeys(listConfig), listName);
|
|
666
|
-
// Check query access (skip if sudo mode)
|
|
667
644
|
let where = args.where;
|
|
668
645
|
if (!context._isSudo) {
|
|
669
646
|
const queryAccess = listConfig.access?.operation?.query;
|
|
@@ -674,28 +651,27 @@ listConfig, prisma, context, config) {
|
|
|
674
651
|
if (accessResult === false) {
|
|
675
652
|
return null;
|
|
676
653
|
}
|
|
677
|
-
// Merge access filter with where clause
|
|
678
654
|
const mergedWhere = mergeFilters(args.where, accessResult);
|
|
679
655
|
if (mergedWhere === null) {
|
|
680
656
|
return null;
|
|
681
657
|
}
|
|
682
658
|
where = mergedWhere;
|
|
683
659
|
}
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
// filterReadableFields; the fragment then narrows to only the requested fields.
|
|
660
|
+
// Access control still runs via filterReadableFields even though a
|
|
661
|
+
// fragment drives `include`; the fragment only narrows which fields come back.
|
|
687
662
|
const fragment = isFragment(args.query) ? args.query : null;
|
|
688
663
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
689
664
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
690
665
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
691
|
-
let { include, declaredOnly } = await resolveReadInclude(args.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
|
|
666
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(args.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
|
|
692
667
|
// Virtual fields have no database column. Whichever path produced
|
|
693
668
|
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
694
669
|
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
695
|
-
// (#628).
|
|
696
|
-
// `
|
|
670
|
+
// (#628). Below, `filterReadableFields` computes a virtual field's value
|
|
671
|
+
// exactly when `selection` says the read is going to return it (ADR-0027)
|
|
672
|
+
// — every one of them for a bare/`include`-based read (`selection` is
|
|
673
|
+
// `undefined`), only the ones a fragment named otherwise.
|
|
697
674
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
|
|
698
|
-
// Execute query with optimized includes
|
|
699
675
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
700
676
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
701
677
|
const model = prisma[getDbKey(listName)];
|
|
@@ -706,33 +682,29 @@ listConfig, prisma, context, config) {
|
|
|
706
682
|
if (!item) {
|
|
707
683
|
return null;
|
|
708
684
|
}
|
|
709
|
-
// Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
710
685
|
// Pass sudo flag through context to skip field-level access checks
|
|
711
686
|
const filtered = await filterReadableFields(item, listConfig.fields, {
|
|
712
687
|
session: context.session,
|
|
713
688
|
context: { ...context, _isSudo: context._isSudo },
|
|
714
|
-
}, config, 0, listName, declaredOnly);
|
|
715
|
-
// When a fragment is provided, pick only the requested fields from the result
|
|
689
|
+
}, config, 0, listName, declaredOnly, selection);
|
|
716
690
|
if (fragment) {
|
|
717
691
|
return pickFields(filtered, fragment._fields);
|
|
718
692
|
}
|
|
719
693
|
return filtered;
|
|
720
694
|
};
|
|
721
695
|
}
|
|
722
|
-
/**
|
|
723
|
-
* Create findMany operation with access control
|
|
724
|
-
*/
|
|
725
696
|
function createFindMany(listName,
|
|
726
697
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
727
698
|
listConfig, prisma, context, config) {
|
|
728
699
|
return async (args) => {
|
|
729
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
730
700
|
warnIfSelectIgnored(args, listName, 'findMany');
|
|
731
|
-
//
|
|
701
|
+
// Singleton misuse throws rather than silently returning `[]` — unlike an
|
|
702
|
+
// access denial, this is a caller-shape error.
|
|
732
703
|
if (isSingletonList(listConfig)) {
|
|
733
704
|
throw new ValidationError([`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`], {});
|
|
734
705
|
}
|
|
735
|
-
// Check query access (skip if sudo mode)
|
|
706
|
+
// Check query access first (skip if sudo mode) — this MUST run before the
|
|
707
|
+
// #912/#915 where/orderBy validation below. See the comment there for why.
|
|
736
708
|
let where = args?.where;
|
|
737
709
|
if (!context._isSudo) {
|
|
738
710
|
const queryAccess = listConfig.access?.operation?.query;
|
|
@@ -743,26 +715,61 @@ listConfig, prisma, context, config) {
|
|
|
743
715
|
if (accessResult === false) {
|
|
744
716
|
return [];
|
|
745
717
|
}
|
|
746
|
-
//
|
|
747
|
-
|
|
718
|
+
// #912 — reject a `where`/`orderBy` key the list config doesn't declare
|
|
719
|
+
// (e.g. a Prisma-generated back-relation), and #915 — reject one naming
|
|
720
|
+
// a field this session cannot READ (closing a probe via a `count()`
|
|
721
|
+
// that varies with the withheld value, or an `orderBy` that leaks
|
|
722
|
+
// relative ordering). Both run only now that the caller is known to
|
|
723
|
+
// have SOME access to the list (`accessResult !== false`): the thrown
|
|
724
|
+
// errors name the offending key, and running them before the access
|
|
725
|
+
// check above would let a caller with ZERO access to the list learn a
|
|
726
|
+
// field's name and read-gating status from the error message alone —
|
|
727
|
+
// turning the validation itself into the kind of oracle #915 closes.
|
|
728
|
+
// `sudo` bypasses this whole branch, matching the write path.
|
|
729
|
+
validateQueryKeys({
|
|
730
|
+
where: args?.where,
|
|
731
|
+
orderBy: args?.orderBy,
|
|
732
|
+
listConfig,
|
|
733
|
+
listName,
|
|
734
|
+
config,
|
|
735
|
+
isSudo: false,
|
|
736
|
+
});
|
|
737
|
+
await validateQueryFieldReadAccess({
|
|
738
|
+
where: args?.where,
|
|
739
|
+
orderBy: args?.orderBy,
|
|
740
|
+
listConfig,
|
|
741
|
+
listName,
|
|
742
|
+
session: context.session,
|
|
743
|
+
context,
|
|
744
|
+
isSudo: false,
|
|
745
|
+
});
|
|
746
|
+
// #916 — scope every relation filter nested in `where`
|
|
747
|
+
// (`some`/`every`/`none`/`is`/`isNot`) by the RELATED list's own `query`
|
|
748
|
+
// access, recursing through every hop of a chain — the `where`
|
|
749
|
+
// counterpart to how `include` is already scoped below via
|
|
750
|
+
// `buildAccessScopedInclude`. Runs after the checks above for the same
|
|
751
|
+
// ordering reason: only once the caller is known to have SOME access to
|
|
752
|
+
// THIS list.
|
|
753
|
+
const scopedWhere = args?.where
|
|
754
|
+
? (await buildAccessScopedWhere(args.where, listConfig, listName, config, {
|
|
755
|
+
session: context.session,
|
|
756
|
+
context,
|
|
757
|
+
}))
|
|
758
|
+
: args?.where;
|
|
759
|
+
const mergedWhere = mergeFilters(scopedWhere, accessResult);
|
|
748
760
|
if (mergedWhere === null) {
|
|
749
761
|
return [];
|
|
750
762
|
}
|
|
751
763
|
where = mergedWhere;
|
|
752
764
|
}
|
|
753
|
-
// When a query fragment is provided, build include from fragment fields
|
|
754
765
|
const fragment = isFragment(args?.query) ? args.query : null;
|
|
755
766
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
756
767
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
757
768
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
758
|
-
let { include, declaredOnly } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
|
|
759
|
-
//
|
|
760
|
-
// `
|
|
761
|
-
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
762
|
-
// (#628). The virtual value is still computed unconditionally below by
|
|
763
|
-
// `filterReadableFields`, independent of what was requested here.
|
|
769
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
|
|
770
|
+
// Strips virtual keys from `include` before the Prisma call — see the
|
|
771
|
+
// `createFindUnique` comment above for why (#628, ADR-0027).
|
|
764
772
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
|
|
765
|
-
// Execute query with optimized includes
|
|
766
773
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
767
774
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
768
775
|
const model = prisma[getDbKey(listName)];
|
|
@@ -773,13 +780,11 @@ listConfig, prisma, context, config) {
|
|
|
773
780
|
skip: args?.skip,
|
|
774
781
|
include,
|
|
775
782
|
});
|
|
776
|
-
// Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
|
|
777
783
|
// Pass sudo flag through context to skip field-level access checks
|
|
778
784
|
const filtered = await Promise.all(items.map((item) => filterReadableFields(item, listConfig.fields, {
|
|
779
785
|
session: context.session,
|
|
780
786
|
context: { ...context, _isSudo: context._isSudo },
|
|
781
|
-
}, config, 0, listName, declaredOnly)));
|
|
782
|
-
// When a fragment is provided, pick only the requested fields from each result
|
|
787
|
+
}, config, 0, listName, declaredOnly, selection)));
|
|
783
788
|
if (fragment) {
|
|
784
789
|
return filtered.map((item) => pickFields(item, fragment._fields));
|
|
785
790
|
}
|
|
@@ -801,9 +806,6 @@ function createFindFirst(findManyOp) {
|
|
|
801
806
|
return result[0] ?? null;
|
|
802
807
|
};
|
|
803
808
|
}
|
|
804
|
-
/**
|
|
805
|
-
* Create create operation with access control and hooks
|
|
806
|
-
*/
|
|
807
809
|
function createCreate(listName,
|
|
808
810
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
809
811
|
listConfig, prisma, context, config) {
|
|
@@ -821,10 +823,8 @@ listConfig, prisma, context, config) {
|
|
|
821
823
|
});
|
|
822
824
|
};
|
|
823
825
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
* Runs create in a loop to ensure all hooks and access control are executed for each item
|
|
827
|
-
*/
|
|
826
|
+
// Runs create in a loop (not Prisma's native createMany) so every item still
|
|
827
|
+
// gets its own hooks and access control.
|
|
828
828
|
function createCreateMany(listName,
|
|
829
829
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
830
830
|
listConfig, prisma, context, config,
|
|
@@ -839,9 +839,6 @@ createFn) {
|
|
|
839
839
|
return results;
|
|
840
840
|
};
|
|
841
841
|
}
|
|
842
|
-
/**
|
|
843
|
-
* Create update operation with access control and hooks
|
|
844
|
-
*/
|
|
845
842
|
function createUpdate(listName,
|
|
846
843
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
847
844
|
listConfig, prisma, context, config) {
|
|
@@ -859,10 +856,8 @@ listConfig, prisma, context, config) {
|
|
|
859
856
|
});
|
|
860
857
|
};
|
|
861
858
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
* Runs findMany to get records, then update in a loop to ensure all hooks and access control are executed
|
|
865
|
-
*/
|
|
859
|
+
// Finds matching records, then updates each individually (not Prisma's native
|
|
860
|
+
// updateMany) so every item still gets its own hooks and access control.
|
|
866
861
|
function createUpdateMany(listName,
|
|
867
862
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
868
863
|
listConfig, prisma, context, config,
|
|
@@ -871,9 +866,7 @@ findManyFn,
|
|
|
871
866
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
872
867
|
updateFn) {
|
|
873
868
|
return async (args) => {
|
|
874
|
-
// First, find all matching records (respects access control)
|
|
875
869
|
const items = await findManyFn({ where: args.where });
|
|
876
|
-
// Then update each one individually (runs hooks and access control for each)
|
|
877
870
|
const results = [];
|
|
878
871
|
for (const item of items) {
|
|
879
872
|
const result = await updateFn({ where: { id: item.id }, data: args.data });
|
|
@@ -882,9 +875,6 @@ updateFn) {
|
|
|
882
875
|
return results;
|
|
883
876
|
};
|
|
884
877
|
}
|
|
885
|
-
/**
|
|
886
|
-
* Create delete operation with access control and hooks
|
|
887
|
-
*/
|
|
888
878
|
function createDelete(listName,
|
|
889
879
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
890
880
|
listConfig, prisma, context, config) {
|
|
@@ -902,14 +892,12 @@ listConfig, prisma, context, config) {
|
|
|
902
892
|
});
|
|
903
893
|
};
|
|
904
894
|
}
|
|
905
|
-
/**
|
|
906
|
-
* Create count operation with access control
|
|
907
|
-
*/
|
|
908
895
|
function createCount(listName,
|
|
909
896
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
910
|
-
listConfig, prisma, context) {
|
|
897
|
+
listConfig, prisma, context, config) {
|
|
911
898
|
return async (args) => {
|
|
912
|
-
// Check query access (skip if sudo mode)
|
|
899
|
+
// Check query access first (skip if sudo mode) — this MUST run before the
|
|
900
|
+
// #912/#915 where validation below. See the comment there for why.
|
|
913
901
|
let where = args?.where;
|
|
914
902
|
if (!context._isSudo) {
|
|
915
903
|
const queryAccess = listConfig.access?.operation?.query;
|
|
@@ -920,14 +908,47 @@ listConfig, prisma, context) {
|
|
|
920
908
|
if (accessResult === false) {
|
|
921
909
|
return 0;
|
|
922
910
|
}
|
|
923
|
-
//
|
|
924
|
-
|
|
911
|
+
// #912 — reject a `where` key the list config doesn't declare (e.g. a
|
|
912
|
+
// Prisma-generated back-relation), and #915 — reject one naming a field
|
|
913
|
+
// this session cannot READ. `count` leaks the most cleanly of any read
|
|
914
|
+
// op — a bare count answers a predicate with no rows returned at all —
|
|
915
|
+
// so it gets the same reject, not a lesser one. Both run only now that
|
|
916
|
+
// the caller is known to have SOME access to the list (`accessResult
|
|
917
|
+
// !== false`) — see the identical comment in `createFindMany` for why
|
|
918
|
+
// that ordering matters: running them before the access check would
|
|
919
|
+
// let a fully-denied caller learn a field's name and read-gating
|
|
920
|
+
// status from the thrown error alone. `sudo` bypasses this whole
|
|
921
|
+
// branch, matching the write path.
|
|
922
|
+
validateQueryKeys({
|
|
923
|
+
where: args?.where,
|
|
924
|
+
listConfig,
|
|
925
|
+
listName,
|
|
926
|
+
config,
|
|
927
|
+
isSudo: false,
|
|
928
|
+
});
|
|
929
|
+
await validateQueryFieldReadAccess({
|
|
930
|
+
where: args?.where,
|
|
931
|
+
listConfig,
|
|
932
|
+
listName,
|
|
933
|
+
session: context.session,
|
|
934
|
+
context,
|
|
935
|
+
isSudo: false,
|
|
936
|
+
});
|
|
937
|
+
// #916 — scope every relation filter nested in `where` by the RELATED
|
|
938
|
+
// list's own `query` access. See the identical comment in
|
|
939
|
+
// `createFindMany` for why this runs here, in this order.
|
|
940
|
+
const scopedWhere = args?.where
|
|
941
|
+
? (await buildAccessScopedWhere(args.where, listConfig, listName, config, {
|
|
942
|
+
session: context.session,
|
|
943
|
+
context,
|
|
944
|
+
}))
|
|
945
|
+
: args?.where;
|
|
946
|
+
const mergedWhere = mergeFilters(scopedWhere, accessResult);
|
|
925
947
|
if (mergedWhere === null) {
|
|
926
948
|
return 0;
|
|
927
949
|
}
|
|
928
950
|
where = mergedWhere;
|
|
929
951
|
}
|
|
930
|
-
// Execute count
|
|
931
952
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
932
953
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
933
954
|
const model = prisma[getDbKey(listName)];
|
|
@@ -937,23 +958,16 @@ listConfig, prisma, context) {
|
|
|
937
958
|
return count;
|
|
938
959
|
};
|
|
939
960
|
}
|
|
940
|
-
/**
|
|
941
|
-
* Create get operation for singleton lists
|
|
942
|
-
* Returns the single record, or auto-creates it if enabled
|
|
943
|
-
*/
|
|
944
961
|
function createGet(listName,
|
|
945
962
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
946
963
|
listConfig, prisma, context, config,
|
|
947
964
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
948
965
|
createFn) {
|
|
949
966
|
return async (args) => {
|
|
950
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
951
967
|
warnIfSelectIgnored(args, listName, 'get');
|
|
952
|
-
// First try to find the existing record
|
|
953
968
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
954
969
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
955
970
|
const model = prisma[getDbKey(listName)];
|
|
956
|
-
// Check query access (skip if sudo mode)
|
|
957
971
|
let where = {};
|
|
958
972
|
if (!context._isSudo) {
|
|
959
973
|
const queryAccess = listConfig.access?.operation?.query;
|
|
@@ -964,45 +978,39 @@ createFn) {
|
|
|
964
978
|
if (accessResult === false) {
|
|
965
979
|
return null;
|
|
966
980
|
}
|
|
967
|
-
//
|
|
981
|
+
// A singleton has no per-record `where`, so the access filter (if any) is
|
|
982
|
+
// the whole `where`.
|
|
968
983
|
if (accessResult && typeof accessResult === 'object') {
|
|
969
984
|
where = accessResult;
|
|
970
985
|
}
|
|
971
986
|
}
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
// filterReadableFields; the fragment then narrows to only the requested fields.
|
|
987
|
+
// Access control still runs via filterReadableFields even though a
|
|
988
|
+
// fragment drives `include`; the fragment only narrows which fields come back.
|
|
975
989
|
const fragment = isFragment(args?.query) ? args.query : null;
|
|
976
990
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
977
991
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
978
992
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
979
|
-
let { include, declaredOnly } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
|
|
993
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
|
|
980
994
|
// Virtual fields have no database column and must never reach Prisma (#628).
|
|
981
995
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
|
|
982
|
-
// Try to find the record
|
|
983
996
|
const item = await model.findFirst({
|
|
984
997
|
where,
|
|
985
998
|
include,
|
|
986
999
|
});
|
|
987
|
-
// If record exists, return it
|
|
988
1000
|
if (item) {
|
|
989
|
-
// Filter readable fields and apply resolveOutput hooks
|
|
990
1001
|
const filtered = await filterReadableFields(item, listConfig.fields, {
|
|
991
1002
|
session: context.session,
|
|
992
1003
|
context: { ...context, _isSudo: context._isSudo },
|
|
993
|
-
}, config, 0, listName, declaredOnly);
|
|
994
|
-
// When a fragment is provided, pick only the requested fields from the result
|
|
1004
|
+
}, config, 0, listName, declaredOnly, selection);
|
|
995
1005
|
if (fragment) {
|
|
996
1006
|
return pickFields(filtered, fragment._fields);
|
|
997
1007
|
}
|
|
998
1008
|
return filtered;
|
|
999
1009
|
}
|
|
1000
|
-
// If no record and auto-create is enabled, create it
|
|
1001
1010
|
if (shouldAutoCreate(listConfig)) {
|
|
1002
1011
|
const defaultData = getDefaultData(listConfig);
|
|
1003
1012
|
return await createFn({ data: defaultData });
|
|
1004
1013
|
}
|
|
1005
|
-
// No record and auto-create is disabled
|
|
1006
1014
|
return null;
|
|
1007
1015
|
};
|
|
1008
1016
|
}
|