mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +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
|
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 {
|
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, "")
|
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 {
|
func collectionSlugs(colls []models.Collection) []string {
|
||||||
out := make([]string, 0, len(colls))
|
out := make([]string, 0, len(colls))
|
||||||
for _, c := range colls {
|
for _, c := range colls {
|
||||||
|
|||||||
Reference in New Issue
Block a user