Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions apps/editor/components/scene-loader.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
'use client'

// Node registry bootstrap is loaded once at the root via
// `<ClientBootstrap>` in `app/layout.tsx` — no per-page side-effect
// import here.
import {
applySceneGraphToEditor,
Editor,
Expand Down
142 changes: 142 additions & 0 deletions apps/editor/lib/local-asset-gc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import 'fake-indexeddb/auto'
import { describe, expect, test } from 'bun:test'
import { deleteAsset, loadAssetUrl, saveAsset } from '@pascal-app/core'
import { collectNodeAssetUrlList, runLocalAssetGc } from './local-asset-gc'

type FetchHandler = (url: string) => Response | Promise<Response>

function mockFetch(handler: FetchHandler): () => void {
const original = globalThis.fetch
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
return handler(url)
}) as typeof fetch
return () => {
globalThis.fetch = original
}
}

function json(body: unknown, ok = true, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}

describe('local-asset-gc collectors', () => {
test('collectNodeAssetUrlList walks url and src', () => {
const urls = collectNodeAssetUrlList({
a: { url: 'asset://guide' },
b: { src: 'asset://model' },
c: { src: 'https://cdn.example.com/x.glb' },
})
expect(urls.sort()).toEqual(['asset://guide', 'asset://model'])
})
})

describe('runLocalAssetGc safety', () => {
test('skips GC when the scene list is truncated at the API limit', async () => {
const scenes = Array.from({ length: 500 }, (_, i) => ({ id: `scene-${i}` }))
const restore = mockFetch((url) => {
if (url.includes('/api/scenes?')) return json({ scenes })
return json({ graph: { nodes: {} } })
})
try {
const result = await runLocalAssetGc(() => ({ nodes: {} }))
expect(result).toBeNull()
} finally {
restore()
}
})

test('skips GC when listing scenes fails', async () => {
const restore = mockFetch(() => json({ error: 'no' }, false, 500))
try {
expect(await runLocalAssetGc(() => ({ nodes: {} }))).toBeNull()
} finally {
restore()
}
})

test('skips GC when the scene inventory is malformed', async () => {
for (const body of [{}, { scenes: [{ id: null }] }]) {
const restore = mockFetch(() => json(body))
try {
expect(await runLocalAssetGc(() => ({ nodes: {} }))).toBeNull()
} finally {
restore()
}
}
})

test('skips GC when a listed scene has no graph', async () => {
const restore = mockFetch((url) =>
url.includes('/api/scenes?') ? json({ scenes: [{ id: 'scene-1' }] }) : json({}),
)
try {
expect(await runLocalAssetGc(() => ({ nodes: {} }))).toBeNull()
} finally {
restore()
}
})

test('skips GC when the local scene cannot be parsed', async () => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: { getItem: () => '{not-json' },
})
const restore = mockFetch(() => json({ scenes: [] }))
try {
expect(await runLocalAssetGc(() => ({ nodes: {} }))).toBeNull()
} finally {
restore()
if (previous) Object.defineProperty(globalThis, 'localStorage', previous)
else Reflect.deleteProperty(globalThis, 'localStorage')
}
})

test('re-reads live nodes so mid-GC uploads stay in the keep-set', async () => {
// Live graph is empty when GC starts, then gains a guide during the
// per-scene fetch — the File must survive the sweep.
let live: Record<string, unknown> = {}
const lateAsset = 'asset://uploaded-mid-gc'
const restore = mockFetch(async (url) => {
if (url.includes('/api/scenes?')) {
// Simulate latency while the user uploads.
live = { guide: { url: lateAsset } }
return json({ scenes: [] })
}
return json({ graph: { nodes: {} } })
})
try {
const result = await runLocalAssetGc(() => ({ nodes: live }))
// Keep-set included the late upload, so sweep ran but did not need to
// delete it (result is a count of removals — File itself is not present
// in this unit test's IDB; the contract is "keep-set includes live URLs").
expect(result).not.toBeNull()
expect(collectNodeAssetUrlList(live)).toContain(lateAsset)
} finally {
restore()
}
})

