@frontera-sdk/cli 1.53.8 → 1.53.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +4 -4
  2. package/src/api/governed-action-api.ts +9 -3
  3. package/src/blueprint/action-rules.ts +175 -9
  4. package/src/blueprint/diff.ts +27 -8
  5. package/src/blueprint/ontology-edit-plan.ts +155 -4
  6. package/src/blueprint/org-name-check.ts +10 -8
  7. package/src/blueprint/projection.ts +13 -3
  8. package/src/blueprint/property-intents.ts +248 -0
  9. package/src/blueprint/publish-plan.ts +40 -4
  10. package/src/blueprint/render.ts +4 -0
  11. package/src/blueprint/tree.ts +22 -0
  12. package/src/commands/action/deploy.ts +31 -24
  13. package/src/commands/action/retire.ts +9 -1
  14. package/src/commands/action/submit.ts +21 -5
  15. package/src/commands/auth/login.ts +2 -2
  16. package/src/commands/blueprint/authoring.ts +22 -4
  17. package/src/commands/blueprint/declarative.ts +140 -32
  18. package/src/commands/knowledge/index-commands.ts +1 -1
  19. package/src/commands/local/check.ts +6 -4
  20. package/src/commands/local/index-commands.ts +8 -2
  21. package/src/commands/local/sync-request.ts +252 -66
  22. package/src/commands/local/sync.ts +29 -24
  23. package/src/device-login.ts +6 -1
  24. package/src/local/local-actions.ts +13 -1
  25. package/src/local/local-applied.ts +1 -1
  26. package/src/local/local-blueprint-schemas.ts +40 -6
  27. package/src/local/local-dev-references.ts +4 -3
  28. package/src/local/local-jobs.ts +28 -0
  29. package/src/local/local-portability.ts +1 -1
  30. package/src/local/local-sync-link.ts +62 -4
  31. package/src/local/local-sync-merge.ts +37 -1
  32. package/src/local/local-sync-plan.ts +44 -6
  33. package/src/local/local-sync-precheck.ts +2 -1
  34. package/src/local/local-sync-push.ts +22 -11
  35. package/src/local/local-sync-remote.ts +13 -8
  36. package/src/local/local-synthetic-data.ts +2 -2
  37. package/src/main.ts +7 -2
  38. package/src/vendor/sdk-sources.json +2 -2
@@ -188,9 +188,12 @@ function backingToFile(
188
188
  ? { column: entry.sourcePath, field: entry.sourceField }
189
189
  : entry.sourcePath
190
190
  }
191
- const editable = (mapping.editableProperties ?? [])
192
- .map((id) => apiNameById.get(id))
193
- .filter((name): name is string => Boolean(name))
191
+ // A set, which the service keeps sorted by property id: written in the type's own property order, so the
192
+ // file reads like the type and stays the same from one read to the next.
193
+ const editableIds = new Set(mapping.editableProperties ?? [])
194
+ const editable = object.properties
195
+ .filter((property) => editableIds.has(property.id))
196
+ .map((property) => property.apiName)
194
197
  return {
195
198
  dataset,
196
199
  mapping: entries,
@@ -355,6 +358,13 @@ function actionToFile(action: Record<string, unknown>, names: ActionNames): Reco
355
358
  if (isRecord(input.valueType)) named(input.valueType, 'objectTypeId', 'objectType', names.object)
356
359
  }
357
360
 
