Files
memind/mindspace-space-quota.mjs
T
john 371900bae5
Memind CI / Test, build, and release guards (pull_request) Failing after 18s
feat(mindspace): add page quota grace write and local delivery guardrails.
Centralize page HTML quota checks with grace-write semantics across page
services and workspace tools, keep localhost MindSpace links clickable in
chat display, and expand Page Data/static-page skill plus local dev docs
for quota, delivery URLs, and native Aider/OpenHands tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:44:26 +08:00

111 lines
3.2 KiB
JavaScript

function asQuotaNumber(value) {
return Number(value ?? 0);
}
export function computeUserSpaceQuotaStats(space = {}) {
const quotaBytes = asQuotaNumber(space.quota_bytes);
const usedBytes = asQuotaNumber(space.used_bytes);
const reservedBytes = asQuotaNumber(space.reserved_bytes);
const occupiedBytes = usedBytes + reservedBytes;
const availableBytes = quotaBytes - occupiedBytes;
return {
quotaBytes,
usedBytes,
reservedBytes,
occupiedBytes,
availableBytes,
isOverQuota: occupiedBytes >= quotaBytes,
};
}
/**
* Page write grace policy:
* - Already at/above quota: block the next page write entirely.
* - Below quota but insufficient headroom: allow this write (may exceed quota).
* - Sufficient headroom: allow normally.
*/
export function evaluatePageWriteQuota(space, requiredBytes) {
const stats = computeUserSpaceQuotaStats(space);
const required = Math.max(0, asQuotaNumber(requiredBytes));
if (stats.isOverQuota) {
return {
allowed: false,
grace: false,
stats,
requiredBytes: required,
reason: 'over_quota',
};
}
if (stats.availableBytes >= required) {
return {
allowed: true,
grace: false,
stats,
requiredBytes: required,
reason: 'sufficient',
};
}
return {
allowed: true,
grace: true,
stats,
requiredBytes: required,
reason: 'grace_write',
};
}
export function buildPageWriteQuotaError(
evaluation,
{ messagePrefix = '剩余空间不足' } = {},
) {
const { stats, requiredBytes, reason } = evaluation;
const message =
reason === 'over_quota'
? '空间已满,请先清理后再创建或修改页面'
: messagePrefix;
return Object.assign(new Error(message), {
code: 'quota_exceeded',
details: {
requiredBytes,
availableBytes: Math.max(0, stats.availableBytes),
usedBytes: stats.usedBytes,
quotaBytes: stats.quotaBytes,
overQuota: reason === 'over_quota',
graceWrite: reason === 'grace_write',
},
});
}
export function assertPageWriteQuota(space, requiredBytes) {
const evaluation = evaluatePageWriteQuota(space, requiredBytes);
if (!evaluation.allowed) {
throw buildPageWriteQuotaError(evaluation);
}
return evaluation;
}
export function isMindSpacePageWriteRelativePath(relativePath) {
const normalized = String(relativePath ?? '').replace(/\\/g, '/').trim();
if (!normalized.toLowerCase().endsWith('.html')) return false;
const zone = normalized.split('/')[0]?.toLowerCase();
return zone === 'public' || zone === 'draft';
}
export async function loadUserSpaceForQuota(pool, userId) {
if (!pool || !userId) return null;
const [rows] = await pool.query(
`SELECT id, quota_bytes, used_bytes, reserved_bytes, status
FROM h5_user_spaces WHERE user_id = ? LIMIT 1`,
[userId],
);
return rows[0] ?? null;
}
export async function assertPageWriteQuotaForUser(pool, userId, requiredBytes) {
const space = await loadUserSpaceForQuota(pool, userId);
if (!space || space.status !== 'active') {
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
}
return assertPageWriteQuota(space, requiredBytes);
}