@cerbos/orm-prisma 4.1.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 (174) hide show
  1. package/README.md +598 -603
  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 +23 -13
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +34 -2555
  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 +27 -17
  95. package/src/adversarial.test.ts +376 -1071
  96. package/src/collections.ts +738 -0
  97. package/src/comparison.ts +1157 -0
  98. package/src/corpus.ts +433 -250
  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 +10 -0
  104. package/src/generated/prisma-adversarial/client.ts +10 -0
  105. package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
  106. package/src/generated/prisma-adversarial/internal/class.ts +24 -4
  107. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +174 -3
  108. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +23 -2
  109. package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
  110. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +54 -54
  111. package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
  112. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +66 -66
  113. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +435 -87
  114. package/src/generated/prisma-adversarial/models.ts +2 -0
  115. package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
  116. package/src/generated/prisma-adversarial-mysql/client.ts +86 -0
  117. package/src/generated/prisma-adversarial-mysql/commonInputTypes.ts +349 -0
  118. package/src/generated/prisma-adversarial-mysql/enums.ts +15 -0
  119. package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
  120. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
  121. package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
  122. package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
  123. package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
  124. package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
  125. package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
  126. package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
  127. package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
  128. package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
  129. package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
  130. package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
  131. package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
  132. package/src/generated/prisma-adversarial-mysql-v6/client.d.ts +1 -0
  133. package/src/generated/prisma-adversarial-mysql-v6/default.d.ts +1 -0
  134. package/src/generated/prisma-adversarial-mysql-v6/edge.d.ts +1 -0
  135. package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
  136. package/src/generated/prisma-adversarial-mysql-v6/runtime/index-browser.d.ts +370 -0
  137. package/src/generated/prisma-adversarial-mysql-v6/runtime/library.d.ts +3982 -0
  138. package/src/generated/prisma-adversarial-mysql-v6/wasm.d.ts +1 -0
  139. package/src/generated/prisma-adversarial-pg/browser.ts +10 -0
  140. package/src/generated/prisma-adversarial-pg/client.ts +10 -0
  141. package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +118 -129
  142. package/src/generated/prisma-adversarial-pg/internal/class.ts +24 -4
  143. package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +174 -3
  144. package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +23 -2
  145. package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
  146. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +54 -54
  147. package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
  148. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +66 -66
  149. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +435 -87
  150. package/src/generated/prisma-adversarial-pg/models.ts +2 -0
  151. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +3476 -388
  152. package/src/generated/prisma-adversarial-v6/index.d.ts +3465 -383
  153. package/src/hierarchy.ts +405 -0
  154. package/src/index.ts +66 -3646
  155. package/src/literals.ts +363 -0
  156. package/src/mapping.ts +474 -0
  157. package/src/narrowing.ts +33 -0
  158. package/src/plan.ts +125 -0
  159. package/src/presence.ts +114 -0
  160. package/src/regex.ts +596 -0
  161. package/src/relations.ts +226 -0
  162. package/src/rewrite.ts +524 -0
  163. package/src/solve.ts +96 -0
  164. package/src/strings.ts +152 -0
  165. package/src/ternary.ts +359 -0
  166. package/src/test-setup.adversarial-url.ts +6 -3
  167. package/src/test-setup.adversarial.mysql.v6.ts +13 -0
  168. package/src/test-setup.adversarial.mysql.v7.ts +12 -0
  169. package/src/test-setup.adversarial.v7.ts +1 -0
  170. package/src/timestamp.ts +308 -0
  171. package/src/translate.ts +419 -0
  172. package/src/translator.test.ts +497 -1317
  173. package/src/types.ts +1310 -0
  174. package/src/value.ts +16 -0
@@ -1,110 +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";
4
+ import { afterAll, beforeAll, describe, expect, test } from "@jest/globals";
5
5
  import {
6
6
  PlanExpression,
7
7
  PlanExpressionValue,
8
8
  PlanExpressionVariable,
9
9
  } from "@cerbos/core";
10
- import type {
11
- PlanExpressionOperand,
12
- Principal,
13
- Resource,
14
- Value,
15
- } from "@cerbos/core";
10
+ import type { PlanExpressionOperand, PlanResourcesResponse } from "@cerbos/core";
16
11
 
17
- import { queryPlanToPrisma, PlanKind, MapperConfig } from ".";
12
+ import { queryPlanToPrisma, PlanKind, UnsupportedQueryPlanError } from ".";
13
+ import type { PrismaFilter } from ".";
18
14
  import {
19
- CONFORMANCE_DIR,
15
+ assertFuzzVerdict,
16
+ FUZZ_DIR,
17
+ fuzzThrown,
18
+ fuzzVerdict,
20
19
  MAPPER,
21
20
  MODEL,
22
- classifyActionsForAdapter,
23
- requireMessage,
24
- } from "./corpus";
25
- import type {
26
- ActionClassification,
27
- ActionsFile,
28
- ThrowingAction,
21
+ pdpTags,
22
+ planOf,
23
+ readCorpusJson,
24
+ readGolden,
25
+ readGoldens,
26
+ writeFuzzResults,
29
27
  } from "./corpus";
28
+ import type { FuzzVerdict, Golden } from "./corpus";
30
29
  import { prisma } from "./test-setup.adversarial";
31
30
 
32
31
  /**
33
- * Adversarial differential suite: every action in the shared `../conformance/` corpus is planned
34
- * against a REAL Cerbos PDP (the sidecar started by `npm run test:adversarial`, loaded with
35
- * `conformance/policies/adversarial.yaml`), translated by this adapter, and executed against
36
- * seeded rows — then the filtered id set is compared against an oracle computed by calling the
37
- * check API for each row with attributes mirroring that row exactly.
38
- *
39
- * No hand-computed expectations: if this adapter's filter semantics diverge from Cerbos's own
40
- * evaluation for any row, the mismatch surfaces mechanically. See `conformance/README.md` for the
41
- * oracle recipe (NULL-as-missing-attribute, the degeneracy guard) — this file only owns seeding
42
- * and executing the query. The mapper and the per-adapter classification live in `./corpus`,
43
- * shared with `translator.test.ts`, which pins the filter this suite then proves returns the
44
- * right rows.
45
- *
46
- * The whole corpus is replayed against every store this adapter is proved on, one store per run,
47
- * selected with `ADAPTER_TEST_DB` (`sqlite` by default, `postgres` for the container-backed leg —
48
- * see `jest.globalSetup.adversarial.js`). The Prisma major and the store are independent
49
- * dimensions: v6/v7 is an ENGINE matrix, and an engine matrix says nothing about how a provider
50
- * coerces a fractional threshold against an `Int` column, escapes a LIKE metacharacter, or
51
- * compares a real `timestamp` rather than milliseconds since the epoch
52
- * (cerbos/query-plan-adapters#320).
53
- */
54
-
55
- const cerbos = new Cerbos("127.0.0.1:3593", { tls: false });
56
-
57
- const SCHEMA_DIR = path.join(__dirname, "..", "prisma");
58
-
59
- const STORE_NAMES = ["sqlite", "postgres"] as const;
60
- type StoreName = (typeof STORE_NAMES)[number];
61
-
62
- function selectedStoreName(): StoreName {
63
- const requested = process.env["ADAPTER_TEST_DB"] ?? "sqlite";
64
- // jest.adversarial.config.js rejects an unknown value before jest resolves this module; the
65
- // repeat here is what keeps the assertion below reading against a value this file trusts.
66
- if (!STORE_NAMES.includes(requested as StoreName)) {
67
- throw new Error(
68
- `Unknown ADAPTER_TEST_DB "${requested}": expected one of ${STORE_NAMES.join(", ")}`
69
- );
70
- }
71
- return requested as StoreName;
72
- }
73
-
74
- const STORE_NAME = selectedStoreName();
75
-
76
- /**
77
- * The four adversarial schemas, which must hold one data model between them.
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`.
78
36
  *
79
- * A generated Prisma client bakes in its provider and its major, so proving the corpus on
80
- * (Prisma 6, Prisma 7) x (SQLite, PostgreSQL) needs four schema files. They are only a matrix
81
- * over the same models — a column that drifts in one of them would seed a different row shape on
82
- * that leg while every assertion in this file stayed identical, which is the projection trap
83
- * conformance/README.md describes applied to the schema instead of the seeds.
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).
84
43
  */
85
- const ADVERSARIAL_SCHEMAS = [
86
- "schema.adversarial.prisma",
87
- "schema.adversarial.v6.prisma",
88
- "schema.adversarial.pg.prisma",
89
- "schema.adversarial.pg.v6.prisma",
90
- ] as const;
91
44
 
