@frontera-sdk/cli 0.1.0 → 1.43.6

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.
Files changed (48) hide show
  1. package/package.json +4 -2
  2. package/src/api/apps-api.ts +13 -1
  3. package/src/api/automation-api.ts +129 -1
  4. package/src/api/blueprint-authoring-api.ts +574 -0
  5. package/src/api/dataset-api.ts +199 -0
  6. package/src/api/platform-api.ts +300 -0
  7. package/src/automation-template.ts +224 -0
  8. package/src/blueprint/compile.ts +371 -0
  9. package/src/blueprint/dataset-revision.ts +33 -0
  10. package/src/blueprint/diff.ts +223 -0
  11. package/src/blueprint/model.ts +227 -0
  12. package/src/blueprint/projection.ts +254 -0
  13. package/src/blueprint/render.ts +73 -0
  14. package/src/blueprint/scaffold.ts +79 -0
  15. package/src/blueprint/tree.ts +121 -0
  16. package/src/commands/agent/index-commands.ts +87 -1
  17. package/src/commands/app/deploy.ts +43 -3
  18. package/src/commands/app/init.ts +23 -1
  19. package/src/commands/app/pull.ts +12 -35
  20. package/src/commands/automation/index-commands.ts +42 -1
  21. package/src/commands/automation/init.ts +52 -0
  22. package/src/commands/automation/project-root.ts +58 -0
  23. package/src/commands/automation/pull.ts +124 -0
  24. package/src/commands/automation/run.ts +271 -0
  25. package/src/commands/blueprint/authoring.ts +410 -0
  26. package/src/commands/blueprint/bind.ts +228 -0
  27. package/src/commands/blueprint/declarative.ts +1052 -0
  28. package/src/commands/blueprint/grants.ts +164 -0
  29. package/src/commands/dataset/index-commands.ts +431 -0
  30. package/src/commands/knowledge/index-commands.ts +278 -27
  31. package/src/commands/knowledge/upload-batch.ts +146 -0
  32. package/src/commands/knowledge/upload-plan.ts +127 -0
  33. package/src/commands/login.ts +49 -11
  34. package/src/commands/pack/index-commands.ts +373 -0
  35. package/src/commands/registry.ts +19 -2
  36. package/src/commands/secret/index-commands.ts +195 -0
  37. package/src/commands/skill/bundle-commands.ts +327 -0
  38. package/src/commands/skill/index-commands.ts +36 -42
  39. package/src/commands/skill/resolve.ts +34 -0
  40. package/src/dev-env.ts +114 -0
  41. package/src/flag-help.ts +34 -0
  42. package/src/harness.ts +30 -3
  43. package/src/main.ts +10 -3
  44. package/src/render-evidence.ts +152 -0
  45. package/src/template.ts +4 -0
  46. package/src/untar.ts +44 -0
  47. package/src/vendor/sdk-sources.json +13 -11
  48. 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
+ }