@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.
- 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,1052 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
|
|
5
|
+
import {
|
|
6
|
+
linkTypeCreateBody,
|
|
7
|
+
linkTypeStructure,
|
|
8
|
+
objectTypeCreateBody,
|
|
9
|
+
metricCreateBody,
|
|
10
|
+
metricPatchBody,
|
|
11
|
+
readBacking,
|
|
12
|
+
resolveSharedProperty,
|
|
13
|
+
sharedPropertyCreateBody,
|
|
14
|
+
sharedPropertyPatchBody,
|
|
15
|
+
} from '../../blueprint/compile'
|
|
16
|
+
import { pickCurrentRevision } from '../../blueprint/dataset-revision'
|
|
17
|
+
import { diff, isEmpty, satisfies, type ModelOperation, type Plan } from '../../blueprint/diff'
|
|
18
|
+
import {
|
|
19
|
+
API_NAME_RULE,
|
|
20
|
+
ARTIFACT_KINDS,
|
|
21
|
+
KIND_DIRECTORY,
|
|
22
|
+
describeApiNameRule,
|
|
23
|
+
indexBundle,
|
|
24
|
+
type ArtifactKind,
|
|
25
|
+
type AuthoredFile,
|
|
26
|
+
type DefinitionBundle,
|
|
27
|
+
} from '../../blueprint/model'
|
|
28
|
+
import { toFiles } from '../../blueprint/projection'
|
|
29
|
+
import { renderDrift, renderPlan } from '../../blueprint/render'
|
|
30
|
+
import { scaffold } from '../../blueprint/scaffold'
|
|
31
|
+
import { pathFor, readTree, removeFromTree, writeTree } from '../../blueprint/tree'
|
|
32
|
+
import { CliError } from '../../errors'
|
|
33
|
+
import type { Command, CommandContext } from '../types'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Declarative Blueprint authoring: the Blueprint as files, applied to a draft.
|
|
37
|
+
*
|
|
38
|
+
* The imperative verbs beside this one (`create`, `update`, `delete`) stay, and stay
|
|
39
|
+
* supported, for exploring an organization interactively. This is the path for
|
|
40
|
+
* DEPLOYING one — re-runnable, reviewable as a diff, and the same tree on every
|
|
41
|
+
* environment because nothing environment-shaped is in it.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
function api(ctx: CommandContext): BlueprintAuthoringApi {
|
|
45
|
+
return new BlueprintAuthoringApi(ctx.apiUrl, ctx.token)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Any uuid at all. The tree is portable only when it contains none. */
|
|
49
|
+
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
|
|
50
|
+
|
|
51
|
+
function root(ctx: CommandContext): string {
|
|
52
|
+
return resolve(ctx.cwd, (ctx.flags.dir as string) ?? 'blueprint')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Datasets, or nothing if this credential cannot read them.
|
|
57
|
+
*
|
|
58
|
+
* Reading datasets needs `dataset: ['read']`, which a key minted only to author the
|
|
59
|
+
* MODEL will not carry — and authoring the model does not need it. So a refusal here
|
|
60
|
+
* is a fact about the credential, not a failure of the command: `pull` still writes
|
|
61
|
+
* every artifact and simply omits `backing`, and `plan` still diffs. Only `bind`
|
|
62
|
+
* genuinely requires the capability, and it says so by name when it is missing.
|
|
63
|
+
*
|
|
64
|
+
* Letting the 403 escape made `pull` unusable for a model-only key, which is the
|
|
65
|
+
* common case for a key scoped to exactly what it needs.
|
|
66
|
+
*/
|
|
67
|
+
async function readableDatasets(
|
|
68
|
+
client: BlueprintAuthoringApi,
|
|
69
|
+
): Promise<Array<{ id?: string; name?: string; currentRevisionId?: string }> | null> {
|
|
70
|
+
try {
|
|
71
|
+
return await client.listDatasets()
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err instanceof CliError && (err.code === 'FORBIDDEN' || err.code === 'UNAUTHORIZED')) return null
|
|
74
|
+
throw err
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Dataset name → id, read once per command that needs it. */
|
|
79
|
+
async function datasetsByName(client: BlueprintAuthoringApi): Promise<Map<string, string>> {
|
|
80
|
+
const datasets = await readableDatasets(client) ?? []
|
|
81
|
+
const byName = new Map<string, string>()
|
|
82
|
+
for (const dataset of datasets) {
|
|
83
|
+
if (dataset.name && dataset.id) byName.set(dataset.name, dataset.id)
|
|
84
|
+
}
|
|
85
|
+
return byName
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Dataset revision id → dataset name, for `pull`.
|
|
90
|
+
*
|
|
91
|
+
* One request per dataset rather than per bound object type: a hundred types sharing
|
|
92
|
+
* one dataset is one lookup.
|
|
93
|
+
*/
|
|
94
|
+
async function datasetNamesByRevision(
|
|
95
|
+
client: BlueprintAuthoringApi,
|
|
96
|
+
): Promise<Map<string, string>> {
|
|
97
|
+
const names = new Map<string, string>()
|
|
98
|
+
for (const dataset of await readableDatasets(client) ?? []) {
|
|
99
|
+
if (!dataset.id || !dataset.name) continue
|
|
100
|
+
for (const revision of await client.datasetRevisions(dataset.id)) {
|
|
101
|
+
if (revision.id) names.set(revision.id, dataset.name)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return names
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function resolveRevision(
|
|
108
|
+
client: BlueprintAuthoringApi,
|
|
109
|
+
name: string,
|
|
110
|
+
): Promise<{ revisionId: string; schemaDigest: string }> {
|
|
111
|
+
const datasets = await readableDatasets(client) ?? []
|
|
112
|
+
const dataset = datasets.find((candidate) => candidate.name === name)
|
|
113
|
+
const datasetId = dataset?.id
|
|
114
|
+
if (!datasetId) {
|
|
115
|
+
throw new CliError(`No dataset named "${name}" in this organization.`, {
|
|
116
|
+
code: 'NOT_FOUND',
|
|
117
|
+
// The 403 shape is worth naming: a key minted without `dataset:read` sees an
|
|
118
|
+
// empty list rather than a refusal, which reads as "the dataset is missing".
|
|
119
|
+
hint: `frontera blueprint plan lists what is reachable. If nothing is, the key may not carry dataset:read.`,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
const revisions = await client.datasetRevisions(datasetId)
|
|
123
|
+
// The dataset names its current revision; the sorted list is only the fallback for
|
|
124
|
+
// a payload that does not carry it. Shared with `bind` — see `dataset-revision.ts`.
|
|
125
|
+
const current = pickCurrentRevision(dataset, revisions)
|
|
126
|
+
if (!current?.id || !current.schemaDigest) {
|
|
127
|
+
throw new CliError(`Dataset "${name}" has no published revision to bind to.`, {
|
|
128
|
+
code: 'FAILURE',
|
|
129
|
+
hint: 'Publish a revision of the dataset first.',
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
return { revisionId: current.id, schemaDigest: current.schemaDigest }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const blueprintPull: Command = {
|
|
136
|
+
meta: {
|
|
137
|
+
noun: 'blueprint',
|
|
138
|
+
verb: 'pull',
|
|
139
|
+
args: [],
|
|
140
|
+
flags: { dir: 'string', force: 'boolean' },
|
|
141
|
+
summary: 'Write the shared draft to a file tree you can commit',
|
|
142
|
+
examples: ['frontera blueprint pull', 'frontera blueprint pull --dir ./blueprint'],
|
|
143
|
+
},
|
|
144
|
+
async run(ctx) {
|
|
145
|
+
const client = api(ctx)
|
|
146
|
+
const { definition } = await client.draft()
|
|
147
|
+
const directory = root(ctx)
|
|
148
|
+
|
|
149
|
+
if (!ctx.flags.force && existsSync(directory) && readTree(directory).length > 0) {
|
|
150
|
+
throw new CliError(`${directory} already holds a Blueprint tree.`, {
|
|
151
|
+
code: 'USAGE',
|
|
152
|
+
// A round trip makes overwriting easy and silent, which is exactly when a
|
|
153
|
+
// confirmation earns its place.
|
|
154
|
+
hint: 'Commit or move what is there, then re-run with --force to overwrite it.',
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const names = await datasetNamesByRevision(client)
|
|
159
|
+
const model = toFiles(definition, names)
|
|
160
|
+
// The root is created even when the draft is empty. An empty Blueprint is a
|
|
161
|
+
// legitimate state — a freshly adopted organization, or one rolled back to its
|
|
162
|
+
// baseline — and leaving no directory behind made the next `plan` report "no
|
|
163
|
+
// Blueprint tree" for what is really "no artifacts yet".
|
|
164
|
+
mkdirSync(directory, { recursive: true })
|
|
165
|
+
// Anything the tree holds that the draft no longer has is deleted, not left
|
|
166
|
+
// behind: `pull` writes what the draft IS, and a leftover file would be read back
|
|
167
|
+
// by the next `plan` as an artifact somebody wants created.
|
|
168
|
+
const keep = new Set(model.files.map((file) => file.path))
|
|
169
|
+
// `--force` exists to overwrite a tree that is wrong, so reading it must not be
|
|
170
|
+
// able to refuse the overwrite. A filename/apiName mismatch throws from
|
|
171
|
+
// `readTree`, which made the broken tree the one case `--force` could not fix.
|
|
172
|
+
const stale = existingPaths(directory)
|
|
173
|
+
.filter((path) => !keep.has(path))
|
|
174
|
+
const dropped = removeFromTree(directory, stale)
|
|
175
|
+
const written = writeTree(directory, model.files)
|
|
176
|
+
// A written file may still carry identifiers the projection cannot turn into
|
|
177
|
+
// names — validation ids, lifecycle state ids. Such a tree round-trips against the
|
|
178
|
+
// SAME draft and is not portable to another deployment, and saying so here is the
|
|
179
|
+
// difference between a documented limit and a surprise at the second deployment.
|
|
180
|
+
const unportable = model.files
|
|
181
|
+
.filter((file) => UUID_PATTERN.test(JSON.stringify(file.document)))
|
|
182
|
+
.map((file) => file.path)
|
|
183
|
+
const bound = definition.objects.filter((object) =>
|
|
184
|
+
(object.governance.sourceMappings[0] as { datasetRevisionId?: string } | undefined)?.datasetRevisionId,
|
|
185
|
+
).length
|
|
186
|
+
// Said plainly rather than left to be discovered: a tree pulled without dataset
|
|
187
|
+
// access is complete as a MODEL and cannot recreate the bindings elsewhere.
|
|
188
|
+
const omitted = bound > 0 && names.size === 0
|
|
189
|
+
? ' Dataset names were not readable with this credential, so `backing` is omitted —'
|
|
190
|
+
+ ' this tree describes the model, not what it reads.'
|
|
191
|
+
: ''
|
|
192
|
+
return {
|
|
193
|
+
data: {
|
|
194
|
+
directory,
|
|
195
|
+
files: written,
|
|
196
|
+
removed: dropped,
|
|
197
|
+
backingsOmitted: omitted !== '',
|
|
198
|
+
unportable,
|
|
199
|
+
},
|
|
200
|
+
text: `Wrote ${written.length} artifact${written.length === 1 ? '' : 's'} to ${directory}.`
|
|
201
|
+
+ (dropped.length ? ` Removed ${dropped.length} no longer on the draft.` : '')
|
|
202
|
+
+ omitted
|
|
203
|
+
+ (unportable.length
|
|
204
|
+
? `\n${unportable.length} carr${unportable.length === 1 ? 'ies' : 'y'} identifiers that name `
|
|
205
|
+
+ `nothing in another deployment (${unportable.join(', ')}) — this tree round-trips here, `
|
|
206
|
+
+ 'but is not portable as it stands.'
|
|
207
|
+
: ''),
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Paths already in the tree, tolerating files this CLI would refuse to read. */
|
|
213
|
+
function existingPaths(directory: string): string[] {
|
|
214
|
+
if (!existsSync(directory)) return []
|
|
215
|
+
try {
|
|
216
|
+
return readTree(directory).map((file) => file.path)
|
|
217
|
+
} catch {
|
|
218
|
+
// A tree that cannot be parsed still has files, and `--force` is how they get
|
|
219
|
+
// replaced. Enumerated directly rather than through the reader that rejected it.
|
|
220
|
+
return ARTIFACT_KINDS.flatMap((kind) => {
|
|
221
|
+
const sub = join(directory, KIND_DIRECTORY[kind])
|
|
222
|
+
if (!existsSync(sub)) return []
|
|
223
|
+
return readdirSync(sub)
|
|
224
|
+
.filter((entry) => /\.(ya?ml|json)$/.test(entry))
|
|
225
|
+
.map((entry) => `${KIND_DIRECTORY[kind]}/${entry}`)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function computePlan(ctx: CommandContext): Promise<{
|
|
231
|
+
plan: Plan
|
|
232
|
+
files: AuthoredFile[]
|
|
233
|
+
/** The draft the plan was computed against — `apply` indexes it for id resolution. */
|
|
234
|
+
definition: DefinitionBundle
|
|
235
|
+
drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }>
|
|
236
|
+
}> {
|
|
237
|
+
const client = api(ctx)
|
|
238
|
+
const directory = root(ctx)
|
|
239
|
+
if (!existsSync(directory)) {
|
|
240
|
+
throw new CliError(`No Blueprint tree at ${directory}.`, {
|
|
241
|
+
code: 'USAGE',
|
|
242
|
+
hint: 'frontera blueprint pull, or frontera blueprint new object-type <ApiName>',
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
const files = readTree(directory)
|
|
246
|
+
const { definition, revision } = await client.draft()
|
|
247
|
+
const plan = diff(files, definition, revision)
|
|
248
|
+
|
|
249
|
+
// Per DISTINCT dataset, which is what the design promised and what the previous
|
|
250
|
+
// loop did not do: it called `datasetRevisions` once per BOUND TYPE, after
|
|
251
|
+
// `datasetNamesByRevision` had already read every dataset's revisions. A hundred
|
|
252
|
+
// types on one dataset cost a hundred redundant round trips.
|
|
253
|
+
const drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }> = []
|
|
254
|
+
if (plan.drift.length > 0) {
|
|
255
|
+
// One pass over the datasets, not two: the revisions are read once and both the
|
|
256
|
+
// revision→name map and the current-revision map are built from the same reads.
|
|
257
|
+
const names = new Map<string, string>()
|
|
258
|
+
const currentByDataset = new Map<string, { id: string; schemaDigest?: string }>()
|
|
259
|
+
for (const dataset of await readableDatasets(client) ?? []) {
|
|
260
|
+
if (!dataset.id || !dataset.name) continue
|
|
261
|
+
const revisions = await client.datasetRevisions(dataset.id)
|
|
262
|
+
for (const revision of revisions) {
|
|
263
|
+
if (revision.id) names.set(revision.id, dataset.name)
|
|
264
|
+
}
|
|
265
|
+
const current = pickCurrentRevision(dataset, revisions)
|
|
266
|
+
if (current?.id) currentByDataset.set(dataset.name, { id: current.id, schemaDigest: current.schemaDigest })
|
|
267
|
+
}
|
|
268
|
+
for (const pin of plan.drift) {
|
|
269
|
+
const datasetName = names.get(pin.datasetRevisionId)
|
|
270
|
+
if (!datasetName) continue
|
|
271
|
+
const current = currentByDataset.get(datasetName)
|
|
272
|
+
if (!current?.schemaDigest || current.schemaDigest === pin.pinnedDigest) continue
|
|
273
|
+
drift.push({ objectApiName: pin.objectApiName, datasetName, currentDigest: current.schemaDigest })
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { plan, files, definition, drift }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export const blueprintPlan: Command = {
|
|
280
|
+
meta: {
|
|
281
|
+
noun: 'blueprint',
|
|
282
|
+
verb: 'plan',
|
|
283
|
+
args: [],
|
|
284
|
+
flags: { dir: 'string', prune: 'boolean' },
|
|
285
|
+
summary: 'Show what applying the file tree would change, and write nothing',
|
|
286
|
+
examples: ['frontera blueprint plan', 'frontera blueprint plan --json'],
|
|
287
|
+
},
|
|
288
|
+
async run(ctx) {
|
|
289
|
+
const { plan, drift } = await computePlan(ctx)
|
|
290
|
+
const body = [renderPlan(plan, { prune: ctx.flags.prune === true }), renderDrift(drift)]
|
|
291
|
+
.filter(Boolean)
|
|
292
|
+
.join('\n')
|
|
293
|
+
return { data: { ...plan, drift }, text: body }
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Apply one operation, on the route that carries its guard.
|
|
299
|
+
*
|
|
300
|
+
* Creations go to the per-artifact POST rather than to a whole-bundle write: the
|
|
301
|
+
* create route mints every id, derives each property's base type from the pinned
|
|
302
|
+
* column, and writes the `governance.sourceMappings` shell that a later `bind`
|
|
303
|
+
* requires. An object type inserted any other way can never afterwards be bound.
|
|
304
|
+
*/
|
|
305
|
+
async function applyOperation(
|
|
306
|
+
client: BlueprintAuthoringApi,
|
|
307
|
+
operation: ModelOperation,
|
|
308
|
+
file: AuthoredFile,
|
|
309
|
+
revision: number,
|
|
310
|
+
sharedPropertyIds: Map<string, string>,
|
|
311
|
+
): Promise<number> {
|
|
312
|
+
const resolveDataset = (name: string) => resolveRevision(client, name)
|
|
313
|
+
|
|
314
|
+
if (operation.kind === 'shared-field') {
|
|
315
|
+
if (operation.operation === 'create') {
|
|
316
|
+
const created = await client.createSharedProperty(
|
|
317
|
+
sharedPropertyCreateBody(file, revision),
|
|
318
|
+
revision,
|
|
319
|
+
)
|
|
320
|
+
// The minted id, threaded into the property writes that follow in THIS run —
|
|
321
|
+
// the same way `expectedRevision` is threaded. Without it a tree that creates a
|
|
322
|
+
// shared field and its implementer together can only work on a second apply.
|
|
323
|
+
const id = created?.sharedProperty?.id
|
|
324
|
+
if (!id) {
|
|
325
|
+
throw new CliError(
|
|
326
|
+
`Creating shared field "${operation.apiName}" returned no id, so nothing can reference it.`,
|
|
327
|
+
{
|
|
328
|
+
code: 'FAILURE',
|
|
329
|
+
hint: 'The service is expected to answer with the created row. Re-run `frontera blueprint plan`.',
|
|
330
|
+
},
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
sharedPropertyIds.set(operation.apiName, id)
|
|
334
|
+
return nextRevision(created, revision)
|
|
335
|
+
}
|
|
336
|
+
assertApplicable('shared-field', file, operation.liveDocument)
|
|
337
|
+
const updated = await client.updateSharedProperty(
|
|
338
|
+
operation.apiName, sharedPropertyPatchBody(file, revision), revision,
|
|
339
|
+
)
|
|
340
|
+
return nextRevision(updated, revision)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (operation.kind === 'object-type') {
|
|
344
|
+
if (operation.operation === 'create') {
|
|
345
|
+
const created = await client.createObjectType(
|
|
346
|
+
await objectTypeCreateBody(file, revision, resolveDataset, sharedPropertyIds),
|
|
347
|
+
revision,
|
|
348
|
+
)
|
|
349
|
+
return nextRevision(created, revision)
|
|
350
|
+
}
|
|
351
|
+
// Refused BEFORE the first write, not after some of them. Everything this
|
|
352
|
+
// executor can express is enumerated below; a difference outside that set has no
|
|
353
|
+
// route to travel, and reporting it as applied is the defect this whole review
|
|
354
|
+
// round keeps finding at a new level. Naming the field beats a silent no-op.
|
|
355
|
+
assertApplicable('object-type', file, operation.liveDocument)
|
|
356
|
+
|
|
357
|
+
// Sent only when the metadata actually differs. The service refuses a command
|
|
358
|
+
// that would not change the draft — "Blueprint Draft command does not change the
|
|
359
|
+
// Shared Draft" — so an unconditional PUT turned a pure property change into a
|
|
360
|
+
// failed run that had applied nothing.
|
|
361
|
+
let current = revision
|
|
362
|
+
const patch = objectTypeMetadataChanges(file, operation.liveDocument)
|
|
363
|
+
if (Object.keys(patch).length > 0) {
|
|
364
|
+
const updated = await client.updateObjectType(
|
|
365
|
+
operation.apiName, { ...patch, expectedRevision: revision }, revision,
|
|
366
|
+
)
|
|
367
|
+
current = nextRevision(updated, revision)
|
|
368
|
+
}
|
|
369
|
+
current = await applyObjectKeys(client, operation.apiName, file, operation.liveDocument, current)
|
|
370
|
+
return applyPropertyPlan(
|
|
371
|
+
client, operation.apiName, file, operation.liveDocument, current, sharedPropertyIds,
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (operation.kind === 'link-type') {
|
|
376
|
+
if (operation.operation !== 'create') assertApplicable('link-type', file, operation.liveDocument)
|
|
377
|
+
const body = operation.operation === 'create'
|
|
378
|
+
? await client.createLinkType(linkTypeCreateBody(file, revision), revision)
|
|
379
|
+
: await client.updateLinkType(operation.apiName, linkTypeStructure(file, revision), revision)
|
|
380
|
+
return nextRevision(body, revision)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (operation.operation !== 'create') assertApplicable('metric', file, operation.liveDocument)
|
|
384
|
+
const result = operation.operation === 'create'
|
|
385
|
+
? await client.createMetric(metricCreateBody(file, revision), revision)
|
|
386
|
+
: await client.updateMetric(operation.apiName, metricPatchBody(file, revision), revision)
|
|
387
|
+
return nextRevision(result, revision)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The revision a write produced, so the next write can declare it.
|
|
392
|
+
*
|
|
393
|
+
* `apply` must never re-read the revision mid-run. Re-reading was the whole defect:
|
|
394
|
+
* every write then declared "expectedRevision = whatever it is right now", so a
|
|
395
|
+
* colleague editing the shared draft between two of our writes could never produce a
|
|
396
|
+
* 409 — the one guard the shared draft depends on, and the same shared-ness that makes
|
|
397
|
+
* `--prune` opt-in. Threading it forward means a concurrent edit makes OUR next
|
|
398
|
+
* expectation stale, which is exactly when the service should refuse us.
|
|
399
|
+
*
|
|
400
|
+
* The `+ 1` fallback is for a route that does not echo the revision; it stays strict,
|
|
401
|
+
* because guessing a revision the server did not confirm still gets refused if the
|
|
402
|
+
* draft moved.
|
|
403
|
+
*/
|
|
404
|
+
function nextRevision(response: unknown, previous: number): number {
|
|
405
|
+
const echoed = (response as { draftRevision?: unknown } | null)?.draftRevision
|
|
406
|
+
return typeof echoed === 'number' ? echoed : previous + 1
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* What each kind's update routes can actually express, named as the FILE names them.
|
|
411
|
+
*
|
|
412
|
+
* Per kind rather than for object types alone, because the defect is not about object
|
|
413
|
+
* types: an update reported as applied and not applied is the same lie whichever route
|
|
414
|
+
* dropped it, and every kind has fields outside what its route takes.
|
|
415
|
+
*
|
|
416
|
+
* OBJECT TYPE — `metadata` travels the PUT, the keys travel `set_object_keys`,
|
|
417
|
+
* `properties` travels the field commands. `governance`, `validations` and `lifecycle`
|
|
418
|
+
* have no command at all.
|
|
419
|
+
*
|
|
420
|
+
* LINK TYPE — `PUT /link-types/:apiName` takes the whole structure, so nearly
|
|
421
|
+
* everything a file states is expressible. `status` is not: the bundle carries it and
|
|
422
|
+
* the route has no field for it.
|
|
423
|
+
*
|
|
424
|
+
* METRIC — `PUT /metrics/:apiName` takes `displayName`, `description`, `definition`
|
|
425
|
+
* and `formatConfig`. `objectType` is deliberately absent: the subject is fixed at
|
|
426
|
+
* creation, the patch body has no channel for it, and editing it in a file was planned,
|
|
427
|
+
* counted as applied, and re-planned identically on every run afterwards.
|
|
428
|
+
*
|
|
429
|
+
* SHARED FIELD — its patch route takes every authored field, including `valueType`,
|
|
430
|
+
* which is a breaking change the release gate reports rather than something the
|
|
431
|
+
* route refuses.
|
|
432
|
+
*/
|
|
433
|
+
const APPLICABLE_FIELDS: Record<ArtifactKind, Set<string>> = {
|
|
434
|
+
'shared-field': new Set([
|
|
435
|
+
'apiName', 'displayName', 'description', 'valueType', 'propertyType', 'formatConfig',
|
|
436
|
+
'status', 'visibility',
|
|
437
|
+
]),
|
|
438
|
+
'object-type': new Set([
|
|
439
|
+
'apiName', 'displayName', 'pluralDisplayName', 'description', 'icon', 'color',
|
|
440
|
+
'status', 'visibility', 'groups', 'primaryKey', 'title', 'properties', 'backing',
|
|
441
|
+
]),
|
|
442
|
+
'link-type': new Set(['apiName', 'cardinality', 'description', 'from', 'to']),
|
|
443
|
+
metric: new Set(['apiName', 'displayName', 'description', 'definition', 'formatConfig']),
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function assertApplicable(
|
|
447
|
+
kind: ArtifactKind,
|
|
448
|
+
file: AuthoredFile,
|
|
449
|
+
live: Record<string, unknown> | undefined,
|
|
450
|
+
): void {
|
|
451
|
+
if (!live) return
|
|
452
|
+
const applicable = APPLICABLE_FIELDS[kind]
|
|
453
|
+
// The SAME predicate the plan used to decide this artifact changed. An equality
|
|
454
|
+
// comparison here would disagree with it about key order and about the fields a file
|
|
455
|
+
// omits, and refuse the run naming a field the plan never called changed.
|
|
456
|
+
const unmappable = Object.keys(file.document).filter((field) =>
|
|
457
|
+
!applicable.has(field) && !satisfies(file.document[field], live[field], field))
|
|
458
|
+
if (unmappable.length === 0) return
|
|
459
|
+
throw new CliError(
|
|
460
|
+
`${file.path}: ${unmappable.join(', ')} ${unmappable.length === 1 ? 'differs' : 'differ'} from the draft, `
|
|
461
|
+
+ 'and no authoring route can apply that.',
|
|
462
|
+
{
|
|
463
|
+
code: 'USAGE',
|
|
464
|
+
hint: kind === 'metric' && unmappable.includes('objectType')
|
|
465
|
+
? 'A metric measures one object type for its lifetime. To move it, delete the file and '
|
|
466
|
+
+ '`apply --prune`, then write it again against the new object type.'
|
|
467
|
+
: 'Revert those fields to match the draft, or change them in the Console. '
|
|
468
|
+
+ 'Applying would report a change that did not happen.',
|
|
469
|
+
},
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* `primaryKey` and `title` — the two references a file states by property apiName.
|
|
475
|
+
*
|
|
476
|
+
* Structural, so they travel `set_object_keys` rather than the metadata PUT. Without
|
|
477
|
+
* this, editing either was planned, counted as applied, and never written.
|
|
478
|
+
*/
|
|
479
|
+
async function applyObjectKeys(
|
|
480
|
+
client: BlueprintAuthoringApi,
|
|
481
|
+
apiName: string,
|
|
482
|
+
file: AuthoredFile,
|
|
483
|
+
live: Record<string, unknown> | undefined,
|
|
484
|
+
revision: number,
|
|
485
|
+
): Promise<number> {
|
|
486
|
+
const wantedKey = file.document.primaryKey
|
|
487
|
+
const wantedTitle = file.document.title
|
|
488
|
+
const keyChanged = wantedKey !== undefined && wantedKey !== live?.primaryKey
|
|
489
|
+
const titleChanged = wantedTitle !== undefined && wantedTitle !== live?.title
|
|
490
|
+
if (!keyChanged && !titleChanged) return revision
|
|
491
|
+
|
|
492
|
+
const detail = await client.getObjectTypeDetail(apiName)
|
|
493
|
+
if (!detail?.id) return revision
|
|
494
|
+
const idByApiName = new Map(
|
|
495
|
+
detail.properties
|
|
496
|
+
.filter((property) => property.apiName && property.id)
|
|
497
|
+
.map((property) => [String(property.apiName), String(property.id)]),
|
|
498
|
+
)
|
|
499
|
+
const resolve = (name: unknown, field: string) => {
|
|
500
|
+
const id = idByApiName.get(String(name))
|
|
501
|
+
if (!id) {
|
|
502
|
+
throw new CliError(`${file.path}: ${field} names "${name}", which is not one of its properties.`, {
|
|
503
|
+
code: 'USAGE',
|
|
504
|
+
hint: 'Both keys name a property by apiName.',
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
return id
|
|
508
|
+
}
|
|
509
|
+
// Both ids are required by the command, so the unchanged one is restated from the
|
|
510
|
+
// file rather than omitted — `set_object_keys` sets the pair, it does not patch one.
|
|
511
|
+
const result = await client.draftCommand({
|
|
512
|
+
kind: 'set_object_keys',
|
|
513
|
+
objectId: detail.id,
|
|
514
|
+
primaryKeyPropertyId: resolve(wantedKey ?? live?.primaryKey, 'primaryKey'),
|
|
515
|
+
titlePropertyId: resolve(wantedTitle ?? live?.title, 'title'),
|
|
516
|
+
}, revision)
|
|
517
|
+
return result.revision
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Object-level metadata the file states and the draft does not already agree with. */
|
|
521
|
+
function objectTypeMetadataChanges(
|
|
522
|
+
file: AuthoredFile,
|
|
523
|
+
live: Record<string, unknown> | undefined,
|
|
524
|
+
): Record<string, unknown> {
|
|
525
|
+
const document = file.document
|
|
526
|
+
const patch: Record<string, unknown> = {}
|
|
527
|
+
const pairs: Array<[string, string]> = [
|
|
528
|
+
['displayName', 'displayName'],
|
|
529
|
+
['pluralDisplayName', 'pluralName'],
|
|
530
|
+
['description', 'description'],
|
|
531
|
+
['icon', 'icon'],
|
|
532
|
+
['color', 'color'],
|
|
533
|
+
['status', 'status'],
|
|
534
|
+
['visibility', 'visibility'],
|
|
535
|
+
]
|
|
536
|
+
for (const [fileField, wireField] of pairs) {
|
|
537
|
+
const value = document[fileField]
|
|
538
|
+
if (value !== undefined && value !== live?.[fileField]) patch[wireField] = value
|
|
539
|
+
}
|
|
540
|
+
const groups = document.groups
|
|
541
|
+
if (Array.isArray(groups) && JSON.stringify(groups) !== JSON.stringify(live?.groups ?? [])) {
|
|
542
|
+
patch.groups = groups
|
|
543
|
+
}
|
|
544
|
+
return patch
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Reconcile one object type's properties.
|
|
549
|
+
*
|
|
550
|
+
* `PUT /object-types/:apiName` takes METADATA only and ignores `properties`, so every
|
|
551
|
+
* field change has to travel a draft command. Additions were wired first and the rest
|
|
552
|
+
* were left with a comment claiming they were "refused by name" — they were not; they
|
|
553
|
+
* were dropped, counted as applied, and re-reported by the next `plan` forever. A
|
|
554
|
+
* planning tool that says it did something it did not is the one failure worth
|
|
555
|
+
* refusing outright, so anything this cannot map now aborts BEFORE the first write.
|
|
556
|
+
*
|
|
557
|
+
* Mapped: add, remove, and a change to any field the file can state. Unmapped: a
|
|
558
|
+
* property rename, which is indistinguishable from a remove plus an add for the same
|
|
559
|
+
* reason an object-type rename is (`rename` exists for that, and there is no
|
|
560
|
+
* per-property equivalent).
|
|
561
|
+
*/
|
|
562
|
+
interface PropertyPlan {
|
|
563
|
+
adds: Array<Record<string, unknown>>
|
|
564
|
+
removes: Array<{ id: string; apiName: string }>
|
|
565
|
+
schemaChanges: Array<{ id: string; apiName: string; patch: Record<string, unknown> }>
|
|
566
|
+
metadataChanges: Array<{ id: string; apiName: string; patch: Record<string, unknown> }>
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Fields a file states that live on `update_field_schema`. */
|
|
570
|
+
const SCHEMA_FIELDS = ['valueType', 'propertyType', 'required', 'unique'] as const
|
|
571
|
+
/** …and on `update_field_metadata`. */
|
|
572
|
+
const METADATA_FIELDS = ['displayName', 'description', 'status', 'visibility'] as const
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* @param live the projected live artifact — the same shape the FILE is written in.
|
|
576
|
+
*
|
|
577
|
+
* Not the route payload. The route answers properties in the catalog's own vocabulary,
|
|
578
|
+
* where the bundle's `valueType` does not exist; comparing against it made every
|
|
579
|
+
* declared `valueType` read as a change and produced a patch per property that changed
|
|
580
|
+
* nothing. The service refuses a command that changes nothing, so a pure removal
|
|
581
|
+
* failed with "does not change the Shared Draft" and applied none of it.
|
|
582
|
+
*
|
|
583
|
+
* @param idByApiName the route payload's ids, which the projection deliberately omits.
|
|
584
|
+
*/
|
|
585
|
+
function planProperties(
|
|
586
|
+
file: AuthoredFile,
|
|
587
|
+
live: Array<Record<string, unknown>>,
|
|
588
|
+
backing: ReturnType<typeof readBacking>,
|
|
589
|
+
idByApiName: Map<string, string>,
|
|
590
|
+
): PropertyPlan {
|
|
591
|
+
const declared = Array.isArray(file.document.properties)
|
|
592
|
+
? (file.document.properties as Array<Record<string, unknown>>)
|
|
593
|
+
: []
|
|
594
|
+
const liveByName = new Map(live.map((property) => [String(property.apiName), property]))
|
|
595
|
+
const declaredNames = new Set(declared.map((property) => String(property.apiName)))
|
|
596
|
+
|
|
597
|
+
const plan: PropertyPlan = { adds: [], removes: [], schemaChanges: [], metadataChanges: [] }
|
|
598
|
+
|
|
599
|
+
for (const property of declared) {
|
|
600
|
+
const name = String(property.apiName)
|
|
601
|
+
const counterpart = liveByName.get(name)
|
|
602
|
+
if (!counterpart) {
|
|
603
|
+
plan.adds.push(property)
|
|
604
|
+
continue
|
|
605
|
+
}
|
|
606
|
+
const schemaPatch: Record<string, unknown> = {}
|
|
607
|
+
for (const field of SCHEMA_FIELDS) {
|
|
608
|
+
if (property[field] !== undefined && property[field] !== counterpart[field]) {
|
|
609
|
+
schemaPatch[field] = property[field]
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// The column a property reads lives in `backing.mapping`, not beside the property.
|
|
613
|
+
const mapped = backing?.mapping?.[name]
|
|
614
|
+
const column = mapped === undefined ? undefined : typeof mapped === 'string' ? mapped : mapped.column
|
|
615
|
+
if (column !== undefined && column !== counterpart.column) schemaPatch.sourceColumn = column
|
|
616
|
+
|
|
617
|
+
const metadataPatch: Record<string, unknown> = {}
|
|
618
|
+
for (const field of METADATA_FIELDS) {
|
|
619
|
+
if (property[field] !== undefined && property[field] !== counterpart[field]) {
|
|
620
|
+
metadataPatch[field] = property[field]
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Carried by NAME through the plan and resolved to an id at write time: the
|
|
624
|
+
// shared field may not exist yet when the plan is computed. Attaching to a
|
|
625
|
+
// shared field is `update_field_metadata`'s job, so it travels that patch.
|
|
626
|
+
//
|
|
627
|
+
// An authored `null` means detached, and a live field with no reference is
|
|
628
|
+
// ALREADY that — so the two are compared as the same state. Treating them as
|
|
629
|
+
// different made a file that says `sharedField: null` re-plan a detach on
|
|
630
|
+
// every run and then fail it, because the applied command changes nothing and
|
|
631
|
+
// the service refuses a command that does not change the Shared Draft.
|
|
632
|
+
if (property.sharedField !== undefined) {
|
|
633
|
+
const wanted = property.sharedField === null ? null : property.sharedField
|
|
634
|
+
const current = counterpart.sharedField ?? null
|
|
635
|
+
if (wanted !== current) metadataPatch.sharedField = wanted
|
|
636
|
+
}
|
|
637
|
+
const id = idByApiName.get(name)
|
|
638
|
+
// A declared property with no live id is either brand new (handled above) or the
|
|
639
|
+
// draft moved; skipping it quietly is how a change goes missing.
|
|
640
|
+
if (!id) continue
|
|
641
|
+
if (Object.keys(schemaPatch).length > 0) plan.schemaChanges.push({ id, apiName: name, patch: schemaPatch })
|
|
642
|
+
if (Object.keys(metadataPatch).length > 0) {
|
|
643
|
+
plan.metadataChanges.push({ id, apiName: name, patch: metadataPatch })
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
for (const property of live) {
|
|
648
|
+
const name = String(property.apiName)
|
|
649
|
+
const id = idByApiName.get(name)
|
|
650
|
+
if (!declaredNames.has(name) && id) plan.removes.push({ id, apiName: name })
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return plan
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function applyPropertyPlan(
|
|
657
|
+
client: BlueprintAuthoringApi,
|
|
658
|
+
apiName: string,
|
|
659
|
+
file: AuthoredFile,
|
|
660
|
+
liveDocument: Record<string, unknown> | undefined,
|
|
661
|
+
revision: number,
|
|
662
|
+
sharedPropertyIds: Map<string, string>,
|
|
663
|
+
): Promise<number> {
|
|
664
|
+
const detail = await client.getObjectTypeDetail(apiName)
|
|
665
|
+
if (!detail?.id) {
|
|
666
|
+
// Silently returning here reported the operation as applied. The plan said this
|
|
667
|
+
// type exists; if it does not, the plan is stale and saying so is the answer.
|
|
668
|
+
throw new CliError(`"${apiName}" is no longer on the draft.`, {
|
|
669
|
+
code: 'CONFLICT',
|
|
670
|
+
hint: 'frontera blueprint plan — the draft moved under this run.',
|
|
671
|
+
})
|
|
672
|
+
}
|
|
673
|
+
const backing = readBacking(file)
|
|
674
|
+
const idByApiName = new Map(
|
|
675
|
+
detail.properties
|
|
676
|
+
.filter((property) => property.apiName && property.id)
|
|
677
|
+
.map((property) => [String(property.apiName), String(property.id)]),
|
|
678
|
+
)
|
|
679
|
+
const liveProperties = Array.isArray(liveDocument?.properties)
|
|
680
|
+
? (liveDocument.properties as Array<Record<string, unknown>>)
|
|
681
|
+
: []
|
|
682
|
+
const plan = planProperties(file, liveProperties, backing, idByApiName)
|
|
683
|
+
|
|
684
|
+
// Every add needs a column, and the service offers no name-match fallback. Checked
|
|
685
|
+
// for ALL of them before the first write, so a partial run cannot start.
|
|
686
|
+
for (const property of plan.adds) {
|
|
687
|
+
const name = String(property.apiName)
|
|
688
|
+
if (backing?.mapping?.[name] === undefined) {
|
|
689
|
+
throw new CliError(
|
|
690
|
+
`${file.path}: new property "${name}" has no column in \`backing.mapping\`.`,
|
|
691
|
+
{
|
|
692
|
+
code: 'USAGE',
|
|
693
|
+
hint: 'A property reads a column, and there is no name-match fallback. Add it to backing.mapping.',
|
|
694
|
+
},
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// A property that leaves AND one that arrives in the same edit is the shape a
|
|
700
|
+
// rename takes, and it is indistinguishable from a genuine replacement — the two
|
|
701
|
+
// have opposite consequences for the data behind the field. The earlier guard only
|
|
702
|
+
// caught it when `backing.mapping` lacked the new name; update the mapping too and
|
|
703
|
+
// it silently ran remove_field + add_field, which is the data drop the refusal was
|
|
704
|
+
// supposed to prevent.
|
|
705
|
+
if (plan.adds.length > 0 && plan.removes.length > 0) {
|
|
706
|
+
throw new CliError(
|
|
707
|
+
`${file.path}: this edit removes ${plan.removes.map((entry) => entry.apiName).join(', ')} and adds `
|
|
708
|
+
+ `${plan.adds.map((entry) => String(entry.apiName)).join(', ')} at once.`,
|
|
709
|
+
{
|
|
710
|
+
code: 'USAGE',
|
|
711
|
+
hint: 'If that is a rename, there is no per-property rename command — do the removal and the '
|
|
712
|
+
+ 'addition in separate applies, so the data consequence is a decision rather than a side effect.',
|
|
713
|
+
},
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
let current = revision
|
|
718
|
+
for (const property of plan.adds) {
|
|
719
|
+
const name = String(property.apiName)
|
|
720
|
+
const mapped = backing!.mapping[name]!
|
|
721
|
+
const column = typeof mapped === 'string' ? mapped : mapped.column
|
|
722
|
+
const sourceField = typeof mapped === 'string' ? undefined : mapped.field
|
|
723
|
+
// Resolved here rather than at plan time: a shared field created earlier in
|
|
724
|
+
// this same run only entered the map when its create answered.
|
|
725
|
+
const sharedPropertyId = resolveSharedProperty(
|
|
726
|
+
file, name, property.sharedField, sharedPropertyIds,
|
|
727
|
+
)
|
|
728
|
+
const result = await client.draftCommand({
|
|
729
|
+
kind: 'add_field',
|
|
730
|
+
objectId: detail.id,
|
|
731
|
+
field: {
|
|
732
|
+
// The one identifier the CLI generates, and it never reaches a file: the
|
|
733
|
+
// command addresses a field that does not exist yet, so there is nothing on
|
|
734
|
+
// the draft to resolve a name against. It lives for one request.
|
|
735
|
+
id: crypto.randomUUID(),
|
|
736
|
+
apiName: name,
|
|
737
|
+
displayName: String(property.displayName ?? name),
|
|
738
|
+
propertyType: (property.propertyType as string | undefined) ?? 'attribute',
|
|
739
|
+
valueType: String(property.valueType ?? 'string'),
|
|
740
|
+
sourceColumn: column,
|
|
741
|
+
...(sourceField === undefined || sourceField === null ? {} : { sourceField }),
|
|
742
|
+
required: Boolean(property.required ?? false),
|
|
743
|
+
unique: Boolean(property.unique ?? false),
|
|
744
|
+
status: 'active',
|
|
745
|
+
visibility: 'normal',
|
|
746
|
+
// A field being ADDED has no reference to detach, so an authored `null`
|
|
747
|
+
// is simply "no shared field" — `add_field`'s uuid is not nullable, and a
|
|
748
|
+
// detach command on a field that is being created makes no sense anyway.
|
|
749
|
+
...(typeof sharedPropertyId === 'string' ? { sharedPropertyId } : {}),
|
|
750
|
+
},
|
|
751
|
+
}, current)
|
|
752
|
+
current = result.revision
|
|
753
|
+
}
|
|
754
|
+
for (const change of plan.schemaChanges) {
|
|
755
|
+
const result = await client.draftCommand({
|
|
756
|
+
kind: 'update_field_schema', objectId: detail.id, fieldId: change.id, patch: change.patch,
|
|
757
|
+
}, current)
|
|
758
|
+
current = result.revision
|
|
759
|
+
}
|
|
760
|
+
for (const change of plan.metadataChanges) {
|
|
761
|
+
// `sharedField` is the file's name for it and `sharedPropertyId` the wire's.
|
|
762
|
+
// `null` survives the resolution and travels as `null`: that is the detach
|
|
763
|
+
// signal on both sides, and dropping it left this patch empty.
|
|
764
|
+
const { sharedField, ...patch } = change.patch
|
|
765
|
+
const sharedPropertyId = resolveSharedProperty(
|
|
766
|
+
file, change.apiName, sharedField, sharedPropertyIds,
|
|
767
|
+
)
|
|
768
|
+
const result = await client.draftCommand({
|
|
769
|
+
kind: 'update_field_metadata',
|
|
770
|
+
objectId: detail.id,
|
|
771
|
+
fieldId: change.id,
|
|
772
|
+
patch: {
|
|
773
|
+
...patch,
|
|
774
|
+
...(sharedPropertyId === undefined ? {} : { sharedPropertyId }),
|
|
775
|
+
},
|
|
776
|
+
}, current)
|
|
777
|
+
current = result.revision
|
|
778
|
+
}
|
|
779
|
+
for (const removal of plan.removes) {
|
|
780
|
+
const result = await client.draftCommand({
|
|
781
|
+
kind: 'remove_field', objectId: detail.id, fieldId: removal.id,
|
|
782
|
+
}, current)
|
|
783
|
+
current = result.revision
|
|
784
|
+
}
|
|
785
|
+
return current
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function pruneOperation(
|
|
789
|
+
client: BlueprintAuthoringApi,
|
|
790
|
+
operation: ModelOperation,
|
|
791
|
+
revision: number,
|
|
792
|
+
): Promise<number> {
|
|
793
|
+
if (operation.kind === 'object-type') {
|
|
794
|
+
const objectType = await client.getObjectType(operation.apiName)
|
|
795
|
+
if (!objectType?.id) return revision
|
|
796
|
+
// Structural: preview, then apply the exact preview. Migration blockers the
|
|
797
|
+
// preview reports abort the run with them printed — never bypassed.
|
|
798
|
+
const removed = await client.removeObject(objectType.id, revision)
|
|
799
|
+
return removed.revision
|
|
800
|
+
}
|
|
801
|
+
// Deleting a shared field DEGRADES its implementers rather than being refused:
|
|
802
|
+
// each keeps the description and format it was inheriting. Deleting a label must
|
|
803
|
+
// not delete the mappings underneath it.
|
|
804
|
+
const result = operation.kind === 'link-type'
|
|
805
|
+
? await client.deleteLinkType(operation.apiName, revision)
|
|
806
|
+
: operation.kind === 'shared-field'
|
|
807
|
+
? await client.deleteSharedProperty(operation.apiName, revision)
|
|
808
|
+
: await client.deleteMetric(operation.apiName, revision)
|
|
809
|
+
return nextRevision(result, revision)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export const blueprintApply: Command = {
|
|
813
|
+
meta: {
|
|
814
|
+
noun: 'blueprint',
|
|
815
|
+
verb: 'apply',
|
|
816
|
+
args: [],
|
|
817
|
+
flags: { dir: 'string', prune: 'boolean', yes: 'boolean' },
|
|
818
|
+
aliases: { y: 'yes' },
|
|
819
|
+
summary: 'Reconcile the shared draft with the file tree',
|
|
820
|
+
examples: [
|
|
821
|
+
'frontera blueprint apply --yes',
|
|
822
|
+
'frontera blueprint apply --prune --yes',
|
|
823
|
+
],
|
|
824
|
+
},
|
|
825
|
+
async run(ctx) {
|
|
826
|
+
const client = api(ctx)
|
|
827
|
+
const prune = ctx.flags.prune === true
|
|
828
|
+
const { plan, files, definition, drift } = await computePlan(ctx)
|
|
829
|
+
|
|
830
|
+
if (isEmpty(plan)) {
|
|
831
|
+
return { data: { ...plan, applied: [] }, text: renderPlan(plan) }
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Drift is REPORTED, and blocks only the artifacts it is about. It used to throw
|
|
835
|
+
// before the plan was even shown, so a colleague's type reading a moved dataset
|
|
836
|
+
// refused an unrelated creation and hid what the run would have done. A rebind is
|
|
837
|
+
// `bind`'s act either way — the plan says so on the line.
|
|
838
|
+
const driftingNames = new Set(drift.map((entry) => entry.objectApiName))
|
|
839
|
+
const blocked = plan.operations.filter((operation) =>
|
|
840
|
+
operation.kind === 'object-type' && driftingNames.has(operation.apiName))
|
|
841
|
+
if (blocked.length > 0) {
|
|
842
|
+
throw new CliError(
|
|
843
|
+
`${blocked.map((operation) => operation.apiName).join(', ')} read a dataset that now publishes a `
|
|
844
|
+
+ 'different column contract. Re-bind before applying those.',
|
|
845
|
+
{ code: 'CONFLICT', hint: renderDrift(drift) || 'frontera blueprint plan' },
|
|
846
|
+
)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (ctx.flags.yes !== true) {
|
|
850
|
+
throw new CliError(
|
|
851
|
+
`This would apply ${plan.operations.length} change${plan.operations.length === 1 ? '' : 's'}.\n`
|
|
852
|
+
+ `${renderPlan(plan, { prune })}\n\nRe-run with --yes to apply.`,
|
|
853
|
+
{ code: 'USAGE', hint: 'frontera blueprint apply --yes' },
|
|
854
|
+
)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const byKey = new Map(files.map((file) => [`${file.kind}:${file.apiName}`, file]))
|
|
858
|
+
const applied: string[] = []
|
|
859
|
+
try {
|
|
860
|
+
// Creations before updates, and object types before the kinds that reference
|
|
861
|
+
// them: a link names both of its endpoints, and a metric names the type it
|
|
862
|
+
// measures. `ARTIFACT_KINDS` fixes that order.
|
|
863
|
+
const ordered = [...plan.operations].sort(byKindThenCreateFirst)
|
|
864
|
+
// Seeded from the revision the PLAN was computed against, then advanced by what
|
|
865
|
+
// each write returns. Never re-read — see `nextRevision`.
|
|
866
|
+
let revision = plan.revision
|
|
867
|
+
// Shared field apiName → id: what the draft already had, plus what this run
|
|
868
|
+
// creates. A file references a shared field by name and the wire takes an
|
|
869
|
+
// id, and one created in this run has no id until its create answers.
|
|
870
|
+
const sharedPropertyIds = indexBundle(definition).sharedPropertyIdByApiName
|
|
871
|
+
for (const operation of ordered) {
|
|
872
|
+
const file = byKey.get(`${operation.kind}:${operation.apiName}`)!
|
|
873
|
+
revision = await applyOperation(client, operation, file, revision, sharedPropertyIds)
|
|
874
|
+
applied.push(`${operation.operation} ${operation.kind} ${operation.apiName}`)
|
|
875
|
+
}
|
|
876
|
+
if (prune) {
|
|
877
|
+
for (const operation of plan.prunes) {
|
|
878
|
+
revision = await pruneOperation(client, operation, revision)
|
|
879
|
+
applied.push(`delete ${operation.kind} ${operation.apiName}`)
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
} catch (err) {
|
|
883
|
+
// What landed is as important as what failed: the draft is shared, and a
|
|
884
|
+
// partial run that does not say where it stopped cannot be resumed safely.
|
|
885
|
+
const done = applied.length
|
|
886
|
+
? `Applied before failing:\n${applied.map((line) => ` ${line}`).join('\n')}\n`
|
|
887
|
+
: 'Nothing was applied.\n'
|
|
888
|
+
throw new CliError(`${done}${(err as Error).message}`, {
|
|
889
|
+
code: err instanceof CliError ? err.code : 'FAILURE',
|
|
890
|
+
hint: 'frontera blueprint plan — re-plan against the current draft, then re-apply.',
|
|
891
|
+
})
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const revision = await client.revision()
|
|
895
|
+
return {
|
|
896
|
+
data: { applied, revision },
|
|
897
|
+
text: `${renderPlan(plan, { prune })}\n\nApplied ${applied.length} change`
|
|
898
|
+
+ `${applied.length === 1 ? '' : 's'}. Draft is at revision ${revision}.`,
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function byKindThenCreateFirst(left: ModelOperation, right: ModelOperation): number {
|
|
904
|
+
const kindOrder = ARTIFACT_KINDS.indexOf(left.kind) - ARTIFACT_KINDS.indexOf(right.kind)
|
|
905
|
+
if (kindOrder !== 0) return kindOrder
|
|
906
|
+
const rank = (operation: ModelOperation) => (operation.operation === 'create' ? 0 : 1)
|
|
907
|
+
return rank(left) - rank(right)
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
export const blueprintNew: Command = {
|
|
911
|
+
meta: {
|
|
912
|
+
noun: 'blueprint',
|
|
913
|
+
verb: 'new',
|
|
914
|
+
args: [
|
|
915
|
+
{ name: 'kind', required: true, description: `One of: ${ARTIFACT_KINDS.join(', ')}` },
|
|
916
|
+
{ name: 'apiName', required: true, description: 'The artifact’s API name' },
|
|
917
|
+
],
|
|
918
|
+
flags: { dir: 'string' },
|
|
919
|
+
offline: true,
|
|
920
|
+
summary: 'Scaffold an artifact file that validates as written',
|
|
921
|
+
examples: [
|
|
922
|
+
'frontera blueprint new object-type Customer',
|
|
923
|
+
'frontera blueprint new link-type customerHoldsPolicy',
|
|
924
|
+
'frontera blueprint new shared-field accountCode',
|
|
925
|
+
],
|
|
926
|
+
},
|
|
927
|
+
async run(ctx) {
|
|
928
|
+
const kind = ctx.positional[0] as ArtifactKind
|
|
929
|
+
if (!ARTIFACT_KINDS.includes(kind)) {
|
|
930
|
+
throw new CliError(
|
|
931
|
+
`Unknown kind "${ctx.positional[0] ?? ''}". Expected one of: ${ARTIFACT_KINDS.join(', ')}.`,
|
|
932
|
+
{ code: 'USAGE', hint: 'frontera blueprint new --help' },
|
|
933
|
+
)
|
|
934
|
+
}
|
|
935
|
+
const apiName = ctx.positional[1]
|
|
936
|
+
if (!apiName) {
|
|
937
|
+
throw new CliError('An apiName is required.', {
|
|
938
|
+
code: 'USAGE',
|
|
939
|
+
hint: `frontera blueprint new ${kind} <apiName>`,
|
|
940
|
+
})
|
|
941
|
+
}
|
|
942
|
+
if (!API_NAME_RULE[kind].test(apiName)) {
|
|
943
|
+
throw new CliError(`"${apiName}" is not a valid ${kind} apiName.`, {
|
|
944
|
+
code: 'USAGE',
|
|
945
|
+
hint: `${describeApiNameRule(kind)} — and it becomes the file name, so nothing else fits.`,
|
|
946
|
+
})
|
|
947
|
+
}
|
|
948
|
+
const directory = root(ctx)
|
|
949
|
+
const relative = pathFor(kind, apiName)
|
|
950
|
+
if (existsSync(resolve(directory, relative))) {
|
|
951
|
+
throw new CliError(`${relative} already exists.`, {
|
|
952
|
+
code: 'USAGE',
|
|
953
|
+
hint: 'Edit it, or choose another apiName.',
|
|
954
|
+
})
|
|
955
|
+
}
|
|
956
|
+
const file: AuthoredFile = {
|
|
957
|
+
kind,
|
|
958
|
+
apiName,
|
|
959
|
+
path: relative,
|
|
960
|
+
document: scaffold(kind, apiName),
|
|
961
|
+
}
|
|
962
|
+
writeTree(directory, [file])
|
|
963
|
+
return {
|
|
964
|
+
data: { path: relative },
|
|
965
|
+
text: `Wrote ${relative}. Edit it, then \`frontera blueprint plan\`.`,
|
|
966
|
+
}
|
|
967
|
+
},
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
export const blueprintRename: Command = {
|
|
971
|
+
meta: {
|
|
972
|
+
noun: 'blueprint',
|
|
973
|
+
verb: 'rename',
|
|
974
|
+
args: [
|
|
975
|
+
{ name: 'kind', required: true, description: 'object-type' },
|
|
976
|
+
{ name: 'from', required: true, description: 'Current apiName' },
|
|
977
|
+
{ name: 'to', required: true, description: 'New apiName' },
|
|
978
|
+
],
|
|
979
|
+
flags: { dir: 'string' },
|
|
980
|
+
summary: 'Rename an object type, and its file, as one act',
|
|
981
|
+
examples: ['frontera blueprint rename object-type Client Customer'],
|
|
982
|
+
},
|
|
983
|
+
async run(ctx) {
|
|
984
|
+
const [kind, from, to] = ctx.positional
|
|
985
|
+
if (kind === 'object-type' && to && !API_NAME_RULE['object-type'].test(to)) {
|
|
986
|
+
throw new CliError(`"${to}" is not a valid object-type apiName.`, {
|
|
987
|
+
code: 'USAGE',
|
|
988
|
+
hint: `${describeApiNameRule('object-type')} — and it becomes the file name.`,
|
|
989
|
+
})
|
|
990
|
+
}
|
|
991
|
+
if (kind !== 'object-type') {
|
|
992
|
+
throw new CliError('Only object types can be renamed in place.', {
|
|
993
|
+
code: 'USAGE',
|
|
994
|
+
// Being specific about the alternative rather than just refusing: for the
|
|
995
|
+
// other kinds a delete-and-create is genuinely equivalent, because nothing
|
|
996
|
+
// hangs off their identity the way data hangs off an object type's.
|
|
997
|
+
hint: 'For a link type, metric or action, rename the file and apply with --prune. '
|
|
998
|
+
+ 'A shared field renamed that way is DELETED and recreated, which detaches every '
|
|
999
|
+
+ 'field implementing it — rename it in the Console instead.',
|
|
1000
|
+
})
|
|
1001
|
+
}
|
|
1002
|
+
if (!from || !to) {
|
|
1003
|
+
throw new CliError('Both the current and the new apiName are required.', {
|
|
1004
|
+
code: 'USAGE',
|
|
1005
|
+
hint: 'frontera blueprint rename object-type <from> <to>',
|
|
1006
|
+
})
|
|
1007
|
+
}
|
|
1008
|
+
const client = api(ctx)
|
|
1009
|
+
const objectType = await client.getObjectType(from)
|
|
1010
|
+
if (!objectType?.id) {
|
|
1011
|
+
throw new CliError(`No object type "${from}" on the draft.`, {
|
|
1012
|
+
code: 'NOT_FOUND',
|
|
1013
|
+
hint: 'frontera blueprint catalog',
|
|
1014
|
+
})
|
|
1015
|
+
}
|
|
1016
|
+
await client.draftCommand(
|
|
1017
|
+
{ kind: 'rename_object_api', objectId: objectType.id, apiName: to },
|
|
1018
|
+
await client.revision(),
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
// The file moves with it. Leaving the tree behind would make the next `plan`
|
|
1022
|
+
// report a create plus a prune — the exact pair this command exists to avoid.
|
|
1023
|
+
const directory = root(ctx)
|
|
1024
|
+
const files = existsSync(directory) ? readTree(directory) : []
|
|
1025
|
+
const existing = files.find((file) => file.kind === 'object-type' && file.apiName === from)
|
|
1026
|
+
if (existing) {
|
|
1027
|
+
writeTree(directory, [{
|
|
1028
|
+
kind: 'object-type',
|
|
1029
|
+
apiName: to,
|
|
1030
|
+
path: pathFor('object-type', to),
|
|
1031
|
+
document: { ...existing.document, apiName: to },
|
|
1032
|
+
}])
|
|
1033
|
+
// The old file is DELETED, not left for the reader to tidy. Leaving it produced
|
|
1034
|
+
// exactly the create-plus-prune pair this command exists to avoid, which made
|
|
1035
|
+
// the instruction to delete it a step you had to obey for the rename to hold.
|
|
1036
|
+
removeFromTree(directory, [existing.path])
|
|
1037
|
+
}
|
|
1038
|
+
return {
|
|
1039
|
+
data: { from, to, movedFile: Boolean(existing) },
|
|
1040
|
+
text: `Renamed object type "${from}" to "${to}".`
|
|
1041
|
+
+ (existing ? ` Moved ${existing.path} to ${pathFor('object-type', to)}.` : ''),
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export const blueprintDeclarativeCommands: Command[] = [
|
|
1047
|
+
blueprintPull,
|
|
1048
|
+
blueprintPlan,
|
|
1049
|
+
blueprintApply,
|
|
1050
|
+
blueprintNew,
|
|
1051
|
+
blueprintRename,
|
|
1052
|
+
]
|