@nmnmcc/ability 2.0.5 → 2.0.7

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 (56) hide show
  1. package/README.md +6 -5
  2. package/dist/Condition.d.ts +6 -6
  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 +128 -79
  10. package/dist/internal/algebra.js.map +1 -1
  11. package/dist/internal/analysis.js +1 -1
  12. package/dist/internal/analysis.js.map +1 -1
  13. package/dist/internal/authorizer.d.ts.map +1 -1
  14. package/dist/internal/authorizer.js +38 -14
  15. package/dist/internal/authorizer.js.map +1 -1
  16. package/dist/internal/bind.js +59 -43
  17. package/dist/internal/bind.js.map +1 -1
  18. package/dist/internal/catalog.d.ts +6 -0
  19. package/dist/internal/catalog.d.ts.map +1 -1
  20. package/dist/internal/catalog.js +17 -14
  21. package/dist/internal/catalog.js.map +1 -1
  22. package/dist/internal/decide.js +1 -1
  23. package/dist/internal/domain.js +1 -1
  24. package/dist/internal/json.js +2 -10
  25. package/dist/internal/json.js.map +1 -1
  26. package/dist/internal/nodes.d.ts +11 -1
  27. package/dist/internal/nodes.d.ts.map +1 -1
  28. package/dist/internal/nodes.js +51 -11
  29. package/dist/internal/nodes.js.map +1 -1
  30. package/dist/internal/patterns.js +1 -1
  31. package/dist/internal/patterns.js.map +1 -1
  32. package/dist/internal/policy-json.js +2 -8
  33. package/dist/internal/policy-json.js.map +1 -1
  34. package/dist/internal/policy-nodes.d.ts +4 -1
  35. package/dist/internal/policy-nodes.d.ts.map +1 -1
  36. package/dist/internal/policy-nodes.js +14 -5
  37. package/dist/internal/policy-nodes.js.map +1 -1
  38. package/dist/internal/search.js +4 -4
  39. package/dist/internal/search.js.map +1 -1
  40. package/dist/internal/trampoline.js +75 -0
  41. package/dist/internal/trampoline.js.map +1 -0
  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 +262 -143
  46. package/src/internal/analysis.ts +3 -1
  47. package/src/internal/authorizer.ts +59 -14
  48. package/src/internal/bind.ts +149 -71
  49. package/src/internal/catalog.ts +54 -50
  50. package/src/internal/json.ts +2 -11
  51. package/src/internal/nodes.ts +58 -24
  52. package/src/internal/patterns.ts +7 -3
  53. package/src/internal/policy-json.ts +2 -9
  54. package/src/internal/policy-nodes.ts +16 -7
  55. package/src/internal/search.ts +13 -10
  56. package/src/internal/trampoline.ts +147 -0
@@ -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,39 @@ 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, with each scalar, in
137
+ * the order that a loop sees them. `Equal` does not see the order of the
138
+ * fields of an object or of the items of a map, but a loader may read it, as
139
+ * `Object.values` does. So two keys need the same order too. A scalar is in
140
+ * the text with its type, so the items of a set or a map show their order.
133
141
  */
