diff --git a/README.md b/README.md
index 15a51fbac..e7474851f 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`) | 2937 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 (7336+ tests / 595 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7350+ tests / 595 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 (7336+ tests, 595 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7350+ tests, 595 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-30, source-synchronized; CI remains authoritative for pass/fail):**
-- **7336+ unit tests** across **595 test files** — CI is authoritative for pass/fail
+- **7350+ unit tests** across **595 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: **2937 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/adapters.ts b/features/project/adapters.ts
index 7095471e5..920b26be6 100644
--- a/features/project/adapters.ts
+++ b/features/project/adapters.ts
@@ -1,5 +1,10 @@
import { createEntityAdapter } from '@reduxjs/toolkit';
import type { Character, World } from '../../types';
-export const charactersAdapter = createEntityAdapter();
-export const worldsAdapter = createEntityAdapter();
+// QNBS-v3: the stable no-op comparer selects RTK's object-safe update path without changing entity insertion order.
+const preserveEntityOrder = () => 0;
+
+export const charactersAdapter = createEntityAdapter({
+ sortComparer: preserveEntityOrder,
+});
+export const worldsAdapter = createEntityAdapter({ sortComparer: preserveEntityOrder });
diff --git a/index.tsx b/index.tsx
index 447242c4a..e1efc73e8 100644
--- a/index.tsx
+++ b/index.tsx
@@ -8,8 +8,8 @@ import { IdbUnlockModal } from './components/settings/IdbUnlockModal';
import { I18nProvider } from './contexts/I18nContext';
import { versionControlActions } from './features/versionControl/versionControlSlice';
import {
- getPersistedProjectPayload,
loadPersistedRootState,
+ normalizePersistedProjectForStore,
shouldAllowInitialMetadataSeed,
} from './services/appBootstrap';
import { initializeStorage } from './services/dbInitialization';
@@ -132,20 +132,12 @@ async function bootApp(): Promise {
// We must manually reconstruct the undo envelope if we loaded flat data.
if (preloadedState?.project) {
const projectPart = preloadedState.project;
- const persistedProjectData = getPersistedProjectPayload(projectPart);
- const hasUndoPayload =
- projectPart.present !== undefined &&
- getPersistedProjectPayload({ present: projectPart.present }) !== undefined;
-
- if (persistedProjectData && !hasUndoPayload) {
- logger.debug('Hydrating flat project state into Redux-Undo envelope.');
- preloadedState.project = {
- past: [],
- present: { data: persistedProjectData }, // Reconstruct the slice structure
- future: [],
- _latestUnfiltered: persistedProjectData, // Helper for redux-undo if needed
- };
- } else if (!persistedProjectData) {
+ const normalizedProject = normalizePersistedProjectForStore(projectPart);
+
+ if (normalizedProject) {
+ logger.debug('Hydrating persisted project state into Redux-Undo envelope.');
+ preloadedState.project = normalizedProject;
+ } else {
// Fallback: Corrupt or empty project state
logger.warn('Project state corrupted. Resetting project.');
delete (preloadedState as Record)['project'];
diff --git a/services/appBootstrap.ts b/services/appBootstrap.ts
index 32b7951bc..afc35ec77 100644
--- a/services/appBootstrap.ts
+++ b/services/appBootstrap.ts
@@ -1,3 +1,5 @@
+import type { EntityAdapter, EntityState } from '@reduxjs/toolkit';
+import { charactersAdapter, worldsAdapter } from '../features/project/adapters';
import type { ProjectData } from '../features/project/projectSlice';
import type { PersistedRootState } from '../types';
import { dbService } from './dbService';
@@ -41,26 +43,98 @@ function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
-function hasEntityStateShape(value: unknown): boolean {
+function hasOwn(value: object, key: PropertyKey): boolean {
+ return Object.hasOwn(value, key);
+}
+
+// QNBS-v3: null-prototype entity records preserve legal persisted IDs that collide with Object.prototype during bootstrap.
+function normalizeEntityCollection(
+ value: unknown,
+ adapter: EntityAdapter,
+): EntityState | undefined {
+ if (Array.isArray(value)) {
+ const ids: string[] = [];
+ const entities = Object.create(null) as Record;
+ for (const entity of value) {
+ if (!isRecord(entity) || typeof entity['id'] !== 'string' || !entity['id'].trim())
+ return undefined;
+ const id = entity['id'];
+ if (hasOwn(entities, id)) return undefined;
+ ids.push(id);
+ entities[id] = entity as T;
+ }
+ return { ...adapter.getInitialState(), ids, entities };
+ }
+
if (!isRecord(value) || !Array.isArray(value['ids']) || !isRecord(value['entities']))
- return false;
- return value['ids'].every((id: unknown) => typeof id === 'string');
+ return undefined;
+ const sourceIds = value['ids'];
+ const sourceEntities = value['entities'];
+ const ids: string[] = [];
+ const entities = Object.create(null) as Record;
+ const seenIds = new Set();
+ for (const id of sourceIds) {
+ if (typeof id !== 'string' || !id.trim() || seenIds.has(id)) return undefined;
+ if (!hasOwn(sourceEntities, id)) return undefined;
+ const entity = sourceEntities[id];
+ if (!isRecord(entity) || entity['id'] !== id) return undefined;
+ seenIds.add(id);
+ ids.push(id);
+ entities[id] = entity as T;
+ }
+ for (const key of Reflect.ownKeys(sourceEntities)) {
+ if (typeof key !== 'string' || !seenIds.has(key)) return undefined;
+ }
+ return { ...adapter.getInitialState(), ids, entities };
}
-// QNBS-v3: structural project evidence prevents malformed envelopes from suppressing fresh seeding while preserving partial metadata repair.
+// QNBS-v3: canonical desktop collections prevent valid filesystem projects from being discarded while malformed envelopes remain non-authoritative.
export function getPersistedProjectPayload(
project: PersistedRootState['project'] | undefined,
): ProjectData | undefined {
const payload = project?.present?.data ?? project?.data;
if (!isRecord(payload)) return undefined;
+ const characters = normalizeEntityCollection(payload['characters'], charactersAdapter);
+ const worlds = normalizeEntityCollection(payload['worlds'], worldsAdapter);
+ if (!characters || !worlds || !Array.isArray(payload['manuscript'])) return undefined;
+ const outline = payload['outline'];
+ if (outline !== undefined && !Array.isArray(outline)) return undefined;
+ return {
+ ...payload,
+ characters,
+ worlds,
+ outline: outline ?? [],
+ } as unknown as ProjectData;
+}
+
+// QNBS-v3: the active payload is normalized before Redux-Undo sees it, preventing desktop array data from bypassing the canonical Redux state boundary.
+export function normalizePersistedProjectForStore(
+ project: PersistedRootState['project'] | undefined,
+): PersistedRootState['project'] | undefined {
+ const payload = getPersistedProjectPayload(project);
+ if (!payload) return undefined;
+
if (
- !hasEntityStateShape(payload.characters) ||
- !hasEntityStateShape(payload.worlds) ||
- !Array.isArray(payload.outline) ||
- !Array.isArray(payload.manuscript)
- )
- return undefined;
- return payload as ProjectData;
+ project &&
+ isRecord(project.present) &&
+ Array.isArray(project.past) &&
+ Array.isArray(project.future)
+ ) {
+ const present = { ...project.present, data: payload };
+ return {
+ ...project,
+ present,
+ _latestUnfiltered: present,
+ };
+ }
+
+ const present = { data: payload };
+ return {
+ past: [],
+ present,
+ future: [],
+ _latestUnfiltered: present,
+ };
}
// QNBS-v3: seed authority follows hydrated project presence, so settings-only state can still initialize the synthetic project without overwriting real user intent.
diff --git a/tests/unit/services/appBootstrap.test.ts b/tests/unit/services/appBootstrap.test.ts
index e13c8f8fe..8f4864a3f 100644
--- a/tests/unit/services/appBootstrap.test.ts
+++ b/tests/unit/services/appBootstrap.test.ts
@@ -9,6 +9,7 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { charactersAdapter } from '../../../features/project/adapters';
import type { PersistedRootState } from '../../../types';
const createPersistedProject = (overrides: Record = {}) => ({
@@ -21,6 +22,15 @@ const createPersistedProject = (overrides: Record = {}) => ({
...overrides,
});
+const createDesktopProject = (overrides: Record = {}) => ({
+ title: '',
+ logline: '',
+ characters: [],
+ worlds: [],
+ manuscript: [],
+ ...overrides,
+});
+
const h = vi.hoisted(() => ({
isTauri: { value: false },
dbLoadState: vi.fn(),
@@ -50,6 +60,7 @@ vi.mock('../../../services/storageService', () => ({
import {
getPersistedProjectPayload,
loadPersistedRootState,
+ normalizePersistedProjectForStore,
shouldAllowInitialMetadataSeed,
} from '../../../services/appBootstrap';
@@ -210,4 +221,203 @@ describe('shouldAllowInitialMetadataSeed', () => {
expect(getPersistedProjectPayload(state.project)).toEqual(project);
expect(shouldAllowInitialMetadataSeed(state)).toBe(false);
});
+
+ // QNBS-v3: canonicalizes supported desktop persistence shapes before hydration authority can suppress fresh-project seeding.
+ it('normalizes desktop arrays and an omitted outline into the Redux shape', () => {
+ const project = createDesktopProject();
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+ const payload = getPersistedProjectPayload(state.project);
+
+ expect(payload).toMatchObject({
+ outline: [],
+ manuscript: [],
+ });
+ expect(payload?.characters).toEqual({ ids: [], entities: {} });
+ expect(payload?.worlds).toEqual({ ids: [], entities: {} });
+ expect(shouldAllowInitialMetadataSeed(state)).toBe(false);
+ });
+
+ it('preserves all entities when normalizing desktop character and world arrays', () => {
+ const character = { id: 'character-1', name: 'Ada' };
+ const world = { id: 'world-1', name: 'Arcadia' };
+ const project = createDesktopProject({ characters: [character], worlds: [world] });
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+ const payload = getPersistedProjectPayload(state.project);
+
+ expect(payload?.characters).toEqual({
+ ids: ['character-1'],
+ entities: { 'character-1': character },
+ });
+ expect(payload?.worlds).toEqual({ ids: ['world-1'], entities: { 'world-1': world } });
+ });
+
+ it('preserves prototype-named array IDs in an adapter-compatible entity state', () => {
+ const ids = ['__proto__', 'constructor', 'toString'];
+ const project = createDesktopProject({
+ characters: ids.map((id) => ({ id, name: `Character ${id}` })),
+ worlds: ids.map((id) => ({ id, name: `World ${id}` })),
+ });
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+ const payload = getPersistedProjectPayload(state.project);
+
+ expect(payload).toBeDefined();
+ if (!payload) return;
+ expect(Object.getPrototypeOf(payload.characters.entities)).toBeNull();
+ expect(Object.getPrototypeOf(payload.worlds.entities)).toBeNull();
+ for (const id of ids) {
+ expect(payload.characters.ids).toContain(id);
+ expect(Object.hasOwn(payload.characters.entities, id)).toBe(true);
+ expect(payload.characters.entities[id]?.id).toBe(id);
+ expect(payload.worlds.ids).toContain(id);
+ expect(Object.hasOwn(payload.worlds.entities, id)).toBe(true);
+ expect(payload.worlds.entities[id]?.id).toBe(id);
+ }
+
+ const updated = charactersAdapter.updateOne(payload.characters, {
+ id: '__proto__',
+ changes: { name: 'Updated' },
+ });
+ const updatedPrototypeCharacter = Object.getOwnPropertyDescriptor(updated.entities, '__proto__')
+ ?.value as { name: string } | undefined;
+ expect(updatedPrototypeCharacter?.name).toBe('Updated');
+ const selectCharacter = charactersAdapter.getSelectors().selectById;
+ expect(selectCharacter(payload.characters, '__proto__')?.name).toBe('Character __proto__');
+ });
+
+ it('rebuilds an EntityState input into a prototype-safe map without losing IDs', () => {
+ const sourceEntities = Object.create(null) as Record;
+ sourceEntities['constructor'] = { id: 'constructor', name: 'Constructor' };
+ sourceEntities['toString'] = { id: 'toString', name: 'To String' };
+ const project = createDesktopProject({
+ characters: { ids: ['constructor', 'toString'], entities: sourceEntities },
+ });
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+ const payload = getPersistedProjectPayload(state.project);
+
+ expect(payload).toBeDefined();
+ if (!payload) return;
+ expect(payload?.characters.ids).toEqual(['constructor', 'toString']);
+ expect(Object.getPrototypeOf(payload.characters.entities)).toBeNull();
+ expect(payload.characters.entities['constructor']?.name).toBe('Constructor');
+ expect(payload.characters.entities['toString']?.name).toBe('To String');
+ });
+
+ // QNBS-v3: orphaned entity entries must not masquerade as canonical state and suppress fresh-project fallback.
+ it('rejects EntityState entries that are not represented by ids', () => {
+ const project = createDesktopProject({
+ characters: {
+ ids: [],
+ entities: { orphan: { id: 'orphan', name: 'Orphan' } },
+ },
+ });
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+
+ expect(getPersistedProjectPayload(state.project)).toBeUndefined();
+ expect(shouldAllowInitialMetadataSeed(state)).toBe(true);
+ });
+
+ it('normalizes mixed array and EntityState desktop collections', () => {
+ const world = { id: 'world-1', name: 'Arcadia' };
+ const project = createDesktopProject({
+ characters: [{ id: 'character-1', name: 'Ada' }],
+ worlds: { ids: ['world-1'], entities: { 'world-1': world } },
+ });
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+ const payload = getPersistedProjectPayload(state.project);
+
+ expect(payload?.characters.ids).toEqual(['character-1']);
+ expect(payload?.worlds).toEqual({ ids: ['world-1'], entities: { 'world-1': world } });
+ });
+
+ it('accepts the inverse mixed EntityState and array representation', () => {
+ const character = { id: 'character-1', name: 'Ada' };
+ const project = createDesktopProject({
+ characters: { ids: ['character-1'], entities: { 'character-1': character } },
+ worlds: [{ id: 'world-1', name: 'Arcadia' }],
+ });
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+ const payload = getPersistedProjectPayload(state.project);
+
+ expect(payload?.characters).toEqual({
+ ids: ['character-1'],
+ entities: { 'character-1': character },
+ });
+ expect(payload?.worlds.ids).toEqual(['world-1']);
+ });
+
+ it('rejects array entities without stable IDs instead of dropping their content', () => {
+ const project = createDesktopProject({ characters: [{ name: 'Missing ID' }] });
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+
+ expect(getPersistedProjectPayload(state.project)).toBeUndefined();
+ expect(shouldAllowInitialMetadataSeed(state)).toBe(true);
+ });
+
+ it('preserves an already canonical Redux project without changing its content', () => {
+ const project = createPersistedProject({
+ title: 'A story',
+ logline: 'A premise',
+ manuscript: [{ id: 'section-1', title: 'Chapter 1', content: 'Existing work' }],
+ });
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+
+ expect(getPersistedProjectPayload(state.project)).toEqual(project);
+ });
+
+ it('keeps a structurally genuine project authoritative when title is absent', () => {
+ const project = createDesktopProject({
+ manuscript: [{ id: 'section-1', title: 'Existing', content: 'Work' }],
+ });
+ Reflect.deleteProperty(project, 'title');
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+
+ expect(getPersistedProjectPayload(state.project)).toBeDefined();
+ expect(shouldAllowInitialMetadataSeed(state)).toBe(false);
+ });
+
+ it('keeps a structurally genuine project authoritative when logline is absent', () => {
+ const project = createDesktopProject({
+ manuscript: [{ id: 'section-1', title: 'Existing', content: 'Work' }],
+ });
+ Reflect.deleteProperty(project, 'logline');
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+
+ expect(getPersistedProjectPayload(state.project)).toBeDefined();
+ expect(shouldAllowInitialMetadataSeed(state)).toBe(false);
+ });
+
+ it('rejects a present non-array outline instead of hiding malformed structure', () => {
+ const project = createDesktopProject({ outline: {} });
+ const state = { project: { data: project } } as unknown as PersistedRootState;
+
+ expect(getPersistedProjectPayload(state.project)).toBeUndefined();
+ expect(shouldAllowInitialMetadataSeed(state)).toBe(true);
+ });
+
+ it('writes the normalized active payload back into an existing undo envelope', () => {
+ const project = createDesktopProject({
+ characters: [{ id: 'character-1', name: 'Ada' }],
+ worlds: [{ id: 'world-1', name: 'Arcadia' }],
+ });
+ const past = [{ data: createPersistedProject({ title: 'Past' }) }];
+ const future = [{ data: createPersistedProject({ title: 'Future' }) }];
+ const envelope = {
+ past,
+ present: { data: project },
+ future,
+ group: 'project-edit',
+ _latestUnfiltered: { data: project },
+ } as unknown as PersistedRootState['project'];
+
+ const normalized = normalizePersistedProjectForStore(envelope);
+
+ expect(normalized?.past).toBe(past);
+ expect(normalized?.future).toBe(future);
+ expect(normalized?.present?.data.characters).toEqual({
+ ids: ['character-1'],
+ entities: { 'character-1': { id: 'character-1', name: 'Ada' } },
+ });
+ expect(normalized?.present?.data.outline).toEqual([]);
+ expect(normalized?._latestUnfiltered).toEqual({ data: normalized?.present?.data });
+ });
});
diff --git a/tests/unit/store.test.ts b/tests/unit/store.test.ts
index fd0ad090c..b14301b5e 100644
--- a/tests/unit/store.test.ts
+++ b/tests/unit/store.test.ts
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
+import { normalizePersistedProjectForStore } from '../../services/appBootstrap';
+import type { PersistedRootState } from '../../types';
// ---------------------------------------------------------------------------
// Mocks
@@ -74,6 +76,49 @@ describe('setupStore', () => {
expect(Array.isArray(state['project']?.future)).toBe(true);
expect(state['project']?.present).toBeTruthy();
});
+
+ // QNBS-v3: the store must receive the canonical active payload so desktop arrays cannot bypass EntityState selectors after undo hydration.
+ it('hydrates normalized active data inside an existing undo envelope', async () => {
+ const { setupStore } = await import('../../app/store');
+ const project = {
+ title: '',
+ logline: '',
+ characters: [{ id: '__proto__', name: 'Ada' }],
+ worlds: [{ id: 'constructor', name: 'Arcadia' }],
+ manuscript: [],
+ };
+ const normalizedProject = normalizePersistedProjectForStore({
+ past: [],
+ present: { data: project },
+ future: [],
+ } as unknown as PersistedRootState['project']);
+
+ expect(normalizedProject).toBeDefined();
+ if (!normalizedProject) return;
+ const store = setupStore({ project: normalizedProject } as PersistedRootState);
+ const state = store.getState() as unknown as {
+ project: {
+ present: {
+ data: {
+ characters: { entities: Record };
+ worlds: { entities: Record };
+ };
+ };
+ };
+ };
+
+ const prototypeCharacter = Object.getOwnPropertyDescriptor(
+ state.project.present.data.characters.entities,
+ '__proto__',
+ )?.value as { id: string } | undefined;
+ const constructorWorld = Object.getOwnPropertyDescriptor(
+ state.project.present.data.worlds.entities,
+ 'constructor',
+ )?.value as { id: string } | undefined;
+ expect(prototypeCharacter?.id).toBe('__proto__');
+ expect(constructorWorld?.id).toBe('constructor');
+ expect(Object.getPrototypeOf(state.project.present.data.characters.entities)).toBeNull();
+ });
});
describe('rootReducer', () => {