busabase-sdk 0.17.2 → 0.18.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 +102 -147
- package/dist/airapp.js +432 -423
- package/dist/client-CbUceGzy.d.ts +20523 -0
- package/dist/index.d.ts +2807 -2739
- package/dist/index.js +4803 -4029
- 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-DIlpYL9z.d.ts +0 -17818
package/dist/airapp.js
CHANGED
|
@@ -1,451 +1,460 @@
|
|
|
1
|
-
|
|
1
|
+
//#region src/airapp.ts
|
|
2
|
+
/**
|
|
3
|
+
* A setup failure carrying its state as a `code`.
|
|
4
|
+
*
|
|
5
|
+
* `message` is deliberately kept in the historical `"CODE: detail"` shape: the
|
|
6
|
+
* generated apps parse the prefix off `error.message`, so an app can migrate to
|
|
7
|
+
* this class without touching its rendering code, then move to `error.code`.
|
|
8
|
+
*/
|
|
2
9
|
var AirAppSetupError = class extends Error {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
code;
|
|
11
|
+
/** The human-readable half, without the `CODE: ` prefix. */
|
|
12
|
+
detail;
|
|
13
|
+
constructor(code, detail) {
|
|
14
|
+
super(`${code}: ${detail}`);
|
|
15
|
+
this.name = "AirAppSetupError";
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.detail = detail;
|
|
18
|
+
}
|
|
12
19
|
};
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
const setupError = (code, detail) => new AirAppSetupError(code, detail);
|
|
21
|
+
/** True for a 404 / NOT_FOUND from any of the client's transports. */
|
|
22
|
+
const isNotFound = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "NOT_FOUND" || "status" in error && error.status === 404);
|
|
23
|
+
const isForbidden = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "FORBIDDEN" || "status" in error && error.status === 403);
|
|
24
|
+
const ownsResource = (node, appId, resourceKey, schemaVersion) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey && node?.metadata?.schemaVersion === schemaVersion;
|
|
25
|
+
const hasResourceIdentity = (node, appId, resourceKey) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey;
|
|
26
|
+
const ownsAppRoot = (node, appId, schemaVersion) => hasResourceIdentity(node, appId, "app-root") && node?.metadata?.schemaVersion === schemaVersion;
|
|
27
|
+
const hasEmptyMetadata = (node) => Object.keys(node?.metadata ?? {}).length === 0;
|
|
28
|
+
/**
|
|
29
|
+
* Nobody has stamped ownership on this node — weaker than `hasEmptyMetadata`,
|
|
30
|
+
* and deliberately so: a file-tree node (Skill/Drive/AirApp) always carries a
|
|
31
|
+
* server-written `metadata.version`, even freshly created and never stamped
|
|
32
|
+
* by any app. Requiring literally-empty metadata there would mean a node
|
|
33
|
+
* `publishAirApp` itself just created is never recognized as ours on the very
|
|
34
|
+
* next read — confirmed against a live server, not assumed.
|
|
35
|
+
*/
|
|
36
|
+
const isUnclaimed = (node) => node?.metadata?.appId === void 0;
|
|
37
|
+
/**
|
|
38
|
+
* The legacy-claim test. An unstamped node is adopted only when its every
|
|
39
|
+
* visible attribute still matches the declaration — a weaker test would let an
|
|
40
|
+
* app claim a same-slug Folder a human repurposed.
|
|
41
|
+
*/
|
|
42
|
+
const matchesDeclaration = (node, declaration, type) => node?.type === type && node?.slug === declaration.slug && node?.name === declaration.name && node?.description === (declaration.description ?? "");
|
|
43
|
+
/**
|
|
44
|
+
* The AirApp equivalent of the legacy claim: an unclaimed `airapp` node counts
|
|
45
|
+
* as ours only when its slug and name still match what we declare.
|
|
46
|
+
*/
|
|
47
|
+
const matchesLegacyAirApp = (node, config) => isUnclaimed(node) && node?.type === "airapp" && node?.slug === config.airApp?.slug && node?.name === config.airApp?.name;
|
|
48
|
+
const resourceMetadata = (config, resourceKey) => ({
|
|
49
|
+
appId: config.appId,
|
|
50
|
+
resourceKey,
|
|
51
|
+
schemaVersion: config.schemaVersion
|
|
27
52
|
});
|
|
53
|
+
/**
|
|
54
|
+
* Decide, from one already-read Folder, what exists / is missing / needs
|
|
55
|
+
* re-stamping. Pure — no I/O — so the ownership rules are directly testable.
|
|
56
|
+
*
|
|
57
|
+
* @throws {AirAppSetupError} `SETUP_CONFLICT` when a node in the way is not
|
|
58
|
+
* this app's. Nothing is ever mutated on that path.
|
|
59
|
+
*/
|
|
28
60
|
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
|
-
};
|
|
61
|
+
if (!folder) return {
|
|
62
|
+
folder: null,
|
|
63
|
+
bases: [],
|
|
64
|
+
missing: [...config.bases],
|
|
65
|
+
repairs: [],
|
|
66
|
+
airApp: null
|
|
67
|
+
};
|
|
68
|
+
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`);
|
|
69
|
+
const rootOwned = hasResourceIdentity(folder.node, config.appId, "app-root");
|
|
70
|
+
const legacyRoot = hasEmptyMetadata(folder.node) && matchesDeclaration(folder.node, config.folder, "folder");
|
|
71
|
+
if (!rootOwned && !legacyRoot) throw setupError("SETUP_CONFLICT", `The Folder ${config.folder.slug} does not belong to this app; nothing was changed`);
|
|
72
|
+
const bases = [];
|
|
73
|
+
const missing = [];
|
|
74
|
+
const repairs = [];
|
|
75
|
+
if (!ownsAppRoot(folder.node, config.appId, config.schemaVersion)) repairs.push({
|
|
76
|
+
nodeId: folder.node.id,
|
|
77
|
+
resourceKey: "app-root",
|
|
78
|
+
metadata: resourceMetadata(config, "app-root")
|
|
79
|
+
});
|
|
80
|
+
for (const base of config.bases) {
|
|
81
|
+
const matches = (folder.children ?? []).filter((node) => node.slug === base.slug);
|
|
82
|
+
if (!matches.length) {
|
|
83
|
+
if (legacyRoot) throw setupError("SETUP_CONFLICT", `The existing unstamped Folder is missing the resource ${base.slug}, so it cannot be claimed safely`);
|
|
84
|
+
missing.push(base);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const node = matches[0];
|
|
88
|
+
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`);
|
|
89
|
+
const owned = hasResourceIdentity(node, config.appId, base.key);
|
|
90
|
+
const legacy = hasEmptyMetadata(node) && matchesDeclaration(node, base, "base");
|
|
91
|
+
if (!owned && !legacy) throw setupError("SETUP_CONFLICT", `The resource ${base.slug} does not match this app's declaration; nothing was changed`);
|
|
92
|
+
if (!ownsResource(node, config.appId, base.key, config.schemaVersion)) repairs.push({
|
|
93
|
+
nodeId: node.id,
|
|
94
|
+
baseId: node.baseId,
|
|
95
|
+
resourceKey: base.key,
|
|
96
|
+
metadata: resourceMetadata(config, base.key)
|
|
97
|
+
});
|
|
98
|
+
bases.push({
|
|
99
|
+
...base,
|
|
100
|
+
nodeId: node.id,
|
|
101
|
+
baseId: node.baseId
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const airAppNode = config.airApp ? (folder.children ?? []).find((node) => hasResourceIdentity(node, config.appId, config.airApp.resourceKey) || matchesLegacyAirApp(node, config)) : void 0;
|
|
105
|
+
if (config.airApp && airAppNode && !ownsResource(airAppNode, config.appId, config.airApp.resourceKey, config.schemaVersion)) repairs.push({
|
|
106
|
+
nodeId: airAppNode.id,
|
|
107
|
+
resourceKey: config.airApp.resourceKey,
|
|
108
|
+
metadata: resourceMetadata(config, config.airApp.resourceKey)
|
|
109
|
+
});
|
|
110
|
+
if (legacyRoot) {
|
|
111
|
+
const declaredSlugs = new Set(config.bases.map((base) => base.slug));
|
|
112
|
+
const ambiguousExtra = (folder.children ?? []).find((node) => !declaredSlugs.has(node.slug) && node.id !== airAppNode?.id && node?.metadata?.appId !== config.appId);
|
|
113
|
+
if (ambiguousExtra) throw setupError("SETUP_CONFLICT", `The existing unstamped Folder holds an unattributable resource ${ambiguousExtra.slug}; nothing was changed`);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
folder: {
|
|
117
|
+
...config.folder,
|
|
118
|
+
nodeId: folder.node.id
|
|
119
|
+
},
|
|
120
|
+
bases,
|
|
121
|
+
missing,
|
|
122
|
+
repairs,
|
|
123
|
+
airApp: airAppNode ? { nodeId: airAppNode.id } : null
|
|
124
|
+
};
|
|
126
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* The create operations for one idempotent ChangeRequest. Pure.
|
|
128
|
+
*
|
|
129
|
+
* When the Folder does not exist yet it is created under the temp `ref`
|
|
130
|
+
* `"app-root"` and the Bases nest under it via `parentNodeRef`, so the whole
|
|
131
|
+
* structure lands in a single reviewable change.
|
|
132
|
+
*/
|
|
127
133
|
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;
|
|
134
|
+
const operations = [];
|
|
135
|
+
if (!folder) operations.push({
|
|
136
|
+
kind: "create",
|
|
137
|
+
ref: "app-root",
|
|
138
|
+
nodeType: "folder",
|
|
139
|
+
slug: config.folder.slug,
|
|
140
|
+
name: config.folder.name,
|
|
141
|
+
description: config.folder.description ?? "",
|
|
142
|
+
metadata: resourceMetadata(config, "app-root")
|
|
143
|
+
});
|
|
144
|
+
for (const base of missingBases) operations.push({
|
|
145
|
+
kind: "create",
|
|
146
|
+
...folder ? { parentNodeId: folder.nodeId } : { parentNodeRef: "app-root" },
|
|
147
|
+
nodeType: "base",
|
|
148
|
+
slug: base.slug,
|
|
149
|
+
name: base.name,
|
|
150
|
+
description: base.description ?? "",
|
|
151
|
+
metadata: resourceMetadata(config, base.key),
|
|
152
|
+
fields: base.fields
|
|
153
|
+
});
|
|
154
|
+
return operations;
|
|
155
155
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
return candidates[0] ?? null;
|
|
156
|
+
const findTopLevelFolder = async (client, config) => {
|
|
157
|
+
const candidates = (await client.nodes.list({
|
|
158
|
+
parentId: null,
|
|
159
|
+
depth: 2
|
|
160
|
+
}) ?? []).flatMap((node) => [node, ...node.children ?? []]).filter((node) => node.type === "folder" && node.slug === config.folder.slug);
|
|
161
|
+
if (candidates.length > 1) throw setupError("SETUP_CONFLICT", `Found more than one Folder with the slug ${config.folder.slug}; nothing was changed`);
|
|
162
|
+
return candidates[0] ?? null;
|
|
166
163
|
};
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
164
|
+
const readFolder = async (client, config) => {
|
|
165
|
+
let nodeId = config.folder.nodeId;
|
|
166
|
+
if (!nodeId) nodeId = (await findTopLevelFolder(client, config))?.id;
|
|
167
|
+
if (!nodeId) return null;
|
|
168
|
+
try {
|
|
169
|
+
return await client.nodes.get({
|
|
170
|
+
nodeId,
|
|
171
|
+
type: "folder"
|
|
172
|
+
});
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (isNotFound(error) && config.folder.nodeId) {
|
|
175
|
+
const discovered = await findTopLevelFolder(client, config);
|
|
176
|
+
return discovered ? await client.nodes.get({
|
|
177
|
+
nodeId: discovered.id,
|
|
178
|
+
type: "folder"
|
|
179
|
+
}) : null;
|
|
180
|
+
}
|
|
181
|
+
if (isNotFound(error)) return null;
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
184
|
};
|
|
185
|
+
/** Read the current state of this app's declared resources. Never mutates. */
|
|
185
186
|
async function inspectProvisionedResources(client, config) {
|
|
186
|
-
|
|
187
|
+
return resolveProvisionedFolder(await readFolder(client, config), config);
|
|
187
188
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
189
|
+
const provisionStates = /* @__PURE__ */ new WeakMap();
|
|
190
|
+
const stateFor = (client, appId) => {
|
|
191
|
+
let byApp = provisionStates.get(client);
|
|
192
|
+
if (!byApp) {
|
|
193
|
+
byApp = /* @__PURE__ */ new Map();
|
|
194
|
+
provisionStates.set(client, byApp);
|
|
195
|
+
}
|
|
196
|
+
let state = byApp.get(appId);
|
|
197
|
+
if (!state) {
|
|
198
|
+
state = {
|
|
199
|
+
inFlight: null,
|
|
200
|
+
metadataUpdatesSupported: void 0
|
|
201
|
+
};
|
|
202
|
+
byApp.set(appId, state);
|
|
203
|
+
}
|
|
204
|
+
return state;
|
|
201
205
|
};
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
206
|
+
const sameFieldName = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected);
|
|
207
|
+
const fieldMatches = (actual, expected) => actual?.slug === expected.slug && actual?.type === expected.type && actual?.required === expected.required && sameFieldName(actual?.name, expected.name);
|
|
208
|
+
/**
|
|
209
|
+
* How an app evolves a Base it already owns: the declared field list may grow,
|
|
210
|
+
* and only at the end.
|
|
211
|
+
*
|
|
212
|
+
* A live Base whose fields are a strict *prefix* of the declaration is an older
|
|
213
|
+
* schema of ours, and the missing suffix is added. Anything else — a field
|
|
214
|
+
* renamed, retyped, reordered, or removed — is not an upgrade this can reason
|
|
215
|
+
* about, so it refuses rather than guessing which of the two shapes is right.
|
|
216
|
+
*
|
|
217
|
+
* @throws {AirAppSetupError} `SETUP_CONFLICT` when the existing fields are not a
|
|
218
|
+
* prefix of the declared ones.
|
|
219
|
+
*/
|
|
220
|
+
const additiveFieldsFor = (actual, expected) => {
|
|
221
|
+
const fields = actual?.fields ?? [];
|
|
222
|
+
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`);
|
|
223
|
+
return expected.fields.slice(fields.length);
|
|
213
224
|
};
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
);
|
|
225
|
-
}
|
|
225
|
+
/**
|
|
226
|
+
* Before re-stamping an unstamped Base as ours, prove it is structurally the
|
|
227
|
+
* Base we declared — same slug, name, description, and exact field list in
|
|
228
|
+
* order. Without this, a stamp would launder a name collision into ownership.
|
|
229
|
+
*/
|
|
230
|
+
const validateRepairBase = (actual, expected, nodeId) => {
|
|
231
|
+
if (!expected) throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
|
|
232
|
+
const fields = actual?.fields ?? [];
|
|
233
|
+
const exactFields = fields.length === expected.fields.length && fields.every((field, index) => fieldMatches(field, expected.fields[index]));
|
|
234
|
+
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
235
|
};
|
|
227
236
|
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;
|
|
237
|
+
if (!current.repairs.length) return current;
|
|
238
|
+
const state = stateFor(client, config.appId);
|
|
239
|
+
const baseRepairs = current.repairs.filter((repair) => repair.baseId);
|
|
240
|
+
const baseByKey = new Map(config.bases.map((base) => [base.key, base]));
|
|
241
|
+
const details = await Promise.all(baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId })));
|
|
242
|
+
const migrations = details.map((detail, index) => {
|
|
243
|
+
const repair = baseRepairs[index];
|
|
244
|
+
const expected = baseByKey.get(repair.resourceKey);
|
|
245
|
+
if (!expected) throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
|
|
246
|
+
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`);
|
|
247
|
+
return {
|
|
248
|
+
repair,
|
|
249
|
+
expected,
|
|
250
|
+
fields: additiveFieldsFor(detail, expected)
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
const pendingFieldRequests = [];
|
|
254
|
+
for (const migration of migrations) for (const field of migration.fields) {
|
|
255
|
+
const changeRequest = await client.bases.fieldChangeRequest({
|
|
256
|
+
operation: "create",
|
|
257
|
+
baseId: migration.repair.baseId,
|
|
258
|
+
slug: field.slug,
|
|
259
|
+
name: field.name,
|
|
260
|
+
type: field.type,
|
|
261
|
+
required: field.required,
|
|
262
|
+
message: `Upgrade ${config.appName}: add ${field.slug}`,
|
|
263
|
+
submittedBy: config.appId
|
|
264
|
+
});
|
|
265
|
+
if (!(changeRequest?.status === "merged" || changeRequest?.materialized === true)) pendingFieldRequests.push(changeRequest?.id ?? field.slug);
|
|
266
|
+
}
|
|
267
|
+
if (pendingFieldRequests.length) throw setupError("SETUP_PENDING", `Submitted ${pendingFieldRequests.length} field upgrade request(s) awaiting Space admin approval: ${pendingFieldRequests.join(", ")}`);
|
|
268
|
+
(migrations.some((migration) => migration.fields.length) ? await Promise.all(baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId }))) : details).forEach((detail, index) => {
|
|
269
|
+
const repair = baseRepairs[index];
|
|
270
|
+
validateRepairBase(detail, baseByKey.get(repair.resourceKey), repair.nodeId);
|
|
271
|
+
});
|
|
272
|
+
if (state.metadataUpdatesSupported === false) return {
|
|
273
|
+
...current,
|
|
274
|
+
repairs: [],
|
|
275
|
+
compatibilityMode: "verified-legacy-fingerprint"
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
for (const repair of current.repairs) {
|
|
279
|
+
await client.nodes.updateMetadata({
|
|
280
|
+
nodeId: repair.nodeId,
|
|
281
|
+
metadata: repair.metadata
|
|
282
|
+
});
|
|
283
|
+
state.metadataUpdatesSupported = true;
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (isNotFound(error)) {
|
|
287
|
+
state.metadataUpdatesSupported = false;
|
|
288
|
+
return {
|
|
289
|
+
...current,
|
|
290
|
+
repairs: [],
|
|
291
|
+
compatibilityMode: "verified-legacy-fingerprint"
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (isForbidden(error)) throw setupError("SETUP_PERMISSION", "This account may not repair resource ownership metadata for this app");
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
const repaired = await inspectProvisionedResources(client, config);
|
|
298
|
+
if (repaired.repairs.length) throw setupError("SCHEMA_INCOMPLETE", "Ownership was repaired but read back incomplete");
|
|
299
|
+
return repaired;
|
|
307
300
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
301
|
+
/**
|
|
302
|
+
* A merged ChangeRequest is not immediately readable — materialization is
|
|
303
|
+
* asynchronous — so poll briefly rather than reporting a successful setup as
|
|
304
|
+
* incomplete.
|
|
305
|
+
*/
|
|
306
|
+
const waitForMaterializedResources = async (client, config, attempts = 20) => {
|
|
307
|
+
let current;
|
|
308
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
309
|
+
current = await inspectProvisionedResources(client, config);
|
|
310
|
+
current = await repairResourceOwnership(client, config, current);
|
|
311
|
+
if (current.folder && current.missing.length === 0) return current;
|
|
312
|
+
if (attempt < attempts - 1) await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
|
|
313
|
+
}
|
|
314
|
+
throw setupError("SCHEMA_INCOMPLETE", "Initialization merged but the resources read back incomplete");
|
|
322
315
|
};
|
|
323
316
|
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);
|
|
317
|
+
let current = await inspectProvisionedResources(client, config);
|
|
318
|
+
current = await repairResourceOwnership(client, config, current);
|
|
319
|
+
if (current.folder && current.missing.length === 0) return current;
|
|
320
|
+
const operations = buildProvisionOperations(config, current.folder, current.missing);
|
|
321
|
+
let changeRequest;
|
|
322
|
+
try {
|
|
323
|
+
changeRequest = await client.nodes.createChangeRequest({
|
|
324
|
+
message: `Initialize ${config.appName} workspace`,
|
|
325
|
+
submittedBy: config.appId,
|
|
326
|
+
autoMerge: true,
|
|
327
|
+
operations
|
|
328
|
+
});
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (isForbidden(error)) throw setupError("SETUP_PERMISSION", "This account may not create this app's resources in this Space");
|
|
331
|
+
const concurrent = await inspectProvisionedResources(client, config).catch(() => null);
|
|
332
|
+
if (concurrent?.folder && concurrent.missing.length === 0) return concurrent;
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
if (changeRequest?.status !== "merged") throw setupError("SETUP_PENDING", `Initialization request ${changeRequest?.id ?? ""} was submitted and awaits Space admin approval`.trim());
|
|
336
|
+
return waitForMaterializedResources(client, config);
|
|
354
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Ensure the declared Folder and Bases exist, as one idempotent ChangeRequest.
|
|
340
|
+
*
|
|
341
|
+
* Safe to call concurrently: calls for the same client + `appId` share one
|
|
342
|
+
* in-flight promise, so a multi-pane app cannot submit the structure twice.
|
|
343
|
+
*
|
|
344
|
+
* @throws {AirAppSetupError} with a `code` describing which screen to show.
|
|
345
|
+
*/
|
|
355
346
|
function provisionDeclaredResources(client, config) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
return state.inFlight;
|
|
347
|
+
const state = stateFor(client, config.appId);
|
|
348
|
+
if (!state.inFlight) state.inFlight = provisionOnce(client, config).finally(() => {
|
|
349
|
+
state.inFlight = null;
|
|
350
|
+
});
|
|
351
|
+
return state.inFlight;
|
|
363
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* The create-vs-update operation list for one AirApp publish. Pure — no I/O —
|
|
355
|
+
* so the decision is directly testable: a local path already present on the
|
|
356
|
+
* deployed node updates it, anything else is a new file. Never deletes a
|
|
357
|
+
* remote-only path — a file this bundle stopped shipping is left alone rather
|
|
358
|
+
* than assumed stale, the same conservative choice the rest of this module
|
|
359
|
+
* makes for a Base's fields (`additiveFieldsFor` only ever appends).
|
|
360
|
+
*/
|
|
364
361
|
function buildAirAppFileOperations(localFiles, deployedPaths) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
})
|
|
373
|
-
);
|
|
362
|
+
const deployed = new Set(deployedPaths);
|
|
363
|
+
return localFiles.map((file) => ({
|
|
364
|
+
kind: deployed.has(file.path) ? "update" : "create",
|
|
365
|
+
path: file.path,
|
|
366
|
+
content: file.content,
|
|
367
|
+
...file.mimeType ? { mimeType: file.mimeType } : {}
|
|
368
|
+
}));
|
|
374
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Find a still-pending ChangeRequest that already proposes creating this
|
|
372
|
+
* declared AirApp, so a second `publishAirApp` call before the first is
|
|
373
|
+
* reviewed does not propose a second, duplicate create for the same slug.
|
|
374
|
+
*
|
|
375
|
+
* Scoped to the first 500 in-review ChangeRequests (10 pages of 50) — a
|
|
376
|
+
* Space with more open reviews than that has bigger problems than this
|
|
377
|
+
* scan not reaching the one it is looking for, and a missed match only
|
|
378
|
+
* costs an extra (harmless, reviewer-visible) duplicate proposal, never a
|
|
379
|
+
* wrong merge.
|
|
380
|
+
*/
|
|
375
381
|
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;
|
|
382
|
+
let cursor;
|
|
383
|
+
for (let page = 0; page < 10; page += 1) {
|
|
384
|
+
const result = await client.changeRequests.list({
|
|
385
|
+
status: ["in_review"],
|
|
386
|
+
...cursor ? { cursor } : {}
|
|
387
|
+
});
|
|
388
|
+
for (const changeRequest of result.changeRequests) if ((changeRequest.operations ?? []).some((operation) => {
|
|
389
|
+
const payload = operation.headCommit?.payload;
|
|
390
|
+
return payload?.kind === "create" && payload?.nodeType === "airapp" && payload?.slug === slug;
|
|
391
|
+
})) return changeRequest.id;
|
|
392
|
+
if (!result.nextCursor) return null;
|
|
393
|
+
cursor = result.nextCursor;
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
393
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* Publish the app's own AirApp bundle: create it under the Folder when this
|
|
399
|
+
* Space has never had it, or propose the local files as an update when it
|
|
400
|
+
* already exists. Always a separate, always-review-first ChangeRequest from
|
|
401
|
+
* the data layer's `provisionDeclaredResources` — see the note on
|
|
402
|
+
* `AirAppNodeDeclaration` for why the two must never share a request.
|
|
403
|
+
*
|
|
404
|
+
* Call after `provisionDeclaredResources` has confirmed the Folder exists.
|
|
405
|
+
* Every call proposes the full local file list, even when nothing actually
|
|
406
|
+
* changed — this module has no access to the deployed content hashes
|
|
407
|
+
* (`fileTrees.listFiles` reports paths, not hashes; only a per-file
|
|
408
|
+
* `readFile` does, and fetching one per file to skip a no-op publish is not
|
|
409
|
+
* worth the round trips a normal publish cadence would spend on it). A
|
|
410
|
+
* reviewer sees an empty diff and merges or ignores it; this is a cost in
|
|
411
|
+
* review noise, not correctness.
|
|
412
|
+
*
|
|
413
|
+
* @throws {AirAppSetupError} `SETUP_CONFLICT` when `config` declares no
|
|
414
|
+
* `airApp`; `SETUP_REQUIRED` when the Folder does not exist yet.
|
|
415
|
+
*/
|
|
394
416
|
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 };
|
|
417
|
+
const airApp = config.airApp;
|
|
418
|
+
if (!airApp) throw setupError("SETUP_CONFLICT", "This app does not declare an airApp to publish");
|
|
419
|
+
const current = await inspectProvisionedResources(client, config);
|
|
420
|
+
if (!current.folder) throw setupError("SETUP_REQUIRED", "Provision the Folder and Bases with provisionDeclaredResources before publishing the AirApp");
|
|
421
|
+
if (!current.airApp) {
|
|
422
|
+
const pendingChangeRequestId = await findPendingAirAppCreate(client, airApp.slug);
|
|
423
|
+
if (pendingChangeRequestId) return {
|
|
424
|
+
status: "pending",
|
|
425
|
+
changeRequestId: pendingChangeRequestId
|
|
426
|
+
};
|
|
427
|
+
const changeRequest = await client.fileTrees.create({
|
|
428
|
+
type: "airapp",
|
|
429
|
+
parentNodeId: current.folder.nodeId,
|
|
430
|
+
slug: airApp.slug,
|
|
431
|
+
name: airApp.name,
|
|
432
|
+
description: airApp.description ?? "",
|
|
433
|
+
files,
|
|
434
|
+
mergeMode: "replace",
|
|
435
|
+
autoMerge: false
|
|
436
|
+
});
|
|
437
|
+
if (changeRequest.materialized) throw setupError("SCHEMA_INCOMPLETE", "AirApp create unexpectedly materialized despite autoMerge: false");
|
|
438
|
+
return {
|
|
439
|
+
status: "created",
|
|
440
|
+
changeRequestId: changeRequest.id
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const operations = buildAirAppFileOperations(files, (await client.fileTrees.listFiles({
|
|
444
|
+
nodeId: current.airApp.nodeId,
|
|
445
|
+
type: "airapp"
|
|
446
|
+
})).map((file) => file.path));
|
|
447
|
+
return {
|
|
448
|
+
status: "updated",
|
|
449
|
+
changeRequestId: (await client.fileTrees.createChangeRequest({
|
|
450
|
+
nodeId: current.airApp.nodeId,
|
|
451
|
+
type: "airapp",
|
|
452
|
+
operations,
|
|
453
|
+
message: `Publish ${config.appName} AirApp`,
|
|
454
|
+
submittedBy: config.appId,
|
|
455
|
+
autoMerge: false
|
|
456
|
+
})).id
|
|
457
|
+
};
|
|
449
458
|
}
|
|
450
|
-
|
|
459
|
+
//#endregion
|
|
451
460
|
export { AirAppSetupError, buildAirAppFileOperations, buildProvisionOperations, inspectProvisionedResources, isNotFound, provisionDeclaredResources, publishAirApp, resolveProvisionedFolder };
|