142
+ const orderOf = (value: unknown): string => {
143
+ if (typeof value !== "object" || value === null) {
144
+ return typeof value === "string"
145
+ ? JSON.stringify(value)
146
+ : `${typeof value}:${String(value)}`
147
+ }
148
+ if (Array.isArray(value)) {
149
+ return `[${value.map(orderOf).join(",")}]`
150
+ }
151
+ if (
152
+ value instanceof Map ||
153
+ value instanceof Set ||
154
+ HashMap.isHashMap(value) ||
155
+ HashSet.isHashSet(value) ||
156
+ Chunk.isChunk(value)
157
+ ) {
158
+ return `<${Array.from(value as Iterable<unknown>, orderOf).join(",")}>`
159
+ }
160
+ return `{${Object.keys(value)
161
+ .map(
162
+ (name) =>
163
+ `${JSON.stringify(name)}:${orderOf((value as Record<string, unknown>)[name])}`,
164
+ )
165
+ .join(",")}}`
166
+ }
167
+
134
168
  /**
135
169
  * Gives the JSON codec of a Schema, or `undefined` when the Schema has no JSON
136
170
  * form, as a struct with a symbol key has none.
@@ -148,6 +182,17 @@ const jsonCodecOf = (
148
182
  }
149
183
  }
150
184
 
185
+ /**
186
+ * Makes the cache key of a principal from its JSON form. The key keeps the
187
+ * principal that the Schema decodes from that form, so the loader sees only
188
+ * the data that the key shows. A principal gets no key when it does not
189
+ * encode, or when the decoded value is not `Equal` to it. Two classes, or a
190
+ * string and a number, may have one JSON form, as in a union, and `Equal`
191
+ * tells them apart. A Schema may make data when it decodes, as the order of
192
+ * the fields of a record, so the key keeps that order too. So two
193
+ * principals with one key are equal, and the cache can not give one the
194
+ * policy of the other.
195
+ */
151
196
  const jsonKey = (
152
197
  schema: Schema.Top,
153
198
  ): ((principal: object) => PrincipalKey | undefined) => {
@@ -170,7 +215,7 @@ const jsonKey = (
170
215
  onFailure: () => undefined,
171
216
  onSuccess: (decoded) =>
172
217
  Equal.equals(decoded, principal)
173
- ? new PrincipalKey(decoded as object, text, true)
218
+ ? new PrincipalKey(decoded as object, text, orderOf(decoded))
174
219
  : undefined,
175
220
  })
176
221
  },
@@ -236,7 +281,7 @@ export const makeLayer = (
236
281
  const text = custom(principal)
237
282
  return text === undefined
238
283
  ? undefined
239
- : new PrincipalKey(canonical?.principal ?? principal, text, false)
284
+ : new PrincipalKey(canonical?.principal ?? principal, text, undefined)
240
285
  }
