From d84e2c0c5417a6ee75e13061c0d5da8dad8c25fa Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:28:45 +0200 Subject: [PATCH] sources: add ResolvedNode + resolveNodes (loaded travels with the node) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveNodes(nodes, ctx, opts) wraps resolveGraph and returns each node enriched with `loaded` — the object a (custom) renderer receives: source spec + fetched data on one node. `loaded` is runtime-only; a new test proves schema's allowlist-based nodeToRecord never serializes it back to the PDS while the declarative `source` still round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sources/src/index.ts | 2 +- packages/sources/src/resolve.test.ts | 39 +++++++++++++++++++++++++++- packages/sources/src/resolve.ts | 23 ++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) 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 })); +} -- 2.51.2