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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2925_keys-0EA5E9" alt="i18n 19 locales — 2925 keys">
<img src="https://img.shields.io/badge/Tests-7266%2B_%2F_591_files-22C55E" alt="7266+ tests / 591 files">
<img src="https://img.shields.io/badge/Tests-7304%2B_%2F_591_files-22C55E" alt="7304+ tests / 591 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
30 changes: 27 additions & 3 deletions features/project/thunks/projectManagementThunks.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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);
Expand Down Expand Up @@ -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;
},
);
135 changes: 94 additions & 41 deletions services/fs/assetFsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -84,36 +87,40 @@ export class FsAssetStore extends FsSnapshotStore {
data: ArrayBuffer,
meta: BinderAssetMeta,
): Promise<void> {
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<BinderAssetPayload | null> {
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;
Expand All @@ -122,28 +129,64 @@ export class FsAssetStore extends FsSnapshotStore {

async deleteBinderAsset(projectId: string, assetId: string): Promise<void> {
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<void> {
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<string[]> {
try {
return await this.withLegacyRoutingOperation(() =>
this.listBinderAssetIdsUnlocked(projectId),
);
} catch (error) {
logger.warn('listBinderAssetIds failed:', error);
return [];
}
}

private async listBinderAssetIdsUnlocked(projectId: string): Promise<string[]> {
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<string>();
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];
Expand All @@ -154,7 +197,17 @@ export class FsAssetStore extends FsSnapshotStore {
}

async deleteAllBinderAssetsForProject(projectId: string): Promise<void> {
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);
}
}),
);
});
}
}
Loading
Loading