@yahoo/uds-create-config 5.22.0 → 5.23.0

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.
@@ -1,5 +1,5 @@
1
1
  import { reject, rejectData } from "../../framework/rejection.js";
2
- import { canonicalCondition, isRef, kindOf, memberRef, pathOf, ref, refLeaf, refSchema, splitRef } from "../../framework/utils/refs.js";
2
+ import { canonicalCondition, collectRefs, isRef, kindOf, memberRef, pathOf, ref, refLeaf, refSchema, splitRef } from "../../framework/utils/refs.js";
3
3
  import { isPlainObject } from "../../framework/utils/field-path.js";
4
4
  import { pathInput } from "../../framework/schemas.js";
5
5
  import { derivedColor, gradient, oklchColor } from "./color.js";
@@ -1724,6 +1724,10 @@ const ComponentEntity = defineEntity({
1724
1724
  nameEdges: bagNameEdges(componentBags),
1725
1725
  styleBags: componentBags,
1726
1726
  rewriteName: bagRewriteName(componentBags),
1727
+ structuralBody: (data) => {
1728
+ const { layers, anatomy } = data;
1729
+ return { renders: collectRefs([...Object.values(layers ?? {}), ...Object.values(anatomy ?? {}).map((node) => node?.element)]).filter((target) => kindOf(target) === "component").map((target) => ({ __ref: target })) };
1730
+ },
1727
1731
  schemas: {
1728
1732
  create: {
1729
1733
  data: componentFields,
@@ -1,4 +1,4 @@
1
- import { isRef, pathOf, ref, refKey, sourceOf } from "../../framework/utils/refs.js";
1
+ import { isRef, kindOf, pathOf, ref, refKey, sourceOf } from "../../framework/utils/refs.js";
2
2
  import { composedValueSchemas } from "./composed-values.js";
3
3
  import { defineEntity } from "../../framework/defineEntity.js";
4
4
  import { defineSubEntity } from "../../framework/defineSubEntity.js";
@@ -141,6 +141,10 @@ const TokenEntity = defineEntity({
141
141
  cssVariables: z.boolean().optional().describe("`false` writes the group's tokens into the values that use them instead of declaring a custom property for each, as Tailwind's `@theme inline` does. A token's own `cssVariable` wins. A config that holds the group from a linked system keeps it unless it sets its own.")
142
142
  }),
143
143
  subEntities: { overrides: Override },
144
+ structuralBody: (data) => {
145
+ const { value, overrides } = data;
146
+ return { aliases: [value, ...Object.values(overrides ?? {}).map((override) => override?.value)].filter((alias) => isRef(alias) && kindOf(alias.__ref) === "token") };
147
+ },
144
148
  schemas: { create: { description: `Create a token at \`<group>/<name>\` — \`data.value\` is the CSS value. The first path segment is its group, and a style property takes a whole group as one ref (\`${JSON.stringify(ref("token", "<group>"))}\`), so put the tokens one prop should offer in one group rather than listing values on the prop.` } }
145
149
  });
146
150
  /**
@@ -985,8 +985,18 @@ declare class Config {
985
985
  * under an id of its own and has to settle that row when the write lands, so it needs the pending
986
986
  * entry and its own staging to agree — and an id minted here would be a second source that
987
987
  * silently disagrees with it. Omitted, one is minted, which is what every non-optimistic caller
988
- * wants. */
988
+ * wants.
989
+ *
990
+ * A delete records more than one patch when items declare they cannot outlive the target
991
+ * ({@link EntityKind.structuralBody}): a delete per dependent, leaves first, then the target, as
992
+ * one changeset. `id` then names the first of them and the changeset. */
989
993
  apply(patch: Patch, id?: string): this;
994
+ /**
995
+ * {@link apply} for exactly the patch it is given: a delete takes nothing with it. For replaying a
996
+ * patch as it was recorded, since a recorded delete's cascade was recorded beside it, ahead of it.
997
+ * Deleting again what the base gained since would remove it with no patch saying so.
998
+ */
999
+ applyRecorded(patch: Patch, id?: string): this;
990
1000
  /**
991
1001
  * `run` with each kind's invariants judged once, on the config it returns, rather than after every
992
1002
  * `apply` inside it. An invariant walks its whole kind, so a pass that writes a row per entity and
@@ -1024,7 +1034,62 @@ declare class Config {
1024
1034
  *
1025
1035
  * Several patches are one gesture, so they share one changeset; a single patch carries none.
1026
1036
  */
1027
- applyAll(patches: readonly Patch[]): this;
1037
+ applyAll(requested: readonly Patch[]): this;
1038
+ /**
1039
+ * `patches` with each delete preceded by the deletes that go with it, each judged against the state
1040
+ * the patches before it produce. A later delete of something the batch already took is dropped, so
1041
+ * a caller listing both the entity and its dependent, in either order, deletes each once.
1042
+ *
1043
+ * Applied unchecked to a scratch snapshot to learn those states. A refusal there stops the
1044
+ * expansion and hands back the rest of `patches` as given, so the real pass refuses at the same
1045
+ * patch, with the same message, a single `apply` would.
1046
+ */
1047
+ private withStructuralDependents;
1048
+ /** The kinds that declare {@link EntityKind.structuralBody}, with it. */
1049
+ private cascadingKinds;
1050
+ /**
1051
+ * Every structural ref the stored items of the cascading kinds hold, keyed by the kind it names,
1052
+ * with bare leaves spelled out in full. A ref into a source names the source's entity, never this
1053
+ * config's, so it is left out, and so is a row a source minted.
1054
+ */
1055
+ private structuralEdges;
1056
+ /**
1057
+ * The deletes that go with `patch`, leaves first: one for every item of a kind declaring
1058
+ * {@link EntityKind.structuralBody} whose structural refs name the deleted entity, under any
1059
+ * spelling {@link refAliases} admits, or anything under its path, preceded by whatever goes with
1060
+ * that item in turn. Empty unless `patch` is a delete.
1061
+ *
1062
+ * `edges` may come from an earlier state, so an edge whose item is gone is skipped.
1063
+ */
1064
+ private structuralDependentsOf;
1065
+ /**
1066
+ * What deleting `kind:path` takes with it, in the order the delete writes them, whether or not the
1067
+ * delete would be refused. {@link EntityKind.structuralBody} decides what counts.
1068
+ */
1069
+ cascadeOf({
1070
+ kind,
1071
+ path
1072
+ }: {
1073
+ kind: string;
1074
+ path: string;
1075
+ }): {
1076
+ kind: string;
1077
+ path: string;
1078
+ }[];
1079
+ /**
1080
+ * Refuse a delete whose cascade takes something that an entity outside it still refers to, naming
1081
+ * the entity the caller deleted. Without this the refusal comes from the dependent's own delete, and
1082
+ * names an entity the caller never asked to delete. A referrer of the target itself is left to the
1083
+ * delete's own rule.
1084
+ */
1085
+ private assertCascadeUnblocked;
1086
+ /**
1087
+ * `deletes` reordered so each one comes after every other one whose entity still refers to it, by
1088
+ * any ref, since each delete is refused while something still points at it. The structural walk
1089
+ * already puts a dependent before what it depends on; this also covers a plain ref between two
1090
+ * dependents. Members of a cycle keep their order, and the delete that meets the cycle refuses.
1091
+ */
1092
+ private referrersFirst;
1028
1093
  /**
1029
1094
  * Whether patches on kinds that name no source can change the rows a derive mints between `before`
1030
1095
  * and `after`: they wrote a pin (an edit to a minted row lands in its pin's `options`), or a row
@@ -1265,8 +1265,26 @@ var Config = class Config {
1265
1265
  * under an id of its own and has to settle that row when the write lands, so it needs the pending
1266
1266
  * entry and its own staging to agree — and an id minted here would be a second source that
1267
1267
  * silently disagrees with it. Omitted, one is minted, which is what every non-optimistic caller
1268
- * wants. */
1268
+ * wants.
1269
+ *
1270
+ * A delete records more than one patch when items declare they cannot outlive the target
1271
+ * ({@link EntityKind.structuralBody}): a delete per dependent, leaves first, then the target, as
1272
+ * one changeset. `id` then names the first of them and the changeset. */
1269
1273
  apply(patch, id) {
1274
+ const dependents = this.structuralDependentsOf({ patch });
1275
+ if (dependents.length === 0) return this.applyRecorded(patch, id);
1276
+ const since = this.state.pending.length;
1277
+ return [...dependents, patch].reduce((config, each) => config.applyRecorded(each), this).withChangeset({
1278
+ since,
1279
+ id
1280
+ });
1281
+ }
1282
+ /**
1283
+ * {@link apply} for exactly the patch it is given: a delete takes nothing with it. For replaying a
1284
+ * patch as it was recorded, since a recorded delete's cascade was recorded beside it, ahead of it.
1285
+ * Deleting again what the base gained since would remove it with no patch saying so.
1286
+ */
1287
+ applyRecorded(patch, id) {
1270
1288
  const deferred = this.state.deferredInvariants;
1271
1289
  if (deferred?.open) deferred.kinds.add(patch.kind);
1272
1290
  const applied = this.applyPatch(patch, id, !deferred?.open);
@@ -1361,8 +1379,9 @@ var Config = class Config {
1361
1379
  *
1362
1380
  * Several patches are one gesture, so they share one changeset; a single patch carries none.
1363
1381
  */
1364
- applyAll(patches) {
1365
- if (patches.length === 0) return this;
1382
+ applyAll(requested) {
1383
+ if (requested.length === 0) return this;
1384
+ const patches = this.withStructuralDependents(requested);
1366
1385
  const changeset = patches.length > 1 ? crypto.randomUUID() : void 0;
1367
1386
  const stampOf = (patch) => stamp(patch, void 0, changeset);
1368
1387
  const touched = new Set(patches.map((patch) => patch.kind));
@@ -1412,6 +1431,196 @@ var Config = class Config {
1412
1431
  return config.derivedForEdit(actionOf(patches));
1413
1432
  }
1414
1433
  /**
1434
+ * `patches` with each delete preceded by the deletes that go with it, each judged against the state
1435
+ * the patches before it produce. A later delete of something the batch already took is dropped, so
1436
+ * a caller listing both the entity and its dependent, in either order, deletes each once.
1437
+ *
1438
+ * Applied unchecked to a scratch snapshot to learn those states. A refusal there stops the
1439
+ * expansion and hands back the rest of `patches` as given, so the real pass refuses at the same
1440
+ * patch, with the same message, a single `apply` would.
1441
+ */
1442
+ withStructuralDependents(patches) {
1443
+ const cascading = this.cascadingKinds();
1444
+ if (cascading.length === 0) return patches;
1445
+ if (!patches.some((patch) => patch.operation === "delete")) return patches;
1446
+ if (!(cascading.some(({ kind }) => this.state.snapshot.slice(kind).items.size > 0) || patches.some((patch) => cascading.some(({ kind }) => kind === patch.kind)))) return patches;
1447
+ const expanded = [];
1448
+ const cascaded = /* @__PURE__ */ new Set();
1449
+ const movesSource = patches.some((patch) => this.entityOf(patch.kind)?.sourceIdentity);
1450
+ let view = movesSource ? this : this.derive({ snapshot: this.state.snapshot.drafting() });
1451
+ let edges;
1452
+ for (const [index, patch] of patches.entries()) {
1453
+ if (patch.operation === "delete" && cascaded.has(`${patch.kind}:${patch.path}`) && !view.state.snapshot.has(patch.kind, view.leafPath(patch.kind, patch.path) ?? patch.path)) continue;
1454
+ if (patch.operation === "delete") edges ??= view.structuralEdges();
1455
+ const step = [...view.structuralDependentsOf({
1456
+ patch,
1457
+ edges
1458
+ }), patch];
1459
+ expanded.push(...step);
1460
+ try {
1461
+ for (const each of step) view = movesSource ? view.applyPatch(each, void 0, false) : view.derive({ snapshot: view.computeApply(view.state.snapshot, each, "write", false) });
1462
+ } catch (error) {
1463
+ if (!isConfigRejection(error)) throw error;
1464
+ return [...expanded, ...patches.slice(index + 1)];
1465
+ }
1466
+ if (patch.operation !== "delete") edges = void 0;
1467
+ for (const each of step) if (each !== patch && each.operation === "delete") for (const spelling of view.refAliases(each.kind, each.path)) cascaded.add(spelling);
1468
+ }
1469
+ return expanded;
1470
+ }
1471
+ /** The kinds that declare {@link EntityKind.structuralBody}, with it. */
1472
+ cascadingKinds() {
1473
+ const cascading = [];
1474
+ for (const [kind, entry] of this.state.entities) if (entry.structuralBody) cascading.push({
1475
+ kind,
1476
+ structuralBody: entry.structuralBody
1477
+ });
1478
+ return cascading;
1479
+ }
1480
+ /**
1481
+ * Every structural ref the stored items of the cascading kinds hold, keyed by the kind it names,
1482
+ * with bare leaves spelled out in full. A ref into a source names the source's entity, never this
1483
+ * config's, so it is left out, and so is a row a source minted.
1484
+ */
1485
+ structuralEdges() {
1486
+ const edges = /* @__PURE__ */ new Map();
1487
+ for (const { kind, structuralBody } of this.cascadingKinds()) for (const entity of this.state.snapshot.values(kind)) {
1488
+ const body = entity.toJSON();
1489
+ if (isMintedRow(body)) continue;
1490
+ const fields = isExtendedRef(body) ? body.extend : body;
1491
+ for (const ref of collectRefs(structuralBody(fields))) {
1492
+ if (sourceOf(ref) !== void 0) continue;
1493
+ const target = splitRef(ref);
1494
+ const named = edges.get(target.kind) ?? [];
1495
+ named.push({
1496
+ targetPath: this.leafPath(target.kind, target.qualifiedPath) ?? target.qualifiedPath,
1497
+ kind,
1498
+ path: entity.path
1499
+ });
1500
+ edges.set(target.kind, named);
1501
+ }
1502
+ }
1503
+ return edges;
1504
+ }
1505
+ /**
1506
+ * The deletes that go with `patch`, leaves first: one for every item of a kind declaring
1507
+ * {@link EntityKind.structuralBody} whose structural refs name the deleted entity, under any
1508
+ * spelling {@link refAliases} admits, or anything under its path, preceded by whatever goes with
1509
+ * that item in turn. Empty unless `patch` is a delete.
1510
+ *
1511
+ * `edges` may come from an earlier state, so an edge whose item is gone is skipped.
1512
+ */
1513
+ structuralDependentsOf({ patch, edges: given, checked = true }) {
1514
+ if (patch.operation !== "delete") return [];
1515
+ if (this.cascadingKinds().length === 0) return [];
1516
+ if (this.declaringSource(patch.kind, patch.path) !== void 0) return [];
1517
+ const edges = given ?? this.structuralEdges();
1518
+ if (edges.size === 0) return [];
1519
+ const seen = new Set([`${patch.kind}:${patch.path}`]);
1520
+ const ordered = [];
1521
+ const visit = ({ kind, path }) => {
1522
+ const spellings = new Set(this.refAliases(kind, path));
1523
+ const fullPath = this.leafPath(kind, path) ?? path;
1524
+ for (const edge of edges.get(kind) ?? []) {
1525
+ if (!(spellings.has(`${kind}:${edge.targetPath}`) || edge.targetPath.startsWith(`${fullPath}/`))) continue;
1526
+ const key = `${edge.kind}:${edge.path}`;
1527
+ if (seen.has(key)) continue;
1528
+ if (!this.state.snapshot.has(edge.kind, edge.path)) continue;
1529
+ seen.add(key);
1530
+ if (this.declaringSource(edge.kind, edge.path) === void 0) visit({
1531
+ kind: edge.kind,
1532
+ path: edge.path
1533
+ });
1534
+ ordered.push({
1535
+ kind: edge.kind,
1536
+ operation: "delete",
1537
+ path: edge.path
1538
+ });
1539
+ }
1540
+ };
1541
+ visit({
1542
+ kind: patch.kind,
1543
+ path: patch.path
1544
+ });
1545
+ if (checked) this.assertCascadeUnblocked({
1546
+ patch,
1547
+ cascade: ordered
1548
+ });
1549
+ return this.referrersFirst(ordered);
1550
+ }
1551
+ /**
1552
+ * What deleting `kind:path` takes with it, in the order the delete writes them, whether or not the
1553
+ * delete would be refused. {@link EntityKind.structuralBody} decides what counts.
1554
+ */
1555
+ cascadeOf({ kind, path }) {
1556
+ return this.structuralDependentsOf({
1557
+ patch: {
1558
+ kind,
1559
+ operation: "delete",
1560
+ path
1561
+ },
1562
+ checked: false
1563
+ }).flatMap((each) => each.operation === "delete" ? [{
1564
+ kind: each.kind,
1565
+ path: each.path
1566
+ }] : []);
1567
+ }
1568
+ /**
1569
+ * Refuse a delete whose cascade takes something that an entity outside it still refers to, naming
1570
+ * the entity the caller deleted. Without this the refusal comes from the dependent's own delete, and
1571
+ * names an entity the caller never asked to delete. A referrer of the target itself is left to the
1572
+ * delete's own rule.
1573
+ */
1574
+ assertCascadeUnblocked({ patch, cascade }) {
1575
+ if (!("path" in patch)) return;
1576
+ const keyOf = (target) => {
1577
+ const { kind, qualifiedPath } = splitRef(target);
1578
+ return `${kind}:${qualifiedPath}`;
1579
+ };
1580
+ const target = `${patch.kind}:${patch.path}`;
1581
+ const going = new Set([target, ...cascade.map((each) => `${each.kind}:${"path" in each ? each.path : ""}`)]);
1582
+ const snapshot = this.state.snapshot;
1583
+ for (const each of cascade) {
1584
+ if (!("path" in each)) continue;
1585
+ if (this.declaringSource(each.kind, each.path) !== void 0) continue;
1586
+ const blockers = this.referrersOf(snapshot, each.kind, each.path).filter((referrer) => !going.has(keyOf(referrer))).filter((referrer) => !isMintedAt(snapshot, referrer));
1587
+ if (blockers.length === 0) continue;
1588
+ rejectReferrers({
1589
+ message: `Cannot delete ${target} — it would also delete ${each.kind}:${each.path}, which ${blockers.join(", ")} still ${blockers.length === 1 ? "references" : "reference"}.`,
1590
+ referrers: blockers
1591
+ });
1592
+ }
1593
+ }
1594
+ /**
1595
+ * `deletes` reordered so each one comes after every other one whose entity still refers to it, by
1596
+ * any ref, since each delete is refused while something still points at it. The structural walk
1597
+ * already puts a dependent before what it depends on; this also covers a plain ref between two
1598
+ * dependents. Members of a cycle keep their order, and the delete that meets the cycle refuses.
1599
+ */
1600
+ referrersFirst(deletes) {
1601
+ const keyOf = (target) => {
1602
+ const { kind, qualifiedPath } = splitRef(target);
1603
+ return `${kind}:${qualifiedPath}`;
1604
+ };
1605
+ const remaining = new Map(deletes.map((each) => [`${each.kind}:${"path" in each ? each.path : ""}`, each]));
1606
+ const sorted = [];
1607
+ const referredToFromRemaining = (key) => {
1608
+ const { kind, qualifiedPath } = splitRef(key);
1609
+ return this.referrersOf(this.state.snapshot, kind, qualifiedPath).some((referrer) => keyOf(referrer) !== key && remaining.has(keyOf(referrer)));
1610
+ };
1611
+ while (remaining.size > 0) {
1612
+ const next = [...remaining.keys()].find((key) => !referredToFromRemaining(key));
1613
+ if (next === void 0) {
1614
+ sorted.push(...remaining.values());
1615
+ break;
1616
+ }
1617
+ const each = remaining.get(next);
1618
+ if (each) sorted.push(each);
1619
+ remaining.delete(next);
1620
+ }
1621
+ return sorted;
1622
+ }
1623
+ /**
1415
1624
  * Whether patches on kinds that name no source can change the rows a derive mints between `before`
1416
1625
  * and `after`: they wrote a pin (an edit to a minted row lands in its pin's `options`), or a row
1417
1626
  * they touched points at a source, changes which source-qualified refs it holds, or appears or
@@ -163,7 +163,9 @@ GI extends GroupInputs = Record<never, never>> = Omit<RefSchema, 'extend'> & {
163
163
  /** Create an item of this kind — `Component.create(config, 'Button', {…})`. Typed body; returns
164
164
  * the config for chaining. (The typed twin of `config.apply({kind, operation:'create', …})`.) */
165
165
  create<C extends Config>(config: C, path: string, data: CreateInput<F>): C; /** Update an item — a partial patch merged over the current value. */
166
- update<C extends Config>(config: C, path: string, data: Partial<z.infer<F>>): C; /** Delete an item (refused while other entities still reference it). */
166
+ update<C extends Config>(config: C, path: string, data: Partial<z.infer<F>>): C;
167
+ /** Delete an item, with whatever cannot exist without it ({@link EntityKind.structuralBody}). Refused
168
+ * while anything else still references it. */
167
169
  delete<C extends Config>(config: C, path: string): C; /** Rename an item's path — references cascade. */
168
170
  rename<C extends Config>(config: C, path: string, to: string): C;
169
171
  /**
@@ -321,7 +323,8 @@ declare function defineEntity<F extends FieldsSchema, G extends FieldsSchema = z
321
323
  /** Fields holding prose with mentions, authored through the class's `text` tag — see
322
324
  * {@link EntityKind.mentionFields}. */
323
325
  mentionFields?: readonly string[]; /** Cross-item rules the kind holds — see {@link EntityKind.invariants}. */
324
- invariants?: (config: Config) => string[];
326
+ invariants?: (config: Config) => string[]; /** See {@link EntityKind.structuralBody}. */
327
+ structuralBody?: (data: unknown) => Record<string, unknown>;
325
328
  /**
326
329
  * For a source kind that can be resolved: the two schemas its resolutions are validated against —
327
330
  * `{ resolved, unavailable }`. Declare it alongside `sourceIdentity` for a kind a config lists in its
@@ -132,7 +132,8 @@ function defineEntity(spec) {
132
132
  uniqueLeaves: spec.uniqueLeaves,
133
133
  authoredValue: spec.authoredValue,
134
134
  mentionFields: spec.mentionFields,
135
- invariants: spec.invariants
135
+ invariants: spec.invariants,
136
+ structuralBody: spec.structuralBody
136
137
  });
137
138
  const schema = refTo(spec.kind);
138
139
  /**
@@ -37,8 +37,12 @@ interface RenamePlan {
37
37
  }
38
38
  interface DeletePlan {
39
39
  readonly path: string;
40
- /** What still points at the entity. Each has to move or go before the delete is allowed. */
40
+ /** What still points at the entity. Each has to move or go before the delete is allowed, unless it
41
+ * is in `cascade`. */
41
42
  readonly dependents: readonly EntityAddress[];
43
+ /** What the delete takes with it, in the order it deletes them, ahead of the entity itself. Reported
44
+ * even when the delete would be refused, as what it would take. */
45
+ readonly cascade: readonly EntityAddress[];
42
46
  /** Why the delete would be refused. `undefined` when it would go through. */
43
47
  readonly blocked?: string;
44
48
  }
@@ -66,9 +66,14 @@ function planDelete({ config, kind, path }) {
66
66
  operation: "delete",
67
67
  path
68
68
  }]));
69
+ const cascade = config.cascadeOf({
70
+ kind,
71
+ path
72
+ });
69
73
  return {
70
74
  path,
71
75
  dependents: addresses(config.dependentsOf(kind, path)),
76
+ cascade,
72
77
  ...blocked === void 0 ? {} : { blocked }
73
78
  };
74
79
  }
@@ -338,6 +338,13 @@ interface EntityKind {
338
338
  * a stored artifact stays loadable.
339
339
  */
340
340
  readonly invariants?: (config: Config) => string[];
341
+ /**
342
+ * The slice of an item's stored fields whose refs it cannot exist without. Deleting the entity one
343
+ * of these refs names, or an entity whose path it is nested under, deletes the item too, in the
344
+ * same changeset and ahead of the entity. A kind that declares nothing here keeps a plain referrer,
345
+ * which refuses the delete. `group-delete` does not cascade.
346
+ */
347
+ readonly structuralBody?: (data: unknown) => Record<string, unknown>;
341
348
  /**
342
349
  * Rewrite one name wherever this kind's data holds it as a key — the cascade half of
343
350
  * {@link nameEdges}, applied by `Config.apply`'s rename alongside `rewriteRefs`.
@@ -32,7 +32,7 @@ function titleCase(kind) {
32
32
  /** Derive a kind's immutable metadata from its field schemas — the value a `defineEntity`/
33
33
  * `defineDerivedEntity` attaches to its class and a `Config.register` stores in `#entities`. */
34
34
  function buildKind(spec) {
35
- const { name, prepareLinkedBody, rebaseLinkedBody, layerKeys, fields, groupFields, subEntities = {}, computed, label, labelPlural, schemas: userSchemas, derived, sourceIdentity, sourceOrigin, authoredRefs, linkable, localGroupFields, emergentGroups, singleton = false, bareMember, sourceResolution, bulkAdopts, bulkOverlay, bulkDirectives, bulkOverlayWrite, nameEdges, rewriteName, styleBags, uniqueLeaves, authoredValue, mentionFields, invariants } = spec;
35
+ const { name, prepareLinkedBody, rebaseLinkedBody, layerKeys, fields, groupFields, subEntities = {}, computed, label, labelPlural, schemas: userSchemas, derived, sourceIdentity, sourceOrigin, authoredRefs, linkable, localGroupFields, emergentGroups, singleton = false, bareMember, sourceResolution, bulkAdopts, bulkOverlay, bulkDirectives, bulkOverlayWrite, nameEdges, rewriteName, styleBags, uniqueLeaves, authoredValue, mentionFields, invariants, structuralBody } = spec;
36
36
  const allComputed = {
37
37
  sources: ({ member, config }) => sourceSlugs(member, config),
38
38
  borrowedFrom: ({ config, path }) => config.borrowedFrom(name, path),
@@ -82,7 +82,8 @@ function buildKind(spec) {
82
82
  ...uniqueLeaves ? { uniqueLeaves } : {},
83
83
  ...authoredValue ? { authoredValue } : {},
84
84
  ...mentionFields?.length ? { mentionFields } : {},
85
- ...invariants ? { invariants } : {}
85
+ ...invariants ? { invariants } : {},
86
+ ...structuralBody ? { structuralBody } : {}
86
87
  };
87
88
  }
88
89
  /** Every operation a kind exposes — its merged `schemas` map, flattened to `{ kind, verb, ... }`.
@@ -150,7 +150,7 @@ function defaultSchemas(kind, declaredFields, groupFields, subEntities, { single
150
150
  input: z.object({ path: PATH }),
151
151
  readOnly: false,
152
152
  label: "Delete",
153
- description: `Delete the ${kind} at \`path\` — refused while other entities still reference it.`,
153
+ description: `Delete the ${kind} at \`path\`, together with any entity that cannot exist without it, such as a token aliasing it or a component whose layers render it. Refused while anything else still references it; \`delete-plan\` names both first.`,
154
154
  scope: "item",
155
155
  approval: "destructive",
156
156
  handler: (i, c) => c.apply({
@@ -222,7 +222,7 @@ function defaultSchemas(kind, declaredFields, groupFields, subEntities, { single
222
222
  input: z.object({ path: PATH }),
223
223
  readOnly: true,
224
224
  label: "Plan a delete",
225
- description: `What deleting the ${kind} at \`path\` runs into — the entities still pointing at it, as data, and the refusal the delete would answer with. Same input as \`delete\`; writes nothing.`,
225
+ description: `What deleting the ${kind} at \`path\` would do: the entities still pointing at it, the ones the delete takes with it (\`cascade\`), and the refusal the delete would answer with. Same input as \`delete\`; writes nothing.`,
226
226
  scope: "item",
227
227
  title: (entity) => `Plan deleting ${entity}`,
228
228
  handler: (i, c) => planDelete({
@@ -183,7 +183,11 @@ declare class ConfigSession<C extends Config = Config> implements LiveConfig {
183
183
  * so a patch removed only here reappears on the next change notification — any peer edit, any
184
184
  * reconnect. See {@link ConfigSink.retract}.
185
185
  */
186
- drop(patchIds: readonly string[]): void;
186
+ drop(patchIds: readonly string[], {
187
+ patchesOnly
188
+ }?: {
189
+ patchesOnly?: boolean;
190
+ }): void;
187
191
  /** Hand the pending batch to the sink now. Resolves once the sink has taken it. */
188
192
  flush(): Promise<void>;
189
193
  /** Detach from the source. The config value remains readable — it just stops advancing. */
@@ -201,18 +201,21 @@ var ConfigSession = class {
201
201
  * so a patch removed only here reappears on the next change notification — any peer edit, any
202
202
  * reconnect. See {@link ConfigSink.retract}.
203
203
  */
204
- drop(patchIds) {
204
+ drop(patchIds, { patchesOnly = false } = {}) {
205
205
  if (patchIds.length === 0) return;
206
- const refused = new Set(patchIds);
206
+ const named = new Set(patchIds);
207
207
  const before = this.#config.pending;
208
- const kept = before.filter((entry) => !refused.has(entry.id) && !(entry.changeset !== void 0 && refused.has(entry.changeset)));
208
+ const refused = new Set(before.filter((entry) => named.has(entry.id) || !patchesOnly && entry.changeset !== void 0 && named.has(entry.changeset)).map((entry) => entry.id));
209
+ const kept = before.filter((entry) => !refused.has(entry.id));
209
210
  if (kept.length === before.length) return;
210
211
  const neverSent = new Set(this.#uncommitted.map((entry) => entry.id));
211
212
  this.#uncommitted = this.#uncommitted.filter((entry) => !refused.has(entry.id));
212
213
  this.#config = this.#afterRebase(this.#config.withDraft(kept), "drop");
213
214
  this.#notify();
214
215
  const survived = new Set(this.#config.pending.map((entry) => entry.id));
215
- this.#retract(before.map((entry) => entry.id).filter((id) => !survived.has(id) && !neverSent.has(id)));
216
+ const removed = new Set(before.map((entry) => entry.id).filter((id) => !survived.has(id)));
217
+ this.#uncommitted = this.#uncommitted.filter((entry) => !removed.has(entry.id));
218
+ this.#retract([...removed].filter((id) => !neverSent.has(id)));
216
219
  }
217
220
  /** Write a removal through to the sink, deferring any id whose own commit is still in flight. */
218
221
  #retract(ids) {