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:
@@ -154,6 +154,29 @@ export function createWorkspaceAssetSync({
|
|||||||
return new Map(rows.map((row) => [row.original_filename, row]));
|
return new Map(rows.map((row) => [row.original_filename, row]));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Deleted workspace assets still own workspace:// storage_key rows — restore instead of re-import. */
|
||||||
|
const loadDeletedWorkspaceAssetsByFilename = async (userId, categoryId) => {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT a.id, a.original_filename, a.checksum, a.size_bytes, a.current_version_id, a.status
|
||||||
|
FROM h5_assets a
|
||||||
|
WHERE a.user_id = ? AND a.category_id = ? AND a.status = 'deleted' AND a.source_type = 'workspace'`,
|
||||||
|
[userId, categoryId],
|
||||||
|
);
|
||||||
|
return new Map(rows.map((row) => [row.original_filename, row]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDeletedWorkspaceAssetByStorageKey = async (userId, storageKey) => {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT a.id, a.original_filename, a.checksum, a.size_bytes, a.current_version_id, a.status
|
||||||
|
FROM h5_asset_versions v
|
||||||
|
JOIN h5_assets a ON a.id = v.asset_id AND a.user_id = ?
|
||||||
|
WHERE v.storage_key = ? AND a.status = 'deleted' AND a.source_type = 'workspace'
|
||||||
|
LIMIT 1`,
|
||||||
|
[userId, storageKey],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
/** Skip re-importing workspace files the user already deleted (checksum unchanged). */
|
/** Skip re-importing workspace files the user already deleted (checksum unchanged). */
|
||||||
const loadDeletedWorkspaceChecksums = async (userId, categoryId) => {
|
const loadDeletedWorkspaceChecksums = async (userId, categoryId) => {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
@@ -307,7 +330,7 @@ export function createWorkspaceAssetSync({
|
|||||||
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
|
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
|
||||||
}
|
}
|
||||||
const [assetRows] = await conn.query(
|
const [assetRows] = await conn.query(
|
||||||
`SELECT id, current_version_id, size_bytes, checksum
|
`SELECT id, current_version_id, size_bytes, checksum, status
|
||||||
FROM h5_assets
|
FROM h5_assets
|
||||||
WHERE id = ? AND user_id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -318,6 +341,7 @@ export function createWorkspaceAssetSync({
|
|||||||
if (!currentAsset) {
|
if (!currentAsset) {
|
||||||
throw Object.assign(new Error('工作区资产不存在'), { code: 'asset_not_found' });
|
throw Object.assign(new Error('工作区资产不存在'), { code: 'asset_not_found' });
|
||||||
}
|
}
|
||||||
|
const wasDeleted = currentAsset.status === 'deleted';
|
||||||
|
|
||||||
const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename);
|
const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename);
|
||||||
if (!detectedMimeType) {
|
if (!detectedMimeType) {
|
||||||
@@ -375,7 +399,8 @@ export function createWorkspaceAssetSync({
|
|||||||
await conn.query(
|
await conn.query(
|
||||||
`UPDATE h5_assets
|
`UPDATE h5_assets
|
||||||
SET size_bytes = ?, checksum = ?, mime_type = ?,
|
SET size_bytes = ?, checksum = ?, mime_type = ?,
|
||||||
asset_type = ?, risk_level = ?, status = ?, workspace_relative_path = ?, updated_at = ?
|
asset_type = ?, risk_level = ?, status = ?, workspace_relative_path = ?,
|
||||||
|
deleted_at = NULL, updated_at = ?
|
||||||
WHERE id = ? AND user_id = ?`,
|
WHERE id = ? AND user_id = ?`,
|
||||||
[
|
[
|
||||||
buffer.length,
|
buffer.length,
|
||||||
@@ -406,16 +431,17 @@ export function createWorkspaceAssetSync({
|
|||||||
currentAsset.id,
|
currentAsset.id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
if (sizeDelta !== 0) {
|
const quotaDelta = wasDeleted ? buffer.length : sizeDelta;
|
||||||
|
if (quotaDelta !== 0) {
|
||||||
await conn.query(
|
await conn.query(
|
||||||
`UPDATE h5_user_spaces SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ?
|
`UPDATE h5_user_spaces SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ?
|
||||||
WHERE id = ? AND user_id = ?`,
|
WHERE id = ? AND user_id = ?`,
|
||||||
[sizeDelta, now, category.space_id, userId],
|
[quotaDelta, now, category.space_id, userId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
return {
|
return {
|
||||||
action: 'updated',
|
action: wasDeleted ? 'restored' : 'updated',
|
||||||
assetId: currentAsset.id,
|
assetId: currentAsset.id,
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
checksum,
|
checksum,
|
||||||
@@ -490,6 +516,7 @@ export function createWorkspaceAssetSync({
|
|||||||
if (!category) return { imported: 0, updated: 0, skipped: 0 };
|
if (!category) return { imported: 0, updated: 0, skipped: 0 };
|
||||||
|
|
||||||
const existingByName = await loadExistingAssets(userId, category.id);
|
const existingByName = await loadExistingAssets(userId, category.id);
|
||||||
|
const deletedByName = await loadDeletedWorkspaceAssetsByFilename(userId, category.id);
|
||||||
const deletedWorkspaceChecksums = await loadDeletedWorkspaceChecksums(userId, category.id);
|
const deletedWorkspaceChecksums = await loadDeletedWorkspaceChecksums(userId, category.id);
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
@@ -524,6 +551,31 @@ export function createWorkspaceAssetSync({
|
|||||||
existing.size_bytes = buffer.length;
|
existing.size_bytes = buffer.length;
|
||||||
updated += 1;
|
updated += 1;
|
||||||
} else {
|
} else {
|
||||||
|
const storageKey = buildWorkspaceStorageKey(
|
||||||
|
userId,
|
||||||
|
category.category_code,
|
||||||
|
file.filename,
|
||||||
|
);
|
||||||
|
const deletedExisting =
|
||||||
|
deletedByName.get(file.filename)
|
||||||
|
?? (await findDeletedWorkspaceAssetByStorageKey(userId, storageKey));
|
||||||
|
if (deletedExisting) {
|
||||||
|
const result = await updateWorkspaceFile(userId, category, deletedExisting, file, buffer);
|
||||||
|
if (result.action === 'skipped') {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await registerWorkspaceArtifactForConversation(userId, source, result);
|
||||||
|
deletedByName.delete(deletedExisting.original_filename);
|
||||||
|
existingByName.set(deletedExisting.original_filename, {
|
||||||
|
id: deletedExisting.id,
|
||||||
|
checksum,
|
||||||
|
size_bytes: buffer.length,
|
||||||
|
current_version_id: deletedExisting.current_version_id,
|
||||||
|
});
|
||||||
|
updated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const result = await importWorkspaceFile(userId, category, file, buffer);
|
const result = await importWorkspaceFile(userId, category, file, buffer);
|
||||||
await registerWorkspaceArtifactForConversation(userId, source, result);
|
await registerWorkspaceArtifactForConversation(userId, source, result);
|
||||||
existingByName.set(file.filename, { checksum });
|
existingByName.set(file.filename, { checksum });
|
||||||
|
|||||||
@@ -476,3 +476,160 @@ test('syncUserWorkspace imports nested workspace files into asset library', asyn
|
|||||||
assert.equal(result.imported, 1);
|
assert.equal(result.imported, 1);
|
||||||
assert.equal(state.assets[0].original_filename, '暑假实习报告/report.csv');
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user