prisma-guard 1.24.0 → 1.26.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 CHANGED
@@ -453,6 +453,67 @@ take: { max: 100, default: 25 } // explicit max and default
453
453
  take: { max: 100 } // max only, no default
454
454
  ```
455
455
 
456
+ ### Unique where shapes
457
+
458
+ `findUnique`, `findUniqueOrThrow`, `update`, `delete`, and `upsert` use Prisma's `WhereUniqueInput` syntax. For these methods, unique fields are configured directly in the shape:
459
+ ```ts
460
+ await prisma.project
461
+ .guard({
462
+ where: { id: true },
463
+ })
464
+ .update({
465
+ data: { title: 'Updated' },
466
+ where: { id: 'abc123' },
467
+ })
468
+ ```
469
+
470
+ Do not use filter operator objects such as `{ id: { equals: true } }` in unique where shapes. That syntax belongs to normal `WhereInput` filters used by methods such as `findMany`, `findFirst`, `count`, `updateMany`, and `deleteMany`.
471
+
472
+ For compound unique constraints, use Prisma's generated compound selector name:
473
+ ```prisma
474
+ model ProjectMember {
475
+ tenantId String
476
+ userId String
477
+
478
+ @@unique([tenantId, userId])
479
+ }
480
+ ```
481
+
482
+ ```ts
483
+ await prisma.projectMember
484
+ .guard({
485
+ where: {
486
+ tenantId_userId: {
487
+ tenantId: true,
488
+ userId: true,
489
+ },
490
+ },
491
+ })
492
+ .update({
493
+ data: req.body.data,
494
+ where: {
495
+ tenantId_userId: {
496
+ tenantId: 'tenant_1',
497
+ userId: 'user_1',
498
+ },
499
+ },
500
+ })
501
+ ```
502
+
503
+ Named compound constraints use the configured name as the selector:
504
+ ```prisma
505
+ @@unique([tenantId, slug], name: "project_slug_per_tenant")
506
+ ```
507
+
508
+ ```ts
509
+ where: {
510
+ project_slug_per_tenant: {
511
+ tenantId: true,
512
+ slug: true,
513
+ },
514
+ }
515
+ ```
516
+
456
517
  ### Creates
457
518
  ```ts
458
519
  await prisma.project
@@ -473,15 +534,15 @@ Fields with `@zod .default(...)` or `@zod .catch(...)` that are omitted from the
473
534
  await prisma.project
474
535
  .guard({
475
536
  data: { title: true },
476
- where: { id: { equals: true } },
537
+ where: { id: true },
477
538
  })
478
539
  .update({
479
540
  data: { title: 'New title' },
480
- where: { id: { equals: 'abc123' } },
541
+ where: { id: 'abc123' },
481
542
  })
482
543
  ```
483
544
 
484
- In update mode, all `data` fields are optional. The `where` shape enforces which filters the client can use.
545
+ In update mode, all `data` fields are optional. The `where` shape must use Prisma unique selector syntax for `update`.
485
546
 
486
547
  ### Forced values
487
548
 
@@ -519,12 +580,12 @@ Forced where conditions are conflict-checked during shape construction. If the s
519
580
  ```ts
520
581
  await prisma.project
521
582
  .guard({
522
- where: { id: { equals: true } },
583
+ where: { id: true },
523
584
  })
524
- .delete({ where: { id: { equals: 'abc123' } } })
585
+ .delete({ where: { id: 'abc123' } })
525
586
  ```
526
587
 
527
- `data` is not valid for delete shapes.
588
+ `data` is not valid for delete shapes. The `where` shape must use Prisma unique selector syntax for `delete`.
528
589
 
529
590
  ### Batch creates
530
591
  ```ts
@@ -689,6 +750,7 @@ await prisma.post
689
750
  disconnect: { id: true },
690
751
  },
691
752
  },
753
+ where: { id: true },
692
754
  })
693
755
  .update({
694
756
  data: {
@@ -698,7 +760,7 @@ await prisma.post
698
760
  disconnect: [{ id: 'tag3' }],
699
761
  },
700
762
  },
701
- where: { id: { equals: 'post1' } },
763
+ where: { id: 'post1' },
702
764
  })
