diff --git a/README.md b/README.md
index bc193c884..9eff55e76 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7266+ tests / 591 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7304+ tests / 591 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -549,7 +549,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7266+ tests, 591 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7304+ tests, 591 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -710,8 +710,8 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `deploy` | `main` only | GitHub Pages after **`ci-success`** succeeds |
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
-**Current test metrics (2026-08-27, source-synchronized; CI remains authoritative for pass/fail):**
-- **7266+ unit tests** across **591 test files** — CI is authoritative for pass/fail
+**Current test metrics (2026-08-30, source-synchronized; CI remains authoritative for pass/fail):**
+- **7304+ unit tests** across **591 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/features/project/thunks/projectManagementThunks.ts b/features/project/thunks/projectManagementThunks.ts
index 7c36d6042..a27c5c458 100644
--- a/features/project/thunks/projectManagementThunks.ts
+++ b/features/project/thunks/projectManagementThunks.ts
@@ -1,10 +1,24 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
+import type { RootState } from '../../../app/store';
import { parseImportedProjectJson } from '../../../services/projectImportSchema';
import { storageService } from '../../../services/storageService';
import type { Character, World } from '../../../types';
import { charactersAdapter, worldsAdapter } from '../adapters';
import type { ProjectData } from '../projectSlice';
+const LEGACY_PROJECT_DIRECTORY_METADATA_KEY = '__worldscriptLegacyProjectDirectory';
+
+// QNBS-v3: compare only storage-owned target identity so mutable snapshot content cannot hide a project switch.
+function restoreTargetIdentity(project: unknown): string | null {
+ if (typeof project !== 'object' || project === null) return null;
+ const record = project as Record;
+ if (typeof record['id'] === 'string' && record['id']) return `id:${record['id']}`;
+ const legacyDirectory = record[LEGACY_PROJECT_DIRECTORY_METADATA_KEY];
+ return typeof legacyDirectory === 'string' && legacyDirectory
+ ? `legacy:${legacyDirectory}`
+ : null;
+}
+
export const importProjectThunk = createAsyncThunk('project/importProject', async (file: File) => {
const text = await file.text();
const projectDataJson = parseImportedProjectJson(text);
@@ -88,8 +102,18 @@ export const importProjectThunk = createAsyncThunk('project/importProject', asyn
export const restoreSnapshotThunk = createAsyncThunk(
'project/restoreSnapshot',
- async (snapshotId: number) => {
- const data = await storageService.getSnapshotData(snapshotId);
- return data;
+ async (snapshotId: number, thunkApi) => {
+ // QNBS-v3: capture ownership before snapshot I/O so payload contents cannot change the restore target.
+ const currentProject = (thunkApi.getState() as RootState).project?.present?.data;
+ if (!currentProject) {
+ throw new Error('Cannot restore a snapshot without an active project.');
+ }
+ const capturedTargetIdentity = restoreTargetIdentity(currentProject);
+ const restored = await storageService.restoreSnapshot(snapshotId, currentProject);
+ const liveProject = (thunkApi.getState() as RootState).project?.present?.data;
+ if (restoreTargetIdentity(liveProject) !== capturedTargetIdentity) {
+ throw new Error('Cannot restore a snapshot after the active project changed.');
+ }
+ return restored;
},
);
diff --git a/services/fs/assetFsStore.ts b/services/fs/assetFsStore.ts
index 51344cca4..fb80b4def 100644
--- a/services/fs/assetFsStore.ts
+++ b/services/fs/assetFsStore.ts
@@ -70,8 +70,11 @@ export class FsAssetStore extends FsSnapshotStore {
private async binderAssetPaths(projectId: string, assetId: string) {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
- const safeId = sanitizePathSegment(projectId, 'project');
const safeAsset = sanitizePathSegment(assetId, 'asset');
+ const safeId = sanitizePathSegment(
+ this.resolveAuxiliaryProjectId(projectId, 'binder', safeAsset),
+ 'project',
+ );
const dir = await apis.join(appDataPath, 'projects', safeId, 'binder');
const binFile = await apis.join(dir, `${safeAsset}.bin`);
const metaFile = await apis.join(dir, `${safeAsset}.meta.json`);
@@ -84,36 +87,40 @@ export class FsAssetStore extends FsSnapshotStore {
data: ArrayBuffer,
meta: BinderAssetMeta,
): Promise {
- const apis = await this.getApis();
- const { dir, binFile, metaFile } = await this.binderAssetPaths(projectId, assetId);
- if (!(await apis.exists(dir))) await apis.mkdir(dir, { recursive: true });
- const metaOut: BinderAssetMeta = { ...meta, byteSize: data.byteLength };
- await writeFileAtomic(apis, binFile, new Uint8Array(data));
- await writeTextFileAtomic(apis, metaFile, JSON.stringify(metaOut));
+ await this.withLegacyRoutingOperation(async () => {
+ const apis = await this.getApis();
+ const { dir, binFile, metaFile } = await this.binderAssetPaths(projectId, assetId);
+ if (!(await apis.exists(dir))) await apis.mkdir(dir, { recursive: true });
+ const metaOut: BinderAssetMeta = { ...meta, byteSize: data.byteLength };
+ await writeFileAtomic(apis, binFile, new Uint8Array(data));
+ await writeTextFileAtomic(apis, metaFile, JSON.stringify(metaOut));
+ });
}
async getBinderAsset(projectId: string, assetId: string): Promise {
try {
- const apis = await this.getApis();
- const { binFile, metaFile } = await this.binderAssetPaths(projectId, assetId);
- if (!(await apis.exists(binFile)) || !(await apis.exists(metaFile))) return null;
- const [bytes, metaRaw] = await Promise.all([
- retryFs(() => apis.readFile(binFile)),
- retryFs(() => apis.readTextFile(metaFile)),
- ]);
- const meta = JSON.parse(metaRaw) as BinderAssetMeta;
- // QNBS-v3: binary + metadata are two independent atomic writes, not one transaction — a byteSize mismatch is the cheapest reliable signal that a partial failure paired a new generation with a stale one.
- if (meta.byteSize !== bytes.byteLength) {
- logger.warn('getBinderAsset: byteSize/binary mismatch — treating pair as corrupt', {
- projectId,
- assetId,
- expected: meta.byteSize,
- actual: bytes.byteLength,
- });
- return null;
- }
- const copy = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
- return { data: copy, meta };
+ return await this.withLegacyRoutingOperation(async () => {
+ const apis = await this.getApis();
+ const { binFile, metaFile } = await this.binderAssetPaths(projectId, assetId);
+ if (!(await apis.exists(binFile)) || !(await apis.exists(metaFile))) return null;
+ const [bytes, metaRaw] = await Promise.all([
+ retryFs(() => apis.readFile(binFile)),
+ retryFs(() => apis.readTextFile(metaFile)),
+ ]);
+ const meta = JSON.parse(metaRaw) as BinderAssetMeta;
+ // QNBS-v3: binary + metadata are two independent atomic writes, not one transaction — a byteSize mismatch is the cheapest reliable signal that a partial failure paired a new generation with a stale one.
+ if (meta.byteSize !== bytes.byteLength) {
+ logger.warn('getBinderAsset: byteSize/binary mismatch — treating pair as corrupt', {
+ projectId,
+ assetId,
+ expected: meta.byteSize,
+ actual: bytes.byteLength,
+ });
+ return null;
+ }
+ const copy = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
+ return { data: copy, meta };
+ });
} catch (error) {
logger.warn('getBinderAsset failed:', error);
return null;
@@ -122,28 +129,64 @@ export class FsAssetStore extends FsSnapshotStore {
async deleteBinderAsset(projectId: string, assetId: string): Promise {
try {
- const apis = await this.getApis();
- const { binFile, metaFile } = await this.binderAssetPaths(projectId, assetId);
- if (await apis.exists(binFile)) await retryFs(() => apis.remove(binFile));
- if (await apis.exists(metaFile)) await retryFs(() => apis.remove(metaFile));
+ await this.withLegacyRoutingOperation(() => this.deleteBinderAssetStrict(projectId, assetId));
} catch (error) {
logger.warn('deleteBinderAsset failed:', error);
}
}
+ protected async deleteBinderAssetStrict(projectId: string, assetId: string): Promise {
+ const { apis, binFile, metaFile } = await this.binderAssetPaths(projectId, assetId);
+ if (await apis.exists(binFile)) await retryFs(() => apis.remove(binFile));
+ if (await apis.exists(metaFile)) await retryFs(() => apis.remove(metaFile));
+ }
+
async listBinderAssetIds(projectId: string): Promise {
+ try {
+ return await this.withLegacyRoutingOperation(() =>
+ this.listBinderAssetIdsUnlocked(projectId),
+ );
+ } catch (error) {
+ logger.warn('listBinderAssetIds failed:', error);
+ return [];
+ }
+ }
+
+ private async listBinderAssetIdsUnlocked(projectId: string): Promise {
try {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
- const safeId = sanitizePathSegment(projectId, 'project');
- const dir = await apis.join(appDataPath, 'projects', safeId, 'binder');
- if (!(await apis.exists(dir))) return [];
- const entries = await retryFs(() => apis.readDir(dir));
const ids = new Set();
- for (const e of entries) {
- const name = e.name ?? '';
- if (name.endsWith('.meta.json')) {
- ids.add(name.replace(/\.meta\.json$/, ''));
+ const legacyProjectId = this.legacyBinderProjectId(projectId);
+ const safeIds = new Set(
+ [projectId, legacyProjectId]
+ .filter((id): id is string => Boolean(id))
+ .map((id) => sanitizePathSegment(id, 'project')),
+ );
+ for (const safeId of safeIds) {
+ try {
+ const dir = await apis.join(appDataPath, 'projects', safeId, 'binder');
+ if (!(await apis.exists(dir))) continue;
+ const legacyOnly =
+ legacyProjectId !== null && safeId !== sanitizePathSegment(projectId, 'project');
+ const allowed = legacyOnly
+ ? new Set(this.legacyBinderAssetIdsForProject(projectId))
+ : null;
+ const entries = await retryFs(() => apis.readDir(dir));
+ for (const e of entries) {
+ const name = e.name ?? '';
+ if (name.endsWith('.meta.json')) {
+ const id = name.replace(/\.meta\.json$/, '');
+ if (!allowed || allowed.has(id)) ids.add(id);
+ }
+ }
+ } catch (error) {
+ // QNBS-v3: one unreadable legacy directory must not erase IDs already collected from a healthy project directory.
+ logger.warn('listBinderAssetIds: skipped unreadable project directory', {
+ projectId,
+ safeId,
+ error: error instanceof Error ? error.message : String(error),
+ });
}
}
return [...ids];
@@ -154,7 +197,17 @@ export class FsAssetStore extends FsSnapshotStore {
}
async deleteAllBinderAssetsForProject(projectId: string): Promise {
- const ids = await this.listBinderAssetIds(projectId);
- await Promise.all(ids.map((id) => this.deleteBinderAsset(projectId, id)));
+ await this.withLegacyRoutingOperation(async () => {
+ const ids = await this.listBinderAssetIdsUnlocked(projectId);
+ await Promise.all(
+ ids.map(async (id) => {
+ try {
+ await this.deleteBinderAssetStrict(projectId, id);
+ } catch (error) {
+ logger.warn('deleteBinderAsset failed:', error);
+ }
+ }),
+ );
+ });
}
}
diff --git a/services/fs/codexFsStore.ts b/services/fs/codexFsStore.ts
index b1e6d3736..9190c6b20 100644
--- a/services/fs/codexFsStore.ts
+++ b/services/fs/codexFsStore.ts
@@ -19,24 +19,34 @@ export class FsCodexStore extends FsSettingsStore {
// Story Codex — projects/{projectId}/codex/codex.snap
async saveStoryCodex(codex: StoryCodex): Promise {
- const apis = await this.getApis();
- const appDataPath = await this.ensureAppDataPath();
- const safeId = sanitizePathSegment(codex.projectId, 'project');
- const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex');
- if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
- const codexFile = await apis.join(codexDir, 'codex.snap');
- await writeTextFileAtomic(apis, codexFile, compressData(codex));
+ await this.withLegacyRoutingOperation(async () => {
+ const apis = await this.getApis();
+ const appDataPath = await this.ensureAppDataPath();
+ const safeId = sanitizePathSegment(
+ this.resolveAuxiliaryProjectId(codex.projectId, 'codex'),
+ 'project',
+ );
+ const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex');
+ if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
+ const codexFile = await apis.join(codexDir, 'codex.snap');
+ await writeTextFileAtomic(apis, codexFile, compressData(codex));
+ });
}
async getStoryCodex(projectId: string): Promise {
try {
- const apis = await this.getApis();
- const appDataPath = await this.ensureAppDataPath();
- const safeId = sanitizePathSegment(projectId, 'project');
- const codexFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'codex.snap');
- if (!(await apis.exists(codexFile))) return null;
- const content = await retryFs(() => apis.readTextFile(codexFile));
- return decompressData(content);
+ return await this.withLegacyRoutingOperation(async () => {
+ const apis = await this.getApis();
+ const appDataPath = await this.ensureAppDataPath();
+ const safeId = sanitizePathSegment(
+ this.resolveAuxiliaryProjectId(projectId, 'codex'),
+ 'project',
+ );
+ const codexFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'codex.snap');
+ if (!(await apis.exists(codexFile))) return null;
+ const content = await retryFs(() => apis.readTextFile(codexFile));
+ return decompressData(content);
+ });
} catch (error) {
logger.error('Failed to load story codex:', error);
return null;
@@ -45,37 +55,55 @@ export class FsCodexStore extends FsSettingsStore {
async deleteStoryCodex(projectId: string): Promise {
try {
- const apis = await this.getApis();
- const appDataPath = await this.ensureAppDataPath();
- const safeId = sanitizePathSegment(projectId, 'project');
- const codexFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'codex.snap');
- if (await apis.exists(codexFile)) await retryFs(() => apis.remove(codexFile));
+ await this.withLegacyRoutingOperation(() => this.deleteStoryCodexStrict(projectId));
} catch (error) {
logger.error('Failed to delete story codex:', error);
}
}
- // RAG Vectors — projects/{projectId}/codex/vectors.snap
-
- async saveRagVectors(projectId: string, vectors: unknown[]): Promise {
+ protected async deleteStoryCodexStrict(projectId: string): Promise {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
- const safeId = sanitizePathSegment(projectId, 'project');
- const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex');
- if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
- const vectorsFile = await apis.join(codexDir, 'vectors.snap');
- await writeTextFileAtomic(apis, vectorsFile, compressData(vectors));
+ const safeId = sanitizePathSegment(
+ this.resolveAuxiliaryProjectId(projectId, 'codex'),
+ 'project',
+ );
+ const codexFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'codex.snap');
+ if (await apis.exists(codexFile)) await retryFs(() => apis.remove(codexFile));
}
- async getRagVectors(projectId: string): Promise {
- try {
+ // RAG Vectors — projects/{projectId}/codex/vectors.snap
+
+ async saveRagVectors(projectId: string, vectors: unknown[]): Promise {
+ await this.withLegacyRoutingOperation(async () => {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
+ // QNBS-v3: vectors.snap has no embedded project provenance, so Codex ownership cannot grant it a legacy fallback route.
const safeId = sanitizePathSegment(projectId, 'project');
- const vectorsFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'vectors.snap');
- if (!(await apis.exists(vectorsFile))) return [];
- const content = await retryFs(() => apis.readTextFile(vectorsFile));
- return decompressData(content);
+ const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex');
+ if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
+ const vectorsFile = await apis.join(codexDir, 'vectors.snap');
+ await writeTextFileAtomic(apis, vectorsFile, compressData(vectors));
+ });
+ }
+
+ async getRagVectors(projectId: string): Promise {
+ try {
+ return await this.withLegacyRoutingOperation(async () => {
+ const apis = await this.getApis();
+ const appDataPath = await this.ensureAppDataPath();
+ const safeId = sanitizePathSegment(projectId, 'project');
+ const vectorsFile = await apis.join(
+ appDataPath,
+ 'projects',
+ safeId,
+ 'codex',
+ 'vectors.snap',
+ );
+ if (!(await apis.exists(vectorsFile))) return [];
+ const content = await retryFs(() => apis.readTextFile(vectorsFile));
+ return decompressData(content);
+ });
} catch (error) {
logger.error('Failed to load RAG vectors:', error);
return [];
@@ -84,11 +112,19 @@ export class FsCodexStore extends FsSettingsStore {
async deleteRagVectors(projectId: string): Promise {
try {
- const apis = await this.getApis();
- const appDataPath = await this.ensureAppDataPath();
- const safeId = sanitizePathSegment(projectId, 'project');
- const vectorsFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'vectors.snap');
- if (await apis.exists(vectorsFile)) await retryFs(() => apis.remove(vectorsFile));
+ await this.withLegacyRoutingOperation(async () => {
+ const apis = await this.getApis();
+ const appDataPath = await this.ensureAppDataPath();
+ const safeId = sanitizePathSegment(projectId, 'project');
+ const vectorsFile = await apis.join(
+ appDataPath,
+ 'projects',
+ safeId,
+ 'codex',
+ 'vectors.snap',
+ );
+ if (await apis.exists(vectorsFile)) await retryFs(() => apis.remove(vectorsFile));
+ });
} catch (error) {
logger.error('Failed to delete RAG vectors:', error);
}
diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts
index ef72c7319..7186cf86d 100644
--- a/services/fs/fsCore.ts
+++ b/services/fs/fsCore.ts
@@ -26,6 +26,12 @@ export type TauriApis = {
let tauriApis: TauriApis | null = null;
+type LegacyAuxiliaryPolicy = {
+ legacyProjectId: string;
+ codex: boolean;
+ binderAssetIds: ReadonlySet;
+};
+
export async function loadTauriApis(): Promise {
if (tauriApis) return tauriApis;
if (!desktopPlatform.runtime.isDesktop) {
@@ -158,7 +164,9 @@ export function decompressData(raw: string): T {
try {
return JSON.parse(decompressed) as T;
} catch {
- throw new DecompressionError('Failed to parse decompressed data as JSON — the payload is corrupt.');
+ throw new DecompressionError(
+ 'Failed to parse decompressed data as JSON — the payload is corrupt.',
+ );
}
}
try {
@@ -301,6 +309,8 @@ export function countProjectWords(projectData: unknown): number {
export class FsCore {
protected appDataPath: string | null = null;
+ private readonly legacyAuxiliaryPolicies = new Map();
+ private legacyRoutingOperationTail: Promise | null = null;
protected lastAutoSnapshotTime = Date.now();
protected readonly AUTO_SNAPSHOT_INTERVAL = 5 * 60 * 1000; // 5 minutes
protected readonly MAX_AUTO_SNAPSHOTS = 20;
@@ -325,4 +335,99 @@ export class FsCore {
protected async getApis(): Promise {
return loadTauriApis();
}
+
+ // QNBS-v3: serialize complete filesystem operations so legacy route ownership cannot change between awaited mutations.
+ protected async withLegacyRoutingOperation(operation: () => Promise): Promise {
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const previous = this.legacyRoutingOperationTail;
+ const current = (previous?.catch(() => undefined) ?? Promise.resolve()).then(() => gate);
+ this.legacyRoutingOperationTail = current;
+ await previous;
+ try {
+ return await operation();
+ } finally {
+ if (this.legacyRoutingOperationTail === current) {
+ this.legacyRoutingOperationTail = null;
+ }
+ release();
+ }
+ }
+
+ protected registerLegacyAuxiliaryPolicy(
+ projectId: string,
+ legacyProjectId: string,
+ policy: Omit,
+ ): void {
+ if (policy.codex || policy.binderAssetIds.size > 0) {
+ // QNBS-v3: one filesystem-owned policy keeps verified legacy auxiliary data addressable without cross-project fallback.
+ this.legacyAuxiliaryPolicies.set(projectId, { legacyProjectId, ...policy });
+ }
+ }
+
+ protected clearLegacyAuxiliaryPolicy(projectId: string): void {
+ this.legacyAuxiliaryPolicies.delete(projectId);
+ }
+
+ // QNBS-v3: quarantine can persist only the verified route, never ambiguous fallback contents, for later recovery.
+ protected legacyAuxiliaryPolicyForProject(projectId: string): {
+ legacyProjectId: string;
+ codex: boolean;
+ binderAssetIds: readonly string[];
+ } | null {
+ const policy = this.policyFor(projectId);
+ if (!policy) return null;
+ return {
+ legacyProjectId: policy.legacyProjectId,
+ codex: policy.codex,
+ binderAssetIds: [...policy.binderAssetIds],
+ };
+ }
+
+ // QNBS-v3: claiming a real project directory invalidates legacy routes targeting that directory before they can redirect another project into it.
+ protected clearLegacyPoliciesTargetingProject(projectId: string): void {
+ const safeProjectId = sanitizePathSegment(projectId, '');
+ if (!safeProjectId || safeProjectId === '.' || safeProjectId === '..') return;
+ for (const [policyProjectId, policy] of this.legacyAuxiliaryPolicies) {
+ if (policy.legacyProjectId === safeProjectId) {
+ this.legacyAuxiliaryPolicies.delete(policyProjectId);
+ }
+ }
+ }
+
+ private policyFor(projectId: string): LegacyAuxiliaryPolicy | undefined {
+ const safeProjectId = sanitizePathSegment(projectId, '');
+ if (!safeProjectId || safeProjectId === '.' || safeProjectId === '..') return undefined;
+ return this.legacyAuxiliaryPolicies.get(safeProjectId);
+ }
+
+ protected resolveAuxiliaryProjectId(
+ projectId: string,
+ kind: 'binder' | 'codex',
+ assetId?: string,
+ ): string {
+ const policy = this.policyFor(projectId);
+ if (!policy) return projectId;
+ if (kind === 'codex' && policy.codex) return policy.legacyProjectId;
+ if (kind === 'binder' && assetId && policy.binderAssetIds.has(assetId)) {
+ return policy.legacyProjectId;
+ }
+ return projectId;
+ }
+
+ protected legacyBinderProjectId(projectId: string): string | null {
+ const policy = this.policyFor(projectId);
+ return policy && policy.binderAssetIds.size > 0 ? policy.legacyProjectId : null;
+ }
+
+ protected legacyCodexProjectId(projectId: string): string | null {
+ const policy = this.policyFor(projectId);
+ return policy?.codex ? policy.legacyProjectId : null;
+ }
+
+ protected legacyBinderAssetIdsForProject(projectId: string): readonly string[] {
+ return [...(this.policyFor(projectId)?.binderAssetIds ?? [])];
+ }
}
diff --git a/services/fs/legacyProjectIdentity.ts b/services/fs/legacyProjectIdentity.ts
new file mode 100644
index 000000000..6a1c49466
--- /dev/null
+++ b/services/fs/legacyProjectIdentity.ts
@@ -0,0 +1,172 @@
+import type { StoryProject } from '../../types';
+import type { SnapshotRestoreTarget } from '../storageBackend';
+import { sanitizePathSegment } from './fsCore';
+
+// QNBS-v3: pure identity and metadata codecs stay separate so recovery orchestration remains auditable.
+// QNBS-v3: one sanitizer and empty-ID policy keeps every filesystem project operation on the same path identity.
+export function projectPathSegment(projectId: string): string | null {
+ const safeProjectId = sanitizePathSegment(projectId, '');
+ return safeProjectId && safeProjectId !== '.' && safeProjectId !== '..' ? safeProjectId : null;
+}
+
+export function persistedProjectId(project: StoryProject): unknown {
+ return (project as unknown as Record)['id'];
+}
+
+export const LEGACY_PROJECT_DIRECTORY_METADATA_KEY = '__worldscriptLegacyProjectDirectory';
+export const LEGACY_AUXILIARY_METADATA_KEY = '__worldscriptLegacyAuxiliary';
+
+// QNBS-v3: legacy snapshot restoration requires content evidence so an invalid ID cannot claim another project's directory.
+export function legacyProjectContent(project: StoryProject): string {
+ const value = { ...(project as unknown as Record) };
+ delete value['id'];
+ delete value[LEGACY_AUXILIARY_METADATA_KEY];
+ delete value[LEGACY_PROJECT_DIRECTORY_METADATA_KEY];
+ return JSON.stringify(value) ?? '';
+}
+
+export function legacyProjectDirectory(project: StoryProject): string | null {
+ const value = (project as unknown as Record)[
+ LEGACY_PROJECT_DIRECTORY_METADATA_KEY
+ ];
+ return typeof value === 'string' && projectPathSegment(value) === value ? value : null;
+}
+
+export function snapshotRestoreTargetDirectory(project: SnapshotRestoreTarget): string | null {
+ const rawProjectId = (project as unknown as Record)['id'];
+ if (rawProjectId !== undefined) {
+ return typeof rawProjectId === 'string' ? projectPathSegment(rawProjectId) : null;
+ }
+ return legacyProjectDirectory(project as StoryProject);
+}
+
+export function hasLegacyMissingProjectId(project: StoryProject, safeProjectId: string): boolean {
+ return (
+ typeof persistedProjectId(project) !== 'string' &&
+ safeProjectId === (projectPathSegment(project.title) ?? 'project')
+ );
+}
+
+export function isLegacyInvalidProjectId(
+ project: StoryProject,
+): project is StoryProject & { id: string } {
+ const rawProjectId = persistedProjectId(project);
+ return typeof rawProjectId === 'string' && !projectPathSegment(rawProjectId);
+}
+
+export function legacyBinderAssetIds(project: StoryProject): string[] {
+ return (project.binderNodes ?? [])
+ .map((node) => node.binderAssetId)
+ .filter((assetId): assetId is string => typeof assetId === 'string')
+ .map((assetId) => sanitizePathSegment(assetId, 'asset'));
+}
+
+// QNBS-v3: Binder IDs become suffixed filenames, so dot segments remain safe here while project directory dots stay rejected.
+export function persistedBinderAssetId(assetId: unknown): assetId is string {
+ return (
+ typeof assetId === 'string' &&
+ assetId.length > 0 &&
+ sanitizePathSegment(assetId, 'asset') === assetId
+ );
+}
+
+export type LegacyAuxiliaryEvidence = {
+ codex: boolean;
+ binderAssetIds: Set;
+ inspectionComplete: boolean;
+};
+
+export type PersistedLegacyAuxiliaryMetadata = {
+ legacyProjectId: 'project';
+ legacyRawProjectId: string;
+ codex: boolean;
+ binderAssetIds: string[];
+};
+
+export type QuarantineLegacyAuxiliaryManifest = {
+ projectId: string;
+ legacyProjectId: string;
+ codex: boolean;
+ binderAssetIds: string[];
+};
+
+export function persistedLegacyAuxiliaryMetadata(
+ project: StoryProject,
+): PersistedLegacyAuxiliaryMetadata | null {
+ const value = (project as unknown as Record)[LEGACY_AUXILIARY_METADATA_KEY];
+ if (typeof value !== 'object' || value === null) return null;
+ const candidate = value as Record;
+ const rawProjectId = candidate['legacyRawProjectId'];
+ const binderAssetIds = candidate['binderAssetIds'];
+ if (
+ candidate['legacyProjectId'] !== 'project' ||
+ typeof rawProjectId !== 'string' ||
+ !rawProjectId ||
+ projectPathSegment(rawProjectId) ||
+ typeof candidate['codex'] !== 'boolean' ||
+ !Array.isArray(binderAssetIds) ||
+ binderAssetIds.some((assetId) => !persistedBinderAssetId(assetId)) ||
+ (!candidate['codex'] && binderAssetIds.length === 0)
+ ) {
+ return null;
+ }
+ return {
+ legacyProjectId: 'project',
+ legacyRawProjectId: rawProjectId,
+ codex: candidate['codex'],
+ binderAssetIds: [...binderAssetIds] as string[],
+ };
+}
+
+export function persistedMetadataFromEvidence(
+ rawProjectId: string,
+ evidence: LegacyAuxiliaryEvidence,
+): PersistedLegacyAuxiliaryMetadata | null {
+ if (!evidence.inspectionComplete || (!evidence.codex && evidence.binderAssetIds.size === 0)) {
+ return null;
+ }
+ return {
+ legacyProjectId: 'project',
+ legacyRawProjectId: rawProjectId,
+ codex: evidence.codex,
+ binderAssetIds: [...evidence.binderAssetIds],
+ };
+}
+
+export function evidenceFromPersistedMetadata(
+ metadata: PersistedLegacyAuxiliaryMetadata,
+): LegacyAuxiliaryEvidence {
+ return {
+ codex: metadata.codex,
+ binderAssetIds: new Set(metadata.binderAssetIds),
+ inspectionComplete: true,
+ };
+}
+
+export function migratedProjectIdentity(
+ project: StoryProject,
+ safeProjectId: string,
+ rawProjectId?: string,
+ evidence?: LegacyAuxiliaryEvidence,
+): StoryProject {
+ const metadata =
+ rawProjectId && evidence ? persistedMetadataFromEvidence(rawProjectId, evidence) : null;
+ // QNBS-v3: legacy fallback directories carry verified auxiliary provenance across restart, while new invalid IDs remain rejected.
+ return {
+ ...project,
+ id: safeProjectId,
+ ...(metadata ? { [LEGACY_AUXILIARY_METADATA_KEY]: metadata } : {}),
+ } as StoryProject;
+}
+
+// QNBS-v3: missing-ID legacy projects retain their loaded directory while callers keep historical auxiliary fallbacks.
+export function legacyProjectWithDirectory(
+ project: StoryProject,
+ safeProjectId: string,
+): StoryProject {
+ if (legacyProjectDirectory(project) === safeProjectId) return project;
+ return {
+ ...project,
+ [LEGACY_PROJECT_DIRECTORY_METADATA_KEY]: safeProjectId,
+ } as StoryProject;
+}
diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts
index 10fe992cd..27264af05 100644
--- a/services/fs/projectFsStore.ts
+++ b/services/fs/projectFsStore.ts
@@ -10,27 +10,115 @@ import type { Character, StoryProject, World } from '../../types';
import { getStaticTranslation } from '../i18n/staticTranslate';
import { logger } from '../logger';
import { parseImportedProjectJson } from '../projectImportSchema';
-import { normalizeSaveProjectInputToStoryProject, type SaveProjectInput } from '../storageBackend';
+import {
+ normalizeSaveProjectInputToStoryProject,
+ type ProjectQuarantineResult,
+ type SaveProjectInput,
+ type SnapshotRestoreTarget,
+} from '../storageBackend';
import { FsAssetStore } from './assetFsStore';
import {
compressData,
decompressData,
retryFs,
sanitizePathSegment,
+ type TauriApis,
writeTextFileAtomic,
} from './fsCore';
+import {
+ evidenceFromPersistedMetadata,
+ hasLegacyMissingProjectId,
+ isLegacyInvalidProjectId,
+ LEGACY_AUXILIARY_METADATA_KEY,
+ LEGACY_PROJECT_DIRECTORY_METADATA_KEY,
+ type LegacyAuxiliaryEvidence,
+ legacyBinderAssetIds,
+ legacyProjectContent,
+ legacyProjectDirectory,
+ legacyProjectWithDirectory,
+ migratedProjectIdentity,
+ type PersistedLegacyAuxiliaryMetadata,
+ persistedLegacyAuxiliaryMetadata,
+ persistedMetadataFromEvidence,
+ persistedProjectId,
+ projectPathSegment,
+ type QuarantineLegacyAuxiliaryManifest,
+ snapshotRestoreTargetDirectory,
+} from './legacyProjectIdentity';
// QNBS-v3 (DA-01): distinguishes corrupt/unreadable saved data from genuine absence — callers must never treat this the same as "no project exists yet".
export class ProjectLoadError extends Error {
constructor(
public readonly reason: 'corrupt' | 'io-error',
message: string,
+ public readonly projectId: string,
) {
super(message);
this.name = 'ProjectLoadError';
}
}
+// QNBS-v3: a stable deletion outcome keeps incomplete legacy cleanup retryable without exposing filesystem details.
+export class ProjectDeleteError extends Error {
+ constructor(
+ public readonly reason:
+ | 'cleanup-incomplete'
+ | 'identity-inspection-failed' = 'cleanup-incomplete',
+ ) {
+ super(
+ reason === 'identity-inspection-failed'
+ ? 'Project deletion was not completed because its stored identity could not be safely verified.'
+ : 'Project deletion was not completed because legacy auxiliary cleanup is incomplete; project data remains available for retry.',
+ );
+ this.name = 'ProjectDeleteError';
+ }
+}
+
+export class ProjectQuarantineError extends Error {
+ constructor(
+ public readonly reason:
+ | 'not-found'
+ | 'io-error'
+ | 'name-exhausted'
+ | 'already-preserved'
+ | 'source-missing',
+ ) {
+ super(
+ reason === 'source-missing'
+ ? 'The project source is no longer present, but its preservation location could not be confirmed.'
+ : 'Project preservation failed. The original project was not deleted.',
+ );
+ this.name = 'ProjectQuarantineError';
+ }
+}
+
+export class ProjectSnapshotRestoreError extends Error {
+ constructor(
+ public readonly reason:
+ | 'target-unavailable'
+ | 'target-mismatch'
+ | 'snapshot-unavailable'
+ | 'snapshot-invalid'
+ | 'snapshot-owner-mismatch'
+ | 'snapshot-owner-unverifiable',
+ ) {
+ super(
+ reason === 'target-mismatch'
+ ? 'Snapshot restoration was not completed because the active project changed.'
+ : reason === 'snapshot-invalid'
+ ? 'Snapshot restoration was not completed because its contents are invalid.'
+ : reason === 'snapshot-unavailable'
+ ? 'Snapshot restoration was not completed because the snapshot could not be read.'
+ : reason === 'snapshot-owner-mismatch'
+ ? 'Snapshot restoration was not completed because it belongs to a different project.'
+ : reason === 'snapshot-owner-unverifiable'
+ ? 'Snapshot restoration was not completed because its project ownership could not be verified.'
+ : 'Snapshot restoration was not completed because the current project target could not be safely verified.',
+ );
+ this.name = 'ProjectSnapshotRestoreError';
+ }
+}
+
// QNBS-v3 (CodeAnt/CodeRabbit): array-or-EntityState — characters/worlds may be either shape in a real saved project.
function isArrayOrEntityState(value: unknown): boolean {
if (Array.isArray(value)) return true;
@@ -55,23 +143,375 @@ function looksLikeStoryProject(value: unknown): value is StoryProject {
);
}
+// QNBS-v3: one sanitizer and empty-ID policy keeps every filesystem project operation on the same path identity.
export class FsProjectStore extends FsAssetStore {
+ private readonly verifiedLegacyProjectDirectories = new Set();
+
+ private async inspectLegacyAuxiliaryEvidence(
+ project: StoryProject,
+ safeProjectId: string,
+ apis: TauriApis,
+ appDataPath: string,
+ ): Promise {
+ const evidence: LegacyAuxiliaryEvidence = {
+ codex: false,
+ binderAssetIds: new Set(),
+ inspectionComplete: true,
+ };
+ const rawProjectId = persistedProjectId(project);
+ if (
+ !isLegacyInvalidProjectId(project) ||
+ safeProjectId === 'project' ||
+ typeof rawProjectId !== 'string'
+ ) {
+ return evidence;
+ }
+
+ try {
+ const legacyProjectPath = await apis.join(appDataPath, 'projects', 'project');
+ const legacyProjectFile = await apis.join(legacyProjectPath, 'project.json');
+ if (await apis.exists(legacyProjectFile)) return evidence;
+
+ const codexFile = await apis.join(legacyProjectPath, 'codex', 'codex.snap');
+ if (await apis.exists(codexFile)) {
+ try {
+ const legacyCodex = decompressData(await apis.readTextFile(codexFile));
+ if (
+ typeof legacyCodex === 'object' &&
+ legacyCodex !== null &&
+ ((legacyCodex as Record)['projectId'] === rawProjectId ||
+ (legacyCodex as Record)['projectId'] === safeProjectId)
+ ) {
+ evidence.codex = true;
+ }
+ } catch (error) {
+ evidence.inspectionComplete = false;
+ logger.warn('Could not verify legacy codex ownership during project load', {
+ projectId: safeProjectId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+
+ const binderPath = await apis.join(legacyProjectPath, 'binder');
+ for (const assetId of new Set(legacyBinderAssetIds(project))) {
+ const binFile = await apis.join(binderPath, `${assetId}.bin`);
+ const metaFile = await apis.join(binderPath, `${assetId}.meta.json`);
+ if ((await apis.exists(binFile)) && (await apis.exists(metaFile))) {
+ evidence.binderAssetIds.add(assetId);
+ }
+ }
+ } catch (error) {
+ evidence.inspectionComplete = false;
+ logger.warn('Could not inspect legacy auxiliary project data during load', {
+ projectId: safeProjectId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ return evidence;
+ }
+
+ private async legacyFallbackProjectState(
+ safeProjectId: string,
+ apis: TauriApis,
+ appDataPath: string,
+ ): Promise<'confirmed' | 'absent' | 'indeterminate'> {
+ if (safeProjectId === 'project') return 'confirmed';
+ try {
+ const legacyProjectFile = await apis.join(appDataPath, 'projects', 'project', 'project.json');
+ return (await apis.exists(legacyProjectFile)) ? 'confirmed' : 'absent';
+ } catch (error) {
+ logger.warn('Could not validate persisted legacy auxiliary provenance', {
+ projectId: safeProjectId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return 'indeterminate';
+ }
+ }
+
+ private async migrateLegacyProjectIdentity(
+ project: StoryProject,
+ safeProjectId: string,
+ apis: TauriApis,
+ appDataPath: string,
+ ): Promise {
+ const persistedMetadata = persistedLegacyAuxiliaryMetadata(project);
+ const currentProjectId = persistedProjectId(project);
+ const hasLegacyInvalidId =
+ typeof currentProjectId === 'string' && !projectPathSegment(currentProjectId);
+ if (
+ persistedMetadata &&
+ currentProjectId !== safeProjectId &&
+ currentProjectId !== persistedMetadata.legacyRawProjectId
+ ) {
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ } else if (persistedMetadata) {
+ const legacyProjectState = await this.legacyFallbackProjectState(
+ safeProjectId,
+ apis,
+ appDataPath,
+ );
+ if (legacyProjectState === 'confirmed') {
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ } else if (legacyProjectState === 'absent') {
+ this.registerLegacyAuxiliaryPolicy(
+ safeProjectId,
+ persistedMetadata.legacyProjectId,
+ evidenceFromPersistedMetadata(persistedMetadata),
+ );
+ } else {
+ throw new ProjectLoadError(
+ 'io-error',
+ 'Could not validate legacy auxiliary project ownership while loading this project.',
+ safeProjectId,
+ );
+ }
+ } else if (!hasLegacyInvalidId) {
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ }
+ const rawProjectId = persistedProjectId(project);
+ if (typeof rawProjectId === 'string' && projectPathSegment(rawProjectId)) {
+ if (!persistedMetadata) this.clearLegacyPoliciesTargetingProject(safeProjectId);
+ return project;
+ }
+
+ if (typeof rawProjectId !== 'string') {
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ if (legacyProjectDirectory(project) === safeProjectId) {
+ this.verifiedLegacyProjectDirectories.add(safeProjectId);
+ return project;
+ }
+ if (!hasLegacyMissingProjectId(project, safeProjectId)) return project;
+ this.verifiedLegacyProjectDirectories.add(safeProjectId);
+ return legacyProjectWithDirectory(project, safeProjectId);
+ }
+
+ if (!projectPathSegment(rawProjectId)) {
+ const evidence = await this.inspectLegacyAuxiliaryEvidence(
+ project,
+ safeProjectId,
+ apis,
+ appDataPath,
+ );
+ if (!evidence.inspectionComplete) {
+ throw new ProjectLoadError(
+ 'io-error',
+ 'Could not verify legacy auxiliary project data while loading this project.',
+ safeProjectId,
+ );
+ }
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ this.registerLegacyAuxiliaryPolicy(safeProjectId, 'project', evidence);
+ return migratedProjectIdentity(project, safeProjectId, rawProjectId, evidence);
+ }
+ if (hasLegacyInvalidId) {
+ return project;
+ }
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ return migratedProjectIdentity(project, safeProjectId);
+ }
+
+ private async resolveLegacySaveIdentity(
+ project: StoryProject,
+ rawProjectId: string,
+ apis: TauriApis,
+ appDataPath: string,
+ ): Promise<{
+ projectId: string;
+ metadata: PersistedLegacyAuxiliaryMetadata | null;
+ inspectionComplete: boolean;
+ } | null> {
+ if (!rawProjectId.trim()) return null;
+ const legacyProjectId = sanitizePathSegment(rawProjectId, 'item');
+ if (!legacyProjectId || legacyProjectId === '.' || legacyProjectId === '..') return null;
+ const projectFile = await apis.join(appDataPath, 'projects', legacyProjectId, 'project.json');
+ if (!(await apis.exists(projectFile))) return null;
+
+ let existingProject: StoryProject;
+ try {
+ const parsed = decompressData(await retryFs(() => apis.readTextFile(projectFile)));
+ if (!looksLikeStoryProject(parsed)) return null;
+ existingProject = parsed;
+ } catch {
+ return null;
+ }
+ const existingMetadata = persistedLegacyAuxiliaryMetadata(existingProject);
+ const existingRawProjectId = persistedProjectId(existingProject);
+ if (
+ existingRawProjectId !== rawProjectId &&
+ existingMetadata?.legacyRawProjectId !== rawProjectId
+ ) {
+ return null;
+ }
+ if (legacyProjectContent(existingProject) !== legacyProjectContent(project)) return null;
+
+ const evidence = existingMetadata
+ ? evidenceFromPersistedMetadata(existingMetadata)
+ : await this.inspectLegacyAuxiliaryEvidence(
+ {
+ ...existingProject,
+ binderNodes: [...(existingProject.binderNodes ?? []), ...(project.binderNodes ?? [])],
+ },
+ legacyProjectId,
+ apis,
+ appDataPath,
+ );
+ return {
+ projectId: legacyProjectId,
+ metadata: existingMetadata ?? persistedMetadataFromEvidence(rawProjectId, evidence),
+ inspectionComplete: existingMetadata ? true : evidence.inspectionComplete,
+ };
+ }
+
+ async restoreSnapshot(
+ snapshotId: number,
+ currentProject: SnapshotRestoreTarget,
+ ): Promise {
+ // QNBS-v3: serialize target validation and snapshot ownership checks so routing cannot change mid-restore.
+ return this.withLegacyRoutingOperation(() =>
+ this.restoreSnapshotUnlocked(snapshotId, currentProject),
+ );
+ }
+
+ private async restoreSnapshotUnlocked(
+ snapshotId: number,
+ currentProject: SnapshotRestoreTarget,
+ ): Promise {
+ const targetDirectory = snapshotRestoreTargetDirectory(currentProject);
+ if (!targetDirectory) {
+ throw new ProjectSnapshotRestoreError('target-unavailable');
+ }
+
+ let validatedTarget: StoryProject | null;
+ try {
+ validatedTarget = await this.loadProjectUnlocked(targetDirectory);
+ } catch (error) {
+ logger.error('Failed to validate the snapshot restore target', {
+ projectId: targetDirectory,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new ProjectSnapshotRestoreError('target-unavailable');
+ }
+ if (!validatedTarget) {
+ throw new ProjectSnapshotRestoreError('target-unavailable');
+ }
+
+ const snapshot = await super.getSnapshotData(snapshotId);
+ if (!looksLikeStoryProject(snapshot)) {
+ throw new ProjectSnapshotRestoreError(
+ snapshot === null ? 'snapshot-unavailable' : 'snapshot-invalid',
+ );
+ }
+
+ const snapshotProjectId = persistedProjectId(snapshot);
+ if (typeof snapshotProjectId !== 'string') {
+ throw new ProjectSnapshotRestoreError('snapshot-owner-unverifiable');
+ }
+ const safeSnapshotProjectId = projectPathSegment(snapshotProjectId);
+ if (!safeSnapshotProjectId) {
+ throw new ProjectSnapshotRestoreError('snapshot-owner-unverifiable');
+ }
+ if (safeSnapshotProjectId !== targetDirectory) {
+ throw new ProjectSnapshotRestoreError('snapshot-owner-mismatch');
+ }
+
+ const restored = { ...(snapshot as unknown as Record) };
+ delete restored['id'];
+ delete restored[LEGACY_PROJECT_DIRECTORY_METADATA_KEY];
+ delete restored[LEGACY_AUXILIARY_METADATA_KEY];
+
+ const validatedTargetId = persistedProjectId(validatedTarget);
+ if (typeof validatedTargetId === 'string') {
+ const safeTargetId = projectPathSegment(validatedTargetId);
+ if (!safeTargetId || safeTargetId !== targetDirectory) {
+ throw new ProjectSnapshotRestoreError('target-unavailable');
+ }
+ restored['id'] = safeTargetId;
+ }
+
+ const validatedTargetDirectory = legacyProjectDirectory(validatedTarget);
+ if (validatedTargetDirectory) {
+ restored[LEGACY_PROJECT_DIRECTORY_METADATA_KEY] = validatedTargetDirectory;
+ }
+ const validatedTargetMetadata = persistedLegacyAuxiliaryMetadata(validatedTarget);
+ if (validatedTargetMetadata) {
+ restored[LEGACY_AUXILIARY_METADATA_KEY] = validatedTargetMetadata;
+ }
+
+ return restored as unknown as StoryProject;
+ }
+
async saveProject(project: SaveProjectInput): Promise {
+ return this.withLegacyRoutingOperation(() => this.saveProjectUnlocked(project));
+ }
+
+ private async saveProjectUnlocked(project: SaveProjectInput): Promise {
const flat = normalizeSaveProjectInputToStoryProject(project);
+ const rawProjectId = (flat as unknown as Record)['id'];
+ const suppliedProjectId = typeof rawProjectId === 'string';
+ let projectId: string;
+ let projectToPersist = flat;
+ const apis = await this.getApis();
+ const appDataPath = await this.ensureAppDataPath();
+ if (suppliedProjectId) {
+ const safeProjectId = projectPathSegment(rawProjectId);
+ if (!safeProjectId) {
+ const legacyIdentity = await this.resolveLegacySaveIdentity(
+ flat,
+ rawProjectId,
+ apis,
+ appDataPath,
+ );
+ if (!legacyIdentity) {
+ throw new Error('Cannot save a project with an unusable project ID.');
+ }
+ if (!legacyIdentity.inspectionComplete) {
+ throw new Error(
+ 'Cannot safely save this legacy project until its auxiliary data can be verified.',
+ );
+ }
+ projectId = legacyIdentity.projectId;
+ projectToPersist = {
+ ...flat,
+ id: projectId,
+ ...(legacyIdentity.metadata
+ ? { [LEGACY_AUXILIARY_METADATA_KEY]: legacyIdentity.metadata }
+ : {}),
+ } as StoryProject;
+ if (legacyIdentity.metadata) {
+ this.registerLegacyAuxiliaryPolicy(
+ projectId,
+ legacyIdentity.metadata.legacyProjectId,
+ evidenceFromPersistedMetadata(legacyIdentity.metadata),
+ );
+ }
+ } else {
+ projectId = safeProjectId;
+ this.verifiedLegacyProjectDirectories.delete(projectId);
+ }
+ } else {
+ const legacyDirectory = legacyProjectDirectory(flat);
+ projectId =
+ legacyDirectory && this.verifiedLegacyProjectDirectories.has(legacyDirectory)
+ ? legacyDirectory
+ : (projectPathSegment(flat.title || '') ?? 'project');
+ }
// Auto-snapshot: fire-and-forget, mirrors dbService behaviour
if (Date.now() - this.lastAutoSnapshotTime > this.AUTO_SNAPSHOT_INTERVAL) {
this.lastAutoSnapshotTime = Date.now();
- this.saveSnapshot('auto', flat)
+ this.saveSnapshot('auto', projectToPersist)
.then(() => this.pruneAutoSnapshots())
- .catch(() => {});
+ .catch((error) => {
+ // QNBS-v3: auto-snapshot failure stays non-fatal while remaining visible for recovery diagnostics.
+ logger.warn('Auto-snapshot failed (project save itself is unaffected)', {
+ projectId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ });
}
- const apis = await this.getApis();
- const appDataPath = await this.ensureAppDataPath();
- const projectId = sanitizePathSegment(
- ((flat as unknown as Record)['id'] as string) || flat.title || 'project',
- );
const projectPath = await apis.join(appDataPath, 'projects', projectId);
if (!(await apis.exists(projectPath))) {
@@ -79,7 +519,8 @@ export class FsProjectStore extends FsAssetStore {
}
const projectFile = await apis.join(projectPath, 'project.json');
- await writeTextFileAtomic(apis, projectFile, compressData(flat));
+ await writeTextFileAtomic(apis, projectFile, compressData(projectToPersist));
+ this.clearLegacyPoliciesTargetingProject(projectId);
// QNBS-v3 (#332): documented best-effort abort — the project data above already saved; a failed marker write only degrades the next cold-boot's project selection, not worth failing this save over.
await this.setActiveProjectId(projectId).catch((error) => {
logger.warn('Failed to persist active-project marker (project save itself succeeded)', {
@@ -125,15 +566,21 @@ export class FsProjectStore extends FsAssetStore {
* collapse into the same `null` a caller would read as "no project exists yet".
*/
async loadProject(projectId: string): Promise {
+ return this.withLegacyRoutingOperation(() => this.loadProjectUnlocked(projectId));
+ }
+
+ private async loadProjectUnlocked(projectId: string): Promise {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
- const safeProjectId = sanitizePathSegment(projectId);
+ const safeProjectId = projectPathSegment(projectId);
+ if (!safeProjectId) return null;
const projectFile = await apis.join(appDataPath, 'projects', safeProjectId, 'project.json');
// QNBS-v3 (CodeRabbit/codex): exists() rejecting is an I/O failure too, not absence — classify it the same as a readTextFile failure rather than letting it escape raw.
let content: string;
try {
if (!(await apis.exists(projectFile))) {
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
return null;
}
content = await retryFs(() => apis.readTextFile(projectFile));
@@ -142,6 +589,7 @@ export class FsProjectStore extends FsAssetStore {
throw new ProjectLoadError(
'io-error',
`Could not read the project file for "${projectId}" — it may be locked, permission-denied, or otherwise inaccessible.`,
+ projectId,
);
}
@@ -157,12 +605,19 @@ export class FsProjectStore extends FsAssetStore {
throw new ProjectLoadError(
'corrupt',
`The saved project file for "${projectId}" appears to be corrupted and could not be read. The file has not been deleted.`,
+ projectId,
);
}
// QNBS-v3: schedule observation after this async load resolves so validation cannot delay or alter the load result.
- scheduleCoreProjectValidation(project);
- return project;
+ const migratedProject = await this.migrateLegacyProjectIdentity(
+ project,
+ safeProjectId,
+ apis,
+ appDataPath,
+ );
+ scheduleCoreProjectValidation(migratedProject);
+ return migratedProject;
}
async listProjects(): Promise {
@@ -183,14 +638,233 @@ export class FsProjectStore extends FsAssetStore {
}
}
+ // QNBS-v3: move the whole folder before reload so corrupt project artifacts remain recoverable.
+ /** Move the whole corrupt project directory aside so its manuscript and assets remain recoverable. */
+ private async prepareLegacyQuarantinePolicy(
+ safeProjectId: string,
+ apis: TauriApis,
+ appDataPath: string,
+ ): Promise<{
+ legacyProjectId: string;
+ codex: boolean;
+ binderAssetIds: readonly string[];
+ } | null> {
+ const policy = this.legacyAuxiliaryPolicyForProject(safeProjectId);
+ if (policy?.legacyProjectId !== 'project') return policy;
+ const legacyProjectState = await this.legacyFallbackProjectState(
+ safeProjectId,
+ apis,
+ appDataPath,
+ );
+ if (legacyProjectState === 'confirmed') {
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ return null;
+ }
+ if (legacyProjectState === 'indeterminate') throw new ProjectQuarantineError('io-error');
+ return policy;
+ }
+
+ async quarantineProject(projectId: string): Promise {
+ return this.withLegacyRoutingOperation(() => this.quarantineProjectUnlocked(projectId));
+ }
+
+ private async quarantineProjectUnlocked(projectId: string): Promise {
+ try {
+ const apis = await this.getApis();
+ const appDataPath = await this.ensureAppDataPath();
+ const safeProjectId = projectPathSegment(projectId);
+ if (!safeProjectId) throw new ProjectQuarantineError('not-found');
+ const projectPath = await apis.join(appDataPath, 'projects', safeProjectId);
+ if (!(await apis.exists(projectPath))) {
+ throw new ProjectQuarantineError('not-found');
+ }
+
+ const quarantineRoot = await apis.join(appDataPath, 'quarantined-projects');
+ await apis.mkdir(quarantineRoot, { recursive: true });
+ const timestamp = Date.now();
+ const legacyPolicy = await this.prepareLegacyQuarantinePolicy(
+ safeProjectId,
+ apis,
+ appDataPath,
+ );
+ for (let attempt = 0; attempt < 100; attempt++) {
+ const suffix = attempt === 0 ? String(timestamp) : `${timestamp}-${attempt}`;
+ const quarantinePath = await apis.join(
+ quarantineRoot,
+ `${safeProjectId}-corrupt-${suffix}`,
+ );
+ // QNBS-v3: reserve a unique directory atomically so rename cannot replace a concurrent quarantine target.
+ try {
+ await apis.mkdir(quarantinePath);
+ } catch (error) {
+ let targetExists: boolean;
+ try {
+ targetExists = await apis.exists(quarantinePath);
+ } catch (probeError) {
+ logger.error('Failed to inspect a concurrent quarantine result', {
+ projectId,
+ error: probeError instanceof Error ? probeError.message : String(probeError),
+ });
+ throw new ProjectQuarantineError('io-error');
+ }
+ if (targetExists) continue;
+ logger.error('Failed to reserve quarantine directory', {
+ projectId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new ProjectQuarantineError('io-error');
+ }
+
+ const preservedPath = await apis.join(quarantinePath, safeProjectId);
+ const releaseReservation = async (): Promise => {
+ try {
+ await apis.remove(quarantinePath, { recursive: true });
+ } catch (cleanupError) {
+ logger.warn('Failed to remove reserved quarantine directory after a failed move', {
+ projectId,
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
+ });
+ }
+ };
+ try {
+ if (legacyPolicy?.legacyProjectId === 'project') {
+ const manifestPath = await apis.join(quarantinePath, 'legacy-auxiliary.json');
+ const manifest: QuarantineLegacyAuxiliaryManifest = {
+ projectId: safeProjectId,
+ legacyProjectId: legacyPolicy.legacyProjectId,
+ codex: legacyPolicy.codex,
+ binderAssetIds: [...legacyPolicy.binderAssetIds],
+ };
+ // QNBS-v3: durable quarantine metadata preserves verified auxiliary provenance without claiming ownership of ambiguous fallback files.
+ await writeTextFileAtomic(apis, manifestPath, JSON.stringify(manifest));
+ }
+ await retryFs(() => apis.rename(projectPath, preservedPath));
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ return { projectId: safeProjectId, path: preservedPath };
+ } catch (error) {
+ let sourceExists: boolean;
+ let preservedExists: boolean;
+ try {
+ sourceExists = await apis.exists(projectPath);
+ preservedExists = await apis.exists(preservedPath);
+ } catch (probeError) {
+ logger.error('Failed to inspect a concurrent quarantine result', {
+ projectId,
+ error: probeError instanceof Error ? probeError.message : String(probeError),
+ });
+ throw new ProjectQuarantineError('io-error');
+ }
+ if (!sourceExists && preservedExists) {
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ return { projectId: safeProjectId, path: preservedPath };
+ }
+ if (!sourceExists) {
+ await releaseReservation();
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ throw new ProjectQuarantineError('source-missing');
+ }
+ await releaseReservation();
+ logger.error('Failed to quarantine project directory', {
+ projectId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new ProjectQuarantineError('io-error');
+ }
+ }
+
+ throw new ProjectQuarantineError('name-exhausted');
+ } catch (error) {
+ if (error instanceof ProjectQuarantineError) throw error;
+ logger.error('Failed to prepare project quarantine', {
+ projectId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new ProjectQuarantineError('io-error');
+ }
+ }
+
async deleteProject(projectId: string): Promise {
+ return this.withLegacyRoutingOperation(() => this.deleteProjectUnlocked(projectId));
+ }
+
+ private async deleteProjectUnlocked(projectId: string): Promise {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
- const safeProjectId = sanitizePathSegment(projectId);
+ const safeProjectId = projectPathSegment(projectId);
+ if (!safeProjectId) return;
const projectPath = await apis.join(appDataPath, 'projects', safeProjectId);
- if (await apis.exists(projectPath)) {
- await retryFs(() => apis.remove(projectPath, { recursive: true }));
+ // QNBS-v3: uncertain existence is a typed retryable deletion failure, never permission to clean up.
+ let projectExists: boolean;
+ try {
+ projectExists = await apis.exists(projectPath);
+ } catch (error) {
+ logger.error('Failed to inspect project existence before deletion', {
+ projectId: safeProjectId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new ProjectDeleteError('identity-inspection-failed');
+ }
+ if (projectExists) {
+ await this.hydrateLegacyPolicyForDeletion(safeProjectId, projectPath, apis, appDataPath);
+ }
+
+ try {
+ const legacyBinderIds = this.legacyBinderAssetIdsForProject(safeProjectId);
+ if (legacyBinderIds.length > 0) {
+ for (const assetId of legacyBinderIds) {
+ await this.deleteBinderAssetStrict(safeProjectId, assetId);
+ }
+ }
+ if (this.legacyCodexProjectId(safeProjectId)) {
+ await this.deleteStoryCodexStrict(safeProjectId);
+ }
+ if (projectExists) await retryFs(() => apis.remove(projectPath, { recursive: true }));
+ } catch (error) {
+ logger.error('Failed to clean up legacy project data during deletion', {
+ projectId: safeProjectId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new ProjectDeleteError();
+ }
+ this.verifiedLegacyProjectDirectories.delete(safeProjectId);
+ this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ }
+
+ private async hydrateLegacyPolicyForDeletion(
+ safeProjectId: string,
+ projectPath: string,
+ apis: TauriApis,
+ appDataPath: string,
+ ): Promise {
+ if (
+ this.legacyBinderAssetIdsForProject(safeProjectId).length > 0 ||
+ this.legacyCodexProjectId(safeProjectId)
+ ) {
+ return;
+ }
+ let project: StoryProject;
+ try {
+ const projectFile = await apis.join(projectPath, 'project.json');
+ if (!(await apis.exists(projectFile))) return;
+ const parsed = decompressData(await retryFs(() => apis.readTextFile(projectFile)));
+ if (!looksLikeStoryProject(parsed)) throw new Error('Stored project is not project-shaped.');
+ project = parsed;
+ } catch (error) {
+ logger.error('Could not inspect project identity before deletion', {
+ projectId: safeProjectId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new ProjectDeleteError('identity-inspection-failed');
+ }
+ try {
+ await this.migrateLegacyProjectIdentity(project, safeProjectId, apis, appDataPath);
+ } catch (error) {
+ logger.error('Could not validate legacy auxiliary data before deletion', {
+ projectId: safeProjectId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new ProjectDeleteError('identity-inspection-failed');
}
}
diff --git a/services/storageBackend.ts b/services/storageBackend.ts
index 83eef5dfe..fa6754023 100644
--- a/services/storageBackend.ts
+++ b/services/storageBackend.ts
@@ -13,6 +13,13 @@ export interface BinderAssetPayload {
meta: BinderAssetMeta;
}
+// QNBS-v3: shared result keeps the affected identity and verified quarantine path explicit across backends.
+/** Result of moving a corrupt desktop project out of the active project namespace. */
+export interface ProjectQuarantineResult {
+ projectId: string;
+ path: string;
+}
+
/** IndexedDB / filesystem key for binder blobs — stable delimiter avoids UUID clashes. */
export function makeBinderAssetStorageKey(projectId: string, assetId: string): string {
const safeProject = projectId.replace(/[\s:]/g, '_').slice(0, 200);
@@ -39,6 +46,12 @@ export interface SaveProjectEnvelope {
*/
export type SaveProjectInput = StoryProject | SaveProjectEnvelope;
+// QNBS-v3: restore carries pre-read project identity so filesystem ownership never comes from snapshot content.
+/**
+ * Current project state captured before snapshot I/O; filesystem backends inspect only identity markers.
+ */
+export type SnapshotRestoreTarget = ProjectData | StoryProject;
+
/** Auto-save from the current `ProjectData` (listener middleware) — returns a properly typed envelope. */
export function saveEnvelopeFromProjectData(data: ProjectData): SaveProjectEnvelope {
return { data };
@@ -66,6 +79,8 @@ export interface StorageBackend {
deleteProject(projectId: string): Promise;
/** QNBS-v3 (#332): optional — only the multi-project Tauri filesystem backend implements this; IndexedDB's single-project contract has no "which one" ambiguity to resolve. */
getActiveProjectId?(): Promise;
+ // QNBS-v3: optional recovery keeps the filesystem-only quarantine contract out of IndexedDB.
+ quarantineProject?(projectId: string): Promise;
saveImage(id: string, base64Data: string): Promise;
getImage(id: string): Promise;
@@ -84,6 +99,8 @@ export interface StorageBackend {
/** Snapshot IDs: numeric (Date.now / IDB auto-increment). */
saveSnapshot(snapshotLabel: string, data: unknown): Promise;
getSnapshotData(snapshotId: number): Promise;
+ // QNBS-v3: filesystem restore validates this pre-read target; other backends retain their existing snapshot semantics.
+ restoreSnapshot?(snapshotId: number, currentProject: SnapshotRestoreTarget): Promise;
listSnapshots(): Promise;
deleteSnapshot(snapshotId: number): Promise;
diff --git a/services/storageService.ts b/services/storageService.ts
index a1e4a7432..37e52eff9 100644
--- a/services/storageService.ts
+++ b/services/storageService.ts
@@ -2,15 +2,19 @@ import type { ProjectSnapshot, Settings, StoryCodex, StoryProject } from '../typ
import type {
BinderAssetMeta,
BinderAssetPayload,
+ ProjectQuarantineResult,
SaveProjectInput,
+ SnapshotRestoreTarget,
StorageBackend,
} from './storageBackend';
export type {
BinderAssetMeta,
BinderAssetPayload,
+ ProjectQuarantineResult,
SaveProjectEnvelope,
SaveProjectInput,
+ SnapshotRestoreTarget,
StorageBackend,
} from './storageBackend';
export {
@@ -26,6 +30,8 @@ import { fileSystemService } from './fileSystemService';
import { logger } from './logger';
import { isTauriRuntime } from './tauriRuntime';
+// QNBS-v3: re-exporting the narrow storage contracts keeps callers on one backend-independent type boundary.
+
declare global {
interface Window {
__TAURI__?: unknown;
@@ -89,6 +95,12 @@ class StorageManager {
return (await backend.getActiveProjectId?.()) ?? null;
}
+ // QNBS-v3: delegate supported desktop quarantine and normalize unsupported backends to null.
+ async quarantineProject(projectId: string): Promise {
+ const backend = await this.getBackend();
+ return (await backend.quarantineProject?.(projectId)) ?? null;
+ }
+
async deleteProject(projectId: string): Promise {
const backend = await this.getBackend();
return backend.deleteProject(projectId);
@@ -149,6 +161,15 @@ class StorageManager {
return backend.getSnapshotData(id);
}
+ // QNBS-v3: filesystem backends receive the pre-read target while IndexedDB keeps its existing snapshot fallback.
+ async restoreSnapshot(id: number, currentProject: SnapshotRestoreTarget): Promise {
+ const backend = await this.getBackend();
+ if (backend.restoreSnapshot) {
+ return backend.restoreSnapshot(id, currentProject);
+ }
+ return backend.getSnapshotData(id);
+ }
+
async listSnapshots(): Promise {
const backend = await this.getBackend();
return backend.listSnapshots();
diff --git a/tests/unit/libraryBackupService.test.ts b/tests/unit/libraryBackupService.test.ts
index 9cafa479e..cdbfb3c88 100644
--- a/tests/unit/libraryBackupService.test.ts
+++ b/tests/unit/libraryBackupService.test.ts
@@ -97,7 +97,11 @@ describe('libraryBackupService — partial corruption (DA-01)', () => {
vi.mocked(storageService.listProjects).mockResolvedValue(['good', 'corrupt']);
vi.mocked(storageService.loadProject).mockImplementation(async (projectId: string) => {
if (projectId === 'corrupt') {
- throw new ProjectLoadError('corrupt', 'The saved project file for "corrupt" is corrupted.');
+ throw new ProjectLoadError(
+ 'corrupt',
+ 'The saved project file for "corrupt" is corrupted.',
+ 'corrupt',
+ );
}
return minimalProject() as unknown as StoryProject;
});
diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts
index a7bd98e4b..431ab524f 100644
--- a/tests/unit/services/fs/fsStores.test.ts
+++ b/tests/unit/services/fs/fsStores.test.ts
@@ -53,10 +53,12 @@ vi.mock('../../../../services/logger', async (importOriginal) => {
});
// QNBS-v3: getStaticTranslation hits the network (fetch) — never call real network in tests.
vi.mock('../../../../services/i18n/staticTranslate', () => ({
- getStaticTranslation: (key: string) => Promise.resolve(key === 'export.loglineLabel' ? 'Logline' : 'Manuscript'),
+ getStaticTranslation: (key: string) =>
+ Promise.resolve(key === 'export.loglineLabel' ? 'Logline' : 'Manuscript'),
}));
import { appStoreRef } from '../../../../app/storeRef';
+import { compressData, decompressData } from '../../../../services/fs/fsCore';
import { FsProjectStore } from '../../../../services/fs/projectFsStore';
import { logger } from '../../../../services/logger';
@@ -82,7 +84,10 @@ function makeFakeFs(): FakeFs {
join: (...parts: string[]) => Promise.resolve(parts.join('/')),
exists: (p: string) =>
Promise.resolve(text.has(p) || bin.has(p) || dirs.has(p) || under(p).length > 0),
- mkdir: (p: string) => {
+ mkdir: (p: string, options?: { recursive?: boolean }) => {
+ if (dirs.has(p) && !options?.recursive) {
+ return Promise.reject(new Error(`EEXIST ${p}`));
+ }
dirs.add(p);
return Promise.resolve();
},
@@ -110,16 +115,33 @@ function makeFakeFs(): FakeFs {
for (const k of [...bin.keys()]) if (k.startsWith(`${p}/`)) bin.delete(k);
return Promise.resolve();
},
+ // QNBS-v3: recursive fake moves preserve every project asset so quarantine tests prove full-directory recovery.
rename: (from: string, to: string) => {
- if (!text.has(from) && !bin.has(from)) return Promise.reject(new Error(`ENOENT ${from}`));
+ const fromEntries = [...text.keys(), ...bin.keys(), ...dirs].filter(
+ (path, index, paths) =>
+ paths.indexOf(path) === index && (path === from || path.startsWith(`${from}/`)),
+ );
+ if (fromEntries.length === 0) return Promise.reject(new Error(`ENOENT ${from}`));
+ const targetDirectoryExists =
+ dirs.has(to) ||
+ [...text.keys(), ...bin.keys(), ...dirs].some((path) => path.startsWith(`${to}/`));
+ if (targetDirectoryExists) return Promise.reject(new Error(`EEXIST ${to}`));
text.delete(to);
bin.delete(to);
- const textValue = text.get(from);
- const binaryValue = bin.get(from);
- if (textValue !== undefined) text.set(to, textValue);
- if (binaryValue !== undefined) bin.set(to, binaryValue);
- text.delete(from);
- bin.delete(from);
+ for (const path of fromEntries) {
+ const target = `${to}${path.slice(from.length)}`;
+ const textValue = text.get(path);
+ const binaryValue = bin.get(path);
+ if (textValue !== undefined) {
+ text.delete(path);
+ text.set(target, textValue);
+ }
+ if (binaryValue !== undefined) {
+ bin.delete(path);
+ bin.set(target, binaryValue);
+ }
+ if (dirs.delete(path)) dirs.add(target);
+ }
return Promise.resolve();
},
readDir: (p: string) => Promise.resolve(under(p).map((name) => ({ name, isDirectory: false }))),
@@ -169,6 +191,1057 @@ describe('FsProjectStore — projects', () => {
expect(await store.listProjects()).toEqual([]);
});
+ // QNBS-v3: the regression protects manuscripts and assets from partial or destructive quarantine.
+ it('quarantines a complete project directory without deleting or relisting it', async () => {
+ await store.saveProject(project as never);
+ const original = fake.text.get('/app/projects/p1/project.json');
+
+ const result = await store.quarantineProject('p1');
+
+ expect(result.projectId).toBe('p1');
+ expect(result.path).toMatch(/^\/app\/quarantined-projects\/p1-corrupt-/);
+ expect(fake.text.get(`${result.path}/project.json`)).toBe(original);
+ expect(fake.text.has('/app/projects/p1/project.json')).toBe(false);
+ expect(await store.listProjects()).not.toContain('p1');
+ await expect(store.loadProject('p1')).resolves.toBeNull();
+ });
+
+ // QNBS-v3: prove a claimed quarantine target cannot turn preserve-first recovery into data loss.
+ it('tries the next quarantine name when a concurrent rename claims the checked target', async () => {
+ await store.saveProject(project as never);
+ const originalMkdir = fake.apis.mkdir;
+ let firstTarget: string | undefined;
+ let racePending = true;
+ fake.apis.mkdir = (path: string, options?: { recursive?: boolean }) => {
+ if (racePending && path.startsWith('/app/quarantined-projects/p1-corrupt-')) {
+ racePending = false;
+ firstTarget = path;
+ return originalMkdir(path, options).then(() => Promise.reject(new Error(`EEXIST ${path}`)));
+ }
+ return originalMkdir(path, options);
+ };
+
+ const result = await store.quarantineProject('p1');
+
+ expect(firstTarget).toBeDefined();
+ expect(result.path).toBe(`${firstTarget}-1/p1`);
+ expect(fake.text.get(`${result.path}/project.json`)).toBeDefined();
+ expect(fake.text.has('/app/projects/p1/project.json')).toBe(false);
+ });
+
+ // QNBS-v3: report an unidentifiable concurrent move without claiming a path that was not observed.
+ it('reports source-missing when another recovery moved the source elsewhere', async () => {
+ await store.saveProject(project as never);
+ const original = fake.text.get('/app/projects/p1/project.json');
+ const originalRename = fake.apis.rename;
+ fake.apis.rename = async (from: string) => {
+ const concurrentPath = '/app/quarantined-projects/p1-corrupt-concurrent';
+ await originalRename(from, concurrentPath);
+ throw new Error(`ENOENT ${from}`);
+ };
+
+ await expect(store.quarantineProject('p1')).rejects.toMatchObject({
+ name: 'ProjectQuarantineError',
+ reason: 'source-missing',
+ });
+ expect(await store.listProjects()).not.toContain('p1');
+ expect(fake.text.get('/app/quarantined-projects/p1-corrupt-concurrent/project.json')).toBe(
+ original,
+ );
+ });
+
+ // QNBS-v3: a vanished source without a verified destination is not evidence of preservation.
+ it('reports source-missing when the source disappears without a quarantine copy', async () => {
+ await store.saveProject(project as never);
+ fake.apis.rename = async (from: string) => {
+ await fake.apis.remove(from);
+ throw new Error(`ENOENT ${from}`);
+ };
+
+ await expect(store.quarantineProject('p1')).rejects.toMatchObject({
+ name: 'ProjectQuarantineError',
+ reason: 'source-missing',
+ message:
+ 'The project source is no longer present, but its preservation location could not be confirmed.',
+ });
+ });
+
+ // QNBS-v3: quarantine retains verified legacy routing without moving ambiguous fallback data into the recovery copy.
+ it('persists verified legacy routing beside a quarantined project', async () => {
+ const legacyProject = { ...project, id: '***' };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+
+ await store.loadProject('item');
+ const result = await store.quarantineProject('item');
+ const quarantineContainer = result.path.slice(0, result.path.lastIndexOf('/'));
+
+ expect(fake.text.get(`${quarantineContainer}/legacy-auxiliary.json`)).toBe(
+ JSON.stringify({
+ projectId: 'item',
+ legacyProjectId: 'project',
+ codex: true,
+ binderAssetIds: [],
+ }),
+ );
+ expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(true);
+ expect(fake.text.has(`${result.path}/project.json`)).toBe(true);
+ expect(await store.getStoryCodex('item')).toBeNull();
+ });
+
+ it('does not map an unusable project ID to an arbitrary quarantine directory', async () => {
+ await expect(store.saveProject({ ...project, id: '***' } as never)).rejects.toThrow(
+ 'Cannot save a project with an unusable project ID.',
+ );
+
+ await expect(store.loadProject('***')).resolves.toBeNull();
+ await expect(store.deleteProject('***')).resolves.toBeUndefined();
+ await expect(store.quarantineProject('***')).rejects.toMatchObject({
+ name: 'ProjectQuarantineError',
+ reason: 'not-found',
+ });
+ expect([...fake.text.keys()].some((path) => path.startsWith('/app/projects/'))).toBe(false);
+ });
+
+ it('migrates a legacy invalid ID to its existing directory identity before save', async () => {
+ const legacyProject = { ...project, id: '***' };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+
+ const loaded = await store.loadProject('item');
+
+ expect((loaded as unknown as Record)['id']).toBe('item');
+ await expect(store.saveProject(loaded as never)).resolves.toBeUndefined();
+ expect(fake.text.has('/app/projects/item/project.json')).toBe(true);
+ expect(await store.loadProject('item')).toEqual(expect.objectContaining({ title: 'My Novel' }));
+ });
+
+ // QNBS-v3: verified Codex and Binder evidence remains addressable while provenance-free vectors stay unassigned.
+ it('keeps verified legacy Binder and Codex data addressable without assigning ambiguous RAG data', async () => {
+ const legacyProject = {
+ ...project,
+ id: '***',
+ binderNodes: [{ binderAssetId: 'legacy-asset' }],
+ };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ const legacyVectors = [{ id: 'legacy-vector' }];
+
+ await store.saveStoryCodex(legacyCodex as never);
+ await store.saveRagVectors('***', legacyVectors);
+ await store.saveBinderAsset('***', 'legacy-asset', new Uint8Array([1, 2]).buffer, {
+ mimeType: 'application/octet-stream',
+ originalFileName: 'legacy.bin',
+ byteSize: 2,
+ });
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+
+ const loaded = await store.loadProject('item');
+
+ expect((loaded as unknown as Record)['id']).toBe('item');
+ expect(await store.getStoryCodex('item')).toEqual(legacyCodex);
+ expect(await store.getRagVectors('item')).toEqual([]);
+ expect(await store.getRagVectors('project')).toEqual(legacyVectors);
+ expect(await store.listBinderAssetIds('item')).toContain('legacy-asset');
+ expect(await store.getBinderAsset('item', 'legacy-asset')).toEqual(
+ expect.objectContaining({
+ meta: expect.objectContaining({ originalFileName: 'legacy.bin' }),
+ }),
+ );
+
+ await fake.apis.writeFile('/app/projects/project/binder/unregistered.bin', new Uint8Array([9]));
+ await fake.apis.writeTextFile(
+ '/app/projects/project/binder/unregistered.meta.json',
+ JSON.stringify({
+ mimeType: 'application/octet-stream',
+ originalFileName: 'unregistered.bin',
+ byteSize: 1,
+ }),
+ );
+ expect(await store.listBinderAssetIds('item')).not.toContain('unregistered');
+ await expect(store.getBinderAsset('item', 'unregistered')).resolves.toBeNull();
+ await store.deleteAllBinderAssetsForProject('item');
+ expect(fake.bin.has('/app/projects/project/binder/unregistered.bin')).toBe(true);
+ expect(fake.text.has('/app/projects/project/binder/unregistered.meta.json')).toBe(true);
+
+ await store.saveProject(loaded as never);
+ expect(fake.text.has('/app/projects/item/project.json')).toBe(true);
+ expect(fake.text.has('/app/projects/project/project.json')).toBe(false);
+
+ await store.deleteProject('item');
+ expect(fake.text.has('/app/projects/item/project.json')).toBe(false);
+ expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(false);
+ expect(fake.text.has('/app/projects/project/codex/vectors.snap')).toBe(true);
+ expect(fake.bin.has('/app/projects/project/binder/legacy-asset.bin')).toBe(false);
+ });
+
+ // QNBS-v3: complete route-using mutations serialize so a legitimate fallback claimant cannot change ownership mid-operation.
+ it('keeps a legacy Codex write on one route while a fallback claimant waits', async () => {
+ const legacyProject = { ...project, id: '***' };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+ await store.loadProject('item');
+
+ const originalWriteTextFile = fake.apis.writeTextFile;
+ let releaseWrite!: () => void;
+ let writeStarted!: () => void;
+ const writeStartedPromise = new Promise((resolve) => {
+ writeStarted = resolve;
+ });
+ fake.apis.writeTextFile = (path: string, content: string) => {
+ if (path.startsWith('/app/projects/project/codex/codex.snap.tmp-')) {
+ writeStarted();
+ return new Promise((resolve, reject) => {
+ releaseWrite = () => {
+ originalWriteTextFile(path, content).then(resolve, reject);
+ };
+ });
+ }
+ return originalWriteTextFile(path, content);
+ };
+
+ const legacyWrite = store.saveStoryCodex({
+ projectId: 'item',
+ entries: [{ name: 'updated' }],
+ } as never);
+ await writeStartedPromise;
+
+ let claimantFinished = false;
+ const claimant = store
+ .saveProject({ ...project, id: 'project', title: 'Legitimate Project' } as never)
+ .then(() => {
+ claimantFinished = true;
+ });
+ await Promise.resolve();
+ expect(claimantFinished).toBe(false);
+
+ releaseWrite();
+ await legacyWrite;
+ await claimant;
+
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/project/codex/codex.snap') as string,
+ ),
+ ).toEqual({ projectId: 'item', entries: [{ name: 'updated' }] });
+ expect(fake.text.has('/app/projects/project/project.json')).toBe(true);
+ });
+
+ // QNBS-v3: deletion cannot report success after route ownership changes during auxiliary cleanup.
+ it('serializes legacy deletion before a legitimate fallback claimant can save', async () => {
+ const legacyProject = { ...project, id: '***' };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+ await store.loadProject('item');
+
+ const originalRemove = fake.apis.remove;
+ let releaseRemove!: () => void;
+ let removeStarted!: () => void;
+ const removeStartedPromise = new Promise((resolve) => {
+ removeStarted = resolve;
+ });
+ fake.apis.remove = (path: string, options?: { recursive?: boolean }) => {
+ if (path === '/app/projects/project/codex/codex.snap') {
+ removeStarted();
+ return new Promise((resolve, reject) => {
+ releaseRemove = () => {
+ originalRemove(path, options).then(resolve, reject);
+ };
+ });
+ }
+ return originalRemove(path, options);
+ };
+
+ const deletion = store.deleteProject('item');
+ await removeStartedPromise;
+
+ let claimantFinished = false;
+ const claimant = store
+ .saveProject({ ...project, id: 'project', title: 'Legitimate Project' } as never)
+ .then(() => {
+ claimantFinished = true;
+ });
+ await Promise.resolve();
+ expect(claimantFinished).toBe(false);
+
+ releaseRemove();
+ await deletion;
+ await claimant;
+
+ expect(fake.text.has('/app/projects/item/project.json')).toBe(false);
+ expect(fake.text.has('/app/projects/project/project.json')).toBe(true);
+ expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(false);
+ });
+
+ // QNBS-v3: a normalized directory identity remains valid evidence while a legacy main file still carries the raw ID.
+ it('accepts the normalized project identity in a legacy Codex snapshot', async () => {
+ const legacyProject = { ...project, id: '***' };
+ const migratedCodex = { projectId: 'item', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(migratedCodex),
+ );
+
+ await expect(store.loadProject('item')).resolves.toEqual(
+ expect.objectContaining({ id: 'item' }),
+ );
+ await expect(store.getStoryCodex('item')).resolves.toEqual(migratedCodex);
+ });
+
+ // QNBS-v3: partial Binder enumeration keeps healthy current assets visible when legacy inspection is temporarily unavailable.
+ it('retains current Binder IDs when the legacy directory cannot be listed', async () => {
+ const legacyProject = {
+ ...project,
+ id: '***',
+ binderNodes: [{ binderAssetId: 'legacy-asset' }],
+ };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/binder', { recursive: true });
+ await fake.apis.writeFile('/app/projects/project/binder/legacy-asset.bin', new Uint8Array([1]));
+ await fake.apis.writeTextFile(
+ '/app/projects/project/binder/legacy-asset.meta.json',
+ JSON.stringify({
+ mimeType: 'application/octet-stream',
+ originalFileName: 'legacy.bin',
+ byteSize: 1,
+ }),
+ );
+ await store.loadProject('item');
+ await store.saveBinderAsset('item', 'current-asset', new Uint8Array([2]).buffer, {
+ mimeType: 'application/octet-stream',
+ originalFileName: 'current.bin',
+ byteSize: 1,
+ });
+
+ const originalReadDir = fake.apis.readDir;
+ fake.apis.readDir = async (path: string) => {
+ if (path === '/app/projects/project/binder') {
+ throw new Error('EAGAIN: legacy Binder directory temporarily unavailable');
+ }
+ return originalReadDir(path);
+ };
+
+ await expect(store.listBinderAssetIds('item')).resolves.toEqual(['current-asset']);
+ });
+
+ // QNBS-v3: persisted legacy provenance keeps coupled filesystem data visible after a desktop restart.
+ it('persists verified legacy auxiliary routing across normalized saves and reloads', async () => {
+ const legacyProject = {
+ ...project,
+ id: '***',
+ binderNodes: [{ binderAssetId: 'legacy-asset' }],
+ };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/binder', { recursive: true });
+ await fake.apis.writeFile(
+ '/app/projects/project/binder/legacy-asset.bin',
+ new Uint8Array([1, 2]),
+ );
+ await fake.apis.writeTextFile(
+ '/app/projects/project/binder/legacy-asset.meta.json',
+ JSON.stringify({
+ mimeType: 'application/octet-stream',
+ originalFileName: 'legacy.bin',
+ byteSize: 2,
+ }),
+ );
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+
+ const loaded = await store.loadProject('item');
+ await store.saveProject(loaded as never);
+
+ const restarted = new FsProjectStore();
+ await expect(restarted.loadProject('item')).resolves.toEqual(
+ expect.objectContaining({ id: 'item' }),
+ );
+ await expect(restarted.getStoryCodex('item')).resolves.toEqual(legacyCodex);
+ await expect(restarted.getStoryCodex('item/')).resolves.toEqual(legacyCodex);
+ await expect(restarted.getBinderAsset('item', 'legacy-asset')).resolves.toEqual(
+ expect.objectContaining({
+ meta: expect.objectContaining({ originalFileName: 'legacy.bin' }),
+ }),
+ );
+ });
+
+ // QNBS-v3: persisted legacy routing must be revalidated so a later legitimate project cannot be captured by stale fallback metadata.
+ it('does not restore persisted legacy routing after a legitimate project identity appears', async () => {
+ const legacyProject = {
+ ...project,
+ id: '***',
+ binderNodes: [{ binderAssetId: 'legacy-asset' }],
+ };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await store.saveStoryCodex(legacyCodex as never);
+ await store.saveBinderAsset('***', 'legacy-asset', new Uint8Array([1]).buffer, {
+ mimeType: 'application/octet-stream',
+ originalFileName: 'legacy.bin',
+ byteSize: 1,
+ });
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+
+ const loaded = await store.loadProject('item');
+ await store.saveProject(loaded as never);
+
+ const legitimateProject = { ...project, id: 'project', title: 'Legitimate Project' };
+ const legitimateCodex = { projectId: 'project', entries: [{ name: 'legitimate' }] };
+ const legitimateVectors = [{ id: 'legitimate-vector' }];
+ await store.saveProject(legitimateProject as never);
+ await expect(store.getStoryCodex('item')).resolves.toBeNull();
+ await store.saveStoryCodex(legitimateCodex as never);
+ await store.saveRagVectors('project', legitimateVectors);
+ await store.saveBinderAsset('project', 'legitimate-asset', new Uint8Array([2]).buffer, {
+ mimeType: 'application/octet-stream',
+ originalFileName: 'legitimate.bin',
+ byteSize: 1,
+ });
+
+ const restarted = new FsProjectStore();
+ await expect(restarted.loadProject('item')).resolves.toEqual(
+ expect.objectContaining({ id: 'item' }),
+ );
+ await expect(restarted.getStoryCodex('item')).resolves.toBeNull();
+ await expect(restarted.listBinderAssetIds('item')).resolves.toEqual([]);
+ await expect(restarted.getStoryCodex('project')).resolves.toEqual(legitimateCodex);
+ await expect(restarted.getRagVectors('project')).resolves.toEqual(legitimateVectors);
+ await expect(restarted.getBinderAsset('project', 'legitimate-asset')).resolves.not.toBeNull();
+ });
+
+ // QNBS-v3: a failed reload must retain the verified legacy route so transient reads do not hide auxiliary data.
+ it('retains legacy routing when a later project reload fails', async () => {
+ const legacyProject = { ...project, id: '***' };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await store.saveStoryCodex(legacyCodex as never);
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+
+ await store.loadProject('item');
+ const originalReadTextFile = fake.apis.readTextFile;
+ fake.apis.readTextFile = async (path: string) => {
+ if (path === '/app/projects/item/project.json') {
+ throw new Error('EAGAIN: project temporarily unavailable');
+ }
+ return originalReadTextFile(path);
+ };
+
+ await expect(store.loadProject('item')).rejects.toMatchObject({
+ name: 'ProjectLoadError',
+ reason: 'io-error',
+ projectId: 'item',
+ });
+ await expect(store.getStoryCodex('item')).resolves.toEqual(legacyCodex);
+ });
+
+ // QNBS-v3: incomplete legacy evidence fails closed instead of making an unverified normalization durable.
+ it('aborts legacy migration when auxiliary ownership evidence cannot be read', async () => {
+ const legacyProject = { ...project, id: '***' };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+
+ const originalReadTextFile = fake.apis.readTextFile;
+ fake.apis.readTextFile = async (path: string) => {
+ if (path === '/app/projects/project/codex/codex.snap') {
+ throw new Error('EAGAIN: codex temporarily unavailable');
+ }
+ return originalReadTextFile(path);
+ };
+
+ await expect(store.loadProject('item')).rejects.toMatchObject({
+ name: 'ProjectLoadError',
+ reason: 'io-error',
+ projectId: 'item',
+ });
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/item/project.json') as string,
+ )['id'],
+ ).toBe('***');
+
+ fake.apis.readTextFile = originalReadTextFile;
+ await expect(store.loadProject('item')).resolves.toEqual(
+ expect.objectContaining({ id: 'item' }),
+ );
+ await expect(store.getStoryCodex('item')).resolves.toEqual(legacyCodex);
+ });
+
+ // QNBS-v3: incomplete save-time evidence must not make an unverified legacy identity durable.
+ it('rejects a legacy save when auxiliary ownership evidence is incomplete', async () => {
+ const legacyProject = { ...project, id: '***' };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+
+ const originalReadTextFile = fake.apis.readTextFile;
+ fake.apis.readTextFile = async (path: string) => {
+ if (path === '/app/projects/project/codex/codex.snap') {
+ throw new Error('EAGAIN: codex temporarily unavailable');
+ }
+ return originalReadTextFile(path);
+ };
+
+ await expect(store.saveProject(legacyProject as never)).rejects.toThrow(
+ 'Cannot safely save this legacy project until its auxiliary data can be verified.',
+ );
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/item/project.json') as string,
+ )['id'],
+ ).toBe('***');
+ });
+
+ // QNBS-v3: indeterminate fallback ownership cannot clear a route that remains the only safe retry path.
+ it('defers persisted legacy routing when the fallback collision probe is indeterminate', async () => {
+ const legacyProject = { ...project, id: '***' };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+
+ const loaded = await store.loadProject('item');
+ await store.saveProject(loaded as never);
+
+ const originalExists = fake.apis.exists;
+ fake.apis.exists = async (path: string) => {
+ if (path === '/app/projects/project/project.json') {
+ throw new Error('EIO: fallback collision probe unavailable');
+ }
+ return originalExists(path);
+ };
+
+ await expect(store.loadProject('item')).rejects.toMatchObject({
+ name: 'ProjectLoadError',
+ reason: 'io-error',
+ projectId: 'item',
+ });
+ await expect(store.getStoryCodex('item')).resolves.toEqual(legacyCodex);
+ });
+
+ // QNBS-v3: Binder filename suffixes make dot-shaped legacy asset IDs safe without weakening project path rules.
+ it('retains dot-shaped legacy Binder asset IDs across persisted routing', async () => {
+ const legacyProject = {
+ ...project,
+ id: '***',
+ binderNodes: [{ binderAssetId: '.' }, { binderAssetId: '..' }],
+ };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/binder', { recursive: true });
+ await fake.apis.writeFile('/app/projects/project/binder/..bin', new Uint8Array([1]));
+ await fake.apis.writeTextFile(
+ '/app/projects/project/binder/..meta.json',
+ JSON.stringify({
+ mimeType: 'application/octet-stream',
+ originalFileName: 'dot.bin',
+ byteSize: 1,
+ }),
+ );
+ await fake.apis.writeFile('/app/projects/project/binder/...bin', new Uint8Array([2]));
+ await fake.apis.writeTextFile(
+ '/app/projects/project/binder/...meta.json',
+ JSON.stringify({
+ mimeType: 'application/octet-stream',
+ originalFileName: 'dot-dot.bin',
+ byteSize: 1,
+ }),
+ );
+
+ const loaded = await store.loadProject('item');
+ await store.saveProject(loaded as never);
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/item/project.json') as string,
+ )['__worldscriptLegacyAuxiliary'],
+ ).toEqual(
+ expect.objectContaining({
+ binderAssetIds: expect.arrayContaining(['.', '..']),
+ }),
+ );
+ const restarted = new FsProjectStore();
+
+ await expect(restarted.loadProject('item')).resolves.toEqual(
+ expect.objectContaining({ id: 'item' }),
+ );
+ await expect(restarted.getBinderAsset('item', '.')).resolves.toEqual(
+ expect.objectContaining({ meta: expect.objectContaining({ originalFileName: 'dot.bin' }) }),
+ );
+ await expect(restarted.getBinderAsset('item', '..')).resolves.toEqual(
+ expect.objectContaining({
+ meta: expect.objectContaining({ originalFileName: 'dot-dot.bin' }),
+ }),
+ );
+ await expect(restarted.listBinderAssetIds('item')).resolves.toEqual(
+ expect.arrayContaining(['.', '..']),
+ );
+ });
+
+ // QNBS-v3: legacy cleanup failures stay retryable so deletion cannot report success while routed data remains.
+ it('retains legacy routing when auxiliary cleanup fails during deletion', async () => {
+ const legacyProject = { ...project, id: '***' };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+ await store.loadProject('item');
+
+ const originalRemove = fake.apis.remove;
+ fake.apis.remove = async (path: string, options?: { recursive?: boolean }) => {
+ if (path === '/app/projects/project/codex/codex.snap') {
+ throw new Error('EIO: legacy codex cleanup unavailable');
+ }
+ return originalRemove(path, options);
+ };
+
+ await expect(store.deleteProject('item')).rejects.toMatchObject({
+ name: 'ProjectDeleteError',
+ });
+ expect(fake.text.has('/app/projects/item/project.json')).toBe(true);
+ expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(true);
+ await expect(store.getStoryCodex('item')).resolves.toEqual(legacyCodex);
+
+ fake.apis.remove = originalRemove;
+ const restarted = new FsProjectStore();
+ await expect(restarted.deleteProject('item')).resolves.toBeUndefined();
+ expect(fake.text.has('/app/projects/item/project.json')).toBe(false);
+ expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(false);
+ });
+
+ // QNBS-v3: deletion revalidates persisted legacy routing so restart-time cleanup cannot orphan verified auxiliary data.
+ it('hydrates persisted legacy routing before deleting an unloaded project', async () => {
+ const legacyProject = { ...project, id: '***' };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+
+ const loaded = await store.loadProject('item');
+ await store.saveProject(loaded as never);
+ const restarted = new FsProjectStore();
+
+ await expect(restarted.deleteProject('item')).resolves.toBeUndefined();
+ expect(fake.text.has('/app/projects/item/project.json')).toBe(false);
+ expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(false);
+ });
+
+ // QNBS-v3: deletion fails closed when project identity cannot be inspected, preserving data for a later retry.
+ it('does not delete a project when its identity cannot be inspected', async () => {
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', '{corrupt');
+
+ await expect(store.deleteProject('item')).rejects.toMatchObject({
+ name: 'ProjectDeleteError',
+ reason: 'identity-inspection-failed',
+ });
+ expect(fake.text.has('/app/projects/item/project.json')).toBe(true);
+ });
+
+ // QNBS-v3: an uncertain existence probe must preserve the project and expose a retryable typed deletion result.
+ it('classifies a project existence probe failure before attempting cleanup', async () => {
+ await store.saveProject(project as never);
+ const originalExists = fake.apis.exists;
+ const originalRemove = fake.apis.remove;
+ const removeSpy = vi.fn(originalRemove);
+ fake.apis.remove = removeSpy;
+ fake.apis.exists = (path: string) =>
+ path === '/app/projects/p1'
+ ? Promise.reject(new Error('EIO: project existence unavailable'))
+ : originalExists(path);
+
+ await expect(store.deleteProject('p1')).rejects.toMatchObject({
+ name: 'ProjectDeleteError',
+ reason: 'identity-inspection-failed',
+ });
+ expect(removeSpy).not.toHaveBeenCalled();
+ expect(fake.text.has('/app/projects/p1/project.json')).toBe(true);
+ });
+
+ // QNBS-v3: snapshot recovery accepts an invalid legacy identity only when its existing fallback directory proves ownership.
+ it('normalizes a legacy invalid project ID restored from a filesystem snapshot', async () => {
+ const legacyProject = { ...project, id: '***' };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+
+ const snapshotId = await store.saveSnapshot('legacy', legacyProject);
+ const restored = await store.getSnapshotData(snapshotId);
+ await expect(store.saveProject(restored as never)).resolves.toBeUndefined();
+ await expect(store.getStoryCodex('item')).resolves.toEqual(legacyCodex);
+
+ const persisted = decompressData>(
+ fake.text.get('/app/projects/item/project.json') as string,
+ );
+ expect(persisted['id']).toBe('item');
+ await expect(
+ store.saveProject({ ...project, id: '***', title: 'Unrelated New Project' } as never),
+ ).rejects.toThrow('Cannot save a project with an unusable project ID.');
+ });
+
+ // QNBS-v3: matching snapshot identity permits older content while keeping the filesystem target authoritative.
+ it('rejects a valid snapshot from another project before changing the target', async () => {
+ await store.saveProject(project as never);
+ const current = await store.loadProject('p1');
+ const snapshotId = await store.saveSnapshot('older', {
+ ...project,
+ id: 'p2',
+ title: 'Older snapshot content',
+ manuscript: [{ id: 's-old', title: 'Older', content: 'previous draft' }],
+ });
+
+ await expect(store.restoreSnapshot(snapshotId, current as never)).rejects.toMatchObject({
+ name: 'ProjectSnapshotRestoreError',
+ reason: 'snapshot-owner-mismatch',
+ });
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/p1/project.json') as string,
+ )['title'],
+ ).toBe('My Novel');
+ expect(fake.text.has('/app/projects/p2/project.json')).toBe(false);
+ });
+
+ // QNBS-v3: the best-effort cold-boot marker cannot veto a validated current filesystem target.
+ it('restores a matching snapshot when the active-project marker is stale', async () => {
+ const secondProject = { ...project, id: 'p2', title: 'Second Novel' };
+ await store.saveProject(secondProject as never);
+ const current = await store.loadProject('p2');
+ const snapshotId = await store.saveSnapshot('p2-snapshot', {
+ ...secondProject,
+ title: 'Older second project content',
+ });
+ fake.text.set('/app/config/active-project-id.txt', 'p1');
+
+ const restored = await store.restoreSnapshot(snapshotId, current as never);
+
+ expect((restored as unknown as Record)['id']).toBe('p2');
+ expect(restored.title).toBe('Older second project content');
+ });
+
+ it('restores older content when the snapshot owner matches the validated target', async () => {
+ await store.saveProject(project as never);
+ const current = await store.loadProject('p1');
+ const snapshotId = await store.saveSnapshot('older', {
+ ...project,
+ id: 'p1',
+ title: 'Older snapshot content',
+ manuscript: [{ id: 's-old', title: 'Older', content: 'previous draft' }],
+ });
+
+ const restored = await store.restoreSnapshot(snapshotId, current as never);
+ const restoredRecord = restored as unknown as Record;
+
+ expect(restoredRecord['id']).toBe('p1');
+ expect(restored.title).toBe('Older snapshot content');
+ await store.saveProject(restored as never);
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/p1/project.json') as string,
+ )['title'],
+ ).toBe('Older snapshot content');
+ expect(fake.text.has('/app/projects/p2/project.json')).toBe(false);
+ });
+
+ // QNBS-v3: target-owned metadata survives a matching restore without trusting snapshot metadata.
+ it('restores older normalized content and preserves target auxiliary metadata', async () => {
+ const legacyProject = { ...project, id: '***', title: 'Current legacy content' };
+ const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+ const current = await store.loadProject('item');
+ const snapshotId = await store.saveSnapshot('older-legacy', {
+ ...legacyProject,
+ id: 'item',
+ title: 'Older legacy content',
+ manuscript: [{ id: 's-old', title: 'Older', content: 'previous draft' }],
+ __worldscriptLegacyProjectDirectory: 'project',
+ __worldscriptLegacyAuxiliary: {
+ legacyProjectId: 'project',
+ legacyRawProjectId: '***',
+ codex: false,
+ binderAssetIds: ['fabricated'],
+ },
+ });
+
+ const restored = await store.restoreSnapshot(snapshotId, current as never);
+ const restoredRecord = restored as unknown as Record;
+ const metadata = restoredRecord['__worldscriptLegacyAuxiliary'] as Record;
+
+ expect(restoredRecord['id']).toBe('item');
+ expect(restored.title).toBe('Older legacy content');
+ expect(restoredRecord['__worldscriptLegacyProjectDirectory']).toBeUndefined();
+ expect(metadata).toEqual(
+ expect.objectContaining({
+ legacyProjectId: 'project',
+ legacyRawProjectId: '***',
+ codex: true,
+ }),
+ );
+ expect(metadata['binderAssetIds']).toEqual([]);
+ });
+
+ it('rejects a historical invalid-ID snapshot even when the target has matching legacy lineage', async () => {
+ const legacyProject = { ...project, id: '***', title: 'Current legacy content' };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+ const current = await store.loadProject('item');
+ const snapshotId = await store.saveSnapshot('legacy-invalid-id', {
+ ...legacyProject,
+ title: 'Older legacy content',
+ });
+
+ await expect(store.restoreSnapshot(snapshotId, current as never)).rejects.toMatchObject({
+ name: 'ProjectSnapshotRestoreError',
+ reason: 'snapshot-owner-unverifiable',
+ });
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/item/project.json') as string,
+ )['title'],
+ ).toBe('Current legacy content');
+ });
+
+ // QNBS-v3: restore fails closed when no safe current filesystem target is available.
+ it.each(['.', '..', '***'])(
+ 'rejects unsafe restore target %s without touching projects',
+ async (id) => {
+ const snapshotId = await store.saveSnapshot('unsafe-target', project);
+
+ await expect(
+ store.restoreSnapshot(snapshotId, { ...project, id } as never),
+ ).rejects.toMatchObject({
+ name: 'ProjectSnapshotRestoreError',
+ reason: 'target-unavailable',
+ });
+ expect([...fake.text.keys()].some((path) => path.startsWith('/app/projects/'))).toBe(false);
+ },
+ );
+
+ // QNBS-v3: ownerless historical snapshots fail closed instead of guessing a missing-ID target.
+ it('rejects a historical missing-ID snapshot for a verified missing-ID target', async () => {
+ const legacyProject = { ...project, id: undefined, title: 'Legacy Novel' };
+ await fake.apis.mkdir('/app/projects/Legacy-Novel', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/Legacy-Novel/project.json',
+ compressData(legacyProject),
+ );
+ const current = await store.loadProject('Legacy-Novel');
+ const snapshotId = await store.saveSnapshot('older-missing-id', {
+ ...legacyProject,
+ title: 'Renamed in older snapshot',
+ });
+
+ await expect(store.restoreSnapshot(snapshotId, current as never)).rejects.toMatchObject({
+ name: 'ProjectSnapshotRestoreError',
+ reason: 'snapshot-owner-unverifiable',
+ });
+ expect(fake.text.has('/app/projects/Legacy-Novel/project.json')).toBe(true);
+ expect(fake.text.has('/app/projects/Renamed-in-older-snapshot/project.json')).toBe(false);
+ });
+
+ it('keeps a missing-ID legacy project bound to its existing title-derived directory', async () => {
+ const legacyProject = { ...project, id: undefined, title: 'Legacy Novel' };
+ await fake.apis.mkdir('/app/projects/Legacy-Novel', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/Legacy-Novel/project.json',
+ compressData(legacyProject),
+ );
+
+ const loaded = await store.loadProject('Legacy-Novel');
+
+ expect((loaded as unknown as Record)['id']).toBeUndefined();
+ await store.saveProject({ ...loaded, title: 'Renamed Novel' } as never);
+ expect(fake.text.has('/app/projects/Legacy-Novel/project.json')).toBe(true);
+ expect(fake.text.has('/app/projects/Renamed-Novel/project.json')).toBe(false);
+
+ const restarted = new FsProjectStore();
+ const reloaded = await restarted.loadProject('Legacy-Novel');
+ await restarted.saveProject({ ...reloaded, title: 'Renamed Again' } as never);
+ expect(fake.text.has('/app/projects/Legacy-Novel/project.json')).toBe(true);
+ expect(fake.text.has('/app/projects/Renamed-Again/project.json')).toBe(false);
+ });
+
+ // QNBS-v3: legacy missing-ID saves preserve historical Binder/Codex fallbacks without inventing cross-project ownership.
+ it('keeps missing-ID legacy auxiliary data on its historical fallback paths', async () => {
+ const legacyProject = { ...project, id: undefined, title: 'Legacy Novel' };
+ const legacyCodex = { projectId: 'default', entries: [{ name: 'legacy' }] };
+ await fake.apis.mkdir('/app/projects/Legacy-Novel', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/Legacy-Novel/project.json',
+ compressData(legacyProject),
+ );
+ await fake.apis.mkdir('/app/projects/browser-project/binder', { recursive: true });
+ await fake.apis.writeFile(
+ '/app/projects/browser-project/binder/legacy-asset.bin',
+ new Uint8Array([1]),
+ );
+ await fake.apis.writeTextFile(
+ '/app/projects/browser-project/binder/legacy-asset.meta.json',
+ JSON.stringify({
+ mimeType: 'application/octet-stream',
+ originalFileName: 'legacy.bin',
+ byteSize: 1,
+ }),
+ );
+ await fake.apis.mkdir('/app/projects/default/codex', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/default/codex/codex.snap',
+ compressData(legacyCodex),
+ );
+
+ const loaded = await store.loadProject('Legacy-Novel');
+
+ expect((loaded as unknown as Record)['id']).toBeUndefined();
+ await expect(store.getBinderAsset('browser-project', 'legacy-asset')).resolves.not.toBeNull();
+ await expect(store.getStoryCodex('default')).resolves.toEqual(legacyCodex);
+ await store.saveProject({ ...loaded, title: 'Renamed Novel' } as never);
+ expect(fake.text.has('/app/projects/Legacy-Novel/project.json')).toBe(true);
+ expect(fake.text.has('/app/projects/Renamed-Novel/project.json')).toBe(false);
+ expect(fake.bin.has('/app/projects/browser-project/binder/legacy-asset.bin')).toBe(true);
+ expect(fake.text.has('/app/projects/default/codex/codex.snap')).toBe(true);
+ });
+
+ it('does not redirect a legacy project to a legitimate project-identity directory', async () => {
+ const legitimateCodex = { projectId: 'project', entries: [{ name: 'legitimate' }] };
+ const legitimateVectors = [{ id: 'legitimate-vector' }];
+ await store.saveProject({ ...project, id: 'project', title: 'Legitimate Project' } as never);
+ await store.saveStoryCodex(legitimateCodex as never);
+ await store.saveRagVectors('project', legitimateVectors);
+ await store.saveBinderAsset('project', 'legitimate-asset', new Uint8Array([3]).buffer, {
+ mimeType: 'application/octet-stream',
+ originalFileName: 'legitimate.bin',
+ byteSize: 1,
+ });
+
+ const legacyProject = {
+ ...project,
+ id: '***',
+ binderNodes: [{ binderAssetId: 'legitimate-asset' }],
+ };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+
+ await expect(store.loadProject('item')).resolves.toEqual(
+ expect.objectContaining({ title: 'My Novel' }),
+ );
+ expect(await store.getStoryCodex('item')).toBeNull();
+ expect(await store.getRagVectors('item')).toEqual([]);
+ expect(await store.listBinderAssetIds('item')).toEqual([]);
+ expect(await store.getStoryCodex('project')).toEqual(legitimateCodex);
+ expect(await store.getRagVectors('project')).toEqual(legitimateVectors);
+ expect(await store.getBinderAsset('project', 'legitimate-asset')).not.toBeNull();
+ });
+
+ // QNBS-v3: ambiguous auxiliary data stays in place and unassigned rather than being exposed through a guessed legacy identity.
+ it('does not assign auxiliary data without ownership evidence to a legacy project', async () => {
+ const ambiguousVectors = [{ id: 'ambiguous-vector' }];
+ await store.saveRagVectors('project', ambiguousVectors);
+ const legacyProject = { ...project, id: '***' };
+ await fake.apis.mkdir('/app/projects/item', { recursive: true });
+ await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
+
+ await store.loadProject('item');
+
+ expect(await store.getRagVectors('item')).toEqual([]);
+ expect(await store.getRagVectors('project')).toEqual(ambiguousVectors);
+ expect(fake.text.has('/app/projects/project/codex/vectors.snap')).toBe(true);
+ });
+
+ it('rejects dot path segments without touching the project namespace', async () => {
+ await store.saveProject(project as never);
+ await store.saveProject({ ...project, id: 'p2', title: 'Other Novel' } as never);
+
+ for (const invalidId of ['.', '..']) {
+ await expect(store.saveProject({ ...project, id: invalidId } as never)).rejects.toThrow(
+ 'Cannot save a project with an unusable project ID.',
+ );
+ await expect(store.loadProject(invalidId)).resolves.toBeNull();
+ await expect(store.deleteProject(invalidId)).resolves.toBeUndefined();
+ await expect(store.quarantineProject(invalidId)).rejects.toMatchObject({
+ name: 'ProjectQuarantineError',
+ reason: 'not-found',
+ });
+ }
+
+ expect(await store.listProjects()).toEqual(expect.arrayContaining(['p1', 'p2']));
+ expect(fake.text.has('/app/projects/p1/project.json')).toBe(true);
+ expect(fake.text.has('/app/projects/p2/project.json')).toBe(true);
+ });
+
+ // QNBS-v3: failed preservation must leave the affected source available for safe recovery.
+ it('leaves the original project intact when quarantine cannot rename it', async () => {
+ await store.saveProject(project as never);
+ const original = fake.text.get('/app/projects/p1/project.json');
+ fake.apis.rename = () => Promise.reject(new Error('EACCES: permission denied'));
+
+ await expect(store.quarantineProject('p1')).rejects.toMatchObject({
+ name: 'ProjectQuarantineError',
+ reason: 'io-error',
+ message: 'Project preservation failed. The original project was not deleted.',
+ });
+
+ expect(fake.text.get('/app/projects/p1/project.json')).toBe(original);
+ expect(await store.listProjects()).toContain('p1');
+ });
+
// QNBS-v3: the store test protects the non-blocking scheduling seam without asserting a verdict authority.
it('returns the decoded project without waiting for or adopting the shadow verdict', async () => {
await store.saveProject(project as never);
diff --git a/tests/unit/services/fs/projectFsStore.test.ts b/tests/unit/services/fs/projectFsStore.test.ts
index c90e671af..2c47459d8 100644
--- a/tests/unit/services/fs/projectFsStore.test.ts
+++ b/tests/unit/services/fs/projectFsStore.test.ts
@@ -52,6 +52,7 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => {
await expect(store.loadProject('missing-id')).resolves.toBeNull();
});
+ // QNBS-v3: preserve the affected project identity when filesystem reads fail during recovery.
it('throws ProjectLoadError("io-error") on a read failure instead of returning null', async () => {
const { FsProjectStore, ProjectLoadError } = await import(
'../../../../services/fs/projectFsStore'
@@ -63,9 +64,10 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => {
const store = new FsProjectStore();
const promise = store.loadProject('locked-id');
await expect(promise).rejects.toThrow(ProjectLoadError);
- await expect(promise).rejects.toMatchObject({ reason: 'io-error' });
+ await expect(promise).rejects.toMatchObject({ reason: 'io-error', projectId: 'locked-id' });
});
+ // QNBS-v3: keep corruption classification attached to the project so quarantine targets only it.
it('throws ProjectLoadError("corrupt") on a corrupt/truncated compressed payload', async () => {
const { FsProjectStore, ProjectLoadError } = await import(
'../../../../services/fs/projectFsStore'
@@ -75,7 +77,7 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => {
const store = new FsProjectStore();
const promise = store.loadProject('corrupt-id');
await expect(promise).rejects.toThrow(ProjectLoadError);
- await expect(promise).rejects.toMatchObject({ reason: 'corrupt' });
+ await expect(promise).rejects.toMatchObject({ reason: 'corrupt', projectId: 'corrupt-id' });
});
it('throws ProjectLoadError("corrupt") on valid JSON that is not project-shaped at all', async () => {
diff --git a/tests/unit/storageService.test.ts b/tests/unit/storageService.test.ts
index f0b4b4f80..79a5bb42b 100644
--- a/tests/unit/storageService.test.ts
+++ b/tests/unit/storageService.test.ts
@@ -90,6 +90,11 @@ describe('storageService (IndexedDB backend in browser)', () => {
expect(await storageService.getActiveProjectId()).toBeNull();
});
+ // QNBS-v3: unsupported IndexedDB quarantine must remain a safe no-op rather than inventing a filesystem path.
+ it('returns null when the IndexedDB backend does not support filesystem quarantine', async () => {
+ await expect(storageService.quarantineProject('project-1')).resolves.toBeNull();
+ });
+
it('delegates saveSettings / loadSettings to dbService', async () => {
await storageService.saveSettings({} as never);
expect(mockDb.saveSettings).toHaveBeenCalled();
@@ -144,6 +149,10 @@ describe('storageService (IndexedDB backend in browser)', () => {
await storageService.getSnapshotData(42);
expect(mockDb.getSnapshotData).toHaveBeenCalledWith(42);
+ const currentProject = { id: 'p1', title: 'Current target' };
+ await storageService.restoreSnapshot(42, currentProject as never);
+ expect(mockDb.getSnapshotData).toHaveBeenCalledWith(42);
+
await storageService.listSnapshots();
expect(mockDb.listSnapshots).toHaveBeenCalled();
diff --git a/tests/unit/thunks/binderAndManagementThunks.test.ts b/tests/unit/thunks/binderAndManagementThunks.test.ts
index 1c58d8e76..e1c695e62 100644
--- a/tests/unit/thunks/binderAndManagementThunks.test.ts
+++ b/tests/unit/thunks/binderAndManagementThunks.test.ts
@@ -2,12 +2,14 @@ import { configureStore } from '@reduxjs/toolkit';
import undoable from 'redux-undo';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+// QNBS-v3: the thunk mock models the target-aware storage boundary used before snapshot I/O.
vi.mock('../../../services/storageService', () => ({
storageService: {
saveImage: vi.fn(),
deleteBinderAsset: vi.fn(),
saveBinderAsset: vi.fn(),
getSnapshotData: vi.fn(),
+ restoreSnapshot: vi.fn(),
},
}));
@@ -52,6 +54,7 @@ beforeEach(() => {
vi.mocked(storageService.saveBinderAsset).mockResolvedValue(undefined);
vi.mocked(storageService.saveImage).mockResolvedValue(undefined);
vi.mocked(storageService.getSnapshotData).mockResolvedValue(null);
+ vi.mocked(storageService.restoreSnapshot).mockResolvedValue(null);
});
// ---------------------------------------------------------------------------
@@ -359,26 +362,57 @@ describe('importProjectThunk', () => {
describe('restoreSnapshotThunk', () => {
it('dispatches fulfilled with snapshot data from storageService', async () => {
const snapshotData = { title: 'Snapshot Title', manuscript: [] };
- vi.mocked(storageService.getSnapshotData).mockResolvedValue(snapshotData as never);
+ vi.mocked(storageService.restoreSnapshot).mockResolvedValue(snapshotData as never);
const store = makeStore();
const action = await store.dispatch(restoreSnapshotThunk(42));
expect(action.type).toBe('project/restoreSnapshot/fulfilled');
expect((action as { payload: typeof snapshotData }).payload).toEqual(snapshotData);
+ expect(storageService.restoreSnapshot).toHaveBeenCalledWith(
+ 42,
+ expect.objectContaining({ id: 'default' }),
+ );
});
- it('calls storageService.getSnapshotData with the correct id', async () => {
- vi.mocked(storageService.getSnapshotData).mockResolvedValue(null);
+ it('captures the current project before requesting snapshot data', async () => {
+ vi.mocked(storageService.restoreSnapshot).mockResolvedValue(null);
const store = makeStore();
await store.dispatch(restoreSnapshotThunk(99));
- expect(storageService.getSnapshotData).toHaveBeenCalledWith(99);
+ expect(storageService.restoreSnapshot).toHaveBeenCalledWith(
+ 99,
+ expect.objectContaining({ id: 'default' }),
+ );
+ });
+
+ // QNBS-v3: an async restore must not fulfill into a different Redux project than the captured target.
+ it('rejects when the active project changes while snapshot I/O is pending', async () => {
+ let releaseRestore!: (value: unknown) => void;
+ vi.mocked(storageService.restoreSnapshot).mockReturnValue(
+ new Promise((resolve) => {
+ releaseRestore = resolve;
+ }),
+ );
+
+ const store = makeStore();
+ const pending = store.dispatch(restoreSnapshotThunk(100));
+ const currentData = store.getState().project.present.data;
+ store.dispatch({
+ type: 'project/restoreSnapshot/fulfilled',
+ payload: { ...currentData, id: 'p2' },
+ });
+ releaseRestore({ ...currentData, id: 'default' });
+
+ const action = await pending;
+
+ expect(action.type).toBe('project/restoreSnapshot/rejected');
+ expect(store.getState().project.present.data.id).toBe('p2');
});
it('dispatches rejected when storageService throws', async () => {
- vi.mocked(storageService.getSnapshotData).mockRejectedValue(new Error('IDB error'));
+ vi.mocked(storageService.restoreSnapshot).mockRejectedValue(new Error('IDB error'));
const store = makeStore();
const action = await store.dispatch(restoreSnapshotThunk(1));