4ca23c256a
背景:08-07 事故修復(60688c3)已把執行紀錄從 KV 搬到 KBDB/D1(entries 表,
API-as-Wall),解掉「稽核資料放在會揮發、被額度打斷的地方」這個結構性錯誤,
也順帶把 Evan 撞到的 1,070 次寫入牆退到 D1 額度層級。但那次修復留了一個誠實
的缺口:MCP list_recent_executions 的說明文字寫著「無固定保留期」——保留期
可設定這件事還沒做。本次補上。
P7 規格(system-dev/docs/3-specs/pending-changes.md「P7」,leo 08-08 confirm):
執行紀錄是稽核資料,預設保留 90 天(3 個月)過期即清;租戶可自訂天數,也可
設「不刪除」(企業稽核,leo:「我願意花很多錢保存,不要刪除」)。
實作(kbdb/src/actions/execution-log.ts,牆內):
- getRetentionDays/setRetentionDays:沿用 execution_log_usage 的 upsert 慣例,
單一 entries 列/租戶(entry_type='execution_log_retention_config'),零建表。
- cleanupExpiredLogs:分兩段掃——有自訂天數的租戶各自 cutoff;其餘(含無租戶)
套預設 90 天,排除「不刪除」與已處理過的租戶。每次呼叫界限刪除量
(CLEANUP_BATCH_LIMIT=500),長期多次呼叫可逐步清完累積量。
路由(kbdb/src/routes/execution-log.ts):GET/PUT /execution-log/retention、
POST /execution-log/cleanup,沿用既有的 Bearer token 全域守衛(fail-closed)。
清理觸發(cypher-executor/src/scheduled.ts):不新增排程基礎設施(wrangler.toml
[triggers] 是受保護檔案)——搭 cypher-executor 既有的每分鐘 cron tick 便車,
固定 UTC 02:30 那一分鐘 fire-and-forget 打一次 KBDB 的 cleanup 端點,一天一次,
不是輪詢。
Portal 薄殼(cypher-executor/src/routes/portal.ts):GET/PUT
/portal/admin/execution-log-retention(role=admin 閘),讓本實例的租戶
(portalTenant)能實際設定保留天數,不只是 KBDB 內部端點。
測試(kbdb/tests/execution-log.test.ts):新增 27 個測試(含原有測試共 27 通過
於本檔),真 SQLite 驗證 cutoff 邏輯、自訂天數隔離、「不刪除」永不清、壞資料
容錯、混合租戶情境、路由層 400/200。測試治具需要「插入指定 created_at 的過期
紀錄」這個正式寫入路徑刻意不開放的能力,做成 kbdb/src/actions/execution-log.ts
內匯出的 testInsert*/testCount* 函式(牆內執行 SQL),測試檔本身零原生 SQL。
量測(不是推論):youlin(yuga3bse)實例上,redeploy 後對 graph_neighbors
webhook 發送 1,200 次併發請求(超過 Evan 實測失敗的 1,070 次)——全部 HTTP 200;
ANALYTICS_KV 的 key 數量在請求前後維持 663 不變,證明新寫入路徑完全不碰 KV,
Evan 撞到的那道牆的成因已被物理移除,不只是延後。
讀取端驗證(真呼叫,非 curl):透過綁定 yuga3bse 的 MCP 連線實際呼叫
arcrun_list_recent_executions(回傳含本次量測寫入的 D1 紀錄)與
arcrun_get_execution_trace(正確回 404 not_found,非崩潰);portal 前端
(https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev/portal)瀏覽器實際載入,
無 console 錯誤、無異常紅色橫幅。
舊資料:KV 裡既有的 stats:* 沿用 60688c3 的既有決定——不搬移,任其依現有 90
天 TTL 自然過期(那是統計快取不是真相源);新的 D1 execution_log 保留政策只
管新資料,不回溯處理。
部署:cypher-executor + kbdb 已手動部署到 youlin(yuga3bse,AI 測試場,leo
08-08 令),未動 prod(uncle6)。本次僅程式碼行為變更、無新增/修改 D1 binding、
無新表——三個既有 D1 binding(CREDENTIALS_DB×2+kbdb DB)維持原樣,未新增第四個。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
404 lines
19 KiB
TypeScript
404 lines
19 KiB
TypeScript
// Execution log — workflow 執行紀錄(KV 額度事故修復,總管交辦,2026-08-07;
|
||
// 保留期可設定=P7,2026-08-09,leo 08-08 confirm:`system-dev/docs/3-specs/pending-changes.md` P7)
|
||
//
|
||
// SDD:無專屬 SDD(延續 2026-08-07 的事故修復任務範圍——同一個 execution_log 資料模型,
|
||
// 加保留期設定與清理,不是新架構)。root cause 見 kbdb/migrations/0004_execution_log_template.sql
|
||
// 開頭註解:cypher-executor 舊版每跑完一次 workflow 就 ANALYTICS_KV.put() 一筆新 key(永不覆蓋)
|
||
// ⇒ 封測者 690 個檔案就把 KV 免費層 1,000 write/日打爆(實測 1,070 write)。
|
||
//
|
||
// KBDB 鐵律(leo 2026-06-14):三張表打天下,永遠不加新 table;新資料類型一律用 template。
|
||
// 本模組 schema 走 template 機制(tpl-execution-log,見上述 migration),但**儲存精神比照既有
|
||
// recipe-stat.ts**:template 只負責文件化(GET /templates 可發現欄位定義),實際一筆執行紀錄
|
||
// 是 entries 表的**一列**(entry_type='execution_log',結構化欄位打包進 metadata_json),
|
||
// 不走 entry_values 全展開的多列 record——那樣一筆執行要拆 5+ 列,1 次執行變 6+ 次 D1 寫入,
|
||
// 直接違反「少記」精神;recipe_stat 早已示範「template 存在+entries 直接存」這個模式合法。
|
||
//
|
||
// leo 兩條判準:
|
||
// ① 執行紀錄是稽核資料 → 搬 D1(entries 表,rows written 100,000/日,額度是 KV 的 100 倍)。
|
||
// ② 不是 n8n、不靠 Execution 計費 → 少記:不留每節點輸入輸出,只留時間/workflow/verdict/
|
||
// duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記一點(見 SUCCESS/FAILED_MESSAGE_MAX)。
|
||
//
|
||
// A2 自我降級:執行紀錄與知識卡(一般 entries)共用同一顆 D1 100,000 rows/日,搬 D1 只是油箱
|
||
// 大了 100 倍,不是解掉共用額度本身。本模組自設更低的「軟上限」(DEFAULT_DAILY_LIMIT),
|
||
// 用量超過 80% → 降成只記失敗;超過 100% → 完全停止記錄,但呼叫端(cypher-executor)的
|
||
// workflow 執行永遠照跑——寫入永不 throw(recordExecutionLog 本身 catch 見呼叫端 route)。
|
||
//
|
||
// 隔離(不污染知識搜尋):entry_type='execution_log'/'execution_log_usage'/
|
||
// 'execution_log_retention_config' 是內部型別,與既有 'value'/'workflow' 同層級。cypher-executor
|
||
// 端(portal-data.ts INTERNAL_ENTRY_TYPES)比照這些一併排除;本模組也從不設
|
||
// metadata_json.embed=true,故永不進 Vectorize 語意搜尋索引。
|
||
//
|
||
// P7 保留期(leo 08-07 兩段發言合起來的最終規格,見 pending-changes.md「提議的規格」段):
|
||
// 儲存 D1、預設保留 90 天(3 個月),過期即清;租戶可自訂天數,也可設「不刪除」(企業稽核)。
|
||
// 清理不掛 Cloudflare Cron(wrangler.toml 的 [triggers] 段落是受保護檔案、AI 不可編輯——
|
||
// 見 InkStoneCo 頂層 P9 段 L1 權限閘),改「搭便車」:cypher-executor 既有的每分鐘
|
||
// scheduled tick(cron workflow 用,見 cypher-executor/src/scheduled.ts)本來就會醒,
|
||
// 在那支既有 handler 裡加一段「一天一次」呼叫本模組的 cleanupExpiredLogs 端點即可,
|
||
// 不需要新的排程基礎設施、不違反「禁輪詢」(那條鐵律管的是主動去戳外部系統要狀態,
|
||
// 這裡是既有 tick 順手打理自己的表,且頻率仍是「一天一次」而非高頻輪詢)。
|
||
import type { Bindings } from '../types';
|
||
import { createEntry, listEntries } from './entry-crud';
|
||
|
||
export interface ExecutionLogInput {
|
||
workflow_id: string;
|
||
owner_id?: string | null;
|
||
verdict: 'success' | 'failed';
|
||
duration_ms: number;
|
||
message?: string;
|
||
target?: string | null;
|
||
}
|
||
|
||
export interface ExecutionLogRow {
|
||
workflow_id: string;
|
||
verdict: string;
|
||
duration_ms: number;
|
||
message: string;
|
||
target?: string;
|
||
recorded_at: number; // unix seconds(entries.created_at 既有慣例,非毫秒)
|
||
}
|
||
|
||
/** 成功訊息截斷長度(少記:夠看一眼結果就好,不留診斷用的長上下文)。 */
|
||
const SUCCESS_MESSAGE_MAX = 200;
|
||
/** 失敗訊息截斷長度(不對稱:失敗要留夠診斷用的上下文,比成功多 10 倍)。 */
|
||
const FAILED_MESSAGE_MAX = 2000;
|
||
/** target 欄位截斷長度(page_name / path 通常是檔名或路徑,不會太長;異常長輸入也不整包吞)。 */
|
||
const TARGET_MAX = 300;
|
||
|
||
/**
|
||
* 每日軟上限預設值:D1 免費層 100,000 rows written/日與知識卡(一般 entries)共用,
|
||
* 本模組自設 20%(20,000)——不是 Cloudflare 硬限制,是「執行紀錄不該把知識卡的額度吃光」的
|
||
* 自我節制門檻,可用 env.EXECUTION_LOG_DAILY_WRITE_LIMIT 覆寫。
|
||
*/
|
||
const DEFAULT_DAILY_LIMIT = 20000;
|
||
/** 用量超過門檻比例 → 降成只記失敗(寫死比例+可測試,不靠感覺調參)。 */
|
||
const DEGRADE_RATIO = 0.8;
|
||
|
||
export type UsageMode = 'log' | 'log_failure_only' | 'skip';
|
||
|
||
function dailyLimit(env: Pick<Bindings, 'EXECUTION_LOG_DAILY_WRITE_LIMIT'>): number {
|
||
const raw = env.EXECUTION_LOG_DAILY_WRITE_LIMIT;
|
||
const n = raw ? parseInt(raw, 10) : NaN;
|
||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_LIMIT;
|
||
}
|
||
|
||
function utcDay(): string {
|
||
return new Date().toISOString().slice(0, 10);
|
||
}
|
||
|
||
function truncate(s: string, max: number): string {
|
||
if (s.length <= max) return s;
|
||
return s.slice(0, Math.max(0, max - 1)) + '…';
|
||
}
|
||
|
||
/**
|
||
* A2 用量計數+降級判斷。單一 entries 列/日(id=`exlog-usage:{day}`,entry_type=
|
||
* 'execution_log_usage',計數包進 metadata_json)——精神完全比照 recipe-stat.ts 的
|
||
* upsert 慣例(讀現有列 → +1 → UPDATE,不存在則 INSERT)。
|
||
*
|
||
* 刻意計「每次呼叫嘗試次數」而非「實際寫入 execution_log 的列數」——即使已降級到
|
||
* 「只記失敗」或「完全停止」,仍要繼續計數,不然額度耗盡後下一次呼叫又會誤判成
|
||
* 「還沒超過」而重新開始寫爆(等於沒有降級機制)。day 用 UTC 日期字串,換日自然歸零。
|
||
*/
|
||
export async function checkUsage(db: D1Database, limit: number): Promise<UsageMode> {
|
||
const id = `exlog-usage:${utcDay()}`;
|
||
const existing = await db
|
||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||
.bind(id)
|
||
.first<{ metadata_json: string | null }>();
|
||
|
||
let count: number;
|
||
if (existing) {
|
||
let prevWrites = 0;
|
||
try {
|
||
const prev = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { writes?: number }) : {};
|
||
prevWrites = Number(prev.writes) || 0;
|
||
} catch {
|
||
prevWrites = 0; // 壞資料誠實視為 0,不讓損毀的計數器卡死降級機制
|
||
}
|
||
count = prevWrites + 1;
|
||
await db
|
||
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
|
||
.bind(JSON.stringify({ day: utcDay(), writes: count }), id)
|
||
.run();
|
||
} else {
|
||
count = 1;
|
||
await db
|
||
.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`)
|
||
.bind(id, JSON.stringify({ day: utcDay(), writes: count }))
|
||
.run();
|
||
}
|
||
|
||
if (count > limit) return 'skip';
|
||
if (count > limit * DEGRADE_RATIO) return 'log_failure_only';
|
||
return 'log';
|
||
}
|
||
|
||
/**
|
||
* 寫入一筆執行紀錄(fire-and-forget 語意由呼叫端 route 的 try/catch 保證,本函式本身
|
||
* 不主動吞錯——route 層統一吞,保持單一吞錯點,避免兩層都吞導致除錯時看不到真因)。
|
||
*/
|
||
export async function recordExecutionLog(
|
||
db: D1Database,
|
||
env: Pick<Bindings, 'EXECUTION_LOG_DAILY_WRITE_LIMIT'>,
|
||
input: ExecutionLogInput,
|
||
): Promise<{ written: boolean; mode: UsageMode }> {
|
||
const limit = dailyLimit(env);
|
||
let mode: UsageMode;
|
||
try {
|
||
mode = await checkUsage(db, limit);
|
||
} catch {
|
||
// fail-open:計數機制本身故障(含 D1 額度打滿)不該連執行紀錄都不寫,
|
||
// 寧可暫時失去降級能力也不要靜默漏記——這一步的失敗仍不影響下面的實際寫入。
|
||
mode = 'log';
|
||
}
|
||
if (mode === 'skip') return { written: false, mode };
|
||
if (mode === 'log_failure_only' && input.verdict !== 'failed') return { written: false, mode };
|
||
|
||
const maxLen = input.verdict === 'failed' ? FAILED_MESSAGE_MAX : SUCCESS_MESSAGE_MAX;
|
||
const target = input.target ? truncate(String(input.target), TARGET_MAX) : null;
|
||
|
||
await createEntry(db, {
|
||
entry_type: 'execution_log',
|
||
owner_id: input.owner_id ?? null,
|
||
page_name: input.workflow_id, // 索引欄位(idx_entries_page)=查詢鍵,讀取端靠它篩單一 workflow
|
||
content: truncate(input.message ?? '', maxLen),
|
||
metadata_json: JSON.stringify({
|
||
verdict: input.verdict,
|
||
duration_ms: Math.max(0, Math.round(input.duration_ms)),
|
||
target,
|
||
}),
|
||
});
|
||
return { written: true, mode };
|
||
}
|
||
|
||
/** 讀某 workflow 最近 N 次執行紀錄(降冪)。owner_id 給了才過濾(租戶隔離,caller 決定)。 */
|
||
export async function listExecutionLog(
|
||
db: D1Database,
|
||
workflowId: string,
|
||
ownerId: string | undefined,
|
||
limit: number,
|
||
): Promise<ExecutionLogRow[]> {
|
||
const { entries } = await listEntries(db, {
|
||
entry_type: 'execution_log',
|
||
page_name: workflowId,
|
||
owner_id: ownerId,
|
||
limit,
|
||
});
|
||
return entries.map((e) => {
|
||
let meta: { verdict?: string; duration_ms?: number; target?: string | null } = {};
|
||
try {
|
||
meta = e.metadata_json ? (JSON.parse(e.metadata_json) as typeof meta) : {};
|
||
} catch {
|
||
/* 壞資料誠實留空,不整筆丟掉(still 回傳 verdict='unknown' 好過整筆消失) */
|
||
}
|
||
return {
|
||
workflow_id: workflowId,
|
||
verdict: meta.verdict ?? 'unknown',
|
||
duration_ms: meta.duration_ms ?? 0,
|
||
message: e.content ?? '',
|
||
...(meta.target ? { target: meta.target } : {}),
|
||
recorded_at: e.created_at,
|
||
};
|
||
});
|
||
}
|
||
|
||
/** 讀某 workflow 最新一次執行紀錄(portal-data.ts last_execution 用)。 */
|
||
export async function latestExecutionLog(
|
||
db: D1Database,
|
||
workflowId: string,
|
||
ownerId: string | undefined,
|
||
): Promise<ExecutionLogRow | null> {
|
||
const rows = await listExecutionLog(db, workflowId, ownerId, 1);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
// ── P7:保留期可設定(2026-08-09) ──────────────────────────────────────────
|
||
//
|
||
// leo 08-07 原話合起來的規格:「預設可以永久保存,但我設定每 3 個月把超過的刪掉……
|
||
// 我願意花很多錢保存,不要刪除」——翻成可執行規則=**預設保留 90 天、租戶可自訂天數、
|
||
// 也可設「不刪除」**(企業稽核用,這是付費理由不是成本負擔,schema 不擋未來計費)。
|
||
//
|
||
// 儲存:沿用 execution_log_usage 的 upsert 慣例——單一 entries 列/租戶
|
||
// (id=`exlog-retention:{owner_id}`,entry_type='execution_log_retention_config')。
|
||
// 無租戶(owner_id 缺,例如舊版 /execute 路徑)套用預設天數,不可個別設定
|
||
// (沒有租戶就沒有「誰的設定」這個概念,硬要存會變成一筆沒有主人的孤兒設定)。
|
||
|
||
/** 預設保留天數:3 個月(leo 08-07:「我設定每 3 個月把超過的刪掉」)。 */
|
||
export const DEFAULT_RETENTION_DAYS = 90;
|
||
|
||
/** 單次清理呼叫最多刪幾列——避免單次 D1 查詢過重;呼叫端(cypher 每日一次 tick)多次呼叫可逐步清完累積量。 */
|
||
const CLEANUP_BATCH_LIMIT = 500;
|
||
|
||
function retentionConfigId(ownerId: string): string {
|
||
return `exlog-retention:${ownerId}`;
|
||
}
|
||
|
||
/** 讀某租戶的保留天數;null=該租戶已設「不刪除」;未設定過=回預設值(不是 null)。 */
|
||
export async function getRetentionDays(
|
||
db: D1Database,
|
||
ownerId: string | null | undefined,
|
||
): Promise<number | null> {
|
||
if (!ownerId) return DEFAULT_RETENTION_DAYS; // 無租戶=套預設,不可個別設定(見上方註解)
|
||
const row = await db
|
||
.prepare(`SELECT metadata_json FROM entries WHERE id = ?`)
|
||
.bind(retentionConfigId(ownerId))
|
||
.first<{ metadata_json: string | null }>();
|
||
if (!row) return DEFAULT_RETENTION_DAYS;
|
||
try {
|
||
const parsed = row.metadata_json
|
||
? (JSON.parse(row.metadata_json) as { retention_days?: number | null })
|
||
: {};
|
||
if (parsed.retention_days === null) return null; // 「不刪除」
|
||
const n = Number(parsed.retention_days);
|
||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_RETENTION_DAYS; // 壞資料誠實退回預設,不讓損毀設定卡死清理
|
||
} catch {
|
||
return DEFAULT_RETENTION_DAYS;
|
||
}
|
||
}
|
||
|
||
/** 設定某租戶的保留天數。days=null=「不刪除」(企業稽核選項);days=正整數=自訂天數。 */
|
||
export async function setRetentionDays(
|
||
db: D1Database,
|
||
ownerId: string,
|
||
days: number | null,
|
||
): Promise<void> {
|
||
const id = retentionConfigId(ownerId);
|
||
const metadata = JSON.stringify({ retention_days: days, updated_at: Math.floor(Date.now() / 1000) });
|
||
const existing = await db.prepare(`SELECT id FROM entries WHERE id = ?`).bind(id).first();
|
||
if (existing) {
|
||
await db
|
||
.prepare(`UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?`)
|
||
.bind(metadata, id)
|
||
.run();
|
||
} else {
|
||
await db
|
||
.prepare(
|
||
`INSERT INTO entries (id, entry_type, owner_id, metadata_json) VALUES (?, 'execution_log_retention_config', ?, ?)`,
|
||
)
|
||
.bind(id, ownerId, metadata)
|
||
.run();
|
||
}
|
||
}
|
||
|
||
export interface CleanupResult {
|
||
deleted: number;
|
||
checked_overrides: number;
|
||
}
|
||
|
||
/**
|
||
* 清掉過期的執行紀錄(entry_type='execution_log' 且早於各自租戶的保留期限)。
|
||
* 分兩段跑:
|
||
* ① 有自訂天數的租戶:各自用自己的 cutoff 刪。
|
||
* ② 其餘(含無租戶/未設定過的租戶):套預設 90 天,但排除「已設不刪除」與
|
||
* 「剛才①處理過」的租戶,避免同一輪重複掃描。
|
||
* 每段各受 CLEANUP_BATCH_LIMIT 界限——呼叫端(cypher 每日一次 tick)長期呼叫可逐步清完累積量,
|
||
* 不追求一次清光(那樣單次 D1 查詢會過重,且清理本身不是使用者等待中的路徑,慢慢清沒有壞處)。
|
||
*/
|
||
export async function cleanupExpiredLogs(db: D1Database): Promise<CleanupResult> {
|
||
const nowSec = Math.floor(Date.now() / 1000);
|
||
|
||
const overridesRes = await db
|
||
.prepare(`SELECT owner_id, metadata_json FROM entries WHERE entry_type = 'execution_log_retention_config'`)
|
||
.all<{ owner_id: string | null; metadata_json: string | null }>();
|
||
const overrides = overridesRes.results ?? [];
|
||
|
||
const neverDeleteOwners: string[] = [];
|
||
const customOwners: Array<{ owner_id: string; days: number }> = [];
|
||
for (const row of overrides) {
|
||
if (!row.owner_id) continue;
|
||
let parsed: { retention_days?: number | null } = {};
|
||
try {
|
||
parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as typeof parsed) : {};
|
||
} catch {
|
||
continue; // 壞資料:不當成任何一種 override,讓該租戶回退到①之外的預設路徑
|
||
}
|
||
if (parsed.retention_days === null) {
|
||
neverDeleteOwners.push(row.owner_id);
|
||
} else {
|
||
const n = Number(parsed.retention_days);
|
||
if (Number.isFinite(n) && n > 0) customOwners.push({ owner_id: row.owner_id, days: n });
|
||
}
|
||
}
|
||
|
||
let deleted = 0;
|
||
|
||
// ① 自訂天數的租戶,各自 cutoff
|
||
for (const { owner_id, days } of customOwners) {
|
||
const cutoff = nowSec - days * 86400;
|
||
const res = await db
|
||
.prepare(
|
||
`DELETE FROM entries WHERE id IN (
|
||
SELECT id FROM entries WHERE entry_type = 'execution_log' AND owner_id = ? AND created_at < ?
|
||
LIMIT ?
|
||
)`,
|
||
)
|
||
.bind(owner_id, cutoff, CLEANUP_BATCH_LIMIT)
|
||
.run();
|
||
deleted += (res.meta?.changes as number | undefined) ?? 0;
|
||
}
|
||
|
||
// ② 其餘:預設 90 天,排除「不刪除」與①已處理的租戶
|
||
const defaultCutoff = nowSec - DEFAULT_RETENTION_DAYS * 86400;
|
||
const excluded = [...neverDeleteOwners, ...customOwners.map((o) => o.owner_id)];
|
||
const sql =
|
||
excluded.length > 0
|
||
? `DELETE FROM entries WHERE id IN (
|
||
SELECT id FROM entries WHERE entry_type = 'execution_log'
|
||
AND created_at < ?
|
||
AND (owner_id IS NULL OR owner_id NOT IN (${excluded.map(() => '?').join(',')}))
|
||
LIMIT ?
|
||
)`
|
||
: `DELETE FROM entries WHERE id IN (
|
||
SELECT id FROM entries WHERE entry_type = 'execution_log' AND created_at < ? LIMIT ?
|
||
)`;
|
||
const binds = excluded.length > 0 ? [defaultCutoff, ...excluded, CLEANUP_BATCH_LIMIT] : [defaultCutoff, CLEANUP_BATCH_LIMIT];
|
||
const res2 = await db.prepare(sql).bind(...binds).run();
|
||
deleted += (res2.meta?.changes as number | undefined) ?? 0;
|
||
|
||
return { deleted, checked_overrides: overrides.length };
|
||
}
|
||
|
||
// ── 測試專用 helpers(P7,2026-08-09) ──────────────────────────────────────
|
||
// 這支檔在 kbdb/src/actions/ 下(資料層 worker 自己=API-as-Wall 的牆本身,D38 允許在
|
||
// 這裡直接碰 D1)。單元測試(kbdb/tests/execution-log.test.ts)不該自己在測試檔裡寫原生
|
||
// SQL——那個檔在「牆外」,即使是測試治具也不該養成在那裡打 SQL 的習慣。所以把「插入一列
|
||
// 指定 created_at 的過期紀錄」「數某類設定列有幾筆」這兩個測試才需要的原語做成正式匯出的
|
||
// 函式,放在牆內、由牆內的程式碼實際執行 SQL,測試檔只呼叫函式——與正式的 recordExecutionLog
|
||
// 刻意不開放指定過去時間形成對照(那是正式寫入路徑的正確限制,這裡是測試的例外通道)。
|
||
|
||
/** 測試專用:直接寫一列指定 created_at 的 execution_log(模擬「N 天前寫入的紀錄」)。 */
|
||
export async function testInsertAgedExecutionLog(
|
||
db: D1Database,
|
||
id: string,
|
||
ownerId: string | null,
|
||
daysAgo: number,
|
||
): Promise<void> {
|
||
const createdAt = Math.floor(Date.now() / 1000) - daysAgo * 86400;
|
||
await db
|
||
.prepare(
|
||
`INSERT INTO entries (id, entry_type, owner_id, page_name, content, metadata_json, created_at)
|
||
VALUES (?, 'execution_log', ?, 'wf-aged', 'old', '{"verdict":"success","duration_ms":1}', ?)`,
|
||
)
|
||
.bind(id, ownerId, createdAt)
|
||
.run();
|
||
}
|
||
|
||
/** 測試專用:寫一列**損毀** metadata_json 的保留期設定(驗證 cleanupExpiredLogs 對壞資料的容錯)。 */
|
||
export async function testInsertBrokenRetentionConfig(db: D1Database, ownerId: string): Promise<void> {
|
||
await db
|
||
.prepare(
|
||
`INSERT INTO entries (id, entry_type, owner_id, metadata_json) VALUES (?, 'execution_log_retention_config', ?, ?)`,
|
||
)
|
||
.bind(retentionConfigId(ownerId), ownerId, '{not valid json')
|
||
.run();
|
||
}
|
||
|
||
/** 測試專用:數某租戶目前有幾列保留期設定(驗證 setRetentionDays 是 upsert,不是每次都新增一列)。 */
|
||
export async function testCountRetentionConfigRows(db: D1Database, ownerId: string): Promise<number> {
|
||
const row = await db
|
||
.prepare(`SELECT COUNT(*) as n FROM entries WHERE entry_type = 'execution_log_retention_config' AND owner_id = ?`)
|
||
.bind(ownerId)
|
||
.first<{ n: number }>();
|
||
return row?.n ?? 0;
|
||
}
|