@frontera-sdk/cli 1.53.5 → 1.53.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.
Files changed (72) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +2 -2
  3. package/src/api/blueprint-authoring-api.ts +19 -19
  4. package/src/api/governed-action-api.ts +78 -3
  5. package/src/api/platform-api.ts +20 -0
  6. package/src/blueprint/dataset-revision.ts +66 -0
  7. package/src/blueprint/diff.ts +134 -6
  8. package/src/blueprint/publish-plan.ts +253 -0
  9. package/src/blueprint/render.ts +19 -0
  10. package/src/commands/action/deploy.ts +143 -25
  11. package/src/commands/action/index-commands.ts +3 -3
  12. package/src/commands/action/list.ts +3 -3
  13. package/src/commands/action/prepare.ts +5 -3
  14. package/src/commands/action/retire.ts +78 -26
  15. package/src/commands/action/review.ts +86 -25
  16. package/src/commands/app/checks.ts +1 -1
  17. package/src/commands/app/deploy.ts +9 -9
  18. package/src/commands/app/deployments.ts +1 -1
  19. package/src/commands/app/dev.ts +79 -14
  20. package/src/commands/app/promote.ts +1 -1
  21. package/src/commands/app/pull.ts +4 -2
  22. package/src/commands/app/rollback.ts +1 -1
  23. package/src/commands/app/save.ts +3 -7
  24. package/src/commands/app/shared.ts +59 -2
  25. package/src/commands/app/unpin.ts +1 -1
  26. package/src/commands/app/versions.ts +1 -1
  27. package/src/commands/blueprint/authoring.ts +110 -24
  28. package/src/commands/blueprint/bind.ts +10 -46
  29. package/src/commands/blueprint/declarative.ts +124 -30
  30. package/src/commands/blueprint/editable.ts +3 -5
  31. package/src/commands/knowledge/index-commands.ts +15 -2
  32. package/src/commands/local/check.ts +21 -13
  33. package/src/commands/local/data.ts +1 -1
  34. package/src/commands/local/dev.ts +1 -21
  35. package/src/commands/local/index-commands.ts +1 -1
  36. package/src/commands/local/snapshot.ts +2 -2
  37. package/src/commands/local/steps.ts +11 -7
  38. package/src/commands/local/sync-request.ts +45 -8
  39. package/src/commands/local/sync.ts +23 -4
  40. package/src/commands/mcp-server/index-commands.ts +99 -11
  41. package/src/commands/registry.ts +1 -1
  42. package/src/commands/types.ts +2 -0
  43. package/src/device-login.ts +19 -1
  44. package/src/exit.ts +5 -0
  45. package/src/flag-help.ts +2 -1
  46. package/src/forge/permission-rules.ts +2 -0
  47. package/src/local/local-actions.ts +6 -5
  48. package/src/local/local-app-runtime.ts +6 -4
  49. package/src/local/local-blueprint-schemas.ts +2 -2
  50. package/src/local/local-build-inputs.ts +2 -1
  51. package/src/local/local-db-snapshot.ts +62 -46
  52. package/src/local/local-dev-references.ts +0 -23
  53. package/src/local/local-docker.ts +84 -10
  54. package/src/local/local-mcp-servers.ts +79 -14
  55. package/src/local/local-person-steps.ts +121 -19
  56. package/src/local/local-project.ts +5 -1
  57. package/src/local/local-supervisor.ts +41 -3
  58. package/src/local/local-sync-link.ts +76 -0
  59. package/src/local/local-sync-precheck.ts +1 -1
  60. package/src/local/local-sync-push.ts +8 -2
  61. package/src/local/local-sync-remote.ts +18 -0
  62. package/src/main.ts +7 -1
  63. package/src/mcp/mcp-apps.ts +96 -20
  64. package/src/mcp/tools/agent-tools.ts +29 -7
  65. package/src/mcp/tools/app-tools.ts +3 -3
  66. package/src/mcp/tools/project-tools.ts +2 -1
  67. package/src/project.ts +47 -11
  68. package/src/remote-guard.ts +4 -1
  69. package/src/templates/next-app-files.ts +4 -1
  70. package/src/templates/next-skills.ts +9 -5
  71. package/src/vendor/kit-assets.json +8 -8
  72. package/src/vendor/sdk-sources.json +17 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.53.5",
