prisma-generator-express 1.67.0 → 1.68.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/README.md +223 -28
- package/dist/copy/operationDefinitions.d.ts +4 -1
- package/dist/copy/operationDefinitions.js +64 -3
- package/dist/copy/operationDefinitions.js.map +1 -1
- package/dist/generators/generateFastifyHandler.js +55 -9
- package/dist/generators/generateFastifyHandler.js.map +1 -1
- package/dist/generators/generateHonoHandler.js +44 -4
- package/dist/generators/generateHonoHandler.js.map +1 -1
- package/dist/generators/generateHonoOpenApi.d.ts +13 -0
- package/dist/generators/generateHonoOpenApi.js +70 -0
- package/dist/generators/generateHonoOpenApi.js.map +1 -0
- package/dist/generators/generateMcpApp.d.ts +10 -0
- package/dist/generators/generateMcpApp.js +113 -0
- package/dist/generators/generateMcpApp.js.map +1 -0
- package/dist/generators/generateModelMcp.d.ts +9 -0
- package/dist/generators/generateModelMcp.js +145 -0
- package/dist/generators/generateModelMcp.js.map +1 -0
- package/dist/generators/generateModelMetadata.js +9 -5
- package/dist/generators/generateModelMetadata.js.map +1 -1
- package/dist/generators/generateRouter.js +62 -56
- package/dist/generators/generateRouter.js.map +1 -1
- package/dist/generators/generateRouterFastify.js +56 -64
- package/dist/generators/generateRouterFastify.js.map +1 -1
- package/dist/generators/generateRouterHono.js +43 -411
- package/dist/generators/generateRouterHono.js.map +1 -1
- package/dist/generators/generateRouterPartsHono.d.ts +14 -0
- package/dist/generators/generateRouterPartsHono.js +180 -0
- package/dist/generators/generateRouterPartsHono.js.map +1 -0
- package/dist/generators/generateUnifiedHandler.js +36 -20
- package/dist/generators/generateUnifiedHandler.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +101 -3
- package/dist/index.js.map +1 -1
- package/dist/utils/copyFiles.d.ts +1 -1
- package/dist/utils/copyFiles.js +20 -1
- package/dist/utils/copyFiles.js.map +1 -1
- package/dist/utils/mcpGate.d.ts +10 -0
- package/dist/utils/mcpGate.js +118 -0
- package/dist/utils/mcpGate.js.map +1 -0
- package/dist/utils/writeFileSafely.js +6 -0
- package/dist/utils/writeFileSafely.js.map +1 -1
- package/package.json +18 -3
- package/src/copy/buildModelOpenApi.ts +76 -126
- package/src/copy/docsRenderer.ts +14 -15
- package/src/copy/mcpRuntime.ts +1017 -0
- package/src/copy/operationDefinitions.ts +78 -4
- package/src/copy/operationPipeline.ts +267 -0
- package/src/copy/operationRuntime.ts +24 -14
- package/src/copy/operationSchemas.ts +2773 -0
- package/src/copy/pagination.ts +10 -2
- package/src/copy/routeConfig.hono.ts +34 -5
- package/src/copy/routeConfig.ts +1 -1
- package/src/copy/routerParts.ts +524 -0
- package/src/generators/generateFastifyHandler.ts +55 -9
- package/src/generators/generateHonoHandler.ts +44 -4
- package/src/generators/generateHonoOpenApi.ts +93 -0
- package/src/generators/generateMcpApp.ts +133 -0
- package/src/generators/generateModelMcp.ts +208 -0
- package/src/generators/generateModelMetadata.ts +16 -10
- package/src/generators/generateRouter.ts +62 -56
- package/src/generators/generateRouterFastify.ts +56 -64
- package/src/generators/generateRouterHono.ts +49 -459
- package/src/generators/generateRouterPartsHono.ts +220 -0
- package/src/generators/generateUnifiedHandler.ts +36 -21
- package/src/index.ts +116 -3
- package/src/utils/copyFiles.ts +36 -0
- package/src/utils/mcpGate.ts +157 -0
- package/src/utils/writeFileSafely.ts +6 -0
|
@@ -0,0 +1,2773 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared operation-contract / JSON Schema builder.
|
|
3
|
+
*
|
|
4
|
+
* One definition of what an operation's arguments look like, consumed by the
|
|
5
|
+
* OpenAPI builder (POST-read request bodies) and the MCP tools (input
|
|
6
|
+
* schemas). Neither consumer converts the other's output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type OperationSchemaObject = {
|
|
10
|
+
type?: string | string[]
|
|
11
|
+
format?: string
|
|
12
|
+
enum?: string[]
|
|
13
|
+
items?: OperationSchemaObject
|
|
14
|
+
properties?: Record<string, OperationSchemaObject>
|
|
15
|
+
required?: string[]
|
|
16
|
+
description?: string
|
|
17
|
+
oneOf?: OperationSchemaObject[]
|
|
18
|
+
anyOf?: OperationSchemaObject[]
|
|
19
|
+
nullable?: boolean
|
|
20
|
+
maxItems?: number
|
|
21
|
+
minimum?: number
|
|
22
|
+
maximum?: number
|
|
23
|
+
minItems?: number
|
|
24
|
+
minProperties?: number
|
|
25
|
+
pattern?: string
|
|
26
|
+
const?: true
|
|
27
|
+
additionalProperties?: boolean | OperationSchemaObject
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const WHERE_PROP: OperationSchemaObject = {
|
|
31
|
+
type: 'object',
|
|
32
|
+
description: 'Filter conditions',
|
|
33
|
+
}
|
|
34
|
+
const TAKE_PROP: OperationSchemaObject = {
|
|
35
|
+
type: 'integer',
|
|
36
|
+
description: 'Limit results',
|
|
37
|
+
}
|
|
38
|
+
const SKIP_PROP: OperationSchemaObject = {
|
|
39
|
+
type: 'integer',
|
|
40
|
+
description: 'Skip results',
|
|
41
|
+
}
|
|
42
|
+
const CURSOR_PROP: OperationSchemaObject = {
|
|
43
|
+
type: 'object',
|
|
44
|
+
description: 'Cursor for pagination',
|
|
45
|
+
}
|
|
46
|
+
const ORDERBY_PROP: OperationSchemaObject = {
|
|
47
|
+
description: 'Sort order (object or array of objects)',
|
|
48
|
+
}
|
|
49
|
+
const SELECT_PROP: OperationSchemaObject = {
|
|
50
|
+
type: 'object',
|
|
51
|
+
description: 'Select fields',
|
|
52
|
+
}
|
|
53
|
+
const INCLUDE_PROP: OperationSchemaObject = {
|
|
54
|
+
type: 'object',
|
|
55
|
+
description: 'Include relations',
|
|
56
|
+
}
|
|
57
|
+
const OMIT_PROP: OperationSchemaObject = {
|
|
58
|
+
type: 'object',
|
|
59
|
+
description: 'Omit fields from response',
|
|
60
|
+
}
|
|
61
|
+
const DISTINCT_PROP: OperationSchemaObject = {
|
|
62
|
+
description: 'Distinct fields (string or array of strings)',
|
|
63
|
+
}
|
|
64
|
+
const AGG_COUNT: OperationSchemaObject = {
|
|
65
|
+
description: 'Count aggregate (true or field selection object)',
|
|
66
|
+
}
|
|
67
|
+
const AGG_AVG: OperationSchemaObject = {
|
|
68
|
+
type: 'object',
|
|
69
|
+
description: 'Average aggregate (field selection object)',
|
|
70
|
+
}
|
|
71
|
+
const AGG_SUM: OperationSchemaObject = {
|
|
72
|
+
type: 'object',
|
|
73
|
+
description: 'Sum aggregate (field selection object)',
|
|
74
|
+
}
|
|
75
|
+
const AGG_MIN: OperationSchemaObject = {
|
|
76
|
+
type: 'object',
|
|
77
|
+
description: 'Min aggregate (field selection object)',
|
|
78
|
+
}
|
|
79
|
+
const AGG_MAX: OperationSchemaObject = {
|
|
80
|
+
type: 'object',
|
|
81
|
+
description: 'Max aggregate (field selection object)',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const PROJECTION_PROPS: Record<string, OperationSchemaObject> = {
|
|
85
|
+
select: SELECT_PROP,
|
|
86
|
+
include: INCLUDE_PROP,
|
|
87
|
+
omit: OMIT_PROP,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const AGGREGATE_PROPS: Record<string, OperationSchemaObject> = {
|
|
91
|
+
_count: AGG_COUNT,
|
|
92
|
+
_avg: AGG_AVG,
|
|
93
|
+
_sum: AGG_SUM,
|
|
94
|
+
_min: AGG_MIN,
|
|
95
|
+
_max: AGG_MAX,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Argument keys every read operation may accept, by operation name. */
|
|
99
|
+
export const READ_OPERATION_ARG_KEYS: Record<string, readonly string[]> = {
|
|
100
|
+
findMany: [
|
|
101
|
+
'where',
|
|
102
|
+
'select',
|
|
103
|
+
'include',
|
|
104
|
+
'omit',
|
|
105
|
+
'orderBy',
|
|
106
|
+
'cursor',
|
|
107
|
+
'take',
|
|
108
|
+
'skip',
|
|
109
|
+
'distinct',
|
|
110
|
+
],
|
|
111
|
+
findUnique: ['where', 'select', 'include', 'omit'],
|
|
112
|
+
findFirst: [
|
|
113
|
+
'where',
|
|
114
|
+
'select',
|
|
115
|
+
'include',
|
|
116
|
+
'omit',
|
|
117
|
+
'orderBy',
|
|
118
|
+
'cursor',
|
|
119
|
+
'take',
|
|
120
|
+
'skip',
|
|
121
|
+
'distinct',
|
|
122
|
+
],
|
|
123
|
+
count: ['where', 'orderBy', 'cursor', 'take', 'skip', 'select'],
|
|
124
|
+
findManyPaginated: [
|
|
125
|
+
'where',
|
|
126
|
+
'select',
|
|
127
|
+
'include',
|
|
128
|
+
'omit',
|
|
129
|
+
'orderBy',
|
|
130
|
+
'cursor',
|
|
131
|
+
'take',
|
|
132
|
+
'skip',
|
|
133
|
+
'distinct',
|
|
134
|
+
],
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function findManyBodySchema(): OperationSchemaObject {
|
|
138
|
+
return {
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {
|
|
141
|
+
where: WHERE_PROP,
|
|
142
|
+
orderBy: ORDERBY_PROP,
|
|
143
|
+
take: TAKE_PROP,
|
|
144
|
+
skip: SKIP_PROP,
|
|
145
|
+
...PROJECTION_PROPS,
|
|
146
|
+
cursor: CURSOR_PROP,
|
|
147
|
+
distinct: DISTINCT_PROP,
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function findUniqueBodySchema(): OperationSchemaObject {
|
|
153
|
+
return {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
where: { type: 'object', description: 'Unique selector' },
|
|
157
|
+
...PROJECTION_PROPS,
|
|
158
|
+
},
|
|
159
|
+
required: ['where'],
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function countBodySchema(): OperationSchemaObject {
|
|
164
|
+
return {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
where: WHERE_PROP,
|
|
168
|
+
orderBy: { description: 'Sort order' },
|
|
169
|
+
take: TAKE_PROP,
|
|
170
|
+
skip: SKIP_PROP,
|
|
171
|
+
cursor: CURSOR_PROP,
|
|
172
|
+
select: {
|
|
173
|
+
description:
|
|
174
|
+
'Count specific fields. When provided, returns per-field counts as an object instead of a single integer.',
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function aggregateBodySchema(): OperationSchemaObject {
|
|
181
|
+
return {
|
|
182
|
+
type: 'object',
|
|
183
|
+
properties: {
|
|
184
|
+
where: WHERE_PROP,
|
|
185
|
+
orderBy: { description: 'Sort order' },
|
|
186
|
+
cursor: CURSOR_PROP,
|
|
187
|
+
take: TAKE_PROP,
|
|
188
|
+
skip: SKIP_PROP,
|
|
189
|
+
...AGGREGATE_PROPS,
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function groupByBodySchema(): OperationSchemaObject {
|
|
195
|
+
return {
|
|
196
|
+
type: 'object',
|
|
197
|
+
properties: {
|
|
198
|
+
by: {
|
|
199
|
+
type: 'array',
|
|
200
|
+
items: { type: 'string' },
|
|
201
|
+
description: 'Fields to group by',
|
|
202
|
+
},
|
|
203
|
+
where: WHERE_PROP,
|
|
204
|
+
orderBy: {
|
|
205
|
+
description: 'Sort order. Required when using skip or take.',
|
|
206
|
+
},
|
|
207
|
+
having: {
|
|
208
|
+
type: 'object',
|
|
209
|
+
description: 'Having conditions (filter object)',
|
|
210
|
+
},
|
|
211
|
+
take: TAKE_PROP,
|
|
212
|
+
skip: SKIP_PROP,
|
|
213
|
+
...AGGREGATE_PROPS,
|
|
214
|
+
},
|
|
215
|
+
required: ['by'],
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const POST_READ_BODY_SCHEMAS: Record<string, () => OperationSchemaObject> = {
|
|
220
|
+
findMany: findManyBodySchema,
|
|
221
|
+
findFirst: findManyBodySchema,
|
|
222
|
+
findFirstOrThrow: findManyBodySchema,
|
|
223
|
+
findManyPaginated: findManyBodySchema,
|
|
224
|
+
findUnique: findUniqueBodySchema,
|
|
225
|
+
findUniqueOrThrow: findUniqueBodySchema,
|
|
226
|
+
count: countBodySchema,
|
|
227
|
+
aggregate: aggregateBodySchema,
|
|
228
|
+
groupBy: groupByBodySchema,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function getPostReadBodySchema(opName: string): OperationSchemaObject {
|
|
232
|
+
return (POST_READ_BODY_SCHEMAS[opName] ?? findManyBodySchema)()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The JSON Schema for one read operation's arguments — the same contract for
|
|
237
|
+
* OpenAPI POST-read bodies and MCP tool inputs.
|
|
238
|
+
*/
|
|
239
|
+
export function buildOperationArgsSchema(
|
|
240
|
+
opName: string,
|
|
241
|
+
): OperationSchemaObject {
|
|
242
|
+
return getPostReadBodySchema(opName)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Model-aware schemas (MCP). The same contract source as the generic bodies
|
|
247
|
+
// above, narrowed with real model metadata: declared fields only, recursive
|
|
248
|
+
// over the static guard shape's select/include trees, additionalProperties
|
|
249
|
+
// false everywhere the set of keys is enumerated.
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
export type SchemaFieldMeta = {
|
|
253
|
+
name: string
|
|
254
|
+
kind: string
|
|
255
|
+
type: string
|
|
256
|
+
isList: boolean
|
|
257
|
+
isRequired: boolean
|
|
258
|
+
isId?: boolean
|
|
259
|
+
isUnique?: boolean
|
|
260
|
+
isUpdatedAt?: boolean
|
|
261
|
+
/** Prisma @default / autoincrement — drives create-mode requiredness. */
|
|
262
|
+
hasDefaultValue?: boolean
|
|
263
|
+
documentation?: string | null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export type SchemaModelMeta = {
|
|
267
|
+
/** Model name; used in schema descriptions. */
|
|
268
|
+
name: string
|
|
269
|
+
fields: readonly SchemaFieldMeta[]
|
|
270
|
+
enums: ReadonlyMap<string, readonly string[]>
|
|
271
|
+
/** Unique field names (id/unique columns), for unique-selector schemas. */
|
|
272
|
+
uniqueFields: readonly string[]
|
|
273
|
+
/**
|
|
274
|
+
* Compound unique constraints. `selector` is the client-input key
|
|
275
|
+
* (fields joined with '_'); `fields` are the constraint columns.
|
|
276
|
+
*/
|
|
277
|
+
compoundUniques: ReadonlyArray<{
|
|
278
|
+
selector: string
|
|
279
|
+
fields: readonly string[]
|
|
280
|
+
}>
|
|
281
|
+
/** Related models by name, for recursion into select/include shapes. */
|
|
282
|
+
modelIndex: ReadonlyMap<string, SchemaModelMeta>
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const SCALAR_JSON: Record<string, OperationSchemaObject> = {
|
|
286
|
+
String: { type: 'string' },
|
|
287
|
+
Int: { type: 'integer' },
|
|
288
|
+
// guard accepts: safe-range integers and /^-?\d+$/ strings for BigInt;
|
|
289
|
+
// numbers and decimal-pattern strings for Decimal (verified runtime)
|
|
290
|
+
BigInt: {
|
|
291
|
+
anyOf: [
|
|
292
|
+
{
|
|
293
|
+
type: 'integer',
|
|
294
|
+
minimum: -9007199254740991,
|
|
295
|
+
maximum: 9007199254740991,
|
|
296
|
+
},
|
|
297
|
+
{ type: 'string', pattern: '^-?[0-9]+$' },
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
Float: { type: 'number' },
|
|
301
|
+
Decimal: {
|
|
302
|
+
anyOf: [
|
|
303
|
+
{ type: 'number' },
|
|
304
|
+
{
|
|
305
|
+
type: 'string',
|
|
306
|
+
pattern: '^-?([0-9]+[.]?[0-9]*|[.][0-9]+)([eE][+-]?[0-9]+)?$',
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
Boolean: { type: 'boolean' },
|
|
311
|
+
DateTime: { type: 'string', format: 'date-time' },
|
|
312
|
+
Json: { description: 'Arbitrary JSON value' },
|
|
313
|
+
Bytes: {
|
|
314
|
+
type: 'string',
|
|
315
|
+
description: 'Binary data serialized as base64 string',
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const MAX_PROJECTION_DEPTH = 4
|
|
320
|
+
/** Shared budget for combinator and relation recursion in where schemas. */
|
|
321
|
+
const MAX_WHERE_DEPTH = 3
|
|
322
|
+
|
|
323
|
+
const ORDERED_FIELD_TYPES = [
|
|
324
|
+
'Int',
|
|
325
|
+
'Float',
|
|
326
|
+
'DateTime',
|
|
327
|
+
'BigInt',
|
|
328
|
+
'Decimal',
|
|
329
|
+
// guard accepts ordered comparisons on String too (verified runtime)
|
|
330
|
+
'String',
|
|
331
|
+
]
|
|
332
|
+
const STRING_SEARCH_OPS = ['contains', 'startsWith', 'endsWith'] as const
|
|
333
|
+
const LIST_FILTER_OPS = ['has', 'hasSome', 'hasEvery', 'isEmpty'] as const
|
|
334
|
+
const JSON_FILTER_OPS = [
|
|
335
|
+
'string_contains',
|
|
336
|
+
'string_starts_with',
|
|
337
|
+
'string_ends_with',
|
|
338
|
+
'path',
|
|
339
|
+
'array_contains',
|
|
340
|
+
'array_starts_with',
|
|
341
|
+
'array_ends_with',
|
|
342
|
+
] as const
|
|
343
|
+
const MODE_VALUES = ['default', 'insensitive'] as const
|
|
344
|
+
|
|
345
|
+
function enumSchema(
|
|
346
|
+
meta: SchemaModelMeta,
|
|
347
|
+
typeName: string,
|
|
348
|
+
): OperationSchemaObject {
|
|
349
|
+
const values = meta.enums.get(typeName)
|
|
350
|
+
if (values) return { type: 'string', enum: [...values] }
|
|
351
|
+
return { type: 'string' }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function scalarBaseSchema(
|
|
355
|
+
meta: SchemaModelMeta,
|
|
356
|
+
field: SchemaFieldMeta,
|
|
357
|
+
): OperationSchemaObject {
|
|
358
|
+
// the single-item form first; list fields wrap it in an array (guard:
|
|
359
|
+
// createBaseType builds the item base, then z.array for isList)
|
|
360
|
+
const single: OperationSchemaObject =
|
|
361
|
+
field.kind === 'enum'
|
|
362
|
+
? enumSchema(meta, field.type)
|
|
363
|
+
: (SCALAR_JSON[field.type] ?? { type: 'string' })
|
|
364
|
+
return field.isList ? { type: 'array', items: single } : single
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Operators guard 1.33 supports for one field (verified against the
|
|
369
|
+
* runtime): scalar lists expose ONLY has/hasSome/hasEvery/isEmpty/equals;
|
|
370
|
+
* Json exposes equals/not; enums expose no string search and no ordered
|
|
371
|
+
* comparisons; `mode` rides along with String search/equality operators.
|
|
372
|
+
*/
|
|
373
|
+
export function allowedFilterOperators(
|
|
374
|
+
field: SchemaFieldMeta,
|
|
375
|
+
): readonly string[] {
|
|
376
|
+
if (field.kind !== 'scalar' && field.kind !== 'enum') return []
|
|
377
|
+
// list membership comes FIRST (guard: getSupportedOperators checks
|
|
378
|
+
// isList before type) — Bytes[] exposes has/hasSome/hasEvery/isEmpty/
|
|
379
|
+
// equals even though scalar Bytes has no where filters at all
|
|
380
|
+
if (field.isList) {
|
|
381
|
+
return [...LIST_FILTER_OPS, 'equals']
|
|
382
|
+
}
|
|
383
|
+
if (field.type === 'Bytes') {
|
|
384
|
+
// guard: "Bytes field cannot be used in where filters" — no operators
|
|
385
|
+
return []
|
|
386
|
+
}
|
|
387
|
+
const ops = ['equals', 'not', 'in', 'notIn']
|
|
388
|
+
// guard ENUM_OPERATORS = equals/not/in/notIn (verified with isEnum: true)
|
|
389
|
+
if (field.kind === 'enum') return ops
|
|
390
|
+
if (field.type === 'Json') return ['equals', 'not', ...JSON_FILTER_OPS]
|
|
391
|
+
if (field.type === 'String') {
|
|
392
|
+
ops.push(...STRING_SEARCH_OPS, 'search', 'mode')
|
|
393
|
+
}
|
|
394
|
+
if (ORDERED_FIELD_TYPES.includes(field.type)) {
|
|
395
|
+
ops.push('lt', 'lte', 'gt', 'gte')
|
|
396
|
+
}
|
|
397
|
+
return ops
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Filter-object surface for one field (verified guard 1.33 forms). */
|
|
401
|
+
function filterOperatorsSchema(
|
|
402
|
+
meta: SchemaModelMeta,
|
|
403
|
+
field: SchemaFieldMeta,
|
|
404
|
+
): Record<string, OperationSchemaObject> {
|
|
405
|
+
const base = scalarBaseSchema(meta, field)
|
|
406
|
+
if (field.kind !== 'scalar' && field.kind !== 'enum') return {}
|
|
407
|
+
const item = base.items ?? base
|
|
408
|
+
if (field.isList) {
|
|
409
|
+
// scalar lists: has/hasSome/hasEvery take ITEM values, isEmpty a
|
|
410
|
+
// boolean, equals an array of items; in/notIn/not/search ops are
|
|
411
|
+
// unsupported (verified runtime)
|
|
412
|
+
return {
|
|
413
|
+
equals: field.isRequired ? base : { anyOf: [base, { type: 'null' }] },
|
|
414
|
+
has: field.isRequired ? item : { anyOf: [item, { type: 'null' }] },
|
|
415
|
+
hasSome: { type: 'array', items: item },
|
|
416
|
+
hasEvery: { type: 'array', items: item },
|
|
417
|
+
isEmpty: { type: 'boolean' },
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const coercing = ['String', 'Int', 'Float'].includes(field.type)
|
|
421
|
+
const coerced = coercing ? coercionSchema(field.type, base) : base
|
|
422
|
+
// null rides along ONLY where guard wraps nullableIfOptional: equals/not
|
|
423
|
+
// values and in/notIn ITEMS, and only on OPTIONAL fields (source-verified)
|
|
424
|
+
const nullable = (node: OperationSchemaObject): OperationSchemaObject =>
|
|
425
|
+
field.isRequired ? node : { anyOf: [node, { type: 'null' }] }
|
|
426
|
+
const operators: Record<string, OperationSchemaObject> = {
|
|
427
|
+
equals: nullable(coerced),
|
|
428
|
+
in: { type: 'array', items: nullable(coerced) },
|
|
429
|
+
notIn: { type: 'array', items: nullable(coerced) },
|
|
430
|
+
}
|
|
431
|
+
if (coercing) {
|
|
432
|
+
operators.lt = coerced
|
|
433
|
+
operators.lte = coerced
|
|
434
|
+
operators.gt = coerced
|
|
435
|
+
operators.gte = coerced
|
|
436
|
+
}
|
|
437
|
+
if (field.type === 'String') {
|
|
438
|
+
// guard coerces search-operator inputs too (verified: contains 5)
|
|
439
|
+
const searchNode = coercionSchema('String', { type: 'string' })
|
|
440
|
+
for (const op of STRING_SEARCH_OPS) operators[op] = searchNode
|
|
441
|
+
operators.search = searchNode
|
|
442
|
+
}
|
|
443
|
+
if (field.type === 'Json') {
|
|
444
|
+
operators.string_contains = { type: 'string' }
|
|
445
|
+
operators.string_starts_with = { type: 'string' }
|
|
446
|
+
operators.string_ends_with = { type: 'string' }
|
|
447
|
+
// path: a NON-EMPTY array of string segments (verified: empty arrays,
|
|
448
|
+
// mixed elements and bare strings are all guard-rejected)
|
|
449
|
+
operators.path = {
|
|
450
|
+
type: 'array',
|
|
451
|
+
items: { type: 'string' },
|
|
452
|
+
minItems: 1,
|
|
453
|
+
}
|
|
454
|
+
// array_* ops accept arbitrary JSON values, not just arrays (verified)
|
|
455
|
+
operators.array_contains = { description: 'JSON value' }
|
|
456
|
+
operators.array_starts_with = { description: 'JSON value' }
|
|
457
|
+
operators.array_ends_with = { description: 'JSON value' }
|
|
458
|
+
}
|
|
459
|
+
if (
|
|
460
|
+
!coercing &&
|
|
461
|
+
field.kind === 'scalar' &&
|
|
462
|
+
ORDERED_FIELD_TYPES.includes(field.type)
|
|
463
|
+
) {
|
|
464
|
+
operators.lt = base
|
|
465
|
+
operators.lte = base
|
|
466
|
+
operators.gt = base
|
|
467
|
+
operators.gte = base
|
|
468
|
+
}
|
|
469
|
+
// `not` accepts a scalar (nullable if optional) or a nested filter
|
|
470
|
+
// object over EVERY other supported operator (guard: buildNotFilterSchema);
|
|
471
|
+
// the nested properties are a SHALLOW COPY so the node never references
|
|
472
|
+
// itself
|
|
473
|
+
if (allowedFilterOperators(field).includes('not')) {
|
|
474
|
+
operators.not = {
|
|
475
|
+
anyOf: [
|
|
476
|
+
nullable(coerced),
|
|
477
|
+
{
|
|
478
|
+
type: 'object',
|
|
479
|
+
properties: { ...operators },
|
|
480
|
+
additionalProperties: false,
|
|
481
|
+
minProperties: 1,
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return operators
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Guard coerces String/Int/Float operator inputs (verified source:
|
|
491
|
+
* wrapWithInputCoercion) — String accepts numbers, Int accepts /^-?\d+$/
|
|
492
|
+
* strings, Float accepts numeric strings. The emitted schema mirrors it.
|
|
493
|
+
*/
|
|
494
|
+
function coercionSchema(
|
|
495
|
+
type: string,
|
|
496
|
+
base: OperationSchemaObject,
|
|
497
|
+
): OperationSchemaObject {
|
|
498
|
+
if (type === 'String') {
|
|
499
|
+
return { anyOf: [base, { type: 'number' }] }
|
|
500
|
+
}
|
|
501
|
+
if (type === 'Int') {
|
|
502
|
+
return { anyOf: [base, { type: 'string', pattern: '^-?[0-9]+$' }] }
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
anyOf: [
|
|
506
|
+
base,
|
|
507
|
+
{
|
|
508
|
+
type: 'string',
|
|
509
|
+
pattern: '^-?([0-9]+[.]?[0-9]*|[.][0-9]+)([eE][+-]?[0-9]+)?$',
|
|
510
|
+
},
|
|
511
|
+
],
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The `mode` sibling, from the shape's own config: `mode: true` is
|
|
517
|
+
* client-controlled (advertised as the enum); a literal value is forced
|
|
518
|
+
* (guard injects it server-side and REJECTS a client-sent mode) and is
|
|
519
|
+
* never advertised. Null = no mode in the schema.
|
|
520
|
+
*/
|
|
521
|
+
function configuredModeSchema(
|
|
522
|
+
modeVal: unknown,
|
|
523
|
+
hasStringOp: boolean,
|
|
524
|
+
): OperationSchemaObject | null {
|
|
525
|
+
if (!hasStringOp) return null
|
|
526
|
+
// no config or `mode: true`: client-controlled (guard accepts a client
|
|
527
|
+
// mode beside any configured String operator); a literal value is forced
|
|
528
|
+
// (guard injects it and REJECTS a client-sent mode) and never advertised
|
|
529
|
+
if (modeVal === undefined || modeVal === true) {
|
|
530
|
+
return { type: 'string', enum: [...MODE_VALUES] }
|
|
531
|
+
}
|
|
532
|
+
return null
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function scalarFilterSchema(
|
|
536
|
+
meta: SchemaModelMeta,
|
|
537
|
+
field: SchemaFieldMeta,
|
|
538
|
+
): OperationSchemaObject {
|
|
539
|
+
const base = scalarBaseSchema(meta, field)
|
|
540
|
+
const operators = filterOperatorsSchema(meta, field)
|
|
541
|
+
if (field.type === 'String') {
|
|
542
|
+
operators.mode = { type: 'string', enum: [...MODE_VALUES] }
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
anyOf: [
|
|
546
|
+
base,
|
|
547
|
+
{
|
|
548
|
+
type: 'object',
|
|
549
|
+
properties: operators,
|
|
550
|
+
additionalProperties: false,
|
|
551
|
+
minProperties: 1,
|
|
552
|
+
},
|
|
553
|
+
],
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The filter object accepted UNDER `not`: every operator key, never `mode`
|
|
559
|
+
* (guard rejects mode inside not) and never a nested `not`.
|
|
560
|
+
*/
|
|
561
|
+
function nestedNotFilterSchema(
|
|
562
|
+
meta: SchemaModelMeta,
|
|
563
|
+
field: SchemaFieldMeta,
|
|
564
|
+
): OperationSchemaObject {
|
|
565
|
+
const {
|
|
566
|
+
not: _not,
|
|
567
|
+
mode: _mode,
|
|
568
|
+
...operators
|
|
569
|
+
} = filterOperatorsSchema(meta, field)
|
|
570
|
+
return {
|
|
571
|
+
type: 'object',
|
|
572
|
+
properties: operators,
|
|
573
|
+
additionalProperties: false,
|
|
574
|
+
minProperties: 1,
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const WHERE_COMBINATORS = new Set(['AND', 'OR', 'NOT'])
|
|
579
|
+
|
|
580
|
+
function takeSchema(max: number | undefined): OperationSchemaObject {
|
|
581
|
+
return {
|
|
582
|
+
type: 'integer',
|
|
583
|
+
minimum: 1,
|
|
584
|
+
...(max !== undefined ? { maximum: max } : {}),
|
|
585
|
+
description:
|
|
586
|
+
'Limit results (positive integer)' +
|
|
587
|
+
(max !== undefined ? '; at most ' + max : ''),
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Whether a shape subtree carries CLIENT-CONTROLLED input (some operator
|
|
593
|
+
* configured with exactly `true`) and whether it carries server-owned
|
|
594
|
+
* values (literals or force() wrappers). Guard 1.33 accepts an empty
|
|
595
|
+
* condition whenever at least one forced leaf participates (it merges
|
|
596
|
+
* them), and requires at least one operator only when every leaf is
|
|
597
|
+
* client-controlled.
|
|
598
|
+
*/
|
|
599
|
+
function scanShapeClientState(
|
|
600
|
+
val: unknown,
|
|
601
|
+
acc: { client: boolean; forced: boolean } = { client: false, forced: false },
|
|
602
|
+
): { client: boolean; forced: boolean } {
|
|
603
|
+
if (isForcedShapeValue(val)) {
|
|
604
|
+
acc.forced = true
|
|
605
|
+
return acc
|
|
606
|
+
}
|
|
607
|
+
if (!isShapeObject(val)) {
|
|
608
|
+
if (val === true) acc.client = true
|
|
609
|
+
else if (val !== undefined) acc.forced = true
|
|
610
|
+
return acc
|
|
611
|
+
}
|
|
612
|
+
for (const v of Object.values(val)) scanShapeClientState(v, acc)
|
|
613
|
+
return acc
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Where schema narrowed by the guard shape's OWN `where` value, recursively.
|
|
618
|
+
* Scalar configs are operator objects (`{ contains: true }`) — a bare-`true`
|
|
619
|
+
* scalar is guard-invalid and advertises nothing (registration flags it).
|
|
620
|
+
* Relations recurse into the related model via the metadata index, and
|
|
621
|
+
* AND/OR/NOT appear only when the shape configures them with an object.
|
|
622
|
+
* `depth` bounds relation hops so nothing runs away.
|
|
623
|
+
*/
|
|
624
|
+
function whereSchema(
|
|
625
|
+
meta: SchemaModelMeta,
|
|
626
|
+
shapeWhere: unknown,
|
|
627
|
+
depth: number = 4,
|
|
628
|
+
): OperationSchemaObject {
|
|
629
|
+
if (!isShapeObject(shapeWhere)) {
|
|
630
|
+
// shape has no usable where tree: advertise the model's declared
|
|
631
|
+
// where-carrying surface without combinator gymnastics
|
|
632
|
+
return whereAllFields(meta, depth)
|
|
633
|
+
}
|
|
634
|
+
const properties: Record<string, OperationSchemaObject> = {}
|
|
635
|
+
|
|
636
|
+
for (const [key, val] of Object.entries(shapeWhere)) {
|
|
637
|
+
if (WHERE_COMBINATORS.has(key)) {
|
|
638
|
+
if (depth <= 0) continue
|
|
639
|
+
// guard configures combinators with a NON-EMPTY OBJECT of fields;
|
|
640
|
+
// `AND`/`OR` input is an ARRAY (minItems 1), `NOT` object or array;
|
|
641
|
+
// every MEMBER must carry a condition unless the config is fully
|
|
642
|
+
// forced (guard merges forced members and accepts `{}`)
|
|
643
|
+
if (!isShapeObject(val) || Object.keys(val).length === 0) continue
|
|
644
|
+
const node = whereSchema(meta, val, depth - 1)
|
|
645
|
+
const state = scanShapeClientState(val)
|
|
646
|
+
if (state.client && !state.forced) node.minProperties = 1
|
|
647
|
+
properties[key] =
|
|
648
|
+
key === 'NOT'
|
|
649
|
+
? {
|
|
650
|
+
anyOf: [node, { type: 'array', items: node, minItems: 1 }],
|
|
651
|
+
}
|
|
652
|
+
: { type: 'array', items: node, minItems: 1 }
|
|
653
|
+
continue
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const field = meta.fields.find((f) => f.name === key)
|
|
657
|
+
if (!field) continue
|
|
658
|
+
|
|
659
|
+
if (field.kind === 'object') {
|
|
660
|
+
const relatedMeta = meta.modelIndex.get(field.type)
|
|
661
|
+
const nestedVal =
|
|
662
|
+
isShapeObject(val) &&
|
|
663
|
+
Object.keys(val).length > 0 &&
|
|
664
|
+
!isForcedShapeValue(val)
|
|
665
|
+
? val
|
|
666
|
+
: undefined
|
|
667
|
+
if (field.isList) {
|
|
668
|
+
// to-many: EXACTLY the operators the shape declares under
|
|
669
|
+
// some/every/none — a shape with only `some` must not advertise
|
|
670
|
+
// `every`/`none`, and vice versa
|
|
671
|
+
const relationProps: Record<string, OperationSchemaObject> = {}
|
|
672
|
+
if (nestedVal && relatedMeta && depth > 0) {
|
|
673
|
+
for (const op of ['some', 'every', 'none'] as const) {
|
|
674
|
+
const opShape = nestedVal[op]
|
|
675
|
+
if (opShape === undefined) continue
|
|
676
|
+
const inner = whereSchema(relatedMeta, opShape, depth - 1)
|
|
677
|
+
// guard accepts an empty condition only when a forced leaf
|
|
678
|
+
// participates; all-client configs demand at least one operator
|
|
679
|
+
const state = scanShapeClientState(opShape)
|
|
680
|
+
if (state.client && !state.forced) inner.minProperties = 1
|
|
681
|
+
relationProps[op] = inner
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (Object.keys(relationProps).length === 0) continue
|
|
685
|
+
properties[key] = {
|
|
686
|
+
type: 'object',
|
|
687
|
+
properties: relationProps,
|
|
688
|
+
additionalProperties: false,
|
|
689
|
+
minProperties: 1,
|
|
690
|
+
}
|
|
691
|
+
continue
|
|
692
|
+
}
|
|
693
|
+
// to-one: prisma-guard 1.33 accepts ONLY is/isNot; a direct nested
|
|
694
|
+
// where config without them is guard-internal, not client input
|
|
695
|
+
if (!relatedMeta || depth <= 0) {
|
|
696
|
+
properties[key] = {
|
|
697
|
+
type: 'object',
|
|
698
|
+
description: 'Relation filter over ' + field.type,
|
|
699
|
+
}
|
|
700
|
+
continue
|
|
701
|
+
}
|
|
702
|
+
const oneProps: Record<string, OperationSchemaObject> = {}
|
|
703
|
+
const nestedCfg = nestedVal ?? {}
|
|
704
|
+
for (const op of ['is', 'isNot'] as const) {
|
|
705
|
+
if (nestedCfg[op] === undefined) continue
|
|
706
|
+
// `is: null` (and isNot) is a legal forced config: clients send the
|
|
707
|
+
// literal null; no nested properties are advertised
|
|
708
|
+
if (nestedCfg[op] === null) {
|
|
709
|
+
oneProps[op] = { type: 'null' }
|
|
710
|
+
continue
|
|
711
|
+
}
|
|
712
|
+
const inner = whereSchema(relatedMeta, nestedCfg[op], depth - 1)
|
|
713
|
+
const state = scanShapeClientState(nestedCfg[op])
|
|
714
|
+
if (state.client && !state.forced) inner.minProperties = 1
|
|
715
|
+
oneProps[op] = inner
|
|
716
|
+
}
|
|
717
|
+
if (Object.keys(oneProps).length === 0) continue
|
|
718
|
+
// a client-controlled to-one condition must not be sent empty;
|
|
719
|
+
// forced-only conditions are merged server-side and may be empty
|
|
720
|
+
const wrapperMin =
|
|
721
|
+
scanShapeClientState(nestedCfg).client &&
|
|
722
|
+
!scanShapeClientState(nestedCfg).forced
|
|
723
|
+
? { minProperties: 1 }
|
|
724
|
+
: {}
|
|
725
|
+
properties[key] = {
|
|
726
|
+
type: 'object',
|
|
727
|
+
properties: oneProps,
|
|
728
|
+
additionalProperties: false,
|
|
729
|
+
...wrapperMin,
|
|
730
|
+
}
|
|
731
|
+
continue
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (field.kind !== 'scalar' && field.kind !== 'enum') continue
|
|
735
|
+
const narrowed = scalarWhereFromShape(meta, field, val)
|
|
736
|
+
if (narrowed === null) continue
|
|
737
|
+
properties[key] = narrowed
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
type: 'object',
|
|
742
|
+
properties,
|
|
743
|
+
additionalProperties: false,
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* One scalar field's where schema in FILTER context: only operator objects
|
|
749
|
+
* are guard-valid here, only operator values of exactly `true` are
|
|
750
|
+
* client-controlled, and the bare-scalar shortcut exists ONLY alongside
|
|
751
|
+
* `equals: true`. Bare-`true` and literal configs are guard-invalid or
|
|
752
|
+
* server-owned — nothing is advertised.
|
|
753
|
+
*/
|
|
754
|
+
function scalarWhereFromShape(
|
|
755
|
+
meta: SchemaModelMeta,
|
|
756
|
+
field: SchemaFieldMeta,
|
|
757
|
+
val: unknown,
|
|
758
|
+
): OperationSchemaObject | null {
|
|
759
|
+
const base = scalarBaseSchema(meta, field)
|
|
760
|
+
|
|
761
|
+
// bare-`true` filter configs and shorthand values (literals, force()
|
|
762
|
+
// wrappers) are guard-invalid in filter context — nothing is advertised;
|
|
763
|
+
// registration refuses the config
|
|
764
|
+
const cfg = isShapeObject(val) && !isForcedShapeValue(val) ? val : undefined
|
|
765
|
+
if (!cfg || Object.keys(cfg).length === 0) return null
|
|
766
|
+
|
|
767
|
+
const allowed = new Set(allowedFilterOperators(field))
|
|
768
|
+
const table = filterOperatorsSchema(meta, field)
|
|
769
|
+
const operators: Record<string, OperationSchemaObject> = {}
|
|
770
|
+
let hasEqualsTrue = false
|
|
771
|
+
for (const [op, opVal] of Object.entries(cfg)) {
|
|
772
|
+
if (op === 'mode') continue // handled below from the configured value
|
|
773
|
+
if (opVal !== true) continue // forced value — server-owned
|
|
774
|
+
if (!allowed.has(op)) continue
|
|
775
|
+
if (op === 'equals') hasEqualsTrue = true
|
|
776
|
+
// the shared table already carries coercion, nullability and the
|
|
777
|
+
// nested-`not` union exactly as guard builds them
|
|
778
|
+
operators[op] = table[op]
|
|
779
|
+
}
|
|
780
|
+
if (Object.keys(operators).length === 0) return null // fully forced key
|
|
781
|
+
|
|
782
|
+
// `mode` is a String-only sibling — never on enums or other scalars
|
|
783
|
+
const hasStringOp =
|
|
784
|
+
field.type === 'String' &&
|
|
785
|
+
['equals', ...STRING_SEARCH_OPS].some((op) => operators[op])
|
|
786
|
+
const mode = configuredModeSchema(cfg.mode, hasStringOp)
|
|
787
|
+
if (mode) operators.mode = mode
|
|
788
|
+
|
|
789
|
+
const node: OperationSchemaObject = {
|
|
790
|
+
type: 'object',
|
|
791
|
+
properties: operators,
|
|
792
|
+
additionalProperties: false,
|
|
793
|
+
minProperties: 1,
|
|
794
|
+
}
|
|
795
|
+
// the bare-value shortcut exists only alongside client-controlled
|
|
796
|
+
// `equals: true`; it shares the operator's coercion and optional-null
|
|
797
|
+
// surface (guard: nullableIfOptional(coerced))
|
|
798
|
+
if (!hasEqualsTrue) return node
|
|
799
|
+
return {
|
|
800
|
+
anyOf: [table.equals ?? base, node],
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function whereAllFields(
|
|
805
|
+
meta: SchemaModelMeta,
|
|
806
|
+
depth: number,
|
|
807
|
+
): OperationSchemaObject {
|
|
808
|
+
const properties: Record<string, OperationSchemaObject> = {}
|
|
809
|
+
for (const field of meta.fields) {
|
|
810
|
+
if (field.kind === 'object') {
|
|
811
|
+
const relatedMeta = meta.modelIndex.get(field.type)
|
|
812
|
+
if (relatedMeta && depth > 0) {
|
|
813
|
+
const inner = whereSchema(relatedMeta, {}, depth - 1)
|
|
814
|
+
properties[field.name] = field.isList
|
|
815
|
+
? {
|
|
816
|
+
type: 'object',
|
|
817
|
+
properties: { some: inner, every: inner, none: inner },
|
|
818
|
+
additionalProperties: false,
|
|
819
|
+
}
|
|
820
|
+
: {
|
|
821
|
+
type: 'object',
|
|
822
|
+
properties: { is: inner, isNot: inner },
|
|
823
|
+
additionalProperties: false,
|
|
824
|
+
}
|
|
825
|
+
} else {
|
|
826
|
+
properties[field.name] = {
|
|
827
|
+
type: 'object',
|
|
828
|
+
description: 'Relation filter over ' + field.type,
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
continue
|
|
832
|
+
}
|
|
833
|
+
if (field.kind === 'scalar' || field.kind === 'enum') {
|
|
834
|
+
properties[field.name] = scalarFilterSchema(meta, field)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
type: 'object',
|
|
839
|
+
properties,
|
|
840
|
+
additionalProperties: false,
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* One recursive orderBy builder for every model level. Scalar fields accept
|
|
846
|
+
* `asc`/`desc` or `{ sort, nulls }`; a configured to-one relation orders by
|
|
847
|
+
* its own fields, a configured to-many relation additionally by `_count`
|
|
848
|
+
* (guard: `_count` orderBy config must be exactly `true`). Only keys the
|
|
849
|
+
* shape declares are advertised.
|
|
850
|
+
*/
|
|
851
|
+
/** Guard orderBy surface: Json and list fields are unsortable (verified);
|
|
852
|
+
* enums and Bytes are sortable. */
|
|
853
|
+
function isSortableField(field: SchemaFieldMeta): boolean {
|
|
854
|
+
if (field.kind === 'enum') return true
|
|
855
|
+
return field.kind === 'scalar' && !field.isList && field.type !== 'Json'
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function orderBySchema(
|
|
859
|
+
meta: SchemaModelMeta,
|
|
860
|
+
shapeVal?: unknown,
|
|
861
|
+
depth: number = 2,
|
|
862
|
+
): OperationSchemaObject {
|
|
863
|
+
const shapeKeys = isShapeObject(shapeVal)
|
|
864
|
+
? new Set(Object.keys(shapeVal))
|
|
865
|
+
: undefined
|
|
866
|
+
const properties: Record<string, OperationSchemaObject> = {}
|
|
867
|
+
for (const field of meta.fields) {
|
|
868
|
+
if (shapeKeys && !shapeKeys.has(field.name)) continue
|
|
869
|
+
if (field.kind === 'object' && depth > 0) {
|
|
870
|
+
// to-many relations order ONLY by `_count` (guard: "To-many relation
|
|
871
|
+
// orderBy only supports _count"); to-one relations order by fields
|
|
872
|
+
if (field.isList) {
|
|
873
|
+
const nestedShape = isShapeObject(shapeVal)
|
|
874
|
+
? isShapeObject(shapeVal[field.name])
|
|
875
|
+
? (shapeVal[field.name] as Record<string, unknown>)
|
|
876
|
+
: undefined
|
|
877
|
+
: undefined
|
|
878
|
+
if (nestedShape?._count !== true) continue
|
|
879
|
+
properties[field.name] = {
|
|
880
|
+
type: 'object',
|
|
881
|
+
properties: { _count: { type: 'string', enum: ['asc', 'desc'] } },
|
|
882
|
+
additionalProperties: false,
|
|
883
|
+
required: ['_count'],
|
|
884
|
+
}
|
|
885
|
+
continue
|
|
886
|
+
}
|
|
887
|
+
const relatedMeta = meta.modelIndex.get(field.type)
|
|
888
|
+
if (!relatedMeta) continue
|
|
889
|
+
const nestedShape = isShapeObject(shapeVal)
|
|
890
|
+
? isShapeObject(shapeVal[field.name])
|
|
891
|
+
? (shapeVal[field.name] as Record<string, unknown>)
|
|
892
|
+
: undefined
|
|
893
|
+
: undefined
|
|
894
|
+
const nestedSingle = orderBySchema(relatedMeta, nestedShape, depth - 1)
|
|
895
|
+
.anyOf?.[0]
|
|
896
|
+
properties[field.name] = {
|
|
897
|
+
type: 'object',
|
|
898
|
+
properties: { ...nestedSingle?.properties },
|
|
899
|
+
additionalProperties: false,
|
|
900
|
+
minProperties: 1,
|
|
901
|
+
}
|
|
902
|
+
continue
|
|
903
|
+
}
|
|
904
|
+
if (!isSortableField(field)) continue
|
|
905
|
+
properties[field.name] = {
|
|
906
|
+
anyOf: [
|
|
907
|
+
{ type: 'string', enum: ['asc', 'desc'] },
|
|
908
|
+
{
|
|
909
|
+
type: 'object',
|
|
910
|
+
properties: {
|
|
911
|
+
sort: { type: 'string', enum: ['asc', 'desc'] },
|
|
912
|
+
nulls: { type: 'string', enum: ['first', 'last'] },
|
|
913
|
+
},
|
|
914
|
+
additionalProperties: false,
|
|
915
|
+
required: ['sort'],
|
|
916
|
+
},
|
|
917
|
+
],
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const single = {
|
|
921
|
+
type: 'object',
|
|
922
|
+
properties,
|
|
923
|
+
additionalProperties: false,
|
|
924
|
+
minProperties: 1,
|
|
925
|
+
}
|
|
926
|
+
return { anyOf: [single, { type: 'array', items: single }] }
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function distinctSchema(
|
|
930
|
+
meta: SchemaModelMeta,
|
|
931
|
+
shapeVal?: unknown,
|
|
932
|
+
): OperationSchemaObject {
|
|
933
|
+
// narrowed to the distinct values the shape declares
|
|
934
|
+
const shapeNames = Array.isArray(shapeVal)
|
|
935
|
+
? shapeVal.filter((v): v is string => typeof v === 'string')
|
|
936
|
+
: typeof shapeVal === 'string'
|
|
937
|
+
? [shapeVal]
|
|
938
|
+
: meta.fields
|
|
939
|
+
.filter((f) => f.kind === 'scalar' || f.kind === 'enum')
|
|
940
|
+
.map((f) => f.name)
|
|
941
|
+
const names = shapeNames.filter((n) =>
|
|
942
|
+
meta.fields.some(
|
|
943
|
+
(f) => f.name === n && (f.kind === 'scalar' || f.kind === 'enum'),
|
|
944
|
+
),
|
|
945
|
+
)
|
|
946
|
+
return {
|
|
947
|
+
anyOf: [
|
|
948
|
+
{ type: 'string', enum: names },
|
|
949
|
+
{ type: 'array', items: { type: 'string', enum: names }, minItems: 1 },
|
|
950
|
+
],
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const FORCED_MARKER = Symbol.for('prisma-guard.forced')
|
|
955
|
+
|
|
956
|
+
/** prisma-guard `force(x)` wrapper: the value is server-owned, never client input. */
|
|
957
|
+
function isForcedShapeValue(value: unknown): boolean {
|
|
958
|
+
return (
|
|
959
|
+
typeof value === 'object' &&
|
|
960
|
+
value !== null &&
|
|
961
|
+
(value as Record<PropertyKey, unknown>)[
|
|
962
|
+
Symbol.for('prisma-guard.forced')
|
|
963
|
+
] === true
|
|
964
|
+
)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Whether a forced (literal or force()-wrapped) value matches the field's
|
|
969
|
+
* type. Guard type-checks forced values inside compound selectors and
|
|
970
|
+
* filter operators (verified runtime); flat unique literals are only
|
|
971
|
+
* checked by Prisma at execution — refusing a mismatched one here is
|
|
972
|
+
* fail-closed, never more permissive than guard.
|
|
973
|
+
*/
|
|
974
|
+
export function forcedValueMatchesField(
|
|
975
|
+
meta: SchemaModelMeta,
|
|
976
|
+
field: SchemaFieldMeta,
|
|
977
|
+
value: unknown,
|
|
978
|
+
): boolean {
|
|
979
|
+
const v = isForcedShapeValue(value)
|
|
980
|
+
? (value as { value: unknown }).value
|
|
981
|
+
: value
|
|
982
|
+
// guard rejects null unique-selector values ("Unique field ... cannot be
|
|
983
|
+
// null"); Json accepts anything (verified)
|
|
984
|
+
if (v === null || v === undefined) return field.type === 'Json'
|
|
985
|
+
if (field.kind === 'enum') {
|
|
986
|
+
const values = meta.enums.get(field.type)
|
|
987
|
+
return !values || values.includes(v as string)
|
|
988
|
+
}
|
|
989
|
+
if (field.kind !== 'scalar') return true
|
|
990
|
+
if (field.isList) return Array.isArray(v)
|
|
991
|
+
switch (field.type) {
|
|
992
|
+
case 'String':
|
|
993
|
+
case 'DateTime':
|
|
994
|
+
case 'Bytes':
|
|
995
|
+
return typeof v === 'string'
|
|
996
|
+
case 'BigInt':
|
|
997
|
+
// safe-range integer or /^-?\d+$/ string (leading zeros allowed —
|
|
998
|
+
// verified: '007' is accepted)
|
|
999
|
+
return (
|
|
1000
|
+
(typeof v === 'number' &&
|
|
1001
|
+
Number.isInteger(v) &&
|
|
1002
|
+
v >= -9007199254740991 &&
|
|
1003
|
+
v <= 9007199254740991) ||
|
|
1004
|
+
(typeof v === 'string' && /^-?[0-9]+$/.test(v))
|
|
1005
|
+
)
|
|
1006
|
+
case 'Decimal':
|
|
1007
|
+
// number or decimal-pattern string (verified)
|
|
1008
|
+
return (
|
|
1009
|
+
typeof v === 'number' ||
|
|
1010
|
+
(typeof v === 'string' &&
|
|
1011
|
+
/^-?([0-9]+[.]?[0-9]*|[.][0-9]+)([eE][+-]?[0-9]+)?$/.test(v))
|
|
1012
|
+
)
|
|
1013
|
+
case 'Int':
|
|
1014
|
+
return typeof v === 'number' && Number.isInteger(v)
|
|
1015
|
+
case 'Float':
|
|
1016
|
+
return typeof v === 'number'
|
|
1017
|
+
case 'Boolean':
|
|
1018
|
+
return typeof v === 'boolean'
|
|
1019
|
+
default:
|
|
1020
|
+
return true // Json and unknown types accept any forced value
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Whether a forced operator VALUE matches guard's per-operator schema
|
|
1026
|
+
* (verified runtime): comparison operators take the field's scalar, `in`
|
|
1027
|
+
* takes an array (elements are not element-type-checked by guard), list
|
|
1028
|
+
* operators take item values, `isEmpty` a boolean, Json path/array ops
|
|
1029
|
+
* arrays, and `mode` never reaches here.
|
|
1030
|
+
*/
|
|
1031
|
+
export function forcedOperatorValueMatches(
|
|
1032
|
+
meta: SchemaModelMeta,
|
|
1033
|
+
field: SchemaFieldMeta,
|
|
1034
|
+
op: string,
|
|
1035
|
+
value: unknown,
|
|
1036
|
+
): boolean {
|
|
1037
|
+
const v = isForcedShapeValue(value)
|
|
1038
|
+
? (value as { value: unknown }).value
|
|
1039
|
+
: value
|
|
1040
|
+
// null is legal ONLY for `equals`/`not` (and list `has`/`equals`) on
|
|
1041
|
+
// OPTIONAL fields — every other operator rejects null outright, even on
|
|
1042
|
+
// optional fields (source-verified: nullableIfOptional wraps only those)
|
|
1043
|
+
if (v === null || v === undefined) {
|
|
1044
|
+
// LIST null rules first — the whole equals/has value may be null only
|
|
1045
|
+
// on optional fields; a Json[] list is z.array(itemBase), NOT
|
|
1046
|
+
// z.unknown, so required Json[] equals:null is rejected
|
|
1047
|
+
if (field.isList) {
|
|
1048
|
+
return (op === 'equals' || op === 'has') && !field.isRequired
|
|
1049
|
+
}
|
|
1050
|
+
if (op === 'equals' || op === 'not') {
|
|
1051
|
+
return field.type === 'Json' || !field.isRequired
|
|
1052
|
+
}
|
|
1053
|
+
// Json array_* operators are z.unknown() — null accepted even on
|
|
1054
|
+
// required fields (verified); string_/path operators reject it
|
|
1055
|
+
if (
|
|
1056
|
+
field.type === 'Json' &&
|
|
1057
|
+
(op === 'array_contains' ||
|
|
1058
|
+
op === 'array_starts_with' ||
|
|
1059
|
+
op === 'array_ends_with')
|
|
1060
|
+
) {
|
|
1061
|
+
return true
|
|
1062
|
+
}
|
|
1063
|
+
return false
|
|
1064
|
+
}
|
|
1065
|
+
// nested `not`: a filter object over supported operators (excluding
|
|
1066
|
+
// `not` and `mode`) with plain coerced values — guard validates the
|
|
1067
|
+
// exact operator set and rejects unknown names and empty objects
|
|
1068
|
+
if (op === 'not' && isShapeObject(v) && !isForcedShapeValue(v)) {
|
|
1069
|
+
return nestedNotValueMatches(meta, field, v)
|
|
1070
|
+
}
|
|
1071
|
+
return plainOperatorValueMatches(meta, field, op, v)
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function nestedNotValueMatches(
|
|
1075
|
+
meta: SchemaModelMeta,
|
|
1076
|
+
field: SchemaFieldMeta,
|
|
1077
|
+
obj: Record<string, unknown>,
|
|
1078
|
+
): boolean {
|
|
1079
|
+
const entries = Object.entries(obj)
|
|
1080
|
+
if (entries.length === 0) return false // "not filter must specify at least one operator"
|
|
1081
|
+
const allowed = allowedFilterOperators(field).filter(
|
|
1082
|
+
(op) => op !== 'not' && op !== 'mode',
|
|
1083
|
+
)
|
|
1084
|
+
for (const [nop, nval] of entries) {
|
|
1085
|
+
if (!allowed.includes(nop)) return false
|
|
1086
|
+
if (isShapeObject(nval) || isForcedShapeValue(nval)) return false
|
|
1087
|
+
if (nval === null) {
|
|
1088
|
+
// nested `equals` alone accepts null on optional fields (verified);
|
|
1089
|
+
// nested search/ordered operators reject it — keep validating the
|
|
1090
|
+
// remaining entries either way
|
|
1091
|
+
if (!(nop === 'equals' && (field.type === 'Json' || !field.isRequired))) {
|
|
1092
|
+
return false
|
|
1093
|
+
}
|
|
1094
|
+
continue
|
|
1095
|
+
}
|
|
1096
|
+
if (!plainOperatorValueMatches(meta, field, nop, nval)) return false
|
|
1097
|
+
}
|
|
1098
|
+
return true
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Guard's input coercion (verified source + runtime): String accepts
|
|
1103
|
+
* numbers, Int accepts /^-?\d+$/ strings, Float accepts numeric strings;
|
|
1104
|
+
* every other type keeps its base form.
|
|
1105
|
+
*/
|
|
1106
|
+
function coercedScalarMatches(field: SchemaFieldMeta, x: unknown): boolean {
|
|
1107
|
+
switch (field.type) {
|
|
1108
|
+
case 'String':
|
|
1109
|
+
return typeof x === 'string' || typeof x === 'number'
|
|
1110
|
+
case 'Int':
|
|
1111
|
+
return (
|
|
1112
|
+
(typeof x === 'number' && Number.isInteger(x)) ||
|
|
1113
|
+
(typeof x === 'string' && /^-?\d+$/.test(x))
|
|
1114
|
+
)
|
|
1115
|
+
case 'Float':
|
|
1116
|
+
return (
|
|
1117
|
+
typeof x === 'number' ||
|
|
1118
|
+
(typeof x === 'string' &&
|
|
1119
|
+
/^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(x))
|
|
1120
|
+
)
|
|
1121
|
+
default:
|
|
1122
|
+
return true
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function plainOperatorValueMatches(
|
|
1127
|
+
meta: SchemaModelMeta,
|
|
1128
|
+
field: SchemaFieldMeta,
|
|
1129
|
+
op: string,
|
|
1130
|
+
x: unknown,
|
|
1131
|
+
): boolean {
|
|
1132
|
+
// list operators come first and cover EVERY list field (scalar and
|
|
1133
|
+
// enum — guard: getSupportedOperators branches on isList before type):
|
|
1134
|
+
// items are the PLAIN item types — no coercion, and NEVER null items
|
|
1135
|
+
// (guard: z.array(itemBase) without nullableIfOptional on items; only
|
|
1136
|
+
// the WHOLE value may be null on optional fields)
|
|
1137
|
+
if (field.isList) {
|
|
1138
|
+
const itemOk = (e: unknown): boolean =>
|
|
1139
|
+
field.kind === 'enum'
|
|
1140
|
+
? enumMember(meta, field, e)
|
|
1141
|
+
: itemValueMatches(field.type, e)
|
|
1142
|
+
switch (op) {
|
|
1143
|
+
case 'equals':
|
|
1144
|
+
return Array.isArray(x) && x.every(itemOk)
|
|
1145
|
+
case 'has':
|
|
1146
|
+
return x === null ? !field.isRequired : itemOk(x)
|
|
1147
|
+
case 'hasSome':
|
|
1148
|
+
case 'hasEvery':
|
|
1149
|
+
return Array.isArray(x) && x.every(itemOk)
|
|
1150
|
+
case 'isEmpty':
|
|
1151
|
+
return typeof x === 'boolean'
|
|
1152
|
+
default:
|
|
1153
|
+
return false
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
if (field.kind === 'enum') {
|
|
1157
|
+
if (op === 'in' || op === 'notIn') {
|
|
1158
|
+
// items are nullableIfOptional(enum) — null rides along only on
|
|
1159
|
+
// optional fields (verified)
|
|
1160
|
+
return (
|
|
1161
|
+
Array.isArray(x) &&
|
|
1162
|
+
x.every((e) =>
|
|
1163
|
+
e === null ? !field.isRequired : enumMember(meta, field, e),
|
|
1164
|
+
)
|
|
1165
|
+
)
|
|
1166
|
+
}
|
|
1167
|
+
return enumMember(meta, field, x)
|
|
1168
|
+
}
|
|
1169
|
+
if (field.kind !== 'scalar') return true
|
|
1170
|
+
const coercing = ['String', 'Int', 'Float'].includes(field.type)
|
|
1171
|
+
switch (op) {
|
|
1172
|
+
case 'equals':
|
|
1173
|
+
case 'not':
|
|
1174
|
+
case 'lt':
|
|
1175
|
+
case 'lte':
|
|
1176
|
+
case 'gt':
|
|
1177
|
+
case 'gte':
|
|
1178
|
+
// coerced types accept their coerced forms (verified: Int "2")
|
|
1179
|
+
if (coercing) return coercedScalarMatches(field, x)
|
|
1180
|
+
return forcedValueMatchesField(meta, field, x)
|
|
1181
|
+
case 'in':
|
|
1182
|
+
case 'notIn': {
|
|
1183
|
+
if (!Array.isArray(x)) return false
|
|
1184
|
+
const itemOk = (e: unknown): boolean => {
|
|
1185
|
+
if (e === null) return !field.isRequired
|
|
1186
|
+
if (isShapeObject(e) || isForcedShapeValue(e)) return false
|
|
1187
|
+
if (coercing) return coercedScalarMatches(field, e)
|
|
1188
|
+
return itemValueMatches(field.type, e)
|
|
1189
|
+
}
|
|
1190
|
+
return x.every(itemOk)
|
|
1191
|
+
}
|
|
1192
|
+
case 'contains':
|
|
1193
|
+
case 'startsWith':
|
|
1194
|
+
case 'endsWith':
|
|
1195
|
+
case 'search':
|
|
1196
|
+
return field.type === 'String' && coercedScalarMatches(field, x)
|
|
1197
|
+
case 'mode':
|
|
1198
|
+
return false // handled by the mode-specific config rules
|
|
1199
|
+
case 'string_contains':
|
|
1200
|
+
case 'string_starts_with':
|
|
1201
|
+
case 'string_ends_with':
|
|
1202
|
+
return field.type === 'Json' && typeof x === 'string'
|
|
1203
|
+
case 'path':
|
|
1204
|
+
// non-empty array of string segments (verified)
|
|
1205
|
+
return (
|
|
1206
|
+
field.type === 'Json' &&
|
|
1207
|
+
Array.isArray(x) &&
|
|
1208
|
+
x.length > 0 &&
|
|
1209
|
+
x.every((e) => typeof e === 'string')
|
|
1210
|
+
)
|
|
1211
|
+
case 'array_contains':
|
|
1212
|
+
case 'array_starts_with':
|
|
1213
|
+
case 'array_ends_with':
|
|
1214
|
+
// arbitrary JSON values (verified)
|
|
1215
|
+
return field.type === 'Json'
|
|
1216
|
+
default:
|
|
1217
|
+
return true
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function enumMember(
|
|
1222
|
+
meta: SchemaModelMeta,
|
|
1223
|
+
field: SchemaFieldMeta,
|
|
1224
|
+
v: unknown,
|
|
1225
|
+
): boolean {
|
|
1226
|
+
const values = meta.enums.get(field.type)
|
|
1227
|
+
return !values || values.includes(v as string)
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function itemValueMatches(type: string, v: unknown): boolean {
|
|
1231
|
+
switch (type) {
|
|
1232
|
+
case 'Int':
|
|
1233
|
+
return typeof v === 'number' && Number.isInteger(v)
|
|
1234
|
+
case 'Float':
|
|
1235
|
+
return typeof v === 'number'
|
|
1236
|
+
case 'Boolean':
|
|
1237
|
+
return typeof v === 'boolean'
|
|
1238
|
+
case 'String':
|
|
1239
|
+
case 'DateTime':
|
|
1240
|
+
case 'Bytes':
|
|
1241
|
+
return typeof v === 'string'
|
|
1242
|
+
case 'BigInt':
|
|
1243
|
+
return (
|
|
1244
|
+
(typeof v === 'number' &&
|
|
1245
|
+
Number.isInteger(v) &&
|
|
1246
|
+
v >= -9007199254740991 &&
|
|
1247
|
+
v <= 9007199254740991) ||
|
|
1248
|
+
(typeof v === 'string' && /^-?[0-9]+$/.test(v))
|
|
1249
|
+
)
|
|
1250
|
+
case 'Decimal':
|
|
1251
|
+
return (
|
|
1252
|
+
typeof v === 'number' ||
|
|
1253
|
+
(typeof v === 'string' &&
|
|
1254
|
+
/^-?([0-9]+[.]?[0-9]*|[.][0-9]+)([eE][+-]?[0-9]+)?$/.test(v))
|
|
1255
|
+
)
|
|
1256
|
+
default:
|
|
1257
|
+
return true
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Whether a findUnique/findUniqueOrThrow shape's `where` config is
|
|
1263
|
+
* guard-valid. Guard 1.33 rejects (or crashes on) anything else, so the
|
|
1264
|
+
* MCP runtime refuses to register a tool exposing such a shape:
|
|
1265
|
+
* - `where` must be a non-empty object (missing / empty / `where: true`
|
|
1266
|
+
* are all guard-invalid),
|
|
1267
|
+
* - at least one key must be a flat unique field or a compound selector
|
|
1268
|
+
* key; other scalar keys ride along as extended unique filters (guard
|
|
1269
|
+
* buildUniqueWhereSchema accepts them — the tenant-safe
|
|
1270
|
+
* `{ id: true, siteId: force(t) }` form); relations are invalid,
|
|
1271
|
+
* - a flat field is configured with exactly `true`, a literal or a
|
|
1272
|
+
* force() wrapper — never filter operators,
|
|
1273
|
+
* - a compound selector is configured with an object carrying EVERY
|
|
1274
|
+
* constraint field (guard: "Missing field ... in compound unique
|
|
1275
|
+
* selector"), each `true`, a literal or a force() wrapper.
|
|
1276
|
+
* Forced values are type-checked (see forcedValueMatchesField).
|
|
1277
|
+
* Returns null when the config is valid, else the problem description.
|
|
1278
|
+
*/
|
|
1279
|
+
export function findUniqueWhereConfigProblem(
|
|
1280
|
+
meta: SchemaModelMeta,
|
|
1281
|
+
shapeWhere: unknown,
|
|
1282
|
+
): string | null {
|
|
1283
|
+
if (!isShapeObject(shapeWhere)) {
|
|
1284
|
+
return 'findUnique where must be an object of unique-field configs'
|
|
1285
|
+
}
|
|
1286
|
+
const keys = Object.keys(shapeWhere)
|
|
1287
|
+
if (keys.length === 0) {
|
|
1288
|
+
return 'findUnique where must cover at least one unique constraint'
|
|
1289
|
+
}
|
|
1290
|
+
let covered = false
|
|
1291
|
+
for (const [key, val] of Object.entries(shapeWhere)) {
|
|
1292
|
+
const constraint = meta.compoundUniques.find((c) => c.selector === key)
|
|
1293
|
+
if (constraint) {
|
|
1294
|
+
covered = true
|
|
1295
|
+
if (!isShapeObject(val) || isForcedShapeValue(val)) {
|
|
1296
|
+
return `compound selector "${key}" must be configured with an object of field configs`
|
|
1297
|
+
}
|
|
1298
|
+
for (const fk of constraint.fields) {
|
|
1299
|
+
if (!(fk in val)) {
|
|
1300
|
+
return `compound selector "${key}" is missing field "${fk}"`
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
for (const [fk, fv] of Object.entries(val)) {
|
|
1304
|
+
if (!constraint.fields.includes(fk)) {
|
|
1305
|
+
return `unknown field "${fk}" in compound selector "${key}"`
|
|
1306
|
+
}
|
|
1307
|
+
if (isShapeObject(fv) && !isForcedShapeValue(fv)) {
|
|
1308
|
+
return `compound selector field "${key}.${fk}" accepts only true or a forced value, not filter operators`
|
|
1309
|
+
}
|
|
1310
|
+
const fkField = fieldByName(meta, fk)
|
|
1311
|
+
if (
|
|
1312
|
+
fv !== true &&
|
|
1313
|
+
!forcedValueMatchesField(meta, fkField ?? scalarGuess(fk), fv)
|
|
1314
|
+
) {
|
|
1315
|
+
return `forced value for "${key}.${fk}" does not match the field type`
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
continue
|
|
1319
|
+
}
|
|
1320
|
+
const flatUnique =
|
|
1321
|
+
meta.uniqueFields.includes(key) &&
|
|
1322
|
+
meta.fields.some((f) => f.name === key && (f.isId || f.isUnique))
|
|
1323
|
+
const keyField = fieldByName(meta, key)
|
|
1324
|
+
if (!flatUnique) {
|
|
1325
|
+
// extended unique where (Prisma 5+, guard buildUniqueWhereSchema): a
|
|
1326
|
+
// NON-unique scalar may ride beside a covering selector — the
|
|
1327
|
+
// tenant-safe `{ id: true, siteId: force(tenant) }` form. Relations
|
|
1328
|
+
// and unknown keys are guard-invalid.
|
|
1329
|
+
if (
|
|
1330
|
+
!keyField ||
|
|
1331
|
+
(keyField.kind !== 'scalar' && keyField.kind !== 'enum')
|
|
1332
|
+
) {
|
|
1333
|
+
return `"${key}" is not a scalar field or compound selector of ${meta.name}`
|
|
1334
|
+
}
|
|
1335
|
+
if (isShapeObject(val) && !isForcedShapeValue(val)) {
|
|
1336
|
+
return `unique where "${key}" accepts only true or a forced value, not filter operators`
|
|
1337
|
+
}
|
|
1338
|
+
// guard parses it as a direct scalar with input coercion, nullable
|
|
1339
|
+
// on optional fields — the same parse as forced data values
|
|
1340
|
+
if (val !== true && !forcedDataValueMatches(meta, keyField, val)) {
|
|
1341
|
+
return `forced value for "${key}" does not match the field type`
|
|
1342
|
+
}
|
|
1343
|
+
continue
|
|
1344
|
+
}
|
|
1345
|
+
covered = true
|
|
1346
|
+
if (isShapeObject(val) && !isForcedShapeValue(val)) {
|
|
1347
|
+
return `unique where "${key}" accepts only true or a forced value, not filter operators`
|
|
1348
|
+
}
|
|
1349
|
+
if (
|
|
1350
|
+
val !== true &&
|
|
1351
|
+
keyField &&
|
|
1352
|
+
!forcedValueMatchesField(meta, keyField, val)
|
|
1353
|
+
) {
|
|
1354
|
+
return `forced value for "${key}" does not match the field type`
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (!covered) {
|
|
1358
|
+
return `unique where must cover a unique constraint of ${meta.name} (a unique field or compound selector)`
|
|
1359
|
+
}
|
|
1360
|
+
return null
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function fieldByName(
|
|
1364
|
+
meta: SchemaModelMeta,
|
|
1365
|
+
name: string,
|
|
1366
|
+
): SchemaFieldMeta | undefined {
|
|
1367
|
+
return meta.fields.find((f) => f.name === name)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** Fallback meta for constraint fields missing from the model metadata. */
|
|
1371
|
+
function scalarGuess(name: string): SchemaFieldMeta {
|
|
1372
|
+
return {
|
|
1373
|
+
name,
|
|
1374
|
+
kind: 'scalar',
|
|
1375
|
+
type: 'String',
|
|
1376
|
+
isList: false,
|
|
1377
|
+
isRequired: true,
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Whether a FILTER-context where config is guard-valid (verified against
|
|
1383
|
+
* the 1.33 runtime): operator objects with known per-type operators, no
|
|
1384
|
+
* shorthand values, non-empty configs everywhere, some/every/none on
|
|
1385
|
+
* to-many relations and is/isNot on to-one relations only, and NO forced
|
|
1386
|
+
* values under the negating operators `none`/`isNot` (guard: "mixes
|
|
1387
|
+
* client-controlled and forced" even when the config is forced-only).
|
|
1388
|
+
* Returns null when valid, else the problem description.
|
|
1389
|
+
*/
|
|
1390
|
+
export function filterWhereConfigProblem(
|
|
1391
|
+
meta: SchemaModelMeta,
|
|
1392
|
+
shapeWhere: unknown,
|
|
1393
|
+
negated: boolean = false,
|
|
1394
|
+
): string | null {
|
|
1395
|
+
if (!isShapeObject(shapeWhere)) {
|
|
1396
|
+
return 'where config must be an object'
|
|
1397
|
+
}
|
|
1398
|
+
if (Object.keys(shapeWhere).length === 0) {
|
|
1399
|
+
return 'where config must define at least one field'
|
|
1400
|
+
}
|
|
1401
|
+
for (const [key, val] of Object.entries(shapeWhere)) {
|
|
1402
|
+
if (WHERE_COMBINATORS.has(key)) {
|
|
1403
|
+
if (!isShapeObject(val) || Object.keys(val).length === 0) {
|
|
1404
|
+
return `combinator "${key}" must be configured with a non-empty object of fields`
|
|
1405
|
+
}
|
|
1406
|
+
const nested = filterWhereConfigProblem(meta, val)
|
|
1407
|
+
if (nested) return nested
|
|
1408
|
+
continue
|
|
1409
|
+
}
|
|
1410
|
+
const field = meta.fields.find((f) => f.name === key)
|
|
1411
|
+
if (!field) return `"${key}" is not a field of ${meta.name}`
|
|
1412
|
+
if (field.kind === 'object') {
|
|
1413
|
+
if (!isShapeObject(val) || isForcedShapeValue(val)) {
|
|
1414
|
+
return `relation "${key}" must be configured with an object`
|
|
1415
|
+
}
|
|
1416
|
+
if (Object.keys(val).length === 0) {
|
|
1417
|
+
return `relation "${key}" must define at least one operator`
|
|
1418
|
+
}
|
|
1419
|
+
const relatedMeta = meta.modelIndex.get(field.type)
|
|
1420
|
+
if (!relatedMeta) continue
|
|
1421
|
+
if (field.isList) {
|
|
1422
|
+
for (const k of Object.keys(val)) {
|
|
1423
|
+
if (k !== 'some' && k !== 'every' && k !== 'none') {
|
|
1424
|
+
return `to-many relation "${key}" accepts only some/every/none, not "${k}"`
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
for (const op of ['some', 'every', 'none'] as const) {
|
|
1428
|
+
const opVal = val[op]
|
|
1429
|
+
if (opVal === undefined) continue
|
|
1430
|
+
if (!isShapeObject(opVal) || Object.keys(opVal).length === 0) {
|
|
1431
|
+
return `relation operator "${key}.${op}" must define at least one operator config`
|
|
1432
|
+
}
|
|
1433
|
+
const nested = filterWhereConfigProblem(
|
|
1434
|
+
relatedMeta,
|
|
1435
|
+
opVal,
|
|
1436
|
+
negated || op === 'none',
|
|
1437
|
+
)
|
|
1438
|
+
if (nested) return nested
|
|
1439
|
+
}
|
|
1440
|
+
} else {
|
|
1441
|
+
for (const k of Object.keys(val)) {
|
|
1442
|
+
if (k !== 'is' && k !== 'isNot') {
|
|
1443
|
+
return `to-one relation "${key}" accepts only is/isNot, not "${k}"`
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
for (const op of ['is', 'isNot'] as const) {
|
|
1447
|
+
const opVal = val[op]
|
|
1448
|
+
if (opVal === undefined) continue
|
|
1449
|
+
// `is: null` / `isNot: null` are legal forced configs
|
|
1450
|
+
if (opVal === null) continue
|
|
1451
|
+
if (!isShapeObject(opVal) || Object.keys(opVal).length === 0) {
|
|
1452
|
+
return `relation operator "${key}.${op}" must define at least one operator config`
|
|
1453
|
+
}
|
|
1454
|
+
const nested = filterWhereConfigProblem(
|
|
1455
|
+
relatedMeta,
|
|
1456
|
+
opVal,
|
|
1457
|
+
negated || op === 'isNot',
|
|
1458
|
+
)
|
|
1459
|
+
if (nested) return nested
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
continue
|
|
1463
|
+
}
|
|
1464
|
+
if (field.kind !== 'scalar' && field.kind !== 'enum') continue
|
|
1465
|
+
if (negated && isForcedShapeValue(val)) {
|
|
1466
|
+
return `"${key}" under a negating operator (none/isNot) cannot carry forced values`
|
|
1467
|
+
}
|
|
1468
|
+
if (!isShapeObject(val) || isForcedShapeValue(val)) {
|
|
1469
|
+
return `filter "${key}" must be configured with an object of operators`
|
|
1470
|
+
}
|
|
1471
|
+
if (Object.keys(val).length === 0) {
|
|
1472
|
+
return `filter "${key}" must configure at least one operator`
|
|
1473
|
+
}
|
|
1474
|
+
const allowed = allowedFilterOperators(field)
|
|
1475
|
+
const opKeys = Object.keys(val).filter((k) => k !== 'mode')
|
|
1476
|
+
if (opKeys.length === 0) {
|
|
1477
|
+
return `filter "${key}" configures only "mode" but no operator`
|
|
1478
|
+
}
|
|
1479
|
+
for (const [op, opVal] of Object.entries(val)) {
|
|
1480
|
+
if (op === 'mode') {
|
|
1481
|
+
if (field.type !== 'String') {
|
|
1482
|
+
return `"mode" is only valid on String fields, not "${key}"`
|
|
1483
|
+
}
|
|
1484
|
+
if (negated && opVal !== true) {
|
|
1485
|
+
return `"mode" of "${key}" cannot be forced under a negating operator`
|
|
1486
|
+
}
|
|
1487
|
+
if (opVal !== true && opVal !== 'default' && opVal !== 'insensitive') {
|
|
1488
|
+
return `"mode" of "${key}" must be true, 'default' or 'insensitive'`
|
|
1489
|
+
}
|
|
1490
|
+
continue
|
|
1491
|
+
}
|
|
1492
|
+
if (!allowed.includes(op)) {
|
|
1493
|
+
return `operator "${op}" is not supported for ${field.kind === 'enum' ? 'enum' : field.type} field "${key}"`
|
|
1494
|
+
}
|
|
1495
|
+
if (opVal === true) continue
|
|
1496
|
+
if (negated) {
|
|
1497
|
+
return `"${key}" under a negating operator (none/isNot) cannot carry forced values`
|
|
1498
|
+
}
|
|
1499
|
+
// `not` also accepts a nested filter object of plain values; every
|
|
1500
|
+
// other operator takes true or a forced value
|
|
1501
|
+
if (isShapeObject(opVal) && !isForcedShapeValue(opVal) && op !== 'not') {
|
|
1502
|
+
return `operator "${op}" of "${key}" accepts only true or a forced value`
|
|
1503
|
+
}
|
|
1504
|
+
if (!forcedOperatorValueMatches(meta, field, op, opVal)) {
|
|
1505
|
+
return `forced value for "${key}.${op}" does not match the operator`
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
return null
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Shape-config keys guard 1.33 accepts per read operation (verified
|
|
1514
|
+
* runtime: "Arg X not allowed for method Y"). `omit` is NOT a shape key.
|
|
1515
|
+
*/
|
|
1516
|
+
const OP_SHAPE_KEYS: Record<string, readonly string[]> = {
|
|
1517
|
+
findMany: [
|
|
1518
|
+
'where',
|
|
1519
|
+
'orderBy',
|
|
1520
|
+
'distinct',
|
|
1521
|
+
'cursor',
|
|
1522
|
+
'take',
|
|
1523
|
+
'skip',
|
|
1524
|
+
'select',
|
|
1525
|
+
'include',
|
|
1526
|
+
],
|
|
1527
|
+
findManyPaginated: [
|
|
1528
|
+
'where',
|
|
1529
|
+
'orderBy',
|
|
1530
|
+
'distinct',
|
|
1531
|
+
'cursor',
|
|
1532
|
+
'take',
|
|
1533
|
+
'skip',
|
|
1534
|
+
'select',
|
|
1535
|
+
'include',
|
|
1536
|
+
],
|
|
1537
|
+
findFirst: [
|
|
1538
|
+
'where',
|
|
1539
|
+
'orderBy',
|
|
1540
|
+
'distinct',
|
|
1541
|
+
'cursor',
|
|
1542
|
+
'take',
|
|
1543
|
+
'skip',
|
|
1544
|
+
'select',
|
|
1545
|
+
'include',
|
|
1546
|
+
],
|
|
1547
|
+
findUnique: ['where', 'select', 'include'],
|
|
1548
|
+
count: ['where', 'orderBy', 'select', 'take', 'cursor', 'skip'],
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/**
|
|
1552
|
+
* Validates the COMPLETE static shape of one read operation the way
|
|
1553
|
+
* prisma-guard 1.33 compiles it (verified runtime): per-operation allowed
|
|
1554
|
+
* keys (unknown keys refused, `where` required for findUnique), where
|
|
1555
|
+
* (filter or unique-selector per operation), orderBy, distinct, cursor,
|
|
1556
|
+
* select, include, `_count` and the take/skip config forms. The full
|
|
1557
|
+
* config tree is validated — it is a finite literal, no depth cap.
|
|
1558
|
+
* Registration refuses a shape with any problem.
|
|
1559
|
+
* Returns null when valid, else the problem description.
|
|
1560
|
+
*/
|
|
1561
|
+
export function argsShapeConfigProblem(
|
|
1562
|
+
meta: SchemaModelMeta,
|
|
1563
|
+
operation: string,
|
|
1564
|
+
shape: unknown,
|
|
1565
|
+
): string | null {
|
|
1566
|
+
if (!isShapeObject(shape)) return null // dynamic shape: checked per call
|
|
1567
|
+
const allowedKeys = OP_SHAPE_KEYS[operation]
|
|
1568
|
+
if (allowedKeys) {
|
|
1569
|
+
for (const key of Object.keys(shape)) {
|
|
1570
|
+
if (!allowedKeys.includes(key)) {
|
|
1571
|
+
return `"${key}" is not a valid shape config key for ${operation}`
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
if (operation === 'findUnique' || operation === 'findUniqueOrThrow') {
|
|
1576
|
+
if (shape.where === undefined) {
|
|
1577
|
+
return 'findUnique shape must define "where"'
|
|
1578
|
+
}
|
|
1579
|
+
const problem = findUniqueWhereConfigProblem(meta, shape.where)
|
|
1580
|
+
if (problem) return problem
|
|
1581
|
+
} else if (shape.where !== undefined) {
|
|
1582
|
+
const problem = filterWhereConfigProblem(meta, shape.where)
|
|
1583
|
+
if (problem) return problem
|
|
1584
|
+
}
|
|
1585
|
+
if (shape.orderBy !== undefined) {
|
|
1586
|
+
const problem = orderByConfigProblem(meta, shape.orderBy)
|
|
1587
|
+
if (problem) return problem
|
|
1588
|
+
}
|
|
1589
|
+
if (shape.distinct !== undefined) {
|
|
1590
|
+
const problem = distinctConfigProblem(meta, shape.distinct)
|
|
1591
|
+
if (problem) return problem
|
|
1592
|
+
}
|
|
1593
|
+
if (shape.cursor !== undefined) {
|
|
1594
|
+
const problem = cursorConfigProblem(meta, shape.cursor)
|
|
1595
|
+
if (problem) return problem
|
|
1596
|
+
}
|
|
1597
|
+
for (const key of ['select', 'include'] as const) {
|
|
1598
|
+
if (shape[key] === undefined) continue
|
|
1599
|
+
if (operation === 'count' && key === 'select') {
|
|
1600
|
+
// count select is a COUNT-FIELD selection: `_all` plus scalars —
|
|
1601
|
+
// no relations (guard: verified). The generic projection validator
|
|
1602
|
+
// would reject the legal `_all` key.
|
|
1603
|
+
const problem = countFieldSelectConfigProblem(meta, shape.select)
|
|
1604
|
+
if (problem) return problem
|
|
1605
|
+
continue
|
|
1606
|
+
}
|
|
1607
|
+
const problem = projectionConfigProblem(meta, shape[key], key)
|
|
1608
|
+
if (problem) return problem
|
|
1609
|
+
}
|
|
1610
|
+
if (shape.take !== undefined) {
|
|
1611
|
+
const problem = takeConfigProblem(shape.take)
|
|
1612
|
+
if (problem) return problem
|
|
1613
|
+
}
|
|
1614
|
+
if (shape.skip !== undefined && shape.skip !== true) {
|
|
1615
|
+
return 'skip config must be true'
|
|
1616
|
+
}
|
|
1617
|
+
return null
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// ---------------------------------------------------------------------------
|
|
1621
|
+
// Write operations (create / update / upsert / delete). The config validators
|
|
1622
|
+
// mirror prisma-guard 1.33 buildDataSchema + the unique-where mutation path
|
|
1623
|
+
// (source-read and runtime-verified): per-operation allowed shape keys, data
|
|
1624
|
+
// configs of true / literal / force() (never relation writes or inline
|
|
1625
|
+
// refines for MCP), and unique-selector wheres for update/upsert/delete.
|
|
1626
|
+
// ---------------------------------------------------------------------------
|
|
1627
|
+
|
|
1628
|
+
const WRITE_OP_SHAPE_KEYS: Record<string, readonly string[]> = {
|
|
1629
|
+
create: ['data', 'select', 'include'],
|
|
1630
|
+
createMany: ['data'],
|
|
1631
|
+
createManyAndReturn: ['data', 'select', 'include'],
|
|
1632
|
+
update: ['where', 'data', 'select', 'include'],
|
|
1633
|
+
updateMany: ['where', 'data'],
|
|
1634
|
+
updateManyAndReturn: ['where', 'data', 'select', 'include'],
|
|
1635
|
+
upsert: ['where', 'create', 'update', 'select', 'include'],
|
|
1636
|
+
delete: ['where', 'select', 'include'],
|
|
1637
|
+
deleteMany: ['where'],
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/** Write ops whose `where` is a unique selector (guard UNIQUE_MUTATION_METHODS). */
|
|
1641
|
+
const UNIQUE_WHERE_WRITE_OPS = new Set(['update', 'upsert', 'delete'])
|
|
1642
|
+
/** Write ops whose `where` is a filter (guard BULK_MUTATION_METHODS). */
|
|
1643
|
+
const FILTER_WHERE_WRITE_OPS = new Set([
|
|
1644
|
+
'updateMany',
|
|
1645
|
+
'updateManyAndReturn',
|
|
1646
|
+
'deleteMany',
|
|
1647
|
+
])
|
|
1648
|
+
/** Write ops carrying `data` (create-mode or update-mode). */
|
|
1649
|
+
const CREATE_DATA_WRITE_OPS = new Set([
|
|
1650
|
+
'create',
|
|
1651
|
+
'createMany',
|
|
1652
|
+
'createManyAndReturn',
|
|
1653
|
+
])
|
|
1654
|
+
const UPDATE_DATA_WRITE_OPS = new Set([
|
|
1655
|
+
'update',
|
|
1656
|
+
'updateMany',
|
|
1657
|
+
'updateManyAndReturn',
|
|
1658
|
+
])
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* Whether a forced (literal or force()-wrapped) DATA value parses under
|
|
1662
|
+
* guard's data field schema (source-read: buildFieldSchema = base type +
|
|
1663
|
+
* wrapWithInputCoercion, .nullable() on optional fields). Beyond the
|
|
1664
|
+
* where-side forms that means: DateTime accepts Date instances, BigInt
|
|
1665
|
+
* accepts bigint, Bytes accepts Uint8Array, String/Int/Float accept their
|
|
1666
|
+
* coerced forms, Json accepts any JSON value (plain objects included).
|
|
1667
|
+
*/
|
|
1668
|
+
function forcedDataValueMatches(
|
|
1669
|
+
meta: SchemaModelMeta,
|
|
1670
|
+
field: SchemaFieldMeta,
|
|
1671
|
+
value: unknown,
|
|
1672
|
+
): boolean {
|
|
1673
|
+
const v = isForcedShapeValue(value)
|
|
1674
|
+
? (value as { value: unknown }).value
|
|
1675
|
+
: value
|
|
1676
|
+
if (v === undefined) return false
|
|
1677
|
+
if (v === null) return field.type === 'Json' || !field.isRequired
|
|
1678
|
+
const itemOk = (x: unknown): boolean => {
|
|
1679
|
+
if (field.kind === 'enum') return enumMember(meta, field, x)
|
|
1680
|
+
switch (field.type) {
|
|
1681
|
+
case 'DateTime':
|
|
1682
|
+
return (
|
|
1683
|
+
(x instanceof Date && !isNaN(x.getTime())) ||
|
|
1684
|
+
(typeof x === 'string' && !isNaN(Date.parse(x)))
|
|
1685
|
+
)
|
|
1686
|
+
case 'BigInt':
|
|
1687
|
+
return typeof x === 'bigint' || itemValueMatches('BigInt', x)
|
|
1688
|
+
case 'Bytes':
|
|
1689
|
+
return typeof x === 'string' || x instanceof Uint8Array
|
|
1690
|
+
case 'Float':
|
|
1691
|
+
// guard Float base is z.number().finite()
|
|
1692
|
+
return (
|
|
1693
|
+
coercedScalarMatches(field, x) &&
|
|
1694
|
+
(typeof x !== 'number' || Number.isFinite(x))
|
|
1695
|
+
)
|
|
1696
|
+
case 'String':
|
|
1697
|
+
case 'Int':
|
|
1698
|
+
return coercedScalarMatches(field, x)
|
|
1699
|
+
case 'Json':
|
|
1700
|
+
return true
|
|
1701
|
+
default:
|
|
1702
|
+
return itemValueMatches(field.type, x)
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
if (field.isList) return Array.isArray(v) && v.every(itemOk)
|
|
1706
|
+
return itemOk(v)
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* Validates one write data config (create / update / upsert's create+update)
|
|
1711
|
+
* the way guard's buildDataSchema compiles it. MCP refuses — at registration,
|
|
1712
|
+
* before any tool exists — the two config forms it cannot mirror in a static
|
|
1713
|
+
* JSON Schema: relation writes and inline refine functions. Everything else
|
|
1714
|
+
* must be guard-valid: known fields, true, a typed literal or force().
|
|
1715
|
+
* Returns null when valid, else the problem description.
|
|
1716
|
+
*/
|
|
1717
|
+
export function dataConfigProblem(
|
|
1718
|
+
meta: SchemaModelMeta,
|
|
1719
|
+
dataConfig: unknown,
|
|
1720
|
+
mode: 'create' | 'update',
|
|
1721
|
+
): string | null {
|
|
1722
|
+
if (!isShapeObject(dataConfig)) {
|
|
1723
|
+
return `${mode} data must be an object of field configs`
|
|
1724
|
+
}
|
|
1725
|
+
for (const [name, val] of Object.entries(dataConfig)) {
|
|
1726
|
+
const field = fieldByName(meta, name)
|
|
1727
|
+
if (!field) {
|
|
1728
|
+
return `unknown field "${name}" in ${mode} data`
|
|
1729
|
+
}
|
|
1730
|
+
if (field.isUpdatedAt) {
|
|
1731
|
+
return `updatedAt field "${name}" cannot be configured in ${mode} data`
|
|
1732
|
+
}
|
|
1733
|
+
if (field.kind === 'object') {
|
|
1734
|
+
return `relation field "${name}" cannot be exposed as MCP ${mode} data — keep relation writes REST-only`
|
|
1735
|
+
}
|
|
1736
|
+
if (field.kind === 'unsupported') {
|
|
1737
|
+
if (val === true || typeof val === 'function') {
|
|
1738
|
+
return `unsupported field "${name}" cannot be client-controlled`
|
|
1739
|
+
}
|
|
1740
|
+
continue
|
|
1741
|
+
}
|
|
1742
|
+
if (typeof val === 'function') {
|
|
1743
|
+
return `inline refine on "${name}" cannot be mirrored in an MCP ${mode} schema`
|
|
1744
|
+
}
|
|
1745
|
+
if (val === true) continue
|
|
1746
|
+
// a plain object is a forced JSON literal on Json fields; anywhere else
|
|
1747
|
+
// it is an operator/relation-style config guard cannot parse as data
|
|
1748
|
+
if (
|
|
1749
|
+
field.type !== 'Json' &&
|
|
1750
|
+
isShapeObject(val) &&
|
|
1751
|
+
!isForcedShapeValue(val)
|
|
1752
|
+
) {
|
|
1753
|
+
return `"${name}" in ${mode} data accepts true, a literal or force(), not an object`
|
|
1754
|
+
}
|
|
1755
|
+
if (!forcedDataValueMatches(meta, field, val)) {
|
|
1756
|
+
return `forced value for "${name}" does not match the field type`
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
return null
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Validates the COMPLETE static shape of one write operation the way
|
|
1764
|
+
* prisma-guard 1.33 compiles mutations: per-operation allowed keys, required
|
|
1765
|
+
* keys (data for create/update families, where+create+update for upsert,
|
|
1766
|
+
* where for every where-carrying op — guard refuses unique mutations without
|
|
1767
|
+
* a unique where and bulk mutations without a where), unique-selector wheres
|
|
1768
|
+
* for update/upsert/delete, filter wheres for the bulk ops, data configs,
|
|
1769
|
+
* and the shared projection rules. Registration refuses a shape with any
|
|
1770
|
+
* problem. Only STATIC shapes reach here: MCP refuses dynamic write shapes
|
|
1771
|
+
* at tool creation. Returns null when valid, else the problem description.
|
|
1772
|
+
*/
|
|
1773
|
+
export function writeShapeConfigProblem(
|
|
1774
|
+
meta: SchemaModelMeta,
|
|
1775
|
+
operation: string,
|
|
1776
|
+
shape: unknown,
|
|
1777
|
+
): string | null {
|
|
1778
|
+
if (!isShapeObject(shape)) {
|
|
1779
|
+
return `${operation} shape must be a static object`
|
|
1780
|
+
}
|
|
1781
|
+
const allowedKeys = WRITE_OP_SHAPE_KEYS[operation]
|
|
1782
|
+
if (!allowedKeys) return `${operation} is not an MCP write operation`
|
|
1783
|
+
for (const key of Object.keys(shape)) {
|
|
1784
|
+
if (!allowedKeys.includes(key)) {
|
|
1785
|
+
return `"${key}" is not a valid shape config key for ${operation}`
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
const requiredKeys =
|
|
1789
|
+
operation === 'upsert'
|
|
1790
|
+
? ['where', 'create', 'update']
|
|
1791
|
+
: allowedKeys.filter((k) => k === 'where' || k === 'data')
|
|
1792
|
+
for (const key of requiredKeys) {
|
|
1793
|
+
if (shape[key] === undefined) {
|
|
1794
|
+
return `${operation} shape must define "${key}"`
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
if (UNIQUE_WHERE_WRITE_OPS.has(operation)) {
|
|
1798
|
+
const problem = findUniqueWhereConfigProblem(meta, shape.where)
|
|
1799
|
+
if (problem) return problem
|
|
1800
|
+
}
|
|
1801
|
+
if (FILTER_WHERE_WRITE_OPS.has(operation)) {
|
|
1802
|
+
const problem = filterWhereConfigProblem(meta, shape.where)
|
|
1803
|
+
if (problem) return problem
|
|
1804
|
+
}
|
|
1805
|
+
if (CREATE_DATA_WRITE_OPS.has(operation)) {
|
|
1806
|
+
const problem = dataConfigProblem(meta, shape.data, 'create')
|
|
1807
|
+
if (problem) return problem
|
|
1808
|
+
}
|
|
1809
|
+
if (UPDATE_DATA_WRITE_OPS.has(operation)) {
|
|
1810
|
+
const problem = dataConfigProblem(meta, shape.data, 'update')
|
|
1811
|
+
if (problem) return problem
|
|
1812
|
+
}
|
|
1813
|
+
if (operation === 'upsert') {
|
|
1814
|
+
const createProblem = dataConfigProblem(meta, shape.create, 'create')
|
|
1815
|
+
if (createProblem) return createProblem
|
|
1816
|
+
const updateProblem = dataConfigProblem(meta, shape.update, 'update')
|
|
1817
|
+
if (updateProblem) return updateProblem
|
|
1818
|
+
}
|
|
1819
|
+
for (const key of ['select', 'include'] as const) {
|
|
1820
|
+
if (shape[key] === undefined) continue
|
|
1821
|
+
const problem = projectionConfigProblem(meta, shape[key], key)
|
|
1822
|
+
if (problem) return problem
|
|
1823
|
+
}
|
|
1824
|
+
return null
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** Guard take config: positive finite integer, or { max, default? }. */
|
|
1828
|
+
function takeConfigProblem(take: unknown): string | null {
|
|
1829
|
+
const okNumber =
|
|
1830
|
+
typeof take === 'number' &&
|
|
1831
|
+
Number.isInteger(take) &&
|
|
1832
|
+
Number.isFinite(take) &&
|
|
1833
|
+
take > 0
|
|
1834
|
+
if (okNumber) return null
|
|
1835
|
+
if (isShapeObject(take)) {
|
|
1836
|
+
const max = take.max
|
|
1837
|
+
const def = take.default
|
|
1838
|
+
if (
|
|
1839
|
+
typeof max === 'number' &&
|
|
1840
|
+
Number.isInteger(max) &&
|
|
1841
|
+
Number.isFinite(max) &&
|
|
1842
|
+
max > 0
|
|
1843
|
+
) {
|
|
1844
|
+
if (def === undefined) return null
|
|
1845
|
+
const okDefault =
|
|
1846
|
+
typeof def === 'number' &&
|
|
1847
|
+
Number.isInteger(def) &&
|
|
1848
|
+
Number.isFinite(def) &&
|
|
1849
|
+
def > 0 &&
|
|
1850
|
+
def <= max
|
|
1851
|
+
return okDefault
|
|
1852
|
+
? null
|
|
1853
|
+
: 'take.default must be a positive integer <= take.max'
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
return 'take must be a positive integer or { max, default? }'
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/** Count-field selection: `_all` plus scalar/enum fields, each `true`. */
|
|
1860
|
+
function countFieldSelectConfigProblem(
|
|
1861
|
+
meta: SchemaModelMeta,
|
|
1862
|
+
cfg: unknown,
|
|
1863
|
+
): string | null {
|
|
1864
|
+
if (!isShapeObject(cfg) || Object.keys(cfg).length === 0) {
|
|
1865
|
+
return 'count select must be a non-empty object'
|
|
1866
|
+
}
|
|
1867
|
+
for (const [key, val] of Object.entries(cfg)) {
|
|
1868
|
+
if (key === '_all') {
|
|
1869
|
+
if (val !== true) return 'count select "_all" must be true'
|
|
1870
|
+
continue
|
|
1871
|
+
}
|
|
1872
|
+
const field = meta.fields.find((f) => f.name === key)
|
|
1873
|
+
if (!field) return `unknown field "${key}" in count select`
|
|
1874
|
+
if (field.kind !== 'scalar' && field.kind !== 'enum') {
|
|
1875
|
+
return `count select field "${key}" must be a scalar`
|
|
1876
|
+
}
|
|
1877
|
+
if (val !== true) return `count select config for "${key}" must be true`
|
|
1878
|
+
}
|
|
1879
|
+
return null
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function orderByConfigProblem(
|
|
1883
|
+
meta: SchemaModelMeta,
|
|
1884
|
+
cfg: unknown,
|
|
1885
|
+
): string | null {
|
|
1886
|
+
if (!isShapeObject(cfg) || Object.keys(cfg).length === 0) {
|
|
1887
|
+
return 'orderBy config must be a non-empty object of fields'
|
|
1888
|
+
}
|
|
1889
|
+
for (const [key, val] of Object.entries(cfg)) {
|
|
1890
|
+
const field = meta.fields.find((f) => f.name === key)
|
|
1891
|
+
if (!field) return `unknown field "${key}" in orderBy`
|
|
1892
|
+
if (field.kind === 'object') {
|
|
1893
|
+
if (!isShapeObject(val) || Object.keys(val).length === 0) {
|
|
1894
|
+
return `orderBy relation "${key}" must be configured with a non-empty object`
|
|
1895
|
+
}
|
|
1896
|
+
const relatedMeta = meta.modelIndex.get(field.type)
|
|
1897
|
+
if (!relatedMeta) continue
|
|
1898
|
+
if (field.isList) {
|
|
1899
|
+
for (const k of Object.keys(val)) {
|
|
1900
|
+
if (k !== '_count') {
|
|
1901
|
+
return `to-many relation orderBy "${key}" only supports _count, not "${k}"`
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
if (val._count !== true) {
|
|
1905
|
+
return `orderBy relation "${key}" _count must be exactly true`
|
|
1906
|
+
}
|
|
1907
|
+
continue
|
|
1908
|
+
}
|
|
1909
|
+
// to-one: validate the ENTIRE nested tree (finite literal, no cap)
|
|
1910
|
+
const nested = orderByConfigProblem(relatedMeta, val)
|
|
1911
|
+
if (nested) return nested
|
|
1912
|
+
continue
|
|
1913
|
+
}
|
|
1914
|
+
if (!isSortableField(field)) {
|
|
1915
|
+
return `${field.isList ? 'list' : 'Json'} field "${key}" cannot be used in orderBy`
|
|
1916
|
+
}
|
|
1917
|
+
if (val !== true) {
|
|
1918
|
+
return `orderBy config for "${key}" must be true`
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
return null
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function distinctConfigProblem(
|
|
1925
|
+
meta: SchemaModelMeta,
|
|
1926
|
+
cfg: unknown,
|
|
1927
|
+
): string | null {
|
|
1928
|
+
if (!Array.isArray(cfg) || cfg.length === 0) {
|
|
1929
|
+
return 'distinct config must be a non-empty array of scalar field names'
|
|
1930
|
+
}
|
|
1931
|
+
for (const name of cfg) {
|
|
1932
|
+
if (typeof name !== 'string') {
|
|
1933
|
+
return 'distinct config must be a non-empty array of scalar field names'
|
|
1934
|
+
}
|
|
1935
|
+
const field = meta.fields.find((f) => f.name === name)
|
|
1936
|
+
if (!field) return `unknown field "${name}" in distinct`
|
|
1937
|
+
if (field.kind !== 'scalar' && field.kind !== 'enum') {
|
|
1938
|
+
return `distinct field "${name}" must be a scalar`
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
return null
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function cursorConfigProblem(
|
|
1945
|
+
meta: SchemaModelMeta,
|
|
1946
|
+
cfg: unknown,
|
|
1947
|
+
): string | null {
|
|
1948
|
+
if (!isShapeObject(cfg) || Object.keys(cfg).length === 0) {
|
|
1949
|
+
return 'cursor config must be a non-empty object of unique selectors'
|
|
1950
|
+
}
|
|
1951
|
+
for (const [key, val] of Object.entries(cfg)) {
|
|
1952
|
+
const constraint = meta.compoundUniques.find((c) => c.selector === key)
|
|
1953
|
+
if (constraint) {
|
|
1954
|
+
// guard requires the EXACT compound key set with literal `true` —
|
|
1955
|
+
// extra fields and force() wrappers are both refused
|
|
1956
|
+
if (!isShapeObject(val) || isForcedShapeValue(val)) {
|
|
1957
|
+
return `cursor compound selector "${key}" must be configured with an object`
|
|
1958
|
+
}
|
|
1959
|
+
const keys = Object.keys(val)
|
|
1960
|
+
if (keys.length !== constraint.fields.length) {
|
|
1961
|
+
return `cursor compound selector "${key}" must configure exactly ${constraint.fields.join(', ')}`
|
|
1962
|
+
}
|
|
1963
|
+
for (const fk of constraint.fields) {
|
|
1964
|
+
if (val[fk] !== true) {
|
|
1965
|
+
return `cursor compound selector "${key}" requires every field configured as literal true`
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
continue
|
|
1969
|
+
}
|
|
1970
|
+
const flatUnique =
|
|
1971
|
+
meta.uniqueFields.includes(key) &&
|
|
1972
|
+
meta.fields.some((f) => f.name === key && (f.isId || f.isUnique))
|
|
1973
|
+
if (!flatUnique) {
|
|
1974
|
+
return `cursor key "${key}" is not a unique field or compound selector of ${meta.name}`
|
|
1975
|
+
}
|
|
1976
|
+
if (val !== true) {
|
|
1977
|
+
return `cursor config for "${key}" must be true`
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
return null
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/**
|
|
1984
|
+
* Nested list-relation argument keys guard accepts (verified runtime,
|
|
1985
|
+
* guard's KNOWN_NESTED_KEYS): where/orderBy/take/skip/cursor on list
|
|
1986
|
+
* relations; to-one relations accept only select/include projections.
|
|
1987
|
+
*/
|
|
1988
|
+
const NESTED_LIST_KEYS = new Set([
|
|
1989
|
+
'where',
|
|
1990
|
+
'orderBy',
|
|
1991
|
+
'take',
|
|
1992
|
+
'skip',
|
|
1993
|
+
'cursor',
|
|
1994
|
+
'select',
|
|
1995
|
+
'include',
|
|
1996
|
+
])
|
|
1997
|
+
|
|
1998
|
+
function projectionConfigProblem(
|
|
1999
|
+
meta: SchemaModelMeta,
|
|
2000
|
+
cfg: unknown,
|
|
2001
|
+
mode: 'select' | 'include',
|
|
2002
|
+
): string | null {
|
|
2003
|
+
if (!isShapeObject(cfg) || Object.keys(cfg).length === 0) {
|
|
2004
|
+
return `${mode} config must be a non-empty object`
|
|
2005
|
+
}
|
|
2006
|
+
for (const [key, val] of Object.entries(cfg)) {
|
|
2007
|
+
if (key === '_count') {
|
|
2008
|
+
const problem = countSelectConfigProblem(meta, val)
|
|
2009
|
+
if (problem) return problem
|
|
2010
|
+
continue
|
|
2011
|
+
}
|
|
2012
|
+
const field = meta.fields.find((f) => f.name === key)
|
|
2013
|
+
if (!field) return `unknown field "${key}" in ${mode}`
|
|
2014
|
+
if (field.kind !== 'object') {
|
|
2015
|
+
if (mode === 'include') {
|
|
2016
|
+
return `"${key}" is not a relation and cannot appear in include`
|
|
2017
|
+
}
|
|
2018
|
+
if (val !== true) return `${mode} config for "${key}" must be true`
|
|
2019
|
+
continue
|
|
2020
|
+
}
|
|
2021
|
+
if (val === true) continue
|
|
2022
|
+
if (!isShapeObject(val) || isForcedShapeValue(val)) {
|
|
2023
|
+
return `${mode} relation "${key}" must be true or an object`
|
|
2024
|
+
}
|
|
2025
|
+
const relatedMeta = meta.modelIndex.get(field.type)
|
|
2026
|
+
if (!relatedMeta) continue
|
|
2027
|
+
if (!field.isList) {
|
|
2028
|
+
// to-one: only nested select/include projections (non-empty)
|
|
2029
|
+
for (const k of Object.keys(val)) {
|
|
2030
|
+
if (k !== 'select' && k !== 'include') {
|
|
2031
|
+
return `to-one relation "${key}" accepts only select/include, not "${k}"`
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
if (val.select !== undefined && val.include !== undefined) {
|
|
2035
|
+
return `nested select for "${key}" cannot define both "select" and "include"`
|
|
2036
|
+
}
|
|
2037
|
+
for (const k of ['select', 'include'] as const) {
|
|
2038
|
+
if (val[k] === undefined) continue
|
|
2039
|
+
if (!isShapeObject(val[k]) || Object.keys(val[k]).length === 0) {
|
|
2040
|
+
return `${k} config for "${key}" must be a non-empty object`
|
|
2041
|
+
}
|
|
2042
|
+
const nested = projectionConfigProblem(relatedMeta, val[k], k)
|
|
2043
|
+
if (nested) return nested
|
|
2044
|
+
}
|
|
2045
|
+
continue
|
|
2046
|
+
}
|
|
2047
|
+
for (const k of Object.keys(val)) {
|
|
2048
|
+
if (!NESTED_LIST_KEYS.has(k)) {
|
|
2049
|
+
return `nested list argument "${k}" is not allowed for "${key}"`
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
if (val.select !== undefined && val.include !== undefined) {
|
|
2053
|
+
return `nested select for "${key}" cannot define both "select" and "include"`
|
|
2054
|
+
}
|
|
2055
|
+
if (val.where !== undefined) {
|
|
2056
|
+
const nested = filterWhereConfigProblem(relatedMeta, val.where)
|
|
2057
|
+
if (nested) return nested
|
|
2058
|
+
}
|
|
2059
|
+
if (val.orderBy !== undefined) {
|
|
2060
|
+
const nested = orderByConfigProblem(relatedMeta, val.orderBy)
|
|
2061
|
+
if (nested) return nested
|
|
2062
|
+
}
|
|
2063
|
+
if (val.cursor !== undefined) {
|
|
2064
|
+
const nested = cursorConfigProblem(relatedMeta, val.cursor)
|
|
2065
|
+
if (nested) return nested
|
|
2066
|
+
}
|
|
2067
|
+
if (val.take !== undefined) {
|
|
2068
|
+
const nested = takeConfigProblem(val.take)
|
|
2069
|
+
if (nested) return `nested take for "${key}": ${nested}`
|
|
2070
|
+
}
|
|
2071
|
+
if (val.skip !== undefined && val.skip !== true) {
|
|
2072
|
+
return `nested skip for "${key}" must be true`
|
|
2073
|
+
}
|
|
2074
|
+
for (const k of ['select', 'include'] as const) {
|
|
2075
|
+
if (val[k] === undefined) continue
|
|
2076
|
+
if (!isShapeObject(val[k]) || Object.keys(val[k]).length === 0) {
|
|
2077
|
+
return `nested ${k} for "${key}" must be a non-empty object`
|
|
2078
|
+
}
|
|
2079
|
+
const nested = projectionConfigProblem(relatedMeta, val[k], k)
|
|
2080
|
+
if (nested) return nested
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
return null
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
function countSelectConfigProblem(
|
|
2087
|
+
meta: SchemaModelMeta,
|
|
2088
|
+
val: unknown,
|
|
2089
|
+
): string | null {
|
|
2090
|
+
if (val === true) return null
|
|
2091
|
+
// guard: "Unknown key X in _count config" — ONLY `select` may appear
|
|
2092
|
+
if (!isShapeObject(val) || isForcedShapeValue(val)) {
|
|
2093
|
+
return '_count config must be true or { select: { relation: ... } }'
|
|
2094
|
+
}
|
|
2095
|
+
const keys = Object.keys(val)
|
|
2096
|
+
if (keys.length !== 1 || keys[0] !== 'select') {
|
|
2097
|
+
return '_count config accepts only the "select" key'
|
|
2098
|
+
}
|
|
2099
|
+
const sel = val.select
|
|
2100
|
+
if (!isShapeObject(sel) || Object.keys(sel).length === 0) {
|
|
2101
|
+
return '_count config must be true or { select: { relation: ... } }'
|
|
2102
|
+
}
|
|
2103
|
+
for (const [rk, rv] of Object.entries(sel)) {
|
|
2104
|
+
const rel = meta.fields.find((f) => f.name === rk)
|
|
2105
|
+
if (!rel || rel.kind !== 'object') {
|
|
2106
|
+
return `unknown relation "${rk}" in _count.select`
|
|
2107
|
+
}
|
|
2108
|
+
if (!rel.isList) {
|
|
2109
|
+
return `"${rk}" is a to-one relation and cannot be counted in _count.select`
|
|
2110
|
+
}
|
|
2111
|
+
if (rv === true) continue
|
|
2112
|
+
const relatedMeta = meta.modelIndex.get(rel.type)
|
|
2113
|
+
if (
|
|
2114
|
+
!isShapeObject(rv) ||
|
|
2115
|
+
Object.keys(rv).length !== 1 ||
|
|
2116
|
+
rv.where === undefined ||
|
|
2117
|
+
!relatedMeta
|
|
2118
|
+
) {
|
|
2119
|
+
return `_count.select entry "${rk}" accepts true or { where: ... }`
|
|
2120
|
+
}
|
|
2121
|
+
const nested = filterWhereConfigProblem(relatedMeta, rv.where)
|
|
2122
|
+
if (nested) return nested
|
|
2123
|
+
}
|
|
2124
|
+
return null
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* Unique-selector where schema for findUnique (prisma-guard 1.33 semantics,
|
|
2129
|
+
* verified against the real runtime). ALL advertised selector keys sit on
|
|
2130
|
+
* the parent object (guard accepts several simultaneously); `anyOf` only
|
|
2131
|
+
* chooses which key is REQUIRED when more than one is advertised.
|
|
2132
|
+
*
|
|
2133
|
+
* - a flat unique field is advertised when the shape configures it with
|
|
2134
|
+
* exactly `true`; literal / force() configs are server-owned (guard
|
|
2135
|
+
* merges them) and never advertised.
|
|
2136
|
+
* - a compound selector is advertised when the shape configures its key
|
|
2137
|
+
* with an object; only `true`-configured constraint fields are
|
|
2138
|
+
* advertised (and required inside the selector).
|
|
2139
|
+
* - guard-invalid configs advertise nothing; registration refuses them
|
|
2140
|
+
* (see findUniqueWhereConfigProblem).
|
|
2141
|
+
*/
|
|
2142
|
+
export function uniqueSelectorWhereSchema(
|
|
2143
|
+
meta: SchemaModelMeta,
|
|
2144
|
+
shapeWhere: unknown,
|
|
2145
|
+
): OperationSchemaObject {
|
|
2146
|
+
const shapeObj = isShapeObject(shapeWhere) ? shapeWhere : undefined
|
|
2147
|
+
const shapeKeys = shapeObj ? new Set(Object.keys(shapeObj)) : undefined
|
|
2148
|
+
|
|
2149
|
+
const properties: Record<string, OperationSchemaObject> = {}
|
|
2150
|
+
const requiredKeys: string[] = []
|
|
2151
|
+
|
|
2152
|
+
if (shapeObj && shapeKeys) {
|
|
2153
|
+
for (const f of meta.fields) {
|
|
2154
|
+
if (!(f.isId || f.isUnique) || !meta.uniqueFields.includes(f.name)) {
|
|
2155
|
+
continue
|
|
2156
|
+
}
|
|
2157
|
+
if (!shapeKeys.has(f.name) || shapeObj[f.name] !== true) continue
|
|
2158
|
+
properties[f.name] = scalarBaseSchema(meta, f)
|
|
2159
|
+
requiredKeys.push(f.name)
|
|
2160
|
+
}
|
|
2161
|
+
for (const cu of meta.compoundUniques) {
|
|
2162
|
+
if (!shapeKeys.has(cu.selector)) continue
|
|
2163
|
+
const inner = shapeObj[cu.selector]
|
|
2164
|
+
if (!isShapeObject(inner) || isForcedShapeValue(inner)) continue
|
|
2165
|
+
const clientFields = cu.fields.filter((f) => inner[f] === true)
|
|
2166
|
+
if (clientFields.length === 0) continue // fully forced — server-owned
|
|
2167
|
+
properties[cu.selector] = {
|
|
2168
|
+
type: 'object',
|
|
2169
|
+
properties: Object.fromEntries(
|
|
2170
|
+
clientFields.map((f) => {
|
|
2171
|
+
const fm = meta.fields.find((x) => x.name === f)
|
|
2172
|
+
return [f, fm ? scalarBaseSchema(meta, fm) : { type: 'string' }]
|
|
2173
|
+
}),
|
|
2174
|
+
),
|
|
2175
|
+
required: [...clientFields],
|
|
2176
|
+
additionalProperties: false,
|
|
2177
|
+
}
|
|
2178
|
+
requiredKeys.push(cu.selector)
|
|
2179
|
+
}
|
|
2180
|
+
// extended unique filters: client-controlled NON-unique scalars beside
|
|
2181
|
+
// the selector — optional, nullable on optional fields (guard parses
|
|
2182
|
+
// them with .nullable() when the field is optional). Only when the
|
|
2183
|
+
// config covers a unique constraint: without one the config is
|
|
2184
|
+
// guard-invalid and nothing beyond the selectors is advertised.
|
|
2185
|
+
const coversConstraint = [...shapeKeys].some(
|
|
2186
|
+
(k) =>
|
|
2187
|
+
meta.compoundUniques.some((c) => c.selector === k) ||
|
|
2188
|
+
(meta.uniqueFields.includes(k) &&
|
|
2189
|
+
meta.fields.some((f) => f.name === k && (f.isId || f.isUnique))),
|
|
2190
|
+
)
|
|
2191
|
+
for (const f of coversConstraint ? meta.fields : []) {
|
|
2192
|
+
if (f.kind !== 'scalar' && f.kind !== 'enum') continue
|
|
2193
|
+
if (properties[f.name] !== undefined) continue
|
|
2194
|
+
if (meta.compoundUniques.some((c) => c.selector === f.name)) continue
|
|
2195
|
+
if (shapeObj[f.name] !== true) continue
|
|
2196
|
+
const base = scalarBaseSchema(meta, f)
|
|
2197
|
+
properties[f.name] = f.isRequired ? base : nullableUnion(base)
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
const node: OperationSchemaObject = {
|
|
2202
|
+
type: 'object',
|
|
2203
|
+
properties,
|
|
2204
|
+
additionalProperties: false,
|
|
2205
|
+
}
|
|
2206
|
+
if (requiredKeys.length === 0) {
|
|
2207
|
+
// fully forced (or guard-invalid): client sends nothing for where;
|
|
2208
|
+
// guard enforces the selector server-side or errors on the config
|
|
2209
|
+
return node
|
|
2210
|
+
}
|
|
2211
|
+
if (requiredKeys.length === 1) {
|
|
2212
|
+
node.required = [requiredKeys[0]]
|
|
2213
|
+
return node
|
|
2214
|
+
}
|
|
2215
|
+
// alternatives choose the required key only — the parent already
|
|
2216
|
+
// constrains the properties, and guard accepts several selector keys
|
|
2217
|
+
// in one input
|
|
2218
|
+
node.anyOf = requiredKeys.map((key) => ({ required: [key] }))
|
|
2219
|
+
return node
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* Unique-cursor schema shared by root list args and nested relation list
|
|
2224
|
+
* args: flat unique fields configured with exactly `true`, plus compound
|
|
2225
|
+
* selectors whose inner object maps EVERY constraint field to `true`
|
|
2226
|
+
* (any other cursor config is guard-invalid and advertises nothing).
|
|
2227
|
+
* Guard accepts several cursor keys in one input; at least one is required.
|
|
2228
|
+
*/
|
|
2229
|
+
export function uniqueCursorSchema(
|
|
2230
|
+
meta: SchemaModelMeta,
|
|
2231
|
+
cursorCfg: unknown,
|
|
2232
|
+
): OperationSchemaObject {
|
|
2233
|
+
const cfg =
|
|
2234
|
+
isShapeObject(cursorCfg) && !isForcedShapeValue(cursorCfg)
|
|
2235
|
+
? cursorCfg
|
|
2236
|
+
: undefined
|
|
2237
|
+
const properties: Record<string, OperationSchemaObject> = {}
|
|
2238
|
+
if (cfg) {
|
|
2239
|
+
for (const f of meta.fields) {
|
|
2240
|
+
if (!(f.isId || f.isUnique) || !meta.uniqueFields.includes(f.name)) {
|
|
2241
|
+
continue
|
|
2242
|
+
}
|
|
2243
|
+
if (cfg[f.name] !== true) continue
|
|
2244
|
+
properties[f.name] = scalarBaseSchema(meta, f)
|
|
2245
|
+
}
|
|
2246
|
+
for (const cu of meta.compoundUniques) {
|
|
2247
|
+
const inner = cfg[cu.selector]
|
|
2248
|
+
if (!isShapeObject(inner) || isForcedShapeValue(inner)) continue
|
|
2249
|
+
if (!cu.fields.every((f) => inner[f] === true)) continue
|
|
2250
|
+
properties[cu.selector] = {
|
|
2251
|
+
type: 'object',
|
|
2252
|
+
properties: Object.fromEntries(
|
|
2253
|
+
cu.fields.map((f) => {
|
|
2254
|
+
const fm = meta.fields.find((x) => x.name === f)
|
|
2255
|
+
return [f, fm ? scalarBaseSchema(meta, fm) : { type: 'string' }]
|
|
2256
|
+
}),
|
|
2257
|
+
),
|
|
2258
|
+
required: [...cu.fields],
|
|
2259
|
+
additionalProperties: false,
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
return {
|
|
2264
|
+
type: 'object',
|
|
2265
|
+
properties,
|
|
2266
|
+
additionalProperties: false,
|
|
2267
|
+
minProperties: 1,
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
function isShapeObject(value: unknown): value is Record<string, unknown> {
|
|
2272
|
+
return (
|
|
2273
|
+
typeof value === 'object' &&
|
|
2274
|
+
value !== null &&
|
|
2275
|
+
!Array.isArray(value) &&
|
|
2276
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
2277
|
+
)
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
/**
|
|
2281
|
+
* Builds one relation entry for include/projection trees: `true` or an
|
|
2282
|
+
* object whose keys mirror EXACTLY what the nested shape configures, with
|
|
2283
|
+
* the configured take bound preserved.
|
|
2284
|
+
*/
|
|
2285
|
+
function relationEntrySchema(
|
|
2286
|
+
meta: SchemaModelMeta,
|
|
2287
|
+
field: SchemaFieldMeta,
|
|
2288
|
+
nestedShape: unknown,
|
|
2289
|
+
relatedMeta: SchemaModelMeta | undefined,
|
|
2290
|
+
depth: number,
|
|
2291
|
+
mode: 'select' | 'include',
|
|
2292
|
+
): OperationSchemaObject {
|
|
2293
|
+
const wrapperProps: Record<string, OperationSchemaObject> = {}
|
|
2294
|
+
|
|
2295
|
+
// nested projections, only when the nested shape configures that key —
|
|
2296
|
+
// in BOTH modes (a select-mode shape may configure include and vice versa)
|
|
2297
|
+
const nestedSelectConfigured =
|
|
2298
|
+
isShapeObject(nestedShape) && nestedShape.select !== undefined
|
|
2299
|
+
const nestedIncludeConfigured =
|
|
2300
|
+
isShapeObject(nestedShape) && nestedShape.include !== undefined
|
|
2301
|
+
if (nestedSelectConfigured && relatedMeta && depth < MAX_PROJECTION_DEPTH) {
|
|
2302
|
+
wrapperProps.select = projectionSchema(
|
|
2303
|
+
relatedMeta,
|
|
2304
|
+
(nestedShape as Record<string, unknown>).select,
|
|
2305
|
+
'select',
|
|
2306
|
+
depth + 1,
|
|
2307
|
+
)
|
|
2308
|
+
}
|
|
2309
|
+
if (nestedIncludeConfigured && relatedMeta && depth < MAX_PROJECTION_DEPTH) {
|
|
2310
|
+
wrapperProps.include = projectionSchema(
|
|
2311
|
+
relatedMeta,
|
|
2312
|
+
(nestedShape as Record<string, unknown>).include,
|
|
2313
|
+
'include',
|
|
2314
|
+
depth + 1,
|
|
2315
|
+
)
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
// list relations may carry where/orderBy/take/skip/cursor under BOTH
|
|
2319
|
+
// select and include (prisma-guard 1.33 permits the configured arguments
|
|
2320
|
+
// in either projection) — only for the keys the nested shape configures;
|
|
2321
|
+
// orderBy narrows to the shape's declared fields, take preserves its bound
|
|
2322
|
+
if (field.isList && isShapeObject(nestedShape)) {
|
|
2323
|
+
if (nestedShape.where !== undefined) {
|
|
2324
|
+
// pass the nested where OBJECT: whereSchema narrows recursively from
|
|
2325
|
+
// the configured tree (a key Set here would fall back to every field)
|
|
2326
|
+
wrapperProps.where = whereSchema(
|
|
2327
|
+
relatedMeta ?? meta,
|
|
2328
|
+
nestedShape.where,
|
|
2329
|
+
Math.max(0, depth),
|
|
2330
|
+
)
|
|
2331
|
+
}
|
|
2332
|
+
if (nestedShape.orderBy !== undefined && relatedMeta) {
|
|
2333
|
+
wrapperProps.orderBy = orderBySchema(relatedMeta, nestedShape.orderBy)
|
|
2334
|
+
}
|
|
2335
|
+
if (nestedShape.take !== undefined) {
|
|
2336
|
+
const takeMax =
|
|
2337
|
+
typeof nestedShape.take === 'number'
|
|
2338
|
+
? nestedShape.take
|
|
2339
|
+
: isShapeObject(nestedShape.take) &&
|
|
2340
|
+
typeof nestedShape.take.max === 'number'
|
|
2341
|
+
? nestedShape.take.max
|
|
2342
|
+
: undefined
|
|
2343
|
+
wrapperProps.take = takeSchema(takeMax)
|
|
2344
|
+
}
|
|
2345
|
+
if (nestedShape.skip !== undefined) {
|
|
2346
|
+
// guard configures nested skip as `true`; input is a non-negative int
|
|
2347
|
+
wrapperProps.skip = {
|
|
2348
|
+
type: 'integer',
|
|
2349
|
+
minimum: 0,
|
|
2350
|
+
description: 'Skip related rows',
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
if (nestedShape.cursor !== undefined) {
|
|
2354
|
+
wrapperProps.cursor = uniqueCursorSchema(
|
|
2355
|
+
relatedMeta ?? meta,
|
|
2356
|
+
nestedShape.cursor,
|
|
2357
|
+
)
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
if (Object.keys(wrapperProps).length === 0) {
|
|
2362
|
+
// nothing configured: the relation literal `true` is the only legal value
|
|
2363
|
+
return { type: 'boolean', const: true }
|
|
2364
|
+
}
|
|
2365
|
+
return {
|
|
2366
|
+
anyOf: [
|
|
2367
|
+
{ type: 'boolean', const: true },
|
|
2368
|
+
{
|
|
2369
|
+
type: 'object',
|
|
2370
|
+
properties: wrapperProps,
|
|
2371
|
+
additionalProperties: false,
|
|
2372
|
+
},
|
|
2373
|
+
],
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
/**
|
|
2378
|
+
* Projection (select/include) narrowed by the static guard shape's own tree.
|
|
2379
|
+
* BOTH modes narrow by the shape's keys; include accepts relations only
|
|
2380
|
+
* (scalar keys are never advertised).
|
|
2381
|
+
*/
|
|
2382
|
+
/**
|
|
2383
|
+
* `_count` projection schema in both guard-1.33 forms: `true` and
|
|
2384
|
+
* `{ select: { relation: true } }` (empty select is guard-invalid).
|
|
2385
|
+
*/
|
|
2386
|
+
function countProjectionSchema(
|
|
2387
|
+
meta: SchemaModelMeta,
|
|
2388
|
+
cfg: unknown,
|
|
2389
|
+
): OperationSchemaObject {
|
|
2390
|
+
if (cfg === true) return { type: 'boolean', const: true }
|
|
2391
|
+
if (
|
|
2392
|
+
isShapeObject(cfg) &&
|
|
2393
|
+
!isForcedShapeValue(cfg) &&
|
|
2394
|
+
isShapeObject(cfg.select)
|
|
2395
|
+
) {
|
|
2396
|
+
const props: Record<string, OperationSchemaObject> = {}
|
|
2397
|
+
for (const f of meta.fields) {
|
|
2398
|
+
// _count counts LIST relations only (guard rejects to-one entries)
|
|
2399
|
+
if (f.kind !== 'object' || !f.isList) continue
|
|
2400
|
+
const entry = cfg.select[f.name]
|
|
2401
|
+
if (entry === undefined) continue
|
|
2402
|
+
const relatedMeta = meta.modelIndex.get(f.type)
|
|
2403
|
+
if (entry === true || !relatedMeta) {
|
|
2404
|
+
props[f.name] = { type: 'boolean', const: true }
|
|
2405
|
+
continue
|
|
2406
|
+
}
|
|
2407
|
+
// configured filtered count: guard accepts `true`, `{}`,
|
|
2408
|
+
// `{where:{}}` and the where mirror (all verified) — the union
|
|
2409
|
+
// covers every form for forced AND client-controlled configs
|
|
2410
|
+
if (isShapeObject(entry) && entry.where !== undefined) {
|
|
2411
|
+
const whereNode = whereSchema(relatedMeta, entry.where)
|
|
2412
|
+
props[f.name] = {
|
|
2413
|
+
anyOf: [
|
|
2414
|
+
{ type: 'boolean', const: true },
|
|
2415
|
+
{
|
|
2416
|
+
type: 'object',
|
|
2417
|
+
properties: { where: whereNode },
|
|
2418
|
+
additionalProperties: false,
|
|
2419
|
+
},
|
|
2420
|
+
],
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
// input mirrors the config: { select: { relation: ..., ... } }
|
|
2425
|
+
return {
|
|
2426
|
+
type: 'object',
|
|
2427
|
+
properties: {
|
|
2428
|
+
select: {
|
|
2429
|
+
type: 'object',
|
|
2430
|
+
properties: props,
|
|
2431
|
+
additionalProperties: false,
|
|
2432
|
+
minProperties: 1,
|
|
2433
|
+
},
|
|
2434
|
+
},
|
|
2435
|
+
additionalProperties: false,
|
|
2436
|
+
required: ['select'],
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
// guard-invalid config: advertise nothing selectable
|
|
2440
|
+
return {
|
|
2441
|
+
type: 'object',
|
|
2442
|
+
properties: {},
|
|
2443
|
+
additionalProperties: false,
|
|
2444
|
+
minProperties: 1,
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
function projectionSchema(
|
|
2449
|
+
meta: SchemaModelMeta,
|
|
2450
|
+
shapeValue: unknown,
|
|
2451
|
+
mode: 'select' | 'include',
|
|
2452
|
+
depth: number,
|
|
2453
|
+
): OperationSchemaObject {
|
|
2454
|
+
const shapeKeys = isShapeObject(shapeValue)
|
|
2455
|
+
? new Set(Object.keys(shapeValue))
|
|
2456
|
+
: undefined
|
|
2457
|
+
const properties: Record<string, OperationSchemaObject> = {}
|
|
2458
|
+
for (const field of meta.fields) {
|
|
2459
|
+
if (shapeKeys && !shapeKeys.has(field.name)) continue
|
|
2460
|
+
if (mode === 'include' && field.kind !== 'object') {
|
|
2461
|
+
// prisma-guard rejects scalar keys inside include; never advertise them
|
|
2462
|
+
continue
|
|
2463
|
+
}
|
|
2464
|
+
if (field.kind === 'object') {
|
|
2465
|
+
const nestedShape = isShapeObject(shapeValue)
|
|
2466
|
+
? shapeValue[field.name]
|
|
2467
|
+
: undefined
|
|
2468
|
+
const relatedMeta = meta.modelIndex.get(field.type)
|
|
2469
|
+
properties[field.name] = relationEntrySchema(
|
|
2470
|
+
meta,
|
|
2471
|
+
field,
|
|
2472
|
+
nestedShape,
|
|
2473
|
+
relatedMeta,
|
|
2474
|
+
depth,
|
|
2475
|
+
mode,
|
|
2476
|
+
)
|
|
2477
|
+
continue
|
|
2478
|
+
}
|
|
2479
|
+
properties[field.name] = { type: 'boolean', const: true }
|
|
2480
|
+
}
|
|
2481
|
+
// _count is advertised only when the shape configures it — same parity
|
|
2482
|
+
// rule as every other projection key; both guard forms are mirrored:
|
|
2483
|
+
// `_count: true` (input `true`) and `_count: { select: { ... } }`
|
|
2484
|
+
// (input object over the configured relations, at least one key)
|
|
2485
|
+
if (shapeKeys && shapeKeys.has('_count') && isShapeObject(shapeValue)) {
|
|
2486
|
+
properties._count = countProjectionSchema(meta, shapeValue._count)
|
|
2487
|
+
}
|
|
2488
|
+
const node: OperationSchemaObject = {
|
|
2489
|
+
type: 'object',
|
|
2490
|
+
properties,
|
|
2491
|
+
additionalProperties: false,
|
|
2492
|
+
}
|
|
2493
|
+
// an empty projection selects nothing — guard requires at least one key
|
|
2494
|
+
node.minProperties = 1
|
|
2495
|
+
return node
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
/**
|
|
2499
|
+
* The model-aware argument schema for one read operation under one static
|
|
2500
|
+
* guard shape. Only keys the shape declares are advertised; `where`,
|
|
2501
|
+
* `orderBy`, `distinct` and the projection keys are built from model
|
|
2502
|
+
* metadata, recursively for nested select/include trees.
|
|
2503
|
+
*/
|
|
2504
|
+
export function buildModelAwareArgsSchema(
|
|
2505
|
+
operation: string,
|
|
2506
|
+
meta: SchemaModelMeta,
|
|
2507
|
+
shape: unknown,
|
|
2508
|
+
): OperationSchemaObject {
|
|
2509
|
+
if (!isShapeObject(shape)) {
|
|
2510
|
+
// dynamic (function) shape: broad schema, runtime rejection
|
|
2511
|
+
return buildOperationArgsSchema(operation)
|
|
2512
|
+
}
|
|
2513
|
+
const shapeKeys = new Set(Object.keys(shape))
|
|
2514
|
+
const properties: Record<string, OperationSchemaObject> = {}
|
|
2515
|
+
const required: string[] = []
|
|
2516
|
+
|
|
2517
|
+
if (shapeKeys.has('where')) {
|
|
2518
|
+
// where is narrowed by the shape's OWN where tree. findUnique gets a
|
|
2519
|
+
// DEDICATED unique-selector schema (flat unique fields + compound
|
|
2520
|
+
// selector objects, per prisma-guard 1.33) — never the generic filter
|
|
2521
|
+
// schema, which would advertise filter operators on unique fields.
|
|
2522
|
+
if (operation === 'findUnique') {
|
|
2523
|
+
properties.where = uniqueSelectorWhereSchema(meta, shape.where)
|
|
2524
|
+
const whereNode = properties.where as {
|
|
2525
|
+
required?: string[]
|
|
2526
|
+
anyOf?: unknown[]
|
|
2527
|
+
}
|
|
2528
|
+
// client-controlled selectors make `where` mandatory (guard rejects
|
|
2529
|
+
// findUnique without it); anyOf alternatives each require their key
|
|
2530
|
+
if (
|
|
2531
|
+
(whereNode.required && whereNode.required.length > 0) ||
|
|
2532
|
+
whereNode.anyOf
|
|
2533
|
+
) {
|
|
2534
|
+
required.push('where')
|
|
2535
|
+
}
|
|
2536
|
+
} else {
|
|
2537
|
+
properties.where = whereSchema(meta, shape.where)
|
|
2538
|
+
}
|
|
2539
|
+
} else if (operation === 'findUnique') {
|
|
2540
|
+
// fully-forced (or absent) unique shape: clients send no where at all;
|
|
2541
|
+
// guard enforces the forced selector server-side.
|
|
2542
|
+
}
|
|
2543
|
+
if (shapeKeys.has('orderBy')) {
|
|
2544
|
+
properties.orderBy = orderBySchema(meta, shape.orderBy)
|
|
2545
|
+
}
|
|
2546
|
+
if (shapeKeys.has('distinct')) {
|
|
2547
|
+
properties.distinct = distinctSchema(meta, shape.distinct)
|
|
2548
|
+
}
|
|
2549
|
+
if (shapeKeys.has('cursor')) {
|
|
2550
|
+
// cursor follows the UNIQUE-SELECTOR surface (guard 1.33 semantics),
|
|
2551
|
+
// the same builder used for nested relation list args
|
|
2552
|
+
properties.cursor = uniqueCursorSchema(meta, shape.cursor)
|
|
2553
|
+
}
|
|
2554
|
+
if (operation === 'count' && shapeKeys.has('select')) {
|
|
2555
|
+
// count select: ONLY the keys the shape configures, at least one
|
|
2556
|
+
// selected — no implicit _all, no relations
|
|
2557
|
+
const selectShape = isShapeObject(shape.select)
|
|
2558
|
+
? new Set(Object.keys(shape.select))
|
|
2559
|
+
: undefined
|
|
2560
|
+
const props: Record<string, OperationSchemaObject> = {}
|
|
2561
|
+
for (const field of meta.fields) {
|
|
2562
|
+
if (field.kind !== 'scalar' && field.kind !== 'enum') continue
|
|
2563
|
+
if (selectShape && !selectShape.has(field.name)) continue
|
|
2564
|
+
props[field.name] = { type: 'boolean', const: true }
|
|
2565
|
+
}
|
|
2566
|
+
if (selectShape && selectShape.has('_all')) {
|
|
2567
|
+
props._all = { type: 'boolean', const: true }
|
|
2568
|
+
}
|
|
2569
|
+
properties.select = {
|
|
2570
|
+
type: 'object',
|
|
2571
|
+
properties: props,
|
|
2572
|
+
additionalProperties: false,
|
|
2573
|
+
minProperties: 1,
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
for (const key of ['select', 'include', 'omit'] as const) {
|
|
2577
|
+
if (!shapeKeys.has(key)) continue
|
|
2578
|
+
// count select is a COUNT-FIELD selection (handled above with _all +
|
|
2579
|
+
// scalars only); the generic projection loop would advertise relations
|
|
2580
|
+
if (operation === 'count' && key === 'select') continue
|
|
2581
|
+
if (key === 'omit') {
|
|
2582
|
+
const omitKeys = isShapeObject(shape[key])
|
|
2583
|
+
? new Set(Object.keys(shape[key] as Record<string, unknown>))
|
|
2584
|
+
: undefined
|
|
2585
|
+
const props: Record<string, OperationSchemaObject> = {}
|
|
2586
|
+
for (const field of meta.fields) {
|
|
2587
|
+
if (omitKeys && !omitKeys.has(field.name)) continue
|
|
2588
|
+
props[field.name] = { type: 'boolean', const: true }
|
|
2589
|
+
}
|
|
2590
|
+
properties.omit = {
|
|
2591
|
+
type: 'object',
|
|
2592
|
+
properties: props,
|
|
2593
|
+
additionalProperties: false,
|
|
2594
|
+
}
|
|
2595
|
+
continue
|
|
2596
|
+
}
|
|
2597
|
+
properties[key] = projectionSchema(meta, shape[key], key, 0)
|
|
2598
|
+
}
|
|
2599
|
+
if (shapeKeys.has('take')) {
|
|
2600
|
+
// advertise the shape's own bound so clients learn the limit upfront and
|
|
2601
|
+
// the SDK rejects violations before the handler runs
|
|
2602
|
+
const take = shape.take
|
|
2603
|
+
const takeMax =
|
|
2604
|
+
typeof take === 'number'
|
|
2605
|
+
? take
|
|
2606
|
+
: isShapeObject(take) && typeof take.max === 'number'
|
|
2607
|
+
? take.max
|
|
2608
|
+
: undefined
|
|
2609
|
+
properties.take = takeSchema(takeMax)
|
|
2610
|
+
}
|
|
2611
|
+
if (shapeKeys.has('skip')) {
|
|
2612
|
+
properties.skip = {
|
|
2613
|
+
type: 'integer',
|
|
2614
|
+
minimum: 0,
|
|
2615
|
+
description: 'Skip results',
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
const schema: OperationSchemaObject = {
|
|
2620
|
+
type: 'object',
|
|
2621
|
+
properties,
|
|
2622
|
+
additionalProperties: false,
|
|
2623
|
+
}
|
|
2624
|
+
if (required.length > 0) schema.required = required
|
|
2625
|
+
return schema
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
// ---------------------------------------------------------------------------
|
|
2629
|
+
// Model-aware WRITE argument schemas (MCP). data/create/update advertise ONLY
|
|
2630
|
+
// the client-controlled (`true`) fields of the guard data config — forced
|
|
2631
|
+
// values are server-owned and never client input. Nullability mirrors guard's
|
|
2632
|
+
// applyCreateUpdateNullability: optional fields accept null, required fields
|
|
2633
|
+
// never do; a required, default-less field stays mandatory in create data.
|
|
2634
|
+
// ---------------------------------------------------------------------------
|
|
2635
|
+
|
|
2636
|
+
function nullableUnion(base: OperationSchemaObject): OperationSchemaObject {
|
|
2637
|
+
// only an UNCONSTRAINED schema (Json: no type, no anyOf) already accepts
|
|
2638
|
+
// null; BigInt/Decimal are typeless anyOf unions that must gain it
|
|
2639
|
+
if (base.type === undefined && base.anyOf === undefined) return base
|
|
2640
|
+
if (base.type === undefined && base.anyOf) {
|
|
2641
|
+
return { ...base, anyOf: [...base.anyOf, { type: 'null' }] }
|
|
2642
|
+
}
|
|
2643
|
+
return { anyOf: [base, { type: 'null' }] }
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
function dataInputSchema(
|
|
2647
|
+
meta: SchemaModelMeta,
|
|
2648
|
+
dataConfig: unknown,
|
|
2649
|
+
mode: 'create' | 'update',
|
|
2650
|
+
): OperationSchemaObject {
|
|
2651
|
+
const cfg = isShapeObject(dataConfig) ? dataConfig : undefined
|
|
2652
|
+
const properties: Record<string, OperationSchemaObject> = {}
|
|
2653
|
+
const required: string[] = []
|
|
2654
|
+
if (cfg) {
|
|
2655
|
+
for (const field of meta.fields) {
|
|
2656
|
+
if (cfg[field.name] !== true) continue
|
|
2657
|
+
const base = scalarBaseSchema(meta, field)
|
|
2658
|
+
properties[field.name] = field.isRequired ? base : nullableUnion(base)
|
|
2659
|
+
if (mode === 'create' && field.isRequired && !field.hasDefaultValue) {
|
|
2660
|
+
required.push(field.name)
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
const node: OperationSchemaObject = {
|
|
2665
|
+
type: 'object',
|
|
2666
|
+
properties,
|
|
2667
|
+
additionalProperties: false,
|
|
2668
|
+
}
|
|
2669
|
+
if (required.length > 0) node.required = required
|
|
2670
|
+
return node
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
export type WriteSchemaOperation =
|
|
2674
|
+
| 'create'
|
|
2675
|
+
| 'createMany'
|
|
2676
|
+
| 'createManyAndReturn'
|
|
2677
|
+
| 'update'
|
|
2678
|
+
| 'updateMany'
|
|
2679
|
+
| 'updateManyAndReturn'
|
|
2680
|
+
| 'upsert'
|
|
2681
|
+
| 'delete'
|
|
2682
|
+
| 'deleteMany'
|
|
2683
|
+
|
|
2684
|
+
/**
|
|
2685
|
+
* The model-aware argument schema for one write operation under one STATIC
|
|
2686
|
+
* guard shape (MCP refuses dynamic write shapes at tool creation — there is
|
|
2687
|
+
* no opaque fallback). Only keys the shape declares are advertised:
|
|
2688
|
+
* - unique-where ops (update/upsert/delete) reuse the findUnique selector
|
|
2689
|
+
* surface, extended unique filters included;
|
|
2690
|
+
* - bulk ops (updateMany*, deleteMany) reuse the filter where surface; a
|
|
2691
|
+
* filter with ANY client-controlled key requires at least one client
|
|
2692
|
+
* condition (stricter than guard, which accepts `{}` beside a forced
|
|
2693
|
+
* tenant and then touches every tenant row);
|
|
2694
|
+
* - `where` is optional only when fully forced (every selector value or
|
|
2695
|
+
* filter leaf server-owned) — the runtime then injects `where: {}` so
|
|
2696
|
+
* the operation core's required-field check passes and guard merges the
|
|
2697
|
+
* forced values;
|
|
2698
|
+
* - data/create/update are narrowed to the shape's client-controlled
|
|
2699
|
+
* fields; createMany* take a non-empty array of them plus
|
|
2700
|
+
* `skipDuplicates`;
|
|
2701
|
+
* - select/include reuse the projection builder.
|
|
2702
|
+
*/
|
|
2703
|
+
export function buildModelAwareWriteArgsSchema(
|
|
2704
|
+
operation: WriteSchemaOperation,
|
|
2705
|
+
meta: SchemaModelMeta,
|
|
2706
|
+
shape: unknown,
|
|
2707
|
+
): OperationSchemaObject {
|
|
2708
|
+
if (!isShapeObject(shape)) {
|
|
2709
|
+
throw new Error(
|
|
2710
|
+
operation + ': MCP write schemas require a static guard shape',
|
|
2711
|
+
)
|
|
2712
|
+
}
|
|
2713
|
+
const properties: Record<string, OperationSchemaObject> = {}
|
|
2714
|
+
const required: string[] = []
|
|
2715
|
+
const shapeKeys = new Set(Object.keys(shape))
|
|
2716
|
+
|
|
2717
|
+
if (shapeKeys.has('where') && UNIQUE_WHERE_WRITE_OPS.has(operation)) {
|
|
2718
|
+
properties.where = uniqueSelectorWhereSchema(meta, shape.where)
|
|
2719
|
+
const whereNode = properties.where as {
|
|
2720
|
+
required?: string[]
|
|
2721
|
+
anyOf?: unknown[]
|
|
2722
|
+
}
|
|
2723
|
+
if (
|
|
2724
|
+
(whereNode.required && whereNode.required.length > 0) ||
|
|
2725
|
+
whereNode.anyOf
|
|
2726
|
+
) {
|
|
2727
|
+
required.push('where')
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
if (shapeKeys.has('where') && FILTER_WHERE_WRITE_OPS.has(operation)) {
|
|
2731
|
+
const node = whereSchema(meta, shape.where)
|
|
2732
|
+
// any client-controlled filter key makes the client state at least one
|
|
2733
|
+
// condition — stricter than guard (which would merge a forced tenant
|
|
2734
|
+
// into `{}` and touch EVERY tenant row): an agent cannot bulk-write a
|
|
2735
|
+
// whole scope by omission. Fully forced filters stay optional.
|
|
2736
|
+
if (scanShapeClientState(shape.where).client) {
|
|
2737
|
+
node.minProperties = 1
|
|
2738
|
+
required.push('where')
|
|
2739
|
+
}
|
|
2740
|
+
properties.where = node
|
|
2741
|
+
}
|
|
2742
|
+
if (CREATE_DATA_WRITE_OPS.has(operation)) {
|
|
2743
|
+
const item = dataInputSchema(meta, shape.data, 'create')
|
|
2744
|
+
if (operation === 'create') {
|
|
2745
|
+
properties.data = item
|
|
2746
|
+
} else {
|
|
2747
|
+
// guard: "expects data to be an array", "received empty data array"
|
|
2748
|
+
properties.data = { type: 'array', items: item, minItems: 1 }
|
|
2749
|
+
properties.skipDuplicates = { type: 'boolean' }
|
|
2750
|
+
}
|
|
2751
|
+
required.push('data')
|
|
2752
|
+
}
|
|
2753
|
+
if (UPDATE_DATA_WRITE_OPS.has(operation)) {
|
|
2754
|
+
properties.data = dataInputSchema(meta, shape.data, 'update')
|
|
2755
|
+
required.push('data')
|
|
2756
|
+
}
|
|
2757
|
+
if (operation === 'upsert') {
|
|
2758
|
+
properties.create = dataInputSchema(meta, shape.create, 'create')
|
|
2759
|
+
properties.update = dataInputSchema(meta, shape.update, 'update')
|
|
2760
|
+
required.push('create', 'update')
|
|
2761
|
+
}
|
|
2762
|
+
for (const key of ['select', 'include'] as const) {
|
|
2763
|
+
if (!shapeKeys.has(key)) continue
|
|
2764
|
+
properties[key] = projectionSchema(meta, shape[key], key, 0)
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
return {
|
|
2768
|
+
type: 'object',
|
|
2769
|
+
properties,
|
|
2770
|
+
required,
|
|
2771
|
+
additionalProperties: false,
|
|
2772
|
+
}
|
|
2773
|
+
}
|