♻️ refactor!: massive refactor

This commit is contained in:
2026-05-31 03:04:30 +07:00
parent c1f12c7201
commit df3c944547
86 changed files with 3691 additions and 1129 deletions
+3
View File
@@ -5,6 +5,9 @@
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
+90 -18
View File
@@ -8,22 +8,25 @@ import {
inferMimeType,
isBinaryLayer,
makeLinkId,
stripEmbeddedSourceUri,
stripRuntimeSource,
} from "./asset-records";
function layer(partial: Partial<Layer> = {}): Layer {
return {
id: "layer-1",
type: "raster",
asset: null,
x: 0,
y: 0,
width: 10,
height: 10,
scale: 1,
rotation: 0,
opacity: 1,
visible: true,
effects: [],
...partial,
};
} as Layer;
}
function project(layers: Layer[]): Project {
@@ -43,22 +46,62 @@ describe("asset record helpers", () => {
expect(isBinaryLayer(layer({ type: "raster" }))).toBe(true);
expect(isBinaryLayer(layer({ type: "sticker" }))).toBe(true);
expect(isBinaryLayer(layer({ type: "text" }))).toBe(false);
expect(inferMimeType(layer({ sourceUri: "data:image/png;base64,a" }))).toBe("image/png");
expect(inferMimeType(layer({ sourceUri: "data:image/webp;base64,a" }))).toBe("image/webp");
expect(inferMimeType(layer({ sourceUri: "https://example.com/image" }))).toBe("image/jpeg");
expect(
inferMimeType(
layer({ asset: { kind: "inline", uri: "data:image/png;base64,a" } }),
),
).toBe("image/png");
expect(
inferMimeType(
layer({ asset: { kind: "inline", uri: "data:image/webp;base64,a" } }),
),
).toBe("image/webp");
expect(
inferMimeType(
layer({ asset: { kind: "remote", uri: "https://example.com/image" } }),
),
).toBe("image/jpeg");
});
it("chooses reusable assets by blob size", () => {
const small = new Blob(["a"]);
const large = new Blob(["larger"]);
expect(chooseReusableAsset([{ id: "small", blob: small }, { id: "large", blob: large }], new Blob(["b"]))?.id).toBe("small");
expect(chooseReusableAsset([{ id: "small", blob: small }], large)).toBeUndefined();
expect(
chooseReusableAsset(
[
{ id: "small", blob: small },
{ id: "large", blob: large },
],
new Blob(["b"]),
)?.id,
).toBe("small");
expect(
chooseReusableAsset([{ id: "small", blob: small }], large),
).toBeUndefined();
});
it("builds stable asset records with precedence for existing and reusable ids", () => {
const blob = new Blob(["image"], { type: "image/png" });
expect(buildAssetRecord({ existingId: "existing", fallbackId: "new", mimeType: "image/png", blob, hash: "h", now: "now" }).id).toBe("existing");
expect(buildAssetRecord({ reusable: { id: "reused", createdAt: "then" }, fallbackId: "new", mimeType: "image/png", blob, hash: "h", now: "now" })).toMatchObject({
expect(
buildAssetRecord({
existingId: "existing",
fallbackId: "new",
mimeType: "image/png",
blob,
hash: "h",
now: "now",
}).id,
).toBe("existing");
expect(
buildAssetRecord({
reusable: { id: "reused", createdAt: "then" },
fallbackId: "new",
mimeType: "image/png",
blob,
hash: "h",
now: "now",
}),
).toMatchObject({
id: "reused",
createdAt: "then",
updatedAt: "now",
@@ -67,22 +110,51 @@ describe("asset record helpers", () => {
it("builds asset links only for binary layers with assets", () => {
const source = project([
layer({ id: "raster", assetId: "asset-r" }),
layer({ id: "text", type: "text", assetId: "asset-text" }),
layer({ id: "sticker", type: "sticker", assetId: "asset-s" }),
layer({ id: "raster", asset: { kind: "stored", id: "asset-r" } }),
layer({
id: "text",
type: "text",
text: "Text",
fontFamily: "system-ui",
fontSize: 12,
color: "#000",
}),
layer({
id: "sticker",
type: "sticker",
asset: { kind: "stored", id: "asset-s" },
}),
layer({ id: "empty" }),
]);
expect(makeLinkId("project-1", "raster")).toBe("project-1:raster");
expect(buildAssetLinks(source, "now")).toEqual([
{ id: "project-1:raster", projectId: "project-1", layerId: "raster", assetId: "asset-r", updatedAt: "now" },
{ id: "project-1:sticker", projectId: "project-1", layerId: "sticker", assetId: "asset-s", updatedAt: "now" },
{
id: "project-1:raster",
projectId: "project-1",
layerId: "raster",
assetId: "asset-r",
updatedAt: "now",
},
{
id: "project-1:sticker",
projectId: "project-1",
layerId: "sticker",
assetId: "asset-s",
updatedAt: "now",
},
]);
expect(collectProjectAssetIds(source)).toEqual(new Set(["asset-r", "asset-s"]));
expect(collectProjectAssetIds(source)).toEqual(
new Set(["asset-r", "asset-s"]),
);
});
it("strips embedded image source uris and keeps remote uris", () => {
expect(stripEmbeddedSourceUri(layer({ sourceUri: "data:image/png;base64,a" })).sourceUri).toBeUndefined();
expect(stripEmbeddedSourceUri(layer({ sourceUri: "blob:local" })).sourceUri).toBe("blob:local");
it("strips runtime source uris", () => {
const stripped = stripRuntimeSource(
layer({ runtimeSourceUri: "blob:local" }),
);
expect(
stripped.type === "raster" ? stripped.runtimeSourceUri : undefined,
).toBeUndefined();
});
});
+35 -10
View File
@@ -1,4 +1,9 @@
import type { Layer, Project } from "@pien-studio/types";
import {
getAssetRefId,
getLayerRuntimeSource,
type Layer,
type Project,
} from "@pien-studio/types";
export type AssetRecordInput = {
existingId?: string;
@@ -27,12 +32,16 @@ export function isBinaryLayer(layer: Layer): boolean {
}
export function inferMimeType(layer: Layer): string {
if (layer.sourceUri?.startsWith("data:image/png")) return "image/png";
if (layer.sourceUri?.startsWith("data:image/webp")) return "image/webp";
const sourceUri = getLayerRuntimeSource(layer);
if (sourceUri?.startsWith("data:image/png")) return "image/png";
if (sourceUri?.startsWith("data:image/webp")) return "image/webp";
return "image/jpeg";
}
export function chooseReusableAsset<T extends { blob: Blob }>(assets: T[], blob: Blob): T | undefined {
export function chooseReusableAsset<T extends { blob: Blob }>(
assets: T[],
blob: Blob,
): T | undefined {
return assets.find((asset) => asset.blob.size === blob.size);
}
@@ -47,25 +56,41 @@ export function buildAssetRecord(input: AssetRecordInput) {
};
}
export function stripEmbeddedSourceUri(layer: Layer): Layer {
export function stripRuntimeSource(layer: Layer): Layer {
if (layer.type !== "raster" && layer.type !== "sticker") return layer;
return {
...layer,
sourceUri: layer.sourceUri?.startsWith("data:image/") ? undefined : layer.sourceUri,
runtimeSourceUri: undefined,
};
}
export function buildAssetLinks(project: Project, updatedAt: string): AssetLinkRecordInput[] {
export function buildAssetLinks(
project: Project,
updatedAt: string,
): AssetLinkRecordInput[] {
return project.layers
.filter((layer) => layer.assetId && isBinaryLayer(layer))
.filter(
(layer) => getAssetRefId(getLayerAsset(layer)) && isBinaryLayer(layer),
)
.map((layer) => ({
id: makeLinkId(project.id, layer.id),
projectId: project.id,
layerId: layer.id,
assetId: layer.assetId as string,
assetId: getAssetRefId(getLayerAsset(layer)) as string,
updatedAt,
}));
}
export function collectProjectAssetIds(project: Project): Set<string> {
return new Set(buildAssetLinks(project, new Date(0).toISOString()).map((link) => link.assetId));
return new Set(
buildAssetLinks(project, new Date(0).toISOString()).map(
(link) => link.assetId,
),
);
}
function getLayerAsset(layer: Layer) {
return layer.type === "raster" || layer.type === "sticker"
? layer.asset
: undefined;
}
+53 -14
View File
@@ -1,6 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "fake-indexeddb/auto";
import { deleteProject, duplicateProject, getProjectById, loadProjects, saveProjects, upsertProject } from "./index";
import {
deleteProject,
duplicateProject,
getProjectById,
loadProjects,
saveProjects,
upsertProject,
} from "./index";
import type { Project } from "@pien-studio/types";
const DB_NAME = "pien.db";
@@ -23,8 +30,12 @@ function ensureSchema(db: IDBDatabase) {
}
if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) {
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, { keyPath: "id" });
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", { unique: false });
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, {
keyPath: "id",
});
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", {
unique: false,
});
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", { unique: false });
}
}
@@ -55,7 +66,9 @@ async function readRawProject(projectId: string): Promise<Project | undefined> {
openRequest.onupgradeneeded = () => ensureSchema(openRequest.result);
const db = await requestToPromise(openRequest);
const tx = db.transaction(PROJECTS_STORE, "readonly");
const record = (await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId))) as Project | undefined;
const record = (await requestToPromise(
tx.objectStore(PROJECTS_STORE).get(projectId),
)) as Project | undefined;
db.close();
return record;
}
@@ -92,6 +105,7 @@ function makeMalformedProject(id: string): Project {
{
id: "layer-1",
type: "raster",
asset: null,
x: 10,
y: 10,
width: 10.4,
@@ -119,6 +133,7 @@ function makeFaceBlurProject(id: string): Project {
{
id: "image-faceblur",
type: "raster",
asset: null,
x: 30,
y: 40,
width: 600,
@@ -141,7 +156,10 @@ function makeFaceBlurProject(id: string): Project {
};
}
function makeAssetProject(id: string, sourceUri = "data:image/png;base64,aGVsbG8="): Project {
function makeAssetProject(
id: string,
sourceUri = "data:image/png;base64,aGVsbG8=",
): Project {
const now = new Date().toISOString();
return {
id,
@@ -154,7 +172,7 @@ function makeAssetProject(id: string, sourceUri = "data:image/png;base64,aGVsbG8
{
id: "image-1",
type: "raster",
sourceUri,
asset: { kind: "inline", uri: sourceUri },
x: 0,
y: 0,
width: 10,
@@ -172,7 +190,10 @@ function makeAssetProject(id: string, sourceUri = "data:image/png;base64,aGVsbG8
describe("storage read flows", () => {
beforeEach(async () => {
await resetDatabase();
vi.spyOn(URL, "createObjectURL").mockImplementation((obj: Blob | MediaSource) => `blob:test-${"size" in obj ? obj.size : "media"}`);
vi.spyOn(URL, "createObjectURL").mockImplementation(
(obj: Blob | MediaSource) =>
`blob:test-${"size" in obj ? obj.size : "media"}`,
);
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
});
@@ -208,7 +229,9 @@ describe("storage read flows", () => {
await seedProject(source);
const loaded = await getProjectById(source.id);
const effect = loaded?.layers[0]?.effects.find((e) => e.kind === "face-blur");
const effect = loaded?.layers[0]?.effects.find(
(e) => e.kind === "face-blur",
);
expect(effect).toBeDefined();
const region = effect?.kind === "face-blur" ? effect.regions[0] : undefined;
expect(region?.x).toBe(120);
@@ -216,8 +239,11 @@ describe("storage read flows", () => {
expect(region?.sourceHeight).toBeUndefined();
const listed = await loadProjects();
const listedEffect = listed.find((p) => p.id === source.id)?.layers[0]?.effects.find((e) => e.kind === "face-blur");
const listedRegion = listedEffect?.kind === "face-blur" ? listedEffect.regions[0] : undefined;
const listedEffect = listed
.find((p) => p.id === source.id)
?.layers[0]?.effects.find((e) => e.kind === "face-blur");
const listedRegion =
listedEffect?.kind === "face-blur" ? listedEffect.regions[0] : undefined;
expect(listedRegion?.width).toBe(180);
expect(listedRegion?.sourceWidth).toBeUndefined();
expect(listedRegion?.sourceHeight).toBeUndefined();
@@ -227,13 +253,23 @@ describe("storage read flows", () => {
await upsertProject(makeAssetProject("asset-project"));
const raw = await readRawProject("asset-project");
expect(raw?.layers[0]?.assetId).toBeDefined();
expect(raw?.layers[0]?.sourceUri).toBeUndefined();
expect(
raw?.layers[0]?.type === "raster" ? raw.layers[0].asset?.kind : undefined,
).toBe("stored");
expect(
raw?.layers[0]?.type === "raster"
? raw.layers[0].runtimeSourceUri
: undefined,
).toBeUndefined();
expect(await readStoreCount(ASSETS_STORE)).toBe(1);
expect(await readStoreCount(ASSET_LINKS_STORE)).toBe(1);
const loaded = await getProjectById("asset-project");
expect(loaded?.layers[0]?.sourceUri).toMatch(/^blob:test-/);
expect(
loaded?.layers[0]?.type === "raster"
? loaded.layers[0].runtimeSourceUri
: undefined,
).toMatch(/^blob:test-/);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
});
@@ -263,7 +299,10 @@ describe("storage read flows", () => {
it("saveProjects prunes projects not in the replacement list", async () => {
const keep = makeAssetProject("keep-project");
const drop = makeAssetProject("drop-project", "data:image/png;base64,ZHJvcA==");
const drop = makeAssetProject(
"drop-project",
"data:image/png;base64,ZHJvcA==",
);
await upsertProject(keep);
await upsertProject(drop);
+174 -63
View File
@@ -1,5 +1,12 @@
import { normalizeProject } from "@pien-studio/editor-core";
import { ProjectSchema, type Layer, type Project } from "@pien-studio/types";
import {
getAssetRefId,
getLayerRuntimeSource,
normalizeProject,
ProjectSchema,
type AssetRef,
type Layer,
type Project,
} from "@pien-studio/types";
import {
buildAssetLinks,
buildAssetRecord,
@@ -7,7 +14,7 @@ import {
inferMimeType,
isBinaryLayer,
makeLinkId,
stripEmbeddedSourceUri,
stripRuntimeSource,
} from "./asset-records";
const DB_NAME = "pien.db";
@@ -65,22 +72,34 @@ function openDatabase(): Promise<IDBDatabase | null> {
}
if (!db.objectStoreNames.contains(ASSETS_STORE)) {
const assetsStore = db.createObjectStore(ASSETS_STORE, { keyPath: "id" });
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false });
const assetsStore = db.createObjectStore(ASSETS_STORE, {
keyPath: "id",
});
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", {
unique: false,
});
} else {
const tx = request.transaction;
if (tx) {
const assetsStore = tx.objectStore(ASSETS_STORE);
if (!assetsStore.indexNames.contains(ASSETS_BY_HASH_INDEX)) {
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false });
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", {
unique: false,
});
}
}
}
if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) {
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, { keyPath: "id" });
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", { unique: false });
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", { unique: false });
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, {
keyPath: "id",
});
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", {
unique: false,
});
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", {
unique: false,
});
}
};
@@ -104,18 +123,45 @@ async function hashBlob(blob: Blob): Promise<string> {
return Array.from(new Uint8Array(buffer)).slice(0, 64).join("-");
}
async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Promise<string | undefined> {
if (!isBinaryLayer(layer)) return layer.assetId;
function getLayerAsset(layer: Layer): AssetRef | null | undefined {
return layer.type === "raster" || layer.type === "sticker"
? layer.asset
: undefined;
}
if (layer.sourceUri?.startsWith("data:image/")) {
const blob = await dataUrlToBlob(layer.sourceUri);
function withStoredAsset(layer: Layer, assetId: string): Layer {
if (layer.type !== "raster" && layer.type !== "sticker") return layer;
return {
...layer,
asset: { kind: "stored", id: assetId },
runtimeSourceUri: undefined,
};
}
function withRuntimeSource(layer: Layer, runtimeSourceUri: string): Layer {
if (layer.type !== "raster" && layer.type !== "sticker") return layer;
return { ...layer, runtimeSourceUri };
}
async function putAssetFromLayer(
assetStore: IDBObjectStore,
layer: Layer,
): Promise<string | undefined> {
const asset = getLayerAsset(layer);
if (!isBinaryLayer(layer)) return getAssetRefId(asset);
const sourceUri = getLayerRuntimeSource(layer);
if (sourceUri?.startsWith("data:image/")) {
const blob = await dataUrlToBlob(sourceUri);
const hash = await hashBlob(blob);
const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX);
const matching = (await requestToPromise(hashIndex.getAll(hash))) as AssetRecord[];
const matching = (await requestToPromise(
hashIndex.getAll(hash),
)) as AssetRecord[];
const reusable = chooseReusableAsset(matching, blob);
const now = new Date().toISOString();
const record = buildAssetRecord({
existingId: layer.assetId,
existingId: getAssetRefId(asset),
reusable,
fallbackId: crypto.randomUUID(),
mimeType: blob.type || inferMimeType(layer),
@@ -129,7 +175,7 @@ async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Prom
return record.id;
}
return layer.assetId;
return getAssetRefId(asset);
}
type PreparedLayerAsset = {
@@ -139,12 +185,20 @@ type PreparedLayerAsset = {
mimeType: string;
};
async function prepareLayerAssets(layers: Layer[]): Promise<PreparedLayerAsset[]> {
async function prepareLayerAssets(
layers: Layer[],
): Promise<PreparedLayerAsset[]> {
const prepared: PreparedLayerAsset[] = [];
for (let index = 0; index < layers.length; index += 1) {
const layer = layers[index];
if (!layer || !isBinaryLayer(layer) || !layer.sourceUri?.startsWith("data:image/")) continue;
const blob = await dataUrlToBlob(layer.sourceUri);
const sourceUri = layer ? getLayerRuntimeSource(layer) : undefined;
if (
!layer ||
!isBinaryLayer(layer) ||
!sourceUri?.startsWith("data:image/")
)
continue;
const blob = await dataUrlToBlob(sourceUri);
const hash = await hashBlob(blob);
prepared.push({
layerIndex: index,
@@ -156,20 +210,23 @@ async function prepareLayerAssets(layers: Layer[]): Promise<PreparedLayerAsset[]
return prepared;
}
async function syncLinksForProject(db: IDBDatabase, project: Project): Promise<Set<string>> {
async function syncLinksForProject(
db: IDBDatabase,
project: Project,
): Promise<Set<string>> {
const tx = db.transaction(ASSET_LINKS_STORE, "readwrite");
const store = tx.objectStore(ASSET_LINKS_STORE);
const byProject = store.index(LINKS_BY_PROJECT_INDEX);
const existing = (await requestToPromise(byProject.getAll(project.id))) as AssetLinkRecord[];
const existing = (await requestToPromise(
byProject.getAll(project.id),
)) as AssetLinkRecord[];
const existingMap = new Map(existing.map((link) => [link.id, link]));
const now = new Date().toISOString();
const links = buildAssetLinks(project, now);
const referenced = new Set(links.map((link) => link.assetId));
for (const link of links) {
await requestToPromise(
store.put(link satisfies AssetLinkRecord),
);
await requestToPromise(store.put(link satisfies AssetLinkRecord));
existingMap.delete(link.id);
}
@@ -189,8 +246,14 @@ async function syncLinksForProject(db: IDBDatabase, project: Project): Promise<S
return referenced;
}
async function cleanupOrphansInternal(db: IDBDatabase, candidateAssetIds?: Iterable<string>): Promise<number> {
const assetsTx = db.transaction([ASSETS_STORE, ASSET_LINKS_STORE], "readwrite");
async function cleanupOrphansInternal(
db: IDBDatabase,
candidateAssetIds?: Iterable<string>,
): Promise<number> {
const assetsTx = db.transaction(
[ASSETS_STORE, ASSET_LINKS_STORE],
"readwrite",
);
const assetsStore = assetsTx.objectStore(ASSETS_STORE);
const linksStore = assetsTx.objectStore(ASSET_LINKS_STORE);
const byAsset = linksStore.index(LINKS_BY_ASSET_INDEX);
@@ -201,11 +264,17 @@ async function cleanupOrphansInternal(db: IDBDatabase, candidateAssetIds?: Itera
let removed = 0;
for (const assetId of candidates) {
const links = (await requestToPromise(byAsset.getAll(assetId))) as AssetLinkRecord[];
const links = (await requestToPromise(
byAsset.getAll(assetId),
)) as AssetLinkRecord[];
if (links.length > 0) continue;
await requestToPromise(assetsStore.delete(assetId));
const url = objectUrlByAssetId.get(assetId);
if (url?.startsWith("blob:") && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
if (
url?.startsWith("blob:") &&
typeof URL !== "undefined" &&
typeof URL.revokeObjectURL === "function"
) {
URL.revokeObjectURL(url);
}
objectUrlByAssetId.delete(assetId);
@@ -221,7 +290,10 @@ async function cleanupOrphansInternal(db: IDBDatabase, candidateAssetIds?: Itera
return removed;
}
async function persistProject(db: IDBDatabase, project: Project): Promise<Project> {
async function persistProject(
db: IDBDatabase,
project: Project,
): Promise<Project> {
const normalized = normalizeProject(project);
const preparedAssets = await prepareLayerAssets(normalized.layers);
const assetTx = db.transaction(ASSETS_STORE, "readwrite");
@@ -232,11 +304,13 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
for (const prepared of preparedAssets) {
const layer = layers[prepared.layerIndex];
if (!layer) continue;
const matching = (await requestToPromise(hashIndex.getAll(prepared.hash))) as AssetRecord[];
const matching = (await requestToPromise(
hashIndex.getAll(prepared.hash),
)) as AssetRecord[];
const reusable = chooseReusableAsset(matching, prepared.blob);
const now = new Date().toISOString();
const record = buildAssetRecord({
existingId: layer.assetId,
existingId: getAssetRefId(getLayerAsset(layer)),
reusable,
fallbackId: crypto.randomUUID(),
mimeType: prepared.mimeType,
@@ -247,11 +321,7 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
await requestToPromise(assetStore.put(record satisfies AssetRecord));
layers[prepared.layerIndex] = {
...layer,
assetId: record.id,
sourceUri: undefined,
};
layers[prepared.layerIndex] = withStoredAsset(layer, record.id);
}
for (let index = 0; index < layers.length; index += 1) {
@@ -260,8 +330,7 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
const assetId = await putAssetFromLayer(assetStore, layer);
if (!assetId) continue;
layers[index] = {
...stripEmbeddedSourceUri(layer),
assetId,
...withStoredAsset(stripRuntimeSource(layer), assetId),
};
}
@@ -286,21 +355,30 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
return persisted;
}
async function hydrateProject(db: IDBDatabase, project: Project): Promise<Project> {
async function hydrateProject(
db: IDBDatabase,
project: Project,
): Promise<Project> {
const tx = db.transaction(ASSETS_STORE, "readonly");
const store = tx.objectStore(ASSETS_STORE);
const layers = await Promise.all(
project.layers.map(async (layer) => {
if (!layer.assetId) return layer;
const record = (await requestToPromise(store.get(layer.assetId))) as AssetRecord | undefined;
const assetId = getAssetRefId(getLayerAsset(layer));
if (!assetId) return layer;
const record = (await requestToPromise(store.get(assetId))) as
| AssetRecord
| undefined;
if (!record?.blob) return layer;
const existing = objectUrlByAssetId.get(layer.assetId);
if (existing) return { ...layer, sourceUri: existing };
if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
const existing = objectUrlByAssetId.get(assetId);
if (existing) return withRuntimeSource(layer, existing);
if (
typeof URL !== "undefined" &&
typeof URL.createObjectURL === "function"
) {
const objectUrl = URL.createObjectURL(record.blob);
objectUrlByAssetId.set(layer.assetId, objectUrl);
return { ...layer, sourceUri: objectUrl };
objectUrlByAssetId.set(assetId, objectUrl);
return withRuntimeSource(layer, objectUrl);
}
return layer;
}),
@@ -309,17 +387,27 @@ async function hydrateProject(db: IDBDatabase, project: Project): Promise<Projec
return { ...project, layers };
}
export function releaseProjectObjectUrls(project: Project, keepAssetIds?: Iterable<string>): void {
const keep = keepAssetIds ? new Set(Array.from(keepAssetIds).filter(Boolean)) : null;
export function releaseProjectObjectUrls(
project: Project,
keepAssetIds?: Iterable<string>,
): void {
const keep = keepAssetIds
? new Set(Array.from(keepAssetIds).filter(Boolean))
: null;
for (const layer of project.layers) {
if (!layer.assetId) continue;
if (keep?.has(layer.assetId)) continue;
const url = objectUrlByAssetId.get(layer.assetId);
const assetId = getAssetRefId(getLayerAsset(layer));
if (!assetId) continue;
if (keep?.has(assetId)) continue;
const url = objectUrlByAssetId.get(assetId);
if (!url) continue;
if (url.startsWith("blob:") && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
if (
url.startsWith("blob:") &&
typeof URL !== "undefined" &&
typeof URL.revokeObjectURL === "function"
) {
URL.revokeObjectURL(url);
}
objectUrlByAssetId.delete(layer.assetId);
objectUrlByAssetId.delete(assetId);
}
}
@@ -368,13 +456,17 @@ export async function loadProjects(): Promise<Project[]> {
try {
const tx = db.transaction(PROJECTS_STORE, "readonly");
const records = await requestToPromise(tx.objectStore(PROJECTS_STORE).getAll());
const records = await requestToPromise(
tx.objectStore(PROJECTS_STORE).getAll(),
);
const parsed = (records as unknown[])
.map((record) => ProjectSchema.safeParse(record))
.filter((result) => result.success)
.map((result) => normalizeProject(result.data));
const hydrated = await Promise.all(parsed.map((project) => hydrateProject(db, project)));
const hydrated = await Promise.all(
parsed.map((project) => hydrateProject(db, project)),
);
await cleanupOrphansInternal(db);
return hydrated.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} finally {
@@ -382,13 +474,17 @@ export async function loadProjects(): Promise<Project[]> {
}
}
export async function getProjectById(projectId: string): Promise<Project | null> {
export async function getProjectById(
projectId: string,
): Promise<Project | null> {
const db = await openDatabase();
if (!db) return null;
try {
const tx = db.transaction(PROJECTS_STORE, "readonly");
const record = await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId));
const record = await requestToPromise(
tx.objectStore(PROJECTS_STORE).get(projectId),
);
const parsed = ProjectSchema.safeParse(record);
if (!parsed.success) return null;
return await hydrateProject(db, normalizeProject(parsed.data));
@@ -401,7 +497,10 @@ export async function upsertProject(project: Project): Promise<void> {
const db = await openDatabase();
if (!db) return;
try {
await persistProject(db, { ...project, updatedAt: new Date().toISOString() });
await persistProject(db, {
...project,
updatedAt: new Date().toISOString(),
});
} finally {
db.close();
}
@@ -415,7 +514,9 @@ export async function deleteProject(projectId: string): Promise<void> {
const linksTx = db.transaction(ASSET_LINKS_STORE, "readwrite");
const linksStore = linksTx.objectStore(ASSET_LINKS_STORE);
const byProject = linksStore.index(LINKS_BY_PROJECT_INDEX);
const links = (await requestToPromise(byProject.getAll(projectId))) as AssetLinkRecord[];
const links = (await requestToPromise(
byProject.getAll(projectId),
)) as AssetLinkRecord[];
for (const link of links) {
await requestToPromise(linksStore.delete(link.id));
}
@@ -426,14 +527,21 @@ export async function deleteProject(projectId: string): Promise<void> {
});
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
await requestToPromise(projectTx.objectStore(PROJECTS_STORE).delete(projectId));
await cleanupOrphansInternal(db, links.map((link) => link.assetId));
await requestToPromise(
projectTx.objectStore(PROJECTS_STORE).delete(projectId),
);
await cleanupOrphansInternal(
db,
links.map((link) => link.assetId),
);
} finally {
db.close();
}
}
export async function duplicateProject(projectId: string): Promise<Project | null> {
export async function duplicateProject(
projectId: string,
): Promise<Project | null> {
const source = await getProjectById(projectId);
if (!source) return null;
const now = new Date().toISOString();
@@ -443,7 +551,10 @@ export async function duplicateProject(projectId: string): Promise<Project | nul
title: `${source.title} Copy`,
createdAt: now,
updatedAt: now,
layers: source.layers.map((layer) => ({ ...layer, id: crypto.randomUUID() })),
layers: source.layers.map((layer) => ({
...layer,
id: crypto.randomUUID(),
})),
};
await upsertProject(copy);
return getProjectById(copy.id);