Compare commits

...

5 Commits

Author SHA1 Message Date
MangoPig 3247f28c87 Merge branch 'Fix/Bootstrap' 2026-06-26 23:31:10 +01:00
MangoPig 891e8b83ed Fix: harden bootstrap worker flow 2026-06-26 23:30:50 +01:00
MangoPig ae1f347549 Merge branch 'Refactor/Code-Quality' 2026-06-26 18:33:12 +01:00
MangoPig 7e62ff6d9a Refactor: improve code quality and worker flow 2026-06-26 18:32:49 +01:00
MangoPig adcc9afe05 Merge branch 'Refactor/Folder-Layout' 2026-06-25 21:53:48 +01:00
16 changed files with 1663 additions and 363 deletions
+25 -3
View File
@@ -1,10 +1,16 @@
package main package main
import ( import (
"context"
"encoding/json"
"log" "log"
"os/signal"
"syscall"
"time"
"moku-backend/internal/bootstrap" "moku-backend/internal/bootstrap"
"moku-backend/internal/process" "moku-backend/internal/jobs"
"moku-backend/internal/worker"
) )
func main() { func main() {
@@ -18,9 +24,25 @@ func main() {
} }
}() }()
app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment) jobStore := jobs.NewStore(app.Database)
runner := worker.NewRunner(jobStore, app.Logger, time.Second)
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
var payload jobs.BootstrapStructureMaterializePayload
if len(job.Payload) > 0 {
if err := json.Unmarshal(job.Payload, &payload); err != nil {
return err
}
}
if err := process.WaitForShutdown(app.ServiceName, app.Logger); err != nil { return bootstrap.NewService(app.Database, app.Config.POSIXRoot).ProcessBootstrapStructureMaterialization(ctx, payload.InstallationID)
})
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment, "pollInterval", time.Second)
if err := runner.Run(ctx); err != nil {
app.Logger.Error("worker stopped", "error", err) app.Logger.Error("worker stopped", "error", err)
log.Fatal(err) log.Fatal(err)
} }
@@ -0,0 +1,64 @@
-- +goose Up
CREATE TYPE bootstrap_materialization_status AS ENUM ('not_started', 'pending', 'running', 'succeeded', 'failed');
CREATE TYPE background_job_status AS ENUM ('pending', 'running', 'succeeded', 'failed');
ALTER TABLE installations
ADD COLUMN IF NOT EXISTS materialization_status bootstrap_materialization_status NOT NULL DEFAULT 'not_started',
ADD COLUMN IF NOT EXISTS materialization_error TEXT,
ADD COLUMN IF NOT EXISTS materialization_requested_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS materialization_started_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS materialization_finished_at TIMESTAMPTZ;
UPDATE installations
SET
materialization_status = CASE
WHEN is_bootstrapped THEN 'succeeded'::bootstrap_materialization_status
ELSE 'not_started'::bootstrap_materialization_status
END,
materialization_error = NULL,
materialization_requested_at = CASE
WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW())
ELSE NULL
END,
materialization_started_at = CASE
WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW())
ELSE NULL
END,
materialization_finished_at = CASE
WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW())
ELSE NULL
END
WHERE materialization_status = 'not_started'::bootstrap_materialization_status;
CREATE TABLE IF NOT EXISTS background_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind TEXT NOT NULL,
status background_job_status NOT NULL DEFAULT 'pending',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 1,
available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_background_jobs_claim ON background_jobs (status, available_at, created_at);
-- +goose Down
DROP INDEX IF EXISTS idx_background_jobs_claim;
DROP TABLE IF EXISTS background_jobs;
ALTER TABLE installations
DROP COLUMN IF EXISTS materialization_finished_at,
DROP COLUMN IF EXISTS materialization_started_at,
DROP COLUMN IF EXISTS materialization_requested_at,
DROP COLUMN IF EXISTS materialization_error,
DROP COLUMN IF EXISTS materialization_status;
DROP TYPE IF EXISTS background_job_status;
DROP TYPE IF EXISTS bootstrap_materialization_status;
@@ -0,0 +1,159 @@
package bootstrap
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
)
const (
materializationNotStarted = "not_started"
materializationPending = "pending"
materializationRunning = "running"
materializationSucceeded = "succeeded"
materializationFailed = "failed"
)
type bootstrapStructurePrerequisites struct {
installation InstallationRecord
admin AdminSummary
}
func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) {
record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, `
SELECT
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error
FROM installations
WHERE singleton = TRUE
LIMIT 1;
`))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func loadInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
return scanInstallationRecord(tx.QueryRow(ctx, `
SELECT
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error
FROM installations
WHERE singleton = TRUE
LIMIT 1;
`))
}
func updateBootstrappedInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
return scanInstallationRecord(tx.QueryRow(ctx, `
UPDATE installations
SET
is_bootstrapped = TRUE,
bootstrapped_at = COALESCE(bootstrapped_at, NOW()),
materialization_status = 'pending'::bootstrap_materialization_status,
materialization_error = NULL,
materialization_requested_at = NOW(),
materialization_started_at = NULL,
materialization_finished_at = NULL,
updated_at = NOW()
WHERE singleton = TRUE
RETURNING
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error;
`))
}
func scanInstallationRecord(row pgx.Row) (InstallationRecord, error) {
var record InstallationRecord
if err := row.Scan(
&record.ID,
&record.Name,
&record.Mode,
&record.Access,
&record.Protocol,
&record.Host,
&record.IsBootstrapped,
&record.MaterializationStatus,
&record.MaterializationError,
); err != nil {
return InstallationRecord{}, err
}
if strings.TrimSpace(record.MaterializationStatus) == "" {
record.MaterializationStatus = materializationNotStarted
}
return record, nil
}
func loadPrimaryAdmin(ctx context.Context, tx pgx.Tx) (AdminSummary, error) {
var admin AdminSummary
if err := tx.QueryRow(ctx, `
SELECT id::text, email, display_name
FROM users
WHERE is_instance_admin = TRUE
ORDER BY created_at ASC
LIMIT 1;
`).Scan(&admin.ID, &admin.Email, &admin.DisplayName); err != nil {
return AdminSummary{}, err
}
return admin, nil
}
func (service *Service) loadBootstrapStructurePrerequisites(
ctx context.Context,
tx pgx.Tx,
) (bootstrapStructurePrerequisites, error) {
installation, err := loadInstallation(ctx, tx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return bootstrapStructurePrerequisites{}, ErrInstallationNotConfigured
}
return bootstrapStructurePrerequisites{}, err
}
admin, err := loadPrimaryAdmin(ctx, tx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return bootstrapStructurePrerequisites{}, ErrAdminNotConfigured
}
return bootstrapStructurePrerequisites{}, err
}
return bootstrapStructurePrerequisites{
installation: installation,
admin: admin,
}, nil
}
@@ -0,0 +1,180 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"strings"
"moku-backend/internal/jobs"
"moku-backend/internal/posixproj"
)
func (service *Service) enqueueBootstrapStructureMaterialization(
ctx context.Context,
installation *InstallationRecord,
) error {
if installation == nil {
return nil
}
jobStore := jobs.NewStore(service.db)
if _, err := jobStore.Enqueue(ctx, jobs.EnqueueInput{
Kind: jobs.KindBootstrapStructureMaterialize,
Payload: jobs.BootstrapStructureMaterializePayload{
InstallationID: installation.ID,
},
}); err != nil {
failure := fmt.Sprintf("enqueue bootstrap materialization job: %v", err)
if markErr := service.markBootstrapMaterializationFailed(ctx, installation.ID, failure); markErr != nil {
return errors.Join(err, markErr)
}
// The relational bootstrap write already committed successfully, so keep the
// response successful and surface the enqueue problem via materialization state.
installation.MaterializationStatus = materializationFailed
installation.MaterializationError = &failure
}
return nil
}
func (service *Service) ProcessBootstrapStructureMaterialization(ctx context.Context, installationID string) error {
trimmedInstallationID := strings.TrimSpace(installationID)
if trimmedInstallationID == "" {
return ErrInstallationNotConfigured
}
installation, err := service.GetInstallation(ctx)
if err != nil {
return err
}
if installation == nil || installation.ID != trimmedInstallationID {
return ErrInstallationNotConfigured
}
if err := service.markBootstrapMaterializationRunning(ctx, trimmedInstallationID); err != nil {
return err
}
if err := service.materializeBootstrapStructure(ctx, *installation); err != nil {
failure := strings.TrimSpace(err.Error())
if failure == "" {
failure = "bootstrap materialization failed"
}
if markErr := service.markBootstrapMaterializationFailed(ctx, trimmedInstallationID, failure); markErr != nil {
return errors.Join(err, markErr)
}
return err
}
return service.markBootstrapMaterializationSucceeded(ctx, trimmedInstallationID)
}
func (service *Service) materializeBootstrapStructure(ctx context.Context, installation InstallationRecord) error {
admin, err := service.GetAdmin(ctx)
if err != nil {
return err
}
if admin == nil {
return ErrAdminNotConfigured
}
organization, err := service.loadPrimaryOrganization(ctx)
if err != nil {
return err
}
if organization == nil {
return ErrBootstrapStructureMissing
}
department, err := service.loadPrimaryDepartment(ctx)
if err != nil {
return err
}
if department == nil {
return ErrBootstrapStructureMissing
}
team, err := service.loadPrimaryTeam(ctx)
if err != nil {
return err
}
if team == nil {
return ErrBootstrapStructureMissing
}
project, err := service.loadPrimaryProject(ctx)
if err != nil {
return err
}
if project == nil {
return ErrBootstrapStructureMissing
}
if err := service.ensureBootstrapPOSIXSkeleton(
installation,
AdminSummary{ID: admin.ID, Email: admin.Email, DisplayName: admin.DisplayName},
namedRecord{ID: organization.ID, Name: organization.Name, Slug: organization.Slug},
namedRecord{ID: department.ID, Name: department.Name, Slug: department.Slug},
namedRecord{ID: team.ID, Name: team.Name, Slug: team.Slug},
namedRecord{ID: project.ID, Name: project.Name, Slug: project.Slug},
); err != nil {
return err
}
return service.rebuildProjection(ctx)
}
func (service *Service) rebuildProjection(ctx context.Context) error {
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil {
return fmt.Errorf("rebuild POSIX projection: %w", err)
}
return nil
}
func (service *Service) markBootstrapMaterializationRunning(ctx context.Context, installationID string) error {
_, err := service.db.Pool.Exec(ctx, `
UPDATE installations
SET
materialization_status = 'running'::bootstrap_materialization_status,
materialization_error = NULL,
materialization_started_at = NOW(),
materialization_finished_at = NULL,
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(installationID))
return err
}
func (service *Service) markBootstrapMaterializationSucceeded(ctx context.Context, installationID string) error {
_, err := service.db.Pool.Exec(ctx, `
UPDATE installations
SET
materialization_status = 'succeeded'::bootstrap_materialization_status,
materialization_error = NULL,
materialization_finished_at = NOW(),
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(installationID))
return err
}
func (service *Service) markBootstrapMaterializationFailed(ctx context.Context, installationID, failure string) error {
_, err := service.db.Pool.Exec(ctx, `
UPDATE installations
SET
materialization_status = 'failed'::bootstrap_materialization_status,
materialization_error = $2,
materialization_finished_at = NOW(),
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(installationID), strings.TrimSpace(failure))
return err
}
@@ -0,0 +1,73 @@
package bootstrap
import "context"
func (service *Service) GetState(ctx context.Context) (BootstrapState, error) {
installation, err := service.GetInstallation(ctx)
if err != nil {
return BootstrapState{}, err
}
admin, err := service.GetAdmin(ctx)
if err != nil {
return BootstrapState{}, err
}
structure, err := service.GetStructure(ctx)
if err != nil {
return BootstrapState{}, err
}
return BootstrapState{
Installation: installation,
Admin: admin,
Structure: structure,
}, nil
}
func (service *Service) GetAppShellState(ctx context.Context) (AppShellState, error) {
installation, err := service.GetInstallation(ctx)
if err != nil {
return AppShellState{}, err
}
admin, err := service.GetAdmin(ctx)
if err != nil {
return AppShellState{}, err
}
organizations, err := service.listOrganizations(ctx)
if err != nil {
return AppShellState{}, err
}
departments, err := service.listDepartments(ctx)
if err != nil {
return AppShellState{}, err
}
teams, err := service.listTeams(ctx)
if err != nil {
return AppShellState{}, err
}
projects, err := service.listProjects(ctx)
if err != nil {
return AppShellState{}, err
}
workspaces, err := service.listWorkspaces(ctx)
if err != nil {
return AppShellState{}, err
}
return AppShellState{
Installation: installation,
Admin: admin,
Organizations: organizations,
Departments: departments,
Teams: teams,
Projects: projects,
Workspaces: workspaces,
}, nil
}
+146 -254
View File
@@ -17,7 +17,6 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"moku-backend/internal/database" "moku-backend/internal/database"
"moku-backend/internal/posixproj"
) )
const ( const (
@@ -48,6 +47,7 @@ const (
var ( var (
ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed") ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed")
ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed") ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed")
ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete")
ErrProjectNotFound = errors.New("project not found") ErrProjectNotFound = errors.New("project not found")
ErrProjectFolderNotFound = errors.New("project folder not found") ErrProjectFolderNotFound = errors.New("project folder not found")
ErrInvalidProjectFolderMove = errors.New("invalid project folder move") ErrInvalidProjectFolderMove = errors.New("invalid project folder move")
@@ -83,13 +83,15 @@ type SaveStructureInput struct {
} }
type InstallationRecord struct { type InstallationRecord struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Mode string `json:"mode"` Mode string `json:"mode"`
Access string `json:"access"` Access string `json:"access"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Host string `json:"host"` Host string `json:"host"`
IsBootstrapped bool `json:"isBootstrapped"` IsBootstrapped bool `json:"isBootstrapped"`
MaterializationStatus string `json:"materializationStatus"`
MaterializationError *string `json:"materializationError,omitempty"`
} }
type AdminRecord struct { type AdminRecord struct {
@@ -194,29 +196,29 @@ type ProjectHierarchyFolderRecord struct {
} }
type CreateProjectFolderInput struct { type CreateProjectFolderInput struct {
ProjectID string ProjectID string
ParentFolderID string ParentFolderPath string
Name string Name string
} }
type DeleteProjectFolderInput struct { type DeleteProjectFolderInput struct {
ProjectID string ProjectID string
FolderID string FolderPath string
} }
type RenameProjectFolderInput struct { type RenameProjectFolderInput struct {
ProjectID string ProjectID string
FolderID string FolderPath string
Name string Name string
} }
type MoveProjectFolderInput struct { type MoveProjectFolderInput struct {
ProjectID string ProjectID string
FolderID string FolderPath string
FolderNodeID string FolderStableID string
ParentFolderID string ParentFolderPath string
ParentNodeID string ParentStableID string
TargetIndex int TargetIndex int
} }
type CreateProjectFolderResult struct { type CreateProjectFolderResult struct {
@@ -226,26 +228,26 @@ type CreateProjectFolderResult struct {
} }
type DeleteProjectFolderResult struct { type DeleteProjectFolderResult struct {
ProjectID string `json:"projectId"` ProjectID string `json:"projectId"`
DeletedFolderID string `json:"deletedFolderId"` DeletedFolderStableID string `json:"deletedFolderId"`
DeletedFolderPath string `json:"deletedFolderPath"` DeletedFolderPath string `json:"deletedFolderPath"`
Folders []ProjectHierarchyFolderRecord `json:"folders"` Folders []ProjectHierarchyFolderRecord `json:"folders"`
} }
type RenameProjectFolderResult struct { type RenameProjectFolderResult struct {
ProjectID string `json:"projectId"` ProjectID string `json:"projectId"`
PreviousFolderID string `json:"previousFolderId"` PreviousFolderStableID string `json:"previousFolderId"`
PreviousFolderPath string `json:"previousFolderPath"` PreviousFolderPath string `json:"previousFolderPath"`
RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"` RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"` Folders []ProjectHierarchyFolderRecord `json:"folders"`
} }
type MoveProjectFolderResult struct { type MoveProjectFolderResult struct {
ProjectID string `json:"projectId"` ProjectID string `json:"projectId"`
PreviousFolderID string `json:"previousFolderId"` PreviousFolderStableID string `json:"previousFolderId"`
PreviousFolderPath string `json:"previousFolderPath"` PreviousFolderPath string `json:"previousFolderPath"`
MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"` MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"` Folders []ProjectHierarchyFolderRecord `json:"folders"`
} }
type projectHierarchyFolderRow struct { type projectHierarchyFolderRow struct {
@@ -276,7 +278,16 @@ func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInpu
protocol = EXCLUDED.protocol, protocol = EXCLUDED.protocol,
host = EXCLUDED.host, host = EXCLUDED.host,
updated_at = NOW() updated_at = NOW()
RETURNING id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped; RETURNING
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error;
`, input.Access, input.Protocol, input.Host) `, input.Access, input.Protocol, input.Host)
return scanInstallationRecord(row) return scanInstallationRecord(row)
@@ -298,7 +309,16 @@ func (service *Service) SaveMode(ctx context.Context, input SaveModeInput) (Inst
name = EXCLUDED.name, name = EXCLUDED.name,
mode = EXCLUDED.mode, mode = EXCLUDED.mode,
updated_at = NOW() updated_at = NOW()
RETURNING id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped; RETURNING
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error;
`, input.Mode, input.Name, defaultInstallationHost) `, input.Mode, input.Name, defaultInstallationHost)
return scanInstallationRecord(row) return scanInstallationRecord(row)
@@ -360,6 +380,11 @@ func (service *Service) SaveAdmin(ctx context.Context, input SaveAdminInput) (Ad
return record, nil return record, nil
} }
// SaveStructure persists the bootstrap domain records synchronously, then hands the
// slow POSIX/projector materialization work to the background worker.
//
// This keeps the API request responsible for validation and durable relational writes,
// while the worker owns retryable filesystem/projection side effects.
func (service *Service) SaveStructure(ctx context.Context, input SaveStructureInput) (StructureRecord, error) { func (service *Service) SaveStructure(ctx context.Context, input SaveStructureInput) (StructureRecord, error) {
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{}) tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
@@ -369,27 +394,19 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
_ = tx.Rollback(ctx) _ = tx.Rollback(ctx)
}() }()
installation, err := loadInstallation(ctx, tx) prerequisites, err := service.loadBootstrapStructurePrerequisites(ctx, tx)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return StructureRecord{}, ErrInstallationNotConfigured
}
return StructureRecord{}, err
}
admin, err := loadPrimaryAdmin(ctx, tx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return StructureRecord{}, ErrAdminNotConfigured
}
return StructureRecord{}, err return StructureRecord{}, err
} }
organizationName := strings.TrimSpace(input.OrganizationName) organizationName := strings.TrimSpace(input.OrganizationName)
if organizationName == "" { if organizationName == "" {
organizationName = defaultRootOrganizationName(installation.Name, installation.Mode, installation.Host, admin.DisplayName) organizationName = defaultRootOrganizationName(
prerequisites.installation.Name,
prerequisites.installation.Mode,
prerequisites.installation.Host,
prerequisites.admin.DisplayName,
)
} }
organization, err := upsertNamedRecord(ctx, tx, ` organization, err := upsertNamedRecord(ctx, tx, `
@@ -398,7 +415,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
ON CONFLICT (slug) DO UPDATE ON CONFLICT (slug) DO UPDATE
SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW() SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug; RETURNING id::text, name, slug;
`, organizationName, primaryOrganizationSlug, admin.ID) `, organizationName, primaryOrganizationSlug, prerequisites.admin.ID)
if err != nil { if err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -408,7 +425,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role) VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (organization_id, user_id) DO UPDATE ON CONFLICT (organization_id, user_id) DO UPDATE
SET role = EXCLUDED.role; SET role = EXCLUDED.role;
`, organization.ID, admin.ID); err != nil { `, organization.ID, prerequisites.admin.ID); err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -418,7 +435,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
ON CONFLICT (organization_id, slug) DO UPDATE ON CONFLICT (organization_id, slug) DO UPDATE
SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW() SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug; RETURNING id::text, name, slug;
`, organization.ID, input.DepartmentName, primaryDepartmentSlug, admin.ID) `, organization.ID, input.DepartmentName, primaryDepartmentSlug, prerequisites.admin.ID)
if err != nil { if err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -429,7 +446,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
ON CONFLICT (organization_id, slug) DO UPDATE ON CONFLICT (organization_id, slug) DO UPDATE
SET department_id = EXCLUDED.department_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW() SET department_id = EXCLUDED.department_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug; RETURNING id::text, name, slug;
`, organization.ID, department.ID, input.TeamName, primaryTeamSlug, admin.ID) `, organization.ID, department.ID, input.TeamName, primaryTeamSlug, prerequisites.admin.ID)
if err != nil { if err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -439,7 +456,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role) VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (team_id, user_id) DO UPDATE ON CONFLICT (team_id, user_id) DO UPDATE
SET role = EXCLUDED.role; SET role = EXCLUDED.role;
`, team.ID, admin.ID); err != nil { `, team.ID, prerequisites.admin.ID); err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -449,7 +466,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
ON CONFLICT (organization_id, slug) DO UPDATE ON CONFLICT (organization_id, slug) DO UPDATE
SET department_id = EXCLUDED.department_id, team_id = EXCLUDED.team_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW() SET department_id = EXCLUDED.department_id, team_id = EXCLUDED.team_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug; RETURNING id::text, name, slug;
`, organization.ID, department.ID, team.ID, input.ProjectName, primaryProjectSlug, admin.ID) `, organization.ID, department.ID, team.ID, input.ProjectName, primaryProjectSlug, prerequisites.admin.ID)
if err != nil { if err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -459,27 +476,27 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role) VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (project_id, user_id) DO UPDATE ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role; SET role = EXCLUDED.role;
`, project.ID, admin.ID); err != nil { `, project.ID, prerequisites.admin.ID); err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
if err := upsertWorkspace(ctx, tx, organization.ID, organization.Name, organizationWorkspaceSlug, bootstrapWorkspaceKindOrg, admin.ID, nil, nil, nil); err != nil { if err := upsertWorkspace(ctx, tx, organization.ID, organization.Name, organizationWorkspaceSlug, bootstrapWorkspaceKindOrg, prerequisites.admin.ID, nil, nil, nil); err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
if err := upsertWorkspace(ctx, tx, organization.ID, department.Name, departmentWorkspaceSlug, bootstrapWorkspaceKindDept, admin.ID, &department.ID, nil, nil); err != nil { if err := upsertWorkspace(ctx, tx, organization.ID, department.Name, departmentWorkspaceSlug, bootstrapWorkspaceKindDept, prerequisites.admin.ID, &department.ID, nil, nil); err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
if err := upsertWorkspace(ctx, tx, organization.ID, team.Name, teamWorkspaceSlug, bootstrapWorkspaceKindTeam, admin.ID, &department.ID, &team.ID, nil); err != nil { if err := upsertWorkspace(ctx, tx, organization.ID, team.Name, teamWorkspaceSlug, bootstrapWorkspaceKindTeam, prerequisites.admin.ID, &department.ID, &team.ID, nil); err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
if err := upsertWorkspace(ctx, tx, organization.ID, project.Name, projectWorkspaceSlug, bootstrapWorkspaceKindProject, admin.ID, &department.ID, &team.ID, &project.ID); err != nil { if err := upsertWorkspace(ctx, tx, organization.ID, project.Name, projectWorkspaceSlug, bootstrapWorkspaceKindProject, prerequisites.admin.ID, &department.ID, &team.ID, &project.ID); err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
installation, err = updateBootstrappedInstallation(ctx, tx) installation, err := updateBootstrappedInstallation(ctx, tx)
if err != nil { if err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -488,21 +505,17 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
return StructureRecord{}, err return StructureRecord{}, err
} }
if err := service.ensureBootstrapPOSIXSkeleton(installation, admin, organization, department, team, project); err != nil { if err := service.enqueueBootstrapStructureMaterialization(ctx, &installation); err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil {
return StructureRecord{}, fmt.Errorf("rebuild POSIX projection: %w", err)
}
return StructureRecord{ return StructureRecord{
Installation: installation, Installation: installation,
Organization: organization, Organization: organization,
Department: department, Department: department,
Team: team, Team: team,
Project: project, Project: project,
Admin: admin, Admin: prerequisites.admin,
}, nil }, nil
} }
@@ -527,6 +540,7 @@ func (service *Service) ResetDevelopmentState(ctx context.Context) error {
user_homes, user_homes,
users, users,
organizations, organizations,
background_jobs,
installations installations
RESTART IDENTITY; RESTART IDENTITY;
`); err != nil { `); err != nil {
@@ -536,24 +550,6 @@ func (service *Service) ResetDevelopmentState(ctx context.Context) error {
return tx.Commit(ctx) return tx.Commit(ctx)
} }
func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) {
record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, `
SELECT id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped
FROM installations
WHERE singleton = TRUE
LIMIT 1;
`))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) GetAdmin(ctx context.Context) (*AdminRecord, error) { func (service *Service) GetAdmin(ctx context.Context) (*AdminRecord, error) {
var record AdminRecord var record AdminRecord
err := service.db.Pool.QueryRow(ctx, ` err := service.db.Pool.QueryRow(ctx, `
@@ -620,86 +616,6 @@ func (service *Service) GetStructure(ctx context.Context) (BootstrapStructureSta
Workspaces: workspaces, Workspaces: workspaces,
}, nil }, nil
} }
func (service *Service) GetState(ctx context.Context) (BootstrapState, error) {
installation, err := service.GetInstallation(ctx)
if err != nil {
return BootstrapState{}, err
}
admin, err := service.GetAdmin(ctx)
if err != nil {
return BootstrapState{}, err
}
structure, err := service.GetStructure(ctx)
if err != nil {
return BootstrapState{}, err
}
return BootstrapState{
Installation: installation,
Admin: admin,
Structure: structure,
}, nil
}
func (service *Service) GetAppShellState(ctx context.Context) (AppShellState, error) {
installation, err := service.GetInstallation(ctx)
if err != nil {
return AppShellState{}, err
}
admin, err := service.GetAdmin(ctx)
if err != nil {
return AppShellState{}, err
}
organizations, err := service.listOrganizations(ctx)
if err != nil {
return AppShellState{}, err
}
departments, err := service.listDepartments(ctx)
if err != nil {
return AppShellState{}, err
}
teams, err := service.listTeams(ctx)
if err != nil {
return AppShellState{}, err
}
projects, err := service.listProjects(ctx)
if err != nil {
return AppShellState{}, err
}
workspaces, err := service.listWorkspaces(ctx)
if err != nil {
return AppShellState{}, err
}
return AppShellState{
Installation: installation,
Admin: admin,
Organizations: organizations,
Departments: departments,
Teams: teams,
Projects: projects,
Workspaces: workspaces,
}, nil
}
func scanInstallationRecord(row pgx.Row) (InstallationRecord, error) {
var record InstallationRecord
if err := row.Scan(&record.ID, &record.Name, &record.Mode, &record.Access, &record.Protocol, &record.Host, &record.IsBootstrapped); err != nil {
return InstallationRecord{}, err
}
return record, nil
}
func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) { func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) {
var record OrganizationRecord var record OrganizationRecord
err := service.db.Pool.QueryRow(ctx, ` err := service.db.Pool.QueryRow(ctx, `
@@ -945,6 +861,9 @@ func (service *Service) getProjectHierarchyFoldersByRootPath(
return nil, err return nil, err
} }
// The projection gives us an unordered tree snapshot. Sibling order is stored in
// the project settings file, so the read path has to rebuild the tree first and
// then apply persisted ordering on top.
folders := buildProjectHierarchyFolderTree(folderRows, rootParentPath) folders := buildProjectHierarchyFolderTree(folderRows, rootParentPath)
folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath) folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
@@ -987,8 +906,14 @@ func (service *Service) createProjectHierarchyFolder(
ctx context.Context, ctx context.Context,
input CreateProjectFolderInput, input CreateProjectFolderInput,
rootPath func(projectSlug string) string, rootPath func(projectSlug string) string,
createOnDisk func(projectSlug, parentFolderID, name string) (string, string, error), createOnDisk func(projectSlug, parentFolderPath, name string) (string, string, error),
) (CreateProjectFolderResult, error) { ) (CreateProjectFolderResult, error) {
// Folder mutations follow the same pattern:
// 1. validate/resolve against the current ordered tree
// 2. mutate POSIX on disk
// 3. rebuild the projection snapshot
// 4. rewrite sibling ordering metadata
// 5. re-read the ordered tree that the frontend should trust
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil { if err != nil {
return CreateProjectFolderResult{}, err return CreateProjectFolderResult{}, err
@@ -1000,22 +925,22 @@ func (service *Service) createProjectHierarchyFolder(
} }
parentOrderID := "" parentOrderID := ""
trimmedParentFolderID := strings.TrimSpace(input.ParentFolderID) trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
if trimmedParentFolderID != "" { if trimmedParentFolderPath != "" {
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderID) parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
if !found { if !found {
return CreateProjectFolderResult{}, ErrProjectFolderNotFound return CreateProjectFolderResult{}, ErrProjectFolderNotFound
} }
parentOrderID = parentFolder.ID parentOrderID = parentFolder.ID
} }
createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderID), input.Name) createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderPath), input.Name)
if err != nil { if err != nil {
return CreateProjectFolderResult{}, err return CreateProjectFolderResult{}, err
} }
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { if err := service.rebuildProjection(ctx); err != nil {
return CreateProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) return CreateProjectFolderResult{}, err
} }
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
@@ -1056,7 +981,7 @@ func (service *Service) deleteProjectHierarchyFolder(
ctx context.Context, ctx context.Context,
input DeleteProjectFolderInput, input DeleteProjectFolderInput,
rootPath func(projectSlug string) string, rootPath func(projectSlug string) string,
deleteOnDisk func(projectSlug, folderID string) (string, error), deleteOnDisk func(projectSlug, folderPath string) (string, error),
) (DeleteProjectFolderResult, error) { ) (DeleteProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil { if err != nil {
@@ -1068,18 +993,18 @@ func (service *Service) deleteProjectHierarchyFolder(
return DeleteProjectFolderResult{}, err return DeleteProjectFolderResult{}, err
} }
deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderID)) deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
if !found { if !found {
return DeleteProjectFolderResult{}, ErrProjectFolderNotFound return DeleteProjectFolderResult{}, ErrProjectFolderNotFound
} }
deletedFolderID, err := deleteOnDisk(project.Slug, input.FolderID) deletedFolderPath, err := deleteOnDisk(project.Slug, input.FolderPath)
if err != nil { if err != nil {
return DeleteProjectFolderResult{}, err return DeleteProjectFolderResult{}, err
} }
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { if err := service.rebuildProjection(ctx); err != nil {
return DeleteProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) return DeleteProjectFolderResult{}, err
} }
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
@@ -1087,7 +1012,7 @@ func (service *Service) deleteProjectHierarchyFolder(
return DeleteProjectFolderResult{}, err return DeleteProjectFolderResult{}, err
} }
if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderID); found { if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderPath); found {
return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection") return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection")
} }
@@ -1103,10 +1028,10 @@ func (service *Service) deleteProjectHierarchyFolder(
} }
return DeleteProjectFolderResult{ return DeleteProjectFolderResult{
ProjectID: project.ID, ProjectID: project.ID,
DeletedFolderID: deletedFolder.ID, DeletedFolderStableID: deletedFolder.ID,
DeletedFolderPath: deletedFolderID, DeletedFolderPath: deletedFolderPath,
Folders: folders, Folders: folders,
}, nil }, nil
} }
@@ -1114,20 +1039,20 @@ func (service *Service) renameProjectHierarchyFolder(
ctx context.Context, ctx context.Context,
input RenameProjectFolderInput, input RenameProjectFolderInput,
rootPath func(projectSlug string) string, rootPath func(projectSlug string) string,
renameOnDisk func(projectSlug, folderID, name string) (string, string, error), renameOnDisk func(projectSlug, folderPath, name string) (string, string, error),
) (RenameProjectFolderResult, error) { ) (RenameProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil { if err != nil {
return RenameProjectFolderResult{}, err return RenameProjectFolderResult{}, err
} }
previousFolderID, renamedFolderID, err := renameOnDisk(project.Slug, input.FolderID, input.Name) previousFolderPath, renamedFolderPath, err := renameOnDisk(project.Slug, input.FolderPath, input.Name)
if err != nil { if err != nil {
return RenameProjectFolderResult{}, err return RenameProjectFolderResult{}, err
} }
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { if err := service.rebuildProjection(ctx); err != nil {
return RenameProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) return RenameProjectFolderResult{}, err
} }
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
@@ -1135,21 +1060,21 @@ func (service *Service) renameProjectHierarchyFolder(
return RenameProjectFolderResult{}, err return RenameProjectFolderResult{}, err
} }
renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderID) renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderPath)
if !found { if !found {
return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection") return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection")
} }
if _, found := findProjectHierarchyFolderByPath(folders, previousFolderID); found { if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection") return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
} }
return RenameProjectFolderResult{ return RenameProjectFolderResult{
ProjectID: project.ID, ProjectID: project.ID,
PreviousFolderID: renamedFolder.ID, PreviousFolderStableID: renamedFolder.ID,
PreviousFolderPath: previousFolderID, PreviousFolderPath: previousFolderPath,
RenamedFolder: renamedFolder, RenamedFolder: renamedFolder,
Folders: folders, Folders: folders,
}, nil }, nil
} }
@@ -1157,7 +1082,7 @@ func (service *Service) moveProjectHierarchyFolder(
ctx context.Context, ctx context.Context,
input MoveProjectFolderInput, input MoveProjectFolderInput,
rootPath func(projectSlug string) string, rootPath func(projectSlug string) string,
moveOnDisk func(projectSlug, folderID, parentFolderID string) (string, string, error), moveOnDisk func(projectSlug, folderPath, parentFolderPath string) (string, string, error),
) (MoveProjectFolderResult, error) { ) (MoveProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil { if err != nil {
@@ -1169,40 +1094,40 @@ func (service *Service) moveProjectHierarchyFolder(
return MoveProjectFolderResult{}, err return MoveProjectFolderResult{}, err
} }
currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderID)) currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
if !found { if !found {
return MoveProjectFolderResult{}, ErrProjectFolderNotFound return MoveProjectFolderResult{}, ErrProjectFolderNotFound
} }
movedFolderOrderID := currentFolder.ID movedFolderStableID := currentFolder.ID
providedFolderNodeID := strings.TrimSpace(input.FolderNodeID) providedFolderStableID := strings.TrimSpace(input.FolderStableID)
if providedFolderNodeID != "" && providedFolderNodeID != movedFolderOrderID { if providedFolderStableID != "" && providedFolderStableID != movedFolderStableID {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
} }
parentOrderID := "" parentOrderID := ""
trimmedParentFolderID := strings.TrimSpace(input.ParentFolderID) trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
providedParentNodeID := strings.TrimSpace(input.ParentNodeID) providedParentStableID := strings.TrimSpace(input.ParentStableID)
if trimmedParentFolderID != "" { if trimmedParentFolderPath != "" {
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderID) parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
if !found { if !found {
return MoveProjectFolderResult{}, ErrProjectFolderNotFound return MoveProjectFolderResult{}, ErrProjectFolderNotFound
} }
parentOrderID = parentFolder.ID parentOrderID = parentFolder.ID
if providedParentNodeID != "" && providedParentNodeID != parentOrderID { if providedParentStableID != "" && providedParentStableID != parentOrderID {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
} }
} else if providedParentNodeID != "" { } else if providedParentStableID != "" {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
} }
previousFolderID, movedFolderID, err := moveOnDisk(project.Slug, input.FolderID, input.ParentFolderID) previousFolderPath, movedFolderPath, err := moveOnDisk(project.Slug, input.FolderPath, input.ParentFolderPath)
if err != nil { if err != nil {
return MoveProjectFolderResult{}, err return MoveProjectFolderResult{}, err
} }
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { if err := service.rebuildProjection(ctx); err != nil {
return MoveProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) return MoveProjectFolderResult{}, err
} }
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
@@ -1210,20 +1135,20 @@ func (service *Service) moveProjectHierarchyFolder(
return MoveProjectFolderResult{}, err return MoveProjectFolderResult{}, err
} }
movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderID) movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderPath)
if !found { if !found {
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection") return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection")
} }
if previousFolderID != movedFolderID { if previousFolderPath != movedFolderPath {
if _, found := findProjectHierarchyFolderByPath(folders, previousFolderID); found { if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection") return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
} }
} }
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedFolderOrderParent(folderOrder, currentFolders, parentOrderID) seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
removeFolderOrderReference(folderOrder, movedFolderOrderID) removeFolderOrderReference(folderOrder, movedFolderStableID)
removeFolderOrderReference(folderOrder, movedFolder.ID) removeFolderOrderReference(folderOrder, movedFolder.ID)
insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex) insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
@@ -1235,17 +1160,17 @@ func (service *Service) moveProjectHierarchyFolder(
return MoveProjectFolderResult{}, err return MoveProjectFolderResult{}, err
} }
movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderID) movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderPath)
if !found { if !found {
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection") return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection")
} }
return MoveProjectFolderResult{ return MoveProjectFolderResult{
ProjectID: project.ID, ProjectID: project.ID,
PreviousFolderID: movedFolder.ID, PreviousFolderStableID: movedFolder.ID,
PreviousFolderPath: previousFolderID, PreviousFolderPath: previousFolderPath,
MovedFolder: movedFolder, MovedFolder: movedFolder,
Folders: folders, Folders: folders,
}, nil }, nil
} }
@@ -1273,39 +1198,6 @@ func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord,
return records, rows.Err() return records, rows.Err()
} }
func loadInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
return scanInstallationRecord(tx.QueryRow(ctx, `
SELECT id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped
FROM installations
WHERE singleton = TRUE
LIMIT 1;
`))
}
func loadPrimaryAdmin(ctx context.Context, tx pgx.Tx) (AdminSummary, error) {
var admin AdminSummary
if err := tx.QueryRow(ctx, `
SELECT id::text, email, display_name
FROM users
WHERE is_instance_admin = TRUE
ORDER BY created_at ASC
LIMIT 1;
`).Scan(&admin.ID, &admin.Email, &admin.DisplayName); err != nil {
return AdminSummary{}, err
}
return admin, nil
}
func updateBootstrappedInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
return scanInstallationRecord(tx.QueryRow(ctx, `
UPDATE installations
SET is_bootstrapped = TRUE, bootstrapped_at = COALESCE(bootstrapped_at, NOW()), updated_at = NOW()
WHERE singleton = TRUE
RETURNING id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped;
`))
}
func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) { func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) {
var record namedRecord var record namedRecord
if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil { if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil {
@@ -9,6 +9,66 @@ import (
"testing" "testing"
) )
type fakeRow struct {
scan func(dest ...any) error
}
func (row fakeRow) Scan(dest ...any) error {
return row.scan(dest...)
}
func TestScanInstallationRecordDefaultsMaterializationStatus(t *testing.T) {
record, err := scanInstallationRecord(fakeRow{scan: func(dest ...any) error {
*(dest[0].(*string)) = "installation-1"
*(dest[1].(*string)) = "MangoPig"
*(dest[2].(*string)) = "personal"
*(dest[3].(*string)) = "local"
*(dest[4].(*string)) = "http"
*(dest[5].(*string)) = "localhost"
*(dest[6].(*bool)) = true
*(dest[7].(*string)) = ""
*(dest[8].(**string)) = nil
return nil
}})
if err != nil {
t.Fatalf("scanInstallationRecord: %v", err)
}
if record.MaterializationStatus != materializationNotStarted {
t.Fatalf("expected default materialization status %q, got %q", materializationNotStarted, record.MaterializationStatus)
}
if record.MaterializationError != nil {
t.Fatalf("expected nil materialization error, got %#v", record.MaterializationError)
}
}
func TestScanInstallationRecordPreservesMaterializationFields(t *testing.T) {
failure := "projection rebuild failed"
record, err := scanInstallationRecord(fakeRow{scan: func(dest ...any) error {
*(dest[0].(*string)) = "installation-2"
*(dest[1].(*string)) = "MangoPig"
*(dest[2].(*string)) = "personal"
*(dest[3].(*string)) = "local"
*(dest[4].(*string)) = "http"
*(dest[5].(*string)) = "localhost"
*(dest[6].(*bool)) = true
*(dest[7].(*string)) = materializationFailed
*(dest[8].(**string)) = &failure
return nil
}})
if err != nil {
t.Fatalf("scanInstallationRecord: %v", err)
}
if record.MaterializationStatus != materializationFailed {
t.Fatalf("expected materialization status %q, got %q", materializationFailed, record.MaterializationStatus)
}
if record.MaterializationError == nil || *record.MaterializationError != failure {
t.Fatalf("expected materialization error %q, got %#v", failure, record.MaterializationError)
}
}
func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) { func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX") rootPath := filepath.Join(t.TempDir(), "POSIX")
t.Setenv("POSIX_ROOT", rootPath) t.Setenv("POSIX_ROOT", rootPath)
+59 -57
View File
@@ -13,25 +13,27 @@ import (
) )
type createProjectFolderRequest struct { type createProjectFolderRequest struct {
Name string `json:"name"` Name string `json:"name"`
ParentFolderID string `json:"parentFolderId"` ParentFolderPath string `json:"parentFolderId"`
} }
type renameProjectFolderRequest struct { type renameProjectFolderRequest struct {
FolderID string `json:"folderId"` FolderPath string `json:"folderId"`
Name string `json:"name"` Name string `json:"name"`
} }
type deleteProjectFolderRequest struct { type deleteProjectFolderRequest struct {
FolderID string `json:"folderId"` FolderPath string `json:"folderId"`
} }
// Keep the existing JSON contract for the frontend, but use clearer path-vs-stable-ID
// names internally so the move flow is easier to reason about.
type moveProjectFolderRequest struct { type moveProjectFolderRequest struct {
FolderID string `json:"folderId"` FolderPath string `json:"folderId"`
FolderNodeID string `json:"folderNodeId"` FolderStableID string `json:"folderNodeId"`
ParentFolderID string `json:"parentFolderId"` ParentFolderPath string `json:"parentFolderId"`
ParentNodeID string `json:"parentNodeId"` ParentStableID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"` TargetIndex int `json:"targetIndex"`
} }
func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) { func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) {
@@ -71,16 +73,16 @@ func (routes apiRoutes) handleCreateProjectFolder(w http.ResponseWriter, r *http
} }
payload.Name = strings.TrimSpace(payload.Name) payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
if payload.Name == "" { if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return return
} }
result, err := routes.bootstrapService().CreateProjectFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ result, err := routes.bootstrapService().CreateProjectFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
ParentFolderID: payload.ParentFolderID, ParentFolderPath: payload.ParentFolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "persist") routes.writeProjectFolderError(w, r, err, "persist")
@@ -104,14 +106,14 @@ func (routes apiRoutes) handleDeleteProjectFolder(w http.ResponseWriter, r *http
} }
payload := decodeDeleteProjectFolderRequest(r) payload := decodeDeleteProjectFolderRequest(r)
if strings.TrimSpace(payload.FolderID) == "" { if strings.TrimSpace(payload.FolderPath) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
result, err := routes.bootstrapService().DeleteProjectFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ result, err := routes.bootstrapService().DeleteProjectFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "delete") routes.writeProjectFolderError(w, r, err, "delete")
@@ -139,9 +141,9 @@ func (routes apiRoutes) handleRenameProjectFolder(w http.ResponseWriter, r *http
return return
} }
payload.FolderID = strings.TrimSpace(payload.FolderID) payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.Name = strings.TrimSpace(payload.Name) payload.Name = strings.TrimSpace(payload.Name)
if payload.FolderID == "" { if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
@@ -151,9 +153,9 @@ func (routes apiRoutes) handleRenameProjectFolder(w http.ResponseWriter, r *http
} }
result, err := routes.bootstrapService().RenameProjectFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ result, err := routes.bootstrapService().RenameProjectFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "rename") routes.writeProjectFolderError(w, r, err, "rename")
@@ -181,22 +183,22 @@ func (routes apiRoutes) handleMoveProjectFolder(w http.ResponseWriter, r *http.R
return return
} }
payload.FolderID = strings.TrimSpace(payload.FolderID) payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.FolderNodeID = strings.TrimSpace(payload.FolderNodeID) payload.FolderStableID = strings.TrimSpace(payload.FolderStableID)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ParentNodeID = strings.TrimSpace(payload.ParentNodeID) payload.ParentStableID = strings.TrimSpace(payload.ParentStableID)
if payload.FolderID == "" { if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
result, err := routes.bootstrapService().MoveProjectFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{ result, err := routes.bootstrapService().MoveProjectFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
FolderNodeID: payload.FolderNodeID, FolderStableID: payload.FolderStableID,
ParentFolderID: payload.ParentFolderID, ParentFolderPath: payload.ParentFolderPath,
ParentNodeID: payload.ParentNodeID, ParentStableID: payload.ParentStableID,
TargetIndex: payload.TargetIndex, TargetIndex: payload.TargetIndex,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "move") routes.writeProjectFolderError(w, r, err, "move")
@@ -249,16 +251,16 @@ func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *
} }
payload.Name = strings.TrimSpace(payload.Name) payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
if payload.Name == "" { if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return return
} }
result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
ParentFolderID: payload.ParentFolderID, ParentFolderPath: payload.ParentFolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "persist") routes.writeProjectFolderError(w, r, err, "persist")
@@ -282,14 +284,14 @@ func (routes apiRoutes) handleDeleteProjectTreeFolder(w http.ResponseWriter, r *
} }
payload := decodeDeleteProjectFolderRequest(r) payload := decodeDeleteProjectFolderRequest(r)
if strings.TrimSpace(payload.FolderID) == "" { if strings.TrimSpace(payload.FolderPath) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
result, err := routes.bootstrapService().DeleteProjectTreeFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ result, err := routes.bootstrapService().DeleteProjectTreeFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "delete") routes.writeProjectFolderError(w, r, err, "delete")
@@ -317,9 +319,9 @@ func (routes apiRoutes) handleRenameProjectTreeFolder(w http.ResponseWriter, r *
return return
} }
payload.FolderID = strings.TrimSpace(payload.FolderID) payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.Name = strings.TrimSpace(payload.Name) payload.Name = strings.TrimSpace(payload.Name)
if payload.FolderID == "" { if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
@@ -329,9 +331,9 @@ func (routes apiRoutes) handleRenameProjectTreeFolder(w http.ResponseWriter, r *
} }
result, err := routes.bootstrapService().RenameProjectTreeFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ result, err := routes.bootstrapService().RenameProjectTreeFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "rename") routes.writeProjectFolderError(w, r, err, "rename")
@@ -359,22 +361,22 @@ func (routes apiRoutes) handleMoveProjectTreeFolder(w http.ResponseWriter, r *ht
return return
} }
payload.FolderID = strings.TrimSpace(payload.FolderID) payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.FolderNodeID = strings.TrimSpace(payload.FolderNodeID) payload.FolderStableID = strings.TrimSpace(payload.FolderStableID)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ParentNodeID = strings.TrimSpace(payload.ParentNodeID) payload.ParentStableID = strings.TrimSpace(payload.ParentStableID)
if payload.FolderID == "" { if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
result, err := routes.bootstrapService().MoveProjectTreeFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{ result, err := routes.bootstrapService().MoveProjectTreeFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
FolderNodeID: payload.FolderNodeID, FolderStableID: payload.FolderStableID,
ParentFolderID: payload.ParentFolderID, ParentFolderPath: payload.ParentFolderPath,
ParentNodeID: payload.ParentNodeID, ParentStableID: payload.ParentStableID,
TargetIndex: payload.TargetIndex, TargetIndex: payload.TargetIndex,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "move") routes.writeProjectFolderError(w, r, err, "move")
@@ -432,7 +434,7 @@ func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (mov
func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest { func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest {
return deleteProjectFolderRequest{ return deleteProjectFolderRequest{
FolderID: strings.TrimSpace(r.URL.Query().Get("folderId")), FolderPath: strings.TrimSpace(r.URL.Query().Get("folderId")),
} }
} }
+196
View File
@@ -0,0 +1,196 @@
package jobs
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/jackc/pgx/v5"
"moku-backend/internal/database"
)
const (
KindBootstrapStructureMaterialize = "bootstrap.structure.materialize"
)
type Status string
const (
StatusPending Status = "pending"
StatusRunning Status = "running"
StatusSucceeded Status = "succeeded"
StatusFailed Status = "failed"
)
type BootstrapStructureMaterializePayload struct {
InstallationID string `json:"installationId"`
}
type Job struct {
ID string
Kind string
Status Status
Payload json.RawMessage
Attempts int
MaxAttempts int
AvailableAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
LastError *string
CreatedAt time.Time
UpdatedAt time.Time
}
type EnqueueInput struct {
Kind string
Payload any
AvailableAt time.Time
MaxAttempts int
}
type Store struct {
db *database.DB
}
func NewStore(db *database.DB) *Store {
return &Store{db: db}
}
func (store *Store) Enqueue(ctx context.Context, input EnqueueInput) (Job, error) {
payload := json.RawMessage([]byte(`{}`))
if input.Payload != nil {
encoded, err := json.Marshal(input.Payload)
if err != nil {
return Job{}, err
}
payload = encoded
}
availableAt := input.AvailableAt
if availableAt.IsZero() {
availableAt = time.Now().UTC()
}
maxAttempts := input.MaxAttempts
if maxAttempts < 1 {
maxAttempts = 1
}
return scanJob(store.db.Pool.QueryRow(ctx, `
INSERT INTO background_jobs (kind, status, payload, attempts, max_attempts, available_at)
VALUES ($1, 'pending'::background_job_status, $2::jsonb, 0, $3, $4)
RETURNING
id::text,
kind,
status::text,
payload,
attempts,
max_attempts,
available_at,
started_at,
finished_at,
last_error,
created_at,
updated_at;
`, strings.TrimSpace(input.Kind), payload, maxAttempts, availableAt))
}
func (store *Store) ClaimNext(ctx context.Context) (*Job, error) {
job, err := scanJob(store.db.Pool.QueryRow(ctx, `
WITH next_job AS (
SELECT id
FROM background_jobs
WHERE status = 'pending'::background_job_status
AND available_at <= NOW()
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE background_jobs AS jobs
SET
status = 'running'::background_job_status,
attempts = jobs.attempts + 1,
started_at = NOW(),
finished_at = NULL,
last_error = NULL,
updated_at = NOW()
FROM next_job
WHERE jobs.id = next_job.id
RETURNING
jobs.id::text,
jobs.kind,
jobs.status::text,
jobs.payload,
jobs.attempts,
jobs.max_attempts,
jobs.available_at,
jobs.started_at,
jobs.finished_at,
jobs.last_error,
jobs.created_at,
jobs.updated_at;
`))
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, err
}
return &job, nil
}
func (store *Store) MarkSucceeded(ctx context.Context, jobID string) error {
_, err := store.db.Pool.Exec(ctx, `
UPDATE background_jobs
SET
status = 'succeeded'::background_job_status,
finished_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(jobID))
return err
}
func (store *Store) MarkFailed(ctx context.Context, jobID, failure string) error {
_, err := store.db.Pool.Exec(ctx, `
UPDATE background_jobs
SET
status = 'failed'::background_job_status,
finished_at = NOW(),
last_error = $2,
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(jobID), strings.TrimSpace(failure))
return err
}
func scanJob(row pgx.Row) (Job, error) {
var job Job
var status string
if err := row.Scan(
&job.ID,
&job.Kind,
&status,
&job.Payload,
&job.Attempts,
&job.MaxAttempts,
&job.AvailableAt,
&job.StartedAt,
&job.FinishedAt,
&job.LastError,
&job.CreatedAt,
&job.UpdatedAt,
); err != nil {
return Job{}, err
}
job.Status = Status(status)
return job, nil
}
+124
View File
@@ -0,0 +1,124 @@
package worker
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"moku-backend/internal/jobs"
)
type JobStore interface {
ClaimNext(ctx context.Context) (*jobs.Job, error)
MarkSucceeded(ctx context.Context, jobID string) error
MarkFailed(ctx context.Context, jobID, failure string) error
}
type Handler func(ctx context.Context, job jobs.Job) error
type Runner struct {
store JobStore
logger *slog.Logger
pollInterval time.Duration
handlers map[string]Handler
}
func NewRunner(store JobStore, logger *slog.Logger, pollInterval time.Duration) *Runner {
interval := pollInterval
if interval <= 0 {
interval = time.Second
}
return &Runner{
store: store,
logger: logger,
pollInterval: interval,
handlers: make(map[string]Handler),
}
}
func (runner *Runner) Register(kind string, handler Handler) {
runner.handlers[strings.TrimSpace(kind)] = handler
}
func (runner *Runner) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return nil
default:
}
job, err := runner.store.ClaimNext(ctx)
if err != nil {
if ctx.Err() != nil {
return nil
}
runner.logger.Error("worker claim failed", "error", err)
if err := waitForNextPoll(ctx, runner.pollInterval); err != nil {
return nil
}
continue
}
if job == nil {
if err := waitForNextPoll(ctx, runner.pollInterval); err != nil {
return nil
}
continue
}
handler, ok := runner.handlers[job.Kind]
if !ok {
failure := fmt.Sprintf("no handler registered for job kind %q", job.Kind)
if err := runner.store.MarkFailed(ctx, job.ID, failure); err != nil {
return err
}
runner.logger.Error("worker job failed", "jobID", job.ID, "kind", job.Kind, "error", failure)
continue
}
if err := handler(ctx, *job); err != nil {
if ctx.Err() != nil {
return nil
}
failure := strings.TrimSpace(err.Error())
if failure == "" {
failure = "job handler returned an empty error"
}
if markErr := runner.store.MarkFailed(ctx, job.ID, failure); markErr != nil {
return markErr
}
runner.logger.Error("worker job failed", "jobID", job.ID, "kind", job.Kind, "error", failure)
continue
}
if err := runner.store.MarkSucceeded(ctx, job.ID); err != nil {
return err
}
runner.logger.Info("worker job succeeded", "jobID", job.ID, "kind", job.Kind)
}
}
func waitForNextPoll(ctx context.Context, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+209
View File
@@ -0,0 +1,209 @@
package worker
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"moku-backend/internal/jobs"
)
func TestRunnerProcessesRegisteredJob(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
job: &jobs.Job{
ID: "job-1",
Kind: jobs.KindBootstrapStructureMaterialize,
Payload: []byte(`{"installationId":"installation-1"}`),
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0)
handlerCalled := false
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
handlerCalled = true
if job.ID != "job-1" {
t.Fatalf("expected job id job-1, got %s", job.ID)
}
return nil
})
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if !handlerCalled {
t.Fatal("expected handler to be called")
}
if len(store.succeeded) != 1 || store.succeeded[0] != "job-1" {
t.Fatalf("expected job to be marked succeeded once, got %#v", store.succeeded)
}
if len(store.failed) != 0 {
t.Fatalf("expected no failed jobs, got %#v", store.failed)
}
}
func TestRunnerMarksFailedWhenHandlerErrors(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
job: &jobs.Job{
ID: "job-2",
Kind: jobs.KindBootstrapStructureMaterialize,
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0)
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
return errors.New("boom")
})
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if len(store.succeeded) != 0 {
t.Fatalf("expected no succeeded jobs, got %#v", store.succeeded)
}
if len(store.failed) != 1 {
t.Fatalf("expected one failed job, got %#v", store.failed)
}
if store.failed[0].jobID != "job-2" {
t.Fatalf("expected failed job id job-2, got %#v", store.failed[0])
}
if !strings.Contains(store.failed[0].failure, "boom") {
t.Fatalf("expected failure to mention handler error, got %#v", store.failed[0])
}
}
func TestRunnerMarksFailedWhenHandlerMissing(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
job: &jobs.Job{
ID: "job-3",
Kind: "unknown.kind",
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0)
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if len(store.failed) != 1 {
t.Fatalf("expected one failed job, got %#v", store.failed)
}
if !strings.Contains(store.failed[0].failure, "no handler registered") {
t.Fatalf("expected missing handler failure, got %#v", store.failed[0])
}
}
func TestRunnerRetriesClaimErrors(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
claimErrors: []error{errors.New("relation \"background_jobs\" does not exist")},
job: &jobs.Job{
ID: "job-4",
Kind: jobs.KindBootstrapStructureMaterialize,
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), time.Millisecond)
handlerCalled := false
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
handlerCalled = true
return nil
})
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if !handlerCalled {
t.Fatal("expected handler to be called after claim retry")
}
if store.claimAttempts < 2 {
t.Fatalf("expected at least two claim attempts, got %d", store.claimAttempts)
}
if len(store.succeeded) != 1 || store.succeeded[0] != "job-4" {
t.Fatalf("expected job to be marked succeeded once, got %#v", store.succeeded)
}
if len(store.failed) != 0 {
t.Fatalf("expected no failed jobs, got %#v", store.failed)
}
}
type fakeJobStore struct {
mu sync.Mutex
job *jobs.Job
claimed bool
claimErrors []error
claimAttempts int
succeeded []string
failed []fakeFailure
cancel context.CancelFunc
}
type fakeFailure struct {
jobID string
failure string
}
func (store *fakeJobStore) ClaimNext(ctx context.Context) (*jobs.Job, error) {
store.mu.Lock()
defer store.mu.Unlock()
store.claimAttempts++
if len(store.claimErrors) > 0 {
err := store.claimErrors[0]
store.claimErrors = store.claimErrors[1:]
return nil, err
}
if store.claimed || store.job == nil {
return nil, nil
}
store.claimed = true
job := *store.job
return &job, nil
}
func (store *fakeJobStore) MarkSucceeded(ctx context.Context, jobID string) error {
store.mu.Lock()
store.succeeded = append(store.succeeded, jobID)
store.mu.Unlock()
if store.cancel != nil {
store.cancel()
}
return nil
}
func (store *fakeJobStore) MarkFailed(ctx context.Context, jobID, failure string) error {
store.mu.Lock()
store.failed = append(store.failed, fakeFailure{jobID: jobID, failure: failure})
store.mu.Unlock()
if store.cancel != nil {
store.cancel()
}
return nil
}
@@ -103,6 +103,9 @@ const buildProjectTree = (
items: readonly ProjectItem[], items: readonly ProjectItem[],
folders: readonly PersistedProjectFolderRecord[] = [], folders: readonly PersistedProjectFolderRecord[] = [],
): ProjectTreeNode[] => [ ): ProjectTreeNode[] => [
// The selector still presents scaffold project leaves beside persisted folders.
// Keep that mixed root shape explicit here so the drag/drop logic can account
// for folder-only ordering when we persist sibling positions.
...items.map((item) => ({ ...items.map((item) => ({
kind: "project" as const, kind: "project" as const,
item, item,
@@ -110,6 +113,11 @@ const buildProjectTree = (
...buildPersistedFolderNodes(folders), ...buildPersistedFolderNodes(folders),
]; ];
const countProjectFolderSiblingsBeforeIndex = (
siblings: readonly ProjectTreeNode[],
index: number,
): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] => const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : []; Array.isArray(body.data?.folders) ? body.data.folders : [];
@@ -596,10 +604,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
? persistedParentLocation.node.children ? persistedParentLocation.node.children
: [] : []
: previewNodes; : previewNodes;
// The preview tree includes project leaves and folders, but the backend only
// stores sibling order for folders. Persist a folder-only index so the server
// can reapply the same position against the authoritative ordered tree.
const targetIndex = previewLocation const targetIndex = previewLocation
? previewSiblings ? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
.slice(0, previewLocation.index)
.filter((node) => node.kind === "folder").length
: 0; : 0;
if ( if (
@@ -795,12 +804,12 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const movePersistedFolder = async ( const movePersistedFolder = async (
folderPath: string, folderPath: string,
parentFolderPath: string | null, parentFolderPath: string | null,
folderNodeId: string, folderStableId: string,
parentNodeId: string | null, parentStableId: string | null,
targetIndex: number, targetIndex: number,
): Promise<void> => { ): Promise<void> => {
const projectId = selectedProject().id; const projectId = selectedProject().id;
if (!folderPath || !folderNodeId || !isUuidString(projectId)) { if (!folderPath || !folderStableId || !isUuidString(projectId)) {
return; return;
} }
@@ -813,9 +822,9 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
}, },
body: JSON.stringify({ body: JSON.stringify({
folderId: folderPath, folderId: folderPath,
folderNodeId, folderNodeId: folderStableId,
parentFolderId: parentFolderPath, parentFolderId: parentFolderPath,
parentNodeId, parentNodeId: parentStableId,
targetIndex, targetIndex,
}), }),
}); });
@@ -93,6 +93,11 @@ const buildPersistedWorkspaceFolderNodes = (
const readPersistedWorkspaceFolders = (body: WorkspaceFoldersResponse): PersistedWorkspaceFolderRecord[] => const readPersistedWorkspaceFolders = (body: WorkspaceFoldersResponse): PersistedWorkspaceFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : []; Array.isArray(body.data?.folders) ? body.data.folders : [];
const countWorkspaceFolderSiblingsBeforeIndex = (
siblings: readonly WorkspaceTreeNode[],
index: number,
): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
const workspaceTreeAdapter: NavTreeAdapter<WorkspaceTreeNode> = { const workspaceTreeAdapter: NavTreeAdapter<WorkspaceTreeNode> = {
getNodeId: getWorkspaceTreeNodeId, getNodeId: getWorkspaceTreeNodeId,
isBranchNode: (node) => node.kind === "folder", isBranchNode: (node) => node.kind === "folder",
@@ -548,10 +553,11 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
? persistedParentLocation.node.children ?? [] ? persistedParentLocation.node.children ?? []
: [] : []
: previewNodes; : previewNodes;
// The tree preview can include static/workspace items, but persisted ordering
// only applies to folder siblings. Convert the preview position into a folder-
// only index before sending it to the backend move endpoint.
const targetIndex = previewLocation const targetIndex = previewLocation
? previewSiblings ? countWorkspaceFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
.slice(0, previewLocation.index)
.filter((node) => node.kind === "folder").length
: 0; : 0;
if ( if (
@@ -716,12 +722,12 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const movePersistedFolder = async ( const movePersistedFolder = async (
folderPath: string, folderPath: string,
parentFolderPath: string | null, parentFolderPath: string | null,
folderNodeId: string, folderStableId: string,
parentNodeId: string | null, parentStableId: string | null,
targetIndex: number, targetIndex: number,
): Promise<void> => { ): Promise<void> => {
const projectId = activeProject()?.id ?? ""; const projectId = activeProject()?.id ?? "";
if (!folderPath || !folderNodeId || !projectId || !isUuidString(projectId)) { if (!folderPath || !folderStableId || !projectId || !isUuidString(projectId)) {
return; return;
} }
@@ -734,9 +740,9 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
}, },
body: JSON.stringify({ body: JSON.stringify({
folderId: folderPath, folderId: folderPath,
folderNodeId, folderNodeId: folderStableId,
parentFolderId: parentFolderPath, parentFolderId: parentFolderPath,
parentNodeId, parentNodeId: parentStableId,
targetIndex, targetIndex,
}), }),
}); });
@@ -40,6 +40,8 @@ type AppShellInstallation = {
protocol: string; protocol: string;
host: string; host: string;
isBootstrapped: boolean; isBootstrapped: boolean;
materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
materializationError?: string;
}; };
type AppShellAdmin = { type AppShellAdmin = {
@@ -101,8 +103,29 @@ type AppShellPayload = {
workspaces: AppShellWorkspace[]; workspaces: AppShellWorkspace[];
}; };
const normalizeInstallation = (
installation: AppShellInstallation | null | undefined,
): AppShellInstallation | undefined => {
if (!installation) {
return undefined;
}
const materializationStatus = installation.materializationStatus?.trim()
? installation.materializationStatus
: installation.isBootstrapped
? "succeeded"
: "not_started";
const materializationError = installation.materializationError?.trim() || undefined;
return {
...installation,
materializationStatus,
materializationError,
};
};
const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({ const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
installation: payload?.installation, installation: normalizeInstallation(payload?.installation),
admin: payload?.admin, admin: payload?.admin,
organizations: Array.isArray(payload?.organizations) ? payload.organizations : [], organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
departments: Array.isArray(payload?.departments) ? payload.departments : [], departments: Array.isArray(payload?.departments) ? payload.departments : [],
@@ -92,6 +92,21 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.heroStatus {
display: grid;
gap: var(--space-2);
justify-items: start;
}
.heroStatusMessage {
max-width: 64ch;
color: var(--color-text-muted);
}
.heroStatusMessage[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
}
.title { .title {
@include text-display; @include text-display;
font-family: var(--font-family-display); font-family: var(--font-family-display);
@@ -194,6 +209,19 @@
background: color-mix(in srgb, var(--color-success-surface, var(--color-surface-secondary)) 80%, transparent); background: color-mix(in srgb, var(--color-success-surface, var(--color-surface-secondary)) 80%, transparent);
} }
.statusBadge[data-status="pending"],
.statusBadge[data-status="running"] {
color: var(--bootstrap-accent);
border-color: color-mix(in srgb, var(--bootstrap-accent) 38%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface-secondary));
}
.statusBadge[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 80%, transparent);
}
.statusBadge[data-status="error"] { .statusBadge[data-status="error"] {
color: var(--color-danger-text, var(--color-text)); color: var(--color-danger-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent); border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent);
@@ -490,6 +518,111 @@
gap: var(--space-3); gap: var(--space-3);
} }
.wizardFinishPanel {
display: grid;
gap: var(--space-4);
justify-items: stretch;
padding: var(--space-2) 0 0;
min-height: min(14rem, 32dvh);
align-content: center;
}
.wizardFinishShell {
display: grid;
gap: var(--space-4);
width: min(100%, 40rem);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.wizardFinishStatusRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
align-items: start;
}
.wizardFinishIndicator {
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 8%, transparent);
}
.wizardFinishIndicator[data-status="failed"] {
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 44%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 36%, transparent);
}
.wizardFinishSpinner {
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
border: 2px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent);
border-top-color: var(--bootstrap-accent);
animation: wizardFinishSpin 900ms linear infinite;
}
.wizardFinishIndicator[data-status="failed"] .wizardFinishSpinner {
border: 2px solid color-mix(in srgb, var(--color-danger-border, var(--color-border)) 22%, transparent);
border-top-color: var(--color-danger-text, var(--color-text));
animation: none;
transform: rotate(45deg);
border-radius: var(--radius-sm);
width: 1rem;
height: 1rem;
}
.wizardFinishCopy {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.wizardFinishTitle {
@include text-title;
margin: 0;
}
.wizardFinishDescription,
.wizardFinishMessage,
.wizardFinishHint {
margin: 0;
color: var(--color-text-muted);
}
.wizardFinishMessage[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
}
.wizardFinishHint {
@include text-caption;
}
.wizardFinishActions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
padding-top: var(--space-1);
}
@keyframes wizardFinishSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@include respond-down(tablet) { @include respond-down(tablet) {
.summaryGrid, .summaryGrid,
.wizardBody { .wizardBody {
@@ -1,6 +1,6 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx // Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { For, Show, createEffect, createMemo, createSignal, type JSX } from "solid-js"; import { For, Show, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js";
import { Portal } from "solid-js/web"; import { Portal } from "solid-js/web";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { resolveAPIBase } from "../../../lib/api"; import { resolveAPIBase } from "../../../lib/api";
@@ -44,6 +44,8 @@ type StructureForm = {
projectName: string; projectName: string;
}; };
type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed";
const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [ const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{ {
id: "instance", id: "instance",
@@ -104,6 +106,8 @@ const initialSubmissionState = (): BootstrapSubmissionState => ({
error: "", error: "",
}); });
const materializationPollIntervalMs = 2000;
const readResponseBody = async (response: Response): Promise<unknown> => { const readResponseBody = async (response: Response): Promise<unknown> => {
const raw = await response.text(); const raw = await response.text();
@@ -184,7 +188,56 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false); const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false);
const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false); const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false);
const [isWizardOpen, setIsWizardOpen] = createSignal(false); const [isWizardOpen, setIsWizardOpen] = createSignal(false);
const [isFinishingBootstrapFlow, setIsFinishingBootstrapFlow] = createSignal(false);
const [currentStepIndex, setCurrentStepIndex] = createSignal(0); const [currentStepIndex, setCurrentStepIndex] = createSignal(0);
const installation = createMemo(() => appShellData.installation());
const materializationState = createMemo<MaterializationState>(() => {
const status = installation()?.materializationStatus;
switch (status) {
case "pending":
case "running":
case "failed":
case "succeeded":
case "not_started":
return status;
default:
return installation()?.isBootstrapped ? "succeeded" : "not_started";
}
});
const isBootstrapPersisted = createMemo(() => installation()?.isBootstrapped ?? false);
const isMaterializationInFlight = createMemo(
() => materializationState() === "pending" || materializationState() === "running",
);
const hasMaterializationFailed = createMemo(() => materializationState() === "failed");
const showBootstrapFinishingState = createMemo(
() => isFinishingBootstrapFlow() && (isMaterializationInFlight() || hasMaterializationFailed()),
);
const materializationStatusLabel = createMemo(() => {
switch (materializationState()) {
case "pending":
return "Materialization queued";
case "running":
return "Materialization running";
case "failed":
return "Materialization failed";
case "succeeded":
return "Ready";
default:
return "Not started";
}
});
const materializationMessage = createMemo(() => {
if (isMaterializationInFlight()) {
return "Your bootstrap is saved. The worker is still creating the POSIX skeleton and rebuilding the app shell index.";
}
if (hasMaterializationFailed()) {
return installation()?.materializationError || "Bootstrap saved, but background materialization did not finish cleanly.";
}
return "";
});
createEffect(() => { createEffect(() => {
if (modeForm.mode === "personal") { if (modeForm.mode === "personal") {
@@ -213,19 +266,65 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
return; return;
} }
const installationAccessor = appShellData.installation; if (!isBootstrapPersisted()) {
const installation = typeof installationAccessor === "function" ? installationAccessor() : undefined; setIsFinishingBootstrapFlow(false);
const isPersistedBootstrap = installation?.isBootstrapped ?? false;
if (!isPersistedBootstrap) {
resetWizardState(); resetWizardState();
} }
setIsBootstrapComplete(isPersistedBootstrap); setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isPersistedBootstrap); setIsWizardOpen(!isBootstrapPersisted() || showBootstrapFinishingState());
setIsBootstrapStateResolved(true); setIsBootstrapStateResolved(true);
}); });
createEffect(() => {
if (!isFinishingBootstrapFlow()) {
return;
}
if (isMaterializationInFlight() || hasMaterializationFailed()) {
return;
}
setIsFinishingBootstrapFlow(false);
setIsWizardOpen(false);
});
createEffect(() => {
if (!isBootstrapPersisted() || !isMaterializationInFlight()) {
return;
}
let cancelled = false;
let timeoutId: number | undefined;
const scheduleReload = (): void => {
timeoutId = window.setTimeout(async () => {
if (cancelled) {
return;
}
// The final bootstrap step only persists relational state. Poll while the
// worker is materializing the POSIX skeleton so the page can transition from
// queued/running to ready/failed without a manual refresh.
await appShellData.reload();
if (!cancelled && isBootstrapPersisted() && isMaterializationInFlight()) {
scheduleReload();
}
}, materializationPollIntervalMs);
};
scheduleReload();
onCleanup(() => {
cancelled = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
});
});
const sidebarToggleLabel = (): string => const sidebarToggleLabel = (): string =>
props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar"; props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar";
const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`; const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
@@ -240,7 +339,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const currentStepState = createMemo<BootstrapSubmissionState>(() => stepState[currentStep().id]); const currentStepState = createMemo<BootstrapSubmissionState>(() => stepState[currentStep().id]);
const isFirstStep = (): boolean => currentStepIndex() === 0; const isFirstStep = (): boolean => currentStepIndex() === 0;
const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1; const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1;
const canDismissWizard = (): boolean => isBootstrapComplete(); const canDismissWizard = (): boolean => isBootstrapPersisted() && !isMaterializationInFlight();
const resetWizardState = (): void => { const resetWizardState = (): void => {
setInstanceForm({ ...defaultInstanceForm }); setInstanceForm({ ...defaultInstanceForm });
@@ -254,6 +353,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
structure: initialSubmissionState(), structure: initialSubmissionState(),
}); });
setCurrentStepIndex(0); setCurrentStepIndex(0);
setIsFinishingBootstrapFlow(false);
}; };
const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => { const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
@@ -313,12 +413,11 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
if (isLastStep()) { if (isLastStep()) {
await appShellData.reload(); await appShellData.reload();
const installationAccessor = appShellData.installation;
const installation = typeof installationAccessor === "function" ? installationAccessor() : undefined;
const isPersistedBootstrap = installation?.isBootstrapped ?? false;
setIsBootstrapComplete(isPersistedBootstrap); const shouldShowFinishingState = isBootstrapPersisted() && (isMaterializationInFlight() || hasMaterializationFailed());
setIsWizardOpen(!isPersistedBootstrap); setIsFinishingBootstrapFlow(shouldShowFinishingState);
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted() || shouldShowFinishingState);
setIsBootstrapStateResolved(true); setIsBootstrapStateResolved(true);
return; return;
} }
@@ -383,9 +482,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
</div> </div>
<section class={styles.hero} data-slot="workspace-home-hero"> <section class={styles.hero} data-slot="workspace-home-hero">
<h1 class={styles.title}>{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1> <h1 class={styles.title}>{isBootstrapPersisted() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1>
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}> <Show when={isBootstrapStateResolved() && !isBootstrapPersisted()}>
<div class={styles.heroActions}> <div class={styles.heroActions}>
<button <button
type="button" type="button"
class={styles.primaryButton} class={styles.primaryButton}
@@ -393,10 +492,10 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
setIsWizardOpen(true); setIsWizardOpen(true);
}} }}
> >
Open bootstrap wizard Open bootstrap wizard
</button> </button>
</div> </div>
</Show> </Show>
</section> </section>
</main> </main>
@@ -425,8 +524,54 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
</Show> </Show>
</header> </header>
<div class={styles.wizardBody}> <Show
<aside class={styles.wizardSidebar} data-slot="bootstrap-wizard-sidebar"> when={!showBootstrapFinishingState()}
fallback={
<div class={styles.wizardFinishPanel} data-slot="bootstrap-wizard-finishing-state">
<div class={styles.wizardFinishShell}>
<div class={styles.wizardFinishStatusRow}>
<div class={styles.wizardFinishIndicator} data-status={materializationState()} aria-hidden="true">
<div class={styles.wizardFinishSpinner} />
</div>
<div class={styles.wizardFinishCopy}>
<span class={styles.wizardStepEyebrow}>Bootstrap status</span>
<h3 class={styles.wizardFinishTitle}>Finishing setup</h3>
<p class={styles.wizardFinishDescription}>
We saved your initial bootstrap. The server is finishing the last background setup steps now.
</p>
</div>
</div>
<div class={styles.statusBadge} data-status={materializationState()}>
{materializationStatusLabel()}
</div>
<Show when={materializationMessage()}>
<p class={styles.wizardFinishMessage} data-status={materializationState()}>
{materializationMessage()}
</p>
</Show>
<Show when={isMaterializationInFlight()}>
<p class={styles.wizardFinishHint}>This window will close automatically when setup is complete.</p>
</Show>
</div>
<Show when={hasMaterializationFailed()}>
<div class={styles.wizardFinishActions}>
<button
type="button"
class={styles.secondaryButton}
onClick={(): void => {
setIsFinishingBootstrapFlow(false);
setIsWizardOpen(false);
}}
>
Close
</button>
</div>
</Show>
</div>
}
>
<div class={styles.wizardBody}>
<aside class={styles.wizardSidebar} data-slot="bootstrap-wizard-sidebar">
<nav class={styles.wizardSteps} aria-label="Bootstrap steps"> <nav class={styles.wizardSteps} aria-label="Bootstrap steps">
<For each={bootstrapStepDefinitions}> <For each={bootstrapStepDefinitions}>
{(step, index): JSX.Element => ( {(step, index): JSX.Element => (
@@ -452,16 +597,18 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
)} )}
</For> </For>
</nav> </nav>
</aside> </aside>
<div class={styles.wizardStepPanel} data-slot="bootstrap-wizard-step-panel"> <div class={styles.wizardStepPanel} data-slot="bootstrap-wizard-step-panel">
<div class={styles.sectionHeader}> <div class={styles.sectionHeader}>
<div> <div>
<span class={styles.wizardStepEyebrow}>{`Step ${currentStepIndex() + 1} of ${bootstrapStepDefinitions.length}`}</span> <span class={styles.wizardStepEyebrow}>{`Step ${currentStepIndex() + 1} of ${bootstrapStepDefinitions.length}`}</span>
<h3 class={styles.sectionTitle}>{currentStep().title}</h3> <h3 class={styles.sectionTitle}>{currentStep().title}</h3>
</div> </div>
<div class={styles.statusBadge} data-status={currentStepState().status}>{statusLabel(currentStepState())}</div> <div class={styles.statusBadge} data-status={currentStepState().status}>
</div> {statusLabel(currentStepState())}
</div>
</div>
<form class={styles.form} onSubmit={handleCurrentStepSubmit}> <form class={styles.form} onSubmit={handleCurrentStepSubmit}>
<Show when={currentStep().id === "instance"}> <Show when={currentStep().id === "instance"}>
@@ -621,8 +768,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<p class={styles.errorText}>{currentStepState().error}</p> <p class={styles.errorText}>{currentStepState().error}</p>
</Show> </Show>
</div>
</div> </div>
</div> </Show>
</section> </section>
</div> </div>
</Portal> </Portal>