92
- /**
93
- * A schema's `model` blocks, with comments, the generator/datasource blocks and all incidental
94
- * whitespace removed — everything that legitimately differs between the four.
95
- */
96
- function modelBlocks(schema: string): string {
97
- const withoutComments = schema
98
- .split("\n")
99
- .filter((line) => !line.trimStart().startsWith("//"))
100
- .join("\n");
101
- const models = withoutComments.match(/^model\s[\s\S]*$/m) ?? [];
102
- return models
103
- .join("\n")
104
- .replace(/[ \t]+/g, " ")
105
- .replace(/\n\s*\n/g, "\n")
106
- .trim();
107
- }
45
+ // -- the dataset: conformance/seeds.json + derived-fields.json ----------------------------------
108
46
 
109
47
  interface Tag {
110
48
  id: string;
@@ -113,405 +51,114 @@ interface Tag {
113
51
 
114
52
  interface Seed {
115
53
  id: string;
116
- aBool: boolean;
117
- aString: string;
118
- aNumber: number;
54
+ aBool: boolean | null;
55
+ aString: string | null;
56
+ aNumber: number | null;
119
57
  aOptionalString: string | null;
58
+ aNumberList: (number | null)[];
59
+ aBoolList: (boolean | null)[];
120
60
  tags: Tag[];
121
61
  subCategoryNames: string[];
122
62
  /** The seed whose scalars this row's to-one `parent` carries; null for no parent. */
123
63
  parentSeedId: string | null;
64
+ /** The store capabilities the row needs (conformance/README.md, "The harness contract"). */
65
+ requires?: string[];
124
66
  }
125
67
 
126
- interface SeedsFile {
127
- principal: Principal;
128
- resourceKind: string;
129
- seeds: Seed[];
130
- }
131
-
132
- // -- corpus coverage guards ---------------------------------------------------------------------
133
- //
134
- // The same parsed seed feeds the stored row AND the check() oracle, so a corpus field this harness
135
- // does not consume is dropped from both sides at once and the differential agrees for the wrong
136
- // reason — the projection trap conformance/README.md describes for actions.json, applied to the
137
- // seeds. Asserting set equality catches both directions: a corpus key nothing here reads, and a key
138
- // this harness reads that the corpus no longer carries.
139
-
140
- const SEED_KEYS = [
141
- "id",
142
- "aBool",
143
- "aString",
144
- "aNumber",
145
- "aOptionalString",
146
- "tags",
147
- "subCategoryNames",
148
- "parentSeedId",
149
- ] as const;
150
-
151
- /** Corpus prose, never read by a harness: the one documented exclusion from SEED_KEYS. */
152
- const SEED_NOTE_KEY = "note";
153
-
154
- /** The one nested object array a seed carries. A key added inside an element is dropped from both
155
- * sides of the differential just as silently as a top-level one, so it is guarded the same way. */
156
- const TAG_KEYS = ["id", "name"] as const;
157
-
158
- const DERIVED_KEYS = [
159
- "createdBy",
160
- "aDouble",
161
- "createdAt",
162
- "scope",
163
- "labels",
164
- ] as const;
165
-
166
- // The corpus principal is guarded the same way and for the same reason. It feeds the PLAN under
167
- // test AND the check() oracle, so an attribute dropped on the way in vanishes from both sides at
168
- // once: the plan folds to ALWAYS_DENIED and the oracle, built from the same principal, agrees. That
169
- // is how langchain-chromadb's hardcoded attribute allowlist let `pv-exists` pass while testing
170
- // nothing (conformance/README.md, "Adding a new hostile shape", step 7). This harness passes the
171
- // principal through verbatim, which is correct; the guard is what proves it still does.
172
- //
173
- // `id` and `roles` are deliberately IN scope, guarded by PRINCIPAL_KEYS one level above the
174
- // attributes — the same two-level shape SEED_KEYS and TAG_KEYS use for a row and its `tags[]`
175
- // elements. A role dropped on the way in changes every policy decision at once; that it is less
176
- // likely to be projected away than an attribute is a reason to expect the assertion to stay quiet,
177
- // not a reason to omit it.
178
- const PRINCIPAL_KEYS = ["id", "roles", "attr"] as const;
179
-
180
- const PRINCIPAL_ATTR_KEYS = [
181
- "allowedTags",
182
- "context",
183
- "fewTeams",
184
- "manyTeams",
185
- ] as const;
186
-
187
- /** One seed's derived fields, exactly as conformance/derived-fields.json carries them. */
188
68
  interface DerivedEntry {
189
69
  createdBy: string;
190
70
  aDouble: number | null;
191
71
  createdAt: string | null;
72
+ updatedAt: string | null;
192
73
  scope: string | null;
193
74
  labels: (string | null)[];
194
75
  }
195
76
 
196
- interface DerivedFile {
197
- fields: string[];
198
- derived: Record<string, DerivedEntry>;
199
- }
77
+ const STORE_NAMES = ["sqlite", "postgres", "mysql"] as const;
200
78
 
201
- function assertKeys(
202
- label: string,
203
- got: string[],
204
- want: readonly string[],
205
- optional: readonly string[] = []
206
- ): void {
207
- const allowed = new Set<string>([...want, ...optional]);
208
- for (const key of got) {
209
- if (!allowed.has(key)) {
210
- throw new Error(
211
- `${label} carries "${key}", which this harness does not consume: an unconsumed corpus field is dropped from the stored row and the check() oracle at once`
212
- );
213
- }
214
- }
215
- const present = new Set(got);
216
- for (const key of want) {
217
- if (!present.has(key)) {
218
- throw new Error(
219
- `${label} is missing "${key}", which this harness consumes`
220
- );
221
- }
222
- }
223
- }
79
+ type StoreName = (typeof STORE_NAMES)[number];
224
80
 
225
- /**
226
- * One principal attribute, checked against the two JSON shapes the corpus carries. A key-set guard
227
- * says nothing about a change inside a value and three of the four attributes are lists, so the
228
- * element type is asserted for the same reason the seed guard descends into `tags[]`.
229
- */
230
- function assertPrincipalAttrShape(label: string, value: unknown): void {
231
- if (typeof value === "string") return;
232
- if (Array.isArray(value) && value.every((el) => typeof el === "string")) {
233
- return;
234
- }
235
- throw new Error(
236
- `${label} is neither a string nor an array of strings, the only two shapes this harness consumes: a reshaped principal attribute feeds the plan and the check() oracle at once`
237
- );
238
- }
81
+ function selectedStoreName(): StoreName {
82
+ const requested = process.env["ADAPTER_TEST_DB"] ?? "sqlite";
239
83
 
240
- const seedsFile: SeedsFile = JSON.parse(
241
- fs.readFileSync(path.join(CONFORMANCE_DIR, "seeds.json"), "utf8")
242
- );
243
- const actionsFile: ActionsFile = JSON.parse(
244
- fs.readFileSync(path.join(CONFORMANCE_DIR, "actions.json"), "utf8")
245
- );
246
- const derivedFile: DerivedFile = JSON.parse(
247
- fs.readFileSync(path.join(CONFORMANCE_DIR, "derived-fields.json"), "utf8")
248
- );
249
- const SEEDS = seedsFile.seeds;
250
-
251
- // SEEDS holds the parsed JSON rows verbatim, so Object.keys reports the corpus key set. Keep it
252
- // that way: a parser that rebuilt each row field by field could only ever report the keys this
253
- // harness already names, and the assertion would pass vacuously.
254
- SEEDS.forEach((seed, index) => {
255
- const label = `seeds.json seeds[${index}]`;
256
- assertKeys(label, Object.keys(seed), SEED_KEYS, [SEED_NOTE_KEY]);
257
- seed.tags.forEach((tag, tagIndex) => {
258
- assertKeys(`${label}.tags[${tagIndex}]`, Object.keys(tag), TAG_KEYS);
259
- });
260
- });
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);
261
87
 
262
- // seedsFile.principal is the parsed JSON object, handed to the SDK untouched, so Object.keys
263
- // reports the corpus key set on both levels.
264
- assertKeys(
265
- "seeds.json principal",
266
- Object.keys(seedsFile.principal),
267
- PRINCIPAL_KEYS
268
- );
269
- // `attr` is optional on the SDK's Principal type; the corpus always carries it, and the assertion
270
- // above is what proves it rather than this fallback.
271
- const PRINCIPAL_ATTR = seedsFile.principal.attr ?? {};
272
- assertKeys(
273
- "seeds.json principal.attr",
274
- Object.keys(PRINCIPAL_ATTR),
275
- PRINCIPAL_ATTR_KEYS
276
- );
277
- for (const [key, value] of Object.entries(PRINCIPAL_ATTR)) {
278
- assertPrincipalAttrShape(`seeds.json principal.attr.${key}`, value);
279
- }
88
+ if (store === undefined) {
89
+ throw new Error(
90
+ `Unknown ADAPTER_TEST_DB "${requested}": expected one of ${STORE_NAMES.join(", ")}`
91
+ );
92
+ }
280
93
 
281
- assertKeys("derived-fields.json fields", derivedFile.fields, DERIVED_KEYS);
282
- const DERIVED_IDS = Object.keys(derivedFile.derived);
283
- if (DERIVED_IDS.length !== SEEDS.length) {
284
- throw new Error(
285
- `derived-fields.json has ${DERIVED_IDS.length} entries for ${SEEDS.length} seeds`
286
- );
287
- }
288
- for (const seed of SEEDS) {
289
- assertKeys(
290
- `derived-fields.json derived["${seed.id}"]`,
291
- Object.keys(derivedFor(seed)),
292
- DERIVED_KEYS
293
- );
94
+ return store;
294
95
  }
295
96
 
296
- const {
297
- oracleActions: ORACLE_ACTIONS,
298
- throwingActions: THROWING_ACTIONS,
299
- supportedExpected: PRISMA_SUPPORTED_EXPECTED,
300
- } = classifyActionsForAdapter(actionsFile, "prisma");
301
- const PRISMA_KNOWN_DIVERGENCES = new Set(
302
- (actionsFile.knownDivergences ?? [])
303
- .filter((entry) => entry.adapters.includes("prisma"))
304
- .map((entry) => entry.action)
305
- );
306
- const EXPECTED_UNSUPPORTED_ACTIONS = new Set(
307
- actionsFile.expectedUnsupported.map((entry) => entry.action)
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)
308
116
  );
309
- // Actions whose `== null` probe targets an attribute the oracle OMITS for NULL columns. They
310
- // carry no oracle comparison: under the omitted representation check() denies every row, so the
311
- // adapter must reject the shape rather than emit a filter (#302).
312
- const NULL_REPRESENTATION_OMITTED = actionsFile.nullRepresentationOmitted.map(
313
- (entry): ThrowingAction => [
314
- entry.action,
315
- entry.reason,
316
- requireMessage(
317
- `nullRepresentationOmitted.${entry.action}.messages.prisma`,
318
- entry.messages?.["prisma"]
319
- ),
320
- ]
117
+
118
+ const SEEDS = (readCorpusJson<{ seeds: Seed[] }>("seeds.json")).seeds.filter(
119
+ (seed) => !UNHELD.has(seed.id)
321
120
  );
322
- /** The one message every null-carrying action must be rejected with under `omitted`. */
323
- const NULL_OMITTED_MESSAGE = NULL_REPRESENTATION_OMITTED[0]?.[2] ?? "";
324
- const MANIFEST_ACTIONS = new Set([
325
- ...actionsFile.conformance,
326
- ...EXPECTED_UNSUPPORTED_ACTIONS,
327
- ...NULL_REPRESENTATION_OMITTED.map(([action]) => action),
328
- // ALL divergences, not just Prisma's: a divergence registered solely for another adapter
329
- // must still enter this manifest, so the size tripwire and the classified-exactly-once
330
- // check flag it for triage here instead of letting the action silently vanish from this
331
- // harness. Classification/skipping still uses the Prisma-filtered set.
332
- ...(actionsFile.knownDivergences ?? []).map((entry) => entry.action),
333
- ]);
334
-
335
- // -- the degeneracy guard (conformance/README.md, "The degeneracy guard") -----------------------
336
- //
337
- // A representative sample of the actions this adapter ORACLE-COMPARES, one per hostile group it
338
- // can express. The two lists are asserted to be complements of `ORACLE_ACTIONS`, so neither can
339
- // drift into the other unnoticed.
340
- //
341
- // w1-size-zero-chain, w1-not-size-chain, w1-size-frac-chain, the two string-cast actions and
342
- // filter-as-conjunct are deliberately absent from both lists: their oracles are empty by
343
- // CONSTRUCTION (no seed holds a to-one parent with zero children, nor one with two or more; every
344
- // seed's aString raises in int()/double(); a filter() in boolean position is not evaluable at
345
- // all), so they cannot satisfy a non-empty assertion. filter-as-conjunct carries its own
346
- // anti-vacuity test instead — see "dropping the untranslatable half over-grants" below.
347
-
348
- const DEGENERACY_GUARD_ACTIONS = [
349
- "vf-le",
350
- // Prisma escapes no LIKE metacharacter at all, so every needle-carrying shape in that group is
351
- // a liveness probe below. `[` is the one metacharacter it can leave alone — it is literal on
352
- // every provider but SQL Server, which Prisma's own guard only rejects for hierarchy prefixes —
353
- // so like-bracket is what carries the group's oracle comparison.
354
- "like-bracket",
355
- "all-on-empty",
356
- "pv-exists",
357
- "pv-all",
358
- "null-eq",
359
- "null-ne",
360
- // The explicit-null convention against a non-null operand (#308). All five are compared rather
361
- // than thrown, because the mapper declares the convention per attribute; every one of them
362
- // under-granted by exactly the NULL-column rows before that declaration existed.
363
- "null-value-ne-const",
364
- "null-value-not-eq-const",
365
- "null-value-not-in-const",
366
- "null-value-f2f",
367
- "null-value-pv-not-exists",
368
- // The absent to-one parent (#309/#315/#316/#334): the four discriminating chain shapes Prisma
369
- // translates. Its unsupported siblings are liveness probes below.
370
- "w1-not-exists-chain",
371
- "w1-not-in-chain",
372
- "w1-not-hasint-chain",
373
- "w1-ternary-chain-cond",
374
- // The real to-one join (#375): one per hazard — the negated hop, the null comparison, two-level
375
- // depth, the root conjunction, and the disjunction, whose failure direction is an under-grant.
376
- "rel-not-bool-hop",
377
- "rel-ne-null-hop",
378
- "rel-bool-hop2",
379
- "rel-hop-and-root",
380
- "rel-hop2-or-exists",
381
- // Case sensitivity in STRING MATCHING (#375 follow-up), a different mechanism from cs-eq:
382
- // collation governs `=`, and on SQLite nothing but `PRAGMA case_sensitive_like` governs LIKE.
383
- "cs-contains",
384
- // The primary key as a filterable attribute (#376): the key against a constant, against a
385
- // column under negation — the shape that emitted an invalid `not: { _ref }` before this
386
- // change — the value-first concatenation solved back to a key equality, and the key inside a
387
- // constructed hierarchy path.
388
- "id-eq-const",
389
- "id-f2f-ne",
390
- "id-concat-vf",
391
- "hier-list-id",
392
- // Root position and bare operand forms (#388): one per hazard — the negation over a bare
393
- // ordering (every other negated ordering in the corpus wraps a size() or a ternary), the bare
394
- // boolean at the ROOT of the condition, and the collection subquery disjoined with a scalar
395
- // predicate rather than conjoined with one.
396
- "not-lt",
397
- "root-bare-bool",
398
- "or-eq-exists",
399
- // Hazard classes the corpus missed (#387): the De Morgan branch over a conjunction, the
400
- // value-first hasIntersection whose operands are not interchangeable in the emitted query, and
401
- // the BELOW-cliff unroll of a principal collection — the shape a principal with three teams
402
- // produces, which nothing planned while pv-exists carried only the value-list form.
403
- "not-and",
404
- "vf-hasint",
405
- "pv-exists-unrolled",
406
- ] as const;
407
121
 
408
- /**
409
- * Shapes Prisma refuses to translate: they have no oracle comparison to guard, and stay here as
410
- * PDP/policy liveness probes for a group Prisma's own list cannot cover. See
411
- * cerbos/query-plan-adapters#324.
412
- */
413
- const DEGENERACY_LIVENESS_PROBES = [
414
- // Prisma emits LIKE with no ESCAPE clause, so a % needle throws — and so does a backslash one,
415
- // which is the default escape character on PostgreSQL and MySQL and literal on SQLite
416
- // (cerbos/query-plan-adapters#320).
417
- "like-percent",
418
- "like-backslash",
419
- // every() cannot require the intermediate hop of a chain, and no relation count with an
420
- // arbitrary threshold — >= 0 or a rounded fractional one — has a none/some spelling.
421
- "w1-all-chain",
422
- "w1-size-nonneg-chain",
423
- "w1-size-frac-le-chain",
424
- // Prisma filters have no column arithmetic, so the whole cr-div group (#311) throws.
425
- "cr-div-neg-zero",
426
- // int() over a numeric column: truncation-versus-rounding, unsupported for every adapter but
427
- // convex, which promotes it in adapterSupportedExpected.
428
- "cast-int-double",
429
- // string() has no Prisma filter form (#376). cast-string-bool carries the group's probe rather
430
- // than cast-string-double because its oracle is 14 of 21 rather than a single row, so a PDP or
431
- // policy that went quiet fails the non-total half of the assertion too.
432
- "cast-string-bool",
433
- // Concatenation against the key where BOTH operands carry a column — the arithmetic solver
434
- // needs a value on the other side, so the id-* group's one throwing shape probes here.
435
- "id-concat",
436
- // The same missing form with BOTH operands columns, so there is no constant to invert off
437
- // either side (#391).
438
- "concat-f2f",
439
- // #387, one probe per group Prisma cannot compare. The negated LIKE is refused for its COLUMN
440
- // needle before the negation matters, so the whole not-* group throws; `mod`, a positional read
441
- // of a scalar list, and list equality over a projection have no Prisma filter form at all.
442
- "not-contains",
443
- "arith-mod",
444
- "index-scalar-list",
445
- "map-eq-list",
446
- ] as const;
122
+ function held(allowed: string[]): string[] {
123
+ return allowed.filter((id) => !UNHELD.has(id));
124
+ }
447
125
 
448
- // -- deterministic derived fields (conformance/README.md, "Deterministic derived fields") --------
449
- //
450
- // Read from conformance/derived-fields.json rather than restated here. The same value feeds the
451
- // stored row and the check() oracle, so a transcription error would be self-consistent and
452
- // invisible to the differential; one machine-readable definition is what makes that impossible.
126
+ const DERIVED = (
127
+ readCorpusJson<{ derived: Record<string, DerivedEntry> }>("derived-fields.json")
128
+ ).derived;
453
129
 
454
130
  function derivedFor(seed: Seed): DerivedEntry {
455
- const entry = derivedFile.derived[seed.id];
131
+ const entry = DERIVED[seed.id];
132
+
456
133
  if (entry === undefined) {
457
134
  throw new Error(`derived-fields.json has no entry for seed "${seed.id}"`);
458
135
  }
459
- return entry;
460
- }
461
-
462
- function doubleFor(seed: Seed): number | null {
463
- return derivedFor(seed).aDouble;
464
- }
465
-
466
- /** Third-level label names. A null element is a NULL label name — a missing element attribute. */
467
- function labelsFor(seed: Seed): (string | null)[] {
468
- return derivedFor(seed).labels;
469
- }
470
-
471
- /** Deterministic ISO instant per seed for the timestamp probe: split around 2025-01-01. */
472
- function isoFor(seed: Seed): string {
473
- return derivedFor(seed).createdBy;
474
- }
475
-
476
- function timestampFor(seed: Seed): string | null {
477
- return derivedFor(seed).createdAt;
478
- }
479
136
 
480
- function scopeFor(seed: Seed): string | null {
481
- return derivedFor(seed).scope;
137
+ return entry;
482
138
  }
483
139
 
484
- // -- the real to-one relation (conformance/README.md, "The real to-one relation") ----------------
485
- //
486
- // `parentSeedId` names the seed whose four scalars this row's `parent` carries, and that seed's
487
- // own `parentSeedId` names the ones `parent.inner` carries. The chain is cut at two levels. Every
488
- // resource owns a FRESH parent (and inner) row rather than pointing at the named seed's own row,
489
- // so no two resources share one and a filter that returned the parent instead of the child cannot
490
- // agree with the oracle by accident.
491
-
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.
492
142
  const SEEDS_BY_ID = new Map(SEEDS.map((seed) => [seed.id, seed]));
493
143
 
494
144
  function parentSeedOf(seed: Seed | undefined): Seed | undefined {
495
145
  const id = seed?.parentSeedId;
146
+
496
147
  if (id === undefined || id === null) {
497
148
  return undefined;
498
149
  }
150
+
499
151
  const parent = SEEDS_BY_ID.get(id);
152
+
500
153
  if (parent === undefined) {
501
- throw new Error(
502
- `seeds.json: "${seed?.id}" names parent "${id}", which is not a seed id`
503
- );
154
+ throw new Error(`seeds.json: "${seed?.id}" names parent "${id}", which is not a seed id`);
504
155
  }
156
+
505
157
  return parent;
506
158
  }
507
159
 
508
160
  /** The four scalars one level of the chain stores, as columns. */
509
- function relationColumns(seed: Seed): {
510
- aBool: boolean;
511
- aString: string;
512
- aNumber: number;
513
- aOptionalString: string | null;
514
- } {
161
+ function relationColumns(seed: Seed) {
515
162
  return {
516
163
  aBool: seed.aBool,
517
164
  aString: seed.aString,
@@ -520,63 +167,20 @@ function relationColumns(seed: Seed): {
520
167
  };
521
168
  }
522
169
 
523
- /** The same four as check() attributes: a NULL column is a MISSING attribute, one hop out. */
524
- function relationAttr(seed: Seed): Record<string, Value> {
525
- const attr: Record<string, Value> = {
526
- aBool: seed.aBool,
527
- aString: seed.aString,
528
- aNumber: seed.aNumber,
529
- };
530
- if (seed.aOptionalString !== null) {
531
- attr["aOptionalString"] = seed.aOptionalString;
532
- }
533
- return attr;
534
- }
535
-
536
- /**
537
- * The same mapper with every per-attribute null convention stripped, so the call-level option is
538
- * the only thing governing null operands.
539
- *
540
- * The #302 completeness guard is a statement about that option: every corpus action carrying a
541
- * null literal must be rejected under `"omitted"`. Declaring `owner`/`coOwner` as explicit-null
542
- * (#308) deliberately overrides the option for those two attributes — which would otherwise read
543
- * as the guard going quiet, when in fact it is the per-attribute declaration doing exactly its
544
- * job. Stripping the declarations keeps the guard testing what it was written to test.
545
- */
546
- function withoutNullConventions(
547
- mapper: Record<string, MapperConfig>
548
- ): Record<string, MapperConfig> {
549
- return Object.fromEntries(
550
- Object.entries(mapper).map(([reference, config]) => {
551
- const { nullAttributeRepresentation: _stripped, ...rest } = config;
552
- return [reference, rest];
553
- })
554
- );
555
- }
556
-
557
- const MAPPER_WITHOUT_NULL_CONVENTIONS = withoutNullConventions(MAPPER);
558
-
559
- beforeAll(async () => {
560
- // CEL string matching is case-sensitive, and this adapter lowers contains/startsWith/endsWith
561
- // to LIKE. On SQLite, LIKE is case-INSENSITIVE for ASCII no matter what collation the column
562
- // was created with — only this pragma changes it — so without it every string predicate
563
- // over-grants by exactly the case-variant rows (the `cs-contains` group; proved by c1, "One").
564
- // The column collation the README talks about governs `=`, not LIKE, which is why cs-eq passed
565
- // here for a long time while the string operators did not. ent and sqlalchemy set the same
566
- // pragma; drizzle needs none because it lowers to REPLACE rather than LIKE.
567
- if (STORE_NAME === "sqlite") {
568
- await prisma.$executeRawUnsafe("PRAGMA case_sensitive_like = ON");
569
- }
170
+ async function seedStore(): Promise<void> {
570
171
  await prisma.adversarialInner.deleteMany();
571
172
  await prisma.adversarialParent.deleteMany();
572
173
  await prisma.adversarialLabel.deleteMany();
573
174
  await prisma.adversarialSubCategory.deleteMany();
574
175
  await prisma.adversarialCategory.deleteMany();
575
176
  await prisma.adversarialTag.deleteMany();
177
+ await prisma.adversarialNumberListElement.deleteMany();
178
+ await prisma.adversarialBoolListElement.deleteMany();
576
179
  await prisma.adversarialResource.deleteMany();
577
180
 
578
181
  // Distinct sub-category/category graphs per seed so no rows share relations by accident.
579
182
  for (const seed of SEEDS) {
183
+ const derived = derivedFor(seed);
580
184
  const parentSeed = parentSeedOf(seed);
581
185
  const innerSeed = parentSeedOf(parentSeed);
582
186
  await prisma.adversarialResource.create({
@@ -585,626 +189,389 @@ beforeAll(async () => {
585
189
  aBool: seed.aBool,
586
190
  aString: seed.aString,
587
191
  aNumber: seed.aNumber,
588
- aDouble: doubleFor(seed),
192
+ aDouble: derived.aDouble,
589
193
  aOptionalString: seed.aOptionalString,
590
- createdBy: isoFor(seed),
591
- scope: scopeFor(seed),
592
- createdAt: timestampFor(seed),
593
- tags: {
594
- create: seed.tags.map((t) => ({ tagId: t.id, name: t.name })),
595
- },
596
- // The to-one chain, one owned row per level. A seed with no parent gets no row at all,
597
- // which is what makes the absent-parent hazard reachable through a SCALAR rather than
598
- // only through mainCategory's collection.
599
- ...(parentSeed === undefined
600
- ? {}
601
- : {
602
- parent: {
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
+ : {
603
209
  create: {
604
210
  ...relationColumns(parentSeed),
605
- ...(innerSeed === undefined
606
- ? {}
607
- : { inner: { create: relationColumns(innerSeed) } }),
211
+ inner: innerSeed === undefined ? undefined : { create: relationColumns(innerSeed) },
608
212
  },
609
213
  },
610
- }),
214
+ // One category holding every subcategory name (conformance/README.md, "The dataset").
611
215
  categories: {
612
- create: seed.subCategoryNames.map((subName) => ({
613
- name: "business",
614
- subCategories: {
615
- create: [
616
- {
617
- name: subName,
618
- labels: {
619
- 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
+ },
620
228
  },
621
- },
622
- ],
623
- },
624
- })),
229
+ ],
625
230
  },
626
231
  },
627
232
  });
628
233
  }
629
- });
234
+ }
630
235
 
631
- afterAll(async () => {
632
- await prisma.$disconnect();
633
- });
236
+ // -- the store ------------------------------------------------------------------------------------
634
237
 
635
- function principal(): Principal {
636
- return seedsFile.principal;
637
- }
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
+ };
638
248
 
639
- /** A NULL tag name in the DB is a missing element attribute on the check side. */
640
- function asTagAttribute(tag: Tag): Record<string, Value> {
641
- const attr: Record<string, Value> = { id: tag.id };
642
- if (tag.name !== null) {
643
- attr["name"] = tag.name;
644
- }
645
- return attr;
646
- }
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;
647
262
 
648
- /** Cerbos attributes mirroring exactly what the seeded DB row holds. */
649
- function asCheckResource(seed: Seed): Resource {
650
- const attr: Record<string, Value> = {
651
- aBool: seed.aBool,
652
- aString: seed.aString,
653
- aNumber: seed.aNumber,
654
- createdBy: isoFor(seed),
655
- owner: seed.aOptionalString,
656
- // The explicit-null alias of the `scope` column, the second half of `null-value-f2f`:
657
- // `scope` itself is omitted when NULL (below), so the corpus carries the same column under
658
- // both conventions and the field-to-field probe has two explicit nulls to compare.
659
- coOwner: scopeFor(seed),
660
- tagNames: seed.tags.map((tag) => tag.name),
661
- obj: { inner: seed.aString },
662
- tags: seed.tags.map(asTagAttribute),
663
- categories: seed.subCategoryNames.map((subName) => ({
664
- name: "business",
665
- subCategories: [
666
- {
667
- name: subName,
668
- labels: labelsFor(seed).map((name): Record<string, Value> =>
669
- name === null ? {} : { name }
670
- ),
671
- },
672
- ],
673
- })),
674
- };
675
- // A DB NULL is a missing attribute on the check side — conditions touching it must deny
676
- // (CEL error), matching SQL three-valued logic excluding the row.
677
- if (seed.aOptionalString !== null) {
678
- attr["aOptionalString"] = seed.aOptionalString;
679
- }
680
- const aDouble = doubleFor(seed);
681
- if (aDouble !== null) {
682
- attr["aDouble"] = aDouble;
683
- }
684
- const scope = scopeFor(seed);
685
- if (scope !== null) {
686
- attr["scope"] = scope;
687
- }
688
- const createdAt = timestampFor(seed);
689
- if (createdAt !== null) {
690
- attr["createdAt"] = createdAt;
691
- }
692
- // mainCategory mirrors the row's single category as ONE nested object (the seeder creates
693
- // at most one category per seed), so direct dotted-chain CEL expressions evaluate cleanly;
694
- // rows without a category get NO attribute — a CEL missing-attr error (deny), matching the
695
- // adapter's empty join chain excluding the row.
696
- if (seed.subCategoryNames.length > 0) {
697
- attr["mainCategory"] = {
698
- name: "business",
699
- subCategories: seed.subCategoryNames.map((name) => ({ name })),
700
- subNames: seed.subCategoryNames,
701
- };
702
- }
703
- // The real to-one chain, mirroring the seeded rows exactly. A row with no parent sends NO
704
- // `parent` attribute — a CEL missing-path error (deny) — matching the adapter's join finding
705
- // nothing; the same holds one level down for `parent.inner`.
706
- const parentSeed = parentSeedOf(seed);
707
- if (parentSeed !== undefined) {
708
- const parentAttr = relationAttr(parentSeed);
709
- const innerSeed = parentSeedOf(parentSeed);
710
- if (innerSeed !== undefined) {
711
- parentAttr["inner"] = relationAttr(innerSeed);
712
- }
713
- attr["parent"] = parentAttr;
714
- }
715
- return { kind: seedsFile.resourceKind, id: seed.id, attr };
716
- }
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");
717
269
 
718
- // -- oracle: ask the PDP itself, row by row --
270
+ const models = withoutComments.match(/^model\s[\s\S]*$/m) ?? [];
719
271
 
720
- async function oracleAllowedIds(action: string): Promise<string[]> {
721
- const ids: string[] = [];
722
- for (const seed of SEEDS) {
723
- const result = await cerbos.checkResource({
724
- principal: principal(),
725
- resource: asCheckResource(seed),
726
- actions: [action],
727
- });
728
- if (result.isAllowed(action)) {
729
- ids.push(seed.id);
730
- }
731
- }
732
- return ids.sort();
272
+ return models.join("\n").replace(/[ \t]+/g, " ").replace(/\n\s*\n/g, "\n").trim();
733
273
  }
734
274
 
735
- /** The degeneracy guard's per-action assertion, labelled so a failure names the action. */
736
- async function expectNonDegenerateOracle(action: string): Promise<void> {
737
- const ids = await oracleAllowedIds(action);
738
- expect({
739
- action,
740
- nonEmpty: ids.length > 0,
741
- nonTotal: ids.length < SEEDS.length,
742
- }).toEqual({ action, nonEmpty: true, nonTotal: true });
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();
743
279
  }
744
280
 
745
- // -- adapter execution through the public queryPlanToPrisma path --
746
-
747
- async function adapterFilteredIds(
748
- action: string,
749
- nullAttributeRepresentation: "explicit" | "omitted" = "explicit",
750
- mapper: Record<string, MapperConfig> = MAPPER
751
- ): Promise<string[]> {
752
- const queryPlan = await cerbos.planResources({
753
- principal: principal(),
754
- resource: { kind: seedsFile.resourceKind },
755
- action,
756
- });
757
- const result = queryPlanToPrisma({
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({
758
286
  queryPlan,
759
- mapper,
287
+ mapper: MAPPER,
760
288
  model: MODEL,
761
- nullAttributeRepresentation,
762
- });
763
- if (result.kind === PlanKind.ALWAYS_DENIED) {
764
- return [];
765
- }
766
- const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
767
- const rows = await prisma.adversarialResource.findMany({
768
- where,
769
- select: { id: true },
289
+ nullAttributeRepresentation: "explicit",
770
290
  });
771
- return rows.map((r) => r.id).sort();
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 : {});
298
+ }
299
+
300
+ // -- the ledger -----------------------------------------------------------------------------------
301
+
302
+ interface LedgerEntry {
303
+ status: "unsupported" | "divergent";
304
+ reason: string;
305
+ issue?: string;
306
+ pdp?: string[];
307
+ }
308
+
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;
315
+
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;
772
320
  }
773
321
 
774
- /** Whether any operand anywhere in the plan is a literal null, or a list containing one. */
775
- function planCarriesNullLiteral(operand: unknown): boolean {
776
- if (typeof operand !== "object" || operand === null) return false;
777
- const node = operand as Record<string, unknown>;
778
- if ("value" in node) {
779
- const value = node["value"];
780
- return value === null || (Array.isArray(value) && value.includes(null));
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);
781
336
  }
782
- const operands = node["operands"];
783
- return Array.isArray(operands) && operands.some(planCarriesNullLiteral);
784
337
  }
785
338
 
786
- describe(`adversarial conformance corpus (${STORE_NAME})`, () => {
787
- // Anti-vacuity for the store split: every other assertion in this file is identical on both
788
- // legs, so a PostgreSQL leg that silently fell back to SQLite would pass the entire suite while
789
- // proving nothing about PostgreSQL — the exact gap #320 reports. Ask the connection the suite
790
- // actually queries through which engine it is. Each spelling is rejected by the other engine, so
791
- // this fails in both directions rather than only when the container is missing.
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
+ }
346
+
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"})`;
359
+ });
360
+
361
+ console.log(`conformance (${STORE_NAME}) passed per tier\n${lines.join("\n")}`);
362
+ });
363
+
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.
792
367
  test("executes against the store ADAPTER_TEST_DB selects", async () => {
793
368
  const rows = await prisma.$queryRawUnsafe<{ banner: string }[]>(
794
- STORE_NAME === "postgres"
795
- ? "select version() as banner"
796
- : "select 'SQLite ' || sqlite_version() as banner"
797
- );
798
- expect(rows[0]?.banner?.split(" ")[0]).toBe(
799
- STORE_NAME === "postgres" ? "PostgreSQL" : "SQLite"
369
+ STORE_BANNERS[STORE_NAME].query
800
370
  );
371
+
372
+ expect(rows[0]?.banner?.split(" ")[0]).toBe(STORE_BANNERS[STORE_NAME].engine);
801
373
  });
