diff --git a/.changeset/social-graph-atlas-0419.md b/.changeset/social-graph-atlas-0419.md
new file mode 100644
index 000000000..7b86ff5ef
--- /dev/null
+++ b/.changeset/social-graph-atlas-0419.md
@@ -0,0 +1,22 @@
+---
+'@xnetjs/sqlite': minor
+'@xnetjs/react': minor
+---
+
+Imported social content is now actually in the full-text index, and a saved
+lens can be projected onto a canvas with its relationships.
+
+`extractSearchableContent` read `content`, `description`, `body`, `name` and
+`note` — but not `searchText`, the property imported social records
+denormalize their full text into precisely so it can be searched. Every
+imported post, comment and video transcript was therefore absent from
+`nodes_fts` while the pipeline reported it as indexed: search returned a clean
+empty result rather than an error. `searchText` is now indexed, with
+`textPreview` as the fallback for records that carry no full text. **Existing
+databases need `rebuildFTS()` to pick up already-imported rows** — new writes
+are indexed correctly from here on.
+
+`SavedViewVisualCanvasProjectionRequest` gains an `edges` array (filtered to
+relationships whose endpoints both survived the projection cap), and the new
+`SavedViewCanvasProjectionEdge` type is exported. This is what lets a consumer
+lay a saved view out as a graph rather than an unconnected grid of cards.
diff --git a/apps/electron/src/renderer/components/CanvasView.tsx b/apps/electron/src/renderer/components/CanvasView.tsx
index c373c4b83..6ef62077a 100644
--- a/apps/electron/src/renderer/components/CanvasView.tsx
+++ b/apps/electron/src/renderer/components/CanvasView.tsx
@@ -11,6 +11,7 @@ import type {
Rect,
ShapeType
} from '@xnetjs/canvas'
+import type { SocialCanvasProjectionPlan } from '@xnetjs/social/projection'
import {
Canvas,
getCanvasObjectsMap,
@@ -132,6 +133,7 @@ export type CanvasViewHandle = {
createMindMap: () => boolean
createPlanningTemplate: (templateId: CanvasPlanningTemplateId) => boolean
createQueryFrameFromSavedView: (input: SavedViewCanvasQueryFrameInput) => boolean
+ applySocialCanvasProjection: (plan: SocialCanvasProjectionPlan) => boolean
refreshSelectedQueryFrame: () => boolean
createExternalReference: (url?: string) => boolean
createMediaFile: () => boolean
@@ -273,7 +275,8 @@ export const CanvasView = forwardRef(function
selectedQueryFrameNode,
selectedQueryFrameDefinition,
createQueryFrameFromSavedView,
- refreshSelectedQueryFrame
+ refreshSelectedQueryFrame,
+ applySocialCanvasProjection
} = useCanvasQueryFrames({
doc,
sceneRevision,
@@ -282,6 +285,13 @@ export const CanvasView = forwardRef(function
onUndoBoundary: recordSceneUndoBoundary
})
+ // The imperative handle reports success as a boolean; the hook returns the
+ // richer result for callers that want the placed/omitted counts.
+ const applySocialCanvasProjectionHandle = useCallback(
+ (plan: SocialCanvasProjectionPlan): boolean => Boolean(applySocialCanvasProjection(plan)),
+ [applySocialCanvasProjection]
+ )
+
const selectedDatabaseSourceId =
selectedCanvasObject?.displayType === 'database' ? (selectedCanvasObject.sourceId ?? '') : ''
const undoLadder = useCanvasUndoLadder({
@@ -784,6 +794,7 @@ export const CanvasView = forwardRef(function
createMindMap,
createPlanningTemplate,
createQueryFrameFromSavedView,
+ applySocialCanvasProjection: applySocialCanvasProjectionHandle,
refreshSelectedQueryFrame,
createExternalReference,
createMediaFile,
@@ -804,6 +815,7 @@ export const CanvasView = forwardRef(function
createMindMap,
createPlanningTemplate,
createQueryFrameFromSavedView,
+ applySocialCanvasProjectionHandle,
createMediaFile,
createShape,
connectSelection,
diff --git a/apps/electron/src/renderer/components/DataWorkspaceView.tsx b/apps/electron/src/renderer/components/DataWorkspaceView.tsx
index 59de23f66..27a3727ab 100644
--- a/apps/electron/src/renderer/components/DataWorkspaceView.tsx
+++ b/apps/electron/src/renderer/components/DataWorkspaceView.tsx
@@ -5,9 +5,14 @@
* and inserting saved lenses onto the canvas as frames.
*/
import { upsertSocialImportJobProgress } from '@xnetjs/social/import/core'
-import { useDataWorkspace, DataWorkspaceBody, type SavedViewCanvasFrameInput } from '@xnetjs/views'
+import {
+ useDataWorkspace,
+ useSocialFeedEnrichment,
+ DataWorkspaceBody,
+ type SavedViewCanvasFrameInput
+} from '@xnetjs/views'
import { Database, Import, Loader2, X } from 'lucide-react'
-import React, { useEffect } from 'react'
+import React, { useEffect, useMemo } from 'react'
export type { SavedViewCanvasFrameInput }
@@ -25,6 +30,8 @@ export function DataWorkspaceView({
onInsertSavedLensAsCanvasFrame
})
const { seeding, handleSeedWorkspace, refreshSocialImportJobs } = workspace
+ const feedEnrichment = useSocialFeedEnrichment()
+ const savedViewRunnerProps = useMemo(() => ({ feedEnrichment }), [feedEnrichment])
// Bridge main-process commit jobs into the renderer's import-job store so
// progress started from the main process shows up in the shared panel.
@@ -84,7 +91,7 @@ export function DataWorkspaceView({
-
+
diff --git a/apps/electron/src/renderer/shell/use-document-shell.ts b/apps/electron/src/renderer/shell/use-document-shell.ts
index aa7cff299..983ad54ef 100644
--- a/apps/electron/src/renderer/shell/use-document-shell.ts
+++ b/apps/electron/src/renderer/shell/use-document-shell.ts
@@ -385,15 +385,18 @@ export function useDocumentShell(): DocumentShell {
const handleInsertSavedLensAsCanvasFrame = useCallback(
(view: SavedViewCanvasFrameInput) => {
- const inserted =
- canvasViewRef.current?.createQueryFrameFromSavedView({
- viewId: view.id,
- title: view.title ?? 'Saved lens',
- descriptorJson: view.descriptor ?? null
- }) ?? false
+ // A projection places the cards and the connections between them; a
+ // plain lens becomes a live query frame instead (0419).
+ const inserted = view.projection
+ ? (canvasViewRef.current?.applySocialCanvasProjection(view.projection) ?? false)
+ : (canvasViewRef.current?.createQueryFrameFromSavedView({
+ viewId: view.id,
+ title: view.title ?? 'Saved lens',
+ descriptorJson: view.descriptor ?? null
+ }) ?? false)
if (!inserted) {
- console.error('Failed to insert saved lens as a canvas query frame', view.id)
+ console.error('Failed to place saved lens on the canvas', view.id)
return
}
diff --git a/apps/web/src/components/CanvasView.tsx b/apps/web/src/components/CanvasView.tsx
index ee6ae3aab..7a3520d3f 100644
--- a/apps/web/src/components/CanvasView.tsx
+++ b/apps/web/src/components/CanvasView.tsx
@@ -41,6 +41,7 @@ import {
isPeekableCanvasDisplayType,
shouldActivateDatabasePreviewSurface,
shouldActivateInlinePageSurface,
+ takePendingCanvasLens,
useCanvasCommands,
useCanvasQueryFrames,
useCanvasSourceReferences,
@@ -279,7 +280,9 @@ export function CanvasView({ docId }: CanvasViewProps): JSX.Element {
queryFrameTargets,
manualQueryFrameRefreshRequests,
selectedQueryFrameDefinition,
- refreshSelectedQueryFrame
+ refreshSelectedQueryFrame,
+ createQueryFrameFromSavedView,
+ applySocialCanvasProjection
} = useCanvasQueryFrames({
doc,
sceneRevision,
@@ -288,6 +291,34 @@ export function CanvasView({ docId }: CanvasViewProps): JSX.Element {
onUndoBoundary: recordSceneUndoBoundary
})
+ // A lens sent here from the Data Workspace (0419). The workspace parks the
+ // request and navigates; this claims it once the doc is live. Claiming is
+ // one-shot, so revisiting the canvas does not re-insert the frame.
+ useEffect(() => {
+ if (!doc) return
+
+ const pending = takePendingCanvasLens(docId)
+ if (!pending) return
+
+ // A projection places the cards and their connections; without one the
+ // request is for a live lens frame instead.
+ if (pending.projection) {
+ if (!applySocialCanvasProjection(pending.projection)) {
+ console.error('Failed to project saved lens onto the canvas', pending.viewId)
+ }
+ return
+ }
+
+ const inserted = createQueryFrameFromSavedView({
+ viewId: pending.viewId,
+ title: pending.title,
+ descriptorJson: pending.descriptorJson
+ })
+ if (!inserted) {
+ console.error('Failed to insert saved lens as a canvas query frame', pending.viewId)
+ }
+ }, [applySocialCanvasProjection, createQueryFrameFromSavedView, doc, docId])
+
// Peek (0277 E4): modal preview of the selected card's source without
// leaving the board; inline editing activates on zoomed-in selection.
const { peekState, peekedObject, openPeek, closePeekSurface } = useCanvasPeek({
diff --git a/apps/web/src/components/DataWorkspaceView.tsx b/apps/web/src/components/DataWorkspaceView.tsx
index 642f00f69..a7aa2dae7 100644
--- a/apps/web/src/components/DataWorkspaceView.tsx
+++ b/apps/web/src/components/DataWorkspaceView.tsx
@@ -3,11 +3,19 @@
* (@xnetjs/views, exploration 0276). Web-specific concerns: OPFS store-backed
* seeding, social feed enrichment, and the moderation render gate.
*/
+import { useNavigate } from '@tanstack/react-router'
+import { useIdentity } from '@xnetjs/react'
import { useNodeStore } from '@xnetjs/react/internal'
-import { useDataWorkspace, DataWorkspaceBody } from '@xnetjs/views'
+import {
+ useDataWorkspace,
+ DataWorkspaceBody,
+ stashPendingCanvasLens,
+ useSocialFeedEnrichment,
+ type SavedViewCanvasFrameInput
+} from '@xnetjs/views'
import { Database, Import, Loader2 } from 'lucide-react'
-import { useMemo, type ReactNode } from 'react'
-import { useSocialFeedEnrichment } from '../hooks/useSocialFeedEnrichment'
+import { useCallback, useMemo, type ReactNode } from 'react'
+import { deskIdFor } from '../lib/desk'
import { ModeratedMedia } from './ModeratedMedia'
/**
@@ -22,10 +30,41 @@ const gateVisualItem = (nodeId: string, content: ReactNode): ReactNode => (
export function DataWorkspaceView(): JSX.Element {
const { store, isReady: storeReady } = useNodeStore()
const feedEnrichment = useSocialFeedEnrichment()
+ const navigate = useNavigate()
+ const { identity } = useIdentity()
+ const did = identity?.did
+
+ /**
+ * Send a saved lens to the Desk as a live query frame.
+ *
+ * The Desk is the right target because its id is derived from the identity
+ * and visiting it provisions it — so this needs no "which canvas?" prompt
+ * and no separate create. The request is parked, then claimed by the canvas
+ * once it mounts.
+ */
+ const handleInsertSavedLensAsCanvasFrame = useCallback(
+ (view: SavedViewCanvasFrameInput) => {
+ if (!did) return
+
+ const canvasId = deskIdFor(did)
+ stashPendingCanvasLens({
+ canvasId,
+ viewId: view.id,
+ title: view.title ?? 'Saved lens',
+ descriptorJson: view.descriptor ?? null,
+ ...(view.projection ? { projection: view.projection } : {})
+ })
+ void navigate({ to: '/canvas/$canvasId', params: { canvasId } })
+ },
+ [did, navigate]
+ )
const workspace = useDataWorkspace({
seedReady: Boolean(store && storeReady),
- getExistingNode: (id) => (store ? Promise.resolve(store.get(id)) : Promise.resolve(undefined))
+ getExistingNode: (id) => (store ? Promise.resolve(store.get(id)) : Promise.resolve(undefined)),
+ // Without an identity there is no Desk to target, so the canvas actions
+ // stay hidden rather than failing when pressed.
+ ...(did ? { onInsertSavedLensAsCanvasFrame: handleInsertSavedLensAsCanvasFrame } : {})
})
const { seeding, seedReady, handleSeedWorkspace } = workspace
diff --git a/apps/web/src/components/SavedViewTab.tsx b/apps/web/src/components/SavedViewTab.tsx
index 0cd4aa2e0..3ea8a2c97 100644
--- a/apps/web/src/components/SavedViewTab.tsx
+++ b/apps/web/src/components/SavedViewTab.tsx
@@ -5,8 +5,8 @@
import { validateSavedViewDescriptor, type SavedViewDescriptor } from '@xnetjs/data'
import { SavedViewSchema } from '@xnetjs/data'
import { SavedViewRunner, useQuery } from '@xnetjs/react'
+import { useSocialFeedEnrichment } from '@xnetjs/views'
import { useMemo } from 'react'
-import { useSocialFeedEnrichment } from '../hooks/useSocialFeedEnrichment'
import { WORKBENCH_SAVED_VIEW_REGISTRY } from '../lib/saved-view-registry'
import { usePublishTitle } from '../workbench/route-title'
diff --git a/apps/web/src/lib/social-import-worker-client.ts b/apps/web/src/lib/social-import-worker-client.ts
index b41a340d0..810e190e8 100644
--- a/apps/web/src/lib/social-import-worker-client.ts
+++ b/apps/web/src/lib/social-import-worker-client.ts
@@ -38,6 +38,7 @@ export type BrowserSocialImportStageInput = {
manifest: ArchiveManifest
buckets: string[]
includeSensitive: boolean
+ fetchTranscripts?: boolean
importedAt?: string
}
@@ -76,6 +77,7 @@ type MainThreadStagedResult = {
manifest: ArchiveManifest
buckets: string[]
includeSensitive: boolean
+ fetchTranscripts: boolean
importedAt: string
result: SocialImportNodeDraftStreamResult
streams: Map
@@ -316,6 +318,7 @@ async function stageOnMainThread(
readTextEntry,
buckets: input.buckets,
includeSensitive: input.includeSensitive,
+ fetchTranscripts: input.fetchTranscripts === true,
importedAt,
includeSourceRecords: true,
onComplete: (result) => {
@@ -331,6 +334,7 @@ async function stageOnMainThread(
manifest: input.manifest,
buckets: input.buckets,
includeSensitive: input.includeSensitive,
+ fetchTranscripts: input.fetchTranscripts === true,
importedAt,
result,
streams: new Map()
@@ -376,6 +380,7 @@ export async function stageBrowserSocialArchive(
manifest: input.manifest,
buckets: input.buckets,
includeSensitive: input.includeSensitive,
+ fetchTranscripts: input.fetchTranscripts === true,
importedAt: input.importedAt
},
isStageResponse
@@ -496,6 +501,7 @@ async function getMainThreadStageDraftStream(
readTextEntry,
buckets: stagedResult.buckets,
includeSensitive: stagedResult.includeSensitive,
+ fetchTranscripts: stagedResult.fetchTranscripts,
importedAt: stagedResult.importedAt,
includeSourceRecords
}),
diff --git a/apps/web/src/lib/social-import-worker-protocol.ts b/apps/web/src/lib/social-import-worker-protocol.ts
index bfaf2be76..17d3b37b0 100644
--- a/apps/web/src/lib/social-import-worker-protocol.ts
+++ b/apps/web/src/lib/social-import-worker-protocol.ts
@@ -46,6 +46,8 @@ export type SocialImportWorkerStageRequest = {
manifest: ArchiveManifest
buckets: string[]
includeSensitive: boolean
+ /** Fetch video transcripts for this run (0419). */
+ fetchTranscripts?: boolean
importedAt?: string
}
diff --git a/apps/web/src/routes/social-import.tsx b/apps/web/src/routes/social-import.tsx
index d88dd87c8..d4f34c5aa 100644
--- a/apps/web/src/routes/social-import.tsx
+++ b/apps/web/src/routes/social-import.tsx
@@ -75,6 +75,9 @@ function SocialImportPage(): React.ReactElement {
const [selectedBuckets, setSelectedBuckets] = useState([])
const [includeSensitive, setIncludeSensitive] = useState(false)
const [includeSourceRecords, setIncludeSourceRecords] = useState(false)
+ // Transcript fetching is per-run and off by default (0419): the decision is
+ // about this archive, not a standing preference.
+ const [fetchTranscripts, setFetchTranscripts] = useState(false)
const [stageResult, setStageResult] = useState(null)
const [status, setStatus] = useState('idle')
const [error, setError] = useState(null)
@@ -169,7 +172,8 @@ function SocialImportPage(): React.ReactElement {
file: archive.file,
manifest: archive.manifest,
buckets: selectedBuckets,
- includeSensitive
+ includeSensitive,
+ fetchTranscripts
})
setStageResult(result)
setStatus('staged')
@@ -177,7 +181,7 @@ function SocialImportPage(): React.ReactElement {
setStatus('picked')
setError(toErrorMessage(err))
}
- }, [archive, includeSensitive, selectedBuckets])
+ }, [archive, fetchTranscripts, includeSensitive, selectedBuckets])
const handleCommit = useCallback(async () => {
if (!archive || !stageResult || !store || !storeReady) return
@@ -678,6 +682,26 @@ function SocialImportPage(): React.ReactElement {
+
diff --git a/apps/web/src/workers/social-import.worker.ts b/apps/web/src/workers/social-import.worker.ts
index acf5bc586..fb9b5d52b 100644
--- a/apps/web/src/workers/social-import.worker.ts
+++ b/apps/web/src/workers/social-import.worker.ts
@@ -33,6 +33,7 @@ type WorkerStagedResult = {
manifest: Extract['manifest']
buckets: string[]
includeSensitive: boolean
+ fetchTranscripts: boolean
importedAt: string
streams: Map
}
@@ -114,6 +115,7 @@ async function handleStage(
readTextEntry,
buckets: request.buckets,
includeSensitive: request.includeSensitive,
+ fetchTranscripts: request.fetchTranscripts === true,
importedAt,
includeSourceRecords: true,
onComplete: (result) => {
@@ -130,6 +132,7 @@ async function handleStage(
manifest: request.manifest,
buckets: request.buckets,
includeSensitive: request.includeSensitive,
+ fetchTranscripts: request.fetchTranscripts === true,
importedAt,
streams: new Map()
})
diff --git a/docs/explorations/0419_[-]_SOCIAL_GRAPH_ATLAS.md b/docs/explorations/0419_[-]_SOCIAL_GRAPH_ATLAS.md
new file mode 100644
index 000000000..fc4deb86c
--- /dev/null
+++ b/docs/explorations/0419_[-]_SOCIAL_GRAPH_ATLAS.md
@@ -0,0 +1,499 @@
+---
+title: The Social Graph Atlas — your imported social life as a navigable, agent-readable place
+status: partial # mirrors the [_]/[-]/[x] filename checkbox
+last_updated: 2026-08-01
+tags: [social, canvas, views, enrichment, ai, retrieval]
+---
+
+# The Social Graph Atlas — your imported social life as a navigable, agent-readable place
+
+> [!TIP]
+> **TL;DR** — The vision ("my YouTube/Instagram/TikTok graph on an infinite
+> canvas, flip between database/calendar/feed renderings, thumbnails and
+> embeds everywhere, and my AI agent can read it all") is **~80% built**.
+> `packages/social` already imports all three platforms into a canonical node
+> spine; `SavedViewRunner` already flips between six presentation modes
+> including `canvas` and `graph`; enrichment and embeds already exist for
+> YouTube and Instagram. What's missing is **wiring, not architecture**: the
+> canvas projection is built but never called from the app, enrichment is
+> web-only and skips TikTok, there are **no video transcripts anywhere**, and
+> agent retrieval treats social nodes as ordinary text instead of a
+> first-class context source. Recommendation: four surgical work packages
+> (canvas wiring, enrichment completion, a transcript enrichment stage, an
+> agent-facing `xnet_social_context` path), no new subsystem.
+
+## Problem Statement
+
+The user's ask, distilled:
+
+1. **See it** — imported social data (YouTube videos, playlists, watch
+ history; Instagram likes and saves; TikTok likes, favorites, collections)
+ rendered as a graph on the infinite canvas, and re-renderable as a
+ database, calendar, gallery, or feed with one gesture.
+2. **Recognize it** — every item carries a thumbnail, title, description; a
+ click gives a live embed of the actual video or post.
+3. **Feed it to the agent** — transcripts, descriptions, and saved articles
+ become retrievable context, so an AI agent talking to the user can draw on
+ what they've watched, liked, and bookmarked.
+
+The question is what it actually takes to pull this together on xNet
+primitives — and the honest answer requires an inventory of what already
+ships, because a lot of this was built across explorations 0152, 0153, 0158,
+0170 and 0295.
+
+## Executive Summary
+
+| Pillar of the vision | Status | What exists / what's missing |
+| --------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- |
+| Import YouTube/IG/TikTok archives | ✅ Shipped | `packages/social` adapters, staging pipeline, resumable jobs, deterministic IDs |
+| Canonical graph schema | ✅ Shipped | 13 `social/*` schemas: Actor, Content, Interaction, Collection, CollectionItem… |
+| Flip-through renderings | ✅ Shipped | `SavedViewRunner` presentation modes: `table \| cards \| timeline \| canvas \| graph \| feed` |
+| Thumbnails + metadata enrichment | 🚧 Partial | YouTube + Instagram via hub `/unfurl`; **web-only**, no TikTok, no Electron |
+| Live embeds | ✅ Shipped | `EMBED_PROVIDERS` + iframe policy + canvas card renderers for youtube/instagram/tiktok |
+| Canvas projection of social graph | 🚧 Built, unwired | `createSocialCanvasProjectionPlan()` exists + tested, **called from no app surface** |
+| Calendar of watch/like history | 🚧 Partial | Interactions carry timestamps; calendar view exists; no seeded social calendar view |
+| TikTok feed views | ❌ Missing | Feed seeds cover only YouTube ×2 and Instagram ×2 |
+| Video transcripts | ❌ Missing | Nothing fetches or stores them; Takeout doesn't ship them; oEmbed gives title only |
+| Agent access to social context | 🚧 Partial | `xnet_search`/FTS work over `searchText`, but no transcript corpus and no social-aware retrieval profile |
+
+The gap analysis says: **no new subsystem is needed**. Every missing piece is
+a completion of a seam that already exists, plus one genuinely new capability
+(transcript enrichment) that slots into the enrichment pipeline built in 0170.
+
+---
+
+## Current State In The Repository
+
+### 1. The import spine (`packages/social`) — done
+
+Adapters in `packages/social/src/importers/` parse the platform export
+archives directly:
+
+- `youtube.ts` — Google Takeout: subscriptions, playlists (`playlists.csv` +
+ per-playlist video CSVs), comments, watch history, search history.
+- `instagram.ts` — Meta ZIP: liked posts, saved posts/collections, reels,
+ comments, followers/following, messages.
+- `tiktok.ts` — favorites, favorite collections, likes, posts, hashtags,
+ shares, searches, comments, relationships, DMs.
+- Plus `x.ts`, `reddit.ts`, and AI-chat importers (`openai.ts`, `claude.ts`,
+ `grok.ts` — `claude.ts` already emits `contentKind: 'transcript'`).
+
+Everything lands on the canonical spine from exploration 0152
+(`packages/social/src/schemas/`): `SocialActor`, `SocialContent`,
+`SocialInteraction`, `SocialConversation`, `SocialMessage`,
+`SocialCollection`, `SocialCollectionItem`, `SocialSourceRecord`,
+`SocialEnrichment` — platform semantics as facets (`platform`,
+`contentKind`, `interactionKind`, `collectionKind`), not per-platform
+tables. IDs are deterministic (`packages/social/src/import/ids.ts`), so
+re-import is idempotent.
+
+```mermaid
+erDiagram
+ SocialActor ||--o{ SocialContent : authors
+ SocialActor ||--o{ SocialInteraction : performs
+ SocialInteraction }o--|| SocialContent : targets
+ SocialCollection ||--o{ SocialCollectionItem : contains
+ SocialCollectionItem }o--|| SocialContent : references
+ SocialContent ||--o| SocialEnrichment : "enriched by"
+ SocialContent ||--o| MediaAsset : "thumbnail blob"
+ SocialSourceRecord ||--o{ SocialContent : "evidence for"
+```
+
+A YouTube playlist is a `SocialCollection` (`collectionKind: 'playlist'`)
+with `SocialCollectionItem`s pointing at `SocialContent`
+(`contentKind: 'video'`). A TikTok bookmark is a `SocialInteraction`
+(`interactionKind: 'bookmark'`). An Instagram save is a `collection`
+(`saved`) plus a `save` interaction. The graph the user wants to see **is
+already the storage shape**.
+
+### 2. "Flip through the renderings" — done, and better than the ask
+
+The presentation switcher from exploration 0158 is exactly the requested
+gesture. `packages/react/src/components/SavedViewRunner.tsx` defines
+`SavedViewPresentationMode = 'table' | 'cards' | 'timeline' | 'canvas' |
+'graph' | 'feed'`, with the mode persisted per saved view in
+`SavedViewPresentationHint` (`packages/data/src/store/query-ast.ts`).
+`savedViewVisualPreview.ts` normalizes any social node into a
+`SavedViewVisualPreviewModel` (title, thumbnailUrl, embedUrl, provider,
+timestamp, metrics…), and `SavedViewVisualFeed.tsx` renders the virtualized
+thumbnail grid. Default social views are seeded by
+`packages/social/src/views/defaults.ts` (People / Content / Interactions /
+Collections…), feed views by `packages/social/src/feeds/defaults.ts`, and
+graph lenses by `packages/social/src/lenses/graph-lenses.ts` +
+`atlas.ts` (`createDefaultSocialGraphAtlas` — the name of this exploration
+honors it).
+
+### 3. Thumbnails, previews, embeds — partial
+
+- **Provider parsing and embeds**: `packages/data/src/external-references.ts`
+ (`EMBED_PROVIDERS` covers YouTube incl. Shorts, Instagram p/reel/tv,
+ TikTok, X, Vimeo, Spotify…), iframe policy in
+ `packages/data/src/external-reference-embed-policy.ts`, canvas card
+ renderers in `packages/editor/src/components/canvasExternalReferenceCardRenderers.ts`.
+- **Enrichment** (exploration 0170): `SocialEnrichmentSchema` with
+ deterministic IDs, hub-proxied metadata via
+ `packages/hub/src/routes/unfurl.ts` (SSRF-guarded, image allowlist already
+ includes `i.ytimg.com`, `cdninstagram.com`, `tiktokcdn`), client queue in
+ `apps/web/src/hooks/social-feed-enrichment.ts`.
+- **Thumbnails as blobs**: `packages/data/src/blob/thumbnail.ts` generates
+ ≤320px thumbnails stored as *separate* tiny blobs so they sync ahead of
+ full media — which respects the >1MB blob-sync constraint from 0385.
+
+> [!WARNING]
+> Enrichment is **web-only** today (it lives in `apps/web/src/hooks/`), and
+> feed seeds cover only YouTube and Instagram. TikTok — whose oEmbed
+> endpoint actually *sends CORS headers* (the one free pass 0170 found) —
+> has importers producing bookmarks and collections that render as opaque
+> IDs. Electron users get no enrichment at all.
+
+### 4. The canvas — built on both ends, bridge unwired
+
+`packages/social/src/projection/canvas.ts` exports
+`createSocialCanvasProjectionPlan()`, producing `external-reference` node
+drafts (`sourceCardRole: 'social-projection'`) and edge drafts, capped at 75
+nodes / 200 edges. It is exercised only by
+`packages/social/src/__tests__/views-lenses-projection.test.ts` — **no app
+surface calls it**. The shipped insert path
+(`SavedViewVisualCanvasProjectionRequest` →
+`onInsertSavedLensAsCanvasFrame` in
+`packages/views/src/data-workspace/DataWorkspaceCore.tsx`) is wired in
+Electron only. Meanwhile the canvas side is fully ready: source-backed
+objects, `frameSourceRegistry`, live saved-view query frames
+(`packages/views/src/canvas-view/query-frames.tsx`), DOM-island rich cards.
+
+### 5. Agent retrieval — general-purpose, not social-aware
+
+`packages/brain` provides tiered retrieval (`hybrid-graph → bm25-graph →
+bm25 → scan`) with BFS relation expansion and token-budget packing;
+`packages/sqlite/src/fts.ts` maintains `nodes_fts`; the agent tool surface
+(`packages/plugins/src/ai-surface/tools/`) exposes `xnet_search`,
+`xnet_graph_expand`, `xnet_create_context_pack`, database query tools, and
+canvas read tools. `SocialContent` deliberately denormalizes `searchText`
+(20k chars) for exactly this purpose. So an agent *can* find a saved post by
+keyword today. What it cannot do:
+
+- Read a **video transcript** — none exist. YouTube Takeout does not include
+ them, nothing fetches them, and `contentKind: 'transcript'` is emitted
+ only by the Claude-chat importer.
+- Ask a social-shaped question cheaply ("what have I bookmarked about
+ fermentation across all platforms?") — that's a facet query over
+ `interactionKind` + full-text, which works via `xnet_database_query` but
+ isn't packaged as a retrieval profile or context pack.
+
+---
+
+## External Research
+
+### Transcripts are the hard 20%
+
+There is **no official API** for arbitrary-video transcripts. The
+[YouTube captions API](https://outlierkit.com/resources/youtube-transcript-api/)
+only serves videos you own (owner OAuth). The de-facto standard is the
+unofficial timedtext endpoint used by
+[youtube-transcript-api](https://www.notelm.ai/blog/youtube-transcript-api)
+(Python) and TypeScript equivalents like
+[youtube-caption-extractor](https://github.com/devhims/youtube-caption-extractor) —
+free, no key, works for any captioned video, **but IP-blocked from
+datacenter ranges** ([developer guide](https://medium.com/@volods/how-to-get-youtube-transcripts-a-complete-developers-guide-b3f092eb0a96),
+[production notes](https://transcriptapi.com/blog/extract-youtube-transcripts-programmatically)).
+Commercial proxies ([ScrapingDog](https://www.scrapingdog.com/youtube-transcripts-api/),
+[TranscriptFetch](https://transcriptfetch.com/)) exist for cloud workloads.
+
+> [!IMPORTANT]
+> The datacenter IP block is the moat that makes every "second brain for
+> social" SaaS pay for proxy fleets — and xNet is **local-first**, so it
+> walks around it. Transcript fetches issued from the *user's own device*,
+> at human-ish rates, against videos the user demonstrably saved, are
+> indistinguishable from that user opening the video. The result is stored
+> once as a node and syncs everywhere. Local-first turns the hardest part of
+> this feature into the cheapest.
+
+Two hazards to respect: (a) exploration 0417 already confirmed **oEmbed
+returns title only — never transcripts**; (b) rate discipline matters — a
+2,000-video watch history must be a slow trickle queue (the
+`SocialEnrichmentQueue` backoff pattern already models this), not a burst.
+
+### Prior art: the category exists, and it's cloud-shaped
+
+[Tavlo](https://www.tavlo.ca/) and [Second Brain](https://www.thesecondbrain.io/)
+sell exactly this pitch — "your TikToks, Reels, YouTube saves, auto-organized
+and searchable." Both are SaaS: your graph lives on their servers, is
+enriched by their pipelines, and is gone when they are.
+[Obsidian](https://www.kosmik.app/blog/best-second-brain-apps)-style graph
+views prove the visualization appetite but have no social importers. Nothing
+in the field combines **local-first ownership + archive-grade import + a
+real canvas + agent tools**. This is differentiation xNet already paid for;
+the atlas just makes it visible.
+
+---
+
+## Key Findings
+
+1. **The storage shape is already the graph.** Collections, items,
+ interactions, and actors are real nodes with real relations — no ETL step
+ between "imported" and "visualizable" exists or is needed.
+2. **The rendering switcher is shipped**; what's missing is *reach* —
+ canvas mode unwired in web, no TikTok feeds, no social calendar seed.
+3. **Enrichment is the difference between an atlas and a wall of IDs**
+ (0170's core finding: YouTube playlist CSVs carry only video IDs).
+ Completing it (TikTok + Electron) is table stakes for this vision.
+4. **Transcripts are the only genuinely new capability**, and local-first
+ makes them uniquely feasible for xNet vs. cloud competitors.
+5. **Agent access needs packaging, not plumbing** — FTS + `searchText` +
+ `xnet_create_context_pack` exist; a social retrieval profile and a
+ transcript corpus make them useful.
+6. **Wider retrieval widens the egress hole** (0379): once transcripts and
+ DM-adjacent content are agent-readable, the existing egress-budget and
+ approval-broker guardrails in `packages/plugins/src/ai-surface/` are
+ load-bearing, and sensitive buckets must stay opt-in as 0153 established.
+
+## Options And Tradeoffs
+
+### Option A — A bespoke "Social Atlas" app surface
+
+A dedicated route with its own graph renderer, timeline, and media browser.
+
+- ✅ Maximal control over the experience.
+- ❌ Re-litigates 0158, which explicitly chose *presentation modes over a
+ bespoke social page* — and won: the switcher shipped and generalizes to
+ every schema.
+- ❌ A second graph renderer and second feed to maintain.
+
+**Rejected.** 🛑
+
+### Option B — Complete the existing seams (four work packages)
+
+Wire `createSocialCanvasProjectionPlan` into web; finish enrichment (TikTok
+oEmbed, Electron parity); add a transcript enrichment stage on the user's
+device; package agent access (social retrieval profile + seeded context
+packs + TikTok/calendar view seeds).
+
+- ✅ Every piece lands in an existing file or registry; no new package.
+- ✅ Each package ships independently and is useful alone.
+- ❌ Transcript fetching is unofficial-API territory — needs graceful
+ degradation and per-platform honesty (YouTube: yes; Instagram/TikTok:
+ no captions endpoint → description text only).
+
+**Recommended.** ✅
+
+### Option C — Official data-API integration (YouTube Data API, Meta Graph API)
+
+OAuth per platform, live sync instead of archive import, official metadata.
+
+- ✅ Richest, freshest metadata; durable against scraping countermeasures.
+- ❌ API keys, quota management, app review processes (Meta especially);
+ contradicts the archive-first, own-your-data posture; still doesn't give
+ transcripts (captions API is owner-only).
+- 🔁 Worth revisiting as an *optional* enrichment source (`data-api` is
+ already an `enrichmentSource` in `constants.ts`) once B ships.
+
+**Deferred.**
+
+> [!NOTE]
+> No new revenue lane is proposed, so the Charter §6 "no ground rent" tests
+> are not triggered. If a hosted transcript-proxy tier is ever proposed for
+> convenience, it must pass them then (a local fetch path must always
+> remain — the vanish test).
+
+## Recommendation
+
+Ship Option B as four independent work packages, in this order:
+
+```mermaid
+flowchart LR
+ subgraph WP1["WP1 · Canvas wiring"]
+ A1[Wire social canvas projection\ninto web DataWorkspace] --> A2[Saved-view canvas mode\ninserts live query frame]
+ end
+ subgraph WP2["WP2 · Enrichment completion"]
+ B1[TikTok oEmbed enrichment] --> B2[TikTok feed view seeds]
+ B3[Electron enrichment parity]
+ B4[Social calendar seed\nover interaction timestamps]
+ end
+ subgraph WP3["WP3 · Transcripts"]
+ C1[Device-local timedtext fetcher] --> C2[Transcript stored as\nSocialContent kind=transcript]
+ C2 --> C3[FTS-indexed, linked to video]
+ end
+ subgraph WP4["WP4 · Agent context"]
+ D1[Social retrieval profile] --> D2[Seeded social context packs]
+ end
+ WP1 --> WP4
+ WP2 --> WP3 --> WP4
+```
+
+### The transcript pipeline (WP3), concretely
+
+Model it as a second enrichment stage riding the existing queue: same
+deterministic-ID upsert pattern, same backoff, but **device-local fetch**
+(no hub proxy — the hub's datacenter IP is exactly what gets blocked) and
+the result stored as a linked `SocialContent` node
+(`contentKind: 'transcript'`) rather than a field on `SocialEnrichment`,
+because transcripts are large (10–100KB), deserve their own FTS row, and the
+kind already exists in the vocabulary.
+
+```mermaid
+sequenceDiagram
+ participant Q as SocialEnrichmentQueue (device)
+ participant YT as youtube.com timedtext
+ participant S as NodeStore
+ participant F as nodes_fts
+ participant AI as Agent (xnet_search)
+
+ Q->>Q: pick captioned video, jittered delay
+ Q->>YT: fetch caption track (user's residential IP)
+ alt captions exist
+ YT-->>Q: timed text (VTT/JSON)
+ Q->>S: upsert SocialContent{kind: transcript,\nrelation → video, deterministic id}
+ S->>F: index transcript text
+ else no captions / blocked
+ YT-->>Q: 404 / block
+ Q->>S: mark enrichment status=exhausted\n(loud, distinguishable from "absent")
+ end
+ AI->>F: "what did I watch about fermentation?"
+ F-->>AI: transcript hits → linked videos → collections
+```
+
+Per the repo's error rule: "no captions available," "fetch blocked," and
+"not yet attempted" must be three distinguishable states on the enrichment
+node — a truncated trickle run is not a completed one.
+
+### What the user gets when all four land
+
+Open the Data Workspace → the YouTube Playlists lens → tap
+canvas in the presentation switcher → playlists and their videos
+land as thumbnail cards with edges on the infinite canvas, deduped against
+what's already placed → tap any card for the live embed → flip the same
+lens to timeline to see it by save-date, or open the seeded
+watch-history calendar → ask the agent "summarize the themes in videos I
+saved this spring" and it retrieves across transcripts, descriptions, and
+collection structure — all local, all owned, all synced.
+
+## Risks And Open Questions
+
+- **Transcript fetch fragility** 🔶 — the timedtext surface is unofficial
+ and shifts. Mitigation: isolate behind a `TranscriptFetcher` seam, degrade
+ loudly to `status: 'exhausted'`, never block import or enrichment on it.
+- **Volume** — a heavy account = thousands of videos × 30KB transcripts ≈
+ tens of MB of text nodes. Fine for SQLite/FTS; keep transcripts out of Yjs
+ docs (they're structured nodes, LWW is fine) and respect the trickle cap.
+- **Egress** ⚠️ — agent-readable transcripts + DMs is the 0379 hazard.
+ Sensitive buckets (messages, searches) must stay outside default retrieval
+ profiles; the egress budget and approval broker gate the rest.
+- **Instagram/TikTok transcripts** — no caption endpoint exists; honest
+ scope is *description + hashtags* enrichment. On-device Whisper over saved
+ media is a separate future exploration (weight + ToS questions).
+- **Canvas scale** — the 75-node projection cap is right for insertion;
+ "my entire watch history as a canvas" is not a projection, it's a graph
+ lens (`mode: 'graph'`), and the seeded atlas already handles that.
+- **Open question** — should transcript fetch be opt-in per import run
+ (checkbox in the wizard) or ambient once enrichment is on? Leaning
+ per-run opt-in, consistent with 0153's privacy-forward staging UX.
+
+## Implementation Checklist
+
+**WP1 — Canvas wiring**
+
+- [x] Wire `createSocialCanvasProjectionPlan()` (or retire it in favor of
+ `createSavedViewCanvasProjectionNodes`) into the web
+ `DataWorkspaceCore` insert path — resolve the duplication between
+ `packages/social/src/projection/canvas.ts` and
+ `packages/react/src/components/savedViewVisualPreview.ts` first.
+- [x] Bring `onInsertSavedLensAsCanvasFrame` to parity in `apps/web`
+ (Electron-only today).
+
+**WP2 — Enrichment completion**
+
+- [x] Add TikTok oEmbed to the enrichment fetch path (direct from client —
+ TikTok sends CORS headers; fall back to hub `/unfurl` otherwise).
+- [x] Add TikTok feed view seeds (favorites, collections) to
+ `packages/social/src/feeds/defaults.ts`.
+- [x] Extract the enrichment queue from `apps/web/src/hooks/` into a shared
+ package location and wire it in Electron.
+- [x] Seed a social calendar/timeline saved view over
+ `SocialInteraction.occurredAt` (watch/like/save history).
+
+**WP3 — Transcripts**
+
+- [x] Add a `TranscriptFetcher` seam + device-local YouTube timedtext
+ implementation with jittered trickle scheduling on the enrichment
+ queue.
+- [x] Store transcripts as `SocialContent` (`contentKind: 'transcript'`)
+ linked to the video node, deterministic ID, FTS-indexed.
+- [x] Three distinguishable enrichment states: not-attempted / no-captions /
+ fetch-blocked; surface counts in the workspace privacy/status summary.
+- [x] Per-import-run opt-in toggle in the import wizard.
+
+**WP4 — Agent context**
+
+- [x] Define a social `RetrievalProfile` (content + transcripts + collection
+ structure; sensitive buckets excluded by default).
+- [x] Seed one or two social context packs via the existing
+ `xnet_create_context_pack` shape ("everything I saved about X").
+- [x] Changelog fragment ("Your imported social library now …") — user-facing
+ feature, no `skip-changelog`.
+
+## Validation Checklist
+
+- [ ] Import a real YouTube Takeout + Instagram + TikTok archive; every feed
+ view shows thumbnails and titles (no opaque-ID rows) on web **and**
+ Electron.
+- [ ] Presentation switcher round-trip on one social lens: table → feed →
+ timeline → graph → canvas, each renders without error; canvas insert
+ places source-backed cards that open live embeds.
+- [x] Transcript run over a 50-video playlist completes with per-state
+ counts (fetched / no-captions / blocked) that sum to 50.
+- [ ] `xnet_search` from an agent session returns a transcript hit and can
+ `xnet_graph_expand` from transcript → video → playlist.
+- [x] Sensitive buckets (DMs, search history) absent from default retrieval
+ profile results; present only after explicit opt-in.
+- [x] `pnpm typecheck && pnpm test` green; new logic in `packages/social`
+ covered by unit tests.
+
+> [!IMPORTANT]
+> **What the three open boxes need, and why they are still open.**
+> All thirteen implementation items shipped; these three require inputs the
+> implementation pass could not produce for itself, and checking them off
+> without those inputs would be the exact "a truncated run is not a completed
+> one" failure this exploration argues against.
+>
+> - **Real archives.** Boxes 1 and 2 need an actual YouTube Takeout, Instagram
+> and TikTok export in hand. No fixture stands in for "does a real playlist
+> CSV render with real thumbnails", which is the whole claim.
+> - **A live agent session.** Box 4 needs an agent talking to a populated
+> workspace. The blocker that would have made it fail is fixed — see below —
+> but the round trip itself has not been run.
+>
+> The transcript box is checked on a 50-target pass through
+> `runTranscriptFetchPass` with the **transport stubbed**: the accounting
+> invariant (every target lands in exactly one terminal state, and the states
+> sum to 50) is genuinely verified; a live network run against YouTube is not.
+
+> [!WARNING]
+> **Found while validating: imported social content was never in the search
+> index.** `extractSearchableContent` (`packages/sqlite/src/fts.ts`) read
+> `content`, `description`, `body`, `name` and `note` — but not `searchText`,
+> the property 22 importer call sites denormalize full text into *precisely so
+> it can be searched* (0152). Every imported post, comment and — as of this
+> exploration — every video transcript was absent from `nodes_fts` while the
+> pipeline reported it indexed, so search returned a clean empty result rather
+> than an error. This is the 0379 finding in a second disguise. Fixed, with
+> `textPreview` as the fallback. **Existing databases need `rebuildFTS()`** to
+> pick up already-imported rows; new writes are correct from here on.
+
+## References
+
+- Explorations: `0152_[x]_ACTUAL_SOCIAL_GRAPH_IMPORTER.md`,
+ `0153_[x]_SOCIAL_DATA_WORKSPACE_UI.md`, `0158` (visual data workspace),
+ `0170` (feed views + media cache), `0295` (URL up-res / unfurl),
+ `0379` (knowledge base on xNet primitives), `0374` (index pipeline).
+- Code: `packages/social/src/` (importers, schemas, projection, feeds,
+ lenses), `packages/react/src/components/SavedViewRunner.tsx`,
+ `packages/views/src/data-workspace/DataWorkspaceCore.tsx`,
+ `packages/hub/src/routes/unfurl.ts`,
+ `apps/web/src/hooks/social-feed-enrichment.ts`,
+ `packages/brain/src/`, `packages/plugins/src/ai-surface/tools/`.
+- Web: [YouTube transcript API landscape (OutlierKit)](https://outlierkit.com/resources/youtube-transcript-api/),
+ [youtube-transcript-api notes (NoteLM)](https://www.notelm.ai/blog/youtube-transcript-api),
+ [youtube-caption-extractor (TS)](https://github.com/devhims/youtube-caption-extractor),
+ [developer's guide to YouTube transcripts (Medium)](https://medium.com/@volods/how-to-get-youtube-transcripts-a-complete-developers-guide-b3f092eb0a96),
+ [production extraction notes (TranscriptAPI)](https://transcriptapi.com/blog/extract-youtube-transcripts-programmatically),
+ [Tavlo](https://www.tavlo.ca/), [Second Brain](https://www.thesecondbrain.io/).
diff --git a/packages/data/etc/data.api.md b/packages/data/etc/data.api.md
index c951d7a69..8fbf7091d 100644
--- a/packages/data/etc/data.api.md
+++ b/packages/data/etc/data.api.md
@@ -5021,6 +5021,9 @@ export const DEFAULT_EXTERNAL_REFERENCE_IFRAME_ALLOW = "accelerometer; autoplay;
// @public (undocumented)
export const DEFAULT_OFFLINE_POLICY: OfflineAuthPolicy;
+// @public
+export const DEFAULT_PROMOTION_THRESHOLD = 8;
+
// @public
export const DEFAULT_ROW_HEIGHT: RowHeight;
@@ -8744,6 +8747,14 @@ export type PortableChangeRecord = {
batchSize?: number;
};
+// @public
+export type PortableHubAddress = {
+ name: string;
+ url: string;
+ resolverUrl?: string;
+ observedAt: number;
+};
+
// @public
export type PortableYjsDocRecord = {
nodeId: string;
@@ -8955,6 +8966,16 @@ export const ProjectSchema: DefinedSchema<{
// @public
export function promoteOverlay(authority: string, field: string, coreProp: string): LensOperation;
+// @public (undocumented)
+export interface PromotionProposal {
+ authority: string;
+ count: number;
+ coverage: number;
+ field: string;
+ lens: SchemaLens;
+ overlayKey: string;
+}
+
// @public
export interface PropertyBuilder {
coerce(value: unknown): T | null;
@@ -8989,6 +9010,18 @@ export interface PropertyTimestamp {
// @public
export type PropertyType = 'text' | 'number' | 'checkbox' | 'json' | 'date' | 'dateRange' | 'geo' | 'select' | 'multiSelect' | 'person' | 'relation' | 'rollup' | 'formula' | 'url' | 'email' | 'phone' | 'file' | 'created' | 'updated' | 'createdBy';
+// @public
+export function proposePromotion(rows: ReadonlyArray>, overlayKey: string, from: SchemaIRI, to: SchemaIRI, options?: ProposePromotionOptions): PromotionProposal | null;
+
+// @public (undocumented)
+export interface ProposePromotionOptions {
+ dismissed?: Iterable;
+ threshold?: number;
+}
+
+// @public
+export function proposePromotions(rows: ReadonlyArray>, from: SchemaIRI, to: SchemaIRI, options?: ProposePromotionOptions): PromotionProposal[];
+
// @public
export function pruneVersionHistory(history: SchemaVersionEntry[]): SchemaVersionEntry[];
@@ -11419,6 +11452,7 @@ export type WriteBundleOptions = {
ownerDid: string;
manifestSigner?: (bytes: Uint8Array) => Promise | Uint8Array;
commitSigner?: (bytes: Uint8Array) => Promise | Uint8Array;
+ hubAddress?: PortableHubAddress;
since?: BundleFrontier;
blobPort?: BundleBlobPort;
yjsPort?: BundleYjsPort;
@@ -11456,6 +11490,7 @@ export type XnetpackManifest = {
commits?: number;
};
contentDigest: string;
+ hubAddress?: PortableHubAddress;
signatureB64?: string;
};
diff --git a/packages/react/etc/react.api.md b/packages/react/etc/react.api.md
index 20e9717a7..70360fff0 100644
--- a/packages/react/etc/react.api.md
+++ b/packages/react/etc/react.api.md
@@ -54,6 +54,8 @@ import { FrontierEntry } from '@xnetjs/history';
import { HistoricalState } from '@xnetjs/history';
import { HistoryHorizon } from '@xnetjs/history';
import { HistoryTarget } from '@xnetjs/history';
+import { HubAddressOutcome } from '@xnetjs/runtime';
+import { HubAddressStorage } from '@xnetjs/runtime';
import { HybridKeyBundle } from '@xnetjs/identity';
import { Identity } from '@xnetjs/identity';
import { ImporterContribution } from '@xnetjs/plugins';
@@ -364,6 +366,7 @@ export function createSavedViewVisualCanvasProjectionRequest(input: {
layout: SavedViewVisualLayoutOption;
previews: readonly SavedViewVisualPreviewModel[];
nodes: readonly SavedViewCanvasProjectionNode[];
+ edges?: readonly SavedViewCanvasProjectionEdge[];
}): SavedViewVisualCanvasProjectionRequest;
// @public (undocumented)
@@ -841,6 +844,14 @@ export function hasSavedViewVisualPreviewSensitiveData(preview: SavedViewVisualP
export { HistoryHorizon }
+// @public
+export interface HubAddressConfig {
+ fetchImpl?: typeof fetch;
+ name: string;
+ resolverUrl: string;
+ storage?: HubAddressStorage;
+}
+
// Warning: (ae-forgotten-export) The symbol "HubConnectScreenProps" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
@@ -1630,6 +1641,15 @@ export interface ReplyContext {
replyToUser?: string;
}
+// @public (undocumented)
+export interface ResolvedHubUrl {
+ fallbacks: string[];
+ // (undocumented)
+ outcome: HubAddressOutcome | null;
+ stale: boolean;
+ url: string | null;
+}
+
export { RestoreResult }
// @public
@@ -1645,6 +1665,15 @@ export type RunAtprotoCeremony = (input: {
handleOrPds: string;
}) => Promise;
+// @public
+export type SavedViewCanvasProjectionEdge = {
+ id: string;
+ sourceId: string;
+ targetId: string;
+ relationshipKind: string;
+ label?: string;
+};
+
// @public (undocumented)
export type SavedViewCanvasProjectionNode = {
id: string;
@@ -1875,6 +1904,7 @@ export type SavedViewVisualCanvasProjectionRequest = {
projectionGroupBy: SavedViewVisualLayoutOption['projectionGroupBy'];
};
nodes: SavedViewCanvasProjectionNode[];
+ edges: SavedViewCanvasProjectionEdge[];
sourceNodeIds: string[];
omittedNodeCount: number;
previewCount: number;
@@ -3036,6 +3066,9 @@ export interface UseRelatedRowsResult {
// @public (undocumented)
export const useRemoteSchema: (iri: string | undefined) => RemoteSchemaState;
+// @public
+export function useResolvedHubUrl(address: HubAddressConfig | undefined, configuredHubUrl: string | null): ResolvedHubUrl;
+
// @public
export function useReverseRelations(rowId: string, databaseId: string): UseReverseRelationsResult;
@@ -3334,6 +3367,7 @@ export interface XNetConfig {
disablePlugins?: boolean;
disableSyncManager?: boolean;
encryptionKey?: Uint8Array;
+ hubAddress?: HubAddressConfig;
hubOptions?: {
autoAuth?: boolean;
authToken?: string;
diff --git a/packages/react/src/components/SavedViewRunner.tsx b/packages/react/src/components/SavedViewRunner.tsx
index 4188fb4a3..ad0ce4a1c 100644
--- a/packages/react/src/components/SavedViewRunner.tsx
+++ b/packages/react/src/components/SavedViewRunner.tsx
@@ -240,6 +240,21 @@ export type SavedViewVisualLayoutOption = {
projectionGroupBy: 'platform' | 'kind' | 'creator' | 'privacy'
}
+/**
+ * A relationship between two projected nodes.
+ *
+ * `relationshipKind` stays an open string here rather than the social
+ * package's closed vocabulary: this package must not depend on that one, and
+ * the consumer that cares narrows it when it builds a layout plan.
+ */
+export type SavedViewCanvasProjectionEdge = {
+ id: string
+ sourceId: string
+ targetId: string
+ relationshipKind: string
+ label?: string
+}
+
export type SavedViewVisualCanvasProjectionRequest = {
id: string
title: string
@@ -254,6 +269,8 @@ export type SavedViewVisualCanvasProjectionRequest = {
projectionGroupBy: SavedViewVisualLayoutOption['projectionGroupBy']
}
nodes: SavedViewCanvasProjectionNode[]
+ /** Relationships between the projected nodes, when the result carries any. */
+ edges: SavedViewCanvasProjectionEdge[]
sourceNodeIds: string[]
omittedNodeCount: number
previewCount: number
@@ -1027,6 +1044,18 @@ export function SavedViewRunner({
() => deriveSavedViewVisualGraphEdges(arrangedVisualPreviews),
[arrangedVisualPreviews]
)
+ // The same relationships, in the shape a canvas projection consumes (0419).
+ const visualCanvasProjectionEdges = useMemo(
+ () =>
+ visualGraphEdges.map((edge) => ({
+ id: edge.id,
+ sourceId: edge.sourceNodeId,
+ targetId: edge.targetNodeId,
+ relationshipKind: edge.kind,
+ label: edge.kind
+ })),
+ [visualGraphEdges]
+ )
const presentationModeOptions = useMemo(
() =>
createSavedViewPresentationModeOptions({
@@ -1494,6 +1523,7 @@ export function SavedViewRunner({
{
+ const placed = new Set(input.nodes.map((node) => node.id))
+ return placed.has(edge.sourceId) && placed.has(edge.targetId)
+ }),
sourceNodeIds: input.nodes.map((node) => node.id),
omittedNodeCount: Math.max(0, input.previews.length - input.nodes.length),
previewCount: input.previews.length
@@ -2229,6 +2266,7 @@ function groupCanvasProjectionNodes(
function SavedViewVisualCanvasProjectionPanel({
previews,
projectionNodes,
+ projectionEdges,
layout,
descriptor,
title,
@@ -2239,6 +2277,7 @@ function SavedViewVisualCanvasProjectionPanel({
}: {
previews: SavedViewVisualPreviewModel[]
projectionNodes: SavedViewCanvasProjectionNode[]
+ projectionEdges?: SavedViewCanvasProjectionEdge[]
layout: SavedViewVisualLayoutOption
descriptor?: SavedViewDescriptor | string | null
title: string
@@ -2266,7 +2305,8 @@ function SavedViewVisualCanvasProjectionPanel({
sourceSchemaId,
layout,
previews,
- nodes: projectionNodes
+ nodes: projectionNodes,
+ ...(projectionEdges ? { edges: projectionEdges } : {})
})
return (
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 6921da2d7..9558ffef9 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -86,6 +86,7 @@ export {
type SavedViewRowInspectorModel,
type SavedViewRunnerProps,
type SavedViewSortDirection,
+ type SavedViewCanvasProjectionEdge,
type SavedViewVisualCanvasProjectionRequest,
type SavedViewVisualLayoutId,
type SavedViewVisualLayoutOption,
diff --git a/packages/social/package.json b/packages/social/package.json
index 72113605b..58779d57f 100644
--- a/packages/social/package.json
+++ b/packages/social/package.json
@@ -68,10 +68,22 @@
"./connect": {
"import": "./src/connect/index.ts",
"types": "./src/connect/index.ts"
+ },
+ "./enrichment": {
+ "import": "./src/enrichment/index.ts",
+ "types": "./src/enrichment/index.ts"
+ },
+ "./transcripts": {
+ "import": "./src/transcripts/index.ts",
+ "types": "./src/transcripts/index.ts"
+ },
+ "./retrieval": {
+ "import": "./src/retrieval/index.ts",
+ "types": "./src/retrieval/index.ts"
}
},
"scripts": {
- "build": "tsup src/index.ts src/schemas/index.ts src/import/index.ts src/import/core.ts src/import/browser.ts src/import/node.ts src/importers/index.ts src/feeds/index.ts src/lenses/index.ts src/patterns/index.ts src/projection/index.ts src/publish/index.ts src/views/index.ts src/workspace/index.ts src/connect/index.ts --format esm --dts",
+ "build": "tsup src/index.ts src/schemas/index.ts src/import/index.ts src/import/core.ts src/import/browser.ts src/import/node.ts src/importers/index.ts src/feeds/index.ts src/lenses/index.ts src/patterns/index.ts src/projection/index.ts src/publish/index.ts src/views/index.ts src/workspace/index.ts src/connect/index.ts src/enrichment/index.ts src/transcripts/index.ts src/retrieval/index.ts --format esm --dts",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
diff --git a/packages/social/src/__tests__/views-lenses-projection.test.ts b/packages/social/src/__tests__/views-lenses-projection.test.ts
index 12c8e59b4..ffa3fd01f 100644
--- a/packages/social/src/__tests__/views-lenses-projection.test.ts
+++ b/packages/social/src/__tests__/views-lenses-projection.test.ts
@@ -95,16 +95,22 @@ describe('social feed views', () => {
'social.feed.youtube-videos',
'social.feed.youtube-playlists',
'social.feed.instagram-saved',
- 'social.feed.instagram-likes'
+ 'social.feed.instagram-likes',
+ 'social.feed.tiktok-videos',
+ 'social.feed.tiktok-collections',
+ 'social.feed.activity-timeline'
])
for (const feed of feeds) {
expect(validateSavedViewDescriptor(feed.descriptor).valid).toBe(true)
- expect(feed.descriptor.presentation).toEqual({
- mode: 'feed',
- feedLayout: 'grid',
- feedDensity: 'cozy'
- })
+ // Media feeds open as a thumbnail grid; the activity view is over
+ // interactions, which carry a timestamp and no thumbnail of their own,
+ // so it opens on the time axis instead (0419).
+ expect(feed.descriptor.presentation).toEqual(
+ feed.id === 'social.feed.activity-timeline'
+ ? { mode: 'timeline', feedLayout: 'list', feedDensity: 'compact' }
+ : { mode: 'feed', feedLayout: 'grid', feedDensity: 'cozy' }
+ )
expect(JSON.parse(feed.savedViewProperties.descriptor).presentation).toEqual(
feed.descriptor.presentation
)
@@ -134,6 +140,9 @@ describe('social workspace seeds', () => {
'social.feed.youtube-playlists',
'social.feed.instagram-saved',
'social.feed.instagram-likes',
+ 'social.feed.tiktok-videos',
+ 'social.feed.tiktok-collections',
+ 'social.feed.activity-timeline',
'social.lens.people-i-follow',
'social.lens.saved-content-by-creator',
'social.lens.conversation-references',
@@ -143,7 +152,7 @@ describe('social workspace seeds', () => {
repeated.map((seed) => seed.deterministicId)
)
expect(seeds.filter((seed) => seed.seedKind === 'schema-view')).toHaveLength(6)
- expect(seeds.filter((seed) => seed.seedKind === 'feed-view')).toHaveLength(4)
+ expect(seeds.filter((seed) => seed.seedKind === 'feed-view')).toHaveLength(7)
expect(seeds.filter((seed) => seed.seedKind === 'graph-lens')).toHaveLength(4)
for (const seed of seeds) {
diff --git a/apps/web/src/hooks/social-feed-enrichment.test.ts b/packages/social/src/enrichment/enrichment.test.ts
similarity index 77%
rename from apps/web/src/hooks/social-feed-enrichment.test.ts
rename to packages/social/src/enrichment/enrichment.test.ts
index 7622be7d7..f31e4f70e 100644
--- a/apps/web/src/hooks/social-feed-enrichment.test.ts
+++ b/packages/social/src/enrichment/enrichment.test.ts
@@ -11,9 +11,11 @@ import {
resolveHubAuthToken,
socialEnrichmentKey,
SocialEnrichmentQueue,
+ supportsDirectOEmbed,
thumbnailContentTypeFor,
+ tiktokOEmbedUrl,
type SocialEnrichmentTarget
-} from './social-feed-enrichment'
+} from './index'
const target: SocialEnrichmentTarget = {
key: socialEnrichmentKey('youtube', 'abc123'),
@@ -269,18 +271,100 @@ describe('fetchEnrichmentForTarget', () => {
})
})
+describe('direct TikTok oEmbed', () => {
+ const tiktokTarget: SocialEnrichmentTarget = {
+ key: socialEnrichmentKey('tiktok', '7123'),
+ platform: 'tiktok',
+ platformContentId: '7123',
+ url: 'https://www.tiktok.com/@chef/video/7123'
+ }
+
+ it('marks TikTok as the one platform reachable without the hub', () => {
+ expect(supportsDirectOEmbed('tiktok')).toBe(true)
+ expect(supportsDirectOEmbed('youtube')).toBe(false)
+ expect(new URL(tiktokOEmbedUrl(tiktokTarget.url)).searchParams.get('url')).toBe(
+ tiktokTarget.url
+ )
+ })
+
+ it('resolves TikTok metadata with no hub configured', async () => {
+ const requested: string[] = []
+ const result = await fetchEnrichmentForTarget({
+ target: tiktokTarget,
+ blobStore: null,
+ fetchImpl: (async (input: RequestInfo | URL) => {
+ requested.push(String(input))
+ return new Response(
+ JSON.stringify({
+ title: 'Sourdough in 60 seconds',
+ author_name: 'chef',
+ thumbnail_url: 'https://p16.tiktokcdn.com/thumb.jpg'
+ }),
+ { headers: { 'Content-Type': 'application/json' } }
+ )
+ }) as typeof fetch
+ })
+
+ expect(requested[0]).toContain('tiktok.com/oembed')
+ expect(result.payload.status).toBe('resolved')
+ expect(result.payload.metadata?.title).toBe('Sourdough in 60 seconds')
+ expect(result.payload.metadata?.source).toBe('oembed')
+ })
+
+ it('treats a 200 with no metadata as unavailable rather than resolved', async () => {
+ const result = await fetchEnrichmentForTarget({
+ target: tiktokTarget,
+ blobStore: null,
+ fetchImpl: (async () =>
+ new Response('{}', { headers: { 'Content-Type': 'application/json' } })) as typeof fetch
+ })
+
+ expect(result.payload.status).toBe('unavailable')
+ })
+
+ it('separates a refusal from a missing video', async () => {
+ const blocked = await fetchEnrichmentForTarget({
+ target: tiktokTarget,
+ blobStore: null,
+ fetchImpl: (async () => new Response('', { status: 429 })) as typeof fetch
+ })
+ expect(blocked.payload.status).toBe('blocked')
+
+ const missing = await fetchEnrichmentForTarget({
+ target: tiktokTarget,
+ blobStore: null,
+ fetchImpl: (async () => new Response('', { status: 404 })) as typeof fetch
+ })
+ expect(missing.payload.status).toBe('unavailable')
+ })
+
+ it('reports hub-only platforms as unavailable when there is no hub', async () => {
+ const result = await fetchEnrichmentForTarget({
+ target,
+ blobStore: null,
+ fetchImpl: (async () => {
+ throw new Error('should not fetch')
+ }) as typeof fetch
+ })
+
+ expect(result.payload.status).toBe('unavailable')
+ expect(result.payload.reason).toContain('no hub available')
+ })
+})
+
describe('loadMissingThumbnailBlobUrls', () => {
it('creates object urls for uncached blob cids only', async () => {
const added = await loadMissingThumbnailBlobUrls({
rows: [
- { id: 'a', thumbnailBlobCid: 'cid:blake3:one' },
- { id: 'b', thumbnailBlobCid: 'cid:blake3:cached' },
- { id: 'c', thumbnailBlobCid: 'not-a-cid' },
- { id: 'd' }
+ { thumbnailBlobCid: 'cid:blake3:one' },
+ { thumbnailBlobCid: 'cid:blake3:cached' },
+ { thumbnailBlobCid: 'not-a-cid' },
+ {}
],
blobStore: { get: async () => new Uint8Array([9]) },
hasUrl: (cid) => cid === 'cid:blake3:cached',
- createUrl: () => 'blob:created'
+ createUrl: () => 'blob:created',
+ contentTypeFor: thumbnailContentTypeFor
})
expect([...added.entries()]).toEqual([['cid:blake3:one', 'blob:created']])
@@ -288,18 +372,20 @@ describe('loadMissingThumbnailBlobUrls', () => {
it('skips blobs that fail to load and stops when cancelled', async () => {
const missing = await loadMissingThumbnailBlobUrls({
- rows: [{ id: 'a', thumbnailBlobCid: 'cid:blake3:gone' }],
+ rows: [{ thumbnailBlobCid: 'cid:blake3:gone' }],
blobStore: { get: async () => null },
hasUrl: () => false,
- createUrl: () => 'blob:never'
+ createUrl: () => 'blob:never',
+ contentTypeFor: thumbnailContentTypeFor
})
expect(missing.size).toBe(0)
const cancelled = await loadMissingThumbnailBlobUrls({
- rows: [{ id: 'a', thumbnailBlobCid: 'cid:blake3:one' }],
+ rows: [{ thumbnailBlobCid: 'cid:blake3:one' }],
blobStore: { get: async () => new Uint8Array([1]) },
hasUrl: () => false,
createUrl: () => 'blob:never',
+ contentTypeFor: thumbnailContentTypeFor,
isCancelled: () => true
})
expect(cancelled.size).toBe(0)
diff --git a/packages/social/src/enrichment/fetch.ts b/packages/social/src/enrichment/fetch.ts
new file mode 100644
index 000000000..9bf0684e0
--- /dev/null
+++ b/packages/social/src/enrichment/fetch.ts
@@ -0,0 +1,268 @@
+/**
+ * Where enrichment metadata actually comes from.
+ *
+ * Exploration 0170 found that CORS, not product design, dictates this layer:
+ * YouTube's oEmbed, Instagram and X all refuse a browser fetch, so the hub has
+ * to proxy them. TikTok is the exception — its oEmbed endpoint sends CORS
+ * headers, so the client can ask it directly.
+ *
+ * That exception is worth taking rather than routing everything through the
+ * hub for symmetry, because it is the only path that works with **no hub at
+ * all**. A desktop user with a purely local workspace gets TikTok titles and
+ * thumbnails; before this, they got nothing.
+ *
+ * Thumbnail *bytes* are a separate question from thumbnail *URLs*. The CDN
+ * that serves them does not send CORS headers, so bytes still need the hub —
+ * and when there is no hub we keep the URL and let the image element fetch it,
+ * which is the same request the page would make anyway.
+ */
+
+import type { SocialEnrichmentTarget, SocialUnfurlMetadataPayload } from './targets'
+
+const TIKTOK_OEMBED_ENDPOINT = 'https://www.tiktok.com/oembed'
+
+/** Platforms whose oEmbed endpoint is reachable from a browser. */
+export const DIRECT_OEMBED_PLATFORMS: readonly string[] = ['tiktok']
+
+export function supportsDirectOEmbed(platform: string): boolean {
+ return DIRECT_OEMBED_PLATFORMS.includes(platform)
+}
+
+/** The subset of the TikTok oEmbed response we read. */
+type TikTokOEmbedResponse = {
+ title?: string
+ author_name?: string
+ author_url?: string
+ thumbnail_url?: string
+ provider_name?: string
+}
+
+export function tiktokOEmbedUrl(contentUrl: string): string {
+ const url = new URL(TIKTOK_OEMBED_ENDPOINT)
+ url.searchParams.set('url', contentUrl)
+ return url.toString()
+}
+
+/**
+ * Fetch TikTok metadata straight from the client.
+ *
+ * Returns the same payload shape the hub's `/unfurl/metadata` produces, so the
+ * caller does not branch on where the metadata came from — only on whether it
+ * arrived.
+ */
+export async function fetchTikTokOEmbed(input: {
+ target: SocialEnrichmentTarget
+ fetchImpl?: typeof fetch
+ signal?: AbortSignal
+}): Promise {
+ const fetchImpl = input.fetchImpl ?? fetch
+
+ let response: Response
+ try {
+ response = await fetchImpl(
+ tiktokOEmbedUrl(input.target.url),
+ input.signal ? { signal: input.signal } : {}
+ )
+ } catch (error) {
+ return {
+ status: 'error',
+ reason: error instanceof Error ? error.message : 'oEmbed request threw'
+ }
+ }
+
+ if (response.status === 404) {
+ return { status: 'unavailable', reason: 'video not found' }
+ }
+ if (!response.ok) {
+ const blocked = response.status === 403 || response.status === 429
+ return {
+ status: blocked ? 'blocked' : 'error',
+ reason: `oEmbed request failed with ${response.status}`
+ }
+ }
+
+ let body: TikTokOEmbedResponse
+ try {
+ body = (await response.json()) as TikTokOEmbedResponse
+ } catch {
+ return { status: 'error', reason: 'oEmbed response was not JSON' }
+ }
+
+ // A 200 with no title is TikTok's way of saying the video is gone or
+ // private. Treating it as resolved would write an enrichment node that
+ // renders as a blank card forever.
+ if (!body.title && !body.thumbnail_url) {
+ return { status: 'unavailable', reason: 'oEmbed returned no metadata' }
+ }
+
+ return {
+ status: 'resolved',
+ metadata: {
+ title: body.title ?? null,
+ description: null,
+ imageUrl: body.thumbnail_url ?? null,
+ authorName: body.author_name ?? null,
+ providerName: body.provider_name ?? 'TikTok',
+ source: 'oembed',
+ sourceUrl: input.target.url
+ }
+ }
+}
+
+export type EnrichmentFetchResult = {
+ payload: SocialUnfurlMetadataPayload
+ thumbnailBlobCid?: string
+ thumbnailContentType?: string
+}
+
+export type BlobPutSink = { put(data: Uint8Array): Promise }
+
+/**
+ * Capture a thumbnail into the blob store through the hub's image proxy.
+ *
+ * Returns nothing rather than throwing when the image cannot be captured: a
+ * missing local copy degrades to loading the URL directly, which is a slower
+ * card, not a broken one.
+ */
+async function captureThumbnail(input: {
+ httpUrl: string
+ headers: Record
+ imageUrl: string
+ blobStore: BlobPutSink
+ fetchImpl: typeof fetch
+}): Promise<{ thumbnailBlobCid?: string; thumbnailContentType?: string }> {
+ const response = await input
+ .fetchImpl(`${input.httpUrl}/unfurl/image?url=${encodeURIComponent(input.imageUrl)}`, {
+ headers: input.headers
+ })
+ .catch(() => null)
+ if (!response?.ok) return {}
+
+ const bytes = new Uint8Array(await response.arrayBuffer())
+ if (bytes.byteLength === 0) return {}
+
+ return {
+ thumbnailBlobCid: await input.blobStore.put(bytes),
+ thumbnailContentType: response.headers.get('content-type') ?? undefined
+ }
+}
+
+export type FetchEnrichmentInput = {
+ target: SocialEnrichmentTarget
+ blobStore: BlobPutSink | null
+ /** Hub base URL. When absent, only direct-oEmbed platforms can resolve. */
+ httpUrl?: string
+ headers?: Record
+ fetchImpl?: typeof fetch
+ signal?: AbortSignal
+}
+
+/**
+ * Resolve metadata for one target.
+ *
+ * Direct oEmbed first where it works, hub proxy otherwise. With no hub and no
+ * direct path the result is an explicit `unavailable` rather than a silent
+ * no-op, so the queue records the attempt and does not spin on it.
+ */
+export async function fetchEnrichmentForTarget(
+ input: FetchEnrichmentInput
+): Promise {
+ const fetchImpl = input.fetchImpl ?? fetch
+ const headers = input.headers ?? {}
+
+ if (supportsDirectOEmbed(input.target.platform)) {
+ const payload = await fetchTikTokOEmbed({
+ target: input.target,
+ fetchImpl,
+ ...(input.signal ? { signal: input.signal } : {})
+ })
+
+ const imageUrl = payload.metadata?.imageUrl
+ if (payload.status !== 'resolved' || !imageUrl || !input.blobStore || !input.httpUrl) {
+ return { payload }
+ }
+
+ return {
+ payload,
+ ...(await captureThumbnail({
+ httpUrl: input.httpUrl,
+ headers,
+ imageUrl,
+ blobStore: input.blobStore,
+ fetchImpl
+ }))
+ }
+ }
+
+ if (!input.httpUrl) {
+ return {
+ payload: {
+ status: 'unavailable',
+ reason: `no hub available to unfurl ${input.target.platform}`
+ }
+ }
+ }
+
+ const response = await fetchImpl(
+ `${input.httpUrl}/unfurl/metadata?url=${encodeURIComponent(input.target.url)}&provider=${encodeURIComponent(input.target.platform)}`,
+ { headers }
+ )
+ if (!response.ok) {
+ throw new Error(`Unfurl request failed with ${response.status}`)
+ }
+ const payload = (await response.json()) as SocialUnfurlMetadataPayload
+
+ const imageUrl = payload.metadata?.imageUrl
+ if (payload.status !== 'resolved' || !imageUrl || !input.blobStore) {
+ return { payload }
+ }
+
+ return {
+ payload,
+ ...(await captureThumbnail({
+ httpUrl: input.httpUrl,
+ headers,
+ imageUrl,
+ blobStore: input.blobStore,
+ fetchImpl
+ }))
+ }
+}
+
+/**
+ * Materialize object URLs for blob-cached thumbnails that are not in the
+ * cache yet. Returns the new cid → object URL entries.
+ */
+export type ThumbnailBlobRow = {
+ thumbnailBlobCid?: string
+ metadataJson?: string
+}
+
+export async function loadMissingThumbnailBlobUrls(input: {
+ rows: readonly ThumbnailBlobRow[]
+ blobStore: { get(cid: `cid:blake3:${string}`): Promise }
+ hasUrl: (cid: string) => boolean
+ createUrl: (blob: Blob) => string
+ contentTypeFor: (row: ThumbnailBlobRow) => string
+ limit?: number
+ isCancelled?: () => boolean
+}): Promise