prisma-guard 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1311 -0
- package/dist/generator/index.d.ts +2 -0
- package/dist/generator/index.js +893 -0
- package/dist/generator/index.js.map +1 -0
- package/dist/runtime/index.d.ts +177 -0
- package/dist/runtime/index.js +2677 -0
- package/dist/runtime/index.js.map +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/generator/index.ts
|
|
4
|
+
import pkg from "@prisma/generator-helper";
|
|
5
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
|
|
8
|
+
// src/generator/emit-scope-map.ts
|
|
9
|
+
function isScopeRoot(documentation) {
|
|
10
|
+
if (!documentation)
|
|
11
|
+
return false;
|
|
12
|
+
const tokens = documentation.split(/[\s\n\r]+/);
|
|
13
|
+
return tokens.some((t) => t === "@scope-root");
|
|
14
|
+
}
|
|
15
|
+
function emitScopeMap(dmmf, onAmbiguousScope) {
|
|
16
|
+
const rootModels = /* @__PURE__ */ new Set();
|
|
17
|
+
for (const model of dmmf.datamodel.models) {
|
|
18
|
+
if (isScopeRoot(model.documentation)) {
|
|
19
|
+
rootModels.add(model.name);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const scopeMap = {};
|
|
23
|
+
for (const model of dmmf.datamodel.models) {
|
|
24
|
+
if (rootModels.has(model.name))
|
|
25
|
+
continue;
|
|
26
|
+
const relations = [];
|
|
27
|
+
for (const field of model.fields) {
|
|
28
|
+
if (!field.relationFromFields || field.relationFromFields.length === 0)
|
|
29
|
+
continue;
|
|
30
|
+
if (!rootModels.has(field.type))
|
|
31
|
+
continue;
|
|
32
|
+
if (field.relationFromFields.length > 1) {
|
|
33
|
+
const msg = `Model "${model.name}" has a composite foreign key to scope root "${field.type}" via relation "${field.name}" (fields: ${field.relationFromFields.join(", ")}). Composite scope relations are not supported.`;
|
|
34
|
+
if (onAmbiguousScope === "error") {
|
|
35
|
+
throw new Error(`prisma-guard: ${msg}`);
|
|
36
|
+
}
|
|
37
|
+
if (onAmbiguousScope === "warn") {
|
|
38
|
+
console.warn(`prisma-guard: ${msg} Excluding model from scope map.`);
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
relations.push({
|
|
43
|
+
fks: [...field.relationFromFields],
|
|
44
|
+
root: field.type,
|
|
45
|
+
relationName: field.name
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (relations.length === 0)
|
|
49
|
+
continue;
|
|
50
|
+
const relationsByRoot = {};
|
|
51
|
+
for (const rel of relations) {
|
|
52
|
+
if (!relationsByRoot[rel.root])
|
|
53
|
+
relationsByRoot[rel.root] = [];
|
|
54
|
+
relationsByRoot[rel.root].push(rel);
|
|
55
|
+
}
|
|
56
|
+
const entries = [];
|
|
57
|
+
for (const [root, rels] of Object.entries(relationsByRoot)) {
|
|
58
|
+
if (rels.length > 1) {
|
|
59
|
+
const relNames = rels.map((r) => r.relationName);
|
|
60
|
+
const msg = `Model "${model.name}" has multiple relations to scope root "${root}" (${relNames.join(", ")}).`;
|
|
61
|
+
if (onAmbiguousScope === "error") {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`prisma-guard: Ambiguous scope detected. Resolve these or set onAmbiguousScope to "warn" or "ignore":
|
|
64
|
+
- ${msg}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (onAmbiguousScope === "warn") {
|
|
68
|
+
console.warn(`prisma-guard: ${msg} Excluding model from scope map.`);
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
entries.push({
|
|
73
|
+
fk: rels[0].fks[0],
|
|
74
|
+
root: rels[0].root,
|
|
75
|
+
relationName: rels[0].relationName
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (entries.length > 0) {
|
|
79
|
+
scopeMap[model.name] = entries;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const roots = Array.from(rootModels).sort();
|
|
83
|
+
const mapEntries = Object.entries(scopeMap).map(([model, entries]) => {
|
|
84
|
+
const entriesStr = entries.map((e) => `{ fk: ${JSON.stringify(e.fk)}, root: ${JSON.stringify(e.root)}, relationName: ${JSON.stringify(e.relationName)} }`).join(", ");
|
|
85
|
+
return ` ${model}: [${entriesStr}],`;
|
|
86
|
+
}).join("\n");
|
|
87
|
+
const scopeRootType = roots.length > 0 ? roots.map((r) => `'${r}'`).join(" | ") : "never";
|
|
88
|
+
const source = `export const SCOPE_MAP = {
|
|
89
|
+
${mapEntries}
|
|
90
|
+
} as const
|
|
91
|
+
|
|
92
|
+
export type ScopeRoot = ${scopeRootType}
|
|
93
|
+
`;
|
|
94
|
+
return { source, roots };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/generator/emit-zod-chains.ts
|
|
98
|
+
import { z as z2 } from "zod";
|
|
99
|
+
|
|
100
|
+
// src/generator/validate-directive.ts
|
|
101
|
+
var ALLOWED_ZOD_METHODS = /* @__PURE__ */ new Set([
|
|
102
|
+
"min",
|
|
103
|
+
"max",
|
|
104
|
+
"length",
|
|
105
|
+
"email",
|
|
106
|
+
"url",
|
|
107
|
+
"uuid",
|
|
108
|
+
"cuid",
|
|
109
|
+
"cuid2",
|
|
110
|
+
"ulid",
|
|
111
|
+
"trim",
|
|
112
|
+
"toLowerCase",
|
|
113
|
+
"toUpperCase",
|
|
114
|
+
"startsWith",
|
|
115
|
+
"endsWith",
|
|
116
|
+
"includes",
|
|
117
|
+
"datetime",
|
|
118
|
+
"ip",
|
|
119
|
+
"cidr",
|
|
120
|
+
"date",
|
|
121
|
+
"time",
|
|
122
|
+
"duration",
|
|
123
|
+
"base64",
|
|
124
|
+
"nanoid",
|
|
125
|
+
"emoji",
|
|
126
|
+
"int",
|
|
127
|
+
"positive",
|
|
128
|
+
"nonnegative",
|
|
129
|
+
"negative",
|
|
130
|
+
"nonpositive",
|
|
131
|
+
"finite",
|
|
132
|
+
"safe",
|
|
133
|
+
"multipleOf",
|
|
134
|
+
"step",
|
|
135
|
+
"gt",
|
|
136
|
+
"gte",
|
|
137
|
+
"lt",
|
|
138
|
+
"lte",
|
|
139
|
+
"nonempty",
|
|
140
|
+
"regex",
|
|
141
|
+
"readonly",
|
|
142
|
+
"optional",
|
|
143
|
+
"nullable",
|
|
144
|
+
"nullish",
|
|
145
|
+
"default",
|
|
146
|
+
"catch"
|
|
147
|
+
]);
|
|
148
|
+
var METHOD_ARITY = {
|
|
149
|
+
min: [1, 2],
|
|
150
|
+
max: [1, 2],
|
|
151
|
+
length: [1, 2],
|
|
152
|
+
email: [0, 1],
|
|
153
|
+
url: [0, 1],
|
|
154
|
+
uuid: [0, 1],
|
|
155
|
+
cuid: [0, 1],
|
|
156
|
+
cuid2: [0, 1],
|
|
157
|
+
ulid: [0, 1],
|
|
158
|
+
trim: [0, 0],
|
|
159
|
+
toLowerCase: [0, 0],
|
|
160
|
+
toUpperCase: [0, 0],
|
|
161
|
+
startsWith: [1, 2],
|
|
162
|
+
endsWith: [1, 2],
|
|
163
|
+
includes: [1, 2],
|
|
164
|
+
datetime: [0, 1],
|
|
165
|
+
ip: [0, 1],
|
|
166
|
+
cidr: [0, 1],
|
|
167
|
+
date: [0, 1],
|
|
168
|
+
time: [0, 1],
|
|
169
|
+
duration: [0, 1],
|
|
170
|
+
base64: [0, 1],
|
|
171
|
+
nanoid: [0, 1],
|
|
172
|
+
emoji: [0, 1],
|
|
173
|
+
int: [0, 1],
|
|
174
|
+
positive: [0, 1],
|
|
175
|
+
nonnegative: [0, 1],
|
|
176
|
+
negative: [0, 1],
|
|
177
|
+
nonpositive: [0, 1],
|
|
178
|
+
finite: [0, 1],
|
|
179
|
+
safe: [0, 1],
|
|
180
|
+
multipleOf: [1, 2],
|
|
181
|
+
step: [1, 2],
|
|
182
|
+
gt: [1, 2],
|
|
183
|
+
gte: [1, 2],
|
|
184
|
+
lt: [1, 2],
|
|
185
|
+
lte: [1, 2],
|
|
186
|
+
nonempty: [0, 1],
|
|
187
|
+
regex: [1, 2],
|
|
188
|
+
readonly: [0, 0],
|
|
189
|
+
optional: [0, 0],
|
|
190
|
+
nullable: [0, 0],
|
|
191
|
+
nullish: [0, 0],
|
|
192
|
+
default: [1, 1],
|
|
193
|
+
catch: [1, 1]
|
|
194
|
+
};
|
|
195
|
+
var MAX_DIRECTIVE_LENGTH = 1024;
|
|
196
|
+
var MAX_CHAIN_DEPTH = 20;
|
|
197
|
+
function validateDirective(raw) {
|
|
198
|
+
if (raw.length > MAX_DIRECTIVE_LENGTH) {
|
|
199
|
+
return { valid: false, reason: "Directive exceeds maximum length" };
|
|
200
|
+
}
|
|
201
|
+
const input = raw.trim();
|
|
202
|
+
if (input.length === 0) {
|
|
203
|
+
return { valid: false, reason: "Empty directive" };
|
|
204
|
+
}
|
|
205
|
+
if (input[0] !== ".") {
|
|
206
|
+
return { valid: false, reason: 'Directive must start with "."' };
|
|
207
|
+
}
|
|
208
|
+
let pos = 0;
|
|
209
|
+
let chainCount = 0;
|
|
210
|
+
const methods = [];
|
|
211
|
+
function peek() {
|
|
212
|
+
return input[pos] ?? "";
|
|
213
|
+
}
|
|
214
|
+
function advance() {
|
|
215
|
+
return input[pos++] ?? "";
|
|
216
|
+
}
|
|
217
|
+
function skipWhitespace() {
|
|
218
|
+
while (pos < input.length && (input[pos] === " " || input[pos] === " ")) {
|
|
219
|
+
pos++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function parseString() {
|
|
223
|
+
const quote = peek();
|
|
224
|
+
if (quote !== '"' && quote !== "'")
|
|
225
|
+
return null;
|
|
226
|
+
advance();
|
|
227
|
+
while (pos < input.length) {
|
|
228
|
+
const ch = input[pos];
|
|
229
|
+
if (ch === "\\") {
|
|
230
|
+
const next = input[pos + 1];
|
|
231
|
+
if (next === "'" || next === '"' || next === "\\") {
|
|
232
|
+
pos += 2;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
return { valid: false, reason: `Invalid escape sequence "\\${next ?? ""}" in string` };
|
|
236
|
+
}
|
|
237
|
+
if (ch === quote) {
|
|
238
|
+
advance();
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (ch.charCodeAt(0) < 32) {
|
|
242
|
+
return { valid: false, reason: "Control character in string" };
|
|
243
|
+
}
|
|
244
|
+
advance();
|
|
245
|
+
}
|
|
246
|
+
return { valid: false, reason: "Unterminated string" };
|
|
247
|
+
}
|
|
248
|
+
function parseNumber() {
|
|
249
|
+
const start = pos;
|
|
250
|
+
if (peek() === "-")
|
|
251
|
+
advance();
|
|
252
|
+
if (pos >= input.length || !/[0-9]/.test(peek())) {
|
|
253
|
+
pos = start;
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
if (peek() === "0" && pos + 1 < input.length && /[0-9]/.test(input[pos + 1])) {
|
|
257
|
+
return { valid: false, reason: "Leading zeros not allowed in numbers" };
|
|
258
|
+
}
|
|
259
|
+
while (pos < input.length && /[0-9]/.test(peek()))
|
|
260
|
+
advance();
|
|
261
|
+
if (peek() === ".") {
|
|
262
|
+
advance();
|
|
263
|
+
if (!/[0-9]/.test(peek())) {
|
|
264
|
+
return { valid: false, reason: "Invalid number: expected digit after decimal point" };
|
|
265
|
+
}
|
|
266
|
+
while (pos < input.length && /[0-9]/.test(peek()))
|
|
267
|
+
advance();
|
|
268
|
+
}
|
|
269
|
+
if (peek() === "e" || peek() === "E") {
|
|
270
|
+
advance();
|
|
271
|
+
if (peek() === "-")
|
|
272
|
+
advance();
|
|
273
|
+
if (peek() === "+") {
|
|
274
|
+
return { valid: false, reason: 'Invalid number: "+" not allowed in exponent' };
|
|
275
|
+
}
|
|
276
|
+
if (!/[0-9]/.test(peek())) {
|
|
277
|
+
return { valid: false, reason: "Invalid number: expected digit in exponent" };
|
|
278
|
+
}
|
|
279
|
+
while (pos < input.length && /[0-9]/.test(peek()))
|
|
280
|
+
advance();
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
function parseRegex() {
|
|
285
|
+
advance();
|
|
286
|
+
if (peek() === "/" || peek() === "*") {
|
|
287
|
+
return { valid: false, reason: "Empty or comment-like regex pattern" };
|
|
288
|
+
}
|
|
289
|
+
let inCharClass = false;
|
|
290
|
+
while (pos < input.length) {
|
|
291
|
+
const ch = input[pos];
|
|
292
|
+
if (ch === "\\") {
|
|
293
|
+
if (pos + 1 >= input.length) {
|
|
294
|
+
return { valid: false, reason: "Unterminated escape in regex" };
|
|
295
|
+
}
|
|
296
|
+
pos += 2;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (ch === "[" && !inCharClass) {
|
|
300
|
+
inCharClass = true;
|
|
301
|
+
pos++;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (ch === "]" && inCharClass) {
|
|
305
|
+
inCharClass = false;
|
|
306
|
+
pos++;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (ch === "/" && !inCharClass) {
|
|
310
|
+
advance();
|
|
311
|
+
while (pos < input.length && /[gimsuy]/.test(peek())) {
|
|
312
|
+
advance();
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
if (ch.charCodeAt(0) < 32 && ch !== " ") {
|
|
317
|
+
return { valid: false, reason: "Control character in regex" };
|
|
318
|
+
}
|
|
319
|
+
pos++;
|
|
320
|
+
}
|
|
321
|
+
return { valid: false, reason: "Unterminated regex literal" };
|
|
322
|
+
}
|
|
323
|
+
function parseObjectKey() {
|
|
324
|
+
skipWhitespace();
|
|
325
|
+
const ch = peek();
|
|
326
|
+
if (ch === '"' || ch === "'") {
|
|
327
|
+
return parseString();
|
|
328
|
+
}
|
|
329
|
+
if (/[a-zA-Z_]/.test(ch)) {
|
|
330
|
+
while (pos < input.length && /[a-zA-Z0-9_]/.test(peek())) {
|
|
331
|
+
advance();
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
return { valid: false, reason: "Expected object key (identifier or string)" };
|
|
336
|
+
}
|
|
337
|
+
function parseArg() {
|
|
338
|
+
skipWhitespace();
|
|
339
|
+
const ch = peek();
|
|
340
|
+
if (ch === "}") {
|
|
341
|
+
return { valid: false, reason: 'Unexpected "}" in directive args' };
|
|
342
|
+
}
|
|
343
|
+
if (ch === "`") {
|
|
344
|
+
return { valid: false, reason: "Template literals not allowed in directive args" };
|
|
345
|
+
}
|
|
346
|
+
if (ch !== "" && ch.charCodeAt(0) < 32) {
|
|
347
|
+
return { valid: false, reason: "Control character not allowed outside strings" };
|
|
348
|
+
}
|
|
349
|
+
if (ch === '"' || ch === "'") {
|
|
350
|
+
return parseString();
|
|
351
|
+
}
|
|
352
|
+
if (ch === "/") {
|
|
353
|
+
return parseRegex();
|
|
354
|
+
}
|
|
355
|
+
if (ch === "{") {
|
|
356
|
+
advance();
|
|
357
|
+
skipWhitespace();
|
|
358
|
+
if (peek() === "}") {
|
|
359
|
+
advance();
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const keyErr = parseObjectKey();
|
|
363
|
+
if (keyErr)
|
|
364
|
+
return keyErr;
|
|
365
|
+
skipWhitespace();
|
|
366
|
+
if (peek() !== ":") {
|
|
367
|
+
return { valid: false, reason: 'Expected ":" after object key' };
|
|
368
|
+
}
|
|
369
|
+
advance();
|
|
370
|
+
const valErr = parseArg();
|
|
371
|
+
if (valErr)
|
|
372
|
+
return valErr;
|
|
373
|
+
skipWhitespace();
|
|
374
|
+
while (peek() === ",") {
|
|
375
|
+
advance();
|
|
376
|
+
skipWhitespace();
|
|
377
|
+
if (peek() === "}")
|
|
378
|
+
break;
|
|
379
|
+
const nextKeyErr = parseObjectKey();
|
|
380
|
+
if (nextKeyErr)
|
|
381
|
+
return nextKeyErr;
|
|
382
|
+
skipWhitespace();
|
|
383
|
+
if (peek() !== ":") {
|
|
384
|
+
return { valid: false, reason: 'Expected ":" after object key' };
|
|
385
|
+
}
|
|
386
|
+
advance();
|
|
387
|
+
const nextValErr = parseArg();
|
|
388
|
+
if (nextValErr)
|
|
389
|
+
return nextValErr;
|
|
390
|
+
skipWhitespace();
|
|
391
|
+
}
|
|
392
|
+
if (peek() !== "}") {
|
|
393
|
+
return { valid: false, reason: 'Expected "}" to close object' };
|
|
394
|
+
}
|
|
395
|
+
advance();
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
if (ch === "[") {
|
|
399
|
+
advance();
|
|
400
|
+
skipWhitespace();
|
|
401
|
+
if (peek() === "]") {
|
|
402
|
+
advance();
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
const firstErr = parseArg();
|
|
406
|
+
if (firstErr)
|
|
407
|
+
return firstErr;
|
|
408
|
+
skipWhitespace();
|
|
409
|
+
while (peek() === ",") {
|
|
410
|
+
advance();
|
|
411
|
+
skipWhitespace();
|
|
412
|
+
if (peek() === "]")
|
|
413
|
+
break;
|
|
414
|
+
const elemErr = parseArg();
|
|
415
|
+
if (elemErr)
|
|
416
|
+
return elemErr;
|
|
417
|
+
skipWhitespace();
|
|
418
|
+
}
|
|
419
|
+
if (peek() !== "]") {
|
|
420
|
+
return { valid: false, reason: 'Expected "]" to close array' };
|
|
421
|
+
}
|
|
422
|
+
advance();
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
if (ch === "-" || /[0-9]/.test(ch)) {
|
|
426
|
+
return parseNumber();
|
|
427
|
+
}
|
|
428
|
+
if (input.startsWith("true", pos)) {
|
|
429
|
+
const after = input[pos + 4];
|
|
430
|
+
if (!after || !/[a-zA-Z0-9_]/.test(after)) {
|
|
431
|
+
pos += 4;
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (input.startsWith("false", pos)) {
|
|
436
|
+
const after = input[pos + 5];
|
|
437
|
+
if (!after || !/[a-zA-Z0-9_]/.test(after)) {
|
|
438
|
+
pos += 5;
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (input.startsWith("NaN", pos)) {
|
|
443
|
+
return { valid: false, reason: "NaN not allowed" };
|
|
444
|
+
}
|
|
445
|
+
if (input.startsWith("Infinity", pos)) {
|
|
446
|
+
return { valid: false, reason: "Infinity not allowed" };
|
|
447
|
+
}
|
|
448
|
+
if (input.startsWith("null", pos)) {
|
|
449
|
+
return { valid: false, reason: "null not allowed as argument value" };
|
|
450
|
+
}
|
|
451
|
+
if (ch === "+") {
|
|
452
|
+
return { valid: false, reason: '"+" prefix not allowed on numbers' };
|
|
453
|
+
}
|
|
454
|
+
if (/[a-zA-Z_]/.test(ch)) {
|
|
455
|
+
return { valid: false, reason: "Identifiers not allowed as argument values" };
|
|
456
|
+
}
|
|
457
|
+
return { valid: false, reason: `Unexpected character "${ch}"` };
|
|
458
|
+
}
|
|
459
|
+
while (pos < input.length) {
|
|
460
|
+
skipWhitespace();
|
|
461
|
+
if (pos >= input.length)
|
|
462
|
+
break;
|
|
463
|
+
if (peek() !== ".") {
|
|
464
|
+
return { valid: false, reason: `Expected "." at position ${pos}, got "${peek()}"` };
|
|
465
|
+
}
|
|
466
|
+
advance();
|
|
467
|
+
if (!/[a-zA-Z_]/.test(peek())) {
|
|
468
|
+
return { valid: false, reason: `Expected method name after "." at position ${pos}` };
|
|
469
|
+
}
|
|
470
|
+
let ident = "";
|
|
471
|
+
while (pos < input.length && /[a-zA-Z0-9_]/.test(peek())) {
|
|
472
|
+
ident += advance();
|
|
473
|
+
}
|
|
474
|
+
if (!ALLOWED_ZOD_METHODS.has(ident)) {
|
|
475
|
+
return { valid: false, reason: `Unknown zod method: ${ident}` };
|
|
476
|
+
}
|
|
477
|
+
skipWhitespace();
|
|
478
|
+
if (peek() !== "(") {
|
|
479
|
+
return { valid: false, reason: `Expected "(" after method "${ident}"` };
|
|
480
|
+
}
|
|
481
|
+
advance();
|
|
482
|
+
skipWhitespace();
|
|
483
|
+
let argCount = 0;
|
|
484
|
+
if (peek() !== ")") {
|
|
485
|
+
const argErr = parseArg();
|
|
486
|
+
if (argErr)
|
|
487
|
+
return argErr;
|
|
488
|
+
argCount = 1;
|
|
489
|
+
skipWhitespace();
|
|
490
|
+
while (peek() === ",") {
|
|
491
|
+
advance();
|
|
492
|
+
skipWhitespace();
|
|
493
|
+
if (peek() === ")")
|
|
494
|
+
break;
|
|
495
|
+
const nextArgErr = parseArg();
|
|
496
|
+
if (nextArgErr)
|
|
497
|
+
return nextArgErr;
|
|
498
|
+
argCount++;
|
|
499
|
+
skipWhitespace();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (peek() !== ")") {
|
|
503
|
+
return { valid: false, reason: `Expected ")" to close method "${ident}"` };
|
|
504
|
+
}
|
|
505
|
+
advance();
|
|
506
|
+
const arity = METHOD_ARITY[ident];
|
|
507
|
+
if (arity) {
|
|
508
|
+
const [minArgs, maxArgs] = arity;
|
|
509
|
+
if (argCount < minArgs || argCount > maxArgs) {
|
|
510
|
+
if (minArgs === maxArgs) {
|
|
511
|
+
return { valid: false, reason: `Method "${ident}" expects ${minArgs} argument(s), got ${argCount}` };
|
|
512
|
+
}
|
|
513
|
+
return { valid: false, reason: `Method "${ident}" expects ${minArgs}-${maxArgs} arguments, got ${argCount}` };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
methods.push(ident);
|
|
517
|
+
chainCount++;
|
|
518
|
+
if (chainCount > MAX_CHAIN_DEPTH) {
|
|
519
|
+
return { valid: false, reason: "Directive exceeds maximum chain depth" };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (chainCount === 0) {
|
|
523
|
+
return { valid: false, reason: "No method calls found" };
|
|
524
|
+
}
|
|
525
|
+
return { valid: true, methods };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/shared/scalar-base.ts
|
|
529
|
+
import { z } from "zod";
|
|
530
|
+
function isJsonSafe(value) {
|
|
531
|
+
const stack = [value];
|
|
532
|
+
while (stack.length > 0) {
|
|
533
|
+
const current = stack.pop();
|
|
534
|
+
if (current === void 0)
|
|
535
|
+
return false;
|
|
536
|
+
if (current === null)
|
|
537
|
+
continue;
|
|
538
|
+
switch (typeof current) {
|
|
539
|
+
case "string":
|
|
540
|
+
case "boolean":
|
|
541
|
+
continue;
|
|
542
|
+
case "number":
|
|
543
|
+
if (!Number.isFinite(current))
|
|
544
|
+
return false;
|
|
545
|
+
continue;
|
|
546
|
+
case "object": {
|
|
547
|
+
if (Array.isArray(current)) {
|
|
548
|
+
for (let i = 0; i < current.length; i++) {
|
|
549
|
+
stack.push(current[i]);
|
|
550
|
+
}
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const proto = Object.getPrototypeOf(current);
|
|
554
|
+
if (proto !== Object.prototype && proto !== null)
|
|
555
|
+
return false;
|
|
556
|
+
const values = Object.values(current);
|
|
557
|
+
for (let i = 0; i < values.length; i++) {
|
|
558
|
+
stack.push(values[i]);
|
|
559
|
+
}
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
default:
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
var SCALAR_BASE = {
|
|
569
|
+
String: () => z.string(),
|
|
570
|
+
Int: () => z.number().int(),
|
|
571
|
+
Float: () => z.number(),
|
|
572
|
+
Decimal: () => z.union([
|
|
573
|
+
z.number(),
|
|
574
|
+
z.string().refine(
|
|
575
|
+
(s) => /^-?(\d+\.?\d*|\.\d+)([eE]-?\d+)?$/.test(s),
|
|
576
|
+
"Invalid decimal string"
|
|
577
|
+
)
|
|
578
|
+
]),
|
|
579
|
+
BigInt: () => z.union([
|
|
580
|
+
z.bigint(),
|
|
581
|
+
z.number().int().refine(
|
|
582
|
+
(v) => v >= Number.MIN_SAFE_INTEGER && v <= Number.MAX_SAFE_INTEGER,
|
|
583
|
+
"Number exceeds safe integer range for BigInt conversion"
|
|
584
|
+
).transform((v) => BigInt(v)),
|
|
585
|
+
z.string().regex(/^-?\d+$/).transform((v) => BigInt(v))
|
|
586
|
+
]),
|
|
587
|
+
Boolean: () => z.boolean(),
|
|
588
|
+
DateTime: () => z.union([
|
|
589
|
+
z.date(),
|
|
590
|
+
z.string().datetime({ offset: true }),
|
|
591
|
+
z.string().datetime()
|
|
592
|
+
]).pipe(z.coerce.date()),
|
|
593
|
+
Json: () => z.unknown().refine(isJsonSafe, "Value must be JSON-serializable (no undefined, functions, symbols, class instances, NaN, or Infinity)"),
|
|
594
|
+
Bytes: () => z.union([
|
|
595
|
+
z.string(),
|
|
596
|
+
z.custom((v) => v instanceof Uint8Array)
|
|
597
|
+
])
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// src/generator/emit-zod-chains.ts
|
|
601
|
+
function buildGenerationBase(fieldType, isList, isEnum, enumValues) {
|
|
602
|
+
let base;
|
|
603
|
+
if (isEnum) {
|
|
604
|
+
const values = enumValues && enumValues.length > 0 ? enumValues : ["__placeholder__"];
|
|
605
|
+
base = z2.enum(values);
|
|
606
|
+
} else {
|
|
607
|
+
const factory = SCALAR_BASE[fieldType];
|
|
608
|
+
if (!factory)
|
|
609
|
+
return null;
|
|
610
|
+
base = factory();
|
|
611
|
+
}
|
|
612
|
+
if (isList)
|
|
613
|
+
base = z2.array(base);
|
|
614
|
+
return base;
|
|
615
|
+
}
|
|
616
|
+
function checkChainCompatibility(fieldType, isList, isEnum, enumValues, methods) {
|
|
617
|
+
const base = buildGenerationBase(fieldType, isList, isEnum, enumValues);
|
|
618
|
+
if (!base)
|
|
619
|
+
return null;
|
|
620
|
+
for (const method of methods) {
|
|
621
|
+
if (typeof base[method] !== "function") {
|
|
622
|
+
return method;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
function findZodInDoc(documentation) {
|
|
628
|
+
return documentation.split("\n").filter((line) => {
|
|
629
|
+
const trimmed = line.trim();
|
|
630
|
+
return /^@zod(?:\s|$|\.)/.test(trimmed);
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
function emitZodChains(dmmf, onInvalidZod) {
|
|
634
|
+
const enumNames = new Set(dmmf.datamodel.enums.map((e) => e.name));
|
|
635
|
+
const enumValues = {};
|
|
636
|
+
for (const e of dmmf.datamodel.enums) {
|
|
637
|
+
enumValues[e.name] = e.values.map((v) => v.name);
|
|
638
|
+
}
|
|
639
|
+
const modelChains = {};
|
|
640
|
+
for (const model of dmmf.datamodel.models) {
|
|
641
|
+
for (const field of model.fields) {
|
|
642
|
+
if (!field.documentation)
|
|
643
|
+
continue;
|
|
644
|
+
const zodLines = findZodInDoc(field.documentation);
|
|
645
|
+
if (zodLines.length === 0)
|
|
646
|
+
continue;
|
|
647
|
+
if (zodLines.length > 1) {
|
|
648
|
+
const msg = `prisma-guard: Multiple @zod directives on ${model.name}.${field.name}. Only one @zod per field allowed.`;
|
|
649
|
+
if (onInvalidZod === "error") {
|
|
650
|
+
throw new Error(msg);
|
|
651
|
+
}
|
|
652
|
+
console.warn(msg);
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
const line = zodLines[0];
|
|
656
|
+
const idx = line.indexOf("@zod");
|
|
657
|
+
const chainStr = line.slice(idx + 4).trim();
|
|
658
|
+
if (chainStr.length === 0) {
|
|
659
|
+
const msg = `prisma-guard: Empty @zod directive on ${model.name}.${field.name}. Add a method chain (e.g. @zod .min(1)) or remove the directive.`;
|
|
660
|
+
if (onInvalidZod === "error") {
|
|
661
|
+
throw new Error(msg);
|
|
662
|
+
}
|
|
663
|
+
console.warn(msg);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
const result = validateDirective(chainStr);
|
|
667
|
+
if (!result.valid) {
|
|
668
|
+
const msg = `prisma-guard: Invalid @zod directive on ${model.name}.${field.name}: ${result.reason}`;
|
|
669
|
+
if (onInvalidZod === "error") {
|
|
670
|
+
throw new Error(msg);
|
|
671
|
+
}
|
|
672
|
+
console.warn(msg);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const isEnum = enumNames.has(field.type);
|
|
676
|
+
const incompatible = checkChainCompatibility(
|
|
677
|
+
field.type,
|
|
678
|
+
field.isList,
|
|
679
|
+
isEnum,
|
|
680
|
+
isEnum ? enumValues[field.type] : void 0,
|
|
681
|
+
result.methods
|
|
682
|
+
);
|
|
683
|
+
if (incompatible) {
|
|
684
|
+
const msg = `prisma-guard: @zod method "${incompatible}" on ${model.name}.${field.name} is not compatible with type "${field.type}"${field.isList ? "[]" : ""}`;
|
|
685
|
+
if (onInvalidZod === "error") {
|
|
686
|
+
throw new Error(msg);
|
|
687
|
+
}
|
|
688
|
+
console.warn(msg);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (!modelChains[model.name])
|
|
692
|
+
modelChains[model.name] = {};
|
|
693
|
+
modelChains[model.name][field.name] = chainStr;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const hasChains = Object.keys(modelChains).length > 0;
|
|
697
|
+
if (!hasChains) {
|
|
698
|
+
return { source: "export const ZOD_CHAINS = {}\n", hasChains: false };
|
|
699
|
+
}
|
|
700
|
+
const entries = Object.entries(modelChains).map(([model, fields]) => {
|
|
701
|
+
const fieldEntries = Object.entries(fields).map(([field, chain]) => ` ${JSON.stringify(field)}: (base: any) => base${chain},`).join("\n");
|
|
702
|
+
return ` ${JSON.stringify(model)}: {
|
|
703
|
+
${fieldEntries}
|
|
704
|
+
},`;
|
|
705
|
+
}).join("\n");
|
|
706
|
+
return {
|
|
707
|
+
source: `export const ZOD_CHAINS = {
|
|
708
|
+
${entries}
|
|
709
|
+
}
|
|
710
|
+
`,
|
|
711
|
+
hasChains: true
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// src/generator/emit-type-map.ts
|
|
716
|
+
var SKIP_FIELD_KINDS = /* @__PURE__ */ new Set(["unsupported"]);
|
|
717
|
+
function collectUniqueConstraints(model) {
|
|
718
|
+
const seen = /* @__PURE__ */ new Set();
|
|
719
|
+
const constraints = [];
|
|
720
|
+
function add(fields) {
|
|
721
|
+
const key = fields.join("\0");
|
|
722
|
+
if (seen.has(key))
|
|
723
|
+
return;
|
|
724
|
+
seen.add(key);
|
|
725
|
+
constraints.push(fields);
|
|
726
|
+
}
|
|
727
|
+
for (const field of model.fields) {
|
|
728
|
+
if (field.isId)
|
|
729
|
+
add([field.name]);
|
|
730
|
+
}
|
|
731
|
+
if (model.primaryKey) {
|
|
732
|
+
add([...model.primaryKey.fields]);
|
|
733
|
+
}
|
|
734
|
+
for (const field of model.fields) {
|
|
735
|
+
if (field.isUnique)
|
|
736
|
+
add([field.name]);
|
|
737
|
+
}
|
|
738
|
+
for (const fields of model.uniqueFields) {
|
|
739
|
+
add([...fields]);
|
|
740
|
+
}
|
|
741
|
+
return constraints;
|
|
742
|
+
}
|
|
743
|
+
function emitTypeMap(dmmf) {
|
|
744
|
+
const enumNames = new Set(dmmf.datamodel.enums.map((e) => e.name));
|
|
745
|
+
for (const e of dmmf.datamodel.enums) {
|
|
746
|
+
if (e.values.length === 0) {
|
|
747
|
+
throw new Error(`prisma-guard: Enum "${e.name}" has zero values.`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const modelEntries = dmmf.datamodel.models.map((model) => {
|
|
751
|
+
const fieldEntries = model.fields.filter((field) => !SKIP_FIELD_KINDS.has(field.kind)).map((field) => {
|
|
752
|
+
const isRelation = field.kind === "object" || field.relationName != null;
|
|
753
|
+
const isEnum = enumNames.has(field.type);
|
|
754
|
+
const meta = [
|
|
755
|
+
`type: ${JSON.stringify(field.type)}`,
|
|
756
|
+
`isList: ${field.isList}`,
|
|
757
|
+
`isRequired: ${field.isRequired}`,
|
|
758
|
+
`isId: ${field.isId}`,
|
|
759
|
+
`isRelation: ${isRelation}`,
|
|
760
|
+
`hasDefault: ${field.hasDefaultValue}`,
|
|
761
|
+
`isUpdatedAt: ${field.isUpdatedAt}`
|
|
762
|
+
];
|
|
763
|
+
if (isEnum)
|
|
764
|
+
meta.push(`isEnum: true`);
|
|
765
|
+
if (field.isUnique)
|
|
766
|
+
meta.push(`isUnique: true`);
|
|
767
|
+
return ` ${JSON.stringify(field.name)}: { ${meta.join(", ")} },`;
|
|
768
|
+
}).join("\n");
|
|
769
|
+
return ` ${JSON.stringify(model.name)}: {
|
|
770
|
+
${fieldEntries}
|
|
771
|
+
},`;
|
|
772
|
+
}).join("\n");
|
|
773
|
+
const enumEntries = dmmf.datamodel.enums.map((e) => {
|
|
774
|
+
const values = e.values.map((v) => JSON.stringify(v.name)).join(", ");
|
|
775
|
+
return ` ${JSON.stringify(e.name)}: [${values}],`;
|
|
776
|
+
}).join("\n");
|
|
777
|
+
const uniqueMapEntries = dmmf.datamodel.models.map((model) => {
|
|
778
|
+
const constraints = collectUniqueConstraints(model);
|
|
779
|
+
if (constraints.length === 0)
|
|
780
|
+
return null;
|
|
781
|
+
const constraintsStr = constraints.map((c) => `[${c.map((f) => JSON.stringify(f)).join(", ")}]`).join(", ");
|
|
782
|
+
return ` ${JSON.stringify(model.name)}: [${constraintsStr}],`;
|
|
783
|
+
}).filter(Boolean).join("\n");
|
|
784
|
+
const typeMapSource = `export const TYPE_MAP = {
|
|
785
|
+
${modelEntries}
|
|
786
|
+
} as const
|
|
787
|
+
`;
|
|
788
|
+
const enumMapSource = `export const ENUM_MAP = {
|
|
789
|
+
${enumEntries}
|
|
790
|
+
} as const
|
|
791
|
+
`;
|
|
792
|
+
const uniqueMapSource = `export const UNIQUE_MAP = {
|
|
793
|
+
${uniqueMapEntries}
|
|
794
|
+
} as const
|
|
795
|
+
`;
|
|
796
|
+
const typesSource = [
|
|
797
|
+
`export type ModelName = keyof typeof TYPE_MAP`,
|
|
798
|
+
`export type FieldName<M extends ModelName> = keyof (typeof TYPE_MAP)[M]`
|
|
799
|
+
].join("\n");
|
|
800
|
+
return `${typeMapSource}
|
|
801
|
+
${enumMapSource}
|
|
802
|
+
${uniqueMapSource}
|
|
803
|
+
${typesSource}
|
|
804
|
+
`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/generator/emit-client.ts
|
|
808
|
+
function toDelegateKey(modelName) {
|
|
809
|
+
return modelName[0].toLowerCase() + modelName.slice(1);
|
|
810
|
+
}
|
|
811
|
+
function emitClient(dmmf) {
|
|
812
|
+
const modelEntries = dmmf.datamodel.models.map((model) => {
|
|
813
|
+
const key = toDelegateKey(model.name);
|
|
814
|
+
return ` ${key}: {
|
|
815
|
+
guard(input: GuardInput): GuardedModel<PrismaClient['${key}']>
|
|
816
|
+
}`;
|
|
817
|
+
}).join("\n");
|
|
818
|
+
return `import type { PrismaClient } from '@prisma/client'
|
|
819
|
+
import type { GuardInput, GuardedModel } from 'prisma-guard'
|
|
820
|
+
import { createGuard } from 'prisma-guard'
|
|
821
|
+
import { SCOPE_MAP, TYPE_MAP, ENUM_MAP, ZOD_CHAINS, GUARD_CONFIG, UNIQUE_MAP } from './index.js'
|
|
822
|
+
import type { ScopeRoot } from './index.js'
|
|
823
|
+
|
|
824
|
+
interface GuardModelExtension {
|
|
825
|
+
${modelEntries}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export const guard = createGuard<typeof TYPE_MAP, ScopeRoot, GuardModelExtension>({
|
|
829
|
+
scopeMap: SCOPE_MAP,
|
|
830
|
+
typeMap: TYPE_MAP,
|
|
831
|
+
enumMap: ENUM_MAP,
|
|
832
|
+
zodChains: ZOD_CHAINS,
|
|
833
|
+
guardConfig: GUARD_CONFIG,
|
|
834
|
+
uniqueMap: UNIQUE_MAP,
|
|
835
|
+
})
|
|
836
|
+
`;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// src/generator/index.ts
|
|
840
|
+
var { generatorHandler } = pkg;
|
|
841
|
+
var VALID_ON_INVALID_ZOD = /* @__PURE__ */ new Set(["error", "warn"]);
|
|
842
|
+
var VALID_ON_AMBIGUOUS_SCOPE = /* @__PURE__ */ new Set(["error", "warn", "ignore"]);
|
|
843
|
+
var VALID_ON_MISSING_SCOPE_CONTEXT = /* @__PURE__ */ new Set(["error", "warn", "ignore"]);
|
|
844
|
+
var VALID_FIND_UNIQUE_MODE = /* @__PURE__ */ new Set(["verify", "reject"]);
|
|
845
|
+
var VALID_ON_SCOPE_RELATION_WRITE = /* @__PURE__ */ new Set(["error", "warn", "strip"]);
|
|
846
|
+
function validateConfigEnum(name, value, allowed) {
|
|
847
|
+
if (!allowed.has(value)) {
|
|
848
|
+
throw new Error(
|
|
849
|
+
`prisma-guard: Invalid generator config "${name}": "${value}". Allowed values: ${[...allowed].join(", ")}`
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
return value;
|
|
853
|
+
}
|
|
854
|
+
generatorHandler({
|
|
855
|
+
onManifest() {
|
|
856
|
+
return {
|
|
857
|
+
prettyName: "Prisma Guard",
|
|
858
|
+
defaultOutput: "generated/guard"
|
|
859
|
+
};
|
|
860
|
+
},
|
|
861
|
+
async onGenerate(options) {
|
|
862
|
+
const output = options.generator.output?.value;
|
|
863
|
+
if (!output)
|
|
864
|
+
throw new Error("prisma-guard: No output directory specified");
|
|
865
|
+
const config = options.generator.config ?? {};
|
|
866
|
+
const onInvalidZod = validateConfigEnum("onInvalidZod", config.onInvalidZod ?? "error", VALID_ON_INVALID_ZOD);
|
|
867
|
+
const onAmbiguousScope = validateConfigEnum("onAmbiguousScope", config.onAmbiguousScope ?? "error", VALID_ON_AMBIGUOUS_SCOPE);
|
|
868
|
+
const onMissingScopeContext = validateConfigEnum("onMissingScopeContext", config.onMissingScopeContext ?? "error", VALID_ON_MISSING_SCOPE_CONTEXT);
|
|
869
|
+
const findUniqueMode = validateConfigEnum("findUniqueMode", config.findUniqueMode ?? "reject", VALID_FIND_UNIQUE_MODE);
|
|
870
|
+
const onScopeRelationWrite = validateConfigEnum("onScopeRelationWrite", config.onScopeRelationWrite ?? "error", VALID_ON_SCOPE_RELATION_WRITE);
|
|
871
|
+
const dmmf = options.dmmf;
|
|
872
|
+
const parts = [];
|
|
873
|
+
parts.push(
|
|
874
|
+
`export const GUARD_CONFIG = {
|
|
875
|
+
onMissingScopeContext: ${JSON.stringify(onMissingScopeContext)},
|
|
876
|
+
findUniqueMode: ${JSON.stringify(findUniqueMode)},
|
|
877
|
+
onScopeRelationWrite: ${JSON.stringify(onScopeRelationWrite)},
|
|
878
|
+
} as const
|
|
879
|
+
`
|
|
880
|
+
);
|
|
881
|
+
const { source: scopeSource } = emitScopeMap(dmmf, onAmbiguousScope);
|
|
882
|
+
parts.push(scopeSource);
|
|
883
|
+
const typeMapSource = emitTypeMap(dmmf);
|
|
884
|
+
parts.push(typeMapSource);
|
|
885
|
+
const { source: zodChainsSource } = emitZodChains(dmmf, onInvalidZod);
|
|
886
|
+
parts.push(zodChainsSource);
|
|
887
|
+
mkdirSync(output, { recursive: true });
|
|
888
|
+
writeFileSync(join(output, "index.ts"), parts.join("\n"), "utf-8");
|
|
889
|
+
const clientSource = emitClient(dmmf);
|
|
890
|
+
writeFileSync(join(output, "client.ts"), clientSource, "utf-8");
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
//# sourceMappingURL=index.js.map
|