feat(t135): 庫目錄可自主移除+標示還在不在同步

leo:「需要加移除按鈕。因為別人裝錯我沒辦法幫他弄,需要可以自主」
+「你應該要顯示這個庫沒有本地對應的 folder,那就不容易刪錯」
(兩態非三態——leo 二修:「分兩種沒意義」,那是內部狀態不是用戶分類)。
- DELETE /portal/admin/libraries/:id(登記簿)與 by-name/:name(auto 庫需輸入庫名確認)
- kbdb 加 deprecate-by-library(auto 庫移除=標 deprecated,資料保留可還原)
- daemon/libraries 存 active 清單 → 卡片標 🟢同步中/灰目前沒有在同步
- 不自動刪(daemon 可能沒開機);daemon 從未回報時整列不標
vitest 24 passed(1 紅=console HTML 搬遷陳舊測試,非本案)。
(實作=子 CC;驗證+commit=總管。含 t116/t117 先前未 commit 的 graph-executor/wasi-shim 修正)
This commit is contained in:
uncle6me-web
2026-07-29 14:45:44 +08:00
parent 6d4980d3d7
commit 0860e84d22
11 changed files with 362 additions and 5 deletions
+43
View File
@@ -1558,6 +1558,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
if (!adminLibs.length) { $('ad-libs').innerHTML = '<div class="muted" style="padding:16px 4px">同步小幫手還沒送來任何庫。裝好小幫手並選好資料夾後,庫會自動出現在這裡。</div>'; return; }
$('ad-libs').innerHTML = adminLibs.map(function (l) {
var disabled = l.status === 'disabled';
var notWatching = l.daemon_watching === false; // daemon 有報告但此庫不在其中
var removeBtn = l.auto
? '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove-auto" data-name="' + esc(l.name) + '">移除</button>'
: '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove" data-id="' + esc(l.record_id) + '" data-name="' + esc(l.display_name || l.name) + '">移除</button>';
return '<div class="card-item">' +
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(l.display_name || l.name) + '</span>' +
@@ -1565,8 +1569,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
(l.auto
? '<span class="tag green">啟用中</span><span class="tag">同步自動出現</span>'
: '<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>') +
removeBtn +
'</div>' +
(!l.auto && l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') +
(notWatching ? '<div style="margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.45)">小幫手目前沒有在同步這個資料夾</div>' : '') +
'</div>';
}).join('');
}
@@ -1682,6 +1688,43 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
// 移除已登記庫(有 record_id
if (act === 'lib-remove') {
var libId = t.getAttribute('data-id') || '';
var libName = t.getAttribute('data-name') || libId;
if (!confirm('把「' + libName + '」從目錄移除?\n\n資料不會刪除,重新同步時會再出現。')) return;
t.disabled = true;
adminApi('DELETE', '/portal/admin/libraries/' + encodeURIComponent(libId))
.then(function (x) {
t.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + '')); return; }
adminLibs = adminLibs.filter(function (l) { return l.record_id !== libId; });
renderAdminLibs();
toast('已從目錄移除');
})
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
// 移除 auto 庫(無 record_identries 標 deprecated
if (act === 'lib-remove-auto') {
var autoName = t.getAttribute('data-name') || '';
var input = window.prompt('移除「' + autoName + '」會讓它的內容不再被搜尋到(資料保留可還原)。\n\n請輸入庫名確認:');
if (input === null) return; // 取消
if (input.trim() !== autoName) { toast('庫名輸入不符,取消移除'); return; }
t.disabled = true;
adminApi('DELETE', '/portal/admin/libraries/by-name/' + encodeURIComponent(autoName), { confirm: autoName })
.then(function (x) {
t.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + '')); return; }
adminLibs = adminLibs.filter(function (l) { return l.name !== autoName; });
renderAdminLibs();
toast('已移除(' + (x.d.deprecated_count || 0) + ' 筆標為不可搜)');
})
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
});
// 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上)
$('ad-otp').addEventListener('click', function (ev) {
+20
View File
@@ -500,6 +500,26 @@ export class GraphExecutor {
iterResults.push(itemResult);
}
// t117: FOREACH 全部項目 success===false → 不再靜默,拋出含 status code 的錯誤
if (iterResults.length > 0) {
const failures = iterResults.filter(
r => r !== null && typeof r === 'object' && (r as Record<string, unknown>).success === false
);
if (failures.length === iterResults.length) {
const first = failures[0] as Record<string, unknown>;
const errParts: string[] = [];
if (first.error) errParts.push(String(first.error));
if (typeof first.status === 'number') errParts.push(`HTTP ${first.status}`);
const bodyData = first.data as { body?: string } | null | undefined;
if (bodyData && typeof bodyData.body === 'string' && bodyData.body) {
errParts.push(bodyData.body.slice(0, 200));
}
throw new Error(
`FOREACH 所有 ${iterResults.length} 項目均失敗(首項:${errParts.join('') || '未知錯誤'}`
);
}
}
result = { ...(result as Record<string, unknown>), results: iterResults };
break;
}
+9 -2
View File
@@ -353,8 +353,15 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
const result = await hostFunctions!.http_request!(url, method, headers, body);
// await 後重新拿 memory.buffergrow 會產生新的 ArrayBuffer
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
} catch {
return 1;
} catch (e) {
// t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
// 取代只 return 1WASM 寫無資訊的 "HTTP request failed")。
// writeOut 失敗(memory 壞)才 fallback return 1。
const errDetail = e instanceof Error ? e.message : String(e);
const errEnv = new TextEncoder().encode(
JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
);
return writeOut(memory.buffer, outPtr, outLenPtr, errEnv);
}
})
: () => 1,
+86 -2
View File
@@ -184,6 +184,18 @@ async function patchRecordValues(env: Bindings, recordId: string, values: Record
return body.record;
}
async function deleteKbdbRecord(env: Bindings, recordId: string): Promise<boolean> {
const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' });
if (res.status === 404) return false;
if (!res.ok) throw new KbdbError(`DELETE /records/${recordId}${res.status}`);
return true;
}
/** KV key for daemon's most-recently-reported active library namest135 daemon hint)。 */
function daemonActiveKey(env: Bindings): string {
return `${portalTenant(env)}:portal:daemon_active_libs`;
}
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
@@ -751,6 +763,11 @@ portalRouter.post('/portal/daemon/libraries', (c) =>
created.push(name);
}
const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
// t135:記下本次 daemon 回報的所有庫名(48h TTL)供 GET /portal/admin/libraries 顯示「未同步」提示。
const activeNames = wanted.map((item) => String(item?.name ?? '').trim()).filter(Boolean);
if (activeNames.length > 0) {
await c.env.WEBHOOKS.put(daemonActiveKey(c.env), JSON.stringify(activeNames), { expirationTtl: 172800 });
}
return c.json({ success: true, created, libraries: after.map(toPublicLibrary) });
}),
);
@@ -905,12 +922,23 @@ portalRouter.get('/portal/admin/extractor', (c) =>
// t52leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
// t135:讀 daemon 最近回報的 active libsKV TTL 48h),已登記的庫若不在其中標 daemon_watching:false。
portalRouter.get('/portal/admin/libraries', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
const out = libs.map(toPublicLibrary);
// 讀 daemon 最近回報的 active lib names(若 KV 不存在 = daemon 從未回報,不標 hint
let daemonActive: Set<string> | null = null;
try {
const raw = await c.env.WEBHOOKS.get(daemonActiveKey(c.env), 'text');
if (raw) daemonActive = new Set((JSON.parse(raw) as string[]).map((n) => String(n).trim()));
} catch { /* KV 不可達不擋主流程 */ }
const out = libs.map((rec) => {
const lib = toPublicLibrary(rec);
const watching = daemonActive === null ? undefined : daemonActive.has(lib.name);
return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) };
});
const known = new Set(out.map((l) => l.name));
// 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library
try {
@@ -922,7 +950,13 @@ portalRouter.get('/portal/admin/libraries', (c) =>
// general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉
if (!n || n === 'general' || known.has(n)) continue;
known.add(n);
out.push({ record_id: '', name: n, display_name: n, description: '資料同步時自動出現(可在此補顯示名)', status: 'active', graph_source: false, auto: true });
const watching = daemonActive === null ? undefined : daemonActive.has(n);
out.push({
record_id: '', name: n, display_name: n,
description: '資料同步時自動出現(可在此補顯示名)',
status: 'active', graph_source: false, auto: true,
...(watching !== undefined ? { daemon_watching: watching } : {}),
});
}
}
} catch {
@@ -1005,3 +1039,53 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) =>
return c.json({ success: true, library: toPublicLibrary(updated) });
}),
);
// DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。
// 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。
// ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。
// ⚠️ 此路由必須在 DELETE /:id 之前宣告(Hono 先到先比;by-name 否則被當成 :id)。
portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const name = decodeURIComponent(c.req.param('name'));
const body = await c.req.json().catch(() => null);
const confirm = String(body?.confirm ?? '').trim();
if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400);
const ownerId = portalTenant(c.env);
const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
method: 'PATCH',
body: JSON.stringify({ owner_id: ownerId, library: name }),
});
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
const data = (await res.json()) as { deprecated_count?: number };
return c.json({
success: true,
deprecated_count: data.deprecated_count ?? 0,
message: `已從自動清單移除「${name}」(共標記 ${data.deprecated_count ?? 0} 筆資料不可搜)。資料保留可還原——重新同步時會再出現。`,
});
}),
);
// DELETE /portal/admin/libraries/:id — 移除已登記庫(有 record_id 的登記簿 record)。
// 只刪登記簿那筆 record;知識資料(entries with library=name)完全不動。
// 資料若有的話,重新同步後會以 auto 庫重新出現。
portalRouter.delete('/portal/admin/libraries/:id', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const recordId = c.req.param('id');
// 成員資格驗(防憑空 id 打到不相干 record)
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
const target = libs.find((l) => l.record_id === recordId);
if (!target) return c.json({ error: '庫不存在' }, 404);
const found = await deleteKbdbRecord(c.env, recordId);
if (!found) return c.json({ error: '庫不存在' }, 404);
return c.json({
success: true,
name: target.values.name ?? '',
message: `已從目錄移除「${target.values.display_name ?? target.values.name ?? ''}」。資料仍在,重新同步會再出現。`,
});
}),
);
+58
View File
@@ -1,6 +1,8 @@
// Cypher Executor 端到端測試
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import { GraphExecutor } from '../src/graph-executor';
import type { ComponentRunner, ExecutionGraph } from '../src/types';
describe('GET /', () => {
it('回傳服務狀態', async () => {
@@ -191,4 +193,60 @@ describe('POST /execute', () => {
});
expect(res.status).toBe(400);
});
});
// t117: FOREACH 全部項目失敗 → 錯誤訊息含 status codeGraphExecutor 單元測試)
describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => {
it('FOREACH 所有項目 success:false(含 status 401)→ executor.execute() 拋出含 "401" 的錯誤', async () => {
// mock loader:任何零件都回 {success:false, status:401, error:"HTTP 401"}
const failLoader = async (_: string): Promise<ComponentRunner> =>
async () => ({ success: false, status: 401, error: 'HTTP 401', data: { body: 'Unauthorized' } });
const executor = new GraphExecutor(failLoader);
const graph: ExecutionGraph = {
id: 'foreach-fail-t117',
name: 'FOREACH 全失敗',
nodes: [
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
{ id: 'writer', type: 'Component', componentId: 'http_request' },
],
edges: [
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
],
};
// t117 核心驗證:全部失敗 → throw(不再靜默)
await expect(executor.execute(graph, {})).rejects.toThrow(/401/);
});
it('FOREACH 部分項目成功 → 不拋出(只有全部失敗才報錯)', async () => {
let callCount = 0;
// 第一次呼叫失敗,第二次成功(部分失敗不觸發 t117 all-fail 路徑)
const mixedLoader = async (_: string): Promise<ComponentRunner> =>
async () => {
callCount++;
if (callCount === 1) return { success: false, status: 401, error: 'HTTP 401' };
return { success: true, data: { ok: true } };
};
const executor = new GraphExecutor(mixedLoader);
const graph: ExecutionGraph = {
id: 'foreach-mixed-t117',
name: 'FOREACH 部分失敗',
nodes: [
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
{ id: 'writer', type: 'Component', componentId: 'http_request' },
],
edges: [
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
],
};
// 部分失敗 → 不拋出,正常回傳 results 陣列
const result = await executor.execute(graph, {});
expect(result).toBeDefined();
});
});
@@ -395,6 +395,82 @@ describe('/portal/admin/libraries', () => {
});
});
// ═══════════════ t135 庫目錄移除 ═══════════════
describe('DELETE /portal/admin/librariest135', () => {
it('DELETE /:id — 成功移除已登記庫;KBDB /records/:id DELETE 被呼叫', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
// 成員驗證:list by template 回有該 record
mockListByTemplate('portal_library', [
{ record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
]);
let deleteCalled = false;
fetchMock
.get(KBDB)
.intercept({ path: '/records/rec_lib1', method: 'DELETE' })
.reply(200, () => { deleteCalled = true; return { success: true }; });
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; name: string; message: string };
expect(data.success).toBe(true);
expect(data.name).toBe('finance');
expect(deleteCalled).toBe(true);
});
it('DELETE /:id — 庫不在目錄 → 404', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', []); // 空目錄
const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(404);
});
it('DELETE /:id — 非 admin → 403', async () => {
await seedAdminSession('tok-user', 'rec_u1');
mockGetRecord('rec_u1', userValues());
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' });
expect(res.status).toBe(403);
});
it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
let deprecateCalled = false;
fetchMock
.get(KBDB)
.intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' })
.reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; });
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; deprecated_count: number };
expect(data.success).toBe(true);
expect(data.deprecated_count).toBe(12);
expect(deprecateCalled).toBe(true);
});
it('DELETE /by-name/:name — 無 confirm → 400', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(400);
});
it('DELETE /by-name/:name — confirm 不符 → 400', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(400);
});
it('DELETE /by-name/:name — 非 admin → 403', async () => {
await seedAdminSession('tok-user', 'rec_u1');
mockGetRecord('rec_u1', userValues());
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' });
expect(res.status).toBe(403);
});
});
// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════
describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122', () => {
+21
View File
@@ -126,6 +126,27 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
}
/**
* owner entries deprecatedt135 by-name
* 沿 deprecated metadata_json.status='deprecated'
* deprecated 0 = deprecated
*/
export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string, library: string): Promise<number> {
const result = await db
.prepare(
`UPDATE entries
SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.status', 'deprecated'),
updated_at = unixepoch()
WHERE owner_id = ?
AND COALESCE(json_extract(metadata_json, '$.library'), 'general') = ?
AND (json_extract(metadata_json, '$.status') IS NULL
OR json_extract(metadata_json, '$.status') != 'deprecated')`,
)
.bind(ownerId, library)
.run();
return (result.meta?.changes as number | undefined) ?? 0;
}
// 「庫」filter 的 SQL 謂詞(portal-auth P1design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
+15
View File
@@ -209,3 +209,18 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
}
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
}
/** 刪除一筆 record:先刪 entry_valuesFK),再刪底層 entries。回 false 表示 record 不存在。 */
export async function deleteRecord(db: D1Database, recordId: string): Promise<boolean> {
const evRes = await db
.prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
.bind(recordId)
.all<{ entry_id: string }>();
const rows = evRes.results ?? [];
if (rows.length === 0) return false;
await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run();
for (const { entry_id } of rows) {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(entry_id).run();
}
return true;
}
+13
View File
@@ -3,6 +3,7 @@ import { Hono } from 'hono';
import type { Bindings } from '../types';
import {
createEntry,
deprecateEntriesByLibrary,
getEntry,
listEntries,
updateEntry,
@@ -139,6 +140,18 @@ entryRoutes.get('/:id', async (c) => {
return c.json({ success: true, entry });
});
// PATCH /entries/deprecate-by-library — body {owner_id, library}。
// t135:把某租戶某庫的所有 entries 標 deprecated,讓庫從 auto 清單消失。
// 此路由必須在 '/:id' 之前,否則 'deprecate-by-library' 會被當成 id 參數。
entryRoutes.patch('/deprecate-by-library', async (c) => {
const body = (await c.req.json().catch(() => null)) as { owner_id?: string; library?: string } | null;
const ownerId = String(body?.owner_id ?? '').trim();
const library = String(body?.library ?? '').trim();
if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400);
const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library);
return c.json({ success: true, deprecated_count: count });
});
// PATCH /entries/:id
entryRoutes.patch('/:id', async (c) => {
const body = await c.req.json().catch(() => ({}));
+8 -1
View File
@@ -1,7 +1,7 @@
// Records route — structured records (entry_values composed by a template).
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { createRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
@@ -47,3 +47,10 @@ recordRoutes.patch('/:recordId', async (c) => {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
}
});
// DELETE /records/:recordId — 刪除一筆 record 及其底層 entries。
recordRoutes.delete('/:recordId', async (c) => {
const found = await deleteRecord(c.env.DB, c.req.param('recordId'));
if (!found) return c.json({ success: false, error: 'not found' }, 404);
return c.json({ success: true });
});
@@ -277,6 +277,19 @@
執行範圍:`cypher-executor/src/routes/portal-data.ts`(新增 export 函式 + chat route)。
測試:portal-data.test.ts 新增 5 案(t129 describe:合併計數/各保一筆/page 備用/空陣列/page_name 優先)。
- [x] **t135 庫目錄移除(2026-07-29,任務層小改)**
來源=leo 裁定「需要加移除按鈕,別人裝錯我沒辦法幫他弄,需要可以自主」。
後端:`DELETE /portal/admin/libraries/:id`(刪登記簿 record,資料不動)+
`DELETE /portal/admin/libraries/by-name/:name`auto 庫;body `confirm:<庫名>` 才執行;
呼叫 KBDB `PATCH /entries/deprecate-by-library`entries 標 deprecated → 從 auto 清單消失)。
KBDB`deleteRecord` action + `DELETE /records/:id` route
`deprecateEntriesByLibrary` action + `PATCH /entries/deprecate-by-library` route(此路由在 `/:id` 之前)。
Daemon hint(根治只增不減):`POST /portal/daemon/libraries` 存 active lib names 到 KVTTL 48h);
`GET /portal/admin/libraries` 讀 KV 後對登記庫標 `daemon_watching: false`(若 daemon 有回報但未含該庫)。
前端:每張庫卡片加「移除」按鈕(登記庫=`confirm()` 對話框;auto 庫=`prompt()` 輸入庫名確認);
移除後即時從列表消失(不必重整);`daemon_watching=false` → 顯示灰字「小幫手目前沒有在同步這個資料夾」。
測試:portal-admin.test.ts 新增 7 案(DELETE/:id 成功/404/非admin403by-name 成功/無confirm/confirm不符/非admin403)。
## 第二波(不在本 SDD 動工範圍,掛號)
- MCP token 綁庫集合(design §9PR#15 擴充,只動 `mcp/`