feat(studio): recover matching Nitro sources and persist import phases

This commit is contained in:
Simo committed 2026-09-11 10:22:06 +02:00
1 parent 00e33623f7
commit 13bd3695cb
45 files changed
+903 -28

No files matched your search

+4 -4
View File
@@ -247,7 +247,7 @@ export function __resetItemsBaseAllocatorForTests(): void {
export async function cloneSingleFurni(params: {
source: CloneSource;
entry: SourceFurni;
onProgress?: (status: string) => void;
onProgress?: (status: string) => void | Promise<void>;
/** Skip the per-item FurnitureData.json write and return the entry instead,
* so batch callers can append everything in one lock + write cycle. */
deferFurniData?: boolean;
@@ -291,7 +291,7 @@ export async function cloneSingleFurni(params: {
const { iconDir, nitroDir } = await getFurniAssetDirs();
// Download .nitro + icon directly from the source hotel.
onProgress?.("downloading");
await onProgress?.("downloading");
const nitroPath = getRuntimePath(
/*turbopackIgnore: true*/ nitroDir,
`${classname}.nitro`,
@@ -370,7 +370,7 @@ export async function cloneSingleFurni(params: {
}
}
onProgress?.("writing_db");
await onProgress?.("writing_db");
const stackHeight =
entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
@@ -426,7 +426,7 @@ export async function cloneSingleFurni(params: {
// FurnitureData entry — reuse the source's furnitype object, but with our id
// AND our offerid. The source's offerid points at the source hotel's sprite
// ids, which would silently break the emulator's catalog offer lookup.
onProgress?.("writing_furnidata");
await onProgress?.("writing_furnidata");
const furniDataEntry = {
...entry,
id: newId,
+3 -1
View File
@@ -6,7 +6,9 @@ import { importRoot, validJobId } from "./furni-job-store";
export function validateFurnitureAttachment(buffer: Buffer, classname: string) {
if (buffer.length > 50 * 1024 * 1024)
throw Error("Maximum file size is 50 MB");
const parsed = parseNitroBundle(buffer);
const parsed = parseNitroBundle(buffer, {
maxOutputLength: 50 * 1024 * 1024,
});
const base = classname.split("*")[0];
if (
parsed.jsonFileName !== `${base}.json` ||
+28 -1
View File
@@ -4,6 +4,8 @@ import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
nitroExists: true,
write: vi.fn(),
official: vi.fn(),
download: vi.fn(),
execute: vi.fn(),
@@ -24,9 +26,10 @@ vi.mock("@/lib/db", () => ({
CatalogPages: {},
}));
vi.mock("node:fs", () => ({
existsSync: () => true,
existsSync: (path: string) => !path.endsWith(".nitro") || mocks.nitroExists,
promises: {
stat: mocks.stat,
writeFile: mocks.write,
readFile: vi.fn().mockResolvedValue(Buffer.alloc(256)),
},
}));
@@ -57,6 +60,9 @@ vi.mock("@/lib/services/import/core/download", () => ({
vi.mock("@/lib/services/swf-to-nitro", () => ({
parseNitroBundle: () => ({ json: {} }),
extractIconFromSwfAsync: async () => null,
convertSwfToNitroAsync: async () => {
throw Error("Source conversion failed");
},
}));
import { importSingleFurni } from "./furni-import";
@@ -78,6 +84,10 @@ function queries() {
describe("completion of existing furniture", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.nitroExists = true;
mocks.write.mockImplementation(async () => {
mocks.nitroExists = true;
});
mocks.official.mockResolvedValue(null);
mocks.download.mockResolvedValue(false);
const rows = [
@@ -92,6 +102,23 @@ describe("completion of existing furniture", () => {
mocks.execute.mockResolvedValue([[{ id: 123, page_id: "15" }], []]);
mocks.build.mockImplementation((value) => value);
});
it("recovers missing Nitro before completing the existing item without changing its IDs", async () => {
mocks.nitroExists = false;
const recover = vi.fn(async () => Buffer.alloc(256));
const result = await importSingleFurni({
...input,
resolveMissingNitro: recover,
});
expect(recover).toHaveBeenCalledWith(1);
expect(mocks.write).toHaveBeenCalledWith(
expect.stringContaining("recycler_scrapbookA.nitro"),
expect.any(Buffer),
{ flag: "wx" },
);
expect(result.ok).toBe(true);
expect(result.itemId).toBe(2000037257);
});
it("reuses local item and sprite IDs without rewriting SQL settings or offers", async () => {
const result = await importSingleFurni(input);
expect(result.ok).toBe(true);
+16 -4
View File
@@ -517,7 +517,8 @@ export async function importSingleFurni(params: {
preserveAssets?: boolean;
/** Validated matching bundle supplied by a queued import. */
providedNitro?: Buffer;
onProgress?: (status: string) => void;
onProgress?: (status: string) => void | Promise<void>;
resolveMissingNitro?: (revision: number) => Promise<Buffer | undefined>;
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
sourceSwfBaseUrl?: string;
/** Per-source nitro bundle + icon base URL. */
@@ -660,7 +661,7 @@ export async function importSingleFurni(params: {
// For color variants (classname has `*`): try color-specific first,
// fall back to base-classname icon (Nitro reuses base for all colors
// when no color-specific icon exists).
onProgress?.("downloading");
await onProgress?.("downloading");
const iconColorAware = classname.replace(/\*/g, "_");
@@ -770,7 +771,7 @@ export async function importSingleFurni(params: {
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
// ── Convert SWF to Nitro ──────────────────────────────────────────
onProgress?.("converting");
await onProgress?.("converting");
let conversionResult: ConversionResult | null = null;
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
@@ -803,6 +804,16 @@ export async function importSingleFurni(params: {
}
}
if (!existsSync(nitroPath) && params.resolveMissingNitro) {
await onProgress?.("checking_sources");
const recovered = await params.resolveMissingNitro(rev);
if (recovered) await fs.writeFile(nitroPath, recovered, { flag: "wx" });
else
warnings.push(
"No matching bundle was recovered from the checked sources. Review source availability or attach the original .nitro file.",
);
}
if (!iconOk && existsSync(nitroPath)) {
try {
const icon = extractFurniIconPng(await fs.readFile(nitroPath));
@@ -847,7 +858,7 @@ export async function importSingleFurni(params: {
}
// ── Insert into DB with enriched data ──────────────────────────────
onProgress?.("writing_db");
await onProgress?.("writing_db");
try {
if (preservingExisting) {
warnings.push(
@@ -1032,6 +1043,7 @@ export async function importSingleFurni(params: {
// Batch mode: return the entry so the caller writes once at the end
} else {
try {
await onProgress?.("writing_furnidata");
await appendFurniEntry(furniEntry, itemType);
} catch (err) {
logger.warn("[import-furni] Failed to update FurnitureData.json", {
+33
View File
@@ -242,3 +242,36 @@ it("attaches recovery files only to safe failed items and preserves retry histor
s.retry(original.id, 4, { chair: randomUUID() }),
).rejects.toThrow("latest retry");
});
it("clears old source provenance and phase when creating a retry", async () => {
const s = await store();
const original: ImportJob = {
...job(),
state: "completed",
items: [
{
id: 1,
classname: "chair",
name: "Chair",
description: "",
type: "flooritem",
revision: 1,
category: "other",
state: "failed",
phase: "converting",
phaseStartedAt: new Date().toISOString(),
sourceAttempt: "Old source",
recoveredSource: {
sourceId: "old",
sourceName: "Old source",
revision: 1,
},
},
],
};
await s.create(original);
const child = await s.retry(original.id, 4);
expect(child?.items[0].phase).toBeUndefined();
expect(child?.items[0].recoveredSource).toBeUndefined();
expect(child?.items[0].sourceAttempt).toBeUndefined();
});
+10 -1
View File
@@ -111,7 +111,16 @@ export class ImportJobStore {
state: "queued",
cancelRequested: false,
items: items.map(
({ error: _error, warnings: _warnings, itemId: _itemId, ...item }) => ({
({
error: _error,
warnings: _warnings,
itemId: _itemId,
phase: _phase,
phaseStartedAt: _phaseStartedAt,
sourceAttempt: _sourceAttempt,
recoveredSource: _recoveredSource,
...item
}) => ({
...item,
...(Object.hasOwn(attachments, item.classname)
? { attachmentId: attachments[item.classname] }
+14 -1
View File
@@ -221,7 +221,7 @@ it("does not save a translated outcome after ownership is lost during translatio
});
await drainFurnitureImports();
expect(mocks.translate).toHaveBeenCalledOnce();
expect(snapshots).toHaveLength(2);
expect(snapshots).toHaveLength(3);
expect(snapshots.at(-1)?.items[0].state).toBe("running");
expect(mocks.updateCatalog).not.toHaveBeenCalled();
});
@@ -249,3 +249,16 @@ it("keeps successful imports complete if their audit write fails", async () => {
expect(job.items[0].state).toBe("done");
expect(job.items[0].warnings).toContain("Audit record could not be saved");
});
it("persists progress before continuing an import and retains the failed phase", async () => {
mocks.import.mockImplementationOnce(async (params) => {
await params.onProgress("downloading");
expect(job.items[0].phase).toBe("downloading");
expect(mocks.save).toHaveBeenCalled();
await params.onProgress("converting");
throw Error("Conversion failed");
});
await drainFurnitureImports();
expect(job.items[0]).toMatchObject({ state: "failed", phase: "converting" });
expect(job.items[0].phaseStartedAt).toBeTruthy();
});
+34 -4
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { createStore, runWithStore } from "@/lib/foundation/request-context";
import type { IpAddress, RequestId, UserId } from "@/lib/foundation/types";
import type { ImportJob } from "@/lib/furni/import-job";
import { IMPORT_PHASES, type ImportJob } from "@/lib/furni/import-job";
import { redis } from "@/lib/redis";
import { logServerError } from "@/lib/server-log";
import { logAudit } from "./audit";
@@ -14,6 +14,7 @@ import { ensureDirectories, importSingleFurni } from "./furni-import";
import { ImportJobStore } from "./furni-job-store";
import { runSyncJobItem } from "./furni-sync-item";
import { repairFurniture } from "./furniture-repair";
import { recoverFurnitureNitro } from "./furniture-source-recovery";
import { rcon } from "./rcon";
const LOCK = "furniture-import-worker:v1";
@@ -120,7 +121,15 @@ async function drain() {
break;
}
item.state = "running";
await saveOwned(job);
const onProgress = async (status: string) => {
const phase = IMPORT_PHASES.find((value) => value === status);
if (!phase) return;
if (item.phase !== phase)
item.phaseStartedAt = new Date().toISOString();
item.phase = phase;
await saveOwned(job);
};
await onProgress("validating");
try {
if (job.sourceId && !source)
throw Error("Import source no longer exists");
@@ -133,12 +142,32 @@ async function drain() {
: undefined;
await requireLease();
const result = job.syncKind
? await runSyncJobItem(item)
? await runSyncJobItem(item, onProgress)
: await (job.mode === "repair"
? repairFurniture
: importSingleFurni)({
...item,
repairExisting: true,
onProgress,
resolveMissingNitro: async (revision) => {
const recovered = await recoverFurnitureNitro(
{ ...item, revision },
{
excludeSourceId: job.sourceId,
onAttempt: async (name) => {
item.sourceAttempt = name;
await onProgress("checking_sources");
},
},
);
await requireLease();
if (!recovered) return undefined;
const { buffer, ...provenance } = recovered;
item.recoveredSource = provenance;
item.sourceAttempt = undefined;
await saveOwned(job);
return buffer;
},
providedNitro,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl,
@@ -148,7 +177,6 @@ async function drain() {
item.warnings = result.warnings;
item.itemId = result.itemId;
if (!result.ok) throw Error(result.error || "Import failed");
item.state = "done";
await logAudit({
userId: job.userId,
action: "furni_import",
@@ -171,12 +199,14 @@ async function drain() {
});
if (job.translate && job.mode !== "repair")
try {
await onProgress("translating");
await patchLocalizedFurniDataEntries([item], true, job.langs);
} catch {
item.warnings.push(
"Translation failed; furniture imported successfully",
);
}
item.state = "done";
} catch (error) {
if (error instanceof ImportLeaseLostError) throw error;
await requireLease();
+10 -3
View File
@@ -6,7 +6,10 @@ import { getSource } from "./clone-sources";
import { importSingleFurni } from "./furni-import";
/** Called only inside the shared import lease and catalog export lock. */
export async function runSyncJobItem(item: ImportJobItem) {
export async function runSyncJobItem(
item: ImportJobItem,
onProgress?: (status: string) => void | Promise<void>,
) {
const [existing] = await db
.select({ id: ItemsBase.id })
.from(ItemsBase)
@@ -22,7 +25,11 @@ export async function runSyncJobItem(item: ImportJobItem) {
const source = await getSource(item.cloneSourceId);
if (!source || !item.cloneEntry)
throw Error("Synchronization source no longer available");
const result = await cloneSingleFurni({ source, entry: item.cloneEntry });
const result = await cloneSingleFurni({
source,
entry: item.cloneEntry,
onProgress,
});
const [created] = result.ok
? await db
.select({ id: ItemsBase.id })
@@ -32,5 +39,5 @@ export async function runSyncJobItem(item: ImportJobItem) {
: [];
return { ...result, itemId: created?.id };
}
return importSingleFurni({ ...item, repairExisting: false });
return importSingleFurni({ ...item, repairExisting: false, onProgress });
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+12 -6
View File
@@ -238,7 +238,10 @@ export function createNitroBundle(
// ── .nitro Bundle Parser ──────────────────────────────────────────
export function parseNitroBundle(buffer: Buffer): {
export function parseNitroBundle(
buffer: Buffer,
options?: { maxOutputLength?: number },
): {
json: Record<string, unknown>;
jsonFileName: string;
png: Buffer;
@@ -267,7 +270,7 @@ export function parseNitroBundle(buffer: Buffer): {
offset += 4;
const jsonCompressed = buffer.subarray(offset, offset + jsonCompressedLen);
offset += jsonCompressedLen;
const jsonStr = inflateJsonOrGzip(jsonCompressed).toString("utf-8");
const jsonStr = inflateJsonOrGzip(jsonCompressed, options).toString("utf-8");
const json = JSON.parse(jsonStr);
// File 2: PNG
@@ -279,7 +282,7 @@ export function parseNitroBundle(buffer: Buffer): {
offset += 4;
const pngCompressed = buffer.subarray(offset, offset + pngCompressedLen);
offset += pngCompressedLen;
const png = inflateJsonOrGzip(pngCompressed);
const png = inflateJsonOrGzip(pngCompressed, options);
return { json, jsonFileName, png, pngFileName };
}
@@ -289,10 +292,13 @@ export function parseNitroBundle(buffer: Buffer): {
* gzip their contents instead of raw-deflate. Try inflate first, then fall
* back to gunzip so both formats parse.
*/
function inflateJsonOrGzip(input: Buffer): Buffer {
function inflateJsonOrGzip(
input: Buffer,
options?: { maxOutputLength?: number },
): Buffer {
try {
return inflateSync(input);
return inflateSync(input, options);
} catch {
return gunzipSync(input);
return gunzipSync(input, options);
}
}
@@ -0,0 +1,12 @@
import { expect, it } from "vitest";
import { createNitroBundle, parseNitroBundle } from "./nitro-builder";
it("honors an explicit decompression limit for imported bundles", () => {
const bundle = createNitroBundle(
{ name: "chair", value: "a".repeat(4096) },
Buffer.from("png"),
"chair",
);
expect(() => parseNitroBundle(bundle, { maxOutputLength: 1024 })).toThrow();
expect(parseNitroBundle(bundle).json.name).toBe("chair");
});