241
286
  return Layer.effect(
242
287
  service,
@@ -7,7 +7,6 @@
7
7
  */
8
8
  import * as Arr from "effect/Array"
9
9
  import * as HashMap from "effect/HashMap"
10
- import * as MutableHashMap from "effect/MutableHashMap"
11
10
  import * as Option from "effect/Option"
12
11
  import * as Result from "effect/Result"
13
12
  import type * as Domain from "../Domain.ts"
@@ -39,8 +38,11 @@ export interface RuleData {
39
38
  * time. It goes away when the binding ends.
40
39
  */
41
40
  interface Memo {
42
- /** The suggestion for each name, by the place where it was looked for. */
43
- readonly suggestions: MutableHashMap.MutableHashMap<string, string>
41
+ /**
42
+ * The suggestion for each name, by the place where it was looked for. The
43
+ * names come from a policy, so the map is native, as `fits` is.
44
+ */
45
+ readonly suggestions: Map<string, string>
44
46
  /**
45
47
  * If a pattern matches a field of a kind, by the kind and the pattern. The
46
48
  * patterns come from a policy, so the map is native: the hash of a string
@@ -55,7 +57,7 @@ interface Memo {
55
57
  }
56
58
 
57
59
  const makeMemo = (): Memo => ({
58
- suggestions: MutableHashMap.empty(),
60
+ suggestions: new Map(),
59
61
  fits: new Map(),
60
62
  issues: { count: 0, noted: false },
61
63
  work: { steps: 0, noted: false },
@@ -77,15 +79,15 @@ const suggestIn = (
77
79
  names: () => ReadonlyArray<string>,
78
80
  ): string => {
79
81
  const key = `${scope}\u0000${name}`
80
- const known = MutableHashMap.get(memo.suggestions, key)
81
- if (Option.isSome(known)) {
82
- return known.value
82
+ const known = memo.suggestions.get(key)
83
+ if (known !== undefined) {
84
+ return known
83
85
  }
84
- if (MutableHashMap.size(memo.suggestions) >= maxSuggestions) {
86
+ if (memo.suggestions.size >= maxSuggestions) {
85
87
  return message
86
88
  }
87
89
  const made = withSuggestion(message, name, names())
88
- MutableHashMap.set(memo.suggestions, key, made)
90
+ memo.suggestions.set(key, made)
89
91
  return made
90
92
  }
91
93
 
@@ -250,16 +252,8 @@ const patternIssues = (
250
252
  }) || before
251
253
  )
252
254
  })
253
- if (memo.work.steps > Limits.searchSteps && !memo.work.noted) {
254
- memo.work.noted = true
255
- return [
256
- {
257
- path: at,
258
- issue: `The field patterns of the policy take more than ${String(Limits.searchSteps)} steps to check. The most is ${String(Limits.searchSteps)}.`,
259
- },
260
- ]
261
- }
262
- return fits
255
+ // Past the limit of the work, `ruleIssues` gives one issue for it.
256
+ return fits || memo.work.steps > Limits.searchSteps
263
257
  ? []
264
258
  : [
265
259
  {
@@ -312,15 +306,45 @@ const principalField = (
312
306
  const isIssue = (value: Catalog.Field | Issue): value is Issue =>
313
307
  "issue" in value
314
308
 
315
- /** The issues of one operand of a test of a field. */
309
+ /**
310
+ * The fields that a test reads, with one field for each distinct kind of
311
+ * value. A rule for very many kinds may have one field type in all of them,
312
+ * so each value of the test is checked one time for each distinct kind, not
313
+ * one time for each resource. The kinds are found by their text in a native
314
+ * `Map`, which belongs to this call.
315
+ */
316
+ const distinctFields = (
317
+ context: Context,
318
+ field: string,
319
+ ): ReadonlyArray<Catalog.Field> =>
320
+ Array.from(
321
+ new Map(
322
+ Arr.map(
323
+ fieldsAt(context, field),
324
+ (found) => [JSON.stringify(found.kinds), found] as const,
325
+ ),
326
+ ).values(),
327
+ )
328
+
329
+ /**
330
+ * Counts the work of the checks of the values of a test, as the work of the
331
+ * field patterns counts. Past the limit, the values are not checked, and
332
+ * `ruleIssues` gives one issue for the work.
333
+ */
334
+ const spendChecks = (memo: Memo, values: number, fields: number): boolean => {
335
+ memo.work.steps += values * Math.max(fields, 1)
336
+ return memo.work.steps <= Limits.searchSteps
337
+ }
338
+
339
+ /** The issues of one operand of a test of a field, with its distinct fields. */
316
340
  const operandIssues = (
317
341
  context: Context,
318
342
  field: string,
343
+ fields: ReadonlyArray<Catalog.Field>,
319
344
  ordered: boolean,
320
345
  operand: Operand,
321
346
  at: ReadonlyArray<PropertyKey>,
322
347
  ): ReadonlyArray<Issue> => {
323
- const fields = fieldsAt(context, field)
324
348
  if (operand._tag === "Literal") {
325
349
  const value = operand.value
326
350
  if (ordered && typeof value !== "string" && typeof value !== "number") {
@@ -518,8 +542,17 @@ const conditionIssues = (
518
542
  }
519
543
  seen.add(node)
520
544
  const here = (issues: ReadonlyArray<Issue>) => under(context.memo, at, issues)
545
+ // A test looks its field up in each resource of the rule. A rule for very
546
+ // many kinds with very many tests is very much work, so it counts. Past
547
+ // the limit, `ruleIssues` gives one issue for it, and the policy does not
548
+ // load.
549
+ const room = (values: number) =>
550
+ spendChecks(context.memo, 1 + values, context.named.length)
521
551
  switch (node._tag) {
522
552
  case "Compare": {
553
+ if (!room(1)) {
554
+ return []
555
+ }
523
556
  const exists = readIssues(context, node.field, ["field"])
524
557
  return here(
525
558
  exists.length > 0
@@ -527,6 +560,7 @@ const conditionIssues = (
527
560
  : operandIssues(
528
561
  context,
529
562
  node.field,
563
+ distinctFields(context, node.field),
530
564
  node.op !== "eq" && node.op !== "ne",
531
565
  node.value,
532
566
  ["value"],
@@ -534,20 +568,28 @@ const conditionIssues = (
534
568
  )
535
569
  }
536
570
  case "In": {
571
+ if (!room(0)) {
572
+ return []
573
+ }
537
574
  const exists = readIssues(context, node.field, ["field"])
575
+ if (exists.length > 0) {
576
+ return here(exists)
577
+ }
578
+ const fields = distinctFields(context, node.field)
579
+ if (!spendChecks(context.memo, node.values.length, fields.length)) {
580
+ return []
581
+ }
538
582
  return here(
539
- exists.length > 0
540
- ? exists
541
- : Arr.flatMap(node.values, (operand, index) =>
542
- operandIssues(context, node.field, false, operand, [
543
- "values",
544
- index,
545
- ]),
546
- ),
583
+ Arr.flatMap(node.values, (operand, index) =>
584
+ operandIssues(context, node.field, fields, false, operand, [
585
+ "values",
586
+ index,
587
+ ]),
588
+ ),
547
589
  )
548
590
  }
549
591
  case "Exists":
550
- return here(readIssues(context, node.field, ["field"]))
592
+ return room(0) ? here(readIssues(context, node.field, ["field"])) : []
551
593
  case "Under":
552
594
  return here(underIssues(context, node.prefix, []))
553
595
  case "And":
@@ -580,19 +622,23 @@ const hasAction = (
580
622
  Arr.some(actions, (action) => descriptor.actions.includes(action))
581
623
 
582
624
  /**
583
- * Checks one rule against a domain. It gives one issue for each problem. `at`
584
- * is the path of the rule, for the paths of the issues.
625
+ * Checks one rule against a domain. It gives one issue for each problem, up
626
+ * to the most issues of the binding. `at` gives the path of the rule, and it
627
+ * is made only for an issue that the binding keeps: a rule with very many
628
+ * problems deep in a tree does not make a full path for each one.
585
629
  */
586
630
  export const ruleIssues = (
587
631
  domain: Domain.Any,
588
632
  rule: RuleData,
589
- at: ReadonlyArray<PropertyKey>,
633
+ at: LazyPath,
590
634
  memo: Memo = makeMemo(),
591
635
  ): ReadonlyArray<Issue> => {
636
+ const here = (issues: ReadonlyArray<Issue>) => under(memo, at, issues)
592
637
  const all = rule.resources.includes("*")
593
- // Each kind is checked one time, also when the list names it again. A
594
- // native `Set` finds the copies in one pass: `Arr.dedupe` compares each
595
- // name with each name before it, and a stored rule may name very many.
638
+ // Each kind is checked one time, also when the list names it again. The
639
+ // names come from a policy, so a native `Set` finds the copies:
640
+ // `Arr.dedupe` puts the names in groups by the hash of Effect, which has
641
+ // no random seed, and very many names with one hash make one long group.
596
642
  const names = Array.from(
597
643
  new Set(Arr.filter(rule.resources, (kind) => kind !== "*")),
598
644
  )
@@ -616,49 +662,81 @@ export const ruleIssues = (
616
662
  ? Arr.filter(known, (descriptor) => hasAction(descriptor, rule.actions))
617
663
  : applicable,
618
664
  }
619
- const actions = Arr.dedupe(
620
- Arr.flatMap(known, (descriptor) => descriptor.actions),
665
+ // A native `Set` finds copies about ten times as fast as `Arr.dedupe`.
666
+ const actions = Array.from(
667
+ new Set(Arr.flatMap(known, (descriptor) => descriptor.actions)),
621
668
  )
669
+ const fieldIssues = Arr.flatMap(rule.fields ?? [], (field, index) =>
670
+ here(
671
+ field.includes("*")
672
+ ? patternIssues(memo, known, field, ["fields", index])
673
+ : fieldIssue(context, field, ["fields", index]),
674
+ ),
675
+ )
676
+ const whenIssues =
677
+ rule.when === undefined
678
+ ? []
679
+ : Nodes.isNode(rule.when)
680
+ ? conditionIssues(
681
+ context,
682
+ rule.when,
683
+ () => [...at(), "when"],
684
+ new Set(),
685
+ )
686
+ : here([notWhole(["when"])])
687
+ // The work is known only after every check of the rule. Past the limit, a
688
+ // check gives no issue, so this issue must come, or the policy would load
689
+ // with checks left out.
690
+ const work = memo.work.steps > Limits.searchSteps && !memo.work.noted
691
+ memo.work.noted ||= work
622
692
  return [
623
- ...Arr.map(
693
+ ...Arr.flatMap(
624
694
  Arr.filter(names, (kind) => !HashMap.has(domain.resources, kind)),
625
- (kind): Issue => ({
626
- path: [...at, "resources"],
627
- issue: suggestIn(
628
- memo,
629
- `There is no resource with the kind "${kind}"`,
630
- kind,
631
- "~kinds",
632
- () => kinds,
633
- ),
634
- }),
695
+ (kind: string) =>
696
+ here([
697
+ {
698
+ path: ["resources"],
699
+ issue: suggestIn(
700
+ memo,
701
+ `There is no resource with the kind "${kind}"`,
702
+ kind,
703
+ "~kinds",
704
+ () => kinds,
705
+ ),
706
+ },
707
+ ]),
635
708
  ),
636
- ...Arr.map(
709
+ ...Arr.flatMap(
637
710
  Arr.filter(
638
711
  rule.actions,
639
712
  (action) => action !== "*" && !actions.includes(action),
640
713
  ),
641
- (action): Issue => ({
642
- path: [...at, "actions"],
643
- issue: suggestIn(
644
- memo,
645
- `The action "${action}" is not an action of these resources`,
646
- action,
647
- `~actions\u0000${names.join(",")}`,
648
- () => actions,
649
- ),
650
- }),
651
- ),
652
- ...Arr.flatMap(rule.fields ?? [], (field, index) =>
653
- field.includes("*")
654
- ? patternIssues(memo, known, field, [...at, "fields", index])
655
- : fieldIssue(context, field, [...at, "fields", index]),
714
+ (action: string) =>
715
+ here([
716
+ {
717
+ path: ["actions"],
718
+ issue: suggestIn(
719
+ memo,
720
+ `The action "${action}" is not an action of these resources`,
721
+ action,
722
+ `~actions\u0000${names.join(",")}`,
723
+ () => actions,
724
+ ),
725
+ },
726
+ ]),
656
727
  ),
657
- ...(rule.when === undefined
658
- ? []
659
- : Nodes.isNode(rule.when)
660
- ? conditionIssues(context, rule.when, () => [...at, "when"], new Set())
661
- : [notWhole([...at, "when"])]),
728
+ ...fieldIssues,
729
+ ...whenIssues,
730
+ // The issue of the work is not one of the most issues of the binding,
731
+ // so a policy past the limit always says why.
732
+ ...(work
733
+ ? [
734
+ {
735
+ path: at(),
736
+ issue: `The policy takes more than ${String(Limits.searchSteps)} steps to check: its field patterns, and the fields and values of its tests on each resource. The most is ${String(Limits.searchSteps)}.`,
737
+ },
738
+ ]
739
+ : []),
662
740
  ]
663
741
  }
664
742
 
@@ -721,7 +799,7 @@ export const treeIssues = (
721
799
  const memo = makeMemo()
722
800
  return Arr.flatMap(rulesOf(root, undefined, 1), (found) =>
723
801
  Array.isArray(found)
724
- ? ruleIssues(domain, found[0], Place.pathOf(found[1]), memo)
802
+ ? ruleIssues(domain, found[0], () => Place.pathOf(found[1]), memo)
725
803
  : [found as Issue],
726
804
  )
727
805
  }
@@ -69,7 +69,9 @@ export const joinKinds = (left: Kinds, right: Kinds): Kinds => ({
69
69
  ? left.number
70
70
  : right.number,
71
71
  object: left.object || right.object,
72
- literals: Arr.dedupe([...left.literals, ...right.literals]),
72
+ // A native `Set` finds copies about ten times as fast as `Arr.dedupe`. It
73
+ // takes `0` and `-0` as one, as `includes` does when a test reads them.
74
+ literals: Array.from(new Set([...left.literals, ...right.literals])),
73
75
  })
74
76
 
75
77
  /** Tests if a number fits the numbers of a field. */
@@ -146,6 +148,12 @@ export interface Branch {
146
148
  readonly field: Field | undefined
147
149
  /** The places below, by name, in the order of the Schema. */
148
150
  readonly children: ReadonlyArray<readonly [string, Branch]>
151
+ /**
152
+ * The places below, by name. A lookup gets each name from `split`, as a
153
+ * new string, and a `HashMap` finds a new string about 20% faster than a
154
+ * native `Map`. The names come from the Schema, so a name from a policy
155
+ * can not make a long list of names with one hash here.
156
+ */
149
157
  readonly names: HashMap.HashMap<string, Branch>
150
158
  /** The place for the keys of a record, when the value may be a record. */
151
159
  readonly key: Key | undefined
@@ -361,12 +369,12 @@ const nameOf = (signature: SchemaAST.PropertySignature): string | undefined =>
361
369
  /**
362
370
  * The properties of a struct by name. A walk makes it one time for each
363
371
  * struct of a place, so a struct with very many fields does not take a
364
- * search for each name.
372
+ * search for each name. It is a native `Map`, as `Branch.names` is.
365
373
  */
366
374
  const propertiesOf = (
367
375
  struct: SchemaAST.Objects,
368
- ): HashMap.HashMap<string, SchemaAST.PropertySignature> =>
369
- HashMap.fromIterable(
376
+ ): ReadonlyMap<string, SchemaAST.PropertySignature> =>
377
+ new Map(
370
378
  Arr.flatMap(struct.propertySignatures, (signature) => {
371
379
  const name = nameOf(signature)
372
380
  return name === undefined ? [] : [[name, signature] as const]
@@ -377,25 +385,29 @@ const propertiesOf = (
377
385
  const namesOf = (
378
386
  structs: ReadonlyArray<SchemaAST.Objects>,
379
387
  ): ReadonlyArray<string> =>
380
- Arr.dedupe(
381
- Arr.flatMap(structs, (struct) =>
382
- Arr.flatMap(struct.propertySignatures, (signature) => {
383
- const name = nameOf(signature)
384
- if (name === undefined) {
385
- return []
386
- }
387
- // A dot in a path is the step into a nested field. A field name with
388
- // a dot would make two fields have one path, so it is a defect.
389
- if (name.includes(".")) {
390
- throw new Error(`A field name may not have a dot: "${name}"`)
391
- }
392
- // An empty name would make an empty part in a path, such as
393
- // `.secret`.
394
- if (name === "") {
395
- throw new Error("A field name may not be empty")
396
- }
397
- return [name]
398
- }),
388
+ // A native `Set` keeps the first copy of each name, in order, about ten
389
+ // times as fast as `Arr.dedupe`.
390
+ Array.from(
391
+ new Set(
392
+ Arr.flatMap(structs, (struct) =>
393
+ Arr.flatMap(struct.propertySignatures, (signature) => {
394
+ const name = nameOf(signature)
395
+ if (name === undefined) {
396
+ return []
397
+ }
398
+ // A dot in a path is the step into a nested field. A field name with
399
+ // a dot would make two fields have one path, so it is a defect.
400
+ if (name.includes(".")) {
401
+ throw new Error(`A field name may not have a dot: "${name}"`)
402
+ }
403
+ // An empty name would make an empty part in a path, such as
404
+ // `.secret`.
405
+ if (name === "") {
406
+ throw new Error("A field name may not be empty")
407
+ }
408
+ return [name]
409
+ }),
410
+ ),
399
411
  ),
400
412
  )
401
413
 
@@ -406,15 +418,6 @@ const namesOf = (
406
418
  */
407
419
  const branchOf = (place: Place, count: { size: number }): Branch => {
408
420
  const structs = Arr.filter(Arr.map(place.schemas, structOf), isStruct)
409
- // The structs of classes. A value of a class is not copied into a plain
410
- // object, so it keeps its getters and methods.
411
- const classes = Arr.filter(
412
- Arr.map(
413
- Arr.filter(place.schemas, (schema) => schema._tag === "Declaration"),
414
- structOf,
415
- ),
416
- isStruct,
417
- )
418
421
  const values = Arr.filter(
419
422
  place.schemas,
420
423
  (schema) => !isStruct(structOf(schema)),
@@ -476,11 +479,18 @@ const branchOf = (place: Place, count: { size: number }): Branch => {
476
479
  }
477
480
  }
478
481
  const structProperties = Arr.map(structs, propertiesOf)
479
- const classProperties = Arr.map(classes, propertiesOf)
480
482
  const children = Arr.map(namesOf(structs), (name) => {
481
- const properties = Arr.flatMap(structProperties, (named) =>
482
- Option.toArray(HashMap.get(named, name)),
483
- )
483
+ const properties = Arr.flatMap(structProperties, (named) => {
484
+ const property = named.get(name)
485
+ return property === undefined ? [] : [property]
486
+ })
487
+ // A struct without the name may still have it as an extra field, with
488
+ // any value: only a decode or a `make` takes extra fields away, and a
489
+ // decision may get a value made by hand. A value that may be any
490
+ // object, as one of `Schema.Unknown` may, may have the name with any
491
+ // value too.
492
+ const top =
493
+ place.top || valueKinds.object || properties.length < structs.length
484
494
  const below: Place = {
485
495
  path: [...place.path, name],
486
496
  schemas: Arr.flatMap(properties, (property) =>
@@ -492,15 +502,10 @@ const branchOf = (place: Place, count: { size: number }): Branch => {
492
502
  place.missing ||
493
503
  values.length > 0 ||
494
504
  Arr.some(properties, (property) => SchemaAST.isOptional(property.type)),
495
- // A struct without the name may still have it as an extra field,
496
- // with any value: only a decode takes extra fields away. A value
497
- // that may be any object, as one of `Schema.Unknown` may, may have
498
- // the name with any value too.
499
- top: place.top || valueKinds.object || properties.length < structs.length,
500
- plain:
501
- place.plain &&
502
- !valueKinds.object &&
503
- Arr.every(classProperties, (named) => HashMap.has(named, name)),
505
+ top,
506
+ // A value that may be anything may be a list, or have a getter, so
507
+ // a read below it may not find plain data.
508
+ plain: place.plain && !top,
504
509
  }
505
510
  return [name, branchOf(below, count)] as const
506
511
  })
@@ -509,6 +514,8 @@ const branchOf = (place: Place, count: { size: number }): Branch => {
509
514
  if (signatures.length === 0) {
510
515
  return { field, children, names, key: undefined, keyBranch: undefined }
511
516
  }
517
+ const keyTop =
518
+ place.top || valueKinds.object || records.length < structs.length
512
519
  const key: Key = {
513
520
  _tag: "Key",
514
521
  parameters: Arr.map(signatures, (signature) => signature.parameter),
@@ -521,11 +528,8 @@ const branchOf = (place: Place, count: { size: number }): Branch => {
521
528
  ),
522
529
  // A record may not have a given key.
523
530
  missing: true,
524
- top: place.top || valueKinds.object || records.length < structs.length,
525
- plain:
526
- place.plain &&
527
- !valueKinds.object &&
528
- Arr.every(classes, (struct) => struct.indexSignatures.length > 0),
531
+ top: keyTop,
532
+ plain: place.plain && !keyTop,
529
533
  },
530
534
  count,
531
535
  )
@@ -143,15 +143,6 @@ const decodeOperands = (
143
143
  decodeOperand(operand, [...path, name, index]),
144
144
  )
145
145
 
146
- const compareOperators: ReadonlyArray<string> = [
147
- "eq",
148
- "ne",
149
- "lt",
150
- "lte",
151
- "gt",
152
- "gte",
153
- ]
154
-
155
146
  const decodeNode = (
156
147
  input: unknown,
157
148
  path: ReadonlyArray<PropertyKey>,
@@ -199,11 +190,11 @@ const makeNode = (
199
190
  return Nodes.never
200
191
  case "Compare": {
201
192
  const op = readString(input, "op", path)
202
- if (!compareOperators.includes(op)) {
193
+ if (!Nodes.isCompareOperator(op)) {
203
194
  throw problem([...path, "op"], `"${op}" is not a compare operator`)
204
195
  }
205
196
  return new Nodes.Compare({
206
- op: op as Nodes.CompareOperator,
197
+ op,
207
198
  field: readString(input, "field", path),
208
199
  value: decodeOperand(read(input, "value", path), [...path, "value"]),
209
200
  })