diff --git a/README.md b/README.md index c55242b9b..382501667 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7516+ tests / 602 files + 7538+ tests / 602 files Codecov Coverage License MIT CI Status @@ -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` | @@ -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 @@ -713,8 +713,8 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt 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) diff --git a/components/StorageErrorScreen.tsx b/components/StorageErrorScreen.tsx index 182ef787c..95103bab5 100644 --- a/components/StorageErrorScreen.tsx +++ b/components/StorageErrorScreen.tsx @@ -9,6 +9,8 @@ export interface StorageErrorCopy { storageUnavailable: string; projectUnavailable: string; projectIoUnavailable: string; + projectUnsupported: string; + projectMigrationGap: string; reload: string; retry: string; recover: string; @@ -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', @@ -54,6 +60,8 @@ export async function loadStorageErrorCopy(): Promise { storageUnavailable, projectUnavailable, projectIoUnavailable, + projectUnsupported, + projectMigrationGap, reload, retry, recover, @@ -78,6 +86,14 @@ export async function loadStorageErrorCopy(): Promise { '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), @@ -97,6 +113,8 @@ export async function loadStorageErrorCopy(): Promise { storageUnavailable, projectUnavailable, projectIoUnavailable, + projectUnsupported, + projectMigrationGap, reload, retry, recover, @@ -117,6 +135,8 @@ function getFailureMessage( const messages: Record = { 'project-corrupt': copy.projectUnavailable, 'project-io': copy.projectIoUnavailable, + 'project-unsupported': copy.projectUnsupported, + 'project-migration-gap': copy.projectMigrationGap, storage: copy.storageUnavailable, }; return messages[failureKind]; @@ -366,7 +386,15 @@ export function StorageErrorScreen({ {getFailureMessage(failureKind, copy)}

- + { @@ -129,8 +129,12 @@ export class FsAssetStore extends FsSnapshotStore { async deleteBinderAsset(projectId: string, assetId: string): Promise { 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); } } @@ -208,6 +212,6 @@ export class FsAssetStore extends FsSnapshotStore { } }), ); - }); + }, projectId); } } diff --git a/services/fs/codexFsStore.ts b/services/fs/codexFsStore.ts index 9190c6b20..e03db8b85 100644 --- a/services/fs/codexFsStore.ts +++ b/services/fs/codexFsStore.ts @@ -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 { @@ -55,8 +55,12 @@ export class FsCodexStore extends FsSettingsStore { async deleteStoryCodex(projectId: string): Promise { 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); } } @@ -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 { @@ -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); } } diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts index 7186cf86d..3eecf0e18 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -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(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(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.', + ); } } @@ -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 {} + + 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(operation: () => Promise): Promise { + protected async withLegacyRoutingOperation( + operation: () => Promise, + projectId?: string, + ): Promise { let release!: () => void; const gate = new Promise((resolve) => { release = resolve; @@ -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) { diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts index 27264af05..3e10a751e 100644 --- a/services/fs/projectFsStore.ts +++ b/services/fs/projectFsStore.ts @@ -6,10 +6,18 @@ import type { EntityState } from '@reduxjs/toolkit'; import { scheduleCoreProjectValidation } from '../../features/project/coreValidationShadow'; +import { + CURRENT_PROJECT_SCHEMA_VERSION, + type ProjectVersionClassification, +} from '../../features/project/projectSchemaVersion'; import type { Character, StoryProject, World } from '../../types'; import { getStaticTranslation } from '../i18n/staticTranslate'; import { logger } from '../logger'; -import { parseImportedProjectJson } from '../projectImportSchema'; +import { + admitCanonicalProjectDocument, + type CanonicalProjectSchemaResult, +} from '../projectDocument'; +import { importedProjectJsonSchema, parseImportedProjectJson } from '../projectImportSchema'; import { normalizeSaveProjectInputToStoryProject, type ProjectQuarantineResult, @@ -20,6 +28,7 @@ import { FsAssetStore } from './assetFsStore'; import { compressData, decompressData, + decompressJsonText, retryFs, sanitizePathSegment, type TauriApis, @@ -27,7 +36,6 @@ import { } from './fsCore'; import { evidenceFromPersistedMetadata, - hasLegacyMissingProjectId, isLegacyInvalidProjectId, LEGACY_AUXILIARY_METADATA_KEY, LEGACY_PROJECT_DIRECTORY_METADATA_KEY, @@ -49,15 +57,26 @@ import { // 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', + public readonly reason: 'corrupt' | 'io-error' | 'unsupported-version', message: string, public readonly projectId: string, + public readonly classification?: ProjectVersionClassification, ) { super(message); this.name = 'ProjectLoadError'; } } +// QNBS-v3: legacy admissions stay readable but cannot enter the ordinary writer until migration fencing exists. +export class ProjectWritebackError extends Error { + constructor(public readonly projectId: string) { + super( + `Project "${projectId}" was loaded from an unversioned legacy source and cannot be saved until durable migration fencing is available.`, + ); + this.name = 'ProjectWritebackError'; + } +} + // QNBS-v3: a stable deletion outcome keeps incomplete legacy cleanup retryable without exposing filesystem details. export class ProjectDeleteError extends Error { constructor( @@ -143,9 +162,200 @@ 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. +// QNBS-v3: reuse nested import validators while returning the original object so opaque fields remain present until raw-carrier writeback. +const storedProjectSchema = { + safeParse(value: unknown): CanonicalProjectSchemaResult { + const result = importedProjectJsonSchema.safeParse(value); + if (!result.success) { + return { + success: false, + error: { + issues: result.error.issues.map((issue) => ({ + path: issue.path, + message: issue.message, + })), + }, + }; + } + if (looksLikeStoryProject(value)) { + return { success: true, data: value }; + } + return { + success: false, + error: { + issues: [ + { + path: [], + message: 'Stored project is missing the required project-owned fields.', + }, + ], + }, + }; + }, +}; + +// QNBS-v3: keep the synthetic legacy-to-V1 marker out of editable state until fenced durable migration exists. +function withoutSyntheticLegacySchemaVersion(project: StoryProject): StoryProject { + const projection = { ...(project as unknown as Record) }; + delete projection['schemaVersion']; + return projection as unknown as StoryProject; +} + +// QNBS-v3: fresh writer output is explicitly CURRENT while legacy admissions remain fenced before this boundary. +function withCurrentSchemaVersion(project: StoryProject): StoryProject { + const projection = { ...(project as unknown as Record) }; + if (!Object.hasOwn(projection, 'schemaVersion')) { + projection['schemaVersion'] = CURRENT_PROJECT_SCHEMA_VERSION; + } + return projection as unknown as StoryProject; +} + +type LegacyAdmissionRecord = { + sourceDirectoryId: string; + persistedProjectId: string | null; + sourceOwnedWriterIdentities: ReadonlySet; + sharedFallbackWriterIdentities: ReadonlySet; + writerIdentities: ReadonlySet; + editable: boolean; +}; + +const LEGACY_FALLBACK_WRITER_IDENTITIES = ['browser-project', 'default', 'project'] as const; + +// QNBS-v3: one source-owned record keeps the canonical directory, embedded identity, aliases, and write verdict together. export class FsProjectStore extends FsAssetStore { private readonly verifiedLegacyProjectDirectories = new Set(); + private readonly legacyAdmissionRecords = new Map(); + + private writerIdentityAliases(projectId: string): Set { + const identities = new Set([projectId]); + const safeProjectId = projectPathSegment(projectId); + if (safeProjectId) identities.add(safeProjectId); + return identities; + } + + private registerLegacyAdmission(sourceDirectoryId: string, project: StoryProject): void { + const persistedId = persistedProjectId(project); + const sourceOwnedWriterIdentities = new Set([sourceDirectoryId]); + const sharedFallbackWriterIdentities = new Set(); + if (typeof persistedId === 'string') { + sourceOwnedWriterIdentities.add(persistedId); + const safePersistedId = projectPathSegment(persistedId); + if (safePersistedId) sourceOwnedWriterIdentities.add(safePersistedId); + if (!safePersistedId) { + for (const fallback of LEGACY_FALLBACK_WRITER_IDENTITIES) { + sourceOwnedWriterIdentities.add(fallback); + } + } + } else { + // QNBS-v3: an ID-less legacy inspection is read-only; shared fallback names must not become global write claims that can block an unrelated CURRENT source. + } + const writerIdentities = new Set([ + ...sourceOwnedWriterIdentities, + ...sharedFallbackWriterIdentities, + ]); + this.legacyAdmissionRecords.set(sourceDirectoryId, { + sourceDirectoryId, + persistedProjectId: typeof persistedId === 'string' ? persistedId : null, + sourceOwnedWriterIdentities, + sharedFallbackWriterIdentities, + writerIdentities, + editable: false, + }); + } + + private clearLegacyAdmissionForSource(sourceDirectoryId: string): void { + this.legacyAdmissionRecords.delete(sourceDirectoryId); + for (const [recordSource, record] of this.legacyAdmissionRecords) { + const sourceOwnedWriterIdentities = new Set( + [...record.sourceOwnedWriterIdentities].filter( + (identity) => projectPathSegment(identity) !== sourceDirectoryId, + ), + ); + if (sourceOwnedWriterIdentities.size === record.sourceOwnedWriterIdentities.size) { + continue; + } + const writerIdentities = new Set([ + ...sourceOwnedWriterIdentities, + ...record.sharedFallbackWriterIdentities, + ]); + if (writerIdentities.size === 0) { + this.legacyAdmissionRecords.delete(recordSource); + } else { + this.legacyAdmissionRecords.set(recordSource, { + ...record, + sourceOwnedWriterIdentities, + writerIdentities, + }); + } + } + } + + private legacyAdmissionsForWriter(projectId: string): LegacyAdmissionRecord[] { + const aliases = this.writerIdentityAliases(projectId); + return [...this.legacyAdmissionRecords.values()].filter((record) => + [...aliases].some((identity) => record.writerIdentities.has(identity)), + ); + } + + private async currentProjectSourceIsAdmitted(sourceDirectoryId: string): Promise { + const projectFileId = projectPathSegment(sourceDirectoryId); + if (!projectFileId) return false; + try { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const projectFile = await apis.join(appDataPath, 'projects', projectFileId, 'project.json'); + if (!(await apis.exists(projectFile))) return false; + const admission = admitCanonicalProjectDocument( + decompressJsonText(await retryFs(() => apis.readTextFile(projectFile))), + storedProjectSchema, + ); + return admission.status === 'CURRENT' && admission.canonical?.projection !== null; + } catch { + return false; + } + } + + // QNBS-v3: current-source validation may release only aliases proven to belong to that current source. + protected override async assertProjectWriteAuthority(projectId: string): Promise { + const admissions = this.legacyAdmissionsForWriter(projectId); + if (admissions.length === 0) return; + + const aliases = this.writerIdentityAliases(projectId); + const hasSharedFallbackConflict = admissions.some((record) => + [...aliases].some((identity) => record.sharedFallbackWriterIdentities.has(identity)), + ); + if (hasSharedFallbackConflict) { + throw new ProjectWritebackError(projectId); + } + + const sourceDirectoryId = projectPathSegment(projectId); + if (sourceDirectoryId && (await this.currentProjectSourceIsAdmitted(sourceDirectoryId))) { + this.clearLegacyAdmissionForSource(sourceDirectoryId); + return; + } + throw new ProjectWritebackError(projectId); + } + + protected override isProjectWriteAuthorityError(error: unknown): boolean { + return error instanceof ProjectWritebackError; + } + + private canonicalLegacyProjection( + project: StoryProject, + sourceDirectoryId: string, + ): StoryProject { + const persistedId = persistedProjectId(project); + const safePersistedId = + typeof persistedId === 'string' ? projectPathSegment(persistedId) : null; + if (safePersistedId && safePersistedId !== sourceDirectoryId) { + return { + ...project, + id: sourceDirectoryId, + [LEGACY_PROJECT_DIRECTORY_METADATA_KEY]: sourceDirectoryId, + } as StoryProject; + } + return project; + } private async inspectLegacyAuxiliaryEvidence( project: StoryProject, @@ -282,8 +492,8 @@ export class FsProjectStore extends FsAssetStore { this.verifiedLegacyProjectDirectories.add(safeProjectId); return project; } - if (!hasLegacyMissingProjectId(project, safeProjectId)) return project; this.verifiedLegacyProjectDirectories.add(safeProjectId); + // QNBS-v3: retain the source directory for every missing-ID load so a later save cannot drift to a title-derived path. return legacyProjectWithDirectory(project, safeProjectId); } @@ -396,15 +606,34 @@ export class FsProjectStore extends FsAssetStore { if (!validatedTarget) { throw new ProjectSnapshotRestoreError('target-unavailable'); } + try { + await this.assertProjectWriteAuthority(targetDirectory); + } catch { + throw new ProjectSnapshotRestoreError('target-unavailable'); + } - const snapshot = await super.getSnapshotData(snapshotId); - if (!looksLikeStoryProject(snapshot)) { - throw new ProjectSnapshotRestoreError( - snapshot === null ? 'snapshot-unavailable' : 'snapshot-invalid', - ); + let snapshotJson: string | null; + try { + snapshotJson = await this.getSnapshotJsonText(snapshotId); + } catch (error) { + logger.error('Failed to read snapshot for restore', { + snapshotId, + error: error instanceof Error ? error.message : String(error), + }); + throw new ProjectSnapshotRestoreError('snapshot-unavailable'); + } + if (snapshotJson === null) { + throw new ProjectSnapshotRestoreError('snapshot-unavailable'); + } + + // QNBS-v3: admit decompressed snapshot text before parsing so duplicate and unsafe version tokens remain visible to the canonical gate. + const snapshotAdmission = admitCanonicalProjectDocument(snapshotJson, storedProjectSchema); + const admittedSnapshot = snapshotAdmission.canonical?.projection; + if (snapshotAdmission.status === 'REFUSED' || !admittedSnapshot) { + throw new ProjectSnapshotRestoreError('snapshot-invalid'); } - const snapshotProjectId = persistedProjectId(snapshot); + const snapshotProjectId = persistedProjectId(admittedSnapshot); if (typeof snapshotProjectId !== 'string') { throw new ProjectSnapshotRestoreError('snapshot-owner-unverifiable'); } @@ -416,7 +645,7 @@ export class FsProjectStore extends FsAssetStore { throw new ProjectSnapshotRestoreError('snapshot-owner-mismatch'); } - const restored = { ...(snapshot as unknown as Record) }; + const restored = { ...(admittedSnapshot as unknown as Record) }; delete restored['id']; delete restored[LEGACY_PROJECT_DIRECTORY_METADATA_KEY]; delete restored[LEGACY_AUXILIARY_METADATA_KEY]; @@ -430,6 +659,8 @@ export class FsProjectStore extends FsAssetStore { restored['id'] = safeTargetId; } + restored['schemaVersion'] = CURRENT_PROJECT_SCHEMA_VERSION; + const validatedTargetDirectory = legacyProjectDirectory(validatedTarget); if (validatedTargetDirectory) { restored[LEGACY_PROJECT_DIRECTORY_METADATA_KEY] = validatedTargetDirectory; @@ -488,7 +719,6 @@ export class FsProjectStore extends FsAssetStore { } } else { projectId = safeProjectId; - this.verifiedLegacyProjectDirectories.delete(projectId); } } else { const legacyDirectory = legacyProjectDirectory(flat); @@ -498,6 +728,13 @@ export class FsProjectStore extends FsAssetStore { : (projectPathSegment(flat.title || '') ?? 'project'); } + await this.assertProjectWriteAuthority(projectId); + if (suppliedProjectId) { + this.verifiedLegacyProjectDirectories.delete(projectId); + } + + projectToPersist = withCurrentSchemaVersion(projectToPersist); + // Auto-snapshot: fire-and-forget, mirrors dbService behaviour if (Date.now() - this.lastAutoSnapshotTime > this.AUTO_SNAPSHOT_INTERVAL) { this.lastAutoSnapshotTime = Date.now(); @@ -569,6 +806,23 @@ export class FsProjectStore extends FsAssetStore { return this.withLegacyRoutingOperation(() => this.loadProjectUnlocked(projectId)); } + // QNBS-v3: desktop bootstrap uses a distinct admission boundary so a readable legacy projection cannot enter the ordinary editable Redux store. + async loadProjectForEditing(projectId: string): Promise { + return this.withLegacyRoutingOperation(async () => { + const project = await this.loadProjectUnlocked(projectId); + const safeProjectId = projectPathSegment(projectId); + if (project && safeProjectId && this.legacyAdmissionRecords.has(safeProjectId)) { + throw new ProjectLoadError( + 'unsupported-version', + `The legacy project file for "${projectId}" is readable but cannot enter the editable application state. The file has not been changed.`, + projectId, + 'LEGACY_UNVERSIONED', + ); + } + return project; + }); + } + private async loadProjectUnlocked(projectId: string): Promise { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); @@ -581,6 +835,7 @@ export class FsProjectStore extends FsAssetStore { try { if (!(await apis.exists(projectFile))) { this.clearLegacyAuxiliaryPolicy(safeProjectId); + this.clearLegacyAdmissionForSource(safeProjectId); return null; } content = await retryFs(() => apis.readTextFile(projectFile)); @@ -594,30 +849,59 @@ export class FsProjectStore extends FsAssetStore { } let project: StoryProject; + let classification: ProjectVersionClassification | undefined; + let legacyAdmission = false; try { - const parsed = decompressData(content); - if (!looksLikeStoryProject(parsed)) { - throw new Error('Parsed content is not project-shaped (missing title/manuscript).'); + const admission = admitCanonicalProjectDocument( + decompressJsonText(content), + storedProjectSchema, + ); + legacyAdmission = admission.status === 'LEGACY_TO_V1'; + classification = admission.source.classification; + if (admission.canonical?.projection === null || admission.canonical === null) { + throw new Error( + admission.source.error ?? + `Project admission refused for ${admission.source.classification} input.`, + ); } - project = parsed; + project = + admission.status === 'LEGACY_TO_V1' + ? withoutSyntheticLegacySchemaVersion(admission.canonical.projection) + : admission.canonical.projection; } catch (error) { logger.error('Failed to parse project file (corrupt data):', error); 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.`, + classification && classification !== 'MALFORMED' ? 'unsupported-version' : 'corrupt', + classification && classification !== 'MALFORMED' + ? 'The saved project file for "' + + projectId + + '" was refused as ' + + classification + + '. The file has not been changed.' + : 'The saved project file for "' + + projectId + + '" appears to be corrupted and could not be read. The file has not been deleted.', projectId, + classification, ); } - // QNBS-v3: schedule observation after this async load resolves so validation cannot delay or alter the load result. + // QNBS-v3: admission remains non-destructive until durable migration and raw-carrier writeback are fenced. const migratedProject = await this.migrateLegacyProjectIdentity( project, safeProjectId, apis, appDataPath, ); + if (legacyAdmission) { + this.registerLegacyAdmission(safeProjectId, project); + } else { + this.clearLegacyAdmissionForSource(safeProjectId); + } scheduleCoreProjectValidation(migratedProject); - return migratedProject; + return legacyAdmission + ? this.canonicalLegacyProjection(migratedProject, safeProjectId) + : migratedProject; } async listProjects(): Promise { @@ -740,6 +1024,7 @@ export class FsProjectStore extends FsAssetStore { } await retryFs(() => apis.rename(projectPath, preservedPath)); this.clearLegacyAuxiliaryPolicy(safeProjectId); + this.clearLegacyAdmissionForSource(safeProjectId); return { projectId: safeProjectId, path: preservedPath }; } catch (error) { let sourceExists: boolean; @@ -756,11 +1041,13 @@ export class FsProjectStore extends FsAssetStore { } if (!sourceExists && preservedExists) { this.clearLegacyAuxiliaryPolicy(safeProjectId); + this.clearLegacyAdmissionForSource(safeProjectId); return { projectId: safeProjectId, path: preservedPath }; } if (!sourceExists) { await releaseReservation(); this.clearLegacyAuxiliaryPolicy(safeProjectId); + this.clearLegacyAdmissionForSource(safeProjectId); throw new ProjectQuarantineError('source-missing'); } await releaseReservation(); @@ -828,6 +1115,7 @@ export class FsProjectStore extends FsAssetStore { throw new ProjectDeleteError(); } this.verifiedLegacyProjectDirectories.delete(safeProjectId); + this.clearLegacyAdmissionForSource(safeProjectId); this.clearLegacyAuxiliaryPolicy(safeProjectId); } diff --git a/services/fs/snapshotFsStore.ts b/services/fs/snapshotFsStore.ts index c689fc5cb..3f5d6c666 100644 --- a/services/fs/snapshotFsStore.ts +++ b/services/fs/snapshotFsStore.ts @@ -9,7 +9,7 @@ import { FsCodexStore } from './codexFsStore'; import { compressData, countProjectWords, - decompressData, + decompressJsonText, retryFs, writeTextFileAtomic, } from './fsCore'; @@ -46,24 +46,37 @@ export class FsSnapshotStore extends FsCodexStore { return id; } - async getSnapshotData(snapshotId: number): Promise { - try { - const apis = await this.getApis(); - const appDataPath = await this.ensureAppDataPath(); - const snapshotFile = await apis.join(appDataPath, 'snapshots', `${snapshotId}.json`); + protected async getSnapshotJsonText(snapshotId: number): Promise { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const snapshotFile = await apis.join(appDataPath, 'snapshots', `${snapshotId}.json`); - if (!(await apis.exists(snapshotFile))) { - return null; - } + if (!(await apis.exists(snapshotFile))) { + return null; + } - const content = await retryFs(() => apis.readTextFile(snapshotFile)); - const envelope = JSON.parse(content) as SnapshotEnvelope; - // New format: envelope with compressed data field - if (envelope && typeof envelope.data === 'string') { - return decompressData(envelope.data); - } - // Legacy format: raw project data stored directly - return envelope; + const content = await retryFs(() => apis.readTextFile(snapshotFile)); + let envelope: unknown; + try { + envelope = JSON.parse(content); + } catch { + // QNBS-v3: preserve malformed raw snapshot text so canonical admission can classify it without a lossy parse/re-serialize step. + return content; + } + if ( + typeof envelope === 'object' && + envelope !== null && + typeof (envelope as { data?: unknown }).data === 'string' + ) { + return decompressJsonText((envelope as { data: string }).data); + } + return content; + } + + async getSnapshotData(snapshotId: number): Promise { + try { + const raw = await this.getSnapshotJsonText(snapshotId); + return raw === null ? null : JSON.parse(raw); } catch (error) { logger.error('Failed to load snapshot:', error); return null; diff --git a/services/libraryBackupService.ts b/services/libraryBackupService.ts index 52b89b8fc..e12f6bb19 100644 --- a/services/libraryBackupService.ts +++ b/services/libraryBackupService.ts @@ -134,6 +134,8 @@ export async function collectLibraryBackupPayload( } catch (error) { // QNBS-v3 (codex P1): only the expected corruption/I-O case is swallowed — an unexpected bug must still surface, not be silently absorbed as "skip this project". if (!(error instanceof ProjectLoadError)) throw error; + // QNBS-v3: an unsupported project must make the backup fail visibly instead of producing an incomplete archive. + if (error.reason === 'unsupported-version') throw error; logger.warn('collectLibraryBackupPayload: skipping unreadable project', { projectId, reason: error.reason, diff --git a/services/startupRecovery.tsx b/services/startupRecovery.tsx index ef6ce9d26..c8a99595d 100644 --- a/services/startupRecovery.tsx +++ b/services/startupRecovery.tsx @@ -51,7 +51,11 @@ export async function renderProjectInitializationFailure( }, } : {})} - {...(failureKind === 'project-io' ? { onRetry: () => window.location.reload() } : {})} + {...(failureKind === 'project-io' || + failureKind === 'project-unsupported' || + failureKind === 'project-migration-gap' + ? { onRetry: () => window.location.reload() } + : {})} {...(canReset ? { onReset: async () => { diff --git a/services/startupRecoveryPolicy.ts b/services/startupRecoveryPolicy.ts index cfb4b013c..11999926a 100644 --- a/services/startupRecoveryPolicy.ts +++ b/services/startupRecoveryPolicy.ts @@ -1,7 +1,12 @@ import { ProjectLoadError } from './fs/projectFsStore'; export type StartupStorageBackend = 'indexeddb' | 'filesystem'; -export type StartupRecoveryFailureKind = 'storage' | 'project-corrupt' | 'project-io'; +export type StartupRecoveryFailureKind = + | 'storage' + | 'project-corrupt' + | 'project-io' + | 'project-unsupported' + | 'project-migration-gap'; export interface StartupRecoveryActions { failureKind: StartupRecoveryFailureKind; @@ -16,11 +21,15 @@ export function getStartupRecoveryActions( ): StartupRecoveryActions { const projectLoadError = error instanceof ProjectLoadError ? error : null; const failureKind: StartupRecoveryFailureKind = - projectLoadError?.reason === 'corrupt' - ? 'project-corrupt' - : projectLoadError || backend === 'filesystem' - ? 'project-io' - : 'storage'; + projectLoadError?.classification === 'UNSUPPORTED_OLDER' + ? 'project-migration-gap' + : projectLoadError?.reason === 'unsupported-version' + ? 'project-unsupported' + : projectLoadError?.reason === 'corrupt' + ? 'project-corrupt' + : projectLoadError || backend === 'filesystem' + ? 'project-io' + : 'storage'; return { failureKind, canQuarantine: failureKind === 'project-corrupt' && backend === 'filesystem', diff --git a/services/storageBackend.ts b/services/storageBackend.ts index fa6754023..264dcbf2a 100644 --- a/services/storageBackend.ts +++ b/services/storageBackend.ts @@ -75,6 +75,8 @@ export function normalizeSaveProjectInputToStoryProject(project: SaveProjectInpu export interface StorageBackend { saveProject(project: SaveProjectInput): Promise; loadProject(projectId: string): Promise; + /** QNBS-v3: editable desktop admission must reject readable legacy projections before Redux hydration. */ + loadProjectForEditing?(projectId: string): Promise; listProjects(): Promise; 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. */ diff --git a/services/storageService.ts b/services/storageService.ts index 37e52eff9..90b730b9e 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -84,6 +84,13 @@ class StorageManager { return backend.loadProject(projectId); } + // QNBS-v3: bootstrap asks for an editable admission explicitly; backends without a distinct boundary retain their existing single-project behavior. + async loadProjectForEditing(projectId: string): Promise { + const backend = await this.getBackend(); + if (backend.loadProjectForEditing) return backend.loadProjectForEditing(projectId); + return backend.loadProject(projectId); + } + async listProjects(): Promise { const backend = await this.getBackend(); return backend.listProjects(); diff --git a/tests/unit/libraryBackupService.test.ts b/tests/unit/libraryBackupService.test.ts index cdbfb3c88..3a9615b45 100644 --- a/tests/unit/libraryBackupService.test.ts +++ b/tests/unit/libraryBackupService.test.ts @@ -115,6 +115,26 @@ describe('libraryBackupService — partial corruption (DA-01)', () => { expect(corrupt?.project).toBeNull(); }); + // QNBS-v3: unsupported projects must never be represented as a successful backup with an omitted payload. + it('fails visibly when a project uses an unsupported schema version', async () => { + const { storageService } = await import('../../services/storageService'); + const { ProjectLoadError } = await import('../../services/fs/projectFsStore'); + vi.mocked(storageService.listProjects).mockResolvedValue(['future']); + vi.mocked(storageService.loadProject).mockRejectedValue( + new ProjectLoadError( + 'unsupported-version', + 'The saved project uses a schema version this build cannot edit.', + 'future', + 'FUTURE', + ), + ); + const { collectLibraryBackupPayload } = await import('../../services/libraryBackupService'); + await expect(collectLibraryBackupPayload()).rejects.toMatchObject({ + reason: 'unsupported-version', + projectId: 'future', + }); + }); + // QNBS-v3 (codex P1): an unexpected (non-ProjectLoadError) failure must still surface, not be silently swallowed as if it were an ordinary corrupt project. it('rethrows an unexpected (non-ProjectLoadError) failure instead of silently swallowing it', async () => { const { storageService } = await import('../../services/storageService'); diff --git a/tests/unit/services/appBootstrap.test.ts b/tests/unit/services/appBootstrap.test.ts index b6bfec500..675460e12 100644 --- a/tests/unit/services/appBootstrap.test.ts +++ b/tests/unit/services/appBootstrap.test.ts @@ -37,6 +37,7 @@ const h = vi.hoisted(() => ({ loadSettings: vi.fn(), listProjects: vi.fn(), loadProject: vi.fn(), + loadProjectForEditing: vi.fn(), getActiveProjectId: vi.fn(), })); @@ -53,6 +54,7 @@ vi.mock('../../../services/storageService', () => ({ loadSettings: h.loadSettings, listProjects: h.listProjects, loadProject: h.loadProject, + loadProjectForEditing: h.loadProjectForEditing, getActiveProjectId: h.getActiveProjectId, }, })); @@ -72,6 +74,7 @@ describe('loadPersistedRootState', () => { h.loadSettings.mockResolvedValue(null); h.listProjects.mockResolvedValue([]); h.loadProject.mockResolvedValue(null); + h.loadProjectForEditing.mockResolvedValue(null); h.getActiveProjectId.mockResolvedValue(null); }); @@ -89,14 +92,14 @@ describe('loadPersistedRootState', () => { h.isTauri.value = true; h.loadSettings.mockResolvedValue({ theme: 'sepia' }); h.listProjects.mockResolvedValue(['proj-1']); - h.loadProject.mockResolvedValue({ id: 'proj-1', title: 'My Novel' }); + h.loadProjectForEditing.mockResolvedValue({ id: 'proj-1', title: 'My Novel' }); const result = await loadPersistedRootState(); expect(h.dbLoadState).not.toHaveBeenCalled(); expect(h.loadSettings).toHaveBeenCalledTimes(1); expect(h.listProjects).toHaveBeenCalledTimes(1); - expect(h.loadProject).toHaveBeenCalledWith('proj-1'); + expect(h.loadProjectForEditing).toHaveBeenCalledWith('proj-1'); expect(result?.settings).toEqual({ theme: 'sepia' }); // Flat shape — index.tsx's existing hydration logic reconstructs the redux-undo envelope. expect(result?.project).toEqual({ data: { id: 'proj-1', title: 'My Novel' } }); @@ -122,30 +125,30 @@ describe('loadPersistedRootState', () => { h.isTauri.value = true; h.listProjects.mockResolvedValue(['proj-1', 'proj-2']); h.getActiveProjectId.mockResolvedValue(null); - h.loadProject.mockResolvedValue({ id: 'proj-1', title: 'First' }); + h.loadProjectForEditing.mockResolvedValue({ id: 'proj-1', title: 'First' }); await loadPersistedRootState(); - expect(h.loadProject).toHaveBeenCalledTimes(1); - expect(h.loadProject).toHaveBeenCalledWith('proj-1'); + expect(h.loadProjectForEditing).toHaveBeenCalledTimes(1); + expect(h.loadProjectForEditing).toHaveBeenCalledWith('proj-1'); }); it('on desktop, prefers the active-project marker over the first listed project id', async () => { h.isTauri.value = true; h.listProjects.mockResolvedValue(['proj-1', 'proj-2']); h.getActiveProjectId.mockResolvedValue('proj-2'); - h.loadProject.mockResolvedValue({ id: 'proj-2', title: 'Second' }); + h.loadProjectForEditing.mockResolvedValue({ id: 'proj-2', title: 'Second' }); await loadPersistedRootState(); - expect(h.loadProject).toHaveBeenCalledTimes(1); - expect(h.loadProject).toHaveBeenCalledWith('proj-2'); + expect(h.loadProjectForEditing).toHaveBeenCalledTimes(1); + expect(h.loadProjectForEditing).toHaveBeenCalledWith('proj-2'); }); it('on desktop, falls back to the first listed project id when the marker points to a deleted project', async () => { h.isTauri.value = true; h.listProjects.mockResolvedValue(['proj-1', 'proj-2']); h.getActiveProjectId.mockResolvedValue('proj-deleted'); - h.loadProject.mockResolvedValue({ id: 'proj-1', title: 'First' }); + h.loadProjectForEditing.mockResolvedValue({ id: 'proj-1', title: 'First' }); await loadPersistedRootState(); - expect(h.loadProject).toHaveBeenCalledTimes(1); - expect(h.loadProject).toHaveBeenCalledWith('proj-1'); + expect(h.loadProjectForEditing).toHaveBeenCalledTimes(1); + expect(h.loadProjectForEditing).toHaveBeenCalledWith('proj-1'); }); }); diff --git a/tests/unit/services/fs/fsCore.test.ts b/tests/unit/services/fs/fsCore.test.ts index 0e8f5a88b..68bd95b32 100644 --- a/tests/unit/services/fs/fsCore.test.ts +++ b/tests/unit/services/fs/fsCore.test.ts @@ -10,8 +10,9 @@ import type { TauriApis } from '../../../../services/fs/fsCore'; import { compressData, countProjectWords, - decompressData, DecompressionError, + decompressData, + decompressJsonText, decryptText, encryptText, retryFs, @@ -152,6 +153,14 @@ describe('compressData / decompressData', () => { it('throws DecompressionError (not a bare SyntaxError) for malformed uncompressed JSON', () => { expect(() => decompressData('this is not json {{{')).toThrow(DecompressionError); }); + + it('returns decompressed JSON text without normalizing numeric literals', () => { + const source = '{"opaque":9007199254740993.0000000000001}'; + const compressed = `\x00lz1\x00${LZString.compressToUTF16(source)}`; + + expect(decompressJsonText(source)).toBe(source); + expect(decompressJsonText(compressed)).toBe(source); + }); }); describe('encryptText / decryptText', () => { diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index 431ab524f..d806316e0 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -62,6 +62,16 @@ import { compressData, decompressData } from '../../../../services/fs/fsCore'; import { FsProjectStore } from '../../../../services/fs/projectFsStore'; import { logger } from '../../../../services/logger'; +// QNBS-v3: shared Binder fixture keeps schema-complete asset nodes consistent across filesystem cleanup and routing tests. +const legacyBinderNode = (binderAssetId: string) => ({ + id: `binder-${binderAssetId}`, + parentId: null, + type: 'pdf' as const, + title: `Legacy ${binderAssetId}`, + sortIndex: 0, + binderAssetId, +}); + interface FakeFs { apis: TauriApis; text: Map; @@ -166,6 +176,7 @@ afterEach(() => { describe('FsProjectStore — projects', () => { const project = { id: 'p1', + schemaVersion: 1, title: 'My Novel', logline: 'A tale', manuscript: [{ id: 's1', title: 'Ch1', content: 'hello world foo' }], @@ -186,6 +197,18 @@ describe('FsProjectStore — projects', () => { expect(await store.listProjects()).not.toContain('p1'); }); + // QNBS-v3: fresh filesystem writes carry an explicit current marker so this build does not reclassify its own output as legacy. + it('stamps a current schema version on newly created projects', async () => { + const { schemaVersion: _schemaVersion, ...unversionedProject } = project; + + await store.saveProject(unversionedProject as never); + + const persisted = decompressData>( + fake.text.get('/app/projects/p1/project.json') as string, + ); + expect(persisted['schemaVersion']).toBe(1); + }); + it('returns null for a missing project and [] when no projects dir', async () => { expect(await store.loadProject('nope')).toBeNull(); expect(await store.listProjects()).toEqual([]); @@ -322,12 +345,376 @@ describe('FsProjectStore — projects', () => { expect(await store.loadProject('item')).toEqual(expect.objectContaining({ title: 'My Novel' })); }); + // QNBS-v3: conflicting embedded identity is kept read-only under the canonical source directory. + it('fences legacy writeback by both directory and embedded project identity', async () => { + const legacyProject = { + id: 'embedded-id', + title: 'Legacy Novel', + logline: 'A legacy tale', + manuscript: [], + characters: [], + worlds: [], + }; + await fake.apis.mkdir('/app/projects/directory-id', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/directory-id/project.json', + compressData(legacyProject), + ); + + const loaded = await store.loadProject('directory-id'); + + expect((loaded as unknown as Record)['id']).toBe('directory-id'); + await expect( + store.saveProject({ ...loaded, title: 'Edited legacy novel' } as never), + ).rejects.toMatchObject({ + name: 'ProjectWritebackError', + projectId: 'directory-id', + }); + expect(fake.text.has('/app/projects/embedded-id/project.json')).toBe(false); + }); + + // QNBS-v3: a valid current source can reclaim its own identity without inheriting a legacy alias fence. + it('does not let a conflicting legacy alias fence a valid current project', async () => { + await store.saveProject({ ...project, id: 'embedded-id' } as never); + await fake.apis.mkdir('/app/projects/directory-id', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/directory-id/project.json', + compressData({ + id: 'embedded-id', + title: 'Legacy Novel', + logline: 'A legacy tale', + manuscript: [], + characters: [], + worlds: [], + }), + ); + + await store.loadProject('directory-id'); + await expect( + store.saveProject({ ...project, id: 'embedded-id', title: 'Current Novel' } as never), + ).resolves.toBeUndefined(); + expect( + decompressData>( + fake.text.get('/app/projects/embedded-id/project.json') as string, + )['title'], + ).toBe('Current Novel'); + }); + + // QNBS-v3: snapshot restore cannot reintroduce mutable state for a project whose source lacks write authority. + it('refuses snapshot restore for a fenced legacy project', async () => { + const legacyProject = { + title: 'Legacy Novel', + logline: 'A legacy tale', + manuscript: [], + characters: [], + worlds: [], + }; + await fake.apis.mkdir('/app/projects/legacy-snapshot', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/legacy-snapshot/project.json', + compressData(legacyProject), + ); + const loaded = await store.loadProject('legacy-snapshot'); + const snapshotId = await store.saveSnapshot('legacy', loaded); + + await expect(store.restoreSnapshot(snapshotId, loaded as never)).rejects.toMatchObject({ + name: 'ProjectSnapshotRestoreError', + reason: 'target-unavailable', + }); + }); + + // QNBS-v3: absence clears the whole directory-owned fence so a later replacement can be saved safely. + it('clears stale legacy writeback fences after the source disappears', async () => { + const legacyProject = { + id: 'replacement-id', + title: 'Legacy Novel', + logline: 'A legacy tale', + manuscript: [], + characters: [], + worlds: [], + }; + await fake.apis.mkdir('/app/projects/old-directory', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/old-directory/project.json', + compressData(legacyProject), + ); + const loaded = await store.loadProject('old-directory'); + await expect(store.saveProject(loaded as never)).rejects.toMatchObject({ + name: 'ProjectWritebackError', + }); + + await fake.apis.remove('/app/projects/old-directory', { recursive: true }); + await expect(store.loadProject('old-directory')).resolves.toBeNull(); + await expect( + store.saveProject({ ...project, id: 'replacement-id' } as never), + ).resolves.toBeUndefined(); + }); + + // QNBS-v3: auxiliary project files cannot mutate while the owning legacy source is fenced. + it('fences project-owned auxiliary asset writes and deletes for legacy projects', async () => { + const asset = new Uint8Array([1, 2, 3]).buffer; + await store.saveBinderAsset('legacy-assets', 'asset-1', asset, { + mimeType: 'application/pdf', + originalFileName: 'legacy.pdf', + byteSize: 3, + }); + await store.saveStoryCodex({ + projectId: 'legacy-assets', + extractedAt: '2026-01-01T00:00:00.000Z', + entities: [], + summary: 'legacy', + }); + await store.saveRagVectors('legacy-assets', [{ id: 'vector-1' }]); + await fake.apis.mkdir('/app/projects/legacy-assets', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/legacy-assets/project.json', + compressData({ + title: 'Legacy Assets', + logline: 'L', + manuscript: [], + characters: [], + worlds: [], + }), + ); + await store.loadProject('legacy-assets'); + + await expect( + store.saveBinderAsset('legacy-assets', 'asset-2', asset, { + mimeType: 'application/pdf', + originalFileName: 'new.pdf', + byteSize: 3, + }), + ).rejects.toMatchObject({ name: 'ProjectWritebackError' }); + await expect(store.deleteBinderAsset('legacy-assets', 'asset-1')).rejects.toMatchObject({ + name: 'ProjectWritebackError', + }); + await expect(store.deleteStoryCodex('legacy-assets')).rejects.toMatchObject({ + name: 'ProjectWritebackError', + }); + await expect(store.saveRagVectors('legacy-assets', [{ id: 'vector-2' }])).rejects.toMatchObject( + { + name: 'ProjectWritebackError', + }, + ); + await expect(store.deleteRagVectors('legacy-assets')).rejects.toMatchObject({ + name: 'ProjectWritebackError', + }); + await expect(store.getBinderAsset('legacy-assets', 'asset-1')).resolves.not.toBeNull(); + await expect(store.getStoryCodex('legacy-assets')).resolves.not.toBeNull(); + await expect(store.getRagVectors('legacy-assets')).resolves.toEqual([{ id: 'vector-1' }]); + }); + + // QNBS-v3: ID-less legacy callers can only address the retained source directory, never an ambiguous fallback route. + it('fails closed for ID-less legacy Binder, Codex, and RAG writes', async () => { + const asset = new Uint8Array([1, 2, 3]).buffer; + const legacyProject = { + title: 'ID-less Legacy', + logline: 'L', + manuscript: [], + characters: [], + worlds: [], + }; + await fake.apis.mkdir('/app/projects/idless-legacy', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/idless-legacy/project.json', + JSON.stringify(legacyProject), + ); + await fake.apis.mkdir('/app/projects/browser-project/binder', { recursive: true }); + await fake.apis.writeFile( + '/app/projects/browser-project/binder/legacy.bin', + new Uint8Array([9]), + ); + await fake.apis.mkdir('/app/projects/default/codex', { recursive: true }); + const originalCodex = { projectId: 'default', entries: [{ name: 'legacy' }] }; + await fake.apis.writeTextFile( + '/app/projects/default/codex/codex.snap', + compressData(originalCodex), + ); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + const originalVectors = [{ id: 'legacy-vector' }]; + await fake.apis.writeTextFile( + '/app/projects/project/codex/vectors.snap', + compressData(originalVectors), + ); + + await store.loadProject('idless-legacy'); + await expect( + store.saveBinderAsset('idless-legacy', 'new', asset, { + mimeType: 'application/pdf', + originalFileName: 'new.pdf', + byteSize: 3, + }), + ).rejects.toMatchObject({ name: 'ProjectWritebackError' }); + await expect( + store.saveStoryCodex({ projectId: 'idless-legacy', entries: [{ name: 'new' }] } as never), + ).rejects.toMatchObject({ name: 'ProjectWritebackError' }); + await expect( + store.saveRagVectors('idless-legacy', [{ id: 'new-vector' }]), + ).rejects.toMatchObject({ name: 'ProjectWritebackError' }); + expect( + decompressData>( + fake.text.get('/app/projects/default/codex/codex.snap') as string, + ), + ).toEqual(originalCodex); + expect( + decompressData( + fake.text.get('/app/projects/project/codex/vectors.snap') as string, + ), + ).toEqual(originalVectors); + expect(fake.bin.has('/app/projects/browser-project/binder/new.bin')).toBe(false); + }); + + // QNBS-v3: background ID-less inspection cannot install a global fallback fence over a valid CURRENT source. + it('keeps CURRENT fallback writes authoritative after ID-less legacy inspection', async () => { + await store.saveProject({ ...project, id: 'project' } as never); + const legacyProject = { + title: 'ID-less Legacy', + logline: 'L', + manuscript: [], + characters: [], + worlds: [], + }; + await fake.apis.mkdir('/app/projects/idless-legacy', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/idless-legacy/project.json', + JSON.stringify(legacyProject), + ); + + await store.loadProject('idless-legacy'); + await store.loadProject('project'); + + await expect( + store.saveBinderAsset('project', 'ambiguous', new Uint8Array([1]).buffer, { + mimeType: 'application/octet-stream', + originalFileName: 'ambiguous.bin', + byteSize: 1, + }), + ).resolves.toBeUndefined(); + await expect( + store.saveStoryCodex({ projectId: 'project', entries: [{ name: 'ambiguous' }] } as never), + ).resolves.toBeUndefined(); + await expect(store.saveRagVectors('project', [{ id: 'ambiguous' }])).resolves.toBeUndefined(); + expect(fake.bin.has('/app/projects/project/binder/ambiguous.bin')).toBe(true); + expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(true); + expect(fake.text.has('/app/projects/project/codex/vectors.snap')).toBe(true); + }); + + // QNBS-v3: readable legacy bytes remain available to inspection but cannot cross the editable bootstrap boundary. + it('refuses ID-less legacy admission for the editable application state without rewriting the source', async () => { + const legacyProject = { + title: 'ID-less Legacy', + logline: 'L', + manuscript: [], + characters: [], + worlds: [], + }; + const source = '/app/projects/idless-legacy/project.json'; + await fake.apis.mkdir('/app/projects/idless-legacy', { recursive: true }); + await fake.apis.writeTextFile(source, JSON.stringify(legacyProject)); + const before = fake.text.get(source); + + await expect(store.loadProjectForEditing('idless-legacy')).rejects.toMatchObject({ + name: 'ProjectLoadError', + reason: 'unsupported-version', + classification: 'LEGACY_UNVERSIONED', + projectId: 'idless-legacy', + }); + expect(fake.text.get(source)).toBe(before); + }); + + // QNBS-v3: authority is checked after queued work completes so a load cannot race a later mutation. + it('rechecks legacy authority after a queued admission change', async () => { + const legacyProject = { + title: 'Racing Legacy', + logline: 'L', + manuscript: [], + characters: [], + worlds: [], + }; + await fake.apis.mkdir('/app/projects/racing-legacy', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/racing-legacy/project.json', + JSON.stringify(legacyProject), + ); + + 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/queue-holder/codex/codex.snap.tmp-')) { + writeStarted(); + return new Promise((resolve, reject) => { + releaseWrite = () => originalWriteTextFile(path, content).then(resolve, reject); + }); + } + return originalWriteTextFile(path, content); + }; + + const holder = store.saveStoryCodex({ projectId: 'queue-holder', entries: [] } as never); + await writeStartedPromise; + const load = store.loadProject('racing-legacy'); + const queuedWrite = store.saveStoryCodex({ projectId: 'racing-legacy', entries: [] } as never); + + releaseWrite(); + await holder; + await load; + await expect(queuedWrite).rejects.toMatchObject({ name: 'ProjectWritebackError' }); + expect(fake.text.has('/app/projects/racing-legacy/codex/codex.snap')).toBe(false); + }); + + // QNBS-v3: auxiliary fence checks use the writers' sanitized identity and fallback so invalid IDs cannot bypass legacy writeback policy. + it('normalizes legacy auxiliary fence identities before mutation', async () => { + const asset = new Uint8Array([1, 2, 3]).buffer; + await fake.apis.mkdir('/app/projects/foo-bar', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/foo-bar/project.json', + compressData({ + id: 'foo/bar', + title: 'Legacy Path ID', + logline: 'L', + manuscript: [], + characters: [], + worlds: [], + }), + ); + await store.loadProject('foo-bar'); + + await expect( + store.saveBinderAsset('foo/bar', 'asset-1', asset, { + mimeType: 'application/pdf', + originalFileName: 'new.pdf', + byteSize: 3, + }), + ).rejects.toMatchObject({ name: 'ProjectWritebackError' }); + + await fake.apis.mkdir('/app/projects/project', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/project.json', + compressData({ + id: '***', + title: 'Legacy Fallback ID', + logline: 'L', + manuscript: [], + characters: [], + worlds: [], + }), + ); + await store.loadProject('project'); + + await expect(store.saveRagVectors('***', [])).rejects.toMatchObject({ + name: 'ProjectWritebackError', + }); + }); + // 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' }], + binderNodes: [legacyBinderNode('legacy-asset')], }; const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; const legacyVectors = [{ id: 'legacy-vector' }]; @@ -514,7 +901,7 @@ describe('FsProjectStore — projects', () => { const legacyProject = { ...project, id: '***', - binderNodes: [{ binderAssetId: 'legacy-asset' }], + binderNodes: [legacyBinderNode('legacy-asset')], }; await fake.apis.mkdir('/app/projects/item', { recursive: true }); await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); @@ -551,7 +938,7 @@ describe('FsProjectStore — projects', () => { const legacyProject = { ...project, id: '***', - binderNodes: [{ binderAssetId: 'legacy-asset' }], + binderNodes: [legacyBinderNode('legacy-asset')], }; await fake.apis.mkdir('/app/projects/item', { recursive: true }); await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); @@ -596,7 +983,7 @@ describe('FsProjectStore — projects', () => { const legacyProject = { ...project, id: '***', - binderNodes: [{ binderAssetId: 'legacy-asset' }], + binderNodes: [legacyBinderNode('legacy-asset')], }; const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; await store.saveStoryCodex(legacyCodex as never); @@ -764,7 +1151,7 @@ describe('FsProjectStore — projects', () => { const legacyProject = { ...project, id: '***', - binderNodes: [{ binderAssetId: '.' }, { binderAssetId: '..' }], + binderNodes: [legacyBinderNode('.'), legacyBinderNode('..')], }; await fake.apis.mkdir('/app/projects/item', { recursive: true }); await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); @@ -995,6 +1382,79 @@ describe('FsProjectStore — projects', () => { expect(fake.text.has('/app/projects/p2/project.json')).toBe(false); }); + // QNBS-v3: a future snapshot cannot overwrite a current target with an unadmitted schema marker. + it('refuses a future snapshot before it can turn a current target into future state', async () => { + await store.saveProject(project as never); + const current = await store.loadProject('p1'); + const snapshotId = await store.saveSnapshot('future', { + ...project, + schemaVersion: 99, + title: 'Future snapshot content', + }); + + await expect(store.restoreSnapshot(snapshotId, current as never)).rejects.toMatchObject({ + name: 'ProjectSnapshotRestoreError', + reason: 'snapshot-invalid', + }); + expect( + decompressData>( + fake.text.get('/app/projects/p1/project.json') as string, + ), + ).toMatchObject({ schemaVersion: 1, title: 'My Novel' }); + }); + + // QNBS-v3: raw snapshot version tokens must be admitted before JSON parsing can normalize them. + it.each([ + [ + 'duplicate schemaVersion', + '{"schemaVersion":1,"schemaVersion":2,"id":"p1","title":"Raw","logline":"L","manuscript":[],"characters":[],"worlds":[]}', + ], + [ + 'unsafe numeric schemaVersion', + '{"schemaVersion":9007199254740993,"id":"p1","title":"Raw","logline":"L","manuscript":[],"characters":[],"worlds":[]}', + ], + ])('refuses %s snapshot tokens before normalization', async (_label, rawSnapshot) => { + await store.saveProject(project as never); + const current = await store.loadProject('p1'); + const snapshotId = 9101; + fake.text.set( + `/app/snapshots/${snapshotId}.json`, + JSON.stringify({ + id: snapshotId, + name: 'raw', + date: new Date().toISOString(), + wordCount: 0, + data: rawSnapshot, + }), + ); + + await expect(store.restoreSnapshot(snapshotId, current as never)).rejects.toMatchObject({ + name: 'ProjectSnapshotRestoreError', + reason: 'snapshot-invalid', + }); + expect( + decompressData>( + fake.text.get('/app/projects/p1/project.json') as string, + ), + ).toMatchObject({ schemaVersion: 1, title: 'My Novel' }); + }); + + // QNBS-v3: pre-marker snapshots remain recoverable through the non-destructive legacy projection. + it('restores a legacy snapshot through canonical in-memory admission', async () => { + await store.saveProject(project as never); + const current = await store.loadProject('p1'); + const { schemaVersion: _schemaVersion, ...legacySnapshot } = project; + const snapshotId = await store.saveSnapshot('legacy-snapshot', { + ...legacySnapshot, + title: 'Legacy snapshot content', + }); + + const restored = await store.restoreSnapshot(snapshotId, current as never); + + expect(restored.title).toBe('Legacy snapshot content'); + expect((restored as unknown as Record)['schemaVersion']).toBe(1); + }); + // 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' }; @@ -1119,6 +1579,36 @@ describe('FsProjectStore — projects', () => { expect(fake.text.has('/app/projects/Renamed-Again/project.json')).toBe(false); }); + // QNBS-v3: a CURRENT ID-less source remains authoritative across repeated title-changing saves. + it('retains a CURRENT ID-less source binding across repeated saves', async () => { + const currentProject = { + schemaVersion: 1, + title: 'Current Novel', + logline: 'L', + manuscript: [], + characters: [], + worlds: [], + }; + await fake.apis.mkdir('/app/projects/current-source', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/current-source/project.json', + compressData(currentProject), + ); + + const loaded = await store.loadProject('current-source'); + await store.saveProject({ ...loaded, title: 'First Rename' } as never); + await store.saveProject({ ...loaded, title: 'Second Rename' } as never); + + expect( + decompressData>( + fake.text.get('/app/projects/current-source/project.json') as string, + ), + ).toMatchObject({ schemaVersion: 1, title: 'Second Rename' }); + expect(fake.text.has('/app/projects/First-Rename/project.json')).toBe(false); + expect(fake.text.has('/app/projects/Second-Rename/project.json')).toBe(false); + expect(fake.text.get('/app/config/active-project-id.txt')).toBe('current-source'); + }); + // 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' }; @@ -1174,7 +1664,7 @@ describe('FsProjectStore — projects', () => { const legacyProject = { ...project, id: '***', - binderNodes: [{ binderAssetId: 'legitimate-asset' }], + binderNodes: [legacyBinderNode('legitimate-asset')], }; await fake.apis.mkdir('/app/projects/item', { recursive: true }); await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); diff --git a/tests/unit/services/fs/projectFsStore.test.ts b/tests/unit/services/fs/projectFsStore.test.ts index 2c47459d8..5a5977739 100644 --- a/tests/unit/services/fs/projectFsStore.test.ts +++ b/tests/unit/services/fs/projectFsStore.test.ts @@ -121,9 +121,11 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => { await expect(promise).rejects.toMatchObject({ reason: 'io-error' }); }); + // QNBS-v3: explicit V1 admission keeps this source writable while legacy sources remain fenced. it('resolves the real project on a valid save with array-shaped characters/worlds', async () => { const { FsProjectStore } = await import('../../../../services/fs/projectFsStore'); const validProject = { + schemaVersion: 1, title: 'My Book', logline: 'L', characters: [], @@ -133,12 +135,17 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => { mockDesktopPlatform.filesystem.exists.mockResolvedValue(true); mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue(compressData(validProject)); const store = new FsProjectStore(); - await expect(store.loadProject('good-id')).resolves.toEqual(validProject); + await expect(store.loadProject('good-id')).resolves.toMatchObject({ + ...validProject, + schemaVersion: 1, + }); }); + // QNBS-v3: EntityState-shaped V1 data remains editable after canonical admission. it('resolves the real project on a valid save with EntityState-shaped characters/worlds', async () => { const { FsProjectStore } = await import('../../../../services/fs/projectFsStore'); const validProject = { + schemaVersion: 1, title: 'My Book', logline: 'L', characters: { ids: [], entities: {} }, @@ -148,6 +155,99 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => { mockDesktopPlatform.filesystem.exists.mockResolvedValue(true); mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue(compressData(validProject)); const store = new FsProjectStore(); - await expect(store.loadProject('good-entity-state-id')).resolves.toEqual(validProject); + await expect(store.loadProject('good-entity-state-id')).resolves.toMatchObject({ + ...validProject, + schemaVersion: 1, + }); }); + + // QNBS-v3: current filesystem admission must reject malformed owned children before editable authority. + it.each([ + ['entity', { characters: [{ id: 'c1', name: 42 }] }], + ['manuscript entry', { manuscript: [{ id: 's1', title: 42, content: 'text' }] }], + ])('rejects malformed nested %s content', async (_label, fragment) => { + const { FsProjectStore } = await import('../../../../services/fs/projectFsStore'); + mockDesktopPlatform.filesystem.exists.mockResolvedValue(true); + mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue( + JSON.stringify({ + title: 'Malformed nested', + logline: 'L', + characters: [], + worlds: [], + manuscript: [], + ...fragment, + }), + ); + const store = new FsProjectStore(); + await expect(store.loadProject('malformed-nested-id')).rejects.toMatchObject({ + name: 'ProjectLoadError', + reason: 'corrupt', + classification: 'MALFORMED', + }); + }); + + // QNBS-v3: legacy admission must not stamp or rewrite a source before durable migration fencing exists. + it('admits a legacy project in memory without rewriting its source', async () => { + const { FsProjectStore } = await import('../../../../services/fs/projectFsStore'); + const source = + '{"title":"Legacy book","logline":"L","characters":[],"worlds":[],"manuscript":[],"opaque":{"exact":9007199254740993}}'; + mockDesktopPlatform.filesystem.exists.mockResolvedValue(true); + mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue(source); + const store = new FsProjectStore(); + + const loaded = await store.loadProject('legacy-id'); + expect(loaded).toMatchObject({ + title: 'Legacy book', + logline: 'L', + characters: [], + worlds: [], + manuscript: [], + }); + expect(loaded).not.toHaveProperty('schemaVersion'); + expect(mockDesktopPlatform.filesystem.writeTextFile).not.toHaveBeenCalled(); + expect(mockDesktopPlatform.filesystem.rename).not.toHaveBeenCalled(); + }); + + // QNBS-v3: legacy admission cannot let ordinary autosave normalize or rewrite its source before fencing. + it('rejects ordinary writeback after admitting a legacy project', async () => { + const { FsProjectStore } = await import('../../../../services/fs/projectFsStore'); + const source = + '{"title":"Legacy book","logline":"L","characters":[],"worlds":[],"manuscript":[]}'; + mockDesktopPlatform.filesystem.exists.mockResolvedValue(true); + mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue(source); + const store = new FsProjectStore(); + + const loaded = await store.loadProject('legacy-id'); + await expect( + store.saveProject({ ...loaded, title: 'Edited legacy book' } as never), + ).rejects.toMatchObject({ + name: 'ProjectWritebackError', + projectId: 'legacy-id', + }); + expect(mockDesktopPlatform.filesystem.writeTextFile).not.toHaveBeenCalled(); + }); + + // QNBS-v3: unsupported versions must refuse editable filesystem authority without touching source. + it.each([ + ['future', { schemaVersion: 99, title: 'Future' }, 'FUTURE'], + ['migration gap', { schemaVersion: 0, title: 'Gap' }, 'UNSUPPORTED_OLDER'], + ])( + 'refuses %s filesystem input without changing its source', + async (_label, value, classification) => { + const { FsProjectStore } = await import('../../../../services/fs/projectFsStore'); + const source = JSON.stringify(value); + mockDesktopPlatform.filesystem.exists.mockResolvedValue(true); + mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue(source); + const store = new FsProjectStore(); + + await expect(store.loadProject('refused-id')).rejects.toMatchObject({ + name: 'ProjectLoadError', + reason: 'unsupported-version', + projectId: 'refused-id', + classification, + }); + expect(mockDesktopPlatform.filesystem.writeTextFile).not.toHaveBeenCalled(); + expect(mockDesktopPlatform.filesystem.rename).not.toHaveBeenCalled(); + }, + ); }); diff --git a/tests/unit/startupRecovery.test.tsx b/tests/unit/startupRecovery.test.tsx index 340aef92e..4f41d65d8 100644 --- a/tests/unit/startupRecovery.test.tsx +++ b/tests/unit/startupRecovery.test.tsx @@ -12,6 +12,8 @@ const { mockRoot, mockReset, mockBackendKind, mockQuarantine, mockCopy, loggerEr storageUnavailable: 'storage unavailable', projectUnavailable: 'project unavailable', projectIoUnavailable: 'project io unavailable', + projectUnsupported: 'project unsupported', + projectMigrationGap: 'project migration gap', reload: 'reload', retry: 'retry', recover: 'recover', @@ -47,9 +49,10 @@ vi.mock('../../components/StorageErrorScreen', () => ({ vi.mock('../../services/fs/projectFsStore', () => { class ProjectLoadError extends Error { constructor( - public readonly reason: 'corrupt' | 'io-error', + public readonly reason: 'corrupt' | 'io-error' | 'unsupported-version', message: string, public readonly projectId: string, + public readonly classification?: 'FUTURE' | 'UNSUPPORTED_OLDER', ) { super(message); this.name = 'ProjectLoadError'; @@ -72,7 +75,12 @@ import { type RecoveryScreenProps = { copy: typeof mockCopy; - failureKind: 'storage' | 'project-corrupt' | 'project-io'; + failureKind: + | 'storage' + | 'project-corrupt' + | 'project-io' + | 'project-unsupported' + | 'project-migration-gap'; onReset?: () => Promise; onRecover?: () => Promise; onRetry?: () => void; @@ -142,4 +150,34 @@ describe('startup recovery rendering', () => { expect(renderedScreenProps().failureKind).toBe('storage'); expect(renderedScreenProps().onReset).toEqual(expect.any(Function)); }); + + // QNBS-v3: unsupported versions expose retry-only recovery so no destructive authority is offered. + it('renders unsupported project versions without quarantine or reset authority', async () => { + mockBackendKind.mockResolvedValue('filesystem'); + await renderProjectInitializationFailure( + mockRoot as never, + new ProjectLoadError('unsupported-version', 'future', 'p1'), + ); + + const props = renderedScreenProps(); + expect(props.failureKind).toBe('project-unsupported'); + expect(props.onRecover).toBeUndefined(); + expect(props.onReset).toBeUndefined(); + expect(props.onRetry).toEqual(expect.any(Function)); + }); + + // QNBS-v3: migration-gap recovery keeps the older-version diagnostic while remaining retry-only. + it('renders migration-gap projects with distinct retry-only recovery', async () => { + mockBackendKind.mockResolvedValue('filesystem'); + await renderProjectInitializationFailure( + mockRoot as never, + new ProjectLoadError('unsupported-version', 'older', 'p1', 'UNSUPPORTED_OLDER'), + ); + + const props = renderedScreenProps(); + expect(props.failureKind).toBe('project-migration-gap'); + expect(props.onRecover).toBeUndefined(); + expect(props.onReset).toBeUndefined(); + expect(props.onRetry).toEqual(expect.any(Function)); + }); }); diff --git a/tests/unit/startupRecoveryPolicy.test.ts b/tests/unit/startupRecoveryPolicy.test.ts index e16517dc2..353e9017f 100644 --- a/tests/unit/startupRecoveryPolicy.test.ts +++ b/tests/unit/startupRecoveryPolicy.test.ts @@ -34,6 +34,33 @@ describe('startup recovery action policy', () => { }); }); + it('keeps unsupported project versions non-quarantinable and retryable', () => { + expect( + getStartupRecoveryActions( + new ProjectLoadError('unsupported-version', 'future', 'project-1', 'FUTURE'), + 'filesystem', + ), + ).toEqual({ + failureKind: 'project-unsupported', + canQuarantine: false, + canReset: false, + }); + }); + + // QNBS-v3: preserve migration-gap provenance so recovery copy explains why this build cannot migrate the project. + it('distinguishes unsupported older projects from future projects', () => { + expect( + getStartupRecoveryActions( + new ProjectLoadError('unsupported-version', 'older', 'project-1', 'UNSUPPORTED_OLDER'), + 'filesystem', + ), + ).toEqual({ + failureKind: 'project-migration-gap', + canQuarantine: false, + canReset: false, + }); + }); + it('retains database reset for non-project failures from IndexedDB', () => { expect(getStartupRecoveryActions(new Error('QuotaExceededError'), 'indexeddb')).toEqual({ failureKind: 'storage',