From 4c93e10cba561edee321f0dfb6a5f8a0c0210fdc Mon Sep 17 00:00:00 2001 From: Steedos Date: Thu, 29 Oct 2026 10:17:59 +0000 Subject: [PATCH 1/6] docs(i18n): translate the entry path into ja (index, why, start, editions, use) Phase 1 of the other-locale plan. Adds ja translations of the 19 entry-path pages (index, why, start/*, editions/*, use/*) and the ja sidebar labels for start/ and editions/. Every heading keeps its English id, fences match the English byte for byte, terms follow the glossary's ja column, and each page carries a --stamp. check-translations: ja 0 stale, 79 missing (was 98). Fixes #386 Claude-Session: https://claude.ai/code/session_01QsR2jRsjbApbf83iyTDDXm Co-Authored-By: Claude --- content/docs/editions/compare.ja.mdx | 62 ++++ content/docs/editions/license.ja.mdx | 118 ++++++++ content/docs/editions/meta.ja.json | 5 + content/docs/editions/public-preview.ja.mdx | 26 ++ content/docs/index.ja.mdx | 87 ++++++ content/docs/start/cloud.ja.mdx | 45 +++ content/docs/start/community.ja.mdx | 296 ++++++++++++++++++++ content/docs/start/enterprise.ja.mdx | 30 ++ content/docs/start/meta.ja.json | 5 + content/docs/use/approvals.ja.mdx | 76 +++++ content/docs/use/collaboration.ja.mdx | 122 ++++++++ content/docs/use/dashboards.ja.mdx | 71 +++++ content/docs/use/files.ja.mdx | 94 +++++++ content/docs/use/import.ja.mdx | 125 +++++++++ content/docs/use/index.ja.mdx | 94 +++++++ content/docs/use/notifications.ja.mdx | 68 +++++ content/docs/use/profile.ja.mdx | 78 ++++++ content/docs/use/records.ja.mdx | 85 ++++++ content/docs/use/search.ja.mdx | 102 +++++++ content/docs/use/views.ja.mdx | 90 ++++++ content/docs/why.ja.mdx | 124 ++++++++ 21 files changed, 1803 insertions(+) create mode 100644 content/docs/editions/compare.ja.mdx create mode 100644 content/docs/editions/license.ja.mdx create mode 100644 content/docs/editions/meta.ja.json create mode 100644 content/docs/editions/public-preview.ja.mdx create mode 100644 content/docs/index.ja.mdx create mode 100644 content/docs/start/cloud.ja.mdx create mode 100644 content/docs/start/community.ja.mdx create mode 100644 content/docs/start/enterprise.ja.mdx create mode 100644 content/docs/start/meta.ja.json create mode 100644 content/docs/use/approvals.ja.mdx create mode 100644 content/docs/use/collaboration.ja.mdx create mode 100644 content/docs/use/dashboards.ja.mdx create mode 100644 content/docs/use/files.ja.mdx create mode 100644 content/docs/use/import.ja.mdx create mode 100644 content/docs/use/index.ja.mdx create mode 100644 content/docs/use/notifications.ja.mdx create mode 100644 content/docs/use/profile.ja.mdx create mode 100644 content/docs/use/records.ja.mdx create mode 100644 content/docs/use/search.ja.mdx create mode 100644 content/docs/use/views.ja.mdx create mode 100644 content/docs/why.ja.mdx diff --git a/content/docs/editions/compare.ja.mdx b/content/docs/editions/compare.ja.mdx new file mode 100644 index 00000000..bfcf0726 --- /dev/null +++ b/content/docs/editions/compare.ja.mdx @@ -0,0 +1,62 @@ +--- +title: エディションの比較 +seoTitle: "ObjectOS エディションの比較:Community、Free、Team、Enterprise" +description: ObjectOS の各エディションとプランに含まれるものを 1 行ずつ。Community、Cloud Free、Cloud Team、Enterprise、そして各違いがどこで強制されるか。 +editions: [community, cloud, enterprise] +translation: + source_sha: 453d01cdf9083450d82f8221495bfac52b798f6ccb9ac8ffbbc5796920086128 + guide_rev: 1 + mode: auto +--- + +ObjectOS は 3 つのエディションを持つひとつの製品です。**ObjectOS Community** は自分で実行するオープンソースのランタイムです。**ObjectOS Cloud** は同じ製品をあなたのためにホストしたもので、**Free** と **Team** のプランがあります。**ObjectOS Enterprise** は自社のインフラストラクチャ上で実行するライセンス制の製品で、**Business** と**Business Self-Managed** はその下の契約オプションです。料金と契約は[ライセンスと料金](/docs/editions/license)にあります。このページは、それぞれが何を違う形で行うかを説明します。 + +このドキュメントのすべてのページは、タイトルの下にエディションバッジの行を持ち、適用されるエディションを示します。サイドバーは、すべてのエディション向けではないページとグループに同じアイコンで印を付けます。 + +## 比較表 [#the-matrix] + +| | **Community** | **Cloud Free** | **Cloud Team** | **Enterprise** | +|---|---|---|---|---| +| 提供形態 | セルフマネージド | ホスト型 | ホスト型 | セルフマネージド | +| 料金 | 無料(Apache-2.0) | 無料 | $24 / AI シート / 月 | 契約による | +| ファイルストレージ | 自前([ストレージ](/docs/configure/storage)) | 環境ごとに 1 GB | 環境ごとに 20 GB | 自前([ストレージ](/docs/configure/storage)) | +| 開発環境 | デプロイした数だけ | —(本番のみ) | 2 つ、さらに AI シートごとに 1 つ | デプロイした数だけ | +| MCP リクエスト | デプロイで設定 | 20 / 分、1,000 / 日 | 120 / 分、1 日の上限なし | デプロイで設定 | +| カスタムドメイン | 自前のホスト | — | ✓ | 自前のホスト | +| レコードレベルおよびフィールドレベルのセキュリティ | ✓ | — | ✓ | ✓ | +| UI 内の AI(AI Builder、「データに質問する」) | MCP のみ:モデルは持ち込み | お試し | ✓ | ✓ | +| AI ガバナンス(アクションの監査、ガードレール、権限のスコープ設定) | — | — | — | ✓ | +| 自社の ID プロバイダーによるシングルサインオン | ✓([認証](/docs/configure/authentication)) | — | — | ✓ | +| SCIM | — | — | — | ✓ | +| クラスタリング / 高可用性 | — | — | — | ✓ | +| ひとつのデプロイ上の複数の組織 | — | — | — | ✓ | +| エアギャップ運用 | — | — | — | ✓ | +| サポート | コミュニティ | コミュニティ | スタンダード | SLA + 専任 | + +ObjectOS Cloud では、シングルサインオンと AI ガバナンスは **Business**、すなわちホスト型サービス上の ObjectOS Enterprise の契約オプションに含まれます。セルフマネージドのデプロイでは、Community と Enterprise は同じランタイムを実行します。Community 列にあるものはすべて Enterprise 列にもあります。 + +## 各違いがどこで強制されるか [#where-each-difference-is-enforced] + +比較表には、製品が強制する違いだけを載せています。以下は、このドキュメントですでに詳しく説明しているものです。 + +- **ライセンスが、セルフマネージドのデプロイが何であるかを決めます。** ライセンスがない場合、単一組織のデプロイは Community の動作で実行されます。オープンな MCP サーバーで、AI は持ち込みです。ライセンスがある場合、AI Builder と「データに質問する」が自社のインフラストラクチャ上で利用可能になります。[AI サービス](/docs/configure/ai)を参照してください。 +- **ひとつのデプロイ上の複数の組織にはライセンスが必要です。** ライセンスなしのマルチ組織の体制は起動時に拒否され、ランタイムはトラフィックを処理しません。[ランタイム設定](/docs/configure/runtime)と[トラブルシューティング](/docs/operate/troubleshooting)を参照してください。 +- **エアギャップ運用はライセンスモードです。** 通常のライセンスはコントロールプレーンに対してオンラインで検証されます。エアギャップライセンスはローカルで検証され、デプロイはコントロールプレーンがないことも宣言しなければなりません。機能し得ない組み合わせは起動時に拒否されます。[エアギャップデプロイ](/docs/deploy/air-gapped)を参照してください。 +- **ObjectOS Cloud では、プランは環境ごとに強制されます。** 開発環境は Free では拒否されます(`403 DEV_ENV_PLAN_LOCKED`)。容量が満杯の環境は添付ファイルのアップロードと一括インポートを拒否しますが、単一レコードの書き込み、読み取り、エクスポートは決して拒否しません(`403 STORAGE_LIMIT_REACHED`、80% から警告)。MCP エンドポイントはプランのリクエスト上限を超えると `429` を返します。[環境](/docs/cloud/environments)と[ストレージと上限](/docs/cloud/storage-and-limits)を参照してください。 +- **ライセンスが失効してもランタイムは決して停止しません。** Business Self-Managed は有料機能をオフにし、データとランタイムは動作し続けます。フルの Enterprise デプロイは最後に支払ったバージョンを維持します。[ライセンスと料金](/docs/editions/license#faq)を参照してください。 + +## このページはどのエディション向けか [#which-edition-is-this-page-for] + +| バッジ | 意味 | +|---|---| +| Community | ObjectOS Community を自分で実行する場合に当てはまる | +| Cloud | ObjectOS Cloud の Free および Team プランで当てはまる | +| Enterprise | ライセンスを受けたセルフマネージドの ObjectOS Enterprise デプロイで当てはまる | + +デプロイと運用は、2 つのセルフマネージドのエディション、Community と Enterprise 向けです。ObjectOS Cloud では、私たちがデプロイを運用します。 + +## 次へ [#next] + +- [開始](/docs/start/cloud):3 つの入り口。Cloud、Community、Enterprise。 +- [ライセンスと料金](/docs/editions/license):料金、契約、オープンソースライセンス。 +- [パブリックプレビュー](/docs/editions/public-preview):ObjectOS Cloud の現在の段階が、データと連携にとって何を意味するか。 diff --git a/content/docs/editions/license.ja.mdx b/content/docs/editions/license.ja.mdx new file mode 100644 index 00000000..7cb56e42 --- /dev/null +++ b/content/docs/editions/license.ja.mdx @@ -0,0 +1,118 @@ +--- +title: ライセンスと料金 +description: ObjectOS は 3 つのエディションを持つひとつの製品です。Community(オープンソース、無料)、Cloud、Enterprise。それぞれのライセンス形態と、ObjectStack フレームワークとの関係を説明します。 +editions: [community, cloud, enterprise] +translation: + source_sha: 8214fa1bad828f1ed43938ca72f4a791acb69e4bdb82a1e015eec4e78d2d1128 + guide_rev: 1 + mode: auto +--- + +## ひとつの製品、3 つのエディション [#one-product-three-editions] + +ObjectOS は **ObjectStack アプリケーションの公式ランタイム環境**で、3 つのエディションがあります。**ObjectOS Community**(オープンソース、セルフホスト、無料)、**ObjectOS Cloud**(私たちが運用)、**ObjectOS Enterprise**(商用ライセンスのもとであなたが運用)です。ObjectStack は、3 つのエディションが実行するプロトコル、SDK、CLI、フレームワークの名前です。 + +{/* + Naming, decided under #79 and re-confirmed on #107: "Console UI" here and in + the Apache-2.0 list further down names the *component* — the shipped browser + bundle, the `@objectstack/console` package, the same spelling architecture.mdx + and configure/system-settings.mdx were deliberately kept at. #79 retired + "Console" as the name of the end-user surface (a reader opens ObjectOS, not + "the Console") but kept the names of things the product ships, and a license + enumerates artifacts rather than surfaces. Both occurrences on this page are + therefore deliberate — do not rewrite them to "the UI" or to "ObjectOS". +*/} + +オープンソースの部分には 2 つの名前と 1 つのライセンスがあります。**[ObjectStack フレームワーク](https://github.com/objectstack-ai/objectstack)**(Apache-2.0)はプロトコル、カーネル、SDK、CLI です。**ObjectOS Community** はそこから構築されたオープンソースのランタイムと Console UI です。本番用のランタイムイメージ、MCP サーバー、権限と共有、監査、あらゆるプロトコル機能など、**独自のアプリケーションを構築、実行、セルフホスト**するために必要なものすべてが無料で、開発と本番の間に機能の制限はありません。そのコードは同じ objectstack リポジトリにあります。ObjectOS Cloud と ObjectOS Enterprise は、その仕組みの上に**組み込みの AI** と**公式に運用 / サポートされる**プラットフォームが欲しいときに購入するものです。 + +## エディション [#editions] + +ObjectOS Community は無料です。2 つの商用エディションには、ひとつの料金表と 2 つの提供形態があり、**AI シート**が唯一の課金単位です。 + +| | **Community** | **Free** | **Team** | **Enterprise** | +|---|---|---|---|---| +| エディション | ObjectOS Community | ObjectOS Cloud | ObjectOS Cloud | ObjectOS Enterprise | +| 提供形態 | **セルフマネージド** | クラウド | クラウド | **セルフマネージド(プライベートデプロイ)** | +| 料金 | **無料**(Apache-2.0) | **無料** | **$24** / AI シート / 月 | 個別見積もり(ボリューム):[営業にお問い合わせください](mailto:sales@objectstack.ai) | +| UI 内の AI(AI Builder +「データに質問する」) | MCP のみ(モデルは持ち込み) | お試し | ✓ | ✓ | +| AI ガバナンス(アクションの監査、ガードレール、権限のスコープ設定) | — | — | — | ✓ | +| クラスタリング / HA、マルチ組織のセルフホスト、エアギャップ、SCIM | — | — | — | ✓ | +| サポート | コミュニティ | コミュニティ | スタンダード | SLA + 専任 | + +Team とフルのプライベートデプロイの間には、**ObjectOS Enterprise の下の 2 つの契約オプション**があり、料金はこれまでどおりです。**Business**(ObjectOS Cloud 上の SSO と AI ガバナンス、**$54** / AI シート / 月、年額払いでは $45 / AI シート / 月。優先サポート)と **Business Self-Managed**(シングルノードのエントリーオプション、**$540** / AI シート / 年)です。どちらも契約により販売されます。[営業にお問い合わせください](mailto:sales@objectstack.ai)。 + +**無料で**セルフホストしたいですか?それが **ObjectOS Community** です(MCP のみの AI:モデルは持ち込み)。下の [ObjectOS Community](#objectos-community) を参照してください。 + +**オープンな仕組み、クローズドなインテリジェンス。** データをモデル化しアプリを実行するために必要なものはすべてオープンソースです(ObjectStack フレームワーク上の ObjectOS Community)。Cloud と Enterprise は、その上に*組み込みのインテリジェンス*(製品内の AI Builder と「データに質問する」アシスタント。ガバナンスと監査付き)を重ね、さらに運用のスケールと公式サポートを加えます。 + +**支払うのは AI シートだけです。** 課金されるシートは **AI シート**だけです。閲覧者、AI を使わないアプリユーザー、MCP 上で構築する開発者は無料で、ユーザーごとのシート課金はありません(ユーザーあたり $150〜300 の既存製品とは明確に対照的です)。 + +## クラウドかセルフマネージドか [#cloud-or-self-managed] + +セルフマネージドの ObjectOS は、**ObjectOS Community**(無料)または **ObjectOS Enterprise**(ライセンス制)です。Cloud は Free と Team で、Team より上はすべて Enterprise の契約です。 + +- **Cloud(Free / Team)**:フルホスト型、セルフサービス。料金にはホスティングと十分な月間 AI 利用枠が含まれます(モデルは私たちが実行します)。 +- **Enterprise**:契約により販売されるライセンス制の ObjectOS で、3 つの形があります。 + - **Business**:ObjectOS Cloud 上の契約オプション。ホスト型サービス上の SSO / SAML と AI ガバナンス(アクションの監査、ガードレール、権限のスコープ設定)、$54 / AI シート / 月、または年額払いで $45 / AI シート / 月、優先サポート。 + - **Business Self-Managed**:シングルノードのエントリーオプション。Business プランを **10〜25 AI シート**向けの**シングルノード Docker ライセンス**として提供します($540/AI シート/年、年額)。自社のインフラストラクチャ上で UI 内の AI と AI ガバナンスを利用でき、**モデル / キーは持ち込み**です(LLM のコストは自分で支払います。プラットフォームの AI 請求や従量課金はありません)。カード払い、スタンダードサポート。ライセンスが失効しても、ランタイムは動作し続け、データはそのまま残ります。有料機能がオフになるだけです(使えなくなることは決してありません)。 + - **フルのプライベートデプロイ**:それを超えるすべてのもの。クラスタリング / HA / マルチノード、マルチ組織のセルフホスト、データレジデンシーと**エアギャップ**運用、SCIM と ID ライフサイクル、コンプライアンスの証明、SLA と専任サポート、オフライン / 請求書払い。複数年の価格固定オプション付きの**年間ライセンス**として販売されます。更新しない場合、デプロイは**最後に支払ったバージョンを永続的に維持**します(失うのはアップデートとサポートであり、システムやデータではありません)。料金はボリュームベースです。[営業にお問い合わせください](mailto:sales@objectstack.ai)。 + +**ノードごとの課金はありません**。クラスタリング / スケールは Enterprise に含まれます。 + +## それぞれが適するとき [#when-you-might-want-each] + +| シナリオ | 選ぶもの | +|---|---| +| プラットフォームを**ホストして運用してほしい** | ObjectOS Cloud(Free → Team) | +| ホスト型サービスで **SSO と AI ガバナンス**が必要 | **Business**、ObjectOS Cloud 上の ObjectOS Enterprise の契約オプション | +| **自社のシングルノード**で実行しなければならず、製品内の AI が欲しい、25 AI シート以下 | **Business Self-Managed**、ObjectOS Enterprise の契約オプション(シングルノードのエントリー) | +| **クラスタリング / HA**、**マルチ組織**、**エアギャップ**、**SCIM**、コンプライアンスの証明、SLA、オフライン払いが必要 | **ObjectOS Enterprise**、フルのプライベートデプロイ | +| ObjectOS Community をセルフホストしているが、**商用契約付きのサポートされたディストリビューション**(SLA、セキュリティパッチの優先対応、指名エスカレーション)が必要 | **ObjectOS Runtime Subscription**:[営業にお問い合わせください](mailto:sales@objectstack.ai) | +| **無料のセルフホスト**で、MCP 経由で AI を持ち込みたい | **ObjectOS Community**(オープンソース) | +| ObjectOS または ObjectStack の名前で **OEM / 再配布**の権利が必要 | 商用契約:[営業にお問い合わせください](mailto:sales@objectstack.ai) | + +## ObjectOS Community [#objectos-community] + +**ObjectOS Community**(ランタイムイメージと Console UI)と、その土台である **ObjectStack フレームワーク**は、[Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) のもとでライセンスされています。コピーレフトなし、明示的な特許許諾、永久に無料。シートも、利用ティアも、ライセンスサーバーも、キーも、テレメトリもありません。ノート PC、自社のサーバー、顧客のサーバー、1000 ポッドのクラスターのどこで実行しても、ライセンスは同じです。そこでの AI の経路はオープンな **MCP サーバー**です。自分の Claude Code(または任意の MCP クライアント)をデプロイに向ければ、自分のモデルでデータをクエリし操作できます。 + +Apache-2.0 であるもの(objectstack リポジトリ内):ランタイムイメージ、すべての`@objectstack/*` npm パッケージ(ランタイム、プラグイン、サービス、ドライバー、アダプター、CLI、クライアント SDK)、templates リポジトリ、そして Console UI(それが提供する Account アプリを含む)。ドキュメントは CC-BY-4.0 です。 + +全文:[LICENSE](https://github.com/objectstack-ai/objectstack/blob/main/LICENSE) + +## 商標 [#trademarks] + +「ObjectOS」と ObjectOS のロゴは**商標**であり、どのコードライセンスの対象でもありません。自社の製品が ObjectOS / ObjectStack の*上に構築されている*、または*互換性がある*と事実に即して述べることはできます。自社の製品名、会社名、ドメイン名にこれらの名前やロゴを使うには書面による許可が必要です。[TRADEMARK.md](https://github.com/objectstack-ai/docs.objectos.ai/blob/main/TRADEMARK.md) を参照してください。 + +## サードパーティのコンポーネント [#third-party-components] + +ObjectOS は ObjectStack フレームワークと、いくつかの上流のオープンソースプロジェクト(Node.js、Hono、Zod、Better Auth、AI SDK など)の上に構築されています。それぞれに独自のライセンスが付属し、すべて商用利用と互換性のある Apache-2.0、MIT、BSD 系の寛容なライセンスです。完全な一覧は、各リリースとともに公開される SBOM を参照してください。 + +## よくある質問 [#faq] + +### ObjectOS はオープンソースですか? [#is-objectos-open-source] + +**ObjectOS Community** はオープンソースです。ランタイムと Console は Apache-2.0 で無料、そのもととなる ObjectStack フレームワークと並んで [objectstack リポジトリ](https://github.com/objectstack-ai/objectstack)でオープンに開発されています。**ObjectOS Cloud**と **ObjectOS Enterprise** は商用エディションで、同じ仕組みの上に組み込みの AI、ホスティングと運用、SSO、サポートを加えたものです。 + +### 顧客に販売するクローズドソースの SaaS で ObjectStack を使えますか? [#can-i-use-objectstack-in-a-closed-source-saas-i-sell-to-my-customers] + +はい。Apache-2.0 にはコピーレフトもロイヤリティもありません。コードを共有する必要はなく、製品が利益を上げても支払うものはありません。 + +### 支払いなしで ObjectOS をセルフホストできますか? [#can-i-self-host-objectos-without-paying] + +はい。それが **ObjectOS Community** です。同じオープンな仕組みをセルフホストし、AI は MCP のみ(モデルは持ち込み)です。*ライセンス制*のセルフマネージドエディションは ObjectOS Enterprise で、その 2 つのオプション(シングルノードの Business Self-Managed、またはフルの Enterprise デプロイ)はどちらも、製品内の AI、AI ガバナンス、商用サポートを追加します。 + +### 更新しないとソフトウェアは動かなくなりますか? [#does-the-software-stop-working-if-i-dont-renew] + +使えなくなることは決してありません。Business Self-Managed は無料の仕組みの動作に戻ります(有料機能はオフになり、データとランタイムは動作し続けます)。フルの Enterprise デプロイは**最後に支払ったバージョンを永続的に**維持します。失うのはアップデート、新しい AI 機能、サポートであり、システムではありません。 + +### ObjectOS は外部に通信しますか? [#does-objectos-phone-home] + +ObjectOS Enterprise はライセンスをオンラインで検証します。エアギャップの Enterprise ライセンスはオフラインで検証します。ObjectOS Community にはテレメトリも、ライセンスチェックも、アップデートの確認通信もありません。 + +### ObjectOS Community に契約上の保証が欲しい場合は? [#what-if-i-want-a-contractual-warranty-on-objectos-community] + +それが **ObjectOS Runtime Subscription** です。ObjectOS Community に対するサポート契約(公式ディストリビューション、SLA、セキュリティパッチの優先対応)で、機能の違いはまったくありません。[sales@objectstack.ai](mailto:sales@objectstack.ai) にお問い合わせください。 + +### ObjectOS Community とフレームワークはずっと無料ですか? [#will-objectos-community-and-the-framework-always-be-free] + +はい。Apache-2.0 の許諾は取り消し不能であり、すでにリリースされたものはすべてリリース時のライセンスのままです。 diff --git a/content/docs/editions/meta.ja.json b/content/docs/editions/meta.ja.json new file mode 100644 index 00000000..df73148b --- /dev/null +++ b/content/docs/editions/meta.ja.json @@ -0,0 +1,5 @@ +{ + "title": "エディション", + "defaultOpen": false, + "pages": ["compare", "license", "public-preview"] +} diff --git a/content/docs/editions/public-preview.ja.mdx b/content/docs/editions/public-preview.ja.mdx new file mode 100644 index 00000000..60043f4d --- /dev/null +++ b/content/docs/editions/public-preview.ja.mdx @@ -0,0 +1,26 @@ +--- +title: パブリックプレビュー +seoTitle: "ObjectOS Cloud パブリックプレビュー:その意味" +description: ObjectOS Cloud はパブリックプレビュー段階です。データは保持され、API はまだ変更される可能性があり、SLA はまだありません。 +editions: [cloud] +translation: + source_sha: cd4762a38174841caa9a60deee0bb14fead010fe52bcf440543efe070cb2bcb2 + guide_rev: 1 + mode: auto +--- + +ObjectOS Cloud は**パブリックプレビュー**段階です。今日からサインアップし、実際のアプリを構築して実行できます。この段階の間は、次の 3 つが当てはまります。 + +| | パブリックプレビュー中 | +|---|---| +| **データ** | 永続的です。構築したものと保存したレコードは保持されます。プレビューは消去されるサンドボックスではありません。 | +| **API** | 変更される可能性があります。一般提供の前に、エンドポイント、パラメーター、レスポンスの形がリリース間で変わることがあります。 | +| **サービスレベル** | SLA はまだありません。公開されるまで、稼働率の約束もサービスクレジットもありません。 | + +## それがあなたにとって意味すること [#what-that-means-for-you] + +- **その上に構築してください。** アプリとそのデータは引き継がれます。プレビューが終わっても最初からやり直すことはありません。 +- **連携するものは固定してください。** 他のシステムが [REST API](/docs/reference/rest-api) や [MCP](/docs/configure/mcp) 経由で ObjectOS Cloud を呼び出す場合は、API が変わったときにその連携を見直すことを想定し、[変更履歴](/docs/resources/changelog)を確認してください。 +- **今すぐ SLA が必要ですか?** ObjectOS を自分で実行してください。**ObjectOS Enterprise** には契約のもとで SLA と専任サポートが付きます。[エディションの比較](/docs/editions/compare)と[ライセンスと料金](/docs/editions/license)を参照してください。 + +パブリックプレビューは ObjectOS Cloud だけの段階です。自分でデプロイして[アップグレード](/docs/operate/upgrade)する ObjectOS Community や ObjectOS Enterprise については何も意味しません。 diff --git a/content/docs/index.ja.mdx b/content/docs/index.ja.mdx new file mode 100644 index 00000000..baaf9d33 --- /dev/null +++ b/content/docs/index.ja.mdx @@ -0,0 +1,87 @@ +--- +title: はじめに +seoTitle: "ドキュメント:開始、構築、デプロイ、運用" +description: "ObjectOS は ObjectStack 上に構築された商用ランタイム環境で、ホスト型(ObjectOS Cloud)またはセルフマネージド(ObjectOS Enterprise)で動作します。オントロジーはあなたのものです。" +editions: [community, cloud, enterprise] +translation: + source_sha: 0347fa621f4342212be72ecb78497ccea72ca3490d850f45dc5b91549d81cf22 + guide_rev: 1 + mode: auto +--- + +オントロジーこそがソフトウェアです。実行可能なひとつのビジネスオントロジー。AI が書き、ランタイムが実行し、エージェントが操作し、あなたが所有します。同じループをブラウザ上のホスト型で、何もインストールせずに使いたいですか?それが ObjectOS、ObjectStack 上に構築された商用ランタイム環境です。ObjectOS は、何もインストールせずにブラウザ上のホスト型で動作するか(ObjectOS Cloud)、あなた自身のインフラストラクチャ上でセルフマネージドで動作し(ObjectOS Enterprise)、どちらのエディションでもオントロジーをエクスポートして、オープンソースの ObjectStack ランタイムで実行できます。 + +ビジネスに必要なもの(ヘルプデスク、承認フロー、CRM)を組み込みの [AI Builder](/docs/build/ai-builder) に説明するか、テンプレートをインストールすると、それが書いたオントロジーは即座に稼働します。REST API、生成された画面、認証、RBAC、監査ログ、ファイルストレージ、バックグラウンドジョブ、Webhook、そしてエージェントが呼び出せる MCP ツールとして公開されたすべてのオブジェクトです。各変更は小さく読みやすい差分で、実行前に人が承認します。 + +オープンソース(Apache-2.0)の [ObjectStack](https://github.com/objectstack-ai/objectstack) スタック、すなわちプロトコル、カーネル、CLI、本番ランタイムは、オントロジーを実行するものであり、ObjectOS の土台です。ObjectOS 上で構築したものはすべて通常の ObjectStack メタデータなので、構造上、そのランタイムへ移植できます。 + +## 開始 [#start] + +| やりたいこと | すること | +|---|---| +| ObjectOS を、何もインストールせずに | **ObjectOS Cloud**、あなたのためのホスト型です。最初のアプリを構築するには Free プランで十分です。[ObjectOS Cloud で始める](/docs/start/cloud)を参照してください | +| ObjectOS を、自社のインフラストラクチャ上でライセンスを受けて | **ObjectOS Enterprise** は、シングルノードの Business Self-Managed オプション以上で、ライセンスされた Enterprise イメージをあなたのサーバー上で実行します。[ObjectOS Enterprise で始める](/docs/start/enterprise)を参照してください | +| ObjectOS を、自分のマシン上で無料で | **ObjectOS Community** はオープンソースのランタイムです。`npm i -g @objectstack/cli && os start` で SQLite データベースとともに設定なしで起動します。[ObjectOS Community をローカルで実行する](/docs/start/community)を参照してください | + +その後、ObjectOS Cloud と ObjectOS Enterprise は同じ場所へ導きます。サインインして、次のことができます。 + +- **AI Builder と対話する**:*「サポートチケットを優先度と担当者付きで管理したい」*。AI Builder があなたの[オントロジー](/docs/ontology)、つまりオブジェクトとそのフィールドを、あなたが承認する小さな差分として書くのを見届けます。[構築 → AI Builder](/docs/build/ai-builder) を参照してください。 +- **組み込みのマーケットプレイスからテンプレートをインストールする**(Todo、Contracts、Helpdesk など)と、ワンクリックで本物のアプリが手に入ります。 +- **[テンプレート](/docs/build/templates)をフォークする**と、TypeScript ソースを自分の管理下に置けます。同じメタデータを、自分のリポジトリでコーディングエージェントとともに作成します。 + +## なぜ …… ではなく ObjectOS なのか [#why-objectos-instead-of-] + +| 今使っているもの | 課題 | ObjectOS が提供するもの | +|---|---|---| +| **Retool / Appsmith** | UI ビルダーは優れているが、データは彼らのクラウドにあり、料金はユーザー数に比例して増える | ホスト型(ObjectOS Cloud)またはあなたのネットワーク内(ObjectOS Enterprise)。オープンソースの ObjectStack(Apache-2.0)上に構築されているため、オントロジーは自由にエクスポートできます。シート課金なし、AI シートのみ | +| **Supabase / Firebase** | BaaS は速いがベンダーロックイン。設計上マルチテナント SaaS | 同じ開発体験(自動 API、認証、ストレージ)ですが、定義はあなたのものです。エクスポートしてオープンな ObjectStack ランタイムで実行するか、自社のデータベース上で ObjectOS Enterprise を実行します | +| **Salesforce / NetSuite** | 強力なプラットフォームだが、カスタマイズは苦痛で、シート単価は目が飛び出るほど高い | 馴染みのある語彙(オブジェクト、フィールド、権限セット、共有ルール)ですが、定義は AI が書き、エージェントが操作し、Apache-2.0 ランタイムへエクスポートできるオープンなオントロジーです。ホスト型でもセルフマネージドでも、シート課金なし。閲覧者と AI を使わないユーザーは無料です | +| **自前で構築(Next + Prisma + NextAuth)** | RBAC、監査、ファイルアップロード、設定、Webhook、ジョブを毎回作り直す | 本来のビジネスロジックを出荷できます。プラットフォームの配管はすでに揃っています | + +## 標準で得られるもの [#what-you-get-out-of-the-box] + +| サーフェス | 内容 | +|---|---| +| **自動 REST API** | 宣言したすべてのオブジェクトに、フィルター/ソート/ページネーション付きの `/api/v1/data/` が用意されます | +| **UI**(`/_console/`) | 生成された画面:レコードの閲覧と編集、そしてユーザー/ロール/権限セットの管理、監査ログ、セッション、API キー、設定の確認を行う **Setup** | +| **Account**([UI 内](/docs/resources/glossary#surface)) | ログイン、登録、パスワードリセット、OAuth、OIDC/SSO、パスキー、2FA | +| **MCP サーバー**(`/api/v1/mcp`) | デフォルトで有効:すべてのオブジェクトがツールであり、AI に公開したすべてのアクションもツールです。エージェントはユーザーとして、そのユーザーの権限のもとで接続します。[AI ツールを接続する(MCP)](/docs/configure/mcp)を参照してください | +| **20 以上のプラグイン** | RBAC + 行レベルセキュリティ、監査、ファイルストレージ、キュー、ジョブ、メール、AI、Webhook。宣言的で、必要に応じて有効化 | + +## あなたの手元に残るもの [#what-stays-yours] + +どちらのエディションでも、オントロジーはあなたのものです。オブジェクト、フィールド、リレーション、アクション、権限、フロー、エージェント定義は通常の ObjectStack メタデータであり、エクスポートしてオープンソースの ObjectStack ランタイムで実行できます。**ObjectOS Enterprise** では、それ以外のものもすべてあなたの手元に残ります。[データレジデンシー](/docs/reference/security#data-residency)には、データの各分類について、どこに保存され、ネットワークの外に出るかどうかが記載されています。 + +セルフマネージドの ObjectOS はライセンスをオンラインで検証します。Enterprise のエアギャップライセンスはオフラインで検証します。したがって、インターネットにアクセスできないネットワークは Enterprise の正式なデプロイ先です。[エアギャップ](/docs/deploy/air-gapped)を参照してください。 + +## ドキュメントを探す [#find-your-docs] + +このドキュメントは 4 種類の読者を対象としています。ObjectOS の使い方に合ったセクションから始めてください。 + +| あなたは …… | やりたいこと | ここから | +|---|---|---| +| **日常的なユーザー** | アプリで作業する:レコード、ビュー、ダッシュボード、承認 | [使い方](/docs/use) | +| **ビルダー** | アプリを作成する:データモデル、インターフェース、自動化、AI エージェント | [構築](/docs/build) | +| **管理者** | プラットフォームを運用する:ユーザー、権限、設定、デプロイ | [設定](/docs/configure)、[デプロイ](/docs/deploy)、[運用](/docs/operate/production) | +| **エージェント、またはそれを接続する人** | MCP 経由でアプリを操作する:レコードの読み書き、AI に公開されたアクションの実行。ユーザーの権限のもとで | [AI ツールを接続する(MCP)](/docs/configure/mcp) | + +## 次に進む [#where-to-go-next] + +| やりたいこと | 読むもの | +|---|---| +| 何もインストールせずに始める | [ObjectOS Cloud で始める](/docs/start/cloud) | +| 実際に動くところを見る | [ObjectOS Community をローカルで実行する](/docs/start/community) | +| 各エディションに含まれるものを比較する | [エディションの比較](/docs/editions/compare) | +| 中身を理解する | [アーキテクチャ](/docs/architecture) | +| Docker で実行する | [Docker](/docs/deploy/docker) | +| Kubernetes にデプロイする | [Kubernetes](/docs/deploy/kubernetes) | +| Postgres / 自社の DB に接続する | [ランタイム設定](/docs/configure/runtime) | +| SSO を設定する | [認証](/docs/configure/authentication) | +| 誰が何を見られるかを制限する | [権限](/docs/configure/permissions) | +| 別のサービスから呼び出す | [API アクセス](/docs/configure/api-access) | +| 他のシステムにイベントを送る | [Webhook](/docs/configure/webhooks) | +| 本番に移行する | [本番環境への準備](/docs/operate/production) | + +## ライセンスと料金 [#license--pricing] + +ObjectOS は **3 つのエディションを持つひとつの製品**です。**ObjectOS Community**、すなわち Apache-2.0 の **[ObjectStack フレームワーク](https://github.com/objectstack-ai/objectstack)** 上に構築されたオープンソースのランタイムと Console は、商用製品での利用、組み込み、セルフホストが無料で、シート単価もライセンスサーバーもありません。**ObjectOS Cloud** と **ObjectOS Enterprise** は、UI に組み込まれた AI、ガバナンス、公式の運用を追加し、料金は **AI シートのみ**です(閲覧者と AI を使わないユーザーは無料)。「年額 5 万ドルから」はありません。[ライセンスと料金](/docs/editions/license)を参照してください。 diff --git a/content/docs/start/cloud.ja.mdx b/content/docs/start/cloud.ja.mdx new file mode 100644 index 00000000..7086f29a --- /dev/null +++ b/content/docs/start/cloud.ja.mdx @@ -0,0 +1,45 @@ +--- +title: ObjectOS Cloud で始める +seoTitle: "ObjectOS Cloud で始める:サインアップして最初のアプリを構築する" +description: あなたのためにホストされる ObjectOS、インストールするものは何もありません。サインアップし、組織に名前を付け、その本番環境を作成し、最初のアプリを構築してチームを招待します。 +editions: [cloud] +translation: + source_sha: f83df72638808eeefa3a77e192b7ec0158e83be9e366f36a3d9c7fd74b8fedf5 + guide_rev: 1 + mode: auto +--- + +**ObjectOS Cloud** は、あなたのためにホストされ運用される ObjectOS です。作業はブラウザで行い、インストールやデプロイするものは何もありません。**Free** プランで最初のアプリを構築するには十分です。ObjectOS Cloud は[パブリックプレビュー](/docs/editions/public-preview)段階です。 + +自分のマシン上で使いたいですか?それは [ObjectOS Community](/docs/start/community) です。自社のインフラストラクチャ上で、ライセンスを受けて使いたいなら、[ObjectOS Enterprise](/docs/start/enterprise) です。 + +## 1. サインアップする [#1-sign-up] + +[www.objectos.ai](https://www.objectos.ai) から始めて、アカウントを作成します。 + +## 2. 組織を作成する [#2-create-your-organization] + +組織は、チームが共有するワークスペースです。そのアプリ、そのデータ、そのメンバーです。名前と **slug**(ObjectOS が組織を指すのに使う短い識別子)を付けます。 + +## 3. 本番環境を作成する [#3-create-the-production-environment] + +組織はアプリを環境の中で実行します。組織の**本番環境**を作成してください。最初のアプリはそこに置かれます。Free プランでは、本番が唯一の環境です。[環境](/docs/cloud/environments)を参照してください。 + +## 4. 最初のアプリを構築する [#4-build-your-first-app] + +方法は 2 つあり、両方を使えます。 + +- **[AI Builder](/docs/build/ai-builder) に説明する**:*「サポートチケットを優先度と担当者付きで管理したい」*。AI Builder はオブジェクト、フィールド、ビューを、実行前にあなたが承認する変更として提案します。 +- **[テンプレート](/docs/build/templates)をインストールして**、動作するアプリから始めます。 + +開発者にはさらに 2 つの道があります。ソースで構築して[パッケージ](/docs/build/packages#two-developer-routes-on-objectos-cloud)を公開するか、自分のエージェントに [MCP](/docs/configure/mcp#build-with-an-agent) 経由で、人が公開するドラフトとして構築させます。どちらの場合も、結果は通常の ObjectStack メタデータです。REST API、生成された画面、権限、監査がそれに付いてきます。次に追加できるものすべては[構築](/docs/build)を参照してください。 + +## 5. チームを招待する [#5-invite-your-team] + +アプリを使う人を招待します。課金されるのは **AI シート**だけです。閲覧者と、製品内の AI を使わずにアプリを使う人は無料です。招待とロールについては[組織とメンバー](/docs/cloud/organization)、環境内のアクセスについては[ユーザー](/docs/configure/users)、誰が何を見られるかについては[権限](/docs/configure/permissions)を参照してください。 + +## 次へ [#next] + +- [ObjectOS を使う](/docs/use):チームがサインインしたときに目にするもの。 +- [AI ツールを接続する(MCP)](/docs/configure/mcp):エージェントにユーザーの権限のもとでアプリを操作させます。 +- [エディションの比較](/docs/editions/compare):Free と Team それぞれに含まれるもの。 diff --git a/content/docs/start/community.ja.mdx b/content/docs/start/community.ja.mdx new file mode 100644 index 00000000..450a8c0d --- /dev/null +++ b/content/docs/start/community.ja.mdx @@ -0,0 +1,296 @@ +--- +title: ObjectOS Community をローカルで実行する +seoTitle: "os start または os init で ObjectOS Community をローカルで実行する" +description: "オープンソースのランタイムである ObjectOS Community で、ゼロから動作するアプリまで。ひとつの CLI、ひとつのコマンドで、アカウントも Docker も不要です。" +editions: [community] +translation: + source_sha: 830910eec853d34ebcd2357928eec4e753013766473391a72b60214c2479ce9e + guide_rev: 1 + mode: auto +--- + +**ObjectOS Community** は ObjectOS のオープンソースエディションです。ランタイムとその UI で、Apache-2.0 の ObjectStack フレームワーク上に構築され、どこでも無料で実行できます。このページでは、ひとつの CLI とひとつのコマンドで自分のマシン上に起動します。ここではアカウントもライセンスも Docker も必要ありません。 + +他の 2 つの入り口にはそれぞれ専用のページがあります。何もインストールせずにホスト型で使いたいなら [ObjectOS Cloud で始める](/docs/start/cloud)、自社のインフラストラクチャ上でライセンス制のエディションを使うなら [ObjectOS Enterprise で始める](/docs/start/enterprise)を参照してください。ここで構築したものは通常の ObjectStack メタデータなので、すべてのエディションで動作します。 + +オープンなランタイムを起動する方法は、何をしているかによって 2 つあります。 + +| あなたは …… | ここから | +|---|---| +| オープンなランタイムを初めて試す、または本番で実行する | [パス A:`os start`](#path-a--os-start-operator--first-time-evaluator) | +| コードでアプリを構築またはカスタマイズする | [パス B:`os init`](#path-b--os-init-developer) | + +どちらも UI 付きの稼働中のサーバーを生成します。アカウントのサインイン、登録、セルフサービスはその UI 内のルートであり、別のサーフェスではありません。違いは、ソースファイルをスキャフォールドするかどうかです。 + +## 前提条件 [#prerequisites] + +- **Node.js 22 以降**:`node --version` +- ターミナル + +それだけです。Docker も、データベースも、アカウント登録も不要です。 + +--- + +## パス A:`os start`(運用担当者 / 初めての評価者) [#path-a--os-start-operator--first-time-evaluator] + +CLI をグローバルにインストールしてから実行します。 + +```bash +npm i -g @objectstack/cli +os start +``` + +次のように表示されます。 + +```text +◆ ObjectStack +──────────────────────────────────────── +🏠 Home: ~/.objectstack +📦 Artifact: none (empty kernel — install apps via the Console marketplace) +🗄️ Database: file:~/.objectstack/data/objectstack.db +🎯 Environment: env_local + → Starting server... + + ✓ Server is ready + + ➜ API: http://localhost:3000/ + ➜ Console: http://localhost:3000/_console/ + ➜ MCP: http://localhost:3000/api/v1/mcp + connect an AI client (Claude Code, Cursor, …) · skill: http://localhost:3000/api/v1/mcp/skill + + Mode: production + Driver: SqlDriver(better-sqlite3) → ~/.objectstack/data/objectstack.db + Tenancy: single + Plugins: 32 loaded + + Press Ctrl+C to stop +``` + +それだけです。オープンソースのランタイムである ObjectOS Community が動作しています。ObjectOS Cloud と ObjectOS Enterprise がその上に何を追加するかは、[エディションの比較](/docs/editions/compare)を参照してください。 + +上のブロックは、`@objectstack/cli` 17.7.0 が空のカーネルを起動したときの出力を書き写したものです。ホームパスは `~/…` と表示していますが、CLI は展開したパスを表示します。実際の起動では、サーバーの起動前に `No objectstack.config.ts or artifact found` の行も表示され、さらに起動時の注意事項(ローカルストレージドライバー、開発用の暗号鍵、起動診断)と読み込まれたプラグイン名の完全な一覧も表示されます。どちらも環境によって異なります。 + +### 何が動いているか [#whats-running] + +| URL | 内容 | +|---|---| +| http://localhost:3000/_console/register | 最初のアカウントを作成する | +| http://localhost:3000/_console/ | 生成された UI と **アプリマーケットプレイス** | +| http://localhost:3000/_console/apps/setup | **Setup**:ユーザー、ロール、監査ログ、設定 | +| http://localhost:3000/api/v1/health | Liveness:プロセスが起動しているか | +| http://localhost:3000/api/v1/ready | Readiness:起動していて、**かつ**データを提供しているか | + +ランタイムは**空のカーネル**で起動します。オブジェクトもアプリもなく、マーケットプレイスが公開されているので、出来合いのアプリを数秒でインストールできます。 + +ここではどちらのプローブも 200 を返し、ローカルではそれをつなぐ先もありません。しかし両者は交換可能ではないので、デプロイする前にどちらがどちらかを知っておく価値があります。Liveness はプロセスが実行中であることだけを報告するため、データベースが停止している間もずっと正常のままです。Readiness は、データドライバーが応答しなくなった時点で **503** を返します。どのプローブをどこに使うかは [Docker](/docs/deploy/docker) と [Kubernetes](/docs/deploy/kubernetes)で説明しています。 + +### チャットで構築する:AI Builder [#build-by-chat--the-ai-builder] + +製品内の AI Builder は ObjectOS のものです。ObjectOS Cloud と ObjectOS Enterprise に含まれ、今起動したオープンなランタイムには含まれません。オープンなランタイムの AI 経路は、バナーに表示された MCP エンドポイント(`http://localhost:3000/api/v1/mcp`。Claude Code や任意の MCP クライアントをそこに向けます)です。ObjectOS では、サインインしたら AI アシスタント(右上のきらめきアイコン)を開き、必要なものを説明します。 + +> *「顧客サポートのチケットを管理したい。各チケットには件名、説明、優先度(low/medium/high/urgent)、ステータス、担当者がある。ステータスでグループ化したカンバンビューを追加して。」* + +AI が計画を提案し、あなたが承認すると、メタデータが稼働します。REST エンドポイント、生成されたビュー、監査ログのエントリ、権限によるゲートです。ファイルの編集も再起動もありません。語彙の全体は [構築 → AI Builder](/docs/build/ai-builder)を参照してください。 + +> **IDE で手書きしていますか?** `npx skills add objectstack-ai/objectstack/skills`を実行すると、Claude Code / Cursor / Copilot / Codex に、ObjectStack メタデータ(ObjectOS が実行するオントロジー)を実際の Zod スキーマに沿って作成する方法を教えられます。[構築 → IDE スキル](/docs/build/ai-skills)を参照してください。 + +### マーケットプレイスからアプリをインストールする [#install-an-app-from-the-marketplace] + +**http://localhost:3000/_console/** を開き、サインインして、アプリを選びます。 + +| アプリ | 得られるもの | +|---|---| +| Todo | 汎用のタスク & プロジェクト管理 | +| Contracts | AI による抽出付きの契約ライフサイクル | +| Procurement | ベンダー、発注書、3 点照合 | +| Compliance | SOC 2 / ISO 27001 の統制 + 証跡 | +| Helpdesk | AI ファーストのカスタマーサポート | +| Content | 編集カレンダー + チャネル ROI | +| HR | 名簿、組織図、休暇 | + +インストール → 再読み込み → そこにあります。オブジェクト、ビュー、権限、シードデータ付きです。再起動は不要です。 + +### よく使うフラグ [#common-flags] + +```bash +os start --port 3200 # different port +os start --database postgres://... # external database +os start --auth-secret "$(openssl rand -hex 32)" # enable auth in /api/v1/auth/* +os start --home /var/lib/objectos # persistent home (production) +``` + +すべてのオプションは[ランタイム設定](/docs/configure/runtime)を、本番向けの構成は [Docker](/docs/deploy/docker) を参照してください。 + +--- + +## パス B:`os init`(開発者) [#path-b--os-init-developer] + +独自のデータモデル、ビュー、フローを定義するために TypeScript を書くときに使います。 + +```bash +npx @objectstack/cli init my-app -t app --install +cd my-app +pnpm dev +``` + +次のように表示されます。 + +```text +◆ Development Mode +──────────────────────────────────────── +📂 Config: my-app/objectstack.config.ts + → Compiling objectstack.config.ts → dist/objectstack.json... + +◆ Compile +──────────────────────────────────────── + ✓ Build complete (312ms) + + Data: 1 Objects 3 Fields + UI: 0 Apps + Logic: 0 Flows + Security: 0 Positions 0 Permissions + Runtime: 0 plugins + + Artifact: my-app/dist/objectstack.json (2.2 KB) + + → Starting dev server (local mode)... +🎯 Environment ID: env_local +📦 Artifact: dist/objectstack.json +🗄️ Database: file:my-app/.objectstack/data/objectstack.db + 👁 watching objectstack.config.ts, src — rebuild + restart on change + + ✓ Server is ready + + ➜ API: http://localhost:3000/ + ➜ Console: http://localhost:3000/_console/ + ➜ MCP: http://localhost:3000/api/v1/mcp + connect an AI client (Claude Code, Cursor, …) · skill: http://localhost:3000/api/v1/mcp/skill + + 🔑 Dev admin: admin@objectos.ai / admin123 + seeded on empty DB · dev only — do not use in production + platform admin — Setup, Studio and every record, but NO app-declared capability, so + an app that gates navigation on requiredPermissions may show it an empty menu; grant + it a permission set under Setup → Users, or sign in as an account your app seeds + + Artifact: dist/objectstack.json + Mode: development + Driver: SqlDriver(better-sqlite3) → my-app/.objectstack/data/objectstack.db + Tenancy: single + Plugins: 38 loaded + + Press Ctrl+C to stop +``` + +上のブロックは、`@objectstack/cli` 17.7.0 で作成したスキャフォールド上で `pnpm dev` を実行したときの出力を書き写したものです。プロジェクトパスは `my-app/…` と表示していますが、CLI は展開したパスを表示します。また `Build complete` の時間は書き写したマシンが計測した値なので、あなたの環境では異なります。実際の実行では、ブロックの上に pnpm のスクリプトヘッダー、ステップごとのコンパイル進捗、起動時の注意事項(設定の読み込み、開発用のシークレットフィールド暗号化)、読み込まれたプラグイン名の完全な一覧、起動診断、MCP 接続の末尾メッセージも表示されます。その末尾メッセージは別のプロセスが出力するため、通常はバナーの途中に入り込み、しばしば 1 行ずつ交互に混ざります。 + +開発サーバーはポート **3000** を要求することに注意してください。これは `os start` と同じポートです。そのポートが使用中の場合は、次の空きポートにバインドし、そのことを表示します。`↪ server bound to port 3001 (requested 3000)`。 + +### 独自のオブジェクトを追加する [#add-your-own-object] + +追加するすべてのオブジェクトは、プロジェクトの名前空間を持ちます。`os init` はそれをプロジェクト名から導出し(`my-app` → `my_app`)、`objectstack.config.ts` に `manifest.namespace` として書き込んだので、タスクオブジェクトは`my_app_task` です。コンパイルは素の `task` を`Rename it to 'my_app_task' (namespace = 'my_app')` で拒否し、次のルールを示します。``Every object.name must be `${manifest.namespace}_${shortName}`.`` + +`src/objects/task.object.ts` を作成します。 + +```ts +// src/objects/task.object.ts +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +const task = ObjectSchema.create({ + name: 'my_app_task', + label: 'Task', + fields: { + subject: Field.text({ label: 'Subject', required: true, maxLength: 200 }), + done: Field.boolean({ label: 'Done', defaultValue: false }), + due: Field.date({ label: 'Due' }), + assignee: Field.lookup('sys_user', { label: 'Assignee' }), + }, + // Org-wide default: who sees records they don't own. Required — the + // compile refuses an object that declares none. + sharingModel: 'private', +}); + +export default task; +``` + +次に、それを `src/objects/index.ts` からエクスポートします。`objectstack.config.ts` は各ディレクトリをその `index.ts` バレル経由で読み込むため、バレルがエクスポートしないファイルは読み込まれません。サンプルオブジェクトの行の下に次の行を追加します。`export { default as task } from './task.object';` + +`npx objectstack generate object task` は、ファイルのスタブと同じバレルの行を書き込みます。 + +保存します。開発サーバーは再コンパイルし、`✚ new object(s): my_app_task — table & seeds sync on restart` を表示して、新しいビルドで自分自身を再起動します。書くべきマイグレーションも、実行すべき再起動もありません。`✗ compile failed` だけが表示された場合は、`pnpm build` を実行して原因を確認してください。これで次のものが手に入ります。 + +- **`/api/v1/data/my_app_task`**:作成、読み取り、更新、削除を、フィルター、ソート、ページネーション付きで([REST API](/docs/reference/rest-api))。サインインした呼び出し元にのみ応答するので、まず開発用管理者としてサインインしてください(下記)。 +- **生成された一覧、フォーム、レコードページ**:スキャフォールドはまだアプリを宣言していないため、Console 内にそれらへのリンクはありません。`http://localhost:3000/_console/apps/setup/my_app_task` を開いてください。 +- **権限セットエディターの Task 行**:Setup → Permission Sets でセットを開き、*Design in Studio* を選びます。そのセットに対する作成、読み取り、編集、削除です。 +- **監査ログのエントリ**:作成、更新、削除のそれぞれが、Setup → Audit Logs に一覧表示されます。 + +```bash +curl -c cookies.txt -H 'Content-Type: application/json' \ + -d '{"email":"admin@objectos.ai","password":"admin123"}' \ + http://localhost:3000/api/v1/auth/sign-in/email +curl -b cookies.txt -H 'Content-Type: application/json' \ + -d '{"subject":"Try the API"}' \ + http://localhost:3000/api/v1/data/my_app_task +curl -b cookies.txt http://localhost:3000/api/v1/data/my_app_task +``` + +### プロジェクトの構成 [#project-layout] + +```text +my-app/ +├── objectstack.config.ts # defineStack(): manifest + one key per barrel +├── src/ # one index.ts barrel per directory +│ ├── actions/ +│ ├── apps/ +│ ├── dashboards/ +│ ├── flows/ +│ ├── objects/ # index.ts + my_app_item.object.ts, the sample object +│ ├── picklists/ +│ ├── skills/ +│ └── views/ +├── dist/ +│ └── objectstack.json # Compiled artifact (regenerated on save) +├── .objectstack/ # Local SQLite databases (pnpm dev creates them) +├── package.json +├── pnpm-workspace.yaml +└── tsconfig.json +``` + +`os init` は `.gitignore` とインストーラーのロックファイルも書き込みます。 + +`dist/objectstack.json` が本番に出荷するものです。これをランタイム(オープンな ObjectStack イメージ、または ObjectOS Enterprise のデプロイ)にマウントすると、それがあなたのアプリになります。 + +### テンプレートから始める [#or-start-from-a-template] + +本番向けのスターターは [github.com/objectstack-ai/templates](https://github.com/objectstack-ai/templates) にあります。 + +```bash +git clone https://github.com/objectstack-ai/templates.git +cd templates/packages/todo +pnpm install +pnpm dev # http://localhost:4002 +``` + +各テンプレートは 2500 行未満で、一度に読み通せ、単体で動作します。 + +--- + +## 標準で読み込まれるもの [#whats-loaded-out-of-the-box] + +どちらのパスでも、次のプラグインが自動的に読み込まれます。 + +> HTTP サーバー、REST API、Dispatcher、MCP サーバー、Auth、Security(RBAC + RLS + FLS)、Audit、Sharing、プラットフォームオブジェクト、Metadata、ObjectQL、デフォルトのデータソースと外部データソースおよびデータソース管理のサービス、Queue、Jobs、Cache、Settings、Email、SMS、Messaging、Storage、Analytics、外部検証、Marketplace、パッケージ管理、マイグレーションの復旧、Setup と Account のアプリ、そして Console UI。 + +パス B では、スキャフォールドの `objectstack.config.ts` が`requires: ['automation', 'triggers']` を宣言しているため、自動化サービスに加えて、レコード変更、スケジュール、相対時間、受信 HTTP/Webhook のフロートリガーも読み込まれます。また、`pnpm dev` が開発モードで実行されるため、専用のテレメトリデータソース(データベースの隣にある 2 つ目の SQLite ファイル)も開きます。 + +どれもインポートしたり配線したりする必要はありません。上の一覧は何も宣言していなくても読み込まれ、自動化サービスとフロートリガーは何かがそれを必要と宣言すると有効になり、開発モードはテレメトリデータソースを追加します。 + +## 次のステップ [#next-steps] + +| 次にすること | 読むもの | +|---|---| +| Docker で実行する(本番向けの構成) | [Docker](/docs/deploy/docker) | +| SQLite の代わりに Postgres を使う | [ランタイム設定](/docs/configure/runtime) | +| Google / Okta / Entra のログインを追加する | [認証](/docs/configure/authentication) | +| 誰が何をできるかを制限する | [権限](/docs/configure/permissions) | +| Slack / Zapier / 自社のサービスにイベントを送る | [Webhook](/docs/configure/webhooks) | +| 本番にデプロイする | [本番環境への準備](/docs/operate/production) | diff --git a/content/docs/start/enterprise.ja.mdx b/content/docs/start/enterprise.ja.mdx new file mode 100644 index 00000000..f8538773 --- /dev/null +++ b/content/docs/start/enterprise.ja.mdx @@ -0,0 +1,30 @@ +--- +title: ObjectOS Enterprise で始める +seoTitle: "ObjectOS Enterprise で始める:ライセンス制、セルフマネージド" +description: ObjectOS Enterprise は、自社のインフラストラクチャ上で実行するライセンス制のエディションです。ライセンスの入手方法と、デプロイガイドの出発点を説明します。 +editions: [enterprise] +translation: + source_sha: 65aa2affdd32ca612f967daaffd1275dc3cb60a61e72bd88b99c97ee9c0f11f2 + guide_rev: 1 + mode: auto +--- + +**ObjectOS Enterprise** は、商用ライセンスのもとで、あなたが運用するインフラストラクチャ上で動作する ObjectOS です。製品内の AI、AI ガバナンス、公式のサポートを自社のサーバー上で利用でき、フルのプライベートデプロイでは、クラスタリング、ひとつのデプロイ上の複数の組織、SCIM、エアギャップ運用も利用できます。完全な一覧は[エディションの比較](/docs/editions/compare)を、シングルノードの **Business Self-Managed** 以上の契約オプションは[ライセンスと料金](/docs/editions/license)を参照してください。 + +## 1. ライセンスを入手する [#1-get-a-license] + +ObjectOS Enterprise は契約により販売されます。[営業にお問い合わせください](mailto:sales@objectstack.ai)。ライセンスにはライセンスされた Enterprise イメージのレジストリ認証情報が付属し、各リリースのリリースノートには実行すべき正確なイメージダイジェストが記載されています。 + +## 2. デプロイする [#2-deploy] + +デプロイガイドは[デプロイ](/docs/deploy)から始まり、すべてのデプロイが行う 3 つの判断を順に説明します。その後、構成を選びます。 + +- [Docker](/docs/deploy/docker):サポートされる構成。プル、検証、ライセンス、実行、スケール、アップグレード。 +- [Kubernetes](/docs/deploy/kubernetes):どのオーケストレーターも保持しなければならない特性。 +- [エアギャップ](/docs/deploy/air-gapped):ライセンスモードとしてのエアギャップと、起動時に拒否される組み合わせ。 + +## 3. 運用する [#3-operate] + +[本番環境への準備](/docs/operate/production)は、本番稼働前のチェックリストです。そこから先のバックアップ、アップグレード、可観測性、監査ログは[運用](/docs/operate)で扱います。 + +購入前に評価したいですか?[ObjectOS Community](/docs/start/community) は同じランタイムを無料で実行し、その上で構築したものは通常の ObjectStack メタデータなので、そのまま Enterprise に移行できます。 diff --git a/content/docs/start/meta.ja.json b/content/docs/start/meta.ja.json new file mode 100644 index 00000000..5c8508c5 --- /dev/null +++ b/content/docs/start/meta.ja.json @@ -0,0 +1,5 @@ +{ + "title": "開始", + "defaultOpen": false, + "pages": ["cloud", "community", "enterprise"] +} diff --git a/content/docs/use/approvals.ja.mdx b/content/docs/use/approvals.ja.mdx new file mode 100644 index 00000000..bf28ea37 --- /dev/null +++ b/content/docs/use/approvals.ja.mdx @@ -0,0 +1,76 @@ +--- +title: 承認 +seoTitle: "承認の受信トレイ:申請、レビュー、リクエストの追跡" +description: レコードを承認に回し、自分に割り当てられたリクエストに対応し、すべての承認の状況を常に把握します。 +editions: [community, cloud, enterprise] +translation: + source_sha: 2e66606b534686f5a7d8944dc432440b441ef36a02de56101bfa89b2627a1ce3 + guide_rev: 1 + mode: auto +--- + +一部のレコードは、先に進む前に承認が必要です。上限を超える経費、値引き、休暇申請などです。そのような場合、ObjectOS は**承認リクエスト**を作成します。これはシステムが管理する稼働中のレコードで、その 1 回の申請を、送信された瞬間から誰かが承認または却下するまで追跡します。これらの追跡用レコードを自分で作成したり編集したりすることはありません。判断が下されるたびに、システムが最新の状態に保ちます。 + +このページでは、プロセスの両側を扱います。何かを承認に回すことと、自分を待っているリクエストに対応することです。 + +## 承認の受信トレイ [#your-approval-inbox] + +承認に関するものはすべて、ひとつの場所にあります:**Account → Inbox → Approvals**。**Account** アプリを開き(全員が利用できます)、左のサイドバーで **Inbox** を展開して、**Approvals** を選びます。 + +リストの上部にはビューのタブがあり、同じ受信トレイを 4 つの切り口で見られます。 + +| ビューのタブ | 表示内容 | +|---|---| +| **My Pending** | *あなた*の判断を待っているリクエスト。これがあなたの ToDo リストです。 | +| **I Submitted** | あなたが承認に回したリクエスト。自分の申請の状況はここで確認します。 | +| **Completed** | 判断が下された(承認または却下された)リクエスト。 | +| **All** | あなたが見られるすべての承認リクエスト。どれが見られるかは下で説明します。 | + +あなたを待っているものが何もないとき、**My Pending** には空の状態 *"No pending approvals — You're all caught up."* が表示されます。一日の終わりに見たい画面です。 + +**見えるのは、自分が関わっているリクエストです。** リクエストが見えるのは、それを申請した人、現在それが待っている相手(直接、または保持しているポジションを通じて)、そしてすでに判断またはコメントした人です。管理者はすべてのリクエストを見られます。レコードを開けるだけでは不十分です。レコードを読み取れても承認に関わっていない同僚には、そのリクエスト、コメント、添付ファイルは見えません。 + +> **ヒント:** Approvals の受信トレイをブックマークするか、ホームページの **Needs your attention** の一覧をちらっと見てください。保留中の承認はそこにも、待っている時間とともに表示されます。 + +## 承認リクエストとは [#what-an-approval-request-is] + +| これは…… | これではない…… | +|---|---| +| 申請ごとに追跡される稼働中のインスタンス。承認に回したレコード 1 件につきリクエスト 1 件 | レコード自体のコピー | +| システム管理。判断が下されるにつれて自動的に作成、更新、クローズされる | 手で編集するもの | +| 監査証跡。誰が申請し、誰が割り当てられ、何がいつ決まったか | 消えてしまう一度きりの通知 | + +各申請がそれぞれのリクエストを持つため、却下後にレコードを再申請すると、新しいリクエストが始まります。古いリクエストは履歴として **Completed** に残ります。 + +## リクエストに対応する [#acting-on-a-request] + +リクエストがあなたに回されると、2 つのことが起こります。 + +1. **通知が届きます**:トップバーのベルに未読のバッジが表示され、その項目がホームページの **Needs your attention** と **Account → Inbox → Notifications** に表示されます。 +2. **レコードにあなた向けの承認アクションが表示されます**:該当するレコードを開くと、レコードアクションとして **Approve** と **Reject** が表示されます。これらのボタンは割り当てられた承認者にだけ表示され、レコードを見ている他の人には表示されません。 + +| するには | 方法 | +|---|---| +| 自分を待っているものを確認する | **Account → Inbox → Approvals** → **My Pending**、またはベル / **Needs your attention** | +| 詳細を確認する | リクエストからレコードを開き、他のレコードと同じようにハイライトとフィールドセクションを読みます | +| 判断する | **Approve** または **Reject** のレコードアクションを使います | +| 処理されたことを確認する | リクエストが **Completed** に移り、申請者に通知されます | + +> **ヒント:** リクエストだけでなく、レコードを読んでください。承認リクエストは、何かに判断が必要だという*こと*を伝えます。承認す*べきかどうか*を伝えるのはレコード自体です。 + +## 保留中にロックされるレコード [#records-locked-while-pending] + +承認の設定によっては、リクエストが保留中の間、レコードが**ロック**されることがあります。判断が下されるまでフィールドを編集できません。これは承認者を守るためです。承認するものは申請されたものとまったく同じで、その間にこっそり編集されることはありません。ロックされたレコードを変更する必要がある場合は、承認者に却下してもらい(またはアプリが許す場合は取り下げ)、変更を加えてから再申請してください。 + +## アプリが提供するレビューキュー [#app-provided-review-queues] + +一部のアプリは、独自に整理した**レビューキューのページ**を追加します。たとえば、アプリのナビゲーションにある「Approvals」ページで、そのチームに関係する項目だけを、あらかじめフィルターした状態で処理できるように表示します。あればそれを使ってください。中身は同じリクエストで、より使いやすい見せ方にすぎません。**Account → Inbox → Approvals** の受信トレイは、常にすべてがひとつの場所にある完全なビューです。 + +## 次に進む [#where-to-go-next] + +| 次にすること | 読むもの | +|---|---| +| 日常的なユーザーとして ObjectOS を見て回る | [ObjectOS を使う](/docs/use) | +| 承認するレコードを操作する | [レコード](/docs/use/records) | +| 自分に回されたその他すべてを把握する | [通知](/docs/use/notifications) | +| 承認者と同じようにリストを切り分ける | [ビュー](/docs/use/views) | diff --git a/content/docs/use/collaboration.ja.mdx b/content/docs/use/collaboration.ja.mdx new file mode 100644 index 00000000..ae83fe16 --- /dev/null +++ b/content/docs/use/collaboration.ja.mdx @@ -0,0 +1,122 @@ +--- +title: レコードでのコラボレーション +seoTitle: "レコードのディスカッション、@メンション、履歴、共有リンク、公開フォーム" +description: チームとレコードについて話し合い、人を @メンションし、何が誰によって変更されたかを確認します。そして共有リンクと公開フォームが外部の人に何を開くかを説明します。 +editions: [community, cloud, enterprise] +translation: + source_sha: 6f006fa86f045c560def736d420ace4826af5c3751a280f581b2c7d8bbc2d102 + guide_rev: 1 + mode: auto +--- + +{/* + Platform checklist items this page documents (objectstack + docs/qa/platform-checklist/areas/records-forms.json and access-security.json): + records-forms.record-discussion-mentions, + records-forms.comment-reactions-and-edit-stamps, + records-forms.field-history-tracking, + access-security.share-link-capability-tokens, + access-security.share-link-landing-page, + access-security.share-link-combined-gates, + access-security.public-form-intake, + access-security.public-form-withdrawal-layers. +*/} + +レコードに関する作業がひとりで完結することはめったにありません。すべてのレコードページは、そのレコードについての会話と、何が変わったかの記録を持てます。さらにアプリがそのように設定されていれば、サインインしない人にレコードを見せたり、その人からデータを集めたりする方法も持てます。 + +## ディスカッション [#the-discussion] + +レコードページのタブの下には **Discussion** があります。コメントとレコードのアクティビティがひとつのフィードに古い順で並びます(ビルダーがディスカッションを有効にしたオブジェクトに表示されます)。 + +| できること | 方法 | +|---|---| +| コメントする | **Write a comment…** に入力し、**Send** を押すか `Ctrl+Enter` を押します。太字、斜体、リスト、インラインコードはツールバーにあり、**Preview** もあります | +| 誰かをメンションする | `@` を入力し、候補から人を選びます(または **Mention someone**) | +| 返信する | コメントの下に返信して、スレッドをまとめます | +| リアクションする | コメントに絵文字のリアクションを追加します | +| 編集または削除する | 自分のコメントのみ。編集されたコメントには **(edited)** の印が付きます。この印はサーバーが付けるので、隠すことはできません | +| レコードをフォローする | **Subscribe to notifications** で、このレコードの更新を受け取ります | + +コメントは送信した瞬間に表示され、その後、重複することなく保存されたコピーに置き換わります。 + +### フィードを絞り込む [#filtering-the-feed] + +フィードには、コメントとレコードのアクティビティが混在しています。フィルターで絞り込めます。 + +| フィルター | 表示内容 | +|---|---| +| **All Activity** | すべて | +| **Comments Only** | コメントと返信 | +| **Field Changes** | レコードのフィールドへの編集 | +| **Tasks Only** | タスクのエントリ | + +フィルターの結果が空なら、レコードにその種類のものがまだないというだけです。 + +## @メンション [#mentions] + +誰かを `@mention` すると、その人のベルに、コメントのプレビューとレコードへの直接リンク付きの通知が届きます。 + +メンションがディスカッション自体より遠くに届くことはありません。通知されるのは、その人がレコードとコメントを読み取れる場合だけです。誰かをメンションしても、その人にアクセス権が**与えられるわけではありません**。コメントの作成者は自分自身のメンションについて通知されず、メンションされていない人も通知されません。 + +## 何が変わり、誰が変えたか [#what-changed-and-who-changed-it] + +| 場所 | 表示内容 | +|---|---| +| ディスカッションのフィード | アプリのビルダーが追跡対象に選んだフィールドについて、**Industry: Technology → Finance** のような行。フォームで見るラベルを使います | +| **History** タブ | レコードの完全な変更履歴。ビルダーが履歴の追跡を有効にしたオブジェクトに表示されます | + +追跡されていないフィールドへの変更には、フィードで変更前後の行が表示されません。 + +## チームとレコードを共有する [#sharing-a-record-with-your-team] + +レコードの `⋯` メニューにある **Share** は、レコードのアドレスをコピーします(またはデバイスの共有シートを開きます)。何かを許可するわけではありません。それを開く人には、引き続きレコードへのアクセス権が必要です。[権限](/docs/configure/permissions)を参照してください。 + +## 共有リンク:リンクを知っている全員 [#share-links-anyone-with-the-link] + +アプリのビルダーが公開共有を有効にしたオブジェクトでは、レコードは**共有リンク**も持てます。アカウントを持たない人や、アカウントはあってもレコードへのアクセス権がない人に、その 1 件のレコードを見せるリンクです。 + +共有リンクは、そのレコードを見られる人が API(`POST /api/v1/share-links`)を通じて 1 件のレコードに対して作成します。各リンクは独自の制限を持てます。 + +| 制限 | 効果 | +|---|---| +| **パスワード** | ページがそれを求めます:**Password required**、正しく入力するまで **Wrong password.** | +| **サインインしたユーザーのみ** | セッションのない訪問者には **Sign in required** と、同じページに戻る **Sign in** リンクが表示されます | +| **メールの許可リスト** | 一覧にあるメールアドレスでサインインした人だけがレコードを見られます | +| **有効期限** | リンクはその日に機能しなくなります | +| **非表示のフィールド** | リンクが表示する内容から除外されるフィールド。オブジェクトが常に共有リンクから隠すフィールドに加えて適用されます | + +リンクを開いた人には、アプリの外にあるページが表示されます。メニューはなく、リンクが求めない限りサインインもありません。レコードのフィールドが読み取り専用で表示されます。フィールドが非表示にされている場合、ページには **Some fields are hidden by the owner** と表示され、それらの名前が示されます。 + +### 失効、そしてリンクが決して公開しないもの [#revoking-and-what-a-link-never-exposes] + +- リンクを**失効**させる(`DELETE /api/v1/share-links/…`)と、即座に機能しなくなります。ページには **This link has expired or was revoked.** と表示されます。有効期限を過ぎたリンクも同様です。 +- **レコードを削除**すると、それへのすべてのリンクは **This link is invalid or no longer available.** と答えます。でっち上げたリンクと同じ答えなので、リンクがレコードの存在を確認させることはありません。 +- **非表示のフィールドはサーバー上で取り除かれます。** 画面からだけでなく、訪問者のブラウザに届くこともありません。 +- **1 件のレコードのみ。** リンクが開くのは、作成対象のレコードだけです。関連レコードやリストの残りは決して開きません。 +- **パスワードはサーバーに残ります。** ハッシュ化して保存され、どのレスポンスもそれを返しません。 +- **何もキャッシュされません。** リンク経由で提供されるページは、ブラウザとプロキシにコピーを保持しないよう指示するため、共有コンピューターの次の人がパスワードで保護されたレコードを見ることはできません。 +- **見えるのは自分のリンクだけです。** リンクを一覧表示すると、自分が作成したものが返され、他の人のものは返されません。 + +アプリのビルダーがオブジェクトの公開共有をオフにすると、そのオブジェクトのすべてのリンクは、再びオンにされるまで機能しなくなります。 + +## 公開フォーム:外部からデータを集める [#public-forms-collecting-data-from-outside] + +**公開フォーム**は、アプリのビルダーが `/forms/contact-us` のようなリンクで公開した、サインインしていない人向けのフォームです。お問い合わせフォーム、Web からのリード獲得、サポートリクエストなどです。訪問者はそれを記入し、フォームの確認表示(たとえばお礼のメッセージ)を見ます。各送信はレコードになり、チームは他のレコードと同じようにそれに対応します。 + +公開フォームが決して開かないもの: + +- **一覧にあるフィールドのみ。** 訪問者が見て記入できるのは、フォームが表示するフィールドだけです。オブジェクトの残りではありません。 +- **検索はできません。** フォームが何を宣言していても、公開フォームにはレコードを検索するフィールド(参照ピッカーやユーザーピッカー)はありません。 +- **所有者の偽装はできません。** 訪問者は、レコードの所有者、所属する組織、ステータスなどのフィールドを設定できません。それらに送られた値は破棄され、サーバーが設定します。 + +公開フォームを**閉じる**には、ビルダーが公開を取り消すか、匿名アクセスをオフにします。するとリンクはフォームが存在しないかのように答え、送信は即座に止まります。 + +## 次に進む [#where-to-go-next] + +| やりたいこと | 読むもの | +|---|---| +| レコードにファイルを添付する | [ファイルと添付ファイル](/docs/use/files) | +| どの通知を受け取るかを制御する | [通知](/docs/use/notifications) | +| 誰がレコードを見られるかを理解する | [権限](/docs/configure/permissions) | +| 公開フォームを構築する | [ビュー → 公開フォーム](/docs/build/interface/views#public-forms) | +| ツアーに戻る | [ObjectOS を使う](/docs/use) | diff --git a/content/docs/use/dashboards.ja.mdx b/content/docs/use/dashboards.ja.mdx new file mode 100644 index 00000000..b997288c --- /dev/null +++ b/content/docs/use/dashboards.ja.mdx @@ -0,0 +1,71 @@ +--- +title: ダッシュボード +seoTitle: "ダッシュボードを読む:KPI、グラフ、日付フィルター" +description: チームの KPI、グラフ、表をひと目で読み、日付やフィルターで 2 クリックで絞り込みます。 +editions: [community, cloud, enterprise] +translation: + source_sha: 2d541eab18014e7c9f5272fff044d83422a7760bfb23362d8270006188c6f864 + guide_rev: 1 + mode: auto +--- + +**ダッシュボード**は、レコードを数値とグラフに変えます。未完了のタスクがいくつあるか、作業がどう推移しているか、誰が負荷を担っているか。ここではダッシュボードを構築するのではなく読みます。ダッシュボードはレコードの変化に合わせて自動的に更新されます。 + +## ダッシュボードの場所 [#where-dashboards-live] + +ダッシュボードはアプリの**ナビゲーションサイドバー**の項目で、通常は **Analytics** のようなグループの中にあります。クリックすると、他のページと同じように開きます。 + +## ダッシュボードのヘッダー [#the-dashboard-header] + +すべてのダッシュボードの上部には次のものがあります。 + +| 要素 | 機能 | +|---|---| +| **タイトルと説明** | このダッシュボードが何を、なぜ測定するか | +| **期間のプリセット** | **Last 90 days** のようなドロップダウン。すべてのウィジェットの期間を一度に変更します | +| **ダッシュボードのフィルター** | **Task Status: All** のようなドロップダウン。すべてのウィジェットを一部分に絞り込みます(例:*In Progress* のみ) | + +> **ヒント:** 数値がおかしく見える場合は、まず期間とフィルターを確認してください。**Task Status: Done** での「Last 90 days」は、全期間、全ステータスとはまったく違う姿を見せます。 + +期間とフィルターは**ダッシュボード全体**に適用されるため、すべてのウィジェットがデータの同じ部分について同じ問いに答えます。2 つのグラフが別のものを数えているのではと悩む必要はありません。 + +## ウィジェットの種類 [#widget-types] + +ダッシュボードは**ウィジェット**のグリッドです。3 種類のウィジェットがあります。 + +| ウィジェット | 見た目 | 適した用途 | +|---|---|---| +| **KPI 統計タイル** | ラベル付きの大きな数値ひとつ | 見出しとなる数値:*Open Tasks: 42* | +| **グラフ** | 棒グラフ、円グラフなどの視覚表現 | グループの比較や傾向の発見 | +| **表** | 行と列 | 数値の裏にある詳細:上位の項目、最近のレコード | + +典型的なレイアウトでは、上部に統計タイルの行(見出し)、中央にグラフ(データの形)、下部に表(裏付け)を置きます。その順に読めば、1 分足らずで全体像がつかめます。 + +## ダッシュボードとビュー [#dashboards-vs-views] + +どちらもレコードを表示しますが、高さが異なります。 + +| サーフェス | 表示内容 | 使うとき | +|---|---|---| +| **ダッシュボード** | 集計:件数、合計、傾向 | 全体像が欲しいとき | +| **ビュー** | 個々のレコードを 1 行ずつ | リストを処理したいとき。[ビューを使う](/docs/use/views)を参照してください | + +## 空の状態 [#empty-states] + +表示するものがないウィジェットは、そのことをはっきり示します。たとえば表には **No rows** と表示されます。これはエラーではありません。現在の期間とフィルターでは、一致するレコードがないということです。期間を広げるかフィルターをリセットすれば、たいていデータは再び表示されます。 + +## 数値からレコードへ [#from-number-to-records] + +ダッシュボードはレコードを要約し、一部のウィジェットではそのレコードへ**ドリルスルー**できます。タイル、グラフの区分、表の行がクリック可能な場合、一致するレコードが開き、数値が示していることに対応できます。そこから先は通常のリストです。[ビューを使う](/docs/use/views)を参照してください。 + +> **ヒント:** ダッシュボードを読むことはループです。おかしな数値を見つけ、フィルターで絞り込み、レコードへドリルスルーし、直すべきものを直します。 + +## 次に進む [#where-to-go-next] + +| やりたいこと | 読むもの | +|---|---| +| 数値の裏にあるレコードを操作する | [レコードを操作する](/docs/use/records) | +| 自分でリストをフィルターしグループ化する | [ビューを使う](/docs/use/views) | +| 自分を待っている申請に対応する | [承認](/docs/use/approvals) | +| どの通知を受け取るかを管理する | [通知](/docs/use/notifications) | +| ツアーに戻る | [ObjectOS を使う](/docs/use) | diff --git a/content/docs/use/files.ja.mdx b/content/docs/use/files.ja.mdx new file mode 100644 index 00000000..52b5d13d --- /dev/null +++ b/content/docs/use/files.ja.mdx @@ -0,0 +1,94 @@ +--- +title: ファイルと添付ファイル +seoTitle: "ファイルと添付ファイル:アップロード、ダウンロード、権限" +description: レコードにファイルを添付するか、ファイルフィールドにアップロードします。誰がそれを見て、追加し、削除できるか、そして削除されたファイルがどのくらい保持されるかを説明します。 +editions: [community, cloud, enterprise] +translation: + source_sha: 3bb89abc1ac77bc14428766ab10e45b3a10ceef5d201d4d588c672c0a4072b9f + guide_rev: 1 + mode: auto +--- + +{/* + Platform checklist items this page documents (objectstack + docs/qa/platform-checklist/areas/attachments-storage.json): + attachments-storage.presigned-upload-roundtrip, + attachments-storage.download-authz-both-sides, + attachments-storage.read-inherits-parent-rls, + attachments-storage.attach-requires-parent-edit, + attachments-storage.sys-file-status-pipeline, + attachments-storage.orphan-tombstone-reap, + attachments-storage.inline-grid-receipt-cells, + attachments-storage.field-accept-maxsize-server-enforced, + attachments-storage.upload-limits-scope-and-ownership. +*/} + +ファイルがレコードに届く方法は 2 つあります。 + +| 方法 | 見た目 | 用途 | +|---|---|---| +| **添付ファイル** | レコードページの **Attachments** タブ。任意の数のファイルを一覧表示します | 契約書、写真、やり取り。レコードに属するあらゆるもの | +| **ファイルフィールドと画像フィールド** | フォーム内のアップロードコントロール。明細テーブルの各行にあることもあります | レコードが必要とする特定の書類。経費明細ごとの領収書、製品写真 | + +どちらもファイル自体はストレージに保管し、レコードはそれを指します。そのため、誰がそれを開けるかは同じルールで決まります。 + +## Attachments タブ [#the-attachments-tab] + +ビルダーが添付ファイルを有効にしたオブジェクトでは、すべてのレコードページに **Attachments** タブが表示されます。 + +| コントロール | 機能 | +|---|---| +| **Upload** | コンピューターから 1 つ以上のファイルを選びます | +| ファイル一覧 | このレコードに添付されたすべてのファイル | +| **Download** | ファイルを開きます | +| **Delete attachment** | 確認後、このレコードからファイルを削除します | + +何も添付されていないレコードには **No attachments yet. Upload a file to get started.** と表示されます。 + +## 誰が何をできるか [#who-can-do-what] + +添付ファイルは、それが属するレコードに従います。 + +| するには | 必要なもの | +|---|---| +| 添付ファイルを見てダウンロードする | レコードの読み取りアクセス | +| 添付ファイルをアップロードする | レコードの**編集**アクセス。読み取りだけでは不十分です | +| 添付ファイルを削除する | それをアップロードした本人であるか、レコードの編集アクセス | + +ファイルをアップロードした人は、常にそれをダウンロードし削除できます。 + +レコードを読み取れない場合、その添付ファイルはあなたにとって存在しません。どのリストにも件数にも表示されません。何かが拒否されると、*You don't have access to attach files to this record.* や *Only the uploader or someone who can edit this record may delete this attachment.* のような平易なメッセージが表示されます。 + +## ファイルフィールドと画像フィールド [#file-and-image-fields] + +ファイルフィールドや画像フィールドは、フォームにアップロードコントロールを表示します。ファイルを選ぶとすぐにアップロードされ、ファイル名付きのチップとして表示されます。保存前ならそれを削除できます。明細テーブル(たとえば請求書の明細行)では、各行に独自のアップロードセルがあります。 + +フィールドは、受け付けるファイルの種類とファイルの最大サイズを制限できます。サーバーは保存時にその制限をもう一度確認します。制限に違反するファイルはフィールド名を示すメッセージとともに拒否され、何も保存されません。 + +## ダウンロードは短時間だけ有効なリンク [#downloads-are-short-lived-links] + +**Download** は、数分間(デフォルトでは 5 分)有効なリンクをサーバーに要求します。そのリンクをコピーして後で開くと、期限切れになっています。レコードからもう一度ダウンロードしてください。レコードへのアクセス権がない人は、有効なリンクをまったく取得できません。 + +## サイズの上限 [#size-limits] + +管理者が別の上限(ストレージ設定の **Limits** にある **Max upload size (MB)**)を設定していない限り、1 つのファイルは **100 MB** までです。上限を超えるファイルは、一部でも保存される前に拒否されます。 + +## 削除されたファイルはどうなるか [#what-happens-to-removed-files] + +添付ファイルを削除すると、**そのレコード**から外れます。ファイル自体は段階的に片付けられるため、間違えてもその場でデータが失われることはありません。 + +| 状態 | 何が起こるか | +|---|---| +| 別のレコードにも添付されている | いずれかのレコードがまだ保持している限り残ります | +| どこにも添付されていない | **30 日間**保持され、その後ストレージが回収されます | +| 開始されたが完了しなかったアップロード | **7 日**後に削除されます | + +## 次に進む [#where-to-go-next] + +| やりたいこと | 読むもの | +|---|---| +| ファイルが属するレコードを開いて編集する | [レコードを操作する](/docs/use/records) | +| チームとレコードについて話し合う | [レコードでのコラボレーション](/docs/use/collaboration) | +| スプレッドシートから多数のレコードを取り込む | [データのインポート](/docs/use/import) | +| ファイルの保存場所を設定する | [ストレージ](/docs/configure/storage) | +| ツアーに戻る | [ObjectOS を使う](/docs/use) | diff --git a/content/docs/use/import.ja.mdx b/content/docs/use/import.ja.mdx new file mode 100644 index 00000000..cf10487c --- /dev/null +++ b/content/docs/use/import.ja.mdx @@ -0,0 +1,125 @@ +--- +title: データのインポート +seoTitle: "インポートウィザード:CSV と Excel のインポート、マッピング、取り消し" +description: CSV や Excel ファイルからレコードを取り込みます。列をマッピングし、何かが保存される前にエラーを修正し、うまくいかなかったインポートを取り消します。 +editions: [community, cloud, enterprise] +translation: + source_sha: 47a5d626f6c1bbf30e5d32d5604cf1ecf5a2d4285086b433ec1cbad75d838a2f + guide_rev: 1 + mode: auto +--- + +{/* + Platform checklist items this page documents (objectstack + docs/qa/platform-checklist/areas/records-forms.json): + records-forms.import-wizard-encoding-and-hints, + records-forms.named-import-mapping, + records-forms.import-job-undo-cancel, + records-forms.import-transform-matrix. +*/} + +顧客リストや昨年のプロジェクトなど、スプレッドシートにレコードがある場合、**インポートウィザード**が 3 つのステップでそれをレコードに変えます:**Upload**、**Mapping**、**Preview**。最後に **Import** を押すまで、何も保存されません。 + +## ウィザードを開く [#opening-the-wizard] + +オブジェクトのリストで、リストヘッダーの **Import** をクリックします。オブジェクトによっては、ヘッダーの `⋯` オーバーフローメニューの中にあります。 + +表示されるのは、オブジェクトがインポートに対して開かれていて、そのレコードの作成を許可されている場合だけです。インポートはレコードを作成するので、**+ New** と同じ権限が必要です。 + +## ステップ 1:Upload [#step-1--upload] + +ファイルをウィザードにドラッグするか、**Browse Files** をクリックするか、Excel や Google Sheets からコピーした行をそのまま貼り付けます(`Ctrl/⌘+V`)。 + +| ファイル | サポート | +|---|---| +| CSV、TSV | はい | +| Excel `.xlsx` | はい | +| 古い Excel `.xls` | いいえ。開いて先に `.xlsx` として保存してください | + +最初の行は列見出しでなければならず、データ行が少なくとも 1 行必要です。 + +**どの列を使えばよいかわからない場合は?** **Download template** をクリックすると、インポートできるすべての列を一覧にし、必須の列に `*` を付け、記入方法を説明した Excel ファイルが手に入ります。 + +> **ヒント:** 中国語版の Excel で保存したファイル(「CSV として保存」)は、UTF-8 ではなく GBK でエンコードされていることがよくあります。変換する必要はありません。ウィザードがエンコーディングを検出するので、中国語の見出しと値はそのまま取り込まれます。 + +## ステップ 2:Mapping [#step-2--mapping] + +ウィザードは名前によって列をオブジェクトのフィールドに対応付け、その確度(**High**、**Medium**、**Low confidence**)を示します。対応付けを確認し、間違っているものを変更します。 + +| 選択肢 | 効果 | +|---|---| +| フィールド | 列の値がそのフィールドに入ります | +| **— Skip —** | 列は無視されます | + +値がフィールドの型らしく見えない列には印が付きます(たとえば *Looks like Date*)。 + +**必須フィールドはマッピングしなければなりません。** マッピングされていない場合、**Next** は無効のままで、ウィザードは不足しているフィールドを示します。その列をファイルに追加するか、それを含むファイルをアップロードしてください。 + +**マッピングの再利用。** **Mapping template** の下にある **Save current** は、この列とフィールドの対応をブラウザに保存するので、同じファイルレイアウトの次のインポートはワンクリックで済みます。アプリのビルダーがこの種のファイル向けに**保存されたマッピング**を設定している場合は、代わりにそれを選びます。その場合、サーバーが列の名前を変更し、値を変換し、独自のルールを適用し、列のマッピングは読み取り専用で表示されます。 + +## ステップ 3:Preview [#step-3--preview] + +プレビューは、インポートされるとおりに行を表示します。問題のある行は強調表示されます。強調表示されたセルをクリックすると、その場で値を修正できます。**Validate data** をクリックすると、何かが書き込まれる前に、すべての行をサーバーに照らして確認します。有効な行の数と、どの行にエラーがあるかが表示されます。検証に合格した行は、インポートが受け入れる行です。 + +よくあるエラー: + +| メッセージ | 意味 | +|---|---| +| *This field is required* | 必須の値が空です | +| *"abc" is not a valid number*(または date、time、true/false) | 値をフィールドの型として読み取れません | +| *"Gold" is not one of the allowed options* | 選択フィールドに、提供されていない値が入りました | +| *No matching record for "Acme"* | 別のレコードを指す列が、存在しないレコードを指定しました | +| *"Acme" matches more than one record* | その名前は一意ではありません。一意の値かレコード ID を使ってください | + +### インポートのオプション [#import-options] + +| オプション | 機能 | +|---|---| +| **When a row matches an existing record** | **Always create new**、**Update existing (skip if no match)**、**Update if matched, else create** | +| **Match on** | 既存のレコードを見つけるために使うフィールド。メールアドレス、コード、外部 ID など | +| **Keep unknown option values** | フィールドが提供していない選択肢の値を、行を拒否する代わりにそのまま保存します | +| **Skip rows with a blank match value** | 照合に使う値がない行を除外します | +| **Import in the background** | インポートを、後で取り消せるジョブとして実行します | + +照合フィールドで更新すると、インポートを安全に繰り返せます。同じファイルをもう一度実行すると、重複を作る代わりに、作成したレコードを更新します。 + +## 結果 [#the-result] + +インポートが完了すると、**作成**、**更新**、**スキップ**されたレコードの数と、行のエラーが表示されます。**Download failed rows** は、取り込めなかった行だけを渡すので、修正してもう一度インポートできます。 + +## バックグラウンドインポート、キャンセル、取り消し [#background-imports-cancel-and-undo] + +**5,000 行**を超えるファイルは常にバックグラウンドでインポートされます。それより小さいファイルは、**Import in the background** にチェックを入れた場合にそうなります。バックグラウンドインポートは進捗を表示し、その間も作業を続けられます。 + +| 操作 | 何が起こるか | +|---|---| +| **Cancel import** | ジョブを停止します。すでに書き込まれた行は残ります。行は完全に保存されるか、まったく保存されないかのどちらかで、半分だけ書き込まれることはありません。 | +| **History** | このオブジェクトのバックグラウンドインポートを新しい順に、ステータス、行数、結果とともに一覧表示します | +| **Undo import** | インポートが作成したレコードを削除し、更新したレコードを以前の値に戻します。それ以外には何も触れません。 | + +取り消せない行がある場合、ウィザードはその数を示します。**Undo import** をもう一度選ぶと、それらだけを再試行します。 + +> **ヒント:** 大きなファイルを初めてインポートしますか?**Import in the background** にチェックを入れてください。結果が期待どおりでなければ、**Undo import** 一回で元に戻せます。 + +## アプリビルダー向け:保存されたマッピング [#for-app-builders-saved-mappings] + +保存されたマッピングは、パッケージ内の `mapping` メタデータです。外部の見出しをフィールドに名前変更し("Full Name" → `name`)、再インポートが重複ではなく更新になるようにするキーを設定し、取り込み時に値を変換できます。 + +| 変換 | 機能 | +|---|---| +| `map` | コードを値に変換します(`'Webform'` → `web`)。エントリのない値はそのまま通ります | +| `constant` | 列を無視して固定値を書き込みます | +| `lookup` | 名前を関連レコードの ID に解決します | +| `split` | ひとつの列を複数のフィールドに分割します | +| `join` | 複数の列をひとつのフィールドに結合し、空の部分はスキップします | + +`javascript` 変換を指定したマッピングは、行が書き込まれる前に拒否されます。それを実行するサーバー側のサンドボックスがないためです。別の形式向けに宣言されたマッピングでファイルをインポートする(JSON を CSV マッピングで)と、両方の形式を示して拒否されます。Excel ファイルは CSV マッピングを通ります。存在しないマッピングを指定したインポートは、位置による列の照合にフォールバックするのではなく拒否されます。 + +## 次に進む [#where-to-go-next] + +| やりたいこと | 読むもの | +|---|---| +| インポートしたレコードを編集する | [レコードを操作する](/docs/use/records) | +| インポートしたばかりのレコードを見つける | [検索](/docs/use/search) | +| インポートしたものだけにリストを絞り込む | [ビューを使う](/docs/use/views) | +| ツアーに戻る | [ObjectOS を使う](/docs/use) | diff --git a/content/docs/use/index.ja.mdx b/content/docs/use/index.ja.mdx new file mode 100644 index 00000000..17dd42e1 --- /dev/null +++ b/content/docs/use/index.ja.mdx @@ -0,0 +1,94 @@ +--- +title: ObjectOS を使う +seoTitle: "ユーザーガイド:サインイン、アプリ、検索、ナビゲーション" +description: 一度サインインすれば、チームが共有するすべてのアプリ、レコード、通知が見つかります。5 分で回る ObjectOS のツアーです。 +editions: [community, cloud, enterprise] +translation: + source_sha: 6cc63a43afd4f93986a9af7c841417befd0918cb6efbc5b38f583fe46cc826c1 + guide_rev: 1 + mode: auto +--- + +ObjectOS は、プロジェクト、タスク、承認、ダッシュボードなど、チームの業務アプリが置かれる場所です。一度サインインすれば、アクセスできるすべてのアプリをひとつの場所で、ひとつの検索ボックスとともに見られます。 + +このセクションは**日常的なユーザー**向けです。アプリを構築する人やシステムを管理する人は、代わりに[構築](/docs/build)または[設定](/docs/configure)に進んでください。 + +## サインイン [#signing-in] + +チームから伝えられた URL を開き、**メールアドレスとパスワード**でサインインします。セッションは記憶されるので、アバターメニューからログアウトするまで、そのブラウザではサインインしたままです。 + +## ホームページ [#the-home-page] + +サインインすると、ホームページに移動します。上から順に次のものがあります。 + +| 領域 | 表示内容 | +|---|---| +| **あいさつ** | 個人宛てのあいさつ("Good afternoon, Ada") | +| **あなたのアプリ** | タイルのグリッド。アクセスできるアプリごとに 1 枚です。タイルをクリックするとアプリが開きます。 | +| **Needs your attention**(対応が必要) | 保留中の通知と承認、それぞれが届いてからの経過時間 | +| **Recently Accessed**(最近のアクセス) | 最近開いたオブジェクトとレコード。昨日の作業に戻る最速の方法です | +| **アクティビティフィード** | ワークスペース全体の最近のアクティビティ | + +> **ヒント:** **Needs your attention** を朝の受信トレイとして使いましょう。どのアプリを開く前でも、あなたを待っている承認が表示されます。 + +## アプリを切り替える [#switching-between-apps] + +左上の**パンくずリスト**は、常に現在地を示します:*アプリ → オブジェクト → レコード*。最初の部分は**アプリスイッチャー**です。クリックするとアプリの一覧がドロップダウンで表示され、ホームに戻らずにアプリ間を移動できます。 + +どのインストールにも、チーム向けに構築された業務アプリと並んで、次のものがあります。 + +| アプリ | 誰が見られるか | 用途 | +|---|---|---| +| **Account** | 全員 | 個人用のスペース:プロフィール、通知の受信トレイ、承認、セキュリティ | +| **Setup** | 管理者のみ | システム管理 | + +## アプリのナビゲーションサイドバー [#the-app-navigation-sidebar] + +アプリ内では、左のサイドバーがそのアプリのメニューです。フラットな項目と**折りたたみ可能なグループ**(たとえば *Workspace* や *Analytics*)が含まれます。各項目は次の 3 つのいずれかを開きます。 + +| 項目の種類 | クリックで開くもの | +|---|---| +| オブジェクト(例:*Tasks*) | そのリストビュー。[ビュー](/docs/use/views)を参照してください | +| ページ | アプリ向けに構築されたカスタムページ | +| ダッシュボード | グラフと KPI。[ダッシュボード](/docs/use/dashboards)を参照してください | + +アクティブな項目の横には**ピンアイコン**が表示され、よく使う場所をピン留めできます。 + +## グローバル検索 [#global-search] + +`⌘K`(Mac)または `Ctrl-K`(Windows/Linux)を押すか、トップバーの **Search…**(検索)をクリックします。**すべてのオブジェクトにわたるレコード**を検索するダイアログが開きます。タスク名、プロジェクト、顧客の数文字を入力すれば、すぐにそこへ移動できます。 + +## トップバー [#the-top-bar] + +トップバーはすべての画面に表示されます。 + +| コントロール | 機能 | +|---|---| +| **Search…** | グローバル検索を開く(`⌘K` / `Ctrl-K`) | +| **ベル** | 通知。赤いバッジが未読数を示します。受信トレイ全体は **Account → Inbox → Notifications** にあります。 | +| **?** | ヘルプ | +| **アバター** | アカウントメニュー(下記) | + +**アバターメニュー**には名前とメールアドレスが表示され、次の項目があります。 + +| 項目 | 機能 | +|---|---| +| **Profile**(プロフィール) | Account アプリでプロフィールを開く | +| **Preferences → Theme**(設定 → テーマ) | ライトとダークを切り替える | +| **Preferences → Language**(設定 → 言語) | インターフェースの言語を変更する | +| **Log out**(ログアウト) | セッションを終了する | + +> **ヒント:** テーマと言語は**アバターメニュー → Preferences** にあり、プロフィールページにはありません。最初に探しがちな場所です。 + +## 次に進む [#where-to-go-next] + +| やりたいこと | 読むもの | +|---|---| +| レコードを開く、作成する、編集する | [レコードを操作する](/docs/use/records) | +| グリッド、カンバン、カレンダーなどを切り替える | [ビューを使う](/docs/use/views) | +| グラフと KPI を読む | [ダッシュボード](/docs/use/dashboards) | +| 申請を承認または却下する | [承認](/docs/use/approvals) | +| 通知を管理する | [通知](/docs/use/notifications) | +| 名前、写真、パスワードを更新する | [プロフィールとセキュリティ](/docs/use/profile) | +| アプリを構築またはカスタマイズする | [構築](/docs/build) | +| システムを管理する | [設定](/docs/configure) | diff --git a/content/docs/use/notifications.ja.mdx b/content/docs/use/notifications.ja.mdx new file mode 100644 index 00000000..ef803520 --- /dev/null +++ b/content/docs/use/notifications.ja.mdx @@ -0,0 +1,68 @@ +--- +title: 通知 +seoTitle: "通知の受信トレイ:承認、ダイジェスト、ミュート" +description: 承認、ダイジェスト、更新など、自分に回されたすべてを、受信トレイに張り付くことなく受け取ります。 +editions: [community, cloud, enterprise] +translation: + source_sha: c72a596060c2df31c53b93bddabfdc1a02e1594e20f9a31483a08d200e07673a + guide_rev: 1 + mode: auto +--- + +ObjectOS は、何かがあなたを必要とするときに知らせます。承認があなたの手元に届いたとき、スケジュールされたダイジェストがプロジェクトを要約したとき、フォローしているレコードが変わったときです。それらのメッセージは、ひと目で確認できるものから完全な履歴まで、3 つの場所に表示されます。 + +| サーフェス | 場所 | 最適な用途 | +|---|---|---| +| **ベル** | すべての画面のトップバー | 「何か新しいものは?」の素早い確認。赤いバッジが未読数を示します | +| **Needs your attention** | ホームページ | 朝の仕分け。最近の項目を、待っている時間とともに | +| **完全な受信トレイ** | **Account → Inbox → Notifications** | すべてを読み、検索し、処理する | + +## ベルとホームページ [#the-bell-and-the-home-page] + +**ベルアイコン**は、ObjectOS のどこにいてもトップバーにあります。その上の赤いバッジは未読の通知を数えています。クリックすると、今の画面を離れずに新着を確認できます。 + +ホームページには **Needs your attention** の一覧が加わります。保留中の通知と承認が、それぞれの経過時間とともに表示されるので、どれが最新でどれが最も長く待っているかがひと目でわかります。 + +> **ヒント:** 一日をホームページから始めましょう。作業に取りかかる前の仕分けは、たいてい **Needs your attention** と **Recently Accessed** で十分です。 + +## 完全な受信トレイ [#the-full-inbox] + +全体像を見るには、**Account → Inbox → Notifications** を開きます。アプリスイッチャーの **Account** アプリを開き、サイドバーの **Inbox** を選びます。デフォルトのビューは、自分の通知をリストとして表示します。 + +| 列 | わかること | +|---|---| +| **Title** | 何が起こったかを 1 行で | +| **Topic** | どのような種類のメッセージか。たとえばプロジェクトのダイジェストか承認か | +| **Severity** | どれだけ緊急か(Info 以上) | +| **Created At** | いつ届いたか | + +これは通常のリストビューなので、リストについて知っていることはすべて当てはまります。フィルター、ソート、その中での検索ができます。 + +## 通知の発生元 [#where-notifications-come-from] + +これらを設定する必要はありません。アプリ内の何かが、あなたが知るべきだと判断したので通知が届きます。 + +| 発生元 | 例 | +|---|---| +| **自動化** | 毎朝、未完了のプロジェクトタスクを要約するスケジュールされたダイジェスト | +| **承認** | 判断すべきリクエストがあなたに割り当てられた、または自分の申請が承認または却下された | +| **購読** | フォローしているオブジェクトやレコードが変わった | + +## うるさいオブジェクトをミュートする [#muting-a-noisy-object] + +あるオブジェクトが有益な情報よりも雑音を多く生むなら、ミュートしましょう。そのオブジェクトのリストを開き、リストヘッダーの**ベルオフのトグル**をクリックします。そのオブジェクトの通知は届かなくなります。もう一度クリックするとミュートが解除されます。ミュートはオブジェクトごとで、影響するのは*あなた*だけです。チームメイトは引き続き通知を受け取ります。 + +> **ヒント:** 無視するのではなくミュートしましょう。ミュートしたオブジェクトは完全に使えるままで、それについて呼び出されなくなるだけです。そして未読バッジが再び意味を持つようになります。 + +## デスクトップ通知 [#desktop-notifications] + +ObjectOS のデスクトップアプリを使っている場合、アプリ内の通知をオペレーティングシステムのネイティブ通知に反映できます。他のデスクトップアプリと同じバナーと通知センターのエントリです。ObjectOS のウィンドウがバックグラウンドにあっても新しい項目が表示され、クリックするとすぐにそこへ移動します。 + +## 次に進む [#where-to-go-next] + +| 次にすること | 読むもの | +|---|---| +| 通知が指す承認に対応する | [承認](/docs/use/approvals) | +| 受信トレイを他のリストと同じようにフィルターしソートする | [ビュー](/docs/use/views) | +| 個人設定を調整する | [プロフィールと設定](/docs/use/profile) | +| 日常的なユーザーとして ObjectOS を見て回る | [ObjectOS を使う](/docs/use) | diff --git a/content/docs/use/profile.ja.mdx b/content/docs/use/profile.ja.mdx new file mode 100644 index 00000000..c8a66a72 --- /dev/null +++ b/content/docs/use/profile.ja.mdx @@ -0,0 +1,78 @@ +--- +title: プロフィールと設定 +description: ObjectOS を自分のものにします。名前とアバター、テーマと言語、そしてサインイン中のすべてのセッションの管理です。 +editions: [community, cloud, enterprise] +translation: + source_sha: d7531547c07b1a8ee043f6832b30aa22e33e8da23e59bb4d2b3ebea8bc9693f3 + guide_rev: 1 + mode: auto +--- + +個人的なものはすべて 2 つの場所にあります。右上の**アバターメニュー**(すべての画面で使える手早い設定)と、**Account** アプリ(プロフィール、セキュリティ、受信トレイ)です。ここでの変更が他の人に影響することはありません。これらはあなたのアカウントの、あなたの設定です。 + +| やりたいこと | 移動先 | +|---|---| +| 名前やアバターを変更する | **Account → Profile** | +| パスワードを変更する | **Account → Profile** → Change Password | +| ライト/ダークモードや言語を切り替える | アバターメニュー → Preferences | +| 他のセッションを確認する、またはサインアウトさせる | **Account → Security** → Active Sessions | +| どのサインインプロバイダーが連携されているか確認する | **Account → Security** → Linked Accounts | +| サインアウトする | アバターメニュー → **Log out** | + +## プロフィール [#your-profile] + +**Account → Profile** を開きます。アプリスイッチャーの **Account** アプリからでも、アバターメニューの **Profile** からでも開けます。 + +| 設定 | 変更できるか | +|---|---| +| **アバター** | はい。**Upload** をクリックして画像を選びます。チームメイトは、割り当て、アクティビティフィード、承認など、あなたが表示されるあらゆる場所でそれを目にします。 | +| **名前** | はい。Personal Information カードで編集します。 | +| **メールアドレス** | いいえ。メールアドレスはアカウントを識別するもので、ここでは変更できません。 | +| **ロール** | 読み取り専用。管理者があなたに割り当てたものを表示します。 | + +**Save Changes** をクリックして編集を適用します。 + +Personal Information の下には **Change Password** カードがあります。現在のパスワードと新しいパスワードを入力すれば完了です。他のデバイスは、セッションが終わるまでサインインしたままです。すべて一掃したい場合は、**Active Sessions** からそれらをサインアウトさせてください。 + +## 設定:テーマと言語 [#preferences-theme-and-language] + +右上のアバターをクリックします。名前とメールアドレスと並んで、Preferences があります。 + +| 設定 | 選択肢 | +|---|---| +| **Theme** | ライトまたはダーク。UI 全体がすぐに切り替わります。 | +| **Language** | UI の表示言語を選びます。 | + +どちらもその場で反映されます。保存ボタンも再読み込みもありません。 + +## セキュリティ [#security] + +**Account → Security** を開くと、ときどき確認する価値のある 2 つのものがあります。 + +| セクション | 用途 | +|---|---| +| **Linked Accounts** | アカウントに連携されたソーシャルまたは会社のサインインプロバイダー(SSO)。何が連携されているかをひと目で確認できます。 | +| **Active Sessions** | 現在あなたとしてサインインしているすべてのデバイスとブラウザ。一覧を確認し、見覚えのないセッションやもう使っていないセッションを**サインアウト**させます。 | + +> **ヒント:** 共有のマシンや借りたマシンでサインインしたまま、ログアウトし忘れましたか?そのマシンは必要ありません。どのデバイスからでも **Active Sessions** でそのセッションを失効させられます。 + +## Account アプリのその他の項目 [#also-in-the-account-app] + +| セクション | 用途 | +|---|---| +| **Inbox → My Organizations** | アカウントが所属する組織。 | +| **Inbox → Notifications / Approvals** | 個人の受信トレイ。[通知](/docs/use/notifications)と[承認](/docs/use/approvals)で説明しています。 | +| **Developer → API Keys, OAuth Applications** | 外部ツールを ObjectOS に接続するための認証情報。**ほとんどのユーザーはこのセクションを必要としません**。必要かどうかわからないなら、必要ありません。 | + +## ログアウト [#logging-out] + +アバターメニューを開き、**Log out** をクリックします。これで使っているデバイスのセッションが終了します。他のデバイスは、**Active Sessions** からサインアウトさせるまで、それぞれのセッションを保持します。 + +## 次に進む [#where-to-go-next] + +| 次にすること | 読むもの | +|---|---| +| 受信トレイを管理下に置く | [通知](/docs/use/notifications) | +| 自分に回されたリクエストに対応する | [承認](/docs/use/approvals) | +| データを使って生産的に作業する | [レコード](/docs/use/records) | +| 日常的なユーザーとして ObjectOS を見て回る | [ObjectOS を使う](/docs/use) | diff --git a/content/docs/use/records.ja.mdx b/content/docs/use/records.ja.mdx new file mode 100644 index 00000000..84c6df65 --- /dev/null +++ b/content/docs/use/records.ja.mdx @@ -0,0 +1,85 @@ +--- +title: レコードを操作する +description: 日々の作業の裏にあるレコード(タスク、プロジェクト、顧客、何でも)を開き、読み、作成し、編集します。 +editions: [community, cloud, enterprise] +translation: + source_sha: 18dd6c9ea408b3c20cbbdf3ace75fbfc3830ea0ae649a3066878122a22934d97 + guide_rev: 1 + mode: auto +--- + +ObjectOS のすべては**レコード**です。タスク、プロジェクト、顧客、請求書。このページでは、レコードを開き、そのページを読み、新しいレコードを作成し、多数のレコードを一度に編集する方法を説明します。 + +## レコードを開く [#opening-a-record] + +どのリストでも、**レコードのタイトルはリンク**です。クリックするとレコードページが開きます。戻るには、レコード上部の戻るリンク(たとえば **< All Tasks**)か、左上のパンくずリストを使います。 + +## レコードページの構成 [#anatomy-of-a-record-page] + +レコードページには、上から順に次のものが表示されます。 + +| 要素 | 内容 | +|---|---| +| **戻るリンク** | 元のリストに戻ります(例:**< All Tasks**) | +| **状態パイプラインバー** | ワークフローを持つオブジェクトのみ。レコードがプロセスのどこにあるかを示す山形のステージ | +| **ハイライト帯** | レコードの主要フィールドの概要(例:Project、Assignee、Priority、Due Date、Progress) | +| **アクションボタン** | このレコードに定義されたアクション。たとえば **Log Time** | +| **フィールドセクション** | 残りのフィールドをカードにまとめたもの(例:Overview、Schedule、Details) | +| **Show N empty fields** | セクションの末尾にあるトグル。ページを短く保つため、空のフィールドはデフォルトで折りたたまれています | + +### 状態パイプラインを読む [#reading-the-state-pipeline] + +オブジェクトにワークフローがある場合、上部のバーはすべてのステージを山形で表示します。完了したステージにはチェックマークが付き(✓ Backlog → ✓ To Do → ✓ In Progress)、**現在のステージは強調表示**されます。ひと目でレコードの進み具合がわかります。 + +> **ヒント:** レコードの項目が少なく見える場合は、**Show N empty fields** をクリックしてください。フィールドは存在していて、まだ値がないだけです。 + +## レコードを作成する [#creating-a-record] + +任意のリストの上部にある **+ New** をクリックします。モーダルが開きます(たとえば *"Create Task — Add a new Task to your database"*)。フォームの中には次のものがあります。 + +| 表示されるもの | 動作 | +|---|---| +| `*` の付いたフィールド | 必須です。入力しないと保存できません | +| **Select…** ピッカー | 別のレコード(Project、User)を指す参照フィールド。入力して検索するか、テーブル閲覧ボタンで完全な一覧から選びます。 | +| ドロップダウン | Status や Priority のような選択フィールド。値をひとつ選びます | +| 日付ピッカー | 日付フィールドをクリックし、カレンダーから日を選びます | + +**Create** で完了するか、**Cancel** で破棄します。 + +## レコードをインラインで編集する [#editing-records-inline] + +変更するたびにレコードを開く必要はありません。グリッドビューで、ツールバーの **Edit inline** トグルをオンにすると、セルが編集可能になり、ステータスの修正、タスクの再割り当て、日付の更新をリスト上でスプレッドシートのように直接行えます。 + +## 行ごとのアクションメニュー [#the-per-row-actions-menu] + +グリッドのすべての行の末尾には `⋮` **Actions** メニューがあります。そのレコード 1 件に使えるアクションがまとまっており、先にレコードを開く代わりの手早い方法です。 + +## 多数のレコードを一度に選択する [#selecting-many-records-at-once] + +グリッドの各行は**チェックボックス**で始まります。 + +1. 対象の行にチェックを入れます(ページ全体ならヘッダーのチェックボックス)。 +2. オブジェクトの**一括アクション**(リスト上部の **Reassign…** のようなボタン)が、選択したものすべてに適用されるようになります。 + +> **ヒント:** 一括アクションはオブジェクトごとに異なります。リストヘッダーの **+ New** と **Import** の隣にあるボタンです。 + +## リストヘッダーの全体 [#the-list-header-in-full] + +すべてのリストの上には次のものがあります。 + +| ボタン | 機能 | +|---|---| +| **+ New** | レコードを作成する(上で説明したモーダル) | +| **Import** | 1 件ずつ入力する代わりに、ファイルからレコードを取り込む | +| カスタムの一括アクション | **Reassign…** のようなオブジェクト固有の操作を、選択したものに適用する | +| ベルオフのトグル | このオブジェクトの通知をミュートする。[通知](/docs/use/notifications)を参照してください | + +## 次に進む [#where-to-go-next] + +| やりたいこと | 読むもの | +|---|---| +| リスト自体の見た目を変える:フィルター、グループ化、カンバンへの切り替え | [ビューを使う](/docs/use/views) | +| 行ではなく合計と傾向を見る | [ダッシュボード](/docs/use/dashboards) | +| 自分を待っているレコードを承認する | [承認](/docs/use/approvals) | +| どの更新を受け取るかを制御する | [通知](/docs/use/notifications) | +| ツアーに戻る | [ObjectOS を使う](/docs/use) | diff --git a/content/docs/use/search.ja.mdx b/content/docs/use/search.ja.mdx new file mode 100644 index 00000000..e1a7596d --- /dev/null +++ b/content/docs/use/search.ja.mdx @@ -0,0 +1,102 @@ +--- +title: 検索 +seoTitle: "検索とコマンドパレット:あらゆるレコードを見つける" +description: レコードの一部を入力するだけで見つけられます。⌘K パレット、リストの検索ボックス、参照ピッカーから。そしてアプリ内のどこへでも移動できます。 +editions: [community, cloud, enterprise] +translation: + source_sha: 78801613cbafc2e8b4473a6057341caba6dbfad1ba128189df982d69710f578b + guide_rev: 1 + mode: auto +--- + +{/* + Platform checklist items this page documents (objectstack + docs/qa/platform-checklist/areas/search.json): search.cross-field-object-search, + search.field-scoped-narrowing, search.rls-both-personas, + search.pinyin-flag-both-sides, search.freshness-and-empty, + search.console-global-search, search.command-palette-navigation, + search.global-search-skips-unreadable. +*/} + +名前、メールアドレス、ステータス、都市名の一部を入力すると、ObjectOS はそれを含むレコードを見つけます。同じ検索が 3 つの場所で動作します。 + +| 場所 | 検索対象 | +|---|---| +| **グローバル検索**:`⌘K`(Mac) / `Ctrl-K`(Windows/Linux)、またはトップバーの **Search…** | 読み取れるすべてのオブジェクトにわたるレコードと、アプリ自体のメニュー | +| **リスト検索**:ビューのツールバーの虫眼鏡 | 今いるリストのレコード | +| **参照ピッカー**:別のレコードを指すフィールドの **Select…** ボックス | 選択できるレコード | + +3 つとも入力した語をサーバーに送り、同じ答えを受け取ります。そのため、ひとつの場所で見つかるレコードは他の場所でも見つかります。 + +## 一致の仕組み [#how-matching-works] + +| 入力 | 見つかるもの | +|---|---| +| 単語の一部:`retail` | 検索対象のフィールドのどこかに `retail` を含むすべてのレコード:*Acme Retail*、または業種が *Retail* の取引先 | +| 大文字小文字は問わない:`Retail`、`RETAIL` | 同じレコード。検索は大文字と小文字を区別しません | +| 複数の語:`retail northwind` | **すべての**語が同じレコードのいずれかのフィールドに一致するレコード | +| 選択肢のラベル:`In Progress` | ステータス(または他の選択フィールド)がその選択肢を持つレコード。保存されている値が異なっていても見つかります | + +どのフィールドを検索するかは、アプリを構築した人がオブジェクトごとに設定します。選んでいない場合、検索はレコードの名前またはタイトル、テキストフィールド(メール、電話、URL、長いテキストを含む)、選択フィールドを対象にします。数値、日付、はい/いいえのフィールド、ファイル、他のレコードへのリンクは対象外です。 + +> **ヒント:** 語を増やすと結果が絞り込まれます。`acme invoice` は Acme の請求書を見つけます。Acme のすべてのレコードとすべての請求書ではありません。 + +## 見つかるのは読み取れるものだけ [#you-only-find-what-you-can-read] + +検索は、リストを開くときと同じく、あなたの権限で実行されます。 + +- 読み取れないレコードは決して返されず、合計件数にも数えられません。 +- アクセス権のないオブジェクトは静かにスキップされます。検索は読み取れるオブジェクトから引き続き答えます。 +- あなたから隠されているフィールドは検索されないため、ヒットからその中身が明らかになることはありません。 + +2 人が同じ語を入力しても、異なる結果になることがあります。それは正しい動作です。それぞれが見ることを許された一致を見ています。 + +## 常に最新 [#always-up-to-date] + +待つべきインデックスはありません。作成したばかりのレコードはすぐに見つかります。名前を変更すると、新しい名前が一致し、古い名前は一致しなくなります。削除したレコードは結果から即座に消えます。 + +何も一致しない場合は **No results found** と表示されます。エラーではありません。語を減らすか、短くしてみてください。 + +## 中国語の名前とピンイン [#chinese-names-and-pinyin] + +アプリが中国語をサポートしている場合、検索はピンインで入力された中国語の名前にも一致します。完全な綴り(`zhangwei`)または頭文字(`zw`)で **张伟** が見つかり、漢字も入力したとおりに一致し続けます。ピンインはレコードの名前フィールドに適用されます。 + +ピンイン検索は、アプリの言語に中国語が含まれている場合に有効になります。管理者は `OS_SEARCH_PINYIN_ENABLED` の設定で、デプロイ全体に対してオンまたはオフにできます。 + +## コマンドパレット [#the-command-palette] + +`⌘K` / `Ctrl-K` を押す(または **Search…** をクリックする)とパレットが開きます。検索ボックスとナビゲーターを兼ねています。 + +| 表示されるもの | 機能 | +|---|---| +| **Recently viewed** | ボックスが空の間に表示されます。最近開いたレコードに 1 クリックで移動できます | +| オブジェクト名の下にグループ化されたレコード | **2 文字以上**入力すると、*Accounts* や *Contacts* などの見出しの下に一致が表示されます。**Enter** で強調表示されたレコードを開きます | +| **Objects**、**Dashboards**、**Pages**、**Reports** | 名前が一致する現在のアプリのメニュー項目。メニューグループ内の項目も含みます | +| **Switch App** | 他のアプリ。現在のアプリには **Current** の印が付きます | +| **Preferences** | **Light Theme**、**Dark Theme**、**System Theme**。即座に適用されます | +| **Open Full Search Page** | 検索ページ(下記)を開きます | + +パレットが提示するのは、いずれにせよたどり着けるものだけです。サイドバーで隠されているメニュー項目はここでも隠され、非アクティブなアプリは **Switch App** に表示されません。 + +> **ヒント:** キーボードから手を離さずに:`⌘K`、入力、矢印キー、**Enter**。もう一度 `⌘K` を押すとパレットが閉じます。 + +## 検索ページ [#the-search-page] + +**Open Full Search Page** は、同じ検索の全画面表示に移動します。一致するアプリのオブジェクト、ダッシュボード、ページ、レポートと、オブジェクトごとにグループ化されたレコードの一致が表示されます。ボックスに入力して絞り込みます。 + +## リスト内を検索する [#searching-inside-a-list] + +ビューのツールバーの虫眼鏡は、今いるリストを絞り込みます。ビューのフィルター、グループ化、ソートと組み合わせて動作します。[ビューを使う](/docs/use/views)を参照してください。*どの*リストかわかっているときに使い、わからないときは `⌘K` を使います。 + +## アプリビルダー向け [#for-app-builders] + +オブジェクトの `searchableFields` は、検索がスキャンするフィールドを設定します。省略すると、上で説明した名前、テキスト、選択フィールドになります。ビューや API 呼び出しはスキャンをそれらのうちの少ないフィールドに絞り込めますが、広げることは決してできません。セット外のフィールドを要求すると、無視されるのではなく拒否されます。計算される `formula` フィールドは保存されないため検索できません。値を保存されるテキストフィールドにコピーして、そちらを検索してください。 + +## 次に進む [#where-to-go-next] + +| やりたいこと | 読むもの | +|---|---| +| リストをフィルター、グループ化、ソートする | [ビューを使う](/docs/use/views) | +| 見つけたレコードを開いて編集する | [レコードを操作する](/docs/use/records) | +| 誰がどのレコードを見られるかを理解する | [権限](/docs/configure/permissions) | +| ツアーに戻る | [ObjectOS を使う](/docs/use) | diff --git a/content/docs/use/views.ja.mdx b/content/docs/use/views.ja.mdx new file mode 100644 index 00000000..30d2ca3a --- /dev/null +++ b/content/docs/use/views.ja.mdx @@ -0,0 +1,90 @@ +--- +title: ビューを使う +seoTitle: "ビューを使う:グリッド、ボード、カレンダー、タイムライン" +description: 同じレコードをグリッド、ボード、カレンダー、タイムラインとして表示し、自分の方法でフィルター、グループ化、ソートします。 +editions: [community, cloud, enterprise] +translation: + source_sha: ed314bea3937ef6fc74e2f3459c058566342cca16afa870864575be9d8550c86 + guide_rev: 1 + mode: auto +--- + +**ビュー**は、オブジェクトのレコードを見るための保存された方法です。同じタスクを、スプレッドシートのようなグリッド、カンバンボード、カレンダーとして表示できます。自由に切り替えられ、データは何も変わりません。 + +## ビューのタブ [#view-tabs] + +すべてのリストの上部には、オブジェクトのビューがタブとして表示されます。たとえば次のとおりです。 + +**All Tasks ★ | In Progress | Urgent | Done | … 9 more | +** + +| タブの要素 | 意味 | +|---|---| +| **★** | デフォルトのビュー。最初に開くものです | +| 名前付きのタブ(*In Progress*、*Urgent*) | チーム向けに設定された、あらかじめフィルターされたビュー。タブの小さな漏斗のバッジは、組み込みのフィルターがあることを示します。 | +| **… 9 more** | ビューが収まりきらないときのオーバーフローメニュー | +| **+** | 自分の**個人ビュー**を作成します。自分のフィルター、自分の列を、自分専用に保存します | + +> **ヒント:** 毎朝同じフィルターを適用し直す代わりに、**+** を一度クリックして個人ビューとして保存しましょう。 + +## ビューの種類を切り替える [#switching-view-types] + +右側のツールバーには、現在のビューの種類(例:**Grid**)が表示されます。クリックすると、同じレコードを見る 6 つの方法を切り替えられます。 + +| 種類 | 最適な用途 | +|---|---| +| **Grid** | 日常的な表。多数のレコードの一覧、ソート、編集 | +| **Kanban** | 進行中の作業。ボード上でステージを移動するカード | +| **Gallery** | 画像が重要なレコード。製品、デザイン、人 | +| **Calendar** | 日付を持つもの。締め切り、イベント、予約を月または週ごとに | +| **Timeline** | 時間に沿ったレコードの時系列フィード | +| **Gantt** | プロジェクト計画。開始、終了、期間を示すバー | + +## ビューのツールバー [#the-view-toolbar] + +ビューの種類の切り替えの隣には、次のものがあります。 + +| コントロール | 機能 | +|---|---| +| **Edit inline** | グリッドでスプレッドシートのようなセル編集を切り替えます。[レコードを操作する](/docs/use/records)を参照してください | +| **Filter** | 選んだ条件に一致するレコードだけを表示します | +| **Group** | 行を見出しの下にまとめます(例:Project や Assignee ごと) | +| **Sort** | レコードを並べ替えます。バッジは有効なソートルールの数を示します | +| 列の設定 | どの列をどの順序で表示するかを選びます | +| 虫眼鏡 | **ビュー内検索**。入力して現在のリストを絞り込みます | + +> **ヒント:** ビュー内検索は今いるリストを絞り込みます。`⌘K` / `Ctrl-K` はすべてのオブジェクトにわたってレコードを検索します。*どの*リストかわかっているときは虫眼鏡を、わからないときは `⌘K` を使いましょう。 + +## グリッドを読む [#reading-a-grid] + +グリッドのセルには追加の情報が詰め込まれています。 + +| 表示されるもの | 意味 | +|---|---| +| 行のチェックボックスと `#` 番号 | 一括アクションのために行を選択し、行数をひと目で数えます | +| 色付きのピル | Status や Priority のような選択フィールドの値。色は値に従います | +| 赤い日付("Overdue 6d") | 日付が過ぎています。その日数だけ期限切れです | +| 進捗バー | 完了率のフィールドをバーで表示したもの | +| 行末の `⋮` | 行ごとのアクションメニュー | +| フッターの件数("10 records") | 現在のビューに含まれるレコードの数 | + +## カンバンボード [#kanban-boards] + +**Kanban** に切り替えると、レコードは**列の中のカード**になります。列はオブジェクトの選択フィールドのひとつから作られます。タスクの場合は通常 Status なので、*Backlog*、*To Do*、*In Progress*、*In Review* などになります。 + +| ボードの要素 | 表示内容 | +|---|---| +| 列ヘッダー | ステージ名と、その列のカードの**件数** | +| カード | レコードのタイトルと主要な詳細。担当者、優先度のピル | +| ツールバー | グリッドと同じ **Filter / Group / Sort** のコントロール | + +カンバンは「各ステージにどれだけの作業があるか」にひと目で答えます。列の件数がボトルネックの検出器です。 + +## 次に進む [#where-to-go-next] + +| やりたいこと | 読むもの | +|---|---| +| ビューの裏にあるレコードを開いて編集する | [レコードを操作する](/docs/use/records) | +| 行ではなく集計された数値を見る | [ダッシュボード](/docs/use/dashboards) | +| 承認リクエストに対応する | [承認](/docs/use/approvals) | +| ベルが知らせる内容を調整する | [通知](/docs/use/notifications) | +| ツアーに戻る | [ObjectOS を使う](/docs/use) | diff --git a/content/docs/why.ja.mdx b/content/docs/why.ja.mdx new file mode 100644 index 00000000..ababb27b --- /dev/null +++ b/content/docs/why.ja.mdx @@ -0,0 +1,124 @@ +--- +title: なぜ ObjectOS なのか +seoTitle: "なぜ ObjectOS なのか:使うべきときと、使うべきでないとき" +description: 率直な説明。使うべきとき、使うべきでないとき、そして何が違うのか。 +editions: [community, cloud, enterprise] +translation: + source_sha: 5f5a99346059a4eebaab08952d7d63a45e80b1fef6b6aaf390b849f7de2d5808 + guide_rev: 1 + mode: auto +--- + +このページは、ObjectOS があなたに合っているかを判断するために、他のすべてのドキュメントの行間を読まなくても済むように存在します。 + +## 賭けの形 [#the-shape-of-the-bet] + +**オントロジーこそがソフトウェアです。** 実行可能なひとつのビジネスオントロジー。AI が書き、ランタイムが実行し、エージェントが操作し、あなたが所有します。4 つの約束です。そして ObjectOS は、何もインストールせずにブラウザ上のホスト型で(**ObjectOS Cloud**)、あなた自身のインフラストラクチャ上でセルフマネージドで(**ObjectOS Enterprise**)、あるいは自分のマシン上で無料で(**ObjectOS Community**、オープンソースのランタイム)、その約束を守ります。 + +- **実行可能。** オブジェクト、フィールド、リレーション、アクション、権限、フロー、エージェント定義は、ランタイムが実行する、型付きでバージョン管理されたひとつの定義です。REST エンドポイント、生成された画面、RBAC、監査ログ、MCP ツールはすべてそこから導出されます。コード生成のステップも、「説明した」と「稼働した」の間のデプロイパイプラインもありません。 +- **AI が書ける。** その定義をファイルごとに手で書く必要はありません。ビジネスに必要なものを組み込みの [AI Builder](/docs/build/ai-builder) に説明してください。AI Builder は少数の監査済みツールを呼び出し、すべての変更は人の承認を待つキューに入り、結果は実際に読める小さな差分になります。 +- **エージェントが操作できる。** すべてのオブジェクトと公開されたすべてのアクションは MCP ツールを兼ねるため、エージェントは人と同じ権限、行レベルセキュリティ、監査のもとで稼働中のアプリを操作します。生の SQL も、スクレイピングした UI も使いません。 +- **あなたが所有する。** オントロジーは通常の [ObjectStack](https://github.com/objectstack-ai/objectstack) メタデータで、Apache-2.0 です。どちらのエディションでもエクスポートして、そのオープンソースのランタイムで実行できます。ObjectOS Enterprise では、ランタイムとデータベースもあなたのインフラストラクチャ上に置かれます。 + +説明は以上です。このページの残りは、それが誰に合い、誰に合わないかです。 + +## 次に当てはまるなら ObjectOS を使ってください [#use-objectos-if-you-] + +- 社内ツール、管理パネル、バックオフィスアプリが必要で、**かつ** +- それを使う人(またはその人の代わりに動く AI エージェント)が、チケットを起票せずにそれを*拡張*できるようにしたく、**かつ** +- ホスト型で運用してほしい(ObjectOS Cloud)、あるいは自社の境界内で実行しなければならない(ObjectOS Enterprise)、**かつ** +- 認証 + RBAC + 監査 + ファイルアップロード + ジョブ + Webhook を 10 回目に作り直したくない。 + +適しているよくあるシナリオ: + +| シナリオ | うまくいく理由 | +|---|---| +| セキュリティレビューでデータ主権が問題になったため、Retool / Appsmith アプリを置き換える | ObjectOS Enterprise はあなたの VPC 内で動作し、データは外に出ません | +| 規制業種向けにコンプライアンス / リスク / ベンダー管理ツールを構築する | 監査ログ、RBAC、フィールドセキュリティ、行レベルの分離が正式な機能です。そして AI による変更はそれ自体が監査エントリになります | +| SaaS 製品の社内管理画面を立ち上げる | ひとつの Node プロセスで、既存のサービスの隣に収まります。あるいは何も実行しなくてよい Cloud テナントです | +| エンタープライズ顧客向けのエアギャップまたはオンプレミスのデプロイ | ObjectOS Enterprise の正式なターゲットです。エアギャップライセンスはオフラインで検証され、AI サービスはローカルモデルを指せます | +| マルチテナントの社内ポータル(複数の組織、ひとつのデプロイ) | 隔離型のテナンシー体制が、ランタイム内で強制される組織ごとの分離の壁を立てます。これは Enterprise の機能です。[ライセンスと料金](/docs/editions/license)を参照してください。分離されるのは組織のデータとメンバーシップであり、スキーマではありません。組織はひとつのデータベースとひとつのメタデータセットを共有します | +| ユーザーが変更を説明するだけで、安全にアプリを拡張できるようにしたい | AI Builder + HITL 承認キュー + 監査ログこそが要点です | +| 基幹 ERP の周りにあるロングテールの部門アプリ:ベンダーのオンボーディング、点検、社内申請 | それぞれが、メールで回されるスプレッドシートではなく、頻繁に変わる小さな型付きの定義になります | +| 初日からエージェントが操作するバックオフィス | すべてのデプロイはデフォルトで有効な MCP サーバーです。エージェントは人と同じ権限と監査を通じて動作します | + +## 次に当てはまるなら ObjectOS を使わないでください [#dont-use-objectos-if-you-] + +- 大企業の基幹 ERP や財務システムを置き換える必要がある → 総勘定元帳、連結、税務、給与には規制が伴い、パッケージシステムがすでにそれを組み込んでいます。その中核の代わりではなく、周りに構築してください。 +- 価値の大部分が多数の既存システムの統合から生まれる → すでに運用しているデータベースへの接続は[デフォルトで読み取り専用で、まだ初期段階](/docs/build/extend-existing-systems)です。それらのシステムの上に載るレイヤーのほうが適しています。 +- データレイク上の分析が必要 → ダッシュボードはアプリ自身のオブジェクト上で動作します。レイクをクエリするようには作られていません。 +- 価格設定やスケジューリングのような、アルゴリズム中心の領域で作業している → オントロジーは何ができるかを宣言するものであり、最適化エンジンではありません。アルゴリズムを[アクション](/docs/build/interface/actions)としてラップすれば、権限、監査、エージェントアクセスが他のものと同様に適用されます。 +- それ自体が認証を受けたプラットフォーム上で実行しなければならない → ObjectOS はそうではありません。認証は稼働中のデプロイに対して行われ、あなたのデプロイの認証はあなたが担う作業です。[セキュリティとコンプライアンス](/docs/reference/security#compliance-frameworks)を参照してください。 +- 高トラフィックのコンシューマー製品を構築している → 従来の Web フレームワークを使ってください。より多くを制御できます。 +- エンドユーザー向けにピクセル単位で作り込んだ独自 UI が必要 → 生成される UI は管理/社内用途向けです。REST 経由で独自のフロントエンドと組み合わせてください。 +- エンジニア以外の人向けのドラッグ&ドロップのキャンバスが欲しい → Retool、Bubble、Airtable を使ってください。ObjectOS は UI をオントロジーから生成します。ビルダーは AI とそれが提案する差分であり、キャンバスではありません。 +- リアルタイムの共同編集(Figma のような)が必要 → それは realtime プラグインが解決するものではありません。 + +最初の 5 つの理由は、ObjectOS の土台であるオープンなランタイムについて、[ObjectStack を使うべきとき](https://objectstack.ai/docs/concepts/when-to-use)に説明されています。 + +## おそらく今使っているものとの比較 [#compared-to-what-youre-probably-using] + +### Retool / Appsmith / 社内開発との比較 [#vs-retool--appsmith--internal] + +| | Retool | ObjectOS | +|---|---|---| +| データの場所 | 彼らのクラウド(上位プランではセルフホストも可) | ObjectOS Cloud、または ObjectOS Enterprise ではあなたのネットワーク | +| UI ビルダー | ドラッグ&ドロップ、非常に洗練されている | メタデータ駆動で生成。カスタマイズ性は低い | +| 料金 | ユーザー単位で、増えると苦しい | AI シートのみ。閲覧者は無料。無料のセルフホストはオープンソースの ObjectStack ランタイム | +| ワークフロー / トリガー | 彼らのワークフローエンジン | 宣言的なフロー + プラグイン | +| バックエンドロジック | 彼らのクエリエディターに限られる | 完全な TypeScript、Node エコシステム全体 | +| 最適な用途 | 既存の API の上に作る手早いダッシュボード | 定義を自分で所有するアプリ | + +### Supabase / Firebase との比較 [#vs-supabase--firebase] + +| | Supabase | ObjectOS | +|---|---|---| +| セットアップ時間 | 約 30 秒 | 約 30 秒 | +| データベース | Postgres、彼らのもの(セルフホストも可) | ObjectOS Cloud ではマネージド。ObjectOS Enterprise では任意の Postgres / MySQL / SQLite / Turso / Mongo、**あなたのもの** | +| 認証 | 組み込み | 組み込み | +| 生成される API | PostgREST | ObjectQL が生成する REST | +| 管理 UI | Console(基本的) | Setup + Account | +| RBAC | Postgres RLS による行レベル | RBAC + 行レベル + フィールドレベル、宣言的 | +| 監査ログ | 自作 | 正式な機能 | +| ベンダーロックイン | 彼らの認証 + 彼らのストレージ + 彼らのリアルタイム | オントロジーはオープンソースの ObjectStack ランタイムへエクスポートでき、すべてのレイヤーがプラグイン | +| 最適な用途 | BaaS が欲しい新規アプリ | 定義を所有する必要があるアプリ。Enterprise ではランタイムも | + +### Salesforce / NetSuite / ServiceNow との比較 [#vs-salesforce--netsuite--servicenow] + +| | Salesforce | ObjectOS | +|---|---|---| +| データモデル | オブジェクト + フィールド + リレーションシップ、プラットフォーム内で設定 | オブジェクト、フィールド、リレーションをひとつのオープンなオントロジーに。エクスポート可能で、Apache-2.0 の ObjectStack ランタイムで実行可能 | +| 権限 | プロファイル + 権限セット + 共有ルール + FLS | 馴染みのある語彙(権限セット、ポジション、共有ルール、FLS)をオントロジーで宣言し、すべての REST 呼び出し、UI クリック、エージェントのツール呼び出しで強制 | +| ユーザーあたりのコスト | $150-300/ユーザー/月 | 閲覧者と AI を使わないユーザーは無料。支払うのは AI シートのみ | +| カスタマイズ | Apex + Lightning + フロー | TypeScript + フロー | +| 実行場所 | 彼らのクラウドのみ | ObjectOS Cloud、またはあなたのインフラストラクチャ(ObjectOS Enterprise) | +| 「自分たちのもの」になるまでの時間 | コンサルタント作業で数か月 | 午後ひとつ | +| 最適な用途 | エコシステムにお金を払う営業主導の企業 | 税金なしでモデルを手に入れたいチーム | + +### 自前で構築する場合(Next.js + Prisma + NextAuth)との比較 [#vs-rolling-your-own-nextjs--prisma--nextauth] + +| | 自作 | ObjectOS | +|---|---|---| +| 最初の REST エンドポイント | 数時間 | 60 秒 | +| 認証(メール + OAuth + OIDC + パスキー + 2FA) | 数週間 | 含まれる | +| すべてのオブジェクトの管理 UI | オブジェクトごとに構築 | 生成される | +| 監査ログ | 自作 | プラグイン、宣言的 | +| 権限付きの S3 へのファイルアップロード | 自作 | プラグイン | +| バックグラウンドジョブ + リトライ + デッドレター | 自作 | プラグイン | +| マルチテナンシー | 自作(そして 2 回は間違える) | 組み込み | +| 最適な用途 | 独自の UX を持つ公開アプリ | スピードが勝る社内ツール | + +## 率直なトレードオフ [#the-honest-tradeoffs] + +- **Retool より UI の自由度は低い。** UI はメタデータから生成されます。独自のフロントエンドと組み合わせることはできます(REST API は UI が使うものと同じです)が、手作りでピクセル単位まで作り込んだ UI が必要なら、自分で構築して ObjectOS をバックエンドとして使ってください。 +- **TypeScript ファースト。** エンジニア以外の人がリポジトリで直接オブジェクトを作成することはありません。Salesforce の管理者は UI ビルダーをクリックして進むことに慣れていますが、ここでは AI Builder の差分か `git` です。 +- **Salesforce より新しい。** Salesforce には 25 年分のエッジケースが文書化されています。私たちには数百です。プロトコルは安定しています。エコシステムは成長中です。 +- **オープンソースの基盤、3 つのエディション。** 土台となる ObjectStack スタックは Apache-2.0 です。商用製品で使い、組み込み、非公開で改変でき、コピーレフトの驚きはありません。**ObjectOS Community**、すなわちそこから構築されたオープンソースのランタイムと Console は、同じライセンスのもとで無料です。**ObjectOS Cloud** と **ObjectOS Enterprise** は商用エディションで、ホスティングと運用、AI Builder、SSO、サポートを追加します。 + +## 現実の確認 [#reality-check] + +最小の ObjectOS は ObjectOS Cloud 上の Free テナントです。最小のセルフマネージド構成は SQLite を使うシングルノードのコンテナです。現在本番で最大のものは、Postgres + S3 + Redis を使い、複数のリージョンにまたがって数万人の社内ユーザーにサービスを提供しています。そのすべてが同じソフトウェアを実行しています。 + +[ObjectOS Cloud](https://www.objectos.ai) で始めるか、ObjectOS Community で`npm create objectstack@latest my-app` を使ってプロジェクトをスキャフォールドし、5 分で判断してください。合わなければ、失ったのは 5 分です。 + +[**ObjectOS Community をローカルで実行する →**](/docs/start/community) From 408661e56dc5acb4811c7f496e85f0b3d76e1d4c Mon Sep 17 00:00:00 2001 From: Steedos Date: Thu, 29 Oct 2026 10:46:47 +0000 Subject: [PATCH 2/6] docs(i18n): translate build/ and build/data/ into ja (#388, PR 1 of 7) Phase 2 of the other-locale plan, first batch: ja for the build/ top level (index, agents, ai-builder, ai-skills, extend-existing-systems, marketplace, packages, reports, templates) and build/data/ (index, relationships, formulas, validation-rules). Same rules as #386: English heading ids, fences byte for byte, glossary ja column, UI labels in English, one line per paragraph, each page stamped. check-translations: ja 0 stale, 66 missing (was 79). Refs #388 Claude-Session: https://claude.ai/code/session_01QsR2jRsjbApbf83iyTDDXm Co-Authored-By: Claude --- content/docs/build/agents.ja.mdx | 162 +++++++++ content/docs/build/ai-builder.ja.mdx | 160 +++++++++ content/docs/build/ai-skills.ja.mdx | 91 +++++ content/docs/build/data/formulas.ja.mdx | 208 ++++++++++++ content/docs/build/data/index.ja.mdx | 310 ++++++++++++++++++ content/docs/build/data/relationships.ja.mdx | 180 ++++++++++ .../docs/build/data/validation-rules.ja.mdx | 237 +++++++++++++ .../docs/build/extend-existing-systems.ja.mdx | 62 ++++ content/docs/build/index.ja.mdx | 53 +++ content/docs/build/marketplace.ja.mdx | 140 ++++++++ content/docs/build/packages.ja.mdx | 164 +++++++++ content/docs/build/reports.ja.mdx | 164 +++++++++ content/docs/build/templates.ja.mdx | 93 ++++++ 13 files changed, 2024 insertions(+) create mode 100644 content/docs/build/agents.ja.mdx create mode 100644 content/docs/build/ai-builder.ja.mdx create mode 100644 content/docs/build/ai-skills.ja.mdx create mode 100644 content/docs/build/data/formulas.ja.mdx create mode 100644 content/docs/build/data/index.ja.mdx create mode 100644 content/docs/build/data/relationships.ja.mdx create mode 100644 content/docs/build/data/validation-rules.ja.mdx create mode 100644 content/docs/build/extend-existing-systems.ja.mdx create mode 100644 content/docs/build/index.ja.mdx create mode 100644 content/docs/build/marketplace.ja.mdx create mode 100644 content/docs/build/packages.ja.mdx create mode 100644 content/docs/build/reports.ja.mdx create mode 100644 content/docs/build/templates.ja.mdx diff --git a/content/docs/build/agents.ja.mdx b/content/docs/build/agents.ja.mdx new file mode 100644 index 00000000..05a53cc9 --- /dev/null +++ b/content/docs/build/agents.ja.mdx @@ -0,0 +1,162 @@ +--- +title: エージェント +seoTitle: "AI エージェント:エージェント、スキル、ツールを定義する" +description: エンドユーザー向けの AI アシスタント。Agent → Skill → Tool の構成で、データとアクションから組み立てます。 +editions: [community, cloud, enterprise] +translation: + source_sha: a79f76287f957b73a7b772cee68ed44718d9d50857f83da882622e1206d1122a + guide_rev: 1 + mode: auto +--- + +エージェントは、**エンドユーザー**がチャットする AI アシスタントです。ヘルプデスクのコパイロット、営業の BDR、社内 HR の Q&A ボットなどです。エージェントはすでに定義したデータとアクションの上に載ります。新しいコードは書かず、既存のプリミティブを組み合わせてペルソナを作ります。 + +Salesforce Agentforce、Microsoft Copilot Studio、ServiceNow Now Assist にそろえた 3 層のアーキテクチャです。 + +```text +Agent ──→ Skill ──→ Tool +(persona) (capability) (callable function) +``` + +| 層 | 内容 | 例 | +|---|---|---| +| **Tool** | 呼び出し可能な関数ひとつ(アクション、クエリ、ナレッジ検索、MCP メソッド) | `create_ticket`、`get_order_status`、`search_kb` | +| **Skill** | 関連するツールを、共通の LLM 指示とともにまとめた名前付きの束 | `ticket_management` = 作成 + 更新 + クローズ + エスカレーション | +| **Agent** | 役割、システムプロンプト、付属するスキルを持つペルソナ | `tier1_support` = 共感的で、本人確認を行い、`ticket_management` + `kb_search` を持つ | + +## エージェントを定義する(ファイル 1 つ) [#define-an-agent-one-file] + +```ts +// src/agents/tier1_support.agent.ts +import { defineAgent } from '@objectstack/spec/ai'; + +export const tier1Support = defineAgent({ + name: 'tier1_support', + label: 'First Line Support', + role: 'Help Desk Assistant', + instructions: ` + You are a friendly first-line support agent. + Always verify the user's identity before discussing account specifics. + Search the FAQ and the policy articles before you answer a how-to question. + Escalate to tier 2 if the issue involves billing or security. + `, + skills: ['ticket_management', 'knowledge_search'], + model: { provider: 'openai', model: 'gpt-4o', temperature: 0.3 }, + memory: { longTerm: { enabled: true, maxEntries: 50 }, reflectionInterval: 10 }, +}); +``` + +エージェントには `knowledge` ブロックはありません。`search_knowledge` ツールがどのソースを読めるかは、ナレッジサービスでソースごとに制限されます。エージェントがどのソースに基づいて答えるべきかは `instructions` に書いてください。 + +UI からは:**Studio → Agents → New Agent**。 + +あるいは、そしてこれが要点ですが、AI Builder にこう伝えます。 + +> *「チケット管理を担当し、FAQ を検索する Tier 1 サポートエージェントを作って。アカウントの詳細について話す前に本人確認をすること。」* + +## スキルを定義する [#define-a-skill] + +```ts +// src/skills/ticket_management.skill.ts +import { defineSkill } from '@objectstack/spec/ai'; + +export const ticketManagement = defineSkill({ + name: 'ticket_management', + label: 'Ticket Management', + instructions: ` + Always confirm the ticket subject and priority before creating one. + Use 'urgent' priority sparingly — only for outages or security incidents. + `, + tools: [ + 'create_ticket', + 'update_ticket', + 'close_ticket', + 'escalate_ticket', + 'action_*', // wildcard: every action the app exposes to AI (`ai.exposed`) + ], +}); +``` + +スキルは**再利用に適した単位**です。ひとつのスキルが多くのエージェントで機能します。 + +## ツールは宣言したメタデータから生まれる [#tools-come-from-your-declared-metadata] + +アクションは、**作成者がそれを AI に公開したとき**(40 文字以上の `ai.description` を伴う `ai: { exposed: true }`)、かつその種類が UI なしで実行される(`script`、`flow`、`api`)場合に、`action_` ツールになります。それ以外に配線は不要です。`support_ticket` オブジェクトの `escalate_ticket` が公開されていれば、AI Builder もあなたのエージェントもそれを呼び出せます。公開されていないアクションはエージェントからは見えず、呼び出し元がどのような権限を持っていても、呼び出されれば拒否されます。 + +呼び出しは**ユーザーとして**チェックされます。アクションの `requiredPermissions` が実行してよいかを決め、アクションはそのユーザーとしてレコードを読み込みます。その後 `script` の本体は信頼されたアプリコードとして実行されるので、それらのチェックを通過する誰に対しても本体を安全に実行できる場合にだけ、アクションを公開してください。[アクション](/docs/build/interface/actions#permissions)を参照してください。 + +次のものも公開できます。 + +| ツールの種類 | ソース | +|---|---| +| **アクション** | インストールされた任意のパッケージで、AI に公開された(`ai.exposed`)任意のアクション | +| **フロー** | 公開された `type: 'flow'` のアクション。エージェントがアクションを呼び出し、アクションがフローを開始します | +| **クエリ** | 保存された ObjectQL クエリ(`*.query.ts`) | +| **ナレッジ検索** | ユーザーが読めるソースを対象とする `search_knowledge` ツール | +| **MCP メソッド** | 接続された MCP サーバーが公開するもの | +| **組み込みのメタデータツール** | `create_object`、`add_field` など。ただし管理者向けエージェントのみ | + +## アンビエントアシスタントのパターン [#ambient-assistant-pattern] + +ユーザーにエージェントを選ばせる代わりに、**アプリ全体でひとつのチャットボックス**(Claude Code / Agentforce のスタイル)が欲しい場合は、App メタデータで `defaultAgent` を宣言し、アプリのコンテキストを付けてアンビエントチャットのエンドポイントを呼び出します。 + +```text +POST /api/v1/ai/chat { context: { appName: 'crm' }, ... } +``` + +`context.appName` が `defaultAgent` を宣言しているアプリに解決されると、ランタイムはそのエージェントを自動的に選択します。ユーザーが一覧から選ぶことはありません。組み込みの AI パネルはこの仕組みを使っています。ランタイムは次のものを解決します。 + +1. アクティブなアプリの**デフォルトエージェント**(アプリの `defaultAgent`)、またはユーザーがアクセスできる最初のエージェント。 +2. **アクティブなスキル**:Skill Registry から読み込まれたエージェントの `skills:` の一覧を、ユーザーの権限セットと現在のオブジェクト/レコードのコンテキストでフィルターしたもの。 + +どのエージェントをどこに表示するかを配線する必要はありません。アプリを宣言し、その `defaultAgent` を設定すれば表示されます。 + +## 権限 [#permissions] + +| 機能 | 権限 | +|---|---| +| エージェントとチャットする | `ai:chat`(とエージェントのスキルのツールへのアクセス) | +| メタデータの変更を承認する | `ai:approve` | +| エージェントとスキルを定義 / 編集する | `ai:author`(通常は Setup Administrator) | +| AI の会話を読む(監査) | `ai:read` | + +会話はユーザーにスコープされます。委任された許可がない限り、あるユーザーが別のユーザーのチャット履歴を見ることはできません。 + +## メモリと会話の状態 [#memory-and-conversation-state] + +エージェントの `memory` ブロックは長期メモリを設定します。各ユーザーについて抽出され、セッションをまたいで引き継がれるメモです。 + +| フィールド | 機能 | +|---|---| +| `longTerm.enabled` | セッションをまたいでメモを保持する(デフォルト `false`) | +| `longTerm.maxEntries` | ユーザーごとに保持するメモの数。新しいものから想起されます。`enabled` が true の場合は必須 | +| `reflectionInterval` | 振り返りの間に何回のやり取りがあるか。メモを書くのは振り返りです。`enabled` が true の場合は必須 | + +短期メモリのブロックはありません。1 回のリクエストのコンテキストウィンドウはトークン予算で制限されます。メモをどこに保存するかはプラットフォームが選ぶことであり、エージェントのメタデータではありません。 + +稼働中のコンテキストウィンドウの刈り込みは、会話のトークン予算の戦略で制御されます。`sliding_window`(デフォルト)、`fifo`、`importance`、`semantic`、`summary` です。 + +会話の行は `ai_conversations` に保存されます。ツール呼び出しの結果と保留中のアクションは、監査のために元の会話を相互参照します。 + +## 可観測性 [#observability] + +エージェントの実行はすべて次のものを出力します。 + +- `audit:ai:chat` イベント(ターンごと) +- `audit:ai:tool` イベント(ツール呼び出しごと、入力と出力付き) +- `audit:ai:pending_action` イベント(変更がキューに入ったとき) +- チャージバックのための、監査ログへのトークン数のメトリクス(モデルごと、プロバイダーごと) + +これらは普段使っている可観測性スタックにつなげられます。[可観測性](/docs/operate/observability)を参照してください。 + +## マルチテナントに関する注意 [#multi-tenant-note] + +エージェントは環境ごとです。テナント A の `tier1_support` エージェントがテナント B のデータ、会話、ナレッジを見ることは決してありません。同じエージェント定義をマーケットプレイスのパッケージで配布していても同じです。 + +## 次に進む [#where-to-go-next] + +- [AI Builder](/docs/build/ai-builder):構築時のアシスタント +- [IDE スキル](/docs/build/ai-skills):`npx skills add objectstack-ai/objectstack/skills` で、IDE のエージェントがメタデータを正しく作成できるようにする +- [アクション](/docs/build/interface/actions):エージェントが使うツールを宣言する +- [AI サービス](/docs/configure/ai):プロバイダー、エンベッダー、MCP の設定 +- [`@objectstack/spec/ai`](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec/src/ai):完全なスキーマ diff --git a/content/docs/build/ai-builder.ja.mdx b/content/docs/build/ai-builder.ja.mdx new file mode 100644 index 00000000..b5f9612a --- /dev/null +++ b/content/docs/build/ai-builder.ja.mdx @@ -0,0 +1,160 @@ +--- +title: AI Builder +seoTitle: "AI Builder:平易な言葉のチャットからアプリを構築する" +description: 平易な言葉の要件を稼働するメタデータに変える組み込みのチャットと、データについての質問に答えるアシスタント。それぞれの仕組み、呼び出すツール、変更の承認方法を説明します。 +editions: [cloud, enterprise] +translation: + source_sha: 3dc30df8f6289a706b6eb8101c4160ba800617f579063fc3b62f98518b34fad2 + guide_rev: 1 + mode: auto +--- + +AI Builder は、**顧客が ObjectOS を拡張する主要な方法**です。ObjectOS を開き、必要なものを平易な言葉で説明すると、オブジェクト、フィールド、ビュー、ダッシュボード、フロー、アプリ全体といったメタデータを構築してくれます。計画を承認するまでは何も構築されず、構築されるものはすべて、読み、変更し、パッケージ化できる通常の ObjectStack メタデータです。 + +## 2 つのエージェント:Builder と Assistant [#two-agents-the-builder-and-the-assistant] + +ObjectOS には 2 つの AI エージェントが付属します。どちらかを選ぶ必要はありません。**どこで尋ねるか**で、どちらが答えるかが決まります。 + +| | **Builder**(`build`) | **Assistant**(`ask`) | +|---|---|---| +| 場所 | アプリ構築のサーフェスにある AI Builder | データコンソールで、レコードの横にあるチャット | +| 何をするか | **アプリ**を設計し変更する:オブジェクト、フィールド、ビュー、ダッシュボード、フロー | **データ**についての質問に答え、グラフを描き、アプリが公開するアクションを実行する | +| 決してしないこと | — | アプリケーションを変更すること。構築のリクエストが届くと断り、Builder を案内します。黙って振り替えることは決してありません | + +どちらも**サインインしている人として**実行されます。すべてのツール呼び出しはその人の権限、レコードアクセス、フィールドレベルセキュリティに照らしてチェックされるので、AI がその人自身にできる以上のものを見たり変更したりすることは決してありません。 + +**Builder を使える人。** AI シートだけでは不十分です。Builder はアプリのメタデータを変更するので、`manage_metadata` または `studio.access` の権限も必要です。どちらも持たないメンバーには Assistant だけが表示され、Builder への直接のリクエストは `403 PERMISSION_DENIED` で拒否され、すべての構築ツールは実行時に権限をもう一度チェックします。 + +## 試してみる [#try-it] + +``` +You: I need to track customer support tickets. + Each ticket has a subject, description, priority (low/medium/high/urgent), + status (new/open/pending/resolved/closed), and assignee. + +AI: Here's the plan: + • Object `support_ticket` with 5 fields + • A kanban view grouped by status + • A dashboard of open tickets by priority + • A few sample tickets to try it with + + Shall I build it? + +You: Yes + +AI: ✓ Object created + ✓ Kanban view and dashboard created + ✓ Sample data added + Published — open Support Tickets to try it. +``` + +これで動作するチケット管理アプリができました。REST エンドポイント、生成されたビュー、監査ログのエントリ、権限のチェックポイントなど、すべてそろっています。ファイルは編集されず、再起動も起きていません。 + +## 構築の流れ [#how-a-build-runs] + +1. **計画。** 単一の変更より大きなもの(「採用管理システムを作って」)については、Builder はまず**ブループリント**を提案します。作成しようとしているオブジェクト、フィールド、リレーションシップ、ビュー、ダッシュボード、サンプルデータと、その前提です。このステップでは何も作成されません。 +2. **承認。** チャットで答えます。承認するか、変更を求めて修正された計画を受け取ります。Builder は、その会話であなたが承認した後にのみ変更を加えます。 +3. **ドラフトとして構築。** 承認された変更は**ドラフト**として書き込まれます。ステージングされた状態で、まだ稼働していません。 +4. **検証。** Builder はドラフトをプラットフォームのルールに照らしてチェックし、何かが稼働する前に直せるものを直します。 +5. **公開。** ドラフトは構築している環境に公開され、アプリがサンプルデータとともにそこに現れます。ステータスパネルが実際に公開されたものを表示します。Builder は、出荷されていない変更を出荷済みと報告することは決してありません。 + +既存のアプリを拡張するには、そう伝えます。*「サポートアプリに満足度アンケートを追加して」*。すると Builder は、別のアプリを構築する代わりに、新しい部品をそのアプリのオブジェクトにリンクするよう設計し、ナビゲーションに追加します。 + +## 依頼できること [#what-you-can-ask-for] + +| 目的 | プロンプト | +|---|---| +| 新しいオブジェクト | *"Create a `product` object with name, sku, price, category."* | +| フィールドの追加 | *"Add a `discount` decimal field to `order`, max 50."* | +| 選択リストの変更 | *"On `task.status`, add 'blocked' between 'open' and 'done'."* | +| リレーションシップ | *"Make `order_line` master-detail to `order`."* | +| 検証 | *"On `invoice`, the discount can't exceed the subtotal."* | +| フロー | *"When a high-priority ticket sits in 'new' for 30 minutes, notify the manager."* | +| アクション | *"Create an action `approve_invoice` that sets status to approved."* | +| 権限セット | *"Create `agent` permission set with read/create/edit on support_ticket and read on customer."* | +| 翻訳 | *"Translate the support_ticket labels and picklists to Spanish."* | + +AI にできないことなら、そう言って手作業の方法を案内します。 + +## エージェントが呼び出すツール [#the-tools-the-agents-call] + +エージェントの能力はそのツールです。環境のライブカタログは `GET /api/v1/ai/tools` で確認できます。 + +**Builder の作成ツール**は[パッケージ](/docs/build/packages)とそのメタデータを扱い、`@objectstack/spec` に照らして検証されます。 + +| 領域 | ツール | +|---|---| +| 計画 | `propose_blueprint`、`apply_blueprint` | +| パッケージ | `create_package`、`list_packages`、`get_package`、`get_active_package`、`set_active_package` | +| オブジェクト | `create_object`、`list_objects`、`describe_object` | +| フィールド | `add_field`、`modify_field`、`delete_field` | + +**Assistant のデータツール**はレコードを読み取り、それに対して動作します。 + +| ツール | 機能 | +|---|---| +| `query_records` | フィルター、フィールドの選択、ソート、ページングを指定してオブジェクトをクエリします。各レコードは `id` 付きで返されるので、回答は言及したレコードにリンクできます。 | +| `get_record` | id で 1 件のレコードを取得します。 | +| `aggregate_data` | サーバー側の `count`、`sum`、`avg`、`min`、`max`、`count_distinct`。必要に応じてグループ化とフィルターができます。 | +| `query_data` | 平易な言葉のリクエストに答えます。関連するオブジェクトを見つけ、クエリを書き、実行し、レコードを返します。 | +| `visualize_data` | グラフを描く唯一のツールです。オブジェクトをひとつのディメンションで 1 つ以上のメジャーにわたって集計し、棒、縦棒、横棒、折れ線、面、円、ドーナツ、レーダー、散布図のグラフをチャットに描きます。軸には表示値(選択肢のラベル、レコード名)がチャットの言語で表示されます。環境の分析サービスが必要です。 | +| `search_knowledge` | 登録されたナレッジソース(レコードのスナップショット、ファイル、URL)を検索します。結果はその人が見られるものにすでにフィルターされています。ナレッジサービスが設定されている場合に存在します。 | +| `action_` | AI に公開された、アプリの業務アクションのひとつを実行します(下記)。 | + +`aggregate_data` は製品内の AI の一部です。外部から [MCP](/docs/configure/mcp) で接続したエージェントは独自のツールセットを受け取り、それはそちらで説明しています。 + +## ツールとしてのアクション [#actions-as-tools] + +アクションは、作成者がそれを **AI に公開した**とき(40 文字以上の `ai.description` を伴う `ai: { exposed: true }`、UI なしで実行される種類(`script`、`flow`、`api`)で)、`action_` という名前のツールになります。公開されると、AI は呼び出し元の権限のもとで、組み込みのツールと同じようにそれを呼び出せます。公開されていないアクションがツールになることはありません。[アクション](/docs/build/interface/actions#from-an-ai-agent)を参照してください。 + +**アクションの実行前の承認。** `ai.requiresConfirmation: true` を宣言している、または別の宣言なしに破壊的に見えるアクションは、すぐには実行されません。AI はそれを**保留中のアクション**として登録し、承認されたときにだけ実行されます。 + +| エンドポイント | 目的 | +|---|---| +| `GET /api/v1/ai/pending-actions` | 自分の保留中のアクションを一覧表示する(ステータスでフィルター) | +| `GET /api/v1/ai/pending-actions/:id` | 自分の保留中のアクションの 1 件 | +| `POST /api/v1/ai/pending-actions/:id/approve` | 承認して実行する | +| `POST /api/v1/ai/pending-actions/:id/reject` | 却下する。理由は任意 | + +保留中のアクションは、それを提案した会話の持ち主に属します。読み取り、承認、却下ができるのはその人だけです。それ以外の人は、管理者であっても、存在しないかのような答えを受け取ります。 + +## API 経由でチャットする [#chatting-through-the-api] + +会話をエージェントに名前(`ask` または `build`)で送ります。 + +```bash +curl -X POST https://your-environment-host/api/v1/ai/agents/ask/chat \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [{ "role": "user", "content": "How many open opportunities over $50k?" }], + "context": { "appName": "crm", "objectName": "opportunity" } + }' +``` + +- **`messages`**:これまでの会話。クライアントから受け付けるのは `user` と `assistant` のターンだけです。 +- **`context`**:その人がどこにいるか。`appName`、`objectName`、`viewName`、`recordId`。スキルはこれを使って何が関連するかを判断します。 +- **`conversationId`**(任意):既存の会話を続けます。 + +回答はデフォルトで Server-Sent Events として**ストリーミング**されます(Vercel AI のデータストリームプロトコル、`x-vercel-ai-ui-message-stream: v1`)。グラフはストリームの中で `data-chart` パートとして届きます。代わりに単一の JSON の回答が欲しい場合は `"stream": false` を送ります。 + +会話は `/api/v1/ai/conversations` の下に保存され、各人は自分のものだけを見られます。 + +## 制限 [#limits] + +- **データアクセスはその人自身の権限に従います。** 読み取りでもアクションでも同じです。 +- **システムパッケージは対象外です。** システムパッケージ(`sys_*`)の変更を AI に依頼すると拒否されます。 +- **大きな構築は段階を踏むほうがうまくいきます。** 「完全な HR アプリを作って」も機能しますが、それぞれがひとつの領域を仕上げる小さな会話の連続のほうがレビューしやすくなります。 +- **ObjectOS Cloud では**、AI は環境の [AI 利用枠](/docs/cloud/ai-credits-and-limits)を消費し、環境が保持できる未公開のドラフトは最大 500 件です([ストレージと上限](/docs/cloud/storage-and-limits#drafts-and-authoring-writes))。セルフマネージドの Enterprise デプロイでは、モデルは自分のもので、[AI サービス](/docs/configure/ai)で設定します。 + +## 構築時の AI の先へ [#going-beyond-build-time-ai] + +同じ AI の仕組みが、エンドユーザー向けの**ランタイムエージェント**(サポートアシスタント、営業のコパイロット、社内の Q&A ボット)も動かしています。同じ Agent → Skill → Tool のアーキテクチャで構築されます。[エージェント](/docs/build/agents)を参照してください。 + +## 次に進む [#where-to-go-next] + +- [エージェント](/docs/build/agents):データの上に載るエンドユーザー向けアシスタント +- [データモデル](/docs/build/data):AI が実際に生成しているもの +- [アクション](/docs/build/interface/actions):AI がランタイムのツールとして公開する単位 +- [AI クレジットと上限](/docs/cloud/ai-credits-and-limits):ObjectOS Cloud の AI 利用枠 +- [AI サービス](/docs/configure/ai):プロバイダーの設定(OpenAI / Anthropic / Google / DeepSeek / …) diff --git a/content/docs/build/ai-skills.ja.mdx b/content/docs/build/ai-skills.ja.mdx new file mode 100644 index 00000000..1747dcf5 --- /dev/null +++ b/content/docs/build/ai-skills.ja.mdx @@ -0,0 +1,91 @@ +--- +title: IDE スキル(Claude Code / Cursor / Copilot) +description: ObjectStack のスキルをインストールすると、Claude Code、Cursor、Copilot、Codex などのコーディングエージェントが、正しい ObjectStack メタデータ(ObjectOS が実行するオントロジー)を作成できるようになります。 +editions: [community, cloud, enterprise] +translation: + source_sha: 1c4b67992500eaa86e4fac4a8e9f8065f54259b7f639e19d1346d5d317b5fc1f + guide_rev: 1 + mode: auto +--- + +[AI Builder](./ai-builder) は ObjectOS の中にあり、テナントのデータベースと対話します。しかし、`*.object.ts` を手で編集しているとき、フローを設計しているとき、Cursor に CEL の述語を書かせるときなど、同じドメイン知識を IDE の中で使いたいこともあります。 + +ObjectStack には、コーディングアシスタントにあらゆる種類の ObjectStack メタデータ(ObjectOS が実行するオントロジー)の作成方法を教える **9 つのファーストパーティのエージェントスキル**が付属します。これらはオープンな [`skills`](https://www.npmjs.com/package/skills) エコシステム(Vercel Labs)を通じて配布され、**Claude Code、Cursor、Copilot、Codex、Gemini CLI、Windsurf、Cline、Continue、Roo、Goose、Kiro、opencode** をはじめとする約 50 のエージェントで動作します。 + +## インストール [#install] + +`@objectstack/*` パッケージを使う任意のプロジェクトで: + +```bash +npx skills add objectstack-ai/objectstack/skills +``` + +CLI は設定済みのエージェント(`.claude/`、`.cursor/`、`.github/copilot/`、`AGENTS.md` など)を検出し、スキルを適切な場所に書き込みます。いつでも再実行して最新バージョンを取得できます。 + +> `package.json` の変更もグローバルインストールも不要です。スキルはリポジトリにコミットされる普通の Markdown ファイルなので、チーム全体と CI のエージェントが同じ指示を共有します。 + +## 得られるもの [#what-you-get] + +| スキル | 読み込むのは、次の作業をしているとき | +|:------|:-----------------------------| +| `objectstack-platform` | `defineStack`、ドライバー、アダプター、プラグイン、サービス、`os` CLI、デプロイ | +| `objectstack-data` | `*.object.ts`、`*.seed.ts`、フィールド、リレーションシップ、検証、インデックス、ライフサイクルフック、RLS | +| `objectstack-query` | ObjectQL:フィルター、ソート、ページネーション、集計、結合、ウィンドウ関数、全文検索 | +| `objectstack-ui` | ビュー、アプリ、ページ、ダッシュボード、レポート、グラフ、アクション | +| `objectstack-automation` | フロー、ワークフロー、トリガー、承認、スケジュール、Webhook | +| `objectstack-ai` | エージェント、ツール、スキル、会話、モデルレジストリ、MCP | +| `objectstack-api` | REST エンドポイント、認証プロバイダー、リアルタイムチャネル、エラーエンベロープ、バッチ/バージョニングの契約 | +| `objectstack-i18n` | 翻訳バンドル、ロケールのフォールバック、カバレッジ | +| `objectstack-formula` | CEL 式:数式フィールド、検証/共有/表示の述語、条件 | + +各スキルは**スコープを認識**しています。いつ自分を読み込み、いつ兄弟スキルに任せるべきかをエージェントに伝えるので、Claude がプラットフォームのスキルでオブジェクトをモデル化しようとしたり、データのスキルで CEL 式を書こうとしたりすることはありません。 + +## 仕組み [#how-it-works] + +各スキルは[フレームワークのリポジトリ](https://github.com/objectstack-ai/objectstack/tree/main/skills)の `skills/` の下にあるフォルダーです。 + +``` +skills/objectstack-data/ +├── SKILL.md # YAML frontmatter + prose guide +├── references/ +│ └── _index.md # pointers into @objectstack/spec Zod sources +└── rules/ # optional deep-dive rules +``` + +`SKILL.md` のフロントマターは、`skills add` がスキルを適切なエージェントのサーフェスに振り分けるために読むものです。 + +```yaml +--- +name: objectstack-data +description: Design ObjectStack data schemas — objects, fields, ... +license: Apache-2.0 +compatibility: Requires @objectstack/spec Zod schemas (v4+) +metadata: + domain: data + tags: object, field, validation, relationship, hook, permission, rls +--- +``` + +すべてのスキルに共通する中核のルールは、**`@objectstack/spec` の Zod スキーマが真実である**ということです。スキルは意図を与え、エージェントは正確なフィールドの形を `node_modules/@objectstack/spec/src/**/*.zod.ts` のスキーマから読み取ります。スキルをインストールすると、でっち上げのフィールドが事実上なくなるのはこのためです。 + +## スキルと AI Builder の比較 [#skills-vs-ai-builder] + +| | [AI Builder](./ai-builder) | IDE スキル | +|:--|:--|:--| +| 実行場所 | ブラウザ | IDE / コーディングエージェント | +| 変更するもの | テナント内の稼働中のメタデータ | リポジトリ内のファイル | +| 誰の代わりに動くか | エンドユーザー / 管理者 | 開発者 | +| 承認モデル | HITL キュー(`ai:approve`) | プルリクエストのレビュー | +| 最適な用途 | 日々の拡張、ノーコードのユーザー | テンプレート、パッケージ、複雑なフロー、ライブラリのコード | + +両者は補完し合います。ほとんどのチームは、テナントのカスタマイズには AI Builder を、**公開するつもりのパッケージ**の作業には IDE スキルを使います([テンプレート](./templates)と[マーケットプレイス](./marketplace)を参照してください)。 + +## 更新 [#updating] + +スキルは `@objectstack/spec` のバージョンに従います。spec パッケージをアップグレードした後は `npx skills add objectstack-ai/objectstack/skills` を再実行し、エージェントを最新の Zod スキーマ、CEL ヘルパー、メタデータの語彙に合わせてください。 + +## 関連 [#related] + +- [AI Builder](./ai-builder):ブラウザ内の対になるもの +- [エージェント](./agents):ObjectOS の*中で*実行されるサーバー側のエージェントを宣言する +- [テンプレート](./templates):公式テンプレートはすべて、これらのスキルを読み込んで構築されています diff --git a/content/docs/build/data/formulas.ja.mdx b/content/docs/build/data/formulas.ja.mdx new file mode 100644 index 00000000..744e03f5 --- /dev/null +++ b/content/docs/build/data/formulas.ja.mdx @@ -0,0 +1,208 @@ +--- +title: 数式 +seoTitle: "数式フィールド、動的デフォルト、CEL のロジック" +description: 計算フィールド、動的デフォルト、条件付きロジック。メタデータが考える必要のあるあらゆる場所で、ひとつの CEL 式言語を使います。 +editions: [community, cloud, enterprise] +translation: + source_sha: 4c94691d64eaae42bb49bfe69035aae77414eb5038b32c992efce1b2768c0aa1 + guide_rev: 1 + mode: auto +--- + +**ひとつの式言語を、あらゆるサーフェスで。** メタデータが値を計算したり条件を評価したりする必要がある場所では、ObjectOS は [CEL](/docs/reference/cel)(Google の Common Expression Language)を使います。数式フィールド、動的デフォルト、条件付きの表示、検証の条件、フローの判断です。構文を一度覚えれば、どこでも使えます。 + +| サーフェス | そこで CEL がすること | +|---|---| +| **数式フィールド**(`type: 'formula'`) | 他のフィールドから読み取り時の値を計算する | +| **動的デフォルト**(``defaultValue: cel`...` ``) | コンパイル時ではなく挿入時に評価する | +| **フィールドの述語**(`visibleWhen`、`readonlyWhen`、`requiredWhen`) | 条件に応じてフィールドを表示 / ロック / 必須にする | +| **[検証ルール](/docs/build/data/validation-rules)**(`condition`) | 無効なレコードに印を付ける | +| **[ビュー](/docs/build/interface/views)**(`visibleOn`、`conditionalFormatting`) | 条件付きのセクションと行のスタイル | +| **[フロー](/docs/build/automation/flows)**(判断、フックの条件) | レコードの状態で分岐する | + +このページでは、ビルダーが数式を*使う*方法を扱います。演算子、標準ライブラリ、`Expression` エンベロープなどの言語全体は [CEL リファレンス](/docs/reference/cel)にあります。 + +## 数式フィールド [#formula-fields] + +数式フィールドは、CEL 式から読み取り時に値を計算します。読み取り専用で、直接書き込むことはできません。 + +```ts +import { F } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const Invoice = ObjectSchema.create({ + name: 'invoice', + fields: { + subtotal: Field.currency({ label: 'Subtotal' }), + tax_rate: Field.number({ label: 'Tax Rate', scale: 4 }), + + total: Field.formula({ + label: 'Total', + returnType: 'number', + expression: F`record.subtotal + record.subtotal * record.tax_rate`, + }), + }, +}); +``` + +重要なキーは 2 つです。 + +| キー | 機能 | +|---|---| +| `expression` | CEL のソース。ランタイムが評価する**唯一の**キーです | +| `returnType` | 数式が返すもの:`'number'`、`'text'`、`'boolean'`、`'date'`。ダッシュボードと書式設定が読み取ります | + +> **警告:** 数式フィールドの `type` は常に `formula` です。`Field.formula` に `type: 'currency'` や `type: 'number'` を**渡さないでください**。`type: 'formula'` を上書きしてしまい、フィールドは黙って一度も計算されなくなります。値の型は代わりに `returnType` で宣言してください。 + +### よく使うパターン [#everyday-patterns] + +```ts +// Tiered label +Field.formula({ + label: 'Discount Tier', + returnType: 'text', + expression: ` + record.amount > 1000000 ? "Platinum" : + record.amount > 500000 ? "Gold" : + record.amount > 100000 ? "Silver" : "Bronze" + `, +}) + +// Days until close +Field.formula({ + label: 'Days to Close', + returnType: 'number', + expression: 'daysBetween(record.close_date, today())', +}) + +// Full name — joinNonEmpty skips blank parts +Field.formula({ + label: 'Full Name', + returnType: 'text', + expression: F`joinNonEmpty([record.salutation, record.first_name, record.last_name], ' ')`, +}) + +// Gross margin with 2 decimal places +Field.formula({ + label: 'Gross Margin %', + returnType: 'number', + expression: 'record.revenue > 0 ? ((record.revenue - record.cost) / record.revenue) * 100 : 0', + scale: 2, +}) +``` + +> **ヒント:** CEL は `null + string` で例外を投げるので、連結するときは null になりうるオペランドを `coalesce(record.x, '')` で包んでください。あるいは `joinNonEmpty` を使えば手間が省けます。 + +### 16.0 の新機能 [#new-in-160] + +- **日付と数値の演算はビルド時のエラーです。** `record.end_date - record.start_date + 1` や `today() + 30` のような式は、実行時に常に失敗していました(黙って null に評価されていました)。現在はビルドがそれらを拒否し、期間には `daysBetween(a, b)` を、ずらすには `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` を案内するエラーを出します。 +- **null ガードの三項演算子がコンパイルできます。** `cond ? value : null`(例:`record.end_date != null ? daysBetween(record.start_date, record.end_date) + 1 : null`)は、入力がそろうまで数式を空白に保つための推奨される方法です。 +- **`floor(x)` / `ceil(x)` が登録されました。** これらは −∞ / +∞ の方向に丸めます(つまり `floor(-1.2) == -2`)。整数除算のようにゼロの方向ではありません。 +- **`today()` との日付の等価比較が一致するようになりました。** 時間の値は比較の前に型変換されるので、`record.due_date == today()` も `daysBetween(today(), record.due_date) == 0` も期待どおりに動作します。 + +### レコードのタイトルとしての数式 [#formula-as-record-title] + +ADR-0079 以降、レコードのタイトルは `nameField` で指定されたフィールドです。複合的なタイトルにはテキストの数式を使います。 + +```ts +ObjectSchema.create({ + name: 'invoice', + nameField: 'display_title', + fields: { + display_title: Field.formula({ + returnType: 'text', + expression: F`"Invoice " + string(record.invoice_no) + " – " + record.customer.name`, + }), + }, +}); +``` + +## 動的デフォルト [#dynamic-defaults] + +`` cel`...` `` で包んだ `defaultValue` は**挿入時**に評価されます。あなたのノート PC ではなく、ユーザーの時刻と ID で評価されます。 + +```ts +import { cel } from '@objectstack/spec'; + +issued_date: Field.date({ + defaultValue: cel`today()`, +}), +due_date: Field.date({ + defaultValue: cel`daysFromNow(30)`, +}), +``` + +同じ手法はシードデータにも使えます。``close_date: cel`daysFromNow(45)` `` を持つシードレコードはパッケージのインストール時に解決されるので、デモデータはコンパイル時のタイムスタンプを運ぶのではなく、常に新しいものになります。 + +## 条件付きのフィールドの動作 [#conditional-field-behavior] + +述語を使うと、フィールドがレコードの他の部分に反応します。 + +```ts +import { P } from '@objectstack/spec'; + +po_number: Field.text({ + label: 'PO Number', + requiredWhen: P`record.amount > 10000`, +}), +rating: Field.select({ + label: 'Rating', + options: [ + { label: 'Hot', value: 'hot' }, + { label: 'Cold', value: 'cold' }, + ], + visibleWhen: P`record.status == 'qualified'`, +}), +notes: Field.textarea({ + readonlyWhen: P`record.status == 'approved'`, +}), +``` + +`F`(formula)、`P`(predicate)、`cel` のタグ付きテンプレートのヘルパーは交換可能です。呼び出し箇所で最も読みやすいものを選んでください。 + +## 30 秒でわかる CEL [#cel-in-30-seconds] + +| 概念 | 構文 | +|---|---| +| 現在のレコードのフィールド | `record.amount` | +| 更新前の値 | `previous.status` | +| 等価 / 論理 | `==` `!=` `&&` `\|\|` `!` | +| 条件 | `cond ? then : else` | +| 包含 | `record.region in ['us', 'eu']` | +| 空白のチェック | `isBlank(record.phone)` | +| 日付 | `today()`、`now()`、`daysBetween(a, b)`、`addDays(d, n)` | +| 文字列 | `upper`、`lower`、`trim`、`contains`、`matches` | + +演算子の完全な表と標準ライブラリ:[CEL リファレンス](/docs/reference/cel)。 + +> **警告:** 述語は素の CEL です。フィールドの参照を決して波括弧で包まないでください。`{record.rating} >= 4` は**マップリテラル**で、解析エラーになります。`record.rating >= 4` と書いてください。波括弧が使われるのは `{{ … }}` のテキストテンプレートだけです。不正な式はビルドに失敗し、実行時には例外を投げます。黙って `false` に評価されることはありません。 + +> **ヒント:** `has(record.x)` は、キーが存在すれば値が `null` でも真になります。「空白でない」ことをチェックするには、`isBlank(...)` か `record.x != null` を使ってください。 + +## ベストプラクティス [#best-practices] + +| すること | しないこと | +|---|---| +| 数式をシンプルで読みやすく保つ | 循環参照を作る | +| エッジケース(null、ゼロ、空)をテストする | 三項演算子を 5 段も入れ子にする | +| `coalesce` / `isBlank` で null を扱う | 頻繁に変わるデータに数式を使う | +| すべての数式で `returnType` を宣言する | 検証ルールが担うべきロジックを作り直す | + +## チャットで構築する [#build-by-chat] + +数式を手で書くことはめったにありません。[AI Builder](/docs/build/ai-builder) にこう伝えます。 + +> *「opportunity に Days to Close の数式を追加して。close_date と今日の間の日数。」* + +AI Builder は CEL を出力し、`returnType` を推論して付け、式は出荷前に検証されます。無効な数式は、位置付きのエラーでビルドに失敗します。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|---|---| +| [CEL リファレンス](/docs/reference/cel) | 言語全体:演算子、標準ライブラリ、式のエンベロープ | +| [データモデル](/docs/build/data) | 数式フィールドが置かれる場所 | +| [検証ルール](/docs/build/data/validation-rules) | 不正な書き込みをブロックする CEL の条件 | +| [ビュー](/docs/build/interface/views) | `visibleOn` と `conditionalFormatting` の CEL | +| [フロー](/docs/build/automation/flows) | 判断とフックの条件の CEL | +| [フィールドの種類](/docs/reference/field-types) | `formula`、`summary`、`autonumber` など | diff --git a/content/docs/build/data/index.ja.mdx b/content/docs/build/data/index.ja.mdx new file mode 100644 index 00000000..6db56f35 --- /dev/null +++ b/content/docs/build/data/index.ja.mdx @@ -0,0 +1,310 @@ +--- +title: データモデル +seoTitle: "データモデル:オブジェクト、フィールド、リレーションシップ" +description: オブジェクト、フィールド、リレーションシップ、検証、インデックス。AI に説明するか、TypeScript で書きます。 +editions: [community, cloud, enterprise] +translation: + source_sha: dfc4b470748f9d65a343db5a45ca7627a0b01485644546deff2adcd36ffb7430 + guide_rev: 1 + mode: auto +--- + +データモデルは、アプリの唯一の信頼できる情報源です。オブジェクトが存在すれば、ObjectOS は REST API、生成されたビュー、RBAC のチェックポイント、監査ログのエントリ、AI ツールとしての公開を無償で提供します。 + +**ほとんどの顧客はスキーマを手で書くことはありません。** [AI Builder](/docs/build/ai-builder) で必要なものを説明すると、プラットフォームがオブジェクト、フィールド、インデックス、翻訳を作成します。このページでは、その土台となる形を説明します。AI が何を生成しているかを理解し、必要なときに直接編集できるようにするためです。 + +## 作成の道 [#authoring-paths] + +| 道 | 見た目 | +|---|---| +| **AI Builder**(主要) | *"Create a `support_ticket` object with subject, description, priority, status, assignee."* | +| **クリックで構築** | **Studio → Objects → New Object** → フォーム | +| **TypeScript(`*.object.ts`)** | 下に示す TS。通常はフォークした[テンプレート](/docs/build/templates)の中 | + +3 つとも同じスキーマを生成します。スキーマが正規のものであり、それ以外はすべて導出されたものです。 + +## オブジェクトの構造 [#anatomy-of-an-object] + +```ts +// src/objects/task.ts +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const Task = ObjectSchema.create({ + name: 'todo_task', + label: 'Task', + pluralLabel: 'Tasks', + icon: 'check-square', + description: 'A single unit of work.', + + fields: { + subject: Field.text({ label: 'Subject', required: true, maxLength: 200 }), + description: Field.markdown({ label: 'Description' }), + status: Field.select({ + label: 'Status', + options: [ + { label: 'To Do', value: 'todo', default: true }, + { label: 'In Progress', value: 'in_progress' }, + { label: 'Done', value: 'done' }, + ], + }), + due: Field.date({ label: 'Due' }), + assignee: Field.lookup('sys_user', { label: 'Assignee' }), + }, + + enable: { + trackHistory: true, // record field changes in audit log + apiEnabled: true, // expose REST endpoints (default true) + feeds: true, // chatter / comments / @mentions + }, +}); +``` + +スタックに登録します。 + +```ts +// objectstack.config.ts +import { defineStack } from '@objectstack/spec'; +import * as objects from './src/objects'; + +export default defineStack({ + manifest: { id: 'my.app', namespace: 'todo', version: '0.1.0', type: 'app', name: 'My App' }, + objects: Object.values(objects), +}); +``` + +すべてのオブジェクト名は、スタックの `namespace` とアンダースコアで始まります。`todo` 名前空間では `todo_task` です。必要なのはそれだけです。`os dev` が再コンパイルし、`/api/v1/data/todo_task`、生成された Task ビュー、その権限の行がすべて現れます。 + +## フィールドの種類 [#field-types] + +ObjectStack には約 25 種類のフィールドが付属します。最もよく使われるもの: + +### スカラー [#scalars] + +| 種類 | 保存するもの | ヘルパー | +|---|---|---| +| `text` | 短い文字列 | `Field.text({ maxLength, required })` | +| `textarea` | 長い文字列 | `Field.textarea(...)` | +| `markdown` | Markdown 付きのリッチテキスト | `Field.markdown(...)` | +| `number` | 数値。`scale` で小数点以下の桁数を設定 | `Field.number({ min, max, scale })` | +| `currency` | 金額 | `Field.currency(...)` | +| `boolean` | 真/偽 | `Field.boolean({ defaultValue })` | +| `date` | 暦日 | `Field.date(...)` | +| `datetime` | タイムスタンプ | `Field.datetime(...)` | +| `email` | 検証されたメールアドレス | `Field.email(...)` | +| `url` | 検証された URL | `Field.url(...)` | +| `phone` | 検証された電話番号 | `Field.phone(...)` | +| `json` | 任意の JSON | `Field.json(...)` | + +### 選択肢 [#choices] + +| 種類 | 用途 | +|---|---| +| `select` | 単一の選択(列挙) | +| `multiselect` | 複数の選択 | + +### リレーションシップ [#relationships] + +| 種類 | カーディナリティ | ヘルパー | +|---|---|---| +| `lookup` | 1 対多(外部キー) | `Field.lookup('sys_user', { label })` | +| `master_detail` | カスケード削除付きの 1 対多 | `Field.masterDetail('order', { label })` | + +### ファイルとメディア [#files--media] + +| 種類 | 保存するもの | +|---|---| +| `file` | ストレージサービス経由のファイル 1 つ | +| `image` | プレビュー付きの画像ファイル | + +### 計算 / 導出 [#computed--derived] + +| 種類 | 動作 | +|---|---| +| `formula` | CEL 式から読み取り時に計算 | +| `summary` | 関連レコードの集計(sum/count/avg) | +| `autonumber` | 連番(`INV-{000001}`) | +| `created`、`lastModified` | システムが管理するタイムスタンプ | +| `createdBy`、`lastModifiedBy` | システムが管理するユーザー参照 | + +## 必須 / 一意 / デフォルト [#required--unique--default] + +すべてのスカラーフィールドに共通する修飾子: + +```ts +Field.text({ + label: 'Code', + required: true, + unique: true, // unique constraint enforced at DB level + defaultValue: '', + inlineHelpText: 'Internal short code', +}) +``` + +## 検証 [#validation] + +インライン: + +```ts +Field.number({ label: 'Quantity', min: 1, max: 9999 }) +Field.text({ label: 'SKU', minLength: 8, maxLength: 8 }) +``` + +オブジェクトレベルのルール:フィールドをまたぐルールと、パターンのための `format` ルールです。ルールの `condition` は無効なレコードを記述します。それが真のとき、書き込みは拒否されます。 + +```ts +ObjectSchema.create({ + name: 'order', + fields: { + sku: Field.text({ label: 'SKU' }), + discount: Field.currency({ label: 'Discount' }), + total: Field.currency({ label: 'Total' }), + }, + validations: [ + { + type: 'cross_field', + name: 'discount_lt_total', + message: 'Discount cannot exceed total', + fields: ['discount', 'total'], + condition: 'record.discount > record.total', + }, + { + type: 'format', + name: 'sku_format', + message: 'SKU looks like ABC-1234', + field: 'sku', + regex: '^[A-Z]{3}-[0-9]{4}$', + }, + ], +}); +``` + +検証は REST、UI、ObjectQL のすべての書き込みで実行されるので、「裏口」はありません。 + +## インデックスとパフォーマンス [#indexes--performance] + +```ts +ObjectSchema.create({ + name: 'order', + fields: { /* ... */ }, + indexes: [ + { fields: ['status', 'created_at'] }, + { fields: ['account', 'created_at'], unique: false }, + ], +}); +``` + +ドライバーはスキーマの同期時に実際の DB インデックスを作成します。 + +## フィールドグループ [#field-groups] + +長いフォームでは、フィールドをグループ化します。 + +```ts +ObjectSchema.create({ + name: 'task', + fieldGroups: [ + { key: 'core', label: 'Task', icon: 'check-square' }, + { key: 'planning', label: 'Planning', icon: 'calendar' }, + { key: 'meta', label: 'Metadata', icon: 'info', defaultExpanded: false }, + ], + fields: { + subject: Field.text({ label: 'Subject', group: 'core' }), + due: Field.date({ label: 'Due', group: 'planning' }), + }, +}); +``` + +## 機能フラグと所有権 [#capability-flags--ownership] + +```ts +ObjectSchema.create({ + name: 'task', + fields: { subject: Field.text({ label: 'Subject' }) }, + ownership: 'user', // 'user' (default) | 'business_unit' | 'org' | 'none' + sharingModel: 'private', // OWD — custom objects default to private (v13) + enable: { + apiEnabled: true, // generated REST endpoints (default true) + searchable: true, // index records for global search (default true) + trackHistory: true, // History tab + field-level diffs (opt-in, default false) + feeds: true, // sys_comment / @mentions (default true) + activities: true, // mirror CRUD to sys_activity timeline (default true) + files: false, // Attachments panel (opt-in, default false) + clone: true, // record deep cloning (default true) + }, +}); +``` + +`enable` は**閉じた語彙**です。このブロックは厳格なので、認識されないフラグ(`feedEnabled` のようなタイプミスや、かつて存在したキー)は、黙って捨てられる設定ではなく、そのキーを示す解析エラーになります。上の 7 つのフラグがそのすべてで、さらにキーがもう 1 つあります。 + +```ts +// Keys of an object; its name and fields are omitted. +enable: { + apiMethods: ['get', 'list', 'create', 'update'], // no delete over the API +} +``` + +`apiMethods` は、オブジェクトが公開する API の操作をホワイトリストで指定します。作成できる語彙はちょうど 6 つのプリミティブ、`get`、`list`、`create`、`update`、`delete`、`bulk` です。制限のないアクセスにはキーを省略します。`[]` はすべて拒否を意味します。8 つのレガシーな値(`upsert`、`import`、`export`、`aggregate`、`search`、`history`、`restore`、`purge`)は、宣言するものではなく*導出される*実効的な操作です。保存されているものはまだ解析されますが、置き換え先を示す警告とともに取り除かれます(`upsert` → `create` + `update`、`export`/`aggregate`/`search` → `list`、`history` → `get`。`restore` と `purge` は何からも導出されず、単に削除されます)。ひとつの落とし穴に注意してください。レガシーな値*だけ*のホワイトリストは `[]` に取り除かれ、オブジェクトの API を広げるのではなく閉じてしまいます。 + +> **ソフトデリートはありません。`enable.trash` は削除されました。** 以前のドキュメントは、廃止されたオブジェクトレベルの `softDelete` プロパティの代わりとして `enable: { trash: true }` を案内していました。これは、はっきり述べておく価値のある形で誤りでした。`trash` にはランタイムの利用者が一度もありませんでした。削除は常にハードデリートだったので、ごみ箱を約束するデフォルト true のフラグは偽りのアフォーダンスでした。作成者は、実行されたことのないソフトデリートを無効にしているつもりで `trash: false` を書いていました。このキー(と隣の `enable.mru`)は現在、無視されるのではなく、ガイダンス付きで**解析時に拒否**されます。ソースから削除するか、`os migrate meta --from 16` を実行して書き換えてください。 +> +> 復元可能性のためには、フィールドごとの `trackHistory`(何が変わったかの監査証跡)か [`lifecycle`](#data-lifecycle-retention) ポリシーを使ってください。本物のごみ箱は現時点で提供されている機能ではありません。 + +> **オブジェクトのサーフェスから削除されたものは他にもあります:** `softDelete`、`versioning`、`search`(トップレベルの `searchableFields` を使ってください)、`recordName`(autonumber フィールドを `nameField` として使ってください)、`keyPrefix`、`tags`、`active`、`abstract`。これらのいずれかを渡すと、`ObjectSchema.create` は置き換え先を示す位置付きのエラーを投げます。 + +ObjectStack 14 以降、`enable.*` のフラグは助言ではなく**強制**されます。`feeds: false` はコメントの作成を 403 `FEEDS_DISABLED` で拒否し、`sys_attachment` の行を作成するには `files` をオプトインする必要があり(そうでなければ 403 `FILES_DISABLED`)、`activities` / `trackHistory` はタイムラインと History タブを制御します。コンプライアンスのための `sys_audit_log` の行は、フラグにかかわらず常に書き込まれます。 + +## データのライフサイクル(保持) [#data-lifecycle-retention] + +大量のデータを持つオブジェクトは `lifecycle` ブロックを宣言でき、プラットフォームがその増加を抑えます(ObjectStack 14.4、ADR-0057)。 + +```ts +ObjectSchema.create({ + name: 'my_event', + fields: { payload: Field.json({ label: 'Payload' }) }, + lifecycle: { + class: 'event', // 'record' | 'audit' | 'telemetry' | 'transient' | 'event' + retention: { maxAge: '14d' }, // reaper deletes rows past the window + // time-sharded table: 2 shards of 1 week + storage: { strategy: 'rotation', shards: 2, unit: 'week' }, + }, +}); +``` + +組み込みの LifecycleService(`OS_LIFECYCLE_DISABLED=1` で無効化)は、期限切れの行を刈り取り、時間でシャーディングされたテーブルをローテーションし、監査クラスのオブジェクトをアーカイブします。`sys_activity`(14 日)や `sys_audit_log`(90 日間ホット、その後アーカイブ)のようなプラットフォームのオブジェクトにはライフサイクルの宣言が付属し、`lifecycle.retention_overrides` 設定で環境ごとに調整できます。 + +## システムオブジェクト(すべてのプロジェクトに無償で付属) [#system-objects-free-with-every-project] + +これらを宣言する必要はありません。常に存在します。 + +| オブジェクト | 内容 | +|---|---| +| `sys_user` | ユーザーアカウント | +| `sys_org` | 組織 / テナント | +| `sys_member` | 組織のメンバーシップ | +| `sys_position`、`sys_permission_set` | RBAC のプリミティブ | +| `sys_audit_log` | 監査証跡(監査機能が読み込まれている場合) | +| `sys_file`、`sys_attachment` | ファイルのメタデータ(ストレージが読み込まれている場合) | +| `sys_comment`、`sys_activity` | フィード / チャター(フィードが読み込まれている場合) | +| `sys_session`、`sys_api_key` | 認証の成果物 | +| `sys_webhook`、`sys_webhook_delivery` | Webhook の購読(有効な場合) | + +`lookup` フィールドでは名前で参照します。例:`Field.lookup('sys_user', { label: 'Owner' })`。 + +## ポリモーフィックなプラットフォーム機能 [#polymorphic-platform-features] + +`feeds: true` と `trackHistory: true` を有効にすると、オブジェクトは自動的に次のものに参加します。 + +- `sys_comment`(thread_id = `:`) +- `sys_attachment`(parent_object = ``、parent_id = ``) +- `sys_activity`(タイムライン) +- `sys_audit_log`(フィールドレベルの差分) + +これらをオブジェクトごとに配線する必要はありません。プラットフォーム上でポリモーフィックに動作します。 + +## 次に進む [#where-to-go-next] + +- [権限](/docs/configure/permissions):オブジェクトへのアクセスを制御する +- [フロー / 自動化](/docs/build/automation/flows):レコードの変更に反応する +- [API アクセス](/docs/configure/api-access):生成された REST を呼び出す +- [`os explain`](/docs/reference/cli):レンダリングされたスキーマを出力する +- [`@objectstack/spec` のソース](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec):スキーマが契約です。ここにあるものはすべてそこから導出されています diff --git a/content/docs/build/data/relationships.ja.mdx b/content/docs/build/data/relationships.ja.mdx new file mode 100644 index 00000000..1b1d8256 --- /dev/null +++ b/content/docs/build/data/relationships.ja.mdx @@ -0,0 +1,180 @@ +--- +title: リレーションシップ +seoTitle: "リレーションシップ:参照、主従、ロールアップ" +description: 参照と主従でオブジェクトをつなぎます。カスケードのルール、フィルター付きのピッカー、階層、中間オブジェクト、ロールアップ。 +editions: [community, cloud, enterprise] +translation: + source_sha: 05bc53d30e7cc26ead447169d0a7c3ba0beb76be6a457d9843593253d11f0469 + guide_rev: 1 + mode: auto +--- + +**リレーションシップは単なるフィールドです。** `lookup` または `masterDetail` フィールドを別のオブジェクトに向けると、ObjectOS がその上のすべてを配線します。外部キーの整合性、レコードピッカー、親ページの関連リスト、クエリの `expand` です。 + +| 種類 | カーディナリティ | 削除のデフォルト | 用途 | +|---|---|---|---| +| `lookup` | 多対 1 | `set_null` | 緩やかな参照(チケット → 担当者) | +| `master_detail` | 多対 1、所有される | `cascade` | 親子の所有関係(注文 → 明細行) | +| `tree` | 自己参照 | — | 階層(カテゴリ、組織図) | +| `user` | 多対 1 | `set_null` | 人のピッカー。`sys_user` に特化した参照 | +| `summary` | 親へのロールアップ | — | 子の集計(sum、count、avg) | + +## 参照(多対 1) [#lookup-many-to-one] + +主力です。別のオブジェクトのレコードを参照します。 + +```ts +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const Contact = ObjectSchema.create({ + name: 'contact', + fields: { + account: Field.lookup('account', { + label: 'Account', + required: true, + }), + }, +}); +``` + +プラットフォームは参照されたレコードが存在することを検証します。外部キーの整合性はすべての書き込みで強制されます。 + +> **ヒント:** フィールドには、指す先のオブジェクトの名前を付けてください。`account_id` ではなく `account` です。関連リスト、`expand`、AI Builder のすべてが、そのほうが読みやすくなります。 + +### 親が削除されたときに起こること [#what-happens-when-the-parent-is-deleted] + +カスケードの動作は `deleteBehavior` で制御します。 + +| 値 | 親の削除時 | +|---|---| +| `set_null` | 子の参照がクリアされます(参照のデフォルト) | +| `cascade` | 子のレコードも削除されます | +| `restrict` | 子が存在する間は削除がブロックされます | + +## フィルター付きの参照 [#filtered-lookups] + +ピッカーが提示するレコードを制限します。静的な条件には `lookupFilters` を、同じレコードの別のフィールドで候補を絞り込むには `dependsOn` を使います。 + +```ts +contact: Field.lookup('contact', { + label: 'Contact', + dependsOn: ['account'], // only contacts of the chosen account + lookupFilters: [ + { field: 'is_active', operator: 'eq', value: true }, + ], +}) +``` + +> **警告:** レガシーな `referenceFilters: string[]` プロパティ(例:`['is_active = true']`)は **16.0 で削除されました**。解析時に取り除かれ、削除される前でさえレコードピッカーの UI はそれを読んでいなかったので、何もフィルターしていませんでした。上に示した構造化された `lookupFilters` + `dependsOn` が、サポートされる唯一の形です。 + +## 主従(所有される子) [#master-detail-owned-children] + +`masterDetail` は所有のセマンティクスを持つ参照です。子は親に属し、デフォルトでは親とともにカスケード削除され、親のフォームで明細行としてインライン編集できます。 + +```ts +export const OrderLine = ObjectSchema.create({ + name: 'order_line', + fields: { + order: Field.masterDetail('order', { label: 'Order' }), + product: Field.lookup('product', { label: 'Product' }), + quantity: Field.number({ label: 'Quantity', min: 1 }), + }, +}); +``` + +| 観点 | 動作 | +|---|---| +| 削除 | `deleteBehavior: 'restrict'` でない限り、実行時に子へカスケードします | +| 所有 | 子のレコードは親のレコードが所有します | +| 編集 | 親のフォームでの明細行のインライン編集(任意) | +| ロールアップ | `summary` フィールドは主(親)の側でのみ有効です | + +参照が任意または共有されるものなら `lookup` を、子のレコードが親なしでは意味をなさないなら `masterDetail` を選んでください。 + +## 自己参照の参照とツリー [#self-referencing-lookups--trees] + +参照は自分自身のオブジェクトを指して階層を構築できます。 + +```ts +parent_account: Field.lookup('account', { + label: 'Parent Account', + description: 'Parent company in hierarchy', +}) +``` + +専用の階層(カテゴリ、組織図)には `tree` フィールドの種類を使います。`lookup` と同じように保存され展開される自己参照の参照です。エンジンは書き込み時に循環のチェックを**行わない**ため、自分自身に戻ってくる連鎖が自動的に拒否されることはありません。 + +> **ヒント:** `tree` / 自己参照のフィールドを[ツリーリストビュー](/docs/build/interface/views)と組み合わせると、階層を入れ子の行として表示できます。 + +## 関連リスト [#related-lists] + +これは無償で得られます。参照が親を指していれば、子のレコードは親の詳細ページに関連リストとして自動的に表示されます。 + +- Contact が `account: Field.lookup('account')` を持つ +- → Account の詳細ページに **Contacts** の関連リストが表示される + +追加の設定は不要です。 + +## 多対多:中間オブジェクト [#many-to-many-junction-objects] + +ObjectOS には直接の多対多フィールドはありません。**中間オブジェクト**としてモデル化します。両側それぞれへのリレーションシップフィールドを 2 つ持つオブジェクトです。 + +```ts +// Student ↔ Course, joined by Enrollment +export const Enrollment = ObjectSchema.create({ + name: 'enrollment', + fields: { + student: Field.masterDetail('student', { label: 'Student' }), + course: Field.masterDetail('course', { label: 'Course' }), + grade: Field.select({ + label: 'Grade', + options: [ + { label: 'Pass', value: 'pass' }, + { label: 'Fail', value: 'fail' }, + ], + }), + }, + indexes: [ + { fields: ['student', 'course'], unique: true }, // one enrollment per pair + ], +}); +``` + +どちらの側も中間レコードを関連リストとして見られます。そして中間オブジェクト自体が、組み合わせの属性(成績、役割、参加日)の自然な置き場所になります。 + +## ロールアップ(`summary` フィールド) [#roll-ups-summary-fields] + +`summary` フィールドで子のレコードを親に集計します。主オブジェクト、つまり主従の親の側でのみ有効です。 + +```ts +// Fields of an object — this one belongs on the Order object. +total_lines: { + type: 'summary', + label: 'Line Count', + summaryOperations: { + object: 'order_line', // child object to aggregate + field: 'quantity', // field to aggregate + function: 'sum', // count | sum | min | max | avg + filter: { status: 'active' }, // new in 16.0: aggregate only matching children + }, +} +``` + +サマリーフィールドは読み取り専用で、子から計算されます。直接書き込むことはありません。 + +**16.0 の新機能:** 任意の `filter` はクエリ形式の `where` 述語です。それに一致する子の行だけが集計され(例:`status == 'active'` の子だけを数える)、子が述語の内外に移ると、ロールアップは再集計されます。また、複数のサマリーが*同じ*子オブジェクトを別々の合計にロールアップできるようになります。 + +## リレーションシップをまたいでクエリする [#querying-across-relationships] + +`expand` を使うと、関連レコードを 1 回の呼び出しで読み込めます。クエリ API は参照フィールドをたどり、参照先のレコードをインライン化するので、クライアントから N+1 のリクエストを発生させずに済みます。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|---|---| +| [データモデル](/docs/build/data) | オブジェクト、フィールド、これらのリレーションシップが存在するスキーマ | +| [数式](/docs/build/data/formulas) | CEL でレコード全体の値を計算する | +| [検証ルール](/docs/build/data/validation-rules) | フィールドをまたぐルールと一意制約 | +| [ビュー](/docs/build/interface/views) | ツリービュー、カンバン、関連リストのサーフェス | +| [フィールドの種類](/docs/reference/field-types) | `lookup`、`master_detail`、`tree`、`summary` の完全なリファレンス | +| [AI Builder](/docs/build/ai-builder) | チャットでリレーションシップを説明し、プラットフォームに配線させる | diff --git a/content/docs/build/data/validation-rules.ja.mdx b/content/docs/build/data/validation-rules.ja.mdx new file mode 100644 index 00000000..2c0defeb --- /dev/null +++ b/content/docs/build/data/validation-rules.ja.mdx @@ -0,0 +1,237 @@ +--- +title: 検証ルール +seoTitle: "検証ルール:必須、一意、CEL のルール" +description: 必須フィールド、一意制約、そして CEL によるルールで、プラットフォームのレベルで不正なデータを止めます。エラーメッセージは自分で制御できます。 +editions: [community, cloud, enterprise] +translation: + source_sha: 1f582742d9d1e87e240b1d6d773f281c8036338f52226387f84a0549f02bc5c1 + guide_rev: 1 + mode: auto +--- + +**検証はすべての書き込み経路で実行されます。** REST、フォーム、ObjectQL のどこでも同じルールが発動するので、不正なデータのための裏口はありません。16.0 以降は複数行の更新も含まれます。一括更新は、一致した行ごとにルールを 1 回評価します。検証ルールは、単一のレコードに対する決定的で同期的な副作用のない述語です。入ってくる書き込み(更新時には以前のレコードも)から I/O なしで判定できます。 + +検証は層になっています。ルールを表現できる最も低い層を使ってください。 + +| 層 | 表現するもの | 例 | +|---|---|---| +| **フィールドの修飾子** | 存在、一意性 | `required: true`、`unique: true` | +| **フィールドの制約** | フィールドごとの形 | `min`、`max`、`maxLength`、`format` | +| **条件付きの修飾子** | 文脈に依存する存在 | ``requiredWhen: P`record.amount > 10000` `` | +| **オブジェクトの検証ルール** | フィールドをまたぐビジネスロジック | 「値引きは合計を超えられない」 | +| **一意インデックス** | 複合 / スコープ付きの一意性 | `{ fields: ['code', 'organization'], unique: true }` | +| **ライフサイクルフック** | 任意の検証コード、削除のガード | `beforeInsert` / `beforeUpdate` / `beforeDelete` | + +## フィールドレベル:必須、一意、制約 [#field-level-required-unique-constraints] + +汎用の修飾子は、すべてのフィールドの種類で動作します。 + +```ts +// Fields of an object; the object around them is omitted. +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +fields: { + email: Field.email({ label: 'Contact Email', required: true, unique: true }), + quantity: Field.number({ label: 'Quantity', min: 1, max: 9999 }), + code: Field.text({ label: 'Code', minLength: 3, maxLength: 20 }), +} +``` + +| 修飾子 | 動作 | +|---|---| +| `required: true` | 実行時に `null` / `undefined` を拒否します | +| `unique: true` | データベースレベルの一意制約。競合しやすいアプリケーション側のチェックではありません | +| `min` / `max` | 数値の範囲(number、currency、percent、rating、slider など) | +| `minLength` / `maxLength` | テキストの種類の文字数の範囲 | +| `format` | 組み込みの形のチェック。`email`、`url`、`phone` のフィールドの種類はデフォルトでそれを持ちます | +| `readonly: true` | 挿入時と更新時にサーバーで強制されます。システム以外によるそのフィールドへの書き込みは捨てられ、レスポンスの `droppedFields` で報告されます。書き込みが `strictReadonlyWrites` オプションを渡した場合は完全に拒否されます。作成時に読み取り専用フィールドへ初期値を入れるには、システムとして書き込みます。シード、システムフック、または `runAs: 'system'` のフローです | + +フィールドの種類も無償で自分自身を検証します。`email` は `local@domain` の形をチェックし、`url` はプロトコルを必要とし、`select` の値は選択肢に一致しなければならず、`lookup` は参照先のレコードが存在することを強制し、`json` は解析できなければなりません。すべてのデフォルトは[フィールドの種類のリファレンス](/docs/reference/field-types)を参照してください。 + +### 条件付きの必須 / 読み取り専用 / 表示 [#conditional-required--read-only--visible] + +存在の要否は、CEL の述語でレコードの他の部分に依存させられます。 + +```ts +import { P } from '@objectstack/spec'; + +po_number: Field.text({ + label: 'PO Number', + requiredWhen: P`record.amount > 10000`, +}), +``` + +`visibleWhen`、`readonlyWhen`、`requiredWhen` はすべて CEL の述語を取ります。[数式](/docs/build/data/formulas)を参照してください。これらはレコード自身のフィールドだけを読みます。評価できない `requiredWhen` や `readonlyWhen`(綴りを間違えたフィールド、`null` に対する大小比較、`record.account.tier` のような参照をたどる読み取り)は、書き込みを通すのではなく `VALIDATION_FAILED` で拒否します。関連レコードを読む必要があるチェックは[検証ルール](#reading-a-related-record)に置いてください。 + +## オブジェクトレベルの検証ルール [#object-level-validation-rules] + +フィールドをまたぐビジネスロジックは、オブジェクトの `validations` 配列に置きます。 + +```ts +export const Order = ObjectSchema.create({ + name: 'order', + fields: { + amount: Field.currency({ label: 'Amount', required: true }), + status: Field.select({ + label: 'Status', + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Submitted', value: 'submitted' }, + ], + }), + }, + + validations: [ + { + name: 'amount_positive', + type: 'script', + severity: 'error', + message: 'Amount must be greater than zero', + // CEL predicate — TRUE means the record is INVALID. + condition: 'record.amount <= 0', + events: ['insert', 'update'], + }, + ], +}); +``` + +> **警告:** `script` ルールの `condition` は**失敗の**述語です。それが TRUE に評価されると検証は失敗します。*不正な*状態のテストを書いてください。`record.amount <= 0` は正でない金額を拒否します。 + +### 共通のプロパティ [#common-properties] + +すべてのルールの種類は、次の基本の形を共有します。 + +| プロパティ | 必須 | 説明 | +|---|---|---| +| `name` | はい | 一意のルール名(`snake_case`) | +| `message` | はい | ユーザー向けのエラーメッセージ | +| `type` | はい | `script`、`state_machine`、`format`、`cross_field`、`json_schema`、`conditional` | +| `severity` | いいえ | `error`(保存をブロック、デフォルト)、`warning`(保存を許可)、`info` | +| `events` | いいえ | `insert`、`update`。デフォルトは `['insert', 'update']`。`delete` イベントは 16.0 で削除されました。評価器は削除時に一度も実行されず(削除はレコードのペイロードを持たない)、黙って何もしていなかったためです。削除は `beforeDelete` フックでガードしてください | +| `priority` | いいえ | 0〜9999、小さいほど先に実行(デフォルト 100) | +| `active` | いいえ | 削除せずに切り替える(デフォルト `true`) | + +### 6 種類のルール [#the-six-rule-types] + +| 種類 | チェックするもの | 主な設定 | +|---|---|---| +| `script` | 任意の CEL 述語 | `condition`(TRUE = 無効) | +| `state_machine` | 許可されたステータスの遷移 | `field`、`transitions` のマップ、任意の `initialStates` | +| `format` | 1 つのフィールドを正規表現または組み込みの形式に照らす | `field`、`regex` または `format: 'email' \| 'url' \| 'phone' \| 'json'` | +| `cross_field` | フィールド間の関係 | `fields`、`condition` | +| `json_schema` | JSON フィールドを JSON Schema に照らす | `field`、`schema` | +| `conditional` | 述語が成り立つときだけ入れ子のルールを適用する | `when`、`then`、任意の `otherwise` | + +いつも使うことになる 2 つのルール: + +```ts +// Two validation rules, entries of the object's `validations` array. + +// State machine — enforce the status flow +{ + name: 'order_status_transitions', + type: 'state_machine', + severity: 'error', + message: 'Invalid status transition', + field: 'status', + transitions: { + draft: ['submitted', 'cancelled'], + submitted: ['approved', 'rejected', 'cancelled'], + approved: ['completed'], + rejected: ['draft'], + cancelled: [], + completed: [], + }, + initialStates: ['draft'], // new in 16.0: states a record may be CREATED in + events: ['insert', 'update'], // include 'insert' so initialStates is checked +} + +// Cross-field — dates in order +{ + name: 'date_range_valid', + type: 'cross_field', + severity: 'error', + message: 'End date must be after start date', + fields: ['start_date', 'end_date'], + condition: 'record.end_date <= record.start_date', + events: ['insert', 'update'], +} +``` + +**16.0 の新機能:** `state_machine` ルールは `initialStates`、つまりレコードを*作成*できる状態を宣言できます。状態フィールドが一覧外の値を持つ挿入は拒否されます(`invalid_initial_state`)。`transitions` は更新だけを制御し、`select` フィールドは宣言された任意の選択肢を初期値として許すので、`initialStates` がないとレコードはフローの途中で生まれることがあります(例:最初から `approved` で作成される)。省略するとレガシーな動作(挿入時に初期状態をチェックしない)のままです。チェックを実行するには、ルールの `events` に `insert` が含まれていなければなりません。 + +更新時の条件では、`previous` が変更前のスナップショットを保持します。`record.stage != previous.stage` で変更を検出できます。 + +### 関連レコードを読む [#reading-a-related-record] + +ObjectStack 17.5 以降、`script` または `cross_field` ルールは、`lookup`、`master_detail`、`user`、`tree` のフィールドを通じて **1 ホップ**だけ読めます。 + +```ts +// One validation rule, an entry of the object's `validations` +{ + name: 'partner_deal_cap', + type: 'script', + severity: 'error', + message: 'Partner deals over 10,000 need a direct contract', + condition: "record.account.type == 'partner' && record.amount > 10000", + events: ['insert', 'update'], +} +``` + +エンジンは、条件が名前を挙げた関連フィールドだけを、参照フィールドごとに 1 回のまとめた読み取りで読み込み、たどるルールがなければ何も読みません。2 ホップ目(`record.account.owner.email`)は拒否され、たどりながら参照の ID を比較することも拒否されます。フィールドレベルの述語と行レベルセキュリティは、レコード自身のフィールドにとどまります。 + +ルールが見るのは `record` と `previous` だけで、それ以外は見えません。誰が保存しているかを尋ねることはできません。`current_user.can(...)` に答えるのは別の場所で([CEL → 権限チェック](/docs/reference/cel#permission-checks)を参照)、検証ルールの中では評価できないため、そのルールは実行されるすべての書き込みを拒否します。 + +## 一意性:ルールではなくインデックスを使う [#uniqueness-use-an-index-not-a-rule] + +`uniqueness` という検証の種類は**ありません**。意図的です。SELECT してから INSERT するチェックは本質的に競合します(TOCTOU)。データベースの一意制約はそうではありません。一意性はデータ層で強制してください。 + +```ts +// Keys of an object; its name and its other fields are omitted. +fields: { + // Field-level + email: Field.email({ label: 'Contact Email', unique: true }), +}, + +// Composite / scoped via indexes +indexes: [ + { fields: ['code', 'organization'], unique: true }, + // add `partial` for a scoped/conditional constraint +], +``` + +同じ理屈で、非同期/リモートの検証(クライアントのフォームの関心事であり、書き込み経路での SSRF/レイテンシーの危険)とカスタムハンドラーも除外されています。任意の検証コードは `beforeInsert` / `beforeUpdate` のライフサイクルフックに、削除時のガードは `beforeDelete` フックに置きます。 + +## カスタムのエラーメッセージ [#custom-error-messages] + +`message` は、ルールが発動したときにユーザーが目にするものです。フォームでも API のエラーレスポンスでも表示されます。行動につながるものにしてください。 + +| 弱い | 強い | +|---|---| +| "Invalid input" | "Close date is required for closed deals" | +| "Validation failed" | "Phone must match format: +1-XXX-XXX-XXXX" | +| "Error" | "Discount cannot exceed total" | + +`severity` で調整します。`error` は保存をブロックし、`warning` はメッセージを表示しつつ保存を通し、`info` は純粋に情報提供です。 + +> **警告:** 述語を評価できないルール(解析エラー、綴りを間違えたフィールド、比較できない値)は**安全側に失敗**します。書き込みは `VALIDATION_FAILED` で拒否され、ルールをスキップして保存されることは決してありません。新しいルールは、公開する前に実際のレコードでテストしてください。 + +## ベストプラクティス [#best-practices] + +| すること | しないこと | +|---|---| +| できるだけ低い層で検証する(フィールド → ルール → フック) | フィールドの修飾子で表現できることにフックを使う | +| 明確で行動につながるメッセージを書く | 同じチェックを複数の層で重複させる | +| `priority` を使って安価な形式チェックを先に実行する | 複雑すぎる条件を作る | +| 実際のデータでルールをテストする | 正当なエッジケースをブロックする | + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|---|---| +| [データモデル](/docs/build/data) | これらのルールが守るオブジェクトとフィールド | +| [数式](/docs/build/data/formulas) | `condition` と `requiredWhen` の背後にある CEL 言語 | +| [リレーションシップ](/docs/build/data/relationships) | オブジェクトをまたぐ参照整合性 | +| [CEL リファレンス](/docs/reference/cel) | 条件のための演算子と標準ライブラリの完全なリファレンス | +| [フィールドの種類](/docs/reference/field-types) | フィールドの種類ごとの組み込みの検証のデフォルト | +| [フロー](/docs/build/automation/flows) | (有効な)レコードの変更に自動化で反応する | diff --git a/content/docs/build/extend-existing-systems.ja.mdx b/content/docs/build/extend-existing-systems.ja.mdx new file mode 100644 index 00000000..649b53f9 --- /dev/null +++ b/content/docs/build/extend-existing-systems.ja.mdx @@ -0,0 +1,62 @@ +--- +title: 既存システムの拡張 +description: すでに運用しているデータベースを外部データソースとして ObjectOS にフェデレーションし(デフォルトで読み取り専用、まだ初期段階)、マイグレーションなしでそのテーブルをオブジェクトとしてモデル化します。 +editions: [community, cloud, enterprise] +translation: + source_sha: f2aed087dbf4f5abe0d1db987fa124702e063509e62d3f866137fa20c45c87c5 + guide_rev: 1 + mode: auto +--- + +ObjectOS を評価しているチームの多くは、すでに記録システムを持っています。CRM、ERP、チケット管理ツール、本番の SQL や MongoDB データベース上で動く自社製のバックオフィスなどです。問われるのは「捨てて作り直すべきか?」であることはまれです。「危険なマイグレーションなしで、すでにあるものと一緒に使えるか?」です。 + +まずひとつの境界を示します。このページが約束できる範囲を決めるものだからです。ObjectOS が実行するオントロジーは**既存システムの上に載るセマンティックレイヤーではありません**。それ自体がシステムであり、データベース、API、UI、エージェントのツールはそこから導出されます。すでに運用しているシステムに対して提供できるものはそれより狭く、[ObjectStack](https://github.com/objectstack-ai/objectstack) の README が述べるとおりに表現すると、**外部データソースのフェデレーションはデフォルトで読み取り専用で、まだ初期段階です。** データベースをデータソースとして接続し、関心のあるテーブルをオブジェクトとしてモデル化すると、ObjectOS は他のすべてと同じ権限モデルを通じてそれを読み取ります。記録システムは記録システムのままです。 + +## 移行の形 [#the-shape-of-the-move] + +業務システムを置き換えるのではありません。ObjectOS をその*隣に*置き、同じデータベースに向けます。 + +1. 既存のデータベースを外部[データソース](/docs/configure/data-sources)として**接続**します(`schemaMode: 'external'`)。ObjectOS はそれに対して DDL を実行せず、オプトインするまで書き込みはオフのままです。パスワードはシークレットストアに保存され、データソースには決して置かれません。読み取りしかできないデータベースユーザーで接続し、書き込みは意図して有効にしてください。 +2. テーブルをオブジェクトとして**モデル化**します。手で行うか、コーディングエージェントにスキーマをスキャンさせ、ソースレベルのオブジェクトファイルを生成させます。 +3. 各オブジェクトをデータソースに**バインド**します(オブジェクトごと、または名前空間全体のルーティングルールで)。 +4. **読み取り**ます。テーブルがオブジェクトになった瞬間から、すべてのビュー、ダッシュボード、フロー、エージェントのツールがそれを読み取れ、正しいデータベースに自動的にルーティングされ、同じ権限で制御されます。 + +レガシーアプリケーションは何も変わりません。行は元の場所にとどまり、レガシーアプリケーションはそれを書き込み続けます。 + +## 書き直しなしで機能する理由 [#why-this-works-without-a-rewrite] + +| 懸念 | ObjectOS の対応 | +|---|---| +| 「データを移動できない」 | データは決して移動しません。ObjectOS はデータベースにその場で接続します。 | +| 「本番への書き込みのリスクは取れない」 | フェデレーションはデフォルトで読み取り専用です。書き込みには、データソースでのオプトイン(`external.allowWrites: true`)**と**各オブジェクトでのオプトイン(`external.writable: true`)が必要です。`SELECT` だけを許可されたデータベースユーザーは、ObjectOS の内外を問わず、すべての経路でそのとおりに保たれます。 | +| 「すべてのテーブルのモデル化には何週間もかかる」 | コーディングエージェントがスキーマをスキャンし、テーブルごとにオブジェクトファイルを 1 つ生成します。あなたは確認して仕上げるだけで、手で打ち込む必要はありません。 | +| 「AI にデータを任せられない」 | エージェントは**サインインしたユーザー**として実行され、オブジェクトレベル、レコードレベル、フィールドレベルの権限に従います。背後にいる人以上のものを見ることはありません。 | +| 「データをネットワークの外に出せない」 | ObjectOS Enterprise では、ObjectOS はあなたの環境で動作します。業務データとプロンプトは境界の内側にとどまります。 | + +## コーディングエージェントでオブジェクトを生成する [#generating-objects-with-a-coding-agent] + +既存のスキーマを取り込む最速の方法は、コーディングエージェント(Claude Code など)を使って**業務テーブルをスキャンし、ソースレベルのオブジェクト定義を生成する**ことです。テーブルごとに `*.object.ts` ファイルを 1 つ作ります。 + +[`hotcrm`](https://github.com/objectstack-ai/hotcrm) リファレンスアプリは、その出力が取るべき正確な形を示しています。各テーブルは `src/objects/.object.ts` になり、型付きの `Field.*` 定義と外部キー用の `Field.lookup(...)` を使った `ObjectSchema.create({ … })` で表され、`defineStack` で組み立てられます。エージェントは接続されたデータベースをイントロスペクトし、列をフィールドの型に対応付け、あなたが所有しコミットするオブジェクトを書きます。合うものは残し、公開したくない列は削除し、その上にラベル、検証、権限を追加します。 + +完全な手順の作成ガイドは[データソース](/docs/configure/data-sources)を参照してください。 + +## 初日から得られるもの [#what-you-get-on-day-one] + +テーブルが既存のデータベースにバインドされたオブジェクトとしてモデル化されると、次のものが得られます。 + +- **自然言語による分析。** ユーザーは実際のレコードについて質問します。「今四半期に遅れた商談はどれで、担当者は誰か?」。答えは ObjectQL を通じてライブデータに対して計算されます。 +- **ガバナンスの効いた自動化。** フローとアクションは同じデータを読み取り、書き込みを有効にしている場合は書き込みます。すべてのステップが監査されます。 +- **生成された API と UI。** REST エンドポイントと管理画面は同じメタデータから生まれます。 +- **ひとつの権限モデル。** 人に適用される境界が、AI のトラフィックにもまったく同じように適用されます。 + +## まだできないこと [#what-it-is-not-yet] + +フェデレーションは初期段階です。成熟しているのは読み取りの経路だと考え、各データソースプロトコルが現在何をサポートしているかは、[データソース](/docs/configure/data-sources)とフレームワーク自身の [External Datasources](https://objectstack.ai/docs/data-modeling/external-datasources) ガイドで確認してください。多くのシステムを、そのどれも実行せずに*記述する*セマンティックレイヤーが必要なら、それは別の種類の製品です。 + +## ここから始める [#start-here] + +- [データソース](/docs/configure/data-sources):データベースを接続し、オブジェクトをバインドし、クエリをルーティングする +- [AI エージェント](/docs/build/agents):オブジェクトの上の宣言的なエージェント +- [権限](/docs/configure/permissions):AI が継承するモデル +- [ObjectOS Community をローカルで実行する](/docs/start/community):オープンなランタイムを数分で立ち上げる diff --git a/content/docs/build/index.ja.mdx b/content/docs/build/index.ja.mdx new file mode 100644 index 00000000..ac3638f6 --- /dev/null +++ b/content/docs/build/index.ja.mdx @@ -0,0 +1,53 @@ +--- +title: 構築 +seoTitle: "アプリを構築する:AI チャット、Studio のフォーム、テンプレート" +description: ObjectOS でアプリが形になる方法。AI とのチャット、Studio のフォームをクリックして進める方法、テンプレートのフォーク。 +editions: [community, cloud, enterprise] +translation: + source_sha: a4c1dc21dbbfcf386995724063bb871474f3f5487259af000a7779e9e21c4916 + guide_rev: 1 + mode: auto +--- + +ObjectOS で構築するとは、[オントロジー](/docs/ontology)を作成することです。その中核(オブジェクトとフィールド、リレーション、アクション、権限、フロー、エージェント)と、人がそれを見るための投影(ビュー、ダッシュボード、アプリ)です。ほとんどの人はそのメタデータを手で書きません。**欲しいものを説明すると、AI Builder がそれを書きます。** 道は 3 つあり、実際に多くの人がプラットフォームを使う順に並べると次のとおりです。 + +| 道 | 対象 | 成果物 | +|---|---|---| +| **[AI Builder](/docs/build/ai-builder)**(主要) | 業務ユーザー、プロダクトオーナー、誰でも | テナント内の稼働中のメタデータ。承認待ちのキューに入ります | +| **クリックで構築**(代替) | Studio のフォームを好む管理者 | 同じ稼働中のメタデータ | +| **[テンプレートのフォーク](/docs/build/templates)**(開発者向け) | コードをソース管理下に置きたいエンジニア | マーケットプレイス経由で配布される TypeScript パッケージ | + +3 つの道はすべて**同じオントロジー**を作成します。同じオブジェクト、フィールド、リレーション、アクション、権限、フローと、同じ投影です。そしてどの道も、何かが実行される前に同じ検証ゲートを通ります。`@objectstack/spec` のスキーマと `os validate` が実行する作成ルールで、ランタイムは AI Builder や Studio からのすべてのメタデータ書き込みにもそれを適用します。ObjectStack のドキュメントの [Validating Metadata](https://objectstack.ai/docs/deployment/validating-metadata) を参照してください。 + +## プラットフォームの語彙 [#the-platform-vocabulary] + +すべてのアプリは同じプリミティブから組み立てられます。一度覚えれば十分です。 + +| 概念 | 内容 | ドキュメント | +|---|---|---| +| **[パッケージ](/docs/build/packages)** | 組織化の単位。`com.acme.crm` のように、バージョン付きでインストール可能 | パッケージ | +| **[データモデル](/docs/build/data)** | オブジェクト + フィールド + リレーションシップ + ステートマシン | データモデル | +| **[インターフェース](/docs/build/interface)** | アプリ、ビュー、フォーム、ダッシュボード、ページ。ユーザーが目にするもの | インターフェース | +| **[アクション](/docs/build/interface/actions)** | 名前付きの操作。REST やボタンから呼び出せ、公開すれば AI エージェントからも呼び出せます。フローを開始することもできます | アクション | +| **[権限](/docs/configure/permissions)** | 誰が何を読み取り、作成し、編集し、削除できるか。権限セット、レコードアクセス、フィールドレベルセキュリティ | 権限 | +| **[フロー](/docs/build/automation/flows)** | 宣言的なビジネスロジック(自動起動 / スケジュール / 手動) | フロー | +| **[エージェント](/docs/build/agents)** | エンドユーザー向けの AI アシスタント。Agent → Skill → Tool | エージェント | +| **[マーケットプレイス](/docs/build/marketplace)** | 誰でもワンクリックでインストールできる出来合いのパッケージ | マーケットプレイス | + +## どこから始めるか [#where-to-start] + +1. **まずは試してみたい?** → ObjectOS を開き、[AI Builder](/docs/build/ai-builder) を起動して、*「サポートチケットを優先度、ステータス、担当者付きで管理したい」* と伝えます。30 秒足らずで動作するオブジェクトができます。 + +2. **その上で本物の製品を構築する?** → [パッケージ](/docs/build/packages)と[データモデル](/docs/build/data)を読んで、AI が何を生成しているかを理解してください。AI で下書きし、手またはチャットで仕上げ、結果をコミットします。 + +3. **Salesforce/Retool のアプリを置き換える?** → 最も近い[マーケットプレイスのテンプレート](/docs/build/marketplace)をフォークし、不要なものを取り除き、足りない部分を AI に埋めてもらいます。 + +## 決してしなくてよいこと [#what-youll-never-have-to-do] + +- REST エンドポイントを書く。プラットフォームがオブジェクトごとに生成します。 +- Studio のフォームを配線する。フォームはスキーマからレンダリングされます。 +- RBAC のチェックを書く。権限はコードではなく宣言です。 +- 管理 UI を構築する。Setup と Account がシステム管理とサインインを担います。 +- 型を生成する。`@objectstack/spec` がスキーマから導出します。 + +ObjectOS はこの点について意図的にこだわりを持っています。**メタデータが存在すれば、あらゆるサーフェス(REST、UI、ObjectQL、監査、AI ツール)が無償で現れます。** だからこそ AI Builder は機能します。メタデータを生成するだけでよく、残りはプラットフォームが処理します。 diff --git a/content/docs/build/marketplace.ja.mdx b/content/docs/build/marketplace.ja.mdx new file mode 100644 index 00000000..1c70423d --- /dev/null +++ b/content/docs/build/marketplace.ja.mdx @@ -0,0 +1,140 @@ +--- +title: マーケットプレイス +seoTitle: "マーケットプレイス:出来合いのアプリをインストールし、公開する" +description: コードを書かずに、稼働中の ObjectOS に出来合いのアプリをインストールします。 +editions: [community, cloud, enterprise] +translation: + source_sha: 5fb581df2aa59854381bb78c50049fa3203dd3c3ded12d2e39d88d4997ff7e68 + guide_rev: 1 + mode: auto +--- + +ObjectOS のマーケットプレイスを使うと、構築済みのアプリを稼働中のランタイムにインストールできます。ビルドのステップも、再起動も、ソースのチェックアウトもありません。初日から本物のソフトウェアをユーザーの前に置く最速の方法です。 + +## 仕組み [#how-it-works] + +マーケットプレイスのクライアントは、オープンソースの `@objectstack/cloud-connection` パッケージです。クラウド管理の環境とセルフホストの ObjectOS イメージには、最初から配線された状態で付属します。自分で組み立てるスタックでは、数行の設定で有効にします。 + +```ts +import { + MarketplaceProxyPlugin, + MarketplaceInstallLocalPlugin, + RuntimeConfigPlugin, + resolveCloudUrl, +} from '@objectstack/cloud-connection'; + +const catalogUrl = resolveCloudUrl(); // OS_CLOUD_URL; 'off' disables + +plugins: [ + ...(catalogUrl ? [ + new MarketplaceProxyPlugin({ controlPlaneUrl: catalogUrl }), + new MarketplaceInstallLocalPlugin({ controlPlaneUrl: catalogUrl }), + ] : []), + new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, installLocal: true }), +] +``` + +閲覧とインストールの*仕組み*はオープンです。カタログの*サービス*(組織のカタログ、レビュー、有料配布)は、`OS_CLOUD_URL` が指すコントロールプレーンが提供します。アプリはインストールされるとランタイム自身のカーネルに置かれ、実行時に何もカタログへの到達性に依存しません。 + +ObjectOS(`/_console/`)を開くと、マーケットプレイスのタブが設定されたアプリカタログを問い合わせ、インストール可能なアプリを表示します。 + +```text +You ─→ ObjectOS ─→ Marketplace tab ─→ pick app ─→ Install + ↓ + Artifact merged into kernel + ↓ + The page re-renders with new + objects / views / permissions + ↓ + Done — no restart +``` + +## カタログ [#catalogs] + +| カタログ | ソース | 使うとき | +|---|---|---| +| **Default** | ObjectOS のランタイムイメージにあらかじめ同梱 | 初めての評価、デモ、オフライン | +| **ObjectStack 公開カタログ** | 公開のアプリレジストリ | 最新のコミュニティ製とファーストパーティのアプリ | +| **プライベートカタログ** | 自分で公開した成果物 | 公開したくない社内アプリ | +| **Local** | ランタイムにマウントしたファイル | エアギャップ、カスタムビルド | + +カタログのソースは、マーケットプレイスのプラグインまたは環境変数で設定します。[ランタイム設定](/docs/configure/runtime)を参照してください。 + +## デフォルトカタログの内容 [#whats-in-the-default-catalog] + +次のアプリはワンクリックでインストールできます。 + +| アプリ | 得られるもの | +|---|---| +| **Todo** | 汎用のタスクとプロジェクトの管理 | +| **Contracts** | AI による条項抽出付きの契約ライフサイクル管理(CLM) | +| **Procurement** | ベンダー、発注書、3 点照合 | +| **Compliance** | SOC 2 / ISO 27001 の統制 + 証跡の収集 | +| **Helpdesk** | AI ファーストのカスタマーサポートのチケット管理 | +| **Content** | 編集カレンダー + チャネル ROI | +| **HR** | 名簿、組織図、休暇 | +| **Project** | プロジェクト / タスク / マイルストーンの管理 | + +ソースは [github.com/objectstack-ai/templates](https://github.com/objectstack-ai/templates) にあります。どれでもクローンして、カスタムアプリの出発点にできます。 + +## インストールの流れ [#install-flow] + +1. **ObjectOS を開きます**:http://localhost:3000/_console/ +2. **サインインします**:まだアカウントがない場合は `/_console/register` で登録します +3. **マーケットプレイスのタブに移動します** +4. **アプリを選び、Install をクリックします** +5. **ページを再読み込みします**:新しいアプリのオブジェクト、ビュー、フローが表示されます + +裏では、マーケットプレイスがアプリのコンパイル済み成果物を取得し、稼働中のカーネルにマージし、そのオブジェクトを ObjectQL に登録します。シードデータは(アプリが定義していれば)最初のインストール時に挿入されます。 + +## アンインストール [#uninstall] + +**Marketplace → インストール済みのアプリ → Uninstall** から行います。アプリのオブジェクトはカーネルから削除され、テーブルはクリーンアップの対象として印が付けられます(データはデフォルトで保持され、テーブルを削除するかどうかは選べます)。 + +## 自分のアプリを公開する [#publishing-your-own-app] + +`os init` で構築したものは、どれでもマーケットプレイスのアプリにできます。 + +```bash +os init my-app -t app --install +cd my-app +# ... write objects, views, etc. ... +os compile # → dist/objectstack.json +os package publish # publish to a catalog +``` + +公開カタログに公開するには、レジストリのアカウントが必要です(`os login`)。プライベートカタログに公開するには、`OS_PACKAGE_REGISTRY` を自分のものに向けます。 + +## バージョニング [#versioning] + +公開されたアプリはすべて不変です。更新すると新しいバージョンができます。ランタイムはアプリごとに、インストール済みのバージョンと利用可能な更新を追跡します。追跡しているカタログに新しいバージョンが公開されると、ユーザーには「Update available」のバッジが表示されます。 + +## CLI からインストールする [#installing-from-the-cli] + +マーケットプレイスのタブが主要なインストールのサーフェスですが、同じ install-local エンドポイントは CI やエアギャップ運用のためにスクリプトから使えます。 + +```bash +# Catalog mode — the runtime resolves the package from its configured catalog +os package install com.acme.crm --runtime http://localhost:3000 \ + --email admin@example.com --password … + +# Air-gapped mode — send a compiled artifact inline, no catalog round-trip +os package install ./dist/objectstack.json --runtime http://localhost:3000 \ + --email admin@example.com --password … +``` + +認証情報は**対象のランタイム**のアカウントです(クラウドのログインではありません)。アプリ内のインストールが使うのと同じ認可です。 + +## 権限 [#permissions] + +アプリのインストールには `manage_marketplace` システム権限が必要です。デフォルトでは **Setup Administrator** 権限セットのメンバーだけがそれを持ちます。通常のユーザーにはマーケットプレイスは読み取り専用で表示されます。 + +## エアギャップのマーケットプレイス [#air-gapped-marketplaces] + +インターネットへの出口がないデプロイでは、ネットワーク内でローカルのカタログサーバーを実行し、ObjectOS をそこに向けます。構成は[エアギャップ](/docs/deploy/air-gapped)を参照してください。 + +## マーケットプレイスではないもの [#what-the-marketplace-is-not] + +- **コードの配布チャネルではありません。** 公開されたアプリはコンパイル済みの成果物で、データ + UI + フローを宣言的に記述します。任意の JavaScript は含みません。 +- **サンドボックスではありません。** インストールされたアプリは、カーネル内の他のものと同じようにデータベースにアクセスできます。信頼できないソースのアプリをインストールする前に、成果物を確認してください。 +- **決済プラットフォームではありません**(今のところ)。公開カタログのアプリは無料の Apache-2.0 です。商用 / 有料の配布はロードマップにあります。 diff --git a/content/docs/build/packages.ja.mdx b/content/docs/build/packages.ja.mdx new file mode 100644 index 00000000..65a825f4 --- /dev/null +++ b/content/docs/build/packages.ja.mdx @@ -0,0 +1,164 @@ +--- +title: パッケージ +seoTitle: "パッケージ:作成、バージョン管理、公開、インストール" +description: ObjectOS における組織化の単位。バージョン付きで、インストール可能で、共有できます。 +editions: [community, cloud, enterprise] +translation: + source_sha: d23055ede31061fdd880d75901375cee4b7968fd7ac57a3a993feae4c88d955c + guide_rev: 1 + mode: auto +--- + +ObjectOS で構築するものはすべて**パッケージ**に置かれます。パッケージは、バージョン付きで自己完結したメタデータの束です。パッケージは、AI Builder が作業する単位であり、マーケットプレイスが配布する単位であり、ObjectOS が更新を追跡する単位です。 + +## パッケージの中身 [#whats-in-a-package] + +```text +com.acme.crm@1.2.0 +├── manifest id, version, namespace, dependencies +├── objects/ *.object.ts, *.state.ts, *.hook.ts +├── views/ *.view.ts, *.page.ts, *.form.ts +├── actions/ *.action.ts +├── flows/ *.flow.ts, *.approval.ts +├── agents/ *.agent.ts, *.skill.ts +├── permissions/ *.permission.ts +├── sharing/ *.sharing.ts +├── translations/ en.ts, zh-CN.ts, ... +├── apps/ *.app.ts (navigation) +└── data/ defineDataset(...) seed +``` + +すべてのメタデータの成果物(オブジェクト、アクション、フローなど)は、ちょうどひとつのパッケージに属します。パッケージ ID は**逆 DNS 形式**(`com.acme.crm`、`org.mycompany.helpdesk`)で、名前空間のプレフィックス(`crm_`、`hd_`)は、同じテナントにインストールされた複数のパッケージ間でテーブル名やオブジェクト名が衝突するのを防ぎます。 + +## パッケージを作成する [#creating-a-package] + +### AI Builder から [#from-the-ai-builder] + +> *「社内 CRM 用の新しいパッケージを、名前空間 `crm` で始めて。」* + +AI は `create_package` と `set_active_package` を呼び出します。それ以降、説明するすべてのオブジェクト / フィールド / アクションは `crm` に置かれます。 + +### CLI から [#from-the-cli] + +```bash +os init my-crm -t app +cd my-crm +# manifest lives in the `manifest:` block of ./objectstack.config.ts +pnpm dev +``` + +### UI から [#from-the-ui] + +**Studio → Packages → New Package**:名前、ID、バージョン、名前空間を指定します。 + +## アクティブなパッケージ [#active-package] + +AI Builder では、会話が**アクティブなパッケージ**を持ちます。新しいメタデータのデフォルトの入れ物です。*「`com.acme.helpdesk` に切り替えて」* のように言うと、AI は `set_active_package` ツールでそれを設定します。CLI では、アクティブなパッケージは単純にプロジェクトの `objectstack.config.ts`(`manifest:` ブロック)で宣言されたものです。 + +## バージョニング [#versioning] + +パッケージは **semver** です。ルールはランタイムと同じです。[変更履歴とバージョニング](/docs/resources/changelog)を参照してください。 + +バージョンを上げても、公開するまでは何も変わりません。ランタイムはテナントごと、パッケージごとに `installed_version` を追跡し、カタログに新しいバージョンがあると、マーケットプレイスに *"Update available"* のバッジが表示されます。 + +## 依存関係 [#dependencies] + +パッケージは他のパッケージに依存できます。 + +```json +{ + "id": "com.acme.helpdesk", + "version": "0.3.0", + "dependencies": { + "com.acme.crm": "^1.0.0", + "sys.feeds": "*" + } +} +``` + +ランタイムはインストール時に依存関係を解決します。`com.acme.crm` がインストールされていない場合、helpdesk のインストールは明確なエラーで失敗します。 + +システムパッケージ(`sys.*`)は常に存在します。フィード、添付ファイル、監査、ID などです。 + +## 公開 [#publishing] + +```bash +os cloud login # once: stores a cloud token in ~/.objectstack/cloud.json +os compile # → dist/objectstack.json +os package publish # → ObjectStack cloud catalog +``` + +`os package publish` は、コンパイル済みのマニフェストを `OS_CLOUD_URL` が指すカタログにアップロードします。認証は `os cloud login`(対話型)か、CI では `OS_CLOUD_API_KEY` の Bearer トークンから得られます。プライベート / エアギャップの配布では、公開を省略し、コンパイル済みの `dist/objectstack.json` を対象のインストールに直接渡します(下の「インストール」を参照)。[マーケットプレイス](/docs/build/marketplace)を参照してください。 + +> **この 2 つの変数は、ビルドマシンの公開用 ID を設定するものです**。パッケージカタログに対する CLI の認証情報です。*デプロイ*がコントロールプレーンに接続する方法ではありません。稼働中のデプロイは、バインドされたときに発行されたトークンで認証し、設定すべき API キーはありません。どちらかの名前をデプロイの設定にコピーする前に、[環境変数](/docs/reference/environment-variables)を確認してください。 + +## ObjectOS Cloud での 2 つの開発者向けの道 [#two-developer-routes-on-objectos-cloud] + +開発者がホスト型の環境に変更を入れる方法は 2 つあります。 + +| | パッケージの公開 | MCP での構築 | +|---|---|---| +| 方法 | ソースで構築し、`os package publish` してから **Marketplace** でインストールする | `metadata:write` スコープで [MCP](/docs/configure/mcp#build-with-an-agent) 経由で接続した自分のエージェントが、環境内で構築する | +| 届くもの | **バージョン付き**のパッケージ。丸ごとインストールされる | 環境内の**ドラフト**。人が Studio → **Changes** で公開する | +| 構築できるもの | 環境が実行するすべてのもの。スクリプト、Webhook、API でトリガーされるフローを含む | 環境の AI 構築が提供するもの:データモデル、インターフェース、サンプルデータ、アプリの外枠、レコードやボタンで開始するフロー、ノーコードのアクション | +| 最適な用途 | リリース:複数の環境での同じバージョン、リポジトリでのレビュー、Marketplace | 反復:ひとつの環境で変更を試し、アプリで確認する | + +アプリを素早く形にするには MCP での構築を使い、バージョンが必要になったらパッケージにします。別の環境や組織に出荷するとき、リポジトリで保持するとき、MCP での構築が提供しないメタデータを運ぶときです。 + +## インストール [#installing] + +インストールは**環境内の**操作で、公開とは別のものです。パッケージは(カタログに)一度公開し、その後、対象の環境の*中から*インストールします。インストールはその環境へのログインによって確認され、認可されます。インストールするのに環境 ID を知る必要はありません。 + +| 方法 | 手順 | +|---|---| +| UI で(推奨) | 環境にサインイン → **Marketplace** → パッケージを選択 → **Install**。現在の環境にインストールされます。 | +| CLI → 稼働中のランタイム | `os package install com.acme.crm --runtime `:ランタイムが設定されたカタログからパッケージを解決し、自身のカーネルにマージします | +| CLI でエアギャップ | `os package install ./dist/objectstack.json --runtime `:コンパイル済みの成果物をインラインで送り、カタログとの往復はありません | +| REST | `POST /api/v1/marketplace/install-local`(ボディ:`{ packageId, versionId? }` または `{ manifest }`) | +| エアギャップ(起動時) | コンパイル済みの `dist/objectstack.json` 成果物をマウントします([エアギャップ](/docs/deploy/air-gapped)を参照) | + +インストールコマンドのパッケージ ID は、安定したマニフェストの ID(`com.acme.crm`)です。内部の `pkg_…` の行 ID は必要ありません。 + +> **CI のショートカット。** *既知の*環境にデプロイするパイプラインは、公開とインストールを 1 ステップで行えます:`os package publish --env --install [--seed-sample-data]`。これは自動化のための便宜であり、人が使う方法ではありません。対話的なインストールは環境の Marketplace で行います。このステップは全部か無かです。インストールが拒否されると何も公開されず、バージョンは保存されず、コマンドはゼロ以外で終了するので、パイプラインは失敗を検知できます。 + +インストールはパッケージのメタデータを稼働中のカーネルにマージし、そのオブジェクトを ObjectQL に登録し、最初のインストール時に初期データを投入します。再起動は不要です。キャッシュされたインストールは次回の起動時に再登録されるので、プロセスの再起動後も残ります。 + +## アンインストール [#uninstalling] + +アンインストールすると、パッケージのキャッシュされたマニフェストが削除され、次回の起動時に再登録されなくなります。オブジェクトの登録は追加的なので、すでに稼働しているパッケージのオブジェクトを完全にアンロードするには**カーネルの再起動**が必要です。データはデフォルトで**保持**されるので、再インストールして再開できます。 + +## パッケージ間の規約 [#cross-package-conventions] + +多くのパッケージが共存するときの衝突を防ぐために: + +| 成果物 | 規約 | +|---|---| +| オブジェクト名 | 常にプレフィックス付き:`crm_account`、`hd_ticket` | +| アクション名 | プレフィックス付き:`crm_assign_owner`、`hd_close_ticket` | +| フロー名 | プレフィックス付き:`hd_overdue_alert` | +| 翻訳キー | 名前空間の下にスコープ:`crm.account.label` | +| シードの `externalId` | `:`、例:`crm:demo-account-1` | +| システム名 | 予約済み:`sys_*`(カスタムパッケージでは決して使わない) | + +CLI と AI Builder はどちらも、作成時にこれを強制します。 + +## システムパッケージ [#system-packages] + +ランタイムには、ポリモーフィックなサービスを提供する、常に存在する少数のパッケージが付属します。 + +| パッケージ | 提供するもの | +|---|---| +| `sys.identity` | `sys_user`、`sys_organization`、`sys_member`、セッション、API キー | +| `sys.feeds` | `sys_comment`、`sys_activity`、`sys_attachment` | +| `sys.audit` | `sys_audit_log` | +| `sys.files` | `sys_file` | +| `sys.ai` | `ai_conversations`、`ai_pending_actions` | +| `sys.settings` | `sys_setting` | + +オブジェクトで `enable: { feeds: true, trackHistory: true, … }` を指定して有効にします。[データモデル](/docs/build/data)を参照してください。 + +## 次に進む [#where-to-go-next] + +- [マーケットプレイス](/docs/build/marketplace):パッケージを他のテナントに配布する +- [データモデル](/docs/build/data):パッケージの中に入るもの +- [`os package` コマンド](/docs/reference/cli):完全な CLI リファレンス diff --git a/content/docs/build/reports.ja.mdx b/content/docs/build/reports.ja.mdx new file mode 100644 index 00000000..efb7965c --- /dev/null +++ b/content/docs/build/reports.ja.mdx @@ -0,0 +1,164 @@ +--- +title: レポート +seoTitle: "レポートを構築する:データセット上のサマリー、マトリックス、結合レポート" +description: ダッシュボードと同じデータセットにバインドされた、グループ化レポートとクロス集計レポート。任意のグラフと、すべての数値の裏にあるレコードへのドリルスルー付きです。 +editions: [community, cloud, enterprise] +translation: + source_sha: b9e6f81d52e7e18ec7dd039e188135dbeea353e30700f37dbc327be0018f9a52 + guide_rev: 1 + mode: auto +--- + +{/* + Platform checklist items this page documents (objectstack + docs/qa/platform-checklist/areas/dashboards.json): + dashboards.dataset-report-authoring, dashboards.drill-through-range, + dashboards.analytics-exposure-gate-and-measure-aggregate. + + Deliberately not written: saved personal reports and scheduled report + delivery. dashboards.saved-report-ownership and + dashboards.report-schedule-dispatch-delivery are retired — the saved-report + stack was removed (objectstack #20102), and `subscription` / `schedule` on a + report are refused because nothing delivers a report on a timetable. The + report `dateRange` and `exportOptions` keys (objectstack #26548) are not + documented yet either: the spec accepts them, but the report page does not + render them. +*/} + +**レポートは、データセットにバインドされた数値の表です。** データセットのメジャーをディメンションでグループ化し(行方向に、マトリックスでは列方向にも)、同じ数値のグラフを表の上に描くことができます。レポートはデータセットからメジャーを*名前で*選ぶので、レポートの「売上」は[ダッシュボード](/docs/build/interface/dashboards)が表示するのと同じ売上です。 + +## データセットから始める [#start-from-a-dataset] + +レポートとダッシュボードは、ひとつのセマンティックレイヤーを共有します。**データセット**が、ベースのオブジェクト、結合するリレーションシップ、グループ化に使えるディメンション、表示できるメジャーを持ちます。一度定義すれば([ダッシュボード → まずデータセット](/docs/build/interface/dashboards#datasets-first)を参照)、好きなだけのレポートとウィジェットをそれにバインドできます。 + +データセットが届かないものには、レポートも届きません。 + +- API がオフになっているオブジェクトはデータセットのベースにできません。クエリは拒否されます。 +- 内部フィールドは、ディメンション、メジャー、フィルターとして使えません。管理者でも同じです。 +- すべてのクエリは閲覧者の権限で実行されるため、レポートが閲覧者の開けないレコードを数えることはありません。 + +## レポートの種類 [#report-types] + +| `type` | 表示内容 | 選択するもの | +|---|---|---| +| `summary` | グループごとに 1 行、列にメジャー | `rows`(ディメンション)と `values`(メジャー) | +| `matrix` | クロス集計:ひとつのディメンションを縦に、別のものを横に、セルにメジャー | `rows`、`columns`、`values` | +| `joined` | 複数のサブレポートを 1 ページに積み重ね、それぞれを表として描画 | `blocks` の各エントリが独自に選択 | + +レコードの単純な一覧はレポートではありません。代わりにオブジェクトのリスト[ビュー](/docs/build/interface/views)として構築してください。 + +```ts +import { defineReport } from '@objectstack/spec/ui' + +// Estimated hours by task status. +export const hoursByStatus = defineReport({ + name: 'hours_by_status', + label: 'Hours by Status', + type: 'summary', + dataset: 'task_metrics', + rows: ['status'], + values: ['est_hours'], +}) + +// Status down, priority across. +export const statusByPriority = defineReport({ + name: 'status_by_priority', + label: 'Status × Priority', + type: 'matrix', + dataset: 'task_metrics', + rows: ['status'], + columns: ['priority'], + values: ['est_hours'], +}) +``` + +`summary` または `matrix` のレポートには、`dataset` と、`values` に少なくとも 1 つのメジャーが必要です。`rows`、`columns`、`values` が指すのはデータセットのディメンションとメジャーであり、オブジェクトの生のフィールドではありません。 + +### 結合レポート [#joined-reports] + +結合レポートは、独立した複数のサブレポートを 1 ページに置きます。たとえば「未完了の作業」の上に「完了した作業」を置くようなものです。各ブロックは独自のデータセットにバインドし、独自の行、列、値を選び、独自の `runtimeFilter` を持ちます。コンテナ自体は何も選択しません。結合レポートの `dataset`、`rows`、`columns`、`values`、`order`、`chart` は拒否され、`dataset` のないブロックは名前を示して拒否されます。ブロックの中に別の結合レポートを入れ子にすることはできません。 + +```ts +import { defineReport } from '@objectstack/spec/ui' + +export const taskOverview = defineReport({ + name: 'task_overview', + label: 'Task Overview', + type: 'joined', + blocks: [ + { name: 'open_block', label: 'Open Tasks', type: 'summary', + dataset: 'task_metrics', rows: ['status'], values: ['est_hours'], + runtimeFilter: { done: false } }, + { name: 'done_block', label: 'Completed Tasks', type: 'summary', + dataset: 'task_metrics', rows: ['status'], values: ['task_count'], + runtimeFilter: { done: true } }, + ], +}) +``` + +コンテナレベルの `runtimeFilter` はすべてのブロックに AND で結合されるので、「今四半期」のようなスコープは一度書くだけで済みます。 + +## フィルターと並べ替え [#filtering-and-ordering] + +- **`runtimeFilter`** は、データセット自身のフィルターに加えて、レポートが集計する行を絞り込みます。 +- **`order`** は結果を並べ替えます。最も重要なキーが先です。各キーはレポートが選択するディメンションまたはメジャーを指し、選択していないキーは作成時のエラーです。並べ替えはサーバー上で結果全体に対して行われます。日付のディメンションはすでに時系列順で返されるので、`order` が必要なのは、メジャーで並べ替えるとき(「大きい順」)、新しいものを先にするとき、または別のディメンションを並べるときだけです。 + +## グラフを追加する [#adding-a-chart] + +`summary` または `matrix` のレポートは、表の上(`columns` を持つマトリックスでは下)にグラフを描けます。グラフの軸は同じデータセットのディメンションとメジャーを指します。 + +```ts +import { defineReport } from '@objectstack/spec/ui' + +export const hoursByStatusChart = defineReport({ + name: 'hours_by_status_chart', + label: 'Hours by Status (Chart)', + type: 'summary', + dataset: 'task_metrics', + rows: ['status'], + values: ['est_hours'], + chart: { type: 'bar', xAxis: 'status', yAxis: 'est_hours' }, +}) +``` + +グラフは表が示すのと同じ数値をプロットします。結合レポートは表だけを描きます。 + +## ドリルスルー [#drill-through] + +集計されたすべての行やセルはクリックできます。その数値の裏にあるレコードを一覧表示するサイドドロワーが開き、グループに正確にスコープされます。日付のディメンションではそのバケットの期間、マトリックスのセルでは行と列の両方です。ドロワーからレコードをクリックして開くか、**Open in list →** でオブジェクトの完全なリストに進みます。`drilldown: false` でオフにできます。 + +一致するデータがないレポートは、壊れた表ではなく空の状態を表示します。 + +## レポートを人の前に出す [#putting-a-report-in-front-of-people] + +[アプリ](/docs/build/interface/apps)のナビゲーションに `report` 項目を追加します。 + +```ts +// One navigation item of an app. +{ id: 'nav_hours_by_status', type: 'report', label: 'Hours by Status', + reportName: 'hours_by_status', icon: 'sigma' } +``` + +レポートページには、レポートのラベルと説明、そしてレポートが表示されます。レポートは `⌘K` コマンドパレットの **Reports** の下にも一覧表示されます。[検索](/docs/use/search)を参照してください。 + +管理者にはレポートページに **Edit** ボタンが表示されます。サイドパネルでレポートの設定が開きます。変更はドラフトとして保存され、公開されるまで他の人には見えません。 + +ドラフトは不完全でもかまいません。まだ存在しないデータセット、メジャー、ディメンションを指していても許されます。公開時にそれらの参照がチェックされ、存在しないものを指すレポートは拒否されるので、公開されたレポートがエラーなしに空白で表示されることはありません。 + +## ダッシュボードかレポートか [#dashboards-or-reports] + +| 使うもの | とき | +|---|---| +| [ダッシュボード](/docs/build/interface/dashboards) | 複数の数値とグラフをひと目で、ダッシュボード全体の日付とフィルターのコントロール付きで | +| レポート | ひとつの問いへの完全な答え。1 行ずつ読むグループ化された表またはクロス集計と、そのグラフ | + +どちらも同じデータセットにバインドし、同じ方法でドリルスルーするので、数値はそれらの間を意味を変えずに行き来します。ダッシュボード全体の期間とグローバルフィルターは、[ダッシュボード → グローバルな期間とフィルター](/docs/build/interface/dashboards#global-date-range-and-filters)で説明しています。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|:--|:--| +| [ダッシュボード](/docs/build/interface/dashboards) | データセット、ウィジェット、グローバルフィルター | +| [ビュー](/docs/build/interface/views) | 単純なレコードの一覧と、リスト内のグラフ | +| [アプリ](/docs/build/interface/apps) | レポートをナビゲーションに置く | +| [権限](/docs/configure/permissions) | すべてのレポートクエリが適用する行レベルセキュリティ | diff --git a/content/docs/build/templates.ja.mdx b/content/docs/build/templates.ja.mdx new file mode 100644 index 00000000..98510551 --- /dev/null +++ b/content/docs/build/templates.ja.mdx @@ -0,0 +1,93 @@ +--- +title: テンプレート +seoTitle: "アプリテンプレート:フォークできる Helpdesk、Contracts、Todo" +description: フォークできるスターターパッケージ。`todo`、`contracts`、`procurement`、`helpdesk` など。 +editions: [community, cloud, enterprise] +translation: + source_sha: 463d4f0100a6c89aa409bc10e01a61663dd4d3c6937fe19376fbc7a0e24145f8 + guide_rev: 1 + mode: auto +--- + +テンプレートは**フォークできるスターターパッケージ**です。それぞれが本物の形を持ったアプリで、マーケットプレイスからワンクリックでインストールすることも、CLI でクローンして拡張用の TypeScript のコードベースを手に入れることもできます。 + +テンプレートが存在する理由は 2 つあります。 + +1. **説明するより見せる**:動作する複数オブジェクトのアプリは、ドキュメントより速く読めます。 +2. **エンジニアリングチームに出発点を与える**:データベース内の AI 生成メタデータだけでなく、コードをソース管理下に置きたいときのためです。 + +## デフォルトカタログに含まれるもの [#what-ships-in-the-default-catalog] + +| テンプレート | 領域 | フォークして作れるもの | +|---|---|---| +| **`todo`** | タスクとプロジェクト | 「何らかの形でグループ化されたやること」のツール全般 | +| **`contracts`** | AI による条項抽出付きの CLM | 承認 + ドキュメント AI | +| **`procurement`** | ベンダー、発注書、3 点照合 | 承認チェーン + 受領 | +| **`compliance`** | SOC 2 / ISO 27001 の統制 | 証跡収集のワークフロー | +| **`helpdesk`** | AI ファーストのカスタマーサポート | チケット + SLA + AI コパイロット | +| **`content`** | 編集カレンダー + チャネル ROI | ライフサイクル + メトリクス | +| **`hr`** | 名簿、組織図、休暇 | 人を中心としたアプリ | +| **`project`** | プロジェクト / タスク / マイルストーンの管理 | プロジェクト管理ツール | + +ソース:[github.com/objectstack-ai/templates](https://github.com/objectstack-ai/templates)。Apache-2.0 です。断りなくフォークしてください。 + +## 30 秒でインストール(コードなし) [#install-in-30-seconds-zero-code] + +ObjectOS で: + +1. **Marketplace** を開き → テンプレートを検索します。 +2. **Install** をクリックします。 +3. 更新します。新しいアプリがナビゲーションに表示されます。 + +完了です。レコード、ビュー、フロー、権限がすべて稼働します。 + +## コードベースとしてフォークする(エンジニア向け) [#fork-as-a-codebase-for-engineers] + +```bash +npm create objectstack@latest my-app +cd my-app +pnpm install +pnpm dev +``` + +スキャフォールダーはすべてのプロジェクトを `blank` テンプレートから始めます。コンテンツのテンプレートを名前で取得することはもうありません。ObjectStack 17.7 では、`--template todo` は廃止済みとして拒否されます。 + +プロジェクトは自己完結した pnpm パッケージです。編集、lint、テスト、コミットできる `*.object.ts`、`*.view.ts`、`*.flow.ts` ファイルです。満足したら: + +```bash +pnpm build +os package publish # → your marketplace +``` + +あなたのフォークをインストールする顧客は、**あなたの**ブランドと**あなたの**変更を受け取ります。 + +## テンプレート憲章(社会契約) [#template-charter-the-social-contract] + +すべてのテンプレートには、スコープと厳格な上限を宣言する `CHARTER.md` が付属します。典型的には、業務オブジェクトは 6 つ以下、`src/` 以下は 2,500 行以下、アプリは 1 つ、デフォルトロケールは 1 つです。憲章は、テンプレートが中途半端な製品ではなく**テンプレート**であり続けるために存在します。フォークがこれらの上限を超えて大きくなったら、既存のものを肥大化させず、新しいテンプレートとして出荷してください。 + +ファイルの接尾辞のプロトコル(`*.object.ts`、`*.state.ts`、`*.hook.ts` など)、作成の順序(オブジェクト → 状態 → フック → ビュー → フロー → …)、作り直す代わりに再利用すべきポリモーフィックなプラットフォームサービス(`sys_comment`、`sys_attachment`、`sys_audit_log`)については、上流の [TEMPLATE_GUIDE.md](https://github.com/objectstack-ai/templates/blob/main/TEMPLATE_GUIDE.md) を参照してください。 + +## 出発点を選ぶ [#pick-a-starting-point] + +| 構築しているもの | フォークするもの | 理由 | +|---|---|---| +| 「もの」を追跡する社内ツール全般 | `todo` | すべてのプリミティブを示す最小で最もきれいな例 | +| 承認チェーンのあるもの全般 | `procurement` または `contracts` | 本物の承認プロセスが配線済み | +| AI を使う顧客向けのもの全般 | `helpdesk` | エージェント + チケットのコパイロットのパターン | +| 規制業種のワークフロー | `compliance` | 監査 + 証跡 + 統制のマッピング | +| 人 / 組織図のアプリ | `hr` | 名簿 + レポートライン | + +## AI Builder とテンプレートを一緒に使う [#ai-builder--templates-together] + +テンプレートをフォークしても、その上で**引き続き** AI Builder を使えます。`helpdesk` をインストールしてから、こう伝えます。 + +> *「`hd_ticket` に、範囲 1〜5 の整数フィールド `csat_score` を追加して、クローズ時に入力を求めて。」* + +AI はインストールされたパッケージをその場で拡張します。変更はデプロイ内の稼働中のメタデータで、他の AI Builder の変更と同じように承認されます。それ自体でテンプレートのソースリポジトリに戻ることはありません。 + +## 次に進む [#where-to-go-next] + +- [AI Builder](/docs/build/ai-builder):任意のテンプレートをチャットで拡張する +- [パッケージ](/docs/build/packages):テンプレートの実体 +- [マーケットプレイス](/docs/build/marketplace):フォークを配布する +- [ObjectOS Community をローカルで実行する](/docs/start/community):開発者向けの道の端から端まで From 38384aa6999ebbd4de99bfe830150606f9e2b431 Mon Sep 17 00:00:00 2001 From: Steedos Date: Thu, 29 Oct 2026 11:01:47 +0000 Subject: [PATCH 3/6] docs(i18n): translate build/automation/ and build/interface/ into ja (#388, PR 2 of 7) Adds ja translations for the 11 automation and interface pages: build/automation/{index,flows,workflows,approvals} and build/interface/{index,apps,views,forms,dashboards,pages,actions}. Both sections' meta.ja.json already match the English pages list. Refs #388 Claude-Session: https://claude.ai/code/session_01QsR2jRsjbApbf83iyTDDXm Co-Authored-By: Claude --- .../docs/build/automation/approvals.ja.mdx | 279 ++++++++++++ content/docs/build/automation/flows.ja.mdx | 404 ++++++++++++++++++ content/docs/build/automation/index.ja.mdx | 43 ++ .../docs/build/automation/workflows.ja.mdx | 181 ++++++++ content/docs/build/interface/actions.ja.mdx | 208 +++++++++ content/docs/build/interface/apps.ja.mdx | 137 ++++++ .../docs/build/interface/dashboards.ja.mdx | 201 +++++++++ content/docs/build/interface/forms.ja.mdx | 175 ++++++++ content/docs/build/interface/index.ja.mdx | 49 +++ content/docs/build/interface/pages.ja.mdx | 166 +++++++ content/docs/build/interface/views.ja.mdx | 294 +++++++++++++ 11 files changed, 2137 insertions(+) create mode 100644 content/docs/build/automation/approvals.ja.mdx create mode 100644 content/docs/build/automation/flows.ja.mdx create mode 100644 content/docs/build/automation/index.ja.mdx create mode 100644 content/docs/build/automation/workflows.ja.mdx create mode 100644 content/docs/build/interface/actions.ja.mdx create mode 100644 content/docs/build/interface/apps.ja.mdx create mode 100644 content/docs/build/interface/dashboards.ja.mdx create mode 100644 content/docs/build/interface/forms.ja.mdx create mode 100644 content/docs/build/interface/index.ja.mdx create mode 100644 content/docs/build/interface/pages.ja.mdx create mode 100644 content/docs/build/interface/views.ja.mdx diff --git a/content/docs/build/automation/approvals.ja.mdx b/content/docs/build/automation/approvals.ja.mdx new file mode 100644 index 00000000..c0d4b0ef --- /dev/null +++ b/content/docs/build/automation/approvals.ja.mdx @@ -0,0 +1,279 @@ +--- +title: 承認 +seoTitle: "承認フロー:レコードを人による承認へ回す" +description: レコードを人による承認へ回します。自動化が行レベルセキュリティを黙ってすり抜けることはさせません。 +editions: [community, cloud, enterprise] +translation: + source_sha: e03dd11f32910f971d7514f12aab8e3c2ac1eceb25966b2b4162185eb062a521 + guide_rev: 1 + mode: auto +--- + +**承認とは、承認ノードを持つ[フロー](/docs/build/automation/flows)です。フローは人が承認または却下するまで一時停止し、その後、対応する分岐に沿って再開します。** 新たに覚えるべき独立した承認エンジンはありません。トリガー、分岐、エラー処理は、ほかのフローとまったく同じように動作します。このページが加えるのは承認ノードそのものと、ルーティングと同じくらい重要な 2 つのアクセスの判断です。 + +## 誰が何をするか [#who-does-what] + +意図的に分けられた 3 つのロールです。 + +| ロール | できること | 必要なもの | +|---|---|---| +| **ビルダー** | 承認フローを作成、編集する | `manage_metadata`(通常は Studio のユーザー) | +| **申請者** | フローに入るレコードを申請する | 通常のレコードアクセス。自動化の設定は不要 | +| **承認者** | 承認依頼に対応する(承認 / 却下) | 権限セットで付与された機能 | + +エンドユーザーはレコードを申請し、依頼に対応します。自動化を編集することはありません。自動化の設定画面は、利用者向けのアプリに含めないでください。 + +## 誰として実行するか:安全上の判断 [#as-whom-does-it-run--the-safety-decision] + +フローは `runAs` を宣言します。承認にとって、これはフローが行レベルセキュリティを黙ってすり抜けないようにするための判断です。 + +| `runAs` | データ操作を実行する主体 | 使う場面 | +|---|---|---| +| `'user'`(デフォルト) | **申請者**。その RLS に従います | フローが、申請者がすでに見られるレコードにだけ触れる場合 | +| `'system'` | **昇格**。RLS をすり抜けます | 申請者が見られないレコードをフローが読み書きしなければならない場合(元帳への記帳、申請者が見られない行を所有する承認者への通知) | + +昇格は、偶然ではなく見える形にするために**明示的に**宣言します。デフォルトが `'user'` なので、承認フローが申請者に、テナントや所有者をまたぐ到達範囲を黙って与えることはできません。昇格はオプトインで、監査できます。 + +> **ヒント:** スケジュールで起動するエスカレーションには起動したユーザーがいないので、そもそも動作させるには `runAs: 'system'` でなければなりません。これが昇格の正当なケースです。「動かすためにどこでも `system`」はアンチパターンです。 + +## 承認ノード [#the-approval-node] + +ノードは**誰が承認するか**と、その判断をどう集約するかを宣言します。 + +```ts +// One flow node; the flow around it is omitted (see the complete flow below). +{ + id: 'manager_approval', + type: 'approval', + label: 'Manager Approval', + config: { + approvers: [{ type: 'field', value: 'owner_manager_id' }], + behavior: 'unanimous', + approvalStatusField: 'approval_status', + lockRecord: true, + }, +} +``` + +| 設定 | 働き | +|---|---| +| `approvers` | 誰が判断しなければならないか。下記の承認者の種類を参照 | +| `behavior` | 複数の承認者をどう集約するか:`'first_response'`(デフォルト)、`'unanimous'`、`'quorum'`、`'per_group'` | +| `minApprovals` | 必要な承認数。`quorum` では合計(N 人中 M 人)、`per_group` ではグループごと(デフォルト 1) | +| `onEmptyApprovers` | 承認者の解決結果が誰もいなかったときの動作:`'admin_rescue'`(デフォルト)、`'fail'`、`'auto_approve'` | +| `decisionOutputs` | 判断が構造化された出力としてフローに持ち帰れるキー | +| `approvalStatusField` | プラグインが依頼のステータスを反映する、省略可能なレコードのフィールド | +| `lockRecord` | 依頼が保留中の間、レコードを編集からロックします | + +`approvers` の各エントリは `type` と `value` を指定します。 + +| `type` | 解決先 | +|---|---| +| `manager` | 申請者のマネージャー(`sys_user.manager_id`)。value は取りません | +| `position` | 組織のポジションの保持者 | +| `department` | 事業単位**とそのすべての下位単位**のメンバー | +| `team` | フラットなコラボレーションチームのメンバー | +| `field` | 起動したレコードのフィールドが保持するユーザー ID | +| `user` | 名前で指定した 1 人のユーザー ID | +| `org_membership_level` | [組織メンバーシップの階層](/docs/configure/users#organization-membership-tiers)(`owner` / `admin` / `delegated_admin` / `member`) | +| `expression` | ノードに入るときに解決される CEL 式。下記を参照 | + +文字どおりの `user` ID よりも、間接的なバインディング(`manager`、`position`、`department`、`team`)を優先してください。本人がチームを移ったり退職したりしても機能し続けますが、ハードコードされた ID は、もう承認を見るべきではない人に黙って承認を回してしまいます。 + +> **警告:`position` とメンバーシップの階層。** `{ type: 'position', value: 'finance_manager' }` はポジション(`sys_user_position`)の保持者に回します。[組織メンバーシップの階層](/docs/configure/users#organization-membership-tiers)(`sys_member.role`:`owner`/`admin`/`delegated_admin`/`member`)は 16.0 以降 `type: 'org_membership_level'` で指定します。旧来の表記 `role` は **1 リリースの間だけの非推奨エイリアス**で、警告付きで今も同じように読み込まれて解決されますが、次のメジャーで削除されます。メンバーシップ階層の種類として書かれたポジション名は誰にも一致せず、依頼は止まってしまいます。`os lint` は両方のケースを指摘します(`approval-approver-not-membership-tier`、`approval-approver-type-deprecated`)。value が組織のポジションを指している場合、修正は `type: 'position'` です。 + +### クォーラムとグループごとの承認(16.0) [#quorum-and-per-group-sign-off-160] + +`first_response` と `unanimous` に加えて、16.0 では 2 つの集約モードが追加されました。**クォーラム**は N 人中 M 人の承認で確定します。 + +```ts +// The `config` of an `approval` node; the node around it is omitted. +config: { + approvers: [ + { type: 'user', value: 'director_a' }, + { type: 'user', value: 'director_b' }, + { type: 'user', value: 'director_c' }, + ], + behavior: 'quorum', + minApprovals: 2, // any 2 of the 3 approve +} +``` + +**グループごと**は、ラベル付けされた*各*グループからの承認(会签)を必要とします。承認者に `group` でラベルを付けると、すべてのグループが `minApprovals` 件の承認(デフォルトはグループごとに 1)を得たときにだけノードが先に進みます。 + +```ts +// The `config` of an `approval` node; the node around it is omitted. +config: { + approvers: [ + { type: 'position', value: 'legal_counsel', group: 'legal' }, + { type: 'position', value: 'finance_manager', group: 'finance' }, + ], + behavior: 'per_group', // one sign-off from legal AND one from finance +} +``` + +すべてのモードに共通する意味: + +- 承認者の集合は、不在時の代理を含む**依頼開始時のスナップショット**に照らして集計されます。誰が応答しなければならないかは依頼が開始されたときに確定し、不在の承認者は依頼を止めるのではなく代理に置き換えられます。 +- **1 件の却下はやはり拒否権です**。どのモードでも、ノードを `rejected` として確定させます。 +- しきい値は実行時に、解決できる承認者の数に**切り詰められる**ので、誤設定された `minApprovals` が依頼をデッドロックさせることはありません。 +- `group` ラベルのない承認者はそれぞれが独自のグループになるので、ラベルなしの承認者リストでも `per_group` のもとで妥当に動作します。 + +### 動的なルーティング:承認者が次の承認者を選ぶ(17.0) [#dynamic-routing-an-approver-picks-the-next-approver-170] + +17.0 で追加された 3 つの機能により、ノードは固定の宣言からではなく、実行がそれまでに生み出したものから承認者を決められます。 + +**`expression` 承認者**は、ノードに入るときに、3 つの明示的なルートに対して CEL 式を解決します。`current.*`(レコードの現在の状態)、`trigger.*`(申請時のスナップショット)、`vars.*`(上流ノードの出力を含むフロー変数)です。結果が承認者の顔ぶれになります。 + +```ts +// The `config` of an `approval` node; the node around it is omitted. +config: { + approvers: [ + { + type: 'expression', + value: 'vars.legal_review.picked_departments', + resolveAs: 'department', // each value is a department id, fanned out to its members + }, + ], +} +``` + +`resolveAs` は `expression` 承認者自身のキー(デフォルトは `'user'`、または `'department'` / `'position'` / `'team'`)で、式が返した ID の種類を示し、静的な承認者の種類と同じグラフ検索で各 ID を展開します。`behavior: 'per_group'` のもとでは、返された各値が独自のグループになり、返された部署ごとに 1 件の承認が必要になります。 + +`record` と素のフィールド名は意図的に**使えません**。プラットフォームのほかのすべての場面で `record` は「イベント時点のレコード」を意味しており、ここで再利用すると 2 つの時点のどちらかを黙って指してしまうからです。 + +上のような式に値を供給するのが **`decisionOutputs`** です。判断が持ち帰れるキーは*作成者*が宣言し、承認者は値を埋めるだけです。受け付けられた出力は `.` というフロー変数として実行を再開させます。素の名前には決してならないので、承認者が作成者の変数を覆い隠すことはできません。 + +```ts +// One flow node; the flow around it is omitted. +{ + id: 'legal_review', + type: 'approval', + label: 'Legal Review', + config: { + approvers: [{ type: 'position', value: 'legal_counsel' }], + decisionOutputs: [ + { key: 'picked_departments', type: 'department', multiple: true }, + ], + }, +} +``` + +エントリは、素のキー(判断の UI ではプレーンなテキスト入力としてレンダリングされます)か、型付きの宣言 `{ key, type, multiple }` のいずれかです。型付きの宣言は、判断の UI に代わりにレコードピッカーをレンダリングさせます。`user` / `department` / `position` / `team` はそれぞれ対応するシステムオブジェクトのピッカーになり、`multiple: true` は ID の配列を集めます。宣言されていないキーを持つ判断は拒否されます。`decision` と `requestId` は予約されています。 + +**`onEmptyApprovers`** は、解決結果に具体的な人が 1 人もいないとき(式の結果が空、ポジションに人がいない、複数選択フィールドが空)の動作を決めます。 + +| 値 | 動作 | +|---|---| +| `'admin_rescue'`(デフォルト) | とにかく依頼を開始し、大きく警告します。特権を持つ管理者が Reassign で引き継げます。レコードを素通りさせることも、実行を止めることもしない唯一の選択肢です | +| `'fail'` | ノードが失敗します(fault エッジ / 実行の失敗)。顔ぶれが空なら設定のバグとしか考えられない場合に選びます | +| `'auto_approve'` | 依頼をスキップし、`output.autoApproved = true` とともに `approve` エッジに沿って続行します。レコードを黙って素通りさせるので、オプトインです | + +このデフォルトは意図的なものです。承認の役目は門番なので、空の顔ぶれが黙って通ることがあってはなりません。 + +## 完全な承認フロー [#a-complete-approval-flow] + +大型の提案を所有者のマネージャーに回し、その判断で分岐します。 + +```ts +export const opportunityApproval = defineFlow({ + name: 'opportunity_approval', + label: 'Opportunity Approval', + type: 'record_change', + status: 'active', + runAs: 'user', // submitter's RLS unless a step genuinely needs more + nodes: [ + { + id: 'start', + type: 'start', + label: 'Proposal over 50,000', + config: { + triggerType: 'record-after-update', + objectName: 'opportunity', + condition: "record.amount >= 50000 && record.stage == 'proposal'", + }, + }, + { + id: 'manager_approval', + type: 'approval', + label: 'Manager Approval', + config: { + approvers: [{ type: 'field', value: 'owner_manager_id' }], + behavior: 'unanimous', + approvalStatusField: 'approval_status', + lockRecord: true, + }, + }, + { + id: 'mark_approved', + type: 'update_record', + label: 'Mark Approved', + config: { + objectName: 'opportunity', + filter: { id: '{record.id}' }, + fields: { stage: 'negotiation' }, + }, + }, + { + id: 'mark_rejected', + type: 'update_record', + label: 'Mark Rejected', + config: { + objectName: 'opportunity', + filter: { id: '{record.id}' }, + fields: { stage: 'closed_lost' }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'manager_approval' }, + { id: 'approved', source: 'manager_approval', target: 'mark_approved', label: 'approve' }, + { id: 'rejected', source: 'manager_approval', target: 'mark_rejected', label: 'reject' }, + { id: 'e4', source: 'mark_approved', target: 'end' }, + { id: 'e5', source: 'mark_rejected', target: 'end' }, + ], +}); +``` + +ラベル付きのエッジに注目してください。`approve` と `reject` が判断後の分岐に名前を付けています。却下の経路は必ずモデル化してください。承認の分岐しかないフローでは、却下されたレコードが行き場を失います。 + +**多段階の承認:** 複数の `approval` ノードをつなぎます。**並行承認:** [フロー](/docs/build/automation/flows)の集約ノードのパターンを参照してください。 + +## 承認者が体験すること [#what-the-approver-experiences] + +永続的な承認の状態は `@objectstack/plugin-approvals` パッケージが管理します。依頼が保留中の間: + +1. プラグインは依頼(`sys_approval_request`)と、それに対して行われたすべての判断(`sys_approval_action`)を永続化します。承認の履歴はクエリできるデータです。 +2. `lockRecord: true` の場合、判断が下るまでレコードは編集からロックされます。 +3. `approvalStatusField` を設定すると、レコード自身のフィールドが依頼のステータスを反映するので、ビューやレポートでそれを使ってフィルターできます。 +4. 承認者が承認または却下すると、プラグインは一時停止していたフローを対応するエッジに沿って再開します。16.0 以降、判断には**添付ファイル**を付けられます(`sys_approval_action.attachments` に永続化され、decide/comment のルートとクライアント SDK を通じて受け渡されます)。署名済みの PDF や証拠は判断とともに保管されます。 +5. 依頼はサーバーで計算された **`decision_progress`** を公開します。`unanimous`/`quorum` では得た承認数と必要な承認数、`per_group` ではグループごとの内訳です。そのため受信トレイや独自の UI は、集計を再実装せずに進捗をレンダリングできます。 + +**依頼を見られるのは誰か(17.0)。** 依頼はレコードのスナップショット、判断の履歴全体、添付ファイルを持つので、読めるのはその参加者だけです。申請者、現在の承認者(依頼が待っているポジションの保持者を含む)、そしてすでに対応またはコメントした人です。プラットフォームと組織の管理者はすべての依頼を見られます。それ以外の人には何も見えず、レコードを読めるかどうかはそれを変えません。承認者で依頼リストをフィルターするのはフィルターであって付与ではありません。それを省くと、テナント全体ではなく、自分が参加している依頼だけが返されます。見える依頼の中でも、スナップショットからは、読み手がそのレコードで読めないフィールドがすべて取り除かれます。17.6 以降は、マスクされた状態でしか読めないフィールドもすべて取り除かれます。 + +**判断は宣言されたアクションです(16.0)。** 判断の全体(承認、却下、再割り当て、修正のための差し戻し(`/revise`)、情報の依頼、リマインド、取り消し、再申請)は、`sys_approval_request` 上の `type: 'api'` アクションとして宣言されており、型付きのパラメータ、保留中のときだけ表示するゲート、申請者か承認者かの述語(例:`record.submitter_id == ctx.user.id`)を持ちます。承認の受信トレイは、手書きのボタンではなく、これらの宣言されたアクションをレンダリングします。そのため新しい判断機能はメタデータとして出荷され、クライアントのリリースは必要ありません。 + +承認すること自体がゲート付きのアクションです。「承認してよい」は、承認者の権限セットで付与される機能(例:`approve_invoice`)としてモデル化し、承認アクションの `requiredPermissions` をそれでゲートします。そうすればゲートは画面から隠されるだけでなく、UI とサーバーの**両方**で強制されます。 + +## ベストプラクティス [#best-practices] + +| すること | しないこと | +|---|---| +| 明確な開始条件を定義する | 承認ステップを作りすぎる | +| 妥当なタイムアウト期間を設定する | 承認を複雑にしすぎる | +| 適切な場合は取り消しを許す | 却下の経路を忘れる | +| すべての関係者に通知する | 承認者をハードコードする | +| 承認の履歴を追跡する | 「承認」を UI でだけゲートする | +| デフォルトは `runAs: 'user'` にし、1 ステップずつ昇格する | 「動かすために」どこでも `runAs: 'system'` を設定する | + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|---|---| +| [フロー](/docs/build/automation/flows) | このページの土台となるフローのリファレンス:トリガー、ステップ、エラー処理 | +| [ワークフロー](/docs/build/automation/workflows) | 承認が置かれるライフサイクルを制約する | +| [アクション](/docs/build/interface/actions) | 申請と承認をインターフェースのボタンとして表示する | +| [CEL 式](/docs/reference/cel) | 開始条件の背後にある言語 | +| [メール](/docs/configure/email) | 承認者と関係者に通知する | +| [自動化の概要](/docs/build/automation) | 選び方:フロー、ワークフロー、承認 | diff --git a/content/docs/build/automation/flows.ja.mdx b/content/docs/build/automation/flows.ja.mdx new file mode 100644 index 00000000..46a7b726 --- /dev/null +++ b/content/docs/build/automation/flows.ja.mdx @@ -0,0 +1,404 @@ +--- +title: フローと自動化 +description: 宣言的なビジネスロジック。AI に説明しても TypeScript で書いても、ランタイムはどちらの場合も同じ成果物を実行します。 +editions: [community, cloud, enterprise] +translation: + source_sha: 011506676f1192c1d884573299f8404d1cf906edfc56cb81b122979ba8410d43 + guide_rev: 1 + mode: auto +--- + +フローは、サーバーを書かずにビジネスロジックを表現する方法です。すべてのフローは、ランタイムが実行する宣言的なメタデータであり、オブジェクトやビューと同じです。そのため、フローは `os diff`、監査ログ、Studio のフロービルダー、そして [AI Builder](/docs/build/ai-builder) のすべてに同時に現れます。 + +ほとんどのお客様は、AI に頼んでフローを作成します。 + +> *「優先度の高いチケットが 'new' のまま 30 分経ったら、Slack でマネージャーに通知して」* + +AI はこのページで説明する形でフローのメタデータを生成するので、読んで編集できます。 + +フローはグラフです。`nodes` が作業を行い、`edges` がそれらをつなぎ、`start` ノードがフローを起動するものを示します。この形は ObjectStack の `FlowSchema` で、ObjectStack の [Flow metadata](https://objectstack.ai/docs/automation/flows) に完全に記載されています。 + +スタックでこの機能を有効にします。 + +```ts +export default defineStack({ + // ... + requires: ['automation', 'triggers'], // the flow engine, and the triggers that start flows +}); +``` + +両方のトークンが必要です。レコードの変更、スケジュール、API 呼び出しで開始するフローがあるのに `requires` にどちらかが欠けているスタックを、`defineStack` は拒否します。そのようなフローは登録されても決して実行されないからです。 + +## フローの種類 [#flow-types] + +| `type` | 起動するもの | 用途 | +|---|---|---| +| `record_change` | レコードの挿入、更新、削除。開始ノードで結び付けます | 「チケットが作成されたら担当者にメールする」 | +| `schedule` | 開始ノード上の cron スケジュール | 「毎晩午前 2 時に古いタスクをマークする」 | +| `schedule`、相対時間 *(16.0)* | 日付フィールドと今日との近さ。毎日のスイープで判定 | 「契約終了の 60 日前に通知する」 | +| `autolaunched` | アクションボタン、別のフロー、または API 呼び出し | 「請求書を承認」アクション | +| `screen` | ユーザー。1 つ以上の画面を通じて | ガイド付きのデータ入力 | +| `api` | 開始ノードの `config.secret` で署名された HTTP リクエスト | 外部システム向けのエンドポイント | + +## レコード変更:レコードの変更に反応する [#record-change-react-to-a-record-change] + +```ts +// src/flows/ticket_assigned_email.ts +import { defineFlow } from '@objectstack/spec'; + +export const ticketAssignedEmail = defineFlow({ + name: 'ticket_assigned_email', + label: 'Email the assignee of a new ticket', + type: 'record_change', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Ticket created', + config: { objectName: 'support_ticket', triggerType: 'record-after-create' }, + }, + { + id: 'email_assignee', + type: 'notify', + label: 'Email the assignee', + config: { + recipients: '{record.assignee}', + title: 'New ticket: {record.subject}', + message: 'You have been assigned {record.subject}, priority {record.priority}.', + channels: ['email'], + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'email_assignee' }, + { id: 'e2', source: 'email_assignee', target: 'end' }, + ], +}); +``` + +メールは `notify` ノードを通じて送られます。独立したメールノードの種類はありません。`channels: ['email']` は、[メール](/docs/configure/email)で設定されたトランスポートを通じて、メールチャネルで通知を送ります。`channels` がなければ、通知はアプリ内の受信トレイにだけ届きます。インラインのテキストの代わりに保存済みのメールテンプレートを送るには、`title` と `message` の代わりに `template`(`sys_email_template` の名前)を設定します。 + +ノードの `config` 内のフィールド値と通知テキストはテンプレートです。`{record.}` は起動したレコードを、`{}` はフロー変数を、`{$User.Id}` は現在のユーザーを読み、`{NOW()}` / `{TODAY()}` は日付マクロです。条件は波括弧なしの素の CEL です。[条件と分岐](#conditions-and-branches)を参照してください。 + +開始ノードの `triggerType` がイベントを選び、省略可能な `condition` がそれを絞り込みます。たとえば `condition: "record.priority == 'high'"` です。 + +| `triggerType` | 発動するタイミング | +|---|---| +| `record-after-create` | 挿入の後 | +| `record-after-update` | 更新の後 | +| `record-after-delete` | 削除の後 | +| `record-after-write` | 挿入**または**更新の後 | +| `record-before-create` | 挿入の前 | +| `record-before-update` | 更新の前 | +| `record-before-delete` | 削除の前 | +| `record-before-write` | 挿入**または**更新の前 | + +更新では、`previous` が変更前のレコードを保持します:`condition: 'record.stage != previous.stage'`。同じ書き込みの中で、保存される前に保留中のレコードを変更するには、フローではなく before フックを使います。フローは保存の副作用のためのものです。レコードの作成、通知、HTTP 呼び出し、承認の依頼などです(ObjectStack の [Hooks vs flows](https://objectstack.ai/docs/automation/hooks#hooks-vs-flows))。 + +## スケジュール:時刻に従って実行する [#scheduled-run-on-a-clock] + +```ts +export const nightlyCleanup = defineFlow({ + name: 'nightly_cleanup', + label: 'Mark overdue tasks', + type: 'schedule', + status: 'active', + runAs: 'system', // a scheduled run has no user, so its data operations need an explicit identity + nodes: [ + { + id: 'start', + type: 'start', + label: 'Every night at 2am', + config: { + triggerType: 'schedule', + schedule: { type: 'cron', expression: '0 2 * * *', timezone: 'America/New_York' }, + }, + }, + { + id: 'mark_overdue', + type: 'update_record', + label: 'Mark open tasks past due', + config: { + objectName: 'task', + filter: { status: 'open', due_date: { $lt: '{TODAY()}' } }, + fields: { status: 'overdue' }, + multi: true, // update every match; without it, a filter that is not a single id is refused + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'mark_overdue' }, + { id: 'e2', source: 'mark_overdue', target: 'end' }, + ], +}); +``` + +`@objectstack/service-job` 機能に支えられています。[ランタイム機能](/docs/reference/runtime-capabilities)を参照してください。時間で起動するフローには組織を引き継ぐ呼び出し元がいません。開始ノードで `organization` を使って組織を宣言しなければならない場合については、ObjectStack の [Flow metadata](https://objectstack.ai/docs/automation/flows#the-acting-organization) に記載されています。 + +> **スケジュールされた処理は、デプロイメントが有効にするまでオフです。** ObjectStack 17.5 以降、時間で起動するフロー(`schedule` の周期、または下記の相対時間スイープ)とパッケージ化された `defineJob` の cron ジョブは、デプロイメントが `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` を `true`(または `1`、`on`、`yes`)に設定したときにだけ有効になります。未設定なら何も有効にならず、そのようなフローはそれぞれエラーではなく *disabled by deployment policy* として報告されます。オフは ObjectStack ランタイムのデフォルトです。お使いの ObjectOS イメージやそのデプロイバンドルのテンプレートがこの変数を設定してくれるかどうかは、このページでは約束できません。アプリがスケジュールフローに依存するときは明示的に設定し、実効値を表示する `os doctor` で確認してください。[環境変数](/docs/reference/environment-variables#runtime-services)を参照してください。プラットフォーム自身のスケジュール処理(承認のエスカレーション、保持期間、メッセージの配信)には影響しません。`isolated` テナンシー方式では、開始ノードで `organization` を宣言していないスケジュールフローは、スイッチがオンでも有効になりません。 + +## 相対時間:日付フィールドを基準に発動する(16.0) [#time-relative-fire-relative-to-a-date-field-160] + +「契約終了の 60 日前に通知する」は、かつては `record.end_date == daysFromNow(60)` を条件とするレコード変更フローとして作成されていました。この述語はレコードが*たまたま変更された*ときにしか評価されないので、放っておくとほとんど発動しません。そのようには書かないでください。代わりに**相対時間トリガー**を宣言します。フローの開始ノードが `config.timeRelative` 記述子を持ち、ランタイムがスケジュールに従ってオブジェクトをスイープし、**一致したレコードごとに 1 回**フローを起動します。 + +```ts +export const renewalReminder = defineFlow({ + name: 'contract_renewal_reminder', + label: 'Contract renewal reminder', + type: 'schedule', + status: 'active', + runAs: 'system', // a sweep has no user to act as + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { + timeRelative: { + object: 'contracts', + dateField: 'end_date', + offsetDays: [60, 30, 7], // T-minus thresholds + filter: { status: 'active' }, // AND-ed with the date window + }, + // Optional sweep cadence — defaults to daily at 08:00 UTC. + schedule: { type: 'cron', expression: '0 8 * * *' }, + }, + }, + { + id: 'notify_owner', + type: 'notify', + label: 'Notify Owner', + config: { recipients: '{record.owner}', title: 'Contract {record.name} ends on {record.end_date}' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'notify_owner' }, + { id: 'e2', source: 'notify_owner', target: 'end' }, + ], +}); +``` + +| キー | 宣言するもの | +|---|---| +| `object` | スイープがレコードを問い合わせるオブジェクト(マシン名) | +| `dateField` | 今日を基準に評価される `date` / `datetime` フィールド(日単位) | +| `offsetDays` | **オフセットモード**:`dateField` がちょうど今日 + 列挙した各オフセットのときに発動します(`[60, 30, 7]`。負の値は過去で、たとえば `[-1]` は翌日) | +| `withinDays` | **範囲モード**:`dateField` が今日から N 日以内である日には毎日発動します。正の値は今後(「まもなく期限切れ」)、負の値は範囲を区切った期限超過の遡り、`0` は今日が期限 | +| `filter` | 計算された日付の範囲と AND 結合される、省略可能な ObjectQL の where マップ(例:`{ status: 'active' }`) | +| `maxRecords` | 1 回のスイープで起動するレコード数の上限(デフォルト 1000。上限で切り詰めるとスイープがログに記録します) | + +`offsetDays` と `withinDays` のどちらか**一方だけ**を設定しなければなりません。ほかによくある 2 つの形です。 + +```ts +// Two variants of the start node's config.timeRelative; the rest of the flow is as above. + +// "Expiring soon" — fires every day a document is within 30 days of expiry. +timeRelative: { object: 'hr_document', dateField: 'expires_on', withinDays: 30 } + +// Overdue sweep — fires for POs up to 14 days past due (bounded lookback). +timeRelative: { object: 'purchase_order', dateField: 'due_date', + withinDays: -14, filter: { status: 'open' } } +``` + +各起動は一致したレコードを自動化コンテキストに置くので、開始ノードの `condition` と `{record.}` の埋め込みは、レコード変更フローとまったく同じように動作します。検出のクエリはシステムとして実行され、レコードごとに障害が分離されます。`os validate` には準備状況のチェックが加わり、`timeRelative.object` がスタックで定義されていないオブジェクトを指しているとき、および自動起動のフローが `draft` ステータスのまま残っているときに警告します。 + +> **同じ日のチェックは、今は問題なく使えます(#3183)。** 16.0 では `record.due_date == today()` が一致します。エンジンが時間型の `==` / `!=` 比較を変換するので、日付フィールドが `today()` と正しく比較されます。本当に「今日が期限」という条件にはこれを使い、「日付の N 日前/後」という形のものには `timeRelative` を使ってください。 + +## 手動で開始:アクションと API 呼び出し [#started-by-hand-actions-and-api-calls] + +```ts +export const approveInvoice = defineFlow({ + name: 'approve_invoice', + label: 'Approve invoice', + type: 'autolaunched', + status: 'active', + variables: [ + { name: 'invoice_id', type: 'text', isInput: true }, + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'approve', + type: 'update_record', + label: 'Approve the invoice', + config: { + objectName: 'invoice', + filter: { id: '{invoice_id}' }, + fields: { status: 'approved', approved_by: '{$User.Id}' }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'approve' }, + { id: 'e2', source: 'approve', target: 'end' }, + ], +}); +``` + +請求書のビューで、種類が `flow` の[アクション](/docs/build/interface/actions)を使ってボタンとして表示するか、REST で呼び出します。 + +```bash +curl -X POST https://app.example.com/api/v1/automation/approve_invoice/trigger \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"params": {"invoice_id": "inv_123"}}' +``` + +入力は `isInput: true` で宣言したフロー変数で、同じ名前で `params` に入れて送ります。宣言された入力だけがバインドされます。呼び出し元のアイデンティティが実行に引き渡されるので、更新は呼び出し元の権限のもとで実行されます。 + +このルートは何が起きたかを示す HTTP ステータスで応答するので、本文の `success` フラグではなくステータスで分岐してください。ディスパッチされた実行(完了、一時停止、または[拒否](#ending-a-run-completed-or-refused))には `200`、不明なフローには `404`、オフに切り替えられたフローには `409 FLOW_DISABLED`、開始ノードのない定義には `422 FLOW_NO_START_NODE`、ディスパッチされて失敗した実行には `400 FLOW_FAILED` です。種類が `flow` のアクションも同じように応答します。[REST API → Flow status table](/docs/reference/rest-api#flow-status-table) を参照してください。 + +## ノードの種類 [#node-types] + +| ノードの `type` | 目的 | +|---|---| +| `get_record` | ObjectQL でレコードを読みます。1 件、または `limit` が 1 より大きいときはリスト | +| `create_record` / `update_record` / `delete_record` | オブジェクトに書き込みます(`multi: true` で一致したすべてを更新または削除) | +| `notify` | 通知を送ります。デフォルトはアプリ内の受信トレイ、`channels: ['email']` でメール | +| `http` | HTTP エンドポイントを呼び出します | +| `connector_action` | インストール済みのコネクタ(Slack など)でアクションを実行します | +| `script` | `config.function` で名前を指定した登録済みの関数を呼び出します | +| `decision` | エッジの条件で分岐します | +| `assignment` | フロー変数を設定します | +| `loop` | コレクションの各項目について本体の領域を 1 回ずつ実行します | +| `parallel` | 分岐の領域を並行して実行します | +| `try_catch` | 領域内の失敗を捕捉し、必要に応じて再試行します | +| `wait` | タイマーまたは名前付きシグナルまで一時停止します | +| `screen` | ユーザーの入力を待って一時停止します | +| `subflow` | 別のフローを呼び出します | +| `approval` | 承認者が判断するまでブロックします(`@objectstack/plugin-approvals` が必要) | +| `end` | 実行を終了します。デフォルトは `completed`、または理由付きの `refused`(下記) | + +### 実行の終了:完了または拒否 [#ending-a-run-completed-or-refused] + +ObjectStack 17.4 以降、`end` ノードは実行の**結果**を宣言します。断らなければならないフロー(重複、上限を超える金額など)は、失敗したり、断ったことを伝えるだけの画面を表示したりする代わりに、拒否の `end` で終わります。 + +```ts +// One flow node; the flow around it is omitted. +{ + id: 'refuse_duplicate', + type: 'end', + label: 'Refused: duplicate', + config: { + outcome: 'refused', // 'completed' (the default) | 'refused' + message: 'Refused: {record.name} is already registered.', + }, +} +``` + +拒否された実行は失敗した実行ではありません。何も例外を投げていないからです。実行は `refused` ステータスで終わり、トリガーのルートはそのステータスとともに `200` で応答し、レンダリングされた `message` は `refusalMessage` として返され、その横に `successMessage` はありません。`outcome` が `refused` のときは `message` が必須で、完了の end では拒否されます。そこでは誰の目にも触れないからです。拒否の `end` には `decision` の分岐から到達させ、すべての書き込みは拒否が通らない分岐に置いてください。 + +## 条件と分岐 [#conditions-and-branches] + +```ts +export const expenseRouting = defineFlow({ + name: 'expense_routing', + label: 'Route new expenses', + type: 'record_change', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Expense submitted', + config: { objectName: 'expense', triggerType: 'record-after-create' }, + }, + { id: 'check_amount', type: 'decision', label: 'Over 10,000?' }, + { + id: 'ask_approver', + type: 'notify', + label: 'Ask the approver', + config: { recipients: '{record.approver}', title: 'Expense over 10,000: {record.name}' }, + }, + { + id: 'auto_approve', + type: 'update_record', + label: 'Auto-approve', + config: { objectName: 'expense', filter: { id: '{record.id}' }, fields: { status: 'auto_approved' } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'check_amount' }, + { id: 'e_large', source: 'check_amount', target: 'ask_approver', condition: 'record.amount > 10000' }, + { id: 'e_other', source: 'check_amount', target: 'auto_approve', isDefault: true }, + { id: 'e2', source: 'ask_approver', target: 'end' }, + { id: 'e3', source: 'auto_approve', target: 'end' }, + ], +}); +``` + +`decision` ノードは出力エッジで分岐します。エッジは `edges` に並んだ順に評価され、`condition` が成り立つ最初のものが実行され、どれも成り立たないときは `isDefault` のエッジが実行されます。`isDefault` がない場合、条件のないエッジは、一致した分岐と並んで毎回実行されます。条件が成り立つ*すべての*分岐を実行するには、decision に `config: { mode: 'inclusive' }` を宣言します。最初の一致を採用するのは ObjectStack 17.5 からのルールです。それ以前は真になったすべての分岐が実行されていました。データベースに保存された decision(Studio から保存されたもの)は、書き換えられることなく、アップグレード時に新しい意味になりました。 + +## エラー処理 [#error-handling] + +```ts +// Keys of a record_change flow on `invoice`. The nodes are omitted: `charge_card` is an +// `http` node, `mark_paid` an `update_record`, and `flag_for_review` a `notify`. +edges: [ + { id: 'e_ok', source: 'charge_card', target: 'mark_paid' }, + { id: 'e_fault', source: 'charge_card', target: 'flag_for_review', type: 'fault' }, +], +errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 1000, backoffMultiplier: 2 }, +``` + +どちらの仕組みもなければ、ノードの失敗で実行は終了します。これらは 2 つの範囲で働きます。 + +| | 範囲 | 失敗時 | +|---|---|---| +| `fault` エッジ | 1 つのノード | 実行はハンドラーから続行し、完了します | +| `errorHandling: { strategy: 'retry', maxRetries }` | フロー全体 | フローは最初から再実行されます。最大で `maxRetries` 回まで | + +`strategy` は `'fail'`(デフォルト)、`'retry'`、`'continue'` のいずれかです。再試行は、すでに成功したすべてのノードを再生します。2 通目のメール、2 件目の作成レコードが生まれるので、失敗が局所的な場合は fault エッジを優先してください。fault エッジが処理した失敗はフローの失敗ではなく、再試行を消費せず、失敗したステップは実行のトレースに残ります。 + +## 数式と式(CEL) [#formulas-and-expressions-cel] + +条件(開始ノードの `condition`、エッジの `condition`)は **CEL**(Common Expression Language)で書きます。安全な式評価のための Google の言語です。 + +```ts +'record.amount > 10000 && record.tier == "enterprise"' +'record.stage != previous.stage' +'!isBlank(record.notes)' +``` + +`record` と `previous` は宣言されたすべてのフィールドを持ち、値のないフィールドは `null` として読まれます。そのため `has(record.notes)` が示すのはフィールドが宣言されていることで、値があることではありません。値の有無は `record.notes != null` または `isBlank(record.notes)` で確かめてください。 + +CEL はサンドボックス化されており(副作用なし、I/O なし)、サーバー側で評価され、フロービルダーで監査できます。 + +## ビジュアルビルダー [#visual-builder] + +ObjectOS には、宣言的なメタデータと相互に変換できるビジュアルフロービルダーが付属しています。エンジニアでなくてもフローを編集でき、編集結果は手で書く TypeScript と同じ形にシリアライズされます。これは Studio の **Automations** の柱です。 + +Studio から保存したフローは、インストール済みのパッケージが提供するフローと並んで、デプロイメントに保存されます。そこから 2 つのルールが導かれます。どちらも ObjectStack 17.6 からです。 + +- **パッケージ化されたフローがその名前を取ります。** パッケージ化されたフローと同じ名前を持つ保存済みのフローは隠され、実行されません。起動ログに警告が出ます。パッケージ化されたフローをカスタマイズするには、新しい名前で複製してください。 +- **保存済みのフローは `status` で切り替えます。** オン/オフのトグルが切り替えるのはパッケージ化されたフローだけです。デプロイメント内で作成したフローは、`status: 'obsolete'` で保存するとオフになり、`status: 'active'` で保存するとオンに戻ります。 + +## フローのテスト [#testing-flows] + +```bash +os test qa/ticket-email.test.json # run a Quality Protocol test suite against a running server +``` + +`os test` は、JSON のテストスイートとして書かれた Quality Protocol のシナリオを、実行中のサーバーに対して実行します。`--tags` はタグでシナリオを選びます。[CLI リファレンス](/docs/reference/cli)を参照してください。 + +## 制限とベストプラクティス [#limits--best-practices] + +- **before フックは小さく保ちます。** 書き込みトランザクションをブロックするからです。 +- **長時間実行のステップの代わりに `wait` ノードを使います。** 実行を永続的に待機させ、タイマーまたは名前付きシグナルで再開します。 +- **独立した作業には `parallel` ブロックを使います。** 分岐を並行して実行し、フローが続行する前に合流させます。 +- **冪等性が重要です。** フローレベルの再試行は、すでに成功したノードを再実行します。外部への副作用は重複を排除すべきです(フローの実行 ID をキーとして使います)。 +- **監査上重要なアクション。** 権限を変更したりレコードを削除したりするフローは、それ自体が `sys_audit_log` にログを書くべきです。 + +## 次に進む [#where-to-go-next] + +- [Webhook](/docs/configure/webhooks):外向きの通知。フローから起動されることが多いもの +- [メール](/docs/configure/email):`notify` ノードの `email` チャネルの背後にあるトランスポート +- [通知](/docs/configure/notifications):受信者、チャネル、テンプレートがどう解決されるか +- [API アクセス](/docs/configure/api-access):外部システムからフローを呼び出す +- [@objectstack/service-automation](https://github.com/objectstack-ai/objectstack/tree/main/packages/services/service-automation):実行エンジンのソース diff --git a/content/docs/build/automation/index.ja.mdx b/content/docs/build/automation/index.ja.mdx new file mode 100644 index 00000000..e9db7348 --- /dev/null +++ b/content/docs/build/automation/index.ja.mdx @@ -0,0 +1,43 @@ +--- +title: 自動化 +seoTitle: "自動化:フロー、ワークフロー、承認" +description: 用途に合ったツールを選びます。手順にはフロー、状態にはワークフロー、人による承認には承認を使います。 +editions: [community, cloud, enterprise] +translation: + source_sha: e39ead174c229520946d302d63cc2c7e6903c12d87048df02a15aed0b5ab0a84 + guide_rev: 1 + mode: auto +--- + +**自動化は、ビジネスロジックをアプリケーションコードに散らばらせるのではなく、ランタイムが実行するメタデータとして、データモデルに宣言的に結び付けます。** 3 つのツールがその範囲をカバーしており、最初に正しいものを選べば、後で作り直す手間が省けます。 + +## どれが必要か [#which-one-do-i-need] + +| 必要なもの | 使うもの | 読むもの | +|---|---|---| +| 「X が起きたら Y をする」:レコードの変更、スケジュール、ボタンのクリックで実行される手順 | **フロー** | [フロー](/docs/build/automation/flows) | +| 「このレコードはこれらの状態だけを通って進める」:管理されたライフサイクル | **ワークフロー**(ステートマシン) | [ワークフロー](/docs/build/automation/workflows) | +| 「先に進む前に人が承認しなければならない」:ルーティングされる人の判断 | **承認** | [承認](/docs/build/automation/approvals) | + +目安:**状態**はワークフローで、**手順**はフローでモデル化します。承認は別のエンジンではありません。承認とは、人が判断するまで承認ノードで一時停止するフローです。 + +## どう組み合わせるか [#how-they-combine] + +3 つは競合せず、組み合わせて使います。 + +- **ワークフロー**は、そもそもどのライフサイクル遷移が有効かを制約します。状態とその間の移動だけで、それ以外は扱いません。 +- **フロー**は、それらの遷移の前後で副作用を実行します。メールを送る、レコードを更新する、外部サービスを呼び出す、待機する、分岐する、などです。 +- フロー内の**承認ノード**は、承認者が操作するまで実行を止め、その後、承認または却下の分岐に沿って再開します。 + +つまり「ケースが new → assigned → resolved と進む」はワークフローであり、「エスカレーションされたらマネージャーに通知する」はフローであり、「5 万ドルを超える値引きには財務マネージャーの承認が必要」は承認ノードを持つフローです。 + +> **ヒント:** 要求が「X が起きたら Y をする」と読めるならフローです。「このレコードは決して状態を飛ばしてはならない」と読めるならワークフローです。ここから始めれば、選択を誤ることはほとんどありません。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|---|---| +| [フロー](/docs/build/automation/flows) | フローの完全なリファレンス:トリガー、ステップの種類、エラー処理、CEL | +| [ワークフロー](/docs/build/automation/workflows) | 厳密なライフサイクルのための、状態とその許可された遷移 | +| [承認](/docs/build/automation/approvals) | 承認ノード、実行アイデンティティ、承認者の体験 | +| [CEL 式](/docs/reference/cel) | 条件と検証ルールで使われる式言語 | diff --git a/content/docs/build/automation/workflows.ja.mdx b/content/docs/build/automation/workflows.ja.mdx new file mode 100644 index 00000000..00fbe406 --- /dev/null +++ b/content/docs/build/automation/workflows.ja.mdx @@ -0,0 +1,181 @@ +--- +title: ワークフロー +seoTitle: "ワークフロー:ステートマシンとしてのレコードのライフサイクル" +description: レコードのライフサイクルをステートマシンとしてモデル化します。レコードが取りうる状態、その間で許される移動、そしてフローの方がうまくできることは何も含めません。 +editions: [community, cloud, enterprise] +translation: + source_sha: 7ec55c7f19bcf4f40b34dc0d852ea4cf3534169ad263dbdff0f1ed34fbb0c600 + guide_rev: 1 + mode: auto +--- + +**ワークフローは、レコードのライフサイクルを有限ステートマシンとしてモデル化します。レコードが取りうる状態と、その間で許される移動です。** オブジェクト上の `state_machine` 検証ルールとして宣言し、状態フィールドへのすべての書き込みがそれに照らしてチェックされます。中心となる要件が「このオブジェクトはこれらの状態だけを通って進める」であるときに使います。 + +Salesforce 風の独立した Workflow Rule という作成タイプはありません。かつての「ワークフロー」の概念は、きれいに 2 つに分かれます。 + +- **`state_machine` 検証ルール**:厳密なライフサイクル遷移のため。このページです。 +- **[フロー](/docs/build/automation/flows)**:イベントで起動する、またはスケジュールされた自動化のため。[承認](/docs/build/automation/approvals)による一時停止も含みます。 + +## ワークフローとフロー [#workflows-vs-flows] + +| | ワークフロー(ステートマシン) | フロー | +|---|---|---| +| モデル化するもの | *状態*:レコードがライフサイクルのどこにあるか | *手順*:何かが起きたときに何が起こるか | +| 答える問い | 「この遷移は今許されるか?」 | 「それについて何をするか?」 | +| 形 | 状態フィールドと、許される移動の表 | ノードとエッジ:トリガー、アクション、分岐 | +| 副作用 | なし。制約するだけです | すべて:メール、更新、HTTP、待機 | + +両者は組み合わせて使います。ステートマシンが遷移を制約し、通知、レコード更新、外部呼び出しが必要なときには、フローがその前後で副作用を実行します。 + +## ステートマシンを定義する [#define-a-state-machine] + +`new → assigned → resolved` と進まなければならず、エスカレーションの経路を持つサポートケースです。ステートマシンはオブジェクトの `validations` のひとつです。 + +```ts +import { P } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const SupportCase = ObjectSchema.create({ + name: 'support_case', + label: 'Support Case', + fields: { + status: Field.select({ + label: 'Status', + required: true, + options: [ + { label: 'New', value: 'new', default: true }, + { label: 'Assigned', value: 'assigned' }, + { label: 'Escalated', value: 'escalated' }, + { label: 'Resolved', value: 'resolved' }, + ], + }), + resolution: Field.textarea({ + label: 'Resolution', + requiredWhen: P`record.status == 'resolved'`, // no resolved case without one + }), + }, + validations: [ + { + type: 'state_machine', + name: 'case_lifecycle', + field: 'status', + message: 'This status change is not allowed.', + initialStates: ['new'], + transitions: { + new: ['assigned'], + assigned: ['resolved', 'escalated'], + escalated: ['resolved'], + resolved: [], + }, + }, + ], +}); +``` + +これを契約として読みます。ケースは `new` として作成され、`new` のケースは担当割り当てしかできません。`assigned` のケースは解決またはエスカレーションでき、エスカレーションされたケースは解決できます。`resolved` には何も列挙されていないので、それ以上は何も移動させられません。ステータスが `resolved` のときは `resolution` が必須なので、それなしでケースを解決する書き込みは拒否されます。 + +## 構成 [#anatomy] + +| キー | 宣言するもの | +|---|---| +| `type: 'state_machine'` | この検証ルールがステートマシンであること | +| `name` | ルールの識別子(snake_case) | +| `field` | ルールが管理する状態フィールド | +| `transitions` | 各状態 → 移動できる状態のマップ。レコードの更新時にチェックされます | +| `initialStates` | レコードを作成できる状態。挿入時にチェックされます | +| `message` | 拒否された書き込みが返すエラー | + +このルールは、すべての検証ルールに共通するキー `severity`、`events`、`active`、`priority` も受け付けます。 + +### 最終状態 [#final-states] + +リストが空の状態は最終状態で、そこから出るすべての移動が拒否されます。キーがまったくない状態は最終状態ではありません。そこではチェックに判断材料がなく、書き込みを通してしまうので、「最終」を意味するときは `[]` を宣言してください。 + +### 遷移の条件 [#conditions-on-a-transition] + +`state_machine` ルールには遷移ごとのガードがありません。移動を条件付きにするには、プラットフォームが強制する場所に条件を置きます。 + +- **ある状態に必要な値**:上の `resolution` のように、そのフィールドに `requiredWhen` を付けます。値なしでその状態に入る書き込みを拒否し、ルールより前から存在する行には手を付けません。 +- **それ以外すべて**:同じ `validations` 配列に、兄弟の `script` または `conditional` 検証ルール(CEL の述語)を置きます。 + +### イベントではなく書き込み [#writes-not-events] + +マシンは状態フィールドへの書き込み時にチェックされます。`status` を現在の値の下に列挙されていない値に変える更新は拒否され、`initialStates` 以外での挿入も同様です。宣言すべき名前付きイベントはありません。正当な移動の表がマシンのすべてです。 + +> **ヒント:** マシンは最小限に保ちます。状態とその間の移動だけです。「そしてメールを送る」と言いたくなった瞬間、ワークフローの領域を離れています。その副作用は、遷移に反応する[フロー](/docs/build/automation/flows)に置いてください。 + +## 副作用はフローに置く [#side-effects-belong-in-flows] + +ステートマシンは有効な遷移を記述するだけで、それ以外は何もしません。遷移で何かを*する*必要があるとき(営業に通知する、クローズ日を記録する、外部システムを呼び出す)は、マシンとレコード変更で起動するフローを組み合わせます。 + +```ts +export const dealClosedWon = defineFlow({ + name: 'deal_closed_won', + label: 'Deal closed won', + type: 'record_change', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Stage moved to closed won', + config: { + triggerType: 'record-after-update', + objectName: 'opportunity', + condition: "record.stage == 'closed_won' && previous.stage != 'closed_won'", + }, + }, + { + id: 'set_closed_date', + type: 'update_record', + label: 'Set Closed Date', + config: { + objectName: 'opportunity', + filter: { id: '{record.id}' }, + fields: { closed_date: '{TODAY()}' }, + }, + }, + { + id: 'notify_sales', + type: 'notify', + label: 'Notify Sales', + config: { recipients: '{record.owner}', title: 'Deal won: {record.name}' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'set_closed_date' }, + { id: 'e2', source: 'set_closed_date', target: 'notify_sales' }, + { id: 'e3', source: 'notify_sales', target: 'end' }, + ], +}); +``` + +マシンは商談が正当に `closed_won` へ*到達した*ことを保証し、フローはその後に起こることを処理します。上のような条件は CEL 式です。[CEL](/docs/reference/cel) を参照してください。 + +## ワークフロールールからの移行 [#migrating-from-workflow-rules] + +Workflow Rules を持つプラットフォームから来た場合の対応は次のとおりです。 + +| 旧来の概念 | 現在の対応するもの | +|---|---| +| Workflow Rule | フロー | +| 時間トリガー(「日付の N 日前/後」) | 相対時間トリガー:フローの開始ノードの `config.timeRelative`(16.0、[フロー](/docs/build/automation/flows)を参照) | +| 時間トリガー(固定時刻) | スケジュールフロー | +| 項目自動更新アクション | `update_record` ノード | +| メールアラート | `notify` ノード | +| HTTP 呼び出し | `http` ノード | +| 承認プロセス | 1 つ以上の `approval` ノードを持つフロー | + +判断の基準:旧ルールが「X が起きたら Y をする」なら、小さなフローとしてモデル化します。「日付フィールドの N 日前/後に Y をする」なら、相対時間トリガーを宣言します。レコード変更フローに日付が等しいという条件を付けてはいけません。それはレコードがたまたま変更されたときにしか評価されないからです。「このレコードは管理された状態を通って進まなければならない」なら、`state_machine` 検証ルールを宣言し、副作用にはフローを使います。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|---|---| +| [フロー](/docs/build/automation/flows) | 遷移の前後の副作用:トリガー、ステップ、エラー処理 | +| [承認](/docs/build/automation/approvals) | フロー内の一時停止としての、人による承認 | +| [検証ルール](/docs/build/data/validation-rules) | ステートマシンが属するルールの系統 | +| [CEL 式](/docs/reference/cel) | `requiredWhen`、検証ルール、フロー条件の背後にある言語 | +| [データモデリング](/docs/build/data) | ライフサイクルを制約するオブジェクト | +| [自動化の概要](/docs/build/automation) | 選び方:フロー、ワークフロー、承認 | diff --git a/content/docs/build/interface/actions.ja.mdx b/content/docs/build/interface/actions.ja.mdx new file mode 100644 index 00000000..c89a8f0a --- /dev/null +++ b/content/docs/build/interface/actions.ja.mdx @@ -0,0 +1,208 @@ +--- +title: アクション +seoTitle: "アクション:REST エンドポイント、ボタン、AI ツール" +description: プラットフォームが 1 つの宣言から REST エンドポイント、ボタン、AI ツールとして公開する、名前付きの操作。 +editions: [community, cloud, enterprise] +translation: + source_sha: 85e291bd4a1a724fe1ba2fbbf181d61ab30004d9389a19eaee2e9ae3175b1096 + guide_rev: 1 + mode: auto +--- + +**アクション**は、オブジェクトに対する名前付きの操作です。一度宣言すると、次のものとして現れます。 + +- `/api/v1/actions//` の **REST エンドポイント** +- レコード詳細ページの**ボタン** +- エージェントと AI Builder 向けの **AI ツール**(`action_`)。AI に公開したとき(`ai.exposed`、[下記](#from-an-ai-agent))に限ります + +3 つの画面で同じことを繰り返す必要はありません。1 つの宣言で、3 通りの呼び出し方ができます。作業が 1 ステップで終わらないときは、アクションから[フロー](/docs/build/automation/flows)を開始することもできます(`type: 'flow'`)。 + +## アクションを宣言する [#declare-an-action] + +```ts +// src/actions/approve_invoice.action.ts +import { Action } from '@objectstack/spec/ui'; + +export const approveInvoice = Action.create({ + name: 'approve_invoice', // lowercase snake_case (machine id) + label: 'Approve Invoice', + objectName: 'invoice', // attaches to the invoice object + icon: 'check', + variant: 'primary', + locations: ['record_header'], // where the button shows + description: 'Approve this invoice?', // shown in the param dialog + successMessage: 'Invoice approved', + refreshAfter: true, + + // collect input before running + params: [ + { name: 'note', label: 'Approval note', type: 'textarea' }, + ], + + // only show the button when the record is still pending + visible: 'record.status == "pending"', + + // expose it to agents and the AI Builder as `action_approve_invoice` + ai: { + exposed: true, + description: 'Approve a pending invoice, recording the approver and an optional note.', + }, + + // what it does — a sandboxed script body. `input` is the params bag; + // `ctx.record` is a read-only snapshot, so the write goes through `ctx.api`, + // which needs the `api.write` capability + type: 'script', + body: { + language: 'js', + capabilities: ['api.write'], + source: ` + await ctx.api.object('invoice').update({ + id: ctx.recordId, + status: 'approved', + approved_by: ctx.user.id, + approved_at: new Date().toISOString(), + approval_note: input.note, + }); + `, + }, +}); +``` + +`os dev` が再コンパイルした後: + +- `POST /api/v1/actions/invoice/approve_invoice` が動作します +- 請求書のレコードページに **Approve Invoice** ボタンが表示されます +- AI アシスタントは、スキルが許可していれば `action_approve_invoice` を呼び出せます。AI に公開されているからです + +## アクションの種類 [#action-types] + +`type` フィールドが、アクションが何をするかを決めます。 + +| `type` | 実行されるもの | 用途 | +|---|---|---| +| `script` | `body`:L1 の数式、またはサンドボックス化された L2 の JavaScript | ほとんどの場合。サーバー側のロジックで、監査でき、公開すれば AI から呼び出せます | +| `api` | `target` エンドポイントへの HTTP 呼び出し(`method`、`bodyExtra`) | データ API やプラットフォームのエンドポイントの再利用 | +| `flow` | `target` で名前を指定したフローを実行します | 多段階のビジネスプロセス | +| `url` | `target` の URL に移動します | ディープリンク、リダイレクト型のアクション | +| `modal` | `target` で名前を指定したページ / モーダルを開きます | カスタムのダイアログ | +| `form` | `target` で名前を指定した FormView を開きます | ガイド付きのデータ入力 | + +```ts +// api type — reuse a data-API endpoint +Action.create({ + name: 'archive_order', + objectName: 'order', + label: 'Archive', + locations: ['list_item'], + type: 'api', + method: 'PATCH', + target: '/api/v1/data/order/{id}', + bodyExtra: { archived: true }, +}); +``` + +`script` 以外の種類には `target` が必要です。どの種類でも 1 つの宣言ですが、`url`、`modal`、`form` のアクションは UI でしか実行されないので、AI ツールになることはありません。 + +## アクションを呼び出す [#calling-an-action] + +### REST [#rest] + +```bash +# the record id can go in the body, or in the path +curl -X POST https://app.example.com/api/v1/actions/invoice/approve_invoice/inv_123 \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"note": "LGTM"}' +``` + +パラメータはリクエスト本文にフラットに入れて送ります。レコード ID は、末尾のパスセグメント(`.../approve_invoice/:recordId`)としても本文の中でも指定できます。レスポンスは、スクリプト本体の戻り値です(`api` の種類のアクションでは呼び出しの結果)。 + +### UI から [#from-the-ui] + +ボタンをどこに表示するかはアクション自身で宣言します。`locations`(`record_header`、`record_more`、`list_item`、`list_toolbar` など)と `order` です。その後、`visible` の述語がレコードごとにレンダリングするかどうかを決めます。リストビューでも、各行と行の選択に対するアクションを名前で指定できます。 + +```ts +// One list view; the container and `data` are omitted (see Views). +{ + type: 'grid', + columns: ['number', 'customer', 'amount', 'status'], + rowActions: ['approve_invoice', 'reject_invoice'], + bulkActions: ['send_to_customer'], +} +``` + +### フローとともに [#with-a-flow] + +アクションを呼び出すフローのノードはありません。逆向きにしてください。多段階のロジックをフローに置き、`type: 'flow'` と、`target` にフローの名前を指定したアクションからそれを開始します。 + +```ts +// flow type — start a flow by name +Action.create({ + name: 'run_invoice_approval', + objectName: 'invoice', + label: 'Run approval', + locations: ['record_more'], + type: 'flow', + target: 'approve_invoice', +}); +``` + +### AI エージェントから [#from-an-ai-agent] + +アクションがエージェントに届くのは、作成者が AI に公開したときだけです。上の例のように、UI なしで実行される種類(`script`、`flow`、`api`)で、`ai: { exposed: true }` と 40 文字以上の `ai.description` を指定します。公開されたアクションは、ObjectOS 自身のエージェントと AI Builder では `action_` ツールになり、[MCP クライアント](/docs/configure/mcp)では `run_action` になります。公開されていなければ、エージェントからは見えず、呼び出されても拒否されます。 + +`approve_invoice` が公開されていて、エージェントが持つスキルに含まれていれば、LLM はそれを呼び出せます。入力は会話から得られ、ユーザーがボタンを押した場合と同じ呼び出し時のチェックが適用されます。 + +> *「請求書 INV-2042 を、メモ『電話で確認済み』を付けて承認して。」* + +## 権限 [#permissions] + +誰がアクションを実行できるかは、呼び出されたときに、呼び出し元としてチェックされます。REST からでも、ボタンからでも、エージェントからでも同じです。 + +1. **`requiredPermissions`**:呼び出し元の[権限セット](/docs/configure/permissions/permission-sets)が持っていなければならない機能です。1 つでも欠けている呼び出し元は `403` を受け取ります。 +2. **対象のレコード**:呼び出し元として読み込まれます。呼び出し元が読めないレコードは、見つからないものとして応答されます。 +3. **AI への公開**:エージェントが届くのは、AI に公開されたアクションだけです。 +4. **UI のゲート**:`visible` と `disabled` の述語(CEL。`record`、`os.user`、パラメータに対して評価されます)が、ボタンをレンダリングするか、グレーアウトするかを制御します。 + +その後アクションが行うことは、信頼されたアプリのコードです。`script` 本体の読み書きはアプリの権限で実行され、レコードアクセスとフィールドレベルのセキュリティはそれを制限**しません**。`flow` アクションはフローの `runAs` に従います。そのため、機密性のあるものには `requiredPermissions` を宣言し、アクションを書くことはコードレビューに値する行為として扱ってください。 + +## 組み込みのアクション [#built-in-actions] + +すべてのオブジェクトには、これらが無償で用意されます。 + +| アクション | 働き | +|---|---| +| `create` | レコードを挿入します | +| `update` | レコードを更新します | +| `delete` | レコードを削除(またはソフト削除)します | +| `restore` | ソフト削除を取り消します | +| `clone` | レコードをディープコピーします | +| `share` | ユーザー、チーム、ポジション、事業単位、または単位とその配下に直接共有します | + +これらを再宣言しないでください。オブジェクトの[ライフサイクルと機能のフラグ](/docs/build/data)に従います。 + +## 監査 [#auditing] + +プラットフォームのイベントは、変更不可能な証跡である `sys_audit_log` に記録されます。次のようなフィールドがあります。 + +- `user_id`:操作を起こしたユーザー +- `action`:アクションの名前 +- `object_name` と `record_id`:何に触れたか +- `old_value` / `new_value`:変更内容 +- `ip_address` / `user_agent`:リクエストの発信元 +- `created_at`:いつ起きたか + +*「誰がボタンを押したのか?」* という質問には、まずここを見てください。 + +## AI Builder でアクションを生成する [#generating-actions-with-the-ai-builder] + +> *「`support_ticket` に `escalate_ticket` というアクションを作って。優先度を urgent に設定し、オンコールのエンジニアに割り当てるもの。」* + +[AI Builder](/docs/build/ai-builder) はアクションのメタデータを生成し、変更を承認待ちのキューに入れます。承認後、アクションは REST、UI、そして再帰的に AI 自身から呼び出せるようになります。 + +## 次に進む [#where-to-go-next] + +- [フロー](/docs/build/automation/flows):`type: 'flow'` のアクションが開始する多段階のロジック +- [エージェント](/docs/build/agents):アクションを AI ツールとして公開する +- [API アクセス](/docs/configure/api-access):外部システムからアクションを呼び出す +- [権限](/docs/configure/permissions):誰が何を呼び出せるかをゲートする diff --git a/content/docs/build/interface/apps.ja.mdx b/content/docs/build/interface/apps.ja.mdx new file mode 100644 index 00000000..d11da2d9 --- /dev/null +++ b/content/docs/build/interface/apps.ja.mdx @@ -0,0 +1,137 @@ +--- +title: アプリ +seoTitle: "アプリ:ナビゲーション、ブランディング、対象者のゲート" +description: オブジェクト、ビュー、ページ、ダッシュボードを、ブランド付きでナビゲーションできる外枠にまとめ、誰が何を見るかを正確にゲートします。 +editions: [community, cloud, enterprise] +translation: + source_sha: 92a61a2c5ecd3b085a106339e991600fa0fc1dc9db0194a67c257c4946b4f080 + guide_rev: 1 + mode: auto +--- + +**アプリ**は、オブジェクト、ビュー、ページ、ダッシュボードをひとつのまとまった体験にまとめる論理的なコンテナです。ナビゲーションツリー、ブランディング、そして決定的に重要な、誰が入れるかを定義します。 + +```ts +import { App } from '@objectstack/spec/ui' + +export const CrmApp = App.create({ + name: 'crm_app', + label: 'CRM', + icon: 'briefcase', + branding: { primaryColor: '#2563EB' }, + navigation: [ + { id: 'group_sales', type: 'group', label: 'Sales', icon: 'briefcase', children: [ + { id: 'nav_leads', type: 'object', objectName: 'crm_lead', label: 'Leads', icon: 'funnel' }, + { id: 'nav_accounts', type: 'object', objectName: 'crm_account', label: 'Accounts', icon: 'building' }, + ]}, + ], + requiredPermissions: ['crm_access'], +}) +``` + +## アプリのプロパティ [#app-properties] + +| プロパティ | 型 | 必須 | 説明 | +|:--|:--|:--|:--| +| `name` | `string` | はい | マシン名(`snake_case`) | +| `label` | `string` | はい | 表示名 | +| `icon` | `string` | — | アプリのアイコン(Lucide) | +| `description` / `version` | `string` | — | 一覧表示用のメタデータ | +| `active` | `boolean` | — | アプリが有効かどうか(デフォルト `true`) | +| `isDefault` | `boolean` | — | これがデフォルトのアプリかどうか | +| `navigation` | `NavigationItem[]` | — | ナビゲーションツリー | +| `branding` | `AppBranding` | — | `primaryColor`、`logo`、`favicon` | +| `requiredPermissions` | `string[]` | — | そもそも誰がアプリを開けるか | +| `homePageId` | `string` | — | ランディングページとして使うナビゲーション項目の `id` | + +## ナビゲーション項目 [#navigation-items] + +ナビゲーションツリーは 8 種類の項目をサポートします。よく使う 5 つは `object`、`dashboard`、`page`、`url`、`group` で、仕様ではほかに `report`、`action`、`component` の項目も定義されています。 + +| `type` | 着地先 | 主な設定 | +|:--|:--|:--| +| `object` | オブジェクトのリストビュー | `objectName`、および省略可能なターゲット指定(下記) | +| `dashboard` | ダッシュボード | `dashboardName` | +| `page` | カスタムページ | `pageName`、省略可能な `params` | +| `url` | 外部の URL | `url`、`target: '_blank'` | +| `group` | 折りたたみ可能なセクション | `children`、`expanded` | + +すべての項目は一意な `id`(小文字の `snake_case`)を宣言**しなければなりません**。`homePageId` が参照するのはこれです。共通のプロパティ:`label`、`icon`、`order`、`badge`、そして下記の 3 つのゲートです。 + +### オブジェクトのエントリのターゲット指定 [#targeting-an-object-entry] + +3 つの省略可能なフィールドで `object` エントリの着地先を絞り込めます。優先順位は `recordId` → `filters` → `viewName` です。 + +- `viewName`:エントリを名前付きのリストビューに固定します。 +- `recordId`:単一のレコードへのディープリンク(「マイプロフィール」)。`{current_user_id}` / `{current_org_id}` のテンプレート変数をサポートします。 +- `filters`:一回限りのパラメータ付きの切り出しです。エントリは素のデータ画面に着地し、各条件は削除できる URL のフィルターチップになります。ビューを作成せずに「自分に割り当てられたもの」のリンクを作るのに最適です。セキュリティ機能ではありません。何を表示するかは、引き続き行レベルの権限が決めます。 + +```ts +// One navigation item of an app. +{ id: 'nav_my_open', type: 'object', label: 'My Open Deals', objectName: 'opportunity', + filters: { owner_id: '{current_user_id}', status: 'open' }, icon: 'user-check' } +``` + +### モバイルのナビゲーション [#mobile-navigation] + +モバイル専用のナビゲーションブロックはありません。`mobileNavigation` は、どのレンダラーも読み取っていなかったため、ObjectStack 17.0 で削除されました。 + +## 対象者によるアプリのゲート [#gating-apps-by-audience] + +同じデータが、まったく異なる 2 つの対象者に役立ちます。スキーマを設計する**ビルダー**と、データを入力して読むだけの**エンドユーザー**です。これらの画面はデフォルトで分けてください。各管理者が手で隠すことに頼ってはいけません。 + +| 対象者 | 画面 | ゲートするもの | +|:--|:--|:--| +| エンドユーザー(利用者) | 厳選されたアプリ → `page` / `view` のエントリ | `App.requiredPermissions`、ナビゲーション項目のゲート | +| ビルダー / 管理者 | Setup / Studio、素のオブジェクトテーブル | 機能:`setup.access`、`studio.access`、`manage_metadata` | + +組み込みの権限セットはすでにこの区分を表しています。`member_default` と `viewer_readonly` は `studio.access` / `manage_metadata` を持た**ない**ので、これらの権限セットのユーザーにはビルダー向けの画面が見えません。`admin_full_access` と `organization_admin` は持っています。 + +各ナビゲーション項目は、独立した 3 つのゲートをサポートします。 + +| ゲート | 型 | 次の場合を除き項目を隠す…… | +|:--|:--|:--| +| `requiredPermissions` | `string[]` | ユーザーが RBAC の機能(例:`manage_metadata`)を持っている | +| `visible` | CEL 式 | 述語が真と評価される(例:`'org_admin' in current_user.positions`) | +| `requiresObject` / `requiresService` | `string` | 指定したオブジェクト / カーネルサービスがインストールされている | + +```ts +// Keys of an app; its other keys are omitted. +navigation: [ + { id: 'nav_contacts', type: 'object', label: 'Contacts', objectName: 'showcase_contact' }, + // Builder-only entry — consumers never render it: + { id: 'nav_designer', type: 'component', label: 'Object Designer', + componentRef: 'metadata:resource', params: { type: 'object' }, + requiredPermissions: ['manage_metadata'] }, +] +``` + +> **無効化せず、隠します。** 無効化されていても見えているビルダー向けのエントリは、やはりノイズです。ゲートされたナビゲーション項目は、その機能を持たないユーザーには*レンダリングされません*。エンドユーザーを混乱させるグレーアウトした部品はありません。 + +利用者向けの画面をすっきり保つ習慣がさらに 2 つあります。 + +- **エンドユーザーには素のテーブルではなくページを渡します。** `page` エントリなら、公開するものを正確に厳選できます。選んだ列、固定された可視化、有効にしたフィルターとアクションだけです。`object` エントリは制限の緩いグリッドを与えます。切り替え可能なビュー、個人用ビュー、完全なツールバーです。 +- **データ画面そのものを厳選します。** 素のオブジェクトの読み取り権限を与えて 40 列のグリッドにユーザーを放り込むよりも、[厳選したビュー](/docs/build/interface/views)を優先してください。 + +アクションのゲートは両面です。UI がボタンを隠すか無効にし、**かつ**サーバーが呼び出しを拒否します。「UI ではゲートされているがサーバーでは開いている」という落とし穴はありません。[アクション](/docs/build/interface/actions)を参照してください。 + +## Setup アプリ:組み込みの例 [#the-setup-app--a-built-in-example] + +プラットフォーム自身の管理 UI である **Setup アプリ**も、同じアプリのメタデータプロトコルからレンダリングされています。ナビゲーション、ページ、ゲートはデータとして宣言され、あなたのアプリと同じレンダラーが描画します。そのゲートは、あなたのビルダー向け画面がそうあるべきとおりのものです。利用者向けの権限セットが持たない `setup.access` 機能の背後にあります。メタデータとしてのアプリがスケールする証拠が欲しいなら、すでにそれを使っています。 + +## アンチパターン [#anti-patterns] + +- **すべてのエンドユーザーをビルダー並みの共同作業者にして**、タブを 1 つずつ隠すこと。利用者とビルダーの区分をデフォルトにしてください。 +- **ビルダー向けのエントリを隠さずに無効化すること。** 見えているのに動かない部品は、やはり混乱を招きます。 +- 厳選したページでエンドユーザーに必要なものを正確に公開できるのに、**素のオブジェクトの読み取り権限を与えること**。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|:--|:--| +| [ビュー](/docs/build/interface/views) | `object` のナビゲーションエントリの着地先 | +| [ページ](/docs/build/interface/pages) | エンドユーザー向けの厳選された `page` エントリ | +| [ダッシュボード](/docs/build/interface/dashboards) | `dashboard` エントリがレンダリングするもの | +| [アクション](/docs/build/interface/actions) | ボタンとその両面のゲート | +| [権限](/docs/configure/permissions) | `requiredPermissions` の背後にある権限セット | +| [インターフェースの概要](/docs/build/interface) | すべての部品がどう組み合わさるか | diff --git a/content/docs/build/interface/dashboards.ja.mdx b/content/docs/build/interface/dashboards.ja.mdx new file mode 100644 index 00000000..04155fc1 --- /dev/null +++ b/content/docs/build/interface/dashboards.ja.mdx @@ -0,0 +1,201 @@ +--- +title: ダッシュボード +seoTitle: "ダッシュボードの構築:データセット、ウィジェット、ドリルダウン" +description: 名前付きデータセットにバインドされたチャートウィジェットで構築する分析ページ。グローバルフィルター、自動更新、元のレコードへのドリルスルーを備えます。 +editions: [community, cloud, enterprise] +translation: + source_sha: c67b6877ef40bb832312c2b13597c8aa27a0351ad3ce7bd72469323bdef0ddc6 + guide_rev: 1 + mode: auto +--- + +**ダッシュボードはウィジェットのグリッドであり、すべてのウィジェットはデータセットにバインドします。** データセットはセマンティックレイヤーで、基になるオブジェクト、結合、ディメンション、メジャーを持ちます。ウィジェットはそれらを名前で選ぶので、「売上」は、それを使うすべてのダッシュボードとレポートで同じ意味になります。 + +```ts +// One dashboard, bound to the `sales` dataset defined below. +const salesDashboard = { + name: 'sales_overview', + label: 'Sales Overview', + description: 'Key sales metrics and pipeline analysis', + refreshIntervalSeconds: 300, // auto-refresh every 5 minutes + + dateRange: { + field: 'close_date', + defaultRange: 'this_quarter', + allowCustomRange: true, + }, + + widgets: [ + { id: 'total_revenue', title: 'Total Revenue', type: 'metric', + dataset: 'sales', values: ['revenue'], + layout: { x: 0, y: 0, w: 3, h: 2 } }, + { id: 'revenue_by_region', title: 'Revenue by Region', type: 'bar', + dataset: 'sales', dimensions: ['region'], values: ['revenue'], + layout: { x: 3, y: 0, w: 6, h: 4 } }, + { id: 'deals_by_month', title: 'Deals by Month', type: 'pie', + dataset: 'sales', dimensions: ['close_month'], values: ['deal_count'], + layout: { x: 9, y: 0, w: 3, h: 4 } }, + ], +} +``` + +## ダッシュボードのプロパティ [#dashboard-properties] + +| プロパティ | 型 | 必須 | 説明 | +|:--|:--|:--|:--| +| `name` | `string` | はい | マシン名(`snake_case`) | +| `label` | `string` | はい | 表示ラベル | +| `description` | `string` | — | ダッシュボードの説明 | +| `widgets` | `DashboardWidget[]` | はい | チャートと指標のウィジェット | +| `refreshIntervalSeconds` | `number` | — | 自動更新の間隔(秒) | +| `dateRange` | `object` | — | グローバルな日付範囲のフィルター | +| `globalFilters` | `GlobalFilter[]` | — | 対話的なフィルターコントロール | + +## まずデータセット [#datasets-first] + +データセットを一度定義し、すべてのウィジェットをそれにバインドします。 + +```ts +import { defineDataset } from '@objectstack/spec/ui' + +export const salesDataset = defineDataset({ + name: 'sales', + label: 'Sales', + object: 'opportunity', + include: ['account'], + dimensions: [ + { name: 'region', field: 'account.region', type: 'string' }, + { name: 'close_month', field: 'close_date', type: 'date', dateGranularity: 'month' }, + ], + measures: [ + { name: 'revenue', field: 'amount', aggregate: 'sum' }, + { name: 'deal_count', aggregate: 'count' }, + ], +}) +``` + +集計はウィジェットではなく、データセットのメジャー(`aggregate`)に置かれます:`count`、`sum`、`avg`、`min`、`max`、`count_distinct`、`array_agg`、`string_agg`。ウィジェットの `dimensions` と `values` は、バインドしたデータセットで宣言された名前を参照しなければなりません。 + +実行時、データセットのクエリは分析サービスを通じて実行され、基になるオブジェクトと結合したオブジェクトに、呼び出し元の行レベルセキュリティの範囲が自動的に適用されます。ダッシュボードが、ユーザーの[権限](/docs/configure/permissions)では見られないレコードを見せることはありません。 + +> 16.0 以降、ウィジェットのスキーマは**厳格**です。宣言されていないトップレベルのキー(入力ミス、ハルシネーションによるキー、削除されたインラインクエリのキー(`object` + `categoryField` + `valueField` + `aggregate`、ピボットの `rowField` / `columnField`))は、**問題のキーを名指しするパースエラー**になり、データセットの形を示します。何もレンダリングしないウィジェットへと黙って取り除かれることは、もうありません。データセットを定義してウィジェットをそれにバインドしてください。`options` は、レンダラー固有の追加設定のための自由形式の逃げ道として残っています。 + +## ウィジェット [#widgets] + +| プロパティ | 型 | 必須 | 説明 | +|:--|:--|:--|:--| +| `id` | `string` | はい | 一意なウィジェット ID(`snake_case`) | +| `dataset` | `string` | はい | バインドするデータセットの名前 | +| `values` | `string[]` | はい | メジャーの名前(少なくとも 1 つ) | +| `dimensions` | `string[]` | — | ディメンションの名前:X / グループ / 分割 | +| `type` | `ChartType` | — | 可視化の種類(デフォルト `metric`) | +| `title` / `description` | `string` | — | 表示用のタイトルとサブタイトル | +| `filter` | `FilterCondition` | — | 表示範囲のフィルター | +| `layout` | `object` | — | グリッド上の位置(省略時は自動配置) | +| `colorVariant` | `enum` | — | KPI / カードのアクセントカラー | +| `compareTo` | `enum \| object` | — | 期間比較のウィンドウ | +| `filterBindings` | `object` | — | ウィジェットごとのグローバルフィルターの対応付け(下記) | + +### チャートの種類 [#chart-types] + +| 種類 | 最適な用途 | +|:--|:--| +| `metric` *(デフォルト)* | 単一の数値の KPI:売上、件数、割合 | +| `bar` / `horizontal-bar` / `column` | カテゴリーの比較 | +| `line` | 時間の経過による傾向 | +| `pie` / `donut` | 分布 | +| `area` | 時間の経過による量 | +| `scatter` | 相関 | +| `radar` | 多次元の比較 | +| `funnel` | コンバージョンの段階 | +| `gauge` / `solid-gauge` / `bullet` / `kpi` | 目標に対する進捗 | +| `treemap` / `sankey` | 階層の比率 / 流れ | +| `table` | 詳細なレコード:**ドリルスルー** | +| `pivot` | クロス集計:**ドリルスルー** | + +### レイアウト [#layout] + +ウィジェットは 12 列のグリッドに配置されます。 + +```ts +// Keys of a dashboard widget; its other keys are omitted. +layout: { + x: 0, // column position (0-11) + y: 0, // row position + w: 6, // width in columns (1-12) + h: 4, // height in rows +} +``` + +## ドリルダウン [#drill-down] + +`table` と `pivot` のウィジェットは**ドリルスルー**できます。集計された行やセルをクリックすると、そのグループの背後にある*元のレコード*を一覧するサイドドロワーが開きます。データセットは生のグループキーを保持しているので、ドロワーのフィルターは正確なレコードに一致します。ラベルから ID を推測することはありません。ドロワー内の任意の行をクリックすると、そのレコードの詳細が開きます。**グループ → レコード一覧 → レコード**という一連の流れです。 + +ドロワーには **"Open in list →"** という逃げ道もあり、同じドリルのフィルターで範囲を絞ったまま、のぞき見をオブジェクトの完全なリストページ(並べ替え、一括選択、エクスポート、共有できる URL)に格上げします。 + +チャートのウィジェットも同じようにドリルできます。棒、点、扇形をクリックすると、ドロワーにそのバケットのレコードが一覧されます。日付のディメンションなら、そのバケットの日付範囲ぴったりです。データセットが基になるオブジェクトを公開し、ウィジェットが少なくとも 1 つのディメンションでグループ化していれば、ドリルスルーは自動的に働き、ウィジェットごとの設定は不要です。`metric` のタイルは集計値だけを表示します。[レポート](/docs/build/reports)も同じようにドリルスルーできます。 + +## グローバルな日付範囲とフィルター [#global-date-range-and-filters] + +グローバルな時間のフィルターはすべてのウィジェットに適用されます。 + +```ts +// Keys of a dashboard; its other keys are omitted. +dateRange: { field: 'created_at', defaultRange: 'this_month', allowCustomRange: true } +``` + +プリセットの範囲:`today`、`yesterday`、`this_week`、`last_week`、`this_month`、`last_month`、`this_quarter`、`last_quarter`、`this_year`、`last_year`、`last_7_days`、`last_30_days`、`last_90_days`、`custom`。 + +対話的なグローバルフィルターも同じように働きます。 + +```ts +// Keys of a dashboard; its other keys are omitted. +globalFilters: [ + { name: 'region', field: 'region', label: 'Region', type: 'select' }, + { field: 'owner', label: 'Sales Rep', type: 'lookup' }, +] +``` + +各フィルターの `name`(デフォルトは `field`)がその安定した識別子です。ウィジェットが `filterBindings` で参照するキーであり、ウィジェットの式の中で `page.` として読めるダッシュボードレベルの変数です。`dateRange` という名前は組み込みの日付範囲のために予約されています。 + +### ウィジェットごとのフィルターのバインディング [#per-widget-filter-bindings] + +デフォルトでは、フィルターはすべてのウィジェットで自身の `field` に適用されます。ウィジェットがその概念を別のフィールドに保持している場合、またはフィルターを無視すべき場合は、`filterBindings` を宣言します。 + +```ts +// Keys of a dashboard; each widget's dataset, values and title are omitted (`/* … */`). +widgets: [ + // Default binding: dateRange → created_at, region → region. + { id: 'invoices_by_status', /* … */ }, + // This widget's fields differ — map each filter explicitly. + { id: 'accounts_signed', + filterBindings: { dateRange: 'signed_at', region: 'sales_region' }, /* … */ }, + // Opt out of a filter with `false`. + { id: 'total_invoices', filterBindings: { region: false }, /* … */ }, +] +``` + +> 優先順位:明示的な `filterBindings` のエントリ(文字列による上書き、または `false` によるオプトアウト)→ フィルターの旧来の `targetWidgets` 許可リスト → フィルター自身の `field`。 + +## ダッシュボードを表示する [#surfacing-a-dashboard] + +[アプリ](/docs/build/interface/apps)に `dashboard` のナビゲーションエントリを追加します。 + +```ts +// One navigation item of an app. +{ id: 'nav_analytics', type: 'dashboard', label: 'Analytics', + dashboardName: 'sales_overview', icon: 'bar-chart' } +``` + +あるいは [AI Builder](/docs/build/ai-builder) に説明します(*「地域別の売上と月次の商談の推移を含む営業概要ダッシュボード」*)。そして承認する前に、生成されたデータセット + ダッシュボードのメタデータを確認します。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|:--|:--| +| [レポート](/docs/build/reports) | 同じデータセットに対するグループ化レポートとクロス集計レポート | +| [アプリ](/docs/build/interface/apps) | ダッシュボードをナビゲーションに置く | +| [ビュー](/docs/build/interface/views) | 同じデータセットに対するインラインの `chart` リストビュー | +| [ページ](/docs/build/interface/pages) | ウィジェットのグリッドでは足りないときの自由形式のレイアウト | +| [データモデル](/docs/build/data) | データセットが集計するオブジェクト | +| [権限](/docs/configure/permissions) | ダッシュボードが継承する行レベルセキュリティ | diff --git a/content/docs/build/interface/forms.ja.mdx b/content/docs/build/interface/forms.ja.mdx new file mode 100644 index 00000000..3e5be655 --- /dev/null +++ b/content/docs/build/interface/forms.ja.mdx @@ -0,0 +1,175 @@ +--- +title: フォーム +seoTitle: "フォーム:作成と編集、セクション、送信後" +description: 作成フォームと編集フォームを 1 つのフラットなフィールドの集合から導き出し、ずれを生まずにフィールドをセクションにまとめ、送信後に起こることを制御します。 +editions: [community, cloud, enterprise] +translation: + source_sha: 7b91231facd6607664e7ec8baee9af1949872ed91a12abe14e7b20eb2d0c96e2 + guide_rev: 1 + mode: auto +--- + +**フォームはオブジェクトのフィールドの投影であり、2 つ目のフィールドリストではありません。** フィールドはオブジェクトで一度だけ宣言し、フォームは*どのフィールドをどこに*置くかを選びます。データの意味(型、検証、デフォルト、フィールドレベルのセキュリティ)がフォームに置かれることはないので、フォームがずれてデータについて偽ることはありません。 + +フォームビューの*種類*(`simple`、`tabbed`、`wizard`、`modal` など)と公開フォームの REST の契約は、[ビュー](/docs/build/interface/views)で扱っています。このページはレイアウトの問題、つまり作成と編集、グループ化、順序についてです。 + +## 作成フォーム ≠ 編集フォーム [#create-form--edit-form] + +新規レコードのフォームはいくつかの必須項目だけを尋ね、編集フォームはレコード全体をセクションに分けて表示するべきです。**2 つのフォームを作成しないでください。** 作成フォームのサブセットは、各フィールドにすでにある意図から*導き出せます*。 + +| フィールドの手がかり | 作成フォームへの影響 | +|:--|:--| +| `required: true` | 作成時に表示**しなければなりません** | +| `readonly` / 数式 / ロールアップ / 自動採番 / システムが記録するもの | 作成時には**決して**表示しません。設定できないからです | +| `defaultValue` がある | **省略できます**。自動で埋まるからです | +| `hidden` | デフォルトではどこにも表示しません | +| `group` | フィールドが属するセクション | +| *宣言の順序* | これがデフォルトの表示順序**です**。`field.order` はありません | + +そのため、妥当な作成フォーム(*編集可能で、必須または中核のフィールドを、宣言の順序で*)は、何も作成しなくてもオブジェクトから自然に導かれます。省略が正解です。余分なものを何も出力しなくても、完全で正しいフォームが得られます。完全な編集フォームも導き出され、各 `field.group` がセクションとして具体化されます。 + +### 作成フォームを手で整えるのは、レイアウトや流れが異なる場合だけ [#hand-shape-the-create-form-only-when-layout-or-flow-diverges] + +逃げ道は、作成のエントリポイントにバインドした名前付きのフォームビューです。これは**疎な上書き**として作成します。ほとんどはフィールド名のリストです。 + +```ts +import { defineView } from '@objectstack/spec' + +const data = { provider: 'object' as const, object: 'showcase_contact' } + +export const ContactViews = defineView({ + list: { + type: 'grid', data, + columns: [{ field: 'name' }, { field: 'email' }, { field: 'company' }, { field: 'stage' }], + // Bind the "+ Add record" entry point to the slim create form: + addRecord: { enabled: true, mode: 'form', formView: 'create' }, + }, + + // Full edit form — grouped by field.group; bare strings inherit field defs. + form: { + type: 'simple', data, + sections: [ + { name: 'contact', label: 'Contact', columns: 2, fields: ['name', 'email', 'phone'] }, + { name: 'work', label: 'Work', columns: 2, fields: ['company', 'title'] }, + { name: 'status', label: 'Status', columns: 2, fields: ['stage', 'lead_score'] }, + ], + }, + + formViews: { + // Sparse create override: only the core fields, one section. + create: { + type: 'simple', data, title: 'New contact', + sections: [ + { label: 'Who is this?', columns: 1, fields: ['name', 'email', 'phone', 'company'] }, + ], + }, + }, +}) +``` + +バインディングは `addRecord.mode: 'form'` + `addRecord.formView: 'create'` です。`formViews.create` がなければ、作成のエントリはデフォルトを導き出します。あれば、作成時にはそれが優先されます。 + +| 本当に必要なこと | 使うもの | +|:--|:--| +| 作成時に尋ねるフィールドを減らす(必須 + いくつかの中核) | **純粋な導出**。手で書かないでください | +| 作成時のグループ化が異なるが、小さなサブセットにすぎない | たいていはやはり導出 | +| 作成がウィザード / 多段階、作成専用の文言、条件付きの表示を持つ | **`formViews.create` を手で書く** | + +> 目安:*フィールドのサブセットが異なる* → 導出する。*レイアウトや流れが異なる* → 上書きする。フィールドを減らすためだけに完全な作成フォームを書くと、2 つの成果物がずれる罠にまっすぐはまります。必須フィールドを追加し、作成フォームのことを忘れ、作成時に実行時の「必須フィールドがありません」が出る、という具合です。 + +作成のフィールドは編集のフィールドの*同じ順序とグループの*サブセットなので、「4 つのフィールドでクイック作成 → 保存 → 完全なレコードに着地」が視覚的に途切れません。導出はそれを無償で保ちます。 + +## フィールドのグループ化と順序 [#field-grouping-and-order] + +モデルはフラットなフィールドの集合です。グリッドはフラットな列を表示します。フォームにはセクションが必要です。これは矛盾ではありません。1 つのフラットな集合を、異なるレンズを通して見ているのです。 + +| レンズ | フィールドの形 | 理由 | +|:--|:--|:--| +| オブジェクトの定義 | フラット | モデルは*どんなデータが存在するか*を記述します | +| テーブル / グリッド | フラットな列 | グリッドはレコード × フィールドの行列です | +| フォーム / レコードページ | グループ化されたセクション | 1 件のレコードを読む人間には区切りが必要です | + +グループ化の概念は **2 つ**あります。区別しておいてください。 + +**1. 意味上のグループ化:`field.group`(オブジェクト上)。** フィールドの論理的な居場所です。モデルとともに移動し、自動生成されるフォームの*デフォルトの*セクション分けの元になります。 + +```ts +// Fields of an object; the object around them is omitted. +fields: { + name: Field.text({ label: 'Full name', group: 'contact' }), + email: Field.email({ label: 'Email', group: 'contact' }), + stage: Field.select({ + label: 'Stage', + group: 'status', + options: [ + { label: 'Lead', value: 'lead' }, + { label: 'Customer', value: 'customer' }, + ], + }), +} +``` + +**2. レイアウト上のグループ化:フォームの `sections`(ビュー上)。** 特定のフォームの明示的な配置です。どのフィールドを、どのセクションに、何列(`columns`)で置くか、折りたたみ可能か。`field.group` をデフォルトとして継承し、必要に応じてフォームごとに上書きします。 + +```ts +// Keys of a view container; `data` and its other keys are omitted. +form: { + type: 'simple', + sections: [ + { name: 'contact', label: 'Contact', columns: 2, fields: ['name', 'email', 'phone'] }, + { name: 'status', label: 'Status', columns: 2, fields: ['stage'] }, + ], +} +``` + +**順序は作成した順序です。** `field.order` はありません。オブジェクト上のフィールドの宣言の順序が、どこでもデフォルトの表示順序であり、`sections`(とその中のフィールド)は順序付きのリストです。 + +> **「グループ」の罠。** グリッドビューの `groupByField` は、フィールドの*値*で **レコード(行)** をグループ化します。`stage = qualified` の行がすべてまとまります。フォームのセクションは **フィールド(列)** を視覚的にグループ化します。同じ言葉で、無関係な 2 つの軸です。グリッドのグループがフォームをセクションに分けることは決してありません。 + +## 送信後に起こること [#what-happens-after-submit] + +送信後の体験を制御するには、フォームビューに `submitBehavior` を追加します。 + +```ts +// Keys of a form view; its other keys are omitted. +submitBehavior: { + kind: 'thank-you', + title: 'Thanks!', + message: 'A specialist will reach out within 24 hours.', +} +``` + +| `kind` | 動作 | +|:--|:--| +| `thank-you` *(デフォルト)* | フォームを確認パネル(`title`、`message`)に置き換えます | +| `redirect` | `delayMs`(デフォルト 0)の後に `url` へ移動します。マーケティングページに最適です | +| `continue` | 次の回答のためにフォームをリセットします。キオスク、一括入力向けです | +| `next-record` | キュー内の次のレコードに進みます(内部モード) | + +公開フォームと内部フォームのどちらも、`?prefill_=` という URL パラメータに従います。メールのリンクやキャンペーンページからフォームに値を入れておけます。事前入力は UX の近道であって、権限のすり抜けではありません。値はやはり検証と、(公開フォームの場合は)サーバー側のフィールドのホワイトリストを通ります。 + +``` +/console/f/contact-us?prefill_company=Acme&prefill_email=ada@example.com +/console/forms/quick_create?prefill_lead_source=event_booth_2026 +``` + +## レコードの詳細はフォームのバインディングではなくロールで決まる [#record-detail-is-driven-by-roles-not-a-form-binding] + +フォームビューをレコード詳細画面に固定するオブジェクトレベルのキーはありません。詳細のレンダリングは、オブジェクトの画面をまたぐ**意味上のロール**から導き出されます。`nameField`(表示名)、`highlightFields`(最も重要なフィールドの帯)、`stageField`(ライフサイクルの進捗ステッパー)、そして `fieldGroups` + `Field.group`(フォーム、モーダル、詳細ページで共有されるセクション分け)です。ロールから導き出せる以上の特注のレイアウトがレコードページに必要なときは、そのオブジェクトにカスタムの[ページ](/docs/build/interface/pages)を割り当てます。 + +## アンチパターン [#anti-patterns] + +- **手で作成した 2 つの完全なフィールドリスト**(`contact_create_form` + `contact_edit_form`)。ずれることが確実です。 +- **フィールドの型 / 検証 / 選択肢をフォームで書き直すこと。** データの意味はオブジェクトにだけ置きます。 +- **すべてのフォームでグループ化を打ち直すこと。** `field.group` を一度宣言し、本当に異なる場合だけ上書きします。 +- **フォームを満たすためにデータモデルに構造的な入れ子を加えること。** モデルはフラットのままにし、フォームがグループ化します。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|:--|:--| +| [ビュー](/docs/build/interface/views) | フォームビューの種類、公開フォームの共有、ビューの完全なスキーマ | +| [データモデル](/docs/build/data) | フィールドとその意図が宣言される場所 | +| [アクション](/docs/build/interface/actions) | フォームビューを開く `type: 'form'` のアクション | +| [ページ](/docs/build/interface/pages) | 導出されたレイアウトを超えるカスタムのレコードページ | +| [権限](/docs/configure/permissions) | フォームが従うフィールドレベルのセキュリティ | diff --git a/content/docs/build/interface/index.ja.mdx b/content/docs/build/interface/index.ja.mdx new file mode 100644 index 00000000..dc6217f8 --- /dev/null +++ b/content/docs/build/interface/index.ja.mdx @@ -0,0 +1,49 @@ +--- +title: インターフェース +seoTitle: "インターフェース:アプリ、ビュー、フォーム、ダッシュボード、ページ" +description: アプリ、ビュー、フォーム、ダッシュボード、ページ、アクション。ユーザーが目にするすべての画面をメタデータとして宣言し、ObjectOS がレンダリングします。 +editions: [community, cloud, enterprise] +translation: + source_sha: b5a75aba85c279c1de7f1e2e11254b300b51906392cfaa0a5f4e1f50a803e61c + guide_rev: 1 + mode: auto +--- + +**ユーザーが目にするものはすべてメタデータです。** インターフェースはデータとして宣言し、オブジェクトと同じライフサイクルを持ちます。バージョン管理し、パッケージで出荷し、AI Builder に生成させることができ、準拠したレンダラーならどれでも描画できます。 + +各部品は上から下へと組み合わさります。 + +1. **アプリ**は外枠です。ナビゲーション、ブランディング、対象者のゲートを持ちます。 +2. その**ナビゲーション**は、オブジェクト、ダッシュボード、ページ、URL を指します。 +3. オブジェクトのエントリは**ビュー**に着地します。グリッド、カンバン、カレンダーなどです。 +4. レコードを開いたり作成したりすると**フォーム**がレンダリングされます。セクションはフィールドのメタデータから導き出され、モードごとに上書きできます。 +5. **ダッシュボード**は同じデータを、名前付きデータセットにバインドされたチャートウィジェットに集約します。 +6. **ページ**はオブジェクトの枠から完全に抜け出します。コンポーネントとリージョンで構成する自由形式のレイアウトです。 +7. **アクション**は上記のすべてにボタンを置きます。1 つの宣言で、UI、REST、フロー、AI エージェントから呼び出せます。 + +## 概念 [#the-concepts] + +| 概念 | 宣言するもの | ページ | +|:--|:--|:--| +| アプリ | ナビゲーションの外枠、ブランディング、対象者のゲート | [アプリ](/docs/build/interface/apps) | +| ビュー | レコードのレンダリング方法:グリッド、カンバン、カレンダー、ガント…… | [ビュー](/docs/build/interface/views) | +| フォーム | 作成 / 編集のレイアウト、セクション、フィールドのグループ化 | [フォーム](/docs/build/interface/forms) | +| ダッシュボード | チャートウィジェット、グローバルフィルター、ドリルダウン | [ダッシュボード](/docs/build/interface/dashboards) | +| ページ | 自由形式のコンポーネントレイアウト。パッケージ化されたドキュメントページ | [ページ](/docs/build/interface/pages) | +| アクション | あらゆる場所にボタンとして表示される名前付きの操作 | [アクション](/docs/build/interface/actions) | + +UI はデータなので、表示するレコードと同じ[権限](/docs/configure/permissions)に従います。また [AI Builder](/docs/build/ai-builder) は、同じ型付きのインターフェースを通じて、そのどれでも生成または変更できます。欲しいインターフェースを説明し、差分を確認して、承認します。 + +> まずデータから始めてください。ビューとフォームは[オブジェクトの定義](/docs/build/data)から妥当なデフォルトを導き出すので、インターフェースのメタデータを書くのは、デフォルトが望みどおりでない箇所だけです。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|:--|:--| +| [アプリ](/docs/build/interface/apps) | オブジェクトをナビゲーションできる外枠で包む | +| [ビュー](/docs/build/interface/views) | レコードのレンダリング方法を選ぶ | +| [フォーム](/docs/build/interface/forms) | 作成と編集の体験を形作る | +| [ダッシュボード](/docs/build/interface/dashboards) | 分析ウィジェットを追加する | +| [ページ](/docs/build/interface/pages) | 自由形式のレイアウトを構成する | +| [アクション](/docs/build/interface/actions) | 操作をボタンの背後に置く | +| [データモデル](/docs/build/data) | すべての画面が読み取るオブジェクト | diff --git a/content/docs/build/interface/pages.ja.mdx b/content/docs/build/interface/pages.ja.mdx new file mode 100644 index 00000000..f46decbb --- /dev/null +++ b/content/docs/build/interface/pages.ja.mdx @@ -0,0 +1,166 @@ +--- +title: ページ +seoTitle: "自由形式のページ:リージョン、コンポーネント、ドキュメントページ" +description: リージョン、コンポーネント、ローカル状態で構成する自由形式のレイアウト。さらに、パッケージの中に同梱して出荷する Markdown のドキュメントページ。 +editions: [community, cloud, enterprise] +translation: + source_sha: 4a88c0ee1ee387d77d686cf62b3d5270e2d183f2d362cd66274326205db8a3d4 + guide_rev: 1 + mode: auto +--- + +**ページは自由形式のコンテナです。** 単一のオブジェクトにバインドされる[ビュー](/docs/build/interface/views)とは異なり、ページは複数のコンポーネントを組み合わせ、ビューを埋め込み、ローカル状態を管理します。ホーム画面、カスタムのレコードレイアウト、ユーティリティパネルなどです。 + +```ts +// One page. +const homePage = { + name: 'sales_home', + label: 'Sales Home', + type: 'home', + regions: [ + { name: 'header', width: 'full', components: [ + { type: 'metric_card', id: 'total_revenue', label: 'Total Revenue', + properties: { dataset: 'sales', values: ['revenue'], format: 'currency' } }, + ]}, + { name: 'main', width: 'large', components: [ + { type: 'list_view', id: 'recent_deals', label: 'Recent Deals', + properties: { object: 'opportunity', view: 'recent_open', limit: 10 } }, + ]}, + ], +} +``` + +## ページのプロパティ [#page-properties] + +| プロパティ | 型 | 必須 | 説明 | +|:--|:--|:--|:--| +| `name` | `string` | はい | マシン名(`snake_case`) | +| `label` | `string` | はい | 表示ラベル | +| `type` | `enum` | — | ページの種類(デフォルト `'record'`、下記を参照) | +| `object` | `string` | — | 関連するオブジェクト(`record` の種類の場合) | +| `template` | `string` | — | レイアウトテンプレートの名前(デフォルト `'default'`) | +| `regions` | `PageRegion[]` | — | コンポーネントを含むレイアウトの区画 | +| `variables` | `PageVariable[]` | — | ローカル状態の変数 | +| `isDefault` | `boolean` | — | その種類のデフォルトのページかどうか | +| `assignedProfiles` | `string[]` | — | このページにアクセスできるプロファイル | + +### ページの種類 [#page-types] + +| 種類 | 用途 | +|:--|:--| +| `record` | 1 つのオブジェクトのレコードに結び付いたカスタムの詳細ページ | +| `home` | アプリのランディング / 入口 | +| `app` | 一般的なアプリケーションのレイアウト | +| `utility` | ツール、設定、ウィザード | +| `list` | データ駆動のインターフェース画面 | + +有効なのはこの 5 つの種類だけです。レンダラーが出荷されなかった以前のロードマップ上の種類は、スキーマから削除されました。 + +## リージョン [#regions] + +リージョンはレイアウトの区画で、それぞれがコンポーネントを保持します。 + +```ts +// Keys of a page; its other keys, and the regions' components, are omitted. +regions: [ + { name: 'sidebar', width: 'small', components: [/* … */] }, + { name: 'content', width: 'large', components: [/* … */] }, +] +``` + +`width` には `'small'`、`'medium'`、`'large'`、`'full'` を指定できます。 + +## コンポーネント [#components] + +コンポーネントはリージョンの中の構成要素です。 + +```ts +// One page component; the page and region around it are omitted. +{ + type: 'chart', + id: 'revenue_chart', + label: 'Revenue Trend', + properties: { chartType: 'line', dataset: 'sales', + dimensions: ['close_month'], values: ['revenue'] }, + events: { onClick: "navigate_to('opportunity_detail', { id: $event.id })" }, + visibleWhen: "'sales_manager' in os.user.positions", +} +``` + +分析を扱うコンポーネント(`chart`、`metric_card`)は名前付きの**データセット**にバインドし、その `dimensions` + `values` を名前で選びます。これは[ダッシュボード](/docs/build/interface/dashboards)のウィジェットと同じ ADR-0021 の形であり、Studio が出力する唯一の形です。削除されたインライン分析の形(コンポーネント上の `object` + `categoryField` + `valueField` + `aggregate`)は、16.0 以降はパースエラーになり、黙って無視される何もしない設定ではありません。 + +| プロパティ | 型 | 説明 | +|:--|:--|:--| +| `type` | `string` | 標準のコンポーネントの種類、またはカスタムの文字列 | +| `id` | `string` | 一意なコンポーネントインスタンスの ID | +| `properties` | `object` | コンポーネント固有の設定 | +| `events` | `object` | イベントハンドラー(アクションの式) | +| `visibleWhen` | `string` | CEL の表示条件の述語。TRUE のときだけコンポーネントがレンダリングされます(`visibility` は非推奨のエイリアスで、今も受け付けられ、`visibleWhen` に正規化されます) | +| `style` / `className` | — | その場限りの CSS | +| `responsiveStyles` | `object` | **推奨される**スタイル指定の手段:デスクトップ優先のブレークポイントごとのスタイルマップ(`large` を基準に、`medium` / `small` / `xsmall` で上書き)。スコープ付きの CSS にコンパイルされます。`var(--space-8)` のようなデザイントークンを優先してください | + +標準の、名前空間付きのコンポーネントの種類: + +| 名前空間 | 種類 | +|:--|:--| +| 構造 | `page:header`、`page:footer`、`page:sidebar`、`page:tabs`、`page:accordion`、`page:card`、`page:section` | +| レコードのコンテキスト | `record:details`、`record:highlights`、`record:related_list`、`record:activity`、`record:chatter`、`record:path`、`record:alert`、`record:quick_actions`、`record:reference_rail`、`record:history` | +| ナビゲーション | `app:launcher`、`nav:menu`、`nav:breadcrumb` | +| ユーティリティ | `global:search`、`global:notifications`、`user:profile` | +| AI | `ai:chat_window`、`ai:suggestion` | +| 要素 | `element:text`、`element:number`、`element:image`、`element:divider`、`element:button`、`element:filter`、`element:form`、`element:record_picker`、`element:text_input` | + +コンポーネントは、`dataSource`(複数オブジェクトのページ向けの、要素ごとのオブジェクトのバインディング)、`responsive`、`aria` の設定を持つこともできます。プロジェクト固有のウィジェットには、カスタムの文字列の種類も受け付けられます。 + +## 変数 [#variables] + +ページは、コンポーネントの間で共有されるローカル状態を保持します。 + +```ts +// Keys of a page; its other keys are omitted. +variables: [ + { name: 'selected_tab', type: 'string', defaultValue: 'overview' }, + { name: 'date_range', type: 'object', defaultValue: { start: null, end: null } }, +] +``` + +`type` には `'string'`(デフォルト)、`'number'`、`'boolean'`、`'object'`、`'array'`、`'record_id'` を指定できます。 + +> `record` の種類のページは、特注のレコードレイアウトのためにサポートされている方法です。ロールから導き出される詳細ページ([フォーム](/docs/build/interface/forms)を参照)では足りないときに使います。全幅のヘッダーに `record:highlights` を、サイドバーに `record:details` と `record:activity` を、メインのリージョンに `record:related_list` のコンポーネントを配置します。 + +[アプリ](/docs/build/interface/apps)から `page` のナビゲーションエントリでページを表示するか、`homePageId` でアプリのランディングページにします。 + +## ドキュメントページ [#doc-pages] + +**ドキュメント**はパッケージのドキュメントのページです。フラットな `src/docs/` ディレクトリに置いたプレーンな Markdown ファイルで、パッケージの成果物にコンパイルされ、UI の `/docs/` でレンダリングされます。ドキュメントは、AI アシスタントがパッケージについての質問に答えるときの根拠にもなります。 + +``` +src/docs/ + crm_index.md → doc name "crm_index" + crm_user_guide.md → doc name "crm_user_guide" +``` + +ファイル名の語幹がドキュメントの `name` になります。ディレクトリによる分類も、順序のファイルもありません。フラットな配置により相互参照が安定します。リンクはパスではなく、常にベース名で解決されます。 + +| ルール | 詳細 | +|:--|:--| +| 命名 | `snake_case`。ビルドの lint は**名前空間のプレフィックス**を要求します(`user_guide` ではなく `crm_user_guide`) | +| フロントマター | 省略可能。`title` と `description` が読み取られます。タイトルはフロントマター → 最初の `#` 見出し → ドキュメント名の順に解決されます | +| 相互参照 | プレーンな相対リンク(`[overview](./crm_index.md)`)。UI では `/docs/` に書き換えられ、GitHub ではそのまま機能します。**同じパッケージ内の壊れたリンクはビルドを失敗させます** | +| Markdown | CommonMark + GFM:テーブル、ハイライト付きのフェンスコード、見出しのアンカー、GitHub のアラート(`> [!TIP]`) | +| ビルド時に拒否されるもの | **MDX / 埋め込みコンポーネント**(ドキュメントはコードではなくデータであり、信頼の境界です)と**画像の参照**(まだアセットサービスがなく、壊れた画像よりも早い失敗の方がよいため) | + +ドキュメントの解決はパッケージ単位です。インストールされた 2 つのパッケージがそれぞれ同じ素の名前のドキュメントを出荷しても共存でき、どちらも他方を上書きしません。 + +> 動的なコンテンツ(ライブのフロー図、レコードのテーブル)については、コンポーネントを埋め込もうとしないでください。URL でメタデータにリンクします。ライブのビューはプラットフォームがレンダリングし、ドキュメントはそれを指し示すだけです。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|:--|:--| +| [アプリ](/docs/build/interface/apps) | ページをナビゲーションに置き、`homePageId` を設定する | +| [ビュー](/docs/build/interface/views) | ページが埋め込むオブジェクトの画面 | +| [フォーム](/docs/build/interface/forms) | ロールから導き出されるレコードレイアウトとカスタムのレコードページ | +| [ダッシュボード](/docs/build/interface/dashboards) | 自由形式の代わりの、分析に特化したレイアウト | +| [アクション](/docs/build/interface/actions) | `element:button` のターゲットと `modal` の種類のアクション | +| [AI Builder](/docs/build/ai-builder) | チャットでページのメタデータを生成する | diff --git a/content/docs/build/interface/views.ja.mdx b/content/docs/build/interface/views.ja.mdx new file mode 100644 index 00000000..cb5582a3 --- /dev/null +++ b/content/docs/build/interface/views.ja.mdx @@ -0,0 +1,294 @@ +--- +title: ビュー +seoTitle: "ビュー:リスト、カンバン、カレンダー、ガント、フォーム" +description: リスト、フォーム、カンバン、カレンダー、ガントなど。すべてのオブジェクトの画面がどう宣言されるか。 +editions: [community, cloud, enterprise] +translation: + source_sha: f7208e0810feac70de2d69b3c01e2700665308487ccc26ac2e84bd3f0357fc0d + guide_rev: 1 + mode: auto +--- + +**ビュー**は、ユーザーがレコードを見て編集するための方法です。ビューは宣言的なメタデータで、オブジェクトと同じライフサイクルを持ちます。一度宣言し、パッケージとともに出荷し、どこでもレンダリングできます。 + +2 つの種類があります。 + +- **リストビュー**:多数のレコードを可視化します(グリッド、カンバン、カレンダー……) +- **フォームビュー**:単一のレコードを表示 / 編集します(シンプル、タブ、ウィザード……) + +スキーマのソース:[`packages/spec/src/ui/view.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/ui/view.zod.ts)。 + +## 最短の宣言 [#the-shortest-possible-declaration] + +ビューを 1 つも宣言しなくても、すべてのオブジェクトには自動的にデフォルトのグリッドリストとシンプルなフォームが用意されます。それらを上書きまたは拡張するには、オブジェクトのビューを、オブジェクトの中ではなくその隣にある `defineView` コンテナで宣言します。`list` がデフォルトのリスト、`form` がデフォルトのフォームで、各ビューは `data` で自分のオブジェクトを指定します。 + +```ts +// src/ui/views/support_ticket.view.ts +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'support_ticket' }; + +export const SupportTicketViews = defineView({ + list: { + type: 'grid', + data, + columns: ['subject', 'status', 'priority', 'assignee'], + }, + form: { + type: 'simple', + data, + sections: [ + { name: 'details', label: 'Details', fields: ['subject', 'description', 'priority', 'status', 'assignee'] }, + ], + }, +}); +``` + +コンテナを、それが記述するオブジェクトの隣に、スタックに登録します。 + +```ts +// objectstack.config.ts +export default defineStack({ + // ... + views: [SupportTicketViews], +}); +``` + +複数のビューを持つオブジェクト(たとえば同じデータに対するカンバン*と*カレンダー)では、同じコンテナに名前付きのビューを追加します。ビュー切り替え用には `listViews`、名前で開くフォーム用には `formViews` です。すべてのリストビューは、種類にかかわらずトップレベルの `columns` を持ちます。 + +```ts +export const SupportTicketViews = defineView({ + list: { type: 'grid', data, columns: ['subject', 'status', 'priority', 'assignee'] }, + listViews: { + by_status: { + label: 'By status', type: 'kanban', data, columns: ['subject', 'priority'], + kanban: { groupByField: 'status', columns: ['subject', 'priority'] }, + }, + schedule: { + label: 'Schedule', type: 'calendar', data, columns: ['subject', 'due_at'], + calendar: { startDateField: 'due_at', titleField: 'subject' }, + }, + by_owner: { + label: 'By owner', type: 'grid', data, columns: ['assignee', 'subject', 'status', 'priority'], + sort: [{ field: 'assignee', order: 'asc' }], + }, + }, + formViews: { + quick: { type: 'modal', data, sections: [{ name: 'quick', label: 'Quick edit', fields: ['status', 'priority', 'assignee'] }] }, + full: { + type: 'tabbed', data, + sections: [ + { name: 'details', label: 'Details', fields: ['subject', 'description'] }, + { name: 'triage', label: 'Triage', fields: ['status', 'priority', 'assignee'] }, + ], + }, + }, +}); +``` + +コンテナとそのルールは、ObjectStack の [View metadata](https://objectstack.ai/docs/ui/views) に完全に記載されています。 + +## リストビューの種類 [#list-view-types] + +すべてのリストビューには、種類にかかわらずトップレベルの `columns` が必要です。その種類のそれ以外の設定は、種類の名前を付けた設定ブロックに入れます。 + +| `type` | レンダリングするもの | 設定ブロックとその必須キー | よく使う省略可能なキー | +|:--|:--|:--|:--| +| `grid` | データテーブル(デフォルト) | — | | +| `kanban` | 列のあるボード | `kanban: { groupByField, columns }` | `summarizeField`、`titleField` | +| `gallery` | カードの束 | `gallery`(必須キーなし) | `coverField`、`titleField` | +| `calendar` | 月 / 週 / 日 | `calendar: { startDateField }` | `endDateField`、`titleField`、`colorField` | +| `timeline` | 時系列のフィード | `timeline: { startDateField, titleField }` | `endDateField`、`groupByField` | +| `gantt` | プロジェクトのタイムライン + 依存関係 | `gantt: { startDateField, endDateField, titleField }` | `progressField`、`dependenciesField` | +| `map` | 地図上のピン | `map`(必須キーなし) | `locationField`、または `latitudeField` + `longitudeField` | +| `tree` | 自己参照の階層 | `tree`(必須キーなし) | `parentField`、`labelField` | +| `chart` | 埋め込みのチャート | `chart: { dataset, values }` | `chartType`、`dimensions` | + +以下の断片はそれぞれ単一のリストビューです。どれも上のように、`data` で自分のオブジェクトを指定して、`defineView` コンテナの `list` として、または `listViews` のエントリとして入れます。 + +### リストの共通オプション [#common-list-options] + +```ts +// One list view; the container and `data` are omitted (see above). +// P is the predicate template tag: import { P } from '@objectstack/spec' +{ + type: 'grid', + columns: ['subject','status','priority','assignee','created_at'], + + filter: [ { field: 'archived', operator: 'equals', value: false } ], + sort: [ { field: 'created_at', order: 'desc' } ], + pagination: { pageSize: 25 }, + searchableFields: ['subject','description'], + filterableFields: ['status','priority','assignee'], + + navigation: { mode: 'drawer' }, // 'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none' + selection: { type: 'multiple' }, // 'none' | 'single' | 'multiple' + + rowActions: ['close_ticket','assign_to_me'], + bulkActions: ['bulk_close','bulk_export'], + + conditionalFormatting: [ + { condition: P`record.priority == 'urgent'`, style: { background: '#fef2f2', fontWeight: '600' } } // style values are strings + ], + + exportOptions: ['csv','xlsx'], + emptyState: { title: 'No tickets yet', message: 'Create one to get started', icon: 'inbox' } +} +``` + +`filter` と `sort` は [ObjectQL](/docs/reference/objectql) にコンパイルされます。`rowActions` と `bulkActions` は名前で[アクション](./actions)を参照します。 + +### カンバン [#kanban] + +```ts +// One list view; the container and `data` are omitted (see above). +{ + type: 'kanban', + columns: ['subject', 'priority', 'assignee'], // still required at the top level + kanban: { + groupByField: 'status', // discrete field — usually a select + summarizeField: 'amount', // optional total per column + columns: ['subject', 'priority', 'assignee'] // fields shown on each card + } +} +``` + +ボードの列は `groupByField` の値から作られ、その順序と色はそのフィールドの `select` の選択肢の定義から取られます。 + +列の間でドラッグ&ドロップすると、グループ化しているフィールドを設定する更新が発行されます。権限のルールは手動での編集と同じです。 + +### カレンダー [#calendar] + +```ts +// One list view; the container and `data` are omitted (see above). +{ + type: 'calendar', + columns: ['subject', 'start_at', 'end_at', 'priority'], + calendar: { + startDateField: 'start_at', + endDateField: 'end_at', // optional — single-point if omitted + titleField: 'subject', + colorField: 'priority' // optional — colours events by value + } +} +``` + +### ガント [#gantt] + +```ts +// One list view; the container and `data` are omitted (see above). +{ + type: 'gantt', + columns: ['name', 'start_at', 'due_at', 'percent_complete'], + gantt: { + startDateField: 'start_at', + endDateField: 'due_at', + titleField: 'name', + progressField: 'percent_complete', // optional, drives the progress bar + dependenciesField: 'depends_on' // optional — multiselect lookup to same object + } +} +``` + +### ツリー [#tree] + +```ts +// One list view; the container and `data` are omitted (see above). +{ + type: 'tree', + columns: ['name'], + tree: { + parentField: 'parent_id', // self-lookup that builds the hierarchy + labelField: 'name', // shown indented in the first column + defaultExpandedDepth: 1 // optional — 0 = roots only, omit = expand all + } +} +``` + +自己参照するオブジェクト(カテゴリー、部品表、組織単位、入れ子のコメント)では、ツリーグリッドが `parentField` のポインターに従って行を入れ子にします。 + +### チャート(インライン) [#chart-inline] + +`chart` リストビューは、名前付きの**データセット**(セマンティックな分析レイヤー、ADR-0021)にバインドし、そのディメンションとメジャーを**名前で**選びます。指標を一度定義すれば、同じデータセットにバインドされたすべてのチャート、ダッシュボード、レポートの一貫性が保たれます。 + +```ts +// One list view; the container and `data` are omitted (see above). +{ + type: 'chart', + columns: ['status'], + chart: { + chartType: 'bar', // 'bar' | 'line' | 'pie' | 'area' | 'scatter' + dataset: 'tickets_by_status', // a dataset declared with defineDataset(...) + dimensions: ['status'], // dataset dimension name(s) — X / group / split + values: ['ticket_count'] // dataset measure name(s) — the value axis + } +} +``` + +インラインの `xAxisField` / `yAxisFields` / `aggregation` / `groupByField` という形は 9.0 で削除されました。今では、すべてのチャート、ダッシュボードのウィジェット、レポートがデータセットにバインドします。より高度な、オブジェクトをまたぐ分析には、専用のレポート画面を使ってください。 + +## フォームビューの種類 [#form-view-types] + +| `type` | レイアウト | +|:--|:--| +| `simple` | 単一の列またはセクション(デフォルト) | +| `tabbed` | タブ付きのセクション | +| `wizard` | ステップごとの流れ | +| `split` | マスター / 詳細の 2 ペイン | +| `drawer` | サイドパネルのフォーム | +| `modal` | ダイアログのフォーム | + +```ts +// One form view; the container and `data` are omitted (see above). +{ + type: 'tabbed', + sections: [ + { label: 'Overview', fields: ['subject','status','priority','assignee'] }, + { label: 'Customer', fields: ['customer','contact','email','phone'] }, + { label: 'Resolution', fields: ['resolution_notes','resolved_at'] } + ], + submitBehavior: { kind: 'next-record' } // 'thank-you' | 'redirect' | 'continue' | 'next-record' +} +``` + +### 公開フォーム [#public-forms] + +フォームは匿名でアクセスできるようにできます。 + +```ts +// One form view; the container and `data` are omitted (see above). +{ + type: 'simple', + sections: [ { fields: ['name','email','message'] } ], + sharing: { + enabled: true, + publicLink: 'contact-us', // slug under /forms/ + allowAnonymous: true + }, + submitBehavior: { kind: 'thank-you' } +} +``` + +これにより `GET /api/v1/forms/contact-us` と `POST /api/v1/forms/contact-us/submit` が自動的に公開されます。認証を必要としない公開フォームのルートです。[REST API → Public forms](/docs/reference/rest-api#public-forms) を参照してください。 + +## 表示条件、ARIA、テーマ [#visibility-aria-theming] + +- `visibleOn: P\`...\``:フォームの**セクション**と**フィールド**で、ユーザー / レコード / 環境に基づいてそれらを隠します(CEL の述語) +- `aria: { label, description, ... }`:スクリーンリーダー向けの ARIA 属性(リストビューとフォームビュー) +- `appearance: { showDescription, allowedVisualizations: [...] }`:リストビューで、エンドユーザーが切り替えられるリストの種類を制限します + +## チャットで構築する [#build-by-chat] + +ビューのメタデータを手で書くことは通常ありません。AI Builder にこう伝えます。 + +> *「support_ticket オブジェクトのカンバンビューを、ステータスでグループ化して追加して。カードには件名と優先度を表示して。列はステータスに応じて赤 / 黄 / 緑に色分けして。」* + +AI Builder はメタデータツールを呼び出し、差分をキューに入れ、あなたが承認するとビューが表示されます。[AI Builder](/docs/build/ai-builder) を参照してください。 + +## 関連項目 [#see-also] + +- [データモデル](/docs/build/data):ビューが読み取るオブジェクト +- [アクション](./actions):`rowActions` / `bulkActions` が参照するもの +- [ObjectQL](/docs/reference/objectql):`filter` / `sort` のコンパイル先 +- [CEL](/docs/reference/cel):`conditionalFormatting`、`visibleOn` +- [`@objectstack/spec/ui/view.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/ui/view.zod.ts):スキーマ From 577b635a3b03b0acb96539b476f401bd832ec8d9 Mon Sep 17 00:00:00 2001 From: Steedos Date: Thu, 29 Oct 2026 12:05:03 +0000 Subject: [PATCH 4/6] fix(search): keep one locale index and read search text from build output (#393) /api/search kept every locale's Orama index (30-40 MB each) and every page module page.data.load() imported, so one Worker isolate grew from 58 MB after its first locale to 283 MB after all eight, and #390's ja pages tipped it past 128 MB into 503s. - scripts/gen-search-data.mjs computes each page's structuredData before next build with fumadocs-mdx's own remark chain and writes one gzipped module per locale to apps/docs/.search/; the route reads that instead of importing page modules. - The route keeps the most recently used locale's index only. - check-search-heap.mjs asks the built route in every locale twice and fails above 100 MB or when the second pass keeps growing; it runs in ci.yml after check-search-locales and its --self-test is in SELF_TESTED. Measured on the built route: levels at 50-54 MB across both passes. Fixes #393 Claude-Session: https://claude.ai/code/session_01QsR2jRsjbApbf83iyTDDXm Co-Authored-By: Claude --- .github/scripts/check-search-heap.mjs | 179 ++++++++++++++++++++++++++ .github/workflows/ci.yml | 12 ++ apps/docs/app/api/search/route.ts | 90 +++++++++++-- apps/docs/package.json | 6 +- apps/docs/scripts/gen-search-data.mjs | 121 +++++++++++++++++ tools/ci-scripts/run-self-tests.mjs | 8 ++ 6 files changed, 401 insertions(+), 15 deletions(-) create mode 100644 .github/scripts/check-search-heap.mjs create mode 100644 apps/docs/scripts/gen-search-data.mjs diff --git a/.github/scripts/check-search-heap.mjs b/.github/scripts/check-search-heap.mjs new file mode 100644 index 00000000..414c4e57 --- /dev/null +++ b/.github/scripts/check-search-heap.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/** + * The search heap gate for #393: the BUILT `/api/search` route stays under a + * heap budget while it is asked in every locale `apps/docs/lib/i18n.ts` + * declares. + * + * #390 added eleven ja pages, every gate was green, and after the deploy + * `/api/search` answered 503 in two or three locales per smoke run, so the site + * rolled back twice. A Cloudflare Worker isolate has 128 MB. The route kept + * every locale's Orama index (30–40 MB each) in one map, so the second or third + * locale an isolate served pushed it over. Nothing measured that before the + * deploy: the route is dynamic, and `check-search-locales.mjs` asks the right + * questions but not how much memory the answers cost. Measured on the built + * route at `09784e7`, heap after a forced GC: 58 MB after the first locale, + * 283 MB after all eight. + * + * So this gate asks the way an isolate is asked. It loads the route handler out + * of `apps/docs/.next/`, calls its `GET` in-process with `?query=permissions` + * in each locale in turn, and then once more in each, and reads `heapUsed` + * after a forced GC after every request: + * + * - `threw`, `status`: a request that throws or answers anything but 200. + * A heap reading after a failed request measures nothing. + * - `over-budget`: the heap after any request is above `BUDGET`. 100 MB + * leaves 28 MB of the isolate's 128 MB for what Node's `heapUsed` does not + * count (code, the Worker runtime) and for the peak while an index builds. + * - `still-growing`: the second round settles more than `GROWTH` above the + * first round's highest reading. Whatever the route keeps per locale has to + * stop growing once every locale has been asked; a leak under the budget + * today is the next content PR's 503. + * + * Run it after `pnpm turbo run build`, because a missing build fails it. It + * needs `--expose-gc` and re-runs itself with the flag when started without it. + * `--self-test` runs every rule against fake handlers and fake heap readings, + * and needs no build. + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const ROUTE = 'apps/docs/.next/server/app/api/search/route.js'; +const QUERY = 'permissions'; +const MB = 1024 * 1024; +const BUDGET = 100 * MB; // `over-budget`: of a Worker isolate's 128 MB +const GROWTH = 8 * MB; // `still-growing`: how far round two may settle above round one + +const RULES = ['threw', 'status', 'over-budget', 'still-growing']; + +/** `languages` out of `i18n.ts`. Throws rather than guessing. */ +function readLanguages(text) { + const list = /languages:\s*\[([^\]]+)\]/.exec(text)?.[1]; + const languages = list?.split(',').map((s) => s.trim().replace(/['"]/g, '')).filter(Boolean) ?? []; + if (!languages.length) throw new Error('could not read languages[] out of apps/docs/lib/i18n.ts'); + return languages; +} + +/** One request. A rejection is recorded, because Next turns it into a 500. */ +async function ask(GET, locale) { + const url = `http://localhost/api/search?locale=${encodeURIComponent(locale)}&query=${QUERY}`; + try { + const res = await GET(new Request(url)); + await res.arrayBuffer(); + return { status: res.status }; + } catch (err) { + return { threw: err?.code ?? String(err?.message ?? err).split('\n')[0] }; + } +} + +const mb = (bytes) => `${(bytes / MB).toFixed(1)} MB`; + +/** Two rounds over `languages`; `heap()` reads `heapUsed` after a forced GC. */ +async function evaluate(GET, languages, heap) { + const findings = []; + const rows = []; + const add = (rule, detail) => findings.push({ rule, detail }); + const peaks = [0, 0]; + const settled = [0, 0]; + rows.push(['route loaded', mb(heap())]); + for (const round of [0, 1]) { + for (const locale of languages) { + const r = await ask(GET, locale); + const label = `${round ? 'again ' : ''}${locale}`; + if (r.threw) add('threw', `${label}: the handler threw ${r.threw}, which Next serves as a 500`); + else if (r.status !== 200) add('status', `${label}: HTTP ${r.status}`); + if (r.threw || r.status !== 200) return { findings, rows }; + const used = heap(); + rows.push([label, mb(used)]); + if (used > BUDGET) add('over-budget', `${label}: ${mb(used)} after GC, above the ${mb(BUDGET)} budget`); + peaks[round] = Math.max(peaks[round], used); + settled[round] = used; + } + } + if (settled[1] - peaks[0] > GROWTH) { + add('still-growing', `round two settled at ${mb(settled[1])}, ${mb(settled[1] - peaks[0])} above round one's highest ${mb(peaks[0])}, more than ${mb(GROWTH)}`); + } + return { findings, rows }; +} + +async function gate() { + if (typeof globalThis.gc !== 'function') { + const child = spawnSync(process.execPath, ['--expose-gc', fileURLToPath(import.meta.url), ...process.argv.slice(2)], { stdio: 'inherit' }); + return child.status ?? 1; + } + const route = join(ROOT, ROUTE); + if (!existsSync(route)) { + console.error(`✗ search heap: ${ROUTE} does not exist. Run pnpm turbo run build first.`); + return 1; + } + const languages = readLanguages(readFileSync(join(ROOT, 'apps/docs/lib/i18n.ts'), 'utf8')); + const GET = (await createRequire(import.meta.url)(route))?.routeModule?.userland?.GET; + if (typeof GET !== 'function') { + console.error(`✗ search heap: ${ROUTE} exposes no routeModule.userland.GET, so nothing was measured.`); + return 1; + } + const heap = () => { + globalThis.gc(); + globalThis.gc(); + return process.memoryUsage().heapUsed; + }; + const { findings, rows } = await evaluate(GET, languages, heap); + console.log(['| after | heapUsed after GC |', '|:--|--:|', ...rows.map(([a, b]) => `| ${a} | ${b} |`)].join('\n')); + for (const f of findings) console.error(` (${f.rule}) ${f.detail}`); + if (findings.length) { + console.error(`\n✗ search heap: ${findings.length} finding(s). The rules are in this script's header.`); + return 1; + } + console.log(`\n✓ search heap: every locale asked twice, heap stayed under ${mb(BUDGET)} and stopped growing`); + return 0; +} + +/* Self-test: one clean fake, then one fake per rule that must break exactly that rule. */ +const LANGS = ['en', 'ja', 'de']; +const ok = async () => new Response('[]'); +/** Heap readings in order: route loaded, then one per request across both rounds. */ +const readings = (...values) => { + const queue = values.map((v) => v * MB); + return () => queue.shift() ?? queue.at(-1) ?? 0; +}; +const CASES = [ + ['clean: levels off under the budget', ok, readings(18, 60, 85, 86, 86, 85, 86), []], + ['ja throws', async (req) => (new URL(req.url).searchParams.get('locale') === 'ja' ? Promise.reject(new Error('x')) : new Response('[]')), readings(18, 60), ['threw']], + ['ja answers 503, as the Worker did', async (req) => new Response('', { status: new URL(req.url).searchParams.get('locale') === 'ja' ? 503 : 200 }), readings(18, 60), ['status']], + ['every index kept: over the budget', ok, readings(18, 58, 98, 137, 137, 137, 137), ['over-budget', 'over-budget', 'over-budget', 'over-budget']], + ['a slow leak under the budget', ok, readings(18, 60, 64, 68, 72, 76, 80), ['still-growing']], + ['round two at round one\'s peak is level', ok, readings(18, 60, 90, 70, 90, 70, 90), []], +]; + +async function selfTest() { + let failed = 0; + const fired = new Set(); + for (const [name, GET, heap, want] of CASES) { + const got = (await evaluate(GET, LANGS, heap)).findings.map((f) => f.rule).sort(); + got.forEach((r) => fired.add(r)); + const pass = JSON.stringify(got) === JSON.stringify([...want].sort()); + if (!pass) failed += 1; + console.log(`${pass ? '✓' : '✗'} ${name}: ${got.length ? got.join(', ') : 'no findings'}`); + } + const silent = RULES.filter((r) => !fired.has(r)); + if (silent.length) { + failed += 1; + console.log(`✗ rules no fixture makes fire: ${silent.join(', ')}`); + } + const langsOk = readLanguages("defineI18n({ languages: ['en', 'zh-Hans'] })").join() === 'en,zh-Hans'; + let unreadable = false; + try { + readLanguages('defineI18n({})'); + } catch { + unreadable = true; + } + if (!langsOk || !unreadable) failed += 1; + console.log(`${langsOk ? '✓' : '✗'} i18n.ts is read; ${unreadable ? '✓' : '✗'} an unreadable one is rejected`); + console.log(failed ? `\n✗ search heap self-test: ${failed} failure(s)` : `\n✓ search heap self-test: ${CASES.length} cases, all ${RULES.length} rules can fire`); + return failed ? 1 : 0; +} + +process.exitCode = process.argv.includes('--self-test') ? await selfTest() : await gate(); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 266f3410..0ed37e43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,6 +199,18 @@ jobs: shell: bash run: node .github/scripts/check-search-locales.mjs | tee -a "$GITHUB_STEP_SUMMARY" + # #393: #390's eleven ja pages passed every step above, and after the + # deploy `/api/search` answered 503 and the site rolled back twice. A + # Worker isolate has 128 MB, and the route kept every locale's index + # (30–40 MB each). This step asks the BUILT handler in every locale, then + # in each once more, the way one isolate is asked, and fails when the + # heap after a forced GC goes above 100 MB or is still growing on the + # second pass. A content PR that would tip the Worker over goes red here, + # not after the deploy. Same footing as the step above. + - name: Search fits the Worker's memory + shell: bash + run: node --expose-gc .github/scripts/check-search-heap.mjs | tee -a "$GITHUB_STEP_SUMMARY" + - run: pnpm turbo run test # Defect 2 of #269: the deploy used to run its own `pnpm install` and its diff --git a/apps/docs/app/api/search/route.ts b/apps/docs/app/api/search/route.ts index 46004afc..b41e3342 100644 --- a/apps/docs/app/api/search/route.ts +++ b/apps/docs/app/api/search/route.ts @@ -1,5 +1,6 @@ import { source } from '@/lib/source'; import { i18n } from '@/lib/i18n'; +import { searchData } from '@/.search'; import { createSearchAPI, type AdvancedIndex, @@ -118,22 +119,65 @@ function breadcrumbs(locale: Locale, url: string): string[] | undefined { return names.filter((name): name is string => typeof name === 'string' && name.length > 0); } -/** `locale`'s pages, indexed as `createFromSource` indexes them. */ +type StructuredData = AdvancedIndex['structuredData']; + +/** + * One locale's `structuredData` — each page's headings and paragraphs — keyed + * by file path under `content/docs`, from `.search/` (#393). + * + * `scripts/gen-search-data.mjs` writes it before every build, with the same + * remark chain fumadocs-mdx compiles the pages with. Reading it from there + * instead of from `page.data.load()` is the point: `load()` imports the page's + * compiled module, the module registry keeps it for the life of the isolate, + * and one locale's page modules are 4–5 MB of heap that outlived its index. + * The module imported here is one string, and the unzipped, parsed copy is + * dropped once the index has the text. + */ +async function texts(locale: Locale): Promise> { + // Gzipped JSON in base64, because the module is Worker upload too. + const packed = (await searchData[locale]()).default; + const bytes = Uint8Array.from(atob(packed), (c) => c.charCodeAt(0)); + const json = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); + return JSON.parse(await new Response(json).text()); +} + +/** + * `locale`'s pages, indexed as `createFromSource` indexes them. A page that + * has no translation in `locale` is the default language's file, so its text + * is read from the default language's module. + */ async function indexes(locale: Locale): Promise { + const [own, fallback] = await Promise.all([ + texts(locale), + locale === i18n.defaultLanguage ? undefined : texts(i18n.defaultLanguage), + ]); return Promise.all( - source.getPages(locale).map(async (page) => ({ - id: page.url, - url: page.url, - title: page.data.title, - description: page.data.description, - structuredData: (await page.data.load()).structuredData, - breadcrumbs: breadcrumbs(locale, page.url), - })), + source.getPages(locale).map(async (page) => { + const file = page.data.info.path; + let structuredData = own[file] ?? fallback?.[file]; + if (!structuredData) { + // `next dev` serves pages added since it wrote `.search/`; a build + // writes it first, so there a missing page is a stale `.search/`. + if (process.env.NODE_ENV !== 'development') { + throw new Error(`/api/search: no search data for ${file}; run scripts/gen-search-data.mjs`); + } + structuredData = (await page.data.load()).structuredData; + } + return { + id: page.url, + url: page.url, + title: page.data.title, + description: page.data.description, + structuredData, + breadcrumbs: breadcrumbs(locale, page.url), + }; + }), ); } /** - * One index per locale, built on that locale's first search (#301). + * One index per locale, built on that locale's first search (#301), and only + * the most recent `KEEP` of them kept (#393, below). * * `createFromSource` with i18n builds every locale's index on the first search * in any locale, so the first reader after a cold start paid for all eight: @@ -156,16 +200,38 @@ async function indexes(locale: Locale): Promise { */ const servers = new Map(); +/** + * How many locales' indexes one isolate keeps (#393). + * + * One index is 30–40 MB of heap, and a Cloudflare Worker isolate has 128 MB in + * all. The map above used to keep every locale an isolate had served, so the + * second or third locale pushed it over and `/api/search` answered 503: on the + * built route at `09784e7`, heap after a forced GC grew from 58 MB after the + * first locale to 283 MB after all eight. Keeping the most recently used one + * holds it level. A search in another locale then pays that locale's build + * again (0.5–1.5 s), and a second entry would leave too little room for that + * build's peak. `.github/scripts/check-search-heap.mjs` measures the level + * after every build. + */ +const KEEP = 1; + function server(locale: Locale): SearchAPI { let found = servers.get(locale); - if (!found) { + if (found) { + // Most recently used last, so the first key is the one to drop. + servers.delete(locale); + } else { const options: Partial = localeOptions[locale]; found = createSearchAPI('advanced', { ...options, search: { ...options.search, sortBy: byWeightedScore }, indexes: () => indexes(locale), }); - servers.set(locale, found); + } + servers.set(locale, found); + for (const oldest of servers.keys()) { + if (servers.size <= KEEP) break; + servers.delete(oldest); } return found; } diff --git a/apps/docs/package.json b/apps/docs/package.json index f7a3034d..7d875e3c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -5,12 +5,12 @@ "description": "ObjectOS Documentation Site", "license": "Apache-2.0", "scripts": { - "dev": "next dev", + "dev": "fumadocs-mdx && node scripts/gen-search-data.mjs && next dev", "gen:zh-hant": "node scripts/gen-zh-hant.mjs", - "build": "next build", + "build": "fumadocs-mdx && node scripts/gen-search-data.mjs && next build", "start": "next start", "lint": "next lint", - "type-check": "next typegen && fumadocs-mdx && tsc --noEmit && node scripts/check-frontmatter.mjs", + "type-check": "next typegen && fumadocs-mdx && node scripts/gen-search-data.mjs && tsc --noEmit && node scripts/check-frontmatter.mjs", "postinstall": "fumadocs-mdx", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", diff --git a/apps/docs/scripts/gen-search-data.mjs b/apps/docs/scripts/gen-search-data.mjs new file mode 100644 index 00000000..59c20e38 --- /dev/null +++ b/apps/docs/scripts/gen-search-data.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/** + * Writes the text `/api/search` indexes, one module per locale (#393). + * + * The route used to read each page's `structuredData` (its headings and + * paragraphs) through `page.data.load()`, which imports the page's compiled + * MDX module. That is the whole rendered page, highlighted code included, and + * a module once imported stays in the module registry for the life of the + * isolate. Building one locale's index therefore left every page module of + * that locale resident after the index was gone. Measured on the built route + * at `09784e7`, with only one index kept: 58 MB after English, 86 MB after all + * eight locales, about 94 MB with ja fully translated, against a Worker + * isolate's 128 MB. + * + * So the same `structuredData` is computed here, before `next build`, and + * written as one string per locale to `.search/.ts`: the JSON, + * gzipped, in base64. The route imports the locale it is asked for, unzips and + * parses it. What stays resident is that string, not the page modules. + * + * Gzipped because the string is also Worker upload, which has a budget the + * `Worker bundle fits the size budget` CI step holds: as plain JSON the + * English, zh-Hans and zh-Hant modules were about 1 MB each, 3.5 MB of the + * 6 MB headroom main had at `09784e7`; gzipped and in base64, about a third. + * + * It is the same `structuredData`, not a lookalike. The processor is + * fumadocs-mdx's own remark chain, built with the public `applyMdxPreset` from + * the compiled `source.config` (`.source/source.config.mjs`, written by the + * `fumadocs-mdx` CLI), plus `remarkInclude`, which `buildMDX` puts first. + * `remarkStructure` runs in the remark phase, so the rehype plugins, which run + * after it and only shape the rendered HTML, are left out: that is + * `rehypeCode`, and it is most of the cost. + * + * A file's locale is its suffix, as `source.config.ts` reads it: `foo.ja.mdx` + * is `ja`, `foo.mdx` is the default language. A locale whose page falls back + * to English reads that page from the default language's module, so English + * text is written once. + * + * The output directory carries its own `.gitignore`, because it is a build + * product. Run from `apps/docs`, after `fumadocs-mdx`, as the `build`, + * `type-check` and `dev` scripts do. + */ +import { applyMdxPreset, remarkInclude } from 'fumadocs-mdx/config'; +import { frontmatter } from 'fumadocs-core/content/md/frontmatter'; +import { createRequire } from 'node:module'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { gzipSync } from 'node:zlib'; + +const APP = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const CONTENT = resolve(APP, '../../content/docs'); +const CONFIG = join(APP, '.source/source.config.mjs'); +const OUT = join(APP, '.search'); + +/** `languages` and `defaultLanguage` out of `lib/i18n.ts`, read as text. Throws rather than guessing. */ +function readI18n() { + const text = readFileSync(join(APP, 'lib/i18n.ts'), 'utf8'); + const languages = /languages:\s*\[([^\]]+)\]/.exec(text)?.[1]?.split(',').map((s) => s.trim().replace(/['"]/g, '')).filter(Boolean) ?? []; + const defaultLanguage = /defaultLanguage:\s*['"]([^'"]+)['"]/.exec(text)?.[1]; + if (!languages.length || !languages.includes(defaultLanguage)) { + throw new Error('could not read languages[] and defaultLanguage out of apps/docs/lib/i18n.ts'); + } + return { languages, defaultLanguage }; +} + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }) + .flatMap((e) => (e.isDirectory() ? walk(join(dir, e.name)) : e.name.endsWith('.mdx') ? [join(dir, e.name)] : [])) + .sort(); +} + +if (!existsSync(CONFIG)) { + console.error(`✗ search data: ${relative(process.cwd(), CONFIG)} does not exist. Run \`fumadocs-mdx\` first.`); + process.exit(1); +} + +const { languages, defaultLanguage } = readI18n(); +// @mdx-js/mdx is fumadocs-mdx's dependency, not this app's, so it is resolved from there: the copy buildMDX uses. +const { createProcessor } = await import( + pathToFileURL(createRequire(import.meta.resolve('fumadocs-mdx')).resolve('@mdx-js/mdx')).href +); +const config = (await import(pathToFileURL(CONFIG).href)).default; +const mdx = await applyMdxPreset({ ...config.mdxOptions, rehypeCodeOptions: false })('bundler'); +const processor = createProcessor({ + outputFormat: 'program', + ...mdx, + remarkPlugins: [remarkInclude, ...(mdx.remarkPlugins ?? [])], + rehypePlugins: [], + format: 'mdx', +}); + +const byLocale = new Map(languages.map((l) => [l, {}])); +for (const path of walk(CONTENT)) { + const file = relative(CONTENT, path).split('\\').join('/'); + const locale = languages.find((l) => l !== defaultLanguage && file.endsWith(`.${l}.mdx`)) ?? defaultLanguage; + const vfile = await processor.process({ + path, + value: frontmatter(readFileSync(path, 'utf8')).content, + data: { frontmatter: {}, _getProcessor: () => processor }, + }); + if (!vfile.data.structuredData) throw new Error(`${file}: remarkStructure wrote no structuredData`); + byLocale.get(locale)[file] = vfile.data.structuredData; +} + +rmSync(OUT, { recursive: true, force: true }); +mkdirSync(OUT, { recursive: true }); +writeFileSync(join(OUT, '.gitignore'), '*\n'); +const banner = '// Generated by scripts/gen-search-data.mjs before every build. Do not edit.\n'; +for (const [locale, pages] of byLocale) { + // A string, not an object literal: the route parses it when it builds the + // index, and the parsed copy is garbage once the index holds the text. + const packed = gzipSync(JSON.stringify(pages), { level: 9 }).toString('base64'); + writeFileSync(join(OUT, `${locale}.ts`), `${banner}export default ${JSON.stringify(packed)};\n`); +} +const loaders = languages.map((l) => ` ${JSON.stringify(l)}: () => import(${JSON.stringify(`./${l}`)}),`).join('\n'); +writeFileSync( + join(OUT, 'index.ts'), + `${banner}/** Each locale's \`structuredData\`, keyed by file path under content/docs, as gzipped JSON in base64. */\nexport const searchData = {\n${loaders}\n} as const;\n`, +); +const sizes = [...byLocale].map(([l, pages]) => `${l} ${Object.keys(pages).length}`).join(', '); +console.log(`✓ search data: wrote .search/ (pages per locale: ${sizes})`); diff --git a/tools/ci-scripts/run-self-tests.mjs b/tools/ci-scripts/run-self-tests.mjs index f5cbadad..fb1f15f6 100644 --- a/tools/ci-scripts/run-self-tests.mjs +++ b/tools/ci-scripts/run-self-tests.mjs @@ -114,6 +114,13 @@ * malformed pin or SHA is refused, that the same commit bumps nothing, and that * the pull-request body stays inside GitHub's size limit with every upstream * reference inside a fence. + * + * `check-search-heap.mjs` is listed for its `--self-test` only (#393), on the + * footing of `check-search-locales.mjs`: its gate mode loads the built search + * route out of `apps/docs/.next/`, so it is a `ci.yml` step after the build. + * Its fixtures are fake handlers and fake heap readings. They prove that a + * failed request, a reading above the budget, and a heap still growing on the + * second pass each go red. */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; @@ -137,6 +144,7 @@ const SELF_TESTED = [ 'check-translation-ownership.mjs', 'check-positioning.mjs', 'check-search-locales.mjs', + 'check-search-heap.mjs', 'check-doc-samples.mjs', 'bump-upstream-pin.mjs', ]; From d0ad718131deb91d673f13e93c22983b6bfe061e Mon Sep 17 00:00:00 2001 From: Steedos Date: Thu, 29 Oct 2026 12:22:00 +0000 Subject: [PATCH 5/6] docs(i18n): re-land build/automation/ and build/interface/ ja pages (#388, PR 2 of 7) Reverts #392, which reverted #390 because /api/search ran out of Worker memory. #394 (#393) fixed that, so the same 11 ja pages come back unchanged. Refs #388 Claude-Session: https://claude.ai/code/session_01QsR2jRsjbApbf83iyTDDXm Co-Authored-By: Claude --- .../docs/build/automation/approvals.ja.mdx | 279 ++++++++++++ content/docs/build/automation/flows.ja.mdx | 404 ++++++++++++++++++ content/docs/build/automation/index.ja.mdx | 43 ++ .../docs/build/automation/workflows.ja.mdx | 181 ++++++++ content/docs/build/interface/actions.ja.mdx | 208 +++++++++ content/docs/build/interface/apps.ja.mdx | 137 ++++++ .../docs/build/interface/dashboards.ja.mdx | 201 +++++++++ content/docs/build/interface/forms.ja.mdx | 175 ++++++++ content/docs/build/interface/index.ja.mdx | 49 +++ content/docs/build/interface/pages.ja.mdx | 166 +++++++ content/docs/build/interface/views.ja.mdx | 294 +++++++++++++ 11 files changed, 2137 insertions(+) create mode 100644 content/docs/build/automation/approvals.ja.mdx create mode 100644 content/docs/build/automation/flows.ja.mdx create mode 100644 content/docs/build/automation/index.ja.mdx create mode 100644 content/docs/build/automation/workflows.ja.mdx create mode 100644 content/docs/build/interface/actions.ja.mdx create mode 100644 content/docs/build/interface/apps.ja.mdx create mode 100644 content/docs/build/interface/dashboards.ja.mdx create mode 100644 content/docs/build/interface/forms.ja.mdx create mode 100644 content/docs/build/interface/index.ja.mdx create mode 100644 content/docs/build/interface/pages.ja.mdx create mode 100644 content/docs/build/interface/views.ja.mdx diff --git a/content/docs/build/automation/approvals.ja.mdx b/content/docs/build/automation/approvals.ja.mdx new file mode 100644 index 00000000..c0d4b0ef --- /dev/null +++ b/content/docs/build/automation/approvals.ja.mdx @@ -0,0 +1,279 @@ +--- +title: 承認 +seoTitle: "承認フロー:レコードを人による承認へ回す" +description: レコードを人による承認へ回します。自動化が行レベルセキュリティを黙ってすり抜けることはさせません。 +editions: [community, cloud, enterprise] +translation: + source_sha: e03dd11f32910f971d7514f12aab8e3c2ac1eceb25966b2b4162185eb062a521 + guide_rev: 1 + mode: auto +--- + +**承認とは、承認ノードを持つ[フロー](/docs/build/automation/flows)です。フローは人が承認または却下するまで一時停止し、その後、対応する分岐に沿って再開します。** 新たに覚えるべき独立した承認エンジンはありません。トリガー、分岐、エラー処理は、ほかのフローとまったく同じように動作します。このページが加えるのは承認ノードそのものと、ルーティングと同じくらい重要な 2 つのアクセスの判断です。 + +## 誰が何をするか [#who-does-what] + +意図的に分けられた 3 つのロールです。 + +| ロール | できること | 必要なもの | +|---|---|---| +| **ビルダー** | 承認フローを作成、編集する | `manage_metadata`(通常は Studio のユーザー) | +| **申請者** | フローに入るレコードを申請する | 通常のレコードアクセス。自動化の設定は不要 | +| **承認者** | 承認依頼に対応する(承認 / 却下) | 権限セットで付与された機能 | + +エンドユーザーはレコードを申請し、依頼に対応します。自動化を編集することはありません。自動化の設定画面は、利用者向けのアプリに含めないでください。 + +## 誰として実行するか:安全上の判断 [#as-whom-does-it-run--the-safety-decision] + +フローは `runAs` を宣言します。承認にとって、これはフローが行レベルセキュリティを黙ってすり抜けないようにするための判断です。 + +| `runAs` | データ操作を実行する主体 | 使う場面 | +|---|---|---| +| `'user'`(デフォルト) | **申請者**。その RLS に従います | フローが、申請者がすでに見られるレコードにだけ触れる場合 | +| `'system'` | **昇格**。RLS をすり抜けます | 申請者が見られないレコードをフローが読み書きしなければならない場合(元帳への記帳、申請者が見られない行を所有する承認者への通知) | + +昇格は、偶然ではなく見える形にするために**明示的に**宣言します。デフォルトが `'user'` なので、承認フローが申請者に、テナントや所有者をまたぐ到達範囲を黙って与えることはできません。昇格はオプトインで、監査できます。 + +> **ヒント:** スケジュールで起動するエスカレーションには起動したユーザーがいないので、そもそも動作させるには `runAs: 'system'` でなければなりません。これが昇格の正当なケースです。「動かすためにどこでも `system`」はアンチパターンです。 + +## 承認ノード [#the-approval-node] + +ノードは**誰が承認するか**と、その判断をどう集約するかを宣言します。 + +```ts +// One flow node; the flow around it is omitted (see the complete flow below). +{ + id: 'manager_approval', + type: 'approval', + label: 'Manager Approval', + config: { + approvers: [{ type: 'field', value: 'owner_manager_id' }], + behavior: 'unanimous', + approvalStatusField: 'approval_status', + lockRecord: true, + }, +} +``` + +| 設定 | 働き | +|---|---| +| `approvers` | 誰が判断しなければならないか。下記の承認者の種類を参照 | +| `behavior` | 複数の承認者をどう集約するか:`'first_response'`(デフォルト)、`'unanimous'`、`'quorum'`、`'per_group'` | +| `minApprovals` | 必要な承認数。`quorum` では合計(N 人中 M 人)、`per_group` ではグループごと(デフォルト 1) | +| `onEmptyApprovers` | 承認者の解決結果が誰もいなかったときの動作:`'admin_rescue'`(デフォルト)、`'fail'`、`'auto_approve'` | +| `decisionOutputs` | 判断が構造化された出力としてフローに持ち帰れるキー | +| `approvalStatusField` | プラグインが依頼のステータスを反映する、省略可能なレコードのフィールド | +| `lockRecord` | 依頼が保留中の間、レコードを編集からロックします | + +`approvers` の各エントリは `type` と `value` を指定します。 + +| `type` | 解決先 | +|---|---| +| `manager` | 申請者のマネージャー(`sys_user.manager_id`)。value は取りません | +| `position` | 組織のポジションの保持者 | +| `department` | 事業単位**とそのすべての下位単位**のメンバー | +| `team` | フラットなコラボレーションチームのメンバー | +| `field` | 起動したレコードのフィールドが保持するユーザー ID | +| `user` | 名前で指定した 1 人のユーザー ID | +| `org_membership_level` | [組織メンバーシップの階層](/docs/configure/users#organization-membership-tiers)(`owner` / `admin` / `delegated_admin` / `member`) | +| `expression` | ノードに入るときに解決される CEL 式。下記を参照 | + +文字どおりの `user` ID よりも、間接的なバインディング(`manager`、`position`、`department`、`team`)を優先してください。本人がチームを移ったり退職したりしても機能し続けますが、ハードコードされた ID は、もう承認を見るべきではない人に黙って承認を回してしまいます。 + +> **警告:`position` とメンバーシップの階層。** `{ type: 'position', value: 'finance_manager' }` はポジション(`sys_user_position`)の保持者に回します。[組織メンバーシップの階層](/docs/configure/users#organization-membership-tiers)(`sys_member.role`:`owner`/`admin`/`delegated_admin`/`member`)は 16.0 以降 `type: 'org_membership_level'` で指定します。旧来の表記 `role` は **1 リリースの間だけの非推奨エイリアス**で、警告付きで今も同じように読み込まれて解決されますが、次のメジャーで削除されます。メンバーシップ階層の種類として書かれたポジション名は誰にも一致せず、依頼は止まってしまいます。`os lint` は両方のケースを指摘します(`approval-approver-not-membership-tier`、`approval-approver-type-deprecated`)。value が組織のポジションを指している場合、修正は `type: 'position'` です。 + +### クォーラムとグループごとの承認(16.0) [#quorum-and-per-group-sign-off-160] + +`first_response` と `unanimous` に加えて、16.0 では 2 つの集約モードが追加されました。**クォーラム**は N 人中 M 人の承認で確定します。 + +```ts +// The `config` of an `approval` node; the node around it is omitted. +config: { + approvers: [ + { type: 'user', value: 'director_a' }, + { type: 'user', value: 'director_b' }, + { type: 'user', value: 'director_c' }, + ], + behavior: 'quorum', + minApprovals: 2, // any 2 of the 3 approve +} +``` + +**グループごと**は、ラベル付けされた*各*グループからの承認(会签)を必要とします。承認者に `group` でラベルを付けると、すべてのグループが `minApprovals` 件の承認(デフォルトはグループごとに 1)を得たときにだけノードが先に進みます。 + +```ts +// The `config` of an `approval` node; the node around it is omitted. +config: { + approvers: [ + { type: 'position', value: 'legal_counsel', group: 'legal' }, + { type: 'position', value: 'finance_manager', group: 'finance' }, + ], + behavior: 'per_group', // one sign-off from legal AND one from finance +} +``` + +すべてのモードに共通する意味: + +- 承認者の集合は、不在時の代理を含む**依頼開始時のスナップショット**に照らして集計されます。誰が応答しなければならないかは依頼が開始されたときに確定し、不在の承認者は依頼を止めるのではなく代理に置き換えられます。 +- **1 件の却下はやはり拒否権です**。どのモードでも、ノードを `rejected` として確定させます。 +- しきい値は実行時に、解決できる承認者の数に**切り詰められる**ので、誤設定された `minApprovals` が依頼をデッドロックさせることはありません。 +- `group` ラベルのない承認者はそれぞれが独自のグループになるので、ラベルなしの承認者リストでも `per_group` のもとで妥当に動作します。 + +### 動的なルーティング:承認者が次の承認者を選ぶ(17.0) [#dynamic-routing-an-approver-picks-the-next-approver-170] + +17.0 で追加された 3 つの機能により、ノードは固定の宣言からではなく、実行がそれまでに生み出したものから承認者を決められます。 + +**`expression` 承認者**は、ノードに入るときに、3 つの明示的なルートに対して CEL 式を解決します。`current.*`(レコードの現在の状態)、`trigger.*`(申請時のスナップショット)、`vars.*`(上流ノードの出力を含むフロー変数)です。結果が承認者の顔ぶれになります。 + +```ts +// The `config` of an `approval` node; the node around it is omitted. +config: { + approvers: [ + { + type: 'expression', + value: 'vars.legal_review.picked_departments', + resolveAs: 'department', // each value is a department id, fanned out to its members + }, + ], +} +``` + +`resolveAs` は `expression` 承認者自身のキー(デフォルトは `'user'`、または `'department'` / `'position'` / `'team'`)で、式が返した ID の種類を示し、静的な承認者の種類と同じグラフ検索で各 ID を展開します。`behavior: 'per_group'` のもとでは、返された各値が独自のグループになり、返された部署ごとに 1 件の承認が必要になります。 + +`record` と素のフィールド名は意図的に**使えません**。プラットフォームのほかのすべての場面で `record` は「イベント時点のレコード」を意味しており、ここで再利用すると 2 つの時点のどちらかを黙って指してしまうからです。 + +上のような式に値を供給するのが **`decisionOutputs`** です。判断が持ち帰れるキーは*作成者*が宣言し、承認者は値を埋めるだけです。受け付けられた出力は `.` というフロー変数として実行を再開させます。素の名前には決してならないので、承認者が作成者の変数を覆い隠すことはできません。 + +```ts +// One flow node; the flow around it is omitted. +{ + id: 'legal_review', + type: 'approval', + label: 'Legal Review', + config: { + approvers: [{ type: 'position', value: 'legal_counsel' }], + decisionOutputs: [ + { key: 'picked_departments', type: 'department', multiple: true }, + ], + }, +} +``` + +エントリは、素のキー(判断の UI ではプレーンなテキスト入力としてレンダリングされます)か、型付きの宣言 `{ key, type, multiple }` のいずれかです。型付きの宣言は、判断の UI に代わりにレコードピッカーをレンダリングさせます。`user` / `department` / `position` / `team` はそれぞれ対応するシステムオブジェクトのピッカーになり、`multiple: true` は ID の配列を集めます。宣言されていないキーを持つ判断は拒否されます。`decision` と `requestId` は予約されています。 + +**`onEmptyApprovers`** は、解決結果に具体的な人が 1 人もいないとき(式の結果が空、ポジションに人がいない、複数選択フィールドが空)の動作を決めます。 + +| 値 | 動作 | +|---|---| +| `'admin_rescue'`(デフォルト) | とにかく依頼を開始し、大きく警告します。特権を持つ管理者が Reassign で引き継げます。レコードを素通りさせることも、実行を止めることもしない唯一の選択肢です | +| `'fail'` | ノードが失敗します(fault エッジ / 実行の失敗)。顔ぶれが空なら設定のバグとしか考えられない場合に選びます | +| `'auto_approve'` | 依頼をスキップし、`output.autoApproved = true` とともに `approve` エッジに沿って続行します。レコードを黙って素通りさせるので、オプトインです | + +このデフォルトは意図的なものです。承認の役目は門番なので、空の顔ぶれが黙って通ることがあってはなりません。 + +## 完全な承認フロー [#a-complete-approval-flow] + +大型の提案を所有者のマネージャーに回し、その判断で分岐します。 + +```ts +export const opportunityApproval = defineFlow({ + name: 'opportunity_approval', + label: 'Opportunity Approval', + type: 'record_change', + status: 'active', + runAs: 'user', // submitter's RLS unless a step genuinely needs more + nodes: [ + { + id: 'start', + type: 'start', + label: 'Proposal over 50,000', + config: { + triggerType: 'record-after-update', + objectName: 'opportunity', + condition: "record.amount >= 50000 && record.stage == 'proposal'", + }, + }, + { + id: 'manager_approval', + type: 'approval', + label: 'Manager Approval', + config: { + approvers: [{ type: 'field', value: 'owner_manager_id' }], + behavior: 'unanimous', + approvalStatusField: 'approval_status', + lockRecord: true, + }, + }, + { + id: 'mark_approved', + type: 'update_record', + label: 'Mark Approved', + config: { + objectName: 'opportunity', + filter: { id: '{record.id}' }, + fields: { stage: 'negotiation' }, + }, + }, + { + id: 'mark_rejected', + type: 'update_record', + label: 'Mark Rejected', + config: { + objectName: 'opportunity', + filter: { id: '{record.id}' }, + fields: { stage: 'closed_lost' }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'manager_approval' }, + { id: 'approved', source: 'manager_approval', target: 'mark_approved', label: 'approve' }, + { id: 'rejected', source: 'manager_approval', target: 'mark_rejected', label: 'reject' }, + { id: 'e4', source: 'mark_approved', target: 'end' }, + { id: 'e5', source: 'mark_rejected', target: 'end' }, + ], +}); +``` + +ラベル付きのエッジに注目してください。`approve` と `reject` が判断後の分岐に名前を付けています。却下の経路は必ずモデル化してください。承認の分岐しかないフローでは、却下されたレコードが行き場を失います。 + +**多段階の承認:** 複数の `approval` ノードをつなぎます。**並行承認:** [フロー](/docs/build/automation/flows)の集約ノードのパターンを参照してください。 + +## 承認者が体験すること [#what-the-approver-experiences] + +永続的な承認の状態は `@objectstack/plugin-approvals` パッケージが管理します。依頼が保留中の間: + +1. プラグインは依頼(`sys_approval_request`)と、それに対して行われたすべての判断(`sys_approval_action`)を永続化します。承認の履歴はクエリできるデータです。 +2. `lockRecord: true` の場合、判断が下るまでレコードは編集からロックされます。 +3. `approvalStatusField` を設定すると、レコード自身のフィールドが依頼のステータスを反映するので、ビューやレポートでそれを使ってフィルターできます。 +4. 承認者が承認または却下すると、プラグインは一時停止していたフローを対応するエッジに沿って再開します。16.0 以降、判断には**添付ファイル**を付けられます(`sys_approval_action.attachments` に永続化され、decide/comment のルートとクライアント SDK を通じて受け渡されます)。署名済みの PDF や証拠は判断とともに保管されます。 +5. 依頼はサーバーで計算された **`decision_progress`** を公開します。`unanimous`/`quorum` では得た承認数と必要な承認数、`per_group` ではグループごとの内訳です。そのため受信トレイや独自の UI は、集計を再実装せずに進捗をレンダリングできます。 + +**依頼を見られるのは誰か(17.0)。** 依頼はレコードのスナップショット、判断の履歴全体、添付ファイルを持つので、読めるのはその参加者だけです。申請者、現在の承認者(依頼が待っているポジションの保持者を含む)、そしてすでに対応またはコメントした人です。プラットフォームと組織の管理者はすべての依頼を見られます。それ以外の人には何も見えず、レコードを読めるかどうかはそれを変えません。承認者で依頼リストをフィルターするのはフィルターであって付与ではありません。それを省くと、テナント全体ではなく、自分が参加している依頼だけが返されます。見える依頼の中でも、スナップショットからは、読み手がそのレコードで読めないフィールドがすべて取り除かれます。17.6 以降は、マスクされた状態でしか読めないフィールドもすべて取り除かれます。 + +**判断は宣言されたアクションです(16.0)。** 判断の全体(承認、却下、再割り当て、修正のための差し戻し(`/revise`)、情報の依頼、リマインド、取り消し、再申請)は、`sys_approval_request` 上の `type: 'api'` アクションとして宣言されており、型付きのパラメータ、保留中のときだけ表示するゲート、申請者か承認者かの述語(例:`record.submitter_id == ctx.user.id`)を持ちます。承認の受信トレイは、手書きのボタンではなく、これらの宣言されたアクションをレンダリングします。そのため新しい判断機能はメタデータとして出荷され、クライアントのリリースは必要ありません。 + +承認すること自体がゲート付きのアクションです。「承認してよい」は、承認者の権限セットで付与される機能(例:`approve_invoice`)としてモデル化し、承認アクションの `requiredPermissions` をそれでゲートします。そうすればゲートは画面から隠されるだけでなく、UI とサーバーの**両方**で強制されます。 + +## ベストプラクティス [#best-practices] + +| すること | しないこと | +|---|---| +| 明確な開始条件を定義する | 承認ステップを作りすぎる | +| 妥当なタイムアウト期間を設定する | 承認を複雑にしすぎる | +| 適切な場合は取り消しを許す | 却下の経路を忘れる | +| すべての関係者に通知する | 承認者をハードコードする | +| 承認の履歴を追跡する | 「承認」を UI でだけゲートする | +| デフォルトは `runAs: 'user'` にし、1 ステップずつ昇格する | 「動かすために」どこでも `runAs: 'system'` を設定する | + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|---|---| +| [フロー](/docs/build/automation/flows) | このページの土台となるフローのリファレンス:トリガー、ステップ、エラー処理 | +| [ワークフロー](/docs/build/automation/workflows) | 承認が置かれるライフサイクルを制約する | +| [アクション](/docs/build/interface/actions) | 申請と承認をインターフェースのボタンとして表示する | +| [CEL 式](/docs/reference/cel) | 開始条件の背後にある言語 | +| [メール](/docs/configure/email) | 承認者と関係者に通知する | +| [自動化の概要](/docs/build/automation) | 選び方:フロー、ワークフロー、承認 | diff --git a/content/docs/build/automation/flows.ja.mdx b/content/docs/build/automation/flows.ja.mdx new file mode 100644 index 00000000..46a7b726 --- /dev/null +++ b/content/docs/build/automation/flows.ja.mdx @@ -0,0 +1,404 @@ +--- +title: フローと自動化 +description: 宣言的なビジネスロジック。AI に説明しても TypeScript で書いても、ランタイムはどちらの場合も同じ成果物を実行します。 +editions: [community, cloud, enterprise] +translation: + source_sha: 011506676f1192c1d884573299f8404d1cf906edfc56cb81b122979ba8410d43 + guide_rev: 1 + mode: auto +--- + +フローは、サーバーを書かずにビジネスロジックを表現する方法です。すべてのフローは、ランタイムが実行する宣言的なメタデータであり、オブジェクトやビューと同じです。そのため、フローは `os diff`、監査ログ、Studio のフロービルダー、そして [AI Builder](/docs/build/ai-builder) のすべてに同時に現れます。 + +ほとんどのお客様は、AI に頼んでフローを作成します。 + +> *「優先度の高いチケットが 'new' のまま 30 分経ったら、Slack でマネージャーに通知して」* + +AI はこのページで説明する形でフローのメタデータを生成するので、読んで編集できます。 + +フローはグラフです。`nodes` が作業を行い、`edges` がそれらをつなぎ、`start` ノードがフローを起動するものを示します。この形は ObjectStack の `FlowSchema` で、ObjectStack の [Flow metadata](https://objectstack.ai/docs/automation/flows) に完全に記載されています。 + +スタックでこの機能を有効にします。 + +```ts +export default defineStack({ + // ... + requires: ['automation', 'triggers'], // the flow engine, and the triggers that start flows +}); +``` + +両方のトークンが必要です。レコードの変更、スケジュール、API 呼び出しで開始するフローがあるのに `requires` にどちらかが欠けているスタックを、`defineStack` は拒否します。そのようなフローは登録されても決して実行されないからです。 + +## フローの種類 [#flow-types] + +| `type` | 起動するもの | 用途 | +|---|---|---| +| `record_change` | レコードの挿入、更新、削除。開始ノードで結び付けます | 「チケットが作成されたら担当者にメールする」 | +| `schedule` | 開始ノード上の cron スケジュール | 「毎晩午前 2 時に古いタスクをマークする」 | +| `schedule`、相対時間 *(16.0)* | 日付フィールドと今日との近さ。毎日のスイープで判定 | 「契約終了の 60 日前に通知する」 | +| `autolaunched` | アクションボタン、別のフロー、または API 呼び出し | 「請求書を承認」アクション | +| `screen` | ユーザー。1 つ以上の画面を通じて | ガイド付きのデータ入力 | +| `api` | 開始ノードの `config.secret` で署名された HTTP リクエスト | 外部システム向けのエンドポイント | + +## レコード変更:レコードの変更に反応する [#record-change-react-to-a-record-change] + +```ts +// src/flows/ticket_assigned_email.ts +import { defineFlow } from '@objectstack/spec'; + +export const ticketAssignedEmail = defineFlow({ + name: 'ticket_assigned_email', + label: 'Email the assignee of a new ticket', + type: 'record_change', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Ticket created', + config: { objectName: 'support_ticket', triggerType: 'record-after-create' }, + }, + { + id: 'email_assignee', + type: 'notify', + label: 'Email the assignee', + config: { + recipients: '{record.assignee}', + title: 'New ticket: {record.subject}', + message: 'You have been assigned {record.subject}, priority {record.priority}.', + channels: ['email'], + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'email_assignee' }, + { id: 'e2', source: 'email_assignee', target: 'end' }, + ], +}); +``` + +メールは `notify` ノードを通じて送られます。独立したメールノードの種類はありません。`channels: ['email']` は、[メール](/docs/configure/email)で設定されたトランスポートを通じて、メールチャネルで通知を送ります。`channels` がなければ、通知はアプリ内の受信トレイにだけ届きます。インラインのテキストの代わりに保存済みのメールテンプレートを送るには、`title` と `message` の代わりに `template`(`sys_email_template` の名前)を設定します。 + +ノードの `config` 内のフィールド値と通知テキストはテンプレートです。`{record.}` は起動したレコードを、`{}` はフロー変数を、`{$User.Id}` は現在のユーザーを読み、`{NOW()}` / `{TODAY()}` は日付マクロです。条件は波括弧なしの素の CEL です。[条件と分岐](#conditions-and-branches)を参照してください。 + +開始ノードの `triggerType` がイベントを選び、省略可能な `condition` がそれを絞り込みます。たとえば `condition: "record.priority == 'high'"` です。 + +| `triggerType` | 発動するタイミング | +|---|---| +| `record-after-create` | 挿入の後 | +| `record-after-update` | 更新の後 | +| `record-after-delete` | 削除の後 | +| `record-after-write` | 挿入**または**更新の後 | +| `record-before-create` | 挿入の前 | +| `record-before-update` | 更新の前 | +| `record-before-delete` | 削除の前 | +| `record-before-write` | 挿入**または**更新の前 | + +更新では、`previous` が変更前のレコードを保持します:`condition: 'record.stage != previous.stage'`。同じ書き込みの中で、保存される前に保留中のレコードを変更するには、フローではなく before フックを使います。フローは保存の副作用のためのものです。レコードの作成、通知、HTTP 呼び出し、承認の依頼などです(ObjectStack の [Hooks vs flows](https://objectstack.ai/docs/automation/hooks#hooks-vs-flows))。 + +## スケジュール:時刻に従って実行する [#scheduled-run-on-a-clock] + +```ts +export const nightlyCleanup = defineFlow({ + name: 'nightly_cleanup', + label: 'Mark overdue tasks', + type: 'schedule', + status: 'active', + runAs: 'system', // a scheduled run has no user, so its data operations need an explicit identity + nodes: [ + { + id: 'start', + type: 'start', + label: 'Every night at 2am', + config: { + triggerType: 'schedule', + schedule: { type: 'cron', expression: '0 2 * * *', timezone: 'America/New_York' }, + }, + }, + { + id: 'mark_overdue', + type: 'update_record', + label: 'Mark open tasks past due', + config: { + objectName: 'task', + filter: { status: 'open', due_date: { $lt: '{TODAY()}' } }, + fields: { status: 'overdue' }, + multi: true, // update every match; without it, a filter that is not a single id is refused + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'mark_overdue' }, + { id: 'e2', source: 'mark_overdue', target: 'end' }, + ], +}); +``` + +`@objectstack/service-job` 機能に支えられています。[ランタイム機能](/docs/reference/runtime-capabilities)を参照してください。時間で起動するフローには組織を引き継ぐ呼び出し元がいません。開始ノードで `organization` を使って組織を宣言しなければならない場合については、ObjectStack の [Flow metadata](https://objectstack.ai/docs/automation/flows#the-acting-organization) に記載されています。 + +> **スケジュールされた処理は、デプロイメントが有効にするまでオフです。** ObjectStack 17.5 以降、時間で起動するフロー(`schedule` の周期、または下記の相対時間スイープ)とパッケージ化された `defineJob` の cron ジョブは、デプロイメントが `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` を `true`(または `1`、`on`、`yes`)に設定したときにだけ有効になります。未設定なら何も有効にならず、そのようなフローはそれぞれエラーではなく *disabled by deployment policy* として報告されます。オフは ObjectStack ランタイムのデフォルトです。お使いの ObjectOS イメージやそのデプロイバンドルのテンプレートがこの変数を設定してくれるかどうかは、このページでは約束できません。アプリがスケジュールフローに依存するときは明示的に設定し、実効値を表示する `os doctor` で確認してください。[環境変数](/docs/reference/environment-variables#runtime-services)を参照してください。プラットフォーム自身のスケジュール処理(承認のエスカレーション、保持期間、メッセージの配信)には影響しません。`isolated` テナンシー方式では、開始ノードで `organization` を宣言していないスケジュールフローは、スイッチがオンでも有効になりません。 + +## 相対時間:日付フィールドを基準に発動する(16.0) [#time-relative-fire-relative-to-a-date-field-160] + +「契約終了の 60 日前に通知する」は、かつては `record.end_date == daysFromNow(60)` を条件とするレコード変更フローとして作成されていました。この述語はレコードが*たまたま変更された*ときにしか評価されないので、放っておくとほとんど発動しません。そのようには書かないでください。代わりに**相対時間トリガー**を宣言します。フローの開始ノードが `config.timeRelative` 記述子を持ち、ランタイムがスケジュールに従ってオブジェクトをスイープし、**一致したレコードごとに 1 回**フローを起動します。 + +```ts +export const renewalReminder = defineFlow({ + name: 'contract_renewal_reminder', + label: 'Contract renewal reminder', + type: 'schedule', + status: 'active', + runAs: 'system', // a sweep has no user to act as + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { + timeRelative: { + object: 'contracts', + dateField: 'end_date', + offsetDays: [60, 30, 7], // T-minus thresholds + filter: { status: 'active' }, // AND-ed with the date window + }, + // Optional sweep cadence — defaults to daily at 08:00 UTC. + schedule: { type: 'cron', expression: '0 8 * * *' }, + }, + }, + { + id: 'notify_owner', + type: 'notify', + label: 'Notify Owner', + config: { recipients: '{record.owner}', title: 'Contract {record.name} ends on {record.end_date}' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'notify_owner' }, + { id: 'e2', source: 'notify_owner', target: 'end' }, + ], +}); +``` + +| キー | 宣言するもの | +|---|---| +| `object` | スイープがレコードを問い合わせるオブジェクト(マシン名) | +| `dateField` | 今日を基準に評価される `date` / `datetime` フィールド(日単位) | +| `offsetDays` | **オフセットモード**:`dateField` がちょうど今日 + 列挙した各オフセットのときに発動します(`[60, 30, 7]`。負の値は過去で、たとえば `[-1]` は翌日) | +| `withinDays` | **範囲モード**:`dateField` が今日から N 日以内である日には毎日発動します。正の値は今後(「まもなく期限切れ」)、負の値は範囲を区切った期限超過の遡り、`0` は今日が期限 | +| `filter` | 計算された日付の範囲と AND 結合される、省略可能な ObjectQL の where マップ(例:`{ status: 'active' }`) | +| `maxRecords` | 1 回のスイープで起動するレコード数の上限(デフォルト 1000。上限で切り詰めるとスイープがログに記録します) | + +`offsetDays` と `withinDays` のどちらか**一方だけ**を設定しなければなりません。ほかによくある 2 つの形です。 + +```ts +// Two variants of the start node's config.timeRelative; the rest of the flow is as above. + +// "Expiring soon" — fires every day a document is within 30 days of expiry. +timeRelative: { object: 'hr_document', dateField: 'expires_on', withinDays: 30 } + +// Overdue sweep — fires for POs up to 14 days past due (bounded lookback). +timeRelative: { object: 'purchase_order', dateField: 'due_date', + withinDays: -14, filter: { status: 'open' } } +``` + +各起動は一致したレコードを自動化コンテキストに置くので、開始ノードの `condition` と `{record.}` の埋め込みは、レコード変更フローとまったく同じように動作します。検出のクエリはシステムとして実行され、レコードごとに障害が分離されます。`os validate` には準備状況のチェックが加わり、`timeRelative.object` がスタックで定義されていないオブジェクトを指しているとき、および自動起動のフローが `draft` ステータスのまま残っているときに警告します。 + +> **同じ日のチェックは、今は問題なく使えます(#3183)。** 16.0 では `record.due_date == today()` が一致します。エンジンが時間型の `==` / `!=` 比較を変換するので、日付フィールドが `today()` と正しく比較されます。本当に「今日が期限」という条件にはこれを使い、「日付の N 日前/後」という形のものには `timeRelative` を使ってください。 + +## 手動で開始:アクションと API 呼び出し [#started-by-hand-actions-and-api-calls] + +```ts +export const approveInvoice = defineFlow({ + name: 'approve_invoice', + label: 'Approve invoice', + type: 'autolaunched', + status: 'active', + variables: [ + { name: 'invoice_id', type: 'text', isInput: true }, + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'approve', + type: 'update_record', + label: 'Approve the invoice', + config: { + objectName: 'invoice', + filter: { id: '{invoice_id}' }, + fields: { status: 'approved', approved_by: '{$User.Id}' }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'approve' }, + { id: 'e2', source: 'approve', target: 'end' }, + ], +}); +``` + +請求書のビューで、種類が `flow` の[アクション](/docs/build/interface/actions)を使ってボタンとして表示するか、REST で呼び出します。 + +```bash +curl -X POST https://app.example.com/api/v1/automation/approve_invoice/trigger \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"params": {"invoice_id": "inv_123"}}' +``` + +入力は `isInput: true` で宣言したフロー変数で、同じ名前で `params` に入れて送ります。宣言された入力だけがバインドされます。呼び出し元のアイデンティティが実行に引き渡されるので、更新は呼び出し元の権限のもとで実行されます。 + +このルートは何が起きたかを示す HTTP ステータスで応答するので、本文の `success` フラグではなくステータスで分岐してください。ディスパッチされた実行(完了、一時停止、または[拒否](#ending-a-run-completed-or-refused))には `200`、不明なフローには `404`、オフに切り替えられたフローには `409 FLOW_DISABLED`、開始ノードのない定義には `422 FLOW_NO_START_NODE`、ディスパッチされて失敗した実行には `400 FLOW_FAILED` です。種類が `flow` のアクションも同じように応答します。[REST API → Flow status table](/docs/reference/rest-api#flow-status-table) を参照してください。 + +## ノードの種類 [#node-types] + +| ノードの `type` | 目的 | +|---|---| +| `get_record` | ObjectQL でレコードを読みます。1 件、または `limit` が 1 より大きいときはリスト | +| `create_record` / `update_record` / `delete_record` | オブジェクトに書き込みます(`multi: true` で一致したすべてを更新または削除) | +| `notify` | 通知を送ります。デフォルトはアプリ内の受信トレイ、`channels: ['email']` でメール | +| `http` | HTTP エンドポイントを呼び出します | +| `connector_action` | インストール済みのコネクタ(Slack など)でアクションを実行します | +| `script` | `config.function` で名前を指定した登録済みの関数を呼び出します | +| `decision` | エッジの条件で分岐します | +| `assignment` | フロー変数を設定します | +| `loop` | コレクションの各項目について本体の領域を 1 回ずつ実行します | +| `parallel` | 分岐の領域を並行して実行します | +| `try_catch` | 領域内の失敗を捕捉し、必要に応じて再試行します | +| `wait` | タイマーまたは名前付きシグナルまで一時停止します | +| `screen` | ユーザーの入力を待って一時停止します | +| `subflow` | 別のフローを呼び出します | +| `approval` | 承認者が判断するまでブロックします(`@objectstack/plugin-approvals` が必要) | +| `end` | 実行を終了します。デフォルトは `completed`、または理由付きの `refused`(下記) | + +### 実行の終了:完了または拒否 [#ending-a-run-completed-or-refused] + +ObjectStack 17.4 以降、`end` ノードは実行の**結果**を宣言します。断らなければならないフロー(重複、上限を超える金額など)は、失敗したり、断ったことを伝えるだけの画面を表示したりする代わりに、拒否の `end` で終わります。 + +```ts +// One flow node; the flow around it is omitted. +{ + id: 'refuse_duplicate', + type: 'end', + label: 'Refused: duplicate', + config: { + outcome: 'refused', // 'completed' (the default) | 'refused' + message: 'Refused: {record.name} is already registered.', + }, +} +``` + +拒否された実行は失敗した実行ではありません。何も例外を投げていないからです。実行は `refused` ステータスで終わり、トリガーのルートはそのステータスとともに `200` で応答し、レンダリングされた `message` は `refusalMessage` として返され、その横に `successMessage` はありません。`outcome` が `refused` のときは `message` が必須で、完了の end では拒否されます。そこでは誰の目にも触れないからです。拒否の `end` には `decision` の分岐から到達させ、すべての書き込みは拒否が通らない分岐に置いてください。 + +## 条件と分岐 [#conditions-and-branches] + +```ts +export const expenseRouting = defineFlow({ + name: 'expense_routing', + label: 'Route new expenses', + type: 'record_change', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Expense submitted', + config: { objectName: 'expense', triggerType: 'record-after-create' }, + }, + { id: 'check_amount', type: 'decision', label: 'Over 10,000?' }, + { + id: 'ask_approver', + type: 'notify', + label: 'Ask the approver', + config: { recipients: '{record.approver}', title: 'Expense over 10,000: {record.name}' }, + }, + { + id: 'auto_approve', + type: 'update_record', + label: 'Auto-approve', + config: { objectName: 'expense', filter: { id: '{record.id}' }, fields: { status: 'auto_approved' } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'check_amount' }, + { id: 'e_large', source: 'check_amount', target: 'ask_approver', condition: 'record.amount > 10000' }, + { id: 'e_other', source: 'check_amount', target: 'auto_approve', isDefault: true }, + { id: 'e2', source: 'ask_approver', target: 'end' }, + { id: 'e3', source: 'auto_approve', target: 'end' }, + ], +}); +``` + +`decision` ノードは出力エッジで分岐します。エッジは `edges` に並んだ順に評価され、`condition` が成り立つ最初のものが実行され、どれも成り立たないときは `isDefault` のエッジが実行されます。`isDefault` がない場合、条件のないエッジは、一致した分岐と並んで毎回実行されます。条件が成り立つ*すべての*分岐を実行するには、decision に `config: { mode: 'inclusive' }` を宣言します。最初の一致を採用するのは ObjectStack 17.5 からのルールです。それ以前は真になったすべての分岐が実行されていました。データベースに保存された decision(Studio から保存されたもの)は、書き換えられることなく、アップグレード時に新しい意味になりました。 + +## エラー処理 [#error-handling] + +```ts +// Keys of a record_change flow on `invoice`. The nodes are omitted: `charge_card` is an +// `http` node, `mark_paid` an `update_record`, and `flag_for_review` a `notify`. +edges: [ + { id: 'e_ok', source: 'charge_card', target: 'mark_paid' }, + { id: 'e_fault', source: 'charge_card', target: 'flag_for_review', type: 'fault' }, +], +errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 1000, backoffMultiplier: 2 }, +``` + +どちらの仕組みもなければ、ノードの失敗で実行は終了します。これらは 2 つの範囲で働きます。 + +| | 範囲 | 失敗時 | +|---|---|---| +| `fault` エッジ | 1 つのノード | 実行はハンドラーから続行し、完了します | +| `errorHandling: { strategy: 'retry', maxRetries }` | フロー全体 | フローは最初から再実行されます。最大で `maxRetries` 回まで | + +`strategy` は `'fail'`(デフォルト)、`'retry'`、`'continue'` のいずれかです。再試行は、すでに成功したすべてのノードを再生します。2 通目のメール、2 件目の作成レコードが生まれるので、失敗が局所的な場合は fault エッジを優先してください。fault エッジが処理した失敗はフローの失敗ではなく、再試行を消費せず、失敗したステップは実行のトレースに残ります。 + +## 数式と式(CEL) [#formulas-and-expressions-cel] + +条件(開始ノードの `condition`、エッジの `condition`)は **CEL**(Common Expression Language)で書きます。安全な式評価のための Google の言語です。 + +```ts +'record.amount > 10000 && record.tier == "enterprise"' +'record.stage != previous.stage' +'!isBlank(record.notes)' +``` + +`record` と `previous` は宣言されたすべてのフィールドを持ち、値のないフィールドは `null` として読まれます。そのため `has(record.notes)` が示すのはフィールドが宣言されていることで、値があることではありません。値の有無は `record.notes != null` または `isBlank(record.notes)` で確かめてください。 + +CEL はサンドボックス化されており(副作用なし、I/O なし)、サーバー側で評価され、フロービルダーで監査できます。 + +## ビジュアルビルダー [#visual-builder] + +ObjectOS には、宣言的なメタデータと相互に変換できるビジュアルフロービルダーが付属しています。エンジニアでなくてもフローを編集でき、編集結果は手で書く TypeScript と同じ形にシリアライズされます。これは Studio の **Automations** の柱です。 + +Studio から保存したフローは、インストール済みのパッケージが提供するフローと並んで、デプロイメントに保存されます。そこから 2 つのルールが導かれます。どちらも ObjectStack 17.6 からです。 + +- **パッケージ化されたフローがその名前を取ります。** パッケージ化されたフローと同じ名前を持つ保存済みのフローは隠され、実行されません。起動ログに警告が出ます。パッケージ化されたフローをカスタマイズするには、新しい名前で複製してください。 +- **保存済みのフローは `status` で切り替えます。** オン/オフのトグルが切り替えるのはパッケージ化されたフローだけです。デプロイメント内で作成したフローは、`status: 'obsolete'` で保存するとオフになり、`status: 'active'` で保存するとオンに戻ります。 + +## フローのテスト [#testing-flows] + +```bash +os test qa/ticket-email.test.json # run a Quality Protocol test suite against a running server +``` + +`os test` は、JSON のテストスイートとして書かれた Quality Protocol のシナリオを、実行中のサーバーに対して実行します。`--tags` はタグでシナリオを選びます。[CLI リファレンス](/docs/reference/cli)を参照してください。 + +## 制限とベストプラクティス [#limits--best-practices] + +- **before フックは小さく保ちます。** 書き込みトランザクションをブロックするからです。 +- **長時間実行のステップの代わりに `wait` ノードを使います。** 実行を永続的に待機させ、タイマーまたは名前付きシグナルで再開します。 +- **独立した作業には `parallel` ブロックを使います。** 分岐を並行して実行し、フローが続行する前に合流させます。 +- **冪等性が重要です。** フローレベルの再試行は、すでに成功したノードを再実行します。外部への副作用は重複を排除すべきです(フローの実行 ID をキーとして使います)。 +- **監査上重要なアクション。** 権限を変更したりレコードを削除したりするフローは、それ自体が `sys_audit_log` にログを書くべきです。 + +## 次に進む [#where-to-go-next] + +- [Webhook](/docs/configure/webhooks):外向きの通知。フローから起動されることが多いもの +- [メール](/docs/configure/email):`notify` ノードの `email` チャネルの背後にあるトランスポート +- [通知](/docs/configure/notifications):受信者、チャネル、テンプレートがどう解決されるか +- [API アクセス](/docs/configure/api-access):外部システムからフローを呼び出す +- [@objectstack/service-automation](https://github.com/objectstack-ai/objectstack/tree/main/packages/services/service-automation):実行エンジンのソース diff --git a/content/docs/build/automation/index.ja.mdx b/content/docs/build/automation/index.ja.mdx new file mode 100644 index 00000000..e9db7348 --- /dev/null +++ b/content/docs/build/automation/index.ja.mdx @@ -0,0 +1,43 @@ +--- +title: 自動化 +seoTitle: "自動化:フロー、ワークフロー、承認" +description: 用途に合ったツールを選びます。手順にはフロー、状態にはワークフロー、人による承認には承認を使います。 +editions: [community, cloud, enterprise] +translation: + source_sha: e39ead174c229520946d302d63cc2c7e6903c12d87048df02a15aed0b5ab0a84 + guide_rev: 1 + mode: auto +--- + +**自動化は、ビジネスロジックをアプリケーションコードに散らばらせるのではなく、ランタイムが実行するメタデータとして、データモデルに宣言的に結び付けます。** 3 つのツールがその範囲をカバーしており、最初に正しいものを選べば、後で作り直す手間が省けます。 + +## どれが必要か [#which-one-do-i-need] + +| 必要なもの | 使うもの | 読むもの | +|---|---|---| +| 「X が起きたら Y をする」:レコードの変更、スケジュール、ボタンのクリックで実行される手順 | **フロー** | [フロー](/docs/build/automation/flows) | +| 「このレコードはこれらの状態だけを通って進める」:管理されたライフサイクル | **ワークフロー**(ステートマシン) | [ワークフロー](/docs/build/automation/workflows) | +| 「先に進む前に人が承認しなければならない」:ルーティングされる人の判断 | **承認** | [承認](/docs/build/automation/approvals) | + +目安:**状態**はワークフローで、**手順**はフローでモデル化します。承認は別のエンジンではありません。承認とは、人が判断するまで承認ノードで一時停止するフローです。 + +## どう組み合わせるか [#how-they-combine] + +3 つは競合せず、組み合わせて使います。 + +- **ワークフロー**は、そもそもどのライフサイクル遷移が有効かを制約します。状態とその間の移動だけで、それ以外は扱いません。 +- **フロー**は、それらの遷移の前後で副作用を実行します。メールを送る、レコードを更新する、外部サービスを呼び出す、待機する、分岐する、などです。 +- フロー内の**承認ノード**は、承認者が操作するまで実行を止め、その後、承認または却下の分岐に沿って再開します。 + +つまり「ケースが new → assigned → resolved と進む」はワークフローであり、「エスカレーションされたらマネージャーに通知する」はフローであり、「5 万ドルを超える値引きには財務マネージャーの承認が必要」は承認ノードを持つフローです。 + +> **ヒント:** 要求が「X が起きたら Y をする」と読めるならフローです。「このレコードは決して状態を飛ばしてはならない」と読めるならワークフローです。ここから始めれば、選択を誤ることはほとんどありません。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|---|---| +| [フロー](/docs/build/automation/flows) | フローの完全なリファレンス:トリガー、ステップの種類、エラー処理、CEL | +| [ワークフロー](/docs/build/automation/workflows) | 厳密なライフサイクルのための、状態とその許可された遷移 | +| [承認](/docs/build/automation/approvals) | 承認ノード、実行アイデンティティ、承認者の体験 | +| [CEL 式](/docs/reference/cel) | 条件と検証ルールで使われる式言語 | diff --git a/content/docs/build/automation/workflows.ja.mdx b/content/docs/build/automation/workflows.ja.mdx new file mode 100644 index 00000000..00fbe406 --- /dev/null +++ b/content/docs/build/automation/workflows.ja.mdx @@ -0,0 +1,181 @@ +--- +title: ワークフロー +seoTitle: "ワークフロー:ステートマシンとしてのレコードのライフサイクル" +description: レコードのライフサイクルをステートマシンとしてモデル化します。レコードが取りうる状態、その間で許される移動、そしてフローの方がうまくできることは何も含めません。 +editions: [community, cloud, enterprise] +translation: + source_sha: 7ec55c7f19bcf4f40b34dc0d852ea4cf3534169ad263dbdff0f1ed34fbb0c600 + guide_rev: 1 + mode: auto +--- + +**ワークフローは、レコードのライフサイクルを有限ステートマシンとしてモデル化します。レコードが取りうる状態と、その間で許される移動です。** オブジェクト上の `state_machine` 検証ルールとして宣言し、状態フィールドへのすべての書き込みがそれに照らしてチェックされます。中心となる要件が「このオブジェクトはこれらの状態だけを通って進める」であるときに使います。 + +Salesforce 風の独立した Workflow Rule という作成タイプはありません。かつての「ワークフロー」の概念は、きれいに 2 つに分かれます。 + +- **`state_machine` 検証ルール**:厳密なライフサイクル遷移のため。このページです。 +- **[フロー](/docs/build/automation/flows)**:イベントで起動する、またはスケジュールされた自動化のため。[承認](/docs/build/automation/approvals)による一時停止も含みます。 + +## ワークフローとフロー [#workflows-vs-flows] + +| | ワークフロー(ステートマシン) | フロー | +|---|---|---| +| モデル化するもの | *状態*:レコードがライフサイクルのどこにあるか | *手順*:何かが起きたときに何が起こるか | +| 答える問い | 「この遷移は今許されるか?」 | 「それについて何をするか?」 | +| 形 | 状態フィールドと、許される移動の表 | ノードとエッジ:トリガー、アクション、分岐 | +| 副作用 | なし。制約するだけです | すべて:メール、更新、HTTP、待機 | + +両者は組み合わせて使います。ステートマシンが遷移を制約し、通知、レコード更新、外部呼び出しが必要なときには、フローがその前後で副作用を実行します。 + +## ステートマシンを定義する [#define-a-state-machine] + +`new → assigned → resolved` と進まなければならず、エスカレーションの経路を持つサポートケースです。ステートマシンはオブジェクトの `validations` のひとつです。 + +```ts +import { P } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const SupportCase = ObjectSchema.create({ + name: 'support_case', + label: 'Support Case', + fields: { + status: Field.select({ + label: 'Status', + required: true, + options: [ + { label: 'New', value: 'new', default: true }, + { label: 'Assigned', value: 'assigned' }, + { label: 'Escalated', value: 'escalated' }, + { label: 'Resolved', value: 'resolved' }, + ], + }), + resolution: Field.textarea({ + label: 'Resolution', + requiredWhen: P`record.status == 'resolved'`, // no resolved case without one + }), + }, + validations: [ + { + type: 'state_machine', + name: 'case_lifecycle', + field: 'status', + message: 'This status change is not allowed.', + initialStates: ['new'], + transitions: { + new: ['assigned'], + assigned: ['resolved', 'escalated'], + escalated: ['resolved'], + resolved: [], + }, + }, + ], +}); +``` + +これを契約として読みます。ケースは `new` として作成され、`new` のケースは担当割り当てしかできません。`assigned` のケースは解決またはエスカレーションでき、エスカレーションされたケースは解決できます。`resolved` には何も列挙されていないので、それ以上は何も移動させられません。ステータスが `resolved` のときは `resolution` が必須なので、それなしでケースを解決する書き込みは拒否されます。 + +## 構成 [#anatomy] + +| キー | 宣言するもの | +|---|---| +| `type: 'state_machine'` | この検証ルールがステートマシンであること | +| `name` | ルールの識別子(snake_case) | +| `field` | ルールが管理する状態フィールド | +| `transitions` | 各状態 → 移動できる状態のマップ。レコードの更新時にチェックされます | +| `initialStates` | レコードを作成できる状態。挿入時にチェックされます | +| `message` | 拒否された書き込みが返すエラー | + +このルールは、すべての検証ルールに共通するキー `severity`、`events`、`active`、`priority` も受け付けます。 + +### 最終状態 [#final-states] + +リストが空の状態は最終状態で、そこから出るすべての移動が拒否されます。キーがまったくない状態は最終状態ではありません。そこではチェックに判断材料がなく、書き込みを通してしまうので、「最終」を意味するときは `[]` を宣言してください。 + +### 遷移の条件 [#conditions-on-a-transition] + +`state_machine` ルールには遷移ごとのガードがありません。移動を条件付きにするには、プラットフォームが強制する場所に条件を置きます。 + +- **ある状態に必要な値**:上の `resolution` のように、そのフィールドに `requiredWhen` を付けます。値なしでその状態に入る書き込みを拒否し、ルールより前から存在する行には手を付けません。 +- **それ以外すべて**:同じ `validations` 配列に、兄弟の `script` または `conditional` 検証ルール(CEL の述語)を置きます。 + +### イベントではなく書き込み [#writes-not-events] + +マシンは状態フィールドへの書き込み時にチェックされます。`status` を現在の値の下に列挙されていない値に変える更新は拒否され、`initialStates` 以外での挿入も同様です。宣言すべき名前付きイベントはありません。正当な移動の表がマシンのすべてです。 + +> **ヒント:** マシンは最小限に保ちます。状態とその間の移動だけです。「そしてメールを送る」と言いたくなった瞬間、ワークフローの領域を離れています。その副作用は、遷移に反応する[フロー](/docs/build/automation/flows)に置いてください。 + +## 副作用はフローに置く [#side-effects-belong-in-flows] + +ステートマシンは有効な遷移を記述するだけで、それ以外は何もしません。遷移で何かを*する*必要があるとき(営業に通知する、クローズ日を記録する、外部システムを呼び出す)は、マシンとレコード変更で起動するフローを組み合わせます。 + +```ts +export const dealClosedWon = defineFlow({ + name: 'deal_closed_won', + label: 'Deal closed won', + type: 'record_change', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Stage moved to closed won', + config: { + triggerType: 'record-after-update', + objectName: 'opportunity', + condition: "record.stage == 'closed_won' && previous.stage != 'closed_won'", + }, + }, + { + id: 'set_closed_date', + type: 'update_record', + label: 'Set Closed Date', + config: { + objectName: 'opportunity', + filter: { id: '{record.id}' }, + fields: { closed_date: '{TODAY()}' }, + }, + }, + { + id: 'notify_sales', + type: 'notify', + label: 'Notify Sales', + config: { recipients: '{record.owner}', title: 'Deal won: {record.name}' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'set_closed_date' }, + { id: 'e2', source: 'set_closed_date', target: 'notify_sales' }, + { id: 'e3', source: 'notify_sales', target: 'end' }, + ], +}); +``` + +マシンは商談が正当に `closed_won` へ*到達した*ことを保証し、フローはその後に起こることを処理します。上のような条件は CEL 式です。[CEL](/docs/reference/cel) を参照してください。 + +## ワークフロールールからの移行 [#migrating-from-workflow-rules] + +Workflow Rules を持つプラットフォームから来た場合の対応は次のとおりです。 + +| 旧来の概念 | 現在の対応するもの | +|---|---| +| Workflow Rule | フロー | +| 時間トリガー(「日付の N 日前/後」) | 相対時間トリガー:フローの開始ノードの `config.timeRelative`(16.0、[フロー](/docs/build/automation/flows)を参照) | +| 時間トリガー(固定時刻) | スケジュールフロー | +| 項目自動更新アクション | `update_record` ノード | +| メールアラート | `notify` ノード | +| HTTP 呼び出し | `http` ノード | +| 承認プロセス | 1 つ以上の `approval` ノードを持つフロー | + +判断の基準:旧ルールが「X が起きたら Y をする」なら、小さなフローとしてモデル化します。「日付フィールドの N 日前/後に Y をする」なら、相対時間トリガーを宣言します。レコード変更フローに日付が等しいという条件を付けてはいけません。それはレコードがたまたま変更されたときにしか評価されないからです。「このレコードは管理された状態を通って進まなければならない」なら、`state_machine` 検証ルールを宣言し、副作用にはフローを使います。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|---|---| +| [フロー](/docs/build/automation/flows) | 遷移の前後の副作用:トリガー、ステップ、エラー処理 | +| [承認](/docs/build/automation/approvals) | フロー内の一時停止としての、人による承認 | +| [検証ルール](/docs/build/data/validation-rules) | ステートマシンが属するルールの系統 | +| [CEL 式](/docs/reference/cel) | `requiredWhen`、検証ルール、フロー条件の背後にある言語 | +| [データモデリング](/docs/build/data) | ライフサイクルを制約するオブジェクト | +| [自動化の概要](/docs/build/automation) | 選び方:フロー、ワークフロー、承認 | diff --git a/content/docs/build/interface/actions.ja.mdx b/content/docs/build/interface/actions.ja.mdx new file mode 100644 index 00000000..c89a8f0a --- /dev/null +++ b/content/docs/build/interface/actions.ja.mdx @@ -0,0 +1,208 @@ +--- +title: アクション +seoTitle: "アクション:REST エンドポイント、ボタン、AI ツール" +description: プラットフォームが 1 つの宣言から REST エンドポイント、ボタン、AI ツールとして公開する、名前付きの操作。 +editions: [community, cloud, enterprise] +translation: + source_sha: 85e291bd4a1a724fe1ba2fbbf181d61ab30004d9389a19eaee2e9ae3175b1096 + guide_rev: 1 + mode: auto +--- + +**アクション**は、オブジェクトに対する名前付きの操作です。一度宣言すると、次のものとして現れます。 + +- `/api/v1/actions//` の **REST エンドポイント** +- レコード詳細ページの**ボタン** +- エージェントと AI Builder 向けの **AI ツール**(`action_`)。AI に公開したとき(`ai.exposed`、[下記](#from-an-ai-agent))に限ります + +3 つの画面で同じことを繰り返す必要はありません。1 つの宣言で、3 通りの呼び出し方ができます。作業が 1 ステップで終わらないときは、アクションから[フロー](/docs/build/automation/flows)を開始することもできます(`type: 'flow'`)。 + +## アクションを宣言する [#declare-an-action] + +```ts +// src/actions/approve_invoice.action.ts +import { Action } from '@objectstack/spec/ui'; + +export const approveInvoice = Action.create({ + name: 'approve_invoice', // lowercase snake_case (machine id) + label: 'Approve Invoice', + objectName: 'invoice', // attaches to the invoice object + icon: 'check', + variant: 'primary', + locations: ['record_header'], // where the button shows + description: 'Approve this invoice?', // shown in the param dialog + successMessage: 'Invoice approved', + refreshAfter: true, + + // collect input before running + params: [ + { name: 'note', label: 'Approval note', type: 'textarea' }, + ], + + // only show the button when the record is still pending + visible: 'record.status == "pending"', + + // expose it to agents and the AI Builder as `action_approve_invoice` + ai: { + exposed: true, + description: 'Approve a pending invoice, recording the approver and an optional note.', + }, + + // what it does — a sandboxed script body. `input` is the params bag; + // `ctx.record` is a read-only snapshot, so the write goes through `ctx.api`, + // which needs the `api.write` capability + type: 'script', + body: { + language: 'js', + capabilities: ['api.write'], + source: ` + await ctx.api.object('invoice').update({ + id: ctx.recordId, + status: 'approved', + approved_by: ctx.user.id, + approved_at: new Date().toISOString(), + approval_note: input.note, + }); + `, + }, +}); +``` + +`os dev` が再コンパイルした後: + +- `POST /api/v1/actions/invoice/approve_invoice` が動作します +- 請求書のレコードページに **Approve Invoice** ボタンが表示されます +- AI アシスタントは、スキルが許可していれば `action_approve_invoice` を呼び出せます。AI に公開されているからです + +## アクションの種類 [#action-types] + +`type` フィールドが、アクションが何をするかを決めます。 + +| `type` | 実行されるもの | 用途 | +|---|---|---| +| `script` | `body`:L1 の数式、またはサンドボックス化された L2 の JavaScript | ほとんどの場合。サーバー側のロジックで、監査でき、公開すれば AI から呼び出せます | +| `api` | `target` エンドポイントへの HTTP 呼び出し(`method`、`bodyExtra`) | データ API やプラットフォームのエンドポイントの再利用 | +| `flow` | `target` で名前を指定したフローを実行します | 多段階のビジネスプロセス | +| `url` | `target` の URL に移動します | ディープリンク、リダイレクト型のアクション | +| `modal` | `target` で名前を指定したページ / モーダルを開きます | カスタムのダイアログ | +| `form` | `target` で名前を指定した FormView を開きます | ガイド付きのデータ入力 | + +```ts +// api type — reuse a data-API endpoint +Action.create({ + name: 'archive_order', + objectName: 'order', + label: 'Archive', + locations: ['list_item'], + type: 'api', + method: 'PATCH', + target: '/api/v1/data/order/{id}', + bodyExtra: { archived: true }, +}); +``` + +`script` 以外の種類には `target` が必要です。どの種類でも 1 つの宣言ですが、`url`、`modal`、`form` のアクションは UI でしか実行されないので、AI ツールになることはありません。 + +## アクションを呼び出す [#calling-an-action] + +### REST [#rest] + +```bash +# the record id can go in the body, or in the path +curl -X POST https://app.example.com/api/v1/actions/invoice/approve_invoice/inv_123 \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"note": "LGTM"}' +``` + +パラメータはリクエスト本文にフラットに入れて送ります。レコード ID は、末尾のパスセグメント(`.../approve_invoice/:recordId`)としても本文の中でも指定できます。レスポンスは、スクリプト本体の戻り値です(`api` の種類のアクションでは呼び出しの結果)。 + +### UI から [#from-the-ui] + +ボタンをどこに表示するかはアクション自身で宣言します。`locations`(`record_header`、`record_more`、`list_item`、`list_toolbar` など)と `order` です。その後、`visible` の述語がレコードごとにレンダリングするかどうかを決めます。リストビューでも、各行と行の選択に対するアクションを名前で指定できます。 + +```ts +// One list view; the container and `data` are omitted (see Views). +{ + type: 'grid', + columns: ['number', 'customer', 'amount', 'status'], + rowActions: ['approve_invoice', 'reject_invoice'], + bulkActions: ['send_to_customer'], +} +``` + +### フローとともに [#with-a-flow] + +アクションを呼び出すフローのノードはありません。逆向きにしてください。多段階のロジックをフローに置き、`type: 'flow'` と、`target` にフローの名前を指定したアクションからそれを開始します。 + +```ts +// flow type — start a flow by name +Action.create({ + name: 'run_invoice_approval', + objectName: 'invoice', + label: 'Run approval', + locations: ['record_more'], + type: 'flow', + target: 'approve_invoice', +}); +``` + +### AI エージェントから [#from-an-ai-agent] + +アクションがエージェントに届くのは、作成者が AI に公開したときだけです。上の例のように、UI なしで実行される種類(`script`、`flow`、`api`)で、`ai: { exposed: true }` と 40 文字以上の `ai.description` を指定します。公開されたアクションは、ObjectOS 自身のエージェントと AI Builder では `action_` ツールになり、[MCP クライアント](/docs/configure/mcp)では `run_action` になります。公開されていなければ、エージェントからは見えず、呼び出されても拒否されます。 + +`approve_invoice` が公開されていて、エージェントが持つスキルに含まれていれば、LLM はそれを呼び出せます。入力は会話から得られ、ユーザーがボタンを押した場合と同じ呼び出し時のチェックが適用されます。 + +> *「請求書 INV-2042 を、メモ『電話で確認済み』を付けて承認して。」* + +## 権限 [#permissions] + +誰がアクションを実行できるかは、呼び出されたときに、呼び出し元としてチェックされます。REST からでも、ボタンからでも、エージェントからでも同じです。 + +1. **`requiredPermissions`**:呼び出し元の[権限セット](/docs/configure/permissions/permission-sets)が持っていなければならない機能です。1 つでも欠けている呼び出し元は `403` を受け取ります。 +2. **対象のレコード**:呼び出し元として読み込まれます。呼び出し元が読めないレコードは、見つからないものとして応答されます。 +3. **AI への公開**:エージェントが届くのは、AI に公開されたアクションだけです。 +4. **UI のゲート**:`visible` と `disabled` の述語(CEL。`record`、`os.user`、パラメータに対して評価されます)が、ボタンをレンダリングするか、グレーアウトするかを制御します。 + +その後アクションが行うことは、信頼されたアプリのコードです。`script` 本体の読み書きはアプリの権限で実行され、レコードアクセスとフィールドレベルのセキュリティはそれを制限**しません**。`flow` アクションはフローの `runAs` に従います。そのため、機密性のあるものには `requiredPermissions` を宣言し、アクションを書くことはコードレビューに値する行為として扱ってください。 + +## 組み込みのアクション [#built-in-actions] + +すべてのオブジェクトには、これらが無償で用意されます。 + +| アクション | 働き | +|---|---| +| `create` | レコードを挿入します | +| `update` | レコードを更新します | +| `delete` | レコードを削除(またはソフト削除)します | +| `restore` | ソフト削除を取り消します | +| `clone` | レコードをディープコピーします | +| `share` | ユーザー、チーム、ポジション、事業単位、または単位とその配下に直接共有します | + +これらを再宣言しないでください。オブジェクトの[ライフサイクルと機能のフラグ](/docs/build/data)に従います。 + +## 監査 [#auditing] + +プラットフォームのイベントは、変更不可能な証跡である `sys_audit_log` に記録されます。次のようなフィールドがあります。 + +- `user_id`:操作を起こしたユーザー +- `action`:アクションの名前 +- `object_name` と `record_id`:何に触れたか +- `old_value` / `new_value`:変更内容 +- `ip_address` / `user_agent`:リクエストの発信元 +- `created_at`:いつ起きたか + +*「誰がボタンを押したのか?」* という質問には、まずここを見てください。 + +## AI Builder でアクションを生成する [#generating-actions-with-the-ai-builder] + +> *「`support_ticket` に `escalate_ticket` というアクションを作って。優先度を urgent に設定し、オンコールのエンジニアに割り当てるもの。」* + +[AI Builder](/docs/build/ai-builder) はアクションのメタデータを生成し、変更を承認待ちのキューに入れます。承認後、アクションは REST、UI、そして再帰的に AI 自身から呼び出せるようになります。 + +## 次に進む [#where-to-go-next] + +- [フロー](/docs/build/automation/flows):`type: 'flow'` のアクションが開始する多段階のロジック +- [エージェント](/docs/build/agents):アクションを AI ツールとして公開する +- [API アクセス](/docs/configure/api-access):外部システムからアクションを呼び出す +- [権限](/docs/configure/permissions):誰が何を呼び出せるかをゲートする diff --git a/content/docs/build/interface/apps.ja.mdx b/content/docs/build/interface/apps.ja.mdx new file mode 100644 index 00000000..d11da2d9 --- /dev/null +++ b/content/docs/build/interface/apps.ja.mdx @@ -0,0 +1,137 @@ +--- +title: アプリ +seoTitle: "アプリ:ナビゲーション、ブランディング、対象者のゲート" +description: オブジェクト、ビュー、ページ、ダッシュボードを、ブランド付きでナビゲーションできる外枠にまとめ、誰が何を見るかを正確にゲートします。 +editions: [community, cloud, enterprise] +translation: + source_sha: 92a61a2c5ecd3b085a106339e991600fa0fc1dc9db0194a67c257c4946b4f080 + guide_rev: 1 + mode: auto +--- + +**アプリ**は、オブジェクト、ビュー、ページ、ダッシュボードをひとつのまとまった体験にまとめる論理的なコンテナです。ナビゲーションツリー、ブランディング、そして決定的に重要な、誰が入れるかを定義します。 + +```ts +import { App } from '@objectstack/spec/ui' + +export const CrmApp = App.create({ + name: 'crm_app', + label: 'CRM', + icon: 'briefcase', + branding: { primaryColor: '#2563EB' }, + navigation: [ + { id: 'group_sales', type: 'group', label: 'Sales', icon: 'briefcase', children: [ + { id: 'nav_leads', type: 'object', objectName: 'crm_lead', label: 'Leads', icon: 'funnel' }, + { id: 'nav_accounts', type: 'object', objectName: 'crm_account', label: 'Accounts', icon: 'building' }, + ]}, + ], + requiredPermissions: ['crm_access'], +}) +``` + +## アプリのプロパティ [#app-properties] + +| プロパティ | 型 | 必須 | 説明 | +|:--|:--|:--|:--| +| `name` | `string` | はい | マシン名(`snake_case`) | +| `label` | `string` | はい | 表示名 | +| `icon` | `string` | — | アプリのアイコン(Lucide) | +| `description` / `version` | `string` | — | 一覧表示用のメタデータ | +| `active` | `boolean` | — | アプリが有効かどうか(デフォルト `true`) | +| `isDefault` | `boolean` | — | これがデフォルトのアプリかどうか | +| `navigation` | `NavigationItem[]` | — | ナビゲーションツリー | +| `branding` | `AppBranding` | — | `primaryColor`、`logo`、`favicon` | +| `requiredPermissions` | `string[]` | — | そもそも誰がアプリを開けるか | +| `homePageId` | `string` | — | ランディングページとして使うナビゲーション項目の `id` | + +## ナビゲーション項目 [#navigation-items] + +ナビゲーションツリーは 8 種類の項目をサポートします。よく使う 5 つは `object`、`dashboard`、`page`、`url`、`group` で、仕様ではほかに `report`、`action`、`component` の項目も定義されています。 + +| `type` | 着地先 | 主な設定 | +|:--|:--|:--| +| `object` | オブジェクトのリストビュー | `objectName`、および省略可能なターゲット指定(下記) | +| `dashboard` | ダッシュボード | `dashboardName` | +| `page` | カスタムページ | `pageName`、省略可能な `params` | +| `url` | 外部の URL | `url`、`target: '_blank'` | +| `group` | 折りたたみ可能なセクション | `children`、`expanded` | + +すべての項目は一意な `id`(小文字の `snake_case`)を宣言**しなければなりません**。`homePageId` が参照するのはこれです。共通のプロパティ:`label`、`icon`、`order`、`badge`、そして下記の 3 つのゲートです。 + +### オブジェクトのエントリのターゲット指定 [#targeting-an-object-entry] + +3 つの省略可能なフィールドで `object` エントリの着地先を絞り込めます。優先順位は `recordId` → `filters` → `viewName` です。 + +- `viewName`:エントリを名前付きのリストビューに固定します。 +- `recordId`:単一のレコードへのディープリンク(「マイプロフィール」)。`{current_user_id}` / `{current_org_id}` のテンプレート変数をサポートします。 +- `filters`:一回限りのパラメータ付きの切り出しです。エントリは素のデータ画面に着地し、各条件は削除できる URL のフィルターチップになります。ビューを作成せずに「自分に割り当てられたもの」のリンクを作るのに最適です。セキュリティ機能ではありません。何を表示するかは、引き続き行レベルの権限が決めます。 + +```ts +// One navigation item of an app. +{ id: 'nav_my_open', type: 'object', label: 'My Open Deals', objectName: 'opportunity', + filters: { owner_id: '{current_user_id}', status: 'open' }, icon: 'user-check' } +``` + +### モバイルのナビゲーション [#mobile-navigation] + +モバイル専用のナビゲーションブロックはありません。`mobileNavigation` は、どのレンダラーも読み取っていなかったため、ObjectStack 17.0 で削除されました。 + +## 対象者によるアプリのゲート [#gating-apps-by-audience] + +同じデータが、まったく異なる 2 つの対象者に役立ちます。スキーマを設計する**ビルダー**と、データを入力して読むだけの**エンドユーザー**です。これらの画面はデフォルトで分けてください。各管理者が手で隠すことに頼ってはいけません。 + +| 対象者 | 画面 | ゲートするもの | +|:--|:--|:--| +| エンドユーザー(利用者) | 厳選されたアプリ → `page` / `view` のエントリ | `App.requiredPermissions`、ナビゲーション項目のゲート | +| ビルダー / 管理者 | Setup / Studio、素のオブジェクトテーブル | 機能:`setup.access`、`studio.access`、`manage_metadata` | + +組み込みの権限セットはすでにこの区分を表しています。`member_default` と `viewer_readonly` は `studio.access` / `manage_metadata` を持た**ない**ので、これらの権限セットのユーザーにはビルダー向けの画面が見えません。`admin_full_access` と `organization_admin` は持っています。 + +各ナビゲーション項目は、独立した 3 つのゲートをサポートします。 + +| ゲート | 型 | 次の場合を除き項目を隠す…… | +|:--|:--|:--| +| `requiredPermissions` | `string[]` | ユーザーが RBAC の機能(例:`manage_metadata`)を持っている | +| `visible` | CEL 式 | 述語が真と評価される(例:`'org_admin' in current_user.positions`) | +| `requiresObject` / `requiresService` | `string` | 指定したオブジェクト / カーネルサービスがインストールされている | + +```ts +// Keys of an app; its other keys are omitted. +navigation: [ + { id: 'nav_contacts', type: 'object', label: 'Contacts', objectName: 'showcase_contact' }, + // Builder-only entry — consumers never render it: + { id: 'nav_designer', type: 'component', label: 'Object Designer', + componentRef: 'metadata:resource', params: { type: 'object' }, + requiredPermissions: ['manage_metadata'] }, +] +``` + +> **無効化せず、隠します。** 無効化されていても見えているビルダー向けのエントリは、やはりノイズです。ゲートされたナビゲーション項目は、その機能を持たないユーザーには*レンダリングされません*。エンドユーザーを混乱させるグレーアウトした部品はありません。 + +利用者向けの画面をすっきり保つ習慣がさらに 2 つあります。 + +- **エンドユーザーには素のテーブルではなくページを渡します。** `page` エントリなら、公開するものを正確に厳選できます。選んだ列、固定された可視化、有効にしたフィルターとアクションだけです。`object` エントリは制限の緩いグリッドを与えます。切り替え可能なビュー、個人用ビュー、完全なツールバーです。 +- **データ画面そのものを厳選します。** 素のオブジェクトの読み取り権限を与えて 40 列のグリッドにユーザーを放り込むよりも、[厳選したビュー](/docs/build/interface/views)を優先してください。 + +アクションのゲートは両面です。UI がボタンを隠すか無効にし、**かつ**サーバーが呼び出しを拒否します。「UI ではゲートされているがサーバーでは開いている」という落とし穴はありません。[アクション](/docs/build/interface/actions)を参照してください。 + +## Setup アプリ:組み込みの例 [#the-setup-app--a-built-in-example] + +プラットフォーム自身の管理 UI である **Setup アプリ**も、同じアプリのメタデータプロトコルからレンダリングされています。ナビゲーション、ページ、ゲートはデータとして宣言され、あなたのアプリと同じレンダラーが描画します。そのゲートは、あなたのビルダー向け画面がそうあるべきとおりのものです。利用者向けの権限セットが持たない `setup.access` 機能の背後にあります。メタデータとしてのアプリがスケールする証拠が欲しいなら、すでにそれを使っています。 + +## アンチパターン [#anti-patterns] + +- **すべてのエンドユーザーをビルダー並みの共同作業者にして**、タブを 1 つずつ隠すこと。利用者とビルダーの区分をデフォルトにしてください。 +- **ビルダー向けのエントリを隠さずに無効化すること。** 見えているのに動かない部品は、やはり混乱を招きます。 +- 厳選したページでエンドユーザーに必要なものを正確に公開できるのに、**素のオブジェクトの読み取り権限を与えること**。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|:--|:--| +| [ビュー](/docs/build/interface/views) | `object` のナビゲーションエントリの着地先 | +| [ページ](/docs/build/interface/pages) | エンドユーザー向けの厳選された `page` エントリ | +| [ダッシュボード](/docs/build/interface/dashboards) | `dashboard` エントリがレンダリングするもの | +| [アクション](/docs/build/interface/actions) | ボタンとその両面のゲート | +| [権限](/docs/configure/permissions) | `requiredPermissions` の背後にある権限セット | +| [インターフェースの概要](/docs/build/interface) | すべての部品がどう組み合わさるか | diff --git a/content/docs/build/interface/dashboards.ja.mdx b/content/docs/build/interface/dashboards.ja.mdx new file mode 100644 index 00000000..04155fc1 --- /dev/null +++ b/content/docs/build/interface/dashboards.ja.mdx @@ -0,0 +1,201 @@ +--- +title: ダッシュボード +seoTitle: "ダッシュボードの構築:データセット、ウィジェット、ドリルダウン" +description: 名前付きデータセットにバインドされたチャートウィジェットで構築する分析ページ。グローバルフィルター、自動更新、元のレコードへのドリルスルーを備えます。 +editions: [community, cloud, enterprise] +translation: + source_sha: c67b6877ef40bb832312c2b13597c8aa27a0351ad3ce7bd72469323bdef0ddc6 + guide_rev: 1 + mode: auto +--- + +**ダッシュボードはウィジェットのグリッドであり、すべてのウィジェットはデータセットにバインドします。** データセットはセマンティックレイヤーで、基になるオブジェクト、結合、ディメンション、メジャーを持ちます。ウィジェットはそれらを名前で選ぶので、「売上」は、それを使うすべてのダッシュボードとレポートで同じ意味になります。 + +```ts +// One dashboard, bound to the `sales` dataset defined below. +const salesDashboard = { + name: 'sales_overview', + label: 'Sales Overview', + description: 'Key sales metrics and pipeline analysis', + refreshIntervalSeconds: 300, // auto-refresh every 5 minutes + + dateRange: { + field: 'close_date', + defaultRange: 'this_quarter', + allowCustomRange: true, + }, + + widgets: [ + { id: 'total_revenue', title: 'Total Revenue', type: 'metric', + dataset: 'sales', values: ['revenue'], + layout: { x: 0, y: 0, w: 3, h: 2 } }, + { id: 'revenue_by_region', title: 'Revenue by Region', type: 'bar', + dataset: 'sales', dimensions: ['region'], values: ['revenue'], + layout: { x: 3, y: 0, w: 6, h: 4 } }, + { id: 'deals_by_month', title: 'Deals by Month', type: 'pie', + dataset: 'sales', dimensions: ['close_month'], values: ['deal_count'], + layout: { x: 9, y: 0, w: 3, h: 4 } }, + ], +} +``` + +## ダッシュボードのプロパティ [#dashboard-properties] + +| プロパティ | 型 | 必須 | 説明 | +|:--|:--|:--|:--| +| `name` | `string` | はい | マシン名(`snake_case`) | +| `label` | `string` | はい | 表示ラベル | +| `description` | `string` | — | ダッシュボードの説明 | +| `widgets` | `DashboardWidget[]` | はい | チャートと指標のウィジェット | +| `refreshIntervalSeconds` | `number` | — | 自動更新の間隔(秒) | +| `dateRange` | `object` | — | グローバルな日付範囲のフィルター | +| `globalFilters` | `GlobalFilter[]` | — | 対話的なフィルターコントロール | + +## まずデータセット [#datasets-first] + +データセットを一度定義し、すべてのウィジェットをそれにバインドします。 + +```ts +import { defineDataset } from '@objectstack/spec/ui' + +export const salesDataset = defineDataset({ + name: 'sales', + label: 'Sales', + object: 'opportunity', + include: ['account'], + dimensions: [ + { name: 'region', field: 'account.region', type: 'string' }, + { name: 'close_month', field: 'close_date', type: 'date', dateGranularity: 'month' }, + ], + measures: [ + { name: 'revenue', field: 'amount', aggregate: 'sum' }, + { name: 'deal_count', aggregate: 'count' }, + ], +}) +``` + +集計はウィジェットではなく、データセットのメジャー(`aggregate`)に置かれます:`count`、`sum`、`avg`、`min`、`max`、`count_distinct`、`array_agg`、`string_agg`。ウィジェットの `dimensions` と `values` は、バインドしたデータセットで宣言された名前を参照しなければなりません。 + +実行時、データセットのクエリは分析サービスを通じて実行され、基になるオブジェクトと結合したオブジェクトに、呼び出し元の行レベルセキュリティの範囲が自動的に適用されます。ダッシュボードが、ユーザーの[権限](/docs/configure/permissions)では見られないレコードを見せることはありません。 + +> 16.0 以降、ウィジェットのスキーマは**厳格**です。宣言されていないトップレベルのキー(入力ミス、ハルシネーションによるキー、削除されたインラインクエリのキー(`object` + `categoryField` + `valueField` + `aggregate`、ピボットの `rowField` / `columnField`))は、**問題のキーを名指しするパースエラー**になり、データセットの形を示します。何もレンダリングしないウィジェットへと黙って取り除かれることは、もうありません。データセットを定義してウィジェットをそれにバインドしてください。`options` は、レンダラー固有の追加設定のための自由形式の逃げ道として残っています。 + +## ウィジェット [#widgets] + +| プロパティ | 型 | 必須 | 説明 | +|:--|:--|:--|:--| +| `id` | `string` | はい | 一意なウィジェット ID(`snake_case`) | +| `dataset` | `string` | はい | バインドするデータセットの名前 | +| `values` | `string[]` | はい | メジャーの名前(少なくとも 1 つ) | +| `dimensions` | `string[]` | — | ディメンションの名前:X / グループ / 分割 | +| `type` | `ChartType` | — | 可視化の種類(デフォルト `metric`) | +| `title` / `description` | `string` | — | 表示用のタイトルとサブタイトル | +| `filter` | `FilterCondition` | — | 表示範囲のフィルター | +| `layout` | `object` | — | グリッド上の位置(省略時は自動配置) | +| `colorVariant` | `enum` | — | KPI / カードのアクセントカラー | +| `compareTo` | `enum \| object` | — | 期間比較のウィンドウ | +| `filterBindings` | `object` | — | ウィジェットごとのグローバルフィルターの対応付け(下記) | + +### チャートの種類 [#chart-types] + +| 種類 | 最適な用途 | +|:--|:--| +| `metric` *(デフォルト)* | 単一の数値の KPI:売上、件数、割合 | +| `bar` / `horizontal-bar` / `column` | カテゴリーの比較 | +| `line` | 時間の経過による傾向 | +| `pie` / `donut` | 分布 | +| `area` | 時間の経過による量 | +| `scatter` | 相関 | +| `radar` | 多次元の比較 | +| `funnel` | コンバージョンの段階 | +| `gauge` / `solid-gauge` / `bullet` / `kpi` | 目標に対する進捗 | +| `treemap` / `sankey` | 階層の比率 / 流れ | +| `table` | 詳細なレコード:**ドリルスルー** | +| `pivot` | クロス集計:**ドリルスルー** | + +### レイアウト [#layout] + +ウィジェットは 12 列のグリッドに配置されます。 + +```ts +// Keys of a dashboard widget; its other keys are omitted. +layout: { + x: 0, // column position (0-11) + y: 0, // row position + w: 6, // width in columns (1-12) + h: 4, // height in rows +} +``` + +## ドリルダウン [#drill-down] + +`table` と `pivot` のウィジェットは**ドリルスルー**できます。集計された行やセルをクリックすると、そのグループの背後にある*元のレコード*を一覧するサイドドロワーが開きます。データセットは生のグループキーを保持しているので、ドロワーのフィルターは正確なレコードに一致します。ラベルから ID を推測することはありません。ドロワー内の任意の行をクリックすると、そのレコードの詳細が開きます。**グループ → レコード一覧 → レコード**という一連の流れです。 + +ドロワーには **"Open in list →"** という逃げ道もあり、同じドリルのフィルターで範囲を絞ったまま、のぞき見をオブジェクトの完全なリストページ(並べ替え、一括選択、エクスポート、共有できる URL)に格上げします。 + +チャートのウィジェットも同じようにドリルできます。棒、点、扇形をクリックすると、ドロワーにそのバケットのレコードが一覧されます。日付のディメンションなら、そのバケットの日付範囲ぴったりです。データセットが基になるオブジェクトを公開し、ウィジェットが少なくとも 1 つのディメンションでグループ化していれば、ドリルスルーは自動的に働き、ウィジェットごとの設定は不要です。`metric` のタイルは集計値だけを表示します。[レポート](/docs/build/reports)も同じようにドリルスルーできます。 + +## グローバルな日付範囲とフィルター [#global-date-range-and-filters] + +グローバルな時間のフィルターはすべてのウィジェットに適用されます。 + +```ts +// Keys of a dashboard; its other keys are omitted. +dateRange: { field: 'created_at', defaultRange: 'this_month', allowCustomRange: true } +``` + +プリセットの範囲:`today`、`yesterday`、`this_week`、`last_week`、`this_month`、`last_month`、`this_quarter`、`last_quarter`、`this_year`、`last_year`、`last_7_days`、`last_30_days`、`last_90_days`、`custom`。 + +対話的なグローバルフィルターも同じように働きます。 + +```ts +// Keys of a dashboard; its other keys are omitted. +globalFilters: [ + { name: 'region', field: 'region', label: 'Region', type: 'select' }, + { field: 'owner', label: 'Sales Rep', type: 'lookup' }, +] +``` + +各フィルターの `name`(デフォルトは `field`)がその安定した識別子です。ウィジェットが `filterBindings` で参照するキーであり、ウィジェットの式の中で `page.` として読めるダッシュボードレベルの変数です。`dateRange` という名前は組み込みの日付範囲のために予約されています。 + +### ウィジェットごとのフィルターのバインディング [#per-widget-filter-bindings] + +デフォルトでは、フィルターはすべてのウィジェットで自身の `field` に適用されます。ウィジェットがその概念を別のフィールドに保持している場合、またはフィルターを無視すべき場合は、`filterBindings` を宣言します。 + +```ts +// Keys of a dashboard; each widget's dataset, values and title are omitted (`/* … */`). +widgets: [ + // Default binding: dateRange → created_at, region → region. + { id: 'invoices_by_status', /* … */ }, + // This widget's fields differ — map each filter explicitly. + { id: 'accounts_signed', + filterBindings: { dateRange: 'signed_at', region: 'sales_region' }, /* … */ }, + // Opt out of a filter with `false`. + { id: 'total_invoices', filterBindings: { region: false }, /* … */ }, +] +``` + +> 優先順位:明示的な `filterBindings` のエントリ(文字列による上書き、または `false` によるオプトアウト)→ フィルターの旧来の `targetWidgets` 許可リスト → フィルター自身の `field`。 + +## ダッシュボードを表示する [#surfacing-a-dashboard] + +[アプリ](/docs/build/interface/apps)に `dashboard` のナビゲーションエントリを追加します。 + +```ts +// One navigation item of an app. +{ id: 'nav_analytics', type: 'dashboard', label: 'Analytics', + dashboardName: 'sales_overview', icon: 'bar-chart' } +``` + +あるいは [AI Builder](/docs/build/ai-builder) に説明します(*「地域別の売上と月次の商談の推移を含む営業概要ダッシュボード」*)。そして承認する前に、生成されたデータセット + ダッシュボードのメタデータを確認します。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|:--|:--| +| [レポート](/docs/build/reports) | 同じデータセットに対するグループ化レポートとクロス集計レポート | +| [アプリ](/docs/build/interface/apps) | ダッシュボードをナビゲーションに置く | +| [ビュー](/docs/build/interface/views) | 同じデータセットに対するインラインの `chart` リストビュー | +| [ページ](/docs/build/interface/pages) | ウィジェットのグリッドでは足りないときの自由形式のレイアウト | +| [データモデル](/docs/build/data) | データセットが集計するオブジェクト | +| [権限](/docs/configure/permissions) | ダッシュボードが継承する行レベルセキュリティ | diff --git a/content/docs/build/interface/forms.ja.mdx b/content/docs/build/interface/forms.ja.mdx new file mode 100644 index 00000000..3e5be655 --- /dev/null +++ b/content/docs/build/interface/forms.ja.mdx @@ -0,0 +1,175 @@ +--- +title: フォーム +seoTitle: "フォーム:作成と編集、セクション、送信後" +description: 作成フォームと編集フォームを 1 つのフラットなフィールドの集合から導き出し、ずれを生まずにフィールドをセクションにまとめ、送信後に起こることを制御します。 +editions: [community, cloud, enterprise] +translation: + source_sha: 7b91231facd6607664e7ec8baee9af1949872ed91a12abe14e7b20eb2d0c96e2 + guide_rev: 1 + mode: auto +--- + +**フォームはオブジェクトのフィールドの投影であり、2 つ目のフィールドリストではありません。** フィールドはオブジェクトで一度だけ宣言し、フォームは*どのフィールドをどこに*置くかを選びます。データの意味(型、検証、デフォルト、フィールドレベルのセキュリティ)がフォームに置かれることはないので、フォームがずれてデータについて偽ることはありません。 + +フォームビューの*種類*(`simple`、`tabbed`、`wizard`、`modal` など)と公開フォームの REST の契約は、[ビュー](/docs/build/interface/views)で扱っています。このページはレイアウトの問題、つまり作成と編集、グループ化、順序についてです。 + +## 作成フォーム ≠ 編集フォーム [#create-form--edit-form] + +新規レコードのフォームはいくつかの必須項目だけを尋ね、編集フォームはレコード全体をセクションに分けて表示するべきです。**2 つのフォームを作成しないでください。** 作成フォームのサブセットは、各フィールドにすでにある意図から*導き出せます*。 + +| フィールドの手がかり | 作成フォームへの影響 | +|:--|:--| +| `required: true` | 作成時に表示**しなければなりません** | +| `readonly` / 数式 / ロールアップ / 自動採番 / システムが記録するもの | 作成時には**決して**表示しません。設定できないからです | +| `defaultValue` がある | **省略できます**。自動で埋まるからです | +| `hidden` | デフォルトではどこにも表示しません | +| `group` | フィールドが属するセクション | +| *宣言の順序* | これがデフォルトの表示順序**です**。`field.order` はありません | + +そのため、妥当な作成フォーム(*編集可能で、必須または中核のフィールドを、宣言の順序で*)は、何も作成しなくてもオブジェクトから自然に導かれます。省略が正解です。余分なものを何も出力しなくても、完全で正しいフォームが得られます。完全な編集フォームも導き出され、各 `field.group` がセクションとして具体化されます。 + +### 作成フォームを手で整えるのは、レイアウトや流れが異なる場合だけ [#hand-shape-the-create-form-only-when-layout-or-flow-diverges] + +逃げ道は、作成のエントリポイントにバインドした名前付きのフォームビューです。これは**疎な上書き**として作成します。ほとんどはフィールド名のリストです。 + +```ts +import { defineView } from '@objectstack/spec' + +const data = { provider: 'object' as const, object: 'showcase_contact' } + +export const ContactViews = defineView({ + list: { + type: 'grid', data, + columns: [{ field: 'name' }, { field: 'email' }, { field: 'company' }, { field: 'stage' }], + // Bind the "+ Add record" entry point to the slim create form: + addRecord: { enabled: true, mode: 'form', formView: 'create' }, + }, + + // Full edit form — grouped by field.group; bare strings inherit field defs. + form: { + type: 'simple', data, + sections: [ + { name: 'contact', label: 'Contact', columns: 2, fields: ['name', 'email', 'phone'] }, + { name: 'work', label: 'Work', columns: 2, fields: ['company', 'title'] }, + { name: 'status', label: 'Status', columns: 2, fields: ['stage', 'lead_score'] }, + ], + }, + + formViews: { + // Sparse create override: only the core fields, one section. + create: { + type: 'simple', data, title: 'New contact', + sections: [ + { label: 'Who is this?', columns: 1, fields: ['name', 'email', 'phone', 'company'] }, + ], + }, + }, +}) +``` + +バインディングは `addRecord.mode: 'form'` + `addRecord.formView: 'create'` です。`formViews.create` がなければ、作成のエントリはデフォルトを導き出します。あれば、作成時にはそれが優先されます。 + +| 本当に必要なこと | 使うもの | +|:--|:--| +| 作成時に尋ねるフィールドを減らす(必須 + いくつかの中核) | **純粋な導出**。手で書かないでください | +| 作成時のグループ化が異なるが、小さなサブセットにすぎない | たいていはやはり導出 | +| 作成がウィザード / 多段階、作成専用の文言、条件付きの表示を持つ | **`formViews.create` を手で書く** | + +> 目安:*フィールドのサブセットが異なる* → 導出する。*レイアウトや流れが異なる* → 上書きする。フィールドを減らすためだけに完全な作成フォームを書くと、2 つの成果物がずれる罠にまっすぐはまります。必須フィールドを追加し、作成フォームのことを忘れ、作成時に実行時の「必須フィールドがありません」が出る、という具合です。 + +作成のフィールドは編集のフィールドの*同じ順序とグループの*サブセットなので、「4 つのフィールドでクイック作成 → 保存 → 完全なレコードに着地」が視覚的に途切れません。導出はそれを無償で保ちます。 + +## フィールドのグループ化と順序 [#field-grouping-and-order] + +モデルはフラットなフィールドの集合です。グリッドはフラットな列を表示します。フォームにはセクションが必要です。これは矛盾ではありません。1 つのフラットな集合を、異なるレンズを通して見ているのです。 + +| レンズ | フィールドの形 | 理由 | +|:--|:--|:--| +| オブジェクトの定義 | フラット | モデルは*どんなデータが存在するか*を記述します | +| テーブル / グリッド | フラットな列 | グリッドはレコード × フィールドの行列です | +| フォーム / レコードページ | グループ化されたセクション | 1 件のレコードを読む人間には区切りが必要です | + +グループ化の概念は **2 つ**あります。区別しておいてください。 + +**1. 意味上のグループ化:`field.group`(オブジェクト上)。** フィールドの論理的な居場所です。モデルとともに移動し、自動生成されるフォームの*デフォルトの*セクション分けの元になります。 + +```ts +// Fields of an object; the object around them is omitted. +fields: { + name: Field.text({ label: 'Full name', group: 'contact' }), + email: Field.email({ label: 'Email', group: 'contact' }), + stage: Field.select({ + label: 'Stage', + group: 'status', + options: [ + { label: 'Lead', value: 'lead' }, + { label: 'Customer', value: 'customer' }, + ], + }), +} +``` + +**2. レイアウト上のグループ化:フォームの `sections`(ビュー上)。** 特定のフォームの明示的な配置です。どのフィールドを、どのセクションに、何列(`columns`)で置くか、折りたたみ可能か。`field.group` をデフォルトとして継承し、必要に応じてフォームごとに上書きします。 + +```ts +// Keys of a view container; `data` and its other keys are omitted. +form: { + type: 'simple', + sections: [ + { name: 'contact', label: 'Contact', columns: 2, fields: ['name', 'email', 'phone'] }, + { name: 'status', label: 'Status', columns: 2, fields: ['stage'] }, + ], +} +``` + +**順序は作成した順序です。** `field.order` はありません。オブジェクト上のフィールドの宣言の順序が、どこでもデフォルトの表示順序であり、`sections`(とその中のフィールド)は順序付きのリストです。 + +> **「グループ」の罠。** グリッドビューの `groupByField` は、フィールドの*値*で **レコード(行)** をグループ化します。`stage = qualified` の行がすべてまとまります。フォームのセクションは **フィールド(列)** を視覚的にグループ化します。同じ言葉で、無関係な 2 つの軸です。グリッドのグループがフォームをセクションに分けることは決してありません。 + +## 送信後に起こること [#what-happens-after-submit] + +送信後の体験を制御するには、フォームビューに `submitBehavior` を追加します。 + +```ts +// Keys of a form view; its other keys are omitted. +submitBehavior: { + kind: 'thank-you', + title: 'Thanks!', + message: 'A specialist will reach out within 24 hours.', +} +``` + +| `kind` | 動作 | +|:--|:--| +| `thank-you` *(デフォルト)* | フォームを確認パネル(`title`、`message`)に置き換えます | +| `redirect` | `delayMs`(デフォルト 0)の後に `url` へ移動します。マーケティングページに最適です | +| `continue` | 次の回答のためにフォームをリセットします。キオスク、一括入力向けです | +| `next-record` | キュー内の次のレコードに進みます(内部モード) | + +公開フォームと内部フォームのどちらも、`?prefill_=` という URL パラメータに従います。メールのリンクやキャンペーンページからフォームに値を入れておけます。事前入力は UX の近道であって、権限のすり抜けではありません。値はやはり検証と、(公開フォームの場合は)サーバー側のフィールドのホワイトリストを通ります。 + +``` +/console/f/contact-us?prefill_company=Acme&prefill_email=ada@example.com +/console/forms/quick_create?prefill_lead_source=event_booth_2026 +``` + +## レコードの詳細はフォームのバインディングではなくロールで決まる [#record-detail-is-driven-by-roles-not-a-form-binding] + +フォームビューをレコード詳細画面に固定するオブジェクトレベルのキーはありません。詳細のレンダリングは、オブジェクトの画面をまたぐ**意味上のロール**から導き出されます。`nameField`(表示名)、`highlightFields`(最も重要なフィールドの帯)、`stageField`(ライフサイクルの進捗ステッパー)、そして `fieldGroups` + `Field.group`(フォーム、モーダル、詳細ページで共有されるセクション分け)です。ロールから導き出せる以上の特注のレイアウトがレコードページに必要なときは、そのオブジェクトにカスタムの[ページ](/docs/build/interface/pages)を割り当てます。 + +## アンチパターン [#anti-patterns] + +- **手で作成した 2 つの完全なフィールドリスト**(`contact_create_form` + `contact_edit_form`)。ずれることが確実です。 +- **フィールドの型 / 検証 / 選択肢をフォームで書き直すこと。** データの意味はオブジェクトにだけ置きます。 +- **すべてのフォームでグループ化を打ち直すこと。** `field.group` を一度宣言し、本当に異なる場合だけ上書きします。 +- **フォームを満たすためにデータモデルに構造的な入れ子を加えること。** モデルはフラットのままにし、フォームがグループ化します。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|:--|:--| +| [ビュー](/docs/build/interface/views) | フォームビューの種類、公開フォームの共有、ビューの完全なスキーマ | +| [データモデル](/docs/build/data) | フィールドとその意図が宣言される場所 | +| [アクション](/docs/build/interface/actions) | フォームビューを開く `type: 'form'` のアクション | +| [ページ](/docs/build/interface/pages) | 導出されたレイアウトを超えるカスタムのレコードページ | +| [権限](/docs/configure/permissions) | フォームが従うフィールドレベルのセキュリティ | diff --git a/content/docs/build/interface/index.ja.mdx b/content/docs/build/interface/index.ja.mdx new file mode 100644 index 00000000..dc6217f8 --- /dev/null +++ b/content/docs/build/interface/index.ja.mdx @@ -0,0 +1,49 @@ +--- +title: インターフェース +seoTitle: "インターフェース:アプリ、ビュー、フォーム、ダッシュボード、ページ" +description: アプリ、ビュー、フォーム、ダッシュボード、ページ、アクション。ユーザーが目にするすべての画面をメタデータとして宣言し、ObjectOS がレンダリングします。 +editions: [community, cloud, enterprise] +translation: + source_sha: b5a75aba85c279c1de7f1e2e11254b300b51906392cfaa0a5f4e1f50a803e61c + guide_rev: 1 + mode: auto +--- + +**ユーザーが目にするものはすべてメタデータです。** インターフェースはデータとして宣言し、オブジェクトと同じライフサイクルを持ちます。バージョン管理し、パッケージで出荷し、AI Builder に生成させることができ、準拠したレンダラーならどれでも描画できます。 + +各部品は上から下へと組み合わさります。 + +1. **アプリ**は外枠です。ナビゲーション、ブランディング、対象者のゲートを持ちます。 +2. その**ナビゲーション**は、オブジェクト、ダッシュボード、ページ、URL を指します。 +3. オブジェクトのエントリは**ビュー**に着地します。グリッド、カンバン、カレンダーなどです。 +4. レコードを開いたり作成したりすると**フォーム**がレンダリングされます。セクションはフィールドのメタデータから導き出され、モードごとに上書きできます。 +5. **ダッシュボード**は同じデータを、名前付きデータセットにバインドされたチャートウィジェットに集約します。 +6. **ページ**はオブジェクトの枠から完全に抜け出します。コンポーネントとリージョンで構成する自由形式のレイアウトです。 +7. **アクション**は上記のすべてにボタンを置きます。1 つの宣言で、UI、REST、フロー、AI エージェントから呼び出せます。 + +## 概念 [#the-concepts] + +| 概念 | 宣言するもの | ページ | +|:--|:--|:--| +| アプリ | ナビゲーションの外枠、ブランディング、対象者のゲート | [アプリ](/docs/build/interface/apps) | +| ビュー | レコードのレンダリング方法:グリッド、カンバン、カレンダー、ガント…… | [ビュー](/docs/build/interface/views) | +| フォーム | 作成 / 編集のレイアウト、セクション、フィールドのグループ化 | [フォーム](/docs/build/interface/forms) | +| ダッシュボード | チャートウィジェット、グローバルフィルター、ドリルダウン | [ダッシュボード](/docs/build/interface/dashboards) | +| ページ | 自由形式のコンポーネントレイアウト。パッケージ化されたドキュメントページ | [ページ](/docs/build/interface/pages) | +| アクション | あらゆる場所にボタンとして表示される名前付きの操作 | [アクション](/docs/build/interface/actions) | + +UI はデータなので、表示するレコードと同じ[権限](/docs/configure/permissions)に従います。また [AI Builder](/docs/build/ai-builder) は、同じ型付きのインターフェースを通じて、そのどれでも生成または変更できます。欲しいインターフェースを説明し、差分を確認して、承認します。 + +> まずデータから始めてください。ビューとフォームは[オブジェクトの定義](/docs/build/data)から妥当なデフォルトを導き出すので、インターフェースのメタデータを書くのは、デフォルトが望みどおりでない箇所だけです。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|:--|:--| +| [アプリ](/docs/build/interface/apps) | オブジェクトをナビゲーションできる外枠で包む | +| [ビュー](/docs/build/interface/views) | レコードのレンダリング方法を選ぶ | +| [フォーム](/docs/build/interface/forms) | 作成と編集の体験を形作る | +| [ダッシュボード](/docs/build/interface/dashboards) | 分析ウィジェットを追加する | +| [ページ](/docs/build/interface/pages) | 自由形式のレイアウトを構成する | +| [アクション](/docs/build/interface/actions) | 操作をボタンの背後に置く | +| [データモデル](/docs/build/data) | すべての画面が読み取るオブジェクト | diff --git a/content/docs/build/interface/pages.ja.mdx b/content/docs/build/interface/pages.ja.mdx new file mode 100644 index 00000000..f46decbb --- /dev/null +++ b/content/docs/build/interface/pages.ja.mdx @@ -0,0 +1,166 @@ +--- +title: ページ +seoTitle: "自由形式のページ:リージョン、コンポーネント、ドキュメントページ" +description: リージョン、コンポーネント、ローカル状態で構成する自由形式のレイアウト。さらに、パッケージの中に同梱して出荷する Markdown のドキュメントページ。 +editions: [community, cloud, enterprise] +translation: + source_sha: 4a88c0ee1ee387d77d686cf62b3d5270e2d183f2d362cd66274326205db8a3d4 + guide_rev: 1 + mode: auto +--- + +**ページは自由形式のコンテナです。** 単一のオブジェクトにバインドされる[ビュー](/docs/build/interface/views)とは異なり、ページは複数のコンポーネントを組み合わせ、ビューを埋め込み、ローカル状態を管理します。ホーム画面、カスタムのレコードレイアウト、ユーティリティパネルなどです。 + +```ts +// One page. +const homePage = { + name: 'sales_home', + label: 'Sales Home', + type: 'home', + regions: [ + { name: 'header', width: 'full', components: [ + { type: 'metric_card', id: 'total_revenue', label: 'Total Revenue', + properties: { dataset: 'sales', values: ['revenue'], format: 'currency' } }, + ]}, + { name: 'main', width: 'large', components: [ + { type: 'list_view', id: 'recent_deals', label: 'Recent Deals', + properties: { object: 'opportunity', view: 'recent_open', limit: 10 } }, + ]}, + ], +} +``` + +## ページのプロパティ [#page-properties] + +| プロパティ | 型 | 必須 | 説明 | +|:--|:--|:--|:--| +| `name` | `string` | はい | マシン名(`snake_case`) | +| `label` | `string` | はい | 表示ラベル | +| `type` | `enum` | — | ページの種類(デフォルト `'record'`、下記を参照) | +| `object` | `string` | — | 関連するオブジェクト(`record` の種類の場合) | +| `template` | `string` | — | レイアウトテンプレートの名前(デフォルト `'default'`) | +| `regions` | `PageRegion[]` | — | コンポーネントを含むレイアウトの区画 | +| `variables` | `PageVariable[]` | — | ローカル状態の変数 | +| `isDefault` | `boolean` | — | その種類のデフォルトのページかどうか | +| `assignedProfiles` | `string[]` | — | このページにアクセスできるプロファイル | + +### ページの種類 [#page-types] + +| 種類 | 用途 | +|:--|:--| +| `record` | 1 つのオブジェクトのレコードに結び付いたカスタムの詳細ページ | +| `home` | アプリのランディング / 入口 | +| `app` | 一般的なアプリケーションのレイアウト | +| `utility` | ツール、設定、ウィザード | +| `list` | データ駆動のインターフェース画面 | + +有効なのはこの 5 つの種類だけです。レンダラーが出荷されなかった以前のロードマップ上の種類は、スキーマから削除されました。 + +## リージョン [#regions] + +リージョンはレイアウトの区画で、それぞれがコンポーネントを保持します。 + +```ts +// Keys of a page; its other keys, and the regions' components, are omitted. +regions: [ + { name: 'sidebar', width: 'small', components: [/* … */] }, + { name: 'content', width: 'large', components: [/* … */] }, +] +``` + +`width` には `'small'`、`'medium'`、`'large'`、`'full'` を指定できます。 + +## コンポーネント [#components] + +コンポーネントはリージョンの中の構成要素です。 + +```ts +// One page component; the page and region around it are omitted. +{ + type: 'chart', + id: 'revenue_chart', + label: 'Revenue Trend', + properties: { chartType: 'line', dataset: 'sales', + dimensions: ['close_month'], values: ['revenue'] }, + events: { onClick: "navigate_to('opportunity_detail', { id: $event.id })" }, + visibleWhen: "'sales_manager' in os.user.positions", +} +``` + +分析を扱うコンポーネント(`chart`、`metric_card`)は名前付きの**データセット**にバインドし、その `dimensions` + `values` を名前で選びます。これは[ダッシュボード](/docs/build/interface/dashboards)のウィジェットと同じ ADR-0021 の形であり、Studio が出力する唯一の形です。削除されたインライン分析の形(コンポーネント上の `object` + `categoryField` + `valueField` + `aggregate`)は、16.0 以降はパースエラーになり、黙って無視される何もしない設定ではありません。 + +| プロパティ | 型 | 説明 | +|:--|:--|:--| +| `type` | `string` | 標準のコンポーネントの種類、またはカスタムの文字列 | +| `id` | `string` | 一意なコンポーネントインスタンスの ID | +| `properties` | `object` | コンポーネント固有の設定 | +| `events` | `object` | イベントハンドラー(アクションの式) | +| `visibleWhen` | `string` | CEL の表示条件の述語。TRUE のときだけコンポーネントがレンダリングされます(`visibility` は非推奨のエイリアスで、今も受け付けられ、`visibleWhen` に正規化されます) | +| `style` / `className` | — | その場限りの CSS | +| `responsiveStyles` | `object` | **推奨される**スタイル指定の手段:デスクトップ優先のブレークポイントごとのスタイルマップ(`large` を基準に、`medium` / `small` / `xsmall` で上書き)。スコープ付きの CSS にコンパイルされます。`var(--space-8)` のようなデザイントークンを優先してください | + +標準の、名前空間付きのコンポーネントの種類: + +| 名前空間 | 種類 | +|:--|:--| +| 構造 | `page:header`、`page:footer`、`page:sidebar`、`page:tabs`、`page:accordion`、`page:card`、`page:section` | +| レコードのコンテキスト | `record:details`、`record:highlights`、`record:related_list`、`record:activity`、`record:chatter`、`record:path`、`record:alert`、`record:quick_actions`、`record:reference_rail`、`record:history` | +| ナビゲーション | `app:launcher`、`nav:menu`、`nav:breadcrumb` | +| ユーティリティ | `global:search`、`global:notifications`、`user:profile` | +| AI | `ai:chat_window`、`ai:suggestion` | +| 要素 | `element:text`、`element:number`、`element:image`、`element:divider`、`element:button`、`element:filter`、`element:form`、`element:record_picker`、`element:text_input` | + +コンポーネントは、`dataSource`(複数オブジェクトのページ向けの、要素ごとのオブジェクトのバインディング)、`responsive`、`aria` の設定を持つこともできます。プロジェクト固有のウィジェットには、カスタムの文字列の種類も受け付けられます。 + +## 変数 [#variables] + +ページは、コンポーネントの間で共有されるローカル状態を保持します。 + +```ts +// Keys of a page; its other keys are omitted. +variables: [ + { name: 'selected_tab', type: 'string', defaultValue: 'overview' }, + { name: 'date_range', type: 'object', defaultValue: { start: null, end: null } }, +] +``` + +`type` には `'string'`(デフォルト)、`'number'`、`'boolean'`、`'object'`、`'array'`、`'record_id'` を指定できます。 + +> `record` の種類のページは、特注のレコードレイアウトのためにサポートされている方法です。ロールから導き出される詳細ページ([フォーム](/docs/build/interface/forms)を参照)では足りないときに使います。全幅のヘッダーに `record:highlights` を、サイドバーに `record:details` と `record:activity` を、メインのリージョンに `record:related_list` のコンポーネントを配置します。 + +[アプリ](/docs/build/interface/apps)から `page` のナビゲーションエントリでページを表示するか、`homePageId` でアプリのランディングページにします。 + +## ドキュメントページ [#doc-pages] + +**ドキュメント**はパッケージのドキュメントのページです。フラットな `src/docs/` ディレクトリに置いたプレーンな Markdown ファイルで、パッケージの成果物にコンパイルされ、UI の `/docs/` でレンダリングされます。ドキュメントは、AI アシスタントがパッケージについての質問に答えるときの根拠にもなります。 + +``` +src/docs/ + crm_index.md → doc name "crm_index" + crm_user_guide.md → doc name "crm_user_guide" +``` + +ファイル名の語幹がドキュメントの `name` になります。ディレクトリによる分類も、順序のファイルもありません。フラットな配置により相互参照が安定します。リンクはパスではなく、常にベース名で解決されます。 + +| ルール | 詳細 | +|:--|:--| +| 命名 | `snake_case`。ビルドの lint は**名前空間のプレフィックス**を要求します(`user_guide` ではなく `crm_user_guide`) | +| フロントマター | 省略可能。`title` と `description` が読み取られます。タイトルはフロントマター → 最初の `#` 見出し → ドキュメント名の順に解決されます | +| 相互参照 | プレーンな相対リンク(`[overview](./crm_index.md)`)。UI では `/docs/` に書き換えられ、GitHub ではそのまま機能します。**同じパッケージ内の壊れたリンクはビルドを失敗させます** | +| Markdown | CommonMark + GFM:テーブル、ハイライト付きのフェンスコード、見出しのアンカー、GitHub のアラート(`> [!TIP]`) | +| ビルド時に拒否されるもの | **MDX / 埋め込みコンポーネント**(ドキュメントはコードではなくデータであり、信頼の境界です)と**画像の参照**(まだアセットサービスがなく、壊れた画像よりも早い失敗の方がよいため) | + +ドキュメントの解決はパッケージ単位です。インストールされた 2 つのパッケージがそれぞれ同じ素の名前のドキュメントを出荷しても共存でき、どちらも他方を上書きしません。 + +> 動的なコンテンツ(ライブのフロー図、レコードのテーブル)については、コンポーネントを埋め込もうとしないでください。URL でメタデータにリンクします。ライブのビューはプラットフォームがレンダリングし、ドキュメントはそれを指し示すだけです。 + +## 次に進む [#where-to-go-next] + +| ページ | 理由 | +|:--|:--| +| [アプリ](/docs/build/interface/apps) | ページをナビゲーションに置き、`homePageId` を設定する | +| [ビュー](/docs/build/interface/views) | ページが埋め込むオブジェクトの画面 | +| [フォーム](/docs/build/interface/forms) | ロールから導き出されるレコードレイアウトとカスタムのレコードページ | +| [ダッシュボード](/docs/build/interface/dashboards) | 自由形式の代わりの、分析に特化したレイアウト | +| [アクション](/docs/build/interface/actions) | `element:button` のターゲットと `modal` の種類のアクション | +| [AI Builder](/docs/build/ai-builder) | チャットでページのメタデータを生成する | diff --git a/content/docs/build/interface/views.ja.mdx b/content/docs/build/interface/views.ja.mdx new file mode 100644 index 00000000..cb5582a3 --- /dev/null +++ b/content/docs/build/interface/views.ja.mdx @@ -0,0 +1,294 @@ +--- +title: ビュー +seoTitle: "ビュー:リスト、カンバン、カレンダー、ガント、フォーム" +description: リスト、フォーム、カンバン、カレンダー、ガントなど。すべてのオブジェクトの画面がどう宣言されるか。 +editions: [community, cloud, enterprise] +translation: + source_sha: f7208e0810feac70de2d69b3c01e2700665308487ccc26ac2e84bd3f0357fc0d + guide_rev: 1 + mode: auto +--- + +**ビュー**は、ユーザーがレコードを見て編集するための方法です。ビューは宣言的なメタデータで、オブジェクトと同じライフサイクルを持ちます。一度宣言し、パッケージとともに出荷し、どこでもレンダリングできます。 + +2 つの種類があります。 + +- **リストビュー**:多数のレコードを可視化します(グリッド、カンバン、カレンダー……) +- **フォームビュー**:単一のレコードを表示 / 編集します(シンプル、タブ、ウィザード……) + +スキーマのソース:[`packages/spec/src/ui/view.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/ui/view.zod.ts)。 + +## 最短の宣言 [#the-shortest-possible-declaration] + +ビューを 1 つも宣言しなくても、すべてのオブジェクトには自動的にデフォルトのグリッドリストとシンプルなフォームが用意されます。それらを上書きまたは拡張するには、オブジェクトのビューを、オブジェクトの中ではなくその隣にある `defineView` コンテナで宣言します。`list` がデフォルトのリスト、`form` がデフォルトのフォームで、各ビューは `data` で自分のオブジェクトを指定します。 + +```ts +// src/ui/views/support_ticket.view.ts +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'support_ticket' }; + +export const SupportTicketViews = defineView({ + list: { + type: 'grid', + data, + columns: ['subject', 'status', 'priority', 'assignee'], + }, + form: { + type: 'simple', + data, + sections: [ + { name: 'details', label: 'Details', fields: ['subject', 'description', 'priority', 'status', 'assignee'] }, + ], + }, +}); +``` + +コンテナを、それが記述するオブジェクトの隣に、スタックに登録します。 + +```ts +// objectstack.config.ts +export default defineStack({ + // ... + views: [SupportTicketViews], +}); +``` + +複数のビューを持つオブジェクト(たとえば同じデータに対するカンバン*と*カレンダー)では、同じコンテナに名前付きのビューを追加します。ビュー切り替え用には `listViews`、名前で開くフォーム用には `formViews` です。すべてのリストビューは、種類にかかわらずトップレベルの `columns` を持ちます。 + +```ts +export const SupportTicketViews = defineView({ + list: { type: 'grid', data, columns: ['subject', 'status', 'priority', 'assignee'] }, + listViews: { + by_status: { + label: 'By status', type: 'kanban', data, columns: ['subject', 'priority'], + kanban: { groupByField: 'status', columns: ['subject', 'priority'] }, + }, + schedule: { + label: 'Schedule', type: 'calendar', data, columns: ['subject', 'due_at'], + calendar: { startDateField: 'due_at', titleField: 'subject' }, + }, + by_owner: { + label: 'By owner', type: 'grid', data, columns: ['assignee', 'subject', 'status', 'priority'], + sort: [{ field: 'assignee', order: 'asc' }], + }, + }, + formViews: { + quick: { type: 'modal', data, sections: [{ name: 'quick', label: 'Quick edit', fields: ['status', 'priority', 'assignee'] }] }, + full: { + type: 'tabbed', data, + sections: [ + { name: 'details', label: 'Details', fields: ['subject', 'description'] }, + { name: 'triage', label: 'Triage', fields: ['status', 'priority', 'assignee'] }, + ], + }, + }, +}); +``` + +コンテナとそのルールは、ObjectStack の [View metadata](https://objectstack.ai/docs/ui/views) に完全に記載されています。 + +## リストビューの種類 [#list-view-types] + +すべてのリストビューには、種類にかかわらずトップレベルの `columns` が必要です。その種類のそれ以外の設定は、種類の名前を付けた設定ブロックに入れます。 + +| `type` | レンダリングするもの | 設定ブロックとその必須キー | よく使う省略可能なキー | +|:--|:--|:--|:--| +| `grid` | データテーブル(デフォルト) | — | | +| `kanban` | 列のあるボード | `kanban: { groupByField, columns }` | `summarizeField`、`titleField` | +| `gallery` | カードの束 | `gallery`(必須キーなし) | `coverField`、`titleField` | +| `calendar` | 月 / 週 / 日 | `calendar: { startDateField }` | `endDateField`、`titleField`、`colorField` | +| `timeline` | 時系列のフィード | `timeline: { startDateField, titleField }` | `endDateField`、`groupByField` | +| `gantt` | プロジェクトのタイムライン + 依存関係 | `gantt: { startDateField, endDateField, titleField }` | `progressField`、`dependenciesField` | +| `map` | 地図上のピン | `map`(必須キーなし) | `locationField`、または `latitudeField` + `longitudeField` | +| `tree` | 自己参照の階層 | `tree`(必須キーなし) | `parentField`、`labelField` | +| `chart` | 埋め込みのチャート | `chart: { dataset, values }` | `chartType`、`dimensions` | + +以下の断片はそれぞれ単一のリストビューです。どれも上のように、`data` で自分のオブジェクトを指定して、`defineView` コンテナの `list` として、または `listViews` のエントリとして入れます。 + +### リストの共通オプション [#common-list-options] + +```ts +// One list view; the container and `data` are omitted (see above). +// P is the predicate template tag: import { P } from '@objectstack/spec' +{ + type: 'grid', + columns: ['subject','status','priority','assignee','created_at'], + + filter: [ { field: 'archived', operator: 'equals', value: false } ], + sort: [ { field: 'created_at', order: 'desc' } ], + pagination: { pageSize: 25 }, + searchableFields: ['subject','description'], + filterableFields: ['status','priority','assignee'], + + navigation: { mode: 'drawer' }, // 'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none' + selection: { type: 'multiple' }, // 'none' | 'single' | 'multiple' + + rowActions: ['close_ticket','assign_to_me'], + bulkActions: ['bulk_close','bulk_export'], + + conditionalFormatting: [ + { condition: P`record.priority == 'urgent'`, style: { background: '#fef2f2', fontWeight: '600' } } // style values are strings + ], + + exportOptions: ['csv','xlsx'], + emptyState: { title: 'No tickets yet', message: 'Create one to get started', icon: 'inbox' } +} +``` + +`filter` と `sort` は [ObjectQL](/docs/reference/objectql) にコンパイルされます。`rowActions` と `bulkActions` は名前で[アクション](./actions)を参照します。 + +### カンバン [#kanban] + +```ts +// One list view; the container and `data` are omitted (see above). +{ + type: 'kanban', + columns: ['subject', 'priority', 'assignee'], // still required at the top level + kanban: { + groupByField: 'status', // discrete field — usually a select + summarizeField: 'amount', // optional total per column + columns: ['subject', 'priority', 'assignee'] // fields shown on each card + } +} +``` + +ボードの列は `groupByField` の値から作られ、その順序と色はそのフィールドの `select` の選択肢の定義から取られます。 + +列の間でドラッグ&ドロップすると、グループ化しているフィールドを設定する更新が発行されます。権限のルールは手動での編集と同じです。 + +### カレンダー [#calendar] + +```ts +// One list view; the container and `data` are omitted (see above). +{ + type: 'calendar', + columns: ['subject', 'start_at', 'end_at', 'priority'], + calendar: { + startDateField: 'start_at', + endDateField: 'end_at', // optional — single-point if omitted + titleField: 'subject', + colorField: 'priority' // optional — colours events by value + } +} +``` + +### ガント [#gantt] + +```ts +// One list view; the container and `data` are omitted (see above). +{ + type: 'gantt', + columns: ['name', 'start_at', 'due_at', 'percent_complete'], + gantt: { + startDateField: 'start_at', + endDateField: 'due_at', + titleField: 'name', + progressField: 'percent_complete', // optional, drives the progress bar + dependenciesField: 'depends_on' // optional — multiselect lookup to same object + } +} +``` + +### ツリー [#tree] + +```ts +// One list view; the container and `data` are omitted (see above). +{ + type: 'tree', + columns: ['name'], + tree: { + parentField: 'parent_id', // self-lookup that builds the hierarchy + labelField: 'name', // shown indented in the first column + defaultExpandedDepth: 1 // optional — 0 = roots only, omit = expand all + } +} +``` + +自己参照するオブジェクト(カテゴリー、部品表、組織単位、入れ子のコメント)では、ツリーグリッドが `parentField` のポインターに従って行を入れ子にします。 + +### チャート(インライン) [#chart-inline] + +`chart` リストビューは、名前付きの**データセット**(セマンティックな分析レイヤー、ADR-0021)にバインドし、そのディメンションとメジャーを**名前で**選びます。指標を一度定義すれば、同じデータセットにバインドされたすべてのチャート、ダッシュボード、レポートの一貫性が保たれます。 + +```ts +// One list view; the container and `data` are omitted (see above). +{ + type: 'chart', + columns: ['status'], + chart: { + chartType: 'bar', // 'bar' | 'line' | 'pie' | 'area' | 'scatter' + dataset: 'tickets_by_status', // a dataset declared with defineDataset(...) + dimensions: ['status'], // dataset dimension name(s) — X / group / split + values: ['ticket_count'] // dataset measure name(s) — the value axis + } +} +``` + +インラインの `xAxisField` / `yAxisFields` / `aggregation` / `groupByField` という形は 9.0 で削除されました。今では、すべてのチャート、ダッシュボードのウィジェット、レポートがデータセットにバインドします。より高度な、オブジェクトをまたぐ分析には、専用のレポート画面を使ってください。 + +## フォームビューの種類 [#form-view-types] + +| `type` | レイアウト | +|:--|:--| +| `simple` | 単一の列またはセクション(デフォルト) | +| `tabbed` | タブ付きのセクション | +| `wizard` | ステップごとの流れ | +| `split` | マスター / 詳細の 2 ペイン | +| `drawer` | サイドパネルのフォーム | +| `modal` | ダイアログのフォーム | + +```ts +// One form view; the container and `data` are omitted (see above). +{ + type: 'tabbed', + sections: [ + { label: 'Overview', fields: ['subject','status','priority','assignee'] }, + { label: 'Customer', fields: ['customer','contact','email','phone'] }, + { label: 'Resolution', fields: ['resolution_notes','resolved_at'] } + ], + submitBehavior: { kind: 'next-record' } // 'thank-you' | 'redirect' | 'continue' | 'next-record' +} +``` + +### 公開フォーム [#public-forms] + +フォームは匿名でアクセスできるようにできます。 + +```ts +// One form view; the container and `data` are omitted (see above). +{ + type: 'simple', + sections: [ { fields: ['name','email','message'] } ], + sharing: { + enabled: true, + publicLink: 'contact-us', // slug under /forms/ + allowAnonymous: true + }, + submitBehavior: { kind: 'thank-you' } +} +``` + +これにより `GET /api/v1/forms/contact-us` と `POST /api/v1/forms/contact-us/submit` が自動的に公開されます。認証を必要としない公開フォームのルートです。[REST API → Public forms](/docs/reference/rest-api#public-forms) を参照してください。 + +## 表示条件、ARIA、テーマ [#visibility-aria-theming] + +- `visibleOn: P\`...\``:フォームの**セクション**と**フィールド**で、ユーザー / レコード / 環境に基づいてそれらを隠します(CEL の述語) +- `aria: { label, description, ... }`:スクリーンリーダー向けの ARIA 属性(リストビューとフォームビュー) +- `appearance: { showDescription, allowedVisualizations: [...] }`:リストビューで、エンドユーザーが切り替えられるリストの種類を制限します + +## チャットで構築する [#build-by-chat] + +ビューのメタデータを手で書くことは通常ありません。AI Builder にこう伝えます。 + +> *「support_ticket オブジェクトのカンバンビューを、ステータスでグループ化して追加して。カードには件名と優先度を表示して。列はステータスに応じて赤 / 黄 / 緑に色分けして。」* + +AI Builder はメタデータツールを呼び出し、差分をキューに入れ、あなたが承認するとビューが表示されます。[AI Builder](/docs/build/ai-builder) を参照してください。 + +## 関連項目 [#see-also] + +- [データモデル](/docs/build/data):ビューが読み取るオブジェクト +- [アクション](./actions):`rowActions` / `bulkActions` が参照するもの +- [ObjectQL](/docs/reference/objectql):`filter` / `sort` のコンパイル先 +- [CEL](/docs/reference/cel):`conditionalFormatting`、`visibleOn` +- [`@objectstack/spec/ui/view.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/ui/view.zod.ts):スキーマ From 3fd70471c184514458239f32260271399caa859c Mon Sep 17 00:00:00 2001 From: Steedos Date: Thu, 29 Oct 2026 13:15:06 +0000 Subject: [PATCH 6/6] fix(worker): stop bundling every docs page twice (#395) Six route handlers (search, og, sitemap, llms.txt, llms-full.txt, llms.mdx) import lib/source.ts, whose fumadocs-mdx collection holds a lazy import() of every compiled page. Turbopack compiles route handlers as their own module graph, so the Worker carried a second copy of every page module: ~20 MB of upload, and each translated page cost ~170-235 KiB instead of ~86. #388's PR 3 would have gone over the 61,440 KiB budget. - scripts/gen-route-data.mjs (was gen-search-data.mjs) also writes .route-data/collection.ts: .source/server.ts with each page's body import replaced by a read of generated data, and markdown.ts, the English pages' processed Markdown the llms routes serve. With --dev it re-exports the full collection so next dev sees new pages. - lib/source.ts builds its loader on that collection; the page route loads bodies through lib/page-body.ts, the only module that imports the full collection. - lib/packed.ts unpacks the generated gzip+base64 strings. - The Worker budget step prints a per-page projection from the bundle's metafile (check-worker-page-cost.mjs, in SELF_TESTED). Measured, same machine: main f7e4a57 59,173 KiB, this change 38,909 KiB; with every ja page filled in, 43,700 KiB. Fixes #395 Claude-Session: https://claude.ai/code/session_01QsR2jRsjbApbf83iyTDDXm Co-Authored-By: Claude --- .github/scripts/check-worker-page-cost.mjs | 214 ++++++++++++++++++ .github/workflows/ci.yml | 14 +- .../docs/app/[lang]/docs/[[...slug]]/page.tsx | 11 +- apps/docs/app/api/search/route.ts | 19 +- apps/docs/lib/packed.ts | 12 + apps/docs/lib/page-body.ts | 22 ++ apps/docs/lib/source.ts | 26 ++- apps/docs/package.json | 6 +- apps/docs/scripts/gen-route-data.mjs | 203 +++++++++++++++++ apps/docs/scripts/gen-search-data.mjs | 121 ---------- tools/ci-scripts/run-self-tests.mjs | 8 + 11 files changed, 514 insertions(+), 142 deletions(-) create mode 100644 .github/scripts/check-worker-page-cost.mjs create mode 100644 apps/docs/lib/packed.ts create mode 100644 apps/docs/lib/page-body.ts create mode 100644 apps/docs/scripts/gen-route-data.mjs delete mode 100644 apps/docs/scripts/gen-search-data.mjs diff --git a/.github/scripts/check-worker-page-cost.mjs b/.github/scripts/check-worker-page-cost.mjs new file mode 100644 index 00000000..a3c99d33 --- /dev/null +++ b/.github/scripts/check-worker-page-cost.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +/** + * The Worker's cost per docs page, and how many more pages the budget holds + * (#395). It runs in `Worker bundle fits the size budget`, after the size + * itself is measured. + * + * #390's eleven ja pages and #388's PR 3 showed how a content batch finds out + * it is too big: the budget step goes red after the batch is written. A + * translated page costs its compiled module, its search text and a little + * routing data, and nothing said how much up front. So this prints, from the + * bundle just packaged: + * + * - the KiB each locale's page modules and search text take, per page; + * - what one more translated page costs: the most expensive locale's + * module and search text per page, plus `OVERHEAD_KIB`; + * - how many more such pages fit under the budget; + * - what one more fully translated locale (one page per English page) would + * cost, and whether it fits. + * + * `OVERHEAD_KIB` is what a page adds outside its module and search text: + * frontmatter, the route manifests, the prerender entry. Measured on #395: + * filling ja from 43 to 98 pages added 4,791 KiB of upload, of which 4,019 + * were page modules and 238 search text, so 534 KiB over 55 pages, about 10 + * KiB each. The projection is within about 2% of that measured growth. + * + * It reads the esbuild metafile OpenNext writes next to the bundled handler + * (`handler.mjs.meta.json`). Turbopack names a page's chunk after its file, + * `content_docs___mdx_.js`, and an English page's chunk + * has no locale part, so `bytesInOutput` per chunk is the bytes each page puts + * in the upload. A chunk under `content_docs_` that names no `_mdx_` file is + * shared between pages and is counted as such, not as a page. A locale's + * search text is the chunk of `.route-data/search-.ts`. + * + * The projection is a mean, not a promise: a page with long code samples costs + * more. It never fails the step on a number; the budget check above does that. + * It fails when it cannot read what it projects from (`unreadable`, + * `no-pages`), because a projection that silently prints nothing is the + * state #395 exists to end. + * + * Usage: `check-worker-page-cost.mjs --meta --size + * --budget `, from anywhere. `--self-test` runs every rule against fake + * metafiles and needs no build. + */ +import { readFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const KIB = 1024; +const OVERHEAD_KIB = 10; // per page, outside its module and search text (header) +const RULES = ['unreadable', 'no-pages']; + +/** `languages` and `defaultLanguage` out of `i18n.ts`. Throws rather than guessing. */ +function readI18n(text) { + const languages = /languages:\s*\[([^\]]+)\]/.exec(text)?.[1]?.split(',').map((s) => s.trim().replace(/['"]/g, '')).filter(Boolean) ?? []; + const defaultLanguage = /defaultLanguage:\s*['"]([^'"]+)['"]/.exec(text)?.[1]; + if (!languages.length || !languages.includes(defaultLanguage)) { + throw new Error('could not read languages[] and defaultLanguage out of apps/docs/lib/i18n.ts'); + } + return { languages, defaultLanguage }; +} + +/** Per-locale page bytes out of a parsed metafile. */ +function pageBytes(meta, { languages, defaultLanguage }) { + const locales = new Map(languages.map((l) => [l, { pages: 0, bytes: 0, search: 0 }])); + let shared = 0; + for (const output of Object.values(meta?.outputs ?? {})) { + for (const [path, input] of Object.entries(output.inputs ?? {})) { + const name = basename(path); + const search = languages.find((l) => name.includes(`route-data_search-${l}_ts`)); + if (search) locales.get(search).search += input.bytesInOutput; + if (!name.startsWith('content_docs_')) continue; + if (!name.includes('_mdx_')) { + shared += input.bytesInOutput; + continue; + } + const locale = languages.find((l) => l !== defaultLanguage && name.includes(`_${l}_mdx_`)) ?? defaultLanguage; + const row = locales.get(locale); + row.pages += 1; + row.bytes += input.bytesInOutput; + } + } + return { locales, shared }; +} + +/** The summary, as Markdown lines, plus findings. */ +function project({ meta, size, budget, i18n }) { + const findings = []; + if (!meta || !Number.isFinite(size) || !Number.isFinite(budget)) { + findings.push({ rule: 'unreadable', detail: 'no metafile, size or budget to project from' }); + return { findings, lines: [] }; + } + const { locales, shared } = pageBytes(meta, i18n); + const english = locales.get(i18n.defaultLanguage); + const translated = [...locales].filter(([l, r]) => l !== i18n.defaultLanguage && r.pages > 0); + if (!english.pages) { + findings.push({ rule: 'no-pages', detail: 'the metafile names no `content_docs_*_mdx_*` chunk, so the page modules were not found' }); + return { findings, lines: [] }; + } + // A translated page is what the next batch adds; English stands in when no locale has one yet. + // The most expensive locale sets the cost, so the projection errs towards less room. + const cost = (r) => (r.bytes + r.search) / r.pages / KIB; + const [costliest, worst] = (translated.length ? translated : [[i18n.defaultLanguage, english]]).reduce((a, b) => (cost(b[1]) > cost(a[1]) ? b : a)); + const perPage = cost(worst) + OVERHEAD_KIB; + const headroom = budget - size; + const left = Math.max(0, Math.floor(headroom / perPage)); + const locale = english.pages * perPage; + const lines = [ + '#### Per-page cost', + '', + '| locale | page modules | module KiB | search KiB | KiB per page |', + '|:--|--:|--:|--:|--:|', + ...[...locales] + .filter(([, r]) => r.pages) + .map(([l, r]) => `| ${l} | ${r.pages} | ${(r.bytes / KIB).toFixed(2)} | ${(r.search / KIB).toFixed(2)} | ${cost(r).toFixed(2)} |`), + `| shared | — | ${(shared / KIB).toFixed(2)} | — | — |`, + '', + `A translated page costs about **${perPage.toFixed(2)} KiB**: ${costliest}'s ${cost(worst).toFixed(2)} KiB per page, the most of any locale, plus ${OVERHEAD_KIB} KiB of routing data.`, + `The budget holds **${left}** more such pages (${headroom.toFixed(2)} KiB of headroom).`, + `One more fully translated locale (${english.pages} pages) would add about ${locale.toFixed(0)} KiB: ${ + locale <= headroom ? `it fits, leaving ${(headroom - locale).toFixed(0)} KiB` : `it does NOT fit, by ${(locale - headroom).toFixed(0)} KiB` + }.`, + ]; + return { findings, lines }; +} + +function arg(name) { + const i = process.argv.indexOf(name); + return i < 0 ? undefined : process.argv[i + 1]; +} + +function gate() { + const i18n = readI18n(readFileSync(join(ROOT, 'apps/docs/lib/i18n.ts'), 'utf8')); + let meta; + try { + meta = JSON.parse(readFileSync(arg('--meta') ?? '', 'utf8')); + } catch { + meta = undefined; + } + const { findings, lines } = project({ meta, size: Number(arg('--size')), budget: Number(arg('--budget')), i18n }); + if (lines.length) console.log(lines.join('\n')); + for (const f of findings) console.error(` (${f.rule}) ${f.detail}`); + if (findings.length) { + console.error(`\n✗ Worker page cost: NOT MEASURED. The rules are in this script's header.`); + return 1; + } + return 0; +} + +/* Self-test: fake metafiles, each case checked for its findings and, where it projects, its numbers. */ +const I18N = { languages: ['en', 'ja', 'de'], defaultLanguage: 'en' }; +const chunk = (name, kib) => [`.next/server/chunks/ssr/${name}`, { bytesInOutput: kib * KIB }]; +const metafile = (...chunks) => ({ outputs: { 'handler.mjs': { inputs: Object.fromEntries([chunk('next_dist_x.js', 900), ...chunks]) } } }); +const CASES = [ + [ + 'ja pages set the per-page cost; en and shared are listed, not averaged', + { meta: metafile(chunk('content_docs_a_mdx_1.js', 50), chunk('content_docs_b_mdx_2.js', 70), chunk('content_docs_a_ja_mdx_3.js', 100), chunk('content_docs_x._.js', 30)), size: 1000, budget: 1450, i18n: I18N }, + [], + ['**110.00 KiB**', 'ja\'s 100.00', '**4** more', '2 pages) would add about 220 KiB: it fits, leaving 230 KiB', '| shared | — | 30.00 |'], + ], + [ + 'no translation yet: English sets the cost', + { meta: metafile(chunk('content_docs_a_mdx_1.js', 40), chunk('content_docs_b_mdx_2.js', 60)), size: 1000, budget: 1100, i18n: I18N }, + [], + ['**60.00 KiB**', '**1** more', 'about 120 KiB: it does NOT fit, by 20 KiB'], + ], + [ + 'over the budget: no page left, and a locale does not fit', + { meta: metafile(chunk('content_docs_a_mdx_1.js', 40), chunk('content_docs_a_de_mdx_9.js', 80)), size: 1200, budget: 1100, i18n: I18N }, + [], + ['**0** more', 'it does NOT fit, by 190 KiB'], + ], + [ + 'search text counts, and the costliest locale sets the cost', + { meta: metafile(chunk('content_docs_a_mdx_1.js', 40), chunk('content_docs_a_ja_mdx_3.js', 60), chunk('content_docs_b_ja_mdx_4.js', 60), chunk('apps_docs__route-data_search-ja_ts_0xv.js', 20), chunk('content_docs_a_de_mdx_9.js', 50)), size: 1000, budget: 1400, i18n: I18N }, + [], + ['| ja | 2 | 120.00 | 20.00 | 70.00 |', 'about **80.00 KiB**: ja\'s 70.00', '**5** more'], + ], + ['no metafile', { meta: undefined, size: 1, budget: 2, i18n: I18N }, ['unreadable'], []], + ['no size', { meta: metafile(chunk('content_docs_a_mdx_1.js', 40)), size: NaN, budget: 2, i18n: I18N }, ['unreadable'], []], + ['the chunks were renamed', { meta: metafile(chunk('pages_a.js', 40)), size: 1, budget: 2, i18n: I18N }, ['no-pages'], []], +]; + +function selfTest() { + let failed = 0; + const fired = new Set(); + for (const [name, input, wantRules, wantText] of CASES) { + const { findings, lines } = project(input); + const got = findings.map((f) => f.rule).sort(); + got.forEach((r) => fired.add(r)); + const text = lines.join('\n'); + const missing = wantText.filter((t) => !text.includes(t)); + const pass = JSON.stringify(got) === JSON.stringify([...wantRules].sort()) && !missing.length; + if (!pass) failed += 1; + console.log(`${pass ? '✓' : '✗'} ${name}: ${got.length ? got.join(', ') : 'no findings'}${missing.length ? ` — missing ${JSON.stringify(missing)}` : ''}`); + } + const silent = RULES.filter((r) => !fired.has(r)); + if (silent.length) { + failed += 1; + console.log(`✗ rules no fixture makes fire: ${silent.join(', ')}`); + } + let unreadable = false; + try { + readI18n('defineI18n({})'); + } catch { + unreadable = true; + } + if (!unreadable) failed += 1; + console.log(`${unreadable ? '✓' : '✗'} an unreadable i18n.ts is rejected`); + console.log(failed ? `\n✗ Worker page cost self-test: ${failed} failure(s)` : `\n✓ Worker page cost self-test: ${CASES.length} cases, all ${RULES.length} rules can fire`); + return failed ? 1 : 0; +} + +process.exitCode = process.argv.includes('--self-test') ? selfTest() : gate(); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ed37e43..6754206e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -409,6 +409,7 @@ jobs: exit 1 fi + VERDICT=0 awk -v size="$SIZE_KIB" -v budget="$WORKER_BUDGET_KIB" -v limit="$WORKER_LIMIT_KIB" ' BEGIN { over = (size > budget) @@ -426,7 +427,18 @@ jobs: printf "one of them. Reduce the bundle, or raise the budget deliberately and say why.\n" } exit (over ? 1 : 0) - }' | tee -a "$GITHUB_STEP_SUMMARY" + }' | tee -a "$GITHUB_STEP_SUMMARY" || VERDICT=$? + + # #395: what the next content batch has room for, printed whether or + # not this one fits. The KiB a translated page costs, read from the + # bundle just packaged, and how many more pages and locales that + # leaves under the budget. It goes red only when it cannot read the + # bundle; the verdict above is the budget gate. + echo | tee -a "$GITHUB_STEP_SUMMARY" + node "$GITHUB_WORKSPACE/.github/scripts/check-worker-page-cost.mjs" \ + --meta .open-next/server-functions/default/apps/docs/handler.mjs.meta.json \ + --size "$SIZE_KIB" --budget "$WORKER_BUDGET_KIB" | tee -a "$GITHUB_STEP_SUMMARY" + exit "$VERDICT" # `include-hidden-files` is load-bearing, not tidiness: the compiled # OpenNext config the deploy reads lives at `.open-next/.build/`, and diff --git a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx index b23fbc38..cf977c19 100644 --- a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx @@ -17,6 +17,7 @@ import type { InferPageType } from 'fumadocs-core/source'; import type { TOCItemType } from 'fumadocs-core/toc'; import { EditionBadges } from '@/components/editions'; import { EDITIONS_PAGE_SLUGS } from '@/lib/editions'; +import { loadPageBody } from '@/lib/page-body'; /** * The page type the loader actually produces, frontmatter schema included. @@ -267,7 +268,11 @@ export default async function Page(props: { // Under `dynamicParams = false` this await runs at BUILD time, in Node, on // every one of the 1139 prerendered paths — the Worker serves the prerendered // result out of the static-assets incremental cache and does not re-render. - const loaded = await page.data.load(); + // + // Loaded through `lib/page-body.ts`, not `page.data.load()`: `source` is + // built without the compiled bodies so the route handlers do not bundle a + // second copy of every page (#395). + const loaded = await loadPageBody(page); const MDX = loaded.body; // Resolved once and handed to both controls (through `copySource` and @@ -309,11 +314,11 @@ export default async function Page(props: { // translation it is this locale's text, which the `.mdx` surface does not // serve (English-only, see `markdownUrl`), so it is rendered into the page // instead: `getLLMText` builds it exactly as `/llms.mdx/docs/...` builds the - // English body, from the module `page.data.load()` above already loaded. + // English body, from the module `loadPageBody` above already loaded. const copySource: MarkdownSource = contentLang === i18n.defaultLanguage ? { url: pageMarkdownUrl } - : { text: await getLLMText(page) }; + : { text: await getLLMText(page, loaded._exports._markdown as string) }; // "Open in ChatGPT / Claude" follows the same rule (#305): the assistant is // sent to the page on screen. The English Markdown when that is the English diff --git a/apps/docs/app/api/search/route.ts b/apps/docs/app/api/search/route.ts index b41e3342..a762706a 100644 --- a/apps/docs/app/api/search/route.ts +++ b/apps/docs/app/api/search/route.ts @@ -1,6 +1,7 @@ import { source } from '@/lib/source'; import { i18n } from '@/lib/i18n'; -import { searchData } from '@/.search'; +import { unpack } from '@/lib/packed'; +import { searchData } from '@/.route-data/search'; import { createSearchAPI, type AdvancedIndex, @@ -123,9 +124,9 @@ type StructuredData = AdvancedIndex['structuredData']; /** * One locale's `structuredData` — each page's headings and paragraphs — keyed - * by file path under `content/docs`, from `.search/` (#393). + * by file path under `content/docs`, from `.route-data/` (#393). * - * `scripts/gen-search-data.mjs` writes it before every build, with the same + * `scripts/gen-route-data.mjs` writes it before every build, with the same * remark chain fumadocs-mdx compiles the pages with. Reading it from there * instead of from `page.data.load()` is the point: `load()` imports the page's * compiled module, the module registry keeps it for the life of the isolate, @@ -134,11 +135,7 @@ type StructuredData = AdvancedIndex['structuredData']; * dropped once the index has the text. */ async function texts(locale: Locale): Promise> { - // Gzipped JSON in base64, because the module is Worker upload too. - const packed = (await searchData[locale]()).default; - const bytes = Uint8Array.from(atob(packed), (c) => c.charCodeAt(0)); - const json = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); - return JSON.parse(await new Response(json).text()); + return unpack((await searchData[locale]()).default); } /** @@ -156,10 +153,10 @@ async function indexes(locale: Locale): Promise { const file = page.data.info.path; let structuredData = own[file] ?? fallback?.[file]; if (!structuredData) { - // `next dev` serves pages added since it wrote `.search/`; a build - // writes it first, so there a missing page is a stale `.search/`. + // `next dev` serves pages added since it wrote `.route-data/`; a build + // writes it first, so there a missing page is a stale `.route-data/`. if (process.env.NODE_ENV !== 'development') { - throw new Error(`/api/search: no search data for ${file}; run scripts/gen-search-data.mjs`); + throw new Error(`/api/search: no search data for ${file}; run scripts/gen-route-data.mjs`); } structuredData = (await page.data.load()).structuredData; } diff --git a/apps/docs/lib/packed.ts b/apps/docs/lib/packed.ts new file mode 100644 index 00000000..8d448fed --- /dev/null +++ b/apps/docs/lib/packed.ts @@ -0,0 +1,12 @@ +/** + * Reads a value `scripts/gen-route-data.mjs` packed: JSON, gzipped, in base64. + * + * The generated modules are Worker upload as well as text the route handlers + * read, so they are stored compressed (#393). `atob`, `Blob` and + * `DecompressionStream` are in both Node and the Workers runtime. + */ +export async function unpack(packed: string): Promise { + const bytes = Uint8Array.from(atob(packed), (c) => c.charCodeAt(0)); + const json = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); + return JSON.parse(await new Response(json).text()); +} diff --git a/apps/docs/lib/page-body.ts b/apps/docs/lib/page-body.ts new file mode 100644 index 00000000..301e9a9c --- /dev/null +++ b/apps/docs/lib/page-body.ts @@ -0,0 +1,22 @@ +import { docs } from 'fumadocs-mdx:collections/server'; +import type { InferPageType } from 'fumadocs-core/source'; +import type { source } from '@/lib/source'; + +/** + * Each page's entry in fumadocs-mdx's full collection, by file path under + * `content/docs`. + * + * This is the one module that imports the full collection, with its lazy + * import of every compiled page (#395). Import it from the page route only: + * anything a route handler imports puts every compiled page in the route + * handlers' module graph, a second copy of them in the Worker upload, which is + * what `lib/source.ts` is built to avoid. + */ +const entries = new Map(docs.docs.map((entry) => [entry.info.path, entry])); + +/** `page`'s compiled body, toc and exports, as `page.data.load()` returns them on the full collection. */ +export async function loadPageBody(page: InferPageType) { + const entry = entries.get(page.data.info.path); + if (!entry) throw new Error(`${page.data.info.path}: not in the fumadocs-mdx collection`); + return entry.load(); +} diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts index 5feca672..b478cef4 100644 --- a/apps/docs/lib/source.ts +++ b/apps/docs/lib/source.ts @@ -1,8 +1,22 @@ -import { docs } from 'fumadocs-mdx:collections/server'; +import { docs } from '@/.route-data/collection'; import { type InferPageType, loader } from 'fumadocs-core/source'; import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons'; import { i18n } from '@/lib/i18n'; +/** + * Every page's tree position, URL and frontmatter, WITHOUT its compiled body + * (#395). + * + * The collection is fumadocs-mdx's own, rewritten by + * `scripts/gen-route-data.mjs` so that a page's body is not a lazy import of + * its compiled module. Six route handlers import this file, and a lazy import + * is still bundled: with the full collection here the route handlers' module + * graph carried a second copy of every page, about 20 MB of the Worker upload. + * `page.data.load()` therefore does not return a renderable body on this + * source; the page route loads bodies through `lib/page-body.ts`, which is the + * one module that imports the full collection. `getText('processed')` works + * for English pages, which is what the `llms` routes read. + */ export const source = loader({ baseUrl: '/docs', i18n, @@ -79,8 +93,14 @@ function stripMdxComments(markdown: string): string { return markdown.replace(FENCE_OR_MDX_COMMENT, (match, fence?: string) => (fence ? match : '')); } -export async function getLLMText(page: InferPageType) { - const processed = stripMdxComments(await page.data.getText('processed')); +/** + * `page`'s Markdown as the `llms` surfaces serve it. `processed` is the page's + * processed Markdown when the caller already has it (the page route, from the + * module it rendered); otherwise it is read through `source`, which has it for + * English pages only. + */ +export async function getLLMText(page: InferPageType, processed?: string) { + processed = stripMdxComments(processed ?? (await page.data.getText('processed'))); return `# ${page.data.title} diff --git a/apps/docs/package.json b/apps/docs/package.json index 7d875e3c..1c23e75a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -5,12 +5,12 @@ "description": "ObjectOS Documentation Site", "license": "Apache-2.0", "scripts": { - "dev": "fumadocs-mdx && node scripts/gen-search-data.mjs && next dev", + "dev": "fumadocs-mdx && node scripts/gen-route-data.mjs --dev && next dev", "gen:zh-hant": "node scripts/gen-zh-hant.mjs", - "build": "fumadocs-mdx && node scripts/gen-search-data.mjs && next build", + "build": "fumadocs-mdx && node scripts/gen-route-data.mjs && next build", "start": "next start", "lint": "next lint", - "type-check": "next typegen && fumadocs-mdx && node scripts/gen-search-data.mjs && tsc --noEmit && node scripts/check-frontmatter.mjs", + "type-check": "next typegen && fumadocs-mdx && node scripts/gen-route-data.mjs && tsc --noEmit && node scripts/check-frontmatter.mjs", "postinstall": "fumadocs-mdx", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", diff --git a/apps/docs/scripts/gen-route-data.mjs b/apps/docs/scripts/gen-route-data.mjs new file mode 100644 index 00000000..8f69feaa --- /dev/null +++ b/apps/docs/scripts/gen-route-data.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node +/** + * Writes what the route handlers read instead of the compiled pages: + * `apps/docs/.route-data/` (#393, #395). + * + * ## Search text (#393) + * + * `/api/search` used to read each page's `structuredData` (its headings and + * paragraphs) through `page.data.load()`, which imports the page's compiled + * MDX module. That is the whole rendered page, highlighted code included, and + * a module once imported stays in the module registry for the life of the + * isolate. Building one locale's index therefore left every page module of + * that locale resident after the index was gone. Measured on the built route + * at `09784e7`, with only one index kept: 58 MB after English, 86 MB after all + * eight locales, about 94 MB with ja fully translated, against a Worker + * isolate's 128 MB. + * + * So the same `structuredData` is computed here and written as one module per + * locale, `search-.ts`, which `search.ts` maps to a lazy import. The + * route imports the locale it is asked for, unzips and parses it. + * + * ## The route handlers' collection (#395) + * + * Every page's compiled module was in the Worker twice. Turbopack compiles the + * page routes and the route handlers as two separate module graphs, and six + * route handlers (search, og, sitemap, `llms.txt`, `llms-full.txt`, + * `llms.mdx`) import `lib/source.ts`, whose collection holds a lazy + * `import()` of every page. A lazy import is still bundled, so the handlers' + * graph carried a second copy of all of them: 19,847 KiB of a 61,529 KiB + * upload with #388's PR 3 applied, which is what put that PR over the budget. + * Only the page route renders a page body, and only at build time. + * + * So `collection.ts` is the collection fumadocs-mdx writes to + * `.source/server.ts`, with every page's lazy body import replaced by a + * function that reads this directory instead. The frontmatter and `meta.json` + * imports are kept as they are, so the page tree, URLs and frontmatter are the + * ones `fumadocs-mdx` built. `lib/source.ts` builds its loader on it; the page + * route loads bodies from the full collection itself. + * + * What the handlers read from a body is the processed Markdown + * (`getText('processed')`, for the `llms` routes), and only for English pages: + * `/llms.mdx/docs/...` and `/llms-full.txt` serve English. That is + * `markdown.ts`. A translation's Markdown is read by the page route, from the + * module it already loaded. + * + * With `--dev`, `collection.ts` re-exports `.source/server.ts` instead: + * `next dev` regenerates that file when a page is added, and a copy taken at + * startup would not see the new page. + * + * ## How the text is made + * + * It is the same text, not a lookalike. The processor is fumadocs-mdx's own + * remark chain, built with the public `applyMdxPreset` from the compiled + * `source.config` (`.source/source.config.mjs`, written by the `fumadocs-mdx` + * CLI), plus `remarkInclude`, which `buildMDX` puts first, and `remarkLLMs`, + * which its postprocess step runs last for `includeProcessedMarkdown`. + * `remarkStructure` runs in the remark phase too, so the rehype plugins, which + * run after both and only shape the rendered HTML, are left out: that is + * `rehypeCode`, and it is most of the cost. + * + * Every string is JSON, gzipped, in base64 (`lib/packed.ts` unpacks it), + * because it is Worker upload too: as plain JSON the English, zh-Hans and + * zh-Hant search modules were about 1 MB each. + * + * A file's locale is its suffix, as `source.config.ts` reads it: `foo.ja.mdx` + * is `ja`, `foo.mdx` is the default language. A locale whose page falls back + * to English reads that page from the default language's module, so English + * text is written once. + * + * The output directory carries its own `.gitignore`, because it is a build + * product. Run from `apps/docs`, after `fumadocs-mdx`, as the `build`, + * `type-check` and `dev` scripts do. + */ +import { applyMdxPreset, remarkInclude } from 'fumadocs-mdx/config'; +import { frontmatter } from 'fumadocs-core/content/md/frontmatter'; +import { remarkLLMs } from 'fumadocs-core/mdx-plugins/remark-llms'; +import { createRequire } from 'node:module'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { gzipSync } from 'node:zlib'; + +const APP = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const CONTENT = resolve(APP, '../../content/docs'); +const CONFIG = join(APP, '.source/source.config.mjs'); +const COLLECTION = join(APP, '.source/server.ts'); +const OUT = join(APP, '.route-data'); +const DEV = process.argv.includes('--dev'); + +/** `languages` and `defaultLanguage` out of `lib/i18n.ts`, read as text. Throws rather than guessing. */ +function readI18n() { + const text = readFileSync(join(APP, 'lib/i18n.ts'), 'utf8'); + const languages = /languages:\s*\[([^\]]+)\]/.exec(text)?.[1]?.split(',').map((s) => s.trim().replace(/['"]/g, '')).filter(Boolean) ?? []; + const defaultLanguage = /defaultLanguage:\s*['"]([^'"]+)['"]/.exec(text)?.[1]; + if (!languages.length || !languages.includes(defaultLanguage)) { + throw new Error('could not read languages[] and defaultLanguage out of apps/docs/lib/i18n.ts'); + } + return { languages, defaultLanguage }; +} + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }) + .flatMap((e) => (e.isDirectory() ? walk(join(dir, e.name)) : e.name.endsWith('.mdx') ? [join(dir, e.name)] : [])) + .sort(); +} + +/** + * `.source/server.ts` with each page's `() => import("…?collection=docs")` + * replaced by `() => body("")`. Throws unless every page's import was + * replaced and no other `import(` is left, so a change in what fumadocs-mdx + * writes fails the build here instead of putting the bodies back. + */ +function lightCollection(source, pages) { + const BODY = /("[^"]+\.mdx"): \(\) => import\("[^"]+\?collection=docs"\)/g; + const replaced = source.match(BODY)?.length ?? 0; + const light = source.replace(BODY, (_, key) => `${key}: () => body(${key})`); + const left = light.match(/\bimport\(\s*["'][^"']*\?collection=/g)?.length ?? 0; + if (replaced !== pages || left) { + throw new Error( + `.source/server.ts: replaced ${replaced} page body import(s) of ${pages} pages and ${left} are left. ` + + 'fumadocs-mdx changed what it writes; update lightCollection() in scripts/gen-route-data.mjs.', + ); + } + return `${light} +import { unpack } from '../lib/packed'; + +/** + * What a route handler reads from a page body (scripts/gen-route-data.mjs): the + * processed Markdown of an English page. The page route renders bodies from the + * full collection, so nothing else is asked for here. + */ +let markdown: Promise> | undefined; +async function body(file: string) { + markdown ??= import('./markdown').then((m) => unpack>(m.default)); + const text = (await markdown)[file]; + if (text === undefined) { + throw new Error(\`\${file}: the route handlers' collection has no body for it; only English pages' processed Markdown is generated (scripts/gen-route-data.mjs)\`); + } + return { _markdown: text }; +} +`; +} + +if (!existsSync(CONFIG) || !existsSync(COLLECTION)) { + console.error(`✗ route data: ${relative(process.cwd(), CONFIG)} or ${relative(process.cwd(), COLLECTION)} does not exist. Run \`fumadocs-mdx\` first.`); + process.exit(1); +} + +const { languages, defaultLanguage } = readI18n(); +// @mdx-js/mdx is fumadocs-mdx's dependency, not this app's, so it is resolved from there: the copy buildMDX uses. +const { createProcessor } = await import( + pathToFileURL(createRequire(import.meta.resolve('fumadocs-mdx')).resolve('@mdx-js/mdx')).href +); +const config = (await import(pathToFileURL(CONFIG).href)).default; +const mdx = await applyMdxPreset({ ...config.mdxOptions, rehypeCodeOptions: false })('bundler'); +const processor = createProcessor({ + outputFormat: 'program', + ...mdx, + remarkPlugins: [remarkInclude, ...(mdx.remarkPlugins ?? []), [remarkLLMs, { _data: true }]], + rehypePlugins: [], + format: 'mdx', +}); + +const search = new Map(languages.map((l) => [l, {}])); +const markdown = {}; +const files = walk(CONTENT); +for (const path of files) { + const file = relative(CONTENT, path).split('\\').join('/'); + const locale = languages.find((l) => l !== defaultLanguage && file.endsWith(`.${l}.mdx`)) ?? defaultLanguage; + const vfile = await processor.process({ + path, + value: frontmatter(readFileSync(path, 'utf8')).content, + data: { frontmatter: {}, _getProcessor: () => processor }, + }); + if (!vfile.data.structuredData) throw new Error(`${file}: remarkStructure wrote no structuredData`); + if (typeof vfile.data.markdown !== 'string') throw new Error(`${file}: remarkLLMs wrote no Markdown`); + search.get(locale)[file] = vfile.data.structuredData; + if (locale === defaultLanguage) markdown[file] = vfile.data.markdown; +} + +const banner = '// Generated by scripts/gen-route-data.mjs before every build. Do not edit.\n'; +const pack = (value) => gzipSync(JSON.stringify(value), { level: 9 }).toString('base64'); +const write = (name, text) => writeFileSync(join(OUT, name), `${banner}${text}`); + +rmSync(OUT, { recursive: true, force: true }); +mkdirSync(OUT, { recursive: true }); +writeFileSync(join(OUT, '.gitignore'), '*\n'); +for (const [locale, pages] of search) { + // A string, not an object literal: the route parses it when it builds the + // index, and the parsed copy is garbage once the index holds the text. + write(`search-${locale}.ts`, `export default ${JSON.stringify(pack(pages))};\n`); +} +const loaders = languages.map((l) => ` ${JSON.stringify(l)}: () => import(${JSON.stringify(`./search-${l}`)}),`).join('\n'); +write('search.ts', `/** Each locale's \`structuredData\`, keyed by file path under content/docs, packed (lib/packed.ts). */\nexport const searchData = {\n${loaders}\n} as const;\n`); +write('markdown.ts', `/** Each English page's processed Markdown, keyed by file path under content/docs, packed (lib/packed.ts). */\nexport default ${JSON.stringify(pack(markdown))};\n`); +write( + 'collection.ts', + DEV + ? "/** `next dev`: the full collection, which fumadocs-mdx keeps current as pages are added. */\nexport { docs } from '../.source/server';\n" + : lightCollection(readFileSync(COLLECTION, 'utf8'), files.length), +); +const sizes = [...search].map(([l, pages]) => `${l} ${Object.keys(pages).length}`).join(', '); +console.log(`✓ route data: wrote .route-data/${DEV ? ' for next dev' : ''} (pages per locale: ${sizes}; English Markdown: ${Object.keys(markdown).length})`); diff --git a/apps/docs/scripts/gen-search-data.mjs b/apps/docs/scripts/gen-search-data.mjs deleted file mode 100644 index 59c20e38..00000000 --- a/apps/docs/scripts/gen-search-data.mjs +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env node -/** - * Writes the text `/api/search` indexes, one module per locale (#393). - * - * The route used to read each page's `structuredData` (its headings and - * paragraphs) through `page.data.load()`, which imports the page's compiled - * MDX module. That is the whole rendered page, highlighted code included, and - * a module once imported stays in the module registry for the life of the - * isolate. Building one locale's index therefore left every page module of - * that locale resident after the index was gone. Measured on the built route - * at `09784e7`, with only one index kept: 58 MB after English, 86 MB after all - * eight locales, about 94 MB with ja fully translated, against a Worker - * isolate's 128 MB. - * - * So the same `structuredData` is computed here, before `next build`, and - * written as one string per locale to `.search/.ts`: the JSON, - * gzipped, in base64. The route imports the locale it is asked for, unzips and - * parses it. What stays resident is that string, not the page modules. - * - * Gzipped because the string is also Worker upload, which has a budget the - * `Worker bundle fits the size budget` CI step holds: as plain JSON the - * English, zh-Hans and zh-Hant modules were about 1 MB each, 3.5 MB of the - * 6 MB headroom main had at `09784e7`; gzipped and in base64, about a third. - * - * It is the same `structuredData`, not a lookalike. The processor is - * fumadocs-mdx's own remark chain, built with the public `applyMdxPreset` from - * the compiled `source.config` (`.source/source.config.mjs`, written by the - * `fumadocs-mdx` CLI), plus `remarkInclude`, which `buildMDX` puts first. - * `remarkStructure` runs in the remark phase, so the rehype plugins, which run - * after it and only shape the rendered HTML, are left out: that is - * `rehypeCode`, and it is most of the cost. - * - * A file's locale is its suffix, as `source.config.ts` reads it: `foo.ja.mdx` - * is `ja`, `foo.mdx` is the default language. A locale whose page falls back - * to English reads that page from the default language's module, so English - * text is written once. - * - * The output directory carries its own `.gitignore`, because it is a build - * product. Run from `apps/docs`, after `fumadocs-mdx`, as the `build`, - * `type-check` and `dev` scripts do. - */ -import { applyMdxPreset, remarkInclude } from 'fumadocs-mdx/config'; -import { frontmatter } from 'fumadocs-core/content/md/frontmatter'; -import { createRequire } from 'node:module'; -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { gzipSync } from 'node:zlib'; - -const APP = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const CONTENT = resolve(APP, '../../content/docs'); -const CONFIG = join(APP, '.source/source.config.mjs'); -const OUT = join(APP, '.search'); - -/** `languages` and `defaultLanguage` out of `lib/i18n.ts`, read as text. Throws rather than guessing. */ -function readI18n() { - const text = readFileSync(join(APP, 'lib/i18n.ts'), 'utf8'); - const languages = /languages:\s*\[([^\]]+)\]/.exec(text)?.[1]?.split(',').map((s) => s.trim().replace(/['"]/g, '')).filter(Boolean) ?? []; - const defaultLanguage = /defaultLanguage:\s*['"]([^'"]+)['"]/.exec(text)?.[1]; - if (!languages.length || !languages.includes(defaultLanguage)) { - throw new Error('could not read languages[] and defaultLanguage out of apps/docs/lib/i18n.ts'); - } - return { languages, defaultLanguage }; -} - -function walk(dir) { - return readdirSync(dir, { withFileTypes: true }) - .flatMap((e) => (e.isDirectory() ? walk(join(dir, e.name)) : e.name.endsWith('.mdx') ? [join(dir, e.name)] : [])) - .sort(); -} - -if (!existsSync(CONFIG)) { - console.error(`✗ search data: ${relative(process.cwd(), CONFIG)} does not exist. Run \`fumadocs-mdx\` first.`); - process.exit(1); -} - -const { languages, defaultLanguage } = readI18n(); -// @mdx-js/mdx is fumadocs-mdx's dependency, not this app's, so it is resolved from there: the copy buildMDX uses. -const { createProcessor } = await import( - pathToFileURL(createRequire(import.meta.resolve('fumadocs-mdx')).resolve('@mdx-js/mdx')).href -); -const config = (await import(pathToFileURL(CONFIG).href)).default; -const mdx = await applyMdxPreset({ ...config.mdxOptions, rehypeCodeOptions: false })('bundler'); -const processor = createProcessor({ - outputFormat: 'program', - ...mdx, - remarkPlugins: [remarkInclude, ...(mdx.remarkPlugins ?? [])], - rehypePlugins: [], - format: 'mdx', -}); - -const byLocale = new Map(languages.map((l) => [l, {}])); -for (const path of walk(CONTENT)) { - const file = relative(CONTENT, path).split('\\').join('/'); - const locale = languages.find((l) => l !== defaultLanguage && file.endsWith(`.${l}.mdx`)) ?? defaultLanguage; - const vfile = await processor.process({ - path, - value: frontmatter(readFileSync(path, 'utf8')).content, - data: { frontmatter: {}, _getProcessor: () => processor }, - }); - if (!vfile.data.structuredData) throw new Error(`${file}: remarkStructure wrote no structuredData`); - byLocale.get(locale)[file] = vfile.data.structuredData; -} - -rmSync(OUT, { recursive: true, force: true }); -mkdirSync(OUT, { recursive: true }); -writeFileSync(join(OUT, '.gitignore'), '*\n'); -const banner = '// Generated by scripts/gen-search-data.mjs before every build. Do not edit.\n'; -for (const [locale, pages] of byLocale) { - // A string, not an object literal: the route parses it when it builds the - // index, and the parsed copy is garbage once the index holds the text. - const packed = gzipSync(JSON.stringify(pages), { level: 9 }).toString('base64'); - writeFileSync(join(OUT, `${locale}.ts`), `${banner}export default ${JSON.stringify(packed)};\n`); -} -const loaders = languages.map((l) => ` ${JSON.stringify(l)}: () => import(${JSON.stringify(`./${l}`)}),`).join('\n'); -writeFileSync( - join(OUT, 'index.ts'), - `${banner}/** Each locale's \`structuredData\`, keyed by file path under content/docs, as gzipped JSON in base64. */\nexport const searchData = {\n${loaders}\n} as const;\n`, -); -const sizes = [...byLocale].map(([l, pages]) => `${l} ${Object.keys(pages).length}`).join(', '); -console.log(`✓ search data: wrote .search/ (pages per locale: ${sizes})`); diff --git a/tools/ci-scripts/run-self-tests.mjs b/tools/ci-scripts/run-self-tests.mjs index fb1f15f6..ab995a5b 100644 --- a/tools/ci-scripts/run-self-tests.mjs +++ b/tools/ci-scripts/run-self-tests.mjs @@ -121,6 +121,13 @@ * Its fixtures are fake handlers and fake heap readings. They prove that a * failed request, a reading above the budget, and a heap still growing on the * second pass each go red. + * + * `check-worker-page-cost.mjs` is listed for its `--self-test` only (#395). Its + * gate mode reads the packaged Worker's metafile, so it runs inside `ci.yml`'s + * `Worker bundle fits the size budget` step. Its fixtures are fake metafiles: + * they prove the per-page cost and the pages left are computed from the + * costliest locale, and that a missing metafile or renamed page chunks fail + * rather than print nothing. */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; @@ -145,6 +152,7 @@ const SELF_TESTED = [ 'check-positioning.mjs', 'check-search-locales.mjs', 'check-search-heap.mjs', + 'check-worker-page-cost.mjs', 'check-doc-samples.mjs', 'bump-upstream-pin.mjs', ];