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-2942_keys-0EA5E9" alt="i18n 19 locales — 2942 keys">
<img src="https://img.shields.io/badge/Tests-7516%2B_%2F_602_files-22C55E" alt="7516+ tests / 602 files">
<img src="https://img.shields.io/badge/Tests-7538%2B_%2F_602_files-22C55E" alt="7538+ tests / 602 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`) | 2942 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 (7516+ tests / 602 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (7538+ tests / 602 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 (7516+ tests, 602 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (7538+ tests, 602 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 @@ -713,8 +713,8 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
<!-- bundle-budget:source-of-truth -->
Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**.

**Current test metrics (2026-09-06, source-synchronized; CI remains authoritative for pass/fail):**
- **7516+ unit tests** across **602 test files** — CI is authoritative for pass/fail
**Current test metrics (2026-09-07, source-synchronized; CI remains authoritative for pass/fail):**
- **7538+ unit tests** across **602 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: **2942 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: 29 additions & 1 deletion components/StorageErrorScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface StorageErrorCopy {
storageUnavailable: string;
projectUnavailable: string;
projectIoUnavailable: string;
projectUnsupported: string;
projectMigrationGap: string;
reload: string;
retry: string;
recover: string;
Expand All @@ -27,6 +29,10 @@ export const STARTUP_COPY_FALLBACKS: StorageErrorCopy = {
projectUnavailable: 'A local project could not be opened. Reload and try again.',
projectIoUnavailable:
'The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.',
projectUnsupported:
'This project uses a schema version this build cannot edit. The original file has not been changed. Upgrade WorldScript Studio or open it with a compatible build.',
projectMigrationGap:
'This project uses an older schema version that this build cannot migrate. The original file has not been changed. Open it with a compatible build that supports its migration path.',
reload: 'Reload',
retry: 'Retry',
recover: 'Quarantine project and reload',
Expand Down Expand Up @@ -54,6 +60,8 @@ export async function loadStorageErrorCopy(): Promise<StorageErrorCopy> {
storageUnavailable,
projectUnavailable,
projectIoUnavailable,
projectUnsupported,
projectMigrationGap,
reload,
retry,
recover,
Expand All @@ -78,6 +86,14 @@ export async function loadStorageErrorCopy(): Promise<StorageErrorCopy> {
'error.startup.projectIoUnavailable',
STARTUP_COPY_FALLBACKS.projectIoUnavailable,
),
startupTranslation(
'error.startup.projectUnsupported',
STARTUP_COPY_FALLBACKS.projectUnsupported,
),
startupTranslation(
'error.startup.projectMigrationGap',
STARTUP_COPY_FALLBACKS.projectMigrationGap,
),
startupTranslation('error.startup.reload', STARTUP_COPY_FALLBACKS.reload),
startupTranslation('error.startup.retry', STARTUP_COPY_FALLBACKS.retry),
startupTranslation('error.startup.recover', STARTUP_COPY_FALLBACKS.recover),
Expand All @@ -97,6 +113,8 @@ export async function loadStorageErrorCopy(): Promise<StorageErrorCopy> {
storageUnavailable,
projectUnavailable,
projectIoUnavailable,
projectUnsupported,
projectMigrationGap,
reload,
retry,
recover,
Expand All @@ -117,6 +135,8 @@ function getFailureMessage(
const messages: Record<StartupRecoveryFailureKind, string> = {
'project-corrupt': copy.projectUnavailable,
'project-io': copy.projectIoUnavailable,
'project-unsupported': copy.projectUnsupported,
'project-migration-gap': copy.projectMigrationGap,
storage: copy.storageUnavailable,
};
return messages[failureKind];
Expand Down Expand Up @@ -366,7 +386,15 @@ export function StorageErrorScreen({
{getFailureMessage(failureKind, copy)}
</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'center' }}>
<RetryButton isProjectIo={failureKind === 'project-io'} copy={copy} onClick={handleRetry} />
<RetryButton
isProjectIo={
failureKind === 'project-io' ||
failureKind === 'project-unsupported' ||
failureKind === 'project-migration-gap'
}
copy={copy}
onClick={handleRetry}
/>
<RecoverButton
failureKind={failureKind}
onRecover={onRecover}
Expand Down
5 changes: 5 additions & 0 deletions docs/native/CORE-MIGRATION-LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ scope shifts — it is a living decision record, not a one-time snapshot.

## Decisions this table records

- **Row 9 current implementation truth:** the filesystem project-load ingress now decodes the
original JSON text before applying the canonical version classifier and refuses future,
migration-gap, and malformed payloads without rewriting the source. Legacy input is admitted
in memory only; durable migration, raw-carrier write-back, generation fencing, and the remaining
universal ingress/egress paths are still incomplete.
- **Wave 2 first slice touches only rows 1-3**, narrowly: project schema, validation, and a
plaintext (no compression, no atomicity guarantee) fs load/save round-trip. This is deliberately
the smallest slice that satisfies Wave 2's stated exit criterion ("representative project
Expand Down
2 changes: 1 addition & 1 deletion services/appBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function loadPersistedRootState(): Promise<PersistedRootState | und
// QNBS-v3 (#332): prefer the last-saved project's marker over projectIds[0] — readDir() order isn't recency, so an arbitrary first entry could hydrate a stale project. Falls back to projectIds[0] for pre-marker installs or a since-deleted active project.
const projectId =
activeProjectId && projectIds.includes(activeProjectId) ? activeProjectId : projectIds[0];
const project = projectId ? await storageService.loadProject(projectId) : null;
const project = projectId ? await storageService.loadProjectForEditing(projectId) : null;
if (!settings && !project) return undefined;
const result: PersistedRootState = {};
if (settings) result.settings = settings;
Expand Down
10 changes: 7 additions & 3 deletions services/fs/assetFsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export class FsAssetStore extends FsSnapshotStore {
const metaOut: BinderAssetMeta = { ...meta, byteSize: data.byteLength };
await writeFileAtomic(apis, binFile, new Uint8Array(data));
await writeTextFileAtomic(apis, metaFile, JSON.stringify(metaOut));
});
}, projectId);
}

async getBinderAsset(projectId: string, assetId: string): Promise<BinderAssetPayload | null> {
Expand Down Expand Up @@ -129,8 +129,12 @@ export class FsAssetStore extends FsSnapshotStore {

async deleteBinderAsset(projectId: string, assetId: string): Promise<void> {
try {
await this.withLegacyRoutingOperation(() => this.deleteBinderAssetStrict(projectId, assetId));
await this.withLegacyRoutingOperation(
() => this.deleteBinderAssetStrict(projectId, assetId),
projectId,
);
} catch (error) {
if (this.isProjectWriteAuthorityError(error)) throw error;
logger.warn('deleteBinderAsset failed:', error);
}
}
Expand Down Expand Up @@ -208,6 +212,6 @@ export class FsAssetStore extends FsSnapshotStore {
}
}),
);
});
}, projectId);
}
}
13 changes: 9 additions & 4 deletions services/fs/codexFsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class FsCodexStore extends FsSettingsStore {
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));
});
}, codex.projectId);
}

