@nmnmcc/ability 0.3.0 → 1.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.
- package/README.md +169 -14
- package/dist/Authorizer.d.ts +64 -39
- package/dist/Authorizer.d.ts.map +1 -1
- package/dist/Authorizer.js +291 -6
- package/dist/Authorizer.js.map +1 -1
- package/dist/Condition.d.ts +444 -65
- package/dist/Condition.d.ts.map +1 -1
- package/dist/Condition.js +1007 -25
- package/dist/Condition.js.map +1 -1
- package/dist/Domain.d.ts +27 -17
- package/dist/Domain.d.ts.map +1 -1
- package/dist/Domain.js +52 -10
- package/dist/Domain.js.map +1 -1
- package/dist/HttpApi.d.ts +84 -40
- package/dist/HttpApi.d.ts.map +1 -1
- package/dist/HttpApi.js +101 -11
- package/dist/HttpApi.js.map +1 -1
- package/dist/Policy.d.ts +311 -53
- package/dist/Policy.d.ts.map +1 -1
- package/dist/Policy.js +1212 -35
- package/dist/Policy.js.map +1 -1
- package/dist/Resource.d.ts +167 -16
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +784 -11
- package/dist/Resource.js.map +1 -1
- package/dist/Rule.d.ts +113 -27
- package/dist/Rule.d.ts.map +1 -1
- package/dist/Rule.js +217 -10
- package/dist/Rule.js.map +1 -1
- package/dist/index.js +4 -4
- package/package.json +1 -1
- package/src/Authorizer.ts +623 -59
- package/src/Condition.ts +2403 -112
- package/src/Domain.ts +79 -22
- package/src/HttpApi.ts +223 -23
- package/src/Policy.ts +2833 -93
- package/src/Resource.ts +1537 -22
- package/src/Rule.ts +461 -43
- package/dist/internal/authorizer.d.ts +0 -28
- package/dist/internal/authorizer.d.ts.map +0 -1
- package/dist/internal/authorizer.js +0 -153
- package/dist/internal/authorizer.js.map +0 -1
- package/dist/internal/condition.d.ts +0 -11
- package/dist/internal/condition.d.ts.map +0 -1
- package/dist/internal/condition.js +0 -393
- package/dist/internal/condition.js.map +0 -1
- package/dist/internal/domain.js +0 -34
- package/dist/internal/domain.js.map +0 -1
- package/dist/internal/httpApi.js +0 -41
- package/dist/internal/httpApi.js.map +0 -1
- package/dist/internal/policy.js +0 -524
- package/dist/internal/policy.js.map +0 -1
- package/dist/internal/resource.js +0 -66
- package/dist/internal/resource.js.map +0 -1
- package/dist/internal/rule.js +0 -131
- package/dist/internal/rule.js.map +0 -1
- package/src/internal/authorizer.ts +0 -271
- package/src/internal/condition.ts +0 -607
- package/src/internal/domain.ts +0 -56
- package/src/internal/httpApi.ts +0 -84
- package/src/internal/policy.ts +0 -815
- package/src/internal/resource.ts +0 -98
- package/src/internal/rule.ts +0 -203
package/src/Resource.ts
CHANGED
|
@@ -21,22 +21,14 @@
|
|
|
21
21
|
* @stability stable
|
|
22
22
|
* @since 0.3.0
|
|
23
23
|
*/
|
|
24
|
+
import * as Arr from "effect/Array"
|
|
25
|
+
import { Class as DataClass } from "effect/Data"
|
|
26
|
+
import * as HashMap from "effect/HashMap"
|
|
27
|
+
import * as HashSet from "effect/HashSet"
|
|
28
|
+
import * as Option from "effect/Option"
|
|
29
|
+
import { hasProperty, isObject } from "effect/Predicate"
|
|
24
30
|
import * as Schema from "effect/Schema"
|
|
25
|
-
import * as
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* @stability stable
|
|
29
|
-
* @category type IDs
|
|
30
|
-
* @since 0.3.0
|
|
31
|
-
*/
|
|
32
|
-
export const TypeId: "~@nmnmcc/ability/Resource" = internal.TypeId
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* @stability stable
|
|
36
|
-
* @category type IDs
|
|
37
|
-
* @since 0.3.0
|
|
38
|
-
*/
|
|
39
|
-
export type TypeId = typeof TypeId
|
|
31
|
+
import * as SchemaAST from "effect/SchemaAST"
|
|
40
32
|
|
|
41
33
|
/**
|
|
42
34
|
* One part of a resource path. A string is a fixed part. A field reference
|
|
@@ -73,7 +65,18 @@ export interface Descriptor {
|
|
|
73
65
|
readonly path: ReadonlyArray<PathSegment> | undefined
|
|
74
66
|
/** The paths of the data fields, with dots for nested fields. */
|
|
75
67
|
readonly fieldPaths: ReadonlyArray<string>
|
|
76
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* The paths in {@link fieldPaths} of fields that hold more than one value,
|
|
70
|
+
* such as a record, a map or an array. The parts of such a field are not
|
|
71
|
+
* in `fieldPaths`, but a rule may name them.
|
|
72
|
+
*/
|
|
73
|
+
readonly openPaths: ReadonlyArray<string>
|
|
74
|
+
/**
|
|
75
|
+
* A struct Schema of the data of the values, with the `_tag` field. It is a
|
|
76
|
+
* struct also for a `Resource.Class`. The class Schema must not be read
|
|
77
|
+
* before the class is made, because Effect then binds the class Schema to
|
|
78
|
+
* the base class, and the values lose the getters of the class.
|
|
79
|
+
*/
|
|
77
80
|
readonly schema: Schema.Top
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -91,7 +94,7 @@ export interface Resource<
|
|
|
91
94
|
Data extends object,
|
|
92
95
|
Value extends { readonly _tag: Kind },
|
|
93
96
|
> {
|
|
94
|
-
readonly
|
|
97
|
+
readonly descriptor: Descriptor
|
|
95
98
|
readonly "~Resource"?: {
|
|
96
99
|
readonly Kind: Kind
|
|
97
100
|
readonly Action: Action
|
|
@@ -164,7 +167,1473 @@ export interface Options<
|
|
|
164
167
|
*/
|
|
165
168
|
readonly path?: (fields: {
|
|
166
169
|
readonly [K in keyof Fields & string]: FieldRef<K>
|
|
167
|
-
}) => ReadonlyArray<
|
|
170
|
+
}) => ReadonlyArray<string | FieldRef<keyof Fields & string>>
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The class of the runtime data of a resource. {@link isResource} uses it to
|
|
175
|
+
* know a resource.
|
|
176
|
+
*/
|
|
177
|
+
class ResourceDescriptor extends DataClass<Descriptor> {}
|
|
178
|
+
|
|
179
|
+
/** The prototypes from `prototype` up, below `Object.prototype`. */
|
|
180
|
+
const prototypesFrom = (prototype: unknown): ReadonlyArray<object> =>
|
|
181
|
+
Arr.unfold(prototype, (current) =>
|
|
182
|
+
typeof current === "object" &&
|
|
183
|
+
current !== null &&
|
|
184
|
+
current !== Object.prototype
|
|
185
|
+
? Option.some([current, Object.getPrototypeOf(current)] as const)
|
|
186
|
+
: Option.none(),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Gives the prototype of the class of a class node, or `undefined`. The node
|
|
191
|
+
* does not keep the class: a class Schema with an annotation or a check, or
|
|
192
|
+
* one inside `Schema.suspend`, is a new Schema with the same node. So the
|
|
193
|
+
* walk asks the node to make a value with no check, and reads its prototype.
|
|
194
|
+
*
|
|
195
|
+
* This uses the `"~constructor"` annotation of Effect, which is not a public
|
|
196
|
+
* API: it gives a link whose decode makes a value of the class from its
|
|
197
|
+
* fields without a check. When that is not so, the walk gives `undefined`,
|
|
198
|
+
* and the check of a policy says no to the getter.
|
|
199
|
+
*/
|
|
200
|
+
const classPrototype = (ast: SchemaAST.Declaration): object | undefined => {
|
|
201
|
+
const make: unknown = ast.annotations?.["~constructor"]
|
|
202
|
+
if (typeof make !== "function") {
|
|
203
|
+
return undefined
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const made: unknown = make(ast.typeParameters)
|
|
207
|
+
const decode: unknown =
|
|
208
|
+
hasProperty(made, "link") &&
|
|
209
|
+
hasProperty(made.link, "transformation") &&
|
|
210
|
+
hasProperty(made.link.transformation, "decode")
|
|
211
|
+
? made.link.transformation.decode
|
|
212
|
+
: undefined
|
|
213
|
+
const value: unknown =
|
|
214
|
+
hasProperty(decode, "transform") && typeof decode.transform === "function"
|
|
215
|
+
? decode.transform({})
|
|
216
|
+
: undefined
|
|
217
|
+
return isObject(value) ? Object.getPrototypeOf(value) : undefined
|
|
218
|
+
} catch {
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Tests if a path names a getter of a class Schema, such as a getter of a
|
|
225
|
+
* `Schema.Class` or a `Principal.Class`, at its root or in a nested field. A
|
|
226
|
+
* condition reads such a field, and the types of a condition take it, so the
|
|
227
|
+
* check of a policy takes it too. It goes into the fields of a struct, the
|
|
228
|
+
* values of a record, the members of a union, `Schema.suspend`, and a class
|
|
229
|
+
* with an annotation or a check.
|
|
230
|
+
*
|
|
231
|
+
* @stability stable
|
|
232
|
+
* @category predicates
|
|
233
|
+
* @since 1.0.0
|
|
234
|
+
*/
|
|
235
|
+
export const hasGetter = (schema: unknown, path: string): boolean =>
|
|
236
|
+
!isTooLong(path) &&
|
|
237
|
+
Schema.isSchema(schema) &&
|
|
238
|
+
getterAt(
|
|
239
|
+
schema.ast,
|
|
240
|
+
path.split("."),
|
|
241
|
+
0,
|
|
242
|
+
(part, name) => part === name,
|
|
243
|
+
new Map(),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The most parts that a field path may have. The walks below call themselves
|
|
248
|
+
* for each part, and a path comes from the input of a policy. So a very long
|
|
249
|
+
* path, as in a recursive Schema, could fill the stack.
|
|
250
|
+
*/
|
|
251
|
+
const maxPathParts = 256
|
|
252
|
+
|
|
253
|
+
/** Tests if a field path has more than {@link maxPathParts} parts. */
|
|
254
|
+
const isTooLong = (path: string): boolean =>
|
|
255
|
+
path.split(".", maxPathParts + 1).length > maxPathParts
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Tests if a Schema has a getter of a class at a path whose parts fit
|
|
259
|
+
* `parts`, as `hasGetter` does for one path. A part fits a name when `fits`
|
|
260
|
+
* says so, as a part with `*` fits many names. A part `**` stands for any
|
|
261
|
+
* number of parts, also none.
|
|
262
|
+
*
|
|
263
|
+
* @stability stable
|
|
264
|
+
* @category predicates
|
|
265
|
+
* @since 1.0.0
|
|
266
|
+
*/
|
|
267
|
+
export const hasMatchingGetter = (
|
|
268
|
+
schema: unknown,
|
|
269
|
+
parts: ReadonlyArray<string>,
|
|
270
|
+
fits: (part: string, name: string) => boolean,
|
|
271
|
+
): boolean =>
|
|
272
|
+
parts.length <= maxPathParts &&
|
|
273
|
+
Schema.isSchema(schema) &&
|
|
274
|
+
getterAt(schema.ast, parts, 0, fits, new Map())
|
|
275
|
+
|
|
276
|
+
/** The names of the getters of a prototype and the prototypes above it. */
|
|
277
|
+
const getterNames = (prototype: object | undefined): ReadonlyArray<string> =>
|
|
278
|
+
Arr.flatMap(prototypesFrom(prototype), (current) =>
|
|
279
|
+
Arr.filter(
|
|
280
|
+
Object.getOwnPropertyNames(current),
|
|
281
|
+
(name) =>
|
|
282
|
+
Object.getOwnPropertyDescriptor(current, name)?.get !== undefined,
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
const getterAt = (
|
|
287
|
+
ast: SchemaAST.AST,
|
|
288
|
+
names: ReadonlyArray<string>,
|
|
289
|
+
index: number,
|
|
290
|
+
fits: (part: string, name: string) => boolean,
|
|
291
|
+
memo: PathMemo<boolean>,
|
|
292
|
+
): boolean =>
|
|
293
|
+
rememberAt(memo, ast, index, false, () => {
|
|
294
|
+
const name = names[index]
|
|
295
|
+
if (name === undefined) {
|
|
296
|
+
return false
|
|
297
|
+
}
|
|
298
|
+
const last = index === names.length - 1
|
|
299
|
+
const next = (type: SchemaAST.AST, at: number) =>
|
|
300
|
+
getterAt(type, names, at, fits, memo)
|
|
301
|
+
// A `**` may stand for no parts.
|
|
302
|
+
if (name === "**" && !last && next(ast, index + 1)) {
|
|
303
|
+
return true
|
|
304
|
+
}
|
|
305
|
+
switch (ast._tag) {
|
|
306
|
+
case "Union":
|
|
307
|
+
return Arr.some(ast.types, (type) => next(type, index))
|
|
308
|
+
case "Suspend":
|
|
309
|
+
return next(ast.thunk(), index)
|
|
310
|
+
case "Declaration": {
|
|
311
|
+
const inner = classStruct(ast)
|
|
312
|
+
if (inner === undefined) {
|
|
313
|
+
return false
|
|
314
|
+
}
|
|
315
|
+
// A path ends at a getter. The library can not see the type of
|
|
316
|
+
// the value of a getter, so it can not check a path that goes
|
|
317
|
+
// past it.
|
|
318
|
+
return last
|
|
319
|
+
? Arr.some(getterNames(classPrototype(ast)), (getter) =>
|
|
320
|
+
fits(name, getter),
|
|
321
|
+
)
|
|
322
|
+
: next(inner, index)
|
|
323
|
+
}
|
|
324
|
+
case "Objects":
|
|
325
|
+
return (
|
|
326
|
+
!last &&
|
|
327
|
+
(Arr.some(
|
|
328
|
+
ast.propertySignatures,
|
|
329
|
+
(property) =>
|
|
330
|
+
typeof property.name === "string" &&
|
|
331
|
+
// A `**` may stand for this part and more.
|
|
332
|
+
((name === "**" && next(property.type, index)) ||
|
|
333
|
+
(fits(name, property.name) && next(property.type, index + 1))),
|
|
334
|
+
) ||
|
|
335
|
+
// A record keeps the Schema of its keys and of its values.
|
|
336
|
+
Arr.some(ast.indexSignatures, (signature) =>
|
|
337
|
+
name === "**"
|
|
338
|
+
? hasSomeKey(signature.parameter) &&
|
|
339
|
+
(next(signature.type, index) ||
|
|
340
|
+
next(signature.type, index + 1))
|
|
341
|
+
: Arr.some(
|
|
342
|
+
[name.replaceAll("*", ""), name.replaceAll("*", "a")],
|
|
343
|
+
(key) =>
|
|
344
|
+
fits(name, key) && keyFits(signature.parameter, key),
|
|
345
|
+
) && next(signature.type, index + 1),
|
|
346
|
+
))
|
|
347
|
+
)
|
|
348
|
+
default:
|
|
349
|
+
return false
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Tests if a record takes a key. A key of an object is a string, also a key
|
|
355
|
+
* that a number Schema takes: `{ 1: "a" }` has the key `"1"`. So a key that
|
|
356
|
+
* is the text of a number is tried as that number too.
|
|
357
|
+
*/
|
|
358
|
+
const keyFits = (parameter: SchemaAST.AST, key: string): boolean => {
|
|
359
|
+
const is = Schema.is(Schema.make(parameter))
|
|
360
|
+
return (
|
|
361
|
+
is(key) || (key !== "" && String(Number(key)) === key && is(Number(key)))
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Gives the struct of a `Schema.Class`. A class is a declaration with one
|
|
367
|
+
* struct as its type parameter, and Effect marks it with a `"~constructor"`
|
|
368
|
+
* annotation, also through more codec steps, as in
|
|
369
|
+
* `Schema.fromJsonString(User)`. Other declarations, such as
|
|
370
|
+
* `Schema.OptionFromNullOr` or `Schema.ReadonlyMap`, have no such mark, and
|
|
371
|
+
* the fields of their type parameter are not fields of the value.
|
|
372
|
+
*/
|
|
373
|
+
const classStruct = (
|
|
374
|
+
ast: SchemaAST.Declaration,
|
|
375
|
+
): SchemaAST.Objects | undefined => {
|
|
376
|
+
const [inner] = ast.typeParameters
|
|
377
|
+
return ast.annotations?.["~constructor"] !== undefined &&
|
|
378
|
+
ast.typeParameters.length === 1 &&
|
|
379
|
+
inner?._tag === "Objects"
|
|
380
|
+
? inner
|
|
381
|
+
: undefined
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Gives the structs inside a Schema, with the `Schema.suspend` nodes that are
|
|
386
|
+
* open on the way to each one. The open nodes are in a native `Set`, because
|
|
387
|
+
* it must find a node by the node itself: `Equal` compares Schema nodes by
|
|
388
|
+
* structure, and a recursive Schema has no end. It looks through optional keys, unions such as
|
|
389
|
+
* `Schema.NullOr`, `Schema.suspend`, and `Schema.Class`. A suspend node that
|
|
390
|
+
* is already open is not opened again, so a recursive Schema has an end.
|
|
391
|
+
*/
|
|
392
|
+
const structsOf = (
|
|
393
|
+
ast: SchemaAST.AST,
|
|
394
|
+
open: ReadonlySet<SchemaAST.AST>,
|
|
395
|
+
): ReadonlyArray<readonly [SchemaAST.Objects, ReadonlySet<SchemaAST.AST>]> => {
|
|
396
|
+
switch (ast._tag) {
|
|
397
|
+
case "Objects":
|
|
398
|
+
return ast.propertySignatures.length === 0 ? [] : [[ast, open]]
|
|
399
|
+
case "Union":
|
|
400
|
+
return ast.types.flatMap((type) => structsOf(type, open))
|
|
401
|
+
case "Suspend":
|
|
402
|
+
return open.has(ast)
|
|
403
|
+
? []
|
|
404
|
+
: structsOf(ast.thunk(), new Set([...open, ast]))
|
|
405
|
+
case "Declaration": {
|
|
406
|
+
const inner = classStruct(ast)
|
|
407
|
+
return inner === undefined ? [] : structsOf(inner, open)
|
|
408
|
+
}
|
|
409
|
+
default:
|
|
410
|
+
return []
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Tests if a Schema holds one plain value, with no parts. */
|
|
415
|
+
/**
|
|
416
|
+
* The kind of value that a field holds: one plain value, a record with keys
|
|
417
|
+
* that the Schema does not name, or another value with parts, such as an
|
|
418
|
+
* array or a map.
|
|
419
|
+
*/
|
|
420
|
+
type FieldShape = "scalar" | "record" | "other"
|
|
421
|
+
|
|
422
|
+
/** Gives the kind of value that a field holds. See {@link FieldShape}. */
|
|
423
|
+
const shapeOf = (
|
|
424
|
+
ast: SchemaAST.AST,
|
|
425
|
+
open: ReadonlySet<SchemaAST.AST> = new Set(),
|
|
426
|
+
): FieldShape => {
|
|
427
|
+
switch (ast._tag) {
|
|
428
|
+
case "Objects":
|
|
429
|
+
return ast.indexSignatures.length > 0 ? "record" : "other"
|
|
430
|
+
case "Union": {
|
|
431
|
+
const shapes = ast.types.map((type) => shapeOf(type, open))
|
|
432
|
+
return shapes.every((shape) => shape === "scalar")
|
|
433
|
+
? "scalar"
|
|
434
|
+
: shapes.includes("record")
|
|
435
|
+
? "record"
|
|
436
|
+
: "other"
|
|
437
|
+
}
|
|
438
|
+
case "Suspend":
|
|
439
|
+
return open.has(ast)
|
|
440
|
+
? "other"
|
|
441
|
+
: shapeOf(ast.thunk(), new Set([...open, ast]))
|
|
442
|
+
default:
|
|
443
|
+
return isScalar(ast) ? "scalar" : "other"
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const isScalar = (ast: SchemaAST.AST): boolean => {
|
|
448
|
+
switch (ast._tag) {
|
|
449
|
+
case "String":
|
|
450
|
+
case "Number":
|
|
451
|
+
case "Boolean":
|
|
452
|
+
case "BigInt":
|
|
453
|
+
case "Symbol":
|
|
454
|
+
case "UniqueSymbol":
|
|
455
|
+
case "Literal":
|
|
456
|
+
case "Enum":
|
|
457
|
+
case "TemplateLiteral":
|
|
458
|
+
case "Null":
|
|
459
|
+
case "Undefined":
|
|
460
|
+
return true
|
|
461
|
+
default:
|
|
462
|
+
return false
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Tests if a Schema holds, in some branch, a value that is not a struct: a
|
|
468
|
+
* scalar, or a value such as a record or an array.
|
|
469
|
+
*/
|
|
470
|
+
const holdsValue = (
|
|
471
|
+
ast: SchemaAST.AST,
|
|
472
|
+
open: ReadonlySet<SchemaAST.AST>,
|
|
473
|
+
): boolean => {
|
|
474
|
+
switch (ast._tag) {
|
|
475
|
+
case "Objects":
|
|
476
|
+
// A struct with a record part, as from `Schema.StructWithRest`, holds
|
|
477
|
+
// keys that are not named, so it holds more than one value.
|
|
478
|
+
return (
|
|
479
|
+
ast.propertySignatures.length === 0 || ast.indexSignatures.length > 0
|
|
480
|
+
)
|
|
481
|
+
case "Union":
|
|
482
|
+
return ast.types.some((type) => holdsValue(type, open))
|
|
483
|
+
case "Suspend":
|
|
484
|
+
return !open.has(ast) && holdsValue(ast.thunk(), new Set([...open, ast]))
|
|
485
|
+
case "Declaration":
|
|
486
|
+
return classStruct(ast) === undefined
|
|
487
|
+
default:
|
|
488
|
+
return true
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** One field that {@link walkFields} finds. */
|
|
493
|
+
interface FieldVisit {
|
|
494
|
+
readonly path: string
|
|
495
|
+
/**
|
|
496
|
+
* `true` for a field that holds a value that is not a struct, and `false`
|
|
497
|
+
* for a field that holds a struct.
|
|
498
|
+
*/
|
|
499
|
+
readonly leaf: boolean
|
|
500
|
+
readonly type: SchemaAST.AST
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Gives the path of every field of a Schema, with a dot between the names of
|
|
505
|
+
* nested fields. A field of a union may come two times: one time as a field
|
|
506
|
+
* that holds a value, and one time as a field that holds a struct.
|
|
507
|
+
*/
|
|
508
|
+
const walkFields = (
|
|
509
|
+
ast: SchemaAST.AST,
|
|
510
|
+
prefix = "",
|
|
511
|
+
open: ReadonlySet<SchemaAST.AST> = new Set(),
|
|
512
|
+
): ReadonlyArray<FieldVisit> =>
|
|
513
|
+
Arr.flatMap(structsOf(ast, open), ([struct, inner]) =>
|
|
514
|
+
Arr.flatMap(struct.propertySignatures, (property): Array<FieldVisit> => {
|
|
515
|
+
const name = property.name
|
|
516
|
+
if (typeof name !== "string") {
|
|
517
|
+
return []
|
|
518
|
+
}
|
|
519
|
+
// A dot in a path is the step into a nested field. A field name with a
|
|
520
|
+
// dot would make two fields have one path, so it is a defect.
|
|
521
|
+
if (name.includes(".")) {
|
|
522
|
+
throw new Error(`A field name may not have a dot: "${name}"`)
|
|
523
|
+
}
|
|
524
|
+
// An empty name would make an empty part in a path, such as `.secret`.
|
|
525
|
+
if (name === "") {
|
|
526
|
+
throw new Error("A field name may not be empty")
|
|
527
|
+
}
|
|
528
|
+
const path = prefix === "" ? name : `${prefix}.${name}`
|
|
529
|
+
const type = property.type
|
|
530
|
+
return [
|
|
531
|
+
...(holdsValue(type, inner) ? [{ path, leaf: true, type }] : []),
|
|
532
|
+
...(structsOf(type, inner).length > 0
|
|
533
|
+
? [{ path, leaf: false, type }, ...walkFields(type, path, inner)]
|
|
534
|
+
: []),
|
|
535
|
+
]
|
|
536
|
+
}),
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Gives the paths of all the fields of a Schema, with dots for nested fields.
|
|
541
|
+
* A nested struct gives its own path and the paths of its fields. Arrays are
|
|
542
|
+
* left out. It throws when a field name has a dot or is empty, because such a
|
|
543
|
+
* name would give two fields one path, or a path with an empty part.
|
|
544
|
+
*
|
|
545
|
+
* @stability stable
|
|
546
|
+
* @category getters
|
|
547
|
+
* @since 1.0.0
|
|
548
|
+
*/
|
|
549
|
+
export const fieldPaths = (schema: Schema.Top): ReadonlyArray<string> =>
|
|
550
|
+
Arr.dedupe(Arr.map(walkFields(schema.ast), (visit) => visit.path))
|
|
551
|
+
|
|
552
|
+
/** Tests if a Schema holds only plain scalars: see {@link isPlainField}. */
|
|
553
|
+
/**
|
|
554
|
+
* The ids of the checks that the search knows. `domainOf` reads them, so a
|
|
555
|
+
* field with only these checks has an exact domain.
|
|
556
|
+
*/
|
|
557
|
+
const knownChecks = HashSet.make(
|
|
558
|
+
"effect/schema/isInt",
|
|
559
|
+
"effect/schema/isFinite",
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Tests if each check of a Schema node is a check that the search knows. A
|
|
564
|
+
* check such as `isGreaterThan(0)` stops some values, and the search does not
|
|
565
|
+
* see it, so it could find a value that the Schema does not take.
|
|
566
|
+
*/
|
|
567
|
+
const hasOnlyKnownChecks = (
|
|
568
|
+
checks: ReadonlyArray<SchemaAST.Check<never>> | undefined,
|
|
569
|
+
): boolean =>
|
|
570
|
+
Arr.every(checks ?? [], (check) =>
|
|
571
|
+
check._tag === "FilterGroup"
|
|
572
|
+
? hasOnlyKnownChecks(check.checks)
|
|
573
|
+
: HashSet.has(knownChecks, check.annotations?.representation?.id),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
const isPlainScalar = (ast: SchemaAST.AST): boolean => {
|
|
577
|
+
// `domainOf` reads the known checks only on a number node. A check on
|
|
578
|
+
// another node, such as on a union of literals, is a check it can not see.
|
|
579
|
+
if (
|
|
580
|
+
ast._tag === "Number"
|
|
581
|
+
? !hasOnlyKnownChecks(ast.checks)
|
|
582
|
+
: (ast.checks ?? []).length > 0
|
|
583
|
+
) {
|
|
584
|
+
return false
|
|
585
|
+
}
|
|
586
|
+
switch (ast._tag) {
|
|
587
|
+
case "String":
|
|
588
|
+
case "Number":
|
|
589
|
+
case "Boolean":
|
|
590
|
+
case "Literal":
|
|
591
|
+
case "Null":
|
|
592
|
+
case "Undefined":
|
|
593
|
+
return true
|
|
594
|
+
case "Union":
|
|
595
|
+
// A union in the mode "oneOf" stops a value that fits two members.
|
|
596
|
+
// The search does not see that, so such a field is not plain.
|
|
597
|
+
return (
|
|
598
|
+
ast.options?.mode !== "oneOf" && Arr.every(ast.types, isPlainScalar)
|
|
599
|
+
)
|
|
600
|
+
default:
|
|
601
|
+
return false
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Tests if a path names a plain scalar field of a Schema: a string, a number,
|
|
607
|
+
* a boolean, a literal, `null`, or an optional field or a union of these. Its
|
|
608
|
+
* only checks may be integer and finite checks: another check, such as
|
|
609
|
+
* `isGreaterThan(0)`, stops values that the search does not know about. On
|
|
610
|
+
* the way, each part must be a required field of a plain struct with no
|
|
611
|
+
* checks, with a name that is not a name of `Object.prototype`.
|
|
612
|
+
*
|
|
613
|
+
* `Policy.coverage` and `Condition.satisfiable` answer `All`, `None` or
|
|
614
|
+
* `Some` only when each field of the condition is such a field. Their model of
|
|
615
|
+
* the values of a field is exact for these Schemas. For other Schemas, such as
|
|
616
|
+
* classes, records, unions of structs or declarations, a value may have data
|
|
617
|
+
* that the Schema does not show, and the answer is `Unknown`.
|
|
618
|
+
*
|
|
619
|
+
* @stability stable
|
|
620
|
+
* @category predicates
|
|
621
|
+
* @since 1.0.0
|
|
622
|
+
*/
|
|
623
|
+
export const isPlainField = (schema: Schema.Top, path: string): boolean =>
|
|
624
|
+
!isTooLong(path) && plainAt(schema.ast, path.split("."))
|
|
625
|
+
|
|
626
|
+
const plainAt = (ast: SchemaAST.AST, names: ReadonlyArray<string>): boolean => {
|
|
627
|
+
const [name, ...rest] = names
|
|
628
|
+
if (
|
|
629
|
+
name === undefined ||
|
|
630
|
+
ast._tag !== "Objects" ||
|
|
631
|
+
ast.indexSignatures.length > 0 ||
|
|
632
|
+
(ast.checks ?? []).length > 0 ||
|
|
633
|
+
name in Object.prototype
|
|
634
|
+
) {
|
|
635
|
+
return false
|
|
636
|
+
}
|
|
637
|
+
const property = ast.propertySignatures.find(
|
|
638
|
+
(signature) => signature.name === name,
|
|
639
|
+
)
|
|
640
|
+
if (property === undefined) {
|
|
641
|
+
return false
|
|
642
|
+
}
|
|
643
|
+
return rest.length === 0
|
|
644
|
+
? isPlainScalar(property.type)
|
|
645
|
+
: !SchemaAST.isOptional(property.type) && plainAt(property.type, rest)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Tests if a path names a field of a Schema that holds a value with no named
|
|
650
|
+
* fields: a scalar, or a value such as a record or an array. It goes into
|
|
651
|
+
* records by their keys, and into recursive Schemas as far as the path goes.
|
|
652
|
+
* So it finds paths that {@link fieldPaths} does not list.
|
|
653
|
+
*
|
|
654
|
+
* @stability stable
|
|
655
|
+
* @category predicates
|
|
656
|
+
* @since 1.0.0
|
|
657
|
+
*/
|
|
658
|
+
export const hasPath = (schema: Schema.Top, path: string): boolean =>
|
|
659
|
+
!isTooLong(path) && resolves(schema.ast, path.split("."), 0, new Map())
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* The answers of a walk along a path, by Schema node and by place in the path.
|
|
663
|
+
* Two branches of a union may come to one node at one place, as in a recursive
|
|
664
|
+
* union, and the memo does that work one time. It is a native `Map`, because
|
|
665
|
+
* it must find a Schema node by the node itself.
|
|
666
|
+
*/
|
|
667
|
+
type PathMemo<A> = Map<SchemaAST.AST, Map<number, A>>
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Gives the answer for a node at a place from the memo, or works it out and
|
|
671
|
+
* keeps it. While the work runs, the memo holds `pending`, so a walk that
|
|
672
|
+
* comes back to the node at the same place, with no step on the path, gets
|
|
673
|
+
* `pending` and stops.
|
|
674
|
+
*/
|
|
675
|
+
const rememberAt = <A>(
|
|
676
|
+
memo: PathMemo<A>,
|
|
677
|
+
ast: SchemaAST.AST,
|
|
678
|
+
index: number,
|
|
679
|
+
pending: A,
|
|
680
|
+
work: () => A,
|
|
681
|
+
): A => {
|
|
682
|
+
const places = memo.get(ast) ?? new Map<number, A>()
|
|
683
|
+
memo.set(ast, places)
|
|
684
|
+
if (places.has(index)) {
|
|
685
|
+
return places.get(index) as A
|
|
686
|
+
}
|
|
687
|
+
places.set(index, pending)
|
|
688
|
+
const result = work()
|
|
689
|
+
places.set(index, result)
|
|
690
|
+
return result
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const resolves = (
|
|
694
|
+
ast: SchemaAST.AST,
|
|
695
|
+
names: ReadonlyArray<string>,
|
|
696
|
+
index: number,
|
|
697
|
+
memo: PathMemo<boolean>,
|
|
698
|
+
): boolean =>
|
|
699
|
+
rememberAt(memo, ast, index, false, () => {
|
|
700
|
+
if (index === names.length) {
|
|
701
|
+
return index > 0 && holdsValue(ast, new Set())
|
|
702
|
+
}
|
|
703
|
+
const name = names[index]!
|
|
704
|
+
switch (ast._tag) {
|
|
705
|
+
case "Objects": {
|
|
706
|
+
const property = ast.propertySignatures.find(
|
|
707
|
+
(signature) => signature.name === name,
|
|
708
|
+
)
|
|
709
|
+
if (property !== undefined) {
|
|
710
|
+
return resolves(property.type, names, index + 1, memo)
|
|
711
|
+
}
|
|
712
|
+
// A key of a record must fit the Schema of the keys.
|
|
713
|
+
return ast.indexSignatures.some(
|
|
714
|
+
(signature) =>
|
|
715
|
+
keyFits(signature.parameter, name) &&
|
|
716
|
+
resolves(signature.type, names, index + 1, memo),
|
|
717
|
+
)
|
|
718
|
+
}
|
|
719
|
+
case "Union":
|
|
720
|
+
return ast.types.some((type) => resolves(type, names, index, memo))
|
|
721
|
+
case "Suspend":
|
|
722
|
+
return resolves(ast.thunk(), names, index, memo)
|
|
723
|
+
case "Declaration": {
|
|
724
|
+
const inner = classStruct(ast)
|
|
725
|
+
return inner !== undefined && resolves(inner, names, index, memo)
|
|
726
|
+
}
|
|
727
|
+
default:
|
|
728
|
+
return false
|
|
729
|
+
}
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Tests if a Schema has a field whose path fits a pattern. `parts` are the
|
|
734
|
+
* parts of the pattern, split at the dots. A part `**` stands for any number
|
|
735
|
+
* of parts, also none. Another part fits a name when `fits` says so, as a
|
|
736
|
+
* part with `*` fits many names. For a record, the walk asks if the key
|
|
737
|
+
* Schema takes a key that fits the part. For a template literal key Schema,
|
|
738
|
+
* with parts that are literals and strings, the answer is exact. For another key Schema, the walk tries sample keys, so it may miss a
|
|
739
|
+
* key, and then the answer is `false`.
|
|
740
|
+
*
|
|
741
|
+
* @stability stable
|
|
742
|
+
* @category predicates
|
|
743
|
+
* @since 1.0.0
|
|
744
|
+
*/
|
|
745
|
+
export const hasMatchingPath = (
|
|
746
|
+
schema: Schema.Top,
|
|
747
|
+
parts: ReadonlyArray<string>,
|
|
748
|
+
fits: (part: string, name: string) => boolean,
|
|
749
|
+
): boolean =>
|
|
750
|
+
parts.length <= maxPathParts &&
|
|
751
|
+
matchesFrom(schema.ast, parts, 0, false, fits, new Map())
|
|
752
|
+
|
|
753
|
+
const matchesFrom = (
|
|
754
|
+
ast: SchemaAST.AST,
|
|
755
|
+
parts: ReadonlyArray<string>,
|
|
756
|
+
index: number,
|
|
757
|
+
stepped: boolean,
|
|
758
|
+
fits: (part: string, name: string) => boolean,
|
|
759
|
+
memo: PathMemo<boolean>,
|
|
760
|
+
): boolean =>
|
|
761
|
+
// The memo keeps two answers for each place: before the first step, and
|
|
762
|
+
// after it.
|
|
763
|
+
rememberAt(memo, ast, index * 2 + (stepped ? 1 : 0), false, () => {
|
|
764
|
+
const next = (type: SchemaAST.AST, at: number, step: boolean): boolean =>
|
|
765
|
+
matchesFrom(type, parts, at, step, fits, memo)
|
|
766
|
+
switch (ast._tag) {
|
|
767
|
+
case "Union":
|
|
768
|
+
return Arr.some(ast.types, (type) => next(type, index, stepped))
|
|
769
|
+
case "Suspend":
|
|
770
|
+
return next(ast.thunk(), index, stepped)
|
|
771
|
+
case "Declaration": {
|
|
772
|
+
const inner = classStruct(ast)
|
|
773
|
+
if (inner !== undefined) {
|
|
774
|
+
return next(inner, index, stepped)
|
|
775
|
+
}
|
|
776
|
+
break
|
|
777
|
+
}
|
|
778
|
+
default:
|
|
779
|
+
break
|
|
780
|
+
}
|
|
781
|
+
// The path ends at a field that holds a value, as a field that a rule
|
|
782
|
+
// may name does.
|
|
783
|
+
if (index === parts.length) {
|
|
784
|
+
return stepped && holdsValue(ast, new Set())
|
|
785
|
+
}
|
|
786
|
+
const part = parts[index]!
|
|
787
|
+
if (part === "**" && next(ast, index + 1, stepped)) {
|
|
788
|
+
return true
|
|
789
|
+
}
|
|
790
|
+
if (ast._tag !== "Objects") {
|
|
791
|
+
return false
|
|
792
|
+
}
|
|
793
|
+
// Keys made from the part, and keys made from the key Schema. A key
|
|
794
|
+
// Schema such as `TemplateLiteral(["x", String])` takes neither "" nor
|
|
795
|
+
// "a", but it takes "x" and "xa".
|
|
796
|
+
const fromPart = [part.replaceAll("*", ""), part.replaceAll("*", "a")]
|
|
797
|
+
return (
|
|
798
|
+
Arr.some(
|
|
799
|
+
ast.propertySignatures,
|
|
800
|
+
(property) =>
|
|
801
|
+
typeof property.name === "string" &&
|
|
802
|
+
(part === "**"
|
|
803
|
+
? next(property.type, index, true)
|
|
804
|
+
: fits(part, property.name) &&
|
|
805
|
+
next(property.type, index + 1, true)),
|
|
806
|
+
) ||
|
|
807
|
+
Arr.some(ast.indexSignatures, (signature) =>
|
|
808
|
+
part === "**"
|
|
809
|
+
? // A step into a record needs a key that its key Schema takes.
|
|
810
|
+
hasSomeKey(signature.parameter) && next(signature.type, index, true)
|
|
811
|
+
: // A template literal key Schema gives an exact answer. Another
|
|
812
|
+
// key Schema is tried with sample keys.
|
|
813
|
+
(globMeetsKeys(part, signature.parameter) ??
|
|
814
|
+
Arr.some(
|
|
815
|
+
Arr.dedupe([...fromPart, ...sampleKeys(signature.parameter)]),
|
|
816
|
+
(key) => keyFits(signature.parameter, key) && fits(part, key),
|
|
817
|
+
)) &&
|
|
818
|
+
next(signature.type, index + 1, true),
|
|
819
|
+
)
|
|
820
|
+
)
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Tests if a key Schema takes some key: the exact search for a template
|
|
825
|
+
* literal, or a sample key.
|
|
826
|
+
*/
|
|
827
|
+
const hasSomeKey = (parameter: SchemaAST.AST): boolean =>
|
|
828
|
+
globMeetsKeys("*", parameter) ??
|
|
829
|
+
Arr.some(Arr.dedupe(["", "a", ...sampleKeys(parameter)]), (key) =>
|
|
830
|
+
keyFits(parameter, key),
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
/** The longest key pattern that the exact search of keys takes. */
|
|
834
|
+
const maxExactKeyLength = 256
|
|
835
|
+
|
|
836
|
+
/** A part of a key pattern: one character, or `*` for any characters. */
|
|
837
|
+
type GlobToken =
|
|
838
|
+
{ readonly _tag: "Char"; readonly char: string } | { readonly _tag: "Star" }
|
|
839
|
+
|
|
840
|
+
/** A part of a template literal: one character, or any characters. */
|
|
841
|
+
type TemplateToken =
|
|
842
|
+
{ readonly _tag: "Char"; readonly char: string } | { readonly _tag: "Any" }
|
|
843
|
+
|
|
844
|
+
const charsOf = (text: string): ReadonlyArray<TemplateToken> =>
|
|
845
|
+
Arr.map(Array.from(text), (char) => ({ _tag: "Char", char }) as const)
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Gives the token lists of a template literal, one for each choice of its
|
|
849
|
+
* unions of literals, or `undefined` when the lists can not be exact: a part
|
|
850
|
+
* that this walk does not know, such as a number, a part with checks, or
|
|
851
|
+
* more than 64 lists.
|
|
852
|
+
*/
|
|
853
|
+
const templateTokens = (
|
|
854
|
+
ast: SchemaAST.TemplateLiteral,
|
|
855
|
+
): ReadonlyArray<ReadonlyArray<TemplateToken>> | undefined => {
|
|
856
|
+
const partTokens = (
|
|
857
|
+
part: SchemaAST.AST,
|
|
858
|
+
): ReadonlyArray<ReadonlyArray<TemplateToken>> | undefined => {
|
|
859
|
+
if ((part.checks ?? []).length > 0) {
|
|
860
|
+
return undefined
|
|
861
|
+
}
|
|
862
|
+
switch (part._tag) {
|
|
863
|
+
case "Literal": {
|
|
864
|
+
const text = String(part.literal)
|
|
865
|
+
// A half of a pair of UTF-16 code units may join a half in the
|
|
866
|
+
// next part, and the tokens of each part can not show that.
|
|
867
|
+
return /[\uD800-\uDFFF]/.test(text) ? undefined : [charsOf(text)]
|
|
868
|
+
}
|
|
869
|
+
case "String":
|
|
870
|
+
return [[{ _tag: "Any" }]]
|
|
871
|
+
case "Number":
|
|
872
|
+
// The tokens can not hold all numbers, such as `-1.5`, so the
|
|
873
|
+
// search is not exact.
|
|
874
|
+
return undefined
|
|
875
|
+
case "Union": {
|
|
876
|
+
// A union in the mode "oneOf" stops a key that fits two members,
|
|
877
|
+
// and token lists can not show that.
|
|
878
|
+
if (part.options?.mode === "oneOf") {
|
|
879
|
+
return undefined
|
|
880
|
+
}
|
|
881
|
+
const choices = Arr.map(part.types, partTokens)
|
|
882
|
+
return Arr.every(choices, (choice) => choice !== undefined)
|
|
883
|
+
? Arr.flatten(
|
|
884
|
+
choices as ReadonlyArray<
|
|
885
|
+
ReadonlyArray<ReadonlyArray<TemplateToken>>
|
|
886
|
+
>,
|
|
887
|
+
)
|
|
888
|
+
: undefined
|
|
889
|
+
}
|
|
890
|
+
default:
|
|
891
|
+
return undefined
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return Arr.reduce(
|
|
895
|
+
ast.parts,
|
|
896
|
+
[[]] as ReadonlyArray<ReadonlyArray<TemplateToken>> | undefined,
|
|
897
|
+
(lists, part) => {
|
|
898
|
+
const choices = partTokens(part)
|
|
899
|
+
if (lists === undefined || choices === undefined) {
|
|
900
|
+
return undefined
|
|
901
|
+
}
|
|
902
|
+
// Each list so far, with each choice of this part. Past 64 lists the
|
|
903
|
+
// search would not be exact, so it gives no answer.
|
|
904
|
+
const all = Arr.flatMap(lists, (list) =>
|
|
905
|
+
Arr.map(choices, (choice) => [...list, ...choice]),
|
|
906
|
+
)
|
|
907
|
+
// The search calls itself for each character, so a long list of
|
|
908
|
+
// tokens gives no answer too.
|
|
909
|
+
return all.length > 64 ||
|
|
910
|
+
Arr.some(all, (list) => list.length > maxExactKeyLength)
|
|
911
|
+
? undefined
|
|
912
|
+
: all
|
|
913
|
+
},
|
|
914
|
+
)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Tests if a key pattern and a list of template tokens share a key. It walks
|
|
919
|
+
* the pairs of places in the two lists, with a few characters that stand for
|
|
920
|
+
* all others, so the answer is exact for these tokens.
|
|
921
|
+
*/
|
|
922
|
+
const tokensMeet = (
|
|
923
|
+
glob: ReadonlyArray<GlobToken>,
|
|
924
|
+
template: ReadonlyArray<TemplateToken>,
|
|
925
|
+
): boolean => {
|
|
926
|
+
const key = (t: number, g: number) => t * (glob.length + 1) + g
|
|
927
|
+
// The places that a pair can reach with no character.
|
|
928
|
+
const closeOf = (
|
|
929
|
+
t: number,
|
|
930
|
+
g: number,
|
|
931
|
+
): ReadonlyArray<readonly [number, number]> => [
|
|
932
|
+
[t, g],
|
|
933
|
+
...(template[t]?._tag === "Any" ? closeOf(t + 1, g) : []),
|
|
934
|
+
...(glob[g]?._tag === "Star" ? closeOf(t, g + 1) : []),
|
|
935
|
+
]
|
|
936
|
+
const alphabet = Arr.dedupe([
|
|
937
|
+
"a",
|
|
938
|
+
"0",
|
|
939
|
+
...Arr.flatMap(glob, (token) =>
|
|
940
|
+
token._tag === "Char" ? [token.char] : [],
|
|
941
|
+
),
|
|
942
|
+
...Arr.flatMap(template, (token) =>
|
|
943
|
+
token._tag === "Char" ? [token.char] : [],
|
|
944
|
+
),
|
|
945
|
+
])
|
|
946
|
+
const stepTemplate = (t: number, char: string): ReadonlyArray<number> => {
|
|
947
|
+
const token = template[t]
|
|
948
|
+
return token === undefined
|
|
949
|
+
? []
|
|
950
|
+
: token._tag === "Any"
|
|
951
|
+
? [t]
|
|
952
|
+
: token.char === char
|
|
953
|
+
? [t + 1]
|
|
954
|
+
: []
|
|
955
|
+
}
|
|
956
|
+
const stepGlob = (g: number, char: string): ReadonlyArray<number> => {
|
|
957
|
+
const token = glob[g]
|
|
958
|
+
return token === undefined
|
|
959
|
+
? []
|
|
960
|
+
: token._tag === "Char"
|
|
961
|
+
? token.char === char
|
|
962
|
+
? [g + 1]
|
|
963
|
+
: []
|
|
964
|
+
: char === "."
|
|
965
|
+
? []
|
|
966
|
+
: [g]
|
|
967
|
+
}
|
|
968
|
+
// A search over the pairs: each pair is seen one time.
|
|
969
|
+
const search = (
|
|
970
|
+
frontier: ReadonlyArray<readonly [number, number]>,
|
|
971
|
+
seen: HashSet.HashSet<number>,
|
|
972
|
+
): boolean => {
|
|
973
|
+
// Each pair comes one time, also when many paths reach it, so the work
|
|
974
|
+
// is at most the number of pairs.
|
|
975
|
+
const fresh = Array.from(
|
|
976
|
+
HashMap.values(
|
|
977
|
+
HashMap.fromIterable(
|
|
978
|
+
Arr.map(
|
|
979
|
+
Arr.filter(
|
|
980
|
+
Arr.flatMap(frontier, ([t, g]) => closeOf(t, g)),
|
|
981
|
+
([t, g]) => !HashSet.has(seen, key(t, g)),
|
|
982
|
+
),
|
|
983
|
+
(pair) => [key(pair[0], pair[1]), pair] as const,
|
|
984
|
+
),
|
|
985
|
+
),
|
|
986
|
+
),
|
|
987
|
+
)
|
|
988
|
+
if (fresh.length === 0) {
|
|
989
|
+
return false
|
|
990
|
+
}
|
|
991
|
+
if (
|
|
992
|
+
Arr.some(fresh, ([t, g]) => t === template.length && g === glob.length)
|
|
993
|
+
) {
|
|
994
|
+
return true
|
|
995
|
+
}
|
|
996
|
+
const next = HashSet.union(
|
|
997
|
+
seen,
|
|
998
|
+
HashSet.fromIterable(Arr.map(fresh, ([t, g]) => key(t, g))),
|
|
999
|
+
)
|
|
1000
|
+
return search(
|
|
1001
|
+
Arr.flatMap(fresh, ([t, g]) =>
|
|
1002
|
+
Arr.flatMap(alphabet, (char) =>
|
|
1003
|
+
Arr.flatMap(stepTemplate(t, char), (t2) =>
|
|
1004
|
+
Arr.map(stepGlob(g, char), (g2) => [t2, g2] as const),
|
|
1005
|
+
),
|
|
1006
|
+
),
|
|
1007
|
+
),
|
|
1008
|
+
next,
|
|
1009
|
+
)
|
|
1010
|
+
}
|
|
1011
|
+
return search([[0, 0]], HashSet.empty())
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Tests if a key pattern fits a key of a template literal key Schema, or
|
|
1016
|
+
* `undefined` when the search is not exact for the Schema. The search calls
|
|
1017
|
+
* itself one time for each character of a key, so a very long part gives
|
|
1018
|
+
* `undefined` too.
|
|
1019
|
+
*/
|
|
1020
|
+
const globMeetsKeys = (
|
|
1021
|
+
part: string,
|
|
1022
|
+
ast: SchemaAST.AST,
|
|
1023
|
+
): boolean | undefined => {
|
|
1024
|
+
if (
|
|
1025
|
+
ast._tag !== "TemplateLiteral" ||
|
|
1026
|
+
(ast.checks ?? []).length > 0 ||
|
|
1027
|
+
part.length > maxExactKeyLength
|
|
1028
|
+
) {
|
|
1029
|
+
return undefined
|
|
1030
|
+
}
|
|
1031
|
+
const lists = templateTokens(ast)
|
|
1032
|
+
if (lists === undefined) {
|
|
1033
|
+
return undefined
|
|
1034
|
+
}
|
|
1035
|
+
const glob = Arr.map(
|
|
1036
|
+
(part.match(/\*+|[^*]/gu) ?? []) as ReadonlyArray<string>,
|
|
1037
|
+
(token): GlobToken =>
|
|
1038
|
+
token.startsWith("*") ? { _tag: "Star" } : { _tag: "Char", char: token },
|
|
1039
|
+
)
|
|
1040
|
+
return Arr.some(lists, (list) => tokensMeet(glob, list))
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* A few keys that a key Schema of a record takes: each part of a template
|
|
1045
|
+
* literal gives its literal, or a short text for a string or a number. These
|
|
1046
|
+
* are samples. A key that is not a sample may fit too, so a "no" from a test
|
|
1047
|
+
* with them is only "not found".
|
|
1048
|
+
*/
|
|
1049
|
+
const sampleKeys = (ast: SchemaAST.AST): ReadonlyArray<string> => {
|
|
1050
|
+
switch (ast._tag) {
|
|
1051
|
+
case "String":
|
|
1052
|
+
return ["", "a"]
|
|
1053
|
+
case "Number":
|
|
1054
|
+
return ["0", "1"]
|
|
1055
|
+
case "Literal":
|
|
1056
|
+
return [String(ast.literal)]
|
|
1057
|
+
case "Union":
|
|
1058
|
+
return Arr.flatMap(ast.types, sampleKeys)
|
|
1059
|
+
case "TemplateLiteral":
|
|
1060
|
+
// Each key of the parts before, with each key of this part. Two
|
|
1061
|
+
// samples for each part keep the list short.
|
|
1062
|
+
return Arr.reduce(ast.parts, [""], (keys: ReadonlyArray<string>, part) =>
|
|
1063
|
+
Arr.take(
|
|
1064
|
+
Arr.flatMap(keys, (key) =>
|
|
1065
|
+
Arr.map(Arr.take(sampleKeys(part), 2), (end) => key + end),
|
|
1066
|
+
),
|
|
1067
|
+
64,
|
|
1068
|
+
),
|
|
1069
|
+
)
|
|
1070
|
+
default:
|
|
1071
|
+
return []
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Tests if a Schema surely has a value: each required field, at each depth,
|
|
1077
|
+
* has a type that has values, with no checks other than integer and finite
|
|
1078
|
+
* checks on numbers. A check such as a minimum length may stop every value,
|
|
1079
|
+
* and the library can not see that, so such a Schema gives `false`. So does a
|
|
1080
|
+
* required field that holds the struct itself again.
|
|
1081
|
+
*
|
|
1082
|
+
* @stability stable
|
|
1083
|
+
* @category predicates
|
|
1084
|
+
* @since 1.0.0
|
|
1085
|
+
*/
|
|
1086
|
+
export const hasValue = (schema: Schema.Top): boolean =>
|
|
1087
|
+
isSurelyInhabited(schema.ast, new Set())
|
|
1088
|
+
|
|
1089
|
+
const isSurelyInhabited = (
|
|
1090
|
+
ast: SchemaAST.AST,
|
|
1091
|
+
open: ReadonlySet<SchemaAST.AST>,
|
|
1092
|
+
): boolean => {
|
|
1093
|
+
if (
|
|
1094
|
+
ast._tag === "Number"
|
|
1095
|
+
? !hasOnlyKnownChecks(ast.checks)
|
|
1096
|
+
: (ast.checks ?? []).length > 0
|
|
1097
|
+
) {
|
|
1098
|
+
return false
|
|
1099
|
+
}
|
|
1100
|
+
switch (ast._tag) {
|
|
1101
|
+
case "Never":
|
|
1102
|
+
return false
|
|
1103
|
+
case "Objects":
|
|
1104
|
+
// A named field must fit the record part too, as in
|
|
1105
|
+
// `Schema.StructWithRest`, and the two types may share no value. That
|
|
1106
|
+
// is not easy to know, so such a struct is not sure.
|
|
1107
|
+
return (
|
|
1108
|
+
(ast.propertySignatures.length === 0 ||
|
|
1109
|
+
ast.indexSignatures.length === 0) &&
|
|
1110
|
+
Arr.every(
|
|
1111
|
+
ast.propertySignatures,
|
|
1112
|
+
(property) =>
|
|
1113
|
+
SchemaAST.isOptional(property.type) ||
|
|
1114
|
+
isSurelyInhabited(property.type, open),
|
|
1115
|
+
)
|
|
1116
|
+
)
|
|
1117
|
+
case "Union":
|
|
1118
|
+
// A union in the mode "oneOf" may stop every value of its members.
|
|
1119
|
+
return (
|
|
1120
|
+
ast.options?.mode !== "oneOf" &&
|
|
1121
|
+
Arr.some(ast.types, (type) => isSurelyInhabited(type, open))
|
|
1122
|
+
)
|
|
1123
|
+
case "Suspend":
|
|
1124
|
+
return (
|
|
1125
|
+
!open.has(ast) &&
|
|
1126
|
+
isSurelyInhabited(ast.thunk(), new Set([...open, ast]))
|
|
1127
|
+
)
|
|
1128
|
+
case "Arrays":
|
|
1129
|
+
// The elements that a tuple needs, and the elements after its rest.
|
|
1130
|
+
// The rest itself may be empty.
|
|
1131
|
+
return Arr.every(
|
|
1132
|
+
[
|
|
1133
|
+
...Arr.filter(
|
|
1134
|
+
ast.elements,
|
|
1135
|
+
(element) => !SchemaAST.isOptional(element),
|
|
1136
|
+
),
|
|
1137
|
+
...ast.rest.slice(1),
|
|
1138
|
+
],
|
|
1139
|
+
(element) => isSurelyInhabited(element, open),
|
|
1140
|
+
)
|
|
1141
|
+
case "Declaration": {
|
|
1142
|
+
const inner = classStruct(ast)
|
|
1143
|
+
if (inner !== undefined) {
|
|
1144
|
+
return isSurelyInhabited(inner, open)
|
|
1145
|
+
}
|
|
1146
|
+
const id = declarationId(ast)
|
|
1147
|
+
// A value of these holds a value of one of its type parameters.
|
|
1148
|
+
if (HashSet.has(wrapperDeclarations, id)) {
|
|
1149
|
+
return Arr.some(ast.typeParameters, (type) =>
|
|
1150
|
+
isSurelyInhabited(type, open),
|
|
1151
|
+
)
|
|
1152
|
+
}
|
|
1153
|
+
// These have a value for any type parameters, such as an empty map
|
|
1154
|
+
// or `Option.none()`. Another declaration may take no value.
|
|
1155
|
+
return HashSet.has(inhabitedDeclarations, id)
|
|
1156
|
+
}
|
|
1157
|
+
case "Enum":
|
|
1158
|
+
return ast.enums.length > 0
|
|
1159
|
+
case "TemplateLiteral":
|
|
1160
|
+
return Arr.every(ast.parts, (part) => isSurelyInhabited(part, open))
|
|
1161
|
+
case "String":
|
|
1162
|
+
case "Number":
|
|
1163
|
+
case "Boolean":
|
|
1164
|
+
case "BigInt":
|
|
1165
|
+
case "Symbol":
|
|
1166
|
+
case "UniqueSymbol":
|
|
1167
|
+
case "Literal":
|
|
1168
|
+
case "Null":
|
|
1169
|
+
case "Undefined":
|
|
1170
|
+
case "Void":
|
|
1171
|
+
case "Unknown":
|
|
1172
|
+
case "Any":
|
|
1173
|
+
case "ObjectKeyword":
|
|
1174
|
+
return true
|
|
1175
|
+
default:
|
|
1176
|
+
// A node that this walk does not know may take no value.
|
|
1177
|
+
return false
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** Declarations of Effect that hold a value of a type parameter. */
|
|
1182
|
+
const wrapperDeclarations = HashSet.make(
|
|
1183
|
+
"effect/schema/Redacted",
|
|
1184
|
+
"effect/schema/Result",
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
/** Declarations of Effect that have a value for any type parameters. */
|
|
1188
|
+
const inhabitedDeclarations = HashSet.make(
|
|
1189
|
+
"effect/schema/Date",
|
|
1190
|
+
"effect/schema/URL",
|
|
1191
|
+
"effect/schema/Uint8Array",
|
|
1192
|
+
"effect/schema/Duration",
|
|
1193
|
+
"effect/schema/DateTimeUtc",
|
|
1194
|
+
"effect/schema/BigDecimal",
|
|
1195
|
+
"effect/schema/Cause",
|
|
1196
|
+
"effect/schema/Chunk",
|
|
1197
|
+
"effect/schema/HashMap",
|
|
1198
|
+
"effect/schema/HashSet",
|
|
1199
|
+
"effect/schema/ReadonlyMap",
|
|
1200
|
+
"effect/schema/ReadonlySet",
|
|
1201
|
+
"effect/schema/Option",
|
|
1202
|
+
"effect/schema/File",
|
|
1203
|
+
"effect/schema/FormData",
|
|
1204
|
+
"effect/schema/RegExp",
|
|
1205
|
+
"effect/schema/Json",
|
|
1206
|
+
"effect/schema/ByteSize",
|
|
1207
|
+
"effect/schema/DateTimeZoned",
|
|
1208
|
+
"effect/schema/Error",
|
|
1209
|
+
"effect/schema/Exit",
|
|
1210
|
+
"effect/schema/CauseReason",
|
|
1211
|
+
"effect/schema/Graph",
|
|
1212
|
+
"effect/schema/SemVer",
|
|
1213
|
+
"effect/schema/SemVerRange",
|
|
1214
|
+
"effect/schema/TimeZone",
|
|
1215
|
+
"effect/schema/TimeZoneNamed",
|
|
1216
|
+
"effect/schema/TimeZoneOffset",
|
|
1217
|
+
"effect/schema/URLSearchParams",
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Tests if a Schema has no value at all: a required field of it, at some
|
|
1222
|
+
* depth, has no value, as `Schema.Never` has none. A required field that
|
|
1223
|
+
* holds the struct itself again, with no way to stop, has no finite value
|
|
1224
|
+
* either. Other checks, such as a minimum and a maximum that do not meet, are
|
|
1225
|
+
* not seen, so a "no" does not prove that the Schema has a value.
|
|
1226
|
+
*
|
|
1227
|
+
* @stability stable
|
|
1228
|
+
* @category predicates
|
|
1229
|
+
* @since 1.0.0
|
|
1230
|
+
*/
|
|
1231
|
+
export const hasNoValue = (schema: Schema.Top): boolean =>
|
|
1232
|
+
isEmptyType(schema.ast, new Set())
|
|
1233
|
+
|
|
1234
|
+
const isEmptyType = (
|
|
1235
|
+
ast: SchemaAST.AST,
|
|
1236
|
+
open: ReadonlySet<SchemaAST.AST>,
|
|
1237
|
+
): boolean => {
|
|
1238
|
+
switch (ast._tag) {
|
|
1239
|
+
case "Never":
|
|
1240
|
+
return true
|
|
1241
|
+
case "Objects":
|
|
1242
|
+
return Arr.some(
|
|
1243
|
+
ast.propertySignatures,
|
|
1244
|
+
(property) =>
|
|
1245
|
+
!SchemaAST.isOptional(property.type) &&
|
|
1246
|
+
isEmptyType(property.type, open),
|
|
1247
|
+
)
|
|
1248
|
+
case "Union":
|
|
1249
|
+
return Arr.every(ast.types, (type) => isEmptyType(type, open))
|
|
1250
|
+
case "Suspend":
|
|
1251
|
+
// A walk that comes back to a suspend node on required fields only
|
|
1252
|
+
// never stops, so such a value can not be made.
|
|
1253
|
+
return open.has(ast) || isEmptyType(ast.thunk(), new Set([...open, ast]))
|
|
1254
|
+
case "Declaration": {
|
|
1255
|
+
const inner = classStruct(ast)
|
|
1256
|
+
return inner !== undefined && isEmptyType(inner, open)
|
|
1257
|
+
}
|
|
1258
|
+
default:
|
|
1259
|
+
return false
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* The values that one field of a resource may hold, as a condition sees them.
|
|
1265
|
+
* A missing field and `undefined` are `null`. The domain may be larger than
|
|
1266
|
+
* the real set of values: Schema checks other than finite and integer checks
|
|
1267
|
+
* are left out. A larger domain only makes a check say "no" more often.
|
|
1268
|
+
*
|
|
1269
|
+
* @stability stable
|
|
1270
|
+
* @category models
|
|
1271
|
+
* @since 1.0.0
|
|
1272
|
+
*/
|
|
1273
|
+
export interface FieldDomain {
|
|
1274
|
+
/** The field may be `null` or missing. */
|
|
1275
|
+
readonly null: boolean
|
|
1276
|
+
/** The field may hold a value with parts, or a value that is not a scalar. */
|
|
1277
|
+
readonly object: boolean
|
|
1278
|
+
/** The field may hold `true` and `false`. */
|
|
1279
|
+
readonly boolean: boolean
|
|
1280
|
+
/** The field may hold any string. */
|
|
1281
|
+
readonly string: boolean
|
|
1282
|
+
/** The numbers that the field may hold. */
|
|
1283
|
+
readonly number: "none" | "integer" | "finite" | "any"
|
|
1284
|
+
/** Single values that the field may hold, other than the ones above. */
|
|
1285
|
+
readonly literals: ReadonlyArray<string | number | boolean>
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const numberOrder = ["none", "integer", "finite", "any"] as const
|
|
1289
|
+
|
|
1290
|
+
const emptyDomain: FieldDomain = {
|
|
1291
|
+
null: false,
|
|
1292
|
+
object: false,
|
|
1293
|
+
boolean: false,
|
|
1294
|
+
string: false,
|
|
1295
|
+
number: "none",
|
|
1296
|
+
literals: [],
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const nullDomain: FieldDomain = { ...emptyDomain, null: true }
|
|
1300
|
+
|
|
1301
|
+
const objectDomain: FieldDomain = { ...emptyDomain, object: true }
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* The domain of a field with no Schema: any value.
|
|
1305
|
+
*
|
|
1306
|
+
* @stability stable
|
|
1307
|
+
* @category constants
|
|
1308
|
+
* @since 1.0.0
|
|
1309
|
+
*/
|
|
1310
|
+
export const anyDomain: FieldDomain = {
|
|
1311
|
+
null: true,
|
|
1312
|
+
object: true,
|
|
1313
|
+
boolean: true,
|
|
1314
|
+
string: true,
|
|
1315
|
+
number: "any",
|
|
1316
|
+
literals: [],
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const joinDomains = (domains: ReadonlyArray<FieldDomain>): FieldDomain =>
|
|
1320
|
+
domains.reduce(
|
|
1321
|
+
(left, right) => ({
|
|
1322
|
+
null: left.null || right.null,
|
|
1323
|
+
object: left.object || right.object,
|
|
1324
|
+
boolean: left.boolean || right.boolean,
|
|
1325
|
+
string: left.string || right.string,
|
|
1326
|
+
number:
|
|
1327
|
+
numberOrder.indexOf(left.number) >= numberOrder.indexOf(right.number)
|
|
1328
|
+
? left.number
|
|
1329
|
+
: right.number,
|
|
1330
|
+
literals: [...left.literals, ...right.literals],
|
|
1331
|
+
}),
|
|
1332
|
+
emptyDomain,
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
const hasCheck = (
|
|
1336
|
+
checks: ReadonlyArray<SchemaAST.Check<never>>,
|
|
1337
|
+
id: string,
|
|
1338
|
+
): boolean =>
|
|
1339
|
+
checks.some(
|
|
1340
|
+
(check) =>
|
|
1341
|
+
check.annotations?.representation?.id === id ||
|
|
1342
|
+
(check._tag === "FilterGroup" && hasCheck(check.checks, id)),
|
|
1343
|
+
)
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* The ids of the declarations of Effect that hold only values with parts.
|
|
1347
|
+
* Effect puts the id in the `representation` annotation. A declaration that
|
|
1348
|
+
* is not in this list may hold a scalar, as `Schema.Json` may.
|
|
1349
|
+
*/
|
|
1350
|
+
const objectDeclarations = HashSet.make(
|
|
1351
|
+
"effect/schema/Date",
|
|
1352
|
+
"effect/schema/URL",
|
|
1353
|
+
"effect/schema/Uint8Array",
|
|
1354
|
+
"effect/schema/Duration",
|
|
1355
|
+
"effect/schema/DateTimeUtc",
|
|
1356
|
+
"effect/schema/BigDecimal",
|
|
1357
|
+
"effect/schema/Redacted",
|
|
1358
|
+
"effect/schema/Cause",
|
|
1359
|
+
"effect/schema/Chunk",
|
|
1360
|
+
"effect/schema/HashMap",
|
|
1361
|
+
"effect/schema/HashSet",
|
|
1362
|
+
"effect/schema/ReadonlyMap",
|
|
1363
|
+
"effect/schema/ReadonlySet",
|
|
1364
|
+
"effect/schema/Option",
|
|
1365
|
+
"effect/schema/Result",
|
|
1366
|
+
"effect/schema/File",
|
|
1367
|
+
"effect/schema/FormData",
|
|
1368
|
+
"effect/schema/RegExp",
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
const declarationId = (ast: SchemaAST.Declaration): string | undefined => {
|
|
1372
|
+
const representation: unknown = ast.annotations?.["representation"]
|
|
1373
|
+
return hasProperty(representation, "id") &&
|
|
1374
|
+
typeof representation.id === "string"
|
|
1375
|
+
? representation.id
|
|
1376
|
+
: undefined
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** The domain of the values of a Schema node. */
|
|
1380
|
+
const domainOfType = (
|
|
1381
|
+
ast: SchemaAST.AST,
|
|
1382
|
+
open: ReadonlySet<SchemaAST.AST>,
|
|
1383
|
+
): FieldDomain => {
|
|
1384
|
+
switch (ast._tag) {
|
|
1385
|
+
case "Null":
|
|
1386
|
+
case "Undefined":
|
|
1387
|
+
return nullDomain
|
|
1388
|
+
case "Never":
|
|
1389
|
+
return emptyDomain
|
|
1390
|
+
// `Schema.Void` takes any value, as `Schema.Unknown` does.
|
|
1391
|
+
case "Void":
|
|
1392
|
+
case "Unknown":
|
|
1393
|
+
case "Any":
|
|
1394
|
+
return anyDomain
|
|
1395
|
+
case "String":
|
|
1396
|
+
case "TemplateLiteral":
|
|
1397
|
+
return { ...emptyDomain, string: true }
|
|
1398
|
+
case "Number": {
|
|
1399
|
+
const checks = ast.checks ?? []
|
|
1400
|
+
return {
|
|
1401
|
+
...emptyDomain,
|
|
1402
|
+
number: hasCheck(checks, "effect/schema/isInt")
|
|
1403
|
+
? "integer"
|
|
1404
|
+
: hasCheck(checks, "effect/schema/isFinite")
|
|
1405
|
+
? "finite"
|
|
1406
|
+
: "any",
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
case "Boolean":
|
|
1410
|
+
return { ...emptyDomain, boolean: true }
|
|
1411
|
+
case "Literal":
|
|
1412
|
+
return typeof ast.literal === "bigint"
|
|
1413
|
+
? objectDomain
|
|
1414
|
+
: { ...emptyDomain, literals: [ast.literal] }
|
|
1415
|
+
case "Enum":
|
|
1416
|
+
return { ...emptyDomain, literals: ast.enums.map(([, value]) => value) }
|
|
1417
|
+
case "Union":
|
|
1418
|
+
return joinDomains(ast.types.map((type) => domainOfType(type, open)))
|
|
1419
|
+
case "Suspend":
|
|
1420
|
+
return open.has(ast)
|
|
1421
|
+
? anyDomain
|
|
1422
|
+
: domainOfType(ast.thunk(), new Set([...open, ast]))
|
|
1423
|
+
case "Objects":
|
|
1424
|
+
// A struct with no fields holds any value: Effect's
|
|
1425
|
+
// `Schema.Struct({})` takes a string too.
|
|
1426
|
+
return ast.propertySignatures.length === 0 &&
|
|
1427
|
+
ast.indexSignatures.length === 0
|
|
1428
|
+
? anyDomain
|
|
1429
|
+
: objectDomain
|
|
1430
|
+
case "Declaration":
|
|
1431
|
+
// A class, and a declaration of Effect in `objectDeclarations`, holds
|
|
1432
|
+
// a value with parts. Another declaration may hold any value, as
|
|
1433
|
+
// `Schema.Json` or one from `Schema.declare` may, so its domain is all
|
|
1434
|
+
// values: a smaller domain could hide a value that a deny rule names.
|
|
1435
|
+
return classStruct(ast) !== undefined ||
|
|
1436
|
+
HashSet.has(objectDeclarations, declarationId(ast))
|
|
1437
|
+
? objectDomain
|
|
1438
|
+
: anyDomain
|
|
1439
|
+
default:
|
|
1440
|
+
return objectDomain
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const domainAt = (
|
|
1445
|
+
ast: SchemaAST.AST,
|
|
1446
|
+
names: ReadonlyArray<string>,
|
|
1447
|
+
index: number,
|
|
1448
|
+
memo: PathMemo<FieldDomain>,
|
|
1449
|
+
): FieldDomain =>
|
|
1450
|
+
// A walk that comes back to a node at the same place may hold any value.
|
|
1451
|
+
rememberAt(memo, ast, index, anyDomain, () => {
|
|
1452
|
+
if (index === names.length) {
|
|
1453
|
+
return domainOfType(ast, new Set())
|
|
1454
|
+
}
|
|
1455
|
+
const name = names[index]!
|
|
1456
|
+
switch (ast._tag) {
|
|
1457
|
+
case "Objects": {
|
|
1458
|
+
const property = ast.propertySignatures.find(
|
|
1459
|
+
(signature) => signature.name === name,
|
|
1460
|
+
)
|
|
1461
|
+
if (property !== undefined) {
|
|
1462
|
+
const inner = domainAt(property.type, names, index + 1, memo)
|
|
1463
|
+
// A field that may be missing may be `null`. So may a field with
|
|
1464
|
+
// a name of `Object.prototype`: a condition reads it only when a
|
|
1465
|
+
// value gives it another value.
|
|
1466
|
+
return SchemaAST.isOptional(property.type) || name in Object.prototype
|
|
1467
|
+
? joinDomains([nullDomain, inner])
|
|
1468
|
+
: inner
|
|
1469
|
+
}
|
|
1470
|
+
// A name that the struct does not have may still be a field of a
|
|
1471
|
+
// value: a condition reads a field that a value has from its
|
|
1472
|
+
// class, such as a getter, and the Schema does not stop such a
|
|
1473
|
+
// field. A key of a record is not safe either: the Schema of a
|
|
1474
|
+
// record checks only the keys that a loop over the keys sees, and
|
|
1475
|
+
// a condition reads the others too. So such a field may hold any
|
|
1476
|
+
// value.
|
|
1477
|
+
return anyDomain
|
|
1478
|
+
}
|
|
1479
|
+
case "Union":
|
|
1480
|
+
return joinDomains(
|
|
1481
|
+
ast.types.map((type) => domainAt(type, names, index, memo)),
|
|
1482
|
+
)
|
|
1483
|
+
case "Suspend":
|
|
1484
|
+
return domainAt(ast.thunk(), names, index, memo)
|
|
1485
|
+
case "Declaration": {
|
|
1486
|
+
const inner = classStruct(ast)
|
|
1487
|
+
return inner === undefined
|
|
1488
|
+
? anyDomain
|
|
1489
|
+
: domainAt(inner, names, index, memo)
|
|
1490
|
+
}
|
|
1491
|
+
case "Arrays":
|
|
1492
|
+
case "ObjectKeyword":
|
|
1493
|
+
case "Unknown":
|
|
1494
|
+
case "Any":
|
|
1495
|
+
// A path may go into these values, and the Schema does not say
|
|
1496
|
+
// what is there.
|
|
1497
|
+
return anyDomain
|
|
1498
|
+
default:
|
|
1499
|
+
// A path does not go into a scalar, so the field is `null`.
|
|
1500
|
+
return nullDomain
|
|
1501
|
+
}
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Gives the domain of a field of a Schema, by its path with dots. A path that
|
|
1506
|
+
* the Schema does not name gives `null`, as a condition reads such a field.
|
|
1507
|
+
*
|
|
1508
|
+
* @stability stable
|
|
1509
|
+
* @category getters
|
|
1510
|
+
* @since 1.0.0
|
|
1511
|
+
*/
|
|
1512
|
+
export const domainOf = (schema: Schema.Top, path: string): FieldDomain =>
|
|
1513
|
+
// No cache here: a search keeps the cells of each field for itself. A path
|
|
1514
|
+
// that is too long to walk may hold any value.
|
|
1515
|
+
isTooLong(path)
|
|
1516
|
+
? anyDomain
|
|
1517
|
+
: domainAt(schema.ast, path.split("."), 0, new Map())
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Checks the names of the top fields of a resource at once, when it is made.
|
|
1521
|
+
* The names of nested fields are checked when the descriptor is made.
|
|
1522
|
+
*/
|
|
1523
|
+
const checkFields = (
|
|
1524
|
+
kind: string,
|
|
1525
|
+
options: Options<ReadonlyArray<string>, Schema.Struct.Fields>,
|
|
1526
|
+
): void => {
|
|
1527
|
+
// The kind of a value comes from its `_tag`. A field with this name could
|
|
1528
|
+
// give a value another kind, so it is a defect.
|
|
1529
|
+
if (Object.hasOwn(options.fields, "_tag")) {
|
|
1530
|
+
throw new Error(`The resource "${kind}" may not have a field "_tag"`)
|
|
1531
|
+
}
|
|
1532
|
+
Arr.map(Object.keys(options.fields), (name) => {
|
|
1533
|
+
if (name.includes(".")) {
|
|
1534
|
+
throw new Error(`A field name may not have a dot: "${name}"`)
|
|
1535
|
+
}
|
|
1536
|
+
if (name === "") {
|
|
1537
|
+
throw new Error("A field name may not be empty")
|
|
1538
|
+
}
|
|
1539
|
+
})
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Gives a value that is made on first use, and then kept. The descriptor of a
|
|
1544
|
+
* resource reads its Schema, and a recursive Schema refers to the resource
|
|
1545
|
+
* class, so it can not be read while the class is made. The cell belongs to
|
|
1546
|
+
* one resource.
|
|
1547
|
+
*/
|
|
1548
|
+
const lazy = <A>(make: () => A): (() => A) => {
|
|
1549
|
+
const cell: { value?: A } = {}
|
|
1550
|
+
return () => {
|
|
1551
|
+
if (!("value" in cell)) {
|
|
1552
|
+
cell.value = make()
|
|
1553
|
+
}
|
|
1554
|
+
return cell.value as A
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Checks each part of the path of a resource: a string, or a reference to a
|
|
1560
|
+
* top-level field of the resource. A reference to another field would read
|
|
1561
|
+
* nothing, and a rule on the path could then match a value by mistake.
|
|
1562
|
+
*/
|
|
1563
|
+
const checkPath = (
|
|
1564
|
+
kind: string,
|
|
1565
|
+
fields: Schema.Struct.Fields,
|
|
1566
|
+
path: ReadonlyArray<unknown>,
|
|
1567
|
+
): ReadonlyArray<PathSegment> =>
|
|
1568
|
+
// `Array.from` gives `undefined` for a hole of a sparse array, so the check
|
|
1569
|
+
// sees each place.
|
|
1570
|
+
Arr.map(Array.from(path), (segment): PathSegment => {
|
|
1571
|
+
if (typeof segment === "string") {
|
|
1572
|
+
return segment
|
|
1573
|
+
}
|
|
1574
|
+
if (
|
|
1575
|
+
hasProperty(segment, "_tag") &&
|
|
1576
|
+
segment._tag === "Field" &&
|
|
1577
|
+
hasProperty(segment, "field") &&
|
|
1578
|
+
typeof segment.field === "string" &&
|
|
1579
|
+
Object.hasOwn(fields, segment.field)
|
|
1580
|
+
) {
|
|
1581
|
+
return { _tag: "Field", field: segment.field }
|
|
1582
|
+
}
|
|
1583
|
+
throw new globalThis.Error(
|
|
1584
|
+
`A part of the path of "${kind}" is not a string or a field of the resource`,
|
|
1585
|
+
)
|
|
1586
|
+
})
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* Copies the options of a resource. The descriptor is made later, when it is
|
|
1590
|
+
* first read, so a change to the objects of the caller before then must not
|
|
1591
|
+
* change it: the Schema of the values and the descriptor must agree.
|
|
1592
|
+
*/
|
|
1593
|
+
const snapshot = (
|
|
1594
|
+
options: Options<ReadonlyArray<string>, Schema.Struct.Fields>,
|
|
1595
|
+
): Options<ReadonlyArray<string>, Schema.Struct.Fields> => ({
|
|
1596
|
+
...options,
|
|
1597
|
+
actions: [...options.actions],
|
|
1598
|
+
fields: { ...options.fields },
|
|
1599
|
+
})
|
|
1600
|
+
|
|
1601
|
+
const makeDescriptor = (
|
|
1602
|
+
kind: string,
|
|
1603
|
+
options: Options<ReadonlyArray<string>, Schema.Struct.Fields>,
|
|
1604
|
+
): Descriptor => {
|
|
1605
|
+
const refs: Record<string, FieldRef<string>> = Object.fromEntries(
|
|
1606
|
+
Arr.map(Object.keys(options.fields), (key) => [
|
|
1607
|
+
key,
|
|
1608
|
+
{ _tag: "Field", field: key },
|
|
1609
|
+
]),
|
|
1610
|
+
)
|
|
1611
|
+
const visits = walkFields(Schema.Struct(options.fields).ast)
|
|
1612
|
+
const leaves = Arr.filter(visits, (visit) => visit.leaf)
|
|
1613
|
+
// In a union, one path may hold a scalar in one branch and a struct in
|
|
1614
|
+
// another. Such a path holds more than one value, so it is open too.
|
|
1615
|
+
const structs = HashSet.fromIterable(
|
|
1616
|
+
Arr.map(
|
|
1617
|
+
Arr.filter(visits, (visit) => !visit.leaf),
|
|
1618
|
+
(visit) => visit.path,
|
|
1619
|
+
),
|
|
1620
|
+
)
|
|
1621
|
+
const open = Arr.filter(
|
|
1622
|
+
leaves,
|
|
1623
|
+
(visit) =>
|
|
1624
|
+
shapeOf(visit.type) !== "scalar" || HashSet.has(structs, visit.path),
|
|
1625
|
+
)
|
|
1626
|
+
return new ResourceDescriptor({
|
|
1627
|
+
kind,
|
|
1628
|
+
actions: [...options.actions],
|
|
1629
|
+
path:
|
|
1630
|
+
options.path === undefined
|
|
1631
|
+
? undefined
|
|
1632
|
+
: checkPath(kind, options.fields, options.path(refs)),
|
|
1633
|
+
fieldPaths: Arr.dedupe(Arr.map(leaves, (visit) => visit.path)),
|
|
1634
|
+
openPaths: Arr.dedupe(Arr.map(open, (visit) => visit.path)),
|
|
1635
|
+
schema: Schema.TaggedStruct(kind, options.fields),
|
|
1636
|
+
})
|
|
168
1637
|
}
|
|
169
1638
|
|
|
170
1639
|
/**
|
|
@@ -226,7 +1695,23 @@ export const Class: <Self = never>(
|
|
|
226
1695
|
options: Options<Actions, Fields>,
|
|
227
1696
|
) => [Self] extends [never]
|
|
228
1697
|
? "Missing `Self` type: use Resource.Class<Self>()(...)"
|
|
229
|
-
: Class<Self, Kind, Actions, Fields> =
|
|
1698
|
+
: Class<Self, Kind, Actions, Fields> = ((identifier?: string) =>
|
|
1699
|
+
(
|
|
1700
|
+
kind: string,
|
|
1701
|
+
input: Options<ReadonlyArray<string>, Schema.Struct.Fields>,
|
|
1702
|
+
) => {
|
|
1703
|
+
const options = snapshot(input)
|
|
1704
|
+
checkFields(kind, options)
|
|
1705
|
+
const Base = Schema.TaggedClass<any>(identifier)(kind, options.fields)
|
|
1706
|
+
const descriptor = lazy(() => makeDescriptor(kind, options))
|
|
1707
|
+
return class extends Base {
|
|
1708
|
+
static get descriptor() {
|
|
1709
|
+
return descriptor()
|
|
1710
|
+
}
|
|
1711
|
+
static readonly kind = kind
|
|
1712
|
+
static readonly actions = [...options.actions]
|
|
1713
|
+
}
|
|
1714
|
+
}) as never
|
|
230
1715
|
|
|
231
1716
|
/**
|
|
232
1717
|
* A resource made by {@link make}. Its values are plain objects.
|
|
@@ -269,7 +1754,24 @@ export const make: <
|
|
|
269
1754
|
>(
|
|
270
1755
|
kind: Kind,
|
|
271
1756
|
options: Options<Actions, Fields>,
|
|
272
|
-
) => Struct<Kind, Actions, Fields> =
|
|
1757
|
+
) => Struct<Kind, Actions, Fields> = ((
|
|
1758
|
+
kind: string,
|
|
1759
|
+
input: Options<ReadonlyArray<string>, Schema.Struct.Fields>,
|
|
1760
|
+
) => {
|
|
1761
|
+
const options = snapshot(input)
|
|
1762
|
+
checkFields(kind, options)
|
|
1763
|
+
const schema = Schema.TaggedStruct(kind, options.fields)
|
|
1764
|
+
const descriptor = lazy(() => makeDescriptor(kind, options))
|
|
1765
|
+
return {
|
|
1766
|
+
get descriptor() {
|
|
1767
|
+
return descriptor()
|
|
1768
|
+
},
|
|
1769
|
+
kind,
|
|
1770
|
+
actions: [...options.actions],
|
|
1771
|
+
schema,
|
|
1772
|
+
make: (input: object) => schema.make(input as never),
|
|
1773
|
+
}
|
|
1774
|
+
}) as never
|
|
273
1775
|
|
|
274
1776
|
/**
|
|
275
1777
|
* Tests if a value is a resource.
|
|
@@ -278,7 +1780,8 @@ export const make: <
|
|
|
278
1780
|
* @category guards
|
|
279
1781
|
* @since 0.3.0
|
|
280
1782
|
*/
|
|
281
|
-
export const isResource
|
|
1783
|
+
export const isResource = (u: unknown): u is Any =>
|
|
1784
|
+
hasProperty(u, "descriptor") && u.descriptor instanceof ResourceDescriptor
|
|
282
1785
|
|
|
283
1786
|
/**
|
|
284
1787
|
* Gets the runtime data of a resource.
|
|
@@ -287,4 +1790,16 @@ export const isResource: (u: unknown) => u is Any = internal.isResource
|
|
|
287
1790
|
* @category getters
|
|
288
1791
|
* @since 0.3.0
|
|
289
1792
|
*/
|
|
290
|
-
export const descriptor
|
|
1793
|
+
export const descriptor = (resource: Any): Descriptor => resource.descriptor
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Gives the kind name of a resource value, from its `_tag` field.
|
|
1797
|
+
*
|
|
1798
|
+
* @stability stable
|
|
1799
|
+
* @category getters
|
|
1800
|
+
* @since 1.0.0
|
|
1801
|
+
*/
|
|
1802
|
+
export const kindOf = (value: unknown): string | undefined =>
|
|
1803
|
+
hasProperty(value, "_tag") && typeof value._tag === "string"
|
|
1804
|
+
? value._tag
|
|
1805
|
+
: undefined
|