mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: more tests, refactored
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Layer, Project } from "@pien-studio/types";
|
||||
import {
|
||||
buildAssetLinks,
|
||||
buildAssetRecord,
|
||||
chooseReusableAsset,
|
||||
collectProjectAssetIds,
|
||||
inferMimeType,
|
||||
isBinaryLayer,
|
||||
makeLinkId,
|
||||
stripEmbeddedSourceUri,
|
||||
} from "./asset-records";
|
||||
|
||||
function layer(partial: Partial<Layer> = {}): Layer {
|
||||
return {
|
||||
id: "layer-1",
|
||||
type: "raster",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
effects: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function project(layers: Layer[]): Project {
|
||||
return {
|
||||
id: "project-1",
|
||||
title: "Project",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-02T00:00:00.000Z",
|
||||
canvas: { width: 100, height: 100, unit: "px" },
|
||||
aspectRatio: "1:1",
|
||||
layers,
|
||||
};
|
||||
}
|
||||
|
||||
describe("asset record helpers", () => {
|
||||
it("identifies binary layers and infers mime types", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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({
|
||||
id: "reused",
|
||||
createdAt: "then",
|
||||
updatedAt: "now",
|
||||
});
|
||||
});
|
||||
|
||||
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: "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" },
|
||||
]);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Layer, Project } from "@pien-studio/types";
|
||||
|
||||
export type AssetRecordInput = {
|
||||
existingId?: string;
|
||||
reusable?: { id: string; createdAt: string };
|
||||
fallbackId: string;
|
||||
mimeType: string;
|
||||
blob: Blob;
|
||||
hash: string;
|
||||
now: string;
|
||||
};
|
||||
|
||||
export type AssetLinkRecordInput = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
layerId: string;
|
||||
assetId: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function makeLinkId(projectId: string, layerId: string): string {
|
||||
return `${projectId}:${layerId}`;
|
||||
}
|
||||
|
||||
export function isBinaryLayer(layer: Layer): boolean {
|
||||
return layer.type === "raster" || layer.type === "sticker";
|
||||
}
|
||||
|
||||
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";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
export function chooseReusableAsset<T extends { blob: Blob }>(assets: T[], blob: Blob): T | undefined {
|
||||
return assets.find((asset) => asset.blob.size === blob.size);
|
||||
}
|
||||
|
||||
export function buildAssetRecord(input: AssetRecordInput) {
|
||||
return {
|
||||
id: input.existingId ?? input.reusable?.id ?? input.fallbackId,
|
||||
mimeType: input.mimeType,
|
||||
blob: input.blob,
|
||||
hash: input.hash,
|
||||
createdAt: input.reusable?.createdAt ?? input.now,
|
||||
updatedAt: input.now,
|
||||
};
|
||||
}
|
||||
|
||||
export function stripEmbeddedSourceUri(layer: Layer): Layer {
|
||||
return {
|
||||
...layer,
|
||||
sourceUri: layer.sourceUri?.startsWith("data:image/") ? undefined : layer.sourceUri,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAssetLinks(project: Project, updatedAt: string): AssetLinkRecordInput[] {
|
||||
return project.layers
|
||||
.filter((layer) => layer.assetId && isBinaryLayer(layer))
|
||||
.map((layer) => ({
|
||||
id: makeLinkId(project.id, layer.id),
|
||||
projectId: project.id,
|
||||
layerId: layer.id,
|
||||
assetId: layer.assetId as string,
|
||||
updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function collectProjectAssetIds(project: Project): Set<string> {
|
||||
return new Set(buildAssetLinks(project, new Date(0).toISOString()).map((link) => link.assetId));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { getProjectById, loadProjects } from "./index";
|
||||
import { deleteProject, duplicateProject, getProjectById, loadProjects, saveProjects, upsertProject } from "./index";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
|
||||
const DB_NAME = "pien.db";
|
||||
@@ -60,6 +60,25 @@ async function readRawProject(projectId: string): Promise<Project | undefined> {
|
||||
return record;
|
||||
}
|
||||
|
||||
async function readStoreCount(storeName: string): Promise<number> {
|
||||
const openRequest = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
openRequest.onupgradeneeded = () => ensureSchema(openRequest.result);
|
||||
const db = await requestToPromise(openRequest);
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const count = await requestToPromise(tx.objectStore(storeName).count());
|
||||
db.close();
|
||||
return count;
|
||||
}
|
||||
|
||||
async function resetDatabase(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function makeMalformedProject(id: string): Project {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
@@ -80,6 +99,7 @@ function makeMalformedProject(id: string): Project {
|
||||
scale: 0,
|
||||
rotation: 720.4,
|
||||
opacity: 0.75,
|
||||
visible: true,
|
||||
effects: [],
|
||||
},
|
||||
],
|
||||
@@ -121,14 +141,43 @@ function makeFaceBlurProject(id: string): Project {
|
||||
};
|
||||
}
|
||||
|
||||
function makeAssetProject(id: string, sourceUri = "data:image/png;base64,aGVsbG8="): Project {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
aspectRatio: "1:1",
|
||||
canvas: { width: 100, height: 100, unit: "px" },
|
||||
layers: [
|
||||
{
|
||||
id: "image-1",
|
||||
type: "raster",
|
||||
sourceUri,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
effects: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("storage read flows", () => {
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
vi.spyOn(URL, "createObjectURL").mockImplementation((obj: Blob | MediaSource) => `blob:test-${"size" in obj ? obj.size : "media"}`);
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("loadProjects hydrates normalized values without persisting writes", async () => {
|
||||
@@ -173,4 +222,58 @@ describe("storage read flows", () => {
|
||||
expect(listedRegion?.sourceWidth).toBeUndefined();
|
||||
expect(listedRegion?.sourceHeight).toBeUndefined();
|
||||
}, 15_000);
|
||||
|
||||
it("persists embedded raster images as assets and hydrates object URLs", async () => {
|
||||
await upsertProject(makeAssetProject("asset-project"));
|
||||
|
||||
const raw = await readRawProject("asset-project");
|
||||
expect(raw?.layers[0]?.assetId).toBeDefined();
|
||||
expect(raw?.layers[0]?.sourceUri).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(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("duplicates projects with new ids while reusing persisted asset content", async () => {
|
||||
await upsertProject(makeAssetProject("source-project"));
|
||||
const copy = await duplicateProject("source-project");
|
||||
|
||||
expect(copy?.id).not.toBe("source-project");
|
||||
expect(copy?.title).toBe("source-project Copy");
|
||||
expect(copy?.layers[0]?.id).not.toBe("image-1");
|
||||
expect(await readStoreCount(PROJECTS_STORE)).toBe(2);
|
||||
expect(await readStoreCount(ASSETS_STORE)).toBe(1);
|
||||
expect(await readStoreCount(ASSET_LINKS_STORE)).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes projects and cleans orphaned assets", async () => {
|
||||
await upsertProject(makeAssetProject("delete-project"));
|
||||
expect(await readStoreCount(ASSETS_STORE)).toBe(1);
|
||||
|
||||
await deleteProject("delete-project");
|
||||
|
||||
expect(await getProjectById("delete-project")).toBeNull();
|
||||
expect(await readStoreCount(PROJECTS_STORE)).toBe(0);
|
||||
expect(await readStoreCount(ASSET_LINKS_STORE)).toBe(0);
|
||||
expect(await readStoreCount(ASSETS_STORE)).toBe(0);
|
||||
});
|
||||
|
||||
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==");
|
||||
await upsertProject(keep);
|
||||
await upsertProject(drop);
|
||||
|
||||
await saveProjects([{ ...keep, title: "kept" }]);
|
||||
|
||||
const projects = await loadProjects();
|
||||
expect(projects.map((project) => project.id)).toEqual(["keep-project"]);
|
||||
expect(projects[0]?.title).toBe("kept");
|
||||
expect(await readStoreCount(PROJECTS_STORE)).toBe(1);
|
||||
expect(await readStoreCount(ASSET_LINKS_STORE)).toBe(1);
|
||||
expect(await readStoreCount(ASSETS_STORE)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+102
-94
@@ -1,5 +1,14 @@
|
||||
import { normalizeProject } from "@pien-studio/editor-core";
|
||||
import { ProjectSchema, type Layer, type Project } from "@pien-studio/types";
|
||||
import {
|
||||
buildAssetLinks,
|
||||
buildAssetRecord,
|
||||
chooseReusableAsset,
|
||||
inferMimeType,
|
||||
isBinaryLayer,
|
||||
makeLinkId,
|
||||
stripEmbeddedSourceUri,
|
||||
} from "./asset-records";
|
||||
|
||||
const DB_NAME = "pien.db";
|
||||
const DB_VERSION = 4;
|
||||
@@ -95,20 +104,6 @@ async function hashBlob(blob: Blob): Promise<string> {
|
||||
return Array.from(new Uint8Array(buffer)).slice(0, 64).join("-");
|
||||
}
|
||||
|
||||
function makeLinkId(projectId: string, layerId: string): string {
|
||||
return `${projectId}:${layerId}`;
|
||||
}
|
||||
|
||||
function isBinaryLayer(layer: Layer): boolean {
|
||||
return layer.type === "raster" || layer.type === "sticker";
|
||||
}
|
||||
|
||||
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";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Promise<string | undefined> {
|
||||
if (!isBinaryLayer(layer)) return layer.assetId;
|
||||
|
||||
@@ -117,22 +112,21 @@ async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Prom
|
||||
const hash = await hashBlob(blob);
|
||||
const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX);
|
||||
const matching = (await requestToPromise(hashIndex.getAll(hash))) as AssetRecord[];
|
||||
const reusable = matching.find((asset) => asset.blob.size === blob.size);
|
||||
const reusable = chooseReusableAsset(matching, blob);
|
||||
const now = new Date().toISOString();
|
||||
const id = layer.assetId ?? reusable?.id ?? crypto.randomUUID();
|
||||
const record = buildAssetRecord({
|
||||
existingId: layer.assetId,
|
||||
reusable,
|
||||
fallbackId: crypto.randomUUID(),
|
||||
mimeType: blob.type || inferMimeType(layer),
|
||||
blob,
|
||||
hash,
|
||||
now,
|
||||
});
|
||||
|
||||
await requestToPromise(
|
||||
assetStore.put({
|
||||
id,
|
||||
mimeType: blob.type || inferMimeType(layer),
|
||||
blob,
|
||||
hash,
|
||||
createdAt: reusable?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
} satisfies AssetRecord),
|
||||
);
|
||||
await requestToPromise(assetStore.put(record satisfies AssetRecord));
|
||||
|
||||
return id;
|
||||
return record.id;
|
||||
}
|
||||
|
||||
return layer.assetId;
|
||||
@@ -170,21 +164,13 @@ async function syncLinksForProject(db: IDBDatabase, project: Project): Promise<S
|
||||
const existingMap = new Map(existing.map((link) => [link.id, link]));
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const referenced = new Set<string>();
|
||||
for (const layer of project.layers) {
|
||||
if (!layer.assetId || !isBinaryLayer(layer)) continue;
|
||||
const id = makeLinkId(project.id, layer.id);
|
||||
referenced.add(layer.assetId);
|
||||
const links = buildAssetLinks(project, now);
|
||||
const referenced = new Set(links.map((link) => link.assetId));
|
||||
for (const link of links) {
|
||||
await requestToPromise(
|
||||
store.put({
|
||||
id,
|
||||
projectId: project.id,
|
||||
layerId: layer.id,
|
||||
assetId: layer.assetId,
|
||||
updatedAt: now,
|
||||
} satisfies AssetLinkRecord),
|
||||
store.put(link satisfies AssetLinkRecord),
|
||||
);
|
||||
existingMap.delete(id);
|
||||
existingMap.delete(link.id);
|
||||
}
|
||||
|
||||
const removedAssetIds = new Set<string>();
|
||||
@@ -247,24 +233,23 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
||||
const layer = layers[prepared.layerIndex];
|
||||
if (!layer) continue;
|
||||
const matching = (await requestToPromise(hashIndex.getAll(prepared.hash))) as AssetRecord[];
|
||||
const reusable = matching.find((asset) => asset.blob.size === prepared.blob.size);
|
||||
const reusable = chooseReusableAsset(matching, prepared.blob);
|
||||
const now = new Date().toISOString();
|
||||
const id = layer.assetId ?? reusable?.id ?? crypto.randomUUID();
|
||||
const record = buildAssetRecord({
|
||||
existingId: layer.assetId,
|
||||
reusable,
|
||||
fallbackId: crypto.randomUUID(),
|
||||
mimeType: prepared.mimeType,
|
||||
blob: prepared.blob,
|
||||
hash: prepared.hash,
|
||||
now,
|
||||
});
|
||||
|
||||
await requestToPromise(
|
||||
assetStore.put({
|
||||
id,
|
||||
mimeType: prepared.mimeType,
|
||||
blob: prepared.blob,
|
||||
hash: prepared.hash,
|
||||
createdAt: reusable?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
} satisfies AssetRecord),
|
||||
);
|
||||
await requestToPromise(assetStore.put(record satisfies AssetRecord));
|
||||
|
||||
layers[prepared.layerIndex] = {
|
||||
...layer,
|
||||
assetId: id,
|
||||
assetId: record.id,
|
||||
sourceUri: undefined,
|
||||
};
|
||||
}
|
||||
@@ -275,9 +260,8 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
||||
const assetId = await putAssetFromLayer(assetStore, layer);
|
||||
if (!assetId) continue;
|
||||
layers[index] = {
|
||||
...layer,
|
||||
...stripEmbeddedSourceUri(layer),
|
||||
assetId,
|
||||
sourceUri: layer.sourceUri?.startsWith("data:image/") ? undefined : layer.sourceUri,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -342,7 +326,11 @@ export function releaseProjectObjectUrls(project: Project, keepAssetIds?: Iterab
|
||||
export async function cleanupOrphanAssets(): Promise<number> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return 0;
|
||||
return cleanupOrphansInternal(db);
|
||||
try {
|
||||
return await cleanupOrphansInternal(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function startAssetCleanupJob(intervalMs = 45_000): () => void {
|
||||
@@ -357,16 +345,20 @@ export async function saveProjects(projects: Project[]): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
|
||||
const existing = await loadProjects();
|
||||
const keep = new Set(projects.map((project) => project.id));
|
||||
for (const project of existing) {
|
||||
if (!keep.has(project.id)) {
|
||||
await deleteProject(project.id);
|
||||
try {
|
||||
const existing = await loadProjects();
|
||||
const keep = new Set(projects.map((project) => project.id));
|
||||
for (const project of existing) {
|
||||
if (!keep.has(project.id)) {
|
||||
await deleteProject(project.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
await persistProject(db, project);
|
||||
for (const project of projects) {
|
||||
await persistProject(db, project);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,55 +366,71 @@ export async function loadProjects(): Promise<Project[]> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return [];
|
||||
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
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));
|
||||
try {
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
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)));
|
||||
void cleanupOrphansInternal(db);
|
||||
return hydrated.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
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 {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProjectById(projectId: string): Promise<Project | null> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return null;
|
||||
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
const record = await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId));
|
||||
const parsed = ProjectSchema.safeParse(record);
|
||||
if (!parsed.success) return null;
|
||||
return hydrateProject(db, normalizeProject(parsed.data));
|
||||
try {
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
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));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertProject(project: Project): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
await persistProject(db, { ...project, updatedAt: new Date().toISOString() });
|
||||
try {
|
||||
await persistProject(db, { ...project, updatedAt: new Date().toISOString() });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProject(projectId: string): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
|
||||
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[];
|
||||
for (const link of links) {
|
||||
await requestToPromise(linksStore.delete(link.id));
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
linksTx.oncomplete = () => resolve();
|
||||
linksTx.onerror = () => reject(linksTx.error);
|
||||
linksTx.onabort = () => reject(linksTx.error);
|
||||
});
|
||||
try {
|
||||
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[];
|
||||
for (const link of links) {
|
||||
await requestToPromise(linksStore.delete(link.id));
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
linksTx.oncomplete = () => resolve();
|
||||
linksTx.onerror = () => reject(linksTx.error);
|
||||
linksTx.onabort = () => reject(linksTx.error);
|
||||
});
|
||||
|
||||
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
|
||||
await requestToPromise(projectTx.objectStore(PROJECTS_STORE).delete(projectId));
|
||||
await cleanupOrphansInternal(db, links.map((link) => link.assetId));
|
||||
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
|
||||
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> {
|
||||
|
||||
Reference in New Issue
Block a user