From 58ed39b5ca1a0edd3a436558885da3123da84351 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 00:00:06 -0400 Subject: [PATCH 1/2] feat: atlas perspectives -- domain type + pure helpers (goal 0095 slice 1, ADR-0041) Perspective per ADR-0041's shape (explicit MemberCardIDs/MemberLinkIDs, no DeletedAt -- delete is immediate, not soft). Pure helpers: AncestorChain (ancestry-closure computation), DescendantsAndSelf (removal cascade), FilterByPerspective (membership + the link-renders-iff-self-and-both-endpoints rule), DiffPerspectives (set difference, order-stable). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- internal/contract/contract.json | 43 ++ internal/contract/schemas/atlas.schema.json | 43 ++ internal/domain/atlas/builtin.go | 12 + internal/domain/atlas/perspective.go | 218 +++++++++ internal/domain/atlas/perspective_test.go | 197 ++++++++ internal/services/atlassvc/atlascard.go | 6 + internal/services/atlassvc/atlaslink.go | 6 + internal/services/atlassvc/atlasnote.go | 6 + .../services/atlassvc/atlasperspective.go | 424 ++++++++++++++++++ .../atlasperspective_lifecycle_test.go | 400 +++++++++++++++++ .../atlassvc/atlasperspective_test.go | 273 +++++++++++ internal/services/atlassvc/atlasservice.go | 20 +- .../services/atlassvc/atlasservice_builtin.go | 44 ++ .../services/atlassvc/atlasservice_export.go | 130 +++++- .../atlassvc/atlasservice_tombstone.go | 25 +- internal/services/atlassvc/atlassession.go | 9 + internal/services/seeding/seed_fingerprint.go | 10 + 17 files changed, 1849 insertions(+), 17 deletions(-) create mode 100644 internal/domain/atlas/perspective.go create mode 100644 internal/domain/atlas/perspective_test.go create mode 100644 internal/services/atlassvc/atlasperspective.go create mode 100644 internal/services/atlassvc/atlasperspective_lifecycle_test.go create mode 100644 internal/services/atlassvc/atlasperspective_test.go diff --git a/internal/contract/contract.json b/internal/contract/contract.json index 78e85a0f..5d0fec43 100644 --- a/internal/contract/contract.json +++ b/internal/contract/contract.json @@ -235,6 +235,43 @@ "label" ] }, + "exportedPerspective": { + "properties": { + "id": { + "type": "string" + }, + "spaceID": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "order": { + "type": "integer" + }, + "memberCardIDs": { + "items": { + "type": "string" + }, + "type": "array" + }, + "memberLinkIDs": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "order" + ] + }, "exportedPosition": { "properties": { "x": { @@ -279,6 +316,12 @@ "$ref": "#/$defs/exportedLink" }, "type": "array" + }, + "perspectives": { + "items": { + "$ref": "#/$defs/exportedPerspective" + }, + "type": "array" } }, "additionalProperties": false, diff --git a/internal/contract/schemas/atlas.schema.json b/internal/contract/schemas/atlas.schema.json index 1620efd7..9e280c15 100644 --- a/internal/contract/schemas/atlas.schema.json +++ b/internal/contract/schemas/atlas.schema.json @@ -200,6 +200,43 @@ "label" ] }, + "exportedPerspective": { + "properties": { + "id": { + "type": "string" + }, + "spaceID": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "order": { + "type": "integer" + }, + "memberCardIDs": { + "items": { + "type": "string" + }, + "type": "array" + }, + "memberLinkIDs": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "order" + ] + }, "exportedPosition": { "properties": { "x": { @@ -244,6 +281,12 @@ "$ref": "#/$defs/exportedLink" }, "type": "array" + }, + "perspectives": { + "items": { + "$ref": "#/$defs/exportedPerspective" + }, + "type": "array" } }, "additionalProperties": false, diff --git a/internal/domain/atlas/builtin.go b/internal/domain/atlas/builtin.go index 0a408ccf..a023790d 100644 --- a/internal/domain/atlas/builtin.go +++ b/internal/domain/atlas/builtin.go @@ -227,6 +227,18 @@ func BuiltInLinks() []Link { } } +// BuiltInPerspectives returns the seeded example perspectives (ADR- +// 0041, goal 0095) -- deliberately EMPTY in this slice (the seeded +// three-perspective reference-architecture example is goal 0095's +// slice 3): the reconcile/fingerprint plumbing exists and runs on +// every startup like every other family's, it just has nothing to +// insert yet. Zero Perspectives is also the product default itself +// (the "everything" view is the absence of a record), so an empty +// slice here changes nothing observable. +func BuiltInPerspectives() []Perspective { + return nil +} + // RetiredBuiltInKindIDs names a built-in Kind ID that once shipped in // BuiltInKinds() and no longer does -- atlassvc's reconcile pass uses // this to remove/tombstone a leftover copy from an existing install, diff --git a/internal/domain/atlas/perspective.go b/internal/domain/atlas/perspective.go new file mode 100644 index 00000000..a899df99 --- /dev/null +++ b/internal/domain/atlas/perspective.go @@ -0,0 +1,218 @@ +package atlas + +import ( + "fmt" + "strings" + "time" + + "github.com/alicoding/mill/internal/domain/seedorigin" +) + +// Perspective is a named, ordered view over one container's (SpaceID's) +// live card set (ADR-0041, goal 0095): membership is explicit and +// stored -- both which cards (MemberCardIDs) and which links +// (MemberLinkIDs) render in this view -- never derived from a query, +// so a target-state connection between two currently-existing systems +// can never leak into a view it wasn't deliberately added to. The +// default "everything" view is the ABSENCE of a Perspective record: +// zero Perspectives over a space means zero behavior change. Order +// places this perspective within its SpaceID's own ordered set (e.g. +// "Current", "Interim", "Target"); it is meaningless compared across +// different SpaceID values. Deliberately carries NO DeletedAt -- +// unlike Card/Note (goal 0093's soft-delete-with-undo), a Perspective +// delete is immediate: cheap to recreate, so no tombstone/undo +// lifecycle exists for it in v1. +type Perspective struct { + ID string + SpaceID string + Name string + Description string + Order int + // MemberCardIDs is the stored set of cards this perspective shows. + // Closed under ancestry by the service layer's AddToPerspective (a + // member's containing chain up to SpaceID joins with it), never by + // this package -- membership mutation is a service concern. + MemberCardIDs []string + // MemberLinkIDs is the stored set of links this perspective shows. + // A link still only actually RENDERS when both its endpoints are + // ALSO members -- see FilterByPerspective. + MemberLinkIDs []string + CreatedAt time.Time + UpdatedAt time.Time + BuiltIn bool + Seed seedorigin.Origin +} + +// ValidatePerspective checks p is well-formed: a non-empty Name, and +// every member card spatially consistent with SpaceID -- actually +// inside the subtree SpaceID roots (AncestorChain returning non-nil), +// or SpaceID == "" (the true root, under which every card lives). +// byID must map every existing card's ID to itself (the service +// layer's full card set, the same convention WouldCycle's own ancestry +// walk uses); whether SpaceID itself names an existing card is +// referential-existence checking, left to the service layer per +// docs/goals/0061's Validation note. +func ValidatePerspective(p Perspective, byID map[string]Card) error { + if strings.TrimSpace(p.Name) == "" { + return fmt.Errorf("a perspective needs a name") + } + for _, cardID := range p.MemberCardIDs { + if AncestorChain(byID, cardID, p.SpaceID) == nil { + return fmt.Errorf("card %q is not inside space %q", cardID, p.SpaceID) + } + } + return nil +} + +// AncestorChain returns every card between cardID and SpaceID, +// exclusive of spaceID itself, inclusive of cardID, ordered from +// SpaceID's nearest child down to cardID -- the exact set ADR-0041's +// ancestry-closure rule adds to a perspective's membership in one step +// (AddToPerspective, and authoring-while-active), so a deeply nested +// card never appears in a filtered view floating with no visible +// container. spaceID == "" names the true root, which contains every +// card. Returns nil (never an empty non-nil slice for a genuine miss) +// when cardID does not actually live inside spaceID's subtree, or when +// a cycle elsewhere in the data makes the walk unable to terminate -- +// the caller's uniform signal to skip the add entirely. cardID == +// spaceID returns a non-nil empty slice: valid (the space contains +// itself), nothing to add. +func AncestorChain(byID map[string]Card, cardID, spaceID string) []string { + if cardID == "" { + return nil + } + chain := []string{} + seen := make(map[string]bool) + cur := cardID + for { + if seen[cur] { + return nil + } + seen[cur] = true + chain = append(chain, cur) + if cur == spaceID { + break + } + c, ok := byID[cur] + if !ok { + return nil + } + if c.ParentID == "" { + if spaceID == "" { + break // reached the true root, and spaceID IS the true root + } + return nil // walked off the root without ever finding spaceID + } + cur = c.ParentID + } + if spaceID != "" { + chain = chain[:len(chain)-1] // drop spaceID itself -- never a member of its own view + } + for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 { + chain[i], chain[j] = chain[j], chain[i] + } + return chain +} + +// DescendantsAndSelf returns cardID together with every card reachable +// by following ParentID links downward from it (its full containment +// subtree) -- the set RemoveFromPerspective's cascade removes in one +// step: a member card left behind after its own container drops out of +// a view would render with no visible parent context, so removing a +// container removes what it holds too. +func DescendantsAndSelf(byID map[string]Card, cardID string) map[string]bool { + out := map[string]bool{cardID: true} + childrenOf := make(map[string][]string, len(byID)) + for id, c := range byID { + childrenOf[c.ParentID] = append(childrenOf[c.ParentID], id) + } + queue := []string{cardID} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for _, child := range childrenOf[cur] { + if !out[child] { + out[child] = true + queue = append(queue, child) + } + } + } + return out +} + +// FilterByPerspective returns the subset of cards that are members of +// p, and the subset of links that RENDER under p: a link renders iff +// the link itself AND both its endpoints are members (ADR-0041's +// stored-not-derived link-membership rule) -- guards a target-state +// connection between two currently-existing systems from leaking into +// a perspective it was never added to. Input order is preserved in +// both outputs. Pure: never mutates cards, links, or p. Callers pass +// already-live (non-tombstoned) cards/links -- a member id naming a +// tombstoned/purged entity simply matches nothing and is silently +// excluded, the same posture every other Atlas read surface takes. +func FilterByPerspective(cards []Card, links []Link, p Perspective) ([]Card, []Link) { + memberCards := make(map[string]bool, len(p.MemberCardIDs)) + for _, id := range p.MemberCardIDs { + memberCards[id] = true + } + memberLinks := make(map[string]bool, len(p.MemberLinkIDs)) + for _, id := range p.MemberLinkIDs { + memberLinks[id] = true + } + outCards := make([]Card, 0, len(cards)) + for _, c := range cards { + if memberCards[c.ID] { + outCards = append(outCards, c) + } + } + outLinks := make([]Link, 0, len(links)) + for _, l := range links { + if memberLinks[l.ID] && memberCards[l.FromCardID] && memberCards[l.ToCardID] { + outLinks = append(outLinks, l) + } + } + return outCards, outLinks +} + +// PerspectiveDiff is the computable diff between two perspectives +// sharing the same live card set (ADR-0041's O(1) reference- +// architecture property, docs/goals/0095): pure set difference over +// stored membership, nothing derived or stored on disk. Re-parenting +// is deliberately never expressed here -- a card carries exactly one +// ParentID shared by every perspective, so containment itself can +// never differ per view (ADR-0041's own invariant; a revisit needs its +// own ADR). +type PerspectiveDiff struct { + AddedCardIDs []string + RemovedCardIDs []string + AddedLinkIDs []string + RemovedLinkIDs []string +} + +// DiffPerspectives computes what changed moving from -> to: a member +// present in "to" but not "from" is added, present in "from" but not +// "to" is removed. Order-stable: added ids follow "to"'s own +// MemberCardIDs/MemberLinkIDs order, removed ids follow "from"'s. +func DiffPerspectives(from, to Perspective) PerspectiveDiff { + return PerspectiveDiff{ + AddedCardIDs: setDiff(to.MemberCardIDs, from.MemberCardIDs), + RemovedCardIDs: setDiff(from.MemberCardIDs, to.MemberCardIDs), + AddedLinkIDs: setDiff(to.MemberLinkIDs, from.MemberLinkIDs), + RemovedLinkIDs: setDiff(from.MemberLinkIDs, to.MemberLinkIDs), + } +} + +// setDiff returns every id in a that is not in b, preserving a's order. +func setDiff(a, b []string) []string { + inB := make(map[string]bool, len(b)) + for _, id := range b { + inB[id] = true + } + out := []string{} + for _, id := range a { + if !inB[id] { + out = append(out, id) + } + } + return out +} diff --git a/internal/domain/atlas/perspective_test.go b/internal/domain/atlas/perspective_test.go new file mode 100644 index 00000000..ffd3b310 --- /dev/null +++ b/internal/domain/atlas/perspective_test.go @@ -0,0 +1,197 @@ +package atlas + +import ( + "reflect" + "testing" +) + +// cardTree builds a small byID map for ancestry tests: +// +// root +// child +// grandchild +// sibling +// other (unrelated root-level card, not under "root") +func cardTree() map[string]Card { + return map[string]Card{ + "root": {ID: "root", ParentID: ""}, + "child": {ID: "child", ParentID: "root"}, + "grandchild": {ID: "grandchild", ParentID: "child"}, + "sibling": {ID: "sibling", ParentID: "root"}, + "other": {ID: "other", ParentID: ""}, + } +} + +func TestAncestorChain_ReturnsChainFromSpaceToCard(t *testing.T) { + got := AncestorChain(cardTree(), "grandchild", "root") + want := []string{"child", "grandchild"} + if !reflect.DeepEqual(got, want) { + t.Errorf("AncestorChain(grandchild, root) = %v, want %v", got, want) + } +} + +func TestAncestorChain_DirectChild(t *testing.T) { + got := AncestorChain(cardTree(), "child", "root") + want := []string{"child"} + if !reflect.DeepEqual(got, want) { + t.Errorf("AncestorChain(child, root) = %v, want %v", got, want) + } +} + +func TestAncestorChain_CardEqualsSpace_ReturnsEmptyNotNil(t *testing.T) { + got := AncestorChain(cardTree(), "root", "root") + if got == nil { + t.Fatal("AncestorChain(root, root) = nil, want a non-nil empty slice") + } + if len(got) != 0 { + t.Errorf("AncestorChain(root, root) = %v, want empty", got) + } +} + +func TestAncestorChain_CardOutsideSpace_ReturnsNil(t *testing.T) { + if got := AncestorChain(cardTree(), "other", "root"); got != nil { + t.Errorf("AncestorChain(other, root) = %v, want nil (other is not under root)", got) + } +} + +func TestAncestorChain_TrueRootSpace_ContainsEveryCard(t *testing.T) { + got := AncestorChain(cardTree(), "grandchild", "") + want := []string{"root", "child", "grandchild"} + if !reflect.DeepEqual(got, want) { + t.Errorf("AncestorChain(grandchild, \"\") = %v, want %v", got, want) + } +} + +func TestAncestorChain_UnknownCard_ReturnsNil(t *testing.T) { + if got := AncestorChain(cardTree(), "does-not-exist", "root"); got != nil { + t.Errorf("AncestorChain(does-not-exist, root) = %v, want nil", got) + } +} + +func TestAncestorChain_EmptyCardID_ReturnsNil(t *testing.T) { + if got := AncestorChain(cardTree(), "", "root"); got != nil { + t.Errorf("AncestorChain(\"\", root) = %v, want nil", got) + } +} + +func TestAncestorChain_Cycle_ReturnsNilRatherThanHanging(t *testing.T) { + byID := map[string]Card{ + "a": {ID: "a", ParentID: "b"}, + "b": {ID: "b", ParentID: "a"}, + } + if got := AncestorChain(byID, "a", "root-never-reached"); got != nil { + t.Errorf("AncestorChain over a cycle = %v, want nil", got) + } +} + +func TestDescendantsAndSelf_ReturnsWholeSubtree(t *testing.T) { + got := DescendantsAndSelf(cardTree(), "root") + want := map[string]bool{"root": true, "child": true, "grandchild": true, "sibling": true} + if !reflect.DeepEqual(got, want) { + t.Errorf("DescendantsAndSelf(root) = %v, want %v", got, want) + } +} + +func TestDescendantsAndSelf_LeafReturnsOnlyItself(t *testing.T) { + got := DescendantsAndSelf(cardTree(), "grandchild") + want := map[string]bool{"grandchild": true} + if !reflect.DeepEqual(got, want) { + t.Errorf("DescendantsAndSelf(grandchild) = %v, want %v", got, want) + } +} + +func TestValidatePerspective_BlankName_Errors(t *testing.T) { + if err := ValidatePerspective(Perspective{Name: " "}, cardTree()); err == nil { + t.Error("ValidatePerspective with a blank name = nil error, want an error") + } +} + +func TestValidatePerspective_MemberOutsideSpace_Errors(t *testing.T) { + p := Perspective{Name: "Current", SpaceID: "root", MemberCardIDs: []string{"other"}} + if err := ValidatePerspective(p, cardTree()); err == nil { + t.Error("ValidatePerspective with a member outside its space = nil error, want an error") + } +} + +func TestValidatePerspective_MemberInsideSpace_Valid(t *testing.T) { + p := Perspective{Name: "Current", SpaceID: "root", MemberCardIDs: []string{"child", "grandchild"}} + if err := ValidatePerspective(p, cardTree()); err != nil { + t.Errorf("ValidatePerspective with members inside its space = %v, want nil", err) + } +} + +func TestFilterByPerspective_MembershipAndLinkRule(t *testing.T) { + cards := []Card{{ID: "a"}, {ID: "b"}, {ID: "c"}} + links := []Link{ + {ID: "l-ab", FromCardID: "a", ToCardID: "b"}, // both endpoints member, link member -> renders + {ID: "l-bc", FromCardID: "b", ToCardID: "c"}, // c is not a member -> hidden despite link being a member + {ID: "l-ba", FromCardID: "b", ToCardID: "a"}, // both endpoints member, but link itself NOT a member -> hidden + } + p := Perspective{ + MemberCardIDs: []string{"a", "b"}, + MemberLinkIDs: []string{"l-ab", "l-bc"}, + } + gotCards, gotLinks := FilterByPerspective(cards, links, p) + + wantCardIDs := []string{"a", "b"} + if gotIDs := cardIDs(gotCards); !reflect.DeepEqual(gotIDs, wantCardIDs) { + t.Errorf("FilterByPerspective cards = %v, want %v", gotIDs, wantCardIDs) + } + wantLinkIDs := []string{"l-ab"} + if gotIDs := linkIDs(gotLinks); !reflect.DeepEqual(gotIDs, wantLinkIDs) { + t.Errorf("FilterByPerspective links = %v, want %v (link renders iff itself AND both endpoints are members)", gotIDs, wantLinkIDs) + } +} + +func TestFilterByPerspective_PreservesInputOrder(t *testing.T) { + cards := []Card{{ID: "z"}, {ID: "a"}, {ID: "m"}} + p := Perspective{MemberCardIDs: []string{"a", "m", "z"}} + got, _ := FilterByPerspective(cards, nil, p) + want := []string{"z", "a", "m"} + if gotIDs := cardIDs(got); !reflect.DeepEqual(gotIDs, want) { + t.Errorf("FilterByPerspective card order = %v, want input order %v", gotIDs, want) + } +} + +func TestDiffPerspectives_AddedAndRemoved_OrderStable(t *testing.T) { + from := Perspective{MemberCardIDs: []string{"a", "b", "c"}, MemberLinkIDs: []string{"l1"}} + to := Perspective{MemberCardIDs: []string{"b", "d", "e"}, MemberLinkIDs: []string{"l2"}} + + diff := DiffPerspectives(from, to) + if want := []string{"d", "e"}; !reflect.DeepEqual(diff.AddedCardIDs, want) { + t.Errorf("AddedCardIDs = %v, want %v", diff.AddedCardIDs, want) + } + if want := []string{"a", "c"}; !reflect.DeepEqual(diff.RemovedCardIDs, want) { + t.Errorf("RemovedCardIDs = %v, want %v", diff.RemovedCardIDs, want) + } + if want := []string{"l2"}; !reflect.DeepEqual(diff.AddedLinkIDs, want) { + t.Errorf("AddedLinkIDs = %v, want %v", diff.AddedLinkIDs, want) + } + if want := []string{"l1"}; !reflect.DeepEqual(diff.RemovedLinkIDs, want) { + t.Errorf("RemovedLinkIDs = %v, want %v", diff.RemovedLinkIDs, want) + } +} + +func TestDiffPerspectives_IdenticalMembership_NoChange(t *testing.T) { + p := Perspective{MemberCardIDs: []string{"a", "b"}, MemberLinkIDs: []string{"l1"}} + diff := DiffPerspectives(p, p) + if len(diff.AddedCardIDs) != 0 || len(diff.RemovedCardIDs) != 0 || len(diff.AddedLinkIDs) != 0 || len(diff.RemovedLinkIDs) != 0 { + t.Errorf("DiffPerspectives(p, p) = %+v, want an all-empty diff", diff) + } +} + +func cardIDs(cards []Card) []string { + out := make([]string, len(cards)) + for i, c := range cards { + out[i] = c.ID + } + return out +} + +func linkIDs(links []Link) []string { + out := make([]string, len(links)) + for i, l := range links { + out[i] = l.ID + } + return out +} diff --git a/internal/services/atlassvc/atlascard.go b/internal/services/atlassvc/atlascard.go index 28c225fc..3672530d 100644 --- a/internal/services/atlassvc/atlascard.go +++ b/internal/services/atlassvc/atlascard.go @@ -79,9 +79,15 @@ func (a *AtlasService) createCardWithID(id, kindID, title, note string, fields m return atlas.Card{}, err } a.cards = append(a.cards, c) + // Authoring-while-active (ADR-0041): a card created while a + // perspective is active joins it, ancestry closed. Snapshot first -- + // a persist failure below must roll this back too. + previousPerspectives := append([]atlas.Perspective(nil), a.perspectives...) + a.joinActivePerspectiveWithCardLocked(c.ID) perr := a.persistLocked() if perr != nil { a.cards = a.cards[:len(a.cards)-1] + a.perspectives = previousPerspectives } a.mu.Unlock() if perr != nil { diff --git a/internal/services/atlassvc/atlaslink.go b/internal/services/atlassvc/atlaslink.go index 65ec61aa..04523616 100644 --- a/internal/services/atlassvc/atlaslink.go +++ b/internal/services/atlassvc/atlaslink.go @@ -53,9 +53,15 @@ func (a *AtlasService) createLinkWithID(id, fromCardID, toCardID, linkKindID, la now := time.Now() l.CreatedAt, l.UpdatedAt = now, now a.links = append(a.links, l) + // Authoring-while-active (ADR-0041): a link created while a + // perspective is active joins it. Snapshot first -- a persist + // failure below must roll this back too. + previousPerspectives := append([]atlas.Perspective(nil), a.perspectives...) + a.joinActivePerspectiveWithLinkLocked(l.ID) perr := a.persistLocked() if perr != nil { a.links = a.links[:len(a.links)-1] + a.perspectives = previousPerspectives } a.mu.Unlock() if perr != nil { diff --git a/internal/services/atlassvc/atlasnote.go b/internal/services/atlassvc/atlasnote.go index a9b933af..ac11e6c3 100644 --- a/internal/services/atlassvc/atlasnote.go +++ b/internal/services/atlassvc/atlasnote.go @@ -171,10 +171,16 @@ func (a *AtlasService) PromoteNote(noteID, kindID, title string) (atlas.Card, er } a.notes = append(a.notes[:noteIdx], a.notes[noteIdx+1:]...) a.cards = append(a.cards, c) + // Authoring-while-active (ADR-0041): the promoted card joins the + // active perspective, same as any other freshly created card. + // Snapshot first -- a persist failure below must roll this back too. + previousPerspectives := append([]atlas.Perspective(nil), a.perspectives...) + a.joinActivePerspectiveWithCardLocked(c.ID) perr := a.persistLocked() if perr != nil { a.notes = insertNoteAt(a.notes, noteIdx, note) a.cards = a.cards[:len(a.cards)-1] + a.perspectives = previousPerspectives } a.mu.Unlock() if perr != nil { diff --git a/internal/services/atlassvc/atlasperspective.go b/internal/services/atlassvc/atlasperspective.go new file mode 100644 index 00000000..51985081 --- /dev/null +++ b/internal/services/atlassvc/atlasperspective.go @@ -0,0 +1,424 @@ +package atlassvc + +import ( + "fmt" + "slices" + "time" + + "github.com/alicoding/mill/internal/domain/atlas" + "github.com/alicoding/mill/internal/services/dataevent" + "github.com/alicoding/mill/internal/services/seeding" +) + +// --- Perspectives (ADR-0041, goal 0095) --- +// +// A Perspective is a named, ordered view over one space's live card +// set: explicit membership over cards AND links, never derived. Zero +// Perspectives over a space is the default "everything" view -- no +// migration, no behavior change. Delete is immediate (no tombstone/ +// undo lifecycle in v1, cheap to recreate); referential cleanup for a +// deleted perspective is the session's own read-time degrade +// (AtlasSession) plus purgeTombstonesLocked's member-set stripping, +// not a bespoke path here. + +// Perspectives returns every perspective across every space -- the +// frontend/caller scopes by SpaceID (goal 0095's slice-1 read model +// mirrors Kinds()/LinkKinds()'s own "return everything, caller +// filters" shape). +func (a *AtlasService) Perspectives() []atlas.Perspective { + a.mu.RLock() + defer a.mu.RUnlock() + out := make([]atlas.Perspective, len(a.perspectives)) + copy(out, a.perspectives) + return out +} + +// CreatePerspective makes a new Perspective over spaceID ("" for the +// true root), appended at the end of that space's own ordered set. +func (a *AtlasService) CreatePerspective(spaceID, name, description string) (atlas.Perspective, error) { + return a.createPerspectiveWithID("", spaceID, name, description, -1, nil, nil) +} + +// createPerspectiveWithID is CreatePerspective's own logic, +// parameterized on the new perspective's id, Order, and initial +// membership -- the seam ImportAtlas uses to preserve a caller- +// supplied id/order/membership (ADR-0036 decision 3), same shape as +// atlascard.go's createCardWithID. order < 0 auto-assigns "append at +// the end of spaceID's own ordered set" (every ordinary create); id == +// "" mints a fresh one. +func (a *AtlasService) createPerspectiveWithID(id, spaceID, name, description string, order int, memberCardIDs, memberLinkIDs []string) (atlas.Perspective, error) { + a.mu.Lock() + if spaceID != "" && a.findCardLocked(spaceID) == -1 { + a.mu.Unlock() + return atlas.Perspective{}, fmt.Errorf("no card with id %q to scope this perspective to", spaceID) + } + if id == "" { + id = seeding.NewSlugID(name, "perspective") + } + if order < 0 { + order = -1 + for _, p := range a.perspectives { + if p.SpaceID == spaceID && p.Order > order { + order = p.Order + } + } + order++ + } + now := time.Now() + p := atlas.Perspective{ + ID: id, SpaceID: spaceID, Name: name, Description: description, Order: order, + MemberCardIDs: memberCardIDs, MemberLinkIDs: memberLinkIDs, + CreatedAt: now, UpdatedAt: now, + } + if err := atlas.ValidatePerspective(p, a.cardsByIDLocked()); err != nil { + a.mu.Unlock() + return atlas.Perspective{}, err + } + a.perspectives = append(a.perspectives, p) + perr := a.persistLocked() + if perr != nil { + a.perspectives = a.perspectives[:len(a.perspectives)-1] + } + a.mu.Unlock() + if perr != nil { + return atlas.Perspective{}, fmt.Errorf("save perspective: %w", perr) + } + dataevent.Emit("atlas", p.ID) + return p, nil +} + +// updatePerspectiveFromImport overwrites an existing perspective's full +// content from an import bundle (ADR-0036 decision 3's "id present and +// known locally -> update in place") -- unlike RenamePerspective, this +// also replaces SpaceID/Order/membership wholesale, since a bundle's +// entry is the complete authored state, not a partial edit. +func (a *AtlasService) updatePerspectiveFromImport(id, spaceID, name, description string, order int, memberCardIDs, memberLinkIDs []string) error { + a.mu.Lock() + idx := a.findPerspectiveLocked(id) + if idx == -1 { + a.mu.Unlock() + return fmt.Errorf("no perspective with id %q", id) + } + if spaceID != "" && a.findCardLocked(spaceID) == -1 { + a.mu.Unlock() + return fmt.Errorf("no card with id %q to scope this perspective to", spaceID) + } + previous := a.perspectives[idx] + p := previous + p.SpaceID, p.Name, p.Description, p.Order = spaceID, name, description, order + p.MemberCardIDs, p.MemberLinkIDs = memberCardIDs, memberLinkIDs + p.UpdatedAt = time.Now() + p.Seed = p.Seed.Touch() + if err := atlas.ValidatePerspective(p, a.cardsByIDLocked()); err != nil { + a.mu.Unlock() + return err + } + a.perspectives[idx] = p + perr := a.persistLocked() + if perr != nil { + a.perspectives[idx] = previous + } + a.mu.Unlock() + if perr != nil { + return fmt.Errorf("save perspective: %w", perr) + } + dataevent.Emit("atlas", id) + return nil +} + +// RenamePerspective replaces a perspective's Name/Description in +// place -- membership/SpaceID/Order move through their own dedicated +// calls (AddToPerspective/RemoveFromPerspective/ReorderPerspective), +// same "one concern per mutator" shape UpdateCard vs. MoveCard takes. +func (a *AtlasService) RenamePerspective(id, name, description string) (atlas.Perspective, error) { + a.mu.Lock() + idx := a.findPerspectiveLocked(id) + if idx == -1 { + a.mu.Unlock() + return atlas.Perspective{}, fmt.Errorf("no perspective with id %q", id) + } + previous := a.perspectives[idx] + p := previous + p.Name, p.Description = name, description + p.UpdatedAt = time.Now() + p.Seed = p.Seed.Touch() + if err := atlas.ValidatePerspective(p, a.cardsByIDLocked()); err != nil { + a.mu.Unlock() + return atlas.Perspective{}, err + } + a.perspectives[idx] = p + perr := a.persistLocked() + if perr != nil { + a.perspectives[idx] = previous + } + a.mu.Unlock() + if perr != nil { + return atlas.Perspective{}, fmt.Errorf("save perspective: %w", perr) + } + dataevent.Emit("atlas", p.ID) + return p, nil +} + +// ReorderPerspective assigns Order = index within orderedIDs to every +// perspective named -- orderedIDs must name EXACTLY the perspectives +// currently scoped to spaceID, once each (the same "whole ordered set, +// not a partial move" shape a drag-reorder list naturally produces). +func (a *AtlasService) ReorderPerspective(spaceID string, orderedIDs []string) error { + a.mu.Lock() + indexByID := make(map[string]int, len(a.perspectives)) + scopedCount := 0 + for i, p := range a.perspectives { + if p.SpaceID == spaceID { + indexByID[p.ID] = i + scopedCount++ + } + } + if len(orderedIDs) != scopedCount { + a.mu.Unlock() + return fmt.Errorf("reorder must name exactly the %d perspective(s) in space %q, got %d", scopedCount, spaceID, len(orderedIDs)) + } + seen := make(map[string]bool, len(orderedIDs)) + for _, id := range orderedIDs { + if _, ok := indexByID[id]; !ok { + a.mu.Unlock() + return fmt.Errorf("perspective %q is not in space %q", id, spaceID) + } + if seen[id] { + a.mu.Unlock() + return fmt.Errorf("perspective %q named twice", id) + } + seen[id] = true + } + previous := make([]atlas.Perspective, len(a.perspectives)) + copy(previous, a.perspectives) + now := time.Now() + for order, id := range orderedIDs { + idx := indexByID[id] + a.perspectives[idx].Order = order + a.perspectives[idx].UpdatedAt = now + a.perspectives[idx].Seed = a.perspectives[idx].Seed.Touch() + } + perr := a.persistLocked() + if perr != nil { + a.perspectives = previous + } + a.mu.Unlock() + if perr != nil { + return fmt.Errorf("save perspective reorder: %w", perr) + } + dataevent.Emit("atlas", spaceID) + return nil +} + +// DeletePerspective removes a perspective immediately -- no tombstone/ +// undo lifecycle (ADR-0041): cheap to recreate. A session parked on +// this perspective degrades to the everything view the next time +// AtlasSession is read; nothing else references a Perspective's id, so +// there is no further referential cleanup to do here. +func (a *AtlasService) DeletePerspective(id string) error { + a.mu.Lock() + idx := a.findPerspectiveLocked(id) + if idx == -1 { + a.mu.Unlock() + return fmt.Errorf("no perspective with id %q", id) + } + removed := a.perspectives[idx] + a.perspectives = append(a.perspectives[:idx], a.perspectives[idx+1:]...) + perr := a.persistLocked() + if perr != nil { + a.perspectives = insertPerspectiveAt(a.perspectives, idx, removed) + } + a.mu.Unlock() + if perr != nil { + return fmt.Errorf("save perspective deletion: %w", perr) + } + dataevent.Emit("atlas", id) + return nil +} + +func insertPerspectiveAt(perspectives []atlas.Perspective, idx int, p atlas.Perspective) []atlas.Perspective { + if idx < 0 || idx > len(perspectives) { + idx = len(perspectives) + } + perspectives = append(perspectives, atlas.Perspective{}) + copy(perspectives[idx+1:], perspectives[idx:]) + perspectives[idx] = p + return perspectives +} + +// --- Membership --- + +// AddToPerspective adds cardID to perspectiveID, closed under ancestry +// (ADR-0041): every container between cardID and the perspective's own +// SpaceID joins with it, so a deeply nested card never appears in a +// filtered view floating with no visible container. Rejects a cardID +// that does not actually live inside the perspective's space. +func (a *AtlasService) AddToPerspective(perspectiveID, cardID string) (atlas.Perspective, error) { + a.mu.Lock() + idx := a.findPerspectiveLocked(perspectiveID) + if idx == -1 { + a.mu.Unlock() + return atlas.Perspective{}, fmt.Errorf("no perspective with id %q", perspectiveID) + } + if a.findCardLocked(cardID) == -1 { + a.mu.Unlock() + return atlas.Perspective{}, fmt.Errorf("no card with id %q", cardID) + } + previous := a.perspectives[idx] + chain := atlas.AncestorChain(a.cardsByIDLocked(), cardID, previous.SpaceID) + if chain == nil { + a.mu.Unlock() + return atlas.Perspective{}, fmt.Errorf("card %q is not inside perspective %q's space %q", cardID, perspectiveID, previous.SpaceID) + } + p := previous + for _, id := range chain { + if !slices.Contains(p.MemberCardIDs, id) { + p.MemberCardIDs = append(p.MemberCardIDs, id) + } + } + p.UpdatedAt = time.Now() + p.Seed = p.Seed.Touch() + a.perspectives[idx] = p + perr := a.persistLocked() + if perr != nil { + a.perspectives[idx] = previous + } + a.mu.Unlock() + if perr != nil { + return atlas.Perspective{}, fmt.Errorf("save perspective membership: %w", perr) + } + dataevent.Emit("atlas", perspectiveID) + return p, nil +} + +// RemoveFromPerspective removes cardID from perspectiveID, cascaded to +// every member descendant cardID currently contains (ADR-0041): a +// member left behind after its own container drops out of a view would +// render with no visible parent context. +func (a *AtlasService) RemoveFromPerspective(perspectiveID, cardID string) (atlas.Perspective, error) { + a.mu.Lock() + idx := a.findPerspectiveLocked(perspectiveID) + if idx == -1 { + a.mu.Unlock() + return atlas.Perspective{}, fmt.Errorf("no perspective with id %q", perspectiveID) + } + previous := a.perspectives[idx] + remove := atlas.DescendantsAndSelf(a.cardsByIDLocked(), cardID) + p := previous + kept := make([]string, 0, len(p.MemberCardIDs)) + for _, id := range p.MemberCardIDs { + if !remove[id] { + kept = append(kept, id) + } + } + p.MemberCardIDs = kept + p.UpdatedAt = time.Now() + p.Seed = p.Seed.Touch() + a.perspectives[idx] = p + perr := a.persistLocked() + if perr != nil { + a.perspectives[idx] = previous + } + a.mu.Unlock() + if perr != nil { + return atlas.Perspective{}, fmt.Errorf("save perspective membership: %w", perr) + } + dataevent.Emit("atlas", perspectiveID) + return p, nil +} + +// --- Authoring-while-active (ADR-0041) --- + +// joinActivePerspectiveWithCardLocked adds cardID -- and, per the +// ancestry-closure rule, every container between it and the +// perspective's own SpaceID -- to the session's ACTIVE perspective, +// when one is set, still exists, and cardID actually lives inside that +// perspective's space. A silent no-op otherwise (including while no +// perspective is active, the default everything view, or cardID falls +// outside the active perspective's space entirely -- e.g. a workflow- +// authored root-level card while a nested perspective is active). +// Caller must hold a.mu; the caller's own persistLocked/dataevent.Emit +// covers this mutation too, so authoring stays one operation, one +// emit. +func (a *AtlasService) joinActivePerspectiveWithCardLocked(cardID string) { + if a.session.ActivePerspectiveID == "" { + return + } + idx := a.findPerspectiveLocked(a.session.ActivePerspectiveID) + if idx == -1 { + return + } + p := &a.perspectives[idx] + chain := atlas.AncestorChain(a.cardsByIDLocked(), cardID, p.SpaceID) + if chain == nil { + return + } + changed := false + for _, id := range chain { + if !slices.Contains(p.MemberCardIDs, id) { + p.MemberCardIDs = append(p.MemberCardIDs, id) + changed = true + } + } + if changed { + p.UpdatedAt = time.Now() + p.Seed = p.Seed.Touch() + } +} + +// joinActivePerspectiveWithLinkLocked adds linkID to the session's +// ACTIVE perspective, when one is set and still exists -- the link's +// own counterpart to joinActivePerspectiveWithCardLocked. A link's +// endpoints are joined independently (drawing a link normally happens +// between two cards already visible, i.e. already members); this call +// only ever adds the link entity itself. Caller must hold a.mu. +func (a *AtlasService) joinActivePerspectiveWithLinkLocked(linkID string) { + if a.session.ActivePerspectiveID == "" { + return + } + idx := a.findPerspectiveLocked(a.session.ActivePerspectiveID) + if idx == -1 { + return + } + p := &a.perspectives[idx] + if !slices.Contains(p.MemberLinkIDs, linkID) { + p.MemberLinkIDs = append(p.MemberLinkIDs, linkID) + p.UpdatedAt = time.Now() + p.Seed = p.Seed.Touch() + } +} + +// --- Purge integration --- + +// purgePerspectiveMembersLocked strips every id in purgedCardIDs from +// every perspective's MemberCardIDs, and every id in purgedLinkIDs from +// every perspective's MemberLinkIDs -- purgeTombstonesLocked's own +// pass (goal 0095): a hard-purged card/link must never linger as a +// dangling member no create/remove path will ever clean up again. +// Caller must hold a.mu. +func (a *AtlasService) purgePerspectiveMembersLocked(purgedCardIDs, purgedLinkIDs map[string]bool) { + if len(purgedCardIDs) == 0 && len(purgedLinkIDs) == 0 { + return + } + for i := range a.perspectives { + if len(purgedCardIDs) > 0 { + a.perspectives[i].MemberCardIDs = removeIDs(a.perspectives[i].MemberCardIDs, purgedCardIDs) + } + if len(purgedLinkIDs) > 0 { + a.perspectives[i].MemberLinkIDs = removeIDs(a.perspectives[i].MemberLinkIDs, purgedLinkIDs) + } + } +} + +// removeIDs returns ids with every id present in remove dropped, +// preserving order -- in-place (ids' own backing array is reused, the +// same keptCards/keptLinks style purgeTombstonesLocked already uses). +func removeIDs(ids []string, remove map[string]bool) []string { + out := ids[:0] + for _, id := range ids { + if !remove[id] { + out = append(out, id) + } + } + return out +} diff --git a/internal/services/atlassvc/atlasperspective_lifecycle_test.go b/internal/services/atlassvc/atlasperspective_lifecycle_test.go new file mode 100644 index 00000000..0dcbce2f --- /dev/null +++ b/internal/services/atlassvc/atlasperspective_lifecycle_test.go @@ -0,0 +1,400 @@ +package atlassvc + +import ( + "testing" + "time" + + "github.com/alicoding/mill/internal/domain/atlas" + "github.com/alicoding/mill/internal/services/servicetest" +) + +// This file continues atlasperspective_test.go (split for the 500-line +// convention, .claude/rules/architecture.md): authoring-while-active, +// delete cascade, purge integration, export/import, and dataevent +// coverage for ADR-0041/goal 0095's Perspective. + +// --- Authoring-while-active (ADR-0041) --- + +func TestCreateCard_JoinsActivePerspectiveWithAncestryClosure(t *testing.T) { + a := newBlankAtlasService(t) + k, err := a.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + root, err := a.CreateCard(k.ID, "Root", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(root): %v", err) + } + child, err := a.CreateCard(k.ID, "Child", "", nil, root.ID, nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(child): %v", err) + } + p, err := a.CreatePerspective(root.ID, "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if err := a.SetAtlasSession(AtlasSessionState{ActivePerspectiveID: p.ID}); err != nil { + t.Fatalf("SetAtlasSession: %v", err) + } + + authored, err := a.CreateCard(k.ID, "Authored", "", nil, child.ID, nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(authored): %v", err) + } + + var got atlas.Perspective + for _, got2 := range a.Perspectives() { + if got2.ID == p.ID { + got = got2 + } + } + if !containsID(got.MemberCardIDs, authored.ID) { + t.Errorf("active perspective's MemberCardIDs = %v, want the freshly authored card %q", got.MemberCardIDs, authored.ID) + } + if !containsID(got.MemberCardIDs, child.ID) { + t.Errorf("active perspective's MemberCardIDs = %v, want the authored card's container %q too (ancestry closure)", got.MemberCardIDs, child.ID) + } +} + +func TestCreateCard_OutsideActivePerspectiveSpace_DoesNotJoin(t *testing.T) { + a := newBlankAtlasService(t) + k, err := a.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + spaceA, err := a.CreateCard(k.ID, "Space A", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(spaceA): %v", err) + } + p, err := a.CreatePerspective(spaceA.ID, "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if err := a.SetAtlasSession(AtlasSessionState{ActivePerspectiveID: p.ID}); err != nil { + t.Fatalf("SetAtlasSession: %v", err) + } + + // A card created at the TRUE root (unrelated to spaceA's subtree) + // must not join a perspective scoped to a different space. + outside, err := a.CreateCard(k.ID, "Outside", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(outside): %v", err) + } + for _, got := range a.Perspectives() { + if got.ID == p.ID && containsID(got.MemberCardIDs, outside.ID) { + t.Errorf("perspective scoped to %q gained member %q, which lives outside its space", spaceA.ID, outside.ID) + } + } +} + +func TestCreateLink_JoinsActivePerspective(t *testing.T) { + a := newBlankAtlasService(t) + k, err := a.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + c1, err := a.CreateCard(k.ID, "A", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard: %v", err) + } + c2, err := a.CreateCard(k.ID, "B", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard: %v", err) + } + lk, err := a.CreateLinkKind("relates to", "") + if err != nil { + t.Fatalf("CreateLinkKind: %v", err) + } + p, err := a.CreatePerspective("", "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if err := a.SetAtlasSession(AtlasSessionState{ActivePerspectiveID: p.ID}); err != nil { + t.Fatalf("SetAtlasSession: %v", err) + } + + link, err := a.CreateLink(c1.ID, c2.ID, lk.ID, "") + if err != nil { + t.Fatalf("CreateLink: %v", err) + } + var got atlas.Perspective + for _, got2 := range a.Perspectives() { + if got2.ID == p.ID { + got = got2 + } + } + if !containsID(got.MemberLinkIDs, link.ID) { + t.Errorf("active perspective's MemberLinkIDs = %v, want the freshly created link %q", got.MemberLinkIDs, link.ID) + } +} + +func TestPromoteNote_JoinsActivePerspective(t *testing.T) { + a := newBlankAtlasService(t) + k, err := a.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + note, err := a.CreateNote("promote me", atlas.Position{}, "") + if err != nil { + t.Fatalf("CreateNote: %v", err) + } + p, err := a.CreatePerspective("", "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if err := a.SetAtlasSession(AtlasSessionState{ActivePerspectiveID: p.ID}); err != nil { + t.Fatalf("SetAtlasSession: %v", err) + } + + promoted, err := a.PromoteNote(note.ID, k.ID, "Promoted") + if err != nil { + t.Fatalf("PromoteNote: %v", err) + } + var got atlas.Perspective + for _, got2 := range a.Perspectives() { + if got2.ID == p.ID { + got = got2 + } + } + if !containsID(got.MemberCardIDs, promoted.ID) { + t.Errorf("active perspective's MemberCardIDs = %v, want the newly promoted card %q", got.MemberCardIDs, promoted.ID) + } +} + +// --- Delete-of-owning-space cascade --- + +func TestDeleteCard_CascadesOwningSpacePerspectiveDeletion(t *testing.T) { + a := newBlankAtlasService(t) + k, err := a.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + space, err := a.CreateCard(k.ID, "Space", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(space): %v", err) + } + p, err := a.CreatePerspective(space.ID, "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if _, err := a.DeleteCard(space.ID); err != nil { + t.Fatalf("DeleteCard: %v", err) + } + for _, got := range a.Perspectives() { + if got.ID == p.ID { + t.Error("DeleteCard on a perspective's own space did not cascade-delete the perspective") + } + } +} + +// --- Purge integration --- + +func TestPurgeTombstonesLocked_StripsPurgedMembersFromPerspectives(t *testing.T) { + a := newBlankAtlasService(t) + k, err := a.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + cardA, err := a.CreateCard(k.ID, "A", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(A): %v", err) + } + cardB, err := a.CreateCard(k.ID, "B", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(B): %v", err) + } + lk, err := a.CreateLinkKind("relates to", "") + if err != nil { + t.Fatalf("CreateLinkKind: %v", err) + } + link, err := a.CreateLink(cardA.ID, cardB.ID, lk.ID, "") + if err != nil { + t.Fatalf("CreateLink: %v", err) + } + p, err := a.CreatePerspective("", "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if _, err := a.AddToPerspective(p.ID, cardA.ID); err != nil { + t.Fatalf("AddToPerspective(A): %v", err) + } + if _, err := a.AddToPerspective(p.ID, cardB.ID); err != nil { + t.Fatalf("AddToPerspective(B): %v", err) + } + // MemberLinkIDs has no public "add existing link" RPC in this slice + // (only the authoring-while-active hook populates it) -- poke it + // directly, same pattern the existing purge tests use for DeletedAt. + a.mu.Lock() + a.perspectives[a.findPerspectiveLocked(p.ID)].MemberLinkIDs = []string{link.ID} + a.mu.Unlock() + + now := time.Now() + a.mu.Lock() + a.cards[a.findCardLocked(cardB.ID)].DeletedAt = now.Add(-tombstoneGraceWindow - time.Minute) + changed := a.purgeTombstonesLocked(now) + a.mu.Unlock() + if !changed { + t.Fatal("purgeTombstonesLocked reported no change") + } + + var got atlas.Perspective + for _, got2 := range a.Perspectives() { + if got2.ID == p.ID { + got = got2 + } + } + if containsID(got.MemberCardIDs, cardB.ID) { + t.Errorf("perspective's MemberCardIDs after purge = %v, want the purged card %q stripped", got.MemberCardIDs, cardB.ID) + } + if !containsID(got.MemberCardIDs, cardA.ID) { + t.Errorf("perspective's MemberCardIDs after purge = %v, want the surviving card %q kept", got.MemberCardIDs, cardA.ID) + } + if containsID(got.MemberLinkIDs, link.ID) { + t.Errorf("perspective's MemberLinkIDs after purge = %v, want the now-gone link %q stripped", got.MemberLinkIDs, link.ID) + } +} + +// --- Export/Import --- + +func TestExportImportAtlas_RoundTripsPerspectiveMembership(t *testing.T) { + source := NewAtlasService(servicetest.NewFakeStore()) + k, err := source.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + spaceCard, err := source.CreateCard(k.ID, "Space", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(space): %v", err) + } + member, err := source.CreateCard(k.ID, "Member", "", nil, spaceCard.ID, nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(member): %v", err) + } + lk, err := source.CreateLinkKind("relates to", "") + if err != nil { + t.Fatalf("CreateLinkKind: %v", err) + } + link, err := source.CreateLink(spaceCard.ID, member.ID, lk.ID, "") + if err != nil { + t.Fatalf("CreateLink: %v", err) + } + p, err := source.CreatePerspective(spaceCard.ID, "Current", "the live system") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if _, err := source.AddToPerspective(p.ID, member.ID); err != nil { + t.Fatalf("AddToPerspective: %v", err) + } + source.mu.Lock() + source.perspectives[source.findPerspectiveLocked(p.ID)].MemberLinkIDs = []string{link.ID} + source.mu.Unlock() + + data, err := source.ExportAtlas() + if err != nil { + t.Fatalf("ExportAtlas: %v", err) + } + + target := NewAtlasService(servicetest.NewFakeStore()) + if _, err := target.ImportAtlas(data); err != nil { + t.Fatalf("ImportAtlas: %v", err) + } + + var got atlas.Perspective + found := false + for _, got2 := range target.Perspectives() { + if got2.ID == p.ID { + got, found = got2, true + } + } + if !found { + t.Fatalf("imported perspective %q not found", p.ID) + } + if got.SpaceID != spaceCard.ID { + t.Errorf("imported perspective's SpaceID = %q, want %q", got.SpaceID, spaceCard.ID) + } + if !containsID(got.MemberCardIDs, member.ID) { + t.Errorf("imported perspective's MemberCardIDs = %v, want %q", got.MemberCardIDs, member.ID) + } + if !containsID(got.MemberLinkIDs, link.ID) { + t.Errorf("imported perspective's MemberLinkIDs = %v, want %q", got.MemberLinkIDs, link.ID) + } +} + +func TestImportAtlas_FiltersUnknownPerspectiveMembers(t *testing.T) { + a := NewAtlasService(servicetest.NewFakeStore()) + bundle := `{"schema":"mill://schema/atlas/v1","perspectives":[{"name":"Current","order":0,"memberCardIDs":["phantom-card"],"memberLinkIDs":["phantom-link"]}]}` + if _, err := a.ImportAtlas(bundle); err != nil { + t.Fatalf("ImportAtlas: %v", err) + } + var got atlas.Perspective + found := false + for _, p := range a.Perspectives() { + if p.Name == "Current" { + got, found = p, true + } + } + if !found { + t.Fatal("imported perspective not found") + } + if len(got.MemberCardIDs) != 0 { + t.Errorf("imported perspective's MemberCardIDs = %v, want empty (unknown member filtered out)", got.MemberCardIDs) + } + if len(got.MemberLinkIDs) != 0 { + t.Errorf("imported perspective's MemberLinkIDs = %v, want empty (unknown member filtered out)", got.MemberLinkIDs) + } +} + +// --- dataevent --- + +func TestDataEvent_PerspectiveMutatorsEmit(t *testing.T) { + a := newBlankAtlasService(t) + k, err := a.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + root, err := a.CreateCard(k.ID, "Root", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard: %v", err) + } + child, err := a.CreateCard(k.ID, "Child", "", nil, root.ID, nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard: %v", err) + } + + got := captureEmits(t) + p, err := a.CreatePerspective(root.ID, "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + assertEmitted(t, *got, p.ID) + + got = captureEmits(t) + if _, err := a.RenamePerspective(p.ID, "Current v2", ""); err != nil { + t.Fatalf("RenamePerspective: %v", err) + } + assertEmitted(t, *got, p.ID) + + got = captureEmits(t) + if _, err := a.AddToPerspective(p.ID, child.ID); err != nil { + t.Fatalf("AddToPerspective: %v", err) + } + assertEmitted(t, *got, p.ID) + + got = captureEmits(t) + if _, err := a.RemoveFromPerspective(p.ID, child.ID); err != nil { + t.Fatalf("RemoveFromPerspective: %v", err) + } + assertEmitted(t, *got, p.ID) + + got = captureEmits(t) + if err := a.ReorderPerspective(root.ID, []string{p.ID}); err != nil { + t.Fatalf("ReorderPerspective: %v", err) + } + assertEmitted(t, *got, root.ID) + + got = captureEmits(t) + if err := a.DeletePerspective(p.ID); err != nil { + t.Fatalf("DeletePerspective: %v", err) + } + assertEmitted(t, *got, p.ID) +} diff --git a/internal/services/atlassvc/atlasperspective_test.go b/internal/services/atlassvc/atlasperspective_test.go new file mode 100644 index 00000000..0af83809 --- /dev/null +++ b/internal/services/atlassvc/atlasperspective_test.go @@ -0,0 +1,273 @@ +package atlassvc + +import ( + "testing" + + "github.com/alicoding/mill/internal/domain/atlas" +) + +// --- Perspective CRUD --- + +func TestCreatePerspective_RoundTrips(t *testing.T) { + a := newBlankAtlasService(t) + p, err := a.CreatePerspective("", "Current", "the live system") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + found := false + for _, got := range a.Perspectives() { + if got.ID == p.ID { + found = true + if got.Name != "Current" || got.Description != "the live system" { + t.Errorf("Perspectives()'s round-tripped perspective = %+v", got) + } + } + } + if !found { + t.Error("CreatePerspective's perspective is not present in Perspectives()") + } +} + +func TestCreatePerspective_UnknownSpace_Errors(t *testing.T) { + a := newBlankAtlasService(t) + if _, err := a.CreatePerspective("does-not-exist", "Current", ""); err == nil { + t.Error("CreatePerspective() with an unknown space id = nil error, want an error") + } +} + +func TestCreatePerspective_BlankName_Errors(t *testing.T) { + a := newBlankAtlasService(t) + if _, err := a.CreatePerspective("", " ", ""); err == nil { + t.Error("CreatePerspective() with a blank name = nil error, want an error") + } +} + +func TestCreatePerspective_OrdersAppendPerSpace(t *testing.T) { + a := newBlankAtlasService(t) + first, err := a.CreatePerspective("", "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + second, err := a.CreatePerspective("", "Target", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if first.Order != 0 || second.Order != 1 { + t.Errorf("Order = %d, %d, want 0, 1 (append within the same space)", first.Order, second.Order) + } +} + +func TestRenamePerspective_RoundTrips(t *testing.T) { + a := newBlankAtlasService(t) + p, err := a.CreatePerspective("", "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + renamed, err := a.RenamePerspective(p.ID, "Target", "the future system") + if err != nil { + t.Fatalf("RenamePerspective: %v", err) + } + if renamed.Name != "Target" || renamed.Description != "the future system" { + t.Errorf("RenamePerspective result = %+v, want Name/Description updated", renamed) + } +} + +func TestRenamePerspective_UnknownID_Errors(t *testing.T) { + a := newBlankAtlasService(t) + if _, err := a.RenamePerspective("does-not-exist", "x", ""); err == nil { + t.Error("RenamePerspective() on an unknown id = nil error, want an error") + } +} + +func TestReorderPerspective_RoundTrips(t *testing.T) { + a := newBlankAtlasService(t) + first, err := a.CreatePerspective("", "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + second, err := a.CreatePerspective("", "Target", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if err := a.ReorderPerspective("", []string{second.ID, first.ID}); err != nil { + t.Fatalf("ReorderPerspective: %v", err) + } + byID := make(map[string]atlas.Perspective) + for _, p := range a.Perspectives() { + byID[p.ID] = p + } + if byID[second.ID].Order != 0 || byID[first.ID].Order != 1 { + t.Errorf("orders after reorder = %d, %d, want 0, 1", byID[second.ID].Order, byID[first.ID].Order) + } +} + +func TestReorderPerspective_WrongSet_Errors(t *testing.T) { + a := newBlankAtlasService(t) + p, err := a.CreatePerspective("", "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if err := a.ReorderPerspective("", []string{p.ID, "phantom"}); err == nil { + t.Error("ReorderPerspective() naming an unknown id = nil error, want an error") + } + if err := a.ReorderPerspective("", []string{}); err == nil { + t.Error("ReorderPerspective() naming too few ids = nil error, want an error") + } +} + +func TestDeletePerspective_RemovesIt(t *testing.T) { + a := newBlankAtlasService(t) + p, err := a.CreatePerspective("", "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if err := a.DeletePerspective(p.ID); err != nil { + t.Fatalf("DeletePerspective: %v", err) + } + for _, got := range a.Perspectives() { + if got.ID == p.ID { + t.Error("DeletePerspective did not remove the perspective") + } + } +} + +func TestDeletePerspective_UnknownID_Errors(t *testing.T) { + a := newBlankAtlasService(t) + if err := a.DeletePerspective("does-not-exist"); err == nil { + t.Error("DeletePerspective() on an unknown id = nil error, want an error") + } +} + +// --- Membership --- + +func TestAddToPerspective_ClosesAncestry(t *testing.T) { + a := newBlankAtlasService(t) + k, err := a.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + root, err := a.CreateCard(k.ID, "Root", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(root): %v", err) + } + child, err := a.CreateCard(k.ID, "Child", "", nil, root.ID, nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(child): %v", err) + } + grandchild, err := a.CreateCard(k.ID, "Grandchild", "", nil, child.ID, nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(grandchild): %v", err) + } + p, err := a.CreatePerspective(root.ID, "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + + got, err := a.AddToPerspective(p.ID, grandchild.ID) + if err != nil { + t.Fatalf("AddToPerspective: %v", err) + } + if !containsID(got.MemberCardIDs, child.ID) || !containsID(got.MemberCardIDs, grandchild.ID) { + t.Errorf("AddToPerspective's MemberCardIDs = %v, want both %q and %q (ancestry closure)", got.MemberCardIDs, child.ID, grandchild.ID) + } + if containsID(got.MemberCardIDs, root.ID) { + t.Errorf("AddToPerspective's MemberCardIDs = %v, want NOT to include the space %q itself", got.MemberCardIDs, root.ID) + } +} + +func TestAddToPerspective_CardOutsideSpace_Errors(t *testing.T) { + a := newBlankAtlasService(t) + k, err := a.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + root, err := a.CreateCard(k.ID, "Root", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(root): %v", err) + } + other, err := a.CreateCard(k.ID, "Other", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(other): %v", err) + } + p, err := a.CreatePerspective(root.ID, "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if _, err := a.AddToPerspective(p.ID, other.ID); err == nil { + t.Error("AddToPerspective() with a card outside the perspective's space = nil error, want an error") + } +} + +func TestRemoveFromPerspective_CascadesToDescendants(t *testing.T) { + a := newBlankAtlasService(t) + k, err := a.CreateKind("Widget", "", "", nil) + if err != nil { + t.Fatalf("CreateKind: %v", err) + } + root, err := a.CreateCard(k.ID, "Root", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(root): %v", err) + } + child, err := a.CreateCard(k.ID, "Child", "", nil, root.ID, nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(child): %v", err) + } + grandchild, err := a.CreateCard(k.ID, "Grandchild", "", nil, child.ID, nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard(grandchild): %v", err) + } + p, err := a.CreatePerspective(root.ID, "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if _, err := a.AddToPerspective(p.ID, grandchild.ID); err != nil { + t.Fatalf("AddToPerspective: %v", err) + } + + got, err := a.RemoveFromPerspective(p.ID, child.ID) + if err != nil { + t.Fatalf("RemoveFromPerspective: %v", err) + } + if containsID(got.MemberCardIDs, child.ID) || containsID(got.MemberCardIDs, grandchild.ID) { + t.Errorf("RemoveFromPerspective's MemberCardIDs = %v, want neither %q nor its descendant %q (cascade)", got.MemberCardIDs, child.ID, grandchild.ID) + } +} + +func TestRemoveFromPerspective_UnknownPerspective_Errors(t *testing.T) { + a := newBlankAtlasService(t) + if _, err := a.RemoveFromPerspective("does-not-exist", "some-card"); err == nil { + t.Error("RemoveFromPerspective() on an unknown perspective = nil error, want an error") + } +} + +// --- Session --- + +func TestAtlasSession_ActivePerspective_Degrades(t *testing.T) { + a := newBlankAtlasService(t) + p, err := a.CreatePerspective("", "Current", "") + if err != nil { + t.Fatalf("CreatePerspective: %v", err) + } + if err := a.SetAtlasSession(AtlasSessionState{ActivePerspectiveID: p.ID}); err != nil { + t.Fatalf("SetAtlasSession: %v", err) + } + if got := a.AtlasSession().ActivePerspectiveID; got != p.ID { + t.Fatalf("AtlasSession().ActivePerspectiveID = %q, want %q", got, p.ID) + } + + if err := a.DeletePerspective(p.ID); err != nil { + t.Fatalf("DeletePerspective: %v", err) + } + if got := a.AtlasSession().ActivePerspectiveID; got != "" { + t.Errorf("AtlasSession().ActivePerspectiveID after the active perspective was deleted = %q, want \"\" (degraded)", got) + } +} + +func containsID(ids []string, id string) bool { + for _, got := range ids { + if got == id { + return true + } + } + return false +} diff --git a/internal/services/atlassvc/atlasservice.go b/internal/services/atlassvc/atlasservice.go index 47859dea..3348bf43 100644 --- a/internal/services/atlassvc/atlasservice.go +++ b/internal/services/atlassvc/atlasservice.go @@ -11,10 +11,10 @@ package atlassvc import ( - "slices" "encoding/json" "fmt" "log/slog" + "slices" "sync" "time" @@ -52,6 +52,11 @@ type persistedState struct { // Session is the map's where-you-were (goal 0091) -- one entry, // not per-container. Session AtlasSessionState + // Perspectives holds every named, ordered view over a space's live + // card set (ADR-0041, goal 0095) -- zero entries means zero + // behavior change: the default "everything" view is the ABSENCE of + // a Perspective record, never an empty one. + Perspectives []atlas.Perspective } // AtlasService holds Atlas's full in-memory state behind one mutex, @@ -80,6 +85,7 @@ type AtlasService struct { // never calls the setter, same posture mirrorsDir takes. guardedDataPaths []string session AtlasSessionState + perspectives []atlas.Perspective } // NewAtlasService restores any persisted state, then reconciles the @@ -125,6 +131,7 @@ func (a *AtlasService) restore() { a.lenses = state.Lenses } a.session = state.Session + a.perspectives = state.Perspectives // Boot-time tombstone purge (goal 0093): unlocked here is safe -- // restore only ever runs once, during construction, before the @@ -144,7 +151,7 @@ func (a *AtlasService) persistLocked() error { state := persistedState{ Kinds: a.kinds, LinkKinds: a.linkKinds, Cards: a.cards, Links: a.links, Notes: a.notes, Lenses: a.lenses, - Session: a.session, + Session: a.session, Perspectives: a.perspectives, } data, err := json.Marshal(state) if err != nil { @@ -249,6 +256,15 @@ func (a *AtlasService) findNoteLocked(id string) int { return -1 } +func (a *AtlasService) findPerspectiveLocked(id string) int { + for i, p := range a.perspectives { + if p.ID == id { + return i + } + } + return -1 +} + // cardsByIDLocked snapshots the current card set keyed by ID -- the // shape atlas.WouldCycle's ancestry walk needs. Caller must hold a.mu. func (a *AtlasService) cardsByIDLocked() map[string]atlas.Card { diff --git a/internal/services/atlassvc/atlasservice_builtin.go b/internal/services/atlassvc/atlasservice_builtin.go index 73259c2b..83bf4a32 100644 --- a/internal/services/atlassvc/atlasservice_builtin.go +++ b/internal/services/atlassvc/atlasservice_builtin.go @@ -52,6 +52,7 @@ func (a *AtlasService) reconcileBuiltIns() { changed = a.reconcileLinkKindsLocked(tombstones, now) || changed changed = a.reconcileCardsLocked(tombstones, now) || changed changed = a.reconcileLinksLocked(tombstones, now) || changed + changed = a.reconcilePerspectivesLocked(tombstones, now) || changed // Retirement runs LAST, after cards have already been re-kinded off // a retired Kind by the upgrade path above -- reference integrity // (no Card may still name a Kind being removed) is checked against @@ -274,3 +275,46 @@ func (a *AtlasService) reconcileLinksLocked(tombstones map[string]bool, now time } return changed } + +// reconcilePerspectivesLocked mirrors reconcileLinksLocked's insert/ +// upgrade/leave-alone-once-Modified/skip-tombstoned algorithm for +// Perspectives (goal 0095) -- a no-op today since +// atlas.BuiltInPerspectives() ships empty (slice 3 adds the seeded +// example), but runs every startup so a future addition top-ups an +// existing install exactly like every other family already does. +func (a *AtlasService) reconcilePerspectivesLocked(tombstones map[string]bool, now time.Time) bool { + byID := make(map[string]int, len(a.perspectives)) + for i, p := range a.perspectives { + byID[p.ID] = i + } + changed := false + for _, golden := range atlas.BuiltInPerspectives() { + idx, present := byID[golden.ID] + if !present { + if tombstones[golden.ID] { + continue + } + golden.CreatedAt, golden.UpdatedAt = now, now + a.perspectives = append(a.perspectives, golden) + changed = true + continue + } + existing := a.perspectives[idx] + if existing.Seed.SeedRevision == 0 { + existing.Seed = seedorigin.Origin{SeedRevision: golden.Seed.SeedRevision, Modified: true} + a.perspectives[idx] = existing + changed = true + continue + } + if existing.Seed.Modified { + continue + } + if existing.Seed.SeedRevision < golden.Seed.SeedRevision { + golden.CreatedAt, golden.UpdatedAt = existing.CreatedAt, now + golden.Seed = seedorigin.Stamp(golden.Seed.SeedRevision) + a.perspectives[idx] = golden + changed = true + } + } + return changed +} diff --git a/internal/services/atlassvc/atlasservice_export.go b/internal/services/atlassvc/atlasservice_export.go index e1386a69..857c4a9d 100644 --- a/internal/services/atlassvc/atlasservice_export.go +++ b/internal/services/atlassvc/atlasservice_export.go @@ -74,14 +74,32 @@ type exportedLink struct { Label string `json:"label,omitempty"` } +// exportedPerspective mirrors atlas.Perspective (ADR-0041, goal 0095). +// MemberCardIDs/MemberLinkIDs are filtered to locally-known ids on +// BOTH sides of the round trip: ExportAtlas drops any member that's +// tombstoned/gone at export time (the same "must not resurrect via +// export -> import" guard the rest of this file applies), and +// ImportAtlas drops any member the target instance doesn't actually +// have after Kinds/Cards/Links have all been imported. +type exportedPerspective struct { + ID string `json:"id,omitempty"` + SpaceID string `json:"spaceID,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Order int `json:"order"` + MemberCardIDs []string `json:"memberCardIDs,omitempty"` + MemberLinkIDs []string `json:"memberLinkIDs,omitempty"` +} + // exportedAtlas is the one envelope (mill://schema/atlas/v1, ADR-0036 // decision 2) carrying the whole graph. type exportedAtlas struct { - Schema string `json:"schema"` - Kinds []exportedKind `json:"kinds,omitempty"` - LinkKinds []exportedLinkKind `json:"linkKinds,omitempty"` - Cards []exportedCard `json:"cards,omitempty"` - Links []exportedLink `json:"links,omitempty"` + Schema string `json:"schema"` + Kinds []exportedKind `json:"kinds,omitempty"` + LinkKinds []exportedLinkKind `json:"linkKinds,omitempty"` + Cards []exportedCard `json:"cards,omitempty"` + Links []exportedLink `json:"links,omitempty"` + Perspectives []exportedPerspective `json:"perspectives,omitempty"` } // ExportAtlas serializes the whole Atlas graph as an indented, portable @@ -136,6 +154,24 @@ func (a *AtlasService) ExportAtlas() (string, error) { } out.Links = append(out.Links, exportedLink{ID: l.ID, FromCardID: l.FromCardID, ToCardID: l.ToCardID, LinkKindID: l.LinkKindID, Label: l.Label}) } + liveLinkIDs := make(map[string]bool, len(out.Links)) + for _, l := range out.Links { + liveLinkIDs[l.ID] = true + } + out.Perspectives = make([]exportedPerspective, 0, len(a.perspectives)) + for _, p := range a.perspectives { + // A perspective scoped to a tombstoned/gone space is skipped + // entirely -- same "must not resurrect via export -> import" + // guard applied to its own card members below. + if p.SpaceID != "" && !liveCardIDs[p.SpaceID] { + continue + } + out.Perspectives = append(out.Perspectives, exportedPerspective{ + ID: p.ID, SpaceID: p.SpaceID, Name: p.Name, Description: p.Description, Order: p.Order, + MemberCardIDs: filterKnownIDs(p.MemberCardIDs, liveCardIDs), + MemberLinkIDs: filterKnownIDs(p.MemberLinkIDs, liveLinkIDs), + }) + } a.mu.RUnlock() data, err := json.MarshalIndent(out, "", " ") @@ -152,14 +188,16 @@ func (a *AtlasService) ExportAtlas() (string, error) { // informational for a post-import confirmation, not load-bearing for // the uniform-semantics preview itself. type AtlasImportSummary struct { - KindsCreated int - KindsUpdated int - LinkKindsCreated int - LinkKindsUpdated int - CardsCreated int - CardsUpdated int - LinksCreated int - LinksUpdated int + KindsCreated int + KindsUpdated int + LinkKindsCreated int + LinkKindsUpdated int + CardsCreated int + CardsUpdated int + LinksCreated int + LinksUpdated int + PerspectivesCreated int + PerspectivesUpdated int } // ImportAtlas applies ADR-0036 decision 3's uniform import rule to @@ -262,6 +300,22 @@ func (a *AtlasService) ImportAtlas(jsonData string) (AtlasImportSummary, error) } } + // Perspectives import LAST -- every card and link they might + // reference must already exist locally so membership can be + // filtered to what's actually known (ImportAtlas's own doc + // comment's ordering rule, applied one family further). + for _, p := range in.Perspectives { + created, err := a.importPerspective(p) + if err != nil { + return summary, fmt.Errorf("import atlas: perspective %q: %w", p.Name, err) + } + if created { + summary.PerspectivesCreated++ + } else { + summary.PerspectivesUpdated++ + } + } + return summary, nil } @@ -331,3 +385,53 @@ func (a *AtlasService) importLink(l exportedLink) (created bool, err error) { _, err = a.createLinkWithID(l.ID, l.FromCardID, l.ToCardID, l.LinkKindID, l.Label) return true, err } + +// filterKnownIDs returns every id in ids that's present in known, +// preserving order -- ExportAtlas's own guard against a stale member +// id leaking out, and ImportAtlas's own guard against a bundled member +// id the target instance never actually created. +func filterKnownIDs(ids []string, known map[string]bool) []string { + if len(ids) == 0 { + return nil + } + out := make([]string, 0, len(ids)) + for _, id := range ids { + if known[id] { + out = append(out, id) + } + } + return out +} + +// importPerspective applies exported Perspective p, filtering its +// membership to cards/links that actually exist LOCALLY at this point +// in the import (every Kind/Card/Link has already been imported by the +// time ImportAtlas reaches this loop). +func (a *AtlasService) importPerspective(p exportedPerspective) (created bool, err error) { + a.mu.RLock() + knownCards := make(map[string]bool, len(a.cards)) + for _, c := range a.cards { + knownCards[c.ID] = true + } + knownLinks := make(map[string]bool, len(a.links)) + for _, l := range a.links { + knownLinks[l.ID] = true + } + a.mu.RUnlock() + memberCards := filterKnownIDs(p.MemberCardIDs, knownCards) + memberLinks := filterKnownIDs(p.MemberLinkIDs, knownLinks) + + if p.ID == "" { + _, err = a.createPerspectiveWithID("", p.SpaceID, p.Name, p.Description, p.Order, memberCards, memberLinks) + return true, err + } + a.mu.RLock() + exists := a.findPerspectiveLocked(p.ID) != -1 + a.mu.RUnlock() + if exists { + err = a.updatePerspectiveFromImport(p.ID, p.SpaceID, p.Name, p.Description, p.Order, memberCards, memberLinks) + return false, err + } + _, err = a.createPerspectiveWithID(p.ID, p.SpaceID, p.Name, p.Description, p.Order, memberCards, memberLinks) + return true, err +} diff --git a/internal/services/atlassvc/atlasservice_tombstone.go b/internal/services/atlassvc/atlasservice_tombstone.go index ca6a26d7..6f7cc74a 100644 --- a/internal/services/atlassvc/atlasservice_tombstone.go +++ b/internal/services/atlassvc/atlasservice_tombstone.go @@ -117,9 +117,22 @@ func (a *AtlasService) DeleteCard(id string) (TombstoneResult, error) { return TombstoneResult{}, fmt.Errorf("tombstone deleted card %q: %w", id, err) } } + // A perspective SCOPED to this card (its own "space") is deleted + // outright (ADR-0041's no-tombstone posture) even though the card + // itself only soft-deletes: a view over a space that no longer + // exists has nothing left to be a view of. + previousPerspectives := append([]atlas.Perspective(nil), a.perspectives...) + kept := a.perspectives[:0] + for _, p := range a.perspectives { + if p.SpaceID != id { + kept = append(kept, p) + } + } + a.perspectives = kept perr := a.persistLocked() if perr != nil { a.cards[idx] = previous + a.perspectives = previousPerspectives } a.mu.Unlock() if perr != nil { @@ -283,13 +296,21 @@ func (a *AtlasService) purgeTombstonesLocked(now time.Time) bool { a.notes = keptNotes if len(purgeCards) > 0 { + purgedLinkIDs := make(map[string]bool) keptLinks := a.links[:0] for _, l := range a.links { - if !purgeCards[l.FromCardID] && !purgeCards[l.ToCardID] { - keptLinks = append(keptLinks, l) + if purgeCards[l.FromCardID] || purgeCards[l.ToCardID] { + purgedLinkIDs[l.ID] = true + continue } + keptLinks = append(keptLinks, l) } a.links = keptLinks + // A hard-purged card/link must never linger as a dangling + // perspective member (goal 0095) -- membership keeps raw ids + // while a card is only soft-deleted, but a permanent purge is + // this pass's one chance to strip them for good. + a.purgePerspectiveMembersLocked(purgeCards, purgedLinkIDs) } return true } diff --git a/internal/services/atlassvc/atlassession.go b/internal/services/atlassvc/atlassession.go index 3714a0b8..c11e613b 100644 --- a/internal/services/atlassvc/atlassession.go +++ b/internal/services/atlassvc/atlassession.go @@ -24,6 +24,12 @@ const testAtlasSessionOffEnv = "MILL_TEST_ATLAS_SESSION_OFF" type AtlasSessionState struct { ViewedID string `json:"viewedID"` OpenCardID string `json:"openCardID"` + // ActivePerspectiveID names the currently-switched-to Perspective + // (ADR-0041, goal 0095) -- "" is the default "everything" view (the + // absence of a Perspective, never an empty one). Degraded on read + // like its siblings: a perspective deleted since this was set + // resolves back to "". + ActivePerspectiveID string `json:"activePerspectiveID"` } // SetAtlasSession persists the current session state -- a zero state @@ -70,5 +76,8 @@ func (a *AtlasService) AtlasSession() AtlasSessionState { out.OpenCardID = "" } } + if out.ActivePerspectiveID != "" && a.findPerspectiveLocked(out.ActivePerspectiveID) == -1 { + out.ActivePerspectiveID = "" + } return out } diff --git a/internal/services/seeding/seed_fingerprint.go b/internal/services/seeding/seed_fingerprint.go index a55d0be6..bc4b7f85 100644 --- a/internal/services/seeding/seed_fingerprint.go +++ b/internal/services/seeding/seed_fingerprint.go @@ -141,6 +141,16 @@ func AllSeedFingerprints() map[string]SeedFingerprint { l.ID, l.CreatedAt, l.UpdatedAt, l.Seed = "", time.Time{}, time.Time{}, seedorigin.Origin{} out[keyFor("atlaslink", id)] = SeedFingerprint{SeedRevision: rev, Fingerprint: fingerprintContent(l)} } + // atlas.BuiltInPerspectives() ships empty this slice (goal 0095's + // seeded example is slice 3) -- the loop still runs so a future + // addition is caught by this same committed-record discipline from + // day one, not bolted on later. + for _, p := range atlas.BuiltInPerspectives() { + id := p.ID + rev := p.Seed.SeedRevision + p.ID, p.CreatedAt, p.UpdatedAt, p.Seed = "", time.Time{}, time.Time{}, seedorigin.Origin{} + out[keyFor("atlasperspective", id)] = SeedFingerprint{SeedRevision: rev, Fingerprint: fingerprintContent(p)} + } return out } From 7e914adb8c8b61e6bd0644e9ce9ab9f2705030f3 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 00:00:35 -0400 Subject: [PATCH 2/2] chore: regenerate Wails bindings for atlas perspectives (goal 0095 slice 1) wails3 generate bindings picks up AtlasService's new Perspective CRUD/ membership methods and the atlas.Perspective type. One mechanical frontend fix rides along: AtlasSessionState's new required activePerspectiveID field means the one existing SetAtlasSession call site needs it too (always "" here -- the switcher UI is a later slice); no behavior change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- .../mill/internal/domain/atlas/index.ts | 1 + .../mill/internal/domain/atlas/models.ts | 43 ++++++++++++ .../services/atlassvc/atlasservice.ts | 70 +++++++++++++++++++ .../mill/internal/services/atlassvc/models.ts | 11 +++ frontend/src/atlas/AtlasView.tsx | 4 +- 5 files changed, 128 insertions(+), 1 deletion(-) diff --git a/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/index.ts index be399c23..681db6d6 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/index.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/index.ts @@ -15,5 +15,6 @@ export type { LinkKind, MirrorContent, Note, + Perspective, Position } from "./models.js"; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/models.ts index 6a8db048..8929d97e 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/domain/atlas/models.ts @@ -288,6 +288,49 @@ export interface Note { "DeletedAt": string; } +/** + * Perspective is a named, ordered view over one container's (SpaceID's) + * live card set (ADR-0041, goal 0095): membership is explicit and + * stored -- both which cards (MemberCardIDs) and which links + * (MemberLinkIDs) render in this view -- never derived from a query, + * so a target-state connection between two currently-existing systems + * can never leak into a view it wasn't deliberately added to. The + * default "everything" view is the ABSENCE of a Perspective record: + * zero Perspectives over a space means zero behavior change. Order + * places this perspective within its SpaceID's own ordered set (e.g. + * "Current", "Interim", "Target"); it is meaningless compared across + * different SpaceID values. Deliberately carries NO DeletedAt -- + * unlike Card/Note (goal 0093's soft-delete-with-undo), a Perspective + * delete is immediate: cheap to recreate, so no tombstone/undo + * lifecycle exists for it in v1. + */ +export interface Perspective { + "ID": string; + "SpaceID": string; + "Name": string; + "Description": string; + "Order": number; + + /** + * MemberCardIDs is the stored set of cards this perspective shows. + * Closed under ancestry by the service layer's AddToPerspective (a + * member's containing chain up to SpaceID joins with it), never by + * this package -- membership mutation is a service concern. + */ + "MemberCardIDs": string[] | null; + + /** + * MemberLinkIDs is the stored set of links this perspective shows. + * A link still only actually RENDERS when both its endpoints are + * ALSO members -- see FilterByPerspective. + */ + "MemberLinkIDs": string[] | null; + "CreatedAt": string; + "UpdatedAt": string; + "BuiltIn": boolean; + "Seed": seedorigin$0.Origin; +} + /** * Position is a card's location within its PARENT's canvas -- only * meaningful when the parent's EffectiveViewMode is ViewModeCanvas; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts index f688aee6..f505b28f 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts @@ -37,6 +37,17 @@ export function AddLinkedCard(fromCardID: string, kindID: string, title: string, return $Call.ByID(1483030434, fromCardID, kindID, title, position); } +/** + * AddToPerspective adds cardID to perspectiveID, closed under ancestry + * (ADR-0041): every container between cardID and the perspective's own + * SpaceID joins with it, so a deeply nested card never appears in a + * filtered view floating with no visible container. Rejects a cardID + * that does not actually live inside the perspective's space. + */ +export function AddToPerspective(perspectiveID: string, cardID: string): $CancellablePromise { + return $Call.ByID(420793018, perspectiveID, cardID); +} + /** * AtlasSession returns the persisted state, DEGRADED to what still * exists: a fully-gone viewed card falls back to root; a tombstoned @@ -170,6 +181,14 @@ export function CreateNote(text: string, pos: atlas$0.Position, parentID: string return $Call.ByID(1216295546, text, pos, parentID); } +/** + * CreatePerspective makes a new Perspective over spaceID ("" for the + * true root), appended at the end of that space's own ordered set. + */ +export function CreatePerspective(spaceID: string, name: string, description: string): $CancellablePromise { + return $Call.ByID(3910340284, spaceID, name, description); +} + /** * DeleteCard soft-deletes a card (goal 0093's quick-delete-with-undo * guard): stamps DeletedAt and leaves ParentID/children untouched -- @@ -215,6 +234,17 @@ export function DeleteNote(id: string): $CancellablePromise<$models.TombstoneRes return $Call.ByID(3922503309, id); } +/** + * DeletePerspective removes a perspective immediately -- no tombstone/ + * undo lifecycle (ADR-0041): cheap to recreate. A session parked on + * this perspective degrades to the everything view the next time + * AtlasSession is read; nothing else references a Perspective's id, so + * there is no further referential cleanup to do here. + */ +export function DeletePerspective(id: string): $CancellablePromise { + return $Call.ByID(2541135077, id); +} + /** * DetectSyncRoots reports which well-known cloud-sync folders * (OneDrive/Dropbox/iCloud Drive) exist on disk, most-likely-first -- @@ -360,6 +390,16 @@ export function OpenCardMirror(cardID: string): $CancellablePromise { return $Call.ByID(3356986203, cardID); } +/** + * Perspectives returns every perspective across every space -- the + * frontend/caller scopes by SpaceID (goal 0095's slice-1 read model + * mirrors Kinds()/LinkKinds()'s own "return everything, caller + * filters" shape). + */ +export function Perspectives(): $CancellablePromise { + return $Call.ByID(1253153793); +} + /** * PickFolder opens the native folder picker -- goal 0067's consent * gate: zero filesystem reads happen before this returns a path the @@ -386,6 +426,36 @@ export function PromoteNote(noteID: string, kindID: string, title: string): $Can return $Call.ByID(881716522, noteID, kindID, title); } +/** + * RemoveFromPerspective removes cardID from perspectiveID, cascaded to + * every member descendant cardID currently contains (ADR-0041): a + * member left behind after its own container drops out of a view would + * render with no visible parent context. + */ +export function RemoveFromPerspective(perspectiveID: string, cardID: string): $CancellablePromise { + return $Call.ByID(2634584680, perspectiveID, cardID); +} + +/** + * RenamePerspective replaces a perspective's Name/Description in + * place -- membership/SpaceID/Order move through their own dedicated + * calls (AddToPerspective/RemoveFromPerspective/ReorderPerspective), + * same "one concern per mutator" shape UpdateCard vs. MoveCard takes. + */ +export function RenamePerspective(id: string, name: string, description: string): $CancellablePromise { + return $Call.ByID(994641070, id, name, description); +} + +/** + * ReorderPerspective assigns Order = index within orderedIDs to every + * perspective named -- orderedIDs must name EXACTLY the perspectives + * currently scoped to spaceID, once each (the same "whole ordered set, + * not a partial move" shape a drag-reorder list naturally produces). + */ +export function ReorderPerspective(spaceID: string, orderedIDs: string[] | null): $CancellablePromise { + return $Call.ByID(538428937, spaceID, orderedIDs); +} + /** * ResolveFileDropRoute decides what a drop/paste of paths means -- * stats them (never lists a directory's contents; that's ScanFolder's diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts index c67ad9ae..3b795e1f 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts @@ -22,6 +22,8 @@ export interface AtlasImportSummary { "CardsUpdated": number; "LinksCreated": number; "LinksUpdated": number; + "PerspectivesCreated": number; + "PerspectivesUpdated": number; } /** @@ -34,6 +36,15 @@ export interface AtlasImportSummary { export interface AtlasSessionState { "viewedID": string; "openCardID": string; + + /** + * ActivePerspectiveID names the currently-switched-to Perspective + * (ADR-0041, goal 0095) -- "" is the default "everything" view (the + * absence of a Perspective, never an empty one). Degraded on read + * like its siblings: a perspective deleted since this was set + * resolves back to "". + */ + "activePerspectiveID": string; } /** diff --git a/frontend/src/atlas/AtlasView.tsx b/frontend/src/atlas/AtlasView.tsx index 2478dd6f..f6ae560a 100644 --- a/frontend/src/atlas/AtlasView.tsx +++ b/frontend/src/atlas/AtlasView.tsx @@ -141,7 +141,9 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) { }, []) useEffect(() => { if (!sessionRestored) return - void AtlasService.SetAtlasSession({ viewedID, openCardID: overlayCardID ?? '' }).catch(() => {}) + // activePerspectiveID: the switcher UI lands in a later slice (goal + // 0095) -- always '' (the everything view) here for now. + void AtlasService.SetAtlasSession({ viewedID, openCardID: overlayCardID ?? '', activePerspectiveID: '' }).catch(() => {}) }, [sessionRestored, viewedID, overlayCardID]) useEffect(() => {