feat: add examples template and multi-connection database tasks (#159)

* feat: add examples template and multi-connection database tasks

* test(default): isolate SPA fallback fixture from build output
This commit is contained in:
chenos
2026-09-09 00:48:55 +08:00
committed by GitHub
parent 008969cc77
commit d29d1fe9c1
304 changed files with 19851 additions and 1946 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@nocobase/app-server": minor
"@nocobase/app-template-default": minor
"@nocobase/app-template-hub": minor
---
Run application migrations and seeds on explicitly selected database connections, with per-connection configuration, startup policies, isolated results and fail-fast execution. Keep plugin tasks on the default system connection and reject task execution against externally managed databases. Preserve legacy configuration and directory support while adopting database/<connectionName> source directories in both application templates; add --connection and --all CLI options and document upgrade rules.
+6
View File
@@ -0,0 +1,6 @@
---
"@nocobase/app-template-default": patch
"@nocobase/app-template-hub": patch
---
Resolve the development entry point's application root two levels above scripts/dev. Start workflow builds, plugin watchers, Vite and the application server from the application directory so pnpm dev no longer tries to read scripts/package.json or writes workflow artifacts under scripts/dist.
+6
View File
@@ -0,0 +1,6 @@
---
"@nocobase/app-template-default": patch
"@nocobase/app-template-hub": patch
---
Add a database-local TypeScript project in both templates so ESLint and editor tooling recognize per-connection migrations and seeds.
@@ -0,0 +1,8 @@
---
'@nocobase/app-template-default': minor
'@nocobase/app-plugin-notification-provider': minor
---
Make Default a clean application starting point: remove all nine example plugins, article/demo pages, sample services and APIs, article migrations and seeds, unused article UI primitives, and obsolete starter dependencies. Keep product capabilities and a localized homepage, with empty application database task directories. Document preserving existing application-owned history during source upgrades. Runnable demonstrations remain in Examples.
Add a notification provider `demo` option so Default can omit the notification demonstration page while retaining notification services and the global host. Existing registrations keep their current behavior.
@@ -0,0 +1,6 @@
---
'@nocobase/app-template-examples': patch
'@nocobase/create-app': minor
---
Add an independent Examples application template based on Default, with a localized examples homepage, article management, initial data, and registered capability examples. Add the `examples` template alias to create-app and include the template in release version synchronization.
+5
View File
@@ -0,0 +1,5 @@
---
"@nocobase/app-template-examples": patch
---
Register Workflow commands in the application CLI and build workflow artifacts through `pnpm nocobase workflow build`.
+7
View File
@@ -0,0 +1,7 @@
---
'@nocobase/app-template-default': minor
'@nocobase/app-template-examples': patch
'@nocobase/app-template-hub': minor
---
Remove the system information plugin package from the workspace and all application templates. Remove its client page, server API, plugin registrations, dependencies, synchronized Skills and integration test references. Document the source upgrade and use a new plugin name in the scaffolding tutorial.
@@ -0,0 +1,5 @@
---
"@nocobase/app-template-examples": patch
---
Align plugin discovery, development watches, and deployment packaging with the CLI composition roots and remove duplicate plugin manifest metadata.
@@ -0,0 +1,7 @@
---
'@nocobase/app-template-default': minor
'@nocobase/app-template-examples': patch
'@nocobase/app-template-hub': minor
---
Remove `@nocobase/app-plugin-file` from all application templates, including client/server registration, direct dependencies, test fixtures and installed-plugin guidance. The plugin's file inventory settings page and API are no longer included by default. Preserve stored files and independently registered file Repository capabilities.
+13 -13
View File
@@ -12,14 +12,14 @@ A new changeset goes in `.changeset/`, never in `.changeset/pre/`. That subdirec
Every published package lives under `packages/`, grouped into six directories by what the package is. The grouping is a convention for readers: pnpm resolves packages by name, so which directory a package sits in changes nothing about how it is depended on or filtered.
| Directory | What belongs here |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `packages/libs/` | Runtime libraries that solve one problem and know nothing about NocoBase applications, such as `caching`, `drive`, and `i18n` |
| `packages/app/` | The application runtime itself — what an application is built out of, such as `app-server`, `app-client`, and `app-portal-sdk` |
| `packages/plugins/` | Application plugins that ship as product features, such as `app-plugin-authentication` |
| `packages/examples/` | Application plugins that exist to demonstrate a capability, such as `app-plugin-routes-example` |
| `packages/templates/` | Complete applications that `create-app` scaffolds from: `app-template-default` and `app-template-hub` |
| `packages/tools/` | Development and build tooling that never ships inside an application, such as `dev-config`, `cli`, and `create-app` |
| Directory | What belongs here |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `packages/libs/` | Runtime libraries that solve one problem and know nothing about NocoBase applications, such as `caching`, `drive`, and `i18n` |
| `packages/app/` | The application runtime itself — what an application is built out of, such as `app-server`, `app-client`, and `app-portal-sdk` |
| `packages/plugins/` | Application plugins that ship as product features, such as `app-plugin-authentication` |
| `packages/examples/` | Application plugins that exist to demonstrate a capability, such as `app-plugin-routes-example` |
| `packages/templates/` | Complete applications that `create-app` scaffolds from: `app-template-default`, `app-template-examples`, and `app-template-hub` |
| `packages/tools/` | Development and build tooling that never ships inside an application, such as `dev-config`, `cli`, and `create-app` |
`packages/README.md` describes each directory in more detail and is the place to look when a new package does not obviously belong to one of them. `pnpm plugin:create` scaffolds into `packages/plugins/`.
@@ -89,7 +89,7 @@ import packageMetadata from '../package.json' with { type: 'json' };
expect(service.getInfo()).toMatchObject({ version: packageMetadata.version });
```
The release workflow runs `changeset version` and then runs the tests, so every published package is on a version different from the one committed by the time the suite executes. A literal that matches today fails during the next release, and it fails after the version bump — the point where the branch has already been rewritten and the run has to be repaired before anything can ship. `@nocobase/app-plugin-system-info` broke a release exactly this way, asserting `'0.0.1'` against a package `changeset version` had just moved to `0.1.0-beta.0`. `packages/templates/app-template-default` shows the shape to copy: its `declaredPluginVersion` helper resolves the version through `require` rather than repeating it.
The release workflow runs `changeset version` and then runs the tests, so every published package is on a version different from the one committed by the time the suite executes. A literal that matches today fails during the next release, and it fails after the version bump — the point where the branch has already been rewritten and the run has to be repaired before anything can ship. `@nocobase/app-plugin-system-info` broke a release exactly this way, asserting `'0.0.1'` against a package `changeset version` had just moved to `0.1.0-beta.0`. Use the manifest import shown above, or resolve the dependency’s `package.json` through `createRequire`, rather than repeating its version.
This applies to the version of any workspace package, whether the test owns it or depends on it. It does not apply to a version a test makes up for a fixture it writes itself — `version: '1.0.0'` in a synthetic `package.json` describes nothing real and never drifts.
@@ -108,15 +108,15 @@ exports resolve to compiled ESM JavaScript and declarations in `dist`. When
changing `packages/tools/dev-config`, run
`pnpm --filter @nocobase/dev-config check`; do not hand-edit generated output.
## Keeping the Two Application Templates in Sync
## Keeping the Application Templates in Sync
`packages/templates/app-template-default` and `packages/templates/app-template-hub` are two applications built on the same framework. A change to the framework layer of one belongs in the other by default: the runtime composition roots, the client shell, routing, layouts and theme, the server entry points, the `cli/` command entry, build and dev scripts, tsconfigs, and the agent-facing documentation — `AGENTS.md`, `CLAUDE.md`, `README.MD`, the nested `client/AGENTS.md` and `server/AGENTS.md`, and `skills/`.
`packages/templates/app-template-default`, `packages/templates/app-template-examples`, and `packages/templates/app-template-hub` are three applications built on the same framework. A change to the framework layer of one belongs in all applicable templates by default: the runtime composition roots, the client shell, routing, layouts and theme, the server entry points, the `cli/` command entry, build and dev scripts, tsconfigs, and the agent-facing documentation — `AGENTS.md`, `CLAUDE.md`, `README.MD`, the nested `client/AGENTS.md` and `server/AGENTS.md`, and `skills/`.
They drift otherwise, and the drift is invisible until someone hits it. Both templates carried a `tsconfig.migrations.base.json` that nothing referenced, and both omitted `database/**/*.ts` from `tsconfig.server.json`, so an application-owned migration ran under `pnpm migrate` but was silently dropped by `pnpm build` — the same defect, twice, because a fix to one was never carried across.
Not everything transfers. Each template keeps its own identity and the parts that follow from what it is: `package.json` name, `displayName`, and version; `nocobase.templateKind` and its plugin list; the pages, locales, and branding that make it that product. When a documentation change mentions the other template by name, reword it rather than copying the sentence — the Hub's own `server/embedded.ts` is not "the entry point when a Hub hosts the application".
Not everything transfers. Examples owns its demonstration homepage, article module, and example plugin composition. Each template keeps its own identity and the parts that follow from what it is: `package.json` name, `displayName`, and version; `nocobase.templateKind` and its plugin list; the pages, locales, and branding that make it that product. When a documentation change mentions the other template by name, reword it rather than copying the sentence — the Hub's own `server/embedded.ts` is not "the entry point when a Hub hosts the application".
Apply both sides in one change and run each template's `check`. A framework change that lands in only one template is incomplete, and a reviewer cannot tell whether the omission was a decision or an oversight; if it genuinely does not apply, say so in the pull request.
Apply all applicable sides in one change and run each affected template's `check`. A framework change that lands in only one template is incomplete, and a reviewer cannot tell whether the omission was a decision or an oversight; if it genuinely does not apply, say so in the pull request.
## Application Themes and UI Styling
@@ -0,0 +1,359 @@
---
title: NocoBase 3 应用数据库多连接方案(草案 V1)
description: 按连接组织数据库源码,并补齐迁移、种子、插件贡献和 CLI 的多连接编排
---
# NocoBase 3 应用数据库多连接方案
## 文档状态
- 版本:草案 V1。
- 日期:2026-09-08。
- 状态:V1 已在工作区实施并完成验证,尚未提交或发布。
- 起点:评估将 `packages/templates/app-template-default/database/` 调整为 `database/main/`、`database/external/` 的方案。
- 适用范围:应用数据库运行时,以及 Default、Hub 两套应用模板。
本文保留最初的 V1 分析作为设计背景。下方“已确认的实施规则”记录讨论后采用的决策;后续各节中的“当前实现”和“待决策”描述均指改造前的分析快照。实际使用以两套模板的 migrations Skill 参考和配置示例为准。
## 已确认的实施规则
- 使用 `database/<connectionName>/{migrations,seeds}`,连接名与配置 key 对应,不扫描目录注册连接。
- 主库为 `database.default`;新模板默认 `main`。插件迁移、种子及运行时默认读写始终使用主库。
- 任务配置放在 `connections.<name>.migrations/seeds`。主库默认自动执行,其他连接默认不自动执行;手动命令不受 `autoRun` 限制。
- 主库优先,其余按名称稳定排序,同一连接先迁移再种子,失败即停止。结果保留完成、跳过、失败和未执行状态,失败退出码非零,不承诺跨库回滚。
- 外部模式在自动执行和 `--all` 中跳过;显式指定外部模式执行迁移或种子时报错。
- 旧顶层配置继续作用于默认连接;旧字段及原有任务环境变量覆盖同名连接内字段。不同的显式目录、重复 sources 或同时设置目录与 sources 报错。
- 默认连接支持旧目录回退;新旧约定目录同时存在时报错,允许显式选择一个来源。不会自动搬迁源码或重写历史。
- 显式来源缺失时报错;约定目录缺失允许无任务应用跳过,发布时仍须验证源码是否完整打包。
- 每个物理库及 schema 只配置一个 managed 所有者。相同配置目标的别名连接在执行前被拒绝;网络别名等无法仅靠配置可靠识别的情况需由应用维护者避免。
- 同步 Default、Hub 的目录、CLI、配置示例与开发文档,复用底层迁移、种子引擎。
## 本轮验证结果
- `@nocobase/app-server`:lint、format、typecheck、205 项测试及 build 通过。
- Default / Hub:两套模板完整 check 通过,分别为 155 / 159 项测试,最终部署包构建通过。
- 两套部署包:CLI 错误输出可解析为 JSON,退出码为 1;真实临时 SQLite 双连接迁移分别执行,第二次运行均无重复执行。
- 已覆盖连接隔离、插件只归属主库、种子历史、旧目录搬迁、自动运行策略、外部模式限制、失败中止、配置冲突、相同目标别名、SQLite 路径及编译后目录。
- changeset 校验与差异空白检查通过。本轮没有连接真实 PostgreSQL、MySQL、Oracle 或 SQL Server;这些方言沿用底层引擎。
## 1. 结论
建议采用以下目录约定:
```text
database/<connectionName>/migrations/
database/<connectionName>/seeds/
```
一级目录名对应 `database.connections` 中的连接名。目录表示“这个连接的数据库变更源码”,不是数据库类型或外部系统的分类目录。
底层 `@nocobase/db` 已具备多连接及指定连接执行迁移、种子的能力。主要工作应放在 `@nocobase/app-server` 的配置解析、任务编排、插件贡献归属和 CLI,而不是重写数据库管理器或迁移引擎。
仅调整目录不足以实现多连接支持。必须同时确保每个目录的任务显式绑定正确连接,并明确外部数据库的管理边界。
## 2. 目标与非目标
### 目标
- 支持一个应用配置并使用多个命名连接。
- 按连接隔离应用迁移、种子、执行策略及结果。
- 保留单连接应用的简单使用方式。
- 防止插件系统表被误建到业务库或第三方数据库。
- 为已有应用提供明确的旧目录和旧配置兼容路径。
- 保持 Default、Hub 两套模板的框架行为一致。
### 非目标
- 不新增跨数据库统一事务、整体回滚或分布式事务。
- 不在第一阶段实现跨连接迁移依赖图。
- 不将连接凭据分散到数据库源码目录。
- 不通过扫描目录自动注册或连接未知数据库。
- 不在第一阶段扩展完整的插件多连接部署协议。
- 不把已有外部数据库的结构导入、元数据编辑或数据同步纳入本次目录改造。
## 3. 当前实现
### 3.1 底层已支持多连接
`@nocobase/db` 的 `DatabaseManager` 支持通过连接名选择连接,查询、Repository 和事务接口也提供连接选择能力。
Migrator 和 Seeder 的创建选项均支持 `connection`,可以让各自任务运行在指定连接上。Migration Context 中的 `builder`、`query` 和 `connection` 来自所选连接。
因此不需要让每个迁移文件自行选择连接。连接绑定应由应用编排层完成,迁移文件继续专注于该连接内的确定性变更。
### 3.2 应用层仍按一套任务执行
| 环节 | 当前行为 | 多连接缺口 |
| --- | --- | --- |
| 配置类型 | `connections` 支持多个连接,但顶层 `migrations` 和 `seeds` 各只有一套 | 缺少每个连接独立的任务配置 |
| 默认目录 | `database/migrations`、`database/seeds` | 尚未按连接解析路径 |
| 启动执行 | Provider 创建一套 Migrator / Seeder,未传入连接名 | 实际落到默认连接 |
| CLI | `migrate`、`seed` 没有连接选择参数 | 无法通过应用命令指定目标连接 |
| 插件贡献 | 应用和插件迁移源合并为同一组 sources | 没有按连接分配来源 |
| 连接参数处理 | 方言默认参数和 SQLite 的部分路径处理主要针对 `main` | 需检查并推广到其他连接 |
| SQLite 存储准备 | 主要为当前默认连接准备目录 | 指定其他连接执行时需要独立准备 |
当前迁移加载器只读取指定目录中的文件,不会把连接子目录自动解释为独立任务。直接移动目录而不修改默认路径,会导致应用迁移不再被原来的来源加载。
### 3.3 模板目录现状
分析时,Default 模板的 `database/migrations/` 和 `database/seeds/` 都只有 `.gitkeep`。模板自身切换目录的成本较低,但已经生成并运行过的应用可能存在历史文件和执行记录,不能按空目录处理。
## 4. 目录设计
建议使用:
```text
database/
main/
migrations/
seeds/
analytics/
migrations/
seeds/
external/
migrations/
seeds/
```
该示例展示目录命名方式,不表示每个连接都必须创建这两个子目录,也不表示名为 `external` 的连接必须执行迁移或种子。
规则如下:
1. `database/main/` 对应 `database.connections.main`,其他连接同理。
2. `external` 只是一个合法连接名,不是所有外部连接的总目录。连接多个外部系统时,优先使用 `erp`、`crm`、`warehouse` 等业务名称。
3. 目录名不决定方言、连接地址、权限或 `schemaManagement`,这些仍由配置决定。
4. 没有应用维护的数据库源码时,不必创建空目录。
5. 以配置中的连接为执行范围,不因磁盘出现新目录就自动注册连接或执行其中的文件。
6. 目录提供默认约定,同时允许显式覆盖迁移、种子的来源路径。
## 5. 区分连接身份与结构管理模式
“不是主库”和“不允许应用管理结构”是两个不同概念,不能都用 `external` 表达。
### 5.1 应用管理的第二个数据库
例如应用自行维护的分析库:
- 连接名可以是 `analytics`。
- 使用 `schemaManagement: managed`。
- 可以维护自己的迁移和种子。
- 独立绑定目标连接,使用该连接上的迁移历史和锁。
这是按连接分目录最直接的适用场景。
### 5.2 接入已有外部数据库
例如由第三方系统维护的 ERP 数据库:
- 连接名可以是 `erp`。
- 使用 `schemaManagement: external`。
- 不因为存在源码目录就自动执行迁移或种子。
- `external` 表示结构管理边界,不等价于数据库账号只读。
当前底层 Migrator 会拒绝对 `external` 模式执行迁移,DatabaseManager 在创建该类连接时还要求显式提供 `metadataStore`。外部连接接入方案必须同时处理元数据存储,不能只增加连接配置和目录。
当前 Seeder 会在目标库创建自己的历史表和使用锁机制,因此“种子只插入业务数据”也不代表执行过程不会涉及结构写入。第一版建议应用层不自动向外部模式连接运行种子;专门支持外部库数据初始化应另行设计。
## 6. 配置方案
### 6.1 建议形态
倾向于将连接专属的迁移和种子配置放在 `connections.<name>` 下,避免另外维护一份需要与连接名保持同步的映射。
以下是拟议配置,不是当前已支持的格式。连接地址和凭据省略;外部连接所需的 `metadataStore` 由运行时另外提供,此示例不构成完整可运行配置。
```yaml
database:
default: main
connections:
main:
dialect: sqlite
database: database.sqlite
schemaManagement: managed
migrations:
autoRun: true
seeds:
autoRun: true
analytics:
dialect: postgres
database: analytics
schemaManagement: managed
migrations:
autoRun: false
seeds:
autoRun: false
erp:
dialect: postgres
database: erp
schemaManagement: external
```
### 6.2 配置职责
- `default` 保持默认连接选择的职责。
- 连接配置描述连接参数和结构管理模式。
- `migrations`、`seeds` 描述该连接的应用任务策略,而不是数据库驱动参数。
- 未覆盖的目录按 `database/<name>/migrations`、`database/<name>/seeds` 解析。
- `autoRun` 控制启动时自动执行,不应直接被当作手动 CLI 执行的总开关。
实现时应在应用层扩展连接配置类型,并将任务配置与传给底层 DatabaseManager 的连接参数分离。无需让通用 `@nocobase/db` 理解应用目录约定。
第一阶段建议 `main` 保留现有模板的自动执行体验,新增连接显式开启自动执行。默认值如何与自定义默认连接、旧配置合并,需要在实现前确定,不能依赖连接声明顺序。
## 7. 运行时编排
### 7.1 单连接执行单元
每个执行单元至少包含:连接名、任务类型、sources、历史表及锁表配置、执行策略。
执行流程建议为:
1. 从配置解析目标连接与任务配置。
2. 校验连接存在、管理模式和路径配置。
3. 准备目标连接所需的本地存储目录。
4. 创建 Migrator / Seeder,显式传入 `connection: name`。
5. 执行任务,并在结果中保留连接身份。
不得只切换 sources 而继续省略连接名,否则其他目录中的迁移仍可能运行在默认库。
### 7.2 多连接执行
- 启动时只执行显式符合自动运行策略的任务。
- 第一阶段优先串行执行,顺序必须稳定、可解释,不依赖文件系统遍历顺序。
- 同一连接内先迁移、后种子。
- 不承诺跨连接事务;一个连接失败时,先前连接已成功提交的变更不会自动撤销。
- 返回或记录各连接的完成、跳过和失败状态,避免只输出一个无法定位目标库的总结果。
对于 `--all` 及启动流程,主库优先规则、失败后是否立即停止和部分成功结果格式列为待决策项。第一版不引入跨连接依赖声明。
历史和锁应随目标数据库隔离。如果两个逻辑连接实际指向同一物理库及 schema,仅连接名不同并不能自动隔离历史表与锁表;实现前应明确是限制此类配置,还是要求显式区分表名。
## 8. 插件迁移归属
当前应用与插件迁移源会合并为一组。多连接后不能将这组 sources 复制到每个连接,否则认证、权限等插件表可能出现在分析库或第三方数据库中。
第一阶段建议:
- 应用源码按连接目录归属。
- 插件迁移和种子继续绑定应用主库,不自动分发到新增连接。
- 不改变插件内部已有目录结构。
- 只有出现明确需求时,再扩展插件声明和应用侧绑定协议。
“插件主库”与 `database.default` 的关系必须明确。现有行为是运行在默认连接,不能在兼容已有应用时无条件改为固定 `main`;但也不应在新设计中因为修改查询默认连接,就无意改变插件系统表的部署位置。该绑定规则是实现前需要确认的重点。
## 9. CLI 方案
拟新增以下用法,当前命令尚不支持这些参数:
```bash
pnpm migrate --connection main
pnpm migrate --connection analytics
pnpm seed --connection analytics
pnpm migrate --all
pnpm seed --all
```
建议语义:
- 不传参数:仍只执行默认连接,避免引入多连接后扩大原命令的影响范围。
- `--connection <name>`:显式选择一个连接,不受该连接 `autoRun: false` 限制。
- `--all`:显式请求对符合执行条件的连接执行任务;不能简单解释为忽略管理模式遍历全部连接。
- `--connection` 与 `--all` 互斥。
- 未知连接或显式请求不支持的外部库操作时,明确报错。
- 多连接输出及 JSON 结果带连接名;存在失败时不能仅报告整体成功。
`--all` 的外部连接跳过说明、失败中止规则,以及现有单连接 JSON 格式的兼容策略,应在实现阶段统一设计。
## 10. 旧目录与历史兼容
### 新应用
新模板直接采用 `database/main/migrations/` 和 `database/main/seeds/`,其他连接按需增加目录。
### 已有应用
- 为旧顶层 `database.migrations`、`database.seeds` 和旧目录提供明确兼容路径。
- 旧任务应映射到原本执行它们的连接,而不是无条件绑定 `main`。
- 新旧配置或目录同时存在时,明确优先级或报错,不静默合并执行。
- 普通启动、迁移命令不应自动搬迁用户源码。
- 不把“移动到其他连接目录”当作历史迁移在另一个数据库上重新执行的授权。
迁移历史文件搬迁应保持文件名、导出的 `name`、内容、包标识以及原目标数据库不变。迁移加载器基于文件内容计算 checksum,迁移执行还会校验历史中已执行文件是否存在。
目录切换必须通过测试确认历史校验正常、已执行迁移不会重跑。不要通过改写历史文件或伪造旧 checksum 绕过校验;种子历史也需要覆盖兼容验证。
## 11. 实施范围与步骤
### 第一步:补齐应用层能力
- 扩展应用数据库配置类型与 Schema。
- 增加按连接解析任务及 sources 的公共逻辑,供 Provider 和 CLI 共用。
- 显式绑定目标连接,补齐外部模式校验及分连接结果。
- 推广连接参数规范化与 SQLite 目录准备,避免继续只处理 `main` 或默认连接。
- 明确插件贡献只属于主库,不向所有连接复制。
### 第二步:切换目录并完善兼容
- 同步调整 Default、Hub 两套模板目录和配置示例。
- 补齐 CLI 参数、帮助和 JSON 兼容策略。
- 更新两套模板的开发文档、AGENTS、Skill 和测试中的旧路径说明。
- 验证开发态、编译产物和发布后运行时的目录解析一致。
- 补齐旧项目升级说明及历史不重复执行的测试。
底层数据库引擎原则上复用;只有测试暴露出底层缺口时,再针对具体问题修改 `@nocobase/db`。不为应用目录约定增加不必要的通用引擎复杂度。
## 12. 验收要点
| 场景 | 预期 |
| --- | --- |
| 单连接新模板 | 主库迁移、种子和插件初始化正常 |
| 两个 managed 连接 | 各目录只修改其目标库,历史分别记录 |
| 非默认连接手动执行 | `--connection` 正确路由,不误写默认库 |
| 新增连接关闭 autoRun | 启动不执行,但允许显式手动执行 |
| external 模式 | 不自动运行迁移和种子,显式不支持操作有清晰错误 |
| 插件贡献 | 系统表仅部署到约定主库 |
| 旧项目升级 | 旧任务保持原连接归属,已执行历史不重跑 |
| 新旧配置或目录并存 | 按明确规则处理,不静默重复执行 |
| 多连接部分失败 | 能定位失败连接,不声称跨连接整体回滚 |
| SQLite 非默认连接 | 文件路径和父目录准备正确 |
| 构建后运行 | 编译产物仍可按连接找到迁移和种子 |
| Default 与 Hub | 同样的框架行为和验证覆盖 |
实际实施时应对修改包及其相关消费者运行范围内的 lint、typecheck、test、build,并运行两套模板的 check。最初仅新增内部草案时不涉及发布产物;本轮实施涉及 app-server 与两套模板,需包含 changeset 并完成上述验证。
## 13. 待决策项
1. 是否最终采用 `connections.<name>.migrations/seeds`,旧顶层配置保留多久?
2. 插件主库是否继续等同于默认连接,还是增加稳定的显式绑定?
3. 新连接的默认执行策略如何定义,是否需要独立于 `autoRun` 的任务启用开关?
4. 多连接的顺序、部分失败中止规则和结构化结果如何确定?
5. 新旧目录同时存在时采用严格报错还是兼容优先级?
6. 指向同一物理库及 schema 的多个连接如何处理历史表和锁冲突?
7. 外部库数据初始化是否有真实需求;若有,是否另设不写目标库历史表的机制?
第一版优先确认目录约定、连接绑定和主库语义,再落实配置及 CLI 细节,避免把目录调整做成缺少边界定义的隐式多库执行。
## 14. 当前实现参考
以下路径相对仓库根目录,用于核对本次分析依据:
- `packages/libs/db/src/database/manager.ts`:连接选择、查询接口和外部连接的 metadataStore 要求。
- `packages/libs/db/src/migration/types.ts`:迁移来源、Context 及连接选项。
- `packages/libs/db/src/migration/migrator.ts`:目标连接、管理模式校验和执行历史校验。
- `packages/libs/db/src/migration/loader.ts`:目录文件加载和 checksum。
- `packages/libs/db/src/seed/seeder.ts`:种子目标连接和执行流程。
- `packages/libs/db/src/seed/internal/history.ts`:目标库中的种子历史表。
- `packages/app/app-server/src/database/types.ts`:当前应用数据库配置类型。
- `packages/app/app-server/src/database/config.ts`:默认路径、Schema 和环境变量映射。
- `packages/app/app-server/src/database/provider.ts`:启动时迁移和种子执行。
- `packages/app/app-server/src/database/tasks.ts`:CLI 使用的应用任务入口。
- `packages/app/app-server/src/database/manager.ts`:连接参数处理。
- `packages/app/app-server/src/database/storage.ts`:SQLite 存储目录准备。
- `packages/app/app-server/src/plugins/resolve.ts`:应用和插件数据库来源合并。
- `packages/templates/app-template-default/cli/commands/migrate.ts`:现有迁移命令。
- `packages/templates/app-template-default/cli/commands/seed.ts`:现有种子命令。
- `packages/templates/app-template-default/tsconfig.server.json`:数据库源码的编译范围。
- `packages/templates/app-template-default/skills/nocobase-app-development/references/migrations.md`:当前应用迁移规范。
@@ -37,10 +37,10 @@ description: 在 NocoBase source workspace 中按显式 capability 创建 App
## 先预览生成计划
以同时提供页面、Server API、业务 Service 和 App Agent 使用说明的 `system-info` 插件为例:
以同时提供页面、Server API、业务 Service 和 App Agent 使用说明的 `app-status` 插件为例:
```bash
pnpm plugin:create system-info \
pnpm plugin:create app-status \
--with client.routes \
--with client.components \
--with server.service-providers \
@@ -75,7 +75,7 @@ pnpm plugin:create plugin-core --empty --dry-run --json
确认计划后,以相同 capability 去掉 dry-run:
```bash
pnpm plugin:create system-info \
pnpm plugin:create app-status \
--with client.routes \
--with client.components \
--with server.service-providers \
@@ -124,22 +124,22 @@ implementation
先运行插件自己的检查:
```bash
pnpm --filter @nocobase/app-plugin-system-info lint
pnpm --filter @nocobase/app-plugin-system-info typecheck
pnpm --filter @nocobase/app-plugin-system-info test
pnpm --filter @nocobase/app-plugin-system-info build
pnpm --filter @nocobase/app-plugin-app-status lint
pnpm --filter @nocobase/app-plugin-app-status typecheck
pnpm --filter @nocobase/app-plugin-app-status test
pnpm --filter @nocobase/app-plugin-app-status build
```
然后注册到目标 App:
```bash
pnpm plugin:register system-info --app app-template-default
pnpm plugin:register app-status --app app-template-default
```
如果需要机器可读的注册快照,或注册结果与预期不一致,可用只读命令查看 dependency、metadata、Client/Server composition root 和 Skills 副本状态:
```bash
pnpm plugin:inspect system-info --app app-template-default --json
pnpm plugin:inspect app-status --app app-template-default --json
```
读取 `result.consistent` 和 `result.issues`,不要只根据 `ok` 判断命令结果。该检查不运行插件代码,也不验证插件接线之外的实现、Route 权限、测试或构建;它不是快速开始的完成门槛。
+1 -1
View File
@@ -39,7 +39,7 @@ They are published and installable like any other plugin — the difference is i
Complete, runnable applications. `create-app` downloads one and scaffolds a project from it, so a template is published as its own source rather than as a built library.
`app-template-default` is what `pnpm create @nocobase/app` produces. `app-template-hub` is the application hub.
`app-template-default` is what `pnpm create @nocobase/app` produces. `app-template-hub` is the application hub. `app-template-examples` combines application-owned article management with installable capability examples and demonstration data.
## `tools/`
+1 -1
View File
@@ -140,7 +140,7 @@ const api = app.services.resolve(apiClientToken);
const realtime = app.services.resolve(realtimeClientToken);
type Order = { readonly id: string };
await api.request({ path: 'system-info' });
await api.request({ path: 'healthz' });
await api.repository<Order>('orders').findOne({
filter: { id: 'order-1' },
});
@@ -138,7 +138,8 @@ export class AppConfig<TContext = unknown> {
const next = await this.loadConfig();
this.validate(next);
this.current = next;
console.info('App configuration loaded', {
// Configuration diagnostics must not corrupt machine-readable CLI stdout.
console.error('App configuration loaded', {
durationMs: Date.now() - startedAt,
});
}
@@ -185,7 +186,7 @@ export class AppConfig<TContext = unknown> {
const startedAt = Date.now();
this.reloadPromise ??= this.performReload()
.then((result) => {
console.info('App configuration reloaded', {
console.error('App configuration reloaded', {
changedNamespaces: result.changedNamespaces,
durationMs: Date.now() - startedAt,
});
+29 -32
View File
@@ -12,6 +12,27 @@ import { createAppPluginDatabaseConfig } from '../plugins/index.js';
import type { ResolvedAppRuntimeConfigContext } from '../runtime/index.js';
import type { AppDatabaseConfig } from './types.js';
const taskSchema = Type.Object(
{
directory: Type.Optional(Type.String({ minLength: 1 })),
packageName: Type.Optional(Type.String({ minLength: 1 })),
autoRun: Type.Optional(Type.Boolean()),
tableName: Type.Optional(Type.String({ minLength: 1 })),
lockTableName: Type.Optional(Type.String({ minLength: 1 })),
extensions: Type.Optional(Type.Array(Type.String())),
sources: Type.Optional(
Type.Array(
Type.Object({
packageName: Type.String({ minLength: 1 }),
directory: Type.String({ minLength: 1 }),
extensions: Type.Optional(Type.Array(Type.String())),
}),
),
),
},
{ additionalProperties: false },
);
export const databaseConfig: AppConfigDefinition<
AppDatabaseConfig,
ResolvedAppRuntimeConfigContext
@@ -24,6 +45,8 @@ export const databaseConfig: AppConfigDefinition<
Type.String(),
Type.Object(
{
migrations: Type.Optional(taskSchema),
seeds: Type.Optional(taskSchema),
dialect: Type.Union([
Type.Literal('sqlite'),
Type.Literal('postgres'),
@@ -64,30 +87,8 @@ export const databaseConfig: AppConfigDefinition<
),
),
),
migrations: Type.Object(
{
directory: Type.String(),
packageName: Type.Optional(Type.String()),
autoRun: Type.Boolean(),
tableName: Type.Optional(Type.String()),
lockTableName: Type.Optional(Type.String()),
extensions: Type.Optional(Type.Array(Type.String())),
},
{ additionalProperties: true },
),
seeds: Type.Optional(
Type.Object(
{
directory: Type.String(),
packageName: Type.Optional(Type.String()),
autoRun: Type.Boolean(),
tableName: Type.Optional(Type.String()),
lockTableName: Type.Optional(Type.String()),
extensions: Type.Optional(Type.Array(Type.String())),
},
{ additionalProperties: true },
),
),
migrations: Type.Optional(taskSchema),
seeds: Type.Optional(taskSchema),
}),
defaults: ({ paths, plugins, appPackageName }) => {
const database: AppDatabaseConfig = {
@@ -100,15 +101,11 @@ export const databaseConfig: AppConfigDefinition<
debug: false,
},
},
migrations: {
directory: paths.database('migrations'),
taskSources: {
directory: paths.database(),
packageName: appPackageName,
autoRun: true,
},
seeds: {
directory: paths.database('seeds'),
packageName: appPackageName,
autoRun: true,
migrations: [],
seeds: [],
},
};
return createAppPluginDatabaseConfig(database, plugins).database;
+15 -1
View File
@@ -18,9 +18,23 @@ export {
type CreateAppSeederOptions,
} from './seeder.js';
export { prepareAppDatabaseStorage } from './storage.js';
export { runAppMigrations, runAppSeeds } from './tasks.js';
export {
runAppMigrations,
runAppSeeds,
runAppDatabaseTasks,
AppDatabaseTaskError,
type AppDatabaseTaskResult,
type AppDatabaseTasksResult,
} from './tasks.js';
export {
planAppDatabaseTasks,
type AppDatabaseTask,
type AppDatabaseTaskKind,
type AppDatabaseTaskSelection,
} from './plan.js';
export type {
AppDatabaseConfig,
AppDatabaseConnectionConfig,
AppDatabaseMigrationConfig,
AppDatabaseSeedConfig,
} from './types.js';
+49 -48
View File
@@ -25,30 +25,34 @@ export function createAppDatabaseManager(
);
}
function resolveConnections(
export function resolveConnections(
connections: AppDatabaseConfig['connections'],
paths: ConfigPaths | undefined,
): AppDatabaseConfig['connections'] {
const main = connections.main;
if (!main) return connections;
if (main.dialect === 'mysql') {
return {
...connections,
main: {
host: '127.0.0.1',
port: 3306,
): Record<string, ConnectionConfig> {
return Object.fromEntries(
Object.entries(connections).map(([name, config]) => {
const { migrations: _migrations, seeds: _seeds, ...connection } = config;
return [name, normalizeConnection(connection, paths)];
}),
);
}
function normalizeConnection(
connection: ConnectionConfig,
paths: ConfigPaths | undefined,
): ConnectionConfig {
switch (connection.dialect) {
case 'mysql':
return {
...(connection.socketPath ? {} : { host: '127.0.0.1', port: 3306 }),
database: 'app',
username: 'root',
password: '',
charset: 'utf8mb4',
...main,
} as ConnectionConfig,
};
}
if (main.dialect === 'postgres') {
return {
...connections,
main: {
...connection,
} as ConnectionConfig;
case 'postgres':
return {
host: '127.0.0.1',
port: 5432,
database: 'app',
@@ -56,27 +60,19 @@ function resolveConnections(
password: '',
ssl: false,
schema: ['public'],
...main,
},
};
}
if (main.dialect === 'oracle') {
return {
...connections,
main: {
...main,
host: main.host ?? '127.0.0.1',
port: main.port ?? 1521,
serviceName: main.serviceName || 'FREEPDB1',
username: main.username ?? 'nocobase',
password: main.password ?? '',
},
};
}
if (main.dialect === 'mssql') {
return {
...connections,
main: {
...connection,
};
case 'oracle':
return {
...connection,
host: connection.host ?? '127.0.0.1',
port: connection.port ?? 1521,
serviceName: connection.serviceName || 'FREEPDB1',
username: connection.username ?? 'nocobase',
password: connection.password ?? '',
};
case 'mssql':
return {
host: '127.0.0.1',
port: 1433,
database: 'app',
@@ -84,14 +80,19 @@ function resolveConnections(
password: '',
encrypt: false,
trustServerCertificate: false,
...main,
},
};
...connection,
};
case 'sqlite': {
const database = (connection as ConnectionConfig & { database?: string })
.database;
const filename = database ?? connection.filename;
return {
...connection,
filename:
filename && filename !== ':memory:' && paths
? paths.storage(filename)
: filename,
};
}
}
const database = (main as ConnectionConfig & { database?: string }).database;
if (!database || !paths) return connections;
return {
...connections,
main: { ...main, filename: paths.storage(database) },
};
}
@@ -0,0 +1,52 @@
import path from 'node:path';
import type { ConfigPaths } from '../config/index.js';
import type { AppDatabaseConfig } from './types.js';
import { resolveConnections } from './manager.js';
/** Catch identical configured targets. Network aliases still require operator validation. */
export function validateDatabaseOwnership(
config: AppDatabaseConfig,
paths?: ConfigPaths,
): void {
const owners = new Map<string, string>();
for (const [name, connection] of Object.entries(
resolveConnections(config.connections, paths),
)) {
if (connection.schemaManagement === 'external') continue;
let target: unknown;
if (connection.dialect === 'sqlite') {
if (!connection.filename || connection.filename === ':memory:') continue;
target = ['sqlite', path.resolve(connection.filename)];
} else {
const schema =
connection.dialect === 'postgres'
? ((typeof connection.schema === 'string'
? connection.schema
: connection.schema?.[0]) ?? 'public')
: connection.dialect === 'oracle'
? connection.username
: connection.dialect === 'mssql'
? 'dbo'
: undefined;
target = [
connection.dialect,
connection.host,
connection.port,
connection.dialect === 'mysql' ? connection.socketPath : undefined,
connection.dialect === 'oracle'
? connection.serviceName
: connection.database,
schema,
];
}
const key = JSON.stringify(target);
const owner = owners.get(key);
if (owner) {
throw new Error(
`Managed database connections "${owner}" and "${name}" target the same database and schema. Use one managed connection per target; aliases must not own separate migration histories.`,
);
}
owners.set(key, name);
}
}
@@ -0,0 +1,170 @@
import { existsSync, statSync } from 'node:fs';
import path from 'node:path';
import { validateDatabaseOwnership } from './ownership.js';
import type { ConfigPaths } from '../config/index.js';
import type { AppDatabaseConfig, AppDatabaseMigrationConfig } from './types.js';
export type AppDatabaseTaskKind = 'migrations' | 'seeds';
export interface AppDatabaseTaskSelection {
connection?: string;
all?: boolean;
autoRun?: boolean;
}
export interface AppDatabaseTask {
connection: string;
kind: AppDatabaseTaskKind;
config: AppDatabaseMigrationConfig;
skipReason?: 'external' | 'auto-run-disabled';
}
export function defaultConnectionName(
config: AppDatabaseConfig,
): string | undefined {
return config.default ?? Object.keys(config.connections)[0];
}
/** Resolve the entire plan before any database is opened or modified. */
export function planAppDatabaseTasks(
config: AppDatabaseConfig,
paths: ConfigPaths | undefined,
kinds: readonly AppDatabaseTaskKind[],
selection: AppDatabaseTaskSelection = {},
): AppDatabaseTask[] {
if (selection.connection !== undefined && selection.all) {
throw new Error('--connection and --all are mutually exclusive.');
}
const primary = defaultConnectionName(config);
if (primary === 'none' || !primary) {
if (selection.connection !== undefined)
throw new Error('Database is not configured.');
return [];
}
if (!Object.hasOwn(config.connections, primary)) {
throw new Error(`Unknown default database connection "${primary}".`);
}
validateDatabaseOwnership(config, paths);
const names =
selection.all || selection.autoRun
? Object.keys(config.connections).sort((a, b) =>
a === primary ? -1 : b === primary ? 1 : a < b ? -1 : a > b ? 1 : 0,
)
: [selection.connection ?? primary];
return names.flatMap((name) => {
if (!Object.hasOwn(config.connections, name)) {
throw new Error(`Unknown database connection "${name}".`);
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
throw new Error(
`Database connection "${name}" must use letters, digits, underscores or hyphens.`,
);
}
const connection = config.connections[name];
const external = connection.schemaManagement === 'external';
if (external && !selection.all && !selection.autoRun) {
throw new Error(
`Database connection "${name}" is external; migrations and seeds are not supported.`,
);
}
return kinds.map((kind) => {
const local = connection[kind];
const legacy = name === primary ? config[kind] : undefined;
// Legacy values (including existing DB_* environment mappings) retain precedence.
// Source selection is exclusive: different explicit sources must never be merged.
if (
legacy?.directory &&
local?.directory &&
legacy.directory !== local.directory
) {
throw new Error(
`Conflicting legacy and connection ${kind} directories for "${name}".`,
);
}
if (legacy?.sources && local?.sources) {
throw new Error(`Configure ${kind} sources only once for "${name}".`);
}
const options = { ...local, ...legacy };
if (options.directory && options.sources) {
throw new Error(
`Configure either ${kind} directory or sources for "${name}", not both.`,
);
}
const root =
paths?.database() ??
config.taskSources?.directory ??
path.resolve('database');
const modern = path.join(root, name, kind);
const old = path.join(root, kind);
let directory = options.directory;
if (!directory && !options.sources && !external) {
if (name === primary && existsSync(old)) {
if (existsSync(modern)) {
throw new Error(
`Both legacy and connection ${kind} directories exist for "${name}". Configure one directory explicitly.`,
);
}
directory = old;
} else directory = modern;
}
const resolvePath = (value: string): string =>
path.resolve(paths?.root() ?? process.cwd(), value);
const packageName =
options.packageName ?? config.taskSources?.packageName ?? 'app';
const appSources = options.sources?.map((source) => ({
...source,
directory: resolvePath(source.directory),
})) ?? [
{
packageName,
directory: resolvePath(directory ?? modern),
extensions: options.extensions,
},
];
const sources = [
...appSources,
...(name === primary ? (config.taskSources?.[kind] ?? []) : []),
];
const autoRun = options.autoRun ?? name === primary;
if (!external && (!selection.autoRun || autoRun)) {
for (const source of sources) {
if (
existsSync(source.directory) &&
!statSync(source.directory).isDirectory()
) {
throw new Error(
`Database ${kind} source is not a directory: ${source.directory}`,
);
}
}
if (options.directory || options.sources) {
for (const source of appSources) {
if (!existsSync(source.directory)) {
throw new Error(
`Explicit database ${kind} source is missing for "${name}": ${source.directory}`,
);
}
}
}
}
return {
connection: name,
kind,
config: {
...options,
packageName,
directory: resolvePath(directory ?? modern),
sources,
autoRun,
},
skipReason: external
? 'external'
: selection.autoRun && !autoRun
? 'auto-run-disabled'
: undefined,
};
});
});
}
@@ -4,8 +4,8 @@ import {
} from '@nocobase/service-provider';
import { databaseManagerToken } from '@nocobase/db';
import { createAppDatabaseManager } from './manager.js';
import { createAppMigrator } from './migrator.js';
import { createAppSeeder } from './seeder.js';
import { executeAppDatabasePlan } from './tasks.js';
import { planAppDatabaseTasks } from './plan.js';
import { prepareAppDatabaseStorage } from './storage.js';
import { databaseConfig } from './config.js';
import type { AppConfigAccessor, ConfigPaths } from '../config/index.js';
@@ -43,24 +43,20 @@ export class DatabaseProvider extends ServiceProvider<DatabaseProviderApplicatio
}
const config = this.getDatabaseConfig();
await prepareAppDatabaseStorage(config, this.app.paths);
const plan = planAppDatabaseTasks(
config,
this.app.paths,
['migrations', 'seeds'],
{ autoRun: true },
);
// All SQLite connections must be usable by runtime services even without automatic tasks.
await prepareAppDatabaseStorage(
config,
this.app.paths,
Object.keys(config.connections),
);
const database = container.resolve(databaseManagerToken);
if (config.migrations.autoRun) {
await createAppMigrator({
database,
config: config.migrations,
sources: config.migrations.sources,
}).latest();
}
if (config.seeds?.autoRun) {
await createAppSeeder({
database,
config: config.seeds,
sources: config.seeds.sources,
}).run();
}
await executeAppDatabasePlan(database, config, this.app.paths, plan);
}
public override async shutdown(): Promise<void> {
+13 -39
View File
@@ -3,49 +3,23 @@ import path from 'node:path';
import type { ConfigPaths } from '../config/index.js';
import type { AppDatabaseConfig } from './types.js';
import { resolveConnections } from './manager.js';
import { defaultConnectionName } from './plan.js';
export async function prepareAppDatabaseStorage(
config: AppDatabaseConfig,
paths?: ConfigPaths,
names?: readonly string[],
): Promise<void> {
const connection = resolveActiveConnection(config);
if (!connection || connection.dialect !== 'sqlite') {
return;
if (config.default === 'none') return;
const primary = defaultConnectionName(config);
const connections = resolveConnections(config.connections, paths);
for (const name of names ?? (primary ? [primary] : [])) {
const connection = connections[name];
if (!connection) throw new Error(`Unknown database connection "${name}".`);
if (connection.dialect !== 'sqlite') continue;
const filename = connection.filename;
if (!filename || filename === ':memory:') continue;
await mkdir(path.dirname(filename), { recursive: true });
}
const filename = resolveSqliteFilename(connection, paths);
if (!filename || filename === ':memory:') {
return;
}
await mkdir(path.dirname(filename), {
recursive: true,
});
}
function resolveSqliteFilename(
connection: AppDatabaseConfig['connections'][string],
paths: ConfigPaths | undefined,
): string | undefined {
if (connection.dialect !== 'sqlite') return undefined;
const database = (connection as typeof connection & { database?: string })
.database;
return database && paths ? paths.storage(database) : connection.filename;
}
function resolveActiveConnection(
config: AppDatabaseConfig,
): AppDatabaseConfig['connections'][string] | undefined {
if (config.default === 'none') {
return undefined;
}
const defaultConnection = config.default
? config.connections[config.default]
: undefined;
if (defaultConnection) {
return defaultConnection;
}
return Object.values(config.connections)[0];
}
+131 -35
View File
@@ -5,18 +5,134 @@ import { createAppDatabaseManager } from './manager.js';
import { createAppMigrator, type AppMigrationRunResult } from './migrator.js';
import { createAppSeeder, type AppSeedRunResult } from './seeder.js';
import { prepareAppDatabaseStorage } from './storage.js';
import {
planAppDatabaseTasks,
type AppDatabaseTask,
type AppDatabaseTaskKind,
type AppDatabaseTaskSelection,
} from './plan.js';
import type { AppDatabaseConfig } from './types.js';
export interface AppDatabaseTaskResult {
connection: string;
kind: AppDatabaseTaskKind;
status: 'completed' | 'skipped' | 'failed' | 'not-run';
reason?: string;
error?: string;
batch?: number;
executed?: string[];
skipped?: string[];
}
export interface AppDatabaseTasksResult {
ok: boolean;
status: 'completed' | 'failed' | 'not-configured';
results: AppDatabaseTaskResult[];
}
export class AppDatabaseTaskError extends Error {
constructor(
public readonly result: AppDatabaseTasksResult,
cause: unknown,
) {
const failed = result.results.find((entry) => entry.status === 'failed');
super(
`Database ${failed?.kind} failed for connection "${failed?.connection}": ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
);
this.name = 'AppDatabaseTaskError';
}
}
/** Manual commands and startup share the same resolved, connection-bound plan. */
export async function executeAppDatabasePlan(
database: DatabaseManager,
config: AppDatabaseConfig,
paths: ConfigPaths | undefined,
plan: readonly AppDatabaseTask[],
): Promise<AppDatabaseTasksResult> {
const result: AppDatabaseTasksResult = {
ok: true,
status: 'completed',
results: [],
};
for (const [index, task] of plan.entries()) {
const identity = { connection: task.connection, kind: task.kind };
if (task.skipReason) {
result.results.push({
...identity,
status: 'skipped',
reason: task.skipReason,
});
continue;
}
try {
await prepareAppDatabaseStorage(config, paths, [task.connection]);
const options = {
database,
connection: task.connection,
config: task.config,
sources: task.config.sources,
};
const completed =
task.kind === 'migrations'
? await createAppMigrator(options).latest()
: await createAppSeeder(options).run();
result.results.push({ ...identity, ...completed });
} catch (error) {
result.ok = false;
result.status = 'failed';
result.results.push({
...identity,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
});
result.results.push(
...plan.slice(index + 1).map((pending): AppDatabaseTaskResult => ({
connection: pending.connection,
kind: pending.kind,
status: 'not-run',
reason: 'previous-task-failed',
})),
);
throw new AppDatabaseTaskError(result, error);
}
}
return result;
}
export async function runAppDatabaseTasks(
config: AppDatabaseConfig,
paths: ConfigPaths | undefined,
selection: AppDatabaseTaskSelection & { kind: AppDatabaseTaskKind },
): Promise<AppDatabaseTasksResult> {
const plan = planAppDatabaseTasks(config, paths, [selection.kind], selection);
if (!plan.length) return { ok: true, status: 'not-configured', results: [] };
const database = createAppDatabaseManager(config, paths);
if (!database) return { ok: true, status: 'not-configured', results: [] };
try {
return await executeAppDatabasePlan(database, config, paths, plan);
} finally {
await database.destroy();
}
}
export async function runAppMigrations(
config: AppDatabaseConfig,
paths?: ConfigPaths,
): Promise<AppMigrationRunResult | undefined> {
return runWithAppDatabase(config, paths, (database) =>
createAppMigrator({
database,
config: config.migrations,
sources: config.migrations.sources,
}).latest(),
const result = await runAppDatabaseTasks(config, paths, {
kind: 'migrations',
});
const first = result.results[0];
return (
first && {
status: first.status as AppMigrationRunResult['status'],
reason: first.reason as AppMigrationRunResult['reason'],
batch: first.batch,
executed: first.executed,
skipped: first.skipped,
}
);
}
@@ -24,34 +140,14 @@ export async function runAppSeeds(
config: AppDatabaseConfig,
paths?: ConfigPaths,
): Promise<AppSeedRunResult | undefined> {
const seeds = config.seeds;
if (!seeds) {
return undefined;
}
return runWithAppDatabase(config, paths, (database) =>
createAppSeeder({
database,
config: seeds,
sources: seeds.sources,
}).run(),
const result = await runAppDatabaseTasks(config, paths, { kind: 'seeds' });
const first = result.results[0];
return (
first && {
status: first.status as AppSeedRunResult['status'],
reason: first.reason as AppSeedRunResult['reason'],
executed: first.executed,
skipped: first.skipped,
}
);
}
async function runWithAppDatabase<TResult>(
config: AppDatabaseConfig,
paths: ConfigPaths | undefined,
run: (database: DatabaseManager) => Promise<TResult>,
): Promise<TResult | undefined> {
await prepareAppDatabaseStorage(config, paths);
const database = createAppDatabaseManager(config, paths);
if (!database) {
return undefined;
}
try {
return await run(database);
} finally {
await database.destroy();
}
}
+18 -2
View File
@@ -5,8 +5,18 @@ import type {
} from '@nocobase/db';
export interface AppDatabaseConfig extends NocoBaseDatabaseConfig {
migrations: AppDatabaseMigrationConfig;
seeds?: AppDatabaseSeedConfig;
connections: Record<string, AppDatabaseConnectionConfig>;
/** @deprecated Configure tasks on connections instead. Applies to the default connection. */
migrations?: Partial<AppDatabaseMigrationConfig>;
/** @deprecated Configure tasks on connections instead. Applies to the default connection. */
seeds?: Partial<AppDatabaseSeedConfig>;
/** Runtime contribution context; populated by the application, not driver options. */
taskSources?: {
directory?: string;
packageName: string;
migrations: readonly MigrationSource[];
seeds: readonly SeedSource[];
};
}
export interface AppDatabaseMigrationConfig {
@@ -28,3 +38,9 @@ export interface AppDatabaseSeedConfig {
lockTableName?: string;
extensions?: readonly string[];
}
export type AppDatabaseConnectionConfig =
NocoBaseDatabaseConfig['connections'][string] & {
migrations?: Partial<AppDatabaseMigrationConfig>;
seeds?: Partial<AppDatabaseSeedConfig>;
};
+4 -23
View File
@@ -58,35 +58,16 @@ export function createAppPluginDatabaseConfig(
resolved: ResolvedAppServerPlugins,
): ResolvedAppPluginDatabaseConfig {
const plugins = resolved.plugins.map((plugin) => plugin.metadata);
const appMigrationSource: MigrationSource = {
packageName: resolved.appPackageName,
directory: database.migrations.directory,
};
const appSeedSource: SeedSource | undefined = database.seeds
? {
packageName: resolved.appPackageName,
directory: database.seeds.directory,
}
: undefined;
return {
plugins,
database: {
...database,
migrations: {
...database.migrations,
taskSources: {
...database.taskSources,
packageName: resolved.appPackageName,
sources: [appMigrationSource, ...createPluginMigrationSources(plugins)],
migrations: createPluginMigrationSources(plugins),
seeds: createPluginSeedSources(plugins),
},
seeds: database.seeds
? {
...database.seeds,
packageName: resolved.appPackageName,
sources: appSeedSource
? [appSeedSource, ...createPluginSeedSources(plugins)]
: undefined,
}
: undefined,
},
};
}
@@ -21,8 +21,8 @@ const featureConfig = defineAppConfig({
});
describe('AppConfig', () => {
it('logs successful loads and reloads without configuration values', async () => {
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
it('logs successful loads and reloads to stderr without configuration values', async () => {
const info = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const config = new AppConfig([featureConfig]);
await config.loadAll();
@@ -0,0 +1,437 @@
import {
mkdirSync,
mkdtempSync,
renameSync,
rmSync,
writeFileSync,
existsSync,
readFileSync,
} from 'node:fs';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import type { Knex } from 'knex';
import ts from 'typescript';
import { objectProvider } from '@nocobase/config/providers/object';
import { AppConfig, createConfigPaths } from '../src/config/index.js';
import {
createAppDatabaseManager,
databaseConfig,
planAppDatabaseTasks,
runAppDatabaseTasks,
type AppDatabaseConfig,
} from '../src/database/index.js';
import { executeAppDatabasePlan } from '../src/database/tasks.js';
import { createAppPluginDatabaseConfig } from '../src/plugins/resolve.js';
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0))
rmSync(root, { recursive: true, force: true });
});
function fixture() {
const parent = path.resolve('tests/.tmp');
mkdirSync(parent, { recursive: true });
const root = mkdtempSync(path.join(parent, 'connections-'));
roots.push(root);
const paths = createConfigPaths({ rootDir: root });
const config: AppDatabaseConfig = {
default: 'main',
connections: {
analytics: {
dialect: 'sqlite',
filename: paths.storage('analytics/data.sqlite'),
migrations: { autoRun: false },
seeds: { autoRun: false },
},
main: { dialect: 'sqlite', filename: paths.storage('main/data.sqlite') },
},
taskSources: {
directory: paths.database(),
packageName: 'test-app',
migrations: [],
seeds: [],
},
};
return { root, paths, config };
}
function migration(
directory: string,
name: string,
table: string,
fail = false,
) {
mkdirSync(directory, { recursive: true });
writeFileSync(
path.join(directory, `${name}.ts`),
`import { defineMigration } from '@nocobase/db';
export default defineMigration({ name: '${name}', async up({ builder }) {
${fail ? "throw new Error('intentional failure');" : `await builder.createCollection('${table}', c => { c.increments('id'); c.string('value'); });`}
}, async down({ builder }) { await builder.dropCollection('${table}'); } });`,
);
}
function seed(directory: string, table: string) {
mkdirSync(directory, { recursive: true });
writeFileSync(
path.join(directory, '002_seed.ts'),
`import { defineSeed } from '@nocobase/db';
export default defineSeed({ name: '002_seed', async run({ query }) {
await query.insertInto('${table}').values({ value: 'initial' }).execute();
} });`,
);
}
async function inspect(
config: AppDatabaseConfig,
name: string,
check: (client: Knex) => Promise<void>,
) {
const database = createAppDatabaseManager(config)!;
try {
await check(await database.connection(name).client<Knex>());
} finally {
await database.destroy();
}
}
describe('connection-bound application database tasks', () => {
it('isolates migrations, seeds and histories and runs plugins only on the default connection', async () => {
const { config, paths, root } = fixture();
migration(paths.database('main/migrations'), '001_main', 'mainRows');
migration(
paths.database('analytics/migrations'),
'001_analytics',
'analyticsRows',
);
seed(paths.database('main/seeds'), 'mainRows');
seed(paths.database('analytics/seeds'), 'analyticsRows');
const pluginDirectory = path.join(root, 'plugin/migrations');
migration(pluginDirectory, '000_plugin', 'systemRows');
const resolved = createAppPluginDatabaseConfig(config, {
appPackageName: 'test-app',
plugins: [
{
metadata: {
packageName: 'test-plugin',
migrationsDirectory: pluginDirectory,
},
},
],
} as Parameters<typeof createAppPluginDatabaseConfig>[1]).database;
const result = await runAppDatabaseTasks(resolved, paths, {
kind: 'migrations',
all: true,
});
expect(result.results.map((r) => r.connection)).toEqual([
'main',
'analytics',
]);
expect(result.results[0].executed).toEqual(['000_plugin', '001_main']);
expect(result.results[1].executed).toEqual(['001_analytics']);
await runAppDatabaseTasks(resolved, paths, { kind: 'seeds', all: true });
await inspect(config, 'analytics', async (client) => {
expect(await client.schema.hasTable('system_rows')).toBe(false);
expect(await client.schema.hasTable('main_rows')).toBe(false);
expect(await client('analytics_rows').select('value')).toEqual([
{ value: 'initial' },
]);
});
const again = await runAppDatabaseTasks(resolved, paths, {
kind: 'migrations',
all: true,
});
expect(again.results.map((r) => r.executed)).toEqual([[], []]);
const seeds = await runAppDatabaseTasks(resolved, paths, {
kind: 'seeds',
all: true,
});
expect(seeds.results.map((r) => r.executed)).toEqual([[], []]);
});
it('manual selection ignores autoRun and never creates the unselected database', async () => {
const { config, paths } = fixture();
migration(
paths.database('analytics/migrations'),
'001_analytics',
'analyticsRows',
);
await runAppDatabaseTasks(config, paths, {
kind: 'migrations',
connection: 'analytics',
});
expect(existsSync(paths.storage('main/data.sqlite'))).toBe(false);
expect(existsSync(paths.storage('analytics/data.sqlite'))).toBe(true);
});
it('keeps startup order, skips disabled tasks and migrates before seeding each connection', async () => {
const { config, paths } = fixture();
migration(paths.database('main/migrations'), '001_main', 'mainRows');
seed(paths.database('main/seeds'), 'mainRows');
const database = createAppDatabaseManager(config)!;
try {
const plan = planAppDatabaseTasks(
config,
paths,
['migrations', 'seeds'],
{ autoRun: true },
);
const result = await executeAppDatabasePlan(
database,
config,
paths,
plan,
);
expect(
result.results.map((r) => [r.connection, r.kind, r.status]),
).toEqual([
['main', 'migrations', 'completed'],
['main', 'seeds', 'completed'],
['analytics', 'migrations', 'skipped'],
['analytics', 'seeds', 'skipped'],
]);
expect(existsSync(paths.storage('analytics/data.sqlite'))).toBe(false);
} finally {
await database.destroy();
}
});
it('reports committed, failed and unattempted connections and stops on failure', async () => {
const { config, paths } = fixture();
config.connections.zlast = {
dialect: 'sqlite',
filename: paths.storage('last/data.sqlite'),
};
migration(paths.database('main/migrations'), '001_main', 'mainRows');
migration(
paths.database('analytics/migrations'),
'001_fail',
'badRows',
true,
);
migration(paths.database('zlast/migrations'), '001_last', 'lastRows');
await expect(
runAppDatabaseTasks(config, paths, { kind: 'migrations', all: true }),
).rejects.toMatchObject({
result: {
ok: false,
results: [
{ connection: 'main', status: 'completed' },
{ connection: 'analytics', status: 'failed' },
{ connection: 'zlast', status: 'not-run' },
],
},
});
expect(existsSync(paths.storage('last/data.sqlite'))).toBe(false);
await inspect(config, 'main', async (client) => {
expect(await client.schema.hasTable('main_rows')).toBe(true);
});
});
it('skips external databases without opening them and rejects explicit execution', async () => {
const { config, paths } = fixture();
config.connections.erp = {
dialect: 'sqlite',
filename: paths.storage('erp.sqlite'),
schemaManagement: 'external',
};
const result = await runAppDatabaseTasks(config, paths, {
kind: 'seeds',
all: true,
});
expect(result.results.find((r) => r.connection === 'erp')).toMatchObject({
status: 'skipped',
reason: 'external',
});
expect(existsSync(paths.storage('erp.sqlite'))).toBe(false);
await expect(
runAppDatabaseTasks(config, paths, { kind: 'seeds', connection: 'erp' }),
).rejects.toThrow('external');
await expect(
runAppDatabaseTasks(config, paths, {
kind: 'migrations',
connection: 'erp',
}),
).rejects.toThrow('external');
});
it('preserves migration and seed history after moving legacy directories for a custom default', async () => {
const { config, paths } = fixture();
config.default = 'analytics';
migration(paths.database('migrations'), '001_legacy', 'legacyRows');
seed(paths.database('seeds'), 'legacyRows');
await runAppDatabaseTasks(config, paths, { kind: 'migrations' });
await runAppDatabaseTasks(config, paths, { kind: 'seeds' });
mkdirSync(paths.database('analytics'), { recursive: true });
renameSync(
paths.database('migrations'),
paths.database('analytics/migrations'),
);
renameSync(paths.database('seeds'), paths.database('analytics/seeds'));
for (const kind of ['migrations', 'seeds'] as const) {
expect(
(await runAppDatabaseTasks(config, paths, { kind })).results[0]
.executed,
).toEqual([]);
}
await inspect(config, 'analytics', async (client) => {
expect(await client('legacy_rows').select('value')).toHaveLength(1);
});
});
it('preflights conflicts, missing explicit sources and unknown connections before writing', async () => {
const { config, paths } = fixture();
migration(paths.database('main/migrations'), '001_main', 'mainRows');
mkdirSync(paths.database('migrations'), { recursive: true });
await expect(
runAppDatabaseTasks(config, paths, { kind: 'migrations' }),
).rejects.toThrow('Both legacy');
rmSync(paths.database('migrations'), { recursive: true });
config.connections.analytics.migrations = { directory: 'missing' };
await expect(
runAppDatabaseTasks(config, paths, { kind: 'migrations', all: true }),
).rejects.toThrow('Explicit');
expect(existsSync(paths.storage('main/data.sqlite'))).toBe(false);
await expect(
runAppDatabaseTasks(config, paths, {
kind: 'migrations',
connection: 'typo',
}),
).rejects.toThrow('Unknown');
await expect(
runAppDatabaseTasks(config, paths, {
kind: 'migrations',
connection: 'main',
all: true,
}),
).rejects.toThrow('mutually exclusive');
});
it('rejects two managed aliases of the same target before creating storage', async () => {
const { config, paths } = fixture();
config.connections.analytics.filename = config.connections.main.filename;
await expect(
runAppDatabaseTasks(config, paths, { kind: 'migrations', all: true }),
).rejects.toThrow('same database and schema');
expect(existsSync(paths.storage('main/data.sqlite'))).toBe(false);
});
it('normalizes every SQLite path consistently, including database aliases and memory databases', async () => {
const { config, paths } = fixture();
config.connections.analytics = {
dialect: 'sqlite',
filename: 'unused.sqlite',
database: 'nested/analytics.sqlite',
} as AppDatabaseConfig['connections'][string];
config.connections.main = {
dialect: 'sqlite',
filename: ':memory:',
database: ':memory:',
} as AppDatabaseConfig['connections'][string];
migration(
paths.database('analytics/migrations'),
'001_analytics',
'analyticsRows',
);
await runAppDatabaseTasks(config, paths, {
kind: 'migrations',
connection: 'analytics',
});
expect(existsSync(paths.storage('nested/analytics.sqlite'))).toBe(true);
expect(existsSync(paths.storage(':memory:'))).toBe(false);
});
it('loads compiled per-connection sources from the runtime database directory', async () => {
const { config, paths, root } = fixture();
const source = paths.database('analytics/migrations');
migration(source, '001_compiled', 'compiledRows');
seed(paths.database('analytics/seeds'), 'compiledRows');
const compiledPaths = createConfigPaths({
rootDir: root,
databaseDir: path.join(root, 'dist/database'),
});
for (const [kind, name] of [
['migrations', '001_compiled'],
['seeds', '002_seed'],
]) {
const directory = compiledPaths.database(`analytics/${kind}`);
mkdirSync(directory, { recursive: true });
const input = readFileSync(
paths.database(`analytics/${kind}/${name}.ts`),
'utf8',
);
writeFileSync(
path.join(directory, `${name}.js`),
ts.transpileModule(input, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
);
}
for (const kind of ['migrations', 'seeds'] as const) {
const result = await runAppDatabaseTasks(config, compiledPaths, {
kind,
connection: 'analytics',
});
expect(result.results[0].executed).toHaveLength(1);
expect(
(
await runAppDatabaseTasks(config, compiledPaths, {
kind,
connection: 'analytics',
})
).results[0].executed,
).toEqual([]);
}
await inspect(config, 'analytics', async (client) => {
expect(await client('compiled_rows').select('value')).toEqual([
{ value: 'initial' },
]);
});
});
it('maps old environment task settings to a custom default without contaminating other connections', async () => {
const { paths } = fixture();
const config = new AppConfig([databaseConfig], {
context: {
paths,
appPackageName: 'test-app',
plugins: { appPackageName: 'test-app', plugins: [] },
},
environment: {
DB_MIGRATIONS_AUTO_RUN: 'false',
DB_MIGRATIONS_TABLE: 'legacy_history',
},
});
config.load(
objectProvider({
database: {
default: 'analytics',
connections: {
analytics: {
dialect: 'sqlite',
filename: ':memory:',
migrations: { autoRun: true },
},
},
},
}),
);
await config.loadAll();
const plan = planAppDatabaseTasks(
config.get(databaseConfig),
paths,
['migrations'],
{ all: true },
);
expect(plan[0]).toMatchObject({
connection: 'analytics',
config: { autoRun: false, tableName: 'legacy_history' },
});
expect(plan[1].config.tableName).toBeUndefined();
expect(plan[1].config.autoRun).toBe(false);
});
});
@@ -138,7 +138,7 @@ describe('DatabaseProvider', () => {
registry.add(provider);
registry.registerAll();
await expect(registry.bootAll()).rejects.toBe(error);
await expect(registry.bootAll()).rejects.toMatchObject({ cause: error });
expect(createDatabaseSeederMock).not.toHaveBeenCalled();
expect(database.destroy).toHaveBeenCalledOnce();
@@ -183,7 +183,7 @@ describe('standalone database tasks', () => {
await expect(
runAppMigrations(createConfig(createTempDirectory())),
).rejects.toBe(error);
).rejects.toMatchObject({ cause: error });
expect(database.destroy).toHaveBeenCalledOnce();
});
+2 -2
View File
@@ -9,8 +9,8 @@ import { createApiClient } from '@nocobase/api-client';
const api = createApiClient({ baseURL: '/api' });
const systemInfo = await api.request<SystemInfo>({
path: '/system-info',
const health = await api.request<{ ok: boolean }>({
path: '/healthz',
});
await api.request({
@@ -35,3 +35,5 @@ Register the plugin in an application package:
With the default App base path, open `/main/notification-provider` after
signing in. The page can trigger success, error, and undoable notifications.
Applications that only need notifications can register `notificationProvider({ demo: false })` in `client/plugins.ts`. This omits the demo route while retaining the Refine notification provider and global Sonner host. The default preserves the existing demo route.
@@ -8,6 +8,8 @@ import routes from './routes.js';
import serviceProviders from './service-provider.js';
export interface NotificationProviderClientOptions {
/** Include the notification demo page. Defaults to true for compatibility. */
readonly demo?: boolean;
/** Label of the undo action in undoable (progress) notifications. */
readonly undoLabel?: string;
}
@@ -16,7 +18,7 @@ const notificationProvider: AppClientPluginFactory<NotificationProviderClientOpt
defineClientPlugin({
packageName: '@nocobase/app-plugin-notification-provider',
serviceProviders,
routes,
routes: (options) => (options.demo === false ? [] : routes),
reactProviders,
});
@@ -1,8 +1,18 @@
import { describe, expect, it } from 'vitest';
import routes from '../client/routes.js';
import notificationProvider from '../client/plugin.js';
describe('client routes', () => {
it('can omit the demo while retaining notification services and host', () => {
const standard = notificationProvider();
const clean = notificationProvider({ demo: false });
expect(standard.routes).toEqual([routes]);
expect(clean.routes).toEqual([]);
expect(clean.serviceProviders).toEqual(standard.serviceProviders);
expect(clean.reactProviders).toEqual(standard.reactProviders);
});
it('defines a lazy notification provider demo route', async () => {
const [route] = routes.routes;
@@ -1,2 +0,0 @@
dist/
/public/r/
@@ -1,2 +0,0 @@
dist/
/public/r/
@@ -1,103 +0,0 @@
# @nocobase/app-plugin-system-info
## 0.1.0-beta.2
### Patch Changes
- 90a4903: Replace the composite application transport with application-owned `ApiClient` and `RealtimeClient` services. Client plugins, examples, and application templates now use object-style HTTP request options through the shared API client, while realtime subscriptions resolve their dedicated WebSocket client.
- Updated dependencies [90a4903]
- Updated dependencies [90a4903]
- Updated dependencies [90a4903]
- Updated dependencies [90a4903]
- Updated dependencies [90a4903]
- Updated dependencies [90a4903]
- Updated dependencies [a864497]
- Updated dependencies [a864497]
- Updated dependencies [90a4903]
- Updated dependencies [90a4903]
- Updated dependencies [90a4903]
- Updated dependencies [90a4903]
- Updated dependencies [90a4903]
- Updated dependencies [90a4903]
- @nocobase/app-server@1.0.0-beta.7
- @nocobase/app-client@1.0.0-beta.10
- @nocobase/app-plugin-authentication@0.1.0-beta.7
## 0.1.0-beta.1
### Minor Changes
- 174eab5: Consolidate the browser packages into `@nocobase/app-client`.
`@nocobase/app-sdk` is gone; its API client now lives in `@nocobase/app-client` and is imported from there. `@nocobase/app-portal-sdk` is deprecated and keeps only what still has consumers: `NocoBaseClient` and the runtime configuration it reads, which exist to reach a v2 NocoBase server, and the route surface containers under `/routing`. Its ACL, auth, data, extension, i18n, and system-settings modules are removed, as is the route tree that `/routing` used to export alongside the surfaces.
`@nocobase/app-client` gains `resolveAppBase()`, which reports the path the application is mounted at.
Four plugins built their API client at import time instead of resolving it from the application's service container, so they could not see `api.baseURL` from the application configuration. They now resolve it, which means an application that configures a base URL gets one client rather than two that disagree.
The injected browser global `NOCOBASE_PORTAL_BASE` is renamed to `APP_BASE_PATH`. Its value has always been the `APP_BASE_PATH` environment variable, and the old name grouped it with the settings that address a v2 NocoBase server. Those keep their names. A client and the server that serves it must be upgraded together.
`@nocobase/app-plugin-data-provider` is removed. It forwarded the Portal data provider, and applications built on the current client runtime do not use it.
The Hub template is rebuilt from the default template and now runs the same client and server stack as every other v3 application. Its `/api/apps` endpoint and its v2 API proxy are gone, so a hub's `.env` no longer configures them.
The Portal SDK's template compatibility check is removed with the rest: it had been disabled behind a constant, and its install script cost every generated project a `pnpm-workspace.yaml` `allowBuilds` entry it did not need. `createPortalViteConfig` no longer takes the plugin that injected it.
- 1527426: Declare identity-sensitive runtime packages as peer dependencies of every plugin.
A plugin used to list `@nocobase/app-server`, `@nocobase/db`, `@nocobase/service-provider`, `@nocobase/i18n`, `@nocobase/queue`, `@nocobase/app-portal-sdk`, and the plugins it builds on among its `dependencies`. Each of these carries state that only works while exactly one copy of the module exists in the process: `ServiceContainer` keys its bindings by the token object itself, React contexts match only the provider created from the same module, and `@nocobase/queue` registers job classes into a global `Locator`. A `dependencies` range lets a package manager install a second copy to satisfy it, which splits that state.
The monorepo could never show the problem, because `workspace:` links every consumer to one directory. It appears once a plugin is installed from a registry into an application, and it appears at runtime rather than at install time: a service that is registered reports `Service "..." is not registered`, or a context reads `undefined` under a mounted provider.
Each of these packages is now a peer dependency paired with a devDependency. The peer is the published contract that makes the installing application provide the single copy; the devDependency pins this repository's copy for development and tests, which the deliberately wide peer range does not. Applications built from the templates are unaffected — they already install every one of these packages directly, which is what satisfies the new peer ranges.
`pnpm plugin:create` generates the same shape, and `pnpm peers:check` enforces it in CI.
### Patch Changes
- Updated dependencies [174eab5]
- Updated dependencies [ab7b341]
- Updated dependencies [1527426]
- Updated dependencies [174eab5]
- @nocobase/app-client@1.0.0-beta.6
- @nocobase/app-server@1.0.0-beta.4
- @nocobase/app-plugin-authentication@0.1.0-beta.5
- @nocobase/service-provider@0.0.2-beta.1
## 0.1.0-beta.0
### Minor Changes
- ac3f033: Export every server plugin from its package's `./server` entry point, and update application composition, plugin discovery, and generated plugins to use the unified entry point.
### Patch Changes
- 78cf0a2: Add a complete App-facing Plugin Skill example with a reusable Notice component, an authenticated Server API, target-App integration tests, and capability-aware Skill scaffolding. Clarify System Info ownership, authorization, and behavioral verification guidance.
- 78cf0a2: Generate runtime-aware TypeScript, ESLint, Node engine, and development dependency configuration for Client-only, Server-only, and full-stack plugins, including stable package-scoped Queue Job identities.
Keep plugins aligned with the Agent development contract by giving Queue, System Information, and Workflow Routes path-scoped authentication, documenting the Queue API path and Database declaration source accurately, and storing example tests under each plugin's root test directory.
- fb1a752: Unify Client and Server application composition around the explicit `serviceProviders` contribution and rename Client React tree contributions to `reactProviders`.
Replace Client bootstrap modules with application-owned ServiceProvider lifecycle hooks, make the default Client start through `ClientApplication` and render through the Browser host, and update built-in plugins and runtime inspection to the new static contribution protocol.
- Updated dependencies [fb1a752]
- Updated dependencies [948304d]
- Updated dependencies [78cf0a2]
- Updated dependencies [ac3f033]
- Updated dependencies [fb1a752]
- Updated dependencies [ac3f033]
- Updated dependencies [78cf0a2]
- Updated dependencies [fb1a752]
- Updated dependencies [fb1a752]
- @nocobase/app-client@1.0.0-beta.5
- @nocobase/app-server-kit@0.1.0-beta.3
- @nocobase/app-plugin-authentication@0.1.0-beta.4
- @nocobase/service-provider@0.0.2-beta.1
- @nocobase/app-sdk@0.0.1-beta.0
## 0.0.1
### Patch Changes
- Add a read-only system information page, API, and App Agent Skill.
@@ -1,26 +0,0 @@
# @nocobase/app-plugin-system-info
Expose a small, read-only system information surface in a NocoBase v3
application.
## Public surfaces
- Client page: `/system-info`
- Server API: `GET /api/system-info`
- Server contract: `@nocobase/app-plugin-system-info/server/tokens`
The page and API require an authenticated application session.
The API returns the plugin package name and version, the current Node.js
version, and the server time. It does not read environment variables, database
records, credentials, or other sensitive application state.
Register the plugin with a target source-workspace application:
```bash
pnpm plugin:register system-info --app app-template-default
```
Registration adds the Client and Server definitions to the target
application's explicit composition roots and synchronizes the plugin-owned
Skill from `skills/` to the application's `.agents/skills/` directory.
@@ -1,4 +0,0 @@
// The plugin's public client surface. The default export is the registration factory an application lists in
// its client/plugins.ts; it keeps every implementation entry behind a dynamic import, so importing this module
// costs the application only the descriptor.
export { default } from './plugin.js';
@@ -1,103 +0,0 @@
import { apiClientToken, useService } from '@nocobase/app-client';
import { useCallback, useEffect, useState, type ReactElement } from 'react';
interface SystemInfoResponse {
readonly packageName: string;
readonly version: string;
readonly nodeVersion: string;
readonly serverTime: string;
}
export default function SystemInfoPage(): ReactElement {
// The Application's own API client, so the route follows whatever `api.baseURL` the Application is configured with.
const api = useService(apiClientToken);
const [result, setResult] = useState<SystemInfoResponse>();
const [error, setError] = useState<string>();
const [isLoading, setIsLoading] = useState<boolean>(true);
const requestPlugin = useCallback(
(signal?: AbortSignal): Promise<SystemInfoResponse> =>
api.request<SystemInfoResponse>({ path: 'system-info', signal }),
[api],
);
const refreshResult = (): void => {
setIsLoading(true);
setError(undefined);
void requestPlugin()
.then(setResult)
.catch((requestError: unknown) => {
setError(
requestError instanceof Error
? requestError.message
: 'Unable to load the server route.',
);
})
.finally(() => setIsLoading(false));
};
useEffect(() => {
const controller = new AbortController();
void requestPlugin(controller.signal)
.then((response) => {
setResult(response);
setError(undefined);
})
.catch((requestError: unknown) => {
if (!controller.signal.aborted) {
setError(
requestError instanceof Error
? requestError.message
: 'Unable to load the server route.',
);
}
})
.finally(() => {
if (!controller.signal.aborted) {
setIsLoading(false);
}
});
return () => controller.abort();
}, [requestPlugin]);
return (
<main className='mx-auto max-w-3xl space-y-6 px-6 py-10'>
<header className='space-y-2 border-b pb-6'>
<p className='text-sm text-muted-foreground'>NocoBase v3 plugin</p>
<h1 className='text-2xl font-semibold'>System information</h1>
<p className='text-sm text-muted-foreground'>
A live, read-only response from the plugin Server API.
</p>
</header>
<button
type='button'
onClick={refreshResult}
disabled={isLoading}
className='rounded-lg border px-3 py-2 text-sm disabled:opacity-50'
>
{isLoading ? 'Loading…' : 'Refresh'}
</button>
{error ? <p className='text-sm text-destructive'>{error}</p> : null}
{result ? (
<dl className='grid gap-4 rounded-xl border p-6'>
<div>
<dt className='text-sm text-muted-foreground'>Package</dt>
<dd className='font-mono text-sm'>{result.packageName}</dd>
</div>
<div>
<dt className='text-sm text-muted-foreground'>Version</dt>
<dd>{result.version}</dd>
</div>
<div>
<dt className='text-sm text-muted-foreground'>Node</dt>
<dd>{result.nodeVersion}</dd>
</div>
<div>
<dt className='text-sm text-muted-foreground'>Server time</dt>
<dd>{result.serverTime}</dd>
</div>
</dl>
) : null}
</main>
);
}
@@ -1,13 +0,0 @@
import {
defineClientPlugin,
type AppClientPluginFactory,
} from '@nocobase/app-client/plugins';
import routes from './routes.js';
const systemInfo: AppClientPluginFactory = defineClientPlugin({
packageName: '@nocobase/app-plugin-system-info',
routes,
});
export default systemInfo;
@@ -1,14 +0,0 @@
import {
defineAppRoutes,
type AppClientAppRoutesContribution,
} from '@nocobase/app-client/plugins';
const routes: AppClientAppRoutesContribution = defineAppRoutes([
{
name: 'index',
path: '/system-info',
componentLoader: () => import('./pages/system-info-page.js'),
},
]);
export default routes;
@@ -1,5 +0,0 @@
import { createClientLibraryConfig } from '@nocobase/dev-config/eslint';
export default createClientLibraryConfig({
tsconfigRootDir: import.meta.dirname,
});
@@ -1,102 +0,0 @@
{
"name": "@nocobase/app-plugin-system-info",
"displayName": "System Information",
"description": "Expose non-sensitive NocoBase server runtime information through a page and API.",
"version": "0.1.0-beta.2",
"type": "module",
"prettier": "@nocobase/dev-config/prettier",
"engines": {
"node": ">=24.0.0"
},
"sideEffects": false,
"exports": {
"./server": {
"types": "./server/index.ts",
"import": "./server/index.ts"
},
"./server/tokens": {
"types": "./server/tokens.ts",
"import": "./server/tokens.ts"
},
"./client": {
"types": "./client/index.ts",
"import": "./client/index.ts"
},
"./client/plugin": {
"types": "./client/plugin.ts",
"import": "./client/plugin.ts"
},
"./client/routes": {
"types": "./client/routes.ts",
"import": "./client/routes.ts"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md",
"CHANGELOG.md",
"skills"
],
"publishConfig": {
"access": "public",
"exports": {
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./server/tokens": {
"types": "./dist/server/tokens.d.ts",
"import": "./dist/server/tokens.js"
},
"./client": {
"types": "./dist/client/index.d.ts",
"import": "./dist/client/index.js"
},
"./client/plugin": {
"types": "./dist/client/plugin.d.ts",
"import": "./dist/client/plugin.js"
},
"./client/routes": {
"types": "./dist/client/routes.d.ts",
"import": "./dist/client/routes.js"
},
"./package.json": "./package.json"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --passWithNoTests",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix --max-warnings 0",
"format": "prettier . --write",
"format:check": "prettier . --check",
"check": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build",
"fix": "pnpm lint:fix && pnpm format"
},
"dependencies": {
"hono": "catalog:"
},
"peerDependencies": {
"@nocobase/app-client": "workspace:^",
"@nocobase/app-plugin-authentication": "workspace:^",
"@nocobase/app-server": "workspace:^",
"@nocobase/service-provider": "workspace:^",
"react": "^19.0.0"
},
"devDependencies": {
"@nocobase/app-client": "workspace:*",
"@nocobase/app-plugin-authentication": "workspace:*",
"@nocobase/app-server": "workspace:*",
"@nocobase/dev-config": "workspace:*",
"@nocobase/service-provider": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:",
"eslint": "catalog:",
"prettier": "catalog:",
"react": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
@@ -1 +0,0 @@
export { default } from './plugin.js';
@@ -1,15 +0,0 @@
import {
defineServerPlugin,
type AppServerPlugin,
} from '@nocobase/app-server/plugins';
import serviceProviders from './providers/index.js';
import routes from './routes/index.js';
const systemInfoPlugin: AppServerPlugin = defineServerPlugin({
packageName: '@nocobase/app-plugin-system-info',
serviceProviders,
routes,
});
export default systemInfoPlugin;
@@ -1,9 +0,0 @@
import type { AppPluginProviderConstructor } from '@nocobase/app-server/plugins';
import { SystemInfoProvider } from './system-info.js';
const serviceProviders: readonly AppPluginProviderConstructor[] = [
SystemInfoProvider,
];
export default serviceProviders;
@@ -1,22 +0,0 @@
import {
ServiceProvider,
type ServiceContainer,
} from '@nocobase/service-provider';
import { DefaultSystemInfoService } from '../services/system-info.js';
import { systemInfoServiceToken } from '../tokens.js';
export interface SystemInfoProviderApplication {
readonly container: ServiceContainer;
}
export class SystemInfoProvider extends ServiceProvider<SystemInfoProviderApplication> {
public readonly name: string = '@nocobase/app-plugin-system-info';
public override register(): void {
this.app.container.singleton(
systemInfoServiceToken,
() => new DefaultSystemInfoService(),
);
}
}
@@ -1,27 +0,0 @@
import { authenticationToken } from '@nocobase/app-plugin-authentication';
import type { AppPluginApplication } from '@nocobase/app-server/plugins';
import {
defineApiRoutes,
type AppApiRouteContribution,
} from '@nocobase/app-server/router';
import { Hono } from 'hono';
import { systemInfoServiceToken } from '../tokens.js';
export const apiRoutes: AppApiRouteContribution<AppPluginApplication> =
defineApiRoutes(({ container }) => {
const router = new Hono();
const authentication = container.resolve(authenticationToken);
const systemInfo = container.resolve(systemInfoServiceToken);
router.use('/system-info', authentication.required());
router.get('/system-info', (context) => context.json(systemInfo.getInfo()));
return router;
});
const routes: readonly AppApiRouteContribution<AppPluginApplication>[] = [
apiRoutes,
];
export default routes;
@@ -1,13 +0,0 @@
import type { SystemInfo, SystemInfoService } from '../tokens.js';
import packageMetadata from '../../package.json' with { type: 'json' };
export class DefaultSystemInfoService implements SystemInfoService {
public getInfo(): SystemInfo {
return {
packageName: '@nocobase/app-plugin-system-info',
version: packageMetadata.version,
nodeVersion: process.version,
serverTime: new Date().toISOString(),
};
}
}
@@ -1,20 +0,0 @@
import {
createServiceToken,
type ServiceToken,
} from '@nocobase/service-provider';
export interface SystemInfoService {
getInfo(): SystemInfo;
}
export interface SystemInfo {
readonly packageName: string;
readonly version: string;
readonly nodeVersion: string;
readonly serverTime: string;
}
export const systemInfoServiceToken: ServiceToken<SystemInfoService> =
createServiceToken<SystemInfoService>(
'@nocobase/app-plugin-system-info/service',
);
@@ -1,46 +0,0 @@
---
name: nocobase-app-plugin-system-info
description: Use the System Info App Plugin in a NocoBase application to inspect the plugin's live server metadata.
metadata:
short-description: Use System Info App Plugin
---
# System Info App Plugin
Use this Skill for the application capabilities provided by
`@nocobase/app-plugin-system-info`.
## Available surfaces
- App page: `/system-info`
- Server API: `GET /api/system-info`
## Usage
1. Open `/system-info` for an interactive view, or request `GET /api/system-info`.
2. Confirm the returned package, version, Node version, and server time.
3. Report availability failures explicitly; do not invent values.
## Rules
- The page and API require an authenticated application session.
- The fixed server metadata has no additional business authorization check.
Reassess this boundary before exposing environment values, private package
data, or user-specific information.
- The endpoint is read-only and does not expose secrets or internal tables.
- Use the supported page or API rather than reading plugin source files.
- The plugin's source Skill is under `skills/`; the App copy under
`.agents/skills/` is synchronized output and must not be edited directly.
## Ownership
- The plugin owns the `/system-info` Route, page, Service, and response shape.
- The App owns plugin registration and where it links to the page.
## Verification
- An anonymous `GET /api/system-info` returns `401`.
- An authenticated request returns the package name, package version, current
Node version, and a parseable server timestamp.
- Opening `/system-info` displays the same live response without inventing
fallback values.
@@ -1,16 +0,0 @@
import { describe, expect, it } from 'vitest';
import routes from '../client/routes.js';
describe('@nocobase/app-plugin-system-info', () => {
it('declares its client contributions', async () => {
expect(routes).toMatchObject({
parent: 'app',
routes: [{ name: 'index', path: '/system-info' }],
});
await expect(routes.routes[0]?.componentLoader()).resolves.toMatchObject({
default: expect.any(Function),
});
});
});
@@ -1,17 +0,0 @@
import { describe, expect, it } from 'vitest';
import serviceProviders from '../server/providers/index.js';
import routes from '../server/routes/index.js';
import systemInfoPlugin from '../server/plugin.js';
describe('@nocobase/app-plugin-system-info', () => {
it('declares its server contributions', () => {
expect(systemInfoPlugin).toMatchObject({
packageName: '@nocobase/app-plugin-system-info',
serviceProviders,
routes,
});
expect(systemInfoPlugin.database).toBeUndefined();
expect(systemInfoPlugin.queue).toBeUndefined();
});
});
@@ -1,32 +0,0 @@
import { ServiceContainer } from '@nocobase/service-provider';
import { describe, expect, it } from 'vitest';
import packageMetadata from '../package.json' with { type: 'json' };
import { SystemInfoProvider } from '../server/providers/system-info.js';
import { DefaultSystemInfoService } from '../server/services/system-info.js';
import { systemInfoServiceToken } from '../server/tokens.js';
describe('@nocobase/app-plugin-system-info', () => {
it('registers its service as a lazy singleton', () => {
const container = new ServiceContainer();
const provider = new SystemInfoProvider({ container });
expect(provider.name).toBe('@nocobase/app-plugin-system-info');
expect(container.resolveIfCreated(systemInfoServiceToken)).toBeUndefined();
provider.register();
const service = container.resolve(systemInfoServiceToken);
expect(service).toBeInstanceOf(DefaultSystemInfoService);
expect(service.getInfo()).toMatchObject({
packageName: '@nocobase/app-plugin-system-info',
// Read from the manifest rather than written out: `changeset version` rewrites the version during a release,
// and the release runs the tests after that rewrite, so a literal here fails every release.
version: packageMetadata.version,
nodeVersion: process.version,
});
expect(service.getInfo().serverTime).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(container.resolve(systemInfoServiceToken)).toBe(service);
});
});
@@ -1,115 +0,0 @@
import {
authenticationToken,
type Auth,
} from '@nocobase/app-plugin-authentication';
import type { AppPluginApplication } from '@nocobase/app-server/plugins';
import { ServiceContainer } from '@nocobase/service-provider';
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';
import { apiRoutes } from '../server/routes/index.js';
import {
systemInfoServiceToken,
type SystemInfoService,
} from '../server/tokens.js';
describe('@nocobase/app-plugin-system-info', () => {
it('serves system information for an authenticated request', async () => {
const router = await apiRoutes.createRouter(
createApplication(
{ required: () => async (_context, next) => next() } as unknown as Auth,
{
getInfo: () => ({
packageName: '@nocobase/app-plugin-system-info',
version: '0.0.1-test',
nodeVersion: 'v24.0.0-test',
serverTime: '2026-08-30T00:00:00.000Z',
}),
},
),
);
const response = await router.request('/system-info');
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
packageName: '@nocobase/app-plugin-system-info',
version: '0.0.1-test',
nodeVersion: 'v24.0.0-test',
serverTime: '2026-08-30T00:00:00.000Z',
});
});
it('requires authentication before reading system information', async () => {
const router = await apiRoutes.createRouter(
createApplication(
{
required: () => (context) =>
context.json(
{ code: 'UNAUTHORIZED', message: 'Authentication required' },
401,
),
} as unknown as Auth,
{
getInfo: () => {
throw new Error('The service must not run for anonymous requests.');
},
},
),
);
const response = await router.request('/system-info');
expect(response.status).toBe(401);
await expect(response.json()).resolves.toEqual({
code: 'UNAUTHORIZED',
message: 'Authentication required',
});
});
it('declares an API route contribution', () => {
expect(apiRoutes).toMatchObject({ scope: 'api' });
expect(apiRoutes.createRouter).toBeTypeOf('function');
});
it('does not apply authentication to later Route contributions', async () => {
const application = new Hono();
const pluginRouter = await apiRoutes.createRouter(
createApplication(
{
required: () => (context) =>
context.json({ code: 'UNAUTHORIZED' }, 401),
} as unknown as Auth,
{
getInfo: () => {
throw new Error('Anonymous requests must not read system info.');
},
},
),
);
application.route('/api', pluginRouter);
application.get('/api/later-plugin', (context) => context.text('later'));
expect((await application.request('/api/system-info')).status).toBe(401);
await expect(
(await application.request('/api/later-plugin')).text(),
).resolves.toBe('later');
});
});
function createApplication(
authentication: Auth,
systemInfo: SystemInfoService,
): AppPluginApplication {
const container = new ServiceContainer();
container.instance(authenticationToken, authentication);
container.instance(systemInfoServiceToken, systemInfo);
return {
appName: 'main',
publicBasePath: '',
config: { app: { name: 'main', publicBasePath: '' } },
paths: {} as never,
router: new Hono(),
container,
};
}
@@ -1,27 +0,0 @@
import { readFile } from 'node:fs/promises';
import { describe, expect, it } from 'vitest';
const packageRoot = new URL('../', import.meta.url);
const skillPath = new URL(
'skills/nocobase-app-plugin-system-info/SKILL.md',
packageRoot,
);
describe('@nocobase/app-plugin-system-info Skill', () => {
it('ships App-facing usage guidance for the real public surfaces', async () => {
const [packageSource, skillSource] = await Promise.all([
readFile(new URL('package.json', packageRoot), 'utf8'),
readFile(skillPath, 'utf8'),
]);
const packageJson = JSON.parse(packageSource) as {
readonly files?: unknown;
};
expect(packageJson.files).toContain('skills');
expect(skillSource).toContain('name: nocobase-app-plugin-system-info');
expect(skillSource).toContain('/system-info');
expect(skillSource).toContain('GET /api/system-info');
expect(skillSource).not.toMatch(/starter|placeholder|manage its settings/i);
});
});
@@ -1,11 +0,0 @@
{
"extends": "@nocobase/dev-config/tsconfig/server-library.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"resolveJsonModule": true,
"rootDir": ".",
"outDir": "dist"
},
"include": ["server/**/*.ts", "client/**/*.ts", "client/**/*.tsx"]
}
@@ -4,6 +4,12 @@ This is a NocoBase 3 application. Do not apply globally installed NocoBase 2 Ski
Do not create a plugin to add a feature. Plugins are separately published packages for capabilities shared across several applications; building one for this application's own feature adds a package boundary, a version, and a release process to work that belongs in `client/` and `server/`. Create one only when the user explicitly asks for a reusable published package.
## Default template scope
Default is the clean application starting point. It registers product capabilities but no `app-plugin-*-example` plugins, example pages, application sample services, or sample APIs. Keep runnable demonstrations in `app-template-examples`. Application-owned server route and provider lists start empty; the only application page is a localized homepage.
`database/main/` starts empty for application-owned migrations and seeds. Do not add article history, demo seeds, or compatibility copies from Examples to this template. Existing installations retain their own executed migration sources when upgrading; see `MIGRATION.md`.
## Load the development skills
`skills/nocobase-app-development/` holds the detailed guidance behind this file. Read its `SKILL.md` first — it routes to the reference that matches your task instead of making you read everything:
@@ -34,8 +40,8 @@ client/locales/ Every user-visible string
client/service-provider.ts Sidebar resources and client startup
server/routes/ HTTP endpoints
server/providers/ Services and their lifecycle
database/migrations/ Schema changes
database/seeds/ Required initial data
database/main/migrations/ Schema changes
database/main/seeds/ Required initial data
cli/commands/ Commands this application owns
tests/ Tests; never beside the source
```
@@ -134,7 +140,7 @@ Keep HTTP concerns in the route and domain logic in a service under `server/prov
### Database
Schema changes are migrations under `database/migrations/`. Data the application requires to run is a seed under `database/seeds/`. Seeds never create structure.
Schema changes are migrations under `database/main/migrations/`. Data the application requires to run is a seed under `database/main/seeds/`. Seeds never create structure.
```ts
const migration: MigrationDefinition = defineMigration({
@@ -158,7 +164,7 @@ Edit an existing migration only while the branch that introduced it is unmerged.
The exported `name` must match the filename. Apply with `pnpm migrate` and verify against a real database.
At runtime, resolve `databaseManagerToken` from the container and use `database.query()` to read and write.
At runtime, resolve `databaseManagerToken` from the container and use `database.query()` to read and write the default connection. Use `database.query('analytics')` for another connection. Application tasks use `database/<connectionName>/{migrations,seeds}` and bind to that connection explicitly; plugin tasks and default runtime access stay on `database.default`. Only managed connections run migrations or seeds. See the migrations reference for execution and upgrade rules.
### User-facing text
@@ -200,14 +206,14 @@ To customize a plugin's page, pass an option on its registration, add a source e
**You are not starting from scratch.** This application ships with plugins that already solve whole categories of requirement, and each one publishes a Skill explaining how to use it. Registration copies those Skills into `.agents/skills/`. Before implementing a feature, check whether a plugin already covers it:
| The requirement sounds like | Read the Skill for |
| ------------------------------------------------------------------------------------------------- | ------------------------------------- |
| Approvals, multi-step processes, "when X happens then Y", business rules that outlive one request | `@nocobase/app-plugin-workflow` |
| Email, IM, or in-app messages; notifying someone that something happened | `@nocobase/app-plugin-notification` |
| Roles, permissions, "user A may only see their own records", field-level or row-level access | `@nocobase/app-plugin-authorization` |
| Sign-in, registration, sessions, password reset | `@nocobase/app-plugin-authentication` |
| Uploads, attachments, file fields, previews | `@nocobase/app-plugin-file` |
| Translated text and language switching | `@nocobase/app-plugin-i18n` |
| The requirement sounds like | Read the Skill for |
| ------------------------------------------------------------------------------------------------- | -------------------------------------- |
| Approvals, multi-step processes, "when X happens then Y", business rules that outlive one request | `@nocobase/app-plugin-workflow` |
| Email, IM, or in-app messages; notifying someone that something happened | `@nocobase/app-plugin-notification` |
| Roles, permissions, "user A may only see their own records", field-level or row-level access | `@nocobase/app-plugin-authorization` |
| Sign-in, registration, sessions, password reset | `@nocobase/app-plugin-authentication` |
| File upload and metadata through Repository | `@nocobase/app-plugin-file-repository` |
| Translated text and language switching | `@nocobase/app-plugin-i18n` |
Run `pnpm plugin:skills:sync` if `.agents/skills/` is missing or looks out of date, then read the Skill for the plugin you need. It documents that plugin's public entries, the ownership boundary, and how to verify the result — which is faster and more correct than inferring an API from its source.
@@ -10,6 +10,26 @@ own `version`, and the release workflow keeps the two aligned through
`scripts/sync-template-version.mjs`. Do not edit it by hand here — a release
will overwrite it.
## System information plugin removed
`@nocobase/app-plugin-system-info` has been removed from the source workspace and template registrations. Remove its manifest entry and client/server registrations when upgrading a derived application, then reinstall dependencies and synchronize plugin Skills. The `/system-info` page and `/api/system-info` endpoint are no longer available.
## File plugin removed from template registration
The template no longer registers or directly depends on `@nocobase/app-plugin-file`. Its file inventory settings page and related API are no longer provided by default. When merging this update, remove the package from the manifest and both client/server plugin lists, then install dependencies and synchronize plugin Skills. This registration change does not delete stored files or database records. Independently registered file Repository capabilities remain unchanged.
## Default without examples
Default no longer registers example plugins or exposes article/demo pages and APIs. Examples remain available in `@nocobase/app-template-examples`. Product capabilities (authentication, authorization, files, notifications, workflow and AI) remain registered.
Unregistering an example plugin leaves its tables and migration history intact. Forward migrations ignore historical packages that are no longer participating. Do not roll back retired plugin migrations after removing their sources; restore the original plugin version first if an intentional rollback is required.
Default does not ship article migrations, seeds, or historical copies. Fresh applications start with empty `database/main/migrations` and `database/main/seeds` directories. The notification provider is registered with `{ demo: false }`, retaining notifications without its demonstration page.
When upgrading an existing application, preserve its original application-owned migrations and seeds byte-for-byte in their original directories. Template cleanup must not delete already executed sources, reset checksums, or erase history. If you choose to archive them outside the application, configure explicit migration/seed sources using their original package owner, and make those sources and dependencies available in every environment before starting or migrating. Do not repoint history to the Examples package name.
Previously created article and plugin records remain in the existing database; the article UI/API and automatic article permission initialization are removed from Default. This source upgrade does not drop tables or delete data.
## Remove duplicate plugin metadata
Remove `nocobase.plugins` from the application manifest after upgrading the CLI and template scripts together. Keep `templateKind` and `defaultTemplateVersion`. Client, Server, and CLI composition roots now determine registered plugins for bulk Skills synchronization and updates. Development watches read Server registrations; deployment packaging follows server imports. Registration still copies plugin Skills, and unregistration cleans up legacy metadata when present.
@@ -65,3 +85,7 @@ page before the overlay opens.
Put meaningful loading UI inside the loaded page or route surface instead,
where it can use the correct page, drawer, dialog, or region presentation.
## Database connections
New applications keep database source under `database/<connectionName>/{migrations,seeds}`. The default connection is also the system/plugin database. Existing top-level task configuration and old directories remain compatible; no automatic source move occurs. See [migration and seed upgrade rules](skills/nocobase-app-development/references/migrations.md#existing-applications) before moving existing files. Use `pnpm migrate --connection <name>` or `--all` to select managed connections explicitly.
@@ -4,6 +4,12 @@ A full-stack NocoBase 3 application. The server runs on `@nocobase/app-server` a
You own everything in this directory. Add pages, API routes, database tables, and business logic directly here — this is your application's source code, not a framework you extend from the outside.
## Default and Examples
Default provides the application shell and product capabilities without example plugins or business demo pages. Its application-owned server route/provider lists start empty, and new databases receive no article sample data. Use `app-template-examples` for article management and runnable plugin demonstrations.
Existing Default installations retain their data when example plugins are unregistered. If an installation already ran the article migration, retain its original migration sources as described in `MIGRATION.md` before upgrading. Do not erase migration history or delete existing data to make an upgrade start.
## Getting started
Start the development server:
@@ -61,8 +67,9 @@ server/ API server
└── embedded.ts Entry point when a host process mounts it
database/
├── migrations/ Your schema history
└── seeds/ Your required initial data
└── main/
├── migrations/ Your schema history (initially empty)
└── seeds/ Your required initial data (initially empty)
tests/ Unit and integration tests
e2e/ Browser tests needing a real server
@@ -85,12 +92,14 @@ The three files named `runtime.ts`, `plugins.ts`, and `routes.ts` are compositio
### Database
| Script | What it does |
| -------------- | ------------------------------------------------------------------------------------------ |
| `pnpm migrate` | Apply pending migrations from your `database/migrations/` and from every registered plugin |
| `pnpm seed` | Run seeds from `database/seeds/` and from registered plugins |
| Script | What it does |
| -------------- | ----------------------------------------------------------------------------------------------- |
| `pnpm migrate` | Apply pending migrations from your `database/main/migrations/` and from every registered plugin |
| `pnpm seed` | Run seeds from `database/main/seeds/` and from registered plugins |
Migrations also run automatically on startup while `database.migrations.autoRun` is `true` in `config.yml`. Run `pnpm migrate` explicitly when you want to apply a schema change without restarting.
Migrations also run automatically on startup while `database.connections.main.migrations.autoRun` is `true` in `config.yml`. Run `pnpm migrate` explicitly when you want to apply a schema change without restarting.
Database tasks support `--connection <name>` and `--all`; no argument targets `database.default`. Per-connection source and upgrade rules are documented in [migrations and seeds](skills/nocobase-app-development/references/migrations.md).
### Checks
@@ -2,86 +2,56 @@ import { Command, Flags } from '@oclif/core';
import type { Interfaces } from '@oclif/core';
import path from 'node:path';
import {
runAppMigrations,
databaseConfig,
} from '@nocobase/app-server/database';
import { resolveStandaloneAppRuntime } from '@nocobase/app-server/node';
import appRuntime from '../../server/runtime.js';
import { runDatabaseCommand } from '../database-command.js';
export default class AppMigrate extends Command {
static override summary = 'Run pending database migrations.';
static override description =
'Resolves this application runtime and applies every migration the application and its plugins declare.';
'Runs the default connection unless --connection or --all is specified. Plugins belong to the default connection. Stops on the first failure.';
static override examples: Command.Example[] = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --json',
'<%= config.bin %> <%= command.id %> --connection analytics --json',
'<%= config.bin %> <%= command.id %> --all',
];
static override flags: {
json: Interfaces.BooleanFlag<boolean>;
all: Interfaces.BooleanFlag<boolean>;
connection: Interfaces.OptionFlag<string | undefined>;
} = {
json: Flags.boolean({
default: false,
description: 'Print one machine-readable JSON result.',
}),
all: Flags.boolean({
default: false,
exclusive: ['connection'],
description:
'Run all managed connections; report external connections as skipped.',
}),
connection: Flags.string({
exclusive: ['all'],
description: 'Target a named managed connection, regardless of autoRun.',
}),
};
public async run(): Promise<void> {
const { flags } = await this.parse(AppMigrate);
const runtime = await resolveStandaloneAppRuntime(appRuntime, {
rootDir: path.resolve(import.meta.dirname, '..', '..'),
});
const result = await runAppMigrations(
runtime.appConfig.get(databaseConfig),
runtime.configPaths,
await runDatabaseCommand(
{
log: (message) => this.log(message),
logJson: (value) => this.logJson(value),
exit: (code) => this.exit(code),
},
'migrations',
flags,
async () =>
resolveStandaloneAppRuntime(appRuntime, {
rootDir: path.resolve(import.meta.dirname, '..', '..'),
}),
);
if (!result) {
if (flags.json) {
this.logJson({ ok: true, status: 'not-configured' });
return;
}
this.log('No database migrator is configured.');
return;
}
if (result.status === 'skipped') {
if (flags.json) {
this.logJson({ ok: true, status: 'skipped', reason: result.reason });
return;
}
this.log(`Database migrations skipped: ${result.reason}.`);
return;
}
if (flags.json) {
this.logJson({
ok: true,
status: 'completed',
batch: result.batch ?? 0,
executed: result.executed ?? [],
skipped: result.skipped ?? [],
});
return;
}
this.log('Database migrations completed.');
this.log(`Batch: ${result.batch ?? 0}`);
this.logNames('Executed', result.executed ?? []);
this.logNames('Skipped', result.skipped ?? []);
}
private logNames(label: string, names: readonly string[]): void {
if (names.length === 0) {
this.log(`${label}: none`);
return;
}
this.log(`${label}:`);
for (const name of names) {
this.log(`- ${name}`);
}
}
}
@@ -2,81 +2,56 @@ import { Command, Flags } from '@oclif/core';
import type { Interfaces } from '@oclif/core';
import path from 'node:path';
import { runAppSeeds, databaseConfig } from '@nocobase/app-server/database';
import { resolveStandaloneAppRuntime } from '@nocobase/app-server/node';
import appRuntime from '../../server/runtime.js';
import { runDatabaseCommand } from '../database-command.js';
export default class AppSeed extends Command {
static override summary = 'Run pending database seeds.';
static override description =
'Resolves this application runtime and applies every seed the application and its plugins declare.';
'Runs the default connection unless --connection or --all is specified. Plugins belong to the default connection. Stops on the first failure.';
static override examples: Command.Example[] = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --json',
'<%= config.bin %> <%= command.id %> --connection analytics --json',
'<%= config.bin %> <%= command.id %> --all',
];
static override flags: {
json: Interfaces.BooleanFlag<boolean>;
all: Interfaces.BooleanFlag<boolean>;
connection: Interfaces.OptionFlag<string | undefined>;
} = {
json: Flags.boolean({
default: false,
description: 'Print one machine-readable JSON result.',
}),
all: Flags.boolean({
default: false,
exclusive: ['connection'],
description:
'Run all managed connections; report external connections as skipped.',
}),
connection: Flags.string({
exclusive: ['all'],
description: 'Target a named managed connection, regardless of autoRun.',
}),
};
public async run(): Promise<void> {
const { flags } = await this.parse(AppSeed);
const runtime = await resolveStandaloneAppRuntime(appRuntime, {
rootDir: path.resolve(import.meta.dirname, '..', '..'),
});
const result = await runAppSeeds(
runtime.appConfig.get(databaseConfig),
runtime.configPaths,
await runDatabaseCommand(
{
log: (message) => this.log(message),
logJson: (value) => this.logJson(value),
exit: (code) => this.exit(code),
},
'seeds',
flags,
async () =>
resolveStandaloneAppRuntime(appRuntime, {
rootDir: path.resolve(import.meta.dirname, '..', '..'),
}),
);
if (!result) {
if (flags.json) {
this.logJson({ ok: true, status: 'not-configured' });
return;
}
this.log('No database seeder is configured.');
return;
}
if (result.status === 'skipped') {
if (flags.json) {
this.logJson({ ok: true, status: 'skipped', reason: result.reason });
return;
}
this.log(`Database seeds skipped: ${result.reason}.`);
return;
}
if (flags.json) {
this.logJson({
ok: true,
status: 'completed',
executed: result.executed ?? [],
skipped: result.skipped ?? [],
});
return;
}
this.log('Database seeds completed.');
this.logNames('Executed', result.executed ?? []);
this.logNames('Skipped', result.skipped ?? []);
}
private logNames(label: string, names: readonly string[]): void {
if (names.length === 0) {
this.log(`${label}: none`);
return;
}
this.log(`${label}:`);
for (const name of names) {
this.log(`- ${name}`);
}
}
}
@@ -0,0 +1,84 @@
import {
AppDatabaseTaskError,
databaseConfig,
runAppDatabaseTasks,
type AppDatabaseTaskKind,
type AppDatabaseTasksResult,
} from '@nocobase/app-server/database';
import type {
AppConfigAccessor,
ConfigPaths,
} from '@nocobase/app-server/config';
/** Keep single-connection JSON fields compatible while exposing per-connection bulk results. */
export async function runDatabaseCommand(
command: {
log(message: string): void;
logJson(value: unknown): void;
exit(code: number): never;
},
kind: AppDatabaseTaskKind,
flags: { json: boolean; all: boolean; connection?: string },
resolveRuntime: () => Promise<{
appConfig: AppConfigAccessor;
configPaths: ConfigPaths;
}>,
): Promise<void> {
let result: AppDatabaseTasksResult;
try {
const runtime = await resolveRuntime();
result = await runAppDatabaseTasks(
runtime.appConfig.get(databaseConfig),
runtime.configPaths,
{ kind, ...flags },
);
} catch (error) {
if (!(error instanceof AppDatabaseTaskError)) {
if (flags.json)
command.logJson({
ok: false,
status: 'failed',
connection: flags.connection,
error: error instanceof Error ? error.message : String(error),
});
else command.log(error instanceof Error ? error.message : String(error));
command.exit(1);
return;
}
result = error.result;
}
if (flags.json) {
if (flags.all || !result.ok) command.logJson(result);
else if (!result.results.length)
command.logJson({ ok: true, status: 'not-configured' });
else {
const entry = result.results[0];
command.logJson({
ok: true,
connection: entry.connection,
status: entry.status,
...(entry.reason ? { reason: entry.reason } : {}),
...(entry.status === 'completed'
? {
...(kind === 'migrations' ? { batch: entry.batch ?? 0 } : {}),
executed: entry.executed ?? [],
skipped: entry.skipped ?? [],
}
: {}),
});
}
} else {
if (!result.results.length) command.log('No database is configured.');
for (const entry of result.results) {
command.log(
`[${entry.connection}] ${kind}: ${entry.status}${entry.reason ? ` (${entry.reason})` : ''}${entry.error ? `: ${entry.error}` : ''}`,
);
if (entry.batch !== undefined) command.log(`Batch: ${entry.batch}`);
if (entry.executed)
command.log(`Executed: ${entry.executed.join(', ') || 'none'}`);
if (entry.skipped)
command.log(`Skipped: ${entry.skipped.join(', ') || 'none'}`);
}
}
if (!result.ok) command.exit(1);
}
@@ -2,11 +2,10 @@ import {
defineCliPlugins,
type AppCliPlugins,
} from '@nocobase/nb3-cli/plugins';
import cliExample from '@nocobase/app-plugin-cli-example/cli';
import workflow from '@nocobase/app-plugin-workflow/cli';
// Array order is command registration order. A plugin contributes its commands
// by appearing in this list; removing its entry and its import removes them.
const cliPlugins: AppCliPlugins = defineCliPlugins([cliExample, workflow]);
const cliPlugins: AppCliPlugins = defineCliPlugins([workflow]);
export default cliPlugins;
@@ -1,6 +1,12 @@
import type { LocaleResource } from '@nocobase/i18n';
const enUS = {
home: {
title: 'Start building your application',
description:
'Describe what you need to your AI Agent, then build pages, data models, and business workflows.',
},
appearance: {
title: 'Appearance',
mode: 'Color mode',
@@ -14,6 +20,7 @@ const enUS = {
title: 'NocoBase',
},
actions: {
close: 'Close',
save: 'Save',
cancel: 'Cancel',
confirm: 'Confirm',
@@ -1,6 +1,11 @@
import type { AppResource } from './en-US.js';
const zhCN: AppResource = {
home: {
title: '开始构建你的应用',
description: '向 AI 助手描述你的需求,逐步构建页面、数据模型和业务流程。',
},
appearance: {
title: '外观',
mode: '颜色模式',
@@ -14,6 +19,7 @@ const zhCN: AppResource = {
title: 'NocoBase',
},
actions: {
close: '关闭',
save: '保存',
cancel: '取消',
confirm: '确认',
@@ -1,37 +1,15 @@
import { apiClientToken, useService } from '@nocobase/app-client';
import { useGetIdentity } from '@refinedev/core';
import { useCallback, type ReactElement } from 'react';
import { SkillsExampleNotice } from '../components/skills-example-notice';
import { loadSkillsExampleNotice } from '../components/skills-example-notice-data';
interface AppIdentity {
email?: string;
fullName?: string;
id: string | number;
}
import { useTranslation } from '@nocobase/i18n/client';
import type { ReactElement } from 'react';
export default function HomePage(): ReactElement {
const { data: identity } = useGetIdentity<AppIdentity>();
// The Application's own API client, so the notice request follows its configured `api.baseURL`.
const api = useService(apiClientToken);
const loadNotice = useCallback(() => loadSkillsExampleNotice(api), [api]);
const { t } = useTranslation();
return (
<section className='mx-auto grid min-h-[calc(100svh-4rem)] w-full max-w-5xl place-items-center px-6 py-10'>
<div className='max-w-xl space-y-6 text-center'>
<h2 className='text-3xl font-semibold tracking-tight'>
Start building with your AI Agent
</h2>
<p className='text-muted-foreground'>
{identity?.fullName || identity?.email
? `Welcome, ${identity.fullName ?? identity.email}. `
: ''}
Describe what you need, and your AI Agent will help you build it.
</p>
<div className='text-left'>
<SkillsExampleNotice loadNotice={loadNotice} />
</div>
<h1 className='font-heading text-3xl font-semibold tracking-tight'>
{t('home.title')}
</h1>
<p className='text-muted-foreground'>{t('home.description')}</p>
</div>
</section>
);
@@ -9,15 +9,10 @@ import authorization from '@nocobase/app-plugin-authorization/client';
import install from '@nocobase/app-plugin-install/client';
import notificationProvider from '@nocobase/app-plugin-notification-provider/client';
import notificationInApp from '@nocobase/app-plugin-notification-in-app/client';
import routesExample from '@nocobase/app-plugin-routes-example/client';
import file from '@nocobase/app-plugin-file/client';
import i18n from '@nocobase/app-plugin-i18n/client';
import workflow from '@nocobase/app-plugin-workflow/client';
import notification from '@nocobase/app-plugin-notification/client';
import systemInfo from '@nocobase/app-plugin-system-info/client';
import repositoryExample from '@nocobase/app-plugin-repository-example/client';
import fileRepository from '@nocobase/app-plugin-file-repository/client';
import fileRepositoryExample from '@nocobase/app-plugin-file-repository-example/client';
// Array order is contribution order. A plugin is enabled by appearing in this
// list; removing its entry and its import disables it.
@@ -28,16 +23,11 @@ const clientPlugins: AppClientPlugins = defineClientPlugins([
authorization(),
i18n(),
install(),
notificationProvider(),
notificationProvider({ demo: false }),
notificationInApp(),
routesExample(),
file(),
workflow(),
notification(),
systemInfo(),
repositoryExample(),
fileRepository(),
fileRepositoryExample(),
]);
export default clientPlugins;
@@ -89,10 +89,24 @@ database:
debug: false
# For Oracle, use dialect: oracle and replace database with serviceName: FREEPDB1.
# For SQL Server, use dialect: mssql and configure encrypt and trustServerCertificate.
migrations:
autoRun: true
seeds:
autoRun: true
schemaManagement: managed
migrations:
autoRun: true
seeds:
autoRun: true
# Additional managed databases use database/<connectionName>/{migrations,seeds}.
# analytics:
# dialect: postgres
# host: localhost
# database: analytics
# username: postgres
# password: ${ANALYTICS_DB_PASSWORD}
# schemaManagement: managed
# migrations:
# autoRun: false
# seeds:
# autoRun: false
# Existing external databases require a runtime metadataStore and do not run tasks.
snowflake:
# Assign a unique worker ID to each concurrently running instance.
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.server.json",
"include": ["**/*.ts"]
}
@@ -16,7 +16,6 @@
"@ai-sdk/react": "2.0.0",
"@base-ui/react": "^1.6.0",
"@dicebear/core": "^9.4.3",
"@fontsource-variable/geist": "^5.3.0",
"@gchust/agent-annotations": "0.1.8",
"@hookform/resolvers": "^5.0.1",
"@nocobase/ai-employee": "workspace:^",
@@ -26,23 +25,13 @@
"@nocobase/app-plugin-ai-knowledge-base": "workspace:^",
"@nocobase/app-plugin-authentication": "workspace:^",
"@nocobase/app-plugin-authorization": "workspace:^",
"@nocobase/app-plugin-cli-example": "workspace:^",
"@nocobase/app-plugin-database-example": "workspace:^",
"@nocobase/app-plugin-file": "workspace:^",
"@nocobase/app-plugin-file-repository": "workspace:^",
"@nocobase/app-plugin-file-repository-example": "workspace:^",
"@nocobase/app-plugin-i18n": "workspace:^",
"@nocobase/app-plugin-install": "workspace:^",
"@nocobase/app-plugin-notification": "workspace:^",
"@nocobase/app-plugin-notification-in-app": "workspace:^",
"@nocobase/app-plugin-notification-provider": "workspace:^",
"@nocobase/app-plugin-notification-providers": "workspace:^",
"@nocobase/app-plugin-queue-example": "workspace:^",
"@nocobase/app-plugin-realtime-example": "workspace:^",
"@nocobase/app-plugin-routes-example": "workspace:^",
"@nocobase/app-plugin-service-provider-example": "workspace:^",
"@nocobase/app-plugin-skills-example": "workspace:^",
"@nocobase/app-plugin-system-info": "workspace:^",
"@nocobase/app-plugin-workflow": "workspace:^",
"@nocobase/app-portal-sdk": "workspace:^",
"@nocobase/app-server": "workspace:^",
@@ -63,13 +52,9 @@
"@oclif/core": "catalog:",
"@refinedev/cli": "^2.16.48",
"@refinedev/core": "catalog:",
"@refinedev/devtools": "^2.0.1",
"@refinedev/react-hook-form": "^5.0.0",
"@refinedev/react-router": "^2.0.0",
"@refinedev/react-table": "^6.0.0",
"@refinedev/rest": "^2.1.0",
"@refinedev/simple-rest": "^6.0.0",
"@shadcn/react": "^0.2.1",
"@tailwindcss/postcss": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tanstack/react-query": "catalog:",
@@ -205,8 +190,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"@nocobase/app-plugin-repository-example": "workspace:^"
}
}
@@ -9,6 +9,7 @@ import { resolveConfigWatch } from './config-watch.mjs';
import { findAvailablePort } from './ports.mjs';
import { waitForHttpReady } from './readiness.mjs';
// This entry lives in scripts/dev; all child commands run from the application root.
const rootDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
@@ -25,6 +25,6 @@ Add domain APIs here, in this application. Do not create a plugin package for a
- Bind services to tokens in a provider's `register()`. Import a token from where it is defined; two `createServiceToken` calls with the same name are two different keys.
- Declaration modules are imported by `server:inspect`. Nothing at module top level may connect to a database, start a worker, or execute a route factory. Long-lived resources belong in `start()` and are released in `shutdown()`.
- Read configuration through the typed config, not `process.env`, inside providers and routes.
- Schema changes are migrations in `../database/migrations/`, spelled out explicitly and never importing an evolving definition.
- Schema changes are migrations in `../database/main/migrations/`, spelled out explicitly and never importing an evolving definition.
Before finishing, run `pnpm typecheck`, `pnpm test`, `pnpm lint`, and `pnpm build`. `pnpm server:inspect --json` prints the composition snapshot. It reports wiring, not correctness — cover behavior with tests.
@@ -2,50 +2,30 @@ import authentication from '@nocobase/app-plugin-authentication/server';
import aiEmployee from '@nocobase/app-plugin-ai-employee/server/plugin';
import aiKnowledgeBase from '@nocobase/app-plugin-ai-knowledge-base/server/plugin';
import authorization from '@nocobase/app-plugin-authorization/server';
import databaseExample from '@nocobase/app-plugin-database-example/server';
import file from '@nocobase/app-plugin-file/server';
import i18n from '@nocobase/app-plugin-i18n/server';
import install from '@nocobase/app-plugin-install/server';
import notification from '@nocobase/app-plugin-notification/server';
import notificationInApp from '@nocobase/app-plugin-notification-in-app/server';
import notificationProviders from '@nocobase/app-plugin-notification-providers/server';
import queueExample from '@nocobase/app-plugin-queue-example/server';
import realtimeExample from '@nocobase/app-plugin-realtime-example/server';
import routesExample from '@nocobase/app-plugin-routes-example/server';
import serviceProviderExample from '@nocobase/app-plugin-service-provider-example/server';
import workflow from '@nocobase/app-plugin-workflow/server';
import systemInfo from '@nocobase/app-plugin-system-info/server';
import skillsExample from '@nocobase/app-plugin-skills-example/server';
import {
defineServerPlugins,
type AppServerPlugins,
} from '@nocobase/app-server/plugins';
import repositoryExample from '@nocobase/app-plugin-repository-example/server';
import fileRepository from '@nocobase/app-plugin-file-repository/server';
import fileRepositoryExample from '@nocobase/app-plugin-file-repository-example/server';
const serverPlugins: AppServerPlugins = defineServerPlugins([
authentication,
aiEmployee,
aiKnowledgeBase,
authorization,
databaseExample,
file,
i18n,
install,
notification,
notificationInApp,
notificationProviders,
queueExample,
realtimeExample,
routesExample,
serviceProviderExample,
workflow,
systemInfo,
skillsExample,
repositoryExample,
fileRepository,
fileRepositoryExample,
]);
export default serverPlugins;
@@ -1,14 +1,5 @@
import type { ApplicationServiceProviderConstructor } from '@nocobase/app-server/application';
import AppExampleProvider from './app-example.js';
export {
appExampleServiceToken,
type AppExampleService,
} from './app-example.js';
const serviceProviders: readonly ApplicationServiceProviderConstructor[] = [
AppExampleProvider,
];
const serviceProviders: readonly ApplicationServiceProviderConstructor[] = [];
export default serviceProviders;
@@ -1,61 +1,6 @@
import type { Application } from '@nocobase/app-server/application';
import {
defineApiRoutes,
defineRootRoutes,
type AppApiRouteContribution,
type AppRouteContribution,
type AppRootRouteContribution,
} from '@nocobase/app-server/router';
import { Hono } from 'hono';
import type { AppRouteContribution } from '@nocobase/app-server/router';
import { appExampleServiceToken } from '../providers/index.js';
export const apiRoutes: AppApiRouteContribution<Application> = defineApiRoutes(
(app) => {
const router = new Hono();
router.get('/example', (context) => {
const exampleService = app.container.resolve(appExampleServiceToken);
return context.json({
scope: 'api',
message: exampleService.getMessage(),
});
});
return router;
},
);
export const rootRoutes: AppRootRouteContribution<Application> =
defineRootRoutes((app) => {
const router = new Hono();
router.get('/example', (context) => {
const exampleService = app.container.resolve(appExampleServiceToken);
return context.html(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Application Route Example</title>
</head>
<body>
<main>
<h1>Application Route Example</h1>
<p>${exampleService.getMessage()}</p>
</main>
</body>
</html>`);
});
return router;
});
const routes: readonly AppRouteContribution<Application>[] = [
apiRoutes,
rootRoutes,
];
const routes: readonly AppRouteContribution<Application>[] = [];
export default routes;
@@ -15,6 +15,8 @@ Use this Skill when building a feature in this application: a page, an endpoint,
Do not use it to develop a published plugin package. Plugin development has its own protocol and lives in a separate repository.
Default ships with a localized homepage, empty application-owned server route/provider lists, and no example plugins or demo data. Use `app-template-examples` to explore runnable demonstrations. `database/main/` starts empty; do not copy example history into Default.
## Before you start
Read the application's `AGENTS.md` first for the rules that apply everywhere. This Skill's references are the detail behind it.
@@ -37,14 +39,14 @@ Build the feature in the application. Do not run a plugin generator, create a `p
This application ships with plugins that already implement whole categories of requirement, each publishing its own Skill under `.agents/skills/` (run `pnpm plugin:skills:sync` if that directory is missing or stale):
| The requirement sounds like | Read the Skill for |
| -------------------------------------------------------- | ------------------------------------- |
| Approvals, multi-step processes, "when X happens then Y" | `@nocobase/app-plugin-workflow` |
| Email, IM, or in-app messages | `@nocobase/app-plugin-notification` |
| Roles, permissions, per-user or per-record access | `@nocobase/app-plugin-authorization` |
| Sign-in, registration, sessions | `@nocobase/app-plugin-authentication` |
| Uploads, attachments, file fields | `@nocobase/app-plugin-file` |
| Translated text and language switching | `@nocobase/app-plugin-i18n` |
| The requirement sounds like | Read the Skill for |
| -------------------------------------------------------- | -------------------------------------- |
| Approvals, multi-step processes, "when X happens then Y" | `@nocobase/app-plugin-workflow` |
| Email, IM, or in-app messages | `@nocobase/app-plugin-notification` |
| Roles, permissions, per-user or per-record access | `@nocobase/app-plugin-authorization` |
| Sign-in, registration, sessions | `@nocobase/app-plugin-authentication` |
| File upload and metadata through Repository | `@nocobase/app-plugin-file-repository` |
| Translated text and language switching | `@nocobase/app-plugin-i18n` |
Read the relevant Skill before writing the feature. Implementing a permission system, a notification sender, or a scheduler by hand when a registered plugin provides one is the most expensive mistake available here.
@@ -76,7 +78,7 @@ Business code belongs in a small, stable set of places:
```text
client/routes.ts, client/pages/, client/components/, client/locales/,
client/service-provider.ts, server/routes/, server/providers/,
database/migrations/, database/seeds/, tests/
database/main/migrations/, database/main/seeds/, tests/
```
Everything else — `client/routing/`, `client/shell/`, `client/layouts/`, `client/theme/`, the server entry points, the build scripts, the tsconfigs — is the framework structure the template provides and evolves. Prefer the mechanism the system already offers: most work that looks like it needs a change there does not.
@@ -1,15 +1,17 @@
# Migrations and seeds
Migrations under `database/migrations/` are the application's schema history. Seeds under `database/seeds/` insert records the application requires to run.
Migrations under `database/main/migrations/` are the application's schema history. Seeds under `database/main/seeds/` insert records the application requires to run.
Migrations create structure. Seeds never do.
`database/tsconfig.json` extends the server configuration so editor tooling and ESLint recognize migrations and seeds as Node source. Keep it in place when adding connection directories.
## Writing a migration
Name the file with a sortable timestamp prefix. The exported `name` must match the filename:
```ts
// database/migrations/202609020001_create_orders.ts
// database/main/migrations/202609020001_create_orders.ts
import { defineMigration, type MigrationDefinition } from '@nocobase/db';
const migration: MigrationDefinition = defineMigration({
@@ -87,7 +89,38 @@ Set `tableName` or `columnName` explicitly when you need to override the derivat
pnpm migrate
```
This applies pending migrations from this application and from every registered plugin, ordered by name across all sources. Migrations also run on startup while `database.migrations.autoRun` is `true` in `config.yml`.
This applies pending migrations for `database.default`, including registered plugins, ordered by name across all sources. The template defaults to `main`. Plugin migrations, seeds and runtime default reads/writes always use this same connection; changing `database.default` changes the application system database.
## Multiple connections
Use `database/<connectionName>/migrations` and `database/<connectionName>/seeds`. Each name must match a configured connection and contain only letters, digits, underscores or hyphens. Directories do not register connections. A connection without application-owned database source needs no empty directory.
Configure `connections.<name>.migrations` and `.seeds` in the database section. Each accepts `autoRun`, `directory` or `sources`, `packageName`, `tableName`, `lockTableName`, and `extensions`. Explicit paths resolve relative to the application root and must exist when executed. Without an explicit path the application resolves the per-connection directory under its runtime database directory, including `dist/database` after compilation. Custom sources replace the application's conventional source; registered plugin sources are still added only on the default connection.
The default connection runs migrations and seeds automatically unless disabled; other connections default to `autoRun: false`. Startup runs the default connection first, then other names in stable ascending order, migrations before seeds within each connection. A failure stops startup; completed database changes remain committed. No cross-connection transaction or rollback is provided.
```bash
pnpm migrate --connection analytics
pnpm seed --connection analytics
pnpm migrate --all --json
pnpm seed --all --json
```
Manual execution ignores `autoRun`. `--connection` and `--all` are mutually exclusive. `--all` uses the same connection order and stops at the first failure. Its JSON result includes completed, skipped, failed and not-run entries; failures exit nonzero. Single-connection JSON retains `status`, `batch` (migrations), `executed`, and `skipped`, with an added `connection` field.
`schemaManagement: external` describes ownership, not read-only credentials. Startup and `--all` skip external connections; explicitly targeting one for migrations or seeds is an error. Runtime access still requires an explicit metadata store. Seeds also use history and lock tables, so they are not a workaround for external schema ownership.
Use one managed connection per physical database/schema. Identical configured targets are rejected before execution, even when only one is selected. Hostname aliases, symlinks and driver-specific routing can hide a shared target; do not configure these as independent managed databases. Distinct history or lock table names alone do not isolate collection metadata and schema ownership.
## Existing applications
Old top-level `database.migrations` and `database.seeds` remain accepted and apply only to the current default connection, never automatically to `main`. Legacy fields override matching per-connection fields, including existing `DB_MIGRATIONS_*` / `DB_SEEDS_*` environment settings. Connection environment variables such as `DB_DATABASE` retain their existing `main` mapping. Prefer the new connection settings for new configuration; remove old overrides when adopting them.
Different explicit old/new directories, two explicit source arrays, or a combined directory and source array are configuration errors. Without an explicit source, the default connection falls back to `database/migrations` or `database/seeds` when present. If both old and new conventional directories exist, execution fails until you select one explicitly. Non-default connections never use the legacy directory.
No command moves source files or rewrites history. When moving a directory, preserve the target database, package identity, filenames, exported names, file contents and history/lock table configuration. Do not copy already-applied migrations to another database as an upgrade. Verify a repeat run executes nothing. An explicitly configured source missing from a release is an error; an absent conventional source is permitted for applications with no owned tasks. Verify release packaging separately to catch accidental omission of conventional sources.
Checksums are based on file contents. Moving unchanged files preserves them; compiling TypeScript into different JavaScript is not guaranteed to preserve an existing development database's checksums. Verify upgrades against the same execution artifact format, and never rewrite checksums to bypass a mismatch.
`pnpm server:inspect --json` lists the plugins that contribute migrations.
@@ -96,7 +129,7 @@ This applies pending migrations from this application and from every registered
Seeds are for records the application cannot run without — a default configuration row, a fixed system record. Not demo data, and not test fixtures.
```ts
// database/seeds/202609020002_seed_order_statuses.ts
// database/main/seeds/202609020002_seed_order_statuses.ts
import { defineSeed, type SeedDefinition } from '@nocobase/db';
const seed: SeedDefinition = defineSeed({
@@ -14,6 +14,7 @@ import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Hono } from 'hono';
import type { Knex } from 'knex';
import { createDefaultCachingConfig } from '@nocobase/caching';
import { CachingProvider } from '@nocobase/app-server/caching';
import { DriveProvider } from '@nocobase/app-server/drive';
@@ -110,6 +111,10 @@ interface FetchableResource {
type TestApp = Application<AppConfig> & CloseableResource;
type TestStandaloneServerOptions = StandaloneServerOptions & {
readonly clientDir?: string;
};
interface RegisteredTestDisposer {
name: string;
dispose: AppDisposer;
@@ -121,12 +126,6 @@ const tempDirs: string[] = [];
const TEST_REALTIME_TOPIC = 'test:realtime';
const require = createRequire(import.meta.url);
function declaredPluginVersion(packageName: string): string {
return (
require(`${packageName}/package.json`) as { readonly version: string }
).version;
}
function requestApp(
app: FetchableResource,
input: Request | string | URL,
@@ -331,6 +330,11 @@ describe('app server', () => {
{
...appRuntime,
plugins: defineServerPlugins<AppConfig>([]),
routes: [
defineApiRoutes(() =>
new Hono().get('/test-runtime', (c) => c.json({ ok: true })),
),
],
serviceProviders: [
...appRuntime.serviceProviders,
TestRuntimeApplicationProvider,
@@ -358,65 +362,9 @@ describe('app server', () => {
await app.start();
expect(providerCalls).toEqual(['plugin', 'plugin service']);
const apiResponse = await requestApp(app, 'http://localhost/api/example');
const rootResponse = await requestApp(app, 'http://localhost/example');
expect(apiResponse.status).toBe(200);
await expect(apiResponse.json()).resolves.toEqual({
scope: 'api',
message: 'Hello from the application provider',
});
expect(rootResponse.status).toBe(200);
expect(rootResponse.headers.get('content-type')).toContain('text/html');
const rootHtml = await rootResponse.text();
expect(rootHtml).toContain('<h1>Application Route Example</h1>');
expect(rootHtml).toContain('Hello from the application provider');
});
it('loads and explicitly reloads plugin config from the selected YAML file', async () => {
const configDir = mkdtempSync(
path.join(tmpdir(), 'nocobase-app-template-default-config-'),
);
tempDirs.push(configDir);
const configPath = path.join(configDir, 'config.yaml');
writeFileSync(configPath, 'heartbeat:\n enabled: false\n');
const runtime = await resolveAppRuntime(
appRuntime,
createEmbeddedTestScope({
id: 'app-template-default',
basePath: '/embedded-app-template-default',
configPath,
}),
);
expect(runtime.appConfig.raw()).toMatchObject({
heartbeat: { enabled: false },
});
writeFileSync(configPath, 'heartbeat:\n enabled: true\n');
await expect(runtime.appConfig.reload()).resolves.toMatchObject({
changedNamespaces: ['heartbeat'],
});
expect(runtime.appConfig.raw()).toMatchObject({
heartbeat: { enabled: true },
});
});
it('does not leak plugin authentication into application-owned API routes', async () => {
const app = await createEmbeddedServer(
createEmbeddedTestScope({
id: 'app-template-default',
basePath: '/embedded-app-template-default',
}),
);
const apiResponse = await requestApp(app, 'http://localhost/api/example');
expect(apiResponse.status).toBe(200);
await expect(apiResponse.json()).resolves.toEqual({
scope: 'api',
message: 'Hello from the application provider',
});
const response = await requestApp(app, 'http://localhost/api/test-runtime');
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ ok: true });
});
it('routes authentication requests through the configured public auth URL', async () => {
@@ -654,117 +602,36 @@ describe('app server', () => {
expect(viteRequestCount).toBe(0);
});
it('keeps plugin API and Root Routes authenticated by their owning contributions', async () => {
it('starts a fresh Default without example tables or APIs', async () => {
const clientDir = mkdtempSync(
path.join(tmpdir(), 'nocobase-default-fresh-client-'),
);
tempDirs.push(clientDir);
const spaContent = '<main>Fresh Default test application</main>';
// The fallback must not depend on a previous local client build.
writeFileSync(path.join(clientDir, 'index.html'), spaContent);
const app = trackCloseable(
await createInstalledStandaloneServer({ viteDevUrl: false }),
await createInstalledStandaloneServer({
viteDevUrl: false,
env: { APP_CONFIG_FILE: 'config.example.yml' },
clientDir,
}),
);
const database = app.application.container.resolve(databaseManagerToken);
const client = await database.connection('main').client<Knex>();
expect(await client.schema.hasTable('articles')).toBe(false);
const tables = await client('sqlite_master')
.where({ type: 'table' })
.pluck('name');
expect(tables.some((name: string) => name.includes('example'))).toBe(false);
const baseUrl = `http://localhost${app.application.publicBasePath}`;
const anonymous = await requestApp(app, `${baseUrl}/api/routes-example`);
const anonymousRoot = await requestApp(
app,
`${baseUrl}/routes-example/root`,
);
expect(anonymous.status).toBe(401);
expect(anonymousRoot.status).toBe(401);
const signIn = await requestApp(
app,
`${baseUrl}/api/auth/sign-in/username`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'nocobase', password: 'admin123' }),
},
);
const cookie = signIn.headers.get('set-cookie');
expect(signIn.status).toBe(200);
const response = await requestApp(app, `${baseUrl}/api/routes-example`, {
headers: { cookie: cookie ?? '' },
});
const rootResponse = await requestApp(
app,
`${baseUrl}/routes-example/root`,
{ headers: { cookie: cookie ?? '' } },
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
plugin: '@nocobase/app-plugin-routes-example',
scope: 'api',
});
expect(rootResponse.status).toBe(200);
await expect(rootResponse.json()).resolves.toMatchObject({
plugin: '@nocobase/app-plugin-routes-example',
scope: 'root',
});
});
it('loads the system info API from the registered app plugin', async () => {
const app = trackCloseable(
await createInstalledStandaloneServer({ viteDevUrl: false }),
);
const baseUrl = `http://localhost${app.application.publicBasePath}`;
const signIn = await requestApp(
app,
`${baseUrl}/api/auth/sign-in/username`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'nocobase', password: 'admin123' }),
},
);
const cookie = signIn.headers.get('set-cookie');
expect(signIn.status).toBe(200);
expect(cookie).toContain('.session_token=');
const response = await requestApp(app, `${baseUrl}/api/system-info`, {
headers: { cookie: cookie ?? '' },
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
packageName: '@nocobase/app-plugin-system-info',
version: declaredPluginVersion('@nocobase/app-plugin-system-info'),
nodeVersion: process.version,
serverTime: expect.any(String),
});
});
it('loads the Skills example API with its owning authentication boundary', async () => {
const app = trackCloseable(
await createInstalledStandaloneServer({ viteDevUrl: false }),
);
const baseUrl = `http://localhost${app.application.publicBasePath}`;
const anonymous = await requestApp(
app,
`${baseUrl}/api/skills-example/notice`,
);
expect(anonymous.status).toBe(401);
const signIn = await requestApp(
app,
`${baseUrl}/api/auth/sign-in/username`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'nocobase', password: 'admin123' }),
},
);
const cookie = signIn.headers.get('set-cookie');
expect(signIn.status).toBe(200);
const response = await requestApp(
app,
`${baseUrl}/api/skills-example/notice`,
{ headers: { cookie: cookie ?? '' } },
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
description: 'This notice was provided by a NocoBase plugin.',
title: 'Plugin Skills are working',
tone: 'success',
});
for (const endpoint of ['articles', 'example', 'routes-example']) {
const response = await requestApp(app, `${baseUrl}/api/${endpoint}`);
// Unregistered GET paths reach the application's existing SPA fallback.
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/html');
await expect(response.text()).resolves.toContain(spaContent);
}
});
it('redirects HTML navigation to installation in install mode', async () => {
@@ -798,56 +665,6 @@ describe('app server', () => {
);
});
it('dispatches jobs from enabled app plugins', async () => {
vi.stubEnv('QUEUE_JOBS_AUTO_LOAD', 'false');
const app = trackCloseable(
await createInstalledStandaloneServer({ viteDevUrl: false }),
);
const baseUrl = `http://localhost${app.application.publicBasePath}`;
const anonymous = await requestApp(app, `${baseUrl}/api/queue-example`);
expect(anonymous.status).toBe(401);
const signIn = await requestApp(
app,
`${baseUrl}/api/auth/sign-in/username`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'nocobase', password: 'admin123' }),
},
);
const cookie = signIn.headers.get('set-cookie');
expect(signIn.status).toBe(200);
const response = await requestApp(app, `${baseUrl}/api/queue-example`, {
headers: { cookie: cookie ?? '' },
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
jobId: expect.any(String),
job: 'QueueExample',
queue: 'default',
syncExecutions: 1,
});
});
it('exposes services registered by enabled plugin providers', async () => {
const app = trackCloseable(
await createIsolatedStandaloneServer({ viteDevUrl: false }),
);
const response = await requestApp(
app,
`http://localhost${app.application.publicBasePath}/api/service-provider-example/status`,
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
service: '@nocobase/app-plugin-service-provider-example',
status: 'ready',
startedAt: expect.any(String),
});
});
it('mounts standalone app-local routes behind the public base path', async () => {
const app = trackCloseable(
await createIsolatedStandaloneServer({ viteDevUrl: false }),
@@ -1472,7 +1289,7 @@ function createEmbeddedTestScope(
}
async function createIsolatedStandaloneServer(
options: StandaloneServerOptions = {},
options: TestStandaloneServerOptions = {},
): Promise<StandaloneServer> {
const sourceRoot = path.resolve(import.meta.dirname, '../..');
const databaseDir = mkdtempSync(
@@ -1492,14 +1309,14 @@ async function createIsolatedStandaloneServer(
rootDir: sourceRoot,
serverDir: path.join(sourceRoot, 'server'),
databaseDir: path.join(sourceRoot, 'database'),
clientDir: path.join(sourceRoot, 'dist/client'),
clientDir: options.clientDir ?? path.join(sourceRoot, 'dist/client'),
storageDir: path.join(sourceRoot, 'storage'),
},
});
}
function createInstalledStandaloneServer(
options: StandaloneServerOptions = {},
options: TestStandaloneServerOptions = {},
): Promise<StandaloneServer> {
return createIsolatedStandaloneServer({
...options,
@@ -1517,19 +1334,11 @@ function createEmbeddedPluginFixture(rootDir: string): void {
const pluginPackages = [
'@nocobase/app-plugin-authentication',
'@nocobase/app-plugin-authorization',
'@nocobase/app-plugin-database-example',
'@nocobase/app-plugin-file',
'@nocobase/app-plugin-i18n',
'@nocobase/app-plugin-install',
'@nocobase/app-plugin-notification',
'@nocobase/app-plugin-notification-in-app',
'@nocobase/app-plugin-notification-providers',
'@nocobase/app-plugin-queue-example',
'@nocobase/app-plugin-realtime-example',
'@nocobase/app-plugin-routes-example',
'@nocobase/app-plugin-service-provider-example',
'@nocobase/app-plugin-skills-example',
'@nocobase/app-plugin-system-info',
'@nocobase/app-plugin-workflow',
];
writeFileSync(
@@ -94,76 +94,8 @@ describe('app client runtime', () => {
text: 'Configured application',
});
expect(app.refineConfig.authProvider).toBeDefined();
expect(app.refineConfig.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'repository-example-customers',
list: '/repository-example/crm',
meta: expect.objectContaining({
i18nNs: '@nocobase/app-plugin-repository-example',
}),
}),
expect.objectContaining({
name: 'repository-example-order-list',
list: '/repository-example/orders',
meta: expect.objectContaining({
i18nNs: '@nocobase/app-plugin-repository-example',
}),
}),
]),
);
const resources = app.refineConfig.resources ?? [];
expect(resources).toContainEqual(
expect.objectContaining({
name: 'repository-example-relation-mutations',
list: '/repository-example/relation-mutations',
meta: expect.objectContaining({ parent: 'repository-example-api' }),
}),
);
expect(resources).toContainEqual(
expect.objectContaining({
name: 'repository-example-atomic',
list: '/repository-example/atomic',
meta: expect.objectContaining({ parent: 'repository-example-api' }),
}),
);
expect(resources).toContainEqual(
expect.objectContaining({
name: 'repository-example-find-many',
list: '/repository-example/find-many',
meta: expect.objectContaining({ parent: 'repository-example-api' }),
}),
);
expect(resources).toContainEqual(
expect.objectContaining({
name: 'repository-example-aggregate',
list: '/repository-example/aggregate',
meta: expect.objectContaining({ parent: 'repository-example-api' }),
}),
);
for (const [group, children] of [
[
'repository-example-crm',
['repository-example-customers', 'repository-example-contacts'],
],
[
'repository-example-orders',
[
'repository-example-order-list',
'repository-example-items',
'repository-example-products',
],
],
] as const) {
expect(
resources.find((resource) => resource.name === group)?.list,
).toBeUndefined();
expect(
resources
.filter((resource) => resource.meta?.parent === group)
.map((resource) => resource.name),
).toEqual(children);
}
expect(app.refineConfig.notificationProvider).toBeDefined();
expect(app.refineConfig.resources ?? []).toEqual([]);
await app.shutdown();
});
@@ -0,0 +1,149 @@
// @vitest-environment node
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { afterEach, expect, it, vi } from 'vitest';
import { runDatabaseCommand } from '../../cli/database-command.js';
import {
createConfigPaths,
type AppConfigAccessor,
} from '@nocobase/app-server/config';
import type { AppDatabaseConfig } from '@nocobase/app-server/database';
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0))
rmSync(root, { recursive: true, force: true });
});
function fixture() {
const parent = path.resolve('tests/.tmp');
mkdirSync(parent, { recursive: true });
const root = mkdtempSync(path.join(parent, 'cli-database-'));
roots.push(root);
const paths = createConfigPaths({ rootDir: root });
const database: AppDatabaseConfig = {
default: 'main',
connections: {
main: { dialect: 'sqlite', filename: paths.storage('main.sqlite') },
analytics: {
dialect: 'sqlite',
filename: paths.storage('analytics.sqlite'),
migrations: { autoRun: false },
},
erp: {
dialect: 'sqlite',
filename: paths.storage('erp.sqlite'),
schemaManagement: 'external',
},
},
};
const runtime = async () => ({
appConfig: { get: () => database } as unknown as AppConfigAccessor,
configPaths: paths,
});
const command = {
log: vi.fn(),
logJson: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
function migration(connection: string, fail = false) {
const directory = paths.database(`${connection}/migrations`);
mkdirSync(directory, { recursive: true });
writeFileSync(
path.join(directory, '001_create.ts'),
`import { defineMigration } from '@nocobase/db';
export default defineMigration({ name: '001_create', async up({ builder }) {
${fail ? "throw new Error('failed migration');" : "await builder.createCollection('rows', c => c.increments('id'));"}
}, async down({ builder }) { await builder.dropCollection('rows'); } });`,
);
}
return { runtime, command, migration };
}
it('retains single-connection JSON fields and honors manual selection', async () => {
const { runtime, command, migration } = fixture();
migration('analytics');
await runDatabaseCommand(
command,
'migrations',
{ json: true, all: false, connection: 'analytics' },
runtime,
);
expect(command.logJson).toHaveBeenCalledWith({
ok: true,
status: 'completed',
connection: 'analytics',
batch: 1,
executed: ['001_create'],
skipped: [],
});
expect(command.exit).not.toHaveBeenCalled();
});
it('reports external skips in all-connection JSON', async () => {
const { runtime, command } = fixture();
await runDatabaseCommand(
command,
'seeds',
{ json: true, all: true },
runtime,
);
expect(command.logJson).toHaveBeenCalledWith(
expect.objectContaining({
ok: true,
results: expect.arrayContaining([
{
connection: 'erp',
kind: 'seeds',
status: 'skipped',
reason: 'external',
},
]),
}),
);
});
it('prints partial failure JSON before exiting nonzero', async () => {
const { runtime, command, migration } = fixture();
migration('main');
migration('analytics', true);
await expect(
runDatabaseCommand(
command,
'migrations',
{ json: true, all: true },
runtime,
),
).rejects.toThrow('exit 1');
expect(command.logJson).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
results: [
expect.objectContaining({ connection: 'main', status: 'completed' }),
expect.objectContaining({ connection: 'analytics', status: 'failed' }),
expect.objectContaining({ connection: 'erp', status: 'not-run' }),
],
}),
);
});
it('reports invalid selection as JSON and exits nonzero', async () => {
const { runtime, command } = fixture();
await expect(
runDatabaseCommand(
command,
'migrations',
{ json: true, all: false, connection: 'unknown' },
runtime,
),
).rejects.toThrow('exit 1');
expect(command.logJson).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
connection: 'unknown',
error: expect.stringContaining('Unknown'),
}),
);
});
@@ -108,16 +108,6 @@ describe('client inspection', () => {
id: '@nocobase/app-plugin-install:install',
path: '/install',
},
{
auth: 'required',
id: '@nocobase/app-plugin-notification-provider:demo',
path: '/notification-provider',
},
{
auth: 'required',
id: '@nocobase/app-plugin-routes-example:index',
path: '/routes-example',
},
{
auth: 'required',
id: '@nocobase/app-plugin-workflow:workflow-detail',
@@ -128,91 +118,6 @@ describe('client inspection', () => {
id: '@nocobase/app-plugin-workflow:workflow-run-detail',
path: '/settings/automation/workflow-runs/:runId',
},
{
auth: 'required',
id: '@nocobase/app-plugin-system-info:index',
path: '/system-info',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:sort',
path: '/repository-example/sort',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:select-combine',
path: '/repository-example/select-combine',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:relation-mutations',
path: '/repository-example/relation-mutations',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:find-many',
path: '/repository-example/find-many',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:aggregate',
path: '/repository-example/aggregate',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:atomic',
path: '/repository-example/atomic',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:crm',
path: '/repository-example/crm',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:orders',
path: '/repository-example/orders',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:contacts',
path: '/repository-example/crm/contacts',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:items',
path: '/repository-example/orders/items',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:products',
path: '/repository-example/orders/products',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:crm-detail',
path: '/repository-example/crm/details/:recordId',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:orders-detail',
path: '/repository-example/orders/details/:recordId',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:contacts-detail',
path: '/repository-example/crm/contacts/details/:recordId',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:items-detail',
path: '/repository-example/orders/items/details/:recordId',
},
{
auth: 'required',
id: '@nocobase/app-plugin-repository-example:products-detail',
path: '/repository-example/orders/products/details/:recordId',
},
]);
expect(
inspection.reactProviders.map(({ id, order }) => ({ id, order })),
@@ -222,10 +127,6 @@ describe('client inspection', () => {
id: '@nocobase/app-plugin-notification-provider:notification-host',
order: 2,
},
{
id: '@nocobase/app-plugin-routes-example:routes-example',
order: 3,
},
]);
expect(
inspection.serviceProviders.map(({ packageName, order }) => ({
@@ -240,8 +141,7 @@ describe('client inspection', () => {
{ packageName: '@nocobase/app-plugin-notification-provider', order: 5 },
{ packageName: '@nocobase/app-plugin-workflow', order: 6 },
{ packageName: '@nocobase/app-plugin-notification', order: 7 },
{ packageName: '@nocobase/app-plugin-repository-example', order: 8 },
{ packageName: '@nocobase/app-plugin-file-repository', order: 9 },
{ packageName: '@nocobase/app-plugin-file-repository', order: 8 },
]);
expect(inspection.configs[0]).toMatchObject({
kind: 'factory',
@@ -20,27 +20,13 @@ describe('Server inspection', () => {
packageName: '@nocobase/app-plugin-authentication',
});
expect(
inspection.plugins.find(
({ packageName }) =>
packageName === '@nocobase/app-plugin-queue-example',
inspection.plugins.some(({ packageName }) =>
packageName.endsWith('-example'),
),
).toMatchObject({ contributions: { jobLocations: 1 } });
expect(
inspection.serviceProviders.find(
({ packageName }) => packageName === '@nocobase/app-plugin-system-info',
),
).toMatchObject({ constructorName: 'SystemInfoProvider' });
).toBe(false);
expect(inspection.routes.map(({ order }) => order)).toEqual(
inspection.routes.map((_route, index) => index + 1),
);
expect(
inspection.routes
.filter(
({ packageName }) =>
packageName === '@nocobase/app-plugin-routes-example',
)
.map(({ scope }) => scope),
).toEqual(['root', 'api']);
expect(
inspection.routes.some(
({ packageName, scope }) =>
@@ -18,11 +18,7 @@ const templateName = JSON.parse(
* Sources `create-app` rewrites the template's package name in, mirroring `PACKAGE_NAME_SOURCES` in
* `packages/tools/create-app/src/lib/scaffold.ts`.
*/
const REWRITTEN_SOURCES = [
'client/runtime.ts',
'client/service-provider.ts',
'server/providers/app-example.ts',
];
const REWRITTEN_SOURCES = ['client/runtime.ts', 'client/service-provider.ts'];
/** Source trees shipped to a generated application, per the `files` field. */
const SHIPPED_SOURCE_DIRECTORIES = ['client', 'server', 'database', 'scripts'];
@@ -0,0 +1,42 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
/.extension-state/
/src/extensions/
/registry.json
/coverage/
/playwright-report/
/test-results/
/.pnpm-store/
/storage/
/.agents/
# Local application configuration
/config.yml
# E2E credentials
.env.e2e
.env.e2e.local
# Editor directories and files
.vscode/_
!.vscode/extensions.json
.idea
.DS_Store
_.suo
_.ntvs_
_.njsproj
_.sln
\*.sw?
@@ -0,0 +1,26 @@
node_modules/
.agents/
.pnpm-store/
dist-ssr/
logs/
*.log
config.yml
!config.example.yml
.env.e2e
.env.e2e.local
!.env.e2e.example
coverage/
playwright-report/
test-results/
client-old/
registry/
registry.config.json
registry.json
public/r/
scripts/registry.mjs
sdk/
.github/
!.gitignore
@@ -0,0 +1 @@
strict-peer-dependencies=false
@@ -0,0 +1,10 @@
client-old/
registry/
# Generated by the package manager, and reformatting it would produce a file pnpm did not write.
# A generated application has none of the directories above but does have this one, which is why
# this file ships in the package: without it `pnpm format:check` fails on a freshly created app.
pnpm-lock.yaml
# Applied seed: preserve its recorded source checksum.
database/main/seeds/202609080001_seed_articles.ts
@@ -0,0 +1,247 @@
# Examples Application Development Guidelines
This is a NocoBase 3 application. Do not apply globally installed NocoBase 2 Skills. You are building the application itself — its pages, its API, its database tables. Everything under this directory is application-owned source code that you may edit directly.
Do not create a plugin to add a feature. Plugins are separately published packages for capabilities shared across several applications; building one for this application's own feature adds a package boundary, a version, and a release process to work that belongs in `client/` and `server/`. Create one only when the user explicitly asks for a reusable published package.
## Examples template scope
This application is based on `@nocobase/app-template-default` and provides runnable learning examples. Its homepage catalogs the registered demonstrations; article management is application-owned. Reusable plugin examples stay in `packages/examples/` in the source workspace. Keep framework changes aligned with Default and Hub; keep demonstration content and branding local. Use a separate configuration and database, never copy Default's runtime state.
## Load the development skills
`skills/nocobase-app-development/` holds the detailed guidance behind this file. Read its `SKILL.md` first — it routes to the reference that matches your task instead of making you read everything:
| Task | Reference |
| ------------------------------------------------ | --------------------------------------- |
| Add a page, route, or navigation entry | `references/client-pages-and-routes.md` |
| Build or style UI | `references/components-and-styling.md` |
| Add an HTTP endpoint | `references/server-routes.md` |
| Read or write data | `references/database-and-data.md` |
| Change the schema | `references/migrations.md` |
| Add translatable text | `references/i18n.md` |
| Add a service, background job, or scheduled task | `references/services-and-jobs.md` |
| Write tests and verify | `references/testing.md` |
Read the one page your task needs, not the whole directory.
## Where things go
Business code goes in these places. This is where you work, and where you should stay unless the task genuinely requires otherwise:
```text
client/routes.ts Declare a page route
client/pages/ The page component
client/components/ Your components
client/components/ui/ shadcn/ui primitives; add with the CLI, do not hand-write
client/locales/ Every user-visible string
client/service-provider.ts Sidebar resources and client startup
server/routes/ HTTP endpoints
server/providers/ Services and their lifecycle
database/main/migrations/ Schema changes
database/main/seeds/ Required initial data
cli/commands/ Commands this application owns
tests/ Tests; never beside the source
```
A feature with a page and an API touches five places: a migration for the table, a route in `server/routes/`, a page in `client/pages/` declared in `client/routes.ts`, a sidebar resource in `client/service-provider.ts`, and strings in `client/locales/`.
### The rest is framework structure
`client/routing/`, `client/shell/`, `client/layouts/`, `client/theme/`, the server entry points, the build scripts, and the tsconfigs are the scaffolding the template provides. It is still this application's own source — it shipped to the user and they may change it — but it is the part the template evolves, so an edit there is what a future upgrade has to reconcile.
Prefer the mechanism the system already provides. Most tasks that look like they need a shell or router change do not: a page needs a route and a resource, a settings page needs `access`, a plugin page is customized through an option or an override. If you find yourself editing the shell to add a page, check whether you have registered the resource first.
When the built-in mechanism genuinely cannot express what is being asked, changing this structure is a legitimate answer — not a last resort to apologize for. Do it deliberately, and leave the next agent enough to work with:
- Comment what you changed and why the built-in path did not fit. On upgrade, an agent reconciling the template's version needs to know whether your change is still needed.
- Update this file and `skills/nocobase-app-development/` in the same change, so the guidance describes the application as it actually is. Documentation describing a layout the application no longer has is worse than none.
## Building a feature
### Pages and routes
Declare the route in `client/routes.ts` and keep the page component behind a lazy `componentLoader`. The loaded module must default-export the component.
```ts
const appRoutes: AppClientRouteContribution = defineAppRoutes([
{
name: 'orders',
path: '/orders',
auth: 'required',
componentLoader: () => import('./pages/orders.js'),
},
]);
```
Route paths are application-internal. Never write the deployment base path such as `/main` into one — the runtime restores it.
`auth` controls browser navigation only: `required` for signed-in pages, `guest` for sign-in and registration, `optional` for pages that work either way. It is not server security. An endpoint the page calls enforces its own authentication independently.
Use `defineSettingsRoutes()` for administrative pages, which mount under `/settings`, and `defineDevRoutes()` for development-only pages, which mount under `/dev` and are absent from a production build. Do not repeat `/settings` or `/dev` in the path. `defineDevRoutes()` is a build boundary, not a permission boundary: a page that must be restricted in production is a settings route with `access`, enforced by the server.
**A route alone does not put the page in the sidebar.** The URL works, but nothing appears in navigation. Register a Refine resource in `client/service-provider.ts` as well:
```ts
this.app.refine.addResources([
{
name: 'orders',
list: '/orders',
meta: { label: 'navigation.orders', i18nNs: APP_NS },
},
]);
```
`list` must match the route's `path`, and `meta.label` is a translation key added to `client/locales/`. So a navigable page is three edits: the route, the resource, and the label. Settings and dev pages are the exception — their `navigation` field handles it.
### Components and styling
Use shadcn/ui for UI. Check `client/components/ui/` first; if the primitive is not there, add it from the shadcn registry rather than writing your own:
```bash
pnpm exec shadcn add card
```
Build your own components by composing these primitives, and put them in `client/components/`.
Style with the semantic Tailwind tokens — `bg-background`, `text-foreground`, `text-muted-foreground`, `border-border`, `bg-primary` — so pages follow the light and dark themes. Do not hard-code colors like `bg-white` or `text-gray-900`; they break the moment someone switches theme.
**Visual consistency is a whole-application property.** Match the surrounding code's spacing, typography, and component choices. If a change genuinely calls for a different look, change the application's design tokens in `client/theme/themes/*.css` so every page moves together. Never restyle only the part you are working on — a page that looks different from the rest is a defect, not a customization.
### Server routes
`defineApiRoutes()` mounts under `/api`, and `defineRootRoutes()` mounts at the root for callbacks and webhooks. Do not repeat `/api` in the path. Each route factory creates and returns its own Hono router.
```ts
export const apiRoutes: AppApiRouteContribution<Application> = defineApiRoutes(
(app) => {
const router = new Hono();
const auth = app.container.resolve(authenticationToken);
router.use('/orders', auth.required());
router.get('/orders', async (context) =>
context.json({ data: await listOrders() }),
);
return router;
},
);
```
**Every route owns its own security.** Mounting under `/api` does not authenticate anything. Install `auth.required()` on the paths the route owns, and add `authorization.middleware()` with an explicit `resource`/`action` check when the operation needs permission rather than just identity. Never rely on middleware from another route or on the order routes happen to be registered in.
Scope middleware to the exact paths you own, or to an isolated sub-router mounted at your prefix. A `router.use('*', ...)` leaks into contributions mounted later.
A webhook that a third party calls cannot use a login session, so it is deliberately public — but public still means verifying a signature, timestamp, or one-time state, and testing that anonymous requests without a valid signature are rejected.
Keep HTTP concerns in the route and domain logic in a service under `server/providers/`.
### Database
Schema changes are migrations under `database/main/migrations/`. Data the application requires to run is a seed under `database/main/seeds/`. Seeds never create structure.
```ts
const migration: MigrationDefinition = defineMigration({
name: '202609020001_create_orders',
async up({ builder }) {
await builder.createCollection('orders', (collection) => {
collection.increments('id');
collection.string('reference', { length: 64, nullable: false });
collection.datetime('createdAt', { nullable: false });
});
},
async down({ builder }) {
await builder.dropCollection('orders');
},
});
```
**A migration is immutable history and must be self-contained.** Spell out every field, index, and constraint in the migration itself. Never import a collection definition, model, or registry that keeps evolving — doing so silently changes what an already-applied migration means. Write `down` as the explicit reverse in a safe dependency order.
Edit an existing migration only while the branch that introduced it is unmerged. Once merged, every correction is a new migration.
The exported `name` must match the filename. Apply with `pnpm migrate` and verify against a real database.
At runtime, resolve `databaseManagerToken` from the container and use `database.query()` to read and write the default connection. Use `database.query('analytics')` for another connection. Application tasks use `database/<connectionName>/{migrations,seeds}` and bind to that connection explicitly; plugin tasks and default runtime access stay on `database.default`. Only managed connections run migrations or seeds. See the migrations reference for execution and upgrade rules.
### User-facing text
Every string a user reads goes through a translation key. `client/locales/en-US.ts` states the wording and derives the shape that other locales are checked against, so a missing key in `zh-CN.ts` is a compile error.
```tsx
const { t } = useTranslation();
t('orders.title');
```
To reword a plugin's string, add an `overrides` block keyed by that plugin's package name in your locale file. Do not edit the plugin.
The account menu language control in `client/shell/language-switcher.tsx` uses a shadcn submenu with radio items. Render it inside `DropdownMenuContent` to preserve menu keyboard navigation and selection semantics.
## The command line
`pnpm nocobase` runs this application's CLI. It holds three kinds of command: `plugin *` manages the plugins this application uses, `app *` is what this application writes for itself in `cli/commands/`, and each registered plugin contributes its own commands under a topic it declares — a workflow plugin's commands appear under `workflow`.
```bash
pnpm nocobase --help # every topic and command
pnpm nocobase app info # a command this application owns
```
Add a command of your own as an oclif `Command` subclass in `cli/commands/`, then list it in `cli/commands/index.ts`; the key becomes its name under `app`. These commands are static tooling — they read and write files and packages. They do not start the application, so nothing in them may resolve a service or query the database. Anything needing the running application is a server route or a job, not a command.
`cli/` is compiled into `dist` alongside the server, so a deployed application runs the same commands with `node ./cli/index.js`. `pnpm migrate` and `pnpm seed` are these commands rather than separate scripts.
A command that cannot work in a deployment belongs in `cli/dev-commands/` instead, which the build excludes — client inspection is there because it needs Vite and the browser client, and neither exists in `dist`. Put a command there rather than shipping one that fails the moment someone runs it.
## Plugins
Plugins are registered in `client/plugins.ts`, `server/plugins.ts`, and `cli/plugins.ts`. Presence in the array enables a plugin and array order is contribution order. A plugin appears in the roots matching what it ships, so a plugin with only commands is listed in `cli/plugins.ts` alone. Bulk Skills synchronization and plugin updates discover plugins from these composition roots.
Let `pnpm plugin:register` and `pnpm plugin:unregister` add and remove entries. Edit these files by hand only to reorder entries or to pass a plugin its options.
To customize a plugin's page, pass an option on its registration, add a source extension under `client/extensions/*/extension.ts`, or add an entry to `client/route-overrides.ts`. Do not redeclare the plugin's route — a duplicate `/login` is a conflict, not a customization. An override replaces only `componentLoader`; route identity, path, and auth mode stay with the plugin. One route takes one override across all three mechanisms.
### Read a plugin's Skill before building what it already does
**You are not starting from scratch.** This application ships with plugins that already solve whole categories of requirement, and each one publishes a Skill explaining how to use it. Registration copies those Skills into `.agents/skills/`. Before implementing a feature, check whether a plugin already covers it:
| The requirement sounds like | Read the Skill for |
| ------------------------------------------------------------------------------------------------- | -------------------------------------- |
| Approvals, multi-step processes, "when X happens then Y", business rules that outlive one request | `@nocobase/app-plugin-workflow` |
| Email, IM, or in-app messages; notifying someone that something happened | `@nocobase/app-plugin-notification` |
| Roles, permissions, "user A may only see their own records", field-level or row-level access | `@nocobase/app-plugin-authorization` |
| Sign-in, registration, sessions, password reset | `@nocobase/app-plugin-authentication` |
| File upload and metadata through Repository | `@nocobase/app-plugin-file-repository` |
| Translated text and language switching | `@nocobase/app-plugin-i18n` |
Run `pnpm plugin:skills:sync` if `.agents/skills/` is missing or looks out of date, then read the Skill for the plugin you need. It documents that plugin's public entries, the ownership boundary, and how to verify the result — which is faster and more correct than inferring an API from its source.
Building a permission system, a notification sender, or a job scheduler by hand when a registered plugin already provides one is the most expensive mistake available here. Prefer the plugin; write your own only when you have read its Skill and confirmed it genuinely does not fit.
`.agents/skills/` itself is generated output: gitignored, and every synchronized directory is replaced wholesale on the next sync, so never edit a file there. This application's own `skills/` directory is the opposite — committed source you should keep current.
## Adding a dependency
Put a package your **server** code imports in `dependencies`. Put everything your **client** code imports — along with build tooling, tests, and type-only imports — in `devDependencies`.
That split looks backwards until you see how the two halves are deployed. `pnpm build` bundles the client: Vite resolves every client import and inlines it into `dist/client`, so nothing has to resolve it again later. The server is not bundled. `dist/server` keeps its bare imports, and `pnpm build` generates `dist/package.json` from your `dependencies` and installs a `node_modules` next to it — that tree is what the deployed server resolves against, and `devDependencies` are not in it.
So the two mistakes fail in opposite ways. A server import left in `devDependencies` works all through development and fails only on the server, with a bare `Cannot find package` naming nothing that points back here. A client package put in `dependencies` never breaks anything — it is just installed into every deployment, where the server never requires it. That one is invisible, so it accumulates: `lucide-react` and `@xyflow/react` were 44 MB of it before this rule was written down.
What decides it is where the importing file lives and what the import is, not what the package is for. `import ts from 'typescript'` in `server/` is a runtime dependency even though TypeScript sounds like tooling. `import type { Config } from 'x'` is erased before anything runs, so it stays a devDependency wherever it appears. A dynamic `import()` counts — deferring the load changes when a package is needed, not whether.
## Before you finish
```bash
pnpm typecheck
pnpm test
pnpm lint
pnpm build
```
Add tests for what you changed: a route's authenticated, unauthenticated, and unauthorized responses; a migration's `up` and `down` against a real database; a page's actual behavior. Tests belong in `tests/`, or in `e2e/` when they need a real server. Never place a test beside the source it covers.
`pnpm client:inspect` and `pnpm server:inspect` show what is wired when a contribution does not appear as expected. They report composition, not correctness — a clean inspection proves nothing about behavior or security.
For creating or editing theme presets, read `skills/nocobase-app-development/references/themes.md` (from the application root).
For UI styling, use the shared color, font, size, spacing, radius and shadow contract in `skills/nocobase-app-development/references/theme-tokens.md` (from the application root). Prefer its Tailwind utilities so components respond to theme changes; keep deliberate fixed-size exceptions explicit.
@@ -0,0 +1,7 @@
# @nocobase/app-template-examples
## 0.0.1
### Patch Changes
- Initial Examples application template, based on the Default application with article management and registered capability examples.
@@ -0,0 +1,5 @@
@AGENTS.md
For creating or editing theme presets, read `skills/nocobase-app-development/references/themes.md` (from the application root).
For UI styling, use the shared color, font, size, spacing, radius and shadow contract in `skills/nocobase-app-development/references/theme-tokens.md` (from the application root). Prefer its Tailwind utilities so components respond to theme changes; keep deliberate fixed-size exceptions explicit.
@@ -0,0 +1,32 @@
# Examples template upgrades
This application starts from `@nocobase/app-template-examples`. Its initial release is based on the current Default template and includes the same framework structure, multi-connection database tasks, and example plugins, plus an examples homepage and article management.
In the template package, `nocobase.defaultTemplateVersion` mirrors `version`; `scripts/sync-template-version.mjs` updates it during release. In a generated application, it records the template version whose source changes have been incorporated. Changing the number alone never upgrades source files.
## System information plugin removed
`@nocobase/app-plugin-system-info` has been removed from the source workspace and template registrations. Remove its manifest entry and client/server registrations when upgrading a derived application, then reinstall dependencies and synchronize plugin Skills. The `/system-info` page and `/api/system-info` endpoint are no longer available.
## File plugin removed from template registration
The template no longer registers or directly depends on `@nocobase/app-plugin-file`. Its file inventory settings page and related API are no longer provided by default. When merging this update, remove the package from the manifest and both client/server plugin lists, then install dependencies and synchronize plugin Skills. This registration change does not delete stored files or database records. Independently registered file Repository capabilities remain unchanged.
## Remove duplicate plugin metadata
Remove `nocobase.plugins` from the application manifest after upgrading the CLI and template scripts together. Keep `templateKind` and `defaultTemplateVersion`. Client, Server, and CLI composition roots now determine registered plugins for bulk Skills synchronization and updates. Development watches read Server registrations; deployment packaging follows server imports. Registration still copies plugin Skills, and unregistration cleans up legacy metadata when present.
## Upgrade checklist
1. Back up application source and its database before upgrading.
2. Review the template changelog and merge the framework changes into your application.
3. Preserve customized pages, plugin choices, locales, configuration, and runtime data.
4. Keep previously executed migration and seed sources unchanged. New database changes require new files.
5. Install dependencies, run checks and build, then verify login, navigation, permissions, article CRUD, and database task history.
6. Update `nocobase.defaultTemplateVersion` only after the source upgrade is complete.
## Independent application state
Creating Examples from Default means copying source conventions, not moving a running database. Use a fresh configuration and database for each generated application. Do not point Examples at an existing Default database: migrations and seeds are tracked by package identity, so changing template identity does not transfer their history.
Default remains unchanged by the introduction of this template. Moving a previously running Default application to Examples is a separate source and database migration, not a directory rename.
@@ -0,0 +1,191 @@
# NocoBase 3 Examples
A full-stack NocoBase 3 application. The server runs on `@nocobase/app-server` and the browser client is React with Refine, shadcn/ui, and Tailwind CSS.
You own everything in this directory. Add pages, API routes, database tables, and business logic directly here — this is your application's source code, not a framework you extend from the outside.
This is the examples template, based on `@nocobase/app-template-default`. It is a standalone application with its own database and configuration. Default provides a clean starting template with product capabilities and no example plugins.
The homepage links to article management, repository queries, CRM, orders, file management, and routes. Additional registered plugins demonstrate services, queues, realtime, CLI commands, and Skills. Plugin demonstrations remain in their own packages under `packages/examples/`; the article page demonstrates application-owned development.
## Create an examples application
After this template and the updated scaffolder have been published:
```bash
pnpm create @nocobase/app my-examples --template examples
```
Before publication, from the source repository root:
```bash
pnpm --filter @nocobase/create-app build
node packages/tools/create-app/bin/run.js my-examples --template ./packages/templates/app-template-examples --db-dialect sqlite
```
The scaffolder creates an independent configuration and database. Never copy another application's `config.yml`, `.env`, `storage/`, or generated `dist/` into this template.
Article migration and seed sources create six articles on a fresh managed default connection. Existing system administrators receive an `articles-manager` permission set during the application's first eligible startup. Later edits and revoked assignments are preserved. Other examples retain their plugins' authorization policies.
## Maintain this template
Keep framework files aligned with Default and Hub: startup/build scripts, runtime composition mechanisms, shell, theme, CLI infrastructure and development guidance. Keep the examples homepage, article module, example plugin choices and sample data here. Existing Default installations retain their historical data; see Default’s `MIGRATION.md` when upgrading to its clean composition.
## Getting started
Start the development server:
```bash
pnpm dev
```
`pnpm dev` starts the API server and the Vite client together, picks free ports if the defaults are taken, and prints the URL to open. Use the printed URL rather than assuming a port.
### Review your configuration
`config.yml` was written when the project was created, carrying the database you chose and a generated `auth.secret`. It gets you running; adjust it for your situation:
- **Database.** Point the connection at the database you actually intend to use — dialect, host, port, and credentials.
- **Where the application is served.** `APP_BASE_PATH` is the public mount path, `/main` by default, so the development URL looks like `http://127.0.0.1:13000/main/`. `APP_SERVER_PORT` and `APP_SERVER_HOST` change what the server binds to.
- **Anything a feature you enable requires** — mail delivery, IM webhooks, storage. `config.example.yml` documents every available option, including sections your `config.yml` does not yet have.
`config.yml` is gitignored because it holds your `auth.secret`. Deploy it through your secret management rather than committing it.
To run against a production build instead:
```bash
pnpm build
pnpm start
```
## Project structure
```text
client/ Browser application
├── routes.ts Your page routes
├── pages/ Your page components
├── components/ Your components
│ └── ui/ shadcn/ui primitives
├── layouts/ Settings and dev tool shells
├── shell/ Authenticated app shell: header, sidebar, user menu
├── routing/ Route rendering, access checks, loading and error UI
├── locales/ Your translated strings
├── theme/ App-wide theme and light/dark selector
├── extensions/ Application-owned copies of plugin-published UI
├── plugins.ts Which plugins the browser loads
├── react-providers.ts Your React context providers
├── service-provider.ts Your client startup logic
└── runtime.ts Composition root tying the above together
server/ API server
├── routes/ Your HTTP endpoints
├── providers/ Your services and their lifecycle
├── config/ Application configuration composition
├── plugins.ts Which plugins the server loads
├── app.ts Application assembly
├── runtime.ts Composition root
├── standalone.ts Node entry point
└── embedded.ts Entry point when a host process mounts it
database/
└── main/
├── migrations/ Schema history for the main connection
└── seeds/ Initial example data
tests/ Unit and integration tests
e2e/ Browser tests needing a real server
config.yml Your configuration, generated at setup; gitignored
config.example.yml Documented configuration template
skills/ In-depth development guides for AI agents
```
The three files named `runtime.ts`, `plugins.ts`, and `routes.ts` are composition roots: they list what the application is made of. Reading them tells you what is loaded and in what order.
## Scripts
### Everyday development
| Script | What it does |
| ------------ | ------------------------------------------------- |
| `pnpm dev` | Start the API server and client with hot reload |
| `pnpm build` | Build the client and server into `dist/` |
| `pnpm start` | Run the built server; requires `pnpm build` first |
### Database
| Script | What it does |
| -------------- | ----------------------------------------------------------------------------------------------- |
| `pnpm migrate` | Apply pending migrations from your `database/main/migrations/` and from every registered plugin |
| `pnpm seed` | Run seeds from `database/main/seeds/` and from registered plugins |
Migrations also run automatically on startup while `database.connections.main.migrations.autoRun` is `true` in `config.yml`. Run `pnpm migrate` explicitly when you want to apply a schema change without restarting.
Database tasks support `--connection <name>` and `--all`; no argument targets `database.default`. Per-connection source and upgrade rules are documented in [migrations and seeds](skills/nocobase-app-development/references/migrations.md).
### Checks
| Script | What it does |
| -------------------- | ------------------------------------------------------------ |
| `pnpm test` | Run the test suite once |
| `pnpm test:watch` | Re-run tests as files change |
| `pnpm test:coverage` | Run tests and write a coverage report |
| `pnpm typecheck` | Typecheck the client, tooling, and server |
| `pnpm lint` | Report lint problems |
| `pnpm fix` | Fix lint problems, then format |
| `pnpm check` | Everything above plus a build; what to run before committing |
### Plugins
Plugins add features to your application. Let these commands edit `package.json`, `client/plugins.ts`, and `server/plugins.ts` for you rather than editing those files by hand.
| Script | What it does |
| ------------------------------- | --------------------------------------------------------- |
| `pnpm plugin:register <name>` | Install a plugin and wire it into the client and server |
| `pnpm plugin:unregister <name>` | Remove a plugin and its wiring |
| `pnpm plugin:update` | Upgrade registered plugins and re-sync their agent skills |
| `pnpm plugin:inspect <name>` | Report a plugin's registration state; read-only |
| `pnpm plugin:skills:sync` | Re-copy plugin agent skills into `.agents/skills/` |
Ordering in `client/plugins.ts` and `server/plugins.ts` is contribution order, and a plugin is enabled by appearing in the array. Reorder entries or pass a plugin its options by hand; leave adding and removing entries to the commands.
### Diagnostics
| Script | What it does |
| --------------------- | ---------------------------------------------------------------------------------- |
| `pnpm client:inspect` | Print the resolved client composition: routes, providers, and which file owns each |
| `pnpm server:inspect` | Print the resolved server composition |
These read static declarations. They report what is wired, not whether it works — a passing inspection is not a substitute for tests.
## Configuration
`config.yml` is the single source of configuration, written for you at generation time and yours to edit from there. It is gitignored because it carries your `auth.secret`; deploy it through your secret management rather than committing it. `config.example.yml` documents every available option, including sections the generated file leaves out.
Two settings decide where the application is reachable:
- `APP_BASE_PATH` is the public path the application is mounted at, `/main` by default. The browser API base URL is derived from it as `<APP_BASE_PATH>/api`.
- `APP_PUBLIC_ORIGIN` is the external origin used to build callback and redirect URLs. Set it in production. It must not repeat the base path — the runtime joins the two.
Configuration is resolved from the file, environment variables, and defaults in that order of precedence.
## Testing
Tests live in `tests/`, never beside the source they cover. Put logic and integration tests under `tests/logic/` and component tests under `tests/components/`. Tests needing a real server, real authentication, or a real database belong in `e2e/`.
### Notification testing
After signing in with the `notification:test` `send` permission, use the
notification logs settings page to send a real message through the durable
Notification Manager. The form is generated from safe Channel test metadata;
Email tests require an address and IM tests use the configured logical target.
The button is unavailable unless the protected targets endpoint is authorized,
and testing is disabled unless `notification.test.enabled` is explicitly set
to `true` in `config.yml`.
## Working with an AI agent
`AGENTS.md` describes how to build features here, and `skills/` holds the in-depth references it links to. If you use Claude Code, `CLAUDE.md` points at the same guidance. Keeping these accurate as your application grows is what keeps agent-written code consistent with the rest of it.
## License
MIT
@@ -0,0 +1,15 @@
import type { AppCliCommands } from '@nocobase/nb3-cli/plugins';
import AppInfo from './info.js';
import AppMigrate from './migrate.js';
import AppSeed from './seed.js';
// Commands this application owns. Each key is the name it answers to under the
// `app` topic, so `info` becomes `pnpm nocobase app info`.
const appCommands: AppCliCommands = {
info: AppInfo,
migrate: AppMigrate,
seed: AppSeed,
};
export default appCommands;
@@ -0,0 +1,50 @@
import { Command, Flags } from '@oclif/core';
import type { Interfaces } from '@oclif/core';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
export default class AppInfo extends Command {
static override summary = 'Print this application’s name and version.';
static override description =
'Reads the application manifest. A starting point for commands this application owns; replace or extend it as needed.';
static override examples: Command.Example[] = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --json',
];
static override flags: {
json: Interfaces.BooleanFlag<boolean>;
} = {
json: Flags.boolean({
default: false,
description: 'Print one machine-readable JSON result.',
}),
};
public async run(): Promise<void> {
const { flags } = await this.parse(AppInfo);
const manifestPath = path.resolve(
import.meta.dirname,
'..',
'..',
'package.json',
);
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as {
name?: string;
version?: string;
};
if (flags.json) {
this.logJson({
ok: true,
name: manifest.name,
version: manifest.version,
});
return;
}
this.log(
`${manifest.name ?? '(unnamed)'} ${manifest.version ?? ''}`.trim(),
);
}
}
@@ -0,0 +1,57 @@
import { Command, Flags } from '@oclif/core';
import type { Interfaces } from '@oclif/core';
import path from 'node:path';
import { resolveStandaloneAppRuntime } from '@nocobase/app-server/node';
import appRuntime from '../../server/runtime.js';
import { runDatabaseCommand } from '../database-command.js';
export default class AppMigrate extends Command {
static override summary = 'Run pending database migrations.';
static override description =
'Runs the default connection unless --connection or --all is specified. Plugins belong to the default connection. Stops on the first failure.';
static override examples: Command.Example[] = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --connection analytics --json',
'<%= config.bin %> <%= command.id %> --all',
];
static override flags: {
json: Interfaces.BooleanFlag<boolean>;
all: Interfaces.BooleanFlag<boolean>;
connection: Interfaces.OptionFlag<string | undefined>;
} = {
json: Flags.boolean({
default: false,
description: 'Print one machine-readable JSON result.',
}),
all: Flags.boolean({
default: false,
exclusive: ['connection'],
description:
'Run all managed connections; report external connections as skipped.',
}),
connection: Flags.string({
exclusive: ['all'],
description: 'Target a named managed connection, regardless of autoRun.',
}),
};
public async run(): Promise<void> {
const { flags } = await this.parse(AppMigrate);
await runDatabaseCommand(
{
log: (message) => this.log(message),
logJson: (value) => this.logJson(value),
exit: (code) => this.exit(code),
},
'migrations',
flags,
async () =>
resolveStandaloneAppRuntime(appRuntime, {
rootDir: path.resolve(import.meta.dirname, '..', '..'),
}),
);
}
}
@@ -0,0 +1,57 @@
import { Command, Flags } from '@oclif/core';
import type { Interfaces } from '@oclif/core';
import path from 'node:path';
import { resolveStandaloneAppRuntime } from '@nocobase/app-server/node';
import appRuntime from '../../server/runtime.js';
import { runDatabaseCommand } from '../database-command.js';
export default class AppSeed extends Command {
static override summary = 'Run pending database seeds.';
static override description =
'Runs the default connection unless --connection or --all is specified. Plugins belong to the default connection. Stops on the first failure.';
static override examples: Command.Example[] = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --connection analytics --json',
'<%= config.bin %> <%= command.id %> --all',
];
static override flags: {
json: Interfaces.BooleanFlag<boolean>;
all: Interfaces.BooleanFlag<boolean>;
connection: Interfaces.OptionFlag<string | undefined>;
} = {
json: Flags.boolean({
default: false,
description: 'Print one machine-readable JSON result.',
}),
all: Flags.boolean({
default: false,
exclusive: ['connection'],
description:
'Run all managed connections; report external connections as skipped.',
}),
connection: Flags.string({
exclusive: ['all'],
description: 'Target a named managed connection, regardless of autoRun.',
}),
};
public async run(): Promise<void> {
const { flags } = await this.parse(AppSeed);
await runDatabaseCommand(
{
log: (message) => this.log(message),
logJson: (value) => this.logJson(value),
exit: (code) => this.exit(code),
},
'seeds',
flags,
async () =>
resolveStandaloneAppRuntime(appRuntime, {
rootDir: path.resolve(import.meta.dirname, '..', '..'),
}),
);
}
}

Some files were not shown because too many files have changed in this diff Show More