703
765
  ```
704
766
 
@@ -736,6 +798,7 @@ await prisma.user
736
798
  },
737
799
  },
738
800
  },
801
+ where: { id: true },
739
802
  })
740
803
  .update({
741
804
  data: {
@@ -745,7 +808,7 @@ await prisma.user
745
808
  connect: [{ id: 'existing-post-id' }],
746
809
  },
747
810
  },
748
- where: { id: { equals: userId } },
811
+ where: { id: userId },
749
812
  })
750
813
  ```
751
814
 
@@ -1069,7 +1132,7 @@ await prisma.project
1069
1132
  await prisma.project
1070
1133
  .guard({
1071
1134
  data: { title: true },
1072
- where: { id: { equals: true } },
1135
+ where: { id: true },
1073
1136
  select: {
1074
1137
  id: true,
1075
1138
  title: true,
@@ -1080,7 +1143,7 @@ await prisma.project
1080
1143
  })
1081
1144
  .update({
1082
1145
  data: { title: 'Updated' },
1083
- where: { id: { equals: 'abc123' } },
1146
+ where: { id: 'abc123' },
1084
1147
  select: {
1085
1148
  id: true,
1086
1149
  title: true,
@@ -1095,11 +1158,11 @@ await prisma.project
1095
1158
  ```ts
1096
1159
  await prisma.project
1097
1160
  .guard({
1098
- where: { id: { equals: true } },
1161
+ where: { id: true },
1099
1162
  include: { members: true },
1100
1163
  })
1101
1164
  .delete({
1102
- where: { id: { equals: 'abc123' } },
1165
+ where: { id: 'abc123' },
1103
1166
  include: { members: true },
1104
1167
  })
1105
1168
  ```
@@ -1197,12 +1260,12 @@ Upsert is supported with dedicated `create` and `update` shape keys that mirror
1197
1260
  ```ts
1198
1261
  await prisma.project
1199
1262
  .guard({
1200
- where: { id: { equals: true } },
1263
+ where: { id: true },
1201
1264
  create: { title: true, status: true },
1202
1265
  update: { title: true },
1203
1266
  })
1204
1267
  .upsert({
1205
- where: { id: { equals: 'abc123' } },
1268
+ where: { id: 'abc123' },
1206
1269
  create: { title: 'New Project', status: 'active' },
1207
1270
  update: { title: 'Updated Title' },
1208
1271
  })
@@ -1214,7 +1277,7 @@ Upsert shapes must define all three: `where`, `create`, and `update`. Missing an
1214
1277
 
1215
1278
  The `create` branch follows the same rules as regular create shapes: all required fields without defaults must be accounted for (as client-allowed, forced, scope FK, or `@zod .default(...)`/`@zod .catch(...)`). The `update` branch follows update rules: all fields are optional.
1216
1279
 
1217
- The `where` must satisfy a unique constraint with equality operators, same as `update` and `delete`.
1280
+ The `where` must satisfy a unique constraint using Prisma unique selector syntax, same as `update` and `delete`. Filter operator objects such as `{ id: { equals: true } }` are rejected in unique where shapes.
1218
1281
 
1219
1282
  ### All data shape value types work
1220
1283
  ```ts
@@ -1222,7 +1285,7 @@ import { force } from 'prisma-guard'
1222
1285
 
1223
1286
  await prisma.project
1224
1287
  .guard({
1225
- where: { id: { equals: true } },
1288
+ where: { id: true },
1226
1289
  create: {
1227
1290
  title: (base) => base.min(1).max(200),
1228
1291
  status: 'draft',
@@ -1233,7 +1296,7 @@ await prisma.project
1233
1296
  },
1234
1297
  })
1235
1298
  .upsert({
1236
- where: { id: { equals: 'abc123' } },
1299
+ where: { id: 'abc123' },
1237
1300
  create: { title: 'New Project' },
1238
1301
  update: { title: 'Updated' },
1239
1302
  })
@@ -1245,13 +1308,13 @@ Upsert returns a record and supports `select` and `include`:
1245
1308
  ```ts
1246
1309
  await prisma.project
1247
1310
  .guard({
1248
- where: { id: { equals: true } },
1311
+ where: { id: true },
1249
1312
  create: { title: true, status: true },
1250
1313
  update: { title: true },
1251
1314
  select: { id: true, title: true, status: true },
1252
1315
  })
1253
1316
  .upsert({
1254
- where: { id: { equals: 'abc123' } },
1317
+ where: { id: 'abc123' },
1255
1318
  create: { title: 'New', status: 'active' },
1256
1319
  update: { title: 'Updated' },
1257
1320
  select: { id: true, title: true },
@@ -1274,18 +1337,18 @@ Upsert works with named shapes and context-dependent shapes:
1274
1337
  await prisma.project
1275
1338
  .guard({
1276
1339
  '/admin/projects/:id': {
1277
- where: { id: { equals: true } },
1340
+ where: { id: true },
1278
1341
  create: { title: true, status: true, priority: true },
1279
1342
  update: { title: true, status: true, priority: true },
1280
1343
  },
1281
1344
  '/editor/projects/:id': {
1282
- where: { id: { equals: true } },
1345
+ where: { id: true },
1283
1346
  create: { title: true, status: 'draft' },
1284
1347
  update: { title: true },
1285
1348
  },
1286
1349
  }, req.headers['x-caller'])
1287
1350
  .upsert({
1288
- where: { id: { equals: req.params.id } },
1351
+ where: { id: req.params.id },
1289
1352
  create: req.body.create,
1290
1353
  update: req.body.update,
1291
1354
  })
@@ -1339,16 +1402,16 @@ await prisma.project
1339
1402
  .guard({
1340
1403
  '/admin/projects/:id': {
1341
1404
  data: { title: true, status: true, priority: true },
1342
- where: { id: { equals: true } },
1405
+ where: { id: true },
1343
1406
  },
1344
1407
  '/editor/projects/:id': {
1345
1408
  data: { title: true },
1346
- where: { id: { equals: true } },
1409
+ where: { id: true },
1347
1410
  },
1348
1411
  }, req.headers['x-caller'])
1349
1412
  .update({
1350
1413
  data: req.body.data,
1351
- where: { id: { equals: req.params.id } },
1414
+ where: { id: req.params.id },
1352
1415
  })
1353
1416
  ```
1354
1417
 
@@ -1605,20 +1668,41 @@ If this behavior is not what you want, restructure your schema so the model refe
1605
1668
 
1606
1669
  ## findUnique behavior
1607
1670
 
1608
- Prisma `findUnique` only accepts declared unique selectors.
1671
+ Prisma `findUnique` and `findUniqueOrThrow` only accept declared unique selectors.
1609
1672
 
1610
- This is valid:
1673
+ This is valid Prisma unique selector syntax:
1611
1674
  ```ts
1612
1675
  await prisma.project.findUnique({
1613
- where: { id: { equals: projectId } },
1676
+ where: { id: projectId },
1614
1677
  })
1615
1678
  ```
1616
1679
 
1617
- This is not generally valid unless declared as a composite unique:
1680
+ For compound unique constraints, Prisma uses a named selector object:
1681
+ ```prisma
1682
+ model Project {
1683
+ tenantId String
1684
+ slug String
1685
+
1686
+ @@unique([tenantId, slug])
1687
+ }
1688
+ ```
1689
+
1690
+ ```ts
1691
+ await prisma.project.findUnique({
1692
+ where: {
1693
+ tenantId_slug: {
1694
+ tenantId,
1695
+ slug,
1696
+ },
1697
+ },
1698
+ })
1699
+ ```
1700
+
1701
+ This flat where object is not valid for `findUnique` unless your Prisma schema declares a matching compound selector with that exact shape:
1618
1702
  ```ts
1619
1703
  where: {
1620
- id: { equals: projectId },
1621
- tenantId: { equals: tenantId },
1704
+ id: projectId,
1705
+ tenantId,
1622
1706
  }
1623
1707
  ```
1624
1708
 
@@ -1649,7 +1733,7 @@ This is weaker because:
1649
1733
 
1650
1734
  For tenant isolation, `"reject"` is the safer production default.
1651
1735
 
1652
- Guard shapes for `findUnique` and `findUniqueOrThrow` must define `where`. A shape without `where` for these methods throws `ShapeError`.
1736
+ Guard shapes for `findUnique` and `findUniqueOrThrow` must define `where`. A shape without `where` for these methods throws `ShapeError`. Unique where shapes must use Prisma unique selector syntax, for example `{ id: true }` or `{ tenantId_slug: { tenantId: true, slug: true } }`.
1653
1737
 
1654
1738
  ---
1655
1739
 
@@ -1771,16 +1855,20 @@ If a model references a scope root through composite foreign keys, that specific
1771
1855
 
1772
1856
  Handle these models explicitly via shape rules.
1773
1857
 
1774
- ### Compound unique selectors
1775
-
1776
- Guard currently records unique constraints as arrays of field names but does not generate the named compound selector schemas that Prisma uses for `@@unique` constraints. For example, `@@unique([firstName, lastName])` requires the selector `{ firstName_lastName: { firstName: "A", lastName: "B" } }`, but guard produces flat `{ firstName: "A", lastName: "B" }` output. This affects `findUnique`, `update`, `delete`, `upsert`, `connect`, and `connectOrCreate` with compound unique constraints.
1777
-
1778
- Single-field unique constraints work correctly. Compound unique support is planned.
1779
-
1780
1858
  ### Cursor fields must cover a unique constraint
1781
1859
 
1782
1860
  Prisma requires cursor-based pagination to use uniquely-identifiable fields. Guard enforces this at shape construction time: cursor fields must cover at least one unique constraint from the model. Non-unique cursor shapes are rejected with `ShapeError`.
1783
1861
 
1862
+ Compound cursor selectors use the same Prisma selector syntax as compound unique `where` values:
1863
+ ```ts
1864
+ cursor: {
1865
+ tenantId_slug: {
1866
+ tenantId: true,
1867
+ slug: true,
1868
+ },
1869
+ }
1870
+ ```
1871
+
1784
1872
  ### `@zod` on list fields applies to the array
1785
1873
 
1786
1874
  `@zod` directives on list fields (e.g. `String[]`) apply to the `z.array(...)` schema, not to individual elements. For example, `.min(1)` on a `String[]` field enforces a minimum array length of 1, not a minimum string length per element.
@@ -2154,7 +2242,6 @@ Node 22
2154
2242
 
2155
2243
  Possible future improvements:
2156
2244
 
2157
- * compound unique selector support
2158
2245
  * richer relation-level policies
2159
2246
  * nested write scope enforcement helpers
2160
2247
  * adapter integrations for SQL-backed runtimes
@@ -121,29 +121,55 @@ export type ScopeRoot = ${scopeRootType}
121
121
  }
122
122
 
123
123
  // src/generator/emit-type-map.ts
124
+ function uniqueSelector(fields, name) {
125
+ if (typeof name === "string" && name.trim().length > 0)
126
+ return name;
127
+ return fields.join("_");
128
+ }
124
129
  function collectUniqueConstraints(model) {
125
- const seen = /* @__PURE__ */ new Set();
130
+ const fieldSetSeen = /* @__PURE__ */ new Set();
131
+ const selectorToFields = /* @__PURE__ */ new Map();
126
132
  const constraints = [];
127
- function add(fields) {
128
- const key = fields.join("\0");
129
- if (seen.has(key))
133
+ function fieldsKey(fields) {
134
+ return fields.join("\0");
135
+ }
136
+ function add(fields, selector) {
137
+ if (fields.length === 0)
138
+ return;
139
+ const normalizedSelector = fields.length === 1 ? fields[0] : uniqueSelector(fields, selector);
140
+ const key = fieldsKey(fields);
141
+ const existingFieldsForSelector = selectorToFields.get(normalizedSelector);
142
+ if (existingFieldsForSelector && existingFieldsForSelector !== key) {
143
+ throw new Error(
144
+ `prisma-guard: Unique selector "${normalizedSelector}" on model "${model.name}" maps to multiple field sets.`
145
+ );
146
+ }
147
+ if (fieldSetSeen.has(key))
130
148
  return;
131
- seen.add(key);
132
- constraints.push(fields);
149
+ fieldSetSeen.add(key);
150
+ selectorToFields.set(normalizedSelector, key);
151
+ constraints.push({
152
+ selector: normalizedSelector,
153
+ fields: [...fields]
154
+ });
133
155
  }
134
156
  for (const field of model.fields) {
135
157
  if (field.isId)
136
- add([field.name]);
158
+ add([field.name], field.name);
137
159
  }
138
160
  if (model.primaryKey) {
139
- add([...model.primaryKey.fields]);
161
+ add([...model.primaryKey.fields], model.primaryKey.name);
140
162
  }
141
163
  for (const field of model.fields) {
142
164
  if (field.isUnique)
143
- add([field.name]);
165
+ add([field.name], field.name);
166
+ }
167
+ const uniqueIndexes = model.uniqueIndexes ?? [];
168
+ for (const index of uniqueIndexes) {
169
+ add([...index.fields], index.name);
144
170
  }
145
171
  for (const fields of model.uniqueFields) {
146
- add([...fields]);
172
+ add([...fields], fields.length === 1 ? fields[0] : fields.join("_"));
147
173
  }
148
174
  return constraints;
149
175
  }
@@ -188,7 +214,10 @@ ${fieldEntries}
188
214
  const constraints = collectUniqueConstraints(model);
189
215
  if (constraints.length === 0)
190
216
  return null;
191
- const constraintsStr = constraints.map((c) => `[${c.map((f) => JSON.stringify(f)).join(", ")}]`).join(", ");
217
+ const constraintsStr = constraints.map((c) => {
218
+ const fields = c.fields.map((f) => JSON.stringify(f)).join(", ");
219
+ return `{ selector: ${JSON.stringify(c.selector)}, fields: [${fields}] }`;
220
+ }).join(", ");
192
221
  return ` ${JSON.stringify(model.name)}: [${constraintsStr}],`;
193
222
  }).filter(Boolean).join("\n");
194
223
  const typeMapSource = `export const TYPE_MAP = {
@@ -263,7 +292,7 @@ function cap(s) {
263
292
  return s.charAt(0).toUpperCase() + s.slice(1);
264
293
  }
265
294
  function emitTypedShapes(dmmf, depth) {
266
- const header = `import type { TYPE_MAP } from './index'
295
+ const header = `import type { TYPE_MAP, UNIQUE_MAP } from './index'
267
296
  import type {
268
297
  TypedGuardShape,
269
298
  OperationShape,
@@ -274,20 +303,21 @@ import type {
274
303
  } from 'prisma-guard'
275
304
 
276
305
  type TM = typeof TYPE_MAP
306
+ type UM = typeof UNIQUE_MAP
277
307
 
278
308
  `;
279
309
  const blocks = dmmf.datamodel.models.map((model) => {
280
310
  const m = model.name;
281
- const projAlias = `export type ${m}Select = TypedProjection<TM, '${m}', ${depth}>
311
+ const projAlias = `export type ${m}Select = TypedProjection<TM, '${m}', ${depth}, UM>
282
312
  export type ${m}Projection = ${m}Select
283
- export type ${m}Include = TypedInclude<TM, '${m}', ${depth}>
313
+ export type ${m}Include = TypedInclude<TM, '${m}', ${depth}, UM>
284
314
  export type ${m}CountSelect = TypedCountSelect<TM, '${m}'>
285
315
  `;
286
- const guardAlias = `export type ${m}GuardShape = TypedGuardShape<TM, '${m}', ${depth}>
316
+ const guardAlias = `export type ${m}GuardShape = TypedGuardShape<TM, '${m}', ${depth}, UM>
287
317
  `;
288
318
  const opAliases = OPERATIONS.map((op) => {
289
319
  const c = cap(op);
290
- return `export type ${m}${c}Shape = OperationShape<TM, '${m}', '${op}', ${depth}>
320
+ return `export type ${m}${c}Shape = OperationShape<TM, '${m}', '${op}', ${depth}, UM>
291
321
  export type ${m}${c}ShapeInput<TCtx = unknown> = ShapeInput<${m}${c}Shape, TCtx>
292
322
  `;
293
323
  }).join("");