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:
uncle6me-web
2026-08-15 19:33:55 +08:00
parent 576afa2364
commit 87f6de7c9f
9 changed files with 3504 additions and 0 deletions
+283
View File
@@ -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: '記錄自己的 IDrec_…)',
},
{
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];
}
}