@frontera-sdk/cli 1.43.6 → 1.43.8
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 +3 -3
- package/src/api/automation-api.ts +86 -6
- package/src/api/blueprint-authoring-api.ts +38 -0
- package/src/automation-template.ts +13 -3
- package/src/blueprint/compile.ts +168 -2
- package/src/blueprint/diff.ts +132 -2
- package/src/blueprint/model.ts +48 -13
- package/src/blueprint/projection.ts +217 -21
- package/src/blueprint/render.ts +24 -1
- package/src/blueprint/scaffold.ts +149 -13
- package/src/commands/automation/build-entry.ts +73 -0
- package/src/commands/automation/dev.ts +453 -0
- package/src/commands/automation/index-commands.ts +5 -55
- package/src/commands/automation/run.ts +127 -10
- package/src/commands/blueprint/declarative.ts +480 -37
- package/src/flag-help.ts +9 -0
- package/src/vendor/sdk-sources.json +10 -9
|
@@ -6,15 +6,27 @@ import {
|
|
|
6
6
|
linkTypeCreateBody,
|
|
7
7
|
linkTypeStructure,
|
|
8
8
|
objectTypeCreateBody,
|
|
9
|
+
actionBody,
|
|
9
10
|
metricCreateBody,
|
|
10
11
|
metricPatchBody,
|
|
11
12
|
readBacking,
|
|
13
|
+
resolveSemanticType,
|
|
12
14
|
resolveSharedProperty,
|
|
15
|
+
semanticTypeCreateBody,
|
|
16
|
+
semanticTypePatchBody,
|
|
13
17
|
sharedPropertyCreateBody,
|
|
14
18
|
sharedPropertyPatchBody,
|
|
19
|
+
type AuthoringIds,
|
|
15
20
|
} from '../../blueprint/compile'
|
|
16
21
|
import { pickCurrentRevision } from '../../blueprint/dataset-revision'
|
|
17
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
assertPrunesLeaveNoDanglingReference,
|
|
24
|
+
diff,
|
|
25
|
+
isEmpty,
|
|
26
|
+
satisfies,
|
|
27
|
+
type ModelOperation,
|
|
28
|
+
type Plan,
|
|
29
|
+
} from '../../blueprint/diff'
|
|
18
30
|
import {
|
|
19
31
|
API_NAME_RULE,
|
|
20
32
|
ARTIFACT_KINDS,
|
|
@@ -27,7 +39,12 @@ import {
|
|
|
27
39
|
} from '../../blueprint/model'
|
|
28
40
|
import { toFiles } from '../../blueprint/projection'
|
|
29
41
|
import { renderDrift, renderPlan } from '../../blueprint/render'
|
|
30
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
SEMANTIC_TYPE_TEMPLATE_NAMES,
|
|
44
|
+
isSemanticTypeTemplate,
|
|
45
|
+
scaffold,
|
|
46
|
+
type SemanticTypeTemplate,
|
|
47
|
+
} from '../../blueprint/scaffold'
|
|
31
48
|
import { pathFor, readTree, removeFromTree, writeTree } from '../../blueprint/tree'
|
|
32
49
|
import { CliError } from '../../errors'
|
|
33
50
|
import type { Command, CommandContext } from '../types'
|
|
@@ -244,7 +261,7 @@ async function computePlan(ctx: CommandContext): Promise<{
|
|
|
244
261
|
}
|
|
245
262
|
const files = readTree(directory)
|
|
246
263
|
const { definition, revision } = await client.draft()
|
|
247
|
-
const plan = diff(files, definition, revision)
|
|
264
|
+
const plan = withheldPrunes(diff(files, definition, revision), directory)
|
|
248
265
|
|
|
249
266
|
// Per DISTINCT dataset, which is what the design promised and what the previous
|
|
250
267
|
// loop did not do: it called `datasetRevisions` once per BOUND TYPE, after
|
|
@@ -276,6 +293,36 @@ async function computePlan(ctx: CommandContext): Promise<{
|
|
|
276
293
|
return { plan, files, definition, drift }
|
|
277
294
|
}
|
|
278
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Withhold — and REPORT — prunes for a kind the tree has no directory for at all.
|
|
298
|
+
*
|
|
299
|
+
* `action` joining `ARTIFACT_KINDS` made every EXISTING tree suddenly claim authority
|
|
300
|
+
* over actions it had never modelled: such a tree has no `actions/`, so the first
|
|
301
|
+
* `apply --prune` after upgrading planned a delete for every live action in the
|
|
302
|
+
* organization — including ones authored through the Console or `blueprint create
|
|
303
|
+
* action`. Nothing writes the directory into an old tree, and nothing required a `pull`
|
|
304
|
+
* first, so an upgrade alone could delete work the tree had never seen.
|
|
305
|
+
*
|
|
306
|
+
* A missing directory is "this tree does not model that kind", which is not the same
|
|
307
|
+
* claim as an empty one. An empty `actions/` still prunes — that is a tree saying it
|
|
308
|
+
* models actions and has none.
|
|
309
|
+
*/
|
|
310
|
+
function withheldPrunes(plan: Plan, directory: string): Plan {
|
|
311
|
+
const modelled = new Set(
|
|
312
|
+
ARTIFACT_KINDS.filter((kind) => existsSync(join(directory, KIND_DIRECTORY[kind]))),
|
|
313
|
+
)
|
|
314
|
+
const prunes = plan.prunes.filter((operation) => modelled.has(operation.kind))
|
|
315
|
+
if (prunes.length === plan.prunes.length) return plan
|
|
316
|
+
// Moved, not dropped: `renderPlan` prints these under their own heading so a tree
|
|
317
|
+
// that deleted a whole directory sees WHY nothing was pruned, instead of reading
|
|
318
|
+
// "Applied 0 changes" and concluding the artifacts were already gone.
|
|
319
|
+
return {
|
|
320
|
+
...plan,
|
|
321
|
+
prunes,
|
|
322
|
+
withheld: plan.prunes.filter((operation) => !modelled.has(operation.kind)),
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
279
326
|
export const blueprintPlan: Command = {
|
|
280
327
|
meta: {
|
|
281
328
|
noun: 'blueprint',
|
|
@@ -286,7 +333,16 @@ export const blueprintPlan: Command = {
|
|
|
286
333
|
examples: ['frontera blueprint plan', 'frontera blueprint plan --json'],
|
|
287
334
|
},
|
|
288
335
|
async run(ctx) {
|
|
289
|
-
const { plan, drift } = await computePlan(ctx)
|
|
336
|
+
const { plan, files, definition, drift } = await computePlan(ctx)
|
|
337
|
+
// Every refusal `apply` will raise, raised here too, and for the reason `plan`
|
|
338
|
+
// exists: a plan that cannot be applied is not one a reader should be shown as if
|
|
339
|
+
// it could. Two independent checks — a prune that would dangle a reference, and an
|
|
340
|
+
// ordering that has no solution.
|
|
341
|
+
orderForApply(plan.operations)
|
|
342
|
+
if (ctx.flags.prune === true) {
|
|
343
|
+
assertPrunesLeaveNoDanglingReference(files, plan.prunes)
|
|
344
|
+
orderForPrune(plan.prunes, liveCompensations(definition))
|
|
345
|
+
}
|
|
290
346
|
const body = [renderPlan(plan, { prune: ctx.flags.prune === true }), renderDrift(drift)]
|
|
291
347
|
.filter(Boolean)
|
|
292
348
|
.join('\n')
|
|
@@ -297,24 +353,56 @@ export const blueprintPlan: Command = {
|
|
|
297
353
|
/**
|
|
298
354
|
* Apply one operation, on the route that carries its guard.
|
|
299
355
|
*
|
|
356
|
+
* Exported for the same reason `assertApplicable` is: the tests drive this dispatch
|
|
357
|
+
* directly, which is the only way to reach the refusal an unknown kind gets.
|
|
358
|
+
*
|
|
300
359
|
* Creations go to the per-artifact POST rather than to a whole-bundle write: the
|
|
301
360
|
* create route mints every id, derives each property's base type from the pinned
|
|
302
361
|
* column, and writes the `governance.sourceMappings` shell that a later `bind`
|
|
303
362
|
* requires. An object type inserted any other way can never afterwards be bound.
|
|
304
363
|
*/
|
|
305
|
-
async function applyOperation(
|
|
364
|
+
export async function applyOperation(
|
|
306
365
|
client: BlueprintAuthoringApi,
|
|
307
366
|
operation: ModelOperation,
|
|
308
367
|
file: AuthoredFile,
|
|
309
368
|
revision: number,
|
|
310
|
-
|
|
369
|
+
ids: AuthoringIds,
|
|
311
370
|
): Promise<number> {
|
|
312
371
|
const resolveDataset = (name: string) => resolveRevision(client, name)
|
|
313
372
|
|
|
373
|
+
if (operation.kind === 'semantic-type') {
|
|
374
|
+
if (operation.operation === 'create') {
|
|
375
|
+
const created = await client.createSemanticType(
|
|
376
|
+
semanticTypeCreateBody(file, revision),
|
|
377
|
+
revision,
|
|
378
|
+
)
|
|
379
|
+
// Threaded exactly as a shared field's id is, and for the same run: a tree
|
|
380
|
+
// that introduces a semantic type and the field carrying it has no id at plan
|
|
381
|
+
// time, and both carriers resolve this map at the moment of their write.
|
|
382
|
+
const id = created?.semanticType?.id
|
|
383
|
+
if (!id) {
|
|
384
|
+
throw new CliError(
|
|
385
|
+
`Creating semantic type "${operation.apiName}" returned no id, so nothing can carry it.`,
|
|
386
|
+
{
|
|
387
|
+
code: 'FAILURE',
|
|
388
|
+
hint: 'The service is expected to answer with the created row. Re-run `frontera blueprint plan`.',
|
|
389
|
+
},
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
ids.semanticTypeIdByApiName.set(operation.apiName, id)
|
|
393
|
+
return nextRevision(created, revision)
|
|
394
|
+
}
|
|
395
|
+
assertApplicable('semantic-type', file, operation.liveDocument)
|
|
396
|
+
const updated = await client.updateSemanticType(
|
|
397
|
+
operation.apiName, semanticTypePatchBody(file, revision), revision,
|
|
398
|
+
)
|
|
399
|
+
return nextRevision(updated, revision)
|
|
400
|
+
}
|
|
401
|
+
|
|
314
402
|
if (operation.kind === 'shared-field') {
|
|
315
403
|
if (operation.operation === 'create') {
|
|
316
404
|
const created = await client.createSharedProperty(
|
|
317
|
-
sharedPropertyCreateBody(file, revision),
|
|
405
|
+
sharedPropertyCreateBody(file, revision, ids.semanticTypeIdByApiName),
|
|
318
406
|
revision,
|
|
319
407
|
)
|
|
320
408
|
// The minted id, threaded into the property writes that follow in THIS run —
|
|
@@ -330,12 +418,14 @@ async function applyOperation(
|
|
|
330
418
|
},
|
|
331
419
|
)
|
|
332
420
|
}
|
|
333
|
-
|
|
421
|
+
ids.sharedPropertyIdByApiName.set(operation.apiName, id)
|
|
334
422
|
return nextRevision(created, revision)
|
|
335
423
|
}
|
|
336
424
|
assertApplicable('shared-field', file, operation.liveDocument)
|
|
337
425
|
const updated = await client.updateSharedProperty(
|
|
338
|
-
operation.apiName,
|
|
426
|
+
operation.apiName,
|
|
427
|
+
sharedPropertyPatchBody(file, revision, ids.semanticTypeIdByApiName),
|
|
428
|
+
revision,
|
|
339
429
|
)
|
|
340
430
|
return nextRevision(updated, revision)
|
|
341
431
|
}
|
|
@@ -343,7 +433,7 @@ async function applyOperation(
|
|
|
343
433
|
if (operation.kind === 'object-type') {
|
|
344
434
|
if (operation.operation === 'create') {
|
|
345
435
|
const created = await client.createObjectType(
|
|
346
|
-
await objectTypeCreateBody(file, revision, resolveDataset,
|
|
436
|
+
await objectTypeCreateBody(file, revision, resolveDataset, ids),
|
|
347
437
|
revision,
|
|
348
438
|
)
|
|
349
439
|
return nextRevision(created, revision)
|
|
@@ -368,7 +458,7 @@ async function applyOperation(
|
|
|
368
458
|
}
|
|
369
459
|
current = await applyObjectKeys(client, operation.apiName, file, operation.liveDocument, current)
|
|
370
460
|
return applyPropertyPlan(
|
|
371
|
-
client, operation.apiName, file, operation.liveDocument, current,
|
|
461
|
+
client, operation.apiName, file, operation.liveDocument, current, ids,
|
|
372
462
|
)
|
|
373
463
|
}
|
|
374
464
|
|
|
@@ -380,11 +470,52 @@ async function applyOperation(
|
|
|
380
470
|
return nextRevision(body, revision)
|
|
381
471
|
}
|
|
382
472
|
|
|
383
|
-
if (operation.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
473
|
+
if (operation.kind === 'action') {
|
|
474
|
+
// Sent as authored, names and all. The service resolves the references against the
|
|
475
|
+
// draft and mints the ids the contract owns, so the file carries no uuid — see
|
|
476
|
+
// `action-reference-resolution` and `action-identity`. An update PRESERVES those
|
|
477
|
+
// ids by matching children on apiName, which is why re-applying an unchanged tree
|
|
478
|
+
// is a no-op rather than a churn of fresh identifiers.
|
|
479
|
+
const body = actionBody(file)
|
|
480
|
+
// Checked against what will actually be SENT, not against the file. `actionBody`
|
|
481
|
+
// drops a pasted `id`, and `id` is not applicable — so checking the file first
|
|
482
|
+
// refused every apply after the first with "id differs from the draft", naming a
|
|
483
|
+
// field the projection never writes and the author therefore cannot revert.
|
|
484
|
+
if (operation.operation !== 'create') {
|
|
485
|
+
assertApplicable('action', { ...file, document: body }, operation.liveDocument)
|
|
486
|
+
}
|
|
487
|
+
const result = operation.operation === 'create'
|
|
488
|
+
? await client.createAction(body, revision)
|
|
489
|
+
: await client.updateAction(operation.apiName, body, revision)
|
|
490
|
+
return nextRevision(result, revision)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (operation.kind === 'metric') {
|
|
494
|
+
if (operation.operation !== 'create') assertApplicable('metric', file, operation.liveDocument)
|
|
495
|
+
const result = operation.operation === 'create'
|
|
496
|
+
? await client.createMetric(metricCreateBody(file, revision), revision)
|
|
497
|
+
: await client.updateMetric(operation.apiName, metricPatchBody(file, revision), revision)
|
|
498
|
+
return nextRevision(result, revision)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return assertNever(operation.kind, 'apply')
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* A kind this dispatch does not handle, refused loudly rather than fallen through.
|
|
506
|
+
*
|
|
507
|
+
* All three dispatches used to end in an UNCONDITIONAL metric arm, so a new kind was
|
|
508
|
+
* not a compile error and not a runtime error either: it was silently treated as a
|
|
509
|
+
* metric. `scaffold` wrote metric YAML under the new kind's path, `apply` POSTed it
|
|
510
|
+
* to `/metrics`, and `--prune` DELETED a metric that happened to share its apiName.
|
|
511
|
+
* The `never` parameter makes adding a kind a type error at each of the three, and
|
|
512
|
+
* this message is what a build that skipped the type check would say.
|
|
513
|
+
*/
|
|
514
|
+
export function assertNever(kind: never, site: string): never {
|
|
515
|
+
throw new CliError(`"${String(kind)}" is not an artifact kind this ${site} can handle.`, {
|
|
516
|
+
code: 'USAGE',
|
|
517
|
+
hint: `Expected one of: ${ARTIFACT_KINDS.join(', ')}.`,
|
|
518
|
+
})
|
|
388
519
|
}
|
|
389
520
|
|
|
390
521
|
/**
|
|
@@ -431,9 +562,12 @@ function nextRevision(response: unknown, previous: number): number {
|
|
|
431
562
|
* route refuses.
|
|
432
563
|
*/
|
|
433
564
|
const APPLICABLE_FIELDS: Record<ArtifactKind, Set<string>> = {
|
|
565
|
+
'semantic-type': new Set([
|
|
566
|
+
'apiName', 'displayName', 'description', 'dataType', 'rule', 'status', 'visibility',
|
|
567
|
+
]),
|
|
434
568
|
'shared-field': new Set([
|
|
435
569
|
'apiName', 'displayName', 'description', 'valueType', 'propertyType', 'formatConfig',
|
|
436
|
-
'status', 'visibility',
|
|
570
|
+
'semanticType', 'status', 'visibility',
|
|
437
571
|
]),
|
|
438
572
|
'object-type': new Set([
|
|
439
573
|
'apiName', 'displayName', 'pluralDisplayName', 'description', 'icon', 'color',
|
|
@@ -441,6 +575,14 @@ const APPLICABLE_FIELDS: Record<ArtifactKind, Set<string>> = {
|
|
|
441
575
|
]),
|
|
442
576
|
'link-type': new Set(['apiName', 'cardinality', 'description', 'from', 'to']),
|
|
443
577
|
metric: new Set(['apiName', 'displayName', 'description', 'definition', 'formatConfig']),
|
|
578
|
+
// Every field, because the action PUT replaces the contract whole — there is no
|
|
579
|
+
// field an update cannot carry. Enumerated rather than left open so that a field the
|
|
580
|
+
// bundle gains later is refused here until someone confirms the route takes it,
|
|
581
|
+
// which is the same bargain the other kinds make.
|
|
582
|
+
action: new Set([
|
|
583
|
+
'apiName', 'displayName', 'description', 'subject', 'effect', 'inputs',
|
|
584
|
+
'businessOutcomes', 'concurrency', 'impact', 'submissionCriteria', 'governance',
|
|
585
|
+
]),
|
|
444
586
|
}
|
|
445
587
|
|
|
446
588
|
export function assertApplicable(
|
|
@@ -634,6 +776,27 @@ function planProperties(
|
|
|
634
776
|
const current = counterpart.sharedField ?? null
|
|
635
777
|
if (wanted !== current) metadataPatch.sharedField = wanted
|
|
636
778
|
}
|
|
779
|
+
// A semantic type is carried by NAME for the same reason and resolved at the same
|
|
780
|
+
// moment, and `null` is compared against a live absence for the same reason: a
|
|
781
|
+
// file that says `semanticType: null` against a field that carries none must plan
|
|
782
|
+
// nothing, or every run re-issues a detach the service refuses as a no-op.
|
|
783
|
+
//
|
|
784
|
+
// WHICH COMMAND IT RIDES is decided here rather than by the loop below, and the
|
|
785
|
+
// ordering is why. The executor sends every schema command before every metadata
|
|
786
|
+
// command, so a file that moves `valueType` and `semanticType` together would
|
|
787
|
+
// otherwise validate the new value type against the OLD semantic type and then
|
|
788
|
+
// the new semantic type against the already-changed value type — one of the two
|
|
789
|
+
// intermediate states contradicts, and the run fails halfway. Riding the same
|
|
790
|
+
// `update_field_schema` patch makes it one command, and R4.4's next-state
|
|
791
|
+
// validation judges the pair the command settles on. It is never on both.
|
|
792
|
+
if (property.semanticType !== undefined) {
|
|
793
|
+
const wanted = property.semanticType === null ? null : property.semanticType
|
|
794
|
+
const current = counterpart.semanticType ?? null
|
|
795
|
+
if (wanted !== current) {
|
|
796
|
+
if (Object.keys(schemaPatch).length > 0) schemaPatch.semanticType = wanted
|
|
797
|
+
else metadataPatch.semanticType = wanted
|
|
798
|
+
}
|
|
799
|
+
}
|
|
637
800
|
const id = idByApiName.get(name)
|
|
638
801
|
// A declared property with no live id is either brand new (handled above) or the
|
|
639
802
|
// draft moved; skipping it quietly is how a change goes missing.
|
|
@@ -659,7 +822,7 @@ async function applyPropertyPlan(
|
|
|
659
822
|
file: AuthoredFile,
|
|
660
823
|
liveDocument: Record<string, unknown> | undefined,
|
|
661
824
|
revision: number,
|
|
662
|
-
|
|
825
|
+
ids: AuthoringIds,
|
|
663
826
|
): Promise<number> {
|
|
664
827
|
const detail = await client.getObjectTypeDetail(apiName)
|
|
665
828
|
if (!detail?.id) {
|
|
@@ -723,7 +886,10 @@ async function applyPropertyPlan(
|
|
|
723
886
|
// Resolved here rather than at plan time: a shared field created earlier in
|
|
724
887
|
// this same run only entered the map when its create answered.
|
|
725
888
|
const sharedPropertyId = resolveSharedProperty(
|
|
726
|
-
file, name, property.sharedField,
|
|
889
|
+
file, name, property.sharedField, ids.sharedPropertyIdByApiName,
|
|
890
|
+
)
|
|
891
|
+
const semanticTypeId = resolveSemanticType(
|
|
892
|
+
file, `field "${name}"`, property.semanticType, ids.semanticTypeIdByApiName,
|
|
727
893
|
)
|
|
728
894
|
const result = await client.draftCommand({
|
|
729
895
|
kind: 'add_field',
|
|
@@ -747,13 +913,19 @@ async function applyPropertyPlan(
|
|
|
747
913
|
// is simply "no shared field" — `add_field`'s uuid is not nullable, and a
|
|
748
914
|
// detach command on a field that is being created makes no sense anyway.
|
|
749
915
|
...(typeof sharedPropertyId === 'string' ? { sharedPropertyId } : {}),
|
|
916
|
+
// Same collapse: `add_field`'s uuid is not nullable, and a field being
|
|
917
|
+
// created has no meaning to detach from.
|
|
918
|
+
...(typeof semanticTypeId === 'string' ? { semanticTypeId } : {}),
|
|
750
919
|
},
|
|
751
920
|
}, current)
|
|
752
921
|
current = result.revision
|
|
753
922
|
}
|
|
754
923
|
for (const change of plan.schemaChanges) {
|
|
755
924
|
const result = await client.draftCommand({
|
|
756
|
-
kind: 'update_field_schema',
|
|
925
|
+
kind: 'update_field_schema',
|
|
926
|
+
objectId: detail.id,
|
|
927
|
+
fieldId: change.id,
|
|
928
|
+
patch: semanticTypeOnWire(file, change.apiName, change.patch, ids),
|
|
757
929
|
}, current)
|
|
758
930
|
current = result.revision
|
|
759
931
|
}
|
|
@@ -761,16 +933,16 @@ async function applyPropertyPlan(
|
|
|
761
933
|
// `sharedField` is the file's name for it and `sharedPropertyId` the wire's.
|
|
762
934
|
// `null` survives the resolution and travels as `null`: that is the detach
|
|
763
935
|
// signal on both sides, and dropping it left this patch empty.
|
|
764
|
-
const { sharedField, ...
|
|
936
|
+
const { sharedField, ...rest } = change.patch
|
|
765
937
|
const sharedPropertyId = resolveSharedProperty(
|
|
766
|
-
file, change.apiName, sharedField,
|
|
938
|
+
file, change.apiName, sharedField, ids.sharedPropertyIdByApiName,
|
|
767
939
|
)
|
|
768
940
|
const result = await client.draftCommand({
|
|
769
941
|
kind: 'update_field_metadata',
|
|
770
942
|
objectId: detail.id,
|
|
771
943
|
fieldId: change.id,
|
|
772
944
|
patch: {
|
|
773
|
-
...
|
|
945
|
+
...semanticTypeOnWire(file, change.apiName, rest, ids),
|
|
774
946
|
...(sharedPropertyId === undefined ? {} : { sharedPropertyId }),
|
|
775
947
|
},
|
|
776
948
|
}, current)
|
|
@@ -785,7 +957,28 @@ async function applyPropertyPlan(
|
|
|
785
957
|
return current
|
|
786
958
|
}
|
|
787
959
|
|
|
788
|
-
|
|
960
|
+
/**
|
|
961
|
+
* The file's `semanticType` name, replaced by the wire's `semanticTypeId`.
|
|
962
|
+
*
|
|
963
|
+
* ONE function for both commands, because both carry the reference: the planner
|
|
964
|
+
* decides which of the two a given change rides (see `planProperties`), and a
|
|
965
|
+
* translation written twice is how one of them keeps the authored key and reports a
|
|
966
|
+
* change it did not make. `null` survives — it is the detach signal on both sides.
|
|
967
|
+
*/
|
|
968
|
+
function semanticTypeOnWire(
|
|
969
|
+
file: AuthoredFile,
|
|
970
|
+
propertyApiName: string,
|
|
971
|
+
patch: Record<string, unknown>,
|
|
972
|
+
ids: AuthoringIds,
|
|
973
|
+
): Record<string, unknown> {
|
|
974
|
+
const { semanticType, ...rest } = patch
|
|
975
|
+
const semanticTypeId = resolveSemanticType(
|
|
976
|
+
file, `field "${propertyApiName}"`, semanticType, ids.semanticTypeIdByApiName,
|
|
977
|
+
)
|
|
978
|
+
return { ...rest, ...(semanticTypeId === undefined ? {} : { semanticTypeId }) }
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export async function pruneOperation(
|
|
789
982
|
client: BlueprintAuthoringApi,
|
|
790
983
|
operation: ModelOperation,
|
|
791
984
|
revision: number,
|
|
@@ -800,12 +993,23 @@ async function pruneOperation(
|
|
|
800
993
|
}
|
|
801
994
|
// Deleting a shared field DEGRADES its implementers rather than being refused:
|
|
802
995
|
// each keeps the description and format it was inheriting. Deleting a label must
|
|
803
|
-
// not delete the mappings underneath it.
|
|
996
|
+
// not delete the mappings underneath it. A semantic type degrades the same way —
|
|
997
|
+
// every carrier reverts to an unconstrained field, which is what a field without
|
|
998
|
+
// one has always been.
|
|
804
999
|
const result = operation.kind === 'link-type'
|
|
805
1000
|
? await client.deleteLinkType(operation.apiName, revision)
|
|
806
|
-
: operation.kind === '
|
|
807
|
-
? await client.
|
|
808
|
-
:
|
|
1001
|
+
: operation.kind === 'action'
|
|
1002
|
+
? await client.deleteAction(operation.apiName, revision)
|
|
1003
|
+
: operation.kind === 'shared-field'
|
|
1004
|
+
? await client.deleteSharedProperty(operation.apiName, revision)
|
|
1005
|
+
: operation.kind === 'semantic-type'
|
|
1006
|
+
? await client.deleteSemanticType(operation.apiName, revision)
|
|
1007
|
+
: operation.kind === 'metric'
|
|
1008
|
+
? await client.deleteMetric(operation.apiName, revision)
|
|
1009
|
+
// Exhaustive on purpose — `assertNever` makes a kind added later a
|
|
1010
|
+
// TYPE error here rather than a silent fall-through to whichever
|
|
1011
|
+
// delete happened to be last.
|
|
1012
|
+
: assertNever(operation.kind, 'prune')
|
|
809
1013
|
return nextRevision(result, revision)
|
|
810
1014
|
}
|
|
811
1015
|
|
|
@@ -827,6 +1031,11 @@ export const blueprintApply: Command = {
|
|
|
827
1031
|
const prune = ctx.flags.prune === true
|
|
828
1032
|
const { plan, files, definition, drift } = await computePlan(ctx)
|
|
829
1033
|
|
|
1034
|
+
// BEFORE the first write, not before the prune loop: the operations land first,
|
|
1035
|
+
// so discovering the contradiction between them and the prunes at the end would
|
|
1036
|
+
// leave a half-applied run to unwind by hand.
|
|
1037
|
+
if (prune) assertPrunesLeaveNoDanglingReference(files, plan.prunes)
|
|
1038
|
+
|
|
830
1039
|
if (isEmpty(plan)) {
|
|
831
1040
|
return { data: { ...plan, applied: [] }, text: renderPlan(plan) }
|
|
832
1041
|
}
|
|
@@ -846,6 +1055,19 @@ export const blueprintApply: Command = {
|
|
|
846
1055
|
)
|
|
847
1056
|
}
|
|
848
1057
|
|
|
1058
|
+
// BOTH orderings resolved before the confirmation, not inside the write loop.
|
|
1059
|
+
// Each refuses a mutual `compensationAction` pair, and a refusal that arrives
|
|
1060
|
+
// after the creates have committed leaves the draft half-applied — which is what
|
|
1061
|
+
// the prune side did, dying on a raw 409 from the service rather than on the
|
|
1062
|
+
// CliError the create side had already learned to raise.
|
|
1063
|
+
//
|
|
1064
|
+
// Creations before updates, and object types before the kinds that reference them:
|
|
1065
|
+
// a link names both of its endpoints, and a metric names the type it measures.
|
|
1066
|
+
// `ARTIFACT_KINDS` fixes that. Then one pass more, because actions can reference
|
|
1067
|
+
// EACH OTHER: a compensation target has to exist before the action naming it.
|
|
1068
|
+
const ordered = orderForApply(plan.operations)
|
|
1069
|
+
const orderedPrunes = prune ? orderForPrune(plan.prunes, liveCompensations(definition)) : []
|
|
1070
|
+
|
|
849
1071
|
if (ctx.flags.yes !== true) {
|
|
850
1072
|
throw new CliError(
|
|
851
1073
|
`This would apply ${plan.operations.length} change${plan.operations.length === 1 ? '' : 's'}.\n`
|
|
@@ -857,24 +1079,25 @@ export const blueprintApply: Command = {
|
|
|
857
1079
|
const byKey = new Map(files.map((file) => [`${file.kind}:${file.apiName}`, file]))
|
|
858
1080
|
const applied: string[] = []
|
|
859
1081
|
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
1082
|
// Seeded from the revision the PLAN was computed against, then advanced by what
|
|
865
1083
|
// each write returns. Never re-read — see `nextRevision`.
|
|
866
1084
|
let revision = plan.revision
|
|
867
1085
|
// Shared field apiName → id: what the draft already had, plus what this run
|
|
868
1086
|
// creates. A file references a shared field by name and the wire takes an
|
|
869
1087
|
// id, and one created in this run has no id until its create answers.
|
|
870
|
-
|
|
1088
|
+
// …and the same for semantic types, which BOTH carrier kinds resolve.
|
|
1089
|
+
const live = indexBundle(definition)
|
|
1090
|
+
const ids: AuthoringIds = {
|
|
1091
|
+
sharedPropertyIdByApiName: live.sharedPropertyIdByApiName,
|
|
1092
|
+
semanticTypeIdByApiName: live.semanticTypeIdByApiName,
|
|
1093
|
+
}
|
|
871
1094
|
for (const operation of ordered) {
|
|
872
1095
|
const file = byKey.get(`${operation.kind}:${operation.apiName}`)!
|
|
873
|
-
revision = await applyOperation(client, operation, file, revision,
|
|
1096
|
+
revision = await applyOperation(client, operation, file, revision, ids)
|
|
874
1097
|
applied.push(`${operation.operation} ${operation.kind} ${operation.apiName}`)
|
|
875
1098
|
}
|
|
876
1099
|
if (prune) {
|
|
877
|
-
for (const operation of
|
|
1100
|
+
for (const operation of orderedPrunes) {
|
|
878
1101
|
revision = await pruneOperation(client, operation, revision)
|
|
879
1102
|
applied.push(`delete ${operation.kind} ${operation.apiName}`)
|
|
880
1103
|
}
|
|
@@ -907,6 +1130,195 @@ function byKindThenCreateFirst(left: ModelOperation, right: ModelOperation): num
|
|
|
907
1130
|
return rank(left) - rank(right)
|
|
908
1131
|
}
|
|
909
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* Reorder action creates so a compensation target is created before the action naming
|
|
1135
|
+
* it.
|
|
1136
|
+
*
|
|
1137
|
+
* `effect.compensationAction` is the one reference an artifact can make to ANOTHER
|
|
1138
|
+
* ARTIFACT OF ITS OWN KIND, and the service resolves it against the draft — so on a
|
|
1139
|
+
* fresh deployment, creating `approveLoan` before `reverseApproval` is refused with
|
|
1140
|
+
* "names an action which is not on the draft". Alphabetical order decided which of the
|
|
1141
|
+
* two a tree got, which is to say it worked or not by accident of naming.
|
|
1142
|
+
*
|
|
1143
|
+
* Only creates are reordered. An update names a target that is already on the draft by
|
|
1144
|
+
* definition, and a plan that mixes the two still creates first — `byKindThenCreateFirst`
|
|
1145
|
+
* has already run.
|
|
1146
|
+
*/
|
|
1147
|
+
export function orderForApply(operations: ModelOperation[]): ModelOperation[] {
|
|
1148
|
+
return byCompensationDependency([...operations].sort(byKindThenCreateFirst))
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Live actions, each with the apiName of the action it compensates.
|
|
1153
|
+
*
|
|
1154
|
+
* The bundle stores `compensationActionId`, so the id is mapped back through the
|
|
1155
|
+
* action list — the prune preflight reasons in names, because that is what a prune
|
|
1156
|
+
* operation carries.
|
|
1157
|
+
*/
|
|
1158
|
+
function liveCompensations(
|
|
1159
|
+
definition: DefinitionBundle,
|
|
1160
|
+
): Array<{ apiName: string; compensates?: string }> {
|
|
1161
|
+
const actions = definition.actions ?? []
|
|
1162
|
+
const byId = new Map(actions.map((action) => [action.id, action.apiName]))
|
|
1163
|
+
return actions.map((action) => {
|
|
1164
|
+
const effect = (action as Record<string, unknown>).effect
|
|
1165
|
+
const id = effect && typeof effect === 'object'
|
|
1166
|
+
? (effect as Record<string, unknown>).compensationActionId
|
|
1167
|
+
: undefined
|
|
1168
|
+
const compensates = typeof id === 'string' ? byId.get(id) : undefined
|
|
1169
|
+
return compensates === undefined ? { apiName: action.apiName } : { apiName: action.apiName, compensates }
|
|
1170
|
+
})
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Deleting runs every dependency the other way round.
|
|
1175
|
+
*
|
|
1176
|
+
* REVERSE `ARTIFACT_KINDS`, because an action names an object type: pruning in creation
|
|
1177
|
+
* order removed the type out from under everything still pointing at it. And within the
|
|
1178
|
+
* action group, the MIRROR of `byCompensationDependency` — `deleteDraftBlueprintAction`
|
|
1179
|
+
* refuses with `ACTION_COMPENSATION_REFERENCE_CONFLICT` while any surviving action still
|
|
1180
|
+
* names the target, so a compensator has to go before the action it compensates.
|
|
1181
|
+
* Sorting by apiName alone meant renaming a pair decided whether the same tree pruned.
|
|
1182
|
+
*/
|
|
1183
|
+
export function orderForPrune(
|
|
1184
|
+
prunes: ModelOperation[],
|
|
1185
|
+
liveActions: ReadonlyArray<{ apiName: string; compensates?: string }> = [],
|
|
1186
|
+
): ModelOperation[] {
|
|
1187
|
+
const byKind = [...prunes].sort(
|
|
1188
|
+
(left, right) => ARTIFACT_KINDS.indexOf(right.kind) - ARTIFACT_KINDS.indexOf(left.kind),
|
|
1189
|
+
)
|
|
1190
|
+
const actions = byKind.filter((operation) => operation.kind === 'action')
|
|
1191
|
+
if (actions.length === 0) return byKind
|
|
1192
|
+
|
|
1193
|
+
const pruned = new Set(actions.map((operation) => operation.apiName))
|
|
1194
|
+
|
|
1195
|
+
// A SURVIVOR blocks the delete just as surely as a pruned pair does, and it is the
|
|
1196
|
+
// commoner case: `deleteDraftBlueprintAction` refuses while ANY remaining action
|
|
1197
|
+
// still names the target. Ordering among the prune set alone missed it entirely —
|
|
1198
|
+
// deleting one half of a pair planned clean and then died on a raw 409 mid-apply,
|
|
1199
|
+
// after the creates had committed, which is the half-applied outcome this pass
|
|
1200
|
+
// exists to prevent.
|
|
1201
|
+
const blocked = liveActions.filter((action) =>
|
|
1202
|
+
action.compensates !== undefined
|
|
1203
|
+
&& pruned.has(action.compensates)
|
|
1204
|
+
&& !pruned.has(action.apiName))
|
|
1205
|
+
if (blocked.length > 0) {
|
|
1206
|
+
const named = blocked
|
|
1207
|
+
.map((action) => `${action.apiName} → ${action.compensates}`)
|
|
1208
|
+
.sort()
|
|
1209
|
+
.join(', ')
|
|
1210
|
+
throw new CliError(
|
|
1211
|
+
`${blocked.length === 1 ? 'An action that survives this prune still names' : 'Actions that survive this prune still name'} `
|
|
1212
|
+
+ `one being deleted as its compensation: ${named}.`,
|
|
1213
|
+
{
|
|
1214
|
+
code: 'USAGE',
|
|
1215
|
+
hint: 'Remove `compensationAction` from the surviving action and apply, then prune.',
|
|
1216
|
+
},
|
|
1217
|
+
)
|
|
1218
|
+
}
|
|
1219
|
+
if (actions.length < 2) return byKind
|
|
1220
|
+
// Reversed edge: `compensator → target` means the compensator is deleted FIRST, so
|
|
1221
|
+
// the target depends on it rather than the other way round.
|
|
1222
|
+
const compensatorsOf = new Map<string, ModelOperation[]>()
|
|
1223
|
+
for (const operation of actions) {
|
|
1224
|
+
const effect = operation.liveDocument?.effect
|
|
1225
|
+
const named = effect && typeof effect === 'object'
|
|
1226
|
+
? (effect as Record<string, unknown>).compensationAction
|
|
1227
|
+
: undefined
|
|
1228
|
+
if (typeof named !== 'string' || !pruned.has(named)) continue
|
|
1229
|
+
const list = compensatorsOf.get(named) ?? []
|
|
1230
|
+
list.push(operation)
|
|
1231
|
+
compensatorsOf.set(named, list)
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const ordered: ModelOperation[] = []
|
|
1235
|
+
const placed = new Set<string>()
|
|
1236
|
+
const visiting = new Set<string>()
|
|
1237
|
+
const visit = (operation: ModelOperation): void => {
|
|
1238
|
+
if (placed.has(operation.apiName)) return
|
|
1239
|
+
// A mutual pair cannot be pruned by ordering alone, and it is refused HERE rather
|
|
1240
|
+
// than left to the service. There is a way through — drop the link, then prune —
|
|
1241
|
+
// but the service's 409 arrives mid-run, after every create and update in the same
|
|
1242
|
+
// apply has already committed. The create side pre-flights the mirror case; the
|
|
1243
|
+
// asymmetry was the defect, not the refusal.
|
|
1244
|
+
if (visiting.has(operation.apiName)) {
|
|
1245
|
+
throw new CliError(
|
|
1246
|
+
`${operation.apiName} and its compensation action name each other, so neither can be deleted first.`,
|
|
1247
|
+
{
|
|
1248
|
+
code: 'USAGE',
|
|
1249
|
+
hint: 'Remove `compensationAction` from one of them and apply, then prune both.',
|
|
1250
|
+
},
|
|
1251
|
+
)
|
|
1252
|
+
}
|
|
1253
|
+
visiting.add(operation.apiName)
|
|
1254
|
+
for (const compensator of compensatorsOf.get(operation.apiName) ?? []) visit(compensator)
|
|
1255
|
+
visiting.delete(operation.apiName)
|
|
1256
|
+
placed.add(operation.apiName)
|
|
1257
|
+
ordered.push(operation)
|
|
1258
|
+
}
|
|
1259
|
+
for (const operation of actions) visit(operation)
|
|
1260
|
+
|
|
1261
|
+
return [...ordered, ...byKind.filter((operation) => operation.kind !== 'action')]
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function byCompensationDependency(operations: ModelOperation[]): ModelOperation[] {
|
|
1265
|
+
const creates = new Map<string, ModelOperation>()
|
|
1266
|
+
for (const operation of operations) {
|
|
1267
|
+
if (operation.kind === 'action' && operation.operation === 'create') {
|
|
1268
|
+
creates.set(operation.apiName, operation)
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (creates.size < 2) return operations
|
|
1272
|
+
|
|
1273
|
+
const target = (operation: ModelOperation): string | undefined => {
|
|
1274
|
+
const effect = operation.document?.effect
|
|
1275
|
+
if (!effect || typeof effect !== 'object') return undefined
|
|
1276
|
+
const named = (effect as Record<string, unknown>).compensationAction
|
|
1277
|
+
// Only a name that is ALSO being created here constrains the order. One that is
|
|
1278
|
+
// already on the draft resolves whenever this runs.
|
|
1279
|
+
return typeof named === 'string' && creates.has(named) ? named : undefined
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const ordered: ModelOperation[] = []
|
|
1283
|
+
const placed = new Set<string>()
|
|
1284
|
+
const visiting = new Set<string>()
|
|
1285
|
+
const visit = (operation: ModelOperation): void => {
|
|
1286
|
+
if (placed.has(operation.apiName)) return
|
|
1287
|
+
if (visiting.has(operation.apiName)) {
|
|
1288
|
+
// Two actions naming each other cannot both be created first, by either party.
|
|
1289
|
+
// Saying so beats a 409 from the service that names only one of the pair.
|
|
1290
|
+
throw new CliError(
|
|
1291
|
+
`${operation.apiName} and its compensation action name each other, so neither can be created first.`,
|
|
1292
|
+
{
|
|
1293
|
+
code: 'USAGE',
|
|
1294
|
+
hint: 'Apply one without `compensationAction`, then add the link in a second apply.',
|
|
1295
|
+
},
|
|
1296
|
+
)
|
|
1297
|
+
}
|
|
1298
|
+
visiting.add(operation.apiName)
|
|
1299
|
+
const dependency = target(operation)
|
|
1300
|
+
if (dependency) visit(creates.get(dependency)!)
|
|
1301
|
+
visiting.delete(operation.apiName)
|
|
1302
|
+
placed.add(operation.apiName)
|
|
1303
|
+
ordered.push(operation)
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// THREE buckets, not two. Collecting everything that is not an action create into
|
|
1307
|
+
// one `rest` and appending the creates after it put every action UPDATE ahead of
|
|
1308
|
+
// every action CREATE — inverting the create-first rule the incoming sort had just
|
|
1309
|
+
// established, and aborting a mixed plan after the earlier writes had committed.
|
|
1310
|
+
const before: ModelOperation[] = []
|
|
1311
|
+
const updates: ModelOperation[] = []
|
|
1312
|
+
for (const operation of operations) {
|
|
1313
|
+
if (operation.kind !== 'action') before.push(operation)
|
|
1314
|
+
else if (operation.operation === 'create') visit(operation)
|
|
1315
|
+
else updates.push(operation)
|
|
1316
|
+
}
|
|
1317
|
+
// Actions sort last in `ARTIFACT_KINDS`, so the other kinds keep their place ahead of
|
|
1318
|
+
// them; only the action creates are permuted, and only among themselves.
|
|
1319
|
+
return [...before, ...ordered, ...updates]
|
|
1320
|
+
}
|
|
1321
|
+
|
|
910
1322
|
export const blueprintNew: Command = {
|
|
911
1323
|
meta: {
|
|
912
1324
|
noun: 'blueprint',
|
|
@@ -915,13 +1327,14 @@ export const blueprintNew: Command = {
|
|
|
915
1327
|
{ name: 'kind', required: true, description: `One of: ${ARTIFACT_KINDS.join(', ')}` },
|
|
916
1328
|
{ name: 'apiName', required: true, description: 'The artifact’s API name' },
|
|
917
1329
|
],
|
|
918
|
-
flags: { dir: 'string' },
|
|
1330
|
+
flags: { dir: 'string', template: 'string' },
|
|
919
1331
|
offline: true,
|
|
920
1332
|
summary: 'Scaffold an artifact file that validates as written',
|
|
921
1333
|
examples: [
|
|
922
1334
|
'frontera blueprint new object-type Customer',
|
|
923
1335
|
'frontera blueprint new link-type customerHoldsPolicy',
|
|
924
1336
|
'frontera blueprint new shared-field accountCode',
|
|
1337
|
+
'frontera blueprint new semantic-type completionRate --template percentage',
|
|
925
1338
|
],
|
|
926
1339
|
},
|
|
927
1340
|
async run(ctx) {
|
|
@@ -945,6 +1358,7 @@ export const blueprintNew: Command = {
|
|
|
945
1358
|
hint: `${describeApiNameRule(kind)} — and it becomes the file name, so nothing else fits.`,
|
|
946
1359
|
})
|
|
947
1360
|
}
|
|
1361
|
+
const template = semanticTypeTemplate(ctx.flags.template, kind)
|
|
948
1362
|
const directory = root(ctx)
|
|
949
1363
|
const relative = pathFor(kind, apiName)
|
|
950
1364
|
if (existsSync(resolve(directory, relative))) {
|
|
@@ -957,7 +1371,7 @@ export const blueprintNew: Command = {
|
|
|
957
1371
|
kind,
|
|
958
1372
|
apiName,
|
|
959
1373
|
path: relative,
|
|
960
|
-
document: scaffold(kind, apiName),
|
|
1374
|
+
document: scaffold(kind, apiName, template === undefined ? {} : { template }),
|
|
961
1375
|
}
|
|
962
1376
|
writeTree(directory, [file])
|
|
963
1377
|
return {
|
|
@@ -967,6 +1381,35 @@ export const blueprintNew: Command = {
|
|
|
967
1381
|
},
|
|
968
1382
|
}
|
|
969
1383
|
|
|
1384
|
+
/**
|
|
1385
|
+
* `--template`, validated where it is read.
|
|
1386
|
+
*
|
|
1387
|
+
* Refused on the kinds that have none rather than ignored: a flag silently doing
|
|
1388
|
+
* nothing is how a reader concludes the scaffold ignored their choice and edits the
|
|
1389
|
+
* file by hand ever after.
|
|
1390
|
+
*/
|
|
1391
|
+
function semanticTypeTemplate(
|
|
1392
|
+
declared: unknown,
|
|
1393
|
+
kind: ArtifactKind,
|
|
1394
|
+
): SemanticTypeTemplate | undefined {
|
|
1395
|
+
if (declared === undefined) return undefined
|
|
1396
|
+
if (kind !== 'semantic-type') {
|
|
1397
|
+
throw new CliError(`--template applies to semantic-type, not ${kind}.`, {
|
|
1398
|
+
code: 'USAGE',
|
|
1399
|
+
hint: `frontera blueprint new semantic-type <apiName> --template ${SEMANTIC_TYPE_TEMPLATE_NAMES[0]}`,
|
|
1400
|
+
})
|
|
1401
|
+
}
|
|
1402
|
+
const name = String(declared)
|
|
1403
|
+
if (!isSemanticTypeTemplate(name)) {
|
|
1404
|
+
throw new CliError(`Unknown template "${name}".`, {
|
|
1405
|
+
code: 'USAGE',
|
|
1406
|
+
hint: `One of: ${SEMANTIC_TYPE_TEMPLATE_NAMES.join(', ')}. `
|
|
1407
|
+
+ 'Each writes a rule the service accepts unedited.',
|
|
1408
|
+
})
|
|
1409
|
+
}
|
|
1410
|
+
return name
|
|
1411
|
+
}
|
|
1412
|
+
|
|
970
1413
|
export const blueprintRename: Command = {
|
|
971
1414
|
meta: {
|
|
972
1415
|
noun: 'blueprint',
|