busa-sdk 0.14.1 → 0.16.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.css +296 -0
- package/dist/airapp-gate.d.ts +157 -0
- package/dist/airapp-gate.js +238 -0
- package/dist/airapp-node.d.ts +66 -0
- package/dist/airapp-node.js +439 -0
- package/dist/airapp.d.ts +222 -0
- package/dist/airapp.js +359 -0
- package/dist/chunk-C23JVY2Y.js +211 -0
- package/dist/{chunk-J2DZKX7A.js → chunk-WSHJMHUS.js} +42 -2
- package/dist/client-DB7fREZX.d.ts +17112 -0
- package/dist/index.d.ts +4 -16983
- package/dist/index.js +104 -0
- package/dist/oauth-node.d.ts +32 -1
- package/dist/oauth-node.js +3 -147
- package/dist/oauth.d.ts +13 -1
- package/dist/oauth.js +1 -1
- package/package.json +15 -2
package/dist/airapp.d.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { B as BusabaseClient } from './client-DB7fREZX.js';
|
|
2
|
+
import '@orpc/contract';
|
|
3
|
+
import '@orpc/shared';
|
|
4
|
+
import 'zod';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AirApp resource provisioning — how an app claims (or creates) the Folder and
|
|
8
|
+
* Bases it declares, exactly once, without ever taking over someone else's.
|
|
9
|
+
*
|
|
10
|
+
* Every App-in-Skill shipped a byte-identical copy of this module (280 lines ×
|
|
11
|
+
* 65 apps, two spellings). That is the wrong place for it: the rules encoded
|
|
12
|
+
* here are not app preferences, they are the safety boundary that keeps an app
|
|
13
|
+
* from adopting a Folder a human created for something else. A third party
|
|
14
|
+
* re-deriving them from scratch gets the happy path right and the conflict
|
|
15
|
+
* cases wrong, and the failure is silent — the app happily writes into data it
|
|
16
|
+
* does not own.
|
|
17
|
+
*
|
|
18
|
+
* The contract, in one line: **an app owns a node only if it stamped it.**
|
|
19
|
+
* Ownership lives in `node.metadata` as `{ appId, resourceKey, schemaVersion }`.
|
|
20
|
+
* Anything else is either a legacy node this app plausibly created before
|
|
21
|
+
* stamping existed (claimable *only* after a full structural fingerprint match)
|
|
22
|
+
* or someone else's (never touched, always a `SETUP_CONFLICT`).
|
|
23
|
+
*
|
|
24
|
+
* This module is isomorphic — browser and Node both — and holds no I/O beyond
|
|
25
|
+
* the passed-in client.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { createBusabaseClient } from "busabase-sdk";
|
|
30
|
+
* import { inspectProvisionedResources, provisionDeclaredResources } from "busabase-sdk/airapp";
|
|
31
|
+
*
|
|
32
|
+
* const client = createBusabaseClient({ baseUrl: window.location.origin });
|
|
33
|
+
* const config = {
|
|
34
|
+
* appId: "kelly-crm",
|
|
35
|
+
* appName: "Kelly CRM",
|
|
36
|
+
* schemaVersion: 1,
|
|
37
|
+
* folder: { slug: "kelly-crm", name: "Kelly CRM", description: "CRM workspace" },
|
|
38
|
+
* bases: [{ key: "contacts", slug: "kelly-crm-contacts-v1", name: "Contacts", fields: [...] }],
|
|
39
|
+
* };
|
|
40
|
+
*
|
|
41
|
+
* let resources = await inspectProvisionedResources(client, config);
|
|
42
|
+
* if (!resources.folder || resources.missing.length) {
|
|
43
|
+
* resources = await provisionDeclaredResources(client, config); // one idempotent ChangeRequest
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
type NodeChangeRequestInput = Parameters<BusabaseClient["nodes"]["createChangeRequest"]>[0];
|
|
49
|
+
type NodeOperationInput = NodeChangeRequestInput["operations"][number];
|
|
50
|
+
type CreateNodeOperationInput = Extract<NodeOperationInput, {
|
|
51
|
+
kind: "create";
|
|
52
|
+
}>;
|
|
53
|
+
/**
|
|
54
|
+
* A Base field, typed straight off the contract so a declaration can never drift
|
|
55
|
+
* from what `nodes.createChangeRequest` actually accepts.
|
|
56
|
+
*/
|
|
57
|
+
type AirAppFieldDeclaration = NonNullable<CreateNodeOperationInput["fields"]>[number];
|
|
58
|
+
/** The client surface provisioning needs. Both `BusabaseClient` and `Busabase` satisfy it. */
|
|
59
|
+
type AirAppProvisioningClient = Pick<BusabaseClient, "nodes" | "bases">;
|
|
60
|
+
/** One Base the app declares it needs. `key` is the app's stable internal handle. */
|
|
61
|
+
interface AirAppBaseDeclaration {
|
|
62
|
+
/** Stable internal handle, e.g. `"contacts"`. Also the node's `resourceKey`. */
|
|
63
|
+
key: string;
|
|
64
|
+
slug: string;
|
|
65
|
+
name: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
fields: AirAppFieldDeclaration[];
|
|
68
|
+
/** Resolved at provision time; ignore when declaring. */
|
|
69
|
+
nodeId?: string;
|
|
70
|
+
/** Resolved at provision time; ignore when declaring. */
|
|
71
|
+
baseId?: string;
|
|
72
|
+
/** Free-form extras an app keeps alongside its declaration (e.g. `readLimit`). */
|
|
73
|
+
[extra: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
interface AirAppFolderDeclaration {
|
|
76
|
+
slug: string;
|
|
77
|
+
name: string;
|
|
78
|
+
description?: string;
|
|
79
|
+
/** Pin the Folder by id once known; otherwise it is discovered by slug. */
|
|
80
|
+
nodeId?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The app's own AirApp node, when it ships one inside its Folder.
|
|
84
|
+
*
|
|
85
|
+
* It is provisioned by publishing the AirApp, not by this module — so it is
|
|
86
|
+
* never created here, only recognized and stamped. Declaring it matters for a
|
|
87
|
+
* second reason: without it, an unstamped Folder holding the app's own AirApp
|
|
88
|
+
* would look like it holds an unattributable stranger, and the legacy claim
|
|
89
|
+
* would be refused.
|
|
90
|
+
*/
|
|
91
|
+
interface AirAppNodeDeclaration {
|
|
92
|
+
slug: string;
|
|
93
|
+
name: string;
|
|
94
|
+
/** Ownership key written into the node's metadata, e.g. `"airapp"`. */
|
|
95
|
+
resourceKey: string;
|
|
96
|
+
}
|
|
97
|
+
/** Everything an app declares about the workspace shape it needs. */
|
|
98
|
+
interface AirAppResourceConfig {
|
|
99
|
+
appId: string;
|
|
100
|
+
appName: string;
|
|
101
|
+
/**
|
|
102
|
+
* Bump when the declared shape changes. A node stamped with an older version
|
|
103
|
+
* is re-stamped (a "repair"), not recreated — the data survives.
|
|
104
|
+
*/
|
|
105
|
+
schemaVersion: number;
|
|
106
|
+
folder: AirAppFolderDeclaration;
|
|
107
|
+
bases: AirAppBaseDeclaration[];
|
|
108
|
+
/** The app's own AirApp node inside the Folder, when it ships one. */
|
|
109
|
+
airApp?: AirAppNodeDeclaration;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The ownership stamp written into `node.metadata`.
|
|
113
|
+
*
|
|
114
|
+
* A type alias rather than an `interface` on purpose: `nodes.updateMetadata`
|
|
115
|
+
* and the create operations take `Record<string, unknown>`, and an interface —
|
|
116
|
+
* being open to declaration merging — is not assignable to an index signature.
|
|
117
|
+
*/
|
|
118
|
+
type AirAppResourceOwnership = {
|
|
119
|
+
appId: string;
|
|
120
|
+
resourceKey: string;
|
|
121
|
+
schemaVersion: number;
|
|
122
|
+
};
|
|
123
|
+
/** A node this app owns but whose ownership stamp needs (re)writing. */
|
|
124
|
+
interface AirAppOwnershipRepair {
|
|
125
|
+
nodeId: string;
|
|
126
|
+
baseId?: string;
|
|
127
|
+
resourceKey: string;
|
|
128
|
+
metadata: AirAppResourceOwnership;
|
|
129
|
+
}
|
|
130
|
+
interface AirAppProvisionedBase extends AirAppBaseDeclaration {
|
|
131
|
+
nodeId: string;
|
|
132
|
+
baseId: string;
|
|
133
|
+
}
|
|
134
|
+
interface AirAppResources {
|
|
135
|
+
/** The app's root Folder, or `null` when it does not exist yet. */
|
|
136
|
+
folder: (AirAppFolderDeclaration & {
|
|
137
|
+
nodeId: string;
|
|
138
|
+
}) | null;
|
|
139
|
+
/** Declared Bases that exist and are owned, with their resolved ids. */
|
|
140
|
+
bases: AirAppProvisionedBase[];
|
|
141
|
+
/** Declared Bases that do not exist yet. */
|
|
142
|
+
missing: AirAppBaseDeclaration[];
|
|
143
|
+
/** Owned nodes whose ownership stamp is missing or stale. */
|
|
144
|
+
repairs: AirAppOwnershipRepair[];
|
|
145
|
+
/**
|
|
146
|
+
* Set when the server is too old for `nodes.updateMetadata`, so ownership was
|
|
147
|
+
* established by full structural fingerprint instead of a stamp.
|
|
148
|
+
*/
|
|
149
|
+
compatibilityMode?: "verified-legacy-fingerprint";
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Why setup cannot proceed. These are the app's five distinguishable states —
|
|
153
|
+
* each one wants a different screen, which is why they are codes and not prose.
|
|
154
|
+
*
|
|
155
|
+
* - `SETUP_REQUIRED` — nothing exists yet; offer to initialize.
|
|
156
|
+
* - `SETUP_PENDING` — the ChangeRequest was submitted and awaits human approval.
|
|
157
|
+
* - `SETUP_CONFLICT` — a node in the way is not this app's; nothing was changed.
|
|
158
|
+
* - `SETUP_PERMISSION` — this account may not create/repair here.
|
|
159
|
+
* - `SCHEMA_INCOMPLETE` — the change merged but read back incomplete.
|
|
160
|
+
*/
|
|
161
|
+
type AirAppSetupCode = "SETUP_REQUIRED" | "SETUP_PENDING" | "SETUP_CONFLICT" | "SETUP_PERMISSION" | "SCHEMA_INCOMPLETE";
|
|
162
|
+
/**
|
|
163
|
+
* A setup failure carrying its state as a `code`.
|
|
164
|
+
*
|
|
165
|
+
* `message` is deliberately kept in the historical `"CODE: detail"` shape: the
|
|
166
|
+
* generated apps parse the prefix off `error.message`, so an app can migrate to
|
|
167
|
+
* this class without touching its rendering code, then move to `error.code`.
|
|
168
|
+
*/
|
|
169
|
+
declare class AirAppSetupError extends Error {
|
|
170
|
+
readonly code: AirAppSetupCode;
|
|
171
|
+
/** The human-readable half, without the `CODE: ` prefix. */
|
|
172
|
+
readonly detail: string;
|
|
173
|
+
constructor(code: AirAppSetupCode, detail: string);
|
|
174
|
+
}
|
|
175
|
+
/** True for a 404 / NOT_FOUND from any of the client's transports. */
|
|
176
|
+
declare const isNotFound: (error: unknown) => boolean;
|
|
177
|
+
/** A node as this module reads it — the fields provisioning actually looks at. */
|
|
178
|
+
interface ReadNode {
|
|
179
|
+
id: string;
|
|
180
|
+
type: string;
|
|
181
|
+
slug: string;
|
|
182
|
+
name: string;
|
|
183
|
+
description: string;
|
|
184
|
+
baseId: string | null;
|
|
185
|
+
metadata: Record<string, unknown>;
|
|
186
|
+
children?: ReadNode[];
|
|
187
|
+
}
|
|
188
|
+
interface ReadFolderDetail {
|
|
189
|
+
node: ReadNode;
|
|
190
|
+
children: ReadNode[];
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Decide, from one already-read Folder, what exists / is missing / needs
|
|
194
|
+
* re-stamping. Pure — no I/O — so the ownership rules are directly testable.
|
|
195
|
+
*
|
|
196
|
+
* @throws {AirAppSetupError} `SETUP_CONFLICT` when a node in the way is not
|
|
197
|
+
* this app's. Nothing is ever mutated on that path.
|
|
198
|
+
*/
|
|
199
|
+
declare function resolveProvisionedFolder(folder: ReadFolderDetail | null | undefined, config: AirAppResourceConfig): AirAppResources;
|
|
200
|
+
/**
|
|
201
|
+
* The create operations for one idempotent ChangeRequest. Pure.
|
|
202
|
+
*
|
|
203
|
+
* When the Folder does not exist yet it is created under the temp `ref`
|
|
204
|
+
* `"app-root"` and the Bases nest under it via `parentNodeRef`, so the whole
|
|
205
|
+
* structure lands in a single reviewable change.
|
|
206
|
+
*/
|
|
207
|
+
declare function buildProvisionOperations(config: AirAppResourceConfig, folder: {
|
|
208
|
+
nodeId: string;
|
|
209
|
+
} | null, missingBases: AirAppBaseDeclaration[]): NodeOperationInput[];
|
|
210
|
+
/** Read the current state of this app's declared resources. Never mutates. */
|
|
211
|
+
declare function inspectProvisionedResources(client: AirAppProvisioningClient, config: AirAppResourceConfig): Promise<AirAppResources>;
|
|
212
|
+
/**
|
|
213
|
+
* Ensure the declared Folder and Bases exist, as one idempotent ChangeRequest.
|
|
214
|
+
*
|
|
215
|
+
* Safe to call concurrently: calls for the same client + `appId` share one
|
|
216
|
+
* in-flight promise, so a multi-pane app cannot submit the structure twice.
|
|
217
|
+
*
|
|
218
|
+
* @throws {AirAppSetupError} with a `code` describing which screen to show.
|
|
219
|
+
*/
|
|
220
|
+
declare function provisionDeclaredResources(client: AirAppProvisioningClient, config: AirAppResourceConfig): Promise<AirAppResources>;
|
|
221
|
+
|
|
222
|
+
export { type AirAppBaseDeclaration, type AirAppFieldDeclaration, type AirAppFolderDeclaration, type AirAppNodeDeclaration, type AirAppOwnershipRepair, type AirAppProvisionedBase, type AirAppProvisioningClient, type AirAppResourceConfig, type AirAppResourceOwnership, type AirAppResources, type AirAppSetupCode, AirAppSetupError, buildProvisionOperations, inspectProvisionedResources, isNotFound, provisionDeclaredResources, resolveProvisionedFolder };
|
package/dist/airapp.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// src/airapp.ts
|
|
2
|
+
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
|
+
}
|
|
12
|
+
};
|
|
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 matchesDeclaration = (node, declaration, type) => node?.type === type && node?.slug === declaration.slug && node?.name === declaration.name && node?.description === (declaration.description ?? "");
|
|
21
|
+
var matchesLegacyAirApp = (node, config) => hasEmptyMetadata(node) && node?.type === "airapp" && node?.slug === config.airApp?.slug && node?.name === config.airApp?.name;
|
|
22
|
+
var resourceMetadata = (config, resourceKey) => ({
|
|
23
|
+
appId: config.appId,
|
|
24
|
+
resourceKey,
|
|
25
|
+
schemaVersion: config.schemaVersion
|
|
26
|
+
});
|
|
27
|
+
function resolveProvisionedFolder(folder, config) {
|
|
28
|
+
if (!folder) {
|
|
29
|
+
return { folder: null, bases: [], missing: [...config.bases], repairs: [] };
|
|
30
|
+
}
|
|
31
|
+
if (folder.node?.type !== "folder" || folder.node?.slug !== config.folder.slug) {
|
|
32
|
+
throw setupError(
|
|
33
|
+
"SETUP_CONFLICT",
|
|
34
|
+
`A different Folder already uses the slug ${config.folder.slug}; nothing was changed`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const rootOwned = hasResourceIdentity(folder.node, config.appId, "app-root");
|
|
38
|
+
const legacyRoot = hasEmptyMetadata(folder.node) && matchesDeclaration(folder.node, config.folder, "folder");
|
|
39
|
+
if (!rootOwned && !legacyRoot) {
|
|
40
|
+
throw setupError(
|
|
41
|
+
"SETUP_CONFLICT",
|
|
42
|
+
`The Folder ${config.folder.slug} does not belong to this app; nothing was changed`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const bases = [];
|
|
46
|
+
const missing = [];
|
|
47
|
+
const repairs = [];
|
|
48
|
+
if (!ownsAppRoot(folder.node, config.appId, config.schemaVersion)) {
|
|
49
|
+
repairs.push({
|
|
50
|
+
nodeId: folder.node.id,
|
|
51
|
+
resourceKey: "app-root",
|
|
52
|
+
metadata: resourceMetadata(config, "app-root")
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
for (const base of config.bases) {
|
|
56
|
+
const matches = (folder.children ?? []).filter((node2) => node2.slug === base.slug);
|
|
57
|
+
if (!matches.length) {
|
|
58
|
+
if (legacyRoot) {
|
|
59
|
+
throw setupError(
|
|
60
|
+
"SETUP_CONFLICT",
|
|
61
|
+
`The existing unstamped Folder is missing the resource ${base.slug}, so it cannot be claimed safely`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
missing.push(base);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const node = matches[0];
|
|
68
|
+
if (matches.length !== 1 || node.type !== "base" || !node.baseId) {
|
|
69
|
+
throw setupError(
|
|
70
|
+
"SETUP_CONFLICT",
|
|
71
|
+
`The resource ${base.slug} does not match this app's declaration; nothing was changed`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const owned = hasResourceIdentity(node, config.appId, base.key);
|
|
75
|
+
const legacy = hasEmptyMetadata(node) && matchesDeclaration(node, base, "base");
|
|
76
|
+
if (!owned && !legacy) {
|
|
77
|
+
throw setupError(
|
|
78
|
+
"SETUP_CONFLICT",
|
|
79
|
+
`The resource ${base.slug} does not match this app's declaration; nothing was changed`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (!ownsResource(node, config.appId, base.key, config.schemaVersion)) {
|
|
83
|
+
repairs.push({
|
|
84
|
+
nodeId: node.id,
|
|
85
|
+
baseId: node.baseId,
|
|
86
|
+
resourceKey: base.key,
|
|
87
|
+
metadata: resourceMetadata(config, base.key)
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
bases.push({ ...base, nodeId: node.id, baseId: node.baseId });
|
|
91
|
+
}
|
|
92
|
+
const airAppNode = config.airApp ? (folder.children ?? []).find(
|
|
93
|
+
(node) => hasResourceIdentity(
|
|
94
|
+
node,
|
|
95
|
+
config.appId,
|
|
96
|
+
config.airApp.resourceKey
|
|
97
|
+
) || matchesLegacyAirApp(node, config)
|
|
98
|
+
) : void 0;
|
|
99
|
+
if (config.airApp && airAppNode && !ownsResource(airAppNode, config.appId, config.airApp.resourceKey, config.schemaVersion)) {
|
|
100
|
+
repairs.push({
|
|
101
|
+
nodeId: airAppNode.id,
|
|
102
|
+
resourceKey: config.airApp.resourceKey,
|
|
103
|
+
metadata: resourceMetadata(config, config.airApp.resourceKey)
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (legacyRoot) {
|
|
107
|
+
const declaredSlugs = new Set(config.bases.map((base) => base.slug));
|
|
108
|
+
const ambiguousExtra = (folder.children ?? []).find(
|
|
109
|
+
(node) => !declaredSlugs.has(node.slug) && node.id !== airAppNode?.id && node?.metadata?.appId !== config.appId
|
|
110
|
+
);
|
|
111
|
+
if (ambiguousExtra) {
|
|
112
|
+
throw setupError(
|
|
113
|
+
"SETUP_CONFLICT",
|
|
114
|
+
`The existing unstamped Folder holds an unattributable resource ${ambiguousExtra.slug}; nothing was changed`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
folder: { ...config.folder, nodeId: folder.node.id },
|
|
120
|
+
bases,
|
|
121
|
+
missing,
|
|
122
|
+
repairs
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function buildProvisionOperations(config, folder, missingBases) {
|
|
126
|
+
const operations = [];
|
|
127
|
+
if (!folder) {
|
|
128
|
+
operations.push({
|
|
129
|
+
kind: "create",
|
|
130
|
+
ref: "app-root",
|
|
131
|
+
nodeType: "folder",
|
|
132
|
+
slug: config.folder.slug,
|
|
133
|
+
name: config.folder.name,
|
|
134
|
+
description: config.folder.description ?? "",
|
|
135
|
+
metadata: resourceMetadata(config, "app-root")
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
for (const base of missingBases) {
|
|
139
|
+
operations.push({
|
|
140
|
+
kind: "create",
|
|
141
|
+
...folder ? { parentNodeId: folder.nodeId } : { parentNodeRef: "app-root" },
|
|
142
|
+
nodeType: "base",
|
|
143
|
+
slug: base.slug,
|
|
144
|
+
name: base.name,
|
|
145
|
+
description: base.description ?? "",
|
|
146
|
+
metadata: resourceMetadata(config, base.key),
|
|
147
|
+
fields: base.fields
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return operations;
|
|
151
|
+
}
|
|
152
|
+
var findTopLevelFolder = async (client, config) => {
|
|
153
|
+
const roots = await client.nodes.list({ parentId: null, depth: 2 });
|
|
154
|
+
const candidates = (roots ?? []).flatMap((node) => [node, ...node.children ?? []]).filter((node) => node.type === "folder" && node.slug === config.folder.slug);
|
|
155
|
+
if (candidates.length > 1) {
|
|
156
|
+
throw setupError(
|
|
157
|
+
"SETUP_CONFLICT",
|
|
158
|
+
`Found more than one Folder with the slug ${config.folder.slug}; nothing was changed`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return candidates[0] ?? null;
|
|
162
|
+
};
|
|
163
|
+
var readFolder = async (client, config) => {
|
|
164
|
+
let nodeId = config.folder.nodeId;
|
|
165
|
+
if (!nodeId) nodeId = (await findTopLevelFolder(client, config))?.id;
|
|
166
|
+
if (!nodeId) return null;
|
|
167
|
+
try {
|
|
168
|
+
return await client.nodes.get({ nodeId, type: "folder" });
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (isNotFound(error) && config.folder.nodeId) {
|
|
171
|
+
const discovered = await findTopLevelFolder(client, config);
|
|
172
|
+
return discovered ? await client.nodes.get({
|
|
173
|
+
nodeId: discovered.id,
|
|
174
|
+
type: "folder"
|
|
175
|
+
}) : null;
|
|
176
|
+
}
|
|
177
|
+
if (isNotFound(error)) return null;
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
async function inspectProvisionedResources(client, config) {
|
|
182
|
+
return resolveProvisionedFolder(await readFolder(client, config), config);
|
|
183
|
+
}
|
|
184
|
+
var provisionStates = /* @__PURE__ */ new WeakMap();
|
|
185
|
+
var stateFor = (client, appId) => {
|
|
186
|
+
let byApp = provisionStates.get(client);
|
|
187
|
+
if (!byApp) {
|
|
188
|
+
byApp = /* @__PURE__ */ new Map();
|
|
189
|
+
provisionStates.set(client, byApp);
|
|
190
|
+
}
|
|
191
|
+
let state = byApp.get(appId);
|
|
192
|
+
if (!state) {
|
|
193
|
+
state = { inFlight: null, metadataUpdatesSupported: void 0 };
|
|
194
|
+
byApp.set(appId, state);
|
|
195
|
+
}
|
|
196
|
+
return state;
|
|
197
|
+
};
|
|
198
|
+
var sameFieldName = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected);
|
|
199
|
+
var fieldMatches = (actual, expected) => actual?.slug === expected.slug && actual?.type === expected.type && actual?.required === expected.required && sameFieldName(actual?.name, expected.name);
|
|
200
|
+
var additiveFieldsFor = (actual, expected) => {
|
|
201
|
+
const fields = actual?.fields ?? [];
|
|
202
|
+
if (fields.length > expected.fields.length || !fields.every((field, index) => fieldMatches(field, expected.fields[index]))) {
|
|
203
|
+
throw setupError(
|
|
204
|
+
"SETUP_CONFLICT",
|
|
205
|
+
`The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return expected.fields.slice(fields.length);
|
|
209
|
+
};
|
|
210
|
+
var validateRepairBase = (actual, expected, nodeId) => {
|
|
211
|
+
if (!expected) {
|
|
212
|
+
throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
|
|
213
|
+
}
|
|
214
|
+
const fields = actual?.fields ?? [];
|
|
215
|
+
const exactFields = fields.length === expected.fields.length && fields.every((field, index) => fieldMatches(field, expected.fields[index]));
|
|
216
|
+
if (actual?.nodeId !== nodeId || actual?.slug !== expected.slug || actual?.name !== expected.name || actual?.description !== (expected.description ?? "") || !exactFields) {
|
|
217
|
+
throw setupError(
|
|
218
|
+
"SETUP_CONFLICT",
|
|
219
|
+
`The structure of ${expected.slug} does not match this app's declaration, so it cannot be claimed safely`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
async function repairResourceOwnership(client, config, current) {
|
|
224
|
+
if (!current.repairs.length) return current;
|
|
225
|
+
const state = stateFor(client, config.appId);
|
|
226
|
+
const baseRepairs = current.repairs.filter((repair) => repair.baseId);
|
|
227
|
+
const baseByKey = new Map(config.bases.map((base) => [base.key, base]));
|
|
228
|
+
const details = await Promise.all(
|
|
229
|
+
baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId }))
|
|
230
|
+
);
|
|
231
|
+
const migrations = details.map((detail, index) => {
|
|
232
|
+
const repair = baseRepairs[index];
|
|
233
|
+
const expected = baseByKey.get(repair.resourceKey);
|
|
234
|
+
if (!expected) {
|
|
235
|
+
throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
|
|
236
|
+
}
|
|
237
|
+
if (detail?.nodeId !== repair.nodeId || detail?.slug !== expected.slug || detail?.name !== expected.name || detail?.description !== (expected.description ?? "")) {
|
|
238
|
+
throw setupError(
|
|
239
|
+
"SETUP_CONFLICT",
|
|
240
|
+
`The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return { repair, expected, fields: additiveFieldsFor(detail, expected) };
|
|
244
|
+
});
|
|
245
|
+
const pendingFieldRequests = [];
|
|
246
|
+
for (const migration of migrations) {
|
|
247
|
+
for (const field of migration.fields) {
|
|
248
|
+
const changeRequest = await client.bases.fieldChangeRequest({
|
|
249
|
+
operation: "create",
|
|
250
|
+
baseId: migration.repair.baseId,
|
|
251
|
+
slug: field.slug,
|
|
252
|
+
name: field.name,
|
|
253
|
+
type: field.type,
|
|
254
|
+
required: field.required,
|
|
255
|
+
message: `Upgrade ${config.appName}: add ${field.slug}`,
|
|
256
|
+
submittedBy: config.appId
|
|
257
|
+
});
|
|
258
|
+
const merged = changeRequest?.status === "merged" || changeRequest?.materialized === true;
|
|
259
|
+
if (!merged) pendingFieldRequests.push(changeRequest?.id ?? field.slug);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (pendingFieldRequests.length) {
|
|
263
|
+
throw setupError(
|
|
264
|
+
"SETUP_PENDING",
|
|
265
|
+
`Submitted ${pendingFieldRequests.length} field upgrade request(s) awaiting Space admin approval: ${pendingFieldRequests.join(", ")}`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const verified = migrations.some((migration) => migration.fields.length) ? await Promise.all(
|
|
269
|
+
baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId }))
|
|
270
|
+
) : details;
|
|
271
|
+
verified.forEach((detail, index) => {
|
|
272
|
+
const repair = baseRepairs[index];
|
|
273
|
+
validateRepairBase(detail, baseByKey.get(repair.resourceKey), repair.nodeId);
|
|
274
|
+
});
|
|
275
|
+
if (state.metadataUpdatesSupported === false) {
|
|
276
|
+
return { ...current, repairs: [], compatibilityMode: "verified-legacy-fingerprint" };
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
for (const repair of current.repairs) {
|
|
280
|
+
await client.nodes.updateMetadata({ nodeId: repair.nodeId, metadata: repair.metadata });
|
|
281
|
+
state.metadataUpdatesSupported = true;
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (isNotFound(error)) {
|
|
285
|
+
state.metadataUpdatesSupported = false;
|
|
286
|
+
return { ...current, repairs: [], compatibilityMode: "verified-legacy-fingerprint" };
|
|
287
|
+
}
|
|
288
|
+
if (isForbidden(error)) {
|
|
289
|
+
throw setupError(
|
|
290
|
+
"SETUP_PERMISSION",
|
|
291
|
+
"This account may not repair resource ownership metadata for this app"
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
const repaired = await inspectProvisionedResources(client, config);
|
|
297
|
+
if (repaired.repairs.length) {
|
|
298
|
+
throw setupError("SCHEMA_INCOMPLETE", "Ownership was repaired but read back incomplete");
|
|
299
|
+
}
|
|
300
|
+
return repaired;
|
|
301
|
+
}
|
|
302
|
+
var waitForMaterializedResources = async (client, config, attempts = 20) => {
|
|
303
|
+
let current;
|
|
304
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
305
|
+
current = await inspectProvisionedResources(client, config);
|
|
306
|
+
current = await repairResourceOwnership(client, config, current);
|
|
307
|
+
if (current.folder && current.missing.length === 0) return current;
|
|
308
|
+
if (attempt < attempts - 1) {
|
|
309
|
+
await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
throw setupError(
|
|
313
|
+
"SCHEMA_INCOMPLETE",
|
|
314
|
+
"Initialization merged but the resources read back incomplete"
|
|
315
|
+
);
|
|
316
|
+
};
|
|
317
|
+
async function provisionOnce(client, config) {
|
|
318
|
+
let current = await inspectProvisionedResources(client, config);
|
|
319
|
+
current = await repairResourceOwnership(client, config, current);
|
|
320
|
+
if (current.folder && current.missing.length === 0) return current;
|
|
321
|
+
const operations = buildProvisionOperations(config, current.folder, current.missing);
|
|
322
|
+
let changeRequest;
|
|
323
|
+
try {
|
|
324
|
+
changeRequest = await client.nodes.createChangeRequest({
|
|
325
|
+
message: `Initialize ${config.appName} workspace`,
|
|
326
|
+
submittedBy: config.appId,
|
|
327
|
+
autoMerge: true,
|
|
328
|
+
operations
|
|
329
|
+
});
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (isForbidden(error)) {
|
|
332
|
+
throw setupError(
|
|
333
|
+
"SETUP_PERMISSION",
|
|
334
|
+
"This account may not create this app's resources in this Space"
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
const concurrent = await inspectProvisionedResources(client, config).catch(() => null);
|
|
338
|
+
if (concurrent?.folder && concurrent.missing.length === 0) return concurrent;
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
if (changeRequest?.status !== "merged") {
|
|
342
|
+
throw setupError(
|
|
343
|
+
"SETUP_PENDING",
|
|
344
|
+
`Initialization request ${changeRequest?.id ?? ""} was submitted and awaits Space admin approval`.trim()
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return waitForMaterializedResources(client, config);
|
|
348
|
+
}
|
|
349
|
+
function provisionDeclaredResources(client, config) {
|
|
350
|
+
const state = stateFor(client, config.appId);
|
|
351
|
+
if (!state.inFlight) {
|
|
352
|
+
state.inFlight = provisionOnce(client, config).finally(() => {
|
|
353
|
+
state.inFlight = null;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return state.inFlight;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export { AirAppSetupError, buildProvisionOperations, inspectProvisionedResources, isNotFound, provisionDeclaredResources, resolveProvisionedFolder };
|