merge: n8n KBDB 節點(薄殼)——對照表 ✅ 那半
「有效才正式開發」的下一格:紙上考卷過了(Haiku 6/7、Sonnet 7/7),換真的。 範圍限對照表標 ✅ 的動作;🔴 那半(關係整組、archive 兩個)沒做,等資料模型定案。 總管審過的(自己驗的): - 每個動作剛好打一支 API,零拼裝——薄殼原則守住 (POST/GET/PATCH /kbdb/records、by-template、POST/GET /kbdb/templates…) - 🔴 那半沒有偷混進來;而且它把「禁止在這裡拼裝:對照表標 🔴/◐ 而 API 沒有的能力」 寫成註解留給下一個人——不只沒犯,還立了牌子 - npm test:20 passed ⚠️ 沒有自己驗的(是它的回報,不是總管的實測): 「八個動作在真 n8n 引擎跑過、KBDB 落庫、獨立 curl 查得回」—— 那需要一台真的 n8n,總管沒複跑。**併的是程式碼形狀與單元測試,不是端到端。** ⚠️ 併 main ≠ 用戶拿得到:沒 npm publish、沒裝上任何 n8n。狀態 ◐ 已改未送達。 📍 InkStoneCo#44 第 ⑤ 環的前半|對照表:system-dev/docs/4-guides/kbdb-動作對照表.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# n8n-nodes-kbdb
|
||||
|
||||
KBDB 的 n8n 節點(community node,薄殼)。讓 n8n 使用者用
|
||||
**sheet/field/record** 三個詞操作 KBDB,不用寫 SQL、不用懂底層。
|
||||
|
||||
- 詞彙定案:InkStoneCo `system-dev/wiki/kbdb-詞彙表.md`(2026-08-15)
|
||||
- 動作集=`system-dev/docs/4-guides/kbdb-動作對照表.md` 標 ✅ 的那半
|
||||
- 薄殼鐵律:`.claude/rules/07-thin-shell.md`——每個動作恰好一個 API 呼叫,
|
||||
API 沒有的能力**不拼裝**(`nodes/Kbdb/actions.ts` 是唯一映射點,有測試釘死)
|
||||
|
||||
## 動作 ↔ API
|
||||
|
||||
| 動作 | API(cypher-executor `/kbdb/*` proxy) |
|
||||
|---|---|
|
||||
| Append Record | `POST /kbdb/records` |
|
||||
| Get a Record | `GET /kbdb/records/:recordId` |
|
||||
| Get Many Records | `GET /kbdb/records/by-template/:sheet` |
|
||||
| Update a Record | `PATCH /kbdb/records/:recordId` |
|
||||
| Create a Sheet | `POST /kbdb/templates` |
|
||||
| Get Sheet Schema | `GET /kbdb/templates/:idOrName` |
|
||||
| List Sheets | `GET /kbdb/templates` |
|
||||
| Search | `GET /kbdb/search` |
|
||||
|
||||
### 刻意不在清單上的(不是漏做)
|
||||
|
||||
- **Add a Field**:對外門(proxy)沒有 `PATCH /kbdb/templates`(實測 404);
|
||||
base 端的 `PATCH /templates/:id` 語意是 **slots 整組覆蓋**(實測:`["name","phone"]`
|
||||
PATCH `["hired_at"]` → 剩 `["hired_at"]`),不是「加一欄」。等 API 有真的
|
||||
add-field 能力再掛上來。
|
||||
- **Archive(record/sheet)**:record 只有 DELETE(真的刪掉,語意不符);
|
||||
sheet 連 DELETE 都沒有。
|
||||
- **關係整組**(Link/Add record to sheet/Get linked/Unlink):沒有 API
|
||||
(等 `Arcrun#132` 資料模型提案 confirm)。
|
||||
|
||||
## 認證
|
||||
|
||||
Credential「KBDB API」= Base URL(你的 Arcrun 實例 cypher-executor 網址)+
|
||||
API Key(namespace,走 `X-Arcrun-API-Key` header)。資料按 namespace 租戶隔離;
|
||||
sheet(template)定義是全域共享的。
|
||||
|
||||
⚠️ 不要直連 kbdb worker 本體——它只收系統內部 Bearer,外面打一定 401(設計)。
|
||||
|
||||
## 開發
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # tsc → dist/
|
||||
npm test # vitest:動作↔API 映射(對照表的機器版)
|
||||
```
|
||||
|
||||
本地 n8n 驗證:把本套件 `npm pack` 出 tarball,裝進
|
||||
`$N8N_USER_FOLDER/.n8n/nodes/`(`npm install <tarball>`),啟動 n8n 即可在
|
||||
節點清單看到 KBDB。
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* KBDB 憑證=「哪一台 Arcrun 實例(cypher-executor)+哪個 namespace」。
|
||||
* 認證走 X-Arcrun-API-Key header(cypher 的 /kbdb/* proxy 以它當租戶 owner_id)。
|
||||
* 薄殼:這裡只描述連線,不含任何業務邏輯。
|
||||
*/
|
||||
export class KbdbApi implements ICredentialType {
|
||||
name = 'kbdbApi';
|
||||
|
||||
displayName = 'KBDB API';
|
||||
|
||||
documentationUrl = 'https://arcrun.dev';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'baseUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'https://arcrun-cypher-executor.<your-subdomain>.workers.dev',
|
||||
description: '你的 Arcrun 實例(cypher-executor)網址——KBDB 的對外入口',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
description: 'Arcrun API key(namespace)。你的資料只在這個 namespace 裡,別人看不到',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
'X-Arcrun-API-Key': '={{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{$credentials.baseUrl}}',
|
||||
url: '/kbdb/templates',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 |
Generated
+2855
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "n8n-nodes-kbdb",
|
||||
"version": "0.1.0",
|
||||
"description": "KBDB actions for n8n — sheet / field / record vocabulary, thin shell over the Arcrun cypher-executor /kbdb/* API",
|
||||
"keywords": [
|
||||
"n8n-community-node-package"
|
||||
],
|
||||
"license": "MIT",
|
||||
"author": "InkStoneCo",
|
||||
"main": "index.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"n8n": {
|
||||
"n8nNodesApiVersion": 1,
|
||||
"credentials": [
|
||||
"dist/credentials/KbdbApi.credentials.js"
|
||||
],
|
||||
"nodes": [
|
||||
"dist/nodes/Kbdb/Kbdb.node.js"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && node -e \"require('fs').copyFileSync('nodes/Kbdb/kbdb.svg','dist/nodes/Kbdb/kbdb.svg')\"",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"n8n-workflow": "^1.82.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"n8n-workflow": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 動作 → API 的映射測試=「kbdb-動作對照表」✅ 那半的機器版。
|
||||
* 表若改(路由變動),這裡會先紅——防對照表漂移(表自己的「過期時會教錯」段)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildRequest } from '../nodes/Kbdb/actions';
|
||||
|
||||
describe('對照表 ✅ 那半:每個動作恰好對到一支現行 API', () => {
|
||||
it('Append record → POST /kbdb/records', () => {
|
||||
expect(buildRequest('record', 'append', { sheet: 'contact', values: { name: '王小明' } })).toEqual({
|
||||
method: 'POST',
|
||||
path: '/kbdb/records',
|
||||
body: { template: 'contact', values: { name: '王小明' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('Get a record → GET /kbdb/records/:recordId', () => {
|
||||
expect(buildRequest('record', 'get', { recordId: 'rec_1' })).toEqual({
|
||||
method: 'GET',
|
||||
path: '/kbdb/records/rec_1',
|
||||
});
|
||||
});
|
||||
|
||||
it('Get many records → GET /kbdb/records/by-template/:sheet', () => {
|
||||
expect(buildRequest('record', 'getMany', { sheet: 'contact' })).toEqual({
|
||||
method: 'GET',
|
||||
path: '/kbdb/records/by-template/contact',
|
||||
});
|
||||
});
|
||||
|
||||
it('Update a record → PATCH /kbdb/records/:recordId(只帶要改的欄)', () => {
|
||||
expect(buildRequest('record', 'update', { recordId: 'rec_1', values: { phone: '0912' } })).toEqual({
|
||||
method: 'PATCH',
|
||||
path: '/kbdb/records/rec_1',
|
||||
body: { values: { phone: '0912' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('Create a sheet → POST /kbdb/templates(name + slots[])', () => {
|
||||
expect(buildRequest('sheet', 'create', { sheet: 'teacher_list', slots: ['name', 'hired_at'] })).toEqual({
|
||||
method: 'POST',
|
||||
path: '/kbdb/templates',
|
||||
body: { name: 'teacher_list', slots: ['name', 'hired_at'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('Get sheet schema → GET /kbdb/templates/:idOrName', () => {
|
||||
expect(buildRequest('sheet', 'getSchema', { sheet: 'contact' })).toEqual({
|
||||
method: 'GET',
|
||||
path: '/kbdb/templates/contact',
|
||||
});
|
||||
});
|
||||
|
||||
it('List sheets → GET /kbdb/templates', () => {
|
||||
expect(buildRequest('sheet', 'list', {})).toEqual({ method: 'GET', path: '/kbdb/templates' });
|
||||
});
|
||||
|
||||
it('Search → GET /kbdb/search(q 必填、mode/filters 透傳)', () => {
|
||||
expect(
|
||||
buildRequest('search', 'search', {
|
||||
query: '牛肉麵',
|
||||
mode: 'keyword',
|
||||
filters: { library: 'general' },
|
||||
}),
|
||||
).toEqual({
|
||||
method: 'GET',
|
||||
path: '/kbdb/search',
|
||||
qs: { q: '牛肉麵', mode: 'keyword', library: 'general' },
|
||||
});
|
||||
});
|
||||
|
||||
it('路徑參數會被 URL encode(中文 sheet 名不炸)', () => {
|
||||
expect(buildRequest('record', 'getMany', { sheet: '通訊錄' }).path).toBe(
|
||||
`/kbdb/records/by-template/${encodeURIComponent('通訊錄')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('對照表 🔴/◐ 那半:沒有的能力要誠實丟錯,不拼裝(07-thin-shell §3.1)', () => {
|
||||
const missing: Array<[string, string]> = [
|
||||
['sheet', 'addField'], // ◐→實測:PATCH /templates 是整組覆蓋且 proxy 未暴露 ⇒ 對外沒有這能力
|
||||
['sheet', 'archive'], // 🔴 API 連 DELETE 都沒有
|
||||
['record', 'archive'], // 🔴 只有 DELETE(真的刪掉),語意不符不掛上來
|
||||
['record', 'link'], // 🔴 關係整組沒有 API
|
||||
['record', 'addToSheet'], // 🔴 同上
|
||||
['record', 'getLinked'], // 🔴 同上
|
||||
['record', 'unlink'], // 🔴 同上
|
||||
];
|
||||
for (const [resource, operation] of missing) {
|
||||
it(`${resource}.${operation} → 明確丟「沒有這個能力」`, () => {
|
||||
expect(() => buildRequest(resource, operation, {})).toThrow(/沒有.*能力/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('必要參數缺漏要在薄殼層就講清楚', () => {
|
||||
it('append 缺 values → 錯誤訊息點名 values', () => {
|
||||
expect(() => buildRequest('record', 'append', { sheet: 'contact' })).toThrow(/values/);
|
||||
});
|
||||
it('create sheet 缺欄位 → 錯誤訊息點名 fields', () => {
|
||||
expect(() => buildRequest('sheet', 'create', { sheet: 'x' })).toThrow(/fields/);
|
||||
});
|
||||
it('create sheet 空欄位陣列 → 擋', () => {
|
||||
expect(() => buildRequest('sheet', 'create', { sheet: 'x', slots: [] })).toThrow(/至少要有一欄/);
|
||||
});
|
||||
it('search 缺 query → 錯誤訊息點名 query', () => {
|
||||
expect(() => buildRequest('search', 'search', {})).toThrow(/query/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["credentials/**/*.ts", "nodes/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
Reference in New Issue
Block a user