@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/dist/context/index.js
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
|
-
import { checkAccess, mergeFilters, filterReadableFields, buildAccessScopedInclude, stripVirtualFieldsFromInclude, foldDeclaredDependencies, } from '../access/index.js';
|
|
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
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
|
|
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.
|
|
44
36
|
*
|
|
45
|
-
* The
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* -
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* Chosen rule (documented intentionally): the config does NOT expose compound
|
|
53
|
-
* (`@@unique`) keys at runtime — there is no list-level unique declaration in the
|
|
54
|
-
* config API — so we cannot validate compound `<Model>_<a>_<b>` selectors. We
|
|
55
|
-
* therefore enforce the tractable subset: `where` must contain EXACTLY ONE
|
|
56
|
-
* recognised single-field unique key and NO other keys. This rejects non-unique
|
|
57
|
-
* filters (the bug in #567) and rejects extra non-unique keys alongside a unique
|
|
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
|
}
|
|
@@ -653,24 +630,17 @@ listConfig, context, config) {
|
|
|
653
630
|
const include = await buildAccessScopedInclude(folded.include, listConfig.fields, { session: context.session, context }, config, listName);
|
|
654
631
|
return { include, declaredOnly: folded.declaredOnly, selection: undefined };
|
|
655
632
|
}
|
|
656
|
-
/**
|
|
657
|
-
* Create findUnique operation with access control
|
|
658
|
-
*/
|
|
659
633
|
function createFindUnique(listName,
|
|
660
634
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
661
635
|
listConfig, prisma, context, config) {
|
|
662
636
|
return async (args) => {
|
|
663
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
664
637
|
warnIfSelectIgnored(args, listName, 'findUnique');
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
// `Prisma.<List>FindUniqueArgs`, so its `where` stays Prisma's
|
|
671
|
-
// `<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.
|
|
672
643
|
assertUniqueWhere(args.where, getUniqueWhereKeys(listConfig), listName);
|
|
673
|
-
// Check query access (skip if sudo mode)
|
|
674
644
|
let where = args.where;
|
|
675
645
|
if (!context._isSudo) {
|
|
676
646
|
const queryAccess = listConfig.access?.operation?.query;
|
|
@@ -681,16 +651,14 @@ listConfig, prisma, context, config) {
|
|
|
681
651
|
if (accessResult === false) {
|
|
682
652
|
return null;
|
|
683
653
|
}
|
|
684
|
-
// Merge access filter with where clause
|
|
685
654
|
const mergedWhere = mergeFilters(args.where, accessResult);
|
|
686
655
|
if (mergedWhere === null) {
|
|
687
656
|
return null;
|
|
688
657
|
}
|
|
689
658
|
where = mergedWhere;
|
|
690
659
|
}
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
// 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.
|
|
694
662
|
const fragment = isFragment(args.query) ? args.query : null;
|
|
695
663
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
696
664
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
@@ -704,7 +672,6 @@ listConfig, prisma, context, config) {
|
|
|
704
672
|
// — every one of them for a bare/`include`-based read (`selection` is
|
|
705
673
|
// `undefined`), only the ones a fragment named otherwise.
|
|
706
674
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
|
|
707
|
-
// Execute query with optimized includes
|
|
708
675
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
709
676
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
710
677
|
const model = prisma[getDbKey(listName)];
|
|
@@ -715,33 +682,29 @@ listConfig, prisma, context, config) {
|
|
|
715
682
|
if (!item) {
|
|
716
683
|
return null;
|
|
717
684
|
}
|
|
718
|
-
// Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
719
685
|
// Pass sudo flag through context to skip field-level access checks
|
|
720
686
|
const filtered = await filterReadableFields(item, listConfig.fields, {
|
|
721
687
|
session: context.session,
|
|
722
688
|
context: { ...context, _isSudo: context._isSudo },
|
|
723
689
|
}, config, 0, listName, declaredOnly, selection);
|
|
724
|
-
// When a fragment is provided, pick only the requested fields from the result
|
|
725
690
|
if (fragment) {
|
|
726
691
|
return pickFields(filtered, fragment._fields);
|
|
727
692
|
}
|
|
728
693
|
return filtered;
|
|
729
694
|
};
|
|
730
695
|
}
|
|
731
|
-
/**
|
|
732
|
-
* Create findMany operation with access control
|
|
733
|
-
*/
|
|
734
696
|
function createFindMany(listName,
|
|
735
697
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
736
698
|
listConfig, prisma, context, config) {
|
|
737
699
|
return async (args) => {
|
|
738
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
739
700
|
warnIfSelectIgnored(args, listName, 'findMany');
|
|
740
|
-
//
|
|
701
|
+
// Singleton misuse throws rather than silently returning `[]` — unlike an
|
|
702
|
+
// access denial, this is a caller-shape error.
|
|
741
703
|
if (isSingletonList(listConfig)) {
|
|
742
704
|
throw new ValidationError([`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`], {});
|
|
743
705
|
}
|
|
744
|
-
// 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.
|
|
745
708
|
let where = args?.where;
|
|
746
709
|
if (!context._isSudo) {
|
|
747
710
|
const queryAccess = listConfig.access?.operation?.query;
|
|
@@ -752,28 +715,61 @@ listConfig, prisma, context, config) {
|
|
|
752
715
|
if (accessResult === false) {
|
|
753
716
|
return [];
|
|
754
717
|
}
|
|
755
|
-
//
|
|
756
|
-
|
|
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);
|
|
757
760
|
if (mergedWhere === null) {
|
|
758
761
|
return [];
|
|
759
762
|
}
|
|
760
763
|
where = mergedWhere;
|
|
761
764
|
}
|
|
762
|
-
// When a query fragment is provided, build include from fragment fields
|
|
763
765
|
const fragment = isFragment(args?.query) ? args.query : null;
|
|
764
766
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
765
767
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
766
768
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
767
769
|
let { include, declaredOnly, selection } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
|
|
768
|
-
//
|
|
769
|
-
// `
|
|
770
|
-
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
771
|
-
// (#628). Below, `filterReadableFields` computes a virtual field's value
|
|
772
|
-
// exactly when `selection` says the read is going to return it (ADR-0027)
|
|
773
|
-
// — every one of them for a bare/`include`-based read (`selection` is
|
|
774
|
-
// `undefined`), only the ones a fragment named otherwise.
|
|
770
|
+
// Strips virtual keys from `include` before the Prisma call — see the
|
|
771
|
+
// `createFindUnique` comment above for why (#628, ADR-0027).
|
|
775
772
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
|
|
776
|
-
// Execute query with optimized includes
|
|
777
773
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
778
774
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
779
775
|
const model = prisma[getDbKey(listName)];
|
|
@@ -784,13 +780,11 @@ listConfig, prisma, context, config) {
|
|
|
784
780
|
skip: args?.skip,
|
|
785
781
|
include,
|
|
786
782
|
});
|
|
787
|
-
// Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
|
|
788
783
|
// Pass sudo flag through context to skip field-level access checks
|
|
789
784
|
const filtered = await Promise.all(items.map((item) => filterReadableFields(item, listConfig.fields, {
|
|
790
785
|
session: context.session,
|
|
791
786
|
context: { ...context, _isSudo: context._isSudo },
|
|
792
787
|
}, config, 0, listName, declaredOnly, selection)));
|
|
793
|
-
// When a fragment is provided, pick only the requested fields from each result
|
|
794
788
|
if (fragment) {
|
|
795
789
|
return filtered.map((item) => pickFields(item, fragment._fields));
|
|
796
790
|
}
|
|
@@ -812,9 +806,6 @@ function createFindFirst(findManyOp) {
|
|
|
812
806
|
return result[0] ?? null;
|
|
813
807
|
};
|
|
814
808
|
}
|
|
815
|
-
/**
|
|
816
|
-
* Create create operation with access control and hooks
|
|
817
|
-
*/
|
|
818
809
|
function createCreate(listName,
|
|
819
810
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
820
811
|
listConfig, prisma, context, config) {
|
|
@@ -832,10 +823,8 @@ listConfig, prisma, context, config) {
|
|
|
832
823
|
});
|
|
833
824
|
};
|
|
834
825
|
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
* Runs create in a loop to ensure all hooks and access control are executed for each item
|
|
838
|
-
*/
|
|
826
|
+
// Runs create in a loop (not Prisma's native createMany) so every item still
|
|
827
|
+
// gets its own hooks and access control.
|
|
839
828
|
function createCreateMany(listName,
|
|
840
829
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
841
830
|
listConfig, prisma, context, config,
|
|
@@ -850,9 +839,6 @@ createFn) {
|
|
|
850
839
|
return results;
|
|
851
840
|
};
|
|
852
841
|
}
|
|
853
|
-
/**
|
|
854
|
-
* Create update operation with access control and hooks
|
|
855
|
-
*/
|
|
856
842
|
function createUpdate(listName,
|
|
857
843
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
858
844
|
listConfig, prisma, context, config) {
|
|
@@ -870,10 +856,8 @@ listConfig, prisma, context, config) {
|
|
|
870
856
|
});
|
|
871
857
|
};
|
|
872
858
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
* Runs findMany to get records, then update in a loop to ensure all hooks and access control are executed
|
|
876
|
-
*/
|
|
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.
|
|
877
861
|
function createUpdateMany(listName,
|
|
878
862
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
879
863
|
listConfig, prisma, context, config,
|
|
@@ -882,9 +866,7 @@ findManyFn,
|
|
|
882
866
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
883
867
|
updateFn) {
|
|
884
868
|
return async (args) => {
|
|
885
|
-
// First, find all matching records (respects access control)
|
|
886
869
|
const items = await findManyFn({ where: args.where });
|
|
887
|
-
// Then update each one individually (runs hooks and access control for each)
|
|
888
870
|
const results = [];
|
|
889
871
|
for (const item of items) {
|
|
890
872
|
const result = await updateFn({ where: { id: item.id }, data: args.data });
|
|
@@ -893,9 +875,6 @@ updateFn) {
|
|
|
893
875
|
return results;
|
|
894
876
|
};
|
|
895
877
|
}
|
|
896
|
-
/**
|
|
897
|
-
* Create delete operation with access control and hooks
|
|
898
|
-
*/
|
|
899
878
|
function createDelete(listName,
|
|
900
879
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
901
880
|
listConfig, prisma, context, config) {
|
|
@@ -913,14 +892,12 @@ listConfig, prisma, context, config) {
|
|
|
913
892
|
});
|
|
914
893
|
};
|
|
915
894
|
}
|
|
916
|
-
/**
|
|
917
|
-
* Create count operation with access control
|
|
918
|
-
*/
|
|
919
895
|
function createCount(listName,
|
|
920
896
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
921
|
-
listConfig, prisma, context) {
|
|
897
|
+
listConfig, prisma, context, config) {
|
|
922
898
|
return async (args) => {
|
|
923
|
-
// 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.
|
|
924
901
|
let where = args?.where;
|
|
925
902
|
if (!context._isSudo) {
|
|
926
903
|
const queryAccess = listConfig.access?.operation?.query;
|
|
@@ -931,14 +908,47 @@ listConfig, prisma, context) {
|
|
|
931
908
|
if (accessResult === false) {
|
|
932
909
|
return 0;
|
|
933
910
|
}
|
|
934
|
-
//
|
|
935
|
-
|
|
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);
|
|
936
947
|
if (mergedWhere === null) {
|
|
937
948
|
return 0;
|
|
938
949
|
}
|
|
939
950
|
where = mergedWhere;
|
|
940
951
|
}
|
|
941
|
-
// Execute count
|
|
942
952
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
943
953
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
944
954
|
const model = prisma[getDbKey(listName)];
|
|
@@ -948,23 +958,16 @@ listConfig, prisma, context) {
|
|
|
948
958
|
return count;
|
|
949
959
|
};
|
|
950
960
|
}
|
|
951
|
-
/**
|
|
952
|
-
* Create get operation for singleton lists
|
|
953
|
-
* Returns the single record, or auto-creates it if enabled
|
|
954
|
-
*/
|
|
955
961
|
function createGet(listName,
|
|
956
962
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
957
963
|
listConfig, prisma, context, config,
|
|
958
964
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
959
965
|
createFn) {
|
|
960
966
|
return async (args) => {
|
|
961
|
-
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
962
967
|
warnIfSelectIgnored(args, listName, 'get');
|
|
963
|
-
// First try to find the existing record
|
|
964
968
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
965
969
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
966
970
|
const model = prisma[getDbKey(listName)];
|
|
967
|
-
// Check query access (skip if sudo mode)
|
|
968
971
|
let where = {};
|
|
969
972
|
if (!context._isSudo) {
|
|
970
973
|
const queryAccess = listConfig.access?.operation?.query;
|
|
@@ -975,14 +978,14 @@ createFn) {
|
|
|
975
978
|
if (accessResult === false) {
|
|
976
979
|
return null;
|
|
977
980
|
}
|
|
978
|
-
//
|
|
981
|
+
// A singleton has no per-record `where`, so the access filter (if any) is
|
|
982
|
+
// the whole `where`.
|
|
979
983
|
if (accessResult && typeof accessResult === 'object') {
|
|
980
984
|
where = accessResult;
|
|
981
985
|
}
|
|
982
986
|
}
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
// 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.
|
|
986
989
|
const fragment = isFragment(args?.query) ? args.query : null;
|
|
987
990
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
988
991
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
@@ -990,30 +993,24 @@ createFn) {
|
|
|
990
993
|
let { include, declaredOnly, selection } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
|
|
991
994
|
// Virtual fields have no database column and must never reach Prisma (#628).
|
|
992
995
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
|
|
993
|
-
// Try to find the record
|
|
994
996
|
const item = await model.findFirst({
|
|
995
997
|
where,
|
|
996
998
|
include,
|
|
997
999
|
});
|
|
998
|
-
// If record exists, return it
|
|
999
1000
|
if (item) {
|
|
1000
|
-
// Filter readable fields and apply resolveOutput hooks
|
|
1001
1001
|
const filtered = await filterReadableFields(item, listConfig.fields, {
|
|
1002
1002
|
session: context.session,
|
|
1003
1003
|
context: { ...context, _isSudo: context._isSudo },
|
|
1004
1004
|
}, config, 0, listName, declaredOnly, selection);
|
|
1005
|
-
// When a fragment is provided, pick only the requested fields from the result
|
|
1006
1005
|
if (fragment) {
|
|
1007
1006
|
return pickFields(filtered, fragment._fields);
|
|
1008
1007
|
}
|
|
1009
1008
|
return filtered;
|
|
1010
1009
|
}
|
|
1011
|
-
// If no record and auto-create is enabled, create it
|
|
1012
1010
|
if (shouldAutoCreate(listConfig)) {
|
|
1013
1011
|
const defaultData = getDefaultData(listConfig);
|
|
1014
1012
|
return await createFn({ data: defaultData });
|
|
1015
1013
|
}
|
|
1016
|
-
// No record and auto-create is disabled
|
|
1017
1014
|
return null;
|
|
1018
1015
|
};
|
|
1019
1016
|
}
|