802
374
 
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: [] });
389
+ }
390
+ );
391
+
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([]);
404
+ }
405
+ );
406
+
803
407
  test("every adversarial schema declares the same data model", () => {
804
408
  const [reference, ...rest] = ADVERSARIAL_SCHEMAS.map((name) => ({
805
409
  name,
806
410
  models: modelBlocks(
807
- fs.readFileSync(path.join(SCHEMA_DIR, name), "utf8")
411
+ fs.readFileSync(path.join(__dirname, "..", "prisma", name), "utf8")
808
412
  ),
809
413
  }));
810
- if (!reference) {
811
- throw new Error("ADVERSARIAL_SCHEMAS is empty");
812
- }
813
- // Guard the guard: a regex that stopped matching would make every schema compare equal on an
814
- // empty string.
815
- expect(reference.models).toContain(`model ${MODEL}`);
816
- for (const schema of rest) {
817
- expect({ name: schema.name, models: schema.models }).toEqual({
818
- name: schema.name,
819
- models: reference.models,
820
- });
821
- }
822
- });
823
414
 
824
- test("adapter-supported expected shapes move from throwing to oracle", () => {
825
- const promotedAction = "promoted-shape";
826
- const classification = classifyActionsForAdapter(
827
- {
828
- conformance: [],
829
- adapterSupportedExpected: {
830
- prisma: [{ action: promotedAction, reason: "supported by Prisma" }],
831
- },
832
- expectedUnsupported: [
833
- {
834
- action: promotedAction,
835
- shape: "synthetic globally unsupported shape",
836
- messages: { prisma: "unsupported" },
837
- },
838
- ],
839
- nullRepresentationOmitted: [],
840
- },
841
- "prisma"
842
- );
415
+ expect(reference?.models).toContain(`model ${MODEL}`);
843
416
 
844
- expect(classification.oracleActions).toContain(promotedAction);
845
- expect(
846
- classification.throwingActions.map(([action]) => action)
847
- ).not.toContain(promotedAction);
417
+ for (const schema of rest) {
418
+ expect(schema).toEqual({ name: schema.name, models: reference?.models });
419
+ }
848
420
  });
