@nmnmcc/ability 2.0.4 → 2.0.6

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 (55) hide show
  1. package/README.md +7 -7
  2. package/dist/Condition.d.ts +4 -4
  3. package/dist/Condition.d.ts.map +1 -1
  4. package/dist/Condition.js.map +1 -1
  5. package/dist/Domain.d.ts +1 -1
  6. package/dist/Policy.d.ts +2 -2
  7. package/dist/Policy.d.ts.map +1 -1
  8. package/dist/Policy.js.map +1 -1
  9. package/dist/internal/algebra.js +85 -10
  10. package/dist/internal/algebra.js.map +1 -1
  11. package/dist/internal/authorizer.d.ts.map +1 -1
  12. package/dist/internal/authorizer.js +37 -14
  13. package/dist/internal/authorizer.js.map +1 -1
  14. package/dist/internal/bind.js +15 -12
  15. package/dist/internal/bind.js.map +1 -1
  16. package/dist/internal/catalog.d.ts +6 -0
  17. package/dist/internal/catalog.d.ts.map +1 -1
  18. package/dist/internal/catalog.js +12 -9
  19. package/dist/internal/catalog.js.map +1 -1
  20. package/dist/internal/decide.js +1 -1
  21. package/dist/internal/domain.js +1 -1
  22. package/dist/internal/json.js +2 -10
  23. package/dist/internal/json.js.map +1 -1
  24. package/dist/internal/nodes.d.ts +4 -1
  25. package/dist/internal/nodes.d.ts.map +1 -1
  26. package/dist/internal/nodes.js +17 -7
  27. package/dist/internal/nodes.js.map +1 -1
  28. package/dist/internal/patterns.js +1 -1
  29. package/dist/internal/patterns.js.map +1 -1
  30. package/dist/internal/policy-json.js +2 -8
  31. package/dist/internal/policy-json.js.map +1 -1
  32. package/dist/internal/policy-nodes.d.ts +4 -1
  33. package/dist/internal/policy-nodes.d.ts.map +1 -1
  34. package/dist/internal/policy-nodes.js +13 -4
  35. package/dist/internal/policy-nodes.js.map +1 -1
  36. package/dist/internal/rule.d.ts.map +1 -1
  37. package/dist/internal/rule.js +6 -6
  38. package/dist/internal/rule.js.map +1 -1
  39. package/dist/internal/search.d.ts.map +1 -1
  40. package/dist/internal/search.js +51 -34
  41. package/dist/internal/search.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/Condition.ts +5 -2
  44. package/src/Policy.ts +1 -2
  45. package/src/internal/algebra.ts +118 -21
  46. package/src/internal/authorizer.ts +56 -14
  47. package/src/internal/bind.ts +39 -18
  48. package/src/internal/catalog.ts +40 -27
  49. package/src/internal/json.ts +2 -11
  50. package/src/internal/nodes.ts +21 -11
  51. package/src/internal/patterns.ts +7 -3
  52. package/src/internal/policy-json.ts +2 -9
  53. package/src/internal/policy-nodes.ts +14 -6
  54. package/src/internal/rule.ts +13 -9
  55. package/src/internal/search.ts +81 -75
@@ -1,14 +1,11 @@
1
1
  import { searchSteps } from "./limits.js";
2
2
  import { always, never } from "./nodes.js";
3
- import { and, not, or, simplify, testCompare } from "./algebra.js";
3
+ import { and, keyOf, not, or, simplify, testCompare } from "./algebra.js";
4
4
  import { anyKinds, lookup } from "./catalog.js";
5
5
  import * as Data from "effect/Data";
6
6
  import * as Arr from "effect/Array";
7
7
  import { isObject } from "effect/Predicate";
8
- import * as HashSet from "effect/HashSet";
9
8
  import * as Option from "effect/Option";
10
- import * as HashMap from "effect/HashMap";
11
- import * as MutableHashMap from "effect/MutableHashMap";
12
9
  // src/internal/search.ts
13
10
  /**
14
11
  * The search for a value that makes a condition true. Each test of a
@@ -79,11 +76,21 @@ const stringCells = (constants) => {
79
76
  * answer for them: `0` and `-0` are one, and `NaN` is one.
80
77
  */
81
78
  const cellText = (cell) => cell === opaque ? "opaque" : typeof cell === "number" ? `number:${Object.is(cell, -0) ? "0" : String(cell)}` : `${typeof cell}:${String(cell)}`;
82
- /** Keeps the first cell of each text, in order. */
83
- const dedupeCells = (cells) => Arr.flatten(Arr.mapAccum(cells, HashSet.empty(), (seen, cell) => {
84
- const text = cellText(cell);
85
- return [HashSet.add(seen, text), HashSet.has(seen, text) ? [] : [cell]];
86
- })[1]);
79
+ /**
80
+ * Keeps the first cell of each text, in order. The texts come from the
81
+ * values of a policy, so they go into a native `Set`: the hash of a string in
82
+ * Effect has no random seed, and a policy could hold very many strings with
83
+ * one hash. The engine hashes a string with a random seed.
84
+ */
85
+ const dedupeCells = (cells) => {
86
+ const seen = /* @__PURE__ */ new Set();
87
+ return Arr.filter(cells, (cell) => {
88
+ const text = cellText(cell);
89
+ if (seen.has(text)) return false;
90
+ seen.add(text);
91
+ return true;
92
+ });
93
+ };
87
94
  /** Gives one value from each cell of a field. */
