From 6ed328b358904d6cd6ea8ea2438a3bcdda721717 Mon Sep 17 00:00:00 2001 From: Mr-Macharia Date: Wed, 15 Jul 2026 11:53:35 +0300 Subject: [PATCH 1/4] Add asset-by-reference storage for user media Store uploaded images/icons in IndexedDB by content hash and reference them from nodes via imageAssetId/iconAssetId. Gate with assetStoreV1 and lazy-migrate legacy data URLs on save. --- ARCHITECTURE.md | 1 + src/components/CustomNode.tsx | 17 +- src/components/FlowEditorPanels.tsx | 2 +- src/components/ImageNode.tsx | 7 +- src/components/command-bar/types.ts | 2 +- .../custom-nodes/ArchitectureNode.tsx | 8 +- src/components/custom-nodes/BrowserNode.tsx | 4 +- src/components/custom-nodes/MobileNode.tsx | 4 +- .../flow-canvas/useFlowCanvasDragDrop.ts | 27 +- .../buildFlowEditorControllerParams.ts | 2 +- src/components/flow-editor/panelProps.ts | 2 +- .../flow-editor/useFlowEditorController.ts | 2 +- .../properties/BulkNodeProperties.tsx | 8 +- src/components/properties/IconPicker.tsx | 73 +++-- src/components/properties/ImageUpload.tsx | 144 +++++---- src/components/properties/NodeProperties.tsx | 16 +- .../properties/bulkNodePropertiesModel.ts | 4 +- .../families/ArchitectureNodeSection.tsx | 69 ++-- src/config/rolloutFlags.ts | 13 +- src/hooks/node-operations/nodeFactories.ts | 11 +- .../node-operations/useNodeOperationAdders.ts | 5 +- src/hooks/useResolvedMediaUrl.ts | 55 ++++ src/lib/nodeBulkEditing.ts | 1 + src/lib/nodeIconState.test.ts | 15 + src/lib/nodeIconState.ts | 17 +- src/lib/nodeMediaState.test.ts | 67 ++++ src/lib/nodeMediaState.ts | 103 ++++++ src/lib/nodeStyleData.ts | 1 + src/lib/types.ts | 9 +- src/services/storage/assetEncode.test.ts | 17 + src/services/storage/assetEncode.ts | 246 +++++++++++++++ src/services/storage/assetHash.test.ts | 26 ++ src/services/storage/assetHash.ts | 42 +++ src/services/storage/assetMigration.test.ts | 70 +++++ src/services/storage/assetMigration.ts | 83 +++++ src/services/storage/assetStore.ts | 294 ++++++++++++++++++ src/services/storage/assetTypes.ts | 53 ++++ src/services/storage/localFirstRuntime.ts | 77 ++++- 38 files changed, 1442 insertions(+), 155 deletions(-) create mode 100644 src/hooks/useResolvedMediaUrl.ts create mode 100644 src/lib/nodeMediaState.test.ts create mode 100644 src/lib/nodeMediaState.ts create mode 100644 src/services/storage/assetEncode.test.ts create mode 100644 src/services/storage/assetEncode.ts create mode 100644 src/services/storage/assetHash.test.ts create mode 100644 src/services/storage/assetHash.ts create mode 100644 src/services/storage/assetMigration.test.ts create mode 100644 src/services/storage/assetMigration.ts create mode 100644 src/services/storage/assetStore.ts create mode 100644 src/services/storage/assetTypes.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 15ade00b..389e7e6e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -130,6 +130,7 @@ Current behavior at a high level: - IndexedDB store and index definitions are now declared in one schema manifest in `src/services/storage/indexedDbSchema.ts` - schema migration markers now live in a dedicated IndexedDB schema metadata store instead of sharing the persisted Zustand state store - local-first chat persistence now uses document-scoped IndexedDB indexes instead of full chat-message store scans +- user images/icons can be stored by content-hash ref in the IndexedDB `assets` store (`assetStoreV1` rollout flag) instead of embedding multi-MB data URLs into every document/history/snapshot copy; nodes hold `imageAssetId` / `iconAssetId` and resolve display URLs at render time Important constraint: diff --git a/src/components/CustomNode.tsx b/src/components/CustomNode.tsx index 864462c5..18f8ecd5 100644 --- a/src/components/CustomNode.tsx +++ b/src/components/CustomNode.tsx @@ -12,6 +12,7 @@ import { NodeTransformControls } from './NodeTransformControls'; import { useActiveNodeSelection } from './useActiveNodeSelection'; import { useTranslation } from 'react-i18next'; import { useProviderShapePreview } from '@/hooks/useProviderShapePreview'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { useShiftHeld } from '@/hooks/useShiftHeld'; import { NodeShapeSVG } from './NodeShapeSVG'; import { DiffBadge, LintViolationBadge } from './NodeBadges'; @@ -73,10 +74,12 @@ function CustomNode(props: LegacyNodeProps): React.ReactElement { const explicitHeightPx = getNumericNodeDimension(explicitHeight); const measuredHeight = (props as { height?: number }).height; const shiftHeld = useShiftHeld(Boolean(selected)); + const resolvedUploadedIconUrl = useResolvedMediaUrl(data, 'image'); + const resolvedCustomIconUrl = useResolvedMediaUrl(data, 'icon'); const resolvedAssetIconUrl = useProviderShapePreview( typeof data.archIconPackId === 'string' ? data.archIconPackId : undefined, typeof data.archIconShapeId === 'string' ? data.archIconShapeId : undefined, - typeof data.customIconUrl === 'string' ? data.customIconUrl : undefined + resolvedCustomIconUrl ); const designSystem = useDesignSystem(); const isActiveSelected = useActiveNodeSelection(Boolean(selected)); @@ -107,7 +110,12 @@ function CustomNode(props: LegacyNodeProps): React.ReactElement { const subLabelSizeClass = fontSizeClassFor(subLabelFontSize); const subLabelFontSizeStyle = subLabelIsNumericSize ? { fontSize: subLabelFontSize + 'px' } : {}; const hasProviderIcon = Boolean(resolvedAssetIconUrl) || Boolean(data.archIconPackId); - const hasIcon = Boolean(iconName) || Boolean(data.customIconUrl) || hasProviderIcon; + const hasIcon = + Boolean(iconName) + || Boolean(data.customIconUrl) + || Boolean(data.iconAssetId) + || Boolean(resolvedCustomIconUrl) + || hasProviderIcon; const hasLabel = Boolean(data.label?.trim()); const hasSubLabel = Boolean(data.subLabel); const mermaidImportedNodeMetadata = readMermaidImportedNodeMetadataFromData(data); @@ -294,7 +302,10 @@ function CustomNode(props: LegacyNodeProps): React.ReactElement { )} void; onAddClassNode?: () => void; onAddEntityNode?: () => void; - onAddImage: (imageUrl: string) => void; + onAddImage: (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => void; onAddBrowserWireframe: () => void; onAddMobileWireframe: () => void; onAddDomainLibraryItem?: (item: DomainLibraryItem) => void; diff --git a/src/components/ImageNode.tsx b/src/components/ImageNode.tsx index 711ca4aa..73f5094b 100644 --- a/src/components/ImageNode.tsx +++ b/src/components/ImageNode.tsx @@ -1,9 +1,12 @@ import React, { memo } from 'react'; import type { LegacyNodeProps } from '@/lib/reactflowCompat'; import type { NodeData } from '@/lib/types'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { NodeChrome } from './NodeChrome'; function ImageNode({ id, data, selected }: LegacyNodeProps): React.ReactElement { + const imageSrc = useResolvedMediaUrl(data, 'image'); + return ( ): React.Rea transform: data.rotation ? `rotate(${data.rotation}deg)` : 'none', }} > - {data.imageUrl ? ( + {imageSrc ? ( {data.label diff --git a/src/components/command-bar/types.ts b/src/components/command-bar/types.ts index 17aeac0f..28137356 100644 --- a/src/components/command-bar/types.ts +++ b/src/components/command-bar/types.ts @@ -61,7 +61,7 @@ export interface CommandBarProps { onAddSequence?: () => void; onAddClassNode?: () => void; onAddEntityNode?: () => void; - onAddImage?: (imageUrl: string) => void; + onAddImage?: (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => void; onAddBrowserWireframe?: () => void; onAddMobileWireframe?: () => void; onAddDomainLibraryItem?: (item: DomainLibraryItem) => void; diff --git a/src/components/custom-nodes/ArchitectureNode.tsx b/src/components/custom-nodes/ArchitectureNode.tsx index d70621ff..3496c5f1 100644 --- a/src/components/custom-nodes/ArchitectureNode.tsx +++ b/src/components/custom-nodes/ArchitectureNode.tsx @@ -7,6 +7,7 @@ import { NodeChrome } from '@/components/NodeChrome'; import { getTransformDiagnosticsAttrs } from '@/components/transformDiagnostics'; import { resolveNodeVisualStyle } from '@/theme'; import { useProviderShapePreview } from '@/hooks/useProviderShapePreview'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import type { DomainLibraryCategory } from '@/services/domainLibrary'; import type { LucideIcon } from 'lucide-react'; import { @@ -57,13 +58,16 @@ function ArchitectureNode({ id, data, selected }: LegacyNodeProps): Re }; const ResourceIcon: LucideIcon = resourceIconMap[resourceType] ?? Server; const customIconUrl = typeof data.customIconUrl === 'string' ? data.customIconUrl : undefined; + const resolvedCustomIconUrl = useResolvedMediaUrl(data, 'icon'); const providerPreviewUrl = useProviderShapePreview( typeof data.archIconPackId === 'string' ? data.archIconPackId : undefined, typeof data.archIconShapeId === 'string' ? data.archIconShapeId : undefined, - customIconUrl + resolvedCustomIconUrl ?? customIconUrl ); const resolvedProviderIconUrl = - provider === 'custom' && customIconUrl ? customIconUrl : providerPreviewUrl; + provider === 'custom' && (resolvedCustomIconUrl || customIconUrl) + ? (resolvedCustomIconUrl ?? customIconUrl) + : providerPreviewUrl; return ( ): React.R const { t } = useTranslation(); const nodeColorPalette = getNodeColorPalette(true); const style = nodeColorPalette[data.color || 'slate'] || nodeColorPalette.slate; + const imageUrl = useResolvedMediaUrl(data, 'image'); return ( ): React.R {/* Content Area */} {renderBrowserVariantContent({ - imageUrl: data.imageUrl, + imageUrl, variant: data.variant, label: data.label, style, diff --git a/src/components/custom-nodes/MobileNode.tsx b/src/components/custom-nodes/MobileNode.tsx index 8388ab3f..87c4761b 100644 --- a/src/components/custom-nodes/MobileNode.tsx +++ b/src/components/custom-nodes/MobileNode.tsx @@ -3,6 +3,7 @@ import type { LegacyNodeProps } from '@/lib/reactflowCompat'; import { useTranslation } from 'react-i18next'; import { NodeData } from '@/lib/types'; import { NodeChrome } from '@/components/NodeChrome'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { renderMobileVariantContent } from './mobileVariantRenderer'; import { getNodeColorPalette } from '../../theme'; @@ -10,6 +11,7 @@ function MobileNode({ id, data, selected }: LegacyNodeProps): React.Re const { t } = useTranslation(); const nodeColorPalette = getNodeColorPalette(true); const style = nodeColorPalette[data.color || 'slate'] || nodeColorPalette.slate; + const imageUrl = useResolvedMediaUrl(data, 'image'); return ( ): React.Re {/* Screen Content */}
{renderMobileVariantContent({ - imageUrl: data.imageUrl, + imageUrl, variant: data.variant, style, imageAlt: t('customNodes.mobileContent'), diff --git a/src/components/flow-canvas/useFlowCanvasDragDrop.ts b/src/components/flow-canvas/useFlowCanvasDragDrop.ts index c99a22be..682edc95 100644 --- a/src/components/flow-canvas/useFlowCanvasDragDrop.ts +++ b/src/components/flow-canvas/useFlowCanvasDragDrop.ts @@ -1,8 +1,9 @@ import { useCallback } from 'react'; +import { ingestUserMediaFile } from '@/services/storage/assetStore'; interface UseFlowCanvasDragDropParams { screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number }; - handleAddImage: (imageUrl: string, position: { x: number; y: number }) => void; + handleAddImage: (imageUrl: string, position: { x: number; y: number }, imageAssetId?: string) => void; onFileDrop?: (file: File, content: string) => void; } @@ -52,17 +53,21 @@ export function useFlowCanvasDragDrop({ if (!file) return; if (file.type.startsWith('image/')) { - const reader = new FileReader(); - reader.onload = (loadEvent) => { - const imageUrl = loadEvent.target?.result as string; - if (!imageUrl) return; - const position = screenToFlowPosition({ - x: event.clientX, - y: event.clientY, + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + void ingestUserMediaFile(file, 'image', { fileName: file.name }) + .then((result) => { + handleAddImage( + result.assetId ? '' : result.displayUrl, + position, + result.assetId + ); + }) + .catch(() => { + // Ignore failed drops; user can retry via the add-image control. }); - handleAddImage(imageUrl, position); - }; - reader.readAsDataURL(file); return; } diff --git a/src/components/flow-editor/buildFlowEditorControllerParams.ts b/src/components/flow-editor/buildFlowEditorControllerParams.ts index 576b2bcb..52359c69 100644 --- a/src/components/flow-editor/buildFlowEditorControllerParams.ts +++ b/src/components/flow-editor/buildFlowEditorControllerParams.ts @@ -106,7 +106,7 @@ interface BuildFlowEditorControllerChromeParams { handleAddSequenceParticipant: () => void; handleAddClassNode: () => void; handleAddEntityNode: () => void; - handleAddImage: (imageUrl: string) => void; + handleAddImage: (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => void; handleAddWireframe: (surface: 'browser' | 'mobile') => void; handleAddDomainLibraryItem: (item: DomainLibraryItem) => void; } diff --git a/src/components/flow-editor/panelProps.ts b/src/components/flow-editor/panelProps.ts index 5cb7a897..ee5e2ff0 100644 --- a/src/components/flow-editor/panelProps.ts +++ b/src/components/flow-editor/panelProps.ts @@ -43,7 +43,7 @@ export interface CommandBarPanelBuilderParams { handleAddSequenceParticipant: () => void; handleAddClassNode: () => void; handleAddEntityNode: () => void; - handleAddImage: (imageUrl: string) => void; + handleAddImage: (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => void; handleAddWireframe: (surface: 'browser' | 'mobile') => void; handleAddDomainLibraryItem: (item: DomainLibraryItem) => void; handleCodeAnalysis?: (code: string, language: SupportedLanguage) => Promise; diff --git a/src/components/flow-editor/useFlowEditorController.ts b/src/components/flow-editor/useFlowEditorController.ts index 3181a458..b6279565 100644 --- a/src/components/flow-editor/useFlowEditorController.ts +++ b/src/components/flow-editor/useFlowEditorController.ts @@ -197,7 +197,7 @@ export interface UseFlowEditorChromeParams { handleAddSequenceParticipant: () => void; handleAddClassNode: () => void; handleAddEntityNode: () => void; - handleAddImage: (imageUrl: string) => void; + handleAddImage: (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => void; handleAddWireframe: (surface: 'browser' | 'mobile') => void; handleAddDomainLibraryItem: (item: DomainLibraryItem) => void; } diff --git a/src/components/properties/BulkNodeProperties.tsx b/src/components/properties/BulkNodeProperties.tsx index 34cc961e..2a925a70 100644 --- a/src/components/properties/BulkNodeProperties.tsx +++ b/src/components/properties/BulkNodeProperties.tsx @@ -175,13 +175,14 @@ export function BulkNodeProperties({ })); } - function handleCustomIconChange(url?: string): void { - const updates = createUploadedIconData(url); + function handleCustomIconChange(url?: string, iconAssetId?: string): void { + const updates = createUploadedIconData(url, iconAssetId); setForm((current) => ({ ...current, - iconMode: url ? 'upload' : '', + iconMode: url || iconAssetId ? 'upload' : '', icon: updates.icon ?? '', customIconUrl: updates.customIconUrl, + iconAssetId: updates.iconAssetId, assetProvider: updates.assetProvider as BulkNodePropertiesFormState['assetProvider'], assetCategory: updates.assetCategory, archIconPackId: updates.archIconPackId, @@ -267,6 +268,7 @@ export function BulkNodeProperties({ void; onSelectProviderIcon: (selection: ProviderIconSelection) => void; - onCustomIconChange: (url?: string) => void; -} - -function readFileAsDataUrl(file: File, onLoad: (result: string) => void): void { - const reader = new FileReader(); - reader.onloadend = () => { - if (typeof reader.result === 'string') { - onLoad(reader.result); - } - }; - reader.readAsDataURL(file); + onCustomIconChange: (url?: string, iconAssetId?: string) => void; } function getInitialSource( selectedProviderPackId: string | undefined, selectedProviderShapeId: string | undefined, - customIconUrl: string | undefined + customIconUrl: string | undefined, + iconAssetId: string | undefined ): IconSource { if (selectedProviderPackId && selectedProviderShapeId) { return 'provider'; } - if (customIconUrl) { + if (customIconUrl || iconAssetId) { return 'upload'; } return 'built-in'; @@ -80,6 +74,7 @@ function getProviderLabel(provider: DomainLibraryCategory): string { export const IconPicker: React.FC = ({ selectedIcon, customIconUrl, + iconAssetId, selectedProvider, selectedProviderCategory, selectedProviderPackId, @@ -92,7 +87,18 @@ export const IconPicker: React.FC = ({ const [userIconSource, setUserIconSource] = useState(null); const [userProvider, setUserProvider] = useState(null); const inferredProvider = inferAssetProviderFromPackId(selectedProviderPackId); - const iconSource = userIconSource ?? getInitialSource(selectedProviderPackId, selectedProviderShapeId, customIconUrl); + const resolvedCustomIconUrl = useResolvedMediaUrl( + { customIconUrl, iconAssetId }, + 'icon' + ); + const iconSource = + userIconSource + ?? getInitialSource( + selectedProviderPackId, + selectedProviderShapeId, + customIconUrl, + iconAssetId + ); const provider = selectedProvider ?? inferredProvider @@ -147,13 +153,24 @@ export const IconPicker: React.FC = ({ setCategory('all'); }, [provider, setCategory, setQuery]); - function handleCustomIconFileChange(event: React.ChangeEvent): void { + async function handleCustomIconFileChange( + event: React.ChangeEvent + ): Promise { const file = event.target.files?.[0]; + event.target.value = ''; if (!file) { return; } - readFileAsDataUrl(file, onCustomIconChange); + try { + const result = await ingestUserMediaFile(file, 'icon', { fileName: file.name }); + onCustomIconChange( + result.assetId ? undefined : result.displayUrl, + result.assetId + ); + } catch { + // Keep picker usable if encode/store fails; user can retry. + } } async function handleProviderIconSelect(item: DomainLibraryItem): Promise { @@ -324,15 +341,23 @@ export const IconPicker: React.FC = ({ {iconSource === 'upload' ? (
- {customIconUrl ? ( + {resolvedCustomIconUrl || iconAssetId ? (
- custom + {resolvedCustomIconUrl ? ( + custom + ) : ( + + )}
Uploaded icon
diff --git a/src/components/properties/ImageUpload.tsx b/src/components/properties/ImageUpload.tsx index c5a9ca39..fcc79c3b 100644 --- a/src/components/properties/ImageUpload.tsx +++ b/src/components/properties/ImageUpload.tsx @@ -1,60 +1,98 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import { Upload } from 'lucide-react'; +import { AssetEncodeError, ingestUserMediaFile } from '@/services/storage/assetStore'; + +export interface ImageUploadChange { + displayUrl?: string; + assetId?: string; +} interface ImageUploadProps { - imageUrl?: string; - onChange: (url?: string) => void; + imageUrl?: string; + onChange: (value?: string, meta?: ImageUploadChange) => void; + kind?: 'image' | 'icon'; } -export const ImageUpload: React.FC = ({ imageUrl, onChange }) => { - const fileInputRef = useRef(null); - - const handleImageUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - const reader = new FileReader(); - reader.onloadend = () => { - onChange(reader.result as string); - }; - reader.readAsDataURL(file); - } - }; - - return ( -
- -
- - {imageUrl ? ( -
- attached -
- -
-
- ) : ( - - )} - - +export const ImageUpload: React.FC = ({ + imageUrl, + onChange, + kind = 'image', +}) => { + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + // Allow re-selecting the same file later. + e.target.value = ''; + if (!file) { + return; + } + + setIsUploading(true); + setErrorMessage(null); + try { + const result = await ingestUserMediaFile(file, kind, { fileName: file.name }); + onChange(result.displayUrl, { + displayUrl: result.displayUrl, + assetId: result.assetId, + }); + } catch (error) { + const message = + error instanceof AssetEncodeError + ? error.message + : 'Failed to process the selected image.'; + setErrorMessage(message); + } finally { + setIsUploading(false); + } + }; + + return ( +
+
+ {imageUrl ? ( +
+ attached +
+
-
- ); +
+ ) : ( + + )} + + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + + { + void handleImageUpload(event); + }} + /> +
+
+ ); }; diff --git a/src/components/properties/NodeProperties.tsx b/src/components/properties/NodeProperties.tsx index 03ef3a74..c766bff3 100644 --- a/src/components/properties/NodeProperties.tsx +++ b/src/components/properties/NodeProperties.tsx @@ -9,6 +9,7 @@ import { IconPicker, type ProviderIconSelection } from './IconPicker'; import { ImageUpload } from './ImageUpload'; import { CollapsibleSection } from '../ui/CollapsibleSection'; import { useMarkdownEditor } from '@/hooks/useMarkdownEditor'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { NodeActionButtons } from './NodeActionButtons'; import { NodeContentSection } from './NodeContentSection'; import { NodeImageSettingsSection } from './NodeImageSettingsSection'; @@ -52,6 +53,7 @@ export const NodeProperties: React.FC = ({ const isGroup = selectedNode.type === 'group'; const isWireframeApp = selectedNode.type === 'browser' || selectedNode.type === 'mobile'; const normalizedIconData = normalizeNodeIconData(selectedNode.data); + const resolvedImageUrl = useResolvedMediaUrl(selectedNode.data, 'image'); const isIconAssetNode = normalizedIconData?.assetPresentation === 'icon'; const assetProvider = normalizedIconData?.assetProvider as DomainLibraryCategory | undefined; const assetCategory = @@ -150,8 +152,8 @@ export const NodeProperties: React.FC = ({ ); } - function handleCustomIconChange(url?: string): void { - onChange(selectedNode.id, createUploadedIconData(url)); + function handleCustomIconChange(url?: string, iconAssetId?: string): void { + onChange(selectedNode.id, createUploadedIconData(url, iconAssetId)); } return ( @@ -263,6 +265,7 @@ export const NodeProperties: React.FC = ({ = ({ onToggle={() => toggleSection('upload')} > onChange(selectedNode.id, { imageUrl: url })} + imageUrl={resolvedImageUrl} + onChange={(url, meta) => + onChange(selectedNode.id, { + imageUrl: meta?.assetId ? undefined : url, + imageAssetId: meta?.assetId, + }) + } /> )} diff --git a/src/components/properties/bulkNodePropertiesModel.ts b/src/components/properties/bulkNodePropertiesModel.ts index 0580b7f0..5151bc4c 100644 --- a/src/components/properties/bulkNodePropertiesModel.ts +++ b/src/components/properties/bulkNodePropertiesModel.ts @@ -43,6 +43,7 @@ export interface BulkNodePropertiesFormState { customColor: string | undefined; icon: string; customIconUrl: string | undefined; + iconAssetId: string | undefined; iconMode: BulkIconMode; assetProvider: DomainLibraryCategory | undefined; assetCategory: string | undefined; @@ -71,6 +72,7 @@ export const INITIAL_BULK_NODE_PROPERTIES_FORM_STATE: BulkNodePropertiesFormStat customColor: undefined, icon: '', customIconUrl: undefined, + iconAssetId: undefined, iconMode: '', assetProvider: undefined, assetCategory: undefined, @@ -164,7 +166,7 @@ export function buildBulkUpdates( } if (form.iconMode === 'upload') { - Object.assign(updates, createUploadedIconData(form.customIconUrl)); + Object.assign(updates, createUploadedIconData(form.customIconUrl, form.iconAssetId)); } if (form.iconMode === 'provider') { diff --git a/src/components/properties/families/ArchitectureNodeSection.tsx b/src/components/properties/families/ArchitectureNodeSection.tsx index 0b038f5d..0826284d 100644 --- a/src/components/properties/families/ArchitectureNodeSection.tsx +++ b/src/components/properties/families/ArchitectureNodeSection.tsx @@ -3,7 +3,9 @@ import type { NodeData } from '@/lib/types'; import type { DomainLibraryCategory, DomainLibraryItem } from '@/services/domainLibrary'; import { loadProviderCatalog } from '@/services/shapeLibrary/providerCatalog'; import { useAssetCatalog } from '@/hooks/useAssetCatalog'; +import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { createProviderIconData, createUploadedIconData } from '@/lib/nodeIconState'; +import { ingestUserMediaFile } from '@/services/storage/assetStore'; import { InspectorField } from '@/components/properties/InspectorPrimitives'; import { SegmentedChoice } from '@/components/properties/SegmentedChoice'; import { Input } from '@/components/ui/Input'; @@ -29,16 +31,6 @@ const PROVIDER_OPTIONS: Array<{ id: DomainLibraryCategory | 'custom'; label: str { id: 'custom', label: 'Custom' }, ]; -function readFileAsDataUrl(file: File, onLoad: (result: string) => void): void { - const reader = new FileReader(); - reader.onloadend = () => { - if (typeof reader.result === 'string') { - onLoad(reader.result); - } - }; - reader.readAsDataURL(file); -} - export function ArchitectureNodeSection({ nodeId, data, @@ -49,6 +41,11 @@ export function ArchitectureNodeSection({ const customProviderLabel = typeof data.archProviderLabel === 'string' ? data.archProviderLabel : ''; const customIconUrl = typeof data.customIconUrl === 'string' ? data.customIconUrl : undefined; + const iconAssetId = typeof data.iconAssetId === 'string' ? data.iconAssetId : undefined; + const resolvedCustomIconUrl = useResolvedMediaUrl( + { customIconUrl, iconAssetId }, + 'icon' + ); const effectiveProvider = provider === 'custom' ? null : provider; const { @@ -98,15 +95,27 @@ export function ArchitectureNodeSection({ }); } - function handleCustomIconChange(event: React.ChangeEvent): void { + async function handleCustomIconChange( + event: React.ChangeEvent + ): Promise { const file = event.target.files?.[0]; + event.target.value = ''; if (!file) { return; } - readFileAsDataUrl(file, (result) => { - onChange(nodeId, createUploadedIconData(result)); - }); + try { + const result = await ingestUserMediaFile(file, 'icon', { fileName: file.name }); + onChange( + nodeId, + createUploadedIconData( + result.assetId ? undefined : result.displayUrl, + result.assetId + ) + ); + } catch { + // Keep inspector usable if encode/store fails. + } } function handleProviderSelect(value: string): void { @@ -124,6 +133,7 @@ export function ArchitectureNodeSection({ archProvider: value as DomainLibraryCategory, archProviderLabel: undefined, customIconUrl: undefined, + iconAssetId: undefined, }); } @@ -154,14 +164,18 @@ export function ArchitectureNodeSection({ placeholder="Provider name, e.g. Hetzner" /> - {customIconUrl ? ( + {resolvedCustomIconUrl || iconAssetId ? (
- Custom provider icon + {resolvedCustomIconUrl ? ( + Custom provider icon + ) : ( + + )}
Custom icon added @@ -172,13 +186,20 @@ export function ArchitectureNodeSection({ type="file" accept="image/svg+xml,image/png,image/jpeg,image/webp" className="hidden" - onChange={handleCustomIconChange} + onChange={(event) => { + void handleCustomIconChange(event); + }} /> @@ -191,7 +212,9 @@ export function ArchitectureNodeSection({ type="file" accept="image/svg+xml,image/png,image/jpeg,image/webp" className="hidden" - onChange={handleCustomIconChange} + onChange={(event) => { + void handleCustomIconChange(event); + }} /> )} diff --git a/src/config/rolloutFlags.ts b/src/config/rolloutFlags.ts index 857d2965..8ce6d137 100644 --- a/src/config/rolloutFlags.ts +++ b/src/config/rolloutFlags.ts @@ -6,7 +6,8 @@ export type RolloutFlagKey = | 'importSql' | 'importOpenApi' | 'importInfraTerraformHcl' - | 'importCodebase'; + | 'importCodebase' + | 'assetStoreV1'; interface RolloutFlagDefinition { key: RolloutFlagKey; @@ -66,6 +67,15 @@ const ROLLOUT_FLAG_DEFINITIONS: Record = defaultEnabled: false, description: 'Repo/codebase analyzer importer (hidden — niche, heavy)', }, + assetStoreV1: { + key: 'assetStoreV1', + envVar: 'VITE_ASSET_STORE_V1', + // Enabled by default: user media is stored by reference in IndexedDB instead of + // embedding multi-MB data URLs into every document/history/snapshot copy. + // Set VITE_ASSET_STORE_V1=0 to force legacy inline data-URL behavior. + defaultEnabled: true, + description: 'Store user images/icons in IndexedDB assets store by content hash', + }, }; function readBooleanEnvFlag(envValue: string | undefined, defaultEnabled: boolean): boolean { @@ -96,4 +106,5 @@ export const ROLLOUT_FLAGS: Record = { importOpenApi: isRolloutFlagEnabled('importOpenApi'), importInfraTerraformHcl: isRolloutFlagEnabled('importInfraTerraformHcl'), importCodebase: isRolloutFlagEnabled('importCodebase'), + assetStoreV1: isRolloutFlagEnabled('assetStoreV1'), }; diff --git a/src/hooks/node-operations/nodeFactories.ts b/src/hooks/node-operations/nodeFactories.ts index edbf9cda..473455a4 100644 --- a/src/hooks/node-operations/nodeFactories.ts +++ b/src/hooks/node-operations/nodeFactories.ts @@ -120,12 +120,19 @@ export function createImageNode( id: string, imageUrl: string, position: { x: number; y: number }, - label: string + label: string, + imageAssetId?: string ): FlowNode { return { id, position, - data: { label, imageUrl, transparency: 1, rotation: 0 }, + data: { + label, + imageUrl: imageAssetId ? undefined : imageUrl, + imageAssetId, + transparency: 1, + rotation: 0, + }, type: 'image', style: { width: 200, height: 200 }, }; diff --git a/src/hooks/node-operations/useNodeOperationAdders.ts b/src/hooks/node-operations/useNodeOperationAdders.ts index 259e0ff3..f485ea66 100644 --- a/src/hooks/node-operations/useNodeOperationAdders.ts +++ b/src/hooks/node-operations/useNodeOperationAdders.ts @@ -255,7 +255,7 @@ export function useNodeOperationAdders({ ); const handleAddImage = useCallback( - (imageUrl: string, position?: { x: number; y: number }) => { + (imageUrl: string, position?: { x: number; y: number }, imageAssetId?: string) => { recordHistory(); const id = createId('image'); commitAddedNode( @@ -264,7 +264,8 @@ export function useNodeOperationAdders({ id, imageUrl, resolvedPosition || getDefaultNodePosition(nodesLength, 100, 100), - t('nodes.image') + t('nodes.image'), + imageAssetId ), position ); diff --git a/src/hooks/useResolvedMediaUrl.ts b/src/hooks/useResolvedMediaUrl.ts new file mode 100644 index 00000000..7a0b7fea --- /dev/null +++ b/src/hooks/useResolvedMediaUrl.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import type { NodeData } from '@/lib/types'; +import { + getImmediateMediaUrl, + getNodeIconRef, + getNodeImageRef, + type NodeMediaField, +} from '@/lib/nodeMediaState'; +import { resolveAssetDisplayUrl } from '@/services/storage/assetStore'; + +/** + * Resolve node image/icon media for display. + * Asset ids are resolved asynchronously to cached blob: URLs. + * Inline / remote URLs are returned immediately (no effect-driven setState). + */ +export function useResolvedMediaUrl( + data: Partial | undefined, + field: NodeMediaField +): string | undefined { + const ref = field === 'image' ? getNodeImageRef(data) : getNodeIconRef(data); + const immediate = getImmediateMediaUrl(data, field); + const [resolvedCache, setResolvedCache] = useState<{ + assetId: string; + url: string; + } | null>(null); + + useEffect(() => { + if (!ref.assetId) { + return; + } + + const assetId = ref.assetId; + let cancelled = false; + + void resolveAssetDisplayUrl(assetId).then((url) => { + if (!cancelled && url) { + setResolvedCache({ assetId, url }); + } + }); + + return () => { + cancelled = true; + }; + }, [ref.assetId]); + + if (ref.assetId) { + if (resolvedCache?.assetId === ref.assetId) { + return resolvedCache.url; + } + // Keep any interim inline URL while the asset resolves. + return immediate; + } + + return immediate; +} diff --git a/src/lib/nodeBulkEditing.ts b/src/lib/nodeBulkEditing.ts index 4bbde371..caac9005 100644 --- a/src/lib/nodeBulkEditing.ts +++ b/src/lib/nodeBulkEditing.ts @@ -89,6 +89,7 @@ const BULK_CAPABILITY_RULES: CapabilityRule[] = [ keys: [ 'icon', 'customIconUrl', + 'iconAssetId', 'assetProvider', 'assetCategory', 'archIconPackId', diff --git a/src/lib/nodeIconState.test.ts b/src/lib/nodeIconState.test.ts index 00d9962d..55fc7e03 100644 --- a/src/lib/nodeIconState.test.ts +++ b/src/lib/nodeIconState.test.ts @@ -30,6 +30,7 @@ describe('nodeIconState', () => { expect(createBuiltInIconData('Database')).toEqual({ icon: 'Database', customIconUrl: undefined, + iconAssetId: undefined, assetProvider: undefined, assetCategory: undefined, archIconPackId: undefined, @@ -48,6 +49,7 @@ describe('nodeIconState', () => { ).toEqual({ icon: undefined, customIconUrl: undefined, + iconAssetId: undefined, archIconPackId: 'aws-official-starter-v1', archIconShapeId: 'compute-lambda', assetProvider: 'aws', @@ -59,6 +61,19 @@ describe('nodeIconState', () => { expect(createUploadedIconData('data:image/svg+xml;base64,abc')).toEqual({ icon: undefined, customIconUrl: 'data:image/svg+xml;base64,abc', + iconAssetId: undefined, + assetProvider: undefined, + assetCategory: undefined, + archIconPackId: undefined, + archIconShapeId: undefined, + }); + }); + + it('createUploadedIconData accepts iconAssetId without inline url', () => { + expect(createUploadedIconData(undefined, 'sha256:abc')).toEqual({ + icon: undefined, + customIconUrl: undefined, + iconAssetId: 'sha256:abc', assetProvider: undefined, assetCategory: undefined, archIconPackId: undefined, diff --git a/src/lib/nodeIconState.ts b/src/lib/nodeIconState.ts index 5b38d421..981a5408 100644 --- a/src/lib/nodeIconState.ts +++ b/src/lib/nodeIconState.ts @@ -66,6 +66,7 @@ export function createBuiltInIconData(icon: string): Partial { return { icon, customIconUrl: undefined, + iconAssetId: undefined, assetProvider: undefined, assetCategory: undefined, archIconPackId: undefined, @@ -73,10 +74,11 @@ export function createBuiltInIconData(icon: string): Partial { }; } -export function createUploadedIconData(url?: string): Partial { +export function createUploadedIconData(url?: string, iconAssetId?: string): Partial { return { icon: undefined, customIconUrl: url, + iconAssetId, assetProvider: undefined, assetCategory: undefined, archIconPackId: undefined, @@ -95,6 +97,7 @@ export function createProviderIconData(input: { return { icon: undefined, customIconUrl: undefined, + iconAssetId: undefined, archIconPackId: input.packId, archIconShapeId: input.shapeId, assetProvider: input.provider ?? resolved.provider, @@ -110,7 +113,8 @@ export function normalizeNodeIconData | undefined>(d const next: Partial = { ...data }; const hasProviderIcon = isNonEmptyString(next.archIconPackId) && isNonEmptyString(next.archIconShapeId); - const hasUploadIcon = isNonEmptyString(next.customIconUrl); + const hasUploadIcon = + isNonEmptyString(next.customIconUrl) || isNonEmptyString(next.iconAssetId); const hasBuiltInIcon = isNonEmptyString(next.icon); if (hasProviderIcon) { @@ -127,7 +131,13 @@ export function normalizeNodeIconData | undefined>(d } if (hasUploadIcon) { - Object.assign(next, createUploadedIconData(next.customIconUrl as string)); + Object.assign( + next, + createUploadedIconData( + next.customIconUrl as string | undefined, + next.iconAssetId as string | undefined + ) + ); return next as T; } @@ -138,6 +148,7 @@ export function normalizeNodeIconData | undefined>(d next.icon = undefined; next.customIconUrl = undefined; + next.iconAssetId = undefined; next.assetProvider = undefined; next.assetCategory = undefined; next.archIconPackId = undefined; diff --git a/src/lib/nodeMediaState.test.ts b/src/lib/nodeMediaState.test.ts new file mode 100644 index 00000000..8764bb4d --- /dev/null +++ b/src/lib/nodeMediaState.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + collectReferencedAssetIds, + createIconMediaData, + createImageMediaData, + getImmediateMediaUrl, + getNodeIconRef, + getNodeImageRef, + nodeHasInlineDataUrlMedia, +} from './nodeMediaState'; + +describe('nodeMediaState', () => { + it('prefers imageAssetId over imageUrl', () => { + expect( + getNodeImageRef({ + imageAssetId: 'sha256:abc', + imageUrl: 'data:image/png;base64,xx', + }) + ).toEqual({ assetId: 'sha256:abc' }); + }); + + it('falls back to imageUrl when no asset id', () => { + expect(getNodeImageRef({ imageUrl: 'https://example.com/a.png' })).toEqual({ + url: 'https://example.com/a.png', + }); + }); + + it('prefers iconAssetId over customIconUrl', () => { + expect( + getNodeIconRef({ + iconAssetId: 'sha256:abcdef0123456789', + customIconUrl: 'data:image/svg+xml;base64,abc', + }) + ).toEqual({ assetId: 'sha256:abcdef0123456789' }); + }); + + it('creates image and icon media payloads', () => { + expect(createImageMediaData({ imageAssetId: 'sha256:1' })).toEqual({ + imageUrl: undefined, + imageAssetId: 'sha256:1', + }); + expect(createIconMediaData({ iconAssetId: 'sha256:2' })).toMatchObject({ + icon: undefined, + iconAssetId: 'sha256:2', + archIconPackId: undefined, + }); + }); + + it('detects inline data URLs', () => { + expect(nodeHasInlineDataUrlMedia({ imageUrl: 'data:image/png;base64,aa' })).toBe(true); + expect(nodeHasInlineDataUrlMedia({ imageUrl: 'https://x' })).toBe(false); + }); + + it('collects referenced asset ids from nodes', () => { + const ids = collectReferencedAssetIds([ + { data: { imageAssetId: 'sha256:a' } }, + { data: { iconAssetId: 'sha256:b', imageUrl: 'https://x' } }, + { data: { imageUrl: 'data:image/png;base64,z' } }, + ]); + expect(ids.sort()).toEqual(['sha256:a', 'sha256:b']); + }); + + it('returns immediate urls only for non-asset refs', () => { + expect(getImmediateMediaUrl({ imageUrl: 'https://x' }, 'image')).toBe('https://x'); + expect(getImmediateMediaUrl({ imageAssetId: 'sha256:a' }, 'image')).toBeUndefined(); + }); +}); diff --git a/src/lib/nodeMediaState.ts b/src/lib/nodeMediaState.ts new file mode 100644 index 00000000..7c47c2e8 --- /dev/null +++ b/src/lib/nodeMediaState.ts @@ -0,0 +1,103 @@ +import type { FlowNode, NodeData } from '@/lib/types'; +import { isAssetId } from '@/services/storage/assetHash'; +import { isDataUrl } from '@/services/storage/assetEncode'; + +export type NodeMediaField = 'image' | 'icon'; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +export function createImageMediaData(input: { + imageUrl?: string; + imageAssetId?: string; +}): Partial { + return { + imageUrl: input.imageUrl, + imageAssetId: input.imageAssetId, + }; +} + +export function createIconMediaData(input: { + customIconUrl?: string; + iconAssetId?: string; +}): Partial { + return { + icon: undefined, + customIconUrl: input.customIconUrl, + iconAssetId: input.iconAssetId, + assetProvider: undefined, + assetCategory: undefined, + archIconPackId: undefined, + archIconShapeId: undefined, + }; +} + +/** + * Prefer asset id for storage-efficient references; fall back to inline URL. + */ +export function getNodeImageRef(data: Partial | undefined): { + assetId?: string; + url?: string; +} { + if (!data) { + return {}; + } + if (isNonEmptyString(data.imageAssetId) && isAssetId(data.imageAssetId)) { + return { assetId: data.imageAssetId.trim() }; + } + if (isNonEmptyString(data.imageUrl)) { + return { url: data.imageUrl }; + } + return {}; +} + +export function getNodeIconRef(data: Partial | undefined): { + assetId?: string; + url?: string; +} { + if (!data) { + return {}; + } + if (isNonEmptyString(data.iconAssetId) && isAssetId(data.iconAssetId)) { + return { assetId: data.iconAssetId.trim() }; + } + if (isNonEmptyString(data.customIconUrl)) { + return { url: data.customIconUrl }; + } + return {}; +} + +/** + * Synchronous display source for places that already have a resolved URL + * or a non-asset URL (https / remaining data URLs). Asset ids need async resolve. + */ +export function getImmediateMediaUrl(data: Partial | undefined, field: NodeMediaField): string | undefined { + const ref = field === 'image' ? getNodeImageRef(data) : getNodeIconRef(data); + if (ref.url) { + return ref.url; + } + return undefined; +} + +export function collectReferencedAssetIds(nodes: Iterable }>): string[] { + const ids = new Set(); + for (const node of nodes) { + const imageRef = getNodeImageRef(node.data); + if (imageRef.assetId) { + ids.add(imageRef.assetId); + } + const iconRef = getNodeIconRef(node.data); + if (iconRef.assetId) { + ids.add(iconRef.assetId); + } + } + return Array.from(ids); +} + +export function nodeHasInlineDataUrlMedia(data: Partial | undefined): boolean { + if (!data) { + return false; + } + return isDataUrl(data.imageUrl) || isDataUrl(data.customIconUrl); +} diff --git a/src/lib/nodeStyleData.ts b/src/lib/nodeStyleData.ts index ce6e131e..4264fb44 100644 --- a/src/lib/nodeStyleData.ts +++ b/src/lib/nodeStyleData.ts @@ -7,6 +7,7 @@ export const NODE_STYLE_FIELDS: Array = [ 'colorMode', 'customColor', 'customIconUrl', + 'iconAssetId', 'fontFamily', 'fontSize', 'fontStyle', diff --git a/src/lib/types.ts b/src/lib/types.ts index 5240d2eb..3dd2a56a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -68,8 +68,12 @@ export interface NodeLabelData { export interface NodeIconData { icon?: string; // Key for the icon map secondaryIcon?: string; // Optional secondary icon key - customIconUrl?: string; // User-uploaded icon (base64 or URL) - imageUrl?: string; // Base64 or URL + customIconUrl?: string; // User-uploaded icon (base64, URL, or legacy inline) + /** Content-addressed ref into the IndexedDB assets store (preferred over customIconUrl). */ + iconAssetId?: string; + imageUrl?: string; // Base64, URL, or legacy inline + /** Content-addressed ref into the IndexedDB assets store (preferred over imageUrl). */ + imageAssetId?: string; mermaidSvg?: string; // Rendered Mermaid SVG markup } @@ -233,6 +237,7 @@ export type NodeStyleData = Pick< | 'colorMode' | 'customColor' | 'customIconUrl' + | 'iconAssetId' | 'fontFamily' | 'fontSize' | 'fontStyle' diff --git a/src/services/storage/assetEncode.test.ts b/src/services/storage/assetEncode.test.ts new file mode 100644 index 00000000..629e19c6 --- /dev/null +++ b/src/services/storage/assetEncode.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { AssetEncodeError, isDataUrl } from './assetEncode'; + +describe('assetEncode helpers', () => { + it('detects data URLs', () => { + expect(isDataUrl('data:image/png;base64,abc')).toBe(true); + expect(isDataUrl('https://example.com/x.png')).toBe(false); + expect(isDataUrl(undefined)).toBe(false); + }); + + it('AssetEncodeError carries a stable code', () => { + const error = new AssetEncodeError('TOO_LARGE', 'too big'); + expect(error.code).toBe('TOO_LARGE'); + expect(error.message).toBe('too big'); + expect(error.name).toBe('AssetEncodeError'); + }); +}); diff --git a/src/services/storage/assetEncode.ts b/src/services/storage/assetEncode.ts new file mode 100644 index 00000000..fdd39b91 --- /dev/null +++ b/src/services/storage/assetEncode.ts @@ -0,0 +1,246 @@ +import { + ASSET_ENCODE_DEFAULTS, + type AssetEncodeOptions, + type AssetIngestKind, +} from './assetTypes'; + +export class AssetEncodeError extends Error { + readonly code: 'TOO_LARGE' | 'UNSUPPORTED' | 'ENCODE_FAILED'; + + constructor(code: AssetEncodeError['code'], message: string) { + super(message); + this.name = 'AssetEncodeError'; + this.code = code; + } +} + +export interface EncodedAssetBytes { + blob: Blob; + mimeType: string; + byteLength: number; + width?: number; + height?: number; +} + +function isSvgMime(mimeType: string, fileName?: string): boolean { + if (mimeType.includes('svg')) { + return true; + } + return Boolean(fileName?.toLowerCase().endsWith('.svg')); +} + +async function blobToArrayBuffer(blob: Blob): Promise { + return blob.arrayBuffer(); +} + +async function loadImageBitmap(blob: Blob): Promise { + if (typeof createImageBitmap === 'function') { + return createImageBitmap(blob); + } + + // Fallback for environments without createImageBitmap (rare in modern browsers). + const objectUrl = URL.createObjectURL(blob); + try { + const image = await new Promise((resolve, reject) => { + const element = new Image(); + element.onload = () => resolve(element); + element.onerror = () => reject(new AssetEncodeError('ENCODE_FAILED', 'Failed to decode image.')); + element.src = objectUrl; + }); + + if (typeof createImageBitmap === 'function') { + return createImageBitmap(image); + } + + // Last resort: draw via canvas using the HTMLImageElement dimensions. + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth || image.width; + canvas.height = image.naturalHeight || image.height; + const context = canvas.getContext('2d'); + if (!context) { + throw new AssetEncodeError('ENCODE_FAILED', 'Canvas 2D context is unavailable.'); + } + context.drawImage(image, 0, 0); + const fallbackBlob = await new Promise((resolve, reject) => { + canvas.toBlob( + (result) => { + if (result) { + resolve(result); + return; + } + reject(new AssetEncodeError('ENCODE_FAILED', 'Failed to encode canvas fallback.')); + }, + 'image/png' + ); + }); + return createImageBitmap(fallbackBlob); + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +function computeScaledSize( + width: number, + height: number, + maxLongEdgePx: number +): { width: number; height: number } { + const longEdge = Math.max(width, height); + if (longEdge <= maxLongEdgePx || longEdge === 0) { + return { width, height }; + } + const scale = maxLongEdgePx / longEdge; + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }; +} + +async function canvasEncode( + source: CanvasImageSource, + width: number, + height: number, + preferWebp: boolean +): Promise<{ blob: Blob; mimeType: string }> { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) { + throw new AssetEncodeError('ENCODE_FAILED', 'Canvas 2D context is unavailable.'); + } + context.drawImage(source, 0, 0, width, height); + + const tryMimeTypes = preferWebp + ? (['image/webp', 'image/jpeg', 'image/png'] as const) + : (['image/jpeg', 'image/png'] as const); + + for (const mimeType of tryMimeTypes) { + const blob = await new Promise((resolve) => { + canvas.toBlob((result) => resolve(result), mimeType, mimeType === 'image/png' ? undefined : 0.88); + }); + if (blob && blob.size > 0) { + return { blob, mimeType: blob.type || mimeType }; + } + } + + throw new AssetEncodeError('ENCODE_FAILED', 'Browser could not encode the image.'); +} + +/** + * Normalize a user-selected image/icon file for storage. + * SVGs are kept as-is (with size check). Rasters are resized and re-encoded. + */ +export async function encodeUserMediaFile( + file: Blob, + kind: AssetIngestKind, + options: Partial = {}, + fileName?: string +): Promise { + const defaults = ASSET_ENCODE_DEFAULTS[kind]; + const resolved: AssetEncodeOptions = { + ...defaults, + ...options, + kind, + }; + + const mimeType = file.type || 'application/octet-stream'; + + if (isSvgMime(mimeType, fileName)) { + if (file.size > resolved.maxBytes) { + throw new AssetEncodeError( + 'TOO_LARGE', + `SVG exceeds the ${Math.round(resolved.maxBytes / (1024 * 1024))}MB limit.` + ); + } + return { + blob: file, + mimeType: mimeType.includes('svg') ? mimeType : 'image/svg+xml', + byteLength: file.size, + }; + } + + if (!mimeType.startsWith('image/')) { + throw new AssetEncodeError('UNSUPPORTED', `Unsupported media type: ${mimeType}`); + } + + let bitmap: ImageBitmap | null = null; + try { + bitmap = await loadImageBitmap(file); + const scaled = computeScaledSize(bitmap.width, bitmap.height, resolved.maxLongEdgePx); + const encoded = await canvasEncode(bitmap, scaled.width, scaled.height, resolved.preferWebp); + + if (encoded.blob.size > resolved.maxBytes) { + throw new AssetEncodeError( + 'TOO_LARGE', + `Encoded image exceeds the ${Math.round(resolved.maxBytes / (1024 * 1024))}MB limit.` + ); + } + + return { + blob: encoded.blob, + mimeType: encoded.mimeType, + byteLength: encoded.blob.size, + width: scaled.width, + height: scaled.height, + }; + } catch (error) { + if (error instanceof AssetEncodeError) { + throw error; + } + // If decode/resize fails, fall back to the original bytes when small enough. + if (file.size <= resolved.maxBytes) { + return { + blob: file, + mimeType, + byteLength: file.size, + }; + } + throw new AssetEncodeError( + 'ENCODE_FAILED', + error instanceof Error ? error.message : 'Failed to process image.' + ); + } finally { + bitmap?.close?.(); + } +} + +export async function encodeDataUrl( + dataUrl: string, + kind: AssetIngestKind +): Promise { + const response = await fetch(dataUrl); + if (!response.ok) { + throw new AssetEncodeError('ENCODE_FAILED', 'Failed to parse data URL.'); + } + const blob = await response.blob(); + return encodeUserMediaFile(blob, kind); +} + +export async function dataUrlToBlob(dataUrl: string): Promise { + const response = await fetch(dataUrl); + if (!response.ok) { + throw new AssetEncodeError('ENCODE_FAILED', 'Failed to parse data URL.'); + } + return response.blob(); +} + +export async function blobToDataUrl(blob: Blob): Promise { + const buffer = await blobToArrayBuffer(blob); + const bytes = new Uint8Array(buffer); + let binary = ''; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + const chunk = bytes.subarray(index, index + chunkSize); + binary += String.fromCharCode(...chunk); + } + const base64 = + typeof btoa === 'function' + ? btoa(binary) + : Buffer.from(bytes).toString('base64'); + const mimeType = blob.type || 'application/octet-stream'; + return `data:${mimeType};base64,${base64}`; +} + +export function isDataUrl(value: unknown): value is string { + return typeof value === 'string' && value.startsWith('data:'); +} diff --git a/src/services/storage/assetHash.test.ts b/src/services/storage/assetHash.test.ts new file mode 100644 index 00000000..38249147 --- /dev/null +++ b/src/services/storage/assetHash.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { hashBytesToAssetId, isAssetId } from './assetHash'; + +describe('assetHash', () => { + it('produces a stable content-addressed id for the same bytes', async () => { + const bytes = new TextEncoder().encode('openflowkit-asset'); + const first = await hashBytesToAssetId(bytes); + const second = await hashBytesToAssetId(bytes); + expect(first).toBe(second); + expect(isAssetId(first)).toBe(true); + }); + + it('produces different ids for different bytes', async () => { + const left = await hashBytesToAssetId(new TextEncoder().encode('a')); + const right = await hashBytesToAssetId(new TextEncoder().encode('b')); + expect(left).not.toBe(right); + }); + + it('accepts sha256 and fnv1a id formats', () => { + expect(isAssetId('sha256:abcdef0123456789')).toBe(true); + expect(isAssetId('fnv1a:deadbeef:12')).toBe(true); + expect(isAssetId('data:image/png;base64,abc')).toBe(false); + expect(isAssetId('https://example.com/x.png')).toBe(false); + expect(isAssetId('')).toBe(false); + }); +}); diff --git a/src/services/storage/assetHash.ts b/src/services/storage/assetHash.ts new file mode 100644 index 00000000..44821618 --- /dev/null +++ b/src/services/storage/assetHash.ts @@ -0,0 +1,42 @@ +function toHex(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let hex = ''; + for (let index = 0; index < bytes.length; index += 1) { + hex += bytes[index].toString(16).padStart(2, '0'); + } + return hex; +} + +/** + * Content-addressed asset id: `sha256:`. + * Falls back to a length+prefix hash when SubtleCrypto is unavailable (rare). + */ +export async function hashBytesToAssetId(bytes: ArrayBuffer | Uint8Array): Promise { + const view = + bytes instanceof Uint8Array + ? bytes + : new Uint8Array(bytes); + + // Copy into a fresh ArrayBuffer so SubtleCrypto always receives a plain ArrayBuffer + // (avoids SharedArrayBuffer / ArrayBufferLike typing issues). + const copy = new Uint8Array(view.byteLength); + copy.set(view); + + if (typeof crypto !== 'undefined' && crypto.subtle?.digest) { + const digest = await crypto.subtle.digest('SHA-256', copy.buffer); + return `sha256:${toHex(digest)}`; + } + + // Deterministic fallback for non-secure contexts / test environments without subtle. + let hash = 2166136261; + for (let index = 0; index < copy.length; index += 1) { + hash ^= copy[index]; + hash = Math.imul(hash, 16777619); + } + const unsigned = hash >>> 0; + return `fnv1a:${unsigned.toString(16).padStart(8, '0')}:${copy.byteLength}`; +} + +export function isAssetId(value: unknown): value is string { + return typeof value === 'string' && /^(sha256|fnv1a):[a-f0-9:]+$/i.test(value.trim()); +} diff --git a/src/services/storage/assetMigration.test.ts b/src/services/storage/assetMigration.test.ts new file mode 100644 index 00000000..11bb53d0 --- /dev/null +++ b/src/services/storage/assetMigration.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowNode, NodeData } from '@/lib/types'; +import { migrateNodeMediaData, migrateNodesMedia } from './assetMigration'; + +vi.mock('./assetStore', () => ({ + isAssetStoreAvailable: vi.fn(() => true), + isDataUrl: (value: unknown) => typeof value === 'string' && value.startsWith('data:'), + ingestDataUrlAsAsset: vi.fn(async (dataUrl: string, kind: 'image' | 'icon') => ({ + assetId: kind === 'image' ? 'sha256:image1' : 'sha256:icon1', + displayUrl: dataUrl, + mimeType: 'image/png', + byteLength: 12, + })), +})); + +import { ingestDataUrlAsAsset, isAssetStoreAvailable } from './assetStore'; + +function node(data: NodeData): FlowNode { + return { + id: 'n1', + position: { x: 0, y: 0 }, + data, + }; +} + +describe('assetMigration', () => { + beforeEach(() => { + vi.mocked(isAssetStoreAvailable).mockReturnValue(true); + vi.mocked(ingestDataUrlAsAsset).mockClear(); + }); + + it('migrates image data URLs into imageAssetId and clears imageUrl', async () => { + const result = await migrateNodeMediaData({ + label: 'Img', + imageUrl: 'data:image/png;base64,aaa', + }); + expect(result.changed).toBe(true); + expect(result.data.imageAssetId).toBe('sha256:image1'); + expect(result.data.imageUrl).toBeUndefined(); + expect(result.assetIds).toContain('sha256:image1'); + }); + + it('migrates custom icon data URLs into iconAssetId', async () => { + const result = await migrateNodeMediaData({ + label: 'Icon', + customIconUrl: 'data:image/svg+xml;base64,bbb', + }); + expect(result.changed).toBe(true); + expect(result.data.iconAssetId).toBe('sha256:icon1'); + expect(result.data.customIconUrl).toBeUndefined(); + }); + + it('leaves https URLs alone', async () => { + const result = await migrateNodeMediaData({ + label: 'Remote', + imageUrl: 'https://example.com/a.png', + }); + expect(result.changed).toBe(false); + expect(ingestDataUrlAsAsset).not.toHaveBeenCalled(); + }); + + it('no-ops when the asset store is disabled', async () => { + vi.mocked(isAssetStoreAvailable).mockReturnValue(false); + const result = await migrateNodesMedia([ + node({ label: 'A', imageUrl: 'data:image/png;base64,aaa' }), + ]); + expect(result.changed).toBe(false); + expect(result.nodes[0].data.imageUrl).toBe('data:image/png;base64,aaa'); + }); +}); diff --git a/src/services/storage/assetMigration.ts b/src/services/storage/assetMigration.ts new file mode 100644 index 00000000..064499a0 --- /dev/null +++ b/src/services/storage/assetMigration.ts @@ -0,0 +1,83 @@ +import type { FlowNode, NodeData } from '@/lib/types'; +import { getNodeIconRef, getNodeImageRef, nodeHasInlineDataUrlMedia } from '@/lib/nodeMediaState'; +import { ingestDataUrlAsAsset, isAssetStoreAvailable, isDataUrl } from './assetStore'; + +export interface MigrateNodeMediaResult { + data: NodeData; + changed: boolean; + assetIds: string[]; +} + +/** + * Migrate a single node's inline data: URLs into the asset store. + * Leaves https:// URLs alone. Clears the large data URL once an asset id is stored. + */ +export async function migrateNodeMediaData(data: NodeData): Promise { + if (!isAssetStoreAvailable() || !nodeHasInlineDataUrlMedia(data)) { + return { data, changed: false, assetIds: [] }; + } + + let next: NodeData = { ...data }; + let changed = false; + const assetIds: string[] = []; + + if (isDataUrl(next.imageUrl) && !getNodeImageRef(next).assetId) { + const ingested = await ingestDataUrlAsAsset(next.imageUrl, 'image'); + if (ingested?.assetId) { + next = { + ...next, + imageAssetId: ingested.assetId, + // Drop embedded payload once the asset store owns the bytes. + imageUrl: undefined, + }; + changed = true; + assetIds.push(ingested.assetId); + } + } else if (getNodeImageRef(next).assetId) { + assetIds.push(getNodeImageRef(next).assetId as string); + } + + if (isDataUrl(next.customIconUrl) && !getNodeIconRef(next).assetId) { + const ingested = await ingestDataUrlAsAsset(next.customIconUrl, 'icon'); + if (ingested?.assetId) { + next = { + ...next, + iconAssetId: ingested.assetId, + customIconUrl: undefined, + }; + changed = true; + assetIds.push(ingested.assetId); + } + } else if (getNodeIconRef(next).assetId) { + assetIds.push(getNodeIconRef(next).assetId as string); + } + + return { data: next, changed, assetIds }; +} + +export async function migrateNodesMedia(nodes: FlowNode[]): Promise<{ + nodes: FlowNode[]; + changed: boolean; + assetIds: string[]; +}> { + if (!isAssetStoreAvailable()) { + return { nodes, changed: false, assetIds: [] }; + } + + let changed = false; + const assetIds: string[] = []; + const nextNodes: FlowNode[] = []; + + for (const node of nodes) { + const migrated = await migrateNodeMediaData(node.data); + if (migrated.changed) { + changed = true; + nextNodes.push({ ...node, data: migrated.data }); + } else { + nextNodes.push(node); + } + assetIds.push(...migrated.assetIds); + } + + return { nodes: nextNodes, changed, assetIds }; +} diff --git a/src/services/storage/assetStore.ts b/src/services/storage/assetStore.ts new file mode 100644 index 00000000..86a852e8 --- /dev/null +++ b/src/services/storage/assetStore.ts @@ -0,0 +1,294 @@ +import { ROLLOUT_FLAGS } from '@/config/rolloutFlags'; +import { + ASSETS_STORE_NAME, + openFlowPersistenceDatabase, +} from './indexedDbSchema'; +import { + deleteRecord, + getAllRecords, + getIndexedDbFactory, + getRecord, + putRecord, + withDatabase, +} from './indexedDbHelpers'; +import { ensureStorageSchemaReady, getBrowserIndexedDbFactory } from './storageRuntime'; +import { reportStorageTelemetry } from './storageTelemetry'; +import { hashBytesToAssetId, isAssetId } from './assetHash'; +import { + AssetEncodeError, + blobToDataUrl, + encodeUserMediaFile, + isDataUrl, +} from './assetEncode'; +import type { + AssetIngestKind, + AssetIngestResult, + FlowAsset, + FlowAssetKind, +} from './assetTypes'; + +const blobUrlCache = new Map(); + +function kindToAssetKind(kind: AssetIngestKind): FlowAssetKind { + return kind === 'icon' ? 'icon' : 'image'; +} + +function revokeCachedUrl(assetId: string): void { + const existing = blobUrlCache.get(assetId); + if (existing) { + URL.revokeObjectURL(existing); + blobUrlCache.delete(assetId); + } +} + +export function isAssetStoreEnabled(): boolean { + return ROLLOUT_FLAGS.assetStoreV1; +} + +export async function putFlowAsset(asset: FlowAsset): Promise { + await ensureStorageSchemaReady(getBrowserIndexedDbFactory()); + await withDatabase(async (database) => { + await putRecord(database, ASSETS_STORE_NAME, asset); + }); +} + +export async function getFlowAsset(assetId: string): Promise { + if (!isAssetId(assetId)) { + return null; + } + + await ensureStorageSchemaReady(getBrowserIndexedDbFactory()); + try { + return await withDatabase(async (database) => { + return getRecord(database, ASSETS_STORE_NAME, assetId); + }); + } catch (error) { + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_READ_FAILED', + severity: 'warning', + message: `Failed to read asset ${assetId}: ${error instanceof Error ? error.message : String(error)}`, + }); + return null; + } +} + +export async function deleteFlowAsset(assetId: string): Promise { + revokeCachedUrl(assetId); + await ensureStorageSchemaReady(getBrowserIndexedDbFactory()); + await withDatabase(async (database) => { + await deleteRecord(database, ASSETS_STORE_NAME, assetId); + }); +} + +export async function listFlowAssets(): Promise { + await ensureStorageSchemaReady(getBrowserIndexedDbFactory()); + return withDatabase(async (database) => { + return getAllRecords(database, ASSETS_STORE_NAME); + }); +} + +/** + * Resolve an asset id to a displayable URL (cached blob: URL). + * Returns null when the asset is missing or the store is unavailable. + */ +export async function resolveAssetDisplayUrl(assetId: string): Promise { + if (!isAssetId(assetId)) { + return null; + } + + const cached = blobUrlCache.get(assetId); + if (cached) { + return cached; + } + + const asset = await getFlowAsset(assetId); + if (!asset?.bytes) { + return null; + } + + const url = URL.createObjectURL(asset.bytes); + blobUrlCache.set(assetId, url); + return url; +} + +/** + * Drop blob URLs that are no longer referenced. Safe to call from GC. + */ +export function clearAssetUrlCache(assetIds?: string[]): void { + if (!assetIds) { + for (const [assetId, url] of blobUrlCache) { + URL.revokeObjectURL(url); + blobUrlCache.delete(assetId); + } + return; + } + + for (const assetId of assetIds) { + revokeCachedUrl(assetId); + } +} + +export async function putEncodedAsset(input: { + blob: Blob; + mimeType: string; + kind: FlowAssetKind; + width?: number; + height?: number; + sourceName?: string; +}): Promise { + const buffer = await input.blob.arrayBuffer(); + const id = await hashBytesToAssetId(buffer); + const existing = await getFlowAsset(id); + if (existing) { + return existing; + } + + const asset: FlowAsset = { + id, + kind: input.kind, + mimeType: input.mimeType, + bytes: input.blob, + byteLength: input.blob.size, + width: input.width, + height: input.height, + createdAt: new Date().toISOString(), + sourceName: input.sourceName, + }; + + await putFlowAsset(asset); + return asset; +} + +/** + * Ingest a user file into the asset store (when enabled) or as a data URL (legacy). + */ +export async function ingestUserMediaFile( + file: File | Blob, + kind: AssetIngestKind, + options?: { fileName?: string } +): Promise { + const fileName = options?.fileName ?? (file instanceof File ? file.name : undefined); + const encoded = await encodeUserMediaFile(file, kind, {}, fileName); + + if (!isAssetStoreEnabled() || !getIndexedDbFactory()) { + return { + displayUrl: await blobToDataUrl(encoded.blob), + mimeType: encoded.mimeType, + byteLength: encoded.byteLength, + width: encoded.width, + height: encoded.height, + }; + } + + try { + const asset = await putEncodedAsset({ + blob: encoded.blob, + mimeType: encoded.mimeType, + kind: kindToAssetKind(kind), + width: encoded.width, + height: encoded.height, + sourceName: fileName, + }); + const displayUrl = await resolveAssetDisplayUrl(asset.id); + return { + assetId: asset.id, + displayUrl: displayUrl ?? (await blobToDataUrl(encoded.blob)), + mimeType: encoded.mimeType, + byteLength: encoded.byteLength, + width: encoded.width, + height: encoded.height, + }; + } catch (error) { + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_WRITE_FAILED', + severity: 'warning', + message: `Asset store write failed; falling back to data URL. ${error instanceof Error ? error.message : String(error)}`, + }); + return { + displayUrl: await blobToDataUrl(encoded.blob), + mimeType: encoded.mimeType, + byteLength: encoded.byteLength, + width: encoded.width, + height: encoded.height, + }; + } +} + +/** + * Ingest an existing data URL into the asset store. Used by lazy migration. + * Returns null when the value is not a data URL or the store is disabled. + */ +export async function ingestDataUrlAsAsset( + dataUrl: string, + kind: AssetIngestKind +): Promise { + if (!isDataUrl(dataUrl) || !isAssetStoreEnabled() || !getIndexedDbFactory()) { + return null; + } + + try { + const response = await fetch(dataUrl); + if (!response.ok) { + return null; + } + const blob = await response.blob(); + return ingestUserMediaFile(blob, kind); + } catch (error) { + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_MIGRATE_FAILED', + severity: 'warning', + message: `Failed to migrate data URL into asset store: ${error instanceof Error ? error.message : String(error)}`, + }); + return null; + } +} + +/** + * Delete assets that are not referenced by any of the provided ids. + * Returns the number of deleted assets. + */ +export async function garbageCollectUnreferencedAssets( + referencedAssetIds: Iterable +): Promise { + if (!isAssetStoreEnabled() || !getIndexedDbFactory()) { + return 0; + } + + const referenced = new Set( + Array.from(referencedAssetIds).filter((id) => isAssetId(id)) + ); + + try { + const all = await listFlowAssets(); + const orphaned = all.filter((asset) => !referenced.has(asset.id)); + await Promise.all(orphaned.map((asset) => deleteFlowAsset(asset.id))); + return orphaned.length; + } catch (error) { + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_GC_FAILED', + severity: 'warning', + message: `Asset GC failed: ${error instanceof Error ? error.message : String(error)}`, + }); + return 0; + } +} + +export function isAssetStoreAvailable(): boolean { + return isAssetStoreEnabled() && Boolean(getIndexedDbFactory()); +} + +export { AssetEncodeError, isAssetId, isDataUrl }; + +/** Test helper: open the assets store via the shared schema (used by tests). */ +export async function openAssetsDatabaseForTests(): Promise { + const factory = getBrowserIndexedDbFactory(); + if (!factory) { + throw new Error('IndexedDB is not available.'); + } + await ensureStorageSchemaReady(factory); + return openFlowPersistenceDatabase(factory); +} diff --git a/src/services/storage/assetTypes.ts b/src/services/storage/assetTypes.ts new file mode 100644 index 00000000..8563067e --- /dev/null +++ b/src/services/storage/assetTypes.ts @@ -0,0 +1,53 @@ +export type FlowAssetKind = 'image' | 'icon' | 'svg'; + +export interface FlowAsset { + id: string; + kind: FlowAssetKind; + mimeType: string; + /** Raw asset bytes. IndexedDB stores Blob natively. */ + bytes: Blob; + byteLength: number; + width?: number; + height?: number; + createdAt: string; + sourceName?: string; +} + +export type AssetIngestKind = 'image' | 'icon'; + +export interface AssetIngestResult { + /** Content-addressed asset id when stored in the asset store. */ + assetId?: string; + /** + * Immediate display URL for the canvas. + * When the asset store is used this is typically a blob: URL from the cache. + * When the store is disabled (or fails), this is a data: URL. + */ + displayUrl: string; + mimeType: string; + byteLength: number; + width?: number; + height?: number; +} + +export interface AssetEncodeOptions { + kind: AssetIngestKind; + maxLongEdgePx: number; + maxBytes: number; + preferWebp: boolean; +} + +export const ASSET_ENCODE_DEFAULTS: Record = { + image: { + kind: 'image', + maxLongEdgePx: 2048, + maxBytes: 4 * 1024 * 1024, + preferWebp: true, + }, + icon: { + kind: 'icon', + maxLongEdgePx: 512, + maxBytes: 1 * 1024 * 1024, + preferWebp: true, + }, +}; diff --git a/src/services/storage/localFirstRuntime.ts b/src/services/storage/localFirstRuntime.ts index e914b550..dd21e550 100644 --- a/src/services/storage/localFirstRuntime.ts +++ b/src/services/storage/localFirstRuntime.ts @@ -17,6 +17,8 @@ import { parseLegacyChatMessagesJson, parsePersistentAISettingsJson, } from './storageSchemas'; +import { isAssetStoreAvailable } from './assetStore'; +import { migrateNodesMedia } from './assetMigration'; const STORE_SUBSCRIPTION_DEBOUNCE_MS = 250; @@ -142,23 +144,68 @@ async function hydrateStoreFromRepository(): Promise { function persistStoreSnapshot(): void { const nextState = useFlowStore.getState(); - const documents = syncWorkspaceDocuments({ - documents: nextState.documents, - activeDocumentId: nextState.activeDocumentId, - tabs: nextState.tabs.map(sanitizePersistedTab), - activeTabId: nextState.activeTabId, - nodes: nextState.nodes, - edges: nextState.edges, - }); - void localFirstRepository.saveFlowDocuments( - documents, - nextState.activeDocumentId, - ); + void (async () => { + // Lazy-migrate legacy inline data: URLs into the assets store before saving. + // Only rewrites the live store when migration actually changes nodes. + let nodesForSave = nextState.nodes; + let tabsForSave = nextState.tabs; + + if (isAssetStoreAvailable()) { + try { + const activeMigrated = await migrateNodesMedia(nextState.nodes); + if (activeMigrated.changed) { + nodesForSave = activeMigrated.nodes; + const activeTabId = nextState.activeTabId; + tabsForSave = nextState.tabs.map((tab) => + tab.id === activeTabId ? { ...tab, nodes: activeMigrated.nodes } : tab + ); + useFlowStore.setState({ + nodes: activeMigrated.nodes, + tabs: tabsForSave, + }); + } + + // Also migrate inactive tab pages so reloads don't re-expand data URLs. + const migratedTabs = await Promise.all( + tabsForSave.map(async (tab) => { + if (tab.id === nextState.activeTabId && activeMigrated.changed) { + return { ...tab, nodes: activeMigrated.nodes }; + } + const migrated = await migrateNodesMedia(tab.nodes); + if (!migrated.changed) { + return tab; + } + return { ...tab, nodes: migrated.nodes }; + }) + ); + if (migratedTabs.some((tab, index) => tab !== tabsForSave[index])) { + tabsForSave = migratedTabs; + useFlowStore.setState({ tabs: tabsForSave }); + } + } catch { + // Migration is best-effort; always fall through to save. + } + } - if (nextState.aiSettings.storageMode === 'local') { - void localFirstRepository.savePersistentAISettings(JSON.stringify(nextState.aiSettings)); - } + const documents = syncWorkspaceDocuments({ + documents: nextState.documents, + activeDocumentId: nextState.activeDocumentId, + tabs: tabsForSave.map(sanitizePersistedTab), + activeTabId: nextState.activeTabId, + nodes: nodesForSave, + edges: nextState.edges, + }); + + await localFirstRepository.saveFlowDocuments( + documents, + nextState.activeDocumentId, + ); + + if (nextState.aiSettings.storageMode === 'local') { + await localFirstRepository.savePersistentAISettings(JSON.stringify(nextState.aiSettings)); + } + })(); } let syncStopper: (() => void) | null = null; From 9d76b0e0251a4a43f5c798eec18518e14030fada Mon Sep 17 00:00:00 2001 From: Mr-Macharia Date: Wed, 15 Jul 2026 12:04:07 +0300 Subject: [PATCH 2/4] Surface asset upload and migration failures Log storage telemetry when save-time migration or image drops fail, and show inline errors for custom icon uploads instead of swallowing. --- .../flow-canvas/useFlowCanvasDragDrop.ts | 13 ++++++++++-- src/components/properties/IconPicker.tsx | 20 ++++++++++++++++--- .../families/ArchitectureNodeSection.tsx | 20 ++++++++++++++++--- src/services/storage/localFirstRuntime.ts | 11 +++++++++- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/components/flow-canvas/useFlowCanvasDragDrop.ts b/src/components/flow-canvas/useFlowCanvasDragDrop.ts index 682edc95..edde4848 100644 --- a/src/components/flow-canvas/useFlowCanvasDragDrop.ts +++ b/src/components/flow-canvas/useFlowCanvasDragDrop.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { ingestUserMediaFile } from '@/services/storage/assetStore'; +import { reportStorageTelemetry } from '@/services/storage/storageTelemetry'; interface UseFlowCanvasDragDropParams { screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number }; @@ -65,8 +66,16 @@ export function useFlowCanvasDragDrop({ result.assetId ); }) - .catch(() => { - // Ignore failed drops; user can retry via the add-image control. + .catch((error) => { + // Best-effort: leave canvas unchanged so the user can retry. + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_DROP_INGEST_FAILED', + severity: 'warning', + message: `Image drop ingest failed: ${ + error instanceof Error ? error.message : String(error) + }`, + }); }); return; } diff --git a/src/components/properties/IconPicker.tsx b/src/components/properties/IconPicker.tsx index 33a45a71..f6b5fe09 100644 --- a/src/components/properties/IconPicker.tsx +++ b/src/components/properties/IconPicker.tsx @@ -10,7 +10,7 @@ import { import { useAssetCatalog } from '@/hooks/useAssetCatalog'; import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { inferAssetProviderFromPackId } from '@/lib/nodeIconState'; -import { ingestUserMediaFile } from '@/services/storage/assetStore'; +import { AssetEncodeError, ingestUserMediaFile } from '@/services/storage/assetStore'; import { ICON_NAMES, ICON_PICKER_PRIORITY_NAMES, NamedIcon } from '../IconMap'; import { Tooltip } from '../Tooltip'; import { Select } from '../ui/Select'; @@ -86,6 +86,7 @@ export const IconPicker: React.FC = ({ const [iconSearch, setIconSearch] = useState(''); const [userIconSource, setUserIconSource] = useState(null); const [userProvider, setUserProvider] = useState(null); + const [uploadError, setUploadError] = useState(null); const inferredProvider = inferAssetProviderFromPackId(selectedProviderPackId); const resolvedCustomIconUrl = useResolvedMediaUrl( { customIconUrl, iconAssetId }, @@ -162,14 +163,22 @@ export const IconPicker: React.FC = ({ return; } + setUploadError(null); try { const result = await ingestUserMediaFile(file, 'icon', { fileName: file.name }); onCustomIconChange( result.assetId ? undefined : result.displayUrl, result.assetId ); - } catch { - // Keep picker usable if encode/store fails; user can retry. + } catch (error) { + // Keep picker usable; surface a short message so the failure is not silent. + const message = + error instanceof AssetEncodeError + ? error.message + : error instanceof Error + ? error.message + : 'Failed to process the selected icon.'; + setUploadError(message); } } @@ -381,6 +390,11 @@ export const IconPicker: React.FC = ({ }} /> + {uploadError ? ( +

+ {uploadError} +

+ ) : null}
) : null}
diff --git a/src/components/properties/families/ArchitectureNodeSection.tsx b/src/components/properties/families/ArchitectureNodeSection.tsx index 0826284d..d6c8c599 100644 --- a/src/components/properties/families/ArchitectureNodeSection.tsx +++ b/src/components/properties/families/ArchitectureNodeSection.tsx @@ -5,7 +5,7 @@ import { loadProviderCatalog } from '@/services/shapeLibrary/providerCatalog'; import { useAssetCatalog } from '@/hooks/useAssetCatalog'; import { useResolvedMediaUrl } from '@/hooks/useResolvedMediaUrl'; import { createProviderIconData, createUploadedIconData } from '@/lib/nodeIconState'; -import { ingestUserMediaFile } from '@/services/storage/assetStore'; +import { AssetEncodeError, ingestUserMediaFile } from '@/services/storage/assetStore'; import { InspectorField } from '@/components/properties/InspectorPrimitives'; import { SegmentedChoice } from '@/components/properties/SegmentedChoice'; import { Input } from '@/components/ui/Input'; @@ -46,6 +46,7 @@ export function ArchitectureNodeSection({ { customIconUrl, iconAssetId }, 'icon' ); + const [uploadError, setUploadError] = React.useState(null); const effectiveProvider = provider === 'custom' ? null : provider; const { @@ -104,6 +105,7 @@ export function ArchitectureNodeSection({ return; } + setUploadError(null); try { const result = await ingestUserMediaFile(file, 'icon', { fileName: file.name }); onChange( @@ -113,8 +115,15 @@ export function ArchitectureNodeSection({ result.assetId ) ); - } catch { - // Keep inspector usable if encode/store fails. + } catch (error) { + // Keep inspector usable; surface a short message so the failure is not silent. + const message = + error instanceof AssetEncodeError + ? error.message + : error instanceof Error + ? error.message + : 'Failed to process the selected icon.'; + setUploadError(message); } } @@ -218,6 +227,11 @@ export function ArchitectureNodeSection({ /> )} + {uploadError ? ( +

+ {uploadError} +

+ ) : null}
) : ( diff --git a/src/services/storage/localFirstRuntime.ts b/src/services/storage/localFirstRuntime.ts index dd21e550..06a0baf5 100644 --- a/src/services/storage/localFirstRuntime.ts +++ b/src/services/storage/localFirstRuntime.ts @@ -19,6 +19,7 @@ import { } from './storageSchemas'; import { isAssetStoreAvailable } from './assetStore'; import { migrateNodesMedia } from './assetMigration'; +import { reportStorageTelemetry } from './storageTelemetry'; const STORE_SUBSCRIPTION_DEBOUNCE_MS = 250; @@ -183,8 +184,16 @@ function persistStoreSnapshot(): void { tabsForSave = migratedTabs; useFlowStore.setState({ tabs: tabsForSave }); } - } catch { + } catch (error) { // Migration is best-effort; always fall through to save. + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_MIGRATE_ON_SAVE_FAILED', + severity: 'warning', + message: `Asset media migration failed during save; continuing with unmigrated media. ${ + error instanceof Error ? error.message : String(error) + }`, + }); } } From 68a9c9920ce67984ca352ba60ea26460d56f7eb8 Mon Sep 17 00:00:00 2001 From: Varun Date: Mon, 27 Jul 2026 16:43:11 +0530 Subject: [PATCH 3/4] fix(assets): make asset-backed media survive export and save races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asset ids only resolve in the browser that stored the bytes, but JSON export serializes nodes verbatim — an exported diagram carried imageAssetId with no bytes and rendered blank on any other machine. - inline assets back to data URLs in buildDiagramDocumentJson, dropping the ids so the importing browser doesn't resolve a missing asset - drop the FNV-1a hash fallback: a 32-bit hash used as a content address can collide and serve the wrong image; fail to the data-URL path instead - tighten isAssetId to full-length sha256 ids - re-read store state after the migration awaits so a save can't write a stale snapshot over newer edits, and chain saves so they can't reorder - surface image-drop failures as a toast instead of silently doing nothing - document why asset GC stays uncalled (needs pages, history and snapshots enumerated together or it deletes referenced images) Co-Authored-By: Claude Opus 5 --- src/components/FlowCanvas.tsx | 1 + .../flow-canvas/useFlowCanvasDragDrop.ts | 13 +- src/config/rolloutFlags.ts | 2 + .../diagramDocumentTransfer.test.ts | 6 +- .../flow-export/diagramDocumentTransfer.ts | 9 +- src/hooks/useFlowExport.ts | 23 ++- src/lib/nodeIconState.test.ts | 4 +- src/lib/nodeMediaState.test.ts | 24 +-- src/services/storage/assetHash.test.ts | 8 +- src/services/storage/assetHash.ts | 21 +-- src/services/storage/assetInlining.test.ts | 72 +++++++++ src/services/storage/assetInlining.ts | 68 +++++++++ src/services/storage/assetMigration.test.ts | 8 +- src/services/storage/assetStore.ts | 7 + src/services/storage/localFirstRuntime.ts | 141 ++++++++++-------- 15 files changed, 291 insertions(+), 116 deletions(-) create mode 100644 src/services/storage/assetInlining.test.ts create mode 100644 src/services/storage/assetInlining.ts diff --git a/src/components/FlowCanvas.tsx b/src/components/FlowCanvas.tsx index a46fae43..1893a653 100644 --- a/src/components/FlowCanvas.tsx +++ b/src/components/FlowCanvas.tsx @@ -161,6 +161,7 @@ export const FlowCanvas: React.FC = ({ const { onDragOver, onDrop } = useFlowCanvasDragDrop({ screenToFlowPosition, handleAddImage, + onImageDropError: (message) => addToast(message, 'error'), }); // --- Keyboard Shortcuts --- diff --git a/src/components/flow-canvas/useFlowCanvasDragDrop.ts b/src/components/flow-canvas/useFlowCanvasDragDrop.ts index edde4848..794242d6 100644 --- a/src/components/flow-canvas/useFlowCanvasDragDrop.ts +++ b/src/components/flow-canvas/useFlowCanvasDragDrop.ts @@ -6,6 +6,7 @@ interface UseFlowCanvasDragDropParams { screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number }; handleAddImage: (imageUrl: string, position: { x: number; y: number }, imageAssetId?: string) => void; onFileDrop?: (file: File, content: string) => void; + onImageDropError?: (message: string) => void; } interface UseFlowCanvasDragDropResult { @@ -41,6 +42,7 @@ export function useFlowCanvasDragDrop({ screenToFlowPosition, handleAddImage, onFileDrop, + onImageDropError, }: UseFlowCanvasDragDropParams): UseFlowCanvasDragDropResult { const onDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); @@ -67,15 +69,16 @@ export function useFlowCanvasDragDrop({ ); }) .catch((error) => { - // Best-effort: leave canvas unchanged so the user can retry. + // Leave the canvas unchanged so the user can retry, but say so — + // a drop that silently does nothing reads as a broken app. + const message = error instanceof Error ? error.message : String(error); reportStorageTelemetry({ area: 'persist', code: 'ASSET_DROP_INGEST_FAILED', severity: 'warning', - message: `Image drop ingest failed: ${ - error instanceof Error ? error.message : String(error) - }`, + message: `Image drop ingest failed: ${message}`, }); + onImageDropError?.(message || 'Could not add that image.'); }); return; } @@ -92,7 +95,7 @@ export function useFlowCanvasDragDrop({ reader.readAsText(file); } }, - [handleAddImage, screenToFlowPosition, onFileDrop] + [handleAddImage, screenToFlowPosition, onFileDrop, onImageDropError] ); return { onDragOver, onDrop }; diff --git a/src/config/rolloutFlags.ts b/src/config/rolloutFlags.ts index 8ce6d137..eaa39a06 100644 --- a/src/config/rolloutFlags.ts +++ b/src/config/rolloutFlags.ts @@ -72,6 +72,8 @@ const ROLLOUT_FLAG_DEFINITIONS: Record = envVar: 'VITE_ASSET_STORE_V1', // Enabled by default: user media is stored by reference in IndexedDB instead of // embedding multi-MB data URLs into every document/history/snapshot copy. + // Asset ids are browser-local, so buildDiagramDocumentJson inlines them back to + // data URLs on export — keep that in step if another cross-machine path is added. // Set VITE_ASSET_STORE_V1=0 to force legacy inline data-URL behavior. defaultEnabled: true, description: 'Store user images/icons in IndexedDB assets store by content hash', diff --git a/src/hooks/flow-export/diagramDocumentTransfer.test.ts b/src/hooks/flow-export/diagramDocumentTransfer.test.ts index 5508025e..b04c8c03 100644 --- a/src/hooks/flow-export/diagramDocumentTransfer.test.ts +++ b/src/hooks/flow-export/diagramDocumentTransfer.test.ts @@ -16,8 +16,8 @@ function createEdge(id: string, source: string, target: string): FlowEdge { } describe('diagramDocumentTransfer', () => { - it('builds diagram document json from the current graph', () => { - const json = buildDiagramDocumentJson({ + it('builds diagram document json from the current graph', async () => { + const json = await buildDiagramDocumentJson({ nodes: [createNode('n1')], edges: [createEdge('e1', 'n1', 'n1')], exportSerializationMode: 'deterministic', @@ -31,7 +31,7 @@ describe('diagramDocumentTransfer', () => { }); it('imports diagram document json into composed nodes and edges', async () => { - const json = buildDiagramDocumentJson({ + const json = await buildDiagramDocumentJson({ nodes: [createNode('n1')], edges: [createEdge('e1', 'n1', 'n1')], exportSerializationMode: 'deterministic', diff --git a/src/hooks/flow-export/diagramDocumentTransfer.ts b/src/hooks/flow-export/diagramDocumentTransfer.ts index 8a6e21c0..e6a9a750 100644 --- a/src/hooks/flow-export/diagramDocumentTransfer.ts +++ b/src/hooks/flow-export/diagramDocumentTransfer.ts @@ -10,6 +10,7 @@ import { persistLatestImportReport, } from '@/services/importFidelity'; import { createImportReportOutcome, type OperationOutcome } from '@/services/operationFeedback'; +import { inlineNodeAssetsForTransfer } from '@/services/storage/assetInlining'; import type { FlowEdge, FlowNode, PlaybackState, DiagramType } from '@/lib/types'; interface ActiveTabDocumentState { @@ -17,15 +18,17 @@ interface ActiveTabDocumentState { playback?: PlaybackState; } -export function buildDiagramDocumentJson(params: { +export async function buildDiagramDocumentJson(params: { nodes: FlowNode[]; edges: FlowEdge[]; exportSerializationMode: ExportSerializationMode; activeTab?: ActiveTabDocumentState; -}): string { +}): Promise { const { nodes, edges, exportSerializationMode, activeTab } = params; + // Exported documents leave this browser, so stored assets have to travel as bytes. + const portableNodes = await inlineNodeAssetsForTransfer(nodes); const { nodes: orderedNodes, edges: orderedEdges } = orderGraphForSerialization( - nodes, + portableNodes, edges, exportSerializationMode, ); diff --git a/src/hooks/useFlowExport.ts b/src/hooks/useFlowExport.ts index ba5083ca..026e96fe 100644 --- a/src/hooks/useFlowExport.ts +++ b/src/hooks/useFlowExport.ts @@ -99,14 +99,21 @@ export const useFlowExport = ( }, [nodes, reactFlowWrapper, addToast, exportBaseName]); // --- JSON Export --- - const handleExportJSON = useCallback(() => { + const handleExportJSON = useCallback(async () => { addToast('Preparing JSON download…', 'info'); - const documentJson = buildDiagramDocumentJson({ - nodes, - edges, - exportSerializationMode: viewSettings.exportSerializationMode, - activeTab, - }); + let documentJson: string; + try { + documentJson = await buildDiagramDocumentJson({ + nodes, + edges, + exportSerializationMode: viewSettings.exportSerializationMode, + activeTab, + }); + } catch (error) { + logger.error('JSON export failed.', { error }); + addToast('Failed to export JSON. Please try again.', 'error'); + return; + } const blob = new Blob([documentJson], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); @@ -119,7 +126,7 @@ export const useFlowExport = ( const handleCopyJSON = useCallback(async () => { addToast('Preparing JSON copy…', 'info'); - const documentJson = buildDiagramDocumentJson({ + const documentJson = await buildDiagramDocumentJson({ nodes, edges, exportSerializationMode: viewSettings.exportSerializationMode, diff --git a/src/lib/nodeIconState.test.ts b/src/lib/nodeIconState.test.ts index 55fc7e03..0df975ec 100644 --- a/src/lib/nodeIconState.test.ts +++ b/src/lib/nodeIconState.test.ts @@ -70,10 +70,10 @@ describe('nodeIconState', () => { }); it('createUploadedIconData accepts iconAssetId without inline url', () => { - expect(createUploadedIconData(undefined, 'sha256:abc')).toEqual({ + expect(createUploadedIconData(undefined, 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).toEqual({ icon: undefined, customIconUrl: undefined, - iconAssetId: 'sha256:abc', + iconAssetId: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', assetProvider: undefined, assetCategory: undefined, archIconPackId: undefined, diff --git a/src/lib/nodeMediaState.test.ts b/src/lib/nodeMediaState.test.ts index 8764bb4d..019b491f 100644 --- a/src/lib/nodeMediaState.test.ts +++ b/src/lib/nodeMediaState.test.ts @@ -13,10 +13,10 @@ describe('nodeMediaState', () => { it('prefers imageAssetId over imageUrl', () => { expect( getNodeImageRef({ - imageAssetId: 'sha256:abc', + imageAssetId: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', imageUrl: 'data:image/png;base64,xx', }) - ).toEqual({ assetId: 'sha256:abc' }); + ).toEqual({ assetId: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }); }); it('falls back to imageUrl when no asset id', () => { @@ -28,20 +28,20 @@ describe('nodeMediaState', () => { it('prefers iconAssetId over customIconUrl', () => { expect( getNodeIconRef({ - iconAssetId: 'sha256:abcdef0123456789', + iconAssetId: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', customIconUrl: 'data:image/svg+xml;base64,abc', }) - ).toEqual({ assetId: 'sha256:abcdef0123456789' }); + ).toEqual({ assetId: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }); }); it('creates image and icon media payloads', () => { - expect(createImageMediaData({ imageAssetId: 'sha256:1' })).toEqual({ + expect(createImageMediaData({ imageAssetId: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' })).toEqual({ imageUrl: undefined, - imageAssetId: 'sha256:1', + imageAssetId: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', }); - expect(createIconMediaData({ iconAssetId: 'sha256:2' })).toMatchObject({ + expect(createIconMediaData({ iconAssetId: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd' })).toMatchObject({ icon: undefined, - iconAssetId: 'sha256:2', + iconAssetId: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', archIconPackId: undefined, }); }); @@ -53,15 +53,15 @@ describe('nodeMediaState', () => { it('collects referenced asset ids from nodes', () => { const ids = collectReferencedAssetIds([ - { data: { imageAssetId: 'sha256:a' } }, - { data: { iconAssetId: 'sha256:b', imageUrl: 'https://x' } }, + { data: { imageAssetId: 'sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' } }, + { data: { iconAssetId: 'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', imageUrl: 'https://x' } }, { data: { imageUrl: 'data:image/png;base64,z' } }, ]); - expect(ids.sort()).toEqual(['sha256:a', 'sha256:b']); + expect(ids.sort()).toEqual(['sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff']); }); it('returns immediate urls only for non-asset refs', () => { expect(getImmediateMediaUrl({ imageUrl: 'https://x' }, 'image')).toBe('https://x'); - expect(getImmediateMediaUrl({ imageAssetId: 'sha256:a' }, 'image')).toBeUndefined(); + expect(getImmediateMediaUrl({ imageAssetId: 'sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' }, 'image')).toBeUndefined(); }); }); diff --git a/src/services/storage/assetHash.test.ts b/src/services/storage/assetHash.test.ts index 38249147..43bd4749 100644 --- a/src/services/storage/assetHash.test.ts +++ b/src/services/storage/assetHash.test.ts @@ -16,9 +16,11 @@ describe('assetHash', () => { expect(left).not.toBe(right); }); - it('accepts sha256 and fnv1a id formats', () => { - expect(isAssetId('sha256:abcdef0123456789')).toBe(true); - expect(isAssetId('fnv1a:deadbeef:12')).toBe(true); + it('accepts only full-length sha256 ids', async () => { + const real = await hashBytesToAssetId(new TextEncoder().encode('x')); + expect(isAssetId(real)).toBe(true); + expect(isAssetId('sha256:abcdef0123456789')).toBe(false); + expect(isAssetId('fnv1a:deadbeef:12')).toBe(false); expect(isAssetId('data:image/png;base64,abc')).toBe(false); expect(isAssetId('https://example.com/x.png')).toBe(false); expect(isAssetId('')).toBe(false); diff --git a/src/services/storage/assetHash.ts b/src/services/storage/assetHash.ts index 44821618..16fb5a99 100644 --- a/src/services/storage/assetHash.ts +++ b/src/services/storage/assetHash.ts @@ -9,7 +9,9 @@ function toHex(buffer: ArrayBuffer): string { /** * Content-addressed asset id: `sha256:`. - * Falls back to a length+prefix hash when SubtleCrypto is unavailable (rare). + * Throws when SubtleCrypto is unavailable (insecure origin) rather than falling + * back to a 32-bit hash — ids are used for dedupe, so a collision would serve the + * wrong image. Callers treat the throw as "asset store unusable" and keep data URLs. */ export async function hashBytesToAssetId(bytes: ArrayBuffer | Uint8Array): Promise { const view = @@ -22,21 +24,14 @@ export async function hashBytesToAssetId(bytes: ArrayBuffer | Uint8Array): Promi const copy = new Uint8Array(view.byteLength); copy.set(view); - if (typeof crypto !== 'undefined' && crypto.subtle?.digest) { - const digest = await crypto.subtle.digest('SHA-256', copy.buffer); - return `sha256:${toHex(digest)}`; + if (!(typeof crypto !== 'undefined' && crypto.subtle?.digest)) { + throw new Error('SubtleCrypto is unavailable; cannot content-address assets.'); } - // Deterministic fallback for non-secure contexts / test environments without subtle. - let hash = 2166136261; - for (let index = 0; index < copy.length; index += 1) { - hash ^= copy[index]; - hash = Math.imul(hash, 16777619); - } - const unsigned = hash >>> 0; - return `fnv1a:${unsigned.toString(16).padStart(8, '0')}:${copy.byteLength}`; + const digest = await crypto.subtle.digest('SHA-256', copy.buffer); + return `sha256:${toHex(digest)}`; } export function isAssetId(value: unknown): value is string { - return typeof value === 'string' && /^(sha256|fnv1a):[a-f0-9:]+$/i.test(value.trim()); + return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/i.test(value.trim()); } diff --git a/src/services/storage/assetInlining.test.ts b/src/services/storage/assetInlining.test.ts new file mode 100644 index 00000000..1db1b106 --- /dev/null +++ b/src/services/storage/assetInlining.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowNode } from '@/lib/types'; +import { inlineNodeAssetsForTransfer } from './assetInlining'; + +const IMAGE_ID = `sha256:${'1'.repeat(64)}`; +const ICON_ID = `sha256:${'2'.repeat(64)}`; + +// jsdom's Blob has no arrayBuffer(), so stand in a minimal blob-like with the +// two members blobToDataUrl actually reads. +function fakeBlob(type: string): Blob { + return { + type, + arrayBuffer: async () => new TextEncoder().encode('bytes').buffer, + } as unknown as Blob; +} + +vi.mock('./assetStore', () => ({ + isAssetStoreAvailable: vi.fn(() => true), + getFlowAsset: vi.fn(async (assetId: string) => + assetId === `sha256:${'9'.repeat(64)}` + ? null + : { id: assetId, bytes: fakeBlob('image/png') } + ), +})); + +import { getFlowAsset, isAssetStoreAvailable } from './assetStore'; + +function node(id: string, data: FlowNode['data']): FlowNode { + return { id, position: { x: 0, y: 0 }, data }; +} + +describe('inlineNodeAssetsForTransfer', () => { + beforeEach(() => { + vi.mocked(isAssetStoreAvailable).mockReturnValue(true); + vi.mocked(getFlowAsset).mockClear(); + }); + + it('swaps asset ids for inline data URLs so exports survive another machine', async () => { + const [result] = await inlineNodeAssetsForTransfer([ + node('n1', { label: 'A', imageAssetId: IMAGE_ID, iconAssetId: ICON_ID }), + ]); + + expect(result.data.imageUrl).toMatch(/^data:image\/png;base64,/); + expect(result.data.customIconUrl).toMatch(/^data:image\/png;base64,/); + // Ids must go: getNodeImageRef prefers the id, so leaving it would make the + // importing browser resolve a missing asset and render nothing. + expect(result.data.imageAssetId).toBeUndefined(); + expect(result.data.iconAssetId).toBeUndefined(); + }); + + it('leaves nodes without asset ids untouched', async () => { + const input = [node('n1', { label: 'B', imageUrl: 'https://example.com/a.png' })]; + const result = await inlineNodeAssetsForTransfer(input); + expect(result[0]).toBe(input[0]); + expect(getFlowAsset).not.toHaveBeenCalled(); + }); + + it('keeps the id when the asset is missing instead of dropping the reference', async () => { + const missing = `sha256:${'9'.repeat(64)}`; + const [result] = await inlineNodeAssetsForTransfer([ + node('n1', { label: 'C', imageAssetId: missing }), + ]); + expect(result.data.imageAssetId).toBe(missing); + expect(result.data.imageUrl).toBeUndefined(); + }); + + it('no-ops when the asset store is disabled', async () => { + vi.mocked(isAssetStoreAvailable).mockReturnValue(false); + const input = [node('n1', { label: 'D', imageAssetId: IMAGE_ID })]; + expect(await inlineNodeAssetsForTransfer(input)).toBe(input); + }); +}); diff --git a/src/services/storage/assetInlining.ts b/src/services/storage/assetInlining.ts new file mode 100644 index 00000000..142717f3 --- /dev/null +++ b/src/services/storage/assetInlining.ts @@ -0,0 +1,68 @@ +import type { FlowNode, NodeData } from '@/lib/types'; +import { getNodeIconRef, getNodeImageRef } from '@/lib/nodeMediaState'; +import { blobToDataUrl } from './assetEncode'; +import { getFlowAsset, isAssetStoreAvailable } from './assetStore'; +import { reportStorageTelemetry } from './storageTelemetry'; + +/** + * Asset ids only mean something inside the browser that stored the bytes. + * Anything leaving this machine (JSON export, clipboard copy) has to carry the + * bytes inline, so we swap `imageAssetId` / `iconAssetId` back for data URLs and + * drop the ids — `getNodeImageRef` prefers the id, so leaving both would make the + * importing browser resolve a missing asset and render nothing. + */ +async function inlineAssetIdsForNodeData(data: NodeData): Promise { + const imageRef = getNodeImageRef(data); + const iconRef = getNodeIconRef(data); + if (!imageRef.assetId && !iconRef.assetId) { + return null; + } + + let next: NodeData = { ...data }; + + if (imageRef.assetId) { + const asset = await getFlowAsset(imageRef.assetId); + if (asset?.bytes) { + next = { ...next, imageUrl: await blobToDataUrl(asset.bytes), imageAssetId: undefined }; + } + } + + if (iconRef.assetId) { + const asset = await getFlowAsset(iconRef.assetId); + if (asset?.bytes) { + next = { ...next, customIconUrl: await blobToDataUrl(asset.bytes), iconAssetId: undefined }; + } + } + + return next; +} + +/** + * Replace asset references with inline data URLs so an exported document is + * self-contained. Missing assets keep their id rather than silently vanishing, + * so a failed lookup is visible instead of looking like an image that was never set. + */ +export async function inlineNodeAssetsForTransfer(nodes: FlowNode[]): Promise { + if (!isAssetStoreAvailable()) { + return nodes; + } + + try { + return await Promise.all( + nodes.map(async (node) => { + const inlined = await inlineAssetIdsForNodeData(node.data); + return inlined ? { ...node, data: inlined } : node; + }) + ); + } catch (error) { + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_INLINE_FAILED', + severity: 'warning', + message: `Failed to inline assets for export; exported media may be missing. ${ + error instanceof Error ? error.message : String(error) + }`, + }); + return nodes; + } +} diff --git a/src/services/storage/assetMigration.test.ts b/src/services/storage/assetMigration.test.ts index 11bb53d0..130a8407 100644 --- a/src/services/storage/assetMigration.test.ts +++ b/src/services/storage/assetMigration.test.ts @@ -6,7 +6,7 @@ vi.mock('./assetStore', () => ({ isAssetStoreAvailable: vi.fn(() => true), isDataUrl: (value: unknown) => typeof value === 'string' && value.startsWith('data:'), ingestDataUrlAsAsset: vi.fn(async (dataUrl: string, kind: 'image' | 'icon') => ({ - assetId: kind === 'image' ? 'sha256:image1' : 'sha256:icon1', + assetId: kind === 'image' ? 'sha256:1111111111111111111111111111111111111111111111111111111111111111' : 'sha256:2222222222222222222222222222222222222222222222222222222222222222', displayUrl: dataUrl, mimeType: 'image/png', byteLength: 12, @@ -35,9 +35,9 @@ describe('assetMigration', () => { imageUrl: 'data:image/png;base64,aaa', }); expect(result.changed).toBe(true); - expect(result.data.imageAssetId).toBe('sha256:image1'); + expect(result.data.imageAssetId).toBe('sha256:1111111111111111111111111111111111111111111111111111111111111111'); expect(result.data.imageUrl).toBeUndefined(); - expect(result.assetIds).toContain('sha256:image1'); + expect(result.assetIds).toContain('sha256:1111111111111111111111111111111111111111111111111111111111111111'); }); it('migrates custom icon data URLs into iconAssetId', async () => { @@ -46,7 +46,7 @@ describe('assetMigration', () => { customIconUrl: 'data:image/svg+xml;base64,bbb', }); expect(result.changed).toBe(true); - expect(result.data.iconAssetId).toBe('sha256:icon1'); + expect(result.data.iconAssetId).toBe('sha256:2222222222222222222222222222222222222222222222222222222222222222'); expect(result.data.customIconUrl).toBeUndefined(); }); diff --git a/src/services/storage/assetStore.ts b/src/services/storage/assetStore.ts index 86a852e8..cfd959a5 100644 --- a/src/services/storage/assetStore.ts +++ b/src/services/storage/assetStore.ts @@ -249,6 +249,13 @@ export async function ingestDataUrlAsAsset( /** * Delete assets that are not referenced by any of the provided ids. * Returns the number of deleted assets. + * + * ponytail: deliberately not called on save. Assets are referenced from more places + * than the live canvas — every document page, each page's undo/redo history, and + * saved snapshots — so a caller that enumerates fewer than all of them permanently + * deletes user images. Until a single collector covers all four, unreferenced bytes + * are left on disk: bounded by what the user uploaded, deduped by content hash, and + * still far smaller than the inline data URLs this replaced. */ export async function garbageCollectUnreferencedAssets( referencedAssetIds: Iterable diff --git a/src/services/storage/localFirstRuntime.ts b/src/services/storage/localFirstRuntime.ts index 06a0baf5..c094702a 100644 --- a/src/services/storage/localFirstRuntime.ts +++ b/src/services/storage/localFirstRuntime.ts @@ -143,78 +143,93 @@ async function hydrateStoreFromRepository(): Promise { }); } -function persistStoreSnapshot(): void { - const nextState = useFlowStore.getState(); - - void (async () => { - // Lazy-migrate legacy inline data: URLs into the assets store before saving. - // Only rewrites the live store when migration actually changes nodes. - let nodesForSave = nextState.nodes; - let tabsForSave = nextState.tabs; - - if (isAssetStoreAvailable()) { - try { - const activeMigrated = await migrateNodesMedia(nextState.nodes); - if (activeMigrated.changed) { - nodesForSave = activeMigrated.nodes; - const activeTabId = nextState.activeTabId; - tabsForSave = nextState.tabs.map((tab) => - tab.id === activeTabId ? { ...tab, nodes: activeMigrated.nodes } : tab - ); - useFlowStore.setState({ - nodes: activeMigrated.nodes, - tabs: tabsForSave, - }); - } +/** + * Migrate legacy inline data: URLs into the assets store, writing results back to + * the store. Skips the write-back when the user edited during the await so a save + * never resurrects stale nodes over newer edits. + */ +async function migrateStoreMediaBeforeSave(): Promise { + if (!isAssetStoreAvailable()) { + return; + } - // Also migrate inactive tab pages so reloads don't re-expand data URLs. - const migratedTabs = await Promise.all( - tabsForSave.map(async (tab) => { - if (tab.id === nextState.activeTabId && activeMigrated.changed) { - return { ...tab, nodes: activeMigrated.nodes }; - } - const migrated = await migrateNodesMedia(tab.nodes); - if (!migrated.changed) { - return tab; - } - return { ...tab, nodes: migrated.nodes }; - }) - ); - if (migratedTabs.some((tab, index) => tab !== tabsForSave[index])) { - tabsForSave = migratedTabs; - useFlowStore.setState({ tabs: tabsForSave }); + try { + const before = useFlowStore.getState(); + const activeMigrated = await migrateNodesMedia(before.nodes); + if (activeMigrated.changed && useFlowStore.getState().nodes === before.nodes) { + useFlowStore.setState({ + nodes: activeMigrated.nodes, + tabs: before.tabs.map((tab) => + tab.id === before.activeTabId ? { ...tab, nodes: activeMigrated.nodes } : tab + ), + }); + } + + // Also migrate inactive tab pages so reloads don't re-expand data URLs. + const beforeTabs = useFlowStore.getState().tabs; + const activeTabId = useFlowStore.getState().activeTabId; + const migratedTabs = await Promise.all( + beforeTabs.map(async (tab) => { + if (tab.id === activeTabId) { + return tab; } - } catch (error) { - // Migration is best-effort; always fall through to save. - reportStorageTelemetry({ - area: 'persist', - code: 'ASSET_MIGRATE_ON_SAVE_FAILED', - severity: 'warning', - message: `Asset media migration failed during save; continuing with unmigrated media. ${ - error instanceof Error ? error.message : String(error) - }`, - }); - } + const migrated = await migrateNodesMedia(tab.nodes); + return migrated.changed ? { ...tab, nodes: migrated.nodes } : tab; + }) + ); + if ( + migratedTabs.some((tab, index) => tab !== beforeTabs[index]) + && useFlowStore.getState().tabs === beforeTabs + ) { + useFlowStore.setState({ tabs: migratedTabs }); } + } catch (error) { + // Migration is best-effort; always fall through to save. + reportStorageTelemetry({ + area: 'persist', + code: 'ASSET_MIGRATE_ON_SAVE_FAILED', + severity: 'warning', + message: `Asset media migration failed during save; continuing with unmigrated media. ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } +} + +// Saves are chained so two overlapping snapshots can't land out of order. +let pendingPersist: Promise = Promise.resolve(); + +function persistStoreSnapshot(): void { + pendingPersist = pendingPersist.then(async () => { + await migrateStoreMediaBeforeSave(); + // Re-read after the awaits above: migration writes back to the store, and the + // user may have edited meanwhile. Saving the pre-await snapshot would drop both. + const state = useFlowStore.getState(); const documents = syncWorkspaceDocuments({ - documents: nextState.documents, - activeDocumentId: nextState.activeDocumentId, - tabs: tabsForSave.map(sanitizePersistedTab), - activeTabId: nextState.activeTabId, - nodes: nodesForSave, - edges: nextState.edges, + documents: state.documents, + activeDocumentId: state.activeDocumentId, + tabs: state.tabs.map(sanitizePersistedTab), + activeTabId: state.activeTabId, + nodes: state.nodes, + edges: state.edges, }); - await localFirstRepository.saveFlowDocuments( - documents, - nextState.activeDocumentId, - ); + await localFirstRepository.saveFlowDocuments(documents, state.activeDocumentId); - if (nextState.aiSettings.storageMode === 'local') { - await localFirstRepository.savePersistentAISettings(JSON.stringify(nextState.aiSettings)); + if (state.aiSettings.storageMode === 'local') { + await localFirstRepository.savePersistentAISettings(JSON.stringify(state.aiSettings)); } - })(); + }).catch((error) => { + reportStorageTelemetry({ + area: 'persist', + code: 'PERSIST_SNAPSHOT_FAILED', + severity: 'error', + message: `Failed to persist workspace snapshot. ${ + error instanceof Error ? error.message : String(error) + }`, + }); + }); } let syncStopper: (() => void) | null = null; From 49bc18e39dfaee9e910b54c9dbdea399fab82951 Mon Sep 17 00:00:00 2001 From: Varun Date: Mon, 27 Jul 2026 17:06:42 +0530 Subject: [PATCH 4/4] fix(assets): pass a typed array to subtle.digest for Node 20 The ArrayBuffer identity check in webcrypto is realm-sensitive, so a buffer minted under jsdom fails validation on Node 20 (CI) while passing on Node 25. Digest the Uint8Array view instead, which every runtime accepts. Latent since the digest call was introduced; removing the FNV fallback is what surfaced it. Co-Authored-By: Claude Opus 5 --- src/services/storage/assetHash.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/services/storage/assetHash.ts b/src/services/storage/assetHash.ts index 16fb5a99..e9de2975 100644 --- a/src/services/storage/assetHash.ts +++ b/src/services/storage/assetHash.ts @@ -19,8 +19,7 @@ export async function hashBytesToAssetId(bytes: ArrayBuffer | Uint8Array): Promi ? bytes : new Uint8Array(bytes); - // Copy into a fresh ArrayBuffer so SubtleCrypto always receives a plain ArrayBuffer - // (avoids SharedArrayBuffer / ArrayBufferLike typing issues). + // Copy so a SharedArrayBuffer-backed view can't reach digest(). const copy = new Uint8Array(view.byteLength); copy.set(view); @@ -28,7 +27,10 @@ export async function hashBytesToAssetId(bytes: ArrayBuffer | Uint8Array): Promi throw new Error('SubtleCrypto is unavailable; cannot content-address assets.'); } - const digest = await crypto.subtle.digest('SHA-256', copy.buffer); + // Pass the typed array, not `copy.buffer`: the ArrayBuffer identity check is + // realm-sensitive, so a buffer minted under jsdom fails Node 20's webcrypto + // validation. A view passes on every runtime. + const digest = await crypto.subtle.digest('SHA-256', copy); return `sha256:${toHex(digest)}`; }