849
421
 
850
- // Adding a throwing action without pinning its message must fail this harness rather than
851
- // silently degrade the throw suite to a bare "it threw" (cerbos/query-plan-adapters#326).
852
- test("a throwing action with no pinned message fails classification", () => {
853
- const unpinnedAction = "unpinned-shape";
854
- const classify = (): ActionClassification =>
855
- classifyActionsForAdapter(
856
- {
857
- conformance: [unpinnedAction],
858
- adapterUnsupported: {
859
- prisma: [{ action: unpinnedAction, reason: "synthetic limitation" }],
860
- },
861
- expectedUnsupported: [],
862
- nullRepresentationOmitted: [],
863
- },
864
- "prisma"
865
- );
866
-
867
- expect(classify).toThrow(/pins no throw message/);
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([]);
868
425
  });
869
426
 
870
- test("manifest assigns all 146 policy actions exactly one Prisma outcome", () => {
871
- const oracle = new Set(ORACLE_ACTIONS);
872
- const throwing = new Set(THROWING_ACTIONS.map(([action]) => action));
873
- const nullOmitted = new Set(
874
- NULL_REPRESENTATION_OMITTED.map(([action]) => action)
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}`)
875
434
  );
876
- const misclassified = [...MANIFEST_ACTIONS].filter((action) => {
877
- const classificationCount = [
878
- oracle.has(action),
879
- throwing.has(action),
880
- nullOmitted.has(action),
881
- PRISMA_KNOWN_DIVERGENCES.has(action),
882
- ].filter(Boolean).length;
883
- return classificationCount !== 1;
884
- });
885
435
 
886
- expect(MANIFEST_ACTIONS.size).toBe(199);
887
- // Deliberate tripwire: every one of these carries a pinned message, so a throwing action
888
- // gained or lost has to be re-triaged here rather than joining the suite unnoticed.
889
- expect(THROWING_ACTIONS).toHaveLength(61);
890
- expect(misclassified).toEqual([]);
891
- expect(
892
- [...PRISMA_SUPPORTED_EXPECTED].filter(
893
- (action) => !EXPECTED_UNSUPPORTED_ACTIONS.has(action)
894
- )
895
- ).toEqual([]);
436
+ expect(malformed).toEqual([]);
896
437
  });
897
438
 
898
- test.each(ORACLE_ACTIONS)(
899
- "%s matches the check() oracle",
900
- async (action) => {
901
- const [oracle, filtered] = await Promise.all([
902
- oracleAllowedIds(action),
903
- adapterFilteredIds(action),
904
- ]);
905
- expect(filtered).toEqual(oracle);
906
- }
907
- );
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);
908
443
 
909
- // Shapes the adapter does not support (globally unsupported planner shapes plus Prisma's
910
- // declared adapterUnsupported list): translation must fail loudly, never produce a
911
- // silently-wrong filter. The plan is fetched OUTSIDE the assertion so a PDP failure fails
912
- // the test instead of passing it, and no query executes — the invariant is that the shape
913
- // throws BEFORE a filter exists, so SQLite rejecting a wrongly emitted filter afterwards
914
- // must not be able to masquerade as the adapter refusing to translate.
915
- //
916
- // The message is asserted, not just the throw: a bare `toThrow()` is satisfied by a mapper
917
- // typo or an unrelated validation, which would leave the classification resting on a failure
918
- // that has nothing to do with the limitation it declares (cerbos/query-plan-adapters#326).
919
- test.each(THROWING_ACTIONS)(
920
- "%s fails during translation with the declared message, before any filter exists (%s)",
921
- async (action, _reason, message) => {
922
- const queryPlan = await cerbos.planResources({
923
- principal: principal(),
924
- resource: { kind: seedsFile.resourceKind },
925
- action,
926
- });
927
- expect(queryPlan.kind).toBe(PlanKind.CONDITIONAL);
928
- expect(() =>
929
- queryPlanToPrisma({
930
- queryPlan,
931
- mapper: MAPPER,
932
- model: MODEL,
933
- nullAttributeRepresentation: "explicit",
934
- })
935
- ).toThrow(message);
936
- }
937
- );
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);
938
451
 
939
- // #387. `filter-as-conjunct` puts a filter() one level below the root, where the guard that
940
- // refuses `filter-as-condition` does not look. Its oracle is empty BY CONSTRUCTION — check()
941
- // cannot evaluate a non-boolean conjunction — so it belongs to neither degeneracy-guard list,
942
- // and a bare "it throws" would say nothing about whether refusing it is REQUIRED.
943
- //
944
- // This is that argument. The other conjunct is `R.attr.aBool`, which the adapter certainly can
945
- // express and which `root-bare-bool` spells on its own; an adapter that dropped the conjunct it
946
- // could not translate would emit exactly that filter and return every row it selects, all of
947
- // which the PDP denies for this action.
948
- test("filter-as-conjunct must be refused: dropping its untranslatable half over-grants", async () => {
949
- expect(await oracleAllowedIds("filter-as-conjunct")).toEqual([]);
950
-
951
- const survivingHalf = await adapterFilteredIds("root-bare-bool");
952
- expect(survivingHalf.length).toBeGreaterThan(0);
953
- expect(survivingHalf.length).toBeLessThan(SEEDS.length);
954
-
955
- const message = THROWING_ACTIONS.find(
956
- ([action]) => action === "filter-as-conjunct"
957
- )?.[2];
958
- expect(message).toBeDefined();
959
- await expect(adapterFilteredIds("filter-as-conjunct")).rejects.toThrow(
960
- message
961
- );
962
- });
452
+ return;
453
+ }
963
454
 
964
- // #302. `null-eq-missing` probes `aOptionalString == null`, and `aOptionalString` follows the
965
- // corpus default: a NULL column sends NO attribute. Both halves are asserted because the
966
- // rejection alone would pass vacuously if the adapter threw for an unrelated reason — the
967
- // over-grant under the default representation is what makes the rejection necessary.
968
- test.each(NULL_REPRESENTATION_OMITTED)(
969
- "%s over-grants under the explicit representation and is rejected under omitted (%s)",
970
- async (action, _reason, message) => {
971
- const oracle = await oracleAllowedIds(action);
972
- expect(oracle).toEqual([]);
973
-
974
- // The default translation emits IS NULL and returns exactly the rows the PDP denies.
975
- const overGranted = await adapterFilteredIds(action, "explicit");
976
- expect(overGranted.length).toBeGreaterThan(0);
977
-
978
- await expect(adapterFilteredIds(action, "omitted")).rejects.toThrow(
979
- message
980
- );
981
- }
982
- );
455
+ const entry = ledgerEntry(golden, tag);
456
+ const queryPlan = await planOf(golden);
983
457
 
984
- // #308. The per-attribute declaration overrides the call-level option, which is the property
985
- // that makes a suite mixing both conventions expressible at all. Asserted in both directions
986
- // against the SAME action and the SAME call-level option, varying only whether the mapper
987
- // declares the convention — so a declaration that did nothing would show up here as the two
988
- // runs agreeing.
989
- test("a per-attribute declaration overrides the call-level representation", async () => {
990
- // `owner` declares "explicit", so the call-level "omitted" does not reach it.
991
- await expect(adapterFilteredIds("null-eq", "omitted")).resolves.toEqual(
992
- await oracleAllowedIds("null-eq")
993
- );
458
+ if (entry?.status === "unsupported") {
459
+ expect(() => translatePlan(queryPlan)).toThrow(UnsupportedQueryPlanError);
994
460
 
995
- // Strip the declaration and the same action under the same option is rejected — so the
996
- // stripped mapper the completeness guard below uses is not quietly equivalent to MAPPER.
997
- await expect(
998
- adapterFilteredIds("null-eq", "omitted", MAPPER_WITHOUT_NULL_CONVENTIONS)
999
- ).rejects.toThrow(NULL_OMITTED_MESSAGE);
1000
- });
461
+ return;
462
+ }
1001
463
 
1002
- // #302 completeness guard. The rejection must key off the null OPERAND, not off a list of
1003
- // operators: `hasIntersection(tagNames, ["public", null])` carries one in its value list, and
1004
- // an allowlist of eq/ne/in silently misses it. Enumerating the corpus rather than naming
1005
- // shapes means a newly added action carrying a null constant is covered automatically.
1006
- test("every corpus action carrying a null literal is rejected under omitted", async () => {
1007
- const nullCarrying: string[] = [];
1008
- for (const action of [...MANIFEST_ACTIONS].sort()) {
1009
- const queryPlan = await cerbos.planResources({
1010
- principal: principal(),
1011
- resource: { kind: seedsFile.resourceKind },
1012
- action,
1013
- });
1014
- if (
1015
- queryPlan.kind === PlanKind.CONDITIONAL &&
1016
- planCarriesNullLiteral(queryPlan.condition)
1017
- ) {
1018
- nullCarrying.push(action);
1019
- }
1020
- }
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();
1021
467
 
1022
- // Guard the guard: if the walk stopped finding null operands the loop below is vacuous.
1023
- expect(nullCarrying).toContain("null-eq-missing");
1024
- expect(nullCarrying).toContain("in-null-elem-hasint");
1025
-
1026
- const notRejected: string[] = [];
1027
- for (const action of nullCarrying) {
1028
- try {
1029
- await adapterFilteredIds(
1030
- action,
1031
- "omitted",
1032
- MAPPER_WITHOUT_NULL_CONVENTIONS
1033
- );
1034
- notRejected.push(action);
1035
- } catch (error) {
1036
- // The rejection must be the null-operand check talking, not an incidental failure — a
1037
- // transport error or mapper typo counting as the required rejection is the silent pass
1038
- // the corpus README warns about.
1039
- if (!String(error).includes(NULL_OMITTED_MESSAGE)) {
1040
- notRejected.push(`${action} (rejected for the wrong reason: ${String(error)})`);
468
+ if (entry?.status === "divergent") {
469
+ expect(ids).not.toEqual(allowed);
470
+
471
+ return;
1041
472
  }
473
+
474
+ expect(ids).toEqual(allowed);
475
+ (passed[tag] ??= {})[golden.tier] = (passed[tag]?.[golden.tier] ?? 0) + 1;
1042
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;
1043
482
  }
1044
- expect(notRejected).toEqual([]);
1045
- });
483
+ }
484
+ });
1046
485
 
1047
- test("pins the upstream has() planner over-grant", async () => {
1048
- const action = "p-has";
1049
- expect(PRISMA_KNOWN_DIVERGENCES.has(action)).toBe(true);
1050
- const queryPlan = await cerbos.planResources({
1051
- principal: principal(),
1052
- resource: { kind: seedsFile.resourceKind },
1053
- action,
1054
- });
1055
- const oracle = await oracleAllowedIds(action);
1056
- const allIds = SEEDS.map((seed) => seed.id).sort();
1057
-
1058
- expect(queryPlan.kind).toBe(PlanKind.ALWAYS_ALLOWED);
1059
- expect(oracle.length).toBeGreaterThan(0);
1060
- expect(oracle.length).toBeLessThan(allIds.length);
1061
- expect(oracle).toContain("a1");
1062
- expect(await adapterFilteredIds(action)).toEqual(allIds);
1063
- });
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
+ ]);
1064
515
 
1065
- // The corpus pins two count spellings over the chain — `size(...) == 0` and
1066
- // `!(size(...) > 0)` — and CEL's type checker rules out a third that a real policy could
1067
- // reach through the planner (`>= 1` and `<= 0` are the only other thresholds this adapter
1068
- // can express, and no policy needs both spellings). The guard must nonetheless be a
1069
- // property of the chain rather than of the two spellings that happen to be pinned, so
1070
- // these synthesise the remaining threshold/polarity combinations directly onto the same
1071
- // seeded store and assert the parentless rows stay out of every one
1072
- // (cerbos/query-plan-adapters#316).
1073
- test("every count threshold over the chain inherits the absent-parent guard", async () => {
1074
- const chain = new PlanExpressionVariable(
1075
- "request.resource.attr.mainCategory.subCategories"
1076
- );
1077
- const size = new PlanExpression("size", [chain]);
1078
516
  const compare = (operator: string, threshold: number) =>
1079
517
  new PlanExpression(operator, [size, new PlanExpressionValue(threshold)]);
1080
- const negate = (condition: PlanExpressionOperand) =>
1081
- new PlanExpression("not", [condition]);
1082
-
1083
- const filteredIdsFor = async (
1084
- condition: PlanExpressionOperand
1085
- ): Promise<string[]> => {
1086
- const result = queryPlanToPrisma({
1087
- queryPlan: {
1088
- kind: PlanKind.CONDITIONAL,
1089
- condition,
1090
- cerbosCallId: "synthetic",
1091
- requestId: "synthetic",
1092
- validationErrors: [],
1093
- metadata: undefined,
1094
- },
1095
- mapper: MAPPER,
1096
- model: MODEL,
1097
- });
1098
- expect(result.kind).toBe(PlanKind.CONDITIONAL);
1099
- const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
1100
- const rows = await prisma.adversarialResource.findMany({
1101
- where,
1102
- select: { id: true },
1103
- });
1104
- return rows.map((row) => row.id).sort();
1105
- };
1106
518
 
1107
- // Each of these is TRUE for a row with no mainCategory only if the guard leaks: an
1108
- // absent to-one parent is a CEL missing-path error, so the PDP denies it outright.
1109
- const emptyByConstruction: [string, PlanExpressionOperand][] = [
519
+ const negate = (condition: PlanExpressionOperand) => new PlanExpression("not", [condition]);
520
+
521
+ for (const [label, condition] of [
1110
522
  ["size(chain) == 0", compare("eq", 0)],
1111
523
  ["size(chain) <= 0", compare("le", 0)],
1112
524
  ["size(chain) < 1", compare("lt", 1)],
1113
525
  ["!(size(chain) >= 1)", negate(compare("ge", 1))],
1114
526
  ["!(size(chain) > 0)", negate(compare("gt", 0))],
1115
- ];
1116
-
1117
- for (const [shape, condition] of emptyByConstruction) {
1118
- expect([shape, await filteredIdsFor(condition)]).toEqual([shape, []]);
527
+ ] as const) {
528
+ expect([label, await synthetic(condition)]).toEqual([label, []]);
1119
529
  }
1120
530
 
1121
- // The mirror image, so the loop above cannot pass by denying everything: the negation of
1122
- // an emptiness check is TRUE for exactly the rows that HAVE the parent.
1123
- const withParent = await oracleAllowedIds("w1-size-nonneg-chain");
531
+ // The mirror image, so the loop above cannot pass by denying everything.
532
+ const withParent = allowedFor("relation/size/non-negative-to-one-chain");
1124
533
  expect(withParent.length).toBeGreaterThan(0);
1125
- expect(withParent.length).toBeLessThan(SEEDS.length);
1126
- expect(await filteredIdsFor(negate(compare("eq", 0)))).toEqual(withParent);
1127
- expect(await filteredIdsFor(negate(compare("lt", 1)))).toEqual(withParent);
534
+ expect(await synthetic(negate(compare("eq", 0)))).toEqual(withParent);
535
+ expect(await synthetic(negate(compare("lt", 1)))).toEqual(withParent);
1128
536
  });
1129
537
 
1130
- // The corpus pins ONE ternary whose condition reaches a chain — `w1-ternary-chain-cond`, whose
1131
- // else-branch is a bare `!aBool`. The guard has to be a property of the ternary's false-branch
1132
- // rather than of that one spelling, so these synthesise the other condition positions onto the
1133
- // same seeded store (cerbos/query-plan-adapters#334). Each expectation is a real check() oracle,
1134
- // never a hand-computed row list.
1135
- test("every ternary condition over the chain inherits the absent-parent guard", async () => {
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 () => {
1136
540
  const chainIn = new PlanExpression("in", [
1137
541
  new PlanExpressionValue("finance"),
1138
542
  new PlanExpressionVariable("request.resource.attr.mainCategory.subNames"),
1139
543
  ]);
544
+
1140
545
  const TRUE = new PlanExpressionValue(true);
1141
546
  const FALSE = new PlanExpressionValue(false);
547
+
1142
548
  const ternary = (
1143
549
  condition: PlanExpressionOperand,
1144
550
  thenBranch: PlanExpressionOperand,
1145
551
  elseBranch: PlanExpressionOperand
1146
552
  ) => new PlanExpression("if", [condition, thenBranch, elseBranch]);
1147
553
 
1148
- const filteredIdsFor = async (
1149
- condition: PlanExpressionOperand
1150
- ): Promise<string[]> => {
1151
- const result = queryPlanToPrisma({
1152
- queryPlan: {
1153
- kind: PlanKind.CONDITIONAL,
1154
- condition,
1155
- cerbosCallId: "synthetic",
1156
- requestId: "synthetic",
1157
- validationErrors: [],
1158
- metadata: undefined,
1159
- },
1160
- mapper: MAPPER,
1161
- model: MODEL,
1162
- });
1163
- expect(result.kind).toBe(PlanKind.CONDITIONAL);
1164
- const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
1165
- const rows = await prisma.adversarialResource.findMany({
1166
- where,
1167
- select: { id: true },
1168
- });
1169
- return rows.map((row) => row.id).sort();
1170
- };
1171
-
1172
- // The rows the chain condition is definitively TRUE for, and the ones it is definitively
1173
- // FALSE for. Everything else — every row with no mainCategory at all — is a CEL
1174
- // missing-path error, which selects NEITHER branch.
1175
- const conditionTrue = await oracleAllowedIds("w1-in-chain");
1176
- const conditionFalse = await oracleAllowedIds("w1-not-in-chain");
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");
1177
557
  expect(conditionTrue.length).toBeGreaterThan(0);
1178
558
  expect(conditionFalse.length).toBeGreaterThan(0);
1179
- expect(conditionTrue.length + conditionFalse.length).toBeLessThan(
1180
- SEEDS.length
1181
- );
559
+ expect(conditionTrue.length + conditionFalse.length).toBeLessThan(SEEDS.length);
1182
560
 
1183
- // The else-branch is what a bare `NOT` over the chain filter over-grants: it is TRUE for
1184
- // every parentless row, so each of these returned the 17 missing-parent seeds on top.
1185
- expect(await filteredIdsFor(ternary(chainIn, FALSE, TRUE))).toEqual(
1186
- conditionFalse
1187
- );
561
+ expect(await synthetic(ternary(chainIn, FALSE, TRUE))).toEqual(conditionFalse);
1188
562
  expect(
1189
- await filteredIdsFor(
1190
- ternary(new PlanExpression("not", [chainIn]), TRUE, FALSE)
1191
- )
563
+ await synthetic(ternary(new PlanExpression("not", [chainIn]), TRUE, FALSE))
1192
564
  ).toEqual(conditionFalse);
1193
- // A `not` condition in false-branch position: the double negation collapses back to the
1194
- // positive membership, which excludes the parentless rows by itself.
1195
565
  expect(
1196
- await filteredIdsFor(
1197
- ternary(new PlanExpression("not", [chainIn]), FALSE, TRUE)
1198
- )
566
+ await synthetic(ternary(new PlanExpression("not", [chainIn]), FALSE, TRUE))
1199
567
  ).toEqual(conditionTrue);
1200
568
 
1201
- // A conjunction condition needs De Morgan, not an outer NOT with the hops ANDed beside it:
1202
- // CEL's `&&` absorbs an erroring operand when the other is FALSE, so a parentless row with
1203
- // aBool=false makes the whole condition definitively FALSE and DOES select the else-branch.
1204
- const aBoolFalse = SEEDS.filter((seed) => !seed.aBool).map((seed) => seed.id);
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);
1205
572
  expect(aBoolFalse.length).toBeGreaterThan(0);
1206
573
  expect(
1207
- await filteredIdsFor(
574
+ await synthetic(
1208
575
  ternary(
1209
576
  new PlanExpression("and", [
1210
577
  chainIn,
@@ -1216,66 +583,4 @@ describe(`adversarial conformance corpus (${STORE_NAME})`, () => {
1216
583
  )
1217
584
  ).toEqual([...new Set([...conditionFalse, ...aBoolFalse])].sort());
1218
585
  });
1219
-
1220
- // The to-one relation carries no corpus action yet — this is the expand half of
1221
- // cerbos/query-plan-adapters#372's expand–contract — so nothing else in this file would notice a
1222
- // seeder that stored no chain at all, or one that attached every parent to the wrong resource.
1223
- // Read the two hops back through a real join rather than counting rows: a count cannot tell an
1224
- // inner row carrying the corpus's values from one carrying the root's own columns, which is
1225
- // exactly the flat-column-alias failure this relation exists to make visible.
1226
- test("the seeded to-one chain matches the corpus relation", async () => {
1227
- const withParent = SEEDS.filter((seed) => parentSeedOf(seed) !== undefined);
1228
- const withInner = SEEDS.filter(
1229
- (seed) => parentSeedOf(parentSeedOf(seed)) !== undefined
1230
- );
1231
- expect(withParent.length).toBeGreaterThan(0);
1232
- expect(withInner.length).toBeGreaterThan(0);
1233
- expect(withParent.length).toBeLessThan(SEEDS.length);
1234
-
1235
- const joined = await prisma.adversarialResource.findMany({
1236
- select: {
1237
- id: true,
1238
- parent: {
1239
- select: { aString: true, inner: { select: { aString: true } } },
1240
- },
1241
- },
1242
- });
1243
- const stored = Object.fromEntries(
1244
- joined.map((row) => [
1245
- row.id,
1246
- [row.parent?.aString ?? null, row.parent?.inner?.aString ?? null],
1247
- ])
1248
- );
1249
- const expected = Object.fromEntries(
1250
- SEEDS.map((seed) => [
1251
- seed.id,
1252
- [
1253
- parentSeedOf(seed)?.aString ?? null,
1254
- parentSeedOf(parentSeedOf(seed))?.aString ?? null,
1255
- ],
1256
- ])
1257
- );
1258
- expect(stored).toEqual(expected);
1259
- });
1260
-
1261
- test("oracle is not degenerate", async () => {
1262
- // Guard the guard: each of these actions must produce a non-empty, non-total oracle set,
1263
- // otherwise the differential comparison could pass vacuously (e.g. PDP denying all).
1264
- //
1265
- // Every entry is asserted to be an action Prisma actually oracle-compares. A list copied
1266
- // from another harness drifts into naming shapes this adapter never compares, which guard
1267
- // nothing (cerbos/query-plan-adapters#324); the membership assertion turns moving an action
1268
- // into Prisma's `adapterUnsupported` set into a failure here rather than a silent no-op.
1269
- for (const action of DEGENERACY_GUARD_ACTIONS) {
1270
- expect(ORACLE_ACTIONS).toContain(action);
1271
- await expectNonDegenerateOracle(action);
1272
- }
1273
- // Shapes Prisma refuses to translate, so there is no comparison behind them: these carry
1274
- // PDP/policy liveness for their group only. Asserting the complement keeps the split
1275
- // honest — an action Prisma gains support for must move up into the guard proper.
1276
- for (const action of DEGENERACY_LIVENESS_PROBES) {
1277
- expect(ORACLE_ACTIONS).not.toContain(action);
1278
- await expectNonDegenerateOracle(action);
1279
- }
1280
- });
1281
586
  });