@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
package/src/corpus.ts CHANGED
@@ -1,289 +1,437 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
+ import { isDeepStrictEqual } from "util";
3
4
 
4
- import {
5
- PlanExpression,
6
- PlanExpressionValue,
7
- PlanExpressionVariable,
8
- } from "@cerbos/core";
9
- import type {
10
- PlanExpressionOperand,
11
- PlanResourcesResponse,
12
- Value,
13
- } from "@cerbos/core";
5
+ import { fromBinary, fromJson, fromJsonString, toBinary } from "@bufbuild/protobuf";
6
+ import type { JsonObject, JsonValue } from "@bufbuild/protobuf";
7
+ import type { PlanResourcesRequest } from "@cerbos/api/cerbos/request/v1/request_pb";
8
+ import type { PlanResourcesResponse as PlanResourcesResponseMessage } from "@cerbos/api/cerbos/response/v1/response_pb";
9
+ import { CerbosService } from "@cerbos/api/cerbos/svc/v1/svc_pb";
10
+ import type { PlanResourcesResponse } from "@cerbos/core";
11
+ import { GRPC } from "@cerbos/grpc";
12
+ import { HTTP } from "@cerbos/http";
13
+ import { Server, ServerCredentials } from "@grpc/grpc-js";
14
+ import type { sendUnaryData, ServerUnaryCall } from "@grpc/grpc-js";
15
+ import { afterAll } from "@jest/globals";
14
16
 
15
- import { PlanKind, MapperConfig } from ".";
17
+ import type { MapperConfig } from ".";
18
+ import { isObject, isString } from "./narrowing";
16
19
 
17
20
  /**
18
- * The parts of the shared `../conformance/` corpus that both of this adapter's suites read.
19
- *
20
- * `adversarial.test.ts` plans against a real PDP and executes the translated query;
21
- * `translator.test.ts` reads the same actions off the golden wire fixtures and asserts nothing
22
- * but the emitted filter. They must agree on two things or they prove less than they appear to:
23
- *
24
- * - **the mapper.** The unit test pins the filter this adapter emits for a mapping; the harness
25
- * proves that same filter returns the rows the PDP allows. Two copies that drifted would leave
26
- * the pinned filters describing a mapping no harness ever executes.
27
- * - **the classification.** Which actions this adapter must refuse, and with which message, is a
28
- * corpus decision (`actions.json`), not a per-suite one.
21
+ * What both of this adapter's suites read from the shared `../conformance/` corpus: the recorded
22
+ * golden plans and the one mapper every case is translated through. `adversarial.test.ts` replays
23
+ * the goldens against a real store; `translator.test.ts` uses the same mapper for the caller-option
24
+ * tests the corpus cannot vary.
29
25
  *
26
+ * Duplicated across adapters on purpose — adapters share data, not code (ADR 0007).
30
27
  * Test-only: excluded from `tsc --build` by `tsconfig.json`, so nothing here reaches `lib/`.
31
28
  */
32
29
 
33
- export const CONFORMANCE_DIR = path.join(
34
- __dirname,
35
- "..",
36
- "..",
37
- "conformance"
38
- );
30
+ export const CONFORMANCE_DIR = path.join(__dirname, "..", "..", "conformance");
39
31
 
40
- const WIRE_FIXTURES_DIR = path.join(CONFORMANCE_DIR, "wire-fixtures");
32
+ /** A file the conformance generator wrote: its shape is the generator's contract with these types. */
33
+ function readJson<T>(file: string): T {
34
+ return JSON.parse(fs.readFileSync(file, "utf8"));
35
+ }
36
+
37
+ export function readCorpusJson<T>(file: string): T {
38
+ return readJson(path.join(CONFORMANCE_DIR, file));
39
+ }
41
40
 
42
41
  /** The Prisma model the corpus's resource kind maps onto. */
43
42
  export const MODEL = "AdversarialResource";
44
43
 