88
95
  const cellsOf = (kinds, constants) => dedupeCells([
89
96
  ...kinds.null ? [null] : [],
@@ -106,14 +113,14 @@ const fixedValue = (name, field, value) => name === field ? value : name.startsW
106
113
  const membersOf = (node, search) => {
107
114
  const known = search.members.get(node);
108
115
  if (known !== void 0) return known;
109
- const members = HashSet.fromIterable(Arr.map(Arr.filter(Arr.map(node.values, literalOf), (literal) => !Number.isNaN(literal)), cellText));
116
+ const members = new Set(Arr.map(Arr.filter(Arr.map(node.values, literalOf), (literal) => !Number.isNaN(literal)), cellText));
110
117
  search.members.set(node, members);
111
118
  return members;
112
119
  };
113
120
  const testFixed = (node, value, search) => {
114
121
  switch (node._tag) {
115
122
  case "Compare": return testCompare(node.op, value, literalOf(node.value));
116
- case "In": return !Number.isNaN(value) && HashSet.has(membersOf(node, search), cellText(value));
123
+ case "In": return !Number.isNaN(value) && membersOf(node, search).has(cellText(value));
117
124
  case "Exists": return value !== null;
118
125
  }
119
126
  };
@@ -151,8 +158,8 @@ const testedFields = (node) => {
151
158
  * and then the first name in order, so the choice is the same each time.
152
159
  */
153
160
  const nextField = (node) => {
154
- const counts = MutableHashMap.empty();
155
- Arr.forEach(testedFields(node), (field) => MutableHashMap.set(counts, field, Option.getOrElse(MutableHashMap.get(counts, field), () => 0) + 1));
161
+ const counts = /* @__PURE__ */ new Map();
162
+ Arr.forEach(testedFields(node), (field) => counts.set(field, (counts.get(field) ?? 0) + 1));
156
163
  const depth = (field) => field.split(".").length;
157
164
  const [first, ...rest] = Array.from(counts);
158
165
  return Arr.reduce(rest, first, (best, next) => depth(next[0]) < depth(best[0]) || depth(next[0]) === depth(best[0]) && (next[1] > best[1] || next[1] === best[1] && next[0] < best[0]) ? next : best)[0];
@@ -167,7 +174,12 @@ const searchNode = (node, search, state, assignment) => {
167
174
  assignment: Option.some(assignment),
168
175
  state
169
176
  };
170
- if (node._tag === "False" || HashSet.has(state.failed, node)) return {
177
+ if (node._tag === "False") return {
178
+ assignment: Option.none(),
179
+ state
180
+ };
181
+ const key = keyOf(node);
182
+ if (search.failed.has(key)) return {
171
183
  assignment: Option.none(),
172
184
  state
173
185
  };
@@ -188,13 +200,8 @@ const searchNode = (node, search, state, assignment) => {
188
200
  steps
189
201
  }, [...assignment, [field, cell]]);
190
202
  });
191
- return Option.isSome(result.assignment) ? result : {
192
- assignment: Option.none(),
193
- state: {
194
- ...result.state,
195
- failed: HashSet.add(result.state.failed, node)
196
- }
197
- };
203
+ if (Option.isNone(result.assignment)) search.failed.add(key);
204
+ return result;
198
205
  };
199
206
  /**
200
207
  * Puts a value at a path in an object, and gives a new object. A value with
@@ -216,7 +223,11 @@ const setPath = (root, names, value) => {
216
223
  };
217
224
  };
218
225
  const exampleOf = (assignment) => Arr.reduce(assignment, {}, (root, [field, value]) => setPath(root, field.split("."), value));
219
- /** The constants that the tests of each field use. */
226
+ /**
227
+ * The constants that the tests of each field use. They come from a policy, so
228
+ * they go into a native `Map` and `Set`, as in {@link dedupeCells}. The map
229
+ * belongs to one search.
230
+ */
220
231
  const constantsOf = (node) => {
221
232
  const pairs = (current) => {
222
233
  switch (current._tag) {
@@ -230,7 +241,13 @@ const constantsOf = (node) => {
230
241
  default: return [];
231
242
  }
232
243
  };
233
- return Arr.reduce(pairs(node), HashMap.empty(), (all, [field, values]) => HashMap.set(all, field, HashSet.union(Option.getOrElse(HashMap.get(all, field), HashSet.empty), HashSet.fromIterable(values))));
244
+ const all = /* @__PURE__ */ new Map();
245
+ Arr.forEach(pairs(node), ([field, values]) => {
246
+ const known = all.get(field) ?? /* @__PURE__ */ new Set();
247
+ Arr.forEach(values, (value) => known.add(value));
248
+ all.set(field, known);
249
+ });
250
+ return all;
234
251
  };
235
252
  /** Gives the kinds of each field of a catalog. A field that it does not have may hold any value. */
236
253
  const kindsIn = (catalog) => (field) => (catalog === void 0 ? void 0 : lookup(catalog, field))?.kinds ?? anyKinds;
@@ -242,24 +259,24 @@ const kindsIn = (catalog) => (field) => (catalog === void 0 ? void 0 : lookup(ca
242
259
  const satisfiable = (node, kindsOf, budget = searchSteps) => {
243
260
  if (node.opaque) return satisfiability.Unknown({ reason: "predicate" });
244
261
  const constants = constantsOf(node);
245
- if (HashMap.size(constants) > 256) return satisfiability.Unknown({ reason: "budget" });
246
- const cells = MutableHashMap.empty();
262
+ if (constants.size > 256) return satisfiability.Unknown({ reason: "budget" });
263
+ const cells = /* @__PURE__ */ new Map();
247
264
  const search = {
248
265
  members: /* @__PURE__ */ new Map(),
249
- cells: (field) => Option.getOrElse(MutableHashMap.get(cells, field), () => {
250
- const known = Option.getOrElse(HashMap.get(constants, field), HashSet.empty);
251
- if (HashSet.size(known) > budget) throw new OutOfBudget();
266
+ failed: /* @__PURE__ */ new Set(),
267
+ cells: (field) => {
268
+ const made = cells.get(field);
269
+ if (made !== void 0) return made;
270
+ const known = constants.get(field) ?? /* @__PURE__ */ new Set();
271
+ if (known.size > budget) throw new OutOfBudget();
252
272
  const found = cellsOf(kindsOf(field), Array.from(known));
253
- MutableHashMap.set(cells, field, found);
273
+ cells.set(field, found);
254
274
  return found;
255
- }),
275
+ },
256
276
  budget
257
277
  };
258
278
  try {
259
- const found = searchNode(simplify(node), search, {
260
- steps: 0,
261
- failed: HashSet.empty()
262
- }, []);
279
+ const found = searchNode(simplify(node), search, { steps: 0 }, []);
263
280
  return Option.match(found.assignment, {
264
281
  onNone: () => satisfiability.Unsatisfiable(),
265
282
  onSome: (assignment) => satisfiability.Satisfiable({ example: exampleOf(assignment) })
@@ -1 +1 @@
1
- {"version":3,"file":"search.js","names":["Algebra.testCompare","Nodes.always","Nodes.never","Algebra.and","Algebra.or","Algebra.not","Catalog.lookup","Catalog.anyKinds","Limits.searchSteps","Algebra.simplify"],"sources":["../../src/internal/search.ts"],"sourcesContent":["/**\n * The search for a value that makes a condition true. Each test of a\n * condition compares one field with constants, so the constants cut the\n * values of each field into a few cells, and every test gives the same answer\n * for all the values of one cell. The search tries one value from each cell,\n * one field at a time.\n *\n * The values of a field come from its kinds in a catalog. The kinds hold every\n * real value, so a \"no\" holds for the real values too. A \"yes\" holds for the\n * kinds: a check of the Schema may still stop the example.\n */\nimport * as Arr from \"effect/Array\"\nimport * as Data from \"effect/Data\"\nimport * as HashMap from \"effect/HashMap\"\nimport * as HashSet from \"effect/HashSet\"\nimport * as MutableHashMap from \"effect/MutableHashMap\"\nimport * as Option from \"effect/Option\"\nimport { isObject } from \"effect/Predicate\"\nimport * as Algebra from \"./algebra.ts\"\nimport * as Catalog from \"./catalog.ts\"\nimport * as Limits from \"./limits.ts\"\nimport * as Nodes from \"./nodes.ts\"\n\ntype Node = Nodes.Node\n\n/**\n * Values of fields, as a nested object. A field that is not in it may have any\n * value. A value with parts that the search does not look into, such as a\n * date, is an empty object.\n */\nexport interface Example {\n\treadonly [field: string]: unknown\n}\n\n/** The answer of a search. */\nexport type Satisfiability = Data.TaggedEnum<{\n\tSatisfiable: { readonly example: Example }\n\tUnsatisfiable: {}\n\tUnknown: { readonly reason: \"predicate\" | \"budget\" }\n}>\n\nconst satisfiability = Data.taggedEnum<Satisfiability>()\n\n/** Stands for a value with parts, such as a struct or a date. */\nconst opaque: object = Object.freeze({})\n\n/** Marks a field that a step of the search did not fix. */\nconst free: unique symbol = Symbol(\"free\")\n\n/** The least number that is greater than `value`. */\nconst nextUp = (value: number): number => {\n\tif (Number.isNaN(value) || value === Infinity) {\n\t\treturn value\n\t}\n\tif (value === 0) {\n\t\treturn Number.MIN_VALUE\n\t}\n\tconst view = new DataView(new ArrayBuffer(8))\n\tview.setFloat64(0, value)\n\tconst bits = view.getBigUint64(0)\n\tview.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n)\n\treturn view.getFloat64(0)\n}\n\n/**\n * Gives one number from each part of the number line that the constants\n * make: each constant, and one number between each two neighbours when there\n * is one. The ends of the domain are in the list too.\n */\nconst numberCells = (\n\tkind: \"integer\" | \"finite\" | \"any\",\n\tconstants: ReadonlyArray<number>,\n): ReadonlyArray<number> => {\n\tconst integer = kind === \"integer\"\n\tconst low = integer\n\t\t? Number.MIN_SAFE_INTEGER\n\t\t: kind === \"finite\"\n\t\t\t? -Number.MAX_VALUE\n\t\t\t: -Infinity\n\tconst high = integer\n\t\t? Number.MAX_SAFE_INTEGER\n\t\t: kind === \"finite\"\n\t\t\t? Number.MAX_VALUE\n\t\t\t: Infinity\n\tconst known: ReadonlyArray<number> = Arr.filter(\n\t\tconstants,\n\t\t(value: number) => !Number.isNaN(value),\n\t)\n\tconst points = Arr.filter(\n\t\tArr.flatMap(known, (value: number) =>\n\t\t\tinteger && Number.isFinite(value)\n\t\t\t\t? [Math.floor(value), Math.ceil(value)]\n\t\t\t\t: [value],\n\t\t),\n\t\t(value: number) => value >= low && value <= high,\n\t)\n\t// `0` and `-0` are one point: every test gives the same answer for both,\n\t// and `===` calls them equal.\n\tconst ordered: Array<number> = [low, high, ...points].sort((a, b) => a - b)\n\tconst sorted = Arr.dedupeAdjacentWith(\n\t\tordered,\n\t\t(a: number, b: number) => a === b,\n\t)\n\tconst cells = Arr.flatMap(sorted, (value, index) => {\n\t\tconst after = sorted[index + 1]\n\t\tconst between = integer ? value + 1 : nextUp(value)\n\t\treturn after !== undefined && between < after ? [value, between] : [value]\n\t})\n\treturn kind === \"any\" ? Arr.append(cells, Number.NaN) : cells\n}\n\n/**\n * Gives one string from each part that the constants make. Strings are in the\n * order of their UTF-16 code units, as `<` sees them. The least string after\n * `a` is `a + \"\\0\"`, so there is a string between `a` and `b` exactly when\n * `b` is not `a + \"\\0\"`.\n */\nconst stringCells = (\n\tconstants: ReadonlyArray<string>,\n): ReadonlyArray<string> => {\n\tconst ordered: Array<string> = [...constants].sort()\n\tconst sorted = Arr.dedupeAdjacentWith(\n\t\tordered,\n\t\t(a: string, b: string) => a === b,\n\t)\n\tif (sorted.length === 0) {\n\t\treturn [\"\"]\n\t}\n\treturn [\n\t\t...(sorted[0] === \"\" ? [] : [\"\"]),\n\t\t...Arr.flatMap(sorted, (value, index) => {\n\t\t\tconst next = `${value}\\0`\n\t\t\treturn next === sorted[index + 1] ? [value] : [value, next]\n\t\t}),\n\t]\n}\n\n/**\n * A text for a cell. Two cells get one text when every test gives the same\n * answer for them: `0` and `-0` are one, and `NaN` is one.\n */\nconst cellText = (cell: unknown): string =>\n\tcell === opaque\n\t\t? \"opaque\"\n\t\t: typeof cell === \"number\"\n\t\t\t? `number:${Object.is(cell, -0) ? \"0\" : String(cell)}`\n\t\t\t: `${typeof cell}:${String(cell)}`\n\n/** Keeps the first cell of each text, in order. */\nconst dedupeCells = (cells: ReadonlyArray<unknown>): ReadonlyArray<unknown> =>\n\tArr.flatten(\n\t\tArr.mapAccum(cells, HashSet.empty<string>(), (seen, cell) => {\n\t\t\tconst text = cellText(cell)\n\t\t\treturn [HashSet.add(seen, text), HashSet.has(seen, text) ? [] : [cell]]\n\t\t})[1],\n\t)\n\n/** Gives one value from each cell of a field. */\nconst cellsOf = (\n\tkinds: Catalog.Kinds,\n\tconstants: ReadonlyArray<Nodes.Scalar>,\n): ReadonlyArray<unknown> =>\n\tdedupeCells([\n\t\t...(kinds.null ? [null] : []),\n\t\t...(kinds.object ? [opaque] : []),\n\t\t...(kinds.boolean ? [true, false] : []),\n\t\t...(kinds.string\n\t\t\t? stringCells(Arr.filter(constants, (value) => typeof value === \"string\"))\n\t\t\t: []),\n\t\t...(kinds.number === \"none\"\n\t\t\t? []\n\t\t\t: numberCells(\n\t\t\t\t\tkinds.number,\n\t\t\t\t\tArr.filter(constants, (value) => typeof value === \"number\"),\n\t\t\t\t)),\n\t\t...kinds.literals,\n\t])\n\nclass OutOfBudget extends Data.Error {}\n\n/** What one search keeps for all its steps. It lives only as long as the search. */\ninterface Search {\n\treadonly cells: (field: string) => ReadonlyArray<unknown>\n\t/**\n\t * The texts of the values of each `In` test, so one test of a value does\n\t * not read every value of the test. It is a native `Map`, because it must\n\t * find a node by the node itself: a hash of a node reads all its values.\n\t */\n\treadonly members: Map<Nodes.In, HashSet.HashSet<string>>\n\treadonly budget: number\n}\n\n/** What a search has done up to now. Each step gives a new one. */\ninterface SearchState {\n\treadonly steps: number\n\t/** The conditions that the search found to be unsatisfiable. */\n\treadonly failed: HashSet.HashSet<Node>\n}\n\ninterface Found {\n\treadonly assignment: Option.Option<ReadonlyArray<readonly [string, unknown]>>\n\treadonly state: SearchState\n}\n\nconst literalOf = (operand: Nodes.Operand): Nodes.Scalar => {\n\tif (operand._tag !== \"Literal\") {\n\t\tthrow new Error(\n\t\t\t\"Condition.satisfiable needs a condition with no principal fields. Call Condition.partial first.\",\n\t\t)\n\t}\n\treturn operand.value\n}\n\n/**\n * The value of the field `name` after `field` gets `value`. A field below\n * `field` is `null` when `value` is not a value with parts.\n */\nconst fixedValue = (\n\tname: string,\n\tfield: string,\n\tvalue: unknown,\n): unknown | typeof free =>\n\tname === field\n\t\t? value\n\t\t: name.startsWith(`${field}.`)\n\t\t\t? value === opaque\n\t\t\t\t? free\n\t\t\t\t: null\n\t\t\t: free\n\nconst membersOf = (node: Nodes.In, search: Search): HashSet.HashSet<string> => {\n\tconst known = search.members.get(node)\n\tif (known !== undefined) {\n\t\treturn known\n\t}\n\t// `===` finds no `NaN`, so the set has none, and `NaN` is in no test.\n\tconst members = HashSet.fromIterable(\n\t\tArr.map(\n\t\t\tArr.filter(\n\t\t\t\tArr.map(node.values, literalOf),\n\t\t\t\t(literal) => !Number.isNaN(literal),\n\t\t\t),\n\t\t\tcellText,\n\t\t),\n\t)\n\tsearch.members.set(node, members)\n\treturn members\n}\n\nconst testFixed = (\n\tnode: Nodes.Compare | Nodes.In | Nodes.Exists,\n\tvalue: unknown,\n\tsearch: Search,\n): boolean => {\n\tswitch (node._tag) {\n\t\tcase \"Compare\":\n\t\t\treturn Algebra.testCompare(node.op, value, literalOf(node.value))\n\t\tcase \"In\":\n\t\t\treturn (\n\t\t\t\t!Number.isNaN(value) &&\n\t\t\t\tHashSet.has(membersOf(node, search), cellText(value))\n\t\t\t)\n\t\tcase \"Exists\":\n\t\t\treturn value !== null\n\t}\n}\n\n/** Puts one value into a field, and into the fields below it. */\nconst fix = (\n\tnode: Node,\n\tfield: string,\n\tvalue: unknown,\n\tsearch: Search,\n): Node => {\n\tconst fixChild = (child: Node) => fix(child, field, value, search)\n\tswitch (node._tag) {\n\t\tcase \"Compare\":\n\t\tcase \"In\":\n\t\tcase \"Exists\": {\n\t\t\tconst known = fixedValue(node.field, field, value)\n\t\t\treturn known === free\n\t\t\t\t? node\n\t\t\t\t: testFixed(node, known, search)\n\t\t\t\t\t? Nodes.always\n\t\t\t\t\t: Nodes.never\n\t\t}\n\t\tcase \"And\":\n\t\t\treturn Algebra.and(Arr.map(node.conditions, fixChild))\n\t\tcase \"Or\":\n\t\t\treturn Algebra.or(Arr.map(node.conditions, fixChild))\n\t\tcase \"Not\":\n\t\t\treturn node.condition.pipe(fixChild, Algebra.not)\n\t\tdefault:\n\t\t\treturn node\n\t}\n}\n\n/** The fields of the tests of a condition, once for each test. */\nconst testedFields = (node: Node): ReadonlyArray<string> => {\n\tswitch (node._tag) {\n\t\tcase \"Compare\":\n\t\tcase \"In\":\n\t\tcase \"Exists\":\n\t\t\treturn [node.field]\n\t\tcase \"And\":\n\t\tcase \"Or\":\n\t\t\treturn Arr.flatMap(node.conditions, testedFields)\n\t\tcase \"Not\":\n\t\t\treturn testedFields(node.condition)\n\t\tdefault:\n\t\t\treturn []\n\t}\n}\n\n/**\n * Picks the next field to split on. A field comes before the fields below it,\n * so a value of a field can fix them. Then a field in more tests comes first,\n * and then the first name in order, so the choice is the same each time.\n */\nconst nextField = (node: Node): string => {\n\tconst counts = MutableHashMap.empty<string, number>()\n\tArr.forEach(testedFields(node), (field) =>\n\t\tMutableHashMap.set(\n\t\t\tcounts,\n\t\t\tfield,\n\t\t\tOption.getOrElse(MutableHashMap.get(counts, field), () => 0) + 1,\n\t\t),\n\t)\n\tconst depth = (field: string) => field.split(\".\").length\n\tconst [first, ...rest] = Array.from(counts)\n\treturn Arr.reduce(rest, first!, (best, next) =>\n\t\tdepth(next[0]) < depth(best[0]) ||\n\t\t(depth(next[0]) === depth(best[0]) &&\n\t\t\t(next[1] > best[1] || (next[1] === best[1] && next[0] < best[0])))\n\t\t\t? next\n\t\t\t: best,\n\t)[0]\n}\n\n/**\n * Finds values of the fields that make a simple condition true. It splits on\n * one field at a time, and tries one value from each cell of the field. Each\n * step reads the whole condition, so the budget counts its size.\n */\nconst searchNode = (\n\tnode: Node,\n\tsearch: Search,\n\tstate: SearchState,\n\tassignment: ReadonlyArray<readonly [string, unknown]>,\n): Found => {\n\tif (node._tag === \"True\") {\n\t\treturn { assignment: Option.some(assignment), state }\n\t}\n\tif (node._tag === \"False\" || HashSet.has(state.failed, node)) {\n\t\treturn { assignment: Option.none(), state }\n\t}\n\tif (state.steps + node.size > search.budget) {\n\t\tthrow new OutOfBudget()\n\t}\n\tconst field = nextField(node)\n\t// The cells are tried in order, until one gives values. A fold over the\n\t// cells does no work after a found answer, and it does not grow the stack\n\t// with the number of cells.\n\tconst result = Arr.reduce(\n\t\tsearch.cells(field),\n\t\t{\n\t\t\tassignment: Option.none(),\n\t\t\tstate: { ...state, steps: state.steps + node.size },\n\t\t} as Found,\n\t\t(before, cell): Found => {\n\t\t\tif (Option.isSome(before.assignment)) {\n\t\t\t\treturn before\n\t\t\t}\n\t\t\tconst steps = before.state.steps + node.size\n\t\t\tif (steps > search.budget) {\n\t\t\t\tthrow new OutOfBudget()\n\t\t\t}\n\t\t\treturn searchNode(\n\t\t\t\tfix(node, field, cell, search),\n\t\t\t\tsearch,\n\t\t\t\t{ ...before.state, steps },\n\t\t\t\t[...assignment, [field, cell]],\n\t\t\t)\n\t\t},\n\t)\n\treturn Option.isSome(result.assignment)\n\t\t? result\n\t\t: {\n\t\t\t\tassignment: Option.none(),\n\t\t\t\tstate: {\n\t\t\t\t\t...result.state,\n\t\t\t\t\t// A condition with the same structure has the same free fields,\n\t\t\t\t\t// so its answer is the same.\n\t\t\t\t\tfailed: HashSet.add(result.state.failed, node),\n\t\t\t\t},\n\t\t\t}\n}\n\n/**\n * Puts a value at a path in an object, and gives a new object. A value with\n * parts (`opaque`) becomes an empty object, and it keeps an object that is\n * there.\n */\nconst setPath = (\n\troot: Readonly<Record<string, unknown>>,\n\tnames: ReadonlyArray<string>,\n\tvalue: unknown,\n): Readonly<Record<string, unknown>> => {\n\tconst [name, ...rest] = names\n\tif (name === undefined) {\n\t\treturn root\n\t}\n\t// Only an own field counts, as a condition reads it. An inherited one,\n\t// such as `__proto__`, is not a field of the example.\n\tconst inner = Object.hasOwn(root, name) ? root[name] : undefined\n\tif (rest.length === 0) {\n\t\treturn value === opaque && isObject(inner)\n\t\t\t? root\n\t\t\t: { ...root, [name]: value === opaque ? {} : value }\n\t}\n\tconst base =\n\t\tisObject(inner) && inner !== opaque\n\t\t\t? (inner as Readonly<Record<string, unknown>>)\n\t\t\t: {}\n\treturn { ...root, [name]: setPath(base, rest, value) }\n}\n\nconst exampleOf = (\n\tassignment: ReadonlyArray<readonly [string, unknown]>,\n): Example =>\n\tArr.reduce(\n\t\tassignment,\n\t\t{} as Readonly<Record<string, unknown>>,\n\t\t(root, [field, value]) => setPath(root, field.split(\".\"), value),\n\t)\n\n/** The constants that the tests of each field use. */\nconst constantsOf = (\n\tnode: Node,\n): HashMap.HashMap<string, HashSet.HashSet<Nodes.Scalar>> => {\n\tconst pairs = (\n\t\tcurrent: Node,\n\t): ReadonlyArray<readonly [string, ReadonlyArray<Nodes.Scalar>]> => {\n\t\tswitch (current._tag) {\n\t\t\tcase \"Compare\":\n\t\t\t\treturn [[current.field, [literalOf(current.value)]]]\n\t\t\tcase \"In\":\n\t\t\t\treturn [[current.field, Arr.map(current.values, literalOf)]]\n\t\t\tcase \"Exists\":\n\t\t\t\treturn [[current.field, []]]\n\t\t\tcase \"Under\":\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"Condition.satisfiable needs a condition with no Under nodes. Call Condition.partial first.\",\n\t\t\t\t)\n\t\t\tcase \"And\":\n\t\t\tcase \"Or\":\n\t\t\t\treturn Arr.flatMap(current.conditions, pairs)\n\t\t\tcase \"Not\":\n\t\t\t\treturn pairs(current.condition)\n\t\t\tdefault:\n\t\t\t\treturn []\n\t\t}\n\t}\n\treturn Arr.reduce(pairs(node), HashMap.empty(), (all, [field, values]) =>\n\t\tHashMap.set(\n\t\t\tall,\n\t\t\tfield,\n\t\t\tHashSet.union(\n\t\t\t\tOption.getOrElse(HashMap.get(all, field), HashSet.empty),\n\t\t\t\tHashSet.fromIterable(values),\n\t\t\t),\n\t\t),\n\t)\n}\n\n/** Gives the kinds of each field of a catalog. A field that it does not have may hold any value. */\nexport const kindsIn =\n\t(catalog: Catalog.Catalog | undefined) =>\n\t(field: string): Catalog.Kinds =>\n\t\t(catalog === undefined ? undefined : Catalog.lookup(catalog, field))\n\t\t\t?.kinds ?? Catalog.anyKinds\n\n/**\n * Searches for values that make a condition true. `kindsOf` gives the kinds\n * of values of each field. The condition must have no principal fields and no\n * `Under` nodes.\n */\nexport const satisfiable = (\n\tnode: Node,\n\tkindsOf: (field: string) => Catalog.Kinds,\n\tbudget: number = Limits.searchSteps,\n): Satisfiability => {\n\tif (node.opaque) {\n\t\treturn satisfiability.Unknown({ reason: \"predicate\" })\n\t}\n\tconst constants = constantsOf(node)\n\t// The search calls itself one time for each field that it fixes.\n\tif (HashMap.size(constants) > Limits.searchFields) {\n\t\treturn satisfiability.Unknown({ reason: \"budget\" })\n\t}\n\t// The cells of each field. A field name is a string, so an Effect map\n\t// finds it.\n\tconst cells = MutableHashMap.empty<string, ReadonlyArray<unknown>>()\n\tconst search: Search = {\n\t\tmembers: new Map(),\n\t\tcells: (field) =>\n\t\t\tOption.getOrElse(MutableHashMap.get(cells, field), () => {\n\t\t\t\t// The cells come from the constants, so many constants are work\n\t\t\t\t// that the budget must count before the cells are made.\n\t\t\t\tconst known = Option.getOrElse(\n\t\t\t\t\tHashMap.get(constants, field),\n\t\t\t\t\tHashSet.empty,\n\t\t\t\t)\n\t\t\t\tif (HashSet.size(known) > budget) {\n\t\t\t\t\tthrow new OutOfBudget()\n\t\t\t\t}\n\t\t\t\tconst found = cellsOf(kindsOf(field), Array.from(known))\n\t\t\t\tMutableHashMap.set(cells, field, found)\n\t\t\t\treturn found\n\t\t\t}),\n\t\tbudget,\n\t}\n\ttry {\n\t\tconst found = searchNode(\n\t\t\tAlgebra.simplify(node),\n\t\t\tsearch,\n\t\t\t{ steps: 0, failed: HashSet.empty() },\n\t\t\t[],\n\t\t)\n\t\treturn Option.match(found.assignment, {\n\t\t\tonNone: () => satisfiability.Unsatisfiable(),\n\t\t\tonSome: (assignment) =>\n\t\t\t\tsatisfiability.Satisfiable({ example: exampleOf(assignment) }),\n\t\t})\n\t} catch (error) {\n\t\tif (error instanceof OutOfBudget) {\n\t\t\treturn satisfiability.Unknown({ reason: \"budget\" })\n\t\t}\n\t\tthrow error\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"search.js","names":["Algebra.testCompare","Nodes.always","Nodes.never","Algebra.and","Algebra.or","Algebra.not","Algebra.keyOf","Catalog.lookup","Catalog.anyKinds","Limits.searchSteps","Algebra.simplify"],"sources":["../../src/internal/search.ts"],"sourcesContent":["/**\n * The search for a value that makes a condition true. Each test of a\n * condition compares one field with constants, so the constants cut the\n * values of each field into a few cells, and every test gives the same answer\n * for all the values of one cell. The search tries one value from each cell,\n * one field at a time.\n *\n * The values of a field come from its kinds in a catalog. The kinds hold every\n * real value, so a \"no\" holds for the real values too. A \"yes\" holds for the\n * kinds: a check of the Schema may still stop the example.\n */\nimport * as Arr from \"effect/Array\"\nimport * as Data from \"effect/Data\"\nimport * as Option from \"effect/Option\"\nimport { isObject } from \"effect/Predicate\"\nimport * as Algebra from \"./algebra.ts\"\nimport * as Catalog from \"./catalog.ts\"\nimport * as Limits from \"./limits.ts\"\nimport * as Nodes from \"./nodes.ts\"\n\ntype Node = Nodes.Node\n\n/**\n * Values of fields, as a nested object. A field that is not in it may have any\n * value. A value with parts that the search does not look into, such as a\n * date, is an empty object.\n */\nexport interface Example {\n\treadonly [field: string]: unknown\n}\n\n/** The answer of a search. */\nexport type Satisfiability = Data.TaggedEnum<{\n\tSatisfiable: { readonly example: Example }\n\tUnsatisfiable: {}\n\tUnknown: { readonly reason: \"predicate\" | \"budget\" }\n}>\n\nconst satisfiability = Data.taggedEnum<Satisfiability>()\n\n/** Stands for a value with parts, such as a struct or a date. */\nconst opaque: object = Object.freeze({})\n\n/** Marks a field that a step of the search did not fix. */\nconst free: unique symbol = Symbol(\"free\")\n\n/** The least number that is greater than `value`. */\nconst nextUp = (value: number): number => {\n\tif (Number.isNaN(value) || value === Infinity) {\n\t\treturn value\n\t}\n\tif (value === 0) {\n\t\treturn Number.MIN_VALUE\n\t}\n\tconst view = new DataView(new ArrayBuffer(8))\n\tview.setFloat64(0, value)\n\tconst bits = view.getBigUint64(0)\n\tview.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n)\n\treturn view.getFloat64(0)\n}\n\n/**\n * Gives one number from each part of the number line that the constants\n * make: each constant, and one number between each two neighbours when there\n * is one. The ends of the domain are in the list too.\n */\nconst numberCells = (\n\tkind: \"integer\" | \"finite\" | \"any\",\n\tconstants: ReadonlyArray<number>,\n): ReadonlyArray<number> => {\n\tconst integer = kind === \"integer\"\n\tconst low = integer\n\t\t? Number.MIN_SAFE_INTEGER\n\t\t: kind === \"finite\"\n\t\t\t? -Number.MAX_VALUE\n\t\t\t: -Infinity\n\tconst high = integer\n\t\t? Number.MAX_SAFE_INTEGER\n\t\t: kind === \"finite\"\n\t\t\t? Number.MAX_VALUE\n\t\t\t: Infinity\n\tconst known: ReadonlyArray<number> = Arr.filter(\n\t\tconstants,\n\t\t(value: number) => !Number.isNaN(value),\n\t)\n\tconst points = Arr.filter(\n\t\tArr.flatMap(known, (value: number) =>\n\t\t\tinteger && Number.isFinite(value)\n\t\t\t\t? [Math.floor(value), Math.ceil(value)]\n\t\t\t\t: [value],\n\t\t),\n\t\t(value: number) => value >= low && value <= high,\n\t)\n\t// `0` and `-0` are one point: every test gives the same answer for both,\n\t// and `===` calls them equal.\n\tconst ordered: Array<number> = [low, high, ...points].sort((a, b) => a - b)\n\tconst sorted = Arr.dedupeAdjacentWith(\n\t\tordered,\n\t\t(a: number, b: number) => a === b,\n\t)\n\tconst cells = Arr.flatMap(sorted, (value, index) => {\n\t\tconst after = sorted[index + 1]\n\t\tconst between = integer ? value + 1 : nextUp(value)\n\t\treturn after !== undefined && between < after ? [value, between] : [value]\n\t})\n\treturn kind === \"any\" ? Arr.append(cells, Number.NaN) : cells\n}\n\n/**\n * Gives one string from each part that the constants make. Strings are in the\n * order of their UTF-16 code units, as `<` sees them. The least string after\n * `a` is `a + \"\\0\"`, so there is a string between `a` and `b` exactly when\n * `b` is not `a + \"\\0\"`.\n */\nconst stringCells = (\n\tconstants: ReadonlyArray<string>,\n): ReadonlyArray<string> => {\n\tconst ordered: Array<string> = [...constants].sort()\n\tconst sorted = Arr.dedupeAdjacentWith(\n\t\tordered,\n\t\t(a: string, b: string) => a === b,\n\t)\n\tif (sorted.length === 0) {\n\t\treturn [\"\"]\n\t}\n\treturn [\n\t\t...(sorted[0] === \"\" ? [] : [\"\"]),\n\t\t...Arr.flatMap(sorted, (value, index) => {\n\t\t\tconst next = `${value}\\0`\n\t\t\treturn next === sorted[index + 1] ? [value] : [value, next]\n\t\t}),\n\t]\n}\n\n/**\n * A text for a cell. Two cells get one text when every test gives the same\n * answer for them: `0` and `-0` are one, and `NaN` is one.\n */\nconst cellText = (cell: unknown): string =>\n\tcell === opaque\n\t\t? \"opaque\"\n\t\t: typeof cell === \"number\"\n\t\t\t? `number:${Object.is(cell, -0) ? \"0\" : String(cell)}`\n\t\t\t: `${typeof cell}:${String(cell)}`\n\n/**\n * Keeps the first cell of each text, in order. The texts come from the\n * values of a policy, so they go into a native `Set`: the hash of a string in\n * Effect has no random seed, and a policy could hold very many strings with\n * one hash. The engine hashes a string with a random seed.\n */\nconst dedupeCells = (cells: ReadonlyArray<unknown>): ReadonlyArray<unknown> => {\n\t// The set belongs to this call.\n\tconst seen = new Set<string>()\n\treturn Arr.filter(cells, (cell) => {\n\t\tconst text = cellText(cell)\n\t\tif (seen.has(text)) {\n\t\t\treturn false\n\t\t}\n\t\tseen.add(text)\n\t\treturn true\n\t})\n}\n\n/** Gives one value from each cell of a field. */\nconst cellsOf = (\n\tkinds: Catalog.Kinds,\n\tconstants: ReadonlyArray<Nodes.Scalar>,\n): ReadonlyArray<unknown> =>\n\tdedupeCells([\n\t\t...(kinds.null ? [null] : []),\n\t\t...(kinds.object ? [opaque] : []),\n\t\t...(kinds.boolean ? [true, false] : []),\n\t\t...(kinds.string\n\t\t\t? stringCells(Arr.filter(constants, (value) => typeof value === \"string\"))\n\t\t\t: []),\n\t\t...(kinds.number === \"none\"\n\t\t\t? []\n\t\t\t: numberCells(\n\t\t\t\t\tkinds.number,\n\t\t\t\t\tArr.filter(constants, (value) => typeof value === \"number\"),\n\t\t\t\t)),\n\t\t...kinds.literals,\n\t])\n\nclass OutOfBudget extends Data.Error {}\n\n/** What one search keeps for all its steps. It lives only as long as the search. */\ninterface Search {\n\treadonly cells: (field: string) => ReadonlyArray<unknown>\n\t/**\n\t * The texts of the values of each `In` test, so one test of a value does\n\t * not read every value of the test. It is a native `Map`, because it must\n\t * find a node by the node itself: a hash of a node reads all its values.\n\t * The texts are in a native `Set`, as in {@link dedupeCells}.\n\t */\n\treadonly members: Map<Nodes.In, Set<string>>\n\t/**\n\t * The keys of the conditions that the search found to be unsatisfiable:\n\t * see `Algebra.keyOf`. A condition with the same structure has the same\n\t * free fields, so its answer is the same. The set only grows, so the steps\n\t * of a search may share it. It is a native `Set`, as in\n\t * {@link dedupeCells}.\n\t */\n\treadonly failed: Set<string | Node>\n\treadonly budget: number\n}\n\n/** What a search has done up to now. Each step gives a new one. */\ninterface SearchState {\n\treadonly steps: number\n}\n\ninterface Found {\n\treadonly assignment: Option.Option<ReadonlyArray<readonly [string, unknown]>>\n\treadonly state: SearchState\n}\n\nconst literalOf = (operand: Nodes.Operand): Nodes.Scalar => {\n\tif (operand._tag !== \"Literal\") {\n\t\tthrow new Error(\n\t\t\t\"Condition.satisfiable needs a condition with no principal fields. Call Condition.partial first.\",\n\t\t)\n\t}\n\treturn operand.value\n}\n\n/**\n * The value of the field `name` after `field` gets `value`. A field below\n * `field` is `null` when `value` is not a value with parts.\n */\nconst fixedValue = (\n\tname: string,\n\tfield: string,\n\tvalue: unknown,\n): unknown | typeof free =>\n\tname === field\n\t\t? value\n\t\t: name.startsWith(`${field}.`)\n\t\t\t? value === opaque\n\t\t\t\t? free\n\t\t\t\t: null\n\t\t\t: free\n\nconst membersOf = (node: Nodes.In, search: Search): Set<string> => {\n\tconst known = search.members.get(node)\n\tif (known !== undefined) {\n\t\treturn known\n\t}\n\t// `===` finds no `NaN`, so the set has none, and `NaN` is in no test.\n\tconst members = new Set(\n\t\tArr.map(\n\t\t\tArr.filter(\n\t\t\t\tArr.map(node.values, literalOf),\n\t\t\t\t(literal) => !Number.isNaN(literal),\n\t\t\t),\n\t\t\tcellText,\n\t\t),\n\t)\n\tsearch.members.set(node, members)\n\treturn members\n}\n\nconst testFixed = (\n\tnode: Nodes.Compare | Nodes.In | Nodes.Exists,\n\tvalue: unknown,\n\tsearch: Search,\n): boolean => {\n\tswitch (node._tag) {\n\t\tcase \"Compare\":\n\t\t\treturn Algebra.testCompare(node.op, value, literalOf(node.value))\n\t\tcase \"In\":\n\t\t\treturn (\n\t\t\t\t!Number.isNaN(value) && membersOf(node, search).has(cellText(value))\n\t\t\t)\n\t\tcase \"Exists\":\n\t\t\treturn value !== null\n\t}\n}\n\n/** Puts one value into a field, and into the fields below it. */\nconst fix = (\n\tnode: Node,\n\tfield: string,\n\tvalue: unknown,\n\tsearch: Search,\n): Node => {\n\tconst fixChild = (child: Node) => fix(child, field, value, search)\n\tswitch (node._tag) {\n\t\tcase \"Compare\":\n\t\tcase \"In\":\n\t\tcase \"Exists\": {\n\t\t\tconst known = fixedValue(node.field, field, value)\n\t\t\treturn known === free\n\t\t\t\t? node\n\t\t\t\t: testFixed(node, known, search)\n\t\t\t\t\t? Nodes.always\n\t\t\t\t\t: Nodes.never\n\t\t}\n\t\tcase \"And\":\n\t\t\treturn Algebra.and(Arr.map(node.conditions, fixChild))\n\t\tcase \"Or\":\n\t\t\treturn Algebra.or(Arr.map(node.conditions, fixChild))\n\t\tcase \"Not\":\n\t\t\treturn node.condition.pipe(fixChild, Algebra.not)\n\t\tdefault:\n\t\t\treturn node\n\t}\n}\n\n/** The fields of the tests of a condition, once for each test. */\nconst testedFields = (node: Node): ReadonlyArray<string> => {\n\tswitch (node._tag) {\n\t\tcase \"Compare\":\n\t\tcase \"In\":\n\t\tcase \"Exists\":\n\t\t\treturn [node.field]\n\t\tcase \"And\":\n\t\tcase \"Or\":\n\t\t\treturn Arr.flatMap(node.conditions, testedFields)\n\t\tcase \"Not\":\n\t\t\treturn testedFields(node.condition)\n\t\tdefault:\n\t\t\treturn []\n\t}\n}\n\n/**\n * Picks the next field to split on. A field comes before the fields below it,\n * so a value of a field can fix them. Then a field in more tests comes first,\n * and then the first name in order, so the choice is the same each time.\n */\nconst nextField = (node: Node): string => {\n\t// A native `Map`, as in `dedupeCells`: a condition that is not bound to a\n\t// domain may have any field names. The map belongs to this call.\n\tconst counts = new Map<string, number>()\n\tArr.forEach(testedFields(node), (field) =>\n\t\tcounts.set(field, (counts.get(field) ?? 0) + 1),\n\t)\n\tconst depth = (field: string) => field.split(\".\").length\n\tconst [first, ...rest] = Array.from(counts)\n\treturn Arr.reduce(rest, first!, (best, next) =>\n\t\tdepth(next[0]) < depth(best[0]) ||\n\t\t(depth(next[0]) === depth(best[0]) &&\n\t\t\t(next[1] > best[1] || (next[1] === best[1] && next[0] < best[0])))\n\t\t\t? next\n\t\t\t: best,\n\t)[0]\n}\n\n/**\n * Finds values of the fields that make a simple condition true. It splits on\n * one field at a time, and tries one value from each cell of the field. Each\n * step reads the whole condition, so the budget counts its size.\n */\nconst searchNode = (\n\tnode: Node,\n\tsearch: Search,\n\tstate: SearchState,\n\tassignment: ReadonlyArray<readonly [string, unknown]>,\n): Found => {\n\tif (node._tag === \"True\") {\n\t\treturn { assignment: Option.some(assignment), state }\n\t}\n\tif (node._tag === \"False\") {\n\t\treturn { assignment: Option.none(), state }\n\t}\n\t// The key reads at most a small part of the node: a node with very many\n\t// nodes, or with very many values in its `In` tests, is its own key. A\n\t// step reads the nodes but not the values, which `membersOf` keeps, so the\n\t// budget counts the size.\n\tconst key = Algebra.keyOf(node)\n\tif (search.failed.has(key)) {\n\t\treturn { assignment: Option.none(), state }\n\t}\n\tif (state.steps + node.size > search.budget) {\n\t\tthrow new OutOfBudget()\n\t}\n\tconst field = nextField(node)\n\t// The cells are tried in order, until one gives values. A fold over the\n\t// cells does no work after a found answer, and it does not grow the stack\n\t// with the number of cells.\n\tconst result = Arr.reduce(\n\t\tsearch.cells(field),\n\t\t{\n\t\t\tassignment: Option.none(),\n\t\t\tstate: { ...state, steps: state.steps + node.size },\n\t\t} as Found,\n\t\t(before, cell): Found => {\n\t\t\tif (Option.isSome(before.assignment)) {\n\t\t\t\treturn before\n\t\t\t}\n\t\t\tconst steps = before.state.steps + node.size\n\t\t\tif (steps > search.budget) {\n\t\t\t\tthrow new OutOfBudget()\n\t\t\t}\n\t\t\treturn searchNode(\n\t\t\t\tfix(node, field, cell, search),\n\t\t\t\tsearch,\n\t\t\t\t{ ...before.state, steps },\n\t\t\t\t[...assignment, [field, cell]],\n\t\t\t)\n\t\t},\n\t)\n\tif (Option.isNone(result.assignment)) {\n\t\tsearch.failed.add(key)\n\t}\n\treturn result\n}\n\n/**\n * Puts a value at a path in an object, and gives a new object. A value with\n * parts (`opaque`) becomes an empty object, and it keeps an object that is\n * there.\n */\nconst setPath = (\n\troot: Readonly<Record<string, unknown>>,\n\tnames: ReadonlyArray<string>,\n\tvalue: unknown,\n): Readonly<Record<string, unknown>> => {\n\tconst [name, ...rest] = names\n\tif (name === undefined) {\n\t\treturn root\n\t}\n\t// Only an own field counts, as a condition reads it. An inherited one,\n\t// such as `__proto__`, is not a field of the example.\n\tconst inner = Object.hasOwn(root, name) ? root[name] : undefined\n\tif (rest.length === 0) {\n\t\treturn value === opaque && isObject(inner)\n\t\t\t? root\n\t\t\t: { ...root, [name]: value === opaque ? {} : value }\n\t}\n\tconst base =\n\t\tisObject(inner) && inner !== opaque\n\t\t\t? (inner as Readonly<Record<string, unknown>>)\n\t\t\t: {}\n\treturn { ...root, [name]: setPath(base, rest, value) }\n}\n\nconst exampleOf = (\n\tassignment: ReadonlyArray<readonly [string, unknown]>,\n): Example =>\n\tArr.reduce(\n\t\tassignment,\n\t\t{} as Readonly<Record<string, unknown>>,\n\t\t(root, [field, value]) => setPath(root, field.split(\".\"), value),\n\t)\n\n/**\n * The constants that the tests of each field use. They come from a policy, so\n * they go into a native `Map` and `Set`, as in {@link dedupeCells}. The map\n * belongs to one search.\n */\nconst constantsOf = (node: Node): Map<string, Set<Nodes.Scalar>> => {\n\tconst pairs = (\n\t\tcurrent: Node,\n\t): ReadonlyArray<readonly [string, ReadonlyArray<Nodes.Scalar>]> => {\n\t\tswitch (current._tag) {\n\t\t\tcase \"Compare\":\n\t\t\t\treturn [[current.field, [literalOf(current.value)]]]\n\t\t\tcase \"In\":\n\t\t\t\treturn [[current.field, Arr.map(current.values, literalOf)]]\n\t\t\tcase \"Exists\":\n\t\t\t\treturn [[current.field, []]]\n\t\t\tcase \"Under\":\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"Condition.satisfiable needs a condition with no Under nodes. Call Condition.partial first.\",\n\t\t\t\t)\n\t\t\tcase \"And\":\n\t\t\tcase \"Or\":\n\t\t\t\treturn Arr.flatMap(current.conditions, pairs)\n\t\t\tcase \"Not\":\n\t\t\t\treturn pairs(current.condition)\n\t\t\tdefault:\n\t\t\t\treturn []\n\t\t}\n\t}\n\tconst all = new Map<string, Set<Nodes.Scalar>>()\n\tArr.forEach(pairs(node), ([field, values]) => {\n\t\tconst known = all.get(field) ?? new Set<Nodes.Scalar>()\n\t\tArr.forEach(values, (value) => known.add(value))\n\t\tall.set(field, known)\n\t})\n\treturn all\n}\n\n/** Gives the kinds of each field of a catalog. A field that it does not have may hold any value. */\nexport const kindsIn =\n\t(catalog: Catalog.Catalog | undefined) =>\n\t(field: string): Catalog.Kinds =>\n\t\t(catalog === undefined ? undefined : Catalog.lookup(catalog, field))\n\t\t\t?.kinds ?? Catalog.anyKinds\n\n/**\n * Searches for values that make a condition true. `kindsOf` gives the kinds\n * of values of each field. The condition must have no principal fields and no\n * `Under` nodes.\n */\nexport const satisfiable = (\n\tnode: Node,\n\tkindsOf: (field: string) => Catalog.Kinds,\n\tbudget: number = Limits.searchSteps,\n): Satisfiability => {\n\tif (node.opaque) {\n\t\treturn satisfiability.Unknown({ reason: \"predicate\" })\n\t}\n\tconst constants = constantsOf(node)\n\t// The search calls itself one time for each field that it fixes.\n\tif (constants.size > Limits.searchFields) {\n\t\treturn satisfiability.Unknown({ reason: \"budget\" })\n\t}\n\t// The cells of each field, in a native `Map` as the constants are.\n\tconst cells = new Map<string, ReadonlyArray<unknown>>()\n\tconst search: Search = {\n\t\tmembers: new Map(),\n\t\tfailed: new Set(),\n\t\tcells: (field) => {\n\t\t\tconst made = cells.get(field)\n\t\t\tif (made !== undefined) {\n\t\t\t\treturn made\n\t\t\t}\n\t\t\t// The cells come from the constants, so many constants are work\n\t\t\t// that the budget must count before the cells are made.\n\t\t\tconst known = constants.get(field) ?? new Set<Nodes.Scalar>()\n\t\t\tif (known.size > budget) {\n\t\t\t\tthrow new OutOfBudget()\n\t\t\t}\n\t\t\tconst found = cellsOf(kindsOf(field), Array.from(known))\n\t\t\tcells.set(field, found)\n\t\t\treturn found\n\t\t},\n\t\tbudget,\n\t}\n\ttry {\n\t\tconst found = searchNode(Algebra.simplify(node), search, { steps: 0 }, [])\n\t\treturn Option.match(found.assignment, {\n\t\t\tonNone: () => satisfiability.Unsatisfiable(),\n\t\t\tonSome: (assignment) =>\n\t\t\t\tsatisfiability.Satisfiable({ example: exampleOf(assignment) }),\n\t\t})\n\t} catch (error) {\n\t\tif (error instanceof OutOfBudget) {\n\t\t\treturn satisfiability.Unknown({ reason: \"budget\" })\n\t\t}\n\t\tthrow error\n\t}\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nmnmcc/ability",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "Typed, composable authorization policies for Effect programs.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/Condition.ts CHANGED
@@ -412,7 +412,8 @@ type ObjectPaths<
412
412
  > = [O] extends [never]
413
413
  ? never
414
414
  : // A record is one field to a rule or a request, as `permittedFields`
415
- // lists it.
415
+ // lists it. So is a struct at the last part that a path may have, since
416
+ // a path does not go below it.
416
417
  | (Parents extends true
417
418
  ? K
418
419
  : Arrays extends true
@@ -420,7 +421,9 @@ type ObjectPaths<
420
421
  ? K
421
422
  : number extends keyof O
422
423
  ? K
423
- : never
424
+ : [Depth] extends [0]
425
+ ? K
426
+ : never
424
427
  : never)
425
428
  | `${K}.${FieldPathInternal<O, PreviousDepth[Depth], Arrays, Parents>}`
426
429
 
package/src/Policy.ts CHANGED
@@ -53,8 +53,7 @@ import type * as Resource from "./Resource.ts"
53
53
  * @category models
54
54
  * @since 0.3.0
55
55
  */
56
- export type Algorithm =
57
- "denyOverrides" | "permitOverrides" | "firstApplicable" | "lastApplicable"
56
+ export type Algorithm = PolicyNodes.Algorithm
58
57
 
59
58
  /**
60
59
  * A list of rules with a combining algorithm.
@@ -12,7 +12,6 @@
12
12
  import * as Arr from "effect/Array"
13
13
  import type * as Context from "effect/Context"
14
14
  import * as Effect from "effect/Effect"
15
- import * as HashSet from "effect/HashSet"
16
15
  import * as Option from "effect/Option"
17
16
  import type * as Resource from "../Resource.ts"
18
17
  import * as Nodes from "./nodes.ts"
@@ -22,19 +21,114 @@ type Node = Nodes.Node
22
21
 
23
22
  const { always, never } = Nodes
24
23
 
25
- // Smart constructors
24
+ // Keys of structure
25
+
26
+ /** The text of an operand. The type is in it, so `"1"` is not `1`. */
27
+ const operandText = (operand: Nodes.Operand): ReadonlyArray<string> =>
28
+ operand._tag === "Literal"
29
+ ? [typeof operand.value, String(operand.value)]
30
+ : ["principal", operand.path]
31
+
32
+ /** The parts of the text of a condition with no predicate. */
33
+ const shapeOf = (node: Node): unknown => {
34
+ switch (node._tag) {
35
+ case "True":
36
+ case "False":
37
+ return [node._tag]
38
+ case "Compare":
39
+ return [node._tag, node.op, node.field, operandText(node.value)]
40
+ case "In":
41
+ return [node._tag, node.field, Arr.map(node.values, operandText)]
42
+ case "Exists":
43
+ return [node._tag, node.field]
44
+ case "Under":
45
+ return [node._tag, Arr.map(node.prefix, operandText)]
46
+ case "And":
47
+ case "Or":
48
+ return [node._tag, Arr.map(node.conditions, shapeOf)]
49
+ case "Not":
50
+ return [node._tag, shapeOf(node.condition)]
51
+ case "Predicate":
52
+ // A smart constructor and a search do not ask for the text of a
53
+ // predicate: it is equal only to itself.
54
+ throw new Error("A predicate has no text of structure")
55
+ }
56
+ }
26
57
 
27
58
  /**
28
- * Keeps the first node of each structure, in order. A node with a predicate is
29
- * equal only to itself, as its test is a function.
59
+ * The text of the structure of a condition with no predicate. Two conditions
60
+ * with one text give the same answer for every value. It reads all of the
61
+ * condition, so a search counts its size in the budget.
30
62
  */
31
- const dedupe = (nodes: ReadonlyArray<Node>): ReadonlyArray<Node> =>
32
- Arr.flatten(
33
- Arr.mapAccum(nodes, HashSet.empty<Node>(), (seen, node) => [
34
- HashSet.add(seen, node),
35
- HashSet.has(seen, node) ? [] : [node],
36
- ])[1],
37
- )
63
+ const structureText = (node: Node): string => JSON.stringify(shapeOf(node))
64
+
65
+ /**
66
+ * The work of the text of the structure of a condition: each node, and each
67
+ * value of an `In` test or an `Under` test. A node counts as one in `size`,
68
+ * also an `In` test with very many values. This walk reads the nodes only,
69
+ * so it takes `size` steps.
70
+ */
71
+ const weightOf = (node: Node): number => {
72
+ switch (node._tag) {
73
+ case "In":
74
+ return 1 + node.values.length
75
+ case "Under":
76
+ return 1 + node.prefix.length
77
+ case "And":
78
+ case "Or":
79
+ return Arr.reduce(
80
+ node.conditions,
81
+ 1,
82
+ (total, child) => total + weightOf(child),
83
+ )
84
+ case "Not":
85
+ return 1 + weightOf(node.condition)
86
+ default:
87
+ return 1
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The most nodes of a condition that a smart constructor reads for its key,
93
+ * and the most children of a child that it puts into a node. A larger child
94
+ * is its own key, and it stays one child, so the work of a constructor stays
95
+ * small. A deep chain of `And` nodes, for one, does not copy all its leaves
96
+ * at each level.
97
+ */
98
+ const keyNodes = 64
99
+
100
+ /** The most work of a key, with the values of its tests: see {@link weightOf}. */
101
+ const keyWeight = 256
102
+
103
+ /**
104
+ * The key of a child in a smart constructor: the text of its structure, or
105
+ * the node itself when it is large. The keys go into a native `Set`, not a
106
+ * `HashSet` of Effect. The hash of a string in Effect has no random seed, so
107
+ * a policy could hold very many strings with one hash, and each test of the
108
+ * set would read all of them. A native `Set` finds a node by the node itself,
109
+ * and it hashes a string with the random seed of the engine.
110
+ */
111
+ export const keyOf = (node: Node): string | Node =>
112
+ node.size > keyNodes || weightOf(node) > keyWeight
113
+ ? node
114
+ : structureText(node)
115
+
116
+ /**
117
+ * Keeps the first node of each structure, in order. A large node is a copy
118
+ * only of itself.
119
+ */
120
+ const dedupe = (nodes: ReadonlyArray<Node>): ReadonlyArray<Node> => {
121
+ // The set belongs to this call.
122
+ const seen = new Set<string | Node>()
123
+ return Arr.filter(nodes, (node) => {
124
+ const key = keyOf(node)
125
+ if (seen.has(key)) {
126
+ return false
127
+ }
128
+ seen.add(key)
129
+ return true
130
+ })
131
+ }
38
132
 
39
133
  /**
40
134
  * Removes the children that the other children make useless. In an `Or`,
@@ -44,15 +138,18 @@ const absorb = (
44
138
  inner: "And" | "Or",
45
139
  nodes: ReadonlyArray<Node>,
46
140
  ): ReadonlyArray<Node> => {
47
- const all = HashSet.fromIterable(nodes)
141
+ // A native `Set`, as in `dedupe`.
142
+ const all = new Set(Arr.map(nodes, keyOf))
48
143
  return Arr.filter(
49
144
  nodes,
50
145
  (node) =>
51
146
  node._tag !== inner ||
52
- !Arr.some(node.conditions, (part) => HashSet.has(all, part)),
147
+ !Arr.some(node.conditions, (part) => all.has(keyOf(part))),
53
148
  )
54
149
  }
55
150
 
151
+ // Smart constructors
152
+
56
153
  /**
57
154
  * Gives the result of an `And` with a `False` child, or of an `Or` with a
58
155
  * `True` child. The children are tested in order, so a predicate before the
@@ -81,7 +178,8 @@ const shortCircuit = (
81
178
  /**
82
179
  * Makes an `And` or an `Or` of simple children, and makes it simple. It looks
83
180
  * one level down only:
84
- * - it puts the children of a child of the same kind into the node;
181
+ * - it puts the children of a child of the same kind into the node, when
182
+ * the child has at most `keyNodes` of them;
85
183
  * - it stops at a `False` in an `And`, or at a `True` in an `Or`;
86
184
  * - it removes `True` from an `And`, `False` from an `Or`, copies, and the
87
185
  * children that other children make useless.
@@ -94,7 +192,9 @@ const junction = (tag: "And" | "Or", children: ReadonlyArray<Node>): Node => {
94
192
  const constant = tag === "And" ? "False" : "True"
95
193
  const neutral = tag === "And" ? "True" : "False"
96
194
  const flat = Arr.flatMap(children, (child) =>
97
- child._tag === tag && !child.opaque ? child.conditions : [child],
195
+ child._tag === tag && !child.opaque && child.conditions.length <= keyNodes
196
+ ? child.conditions
197
+ : [child],
98
198
  )
99
199
  return Option.match(
100
200
  Arr.findFirstIndex(flat, (child) => child._tag === constant),
@@ -490,10 +590,7 @@ export const fields = (node: Node): ReadonlyArray<string> => {
490
590
  return []
491
591
  }
492
592
  }
493
- return Arr.flatten(
494
- Arr.mapAccum(all(node), HashSet.empty<string>(), (seen, field) => [
495
- HashSet.add(seen, field),
496
- HashSet.has(seen, field) ? [] : [field],
497
- ])[1],
498
- )
593
+ // A native `Set`, as in `dedupe`: a field of a condition that is not bound
594
+ // to a domain may be any string.
595
+ return Array.from(new Set(all(node)))
499
596
  }
@@ -7,11 +7,14 @@
7
7
  // language service check for `unknown` in an Effect is off here.
8
8
  // @effect-diagnostics anyUnknownInErrorContext:off
9
9
  import * as Cache from "effect/Cache"
10
+ import * as Chunk from "effect/Chunk"
10
11
  import * as Context from "effect/Context"
11
12
  import type * as Duration from "effect/Duration"
12
13
  import * as Effect from "effect/Effect"
13
14
  import * as Equal from "effect/Equal"
14
15
  import * as Hash from "effect/Hash"
16
+ import * as HashMap from "effect/HashMap"
17
+ import * as HashSet from "effect/HashSet"
15
18
  import * as Layer from "effect/Layer"
16
19
  import * as Result from "effect/Result"
17
20
  import * as Schema from "effect/Schema"
@@ -103,18 +106,24 @@ type AnyTarget = {
103
106
  class PrincipalKey implements Equal.Equal {
104
107
  readonly principal: object
105
108
  readonly text: string
106
- /** The key is the JSON form, so the decoded principals must be equal too. */
107
- readonly exact: boolean
108
- constructor(principal: object, text: string, exact: boolean) {
109
+ /**
110
+ * The order of the parts of the decoded principal, from {@link orderOf},
111
+ * when the key is the JSON form. The decoded principals must be equal
112
+ * too, with their parts in the same order. `undefined` for a key from
113
+ * `cache.key`.
114
+ */
115
+ readonly order: string | undefined
116
+ constructor(principal: object, text: string, order: string | undefined) {
109
117
  this.principal = principal
110
118
  this.text = text
111
- this.exact = exact
119
+ this.order = order
112
120
  }
113
121
  [Equal.symbol](that: unknown): boolean {
114
122
  return (
115
123
  that instanceof PrincipalKey &&
116
124
  that.text === this.text &&
117
- (!this.exact || Equal.equals(that.principal, this.principal))
125
+ that.order === this.order &&
126
+ (this.order === undefined || Equal.equals(that.principal, this.principal))
118
127
  )
119
128
  }
120
129
  [Hash.symbol](): number {
@@ -123,14 +132,36 @@ class PrincipalKey implements Equal.Equal {
123
132
  }
124
133
 
125
134
  /**
126
- * Makes the cache key of a principal from its JSON form. The key keeps the
127
- * principal that the Schema decodes from that form, so the loader sees only
128
- * the data that the key shows. A principal gets no key when it does not
129
- * encode, or when the decoded value is not `Equal` to it. Two classes, or a
130
- * string and a number, may have one JSON form, as in a union, and `Equal`
131
- * tells them apart. So two principals with one key are equal, and the cache
132
- * can not give one the policy of the other.
135
+ * The text of the order of the parts of a value: the names of the fields of
136
+ * each object, and the items of each list, set and map, in the order that a
137
+ * loop sees them. `Equal` does not see this order, but a loader may read it,
138
+ * as `Object.values` does. So two keys need the same order too. A scalar has
139
+ * no parts, and `Equal` compares it.
133
140
  */
141
+ const orderOf = (value: unknown): string => {
142
+ if (typeof value !== "object" || value === null) {
143
+ return ""
144
+ }
145
+ if (Array.isArray(value)) {
146
+ return `[${value.map(orderOf).join(",")}]`
147
+ }
148
+ if (
149
+ value instanceof Map ||
150
+ value instanceof Set ||
151
+ HashMap.isHashMap(value) ||
152
+ HashSet.isHashSet(value) ||
153
+ Chunk.isChunk(value)
154
+ ) {
155
+ return `<${Array.from(value as Iterable<unknown>, orderOf).join(",")}>`
156
+ }
157
+ return `{${Object.keys(value)
158
+ .map(
159
+ (name) =>
160
+ `${JSON.stringify(name)}:${orderOf((value as Record<string, unknown>)[name])}`,
161
+ )
162
+ .join(",")}}`
163
+ }
164
+
134
165
  /**
135
166
  * Gives the JSON codec of a Schema, or `undefined` when the Schema has no JSON
136
167
  * form, as a struct with a symbol key has none.
@@ -148,6 +179,17 @@ const jsonCodecOf = (
148
179
  }
149
180
  }
150
181
 
182
+ /**
183
+ * Makes the cache key of a principal from its JSON form. The key keeps the
184
+ * principal that the Schema decodes from that form, so the loader sees only
185
+ * the data that the key shows. A principal gets no key when it does not
186
+ * encode, or when the decoded value is not `Equal` to it. Two classes, or a
187
+ * string and a number, may have one JSON form, as in a union, and `Equal`
188
+ * tells them apart. A Schema may make data when it decodes, as the order of
189
+ * the fields of a record, so the key keeps that order too. So two
190
+ * principals with one key are equal, and the cache can not give one the
191
+ * policy of the other.
192
+ */
151
193
  const jsonKey = (
152
194
  schema: Schema.Top,
153
195
  ): ((principal: object) => PrincipalKey | undefined) => {
@@ -170,7 +212,7 @@ const jsonKey = (
170
212
  onFailure: () => undefined,
171
213
  onSuccess: (decoded) =>
172
214
  Equal.equals(decoded, principal)
173
- ? new PrincipalKey(decoded as object, text, true)
215
+ ? new PrincipalKey(decoded as object, text, orderOf(decoded))
174
216
  : undefined,
175
217
  })
176
218
  },
@@ -236,7 +278,7 @@ export const makeLayer = (
236
278
  const text = custom(principal)
237
279
  return text === undefined
238
280
  ? undefined
239
- : new PrincipalKey(canonical?.principal ?? principal, text, false)
281
+ : new PrincipalKey(canonical?.principal ?? principal, text, undefined)
240
282
  }
241
283
  return Layer.effect(
242
284
  service,