busa-cli 0.30.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +5 -2
- package/dist/index.js +2 -2
- package/dist/{run-CG95MbX_.js → update-check-DI0PhVoT.js} +1313 -353
- package/package.json +2 -2
|
@@ -6,17 +6,178 @@ import { DEFAULT_BASE_URL, cloudContract, createBusabaseClient, getRecordByField
|
|
|
6
6
|
import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
|
|
7
7
|
import { createInterface } from "node:readline/promises";
|
|
8
8
|
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import { access, mkdir, mkdtemp, open, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
9
10
|
import { PassThrough, Readable } from "node:stream";
|
|
10
11
|
import { pipeline } from "node:stream/promises";
|
|
11
12
|
import { ZstdCompress, ZstdDecompress } from "node:zlib";
|
|
12
13
|
import * as tar from "tar-stream";
|
|
13
|
-
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
15
|
import { createServer } from "node:http";
|
|
16
16
|
import { parse, stringify } from "yaml";
|
|
17
17
|
import { checkAirApp } from "busabase-sdk/airapp-check";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { Uint8ArrayReader, ZipReader } from "@zip.js/zip.js";
|
|
20
|
+
const LocaleSchema = z.enum({
|
|
21
|
+
defaultLocale: "en",
|
|
22
|
+
locales: [
|
|
23
|
+
"en",
|
|
24
|
+
"zh-CN",
|
|
25
|
+
"zh-TW",
|
|
26
|
+
"ja",
|
|
27
|
+
"ko",
|
|
28
|
+
"de",
|
|
29
|
+
"fr",
|
|
30
|
+
"es",
|
|
31
|
+
"pt"
|
|
32
|
+
],
|
|
33
|
+
extendLocales: [
|
|
34
|
+
"en",
|
|
35
|
+
"zh-CN",
|
|
36
|
+
"zh-TW",
|
|
37
|
+
"ja",
|
|
38
|
+
"ko",
|
|
39
|
+
"fr",
|
|
40
|
+
"de",
|
|
41
|
+
"es",
|
|
42
|
+
"ru",
|
|
43
|
+
"it",
|
|
44
|
+
"vi",
|
|
45
|
+
"pt"
|
|
46
|
+
]
|
|
47
|
+
}.locales);
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region ../../packages/openlib/i18n/i-string.ts
|
|
50
|
+
/**
|
|
51
|
+
* i18n string utilities and types / i18n 字符串工具与类型
|
|
52
|
+
*
|
|
53
|
+
* Purpose / 作用
|
|
54
|
+
* - EN: Provide a lightweight, persistable representation for business strings that may be multilingual.
|
|
55
|
+
* Values can be plain strings or locale-keyed records, allowing storage and logic usage.
|
|
56
|
+
* - ZH: 为“可持久化且可多语言”的业务字符串提供轻量表示形式;可存纯文本,或按语言分组的对象,
|
|
57
|
+
* 方便在业务逻辑内读取、比较与分支判断。
|
|
58
|
+
*
|
|
59
|
+
* Model (iString) / 模型(iString)
|
|
60
|
+
* - EN: A value is either a string or a record like { en: "Hello", zh: "你好" }.
|
|
61
|
+
* - ZH: 值可以是纯字符串,或形如 { en: "Hello", zh: "你好" } 的按语言分布对象。
|
|
62
|
+
*
|
|
63
|
+
* Validation / 校验
|
|
64
|
+
* - EN: Zod schemas validate both the record shape and the union type.
|
|
65
|
+
* - ZH: 通过 Zod 对记录结构以及联合类型进行校验,Locale 受 ./types 中定义约束。
|
|
66
|
+
*
|
|
67
|
+
* Exports / 导出
|
|
68
|
+
* - iStringRecordSchema: zod schema for { [locale]: string } constrained by supported locales.
|
|
69
|
+
* - iStringSchema: union of string | iStringRecordSchema.
|
|
70
|
+
* - isIString(str):
|
|
71
|
+
* - EN: Quick check for JSON iString records; returns parsed record or false.
|
|
72
|
+
* - ZH: 快速检测 JSON 字符串是否为 iString 记录;成功返回解析对象,否则返回 false。
|
|
73
|
+
* - iStringMatch(str, compare, lang?):
|
|
74
|
+
* - EN: Compare by specified locale or any locale; supports plain string.
|
|
75
|
+
* - ZH: 支持按指定语言或任一语言进行等值比较;纯字符串直接比较。
|
|
76
|
+
* - iStringParse(str, lang='en'):
|
|
77
|
+
* - EN: Resolve to a display string with sensible fallback.
|
|
78
|
+
* - ZH: 解析为用于展示的字符串,包含合理的回退策略。
|
|
79
|
+
* - iStringify(str, lang='en'):
|
|
80
|
+
* - EN: Wrap a plain string into a record using the given locale.
|
|
81
|
+
* - ZH: 将纯字符串按给定语言包装为记录对象。
|
|
82
|
+
* - iStringConcat(str):
|
|
83
|
+
* - EN: Concatenate all locale values for display/debug (order not guaranteed).
|
|
84
|
+
* - ZH: 拼接所有语言值以便展示/调试(顺序不保证)。
|
|
85
|
+
*
|
|
86
|
+
* Fallback behavior (iStringParse) / 回退策略(iStringParse)
|
|
87
|
+
* - EN: If str is a string, return it. If it is a record: prefer requested locale; else any non-"en"; else "en".
|
|
88
|
+
* Warn and return empty string if no suitable value exists.
|
|
89
|
+
* - ZH: 若为纯字符串,原样返回;若为记录:优先使用请求的语言;否则使用任一非 "en";再退回 "en"。
|
|
90
|
+
* 若均不可用,会发出警告并返回空字符串。
|
|
91
|
+
*
|
|
92
|
+
* Examples / 示例
|
|
93
|
+
* - iStringify('Hello') => { en: 'Hello' }
|
|
94
|
+
* - iStringParse({ en: 'Hello', zh: '你好' }, 'zh') => '你好'
|
|
95
|
+
* - iStringMatch({ en: 'Hello' }, 'Hello') => true
|
|
96
|
+
*
|
|
97
|
+
* Notes / 说明
|
|
98
|
+
* - EN: Depends on zod and the Locale/LocaleSchema from ./types to keep locales consistent.
|
|
99
|
+
* - ZH: 依赖 zod 以及 ./types 中的 Locale/LocaleSchema,确保语言键一致与受控。
|
|
100
|
+
* - EN: isIString expects a JSON string that decodes to a locale-keyed object; plain strings return false.
|
|
101
|
+
* - ZH: isIString 接受 JSON 字符串并尝试解析为按语言分布的对象;纯字符串将返回 false。
|
|
102
|
+
*/
|
|
103
|
+
const iStringRecordSchema = z.partialRecord(LocaleSchema, z.string());
|
|
104
|
+
const iStringSchema = z.union([z.string(), iStringRecordSchema]).describe("i18n string");
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region ../../packages/busabase-contract/src/contract/node-agent-prompt-schemas.ts
|
|
107
|
+
/**
|
|
108
|
+
* Per-node custom scenario prompts (`node.metadata.agentPrompts`) — the single
|
|
109
|
+
* schema shared by BOTH write and read paths, per
|
|
110
|
+
* `apps/busabase-cloud/content/spec/node-agent-prompts-v2.md` §7.3:
|
|
111
|
+
*
|
|
112
|
+
* - Write: `busabase-cli nodes set-agent-prompts` validates a file against this
|
|
113
|
+
* schema client-side, before calling `nodes.updateMetadata` — a malformed file
|
|
114
|
+
* never reaches the network.
|
|
115
|
+
* - Read: `buildNodeAgentPrompts` (`packages/busabase-core/.../node-agent-prompts.ts`)
|
|
116
|
+
* `.safeParse`s whatever is stored in `metadata.agentPrompts` and falls back to
|
|
117
|
+
* the node type's default scenarios on failure, so a bad write (or a manual
|
|
118
|
+
* jsonb edit) can never crash the Agent Prompts dialog.
|
|
119
|
+
*
|
|
120
|
+
* One schema, one place to edit the limits — do not duplicate this validation on
|
|
121
|
+
* either side.
|
|
122
|
+
*/
|
|
123
|
+
/** Recommended limits from the spec (§7.3) — kept as named constants so a limit
|
|
124
|
+
* change has exactly one place to edit, and so error messages can cite them. */
|
|
125
|
+
const CUSTOM_AGENT_PROMPT_LIMITS = {
|
|
126
|
+
/** Max custom prompts per node. */
|
|
127
|
+
maxPrompts: 50,
|
|
128
|
+
/** Max characters per localized `label` value. */
|
|
129
|
+
maxLabelChars: 80,
|
|
130
|
+
/** Max bytes (UTF-8) per localized `body` value. */
|
|
131
|
+
maxBodyBytes: 8192
|
|
132
|
+
};
|
|
133
|
+
/** Same intent vocabulary `PromptDef.intent` uses — `change` is the router-side
|
|
134
|
+
* default when omitted, matching the existing curated-prompt behavior. */
|
|
135
|
+
const customPromptIntentSchema = z.enum(["read-only", "change"]);
|
|
136
|
+
const iStringLocaleValues = (value) => typeof value === "string" ? [value] : Object.values(value);
|
|
137
|
+
/** UTF-8 byte length — `body` includes CJK/JA text, whose per-character byte
|
|
138
|
+
* cost is not 1, so a character-count limit alone would under-enforce §7.3's
|
|
139
|
+
* "8 KiB per localized body" budget. */
|
|
140
|
+
const utf8ByteLength = (value) => new TextEncoder().encode(value).length;
|
|
141
|
+
const customPromptLabelSchema = iStringSchema.refine((value) => iStringLocaleValues(value).every((v) => v.length <= CUSTOM_AGENT_PROMPT_LIMITS.maxLabelChars), { message: `label must be at most ${CUSTOM_AGENT_PROMPT_LIMITS.maxLabelChars} characters per locale` });
|
|
142
|
+
const customPromptBodySchema = iStringSchema.refine((value) => iStringLocaleValues(value).every((v) => utf8ByteLength(v) <= CUSTOM_AGENT_PROMPT_LIMITS.maxBodyBytes), { message: `body must be at most ${CUSTOM_AGENT_PROMPT_LIMITS.maxBodyBytes} bytes (UTF-8) per locale` });
|
|
143
|
+
/**
|
|
144
|
+
* One custom scenario prompt. `body`'s `{target}` placeholder is substituted at
|
|
145
|
+
* render time with the same target string `PromptDef.body(target)` receives
|
|
146
|
+
* today (see `node-agent-prompts.ts`) — this schema does not interpolate it.
|
|
147
|
+
*/
|
|
148
|
+
const customPromptDefSchema = z.object({
|
|
149
|
+
/** Stable id, unique within this node's custom list. */
|
|
150
|
+
key: z.string().trim().min(1, { message: "key must not be empty" }),
|
|
151
|
+
/** Defaults to `change` (same default the curated prompts use) so a prompt
|
|
152
|
+
* cannot silently bypass the approval-first policy by omission. */
|
|
153
|
+
intent: customPromptIntentSchema.optional(),
|
|
154
|
+
/** Short title shown in the dialog's left list. */
|
|
155
|
+
label: customPromptLabelSchema,
|
|
156
|
+
/** Template text; "{target}" is substituted with the rendered target line. */
|
|
157
|
+
body: customPromptBodySchema
|
|
158
|
+
});
|
|
159
|
+
/**
|
|
160
|
+
* The full `metadata.agentPrompts` array. `.max` bounds the list; the
|
|
161
|
+
* `superRefine` catches duplicate keys with an issue path pointing at the
|
|
162
|
+
* offending entry, so a CLI/server validation error names which entry and
|
|
163
|
+
* which key collided instead of a bare "keys must be unique".
|
|
164
|
+
*/
|
|
165
|
+
const customAgentPromptsSchema = z.array(customPromptDefSchema).max(CUSTOM_AGENT_PROMPT_LIMITS.maxPrompts, { message: `at most ${CUSTOM_AGENT_PROMPT_LIMITS.maxPrompts} custom prompts per node` }).superRefine((prompts, ctx) => {
|
|
166
|
+
const firstIndexByKey = /* @__PURE__ */ new Map();
|
|
167
|
+
prompts.forEach((prompt, index) => {
|
|
168
|
+
const firstIndex = firstIndexByKey.get(prompt.key);
|
|
169
|
+
if (firstIndex === void 0) {
|
|
170
|
+
firstIndexByKey.set(prompt.key, index);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
ctx.addIssue({
|
|
174
|
+
code: "custom",
|
|
175
|
+
path: [index, "key"],
|
|
176
|
+
message: `duplicate key "${prompt.key}" — already used at entry ${firstIndex + 1}`
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
//#endregion
|
|
20
181
|
//#region ../../packages/busabase-contract/src/tasks/base-field.ts
|
|
21
182
|
/**
|
|
22
183
|
* Changing a Base's schema, as one task with an `operation` instead of six
|
|
@@ -2842,19 +3003,55 @@ z.object({
|
|
|
2842
3003
|
textContentHash: z.string().nullable(),
|
|
2843
3004
|
byteCount: z.number().int().nonnegative()
|
|
2844
3005
|
});
|
|
3006
|
+
z.object({ nodeIds: z.array(z.string()).min(1).max(25) });
|
|
2845
3007
|
z.object({
|
|
2846
3008
|
/**
|
|
2847
|
-
*
|
|
2848
|
-
*
|
|
2849
|
-
*
|
|
2850
|
-
*
|
|
2851
|
-
*
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
3009
|
+
* One entry per requested node that is a Doc in this space. A node id that
|
|
3010
|
+
* does not resolve is simply absent (not an error): the caller asked for a
|
|
3011
|
+
* batch, and one bad id must not cost it the other 24. A Doc that exists but
|
|
3012
|
+
* has no body object yet yields `markdown: ""`, matching what a read through
|
|
3013
|
+
* the Doc domain would return.
|
|
3014
|
+
*/
|
|
3015
|
+
bodies: z.array(z.object({
|
|
3016
|
+
nodeId: z.string(),
|
|
3017
|
+
markdown: z.string()
|
|
3018
|
+
})) });
|
|
3019
|
+
z.object({
|
|
3020
|
+
/**
|
|
3021
|
+
* The space id the archive was ORIGINALLY exported from (`manifest.spaceId`
|
|
3022
|
+
* in the `.bbdump`, already integrity-verified before this is called).
|
|
3023
|
+
* `importTableRows`'s "nodes" handling needs this to recognize the
|
|
3024
|
+
* archive's own root-node row deterministically (`rootNodeIdForSpace`) —
|
|
3025
|
+
* scanning each batch's rows for "the one with a null `parentId`" only
|
|
3026
|
+
* works when that row happens to land in the SAME batch as its children,
|
|
3027
|
+
* which cursor pagination (id-ordered, not tree-ordered) does not
|
|
3028
|
+
* guarantee once a space has more nodes than one page. See the matching
|
|
3029
|
+
* comment in `import-logic.ts`.
|
|
3030
|
+
*/
|
|
3031
|
+
sourceSpaceId: z.string(),
|
|
3032
|
+
/**
|
|
3033
|
+
* Continue a restore that was interrupted partway through, instead of
|
|
3034
|
+
* requiring an empty space.
|
|
3035
|
+
*
|
|
3036
|
+
* A restore that FAILS rolls itself back (`importAbort`), so the target is
|
|
3037
|
+
* left clean and a plain re-run works. What cannot roll itself back is a
|
|
3038
|
+
* restore whose process died — Ctrl-C, OOM, a dropped connection, the
|
|
3039
|
+
* machine rebooting. That leaves the space holding however many of the
|
|
3040
|
+
* archive's rows had landed, and every subsequent attempt is refused
|
|
3041
|
+
* ("requires an empty target space") with no way forward except wiping it.
|
|
3042
|
+
*
|
|
3043
|
+
* In this mode the empty-space guard is skipped and inserts become
|
|
3044
|
+
* `ON CONFLICT DO NOTHING`, so replaying the same archive re-lands only what
|
|
3045
|
+
* is missing. Blobs and doc bodies are content-addressed writes to object
|
|
3046
|
+
* storage and were already idempotent.
|
|
3047
|
+
*
|
|
3048
|
+
* DANGEROUS if pointed at the wrong space: rows that collide are silently
|
|
3049
|
+
* skipped rather than reported, so restoring archive A into a space holding
|
|
3050
|
+
* archive B's data would interleave the two instead of refusing. Only pass
|
|
3051
|
+
* it to continue the SAME archive into the SAME space.
|
|
3052
|
+
*/
|
|
3053
|
+
resume: z.boolean().optional().default(false)
|
|
3054
|
+
});
|
|
2858
3055
|
z.object({ sessionId: z.string() });
|
|
2859
3056
|
z.object({
|
|
2860
3057
|
sessionId: z.string(),
|
|
@@ -2873,6 +3070,85 @@ z.object({
|
|
|
2873
3070
|
warnings: z.array(z.string())
|
|
2874
3071
|
});
|
|
2875
3072
|
z.object({ ok: z.boolean() });
|
|
3073
|
+
//#endregion
|
|
3074
|
+
//#region src/retry.ts
|
|
3075
|
+
const BASE_DELAY_MS = 500;
|
|
3076
|
+
const MAX_DELAY_MS = 2e4;
|
|
3077
|
+
const READ_METHODS = /* @__PURE__ */ new Set([
|
|
3078
|
+
"GET",
|
|
3079
|
+
"HEAD",
|
|
3080
|
+
"OPTIONS"
|
|
3081
|
+
]);
|
|
3082
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3083
|
+
const isNetworkError = (error) => error instanceof TypeError || /fetch failed|econnreset|econnrefused|etimedout|socket hang up/i.test(error instanceof Error ? error.message : String(error));
|
|
3084
|
+
/**
|
|
3085
|
+
* Whether an already-thrown error is worth another attempt.
|
|
3086
|
+
*
|
|
3087
|
+
* `shouldRetry` below decides from a `Response`; this decides from an `Error`,
|
|
3088
|
+
* for callers that retry ABOVE the fetch layer (see `backup`'s exporter, which
|
|
3089
|
+
* has to retry oRPC reads that `withRetry` refuses to repeat because they are
|
|
3090
|
+
* POSTs). The distinction that matters is transient-vs-deterministic: a dropped
|
|
3091
|
+
* socket or a 502 may answer differently next time, while a 404, a validation
|
|
3092
|
+
* refusal or a shape mismatch answers identically every time — retrying those
|
|
3093
|
+
* only burns the backoff, which at scale (one per attachment, on a space with
|
|
3094
|
+
* tens of thousands) is the difference between a slow backup and a stalled one.
|
|
3095
|
+
*/
|
|
3096
|
+
function isTransientError(error) {
|
|
3097
|
+
if (isNetworkError(error)) return true;
|
|
3098
|
+
const status = error?.status;
|
|
3099
|
+
if (typeof status === "number") return status === 429 || status >= 500;
|
|
3100
|
+
return /\bHTTP (429|5\d\d)\b/.test(error instanceof Error ? error.message : String(error));
|
|
3101
|
+
}
|
|
3102
|
+
/** `Retry-After` is either delay-seconds or an HTTP-date; both are legal. */
|
|
3103
|
+
function parseRetryAfter(header, now = Date.now()) {
|
|
3104
|
+
if (!header) return void 0;
|
|
3105
|
+
const seconds = Number(header.trim());
|
|
3106
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, MAX_DELAY_MS);
|
|
3107
|
+
const at = Date.parse(header);
|
|
3108
|
+
if (Number.isNaN(at)) return void 0;
|
|
3109
|
+
return Math.min(Math.max(at - now, 0), MAX_DELAY_MS);
|
|
3110
|
+
}
|
|
3111
|
+
/** Whether this outcome is worth another attempt for a request using `method`. */
|
|
3112
|
+
function shouldRetry(method, status) {
|
|
3113
|
+
if (status === 429) return true;
|
|
3114
|
+
if (!READ_METHODS.has(method.toUpperCase())) return false;
|
|
3115
|
+
return status === void 0 || status >= 500;
|
|
3116
|
+
}
|
|
3117
|
+
/**
|
|
3118
|
+
* Wrap a fetch so transient failures are retried with exponential backoff.
|
|
3119
|
+
* Returns `base` unchanged when `retries` is 0, so the no-retry path adds no
|
|
3120
|
+
* wrapper and no behaviour.
|
|
3121
|
+
*/
|
|
3122
|
+
function withRetry(base, options) {
|
|
3123
|
+
const { retries } = options;
|
|
3124
|
+
if (retries <= 0) return base;
|
|
3125
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
3126
|
+
const notify = options.notify ?? ((message) => console.error(message));
|
|
3127
|
+
const jitter = options.jitter ?? Math.random;
|
|
3128
|
+
return (async (input, init) => {
|
|
3129
|
+
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
3130
|
+
for (let attempt = 0;; attempt += 1) {
|
|
3131
|
+
let response;
|
|
3132
|
+
let failure;
|
|
3133
|
+
try {
|
|
3134
|
+
response = await base(input, init);
|
|
3135
|
+
} catch (error) {
|
|
3136
|
+
if (!isNetworkError(error)) throw error;
|
|
3137
|
+
failure = error;
|
|
3138
|
+
}
|
|
3139
|
+
const status = response?.status;
|
|
3140
|
+
const last = attempt >= retries;
|
|
3141
|
+
if (!shouldRetry(method, status) || last) {
|
|
3142
|
+
if (response) return response;
|
|
3143
|
+
throw failure;
|
|
3144
|
+
}
|
|
3145
|
+
const backoff = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
|
|
3146
|
+
const delay = parseRetryAfter(response?.headers.get("retry-after") ?? null) ?? Math.round(backoff * (.5 + jitter() * .5));
|
|
3147
|
+
notify(`[busabase-cli] ${status ? `HTTP ${status}` : "connection failed"} — retrying in ${Math.round(delay / 1e3)}s (attempt ${attempt + 2} of ${retries + 1})`);
|
|
3148
|
+
await sleep(delay);
|
|
3149
|
+
}
|
|
3150
|
+
});
|
|
3151
|
+
}
|
|
2876
3152
|
/**
|
|
2877
3153
|
* `.bbdump` manifest — the archive's own table of contents + integrity
|
|
2878
3154
|
* record. Written last (after every entry's bytes/checksums are known) so it
|
|
@@ -3263,9 +3539,12 @@ async function streamNdjsonRows(body, send) {
|
|
|
3263
3539
|
* integrity-verified (`verifyArchive`) before this is called.
|
|
3264
3540
|
*/
|
|
3265
3541
|
async function importFull(options) {
|
|
3266
|
-
const { client, archivePath, sourceSpaceId, onProgress } = options;
|
|
3542
|
+
const { client, archivePath, sourceSpaceId, resume = false, onProgress } = options;
|
|
3267
3543
|
const log = onProgress ?? (() => {});
|
|
3268
|
-
const { sessionId } = await client.dump.importBegin({
|
|
3544
|
+
const { sessionId } = await client.dump.importBegin({
|
|
3545
|
+
sourceSpaceId,
|
|
3546
|
+
resume
|
|
3547
|
+
});
|
|
3269
3548
|
try {
|
|
3270
3549
|
const docBodyRows = [];
|
|
3271
3550
|
await streamArchive(archivePath, async (entry) => {
|
|
@@ -3322,7 +3601,7 @@ async function importFull(options) {
|
|
|
3322
3601
|
}
|
|
3323
3602
|
return await client.dump.importCommit({ sessionId });
|
|
3324
3603
|
} catch (error) {
|
|
3325
|
-
await client.dump.importAbort({ sessionId }).catch(() => void 0);
|
|
3604
|
+
if (!resume) await client.dump.importAbort({ sessionId }).catch(() => void 0);
|
|
3326
3605
|
throw error;
|
|
3327
3606
|
}
|
|
3328
3607
|
}
|
|
@@ -3339,33 +3618,285 @@ async function importFull(options) {
|
|
|
3339
3618
|
* the one page here, close to the failure, rather than widening the shared
|
|
3340
3619
|
* fetch-level wrapper (which would change retry behavior for every command).
|
|
3341
3620
|
*/
|
|
3342
|
-
const
|
|
3621
|
+
const DEFAULT_PAGE_FETCH_RETRIES = 3;
|
|
3343
3622
|
const PAGE_FETCH_RETRY_DELAY_MS = 1e3;
|
|
3344
|
-
const fetchPageWithRetry = async (fetchPage, onRetry) => {
|
|
3623
|
+
const fetchPageWithRetry = async (fetchPage, onRetry, shouldRetryError = () => true, retries = DEFAULT_PAGE_FETCH_RETRIES) => {
|
|
3345
3624
|
for (let attempt = 0;; attempt += 1) try {
|
|
3346
3625
|
return await fetchPage();
|
|
3347
3626
|
} catch (error) {
|
|
3348
|
-
if (
|
|
3627
|
+
if (!shouldRetryError(error)) throw error;
|
|
3628
|
+
if (attempt >= retries) throw error;
|
|
3349
3629
|
onRetry?.(attempt + 1, error);
|
|
3350
3630
|
await new Promise((resolve) => setTimeout(resolve, PAGE_FETCH_RETRY_DELAY_MS * (attempt + 1)));
|
|
3351
3631
|
}
|
|
3352
3632
|
};
|
|
3353
3633
|
/**
|
|
3634
|
+
* A per-item loop (one doc body, one attachment blob, one asset-text blob —
|
|
3635
|
+
* always exactly one request each, no server-side batching available) can run
|
|
3636
|
+
* for many minutes on a real space with no output between its start and its
|
|
3637
|
+
* own final summary line, which reads exactly like a hang. Call this once per
|
|
3638
|
+
* iteration; it logs `message()` only every `PROGRESS_INTERVAL_MS`, not on
|
|
3639
|
+
* every single item.
|
|
3640
|
+
*/
|
|
3641
|
+
const PROGRESS_INTERVAL_MS = 5e3;
|
|
3642
|
+
const throttledProgress = (log, message) => {
|
|
3643
|
+
let lastAt = Date.now();
|
|
3644
|
+
return () => {
|
|
3645
|
+
const now = Date.now();
|
|
3646
|
+
if (now - lastAt < PROGRESS_INTERVAL_MS) return;
|
|
3647
|
+
lastAt = now;
|
|
3648
|
+
log(message());
|
|
3649
|
+
};
|
|
3650
|
+
};
|
|
3651
|
+
/**
|
|
3652
|
+
* How many blob downloads may be in flight at once.
|
|
3653
|
+
*
|
|
3654
|
+
* The export used to be strictly one-request-at-a-time. On the real Vika Team
|
|
3655
|
+
* space that is 10,486 attachments x 2 sequential round trips each (resolve a
|
|
3656
|
+
* download URL, then fetch the bytes) — ~21k serialized requests, which is
|
|
3657
|
+
* where the bulk of a multi-HOUR backup went. Latency, not bandwidth, was the
|
|
3658
|
+
* limit: a single in-flight download never comes close to saturating the link.
|
|
3659
|
+
*
|
|
3660
|
+
* 8 is deliberately modest. These run against a live production server that is
|
|
3661
|
+
* also serving real users, and each in-flight download holds its whole body in
|
|
3662
|
+
* memory (see the buffer in the attachment loop), so the ceiling also bounds
|
|
3663
|
+
* peak heap at roughly 8 x the largest file rather than the whole set.
|
|
3664
|
+
*/
|
|
3665
|
+
const BLOB_DOWNLOAD_CONCURRENCY = 8;
|
|
3666
|
+
/**
|
|
3667
|
+
* Run `work` over `items` with at most `limit` in flight, handing each result
|
|
3668
|
+
* to `consume` strictly IN ORDER and one at a time.
|
|
3669
|
+
*
|
|
3670
|
+
* The ordering guarantee is what makes this safe to drop into the existing
|
|
3671
|
+
* loops: `consume` keeps doing exactly what the sequential body did (append to
|
|
3672
|
+
* a spool file, bump counters, dedupe against a Set), with no concurrent
|
|
3673
|
+
* access to any of it — only the network wait overlaps. Ordered output also
|
|
3674
|
+
* keeps an archive's bytes reproducible for a given space rather than varying
|
|
3675
|
+
* with which download happened to finish first.
|
|
3676
|
+
*
|
|
3677
|
+
* A rejected `work` is captured rather than thrown, so one bad item still
|
|
3678
|
+
* reaches `consume` as a failure and the run continues — the loops below
|
|
3679
|
+
* already treat a single unreachable blob as a warning, not a reason to lose
|
|
3680
|
+
* the other 10,485.
|
|
3681
|
+
*/
|
|
3682
|
+
const forEachConcurrent = async (items, limit, work, consume) => {
|
|
3683
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
3684
|
+
let started = 0;
|
|
3685
|
+
for (let emitted = 0; emitted < items.length; emitted += 1) {
|
|
3686
|
+
while (inFlight.size < limit && started < items.length) {
|
|
3687
|
+
const index = started;
|
|
3688
|
+
started += 1;
|
|
3689
|
+
inFlight.set(index, work(items[index]).then((value) => ({
|
|
3690
|
+
ok: true,
|
|
3691
|
+
value
|
|
3692
|
+
}), (error) => ({
|
|
3693
|
+
ok: false,
|
|
3694
|
+
error
|
|
3695
|
+
})));
|
|
3696
|
+
}
|
|
3697
|
+
const pending = inFlight.get(emitted);
|
|
3698
|
+
inFlight.delete(emitted);
|
|
3699
|
+
await consume(await pending, items[emitted]);
|
|
3700
|
+
}
|
|
3701
|
+
};
|
|
3702
|
+
/**
|
|
3354
3703
|
* Write an NDJSON archive entry from already-collected rows without ever
|
|
3355
3704
|
* concatenating them into a single JS string first. A real production table
|
|
3356
3705
|
* (e.g. `fieldValues` holding long CMS/article-body content) can produce an
|
|
3357
3706
|
* NDJSON blob well past V8's max string length — `"".join("\n")` on such a
|
|
3358
3707
|
* table throws `RangeError: Invalid string length` and aborts the whole
|
|
3359
|
-
* backup.
|
|
3360
|
-
*
|
|
3361
|
-
*
|
|
3708
|
+
* backup. Streaming line-sized buffers through `ArchiveWriter.addStream`
|
|
3709
|
+
* (already built for exactly this — see its own doc comment) avoids ever
|
|
3710
|
+
* holding one oversized string/buffer.
|
|
3711
|
+
*
|
|
3712
|
+
* `addStream` needs the entry's total byte size up front (a tar constraint),
|
|
3713
|
+
* which used to be met by materializing every line as a `Buffer` in an array
|
|
3714
|
+
* and summing it. On `fieldValues` that is 1.1M `Buffer`s alive at once while
|
|
3715
|
+
* the 1.1M row objects they came from are also still live — the same
|
|
3716
|
+
* peak-heap shape `SpooledNdjsonEntry` was written to avoid, on the one table
|
|
3717
|
+
* that dwarfs the rest. Measure the size in a first pass (keeping only a
|
|
3718
|
+
* running total), then re-serialize lazily from a generator so at most one
|
|
3719
|
+
* line exists at a time. The cost is `JSON.stringify` twice per row, which is
|
|
3720
|
+
* CPU against a backup whose real bottleneck is the network.
|
|
3362
3721
|
*/
|
|
3363
3722
|
const writeNdjsonEntry = async (writer, path, rows) => {
|
|
3364
|
-
|
|
3365
|
-
const totalSize
|
|
3366
|
-
|
|
3723
|
+
let totalSize = 0;
|
|
3724
|
+
for (const row of rows) totalSize += Buffer.byteLength(`${JSON.stringify(row)}\n`, "utf8");
|
|
3725
|
+
const lines = function* () {
|
|
3726
|
+
for (const row of rows) yield Buffer.from(`${JSON.stringify(row)}\n`, "utf8");
|
|
3727
|
+
};
|
|
3728
|
+
await writer.addStream(path, totalSize, Readable.from(lines()));
|
|
3729
|
+
};
|
|
3730
|
+
/**
|
|
3731
|
+
* Same NDJSON-entry shape as {@link writeNdjsonEntry}, but for a table built
|
|
3732
|
+
* one row at a time over a long fetch loop (attachment blobs, asset-text
|
|
3733
|
+
* blobs) instead of already sitting in memory. `ArchiveWriter.addStream`
|
|
3734
|
+
* requires the entry's total byte size up front (a tar constraint), which is
|
|
3735
|
+
* why `writeNdjsonEntry` collects every row into an array first — fine for
|
|
3736
|
+
* table rows, but a real space's attachment blobs (base64-encoded, one row
|
|
3737
|
+
* per file) can total many GB. Held as an array of Buffers so their combined
|
|
3738
|
+
* size could be summed, that blew Node's default heap outright — confirmed
|
|
3739
|
+
* live: "JavaScript heap out of memory" partway through a ~5.7GB attachment
|
|
3740
|
+
* set. Spooling each row to a temp file as it's produced, then streaming
|
|
3741
|
+
* that file into the archive once its final size is known from disk, keeps
|
|
3742
|
+
* peak memory bounded by one row at a time rather than the whole table.
|
|
3743
|
+
*/
|
|
3744
|
+
var SpooledNdjsonEntry = class {
|
|
3745
|
+
path;
|
|
3746
|
+
stream;
|
|
3747
|
+
/**
|
|
3748
|
+
* `spoolPath` is deterministic (derived from the archive path) rather than a
|
|
3749
|
+
* random temp name, so a killed backup leaves a file the next run can find
|
|
3750
|
+
* and continue from — see `recoverCompleteRows`. A random name in `tmpdir()`
|
|
3751
|
+
* left nothing but an orphan nobody could match back to its archive.
|
|
3752
|
+
*/
|
|
3753
|
+
constructor(spoolPath, append) {
|
|
3754
|
+
this.path = spoolPath;
|
|
3755
|
+
this.stream = createWriteStream(this.path, append ? { flags: "a" } : {});
|
|
3756
|
+
}
|
|
3757
|
+
async writeRow(row) {
|
|
3758
|
+
const buf = Buffer.from(`${JSON.stringify(row)}\n`, "utf8");
|
|
3759
|
+
if (!this.stream.write(buf)) await new Promise((resolve) => this.stream.once("drain", resolve));
|
|
3760
|
+
}
|
|
3761
|
+
async finish(writer, archivePath) {
|
|
3762
|
+
await new Promise((resolve, reject) => {
|
|
3763
|
+
this.stream.end((error) => error ? reject(error) : resolve());
|
|
3764
|
+
});
|
|
3765
|
+
const { size } = await stat(this.path);
|
|
3766
|
+
await writer.addStream(archivePath, size, createReadStream(this.path));
|
|
3767
|
+
await unlink(this.path);
|
|
3768
|
+
}
|
|
3769
|
+
};
|
|
3770
|
+
/**
|
|
3771
|
+
* What a previous, killed run already spooled: the identifying key of every
|
|
3772
|
+
* COMPLETE row, plus the totals those rows represent.
|
|
3773
|
+
*
|
|
3774
|
+
* "Complete" is load-bearing. A process killed mid-write leaves a final line
|
|
3775
|
+
* with no trailing newline — valid-looking JSON prefix, truncated content. The
|
|
3776
|
+
* file is therefore truncated back to its last newline before anything is
|
|
3777
|
+
* appended, so a resumed run can never splice new output onto half a row.
|
|
3778
|
+
*
|
|
3779
|
+
* Byte totals are recomputed from the base64 lengths WITHOUT decoding: a real
|
|
3780
|
+
* space's spool is ~5.7GB, and decoding it just to re-derive a counter would
|
|
3781
|
+
* cost more than re-downloading. base64 encodes 3 bytes per 4 chars, minus one
|
|
3782
|
+
* byte per `=` of padding.
|
|
3783
|
+
*/
|
|
3784
|
+
/**
|
|
3785
|
+
* Suffixes of the two spool files a backup writes next to its archive, in the
|
|
3786
|
+
* order `exportFull` derives them (attachment blobs, then asset-text blobs).
|
|
3787
|
+
* Exported so `commands.ts` can detect leftovers from an interrupted run
|
|
3788
|
+
* without duplicating the naming.
|
|
3789
|
+
*/
|
|
3790
|
+
const SPOOL_SUFFIXES = [".attachment-blobs.part", ".asset-text-blobs.part"];
|
|
3791
|
+
const decodedBase64Bytes = (base64) => {
|
|
3792
|
+
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
|
|
3793
|
+
return Math.max(0, base64.length * 3 / 4 - padding);
|
|
3794
|
+
};
|
|
3795
|
+
const recoverCompleteRows = async (spoolPath, keyField) => {
|
|
3796
|
+
const keys = /* @__PURE__ */ new Set();
|
|
3797
|
+
let count = 0;
|
|
3798
|
+
let bytes = 0;
|
|
3799
|
+
let completeBytes = 0;
|
|
3800
|
+
let handle;
|
|
3801
|
+
try {
|
|
3802
|
+
handle = await open(spoolPath, "r+");
|
|
3803
|
+
} catch {
|
|
3804
|
+
return {
|
|
3805
|
+
keys,
|
|
3806
|
+
count,
|
|
3807
|
+
bytes
|
|
3808
|
+
};
|
|
3809
|
+
}
|
|
3810
|
+
try {
|
|
3811
|
+
let carry = "";
|
|
3812
|
+
for await (const chunk of handle.createReadStream({
|
|
3813
|
+
encoding: "utf8",
|
|
3814
|
+
autoClose: false
|
|
3815
|
+
})) {
|
|
3816
|
+
carry += chunk;
|
|
3817
|
+
let newlineIndex;
|
|
3818
|
+
while ((newlineIndex = carry.indexOf("\n")) !== -1) {
|
|
3819
|
+
const line = carry.slice(0, newlineIndex);
|
|
3820
|
+
carry = carry.slice(newlineIndex + 1);
|
|
3821
|
+
completeBytes += Buffer.byteLength(line, "utf8") + 1;
|
|
3822
|
+
if (!line) continue;
|
|
3823
|
+
let row;
|
|
3824
|
+
try {
|
|
3825
|
+
row = JSON.parse(line);
|
|
3826
|
+
} catch {
|
|
3827
|
+
completeBytes -= Buffer.byteLength(line, "utf8") + 1;
|
|
3828
|
+
carry = "";
|
|
3829
|
+
break;
|
|
3830
|
+
}
|
|
3831
|
+
const key = row[keyField];
|
|
3832
|
+
if (typeof key !== "string") continue;
|
|
3833
|
+
keys.add(key);
|
|
3834
|
+
count += 1;
|
|
3835
|
+
bytes += decodedBase64Bytes(typeof row.base64 === "string" ? row.base64 : "");
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
await handle.truncate(completeBytes);
|
|
3839
|
+
} finally {
|
|
3840
|
+
await handle.close();
|
|
3841
|
+
}
|
|
3842
|
+
return {
|
|
3843
|
+
keys,
|
|
3844
|
+
count,
|
|
3845
|
+
bytes
|
|
3846
|
+
};
|
|
3367
3847
|
};
|
|
3368
3848
|
const EXPORT_TABLES = [...DumpTableSchema.options];
|
|
3849
|
+
/**
|
|
3850
|
+
* Which dump tables reference which OTHER dump tables, column by column —
|
|
3851
|
+
* mirrors the real `.references()` declarations in busabase-core's schema
|
|
3852
|
+
* (`domains/base/schema.ts`, `domains/assets/schema/assets.ts`,
|
|
3853
|
+
* `db/schema.ts`). Only cross-table FKs matter here: a column with no
|
|
3854
|
+
* `.references()` (e.g. `fieldValues.fieldId`, a loose text ref by design, or
|
|
3855
|
+
* `assetUsages.recordId`, which defaults to `""`) cannot throw a violation and
|
|
3856
|
+
* must NOT be listed, or the drift check would drop rows Postgres would have
|
|
3857
|
+
* accepted.
|
|
3858
|
+
*
|
|
3859
|
+
* `nodes.parentId` is deliberately absent despite being a real self-FK: the
|
|
3860
|
+
* importer rewrites it (dropping the source root and remapping its children
|
|
3861
|
+
* onto the target's own root — see `import-logic.ts`), so a parentId that
|
|
3862
|
+
* looks dangling here is resolved server-side.
|
|
3863
|
+
*/
|
|
3864
|
+
const DUMP_FOREIGN_KEYS = {
|
|
3865
|
+
bases: { nodeId: "nodes" },
|
|
3866
|
+
baseFields: { baseId: "bases" },
|
|
3867
|
+
views: { baseId: "bases" },
|
|
3868
|
+
records: {
|
|
3869
|
+
baseId: "bases",
|
|
3870
|
+
headCommitId: "commits"
|
|
3871
|
+
},
|
|
3872
|
+
fieldValues: {
|
|
3873
|
+
baseId: "bases",
|
|
3874
|
+
recordId: "records",
|
|
3875
|
+
changeRequestId: "changeRequests",
|
|
3876
|
+
operationId: "operations",
|
|
3877
|
+
commitId: "commits"
|
|
3878
|
+
},
|
|
3879
|
+
recordLinks: {
|
|
3880
|
+
baseId: "bases",
|
|
3881
|
+
fieldId: "baseFields",
|
|
3882
|
+
sourceRecordId: "records",
|
|
3883
|
+
targetBaseId: "bases",
|
|
3884
|
+
targetRecordId: "records",
|
|
3885
|
+
commitId: "commits"
|
|
3886
|
+
},
|
|
3887
|
+
assetUsages: {
|
|
3888
|
+
assetId: "assets",
|
|
3889
|
+
nodeId: "nodes"
|
|
3890
|
+
},
|
|
3891
|
+
assetTexts: { assetId: "assets" },
|
|
3892
|
+
auditEvents: {
|
|
3893
|
+
baseId: "bases",
|
|
3894
|
+
recordId: "records",
|
|
3895
|
+
changeRequestId: "changeRequests",
|
|
3896
|
+
operationId: "operations",
|
|
3897
|
+
commitId: "commits"
|
|
3898
|
+
}
|
|
3899
|
+
};
|
|
3369
3900
|
const HISTORY_TABLES = [
|
|
3370
3901
|
"commits",
|
|
3371
3902
|
"changeRequests",
|
|
@@ -3375,6 +3906,84 @@ const HISTORY_TABLES = [
|
|
|
3375
3906
|
"auditEvents"
|
|
3376
3907
|
];
|
|
3377
3908
|
/**
|
|
3909
|
+
* A change request in one of these states is FINISHED — its content is history.
|
|
3910
|
+
* Anything else (`in_review`, `approved`, `conflict`, …) is work somebody has
|
|
3911
|
+
* not finished yet, and a backup that silently dropped it would lose a
|
|
3912
|
+
* colleague's in-flight work, not history. On the validated production space
|
|
3913
|
+
* that is 492 unfinished out of 22,828.
|
|
3914
|
+
*/
|
|
3915
|
+
const FINISHED_CR_STATUSES = /* @__PURE__ */ new Set([
|
|
3916
|
+
"merged",
|
|
3917
|
+
"closed",
|
|
3918
|
+
"rejected"
|
|
3919
|
+
]);
|
|
3920
|
+
/**
|
|
3921
|
+
* Reduce the history tables to what the CURRENT state (plus unfinished work)
|
|
3922
|
+
* genuinely needs, for `--no-history`.
|
|
3923
|
+
*
|
|
3924
|
+
* The flag used to just omit the history tables wholesale, which produced an
|
|
3925
|
+
* archive that could never be restored: `records.head_commit_id` is a NOT NULL
|
|
3926
|
+
* FK into `commits`, so every record dangled. Verified end-to-end — the
|
|
3927
|
+
* restore died on the first `records` batch. Dropping a table a live table
|
|
3928
|
+
* still points at is not "less history", it is a broken archive.
|
|
3929
|
+
*
|
|
3930
|
+
* What the shape of the data actually allows (measured on the real Vika Team
|
|
3931
|
+
* space, 1,130,898 fieldValues rows):
|
|
3932
|
+
* - Every (record, field) cell holds exactly ONE row — there are no
|
|
3933
|
+
* per-field historical versions to drop. 499,186 rows carry a `recordId`
|
|
3934
|
+
* and ARE the current values.
|
|
3935
|
+
* - The other 631,712 rows carry no `recordId` at all: they are values staged
|
|
3936
|
+
* inside change requests ("this CR proposes setting X to Y"). Those are the
|
|
3937
|
+
* history, and they are 56% of the table.
|
|
3938
|
+
* - 97.8% of change requests are already merged.
|
|
3939
|
+
*
|
|
3940
|
+
* So: keep every current value, keep unfinished change requests whole, and
|
|
3941
|
+
* drop the finished ones' contents. Commits are then kept transitively —
|
|
3942
|
+
* whatever the surviving rows still reference — rather than by any rule about
|
|
3943
|
+
* what a commit "is", so a NOT NULL `commitId` can never be left dangling.
|
|
3944
|
+
*/
|
|
3945
|
+
const pruneToCurrentState = (tableRows, tableCounts, log) => {
|
|
3946
|
+
const before = Object.fromEntries(HISTORY_TABLES.map((t) => [t, (tableRows[t] ?? []).length]));
|
|
3947
|
+
const beforeFieldValues = (tableRows.fieldValues ?? []).length;
|
|
3948
|
+
const keptCrIds = new Set((tableRows.changeRequests ?? []).filter((cr) => !FINISHED_CR_STATUSES.has(cr.status)).map((cr) => cr.id));
|
|
3949
|
+
const keptOperations = (tableRows.operations ?? []).filter((op) => keptCrIds.has(op.changeRequestId));
|
|
3950
|
+
const keptOpIds = new Set(keptOperations.map((op) => op.id));
|
|
3951
|
+
const keptFieldValues = (tableRows.fieldValues ?? []).filter((fv) => {
|
|
3952
|
+
if (fv.recordId) return true;
|
|
3953
|
+
if (fv.changeRequestId && keptCrIds.has(fv.changeRequestId)) return true;
|
|
3954
|
+
return Boolean(fv.operationId) && keptOpIds.has(fv.operationId);
|
|
3955
|
+
});
|
|
3956
|
+
for (const fv of keptFieldValues) {
|
|
3957
|
+
if (fv.changeRequestId && !keptCrIds.has(fv.changeRequestId)) fv.changeRequestId = null;
|
|
3958
|
+
if (fv.operationId && !keptOpIds.has(fv.operationId)) fv.operationId = null;
|
|
3959
|
+
}
|
|
3960
|
+
const keptReviews = (tableRows.reviews ?? []).filter((r) => keptCrIds.has(r.changeRequestId));
|
|
3961
|
+
const keptComments = (tableRows.comments ?? []).filter((c) => !c.changeRequestId || keptCrIds.has(c.changeRequestId));
|
|
3962
|
+
const neededCommitIds = /* @__PURE__ */ new Set();
|
|
3963
|
+
const need = (id) => {
|
|
3964
|
+
if (typeof id === "string" && id) neededCommitIds.add(id);
|
|
3965
|
+
};
|
|
3966
|
+
for (const r of tableRows.records ?? []) need(r.headCommitId);
|
|
3967
|
+
for (const rl of tableRows.recordLinks ?? []) need(rl.commitId);
|
|
3968
|
+
for (const fv of keptFieldValues) need(fv.commitId);
|
|
3969
|
+
for (const op of keptOperations) need(op.headCommitId);
|
|
3970
|
+
for (const c of keptComments) need(c.commitId);
|
|
3971
|
+
const keptCommits = (tableRows.commits ?? []).filter((c) => neededCommitIds.has(c.id));
|
|
3972
|
+
const assign = (table, rows) => {
|
|
3973
|
+
tableRows[table] = rows;
|
|
3974
|
+
tableCounts[table] = rows.length;
|
|
3975
|
+
};
|
|
3976
|
+
assign("fieldValues", keptFieldValues);
|
|
3977
|
+
assign("changeRequests", (tableRows.changeRequests ?? []).filter((cr) => keptCrIds.has(cr.id)));
|
|
3978
|
+
assign("operations", keptOperations);
|
|
3979
|
+
assign("reviews", keptReviews);
|
|
3980
|
+
assign("comments", keptComments);
|
|
3981
|
+
assign("commits", keptCommits);
|
|
3982
|
+
assign("auditEvents", []);
|
|
3983
|
+
const dropped = (table) => before[table] - (tableRows[table] ?? []).length;
|
|
3984
|
+
log(` --no-history: kept ${keptFieldValues.length}/${beforeFieldValues} fieldValues (dropped ${beforeFieldValues - keptFieldValues.length} staged in finished change requests), ${keptCrIds.size} unfinished change request(s); dropped ${dropped("commits")} commits, ${dropped("operations")} operations, ${dropped("reviews")} reviews, ${dropped("auditEvents")} audit events.`);
|
|
3985
|
+
};
|
|
3986
|
+
/**
|
|
3378
3987
|
* Full-fidelity export: pulls every dump-eligible table via the `dump.exportTables`
|
|
3379
3988
|
* endpoint (cursor-paginated, raw rows with original ids), doc bodies for
|
|
3380
3989
|
* every `doc` node via the public `docs.body` read, every asset's bytes via
|
|
@@ -3383,16 +3992,18 @@ const HISTORY_TABLES = [
|
|
|
3383
3992
|
* keeps the format uniform regardless of table size).
|
|
3384
3993
|
*/
|
|
3385
3994
|
async function exportFull(options) {
|
|
3386
|
-
const { client, outPath, spaceId, sourceHost, includeHistory, toolVersion, onProgress } = options;
|
|
3387
|
-
const
|
|
3995
|
+
const { client, outPath, spaceId, sourceHost, includeHistory, toolVersion, resume = false, retries = DEFAULT_PAGE_FETCH_RETRIES, onProgress } = options;
|
|
3996
|
+
const [attachmentSpoolPath, assetTextSpoolPath] = SPOOL_SUFFIXES.map((suffix) => `${outPath}${suffix}`);
|
|
3997
|
+
const pageLimit = options.pageLimit ?? 2e3;
|
|
3388
3998
|
const log = onProgress ?? (() => {});
|
|
3389
3999
|
const writer = ArchiveWriter.create(outPath);
|
|
3390
4000
|
const tableCounts = {};
|
|
3391
4001
|
const tableRows = {};
|
|
3392
|
-
const tables =
|
|
4002
|
+
const tables = EXPORT_TABLES;
|
|
3393
4003
|
for (const table of tables) {
|
|
3394
4004
|
const rows = [];
|
|
3395
4005
|
let cursor;
|
|
4006
|
+
const reportProgress = throttledProgress(log, () => ` ...${table}: ${rows.length} rows so far`);
|
|
3396
4007
|
do {
|
|
3397
4008
|
const page = await fetchPageWithRetry(() => client.dump.exportTables({
|
|
3398
4009
|
table,
|
|
@@ -3400,101 +4011,207 @@ async function exportFull(options) {
|
|
|
3400
4011
|
limit: pageLimit
|
|
3401
4012
|
}), (attempt, error) => {
|
|
3402
4013
|
const message = error instanceof Error ? error.message : String(error);
|
|
3403
|
-
log(` retrying ${table} page (attempt ${attempt}/${
|
|
3404
|
-
});
|
|
4014
|
+
log(` retrying ${table} page (attempt ${attempt}/${retries}): ${message}`);
|
|
4015
|
+
}, void 0, retries);
|
|
3405
4016
|
rows.push(...page.rows);
|
|
3406
4017
|
cursor = page.nextCursor ?? void 0;
|
|
4018
|
+
if (cursor) reportProgress();
|
|
3407
4019
|
} while (cursor);
|
|
3408
4020
|
tableRows[table] = rows;
|
|
3409
4021
|
tableCounts[table] = rows.length;
|
|
3410
4022
|
log(`exported ${table}: ${rows.length} rows`);
|
|
3411
4023
|
}
|
|
4024
|
+
if (!includeHistory) pruneToCurrentState(tableRows, tableCounts, log);
|
|
4025
|
+
{
|
|
4026
|
+
const exported = new Set(tables);
|
|
4027
|
+
const idsOf = (table) => exported.has(table) ? new Set((tableRows[table] ?? []).map((row) => row.id)) : null;
|
|
4028
|
+
const validIds = /* @__PURE__ */ new Map();
|
|
4029
|
+
const validIdsFor = (table) => {
|
|
4030
|
+
if (!validIds.has(table)) validIds.set(table, idsOf(table));
|
|
4031
|
+
return validIds.get(table) ?? null;
|
|
4032
|
+
};
|
|
4033
|
+
for (const table of IMPORT_ORDER) {
|
|
4034
|
+
const foreignKeys = DUMP_FOREIGN_KEYS[table];
|
|
4035
|
+
if (!foreignKeys || !exported.has(table)) continue;
|
|
4036
|
+
const rows = tableRows[table] ?? [];
|
|
4037
|
+
if (rows.length === 0) continue;
|
|
4038
|
+
const kept = rows.filter((row) => Object.entries(foreignKeys).every(([column, targetTable]) => {
|
|
4039
|
+
const id = row[column];
|
|
4040
|
+
if (id === null || id === void 0 || id === "") return true;
|
|
4041
|
+
const valid = validIdsFor(targetTable);
|
|
4042
|
+
return valid === null || valid.has(id);
|
|
4043
|
+
}));
|
|
4044
|
+
const dropped = rows.length - kept.length;
|
|
4045
|
+
if (dropped === 0) continue;
|
|
4046
|
+
log(` WARNING: dropped ${dropped} ${table} row(s) referencing a ${Object.values(foreignKeys).join("/")} that this archive does not carry — a long backup of an actively-used space can drift out of sync with itself. Re-run the backup for a tighter snapshot if these matter.`);
|
|
4047
|
+
tableRows[table] = kept;
|
|
4048
|
+
tableCounts[table] = kept.length;
|
|
4049
|
+
validIds.set(table, new Set(kept.map((row) => row.id)));
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
3412
4052
|
const docNodes = (tableRows.nodes ?? []).filter((n) => n.type === "doc");
|
|
3413
4053
|
const docBodyEntries = [];
|
|
3414
4054
|
const skippedDocBodies = [];
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
4055
|
+
const reportDocProgress = throttledProgress(log, () => ` ...doc bodies: ${docBodyEntries.length}/${docNodes.length} fetched`);
|
|
4056
|
+
const isMissingRoute = (error) => /not found|404|no procedure|unknown procedure/i.test(error instanceof Error ? error.message : String(error));
|
|
4057
|
+
const readOneDocBodyLegacy = async (nodeId) => await fetchPageWithRetry(async () => {
|
|
4058
|
+
const detail = await client.nodes.get({
|
|
4059
|
+
nodeId,
|
|
4060
|
+
type: "doc"
|
|
4061
|
+
});
|
|
4062
|
+
if (detail.type !== "doc") throw new Error(`Expected a doc node for ${nodeId}, got "${detail.type}"`);
|
|
4063
|
+
return detail.body ?? "";
|
|
4064
|
+
}, (attempt, error) => log(` retrying doc body ${nodeId} (attempt ${attempt}/${retries}): ${error instanceof Error ? error.message : String(error)}`), isTransientError, retries);
|
|
4065
|
+
const applyLegacyDocBody = (nodeId, body) => {
|
|
4066
|
+
docBodyEntries.push({
|
|
4067
|
+
nodeId,
|
|
4068
|
+
body
|
|
4069
|
+
});
|
|
4070
|
+
};
|
|
4071
|
+
let batchRouteAvailable = true;
|
|
4072
|
+
for (let i = 0; i < docNodes.length; i += 25) {
|
|
4073
|
+
reportDocProgress();
|
|
4074
|
+
const nodeIds = docNodes.slice(i, i + 25).map((node) => node.id);
|
|
4075
|
+
if (batchRouteAvailable) try {
|
|
4076
|
+
const { bodies } = await fetchPageWithRetry(() => client.dump.exportDocBodies({ nodeIds }), (attempt, error) => log(` retrying doc bodies batch (attempt ${attempt}): ${error instanceof Error ? error.message : String(error)}`), (error) => !isMissingRoute(error), retries);
|
|
4077
|
+
const byId = new Map(bodies.map((entry) => [entry.nodeId, entry.markdown]));
|
|
4078
|
+
for (const nodeId of nodeIds) {
|
|
4079
|
+
const markdown = byId.get(nodeId);
|
|
4080
|
+
if (markdown === void 0) {
|
|
4081
|
+
skippedDocBodies.push(nodeId);
|
|
4082
|
+
log(` WARNING: server returned no body for doc node ${nodeId}, skipping it`);
|
|
4083
|
+
continue;
|
|
4084
|
+
}
|
|
4085
|
+
docBodyEntries.push({
|
|
4086
|
+
nodeId,
|
|
4087
|
+
body: markdown
|
|
4088
|
+
});
|
|
4089
|
+
}
|
|
4090
|
+
continue;
|
|
3427
4091
|
} catch (error) {
|
|
4092
|
+
if (!isMissingRoute(error)) {
|
|
4093
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4094
|
+
skippedDocBodies.push(...nodeIds);
|
|
4095
|
+
log(` WARNING: could not read doc bodies for ${nodeIds.length} node(s), skipping them (${message})`);
|
|
4096
|
+
continue;
|
|
4097
|
+
}
|
|
4098
|
+
batchRouteAvailable = false;
|
|
4099
|
+
log(" NOTE: this server predates `dump.exportDocBodies`; falling back to one request per Doc. Archived Docs cannot be read that way, so their bodies will be missing from this archive — upgrade the server to capture them.");
|
|
4100
|
+
}
|
|
4101
|
+
await forEachConcurrent(nodeIds, BLOB_DOWNLOAD_CONCURRENCY, (nodeId) => readOneDocBodyLegacy(nodeId), (settled, nodeId) => {
|
|
4102
|
+
if (settled.ok) {
|
|
4103
|
+
applyLegacyDocBody(nodeId, settled.value);
|
|
4104
|
+
return;
|
|
4105
|
+
}
|
|
4106
|
+
const { error } = settled;
|
|
3428
4107
|
const message = error instanceof Error ? error.message : String(error);
|
|
3429
4108
|
skippedDocBodies.push(nodeId);
|
|
3430
4109
|
log(` WARNING: could not read doc body for node ${nodeId}, skipping it (${message})`);
|
|
3431
|
-
}
|
|
4110
|
+
});
|
|
3432
4111
|
}
|
|
3433
4112
|
log(`fetched ${docBodyEntries.length} doc bodies, skipped ${skippedDocBodies.length}`);
|
|
3434
4113
|
const attachmentsById = new Map((tableRows.attachments ?? []).map((a) => [a.id, a]));
|
|
3435
|
-
const
|
|
3436
|
-
|
|
3437
|
-
|
|
4114
|
+
const recoveredBlobs = resume ? await recoverCompleteRows(attachmentSpoolPath, "storageKey") : {
|
|
4115
|
+
keys: /* @__PURE__ */ new Set(),
|
|
4116
|
+
count: 0,
|
|
4117
|
+
bytes: 0
|
|
4118
|
+
};
|
|
4119
|
+
const attachmentBlobs = new SpooledNdjsonEntry(attachmentSpoolPath, resume);
|
|
4120
|
+
let blobCount = recoveredBlobs.count;
|
|
4121
|
+
let blobBytes = recoveredBlobs.bytes;
|
|
3438
4122
|
const skippedAssets = [];
|
|
3439
|
-
|
|
3440
|
-
|
|
4123
|
+
const assetRows = tableRows.assets ?? [];
|
|
4124
|
+
if (recoveredBlobs.count > 0) log(` resuming: ${recoveredBlobs.count} attachment blob(s) already downloaded (${recoveredBlobs.bytes} bytes), continuing with the rest`);
|
|
4125
|
+
const reportBlobProgress = throttledProgress(log, () => ` ...attachment blobs: ${blobCount}/${assetRows.length} fetched`);
|
|
4126
|
+
await forEachConcurrent(assetRows, BLOB_DOWNLOAD_CONCURRENCY, async (asset) => {
|
|
3441
4127
|
const attachment = attachmentsById.get(asset.attachmentId);
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
4128
|
+
if (!attachment) throw new Error("no matching attachments row (orphaned asset)");
|
|
4129
|
+
if (recoveredBlobs.keys.has(attachment.storageKey)) return null;
|
|
4130
|
+
return await fetchPageWithRetry(async () => {
|
|
4131
|
+
const download = await client.assets.download({ assetId: asset.id });
|
|
3445
4132
|
const resolvedUrl = new URL(download.downloadUrl, sourceHost).toString();
|
|
3446
4133
|
const res = await fetch(resolvedUrl);
|
|
3447
4134
|
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
|
3448
|
-
|
|
3449
|
-
attachmentBlobRows.push({
|
|
4135
|
+
return {
|
|
3450
4136
|
storageKey: attachment.storageKey,
|
|
3451
4137
|
mimeType: download.mimeType,
|
|
3452
|
-
|
|
3453
|
-
}
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
4138
|
+
buf: Buffer.from(await res.arrayBuffer())
|
|
4139
|
+
};
|
|
4140
|
+
}, (attempt, error) => log(` retrying asset ${asset.id} (attempt ${attempt}/${retries}): ${error instanceof Error ? error.message : String(error)}`), isTransientError, retries);
|
|
4141
|
+
}, async (settled, asset) => {
|
|
4142
|
+
reportBlobProgress();
|
|
4143
|
+
const assetId = asset.id;
|
|
4144
|
+
if (!settled.ok) {
|
|
4145
|
+
const { error } = settled;
|
|
3457
4146
|
const message = error instanceof Error ? error.message : String(error);
|
|
3458
4147
|
skippedAssets.push(assetId);
|
|
3459
4148
|
log(` WARNING: could not download asset ${assetId}, skipping its blob (${message})`);
|
|
4149
|
+
return;
|
|
3460
4150
|
}
|
|
3461
|
-
|
|
4151
|
+
if (!settled.value) return;
|
|
4152
|
+
const { storageKey, mimeType, buf } = settled.value;
|
|
4153
|
+
await attachmentBlobs.writeRow({
|
|
4154
|
+
storageKey,
|
|
4155
|
+
mimeType,
|
|
4156
|
+
base64: buf.toString("base64")
|
|
4157
|
+
});
|
|
4158
|
+
blobCount += 1;
|
|
4159
|
+
blobBytes += buf.length;
|
|
4160
|
+
});
|
|
3462
4161
|
log(`fetched ${blobCount} attachment blobs (${blobBytes} bytes), skipped ${skippedAssets.length}`);
|
|
3463
|
-
const
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
4162
|
+
const recoveredTexts = resume ? await recoverCompleteRows(assetTextSpoolPath, "textStorageKey") : {
|
|
4163
|
+
keys: /* @__PURE__ */ new Set(),
|
|
4164
|
+
count: 0,
|
|
4165
|
+
bytes: 0
|
|
4166
|
+
};
|
|
4167
|
+
const textBlobs = new SpooledNdjsonEntry(assetTextSpoolPath, resume);
|
|
4168
|
+
const seenTextKeys = new Set(recoveredTexts.keys);
|
|
4169
|
+
let textBlobCount = recoveredTexts.count;
|
|
4170
|
+
let textBlobBytes = recoveredTexts.bytes;
|
|
3467
4171
|
const skippedAssetTexts = [];
|
|
3468
|
-
|
|
4172
|
+
const assetTextRows = tableRows.assetTexts ?? [];
|
|
4173
|
+
if (recoveredTexts.count > 0) log(` resuming: ${recoveredTexts.count} asset-text blob(s) already downloaded`);
|
|
4174
|
+
const reportTextProgress = throttledProgress(log, () => ` ...asset-text blobs: ${textBlobCount}/${assetTextRows.length} fetched`);
|
|
4175
|
+
await forEachConcurrent(assetTextRows, BLOB_DOWNLOAD_CONCURRENCY, async (assetText) => await fetchPageWithRetry(async () => {
|
|
4176
|
+
const info = await client.dump.exportAssetText({ assetId: assetText.assetId });
|
|
4177
|
+
if (!info.downloadUrl) return null;
|
|
4178
|
+
if (recoveredTexts.keys.has(info.textStorageKey)) return null;
|
|
4179
|
+
const resolvedUrl = new URL(info.downloadUrl, sourceHost).toString();
|
|
4180
|
+
const res = await fetch(resolvedUrl);
|
|
4181
|
+
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
|
4182
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
4183
|
+
if (info.textContentHash) {
|
|
4184
|
+
const actual = `sha256:${createHash("sha256").update(buf).digest("hex")}`;
|
|
4185
|
+
if (actual !== info.textContentHash) throw new Error(`content hash mismatch (expected ${info.textContentHash}, downloaded ${actual})`);
|
|
4186
|
+
}
|
|
4187
|
+
return {
|
|
4188
|
+
textStorageKey: info.textStorageKey,
|
|
4189
|
+
buf
|
|
4190
|
+
};
|
|
4191
|
+
}, (attempt, error) => log(` retrying asset text ${assetText.assetId} (attempt ${attempt}/${retries}): ${error instanceof Error ? error.message : String(error)}`), isTransientError, retries), async (settled, assetText) => {
|
|
4192
|
+
reportTextProgress();
|
|
3469
4193
|
const assetId = assetText.assetId;
|
|
3470
|
-
|
|
3471
|
-
const
|
|
3472
|
-
if (!info.downloadUrl) continue;
|
|
3473
|
-
if (seenTextKeys.has(info.textStorageKey)) continue;
|
|
3474
|
-
const resolvedUrl = new URL(info.downloadUrl, sourceHost).toString();
|
|
3475
|
-
const res = await fetch(resolvedUrl);
|
|
3476
|
-
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
|
3477
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
3478
|
-
if (info.textContentHash) {
|
|
3479
|
-
const actual = `sha256:${createHash("sha256").update(buf).digest("hex")}`;
|
|
3480
|
-
if (actual !== info.textContentHash) throw new Error(`content hash mismatch (expected ${info.textContentHash}, downloaded ${actual})`);
|
|
3481
|
-
}
|
|
3482
|
-
seenTextKeys.add(info.textStorageKey);
|
|
3483
|
-
textBlobRows.push({
|
|
3484
|
-
textStorageKey: info.textStorageKey,
|
|
3485
|
-
base64: buf.toString("base64")
|
|
3486
|
-
});
|
|
3487
|
-
textBlobCount += 1;
|
|
3488
|
-
textBlobBytes += buf.length;
|
|
3489
|
-
} catch (error) {
|
|
4194
|
+
if (!settled.ok) {
|
|
4195
|
+
const { error } = settled;
|
|
3490
4196
|
const message = error instanceof Error ? error.message : String(error);
|
|
3491
4197
|
skippedAssetTexts.push(assetId);
|
|
3492
4198
|
log(` WARNING: could not download text for asset ${assetId}, skipping its blob (${message})`);
|
|
4199
|
+
return;
|
|
3493
4200
|
}
|
|
3494
|
-
|
|
4201
|
+
if (!settled.value) return;
|
|
4202
|
+
const { textStorageKey, buf } = settled.value;
|
|
4203
|
+
if (seenTextKeys.has(textStorageKey)) return;
|
|
4204
|
+
seenTextKeys.add(textStorageKey);
|
|
4205
|
+
await textBlobs.writeRow({
|
|
4206
|
+
textStorageKey,
|
|
4207
|
+
base64: buf.toString("base64")
|
|
4208
|
+
});
|
|
4209
|
+
textBlobCount += 1;
|
|
4210
|
+
textBlobBytes += buf.length;
|
|
4211
|
+
});
|
|
3495
4212
|
log(`fetched ${textBlobCount} asset-text blobs (${textBlobBytes} bytes), skipped ${skippedAssetTexts.length}`);
|
|
3496
|
-
await
|
|
3497
|
-
await
|
|
4213
|
+
await attachmentBlobs.finish(writer, "tree/attachmentBlobs.ndjson");
|
|
4214
|
+
await textBlobs.finish(writer, "tree/assetTextBlobs.ndjson");
|
|
3498
4215
|
for (const table of IMPORT_ORDER) {
|
|
3499
4216
|
if (!tables.includes(table)) continue;
|
|
3500
4217
|
await writeNdjsonEntry(writer, `tree/${table}.ndjson`, tableRows[table] ?? []);
|
|
@@ -3519,10 +4236,24 @@ async function exportFull(options) {
|
|
|
3519
4236
|
}
|
|
3520
4237
|
//#endregion
|
|
3521
4238
|
//#region src/backup/commands.ts
|
|
4239
|
+
/**
|
|
4240
|
+
* `backup` and `restore` — the terminal-facing halves of the full-fidelity
|
|
4241
|
+
* `.bbdump` archive format. Thin orchestration: drain every dump-eligible table
|
|
4242
|
+
* into an archive, or verify an archive and replay it into an empty space.
|
|
4243
|
+
*/
|
|
3522
4244
|
/** Progress is diagnostics, not data — it must never pollute `--output json` on stdout. */
|
|
3523
4245
|
const reportProgress$1 = (message) => {
|
|
3524
4246
|
console.error(message);
|
|
3525
4247
|
};
|
|
4248
|
+
/** Which of this archive's spool files a previous, interrupted run left behind. */
|
|
4249
|
+
const findSpoolFiles = async (outFile) => {
|
|
4250
|
+
const present = [];
|
|
4251
|
+
for (const suffix of SPOOL_SUFFIXES) try {
|
|
4252
|
+
await access(`${outFile}${suffix}`);
|
|
4253
|
+
present.push(`${outFile}${suffix}`);
|
|
4254
|
+
} catch {}
|
|
4255
|
+
return present;
|
|
4256
|
+
};
|
|
3526
4257
|
/**
|
|
3527
4258
|
* Deliberately not implemented: `export` already writes the portable current-state
|
|
3528
4259
|
* representation as a readable, diffable directory. The two formats stay separate
|
|
@@ -3557,7 +4288,11 @@ const runBackup = async (client, options) => {
|
|
|
3557
4288
|
if (options.stateOnly) throw new Error(STATE_ONLY_BACKUP_MESSAGE);
|
|
3558
4289
|
const { spaceId } = options;
|
|
3559
4290
|
if (!spaceId) throw new Error("No space configured. Pass --space-id <id>, export BUSABASE_SPACE_ID, or run `busabase-cli login`.");
|
|
3560
|
-
|
|
4291
|
+
if (!options.resume) {
|
|
4292
|
+
const orphaned = await findSpoolFiles(options.outFile);
|
|
4293
|
+
if (orphaned.length > 0) reportProgress$1(` NOTE: found ${orphaned.length} partial file(s) from an interrupted backup (${orphaned.join(", ")}). Re-run with --resume to continue from them; this run will start over and overwrite them.`);
|
|
4294
|
+
}
|
|
4295
|
+
reportProgress$1(`Backing up space ${spaceId} → ${options.outFile} (full fidelity, history=${options.includeHistory}${options.resume ? ", resuming" : ""})`);
|
|
3561
4296
|
const manifest = await exportFull({
|
|
3562
4297
|
client,
|
|
3563
4298
|
outPath: options.outFile,
|
|
@@ -3565,6 +4300,8 @@ const runBackup = async (client, options) => {
|
|
|
3565
4300
|
sourceHost: options.baseUrl,
|
|
3566
4301
|
includeHistory: options.includeHistory,
|
|
3567
4302
|
toolVersion: options.toolVersion,
|
|
4303
|
+
resume: Boolean(options.resume),
|
|
4304
|
+
retries: options.retries,
|
|
3568
4305
|
onProgress: (message) => reportProgress$1(` ${message}`)
|
|
3569
4306
|
});
|
|
3570
4307
|
const rows = Object.values(manifest.tables).reduce((total, count) => total + count, 0);
|
|
@@ -3583,7 +4320,7 @@ const runBackup = async (client, options) => {
|
|
|
3583
4320
|
].join("\n");
|
|
3584
4321
|
};
|
|
3585
4322
|
const runRestore = async (client, options) => {
|
|
3586
|
-
if (options.resume)
|
|
4323
|
+
if (options.resume) reportProgress$1("Resuming: rows already present are left alone, and this run will NOT roll back on failure. Only do this to continue THIS archive into THIS space.");
|
|
3587
4324
|
reportProgress$1(`Reading ${options.file} …`);
|
|
3588
4325
|
const manifest = await verifyArchive(options.file);
|
|
3589
4326
|
reportProgress$1(`Archive: fidelity=${manifest.fidelity} space=${manifest.spaceId} exportedAt=${manifest.exportedAt}`);
|
|
@@ -3593,6 +4330,7 @@ const runRestore = async (client, options) => {
|
|
|
3593
4330
|
client,
|
|
3594
4331
|
archivePath: options.file,
|
|
3595
4332
|
sourceSpaceId: manifest.spaceId,
|
|
4333
|
+
resume: Boolean(options.resume),
|
|
3596
4334
|
onProgress: (message) => reportProgress$1(` ${message}`)
|
|
3597
4335
|
});
|
|
3598
4336
|
if (options.json) return {
|
|
@@ -4910,138 +5648,53 @@ const auditPackage = (tree, files, options = {}) => {
|
|
|
4910
5648
|
*
|
|
4911
5649
|
* Runs for any directory carrying a root `SKILL.md` — package or not, template or not.
|
|
4912
5650
|
* It reports nothing at all when there is no manual: a package without one is a
|
|
4913
|
-
* perfectly legitimate thing to publish, not a Skill that failed.
|
|
4914
|
-
*
|
|
4915
|
-
* The honest limit: a checker can tell you the frontmatter parses and the files the
|
|
4916
|
-
* manual points at exist. It cannot tell you the manual is *true*. Everything in
|
|
4917
|
-
* `SKILL.md` becomes instructions an agent acts on, and no static rule catches a
|
|
4918
|
-
* confident, wrong sentence about what a table means.
|
|
4919
|
-
*/
|
|
4920
|
-
const auditSkill = (files, options = {}) => {
|
|
4921
|
-
const findings = [];
|
|
4922
|
-
const error = (rule, message) => findings.push({
|
|
4923
|
-
severity: "error",
|
|
4924
|
-
rule,
|
|
4925
|
-
message
|
|
4926
|
-
});
|
|
4927
|
-
const warn = (rule, message) => findings.push({
|
|
4928
|
-
severity: "warning",
|
|
4929
|
-
rule,
|
|
4930
|
-
message
|
|
4931
|
-
});
|
|
4932
|
-
const root = options.root ?? "";
|
|
4933
|
-
const source = readText(files, root, PACKAGE_SKILL_ENTRY);
|
|
4934
|
-
if (source === void 0) return findings;
|
|
4935
|
-
let body = "";
|
|
4936
|
-
try {
|
|
4937
|
-
const parsed = parseFrontmatter(source, PACKAGE_SKILL_ENTRY);
|
|
4938
|
-
body = parsed.body;
|
|
4939
|
-
const frontmatter = SkillFrontmatterSchema$1.safeParse(parsed.data);
|
|
4940
|
-
if (!frontmatter.success) error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY} frontmatter is not a valid Skill declaration: ${frontmatter.error.issues.map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`).join("; ")}`);
|
|
4941
|
-
else if (frontmatter.data.description.trim() === "") error("skill/description", `${PACKAGE_SKILL_ENTRY} has no description. An agent chooses whether to reach for a Skill by reading it, so an empty one is not cosmetic — it is a Skill that never gets picked.`);
|
|
4942
|
-
} catch (cause) {
|
|
4943
|
-
error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY}: ${cause.message}`);
|
|
4944
|
-
return findings;
|
|
4945
|
-
}
|
|
4946
|
-
const todos = (body.match(/^.*\bTODO\b.*$/gm) ?? []).length;
|
|
4947
|
-
if (todos > 0) error("skill/todo", `${PACKAGE_SKILL_ENTRY} still carries ${todos} TODO line(s). The export draft leaves them on purpose — an agent acts on what this file says, and a blank the author was meant to fill in is worse than nothing once it ships.`);
|
|
4948
|
-
const present = new Set(relativePaths(files, root));
|
|
4949
|
-
const sidecar = new RegExp(`\\]\\((\\.?/?(?:${PACKAGE_SIDECARS})/[^)\\s]+)\\)|\`((?:${PACKAGE_SIDECARS})/[^\`\\s]+)\``, "g");
|
|
4950
|
-
const seen = /* @__PURE__ */ new Set();
|
|
4951
|
-
for (const match of body.matchAll(sidecar)) {
|
|
4952
|
-
const target = (match[1] ?? match[2]).replace(/^\.?\//, "");
|
|
4953
|
-
if (seen.has(target)) continue;
|
|
4954
|
-
seen.add(target);
|
|
4955
|
-
if (!present.has(target)) warn("skill/missing-reference", `${PACKAGE_SKILL_ENTRY} points at "${target}", which the package does not ship. An agent told to read it has an instruction it cannot satisfy.`);
|
|
4956
|
-
}
|
|
4957
|
-
return findings;
|
|
4958
|
-
};
|
|
4959
|
-
const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
|
|
4960
|
-
const LocaleSchema = z.enum({
|
|
4961
|
-
defaultLocale: "en",
|
|
4962
|
-
locales: [
|
|
4963
|
-
"en",
|
|
4964
|
-
"zh-CN",
|
|
4965
|
-
"zh-TW",
|
|
4966
|
-
"ja",
|
|
4967
|
-
"ko",
|
|
4968
|
-
"de",
|
|
4969
|
-
"fr",
|
|
4970
|
-
"es",
|
|
4971
|
-
"pt"
|
|
4972
|
-
],
|
|
4973
|
-
extendLocales: [
|
|
4974
|
-
"en",
|
|
4975
|
-
"zh-CN",
|
|
4976
|
-
"zh-TW",
|
|
4977
|
-
"ja",
|
|
4978
|
-
"ko",
|
|
4979
|
-
"fr",
|
|
4980
|
-
"de",
|
|
4981
|
-
"es",
|
|
4982
|
-
"ru",
|
|
4983
|
-
"it",
|
|
4984
|
-
"vi",
|
|
4985
|
-
"pt"
|
|
4986
|
-
]
|
|
4987
|
-
}.locales);
|
|
4988
|
-
//#endregion
|
|
4989
|
-
//#region ../../packages/openlib/i18n/i-string.ts
|
|
4990
|
-
/**
|
|
4991
|
-
* i18n string utilities and types / i18n 字符串工具与类型
|
|
4992
|
-
*
|
|
4993
|
-
* Purpose / 作用
|
|
4994
|
-
* - EN: Provide a lightweight, persistable representation for business strings that may be multilingual.
|
|
4995
|
-
* Values can be plain strings or locale-keyed records, allowing storage and logic usage.
|
|
4996
|
-
* - ZH: 为“可持久化且可多语言”的业务字符串提供轻量表示形式;可存纯文本,或按语言分组的对象,
|
|
4997
|
-
* 方便在业务逻辑内读取、比较与分支判断。
|
|
4998
|
-
*
|
|
4999
|
-
* Model (iString) / 模型(iString)
|
|
5000
|
-
* - EN: A value is either a string or a record like { en: "Hello", zh: "你好" }.
|
|
5001
|
-
* - ZH: 值可以是纯字符串,或形如 { en: "Hello", zh: "你好" } 的按语言分布对象。
|
|
5002
|
-
*
|
|
5003
|
-
* Validation / 校验
|
|
5004
|
-
* - EN: Zod schemas validate both the record shape and the union type.
|
|
5005
|
-
* - ZH: 通过 Zod 对记录结构以及联合类型进行校验,Locale 受 ./types 中定义约束。
|
|
5006
|
-
*
|
|
5007
|
-
* Exports / 导出
|
|
5008
|
-
* - iStringRecordSchema: zod schema for { [locale]: string } constrained by supported locales.
|
|
5009
|
-
* - iStringSchema: union of string | iStringRecordSchema.
|
|
5010
|
-
* - isIString(str):
|
|
5011
|
-
* - EN: Quick check for JSON iString records; returns parsed record or false.
|
|
5012
|
-
* - ZH: 快速检测 JSON 字符串是否为 iString 记录;成功返回解析对象,否则返回 false。
|
|
5013
|
-
* - iStringMatch(str, compare, lang?):
|
|
5014
|
-
* - EN: Compare by specified locale or any locale; supports plain string.
|
|
5015
|
-
* - ZH: 支持按指定语言或任一语言进行等值比较;纯字符串直接比较。
|
|
5016
|
-
* - iStringParse(str, lang='en'):
|
|
5017
|
-
* - EN: Resolve to a display string with sensible fallback.
|
|
5018
|
-
* - ZH: 解析为用于展示的字符串,包含合理的回退策略。
|
|
5019
|
-
* - iStringify(str, lang='en'):
|
|
5020
|
-
* - EN: Wrap a plain string into a record using the given locale.
|
|
5021
|
-
* - ZH: 将纯字符串按给定语言包装为记录对象。
|
|
5022
|
-
* - iStringConcat(str):
|
|
5023
|
-
* - EN: Concatenate all locale values for display/debug (order not guaranteed).
|
|
5024
|
-
* - ZH: 拼接所有语言值以便展示/调试(顺序不保证)。
|
|
5025
|
-
*
|
|
5026
|
-
* Fallback behavior (iStringParse) / 回退策略(iStringParse)
|
|
5027
|
-
* - EN: If str is a string, return it. If it is a record: prefer requested locale; else any non-"en"; else "en".
|
|
5028
|
-
* Warn and return empty string if no suitable value exists.
|
|
5029
|
-
* - ZH: 若为纯字符串,原样返回;若为记录:优先使用请求的语言;否则使用任一非 "en";再退回 "en"。
|
|
5030
|
-
* 若均不可用,会发出警告并返回空字符串。
|
|
5031
|
-
*
|
|
5032
|
-
* Examples / 示例
|
|
5033
|
-
* - iStringify('Hello') => { en: 'Hello' }
|
|
5034
|
-
* - iStringParse({ en: 'Hello', zh: '你好' }, 'zh') => '你好'
|
|
5035
|
-
* - iStringMatch({ en: 'Hello' }, 'Hello') => true
|
|
5651
|
+
* perfectly legitimate thing to publish, not a Skill that failed.
|
|
5036
5652
|
*
|
|
5037
|
-
*
|
|
5038
|
-
*
|
|
5039
|
-
*
|
|
5040
|
-
*
|
|
5041
|
-
* - ZH: isIString 接受 JSON 字符串并尝试解析为按语言分布的对象;纯字符串将返回 false。
|
|
5653
|
+
* The honest limit: a checker can tell you the frontmatter parses and the files the
|
|
5654
|
+
* manual points at exist. It cannot tell you the manual is *true*. Everything in
|
|
5655
|
+
* `SKILL.md` becomes instructions an agent acts on, and no static rule catches a
|
|
5656
|
+
* confident, wrong sentence about what a table means.
|
|
5042
5657
|
*/
|
|
5043
|
-
const
|
|
5044
|
-
|
|
5658
|
+
const auditSkill = (files, options = {}) => {
|
|
5659
|
+
const findings = [];
|
|
5660
|
+
const error = (rule, message) => findings.push({
|
|
5661
|
+
severity: "error",
|
|
5662
|
+
rule,
|
|
5663
|
+
message
|
|
5664
|
+
});
|
|
5665
|
+
const warn = (rule, message) => findings.push({
|
|
5666
|
+
severity: "warning",
|
|
5667
|
+
rule,
|
|
5668
|
+
message
|
|
5669
|
+
});
|
|
5670
|
+
const root = options.root ?? "";
|
|
5671
|
+
const source = readText(files, root, PACKAGE_SKILL_ENTRY);
|
|
5672
|
+
if (source === void 0) return findings;
|
|
5673
|
+
let body = "";
|
|
5674
|
+
try {
|
|
5675
|
+
const parsed = parseFrontmatter(source, PACKAGE_SKILL_ENTRY);
|
|
5676
|
+
body = parsed.body;
|
|
5677
|
+
const frontmatter = SkillFrontmatterSchema$1.safeParse(parsed.data);
|
|
5678
|
+
if (!frontmatter.success) error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY} frontmatter is not a valid Skill declaration: ${frontmatter.error.issues.map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`).join("; ")}`);
|
|
5679
|
+
else if (frontmatter.data.description.trim() === "") error("skill/description", `${PACKAGE_SKILL_ENTRY} has no description. An agent chooses whether to reach for a Skill by reading it, so an empty one is not cosmetic — it is a Skill that never gets picked.`);
|
|
5680
|
+
} catch (cause) {
|
|
5681
|
+
error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY}: ${cause.message}`);
|
|
5682
|
+
return findings;
|
|
5683
|
+
}
|
|
5684
|
+
const todos = (body.match(/^.*\bTODO\b.*$/gm) ?? []).length;
|
|
5685
|
+
if (todos > 0) error("skill/todo", `${PACKAGE_SKILL_ENTRY} still carries ${todos} TODO line(s). The export draft leaves them on purpose — an agent acts on what this file says, and a blank the author was meant to fill in is worse than nothing once it ships.`);
|
|
5686
|
+
const present = new Set(relativePaths(files, root));
|
|
5687
|
+
const sidecar = new RegExp(`\\]\\((\\.?/?(?:${PACKAGE_SIDECARS})/[^)\\s]+)\\)|\`((?:${PACKAGE_SIDECARS})/[^\`\\s]+)\``, "g");
|
|
5688
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5689
|
+
for (const match of body.matchAll(sidecar)) {
|
|
5690
|
+
const target = (match[1] ?? match[2]).replace(/^\.?\//, "");
|
|
5691
|
+
if (seen.has(target)) continue;
|
|
5692
|
+
seen.add(target);
|
|
5693
|
+
if (!present.has(target)) warn("skill/missing-reference", `${PACKAGE_SKILL_ENTRY} points at "${target}", which the package does not ship. An agent told to read it has an instruction it cannot satisfy.`);
|
|
5694
|
+
}
|
|
5695
|
+
return findings;
|
|
5696
|
+
};
|
|
5697
|
+
const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
|
|
5045
5698
|
//#endregion
|
|
5046
5699
|
//#region ../../packages/busabase-contract/src/contract/auto-merge.ts
|
|
5047
5700
|
/**
|
|
@@ -5187,7 +5840,8 @@ const baseSchema = z.object({
|
|
|
5187
5840
|
requiredApprovals: z.number()
|
|
5188
5841
|
}),
|
|
5189
5842
|
createdAt: z.string(),
|
|
5190
|
-
fields: z.array(baseFieldSchema)
|
|
5843
|
+
fields: z.array(baseFieldSchema),
|
|
5844
|
+
metadata: z.record(z.string(), z.unknown()).default({})
|
|
5191
5845
|
});
|
|
5192
5846
|
z.object({
|
|
5193
5847
|
parentNodeId: z.string().optional().describe("Parent node id. Must be a folder or the space root; container-incapable node types (Base, Doc, AirApp, etc.) cannot hold children."),
|
|
@@ -5371,6 +6025,7 @@ const nodeSchema = z.lazy(() => z.object({
|
|
|
5371
6025
|
name: z.string(),
|
|
5372
6026
|
description: z.string(),
|
|
5373
6027
|
metadata: z.object({ version: z.string().optional() }).catchall(z.unknown()).default({}),
|
|
6028
|
+
settings: nodeSettingsSchema.default({}),
|
|
5374
6029
|
explicitVisibility: z.enum([
|
|
5375
6030
|
"private",
|
|
5376
6031
|
"workspace",
|
|
@@ -5454,6 +6109,53 @@ z.object({
|
|
|
5454
6109
|
nodeId: z.string(),
|
|
5455
6110
|
metadata: z.record(z.string(), z.unknown())
|
|
5456
6111
|
});
|
|
6112
|
+
/**
|
|
6113
|
+
* System settings Busabase itself acts on, per node.
|
|
6114
|
+
*
|
|
6115
|
+
* A CLOSED object, unlike `metadata`'s `z.record(z.string(), z.unknown())`.
|
|
6116
|
+
* That difference is the point: `metadata` is the open bag callers put their
|
|
6117
|
+
* own keys in, and its endpoint merges whatever it is handed. Anything the
|
|
6118
|
+
* product *reads and acts on* must not live there — `metadata.visibility` had
|
|
6119
|
+
* to be pulled out into its own column precisely because the generic endpoint
|
|
6120
|
+
* handed a `write`-level actor a door into an ACL input.
|
|
6121
|
+
*
|
|
6122
|
+
* Adding a key here is a deliberate change to this schema, which is what keeps
|
|
6123
|
+
* `PATCH /nodes/{nodeId}/settings` from becoming a second free-form bag.
|
|
6124
|
+
*
|
|
6125
|
+
* `strictObject`, not `object`: zod's default is to STRIP an unknown key, which
|
|
6126
|
+
* would hand a client that misspelled `airappEngine` a 200 and no effect —
|
|
6127
|
+
* exactly the silent failure a closed schema exists to prevent. Verified
|
|
6128
|
+
* against a running server, where the permissive version returned 200.
|
|
6129
|
+
*/
|
|
6130
|
+
const nodeSettingsSchema = z.strictObject({
|
|
6131
|
+
/**
|
|
6132
|
+
* Which engine an AirApp runs on, when a human has chosen.
|
|
6133
|
+
*
|
|
6134
|
+
* `undefined` means "follow the app" — `airapp.json`'s `preferredEngine`
|
|
6135
|
+
* decides, or the default does. A value means somebody overrode it in the
|
|
6136
|
+
* node settings dialog, and it outranks the manifest from then on. So absence
|
|
6137
|
+
* must stay distinguishable from any particular value; `null` clears an
|
|
6138
|
+
* override and returns the node to following the app.
|
|
6139
|
+
*/
|
|
6140
|
+
airappEngine: z.enum([
|
|
6141
|
+
"browser",
|
|
6142
|
+
"local",
|
|
6143
|
+
"remote"
|
|
6144
|
+
]).nullish() });
|
|
6145
|
+
z.object({
|
|
6146
|
+
nodeId: z.string(),
|
|
6147
|
+
settings: nodeSettingsSchema
|
|
6148
|
+
});
|
|
6149
|
+
z.object({ nodeId: z.string() });
|
|
6150
|
+
z.object({
|
|
6151
|
+
nodeId: z.string(),
|
|
6152
|
+
agentPrompts: customAgentPromptsSchema.nullable()
|
|
6153
|
+
});
|
|
6154
|
+
z.object({
|
|
6155
|
+
nodeId: z.string(),
|
|
6156
|
+
/** Replaces the whole list — this is not a merge. Send `null` to clear. */
|
|
6157
|
+
agentPrompts: customAgentPromptsSchema.nullable()
|
|
6158
|
+
});
|
|
5457
6159
|
z.object({
|
|
5458
6160
|
query: z.string().min(1),
|
|
5459
6161
|
limit: z.coerce.number().int().min(1).max(50).optional().default(20)
|
|
@@ -5649,7 +6351,8 @@ z.object({
|
|
|
5649
6351
|
"change_request.reviewed",
|
|
5650
6352
|
"change_request.merged",
|
|
5651
6353
|
"change_request.pending_review",
|
|
5652
|
-
"node.metadata_updated"
|
|
6354
|
+
"node.metadata_updated",
|
|
6355
|
+
"node.settings_updated"
|
|
5653
6356
|
]),
|
|
5654
6357
|
spaceId: z.string(),
|
|
5655
6358
|
actorId: z.string(),
|
|
@@ -5680,6 +6383,8 @@ const auditActionSchema = z.enum([
|
|
|
5680
6383
|
"asset.text_written",
|
|
5681
6384
|
"asset.text_marked_none",
|
|
5682
6385
|
"node.metadata_updated",
|
|
6386
|
+
"node.settings_updated",
|
|
6387
|
+
"node.agent_prompts_updated",
|
|
5683
6388
|
"node.purged"
|
|
5684
6389
|
]);
|
|
5685
6390
|
z.object({
|
|
@@ -6528,6 +7233,38 @@ const decodeText = (bytes, filePath) => {
|
|
|
6528
7233
|
if (text.includes("�")) throw new Error(`${filePath} is not valid UTF-8 text.`);
|
|
6529
7234
|
return text;
|
|
6530
7235
|
};
|
|
7236
|
+
const AIRAPP_MANIFEST_FILENAME = "airapp.json";
|
|
7237
|
+
const PYTHON_RUNTIME_MARKERS = ["requirements.txt", "pyproject.toml"];
|
|
7238
|
+
/**
|
|
7239
|
+
* Resolve only the lifecycle facts the package format needs to validate.
|
|
7240
|
+
* Keep the same explicit -> inferred -> default order as Busabase's runtime
|
|
7241
|
+
* descriptor without making busabase-package depend on busabase-core (core
|
|
7242
|
+
* already depends on this package).
|
|
7243
|
+
*/
|
|
7244
|
+
const resolveAirAppPackageLifecycle = (files, slug) => {
|
|
7245
|
+
const paths = new Set(files.map((file) => file.path));
|
|
7246
|
+
const manifestFile = files.find((file) => file.path === AIRAPP_MANIFEST_FILENAME);
|
|
7247
|
+
let manifest = {};
|
|
7248
|
+
if (manifestFile) {
|
|
7249
|
+
let parsed;
|
|
7250
|
+
try {
|
|
7251
|
+
parsed = JSON.parse(decodeText(manifestFile.bytes, AIRAPP_MANIFEST_FILENAME));
|
|
7252
|
+
} catch (error) {
|
|
7253
|
+
throw new Error(`AirApp "${slug}" has an invalid ${AIRAPP_MANIFEST_FILENAME}: ${error.message}`);
|
|
7254
|
+
}
|
|
7255
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`AirApp "${slug}" has an invalid ${AIRAPP_MANIFEST_FILENAME}: expected an object.`);
|
|
7256
|
+
manifest = parsed;
|
|
7257
|
+
}
|
|
7258
|
+
const declaredRuntime = manifest.runtime;
|
|
7259
|
+
if (declaredRuntime !== void 0 && declaredRuntime !== "node" && declaredRuntime !== "python") throw new Error(`AirApp "${slug}" declares unknown runtime ${JSON.stringify(declaredRuntime)} in ${AIRAPP_MANIFEST_FILENAME}.`);
|
|
7260
|
+
const runtime = declaredRuntime === "node" || declaredRuntime === "python" ? declaredRuntime : PYTHON_RUNTIME_MARKERS.some((path) => paths.has(path)) ? "python" : "node";
|
|
7261
|
+
const hasCustomCommand = (key) => typeof manifest[key] === "string" && manifest[key].trim().length > 0;
|
|
7262
|
+
return {
|
|
7263
|
+
runtime,
|
|
7264
|
+
usesDefaultInstall: !hasCustomCommand("install"),
|
|
7265
|
+
usesDefaultStart: !hasCustomCommand("start")
|
|
7266
|
+
};
|
|
7267
|
+
};
|
|
6531
7268
|
const parseJsonFile = (files, filePath) => {
|
|
6532
7269
|
const bytes = files.get(filePath);
|
|
6533
7270
|
if (!bytes) throw new Error(`Missing ${filePath}`);
|
|
@@ -6707,7 +7444,10 @@ const readDirNode = (files, dir, slug) => {
|
|
|
6707
7444
|
const ignore = readIgnoreFile(files, dir);
|
|
6708
7445
|
const entries = collectSubtree(files, dir).filter((entry) => entry.path !== "_node.json" && entry.path !== ".busabaseignore" && !ignore.ignores(entry.path));
|
|
6709
7446
|
assertNoReservedNames(entries.map((entry) => entry.path), `${meta.type} "${slug}"`);
|
|
6710
|
-
if (meta.type === "airapp"
|
|
7447
|
+
if (meta.type === "airapp") {
|
|
7448
|
+
const lifecycle = resolveAirAppPackageLifecycle(entries, slug);
|
|
7449
|
+
if (lifecycle.runtime === "node" && (lifecycle.usesDefaultInstall || lifecycle.usesDefaultStart) && !entries.some((entry) => entry.path === "package.json")) throw new Error(`AirApp "${slug}" has no package.json required by its default Node lifecycle${ignore.isEmpty ? "" : ` — check ${dir}${PACKAGE_AIRAPP_IGNORE}, which must never exclude it`}. Nothing was installed.`);
|
|
7450
|
+
}
|
|
6711
7451
|
return {
|
|
6712
7452
|
type: meta.type,
|
|
6713
7453
|
slug,
|
|
@@ -6904,6 +7644,8 @@ const validateTemplate = (tree) => {
|
|
|
6904
7644
|
else if (airapps.length > 0 || template?.airapp || template?.airapps?.length) errors.push(resolved.error);
|
|
6905
7645
|
for (const node of walkNodes(tree.nodes)) {
|
|
6906
7646
|
if (!isAirApp(node)) continue;
|
|
7647
|
+
const lifecycle = resolveAirAppPackageLifecycle(node.files, node.slug);
|
|
7648
|
+
if (lifecycle.runtime !== "node" || !lifecycle.usesDefaultStart) continue;
|
|
6907
7649
|
const manifestFile = node.files.find((file) => file.path === "package.json");
|
|
6908
7650
|
if (!manifestFile) continue;
|
|
6909
7651
|
let scripts;
|
|
@@ -6913,7 +7655,7 @@ const validateTemplate = (tree) => {
|
|
|
6913
7655
|
errors.push(`AirApp "${node.slug}": package.json is not valid JSON (${error.message}).`);
|
|
6914
7656
|
continue;
|
|
6915
7657
|
}
|
|
6916
|
-
if (typeof scripts?.dev !== "string") errors.push(`AirApp "${node.slug}": package.json has no \`dev\` script.
|
|
7658
|
+
if (typeof scripts?.dev !== "string") errors.push(`AirApp "${node.slug}": package.json has no \`dev\` script. This AirApp uses the default Node start command \`npm run dev\`, so without it the app installs and then never boots.`);
|
|
6917
7659
|
}
|
|
6918
7660
|
if (airapps.length === 0) warnings.push("No AirApp — this template installs data only. Users will see tables, not an app.");
|
|
6919
7661
|
if (template?.requires?.airapp && airapps.length === 0) errors.push("template.requires.airapp is true, but content/ contains no AirApp.");
|
|
@@ -7418,6 +8160,7 @@ const applyInstall = async (client, plan, options) => {
|
|
|
7418
8160
|
pendingChangeRequests: 0,
|
|
7419
8161
|
warnings: [...plan.warnings]
|
|
7420
8162
|
};
|
|
8163
|
+
const createdRootNodeIds = [];
|
|
7421
8164
|
const fieldIds = /* @__PURE__ */ new Map();
|
|
7422
8165
|
const bases = [];
|
|
7423
8166
|
const progress = options.onProgress ?? (() => {});
|
|
@@ -7437,6 +8180,7 @@ const applyInstall = async (client, plan, options) => {
|
|
|
7437
8180
|
metadata: app?.rootMetadata
|
|
7438
8181
|
});
|
|
7439
8182
|
state.created.folders++;
|
|
8183
|
+
createdRootNodeIds.push(state.targetFolderNodeId);
|
|
7440
8184
|
}
|
|
7441
8185
|
await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds, app);
|
|
7442
8186
|
if (app?.rootSkill) {
|
|
@@ -7528,8 +8272,16 @@ const applyInstall = async (client, plan, options) => {
|
|
|
7528
8272
|
const created = Object.entries(state.created).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`).join(", ");
|
|
7529
8273
|
const partial = created ? `Created before failure: ${created}. Target folder: ${plan.targetFolderSlug}.` : "No package resources were created.";
|
|
7530
8274
|
const pending = state.pendingChangeRequests ? ` ${state.pendingChangeRequests} ChangeRequest(s) may still be pending.` : "";
|
|
8275
|
+
const rollback = await rollbackPartialInstall(client, {
|
|
8276
|
+
createdRootNodeIds,
|
|
8277
|
+
enabled: options.rollbackOnFailure !== false,
|
|
8278
|
+
installedIntoExistingFolder: Boolean(plan.existingTargetFolderNodeId),
|
|
8279
|
+
anythingCreated: Object.values(state.created).some((count) => count > 0),
|
|
8280
|
+
submittedBy: options.submittedBy ?? "busabase-cli install",
|
|
8281
|
+
progress
|
|
8282
|
+
});
|
|
7531
8283
|
const reason = error instanceof Error ? error.message : String(error);
|
|
7532
|
-
const message = `Install stopped during ${phase}. ${partial}${pending} Cause: ${reason}`;
|
|
8284
|
+
const message = `Install stopped during ${phase}. ${partial}${pending}${rollback} Cause: ${reason}`;
|
|
7533
8285
|
const details = {
|
|
7534
8286
|
phase,
|
|
7535
8287
|
targetFolderSlug: plan.targetFolderSlug,
|
|
@@ -7544,6 +8296,47 @@ const applyInstall = async (client, plan, options) => {
|
|
|
7544
8296
|
}
|
|
7545
8297
|
};
|
|
7546
8298
|
/**
|
|
8299
|
+
* Undo a failed install by moving what it created to Trash.
|
|
8300
|
+
*
|
|
8301
|
+
* Deleting a node takes its whole subtree — Bases, their records, nested
|
|
8302
|
+
* folders — so undoing an install is a matter of deleting the roots it made,
|
|
8303
|
+
* not walking everything it touched. It moves them to Trash rather than
|
|
8304
|
+
* purging: purge refuses a subtree containing a Base (which every real package
|
|
8305
|
+
* has), and Trash is reversible, which matters for an automatic action taken
|
|
8306
|
+
* while something has already gone wrong.
|
|
8307
|
+
*
|
|
8308
|
+
* Only ever deletes a target folder THIS install created. Installing into a
|
|
8309
|
+
* folder the user already had is reported instead of guessed at: the new nodes
|
|
8310
|
+
* sit among the user's own, and there is no reliable way here to tell which
|
|
8311
|
+
* were ours. Deleting the wrong thing is far worse than leaving a mess.
|
|
8312
|
+
*
|
|
8313
|
+
* Best-effort: a rollback runs after a failure, so it can hit the same broken
|
|
8314
|
+
* condition. Whatever happens is reported, never thrown — the install error is
|
|
8315
|
+
* the one the caller needs.
|
|
8316
|
+
*/
|
|
8317
|
+
const rollbackPartialInstall = async (client, args) => {
|
|
8318
|
+
if (!args.anythingCreated) return "";
|
|
8319
|
+
if (!args.enabled) return " Left in place (--no-rollback).";
|
|
8320
|
+
if (args.createdRootNodeIds.length === 0) return args.installedIntoExistingFolder ? " Not rolled back: this installed into a folder that already existed, so its new items sit among yours and cannot be told apart safely — remove them by hand." : " Nothing to roll back.";
|
|
8321
|
+
args.progress("Install failed — moving what it created to Trash…");
|
|
8322
|
+
const failures = [];
|
|
8323
|
+
for (const nodeId of args.createdRootNodeIds) try {
|
|
8324
|
+
await client.nodes.createChangeRequest({
|
|
8325
|
+
operations: [{
|
|
8326
|
+
kind: "delete",
|
|
8327
|
+
nodeId
|
|
8328
|
+
}],
|
|
8329
|
+
message: "Roll back a failed install",
|
|
8330
|
+
submittedBy: args.submittedBy,
|
|
8331
|
+
autoMerge: true
|
|
8332
|
+
});
|
|
8333
|
+
} catch (rollbackError) {
|
|
8334
|
+
failures.push(`${nodeId} (${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)})`);
|
|
8335
|
+
}
|
|
8336
|
+
if (failures.length === 0) return " Rolled back: everything it created was moved to Trash.";
|
|
8337
|
+
return ` Rollback incomplete — could not remove ${failures.join("; ")}. Remove them by hand.`;
|
|
8338
|
+
};
|
|
8339
|
+
/**
|
|
7547
8340
|
* A property rather than a subclass: the whole point of the rethrow above is to
|
|
7548
8341
|
* keep the original error's prototype (an `ORPCError`'s `code`/`status`/`data`
|
|
7549
8342
|
* are what make a permission or quota failure actionable), so the diagnostics
|
|
@@ -8052,21 +8845,26 @@ const findSourceNode = (nodes, slugOrId) => {
|
|
|
8052
8845
|
const collectPackageTree = async (client, root, options) => {
|
|
8053
8846
|
const children = root.type === "folder" ? root.children ?? [] : [root];
|
|
8054
8847
|
const { skillSource, rest } = partitionTemplateSkill(children);
|
|
8055
|
-
const
|
|
8848
|
+
const collectOptions = {
|
|
8849
|
+
...options,
|
|
8850
|
+
baseResourceKeys: collectBaseResourceKeys(children)
|
|
8851
|
+
};
|
|
8852
|
+
const nodes = await collectNodes(client, rest, collectOptions);
|
|
8056
8853
|
const tree = {
|
|
8057
8854
|
manifest: {
|
|
8058
8855
|
...options.manifest,
|
|
8059
8856
|
format: PACKAGE_FORMAT
|
|
8060
8857
|
},
|
|
8061
|
-
nodes
|
|
8062
|
-
...skillSource ? { rootSkill: await collectRootSkill(client, skillSource,
|
|
8063
|
-
assets: await collectDocAssets(client, nodes,
|
|
8858
|
+
nodes,
|
|
8859
|
+
...skillSource ? { rootSkill: await collectRootSkill(client, skillSource, collectOptions) } : {},
|
|
8860
|
+
assets: await collectDocAssets(client, nodes, collectOptions)
|
|
8064
8861
|
};
|
|
8065
|
-
assertSelfContained(tree,
|
|
8862
|
+
assertSelfContained(tree, collectOptions);
|
|
8066
8863
|
return tree;
|
|
8067
8864
|
};
|
|
8068
8865
|
/**
|
|
8069
|
-
* Undo the
|
|
8866
|
+
* Undo the slug INSTALL chose for one node, restoring the one the package
|
|
8867
|
+
* declared.
|
|
8070
8868
|
*
|
|
8071
8869
|
* Install prefixes a template's Base slugs with the target folder (`contacts` →
|
|
8072
8870
|
* `kelly-crm-contacts`) and records the original as the node's `resourceKey`.
|
|
@@ -8076,53 +8874,48 @@ const collectPackageTree = async (client, root, options) => {
|
|
|
8076
8874
|
* which is the property that lets a user install a template, adjust it, and
|
|
8077
8875
|
* publish the result without the names drifting a little further each time.
|
|
8078
8876
|
*
|
|
8079
|
-
* The root
|
|
8080
|
-
*
|
|
8081
|
-
|
|
8082
|
-
|
|
8083
|
-
|
|
8084
|
-
|
|
8877
|
+
* The app-root stamp is skipped: `resourceKey` is the reserved `app-root`
|
|
8878
|
+
* marker there, not a slug anything should be named after.
|
|
8879
|
+
*
|
|
8880
|
+
* Applied to the ONE node `source` describes — never through a space-wide
|
|
8881
|
+
* slug→slug rename table. An earlier version built exactly that table (keyed
|
|
8882
|
+
* by each node's *current* slug) and applied it by re-matching slugs across
|
|
8883
|
+
* the whole exported tree; that broke the moment two unrelated nodes shared a
|
|
8884
|
+
* slug anywhere in the space — observed live: an AirApp installed into a
|
|
8885
|
+
* folder auto-named after it end up BOTH called "social-media" pre-restore
|
|
8886
|
+
* (the folder, and the app before its own stamp renamed it back to "airapp"),
|
|
8887
|
+
* so the app's rename bled onto its own parent folder on export.
|
|
8888
|
+
*/
|
|
8889
|
+
const restoredNodeSlug = (source) => {
|
|
8890
|
+
const key = source.metadata?.resourceKey;
|
|
8891
|
+
if (typeof key === "string" && key !== "app-root" && key !== source.slug && NODE_SLUG_PATTERN.test(key)) return key;
|
|
8892
|
+
return source.slug;
|
|
8893
|
+
};
|
|
8894
|
+
/**
|
|
8895
|
+
* The other half of slug restoration: a relation field's `targetBaseSlug` (set
|
|
8896
|
+
* from `context.baseSlugById` in {@link toPackageField}, itself built by
|
|
8897
|
+
* {@link buildIndexes} from each Base's raw current server slug) must resolve
|
|
8898
|
+
* to whatever THAT target Base's own node slug gets restored to, or the
|
|
8899
|
+
* package would ship a relation pointing at a name it never actually wrote.
|
|
8900
|
+
*
|
|
8901
|
+
* Keyed by Base id (globally unique, unlike slug — see {@link restoredNodeSlug}'s
|
|
8902
|
+
* doc) so `buildIndexes` can look a target base's restored slug up without
|
|
8903
|
+
* re-deriving it from a name that might collide with something else in the
|
|
8904
|
+
* space.
|
|
8905
|
+
*/
|
|
8906
|
+
const collectBaseResourceKeys = (sources) => {
|
|
8907
|
+
const byBaseId = /* @__PURE__ */ new Map();
|
|
8908
|
+
const walk = (list) => {
|
|
8085
8909
|
for (const source of list) {
|
|
8086
|
-
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
}
|
|
8090
|
-
};
|
|
8091
|
-
index(sources);
|
|
8092
|
-
if (renames.size === 0) return [...nodes];
|
|
8093
|
-
const rewriteField = (field) => {
|
|
8094
|
-
const target = field.options.targetBaseSlug;
|
|
8095
|
-
if (!target) return field;
|
|
8096
|
-
const renamed = renames.get(target);
|
|
8097
|
-
return renamed ? {
|
|
8098
|
-
...field,
|
|
8099
|
-
options: {
|
|
8100
|
-
...field.options,
|
|
8101
|
-
targetBaseSlug: renamed
|
|
8102
|
-
}
|
|
8103
|
-
} : field;
|
|
8104
|
-
};
|
|
8105
|
-
const rewrite = (node) => {
|
|
8106
|
-
const slug = renames.get(node.slug) ?? node.slug;
|
|
8107
|
-
if (node.type === "folder") return {
|
|
8108
|
-
...node,
|
|
8109
|
-
slug,
|
|
8110
|
-
children: node.children.map(rewrite)
|
|
8111
|
-
};
|
|
8112
|
-
if (node.type === "base") return {
|
|
8113
|
-
...node,
|
|
8114
|
-
slug,
|
|
8115
|
-
base: {
|
|
8116
|
-
...node.base,
|
|
8117
|
-
fields: node.base.fields.map(rewriteField)
|
|
8910
|
+
if (source.type === "base") {
|
|
8911
|
+
const restored = restoredNodeSlug(source);
|
|
8912
|
+
if (restored !== source.slug) byBaseId.set(source.baseId ?? source.slug, restored);
|
|
8118
8913
|
}
|
|
8119
|
-
|
|
8120
|
-
|
|
8121
|
-
...node,
|
|
8122
|
-
slug
|
|
8123
|
-
};
|
|
8914
|
+
if (source.children) walk(source.children);
|
|
8915
|
+
}
|
|
8124
8916
|
};
|
|
8125
|
-
|
|
8917
|
+
walk(sources);
|
|
8918
|
+
return byBaseId;
|
|
8126
8919
|
};
|
|
8127
8920
|
/**
|
|
8128
8921
|
* Separate the app's manual from its resources.
|
|
@@ -8200,7 +8993,7 @@ const collectNodes = async (client, sources, options) => {
|
|
|
8200
8993
|
};
|
|
8201
8994
|
const collectNode = async (client, source, options) => {
|
|
8202
8995
|
const common = {
|
|
8203
|
-
slug: source
|
|
8996
|
+
slug: restoredNodeSlug(source),
|
|
8204
8997
|
name: source.name,
|
|
8205
8998
|
description: source.description ?? "",
|
|
8206
8999
|
position: source.position
|
|
@@ -8237,12 +9030,14 @@ const collectBase = async (client, source, common, options) => {
|
|
|
8237
9030
|
const baseId = source.baseId ?? source.slug;
|
|
8238
9031
|
const base = await client.bases.get({ baseId });
|
|
8239
9032
|
if (!base) throw new Error(`Base node "${source.slug}" has no Base behind it (looked up ${baseId}). The space may be mid-migration; re-run export.`);
|
|
8240
|
-
const
|
|
8241
|
-
const
|
|
9033
|
+
const allViews = await client.bases.listViews({ baseId: base.id });
|
|
9034
|
+
const views = allViews.filter((view) => view.slug.length > 0);
|
|
9035
|
+
if (views.length !== allViews.length) options.warn(`Base "${common.slug}" has ${allViews.length - views.length} view(s) with no slug — they were dropped (predates the slug requirement; rename it in the UI to keep it).`);
|
|
9036
|
+
const { baseSlugById, fieldSlugById } = await buildIndexes(client, options.baseResourceKeys ?? /* @__PURE__ */ new Map());
|
|
8242
9037
|
const fields = [...base.fields].sort((a, b) => a.position - b.position).map((field) => toPackageField(field, {
|
|
8243
9038
|
fieldSlugById,
|
|
8244
9039
|
baseSlugById,
|
|
8245
|
-
baseSlug:
|
|
9040
|
+
baseSlug: common.slug,
|
|
8246
9041
|
warn: options.warn
|
|
8247
9042
|
}));
|
|
8248
9043
|
const records = await collectRecords(client, base.id, base.fields, options);
|
|
@@ -8272,12 +9067,12 @@ const collectBase = async (client, source, common, options) => {
|
|
|
8272
9067
|
*
|
|
8273
9068
|
* `bases.list()` already hydrates each base's fields, so one call builds both.
|
|
8274
9069
|
*/
|
|
8275
|
-
const buildIndexes = async (client) => {
|
|
9070
|
+
const buildIndexes = async (client, baseResourceKeys) => {
|
|
8276
9071
|
const bases = await client.bases.list({});
|
|
8277
9072
|
const baseSlugById = /* @__PURE__ */ new Map();
|
|
8278
9073
|
const fieldSlugById = /* @__PURE__ */ new Map();
|
|
8279
9074
|
for (const base of bases) {
|
|
8280
|
-
baseSlugById.set(base.id, base.slug);
|
|
9075
|
+
baseSlugById.set(base.id, baseResourceKeys.get(base.id) ?? base.slug);
|
|
8281
9076
|
for (const field of base.fields ?? []) fieldSlugById.set(field.id, field.slug);
|
|
8282
9077
|
}
|
|
8283
9078
|
return {
|
|
@@ -8464,12 +9259,18 @@ const assertSelfContained = (tree, options) => {
|
|
|
8464
9259
|
const packagedRecordKeys = new Set(baseNodes.flatMap((node) => node.type === "base" ? node.records.map((record) => record.key) : []));
|
|
8465
9260
|
for (const node of baseNodes) {
|
|
8466
9261
|
if (node.type !== "base") continue;
|
|
9262
|
+
for (const field of node.base.fields) {
|
|
9263
|
+
if (field.type !== "attachment" || !field.required) continue;
|
|
9264
|
+
field.required = false;
|
|
9265
|
+
options.warn(`Base "${node.slug}": attachment field "${field.slug}" is required in the source, but ${PACKAGE_FORMAT} never carries attachment values — the field would be empty in every installed record and the install would fail. It is marked NOT required in this package.`);
|
|
9266
|
+
}
|
|
8467
9267
|
for (const field of node.base.fields) {
|
|
8468
9268
|
const targetBaseSlug = field.options.targetBaseSlug;
|
|
8469
9269
|
if (field.type !== "relation" || !targetBaseSlug) continue;
|
|
8470
9270
|
if (!packagedBaseSlugs.has(targetBaseSlug)) throw new Error(`Field "${node.slug}.${field.slug}" relates to Base "${targetBaseSlug}", which is outside the subtree being exported. A package must be self-contained — export a subtree that includes both Bases, or remove the field.`);
|
|
8471
9271
|
}
|
|
8472
9272
|
let dangling = 0;
|
|
9273
|
+
const emptiedRequired = /* @__PURE__ */ new Map();
|
|
8473
9274
|
for (const record of node.records) for (const field of node.base.fields) {
|
|
8474
9275
|
if (field.type !== "relation") continue;
|
|
8475
9276
|
const value = record.fields[field.slug];
|
|
@@ -8477,9 +9278,18 @@ const assertSelfContained = (tree, options) => {
|
|
|
8477
9278
|
if (keys.length === 0) continue;
|
|
8478
9279
|
const kept = keys.filter((key) => typeof key !== "string" || packagedRecordKeys.has(key));
|
|
8479
9280
|
dangling += keys.length - kept.length;
|
|
8480
|
-
if (kept.length !== keys.length)
|
|
9281
|
+
if (kept.length !== keys.length) {
|
|
9282
|
+
record.fields[field.slug] = kept;
|
|
9283
|
+
if (kept.length === 0 && field.required) emptiedRequired.set(field.slug, (emptiedRequired.get(field.slug) ?? 0) + 1);
|
|
9284
|
+
}
|
|
8481
9285
|
}
|
|
8482
9286
|
if (dangling > 0) options.warn(`Base "${node.slug}" has ${dangling} relation value(s) pointing at records outside the exported subtree — they were dropped.`);
|
|
9287
|
+
for (const [slug, count] of emptiedRequired) {
|
|
9288
|
+
const field = node.base.fields.find((candidate) => candidate.slug === slug);
|
|
9289
|
+
if (!field) continue;
|
|
9290
|
+
field.required = false;
|
|
9291
|
+
options.warn(`Base "${node.slug}": relation field "${slug}" is required in the source, but ${count} record(s) point only at records outside the export (typically archived ones, which are never exported). It is marked NOT required in this package so the package can be installed; those records install with that field empty.`);
|
|
9292
|
+
}
|
|
8483
9293
|
}
|
|
8484
9294
|
};
|
|
8485
9295
|
//#endregion
|
|
@@ -8818,15 +9628,59 @@ const renderNodeTree = (nodes, indent) => {
|
|
|
8818
9628
|
return lines;
|
|
8819
9629
|
};
|
|
8820
9630
|
/**
|
|
8821
|
-
*
|
|
8822
|
-
*
|
|
9631
|
+
* A required field the package cannot satisfy for at least one record. The
|
|
9632
|
+
* server rejects these — but only once it is already writing records, deep into
|
|
9633
|
+
* pass 4 of 5, having created everything before them.
|
|
9634
|
+
*
|
|
9635
|
+
* Real occurrence: a package whose `content-artifacts` Base declared a REQUIRED
|
|
9636
|
+
* attachment field (`busabase-package@1` never carries attachment bytes, so the
|
|
9637
|
+
* column is empty in every record) failed with `Invalid field value: 文件 is
|
|
9638
|
+
* required` **after creating 2,903 records**. `export` no longer produces such
|
|
9639
|
+
* packages, but one written by hand — or exported before that fix — still can,
|
|
9640
|
+
* and `install` should say so before touching the target.
|
|
8823
9641
|
*/
|
|
8824
|
-
const
|
|
9642
|
+
const findUnsatisfiableRequiredFields = (plan) => {
|
|
9643
|
+
const problems = [];
|
|
9644
|
+
for (const node of walkNodes(plan.tree.nodes)) {
|
|
9645
|
+
if (node.type !== "base") continue;
|
|
9646
|
+
for (const field of node.base.fields) {
|
|
9647
|
+
if (!field.required) continue;
|
|
9648
|
+
const missing = node.records.filter((record) => {
|
|
9649
|
+
const value = record.fields[field.slug];
|
|
9650
|
+
if (value === null || value === void 0 || value === "") return true;
|
|
9651
|
+
return Array.isArray(value) && value.length === 0;
|
|
9652
|
+
}).length;
|
|
9653
|
+
if (missing === 0) continue;
|
|
9654
|
+
problems.push(` • Base "${node.slug}", field "${field.slug}" (${field.type}) is required, but ${missing} of ${node.records.length} record(s) carry no value for it.`);
|
|
9655
|
+
}
|
|
9656
|
+
}
|
|
9657
|
+
return problems;
|
|
9658
|
+
};
|
|
9659
|
+
/**
|
|
9660
|
+
* Everything that would stop this plan from applying, as messages — so a dry
|
|
9661
|
+
* run can REPORT them and a real install can throw on them, from one source of
|
|
9662
|
+
* truth. Previously the checks lived only inside `assertPlanIsApplicable`,
|
|
9663
|
+
* which `--dry-run` returned before ever reaching: a dry run therefore printed
|
|
9664
|
+
* a clean-looking plan for a package that could not install at all.
|
|
9665
|
+
*/
|
|
9666
|
+
const findPlanBlockers = (plan, autoMerge) => {
|
|
9667
|
+
const blockers = [];
|
|
8825
9668
|
if (plan.collisions.some((collision) => !collision.renamedTo)) {
|
|
8826
9669
|
const list = plan.collisions.filter((collision) => !collision.renamedTo).map((collision) => ` • ${collision.kind} "${collision.slug}" (${collision.path})`).join("\n");
|
|
8827
|
-
|
|
9670
|
+
blockers.push(`Install would collide with content that already exists:\n${list}\n\nNothing was created. Re-run with --rename to install the colliding items under suffixed slugs (e.g. "-2"), or pick a different --into-folder.`);
|
|
8828
9671
|
}
|
|
8829
|
-
if (plan.requiresAutoMerge && !autoMerge)
|
|
9672
|
+
if (plan.requiresAutoMerge && !autoMerge) blockers.push("This package's records carry relation values, and a relation stores the ids of the records it points at — which only exist once the records are merged. Installing review-first would leave every relation empty.\n\nRe-run with --auto-merge to install it (this trusts the package author: records are created immediately instead of landing as change requests you review first).");
|
|
9673
|
+
const unsatisfiable = findUnsatisfiableRequiredFields(plan);
|
|
9674
|
+
if (unsatisfiable.length > 0) blockers.push(`This package cannot satisfy its own required fields:\n${unsatisfiable.join("\n")}\n\nThe server rejects these while writing records, partway through the install. Re-export the package (export relaxes fields it cannot fill), or edit the Base definitions in the package to make them optional.`);
|
|
9675
|
+
return blockers;
|
|
9676
|
+
};
|
|
9677
|
+
/**
|
|
9678
|
+
* §4 + §12: without `--auto-merge` an unmerged base has no field ids to patch and no
|
|
9679
|
+
* records to link, so passes 2-5 cannot run. Fail fast and say exactly why.
|
|
9680
|
+
*/
|
|
9681
|
+
const assertPlanIsApplicable = (plan, autoMerge) => {
|
|
9682
|
+
const blockers = findPlanBlockers(plan, autoMerge);
|
|
9683
|
+
if (blockers.length > 0) throw new Error(blockers.join("\n\n"));
|
|
8830
9684
|
};
|
|
8831
9685
|
//#endregion
|
|
8832
9686
|
//#region ../../packages/busabase-package/src/discover.ts
|
|
@@ -9464,9 +10318,12 @@ const runInstall = async (client, repoUrl, options) => {
|
|
|
9464
10318
|
installSampleRecords: !options.noSampleRecords
|
|
9465
10319
|
});
|
|
9466
10320
|
if (options.dryRun) {
|
|
9467
|
-
const
|
|
10321
|
+
const blockers = findPlanBlockers(plan, Boolean(options.autoMerge));
|
|
10322
|
+
const verdict = blockers.length === 0 ? "Dry run — nothing was created. No blockers found." : `Dry run — nothing was created.\n\nThis package would NOT install as-is:\n\n${blockers.join("\n\n")}`;
|
|
10323
|
+
const report = `${renderPlan(plan)}\n\n${verdict}`;
|
|
9468
10324
|
return options.json ? {
|
|
9469
10325
|
dryRun: true,
|
|
10326
|
+
blockers,
|
|
9470
10327
|
...toPlanSummary(plan)
|
|
9471
10328
|
} : report;
|
|
9472
10329
|
}
|
|
@@ -9477,6 +10334,7 @@ const runInstall = async (client, repoUrl, options) => {
|
|
|
9477
10334
|
onProgress: reportProgress,
|
|
9478
10335
|
serverUrl: options.serverUrl,
|
|
9479
10336
|
installSampleRecords: !options.noSampleRecords,
|
|
10337
|
+
rollbackOnFailure: !options.noRollback,
|
|
9480
10338
|
source: {
|
|
9481
10339
|
...github ? {
|
|
9482
10340
|
repo: `${github.owner}/${github.repo}`,
|
|
@@ -9788,67 +10646,6 @@ async function paginateAll(options) {
|
|
|
9788
10646
|
return written;
|
|
9789
10647
|
}
|
|
9790
10648
|
//#endregion
|
|
9791
|
-
//#region src/retry.ts
|
|
9792
|
-
const BASE_DELAY_MS = 500;
|
|
9793
|
-
const MAX_DELAY_MS = 2e4;
|
|
9794
|
-
const READ_METHODS = /* @__PURE__ */ new Set([
|
|
9795
|
-
"GET",
|
|
9796
|
-
"HEAD",
|
|
9797
|
-
"OPTIONS"
|
|
9798
|
-
]);
|
|
9799
|
-
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9800
|
-
const isNetworkError = (error) => error instanceof TypeError || /fetch failed|econnreset|econnrefused|etimedout|socket hang up/i.test(error instanceof Error ? error.message : String(error));
|
|
9801
|
-
/** `Retry-After` is either delay-seconds or an HTTP-date; both are legal. */
|
|
9802
|
-
function parseRetryAfter(header, now = Date.now()) {
|
|
9803
|
-
if (!header) return void 0;
|
|
9804
|
-
const seconds = Number(header.trim());
|
|
9805
|
-
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, MAX_DELAY_MS);
|
|
9806
|
-
const at = Date.parse(header);
|
|
9807
|
-
if (Number.isNaN(at)) return void 0;
|
|
9808
|
-
return Math.min(Math.max(at - now, 0), MAX_DELAY_MS);
|
|
9809
|
-
}
|
|
9810
|
-
/** Whether this outcome is worth another attempt for a request using `method`. */
|
|
9811
|
-
function shouldRetry(method, status) {
|
|
9812
|
-
if (status === 429) return true;
|
|
9813
|
-
if (!READ_METHODS.has(method.toUpperCase())) return false;
|
|
9814
|
-
return status === void 0 || status >= 500;
|
|
9815
|
-
}
|
|
9816
|
-
/**
|
|
9817
|
-
* Wrap a fetch so transient failures are retried with exponential backoff.
|
|
9818
|
-
* Returns `base` unchanged when `retries` is 0, so the no-retry path adds no
|
|
9819
|
-
* wrapper and no behaviour.
|
|
9820
|
-
*/
|
|
9821
|
-
function withRetry(base, options) {
|
|
9822
|
-
const { retries } = options;
|
|
9823
|
-
if (retries <= 0) return base;
|
|
9824
|
-
const sleep = options.sleep ?? defaultSleep;
|
|
9825
|
-
const notify = options.notify ?? ((message) => console.error(message));
|
|
9826
|
-
const jitter = options.jitter ?? Math.random;
|
|
9827
|
-
return (async (input, init) => {
|
|
9828
|
-
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
9829
|
-
for (let attempt = 0;; attempt += 1) {
|
|
9830
|
-
let response;
|
|
9831
|
-
let failure;
|
|
9832
|
-
try {
|
|
9833
|
-
response = await base(input, init);
|
|
9834
|
-
} catch (error) {
|
|
9835
|
-
if (!isNetworkError(error)) throw error;
|
|
9836
|
-
failure = error;
|
|
9837
|
-
}
|
|
9838
|
-
const status = response?.status;
|
|
9839
|
-
const last = attempt >= retries;
|
|
9840
|
-
if (!shouldRetry(method, status) || last) {
|
|
9841
|
-
if (response) return response;
|
|
9842
|
-
throw failure;
|
|
9843
|
-
}
|
|
9844
|
-
const backoff = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
|
|
9845
|
-
const delay = parseRetryAfter(response?.headers.get("retry-after") ?? null) ?? Math.round(backoff * (.5 + jitter() * .5));
|
|
9846
|
-
notify(`[busabase-cli] ${status ? `HTTP ${status}` : "connection failed"} — retrying in ${Math.round(delay / 1e3)}s (attempt ${attempt + 2} of ${retries + 1})`);
|
|
9847
|
-
await sleep(delay);
|
|
9848
|
-
}
|
|
9849
|
-
});
|
|
9850
|
-
}
|
|
9851
|
-
//#endregion
|
|
9852
10649
|
//#region src/task-command.ts
|
|
9853
10650
|
const collect = (value, previous = []) => [...previous, value];
|
|
9854
10651
|
/** Range-checks before the request goes out, so a cap surfaces as a usage error. */
|
|
@@ -10079,14 +10876,22 @@ function resolveConfig(opts) {
|
|
|
10079
10876
|
}));
|
|
10080
10877
|
const file = loadDotEnvFile();
|
|
10081
10878
|
const baseUrl = opts.baseUrl ?? process.env.BUSABASE_BASE_URL ?? file.BUSABASE_BASE_URL ?? DEFAULT_BASE_URL;
|
|
10879
|
+
const retries = opts.retries ?? DEFAULT_RETRIES;
|
|
10082
10880
|
return {
|
|
10083
10881
|
baseUrl,
|
|
10882
|
+
/**
|
|
10883
|
+
* The number itself, not just the `fetch` built from it. `backup` retries
|
|
10884
|
+
* at a level this wrapper cannot reach — see `FullExportOptions.retries` —
|
|
10885
|
+
* and has to honour the same flag, or `--retries` silently means nothing
|
|
10886
|
+
* for the one command whose failures cost hours.
|
|
10887
|
+
*/
|
|
10888
|
+
retries,
|
|
10084
10889
|
webUrl: process.env.BUSABASE_WEB_URL ?? file.BUSABASE_WEB_URL ?? baseUrl,
|
|
10085
10890
|
apiKey: opts.apiKey ?? process.env.BUSABASE_API_KEY ?? file.BUSABASE_API_KEY,
|
|
10086
10891
|
spaceId: opts.spaceId ?? process.env.BUSABASE_SPACE_ID ?? file.BUSABASE_SPACE_ID,
|
|
10087
10892
|
sourceChannel: "cli",
|
|
10088
10893
|
output: opts.output ?? configuredOutput() ?? "text",
|
|
10089
|
-
fetch: withRetry(fetch, { retries
|
|
10894
|
+
fetch: withRetry(fetch, { retries })
|
|
10090
10895
|
};
|
|
10091
10896
|
}
|
|
10092
10897
|
/** Enough to ride out a rate limiter or a proxy blip; not enough to hide a real outage. */
|
|
@@ -10337,6 +11142,24 @@ async function putTextCommand(client, opts) {
|
|
|
10337
11142
|
});
|
|
10338
11143
|
}
|
|
10339
11144
|
/** Wrap a leaf-command handler: resolve config, build the client, render the result. */
|
|
11145
|
+
/**
|
|
11146
|
+
* Is this a 404 — either "this server has no such route" or "no such node"?
|
|
11147
|
+
*
|
|
11148
|
+
* Duck-typed rather than an `instanceof ORPCError` check, because this package
|
|
11149
|
+
* deliberately depends on no `@orpc/*` package; the SDK builds the error and
|
|
11150
|
+
* only its `status` is needed here.
|
|
11151
|
+
*
|
|
11152
|
+
* Not distinguishing the two 404s is safe for the one thing this gates: a
|
|
11153
|
+
* version fallback whose other branch targets the SAME node. An old server
|
|
11154
|
+
* answers 404 because the route is missing and the fallback then succeeds; a
|
|
11155
|
+
* current server answers 404 because the node does not exist, the fallback
|
|
11156
|
+
* asks about the same missing node and 404s too, and the caller still sees a
|
|
11157
|
+
* "not found" error. The cost of guessing wrong is one extra request, not a
|
|
11158
|
+
* write landing somewhere unintended.
|
|
11159
|
+
*/
|
|
11160
|
+
function isNotFound(error) {
|
|
11161
|
+
return typeof error === "object" && error !== null && "status" in error && error.status === 404;
|
|
11162
|
+
}
|
|
10340
11163
|
function runAction(state, handler) {
|
|
10341
11164
|
return async (_opts, cmd) => {
|
|
10342
11165
|
const opts = cmd.optsWithGlobals();
|
|
@@ -10901,6 +11724,48 @@ Examples:
|
|
|
10901
11724
|
message: opts.message,
|
|
10902
11725
|
submittedBy: opts.submittedBy
|
|
10903
11726
|
})));
|
|
11727
|
+
addGlobalFlags(nodes.command("set-agent-prompts")).description("Set a node's custom scenario prompts, shown in the Agent Prompts dialog instead of the node type's generic ones. Capability prompts (what the node type can actually do) are unaffected.").requiredOption("--node-id <id>", "node id to customize").requiredOption("--file <path>", "JSON file: an array of { key, intent?, label, body } — see the help text below").addHelpText("after", `
|
|
11728
|
+
Each entry: { key: string (unique per node), intent?: "read-only" | "change",
|
|
11729
|
+
label: string | { <locale>: string }, body: string | { <locale>: string } }.
|
|
11730
|
+
Locale keys (when using the object form): en, zh-CN, zh-TW, ja, ko, de, fr, es,
|
|
11731
|
+
pt — anything else fails validation as "Invalid input" on that field.
|
|
11732
|
+
"body" may contain a literal "{target}" placeholder, substituted at render time
|
|
11733
|
+
with the same node/space description every built-in prompt uses. At most 50
|
|
11734
|
+
entries per node, 80 characters per localized label, 8 KiB per localized body.
|
|
11735
|
+
|
|
11736
|
+
The file is validated against this exact schema BEFORE any network request —
|
|
11737
|
+
a malformed file (duplicate key, an over-limit label/body, an unrecognized
|
|
11738
|
+
locale) is reported entry-by-entry and nothing is written. \`--file\`'s
|
|
11739
|
+
contents ARE the array to store; this command does not reshape it.
|
|
11740
|
+
|
|
11741
|
+
Examples:
|
|
11742
|
+
busabase-cli nodes set-agent-prompts --node-id nod_123 --file prompts.json`).action(runAction(state, async (client, opts) => {
|
|
11743
|
+
const filePath = opts.file;
|
|
11744
|
+
let raw;
|
|
11745
|
+
try {
|
|
11746
|
+
raw = JSON.parse(readFileSync(filePath, "utf8"));
|
|
11747
|
+
} catch (error) {
|
|
11748
|
+
throw new CliOutcomeError("VALIDATION", `Could not read/parse ${filePath} as JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
11749
|
+
}
|
|
11750
|
+
const parsed = customAgentPromptsSchema.safeParse(raw);
|
|
11751
|
+
if (!parsed.success) {
|
|
11752
|
+
const detail = formatIssues(parsed.error.issues);
|
|
11753
|
+
throw new CliOutcomeError("VALIDATION", `Invalid agent prompts in ${filePath}${detail ? ` — ${detail}` : ""}`);
|
|
11754
|
+
}
|
|
11755
|
+
const nodeId = opts.nodeId;
|
|
11756
|
+
try {
|
|
11757
|
+
return await client.nodes.updateAgentPrompts({
|
|
11758
|
+
nodeId,
|
|
11759
|
+
agentPrompts: parsed.data
|
|
11760
|
+
});
|
|
11761
|
+
} catch (error) {
|
|
11762
|
+
if (!isNotFound(error)) throw error;
|
|
11763
|
+
return await client.nodes.updateMetadata({
|
|
11764
|
+
nodeId,
|
|
11765
|
+
metadata: { agentPrompts: parsed.data }
|
|
11766
|
+
});
|
|
11767
|
+
}
|
|
11768
|
+
}));
|
|
10904
11769
|
const bases = program.command("bases").description("Bases (structured tables)");
|
|
10905
11770
|
addGlobalFlags(bases.command("list")).description("List Bases in the active space").addOption(new Option("--status <active|archived>", "live Bases (default) or archived ones").choices(["active", "archived"])).action(runAction(state, (client, opts) => client.bases.list({ status: opts.status ?? "active" })));
|
|
10906
11771
|
addGlobalFlags(bases.command("get")).description("Get one Base by slug").requiredOption("--slug <slug>", "Base slug").action(runAction(state, async (client, opts) => {
|
|
@@ -11024,7 +11889,7 @@ Examples:
|
|
|
11024
11889
|
startLine: opts.startLine,
|
|
11025
11890
|
endLine: opts.endLine
|
|
11026
11891
|
})));
|
|
11027
|
-
addGlobalFlags(program.command("install")).description("Install a Busabase package from a GitHub repo or a local directory into this space").argument("<source>", "GitHub repo URL (optionally /tree/<ref>[/<subdir>]), or a local directory / file:// URL written by `busabase-cli export`").option("--into-folder <name>", "target folder slug (default: the package's manifest name)").option("--dry-run", "print the plan (tree, record counts, collisions) and create nothing").option("--auto-merge", "merge the package's records and docs on the spot instead of leaving them as change requests to review — this TRUSTS THE PACKAGE AUTHOR, since skills and AirApps carry code your agents will run (default: review first)").option("--rename", "install colliding items under suffixed slugs (-2, -3, …) instead of failing").option("--skill <name>", "which package to install when the URL is a repository holding several (e.g. busabase/skills)").option("--no-sample-records", "propose a template's sample rows for review instead of merging them (default: merged, so the app is not empty on first open)").addHelpText("after", `
|
|
11892
|
+
addGlobalFlags(program.command("install")).description("Install a Busabase package from a GitHub repo or a local directory into this space").argument("<source>", "GitHub repo URL (optionally /tree/<ref>[/<subdir>]), or a local directory / file:// URL written by `busabase-cli export`").option("--into-folder <name>", "target folder slug (default: the package's manifest name)").option("--dry-run", "print the plan (tree, record counts, collisions) and create nothing").option("--auto-merge", "merge the package's records and docs on the spot instead of leaving them as change requests to review — this TRUSTS THE PACKAGE AUTHOR, since skills and AirApps carry code your agents will run (default: review first)").option("--rename", "install colliding items under suffixed slugs (-2, -3, …) instead of failing").option("--skill <name>", "which package to install when the URL is a repository holding several (e.g. busabase/skills)").option("--no-sample-records", "propose a template's sample rows for review instead of merging them (default: merged, so the app is not empty on first open)").option("--no-rollback", "keep whatever a failed install created (default: it is moved to Trash)").addHelpText("after", `
|
|
11028
11893
|
The URL's git ref is the version pin — a tag installs that tag's content forever,
|
|
11029
11894
|
even after the branch moves on. GITHUB_TOKEN is honored for private repos.
|
|
11030
11895
|
|
|
@@ -11064,6 +11929,7 @@ field with nothing linked yet does not trigger this.`).action(runArgAction(state
|
|
|
11064
11929
|
rename: Boolean(opts.rename),
|
|
11065
11930
|
skill: opts.skill,
|
|
11066
11931
|
noSampleRecords: opts.sampleRecords === false,
|
|
11932
|
+
noRollback: opts.rollback === false,
|
|
11067
11933
|
json: config.output === "json",
|
|
11068
11934
|
githubToken: process.env.GITHUB_TOKEN,
|
|
11069
11935
|
serverUrl: config.baseUrl
|
|
@@ -11126,7 +11992,7 @@ and as something \`busabase-cli install\` can install.`).action(runArgAction(sta
|
|
|
11126
11992
|
json: config.output === "json",
|
|
11127
11993
|
baseUrl: config.baseUrl
|
|
11128
11994
|
})));
|
|
11129
|
-
addGlobalFlags(program.command("backup")).description("Back up the active space to a full-fidelity .bbdump archive").option("-o, --out-file <file>", "output archive path", "space.bbdump").option("--no-history", "
|
|
11995
|
+
addGlobalFlags(program.command("backup")).description("Back up the active space to a full-fidelity .bbdump archive").option("-o, --out-file <file>", "output archive path", "space.bbdump").option("--no-history", "drop finished change requests, reviews, comments and audit events (unfinished work is kept)").option("--state-only", "superseded by `busabase-cli export` — see the error text").option("--resume", "continue an interrupted backup, reusing the attachment bytes it already downloaded").addHelpText("after", `
|
|
11130
11996
|
A backup is full fidelity: raw rows with their original ids, every doc body, and
|
|
11131
11997
|
every attachment's bytes, in one integrity-checked archive. Vault items and
|
|
11132
11998
|
webhook signing secrets are never written to it.
|
|
@@ -11134,19 +12000,27 @@ webhook signing secrets are never written to it.
|
|
|
11134
12000
|
Examples:
|
|
11135
12001
|
busabase-cli backup # → ./space.bbdump
|
|
11136
12002
|
busabase-cli backup -o ./backups/acme-2026-07-19.bbdump
|
|
11137
|
-
busabase-cli backup --no-history #
|
|
12003
|
+
busabase-cli backup --no-history # current state + unfinished work
|
|
12004
|
+
|
|
12005
|
+
\`--no-history\` still restores completely: every record, field value, file and
|
|
12006
|
+
permission is there. What it leaves out is the paper trail — change requests
|
|
12007
|
+
that are already finished, their reviews and comments, and the audit log of who
|
|
12008
|
+
did what when. Change requests still in review (or approved but unmerged) are
|
|
12009
|
+
KEPT, because those are unfinished work rather than history.
|
|
11138
12010
|
|
|
11139
12011
|
To hand content to someone else rather than back it up, use \`busabase-cli export\`,
|
|
11140
12012
|
which writes a readable, diffable package directory you can push to GitHub.`).action(runAction(state, (client, opts, config) => runBackup(client, {
|
|
11141
12013
|
outFile: opts.outFile,
|
|
11142
12014
|
includeHistory: opts.history !== false,
|
|
12015
|
+
resume: Boolean(opts.resume),
|
|
11143
12016
|
stateOnly: Boolean(opts.stateOnly),
|
|
11144
12017
|
json: config.output === "json",
|
|
11145
12018
|
baseUrl: config.baseUrl,
|
|
11146
12019
|
spaceId: config.spaceId,
|
|
12020
|
+
retries: config.retries,
|
|
11147
12021
|
toolVersion: pkgVersion()
|
|
11148
12022
|
})));
|
|
11149
|
-
addGlobalFlags(program.command("restore")).description("Restore a .bbdump archive into an EMPTY space, preserving original ids").argument("<file>", "path to the .bbdump archive").option("--resume", "
|
|
12023
|
+
addGlobalFlags(program.command("restore")).description("Restore a .bbdump archive into an EMPTY space, preserving original ids").argument("<file>", "path to the .bbdump archive").option("--resume", "continue a restore whose process was killed partway through (same archive, same space)").option("--into-folder <name>", "state-only archives only — see the error text").addHelpText("after", `
|
|
11150
12024
|
The archive's checksum is verified before a single row is written, and the target
|
|
11151
12025
|
space must be EMPTY — a restore replays original ids, so it cannot merge into a
|
|
11152
12026
|
space that already holds content. Those ids are also unique across the whole
|
|
@@ -11327,4 +12201,90 @@ function explainError(error, config) {
|
|
|
11327
12201
|
return `${body}\n → Docs: ${DOCS_TROUBLESHOOTING}`;
|
|
11328
12202
|
}
|
|
11329
12203
|
//#endregion
|
|
11330
|
-
|
|
12204
|
+
//#region src/update-check.ts
|
|
12205
|
+
/** 24h — frequent enough to notice a new release, rare enough to never matter for latency. */
|
|
12206
|
+
const CHECK_INTERVAL_MS = 864e5;
|
|
12207
|
+
/**
|
|
12208
|
+
* Only paid on the (at most once per 24h, per package) cache-refresh path, and
|
|
12209
|
+
* only for a real network round trip — not on every command. A live check
|
|
12210
|
+
* against the real npm registry measured a cold DNS+TLS handshake at ~800ms
|
|
12211
|
+
* on its own, so anything near that cuts it too close and turns "occasionally
|
|
12212
|
+
* shows the notice a run late" into "usually misses it on the first try
|
|
12213
|
+
* after 24h" — 1500ms leaves real headroom while still being far short of
|
|
12214
|
+
* anything a human would perceive as the command hanging.
|
|
12215
|
+
*/
|
|
12216
|
+
const FETCH_TIMEOUT_MS = 1500;
|
|
12217
|
+
function cacheDir() {
|
|
12218
|
+
return process.env.BUSABASE_UPDATE_CACHE_DIR || join(homedir(), ".cache", "busabase");
|
|
12219
|
+
}
|
|
12220
|
+
function cacheFile() {
|
|
12221
|
+
return join(cacheDir(), "update-check.json");
|
|
12222
|
+
}
|
|
12223
|
+
async function readCache() {
|
|
12224
|
+
try {
|
|
12225
|
+
const raw = await readFile(cacheFile(), "utf8");
|
|
12226
|
+
const parsed = JSON.parse(raw);
|
|
12227
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
12228
|
+
} catch {
|
|
12229
|
+
return {};
|
|
12230
|
+
}
|
|
12231
|
+
}
|
|
12232
|
+
async function writeCacheEntry(packageName, entry) {
|
|
12233
|
+
try {
|
|
12234
|
+
await mkdir(cacheDir(), { recursive: true });
|
|
12235
|
+
const all = await readCache();
|
|
12236
|
+
all[packageName] = entry;
|
|
12237
|
+
await writeFile(cacheFile(), JSON.stringify(all), "utf8");
|
|
12238
|
+
} catch {}
|
|
12239
|
+
}
|
|
12240
|
+
async function fetchLatestVersion(packageName) {
|
|
12241
|
+
const controller = new AbortController();
|
|
12242
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
12243
|
+
try {
|
|
12244
|
+
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`, { signal: controller.signal });
|
|
12245
|
+
if (!res.ok) return null;
|
|
12246
|
+
const data = await res.json();
|
|
12247
|
+
return typeof data.version === "string" ? data.version : null;
|
|
12248
|
+
} catch {
|
|
12249
|
+
return null;
|
|
12250
|
+
} finally {
|
|
12251
|
+
clearTimeout(timeout);
|
|
12252
|
+
}
|
|
12253
|
+
}
|
|
12254
|
+
/** Opted out via env, CI, or a non-interactive terminal — a notice nobody will read. */
|
|
12255
|
+
function checksDisabled() {
|
|
12256
|
+
if (process.env.BUSABASE_NO_UPDATE_CHECK) return true;
|
|
12257
|
+
if (process.env.CI === "true") return true;
|
|
12258
|
+
if (!process.stdout.isTTY) return true;
|
|
12259
|
+
return false;
|
|
12260
|
+
}
|
|
12261
|
+
function formatNotice(packageName, currentVersion, latestVersion) {
|
|
12262
|
+
return `\nA new version of ${packageName} is available: ${currentVersion} → ${latestVersion}\nRun \`npm i -g ${packageName}@latest\` to update.\n`;
|
|
12263
|
+
}
|
|
12264
|
+
/**
|
|
12265
|
+
* Returns an update notice string if a newer version is known, else `null` —
|
|
12266
|
+
* caller decides where that goes (stderr for `busabase-cli`, the server boot
|
|
12267
|
+
* banner for `busabase`). Never throws.
|
|
12268
|
+
*
|
|
12269
|
+
* Cache hit (checked within the last 24h): synchronous-fast, no network call.
|
|
12270
|
+
* Cache miss/stale: pays for ONE bounded (`FETCH_TIMEOUT_MS`) registry round
|
|
12271
|
+
* trip so this run can already show the notice if a new version just shipped;
|
|
12272
|
+
* a genuinely slow/dead network just means this run stays silent and the next
|
|
12273
|
+
* stale check tries again — the timeout caps the worst case, it doesn't
|
|
12274
|
+
* silently retry in the background past process exit (a short-lived CLI
|
|
12275
|
+
* process can't rely on a detached background task actually finishing).
|
|
12276
|
+
*/
|
|
12277
|
+
async function checkForUpdate(packageName, currentVersion) {
|
|
12278
|
+
if (checksDisabled()) return null;
|
|
12279
|
+
const cached = (await readCache())[packageName];
|
|
12280
|
+
if (!(!cached || Date.now() - cached.lastCheckedAt > CHECK_INTERVAL_MS)) return cached.latestVersion !== currentVersion ? formatNotice(packageName, currentVersion, cached.latestVersion) : null;
|
|
12281
|
+
const latest = await fetchLatestVersion(packageName);
|
|
12282
|
+
if (!latest) return null;
|
|
12283
|
+
await writeCacheEntry(packageName, {
|
|
12284
|
+
lastCheckedAt: Date.now(),
|
|
12285
|
+
latestVersion: latest
|
|
12286
|
+
});
|
|
12287
|
+
return latest !== currentVersion ? formatNotice(packageName, currentVersion, latest) : null;
|
|
12288
|
+
}
|
|
12289
|
+
//#endregion
|
|
12290
|
+
export { render as a, runCli as i, HELP as n, pkgVersion as r, checkForUpdate as t };
|