···5export * as Binding from "./inlay/Binding";
6export * as pack from "./inlay/pack";
7export * as Fragment from "./inlay/Fragment";
08export * as Maybe from "./inlay/Maybe";
9export * as Missing from "./inlay/Missing";
10-export * as Loading from "./inlay/Loading";
11export * as Slot from "./inlay/Slot";
12export * as Throw from "./inlay/Throw";
13export * as component from "./inlay/component";
···5export * as Binding from "./inlay/Binding";
6export * as pack from "./inlay/pack";
7export * as Fragment from "./inlay/Fragment";
8+export * as Loading from "./inlay/Loading";
9export * as Maybe from "./inlay/Maybe";
10export * as Missing from "./inlay/Missing";
011export * as Slot from "./inlay/Slot";
12export * as Throw from "./inlay/Throw";
13export * as component from "./inlay/component";
+30-67
generated/at/inlay/component.defs.ts
···27 | l.Unknown$TypedObject;
2829 /**
30- * What kinds of pages this component is a view for
31 */
32 view?: (
33 | l.$Typed<ViewRecord>
34- | l.$Typed<ViewCollection>
35- | l.$Typed<ViewIdentity>
36 | l.Unknown$TypedObject
37 )[];
38-39- /**
40- * Data types this component can render. Used for data-driven discovery.
41- */
42- accepts?: AcceptsEntry[];
4344 /**
45 * Ordered list of pack URIs (import stack). First pack that exports an NSID wins.
···81 l.typedUnion(
82 [
83 l.typedRef<ViewRecord>((() => viewRecord) as any),
84- l.typedRef<ViewCollection>((() => viewCollection) as any),
85- l.typedRef<ViewIdentity>((() => viewIdentity) as any),
86 ],
87 false
88 )
89 )
90- ),
91- accepts: l.optional(
92- l.array(l.ref<AcceptsEntry>((() => acceptsEntry) as any))
93 ),
94 imports: l.optional(l.array(l.string({ format: "at-uri" }))),
95 via: l.optional(
···161162export { bodyTemplate };
163164-/** Component is a view for individual records of a collection. Omit collection for a generic record view. */
165type ViewRecord = {
166 $type?: "at.inlay.component#viewRecord";
167···169 * The collection this component views. Omit for any-collection.
170 */
171 collection?: l.NsidString;
0000000000172};
173174export type { ViewRecord };
175176-/** Component is a view for individual records of a collection. Omit collection for a generic record view. */
177const viewRecord = l.typedObject<ViewRecord>(
178 $nsid,
179 "viewRecord",
180- l.object({ collection: l.optional(l.string({ format: "nsid" })) })
0000181);
182183export { viewRecord };
184185-/** Component is a view for a collection listing. Omit collection for a generic collection view. */
186-type ViewCollection = {
187- $type?: "at.inlay.component#viewCollection";
188189 /**
190- * The collection this component lists. Omit for any-collection.
191- */
192- collection?: l.NsidString;
193-};
194-195-export type { ViewCollection };
196-197-/** Component is a view for a collection listing. Omit collection for a generic collection view. */
198-const viewCollection = l.typedObject<ViewCollection>(
199- $nsid,
200- "viewCollection",
201- l.object({ collection: l.optional(l.string({ format: "nsid" })) })
202-);
203-204-export { viewCollection };
205-206-/** Component is a view for an identity (person/DID). */
207-type ViewIdentity = { $type?: "at.inlay.component#viewIdentity" };
208-209-export type { ViewIdentity };
210-211-/** Component is a view for an identity (person/DID). */
212-const viewIdentity = l.typedObject<ViewIdentity>(
213- $nsid,
214- "viewIdentity",
215- l.object({})
216-);
217-218-export { viewIdentity };
219-220-/** Declares a data type this component can handle, routed to a specific prop. */
221-type AcceptsEntry = {
222- $type?: "at.inlay.component#acceptsEntry";
223-224- /**
225- * Lexicon field type.
226 */
227 type:
228 | "string"
···251 | l.UnknownString;
252253 /**
254- * Prop to bind matched data to.
255 */
256 prop: string;
257-258- /**
259- * For at-uri strings, restricts to this collection.
260- */
261- collection?: l.NsidString;
262};
263264-export type { AcceptsEntry };
265266-/** Declares a data type this component can handle, routed to a specific prop. */
267-const acceptsEntry = l.typedObject<AcceptsEntry>(
268 $nsid,
269- "acceptsEntry",
270 l.object({
271 type: l.string<{
272 maxLength: 128;
···298 }>({ maxLength: 64 })
299 ),
300 prop: l.string({ maxLength: 256 }),
301- collection: l.optional(l.string({ format: "nsid" })),
302 })
303);
304305-export { acceptsEntry };
···27 | l.Unknown$TypedObject;
2829 /**
30+ * What data types this component is a view for
31 */
32 view?: (
33 | l.$Typed<ViewRecord>
34+ | l.$Typed<ViewPrimitive>
035 | l.Unknown$TypedObject
36 )[];
000003738 /**
39 * Ordered list of pack URIs (import stack). First pack that exports an NSID wins.
···75 l.typedUnion(
76 [
77 l.typedRef<ViewRecord>((() => viewRecord) as any),
78+ l.typedRef<ViewPrimitive>((() => viewPrimitive) as any),
079 ],
80 false
81 )
82 )
00083 ),
84 imports: l.optional(l.array(l.string({ format: "at-uri" }))),
85 via: l.optional(
···151152export { bodyTemplate };
153154+/** Component is a view for individual records of a collection. Omit collection for a generic record view. When rkey is present, the component accepts bare DIDs (expanded to full AT URIs) and appears on identity pages. */
155type ViewRecord = {
156 $type?: "at.inlay.component#viewRecord";
157···159 * The collection this component views. Omit for any-collection.
160 */
161 collection?: l.NsidString;
162+163+ /**
164+ * The record key, baked from the collection's lexicon at authoring time. Presence enables DID expansion and identity page routing.
165+ */
166+ rkey?: string;
167+168+ /**
169+ * Which prop receives the AT URI.
170+ */
171+ prop: string;
172};
173174export type { ViewRecord };
175176+/** Component is a view for individual records of a collection. Omit collection for a generic record view. When rkey is present, the component accepts bare DIDs (expanded to full AT URIs) and appears on identity pages. */
177const viewRecord = l.typedObject<ViewRecord>(
178 $nsid,
179 "viewRecord",
180+ l.object({
181+ collection: l.optional(l.string({ format: "nsid" })),
182+ rkey: l.optional(l.string({ maxLength: 512 })),
183+ prop: l.string({ maxLength: 256 }),
184+ })
185);
186187export { viewRecord };
188189+/** Component is a view for a primitive value type. */
190+type ViewPrimitive = {
191+ $type?: "at.inlay.component#viewPrimitive";
192193 /**
194+ * Lexicon primitive type.
00000000000000000000000000000000000195 */
196 type:
197 | "string"
···220 | l.UnknownString;
221222 /**
223+ * Which prop receives the value.
224 */
225 prop: string;
00000226};
227228+export type { ViewPrimitive };
229230+/** Component is a view for a primitive value type. */
231+const viewPrimitive = l.typedObject<ViewPrimitive>(
232 $nsid,
233+ "viewPrimitive",
234 l.object({
235 type: l.string<{
236 maxLength: 128;
···262 }>({ maxLength: 64 })
263 ),
264 prop: l.string({ maxLength: 256 }),
0265 })
266);
267268+export { viewPrimitive };
+17-35
lexicons/at/inlay/component.json
···24 "type": "array",
25 "items": {
26 "type": "union",
27- "refs": ["#viewRecord", "#viewCollection", "#viewIdentity"]
28- },
29- "description": "What kinds of pages this component is a view for"
30- },
31- "accepts": {
32- "type": "array",
33- "items": {
34- "type": "ref",
35- "ref": "#acceptsEntry"
36 },
37- "description": "Data types this component can render. Used for data-driven discovery."
38 },
39 "imports": {
40 "type": "array",
···91 },
92 "viewRecord": {
93 "type": "object",
94- "description": "Component is a view for individual records of a collection. Omit collection for a generic record view.",
095 "properties": {
96 "collection": {
97 "type": "string",
98 "format": "nsid",
99 "description": "The collection this component views. Omit for any-collection."
100- }
101- }
102- },
103- "viewCollection": {
104- "type": "object",
105- "description": "Component is a view for a collection listing. Omit collection for a generic collection view.",
106- "properties": {
107- "collection": {
108 "type": "string",
109- "format": "nsid",
110- "description": "The collection this component lists. Omit for any-collection."
00000111 }
112 }
113 },
114- "viewIdentity": {
115 "type": "object",
116- "description": "Component is a view for an identity (person/DID).",
117- "properties": {}
118- },
119- "acceptsEntry": {
120- "type": "object",
121- "description": "Declares a data type this component can handle, routed to a specific prop.",
122 "required": ["type", "prop"],
123 "properties": {
124 "type": {
125 "type": "string",
126 "maxLength": 128,
127- "description": "Lexicon field type.",
128 "knownValues": [
129 "string",
130 "integer",
···155 "prop": {
156 "type": "string",
157 "maxLength": 256,
158- "description": "Prop to bind matched data to."
159- },
160- "collection": {
161- "type": "string",
162- "format": "nsid",
163- "description": "For at-uri strings, restricts to this collection."
164 }
165 }
166 }
···24 "type": "array",
25 "items": {
26 "type": "union",
27+ "refs": ["#viewRecord", "#viewPrimitive"]
0000000028 },
29+ "description": "What data types this component is a view for"
30 },
31 "imports": {
32 "type": "array",
···83 },
84 "viewRecord": {
85 "type": "object",
86+ "description": "Component is a view for individual records of a collection. Omit collection for a generic record view. When rkey is present, the component accepts bare DIDs (expanded to full AT URIs) and appears on identity pages.",
87+ "required": ["prop"],
88 "properties": {
89 "collection": {
90 "type": "string",
91 "format": "nsid",
92 "description": "The collection this component views. Omit for any-collection."
93+ },
94+ "rkey": {
00000095 "type": "string",
96+ "maxLength": 512,
97+ "description": "The record key, baked from the collection's lexicon at authoring time. Presence enables DID expansion and identity page routing."
98+ },
99+ "prop": {
100+ "type": "string",
101+ "maxLength": 256,
102+ "description": "Which prop receives the AT URI."
103 }
104 }
105 },
106+ "viewPrimitive": {
107 "type": "object",
108+ "description": "Component is a view for a primitive value type.",
00000109 "required": ["type", "prop"],
110 "properties": {
111 "type": {
112 "type": "string",
113 "maxLength": 128,
114+ "description": "Lexicon primitive type.",
115 "knownValues": [
116 "string",
117 "integer",
···142 "prop": {
143 "type": "string",
144 "maxLength": 256,
145+ "description": "Which prop receives the value."
00000146 }
147 }
148 }
+77-81
packages/@inlay/render/src/index.ts
···19import { validateProps } from "./validate.js";
2021import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js";
022import type { Main as PackRecord } from "../../../../generated/at/inlay/pack.defs.js";
23import type { CachePolicy } from "../../../../generated/at/inlay/defs.defs.js";
24···204export function resolvePath(obj: unknown, path: string[]): unknown {
205 let current: unknown = obj;
206 for (const seg of path) {
207- if (current == null || typeof current !== "object") return undefined;
0000000000000000208 if (!Object.hasOwn(current, seg)) return undefined;
209 current = (current as Record<string, unknown>)[seg];
210 }
···240 const type = element.type;
241 let resolvedProps = props;
242 if (component.view) {
243- resolvedProps = await expandBareDid(props, component, resolver);
244 }
245246 if (validate) {
···332 return out;
333 }) as Record<string, unknown>;
334335- let scope: Record<string, unknown> = { ...slottedProps };
336 let cache: CachePolicy | undefined;
337- const uri = props.uri as string | undefined;
338- if (uri && uri.startsWith("at://")) {
0000000000339 const parsed = new AtUri(uri);
340 ensureValidNsid(parsed.collection);
341- if (parsed.collection && parsed.rkey) {
342- const viewRecord = component.view?.some(
343- (v) =>
344- v.$type === "at.inlay.component#viewRecord" &&
345- (v as any).collection === parsed.collection
346- );
347- if (viewRecord) {
348- const built = await buildScope(
349- parsed.host,
350- parsed.collection,
351- parsed.rkey,
352- tree,
353- resolver
354- );
355- scope = { ...props, ...built.scope };
356- cache = built.cache;
357- }
358 }
0000000000359 }
360361 const node = resolveBindings(tree, scopeResolver(scope));
···367 };
368}
369370-async function buildScope(
371 did: AtIdentifierString,
372 collection: NsidString,
373 rkey: string,
374 tree: unknown,
375 resolver: Resolver
376-): Promise<{ scope: Record<string, unknown>; cache?: CachePolicy }> {
377 const recordUri: AtUriString = `at://${did}/${collection}/${rkey}`;
378- const scope: Record<string, unknown> = {
379- did,
380- collection,
381- rkey,
382- uri: recordUri,
383- };
384385- // Skip record fetch if all bindings can be satisfied from URI parts alone
386- if (tree && !needsRecord(tree, scope)) {
387- return { scope };
388 }
389390- const record = await resolver.fetchRecord(recordUri);
391- if (record && typeof record === "object") {
392- const merged = Object.create(null) as Record<string, unknown>;
393- Object.assign(merged, record, scope);
394 return {
395- scope: merged,
396 cache: {
397 tags: [{ $type: "at.inlay.defs#tagRecord", uri: recordUri }],
398 },
399 };
400 }
401- return { scope };
402}
403404-/** Check if any Binding in the deserialized tree needs data beyond what scope provides. */
405-function needsRecord(tree: unknown, scope: Record<string, unknown>): boolean {
406- let missed = false;
407 walkTree(tree, (obj, walk) => {
408 if (isValidElement(obj)) {
409 const el = obj as Element;
410 if (el.type === "at.inlay.Binding") {
411 const path = (el.props as Record<string, unknown>)?.path;
412- if (
413- Array.isArray(path) &&
414- resolvePath(scope, path as string[]) == null
415- ) {
416- missed = true;
417 }
418 return obj;
419 }
···421 for (const v of Object.values(obj)) walk(v);
422 return obj;
423 });
424- return missed;
425}
426427async function renderExternal(
···495 };
496}
497498-async function expandBareDid(
499 props: Record<string, unknown>,
500- component: ComponentRecord,
501- resolver: Resolver
502-): Promise<Record<string, unknown>> {
503- const uri = props.uri as string | undefined;
504- if (!uri || !uri.startsWith("did:")) return props;
505-506 const view = component.view!;
507- const hasIdentityView = view.some(
508- (v) => v.$type === "at.inlay.component#viewIdentity"
509- );
510- if (!hasIdentityView) return props;
511512- const viewRecord = view.find(
513- (v) =>
514- v.$type === "at.inlay.component#viewRecord" && !!(v as any).collection
515- ) as { collection: string } | undefined;
516- if (!viewRecord) return props;
517518- const viewLex = (await resolver.resolveLexicon(viewRecord.collection)) as {
519- defs?: Record<string, { key?: string }>;
520- } | null;
521522- let identityRkey: string | null = null;
523- if (viewLex?.defs?.main?.key?.startsWith("literal:")) {
524- identityRkey = viewLex.defs.main.key.slice("literal:".length);
525- } else if (!viewLex) {
526- identityRkey = "self";
527- }
528-529- if (identityRkey) {
530- return {
531- ...props,
532- uri: `at://${uri}/${viewRecord.collection}/${identityRkey}`,
533- };
534- }
535- return props;
536}
···19import { validateProps } from "./validate.js";
2021import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js";
22+import { viewRecord as viewRecordSchema } from "../../../../generated/at/inlay/component.defs.js";
23import type { Main as PackRecord } from "../../../../generated/at/inlay/pack.defs.js";
24import type { CachePolicy } from "../../../../generated/at/inlay/defs.defs.js";
25···205export function resolvePath(obj: unknown, path: string[]): unknown {
206 let current: unknown = obj;
207 for (const seg of path) {
208+ if (current == null) return undefined;
209+ if (typeof current === "string") {
210+ if (!current.startsWith("at://")) return undefined;
211+ try {
212+ const parsed = new AtUri(current);
213+ const uriParts: Record<string, string> = {
214+ did: parsed.host,
215+ collection: parsed.collection,
216+ rkey: parsed.rkey,
217+ };
218+ current = uriParts[seg];
219+ } catch {
220+ return undefined;
221+ }
222+ continue;
223+ }
224+ if (typeof current !== "object") return undefined;
225 if (!Object.hasOwn(current, seg)) return undefined;
226 current = (current as Record<string, unknown>)[seg];
227 }
···257 const type = element.type;
258 let resolvedProps = props;
259 if (component.view) {
260+ resolvedProps = expandBareDid(props, component);
261 }
262263 if (validate) {
···349 return out;
350 }) as Record<string, unknown>;
351352+ let scope: Record<string, unknown> = { props: slottedProps };
353 let cache: CachePolicy | undefined;
354+355+ // Find a matching viewRecord and resolve its AT URI prop
356+ const viewRecords = (component.view ?? []).filter((v) =>
357+ viewRecordSchema.isTypeOf(v)
358+ );
359+360+ for (const vr of viewRecords) {
361+ const prop = vr.prop;
362+ const uri = props[prop] as string | undefined;
363+ if (!uri || !uri.startsWith("at://")) {
364+ continue;
365+ }
366 const parsed = new AtUri(uri);
367 ensureValidNsid(parsed.collection);
368+ if (!parsed.collection || !parsed.rkey) {
369+ continue;
370+ }
371+ if (vr.collection && vr.collection !== parsed.collection) {
372+ continue;
000000000000373 }
374+ const built = await buildRecord(
375+ parsed.host,
376+ parsed.collection,
377+ parsed.rkey,
378+ tree,
379+ resolver
380+ );
381+ scope = { props: slottedProps, record: built.record };
382+ cache = built.cache;
383+ break;
384 }
385386 const node = resolveBindings(tree, scopeResolver(scope));
···392 };
393}
394395+async function buildRecord(
396 did: AtIdentifierString,
397 collection: NsidString,
398 rkey: string,
399 tree: unknown,
400 resolver: Resolver
401+): Promise<{ record: Record<string, unknown>; cache?: CachePolicy }> {
402 const recordUri: AtUriString = `at://${did}/${collection}/${rkey}`;
000000403404+ // Skip record fetch if no bindings reference record.*
405+ if (tree && !needsRecord(tree)) {
406+ return { record: {} };
407 }
408409+ const fetched = await resolver.fetchRecord(recordUri);
410+ if (fetched && typeof fetched === "object") {
00411 return {
412+ record: fetched as Record<string, unknown>,
413 cache: {
414 tags: [{ $type: "at.inlay.defs#tagRecord", uri: recordUri }],
415 },
416 };
417 }
418+ return { record: {} };
419}
420421+/** Check if any Binding in the deserialized tree references record.* */
422+function needsRecord(tree: unknown): boolean {
423+ let found = false;
424 walkTree(tree, (obj, walk) => {
425 if (isValidElement(obj)) {
426 const el = obj as Element;
427 if (el.type === "at.inlay.Binding") {
428 const path = (el.props as Record<string, unknown>)?.path;
429+ if (Array.isArray(path) && path[0] === "record") {
430+ found = true;
000431 }
432 return obj;
433 }
···435 for (const v of Object.values(obj)) walk(v);
436 return obj;
437 });
438+ return found;
439}
440441async function renderExternal(
···509 };
510}
511512+function expandBareDid(
513 props: Record<string, unknown>,
514+ component: ComponentRecord
515+): Record<string, unknown> {
0000516 const view = component.view!;
0000517518+ // Find the first viewRecord with both collection and rkey — that enables DID expansion
519+ const entry = view
520+ .filter((v) => viewRecordSchema.isTypeOf(v))
521+ .find((v) => v.collection && v.rkey);
522+ if (!entry) return props;
523524+ const prop = entry.prop ?? "uri";
525+ const value = props[prop] as string | undefined;
526+ if (!value || !value.startsWith("did:")) return props;
527528+ return {
529+ ...props,
530+ [prop]: `at://${value}/${entry.collection}/${entry.rkey}`,
531+ };
0000000000532}
+84-41
packages/@inlay/render/src/validate.ts
···1import { Lexicons, type LexiconDoc } from "@atproto/lexicon";
2import { AtUri } from "@atproto/syntax";
3import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js";
00004import type { Resolver } from "./index.js";
56// --- Lexicon cache (module-level) ---
78const lexiconCache = new Map<string, Lexicons>();
9000000000010// --- Public API ---
1112export async function validateProps(
···23 lexiconCache.set(type, lexicons);
24 }
25 lexicons.assertValidXrpcInput(type, props);
26- } else if (component.accepts?.length) {
27- const propEntries = new Map<
28- string,
29- { type: string; formats: Set<string> }
30- >();
31- for (const accept of component.accepts) {
32- if (!accept.prop) continue;
33- const existing = propEntries.get(accept.prop);
34- if (existing) {
35- if (accept.format) existing.formats.add(accept.format);
36- } else {
37- const formats = new Set<string>();
38- if (accept.format) formats.add(accept.format);
39- propEntries.set(accept.prop, { type: accept.type, formats });
0000000000000000000000000000000000000000000000040 }
41 }
42- const properties: Record<string, { type: string; format?: string }> = {};
43- const required = [...propEntries.keys()];
44- for (const [prop, { type: t, formats }] of propEntries) {
45- const format = unionFormats(formats);
46- properties[prop] = format ? { type: t, format } : { type: t };
47- }
48- const syntheticLex = {
49- lexicon: 1,
50- id: type,
51- defs: {
52- main: {
53- type: "procedure",
54- input: {
55- encoding: "application/json",
56- schema: { type: "object", required, properties },
57- },
58- },
59- },
60- } as unknown as LexiconDoc;
61- const lexicons = new Lexicons([syntheticLex]);
62- lexicons.assertValidXrpcInput(type, props);
63 }
6465- if (component.accepts?.length) {
0066 const allowedCollections = new Map<string, Set<string>>();
67- for (const accept of component.accepts) {
68- if (!accept.prop || !accept.collection) continue;
69- let set = allowedCollections.get(accept.prop);
070 if (!set) {
71 set = new Set();
72- allowedCollections.set(accept.prop, set);
73 }
74- set.add(accept.collection);
75 }
76 for (const [prop, allowed] of allowedCollections) {
77 const value = props[prop];
···1import { Lexicons, type LexiconDoc } from "@atproto/lexicon";
2import { AtUri } from "@atproto/syntax";
3import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js";
4+import {
5+ viewRecord as viewRecordSchema,
6+ viewPrimitive as viewPrimitiveSchema,
7+} from "../../../../generated/at/inlay/component.defs.js";
8import type { Resolver } from "./index.js";
910// --- Lexicon cache (module-level) ---
1112const lexiconCache = new Map<string, Lexicons>();
1314+// --- Helpers ---
15+16+function getViewPrimitives(component: ComponentRecord) {
17+ return (component.view ?? []).filter((v) => viewPrimitiveSchema.isTypeOf(v));
18+}
19+20+function getViewRecords(component: ComponentRecord) {
21+ return (component.view ?? []).filter((v) => viewRecordSchema.isTypeOf(v));
22+}
23+24// --- Public API ---
2526export async function validateProps(
···37 lexiconCache.set(type, lexicons);
38 }
39 lexicons.assertValidXrpcInput(type, props);
40+ } else {
41+ // Synthesize validation from view entries (viewPrimitive + viewRecord)
42+ const primitives = getViewPrimitives(component);
43+ const records = getViewRecords(component);
44+45+ if (primitives.length > 0 || records.length > 0) {
46+ const propEntries = new Map<
47+ string,
48+ { type: string; formats: Set<string> }
49+ >();
50+51+ // viewPrimitive entries define typed props
52+ for (const vp of primitives) {
53+ if (!vp.prop) continue;
54+ const existing = propEntries.get(vp.prop);
55+ if (existing) {
56+ if (vp.format) existing.formats.add(vp.format);
57+ } else {
58+ const formats = new Set<string>();
59+ if (vp.format) formats.add(vp.format);
60+ propEntries.set(vp.prop, { type: vp.type, formats });
61+ }
62+ }
63+64+ // viewRecord entries imply a string prop (at-uri or did format)
65+ for (const vr of records) {
66+ const prop = vr.prop ?? "uri";
67+ const existing = propEntries.get(prop);
68+ if (existing) {
69+ existing.formats.add("at-uri");
70+ if (vr.rkey) existing.formats.add("did");
71+ } else {
72+ const formats = new Set<string>(["at-uri"]);
73+ if (vr.rkey) formats.add("did");
74+ propEntries.set(prop, { type: "string", formats });
75+ }
76+ }
77+78+ if (propEntries.size > 0) {
79+ const properties: Record<string, { type: string; format?: string }> =
80+ {};
81+ const required = [...propEntries.keys()];
82+ for (const [prop, { type: t, formats }] of propEntries) {
83+ const format = unionFormats(formats);
84+ properties[prop] = format ? { type: t, format } : { type: t };
85+ }
86+ const syntheticLex = {
87+ lexicon: 1,
88+ id: type,
89+ defs: {
90+ main: {
91+ type: "procedure",
92+ input: {
93+ encoding: "application/json",
94+ schema: { type: "object", required, properties },
95+ },
96+ },
97+ },
98+ } as unknown as LexiconDoc;
99+ const lexicons = new Lexicons([syntheticLex]);
100+ lexicons.assertValidXrpcInput(type, props);
101 }
102 }
000000000000000000000103 }
104105+ // Collection constraint checking from viewRecord entries
106+ const records = getViewRecords(component);
107+ if (records.length > 0) {
108 const allowedCollections = new Map<string, Set<string>>();
109+ for (const vr of records) {
110+ if (!vr.collection) continue;
111+ const prop = vr.prop ?? "uri";
112+ let set = allowedCollections.get(prop);
113 if (!set) {
114 set = new Set();
115+ allowedCollections.set(prop, set);
116 }
117+ set.add(vr.collection);
118 }
119 for (const [prop, allowed] of allowedCollections) {
120 const value = props[prop];