busabase-sdk 0.17.3 → 0.19.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/airapp-gate.d.ts +74 -114
- package/dist/airapp-gate.js +231 -229
- package/dist/airapp-node.d.ts +97 -52
- package/dist/airapp-node.js +422 -444
- package/dist/airapp.d.ts +111 -147
- package/dist/airapp.js +582 -423
- package/dist/client-slzL_ils.d.ts +20560 -0
- package/dist/index.d.ts +2776 -2788
- package/dist/index.js +4902 -4095
- package/dist/oauth-Dg2Z41RP.js +173 -0
- package/dist/oauth-FfuT0EGC.d.ts +69 -0
- package/dist/oauth-node-DGiWkh_l.d.ts +64 -0
- package/dist/oauth-node.d.ts +2 -64
- package/dist/oauth-node.js +188 -3
- package/dist/oauth.d.ts +2 -67
- package/dist/oauth.js +2 -2
- package/dist/url-B8GMXalA.js +10 -0
- package/package.json +6 -6
- package/dist/chunk-5NYQX65A.js +0 -6
- package/dist/chunk-C23JVY2Y.js +0 -211
- package/dist/chunk-WSHJMHUS.js +0 -215
- package/dist/client-BgQn-yGL.d.ts +0 -17842
package/dist/airapp.js
CHANGED
|
@@ -1,451 +1,610 @@
|
|
|
1
|
-
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const TemplateAirAppRefSchema = z.object({
|
|
3
|
+
/** Slug of the `content/<dir>` holding the AirApp. */
|
|
4
|
+
slug: z.string().min(1),
|
|
5
|
+
role: z.enum([
|
|
6
|
+
"primary",
|
|
7
|
+
"admin",
|
|
8
|
+
"public",
|
|
9
|
+
"tool"
|
|
10
|
+
]),
|
|
11
|
+
label: z.string().optional()
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* Secrets the app expects to find in the Vault.
|
|
15
|
+
*
|
|
16
|
+
* DECLARED, never created: the package format has no slot for secret values and
|
|
17
|
+
* must not grow one (the same "you cannot leak what the format cannot express"
|
|
18
|
+
* rule the whole format is built on). Install surfaces these as a post-install
|
|
19
|
+
* prompt; the user fills them in the Vault themselves.
|
|
20
|
+
*/
|
|
21
|
+
const TemplateSecretSchema = z.object({
|
|
22
|
+
key: z.string().min(1),
|
|
23
|
+
description: z.string().default(""),
|
|
24
|
+
required: z.boolean().default(true)
|
|
25
|
+
});
|
|
26
|
+
z.object({
|
|
27
|
+
/** Template Center category, e.g. `"crm"`, `"email"`, `"content"`. */
|
|
28
|
+
category: z.string().min(1),
|
|
29
|
+
tags: z.array(z.string()).default([]),
|
|
30
|
+
/** Card/detail screenshots, package-relative (`assets/screenshots/overview.webp`). */
|
|
31
|
+
screenshots: z.array(z.string()).default([]),
|
|
32
|
+
/**
|
|
33
|
+
* Ready-made prompts shown after install ("Ask agent" prefills the first).
|
|
34
|
+
*
|
|
35
|
+
* They are the difference between a folder of tables and something a user can
|
|
36
|
+
* *use*: the point of a template is that the agent already knows the job, and
|
|
37
|
+
* these are how that is made visible rather than left for the user to guess.
|
|
38
|
+
*/
|
|
39
|
+
agentPrompts: z.array(z.string()).default([]),
|
|
40
|
+
/** Single-AirApp shorthand. Mutually exclusive with `airapps`. */
|
|
41
|
+
airapp: z.string().optional(),
|
|
42
|
+
/** Multi-AirApp form. Exactly one entry must have `role: "primary"`. */
|
|
43
|
+
airapps: z.array(TemplateAirAppRefSchema).optional(),
|
|
44
|
+
/**
|
|
45
|
+
* Bumped by the author when the declared resource shape changes.
|
|
46
|
+
*
|
|
47
|
+
* Part of the ownership stamp, so BOTH doors must agree on it: the installer
|
|
48
|
+
* writes it, and a skill's own `setup.mjs` compares against it to decide
|
|
49
|
+
* whether a node it finds is its own current shape or an older one to repair.
|
|
50
|
+
* Defaulted rather than required so an author who never versions their app
|
|
51
|
+
* still gets a stamp both sides recognise.
|
|
52
|
+
*/
|
|
53
|
+
schemaVersion: z.number().int().nonnegative().default(1),
|
|
54
|
+
vaultNamespace: z.string().optional(),
|
|
55
|
+
secrets: z.array(TemplateSecretSchema).default([]),
|
|
56
|
+
requires: z.object({ airapp: z.boolean().optional() }).default({})
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* `metadata.busabase` inside the root `SKILL.md`'s YAML frontmatter.
|
|
60
|
+
*
|
|
61
|
+
* `template: true` is an EXPLICIT opt-in, not an inference from "this skill
|
|
62
|
+
* happens to contain a package". Publishing a template means accepting that
|
|
63
|
+
* installers will run its AirApp code and feed its SKILL.md to their agent; that
|
|
64
|
+
* deserves a deliberate flag rather than a side effect of directory shape.
|
|
65
|
+
*/
|
|
66
|
+
const SkillBusabaseMetadataSchema = z.object({
|
|
67
|
+
template: z.boolean().default(false),
|
|
68
|
+
folderSlug: z.string().optional(),
|
|
69
|
+
/** Resource keys the manual talks about; each must exist under `content/`. */
|
|
70
|
+
resources: z.array(z.string()).default([]),
|
|
71
|
+
risk: z.string().optional()
|
|
72
|
+
});
|
|
73
|
+
z.object({
|
|
74
|
+
name: z.string().min(1),
|
|
75
|
+
description: z.string().default(""),
|
|
76
|
+
metadata: z.object({ busabase: SkillBusabaseMetadataSchema.optional() }).passthrough().optional()
|
|
77
|
+
});
|
|
78
|
+
/** Stamp on every resource node (Base, Drive, AirApp, …) an app owns. */
|
|
79
|
+
const AppResourceOwnershipSchema = z.object({
|
|
80
|
+
appId: z.string().min(1),
|
|
81
|
+
/** Stable internal handle (`"contacts"`), NOT the installed slug. */
|
|
82
|
+
resourceKey: z.string().min(1),
|
|
83
|
+
schemaVersion: z.number().int().nonnegative()
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* The `resourceKey` reserved for an app's root Folder.
|
|
87
|
+
*
|
|
88
|
+
* `busabase-sdk` recognises an app's own Folder by looking for exactly this
|
|
89
|
+
* value (`ownsAppRoot`), so the installer must write it too — a Folder stamped
|
|
90
|
+
* with anything else reads as a stranger's, and the skill's own `setup.mjs`
|
|
91
|
+
* then refuses to touch its own workspace with `SETUP_CONFLICT`. Exported so
|
|
92
|
+
* neither side carries the string literal privately.
|
|
93
|
+
*/
|
|
94
|
+
const APP_ROOT_RESOURCE_KEY = "app-root";
|
|
95
|
+
AppResourceOwnershipSchema.extend({
|
|
96
|
+
resourceKey: z.literal(APP_ROOT_RESOURCE_KEY),
|
|
97
|
+
version: z.string().optional(),
|
|
98
|
+
source: z.object({
|
|
99
|
+
repo: z.string().optional(),
|
|
100
|
+
ref: z.string().optional(),
|
|
101
|
+
subdir: z.string().optional()
|
|
102
|
+
}).optional(),
|
|
103
|
+
installedAt: z.string().optional()
|
|
104
|
+
});
|
|
105
|
+
z.object({
|
|
106
|
+
appId: z.string().min(1),
|
|
107
|
+
["isTemplateSkill"]: z.literal(true)
|
|
108
|
+
});
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/airapp.ts
|
|
111
|
+
/**
|
|
112
|
+
* AirApp resource provisioning — how an app claims (or creates) the Folder and
|
|
113
|
+
* Bases it declares, exactly once, without ever taking over someone else's.
|
|
114
|
+
*
|
|
115
|
+
* Every App-in-Skill shipped a byte-identical copy of this module (280 lines ×
|
|
116
|
+
* 65 apps, two spellings). That is the wrong place for it: the rules encoded
|
|
117
|
+
* here are not app preferences, they are the safety boundary that keeps an app
|
|
118
|
+
* from adopting a Folder a human created for something else. A third party
|
|
119
|
+
* re-deriving them from scratch gets the happy path right and the conflict
|
|
120
|
+
* cases wrong, and the failure is silent — the app happily writes into data it
|
|
121
|
+
* does not own.
|
|
122
|
+
*
|
|
123
|
+
* The contract, in one line: **an app owns a node only if it stamped it.**
|
|
124
|
+
* Ownership lives in `node.metadata` as `{ appId, resourceKey, schemaVersion }`.
|
|
125
|
+
* Anything else is either a legacy node this app plausibly created before
|
|
126
|
+
* stamping existed (claimable *only* after a full structural fingerprint match)
|
|
127
|
+
* or someone else's (never touched, always a `SETUP_CONFLICT`).
|
|
128
|
+
*
|
|
129
|
+
* This module is isomorphic — browser and Node both — and holds no I/O beyond
|
|
130
|
+
* the passed-in client.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* import { createBusabaseClient } from "busabase-sdk";
|
|
135
|
+
* import { inspectProvisionedResources, provisionDeclaredResources } from "busabase-sdk/airapp";
|
|
136
|
+
*
|
|
137
|
+
* const client = createBusabaseClient({ baseUrl: window.location.origin });
|
|
138
|
+
* const config = {
|
|
139
|
+
* appId: "kelly-crm",
|
|
140
|
+
* appName: "Kelly CRM",
|
|
141
|
+
* schemaVersion: 1,
|
|
142
|
+
* folder: { slug: "kelly-crm", name: "Kelly CRM", description: "CRM workspace" },
|
|
143
|
+
* bases: [{ key: "contacts", slug: "kelly-crm-contacts-v1", name: "Contacts", fields: [...] }],
|
|
144
|
+
* };
|
|
145
|
+
*
|
|
146
|
+
* let resources = await inspectProvisionedResources(client, config);
|
|
147
|
+
* if (!resources.folder || resources.missing.length) {
|
|
148
|
+
* resources = await provisionDeclaredResources(client, config); // one idempotent ChangeRequest
|
|
149
|
+
* }
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
/**
|
|
153
|
+
* A setup failure carrying its state as a `code`.
|
|
154
|
+
*
|
|
155
|
+
* `message` is deliberately kept in the historical `"CODE: detail"` shape: the
|
|
156
|
+
* generated apps parse the prefix off `error.message`, so an app can migrate to
|
|
157
|
+
* this class without touching its rendering code, then move to `error.code`.
|
|
158
|
+
*/
|
|
2
159
|
var AirAppSetupError = class extends Error {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
160
|
+
code;
|
|
161
|
+
/** The human-readable half, without the `CODE: ` prefix. */
|
|
162
|
+
detail;
|
|
163
|
+
constructor(code, detail) {
|
|
164
|
+
super(`${code}: ${detail}`);
|
|
165
|
+
this.name = "AirAppSetupError";
|
|
166
|
+
this.code = code;
|
|
167
|
+
this.detail = detail;
|
|
168
|
+
}
|
|
12
169
|
};
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
170
|
+
const setupError = (code, detail) => new AirAppSetupError(code, detail);
|
|
171
|
+
/** True for a 404 / NOT_FOUND from any of the client's transports. */
|
|
172
|
+
const isNotFound = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "NOT_FOUND" || "status" in error && error.status === 404);
|
|
173
|
+
const isForbidden = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "FORBIDDEN" || "status" in error && error.status === 403);
|
|
174
|
+
const ownsResource = (node, appId, resourceKey, schemaVersion) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey && node?.metadata?.schemaVersion === schemaVersion;
|
|
175
|
+
const hasResourceIdentity = (node, appId, resourceKey) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey;
|
|
176
|
+
const ownsAppRoot = (node, appId, schemaVersion) => hasResourceIdentity(node, appId, "app-root") && node?.metadata?.schemaVersion === schemaVersion;
|
|
177
|
+
const hasEmptyMetadata = (node) => Object.keys(node?.metadata ?? {}).length === 0;
|
|
178
|
+
/**
|
|
179
|
+
* Nobody has stamped ownership on this node — weaker than `hasEmptyMetadata`,
|
|
180
|
+
* and deliberately so: a file-tree node (Skill/Drive/AirApp) always carries a
|
|
181
|
+
* server-written `metadata.version`, even freshly created and never stamped
|
|
182
|
+
* by any app. Requiring literally-empty metadata there would mean a node
|
|
183
|
+
* `publishAirApp` itself just created is never recognized as ours on the very
|
|
184
|
+
* next read — confirmed against a live server, not assumed.
|
|
185
|
+
*/
|
|
186
|
+
const isUnclaimed = (node) => node?.metadata?.appId === void 0;
|
|
187
|
+
/**
|
|
188
|
+
* The legacy-claim test. An unstamped node is adopted only when its every
|
|
189
|
+
* visible attribute still matches the declaration — a weaker test would let an
|
|
190
|
+
* app claim a same-slug Folder a human repurposed.
|
|
191
|
+
*/
|
|
192
|
+
const matchesDeclaration = (node, declaration, type) => node?.type === type && node?.slug === declaration.slug && node?.name === declaration.name && node?.description === (declaration.description ?? "");
|
|
193
|
+
/**
|
|
194
|
+
* The AirApp equivalent of the legacy claim: an unclaimed `airapp` node counts
|
|
195
|
+
* as ours only when its slug and name still match what we declare.
|
|
196
|
+
*/
|
|
197
|
+
const matchesLegacyAirApp = (node, config) => isUnclaimed(node) && node?.type === "airapp" && node?.slug === config.airApp?.slug && node?.name === config.airApp?.name;
|
|
198
|
+
const resourceMetadata = (config, resourceKey) => ({
|
|
199
|
+
appId: config.appId,
|
|
200
|
+
resourceKey,
|
|
201
|
+
schemaVersion: config.schemaVersion
|
|
27
202
|
});
|
|
203
|
+
/**
|
|
204
|
+
* Decide, from one already-read Folder, what exists / is missing / needs
|
|
205
|
+
* re-stamping. Pure — no I/O — so the ownership rules are directly testable.
|
|
206
|
+
*
|
|
207
|
+
* @throws {AirAppSetupError} `SETUP_CONFLICT` when a node in the way is not
|
|
208
|
+
* this app's. Nothing is ever mutated on that path.
|
|
209
|
+
*/
|
|
28
210
|
function resolveProvisionedFolder(folder, config) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const airAppNode = config.airApp ? (folder.children ?? []).find(
|
|
94
|
-
(node) => hasResourceIdentity(
|
|
95
|
-
node,
|
|
96
|
-
config.appId,
|
|
97
|
-
config.airApp.resourceKey
|
|
98
|
-
) || matchesLegacyAirApp(node, config)
|
|
99
|
-
) : void 0;
|
|
100
|
-
if (config.airApp && airAppNode && !ownsResource(airAppNode, config.appId, config.airApp.resourceKey, config.schemaVersion)) {
|
|
101
|
-
repairs.push({
|
|
102
|
-
nodeId: airAppNode.id,
|
|
103
|
-
resourceKey: config.airApp.resourceKey,
|
|
104
|
-
metadata: resourceMetadata(config, config.airApp.resourceKey)
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
if (legacyRoot) {
|
|
108
|
-
const declaredSlugs = new Set(config.bases.map((base) => base.slug));
|
|
109
|
-
const ambiguousExtra = (folder.children ?? []).find(
|
|
110
|
-
(node) => !declaredSlugs.has(node.slug) && node.id !== airAppNode?.id && node?.metadata?.appId !== config.appId
|
|
111
|
-
);
|
|
112
|
-
if (ambiguousExtra) {
|
|
113
|
-
throw setupError(
|
|
114
|
-
"SETUP_CONFLICT",
|
|
115
|
-
`The existing unstamped Folder holds an unattributable resource ${ambiguousExtra.slug}; nothing was changed`
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return {
|
|
120
|
-
folder: { ...config.folder, nodeId: folder.node.id },
|
|
121
|
-
bases,
|
|
122
|
-
missing,
|
|
123
|
-
repairs,
|
|
124
|
-
airApp: airAppNode ? { nodeId: airAppNode.id } : null
|
|
125
|
-
};
|
|
211
|
+
if (!folder) return {
|
|
212
|
+
folder: null,
|
|
213
|
+
bases: [],
|
|
214
|
+
missing: [...config.bases],
|
|
215
|
+
repairs: [],
|
|
216
|
+
airApp: null
|
|
217
|
+
};
|
|
218
|
+
if (folder.node?.type !== "folder" || folder.node?.slug !== config.folder.slug) throw setupError("SETUP_CONFLICT", `A different Folder already uses the slug ${config.folder.slug}; nothing was changed`);
|
|
219
|
+
const rootOwned = hasResourceIdentity(folder.node, config.appId, APP_ROOT_RESOURCE_KEY);
|
|
220
|
+
const legacyRoot = hasEmptyMetadata(folder.node) && matchesDeclaration(folder.node, config.folder, "folder");
|
|
221
|
+
if (!rootOwned && !legacyRoot) throw setupError("SETUP_CONFLICT", `The Folder ${config.folder.slug} does not belong to this app; nothing was changed`);
|
|
222
|
+
const bases = [];
|
|
223
|
+
const missing = [];
|
|
224
|
+
const repairs = [];
|
|
225
|
+
if (!ownsAppRoot(folder.node, config.appId, config.schemaVersion)) repairs.push({
|
|
226
|
+
nodeId: folder.node.id,
|
|
227
|
+
resourceKey: APP_ROOT_RESOURCE_KEY,
|
|
228
|
+
metadata: resourceMetadata(config, APP_ROOT_RESOURCE_KEY)
|
|
229
|
+
});
|
|
230
|
+
for (const base of config.bases) {
|
|
231
|
+
const matches = (folder.children ?? []).filter((node) => node.slug === base.slug);
|
|
232
|
+
if (!matches.length) {
|
|
233
|
+
if (legacyRoot) throw setupError("SETUP_CONFLICT", `The existing unstamped Folder is missing the resource ${base.slug}, so it cannot be claimed safely`);
|
|
234
|
+
missing.push(base);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const node = matches[0];
|
|
238
|
+
if (matches.length !== 1 || node.type !== "base" || !node.baseId) throw setupError("SETUP_CONFLICT", `The resource ${base.slug} does not match this app's declaration; nothing was changed`);
|
|
239
|
+
const owned = hasResourceIdentity(node, config.appId, base.key);
|
|
240
|
+
const legacy = hasEmptyMetadata(node) && matchesDeclaration(node, base, "base");
|
|
241
|
+
if (!owned && !legacy) throw setupError("SETUP_CONFLICT", `The resource ${base.slug} does not match this app's declaration; nothing was changed`);
|
|
242
|
+
if (!ownsResource(node, config.appId, base.key, config.schemaVersion)) repairs.push({
|
|
243
|
+
nodeId: node.id,
|
|
244
|
+
baseId: node.baseId,
|
|
245
|
+
resourceKey: base.key,
|
|
246
|
+
metadata: resourceMetadata(config, base.key)
|
|
247
|
+
});
|
|
248
|
+
bases.push({
|
|
249
|
+
...base,
|
|
250
|
+
nodeId: node.id,
|
|
251
|
+
baseId: node.baseId
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const airAppNode = config.airApp ? (folder.children ?? []).find((node) => hasResourceIdentity(node, config.appId, config.airApp.resourceKey) || matchesLegacyAirApp(node, config)) : void 0;
|
|
255
|
+
if (config.airApp && airAppNode && !ownsResource(airAppNode, config.appId, config.airApp.resourceKey, config.schemaVersion)) repairs.push({
|
|
256
|
+
nodeId: airAppNode.id,
|
|
257
|
+
resourceKey: config.airApp.resourceKey,
|
|
258
|
+
metadata: resourceMetadata(config, config.airApp.resourceKey)
|
|
259
|
+
});
|
|
260
|
+
if (legacyRoot) {
|
|
261
|
+
const declaredSlugs = new Set(config.bases.map((base) => base.slug));
|
|
262
|
+
const ambiguousExtra = (folder.children ?? []).find((node) => !declaredSlugs.has(node.slug) && node.id !== airAppNode?.id && node?.metadata?.appId !== config.appId);
|
|
263
|
+
if (ambiguousExtra) throw setupError("SETUP_CONFLICT", `The existing unstamped Folder holds an unattributable resource ${ambiguousExtra.slug}; nothing was changed`);
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
folder: {
|
|
267
|
+
...config.folder,
|
|
268
|
+
nodeId: folder.node.id
|
|
269
|
+
},
|
|
270
|
+
bases,
|
|
271
|
+
missing,
|
|
272
|
+
repairs,
|
|
273
|
+
airApp: airAppNode ? { nodeId: airAppNode.id } : null
|
|
274
|
+
};
|
|
126
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* The create operations for one idempotent ChangeRequest. Pure.
|
|
278
|
+
*
|
|
279
|
+
* When the Folder does not exist yet it is created under the temp `ref`
|
|
280
|
+
* `"app-root"` and the Bases nest under it via `parentNodeRef`, so the whole
|
|
281
|
+
* structure lands in a single reviewable change.
|
|
282
|
+
*/
|
|
127
283
|
function buildProvisionOperations(config, folder, missingBases) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// The declaration's `type` is a plain `string` (see AirAppFieldDeclaration);
|
|
150
|
-
// the server validates the real field-type enum on the wire.
|
|
151
|
-
fields: base.fields
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
return operations;
|
|
284
|
+
const operations = [];
|
|
285
|
+
if (!folder) operations.push({
|
|
286
|
+
kind: "create",
|
|
287
|
+
ref: "app-root",
|
|
288
|
+
nodeType: "folder",
|
|
289
|
+
slug: config.folder.slug,
|
|
290
|
+
name: config.folder.name,
|
|
291
|
+
description: config.folder.description ?? "",
|
|
292
|
+
metadata: resourceMetadata(config, APP_ROOT_RESOURCE_KEY)
|
|
293
|
+
});
|
|
294
|
+
for (const base of missingBases) operations.push({
|
|
295
|
+
kind: "create",
|
|
296
|
+
...folder ? { parentNodeId: folder.nodeId } : { parentNodeRef: "app-root" },
|
|
297
|
+
nodeType: "base",
|
|
298
|
+
slug: base.slug,
|
|
299
|
+
name: base.name,
|
|
300
|
+
description: base.description ?? "",
|
|
301
|
+
metadata: resourceMetadata(config, base.key),
|
|
302
|
+
fields: base.fields
|
|
303
|
+
});
|
|
304
|
+
return operations;
|
|
155
305
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
return candidates[0] ?? null;
|
|
306
|
+
const findTopLevelFolder = async (client, config) => {
|
|
307
|
+
const candidates = (await client.nodes.list({
|
|
308
|
+
parentId: null,
|
|
309
|
+
depth: 2
|
|
310
|
+
}) ?? []).flatMap((node) => [node, ...node.children ?? []]).filter((node) => node.type === "folder" && node.slug === config.folder.slug);
|
|
311
|
+
if (candidates.length > 1) throw setupError("SETUP_CONFLICT", `Found more than one Folder with the slug ${config.folder.slug}; nothing was changed`);
|
|
312
|
+
return candidates[0] ?? null;
|
|
166
313
|
};
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
314
|
+
const readFolder = async (client, config) => {
|
|
315
|
+
let nodeId = config.folder.nodeId;
|
|
316
|
+
if (!nodeId) nodeId = (await findTopLevelFolder(client, config))?.id;
|
|
317
|
+
if (!nodeId) return null;
|
|
318
|
+
try {
|
|
319
|
+
return await client.nodes.get({
|
|
320
|
+
nodeId,
|
|
321
|
+
type: "folder"
|
|
322
|
+
});
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (isNotFound(error) && config.folder.nodeId) {
|
|
325
|
+
const discovered = await findTopLevelFolder(client, config);
|
|
326
|
+
return discovered ? await client.nodes.get({
|
|
327
|
+
nodeId: discovered.id,
|
|
328
|
+
type: "folder"
|
|
329
|
+
}) : null;
|
|
330
|
+
}
|
|
331
|
+
if (isNotFound(error)) return null;
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
184
334
|
};
|
|
335
|
+
/** Read the current state of this app's declared resources. Never mutates. */
|
|
185
336
|
async function inspectProvisionedResources(client, config) {
|
|
186
|
-
|
|
337
|
+
return resolveProvisionedFolder(await readFolder(client, config), config);
|
|
187
338
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
339
|
+
const provisionStates = /* @__PURE__ */ new WeakMap();
|
|
340
|
+
const stateFor = (client, appId) => {
|
|
341
|
+
let byApp = provisionStates.get(client);
|
|
342
|
+
if (!byApp) {
|
|
343
|
+
byApp = /* @__PURE__ */ new Map();
|
|
344
|
+
provisionStates.set(client, byApp);
|
|
345
|
+
}
|
|
346
|
+
let state = byApp.get(appId);
|
|
347
|
+
if (!state) {
|
|
348
|
+
state = {
|
|
349
|
+
inFlight: null,
|
|
350
|
+
metadataUpdatesSupported: void 0
|
|
351
|
+
};
|
|
352
|
+
byApp.set(appId, state);
|
|
353
|
+
}
|
|
354
|
+
return state;
|
|
201
355
|
};
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
356
|
+
const sameFieldName = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected);
|
|
357
|
+
const fieldMatches = (actual, expected) => actual?.slug === expected.slug && actual?.type === expected.type && actual?.required === expected.required && sameFieldName(actual?.name, expected.name);
|
|
358
|
+
/**
|
|
359
|
+
* How an app evolves a Base it already owns: the declared field list may grow,
|
|
360
|
+
* and only at the end.
|
|
361
|
+
*
|
|
362
|
+
* A live Base whose fields are a strict *prefix* of the declaration is an older
|
|
363
|
+
* schema of ours, and the missing suffix is added. Anything else — a field
|
|
364
|
+
* renamed, retyped, reordered, or removed — is not an upgrade this can reason
|
|
365
|
+
* about, so it refuses rather than guessing which of the two shapes is right.
|
|
366
|
+
*
|
|
367
|
+
* @throws {AirAppSetupError} `SETUP_CONFLICT` when the existing fields are not a
|
|
368
|
+
* prefix of the declared ones.
|
|
369
|
+
*/
|
|
370
|
+
const additiveFieldsFor = (actual, expected) => {
|
|
371
|
+
const fields = actual?.fields ?? [];
|
|
372
|
+
if (fields.length > expected.fields.length || !fields.every((field, index) => fieldMatches(field, expected.fields[index]))) throw setupError("SETUP_CONFLICT", `The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`);
|
|
373
|
+
return expected.fields.slice(fields.length);
|
|
213
374
|
};
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
);
|
|
225
|
-
}
|
|
375
|
+
/**
|
|
376
|
+
* Before re-stamping an unstamped Base as ours, prove it is structurally the
|
|
377
|
+
* Base we declared — same slug, name, description, and exact field list in
|
|
378
|
+
* order. Without this, a stamp would launder a name collision into ownership.
|
|
379
|
+
*/
|
|
380
|
+
const validateRepairBase = (actual, expected, nodeId) => {
|
|
381
|
+
if (!expected) throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
|
|
382
|
+
const fields = actual?.fields ?? [];
|
|
383
|
+
const exactFields = fields.length === expected.fields.length && fields.every((field, index) => fieldMatches(field, expected.fields[index]));
|
|
384
|
+
if (actual?.nodeId !== nodeId || actual?.slug !== expected.slug || actual?.name !== expected.name || actual?.description !== (expected.description ?? "") || !exactFields) throw setupError("SETUP_CONFLICT", `The structure of ${expected.slug} does not match this app's declaration, so it cannot be claimed safely`);
|
|
226
385
|
};
|
|
227
386
|
async function repairResourceOwnership(client, config, current) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
state.metadataUpdatesSupported = false;
|
|
292
|
-
return { ...current, repairs: [], compatibilityMode: "verified-legacy-fingerprint" };
|
|
293
|
-
}
|
|
294
|
-
if (isForbidden(error)) {
|
|
295
|
-
throw setupError(
|
|
296
|
-
"SETUP_PERMISSION",
|
|
297
|
-
"This account may not repair resource ownership metadata for this app"
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
throw error;
|
|
301
|
-
}
|
|
302
|
-
const repaired = await inspectProvisionedResources(client, config);
|
|
303
|
-
if (repaired.repairs.length) {
|
|
304
|
-
throw setupError("SCHEMA_INCOMPLETE", "Ownership was repaired but read back incomplete");
|
|
305
|
-
}
|
|
306
|
-
return repaired;
|
|
387
|
+
if (!current.repairs.length) return current;
|
|
388
|
+
const state = stateFor(client, config.appId);
|
|
389
|
+
const baseRepairs = current.repairs.filter((repair) => repair.baseId);
|
|
390
|
+
const baseByKey = new Map(config.bases.map((base) => [base.key, base]));
|
|
391
|
+
const details = await Promise.all(baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId })));
|
|
392
|
+
const migrations = details.map((detail, index) => {
|
|
393
|
+
const repair = baseRepairs[index];
|
|
394
|
+
const expected = baseByKey.get(repair.resourceKey);
|
|
395
|
+
if (!expected) throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
|
|
396
|
+
if (detail?.nodeId !== repair.nodeId || detail?.slug !== expected.slug || detail?.name !== expected.name || detail?.description !== (expected.description ?? "")) throw setupError("SETUP_CONFLICT", `The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`);
|
|
397
|
+
return {
|
|
398
|
+
repair,
|
|
399
|
+
expected,
|
|
400
|
+
fields: additiveFieldsFor(detail, expected)
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
const pendingFieldRequests = [];
|
|
404
|
+
for (const migration of migrations) for (const field of migration.fields) {
|
|
405
|
+
const changeRequest = await client.bases.fieldChangeRequest({
|
|
406
|
+
operation: "create",
|
|
407
|
+
baseId: migration.repair.baseId,
|
|
408
|
+
slug: field.slug,
|
|
409
|
+
name: field.name,
|
|
410
|
+
type: field.type,
|
|
411
|
+
required: field.required,
|
|
412
|
+
message: `Upgrade ${config.appName}: add ${field.slug}`,
|
|
413
|
+
submittedBy: config.appId
|
|
414
|
+
});
|
|
415
|
+
if (!(changeRequest?.status === "merged" || changeRequest?.materialized === true)) pendingFieldRequests.push(changeRequest?.id ?? field.slug);
|
|
416
|
+
}
|
|
417
|
+
if (pendingFieldRequests.length) throw setupError("SETUP_PENDING", `Submitted ${pendingFieldRequests.length} field upgrade request(s) awaiting Space admin approval: ${pendingFieldRequests.join(", ")}`);
|
|
418
|
+
(migrations.some((migration) => migration.fields.length) ? await Promise.all(baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId }))) : details).forEach((detail, index) => {
|
|
419
|
+
const repair = baseRepairs[index];
|
|
420
|
+
validateRepairBase(detail, baseByKey.get(repair.resourceKey), repair.nodeId);
|
|
421
|
+
});
|
|
422
|
+
if (state.metadataUpdatesSupported === false) return {
|
|
423
|
+
...current,
|
|
424
|
+
repairs: [],
|
|
425
|
+
compatibilityMode: "verified-legacy-fingerprint"
|
|
426
|
+
};
|
|
427
|
+
try {
|
|
428
|
+
for (const repair of current.repairs) {
|
|
429
|
+
await client.nodes.updateMetadata({
|
|
430
|
+
nodeId: repair.nodeId,
|
|
431
|
+
metadata: repair.metadata
|
|
432
|
+
});
|
|
433
|
+
state.metadataUpdatesSupported = true;
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (isNotFound(error)) {
|
|
437
|
+
state.metadataUpdatesSupported = false;
|
|
438
|
+
return {
|
|
439
|
+
...current,
|
|
440
|
+
repairs: [],
|
|
441
|
+
compatibilityMode: "verified-legacy-fingerprint"
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (isForbidden(error)) throw setupError("SETUP_PERMISSION", "This account may not repair resource ownership metadata for this app");
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
const repaired = await inspectProvisionedResources(client, config);
|
|
448
|
+
if (repaired.repairs.length) throw setupError("SCHEMA_INCOMPLETE", "Ownership was repaired but read back incomplete");
|
|
449
|
+
return repaired;
|
|
307
450
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
451
|
+
/**
|
|
452
|
+
* A merged ChangeRequest is not immediately readable — materialization is
|
|
453
|
+
* asynchronous — so poll briefly rather than reporting a successful setup as
|
|
454
|
+
* incomplete.
|
|
455
|
+
*/
|
|
456
|
+
const waitForMaterializedResources = async (client, config, attempts = 20) => {
|
|
457
|
+
let current;
|
|
458
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
459
|
+
current = await inspectProvisionedResources(client, config);
|
|
460
|
+
current = await repairResourceOwnership(client, config, current);
|
|
461
|
+
if (current.folder && current.missing.length === 0) return current;
|
|
462
|
+
if (attempt < attempts - 1) await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
|
|
463
|
+
}
|
|
464
|
+
throw setupError("SCHEMA_INCOMPLETE", "Initialization merged but the resources read back incomplete");
|
|
322
465
|
};
|
|
323
466
|
async function provisionOnce(client, config) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (concurrent?.folder && concurrent.missing.length === 0) return concurrent;
|
|
345
|
-
throw error;
|
|
346
|
-
}
|
|
347
|
-
if (changeRequest?.status !== "merged") {
|
|
348
|
-
throw setupError(
|
|
349
|
-
"SETUP_PENDING",
|
|
350
|
-
`Initialization request ${changeRequest?.id ?? ""} was submitted and awaits Space admin approval`.trim()
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
return waitForMaterializedResources(client, config);
|
|
467
|
+
let current = await inspectProvisionedResources(client, config);
|
|
468
|
+
current = await repairResourceOwnership(client, config, current);
|
|
469
|
+
if (current.folder && current.missing.length === 0) return current;
|
|
470
|
+
const operations = buildProvisionOperations(config, current.folder, current.missing);
|
|
471
|
+
let changeRequest;
|
|
472
|
+
try {
|
|
473
|
+
changeRequest = await client.nodes.createChangeRequest({
|
|
474
|
+
message: `Initialize ${config.appName} workspace`,
|
|
475
|
+
submittedBy: config.appId,
|
|
476
|
+
autoMerge: true,
|
|
477
|
+
operations
|
|
478
|
+
});
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (isForbidden(error)) throw setupError("SETUP_PERMISSION", "This account may not create this app's resources in this Space");
|
|
481
|
+
const concurrent = await inspectProvisionedResources(client, config).catch(() => null);
|
|
482
|
+
if (concurrent?.folder && concurrent.missing.length === 0) return concurrent;
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
if (changeRequest?.status !== "merged") throw setupError("SETUP_PENDING", `Initialization request ${changeRequest?.id ?? ""} was submitted and awaits Space admin approval`.trim());
|
|
486
|
+
return waitForMaterializedResources(client, config);
|
|
354
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* Ensure the declared Folder and Bases exist, as one idempotent ChangeRequest.
|
|
490
|
+
*
|
|
491
|
+
* Safe to call concurrently: calls for the same client + `appId` share one
|
|
492
|
+
* in-flight promise, so a multi-pane app cannot submit the structure twice.
|
|
493
|
+
*
|
|
494
|
+
* @throws {AirAppSetupError} with a `code` describing which screen to show.
|
|
495
|
+
*/
|
|
355
496
|
function provisionDeclaredResources(client, config) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
return state.inFlight;
|
|
497
|
+
const state = stateFor(client, config.appId);
|
|
498
|
+
if (!state.inFlight) state.inFlight = provisionOnce(client, config).finally(() => {
|
|
499
|
+
state.inFlight = null;
|
|
500
|
+
});
|
|
501
|
+
return state.inFlight;
|
|
363
502
|
}
|
|
503
|
+
/**
|
|
504
|
+
* The create-vs-update operation list for one AirApp publish. Pure — no I/O —
|
|
505
|
+
* so the decision is directly testable: a local path already present on the
|
|
506
|
+
* deployed node updates it, anything else is a new file. Never deletes a
|
|
507
|
+
* remote-only path — a file this bundle stopped shipping is left alone rather
|
|
508
|
+
* than assumed stale, the same conservative choice the rest of this module
|
|
509
|
+
* makes for a Base's fields (`additiveFieldsFor` only ever appends).
|
|
510
|
+
*/
|
|
364
511
|
function buildAirAppFileOperations(localFiles, deployedPaths) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
})
|
|
373
|
-
);
|
|
512
|
+
const deployed = new Set(deployedPaths);
|
|
513
|
+
return localFiles.map((file) => ({
|
|
514
|
+
kind: deployed.has(file.path) ? "update" : "create",
|
|
515
|
+
path: file.path,
|
|
516
|
+
content: file.content,
|
|
517
|
+
...file.mimeType ? { mimeType: file.mimeType } : {}
|
|
518
|
+
}));
|
|
374
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Find a still-pending ChangeRequest that already proposes creating this
|
|
522
|
+
* declared AirApp, so a second `publishAirApp` call before the first is
|
|
523
|
+
* reviewed does not propose a second, duplicate create for the same slug.
|
|
524
|
+
*
|
|
525
|
+
* Scoped to the first 500 in-review ChangeRequests (10 pages of 50) — a
|
|
526
|
+
* Space with more open reviews than that has bigger problems than this
|
|
527
|
+
* scan not reaching the one it is looking for, and a missed match only
|
|
528
|
+
* costs an extra (harmless, reviewer-visible) duplicate proposal, never a
|
|
529
|
+
* wrong merge.
|
|
530
|
+
*/
|
|
375
531
|
async function findPendingAirAppCreate(client, slug) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
cursor = result.nextCursor;
|
|
391
|
-
}
|
|
392
|
-
return null;
|
|
532
|
+
let cursor;
|
|
533
|
+
for (let page = 0; page < 10; page += 1) {
|
|
534
|
+
const result = await client.changeRequests.list({
|
|
535
|
+
status: ["in_review"],
|
|
536
|
+
...cursor ? { cursor } : {}
|
|
537
|
+
});
|
|
538
|
+
for (const changeRequest of result.changeRequests) if ((changeRequest.operations ?? []).some((operation) => {
|
|
539
|
+
const payload = operation.headCommit?.payload;
|
|
540
|
+
return payload?.kind === "create" && payload?.nodeType === "airapp" && payload?.slug === slug;
|
|
541
|
+
})) return changeRequest.id;
|
|
542
|
+
if (!result.nextCursor) return null;
|
|
543
|
+
cursor = result.nextCursor;
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
393
546
|
}
|
|
547
|
+
/**
|
|
548
|
+
* Publish the app's own AirApp bundle: create it under the Folder when this
|
|
549
|
+
* Space has never had it, or propose the local files as an update when it
|
|
550
|
+
* already exists. Always a separate, always-review-first ChangeRequest from
|
|
551
|
+
* the data layer's `provisionDeclaredResources` — see the note on
|
|
552
|
+
* `AirAppNodeDeclaration` for why the two must never share a request.
|
|
553
|
+
*
|
|
554
|
+
* Call after `provisionDeclaredResources` has confirmed the Folder exists.
|
|
555
|
+
* Every call proposes the full local file list, even when nothing actually
|
|
556
|
+
* changed — this module has no access to the deployed content hashes
|
|
557
|
+
* (`fileTrees.listFiles` reports paths, not hashes; only a per-file
|
|
558
|
+
* `readFile` does, and fetching one per file to skip a no-op publish is not
|
|
559
|
+
* worth the round trips a normal publish cadence would spend on it). A
|
|
560
|
+
* reviewer sees an empty diff and merges or ignores it; this is a cost in
|
|
561
|
+
* review noise, not correctness.
|
|
562
|
+
*
|
|
563
|
+
* @throws {AirAppSetupError} `SETUP_CONFLICT` when `config` declares no
|
|
564
|
+
* `airApp`; `SETUP_REQUIRED` when the Folder does not exist yet.
|
|
565
|
+
*/
|
|
394
566
|
async function publishAirApp(client, config, files) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const operations = buildAirAppFileOperations(
|
|
437
|
-
files,
|
|
438
|
-
deployedFiles.map((file) => file.path)
|
|
439
|
-
);
|
|
440
|
-
const changeRequest = await client.fileTrees.createChangeRequest({
|
|
441
|
-
nodeId: current.airApp.nodeId,
|
|
442
|
-
type: "airapp",
|
|
443
|
-
operations,
|
|
444
|
-
message: `Publish ${config.appName} AirApp`,
|
|
445
|
-
submittedBy: config.appId,
|
|
446
|
-
autoMerge: false
|
|
447
|
-
});
|
|
448
|
-
return { status: "updated", changeRequestId: changeRequest.id };
|
|
567
|
+
const airApp = config.airApp;
|
|
568
|
+
if (!airApp) throw setupError("SETUP_CONFLICT", "This app does not declare an airApp to publish");
|
|
569
|
+
const current = await inspectProvisionedResources(client, config);
|
|
570
|
+
if (!current.folder) throw setupError("SETUP_REQUIRED", "Provision the Folder and Bases with provisionDeclaredResources before publishing the AirApp");
|
|
571
|
+
if (!current.airApp) {
|
|
572
|
+
const pendingChangeRequestId = await findPendingAirAppCreate(client, airApp.slug);
|
|
573
|
+
if (pendingChangeRequestId) return {
|
|
574
|
+
status: "pending",
|
|
575
|
+
changeRequestId: pendingChangeRequestId
|
|
576
|
+
};
|
|
577
|
+
const changeRequest = await client.fileTrees.create({
|
|
578
|
+
type: "airapp",
|
|
579
|
+
parentNodeId: current.folder.nodeId,
|
|
580
|
+
slug: airApp.slug,
|
|
581
|
+
name: airApp.name,
|
|
582
|
+
description: airApp.description ?? "",
|
|
583
|
+
files,
|
|
584
|
+
mergeMode: "replace",
|
|
585
|
+
autoMerge: false
|
|
586
|
+
});
|
|
587
|
+
if (changeRequest.materialized) throw setupError("SCHEMA_INCOMPLETE", "AirApp create unexpectedly materialized despite autoMerge: false");
|
|
588
|
+
return {
|
|
589
|
+
status: "created",
|
|
590
|
+
changeRequestId: changeRequest.id
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const operations = buildAirAppFileOperations(files, (await client.fileTrees.listFiles({
|
|
594
|
+
nodeId: current.airApp.nodeId,
|
|
595
|
+
type: "airapp"
|
|
596
|
+
})).map((file) => file.path));
|
|
597
|
+
return {
|
|
598
|
+
status: "updated",
|
|
599
|
+
changeRequestId: (await client.fileTrees.createChangeRequest({
|
|
600
|
+
nodeId: current.airApp.nodeId,
|
|
601
|
+
type: "airapp",
|
|
602
|
+
operations,
|
|
603
|
+
message: `Publish ${config.appName} AirApp`,
|
|
604
|
+
submittedBy: config.appId,
|
|
605
|
+
autoMerge: false
|
|
606
|
+
})).id
|
|
607
|
+
};
|
|
449
608
|
}
|
|
450
|
-
|
|
609
|
+
//#endregion
|
|
451
610
|
export { AirAppSetupError, buildAirAppFileOperations, buildProvisionOperations, inspectProvisionedResources, isNotFound, provisionDeclaredResources, publishAirApp, resolveProvisionedFolder };
|