prototypey.org - atproto lexicon typescript toolkit - mirror https://github.com/tylersayshi/prototypey
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add type-level inference for query, procedure, and subscription (#100)

authored by

JP Hastings-Edrei and committed by
GitHub
7a0cfd8d 641edcd0

+525 -40
+31
.changeset/query-procedure-subscription-type-hints.md
··· 1 + --- 2 + "prototypey": minor 3 + --- 4 + 5 + Add type-level inference for `query`, `procedure`, and `subscription` lexicon types. 6 + 7 + The `~infer` type on a lexicon now resolves `parameters`, `input`, `output`, and `message` bodies into the shapes you'd expect, so you can pull request/response types directly off the schema instead of hand-writing them. 8 + 9 + ```ts 10 + const getUser = lx.lexicon("com.example.getUser", { 11 + main: lx.query({ 12 + parameters: lx.params({ 13 + did: lx.string({ required: true, format: "did" }), 14 + }), 15 + output: { 16 + encoding: "application/json", 17 + schema: lx.object({ 18 + name: lx.string({ required: true }), 19 + bio: lx.string(), 20 + }), 21 + }, 22 + }), 23 + }); 24 + 25 + type GetUser = (typeof getUser)["~infer"]; 26 + // { 27 + // $type: "com.example.getUser" 28 + // parameters: { did: string } 29 + // output: { name: string; bio?: string | undefined } 30 + // } 31 + ```
+72 -40
packages/prototypey/core/infer.ts
··· 17 17 18 18 type InferType<T> = T extends { type: "record" } 19 19 ? InferRecord<T> 20 - : T extends { type: "object" } 21 - ? InferObject<T> 22 - : T extends { type: "array" } 23 - ? InferArray<T> 24 - : T extends { type: "params" } 25 - ? InferParams<T> 26 - : T extends { type: "permission-set" } 27 - ? InferPermissionSet<T> 28 - : T extends { type: "union" } 29 - ? InferUnion<T> 30 - : T extends { type: "token" } 31 - ? InferToken<T> 32 - : T extends { type: "ref" } 33 - ? InferRef<T> 34 - : T extends { type: "unknown" } 35 - ? unknown 36 - : T extends { type: "null" } 37 - ? null 38 - : T extends { type: "boolean" } 39 - ? boolean 40 - : T extends { type: "integer" } 41 - ? number 42 - : T extends { type: "string" } 43 - ? T extends { 44 - enum: readonly (infer E extends string)[]; 45 - } 46 - ? E 47 - : T extends { 48 - knownValues: readonly (infer K extends 49 - string)[]; 50 - } 51 - ? K | (string & {}) 52 - : string 53 - : T extends { type: "bytes" } 54 - ? Uint8Array 55 - : T extends { type: "cid-link" } 56 - ? string 57 - : T extends { type: "blob" } 58 - ? Blob 59 - : never; 20 + : T extends { type: "query" } 21 + ? InferQuery<T> 22 + : T extends { type: "procedure" } 23 + ? InferProcedure<T> 24 + : T extends { type: "subscription" } 25 + ? InferSubscription<T> 26 + : T extends { type: "object" } 27 + ? InferObject<T> 28 + : T extends { type: "array" } 29 + ? InferArray<T> 30 + : T extends { type: "params" } 31 + ? InferParams<T> 32 + : T extends { type: "permission-set" } 33 + ? InferPermissionSet<T> 34 + : T extends { type: "union" } 35 + ? InferUnion<T> 36 + : T extends { type: "token" } 37 + ? InferToken<T> 38 + : T extends { type: "ref" } 39 + ? InferRef<T> 40 + : T extends { type: "unknown" } 41 + ? unknown 42 + : T extends { type: "null" } 43 + ? null 44 + : T extends { type: "boolean" } 45 + ? boolean 46 + : T extends { type: "integer" } 47 + ? number 48 + : T extends { type: "string" } 49 + ? T extends { 50 + enum: readonly (infer E extends string)[]; 51 + } 52 + ? E 53 + : T extends { 54 + knownValues: readonly (infer K extends 55 + string)[]; 56 + } 57 + ? K | (string & {}) 58 + : string 59 + : T extends { type: "bytes" } 60 + ? Uint8Array 61 + : T extends { type: "cid-link" } 62 + ? string 63 + : T extends { type: "blob" } 64 + ? Blob 65 + : never; 60 66 61 67 type InferToken<T> = T extends { enum: readonly (infer U)[] } ? U : string; 62 68 ··· 170 176 ? InferUnion<R> 171 177 : unknown 172 178 : unknown; 179 + 180 + type InferBody<T> = T extends { schema: infer S } 181 + ? S extends { type: "object" } 182 + ? InferObject<S> 183 + : S extends { type: "union" } 184 + ? InferUnion<S> 185 + : unknown 186 + : unknown; 187 + 188 + type InferQuery<T> = Prettify< 189 + (T extends { parameters: infer P } ? { parameters: InferType<P> } : {}) & 190 + (T extends { output: infer O } ? { output: InferBody<O> } : {}) 191 + >; 192 + 193 + type InferProcedure<T> = Prettify< 194 + (T extends { parameters: infer P } ? { parameters: InferType<P> } : {}) & 195 + (T extends { input: infer I } ? { input: InferBody<I> } : {}) & 196 + (T extends { output: infer O } ? { output: InferBody<O> } : {}) 197 + >; 198 + 199 + type InferSubscription<T> = Prettify< 200 + (T extends { parameters: infer P } ? { parameters: InferType<P> } : {}) & 201 + (T extends { message: { schema: infer S } } 202 + ? { message: InferType<S> } 203 + : {}) 204 + >; 173 205 174 206 /** 175 207 * Recursively replaces stub references in a type with their actual definitions.
+204
packages/prototypey/core/tests/from-json-infer.test.ts
··· 634 634 }); 635 635 636 636 // ============================================================================ 637 + // QUERY TYPE TESTS 638 + // ============================================================================ 639 + 640 + test("fromJSON InferQuery handles query with parameters and output", () => { 641 + const lexicon = fromJSON({ 642 + id: "app.bsky.graph.getStarterPack", 643 + defs: { 644 + main: { 645 + type: "query", 646 + parameters: { 647 + type: "params", 648 + required: ["starterPack"], 649 + properties: { 650 + starterPack: { type: "string", format: "at-uri" }, 651 + }, 652 + }, 653 + output: { 654 + encoding: "application/json", 655 + schema: { 656 + type: "object", 657 + required: ["starterPack"], 658 + properties: { 659 + starterPack: { 660 + type: "ref", 661 + ref: "app.bsky.graph.defs#starterPackView", 662 + }, 663 + }, 664 + }, 665 + }, 666 + }, 667 + }, 668 + }); 669 + 670 + attest(lexicon["~infer"]).type.toString.snap(`{ 671 + $type: "app.bsky.graph.getStarterPack" 672 + parameters: { starterPack: string } 673 + output: { 674 + starterPack: { 675 + [x: string]: unknown 676 + $type: "app.bsky.graph.defs#starterPackView" 677 + } 678 + } 679 + }`); 680 + }); 681 + 682 + test("fromJSON InferQuery handles query with only output", () => { 683 + const lexicon = fromJSON({ 684 + id: "com.example.getStatus", 685 + defs: { 686 + main: { 687 + type: "query", 688 + output: { 689 + encoding: "application/json", 690 + schema: { 691 + type: "object", 692 + required: ["status"], 693 + properties: { 694 + status: { type: "string" }, 695 + uptime: { type: "integer" }, 696 + }, 697 + }, 698 + }, 699 + }, 700 + }, 701 + }); 702 + 703 + attest(lexicon["~infer"]).type.toString.snap(`{ 704 + $type: "com.example.getStatus" 705 + output: { uptime?: number | undefined; status: string } 706 + }`); 707 + }); 708 + 709 + test("fromJSON InferQuery handles query with local ref in output", () => { 710 + const lexicon = fromJSON({ 711 + id: "com.example.getUser", 712 + defs: { 713 + profile: { 714 + type: "object", 715 + properties: { 716 + name: { type: "string" }, 717 + bio: { type: "string" }, 718 + }, 719 + required: ["name"], 720 + }, 721 + main: { 722 + type: "query", 723 + parameters: { 724 + type: "params", 725 + required: ["did"], 726 + properties: { 727 + did: { type: "string", format: "did" }, 728 + }, 729 + }, 730 + output: { 731 + encoding: "application/json", 732 + schema: { 733 + type: "object", 734 + required: ["profile"], 735 + properties: { 736 + profile: { type: "ref", ref: "#profile" }, 737 + }, 738 + }, 739 + }, 740 + }, 741 + }, 742 + }); 743 + 744 + attest(lexicon["~infer"]).type.toString.snap(`{ 745 + $type: "com.example.getUser" 746 + parameters: { did: string } 747 + output: { 748 + profile: { 749 + bio?: string | undefined 750 + name: string 751 + $type: "#profile" 752 + } 753 + } 754 + }`); 755 + }); 756 + 757 + // ============================================================================ 758 + // PROCEDURE TYPE TESTS 759 + // ============================================================================ 760 + 761 + test("fromJSON InferProcedure handles procedure with input and output", () => { 762 + const lexicon = fromJSON({ 763 + id: "com.example.createPost", 764 + defs: { 765 + main: { 766 + type: "procedure", 767 + input: { 768 + encoding: "application/json", 769 + schema: { 770 + type: "object", 771 + required: ["text"], 772 + properties: { 773 + text: { type: "string" }, 774 + }, 775 + }, 776 + }, 777 + output: { 778 + encoding: "application/json", 779 + schema: { 780 + type: "object", 781 + required: ["uri", "cid"], 782 + properties: { 783 + uri: { type: "string", format: "at-uri" }, 784 + cid: { type: "string" }, 785 + }, 786 + }, 787 + }, 788 + }, 789 + }, 790 + }); 791 + 792 + attest(lexicon["~infer"]).type.toString.snap(`{ 793 + $type: "com.example.createPost" 794 + input: { text: string } 795 + output: { cid: string; uri: string } 796 + }`); 797 + }); 798 + 799 + // ============================================================================ 800 + // SUBSCRIPTION TYPE TESTS 801 + // ============================================================================ 802 + 803 + test("fromJSON InferSubscription handles subscription with message", () => { 804 + const lexicon = fromJSON({ 805 + id: "com.example.subscribe", 806 + defs: { 807 + main: { 808 + type: "subscription", 809 + parameters: { 810 + type: "params", 811 + properties: { 812 + cursor: { type: "integer" }, 813 + }, 814 + }, 815 + message: { 816 + schema: { 817 + type: "union", 818 + refs: ["com.example.event#create", "com.example.event#delete"], 819 + }, 820 + }, 821 + }, 822 + }, 823 + }); 824 + 825 + attest(lexicon["~infer"]).type.toString.snap(`{ 826 + $type: "com.example.subscribe" 827 + parameters: { cursor?: number | undefined } 828 + message: 829 + | { 830 + [x: string]: unknown 831 + $type: "com.example.event#create" 832 + } 833 + | { 834 + [x: string]: unknown 835 + $type: "com.example.event#delete" 836 + } 837 + }`); 838 + }); 839 + 840 + // ============================================================================ 637 841 // NESTED OBJECTS TESTS 638 842 // ============================================================================ 639 843
+51
packages/prototypey/core/tests/infer.bench.ts
··· 348 348 return schema["~infer"]; 349 349 }).types([587, "instantiations"]); 350 350 351 + bench("infer with query endpoint", () => { 352 + const schema = lx.lexicon("app.bsky.graph.getStarterPack", { 353 + main: lx.query({ 354 + parameters: lx.params({ 355 + starterPack: lx.string({ required: true, format: "at-uri" }), 356 + }), 357 + output: { 358 + encoding: "application/json", 359 + schema: lx.object({ 360 + starterPack: lx.ref("app.bsky.graph.defs#starterPackView", { 361 + required: true, 362 + }), 363 + }), 364 + }, 365 + }), 366 + }); 367 + return schema["~infer"]; 368 + }).types([1137, "instantiations"]); 369 + 370 + bench("fromJSON infer with query endpoint", () => { 371 + const schema = fromJSON({ 372 + id: "app.bsky.graph.getStarterPack", 373 + defs: { 374 + main: { 375 + type: "query", 376 + parameters: { 377 + type: "params", 378 + required: ["starterPack"], 379 + properties: { 380 + starterPack: { type: "string", format: "at-uri" }, 381 + }, 382 + }, 383 + output: { 384 + encoding: "application/json", 385 + schema: { 386 + type: "object", 387 + required: ["starterPack"], 388 + properties: { 389 + starterPack: { 390 + type: "ref", 391 + ref: "app.bsky.graph.defs#starterPackView", 392 + }, 393 + }, 394 + }, 395 + }, 396 + }, 397 + }, 398 + }); 399 + return schema["~infer"]; 400 + }).types([343, "instantiations"]); 401 + 351 402 bench("infer with simple permission set", () => { 352 403 const schema = lx.lexicon("com.example.authCore", { 353 404 main: lx.permissionSet({
+167
packages/prototypey/core/tests/infer.test.ts
··· 619 619 }); 620 620 621 621 // ============================================================================ 622 + // QUERY TYPE TESTS 623 + // ============================================================================ 624 + 625 + test("InferQuery handles query with parameters and output", () => { 626 + const lexicon = lx.lexicon("app.bsky.graph.getStarterPack", { 627 + main: lx.query({ 628 + parameters: lx.params({ 629 + starterPack: lx.string({ required: true, format: "at-uri" }), 630 + }), 631 + output: { 632 + encoding: "application/json", 633 + schema: lx.object({ 634 + starterPack: lx.ref("app.bsky.graph.defs#starterPackView", { 635 + required: true, 636 + }), 637 + }), 638 + }, 639 + }), 640 + }); 641 + 642 + attest(lexicon["~infer"]).type.toString.snap(`{ 643 + $type: "app.bsky.graph.getStarterPack" 644 + parameters: { starterPack: string } 645 + output: { 646 + starterPack?: 647 + | { 648 + [x: string]: unknown 649 + $type: "app.bsky.graph.defs#starterPackView" 650 + } 651 + | undefined 652 + } 653 + }`); 654 + }); 655 + 656 + test("InferQuery handles query with only output", () => { 657 + const lexicon = lx.lexicon("com.example.getStatus", { 658 + main: lx.query({ 659 + output: { 660 + encoding: "application/json", 661 + schema: lx.object({ 662 + status: lx.string({ required: true }), 663 + uptime: lx.integer(), 664 + }), 665 + }, 666 + }), 667 + }); 668 + 669 + attest(lexicon["~infer"]).type.toString.snap(`{ 670 + $type: "com.example.getStatus" 671 + output: { uptime?: number | undefined; status: string } 672 + }`); 673 + }); 674 + 675 + test("InferQuery handles query with only parameters", () => { 676 + const lexicon = lx.lexicon("com.example.ping", { 677 + main: lx.query({ 678 + parameters: lx.params({ 679 + echo: lx.string(), 680 + }), 681 + }), 682 + }); 683 + 684 + attest(lexicon["~infer"]).type.toString.snap(`{ 685 + $type: "com.example.ping" 686 + parameters: { echo?: string | undefined } 687 + }`); 688 + }); 689 + 690 + test("InferQuery handles query with local ref in output", () => { 691 + const lexicon = lx.lexicon("com.example.getUser", { 692 + profile: lx.object({ 693 + name: lx.string({ required: true }), 694 + bio: lx.string(), 695 + }), 696 + main: lx.query({ 697 + parameters: lx.params({ 698 + did: lx.string({ required: true, format: "did" }), 699 + }), 700 + output: { 701 + encoding: "application/json", 702 + schema: lx.object({ 703 + profile: lx.ref("#profile", { required: true }), 704 + }), 705 + }, 706 + }), 707 + }); 708 + 709 + attest(lexicon["~infer"]).type.toString.snap(`{ 710 + $type: "com.example.getUser" 711 + parameters: { did: string } 712 + output: { 713 + profile?: 714 + | { 715 + bio?: string | undefined 716 + name: string 717 + $type: "#profile" 718 + } 719 + | undefined 720 + } 721 + }`); 722 + }); 723 + 724 + // ============================================================================ 725 + // PROCEDURE TYPE TESTS 726 + // ============================================================================ 727 + 728 + test("InferProcedure handles procedure with input and output", () => { 729 + const lexicon = lx.lexicon("com.example.createPost", { 730 + main: lx.procedure({ 731 + input: { 732 + encoding: "application/json", 733 + schema: lx.object({ 734 + text: lx.string({ required: true }), 735 + }), 736 + }, 737 + output: { 738 + encoding: "application/json", 739 + schema: lx.object({ 740 + uri: lx.string({ required: true, format: "at-uri" }), 741 + cid: lx.string({ required: true }), 742 + }), 743 + }, 744 + }), 745 + }); 746 + 747 + attest(lexicon["~infer"]).type.toString.snap(`{ 748 + $type: "com.example.createPost" 749 + input: { text: string } 750 + output: { cid: string; uri: string } 751 + }`); 752 + }); 753 + 754 + // ============================================================================ 755 + // SUBSCRIPTION TYPE TESTS 756 + // ============================================================================ 757 + 758 + test("InferSubscription handles subscription with parameters and message", () => { 759 + const lexicon = lx.lexicon("com.example.subscribe", { 760 + main: lx.subscription({ 761 + parameters: lx.params({ 762 + cursor: lx.integer(), 763 + }), 764 + message: { 765 + schema: lx.union([ 766 + "com.example.event#create", 767 + "com.example.event#delete", 768 + ]), 769 + }, 770 + }), 771 + }); 772 + 773 + attest(lexicon["~infer"]).type.toString.snap(`{ 774 + $type: "com.example.subscribe" 775 + parameters: { cursor?: number | undefined } 776 + message: 777 + | { 778 + [x: string]: unknown 779 + $type: "com.example.event#create" 780 + } 781 + | { 782 + [x: string]: unknown 783 + $type: "com.example.event#delete" 784 + } 785 + }`); 786 + }); 787 + 788 + // ============================================================================ 622 789 // NESTED OBJECTS TESTS 623 790 // ============================================================================ 624 791