@cerbos/orm-prisma 4.0.0 → 5.0.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 (203) hide show
  1. package/README.md +611 -460
  2. package/lib/collections.d.ts +30 -0
  3. package/lib/collections.d.ts.map +1 -0
  4. package/lib/collections.js +421 -0
  5. package/lib/collections.js.map +1 -0
  6. package/lib/comparison.d.ts +24 -0
  7. package/lib/comparison.d.ts.map +1 -0
  8. package/lib/comparison.js +731 -0
  9. package/lib/comparison.js.map +1 -0
  10. package/lib/errors.d.ts +13 -0
  11. package/lib/errors.d.ts.map +1 -0
  12. package/lib/errors.js +17 -0
  13. package/lib/errors.js.map +1 -0
  14. package/lib/evaluate.d.ts +5 -0
  15. package/lib/evaluate.d.ts.map +1 -0
  16. package/lib/evaluate.js +118 -0
  17. package/lib/evaluate.js.map +1 -0
  18. package/lib/fields.d.ts +34 -0
  19. package/lib/fields.d.ts.map +1 -0
  20. package/lib/fields.js +253 -0
  21. package/lib/fields.js.map +1 -0
  22. package/lib/functions.d.ts +5 -0
  23. package/lib/functions.d.ts.map +1 -0
  24. package/lib/functions.js +408 -0
  25. package/lib/functions.js.map +1 -0
  26. package/lib/hierarchy.d.ts +6 -0
  27. package/lib/hierarchy.d.ts.map +1 -0
  28. package/lib/hierarchy.js +266 -0
  29. package/lib/hierarchy.js.map +1 -0
  30. package/lib/index.d.ts +91 -8
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +34 -2301
  33. package/lib/index.js.map +1 -1
  34. package/lib/literals.d.ts +20 -0
  35. package/lib/literals.d.ts.map +1 -0
  36. package/lib/literals.js +290 -0
  37. package/lib/literals.js.map +1 -0
  38. package/lib/mapping.d.ts +95 -0
  39. package/lib/mapping.d.ts.map +1 -0
  40. package/lib/mapping.js +316 -0
  41. package/lib/mapping.js.map +1 -0
  42. package/lib/narrowing.d.ts +12 -0
  43. package/lib/narrowing.d.ts.map +1 -0
  44. package/lib/narrowing.js +32 -0
  45. package/lib/narrowing.js.map +1 -0
  46. package/lib/plan.d.ts +49 -0
  47. package/lib/plan.d.ts.map +1 -0
  48. package/lib/plan.js +86 -0
  49. package/lib/plan.js.map +1 -0
  50. package/lib/presence.d.ts +25 -0
  51. package/lib/presence.d.ts.map +1 -0
  52. package/lib/presence.js +82 -0
  53. package/lib/presence.js.map +1 -0
  54. package/lib/regex.d.ts +33 -0
  55. package/lib/regex.d.ts.map +1 -0
  56. package/lib/regex.js +480 -0
  57. package/lib/regex.js.map +1 -0
  58. package/lib/relations.d.ts +58 -0
  59. package/lib/relations.d.ts.map +1 -0
  60. package/lib/relations.js +175 -0
  61. package/lib/relations.js.map +1 -0
  62. package/lib/rewrite.d.ts +36 -0
  63. package/lib/rewrite.d.ts.map +1 -0
  64. package/lib/rewrite.js +366 -0
  65. package/lib/rewrite.js.map +1 -0
  66. package/lib/solve.d.ts +20 -0
  67. package/lib/solve.d.ts.map +1 -0
  68. package/lib/solve.js +77 -0
  69. package/lib/solve.js.map +1 -0
  70. package/lib/strings.d.ts +12 -0
  71. package/lib/strings.d.ts.map +1 -0
  72. package/lib/strings.js +110 -0
  73. package/lib/strings.js.map +1 -0
  74. package/lib/ternary.d.ts +12 -0
  75. package/lib/ternary.d.ts.map +1 -0
  76. package/lib/ternary.js +192 -0
  77. package/lib/ternary.js.map +1 -0
  78. package/lib/timestamp.d.ts +45 -0
  79. package/lib/timestamp.d.ts.map +1 -0
  80. package/lib/timestamp.js +225 -0
  81. package/lib/timestamp.js.map +1 -0
  82. package/lib/translate.d.ts +20 -0
  83. package/lib/translate.d.ts.map +1 -0
  84. package/lib/translate.js +287 -0
  85. package/lib/translate.js.map +1 -0
  86. package/lib/types.d.ts +29 -0
  87. package/lib/types.d.ts.map +1 -0
  88. package/lib/types.js +980 -0
  89. package/lib/types.js.map +1 -0
  90. package/lib/value.d.ts +8 -0
  91. package/lib/value.d.ts.map +1 -0
  92. package/lib/value.js +7 -0
  93. package/lib/value.js.map +1 -0
  94. package/package.json +31 -18
  95. package/src/adversarial.test.ts +488 -488
  96. package/src/collections.ts +738 -0
  97. package/src/comparison.ts +1157 -0
  98. package/src/corpus.ts +597 -0
  99. package/src/errors.ts +12 -0
  100. package/src/evaluate.ts +170 -0
  101. package/src/fields.ts +373 -0
  102. package/src/functions.ts +548 -0
  103. package/src/generated/prisma-adversarial/browser.ts +20 -0
  104. package/src/generated/prisma-adversarial/client.ts +20 -0
  105. package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
  106. package/src/generated/prisma-adversarial/internal/class.ts +44 -4
  107. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +350 -3
  108. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +49 -2
  109. package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
  110. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
  111. package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
  112. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
  113. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +547 -75
  114. package/src/generated/prisma-adversarial/models.ts +4 -0
  115. package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
  116. package/src/generated/{prisma → prisma-adversarial-mysql}/client.ts +23 -18
  117. package/src/generated/{prisma → prisma-adversarial-mysql}/commonInputTypes.ts +167 -92
  118. package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
  119. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
  120. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
  121. package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
  122. package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
  123. package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
  124. package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
  125. package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
  126. package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
  127. package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
  128. package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
  129. package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
  130. package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
  131. package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
  132. package/src/generated/prisma-adversarial-pg/browser.ts +64 -0
  133. package/src/generated/prisma-adversarial-pg/client.ts +86 -0
  134. package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +345 -0
  135. package/src/generated/prisma-adversarial-pg/enums.ts +15 -0
  136. package/src/generated/prisma-adversarial-pg/internal/class.ts +284 -0
  137. package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +1542 -0
  138. package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespaceBrowser.ts +72 -43
  139. package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
  140. package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
  141. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
  142. package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
  143. package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
  144. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
  145. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +2139 -0
  146. package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
  147. package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
  148. package/src/generated/prisma-adversarial-pg/models.ts +20 -0
  149. package/src/generated/prisma-adversarial-pg-v6/client.d.ts +1 -0
  150. package/src/generated/prisma-adversarial-pg-v6/default.d.ts +1 -0
  151. package/src/generated/prisma-adversarial-pg-v6/edge.d.ts +1 -0
  152. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +14918 -0
  153. package/src/generated/prisma-adversarial-pg-v6/runtime/index-browser.d.ts +370 -0
  154. package/src/generated/prisma-adversarial-pg-v6/runtime/library.d.ts +3982 -0
  155. package/src/generated/prisma-adversarial-pg-v6/wasm.d.ts +1 -0
  156. package/src/generated/prisma-adversarial-v6/index.d.ts +6515 -304
  157. package/src/hierarchy.ts +405 -0
  158. package/src/index.ts +134 -3325
  159. package/src/literals.ts +363 -0
  160. package/src/mapping.ts +474 -0
  161. package/src/narrowing.ts +33 -0
  162. package/src/plan.ts +125 -0
  163. package/src/presence.ts +114 -0
  164. package/src/regex.ts +596 -0
  165. package/src/relations.ts +226 -0
  166. package/src/rewrite.ts +524 -0
  167. package/src/solve.ts +96 -0
  168. package/src/strings.ts +152 -0
  169. package/src/ternary.ts +359 -0
  170. package/src/test-setup.adversarial-url.ts +21 -0
  171. package/src/test-setup.adversarial.mysql.v6.ts +13 -0
  172. package/src/test-setup.adversarial.mysql.v7.ts +12 -0
  173. package/src/test-setup.adversarial.pg.v6.ts +13 -0
  174. package/src/test-setup.adversarial.pg.v7.ts +9 -0
  175. package/src/test-setup.adversarial.v7.ts +1 -0
  176. package/src/timestamp.ts +308 -0
  177. package/src/translate.ts +419 -0
  178. package/src/translator.test.ts +766 -0
  179. package/src/types.ts +1310 -0
  180. package/src/value.ts +16 -0
  181. package/src/generated/prisma/browser.ts +0 -59
  182. package/src/generated/prisma/internal/class.ts +0 -274
  183. package/src/generated/prisma/internal/prismaNamespace.ts +0 -1403
  184. package/src/generated/prisma/models/Category.ts +0 -1412
  185. package/src/generated/prisma/models/Label.ts +0 -1276
  186. package/src/generated/prisma/models/NestedResource.ts +0 -1562
  187. package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
  188. package/src/generated/prisma/models/Resource.ts +0 -2196
  189. package/src/generated/prisma/models/SubCategory.ts +0 -1412
  190. package/src/generated/prisma/models/Tag.ts +0 -1276
  191. package/src/generated/prisma/models/User.ts +0 -1503
  192. package/src/generated/prisma/models.ts +0 -19
  193. package/src/generated/prisma-v6/index.d.ts +0 -13726
  194. package/src/index.test.ts +0 -8885
  195. package/src/test-setup.v6.ts +0 -5
  196. package/src/test-setup.v7.ts +0 -7
  197. /package/src/generated/{prisma → prisma-adversarial-mysql}/enums.ts +0 -0
  198. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/client.d.ts +0 -0
  199. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/default.d.ts +0 -0
  200. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/edge.d.ts +0 -0
  201. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/index-browser.d.ts +0 -0
  202. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/library.d.ts +0 -0
  203. /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/wasm.d.ts +0 -0