async getStoryCodex(projectId: string): Promise<StoryCodex | null> {
Expand All @@ -55,8 +55,12 @@ export class FsCodexStore extends FsSettingsStore {

async deleteStoryCodex(projectId: string): Promise<void> {
try {
await this.withLegacyRoutingOperation(() => this.deleteStoryCodexStrict(projectId));
await this.withLegacyRoutingOperation(
() => this.deleteStoryCodexStrict(projectId),
projectId,
);
} catch (error) {
if (this.isProjectWriteAuthorityError(error)) throw error;
logger.error('Failed to delete story codex:', error);
}
}
Expand Down Expand Up @@ -84,7 +88,7 @@ export class FsCodexStore extends FsSettingsStore {
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));
});
}, projectId);
}

async getRagVectors(projectId: string): Promise<unknown[]> {
Expand Down Expand Up @@ -124,8 +128,9 @@ export class FsCodexStore extends FsSettingsStore {
'vectors.snap',
);
if (await apis.exists(vectorsFile)) await retryFs(() => apis.remove(vectorsFile));
});
}, projectId);
} catch (error) {
if (this.isProjectWriteAuthorityError(error)) throw error;
logger.error('Failed to delete RAG vectors:', error);
}
}
Expand Down
44 changes: 32 additions & 12 deletions services/fs/fsCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,25 +154,32 @@ export class DecompressionError extends Error {
}
}

// QNBS-v3 (Amazon Q): JSON.parse also wrapped — a bare SyntaxError would break the DecompressionError-only contract callers rely on.
export function decompressData<T>(raw: string): T {
/**
* Decodes a stored payload while retaining the decompressed JSON text as the lossless admission
* input. The typed helper below remains the compatibility path for non-project stores.
*/
export function decompressJsonText(raw: string): string {
let json = raw;
if (raw.startsWith(LZ_PREFIX)) {
const decompressed = LZString.decompressFromUTF16(raw.slice(LZ_PREFIX.length));
if (decompressed === null) {
throw new DecompressionError();
}
try {
return JSON.parse(decompressed) as T;
} catch {
throw new DecompressionError(
'Failed to parse decompressed data as JSON — the payload is corrupt.',
);
}
json = decompressed;
}
return json;
}

// QNBS-v3: JSON.parse also wrapped — a bare SyntaxError would break the DecompressionError-only contract callers rely on.
export function decompressData<T>(raw: string): T {
try {
return JSON.parse(raw) as T;
return JSON.parse(decompressJsonText(raw)) as T;
} catch {
throw new DecompressionError('Failed to parse stored data as JSON — the payload is corrupt.');
throw new DecompressionError(
raw.startsWith(LZ_PREFIX)
? 'Failed to parse decompressed data as JSON — the payload is corrupt.'
: 'Failed to parse stored data as JSON — the payload is corrupt.',
);
}
}

Expand Down Expand Up @@ -336,8 +343,18 @@ export class FsCore {
return loadTauriApis();
}

// QNBS-v3: subclasses re-evaluate project authority only after the serialized operation begins.
protected async assertProjectWriteAuthority(_projectId: string): Promise<void> {}

protected isProjectWriteAuthorityError(_error: unknown): boolean {
return false;
}

// QNBS-v3: serialize complete filesystem operations so legacy route ownership cannot change between awaited mutations.
protected async withLegacyRoutingOperation<T>(operation: () => Promise<T>): Promise<T> {
protected async withLegacyRoutingOperation<T>(
operation: () => Promise<T>,
projectId?: string,
): Promise<T> {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
Expand All @@ -347,6 +364,9 @@ export class FsCore {
this.legacyRoutingOperationTail = current;
await previous;
try {
if (projectId !== undefined) {
await this.assertProjectWriteAuthority(projectId);
}
return await operation();
} finally {
if (this.legacyRoutingOperationTail === current) {
Expand Down
Loading
Loading