feat(n8n): KBDB community node 薄殼——對照表 ✅ 那半的八個動作
leo 2026-08-15「有效才正式開發」下一格:紙上考卷驗過的動作集做成真的 n8n 節點。 詞彙照定案(sheet/field/record,InkStoneCo system-dev/wiki/kbdb-詞彙表.md)。 - 動作 ↔ API 映射收斂在 nodes/Kbdb/actions.ts(純函式),20 個 vitest 釘死 ——就是動作對照表 ✅ 那半的機器版;🔴/◐ 的動作刻意不存在,呼叫丟 「沒有這個能力」(07-thin-shell §3.1:API 沒有的能力不拼裝) - 對外走 cypher-executor /kbdb/* proxy(X-Arcrun-API-Key),不直連 kbdb worker - Add a field 不掛:proxy 無 PATCH /kbdb/templates(實測 404);base 端 PATCH /templates/:id 語意是 slots 整組覆蓋(本地實測),不是加一欄 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
IHttpRequestMethods,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { buildRequest, type ActionParams } from './actions';
|
||||
|
||||
/**
|
||||
* KBDB node(薄殼)。
|
||||
*
|
||||
* 詞彙依 leo 2026-08-15 定案(InkStoneCo `system-dev/wiki/kbdb-詞彙表.md`):
|
||||
* 對外只有三個字——sheet(一張表=一個看法)/field(一欄)/record(一筆記錄,
|
||||
* 有自己的 ID,同一筆可以出現在好幾張表裡)。
|
||||
*
|
||||
* 動作集=動作對照表標 ✅ 的那半。標 🔴(關係整組、Archive 兩個)與 ◐ 裡
|
||||
* 語意不成立的(Add a field),**刻意不在這裡**——API 沒有的能力,薄殼不拼裝。
|
||||
*/
|
||||
export class Kbdb implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'KBDB',
|
||||
name: 'kbdb',
|
||||
icon: 'file:kbdb.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description:
|
||||
'KBDB:把東西和「怎麼看東西」拆開的資料庫。先把 record 丟進去,再用 sheet 決定怎麼看它——不建表、不用 SQL、不用工程師。',
|
||||
defaults: { name: 'KBDB' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [{ name: 'kbdbApi', required: true }],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{ name: 'Record', value: 'record', description: '一筆記錄。有自己的 ID' },
|
||||
{ name: 'Sheet', value: 'sheet', description: '一張表=一個看法。收起它,記錄不會消失' },
|
||||
{ name: 'Search', value: 'search', description: '搜尋你存進 KBDB 的內容' },
|
||||
],
|
||||
default: 'record',
|
||||
},
|
||||
// ── record operations ────────────────────────────────────────────
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['record'] } },
|
||||
options: [
|
||||
{
|
||||
name: 'Append Record',
|
||||
value: 'append',
|
||||
description: '在一張 sheet 裡新增一筆記錄',
|
||||
action: 'Append a record to a sheet',
|
||||
},
|
||||
{
|
||||
name: 'Get a Record',
|
||||
value: 'get',
|
||||
description: '用 record ID 取回一筆記錄',
|
||||
action: 'Get a record',
|
||||
},
|
||||
{
|
||||
name: 'Get Many Records',
|
||||
value: 'getMany',
|
||||
description: '列出一張 sheet 裡(你 namespace 底下)的所有記錄',
|
||||
action: 'Get many records from a sheet',
|
||||
},
|
||||
{
|
||||
name: 'Update a Record',
|
||||
value: 'update',
|
||||
description: '改一筆既有記錄的欄位值(只改你給的欄,其他欄不動)',
|
||||
action: 'Update a record',
|
||||
},
|
||||
],
|
||||
default: 'append',
|
||||
},
|
||||
// ── sheet operations ─────────────────────────────────────────────
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['sheet'] } },
|
||||
options: [
|
||||
{
|
||||
name: 'Create a Sheet',
|
||||
value: 'create',
|
||||
description: '開一張新表=寫下你想看哪幾個欄位。不建實體表、不用等人',
|
||||
action: 'Create a sheet',
|
||||
},
|
||||
{
|
||||
name: 'Get Sheet Schema',
|
||||
value: 'getSchema',
|
||||
description: '看一張 sheet 有哪些欄位',
|
||||
action: 'Get sheet schema',
|
||||
},
|
||||
{
|
||||
name: 'List Sheets',
|
||||
value: 'list',
|
||||
description: '列出所有 sheet',
|
||||
action: 'List sheets',
|
||||
},
|
||||
],
|
||||
default: 'list',
|
||||
},
|
||||
// ── search operations ────────────────────────────────────────────
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['search'] } },
|
||||
options: [
|
||||
{
|
||||
name: 'Search',
|
||||
value: 'search',
|
||||
description:
|
||||
'全文/語意搜尋。搜的是內容本身:命中回「那一格的內容」(含 entry id),不是整筆 record,也不含同筆的其他欄——目前沒有從命中反查所屬 record 的能力',
|
||||
action: 'Search KBDB',
|
||||
},
|
||||
],
|
||||
default: 'search',
|
||||
},
|
||||
// ── shared fields ────────────────────────────────────────────────
|
||||
{
|
||||
displayName: 'Sheet',
|
||||
name: 'sheet',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['record', 'sheet'],
|
||||
operation: ['append', 'getMany', 'create', 'getSchema'],
|
||||
},
|
||||
},
|
||||
description: 'Sheet 名稱(或 ID)',
|
||||
},
|
||||
{
|
||||
displayName: 'Record ID',
|
||||
name: 'recordId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: { show: { resource: ['record'], operation: ['get', 'update'] } },
|
||||
description: '記錄自己的 ID(rec_…)',
|
||||
},
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
type: 'json',
|
||||
default: '{}',
|
||||
required: true,
|
||||
displayOptions: { show: { resource: ['record'], operation: ['append', 'update'] } },
|
||||
description: '欄位值,JSON 物件:{"欄名": "內容"}。Update 時只需給要改的欄',
|
||||
},
|
||||
{
|
||||
displayName: 'Fields',
|
||||
name: 'fields',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: { show: { resource: ['sheet'], operation: ['create'] } },
|
||||
placeholder: 'name, phone, email',
|
||||
description: '這張表要看哪幾欄,逗號分隔',
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: { show: { resource: ['sheet'], operation: ['create'] } },
|
||||
description: '這張 sheet 是做什麼的(選填)',
|
||||
},
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: { show: { resource: ['search'] } },
|
||||
description: '要找什麼',
|
||||
},
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Keyword', value: 'keyword' },
|
||||
{ name: 'Semantic', value: 'semantic' },
|
||||
],
|
||||
default: 'keyword',
|
||||
displayOptions: { show: { resource: ['search'] } },
|
||||
description: 'Keyword=字面命中;Semantic=語意相近(實例沒開語意模組時會誠實降級成 keyword)',
|
||||
},
|
||||
{
|
||||
displayName: 'Filters',
|
||||
name: 'filters',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Filter',
|
||||
default: {},
|
||||
displayOptions: { show: { resource: ['search'] } },
|
||||
options: [
|
||||
{ displayName: 'Library', name: 'library', type: 'string', default: '' },
|
||||
{ displayName: 'Source', name: 'source', type: 'string', default: '' },
|
||||
{ displayName: 'Entry Type', name: 'entry_type', type: 'string', default: '' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const credentials = await this.getCredentials('kbdbApi');
|
||||
const baseUrl = String(credentials.baseUrl).replace(/\/$/, '');
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const resource = this.getNodeParameter('resource', i) as string;
|
||||
const operation = this.getNodeParameter('operation', i) as string;
|
||||
|
||||
const params: ActionParams = {};
|
||||
if (resource === 'record') {
|
||||
if (operation === 'append' || operation === 'getMany') {
|
||||
params.sheet = this.getNodeParameter('sheet', i) as string;
|
||||
}
|
||||
if (operation === 'get' || operation === 'update') {
|
||||
params.recordId = this.getNodeParameter('recordId', i) as string;
|
||||
}
|
||||
if (operation === 'append' || operation === 'update') {
|
||||
const raw = this.getNodeParameter('values', i);
|
||||
params.values = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Record<string, string>;
|
||||
}
|
||||
} else if (resource === 'sheet') {
|
||||
if (operation !== 'list') {
|
||||
params.sheet = this.getNodeParameter('sheet', i) as string;
|
||||
}
|
||||
if (operation === 'create') {
|
||||
params.slots = (this.getNodeParameter('fields', i) as string)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
params.description = this.getNodeParameter('description', i, '') as string;
|
||||
}
|
||||
} else if (resource === 'search') {
|
||||
params.query = this.getNodeParameter('query', i) as string;
|
||||
params.mode = this.getNodeParameter('mode', i, 'keyword') as 'keyword' | 'semantic';
|
||||
params.filters = this.getNodeParameter('filters', i, {}) as ActionParams['filters'];
|
||||
}
|
||||
|
||||
const req = buildRequest(resource, operation, params);
|
||||
const response = await this.helpers.httpRequestWithAuthentication.call(this, 'kbdbApi', {
|
||||
method: req.method as IHttpRequestMethods,
|
||||
url: `${baseUrl}${req.path}`,
|
||||
qs: req.qs,
|
||||
body: req.body,
|
||||
json: true,
|
||||
});
|
||||
|
||||
const asArray = Array.isArray(response) ? response : [response];
|
||||
for (const entry of asArray) {
|
||||
returnData.push({ json: entry as IDataObject, pairedItem: { item: i } });
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });
|
||||
}
|
||||
}
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* KBDB 動作 → HTTP 請求的純映射(薄殼,rule 07)。
|
||||
*
|
||||
* 每個動作=**恰好一個** API 呼叫。這個檔案刻意是純函式,讓「動作對到哪支 API」
|
||||
* 可以被單元測試釘死——它就是 `system-dev/docs/4-guides/kbdb-動作對照表.md`(頂層
|
||||
* InkStoneCo)標 ✅ 那半的機器版。
|
||||
*
|
||||
* 🔴 禁止在這裡拼裝:對照表標 🔴/◐ 而 API 沒有的能力(Archive、關係整組、
|
||||
* Add a field),這裡**沒有**對應動作——不是漏做,是「回報沒有能力」的實作方式
|
||||
* (見 .claude/rules/07-thin-shell.md §3.1;D91/D92 正是拼裝出來的)。
|
||||
*
|
||||
* 對外的門=cypher-executor 的 /kbdb/* proxy(X-Arcrun-API-Key 租戶隔離)。
|
||||
* KBDB worker 本體只收系統內部的 Bearer,外面打一定 401(設計,不是故障)。
|
||||
*/
|
||||
|
||||
export interface KbdbRequest {
|
||||
method: 'GET' | 'POST' | 'PATCH';
|
||||
path: string;
|
||||
body?: Record<string, unknown>;
|
||||
qs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ActionParams {
|
||||
sheet?: string;
|
||||
recordId?: string;
|
||||
values?: Record<string, string>;
|
||||
slots?: string[];
|
||||
description?: string;
|
||||
query?: string;
|
||||
mode?: 'keyword' | 'semantic';
|
||||
filters?: { library?: string; source?: string; entry_type?: string };
|
||||
}
|
||||
|
||||
function need<T>(v: T | undefined | null, what: string): T {
|
||||
if (v === undefined || v === null || (typeof v === 'string' && v === '')) {
|
||||
throw new Error(`缺少必要參數:${what}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function buildRequest(resource: string, operation: string, p: ActionParams): KbdbRequest {
|
||||
const key = `${resource}.${operation}`;
|
||||
switch (key) {
|
||||
// ── record ─────────────────────────────────────────────
|
||||
case 'record.append':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: '/kbdb/records',
|
||||
body: { template: need(p.sheet, 'sheet'), values: need(p.values, 'values') },
|
||||
};
|
||||
case 'record.get':
|
||||
return { method: 'GET', path: `/kbdb/records/${encodeURIComponent(need(p.recordId, 'recordId'))}` };
|
||||
case 'record.getMany':
|
||||
return { method: 'GET', path: `/kbdb/records/by-template/${encodeURIComponent(need(p.sheet, 'sheet'))}` };
|
||||
case 'record.update':
|
||||
return {
|
||||
method: 'PATCH',
|
||||
path: `/kbdb/records/${encodeURIComponent(need(p.recordId, 'recordId'))}`,
|
||||
body: { values: need(p.values, 'values') },
|
||||
};
|
||||
// ── sheet ──────────────────────────────────────────────
|
||||
case 'sheet.create': {
|
||||
const slots = need(p.slots, 'fields');
|
||||
if (!Array.isArray(slots) || slots.length === 0) throw new Error('fields 至少要有一欄');
|
||||
const body: Record<string, unknown> = { name: need(p.sheet, 'sheet'), slots };
|
||||
if (p.description) body.description = p.description;
|
||||
return { method: 'POST', path: '/kbdb/templates', body };
|
||||
}
|
||||
case 'sheet.getSchema':
|
||||
return { method: 'GET', path: `/kbdb/templates/${encodeURIComponent(need(p.sheet, 'sheet'))}` };
|
||||
case 'sheet.list':
|
||||
return { method: 'GET', path: '/kbdb/templates' };
|
||||
// ── search ─────────────────────────────────────────────
|
||||
case 'search.search': {
|
||||
const qs: Record<string, string> = { q: need(p.query, 'query') };
|
||||
if (p.mode) qs.mode = p.mode;
|
||||
for (const k of ['library', 'source', 'entry_type'] as const) {
|
||||
const v = p.filters?.[k];
|
||||
if (v) qs[k] = v;
|
||||
}
|
||||
return { method: 'GET', path: '/kbdb/search', qs };
|
||||
}
|
||||
default:
|
||||
// 誠實回報,不拼裝(07-thin-shell §3.1;對照表「怎麼用這張表」第 2 條)
|
||||
throw new Error(`KBDB 目前沒有「${key}」這個能力(API 端不存在,薄殼不拼裝)`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60">
|
||||
<rect width="60" height="60" rx="12" fill="#1f2733"/>
|
||||
<circle cx="20" cy="22" r="6" fill="#5fd4a7"/>
|
||||
<circle cx="41" cy="18" r="5" fill="#7aa7ff"/>
|
||||
<circle cx="36" cy="41" r="7" fill="#ffb45f"/>
|
||||
<line x1="20" y1="22" x2="41" y2="18" stroke="#8fa3bd" stroke-width="2"/>
|
||||
<line x1="20" y1="22" x2="36" y2="41" stroke="#8fa3bd" stroke-width="2"/>
|
||||
<line x1="41" y1="18" x2="36" y2="41" stroke="#8fa3bd" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 499 B |
Reference in New Issue
Block a user