@@ -1,28 +1,48 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
 
4
- import { GRPC as Cerbos } from "@cerbos/grpc";
5
- import type { Principal, Resource, Value } from "@cerbos/core";
6
-
7
- import { queryPlanToPrisma, PlanKind, MapperConfig } from ".";
4
+ import { afterAll, beforeAll, describe, expect, test } from "@jest/globals";
5
+ import {
6
+ PlanExpression,
7
+ PlanExpressionValue,
8
+ PlanExpressionVariable,
9
+ } from "@cerbos/core";
10
+ import type { PlanExpressionOperand, PlanResourcesResponse } from "@cerbos/core";
11
+
12
+ import { queryPlanToPrisma, PlanKind, UnsupportedQueryPlanError } from ".";
13
+ import type { PrismaFilter } from ".";
14
+ import {
15
+ assertFuzzVerdict,
16
+ FUZZ_DIR,
17
+ fuzzThrown,
18
+ fuzzVerdict,
19
+ MAPPER,
20
+ MODEL,
21
+ pdpTags,
22
+ planOf,
23
+ readCorpusJson,
24
+ readGolden,
25
+ readGoldens,
26
+ writeFuzzResults,
27
+ } from "./corpus";
28
+ import type { FuzzVerdict, Golden } from "./corpus";
8
29
  import { prisma } from "./test-setup.adversarial";
9
30
 
10
31
  /**
11
- * Adversarial differential suite: every action in the shared `../conformance/` corpus is planned
12
- * against a REAL Cerbos PDP (the sidecar started by `npm run test:adversarial`, loaded with
13
- * `conformance/policies/adversarial.yaml`), translated by this adapter, and executed against
14
- * seeded rows — then the filtered id set is compared against an oracle computed by calling the
15
- * check API for each row with attributes mirroring that row exactly.
32
+ * The conformance harness (conformance/README.md, "The harness contract"). It loads the corpus
33
+ * dataset into a real store, translates every recorded golden plan for both pinned PDPs, runs the
34
+ * query through a generated Prisma client, and compares the returned ids with the `allowed` ids the
35
+ * PDP's check() recorded. No PDP runs here. Exceptions live in `conformance-ledger.json`.
16
36
  *
17
- * No hand-computed expectations: if this adapter's filter semantics diverge from Cerbos's own
18
- * evaluation for any row, the mismatch surfaces mechanically. See `conformance/README.md` for the
19
- * oracle recipe (NULL-as-missing-attribute, the degeneracy guard) — this file only owns the
20
- * Prisma-specific translation (seeding, field mapping, executing the query).
37
+ * The store is chosen with `ADAPTER_TEST_DB` (`sqlite` by default, `postgres` and `mysql` via
38
+ * testcontainers started in `jest.globalSetup.adversarial.js`), and the Prisma major with
39
+ * `PRISMA_VERSION`. The two are independent dimensions: v6/v7 is an ENGINE matrix, and an engine
40
+ * matrix says nothing about how a provider coerces a fractional threshold against an `Int` column,
41
+ * escapes a LIKE metacharacter, compares a real `timestamp` (cerbos/query-plan-adapters#320), or
42
+ * decides with its COLLATION whether `=` is case-sensitive at all (#340).
21
43
  */
22
44
 
23
- const cerbos = new Cerbos("127.0.0.1:3593", { tls: false });
24
-
25
- const CONFORMANCE_DIR = path.join(__dirname, "..", "..", "conformance");
45
+ // -- the dataset: conformance/seeds.json + derived-fields.json ----------------------------------
26
46
 
