const TABLE = 'h5_memory_v2_candidates'; const ALLOWED_STATUSES = new Set(['candidate', 'accepted', 'rejected', 'forgotten']); export function buildPersonalMemoryCandidateSchemaSql({ table = TABLE } = {}) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error('Invalid candidate table name'); return `CREATE TABLE IF NOT EXISTS \`${table}\` ( id VARCHAR(64) PRIMARY KEY, user_id CHAR(36) NOT NULL, session_id VARCHAR(191) NULL, memory_type VARCHAR(32) NOT NULL, content TEXT NOT NULL, importance DECIMAL(5,4) NOT NULL, confidence DECIMAL(5,4) NOT NULL, status VARCHAR(24) NOT NULL DEFAULT 'candidate', policy_reason VARCHAR(64) NOT NULL, evidence_json JSON NOT NULL, reviewed_by CHAR(36) NULL, reviewed_at BIGINT NULL, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, UNIQUE KEY uniq_user_type_content (user_id, memory_type, id), KEY idx_candidate_status_created (status, created_at), KEY idx_candidate_user_status (user_id, status, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`; } export async function ensurePersonalMemoryCandidateSchema(pool, options = {}) { if (!pool?.query) throw new Error('Candidate schema requires a MySQL pool'); await pool.query(buildPersonalMemoryCandidateSchemaSql(options)); } function parseJson(value, fallback = null) { if (value == null) return fallback; if (typeof value === 'object') return value; try { return JSON.parse(value); } catch { return fallback; } } function normalizeRow(row) { return { id: String(row.id), userId: String(row.user_id), sessionId: row.session_id == null ? null : String(row.session_id), memoryType: String(row.memory_type), content: String(row.content), importance: Number(row.importance), confidence: Number(row.confidence), status: String(row.status), policyReason: String(row.policy_reason), evidence: parseJson(row.evidence_json, {}), reviewedBy: row.reviewed_by == null ? null : String(row.reviewed_by), reviewedAt: row.reviewed_at == null ? null : Number(row.reviewed_at), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), }; } export function createPersonalMemoryCandidateStore(pool, { table = TABLE, now = () => Date.now() } = {}) { if (!pool?.query) return null; if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error('Invalid candidate table name'); const quoted = `\`${table}\``; return { async saveCandidate(candidate, { autoAccept = false, reviewedBy = 'system:auto-review' } = {}) { const updatedAt = now(); const status = autoAccept ? 'accepted' : 'candidate'; const reviewedAt = autoAccept ? updatedAt : null; const reviewer = autoAccept ? String(reviewedBy || 'system:auto-review') : null; const [result] = await pool.query( `INSERT IGNORE INTO ${quoted} (id, user_id, session_id, memory_type, content, importance, confidence, status, policy_reason, evidence_json, reviewed_by, reviewed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ candidate.id, candidate.userId, candidate.sessionId, candidate.memoryType, candidate.content, candidate.importance, candidate.confidence, status, candidate.policyReason, JSON.stringify(candidate.evidence ?? {}), reviewer, reviewedAt, candidate.createdAt, updatedAt, ], ); return { inserted: Number(result?.affectedRows ?? 0) > 0, status }; }, async listCandidates({ status = 'candidate', userId = null, limit = 50, offset = 0 } = {}) { const normalizedStatus = String(status || 'candidate'); if (!ALLOWED_STATUSES.has(normalizedStatus)) throw new Error('Invalid candidate status'); const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50)); const safeOffset = Math.max(0, Number(offset) || 0); const where = ['status = ?']; const params = [normalizedStatus]; if (userId) { where.push('user_id = ?'); params.push(String(userId)); } params.push(safeLimit, safeOffset); const [rows] = await pool.query( `SELECT * FROM ${quoted} WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params, ); return rows.map(normalizeRow); }, async countByStatus() { const [rows] = await pool.query( `SELECT status, COUNT(*) AS count FROM ${quoted} GROUP BY status`, ); return Object.fromEntries(rows.map((row) => [String(row.status), Number(row.count)])); }, async reviewCandidate(id, status, { reviewedBy = null } = {}) { if (!['accepted', 'rejected'].includes(status)) throw new Error('Invalid review status'); const reviewedAt = now(); const [result] = await pool.query( `UPDATE ${quoted} SET status = ?, reviewed_by = ?, reviewed_at = ?, updated_at = ? WHERE id = ? AND status = 'candidate'`, [status, reviewedBy, reviewedAt, reviewedAt, String(id)], ); return { updated: Number(result?.affectedRows ?? 0) > 0, status, reviewedAt }; }, }; }