mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
1a9f637866
* fix(apps): make db --environment optional, auto-select branch server-side All db shortcuts defaulted --environment to "dev", which forced single-env apps (whose DB lives on the online branch, with no dev branch) to fail with "Invalid DB Branch: dev" unless the user explicitly passed --environment online. Change the default to empty: when --environment is omitted the CLI sends no env, letting the server pick the branch by the app's multi-env state (multi-env → dev, single-env → online), matching miaoda-cli's behavior of not carrying dbBranch when unset. Explicit --environment dev|online is unchanged; explicit dev on a single-env app still errors as expected. - 10 db shortcuts: dbEnvFlags default "dev" → "" (+db-execute, +db-table-list, +db-table-get, +db-quota-get, +db-data-export, +db-data-import, +db-changelog-list, +db-audit-list/-set/-status) - dry-run e2e assertions updated: default env is now unset, not "dev" - skill docs (lark-apps-db, lark-apps-db-execute) describe the auto-select * fix(apps): omit empty --environment param; refine dry-run tests and skill doc Address PR #1735 review: - omit-empty: when --environment is unset, drop the env query key entirely instead of sending env="" — matches the family's omit-empty convention (cf. page_token) and miaoda-cli's "no dbBranch when unset". Add dbEnvParams helper; apply across all db shortcuts (execute, table-list/-get, quota-get, changelog-list, audit-list/-set/-status, data-export/-import) plus the export/import query params, queryExportTotal and audit-list table/status probes. - e2e dry-run assertions pin env is omitted via .Exists() (was Equal ""). - skill doc (lark-apps-db): rewrite the --environment guidance from an agent's decision POV — read vs write, single-env writes hit online prod, explicit dev on single-env as a probe; drop redundant/changelog phrasing. * fix(apps): db recovery --environment support + diff/migrate display fixes - +db-recovery-diff/-apply: add --environment (env → query param on submit and both status polls), aligned with the recovery env IDL - recovery diff: parse string row counts (inserted/deleted arrive as strings) so they render as "-N rows" instead of "no changes"; drop the redundant per-table data-row line when a schema action (drop/restore/alter) exists for the same table; count tables_affected by distinct tables - +db-env-migrate: run a dry_run preview before apply to backfill the change count when the server reports changes_applied=0 on a cold apply (matches miaoda-cli's diff-then-apply) - lark-apps-db.md: drop the redundant recovery clause (recovery follows the standard --environment rule) * test(apps): cover no-env dry-run defaults + numericAsFloat string path Address CodeRabbit review threads on PR #1735: - numericAsFloat: add numeric-string cases ("13.5", " 13.5 ", int, empty) - db-data-import: assert dry-run omits env when --environment unset (table still defaults to file basename) - db-quota-get: assert dry-run omits env when --environment unset
212 lines
7.9 KiB
Go
212 lines
7.9 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
)
|
||
|
||
const dbEnvMigrateHint = "ensure the app is multi-env (`+db-env-create`) and has pending dev changes; preview with `+db-env-diff`"
|
||
|
||
// AppsDBEnvDiff 预览 dev→online 待发布的结构变更(不落地)。
|
||
//
|
||
// POST /apps/{app_id}/db/env_migrate,body {dry_run:true},同步返 {from,to,changes[]}。
|
||
// 与 +db-env-migrate 同端点、dry_run 区分;预览也需 spark:app:write scope。
|
||
var AppsDBEnvDiff = common.Shortcut{
|
||
Service: appsService,
|
||
Command: "+db-env-diff",
|
||
Description: "Preview pending dev→online schema changes (no apply)",
|
||
Risk: "read",
|
||
Tips: []string{
|
||
"Example: lark-cli apps +db-env-diff --app-id <app_id>",
|
||
"Apply the previewed changes with +db-env-migrate --yes.",
|
||
},
|
||
Scopes: []string{"spark:app:write"},
|
||
AuthTypes: []string{"user"},
|
||
HasFormat: true,
|
||
Flags: []common.Flag{
|
||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||
},
|
||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||
_, err := requireAppID(rctx.Str("app-id"))
|
||
return err
|
||
},
|
||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Preview dev→online migration").Body(map[string]interface{}{"dry_run": true})
|
||
},
|
||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||
appID, err := requireAppID(rctx.Str("app-id"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
stop := rctx.StartSpinner("Previewing migration diff (dev → online)")
|
||
defer stop()
|
||
data, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true})
|
||
stop()
|
||
if err != nil {
|
||
return withAppsHint(err, dbEnvMigrateHint)
|
||
}
|
||
from, to := common.GetString(data, "from"), common.GetString(data, "to")
|
||
changes := projectMigrationChanges(data["changes"])
|
||
out := map[string]interface{}{"from": from, "to": to, "changes": changes}
|
||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||
renderMigrationDiff(w, from, to, changes)
|
||
})
|
||
return nil
|
||
},
|
||
}
|
||
|
||
// AppsDBEnvMigrate 把 dev 的待发布结构变更发布到 online(异步,CLI 轮询至完成)。
|
||
//
|
||
// POST /apps/{app_id}/db/env_migrate,body {dry_run:false} → task_id,轮询 env_migrate_status
|
||
// 至 success;后端 status:applied,CLI 对外统一呈现 migrated。high-risk-write。
|
||
var AppsDBEnvMigrate = common.Shortcut{
|
||
Service: appsService,
|
||
Command: "+db-env-migrate",
|
||
Description: "Publish pending dev→online schema changes (irreversible)",
|
||
Risk: "high-risk-write",
|
||
Tips: []string{
|
||
"Example: lark-cli apps +db-env-migrate --app-id <app_id> --yes",
|
||
"Preview first with +db-env-diff.",
|
||
},
|
||
Scopes: []string{"spark:app:write"},
|
||
AuthTypes: []string{"user"},
|
||
HasFormat: true,
|
||
Flags: []common.Flag{
|
||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||
},
|
||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||
_, err := requireAppID(rctx.Str("app-id"))
|
||
return err
|
||
},
|
||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Apply dev→online migration").Body(map[string]interface{}{"dry_run": false})
|
||
},
|
||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||
appID, err := requireAppID(rctx.Str("app-id"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply):服务端在未经
|
||
// dry_run 预热时直接 apply,虽发布成功却把 changes_applied 回填成 0(展示「Migrated (0 changes)」)。
|
||
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
|
||
// 交由下面真实 apply 统一报同样的业务错。
|
||
pending := 0
|
||
var previewFrom, previewTo string
|
||
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
|
||
pending = len(projectMigrationChanges(preview["changes"]))
|
||
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
|
||
}
|
||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||
defer stop()
|
||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||
if err != nil {
|
||
return withAppsHint(err, dbEnvMigrateHint)
|
||
}
|
||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||
if from == "" {
|
||
from = previewFrom
|
||
}
|
||
if to == "" {
|
||
to = previewTo
|
||
}
|
||
taskID := common.GetString(submit, "task_id")
|
||
applied := intFromAny(submit["changes_applied"])
|
||
if applied == 0 {
|
||
applied = len(projectMigrationChanges(submit["changes"]))
|
||
}
|
||
// 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。
|
||
if taskID != "" {
|
||
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||
func() (map[string]interface{}, error) {
|
||
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
|
||
},
|
||
func(d map[string]interface{}) (bool, error) {
|
||
switch strings.ToLower(common.GetString(d, "status")) {
|
||
case "success", "applied", "migrated":
|
||
return true, nil
|
||
case "failed":
|
||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", migrateFailMsg(d, taskID)), dbEnvMigrateHint)
|
||
}
|
||
return false, nil
|
||
})
|
||
if perr != nil {
|
||
return perr
|
||
}
|
||
if n := intFromAny(final["changes_applied"]); n > 0 {
|
||
applied = n
|
||
}
|
||
}
|
||
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
|
||
if applied == 0 && pending > 0 {
|
||
applied = pending
|
||
}
|
||
stop() // clear spinner before printing the result
|
||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||
fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied)
|
||
})
|
||
return nil
|
||
},
|
||
}
|
||
|
||
type migrationChange struct {
|
||
Type string `json:"type"`
|
||
Table string `json:"table"`
|
||
Statement string `json:"statement"`
|
||
}
|
||
|
||
// projectMigrationChanges 把服务端原始变更项投影为白名单 migrationChange(type/table/statement)。
|
||
func projectMigrationChanges(raw interface{}) []migrationChange {
|
||
arr, _ := raw.([]interface{})
|
||
out := make([]migrationChange, 0, len(arr))
|
||
for _, it := range arr {
|
||
if m, ok := it.(map[string]interface{}); ok {
|
||
out = append(out, migrationChange{
|
||
Type: common.GetString(m, "type"),
|
||
Table: common.GetString(m, "table"),
|
||
Statement: common.GetString(m, "statement"),
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// renderMigrationDiff 渲染 dev→online 待发布变更:无变更打提示,否则逐条打 statement。
|
||
func renderMigrationDiff(w io.Writer, from, to string, changes []migrationChange) {
|
||
if len(changes) == 0 {
|
||
fmt.Fprintf(w, "No pending changes from %s to %s.\n", from, to)
|
||
return
|
||
}
|
||
fmt.Fprintf(w, "%s → %s (%d changes):\n\n", from, to, len(changes))
|
||
for _, c := range changes {
|
||
fmt.Fprintf(w, " %s\n", c.Statement)
|
||
}
|
||
}
|
||
|
||
// migrateFailMsg 取发布失败信息:优先服务端 error_message,缺失则用带 task_id 的兜底文案。
|
||
func migrateFailMsg(d map[string]interface{}, taskID string) string {
|
||
if m := common.GetString(d, "error_message"); m != "" {
|
||
return m
|
||
}
|
||
return fmt.Sprintf("migration apply failed (task_id=%s)", taskID)
|
||
}
|
||
|
||
// intFromAny 把 JSON number / json.Number 转 int(计数用)。
|
||
func intFromAny(v interface{}) int {
|
||
if f, ok := numericAsFloat(v); ok {
|
||
return int(f)
|
||
}
|
||
return 0
|
||
}
|