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.

property emit and lx.object fixes (#90)

* move away from relying on including required and nullable in output

* support nullable and required objects and update the type benchmarks

* changeset

* new bench for just object with required/nullable

* format

* fix lint

authored by

Tyler Lawson and committed by
GitHub
8d822917 d24dc56a

+333 -146
+5
.changeset/fifty-eels-deny.md
··· 1 + --- 2 + "prototypey": patch 3 + --- 4 + 5 + allows for marking objects nullable & required. Removes the erroneous emit of required and nullable as fields
+4
eslint.config.js
··· 32 32 "@typescript-eslint/no-unsafe-member-access": "off", 33 33 "@typescript-eslint/no-unsafe-call": "off", 34 34 "@typescript-eslint/restrict-plus-operands": "off", 35 + "@typescript-eslint/no-unused-vars": [ 36 + "error", 37 + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 38 + ], 35 39 }, 36 40 }, 37 41 {
+15 -18
packages/prototypey/cli/tests/gen-emit.test.ts
··· 209 209 parameters: { 210 210 type: "params", 211 211 properties: { 212 - q: { type: "string", required: true }, 212 + q: { type: "string" }, 213 213 limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, 214 214 cursor: { type: "string" }, 215 215 }, ··· 224 224 posts: { 225 225 type: "array", 226 226 items: { type: "ref", ref: "app.bsky.feed.defs#postView" }, 227 - required: true, 228 227 }, 229 228 }, 230 229 required: ["posts"], ··· 289 288 schema: { 290 289 type: "object", 291 290 properties: { 292 - repo: { type: "string", required: true }, 293 - collection: { type: "string", required: true }, 294 - record: { type: "unknown", required: true }, 291 + repo: { type: "string" }, 292 + collection: { type: "string" }, 293 + record: { type: "unknown" }, 295 294 }, 296 295 required: ["repo", "collection", "record"], 297 296 }, ··· 301 300 schema: { 302 301 type: "object", 303 302 properties: { 304 - uri: { type: "string", required: true }, 305 - cid: { type: "string", required: true }, 303 + uri: { type: "string" }, 304 + cid: { type: "string" }, 306 305 }, 307 306 required: ["uri", "cid"], 308 307 }, ··· 380 379 commit: { 381 380 type: "object", 382 381 properties: { 383 - seq: { type: "integer", required: true }, 384 - rebase: { type: "boolean", required: true }, 382 + seq: { type: "integer" }, 383 + rebase: { type: "boolean" }, 385 384 }, 386 385 required: ["seq", "rebase"], 387 386 }, 388 387 identity: { 389 388 type: "object", 390 389 properties: { 391 - seq: { type: "integer", required: true }, 392 - did: { type: "string", format: "did", required: true }, 390 + seq: { type: "integer" }, 391 + did: { type: "string", format: "did" }, 393 392 }, 394 393 required: ["seq", "did"], 395 394 }, 396 395 account: { 397 396 type: "object", 398 397 properties: { 399 - seq: { type: "integer", required: true }, 400 - active: { type: "boolean", required: true }, 398 + seq: { type: "integer" }, 399 + active: { type: "boolean" }, 401 400 }, 402 401 required: ["seq", "active"], 403 402 }, ··· 442 441 postView: { 443 442 type: "object", 444 443 properties: { 445 - uri: { type: "string", format: "at-uri", required: true }, 446 - cid: { type: "string", format: "cid", required: true }, 444 + uri: { type: "string", format: "at-uri" }, 445 + cid: { type: "string", format: "cid" }, 447 446 author: { 448 447 type: "ref", 449 448 ref: "app.bsky.actor.defs#profileViewBasic", 450 - required: true, 451 449 }, 452 450 embed: { 453 451 type: "union", ··· 511 509 type: "string", 512 510 maxLength: 300, 513 511 maxGraphemes: 300, 514 - required: true, 515 512 }, 516 - createdAt: { type: "string", format: "datetime", required: true }, 513 + createdAt: { type: "string", format: "datetime" }, 517 514 images: { 518 515 type: "array", 519 516 items: {
+14 -16
packages/prototypey/cli/tests/gen-from-json.test.ts
··· 170 170 parameters: { 171 171 type: "params", 172 172 properties: { 173 - q: { type: "string", required: true }, 173 + q: { type: "string" }, 174 174 limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, 175 175 cursor: { type: "string" }, 176 176 }, ··· 185 185 posts: { 186 186 type: "array", 187 187 items: { type: "ref", ref: "app.bsky.feed.defs#postView" }, 188 - required: true, 189 188 }, 190 189 }, 191 190 required: ["posts"], ··· 220 219 schema: { 221 220 type: "object", 222 221 properties: { 223 - repo: { type: "string", required: true }, 224 - collection: { type: "string", required: true }, 225 - record: { type: "unknown", required: true }, 222 + repo: { type: "string" }, 223 + collection: { type: "string" }, 224 + record: { type: "unknown" }, 226 225 }, 227 226 required: ["repo", "collection", "record"], 228 227 }, ··· 232 231 schema: { 233 232 type: "object", 234 233 properties: { 235 - uri: { type: "string", required: true }, 236 - cid: { type: "string", required: true }, 234 + uri: { type: "string" }, 235 + cid: { type: "string" }, 237 236 }, 238 237 required: ["uri", "cid"], 239 238 }, ··· 277 276 commit: { 278 277 type: "object", 279 278 properties: { 280 - seq: { type: "integer", required: true }, 281 - rebase: { type: "boolean", required: true }, 279 + seq: { type: "integer" }, 280 + rebase: { type: "boolean" }, 282 281 }, 283 282 required: ["seq", "rebase"], 284 283 }, 285 284 identity: { 286 285 type: "object", 287 286 properties: { 288 - seq: { type: "integer", required: true }, 289 - did: { type: "string", format: "did", required: true }, 287 + seq: { type: "integer" }, 288 + did: { type: "string", format: "did" }, 290 289 }, 291 290 required: ["seq", "did"], 292 291 }, 293 292 account: { 294 293 type: "object", 295 294 properties: { 296 - seq: { type: "integer", required: true }, 297 - active: { type: "boolean", required: true }, 295 + seq: { type: "integer" }, 296 + active: { type: "boolean" }, 298 297 }, 299 298 required: ["seq", "active"], 300 299 }, ··· 323 322 postView: { 324 323 type: "object", 325 324 properties: { 326 - uri: { type: "string", format: "at-uri", required: true }, 327 - cid: { type: "string", format: "cid", required: true }, 325 + uri: { type: "string", format: "at-uri" }, 326 + cid: { type: "string", format: "cid" }, 328 327 author: { 329 328 type: "ref", 330 329 ref: "app.bsky.actor.defs#profileViewBasic", 331 - required: true, 332 330 }, 333 331 embed: { 334 332 type: "union",
+2 -4
packages/prototypey/cli/tests/workflow.test.ts
··· 39 39 record: { 40 40 type: "object", 41 41 properties: { 42 - text: { type: "string", maxLength: 300, required: true }, 42 + text: { type: "string", maxLength: 300 }, 43 43 createdAt: { 44 44 type: "string", 45 45 format: "datetime", 46 - required: true, 47 46 }, 48 47 }, 49 48 }, ··· 68 67 parameters: { 69 68 type: "params", 70 69 properties: { 71 - q: { type: "string", required: true }, 70 + q: { type: "string" }, 72 71 limit: { 73 72 type: "integer", 74 73 minimum: 1, ··· 86 85 posts: { 87 86 type: "array", 88 87 items: { type: "ref", ref: "app.test.post#main" }, 89 - required: true, 90 88 }, 91 89 }, 92 90 required: ["posts"],
+92 -15
packages/prototypey/core/lib.ts
··· 4 4 import type { LexiconDoc, ValidationResult } from "@atproto/lexicon"; 5 5 import { Lexicons } from "@atproto/lexicon"; 6 6 7 + /** Runtime markers for property-level required/nullable on objects (avoids collision with array form) */ 8 + const PROP_REQUIRED = Symbol("required"); 9 + const PROP_NULLABLE = Symbol("nullable"); 10 + 7 11 /** @see https://atproto.com/specs/lexicon#overview-of-types */ 8 12 type LexiconType = 9 13 // Concrete types ··· 214 218 type ObjectOptions = { 215 219 /** Human-readable description of the object */ 216 220 description?: string; 221 + /** Indicates this object is a required property when nested in a parent object */ 222 + required?: boolean; 223 + /** Indicates this object can be explicitly set to null when nested in a parent object */ 224 + nullable?: boolean; 217 225 }; 218 226 219 227 type RequiredKeys<T> = { 220 - [K in keyof T]: T[K] extends { required: true } ? K : never; 228 + [K in keyof T]: T[K] extends { required: true } | { _required: true } 229 + ? K 230 + : never; 221 231 }[keyof T]; 222 232 223 233 type NullableKeys<T> = { 224 - [K in keyof T]: T[K] extends { nullable: true } ? K : never; 234 + [K in keyof T]: T[K] extends { nullable: true } | { _nullable: true } 235 + ? K 236 + : never; 225 237 }[keyof T]; 226 238 227 239 /** ··· 233 245 /** Property definitions */ 234 246 properties: { 235 247 [K in keyof T]: T[K] extends { type: "object" } 236 - ? T[K] 248 + ? Omit<T[K], "_required" | "_nullable"> 237 249 : Omit<T[K], "required" | "nullable">; 238 250 }; 239 251 } & ([RequiredKeys<T>] extends [never] ··· 242 254 ([NullableKeys<T>] extends [never] 243 255 ? {} 244 256 : { nullable: UnionToTuple<NullableKeys<T>> }) & 245 - O; 257 + (O extends { required: true } ? { _required: true } : {}) & 258 + (O extends { nullable: true } ? { _nullable: true } : {}) & 259 + Omit<O, "required" | "nullable">; 246 260 247 261 /** 248 262 * Map of parameter names to their lexicon item definitions. ··· 681 695 properties: T, 682 696 options?: O, 683 697 ): ObjectResult<T, O> { 684 - const required = Object.keys(properties).filter( 685 - (key) => "required" in properties[key] && properties[key].required, 686 - ); 687 - const nullable = Object.keys(properties).filter( 688 - (key) => "nullable" in properties[key] && properties[key].nullable, 689 - ); 690 - const result: Record<string, unknown> = { 698 + const { 699 + required: propRequired, 700 + nullable: propNullable, 701 + ...objectOptions 702 + } = (options ?? {}) as ObjectOptions; 703 + const required = Object.keys(properties).filter((key) => { 704 + const prop = properties[key] as Record<string | symbol, unknown>; 705 + return ( 706 + (typeof prop.required === "boolean" && prop.required) || 707 + prop[PROP_REQUIRED] === true 708 + ); 709 + }); 710 + const nullable = Object.keys(properties).filter((key) => { 711 + const prop = properties[key] as Record<string | symbol, unknown>; 712 + return ( 713 + (typeof prop.nullable === "boolean" && prop.nullable) || 714 + prop[PROP_NULLABLE] === true 715 + ); 716 + }); 717 + // Strip internal-only flags from properties 718 + const cleanedProperties: Record<string, unknown> = {}; 719 + for (const [key, value] of Object.entries(properties)) { 720 + const prop = { ...(value as Record<string | symbol, unknown>) }; 721 + if (typeof prop.required === "boolean") delete prop.required; 722 + if (typeof prop.nullable === "boolean") delete prop.nullable; 723 + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 724 + if (PROP_REQUIRED in prop) delete prop[PROP_REQUIRED]; 725 + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 726 + if (PROP_NULLABLE in prop) delete prop[PROP_NULLABLE]; 727 + cleanedProperties[key] = prop; 728 + } 729 + const result: Record<string | symbol, unknown> = { 691 730 type: "object", 692 - properties, 693 - ...options, 731 + properties: cleanedProperties, 732 + ...objectOptions, 694 733 }; 695 734 if (required.length > 0) { 696 735 result.required = required; ··· 698 737 if (nullable.length > 0) { 699 738 result.nullable = nullable; 700 739 } 740 + if (propRequired) result[PROP_REQUIRED] = true; 741 + if (propNullable) result[PROP_NULLABLE] = true; 701 742 return result as ObjectResult<T, O>; 702 743 }, 703 744 /** ··· 710 751 const required = Object.keys(properties).filter( 711 752 (key) => properties[key].required, 712 753 ); 754 + // Strip internal-only flags (required) from properties 755 + const cleanedProperties: Record<string, unknown> = {}; 756 + for (const [key, value] of Object.entries(properties)) { 757 + const { required: _r, ...rest } = value as Record<string, unknown>; 758 + cleanedProperties[key] = rest; 759 + } 713 760 const result: Record<string, unknown> = { 714 761 type: "params", 715 - properties, 762 + properties: cleanedProperties, 716 763 }; 717 764 if (required.length > 0) { 718 765 result.required = required; ··· 857 904 }, 858 905 }; 859 906 907 + /** 908 + * Recursively strips internal-only flags (required, nullable) from 909 + * individual properties in object/params nodes, matching what lx.object() 910 + * and lx.params() produce. 911 + */ 912 + function stripPropertyFlags( 913 + node: Record<string, unknown>, 914 + ): Record<string, unknown> { 915 + for (const [key, value] of Object.entries(node)) { 916 + if (value && typeof value === "object" && !Array.isArray(value)) { 917 + node[key] = stripPropertyFlags(value as Record<string, unknown>); 918 + } 919 + } 920 + if ( 921 + (node.type === "object" || node.type === "params") && 922 + node.properties && 923 + typeof node.properties === "object" 924 + ) { 925 + const props = node.properties as Record<string, Record<string, unknown>>; 926 + for (const prop of Object.values(props)) { 927 + delete prop.required; 928 + delete prop.nullable; 929 + } 930 + } 931 + return node; 932 + } 933 + 860 934 /** helper to pull lexicon from json directly */ 861 935 export function fromJSON<const Lex extends LexiconNamespace>(json: Lex) { 862 - return lx.lexicon<Lex["id"], Lex["defs"]>(json.id, json.defs); 936 + const defs = stripPropertyFlags( 937 + structuredClone(json.defs) as Record<string, unknown>, 938 + ) as Lex["defs"]; 939 + return lx.lexicon<Lex["id"], Lex["defs"]>(json.id, defs); 863 940 }
+22 -40
packages/prototypey/core/tests/bsky-actor.test.ts
··· 19 19 expect(profileViewBasic).toEqual({ 20 20 type: "object", 21 21 properties: { 22 - did: { type: "string", required: true, format: "did" }, 23 - handle: { type: "string", required: true, format: "handle" }, 22 + did: { type: "string", format: "did" }, 23 + handle: { type: "string", format: "handle" }, 24 24 displayName: { type: "string", maxGraphemes: 64, maxLength: 640 }, 25 25 pronouns: { type: "string" }, 26 26 avatar: { type: "string", format: "uri" }, ··· 58 58 expect(profileView).toEqual({ 59 59 type: "object", 60 60 properties: { 61 - did: { type: "string", required: true, format: "did" }, 62 - handle: { type: "string", required: true, format: "handle" }, 61 + did: { type: "string", format: "did" }, 62 + handle: { type: "string", format: "handle" }, 63 63 displayName: { type: "string", maxGraphemes: 64, maxLength: 640 }, 64 64 pronouns: { type: "string" }, 65 65 description: { type: "string", maxGraphemes: 256, maxLength: 2560 }, ··· 106 106 expect(profileViewDetailed).toEqual({ 107 107 type: "object", 108 108 properties: { 109 - did: { type: "string", required: true, format: "did" }, 110 - handle: { type: "string", required: true, format: "handle" }, 109 + did: { type: "string", format: "did" }, 110 + handle: { type: "string", format: "handle" }, 111 111 displayName: { type: "string", maxGraphemes: 64, maxLength: 640 }, 112 112 description: { type: "string", maxGraphemes: 256, maxLength: 2560 }, 113 113 pronouns: { type: "string" }, ··· 176 176 properties: { 177 177 allowIncoming: { 178 178 type: "string", 179 - required: true, 180 179 knownValues: ["all", "none", "following"], 181 180 }, 182 181 }, ··· 197 196 properties: { 198 197 allowSubscriptions: { 199 198 type: "string", 200 - required: true, 201 199 knownValues: ["followers", "mutuals", "none"], 202 200 }, 203 201 }, ··· 252 250 expect(knownFollowers).toEqual({ 253 251 type: "object", 254 252 properties: { 255 - count: { type: "integer", required: true }, 253 + count: { type: "integer" }, 256 254 followers: { 257 255 type: "array", 258 256 items: { type: "ref", ref: "#profileViewBasic" }, 259 - required: true, 260 257 minLength: 0, 261 258 maxLength: 5, 262 259 }, ··· 284 281 verifications: { 285 282 type: "array", 286 283 items: { type: "ref", ref: "#verificationView" }, 287 - required: true, 288 284 }, 289 285 verifiedStatus: { 290 286 type: "string", 291 - required: true, 292 287 knownValues: ["valid", "invalid", "none"], 293 288 }, 294 289 trustedVerifierStatus: { 295 290 type: "string", 296 - required: true, 297 291 knownValues: ["valid", "invalid", "none"], 298 292 }, 299 293 }, ··· 312 306 expect(verificationView).toEqual({ 313 307 type: "object", 314 308 properties: { 315 - issuer: { type: "string", required: true, format: "did" }, 316 - uri: { type: "string", required: true, format: "at-uri" }, 317 - isValid: { type: "boolean", required: true }, 318 - createdAt: { type: "string", required: true, format: "datetime" }, 309 + issuer: { type: "string", format: "did" }, 310 + uri: { type: "string", format: "at-uri" }, 311 + isValid: { type: "boolean" }, 312 + createdAt: { type: "string", format: "datetime" }, 319 313 }, 320 314 required: ["issuer", "uri", "isValid", "createdAt"], 321 315 }); ··· 373 367 expect(adultContentPref).toEqual({ 374 368 type: "object", 375 369 properties: { 376 - enabled: { type: "boolean", required: true, default: false }, 370 + enabled: { type: "boolean", default: false }, 377 371 }, 378 372 required: ["enabled"], 379 373 }); ··· 393 387 type: "object", 394 388 properties: { 395 389 labelerDid: { type: "string", format: "did" }, 396 - label: { type: "string", required: true }, 390 + label: { type: "string" }, 397 391 visibility: { 398 392 type: "string", 399 - required: true, 400 393 knownValues: ["ignore", "show", "warn", "hide"], 401 394 }, 402 395 }, ··· 418 411 expect(savedFeed).toEqual({ 419 412 type: "object", 420 413 properties: { 421 - id: { type: "string", required: true }, 414 + id: { type: "string" }, 422 415 type: { 423 416 type: "string", 424 - required: true, 425 417 knownValues: ["feed", "list", "timeline"], 426 418 }, 427 - value: { type: "string", required: true }, 428 - pinned: { type: "boolean", required: true }, 419 + value: { type: "string" }, 420 + pinned: { type: "boolean" }, 429 421 }, 430 422 required: ["id", "type", "value", "pinned"], 431 423 }); ··· 444 436 items: { 445 437 type: "array", 446 438 items: { type: "ref", ref: "app.bsky.actor.defs#savedFeed" }, 447 - required: true, 448 439 }, 449 440 }, 450 441 required: ["items"], ··· 464 455 pinned: { 465 456 type: "array", 466 457 items: { type: "string", format: "at-uri" }, 467 - required: true, 468 458 }, 469 459 saved: { 470 460 type: "array", 471 461 items: { type: "string", format: "at-uri" }, 472 - required: true, 473 462 }, 474 463 timelineIndex: { type: "integer" }, 475 464 }, ··· 503 492 expect(feedViewPref).toEqual({ 504 493 type: "object", 505 494 properties: { 506 - feed: { type: "string", required: true }, 495 + feed: { type: "string" }, 507 496 hideReplies: { type: "boolean" }, 508 497 hideRepliesByUnfollowed: { type: "boolean", default: true }, 509 498 hideRepliesByLikeCount: { type: "integer" }, ··· 548 537 tags: { 549 538 type: "array", 550 539 items: { type: "string", maxLength: 640, maxGraphemes: 64 }, 551 - required: true, 552 540 maxLength: 100, 553 541 }, 554 542 }, ··· 591 579 id: { type: "string" }, 592 580 value: { 593 581 type: "string", 594 - required: true, 595 582 maxLength: 10000, 596 583 maxGraphemes: 1000, 597 584 }, 598 585 targets: { 599 586 type: "array", 600 587 items: { type: "ref", ref: "app.bsky.actor.defs#mutedWordTarget" }, 601 - required: true, 602 588 }, 603 589 actorTarget: { 604 590 type: "string", ··· 624 610 items: { 625 611 type: "array", 626 612 items: { type: "ref", ref: "app.bsky.actor.defs#mutedWord" }, 627 - required: true, 628 613 }, 629 614 }, 630 615 required: ["items"], ··· 642 627 items: { 643 628 type: "array", 644 629 items: { type: "string", format: "at-uri" }, 645 - required: true, 646 630 }, 647 631 }, 648 632 required: ["items"], ··· 660 644 labelers: { 661 645 type: "array", 662 646 items: { type: "ref", ref: "#labelerPrefItem" }, 663 - required: true, 664 647 }, 665 648 }, 666 649 required: ["labelers"], ··· 675 658 expect(labelerPrefItem).toEqual({ 676 659 type: "object", 677 660 properties: { 678 - did: { type: "string", required: true, format: "did" }, 661 + did: { type: "string", format: "did" }, 679 662 }, 680 663 required: ["did"], 681 664 }); ··· 714 697 expect(bskyAppProgressGuide).toEqual({ 715 698 type: "object", 716 699 properties: { 717 - guide: { type: "string", required: true, maxLength: 100 }, 700 + guide: { type: "string", maxLength: 100 }, 718 701 }, 719 702 required: ["guide"], 720 703 }); ··· 731 714 expect(nux).toEqual({ 732 715 type: "object", 733 716 properties: { 734 - id: { type: "string", required: true, maxLength: 100 }, 735 - completed: { type: "boolean", required: true, default: false }, 717 + id: { type: "string", maxLength: 100 }, 718 + completed: { type: "boolean", default: false }, 736 719 data: { type: "string", maxLength: 3000, maxGraphemes: 300 }, 737 720 expiresAt: { type: "string", format: "datetime" }, 738 721 }, ··· 815 798 properties: { 816 799 status: { 817 800 type: "string", 818 - required: true, 819 801 knownValues: ["app.bsky.actor.status#live"], 820 802 }, 821 - record: { type: "unknown", required: true }, 803 + record: { type: "unknown" }, 822 804 embed: { 823 805 type: "union", 824 806 refs: ["app.bsky.embed.external#view"],
+20 -25
packages/prototypey/core/tests/bsky-feed.test.ts
··· 28 28 expect(postView).toEqual({ 29 29 type: "object", 30 30 properties: { 31 - uri: { type: "string", required: true, format: "at-uri" }, 32 - cid: { type: "string", required: true, format: "cid" }, 31 + uri: { type: "string", format: "at-uri" }, 32 + cid: { type: "string", format: "cid" }, 33 33 author: { 34 34 type: "ref", 35 35 ref: "app.bsky.actor.defs#profileViewBasic", 36 - required: true, 37 36 }, 38 - record: { type: "unknown", required: true }, 37 + record: { type: "unknown" }, 39 38 embed: { 40 39 type: "union", 41 40 refs: [ ··· 51 50 repostCount: { type: "integer" }, 52 51 likeCount: { type: "integer" }, 53 52 quoteCount: { type: "integer" }, 54 - indexedAt: { type: "string", required: true, format: "datetime" }, 53 + indexedAt: { type: "string", format: "datetime" }, 55 54 viewer: { type: "ref", ref: "#viewerState" }, 56 55 labels: { 57 56 type: "array", ··· 113 112 expect(feedViewPost).toEqual({ 114 113 type: "object", 115 114 properties: { 116 - post: { type: "ref", ref: "#postView", required: true }, 115 + post: { type: "ref", ref: "#postView" }, 117 116 reply: { type: "ref", ref: "#replyRef" }, 118 117 reason: { 119 118 type: "union", ··· 143 142 root: { 144 143 type: "union", 145 144 refs: ["#postView", "#notFoundPost", "#blockedPost"], 146 - required: true, 147 145 }, 148 146 parent: { 149 147 type: "union", 150 148 refs: ["#postView", "#notFoundPost", "#blockedPost"], 151 - required: true, 152 149 }, 153 150 grandparentAuthor: { 154 151 type: "ref", ··· 173 170 by: { 174 171 type: "ref", 175 172 ref: "app.bsky.actor.defs#profileViewBasic", 176 - required: true, 177 173 }, 178 174 uri: { type: "string", format: "at-uri" }, 179 175 cid: { type: "string", format: "cid" }, 180 - indexedAt: { type: "string", required: true, format: "datetime" }, 176 + indexedAt: { type: "string", format: "datetime" }, 181 177 }, 182 178 required: ["by", "indexedAt"], 183 179 }); ··· 205 201 expect(threadViewPost).toEqual({ 206 202 type: "object", 207 203 properties: { 208 - post: { type: "ref", ref: "#postView", required: true }, 204 + post: { type: "ref", ref: "#postView" }, 209 205 parent: { 210 206 type: "union", 211 207 refs: ["#threadViewPost", "#notFoundPost", "#blockedPost"], ··· 232 228 expect(notFoundPost).toEqual({ 233 229 type: "object", 234 230 properties: { 235 - uri: { type: "string", required: true, format: "at-uri" }, 236 - notFound: { type: "boolean", required: true, const: true }, 231 + uri: { type: "string", format: "at-uri" }, 232 + notFound: { type: "boolean", const: true }, 237 233 }, 238 234 required: ["uri", "notFound"], 239 235 }); ··· 249 245 expect(blockedPost).toEqual({ 250 246 type: "object", 251 247 properties: { 252 - uri: { type: "string", required: true, format: "at-uri" }, 253 - blocked: { type: "boolean", required: true, const: true }, 254 - author: { type: "ref", ref: "#blockedAuthor", required: true }, 248 + uri: { type: "string", format: "at-uri" }, 249 + blocked: { type: "boolean", const: true }, 250 + author: { type: "ref", ref: "#blockedAuthor" }, 255 251 }, 256 252 required: ["uri", "blocked", "author"], 257 253 }); ··· 266 262 expect(blockedAuthor).toEqual({ 267 263 type: "object", 268 264 properties: { 269 - did: { type: "string", required: true, format: "did" }, 265 + did: { type: "string", format: "did" }, 270 266 viewer: { type: "ref", ref: "app.bsky.actor.defs#viewerState" }, 271 267 }, 272 268 required: ["did"], ··· 299 295 expect(generatorView).toEqual({ 300 296 type: "object", 301 297 properties: { 302 - uri: { type: "string", required: true, format: "at-uri" }, 303 - cid: { type: "string", required: true, format: "cid" }, 304 - did: { type: "string", required: true, format: "did" }, 298 + uri: { type: "string", format: "at-uri" }, 299 + cid: { type: "string", format: "cid" }, 300 + did: { type: "string", format: "did" }, 305 301 creator: { 306 302 type: "ref", 307 303 ref: "app.bsky.actor.defs#profileView", 308 - required: true, 309 304 }, 310 - displayName: { type: "string", required: true }, 305 + displayName: { type: "string" }, 311 306 description: { type: "string", maxGraphemes: 300, maxLength: 3000 }, 312 307 descriptionFacets: { 313 308 type: "array", ··· 328 323 "app.bsky.feed.defs#contentModeVideo", 329 324 ], 330 325 }, 331 - indexedAt: { type: "string", required: true, format: "datetime" }, 326 + indexedAt: { type: "string", format: "datetime" }, 332 327 }, 333 328 required: ["uri", "cid", "did", "creator", "displayName", "indexedAt"], 334 329 }); ··· 357 352 expect(skeletonFeedPost).toEqual({ 358 353 type: "object", 359 354 properties: { 360 - post: { type: "string", required: true, format: "at-uri" }, 355 + post: { type: "string", format: "at-uri" }, 361 356 reason: { 362 357 type: "union", 363 358 refs: ["#skeletonReasonRepost", "#skeletonReasonPin"], ··· 376 371 expect(skeletonReasonRepost).toEqual({ 377 372 type: "object", 378 373 properties: { 379 - repost: { type: "string", required: true, format: "at-uri" }, 374 + repost: { type: "string", format: "at-uri" }, 380 375 }, 381 376 required: ["repost"], 382 377 });
+39 -12
packages/prototypey/core/tests/infer.bench.ts
··· 10 10 }), 11 11 }); 12 12 return schema["~infer"]; 13 - }).types([685, "instantiations"]); 13 + }).types([803, "instantiations"]); 14 14 15 15 bench("infer with complex nested structure", () => { 16 16 const schema = lx.lexicon("test.complex", { ··· 33 33 }), 34 34 }); 35 35 return schema["~infer"]; 36 - }).types([956, "instantiations"]); 36 + }).types([1110, "instantiations"]); 37 37 38 38 bench("infer with circular reference", () => { 39 39 const ns = lx.lexicon("test", { ··· 50 50 }), 51 51 }); 52 52 return ns["~infer"]; 53 - }).types([634, "instantiations"]); 53 + }).types([781, "instantiations"]); 54 54 55 55 bench("infer with app.bsky.feed.defs lexicon", () => { 56 56 const schema = lx.lexicon("app.bsky.feed.defs", { ··· 117 117 interactionShare: lx.token("User shared the feed item"), 118 118 }); 119 119 return schema["~infer"]; 120 - }).types([1237, "instantiations"]); 120 + }).types([1437, "instantiations"]); 121 + 122 + bench("infer with required/nullable nested objects", () => { 123 + const schema = lx.lexicon("test.nestedFlags", { 124 + main: lx.object({ 125 + profile: lx.object( 126 + { 127 + name: lx.string({ required: true }), 128 + bio: lx.string(), 129 + }, 130 + { required: true }, 131 + ), 132 + settings: lx.object( 133 + { 134 + theme: lx.string(), 135 + }, 136 + { nullable: true }, 137 + ), 138 + metadata: lx.object( 139 + { 140 + source: lx.string({ required: true }), 141 + }, 142 + { required: true, nullable: true }, 143 + ), 144 + }), 145 + }); 146 + return schema["~infer"]; 147 + }).types([1334, "instantiations"]); 121 148 122 149 bench("fromJSON infer with simple object", () => { 123 150 const schema = fromJSON({ ··· 134 161 }, 135 162 }); 136 163 return schema["~infer"]; 137 - }).types([438, "instantiations"]); 164 + }).types([504, "instantiations"]); 138 165 139 166 bench("fromJSON infer with complex nested structure", () => { 140 167 const schema = fromJSON({ ··· 177 204 }, 178 205 }); 179 206 return schema["~infer"]; 180 - }).types([499, "instantiations"]); 207 + }).types([565, "instantiations"]); 181 208 182 209 bench("fromJSON infer with circular reference", () => { 183 210 const ns = fromJSON({ ··· 208 235 }, 209 236 }); 210 237 return ns["~infer"]; 211 - }).types([411, "instantiations"]); 238 + }).types([477, "instantiations"]); 212 239 213 240 bench("fromJSON infer with app.bsky.feed.defs lexicon", () => { 214 241 const schema = fromJSON({ ··· 325 352 }, 326 353 }); 327 354 return schema["~infer"]; 328 - }).types([513, "instantiations"]); 355 + }).types([579, "instantiations"]); 329 356 330 357 bench("infer with simple permission set", () => { 331 358 const schema = lx.lexicon("com.example.authCore", { ··· 341 368 }), 342 369 }); 343 370 return schema["~infer"]; 344 - }).types([271, "instantiations"]); 371 + }).types([312, "instantiations"]); 345 372 346 373 bench("infer with complex permission set", () => { 347 374 const schema = lx.lexicon("com.example.fullPerms", { ··· 371 398 }), 372 399 }); 373 400 return schema["~infer"]; 374 - }).types([277, "instantiations"]); 401 + }).types([318, "instantiations"]); 375 402 376 403 bench("fromJSON infer with simple permission set", () => { 377 404 const schema = fromJSON({ ··· 394 421 }, 395 422 }); 396 423 return schema["~infer"]; 397 - }).types([288, "instantiations"]); 424 + }).types([329, "instantiations"]); 398 425 399 426 bench("fromJSON infer with complex permission set", () => { 400 427 const schema = fromJSON({ ··· 439 466 }, 440 467 }); 441 468 return schema["~infer"]; 442 - }).types([336, "instantiations"]); 469 + }).types([377, "instantiations"]);
+48
packages/prototypey/core/tests/infer.test.ts
··· 560 560 }`); 561 561 }); 562 562 563 + test("InferObject handles required nested object", () => { 564 + const lexicon = lx.lexicon("test.requiredNested", { 565 + main: lx.object({ 566 + user: lx.object( 567 + { 568 + name: lx.string({ required: true }), 569 + email: lx.string(), 570 + }, 571 + { required: true }, 572 + ), 573 + }), 574 + }); 575 + 576 + attest(lexicon["~infer"]).type.toString.snap(`{ 577 + $type: "test.requiredNested" 578 + user: { email?: string | undefined; name: string } 579 + }`); 580 + }); 581 + 582 + test("InferObject handles nullable nested object", () => { 583 + const lexicon = lx.lexicon("test.nullableNested", { 584 + main: lx.object({ 585 + meta: lx.object({ tag: lx.string() }, { nullable: true }), 586 + }), 587 + }); 588 + 589 + attest(lexicon["~infer"]).type.toString.snap(`{ 590 + $type: "test.nullableNested" 591 + meta?: { tag?: string | undefined } | null | undefined 592 + }`); 593 + }); 594 + 595 + test("InferObject handles required+nullable nested object", () => { 596 + const lexicon = lx.lexicon("test.requiredNullableNested", { 597 + main: lx.object({ 598 + data: lx.object( 599 + { value: lx.string() }, 600 + { required: true, nullable: true }, 601 + ), 602 + }), 603 + }); 604 + 605 + attest(lexicon["~infer"]).type.toString.snap(`{ 606 + $type: "test.requiredNullableNested" 607 + data: { value?: string | undefined } | null 608 + }`); 609 + }); 610 + 563 611 // ============================================================================ 564 612 // NESTED ARRAYS TESTS 565 613 // ============================================================================
+72 -16
packages/prototypey/core/tests/primitives.test.ts
··· 232 232 type: "object", 233 233 description: "User profile object", 234 234 properties: { 235 - id: { type: "string", required: true }, 235 + id: { type: "string" }, 236 236 name: { type: "string" }, 237 237 }, 238 238 required: ["id"], ··· 250 250 type: "object", 251 251 description: "Optional profile fields", 252 252 properties: { 253 - bio: { type: "string", nullable: true }, 253 + bio: { type: "string" }, 254 254 }, 255 255 nullable: ["bio"], 256 256 }); 257 257 }); 258 258 259 + test("lx.object() with required option marks object as required in parent", () => { 260 + const result = lx.object({ 261 + foo: lx.object({ bar: lx.string({ required: true }) }, { required: true }), 262 + }); 263 + expect(result).toEqual({ 264 + type: "object", 265 + required: ["foo"], 266 + properties: { 267 + foo: { 268 + type: "object", 269 + required: ["bar"], 270 + properties: { 271 + bar: { type: "string" }, 272 + }, 273 + }, 274 + }, 275 + }); 276 + }); 277 + 278 + test("lx.object() with nullable option marks object as nullable in parent", () => { 279 + const result = lx.object({ 280 + foo: lx.object({ bar: lx.string() }, { nullable: true }), 281 + }); 282 + expect(result).toEqual({ 283 + type: "object", 284 + nullable: ["foo"], 285 + properties: { 286 + foo: { 287 + type: "object", 288 + properties: { 289 + bar: { type: "string" }, 290 + }, 291 + }, 292 + }, 293 + }); 294 + }); 295 + 296 + test("lx.object() nested with own required fields is not falsely required in parent", () => { 297 + const result = lx.object({ 298 + foo: lx.object({ 299 + bar: lx.string({ required: true }), 300 + }), 301 + }); 302 + // foo should NOT appear in parent's required array 303 + expect(result).toEqual({ 304 + type: "object", 305 + properties: { 306 + foo: { 307 + type: "object", 308 + required: ["bar"], 309 + properties: { 310 + bar: { type: "string" }, 311 + }, 312 + }, 313 + }, 314 + }); 315 + }); 316 + 259 317 test("lx.token() with interaction event", () => { 260 318 const result = lx.token( 261 319 "Request that less content like the given feed item be shown in the feed", ··· 397 455 expect(result).toEqual({ 398 456 type: "params", 399 457 properties: { 400 - q: { type: "string", required: true }, 458 + q: { type: "string" }, 401 459 limit: { type: "integer" }, 402 460 }, 403 461 required: ["q"], ··· 443 501 expect(result).toEqual({ 444 502 type: "params", 445 503 properties: { 446 - q: { type: "string", required: true }, 504 + q: { type: "string" }, 447 505 limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, 448 506 cursor: { type: "string" }, 449 507 }, ··· 473 531 parameters: { 474 532 type: "params", 475 533 properties: { 476 - q: { type: "string", required: true }, 534 + q: { type: "string" }, 477 535 limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, 478 536 }, 479 537 required: ["q"], ··· 503 561 posts: { 504 562 type: "array", 505 563 items: { type: "ref", ref: "app.bsky.feed.defs#postView" }, 506 - required: true, 507 564 }, 508 565 cursor: { type: "string" }, 509 566 }, ··· 550 607 parameters: { 551 608 type: "params", 552 609 properties: { 553 - q: { type: "string", required: true }, 610 + q: { type: "string" }, 554 611 sort: { type: "string", enum: ["top", "latest"], default: "latest" }, 555 612 limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, 556 613 cursor: { type: "string" }, ··· 567 624 posts: { 568 625 type: "array", 569 626 items: { type: "ref", ref: "app.bsky.feed.defs#postView" }, 570 - required: true, 571 627 }, 572 628 }, 573 629 required: ["posts"], ··· 624 680 schema: { 625 681 type: "object", 626 682 properties: { 627 - text: { type: "string", required: true, maxGraphemes: 300 }, 683 + text: { type: "string", maxGraphemes: 300 }, 628 684 createdAt: { type: "string", format: "datetime" }, 629 685 }, 630 686 required: ["text"], ··· 650 706 schema: { 651 707 type: "object", 652 708 properties: { 653 - uri: { type: "string", required: true }, 654 - cid: { type: "string", required: true }, 709 + uri: { type: "string" }, 710 + cid: { type: "string" }, 655 711 }, 656 712 required: ["uri", "cid"], 657 713 }, ··· 704 760 schema: { 705 761 type: "object", 706 762 properties: { 707 - repo: { type: "string", required: true }, 708 - collection: { type: "string", required: true }, 709 - record: { type: "unknown", required: true }, 763 + repo: { type: "string" }, 764 + collection: { type: "string" }, 765 + record: { type: "unknown" }, 710 766 validate: { type: "boolean", default: true }, 711 767 }, 712 768 required: ["repo", "collection", "record"], ··· 717 773 schema: { 718 774 type: "object", 719 775 properties: { 720 - uri: { type: "string", required: true }, 721 - cid: { type: "string", required: true }, 776 + uri: { type: "string" }, 777 + cid: { type: "string" }, 722 778 }, 723 779 required: ["uri", "cid"], 724 780 },