test('keeps a material texture added to the live graph during inventory', async () => {
const assetUrl = await saveAsset(new File(['texture'], 'texture.png'))
let materials: unknown = {}
const restore = mockFetch((url) => {
if (url.includes('/api/scenes?')) {
materials = { floor: { texture: { url: assetUrl } } }
return json({ scenes: [] })
}
return json({ graph: { nodes: {} } })
})
try {
await runLocalAssetGc(() => ({ nodes: {}, materials }))
expect(await loadAssetUrl(assetUrl)).not.toBeNull()
} finally {
restore()
await deleteAsset(assetUrl)
}
})
})
124 changes: 124 additions & 0 deletions apps/editor/lib/local-asset-gc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {
collectGraphAssetUrlsFromParts,
collectNodeAssetUrls,
sweepLocalAssetsExcept,
} from '@pascal-app/core'

const LOCAL_STORAGE_SCENE_KEY = 'pascal-editor-scene'
/** Must match apps/editor/app/api/scenes/route.ts listQuerySchema max. */
const SCENES_LIST_MAX = 500

type SceneGraphLike = {
nodes?: Record<string, unknown>
materials?: unknown
}

function collectGraphAssetUrls(graph: SceneGraphLike | null | undefined): string[] {
if (!graph?.nodes && !graph?.materials) return []
return collectGraphAssetUrlsFromParts(graph.nodes as never, graph.materials)
}

/** Asset URLs still referenced by the browser's localStorage scene, if any. */
export function collectLocalStorageSceneAssetUrls(): string[] | null {
if (typeof localStorage === 'undefined') return []
try {
const raw = localStorage.getItem(LOCAL_STORAGE_SCENE_KEY)
if (!raw) return []
return collectGraphAssetUrls(JSON.parse(raw) as SceneGraphLike)
} catch {
return null
}
}

