package sqlite import ( "context" "database/sql" "fmt" "time" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) type WorkspaceStore struct{} func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} } // ── columns ──────────────────────────────── const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at` const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at` // ── scanners ─────────────────────────────── func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) { var w models.Workspace var maxBytes sql.NullInt64 err := row.Scan( &w.ID, &w.OwnerType, &w.OwnerID, &w.Name, &w.RootPath, &maxBytes, &w.Status, st(&w.CreatedAt), st(&w.UpdatedAt), ) if err != nil { return nil, err } if maxBytes.Valid { w.MaxBytes = &maxBytes.Int64 } return &w, nil } func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) { var f models.WorkspaceFile var contentType sql.NullString var sha256 sql.NullString err := row.Scan( &f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory, &contentType, &f.SizeBytes, &sha256, st(&f.CreatedAt), st(&f.UpdatedAt), ) if err != nil { return nil, err } if contentType.Valid { f.ContentType = contentType.String } if sha256.Valid { f.SHA256 = sha256.String } return &f, nil } // ── Workspace CRUD ───────────────────────── func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { if w.ID == "" { w.ID = store.NewID() } now := time.Now().UTC() w.CreatedAt = now w.UpdatedAt = now _, err := DB.ExecContext(ctx, ` INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath, w.MaxBytes, w.Status, now.Format(timeFmt), now.Format(timeFmt), ) return err } func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE id = ?`, id) return scanWorkspace(row) } func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error { b := NewUpdate("workspaces") if patch.Name != nil { b.Set("name", *patch.Name) } if patch.MaxBytes != nil { b.Set("max_bytes", *patch.MaxBytes) } if patch.Status != nil { b.Set("status", *patch.Status) } if !b.HasSets() { return nil } b.SetExpr("updated_at", nowExpr) b.Where("id", id) _, err := b.Exec(DB) return err } func (s *WorkspaceStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id) return err } // ── Ownership lookup ─────────────────────── func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE owner_type = ? AND owner_id = ? AND status != 'deleting' ORDER BY created_at DESC LIMIT 1`, ownerType, ownerID) return scanWorkspace(row) } func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) { rows, err := DB.QueryContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE owner_type = ? AND owner_id = ? AND status != 'deleting' ORDER BY created_at DESC`, ownerType, ownerID) if err != nil { return nil, err } defer rows.Close() var out []models.Workspace for rows.Next() { w, err := scanWorkspace(rows) if err != nil { return nil, err } out = append(out, *w) } return out, rows.Err() } // ── File index ───────────────────────────── func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error { if f.ID == "" { f.ID = store.NewID() } now := time.Now().UTC() f.CreatedAt = now f.UpdatedAt = now _, err := DB.ExecContext(ctx, ` INSERT INTO workspace_files (id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (workspace_id, path) DO UPDATE SET is_directory = excluded.is_directory, content_type = excluded.content_type, size_bytes = excluded.size_bytes, sha256 = excluded.sha256, updated_at = datetime('now')`, f.ID, f.WorkspaceID, f.Path, f.IsDirectory, models.NullString(&f.ContentType), f.SizeBytes, models.NullString(&f.SHA256), now.Format(timeFmt), now.Format(timeFmt), ) return err } func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = ? AND path = ?`, workspaceID, path) return err } func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = ? AND path LIKE ?`, workspaceID, prefix+"%") return err } func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceFileCols+` FROM workspace_files WHERE workspace_id = ? AND path = ?`, workspaceID, path) return scanWorkspaceFile(row) } func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) { var query string var args []interface{} if recursive { if prefix == "" { query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = ? ORDER BY path` args = []interface{}{workspaceID} } else { query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = ? AND path LIKE ? ORDER BY path` args = []interface{}{workspaceID, prefix + "%"} } } else { if prefix == "" { query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = ? AND path NOT LIKE '%/%' ORDER BY path` args = []interface{}{workspaceID} } else { // Direct children: starts with prefix, no slash after prefix query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files WHERE workspace_id = ? AND path LIKE ? AND substr(path, %d) NOT LIKE '%%/%%' ORDER BY path`, len(prefix)+1) args = []interface{}{workspaceID, prefix + "%"} } } rows, err := DB.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var out []models.WorkspaceFile for rows.Next() { f, err := scanWorkspaceFile(rows) if err != nil { return nil, err } out = append(out, *f) } return out, rows.Err() } func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = ?`, workspaceID) return err } // ── Stats ────────────────────────────────── func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) { stats := &models.WorkspaceStats{WorkspaceID: workspaceID} err := DB.QueryRowContext(ctx, ` SELECT COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0), COALESCE(SUM(size_bytes), 0) FROM workspace_files WHERE workspace_id = ?`, workspaceID, ).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes) if err != nil { return nil, err } var maxBytes sql.NullInt64 err = DB.QueryRowContext(ctx, `SELECT max_bytes FROM workspaces WHERE id = ?`, workspaceID, ).Scan(&maxBytes) if err != nil && err != sql.ErrNoRows { return nil, err } if maxBytes.Valid { stats.MaxBytes = &maxBytes.Int64 } return stats, nil }