docs(templates): derive-on-compose phase status + resume plan
Companion to the design doc — captures current state, the four
decisions already made, what's done (phase 1, commit 5615f3d),
and a full play-by-play for phases 2 through 9 with exact files,
methods, and tests to touch. Written so a future session after
context compaction can pick up cleanly.
This commit is contained in:
270
docs/plans/2026-05-12-derive-on-compose-status.md
Normal file
270
docs/plans/2026-05-12-derive-on-compose-status.md
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
# Derive-on-compose: implementation status + remaining plan
|
||||||
|
|
||||||
|
> **For Claude resuming after compaction:** This is the single starting point.
|
||||||
|
> Read it in full. Then read the companion design doc
|
||||||
|
> `2026-05-12-derive-on-compose-design.md` for the architectural rationale.
|
||||||
|
> Do NOT make code changes until you've also confirmed which phase the user
|
||||||
|
> wants next.
|
||||||
|
|
||||||
|
## Where we are
|
||||||
|
|
||||||
|
**Branch**: `feature/templates-folder-hierarchy`. Already pushed.
|
||||||
|
|
||||||
|
**Last commit on this feature**: `5615f3d` — *Phase 1 complete, additive
|
||||||
|
schema only*.
|
||||||
|
|
||||||
|
**All test suites currently green**:
|
||||||
|
- `tests/ScadaLink.CentralUI.Tests` — 159 passing
|
||||||
|
- `tests/ScadaLink.SiteRuntime.Tests` — 129 passing
|
||||||
|
- `tests/ScadaLink.TemplateEngine.Tests` — 199 passing
|
||||||
|
|
||||||
|
## Design decisions already made (from the brainstorm)
|
||||||
|
|
||||||
|
User picked the **full Aveva model** with all four customization scopes:
|
||||||
|
|
||||||
|
- **Naming**: dot-separated → `Pump.TempSensor`
|
||||||
|
- **Delete base with derivatives**: block with a list of the dependents
|
||||||
|
- **Migration of existing compositions**: auto-migrate all on the EF Core
|
||||||
|
migration step in Phase 3
|
||||||
|
- **Tree UX**: derived templates hidden by default; toggle to reveal
|
||||||
|
- **Customization scope**: override attribute values, override script bodies,
|
||||||
|
add new attrs/scripts per slot, lock fields against override
|
||||||
|
|
||||||
|
## Done — Phase 1: Additive schema
|
||||||
|
|
||||||
|
Commits: `6854843` (design doc) + `a968cef` (decisions recorded) + `5615f3d`.
|
||||||
|
|
||||||
|
Files touched in `5615f3d`:
|
||||||
|
|
||||||
|
- `src/ScadaLink.Commons/Entities/Templates/Template.cs`
|
||||||
|
- Added `IsDerived: bool`
|
||||||
|
- Added `OwnerCompositionId: int?` (plain int — not an EF nav prop)
|
||||||
|
- `src/ScadaLink.Commons/Entities/Templates/TemplateAttribute.cs`
|
||||||
|
- Added `IsInherited: bool`
|
||||||
|
- Added `LockedInDerived: bool`
|
||||||
|
- `src/ScadaLink.Commons/Entities/Templates/TemplateScript.cs`
|
||||||
|
- Same two fields
|
||||||
|
- `src/ScadaLink.ConfigurationDatabase/Migrations/20260512121446_AddDerivedTemplateFields.cs`
|
||||||
|
- EF Core migration. Six new columns, all NOT NULL DEFAULT 0 (or nullable
|
||||||
|
int). No data transform — existing rows get defaults.
|
||||||
|
- `ScadaLinkDbContextModelSnapshot.cs` regenerated.
|
||||||
|
|
||||||
|
**No behavior changes**. New fields are never read or written yet.
|
||||||
|
|
||||||
|
## Remaining — phases 2 through 9
|
||||||
|
|
||||||
|
Each phase ships independently. The cumulative behavior change fully lands at
|
||||||
|
phase 3.
|
||||||
|
|
||||||
|
### Phase 2: Compose flow change
|
||||||
|
|
||||||
|
`TemplateService.AddCompositionAsync` currently creates a `TemplateComposition`
|
||||||
|
that references a base template directly. Change it so NEW compositions:
|
||||||
|
|
||||||
|
1. Create a new `Template` entity:
|
||||||
|
- `Name = $"{parentTemplate.Name}.{instanceName}"`
|
||||||
|
- `ParentTemplateId = composedTemplateId` (the base)
|
||||||
|
- `IsDerived = true`
|
||||||
|
- `FolderId = null` (or inherit from parent? — pick null; derived
|
||||||
|
templates aren't visible in the tree)
|
||||||
|
2. Copy the base's `Attributes` into the new template marked
|
||||||
|
`IsInherited = true`. Same for `Scripts` and `Alarms`.
|
||||||
|
3. Create the `TemplateComposition` with `ComposedTemplateId = newTemplate.Id`
|
||||||
|
(the derived, not the base).
|
||||||
|
4. Set the derived template's `OwnerCompositionId = composition.Id` (back-ref).
|
||||||
|
|
||||||
|
Files to touch:
|
||||||
|
|
||||||
|
- `src/ScadaLink.TemplateEngine/TemplateService.cs` — extend
|
||||||
|
`AddCompositionAsync` (currently around line 290+).
|
||||||
|
- `src/ScadaLink.TemplateEngine/TemplateService.cs` — also extend
|
||||||
|
`DeleteCompositionAsync` to cascade-delete the derived template.
|
||||||
|
- `src/ScadaLink.TemplateEngine/TemplateService.cs` — extend `DeleteTemplateAsync`
|
||||||
|
to block when the template is composed (already happens) AND when
|
||||||
|
derivatives exist; reword the error.
|
||||||
|
- Validation: derived template names must remain unique (the base existing
|
||||||
|
uniqueness index on `Template.Name` covers this — but if `Pump.TempSensor`
|
||||||
|
collides with a user-created template of that name, the add fails. Add a
|
||||||
|
guard with a clear error message.)
|
||||||
|
|
||||||
|
Tests to add (`tests/ScadaLink.TemplateEngine.Tests/`):
|
||||||
|
|
||||||
|
- AddCompositionAsync creates a derived template.
|
||||||
|
- The derived template inherits attributes / scripts with `IsInherited = true`.
|
||||||
|
- DeleteCompositionAsync cascades to remove the derived template.
|
||||||
|
- Deleting a base with derivatives is blocked with a message that lists them.
|
||||||
|
|
||||||
|
### Phase 3: Migration of existing compositions
|
||||||
|
|
||||||
|
EF Core data migration that runs on next startup. Two paths:
|
||||||
|
|
||||||
|
**Option A (simpler) — write the migration as a custom SQL/code step**
|
||||||
|
|
||||||
|
In `src/ScadaLink.ConfigurationDatabase/Migrations/`, generate a new migration
|
||||||
|
called e.g. `MigrateCompositionsToDerived`. In its `Up` method, use raw
|
||||||
|
`migrationBuilder.Sql(...)` or a delegate to:
|
||||||
|
|
||||||
|
```
|
||||||
|
FOREACH composition IN TemplateComposition WHERE ComposedTemplate.IsDerived = false:
|
||||||
|
derived := INSERT INTO Templates (
|
||||||
|
Name = parent.Name || '.' || composition.InstanceName,
|
||||||
|
ParentTemplateId = composition.ComposedTemplateId,
|
||||||
|
IsDerived = true,
|
||||||
|
OwnerCompositionId = composition.Id)
|
||||||
|
INSERT INTO TemplateAttributes (Name, Value, DataType, IsLocked, Description,
|
||||||
|
DataSourceReference, IsInherited=true,
|
||||||
|
LockedInDerived=false, TemplateId=derived.Id)
|
||||||
|
FROM TemplateAttributes WHERE TemplateId = composition.ComposedTemplateId
|
||||||
|
same for TemplateScripts and TemplateAlarms
|
||||||
|
UPDATE TemplateComposition SET ComposedTemplateId = derived.Id WHERE Id = composition.Id
|
||||||
|
```
|
||||||
|
|
||||||
|
Run as raw SQL to keep the migration deterministic across providers (SQL
|
||||||
|
Server vs SQLite if used in dev).
|
||||||
|
|
||||||
|
**Option B — service-layer one-time migration**
|
||||||
|
|
||||||
|
Add a startup hook that runs once (controlled by a flag column) and uses
|
||||||
|
`TemplateService` directly. Cleaner for complex logic but more code.
|
||||||
|
|
||||||
|
**Pick Option A** unless the logic doesn't fit comfortably in SQL.
|
||||||
|
|
||||||
|
Validation post-migration: every `TemplateComposition.ComposedTemplateId`
|
||||||
|
should now reference a template with `IsDerived = true`.
|
||||||
|
|
||||||
|
Tests: integration test that seeds pre-migration data, runs the migration,
|
||||||
|
asserts the new shape.
|
||||||
|
|
||||||
|
### Phase 4: Inherit/override resolution in flattening
|
||||||
|
|
||||||
|
`src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs`:
|
||||||
|
|
||||||
|
- The service currently walks `Template.ParentTemplateId` chains in
|
||||||
|
`ResolveInheritedScripts`. That same chain now naturally includes the
|
||||||
|
base→derived link. No special handling needed for the chain itself.
|
||||||
|
- BUT when a derived template's attribute is `IsInherited = true`, the
|
||||||
|
effective value comes from the base (resolved through the chain). If
|
||||||
|
`IsInherited = false`, the derived's own value is the override and wins.
|
||||||
|
- Treat `LockedInDerived` as an enforcement check: if a derived row has
|
||||||
|
`IsInherited = false` (override) AND the base row has `LockedInDerived =
|
||||||
|
true`, emit a flattening validation error.
|
||||||
|
|
||||||
|
Tests: round-trip flattening with overridden + inherited combinations.
|
||||||
|
|
||||||
|
### Phase 5: Lock semantics enforcement
|
||||||
|
|
||||||
|
In `TemplateService.UpdateAttributeAsync` and `UpdateScriptAsync`:
|
||||||
|
|
||||||
|
- If the target template has `IsDerived = true`, look up the base via
|
||||||
|
`ParentTemplateId`.
|
||||||
|
- If the same-named attribute on the base has `LockedInDerived = true`,
|
||||||
|
reject the update with a clear error: *"This attribute is locked by the
|
||||||
|
base template '$Sensor' and cannot be overridden."*
|
||||||
|
- If the update is being applied on a base template AND there are
|
||||||
|
derivatives that have `IsInherited = false` for this field (overrides), the
|
||||||
|
caller may want to know — surface a warning result. Out of scope for now;
|
||||||
|
flag as TODO.
|
||||||
|
|
||||||
|
Tests: lock-blocked updates fail with the expected message.
|
||||||
|
|
||||||
|
### Phase 6: Template tree UI — hide derived
|
||||||
|
|
||||||
|
`src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor`:
|
||||||
|
|
||||||
|
- Filter `_templates` to exclude `t.IsDerived` by default.
|
||||||
|
- Add a "Show derived templates" checkbox at the top.
|
||||||
|
- When shown, render derived templates indented under their base
|
||||||
|
(`t.ParentTemplateId == base.Id && t.IsDerived`).
|
||||||
|
- Compositions tab on `TemplateEdit` should link each composition row to the
|
||||||
|
derived template's edit page (the existing link likely points to the base
|
||||||
|
— needs to follow `composition.ComposedTemplateId` which after phase 3 is
|
||||||
|
the derived).
|
||||||
|
|
||||||
|
### Phase 7: Derived TemplateEdit UI
|
||||||
|
|
||||||
|
`src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor`:
|
||||||
|
|
||||||
|
- Top banner when `_selectedTemplate.IsDerived`:
|
||||||
|
*"Derived from `[base.Name]` — composed inside `[parent.Name]` as
|
||||||
|
`[composition.InstanceName]`."*
|
||||||
|
- Attributes table: new column showing **Inherited** or **Overridden** badge.
|
||||||
|
- Locked-from-base attributes (base has `LockedInDerived = true`) render
|
||||||
|
readonly with a 🔒 icon and tooltip *"Locked by base — cannot override."*
|
||||||
|
- Editing an inherited row flips it to override (`IsInherited = false`).
|
||||||
|
- "Revert to base" button per row when `IsInherited = false` — clears the
|
||||||
|
override; row reverts to the base value (`IsInherited = true`, value
|
||||||
|
cleared).
|
||||||
|
- Adding a new attribute/script: creates a row with `IsInherited = false`
|
||||||
|
(it's not from the base).
|
||||||
|
- Removing an inherited row should be blocked (the row exists because it's
|
||||||
|
on the base; can't remove it on the derived). Removing an own-added row
|
||||||
|
deletes normally.
|
||||||
|
|
||||||
|
### Phase 8: Base TemplateEdit UI — lock toggle
|
||||||
|
|
||||||
|
Same `TemplateEdit.razor`, when editing a base template:
|
||||||
|
|
||||||
|
- New column on Attributes table: a 🔒 toggle representing `LockedInDerived`.
|
||||||
|
- Same on Scripts table.
|
||||||
|
- Tooltip on the toggle: *"Lock this against per-slot override in derived
|
||||||
|
templates."*
|
||||||
|
|
||||||
|
When a base template is open and the user toggles `LockedInDerived = true`
|
||||||
|
while derived templates exist with overrides, surface a warning toast:
|
||||||
|
*"N derived templates currently override this. Existing overrides won't be
|
||||||
|
silently reverted but deploy validation will flag them."*
|
||||||
|
|
||||||
|
### Phase 9: Editor metadata simplification
|
||||||
|
|
||||||
|
Now that derived templates have a single owner, `BuildParentContextsAsync`
|
||||||
|
in `TemplateEdit.razor` simplifies:
|
||||||
|
|
||||||
|
- **If the open template `IsDerived`**: parent = the single template that
|
||||||
|
owns the `OwnerCompositionId`. Always one — no picker.
|
||||||
|
- **If the open template is a base** (`!IsDerived`): suppress `Parent.*`
|
||||||
|
assistance entirely. Add a SCADA008 hint diagnostic on `Parent.*` use:
|
||||||
|
*"Parent access on a base template is ambiguous — override this script in
|
||||||
|
a derived template instead."*
|
||||||
|
|
||||||
|
Remove the multi-parent `select` dropdown that was added in commit `0139c9c`
|
||||||
|
(or keep it but guard with a check that should never fire after phase 3).
|
||||||
|
|
||||||
|
`GetTemplatesComposingAsync` (added in `0139c9c`) is still useful elsewhere —
|
||||||
|
keep it.
|
||||||
|
|
||||||
|
## How to resume
|
||||||
|
|
||||||
|
After compaction, the future session should:
|
||||||
|
|
||||||
|
1. Read this file. Read the design doc
|
||||||
|
`2026-05-12-derive-on-compose-design.md` for context.
|
||||||
|
2. Run `git log --oneline -15` to confirm the branch is at `5615f3d` or
|
||||||
|
later.
|
||||||
|
3. Check test status: `dotnet test` across the three suites named above.
|
||||||
|
4. Ask the user which phase to tackle next (or proceed from phase 2 if the
|
||||||
|
user has explicitly said "continue").
|
||||||
|
5. Each phase is its own commit. Prefer phase 2 + phase 3 together because
|
||||||
|
phase 2 alone leaves new and existing compositions in different shapes
|
||||||
|
until phase 3's migration runs.
|
||||||
|
|
||||||
|
## Pre-existing capability worth knowing
|
||||||
|
|
||||||
|
The script-scope editor work (commits `3ed05f0` → `0139c9c`) is already in
|
||||||
|
place. Scripts on derived templates will automatically benefit from the
|
||||||
|
single-parent context simplification in phase 9. The runtime accessors
|
||||||
|
(`Attributes` / `Children` / `Parent` / `Scope`) defined there continue to
|
||||||
|
work unchanged — the canonical-name paths they resolve are stable across
|
||||||
|
the derivation change.
|
||||||
|
|
||||||
|
The multi-parent picker introduced in `0139c9c` becomes mostly dormant
|
||||||
|
after phase 3 (no template should be composed by multiple parents anymore
|
||||||
|
through the new flow). Plan to remove it in phase 9.
|
||||||
|
|
||||||
|
## Quick sanity script (run before phase 2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short # should be clean
|
||||||
|
git log --oneline -5 # top should include 5615f3d
|
||||||
|
dotnet build # 0 errors, 0 warnings
|
||||||
|
dotnet test tests/ScadaLink.TemplateEngine.Tests/ScadaLink.TemplateEngine.Tests.csproj
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user