mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
fix(store): gate SeedDefaultCollections on zero-collection workspaces (IDEA-1479)
The server's startup auto-upgrade hook (cmd/pad/main.go) called SeedDefaultCollections against every workspace at boot. That hook dates to the initial release — long before workspace templates existed — and was written as a backfill for workspaces created before tasks/ideas/plans/docs landed in Defaults(). Post-templates, the hook unconditionally re-materialized the Software-template collections into any workspace missing them — including blank-template workspaces (IDEA-1479), which ship only Conventions + Playbooks by design. Result: every restart silently regrew the ghost user-facing collections the blank template was explicitly built to avoid. Fix: SeedDefaultCollections now returns nil immediately when the workspace has any existing collection (system or user-facing). The rescue path still triggers for genuinely-empty workspaces, preserving the original backfill intent. Tests: - TestBlankWorkspaceSurvivesSeedDefaultCollections — blank workspace remains 2 collections after auto-upgrade (and after a second pass). - TestEmptyWorkspaceStillGetsDefaults — zero-collection workspace still gets the full Software default set.
This commit is contained in:
@@ -405,7 +405,34 @@ func (s *Store) MigrateItemFieldValues(collectionID string, migrations []models.
|
||||
return totalAffected, nil
|
||||
}
|
||||
|
||||
// SeedDefaultCollections is a rescue hook for workspaces that somehow ended
|
||||
// up with zero collections — e.g. workspaces created before the seed-on-init
|
||||
// flow existed, or a partial init that failed before any collection landed.
|
||||
// The server calls it for every workspace at startup as an auto-upgrade.
|
||||
//
|
||||
// It is intentionally a no-op for workspaces that already have at least one
|
||||
// collection (system or user-facing). Without this guard, the hook would
|
||||
// re-materialize the standard Software-template collections (Tasks/Ideas/
|
||||
// Plans/Docs) into workspaces created from non-default templates on every
|
||||
// boot — including the `blank` template (IDEA-1479), which ships only
|
||||
// Conventions + Playbooks and would silently grow ghost user-facing
|
||||
// collections on each restart.
|
||||
//
|
||||
// The "zero collections" guard preserves the original backfill intent
|
||||
// (rescue truly-empty workspaces) while making the hook safe for the
|
||||
// post-templates world where workspaces legitimately diverge from
|
||||
// Defaults().
|
||||
func (s *Store) SeedDefaultCollections(workspaceID string) error {
|
||||
existing, err := s.ListCollectionsMinimal(workspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check existing collections for rescue seed: %w", err)
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
// Workspace already has collections — its shape was set by a
|
||||
// template (default, blank, hiring, etc.) or by manual user
|
||||
// edits. Either way, the rescue path doesn't apply.
|
||||
return nil
|
||||
}
|
||||
return s.SeedCollectionsFromTemplate(workspaceID, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,83 @@ func TestSeedFromBlankTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlankWorkspaceSurvivesSeedDefaultCollections is the regression guard
|
||||
// for codex round-2: the server runs SeedDefaultCollections against every
|
||||
// workspace at startup as an auto-upgrade rescue. Before the fix, that hook
|
||||
// unconditionally re-materialized the standard Software-template collections
|
||||
// (tasks/ideas/plans/docs) into any workspace missing them — including
|
||||
// blank-template workspaces, which would silently grow ghost collections on
|
||||
// every server restart. The fix gates the rescue on "workspace has zero
|
||||
// collections" so blank (which ships 2 system collections) is a no-op.
|
||||
func TestBlankWorkspaceSurvivesSeedDefaultCollections(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ws := createTestWorkspace(t, s, "Blank Survival Test")
|
||||
|
||||
if err := s.SeedCollectionsFromTemplate(ws.ID, "blank"); err != nil {
|
||||
t.Fatalf("SeedCollectionsFromTemplate(blank) error: %v", err)
|
||||
}
|
||||
|
||||
// Simulate a server restart firing the auto-upgrade hook.
|
||||
if err := s.SeedDefaultCollections(ws.ID); err != nil {
|
||||
t.Fatalf("SeedDefaultCollections error: %v", err)
|
||||
}
|
||||
|
||||
colls, err := s.ListCollections(ws.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCollections error: %v", err)
|
||||
}
|
||||
if len(colls) != 2 {
|
||||
t.Fatalf("blank workspace has %d collections after auto-upgrade, want 2 (Conventions + Playbooks only); got %+v", len(colls), collectionSlugs(colls))
|
||||
}
|
||||
for _, c := range colls {
|
||||
if c.Slug == "tasks" || c.Slug == "ideas" || c.Slug == "plans" || c.Slug == "docs" {
|
||||
t.Errorf("blank workspace acquired user-facing software collection %q after SeedDefaultCollections — auto-upgrade rescue gate is broken", c.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
// And repeated invocations remain no-ops.
|
||||
if err := s.SeedDefaultCollections(ws.ID); err != nil {
|
||||
t.Fatalf("SeedDefaultCollections (second run) error: %v", err)
|
||||
}
|
||||
colls, _ = s.ListCollections(ws.ID)
|
||||
if len(colls) != 2 {
|
||||
t.Errorf("blank workspace has %d collections after second auto-upgrade pass, want 2", len(colls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyWorkspaceStillGetsDefaults verifies the rescue path still works
|
||||
// for a workspace that genuinely has zero collections — the original intent
|
||||
// of the SeedDefaultCollections hook (predates templates; see git blame on
|
||||
// cmd/pad/main.go's auto-upgrade block). If a workspace was created before
|
||||
// the seed-on-init flow existed, or a partial init failed before any
|
||||
// collection landed, the auto-upgrade must still materialize the Software
|
||||
// defaults.
|
||||
func TestEmptyWorkspaceStillGetsDefaults(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ws := createTestWorkspace(t, s, "Empty Rescue Test")
|
||||
|
||||
// No SeedCollectionsFromTemplate — workspace starts with zero
|
||||
// collections (simulating a pre-templates-era workspace, or a
|
||||
// partial init).
|
||||
if err := s.SeedDefaultCollections(ws.ID); err != nil {
|
||||
t.Fatalf("SeedDefaultCollections error: %v", err)
|
||||
}
|
||||
|
||||
colls, err := s.ListCollections(ws.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCollections error: %v", err)
|
||||
}
|
||||
slugs := make(map[string]bool, len(colls))
|
||||
for _, c := range colls {
|
||||
slugs[c.Slug] = true
|
||||
}
|
||||
for _, want := range []string{"tasks", "ideas", "plans", "docs", "conventions", "playbooks"} {
|
||||
if !slugs[want] {
|
||||
t.Errorf("empty workspace rescue did not materialize default collection %q (got %v)", want, collectionSlugs(colls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectionSlugs(colls []models.Collection) []string {
|
||||
out := make([]string, 0, len(colls))
|
||||
for _, c := range colls {
|
||||
|
||||
Reference in New Issue
Block a user