@frontera-sdk/cli 0.1.0 → 1.43.5
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/package.json +4 -2
- package/src/api/apps-api.ts +13 -1
- package/src/api/automation-api.ts +129 -1
- package/src/api/blueprint-authoring-api.ts +574 -0
- package/src/api/dataset-api.ts +199 -0
- package/src/api/platform-api.ts +300 -0
- package/src/automation-template.ts +224 -0
- package/src/blueprint/compile.ts +371 -0
- package/src/blueprint/dataset-revision.ts +33 -0
- package/src/blueprint/diff.ts +223 -0
- package/src/blueprint/model.ts +227 -0
- package/src/blueprint/projection.ts +254 -0
- package/src/blueprint/render.ts +73 -0
- package/src/blueprint/scaffold.ts +79 -0
- package/src/blueprint/tree.ts +121 -0
- package/src/commands/agent/index-commands.ts +87 -1
- package/src/commands/app/deploy.ts +43 -3
- package/src/commands/app/init.ts +23 -1
- package/src/commands/app/pull.ts +12 -35
- package/src/commands/automation/index-commands.ts +42 -1
- package/src/commands/automation/init.ts +52 -0
- package/src/commands/automation/project-root.ts +58 -0
- package/src/commands/automation/pull.ts +124 -0
- package/src/commands/automation/run.ts +271 -0
- package/src/commands/blueprint/authoring.ts +410 -0
- package/src/commands/blueprint/bind.ts +228 -0
- package/src/commands/blueprint/declarative.ts +1052 -0
- package/src/commands/blueprint/grants.ts +164 -0
- package/src/commands/dataset/index-commands.ts +431 -0
- package/src/commands/knowledge/index-commands.ts +278 -27
- package/src/commands/knowledge/upload-batch.ts +146 -0
- package/src/commands/knowledge/upload-plan.ts +127 -0
- package/src/commands/login.ts +49 -11
- package/src/commands/pack/index-commands.ts +373 -0
- package/src/commands/registry.ts +19 -2
- package/src/commands/secret/index-commands.ts +195 -0
- package/src/commands/skill/bundle-commands.ts +327 -0
- package/src/commands/skill/index-commands.ts +36 -42
- package/src/commands/skill/resolve.ts +34 -0
- package/src/dev-env.ts +114 -0
- package/src/flag-help.ts +34 -0
- package/src/harness.ts +30 -3
- package/src/main.ts +10 -3
- package/src/render-evidence.ts +152 -0
- package/src/template.ts +4 -0
- package/src/untar.ts +44 -0
- package/src/vendor/sdk-sources.json +13 -11
- package/src/commands/blueprint/reserved.ts +0 -40
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { CliError } from '../errors'
|
|
2
|
+
import type { DefinitionBundle } from '../blueprint/model'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Blueprint AUTHORING over HTTP.
|
|
6
|
+
*
|
|
7
|
+
* Separate from `PlatformApi`, which reads the workspace's granted slice. Authoring is
|
|
8
|
+
* organization-grain: the shared draft, its revision lifecycle, and the release it
|
|
9
|
+
* publishes to. A workspace credential cannot reach any of it — the service refuses a
|
|
10
|
+
* workspace key at organization scope by design — so these calls need an `sk-org-`
|
|
11
|
+
* organization key.
|
|
12
|
+
*
|
|
13
|
+
* Every mutation carries `expectedRevision`. The draft is shared, so a write that does
|
|
14
|
+
* not say which revision it was computed against is a lost update waiting to happen;
|
|
15
|
+
* the service answers 409 when the revision has moved, and the caller re-reads.
|
|
16
|
+
*/
|
|
17
|
+
export class BlueprintAuthoringApi {
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly apiBaseUrl: string,
|
|
20
|
+
private readonly token: string,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
private async call<T>(
|
|
24
|
+
path: string,
|
|
25
|
+
init: { method?: string; body?: unknown } = {},
|
|
26
|
+
): Promise<T> {
|
|
27
|
+
const res = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
28
|
+
method: init.method ?? 'GET',
|
|
29
|
+
headers: {
|
|
30
|
+
authorization: `Bearer ${this.token}`,
|
|
31
|
+
'content-type': 'application/json',
|
|
32
|
+
},
|
|
33
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const text = await res.text()
|
|
37
|
+
let payload: any = null
|
|
38
|
+
try { payload = text ? JSON.parse(text) : null } catch { /* non-JSON body */ }
|
|
39
|
+
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
// The credential failures worth telling apart, because each has a different
|
|
42
|
+
// fix and a generic "request failed" sends the reader to the wrong one.
|
|
43
|
+
if (res.status === 401) {
|
|
44
|
+
throw new CliError(
|
|
45
|
+
'The credential was refused. An organization key that has been revoked or has ' +
|
|
46
|
+
'expired reads exactly like this — mint or rotate one in Settings → API keys.',
|
|
47
|
+
{ code: 'UNAUTHORIZED', hint: 'frontera login --api-url <url>' },
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
if (res.status === 403) {
|
|
51
|
+
throw new CliError(
|
|
52
|
+
payload?.message
|
|
53
|
+
? `${payload.message} — the key may not carry the capability this route needs, its `
|
|
54
|
+
+ `creator's role may have changed, or the row is in a workspace the key does not list`
|
|
55
|
+
: "The key is not permitted to author this organization's Blueprint.",
|
|
56
|
+
{
|
|
57
|
+
code: 'FORBIDDEN',
|
|
58
|
+
// Three distinct causes with three distinct fixes, and the service cannot
|
|
59
|
+
// tell them apart for us. Naming all three beats naming the wrong one:
|
|
60
|
+
// a workspace-addressed route refuses a key whose workspace list omits
|
|
61
|
+
// that workspace, which has nothing to do with organization:update.
|
|
62
|
+
hint: 'Check the key carries the capability, and — for a workspace- or '
|
|
63
|
+
+ 'agent-addressed route — that its workspace list includes that workspace.',
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
if (res.status === 409) {
|
|
68
|
+
throw new CliError(
|
|
69
|
+
payload?.message
|
|
70
|
+
?? 'The shared draft moved since this change was computed. Re-run to pick up the current revision.',
|
|
71
|
+
{ code: 'CONFLICT', hint: 'frontera blueprint status' },
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
if (res.status === 404) {
|
|
75
|
+
throw new CliError(payload?.message ?? 'Not found', {
|
|
76
|
+
code: 'NOT_FOUND',
|
|
77
|
+
hint: 'frontera blueprint status',
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
throw new CliError(payload?.message ?? `${res.status} ${text.slice(0, 200)}`, { code: 'FAILURE', hint: 'frontera blueprint status' })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (payload?.data ?? payload) as T
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The organization's lifecycle, or `null` when it has no Blueprint yet.
|
|
88
|
+
*
|
|
89
|
+
* "Not adopted" is a legitimate state, not an error: it is the state every
|
|
90
|
+
* organization starts in. Surfacing it as a 404 made `status` — the command whose
|
|
91
|
+
* whole job is to report the state — fail instead of describing it.
|
|
92
|
+
*/
|
|
93
|
+
async lifecycleOrNull(): Promise<{
|
|
94
|
+
draft?: { revision?: number }
|
|
95
|
+
activeReleaseId?: string
|
|
96
|
+
activeRelease?: { id?: string; releaseLabel?: string; releaseNumber?: number }
|
|
97
|
+
} | null> {
|
|
98
|
+
try {
|
|
99
|
+
return await this.lifecycle()
|
|
100
|
+
} catch (err) {
|
|
101
|
+
const notFound = err instanceof CliError && (err.code === 'NOT_FOUND' || err.code === 'FAILURE')
|
|
102
|
+
if (notFound) return null
|
|
103
|
+
throw err
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The organization's lifecycle: draft revision, active release. */
|
|
108
|
+
lifecycle(): Promise<{
|
|
109
|
+
draft?: { revision?: number }
|
|
110
|
+
activeReleaseId?: string
|
|
111
|
+
activeRelease?: { id?: string; releaseLabel?: string; releaseNumber?: number }
|
|
112
|
+
}> {
|
|
113
|
+
return this.call('/v1/blueprint/lifecycle')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The draft revision every mutation must be computed against. */
|
|
117
|
+
async revision(): Promise<number> {
|
|
118
|
+
const lifecycle = await this.lifecycle()
|
|
119
|
+
const revision = lifecycle?.draft?.revision
|
|
120
|
+
if (typeof revision !== 'number') {
|
|
121
|
+
throw new CliError(
|
|
122
|
+
'This organization has no Blueprint draft yet — run `frontera blueprint adopt` first.',
|
|
123
|
+
{ code: 'FAILURE', hint: 'frontera blueprint adopt' },
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
return revision
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
adopt(): Promise<unknown> {
|
|
130
|
+
return this.call('/v1/blueprint/lifecycle/adopt', { method: 'POST', body: {} })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── draft catalog ─────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
createObjectType(body: Record<string, unknown>, expectedRevision: number) {
|
|
136
|
+
return this.call('/v1/blueprint/object-types', { method: 'POST', body: { ...body, expectedRevision } })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
updateObjectType(apiName: string, body: Record<string, unknown>, expectedRevision: number) {
|
|
140
|
+
return this.call(`/v1/blueprint/object-types/${encodeURIComponent(apiName)}`, {
|
|
141
|
+
method: 'PUT',
|
|
142
|
+
body: { ...body, expectedRevision },
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The draft's object type. The route nests it under `objectType`. */
|
|
147
|
+
async getObjectType(apiName: string): Promise<{ id?: string } | null> {
|
|
148
|
+
const payload = await this.call<{ objectType?: { id?: string } }>(
|
|
149
|
+
`/v1/blueprint/object-types/${encodeURIComponent(apiName)}?view=draft`,
|
|
150
|
+
)
|
|
151
|
+
return payload?.objectType ?? (payload as { id?: string } | null)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The draft object type AND its properties.
|
|
156
|
+
*
|
|
157
|
+
* The route answers `{ objectType, properties, draftRevision }` — the properties
|
|
158
|
+
* are a SIBLING of the object type, not a field on it. `getObjectType` above
|
|
159
|
+
* returns the nested object alone, which is what a caller wanting an id wants and
|
|
160
|
+
* exactly wrong for a caller wanting the fields to map.
|
|
161
|
+
*/
|
|
162
|
+
async getObjectTypeDetail(apiName: string): Promise<{
|
|
163
|
+
id?: string
|
|
164
|
+
properties: Array<{ id?: string; apiName?: string }>
|
|
165
|
+
} | null> {
|
|
166
|
+
const payload = await this.call<{
|
|
167
|
+
objectType?: { id?: string }
|
|
168
|
+
properties?: Array<{ id?: string; apiName?: string }>
|
|
169
|
+
}>(`/v1/blueprint/object-types/${encodeURIComponent(apiName)}?view=draft`)
|
|
170
|
+
if (!payload?.objectType?.id) return null
|
|
171
|
+
return { id: payload.objectType.id, properties: payload.properties ?? [] }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The draft catalog — what an organization key can actually see. */
|
|
175
|
+
listObjectTypes(): Promise<Array<{ id?: string; apiName?: string; displayName?: string }>> {
|
|
176
|
+
return this.call('/v1/blueprint/object-types?view=draft')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Endpoints return object types by ID, not by API name — see `blueprint catalog`. */
|
|
180
|
+
listLinkTypes(): Promise<
|
|
181
|
+
Array<{ apiName?: string; fromObjectTypeId?: string; toObjectTypeId?: string }>
|
|
182
|
+
> {
|
|
183
|
+
return this.call('/v1/blueprint/link-types?view=draft')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
listMetrics(): Promise<
|
|
187
|
+
Array<{ apiName?: string; displayName?: string; objectTypeId?: string }>
|
|
188
|
+
> {
|
|
189
|
+
return this.call('/v1/blueprint/metrics?view=draft')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
createLinkType(body: Record<string, unknown>, expectedRevision: number) {
|
|
193
|
+
return this.call('/v1/blueprint/link-types', { method: 'POST', body: { ...body, expectedRevision } })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
updateLinkType(apiName: string, body: Record<string, unknown>, expectedRevision: number) {
|
|
197
|
+
return this.call(`/v1/blueprint/link-types/${encodeURIComponent(apiName)}`, {
|
|
198
|
+
method: 'PUT',
|
|
199
|
+
body: { ...body, expectedRevision },
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
deleteLinkType(apiName: string, expectedRevision: number) {
|
|
204
|
+
return this.call(`/v1/blueprint/link-types/${encodeURIComponent(apiName)}`, {
|
|
205
|
+
method: 'DELETE',
|
|
206
|
+
body: { expectedRevision },
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
createMetric(body: Record<string, unknown>, expectedRevision: number) {
|
|
211
|
+
return this.call('/v1/blueprint/metrics', { method: 'POST', body: { ...body, expectedRevision } })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
updateMetric(apiName: string, body: Record<string, unknown>, expectedRevision: number) {
|
|
215
|
+
return this.call(`/v1/blueprint/metrics/${encodeURIComponent(apiName)}`, {
|
|
216
|
+
method: 'PUT',
|
|
217
|
+
body: { ...body, expectedRevision },
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
deleteMetric(apiName: string, expectedRevision: number) {
|
|
222
|
+
return this.call(`/v1/blueprint/metrics/${encodeURIComponent(apiName)}`, {
|
|
223
|
+
method: 'DELETE',
|
|
224
|
+
body: { expectedRevision },
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── shared field types ─────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
listSharedProperties(): Promise<
|
|
231
|
+
Array<{ id?: string; apiName?: string; displayName?: string; dataType?: string }>
|
|
232
|
+
> {
|
|
233
|
+
return this.call('/v1/blueprint/shared-fields?view=draft')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Create, and read the minted id back.
|
|
238
|
+
*
|
|
239
|
+
* The response carries the created row rather than only a revision, and that is
|
|
240
|
+
* load-bearing: a tree that creates a shared field and the property implementing
|
|
241
|
+
* it in one `apply` has no id at plan time, so the executor takes it from here. The
|
|
242
|
+
* CLI never invents one — no other artifact is created that way.
|
|
243
|
+
*/
|
|
244
|
+
createSharedProperty(
|
|
245
|
+
body: Record<string, unknown>,
|
|
246
|
+
expectedRevision: number,
|
|
247
|
+
): Promise<{ sharedProperty?: { id?: string; apiName?: string }; draftRevision?: number }> {
|
|
248
|
+
return this.call('/v1/blueprint/shared-fields', {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
body: { ...body, expectedRevision },
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
updateSharedProperty(apiName: string, body: Record<string, unknown>, expectedRevision: number) {
|
|
255
|
+
return this.call(`/v1/blueprint/shared-fields/${encodeURIComponent(apiName)}`, {
|
|
256
|
+
method: 'PUT',
|
|
257
|
+
body: { ...body, expectedRevision },
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Succeeds with implementers: each reverts to a regular property keeping its values. */
|
|
262
|
+
deleteSharedProperty(apiName: string, expectedRevision: number) {
|
|
263
|
+
return this.call(`/v1/blueprint/shared-fields/${encodeURIComponent(apiName)}`, {
|
|
264
|
+
method: 'DELETE',
|
|
265
|
+
body: { expectedRevision },
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Removing an object type is a STRUCTURAL change, so it goes through preview and
|
|
271
|
+
* apply rather than a plain DELETE: the service refuses an apply whose digest does
|
|
272
|
+
* not match the preview it was shown, which is what stops a change from landing
|
|
273
|
+
* differently to how it was reviewed.
|
|
274
|
+
*/
|
|
275
|
+
async removeObject(
|
|
276
|
+
objectId: string,
|
|
277
|
+
expectedRevision: number,
|
|
278
|
+
): Promise<{ preview: unknown; applied: unknown; revision: number }> {
|
|
279
|
+
return this.draftCommand({ kind: 'remove_object', objectId }, expectedRevision)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── release lifecycle ─────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
validate(): Promise<{ id?: string; reportId?: string }> {
|
|
285
|
+
return this.call('/v1/blueprint/lifecycle/validate', { method: 'POST', body: {} })
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
publish(input: {
|
|
289
|
+
expectedRevision: number
|
|
290
|
+
validationReportId: string
|
|
291
|
+
releaseLabel: string
|
|
292
|
+
releaseNotes: string
|
|
293
|
+
instruction?: string
|
|
294
|
+
}) {
|
|
295
|
+
const { instruction, ...body } = input
|
|
296
|
+
// Publish REQUIRES the field even when empty; rollback treats it as optional.
|
|
297
|
+
// Dropping the default here failed typebox before authorization ever ran.
|
|
298
|
+
return this.postWithMigrations(
|
|
299
|
+
'/v1/blueprint/lifecycle/publish',
|
|
300
|
+
{ migrationInstructions: [], ...body },
|
|
301
|
+
instruction,
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* POST a release operation, negotiating migration instructions once.
|
|
307
|
+
*
|
|
308
|
+
* A release that discards changes is refused until each discarded change carries an
|
|
309
|
+
* explanation, and the service names the change ids it wants. Rather than make the
|
|
310
|
+
* caller guess them, this asks, then retries with one instruction per named change.
|
|
311
|
+
* Shared by publish and rollback because the refusal is identical for both.
|
|
312
|
+
*/
|
|
313
|
+
private async postWithMigrations(
|
|
314
|
+
path: string,
|
|
315
|
+
body: Record<string, unknown>,
|
|
316
|
+
instruction?: string,
|
|
317
|
+
): Promise<unknown> {
|
|
318
|
+
try {
|
|
319
|
+
return await this.call(path, { method: 'POST', body })
|
|
320
|
+
} catch (err) {
|
|
321
|
+
if (!(err instanceof CliError) || err.code !== 'CONFLICT' || !instruction) throw err
|
|
322
|
+
const changeIds = await this.migrationBlockers(path, body)
|
|
323
|
+
if (changeIds.length === 0) throw err
|
|
324
|
+
return this.call(path, {
|
|
325
|
+
method: 'POST',
|
|
326
|
+
body: {
|
|
327
|
+
...body,
|
|
328
|
+
migrationInstructions: changeIds.map((changeId) => ({ changeId, instruction })),
|
|
329
|
+
},
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Roll back to an earlier release.
|
|
336
|
+
*
|
|
337
|
+
* The service refuses when discarded changes need an explanation, and names the
|
|
338
|
+
* change ids it wants covered. Rather than make the caller guess, this asks once
|
|
339
|
+
* and retries with an instruction per named change when `instruction` is given.
|
|
340
|
+
*/
|
|
341
|
+
async rollback(
|
|
342
|
+
releaseId: string,
|
|
343
|
+
input: { releaseLabel: string; releaseNotes: string; instruction?: string },
|
|
344
|
+
): Promise<unknown> {
|
|
345
|
+
return this.postWithMigrations(
|
|
346
|
+
`/v1/blueprint/lifecycle/releases/${encodeURIComponent(releaseId)}/rollback`,
|
|
347
|
+
{ releaseLabel: input.releaseLabel, releaseNotes: input.releaseNotes },
|
|
348
|
+
input.instruction,
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── the whole draft, for pull / plan ──────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The draft's definition bundle and the revision it is at, in one read.
|
|
356
|
+
*
|
|
357
|
+
* `plan` needs both and must see them from a single observation: reading the
|
|
358
|
+
* definition and then the revision separately would let the draft move between the
|
|
359
|
+
* two, and the plan would be computed against a bundle the revision no longer
|
|
360
|
+
* describes.
|
|
361
|
+
*/
|
|
362
|
+
async draft(): Promise<{ definition: DefinitionBundle; revision: number }> {
|
|
363
|
+
const lifecycle = await this.call<{
|
|
364
|
+
draft?: { revision?: number; definition?: DefinitionBundle }
|
|
365
|
+
}>('/v1/blueprint/lifecycle')
|
|
366
|
+
const revision = lifecycle?.draft?.revision
|
|
367
|
+
const definition = lifecycle?.draft?.definition
|
|
368
|
+
if (typeof revision !== 'number' || !definition) {
|
|
369
|
+
throw new CliError(
|
|
370
|
+
'This organization has no Blueprint draft yet.',
|
|
371
|
+
{ code: 'FAILURE', hint: 'frontera blueprint adopt' },
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
return { definition, revision }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── actions ───────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
listActions(): Promise<Array<{ apiName?: string; displayName?: string }>> {
|
|
380
|
+
return this.call('/v1/blueprint/actions?view=draft')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
createAction(action: Record<string, unknown>, expectedRevision: number) {
|
|
384
|
+
return this.call('/v1/blueprint/actions', { method: 'POST', body: { expectedRevision, action } })
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
updateAction(apiName: string, action: Record<string, unknown>, expectedRevision: number) {
|
|
388
|
+
return this.call(`/v1/blueprint/actions/${encodeURIComponent(apiName)}`, {
|
|
389
|
+
method: 'PUT',
|
|
390
|
+
body: { expectedRevision, action },
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
deleteAction(apiName: string, expectedRevision: number) {
|
|
395
|
+
return this.call(`/v1/blueprint/actions/${encodeURIComponent(apiName)}`, {
|
|
396
|
+
method: 'DELETE',
|
|
397
|
+
body: { expectedRevision },
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── object sets ───────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
listObjectSets(): Promise<Array<{ id?: string; name?: string; kind?: string }>> {
|
|
404
|
+
return this.call('/v1/blueprint/object-sets')
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
createObjectSet(body: Record<string, unknown>) {
|
|
408
|
+
return this.call('/v1/blueprint/object-sets', { method: 'POST', body })
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
updateObjectSet(id: string, body: Record<string, unknown>) {
|
|
412
|
+
return this.call(`/v1/blueprint/object-sets/${encodeURIComponent(id)}`, { method: 'PUT', body })
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
deleteObjectSet(id: string) {
|
|
416
|
+
return this.call(`/v1/blueprint/object-sets/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── grants, and the names they are addressed by ───────────────────────────
|
|
420
|
+
|
|
421
|
+
orgWorkspaces(): Promise<Array<{ id?: string; name?: string; slug?: string }>> {
|
|
422
|
+
return this.call('/v1/blueprint/org-workspaces')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
orgAgents(): Promise<Array<{ id?: string; name?: string }>> {
|
|
426
|
+
return this.call('/v1/blueprint/org-agents')
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
workspaceGrants(workspaceId: string): Promise<Array<{ objectTypeApiName?: string }>> {
|
|
430
|
+
return this.call(`/v1/blueprint/grants/workspace/${encodeURIComponent(workspaceId)}`)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
agentGrants(agentId: string): Promise<Array<{ objectTypeApiName?: string }>> {
|
|
434
|
+
return this.call(`/v1/blueprint/grants/agent/${encodeURIComponent(agentId)}`)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
grantWorkspace(workspaceId: string, objectTypeApiName: string) {
|
|
438
|
+
return this.call('/v1/blueprint/grants/workspace', {
|
|
439
|
+
method: 'POST',
|
|
440
|
+
body: { workspaceId, objectTypeApiName },
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
revokeWorkspace(workspaceId: string, objectTypeApiName: string) {
|
|
445
|
+
return this.call(
|
|
446
|
+
`/v1/blueprint/grants/workspace/${encodeURIComponent(workspaceId)}/${encodeURIComponent(objectTypeApiName)}`,
|
|
447
|
+
{ method: 'DELETE' },
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
grantAgent(agentId: string, objectTypeApiName: string) {
|
|
452
|
+
return this.call('/v1/blueprint/grants/agent', {
|
|
453
|
+
method: 'POST',
|
|
454
|
+
body: { agentId, objectTypeApiName },
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
revokeAgent(agentId: string, objectTypeApiName: string) {
|
|
459
|
+
return this.call(
|
|
460
|
+
`/v1/blueprint/grants/agent/${encodeURIComponent(agentId)}/${encodeURIComponent(objectTypeApiName)}`,
|
|
461
|
+
{ method: 'DELETE' },
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── datasets: read-only, and reachable because the key holds `dataset:read` ─
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Datasets live on `/v1/data-integration`, not on the Blueprint router. No lane
|
|
469
|
+
* declaration opens them: the organization-key lane refuses undeclared WRITES and
|
|
470
|
+
* UNGATED reads, and these are gated — so the only requirement is that the key was
|
|
471
|
+
* minted carrying `dataset: ['read']`.
|
|
472
|
+
*/
|
|
473
|
+
async listDatasets(): Promise<Array<{ id?: string; name?: string; currentRevisionId?: string }>> {
|
|
474
|
+
// Every page, not the first. The route defaults to 50 and answers `nextCursor`;
|
|
475
|
+
// stopping at one page made dataset 51 report "No dataset named X" and made
|
|
476
|
+
// `pull` omit its backing silently, because the omission warning keys on having
|
|
477
|
+
// found NO names rather than on having stopped early.
|
|
478
|
+
const all: Array<{ id?: string; name?: string; currentRevisionId?: string }> = []
|
|
479
|
+
let cursor: string | undefined
|
|
480
|
+
do {
|
|
481
|
+
const page = await this.call<{
|
|
482
|
+
items?: Array<{
|
|
483
|
+
id?: string
|
|
484
|
+
apiName?: string
|
|
485
|
+
currentRevision?: { id?: string }
|
|
486
|
+
currentRevisionId?: string
|
|
487
|
+
}>
|
|
488
|
+
nextCursor?: string | null
|
|
489
|
+
}>(`/v1/data-integration/datasets${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`)
|
|
490
|
+
for (const dataset of page?.items ?? []) {
|
|
491
|
+
all.push({
|
|
492
|
+
...(dataset.id === undefined ? {} : { id: dataset.id }),
|
|
493
|
+
...(dataset.apiName === undefined ? {} : { name: dataset.apiName }),
|
|
494
|
+
// The route SAYS which revision is current. Deriving it by sorting
|
|
495
|
+
// revisions descending is a different answer after a rollback, and `bind`
|
|
496
|
+
// would pin the wrong one.
|
|
497
|
+
...(dataset.currentRevision?.id ?? dataset.currentRevisionId
|
|
498
|
+
? { currentRevisionId: dataset.currentRevision?.id ?? dataset.currentRevisionId }
|
|
499
|
+
: {}),
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
cursor = page?.nextCursor ?? undefined
|
|
503
|
+
} while (cursor)
|
|
504
|
+
return all
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async datasetRevisions(datasetId: string): Promise<Array<{
|
|
508
|
+
id?: string
|
|
509
|
+
revision?: number
|
|
510
|
+
schemaDigest?: string
|
|
511
|
+
columns?: Array<{ name?: string; type?: unknown; nullable?: boolean }>
|
|
512
|
+
}>> {
|
|
513
|
+
const page = await this.call<{ items?: Array<{ id?: string; revision?: number }> }>(
|
|
514
|
+
`/v1/data-integration/datasets/${encodeURIComponent(datasetId)}/revisions`,
|
|
515
|
+
)
|
|
516
|
+
// Highest revision first. This orders the LIST; which revision is CURRENT comes
|
|
517
|
+
// from the dataset itself (`currentRevisionId`), because after a rollback the
|
|
518
|
+
// highest-numbered revision is not the live one.
|
|
519
|
+
return [...(page?.items ?? [])].sort((left, right) => (right.revision ?? 0) - (left.revision ?? 0))
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ── object draft commands ─────────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Preview a command, then apply the exact thing that was previewed.
|
|
526
|
+
*
|
|
527
|
+
* Every impactful object change travels this pair — `rename_object_api`,
|
|
528
|
+
* `set_editable_properties`, `update_source_binding`, the field operations and
|
|
529
|
+
* `remove_object`. The service refuses an apply whose digest does not match the
|
|
530
|
+
* preview it showed, which is what stops a change landing differently to how it was
|
|
531
|
+
* reviewed.
|
|
532
|
+
*/
|
|
533
|
+
async draftCommand(
|
|
534
|
+
command: Record<string, unknown>,
|
|
535
|
+
expectedRevision: number,
|
|
536
|
+
): Promise<{ preview: unknown; applied: unknown; revision: number }> {
|
|
537
|
+
const preview = await this.call<{ previewDigest?: string; digest?: string }>(
|
|
538
|
+
'/v1/blueprint/object-draft-commands/preview',
|
|
539
|
+
{ method: 'POST', body: { expectedRevision, command } },
|
|
540
|
+
)
|
|
541
|
+
const applied = await this.call<{ draftRevision?: number }>(
|
|
542
|
+
'/v1/blueprint/object-draft-commands/apply',
|
|
543
|
+
{
|
|
544
|
+
method: 'POST',
|
|
545
|
+
body: { expectedRevision, command, previewDigest: preview?.previewDigest ?? preview?.digest },
|
|
546
|
+
},
|
|
547
|
+
)
|
|
548
|
+
return { preview, applied, revision: applied?.draftRevision ?? expectedRevision + 1 }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
/** Append-only. A property that already exists is changed by a field command. */
|
|
554
|
+
addProperty(objectTypeApiName: string, body: Record<string, unknown>, expectedRevision: number) {
|
|
555
|
+
return this.call(
|
|
556
|
+
`/v1/blueprint/object-types/${encodeURIComponent(objectTypeApiName)}/properties`,
|
|
557
|
+
{ method: 'POST', body: { ...body, expectedRevision } },
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The change ids a blocked release operation wants instructions for. */
|
|
562
|
+
private async migrationBlockers(path: string, body: Record<string, unknown>): Promise<string[]> {
|
|
563
|
+
const res = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
564
|
+
method: 'POST',
|
|
565
|
+
headers: { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' },
|
|
566
|
+
body: JSON.stringify(body),
|
|
567
|
+
})
|
|
568
|
+
const payload: any = await res.json().catch(() => null)
|
|
569
|
+
const blockers: Array<{ code?: string; changeId?: string }> = payload?.details?.blockers ?? []
|
|
570
|
+
return blockers
|
|
571
|
+
.filter((b) => b.code === 'MIGRATION_INSTRUCTION_REQUIRED' && b.changeId)
|
|
572
|
+
.map((b) => b.changeId!)
|
|
573
|
+
}
|
|
574
|
+
}
|