Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
7057cf8
fix: fail closed encrypted storage lifecycle
qnbs Aug 11, 2026
3f70e0e
fix: harden desktop AI integrations
qnbs Aug 11, 2026
814ff04
feat: add durable encryption migration journal
qnbs Aug 11, 2026
349b962
docs: clarify encryption journal rollout
qnbs Aug 11, 2026
79b482c
fix: expose local provider diagnostics
qnbs Aug 11, 2026
8145eec
fix: resolve audited dependency vulnerabilities
qnbs Aug 11, 2026
366fcfe
fix: resolve audited dependency vulnerabilities
qnbs Aug 11, 2026
f978606
Merge branch 'fix/encryption-lifecycle-safety' into fix/desktop-relia…
qnbs Aug 11, 2026
c0707ed
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
a130b00
fix: make encryption journal updates atomic
qnbs Aug 11, 2026
82e2e8d
fix: enforce pnpm v11 build-script policy
qnbs Aug 11, 2026
05d590c
docs: reconcile PR 310 migration work
qnbs Aug 11, 2026
0ebbca2
feat(storage): add secure record envelope primitives
qnbs Aug 11, 2026
9afbdec
feat(storage): checkpoint resumable migration batches
qnbs Aug 11, 2026
7498444
chore: harden dependency execution policy
qnbs Aug 11, 2026
58a3a82
feat(storage): add resumable secondary store adapters
qnbs Aug 11, 2026
cb87963
fix(storage): fail closed during migration reads
qnbs Aug 11, 2026
2017957
fix(storage): harden migration recovery protocol
qnbs Aug 11, 2026
c4b64f8
fix(deps): reconcile release-age lockfile
qnbs Aug 11, 2026
997b2f6
fix(storage): harden migration recovery protocol
qnbs Aug 11, 2026
88016dd
fix(tauri): bound Python probes and LoRA process lifecycle
qnbs Aug 11, 2026
fa3cd98
chore(deps): align pnpm v11 security policy
qnbs Aug 11, 2026
fd7ed7c
chore: merge encryption lifecycle foundation
qnbs Aug 11, 2026
dda48b3
chore: merge desktop reliability foundation
qnbs Aug 11, 2026
fefd9ef
docs: add desktop performance evidence ledger
qnbs Aug 11, 2026
8b0afe5
docs: capture current agent handoff state
qnbs Aug 11, 2026
a623213
docs: correct README i18n key count (2869 → 2876)
qnbs Aug 11, 2026
264fb7d
fix(storage): close TOCTOU/fail-closed gaps in the IDB encryption guard
qnbs Aug 11, 2026
d318abc
fix(storage,i18n): correct encryption-scope claims and salt-loss fail…
qnbs Aug 11, 2026
ebdcac1
fix: backfill writingSurfaceStyle in all Settings producers; correct …
qnbs Aug 11, 2026
d0ca4e6
fix(storage): correct transaction-ordering regression and cross-tab s…
qnbs Aug 11, 2026
759a9ec
fix(i18n): synchronize stale feature-flag counts and add missing Basq…
qnbs Aug 11, 2026
5e80aaa
fix(settings): distinguish setup-failed from wrong-passphrase errors;…
qnbs Aug 11, 2026
09d8c59
Merge remote-tracking branch 'origin/fix/encryption-lifecycle-safety'…
qnbs Aug 11, 2026
1a081cf
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
e42fd3c
fix(desktop): fix LoRA env-report deserialization, panic risk, Python…
qnbs Aug 11, 2026
da3b4d7
Merge remote-tracking branch 'origin/fix/desktop-reliability-hardenin…
qnbs Aug 11, 2026
501a403
fix: correct Tauri arg casing and surface native diagnostics in LoRA …
qnbs Aug 11, 2026
e204463
fix: correct AiCreativity casing typo blocking typecheck
qnbs Aug 11, 2026
fd38847
fix: classify a killed training process as aborted, not failed
qnbs Aug 11, 2026
8a6f895
fix: sync Local AI panel when a preload finishes outside handleDownload
qnbs Aug 11, 2026
a01e298
fix: scope the global download modal to explicit preloads only
qnbs Aug 11, 2026
20c9954
fix: guard connection-context races and the WebGPU spinner in AiProvi…
qnbs Aug 11, 2026
756e3c0
fix: correct LM Studio/vLLM/custom local-backend streaming and routing
qnbs Aug 11, 2026
2438f99
test: cover vLLM preset and invalid OpenAI-compatible models responses
qnbs Aug 11, 2026
7cdd128
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
3253c41
fix: resolve tsgo narrowing issue in AiProviderCard's stale-context test
qnbs Aug 11, 2026
56e2509
fix: enforce legal phase transitions and close the checkpoint cursor-…
qnbs Aug 11, 2026
a8dd917
fix: gate protected-store writes against an active migration
qnbs Aug 11, 2026
46198b2
fix(storage): skip corrupt scene revisions instead of hiding history;…
qnbs Aug 11, 2026
411943a
fix(storage): fail migration to recovery-required on a verification s…
qnbs Aug 11, 2026
beadfa2
fix(ai): degrade cache reads to a miss on lock/migration, re-encrypt …
qnbs Aug 11, 2026
3f31c1e
fix(ollama): report invalidResponse instead of a false-positive conne…
qnbs Aug 11, 2026
68050d8
docs(test): clarify encryptionMigrationJournal.test.ts's ownership-CA…
qnbs Aug 11, 2026
dc0b526
fix(settings): stop rendering the connection test result twice in AiP…
qnbs Aug 11, 2026
ed46bef
docs: refresh stack SHAs in the performance ledger; reconcile PR310-R…
qnbs Aug 11, 2026
667f6f3
fix(storage): route locked encrypted startup and Lock Session to the …
qnbs Aug 11, 2026
99c2839
fix(storage): route locked encrypted startup and Lock Session to the …
qnbs Aug 11, 2026
0353364
fix(settings): restore sepia as the first-run appearance default
qnbs Aug 11, 2026
ad4364a
Merge branch 'fix/encryption-lifecycle-safety' into fix/desktop-relia…
qnbs Aug 11, 2026
dd92628
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
74ce8fd
docs: refresh stack SHAs after the appearancePreset/unlock-routing fi…
qnbs Aug 11, 2026
58d95d1
docs: refresh session handoff, archive the prior capture
qnbs Aug 11, 2026
be11482
fix(lora,settings): stale-run training race, stuck onboarding on canc…
qnbs Aug 11, 2026
d536649
fix(settings,ai): decouple URL edits from protocol preset; guard prel…
qnbs Aug 11, 2026
ebafce7
fix(desktop): revalidate the cached Python interpreter before trustin…
qnbs Aug 11, 2026
c3f00cf
fix(settings): auto-validate a newly saved Gemini key instead of defe…
qnbs Aug 11, 2026
0b1cc2e
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
bc078b8
docs: refresh session handoff after the #336 fresh-review-wave fix ca…
qnbs Aug 11, 2026
edc3ef1
fix(deps): exclude the two unpatched uuid releases from the override …
qnbs Aug 11, 2026
e99f354
Merge branch 'fix/encryption-lifecycle-safety' into fix/desktop-relia…
qnbs Aug 11, 2026
5bd4c77
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
451f681
fix(lora): clear cancellationRequested on a failed native abort; coll…
qnbs Aug 11, 2026
b01564e
fix(i18n): translate LoRA onboarding strings and fix broken shell com…
qnbs Aug 11, 2026
1096861
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 12, 2026
6dc90eb
docs: refresh performance ledger SHAs after #336 layering-mistake cor…
qnbs Aug 12, 2026
3a3ed23
docs: capture session handoff after the second layering-mistake corre…
qnbs Aug 12, 2026
652fa72
docs(pr310): mark review-thread reconciliation complete (0/317 unreso…
qnbs Aug 12, 2026
a24a987
docs: update handoff with Tauri build success and #310 reconciliation…
qnbs Aug 12, 2026
369d064
fix(lora): only classify training as aborted on a confirmed native ca…
qnbs Aug 12, 2026
09eae35
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 12, 2026
afbcdb8
Merge remote-tracking branch 'origin/main' into fix/desktop-reliabili…
qnbs Aug 12, 2026
56389e6
fix(lora,ai): distinguish pending-start cancellation from a true abor…
qnbs Aug 12, 2026
5568d91
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 12, 2026
43e4afc
fix(settings): restore the WebGPU auto-probe lost when the ollama aut…
qnbs Aug 12, 2026
55bc5ea
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 12, 2026
1f1ec61
Merge remote-tracking branch 'origin/main' into feat/encryption-recov…
qnbs Aug 12, 2026
e708940
fix(a11y): set aria-busy on the provider connection status region whi…
qnbs Aug 12, 2026
1335e81
fix(test): give the binder-asset transaction mock a real .transaction…
qnbs Aug 12, 2026
5fed880
fix(storage,ai,a11y): fail-soft cache reads, single-flight IDB open, …
qnbs Aug 12, 2026
0ab3341
docs: capture handoff after merging #335/#336 into main and #337's fu…
qnbs Aug 12, 2026
92dd2b5
fix(test,docs): track cursor completion in binder mock; fix doc drift…
qnbs Aug 12, 2026
ed22774
test(storage): cover scene/cache payload-shape branches in the second…
qnbs Aug 12, 2026
8bd6947
fix(storage): cross-tab admission for protected writes vs. active mig…
qnbs Aug 12, 2026
1419352
Merge remote-tracking branch 'origin/main' into feat/encryption-migra…
qnbs Aug 12, 2026
1940fe9
fix(storage,test): close deadlock risk in deleteProject, fix mock fai…
qnbs Aug 12, 2026
179f11b
fix(storage,test): admit reencryptLegacyEntry, in-process fallback lo…
qnbs Aug 12, 2026
096cf96
fix(storage): claim fallback lock ownership synchronously, not after …
qnbs Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions services/ai/aiInferenceCacheService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// QNBS-v3: Two-layer inference cache keeps hot reads in memory while the durable layer is encrypted.
import { logger } from '../logger';
import { withProtectedWriteAdmission } from '../storage/protectedWriteAdmission';
import {
assertSecureStorageReadable,
assertSecureStorageWritableForMutation,
Expand Down Expand Up @@ -168,8 +169,11 @@ export class AiInferenceCacheService {
): Promise<void> {
if (!this.db) return;
try {
const encoded = await this.encodeEntry(key, result, timestamp);
await this.persistEntry(encoded);
// QNBS-v3: shares the writer-admission lock so this opportunistic write cannot land mid-migration-batch either.
await withProtectedWriteAdmission(async () => {
const encoded = await this.encodeEntry(key, result, timestamp);
await this.persistEntry(encoded);
});
} catch {
// QNBS-v3: best-effort; a failed opportunistic re-encrypt is not user-visible and TTL still bounds exposure.
}
Expand Down Expand Up @@ -254,9 +258,12 @@ export class AiInferenceCacheService {
await this.dbReady;
if (!this.db) return;
try {
const entry = await this.encodeEntry(key, result, Date.now());
await this.idbEvictOldest();
await this.persistEntry(entry);
// QNBS-v3: shares the writer-admission lock so eviction/persist cannot run mid-migration-batch and produce a false verification shortfall (#338).
await withProtectedWriteAdmission(async () => {
const entry = await this.encodeEntry(key, result, Date.now());
await this.idbEvictOldest();
await this.persistEntry(entry);
});
Comment thread
qnbs marked this conversation as resolved.
} catch {
// QNBS-v3: The encrypted durable cache is non-authoritative; lock or migration state must not fail inference.
}
Expand Down
22 changes: 15 additions & 7 deletions services/sceneRevisionService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// QNBS-v3: Standalone IDB for scene revisions avoids a shared schema upgrade and keeps history bounded.
import type { SceneRevision } from '../types';
import { createLogger } from './logger';
import { withProtectedWriteAdmission } from './storage/protectedWriteAdmission';
import {
assertSecureStorageReadable,
assertSecureStorageWritableForMutation,
Expand Down Expand Up @@ -228,10 +229,14 @@ export async function saveRevision(
...(authorName !== undefined ? { authorName } : {}),
};

// QNBS-v3: Encrypt before IDB work so WebCrypto cannot make a write transaction inactive.
const stored = await encodeRevision(revision);
const db = await getDb();
await saveStoredRevisionWithRetention(db, stored);
// QNBS-v3: shares the writer-admission lock with primary-store writes so encode-through-commit
// cannot straddle a migration batch and land under a superseded key/generation (#338).
await withProtectedWriteAdmission(async () => {
// QNBS-v3: Encrypt before IDB work so WebCrypto cannot make a write transaction inactive.
const stored = await encodeRevision(revision);
const db = await getDb();
await saveStoredRevisionWithRetention(db, stored);
});
return revision;
}

Expand Down Expand Up @@ -266,9 +271,12 @@ export async function listRevisions(sectionId: string): Promise<SceneRevision[]>

/** Deletes a single revision by ID. */
export async function deleteRevision(id: string): Promise<void> {
await assertSecureStorageWritableForMutation();
const db = await getDb();
await deleteRevisions(db, [id]);
// QNBS-v3: shares the writer-admission lock so this delete cannot land mid-migration-batch either.
await withProtectedWriteAdmission(async () => {
await assertSecureStorageWritableForMutation();
const db = await getDb();
await deleteRevisions(db, [id]);
});
}

/** Reset the singleton and close its handle so tests cannot retain a stale database connection. */
Expand Down
126 changes: 71 additions & 55 deletions services/storage/idbAssetStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { BinderAssetMeta, BinderAssetPayload } from '../storageBackend';
import { makeBinderAssetIdsPrefix, makeBinderAssetStorageKey } from '../storageBackend';
import { getUserFriendlyDbError, retryDb } from './idbCore';
import { IdbSnapshotStore } from './idbSnapshotStore';
import { withProtectedWriteAdmission } from './protectedWriteAdmission';
import {
assertIdbProtectedWriteAllowed,
assertNoActiveEncryptionMigration,
Expand All @@ -23,19 +24,21 @@ export class IdbAssetStore extends IdbSnapshotStore {
// --- Image Store Methods ---

async saveImage(id: string, base64: string): Promise<void> {
// QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey`
// yields the event loop, which auto-commits an already-open IDB transaction
// (TransactionInactiveError on put), and re-reading isIdbEncryptionReady() after any
// later await could race with Lock Session and silently fall back to plaintext.
const writeKey = await resolveProtectedWriteKey();
const payload = writeKey ? await idbEncryptWithKey(writeKey, base64) : base64;
// QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write.
await assertNoActiveEncryptionMigration();
const store = await this.getObjectStore(IMAGES_STORE, 'readwrite');
return new Promise((resolve, reject) => {
const request = store.put(payload, id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
return withProtectedWriteAdmission(async () => {
// QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey`
// yields the event loop, which auto-commits an already-open IDB transaction
// (TransactionInactiveError on put), and re-reading isIdbEncryptionReady() after any
// later await could race with Lock Session and silently fall back to plaintext.
const writeKey = await resolveProtectedWriteKey();
const payload = writeKey ? await idbEncryptWithKey(writeKey, base64) : base64;
// QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write.
await assertNoActiveEncryptionMigration();
const store = await this.getObjectStore(IMAGES_STORE, 'readwrite');
return new Promise<void>((resolve, reject) => {
const request = store.put(payload, id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
});
}

Expand Down Expand Up @@ -66,13 +69,15 @@ export class IdbAssetStore extends IdbSnapshotStore {
}

async deleteImage(id: string): Promise<void> {
// QNBS-v3: A locked session must not be able to destroy protected images it cannot read.
await assertIdbProtectedWriteAllowed();
const store = await this.getObjectStore(IMAGES_STORE, 'readwrite');
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
return withProtectedWriteAdmission(async () => {
// QNBS-v3: A locked session must not be able to destroy protected images it cannot read.
await assertIdbProtectedWriteAllowed();
const store = await this.getObjectStore(IMAGES_STORE, 'readwrite');
return new Promise<void>((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
});
}

Expand All @@ -84,30 +89,32 @@ export class IdbAssetStore extends IdbSnapshotStore {
data: ArrayBuffer,
meta: BinderAssetMeta,
): Promise<void> {
return retryDb(async () => {
const writeKey = await resolveProtectedWriteKey();
const key = makeBinderAssetStorageKey(projectId, assetId);
const fullMeta = { ...meta, byteSize: data.byteLength };
// QNBS-v3: idbEncrypt serialises via JSON.stringify, which silently drops a Blob ({} → no data).
// When encrypting, persist the raw bytes; otherwise store a structured-clone-friendly Blob.
const payload = writeKey
? await idbEncryptWithKey(writeKey, {
meta: fullMeta,
bytes: Array.from(new Uint8Array(data)),
})
: {
meta: fullMeta,
blob: new Blob([data], { type: meta.mimeType || 'application/octet-stream' }),
};
// QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write.
await assertNoActiveEncryptionMigration();
const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite');
return new Promise<void>((resolve, reject) => {
const req = store.put(payload, key);
req.onsuccess = () => resolve();
req.onerror = () => reject(getUserFriendlyDbError(req.error));
});
});
return retryDb(() =>
withProtectedWriteAdmission(async () => {
const writeKey = await resolveProtectedWriteKey();
const key = makeBinderAssetStorageKey(projectId, assetId);
const fullMeta = { ...meta, byteSize: data.byteLength };
// QNBS-v3: idbEncrypt serialises via JSON.stringify, which silently drops a Blob ({} → no data).
// When encrypting, persist the raw bytes; otherwise store a structured-clone-friendly Blob.
const payload = writeKey
? await idbEncryptWithKey(writeKey, {
meta: fullMeta,
bytes: Array.from(new Uint8Array(data)),
})
Comment on lines +99 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Encrypted binder assets are converted into a JavaScript number[] and then JSON-encoded before encryption. This expands every byte into decimal text plus array/object overhead and creates multiple full-size in-memory copies, so normal large PDFs or other binder files can exceed browser memory or IndexedDB quota and fail where the previous Blob representation succeeded. Encrypt a binary-safe representation rather than serializing the entire buffer as a number array. [performance]

Severity Level: Major ⚠️
- ❌ Large encrypted binder uploads can fail from quota expansion.
- ⚠️ Encrypted imports require multiple full-size memory copies.
- ⚠️ Research-PDF offline storage capacity is reduced.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/storage/idbAssetStore.ts
**Line:** 99:103
**Comment:**
	*Performance: Encrypted binder assets are converted into a JavaScript `number[]` and then JSON-encoded before encryption. This expands every byte into decimal text plus array/object overhead and creates multiple full-size in-memory copies, so normal large PDFs or other binder files can exceed browser memory or IndexedDB quota and fail where the previous Blob representation succeeded. Encrypt a binary-safe representation rather than serializing the entire buffer as a number array.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed real, but pre-existing — this exact number[]/JSON-encoding pattern in saveBinderAsset predates this PR (verified via git diff origin/main...HEAD -- services/storage/idbAssetStore.ts, which shows only the admission-wrapping change, no modification to this encoding). Fixing it means redesigning how encrypted binder assets are serialized (e.g. encrypting raw bytes directly instead of round-tripping through a JSON array), which is a real, separate, non-trivial change unrelated to this PR's migration-admission scope. Leaving this thread unresolved rather than falsely closing it — worth a dedicated follow-up.

: {
meta: fullMeta,
blob: new Blob([data], { type: meta.mimeType || 'application/octet-stream' }),
};
// QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write.
await assertNoActiveEncryptionMigration();
const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite');
return new Promise<void>((resolve, reject) => {
const req = store.put(payload, key);
req.onsuccess = () => resolve();
req.onerror = () => reject(getUserFriendlyDbError(req.error));
});
}),
);
}

async getBinderAsset(projectId: string, assetId: string): Promise<BinderAssetPayload | null> {
Expand Down Expand Up @@ -136,17 +143,19 @@ export class IdbAssetStore extends IdbSnapshotStore {
}

async deleteBinderAsset(projectId: string, assetId: string): Promise<void> {
return retryDb(async () => {
// QNBS-v3: A locked session must not be able to destroy protected binder assets it cannot read.
await assertIdbProtectedWriteAllowed();
const key = makeBinderAssetStorageKey(projectId, assetId);
const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite');
return new Promise<void>((resolve, reject) => {
const req = store.delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(getUserFriendlyDbError(req.error));
});
});
return retryDb(() =>
withProtectedWriteAdmission(async () => {
// QNBS-v3: A locked session must not be able to destroy protected binder assets it cannot read.
await assertIdbProtectedWriteAllowed();
const key = makeBinderAssetStorageKey(projectId, assetId);
const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite');
return new Promise<void>((resolve, reject) => {
const req = store.delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(getUserFriendlyDbError(req.error));
});
}),
);
}

async listBinderAssetIds(projectId: string): Promise<string[]> {
Expand Down Expand Up @@ -176,6 +185,13 @@ export class IdbAssetStore extends IdbSnapshotStore {
}

async deleteAllBinderAssetsForProject(projectId: string): Promise<void> {
return withProtectedWriteAdmission(() =>
this.deleteAllBinderAssetsForProjectUnadmitted(projectId),
);
}

// QNBS-v3: unwrapped core for deleteProject() to call inside its own single outer admission — nesting withProtectedWriteAdmission (same shared lock name, same call stack) can deadlock if an exclusive migration request queues between the outer and inner acquisition.
protected async deleteAllBinderAssetsForProjectUnadmitted(projectId: string): Promise<void> {
return retryDb(async () => {
await assertIdbProtectedWriteAllowed();
const ids = await this.listBinderAssetIds(projectId);
Expand Down
Loading
Loading