@frontera-sdk/cli 1.43.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.43.5",
3
+ "version": "1.43.7",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -37,8 +37,8 @@
37
37
  "build:release": "bun run scripts/build-release.ts"
38
38
  },
39
39
  "dependencies": {
40
- "@frontera-sdk/automation": "0.1.0",
41
- "@frontera-sdk/core": "0.1.0",
40
+ "@frontera-sdk/automation": "1.43.6",
41
+ "@frontera-sdk/core": "1.43.6",
42
42
  "gray-matter": "^4.0.3",
43
43
  "yaml": "^2.9.0"
44
44
  },
@@ -134,10 +134,75 @@ export class AutomationApi {
134
134
  * kill switch, not a lock, so an operator can test a fix without re-arming the
135
135
  * cron.
136
136
  */
137
- run(slug: string): Promise<{ slug: string; version: number; queued: boolean }> {
138
- return this.client.request<{ slug: string; version: number; queued: boolean }>(
139
- `/v1/automations/${encodeURIComponent(slug)}/run`,
140
- { method: 'POST' },
137
+ /** `version` omitted runs the live one the behaviour every caller had before
138
+ * the parameter existed, and the one the service still defaults to. */
139
+ run(
140
+ slug: string,
141
+ version?: number,
142
+ dev?: boolean,
143
+ ): Promise<{ slug: string; version: number | null; queued: boolean; dev?: boolean }> {
144
+ const body = dev ? { dev: true } : version === undefined ? undefined : { version }
145
+ return this.client.request(`/v1/automations/${encodeURIComponent(slug)}/run`, {
146
+ method: 'POST',
147
+ body,
148
+ })
149
+ }
150
+
151
+ /** Open or take over this automation's dev session. Creates the automation
152
+ * row when absent — an author develops before the first deploy. */
153
+ devSessionOpen(
154
+ slug: string,
155
+ entryFile: string,
156
+ holderId: string,
157
+ ): Promise<{
158
+ sessionId: string
159
+ slug: string
160
+ liveVersionId: string | null
161
+ created: boolean
162
+ leaseExpiresAt: string
163
+ leaseMs: number
164
+ }> {
165
+ return this.client.request(
166
+ `/v1/automations/${encodeURIComponent(slug)}/dev/session`,
167
+ { method: 'POST', body: { entryFile, holderId } },
168
+ )
169
+ }
170
+
171
+ /** Heartbeat and claim in one call: a lost lease invalidates a claim, so
172
+ * asking separately would let a taken-over worker execute one more run. */
173
+ /**
174
+ * Heartbeat, claim, and hand over the manifest — one call.
175
+ *
176
+ * The manifest rides here because this is the only message that comes from
177
+ * the machine holding the file. It is what the claimed run's grants are set
178
+ * from, so a grant added while iterating takes effect on the very next run
179
+ * with no redeploy and no session restart.
180
+ */
181
+ devPoll(
182
+ slug: string,
183
+ sessionId: string,
184
+ holderId: string,
185
+ manifest: Record<string, unknown>,
186
+ ): Promise<{
187
+ held: boolean
188
+ run: { runId: string; runToken: string; grants: string[]; workspaceId: string } | null
189
+ }> {
190
+ const q = `sessionId=${encodeURIComponent(sessionId)}&holderId=${encodeURIComponent(holderId)}`
191
+ return this.client.request(
192
+ `/v1/automations/${encodeURIComponent(slug)}/dev/poll?${q}`,
193
+ { method: 'POST', body: { manifest } },
194
+ )
195
+ }
196
+
197
+ devSessionClose(
198
+ slug: string,
199
+ sessionId: string,
200
+ holderId: string,
201
+ ): Promise<{ closed: boolean }> {
202
+ const q = `sessionId=${encodeURIComponent(sessionId)}&holderId=${encodeURIComponent(holderId)}`
203
+ return this.client.request(
204
+ `/v1/automations/${encodeURIComponent(slug)}/dev/session?${q}`,
205
+ { method: 'DELETE' },
141
206
  )
142
207
  }
143
208
 
@@ -145,8 +210,23 @@ export class AutomationApi {
145
210
  return this.client.request<RegistryStatus>('/v1/automations/registry-status')
146
211
  }
147
212
 
148
- runs(slug: string, limit?: number): Promise<AutomationRunSummary[]> {
149
- const query = limit === undefined ? '' : `?limit=${limit}`
213
+ /**
214
+ * Run history. `mode` is NOT optional in effect, only in the signature.
215
+ *
216
+ * The route defaults to `live` when it is absent, so a caller that omits it
217
+ * on a dev path reads the wrong history in silence — `automation run --dev`
218
+ * waited out its whole timeout for a run it had already finished, and would
219
+ * report a cron fire that happened during the wait as if it were the dev run.
220
+ */
221
+ runs(
222
+ slug: string,
223
+ limit?: number,
224
+ mode: 'live' | 'dev' = 'live',
225
+ ): Promise<AutomationRunSummary[]> {
226
+ const params = new URLSearchParams()
227
+ if (limit !== undefined) params.set('limit', String(limit))
228
+ if (mode === 'dev') params.set('mode', 'dev')
229
+ const query = params.size > 0 ? `?${params}` : ''
150
230
  return this.client.request<AutomationRunSummary[]>(
151
231
  `/v1/automations/${encodeURIComponent(slug)}/runs${query}`,
152
232
  )
@@ -266,6 +266,44 @@ export class BlueprintAuthoringApi {
266
266
  })
267
267
  }
268
268
 
269
+ // ── semantic types ─────────────────────────────────────────────────────
270
+
271
+ listSemanticTypes(): Promise<
272
+ Array<{ id?: string; apiName?: string; displayName?: string; dataType?: string }>
273
+ > {
274
+ return this.call('/v1/blueprint/semantic-types?view=draft')
275
+ }
276
+
277
+ /**
278
+ * Create, and read the minted id back — the same contract the shared-field create
279
+ * has, and needed by BOTH carrier kinds: a tree that introduces a semantic type
280
+ * alongside the shared field or the property carrying it has no id at plan time.
281
+ */
282
+ createSemanticType(
283
+ body: Record<string, unknown>,
284
+ expectedRevision: number,
285
+ ): Promise<{ semanticType?: { id?: string; apiName?: string }; draftRevision?: number }> {
286
+ return this.call('/v1/blueprint/semantic-types', {
287
+ method: 'POST',
288
+ body: { ...body, expectedRevision },
289
+ })
290
+ }
291
+
292
+ updateSemanticType(apiName: string, body: Record<string, unknown>, expectedRevision: number) {
293
+ return this.call(`/v1/blueprint/semantic-types/${encodeURIComponent(apiName)}`, {
294
+ method: 'PUT',
295
+ body: { ...body, expectedRevision },
296
+ })
297
+ }
298
+
299
+ /** Succeeds with carriers: each reverts to an unconstrained field. */
300
+ deleteSemanticType(apiName: string, expectedRevision: number) {
301
+ return this.call(`/v1/blueprint/semantic-types/${encodeURIComponent(apiName)}`, {
302
+ method: 'DELETE',
303
+ body: { expectedRevision },
304
+ })
305
+ }
306
+
269
307
  /**
270
308
  * Removing an object type is a STRUCTURAL change, so it goes through preview and
271
309
  * apply rather than a plain DELETE: the service refuses an apply whose digest does
@@ -112,6 +112,16 @@ export function automationScaffoldFiles(name: string): Record<string, string> {
112
112
 
113
113
  out['src/index.ts'] = `import { automation } from '@frontera-sdk/automation'
114
114
 
115
+ /**
116
+ * The object type this reads. CHANGE THIS to one of yours —
117
+ * \`frontera blueprint list\` shows what this workspace can read.
118
+ *
119
+ * Exported, and the starter test imports it, so changing it here changes both.
120
+ * The test used to hard-code the same literal, and every author's first edit
121
+ * broke it with a bare array diff naming neither the cause nor the fix.
122
+ */
123
+ export const OBJECT_TYPE = 'YourObjectType'
124
+
115
125
  export default automation(
116
126
  {
117
127
  name: '${name}',
@@ -127,7 +137,7 @@ export default automation(
127
137
  // fails and the run is retried, this one returns what it returned the first
128
138
  // time instead of querying again. Steps are also what the Console draws.
129
139
  const found = await ctx.step.run('load', async () => {
130
- const result = await ctx.blueprint.query('YourObjectType', { limit: 10 })
140
+ const result = await ctx.blueprint.query(OBJECT_TYPE, { limit: 10 })
131
141
  await ctx.log(\`loaded \${result.rows.length} row(s)\`)
132
142
  return { count: result.rows.length }
133
143
  })
@@ -163,7 +173,7 @@ export default automation(
163
173
  out['src/index.test.ts'] = `import { expect, test } from 'bun:test'
164
174
  import { createTestContext } from '@frontera-sdk/automation'
165
175
 
166
- import automation from './index'
176
+ import automation, { OBJECT_TYPE } from './index'
167
177
 
168
178
  // A handler is just a function, so it can be tested without deploying. The
169
179
  // context refuses what the platform refuses — a grant the manifest does not
@@ -183,7 +193,7 @@ test('handles rows', async () => {
183
193
  grants: ['blueprint:read'],
184
194
  // Stub only what this test is about; an object type you do not stub returns
185
195
  // no rows, which is a real answer.
186
- blueprint: { YourObjectType: { rows: [{ id: 'a' }, { id: 'b' }], hasMore: false } },
196
+ blueprint: { [OBJECT_TYPE]: { rows: [{ id: 'a' }, { id: 'b' }], hasMore: false } },
187
197
  })
188
198
 
189
199
  await automation.handler(ctx)
@@ -119,6 +119,50 @@ export function resolveSharedProperty(
119
119
  return id
120
120
  }
121
121
 
122
+ /**
123
+ * `semanticType: percentage` → the uuid the wire takes. THREE-VALUED, exactly like
124
+ * `resolveSharedProperty` above and for the same reasons — omission preserves, `null`
125
+ * detaches, a name attaches or replaces — and resolved at the moment of the write so
126
+ * a semantic type created earlier in the same `apply` is already in the map.
127
+ *
128
+ * Its own function rather than a parameterisation of the shared-field resolver: the
129
+ * two namespaces are independent (a shared field and a semantic type may share an
130
+ * apiName), the messages name different files, and collapsing them would make the
131
+ * one map serve two references — which is how a typo in one resolves to the other.
132
+ *
133
+ * `carrier` is what the reference is written ON, and it is a free string because both
134
+ * carrier kinds resolve through here: a field on an object type, and a shared field
135
+ * standing alone.
136
+ */
137
+ export function resolveSemanticType(
138
+ file: AuthoredFile,
139
+ carrier: string,
140
+ declared: unknown,
141
+ semanticTypeIdByApiName: Map<string, string>,
142
+ ): string | null | undefined {
143
+ if (declared === undefined) return undefined
144
+ if (declared === null) return null
145
+ const name = String(declared)
146
+ const id = semanticTypeIdByApiName.get(name)
147
+ if (!id) {
148
+ throw new CliError(
149
+ `${file.path}: ${carrier} names semantic type "${name}", which is not on the draft.`,
150
+ {
151
+ code: 'USAGE',
152
+ hint: `Add semantic-types/${name}.yaml, or correct the name. `
153
+ + 'A semantic type is referenced by apiName — the tree carries no identifiers.',
154
+ },
155
+ )
156
+ }
157
+ return id
158
+ }
159
+
160
+ /** Both id maps a write may need to resolve, threaded together so neither is forgotten. */
161
+ export interface AuthoringIds {
162
+ sharedPropertyIdByApiName: Map<string, string>
163
+ semanticTypeIdByApiName: Map<string, string>
164
+ }
165
+
122
166
  function columnOf(entry: string | { column: string; field?: string | null }): {
123
167
  column: string
124
168
  field?: string | null
@@ -137,7 +181,7 @@ export async function objectTypeCreateBody(
137
181
  file: AuthoredFile,
138
182
  expectedRevision: number,
139
183
  resolveDataset: DatasetResolver,
140
- sharedPropertyIdByApiName: Map<string, string>,
184
+ ids: AuthoringIds,
141
185
  ): Promise<Record<string, unknown>> {
142
186
  const document = file.document
143
187
  const backing = readBacking(file)
@@ -199,7 +243,10 @@ export async function objectTypeCreateBody(
199
243
  // an empty organization creates the type and all its fields in one call, so a
200
244
  // reference the create body cannot carry is unreachable on exactly that run.
201
245
  const sharedPropertyId = resolveSharedProperty(
202
- file, apiName, property.sharedField, sharedPropertyIdByApiName,
246
+ file, apiName, property.sharedField, ids.sharedPropertyIdByApiName,
247
+ )
248
+ const semanticTypeId = resolveSemanticType(
249
+ file, `field "${apiName}"`, property.semanticType, ids.semanticTypeIdByApiName,
203
250
  )
204
251
  return {
205
252
  apiName,
@@ -215,6 +262,9 @@ export async function objectTypeCreateBody(
215
262
  // "no shared field" — and the create body's uuid is not nullable, so
216
263
  // sending it would be a 422 rather than a detach.
217
264
  ...(typeof sharedPropertyId === 'string' ? { sharedPropertyId } : {}),
265
+ // Same three-state collapse as the shared field beside it: a CREATE has
266
+ // nothing to detach from, and the create body's uuid is not nullable.
267
+ ...(typeof semanticTypeId === 'string' ? { semanticTypeId } : {}),
218
268
  }
219
269
  }),
220
270
  columnMappings: Object.entries(backing.mapping).map(([propertyApiName, entry]) => {
@@ -308,6 +358,56 @@ export function metricCreateBody(
308
358
  return { ...body, objectTypeApiName: objectType, expectedRevision }
309
359
  }
310
360
 
361
+ /**
362
+ * `POST /v1/blueprint/actions` and its PUT.
363
+ *
364
+ * Carried through as authored, name references and all. The service resolves them
365
+ * against the draft and mints the ids the contract owns — see
366
+ * `action-reference-resolution` and `action-identity` — so translating anything here
367
+ * would put a second resolver on the wire's other end, which is the arrangement
368
+ * `linkTypeStructure`'s neighbours exist to avoid.
369
+ */
370
+ /**
371
+ * An action file with every id the SERVICE mints removed — the action's own and each
372
+ * child's.
373
+ *
374
+ * Exported because two callers must agree exactly: `actionBody` sends this, and the
375
+ * differ compares against it. Stripping only the top-level id made a pasted Console
376
+ * export plan an `~ action` update on EVERY run — the projection strips child ids too,
377
+ * so the raw file never matched the live side, and the apply then sent a body identical
378
+ * to what was already stored. `replaceBlueprintDraft` has no no-op guard, so each run
379
+ * bumped the revision and changed nothing.
380
+ *
381
+ * Forwarding a pasted id was wrong on its own terms as well: `assignActionIdentity`
382
+ * honours a pinned id, so an export from another organization created an action here
383
+ * carrying THAT deployment's uuid — accepted, because the duplicate check is per-org,
384
+ * and invisible afterwards, because the next `pull` strips it again while contract
385
+ * digests and binding rows keep keying off it.
386
+ */
387
+ export function actionWithoutMintedIds(document: Record<string, unknown>): Record<string, unknown> {
388
+ const drop = (value: unknown): unknown => {
389
+ if (Array.isArray(value)) return value.map(drop)
390
+ if (!value || typeof value !== 'object') return value
391
+ const out: Record<string, unknown> = {}
392
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
393
+ // `id` only. A REFERENCE ending in `Id` is the service's own resolution of a name
394
+ // and never appears in a projected file, so it is left alone rather than guessed
395
+ // at — the projection is the authority on what a file may carry.
396
+ if (key === 'id') continue
397
+ out[key] = drop(entry)
398
+ }
399
+ return out
400
+ }
401
+ return drop(document) as Record<string, unknown>
402
+ }
403
+
404
+ export function actionBody(file: AuthoredFile): Record<string, unknown> {
405
+ // `apiName` from the PATH, not the document: the tree's filename is the identity,
406
+ // and a file whose body disagreed with its name would create one artifact and be
407
+ // pruned as another on the next run.
408
+ return { ...actionWithoutMintedIds(file.document), apiName: file.apiName }
409
+ }
410
+
311
411
  /** The patch route takes metadata and the definition, never the subject. */
312
412
  export function metricPatchBody(
313
413
  file: AuthoredFile,
@@ -327,8 +427,12 @@ export function metricPatchBody(
327
427
  export function sharedPropertyCreateBody(
328
428
  file: AuthoredFile,
329
429
  expectedRevision: number,
430
+ semanticTypeIdByApiName: Map<string, string>,
330
431
  ): Record<string, unknown> {
331
432
  const document = file.document
433
+ const semanticTypeId = resolveSemanticType(
434
+ file, `shared field "${file.apiName}"`, document.semanticType, semanticTypeIdByApiName,
435
+ )
332
436
  return {
333
437
  apiName: requireString(document, 'apiName', file.path),
334
438
  displayName: requireString(document, 'displayName', file.path),
@@ -339,6 +443,11 @@ export function sharedPropertyCreateBody(
339
443
  // the file instead of arriving as a 422 from the service.
340
444
  propertyType: requireString(document, 'propertyType', file.path),
341
445
  ...optional(document.formatConfig as Record<string, unknown> | undefined, 'formatConfig'),
446
+ // A shared field created with nothing carrying it yet still states what its
447
+ // implementers will mean, so the reference travels the CREATE rather than a
448
+ // follow-up patch: on a first apply against an empty organization there is no
449
+ // second call to make.
450
+ ...(typeof semanticTypeId === 'string' ? { semanticTypeId } : {}),
342
451
  ...optional(document.status as string | undefined, 'status'),
343
452
  ...optional(document.visibility as string | undefined, 'visibility'),
344
453
  expectedRevision,
@@ -356,6 +465,63 @@ export function sharedPropertyCreateBody(
356
465
  export function sharedPropertyPatchBody(
357
466
  file: AuthoredFile,
358
467
  expectedRevision: number,
468
+ semanticTypeIdByApiName: Map<string, string>,
469
+ ): Record<string, unknown> {
470
+ const {
471
+ apiName: _apiName,
472
+ // TRANSLATED, never passed through. The file says `semanticType: <name>` and the
473
+ // route takes `semanticTypeId: <uuid> | null`; sending the authored key would
474
+ // leave the route's own key absent, so the patch would report success having
475
+ // changed nothing — the silent no-op this projection exists to prevent.
476
+ semanticType,
477
+ ...body
478
+ } = file.document as Record<string, unknown>
479
+ const semanticTypeId = resolveSemanticType(
480
+ file, `shared field "${file.apiName}"`, semanticType, semanticTypeIdByApiName,
481
+ )
482
+ return {
483
+ ...body,
484
+ ...(semanticTypeId === undefined ? {} : { semanticTypeId }),
485
+ expectedRevision,
486
+ }
487
+ }
488
+
489
+ /**
490
+ * A semantic type is authored whole: metadata plus one rule, and the create route
491
+ * takes the same fields the bundle carries.
492
+ *
493
+ * The rule is passed through UNVALIDATED. Its grammar is four discriminated schemas
494
+ * the service owns, and restating them here would be a second copy that drifts —
495
+ * the route answers a 400 carrying the path into the rule, which is a better error
496
+ * than any this package could invent.
497
+ */
498
+ export function semanticTypeCreateBody(
499
+ file: AuthoredFile,
500
+ expectedRevision: number,
501
+ ): Record<string, unknown> {
502
+ const document = file.document
503
+ if (document.rule === undefined) {
504
+ throw new CliError(`${file.path} declares no \`rule\`.`, {
505
+ code: 'USAGE',
506
+ hint: 'A semantic type is a rule. `frontera blueprint new semantic-type <name>` writes one that validates.',
507
+ })
508
+ }
509
+ return {
510
+ apiName: requireString(document, 'apiName', file.path),
511
+ displayName: requireString(document, 'displayName', file.path),
512
+ ...optional(document.description as string | undefined, 'description'),
513
+ dataType: requireString(document, 'dataType', file.path),
514
+ rule: document.rule,
515
+ ...optional(document.status as string | undefined, 'status'),
516
+ ...optional(document.visibility as string | undefined, 'visibility'),
517
+ expectedRevision,
518
+ }
519
+ }
520
+
521
+ /** `apiName` is omitted for the same reason a shared field's is: it means RENAME. */
522
+ export function semanticTypePatchBody(
523
+ file: AuthoredFile,
524
+ expectedRevision: number,
359
525
  ): Record<string, unknown> {
360
526
  const { apiName: _apiName, ...body } = file.document as Record<string, unknown>
361
527
  return { ...body, expectedRevision }
@@ -1,3 +1,4 @@
1
+ import { actionWithoutMintedIds } from './compile'
1
2
  import { toFiles } from './projection'
2
3
  import {
3
4
  ARTIFACT_KINDS,
@@ -5,6 +6,7 @@ import {
5
6
  type AuthoredFile,
6
7
  type DefinitionBundle,
7
8
  } from './model'
9
+ import { CliError } from '../errors'
8
10
 
9
11
  /**
10
12
  * The plan: what the files say that the live draft does not, expressed as typed
@@ -48,6 +50,16 @@ export interface Plan {
48
50
  operations: ModelOperation[]
49
51
  /** Artifacts in the draft but absent from the tree. Applied only with `--prune`. */
50
52
  prunes: ModelOperation[]
53
+ /**
54
+ * Prunes NOT offered, because the tree has no directory for that kind at all.
55
+ *
56
+ * Reported rather than dropped. Git cannot transport an empty directory, so
57
+ * `rm -rf blueprint/metrics` is indistinguishable from a tree that never modelled
58
+ * metrics — and withholding silently answered `Applied 0 changes` while the metrics
59
+ * stayed live. Saying which prunes were withheld, and why, is the difference between
60
+ * a guard and a surprise.
61
+ */
62
+ withheld?: ModelOperation[]
51
63
  drift: BindingDrift[]
52
64
  unchanged: number
53
65
  }
@@ -148,6 +160,37 @@ function describeUpdate(local: Record<string, unknown>, live: Record<string, unk
148
160
  return parts.length ? parts.join(', ') : 'fields changed'
149
161
  }
150
162
 
163
+ /**
164
+ * Has this artifact changed? Subset for most kinds, EXACT for actions.
165
+ *
166
+ * The asymmetry follows the routes, not a preference. An object type, link type or
167
+ * metric is updated by a patch, so a field the file omits is a field the file has no
168
+ * opinion about — which is what makes `satisfies` a subset comparison and what lets a
169
+ * hand-authored file leave every service-derived value out.
170
+ *
171
+ * An action's PUT replaces the contract WHOLE. There, omitting a field deletes it. Under
172
+ * a subset comparison that deletion was invisible: `plan` reported nothing, and a
173
+ * deletion travelling alongside any other edit was applied without ever being named.
174
+ * Comparing both directions makes the removal a change, which is what it is.
175
+ *
176
+ * Safe because an action file is a total projection — `actionToFile` strips only the ids
177
+ * the service mints and turns references into names, both of which `apply` puts back. A
178
+ * pulled tree therefore still reports no changes, which the round-trip test asserts.
179
+ */
180
+ function matches(
181
+ kind: ArtifactKind,
182
+ local: Record<string, unknown>,
183
+ live: Record<string, unknown>,
184
+ ): boolean {
185
+ if (kind !== 'action') return satisfies(local, live)
186
+ // Compared against what `apply` will actually SEND, which is the file minus every id
187
+ // the service mints. The live side is the projection, which already strips them — so
188
+ // comparing the raw file made a pasted export differ forever, on ids neither side
189
+ // could ever agree about.
190
+ const authored = actionWithoutMintedIds(local)
191
+ return satisfies(authored, live) && satisfies(live, authored)
192
+ }
193
+
151
194
  export function diff(
152
195
  localFiles: AuthoredFile[],
153
196
  live: DefinitionBundle,
@@ -179,7 +222,7 @@ export function diff(
179
222
  })
180
223
  continue
181
224
  }
182
- if (satisfies(file.document, existing.document)) {
225
+ if (matches(kind, file.document, existing.document)) {
183
226
  unchanged += 1
184
227
  continue
185
228
  }
@@ -194,13 +237,100 @@ export function diff(
194
237
  }
195
238
  for (const file of liveModel.files.filter((candidate) => candidate.kind === kind)) {
196
239
  if (localByKey.has(`${kind}:${file.apiName}`)) continue
197
- prunes.push({ operation: 'delete', kind, apiName: file.apiName })
240
+ // The live document rides along in `liveDocument`, which is what that field
241
+ // means — `document` is the AUTHORED one, and a delete has no authored side. A
242
+ // delete needs no body at all, but ORDERING the prunes does: an action naming
243
+ // another as its compensation must be deleted first, and only the live side
244
+ // knows which.
245
+ prunes.push({ operation: 'delete', kind, apiName: file.apiName, liveDocument: file.document })
198
246
  }
199
247
  }
200
248
 
201
249
  return { revision, operations, prunes, drift: driftOf(live), unchanged }
202
250
  }
203
251
 
252
+ /**
253
+ * Every place the tree names a semantic type, as a path a reader can open.
254
+ *
255
+ * Both carrier kinds, because both author the reference: a shared field names one at
256
+ * its top level, and an object type names one per property. `null` is a detach and
257
+ * names nothing.
258
+ */
259
+ function semanticTypeReferences(files: AuthoredFile[]): Array<{ name: string; carrier: string }> {
260
+ const references: Array<{ name: string; carrier: string }> = []
261
+ for (const file of files) {
262
+ if (file.kind === 'shared-field') {
263
+ const declared = file.document.semanticType
264
+ if (typeof declared === 'string') references.push({ name: declared, carrier: file.path })
265
+ continue
266
+ }
267
+ if (file.kind !== 'object-type') continue
268
+ const properties = file.document.properties
269
+ if (!Array.isArray(properties)) continue
270
+ for (const property of properties) {
271
+ const entry = property as { apiName?: unknown; semanticType?: unknown }
272
+ if (typeof entry.semanticType !== 'string') continue
273
+ references.push({
274
+ name: entry.semanticType,
275
+ carrier: `${file.path} (field "${String(entry.apiName)}")`,
276
+ })
277
+ }
278
+ }
279
+ return references
280
+ }
281
+
282
+ /**
283
+ * Refuse a prune that would delete a semantic type the surviving tree still names.
284
+ *
285
+ * The planner compares each authored file against the live draft INDEPENDENTLY of
286
+ * what else the run prunes, so a tree that keeps `shared-fields/status.yaml` carrying
287
+ * `semanticType: code` and drops `semantic-types/code.yaml` plans as `unchanged: 1`
288
+ * plus one prune. Executed, the delete route degrades its carriers — it clears the
289
+ * very reference the surviving file still declares — so the run reports success and
290
+ * leaves the draft disagreeing with the tree. The next `apply` then fails on name
291
+ * resolution, because `code` is no longer in the live id map: the report is right and
292
+ * the recovery is manual.
293
+ *
294
+ * Refused rather than reordered, and refused naming EVERY surviving carrier: the
295
+ * author has to decide whether the type is going or the carriers are, and doing half
296
+ * of it for them is how a rule silently stops being enforced.
297
+ *
298
+ * Called where `--prune` is honoured rather than from `diff` itself. A tree is allowed
299
+ * to be partial — one file, applied on its own, is the normal way to work — and only
300
+ * `--prune` claims the tree is the whole truth.
301
+ */
302
+ export function assertPrunesLeaveNoDanglingReference(
303
+ localFiles: AuthoredFile[],
304
+ prunes: ModelOperation[],
305
+ ): void {
306
+ const pruned = new Set(
307
+ prunes.filter((operation) => operation.kind === 'semantic-type')
308
+ .map((operation) => operation.apiName),
309
+ )
310
+ if (pruned.size === 0) return
311
+ const orphaned = new Map<string, string[]>()
312
+ for (const reference of semanticTypeReferences(localFiles)) {
313
+ if (!pruned.has(reference.name)) continue
314
+ const carriers = orphaned.get(reference.name) ?? []
315
+ carriers.push(reference.carrier)
316
+ orphaned.set(reference.name, carriers)
317
+ }
318
+ if (orphaned.size === 0) return
319
+ const detail = [...orphaned.entries()]
320
+ .map(([name, carriers]) => ` ${name} — still carried by ${carriers.join(', ')}`)
321
+ .join('\n')
322
+ throw new CliError(
323
+ `--prune would delete ${orphaned.size === 1 ? 'a semantic type' : 'semantic types'} the tree `
324
+ + `still references:\n${detail}`,
325
+ {
326
+ code: 'USAGE',
327
+ hint: 'Delete the carriers\' `semanticType:` lines too, or keep the semantic-types/ file. '
328
+ + 'Deleting the type on its own clears every reference to it, which the surviving files '
329
+ + 'would then contradict.',
330
+ },
331
+ )
332
+ }
333
+
204
334
  /**
205
335
  * Bound object types whose pinned schema digest can be checked against the dataset's
206
336
  * current revision. The comparison itself needs the dataset read, so this only