@frontera-sdk/cli 1.43.6 → 1.43.7
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
package/src/blueprint/model.ts
CHANGED
|
@@ -65,45 +65,65 @@ export interface DefinitionBundle {
|
|
|
65
65
|
* carries no key at all, and the schemas are strict.
|
|
66
66
|
*/
|
|
67
67
|
sharedProperties?: BundleNamed[]
|
|
68
|
+
/** Optional for the same reason `sharedProperties` is. */
|
|
69
|
+
semanticTypes?: BundleNamed[]
|
|
68
70
|
/** v2 only. Absent on a v1 bundle rather than empty — the schemas are strict. */
|
|
69
71
|
actions?: BundleNamed[]
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/**
|
|
73
75
|
* The authorable kinds, in the order a reader meets them — which is also the order
|
|
74
|
-
* `apply` writes them in, so a kind that others reference comes first. A property
|
|
75
|
-
*
|
|
76
|
+
* `apply` writes them in, so a kind that others reference comes first. A property names
|
|
77
|
+
* its shared field, so shared fields lead; a link names two object types and a metric
|
|
78
|
+
* names one; an action names object types, their properties and their transitions, so
|
|
79
|
+
* it comes last.
|
|
76
80
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
81
|
+
* A SEMANTIC TYPE IS FIRST, and it has to be: both other carrier kinds reference one
|
|
82
|
+
* — a shared field by `semanticType:` and a property by the same key — so a tree that
|
|
83
|
+
* introduces a semantic type and something carrying it has to create the type before
|
|
84
|
+
* either. Same rule that puts shared fields ahead of object types, one level deeper.
|
|
85
|
+
*
|
|
86
|
+
* A kind is authorable only when the tree can EXPRESS it: a file the CLI can write but
|
|
87
|
+
* never apply is worse than no file, because it breaks the round trip the tree promises
|
|
88
|
+
* and fails only at the end of a deployment. Two things a file may still NAME are not
|
|
89
|
+
* themselves authorable here, and both are named rather than hidden because a tree
|
|
90
|
+
* carrying either is portable only to a deployment that already has it.
|
|
80
91
|
*
|
|
81
92
|
* **Source systems** have no route of their own — `createDraftObjectType` synthesises
|
|
82
|
-
* one for an organization's first object type and nothing else ever creates one.
|
|
93
|
+
* one for an organization's first object type and nothing else ever creates one. An
|
|
94
|
+
* action's `effect.authoritySourceSystem` therefore names something this tree cannot
|
|
95
|
+
* itself create, and on a second deployment that name may resolve to nothing.
|
|
96
|
+
*
|
|
97
|
+
* **Lifecycles** are the same shape of gap one level down. `objectTypeCreateBody`
|
|
98
|
+
* refuses a file that declares one — the create route cannot express it — so an
|
|
99
|
+
* action's `lifecycle_transition` change names a transition the tree cannot author.
|
|
100
|
+
* Against the draft it was pulled from that resolves; against a fresh organization it
|
|
101
|
+
* does not, and the refusal lands mid-apply.
|
|
83
102
|
*
|
|
84
|
-
* **Actions**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* to resolve them back. `blueprint create|update|delete action` still authors them
|
|
89
|
-
* imperatively; only the FILE path is withheld, and withheld visibly rather than
|
|
90
|
-
* shipped broken.
|
|
103
|
+
* **Actions** joined the list once the service learned to resolve their references by
|
|
104
|
+
* name and to mint the ids they own — see `action-reference-resolution` and
|
|
105
|
+
* `action-identity`. Before that they carried uuids that were correct in the
|
|
106
|
+
* deployment that minted them and wrong in every other.
|
|
91
107
|
*/
|
|
92
108
|
export const ARTIFACT_KINDS = [
|
|
109
|
+
'semantic-type',
|
|
93
110
|
'shared-field',
|
|
94
111
|
'object-type',
|
|
95
112
|
'link-type',
|
|
96
113
|
'metric',
|
|
114
|
+
'action',
|
|
97
115
|
] as const
|
|
98
116
|
|
|
99
117
|
export type ArtifactKind = (typeof ARTIFACT_KINDS)[number]
|
|
100
118
|
|
|
101
119
|
/** `object-type` → `object-types/`. Plural directory, singular command word. */
|
|
102
120
|
export const KIND_DIRECTORY: Record<ArtifactKind, string> = {
|
|
121
|
+
'semantic-type': 'semantic-types',
|
|
103
122
|
'shared-field': 'shared-fields',
|
|
104
123
|
'object-type': 'object-types',
|
|
105
124
|
'link-type': 'link-types',
|
|
106
125
|
metric: 'metrics',
|
|
126
|
+
action: 'actions',
|
|
107
127
|
}
|
|
108
128
|
|
|
109
129
|
export const DIRECTORY_KIND: Record<string, ArtifactKind> = Object.fromEntries(
|
|
@@ -120,10 +140,14 @@ export const DIRECTORY_KIND: Record<string, ArtifactKind> = Object.fromEntries(
|
|
|
120
140
|
* existed. A name that cannot name a file is refused before anything is written.
|
|
121
141
|
*/
|
|
122
142
|
export const API_NAME_RULE: Record<ArtifactKind, RegExp> = {
|
|
143
|
+
'semantic-type': /^[a-z][A-Za-z0-9]{0,99}$/,
|
|
123
144
|
'shared-field': /^[a-z][A-Za-z0-9]{0,99}$/,
|
|
124
145
|
'object-type': /^[A-Z][A-Za-z0-9]{0,99}$/,
|
|
125
146
|
'link-type': /^[a-z][A-Za-z0-9]{0,99}$/,
|
|
126
147
|
metric: /^[a-z][A-Za-z0-9]{0,99}$/,
|
|
148
|
+
// `apiNameSchema` in `action-definition-schema.ts`, which governs the action and
|
|
149
|
+
// every member of it — the same camelCase rule, and the same 100-character bound.
|
|
150
|
+
action: /^[a-z][A-Za-z0-9]{0,99}$/,
|
|
127
151
|
}
|
|
128
152
|
|
|
129
153
|
export function describeApiNameRule(kind: ArtifactKind): string {
|
|
@@ -164,6 +188,13 @@ export interface LiveIndex {
|
|
|
164
188
|
* reference a file writes that is not addressed by name on both sides.
|
|
165
189
|
*/
|
|
166
190
|
sharedPropertyIdByApiName: Map<string, string>
|
|
191
|
+
/**
|
|
192
|
+
* The SECOND such reference, and it needs its own map rather than sharing the one
|
|
193
|
+
* above: `semanticType: percentage` is resolved on BOTH carrier kinds — a property
|
|
194
|
+
* entry and a shared field — and the two namespaces are independent, so a shared
|
|
195
|
+
* field and a semantic type may legitimately share an apiName.
|
|
196
|
+
*/
|
|
197
|
+
semanticTypeIdByApiName: Map<string, string>
|
|
167
198
|
/** The live object, by apiName — the merge reads `governance.sourceMappings` off it. */
|
|
168
199
|
objectByApiName: Map<string, BundleObject>
|
|
169
200
|
relationshipByApiName: Map<string, BundleRelationship>
|
|
@@ -178,6 +209,7 @@ export function emptyIndex(): LiveIndex {
|
|
|
178
209
|
metricIdByApiName: new Map(),
|
|
179
210
|
actionIdByApiName: new Map(),
|
|
180
211
|
sharedPropertyIdByApiName: new Map(),
|
|
212
|
+
semanticTypeIdByApiName: new Map(),
|
|
181
213
|
objectByApiName: new Map(),
|
|
182
214
|
relationshipByApiName: new Map(),
|
|
183
215
|
}
|
|
@@ -202,6 +234,9 @@ export function indexBundle(bundle: DefinitionBundle): LiveIndex {
|
|
|
202
234
|
for (const shared of bundle.sharedProperties ?? []) {
|
|
203
235
|
index.sharedPropertyIdByApiName.set(shared.apiName, shared.id)
|
|
204
236
|
}
|
|
237
|
+
for (const semanticType of bundle.semanticTypes ?? []) {
|
|
238
|
+
index.semanticTypeIdByApiName.set(semanticType.apiName, semanticType.id)
|
|
239
|
+
}
|
|
205
240
|
return index
|
|
206
241
|
}
|
|
207
242
|
|
|
@@ -52,31 +52,61 @@ function rest(source: Record<string, unknown>, resolved: Set<string>): Record<st
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* A property entry, with its
|
|
55
|
+
* A property entry, with its two references named rather than identified.
|
|
56
56
|
*
|
|
57
|
-
*
|
|
58
|
-
* to resolve to, so
|
|
59
|
-
* be unportable in the precise sense this whole tree exists to avoid.
|
|
60
|
-
*
|
|
57
|
+
* `sharedPropertyId` and `semanticTypeId` are the uuids a property carries that HAVE
|
|
58
|
+
* names to resolve to, so both are resolved rather than stripped — a file that kept
|
|
59
|
+
* either would be unportable in the precise sense this whole tree exists to avoid.
|
|
60
|
+
* The authored keys are `sharedField` and `semanticType`: the tree is what a person
|
|
61
|
+
* writes, and a person writes field and type, not property and id.
|
|
61
62
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* deployment instead of at the `pull` that produced it.
|
|
63
|
+
* Both are emitted, and both may be present on the wire even though the bundle
|
|
64
|
+
* forbids an implementing property from carrying its own — the projection reports
|
|
65
|
+
* what the draft says rather than re-deciding it.
|
|
66
66
|
*/
|
|
67
67
|
function propertyToFile(
|
|
68
68
|
property: Record<string, unknown>,
|
|
69
69
|
objectApiName: string,
|
|
70
70
|
sharedApiNameById: Map<string, string>,
|
|
71
|
+
semanticApiNameById: Map<string, string>,
|
|
71
72
|
): Record<string, unknown> {
|
|
72
|
-
const body = rest(property, new Set(['id', 'sharedPropertyId']))
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
const body = rest(property, new Set(['id', 'sharedPropertyId', 'semanticTypeId']))
|
|
74
|
+
const semanticType = resolveName(
|
|
75
|
+
property.semanticTypeId,
|
|
76
|
+
semanticApiNameById,
|
|
77
|
+
'semantic type',
|
|
78
|
+
`Field "${String(property.apiName)}" on ${objectApiName}`,
|
|
79
|
+
)
|
|
80
|
+
const withSemanticType = semanticType === undefined ? body : { ...body, semanticType }
|
|
81
|
+
const sharedField = resolveName(
|
|
82
|
+
property.sharedPropertyId,
|
|
83
|
+
sharedApiNameById,
|
|
84
|
+
'shared field',
|
|
85
|
+
`Field "${String(property.apiName)}" on ${objectApiName}`,
|
|
86
|
+
)
|
|
87
|
+
return sharedField === undefined ? withSemanticType : { ...withSemanticType, sharedField }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A uuid reference, as the name a file may carry — or nothing, when the bundle
|
|
92
|
+
* carries no reference at all.
|
|
93
|
+
*
|
|
94
|
+
* Shared by both reference kinds and both carriers, because the failure is the same
|
|
95
|
+
* one every time: an id the bundle does not declare is a corrupt export, and writing
|
|
96
|
+
* it into the file would produce a tree that round-trips here and names nothing in
|
|
97
|
+
* the next deployment. It fails at the `pull` that produced it instead.
|
|
98
|
+
*/
|
|
99
|
+
function resolveName(
|
|
100
|
+
id: unknown,
|
|
101
|
+
apiNameById: Map<string, string>,
|
|
102
|
+
label: string,
|
|
103
|
+
subject: string,
|
|
104
|
+
): string | undefined {
|
|
105
|
+
if (id === undefined || id === null) return undefined
|
|
106
|
+
const apiName = apiNameById.get(String(id))
|
|
76
107
|
if (!apiName) {
|
|
77
108
|
throw new CliError(
|
|
78
|
-
|
|
79
|
-
+ `${String(sharedPropertyId)}, which this draft does not declare.`,
|
|
109
|
+
`${subject} references ${label} ${String(id)}, which this draft does not declare.`,
|
|
80
110
|
{
|
|
81
111
|
code: 'FAILURE',
|
|
82
112
|
hint: 'The draft is inconsistent — re-read it, and report it if it persists. '
|
|
@@ -84,7 +114,7 @@ function propertyToFile(
|
|
|
84
114
|
},
|
|
85
115
|
)
|
|
86
116
|
}
|
|
87
|
-
return
|
|
117
|
+
return apiName
|
|
88
118
|
}
|
|
89
119
|
|
|
90
120
|
/**
|
|
@@ -130,6 +160,7 @@ function backingToFile(
|
|
|
130
160
|
function objectToFile(
|
|
131
161
|
object: BundleObject,
|
|
132
162
|
sharedApiNameById: Map<string, string>,
|
|
163
|
+
semanticApiNameById: Map<string, string>,
|
|
133
164
|
datasetNameByRevisionId?: Map<string, string>,
|
|
134
165
|
): Record<string, unknown> {
|
|
135
166
|
const byId = new Map(object.properties.map((property) => [property.id, property.apiName]))
|
|
@@ -143,7 +174,7 @@ function objectToFile(
|
|
|
143
174
|
primaryKey: byId.get(object.primaryKeyPropertyId) ?? object.primaryKeyPropertyId,
|
|
144
175
|
title: byId.get(object.titlePropertyId) ?? object.titlePropertyId,
|
|
145
176
|
properties: object.properties.map((property) =>
|
|
146
|
-
propertyToFile(property, object.apiName, sharedApiNameById)),
|
|
177
|
+
propertyToFile(property, object.apiName, sharedApiNameById, semanticApiNameById)),
|
|
147
178
|
}
|
|
148
179
|
}
|
|
149
180
|
|
|
@@ -166,6 +197,133 @@ function linkToFile(link: BundleRelationship, objectApiNameById: Map<string, str
|
|
|
166
197
|
}
|
|
167
198
|
}
|
|
168
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Every name an action can reference, indexed by the id the bundle stores.
|
|
202
|
+
*
|
|
203
|
+
* Built once per projection because an action reaches into three levels of another
|
|
204
|
+
* artifact — a type, its properties, its lifecycle transitions — and rebuilding the
|
|
205
|
+
* maps per action would be quadratic in the size of the draft.
|
|
206
|
+
*/
|
|
207
|
+
interface ActionNames {
|
|
208
|
+
object: Map<string, string>
|
|
209
|
+
/** Property and transition ids are globally unique, so one flat map each is enough. */
|
|
210
|
+
property: Map<string, string>
|
|
211
|
+
transition: Map<string, string>
|
|
212
|
+
sourceSystem: Map<string, string>
|
|
213
|
+
action: Map<string, string>
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
217
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Replace an id field with the name it stands for.
|
|
222
|
+
*
|
|
223
|
+
* A miss LEAVES THE ID IN PLACE rather than dropping the field. A dropped reference
|
|
224
|
+
* would make the file parse and then mean something else — the projection's one rule
|
|
225
|
+
* is that what it cannot translate it carries through untouched, so the loss is
|
|
226
|
+
* visible in a diff instead of silent.
|
|
227
|
+
*/
|
|
228
|
+
function named(
|
|
229
|
+
target: Record<string, unknown>,
|
|
230
|
+
idField: string,
|
|
231
|
+
nameField: string,
|
|
232
|
+
names: Map<string, string>,
|
|
233
|
+
): void {
|
|
234
|
+
const id = target[idField]
|
|
235
|
+
if (typeof id !== 'string') return
|
|
236
|
+
const name = names.get(id)
|
|
237
|
+
if (!name) return
|
|
238
|
+
target[nameField] = name
|
|
239
|
+
delete target[idField]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** `{ kind: 'input', fieldId }` → `{ kind: 'input', field }`, anywhere below `node`. */
|
|
243
|
+
function nameFieldRefs(node: unknown, fields: Map<string, string>, properties: Map<string, string>): void {
|
|
244
|
+
if (Array.isArray(node)) {
|
|
245
|
+
for (const entry of node) nameFieldRefs(entry, fields, properties)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
if (!isRecord(node)) return
|
|
249
|
+
if ((node.kind === 'input' || node.kind === 'output') && typeof node.fieldId === 'string') {
|
|
250
|
+
named(node, 'fieldId', 'field', fields)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
if (node.kind === 'observation_property' && typeof node.propertyId === 'string') {
|
|
254
|
+
named(node, 'propertyId', 'property', properties)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
for (const value of Object.values(node)) nameFieldRefs(value, fields, properties)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* An action as the bundle carries it → the portable file.
|
|
262
|
+
*
|
|
263
|
+
* The exact inverse of the service's `resolveActionReferences` and
|
|
264
|
+
* `assignActionIdentity`, and it has to stay that way: `pull` writes what `apply` must
|
|
265
|
+
* be able to send back. Ids the contract OWNS are stripped, because the service mints
|
|
266
|
+
* them; ids it REFERENCES become names, because names mean the same thing in every
|
|
267
|
+
* deployment and ids do not.
|
|
268
|
+
*/
|
|
269
|
+
function actionToFile(action: Record<string, unknown>, names: ActionNames): Record<string, unknown> {
|
|
270
|
+
const file = structuredClone(action)
|
|
271
|
+
delete file.id
|
|
272
|
+
|
|
273
|
+
if (isRecord(file.subject)) named(file.subject, 'objectTypeId', 'objectType', names.object)
|
|
274
|
+
if (isRecord(file.effect)) {
|
|
275
|
+
named(file.effect, 'authoritySourceSystemId', 'authoritySourceSystem', names.sourceSystem)
|
|
276
|
+
named(file.effect, 'compensationActionId', 'compensationAction', names.action)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// The action's own inputs, named before anything can reference them.
|
|
280
|
+
const inputNames = new Map<string, string>()
|
|
281
|
+
for (const input of Array.isArray(file.inputs) ? file.inputs : []) {
|
|
282
|
+
if (!isRecord(input)) continue
|
|
283
|
+
if (typeof input.id === 'string' && typeof input.apiName === 'string') {
|
|
284
|
+
inputNames.set(input.id, input.apiName)
|
|
285
|
+
}
|
|
286
|
+
delete input.id
|
|
287
|
+
if (isRecord(input.valueType)) named(input.valueType, 'objectTypeId', 'objectType', names.object)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const criterion of Array.isArray(file.submissionCriteria) ? file.submissionCriteria : []) {
|
|
291
|
+
if (!isRecord(criterion)) continue
|
|
292
|
+
delete criterion.id
|
|
293
|
+
// Criteria see inputs only — an output does not exist until the action has run.
|
|
294
|
+
nameFieldRefs(criterion.assertion, inputNames, names.property)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const outcome of Array.isArray(file.businessOutcomes) ? file.businessOutcomes : []) {
|
|
298
|
+
if (!isRecord(outcome)) continue
|
|
299
|
+
// Output fields are scoped to their outcome, exactly as the bundle validates them.
|
|
300
|
+
const outputNames = new Map<string, string>()
|
|
301
|
+
for (const field of Array.isArray(outcome.output) ? outcome.output : []) {
|
|
302
|
+
if (!isRecord(field)) continue
|
|
303
|
+
if (typeof field.id === 'string' && typeof field.apiName === 'string') {
|
|
304
|
+
outputNames.set(field.id, field.apiName)
|
|
305
|
+
}
|
|
306
|
+
delete field.id
|
|
307
|
+
if (isRecord(field.valueType)) named(field.valueType, 'objectTypeId', 'objectType', names.object)
|
|
308
|
+
}
|
|
309
|
+
const scope = new Map([...inputNames, ...outputNames])
|
|
310
|
+
for (const change of Array.isArray(outcome.expectedChanges) ? outcome.expectedChanges : []) {
|
|
311
|
+
if (!isRecord(change)) continue
|
|
312
|
+
named(change, 'objectTypeId', 'objectType', names.object)
|
|
313
|
+
named(change, 'propertyId', 'property', names.property)
|
|
314
|
+
named(change, 'transitionId', 'transition', names.transition)
|
|
315
|
+
nameFieldRefs(change.value, scope, names.property)
|
|
316
|
+
}
|
|
317
|
+
for (const postcondition of Array.isArray(outcome.postconditions) ? outcome.postconditions : []) {
|
|
318
|
+
if (!isRecord(postcondition)) continue
|
|
319
|
+
delete postcondition.id
|
|
320
|
+
nameFieldRefs(postcondition.assertion, scope, names.property)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return file
|
|
325
|
+
}
|
|
326
|
+
|
|
169
327
|
/**
|
|
170
328
|
* Live draft → the file tree a person edits. Lossless but for the resolved ids.
|
|
171
329
|
*
|
|
@@ -184,18 +342,36 @@ export function toFiles(
|
|
|
184
342
|
const sharedApiNameById = new Map(
|
|
185
343
|
(bundle.sharedProperties ?? []).map((shared) => [shared.id, shared.apiName]),
|
|
186
344
|
)
|
|
345
|
+
const semanticApiNameById = new Map(
|
|
346
|
+
(bundle.semanticTypes ?? []).map((semanticType) => [semanticType.id, semanticType.apiName]),
|
|
347
|
+
)
|
|
187
348
|
const files: AuthoredFile[] = []
|
|
188
349
|
const push = (kind: AuthoredFile['kind'], apiName: string, document: Record<string, unknown>) => {
|
|
189
350
|
files.push({ kind, apiName, path: `${KIND_DIRECTORY[kind]}/${apiName}.yaml`, document })
|
|
190
351
|
}
|
|
191
352
|
|
|
353
|
+
for (const semanticType of bundle.semanticTypes ?? []) {
|
|
354
|
+
// Metadata plus one rule, and no reference of its own: stripping the id is the
|
|
355
|
+
// whole projection. First, because both carriers name one.
|
|
356
|
+
push('semantic-type', semanticType.apiName, rest(semanticType, new Set(['id'])))
|
|
357
|
+
}
|
|
192
358
|
for (const shared of bundle.sharedProperties ?? []) {
|
|
193
|
-
// A shared field owns no backing and
|
|
194
|
-
//
|
|
195
|
-
|
|
359
|
+
// A shared field owns no backing, and the one reference it does carry is a
|
|
360
|
+
// semantic type — resolved to its apiName here rather than stripped. Stripping
|
|
361
|
+
// was the likelier leak: this copy took out `id` alone, so the reference would
|
|
362
|
+
// have travelled into the file as a per-deployment uuid.
|
|
363
|
+
const semanticType = resolveName(
|
|
364
|
+
shared.semanticTypeId, semanticApiNameById, 'semantic type', `Shared field "${shared.apiName}"`,
|
|
365
|
+
)
|
|
366
|
+
push('shared-field', shared.apiName, {
|
|
367
|
+
...rest(shared, new Set(['id', 'semanticTypeId'])),
|
|
368
|
+
...(semanticType === undefined ? {} : { semanticType }),
|
|
369
|
+
})
|
|
196
370
|
}
|
|
197
371
|
for (const object of bundle.objects) {
|
|
198
|
-
push('object-type', object.apiName, objectToFile(
|
|
372
|
+
push('object-type', object.apiName, objectToFile(
|
|
373
|
+
object, sharedApiNameById, semanticApiNameById, datasetNameByRevisionId,
|
|
374
|
+
))
|
|
199
375
|
}
|
|
200
376
|
for (const link of bundle.relationships) {
|
|
201
377
|
push('link-type', link.apiName, linkToFile(link, objectApiNameById, propertyApiNameById))
|
|
@@ -213,6 +389,26 @@ export function toFiles(
|
|
|
213
389
|
})
|
|
214
390
|
}
|
|
215
391
|
|
|
392
|
+
// v1 has no `actions` key at all — see `carriesActions`. An empty array here would
|
|
393
|
+
// write an `actions/` directory a v1 draft can never accept back.
|
|
394
|
+
if (bundle.actions) {
|
|
395
|
+
const names: ActionNames = {
|
|
396
|
+
object: objectApiNameById,
|
|
397
|
+
property: propertyApiNameById,
|
|
398
|
+
transition: new Map(
|
|
399
|
+
bundle.objects.flatMap((object) => {
|
|
400
|
+
const lifecycle = (object as { lifecycle?: { transitions?: Array<{ id: string; apiName: string }> } }).lifecycle
|
|
401
|
+
return (lifecycle?.transitions ?? []).map((entry) => [entry.id, entry.apiName] as const)
|
|
402
|
+
}),
|
|
403
|
+
),
|
|
404
|
+
sourceSystem: new Map(bundle.sourceSystems.map((entry) => [entry.id, entry.apiName])),
|
|
405
|
+
action: new Map(bundle.actions.map((entry) => [entry.id, entry.apiName])),
|
|
406
|
+
}
|
|
407
|
+
for (const action of bundle.actions) {
|
|
408
|
+
push('action', action.apiName, actionToFile(action as Record<string, unknown>, names))
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
216
412
|
return { schemaVersion: bundle.schemaVersion, files: files.sort(byPath) }
|
|
217
413
|
}
|
|
218
414
|
|
package/src/blueprint/render.ts
CHANGED
|
@@ -40,10 +40,13 @@ export function renderPlan(plan: Plan, options: { prune: boolean } = { prune: fa
|
|
|
40
40
|
})
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
const withheld = withheldNote(plan)
|
|
44
|
+
|
|
43
45
|
if (rows.length === 0) {
|
|
44
|
-
|
|
46
|
+
const nothing = plan.unchanged === 0
|
|
45
47
|
? 'No artifacts, and nothing to do.'
|
|
46
48
|
: `No changes. ${plan.unchanged} artifact${plan.unchanged === 1 ? ' matches' : 's match'} the draft.`
|
|
49
|
+
return withheld ? `${nothing}\n${withheld}` : nothing
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
const kindWidth = Math.max(...rows.map((row) => row.kind.length))
|
|
@@ -55,9 +58,29 @@ export function renderPlan(plan: Plan, options: { prune: boolean } = { prune: fa
|
|
|
55
58
|
if (plan.unchanged > 0) {
|
|
56
59
|
lines.push(` = ${plan.unchanged} unchanged`)
|
|
57
60
|
}
|
|
61
|
+
if (withheld) lines.push(withheld)
|
|
58
62
|
return lines.join('\n')
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The prunes a missing directory withheld, and how to mean them.
|
|
67
|
+
*
|
|
68
|
+
* Printed on every plan, with or without `--prune`: the withholding is not about the
|
|
69
|
+
* flag, and a reader who deleted `metrics/` outright needs to know the artifacts are
|
|
70
|
+
* still live either way.
|
|
71
|
+
*/
|
|
72
|
+
function withheldNote(plan: Plan): string {
|
|
73
|
+
const withheld = plan.withheld ?? []
|
|
74
|
+
if (withheld.length === 0) return ''
|
|
75
|
+
const kinds = [...new Set(withheld.map((operation) => operation.kind))]
|
|
76
|
+
return withheld
|
|
77
|
+
.map((operation) => ` · ${operation.kind} ${operation.apiName} withheld — this tree has no ${operation.kind}s/ directory`)
|
|
78
|
+
.join('\n')
|
|
79
|
+
+ `\n A missing directory means "not modelled here", not "delete these". To mean `
|
|
80
|
+
+ `the delete, keep ${kinds.map((kind) => `${kind}s/`).join(' and ')} in the tree — `
|
|
81
|
+
+ `git needs a .gitkeep to carry an empty one — then re-run with --prune.`
|
|
82
|
+
}
|
|
83
|
+
|
|
61
84
|
/** The `!` lines: a bound type whose dataset has moved to a different contract. */
|
|
62
85
|
export function renderDrift(
|
|
63
86
|
drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }>,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { CliError } from '../errors'
|
|
2
|
+
import { ARTIFACT_KINDS, type ArtifactKind } from './model'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* A starting file per kind, complete enough to apply as written once the placeholders
|
|
@@ -9,7 +10,82 @@ import type { ArtifactKind } from './model'
|
|
|
9
10
|
* thing they write omits three more. Every field the create route requires is here,
|
|
10
11
|
* including the ones with dull answers.
|
|
11
12
|
*/
|
|
12
|
-
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The semantic-type starting points, each expressible with the facets this slice
|
|
16
|
+
* ships — no `pattern`, so no uuid or email template pretends to exist.
|
|
17
|
+
*
|
|
18
|
+
* A fixed library rather than a prompt: the CLI does not prompt (see `prompt.ts`),
|
|
19
|
+
* because an agent's stdin is never a TTY and a command that asked would hang. So the
|
|
20
|
+
* choice is a flag with a deterministic default, and every template writes a rule the
|
|
21
|
+
* service accepts unedited.
|
|
22
|
+
*/
|
|
23
|
+
export const SEMANTIC_TYPE_TEMPLATES = {
|
|
24
|
+
/** A quantity that cannot go below zero — the commonest numeric constraint there is. */
|
|
25
|
+
'non-negative': {
|
|
26
|
+
dataType: 'number',
|
|
27
|
+
// Bounds are canonical decimal STRINGS on the wire, not numbers: one spelling per
|
|
28
|
+
// value, so a rule can be compared as text and `1` and `1.0` cannot both exist.
|
|
29
|
+
rule: { minimum: '0' },
|
|
30
|
+
},
|
|
31
|
+
percentage: {
|
|
32
|
+
dataType: 'number',
|
|
33
|
+
rule: { minimum: '0', maximum: '100' },
|
|
34
|
+
},
|
|
35
|
+
'status-enum': {
|
|
36
|
+
dataType: 'string',
|
|
37
|
+
rule: { enum: ['active', 'inactive'] },
|
|
38
|
+
},
|
|
39
|
+
'code-length': {
|
|
40
|
+
dataType: 'string',
|
|
41
|
+
rule: { minLength: 1, maxLength: 64 },
|
|
42
|
+
},
|
|
43
|
+
} as const satisfies Record<string, { dataType: string; rule: Record<string, unknown> }>
|
|
44
|
+
|
|
45
|
+
export type SemanticTypeTemplate = keyof typeof SEMANTIC_TYPE_TEMPLATES
|
|
46
|
+
|
|
47
|
+
export const SEMANTIC_TYPE_TEMPLATE_NAMES = Object.keys(
|
|
48
|
+
SEMANTIC_TYPE_TEMPLATES,
|
|
49
|
+
) as SemanticTypeTemplate[]
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What `new semantic-type` writes without `--template`.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately NOT inferred from the apiName: a `percentage` that scaffolded a number
|
|
55
|
+
* and a `percentComplete` that scaffolded a string would be the same command behaving
|
|
56
|
+
* two ways, and the reader could not tell which they were about to get. One default,
|
|
57
|
+
* always, and the flag is how the other three are asked for.
|
|
58
|
+
*/
|
|
59
|
+
export const DEFAULT_SEMANTIC_TYPE_TEMPLATE: SemanticTypeTemplate = 'code-length'
|
|
60
|
+
|
|
61
|
+
export function isSemanticTypeTemplate(value: string): value is SemanticTypeTemplate {
|
|
62
|
+
return Object.prototype.hasOwnProperty.call(SEMANTIC_TYPE_TEMPLATES, value)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ScaffoldOptions {
|
|
66
|
+
template?: SemanticTypeTemplate
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function scaffold(
|
|
70
|
+
kind: ArtifactKind,
|
|
71
|
+
apiName: string,
|
|
72
|
+
options: ScaffoldOptions = {},
|
|
73
|
+
): Record<string, unknown> {
|
|
74
|
+
if (kind === 'semantic-type') {
|
|
75
|
+
const template = SEMANTIC_TYPE_TEMPLATES[options.template ?? DEFAULT_SEMANTIC_TYPE_TEMPLATE]
|
|
76
|
+
return {
|
|
77
|
+
apiName,
|
|
78
|
+
displayName: apiName,
|
|
79
|
+
description: 'CHANGE_ME',
|
|
80
|
+
// The family the rule is read under. The rule objects are kind-free — a number
|
|
81
|
+
// rule's `{ minimum, maximum }` is the same two keys a date rule uses — so this
|
|
82
|
+
// is what says which of the four schemas the rule is judged against, and moving
|
|
83
|
+
// one without the other is refused.
|
|
84
|
+
dataType: template.dataType,
|
|
85
|
+
rule: { ...template.rule },
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
13
89
|
if (kind === 'object-type') {
|
|
14
90
|
return {
|
|
15
91
|
apiName,
|
|
@@ -64,16 +140,76 @@ export function scaffold(kind: ArtifactKind, apiName: string): Record<string, un
|
|
|
64
140
|
}
|
|
65
141
|
}
|
|
66
142
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
143
|
+
if (kind === 'action') {
|
|
144
|
+
// Every reference is a NAME. The service resolves them against the draft and mints
|
|
145
|
+
// the ids the contract owns, so nothing here carries a uuid — which is exactly
|
|
146
|
+
// what makes the file applicable to a second deployment.
|
|
147
|
+
return {
|
|
148
|
+
apiName,
|
|
149
|
+
displayName: apiName,
|
|
150
|
+
description: 'CHANGE_ME',
|
|
151
|
+
subject: { objectType: 'CHANGE_ME', mode: 'existing' },
|
|
152
|
+
effect: {
|
|
153
|
+
kind: 'update',
|
|
154
|
+
// Named, but note it cannot be authored from this tree: source systems have no
|
|
155
|
+
// route, and one is synthesised per organization. On a second deployment this
|
|
156
|
+
// name may resolve to nothing.
|
|
157
|
+
authoritySourceSystem: 'CHANGE_ME',
|
|
158
|
+
description: 'CHANGE_ME',
|
|
159
|
+
},
|
|
160
|
+
inputs: [],
|
|
161
|
+
// `min(1)` on the create route: an action that declares no outcome cannot say
|
|
162
|
+
// what it did, so a scaffold without one would not apply as written.
|
|
163
|
+
businessOutcomes: [{
|
|
164
|
+
code: 'applied',
|
|
165
|
+
displayName: 'Applied',
|
|
166
|
+
description: 'CHANGE_ME',
|
|
167
|
+
effect: 'confirmed_applied',
|
|
168
|
+
expectedChanges: [],
|
|
169
|
+
postconditions: [],
|
|
170
|
+
output: [],
|
|
171
|
+
}],
|
|
172
|
+
concurrency: { strategy: 'expected_version' },
|
|
173
|
+
impact: { maxSubjects: 1 },
|
|
174
|
+
submissionCriteria: [],
|
|
175
|
+
governance: {
|
|
176
|
+
// Shaped, not `CHANGE_ME`: this one is validated by
|
|
177
|
+
// `/^[a-z][a-z0-9_-]{0,63}:[a-z][a-z0-9_-]{0,63}$/`, so a bare placeholder is
|
|
178
|
+
// the one field in this file that cannot apply as written — and it fails
|
|
179
|
+
// server-side, after the plan was confirmed.
|
|
180
|
+
invokeCapability: 'changeme:invoke',
|
|
181
|
+
approval: { mode: 'none' },
|
|
182
|
+
reason: 'optional',
|
|
183
|
+
sensitivity: 'internal',
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (kind === 'metric') {
|
|
189
|
+
// A metric names the type it measures and the measures it computes. Both are
|
|
190
|
+
// required by the create route, so a scaffold without them would contradict this
|
|
191
|
+
// command's own promise that what it writes validates as written.
|
|
192
|
+
return {
|
|
193
|
+
apiName,
|
|
194
|
+
displayName: apiName,
|
|
195
|
+
objectType: 'CHANGE_ME',
|
|
196
|
+
definition: {
|
|
197
|
+
measures: [{ agg: 'count', label: 'Count' }],
|
|
198
|
+
dimensions: [],
|
|
199
|
+
},
|
|
200
|
+
}
|
|
78
201
|
}
|
|
202
|
+
|
|
203
|
+
// NOT a fall-through to the metric arm, which is what stood here. A kind without a
|
|
204
|
+
// template used to write metric YAML under that kind's directory: the file
|
|
205
|
+
// validated as nothing, `readTree` accepted it, and the reader found out at `apply`
|
|
206
|
+
// — three commands later, in someone else's organization.
|
|
207
|
+
return unhandledKind(kind)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function unhandledKind(kind: never): never {
|
|
211
|
+
throw new CliError(`"${String(kind)}" has no scaffold.`, {
|
|
212
|
+
code: 'USAGE',
|
|
213
|
+
hint: `Expected one of: ${ARTIFACT_KINDS.join(', ')}.`,
|
|
214
|
+
})
|
|
79
215
|
}
|