27
47
  interface Tag {
28
48
  id: string;
@@ -31,556 +51,536 @@ interface Tag {
31
51
 
32
52
  interface Seed {
33
53
  id: string;
34
- aBool: boolean;
35
- aString: string;
36
- aNumber: number;
54
+ aBool: boolean | null;
55
+ aString: string | null;
56
+ aNumber: number | null;
37
57
  aOptionalString: string | null;
58
+ aNumberList: (number | null)[];
59
+ aBoolList: (boolean | null)[];
38
60
  tags: Tag[];
39
61
  subCategoryNames: string[];
62
+ /** The seed whose scalars this row's to-one `parent` carries; null for no parent. */
63
+ parentSeedId: string | null;
64
+ /** The store capabilities the row needs (conformance/README.md, "The harness contract"). */
65
+ requires?: string[];
40
66
  }
41
67
 
42
- interface SeedsFile {
43
- principal: Principal;
44
- resourceKind: string;
45
- seeds: Seed[];
46
- }
47
-
48
- interface UnsupportedShape {
49
- action: string;
50
- shape: string;
51
- springDataMessage: string;
52
- }
53
-
54
- interface AdapterUnsupportedEntry {
55
- action: string;
56
- reason: string;
68
+ interface DerivedEntry {
69
+ createdBy: string;
70
+ aDouble: number | null;
71
+ createdAt: string | null;
72
+ updatedAt: string | null;
73
+ scope: string | null;
74
+ labels: (string | null)[];
57
75
  }
58
76
 
59
- interface KnownDivergence {
60
- action: string;
61
- adapters: string[];
62
- }
77
+ const STORE_NAMES = ["sqlite", "postgres", "mysql"] as const;
63
78
 
64
- interface ActionsFile {
65
- conformance: string[];
66
- adapterUnsupported?: Record<string, AdapterUnsupportedEntry[]>;
67
- adapterSupportedExpected?: Record<string, AdapterUnsupportedEntry[]>;
68
- expectedUnsupported: UnsupportedShape[];
69
- knownDivergences?: KnownDivergence[];
70
- }
79
+ type StoreName = (typeof STORE_NAMES)[number];
71
80
 
72
- type ThrowingAction = readonly [action: string, reason: string];
81
+ function selectedStoreName(): StoreName {
82
+ const requested = process.env["ADAPTER_TEST_DB"] ?? "sqlite";
73
83
 
74
- interface ActionClassification {
75
- oracleActions: string[];
76
- throwingActions: ThrowingAction[];
77
- supportedExpected: Set<string>;
78
- }
84
+ // A typo must fail rather than silently fall back to SQLite (jest.adversarial.config.js rejects
85
+ // it first; the repeat keeps this file reading against a value it trusts).
86
+ const store = STORE_NAMES.find((name) => name === requested);
79
87
 
80
- function classifyActionsForAdapter(
81
- manifest: ActionsFile,
82
- adapter: string
83
- ): ActionClassification {
84
- const unsupported = manifest.adapterUnsupported?.[adapter] ?? [];
85
- const unsupportedActions = new Set(unsupported.map((entry) => entry.action));
86
- const supportedExpected = new Set(
87
- (manifest.adapterSupportedExpected?.[adapter] ?? []).map(
88
- (entry) => entry.action
89
- )
90
- );
91
- const oracleActions = [
92
- ...manifest.conformance.filter(
93
- (action) => !unsupportedActions.has(action)
94
- ),
95
- ...supportedExpected,
96
- ];
97
- const throwingActions: ThrowingAction[] = [
98
- ...unsupported.map(
99
- (entry): ThrowingAction => [entry.action, entry.reason]
100
- ),
101
- ...manifest.expectedUnsupported
102
- .filter((entry) => !supportedExpected.has(entry.action))
103
- .map((entry): ThrowingAction => [entry.action, entry.shape]),
104
- ];
88
+ if (store === undefined) {
89
+ throw new Error(
90
+ `Unknown ADAPTER_TEST_DB "${requested}": expected one of ${STORE_NAMES.join(", ")}`
91
+ );
92
+ }
105
93
 
106
- return {
107
- oracleActions: [...new Set(oracleActions)].sort(),
108
- throwingActions: throwingActions.sort(([left], [right]) =>
109
- left.localeCompare(right)
110
- ),
111
- supportedExpected,
112
- };
94
+ return store;
113
95
  }
114
96
 
115
- const seedsFile: SeedsFile = JSON.parse(
116
- fs.readFileSync(path.join(CONFORMANCE_DIR, "seeds.json"), "utf8")
97
+ const STORE_NAME = selectedStoreName();
98
+
99
+ // A seed requiring a capability the ledger's `lacks` names for this store is a resource it cannot
100
+ // hold: it is not loaded, and its id is struck from every allowed set.
101
+ const LACKS = Object.entries(
102
+ (
103
+ // SAFETY: validate-corpus.sh checks every conformance-ledger.json against its schema.
104
+ JSON.parse(
105
+ fs.readFileSync(path.join(__dirname, "..", "conformance-ledger.json"), "utf8")
106
+ ) as { lacks?: Record<string, { reason: string; stores?: string[] }> }
107
+ ).lacks ?? {}
108
+ )
109
+ .filter(([, entry]) => entry.stores?.includes(STORE_NAME) ?? true)
110
+ .map(([capability]) => capability);
111
+
112
+ const UNHELD = new Set(
113
+ (readCorpusJson<{ seeds: Seed[] }>("seeds.json")).seeds
114
+ .filter((seed) => seed.requires?.some((capability) => LACKS.includes(capability)))
115
+ .map((seed) => seed.id)
117
116
  );
118
- const actionsFile: ActionsFile = JSON.parse(
119
- fs.readFileSync(path.join(CONFORMANCE_DIR, "actions.json"), "utf8")
120
- );
121
- const SEEDS = seedsFile.seeds;
122
-
123
- const {
124
- oracleActions: ORACLE_ACTIONS,
125
- throwingActions: THROWING_ACTIONS,
126
- supportedExpected: PRISMA_SUPPORTED_EXPECTED,
127
- } = classifyActionsForAdapter(actionsFile, "prisma");
128
- const PRISMA_KNOWN_DIVERGENCES = new Set(
129
- (actionsFile.knownDivergences ?? [])
130
- .filter((entry) => entry.adapters.includes("prisma"))
131
- .map((entry) => entry.action)
132
- );
133
- const EXPECTED_UNSUPPORTED_ACTIONS = new Set(
134
- actionsFile.expectedUnsupported.map((entry) => entry.action)
117
+
118
+ const SEEDS = (readCorpusJson<{ seeds: Seed[] }>("seeds.json")).seeds.filter(
119
+ (seed) => !UNHELD.has(seed.id)
135
120
  );
136
- const MANIFEST_ACTIONS = new Set([
137
- ...actionsFile.conformance,
138
- ...EXPECTED_UNSUPPORTED_ACTIONS,
139
- ...PRISMA_KNOWN_DIVERGENCES,
140
- ]);
141
-
142
- function doubleFor(seed: Seed): number | null {
143
- switch (seed.id) {
144
- case "a1":
145
- return -0.6;
146
- case "a2":
147
- return 0.25;
148
- case "a3":
149
- return null;
150
- default:
151
- return seed.aNumber + 0.3;
152
- }
121
+
122
+ function held(allowed: string[]): string[] {
123
+ return allowed.filter((id) => !UNHELD.has(id));
153
124
  }
154
125
 
155
- function labelsFor(seed: Seed): (string | null)[] {
156
- switch (seed.id) {
157
- case "a1":
158
- return ["gold", "silver"];
159
- case "a6":
160
- return [null, "silver"];
161
- case "a8":
162
- return ["silver"];
163
- case "c1":
164
- return ["Gold"];
165
- default:
166
- return [];
126
+ const DERIVED = (
127
+ readCorpusJson<{ derived: Record<string, DerivedEntry> }>("derived-fields.json")
128
+ ).derived;
129
+
130
+ function derivedFor(seed: Seed): DerivedEntry {
131
+ const entry = DERIVED[seed.id];
132
+
133
+ if (entry === undefined) {
134
+ throw new Error(`derived-fields.json has no entry for seed "${seed.id}"`);
167
135
  }
168
- }
169
136
 
170
- /** Deterministic ISO instant per seed for the timestamp probe: split around 2025-01-01. */
171
- function isoFor(seed: Seed): string {
172
- return seed.aNumber >= 2 ? "2024-06-01T00:00:00Z" : "2026-06-01T00:00:00Z";
137
+ return entry;
173
138
  }
174
139
 
175
- function timestampFor(seed: Seed): string | null {
176
- switch (seed.id) {
177
- case "a1":
178
- return "2020-03-15T10:30:00Z";
179
- case "a2":
180
- return "2037-01-01T00:00:00Z";
181
- case "a3":
182
- return null;
183
- case "a4":
184
- return "2024-06-01T00:00:00Z";
185
- case "a5":
186
- return "2020-03-15T10:30:00.123456Z";
187
- default:
188
- return seed.aNumber >= 2
189
- ? "2036-06-06T06:06:06Z"
190
- : "2021-05-05T05:05:05Z";
140
+ // The real to-one relation (conformance/README.md, "The dataset"): every resource owns a FRESH
141
+ // parent (and inner) row carrying the named seed's scalars, so no two resources share one.
142
+ const SEEDS_BY_ID = new Map(SEEDS.map((seed) => [seed.id, seed]));
143
+
144
+ function parentSeedOf(seed: Seed | undefined): Seed | undefined {
145
+ const id = seed?.parentSeedId;
146
+
147
+ if (id === undefined || id === null) {
148
+ return undefined;
191
149
  }
192
- }
193
150
 
194
- function scopeFor(seed: Seed): string | null {
195
- switch (seed.id) {
196
- case "a1":
197
- return "dept";
198
- case "a2":
199
- return "dept.eng";
200
- case "a3":
201
- return "dept.eng.platform";
202
- case "a4":
203
- return "dept.eng.platform.obs";
204
- case "a5":
205
- return "dept.engineering";
206
- case "a6":
207
- return "dept.sales";
208
- case "a8":
209
- return "";
210
- case "a9":
211
- return "50%";
212
- case "b1":
213
- return "50%:a_b:x";
214
- case "b2":
215
- return "50x:a_b:y";
216
- case "b3":
217
- return "50%:aXb:y";
218
- case "b4":
219
- return "50%:a_b";
220
- case "b5":
221
- return "dept.eng.platform2";
222
- case "b6":
223
- return "50%.a_b";
224
- case "c1":
225
- return "Dept.Eng";
226
- case "c2":
227
- return "dept.eng.";
228
- case "d1":
229
- return "[env]:prod:eu";
230
- case "d2":
231
- return "e:prod:eu";
232
- default:
233
- return null;
151
+ const parent = SEEDS_BY_ID.get(id);
152
+
153
+ if (parent === undefined) {
154
+ throw new Error(`seeds.json: "${seed?.id}" names parent "${id}", which is not a seed id`);
234
155
  }
156
+
157
+ return parent;
235
158
  }
236
159
 
237
- const MAPPER: Record<string, MapperConfig> = {
238
- "request.resource.attr.aBool": { field: "aBool" },
239
- "request.resource.attr.aString": { field: "aString" },
240
- "request.resource.attr.aNumber": { field: "aNumber" },
241
- "request.resource.attr.aDouble": { field: "aDouble" },
242
- "request.resource.attr.aOptionalString": { field: "aOptionalString" },
243
- "request.resource.attr.createdBy": { field: "createdBy" },
244
- "request.resource.attr.scope": { field: "scope" },
245
- "request.resource.attr.createdAt": {
246
- field: "createdAt",
247
- valueType: "dateTime",
248
- },
249
- "request.resource.attr.owner": {
250
- field: "aOptionalString",
251
- nullable: true,
252
- },
253
- // obj.inner is not a real nested column — mirrors aString, same trick the spring-data
254
- // reference harness uses for the p-struct probe.
255
- "request.resource.attr.obj.inner": { field: "aString" },
256
- "request.resource.attr.tags": {
257
- relation: {
258
- name: "tags",
259
- type: "many",
260
- // Model name enables field-to-field comparisons between tag columns; the nullable
261
- // flag on `name` enables the adapter's three-valued-logic guards for collection
262
- // macros over elements whose name column is NULL (a missing attribute — a CEL
263
- // error, hence deny — on the check side).
264
- model: "AdversarialTag",
265
- fields: {
266
- id: { field: "tagId" },
267
- name: { field: "name", nullable: true },
268
- },
269
- },
270
- },
271
- "request.resource.attr.tagNames": {
272
- relation: {
273
- name: "tags",
274
- type: "many",
275
- field: "name",
276
- fields: { name: { field: "name", nullable: true } },
277
- },
278
- },
279
- "request.resource.attr.categories": {
280
- relation: {
281
- name: "categories",
282
- type: "many",
283
- fields: {
284
- name: { field: "name" },
285
- subCategories: {
286
- relation: {
287
- name: "subCategories",
288
- type: "many",
289
- fields: {
290
- name: { field: "name" },
291
- labels: {
292
- relation: {
293
- name: "labels",
294
- type: "many",
295
- fields: {
296
- name: { field: "name", nullable: true },
297
- },
298
- },
299
- },
300
- },
301
- },
302
- },
303
- },
304
- },
305
- },
306
- // Multi-hop chain probe (W1): mainCategory mirrors the SAME categories/subCategories relation
307
- // as a single-object dotted chain on the check side (every seed holds at most one category),
308
- // pinning that the adapter joins through every intermediate hop, never off the root.
309
- "request.resource.attr.mainCategory": {
310
- relation: {
311
- name: "categories",
312
- type: "many",
313
- fields: {
314
- name: { field: "name" },
315
- subCategories: {
316
- relation: {
317
- name: "subCategories",
318
- type: "many",
319
- fields: { name: { field: "name" } },
320
- },
321
- },
322
- // subNames: the same 2-hop chain but with a bare `field`, so plain `in` membership
323
- // compares the flattened tail's name column directly.
324
- subNames: {
325
- relation: {
326
- name: "subCategories",
327
- type: "many",
328
- field: "name",
329
- },
330
- },
331
- },
332
- },
333
- },
334
- };
160
+ /** The four scalars one level of the chain stores, as columns. */
161
+ function relationColumns(seed: Seed) {
162
+ return {
163
+ aBool: seed.aBool,
164
+ aString: seed.aString,
165
+ aNumber: seed.aNumber,
166
+ aOptionalString: seed.aOptionalString,
167
+ };
168
+ }
335
169
 
336
- beforeAll(async () => {
170
+ async function seedStore(): Promise<void> {
171
+ await prisma.adversarialInner.deleteMany();
172
+ await prisma.adversarialParent.deleteMany();
337
173
  await prisma.adversarialLabel.deleteMany();
338
174
  await prisma.adversarialSubCategory.deleteMany();
339
175
  await prisma.adversarialCategory.deleteMany();
340
176
  await prisma.adversarialTag.deleteMany();
177
+ await prisma.adversarialNumberListElement.deleteMany();
178
+ await prisma.adversarialBoolListElement.deleteMany();
341
179
  await prisma.adversarialResource.deleteMany();
342
180
 
343
181
  // Distinct sub-category/category graphs per seed so no rows share relations by accident.
344
182
  for (const seed of SEEDS) {
183
+ const derived = derivedFor(seed);
184
+ const parentSeed = parentSeedOf(seed);
185
+ const innerSeed = parentSeedOf(parentSeed);
345
186
  await prisma.adversarialResource.create({
346
187
  data: {
347
188
  id: seed.id,
348
189
  aBool: seed.aBool,
349
190
  aString: seed.aString,
350
191
  aNumber: seed.aNumber,
351
- aDouble: doubleFor(seed),
192
+ aDouble: derived.aDouble,
352
193
  aOptionalString: seed.aOptionalString,
353
- createdBy: isoFor(seed),
354
- scope: scopeFor(seed),
355
- createdAt: timestampFor(seed),
356
- tags: {
357
- create: seed.tags.map((t) => ({ tagId: t.id, name: t.name })),
358
- },
194
+ createdBy: derived.createdBy,
195
+ scope: derived.scope,
196
+ createdAt: derived.createdAt,
197
+ updatedAt: derived.updatedAt,
198
+ // One row per element, null elements included: membership is all a filter asks of
199
+ // either list, so the element's position is not stored.
200
+ numberList: { create: seed.aNumberList.map((value) => ({ value })) },
201
+ boolList: { create: seed.aBoolList.map((value) => ({ value })) },
202
+ tags: { create: seed.tags.map((t) => ({ tagId: t.id, name: t.name })) },
203
+ // The to-one chain, one owned row per level. A seed with no parent gets no row at all:
204
+ // Prisma reads an undefined relation as no write.
205
+ parent:
206
+ parentSeed === undefined
207
+ ? undefined
208
+ : {
209
+ create: {
210
+ ...relationColumns(parentSeed),
211
+ inner: innerSeed === undefined ? undefined : { create: relationColumns(innerSeed) },
212
+ },
213
+ },
214
+ // One category holding every subcategory name (conformance/README.md, "The dataset").
359
215
  categories: {
360
- create: seed.subCategoryNames.map((subName) => ({
361
- name: "business",
362
- subCategories: {
363
- create: [
364
- {
365
- name: subName,
366
- labels: {
367
- create: labelsFor(seed).map((name) => ({ name })),
216
+ create:
217
+ seed.subCategoryNames.length === 0
218
+ ? []
219
+ : [
220
+ {
221
+ name: "business",
222
+ subCategories: {
223
+ create: seed.subCategoryNames.map((subName) => ({
224
+ name: subName,
225
+ labels: { create: derived.labels.map((name) => ({ name })) },
226
+ })),
227
+ },
368
228
  },
369
- },
370
- ],
371
- },
372
- })),
229
+ ],
373
230
  },
374
231
  },
375
232
  });
376
233
  }
377
- });
234
+ }
378
235
 
379
- afterAll(async () => {
380
- await prisma.$disconnect();
381
- });
236
+ // -- the store ------------------------------------------------------------------------------------
382
237
 
383
- function principal(): Principal {
384
- return seedsFile.principal;
238
+ /**
239
+ * How each leg asks its own connection which engine answered, and the first word of the answer.
240
+ * Each spelling is a syntax error on the other two engines, so the anti-vacuity test fails in every
241
+ * direction. MySQL's `version()` answers a bare `8.4.x`; `@@version_comment` names the engine.
242
+ */
243
+ const STORE_BANNERS: Record<StoreName, { query: string; engine: string }> = {
244
+ sqlite: { query: "select 'SQLite ' || sqlite_version() as banner", engine: "SQLite" },
245
+ postgres: { query: "select version() as banner", engine: "PostgreSQL" },
246
+ mysql: { query: "select @@version_comment as banner", engine: "MySQL" },
247
+ };
248
+
249
+ /**
250
+ * The six adversarial schemas — (Prisma 6, Prisma 7) x (SQLite, PostgreSQL, MySQL) — must hold one
251
+ * data model between them: a column that drifts in one would seed a different row shape on that leg
252
+ * while every assertion here stayed identical.
253
+ */
254
+ const ADVERSARIAL_SCHEMAS = [
255
+ "schema.adversarial.prisma",
256
+ "schema.adversarial.v6.prisma",
257
+ "schema.adversarial.pg.prisma",
258
+ "schema.adversarial.pg.v6.prisma",
259
+ "schema.adversarial.mysql.prisma",
260
+ "schema.adversarial.mysql.v6.prisma",
261
+ ] as const;
262
+
263
+ /** A schema's `model` blocks, without comments, generator/datasource blocks or incidental space. */
264
+ function modelBlocks(schema: string): string {
265
+ const withoutComments = schema
266
+ .split("\n")
267
+ .filter((line) => !line.trimStart().startsWith("//"))
268
+ .join("\n");
269
+
270
+ const models = withoutComments.match(/^model\s[\s\S]*$/m) ?? [];
271
+
272
+ return models.join("\n").replace(/[ \t]+/g, " ").replace(/\n\s*\n/g, "\n").trim();
385
273
  }
386
274
 
387
- /** A NULL tag name in the DB is a missing element attribute on the check side. */
388
- function asTagAttribute(tag: Tag): Record<string, Value> {
389
- const attr: Record<string, Value> = { id: tag.id };
390
- if (tag.name !== null) {
391
- attr["name"] = tag.name;
392
- }
393
- return attr;
275
+ async function selectIds(where: PrismaFilter): Promise<string[]> {
276
+ const rows = await prisma.adversarialResource.findMany({ where, select: { id: true } });
277
+
278
+ return rows.map((row) => row.id).sort();
394
279
  }
395
280
 
396
- /** Cerbos attributes mirroring exactly what the seeded DB row holds. */
397
- function asCheckResource(seed: Seed): Resource {
398
- const attr: Record<string, Value> = {
399
- aBool: seed.aBool,
400
- aString: seed.aString,
401
- aNumber: seed.aNumber,
402
- createdBy: isoFor(seed),
403
- owner: seed.aOptionalString,
404
- tagNames: seed.tags.map((tag) => tag.name),
405
- obj: { inner: seed.aString },
406
- tags: seed.tags.map(asTagAttribute),
407
- categories: seed.subCategoryNames.map((subName) => ({
408
- name: "business",
409
- subCategories: [
410
- {
411
- name: subName,
412
- labels: labelsFor(seed).map((name): Record<string, Value> =>
413
- name === null ? {} : { name }
414
- ),
415
- },
416
- ],
417
- })),
418
- };
419
- // A DB NULL is a missing attribute on the check side — conditions touching it must deny
420
- // (CEL error), matching SQL three-valued logic excluding the row.
421
- if (seed.aOptionalString !== null) {
422
- attr["aOptionalString"] = seed.aOptionalString;
423
- }
424
- const aDouble = doubleFor(seed);
425
- if (aDouble !== null) {
426
- attr["aDouble"] = aDouble;
427
- }
428
- const scope = scopeFor(seed);
429
- if (scope !== null) {
430
- attr["scope"] = scope;
431
- }
432
- const createdAt = timestampFor(seed);
433
- if (createdAt !== null) {
434
- attr["createdAt"] = createdAt;
435
- }
436
- // mainCategory mirrors the row's single category as ONE nested object (the seeder creates
437
- // at most one category per seed), so direct dotted-chain CEL expressions evaluate cleanly;
438
- // rows without a category get NO attribute — a CEL missing-attr error (deny), matching the
439
- // adapter's empty join chain excluding the row.
440
- if (seed.subCategoryNames.length > 0) {
441
- attr["mainCategory"] = {
442
- name: "business",
443
- subCategories: seed.subCategoryNames.map((name) => ({ name })),
444
- subNames: seed.subCategoryNames,
445
- };
446
- }
447
- return { kind: seedsFile.resourceKind, id: seed.id, attr };
281
+ // Every attribute the dataset omits when NULL is declared `omitted` in MAPPER. The call-level
282
+ // option covers what is left, the list and relation elements, which the dataset sends as values;
283
+ // the adapter's own default is pinned in translator.test.ts.
284
+ const translatePlan = (queryPlan: PlanResourcesResponse) =>
285
+ queryPlanToPrisma({
286
+ queryPlan,
287
+ mapper: MAPPER,
288
+ model: MODEL,
289
+ nullAttributeRepresentation: "explicit",
290
+ });
291
+
292
+ async function selectAllowed(queryPlan: PlanResourcesResponse): Promise<string[]> {
293
+ const result = translatePlan(queryPlan);
294
+
295
+ if (result.kind === PlanKind.ALWAYS_DENIED) return [];
296
+
297
+ return selectIds(result.kind === PlanKind.CONDITIONAL ? result.filters : {});
448
298
  }
449
299
 
450
- // -- oracle: ask the PDP itself, row by row --
300
+ // -- the ledger -----------------------------------------------------------------------------------
451
301
 
452
- async function oracleAllowedIds(action: string): Promise<string[]> {
453
- const ids: string[] = [];
454
- for (const seed of SEEDS) {
455
- const result = await cerbos.checkResource({
456
- principal: principal(),
457
- resource: asCheckResource(seed),
458
- actions: [action],
459
- });
460
- if (result.isAllowed(action)) {
461
- ids.push(seed.id);
462
- }
463
- }
464
- return ids.sort();
302
+ interface LedgerEntry {
303
+ status: "unsupported" | "divergent";
304
+ reason: string;
305
+ issue?: string;
306
+ pdp?: string[];
465
307
  }
466
308
 
467
- // -- adapter execution through the public queryPlanToPrisma path --
309
+ const LEDGER = (
310
+ // SAFETY: validate-corpus.sh checks every conformance-ledger.json against its schema.
311
+ JSON.parse(
312
+ fs.readFileSync(path.join(__dirname, "..", "conformance-ledger.json"), "utf8")
313
+ ) as { cases: Record<string, LedgerEntry> }
314
+ ).cases;
468
315
 
469
- async function adapterFilteredIds(action: string): Promise<string[]> {
470
- const queryPlan = await cerbos.planResources({
471
- principal: principal(),
472
- resource: { kind: seedsFile.resourceKind },
473
- action,
474
- });
475
- const result = queryPlanToPrisma({
476
- queryPlan,
477
- mapper: MAPPER,
478
- model: "AdversarialResource",
479
- });
480
- if (result.kind === PlanKind.ALWAYS_DENIED) {
481
- return [];
316
+ function ledgerEntry(golden: Golden, tag: string): LedgerEntry | undefined {
317
+ const entry = LEDGER[golden.id];
318
+
319
+ return entry && (entry.pdp === undefined || entry.pdp.includes(tag)) ? entry : undefined;
320
+ }
321
+
322
+ // -- the replay -----------------------------------------------------------------------------------
323
+
324
+ const TAGS = pdpTags();
325
+ const GOLDENS = new Map(TAGS.map((tag) => [tag, readGoldens(tag)]));
326
+ const passed: Record<string, Record<string, number>> = {};
327
+ const totals: Record<string, Record<string, number>> = {};
328
+ const fuzzResults: FuzzVerdict[] = [];
329
+
330
+ /** A fuzz case judged on this store (conformance/README.md, "Fuzzing"). */
331
+ async function fuzzReplay(golden: Golden): Promise<FuzzVerdict> {
332
+ try {
333
+ return fuzzVerdict(golden, held(golden.allowed).sort(), await selectAllowed(await planOf(golden)));
334
+ } catch (error) {
335
+ return fuzzThrown(golden, error, UnsupportedQueryPlanError);
482
336
  }
483
- const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
484
- const rows = await prisma.adversarialResource.findMany({
485
- where,
486
- select: { id: true },
487
- });
488
- return rows.map((r) => r.id).sort();
489
337
  }
490
338
 
491
- describe("adversarial conformance corpus", () => {
492
- test("adapter-supported expected shapes move from throwing to oracle", () => {
493
- const promotedAction = "promoted-shape";
494
- const classification = classifyActionsForAdapter(
495
- {
496
- conformance: [],
497
- adapterSupportedExpected: {
498
- prisma: [{ action: promotedAction, reason: "supported by Prisma" }],
499
- },
500
- expectedUnsupported: [
501
- {
502
- action: promotedAction,
503
- shape: "synthetic globally unsupported shape",
504
- springDataMessage: "unsupported",
505
- },
506
- ],
507
- },
508
- "prisma"
509
- );
339
+ beforeAll(async () => {
340
+ // CEL string matching is case-sensitive, and this adapter lowers contains/startsWith/endsWith to
341
+ // LIKE. On SQLite, LIKE is case-INSENSITIVE for ASCII whatever the column collation; only this
342
+ // pragma changes it (conformance/README.md, "Store configuration is part of conformance").
343
+ if (STORE_NAME === "sqlite") {
344
+ await prisma.$executeRawUnsafe("PRAGMA case_sensitive_like = ON");
345
+ }
510
346
 
511
- expect(classification.oracleActions).toContain(promotedAction);
512
- expect(
513
- classification.throwingActions.map(([action]) => action)
514
- ).not.toContain(promotedAction);
347
+ await seedStore();
348
+ }, 120_000);
349
+
350
+ afterAll(async () => {
351
+ await prisma.$disconnect();
352
+ writeFuzzResults("prisma", `${STORE_NAME}-v${process.env["PRISMA_VERSION"] ?? "7"}`, fuzzResults);
353
+
354
+ const lines = TAGS.map((tag) => {
355
+ const tiers = Object.keys(totals[tag] ?? {}).sort();
356
+ const skipped = GOLDENS.get(tag)!.filter((golden) => golden.plannerDivergence !== null);
357
+
358
+ return `${tag}: ${tiers.map((tier) => `${tier} ${passed[tag]?.[tier] ?? 0}/${totals[tag]?.[tier]}`).join(", ")} (planner divergences skipped: ${skipped.map((golden) => golden.id).join(", ") || "none"})`;
515
359
  });
516
360
 
517
- test("manifest assigns all 118 policy actions exactly one Prisma outcome", () => {
518
- const oracle = new Set(ORACLE_ACTIONS);
519
- const throwing = new Set(THROWING_ACTIONS.map(([action]) => action));
520
- const misclassified = [...MANIFEST_ACTIONS].filter((action) => {
521
- const classificationCount = [
522
- oracle.has(action),
523
- throwing.has(action),
524
- PRISMA_KNOWN_DIVERGENCES.has(action),
525
- ].filter(Boolean).length;
526
- return classificationCount !== 1;
527
- });
361
+ console.log(`conformance (${STORE_NAME}) passed per tier\n${lines.join("\n")}`);
362
+ });
528
363
 
529
- expect(MANIFEST_ACTIONS.size).toBe(118);
530
- expect(misclassified).toEqual([]);
531
- expect(
532
- [...PRISMA_SUPPORTED_EXPECTED].filter(
533
- (action) => !EXPECTED_UNSUPPORTED_ACTIONS.has(action)
534
- )
535
- ).toEqual([]);
364
+ describe(`conformance (${STORE_NAME})`, () => {
365
+ // Every assertion below is identical on every leg, so a PostgreSQL leg that silently fell back to
366
+ // SQLite would pass while proving nothing about PostgreSQL. Ask the connection which engine it is.
367
+ test("executes against the store ADAPTER_TEST_DB selects", async () => {
368
+ const rows = await prisma.$queryRawUnsafe<{ banner: string }[]>(
369
+ STORE_BANNERS[STORE_NAME].query
370
+ );
371
+
372
+ expect(rows[0]?.banner?.split(" ")[0]).toBe(STORE_BANNERS[STORE_NAME].engine);
536
373
  });
537
374
 
538
- test.each(ORACLE_ACTIONS)(
539
- "%s matches the check() oracle",
540
- async (action) => {
541
- const [oracle, filtered] = await Promise.all([
542
- oracleAllowedIds(action),
543
- adapterFilteredIds(action),
544
- ]);
545
- expect(filtered).toEqual(oracle);
375
+ // Store configuration is part of conformance. Prisma's MySQL migration engine hardcodes
376
+ // `utf8mb4_unicode_ci`, so the tables are converted after `db push`
377
+ // (jest.globalSetup.adversarial.js). Under that default these return the case variant, the
378
+ // accent variant and the wrong-case prefix; asserted by name so a failure reads as "the store is
379
+ // misconfigured" rather than as a translation bug.
380
+ (STORE_NAME === "mysql" ? test : test.skip)(
381
+ "the MySQL leg runs under a byte-exact NO PAD collation",
382
+ async () => {
383
+ expect({
384
+ caseVariant: await selectIds({ aString: "one" }),
385
+ accentFolded: await selectIds({ aString: "hello🚀" }),
386
+ wrongCasePrefix: await selectIds({ aString: { startsWith: "ON" } }),
387
+ trailingSpace: await selectIds({ aString: "one " }),
388
+ }).toEqual({ caseVariant: ["a1"], accentFolded: [], wrongCasePrefix: [], trailingSpace: [] });
546
389
  }
547
390
  );
548
391
 
549
- // Shapes the adapter does not support (globally unsupported planner shapes plus Prisma's
550
- // declared adapterUnsupported list): translation must fail loudly, never produce a
551
- // silently-wrong filter.
552
- test.each(THROWING_ACTIONS)(
553
- "%s fails loudly instead of silently mistranslating (%s)",
554
- async (action) => {
555
- await expect(adapterFilteredIds(action)).rejects.toThrow();
392
+ // `PRAGMA case_sensitive_like` holds per CONNECTION, so it must reach every connection a query can
393
+ // land on. Prisma 6 pooled SQLite connections until its schema pinned `connection_limit=1`;
394
+ // concurrent queries are what spread onto the rest.
395
+ (STORE_NAME === "sqlite" ? test : test.skip)(
396
+ "the SQLite leg's LIKE is case-sensitive on every connection",
397
+ async () => {
398
+ const results = await Promise.all(
399
+ Array.from({ length: 32 }, () => selectIds({ aString: { contains: "one" } }))
400
+ );
401
+
402
+ expect(results[0]?.length).toBeGreaterThan(0);
403
+ expect(results.filter((ids) => ids.includes("c1"))).toEqual([]);
556
404
  }
557
405
  );
558
406
 
559
- test("pins the upstream has() planner over-grant", async () => {
560
- const action = "p-has";
561
- expect(PRISMA_KNOWN_DIVERGENCES.has(action)).toBe(true);
562
- const queryPlan = await cerbos.planResources({
563
- principal: principal(),
564
- resource: { kind: seedsFile.resourceKind },
565
- action,
566
- });
567
- const oracle = await oracleAllowedIds(action);
568
- const allIds = SEEDS.map((seed) => seed.id).sort();
569
-
570
- expect(queryPlan.kind).toBe(PlanKind.ALWAYS_ALLOWED);
571
- expect(oracle.length).toBeGreaterThan(0);
572
- expect(oracle.length).toBeLessThan(allIds.length);
573
- expect(oracle).toContain("a1");
574
- expect(await adapterFilteredIds(action)).toEqual(allIds);
407
+ test("every adversarial schema declares the same data model", () => {
408
+ const [reference, ...rest] = ADVERSARIAL_SCHEMAS.map((name) => ({
409
+ name,
410
+ models: modelBlocks(
411
+ fs.readFileSync(path.join(__dirname, "..", "prisma", name), "utf8")
412
+ ),
413
+ }));
414
+
415
+ expect(reference?.models).toContain(`model ${MODEL}`);
416
+
417
+ for (const schema of rest) {
418
+ expect(schema).toEqual({ name: schema.name, models: reference?.models });
419
+ }
420
+ });
421
+
422
+ (FUZZ_DIR ? test.skip : test)("the ledger names no case without a golden file", () => {
423
+ const ids = new Set(TAGS.flatMap((tag) => GOLDENS.get(tag)!.map((golden) => golden.id)));
424
+ expect(Object.keys(LEDGER).filter((id) => !ids.has(id))).toEqual([]);
425
+ });
426
+
427
+ // A golden that does not say which PDP recorded it, or whether that PDP diverged, cannot be
428
+ // routed: fail on it rather than let the filter below read a missing field as "no divergence".
429
+ test("every golden file is recorded for its tag and declares plannerDivergence", () => {
430
+ const malformed = TAGS.flatMap((tag) =>
431
+ GOLDENS.get(tag)!
432
+ .filter((golden) => golden.pdp !== tag || golden.plannerDivergence === undefined)
433
+ .map((golden) => `${tag}/${golden.id}`)
434
+ );
435
+
436
+ expect(malformed).toEqual([]);
575
437
  });
576
438
 
577
- test("oracle is not degenerate", async () => {
578
- // Guard the guard: at least one action must produce a non-empty, non-total oracle set,
579
- // otherwise the differential comparison could pass vacuously (e.g. PDP denying all).
580
- for (const action of ["vf-le", "like-percent", "all-on-empty"]) {
581
- const ids = await oracleAllowedIds(action);
582
- expect(ids.length).toBeGreaterThan(0);
583
- expect(ids.length).toBeLessThan(SEEDS.length);
439
+ for (const tag of TAGS) {
440
+ // A plannerDivergence is a PDP bug no adapter can pass. The generator writes it only into the
441
+ // golden files of the tags it applies to, so a non-null value is the whole skip condition.
442
+ const cases = GOLDENS.get(tag)!.filter((golden) => golden.plannerDivergence === null);
443
+
444
+ test.each(cases.map((golden) => [golden.id, golden] as const))(
445
+ `${tag} %s`,
446
+ async (_id, golden) => {
447
+ if (FUZZ_DIR) {
448
+ const verdict = await fuzzReplay(golden);
449
+ fuzzResults.push(verdict);
450
+ assertFuzzVerdict(verdict);
451
+
452
+ return;
453
+ }
454
+
455
+ const entry = ledgerEntry(golden, tag);
456
+ const queryPlan = await planOf(golden);
457
+
458
+ if (entry?.status === "unsupported") {
459
+ expect(() => translatePlan(queryPlan)).toThrow(UnsupportedQueryPlanError);
460
+
461
+ return;
462
+ }
463
+
464
+ // Both sides sorted the same way, so a divergent entry cannot pass on ordering alone.
465
+ const ids = await selectAllowed(queryPlan);
466
+ const allowed = held(golden.allowed).sort();
467
+
468
+ if (entry?.status === "divergent") {
469
+ expect(ids).not.toEqual(allowed);
470
+
471
+ return;
472
+ }
473
+
474
+ expect(ids).toEqual(allowed);
475
+ (passed[tag] ??= {})[golden.tier] = (passed[tag]?.[golden.tier] ?? 0) + 1;
476
+ }
477
+ );
478
+
479
+ // The denominator is every golden file in the tier, skipped planner divergences included.
480
+ for (const golden of GOLDENS.get(tag)!) {
481
+ (totals[tag] ??= {})[golden.tier] = (totals[tag]?.[golden.tier] ?? 0) + 1;
482
+ }
483
+ }
484
+ });
485
+
486
+ // -- shapes the corpus does not spell -------------------------------------------------------------
487
+ //
488
+ // Synthesised plans over the same seeded store, for guards that must be a property of the
489
+ // `mainCategory` chain rather than of the few spellings the corpus pins. Every expectation is a
490
+ // recorded check() decision from a golden file, never a hand-computed row list.
491
+
492
+ describe(`the absent-parent guard over the chain (${STORE_NAME})`, () => {
493
+ const allowedFor = (id: string) => held(readGolden(TAGS[0]!, id).allowed);
494
+
495
+ async function synthetic(condition: PlanExpressionOperand): Promise<string[]> {
496
+ const queryPlan: PlanResourcesResponse = {
497
+ kind: PlanKind.CONDITIONAL,
498
+ condition,
499
+ cerbosCallId: "synthetic",
500
+ requestId: "synthetic",
501
+ validationErrors: [],
502
+ metadata: undefined,
503
+ };
504
+
505
+ return selectAllowed(queryPlan);
506
+ }
507
+
508
+ // Every count threshold, not only the two spellings the corpus pins (#316). Each is TRUE for a
509
+ // row with no mainCategory only if the guard leaks: an absent to-one parent is a CEL
510
+ // missing-path error, so the PDP denies it outright.
511
+ test("every count threshold inherits the guard", async () => {
512
+ const size = new PlanExpression("size", [
513
+ new PlanExpressionVariable("request.resource.attr.mainCategory.subCategories"),
514
+ ]);
515
+
516
+ const compare = (operator: string, threshold: number) =>
517
+ new PlanExpression(operator, [size, new PlanExpressionValue(threshold)]);
518
+
519
+ const negate = (condition: PlanExpressionOperand) => new PlanExpression("not", [condition]);
520
+
521
+ for (const [label, condition] of [
522
+ ["size(chain) == 0", compare("eq", 0)],
523
+ ["size(chain) <= 0", compare("le", 0)],
524
+ ["size(chain) < 1", compare("lt", 1)],
525
+ ["!(size(chain) >= 1)", negate(compare("ge", 1))],
526
+ ["!(size(chain) > 0)", negate(compare("gt", 0))],
527
+ ] as const) {
528
+ expect([label, await synthetic(condition)]).toEqual([label, []]);
584
529
  }
530
+
531
+ // The mirror image, so the loop above cannot pass by denying everything.
532
+ const withParent = allowedFor("relation/size/non-negative-to-one-chain");
533
+ expect(withParent.length).toBeGreaterThan(0);
534
+ expect(await synthetic(negate(compare("eq", 0)))).toEqual(withParent);
535
+ expect(await synthetic(negate(compare("lt", 1)))).toEqual(withParent);
536
+ });
537
+
538
+ // Every ternary condition position over the chain, not only the one the corpus pins (#334).
539
+ test("every ternary condition inherits the guard", async () => {
540
+ const chainIn = new PlanExpression("in", [
541
+ new PlanExpressionValue("finance"),
542
+ new PlanExpressionVariable("request.resource.attr.mainCategory.subNames"),
543
+ ]);
544
+
545
+ const TRUE = new PlanExpressionValue(true);
546
+ const FALSE = new PlanExpressionValue(false);
547
+
548
+ const ternary = (
549
+ condition: PlanExpressionOperand,
550
+ thenBranch: PlanExpressionOperand,
551
+ elseBranch: PlanExpressionOperand
552
+ ) => new PlanExpression("if", [condition, thenBranch, elseBranch]);
553
+
554
+ // A row with no mainCategory is neither: the condition errors and selects neither branch.
555
+ const conditionTrue = allowedFor("relation/in/to-one-chain");
556
+ const conditionFalse = allowedFor("relation/in/negated-to-one-chain");
557
+ expect(conditionTrue.length).toBeGreaterThan(0);
558
+ expect(conditionFalse.length).toBeGreaterThan(0);
559
+ expect(conditionTrue.length + conditionFalse.length).toBeLessThan(SEEDS.length);
560
+
561
+ expect(await synthetic(ternary(chainIn, FALSE, TRUE))).toEqual(conditionFalse);
562
+ expect(
563
+ await synthetic(ternary(new PlanExpression("not", [chainIn]), TRUE, FALSE))
564
+ ).toEqual(conditionFalse);
565
+ expect(
566
+ await synthetic(ternary(new PlanExpression("not", [chainIn]), FALSE, TRUE))
567
+ ).toEqual(conditionTrue);
568
+
569
+ // A conjunction condition needs De Morgan: CEL's `&&` absorbs an erroring operand when the
570
+ // other is FALSE, so a parentless row with aBool=false DOES select the else-branch.
571
+ const aBoolFalse = SEEDS.filter((seed) => seed.aBool === false).map((seed) => seed.id);
572
+ expect(aBoolFalse.length).toBeGreaterThan(0);
573
+ expect(
574
+ await synthetic(
575
+ ternary(
576
+ new PlanExpression("and", [
577
+ chainIn,
578
+ new PlanExpressionVariable("request.resource.attr.aBool"),
579
+ ]),
580
+ FALSE,
581
+ TRUE
582
+ )
583
+ )
584
+ ).toEqual([...new Set([...conditionFalse, ...aBoolFalse])].sort());
585
585
  });
586
586
  });