mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-29 02:03:53 +08:00
Feat/version control (#9563)
* feat(database): add tags property to collection * feat(cli): add version commit command * chore: add collection tags * refactor: expose plugin-backups server api * refactor(database): unify backup and restore options * feat(database): support selective backup and skip full pre-drop * chore: add database adapter option tests * feat(backups): expose backup and restore table options * chore: sub class of BackupManager can change file prefix * feat(backups): add description fied in backup list * fix: remove backup metadata on destroy * fix: quote postgres backup table patterns * chore: add collection tags * feat(backups): isolate version control backup storage and cache * feat(backups): isolate restoring from version control or backups`s storage and cache * fix(cli): allow 2000-char version commit description * chore(cli): rename command `version commmit` to `revision create` * fix(plugin-backups): persist backup creator metadata * feat(client): include command user in event payload * chore(ai): add error handle in app:plublishEvent api * docs: add version control operations guide * fix(collection): mark system collections with system tags * fix(collection): mark business collections with business tags * refactor(database): replace collection tags with data category * test(plugin-backups): stabilize restore manager tests * fix(plugin-backups): stabilize restore test metadata * chore: update collection data categories * fix(plugin-verification): move data category to server collection * fix(plugin-backups): qualify postgres table filters with schema * chore: mysql client * fix(cli): check version control plugin before revision create --------- Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
@@ -65,6 +65,8 @@ RUN if [ "$INCLUDE_DOCS_ARCHIVE" = "1" ]; then \
|
||||
fi && \
|
||||
rm -f /tmp/dist.tar.gz
|
||||
|
||||
FROM mysql:8.0.39 AS mysql-client-assets
|
||||
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
ARG COMMIT_HASH
|
||||
ARG INCLUDE_DOCS_ARCHIVE=1
|
||||
@@ -75,6 +77,9 @@ ARG USE_ALIYUN_MIRROR=0
|
||||
ENV NB_SKIP_STARTUP_UPDATE=1 \
|
||||
NOCOBASE_RUNNING_IN_DOCKER=true
|
||||
|
||||
COPY --from=mysql-client-assets /usr/bin/mysql /usr/bin/mysql
|
||||
COPY --from=mysql-client-assets /usr/bin/mysqldump /usr/bin/mysqldump
|
||||
|
||||
RUN set -eux; \
|
||||
rm -f /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; \
|
||||
if [ "$USE_ALIYUN_MIRROR" = "1" ]; then \
|
||||
@@ -109,6 +114,7 @@ RUN set -eux; \
|
||||
libfreetype6 \
|
||||
fontconfig \
|
||||
libgssapi-krb5-2 \
|
||||
libncurses6 \
|
||||
fonts-liberation; \
|
||||
if [ "$INSTALL_POSTGRES_16_CLIENT" = "1" ]; then \
|
||||
apt-get install -y --no-install-recommends postgresql-client-16; \
|
||||
@@ -117,6 +123,8 @@ RUN set -eux; \
|
||||
apt-get install -y --no-install-recommends fonts-noto-cjk; \
|
||||
fi; \
|
||||
nginx -v; \
|
||||
mysql --version; \
|
||||
mysqldump --version; \
|
||||
apt-get purge -y --auto-remove wget gnupg dirmngr; \
|
||||
rm -rf \
|
||||
/etc/apt/sources.list.d/nginx.list \
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
"label": "备份管理",
|
||||
"link": "/ops-management/backup-manager/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "版本管理",
|
||||
"link": "/ops-management/version-control/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "迁移管理",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
pkg: '@nocobase/plugin-version-control'
|
||||
title: "版本管理"
|
||||
description: "版本管理插件操作手册:创建版本、恢复版本、设置保留数量、配置快捷键和纳入版本的用户数据表。"
|
||||
keywords: "版本管理,Version control,运维管理,创建版本,恢复版本,NocoBase"
|
||||
---
|
||||
|
||||
# 版本管理
|
||||
|
||||
在 NocoBase 中,**版本管理(Version control)** 用来给当前应用保存一份可回退的版本。你可以手动创建版本,在需要时恢复到某个已保存的状态,也可以在插件设置中控制保留数量、快捷键,以及哪些用户数据表需要跟着版本一起保存。
|
||||
|
||||
它依赖 [备份管理](../backup-manager/index.mdx)。如果你已经启用版本管理插件,但系统仍然出现相关错误提示,先确认备份管理插件已经启用。
|
||||
|
||||
## 进入插件
|
||||
|
||||
你可以从「系统设置」里的「版本管理」进入插件页面。顶部导航栏里也会出现一个版本管理按钮,点击后可以直接创建版本,或者跳到版本列表。创建版本的默认快捷键是 `Ctrl + K`,你可以在设置页里修改。
|
||||
|
||||

|
||||
|
||||
## 创建版本
|
||||
|
||||
点击「创建版本」,输入一段描述后保存即可。描述最多 2000 个字符,适合记录这次版本对应的变更背景,比如“调整审批流程字段和权限”。
|
||||
|
||||

|
||||
|
||||
点击保存后,列表里会先出现一条「保存中」的记录。保存完成后,版本会显示在列表里。
|
||||
|
||||
几个要点:
|
||||
|
||||
- 版本名由系统自动生成,不需要手动填写
|
||||
- 从顶部导航按钮、快捷键和列表页按钮创建,效果完全一样
|
||||
- 列表会显示版本名、描述、文件大小、创建时间、创建人,以及后续操作
|
||||
|
||||
## 管理与恢复版本
|
||||
|
||||
版本列表页主要有这几类操作:
|
||||
|
||||
- 「刷新」:重新加载当前版本列表
|
||||
- 「删除」:删除单个版本,或勾选多条后批量删除
|
||||
- 「恢复」:把当前应用恢复到该版本保存时的状态
|
||||
|
||||
:::warning 注意
|
||||
|
||||
恢复版本会覆盖当前应用当前的配置状态,以及该版本中包含的数据内容。恢复前,建议先创建一个当前版本,方便你随时回退。
|
||||
|
||||
:::
|
||||
|
||||
点击「恢复」后,应用会短暂进入维护状态并执行恢复。恢复完成前,不要重复提交恢复操作。如果恢复失败,界面会显示错误通知。
|
||||
|
||||
## 设置版本策略
|
||||
|
||||
切换到「设置」标签页后,你可以控制版本保留和版本内容范围。
|
||||
|
||||

|
||||
|
||||
设置项包括:
|
||||
|
||||
- `Versions to keep`:保留的版本数量上限。超过上限后,较早的版本会自动删除
|
||||
- `Shortcut: create version`:创建版本的快捷键。按 `Ctrl + 字母键` 设置,按 `Backspace` 清除
|
||||
- `User collections`:选择哪些用户创建的数据表需要跟着版本一起保存
|
||||
|
||||
:::tip
|
||||
|
||||
默认情况下,版本不会包含你自己创建的数据表内容。只有当你希望把部分业务数据也一起纳入版本时,才需要在这里选择对应的数据表。
|
||||
|
||||
:::
|
||||
|
||||
如果你选择了某个用户数据表,系统会把和它存在关系的数据表一并纳入版本,这样恢复时通常更完整。
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [备份管理](../backup-manager/index.mdx) — 版本管理依赖的基础能力
|
||||
- [迁移管理](../migration-manager/index.md) — 在多环境之间迁移应用配置
|
||||
- [发布管理](../release-management/index.md) — 结合备份、迁移和变量配置规划发布流程
|
||||
@@ -18,6 +18,11 @@
|
||||
"label": "Backup-Verwaltung",
|
||||
"link": "/ops-management/backup-manager/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Versionsverwaltung",
|
||||
"link": "/ops-management/version-control/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Migrationsverwaltung",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "Versionsverwaltung"
|
||||
description: "Leitfaden für das Versionsverwaltungs-Plugin: Versionen erstellen, wiederherstellen, Aufbewahrung konfigurieren, Tastenkürzel setzen und Benutzer-Sammlungen einbeziehen."
|
||||
keywords: "Versionsverwaltung,Version control,Betriebsmanagement,Version erstellen,Version wiederherstellen,NocoBase"
|
||||
---
|
||||
|
||||
# Versionsverwaltung
|
||||
|
||||
In NocoBase kannst du mit der **Versionsverwaltung (Version control)** eine wiederherstellbare Version der aktuellen Anwendung speichern. Du kannst Versionen manuell erstellen, bei Bedarf auf eine gespeicherte Version zurücksetzen und in den Plugin-Einstellungen steuern, wie viele Versionen aufbewahrt werden, welches Tastenkürzel verwendet wird und welche Benutzer-Sammlungen mitgesichert werden.
|
||||
|
||||
Sie basiert auf [Backup-Verwaltung](../backup-manager/index.mdx). Wenn das Plugin für die Versionsverwaltung bereits aktiviert ist, das System aber weiterhin entsprechende Fehler anzeigt, prüfe zuerst, ob die Backup-Verwaltung aktiviert ist.
|
||||
|
||||
## Plugin öffnen
|
||||
|
||||
Du kannst das Plugin unter 「System settings」 → 「Version control」 öffnen. In der oberen Leiste erscheint außerdem eine Schaltfläche für die Versionsverwaltung. Darüber kannst du direkt eine Version erstellen oder zur Versionsliste wechseln. Das Standard-Tastenkürzel zum Erstellen einer Version ist `Ctrl + K` und kann im Einstellungs-Tab geändert werden.
|
||||
|
||||

|
||||
|
||||
## Eine Version erstellen
|
||||
|
||||
Klicke auf 「Create version」, gib eine Beschreibung ein und speichere sie. Die Beschreibung kann bis zu 2000 Zeichen lang sein. Sie eignet sich gut, um den Hintergrund der Änderung festzuhalten, zum Beispiel „Felder und Berechtigungen im Genehmigungsprozess angepasst“.
|
||||
|
||||

|
||||
|
||||
Nach einem Klick auf Speichern erscheint in der Liste zunächst ein temporärer Eintrag mit dem Status „Saving“. Nach Abschluss wird die gespeicherte Version in der Liste angezeigt.
|
||||
|
||||
Wichtige Punkte:
|
||||
|
||||
- Der Versionsname wird automatisch generiert
|
||||
- Das Erstellen über die obere Leiste, das Tastenkürzel oder die Listenseite funktioniert identisch
|
||||
- Die Liste zeigt Versionsname, Beschreibung, Dateigröße, Erstellungszeit, Ersteller und verfügbare Aktionen
|
||||
|
||||
## Versionen verwalten und wiederherstellen
|
||||
|
||||
Die Versionsliste bietet hauptsächlich diese Aktionen:
|
||||
|
||||
- 「Refresh」 lädt die aktuelle Liste neu
|
||||
- 「Delete」 löscht eine einzelne oder mehrere ausgewählte Versionen
|
||||
- 「Restore」 stellt die Anwendung auf den in dieser Version gespeicherten Stand zurück
|
||||
|
||||
:::warning Achtung
|
||||
|
||||
Beim Wiederherstellen einer Version werden die aktuelle Anwendungskonfiguration und die in dieser Version enthaltenen Daten überschrieben. Es empfiehlt sich, vor der Wiederherstellung zuerst eine Version des aktuellen Stands zu erstellen.
|
||||
|
||||
:::
|
||||
|
||||
Nach einem Klick auf 「Restore」 wechselt die Anwendung für kurze Zeit in den Wartungsmodus, während die Wiederherstellung läuft. Starte in dieser Zeit keine weitere Wiederherstellung. Wenn die Wiederherstellung fehlschlägt, zeigt die Oberfläche eine Fehlermeldung an.
|
||||
|
||||
## Versionsregeln konfigurieren
|
||||
|
||||
Im Tab 「Settings」 steuerst du Aufbewahrung und Inhalt jeder Version.
|
||||
|
||||

|
||||
|
||||
Die Einstellungen umfassen:
|
||||
|
||||
- `Versions to keep`: maximale Anzahl gespeicherter Versionen. Ältere Versionen werden automatisch gelöscht, sobald das Limit überschritten ist
|
||||
- `Shortcut: create version`: Tastenkürzel zum Erstellen einer Version. Mit `Ctrl + Buchstabe` festlegen, mit `Backspace` löschen
|
||||
- `User collections`: auswählen, welche Daten aus benutzererstellten Sammlungen in gespeicherte Versionen aufgenommen werden sollen
|
||||
|
||||
:::tip
|
||||
|
||||
Standardmäßig enthalten gespeicherte Versionen keine Daten aus benutzererstellten Sammlungen. Du musst hier nur dann Sammlungen auswählen, wenn auch Geschäftsdaten zusammen mit der Anwendungsversion wiederhergestellt werden sollen.
|
||||
|
||||
:::
|
||||
|
||||
Wenn du eine Benutzer-Sammlung einschließt, nimmt NocoBase auch verwandte Sammlungen automatisch auf, damit die Wiederherstellung in der Regel vollständiger ist.
|
||||
|
||||
## Verwandte Links
|
||||
|
||||
- [Backup-Verwaltung](../backup-manager/index.mdx) — die grundlegende Funktion, auf der die Versionsverwaltung basiert
|
||||
- [Migrationsverwaltung](../migration-manager/index.md) — Anwendungskonfiguration zwischen Umgebungen verschieben
|
||||
- [Release-Management](../release-management/index.md) — Veröffentlichungsabläufe mit Backups, Migrationen und Variablen planen
|
||||
@@ -18,6 +18,11 @@
|
||||
"label": "Backup Manager",
|
||||
"link": "/ops-management/backup-manager/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Version control",
|
||||
"link": "/ops-management/version-control/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Migration Manager",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "Version control"
|
||||
description: "Version control plugin guide: create versions, restore versions, configure retention, set shortcuts, and include user collections in saved versions."
|
||||
keywords: "Version control,ops management,create version,restore version,NocoBase"
|
||||
---
|
||||
|
||||
# Version control
|
||||
|
||||
In NocoBase, **Version control** lets you save a restorable version of the current application. You can create versions manually, restore the application to a saved version when needed, and use the plugin settings to control how many versions to keep, which shortcut to use, and which user collections should be saved with the version.
|
||||
|
||||
It depends on [Backup manager](../backup-manager/index.mdx). If the Version control plugin is already enabled but the system still shows related errors, first make sure Backup manager is enabled.
|
||||
|
||||
## Open the plugin
|
||||
|
||||
You can open the plugin from 「System settings」 → 「Version control」. A Version control button also appears in the top bar. Click it to create a version directly or jump to the versions list. The default shortcut for creating a version is `Ctrl + K`, and you can change it in the settings tab.
|
||||
|
||||

|
||||
|
||||
## Create a version
|
||||
|
||||
Click 「Create version」, enter a description, and save it. The description can be up to 2000 characters. It is useful for recording the background of the change, such as “Adjusted approval fields and permissions”.
|
||||
|
||||

|
||||
|
||||
After you click save, the list first shows a temporary “Saving” entry. When the task finishes, the saved version appears in the list.
|
||||
|
||||
Key points:
|
||||
|
||||
- Version names are generated automatically
|
||||
- Creating a version from the top bar, the shortcut, or the list page behaves the same
|
||||
- The list shows the version name, description, file size, creation time, creator, and available actions
|
||||
|
||||
## Manage and restore versions
|
||||
|
||||
The versions list mainly provides these actions:
|
||||
|
||||
- 「Refresh」 reloads the current list
|
||||
- 「Delete」 removes one version, or multiple selected versions in batch
|
||||
- 「Restore」 restores the application to the state saved in that version
|
||||
|
||||
:::warning Notice
|
||||
|
||||
Restoring a version overwrites the current application configuration and the data included in that version. It is recommended to create a version of the current state before restoring, so you can roll back again if needed.
|
||||
|
||||
:::
|
||||
|
||||
After you click 「Restore」, the application enters maintenance mode for a short time while the restore is running. Do not submit another restore request during that time. If the restore fails, the UI shows an error notification.
|
||||
|
||||
## Configure version rules
|
||||
|
||||
Open the 「Settings」 tab to control retention and what each version includes.
|
||||
|
||||

|
||||
|
||||
The settings include:
|
||||
|
||||
- `Versions to keep`: the maximum number of saved versions. Older versions are deleted automatically after the limit is exceeded
|
||||
- `Shortcut: create version`: the shortcut for creating a version. Press `Ctrl + a letter key` to set it, or `Backspace` to clear it
|
||||
- `User collections`: choose which user-created collections should have their data included in saved versions
|
||||
|
||||
:::tip
|
||||
|
||||
By default, saved versions do not include data from user-created collections. You only need to select collections here when you want some business data to be restored together with the application version.
|
||||
|
||||
:::
|
||||
|
||||
If you include a user collection, NocoBase also includes related collections automatically, so restores are usually more complete.
|
||||
|
||||
## Related links
|
||||
|
||||
- [Backup manager](../backup-manager/index.mdx) — the underlying capability required by Version control
|
||||
- [Migration manager](../migration-manager/index.md) — move application configuration across environments
|
||||
- [Release management](../release-management/index.md) — plan release workflows with backups, migrations, and variables
|
||||
@@ -18,6 +18,11 @@
|
||||
"label": "Gestión de copias de seguridad",
|
||||
"link": "/ops-management/backup-manager/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Control de versiones",
|
||||
"link": "/ops-management/version-control/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Gestión de migraciones",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "Control de versiones"
|
||||
description: "Guía del plugin de control de versiones: crear versiones, restaurarlas, configurar la retención, el atajo y las colecciones de usuario incluidas."
|
||||
keywords: "Control de versiones,Version control,gestión operativa,crear versión,restaurar versión,NocoBase"
|
||||
---
|
||||
|
||||
# Control de versiones
|
||||
|
||||
En NocoBase, **Control de versiones** te permite guardar una versión recuperable de la aplicación actual. Puedes crear versiones manualmente, restaurar una versión guardada cuando lo necesites y usar la configuración del plugin para controlar cuántas versiones conservar, qué atajo usar y qué colecciones de usuario deben guardarse junto con la versión.
|
||||
|
||||
Depende de [Gestión de copias de seguridad](../backup-manager/index.mdx). Si el plugin de control de versiones ya está habilitado pero el sistema sigue mostrando errores relacionados, primero confirma que la gestión de copias de seguridad también esté habilitada.
|
||||
|
||||
## Abrir el plugin
|
||||
|
||||
Puedes abrirlo desde 「System settings」 → 「Version control」. También aparece un botón de control de versiones en la barra superior. Desde ahí puedes crear una versión directamente o ir a la lista de versiones. El atajo predeterminado para crear una versión es `Ctrl + K`, y puedes cambiarlo en la pestaña de configuración.
|
||||
|
||||

|
||||
|
||||
## Crear una versión
|
||||
|
||||
Haz clic en 「Create version」, escribe una descripción y guarda. La descripción puede tener hasta 2000 caracteres. Suele usarse para registrar el contexto del cambio, por ejemplo “Ajuste de campos y permisos del flujo de aprobación”.
|
||||
|
||||

|
||||
|
||||
Después de hacer clic en guardar, la lista muestra primero una entrada temporal en estado “Saving”. Cuando termina, la versión aparece en la lista.
|
||||
|
||||
Puntos clave:
|
||||
|
||||
- El nombre de la versión se genera automáticamente
|
||||
- Crear una versión desde la barra superior, el atajo o la página de lista tiene el mismo efecto
|
||||
- La lista muestra nombre, descripción, tamaño del archivo, fecha de creación, creador y acciones disponibles
|
||||
|
||||
## Administrar y restaurar versiones
|
||||
|
||||
La lista de versiones ofrece principalmente estas acciones:
|
||||
|
||||
- 「Refresh」 vuelve a cargar la lista actual
|
||||
- 「Delete」 elimina una versión o varias versiones seleccionadas
|
||||
- 「Restore」 restaura la aplicación al estado guardado en esa versión
|
||||
|
||||
:::warning Atención
|
||||
|
||||
Restaurar una versión sobrescribe la configuración actual de la aplicación y los datos incluidos en esa versión. Se recomienda crear primero una versión del estado actual para poder volver atrás si hace falta.
|
||||
|
||||
:::
|
||||
|
||||
Después de hacer clic en 「Restore」, la aplicación entra brevemente en modo de mantenimiento mientras se ejecuta la restauración. No envíes otra restauración durante ese tiempo. Si falla, la interfaz muestra una notificación de error.
|
||||
|
||||
## Configurar las reglas de versión
|
||||
|
||||
Abre la pestaña 「Settings」 para controlar la retención y el contenido de cada versión.
|
||||
|
||||

|
||||
|
||||
La configuración incluye:
|
||||
|
||||
- `Versions to keep`: número máximo de versiones guardadas. Las versiones antiguas se eliminan automáticamente cuando se supera el límite
|
||||
- `Shortcut: create version`: atajo para crear una versión. Presiona `Ctrl + una letra` para configurarlo y `Backspace` para borrarlo
|
||||
- `User collections`: selecciona qué datos de colecciones creadas por usuarios deben incluirse en las versiones guardadas
|
||||
|
||||
:::tip
|
||||
|
||||
De forma predeterminada, las versiones guardadas no incluyen datos de colecciones creadas por usuarios. Solo necesitas seleccionar colecciones aquí cuando quieras restaurar también parte de los datos de negocio.
|
||||
|
||||
:::
|
||||
|
||||
Si incluyes una colección de usuario, NocoBase también incluye automáticamente las colecciones relacionadas, por lo que la restauración suele ser más completa.
|
||||
|
||||
## Enlaces relacionados
|
||||
|
||||
- [Gestión de copias de seguridad](../backup-manager/index.mdx) — capacidad base de la que depende el control de versiones
|
||||
- [Gestión de migraciones](../migration-manager/index.md) — mover la configuración de la aplicación entre entornos
|
||||
- [Gestión de publicaciones](../release-management/index.md) — planificar flujos de publicación con copias de seguridad, migraciones y variables
|
||||
@@ -18,6 +18,11 @@
|
||||
"label": "Gestion des sauvegardes",
|
||||
"link": "/ops-management/backup-manager/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Gestion des versions",
|
||||
"link": "/ops-management/version-control/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Gestion des migrations",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "Gestion des versions"
|
||||
description: "Guide du plugin de gestion des versions : créer des versions, les restaurer, régler la rétention, définir un raccourci et inclure des collections utilisateur."
|
||||
keywords: "Gestion des versions,Version control,gestion des opérations,créer une version,restaurer une version,NocoBase"
|
||||
---
|
||||
|
||||
# Gestion des versions
|
||||
|
||||
Dans NocoBase, **Gestion des versions** permet d'enregistrer une version restaurable de l'application actuelle. Tu peux créer des versions manuellement, restaurer une version enregistrée quand nécessaire, et utiliser les réglages du plugin pour définir combien de versions conserver, quel raccourci utiliser et quelles collections utilisateur doivent être incluses dans une version.
|
||||
|
||||
Cette fonction dépend de [Gestion des sauvegardes](../backup-manager/index.mdx). Si le plugin de gestion des versions est déjà activé mais que le système affiche encore des erreurs liées à cette fonction, vérifie d'abord que la gestion des sauvegardes est aussi activée.
|
||||
|
||||
## Ouvrir le plugin
|
||||
|
||||
Tu peux ouvrir le plugin depuis 「System settings」 → 「Version control」. Un bouton de gestion des versions apparaît aussi dans la barre supérieure. Il permet de créer une version directement ou d'aller à la liste des versions. Le raccourci par défaut pour créer une version est `Ctrl + K`, et tu peux le modifier dans l'onglet des réglages.
|
||||
|
||||

|
||||
|
||||
## Créer une version
|
||||
|
||||
Clique sur 「Create version」, saisis une description, puis enregistre. La description peut contenir jusqu'à 2000 caractères. Elle sert bien à noter le contexte du changement, par exemple « Ajustement des champs et permissions du flux d'approbation ».
|
||||
|
||||

|
||||
|
||||
Après avoir cliqué sur enregistrer, la liste affiche d'abord une ligne temporaire avec l'état « Saving ». Une fois terminée, la version enregistrée apparaît dans la liste.
|
||||
|
||||
Points clés :
|
||||
|
||||
- Le nom de version est généré automatiquement
|
||||
- Créer une version depuis la barre supérieure, le raccourci ou la page de liste produit le même résultat
|
||||
- La liste affiche le nom, la description, la taille du fichier, la date de création, l'auteur et les actions disponibles
|
||||
|
||||
## Gérer et restaurer des versions
|
||||
|
||||
La liste des versions propose principalement ces actions :
|
||||
|
||||
- 「Refresh」 recharge la liste actuelle
|
||||
- 「Delete」 supprime une version, ou plusieurs versions sélectionnées
|
||||
- 「Restore」 restaure l'application dans l'état enregistré par cette version
|
||||
|
||||
:::warning Attention
|
||||
|
||||
La restauration d'une version écrase la configuration actuelle de l'application ainsi que les données incluses dans cette version. Il est recommandé de créer d'abord une version de l'état actuel pour pouvoir revenir en arrière si besoin.
|
||||
|
||||
:::
|
||||
|
||||
Après un clic sur 「Restore」, l'application passe brièvement en mode maintenance pendant la restauration. N'envoie pas une autre demande de restauration pendant ce temps. Si l'opération échoue, l'interface affiche une notification d'erreur.
|
||||
|
||||
## Configurer les règles de version
|
||||
|
||||
Ouvre l'onglet 「Settings」 pour contrôler la rétention et le contenu de chaque version.
|
||||
|
||||

|
||||
|
||||
Les réglages incluent :
|
||||
|
||||
- `Versions to keep` : nombre maximal de versions conservées. Les versions les plus anciennes sont supprimées automatiquement une fois la limite dépassée
|
||||
- `Shortcut: create version` : raccourci pour créer une version. Appuie sur `Ctrl + une lettre` pour le définir, ou sur `Backspace` pour l'effacer
|
||||
- `User collections` : choisir quelles données des collections créées par les utilisateurs doivent être incluses dans les versions enregistrées
|
||||
|
||||
:::tip
|
||||
|
||||
Par défaut, les versions enregistrées n'incluent pas les données des collections créées par les utilisateurs. Tu n'as besoin de sélectionner des collections ici que si tu veux restaurer aussi certaines données métier avec la version de l'application.
|
||||
|
||||
:::
|
||||
|
||||
Si tu inclus une collection utilisateur, NocoBase inclut aussi automatiquement les collections liées, ce qui rend généralement la restauration plus complète.
|
||||
|
||||
## Liens associés
|
||||
|
||||
- [Gestion des sauvegardes](../backup-manager/index.mdx) — capacité de base requise par la gestion des versions
|
||||
- [Gestion des migrations](../migration-manager/index.md) — déplacer la configuration de l'application entre plusieurs environnements
|
||||
- [Gestion des publications](../release-management/index.md) — planifier un processus de publication avec sauvegardes, migrations et variables
|
||||
@@ -18,6 +18,11 @@
|
||||
"label": "Manajemen Backup",
|
||||
"link": "/ops-management/backup-manager/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Kontrol versi",
|
||||
"link": "/ops-management/version-control/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Manajemen Migrasi",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "Kontrol versi"
|
||||
description: "Panduan plugin kontrol versi: membuat versi, memulihkan versi, mengatur retensi, shortcut, dan koleksi pengguna yang ikut disimpan."
|
||||
keywords: "Kontrol versi,Version control,manajemen operasi,membuat versi,memulihkan versi,NocoBase"
|
||||
---
|
||||
|
||||
# Kontrol versi
|
||||
|
||||
Di NocoBase, **Kontrol versi** memungkinkan kamu menyimpan versi aplikasi saat ini yang bisa dipulihkan kembali. Kamu bisa membuat versi secara manual, memulihkan aplikasi ke versi yang sudah disimpan saat diperlukan, dan memakai pengaturan plugin untuk mengatur berapa banyak versi yang disimpan, shortcut yang dipakai, serta koleksi pengguna mana yang ikut disimpan bersama versi tersebut.
|
||||
|
||||
Fitur ini bergantung pada [Manajemen Backup](../backup-manager/index.mdx). Jika plugin kontrol versi sudah diaktifkan tetapi sistem masih menampilkan error terkait, pastikan dulu Manajemen Backup juga aktif.
|
||||
|
||||
## Membuka plugin
|
||||
|
||||
Kamu bisa membukanya dari 「System settings」 → 「Version control」. Tombol kontrol versi juga muncul di bilah atas. Dari sana kamu bisa langsung membuat versi atau membuka daftar versi. Shortcut bawaan untuk membuat versi adalah `Ctrl + K`, dan kamu bisa mengubahnya di tab pengaturan.
|
||||
|
||||

|
||||
|
||||
## Membuat versi
|
||||
|
||||
Klik 「Create version」, isi deskripsi, lalu simpan. Deskripsi bisa sampai 2000 karakter. Bagian ini cocok untuk mencatat konteks perubahan, misalnya “Menyesuaikan field dan izin alur persetujuan”.
|
||||
|
||||

|
||||
|
||||
Setelah kamu mengklik simpan, daftar akan menampilkan entri sementara dengan status “Saving”. Setelah selesai, versi yang tersimpan akan muncul di daftar.
|
||||
|
||||
Poin penting:
|
||||
|
||||
- Nama versi dibuat otomatis
|
||||
- Membuat versi dari bilah atas, shortcut, atau halaman daftar memberikan hasil yang sama
|
||||
- Daftar menampilkan nama versi, deskripsi, ukuran file, waktu pembuatan, pembuat, dan tindakan yang tersedia
|
||||
|
||||
## Mengelola dan memulihkan versi
|
||||
|
||||
Daftar versi terutama menyediakan tindakan berikut:
|
||||
|
||||
- 「Refresh」 memuat ulang daftar saat ini
|
||||
- 「Delete」 menghapus satu versi atau beberapa versi yang dipilih
|
||||
- 「Restore」 memulihkan aplikasi ke keadaan yang tersimpan pada versi itu
|
||||
|
||||
:::warning Perhatian
|
||||
|
||||
Memulihkan versi akan menimpa konfigurasi aplikasi saat ini dan data yang termasuk dalam versi tersebut. Sebaiknya buat dulu versi dari keadaan saat ini sebelum melakukan pemulihan, supaya kamu bisa kembali lagi jika perlu.
|
||||
|
||||
:::
|
||||
|
||||
Setelah kamu mengklik 「Restore」, aplikasi akan masuk ke mode pemeliharaan untuk waktu singkat selama proses pemulihan berjalan. Jangan kirim permintaan pemulihan lain selama proses ini. Jika pemulihan gagal, antarmuka akan menampilkan notifikasi kesalahan.
|
||||
|
||||
## Mengatur aturan versi
|
||||
|
||||
Buka tab 「Settings」 untuk mengatur retensi dan isi setiap versi.
|
||||
|
||||

|
||||
|
||||
Pengaturannya meliputi:
|
||||
|
||||
- `Versions to keep`: jumlah maksimum versi yang disimpan. Versi lama akan dihapus otomatis setelah batas terlampaui
|
||||
- `Shortcut: create version`: shortcut untuk membuat versi. Tekan `Ctrl + huruf` untuk mengatur, atau `Backspace` untuk menghapus
|
||||
- `User collections`: pilih data dari koleksi buatan pengguna mana yang harus ikut dimasukkan ke dalam versi yang disimpan
|
||||
|
||||
:::tip
|
||||
|
||||
Secara default, versi yang disimpan tidak menyertakan data dari koleksi buatan pengguna. Kamu hanya perlu memilih koleksi di sini jika ingin memulihkan sebagian data bisnis bersama versi aplikasi.
|
||||
|
||||
:::
|
||||
|
||||
Jika kamu menyertakan satu koleksi pengguna, NocoBase juga akan menyertakan koleksi terkait secara otomatis, sehingga hasil pemulihan biasanya lebih lengkap.
|
||||
|
||||
## Tautan terkait
|
||||
|
||||
- [Manajemen Backup](../backup-manager/index.mdx) — kemampuan dasar yang dibutuhkan kontrol versi
|
||||
- [Manajemen Migrasi](../migration-manager/index.md) — memindahkan konfigurasi aplikasi antar lingkungan
|
||||
- [Manajemen Release](../release-management/index.md) — merencanakan alur rilis dengan backup, migrasi, dan variabel
|
||||
@@ -18,6 +18,11 @@
|
||||
"label": "バックアップ管理",
|
||||
"link": "/ops-management/backup-manager/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "バージョン管理",
|
||||
"link": "/ops-management/version-control/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "移行管理",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "バージョン管理"
|
||||
description: "バージョン管理プラグインの操作ガイド。バージョンの作成、復元、保持数、ショートカット、ユーザーコレクションの設定を説明します。"
|
||||
keywords: "バージョン管理,Version control,運用管理,バージョン作成,バージョン復元,NocoBase"
|
||||
---
|
||||
|
||||
# バージョン管理
|
||||
|
||||
NocoBase の **バージョン管理(Version control)** は、現在のアプリケーションを復元可能なバージョンとして保存するための機能です。手動でバージョンを作成し、必要なときに保存済みの状態へ戻し、プラグイン設定で保持数、ショートカット、どのユーザーコレクションを一緒に保存するかを設定できます。
|
||||
|
||||
[バックアップ管理](../backup-manager/index.mdx) に依存します。バージョン管理プラグインを有効にしているのに関連エラーが表示される場合は、まずバックアップ管理が有効か確認してください。
|
||||
|
||||
## プラグインを開く
|
||||
|
||||
「System settings」の「Version control」から開けます。トップバーにもバージョン管理ボタンが表示され、そこから直接バージョンを作成したり、バージョン一覧へ移動したりできます。バージョン作成のデフォルトショートカットは `Ctrl + K` で、設定タブから変更できます。
|
||||
|
||||

|
||||
|
||||
## バージョンを作成する
|
||||
|
||||
「Create version」をクリックし、説明を入力して保存します。説明は最大 2000 文字です。たとえば「承認フローのフィールドと権限を調整」のように、変更内容の背景を残すのに向いています。
|
||||
|
||||

|
||||
|
||||
保存をクリックすると、一覧にはまず「Saving」状態の行が表示されます。保存が終わると正式なバージョンとして一覧に表示されます。
|
||||
|
||||
主なポイント:
|
||||
|
||||
- バージョン名は自動生成されます
|
||||
- トップバー、ショートカット、一覧ページのどこから作成しても動作は同じです
|
||||
- 一覧には名前、説明、サイズ、作成日時、作成者、操作が表示されます
|
||||
|
||||
## バージョンの管理と復元
|
||||
|
||||
バージョン一覧では主に次の操作を使います:
|
||||
|
||||
- 「Refresh」: 一覧を再読み込みします
|
||||
- 「Delete」: 単一のバージョン、または選択した複数のバージョンを削除します
|
||||
- 「Restore」: アプリケーションをそのバージョンの保存時点の状態に戻します
|
||||
|
||||
:::warning 注意
|
||||
|
||||
バージョンを復元すると、現在のアプリケーション設定と、そのバージョンに含まれるデータが上書きされます。復元前に現在の状態を新しいバージョンとして保存しておくことをおすすめします。
|
||||
|
||||
:::
|
||||
|
||||
「Restore」をクリックすると、復元中はアプリケーションが短時間メンテナンス状態になります。その間は再度復元を実行しないでください。失敗した場合は UI にエラー通知が表示されます。
|
||||
|
||||
## バージョンルールを設定する
|
||||
|
||||
「Settings」タブでは、保持数と保存範囲を設定できます。
|
||||
|
||||

|
||||
|
||||
設定項目は次のとおりです:
|
||||
|
||||
- `Versions to keep`: 保存しておくバージョン数の上限です。上限を超えると古いバージョンは自動削除されます
|
||||
- `Shortcut: create version`: バージョン作成のショートカットです。`Ctrl + 文字キー` で設定し、`Backspace` で解除します
|
||||
- `User collections`: どのユーザー作成コレクションのデータをバージョンに含めるかを選びます
|
||||
|
||||
:::tip
|
||||
|
||||
デフォルトでは、ユーザー作成コレクションのデータはバージョンに含まれません。業務データも一緒に復元したい場合だけ、ここで対象コレクションを選んでください。
|
||||
|
||||
:::
|
||||
|
||||
ユーザーコレクションを選ぶと、NocoBase は関連コレクションも自動的に含めるため、復元結果が通常より完全になります。
|
||||
|
||||
## 関連リンク
|
||||
|
||||
- [バックアップ管理](../backup-manager/index.mdx) — バージョン管理が依存する基盤機能
|
||||
- [移行管理](../migration-manager/index.md) — アプリケーション設定を別環境へ移行する
|
||||
- [リリース管理](../release-management/index.md) — バックアップ、移行、変数設定を含む公開フローを整理する
|
||||
@@ -18,6 +18,11 @@
|
||||
"label": "Gerenciamento de backups",
|
||||
"link": "/ops-management/backup-manager/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Controle de versão",
|
||||
"link": "/ops-management/version-control/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Gerenciamento de migrações",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "Controle de versão"
|
||||
description: "Guia do plugin de controle de versão: criar versões, restaurar versões, configurar retenção, atalho e coleções de usuário incluídas."
|
||||
keywords: "Controle de versão,Version control,gestão operacional,criar versão,restaurar versão,NocoBase"
|
||||
---
|
||||
|
||||
# Controle de versão
|
||||
|
||||
No NocoBase, **Controle de versão** permite salvar uma versão restaurável da aplicação atual. Você pode criar versões manualmente, restaurar uma versão salva quando precisar e usar as configurações do plugin para controlar quantas versões manter, qual atalho usar e quais coleções de usuário devem ser salvas junto com a versão.
|
||||
|
||||
Ele depende de [Gerenciamento de backups](../backup-manager/index.mdx). Se o plugin de controle de versão já estiver habilitado, mas o sistema ainda mostrar erros relacionados, confirme primeiro que o Gerenciamento de backups também está habilitado.
|
||||
|
||||
## Abrir o plugin
|
||||
|
||||
Você pode abrir o plugin em 「System settings」 → 「Version control」. Um botão de controle de versão também aparece na barra superior. A partir dele, você pode criar uma versão diretamente ou ir para a lista de versões. O atalho padrão para criar uma versão é `Ctrl + K`, e você pode alterá-lo na aba de configurações.
|
||||
|
||||

|
||||
|
||||
## Criar uma versão
|
||||
|
||||
Clique em 「Create version」, preencha uma descrição e salve. A descrição pode ter até 2000 caracteres. Ela é útil para registrar o contexto da alteração, como “Ajuste dos campos e permissões do fluxo de aprovação”.
|
||||
|
||||

|
||||
|
||||
Depois de clicar em salvar, a lista mostra primeiro um item temporário em estado “Saving”. Quando termina, a versão salva aparece na lista.
|
||||
|
||||
Pontos principais:
|
||||
|
||||
- O nome da versão é gerado automaticamente
|
||||
- Criar pela barra superior, pelo atalho ou pela página da lista produz o mesmo resultado
|
||||
- A lista mostra nome da versão, descrição, tamanho do arquivo, hora de criação, criador e ações disponíveis
|
||||
|
||||
## Gerenciar e restaurar versões
|
||||
|
||||
A lista de versões oferece principalmente estas ações:
|
||||
|
||||
- 「Refresh」 recarrega a lista atual
|
||||
- 「Delete」 remove uma versão ou várias versões selecionadas
|
||||
- 「Restore」 restaura a aplicação para o estado salvo naquela versão
|
||||
|
||||
:::warning Atenção
|
||||
|
||||
Restaurar uma versão sobrescreve a configuração atual da aplicação e os dados incluídos naquela versão. Recomenda-se criar antes uma versão do estado atual, para que você possa voltar atrás se precisar.
|
||||
|
||||
:::
|
||||
|
||||
Depois de clicar em 「Restore」, a aplicação entra em modo de manutenção por um curto período enquanto a restauração é executada. Não envie outra restauração durante esse processo. Se a restauração falhar, a interface mostrará uma notificação de erro.
|
||||
|
||||
## Configurar as regras de versão
|
||||
|
||||
Abra a aba 「Settings」 para controlar retenção e conteúdo de cada versão.
|
||||
|
||||

|
||||
|
||||
As configurações incluem:
|
||||
|
||||
- `Versions to keep`: número máximo de versões salvas. As versões mais antigas são removidas automaticamente quando o limite é excedido
|
||||
- `Shortcut: create version`: atalho para criar uma versão. Pressione `Ctrl + uma letra` para definir e `Backspace` para limpar
|
||||
- `User collections`: escolha quais dados de coleções criadas por usuários devem ser incluídos nas versões salvas
|
||||
|
||||
:::tip
|
||||
|
||||
Por padrão, as versões salvas não incluem dados de coleções criadas por usuários. Você só precisa selecionar coleções aqui quando quiser restaurar também parte dos dados de negócio.
|
||||
|
||||
:::
|
||||
|
||||
Se você incluir uma coleção de usuário, o NocoBase também incluirá automaticamente as coleções relacionadas, então a restauração costuma ficar mais completa.
|
||||
|
||||
## Links relacionados
|
||||
|
||||
- [Gerenciamento de backups](../backup-manager/index.mdx) — capacidade básica exigida pelo controle de versão
|
||||
- [Gerenciamento de migrações](../migration-manager/index.md) — mover a configuração da aplicação entre ambientes
|
||||
- [Gerenciamento de publicações](../release-management/index.md) — planejar fluxos de publicação com backups, migrações e variáveis
|
||||
@@ -18,6 +18,11 @@
|
||||
"label": "Управление резервными копиями",
|
||||
"link": "/ops-management/backup-manager/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Управление версиями",
|
||||
"link": "/ops-management/version-control/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Управление миграциями",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "Управление версиями"
|
||||
description: "Руководство по плагину управления версиями: создание версий, восстановление, настройка хранения, горячих клавиш и включаемых пользовательских коллекций."
|
||||
keywords: "Управление версиями,Version control,операционное управление,создать версию,восстановить версию,NocoBase"
|
||||
---
|
||||
|
||||
# Управление версиями
|
||||
|
||||
В NocoBase **Управление версиями (Version control)** позволяет сохранить текущее приложение как восстанавливаемую версию. Ты можешь вручную создавать версии, при необходимости восстанавливать приложение до сохраненного состояния и через настройки плагина управлять количеством хранимых версий, горячей клавишей и пользовательскими коллекциями, которые нужно включать в версию.
|
||||
|
||||
Этот плагин зависит от [Управления резервными копиями](../backup-manager/index.mdx). Если плагин управления версиями уже включен, но система все равно показывает связанные ошибки, сначала проверь, что модуль резервных копий тоже включен.
|
||||
|
||||
## Открыть плагин
|
||||
|
||||
Плагин можно открыть через 「System settings」 → 「Version control」. Кнопка управления версиями также появляется в верхней панели. Через нее можно сразу создать версию или перейти к списку версий. Горячая клавиша по умолчанию для создания версии — `Ctrl + K`, ее можно изменить на вкладке настроек.
|
||||
|
||||

|
||||
|
||||
## Создать версию
|
||||
|
||||
Нажми 「Create version」, введи описание и сохрани. Описание может содержать до 2000 символов. Его удобно использовать, чтобы зафиксировать контекст изменения, например: «Изменены поля и права в процессе согласования».
|
||||
|
||||

|
||||
|
||||
После нажатия на сохранение в списке сначала появляется временная запись со статусом «Saving». Когда задача завершается, сохраненная версия появляется в списке.
|
||||
|
||||
Основные моменты:
|
||||
|
||||
- Имя версии генерируется автоматически
|
||||
- Создание версии из верхней панели, по горячей клавише или со страницы списка работает одинаково
|
||||
- В списке показываются имя версии, описание, размер файла, время создания, автор и доступные действия
|
||||
|
||||
## Управление и восстановление версий
|
||||
|
||||
На странице списка версий доступны такие действия:
|
||||
|
||||
- 「Refresh」 — перезагрузить текущий список
|
||||
- 「Delete」 — удалить одну версию или несколько выбранных версий
|
||||
- 「Restore」 — восстановить приложение до состояния, сохраненного в этой версии
|
||||
|
||||
:::warning Внимание
|
||||
|
||||
Восстановление версии перезаписывает текущую конфигурацию приложения и данные, включенные в эту версию. Перед восстановлением лучше сначала создать версию текущего состояния, чтобы при необходимости можно было быстро откатиться обратно.
|
||||
|
||||
:::
|
||||
|
||||
После нажатия 「Restore」 приложение ненадолго переходит в режим обслуживания, пока выполняется восстановление. Не запускай повторное восстановление в это время. Если операция завершится ошибкой, интерфейс покажет уведомление об ошибке.
|
||||
|
||||
## Настроить правила версий
|
||||
|
||||
Открой вкладку 「Settings」, чтобы управлять хранением версий и тем, что именно включается в версию.
|
||||
|
||||

|
||||
|
||||
Настройки включают:
|
||||
|
||||
- `Versions to keep`: максимальное количество сохраненных версий. После превышения лимита более старые версии удаляются автоматически
|
||||
- `Shortcut: create version`: горячая клавиша для создания версии. Нажми `Ctrl + буква`, чтобы задать ее, или `Backspace`, чтобы очистить
|
||||
- `User collections`: выбрать, какие данные из пользовательских коллекций должны включаться в сохраненные версии
|
||||
|
||||
:::tip
|
||||
|
||||
По умолчанию сохраненные версии не включают данные пользовательских коллекций. Выбирать коллекции здесь нужно только тогда, когда ты хочешь восстанавливать вместе с версией приложения и часть бизнес-данных.
|
||||
|
||||
:::
|
||||
|
||||
Если ты включаешь пользовательскую коллекцию, NocoBase автоматически добавляет и связанные коллекции, поэтому восстановление обычно получается более полным.
|
||||
|
||||
## Связанные ссылки
|
||||
|
||||
- [Управление резервными копиями](../backup-manager/index.mdx) — базовая возможность, на которой строится управление версиями
|
||||
- [Управление миграциями](../migration-manager/index.md) — перенос конфигурации приложения между окружениями
|
||||
- [Управление релизами](../release-management/index.md) — планирование процессов публикации с резервными копиями, миграциями и переменными
|
||||
@@ -18,6 +18,11 @@
|
||||
"label": "Quản lý sao lưu",
|
||||
"link": "/ops-management/backup-manager/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Quản lý phiên bản",
|
||||
"link": "/ops-management/version-control/"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Quản lý di chuyển",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "Quản lý phiên bản"
|
||||
description: "Hướng dẫn plugin quản lý phiên bản: tạo phiên bản, khôi phục phiên bản, cấu hình số lượng lưu giữ, phím tắt và các collection người dùng được đưa vào phiên bản."
|
||||
keywords: "Quản lý phiên bản,Version control,vận hành,tạo phiên bản,khôi phục phiên bản,NocoBase"
|
||||
---
|
||||
|
||||
# Quản lý phiên bản
|
||||
|
||||
Trong NocoBase, **Quản lý phiên bản (Version control)** giúp bạn lưu lại một phiên bản có thể khôi phục của ứng dụng hiện tại. Bạn có thể tạo phiên bản thủ công, khôi phục ứng dụng về một phiên bản đã lưu khi cần, đồng thời dùng phần cài đặt của plugin để kiểm soát số lượng phiên bản được giữ lại, phím tắt sử dụng và những collection người dùng nào sẽ được lưu kèm.
|
||||
|
||||
Plugin này phụ thuộc vào [Quản lý sao lưu](../backup-manager/index.mdx). Nếu bạn đã bật plugin quản lý phiên bản nhưng hệ thống vẫn hiển thị lỗi liên quan, trước hết hãy kiểm tra xem plugin Quản lý sao lưu đã được bật chưa.
|
||||
|
||||
## Mở plugin
|
||||
|
||||
Bạn có thể mở plugin từ 「System settings」 → 「Version control」. Một nút quản lý phiên bản cũng xuất hiện trên thanh trên cùng. Từ đó bạn có thể tạo phiên bản trực tiếp hoặc chuyển tới danh sách phiên bản. Phím tắt mặc định để tạo phiên bản là `Ctrl + K`, và bạn có thể đổi nó trong tab cài đặt.
|
||||
|
||||

|
||||
|
||||
## Tạo phiên bản
|
||||
|
||||
Nhấp vào 「Create version」, nhập mô tả rồi lưu. Mô tả có thể dài tối đa 2000 ký tự. Trường này phù hợp để ghi lại bối cảnh thay đổi, ví dụ “Điều chỉnh trường và quyền trong quy trình phê duyệt”.
|
||||
|
||||

|
||||
|
||||
Sau khi nhấp lưu, danh sách sẽ hiển thị trước một dòng tạm thời ở trạng thái “Saving”. Khi hoàn tất, phiên bản đã lưu sẽ xuất hiện trong danh sách.
|
||||
|
||||
Các điểm chính:
|
||||
|
||||
- Tên phiên bản được tạo tự động
|
||||
- Tạo từ thanh trên cùng, phím tắt hoặc trang danh sách đều cho cùng một kết quả
|
||||
- Danh sách hiển thị tên phiên bản, mô tả, kích thước tệp, thời gian tạo, người tạo và các thao tác khả dụng
|
||||
|
||||
## Quản lý và khôi phục phiên bản
|
||||
|
||||
Danh sách phiên bản chủ yếu cung cấp các thao tác sau:
|
||||
|
||||
- 「Refresh」 tải lại danh sách hiện tại
|
||||
- 「Delete」 xóa một phiên bản hoặc nhiều phiên bản đã chọn
|
||||
- 「Restore」 khôi phục ứng dụng về trạng thái đã lưu trong phiên bản đó
|
||||
|
||||
:::warning Lưu ý
|
||||
|
||||
Khôi phục phiên bản sẽ ghi đè cấu hình hiện tại của ứng dụng và dữ liệu được bao gồm trong phiên bản đó. Bạn nên tạo trước một phiên bản của trạng thái hiện tại để có thể quay lại khi cần.
|
||||
|
||||
:::
|
||||
|
||||
Sau khi nhấp 「Restore」, ứng dụng sẽ vào chế độ bảo trì trong thời gian ngắn khi quá trình khôi phục đang chạy. Đừng gửi thêm một yêu cầu khôi phục khác trong lúc này. Nếu khôi phục thất bại, giao diện sẽ hiển thị thông báo lỗi.
|
||||
|
||||
## Cấu hình quy tắc phiên bản
|
||||
|
||||
Mở tab 「Settings」 để kiểm soát số lượng lưu giữ và nội dung của mỗi phiên bản.
|
||||
|
||||

|
||||
|
||||
Các mục cài đặt gồm:
|
||||
|
||||
- `Versions to keep`: số lượng phiên bản lưu tối đa. Các phiên bản cũ sẽ bị xóa tự động khi vượt quá giới hạn
|
||||
- `Shortcut: create version`: phím tắt để tạo phiên bản. Nhấn `Ctrl + một chữ cái` để đặt, hoặc `Backspace` để xóa
|
||||
- `User collections`: chọn dữ liệu từ những collection do người dùng tạo sẽ được đưa vào các phiên bản đã lưu
|
||||
|
||||
:::tip
|
||||
|
||||
Mặc định, các phiên bản đã lưu không bao gồm dữ liệu từ collection do người dùng tạo. Bạn chỉ cần chọn collection ở đây khi muốn khôi phục cả một phần dữ liệu nghiệp vụ cùng với phiên bản của ứng dụng.
|
||||
|
||||
:::
|
||||
|
||||
Nếu bạn đưa một collection người dùng vào, NocoBase cũng sẽ tự động đưa các collection liên quan vào, vì vậy kết quả khôi phục thường đầy đủ hơn.
|
||||
|
||||
## Liên kết liên quan
|
||||
|
||||
- [Quản lý sao lưu](../backup-manager/index.mdx) — năng lực nền tảng mà quản lý phiên bản phụ thuộc vào
|
||||
- [Quản lý di chuyển](../migration-manager/index.md) — chuyển cấu hình ứng dụng giữa các môi trường
|
||||
- [Quản lý phát hành](../release-management/index.md) — lên kế hoạch quy trình phát hành với sao lưu, di chuyển và biến cấu hình
|
||||
@@ -11,6 +11,25 @@ import { Args, Command, Flags } from '@oclif/core';
|
||||
import { executeRawApiRequest } from '../../lib/api-client.js';
|
||||
import { ensureCrossEnvConfirmed } from '../../lib/env-guard.js';
|
||||
|
||||
const VERSION_CONTROL_PLUGIN_PACKAGE = '@nocobase/plugin-version-control';
|
||||
|
||||
interface PluginListSummaryItem {
|
||||
packageName?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function extractPluginList(data: unknown): PluginListSummaryItem[] {
|
||||
const list = Array.isArray(data) ? data : isRecord(data) && Array.isArray(data.data) ? data.data : [];
|
||||
return list.filter(isRecord).map((item) => ({
|
||||
packageName: typeof item.packageName === 'string' ? item.packageName : undefined,
|
||||
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export default class RevisionCreate extends Command {
|
||||
static override summary = 'Save the current NocoBase build as a restorable revision';
|
||||
|
||||
@@ -49,7 +68,7 @@ export default class RevisionCreate extends Command {
|
||||
'json-output': Flags.boolean({
|
||||
char: 'j',
|
||||
description: 'Print raw JSON response',
|
||||
default: true,
|
||||
default: false,
|
||||
allowNo: true,
|
||||
}),
|
||||
};
|
||||
@@ -71,6 +90,33 @@ export default class RevisionCreate extends Command {
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginListResponse = await executeRawApiRequest({
|
||||
envName: flags.env,
|
||||
baseUrl: flags['api-base-url'],
|
||||
role: flags.role,
|
||||
token: flags.token,
|
||||
method: 'GET',
|
||||
path: '/pm:list',
|
||||
query: {
|
||||
mode: 'summary',
|
||||
},
|
||||
});
|
||||
|
||||
if (!pluginListResponse.ok) {
|
||||
this.error(
|
||||
`Failed to check plugin status with status ${pluginListResponse.status}\n${JSON.stringify(pluginListResponse.data, null, 2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const versionControlPlugin = extractPluginList(pluginListResponse.data).find(
|
||||
(plugin) => plugin.packageName === VERSION_CONTROL_PLUGIN_PACKAGE,
|
||||
);
|
||||
if (!versionControlPlugin?.enabled) {
|
||||
this.error(
|
||||
`The ${VERSION_CONTROL_PLUGIN_PACKAGE} plugin is not enabled. Enable it first with \`nb plugin enable ${VERSION_CONTROL_PLUGIN_PACKAGE}\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await executeRawApiRequest({
|
||||
envName: flags.env,
|
||||
baseUrl: flags['api-base-url'],
|
||||
@@ -96,6 +142,6 @@ export default class RevisionCreate extends Command {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`HTTP ${response.status}`);
|
||||
this.log('Revision created successfully');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,10 +93,24 @@ export type DumpRules =
|
||||
|
||||
export type MigrationRule = 'overwrite' | 'skip' | 'upsert' | 'schema-only' | 'insert-ignore' | (string & {}) | null;
|
||||
|
||||
/**
|
||||
* `dataCategory = system` marks data that is foundational to system operation and should be treated as core runtime data.
|
||||
* `dataCategory = business` marks data owned by plugin features and used as part of the plugin's business domain.
|
||||
* `dataCategory = runtime` excludes the collection's data from backup snapshots.
|
||||
*/
|
||||
export type DataCategory = 'system' | 'business' | 'runtime';
|
||||
export type DataCategories = DataCategory | DataCategory[];
|
||||
export const TAG = {
|
||||
basic: 'basic',
|
||||
business: 'business',
|
||||
ignoredBackup: 'ignored:backup',
|
||||
};
|
||||
|
||||
export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'> {
|
||||
name: string;
|
||||
title?: string;
|
||||
namespace?: string;
|
||||
dataCategory?: DataCategories;
|
||||
migrationRules?: MigrationRule[];
|
||||
dumpRules?: DumpRules;
|
||||
tableName?: string;
|
||||
@@ -180,6 +194,10 @@ export class Collection<
|
||||
this.setSortable(options.sortable);
|
||||
}
|
||||
|
||||
get dataCategory() {
|
||||
return this.options.dataCategory;
|
||||
}
|
||||
|
||||
get underscored() {
|
||||
return this.options.underscored;
|
||||
}
|
||||
|
||||
@@ -325,6 +325,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
|
||||
this.collection({
|
||||
name: 'migrations',
|
||||
dataCategory: 'system',
|
||||
autoGenId: false,
|
||||
timestamps: false,
|
||||
dumpRules: 'required',
|
||||
|
||||
@@ -20,6 +20,7 @@ export class ApplicationVersion {
|
||||
app.db.collection({
|
||||
origin: '@nocobase/server',
|
||||
name: 'applicationVersion',
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['schema-only'],
|
||||
dataType: 'meta',
|
||||
timestamps: false,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'applicationPlugins',
|
||||
dataCategory: 'system',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
repository: 'PluginManagerRepository',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'rolesUsers',
|
||||
dataCategory: 'business',
|
||||
description: "User's roles",
|
||||
dumpRules: {
|
||||
group: 'user',
|
||||
|
||||
@@ -15,6 +15,7 @@ export default defineCollection({
|
||||
description: 'Role data',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
name: 'roles',
|
||||
dataCategory: 'system',
|
||||
title: '{{t("Roles")}}',
|
||||
autoGenId: false,
|
||||
model: 'RoleModel',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'rolesResources',
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
model: 'RoleResourceModel',
|
||||
indexes: [
|
||||
|
||||
@@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'rolesResourcesActions',
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
model: 'RoleResourceActionModel',
|
||||
fields: [
|
||||
|
||||
@@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'rolesResourcesScopes',
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
fields: [
|
||||
{
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'customRequests',
|
||||
dataCategory: 'system',
|
||||
autoGenId: false,
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
fields: [
|
||||
|
||||
+1
@@ -12,5 +12,6 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'customRequestsRoles',
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { FilterGroupType } from '@nocobase/utils';
|
||||
|
||||
export default {
|
||||
name: 'aiContextDatasources',
|
||||
dataCategory: 'business',
|
||||
migrationRules: ['schema-only'],
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ export default defineCollection({
|
||||
migrationRules: ['schema-only'],
|
||||
autoGenId: false,
|
||||
name: 'aiConversations',
|
||||
dataCategory: 'business',
|
||||
fields: [
|
||||
{
|
||||
name: 'sessionId',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
import aiEmployees from '../../collections/ai-employees';
|
||||
|
||||
export default defineCollection({
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
autoGenId: false,
|
||||
sortable: true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
migrationRules: ['schema-only', 'skip'],
|
||||
name: 'aiFiles',
|
||||
dataCategory: 'business',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
template: 'file',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
dataCategory: 'business',
|
||||
migrationRules: ['schema-only'],
|
||||
autoGenId: false,
|
||||
name: 'aiMessages',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'aiSettings',
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ export default defineCollection({
|
||||
migrationRules: ['schema-only'],
|
||||
autoGenId: false,
|
||||
name: 'aiToolMessages',
|
||||
dataCategory: 'business',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'lcCheckpointBlobs',
|
||||
dataCategory: 'business',
|
||||
migrationRules: ['schema-only'],
|
||||
autoGenId: false,
|
||||
fields: [
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'lcCheckpointWrites',
|
||||
dataCategory: 'business',
|
||||
migrationRules: ['schema-only'],
|
||||
autoGenId: false,
|
||||
fields: [
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'lcCheckpoints',
|
||||
dataCategory: 'business',
|
||||
migrationRules: ['schema-only'],
|
||||
autoGenId: false,
|
||||
fields: [
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
import llmServices from '../../collections/llm-services';
|
||||
|
||||
export default defineCollection({
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
autoGenId: false,
|
||||
...llmServices,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'usersAiEmployees',
|
||||
dataCategory: 'business',
|
||||
migrationRules: ['schema-only'],
|
||||
fields: [
|
||||
{ type: 'sort', name: 'sort' },
|
||||
|
||||
@@ -17,6 +17,7 @@ export default {
|
||||
migrationRules: ['schema-only'],
|
||||
shared: true,
|
||||
name: 'apiKeys',
|
||||
dataCategory: 'business',
|
||||
sortable: 'sort',
|
||||
createdBy: true,
|
||||
updatedAt: false,
|
||||
|
||||
+1
@@ -9,6 +9,7 @@
|
||||
|
||||
export default {
|
||||
name: 'asyncTasks',
|
||||
dataCategory: 'business',
|
||||
autoGenId: false,
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['schema-only'],
|
||||
|
||||
@@ -19,6 +19,7 @@ export default defineCollection({
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
shared: true,
|
||||
name: 'authenticators',
|
||||
dataCategory: 'system',
|
||||
sortable: true,
|
||||
model: 'AuthModel',
|
||||
createdBy: true,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { issuedTokensCollectionName } from '../../constants';
|
||||
|
||||
export default defineCollection({
|
||||
name: issuedTokensCollectionName,
|
||||
dataCategory: 'business',
|
||||
migrationRules: ['schema-only'],
|
||||
autoGenId: false,
|
||||
createdAt: true,
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineCollection({
|
||||
migrationRules: ['schema-only'],
|
||||
shared: true,
|
||||
name: 'tokenBlacklist',
|
||||
dataCategory: 'business',
|
||||
model: 'TokenBlacklistModel',
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ import { tokenPolicyCollectionName } from '../../constants';
|
||||
|
||||
export default defineCollection({
|
||||
name: tokenPolicyCollectionName,
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
autoGenId: false,
|
||||
createdAt: true,
|
||||
|
||||
@@ -20,6 +20,7 @@ export default defineCollection({
|
||||
shared: true,
|
||||
migrationRules: ['schema-only', 'overwrite'],
|
||||
name: 'usersAuthenticators',
|
||||
dataCategory: 'business',
|
||||
model: 'UserAuthModel',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
|
||||
+183
-10
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import * as cp from 'child_process';
|
||||
import os from 'os';
|
||||
import { getDBAdapter } from '../../adapters/database';
|
||||
@@ -92,10 +101,71 @@ describe('DatabaseAdapter', () => {
|
||||
});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const dir = os.tmpdir();
|
||||
await adapter.backup(dir);
|
||||
await adapter.backup({ dir });
|
||||
expect(mockedExec.mock.lastCall[0]).toContain('pg_dump');
|
||||
});
|
||||
|
||||
it('backup function with included and excluded tables', async () => {
|
||||
const mockedExec = cp.exec as unknown as Mock;
|
||||
mockedExec.mockClear();
|
||||
mockedExec.mockImplementation((_command, _options, callback) => {
|
||||
callback(null, { stdout: 'done' });
|
||||
});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const dir = os.tmpdir();
|
||||
await adapter.backup({
|
||||
dir,
|
||||
includeTables: ['users', 'posts'],
|
||||
excludeTables: ['logs', 'audit_logs'],
|
||||
});
|
||||
const command = mockedExec.mock.lastCall[0];
|
||||
expect(command).toContain('pg_dump');
|
||||
expect(command).toContain(`-t '"users"' -t '"posts"'`);
|
||||
expect(command).toContain(`-T '"logs"' -T '"audit_logs"'`);
|
||||
});
|
||||
|
||||
it('backup function should quote mixed-case included and excluded tables', async () => {
|
||||
const mockedExec = cp.exec as unknown as Mock;
|
||||
mockedExec.mockClear();
|
||||
mockedExec.mockImplementation((_command, _options, callback) => {
|
||||
callback(null, { stdout: 'done' });
|
||||
});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const dir = os.tmpdir();
|
||||
await adapter.backup({
|
||||
dir,
|
||||
includeTables: ['applicationPlugins', 'public.rolesUsers'],
|
||||
excludeTables: ['dataSourcesCollections'],
|
||||
});
|
||||
const command = mockedExec.mock.lastCall[0];
|
||||
expect(command).toContain(`-t '"applicationPlugins"'`);
|
||||
expect(command).toContain(`-t '"public"."rolesUsers"'`);
|
||||
expect(command).toContain(`-T '"dataSourcesCollections"'`);
|
||||
});
|
||||
|
||||
it('backup function should qualify included and excluded tables with configured schema', async () => {
|
||||
const mockedExec = cp.exec as unknown as Mock;
|
||||
mockedExec.mockClear();
|
||||
mockedExec.mockImplementation((_command, _options, callback) => {
|
||||
callback(null, { stdout: 'done' });
|
||||
});
|
||||
const adapter = getDBAdapter({
|
||||
...dbOpts,
|
||||
schema: 'test_version_control',
|
||||
});
|
||||
const dir = os.tmpdir();
|
||||
await adapter.backup({
|
||||
dir,
|
||||
includeTables: ['applicationPlugins', 'public.rolesUsers'],
|
||||
excludeTables: ['dataSourcesCollections'],
|
||||
});
|
||||
const command = mockedExec.mock.lastCall[0];
|
||||
expect(command).toContain(`-t '"test_version_control"."applicationPlugins"'`);
|
||||
expect(command).toContain(`-t '"public"."rolesUsers"'`);
|
||||
expect(command).toContain(`-T '"test_version_control"."dataSourcesCollections"'`);
|
||||
expect(command).toContain(`--schema=test_version_control`);
|
||||
});
|
||||
|
||||
it('restore function', async () => {
|
||||
const mockedExec = cp.exec as unknown as Mock;
|
||||
mockedExec.mockImplementation((_command, _options, callback) => {
|
||||
@@ -103,10 +173,28 @@ describe('DatabaseAdapter', () => {
|
||||
});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const filePath = os.tmpdir();
|
||||
await adapter.restore(filePath);
|
||||
await adapter.restore({ filePath });
|
||||
expect(mockedExec.mock.lastCall[0]).toContain('pg_restore');
|
||||
});
|
||||
|
||||
it('restore function should skip dropping all tables when skipDropAllTables is true', async () => {
|
||||
const mockedExec = cp.exec as unknown as Mock;
|
||||
mockedExec.mockClear();
|
||||
mockedExec.mockImplementation((_command, _options, callback) => {
|
||||
callback(null, { stdout: 'done' });
|
||||
});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const filePath = os.tmpdir();
|
||||
await adapter.restore({ filePath, skipDropAllTables: true });
|
||||
|
||||
const commands = mockedExec.mock.calls.map(([command]) => command);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0]).toContain('pg_restore');
|
||||
expect(commands.some((command) => command.includes('DROP TABLE IF EXISTS'))).toBe(false);
|
||||
expect(commands.some((command) => command.includes('DROP VIEW IF EXISTS'))).toBe(false);
|
||||
expect(commands.some((command) => command.includes('DROP TRIGGER IF EXISTS'))).toBe(false);
|
||||
});
|
||||
|
||||
it('restore function should sync collection schema metadata when schema is renamed', async () => {
|
||||
const mockedExec = cp.exec as unknown as Mock;
|
||||
mockedExec.mockClear();
|
||||
@@ -119,7 +207,7 @@ describe('DatabaseAdapter', () => {
|
||||
tablePrefix: 'nb_',
|
||||
});
|
||||
|
||||
await adapter.restore(os.tmpdir(), 'source_schema');
|
||||
await adapter.restore({ filePath: os.tmpdir(), schema: 'source_schema' });
|
||||
|
||||
const commands = mockedExec.mock.calls.map(([command]) => command);
|
||||
expect(commands.some((command) => command.includes('jsonb_set') && command.includes('nb_collections'))).toBe(
|
||||
@@ -141,7 +229,7 @@ describe('DatabaseAdapter', () => {
|
||||
schema: 'public',
|
||||
});
|
||||
|
||||
await adapter.restore(os.tmpdir(), 'public');
|
||||
await adapter.restore({ filePath: os.tmpdir(), schema: 'public' });
|
||||
|
||||
const commands = mockedExec.mock.calls.map(([command]) => command);
|
||||
expect(commands.some((command) => command.includes('jsonb_set'))).toBe(false);
|
||||
@@ -186,11 +274,35 @@ describe('DatabaseAdapter', () => {
|
||||
const mockedSpawn = cp.spawn as unknown as Mock;
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const dir = os.tmpdir();
|
||||
await adapter.backup(dir);
|
||||
await adapter.backup({ dir });
|
||||
fs.promises.unlink(`${dir}/data`).catch(() => {});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('mysqldump', expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it('backup function with included and excluded tables', async () => {
|
||||
const mockedSpawn = cp.spawn as unknown as Mock;
|
||||
mockedSpawn.mockClear();
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const dir = os.tmpdir();
|
||||
await adapter.backup({
|
||||
dir,
|
||||
includeTables: ['users', 'posts'],
|
||||
excludeTables: ['logs', 'audit_logs'],
|
||||
});
|
||||
fs.promises.unlink(`${dir}/data`).catch(() => {});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'mysqldump',
|
||||
expect.arrayContaining([
|
||||
'test',
|
||||
'--ignore-table=test.logs',
|
||||
'--ignore-table=test.audit_logs',
|
||||
'users',
|
||||
'posts',
|
||||
]),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('restore function', async () => {
|
||||
const mockedExec = cp.exec as unknown as Mock;
|
||||
mockedExec.mockImplementation((_command, _options, callback) => {
|
||||
@@ -198,9 +310,26 @@ describe('DatabaseAdapter', () => {
|
||||
});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const filePath = os.tmpdir();
|
||||
await adapter.restore(filePath);
|
||||
await adapter.restore({ filePath });
|
||||
expect(mockedExec.mock.lastCall[0]).toContain('mysql');
|
||||
});
|
||||
|
||||
it('restore function should skip dropping all tables when skipDropAllTables is true', async () => {
|
||||
const mockedExec = cp.exec as unknown as Mock;
|
||||
mockedExec.mockClear();
|
||||
mockedExec.mockImplementation((_command, _options, callback) => {
|
||||
callback(null, { stdout: 'done' });
|
||||
});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const filePath = os.tmpdir();
|
||||
await adapter.restore({ filePath, skipDropAllTables: true });
|
||||
|
||||
const commands = mockedExec.mock.calls.map(([command]) => command);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0]).toContain('mysql');
|
||||
expect(commands[0]).toContain(` < ${filePath}`);
|
||||
expect(commands.some((command) => command.includes('drop_all_tables_and_triggers'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MariaDBAdapter', () => {
|
||||
@@ -245,11 +374,38 @@ describe('DatabaseAdapter', () => {
|
||||
});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const dir = os.tmpdir();
|
||||
await adapter.backup(dir);
|
||||
await adapter.backup({ dir });
|
||||
fs.promises.unlink(`${dir}/data`).catch(() => {});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('mysqldump', expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it('backup function with included and excluded tables', async () => {
|
||||
const mockedSpawn = cp.spawn as unknown as Mock;
|
||||
mockedSpawn.mockClear();
|
||||
(cp.execSync as Mock).mockImplementation((_command, _callback) => {
|
||||
return 'MySQL 8.0';
|
||||
});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const dir = os.tmpdir();
|
||||
await adapter.backup({
|
||||
dir,
|
||||
includeTables: ['users', 'posts'],
|
||||
excludeTables: ['logs', 'audit_logs'],
|
||||
});
|
||||
fs.promises.unlink(`${dir}/data`).catch(() => {});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'mysqldump',
|
||||
expect.arrayContaining([
|
||||
'test',
|
||||
'--ignore-table=test.logs',
|
||||
'--ignore-table=test.audit_logs',
|
||||
'users',
|
||||
'posts',
|
||||
]),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('restore function', async () => {
|
||||
const mockedExec = cp.exec as unknown as Mock;
|
||||
mockedExec.mockImplementation((_command, _options, callback) => {
|
||||
@@ -257,9 +413,26 @@ describe('DatabaseAdapter', () => {
|
||||
});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const filePath = os.tmpdir();
|
||||
await adapter.restore(filePath);
|
||||
await adapter.restore({ filePath });
|
||||
expect(mockedExec.mock.lastCall[0]).toContain('mysql');
|
||||
});
|
||||
|
||||
it('restore function should skip dropping all tables when skipDropAllTables is true', async () => {
|
||||
const mockedExec = cp.exec as unknown as Mock;
|
||||
mockedExec.mockClear();
|
||||
mockedExec.mockImplementation((_command, _options, callback) => {
|
||||
callback(null, { stdout: 'done' });
|
||||
});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const filePath = os.tmpdir();
|
||||
await adapter.restore({ filePath, skipDropAllTables: true });
|
||||
|
||||
const commands = mockedExec.mock.calls.map(([command]) => command);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0]).toContain('mysql');
|
||||
expect(commands[0]).toContain(` < ${filePath}`);
|
||||
expect(commands.some((command) => command.includes('drop_all_tables_and_triggers'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQLiteAdapter', () => {
|
||||
@@ -279,7 +452,7 @@ describe('DatabaseAdapter', () => {
|
||||
mockedCopyFile.mockImplementation((_src, _dest) => {});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const dir = os.tmpdir();
|
||||
await adapter.backup(dir);
|
||||
await adapter.backup({ dir });
|
||||
expect(mockedCopyFile.mock.lastCall[0]).toContain('database.sqlite');
|
||||
});
|
||||
|
||||
@@ -288,7 +461,7 @@ describe('DatabaseAdapter', () => {
|
||||
mockedCopyFile.mockImplementation((_src, _dest) => {});
|
||||
const adapter = getDBAdapter(dbOpts);
|
||||
const dir = os.tmpdir();
|
||||
await adapter.restore(dir);
|
||||
await adapter.restore({ filePath: dir });
|
||||
expect(mockedCopyFile.mock.lastCall[0]).toContain(dir);
|
||||
});
|
||||
});
|
||||
|
||||
+16
-2
@@ -12,7 +12,7 @@ import { BackupManager, BackupSettings } from '../../managers/backup';
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import path from 'path';
|
||||
import { storagePathJoin } from '@nocobase/utils';
|
||||
import { BACKUP_EXTENSION } from '../../utils';
|
||||
import { BACKUP_EXTENSION, METADATA_EXTENSION } from '../../utils';
|
||||
import fs from 'fs';
|
||||
import * as cp from 'child_process';
|
||||
import PluginFileManagerServer from '@nocobase/plugin-file-manager';
|
||||
@@ -57,6 +57,7 @@ vi.mock('child_process', async (importOriginal) => {
|
||||
const backupFileBaseName = 'backup_for_unit_tests';
|
||||
const backupFilesFolder = storagePathJoin('backups', 'main');
|
||||
const finalBackupFilePath = path.join(backupFilesFolder, `${backupFileBaseName}.${BACKUP_EXTENSION}`);
|
||||
const finalMetadataFilePath = path.join(backupFilesFolder, `${backupFileBaseName}${METADATA_EXTENSION}`);
|
||||
|
||||
async function listZipEntries(filePath: string) {
|
||||
return await new Promise<string[]>((resolve, reject) => {
|
||||
@@ -98,6 +99,7 @@ describe('BackupManager', async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
fs.promises.unlink(finalBackupFilePath).catch(() => {});
|
||||
fs.promises.unlink(finalMetadataFilePath).catch(() => {});
|
||||
});
|
||||
|
||||
it('createBackupName', async () => {
|
||||
@@ -289,12 +291,24 @@ describe('BackupManager', async () => {
|
||||
});
|
||||
|
||||
describe('destroy', async () => {
|
||||
it('should delete the backup file', async () => {
|
||||
it('should delete the backup file and metadata file', async () => {
|
||||
const backupManager = new BackupManager(app, null, defaultBackupSettings);
|
||||
await backupManager.backup(backupFileBaseName);
|
||||
await backupManager.destroy(`${backupFileBaseName}.${BACKUP_EXTENSION}`);
|
||||
const files = await fs.promises.readdir(backupFilesFolder);
|
||||
expect(files).not.toContain(`${backupFileBaseName}.${BACKUP_EXTENSION}`);
|
||||
expect(files).not.toContain(`${backupFileBaseName}${METADATA_EXTENSION}`);
|
||||
});
|
||||
|
||||
it('should ignore missing metadata file when deleting backup file', async () => {
|
||||
const backupManager = new BackupManager(app, null, defaultBackupSettings);
|
||||
await backupManager.backup(backupFileBaseName);
|
||||
await fs.promises.unlink(finalMetadataFilePath);
|
||||
|
||||
await expect(backupManager.destroy(`${backupFileBaseName}.${BACKUP_EXTENSION}`)).resolves.toBeUndefined();
|
||||
|
||||
const files = await fs.promises.readdir(backupFilesFolder);
|
||||
expect(files).not.toContain(`${backupFileBaseName}.${BACKUP_EXTENSION}`);
|
||||
});
|
||||
|
||||
it('should reject path traversal attempts', async () => {
|
||||
|
||||
+68
-31
@@ -11,7 +11,7 @@ import * as cp from 'child_process';
|
||||
import archiver from 'archiver';
|
||||
import path from 'path';
|
||||
import { storagePathJoin } from '@nocobase/utils';
|
||||
import { BACKUP_EXTENSION, SETTINGS } from '../../utils';
|
||||
import { BACKUP_EXTENSION, getDBVersion, SETTINGS } from '../../utils';
|
||||
import { getApp } from '..';
|
||||
import fs from 'fs';
|
||||
import { MockServer, sleep } from '@nocobase/test/server';
|
||||
@@ -57,12 +57,12 @@ vi.mock('child_process', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const backupFileBaseName = 'backup_for_unit_tests';
|
||||
const backupFilesFolder = storagePathJoin('backups', 'main');
|
||||
const finalBackupFilePath = path.join(backupFilesFolder, `${backupFileBaseName}.${BACKUP_EXTENSION}`);
|
||||
const schemaMismatchBackupFilePath = path.join(backupFilesFolder, `backup_schema_mismatch.${BACKUP_EXTENSION}`);
|
||||
const createdBackupFilePaths = new Set<string>();
|
||||
|
||||
async function createBackupArchive(filePath: string, metadata: Record<string, any>) {
|
||||
createdBackupFilePaths.add(filePath);
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const output = fs.createWriteStream(filePath);
|
||||
@@ -80,6 +80,16 @@ async function createBackupArchive(filePath: string, metadata: Record<string, an
|
||||
});
|
||||
}
|
||||
|
||||
function createBackupFile(caseName: string) {
|
||||
const backupFileBaseName = `backup_${caseName}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const backupFilePath = path.join(backupFilesFolder, `${backupFileBaseName}.${BACKUP_EXTENSION}`);
|
||||
createdBackupFilePaths.add(backupFilePath);
|
||||
return {
|
||||
backupFileBaseName,
|
||||
backupFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RestoreManager', () => {
|
||||
let app: MockServer;
|
||||
const defaultBackupSettings: BackupSettings = {
|
||||
@@ -93,7 +103,6 @@ describe('RestoreManager', () => {
|
||||
beforeEach(async () => {
|
||||
app = await getApp();
|
||||
await fs.promises.mkdir(backupFilesFolder, { recursive: true });
|
||||
await fs.promises.unlink(finalBackupFilePath).catch(() => {});
|
||||
await fs.promises.unlink(schemaMismatchBackupFilePath).catch(() => {});
|
||||
|
||||
mockExecImplementation = (command, _options, callback) => {
|
||||
@@ -141,10 +150,13 @@ describe('RestoreManager', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
for (const backupFilePath of createdBackupFilePaths) {
|
||||
await fs.promises.unlink(backupFilePath).catch(() => {});
|
||||
}
|
||||
createdBackupFilePaths.clear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
fs.promises.unlink(finalBackupFilePath).catch(() => {});
|
||||
fs.promises.unlink(schemaMismatchBackupFilePath).catch(() => {});
|
||||
});
|
||||
|
||||
@@ -165,6 +177,15 @@ describe('RestoreManager', () => {
|
||||
};
|
||||
}
|
||||
|
||||
async function createMetadataCompatibleWithCurrentDb(database: Record<string, any> = {}) {
|
||||
const version = await getDBVersion(app.db);
|
||||
return createMetadata({
|
||||
version,
|
||||
backupClientVersion: version,
|
||||
...database,
|
||||
});
|
||||
}
|
||||
|
||||
function createCtx(requestBody: Record<string, any> = {}) {
|
||||
return {
|
||||
app,
|
||||
@@ -198,17 +219,15 @@ describe('RestoreManager', () => {
|
||||
}
|
||||
|
||||
it('restoreFromBackup', async () => {
|
||||
vi.spyOn(app, 'runCommand').mockReturnValue({} as any);
|
||||
const backupManager = new BackupManager(app, null, defaultBackupSettings);
|
||||
await backupManager.backup(backupFileBaseName);
|
||||
await sleep(3000);
|
||||
const { backupFileBaseName, backupFilePath } = createBackupFile('restore-from-backup');
|
||||
await fs.promises.writeFile(backupFilePath, 'mocked backup file');
|
||||
const ctx = {
|
||||
app: app,
|
||||
logger: app.logger,
|
||||
i18n: app.i18n,
|
||||
};
|
||||
const restoreManager = new RestoreManager(ctx);
|
||||
const restoreSpy = vi.spyOn(restoreManager, 'restore');
|
||||
const restoreSpy = vi.spyOn(restoreManager, 'restore').mockResolvedValue(undefined);
|
||||
await restoreManager.restoreFromBackup(`${backupFileBaseName}.${BACKUP_EXTENSION}`, 'task_id');
|
||||
expect(restoreSpy).toHaveBeenCalled();
|
||||
});
|
||||
@@ -238,20 +257,18 @@ describe('RestoreManager', () => {
|
||||
});
|
||||
|
||||
it('restoreFromUpload', async () => {
|
||||
const backupManager = new BackupManager(app, null, defaultBackupSettings);
|
||||
vi.spyOn(app, 'runCommand').mockReturnValue({} as any);
|
||||
await backupManager.backup(backupFileBaseName);
|
||||
await sleep(3000);
|
||||
const { backupFilePath } = createBackupFile('restore-from-upload');
|
||||
await fs.promises.writeFile(backupFilePath, 'mocked backup file');
|
||||
const ctx = {
|
||||
app: app,
|
||||
logger: app.logger,
|
||||
i18n: app.i18n,
|
||||
};
|
||||
const restoreManager = new RestoreManager(ctx);
|
||||
const restoreSpy = vi.spyOn(restoreManager, 'restore');
|
||||
const restoreSpy = vi.spyOn(restoreManager, 'restore').mockResolvedValue(undefined);
|
||||
await restoreManager.restoreFromUpload(
|
||||
{
|
||||
path: finalBackupFilePath,
|
||||
path: backupFilePath,
|
||||
} as unknown as Express.Multer.File,
|
||||
'task_id',
|
||||
);
|
||||
@@ -259,10 +276,9 @@ describe('RestoreManager', () => {
|
||||
});
|
||||
|
||||
it('restore', async () => {
|
||||
const backupManager = new BackupManager(app, null, defaultBackupSettings);
|
||||
vi.spyOn(app, 'runCommand').mockReturnValue({} as any);
|
||||
await backupManager.backup(backupFileBaseName);
|
||||
await sleep(3000);
|
||||
const { backupFilePath } = createBackupFile('restore');
|
||||
await createBackupArchive(backupFilePath, await createMetadataCompatibleWithCurrentDb());
|
||||
const runCommandSpy = vi.spyOn(app, 'runCommand').mockResolvedValue({} as any);
|
||||
const ctx = {
|
||||
app: app,
|
||||
logger: app.logger,
|
||||
@@ -278,19 +294,27 @@ describe('RestoreManager', () => {
|
||||
settings = await app.db.getRepository(SETTINGS).findOne();
|
||||
expect(settings.encryptionPassword).toBe('123456');
|
||||
|
||||
const restoreManager = new RestoreManager(ctx);
|
||||
await restoreManager.restore(finalBackupFilePath, 'task_id');
|
||||
await sleep(3000);
|
||||
const restoreManager = new RestoreManager(ctx, {
|
||||
dialect: 'postgres',
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
database: 'test',
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
schema: 'source_schema',
|
||||
});
|
||||
await restoreManager.restore(backupFilePath, 'task_id');
|
||||
await vi.waitFor(() => {
|
||||
expect(runCommandSpy).toHaveBeenCalledWith('upgrade');
|
||||
});
|
||||
settings = await app.db.getRepository(SETTINGS).findOne();
|
||||
// after the restore, the backup encryption should be disabled
|
||||
expect(settings.encryptionPassword).toBe('');
|
||||
});
|
||||
|
||||
it('restore with tolerentMode', async () => {
|
||||
const backupManager = new BackupManager(app, null, defaultBackupSettings);
|
||||
vi.spyOn(app, 'runCommand').mockReturnValue({} as any);
|
||||
await backupManager.backup(backupFileBaseName);
|
||||
await sleep(3000);
|
||||
const { backupFilePath } = createBackupFile('restore-tolerent-mode');
|
||||
await createBackupArchive(backupFilePath, createMetadata());
|
||||
const ctx = {
|
||||
app: app,
|
||||
logger: app.logger,
|
||||
@@ -306,11 +330,24 @@ describe('RestoreManager', () => {
|
||||
settings = await app.db.getRepository(SETTINGS).findOne();
|
||||
expect(settings.encryptionPassword).toBe('123456');
|
||||
|
||||
const restoreManager = new RestoreManager(ctx);
|
||||
const restoreManager = new RestoreManager(ctx, {
|
||||
dialect: 'postgres',
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
database: 'test',
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
schema: 'source_schema',
|
||||
});
|
||||
const tolerentMode = true;
|
||||
vi.spyOn(app, 'runCommand').mockRejectedValueOnce(new Error('some errors happend and ignored'));
|
||||
await restoreManager.restore(finalBackupFilePath, 'task_id', undefined, tolerentMode);
|
||||
await sleep(3000);
|
||||
const runCommandSpy = vi
|
||||
.spyOn(app, 'runCommand')
|
||||
.mockRejectedValueOnce(new Error('some errors happend and ignored'))
|
||||
.mockResolvedValue({} as any);
|
||||
await restoreManager.restore(backupFilePath, 'task_id', undefined, tolerentMode);
|
||||
await vi.waitFor(() => {
|
||||
expect(runCommandSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
settings = await app.db.getRepository(SETTINGS).findOne();
|
||||
// after the restore, the backup encryption should be disabled
|
||||
expect(settings.encryptionPassword).toBe('');
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { DatabaseOptions } from '@nocobase/database';
|
||||
import { exec as execCallback, execSync, spawn } from 'child_process';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
@@ -13,10 +22,23 @@ const D$$ = os.platform() === 'win32' ? '$$' : '\\$\\$';
|
||||
|
||||
const STREAM_BUFFER_SIZE = 2 * 1024 * 1024; // 2MB buffer for better IO performance
|
||||
|
||||
export type DBBackupOptions = {
|
||||
dir: string;
|
||||
skipFdw?: boolean;
|
||||
includeTables?: string[];
|
||||
excludeTables?: string[];
|
||||
};
|
||||
|
||||
export type DBRestoreOptions = {
|
||||
filePath: string;
|
||||
schema?: string;
|
||||
skipDropAllTables?: boolean;
|
||||
};
|
||||
|
||||
export interface DBAdapter {
|
||||
dbOpts: DatabaseOptions;
|
||||
backup(dir: string, skipFdw?: boolean): Promise<void>;
|
||||
restore(filePath: string, schema?: string): Promise<void>;
|
||||
backup(options: DBBackupOptions): Promise<void>;
|
||||
restore(options: DBRestoreOptions): Promise<void>;
|
||||
check(op: 'backup' | 'restore'): Promise<void>;
|
||||
clientVersion(op: 'backup' | 'restore'): Promise<string | void>;
|
||||
}
|
||||
@@ -37,12 +59,23 @@ const formatPathInEnv = (path?: string) => {
|
||||
return path;
|
||||
};
|
||||
const escapeStringLiteral = (value: string) => String(value).replace(/'/g, "''");
|
||||
const quotePgIdentifier = (value: string) => `"${String(value).replace(/"/g, '""')}"`;
|
||||
const quoteShellArg = (value: string) => `'${String(value).replace(/'/g, "'\\''")}'`;
|
||||
const quotePgTablePattern = (table: string) => quoteShellArg(String(table).split('.').map(quotePgIdentifier).join('.'));
|
||||
const qualifyPgTablePattern = (table: string, schema?: string) => {
|
||||
const tablePattern = String(table);
|
||||
if (!schema || tablePattern.includes('.')) {
|
||||
return tablePattern;
|
||||
}
|
||||
|
||||
return `${schema}.${tablePattern}`;
|
||||
};
|
||||
|
||||
abstract class BaseDBAdapter implements DBAdapter {
|
||||
constructor(public dbOpts: DatabaseOptions) {}
|
||||
|
||||
abstract backup(dir: string, skipFdw?: boolean): Promise<void>;
|
||||
abstract restore(filePath: string): Promise<void>;
|
||||
abstract backup(options: DBBackupOptions): Promise<void>;
|
||||
abstract restore(options: DBRestoreOptions): Promise<void>;
|
||||
|
||||
async check(_: 'backup' | 'restore') {}
|
||||
async clientVersion(_: 'backup' | 'restore'): Promise<string | void> {}
|
||||
@@ -82,7 +115,7 @@ class MySQLAdapter extends BaseDBAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async backup(dir: string, skipFdw = false): Promise<void> {
|
||||
async backup({ dir, skipFdw = false, includeTables, excludeTables }: DBBackupOptions): Promise<void> {
|
||||
const { username, host, port, database, password } = this.dbOpts;
|
||||
const filePath = `${dir}/data`;
|
||||
const versionStr = await this.clientVersion('backup');
|
||||
@@ -94,6 +127,13 @@ class MySQLAdapter extends BaseDBAdapter {
|
||||
createServerSQL = await this.#getFederatedServerSQL(username, host, port, database, password);
|
||||
}
|
||||
|
||||
const includeOption =
|
||||
Array.isArray(includeTables) && includeTables.length ? includeTables.map((table) => table) : [];
|
||||
const excludeOption =
|
||||
Array.isArray(excludeTables) && excludeTables.length
|
||||
? excludeTables.map((table) => `--ignore-table=${database}.${table}`)
|
||||
: [];
|
||||
|
||||
const mysqldumpArgs = [
|
||||
'-u',
|
||||
username,
|
||||
@@ -111,6 +151,14 @@ class MySQLAdapter extends BaseDBAdapter {
|
||||
database,
|
||||
];
|
||||
|
||||
if (excludeOption.length) {
|
||||
mysqldumpArgs.push(...excludeOption);
|
||||
}
|
||||
|
||||
if (includeOption.length) {
|
||||
mysqldumpArgs.push(...includeOption);
|
||||
}
|
||||
|
||||
// Stream mysqldump output directly to final file (no intermediate file)
|
||||
return new Promise((resolve, reject) => {
|
||||
const mysqldumpProcess = spawn(this.#backupCmd, mysqldumpArgs, {
|
||||
@@ -226,12 +274,13 @@ class MySQLAdapter extends BaseDBAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async restore(filePath: string): Promise<void> {
|
||||
async restore({ filePath, skipDropAllTables = false }: DBRestoreOptions): Promise<void> {
|
||||
const { username, host, port, database, password } = this.dbOpts;
|
||||
|
||||
const dropDataCommand = `mysql -u ${username} -h ${host} ${
|
||||
port ? `-P ${port}` : ''
|
||||
} --protocol=tcp -D ${database} -e "
|
||||
if (!skipDropAllTables) {
|
||||
const dropDataCommand = `mysql -u ${username} -h ${host} ${
|
||||
port ? `-P ${port}` : ''
|
||||
} --protocol=tcp -D ${database} -e "
|
||||
DELIMITER $$
|
||||
DROP PROCEDURE IF EXISTS drop_all_tables_and_triggers$$
|
||||
CREATE PROCEDURE drop_all_tables_and_triggers()
|
||||
@@ -312,8 +361,9 @@ class MySQLAdapter extends BaseDBAdapter {
|
||||
DELIMITER ;
|
||||
"`;
|
||||
|
||||
// Run the command to drop all tables
|
||||
await run(dropDataCommand, { MYSQL_PWD: password });
|
||||
// Run the command to drop all tables
|
||||
await run(dropDataCommand, { MYSQL_PWD: password });
|
||||
}
|
||||
|
||||
const command = `${this.#restoreCmd} -u ${username} -h ${host} ${
|
||||
port ? `-P ${port}` : ''
|
||||
@@ -349,18 +399,30 @@ class PostgresAdapter extends BaseDBAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async backup(dir: string): Promise<void> {
|
||||
async backup({ dir, includeTables, excludeTables }: DBBackupOptions): Promise<void> {
|
||||
const { username, host, port, database, password, schema: backupSchema } = this.dbOpts;
|
||||
const filePath = `${dir}/data`;
|
||||
const schemaOption = backupSchema ? `--schema=${backupSchema}` : '';
|
||||
const includeOption =
|
||||
Array.isArray(includeTables) && includeTables.length
|
||||
? includeTables
|
||||
.map((table) => `-t ${quotePgTablePattern(qualifyPgTablePattern(table, backupSchema))}`)
|
||||
.join(' ')
|
||||
: '';
|
||||
const excludeOption =
|
||||
Array.isArray(excludeTables) && excludeTables.length
|
||||
? excludeTables
|
||||
.map((table) => `-T ${quotePgTablePattern(qualifyPgTablePattern(table, backupSchema))}`)
|
||||
.join(' ')
|
||||
: '';
|
||||
// set the password in the environment variable, so we don't need to pass it in the command
|
||||
const command = `${this.#backupCmd} -U ${username} -h ${host} ${
|
||||
const command = `${this.#backupCmd} ${includeOption} ${excludeOption} -U ${username} -h ${host} ${
|
||||
port ? `-p ${port}` : ''
|
||||
} -F c -b --quote-all-identifiers ${schemaOption} -f ${filePath} ${database}`;
|
||||
await run(command, { PGPASSWORD: password });
|
||||
}
|
||||
|
||||
async restore(filePath: string, schema?: string): Promise<void> {
|
||||
async restore({ filePath, schema, skipDropAllTables = false }: DBRestoreOptions): Promise<void> {
|
||||
const { username, host, port, database, password } = this.dbOpts;
|
||||
let schemaOption = this.dbOpts.schema;
|
||||
if (schema && !schemaOption) {
|
||||
@@ -374,7 +436,8 @@ class PostgresAdapter extends BaseDBAdapter {
|
||||
const relnamespaceCondition = schemaOption
|
||||
? `WHERE relnamespace = '${schemaOption}'::regnamespace`
|
||||
: `WHERE tgrelid IN (SELECT oid FROM pg_class WHERE relnamespace NOT IN (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname IN ('pg_catalog', 'information_schema')))`;
|
||||
const dropDataCommand = `psql -U ${username} -h ${host} ${port ? `-p ${port}` : ''} -d ${database} -c "
|
||||
if (!skipDropAllTables) {
|
||||
const dropDataCommand = `psql -U ${username} -h ${host} ${port ? `-p ${port}` : ''} -d ${database} -c "
|
||||
DO ${D$$} DECLARE r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT viewname,schemaname FROM pg_views ${schemaNameCondition}) LOOP
|
||||
@@ -415,8 +478,9 @@ class PostgresAdapter extends BaseDBAdapter {
|
||||
|
||||
END ${D$$};"`.replace(/\n/g, ' ');
|
||||
|
||||
// Run the command to drop all existing data
|
||||
await run(dropDataCommand, { PGPASSWORD: password });
|
||||
// Run the command to drop all existing data
|
||||
await run(dropDataCommand, { PGPASSWORD: password });
|
||||
}
|
||||
|
||||
if (schema === schemaOption || !schemaOption) {
|
||||
// current schema is the same as the backup schema
|
||||
@@ -497,7 +561,7 @@ class PostgresAdapter extends BaseDBAdapter {
|
||||
}
|
||||
|
||||
class SQLiteAdapter extends BaseDBAdapter {
|
||||
async backup(dir: string): Promise<void> {
|
||||
async backup({ dir }: DBBackupOptions): Promise<void> {
|
||||
const { storage } = this.dbOpts;
|
||||
const filePath = `${dir}/data`;
|
||||
const dbFilePath = path.resolve(storage);
|
||||
@@ -505,7 +569,7 @@ class SQLiteAdapter extends BaseDBAdapter {
|
||||
await fsPromises.copyFile(dbFilePath, filePath);
|
||||
}
|
||||
|
||||
async restore(filePath: string): Promise<void> {
|
||||
async restore({ filePath }: DBRestoreOptions): Promise<void> {
|
||||
const { storage } = this.dbOpts;
|
||||
const dbFilePath = path.resolve(storage);
|
||||
await fsPromises.copyFile(filePath, dbFilePath, fsPromises.constants.COPYFILE_FICLONE);
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
import { SETTINGS } from '../utils';
|
||||
|
||||
export default defineCollection({
|
||||
name: `${SETTINGS}`,
|
||||
dataCategory: 'business',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'skip'],
|
||||
fields: [
|
||||
|
||||
@@ -1 +1,14 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export { BackupManager } from './managers/backup';
|
||||
export type { BackupSettings, BackupCreator, BackupTaskResult } from './managers/backup';
|
||||
export { RestoreManager, RestoreOptions } from './managers/restore';
|
||||
export { BACKUP_EXTENSION } from './utils';
|
||||
export { default } from './plugin';
|
||||
|
||||
@@ -26,12 +26,15 @@ import {
|
||||
BACKUP_EXTENSION,
|
||||
BACKUP_TASKS_CACHE_NAME,
|
||||
FILE_ENCRYPTION_SALT,
|
||||
METADATA_EXTENSION,
|
||||
PLUGIN_BACKUPS_NAME,
|
||||
getDBVersion,
|
||||
humanFileSize,
|
||||
resolvePathWithinBase,
|
||||
} from '../utils';
|
||||
|
||||
const BACKUP_METADATA_VERSION = 1;
|
||||
|
||||
export interface BackupSettings {
|
||||
storageId?: string;
|
||||
encryptionPassword: string;
|
||||
@@ -39,13 +42,24 @@ export interface BackupSettings {
|
||||
keep?: number;
|
||||
scheduled: boolean;
|
||||
cron: string;
|
||||
includeTables?: string[];
|
||||
excludeTables?: string[];
|
||||
description?: string;
|
||||
createdBy?: BackupCreator;
|
||||
}
|
||||
|
||||
export interface BackupFile {
|
||||
name: string;
|
||||
fileSize?: string;
|
||||
createdAt?: Date;
|
||||
description?: string;
|
||||
inProgress: boolean;
|
||||
createdBy?: BackupCreator;
|
||||
}
|
||||
|
||||
export interface BackupCreator {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface BackupTaskResult {
|
||||
@@ -58,6 +72,8 @@ export class BackupManager {
|
||||
ctx: ResourcerContext | null; // when triggered by cron job, ctx is null
|
||||
#settings: BackupSettings;
|
||||
#dbAdapter: DBAdapter;
|
||||
#backupTasksCacheName: string;
|
||||
#backupPrefix: string;
|
||||
#backupDir: string;
|
||||
#tempDir: string;
|
||||
#uploadDir: string;
|
||||
@@ -68,12 +84,34 @@ export class BackupManager {
|
||||
this.ctx = ctx;
|
||||
this.#settings = settings;
|
||||
this.#dbAdapter = getDBAdapter(app.db.options);
|
||||
this.#backupTasksCacheName = BACKUP_TASKS_CACHE_NAME;
|
||||
this.#backupPrefix = 'backup_';
|
||||
this.#backupDir = storagePathJoin('backups', app.name);
|
||||
this.#tempDir = storagePathJoin('tmp', 'backups', app.name);
|
||||
this.#uploadDir = storagePathJoin('uploads');
|
||||
this.#aesKeyPath = storagePathJoin('apps', app.name, 'aes_key.dat');
|
||||
}
|
||||
|
||||
protected set backupPrefix(backupPrefix: string) {
|
||||
this.#backupPrefix = backupPrefix;
|
||||
}
|
||||
|
||||
protected set backupDir(backupDir: string) {
|
||||
this.#backupDir = backupDir;
|
||||
}
|
||||
|
||||
protected set tempDir(tempDir: string) {
|
||||
this.#tempDir = tempDir;
|
||||
}
|
||||
|
||||
protected set uploadDir(uploadDir: string) {
|
||||
this.#uploadDir = uploadDir;
|
||||
}
|
||||
|
||||
protected set backupTasksCacheName(backupTasksCacheName: string) {
|
||||
this.#backupTasksCacheName = backupTasksCacheName;
|
||||
}
|
||||
|
||||
async createBackupName() {
|
||||
await this.#dbAdapter.check('backup');
|
||||
await fsPromises.mkdir(this.#backupDir, { recursive: true });
|
||||
@@ -82,14 +120,17 @@ export class BackupManager {
|
||||
return this.#generateFileBaseName();
|
||||
}
|
||||
|
||||
async backup(fileBaseName: string, opts: BackupSettings = this.#settings) {
|
||||
async backup(fileBaseName: string, opts?: Partial<BackupSettings>) {
|
||||
const contentPath = path.join(this.#tempDir, fileBaseName);
|
||||
return this.#runBackupTask(opts, fileBaseName, contentPath);
|
||||
return this.#runBackupTask({ ...this.#settings, ...(opts ?? {}) }, fileBaseName, contentPath);
|
||||
}
|
||||
|
||||
async destroy(fileName: string) {
|
||||
const filePath = this.#getValidatedFilePath(fileName);
|
||||
const fileBaseName = path.basename(filePath, `.${BACKUP_EXTENSION}`);
|
||||
const metadataFilePath = path.join(this.#backupDir, `${fileBaseName}${METADATA_EXTENSION}`);
|
||||
await fsPromises.unlink(filePath);
|
||||
await fsPromises.rm(metadataFilePath, { force: true });
|
||||
}
|
||||
|
||||
async list() {
|
||||
@@ -98,7 +139,7 @@ export class BackupManager {
|
||||
// clean up the lock files if the backup process done.
|
||||
// These files can be left behind if the backup process is interrupted for some reason
|
||||
const cleanStaleLockFiles = async () => {
|
||||
const statusCache = this.app.cacheManager.getCache(BACKUP_TASKS_CACHE_NAME);
|
||||
const statusCache = this.app.cacheManager.getCache(this.#backupTasksCacheName);
|
||||
for (const backup of inProgressBackups) {
|
||||
if (!(await statusCache.get(backup.name))) {
|
||||
await this.#removeLockFile(path.basename(backup.name, `.${BACKUP_EXTENSION}`));
|
||||
@@ -135,7 +176,12 @@ export class BackupManager {
|
||||
// create content path to store the uncompressed backup files
|
||||
await this.#createContentPath(contentPath);
|
||||
// Backup the database
|
||||
await this.#dbAdapter.backup(contentPath, !this.app.pm.has('collection-fdw'));
|
||||
await this.#dbAdapter.backup({
|
||||
dir: contentPath,
|
||||
skipFdw: !this.app.pm.has('collection-fdw'),
|
||||
includeTables: opts.includeTables,
|
||||
excludeTables: opts.excludeTables,
|
||||
});
|
||||
// save the metadata
|
||||
await this.#metadataBackup(opts, contentPath);
|
||||
// 3. compress the backup files
|
||||
@@ -188,8 +234,11 @@ export class BackupManager {
|
||||
});
|
||||
|
||||
const metadata = {
|
||||
metadataVersion: BACKUP_METADATA_VERSION,
|
||||
enableFilesBackup: opts.enableFilesBackup,
|
||||
version: await this.app.version.get(),
|
||||
description: opts.description,
|
||||
createdBy: opts.createdBy,
|
||||
database: {
|
||||
dialect,
|
||||
underscored,
|
||||
@@ -200,8 +249,7 @@ export class BackupManager {
|
||||
},
|
||||
plugins,
|
||||
};
|
||||
// save the metadata to file _metadata.json
|
||||
const metadataFilePath = path.join(dir, '_metadata.json');
|
||||
const metadataFilePath = path.join(dir, METADATA_EXTENSION);
|
||||
try {
|
||||
await fsPromises.writeFile(metadataFilePath, JSON.stringify(metadata, null, 2));
|
||||
} catch (error) {
|
||||
@@ -214,6 +262,8 @@ export class BackupManager {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
const filePath = path.join(this.#backupDir, `${fileBaseName}.${BACKUP_EXTENSION}`);
|
||||
const sourceMetadataFilePath = path.join(dir, METADATA_EXTENSION);
|
||||
const metadataFilePath = path.join(this.#backupDir, `${fileBaseName}${METADATA_EXTENSION}`);
|
||||
const outputFileStream = fs.createWriteStream(filePath);
|
||||
|
||||
try {
|
||||
@@ -302,6 +352,7 @@ export class BackupManager {
|
||||
|
||||
// Wait for the 'close' event
|
||||
await onClose;
|
||||
await fsPromises.copyFile(sourceMetadataFilePath, metadataFilePath);
|
||||
} catch (error) {
|
||||
this.app.logger.error(`Error compressing files: ${error.message}`, { module: BACKUPS });
|
||||
throw new Error(this.#t('ERROR_COMPRESSING_FILES', error.message));
|
||||
@@ -457,7 +508,7 @@ export class BackupManager {
|
||||
}
|
||||
|
||||
#generateFileBaseName() {
|
||||
return `backup_${dayjs().format(`YYYYMMDD_HHmmss_${Math.floor(1000 + Math.random() * 9000)}`)}`;
|
||||
return `${this.#backupPrefix}${dayjs().format(`YYYYMMDD_HHmmss_${Math.floor(1000 + Math.random() * 9000)}`)}`;
|
||||
}
|
||||
|
||||
async #listCompletedBackups(inProgressFiles: BackupFile[] = []): Promise<BackupFile[]> {
|
||||
@@ -476,11 +527,18 @@ export class BackupManager {
|
||||
const backupPromises = files
|
||||
.filter((file) => file.endsWith(`.${BACKUP_EXTENSION}`) && !inProgressFileNames.includes(file))
|
||||
.map(async (file): Promise<BackupFile> => {
|
||||
const stats = await fsPromises.stat(path.join(this.#backupDir, file));
|
||||
const fileBaseName = path.basename(file, `.${BACKUP_EXTENSION}`);
|
||||
const metadataFilePath = path.join(this.#backupDir, `${fileBaseName}${METADATA_EXTENSION}`);
|
||||
const [stats, metadata] = await Promise.all([
|
||||
fsPromises.stat(path.join(this.#backupDir, file)),
|
||||
this.#readBackupDescription(metadataFilePath),
|
||||
]);
|
||||
return {
|
||||
name: file,
|
||||
fileSize: humanFileSize(stats.size),
|
||||
createdAt: stats.ctime,
|
||||
description: metadata?.description,
|
||||
createdBy: metadata?.createdBy,
|
||||
inProgress: false,
|
||||
};
|
||||
});
|
||||
@@ -488,6 +546,20 @@ export class BackupManager {
|
||||
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
async #readBackupDescription(
|
||||
metadataFilePath: string,
|
||||
): Promise<{ description?: string; createdBy?: BackupCreator } | undefined> {
|
||||
try {
|
||||
const metadata = JSON.parse(await fsPromises.readFile(metadataFilePath, 'utf8'));
|
||||
return {
|
||||
description: metadata.description,
|
||||
createdBy: metadata.createdBy,
|
||||
};
|
||||
} catch (_error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async #listProgressBackups(): Promise<BackupFile[]> {
|
||||
// list the lock(in progressing) files
|
||||
try {
|
||||
|
||||
@@ -47,8 +47,9 @@ interface Metadata {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RestoreOptions {
|
||||
export interface RestoreOptions {
|
||||
forceSchemaRestore?: boolean;
|
||||
skipDropAllTables?: boolean;
|
||||
}
|
||||
|
||||
const RESTORE_STEPS = {
|
||||
@@ -61,6 +62,7 @@ const RESTORE_STEPS = {
|
||||
export class RestoreManager {
|
||||
ctx: ResourcerContext;
|
||||
#dbAdapter: DBAdapter;
|
||||
#restoreTasksCacheName: string;
|
||||
#backupDir: string;
|
||||
#tempDir: string;
|
||||
#uploadDir: string;
|
||||
@@ -68,12 +70,29 @@ export class RestoreManager {
|
||||
constructor(ctx: ResourcerContext, dbOptions?: any) {
|
||||
this.ctx = ctx;
|
||||
this.#dbAdapter = getDBAdapter(dbOptions || ctx.app.db.options);
|
||||
this.#restoreTasksCacheName = RESTORE_TASKS_CACHE_NAME;
|
||||
this.#backupDir = storagePathJoin('backups', ctx.app.name);
|
||||
this.#tempDir = storagePathJoin('tmp', 'backups', ctx.app.name);
|
||||
this.#uploadDir = storagePathJoin('uploads');
|
||||
this.#aesKeyPath = storagePathJoin('apps', ctx.app.name, 'aes_key.dat');
|
||||
}
|
||||
|
||||
protected set backupDir(backupDir: string) {
|
||||
this.#backupDir = backupDir;
|
||||
}
|
||||
|
||||
protected set tempDir(tempDir: string) {
|
||||
this.#tempDir = tempDir;
|
||||
}
|
||||
|
||||
protected set uploadDir(uploadDir: string) {
|
||||
this.#uploadDir = uploadDir;
|
||||
}
|
||||
|
||||
protected set restoreTasksCacheName(restoreTasksCacheName: string) {
|
||||
this.#restoreTasksCacheName = restoreTasksCacheName;
|
||||
}
|
||||
|
||||
async restoreFromBackup(
|
||||
backupFileName: string,
|
||||
taskId: string,
|
||||
@@ -116,7 +135,7 @@ export class RestoreManager {
|
||||
// check the metadata file
|
||||
const metadata = await this.#parseMetadataFile(path.join(extractedDir, metadataFile), tolerentMode, options);
|
||||
try {
|
||||
await this.#restoreDataCLI(extractedDir, dbFile, uploadsExist, metadata);
|
||||
await this.#restoreDataCLI(extractedDir, dbFile, uploadsExist, metadata, options);
|
||||
} catch (error) {
|
||||
const dbVersion = await getDBVersion(this.ctx.app.db);
|
||||
const restoreClientVersion = await this.#dbAdapter.clientVersion('restore');
|
||||
@@ -160,6 +179,7 @@ export class RestoreManager {
|
||||
dbFile: string,
|
||||
restoreUploads: boolean,
|
||||
metadata: Metadata,
|
||||
options?: RestoreOptions,
|
||||
): Promise<void> {
|
||||
const tmpBackupDir = path.join(this.#tempDir, 'before-restore');
|
||||
try {
|
||||
@@ -167,7 +187,11 @@ export class RestoreManager {
|
||||
// ensure the app cleaned before restoring the database
|
||||
await this.ctx.app.emitAsync('beforeStop');
|
||||
await this.ctx.app.emitAsync('afterStop');
|
||||
await this.#dbAdapter.restore(path.join(extractedDir, dbFile), metadata.database.schema);
|
||||
await this.#dbAdapter.restore({
|
||||
filePath: path.join(extractedDir, dbFile),
|
||||
schema: metadata.database.schema,
|
||||
skipDropAllTables: options?.skipDropAllTables === true,
|
||||
});
|
||||
this.ctx.logger.info('Database restored successfully', { module: BACKUPS });
|
||||
// copy the uploads directory
|
||||
await this.#restoreFilesAndCleanup(restoreUploads, extractedDir);
|
||||
@@ -179,12 +203,12 @@ export class RestoreManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async getStatusCache() {
|
||||
protected async getStatusCache() {
|
||||
try {
|
||||
return this.ctx.app.cacheManager.getCache(RESTORE_TASKS_CACHE_NAME);
|
||||
return this.ctx.app.cacheManager.getCache(this.#restoreTasksCacheName);
|
||||
} catch (e) {
|
||||
return await this.ctx.app.cacheManager.createCache({
|
||||
name: RESTORE_TASKS_CACHE_NAME,
|
||||
name: this.#restoreTasksCacheName,
|
||||
store: 'memory',
|
||||
ttl: RESTORE_TASKS_CACHE_TTL,
|
||||
max: 10,
|
||||
@@ -212,7 +236,7 @@ export class RestoreManager {
|
||||
}
|
||||
// check the metadata file
|
||||
const metadata = await this.#parseMetadataFile(path.join(extractedDir, metadataFile), tolerentMode, options);
|
||||
this.#restoreData(extractedDir, dbFile, uploadsExist, taskId, metadata).catch(async (error) => {
|
||||
this.#restoreData(extractedDir, dbFile, uploadsExist, taskId, metadata, options).catch(async (error) => {
|
||||
try {
|
||||
const dbVersion = await getDBVersion(this.ctx.app.db);
|
||||
const restoreClientVersion = await this.#dbAdapter.clientVersion('restore');
|
||||
@@ -430,6 +454,7 @@ export class RestoreManager {
|
||||
restoreUploads: boolean,
|
||||
taskId: string,
|
||||
metadata: Metadata,
|
||||
options?: RestoreOptions,
|
||||
): Promise<void> {
|
||||
this.#notify(RESTORE_STEPS.BEGIN);
|
||||
// restore the database
|
||||
@@ -438,13 +463,17 @@ export class RestoreManager {
|
||||
const tmpBackupDir = path.join(this.#tempDir, 'before-restore');
|
||||
try {
|
||||
await fsPromises.mkdir(tmpBackupDir, { recursive: true });
|
||||
await this.#dbAdapter.backup(tmpBackupDir);
|
||||
await this.#dbAdapter.backup({ dir: tmpBackupDir });
|
||||
|
||||
// ensure the app cleaned before restoring the database
|
||||
await this.ctx.app.emitAsync('beforeStop');
|
||||
await this.ctx.app.emitAsync('afterStop');
|
||||
|
||||
await this.#dbAdapter.restore(path.join(extractedDir, dbFile), metadata.database.schema);
|
||||
await this.#dbAdapter.restore({
|
||||
filePath: path.join(extractedDir, dbFile),
|
||||
schema: metadata.database.schema,
|
||||
skipDropAllTables: options?.skipDropAllTables === true,
|
||||
});
|
||||
this.ctx.logger.info('Database restored successfully', { module: BACKUPS });
|
||||
// copy the uploads directory
|
||||
if (restoreUploads) {
|
||||
@@ -492,7 +521,7 @@ export class RestoreManager {
|
||||
const dbFile = path.join(this.#tempDir, 'before-restore', 'data');
|
||||
if (await fs.pathExists(dbFile)) {
|
||||
try {
|
||||
await this.#dbAdapter.restore(dbFile, this.#dbAdapter.dbOpts.schema);
|
||||
await this.#dbAdapter.restore({ filePath: dbFile, schema: this.#dbAdapter.dbOpts.schema });
|
||||
} catch (error) {
|
||||
this.ctx.logger.error('Error reverting the database restore process', { module: BACKUPS });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
export const BACKUP_EXTENSION = 'nbdata';
|
||||
export const METADATA_EXTENSION = '_metadata.json';
|
||||
export const STORAGE_PATH = 'storage/uploads';
|
||||
export const SETTINGS = 'backupSettings';
|
||||
export const BACKUPS = 'backups';
|
||||
|
||||
@@ -13,6 +13,7 @@ export default {
|
||||
namespace: 'iframe-block.iframe-html-storage',
|
||||
dumpRules: 'required',
|
||||
name: 'iframeHtml',
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'blockTemplateLinks',
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
fields: [
|
||||
{
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'blockTemplates',
|
||||
dataCategory: 'system',
|
||||
autoGenId: false,
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
fields: [
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
export default {
|
||||
name: 'desktopRoutes',
|
||||
dataCategory: 'system',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
inherit: false,
|
||||
|
||||
@@ -69,7 +69,7 @@ export class PluginClientServer extends Plugin {
|
||||
this.app.acl.allow('app', 'getInfo');
|
||||
this.app.acl.registerSnippet({
|
||||
name: 'app',
|
||||
actions: ['app:restart', 'app:refresh', 'app:clearCache'],
|
||||
actions: ['app:restart', 'app:refresh', 'app:clearCache', 'app:publishEvent'],
|
||||
});
|
||||
const dialect = this.app.db.sequelize.getDialect();
|
||||
|
||||
@@ -132,10 +132,39 @@ export class PluginClientServer extends Plugin {
|
||||
ctx.app.runCommand('refresh');
|
||||
await next();
|
||||
},
|
||||
async publishEvent(ctx, next) {
|
||||
const { plugin, command, payload } = ctx.action?.params?.values ?? {};
|
||||
|
||||
if (!plugin || typeof plugin !== 'string') {
|
||||
ctx.throw(400, 'Plugin is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!command || typeof command !== 'string') {
|
||||
ctx.throw(400, 'Command is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, username } = ctx.auth?.user ?? {};
|
||||
const user = id ? { id, username } : undefined;
|
||||
|
||||
const eventName = `${command}@${plugin}`;
|
||||
try {
|
||||
await ctx.app.eventQueue.publish(eventName, {
|
||||
plugin,
|
||||
command,
|
||||
user,
|
||||
payload: payload ?? {},
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.app.logger.warn(`fail to publish event to [${eventName}]: ${(err as Error).message}`, payload);
|
||||
}
|
||||
await next();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.app.auditManager.registerActions(['app:restart', 'app:refresh', 'app:clearCache']);
|
||||
this.app.auditManager.registerActions(['app:restart', 'app:refresh', 'app:clearCache', 'app:publishEvent']);
|
||||
|
||||
this.registerActionHandlers();
|
||||
this.bindNewMenuToRoles();
|
||||
|
||||
+10
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
@@ -11,6 +20,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'databaseServers',
|
||||
dataCategory: 'system',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
autoGenId: false,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ export default {
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
shared: true,
|
||||
name: 'collectionCategories',
|
||||
dataCategory: 'system',
|
||||
sortable: true,
|
||||
fields: [
|
||||
{
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ export default {
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
shared: true,
|
||||
name: 'collections',
|
||||
dataCategory: 'system',
|
||||
sortable: 'sort',
|
||||
autoGenId: false,
|
||||
model: 'CollectionModel',
|
||||
|
||||
@@ -14,6 +14,7 @@ export default {
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
shared: true,
|
||||
name: 'fields',
|
||||
dataCategory: 'system',
|
||||
autoGenId: false,
|
||||
model: 'FieldModel',
|
||||
timestamps: false,
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'dataSourcesCollections',
|
||||
dataCategory: 'system',
|
||||
model: 'DataSourcesCollectionModel',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'dataSourcesFields',
|
||||
dataCategory: 'system',
|
||||
model: 'DataSourcesFieldModel',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
name: 'dataSourcesRolesResourcesActions',
|
||||
dataCategory: 'system',
|
||||
model: 'DataSourcesRolesResourcesActionModel',
|
||||
fields: [
|
||||
{
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
name: 'dataSourcesRolesResourcesScopes',
|
||||
dataCategory: 'system',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
name: 'dataSourcesRolesResources',
|
||||
dataCategory: 'system',
|
||||
model: 'DataSourcesRolesResourcesModel',
|
||||
fields: [
|
||||
{
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'dataSourcesRoles',
|
||||
dataCategory: 'system',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
autoGenId: false,
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'dataSources',
|
||||
dataCategory: 'system',
|
||||
model: 'DataSourceModel',
|
||||
autoGenId: false,
|
||||
shared: true,
|
||||
|
||||
+1
@@ -20,6 +20,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'departmentsRoles',
|
||||
dataCategory: 'business',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite'],
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ export const parentIdField = {
|
||||
|
||||
export default defineCollection({
|
||||
name: 'departments',
|
||||
dataCategory: 'business',
|
||||
migrationRules: ['overwrite'],
|
||||
title: '{{t("Departments")}}',
|
||||
dumpRules: 'required',
|
||||
|
||||
+1
@@ -20,6 +20,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'departmentsUsers',
|
||||
dataCategory: 'business',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['schema-only'],
|
||||
fields: [
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ import { VAR_NAME_RE } from '../../re';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'environmentVariables',
|
||||
dataCategory: 'system',
|
||||
autoGenId: false,
|
||||
migrationRules: ['schema-only'],
|
||||
fields: [
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ export default defineCollection({
|
||||
dumpRules: 'skipped',
|
||||
migrationRules: ['schema-only', 'overwrite'],
|
||||
name: 'chinaRegions',
|
||||
dataCategory: 'business',
|
||||
autoGenId: false,
|
||||
fields: [
|
||||
// 如使用代码作为 id 可能更节省,但由于代码数字最长为 12 字节,除非使用 bigint(64) 才够放置
|
||||
|
||||
@@ -67,6 +67,7 @@ export default defineCollection({
|
||||
},
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
name: 'sequences',
|
||||
dataCategory: 'system',
|
||||
shared: true,
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ export default {
|
||||
asStrategyResource: true,
|
||||
shared: true,
|
||||
name: 'attachments',
|
||||
dataCategory: 'business',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
template: 'file',
|
||||
|
||||
@@ -11,6 +11,7 @@ export default {
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
name: 'storages',
|
||||
dataCategory: 'system',
|
||||
shared: true,
|
||||
fields: [
|
||||
{
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ export default {
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
name: 'flowModelTreePath',
|
||||
dataCategory: 'system',
|
||||
autoGenId: false,
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CollectionOptions } from '@nocobase/database';
|
||||
export default {
|
||||
dumpRules: 'required',
|
||||
name: 'flowModels',
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
autoGenId: false,
|
||||
timestamps: false,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'flowSql',
|
||||
dataCategory: 'system',
|
||||
filterTargetKey: 'uid',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
fields: [
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'graphPositions',
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
shared: true,
|
||||
fields: [
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'localeTester',
|
||||
dataCategory: 'business',
|
||||
migrationRules: ['schema-only', 'overwrite'],
|
||||
autoGenId: true,
|
||||
fields: [
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ export default defineCollection({
|
||||
},
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
name: 'localizationTexts',
|
||||
dataCategory: 'system',
|
||||
model: 'LocalizationTextModel',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ export default defineCollection({
|
||||
},
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
name: 'localizationTranslations',
|
||||
dataCategory: 'system',
|
||||
model: 'LocalizationTranslationModel',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineCollection({
|
||||
},
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
name: MapConfigurationCollectionName,
|
||||
dataCategory: 'system',
|
||||
shared: true,
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'mobileRoutes',
|
||||
dataCategory: 'system',
|
||||
dumpRules: 'required',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
title: 'mobileRoutes',
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ export default defineCollection({
|
||||
},
|
||||
migrationRules: ['schema-only', 'overwrite'],
|
||||
name: 'applications',
|
||||
dataCategory: 'business',
|
||||
model: 'ApplicationModel',
|
||||
autoGenId: false,
|
||||
sortable: 'sort',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { InAppMessagesDefinition, ChannelsDefinition } from './index';
|
||||
|
||||
export const messageCollection: CollectionOptions = {
|
||||
name: InAppMessagesDefinition.name,
|
||||
dataCategory: 'business',
|
||||
title: 'in-app messages',
|
||||
migrationRules: ['schema-only'],
|
||||
fields: [
|
||||
|
||||
@@ -11,6 +11,7 @@ import { COLLECTION_NAME } from '../constant';
|
||||
|
||||
export default {
|
||||
name: COLLECTION_NAME.channels,
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
filterTargetKey: 'name',
|
||||
autoGenId: false,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { COLLECTION_NAME } from '../constant';
|
||||
|
||||
export default {
|
||||
name: COLLECTION_NAME.logs,
|
||||
dataCategory: 'business',
|
||||
migrationRules: ['schema-only'],
|
||||
title: 'MessageLogs',
|
||||
fields: [
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'publicForms',
|
||||
dataCategory: 'system',
|
||||
filterTargetKey: 'key',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
createdBy: true,
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ import { defineCollection } from '@nocobase/database';
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'systemSettings',
|
||||
dataCategory: 'system',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
fields: [
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user