diff --git a/README.md b/README.md index a56bea5..23fe35f 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Finally, if the condition is a type guard, the parameter you pass will be inferr To define policies, you create a policy set using the `definePolicies` function. Each policy definition is created using the `definePolicy` function, which takes a policy name and a callback that defines the policy logic (or a boolean value). -> [!IMPORTANT] +> [!IMPORTANT] > For convenience, the condition can be a boolean value but **you will lose type inference** > > If you want TS to infer something (not null, union, etc), use a condition function @@ -393,6 +393,56 @@ if (check(guard.post.policy("post has comments"), post)) { console.log("Post has comments"); } ``` + +### `checkAllSettle` +Evaluates all the policies with `check` and returns a snapshot with the results. + +It's useful to serialize policies. + +```ts +export function checkAllSettle( + policies: TPolicies +): PoliciesSnapshot +``` + +Example: +```ts +// TLDR +const snapshot = checkAllSettle([ + [guard.post.policy("my post"), post], + [guard.post.policy("all my published posts"), post], +]); + +// Example +const PostPolicies = definePolicies((context: Context) => { + const myPostPolicy = definePolicy( + "my post", + (post: Post) => post.userId === context.userId, + () => new Error("Not the author") + ); + + return [ + myPostPolicy, + definePolicy("all published posts or mine", (post: Post) => + or(check(myPostPolicy, post), post.status === "published") + ), + ]; +}); + +const guard = { + post: PostPolicies(context), +}; + +const snapshot = checkAllSettle([ + [guard.post.policy("my post"), post], + [guard.post.policy("all my published posts"), post], + ["post has comments", post.comments.length > 0], +]); + +console.log(snapshot); // { "my post": boolean; "all my published posts": boolean; "post has comments": boolean; } +console.log(snapshot["my post"]) // boolean +``` + ### Condition helpers #### `or` Logical OR operator for policy conditions. diff --git a/package.json b/package.json index a1ef2d9..88ee3b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "comply", - "version": "0.2.0", + "version": "0.3.0", "description": "Comply is a tiny library to help you define policies in your app", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/src/index.test.ts b/src/index.test.ts index df9c115..e148def 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod"; -import { assert, and, check, definePolicies, definePolicy, matchSchema, notNull, or } from "."; +import { assert, and, check, checkAllSettle, definePolicies, definePolicy, matchSchema, notNull, or } from "."; describe("Define policy", () => { type Post = { userId: string; comments: string[] }; @@ -887,3 +887,63 @@ describe("Logical operators", () => { ).toThrowError(); }); }); + +describe("Check all settle", () => { + type Context = { userId: string }; + type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" }; + + it("should snapshot policies", () => { + const PostPolicies = definePolicies((context: Context) => { + const myPostPolicy = definePolicy( + "my post", + (post: Post) => post.userId === context.userId, + () => new Error("Not the author") + ); + + return [ + myPostPolicy, + definePolicy("all my published posts", (post: Post) => + and(check(myPostPolicy, post), post.status === "published") + ), + ]; + }); + + const guard = { + post: PostPolicies({ userId: "1" }), + }; + + const snapshot = checkAllSettle([ + [definePolicy("is not null", notNull), "not null"], + [definePolicy("is true", true)], + ["post has comments", true], + ["post has likes", () => true], + [guard.post.policy("my post"), { userId: "1", comments: [], status: "published" }], + [guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" }], + ]); + + expect(snapshot).toStrictEqual({ + "is not null": true, + "is true": true, + "post has comments": true, + "post has likes": true, + "my post": true, + "all my published posts": true, + }); + + expectTypeOf(snapshot).toEqualTypeOf<{ + "is not null": boolean; + "is true": boolean; + "post has comments": boolean; + "post has likes": boolean; + "my post": boolean; + "all my published posts": boolean; + }>(); + + /** @ts-expect-error */ + expectTypeOf(checkAllSettle([[definePolicy("is not null", notNull)]])).toEqualTypeOf<{ [x: string]: boolean }>(); + /** @ts-expect-error */ + expectTypeOf(checkAllSettle([[definePolicy("is true", true), "extra arg"]])).toEqualTypeOf<{ + [x: string]: boolean; + }>(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 4340768..783622f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -131,7 +131,7 @@ type PolicySetOrFactory = T extends AnyPolicies ? (...args: Parameters) => PolicySet> : never; -type WithRequiredContext = T extends (arg: infer A) => any ? (unknown extends A ? never : T) : never; +type WithRequiredArg = T extends (arg: infer A) => any ? (unknown extends A ? never : T) : never; /** * Create a set of policies @@ -222,7 +222,7 @@ export function definePolicies(policies: T): PolicySet * ``` */ export function definePolicies( - define: WithRequiredContext<(context: Context) => T> + define: WithRequiredArg<(context: Context) => T> ): (context: Context) => PolicySetOrFactory; export function definePolicies(defineOrPolicies: T | ((context: Context) => T)) { @@ -589,6 +589,89 @@ export function check( return typeof policy.condition === "boolean" ? policy.condition : policy.condition(arg); } +type PolicyTuple = + | readonly [string, PolicyConditionNoArg] + | readonly [Policy] + | readonly [Policy, any]; + +type InferPolicyName = TPolicyTuple extends readonly [infer name, any] + ? name extends Policy + ? Name + : name extends string + ? name + : never + : TPolicyTuple extends readonly [Policy] + ? Name + : never; + +type PoliciesSnapshot = { [K in TPolicyName]: boolean }; + +/** + * Create a snapshot of policies and their evaluation results + * + * It evaluates all the policies with `check` + * + * @param policies - A tuple of policies and their arguments (if needed) + * + * @example + * ```ts + * // TLDR + const snapshot = checkAllSettle([ + [guard.post.policy("my post"), post], + [guard.post.policy("all my published posts"), post], + ["post has comments", post.comments.length > 0], + ]); + + // returns: { "my post": boolean; "all my published posts": boolean; "post has comments": boolean; } + + * // Example + const PostPolicies = definePolicies((context: Context) => { + const myPostPolicy = definePolicy( + "my post", + (post: Post) => post.userId === context.userId, + () => new Error("Not the author") + ); + + return [ + myPostPolicy, + definePolicy("all published posts or mine", (post: Post) => + or(check(myPostPolicy, post), post.status === "published") + ), + ]; + }); + + const guard = { + post: PostPolicies(context), + }; + + const snapshot = checkAllSettle([ + [guard.post.policy("my post"), post], + [guard.post.policy("all my published posts"), post], + ["post has comments", post.comments.length > 0], + ]); + + console.log(snapshot); // { "my post": boolean; "all my published posts": boolean; "post has comments": boolean; } + * ``` + */ +export function checkAllSettle< + const TPolicies extends readonly PolicyTuple[], + TPolicyTuple extends TPolicies[number], + TPolicyName extends InferPolicyName, +>(policies: TPolicies): PoliciesSnapshot { + return policies.reduce( + (acc, policyTuple) => { + const [policyOrName, arg] = policyTuple; + const policyName = typeof policyOrName === "string" ? policyOrName : policyOrName.name; + + acc[policyName as TPolicyName] = + typeof policyOrName === "string" ? (typeof arg === "function" ? arg() : arg) : policyOrName.check(arg); + + return acc; + }, + {} as PoliciesSnapshot + ); +} + /* -------------------------------------------------------------------------- */ /* Helpers; */ /* -------------------------------------------------------------------------- */