45
- // -- actions.json --------------------------------------------------------------------------------
44
+ // -- the golden files ----------------------------------------------------------------------------
45
+
46
+ export interface Golden {
47
+ id: string;
48
+ pdp: string;
49
+ tier: "core" | "extended" | "adversarial";
50
+ plan: JsonObject;
51
+ allowed: string[];
52
+ plannerDivergence: { pdp?: string[]; issue: string; reason?: string } | null;
53
+ }
54
+
55
+ /** The PDP tags the goldens were recorded against: current first, then previous. */
56
+ export function pdpTags(): string[] {
57
+ const versions = readCorpusJson<Record<
58
+ "current" | "previous",
59
+ { tag: string }
60
+ >>("pdp-versions.json");
46
61
 
47
- export interface UnsupportedShape {
48
- action: string;
49
- shape: string;
50
- /** One entry per adapter that must reject the shape; the corpus asserts the key set. */
51
- messages: Record<string, string>;
62
+ return [versions.current.tag, versions.previous.tag];
52
63
  }
53
64
 
54
- export interface AdapterUnsupportedEntry {
55
- action: string;
56
- reason: string;
57
- /** Absent on `adapterSupportedExpected` / `nullRepresentationOmitted`, required on a throw. */
58
- message?: string;
65
+ /** Every golden file recorded against `tag`, sorted by case id. */
66
+ export function readGoldens(tag: string): Golden[] {
67
+ const root = FUZZ_DIR ? path.join(FUZZ_DIR, tag) : path.join(CONFORMANCE_DIR, "golden", tag);
68
+
69
+ const files = fs.readdirSync(root, { recursive: true, encoding: "utf8" })
70
+ .filter((file) => file.endsWith(".json"))
71
+ .map((file) => readJson<Golden>(path.join(root, file)));
72
+
73
+ return files.sort((a, b) => a.id.localeCompare(b.id));
59
74
  }
60
75
 
76
+ export function readGolden(tag: string, id: string): Golden {
77
+ return readJson(path.join(CONFORMANCE_DIR, "golden", tag, `${id}.json`));
78
+ }
79
+
80
+ // -- fuzz mode ------------------------------------------------------------------------------------
81
+
61
82
  /**
62
- * A `nullRepresentationOmitted` entry. Every adapter must reject these — the two NULL conventions
63
- * are indistinguishable on the wire — so `messages` names the whole roster with no promotions to
64
- * subtract.
83
+ * The directory a fuzz run was recorded into (conformance/README.md, "Fuzzing"). When it is set,
84
+ * `readGoldens` reads the generated cases from it instead of the corpus, and the harness judges
85
+ * each case by `fuzzVerdict` instead of the ledger: a refusal is always acceptable, since the
86
+ * ledger has no entry for a shape nobody has written down yet.
65
87
  */
66
- export interface NullRepresentationOmittedEntry {
67
- action: string;
68
- reason: string;
69
- messages: Record<string, string>;
88
+ export const FUZZ_DIR = process.env["QPA_FUZZ_DIR"] || undefined;
89
+
90
+ export interface FuzzVerdict {
91
+ pdp: string;
92
+ id: string;
93
+ verdict: "match" | "refused" | "over-grant" | "under-grant" | "error";
94
+ expected?: string[];
95
+ returned?: string[];
96
+ detail?: string;
97
+ }
98
+
99
+ /** Judges the ids a translated filter returned against the ids the PDP allowed. */
100
+ export function fuzzVerdict(golden: Golden, expected: string[], returned: string[]): FuzzVerdict {
101
+ const base = { pdp: golden.pdp, id: golden.id };
102
+
103
+ // Compared element by element: under jest the arrays come from different realms, which
104
+ // isDeepStrictEqual tells apart.
105
+ if (returned.length === expected.length && returned.every((id, i) => id === expected[i])) {
106
+ return { ...base, verdict: "match" };
107
+ }
108
+
109
+ const allowed = new Set(expected);
110
+ const got = new Set(returned);
111
+
112
+ const verdict = returned.some((id) => !allowed.has(id))
113
+ ? "over-grant"
114
+ : expected.some((id) => !got.has(id))
115
+ ? "under-grant"
116
+ : "error";
117
+
118
+ if (verdict === "error") {
119
+ return { ...base, verdict, expected, returned, detail: "the right rows, but repeated or out of order" };
120
+ }
121
+
122
+ return { ...base, verdict, expected, returned };
123
+ }
124
+
125
+ /** A translation or query that threw, judged: the adapter's refusal type is the invariant working. */
126
+ export function fuzzThrown(golden: Golden, cause: unknown, refusal: abstract new (...args: never[]) => Error): FuzzVerdict {
127
+ const base = { pdp: golden.pdp, id: golden.id };
128
+
129
+ if (cause instanceof refusal) return { ...base, verdict: "refused" };
130
+
131
+ return { ...base, verdict: "error", detail: cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause) };
132
+ }
133
+
134
+ /** Fails the case unless its verdict is a match or a refusal. */
135
+ export function assertFuzzVerdict(verdict: FuzzVerdict): void {
136
+ if (verdict.verdict === "match" || verdict.verdict === "refused") return;
137
+ throw new Error(
138
+ `${verdict.verdict}: expected [${verdict.expected?.join(", ") ?? ""}], returned [${verdict.returned?.join(", ") ?? ""}]${verdict.detail ? `: ${verdict.detail}` : ""}`,
139
+ );
70
140
  }
71
141
 
72
- export interface KnownDivergence {
73
- action: string;
74
- adapters: string[];
142
+ /** Writes `<FUZZ_DIR>/results/<adapter>-<store>.json`, which `fuzz-report` reads. */
143
+ export function writeFuzzResults(adapter: string, store: string, results: FuzzVerdict[]): void {
144
+ if (!FUZZ_DIR) return;
145
+ const dir = path.join(FUZZ_DIR, "results");
146
+ fs.mkdirSync(dir, { recursive: true });
147
+ const sorted = [...results].sort((a, b) => a.id.localeCompare(b.id) || a.pdp.localeCompare(b.pdp));
148
+ fs.writeFileSync(
149
+ path.join(dir, `${adapter}-${store}.json`),
150
+ `${JSON.stringify({ adapter, store, results: sorted }, null, 2)}\n`,
151
+ );
75
152
  }
76
153
 
77
- export interface ActionsFile {
78
- conformance: string[];
79
- adapterUnsupported?: Record<string, AdapterUnsupportedEntry[]>;
80
- adapterSupportedExpected?: Record<string, AdapterUnsupportedEntry[]>;
81
- expectedUnsupported: UnsupportedShape[];
82
- nullRepresentationOmitted: NullRepresentationOmittedEntry[];
83
- knownDivergences?: KnownDivergence[];
154
+ /**
155
+ * The instant substituted for `__NOW_MINUS_24H__`, the literal the planner folds
156
+ * `now() - duration("24h")` into. The PDP folds its clock at nanosecond precision, so the
157
+ * substitute carries sub-millisecond digits too: a tidy millisecond instant would translate here
158
+ * while the same case refuses in production (`Timestamp value exceeds millisecond precision`).
159
+ */
160
+ export function nowMinus24h(): string {
161
+ const ms = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
162
+
163
+ return `${ms.slice(0, -1)}456789Z`;
84
164
  }
85
165
 
166
+ // -- the plans, as the SDK returns them ----------------------------------------------------------
167
+
168
+ /*
169
+ * A golden reaches the adapter through both official JS clients, `@cerbos/http` and
170
+ * `@cerbos/grpc`, so it arrives as an application's `planResources` call returns it. Only the PDP
171
+ * is replaced: each transport has a stub that answers PlanResources with the response registered
172
+ * under the request's `requestId`, a token unique to one call. A plan the two clients decode
173
+ * differently fails the case, so every case also proves the adapter is handed the same input
174
+ * whichever transport the application uses.
175
+ */
176
+
86
177
  /**
87
- * A shape this adapter must refuse, with the substring its error has to contain.
88
- *
89
- * The message is what turns "it threw" into "it threw for the declared reason": without it a
90
- * mapper typo or an unrelated validation satisfies the assertion just as well as the limitation
91
- * the corpus documents (cerbos/query-plan-adapters#326).
178
+ * The base URL of the stubbed HTTP PDP. `.invalid` never resolves, so a request that escapes the
179
+ * stub fails rather than reaching a real server.
92
180
  */
93
- export type ThrowingAction = readonly [
94
- action: string,
95
- reason: string,
96
- message: string,
97
- ];
98
-
99
- export interface ActionClassification {
100
- oracleActions: string[];
101
- throwingActions: ThrowingAction[];
102
- supportedExpected: Set<string>;
181
+ const STUB_HTTP_PDP = "http://stub-pdp.invalid";
182
+
183
+ const planResources = CerbosService.method.planResources;
184
+
185
+ /** The response bodies waiting to be served, by the `requestId` of the call that will read them. */
186
+ const pendingResponses = new Map<string, JsonObject>();
187
+ let requestCount = 0;
188
+
189
+ function takeResponse(requestId: string): JsonObject {
190
+ const body = pendingResponses.get(requestId);
191
+ pendingResponses.delete(requestId);
192
+
193
+ if (body === undefined) throw new Error(`stub PDP: no response for request ${requestId}`);
194
+
195
+ return body;
103
196
  }
104
197
 
105
- /** The pinned message, or a failure — a throwing action without one asserts nothing. */
106
- export function requireMessage(
107
- label: string,
108
- message: string | undefined
109
- ): string {
110
- if (message === undefined || message === "") {
111
- throw new Error(
112
- `actions.json pins no throw message for ${label}: the throw suite would accept a failure for any reason`
113
- );
114
- }
115
- return message;
198
+ function requestUrl(input: Parameters<typeof fetch>[0]): string {
199
+ if (isString(input)) return input;
200
+
201
+ return input instanceof URL ? input.href : input.url;
116
202
  }
117
203
 
118
- export function classifyActionsForAdapter(
119
- manifest: ActionsFile,
120
- adapter: string
121
- ): ActionClassification {
122
- const unsupported = manifest.adapterUnsupported?.[adapter] ?? [];
123
- const unsupportedActions = new Set(unsupported.map((entry) => entry.action));
124
- const supportedExpected = new Set(
125
- (manifest.adapterSupportedExpected?.[adapter] ?? []).map(
126
- (entry) => entry.action
127
- )
128
- );
129
- const oracleActions = [
130
- ...manifest.conformance.filter(
131
- (action) => !unsupportedActions.has(action)
132
- ),
133
- ...supportedExpected,
134
- ];
135
- const throwingActions: ThrowingAction[] = [
136
- ...unsupported.map(
137
- (entry): ThrowingAction => [
138
- entry.action,
139
- entry.reason,
140
- requireMessage(`adapterUnsupported.${adapter}.${entry.action}`, entry.message),
141
- ]
142
- ),
143
- ...manifest.expectedUnsupported
144
- .filter((entry) => !supportedExpected.has(entry.action))
145
- .map((entry): ThrowingAction => [
146
- entry.action,
147
- entry.shape,
148
- requireMessage(
149
- `expectedUnsupported.${entry.action}.messages.${adapter}`,
150
- entry.messages?.[adapter]
151
- ),
152
- ]),
153
- ];
154
-
155
- return {
156
- oracleActions: [...new Set(oracleActions)].sort(),
157
- throwingActions: throwingActions.sort(([left], [right]) =>
158
- left.localeCompare(right)
159
- ),
160
- supportedExpected,
204
+ /**
205
+ * An `@cerbos/http` client whose PlanResources call is answered here. Only requests to
206
+ * `STUB_HTTP_PDP` are intercepted; every other `fetch` goes through untouched.
207
+ */
208
+ function httpClient(): HTTP {
209
+ const passthrough = globalThis.fetch;
210
+ globalThis.fetch = async (input, init) => {
211
+ if (!requestUrl(input).startsWith(STUB_HTTP_PDP)) return passthrough(input, init);
212
+ const { requestId } = fromJsonString(planResources.input, String(init?.body), { ignoreUnknownFields: true });
213
+
214
+ return new Response(toProtoJson(takeResponse(requestId)), {
215
+ status: 200,
216
+ headers: { "Content-Type": "application/json" },
217
+ });
161
218
  };
162
- }
163
219
 
164
- // -- the golden wire fixtures --------------------------------------------------------------------
220
+ return new HTTP(STUB_HTTP_PDP);
221
+ }
165
222
 
166
223
  /**
167
- * The instant `regenerate-wire-fixtures.sh` substitutes for the one operand it cannot pin.
168
- *
169
- * `ts-window` and `ts-vf` compare against `now() - duration("24h")`, which the planner folds to a
170
- * literal timestamp: a different value on every capture, so the script rewrites it to
171
- * `__NOW_MINUS_24H__` to keep the drift check deterministic. Reading the fixture back therefore
172
- * means choosing a value, and the choice is load-bearing rather than arbitrary — Cerbos emits the
173
- * PDP's clock at nanosecond precision, which is exactly why both actions are `adapterUnsupported`
174
- * for this adapter (`Timestamp value exceeds millisecond precision`). A tidy millisecond instant
175
- * here would translate cleanly and quietly contradict `actions.json`, so the fraction carries the
176
- * nine digits a real plan carries. `translator.test.ts` pins both sides of that boundary, which is
177
- * what the `plannedAt` override on `planFromWireFixture` is for.
224
+ * An `@cerbos/grpc` client connected to an in-process gRPC server on a loopback port, which
225
+ * answers PlanResources with the registered response encoded as the protobuf a PDP sends.
178
226
  */
179
- export const PLANNED_AT = "2026-08-11T09:13:39.123456789Z";
227
+ async function grpcClient(): Promise<{ client: GRPC; server: Server }> {
228
+ const server = new Server();
229
+ server.addService(
230
+ {
231
+ planResources: {
232
+ path: `/${CerbosService.typeName}/${planResources.name}`,
233
+ requestStream: false,
234
+ responseStream: false,
235
+ requestSerialize: (message: PlanResourcesRequest) =>
236
+ Buffer.from(toBinary(planResources.input, message)),
237
+ requestDeserialize: (bytes: Buffer) => fromBinary(planResources.input, bytes),
238
+ responseSerialize: (message: PlanResourcesResponseMessage) =>
239
+ Buffer.from(toBinary(planResources.output, message)),
240
+ responseDeserialize: (bytes: Buffer) => fromBinary(planResources.output, bytes),
241
+ },
242
+ },
243
+ {
244
+ planResources: (
245
+ call: ServerUnaryCall<PlanResourcesRequest, PlanResourcesResponseMessage>,
246
+ respond: sendUnaryData<PlanResourcesResponseMessage>
247
+ ) => {
248
+ try {
249
+ respond(null, fromJson(planResources.output, takeResponse(call.request.requestId)));
250
+ } catch (error) {
251
+ respond(error instanceof Error ? error : new Error(String(error)), null);
252
+ }
253
+ },
254
+ }
255
+ );
180
256
 
181
- interface WireOperand {
182
- expression?: { operator: string; operands: WireOperand[] };
183
- variable?: string;
184
- value?: unknown;
257
+ const port = await new Promise<number>((resolve, reject) =>
258
+ server.bindAsync("127.0.0.1:0", ServerCredentials.createInsecure(), (error, bound) =>
259
+ error ? reject(error) : resolve(bound)
260
+ )
261
+ );
262
+
263
+ // The stub is on loopback; never route it through an ambient proxy.
264
+ const client = new GRPC(`127.0.0.1:${port}`, {
265
+ tls: false,
266
+ channelOptions: { "grpc.enable_http_proxy": 0 },
267
+ });
268
+
269
+ return { client, server };
185
270
  }
186
271
 
187
- interface WireFixture {
188
- action: string;
189
- resourceKind: string;
190
- filter: { kind: string; condition?: WireOperand };
272
+ let stubClients: Promise<{ http: HTTP; grpc: GRPC; server: Server }> | undefined;
273
+
274
+ function sdkClients(): Promise<{ http: HTTP; grpc: GRPC; server: Server }> {
275
+ stubClients ??= grpcClient().then(({ client, server }) => ({
276
+ http: httpClient(),
277
+ grpc: client,
278
+ server,
279
+ }));
280
+
281
+ return stubClients;
191
282
  }
192
283
 
193
- function operandFromWire(
194
- node: WireOperand,
195
- plannedAt: string
196
- ): PlanExpressionOperand {
197
- if (node.expression) {
198
- return new PlanExpression(
199
- node.expression.operator,
200
- node.expression.operands.map((child) => operandFromWire(child, plannedAt))
284
+ afterAll(async () => {
285
+ if (!stubClients) return;
286
+ const { grpc, server } = await stubClients;
287
+ grpc.close();
288
+ server.forceShutdown();
289
+ });
290
+
291
+ /**
292
+ * JSON as the PDP writes it. `JSON.stringify` writes -0 as `0`, where protojson keeps the sign, so
293
+ * a stub serialising with it would hand the SDK a different number from the one the PDP sends.
294
+ */
295
+ function toProtoJson(node: JsonValue): string {
296
+ if (Object.is(node, -0)) return "-0";
297
+
298
+ if (Array.isArray(node)) return `[${node.map(toProtoJson).join(",")}]`;
299
+
300
+ if (isObject(node)) {
301
+ const members = Object.entries(node).map(
302
+ ([key, child]) => `${JSON.stringify(key)}:${toProtoJson(child)}`
201
303
  );
304
+
305
+ return `{${members.join(",")}}`;
202
306
  }
203
- if (node.variable !== undefined) {
204
- return new PlanExpressionVariable(node.variable);
205
- }
206
- if (!("value" in node)) {
207
- throw new Error(
208
- `Wire fixture operand is neither an expression, a variable nor a value: ${JSON.stringify(node)}`
307
+
308
+ return JSON.stringify(node);
309
+ }
310
+
311
+ function substituteNow(node: JsonValue, now: string): JsonValue {
312
+ if (node === "__NOW_MINUS_24H__") return now;
313
+
314
+ if (Array.isArray(node)) return node.map((child) => substituteNow(child, now));
315
+
316
+ if (isObject(node)) {
317
+ return Object.fromEntries(
318
+ Object.entries(node).map(([key, child]) => [key, substituteNow(child, now)])
209
319
  );
210
320
  }
211
- // The one cast in this file. A fixture is JSON the PDP produced, so its leaves are already
212
- // exactly the JSON shapes `Value` admits — but `JSON.parse` cannot say so, and re-validating a
213
- // file the corpus workflow regenerates and diffs would assert nothing new.
214
- return new PlanExpressionValue(
215
- (node.value === "__NOW_MINUS_24H__" ? plannedAt : node.value) as Value
216
- );
321
+
322
+ return node;
217
323
  }
218
324
 
219
- /** Every action the corpus has a golden wire fixture for, sorted. */
220
- export function wireFixtureActions(): string[] {
221
- return fs
222
- .readdirSync(WIRE_FIXTURES_DIR)
223
- .filter((name) => name.endsWith(".json"))
224
- .map((name) => name.slice(0, -".json".length))
225
- .sort();
325
+ /** One PlanResources call through `client`, answered with `response`. */
326
+ function fetchPlan(
327
+ client: HTTP | GRPC,
328
+ golden: Golden,
329
+ response: JsonObject
330
+ ): Promise<PlanResourcesResponse> {
331
+ const requestId = `${golden.id}#${++requestCount}`;
332
+ pendingResponses.set(requestId, response);
333
+
334
+ return client.planResources({
335
+ requestId,
336
+ principal: { id: "stub", roles: ["USER"] },
337
+ resource: { kind: "conformance" },
338
+ action: golden.id,
339
+ });
226
340
  }
227
341
 
228
342
  /**
229
- * The plan the pinned PDP produced for `action`, decoded into the shape the SDK hands callers.
230
- *
231
- * The fixture is the PDP's HTTP response, so the decoding here is the one `@cerbos/http` performs
232
- * — `{expression|variable|value}` nodes into `PlanExpression` / `PlanExpressionVariable` /
233
- * `PlanExpressionValue`. It is deliberately not a hand-built plan: a plan somebody typed is a
234
- * belief about what the planner emits, and this repository keeps fixtures precisely because that
235
- * belief has been wrong before. See docs/adr/0006.
343
+ * A golden's plan as the SDK returns it: the recorded `filter` is served as the response to a
344
+ * stubbed PlanResources call over each transport, so the SDK's own decoding produces what the
345
+ * adapter receives.
236
346
  */
237
- export function planFromWireFixture(
238
- action: string,
239
- plannedAt: string = PLANNED_AT
240
- ): PlanResourcesResponse {
241
- const fixture: WireFixture = JSON.parse(
242
- fs.readFileSync(path.join(WIRE_FIXTURES_DIR, `${action}.json`), "utf8")
243
- );
244
- const base = {
245
- cerbosCallId: "",
246
- requestId: "",
247
- validationErrors: [],
248
- metadata: undefined,
347
+ export async function planOf(
348
+ golden: Golden,
349
+ now: string = nowMinus24h()
350
+ ): Promise<PlanResourcesResponse> {
351
+ const response: JsonObject = {
352
+ requestId: golden.id,
353
+ action: golden.id,
354
+ resourceKind: "conformance",
355
+ policyVersion: "default",
356
+ filter: substituteNow(golden.plan, now),
249
357
  };
250
- switch (fixture.filter.kind) {
251
- case PlanKind.CONDITIONAL:
252
- if (!fixture.filter.condition) {
253
- throw new Error(`Wire fixture ${action} is conditional with no condition`);
254
- }
255
- return {
256
- ...base,
257
- kind: PlanKind.CONDITIONAL,
258
- condition: operandFromWire(fixture.filter.condition, plannedAt),
259
- };
260
- case PlanKind.ALWAYS_ALLOWED:
261
- case PlanKind.ALWAYS_DENIED:
262
- return { ...base, kind: fixture.filter.kind };
263
- default:
264
- throw new Error(
265
- `Wire fixture ${action} has an unrecognised filter kind ${fixture.filter.kind}`
266
- );
358
+
359
+ const { http, grpc } = await sdkClients();
360
+
361
+ const [overHttp, overGrpc] = await Promise.all([
362
+ fetchPlan(http, golden, response),
363
+ fetchPlan(grpc, golden, response),
364
+ ]);
365
+
366
+ if (!isDeepStrictEqual(overHttp, overGrpc)) {
367
+ throw new Error(`${golden.id}: @cerbos/http and @cerbos/grpc decode the plan differently`);
267
368
  }
369
+
370
+ return overGrpc;
268
371
  }
269
372
 
270
373
  // -- the mapper ----------------------------------------------------------------------------------
271
374
 
272
- export const MAPPER: Record<string, MapperConfig> = {
273
- // The primary key, reached as `request.resource.id` rather than through `attr` (the `id-*`
274
- // actions). It is a mapping like any other here, which is the point: an adapter that resolves
375
+ interface ConformanceMapper {
376
+ [reference: string]: MapperConfig;
377
+ }
378
+
379
+ export const MAPPER: ConformanceMapper = {
380
+ // The primary key, reached as `request.resource.id` rather than through `attr` (the
381
+ // `identifier/*` cases). It is a mapping like any other here, which is the point: an adapter that resolves
275
382
  // references by stripping a `request.resource.attr.` prefix never sees this name.
276
- "request.resource.id": { field: "id" },
277
- "request.resource.attr.aBool": { field: "aBool" },
278
- "request.resource.attr.aString": { field: "aString" },
279
- "request.resource.attr.aNumber": { field: "aNumber" },
280
- "request.resource.attr.aDouble": { field: "aDouble" },
281
- "request.resource.attr.aOptionalString": { field: "aOptionalString" },
282
- "request.resource.attr.createdBy": { field: "createdBy" },
283
- "request.resource.attr.scope": { field: "scope" },
383
+ "request.resource.id": { field: "id", valueType: "string" },
384
+ // Every attribute resources.json OMITS when its column is NULL follows the omitted convention:
385
+ // aBool, aString and aNumber (NULL on j3, j1, j2), aOptionalString, aDouble, scope, createdAt,
386
+ // updatedAt, obj.inner and every `parent` field. A null literal against one is then a
387
+ // missing-attribute error the PDP denies, never an IS NULL match (#528).
388
+ "request.resource.attr.aBool": {
389
+ field: "aBool",
390
+ valueType: "boolean",
391
+ nullable: true,
392
+ nullAttributeRepresentation: "omitted",
393
+ },
394
+ "request.resource.attr.aString": {
395
+ field: "aString",
396
+ valueType: "string",
397
+ nullable: true,
398
+ nullAttributeRepresentation: "omitted",
399
+ },
400
+ "request.resource.attr.aNumber": {
401
+ field: "aNumber",
402
+ valueType: "number",
403
+ nullable: true,
404
+ nullAttributeRepresentation: "omitted",
405
+ },
406
+ "request.resource.attr.aDouble": {
407
+ field: "aDouble",
408
+ valueType: "number",
409
+ nullAttributeRepresentation: "omitted",
410
+ },
411
+ // resources.json OMITS aOptionalString when the column is NULL, so `== null` against it is a
412
+ // missing-attribute error the PDP denies, never an IS NULL match.
413
+ "request.resource.attr.aOptionalString": {
414
+ field: "aOptionalString",
415
+ valueType: "string",
416
+ nullable: true,
417
+ nullAttributeRepresentation: "omitted",
418
+ },
419
+ "request.resource.attr.createdBy": { field: "createdBy", valueType: "string" },
420
+ "request.resource.attr.scope": {
421
+ field: "scope",
422
+ valueType: "string",
423
+ nullable: true,
424
+ nullAttributeRepresentation: "omitted",
425
+ },
284
426
  "request.resource.attr.createdAt": {
285
427
  field: "createdAt",
286
428
  valueType: "dateTime",
429
+ nullAttributeRepresentation: "omitted",
430
+ },
431
+ "request.resource.attr.updatedAt": {
432
+ field: "updatedAt",
433
+ valueType: "dateTime",
434
+ nullAttributeRepresentation: "omitted",
287
435
  },
288
436
  // `owner` and `coOwner` alias columns that `aOptionalString` and `scope` also map, under the
289
437
  // OTHER null convention: the oracle sends a real null attribute for them rather than omitting
@@ -291,42 +439,52 @@ export const MAPPER: Record<string, MapperConfig> = {
291
439
  // and leaves it untouched for every other mapping (cerbos/query-plan-adapters#308).
292
440
  "request.resource.attr.owner": {
293
441
  field: "aOptionalString",
442
+ valueType: "string",
294
443
  nullable: true,
295
444
  nullAttributeRepresentation: "explicit",
296
445
  },
297
446
  "request.resource.attr.coOwner": {
298
447
  field: "scope",
448
+ valueType: "string",
299
449
  nullable: true,
300
450
  nullAttributeRepresentation: "explicit",
301
451
  },
302
- // obj.inner is not a real nested column — mirrors aString, same trick the spring-data
303
- // reference harness uses for the p-struct probe. `parent.inner` below is the opposite: a real
304
- // two-level join. The two are kept side by side on purpose.
305
- "request.resource.attr.obj.inner": { field: "aString" },
306
- // The corpus's one REAL to-one chain (the `rel-*` actions). `type: "one"` is what makes the
452
+ // obj.inner is not a real nested column — mirrors aString, same trick the spring-data reference
453
+ // harness uses for the comparison/equals/nested-map-member case. `parent.inner` below is the
454
+ // opposite: a real two-level join. The two are kept side by side on purpose.
455
+ "request.resource.attr.obj.inner": {
456
+ field: "aString",
457
+ valueType: "string",
458
+ nullable: true,
459
+ nullAttributeRepresentation: "omitted",
460
+ },
461
+ // The corpus's one REAL to-one chain (the `relation/*` cases). `type: "one"` is what makes the
307
462
  // adapter emit `is:` rather than `some:`, and `is:` on an optional relation is what requires
308
463
  // the hop to exist — the absent-parent guard the negated shapes discriminate. `inner` nests
309
464
  // the same declaration one level further out; two levels is where alias scoping breaks.
465
+ // A column reached through a relation takes the relation entry's null convention, so the one
466
+ // declaration here covers every `parent.*` and `parent.inner.*` field.
310
467
  "request.resource.attr.parent": {
468
+ nullAttributeRepresentation: "omitted",
311
469
  relation: {
312
470
  name: "parent",
313
471
  type: "one",
314
472
  model: "AdversarialParent",
315
473
  fields: {
316
- aBool: { field: "aBool" },
317
- aString: { field: "aString" },
318
- aNumber: { field: "aNumber" },
319
- aOptionalString: { field: "aOptionalString", nullable: true },
474
+ aBool: { field: "aBool", valueType: "boolean", nullable: true },
475
+ aString: { field: "aString", valueType: "string", nullable: true },
476
+ aNumber: { field: "aNumber", valueType: "number", nullable: true },
477
+ aOptionalString: { field: "aOptionalString", valueType: "string", nullable: true },
320
478
  inner: {
321
479
  relation: {
322
480
  name: "inner",
323
481
  type: "one",
324
482
  model: "AdversarialInner",
325
483
  fields: {
326
- aBool: { field: "aBool" },
327
- aString: { field: "aString" },
328
- aNumber: { field: "aNumber" },
329
- aOptionalString: { field: "aOptionalString", nullable: true },
484
+ aBool: { field: "aBool", valueType: "boolean", nullable: true },
485
+ aString: { field: "aString", valueType: "string", nullable: true },
486
+ aNumber: { field: "aNumber", valueType: "number", nullable: true },
487
+ aOptionalString: { field: "aOptionalString", valueType: "string", nullable: true },
330
488
  },
331
489
  },
332
490
  },
@@ -337,14 +495,15 @@ export const MAPPER: Record<string, MapperConfig> = {
337
495
  relation: {
338
496
  name: "tags",
339
497
  type: "many",
340
- // Model name enables field-to-field comparisons between tag columns; the nullable
341
- // flag on `name` enables the adapter's three-valued-logic guards for collection
342
- // macros over elements whose name column is NULL (a missing attribute — a CEL
343
- // error, hence deny — on the check side).
498
+ // Model name enables field-to-field comparisons between tag columns. `name` is NULLable
499
+ // in the schema, so it keeps the adapter's three-valued-logic guards for collection
500
+ // macros over elements whose name column is NULL (a missing attribute — a CEL error,
501
+ // hence deny — on the check side). Those guards are the default; every REQUIRED element
502
+ // column below says `nullable: false`, because Prisma rejects `{ name: null }` on one.
344
503
  model: "AdversarialTag",
345
504
  fields: {
346
- id: { field: "tagId" },
347
- name: { field: "name", nullable: true },
505
+ id: { field: "tagId", nullable: false },
506
+ name: { field: "name", valueType: "string", nullable: true },
348
507
  },
349
508
  },
350
509
  },
@@ -353,7 +512,30 @@ export const MAPPER: Record<string, MapperConfig> = {
353
512
  name: "tags",
354
513
  type: "many",
355
514
  field: "name",
356
- fields: { name: { field: "name", nullable: true } },
515
+ fields: { name: { field: "name", valueType: "string", nullable: true } },
516
+ },
517
+ },
518
+ // The two homogeneous scalar lists, one owned row per element, projected the way `tagNames`
519
+ // is. `valueType` sits on the list mapping itself because that is where a membership test reads
520
+ // it: a literal of another type (`"2" in aNumberList`) is refused rather than bound, since CEL's
521
+ // heterogeneous equality answers it false and a store that coerces would not. The element column
522
+ // is nullable because a6 and a4 hold a null element, which is a VALUE in CEL.
523
+ "request.resource.attr.aNumberList": {
524
+ valueType: "number",
525
+ relation: {
526
+ name: "numberList",
527
+ type: "many",
528
+ field: "value",
529
+ fields: { value: { field: "value", valueType: "number", nullable: true } },
530
+ },
531
+ },
532
+ "request.resource.attr.aBoolList": {
533
+ valueType: "boolean",
534
+ relation: {
535
+ name: "boolList",
536
+ type: "many",
537
+ field: "value",
538
+ fields: { value: { field: "value", valueType: "boolean", nullable: true } },
357
539
  },
358
540
  },
359
541
  "request.resource.attr.categories": {
@@ -361,19 +543,19 @@ export const MAPPER: Record<string, MapperConfig> = {
361
543
  name: "categories",
362
544
  type: "many",
363
545
  fields: {
364
- name: { field: "name" },
546
+ name: { field: "name", valueType: "string", nullable: false },
365
547
  subCategories: {
366
548
  relation: {
367
549
  name: "subCategories",
368
550
  type: "many",
369
551
  fields: {
370
- name: { field: "name" },
552
+ name: { field: "name", valueType: "string", nullable: false },
371
553
  labels: {
372
554
  relation: {
373
555
  name: "labels",
374
556
  type: "many",
375
557
  fields: {
376
- name: { field: "name", nullable: true },
558
+ name: { field: "name", valueType: "string", nullable: true },
377
559
  },
378
560
  },
379
561
  },
@@ -383,20 +565,21 @@ export const MAPPER: Record<string, MapperConfig> = {
383
565
  },
384
566
  },
385
567
  },
386
- // Multi-hop chain probe (W1): mainCategory mirrors the SAME categories/subCategories relation
387
- // as a single-object dotted chain on the check side (every seed holds at most one category),
388
- // pinning that the adapter joins through every intermediate hop, never off the root.
568
+ // The multi-hop chain (the `relation/*/*-to-one-chain` cases): mainCategory mirrors the SAME
569
+ // categories/subCategories relation as a single-object dotted chain on the check side (every
570
+ // seed holds at most one category), pinning that the adapter joins through every intermediate
571
+ // hop, never off the root.
389
572
  "request.resource.attr.mainCategory": {
390
573
  relation: {
391
574
  name: "categories",
392
575
  type: "many",
393
576
  fields: {
394
- name: { field: "name" },
577
+ name: { field: "name", valueType: "string", nullable: false },
395
578
  subCategories: {
396
579
  relation: {
397
580
  name: "subCategories",
398
581
  type: "many",
399
- fields: { name: { field: "name" } },
582
+ fields: { name: { field: "name", valueType: "string", nullable: false } },
400
583
  },
401
584
  },
402
585
  // subNames: the same 2-hop chain but with a bare `field`, so plain `in` membership