Fix workspace sync restore for deleted assets with orphaned storage keys.

When a workspace file was soft-deleted but its workspace:// storage_key row remains, re-sync now restores and updates the asset instead of inserting a duplicate version that breaks WeChat Agent delivery.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-29 14:37:01 +08:00
parent 073185f57c
commit 9610bd56cf
2 changed files with 214 additions and 5 deletions
+157
View File
@@ -476,3 +476,160 @@ test('syncUserWorkspace imports nested workspace files into asset library', asyn
assert.equal(result.imported, 1);
assert.equal(state.assets[0].original_filename, '暑假实习报告/report.csv');
});
test('syncUserWorkspace restores deleted workspace asset instead of duplicate storage_key insert', async () => {
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-deleted-restore-'));
const storageRoot = path.join(h5Root, 'data', 'mindspace');
const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' });
await fs.mkdir(path.join(workspace, 'public'), { recursive: true });
await fs.writeFile(
path.join(workspace, 'public', 'simple-page.html'),
'<!doctype html><html><body>restored</body></html>',
);
const storageKey = 'workspace://user-1/public/simple-page.html';
const state = {
categories: [
{ id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' },
],
spaces: [
{
id: 'space-1',
user_id: 'user-1',
quota_bytes: 5 * 1024 * 1024,
used_bytes: 0,
reserved_bytes: 0,
status: 'active',
},
],
assets: [
{
id: 'asset-deleted-1',
user_id: 'user-1',
category_id: 'cat-public',
original_filename: 'simple-page.html',
checksum: 'old-checksum',
size_bytes: 10,
current_version_id: 'version-deleted-1',
status: 'deleted',
source_type: 'workspace',
deleted_at: Date.now(),
},
],
versions: [
{
id: 'version-deleted-1',
asset_id: 'asset-deleted-1',
storage_key: storageKey,
scan_status: 'passed',
},
],
};
const pool = {
async query(sql, params = []) {
if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) {
const category = state.categories.find(
(item) => item.user_id === params[0] && item.category_code === params[1],
);
return [category ? [category] : []];
}
if (sql.includes('FROM h5_asset_versions v') && sql.includes('storage_key = ?')) {
const row = state.versions.find((item) => item.storage_key === params[1]);
if (!row) return [[]];
const asset = state.assets.find(
(item) => item.id === row.asset_id && item.user_id === params[0] && item.status === 'deleted',
);
return asset
? [[{
id: asset.id,
original_filename: asset.original_filename,
checksum: asset.checksum,
size_bytes: asset.size_bytes,
current_version_id: asset.current_version_id,
status: asset.status,
}]]
: [[]];
}
if (sql.includes('FROM h5_assets')) {
const assets = state.assets.filter((item) => {
if (item.user_id !== params[0] || item.category_id !== params[1]) return false;
if (sql.includes("status <> 'deleted'") && item.status === 'deleted') return false;
if (sql.includes("status = 'deleted'") && item.status !== 'deleted') return false;
if (sql.includes("source_type = 'workspace'") && item.source_type !== 'workspace') {
return false;
}
return true;
});
return [assets];
}
return [[]];
},
async getConnection() {
return {
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
async query(sql, params = []) {
if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) {
return [[state.spaces[0]]];
}
if (sql.includes('FROM h5_assets') && sql.includes('FOR UPDATE')) {
const asset = state.assets.find(
(item) => item.id === params[0] && item.user_id === params[1],
);
return [asset ? [asset] : []];
}
if (sql.includes('UPDATE h5_assets') && sql.includes('deleted_at = NULL')) {
const asset = state.assets.find(
(item) => item.id === params[8] && item.user_id === params[9],
);
asset.size_bytes = params[0];
asset.checksum = params[1];
asset.status = params[5];
asset.deleted_at = null;
return [[]];
}
if (sql.includes('UPDATE h5_asset_versions')) {
const version = state.versions.find(
(item) => item.id === params[6] && item.asset_id === params[7],
);
version.size_bytes = params[0];
version.checksum = params[1];
version.scan_status = params[4];
return [[]];
}
if (sql.includes('used_bytes = GREATEST')) {
state.spaces[0].used_bytes += params[0];
return [[]];
}
if (sql.includes('INSERT INTO h5_assets')) {
throw new Error('should not insert a new asset when restoring deleted workspace file');
}
if (sql.includes('INSERT INTO h5_asset_versions')) {
throw new Error('should not insert a new asset version when restoring deleted workspace file');
}
return pool.query(sql, params);
},
};
},
};
const sync = createWorkspaceAssetSync({
pool,
storageRoot,
h5Root,
maxFileBytes: 1024 * 1024,
idFactory: () => 'unused-id',
});
const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'public' });
assert.equal(result.updated, 1);
assert.equal(state.assets.length, 1);
assert.equal(state.assets[0].status, 'ready');
assert.equal(state.assets[0].deleted_at, null);
assert.equal(state.versions.length, 1);
assert.equal(state.versions[0].storage_key, storageKey);
assert.ok(state.spaces[0].used_bytes > 0);
});