From 28f82ab9af51a75cdecc79dd27b03298958ee899 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:03:17 +0000 Subject: [PATCH 1/2] chore(deps): update dependency tsx to v4.23.11 --- package.json | 2 +- pnpm-lock.yaml | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index a419213c..9be7ee38 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "globals": "17.9.0", "npm": "11.19.0", "prettier": "3.8.5", - "tsx": "4.23.9", + "tsx": "4.23.11", "typescript": "5.9.3", "vitest": "4.1.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93b1bcdf..bfc2d324 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,14 +67,14 @@ importers: specifier: 3.8.5 version: 3.8.5 tsx: - specifier: 4.23.9 - version: 4.23.9 + specifier: 4.23.11 + version: 4.23.11 typescript: specifier: 5.9.3 version: 5.9.3 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@22.20.1)(vite@7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.9)) + version: 4.1.10(@types/node@22.20.1)(vite@7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.11)) packages: @@ -3724,8 +3724,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.9: - resolution: {integrity: sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw==} + tsx@4.23.11: + resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==} engines: {node: '>=18.0.0'} hasBin: true @@ -5468,13 +5468,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.9))': + '@vitest/mocker@4.1.10(vite@7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.11))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.9) + vite: 7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.11) '@vitest/pretty-format@4.1.10': dependencies: @@ -7896,7 +7896,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.9: + tsx@4.23.11: dependencies: esbuild: 0.28.0 optionalDependencies: @@ -8064,7 +8064,7 @@ snapshots: unist-util-stringify-position: 2.0.3 vfile-message: 2.0.4 - vite@7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.9): + vite@7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.11): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.5) @@ -8076,12 +8076,12 @@ snapshots: '@types/node': 22.20.1 fsevents: 2.3.3 jiti: 2.5.1 - tsx: 4.23.9 + tsx: 4.23.11 - vitest@4.1.10(@types/node@22.20.1)(vite@7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.9)): + vitest@4.1.10(@types/node@22.20.1)(vite@7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.11)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.9)) + '@vitest/mocker': 4.1.10(vite@7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.11)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -8098,7 +8098,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.9) + vite: 7.0.5(@types/node@22.20.1)(jiti@2.5.1)(tsx@4.23.11) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 From eff66955710fca11264fb016392cc3040718e015 Mon Sep 17 00:00:00 2001 From: Tom8810 Date: Thu, 13 Aug 2026 16:19:01 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E5=A4=A7=E9=87=8F=E3=83=AC?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=89=E3=82=92JSON=E3=82=92=E7=94=A8?= =?UTF-8?q?=E3=81=84=E3=81=A6=E6=93=8D=E4=BD=9C=E3=81=99=E3=82=8B=E3=83=84?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 59 +- README_en.md | 59 +- manifest.json | 12 + src/lib/record-file.ts | 451 +++++++++++++ src/schema/record/index.ts | 10 + src/schema/record/record-file.ts | 101 +++ src/tools/index.ts | 6 + .../__tests__/export-records-to-file.test.ts | 408 ++++++++++++ .../import-records-from-file.test.ts | 559 ++++++++++++++++ .../update-records-from-file.test.ts | 629 ++++++++++++++++++ .../kintone/record/export-records-to-file.ts | 239 +++++++ src/tools/kintone/record/get-records.ts | 6 +- .../record/import-records-from-file.ts | 186 ++++++ .../record/update-records-from-file.ts | 317 +++++++++ 14 files changed, 2983 insertions(+), 59 deletions(-) create mode 100644 src/lib/record-file.ts create mode 100644 src/schema/record/record-file.ts create mode 100644 src/tools/kintone/record/__tests__/export-records-to-file.test.ts create mode 100644 src/tools/kintone/record/__tests__/import-records-from-file.test.ts create mode 100644 src/tools/kintone/record/__tests__/update-records-from-file.test.ts create mode 100644 src/tools/kintone/record/export-records-to-file.ts create mode 100644 src/tools/kintone/record/import-records-from-file.ts create mode 100644 src/tools/kintone/record/update-records-from-file.ts diff --git a/README.md b/README.md index f90e7cef..0c6449ed 100644 --- a/README.md +++ b/README.md @@ -191,34 +191,37 @@ export HTTPS_PROXY="http://username:password@proxy.example.com:8080" ## ツール一覧 -| ツール名 | 説明 | -| --------------------------------- | -------------------------------------- | -| `kintone-get-apps` | 複数のアプリ情報を取得 | -| `kintone-get-app` | 単一アプリの詳細情報を取得 | -| `kintone-get-form-fields` | アプリのフィールド設定を取得 | -| `kintone-get-form-layout` | アプリのフォームレイアウトを取得 | -| `kintone-update-form-fields` | アプリのフィールド設定を更新 | -| `kintone-update-form-layout` | アプリのフォームレイアウトを更新 | -| `kintone-delete-form-fields` | アプリのフィールドを削除 | -| `kintone-get-process-management` | プロセス管理設定を取得 | -| `kintone-get-app-deploy-status` | アプリ設定の運用環境への反映状況確認 | -| `kintone-get-general-settings` | アプリの一般設定を取得 | -| `kintone-add-form-fields` | アプリにフィールドを追加 | -| `kintone-get-records` | 複数のレコードを取得 | -| `kintone-add-records` | 複数のレコードを追加 | -| `kintone-update-records` | 複数のレコードを更新 | -| `kintone-delete-records` | 複数のレコードを削除 | -| `kintone-update-statuses` | 複数のレコードのステータスを更新 | -| `kintone-get-record-comments` | レコードのコメントを取得 | -| `kintone-add-record-comment` | レコードにコメントを追加 | -| `kintone-add-app` | 動作テスト環境にアプリを作成 | -| `kintone-deploy-app` | アプリ設定を運用環境へ反映 | -| `kintone-update-general-settings` | アプリの一般設定を変更 | -| `kintone-download-file` | 添付ファイルフィールドのファイルを保存 | -| `kintone-add-space-from-template` | テンプレートからスペースを作成 | -| `kintone-update-space` | スペースの設定を更新 | -| `kintone-get-space` | スペースの情報を取得 | -| `kintone-delete-space` | スペースを削除 | +| ツール名 | 説明 | +| ---------------------------------- | ---------------------------------------- | +| `kintone-get-apps` | 複数のアプリ情報を取得 | +| `kintone-get-app` | 単一アプリの詳細情報を取得 | +| `kintone-get-form-fields` | アプリのフィールド設定を取得 | +| `kintone-get-form-layout` | アプリのフォームレイアウトを取得 | +| `kintone-update-form-fields` | アプリのフィールド設定を更新 | +| `kintone-update-form-layout` | アプリのフォームレイアウトを更新 | +| `kintone-delete-form-fields` | アプリのフィールドを削除 | +| `kintone-get-process-management` | プロセス管理設定を取得 | +| `kintone-get-app-deploy-status` | アプリ設定の運用環境への反映状況確認 | +| `kintone-get-general-settings` | アプリの一般設定を取得 | +| `kintone-add-form-fields` | アプリにフィールドを追加 | +| `kintone-get-records` | 複数のレコードを取得 | +| `kintone-export-records-to-file` | 複数のレコードを取得しJSONファイルに保存 | +| `kintone-add-records` | 複数のレコードを追加 | +| `kintone-import-records-from-file` | JSONファイルからレコードをまとめて追加 | +| `kintone-update-records` | 複数のレコードを更新 | +| `kintone-update-records-from-file` | JSONファイルからレコードをまとめて更新 | +| `kintone-delete-records` | 複数のレコードを削除 | +| `kintone-update-statuses` | 複数のレコードのステータスを更新 | +| `kintone-get-record-comments` | レコードのコメントを取得 | +| `kintone-add-record-comment` | レコードにコメントを追加 | +| `kintone-add-app` | 動作テスト環境にアプリを作成 | +| `kintone-deploy-app` | アプリ設定を運用環境へ反映 | +| `kintone-update-general-settings` | アプリの一般設定を変更 | +| `kintone-download-file` | 添付ファイルフィールドのファイルを保存 | +| `kintone-add-space-from-template` | テンプレートからスペースを作成 | +| `kintone-update-space` | スペースの設定を更新 | +| `kintone-get-space` | スペースの情報を取得 | +| `kintone-delete-space` | スペースを削除 | ## ドキュメント diff --git a/README_en.md b/README_en.md index 84ba030c..27c3e140 100644 --- a/README_en.md +++ b/README_en.md @@ -200,34 +200,37 @@ export HTTPS_PROXY="http://username:password@proxy.example.com:8080" ## Tools -| Tool Name | Description | -| --------------------------------- | -------------------------------------------------- | -| `kintone-get-apps` | Get information of multiple apps | -| `kintone-get-app` | Get details of a single app | -| `kintone-get-form-fields` | Get app field settings | -| `kintone-get-form-layout` | Get app form layout | -| `kintone-update-form-fields` | Update app field settings | -| `kintone-update-form-layout` | Update app form layout | -| `kintone-delete-form-fields` | Delete app fields | -| `kintone-get-process-management` | Get process management settings | -| `kintone-get-app-deploy-status` | Check app settings deployment status to production | -| `kintone-get-general-settings` | Get general settings of an app | -| `kintone-add-form-fields` | Add fields to an app | -| `kintone-get-records` | Get multiple records | -| `kintone-add-records` | Add multiple records | -| `kintone-update-records` | Update multiple records | -| `kintone-delete-records` | Delete multiple records | -| `kintone-update-statuses` | Update status of multiple records | -| `kintone-get-record-comments` | Get comments on a record | -| `kintone-add-record-comment` | Add a comment to a record | -| `kintone-add-app` | Create app in pre-live environment | -| `kintone-deploy-app` | Deploy app settings to production | -| `kintone-update-general-settings` | Update app general settings | -| `kintone-download-file` | Download and save a file from an attachment field | -| `kintone-add-space-from-template` | Create a new kintone space from a space template | -| `kintone-update-space` | Update space settings | -| `kintone-get-space` | Get space information and portal settings | -| `kintone-delete-space` | Delete a space | +| Tool Name | Description | +| ---------------------------------- | ------------------------------------------------------------------------------------------ | +| `kintone-get-apps` | Get information of multiple apps | +| `kintone-get-app` | Get details of a single app | +| `kintone-get-form-fields` | Get app field settings | +| `kintone-get-form-layout` | Get app form layout | +| `kintone-update-form-fields` | Update app field settings | +| `kintone-update-form-layout` | Update app form layout | +| `kintone-delete-form-fields` | Delete app fields | +| `kintone-get-process-management` | Get process management settings | +| `kintone-get-app-deploy-status` | Check app settings deployment status to production | +| `kintone-get-general-settings` | Get general settings of an app | +| `kintone-add-form-fields` | Add fields to an app | +| `kintone-get-records` | Get multiple records | +| `kintone-export-records-to-file` | Export records to a local JSON file (no record contents returned to LLM) | +| `kintone-add-records` | Add multiple records | +| `kintone-import-records-from-file` | Add records in bulk from a local JSON file (no record contents loaded into LLM context) | +| `kintone-update-records` | Update multiple records | +| `kintone-update-records-from-file` | Update records in bulk from a local JSON file (no record contents loaded into LLM context) | +| `kintone-delete-records` | Delete multiple records | +| `kintone-update-statuses` | Update status of multiple records | +| `kintone-get-record-comments` | Get comments on a record | +| `kintone-add-record-comment` | Add a comment to a record | +| `kintone-add-app` | Create app in pre-live environment | +| `kintone-deploy-app` | Deploy app settings to production | +| `kintone-update-general-settings` | Update app general settings | +| `kintone-download-file` | Download and save a file from an attachment field | +| `kintone-add-space-from-template` | Create a new kintone space from a space template | +| `kintone-update-space` | Update space settings | +| `kintone-get-space` | Get space information and portal settings | +| `kintone-delete-space` | Delete a space | ## Documentation diff --git a/manifest.json b/manifest.json index efb18259..037a4c22 100644 --- a/manifest.json +++ b/manifest.json @@ -92,14 +92,26 @@ "name": "kintone-get-records", "description": "Get multiple records from a Kintone app" }, + { + "name": "kintone-export-records-to-file", + "description": "Retrieve records from a Kintone app and save them to a local JSON file under KINTONE_ATTACHMENTS_DIR without returning record contents to the LLM" + }, { "name": "kintone-add-records", "description": "Add multiple records to a Kintone app" }, + { + "name": "kintone-import-records-from-file", + "description": "Add records to a Kintone app in bulk by reading them from a local JSON file without loading the record contents into the LLM context" + }, { "name": "kintone-update-records", "description": "Update multiple records in a Kintone app" }, + { + "name": "kintone-update-records-from-file", + "description": "Update records of a Kintone app in bulk by reading them from a local JSON file without loading the record contents into the LLM context" + }, { "name": "kintone-delete-records", "description": "Delete multiple records from a Kintone app" diff --git a/src/lib/record-file.ts b/src/lib/record-file.ts new file mode 100644 index 00000000..7f987a63 --- /dev/null +++ b/src/lib/record-file.ts @@ -0,0 +1,451 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { KintoneFormFieldProperty } from "@kintone/rest-api-client"; +import type { ZodError } from "zod"; +import type { + CellValueFromFile, + FieldValueFromFile, + RecordFromFile, + SubtableRowFromFile, +} from "../schema/record/index.js"; + +/** Maximum number of records kintone accepts in one add/update request. */ +export const MAX_RECORDS_PER_REQUEST = 100; + +const MAX_REPORTED_ISSUES = 10; + +/** A record in the shape the kintone REST API accepts for add and update. */ +export type RecordForWrite = { [fieldCode: string]: { value: unknown } }; + +export type WritableField = { + type: string; + subtableFields?: Map; +}; + +export type WritableFields = Map; + +/** + * Field types kintone rejects on record creation and update. Creator, modifier + * and their timestamps are registrable on creation but need app management + * permission, so they are dropped as well to keep a run possible with the + * record add or edit permission alone. + */ +const NON_WRITABLE_FIELD_TYPES = new Set([ + "RECORD_NUMBER", + "__ID__", + "__REVISION__", + "CREATOR", + "CREATED_TIME", + "MODIFIER", + "UPDATED_TIME", + "CALC", + "CATEGORY", + "STATUS", + "STATUS_ASSIGNEE", + "REFERENCE_TABLE", + "GROUP", + "LABEL", + "SPACER", + "HR", +]); + +const CODE_ENTRY_FIELD_TYPES = new Set([ + "USER_SELECT", + "ORGANIZATION_SELECT", + "GROUP_SELECT", +]); + +type FieldResult = + | { kind: "value"; value: unknown } + | { kind: "skip" } + | { kind: "issue"; message: string }; + +const isWritableFieldProperty = ( + field: KintoneFormFieldProperty.OneOf | KintoneFormFieldProperty.InSubtable, + lookupCopyDestinations: Set, +): boolean => { + if (NON_WRITABLE_FIELD_TYPES.has(field.type)) { + return false; + } + if (lookupCopyDestinations.has(field.code)) { + return false; + } + if ( + field.type === "SINGLE_LINE_TEXT" && + "expression" in field && + field.expression !== "" + ) { + return false; + } + return true; +}; + +const collectLookupCopyDestinations = (properties: { + [fieldCode: string]: KintoneFormFieldProperty.OneOf; +}): Set => { + const destinations = new Set(); + const collect = ( + field: KintoneFormFieldProperty.OneOf | KintoneFormFieldProperty.InSubtable, + ) => { + if ("lookup" in field) { + for (const mapping of field.lookup.fieldMappings) { + destinations.add(mapping.field); + } + } + }; + + for (const field of Object.values(properties)) { + collect(field); + if (field.type === "SUBTABLE") { + for (const innerField of Object.values(field.fields)) { + collect(innerField); + } + } + } + return destinations; +}; + +/** + * Build the set of fields that can carry a value on record add and update, + * based on the form settings of the target app. + */ +export const buildWritableFields = (properties: { + [fieldCode: string]: KintoneFormFieldProperty.OneOf; +}): WritableFields => { + const lookupCopyDestinations = collectLookupCopyDestinations(properties); + const writableFields: WritableFields = new Map(); + + for (const [code, field] of Object.entries(properties)) { + if (!isWritableFieldProperty(field, lookupCopyDestinations)) { + continue; + } + if (field.type === "SUBTABLE") { + const subtableFields = new Map(); + for (const [innerCode, innerField] of Object.entries(field.fields)) { + if (isWritableFieldProperty(innerField, lookupCopyDestinations)) { + subtableFields.set(innerCode, innerField.type); + } + } + writableFields.set(code, { type: field.type, subtableFields }); + continue; + } + writableFields.set(code, { type: field.type }); + } + + return writableFields; +}; + +const toCodeEntries = ( + value: CellValueFromFile, +): Array<{ code: string }> | null => { + if (value === null) { + return []; + } + if (!Array.isArray(value)) { + return null; + } + const entries: Array<{ code: string }> = []; + for (const entry of value) { + if (typeof entry !== "object" || entry === null || !("code" in entry)) { + return null; + } + entries.push({ code: entry.code }); + } + return entries; +}; + +const isSubtableRows = ( + value: FieldValueFromFile, +): value is SubtableRowFromFile[] => + Array.isArray(value) && + value.some( + (entry) => typeof entry === "object" && entry !== null && "value" in entry, + ); + +const normalizeCellValue = ( + fieldCode: string, + fieldType: string, + value: FieldValueFromFile, +): FieldResult => { + if (isSubtableRows(value)) { + return { + kind: "issue", + message: `field "${fieldCode}" holds table rows, but it is a ${fieldType} field in the app`, + }; + } + + if (CODE_ENTRY_FIELD_TYPES.has(fieldType)) { + const entries = toCodeEntries(value); + if (entries === null) { + return { + kind: "issue", + message: `field "${fieldCode}" (${fieldType}) must be an array of objects with a code property`, + }; + } + return { kind: "value", value: entries }; + } + + if (fieldType === "FILE") { + if (value === null) { + return { kind: "value", value: [] }; + } + if (!Array.isArray(value)) { + return { + kind: "issue", + message: `field "${fieldCode}" (FILE) must be an array of objects with a fileKey property`, + }; + } + const fileKeys: Array<{ fileKey: string }> = []; + for (const entry of value) { + if ( + typeof entry !== "object" || + entry === null || + !("fileKey" in entry) + ) { + return { + kind: "issue", + message: `field "${fieldCode}" (FILE) must be an array of objects with a fileKey property`, + }; + } + // fileKeys read from a record cannot be re-registered, only upload keys can. + if (entry.contentType !== undefined || entry.size !== undefined) { + return { kind: "skip" }; + } + fileKeys.push({ fileKey: entry.fileKey }); + } + return { kind: "value", value: fileKeys }; + } + + if (typeof value === "number") { + return { kind: "value", value: String(value) }; + } + + return { kind: "value", value }; +}; + +const normalizeSubtableValue = ( + fieldCode: string, + value: FieldValueFromFile, + subtableFields: Map, + keepRowIds: boolean, + skippedFieldCodes: Set, +): FieldResult => { + if (value === null) { + return { kind: "value", value: [] }; + } + if (!Array.isArray(value)) { + return { + kind: "issue", + message: `field "${fieldCode}" (SUBTABLE) must be an array of table rows`, + }; + } + + const rows: Array<{ id?: string; value: RecordForWrite }> = []; + for (const row of value) { + if (typeof row !== "object" || row === null || !("value" in row)) { + return { + kind: "issue", + message: `field "${fieldCode}" (SUBTABLE) must be an array of table rows`, + }; + } + const cells: RecordForWrite = {}; + for (const [cellCode, cell] of Object.entries(row.value)) { + const cellType = subtableFields.get(cellCode); + if (cellType === undefined) { + skippedFieldCodes.add(cellCode); + continue; + } + const result = normalizeCellValue(cellCode, cellType, cell.value); + if (result.kind === "issue") { + return result; + } + if (result.kind === "skip") { + skippedFieldCodes.add(cellCode); + continue; + } + cells[cellCode] = { value: result.value }; + } + rows.push( + keepRowIds && row.id !== undefined + ? { id: row.id, value: cells } + : { value: cells }, + ); + } + + return { kind: "value", value: rows }; +}; + +export type NormalizeRecordOptions = { + writableFields: WritableFields; + /** + * Keep table row ids. kintone updates the row with that id, and creates a new + * row when the id is omitted, so ids are kept on update and dropped on add. + */ + keepSubtableRowIds: boolean; + skippedFieldCodes: Set; + issues: string[]; + recordLabel: string; +}; + +/** + * Turn a record read from a file into the shape kintone accepts, dropping the + * fields it rejects and collecting what was dropped or looks wrong. + */ +export const normalizeRecord = ( + record: RecordFromFile, + { + writableFields, + keepSubtableRowIds, + skippedFieldCodes, + issues, + recordLabel, + }: NormalizeRecordOptions, +): RecordForWrite => { + const normalized: RecordForWrite = {}; + + for (const [fieldCode, field] of Object.entries(record)) { + const writableField = writableFields.get(fieldCode); + if (writableField === undefined) { + skippedFieldCodes.add(fieldCode); + continue; + } + + const result = + writableField.subtableFields !== undefined + ? normalizeSubtableValue( + fieldCode, + field.value, + writableField.subtableFields, + keepSubtableRowIds, + skippedFieldCodes, + ) + : normalizeCellValue(fieldCode, writableField.type, field.value); + + if (result.kind === "issue") { + issues.push(`${recordLabel}: ${result.message}`); + continue; + } + if (result.kind === "skip") { + skippedFieldCodes.add(fieldCode); + continue; + } + normalized[fieldCode] = { value: result.value }; + } + + return normalized; +}; + +const extractRawRecords = (parsed: unknown): unknown[] | null => { + if (Array.isArray(parsed)) { + return parsed; + } + if ( + typeof parsed === "object" && + parsed !== null && + "records" in parsed && + Array.isArray(parsed.records) + ) { + return parsed.records; + } + return null; +}; + +export const resolveRecordFilePath = ( + filePath: string, + attachmentsDir: string | undefined, +): string => { + if (path.isAbsolute(filePath)) { + return filePath; + } + if (!attachmentsDir) { + throw new Error( + `filePath must be an absolute path unless KINTONE_ATTACHMENTS_DIR is set: ${filePath}`, + ); + } + return path.resolve(attachmentsDir, filePath); +}; + +/** + * Read a JSON file holding kintone records, accepting both a plain array and an + * object with a "records" array. Entries are returned unvalidated so that each + * tool can apply the shape it expects. + */ +export const readRawRecordsFromFile = ( + filePath: string, + attachmentsDir: string | undefined, +): { resolvedPath: string; rawRecords: unknown[] } => { + const resolvedPath = resolveRecordFilePath(filePath, attachmentsDir); + if (!fs.existsSync(resolvedPath)) { + throw new Error(`File not found: ${resolvedPath}`); + } + + const fileContent = fs.readFileSync(resolvedPath, "utf-8"); + let parsedJson: unknown; + try { + parsedJson = JSON.parse(fileContent); + } catch (error) { + throw new Error( + `Failed to parse ${resolvedPath} as JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const rawRecords = extractRawRecords(parsedJson); + if (rawRecords === null) { + throw new Error( + `${resolvedPath} must contain a JSON array of records or an object with a "records" array`, + ); + } + + return { resolvedPath, rawRecords }; +}; + +/** + * Cut the slice of a file the run works on. kintone needs at least one record + * per request, so an empty selection is reported instead of writing nothing. + */ +export const selectTargetRecords = ( + rawRecords: unknown[], + options: { offset: number | undefined; limit: number | undefined }, + resolvedPath: string, +): { startIndex: number; targets: unknown[] } => { + const { offset, limit } = options; + const startIndex = offset ?? 0; + const targets = rawRecords.slice( + startIndex, + limit === undefined ? undefined : startIndex + limit, + ); + + if (targets.length === 0) { + const window = + limit === undefined + ? `offset ${startIndex}` + : `offset ${startIndex} with limit ${limit}`; + throw new Error( + `No record was selected from ${resolvedPath}: it holds ${rawRecords.length} record(s), and ${window} selects none.`, + ); + } + + return { startIndex, targets }; +}; + +export const buildIssueMessage = ( + summary: string, + issues: string[], +): string => { + const reported = issues.slice(0, MAX_REPORTED_ISSUES); + const remaining = issues.length - reported.length; + const suffix = remaining > 0 ? `\n...and ${remaining} more issue(s)` : ""; + return `${summary}\n${reported.join("\n")}${suffix}`; +}; + +export const describeCause = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** Point at the first schema violation of an entry read from a file. */ +export const describeParseFailure = ( + error: ZodError, + recordLabel: string, +): string => { + const issue = error.issues[0]; + return `${recordLabel}: ${issue.path.join(".") || "(root)"} ${issue.message}`; +}; diff --git a/src/schema/record/index.ts b/src/schema/record/index.ts index b03cb8cf..f245c898 100644 --- a/src/schema/record/index.ts +++ b/src/schema/record/index.ts @@ -3,3 +3,13 @@ export { recordSchemaForParameterWithoutFile, } from "./record-for-parameter.js"; export { recordSchema } from "./records.js"; +export { + recordSchemaFromFile, + numericIdFromFileSchema, +} from "./record-file.js"; +export type { + RecordFromFile, + CellValueFromFile, + SubtableRowFromFile, + FieldValueFromFile, +} from "./record-file.js"; diff --git a/src/schema/record/record-file.ts b/src/schema/record/record-file.ts new file mode 100644 index 00000000..6b29cac7 --- /dev/null +++ b/src/schema/record/record-file.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; + +/** + * Shape of a kintone record as it is written in a local JSON file: either the + * retrieval format ({ type, value }) or the registration format ({ value }). + */ +const fieldTypeSchema = z.enum([ + "RECORD_NUMBER", + "__ID__", + "__REVISION__", + "CREATOR", + "CREATED_TIME", + "MODIFIER", + "UPDATED_TIME", + "SINGLE_LINE_TEXT", + "MULTI_LINE_TEXT", + "RICH_TEXT", + "NUMBER", + "CALC", + "CHECK_BOX", + "RADIO_BUTTON", + "MULTI_SELECT", + "DROP_DOWN", + "USER_SELECT", + "ORGANIZATION_SELECT", + "GROUP_SELECT", + "DATE", + "TIME", + "DATETIME", + "LINK", + "FILE", + "SUBTABLE", + "REFERENCE_TABLE", + "CATEGORY", + "STATUS", + "STATUS_ASSIGNEE", + "GROUP", + "LABEL", + "SPACER", + "HR", +]); + +/** + * Record ids, revisions and table row ids are strings in a record read from + * kintone, but the REST API takes a number just as well (RecordID and Revision + * are string | number), and a hand-written or generated file often holds the + * JSON number. Both are accepted and normalized to the string form. + */ +export const numericIdFromFileSchema = z + .union([z.string(), z.number()], { + // A plain union reports "Invalid input", which hides what was expected. + errorMap: () => ({ + message: "must be a numeric value, written as a string or a number", + }), + }) + .transform((value) => String(value)); + +const codeEntrySchema = z.object({ + code: z.string(), + name: z.string().optional(), +}); + +const fileEntrySchema = z.object({ + fileKey: z.string(), + contentType: z.string().optional(), + name: z.string().optional(), + // Only read to tell a retrieved attachment from an upload key, never sent. + size: z.union([z.string(), z.number()]).optional(), +}); + +const cellValueSchema = z.union([ + z.string(), + z.number(), + z.array(z.string()), + codeEntrySchema, + z.array(codeEntrySchema), + z.array(fileEntrySchema), + z.null(), +]); + +const subtableRowSchema = z.object({ + id: numericIdFromFileSchema.optional(), + value: z.record( + z.object({ + type: fieldTypeSchema.optional(), + value: cellValueSchema, + }), + ), +}); + +export const recordSchemaFromFile = z.record( + z.object({ + type: fieldTypeSchema.optional(), + value: z.union([cellValueSchema, z.array(subtableRowSchema)]), + }), +); + +export type RecordFromFile = z.infer; +export type CellValueFromFile = z.infer; +export type SubtableRowFromFile = z.infer; +export type FieldValueFromFile = RecordFromFile[string]["value"]; diff --git a/src/tools/index.ts b/src/tools/index.ts index 0b43a950..3f24eb68 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -4,6 +4,9 @@ import { addRecords } from "./kintone/record/add-records.js"; import { deleteRecords } from "./kintone/record/delete-records.js"; import { getRecordComments } from "./kintone/record/get-record-comments.js"; import { getRecords } from "./kintone/record/get-records.js"; +import { exportRecordsToFile } from "./kintone/record/export-records-to-file.js"; +import { importRecordsFromFile } from "./kintone/record/import-records-from-file.js"; +import { updateRecordsFromFile } from "./kintone/record/update-records-from-file.js"; import { updateRecords } from "./kintone/record/update-records.js"; import { getApp } from "./kintone/app/get-app.js"; import { getApps } from "./kintone/app/get-apps.js"; @@ -42,6 +45,9 @@ export const tools: Array> = [ addFormFields, updateStatuses, addRecords, + importRecordsFromFile, + updateRecordsFromFile, + exportRecordsToFile, deleteRecords, getRecords, getRecordComments, diff --git a/src/tools/kintone/record/__tests__/export-records-to-file.test.ts b/src/tools/kintone/record/__tests__/export-records-to-file.test.ts new file mode 100644 index 00000000..f7941a9f --- /dev/null +++ b/src/tools/kintone/record/__tests__/export-records-to-file.test.ts @@ -0,0 +1,408 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { z } from "zod"; +import type * as NodeFs from "node:fs"; +import { createMockClient } from "../../../../__tests__/utils.js"; + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + default: { + ...actual, + writeFileSync: vi.fn(), + }, + writeFileSync: vi.fn(), + }; +}); + +vi.mock("../../../../lib/filesystem.js", () => ({ + ensureDirectoryExists: vi.fn(), +})); + +import fs from "node:fs"; +import { exportRecordsToFile } from "../export-records-to-file.js"; +import * as filesystem from "../../../../lib/filesystem.js"; + +const mockGetRecords = vi.fn(); +const mockGetAllRecords = vi.fn(); + +describe("export-records-to-file tool", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { + ...originalEnv, + KINTONE_BASE_URL: "https://example.cybozu.com", + KINTONE_USERNAME: "testuser", + KINTONE_PASSWORD: "testpass", + KINTONE_ATTACHMENTS_DIR: "/tmp/exports", + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("tool configuration", () => { + it("should have correct name", () => { + expect(exportRecordsToFile.name).toBe("kintone-export-records-to-file"); + }); + + it("should have title and description", () => { + expect(exportRecordsToFile.config.title).toBe("Export Records to File"); + expect(exportRecordsToFile.config.description).toContain( + "WITHOUT returning the record contents to the LLM", + ); + expect(exportRecordsToFile.config.description).toContain( + "KINTONE_ATTACHMENTS_DIR", + ); + expect(exportRecordsToFile.config.description).toContain( + "confirmLargeFetch", + ); + }); + + it("should accept valid inputs (input schema)", () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const schema = z.object(exportRecordsToFile.config.inputSchema!); + expect(() => schema.parse({ app: "123" })).not.toThrow(); + expect(() => + schema.parse({ + app: "123", + filters: { textContains: [{ field: "title", value: "x" }] }, + fields: ["title"], + orderBy: [{ field: "created", order: "desc" }], + limit: 100, + offset: 0, + confirmLargeFetch: true, + }), + ).not.toThrow(); + }); + + it("should reject invalid inputs (input schema)", () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const schema = z.object(exportRecordsToFile.config.inputSchema!); + expect(() => schema.parse({})).toThrow(); + expect(() => schema.parse({ app: 123 })).toThrow(); + expect(() => schema.parse({ app: "123", limit: 600 })).toThrow(); + expect(() => schema.parse({ app: "123", offset: -1 })).toThrow(); + expect(() => + schema.parse({ app: "123", confirmLargeFetch: "yes" }), + ).toThrow(); + }); + + it("should validate output schema", () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const schema = z.object(exportRecordsToFile.config.outputSchema!); + expect(() => + schema.parse({ + filePath: "/tmp/exports/foo.json", + app: "123", + totalCount: "10", + savedCount: 10, + fileSize: 2048, + }), + ).not.toThrow(); + + expect(() => + schema.parse({ + filePath: "/tmp/exports/foo.json", + app: "123", + totalCount: 10, + savedCount: 10, + fileSize: 2048, + }), + ).toThrow(); + }); + }); + + describe("callback function", () => { + it("should throw when KINTONE_ATTACHMENTS_DIR is not provided", async () => { + const mockClient = createMockClient(); + mockClient.record.getRecords = mockGetRecords; + mockClient.record.getAllRecords = mockGetAllRecords; + + await expect( + exportRecordsToFile.callback( + { app: "123" }, + { client: mockClient, attachmentsDir: undefined }, + ), + ).rejects.toThrow( + "KINTONE_ATTACHMENTS_DIR environment variable must be set", + ); + expect(mockGetRecords).not.toHaveBeenCalled(); + expect(mockGetAllRecords).not.toHaveBeenCalled(); + }); + + describe("limit specified mode (uses getRecords)", () => { + it("should perform a single getRecords call with composed query", async () => { + const records = [ + { + $id: { type: "__ID__", value: "1" }, + title: { type: "SINGLE_LINE_TEXT", value: "A" }, + }, + { + $id: { type: "__ID__", value: "2" }, + title: { type: "SINGLE_LINE_TEXT", value: "B" }, + }, + ]; + mockGetRecords.mockResolvedValueOnce({ records, totalCount: "2" }); + + const mockClient = createMockClient(); + mockClient.record.getRecords = mockGetRecords; + mockClient.record.getAllRecords = mockGetAllRecords; + + const result = await exportRecordsToFile.callback( + { + app: "123", + filters: { textContains: [{ field: "title", value: "x" }] }, + orderBy: [{ field: "created", order: "desc" }], + limit: 100, + offset: 50, + }, + { client: mockClient, attachmentsDir: "/tmp/exports" }, + ); + + expect(mockGetRecords).toHaveBeenCalledTimes(1); + expect(mockGetRecords).toHaveBeenCalledWith({ + app: "123", + query: 'title like "x" order by created desc limit 100 offset 50', + fields: undefined, + totalCount: true, + }); + expect(mockGetAllRecords).not.toHaveBeenCalled(); + + expect(filesystem.ensureDirectoryExists).toHaveBeenCalledWith( + "/tmp/exports", + ); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + const [writtenPath, writtenContent] = vi.mocked(fs.writeFileSync).mock + .calls[0]; + expect(typeof writtenPath).toBe("string"); + expect(String(writtenPath)).toMatch( + /^\/tmp\/exports\/kintone-records_app-123_\d{8}-\d{6}-\d{3}\.json$/, + ); + expect(JSON.parse(String(writtenContent))).toEqual({ + records, + totalCount: "2", + }); + + expect(result.structuredContent).toMatchObject({ + app: "123", + totalCount: "2", + savedCount: 2, + }); + const sc = result.structuredContent as Record; + expect(sc.filePath).toBe(writtenPath); + expect(sc.fileSize).toBe(Buffer.byteLength(String(writtenContent))); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toEqual({ + type: "text", + text: JSON.stringify(result.structuredContent, null, 2), + }); + }); + + it("should omit offset clause when offset is not specified", async () => { + mockGetRecords.mockResolvedValueOnce({ records: [], totalCount: "0" }); + const mockClient = createMockClient(); + mockClient.record.getRecords = mockGetRecords; + mockClient.record.getAllRecords = mockGetAllRecords; + + await exportRecordsToFile.callback( + { app: "123", limit: 50 }, + { client: mockClient, attachmentsDir: "/tmp/exports" }, + ); + + expect(mockGetRecords).toHaveBeenCalledWith({ + app: "123", + query: "limit 50", + fields: undefined, + totalCount: true, + }); + }); + + it("should NOT include record contents in the LLM-facing text content", async () => { + const sensitiveRecord = { + $id: { type: "__ID__", value: "1" }, + secret: { type: "SINGLE_LINE_TEXT", value: "TOP_SECRET_VALUE_XYZ" }, + }; + mockGetRecords.mockResolvedValueOnce({ + records: [sensitiveRecord], + totalCount: "1", + }); + + const mockClient = createMockClient(); + mockClient.record.getRecords = mockGetRecords; + mockClient.record.getAllRecords = mockGetAllRecords; + + const result = await exportRecordsToFile.callback( + { app: "123", limit: 1 }, + { client: mockClient, attachmentsDir: "/tmp/exports" }, + ); + + expect(result.content[0]).toMatchObject({ type: "text" }); + const text = (result.content[0] as { text: string }).text; + expect(text).not.toContain("TOP_SECRET_VALUE_XYZ"); + expect(text).not.toContain("secret"); + }); + }); + + describe("full-export mode (uses getAllRecords)", () => { + it("should probe totalCount with $id only and use getAllRecords when totalCount <= threshold", async () => { + mockGetRecords.mockResolvedValueOnce({ + records: [{ $id: { type: "__ID__", value: "1" } }], + totalCount: "250", + }); + const fullRecords = Array.from({ length: 250 }, (_, i) => ({ + $id: { type: "__ID__", value: String(i + 1) }, + })); + mockGetAllRecords.mockResolvedValueOnce(fullRecords); + + const mockClient = createMockClient(); + mockClient.record.getRecords = mockGetRecords; + mockClient.record.getAllRecords = mockGetAllRecords; + + const result = await exportRecordsToFile.callback( + { + app: "123", + filters: { equals: [{ field: "status", value: "approved" }] }, + orderBy: [{ field: "created", order: "asc" }], + fields: ["title", "status"], + }, + { client: mockClient, attachmentsDir: "/tmp/exports" }, + ); + + // probe call + expect(mockGetRecords).toHaveBeenCalledTimes(1); + expect(mockGetRecords).toHaveBeenCalledWith({ + app: "123", + query: 'status = "approved" order by created asc limit 1', + fields: ["$id"], + totalCount: true, + }); + + // getAllRecords call with separated condition and orderBy + expect(mockGetAllRecords).toHaveBeenCalledTimes(1); + expect(mockGetAllRecords).toHaveBeenCalledWith({ + app: "123", + condition: 'status = "approved"', + orderBy: "created asc", + fields: ["title", "status", "$id"], + }); + + expect(result.structuredContent).toMatchObject({ + app: "123", + totalCount: "250", + savedCount: 250, + }); + + const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1]; + const parsed = JSON.parse(String(writtenContent)); + expect(parsed.records).toHaveLength(250); + expect(parsed.totalCount).toBe("250"); + }); + + it("should refuse to fetch when totalCount exceeds 500 without confirmLargeFetch", async () => { + mockGetRecords.mockResolvedValueOnce({ + records: [{ $id: { type: "__ID__", value: "1" } }], + totalCount: "1200", + }); + + const mockClient = createMockClient(); + mockClient.record.getRecords = mockGetRecords; + mockClient.record.getAllRecords = mockGetAllRecords; + + await expect( + exportRecordsToFile.callback( + { app: "123" }, + { client: mockClient, attachmentsDir: "/tmp/exports" }, + ), + ).rejects.toThrow(/1200.*exceeds the safety threshold of 500/); + + expect(mockGetAllRecords).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it("should proceed when totalCount exceeds 500 and confirmLargeFetch is true", async () => { + mockGetRecords.mockResolvedValueOnce({ + records: [{ $id: { type: "__ID__", value: "1" } }], + totalCount: "1200", + }); + const fullRecords = Array.from({ length: 1200 }, (_, i) => ({ + $id: { type: "__ID__", value: String(i + 1) }, + })); + mockGetAllRecords.mockResolvedValueOnce(fullRecords); + + const mockClient = createMockClient(); + mockClient.record.getRecords = mockGetRecords; + mockClient.record.getAllRecords = mockGetAllRecords; + + const result = await exportRecordsToFile.callback( + { app: "123", confirmLargeFetch: true }, + { client: mockClient, attachmentsDir: "/tmp/exports" }, + ); + + expect(mockGetAllRecords).toHaveBeenCalledWith({ + app: "123", + condition: undefined, + orderBy: undefined, + fields: undefined, + }); + expect(result.structuredContent).toMatchObject({ + totalCount: "1200", + savedCount: 1200, + }); + }); + + it("should proceed normally at exactly the threshold (500 records)", async () => { + mockGetRecords.mockResolvedValueOnce({ + records: [{ $id: { type: "__ID__", value: "1" } }], + totalCount: "500", + }); + const fullRecords = Array.from({ length: 500 }, (_, i) => ({ + $id: { type: "__ID__", value: String(i + 1) }, + })); + mockGetAllRecords.mockResolvedValueOnce(fullRecords); + + const mockClient = createMockClient(); + mockClient.record.getRecords = mockGetRecords; + mockClient.record.getAllRecords = mockGetAllRecords; + + const result = await exportRecordsToFile.callback( + { app: "123" }, + { client: mockClient, attachmentsDir: "/tmp/exports" }, + ); + + expect(mockGetAllRecords).toHaveBeenCalledTimes(1); + expect(result.structuredContent).toMatchObject({ + totalCount: "500", + savedCount: 500, + }); + }); + + it("should not request the record id twice when it is already selected", async () => { + mockGetRecords.mockResolvedValueOnce({ + records: [], + totalCount: "0", + }); + mockGetAllRecords.mockResolvedValueOnce([]); + + const mockClient = createMockClient(); + mockClient.record.getRecords = mockGetRecords; + mockClient.record.getAllRecords = mockGetAllRecords; + + await exportRecordsToFile.callback( + { app: "123", fields: ["$id", "title"] }, + { client: mockClient, attachmentsDir: "/tmp/exports" }, + ); + + expect(mockGetAllRecords).toHaveBeenCalledWith({ + app: "123", + condition: undefined, + orderBy: undefined, + fields: ["$id", "title"], + }); + }); + }); + }); +}); diff --git a/src/tools/kintone/record/__tests__/import-records-from-file.test.ts b/src/tools/kintone/record/__tests__/import-records-from-file.test.ts new file mode 100644 index 00000000..b1a02012 --- /dev/null +++ b/src/tools/kintone/record/__tests__/import-records-from-file.test.ts @@ -0,0 +1,559 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { z } from "zod"; +import type * as NodeFs from "node:fs"; +import { createMockClient } from "../../../../__tests__/utils.js"; + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + default: { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + }, + existsSync: vi.fn(), + readFileSync: vi.fn(), + }; +}); + +import fs from "node:fs"; +import { importRecordsFromFile } from "../import-records-from-file.js"; + +const mockAddRecords = vi.fn(); +const mockGetFormFields = vi.fn(); + +const formProperties = { + レコード番号: { type: "RECORD_NUMBER", code: "レコード番号" }, + title: { type: "SINGLE_LINE_TEXT", code: "title", expression: "" }, + autoText: { type: "SINGLE_LINE_TEXT", code: "autoText", expression: "1+1" }, + price: { type: "NUMBER", code: "price" }, + total: { type: "CALC", code: "total" }, + user: { type: "USER_SELECT", code: "user" }, + attachment: { type: "FILE", code: "attachment" }, + customerCode: { + type: "SINGLE_LINE_TEXT", + code: "customerCode", + lookup: { + relatedApp: { app: "9", code: "" }, + relatedKeyField: "code", + fieldMappings: [{ field: "customerName", relatedField: "name" }], + }, + }, + customerName: { type: "SINGLE_LINE_TEXT", code: "customerName" }, + items: { + type: "SUBTABLE", + code: "items", + fields: { + itemName: { type: "SINGLE_LINE_TEXT", code: "itemName", expression: "" }, + itemTotal: { type: "CALC", code: "itemTotal" }, + }, + }, + created: { type: "CREATED_TIME", code: "created" }, +}; + +const setFileContent = (content: unknown) => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + typeof content === "string" ? content : JSON.stringify(content), + ); +}; + +const createClient = () => { + const client = createMockClient(); + client.record.addRecords = mockAddRecords; + client.app.getFormFields = mockGetFormFields; + return client; +}; + +describe("import-records-from-file tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetFormFields.mockResolvedValue({ + properties: formProperties, + revision: "1", + }); + mockAddRecords.mockImplementation( + ({ records }: { records: unknown[] }) => ({ + ids: records.map((_, index) => String(index + 1)), + revisions: records.map(() => "1"), + }), + ); + }); + + describe("tool configuration", () => { + it("should have correct name", () => { + expect(importRecordsFromFile.name).toBe( + "kintone-import-records-from-file", + ); + }); + + it("should have title and description", () => { + expect(importRecordsFromFile.config.title).toBe( + "Import Records from File", + ); + expect(importRecordsFromFile.config.description).toContain( + "WITHOUT loading the record contents into the conversation", + ); + expect(importRecordsFromFile.config.description).toContain( + "batches of 100", + ); + }); + + it("should accept valid inputs (input schema)", () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const schema = z.object(importRecordsFromFile.config.inputSchema!); + expect(() => + schema.parse({ app: "123", filePath: "/tmp/records.json" }), + ).not.toThrow(); + expect(() => + schema.parse({ + app: "123", + filePath: "records.json", + offset: 0, + limit: 100, + dryRun: true, + }), + ).not.toThrow(); + }); + + it("should reject invalid inputs (input schema)", () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const schema = z.object(importRecordsFromFile.config.inputSchema!); + expect(() => schema.parse({})).toThrow(); + expect(() => schema.parse({ app: "123" })).toThrow(); + expect(() => schema.parse({ app: 123, filePath: "a.json" })).toThrow(); + expect(() => + schema.parse({ app: "123", filePath: "a.json", offset: -1 }), + ).toThrow(); + expect(() => + schema.parse({ app: "123", filePath: "a.json", limit: 0 }), + ).toThrow(); + }); + + it("should validate output schema", () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const schema = z.object(importRecordsFromFile.config.outputSchema!); + expect(() => + schema.parse({ + app: "123", + filePath: "/tmp/records.json", + totalRecordsInFile: 10, + targetRecordCount: 10, + addedCount: 10, + skippedFieldCodes: ["$id"], + dryRun: false, + }), + ).not.toThrow(); + + expect(() => + schema.parse({ + app: "123", + filePath: "/tmp/records.json", + totalRecordsInFile: "10", + targetRecordCount: 10, + addedCount: 10, + skippedFieldCodes: [], + dryRun: false, + }), + ).toThrow(); + }); + }); + + describe("file handling", () => { + it("should throw when the file does not exist", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await expect( + importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/missing.json" }, + { client: createClient() }, + ), + ).rejects.toThrow("File not found: /tmp/missing.json"); + expect(mockAddRecords).not.toHaveBeenCalled(); + }); + + it("should throw when a relative path is given without KINTONE_ATTACHMENTS_DIR", async () => { + await expect( + importRecordsFromFile.callback( + { app: "123", filePath: "records.json" }, + { client: createClient(), attachmentsDir: undefined }, + ), + ).rejects.toThrow("filePath must be an absolute path"); + }); + + it("should resolve a relative path against KINTONE_ATTACHMENTS_DIR", async () => { + setFileContent({ records: [{ title: { value: "A" } }] }); + + const result = await importRecordsFromFile.callback( + { app: "123", filePath: "records.json" }, + { client: createClient(), attachmentsDir: "/tmp/exports" }, + ); + + expect(fs.readFileSync).toHaveBeenCalledWith( + "/tmp/exports/records.json", + "utf-8", + ); + expect(result.structuredContent).toMatchObject({ + filePath: "/tmp/exports/records.json", + }); + }); + + it("should throw when the file is not valid JSON", async () => { + setFileContent("{ broken"); + + await expect( + importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow("Failed to parse /tmp/records.json as JSON"); + }); + + it("should throw when the JSON is neither an array nor a records object", async () => { + setFileContent({ items: [] }); + + await expect( + importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow("must contain a JSON array of records or an object"); + }); + + it("should accept a plain JSON array of records", async () => { + setFileContent([{ title: { value: "A" } }, { title: { value: "B" } }]); + + const result = await importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockAddRecords).toHaveBeenCalledWith({ + app: "123", + records: [{ title: { value: "A" } }, { title: { value: "B" } }], + }); + expect(result.structuredContent).toMatchObject({ + totalRecordsInFile: 2, + targetRecordCount: 2, + addedCount: 2, + dryRun: false, + }); + }); + }); + + describe("normalization", () => { + it("should convert retrieval format to registration format and drop fields kintone rejects", async () => { + setFileContent({ + records: [ + { + $id: { type: "__ID__", value: "1" }, + $revision: { type: "__REVISION__", value: "3" }, + レコード番号: { type: "RECORD_NUMBER", value: "1" }, + title: { type: "SINGLE_LINE_TEXT", value: "A" }, + autoText: { type: "SINGLE_LINE_TEXT", value: "2" }, + total: { type: "CALC", value: "300" }, + created: { type: "CREATED_TIME", value: "2024-01-01T00:00:00Z" }, + customerCode: { type: "SINGLE_LINE_TEXT", value: "C-1" }, + customerName: { type: "SINGLE_LINE_TEXT", value: "Cybozu" }, + notInApp: { type: "SINGLE_LINE_TEXT", value: "x" }, + }, + ], + totalCount: "1", + }); + + const result = await importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockGetFormFields).toHaveBeenCalledWith({ app: "123" }); + expect(mockAddRecords).toHaveBeenCalledWith({ + app: "123", + records: [{ title: { value: "A" }, customerCode: { value: "C-1" } }], + }); + expect(result.structuredContent).toMatchObject({ + skippedFieldCodes: [ + "$id", + "$revision", + "autoText", + "created", + "customerName", + "notInApp", + "total", + "レコード番号", + ], + }); + }); + + it("should reduce user selection values to codes and stringify numbers", async () => { + setFileContent([ + { + user: { + type: "USER_SELECT", + value: [{ code: "sato", name: "Noboru Sato" }], + }, + price: { value: 1200 }, + }, + ]); + + await importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockAddRecords).toHaveBeenCalledWith({ + app: "123", + records: [ + { + user: { value: [{ code: "sato" }] }, + price: { value: "1200" }, + }, + ], + }); + }); + + it("should keep upload fileKeys but drop attachment values taken from record retrieval", async () => { + setFileContent([ + { attachment: { value: [{ fileKey: "upload-key" }] } }, + { + attachment: { + type: "FILE", + value: [ + { + contentType: "text/plain", + fileKey: "download-key", + name: "a.txt", + size: "10", + }, + ], + }, + }, + ]); + + const result = await importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockAddRecords).toHaveBeenCalledWith({ + app: "123", + records: [{ attachment: { value: [{ fileKey: "upload-key" }] } }, {}], + }); + expect(result.structuredContent).toMatchObject({ + skippedFieldCodes: ["attachment"], + }); + }); + + it("should add table rows as new rows and drop non-writable columns", async () => { + setFileContent([ + { + items: { + type: "SUBTABLE", + value: [ + { + id: "48290", + value: { + itemName: { type: "SINGLE_LINE_TEXT", value: "pen" }, + itemTotal: { type: "CALC", value: "100" }, + }, + }, + ], + }, + }, + ]); + + const result = await importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockAddRecords).toHaveBeenCalledWith({ + app: "123", + records: [ + { items: { value: [{ value: { itemName: { value: "pen" } } }] } }, + ], + }); + expect(result.structuredContent).toMatchObject({ + skippedFieldCodes: ["itemTotal"], + }); + }); + }); + + describe("validation", () => { + it("should reject a file whose records are not kintone record objects without adding anything", async () => { + setFileContent([{ title: { value: "A" } }, { title: "B" }]); + + await expect( + importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow("No record was added"); + expect(mockGetFormFields).not.toHaveBeenCalled(); + expect(mockAddRecords).not.toHaveBeenCalled(); + }); + + it("should reject values that do not match the field type in the app", async () => { + setFileContent([{ user: { value: "sato" } }]); + + await expect( + importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow( + 'field "user" (USER_SELECT) must be an array of objects with a code property', + ); + expect(mockAddRecords).not.toHaveBeenCalled(); + }); + }); + + describe("batching", () => { + it("should add records in batches of 100", async () => { + const records = Array.from({ length: 250 }, (_, index) => ({ + title: { value: `record-${index}` }, + })); + setFileContent({ records }); + + const result = await importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockAddRecords).toHaveBeenCalledTimes(3); + expect(mockAddRecords.mock.calls[0][0].records).toHaveLength(100); + expect(mockAddRecords.mock.calls[1][0].records).toHaveLength(100); + expect(mockAddRecords.mock.calls[2][0].records).toHaveLength(50); + expect(result.structuredContent).toMatchObject({ + totalRecordsInFile: 250, + targetRecordCount: 250, + addedCount: 250, + }); + }); + + it("should honor offset and limit", async () => { + const records = Array.from({ length: 10 }, (_, index) => ({ + title: { value: `record-${index}` }, + })); + setFileContent({ records }); + + const result = await importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json", offset: 4, limit: 3 }, + { client: createClient() }, + ); + + expect(mockAddRecords).toHaveBeenCalledWith({ + app: "123", + records: [ + { title: { value: "record-4" } }, + { title: { value: "record-5" } }, + { title: { value: "record-6" } }, + ], + }); + expect(result.structuredContent).toMatchObject({ + totalRecordsInFile: 10, + targetRecordCount: 3, + addedCount: 3, + }); + }); + + it("should reject a run that selects no record", async () => { + setFileContent({ records: [{ title: { value: "A" } }] }); + + await expect( + importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json", offset: 5 }, + { client: createClient() }, + ), + ).rejects.toThrow( + "No record was selected from /tmp/records.json: it holds 1 record(s), and offset 5 selects none.", + ); + expect(mockGetFormFields).not.toHaveBeenCalled(); + expect(mockAddRecords).not.toHaveBeenCalled(); + }); + + it("should reject an empty file", async () => { + setFileContent([]); + + await expect( + importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow("No record was selected"); + expect(mockAddRecords).not.toHaveBeenCalled(); + }); + + it("should report the added count and the resume offset when a batch fails", async () => { + const records = Array.from({ length: 150 }, (_, index) => ({ + title: { value: `record-${index}` }, + })); + setFileContent({ records }); + + mockAddRecords + .mockResolvedValueOnce({ + ids: Array.from({ length: 100 }, (_, index) => String(index)), + revisions: [], + }) + .mockRejectedValueOnce(new Error("[520] invalid value")); + + await expect( + importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow( + /Failed to add the batch starting at record #100\..*100 record\(s\) were already added.*resume with offset 100.*invalid value/s, + ); + }); + }); + + describe("dry run", () => { + it("should validate against the app without adding records", async () => { + setFileContent({ + records: [ + { + title: { type: "SINGLE_LINE_TEXT", value: "A" }, + total: { type: "CALC", value: "1" }, + }, + ], + }); + + const result = await importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json", dryRun: true }, + { client: createClient() }, + ); + + expect(mockGetFormFields).toHaveBeenCalledWith({ app: "123" }); + expect(mockAddRecords).not.toHaveBeenCalled(); + expect(result.structuredContent).toMatchObject({ + targetRecordCount: 1, + addedCount: 0, + skippedFieldCodes: ["total"], + dryRun: true, + }); + }); + }); + + describe("LLM-facing content", () => { + it("should not include record contents in the returned text", async () => { + setFileContent({ + records: [{ title: { value: "TOP_SECRET_VALUE_XYZ" } }], + }); + + const result = await importRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toEqual({ + type: "text", + text: JSON.stringify(result.structuredContent, null, 2), + }); + const text = (result.content[0] as { text: string }).text; + expect(text).not.toContain("TOP_SECRET_VALUE_XYZ"); + }); + }); +}); diff --git a/src/tools/kintone/record/__tests__/update-records-from-file.test.ts b/src/tools/kintone/record/__tests__/update-records-from-file.test.ts new file mode 100644 index 00000000..793aafbe --- /dev/null +++ b/src/tools/kintone/record/__tests__/update-records-from-file.test.ts @@ -0,0 +1,629 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { z } from "zod"; +import type * as NodeFs from "node:fs"; +import { createMockClient } from "../../../../__tests__/utils.js"; + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + default: { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + }, + existsSync: vi.fn(), + readFileSync: vi.fn(), + }; +}); + +import fs from "node:fs"; +import { updateRecordsFromFile } from "../update-records-from-file.js"; + +const mockUpdateRecords = vi.fn(); +const mockGetFormFields = vi.fn(); + +const formProperties = { + レコード番号: { type: "RECORD_NUMBER", code: "レコード番号" }, + title: { type: "SINGLE_LINE_TEXT", code: "title", expression: "" }, + price: { type: "NUMBER", code: "price" }, + total: { type: "CALC", code: "total" }, + user: { type: "USER_SELECT", code: "user" }, + attachment: { type: "FILE", code: "attachment" }, + customerCode: { type: "SINGLE_LINE_TEXT", code: "customerCode" }, + items: { + type: "SUBTABLE", + code: "items", + fields: { + itemName: { type: "SINGLE_LINE_TEXT", code: "itemName", expression: "" }, + itemTotal: { type: "CALC", code: "itemTotal" }, + }, + }, + created: { type: "CREATED_TIME", code: "created" }, +}; + +const setFileContent = (content: unknown) => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + typeof content === "string" ? content : JSON.stringify(content), + ); +}; + +const createClient = () => { + const client = createMockClient(); + client.record.updateRecords = mockUpdateRecords; + client.app.getFormFields = mockGetFormFields; + return client; +}; + +describe("update-records-from-file tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetFormFields.mockResolvedValue({ + properties: formProperties, + revision: "1", + }); + mockUpdateRecords.mockImplementation( + ({ records }: { records: unknown[] }) => ({ + records: records.map((_, index) => ({ + id: String(index + 1), + revision: "2", + })), + }), + ); + }); + + describe("tool configuration", () => { + it("should have correct name", () => { + expect(updateRecordsFromFile.name).toBe( + "kintone-update-records-from-file", + ); + }); + + it("should have title and description", () => { + expect(updateRecordsFromFile.config.title).toBe( + "Update Records from File", + ); + expect(updateRecordsFromFile.config.description).toContain( + "WITHOUT loading the record contents into the conversation", + ); + expect(updateRecordsFromFile.config.description).toContain( + "records that do not exist are never created", + ); + }); + + it("should accept valid inputs (input schema)", () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const schema = z.object(updateRecordsFromFile.config.inputSchema!); + expect(() => + schema.parse({ app: "123", filePath: "/tmp/records.json" }), + ).not.toThrow(); + expect(() => + schema.parse({ + app: "123", + filePath: "records.json", + offset: 0, + limit: 100, + dryRun: true, + }), + ).not.toThrow(); + }); + + it("should reject invalid inputs (input schema)", () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const schema = z.object(updateRecordsFromFile.config.inputSchema!); + expect(() => schema.parse({})).toThrow(); + expect(() => schema.parse({ app: "123" })).toThrow(); + expect(() => schema.parse({ app: 123, filePath: "a.json" })).toThrow(); + expect(() => + schema.parse({ app: "123", filePath: "a.json", offset: -1 }), + ).toThrow(); + expect(() => + schema.parse({ app: "123", filePath: "a.json", limit: 0 }), + ).toThrow(); + }); + + it("should validate output schema", () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const schema = z.object(updateRecordsFromFile.config.outputSchema!); + expect(() => + schema.parse({ + app: "123", + filePath: "/tmp/records.json", + totalRecordsInFile: 10, + targetRecordCount: 10, + updatedCount: 10, + skippedFieldCodes: ["total"], + dryRun: false, + }), + ).not.toThrow(); + + expect(() => + schema.parse({ + app: "123", + filePath: "/tmp/records.json", + totalRecordsInFile: 10, + targetRecordCount: 10, + updatedCount: "10", + skippedFieldCodes: [], + dryRun: false, + }), + ).toThrow(); + }); + }); + + describe("record identification", () => { + it("should take the record id from $id and keep it out of the values", async () => { + setFileContent({ + records: [ + { + $id: { type: "__ID__", value: "42" }, + $revision: { type: "__REVISION__", value: "7" }, + レコード番号: { type: "RECORD_NUMBER", value: "42" }, + title: { type: "SINGLE_LINE_TEXT", value: "A" }, + total: { type: "CALC", value: "300" }, + }, + ], + totalCount: "1", + }); + + const result = await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockGetFormFields).toHaveBeenCalledWith({ app: "123" }); + expect(mockUpdateRecords).toHaveBeenCalledWith({ + app: "123", + records: [ + { id: "42", record: { title: { value: "A" } }, revision: undefined }, + ], + upsert: false, + }); + expect(result.structuredContent).toMatchObject({ + totalRecordsInFile: 1, + targetRecordCount: 1, + updatedCount: 1, + skippedFieldCodes: ["total", "レコード番号"], + dryRun: false, + }); + }); + + it("should accept entries that carry the record id next to the values", async () => { + setFileContent([ + { id: "10", record: { title: { value: "A" } } }, + { id: "11", record: { price: { value: 500 } } }, + ]); + + await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockUpdateRecords).toHaveBeenCalledWith({ + app: "123", + records: [ + { id: "10", record: { title: { value: "A" } }, revision: undefined }, + { + id: "11", + record: { price: { value: "500" } }, + revision: undefined, + }, + ], + upsert: false, + }); + }); + + it("should reject a record without a record id", async () => { + setFileContent([{ title: { value: "A" } }]); + + await expect( + updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow("no record id found"); + expect(mockUpdateRecords).not.toHaveBeenCalled(); + expect(mockGetFormFields).not.toHaveBeenCalled(); + }); + + it("should accept a record id written as a JSON number", async () => { + setFileContent([ + { id: 10, record: { title: { value: "A" } } }, + { $id: { value: 11 }, title: { value: "B" } }, + ]); + + await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockUpdateRecords).toHaveBeenCalledWith({ + app: "123", + records: [ + { id: "10", record: { title: { value: "A" } }, revision: undefined }, + { id: "11", record: { title: { value: "B" } }, revision: undefined }, + ], + upsert: false, + }); + }); + + it("should reject an entry whose id is neither a string nor a number", async () => { + setFileContent([{ id: true, record: { title: { value: "A" } } }]); + + await expect( + updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow( + "record #0: id must be a numeric value, written as a string or a number", + ); + expect(mockUpdateRecords).not.toHaveBeenCalled(); + }); + }); + + describe("revision handling", () => { + it("should ignore the $revision an export carries along", async () => { + setFileContent([ + { + $id: { value: "1" }, + $revision: { value: "7" }, + title: { value: "A" }, + }, + ]); + + await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockUpdateRecords).toHaveBeenCalledWith({ + app: "123", + records: [ + { id: "1", record: { title: { value: "A" } }, revision: undefined }, + ], + upsert: false, + }); + }); + + it("should send the revision stated on an entry", async () => { + setFileContent([ + { id: "2", record: { title: { value: "B" } }, revision: "9" }, + ]); + + await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockUpdateRecords).toHaveBeenCalledWith({ + app: "123", + records: [ + { id: "2", record: { title: { value: "B" } }, revision: "9" }, + ], + upsert: false, + }); + }); + + it("should accept a revision written as a JSON number", async () => { + setFileContent([ + { id: "2", record: { title: { value: "B" } }, revision: 9 }, + { id: "3", record: { title: { value: "C" } }, revision: -1 }, + ]); + + await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockUpdateRecords).toHaveBeenCalledWith({ + app: "123", + records: [ + { id: "2", record: { title: { value: "B" } }, revision: "9" }, + { id: "3", record: { title: { value: "C" } }, revision: "-1" }, + ], + upsert: false, + }); + }); + }); + + describe("normalization", () => { + it("should keep table row ids so existing rows are updated", async () => { + setFileContent([ + { + $id: { value: "1" }, + items: { + type: "SUBTABLE", + value: [ + { + id: "48290", + value: { + itemName: { type: "SINGLE_LINE_TEXT", value: "pen" }, + itemTotal: { type: "CALC", value: "100" }, + }, + }, + { value: { itemName: { value: "new row" } } }, + ], + }, + }, + ]); + + const result = await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockUpdateRecords.mock.calls[0][0].records[0].record).toEqual({ + items: { + value: [ + { id: "48290", value: { itemName: { value: "pen" } } }, + { value: { itemName: { value: "new row" } } }, + ], + }, + }); + expect(result.structuredContent).toMatchObject({ + skippedFieldCodes: ["itemTotal"], + }); + }); + + it("should accept a table row id written as a JSON number", async () => { + setFileContent([ + { + $id: { value: "1" }, + items: { + type: "SUBTABLE", + value: [{ id: 48290, value: { itemName: { value: "pen" } } }], + }, + }, + ]); + + await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockUpdateRecords.mock.calls[0][0].records[0].record).toEqual({ + items: { + value: [{ id: "48290", value: { itemName: { value: "pen" } } }], + }, + }); + }); + + it("should reduce user selection values to codes and drop attachments read from records", async () => { + setFileContent([ + { + $id: { value: "1" }, + user: { + type: "USER_SELECT", + value: [{ code: "sato", name: "Noboru Sato" }], + }, + attachment: { + type: "FILE", + value: [ + { + contentType: "text/plain", + fileKey: "download-key", + name: "a.txt", + size: "10", + }, + ], + }, + created: { type: "CREATED_TIME", value: "2024-01-01T00:00:00Z" }, + }, + ]); + + const result = await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockUpdateRecords.mock.calls[0][0].records[0].record).toEqual({ + user: { value: [{ code: "sato" }] }, + }); + expect(result.structuredContent).toMatchObject({ + skippedFieldCodes: ["attachment", "created"], + }); + }); + + it("should reject values that do not match the field type in the app", async () => { + setFileContent([{ $id: { value: "1" }, user: { value: "sato" } }]); + + await expect( + updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow( + 'field "user" (USER_SELECT) must be an array of objects with a code property', + ); + expect(mockUpdateRecords).not.toHaveBeenCalled(); + }); + }); + + describe("file handling and batching", () => { + it("should throw when the file does not exist", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await expect( + updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/missing.json" }, + { client: createClient() }, + ), + ).rejects.toThrow("File not found: /tmp/missing.json"); + }); + + it("should throw when the file is not valid JSON", async () => { + setFileContent("{ broken"); + + await expect( + updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow("Failed to parse /tmp/records.json as JSON"); + }); + + it("should update records in batches of 100", async () => { + const records = Array.from({ length: 250 }, (_, index) => ({ + $id: { value: String(index + 1) }, + title: { value: `record-${index}` }, + })); + setFileContent({ records }); + + const result = await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(mockUpdateRecords).toHaveBeenCalledTimes(3); + expect(mockUpdateRecords.mock.calls[0][0].records).toHaveLength(100); + expect(mockUpdateRecords.mock.calls[1][0].records).toHaveLength(100); + expect(mockUpdateRecords.mock.calls[2][0].records).toHaveLength(50); + expect(result.structuredContent).toMatchObject({ + totalRecordsInFile: 250, + targetRecordCount: 250, + updatedCount: 250, + }); + }); + + it("should honor offset and limit", async () => { + const records = Array.from({ length: 10 }, (_, index) => ({ + $id: { value: String(index) }, + title: { value: `record-${index}` }, + })); + setFileContent({ records }); + + const result = await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json", offset: 4, limit: 2 }, + { client: createClient() }, + ); + + expect(mockUpdateRecords).toHaveBeenCalledWith({ + app: "123", + records: [ + { + id: "4", + record: { title: { value: "record-4" } }, + revision: undefined, + }, + { + id: "5", + record: { title: { value: "record-5" } }, + revision: undefined, + }, + ], + upsert: false, + }); + expect(result.structuredContent).toMatchObject({ + totalRecordsInFile: 10, + targetRecordCount: 2, + updatedCount: 2, + }); + }); + + it("should reject a run that selects no record", async () => { + setFileContent({ records: [{ $id: { value: "1" } }] }); + + await expect( + updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json", offset: 5 }, + { client: createClient() }, + ), + ).rejects.toThrow( + "No record was selected from /tmp/records.json: it holds 1 record(s), and offset 5 selects none.", + ); + expect(mockGetFormFields).not.toHaveBeenCalled(); + expect(mockUpdateRecords).not.toHaveBeenCalled(); + }); + + it("should reject an empty file", async () => { + setFileContent([]); + + await expect( + updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow("No record was selected"); + expect(mockUpdateRecords).not.toHaveBeenCalled(); + }); + + it("should report the updated count and the resume offset when a batch fails", async () => { + const records = Array.from({ length: 150 }, (_, index) => ({ + $id: { value: String(index + 1) }, + title: { value: `record-${index}` }, + })); + setFileContent({ records }); + + mockUpdateRecords + .mockResolvedValueOnce({ + records: Array.from({ length: 100 }, (_, index) => ({ + id: String(index + 1), + revision: "2", + })), + }) + .mockRejectedValueOnce(new Error("[520] invalid value")); + + await expect( + updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ), + ).rejects.toThrow( + /Failed to update the batch starting at record #100\..*100 record\(s\) were already updated.*resume with offset 100.*invalid value/s, + ); + }); + }); + + describe("dry run", () => { + it("should validate against the app without updating records", async () => { + setFileContent({ + records: [ + { + $id: { value: "1" }, + title: { type: "SINGLE_LINE_TEXT", value: "A" }, + total: { type: "CALC", value: "1" }, + }, + ], + }); + + const result = await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json", dryRun: true }, + { client: createClient() }, + ); + + expect(mockGetFormFields).toHaveBeenCalledWith({ app: "123" }); + expect(mockUpdateRecords).not.toHaveBeenCalled(); + expect(result.structuredContent).toMatchObject({ + targetRecordCount: 1, + updatedCount: 0, + skippedFieldCodes: ["total"], + dryRun: true, + }); + }); + }); + + describe("LLM-facing content", () => { + it("should not include record contents in the returned text", async () => { + setFileContent({ + records: [ + { $id: { value: "1" }, title: { value: "TOP_SECRET_VALUE_XYZ" } }, + ], + }); + + const result = await updateRecordsFromFile.callback( + { app: "123", filePath: "/tmp/records.json" }, + { client: createClient() }, + ); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toEqual({ + type: "text", + text: JSON.stringify(result.structuredContent, null, 2), + }); + const text = (result.content[0] as { text: string }).text; + expect(text).not.toContain("TOP_SECRET_VALUE_XYZ"); + }); + }); +}); diff --git a/src/tools/kintone/record/export-records-to-file.ts b/src/tools/kintone/record/export-records-to-file.ts new file mode 100644 index 00000000..69bfbb6e --- /dev/null +++ b/src/tools/kintone/record/export-records-to-file.ts @@ -0,0 +1,239 @@ +import fs from "node:fs"; +import path from "node:path"; +import { z } from "zod"; +import { createTool } from "../../factory.js"; +import { ensureDirectoryExists } from "../../../lib/filesystem.js"; +import { + filtersSchema, + orderBySchema, + buildQueryFromFilters, +} from "./get-records.js"; +import type { KintoneToolCallback } from "../../types/tool.js"; + +const LARGE_FETCH_THRESHOLD = 500; + +const inputSchema = { + app: z.string().describe('App ID (numeric string). e.g. "123"'), + filters: filtersSchema, + fields: z + .array(z.string()) + .optional() + .describe( + 'Field codes to retrieve. Omit for all fields. The record id ("$id") is always retrieved on top of the selection.', + ), + orderBy: orderBySchema, + limit: z + .number() + .min(1) + .max(500) + .optional() + .describe( + "Records per call (1–500). Omit to fetch all via auto-pagination.", + ), + offset: z + .number() + .min(0) + .optional() + .describe( + "Records to skip. Only effective when `limit` is specified. " + + "Ignored in full-export mode (limit omitted) since getAllRecords does not support offset.", + ), + confirmLargeFetch: z + .boolean() + .optional() + .describe( + `Confirmation flag for full-export mode. When totalCount exceeds ${LARGE_FETCH_THRESHOLD}, ` + + "the tool refuses to proceed unless this is set to true. " + + "Confirm with the user before re-invoking with this flag.", + ), +}; + +const outputSchema = { + filePath: z + .string() + .describe("Absolute path to the saved JSON file containing records"), + app: z.string().describe("The app ID that records were exported from"), + totalCount: z + .string() + .describe("Total count of records matching the query on the kintone side"), + savedCount: z + .number() + .describe("Number of records actually written to the output file"), + fileSize: z.number().describe("Size of the saved file in bytes"), +}; + +const RECORD_ID_FIELD_CODE = "$id"; + +/** + * kintone returns only the requested field codes, and a file without the record + * id cannot be fed back to kintone-update-records-from-file, so the record id is + * retrieved along with any selection. + */ +const withRecordId = (fields: string[] | undefined): string[] | undefined => + fields === undefined || fields.includes(RECORD_ID_FIELD_CODE) + ? fields + : [...fields, RECORD_ID_FIELD_CODE]; + +const formatTimestamp = (d: Date): string => { + const pad = (n: number, w = 2) => String(n).padStart(w, "0"); + return ( + `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` + + `-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` + + `-${pad(d.getMilliseconds(), 3)}` + ); +}; + +type ConditionAndOrderBy = { + condition: string | undefined; + orderByClause: string | undefined; +}; + +const buildConditionAndOrderBy = ( + filters: z.infer, + orderBy: z.infer, +): ConditionAndOrderBy => { + const condition = filters ? buildQueryFromFilters(filters) : undefined; + const orderByClause = + orderBy && orderBy.length > 0 + ? orderBy.map((o) => `${o.field} ${o.order ?? "asc"}`).join(", ") + : undefined; + return { condition, orderByClause }; +}; + +const composeQueryString = ( + condition: string | undefined, + orderByClause: string | undefined, + suffix: string | undefined, +): string => { + const parts: string[] = []; + if (condition) parts.push(condition); + if (orderByClause) parts.push(`order by ${orderByClause}`); + if (suffix) parts.push(suffix); + return parts.join(" "); +}; + +const writeRecordsFile = ( + attachmentsDir: string, + app: string, + records: unknown[], + totalCount: string, +): { filePath: string; fileSize: number } => { + ensureDirectoryExists(attachmentsDir); + + const fileName = `kintone-records_app-${app}_${formatTimestamp(new Date())}.json`; + const filePath = path.join(attachmentsDir, fileName); + const fileContent = JSON.stringify({ records, totalCount }, null, 2); + fs.writeFileSync(filePath, fileContent, "utf-8"); + const fileSize = Buffer.byteLength(fileContent, "utf-8"); + + return { filePath, fileSize }; +}; + +const toolName = "kintone-export-records-to-file"; +const toolConfig = { + title: "Export Records to File", + description: + "Export kintone app records to a local JSON file under KINTONE_ATTACHMENTS_DIR, WITHOUT returning the record contents to the LLM. " + + "Use this for numerical/date analysis, scripting, or datasets larger than about 50 records, so record contents are not loaded into the conversation. " + + "Omitting `limit` fetches all matching records via getAllRecords; for safety, when the matched total exceeds " + + `${LARGE_FETCH_THRESHOLD} records the tool stops and requires \`confirmLargeFetch=true\` to proceed. ` + + 'The file holds { "records": [...] } in the retrieval format ({ "type": ..., "value": ... }), including the record id in "$id", so it can be edited and fed back to kintone-update-records-from-file or kintone-import-records-from-file. ' + + "totalCount reflects the record count matched on the kintone side, while savedCount is the number of records actually written to the file.", + inputSchema, + outputSchema, +}; + +const callback: KintoneToolCallback = async ( + { app, filters, fields, orderBy, limit, offset, confirmLargeFetch }, + { client, attachmentsDir }, +) => { + if (!attachmentsDir) { + throw new Error( + "KINTONE_ATTACHMENTS_DIR environment variable must be set to use kintone-export-records-to-file", + ); + } + + const { condition, orderByClause } = buildConditionAndOrderBy( + filters, + orderBy, + ); + const fieldsToRetrieve = withRecordId(fields); + + let records: unknown[]; + let totalCount: string; + + if (limit !== undefined) { + const limitClause = + offset !== undefined + ? `limit ${limit} offset ${offset}` + : `limit ${limit}`; + const query = composeQueryString(condition, orderByClause, limitClause); + + const response = await client.record.getRecords({ + app, + query, + fields: fieldsToRetrieve, + totalCount: true, + }); + records = response.records; + totalCount = response.totalCount ?? String(response.records.length); + } else { + // Full-export mode: probe totalCount first with a minimal request, then + // gate on confirmLargeFetch before calling getAllRecords. + const probeQuery = composeQueryString(condition, orderByClause, "limit 1"); + const probe = await client.record.getRecords({ + app, + query: probeQuery, + fields: [RECORD_ID_FIELD_CODE], + totalCount: true, + }); + totalCount = probe.totalCount ?? "0"; + const totalCountNum = Number(totalCount); + + if ( + Number.isFinite(totalCountNum) && + totalCountNum > LARGE_FETCH_THRESHOLD && + confirmLargeFetch !== true + ) { + throw new Error( + `Total matching records (${totalCount}) exceeds the safety threshold of ${LARGE_FETCH_THRESHOLD}. ` + + "Confirm with the user before fetching this many records, then re-invoke this tool with " + + "`confirmLargeFetch: true`. No records were fetched or written.", + ); + } + + records = await client.record.getAllRecords({ + app, + condition, + orderBy: orderByClause, + fields: fieldsToRetrieve, + }); + } + + const { filePath, fileSize } = writeRecordsFile( + attachmentsDir, + app, + records, + totalCount, + ); + + const result = { + filePath, + app, + totalCount, + savedCount: records.length, + fileSize, + }; + + return { + structuredContent: result, + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; +}; + +export const exportRecordsToFile = createTool(toolName, toolConfig, callback); diff --git a/src/tools/kintone/record/get-records.ts b/src/tools/kintone/record/get-records.ts index d261b876..0e172734 100644 --- a/src/tools/kintone/record/get-records.ts +++ b/src/tools/kintone/record/get-records.ts @@ -3,7 +3,7 @@ import { createTool } from "../../factory.js"; import { recordSchema } from "../../../schema/record/index.js"; import type { KintoneToolCallback } from "../../types/tool.js"; -const filtersSchema = z +export const filtersSchema = z .object({ textContains: z .array( @@ -85,7 +85,7 @@ const filtersSchema = z "Filter conditions for records. All conditions are AND-combined. NOTE: This MCP server does not currently support OR conditions in filters. Use kintone-get-form-fields tool to discover available field codes and types for an app", ); -const orderBySchema = z +export const orderBySchema = z .array( z.object({ field: z.string().describe("Field code to sort by"), @@ -128,7 +128,7 @@ const outputSchema = { totalCount: z.string().describe("Total count of records matching the query"), }; -function buildQueryFromFilters( +export function buildQueryFromFilters( filters: NonNullable>, ): string | undefined { const conditions: string[] = []; diff --git a/src/tools/kintone/record/import-records-from-file.ts b/src/tools/kintone/record/import-records-from-file.ts new file mode 100644 index 00000000..24a1b076 --- /dev/null +++ b/src/tools/kintone/record/import-records-from-file.ts @@ -0,0 +1,186 @@ +import { z } from "zod"; +import { createTool } from "../../factory.js"; +import { recordSchemaFromFile } from "../../../schema/record/index.js"; +import { + MAX_RECORDS_PER_REQUEST, + buildIssueMessage, + buildWritableFields, + describeCause, + describeParseFailure, + normalizeRecord, + readRawRecordsFromFile, + selectTargetRecords, +} from "../../../lib/record-file.js"; +import type { RecordFromFile } from "../../../schema/record/index.js"; +import type { RecordForWrite } from "../../../lib/record-file.js"; +import type { KintoneToolCallback } from "../../types/tool.js"; + +const inputSchema = { + app: z.string().describe('App ID (numeric string). e.g. "123"'), + filePath: z + .string() + .describe( + "Path to the JSON file holding the records to add. Absolute path, or a file name resolved against KINTONE_ATTACHMENTS_DIR.", + ), + offset: z + .number() + .min(0) + .optional() + .describe( + "Records to skip from the beginning of the file. Use it to resume after a partially failed run.", + ), + limit: z + .number() + .min(1) + .optional() + .describe( + "Maximum number of records to add. Omit to add every record after offset.", + ), + dryRun: z + .boolean() + .optional() + .describe( + "When true, validate the file against the app form settings without adding any record. Defaults to false.", + ), +}; + +const outputSchema = { + app: z.string().describe("The app ID that records were added to"), + filePath: z.string().describe("Absolute path to the JSON file that was read"), + totalRecordsInFile: z + .number() + .describe("Number of records contained in the file"), + targetRecordCount: z + .number() + .describe("Number of records selected by offset and limit"), + addedCount: z + .number() + .describe("Number of records actually added to kintone (0 for a dry run)"), + skippedFieldCodes: z + .array(z.string()) + .describe( + "Field codes dropped before sending, because kintone rejects them on record creation or they do not exist in the app", + ), + dryRun: z.boolean().describe("Whether this run was a validation-only run"), +}; + +const toolName = "kintone-import-records-from-file"; +const toolConfig = { + title: "Import Records from File", + description: + "Add records to a kintone app in bulk by reading them from a local JSON file, WITHOUT loading the record contents into the conversation. " + + "Use this to register datasets that were prepared or exported as a file, instead of passing every record through the LLM. " + + 'The file may be a JSON array of records or an object holding them under a "records" key, and each field may use the retrieval format ({ "type": ..., "value": ... }) or the registration format ({ "value": ... }). Numeric values may be written as a JSON string ("12") or a JSON number (12). Use kintone-get-form-fields first to discover the field codes and formats the app expects when the file still has to be written. ' + + "The app form settings are read first, and every field kintone rejects on record creation is dropped before sending: record number, record ID, revision, calculated fields, auto-calculated single-line text, lookup copy destinations, category, status, assignee, related records, creator/modifier and their timestamps, plus field codes that do not exist in the app. " + + 'For FILE fields, upload each file with kintone-upload-file first and write value: [{ "fileKey": "..." }]; attachment values taken from record retrieval cannot be re-registered and are dropped. Table rows are always added as new rows. Dropped field codes are reported in skippedFieldCodes. ' + + "All records are validated before anything is sent, then added in batches of 100 (the kintone API limit). kintone rolls back a failed batch, but batches already committed are kept, so a failure reports how many records were added and the file index to resume from with offset. " + + "Requires the record add permission on the app; set dryRun to validate a file without writing.", + inputSchema, + outputSchema, +}; + +const callback: KintoneToolCallback = async ( + { app, filePath, offset, limit, dryRun }, + { client, attachmentsDir }, +) => { + const { resolvedPath, rawRecords } = readRawRecordsFromFile( + filePath, + attachmentsDir, + ); + + const { startIndex, targets } = selectTargetRecords( + rawRecords, + { offset, limit }, + resolvedPath, + ); + + const parsedRecords: RecordFromFile[] = []; + const issues: string[] = []; + targets.forEach((target, index) => { + const parsed = recordSchemaFromFile.safeParse(target); + if (!parsed.success) { + issues.push( + describeParseFailure(parsed.error, `record #${startIndex + index}`), + ); + return; + } + parsedRecords.push(parsed.data); + }); + if (issues.length > 0) { + throw new Error( + buildIssueMessage( + `${resolvedPath} contains records that are not valid kintone record objects. No record was added.`, + issues, + ), + ); + } + + const { properties } = await client.app.getFormFields({ app }); + const writableFields = buildWritableFields(properties); + + const skippedFieldCodes = new Set(); + const recordsToAdd: RecordForWrite[] = parsedRecords.map((record, index) => + normalizeRecord(record, { + writableFields, + keepSubtableRowIds: false, + skippedFieldCodes, + issues, + recordLabel: `record #${startIndex + index}`, + }), + ); + if (issues.length > 0) { + throw new Error( + buildIssueMessage( + `${resolvedPath} contains field values that do not match the form settings of app ${app}. No record was added.`, + issues, + ), + ); + } + + let addedCount = 0; + if (!dryRun) { + for ( + let index = 0; + index < recordsToAdd.length; + index += MAX_RECORDS_PER_REQUEST + ) { + const batch = recordsToAdd.slice(index, index + MAX_RECORDS_PER_REQUEST); + try { + const response = await client.record.addRecords({ + app, + records: batch, + }); + addedCount += response.ids.length; + } catch (error) { + throw new Error( + `Failed to add the batch starting at record #${startIndex + index}. ` + + `${addedCount} record(s) were already added and are not rolled back; the failed batch was rolled back by kintone. ` + + `Fix the data and resume with offset ${startIndex + index}. ` + + `Cause: ${describeCause(error)}`, + ); + } + } + } + + const result = { + app, + filePath: resolvedPath, + totalRecordsInFile: rawRecords.length, + targetRecordCount: recordsToAdd.length, + addedCount, + skippedFieldCodes: [...skippedFieldCodes].sort(), + dryRun: dryRun ?? false, + }; + + return { + structuredContent: result, + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; +}; + +export const importRecordsFromFile = createTool(toolName, toolConfig, callback); diff --git a/src/tools/kintone/record/update-records-from-file.ts b/src/tools/kintone/record/update-records-from-file.ts new file mode 100644 index 00000000..5a46095f --- /dev/null +++ b/src/tools/kintone/record/update-records-from-file.ts @@ -0,0 +1,317 @@ +import { z } from "zod"; +import { createTool } from "../../factory.js"; +import { + numericIdFromFileSchema, + recordSchemaFromFile, +} from "../../../schema/record/index.js"; +import { + MAX_RECORDS_PER_REQUEST, + buildIssueMessage, + buildWritableFields, + describeCause, + describeParseFailure, + normalizeRecord, + readRawRecordsFromFile, + selectTargetRecords, +} from "../../../lib/record-file.js"; +import type { RecordFromFile } from "../../../schema/record/index.js"; +import type { + RecordForWrite, + WritableFields, +} from "../../../lib/record-file.js"; +import type { KintoneToolCallback } from "../../types/tool.js"; + +const inputSchema = { + app: z.string().describe('App ID (numeric string). e.g. "123"'), + filePath: z + .string() + .describe( + "Path to the JSON file holding the records to update. Absolute path, or a file name resolved against KINTONE_ATTACHMENTS_DIR.", + ), + offset: z + .number() + .min(0) + .optional() + .describe( + "Records to skip from the beginning of the file. Use it to resume after a partially failed run.", + ), + limit: z + .number() + .min(1) + .optional() + .describe( + "Maximum number of records to update. Omit to update every record after offset.", + ), + dryRun: z + .boolean() + .optional() + .describe( + "When true, validate the file against the app form settings without updating any record. Defaults to false.", + ), +}; + +const outputSchema = { + app: z.string().describe("The app ID whose records were updated"), + filePath: z.string().describe("Absolute path to the JSON file that was read"), + totalRecordsInFile: z + .number() + .describe("Number of records contained in the file"), + targetRecordCount: z + .number() + .describe("Number of records selected by offset and limit"), + updatedCount: z + .number() + .describe("Number of records actually updated (0 for a dry run)"), + skippedFieldCodes: z + .array(z.string()) + .describe( + "Field codes dropped before sending, because kintone rejects them on record update or they do not exist in the app", + ), + dryRun: z.boolean().describe("Whether this run was a validation-only run"), +}; + +/** + * Entry shape accepted in addition to a plain record: the same shape + * kintone-update-records takes, so a file can carry the record id and the + * expected revision next to the values. updateKey is out of scope. + */ +const updateEntrySchema = z.object({ + id: numericIdFromFileSchema, + record: recordSchemaFromFile, + revision: numericIdFromFileSchema.optional(), +}); + +type UpdateTarget = { + id: string; + record: RecordForWrite; + revision?: string; +}; + +const RECORD_ID_FIELD_CODE = "$id"; +const REVISION_FIELD_CODE = "$revision"; + +const isKeyedEntry = (entry: unknown): boolean => + typeof entry === "object" && + entry !== null && + "record" in entry && + "id" in entry; + +const takeRecordIdFieldValue = (record: RecordFromFile): string | null => { + const field = record[RECORD_ID_FIELD_CODE]; + if (field === undefined) { + return null; + } + if (typeof field.value === "string") { + return field.value; + } + if (typeof field.value === "number") { + return String(field.value); + } + return null; +}; + +type ParsedEntry = { + record: RecordFromFile; + id: string; + revision?: string; + recordLabel: string; +}; + +const readKeyedEntry = ( + entry: unknown, + issues: string[], + recordLabel: string, +): ParsedEntry | null => { + const parsed = updateEntrySchema.safeParse(entry); + if (!parsed.success) { + issues.push(describeParseFailure(parsed.error, recordLabel)); + return null; + } + + const { id, record, revision } = parsed.data; + dropRecordKeyFields(record); + return { record, id, revision, recordLabel }; +}; + +const readPlainRecord = ( + entry: unknown, + issues: string[], + recordLabel: string, +): ParsedEntry | null => { + const parsed = recordSchemaFromFile.safeParse(entry); + if (!parsed.success) { + issues.push(describeParseFailure(parsed.error, recordLabel)); + return null; + } + + const record = parsed.data; + const id = takeRecordIdFieldValue(record); + if (id === null) { + issues.push( + `${recordLabel}: no record id found. Put the record id in "$id", or use the { "id": ..., "record": ... } form.`, + ); + return null; + } + + // A revision copied along by an export is not an intentional expectation, so + // it is ignored. Use the { "id": ..., "revision": ... } form to check it. + dropRecordKeyFields(record); + return { record, id, recordLabel }; +}; + +/** The record id and revision identify a record instead of holding a value. */ +const dropRecordKeyFields = (record: RecordFromFile): void => { + delete record[RECORD_ID_FIELD_CODE]; + delete record[REVISION_FIELD_CODE]; +}; + +const parseEntry = ( + entry: unknown, + recordLabel: string, + issues: string[], +): ParsedEntry | null => + isKeyedEntry(entry) + ? readKeyedEntry(entry, issues, recordLabel) + : readPlainRecord(entry, issues, recordLabel); + +const buildUpdateTarget = ( + entry: ParsedEntry, + options: { + writableFields: WritableFields; + skippedFieldCodes: Set; + issues: string[]; + }, +): UpdateTarget => { + const { writableFields, skippedFieldCodes, issues } = options; + + const record = normalizeRecord(entry.record, { + writableFields, + keepSubtableRowIds: true, + skippedFieldCodes, + issues, + recordLabel: entry.recordLabel, + }); + + return { id: entry.id, record, revision: entry.revision }; +}; + +const toolName = "kintone-update-records-from-file"; +const toolConfig = { + title: "Update Records from File", + description: + "Update records of a kintone app in bulk by reading them from a local JSON file, WITHOUT loading the record contents into the conversation. " + + "Use this to apply datasets that were exported and edited as a file, instead of passing every record through the LLM. " + + 'The file may be a JSON array or an object holding the entries under a "records" key. An entry is either a plain record, whose record id is taken from "$id" and whose "$revision" is ignored, or an object of the form { "id": ..., "record": { ... }, "revision": ... } where revision is the expected revision number and the update fails when it does not match; specify -1 or omit it to skip revision validation. Record ids, revisions and table row ids may be written as a JSON string ("12") or a JSON number (12). Each field may use the retrieval format ({ "type": ..., "value": ... }) or the registration format ({ "value": ... }). Use kintone-get-form-fields first to discover the field codes and formats the app expects when the file still has to be written. ' + + "Records are matched by record ID only, and records that do not exist are never created: the update fails instead. " + + "The app form settings are read first, and every field kintone rejects on record update is dropped before sending: record number, record ID, revision, calculated fields, auto-calculated single-line text, lookup copy destinations, category, status, assignee, related records, creator/modifier and their timestamps, plus field codes that do not exist in the app. " + + "Fields absent from an entry keep their current value, but a table included in an entry replaces the whole table: rows keep their row id and existing rows missing from the entry are deleted. " + + 'For FILE fields, upload each file with kintone-upload-file first and write value: [{ "fileKey": "..." }]; attachment values taken from record retrieval cannot be re-registered and are dropped. Dropped field codes are reported in skippedFieldCodes. ' + + "All entries are validated before anything is sent, then updated in batches of 100 (the kintone API limit). kintone rolls back a failed batch, but batches already committed are kept, so a failure reports how many records were updated and the file index to resume from with offset. " + + "Requires the record edit permission on the app; set dryRun to validate a file without writing.", + inputSchema, + outputSchema, +}; + +const callback: KintoneToolCallback = async ( + { app, filePath, offset, limit, dryRun }, + { client, attachmentsDir }, +) => { + const { resolvedPath, rawRecords } = readRawRecordsFromFile( + filePath, + attachmentsDir, + ); + + const { startIndex, targets } = selectTargetRecords( + rawRecords, + { offset, limit }, + resolvedPath, + ); + + const issues: string[] = []; + const parsedEntries: ParsedEntry[] = []; + targets.forEach((target, index) => { + const parsed = parseEntry(target, `record #${startIndex + index}`, issues); + if (parsed !== null) { + parsedEntries.push(parsed); + } + }); + if (issues.length > 0) { + throw new Error( + buildIssueMessage( + `${resolvedPath} contains entries that are not valid kintone record objects. No record was updated.`, + issues, + ), + ); + } + + const { properties } = await client.app.getFormFields({ app }); + const writableFields = buildWritableFields(properties); + + const skippedFieldCodes = new Set(); + const recordsToUpdate: UpdateTarget[] = parsedEntries.map((entry) => + buildUpdateTarget(entry, { + writableFields, + skippedFieldCodes, + issues, + }), + ); + if (issues.length > 0) { + throw new Error( + buildIssueMessage( + `${resolvedPath} contains entries that cannot be applied to app ${app}. No record was updated.`, + issues, + ), + ); + } + + let updatedCount = 0; + if (!dryRun) { + for ( + let index = 0; + index < recordsToUpdate.length; + index += MAX_RECORDS_PER_REQUEST + ) { + const batch = recordsToUpdate.slice( + index, + index + MAX_RECORDS_PER_REQUEST, + ); + try { + const response = await client.record.updateRecords({ + app, + records: batch, + upsert: false, // upsertモードは対象外 + }); + updatedCount += response.records.length; + } catch (error) { + throw new Error( + `Failed to update the batch starting at record #${startIndex + index}. ` + + `${updatedCount} record(s) were already updated and are not rolled back; the failed batch was rolled back by kintone. ` + + `Fix the data and resume with offset ${startIndex + index}. ` + + `Cause: ${describeCause(error)}`, + ); + } + } + } + + const result = { + app, + filePath: resolvedPath, + totalRecordsInFile: rawRecords.length, + targetRecordCount: recordsToUpdate.length, + updatedCount, + skippedFieldCodes: [...skippedFieldCodes].sort(), + dryRun: dryRun ?? false, + }; + + return { + structuredContent: result, + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; +}; + +export const updateRecordsFromFile = createTool(toolName, toolConfig, callback);