Reference

Edit operations

The five validated operations that change a model by qualified name, what each one touches, when an edit is rejected, and how edits record migration steps after a release.

Tools never patch Appling text by hand. They change the model through five edit operations that address elements by qualified name: add, rename, retype, remove and set. The IDE inspector and graph, the IDE chat and the MCP edit tool all use them. Each operation is applied to the text, the touched element is printed canonically, the result is validated, and the edit is rejected when it would make the model worse.

applyEdit

import { applyEdit } from '@appling/model';

const result = await applyEdit(sources, { op: 'rename', target: 'sales.Order.total', name: 'amount' });
if (result.ok) {
  result.sources; // every source with the edit applied
  result.edits;   // minimal text edits: { uri, offset, length, text }
} else {
  result.errors;  // why the edit was rejected
}
ParameterMeaning
sourcesThe whole workspace as { [uri]: text }. Include every file, so references across files resolve and renames reach them
operationOne of the operations below
options.releaseOptional. A release from readReleases, usually latestRelease(readReleases(root)); turns on release aware edits

applyEdit writes nothing. On success it returns the changed texts and one text edit per changed file, covering the smallest range that differs, so an editor can apply it as one undoable step. Unchanged files are left out of edits.

Examples

The examples on this page start from this model, in one file:

module crm {
  export Account

  entity Account {
    name: String
  }
}

module sales {
  enum OrderStatus {
    draft
    placed
  }

  entity Order {
    customer: crm.Account
    status: OrderStatus state default draft
    total: Money

    invariant "total never negative" { total >= 0 }
  }
}

add

{ op: 'add', parent: 'sales.Order', source: 'note: Text?' }
FieldMeaning
parentThe qualified name of the element to add to
sourceAppling source of exactly one new member, such as a field, invariant "..." { ... }, requires { ... } else E or, for a module, a whole declaration

The source goes at the last position inside the parent where it parses as one more member of that parent. The parent is then printed canonically. The example adds the field at the end of the entity:

  entity Order {
    customer: crm.Account
    status: OrderStatus state default draft
    total: Money

    invariant "total never negative" { total >= 0 }
    note: Text?
  }

When no position fits, the edit is rejected with "<source>" does not fit into <parent>., for example for entity X {} inside an entity.

rename

{ op: 'rename', target: 'sales.Order.total', name: 'amount' }
FieldMeaning
targetThe qualified name of the element
nameThe new name, without module path

Rename changes only name tokens, never the layout. It follows every place that names the element:

  • references, also as one segment of a qualified name, such as Account in crm.Account, or Order in Order.status,
  • names in expressions, such as total in an invariant or a bare enum value used as a default,
  • transitions of machines over a renamed enum value (transition draft -> placed),
  • the module’s export list, for a top level declaration,
  • message keys that name the element, such as Order.total = "Total",
  • projection fields without a value that copy an entity field by name: they are renamed with the field.

The example renames the field and the name in the invariant:

    total: Money
    invariant "total never negative" { total >= 0 }

becomes

    amount: Money
    invariant "total never negative" { amount >= 0 }

Renaming the enum value sales.OrderStatus.draft to open changes both the value and the default default open.

retype

{ op: 'retype', target: 'sales.Order.total', type: 'Decimal' }
FieldMeaning
targetA field or parameter
typeThe new type in Appling syntax, such as Money, crm.Account, Line[] or DateTime?

Retype replaces the type and prints the element canonically: total: Money becomes total: Decimal. It is rejected when the new type breaks something, for example an expression that no longer type checks.

remove

{ op: 'remove', target: 'sales.Order.status' }
FieldMeaning
targetThe qualified name of the element

Remove deletes the element’s text with the whitespace before it. In a comma separated list, such as the errors of an action, it also removes the separating comma. For a top level declaration it removes its name from the module’s export list, and the whole export line when that was its only name.

Remove does not remove what still refers to the element; the edit is rejected instead. Removing crm.Account from the example fails with Could not resolve reference to TypeDecl named 'crm.Account'., and removing sales.Order.total fails with Unknown name "total". because the invariant uses it. Remove the references first.

set

{ op: 'set', target: 'sales.Order.status', property: 'default', value: 'placed' }
FieldMeaning
targetThe qualified name of the element
propertyA property name as the graph’s props shows it, such as default, derived, condition, modifiers, label, preconditions or schedule
valueAppling source for the property; empty removes it

Set covers three cases:

PropertyvalueResult
presentnot emptyReplaces the property’s text: default draft becomes default placed
presentemptyRemoves the property with its keyword: status: OrderStatus state default draft becomes status: OrderStatus state
absentnot emptyInserts it with its keyword where it parses: set of modifiers to unique on total gives total: Money unique

The element is printed canonically afterwards. Set is rejected with Cannot set <property> on <kind>. when the value cannot be placed. For a list property such as modifiers or preconditions, the value replaces the whole list, from its first to its last item.

Rejection rules

An edit is rejected, and nothing changes, when:

ReasonMessage
No element has the qualified nameNo element sales.Nope.
An added member fits nowhere in the parent"entity X {}" does not fit into sales.Order.
A property cannot be set on that elementCannot set <property> on <kind>.
The edited text does not parseThe edit does not parse.
Two changes of one rename overlapThe edit touches overlapping text.
Validation reports an error that was not there beforeThe new error messages, such as Unknown name "total".

Errors that existed before the edit do not block it, so you can edit a model that is still incomplete. Only new errors count.

Release aware edits

After appling release, the model’s domain elements are part of a released schema, and generated projects and databases depend on them. When applyEdit gets a release, an edit of a released element also records a migration step, so appling generate --migrate can carry it into generated projects.

A step is appended when all of this holds:

  • the operation is rename, retype or remove,
  • the element is named and belongs to a domain (L1) declaration: an enum, value type or entity, or one of their members,
  • the element exists in the release, traced back through renames already recorded since the release.

The step goes into the module’s migration since_v<version> block, which is created at the end of the module when it does not exist. The step uses the element’s path inside its module as it is before the edit:

OperationStep
renamerename Order.total -> amount
retyperetype Order.amount: Money -> Decimal
removeremove Order.legacy

Starting from the example model released as 1.0.0, renaming sales.Order.total to amount and then retyping sales.Order.amount to Decimal gives:

module crm {
  export Account

  entity Account {
    name: String
  }
}

module sales {
  enum OrderStatus {
    draft
    placed
  }

  entity Order {
    customer: crm.Account
    status: OrderStatus state default draft
    amount: Decimal

    invariant "total never negative" { amount >= 0 }
  }

  migration since_v1_0_0 {
    rename Order.total -> amount
    retype Order.amount: Money -> Decimal
  }
}

Elements added after the release get no steps: adding note: Text? and removing it again leaves the block unchanged. Elements outside the domain layer get no steps either. See Migrations for the rules of migration blocks.

The IDE, the MCP server and the IDE chat always pass the latest release in the workspace’s .appling/releases/ folder.