diff --git a/packages/sources/src/index.ts b/packages/sources/src/index.ts index 7802aed..5ff26d1 100644 --- a/packages/sources/src/index.ts +++ b/packages/sources/src/index.ts @@ -11,6 +11,6 @@ export type { ResolveOptions, ResolveResult } from './types.js'; -export { resolve, resolveGraph } from './resolve.js'; +export { resolve, resolveGraph, resolveNodes, type ResolvedNode } from './resolve.js'; export { cacheKey, MemoryCacheAdapter } from './cache.js'; export { DEFAULT_APPVIEW } from './atproto.js'; diff --git a/packages/sources/src/resolve.test.ts b/packages/sources/src/resolve.test.ts index faf41a9..cc43588 100644 --- a/packages/sources/src/resolve.test.ts +++ b/packages/sources/src/resolve.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { resolve, resolveGraph } from './resolve.js'; +import { nodeToRecord, type Node } from '@blento/schema'; +import { resolve, resolveGraph, resolveNodes } from './resolve.js'; import { MemoryCacheAdapter } from './cache.js'; import type { Source, SourceContext } from './types.js'; import { fakeFetch } from './testutil.js'; @@ -77,3 +78,39 @@ describe('resolveGraph', () => { expect(out.bad).toBeNull(); }); }); + +describe('resolveNodes (loaded on the node)', () => { + const ctx = (fetchImpl: typeof fetch): SourceContext => ({ self: 'did:plc:aaa', fetchImpl }); + + it('attaches loaded data onto each node without mutating the input', async () => { + const ff = fakeFetch([{ match: 'getAuthorFeed', body: { feed: ['p'], cursor: 'z' } }]); + const nodes = [{ id: 'a', source: atproto({ actor: '$self' }) }, { id: 'b' }]; + const resolved = await resolveNodes(nodes, ctx(ff.fetch)); + + expect(resolved[0].loaded).toEqual({ data: { feed: ['p'], cursor: 'z' }, nextCursor: 'z' }); + expect(resolved[1].loaded).toBeUndefined(); + // inputs untouched — resolveNodes returns enriched copies + expect('loaded' in nodes[0]).toBe(false); + }); + + it('loaded is runtime-only: nodeToRecord (allowlist) never serializes it back to the PDS', async () => { + const ff = fakeFetch([{ match: 'getAuthorFeed', body: { feed: [], cursor: 'z' } }]); + const node: Node = { + id: 'n1', + kind: 'leaf', + parent: 'root', + rank: 'a0', + page: 'blento.self', + content: { $type: 'app.blento.defs#card', cardType: 'latestPost' }, + source: atproto({ actor: '$self' }), + version: 1 + }; + const [resolved] = await resolveNodes([node], ctx(ff.fetch)); + expect(resolved.loaded).toBeDefined(); + + const record = nodeToRecord(resolved); + expect('loaded' in record).toBe(false); + // the declarative source DOES round-trip; only the fetched data is dropped + expect(record.source).toEqual(node.source); + }); +}); diff --git a/packages/sources/src/resolve.ts b/packages/sources/src/resolve.ts index 477bc25..a9d1ca0 100644 --- a/packages/sources/src/resolve.ts +++ b/packages/sources/src/resolve.ts @@ -138,3 +138,26 @@ export async function resolveGraph( return out; } + +/** + * A node enriched with its resolved data. `loaded` is RUNTIME-ONLY: the schema's `nodeToRecord` is + * allowlist-based, so this field is never serialized back to the PDS — the stored record keeps only + * the declarative `source`. This is the object renderers receive: everything on one node. + */ +export type ResolvedNode = N & { + loaded?: ResolveResult | null; +}; + +/** + * Resolve a graph and return each node enriched with its `loaded` data (source spec stays put). + * A thin wrapper over `resolveGraph` for the common "hand renderers a node that carries its data" + * shape. Nodes without a resolvable source get `loaded: undefined`. + */ +export async function resolveNodes>( + nodes: N[], + ctx: SourceContext, + opts: ResolveOptions = {} +): Promise[]> { + const map = await resolveGraph(nodes, ctx, opts); + return nodes.map((n) => ({ ...n, loaded: map[n.id] ?? undefined })); +}