3
+ "version": "1.53.7",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.53.5",
44
- "@frontera-sdk/functions": "1.53.5",
43
+ "@frontera-sdk/core": "1.53.7",
44
+ "@frontera-sdk/functions": "1.53.7",
45
45
  "ai": "^6.0.116",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.53.5",
52
+ "@frontera-sdk/forge-contracts": "1.53.7",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -159,8 +159,8 @@ export class AppsApi {
159
159
  * `pull` used `ensureApp`, so a typo or a slug the platform had since
160
160
  * renamed produced a brand new empty app instead of an error.
161
161
  */
162
- resolveApp(slugOrId: string): Promise<{ id: string; slug: string; displayName: string }> {
163
- return this.client.request<{ id: string; slug: string; displayName: string }>(
162
+ resolveApp(slugOrId: string): Promise<{ id: string; slug: string; displayName: string; kind?: 'code' | 'chat' }> {
163
+ return this.client.request<{ id: string; slug: string; displayName: string; kind?: 'code' | 'chat' }>(
164
164
  `/v1/platform-apps/resolve/${encodeURIComponent(slugOrId)}`,
165
165
  )
166
166
  }
@@ -3,6 +3,8 @@ import { organizationHeaders } from '../organization'
3
3
  import { credentialFailure } from './credential-failure'
4
4
  import { formatValidationDetails } from './validation-detail'
5
5
  import type { DefinitionBundle } from '../blueprint/model'
6
+ import type { ValidationReport } from '../blueprint/publish-plan'
7
+ import { withBlockers } from '../exit'
6
8
 
7
9
  /** A row of `GET /v1/blueprint/markings`. */
8
10
  export interface MarkingRow {
@@ -75,10 +77,14 @@ export class BlueprintAuthoringApi {
75
77
  })
76
78
  if (credential) throw credential
77
79
  if (res.status === 409) {
80
+ // The blockers ride in `details`: printed under the message, and kept whole for `--json`.
78
81
  throw new CliError(
79
- payload?.message
80
- ?? 'The shared draft moved since this change was computed. Re-run to pick up the current revision.',
81
- { code: 'CONFLICT', hint: 'frontera blueprint status' },
82
+ withBlockers(
83
+ payload?.message
84
+ ?? 'The shared draft moved since this change was computed. Re-run to pick up the current revision.',
85
+ payload?.details,
86
+ ),
87
+ { code: 'CONFLICT', hint: 'frontera blueprint status', ...(payload?.details !== undefined ? { details: payload.details } : {}) },
82
88
  )
83
89
  }
84
90
  if (res.status === 404) {
@@ -351,42 +357,36 @@ export class BlueprintAuthoringApi {
351
357
 
352
358
  // ── release lifecycle ─────────────────────────────────────────────────────
353
359
 
354
- validate(): Promise<{
355
- id?: string
360
+ validate(): Promise<ValidationReport & {
356
361
  reportId?: string
357
- draftRevision?: number
358
362
  draftDigest?: string
359
363
  baseReleaseDigest?: string
360
364
  catalogProjection?: { objectTypes?: unknown[]; linkTypes?: unknown[] }
361
- evolution?: { changes?: unknown[] }
362
365
  }> {
363
366
  return this.call('/v1/blueprint/lifecycle/validate', { method: 'POST', body: {} })
364
367
  }
365
368
 
369
+ /**
370
+ * One POST, with every instruction already in it. Publish never asks the service
371
+ * which changes need one: each refused publish is kept as a failed attempt, so the
372
+ * change ids come from the validation report (`publish-plan.ts`).
373
+ */
366
374
  publish(input: {
367
375
  expectedRevision: number
368
376
  validationReportId: string
369
377
  releaseLabel: string
370
378
  releaseNotes: string
371
- instruction?: string
372
- }) {
373
- const { instruction, ...body } = input
374
- // Publish REQUIRES the field even when empty; rollback treats it as optional.
375
- // Dropping the default here failed typebox before authorization ever ran.
376
- return this.postWithMigrations(
377
- '/v1/blueprint/lifecycle/publish',
378
- { migrationInstructions: [], ...body },
379
- instruction,
380
- )
379
+ migrationInstructions: Array<{ changeId: string; instruction: string }>
380
+ }): Promise<unknown> {
381
+ return this.call('/v1/blueprint/lifecycle/publish', { method: 'POST', body: input })
381
382
  }
382
383
 
383
384
  /**
384
- * POST a release operation, negotiating migration instructions once.
385
+ * POST a rollback, negotiating migration instructions once.
385
386
  *
386
387
  * A release that discards changes is refused until each discarded change carries an
387
388
  * explanation, and the service names the change ids it wants. Rather than make the
388
389
  * caller guess them, this asks, then retries with one instruction per named change.
389
- * Shared by publish and rollback because the refusal is identical for both.
390
390
  */
391
391
  private async postWithMigrations(
392
392
  path: string,
@@ -74,6 +74,31 @@ export interface DeployableActionEntry {
74
74
  warnings?: Array<{ code: string; path?: string; message: string }>
75
75
  }
76
76
 
77
+ /** An Action's current deployment, as `GET …/admin/deployments?actionDefinitionId=` answers it. */
78
+ export interface CurrentDeployment {
79
+ stateId: string
80
+ state: string
81
+ generation: number
82
+ bindingId: string
83
+ bindingRevisionId: string | null
84
+ /** The contract the deployment was decided for; a later edit of the Action does not move it. */
85
+ actionContractDigest: string
86
+ }
87
+
88
+ /** The write path calling another system an Action runs, drains or last ran; `planKind` is null when the service cannot read it. */
89
+ export interface ExternalWritePath {
90
+ stateId: string
91
+ state: string
92
+ planKind: string | null
93
+ /** The organization it was read in, which a person's calls then name. */
94
+ organizationId?: string
95
+ /**
96
+ * Asked with one of the Action's Binding revisions: why the caller's review
97
+ * would not turn that one on in this one's place, or null when it would.
98
+ */
99
+ reviewRefusal?: 'key_built' | 'reviewer_built' | null
100
+ }
101
+
77
102
  export interface ActionDiscovery {
78
103
  actionDefinitionId: string
79
104
  apiName: string
@@ -414,7 +439,7 @@ export class GovernedActionApi {
414
439
  actionDefinitionId: string
415
440
  actionContractDigest: string
416
441
  mutationPlanRevisionId: string
417
- }): Promise<{ id: string; stateId: string }> {
442
+ }): Promise<{ id: string; stateId: string; organizationId?: string }> {
418
443
  return this.call(`/v1/blueprint/governed-actions/admin/bindings/${bindingId}/revisions`, {
419
444
  method: 'POST',
420
445
  body: { expectedRevision: 0, parentRevisionId: null, ...input },
@@ -453,6 +478,7 @@ export class GovernedActionApi {
453
478
  id: string
454
479
  bindingId: string
455
480
  actionDefinitionId: string
481
+ mutationPlanRevisionId: string
456
482
  stateId: string
457
483
  state: string
458
484
  stateGeneration: number
@@ -486,14 +512,63 @@ export class GovernedActionApi {
486
512
  return (published?.definition as { id?: string } | undefined)?.id ?? null
487
513
  }
488
514
 
515
+ /**
516
+ * The write path calling another system that the Action runs, drains or last
517
+ * ran, or null. While there is one, the service turns a new one on only
518
+ * after a second person reviews it, and never one an API key built. An
519
+ * older platform has no such route and no such rule, so it answers null.
520
+ */
521
+ async externalWritePath(actionDefinitionId: string, bindingRevisionId?: string): Promise<ExternalWritePath | null> {
522
+ const revision = bindingRevisionId ? `&bindingRevisionId=${encodeURIComponent(bindingRevisionId)}` : ''
523
+ try {
524
+ return await this.call(`/v1/blueprint/governed-actions/admin/deployments/external-write-path?actionDefinitionId=${encodeURIComponent(actionDefinitionId)}${revision}`)
525
+ } catch (err) {
526
+ if (err instanceof CliError && err.code === 'NOT_FOUND') return null
527
+ throw err
528
+ }
529
+ }
530
+
489
531
  /** The Action's current deployment decision, or null when it has none. */
490
- async currentDeployment(actionDefinitionId: string): Promise<{ stateId: string; state: string; generation: number; bindingRevisionId: string | null } | null> {
491
- const page = await this.call<{ entries?: Array<{ stateId: string; state: string; generation: number; bindingRevisionId: string | null }> }>(
532
+ async currentDeployment(actionDefinitionId: string): Promise<CurrentDeployment | null> {
533
+ const page = await this.call<{ entries?: CurrentDeployment[] }>(
492
534
  `/v1/blueprint/governed-actions/admin/deployments?actionDefinitionId=${encodeURIComponent(actionDefinitionId)}`,
493
535
  )
494
536
  return page?.entries?.[0] ?? null
495
537
  }
496
538
 
539
+ /**
540
+ * Switch the Action's current deployment off: the service records a NEW
541
+ * disabled deployment under the fresh `stateId` and `bindingId`, and the one
542
+ * it replaces stops being current (a running one drains).
543
+ */
544
+ disableDeployment(stateId: string, input: {
545
+ bindingId: string
546
+ actionDefinitionId: string
547
+ actionContractDigest: string
548
+ expectedCurrentStateId: string | null
549
+ expectedCurrentGeneration: number | null
550
+ reason: string
551
+ }): Promise<{ stateId: string; state: string; generation: number }> {
552
+ return this.call(`/v1/blueprint/governed-actions/admin/deployments/${encodeURIComponent(stateId)}/disable`, {
553
+ method: 'POST',
554
+ body: input,
555
+ })
556
+ }
557
+
558
+ /**
559
+ * The Action's draining deployments. None is current, so the deployment list
560
+ * omits them, while publish still refuses a changed Action until each is
561
+ * retired. Read per binding, from the state of its newest revision.
562
+ */
563
+ async drainingDeployments(actionDefinitionId: string): Promise<Array<{ stateId: string; generation: number }>> {
564
+ // ponytail: the first page of bindings only (50, in id order), so an Action deployed more than 50 times may hide one; page the list, or let the service filter by state, when that happens.
565
+ const { entries } = await this.listBindings(actionDefinitionId)
566
+ const states = await Promise.all(entries.map((entry) => this.getBindingRevision(entry.revisionId)))
567
+ return states
568
+ .filter((revision) => revision.state === 'draining' && revision.actionDefinitionId === actionDefinitionId)
569
+ .map((revision) => ({ stateId: revision.stateId, generation: revision.stateGeneration }))
570
+ }
571
+
497
572
  /** Stand a deployment decision down: one that was never bound, or a draining one (the service refuses the rest). */
498
573
  retireDeployment(stateId: string, expectedGeneration: number, reason: string): Promise<{ stateId?: string; state?: string; generation?: number }> {
499
574
  return this.call(`/v1/blueprint/governed-actions/admin/deployments/${encodeURIComponent(stateId)}/retire`, {
@@ -4,6 +4,7 @@ import type { MetricQueryBody, MetricQueryResult } from '../blueprint/metric-que
4
4
  import { organizationHeaders, selectedOrganization } from '../organization'
5
5
 
6
6
  import { CliError } from '../errors'
7
+ import type { SavedServer } from '../local/local-mcp-servers'
7
8
  import { BLUEPRINT_SCHEMA_CAPABILITIES } from '../blueprint-types'
8
9
 
9
10
  /**
@@ -291,6 +292,11 @@ export class PlatformApi {
291
292
  return this.get<{ server: unknown }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`)
292
293
  }
293
294
 
295
+ /** Created, or replaced in place: `created` says which. */
296
+ saveMcpServer(slug: string, definition: Record<string, unknown>): Promise<SavedServer> {
297
+ return this.client.request<SavedServer>(`/v1/mcp-servers/${encodeURIComponent(slug)}`, { method: 'PUT', body: definition })
298
+ }
299
+
294
300
  /** Archived, not deleted: the Function runs its tools started still name it. */
295
301
  archiveMcpServer(slug: string): Promise<{ slug: string; archived: boolean }> {
296
302
  return this.client.request<{ slug: string; archived: boolean }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`, { method: 'DELETE' })
@@ -721,6 +727,20 @@ export class PlatformApi {
721
727
 
722
728
  // ── Knowledge ─────────────────────────────────────────────────────────────
723
729
 
730
+ /**
731
+ * Whether the platform runs the Knowledge app. Its routes answer 404 before any sign-in check where it does not, so
732
+ * any other answer, a refusal of this key included, means it runs. No answer counts as not running.
733
+ */
734
+ async knowledgeAppRuns(): Promise<boolean> {
735
+ try {
736
+ await this.get<unknown>('/v1/knowledge-app/me')
737
+ return true
738
+ } catch (err) {
739
+ const status = (err as { status?: number }).status
740
+ return status !== undefined && status !== 404
741
+ }
742
+ }
743
+
724
744
  knowledgeBases(workspaceId: string) {
725
745
  return this.getList<unknown>(`/v1/workspace-knowledge?workspaceId=${encodeURIComponent(workspaceId)}`)
726
746
  }
@@ -1,3 +1,5 @@
1
+ import { CliError } from '../errors'
2
+
1
3
  /**
2
4
  * Which revision of a dataset is CURRENT.
3
5
  *
@@ -31,3 +33,67 @@ export function pickCurrentRevision<T extends { id?: string }>(
31
33
  }
32
34
  return revisions[0]
33
35
  }
36
+
37
+ /** A file's `backing.mapping` entry: a column, or a struct field within one. */
38
+ export type MappingEntry = string | { column: string; field?: string | null }
39
+
40
+ export interface ColumnMapping {
41
+ propertyId: string
42
+ column: string
43
+ field?: string
44
+ }
45
+
46
+ /**
47
+ * Every property that reads a column, paired with the column the mapping names.
48
+ * Shared by `bind` and by `apply`'s rebind, which send the same draft command.
49
+ */
50
+ export function columnMappingsFrom(
51
+ properties: ReadonlyArray<{ id?: string; apiName?: string; calculation?: unknown }>,
52
+ mapping: Readonly<Record<string, MappingEntry | null | undefined>>,
53
+ ): { columnMappings: ColumnMapping[]; unmapped: string[] } {
54
+ const columnMappings: ColumnMapping[] = []
55
+ const unmapped: string[] = []
56
+ for (const property of properties) {
57
+ const name = property.apiName
58
+ // A calculated field (a location from latitude and longitude) reads no column.
59
+ if (!name || property.calculation) continue
60
+ const entry = mapping[name]
61
+ if (entry === undefined || entry === null) {
62
+ unmapped.push(name)
63
+ continue
64
+ }
65
+ // Read in the same call as the names, so a property without an id means the draft moved mid-command.
66
+ if (!property.id) {
67
+ throw new CliError(`Property "${name}" is no longer on the draft.`, {
68
+ code: 'CONFLICT',
69
+ hint: 'Re-run the command against the current draft.',
70
+ })
71
+ }
72
+ const resolved = typeof entry === 'string' ? { column: entry } : entry
73
+ columnMappings.push({
74
+ propertyId: property.id,
75
+ column: resolved.column,
76
+ ...(resolved.field === undefined || resolved.field === null ? {} : { field: resolved.field }),
77
+ })
78
+ }
79
+ return { columnMappings, unmapped }
80
+ }
81
+
82
+ /** The `update_source_binding` draft command that pins a type to a Dataset revision. */
83
+ export function datasetBindingCommand(
84
+ objectId: string,
85
+ datasetRevisionId: string,
86
+ columnMappings: ColumnMapping[],
87
+ acceptContractChange: boolean,
88
+ ): Record<string, unknown> {
89
+ return {
90
+ kind: 'update_source_binding',
91
+ objectId,
92
+ binding: {
93
+ kind: 'dataset',
94
+ datasetRevisionId,
95
+ columnMappings,
96
+ ...(acceptContractChange ? { acceptContractChange: true } : {}),
97
+ },
98
+ }
99
+ }
@@ -1,4 +1,5 @@
1
1
  import { actionWithoutMintedIds } from './compile'
2
+ import { columnMappingsFrom, type ColumnMapping, type MappingEntry } from './dataset-revision'
2
3
  import { toFiles, withoutImpliedTiming } from './projection'
3
4
  import {
4
5
  ARTIFACT_KINDS,
@@ -61,11 +62,132 @@ export interface Plan {
61
62
  */
62
63
  withheld?: ModelOperation[]
63
64
  drift: BindingDrift[]
65
+ /** Object types whose `backing.editable` differs from the draft's editable set. */
66
+ editable: EditableChange[]
67
+ /** Object types whose `backing.dataset` names another Dataset than the draft reads. */
68
+ rebind: RebindChange[]
64
69
  unchanged: number
65
70
  }
66
71
 
72
+ /**
73
+ * One object type moving to the Dataset its file names.
74
+ *
75
+ * Its own list for the reason `EditableChange` is: it travels its own draft command
76
+ * (`update_source_binding`, the one `bind` sends). Either it carries that command's
77
+ * pin and mapping, or the reason it cannot be sent and what to run instead.
78
+ */
79
+ export type RebindChange = {
80
+ objectApiName: string
81
+ objectId: string
82
+ /** The Dataset the draft reads; null for a type with no Dataset yet. */
83
+ from: string | null
84
+ to: string
85
+ } & (
86
+ | { datasetRevisionId: string; columnMappings: ColumnMapping[] }
87
+ | { refused: string; next: string }
88
+ )
89
+
90
+ /** What the Datasets say, read once per plan. */
91
+ export interface DatasetIndex {
92
+ nameByRevisionId: Map<string, string>
93
+ /** Dataset name → its current revision; `columns` absent when the revision list did not carry them. */
94
+ current: Map<string, { id: string; schemaDigest?: string; columns?: string[] }>
95
+ }
96
+
97
+ /**
98
+ * The object types whose file names a Dataset the draft does not read.
99
+ *
100
+ * Each property keeps the column it reads: the file's `backing.mapping` first, then
101
+ * the draft's current mapping. A mapped column the new revision does not publish is
102
+ * refused here, before any write; a column whose type moved is the service's refusal.
103
+ * A pin whose Dataset this credential cannot name is left alone, never guessed.
104
+ */
105
+ export function rebindChanges(files: AuthoredFile[], live: DefinitionBundle, datasets: DatasetIndex): RebindChange[] {
106
+ const changes: RebindChange[] = []
107
+ for (const file of files) {
108
+ if (file.kind !== 'object-type') continue
109
+ const backing = file.document.backing as { dataset?: unknown; mapping?: Record<string, MappingEntry | null> } | undefined
110
+ const to = backing?.dataset
111
+ if (typeof to !== 'string' || !to) continue
112
+ const object = live.objects.find((candidate) => candidate.apiName === file.apiName)
113
+ if (!object) continue
114
+ const pin = object.governance.sourceMappings[0] as
115
+ | { datasetRevisionId?: string; propertyMappings?: Array<{ propertyId: string; sourcePath: string; sourceField?: string | null }> }
116
+ | undefined
117
+ const from = pin?.datasetRevisionId ? datasets.nameByRevisionId.get(pin.datasetRevisionId) : null
118
+ if (from === undefined || from === to) continue
119
+
120
+ const base = { objectApiName: file.apiName, objectId: object.id, from, to }
121
+ const next = `frontera blueprint bind ${file.apiName} --dataset ${to} --plan ./map.json`
122
+ const target = datasets.current.get(to)
123
+ if (!target) {
124
+ changes.push({ ...base, refused: `no Dataset named "${to}" is visible`, next: 'frontera dataset list' })
125
+ continue
126
+ }
127
+ const kept: Record<string, MappingEntry> = {}
128
+ for (const entry of pin?.propertyMappings ?? []) {
129
+ const name = object.properties.find((property) => property.id === entry.propertyId)?.apiName
130
+ if (name) kept[name] = entry.sourceField ? { column: entry.sourcePath, field: entry.sourceField } : entry.sourcePath
131
+ }
132
+ const { columnMappings, unmapped } = columnMappingsFrom(object.properties, { ...kept, ...backing?.mapping })
133
+ if (unmapped.length) {
134
+ changes.push({ ...base, refused: `no column mapped for ${unmapped.join(', ')}`, next })
135
+ continue
136
+ }
137
+ const published = target.columns ? new Set(target.columns) : null
138
+ const missing = published ? columnMappings.filter((mapping) => !published.has(mapping.column)) : []
139
+ if (missing.length) {
140
+ const nameOf = (id: string) => object.properties.find((property) => property.id === id)?.apiName ?? id
141
+ changes.push({
142
+ ...base,
143
+ refused: `"${to}" does not publish ${missing.map((mapping) => `${mapping.column} (read by ${nameOf(mapping.propertyId)})`).join(', ')}`,
144
+ next,
145
+ })
146
+ continue
147
+ }
148
+ changes.push({ ...base, datasetRevisionId: target.id, columnMappings })
149
+ }
150
+ return changes
151
+ }
152
+
153
+ /**
154
+ * One object type's editable set, as its file declares it and as the draft has it.
155
+ *
156
+ * Its own list rather than an object-type update, because it travels its own draft
157
+ * command (`set_editable_properties`), not the object-type patch, and because a type
158
+ * created in this run has an editable set only after its create answers.
159
+ */
160
+ export interface EditableChange {
161
+ objectApiName: string
162
+ /** API names, sorted. Empty for a type the draft does not have yet. */
163
+ from: string[]
164
+ to: string[]
165
+ }
166
+
67
167
  export function isEmpty(plan: Plan): boolean {
68
- return plan.operations.length === 0 && plan.prunes.length === 0
168
+ return plan.operations.length === 0 && plan.prunes.length === 0 && plan.editable.length === 0 && plan.rebind.length === 0
169
+ }
170
+
171
+ /**
172
+ * The editable sets the files declare that the draft does not have.
173
+ *
174
+ * Only a file that states `backing.editable` has an opinion, as for every other
175
+ * field: one that leaves the key out keeps whatever the draft holds, and
176
+ * `editable: []` asks for none.
177
+ */
178
+ export function editableChanges(files: AuthoredFile[], live: DefinitionBundle): EditableChange[] {
179
+ const changes: EditableChange[] = []
180
+ for (const file of files) {
181
+ if (file.kind !== 'object-type') continue
182
+ const declared = (file.document.backing as { editable?: unknown } | undefined)?.editable
183
+ if (!Array.isArray(declared)) continue
184
+ const to = [...new Set(declared.map(String))].sort()
185
+ const object = live.objects.find((candidate) => candidate.apiName === file.apiName)
186
+ const ids = (object?.governance.sourceMappings[0] as { editableProperties?: string[] } | undefined)?.editableProperties ?? []
187
+ const from = ids.flatMap((id) => object?.properties.find((property) => property.id === id)?.apiName ?? []).sort()
188
+ if (from.join('\n') !== to.join('\n')) changes.push({ objectApiName: file.apiName, from, to })
189
+ }
190
+ return changes
69
191
  }
70
192
 
71
193
  /**
@@ -91,9 +213,9 @@ export function isEmpty(plan: Plan): boolean {
91
213
  * field the plan never called changed.
92
214
  */
93
215
  export function satisfies(local: unknown, live: unknown, key?: string): boolean {
94
- // `backing` is owned by `bind`, which travels the probe and the contract-change
95
- // gate. Letting it into the plan would mean `apply` silently rebinding a type
96
- // because somebody edited a column name in a file.
216
+ // `backing` is compared on its own: `editableChanges` and `rebindChanges` plan
217
+ // the editable set and a new Dataset. A column edited in place stays `bind`'s,
218
+ // so `apply` never repoints a property because a mapping line changed.
97
219
  if (key === 'backing') return true
98
220
 
99
221
  if (Array.isArray(local)) {
@@ -227,6 +349,8 @@ export function diff(
227
349
  localFiles: AuthoredFile[],
228
350
  live: DefinitionBundle,
229
351
  revision: number,
352
+ /** Absent when the Datasets were not read: no rebind is planned. */
353
+ datasets?: DatasetIndex,
230
354
  ): Plan {
231
355
  const liveModel = toFiles(live)
232
356
  const liveByKey = new Map(liveModel.files.map((file) => [`${file.kind}:${file.apiName}`, file]))
@@ -234,6 +358,10 @@ export function diff(
234
358
 
235
359
  const operations: ModelOperation[] = []
236
360
  const prunes: ModelOperation[] = []
361
+ const editable = editableChanges(localFiles, live)
362
+ const rebind = datasets ? rebindChanges(localFiles, live, datasets) : []
363
+ // A type whose only difference is its editable set or its Dataset is listed there, not as unchanged.
364
+ const editableNames = new Set([...editable, ...rebind].map((change) => change.objectApiName))
237
365
  let unchanged = 0
238
366
 
239
367
  // Fixed kind order so the plan reads the same way every run, and so creations of
@@ -255,7 +383,7 @@ export function diff(
255
383
  continue
256
384
  }
257
385
  if (matches(kind, file.document, existing.document)) {
258
- unchanged += 1
386
+ if (kind !== 'object-type' || !editableNames.has(file.apiName)) unchanged += 1
259
387
  continue
260
388
  }
261
389
  operations.push({
@@ -278,7 +406,7 @@ export function diff(
278
406
  }
279
407
  }
280
408
 
281
- return { revision, operations, prunes, drift: driftOf(live), unchanged }
409
+ return { revision, operations, prunes, drift: driftOf(live), editable, rebind, unchanged }
282
410
  }
283
411
 
284
412
  /**