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.js CHANGED
@@ -1,451 +1,460 @@
1
- // src/airapp.ts
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
- code;
4
- /** The human-readable half, without the `CODE: ` prefix. */
5
- detail;
6
- constructor(code, detail) {
7
- super(`${code}: ${detail}`);
8
- this.name = "AirAppSetupError";
9
- this.code = code;
10
- this.detail = detail;
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
- var setupError = (code, detail) => new AirAppSetupError(code, detail);
14
- var isNotFound = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "NOT_FOUND" || "status" in error && error.status === 404);
15
- var isForbidden = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "FORBIDDEN" || "status" in error && error.status === 403);
16
- var ownsResource = (node, appId, resourceKey, schemaVersion) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey && node?.metadata?.schemaVersion === schemaVersion;
17
- var hasResourceIdentity = (node, appId, resourceKey) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey;
18
- var ownsAppRoot = (node, appId, schemaVersion) => hasResourceIdentity(node, appId, "app-root") && node?.metadata?.schemaVersion === schemaVersion;
19
- var hasEmptyMetadata = (node) => Object.keys(node?.metadata ?? {}).length === 0;
20
- var isUnclaimed = (node) => node?.metadata?.appId === void 0;
21
- var matchesDeclaration = (node, declaration, type) => node?.type === type && node?.slug === declaration.slug && node?.name === declaration.name && node?.description === (declaration.description ?? "");
22
- var matchesLegacyAirApp = (node, config) => isUnclaimed(node) && node?.type === "airapp" && node?.slug === config.airApp?.slug && node?.name === config.airApp?.name;
23
- var resourceMetadata = (config, resourceKey) => ({
24
- appId: config.appId,
25
- resourceKey,
26
- schemaVersion: config.schemaVersion
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
- if (!folder) {
30
- return { folder: null, bases: [], missing: [...config.bases], repairs: [], airApp: null };
31
- }
32
- if (folder.node?.type !== "folder" || folder.node?.slug !== config.folder.slug) {
33
- throw setupError(
34
- "SETUP_CONFLICT",
35
- `A different Folder already uses the slug ${config.folder.slug}; nothing was changed`
36
- );
37
- }
38
- const rootOwned = hasResourceIdentity(folder.node, config.appId, "app-root");
39
- const legacyRoot = hasEmptyMetadata(folder.node) && matchesDeclaration(folder.node, config.folder, "folder");
40
- if (!rootOwned && !legacyRoot) {
41
- throw setupError(
42
- "SETUP_CONFLICT",
43
- `The Folder ${config.folder.slug} does not belong to this app; nothing was changed`
44
- );
45
- }
46
- const bases = [];
47
- const missing = [];
48
- const repairs = [];
49
- if (!ownsAppRoot(folder.node, config.appId, config.schemaVersion)) {
50
- repairs.push({
51
- nodeId: folder.node.id,
52
- resourceKey: "app-root",
53
- metadata: resourceMetadata(config, "app-root")
54
- });
55
- }
56
- for (const base of config.bases) {
57
- const matches = (folder.children ?? []).filter((node2) => node2.slug === base.slug);
58
- if (!matches.length) {
59
- if (legacyRoot) {
60
- throw setupError(
61
- "SETUP_CONFLICT",
62
- `The existing unstamped Folder is missing the resource ${base.slug}, so it cannot be claimed safely`
63
- );
64
- }
65
- missing.push(base);
66
- continue;
67
- }
68
- const node = matches[0];
69
- if (matches.length !== 1 || node.type !== "base" || !node.baseId) {
70
- throw setupError(
71
- "SETUP_CONFLICT",
72
- `The resource ${base.slug} does not match this app's declaration; nothing was changed`
73
- );
74
- }
75
- const owned = hasResourceIdentity(node, config.appId, base.key);
76
- const legacy = hasEmptyMetadata(node) && matchesDeclaration(node, base, "base");
77
- if (!owned && !legacy) {
78
- throw setupError(
79
- "SETUP_CONFLICT",
80
- `The resource ${base.slug} does not match this app's declaration; nothing was changed`
81
- );
82
- }
83
- if (!ownsResource(node, config.appId, base.key, config.schemaVersion)) {
84
- repairs.push({
85
- nodeId: node.id,
86
- baseId: node.baseId,
87
- resourceKey: base.key,
88
- metadata: resourceMetadata(config, base.key)
89
- });
90
- }
91
- bases.push({ ...base, nodeId: node.id, baseId: node.baseId });
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
- const operations = [];
129
- if (!folder) {
130
- operations.push({
131
- kind: "create",
132
- ref: "app-root",
133
- nodeType: "folder",
134
- slug: config.folder.slug,
135
- name: config.folder.name,
136
- description: config.folder.description ?? "",
137
- metadata: resourceMetadata(config, "app-root")
138
- });
139
- }
140
- for (const base of missingBases) {
141
- operations.push({
142
- kind: "create",
143
- ...folder ? { parentNodeId: folder.nodeId } : { parentNodeRef: "app-root" },
144
- nodeType: "base",
145
- slug: base.slug,
146
- name: base.name,
147
- description: base.description ?? "",
148
- metadata: resourceMetadata(config, base.key),
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
- var findTopLevelFolder = async (client, config) => {
157
- const roots = await client.nodes.list({ parentId: null, depth: 2 });
158
- const candidates = (roots ?? []).flatMap((node) => [node, ...node.children ?? []]).filter((node) => node.type === "folder" && node.slug === config.folder.slug);
159
- if (candidates.length > 1) {
160
- throw setupError(
161
- "SETUP_CONFLICT",
162
- `Found more than one Folder with the slug ${config.folder.slug}; nothing was changed`
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
- var readFolder = async (client, config) => {
168
- let nodeId = config.folder.nodeId;
169
- if (!nodeId) nodeId = (await findTopLevelFolder(client, config))?.id;
170
- if (!nodeId) return null;
171
- try {
172
- return await client.nodes.get({ nodeId, type: "folder" });
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
- }
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
- return resolveProvisionedFolder(await readFolder(client, config), config);
187
+ return resolveProvisionedFolder(await readFolder(client, config), config);
187
188
  }
188
- var provisionStates = /* @__PURE__ */ new WeakMap();
189
- var stateFor = (client, appId) => {
190
- let byApp = provisionStates.get(client);
191
- if (!byApp) {
192
- byApp = /* @__PURE__ */ new Map();
193
- provisionStates.set(client, byApp);
194
- }
195
- let state = byApp.get(appId);
196
- if (!state) {
197
- state = { inFlight: null, metadataUpdatesSupported: void 0 };
198
- byApp.set(appId, state);
199
- }
200
- return state;
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
- var sameFieldName = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected);
203
- var fieldMatches = (actual, expected) => actual?.slug === expected.slug && actual?.type === expected.type && actual?.required === expected.required && sameFieldName(actual?.name, expected.name);
204
- var additiveFieldsFor = (actual, expected) => {
205
- const fields = actual?.fields ?? [];
206
- if (fields.length > expected.fields.length || !fields.every((field, index) => fieldMatches(field, expected.fields[index]))) {
207
- throw setupError(
208
- "SETUP_CONFLICT",
209
- `The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`
210
- );
211
- }
212
- return expected.fields.slice(fields.length);
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
- var validateRepairBase = (actual, expected, nodeId) => {
215
- if (!expected) {
216
- throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
217
- }
218
- const fields = actual?.fields ?? [];
219
- const exactFields = fields.length === expected.fields.length && fields.every((field, index) => fieldMatches(field, expected.fields[index]));
220
- if (actual?.nodeId !== nodeId || actual?.slug !== expected.slug || actual?.name !== expected.name || actual?.description !== (expected.description ?? "") || !exactFields) {
221
- throw setupError(
222
- "SETUP_CONFLICT",
223
- `The structure of ${expected.slug} does not match this app's declaration, so it cannot be claimed safely`
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
- if (!current.repairs.length) return current;
229
- const state = stateFor(client, config.appId);
230
- const baseRepairs = current.repairs.filter((repair) => repair.baseId);
231
- const baseByKey = new Map(config.bases.map((base) => [base.key, base]));
232
- const details = await Promise.all(
233
- baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId }))
234
- );
235
- const migrations = details.map((detail, index) => {
236
- const repair = baseRepairs[index];
237
- const expected = baseByKey.get(repair.resourceKey);
238
- if (!expected) {
239
- throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
240
- }
241
- if (detail?.nodeId !== repair.nodeId || detail?.slug !== expected.slug || detail?.name !== expected.name || detail?.description !== (expected.description ?? "")) {
242
- throw setupError(
243
- "SETUP_CONFLICT",
244
- `The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`
245
- );
246
- }
247
- return { repair, expected, fields: additiveFieldsFor(detail, expected) };
248
- });
249
- const pendingFieldRequests = [];
250
- for (const migration of migrations) {
251
- for (const field of migration.fields) {
252
- const changeRequest = await client.bases.fieldChangeRequest({
253
- operation: "create",
254
- baseId: migration.repair.baseId,
255
- slug: field.slug,
256
- name: field.name,
257
- // The declaration's `type` is a plain `string` (see AirAppFieldDeclaration);
258
- // the server validates the real field-type enum on the wire.
259
- type: field.type,
260
- required: field.required,
261
- message: `Upgrade ${config.appName}: add ${field.slug}`,
262
- submittedBy: config.appId
263
- });
264
- const merged = changeRequest?.status === "merged" || changeRequest?.materialized === true;
265
- if (!merged) pendingFieldRequests.push(changeRequest?.id ?? field.slug);
266
- }
267
- }
268
- if (pendingFieldRequests.length) {
269
- throw setupError(
270
- "SETUP_PENDING",
271
- `Submitted ${pendingFieldRequests.length} field upgrade request(s) awaiting Space admin approval: ${pendingFieldRequests.join(", ")}`
272
- );
273
- }
274
- const verified = migrations.some((migration) => migration.fields.length) ? await Promise.all(
275
- baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId }))
276
- ) : details;
277
- verified.forEach((detail, index) => {
278
- const repair = baseRepairs[index];
279
- validateRepairBase(detail, baseByKey.get(repair.resourceKey), repair.nodeId);
280
- });
281
- if (state.metadataUpdatesSupported === false) {
282
- return { ...current, repairs: [], compatibilityMode: "verified-legacy-fingerprint" };
283
- }
284
- try {
285
- for (const repair of current.repairs) {
286
- await client.nodes.updateMetadata({ nodeId: repair.nodeId, metadata: repair.metadata });
287
- state.metadataUpdatesSupported = true;
288
- }
289
- } catch (error) {
290
- if (isNotFound(error)) {
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
- var waitForMaterializedResources = async (client, config, attempts = 20) => {
309
- let current;
310
- for (let attempt = 0; attempt < attempts; attempt += 1) {
311
- current = await inspectProvisionedResources(client, config);
312
- current = await repairResourceOwnership(client, config, current);
313
- if (current.folder && current.missing.length === 0) return current;
314
- if (attempt < attempts - 1) {
315
- await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
316
- }
317
- }
318
- throw setupError(
319
- "SCHEMA_INCOMPLETE",
320
- "Initialization merged but the resources read back incomplete"
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
- let current = await inspectProvisionedResources(client, config);
325
- current = await repairResourceOwnership(client, config, current);
326
- if (current.folder && current.missing.length === 0) return current;
327
- const operations = buildProvisionOperations(config, current.folder, current.missing);
328
- let changeRequest;
329
- try {
330
- changeRequest = await client.nodes.createChangeRequest({
331
- message: `Initialize ${config.appName} workspace`,
332
- submittedBy: config.appId,
333
- autoMerge: true,
334
- operations
335
- });
336
- } catch (error) {
337
- if (isForbidden(error)) {
338
- throw setupError(
339
- "SETUP_PERMISSION",
340
- "This account may not create this app's resources in this Space"
341
- );
342
- }
343
- const concurrent = await inspectProvisionedResources(client, config).catch(() => null);
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
- const state = stateFor(client, config.appId);
357
- if (!state.inFlight) {
358
- state.inFlight = provisionOnce(client, config).finally(() => {
359
- state.inFlight = null;
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
- const deployed = new Set(deployedPaths);
366
- return localFiles.map(
367
- (file) => ({
368
- kind: deployed.has(file.path) ? "update" : "create",
369
- path: file.path,
370
- content: file.content,
371
- ...file.mimeType ? { mimeType: file.mimeType } : {}
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
- let cursor;
377
- for (let page = 0; page < 10; page += 1) {
378
- const result = await client.changeRequests.list({
379
- status: ["in_review"],
380
- ...cursor ? { cursor } : {}
381
- });
382
- for (const changeRequest of result.changeRequests) {
383
- const matches = (changeRequest.operations ?? []).some((operation) => {
384
- const payload = operation.headCommit?.payload;
385
- return payload?.kind === "create" && payload?.nodeType === "airapp" && payload?.slug === slug;
386
- });
387
- if (matches) return changeRequest.id;
388
- }
389
- if (!result.nextCursor) return null;
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
- const airApp = config.airApp;
396
- if (!airApp) {
397
- throw setupError("SETUP_CONFLICT", "This app does not declare an airApp to publish");
398
- }
399
- const current = await inspectProvisionedResources(client, config);
400
- if (!current.folder) {
401
- throw setupError(
402
- "SETUP_REQUIRED",
403
- "Provision the Folder and Bases with provisionDeclaredResources before publishing the AirApp"
404
- );
405
- }
406
- if (!current.airApp) {
407
- const pendingChangeRequestId = await findPendingAirAppCreate(client, airApp.slug);
408
- if (pendingChangeRequestId) {
409
- return { status: "pending", changeRequestId: pendingChangeRequestId };
410
- }
411
- const changeRequest2 = await client.fileTrees.create({
412
- type: "airapp",
413
- parentNodeId: current.folder.nodeId,
414
- slug: airApp.slug,
415
- name: airApp.name,
416
- description: airApp.description ?? "",
417
- files,
418
- mergeMode: "replace",
419
- // Explicit even though this app's write-permission credential would
420
- // otherwise auto-merge it: executable AirApp code always gets human
421
- // review before it runs in a viewer's browser, no exceptions.
422
- autoMerge: false
423
- });
424
- if (changeRequest2.materialized) {
425
- throw setupError(
426
- "SCHEMA_INCOMPLETE",
427
- "AirApp create unexpectedly materialized despite autoMerge: false"
428
- );
429
- }
430
- return { status: "created", changeRequestId: changeRequest2.id };
431
- }
432
- const deployedFiles = await client.fileTrees.listFiles({
433
- nodeId: current.airApp.nodeId,
434
- type: "airapp"
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 };