361
+ for (const record of Array.isArray(file.records) ? file.records : []) {
362
+ if (!isRecord(record)) continue
363
+ named(record, 'objectTypeId', 'objectType', names.object)
364
+ named(record, 'keyFieldId', 'keyInput', inputNames)
365
+ named(record, 'versionFieldId', 'versionInput', inputNames)
366
+ }
367
+
358
368
  for (const criterion of Array.isArray(file.submissionCriteria) ? file.submissionCriteria : []) {
359
369
  if (!isRecord(criterion)) continue
360
370
  delete criterion.id
@@ -0,0 +1,248 @@
1
+ import { parseRef } from '../local/local-sync-refs'
2
+ import type { AuthoredFile, DefinitionBundle } from './model'
3
+
4
+ /**
5
+ * What a type file says about a property the draft holds under another name.
6
+ *
7
+ * A property the file stops naming and one it names for the first time look the
8
+ * same whether the person renamed a field or replaced it, and the two do opposite
9
+ * things to the values behind it. Two keys on the new property say which:
10
+ *
11
+ * renamedFrom: <old> the same property under a new name. Its id, its column,
12
+ * its edited values, its editability and every Action, link
13
+ * and metric that names it stay with it.
14
+ * replaces: <old> a new property. <old> is removed, and its values are not
15
+ * carried over.
16
+ *
17
+ * Read here and taken off the documents, so nothing after this compares or sends
18
+ * them. A key whose old name the draft no longer holds has already happened (the
19
+ * draft was renamed, or the name never reached it) and asks for nothing.
20
+ */
21
+ export interface PropertyRename { objectApiName: string; from: string; to: string }
22
+ /** A saved object set's file, `object-sets/<ref>.yaml` under the Blueprint folder. */
23
+ export interface ObjectSetDocument { path: string; ref: string; document: Record<string, unknown> }
24
+ export interface PropertyReplacement { objectApiName: string; removed: string; added: string }
25
+ export interface PropertyRefusal { objectApiName: string; message: string; next: string }
26
+
27
+ export interface PropertyIntents {
28
+ /** The files without `renamedFrom` and `replaces`. */
29
+ files: AuthoredFile[]
30
+ /** The draft as it reads once every rename is made: names changed, ids kept. */
31
+ live: DefinitionBundle
32
+ renames: PropertyRename[]
33
+ replacements: PropertyReplacement[]
34
+ refusals: PropertyRefusal[]
35
+ }
36
+
37
+ const INTENT_KEYS = ['renamedFrom', 'replaces'] as const
38
+
39
+ type Doc = Record<string, unknown>
40
+ const isDoc = (value: unknown): value is Doc => typeof value === 'object' && value !== null && !Array.isArray(value)
41
+ const propertiesOf = (doc: Doc): Doc[] => (Array.isArray(doc.properties) ? doc.properties.filter(isDoc) : [])
42
+
43
+ export function renameCommand(objectApiName: string, from: string, to: string): string {
44
+ return `frontera blueprint rename property ${objectApiName} ${from} ${to}`
45
+ }
46
+
47
+ export function readPropertyIntents(files: AuthoredFile[], draft: DefinitionBundle, objectSets: ObjectSetDocument[] = []): PropertyIntents {
48
+ const live = structuredClone(draft)
49
+ const renames: PropertyRename[] = []
50
+ const replacements: PropertyReplacement[] = []
51
+ const refusals: PropertyRefusal[] = []
52
+
53
+ for (const file of files) {
54
+ if (file.kind !== 'object-type') continue
55
+ const object = live.objects.find((candidate) => candidate.apiName === file.apiName)
56
+ if (!object) continue
57
+ const declared = propertiesOf(file.document)
58
+ const declaredNames = new Set(declared.map((property) => String(property.apiName)))
59
+ const liveNames = new Set(object.properties.map((property) => property.apiName))
60
+ const refuse = (message: string, next: string) => refusals.push({ objectApiName: file.apiName, message: `${file.path}: ${message}`, next })
61
+
62
+ const renamedHere = new Map<string, string>()
63
+ const replaced = new Set<string>()
64
+ const replacing = new Set<string>()
65
+ for (const property of declared) {
66
+ const name = String(property.apiName)
67
+ const from = property.renamedFrom
68
+ if (typeof from === 'string' && from !== name && liveNames.has(from)) {
69
+ if (liveNames.has(name)) {
70
+ refuse(`${name} says renamedFrom: ${from}, and ${file.apiName} already has both ${from} and ${name}.`,
71
+ `rename ${from} once ${name} is gone (remove ${name} in an apply of its own), or take renamedFrom off ${name}`)
72
+ } else if (renamedHere.has(from)) {
73
+ refuse(`${renamedHere.get(from)} and ${name} both say renamedFrom: ${from}; one property has one new name.`,
74
+ `keep renamedFrom: ${from} on one of them`)
75
+ } else {
76
+ renamedHere.set(from, name)
77
+ }
78
+ }
79
+ const old = property.replaces
80
+ if (typeof old === 'string' && liveNames.has(old) && !declaredNames.has(old) && !liveNames.has(name)) {
81
+ replaced.add(old)
82
+ replacing.add(name)
83
+ replacements.push({ objectApiName: file.apiName, removed: old, added: name })
84
+ }
85
+ }
86
+ for (const [from, to] of renamedHere) {
87
+ renames.push({ objectApiName: file.apiName, from, to })
88
+ object.properties.find((property) => property.apiName === from)!.apiName = to
89
+ }
90
+
91
+ const after = new Set(object.properties.map((property) => property.apiName))
92
+ // A calculated field is added from its latitude and longitude, never in place of a column.
93
+ const added = declared
94
+ .filter((property) => !after.has(String(property.apiName)) && !property.calculation && !replacing.has(String(property.apiName)))
95
+ .map((property) => String(property.apiName))
96
+ const removed = [...after].filter((name) => !declaredNames.has(name) && !replaced.has(name))
97
+ if (added.length && removed.length) {
98
+ const one = added.length === 1 && removed.length === 1
99
+ refuse(
100
+ `this edit removes ${removed.join(', ')} and adds ${added.join(', ')} at once, and a rename and a replacement look the same: `
101
+ + 'a rename keeps the values, edits and references behind the field, a replacement does not carry them over.',
102
+ one
103
+ ? `if ${removed[0]} is now ${added[0]}, run \`${renameCommand(file.apiName, removed[0]!, added[0]!)}\` `
104
+ + `(or write renamedFrom: ${removed[0]} under ${added[0]}); if ${added[0]} replaces it, write replaces: ${removed[0]} under ${added[0]}, then apply again`
105
+ : 'under each new property write renamedFrom: <old name> (the same field, renamed) or replaces: <old name> (a new field; the old one\'s values are not carried over), then apply again',
106
+ )
107
+ }
108
+ }
109
+
110
+ for (const rename of renames) {
111
+ // A new property that takes the old name back is what those references mean now.
112
+ const typeFile = files.find((file) => file.kind === 'object-type' && file.apiName === rename.objectApiName)!
113
+ if (propertiesOf(typeFile.document).some((property) => property.apiName === rename.from)) continue
114
+ const stale = [
115
+ ...files.flatMap((file) => namesOf(file, rename.objectApiName, rename.from).map(() => file.path)),
116
+ ...objectSets.flatMap((set) => objectSetNamesOf(set, files, objectSets, rename.objectApiName, rename.from).map(() => set.path)),
117
+ ]
118
+ if (stale.length) {
119
+ refusals.push({
120
+ objectApiName: rename.objectApiName,
121
+ message: `${[...new Set(stale)].join(', ')} still name${stale.length === 1 ? 's' : ''} ${rename.objectApiName}.${rename.from}, which this apply renames to ${rename.to}.`,
122
+ next: `\`${renameCommand(rename.objectApiName, rename.from, rename.to)}\` changes every file that names it`,
123
+ })
124
+ }
125
+ }
126
+
127
+ return { files: files.map(withoutIntents), live, renames, replacements, refusals }
128
+ }
129
+
130
+ function withoutIntents(file: AuthoredFile): AuthoredFile {
131
+ if (file.kind !== 'object-type' || !propertiesOf(file.document).some((property) => INTENT_KEYS.some((key) => key in property))) return file
132
+ const document = structuredClone(file.document)
133
+ for (const property of propertiesOf(document)) for (const key of INTENT_KEYS) delete property[key]
134
+ return { ...file, document }
135
+ }
136
+
137
+ /**
138
+ * Each place a file names `<objectApiName>.<name>`, other than the property's own
139
+ * `apiName`, with how to name `to` there instead: a type's keys, its mapping, its
140
+ * editable list and its location fields; an Action's changes and the subject
141
+ * properties it observes; a link's ends; a metric's measures, filters, dimensions
142
+ * and time property.
143
+ */
144
+ export function namesOf(file: AuthoredFile, objectApiName: string, name: string): Array<(to: string) => void> {
145
+ const places: Array<(to: string) => void> = []
146
+ const doc = file.document
147
+ const at = (holder: Doc | unknown[], key: string | number) => places.push((to) => { (holder as Record<string | number, unknown>)[key] = to })
148
+
149
+ if (file.kind === 'object-type' && file.apiName === objectApiName) {
150
+ for (const key of ['primaryKey', 'title']) if (doc[key] === name) at(doc, key)
151
+ const backing = isDoc(doc.backing) ? doc.backing : undefined
152
+ const mapping = isDoc(backing?.mapping) ? backing.mapping : undefined
153
+ if (mapping && Object.hasOwn(mapping, name)) {
154
+ places.push((to) => {
155
+ backing!.mapping = Object.fromEntries(Object.entries(mapping).map(([key, value]) => [key === name ? to : key, value]))
156
+ })
157
+ }
158
+ if (Array.isArray(backing?.editable)) backing.editable.forEach((entry, index) => { if (entry === name) at(backing.editable as unknown[], index) })
159
+ for (const property of propertiesOf(doc)) {
160
+ const calculation = property.calculation
161
+ if (isDoc(calculation)) for (const key of ['latitude', 'longitude']) if (calculation[key] === name) at(calculation, key)
162
+ }
163
+ return places
164
+ }
165
+
166
+ const subject = file.kind === 'action' && isDoc(doc.subject) ? doc.subject.objectType : undefined
167
+ const measured = file.kind === 'metric' && doc.objectType === objectApiName
168
+ const walk = (node: unknown, key?: string): void => {
169
+ if (Array.isArray(node)) {
170
+ node.forEach((entry, index) => {
171
+ if (measured && key === 'dimensions' && entry === name) at(node, index)
172
+ else walk(entry)
173
+ })
174
+ return
175
+ }
176
+ if (!isDoc(node)) return
177
+ if (node.property === name) {
178
+ const own = node.objectType === objectApiName
179
+ || (node.objectType === undefined && ((subject === objectApiName && node.kind === 'observation_property') || measured))
180
+ if (own) at(node, 'property')
181
+ }
182
+ if (measured && node.timeseries === name) at(node, 'timeseries')
183
+ for (const [child, value] of Object.entries(node)) walk(value, child)
184
+ }
185
+ if (file.kind === 'action' || file.kind === 'link-type' || file.kind === 'metric') walk(doc)
186
+ return places
187
+ }
188
+
189
+ /**
190
+ * Each place a saved object set names `<objectApiName>.<name>`: a condition's
191
+ * `property` in a filter over a set of that type. The type a filter reads is the
192
+ * type of the set it narrows, followed through links (by the project's link files)
193
+ * and references to other sets (by their files' `objectType`).
194
+ */
195
+ export function objectSetNamesOf(
196
+ set: ObjectSetDocument,
197
+ files: AuthoredFile[],
198
+ sets: ObjectSetDocument[],
199
+ objectApiName: string,
200
+ name: string,
201
+ ): Array<(to: string) => void> {
202
+ const places: Array<(to: string) => void> = []
203
+ const ends = new Map(files.filter((file) => file.kind === 'link-type').map((file) => {
204
+ const end = (key: string) => (isDoc(file.document[key]) ? (file.document[key] as Doc).objectType : undefined)
205
+ return [file.apiName, { from: end('from'), to: end('to') }]
206
+ }))
207
+ const typeOf = (expr: unknown): unknown => {
208
+ if (!isDoc(expr)) return undefined
209
+ switch (expr.type) {
210
+ case 'base':
211
+ case 'static':
212
+ return expr.objectType
213
+ case 'filter':
214
+ return typeOf(expr.objectSet)
215
+ case 'searchAround': {
216
+ const from = typeOf(expr.objectSet)
217
+ const link = ends.get(String(expr.link))
218
+ return from === undefined || !link ? undefined : link.from === from ? link.to : link.to === from ? link.from : undefined
219
+ }
220
+ case 'union':
221
+ case 'intersect':
222
+ case 'subtract':
223
+ return Array.isArray(expr.objectSets) ? typeOf(expr.objectSets[0]) : undefined
224
+ case 'reference': {
225
+ const ref = typeof expr.objectSetId === 'string' ? parseRef(expr.objectSetId) : null
226
+ return ref?.kind === 'objectSet'
227
+ ? sets.find((other) => other.ref === ref.name || other.document.name === ref.name)?.document.objectType
228
+ : undefined
229
+ }
230
+ default:
231
+ return undefined
232
+ }
233
+ }
234
+ const conditions = (where: unknown): void => {
235
+ if (Array.isArray(where)) return where.forEach(conditions)
236
+ if (!isDoc(where)) return
237
+ if (where.property === name) places.push((to) => { where.property = to })
238
+ for (const key of ['and', 'or', 'not']) conditions(where[key])
239
+ }
240
+ const walk = (expr: unknown): void => {
241
+ if (!isDoc(expr)) return
242
+ if (expr.type === 'filter' && typeOf(expr.objectSet) === objectApiName) conditions(expr.where)
243
+ walk(expr.objectSet)
244
+ if (Array.isArray(expr.objectSets)) expr.objectSets.forEach(walk)
245
+ }
246
+ walk(set.document.definition)
247
+ return places
248
+ }
@@ -52,6 +52,8 @@ export interface ChangedAction {
52
52
  apiName: string
53
53
  /** As on `NewAction`: known when the release changes the Action's effect kind. */
54
54
  editsBlueprint?: boolean
55
+ /** The object type whose change changed it, when its own definition did not change. */
56
+ objectType?: string
55
57
  }
56
58
 
57
59
  export interface PublishPlan {
@@ -120,7 +122,22 @@ const NEXT_STEPS: Record<string, (name: string, signIn: string | null) => { why:
120
122
  ACTION_BINDING_STATE_CONTRADICTORY: () => ({ why: 'Its deployment state does not add up.', commands: ['frontera action list'] }),
121
123
  }
122
124
 
123
- export function publishPlan(report: ValidationReport, signIn: string | null, profile?: string): PublishPlan {
125
+ /** The object types this release changes other than by adding, by id: a property renamed or retyped changes the contract of an Action that uses it. */
126
+ function changedObjectTypes(report: ValidationReport): Map<string, string> {
127
+ return new Map((report.evolution?.changes ?? [])
128
+ .filter((change) => change.subject.kind === 'object' && change.classification !== 'additive')
129
+ .map((change) => [change.subject.id, change.subject.label]))
130
+ }
131
+
132
+ /** Whether `publishPlan` needs each Action's object type: a switched-off Action whose own definition this release leaves alone, beside a type it changes. */
133
+ export function needsActionSubjects(report: ValidationReport): boolean {
134
+ const own = new Set((report.evolution?.changes ?? []).filter((change) => change.subject.kind === 'action').map((change) => change.subject.id))
135
+ return changedObjectTypes(report).size > 0
136
+ && (report.bindingReadiness?.actions ?? []).some((action) => action.reason === 'ACTION_DISABLED' && !own.has(action.actionDefinitionId))
137
+ }
138
+
139
+ /** @param actionSubjects each Action's object type id, read from the draft when `needsActionSubjects` says so. */
140
+ export function publishPlan(report: ValidationReport, signIn: string | null, profile?: string, actionSubjects?: ReadonlyMap<string, string>): PublishPlan {
124
141
  const changes = report.evolution?.changes ?? []
125
142
  const actions = report.bindingReadiness?.actions ?? []
126
143
  // Ids to names: a blocker names its subject only by an id in its path.
@@ -141,6 +158,7 @@ export function publishPlan(report: ValidationReport, signIn: string | null, pro
141
158
  const newActionIds = new Set(changes.filter((change) => change.code === NEW_ACTION_ENVIRONMENT_CHANGE).map((change) => change.subject.id))
142
159
  const changedActionIds = new Set(changes.filter((change) => change.subject.kind === 'action').map((change) => change.subject.id))
143
160
  const editsBlueprint = editsBlueprintById(changes)
161
+ const changedTypes = changedObjectTypes(report)
144
162
  return {
145
163
  reportId: report.id,
146
164
  draftRevision: report.draftRevision,
@@ -158,16 +176,34 @@ export function publishPlan(report: ValidationReport, signIn: string | null, pro
158
176
  }
159
177
  }),
160
178
  changedSwitchedOff: actions
161
- .filter((action) => action.reason === 'ACTION_DISABLED' && changedActionIds.has(action.actionDefinitionId) && !newActionIds.has(action.actionDefinitionId))
162
- .map((action) => {
179
+ .filter((action) => action.reason === 'ACTION_DISABLED' && !newActionIds.has(action.actionDefinitionId))
180
+ .flatMap((action) => {
181
+ const objectType = changedActionIds.has(action.actionDefinitionId) ? undefined : changedTypes.get(actionSubjects?.get(action.actionDefinitionId) ?? '')
182
+ if (!changedActionIds.has(action.actionDefinitionId) && objectType === undefined) return []
163
183
  const edits = editsBlueprint.get(action.actionDefinitionId)
164
- return { apiName: action.actionApiName, ...(edits === undefined ? {} : { editsBlueprint: edits }) }
184
+ return [{ apiName: action.actionApiName, ...(edits === undefined ? {} : { editsBlueprint: edits }), ...(objectType ? { objectType } : {}) }]
165
185
  }),
166
186
  // Only a report that carries the list: one without it says nothing either way.
167
187
  unchanged: Array.isArray(report.evolution?.changes) && changes.length === 0,
168
188
  }
169
189
  }
170
190
 
191
+ /**
192
+ * What a person runs after clearing one blocker, so a publish is not tried while
193
+ * another remains: the other blockers' commands, then the publish, written with
194
+ * an instruction to fill in when the release needs one. An Action with no
195
+ * deployment decision is left to the publish, which records it.
196
+ */
197
+ export function stepsToPublish(plan: PublishPlan, profile?: string): string[] {
198
+ const blockers = plan.blockers.filter((blocker) => blocker.code !== 'ACTION_UNBOUND')
199
+ .flatMap((blocker) => blocker.next?.commands ?? [])
200
+ .filter((command) => !command.startsWith('frontera blueprint publish'))
201
+ const publish = plan.instructionsNeeded.length
202
+ ? 'frontera blueprint publish --label cli-release --instruction "<how existing data and environments take these changes>"'
203
+ : 'frontera blueprint publish'
204
+ return [...new Set([...blockers, aimed(publish, profile)])]
205
+ }
206
+
171
207
  /** One instruction per change the publish must explain: the person's text, and a new Action's own. */
172
208
  export function migrationInstructions(plan: PublishPlan, instruction: string | undefined): Array<{ changeId: string; instruction: string }> {
173
209
  return [
@@ -59,6 +59,10 @@ export function renderPlan(plan: Plan, options: { prune: boolean } = { prune: fa
59
59
  : { marker: MARKER.update, kind: 'object-type', apiName: change.objectApiName, detail: move })
60
60
  }
61
61
 
62
+ for (const refusal of plan.propertyRefusals ?? []) {
63
+ rows.push({ marker: '✕', kind: 'object-type', apiName: refusal.objectApiName, detail: `cannot apply: ${refusal.message} Next: ${refusal.next}` })
64
+ }
65
+
62
66
  const withheld = withheldNote(plan)
63
67
 
64
68
  if (rows.length === 0) {
@@ -10,6 +10,7 @@ import {
10
10
  type ArtifactKind,
11
11
  type AuthoredFile,
12
12
  } from './model'
13
+ import type { ObjectSetDocument } from './property-intents'
13
14
 
14
15
  /**
15
16
  * Reading and writing the `blueprint/` tree. No network, no resolution.
@@ -146,6 +147,27 @@ export function removeFromTree(root: string, paths: string[]): string[] {
146
147
  return removed
147
148
  }
148
149
 
150
+ /**
151
+ * The saved object sets' files, `object-sets/` beside the model's folders. Not part
152
+ * of the model `apply` sends (`local dev` saves them itself), but they name its
153
+ * properties, so a property rename reads and rewrites them.
154
+ */
155
+ export function readObjectSets(root: string): ObjectSetDocument[] {
156
+ const directory = join(root, 'object-sets')
157
+ if (!existsSync(directory)) return []
158
+ return readdirSync(directory).sort().flatMap((entry) => {
159
+ const extension = extname(entry)
160
+ if (!READABLE.has(extension) || entry.startsWith('.') || entry.startsWith('#')) return []
161
+ const path = `object-sets/${entry}`
162
+ // One that does not parse is skipped, as `local dev` skips it when it saves the sets.
163
+ try {
164
+ return [{ path, ref: entry.slice(0, -extension.length), document: parseDocument(path, readFileSync(join(directory, entry), 'utf8')) }]
165
+ } catch {
166
+ return []
167
+ }
168
+ })
169
+ }
170
+
149
171
  export function writeTree(root: string, files: AuthoredFile[]): string[] {
150
172
  const written: string[] = []
151
173
  for (const file of files) {
@@ -4,7 +4,7 @@ import { GovernedActionApi } from '../../api/governed-action-api'
4
4
  import { credentialStore } from '../../credential-store'
5
5
  import { readDeviceSession } from '../../device-login'
6
6
  import { CliError, UsageError } from '../../errors'
7
- import { deriveOntologyEditPlan, PlanDerivationError } from '../../blueprint/ontology-edit-plan'
7
+ import { deriveOntologyEditPlan, PlanDerivationError, type ObjectTypeShape } from '../../blueprint/ontology-edit-plan'
8
8
  import { validationFindings } from '../../local/local-actions'
9
9
  import { selectedOrganization, setSelectedOrganization } from '../../organization'
10
10
  import { canPrompt, promptConfirm } from '../../prompt'
@@ -73,15 +73,7 @@ export const actionDeploy: Command = {
73
73
  const published = await client.publishedAction(apiName)
74
74
  const definition = published.definition
75
75
 
76
- const objectType = await client.activeObjectType(
77
- await resolveObjectTypeApiName(client, definition.subject.objectTypeId, apiName),
78
- )
79
- if (!objectType) {
80
- throw new CliError(`Object type for "${apiName}" is not in the active release.`, {
81
- code: 'NOT_FOUND',
82
- hint: 'Publish a release that carries it, then deploy.',
83
- })
84
- }
76
+ const objectType = await activeObjectTypeOf(client, definition.subject.objectTypeId, `The object type "${apiName}" acts on`)
85
77
 
86
78
  // An attachment Action (L6): the file arrives in --file-input, and the write fills --attach and/or --attach-item-id.
87
79
  const attachProperty = flagString(ctx, 'attach')
@@ -98,9 +90,15 @@ export const actionDeploy: Command = {
98
90
  ? { uploadInput: fileInput, ...(attachProperty ? { property: attachProperty } : {}), ...(attachItemId ? { itemIdProperty: attachItemId } : {}), ...(mediaSet ? { mediaSetId: mediaSet } : {}) }
99
91
  : undefined
100
92
 
93
+ // Each record the Action writes beside its subject, from the same active release.
94
+ const relatedTypes = []
95
+ for (const record of definition.records ?? []) {
96
+ relatedTypes.push(await activeObjectTypeOf(client, record.objectTypeId, `The object type of record "${record.key}" of "${apiName}"`))
97
+ }
98
+
101
99
  let plan
102
100
  try {
103
- plan = deriveOntologyEditPlan(definition, objectType, attachment)
101
+ plan = deriveOntologyEditPlan(definition, objectType, attachment, relatedTypes)
104
102
  } catch (err) {
105
103
  if (err instanceof PlanDerivationError) {
106
104
  throw new CliError(err.message, { code: 'VALIDATION_ERROR', hint: err.hint })
@@ -113,12 +111,21 @@ export const actionDeploy: Command = {
113
111
  ...(plan.mediaAttachment ? [`${[plan.mediaAttachment.propertyApiName, plan.mediaAttachment.itemIdProperty].filter(Boolean).join(' and ')} ← the file in ${plan.mediaAttachment.uploadParameter} (Media Set ${plan.mediaAttachment.mediaSetId})`] : []),
114
112
  ].join(', ')
115
113
 
114
+ // Each record the write path writes beside its subject, one line each.
115
+ const creates = (plan.related ?? []).map((step) => {
116
+ const sets = Object.entries(step.set).map(([property, parameter]) => `${property} ← ${parameter}`).join(', ')
117
+ if (step.op === 'create') return ` and creates ${step.record} (${step.objectTypeApiName}), filed under ${step.pkParameter}: ${sets}\n`
118
+ const checked = `${step.objectTypeApiName}), addressed by ${step.pkParameter}, compare-and-set on ${step.expectedVersionParameter}`
119
+ return step.op === 'delete' ? ` and deletes ${step.record} (${checked}\n` : ` and patches ${step.record} (${checked}: ${sets}\n`
120
+ }).join('')
121
+
116
122
  if (flagBool(ctx, 'dry-run')) {
117
123
  return {
118
124
  data: { plan, dryRun: true },
119
125
  text: `${plan.op} ${plan.objectTypeApiName}, addressed by ${plan.pkParameter}\n`
120
126
  + ` sets ${writes}\n`
121
127
  + (plan.expectedVersionParameter ? ` compare-and-set on ${plan.expectedVersionParameter}\n` : '')
128
+ + creates
122
129
  + '\nNothing was written. Re-run without --dry-run to build it.',
123
130
  }
124
131
  }
@@ -181,7 +188,7 @@ export const actionDeploy: Command = {
181
188
  stateId: binding.stateId,
182
189
  validationReportId: validation.id,
183
190
  }
184
- const summary = ` ${plan.op} ${plan.objectTypeApiName} — sets ${writes}`
191
+ const summary = ` ${plan.op} ${plan.objectTypeApiName} — sets ${writes}${creates ? `\n${creates.trimEnd()}` : ''}`
185
192
  const review = `frontera action review ${binding.id} --report ${validation.id}${at}`
186
193
  const again = `frontera action deploy ${apiName} --yes${at}`
187
194
  const secondPerson = `a second person who may review Actions turns it on with: ${review}`
@@ -262,22 +269,22 @@ export const actionDeploy: Command = {
262
269
  }
263
270
 
264
271
  /**
265
- * The Action names its subject by id; every read route addresses object types
266
- * by API name. One list read bridges them, rather than making the caller pass
267
- * a name the Action already knows.
272
+ * The Action names object types by id; every read route addresses them by API
273
+ * name. One list read bridges them, rather than making the caller pass a name
274
+ * the Action already knows. `named` says which type a refusal is about.
268
275
  */
269
- async function resolveObjectTypeApiName(
276
+ async function activeObjectTypeOf(
270
277
  client: GovernedActionApi,
271
278
  objectTypeId: string,
272
- actionApiName: string,
273
- ): Promise<string> {
274
- const types = await client.listActiveObjectTypes()
275
- const match = types.find((t) => t.id === objectTypeId)
276
- if (!match?.apiName) {
277
- throw new CliError(`The object type "${actionApiName}" acts on is not in the active release.`, {
279
+ named: string,
280
+ ): Promise<ObjectTypeShape> {
281
+ const apiName = (await client.listActiveObjectTypes()).find((t) => t.id === objectTypeId)?.apiName
282
+ const objectType = apiName ? await client.activeObjectType(apiName) : null
283
+ if (!objectType) {
284
+ throw new CliError(`${named} is not in the active release.`, {
278
285
  code: 'NOT_FOUND',
279
- hint: 'frontera blueprint catalog',
286
+ hint: 'Publish a release that carries it, then deploy.',
280
287
  })
281
288
  }
282
- return match.apiName
289
+ return objectType
283
290
  }
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
 
3
+ import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
3
4
  import { GovernedActionApi, type ExternalWritePath } from '../../api/governed-action-api'
5
+ import { publishPlan, stepsToPublish } from '../../blueprint/publish-plan'
4
6
  import { CliError, UsageError } from '../../errors'
5
7
  import { canPrompt, promptConfirm } from '../../prompt'
6
8
  import { flagBool, flagString, type Command } from '../types'
@@ -99,6 +101,12 @@ export const actionRetire: Command = {
99
101
  ? `The last write path "${apiName}" ran calls another system (${external.planKind ?? 'a kind the platform cannot read'}), which a second person reviewed, so a second person reviews the one that replaces it. `
100
102
  + `\`frontera action deploy ${apiName}${at}\` builds it and prints the command they run.\n\n`
101
103
  : ''
104
+ // What publishing needs now, read as `blueprint publish` reads it: another Action still to retire
105
+ // comes before the publish, and the publish carries its instruction when the release needs one.
106
+ const profile = typeof ctx.flags.profile === 'string' ? ctx.flags.profile : undefined
107
+ const toPublish = await new BlueprintAuthoringApi(ctx.apiUrl, ctx.token).validate()
108
+ .then((report) => stepsToPublish(publishPlan(report, null, profile), profile))
109
+ .catch(() => [`frontera blueprint publish${at}`])
102
110
  const what = running
103
111
  ? `Switched off the running deployment of "${apiName}" and retired it. "${apiName}" does not run until it is deployed again.`
104
112
  : current
@@ -106,7 +114,7 @@ export const actionRetire: Command = {
106
114
  : `Retired the deployment of "${apiName}" that was still draining.`
107
115
  return {
108
116
  data: { action: apiName, retired: true, from: current?.state ?? 'draining', switchedOff: running, deployment: retired, externalWritePath: external, ...(unread !== null ? { externalWritePathError: unread } : {}) },
109
- text: `${what}\n\n${secondPerson}Next:\n frontera blueprint publish${at}${running ? `\n frontera action deploy ${apiName}${at}` : ''}`,
117
+ text: `${what}\n\n${secondPerson}Next:\n${[...toPublish, ...(running ? [`frontera action deploy ${apiName}${at}`] : [])].map((command) => ` ${command}`).join('\n')}`,
110
118
  }
111
119
  },
112
120
  }
@@ -4,6 +4,7 @@ import { toEnvelope } from '../../exit'
4
4
  import { parseInputFlag } from '../automation/run'
5
5
  import { flagString, type Command } from '../types'
6
6
  import { criteriaRefusal } from './criteria-refusal'
7
+ import { whyNot } from './list'
7
8
  import { requestSummary } from './request-summary'
8
9
 
9
10
  /**
@@ -23,7 +24,8 @@ import { requestSummary } from './request-summary'
23
24
  /**
24
25
  * `priority=2` is the number 2 and `title=Broken` the string — unless the
25
26
  * Action declares the input a string, when the text is taken as written, so
26
- * `ticketId=123` stays "123".
27
+ * `ticketId=123` stays "123". `assignee=null` is null for every input, which
28
+ * empties the property it sets; the text "null" goes through `--input`.
27
29
  */
28
30
  function pair(raw: string, stringInputs: ReadonlySet<string>): [string, unknown] {
29
31
  const eq = raw.indexOf('=')
@@ -32,6 +34,7 @@ function pair(raw: string, stringInputs: ReadonlySet<string>): [string, unknown]
32
34
  }
33
35
  const key = raw.slice(0, eq)
34
36
  const text = raw.slice(eq + 1)
37
+ if (text === 'null') return [key, null]
35
38
  if (stringInputs.has(key)) return [key, text]
36
39
  try {
37
40
  return [key, JSON.parse(text)]
@@ -48,7 +51,7 @@ export const actionSubmit: Command = {
48
51
  scope: 'workspace' as const,
49
52
  args: [
50
53
  { name: 'apiName', required: true, description: 'The Action to submit, as `action available` lists it' },
51
- { name: 'key=value...', required: false, description: 'Parameters; text for string inputs, otherwise read as JSON when it parses' },
54
+ { name: 'key=value...', required: false, description: 'Parameters; text for string inputs, otherwise read as JSON when it parses. name=null is null for any input, which clears what it sets' },
52
55
  ],
53
56
  flags: {
54
57
  input: 'string',
@@ -60,12 +63,14 @@ export const actionSubmit: Command = {
60
63
  },
61
64
  summary: 'Submit a request to a deployed Action',
62
65
  details: [
66
+ '`name=null` empties the property an optional input sets (no default); leaving the input out keeps it as it is. For the text "null", use `--input \'{"name":"null"}\'`.',
63
67
  'On the local runtime, `--as <persona>` submits as a local persona (`--as submitter`), so a later step can be decided by another one. Refused anywhere else.',
64
68
  ],
65
69
  examples: [
66
70
  'frontera action submit createTicket title=Broken priority=2',
67
71
  'frontera action submit escalateTicket --subject TKT-1 --subject-version 0',
68
72
  'frontera action submit escalateTicket id=TKT-1 expectedVersion=0',
73
+ 'frontera action submit updateTicket id=TKT-1 expectedVersion=3 owner=null',
69
74
  'frontera action submit createTicket --input @params.json',
70
75
  'frontera action submit createTicket --idempotency-key retry-2026-09-26-01',
71
76
  ],
@@ -87,7 +92,14 @@ export const actionSubmit: Command = {
87
92
  // refused here, by name, instead of by the service without one.
88
93
  const action = (await client.discover()).find((a) => a.apiName === apiName)
89
94
  if (!action) {
90
- throw new CliError(`You cannot submit "${apiName}" in this workspace.`, {
95
+ // Published but not running (switched off after a change to it or its object type, never
96
+ // deployed): said as `action list` says it, with the command that turns it on.
97
+ const listed = await client.listDeployableActions().catch(() => null)
98
+ const entry = Array.isArray(listed) ? listed.find((e) => e.definition?.apiName === apiName) : undefined
99
+ const why = entry
100
+ ? whyNot(apiName, { published: true, deploymentStatus: entry.deploymentStatus, reason: entry.reason ?? null, available: entry.availability === 'available' && entry.executable !== false })
101
+ : ''
102
+ throw new CliError(`You cannot submit "${apiName}" in this workspace${why ? `. ${apiName}: ${why}` : '.'}`, {
91
103
  code: 'NOT_FOUND',
92
104
  hint: 'frontera action available — to list the Actions you can submit',
93
105
  })
@@ -189,10 +201,14 @@ function transient(err: unknown): boolean {
189
201
  return typeof status === 'number' && (status >= 500 || status === 429)
190
202
  }
191
203
 
192
- /** Inputs the Action declares as strings, whose text must not be read as JSON. */
204
+ /**
205
+ * Inputs the Action declares as strings, whose text must not be read as JSON:
206
+ * `type: 'string'`, or `['string', 'null']` for an optional input that also
207
+ * takes null (an older service says only `'string'`).
208
+ */
193
209
  function stringInputs(action: ActionDiscovery): ReadonlySet<string> {
194
210
  const properties = action.inputSchema?.properties?.input?.properties ?? {}
195
211
  return new Set(Object.entries(properties)
196
- .filter(([, schema]) => (schema as { type?: unknown } | null)?.type === 'string')
212
+ .filter(([, schema]) => ([] as unknown[]).concat((schema as { type?: unknown } | null)?.type).includes('string'))
197
213
  .map(([name]) => name))
198
214
  }
@@ -39,7 +39,7 @@ export const authLogin: Command = {
39
39
  flagHelp: { 'no-open': 'print the approval address without opening a browser' },
40
40
  summary: 'Sign in as yourself in the browser, for the commands that act as a person',
41
41
  details: [
42
- 'Prints a code and opens the platform’s approval page; approve the code there while signed in. The CLI then keeps a session for the selected profile, beside its key, and uses it only for commands that act as a person (they refuse API keys). The session lasts 12 hours; `frontera auth logout` ends it sooner.',
42
+ 'Prints a code and opens the platform’s approval page; approve the code there while signed in. The CLI then keeps a session for the selected profile, beside its key, and uses it for commands that act as a person (they refuse API keys), for organization commands when the profile holds a workspace key, and for a project’s change requests when the profile is the one `frontera local sync connect` names. Every other command keeps using the profile’s key. The session lasts 12 hours; `frontera auth logout` ends it sooner.',
43
43
  'Approve only a code you started yourself, in your own terminal, just now.',
44
44
  ],
45
45
  examples: ['frontera auth login --browser', 'frontera auth login --browser --profile acme --no-open'],
@@ -61,7 +61,7 @@ export const authLogin: Command = {
61
61
  await saveDeviceSession(store, profile, { ...session, ...(email ? { email } : {}) })
62
62
  return {
63
63
  data: { profile, signedIn: true, email, expiresAt: session.expiresAt, reused: false },
64
- text: `Signed in for ${profile}${email ? ` as ${email}` : ''} until ${session.expiresAt}. Commands that act as a person use this session; every other command keeps using the profile's key.`,
64
+ text: `Signed in for ${profile}${email ? ` as ${email}` : ''} until ${session.expiresAt}. Commands that act as a person use this session, as do organization commands on a profile that holds a workspace key and a project's change requests on its \`local sync\` profile; every other command keeps using the profile's key.`,
65
65
  }
66
66
  },
67
67
  }