bend-schema 0.1.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/src/index.ts ADDED
@@ -0,0 +1,350 @@
1
+ // bend-schema's TypeScript face: build a schema with `s`, then call
2
+ // `.parse`, `.check` or `.encode` on it. What is proved is the core's
3
+ // (core/LAWS.bend): which values a schema accepts, the first error and its
4
+ // path, and that dec reads what enc writes. What is NOT proved lives here: the
5
+ // builder (a mirror of the core's Schema constructors), the conversion between
6
+ // the core's Meaning values and plain JS, and `.refine()` predicates, whose
7
+ // errors say `proved: false`.
8
+
9
+ import * as core from "../dist-core/core.mjs";
10
+ import type { BendList, Schema as Node, Step } from "../dist-core/core.mjs";
11
+ import { toRaw, whyText } from "./codec";
12
+
13
+ // enc and dec return a type computed from the schema (Meaning(s)), which
14
+ // bend-emit cannot write as a TS signature; they are untyped here.
15
+ const { enc, dec } = core as unknown as { enc: (s: Node, m: unknown) => core.Raw; dec: (s: Node, r: core.Raw) => core.BendMaybe<unknown> };
16
+
17
+ export { BUDGET, KEYS_MAX, NAT_MAX, nat, toRaw } from "./codec";
18
+ export type { Raw } from "../dist-core/core.mjs";
19
+
20
+ // ------------------------------------------------------------------ issues
21
+
22
+ export type PathPart = string | number;
23
+
24
+ /** The first error: where it is, and why. `proved: true` came from the core;
25
+ * `proved: false` from a `.refine()` predicate. */
26
+ export class Issue {
27
+ constructor(
28
+ readonly path: PathPart[],
29
+ readonly message: string,
30
+ readonly proved: boolean,
31
+ ) {}
32
+
33
+ /** "plan.tiers[3].up: must be ..." -- `where` names the value. */
34
+ text(where = ""): string {
35
+ const p = this.path.map((x) => (typeof x === "number" ? `[${x}]` : `.${x}`)).join("");
36
+ const at = (where + p).replace(/^\./, "") || "the value";
37
+ return `${at}: ${this.message}`;
38
+ }
39
+
40
+ toString(): string {
41
+ return this.text();
42
+ }
43
+ }
44
+
45
+ export type Result<T> = { ok: true; value: T } | { ok: false; error: Issue };
46
+
47
+ // ---------------------------------------------------------------- the builder
48
+
49
+ type Kind =
50
+ | { k: "nat" }
51
+ | { k: "natIn"; lo: number; hi: number }
52
+ | { k: "str" }
53
+ | { k: "strLen"; lo: number; hi: number; inner: Schema<string> }
54
+ | { k: "bool" }
55
+ | { k: "true" }
56
+ | { k: "nullable"; inner: Schema<any> }
57
+ | { k: "optional"; inner: Schema<any> }
58
+ | { k: "list"; elem: Schema<any> }
59
+ | { k: "listLen"; lo: number; hi: number; inner: Schema<any[]> }
60
+ | { k: "tuple"; items: Schema<any>[] }
61
+ | { k: "object"; fields: [string, Schema<any>][] }
62
+ | { k: "strict"; obj: Schema<any> }
63
+ | { k: "enum"; names: string[] }
64
+ | { k: "oneKey"; cases: [string, Schema<any>][] }
65
+ | { k: "tagged"; key: string; cases: [string, Schema<any>][] };
66
+
67
+ type Refinement = { fn: (x: any) => boolean; why: string };
68
+
69
+ export class Schema<T> {
70
+ declare readonly _T: T;
71
+ /** @internal */ readonly kind: Kind;
72
+ /** @internal */ readonly refines: Refinement[];
73
+ private cached?: Node;
74
+
75
+ /** @internal */ constructor(kind: Kind, refines: Refinement[] = []) {
76
+ this.kind = kind;
77
+ this.refines = refines;
78
+ }
79
+
80
+ /** A host-side predicate, run after the proved check passes. Not proved:
81
+ * its error carries `proved: false`. Keeps the schema's own methods. */
82
+ refine(fn: (x: T) => boolean, why: string): this {
83
+ const Ctor = this.constructor as new (k: Kind, r: Refinement[]) => this;
84
+ return new Ctor(this.kind, [...this.refines, { fn, why }]);
85
+ }
86
+
87
+ /** null or this. The key must still be present. */
88
+ nullable(): Schema<T | null> {
89
+ return new Schema<T | null>({ k: "nullable", inner: this });
90
+ }
91
+
92
+ /** The key may be absent. Only valid as an object field. */
93
+ optional(): Optional<T> {
94
+ return new Optional<T>({ k: "optional", inner: this });
95
+ }
96
+
97
+ /** Check v, then read it as T. */
98
+ parse(v: unknown): Result<T> {
99
+ const node = this.node;
100
+ const raw = toRaw(v);
101
+ const e = core.check0(node, raw);
102
+ if (e.$ === "Some") return { ok: false, error: new Issue(pathOf(e.value.path), whyText(e.value.why), true) };
103
+ const m = dec(node, raw);
104
+ if (m.$ !== "Some") throw new Error("bend-schema: dec refused a value check0 accepted (a bug in the core)");
105
+ const value = toJs(this, m.value) as T;
106
+ const r = refineAt(this, value, []);
107
+ return r ? { ok: false, error: r } : { ok: true, value };
108
+ }
109
+
110
+ /** The first error, or null. */
111
+ check(v: unknown): Issue | null {
112
+ const r = this.parse(v);
113
+ return r.ok ? null : r.error;
114
+ }
115
+
116
+ /** Write x as plain JSON-ready JS; parse reads it back. Throws when x breaks
117
+ * a bound or a refinement, which its type cannot rule out. */
118
+ encode(x: T): unknown {
119
+ const out = rawToJs(enc(this.node, toMeaning(this, x)));
120
+ // An absent top-level value is legitimate for an Optional schema, and
121
+ // JSON has no way to write it, so it stays undefined rather than throwing.
122
+ if (out === undefined) return undefined;
123
+ const e = this.check(out);
124
+ if (e) throw new Error(`bend-schema: encode: ${e.text()}`);
125
+ return out;
126
+ }
127
+
128
+ /** The core's Schema value. Throws if the core finds it ill-formed. */
129
+ get node(): Node {
130
+ if (!this.cached) {
131
+ const n = toNode(this);
132
+ if (!core.wf(n)) {
133
+ throw new Error(
134
+ "bend-schema: ill-formed schema (a duplicate key, a nullable inside a nullable, " +
135
+ ".optional() outside an object field, or .strict() on a non-object)",
136
+ );
137
+ }
138
+ this.cached = n;
139
+ }
140
+ return this.cached;
141
+ }
142
+ }
143
+
144
+ /** A field that may be absent. */
145
+ export class Optional<T> extends Schema<T | undefined> {
146
+ declare readonly _optional: true;
147
+ }
148
+
149
+ export class NatSchema extends Schema<number> {
150
+ /** lo <= n <= hi, both ends included. */
151
+ in(lo: number, hi: number): NatSchema {
152
+ return new NatSchema({ k: "natIn", lo: whole(lo), hi: whole(hi) }, this.refines);
153
+ }
154
+ }
155
+
156
+ export class StrSchema extends Schema<string> {
157
+ /** lo <= length <= hi, both ends included. */
158
+ len(lo: number, hi: number): StrSchema {
159
+ return new StrSchema({ k: "strLen", lo: whole(lo), hi: whole(hi), inner: this });
160
+ }
161
+ }
162
+
163
+ export class ListSchema<T> extends Schema<T[]> {
164
+ /** lo <= element count <= hi, both ends included. */
165
+ len(lo: number, hi: number): ListSchema<T> {
166
+ return new ListSchema<T>({ k: "listLen", lo: whole(lo), hi: whole(hi), inner: this });
167
+ }
168
+ }
169
+
170
+ export class ObjectSchema<T> extends Schema<T> {
171
+ /** Refuse keys this object does not name (by default they are dropped). */
172
+ strict(): ObjectSchema<T> {
173
+ return new ObjectSchema<T>({ k: "strict", obj: this });
174
+ }
175
+ }
176
+
177
+ function whole(n: number): number {
178
+ if (!Number.isSafeInteger(n) || n < 0) throw new Error(`bend-schema: a bound must be a whole number >= 0, got ${n}`);
179
+ return n;
180
+ }
181
+
182
+ type Shape = Record<string, Schema<any>>;
183
+ type Simplify<T> = { [K in keyof T]: T[K] } & {};
184
+ type OptKeys<S extends Shape> = { [K in keyof S]: S[K] extends Optional<any> ? K : never }[keyof S];
185
+ type ObjOf<S extends Shape> = Simplify<
186
+ { [K in Exclude<keyof S, OptKeys<S>>]: Infer<S[K]> } & { [K in OptKeys<S>]?: Exclude<Infer<S[K]>, undefined> }
187
+ >;
188
+ type Union<S extends Shape> = { [K in keyof S]: { [P in K]: Infer<S[K]> } }[keyof S];
189
+ type TaggedUnion<K extends string, S extends Record<string, ObjectSchema<any>>> = {
190
+ [N in keyof S]: Simplify<{ [P in K]: N } & Infer<S[N]>>;
191
+ }[keyof S];
192
+
193
+ export type Infer<X> = X extends Schema<infer T> ? T : never;
194
+
195
+ export const s = {
196
+ nat: () => new NatSchema({ k: "nat" }),
197
+ str: () => new StrSchema({ k: "str" }),
198
+ bool: () => new Schema<boolean>({ k: "bool" }),
199
+ true: () => new Schema<true>({ k: "true" }),
200
+ list: <T>(elem: Schema<T>) => new ListSchema<T>({ k: "list", elem }),
201
+ tuple: <A extends Schema<any>[]>(...items: A) =>
202
+ new Schema<{ [I in keyof A]: Infer<A[I]> }>({ k: "tuple", items }),
203
+ object: <S extends Shape>(shape: S) => new ObjectSchema<ObjOf<S>>({ k: "object", fields: Object.entries(shape) }),
204
+ enum: <const N extends readonly [string, ...string[]]>(names: N) => new Schema<N[number]>({ k: "enum", names: [...names] }),
205
+ oneKey: <S extends Shape>(cases: S) => new Schema<Union<S>>({ k: "oneKey", cases: Object.entries(cases) }),
206
+ tagged: <K extends string, S extends Record<string, ObjectSchema<any>>>(key: K, cases: S) =>
207
+ new Schema<TaggedUnion<K, S>>({ k: "tagged", key, cases: Object.entries(cases) }),
208
+ };
209
+
210
+ // `s.Infer<typeof x>` reads as `Infer<typeof x>`.
211
+ export declare namespace s {
212
+ type Infer<X> = X extends Schema<infer T> ? T : never;
213
+ }
214
+
215
+ function toNode(x: Schema<any>): Node {
216
+ const k = x.kind;
217
+ switch (k.k) {
218
+ case "nat": return { $: "SNat" };
219
+ case "natIn": return { $: "SNatIn", lo: BigInt(k.lo), hi: BigInt(k.hi) };
220
+ case "str": return { $: "SStr" };
221
+ case "strLen": return { $: "SStrLen", lo: BigInt(k.lo), hi: BigInt(k.hi), s: toNode(k.inner) };
222
+ case "bool": return { $: "SBool" };
223
+ case "true": return { $: "STrue" };
224
+ case "nullable": return { $: "SOpt", inner: toNode(k.inner) };
225
+ case "optional": return { $: "SOptional", inner: toNode(k.inner) };
226
+ case "listLen": return { $: "SListLen", lo: BigInt(k.lo), hi: BigInt(k.hi), s: toNode(k.inner) };
227
+ case "list": return { $: "SList", elem: toNode(k.elem) };
228
+ case "tuple": return k.items.reduceRight<Node>((rest, it) => ({ $: "STuple", s: toNode(it), rest }), { $: "STEnd" });
229
+ case "object": return k.fields.reduceRight<Node>((rest, [name, f]) => ({ $: "SField", name, s: toNode(f), rest }), { $: "SEnd" });
230
+ case "strict": return { $: "SStrict", s: toNode(k.obj) };
231
+ case "enum": return { $: "SEnum", names: k.names.reduceRight<BendList<string>>((tail, head) => ({ $: "Con", head, tail }), { $: "Nil" }) };
232
+ case "oneKey": return k.cases.reduceRight<Node>((rest, [name, c]) => ({ $: "SVariant", name, s: toNode(c), rest }), { $: "SVEnd" });
233
+ case "tagged": return k.cases.reduceRight<Node>((rest, [name, c]) => ({ $: "STagged", key: k.key, name, s: toNode(c), rest }), { $: "STagEnd", key: k.key });
234
+ }
235
+ }
236
+
237
+ // ------------------------------------------- Meaning <-> plain JS (not proved)
238
+
239
+ type M = any; // a core Meaning(s) value: number (a Nat), string, boolean, Unit, Both, Either, Maybe, List
240
+
241
+ const unit = { $: "Unit" };
242
+ const both = (a: M, b: M) => ({ $: "Both", a, b });
243
+
244
+ function toJs(x: Schema<any>, m: M): unknown {
245
+ const k = x.kind;
246
+ switch (k.k) {
247
+ case "nat": case "natIn": return Number(m);
248
+ case "str": case "bool": case "enum": return m;
249
+ case "strLen": return toJs(k.inner, m);
250
+ case "true": return true;
251
+ case "nullable": return m.$ === "None" ? null : toJs(k.inner, m.value);
252
+ case "optional": return m.$ === "None" ? undefined : toJs(k.inner, m.value);
253
+ case "listLen": return toJs(k.inner, m);
254
+ case "list": { const out = []; for (let l = m; l.$ === "Con"; l = l.tail) out.push(toJs(k.elem, l.head)); return out; }
255
+ case "tuple": { const out = []; let b = m; for (const it of k.items) { out.push(toJs(it, b.a)); b = b.b; } return out; }
256
+ case "object": { const out: Record<string, unknown> = {}; let b = m; for (const [n, f] of k.fields) { const x = toJs(f, b.a); if (x !== undefined) put(out, n, x); b = b.b; } return out; }
257
+ case "strict": return toJs(k.obj, m);
258
+ case "oneKey": { let e = m; for (const [n, c] of k.cases) { if (e.$ === "Inl") return { [n]: toJs(c, e.value) }; e = e.value; } throw new Error("unreachable: Empty"); }
259
+ case "tagged": { let e = m; for (const [n, c] of k.cases) { if (e.$ === "Inl") return { [k.key]: n, ...(toJs(c, e.value) as object) }; e = e.value; } throw new Error("unreachable: Empty"); }
260
+ }
261
+ }
262
+
263
+ function toMeaning(x: Schema<any>, v: any): M {
264
+ const k = x.kind;
265
+ switch (k.k) {
266
+ case "nat": case "natIn": return BigInt(whole(v));
267
+ case "str": case "bool": case "enum": return v;
268
+ case "strLen": return toMeaning(k.inner, v);
269
+ case "true": return unit;
270
+ case "nullable": return v === null ? { $: "None" } : { $: "Some", value: toMeaning(k.inner, v) };
271
+ case "optional": return v === undefined ? { $: "None" } : { $: "Some", value: toMeaning(k.inner, v) };
272
+ case "listLen": return toMeaning(k.inner, v);
273
+ case "list": return (v as any[]).reduceRight((tail, h) => ({ $: "Con", head: toMeaning(k.elem, h), tail }), { $: "Nil" });
274
+ case "tuple": return k.items.reduceRight((rest: M, it, i) => both(toMeaning(it, v[i]), rest), unit);
275
+ case "object": return k.fields.reduceRight((rest: M, [n, f]) => both(toMeaning(f, v[n]), rest), unit);
276
+ case "strict": return toMeaning(k.obj, v);
277
+ case "oneKey": {
278
+ const i = k.cases.findIndex(([n]) => n in v);
279
+ if (i < 0) throw new Error("bend-schema: encode: no case key present");
280
+ return inj(i, toMeaning(k.cases[i]![1], v[k.cases[i]![0]]));
281
+ }
282
+ case "tagged": {
283
+ const i = k.cases.findIndex(([n]) => v[k.key] === n);
284
+ if (i < 0) throw new Error(`bend-schema: encode: unknown tag ${String(v[k.key])}`);
285
+ const { [k.key]: _, ...rest } = v;
286
+ return inj(i, toMeaning(k.cases[i]![1], rest));
287
+ }
288
+ }
289
+ }
290
+
291
+ const inj = (i: number, m: M): M => (i === 0 ? { $: "Inl", value: m } : { $: "Inr", value: inj(i - 1, m) });
292
+
293
+ // `out[n] = x` is a prototype setter for a field named `__proto__`, which would
294
+ // silently drop it -- a required field gone from a value parse calls valid, and
295
+ // an encode whose own re-check then fails. defineProperty has no such case.
296
+ function put(out: Record<string, unknown>, n: string, x: unknown): void {
297
+ Object.defineProperty(out, n, { value: x, enumerable: true, writable: true, configurable: true });
298
+ }
299
+
300
+ function rawToJs(r: core.Raw): unknown {
301
+ switch (r.$) {
302
+ case "RNum": return Number(r.n);
303
+ case "RBool": return r.b;
304
+ case "RNull": return null;
305
+ case "RStr": return r.s;
306
+ case "RNil": case "RCons": { const out = []; for (let l: core.Raw = r; l.$ === "RCons"; l = l.tail) out.push(rawToJs(l.head)); return out; }
307
+ case "REnd": case "RKey": { const out: Record<string, unknown> = {}; for (let l: core.Raw = r; l.$ === "RKey"; l = l.rest) if (l.val.$ !== "RMissing") put(out, l.key, rawToJs(l.val)); return out; }
308
+ // An absent value is legitimate at the top level of an Optional schema. JSON
309
+ // cannot write it, so it reads as undefined; a nested one never reaches
310
+ // here, because the RKey arm drops it.
311
+ case "RMissing": return undefined;
312
+ default: throw new Error(`bend-schema: encode produced ${r.$}`);
313
+ }
314
+ }
315
+
316
+ // --------------------------------------------------------------- issues' paths
317
+
318
+ function pathOf(p: BendList<Step>): PathPart[] {
319
+ const out: PathPart[] = [];
320
+ for (let x = p; x.$ === "Con"; x = x.tail) {
321
+ const st = x.head;
322
+ if (st.$ === "AtIndex") out.push(Number(st.i));
323
+ else if (st.$ === "AtField") out.push(st.name);
324
+ else if (st.$ === "AtKey") out.push(st.key);
325
+ else out.push(Number(st.i), st.key);
326
+ }
327
+ return out;
328
+ }
329
+
330
+ // Run refinements bottom-up over the parsed value, first failure in reading order.
331
+ function refineAt(x: Schema<any>, v: any, path: PathPart[]): Issue | null {
332
+ const k = x.kind;
333
+ const sub = (c: Schema<any>, cv: any, p: PathPart) => refineAt(c, cv, [...path, p]);
334
+ let e: Issue | null = null;
335
+ switch (k.k) {
336
+ case "strLen": e = refineAt(k.inner, v, path); break;
337
+ case "nullable": if (v !== null) e = refineAt(k.inner, v, path); break;
338
+ case "optional": if (v !== undefined) e = refineAt(k.inner, v, path); break;
339
+ case "listLen": e = refineAt(k.inner, v, path); break;
340
+ case "list": for (let i = 0; i < v.length && !e; i++) e = sub(k.elem, v[i], i); break;
341
+ case "tuple": for (let i = 0; i < k.items.length && !e; i++) e = sub(k.items[i]!, v[i], i); break;
342
+ case "object": for (const [n, f] of k.fields) if (!e && !(f.kind.k === "optional" && v[n] === undefined)) e = sub(f, v[n], n); break;
343
+ case "strict": e = refineAt(k.obj, v, path); break;
344
+ case "oneKey": for (const [n, c] of k.cases) if (!e && n in v) e = sub(c, v[n], n); break;
345
+ case "tagged": for (const [n, c] of k.cases) if (!e && v[k.key] === n) e = refineAt(c, v, path); break;
346
+ }
347
+ if (e) return e;
348
+ for (const r of x.refines) if (!r.fn(v)) return new Issue(path, r.why, false);
349
+ return null;
350
+ }