/**
* Union of asset URLs across every graph this origin can still open.
* Returns null when the inventory cannot be proven complete — callers must
* skip GC rather than sweep from a partial keep-set.
*/
export async function collectAllPersistedAssetUrls(
currentGraph: SceneGraphLike,
): Promise<string[] | null> {
const keep = new Set<string>(collectGraphAssetUrls(currentGraph))
const localUrls = collectLocalStorageSceneAssetUrls()
if (localUrls === null) return null
for (const url of localUrls) keep.add(url)

// Server-side scenes may share the same asset:// handles after duplication.
// If listing fails or is truncated we cannot prove the keep-set is complete.
let scenesJson: { data?: { scenes?: unknown[] }; scenes?: unknown[] } | null = null
try {
const res = await fetch(`/api/scenes?limit=${SCENES_LIST_MAX}`)
if (!res.ok) return null
scenesJson = (await res.json()) as {
data?: { scenes?: unknown[] }
scenes?: unknown[]
}
} catch {
return null
}

const list = scenesJson?.data?.scenes ?? scenesJson?.scenes
if (!Array.isArray(list)) return null
// The API has no cursor/total. A full page means older scenes were dropped
// by `limit` — treating it as complete would delete their Files.
if (list.length >= SCENES_LIST_MAX) return null

for (const entry of list) {
const id = (entry as { id?: unknown } | null)?.id
if (typeof id !== 'string' || !id) return null
try {
const res = await fetch(`/api/scenes/${encodeURIComponent(id)}`)
if (!res.ok) return null
const body = (await res.json()) as {
data?: { graph?: SceneGraphLike; scene?: { graph?: SceneGraphLike } }
graph?: SceneGraphLike
}
const graph = body.data?.graph ?? body.data?.scene?.graph ?? body.graph ?? null
if (!graph || typeof graph !== 'object') return null
for (const url of collectGraphAssetUrls(graph)) keep.add(url)
} catch {
return null
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

return [...keep]
Comment thread
cursor[bot] marked this conversation as resolved.
}

/**
* Explicit GC: delete IndexedDB Files that no persisted scene references.
*
* **Do not call this from scene mount / load.** Automatic mount-time GC races
* keepalive flushes, other tabs, and uploads that have hit IndexedDB but are
* not yet on a node; a sequential GET per scene also burns the shared API rate
* bucket used by autosave (#733 review). Call only when the host can prove:
* no in-flight writes, a complete scene inventory, and spare rate budget.
*
* `getLiveGraph` is re-read immediately before sweeping so both nodes and
* unsaved material texture references survive.
* No-ops when the full keep-set cannot be built (never partial-sweeps).
*/
export async function runLocalAssetGc(
getLiveGraph: () => SceneGraphLike,
extraKeepUrls: Iterable<string> = [],
): Promise<number | null> {
const keep = await collectAllPersistedAssetUrls(getLiveGraph())
if (keep === null) return null

const finalKeep = new Set<string>(keep)
for (const url of collectGraphAssetUrls(getLiveGraph())) {
finalKeep.add(url)
}
Comment thread
cursor[bot] marked this conversation as resolved.
for (const url of extraKeepUrls) {
if (typeof url === 'string' && url.startsWith('asset://')) finalKeep.add(url)
}
return sweepLocalAssetsExcept(finalKeep)
Comment thread
cursor[bot] marked this conversation as resolved.
}

/** For tests: collect urls from an arbitrary node map. */
export function collectNodeAssetUrlList(nodes: Record<string, unknown>): string[] {
const urls: string[] = []
for (const node of Object.values(nodes)) {
urls.push(...collectNodeAssetUrls(node as never))
}
return urls
}
1 change: 1 addition & 0 deletions apps/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"agentation": "^3.0.2",
"fake-indexeddb": "^6.2.5",
"react-grab": "^0.1.50",
"react-scan": "^0.5.7",
"tw-animate-css": "^1.4.0",
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@ export {
type WallConstructionResolution,
} from './hooks/spatial-grid/support-host-patch'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
export {
deleteAsset,
listLocalAssetUrls,
loadAssetUrl,
saveAsset,
sweepLocalAssetsExcept,
} from './lib/asset-storage'
export {
clampDoorOperationState,
getDoorRenderOpenAmount,
Expand All @@ -106,6 +112,11 @@ export {
SECTIONAL_GARAGE_RENDER_OPEN_SCALE,
} from './lib/door-operation'
export { getDefaultLevelName, getLevelDisplayName } from './lib/level-name'
export {
collectGraphAssetUrlsFromParts,
collectNodeAssetUrls,
collectSceneAssetUrls,
} from './lib/local-asset-lifecycle'
export {
areMeasurementPointsCoplanar,
closestMeasurementFeatureBinding,
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/lib/asset-storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'fake-indexeddb/auto'
import { afterEach, describe, expect, test } from 'bun:test'
import { loadAssetUrl, saveAsset } from './asset-storage'
import { deleteAsset, loadAssetUrl, saveAsset } from './asset-storage'

function file(contents: string, name = 'test.txt'): File {
return new File([contents], name, { type: 'text/plain' })
Expand Down Expand Up @@ -61,3 +61,18 @@ describe('loadAssetUrl', () => {
expect(await loadAssetUrl('')).toBeNull()
})
})

describe('deleteAsset', () => {
test('removes the IndexedDB entry so later loads miss', async () => {
const url = await saveAsset(file('delete-me'))
expect(await loadAssetUrl(url)).not.toBeNull()

expect(await deleteAsset(url)).toBe(true)
expect(await loadAssetUrl(url)).toBeNull()
})

test('ignores non-asset URLs', async () => {
expect(await deleteAsset('https://cdn.example.com/a.glb')).toBe(false)
expect(await deleteAsset('')).toBe(false)
})
})
Loading
Loading