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.
Files changed (68) hide show
  1. package/README.md +223 -28
  2. package/dist/copy/operationDefinitions.d.ts +4 -1
  3. package/dist/copy/operationDefinitions.js +64 -3
  4. package/dist/copy/operationDefinitions.js.map +1 -1
  5. package/dist/generators/generateFastifyHandler.js +55 -9
  6. package/dist/generators/generateFastifyHandler.js.map +1 -1
  7. package/dist/generators/generateHonoHandler.js +44 -4
  8. package/dist/generators/generateHonoHandler.js.map +1 -1
  9. package/dist/generators/generateHonoOpenApi.d.ts +13 -0
  10. package/dist/generators/generateHonoOpenApi.js +70 -0
  11. package/dist/generators/generateHonoOpenApi.js.map +1 -0
  12. package/dist/generators/generateMcpApp.d.ts +10 -0
  13. package/dist/generators/generateMcpApp.js +113 -0
  14. package/dist/generators/generateMcpApp.js.map +1 -0
  15. package/dist/generators/generateModelMcp.d.ts +9 -0
  16. package/dist/generators/generateModelMcp.js +145 -0
  17. package/dist/generators/generateModelMcp.js.map +1 -0
  18. package/dist/generators/generateModelMetadata.js +9 -5
  19. package/dist/generators/generateModelMetadata.js.map +1 -1
  20. package/dist/generators/generateRouter.js +62 -56
  21. package/dist/generators/generateRouter.js.map +1 -1
  22. package/dist/generators/generateRouterFastify.js +56 -64
  23. package/dist/generators/generateRouterFastify.js.map +1 -1
  24. package/dist/generators/generateRouterHono.js +43 -411
  25. package/dist/generators/generateRouterHono.js.map +1 -1
  26. package/dist/generators/generateRouterPartsHono.d.ts +14 -0
  27. package/dist/generators/generateRouterPartsHono.js +180 -0
  28. package/dist/generators/generateRouterPartsHono.js.map +1 -0
  29. package/dist/generators/generateUnifiedHandler.js +36 -20
  30. package/dist/generators/generateUnifiedHandler.js.map +1 -1
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +101 -3
  33. package/dist/index.js.map +1 -1
  34. package/dist/utils/copyFiles.d.ts +1 -1
  35. package/dist/utils/copyFiles.js +20 -1
  36. package/dist/utils/copyFiles.js.map +1 -1
  37. package/dist/utils/mcpGate.d.ts +10 -0
  38. package/dist/utils/mcpGate.js +118 -0
  39. package/dist/utils/mcpGate.js.map +1 -0
  40. package/dist/utils/writeFileSafely.js +6 -0
  41. package/dist/utils/writeFileSafely.js.map +1 -1
  42. package/package.json +18 -3
  43. package/src/copy/buildModelOpenApi.ts +76 -126
  44. package/src/copy/docsRenderer.ts +14 -15
  45. package/src/copy/mcpRuntime.ts +1017 -0
  46. package/src/copy/operationDefinitions.ts +78 -4
  47. package/src/copy/operationPipeline.ts +267 -0
  48. package/src/copy/operationRuntime.ts +24 -14
  49. package/src/copy/operationSchemas.ts +2773 -0
  50. package/src/copy/pagination.ts +10 -2
  51. package/src/copy/routeConfig.hono.ts +34 -5
  52. package/src/copy/routeConfig.ts +1 -1
  53. package/src/copy/routerParts.ts +524 -0
  54. package/src/generators/generateFastifyHandler.ts +55 -9
  55. package/src/generators/generateHonoHandler.ts +44 -4
  56. package/src/generators/generateHonoOpenApi.ts +93 -0
  57. package/src/generators/generateMcpApp.ts +133 -0
  58. package/src/generators/generateModelMcp.ts +208 -0
  59. package/src/generators/generateModelMetadata.ts +16 -10
  60. package/src/generators/generateRouter.ts +62 -56
  61. package/src/generators/generateRouterFastify.ts +56 -64
  62. package/src/generators/generateRouterHono.ts +49 -459
  63. package/src/generators/generateRouterPartsHono.ts +220 -0
  64. package/src/generators/generateUnifiedHandler.ts +36 -21
  65. package/src/index.ts +116 -3
  66. package/src/utils/copyFiles.ts +36 -0
  67. package/src/utils/mcpGate.ts +157 -0
  68. 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
+ }