mirror of
https://github.com/get-drexa/drive.git
synced 2026-02-02 13:11:18 +00:00
impl: dir content pagination
This commit is contained in:
@@ -15,8 +15,45 @@
|
||||
},
|
||||
"paths": {
|
||||
"/accounts": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Retrieve all accounts for the authenticated user",
|
||||
"tags": [
|
||||
"accounts"
|
||||
],
|
||||
"summary": "List accounts",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of accounts for the authenticated user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/internal_account.Account"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Create a new user account with email and password. Returns the account, user, and authentication tokens.",
|
||||
"description": "Create a new user account with email and password. Returns the account, user, and authentication tokens. Tokens can be delivered via HTTP-only cookies or in the response body based on the tokenDelivery field.",
|
||||
"tags": [
|
||||
"accounts"
|
||||
],
|
||||
@@ -44,7 +81,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request body",
|
||||
"description": "Invalid request body or token delivery method",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1616,6 +1653,15 @@
|
||||
"description": "Password for the new account (min 8 characters)",
|
||||
"type": "string",
|
||||
"example": "securepassword123"
|
||||
},
|
||||
"tokenDelivery": {
|
||||
"description": "How to deliver tokens: \"cookie\" (set HTTP-only cookies) or \"body\" (include in response)",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cookie",
|
||||
"body"
|
||||
],
|
||||
"example": "body"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1624,7 +1670,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accessToken": {
|
||||
"description": "JWT access token for immediate authentication",
|
||||
"description": "JWT access token for immediate authentication (only included when tokenDelivery is \"body\")",
|
||||
"type": "string",
|
||||
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature"
|
||||
},
|
||||
@@ -1637,7 +1683,7 @@
|
||||
]
|
||||
},
|
||||
"refreshToken": {
|
||||
"description": "Base64 URL encoded refresh token",
|
||||
"description": "Base64 URL encoded refresh token (only included when tokenDelivery is \"body\")",
|
||||
"type": "string",
|
||||
"example": "dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi"
|
||||
},
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -24,7 +27,7 @@ type DirectoryInfo struct {
|
||||
Kind string `json:"kind" example:"directory"`
|
||||
// Unique directory identifier
|
||||
ID string `json:"id" example:"kRp2XYTq9A55"`
|
||||
// ParentID is the pbulic ID of the directory this directory is in
|
||||
// ParentID is the public ID of the directory this directory is in
|
||||
ParentID string `json:"parentId,omitempty" example:"kRp2XYTq9A55"`
|
||||
// Full path from root (included when ?include=path)
|
||||
Path virtualfs.Path `json:"path,omitempty"`
|
||||
@@ -54,6 +57,15 @@ type postDirectoryContentRequest struct {
|
||||
Items []string `json:"items" example:"mElnUNCm8F22,kRp2XYTq9A55"`
|
||||
}
|
||||
|
||||
// listDirectoryResponse represents the response to a request to list the contents of a directory
|
||||
// @Description Response to a request to list the contents of a directory
|
||||
type listDirectoryResponse struct {
|
||||
// Items is the list of items in the directory, limited to the limit specified in the request
|
||||
Items []any `json:"items"`
|
||||
// NextCursor is the cursor to use to get the next page of results
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
// moveItemsToDirectoryResponse represents the response to a request
|
||||
// to move items into a directory.
|
||||
// @Description Response from moving items to a directory with status for each item
|
||||
@@ -80,6 +92,12 @@ type moveItemError struct {
|
||||
Error string `json:"error" example:"permission denied"`
|
||||
}
|
||||
|
||||
type decodedListChildrenCursor struct {
|
||||
orderBy virtualfs.ListChildrenOrder
|
||||
orderDirection virtualfs.ListChildrenDirection
|
||||
nodeID string
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error {
|
||||
account := account.CurrentAccount(c)
|
||||
if account == nil {
|
||||
@@ -236,19 +254,79 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
|
||||
|
||||
// listDirectory returns directory contents
|
||||
// @Summary List directory contents
|
||||
// @Description Get all files and subdirectories within a directory
|
||||
// @Description Get all files and subdirectories within a directory with optional pagination, sorting, and filtering
|
||||
// @Tags directories
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param accountID path string true "Account ID" format(uuid)
|
||||
// @Param directoryID path string true "Directory ID"
|
||||
// @Success 200 {array} interface{} "Array of FileInfo and DirectoryInfo objects"
|
||||
// @Param directoryID path string true "Directory ID (use 'root' for the root directory)"
|
||||
// @Param orderBy query string false "Sort field: name, createdAt, or updatedAt" Enums(name,createdAt,updatedAt)
|
||||
// @Param dir query string false "Sort direction: asc or desc" Enums(asc,desc)
|
||||
// @Param limit query integer false "Maximum number of items to return (default: 100, min: 1)"
|
||||
// @Param cursor query string false "Cursor for pagination (base64-encoded cursor from previous response)"
|
||||
// @Success 200 {object} listDirectoryResponse "Paginated list of FileInfo and DirectoryInfo objects"
|
||||
// @Failure 400 {object} map[string]string "Invalid limit or cursor"
|
||||
// @Failure 401 {string} string "Not authenticated"
|
||||
// @Failure 404 {string} string "Directory not found"
|
||||
// @Router /accounts/{accountID}/directories/{directoryID}/content [get]
|
||||
func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
|
||||
node := mustCurrentDirectoryNode(c)
|
||||
children, err := h.vfs.ListChildren(c.Context(), h.db, node)
|
||||
|
||||
opts := virtualfs.ListChildrenOptions{}
|
||||
|
||||
if by := c.Query("orderBy"); by != "" {
|
||||
switch by {
|
||||
case "name":
|
||||
opts.OrderBy = virtualfs.ListChildrenOrderByName
|
||||
case "createdAt":
|
||||
opts.OrderBy = virtualfs.ListChildrenOrderByCreatedAt
|
||||
case "updatedAt":
|
||||
opts.OrderBy = virtualfs.ListChildrenOrderByUpdatedAt
|
||||
}
|
||||
}
|
||||
|
||||
if dir := c.Query("dir"); dir != "" {
|
||||
switch dir {
|
||||
case "asc":
|
||||
opts.OrderDirection = virtualfs.ListChildrenDirectionAsc
|
||||
case "desc":
|
||||
opts.OrderDirection = virtualfs.ListChildrenDirectionDesc
|
||||
}
|
||||
}
|
||||
|
||||
if limit := c.Query("limit"); limit != "" {
|
||||
limit, err := strconv.Atoi(limit)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid limit"})
|
||||
}
|
||||
if limit < 1 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Limit must be at least 1"})
|
||||
}
|
||||
opts.Limit = limit
|
||||
}
|
||||
|
||||
if cursor := c.Query("cursor"); cursor != "" {
|
||||
dc, err := decodeListChildrenCursor(cursor)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"})
|
||||
}
|
||||
|
||||
n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, node.AccountID, dc.nodeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"})
|
||||
}
|
||||
return httperr.Internal(err)
|
||||
}
|
||||
|
||||
opts.Cursor = &virtualfs.ListChildrenCursor{
|
||||
Node: n,
|
||||
OrderBy: dc.orderBy,
|
||||
OrderDirection: dc.orderDirection,
|
||||
}
|
||||
}
|
||||
|
||||
children, cursor, err := h.vfs.ListChildren(c.Context(), h.db, node, opts)
|
||||
if err != nil {
|
||||
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
@@ -283,7 +361,10 @@ func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(items)
|
||||
return c.JSON(listDirectoryResponse{
|
||||
Items: items,
|
||||
NextCursor: encodeListChildrenCursor(cursor),
|
||||
})
|
||||
}
|
||||
|
||||
// patchDirectory updates directory properties
|
||||
@@ -562,3 +643,64 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
|
||||
|
||||
return c.JSON(res)
|
||||
}
|
||||
|
||||
func encodeListChildrenCursor(cursor *virtualfs.ListChildrenCursor) string {
|
||||
var by int
|
||||
switch cursor.OrderBy {
|
||||
case virtualfs.ListChildrenOrderByName:
|
||||
by = 0
|
||||
case virtualfs.ListChildrenOrderByCreatedAt:
|
||||
by = 1
|
||||
case virtualfs.ListChildrenOrderByUpdatedAt:
|
||||
by = 2
|
||||
}
|
||||
|
||||
var d int
|
||||
switch cursor.OrderDirection {
|
||||
case virtualfs.ListChildrenDirectionAsc:
|
||||
d = 0
|
||||
case virtualfs.ListChildrenDirectionDesc:
|
||||
d = 1
|
||||
}
|
||||
|
||||
s := fmt.Sprintf("%d:%d:%s", by, d, cursor.Node.ID)
|
||||
return base64.URLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
func decodeListChildrenCursor(s string) (*decodedListChildrenCursor, error) {
|
||||
bs, err := base64.URLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parts := strings.Split(string(bs), ":")
|
||||
if len(parts) != 3 {
|
||||
return nil, errors.New("invalid cursor")
|
||||
}
|
||||
|
||||
c := new(decodedListChildrenCursor)
|
||||
|
||||
switch parts[0] {
|
||||
case "0":
|
||||
c.orderBy = virtualfs.ListChildrenOrderByName
|
||||
case "1":
|
||||
c.orderBy = virtualfs.ListChildrenOrderByCreatedAt
|
||||
case "2":
|
||||
c.orderBy = virtualfs.ListChildrenOrderByUpdatedAt
|
||||
default:
|
||||
return nil, errors.New("invalid cursor")
|
||||
}
|
||||
|
||||
switch parts[1] {
|
||||
case "0":
|
||||
c.orderDirection = virtualfs.ListChildrenDirectionAsc
|
||||
case "1":
|
||||
c.orderDirection = virtualfs.ListChildrenDirectionDesc
|
||||
default:
|
||||
return nil, errors.New("invalid cursor")
|
||||
}
|
||||
|
||||
c.nodeID = parts[2]
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ type FileInfo struct {
|
||||
Kind string `json:"kind" example:"file"`
|
||||
// Unique file identifier
|
||||
ID string `json:"id" example:"mElnUNCm8F22"`
|
||||
// ParentID is the pbulic ID of the directory this file is in
|
||||
ParentID string `json:"parentId,omitempty" exmaple:"kRp2XYTq9A55"`
|
||||
// ParentID is the public ID of the directory this file is in
|
||||
ParentID string `json:"parentId,omitempty" example:"kRp2XYTq9A55"`
|
||||
// File name
|
||||
Name string `json:"name" example:"document.pdf"`
|
||||
// File size in bytes
|
||||
@@ -297,7 +297,6 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return c.JSON(res)
|
||||
|
||||
} else {
|
||||
err = h.vfs.PermanentlyDeleteFiles(c.Context(), tx, nodes)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,9 @@ package virtualfs
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNodeNotFound = errors.New("node not found")
|
||||
ErrNodeConflict = errors.New("node conflict")
|
||||
ErrUnsupportedOperation = errors.New("unsupported operation")
|
||||
ErrNodeNotFound = errors.New("node not found")
|
||||
ErrNodeConflict = errors.New("node conflict")
|
||||
ErrUnsupportedOperation = errors.New("unsupported operation")
|
||||
ErrCursorMismatchedOrderField = errors.New("cursor mismatched order field")
|
||||
ErrCursorMismatchedDirection = errors.New("cursor mismatched direction")
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +20,23 @@ import (
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ListChildrenOrder string
|
||||
|
||||
const (
|
||||
ListChildrenOrderByName ListChildrenOrder = "name"
|
||||
ListChildrenOrderByCreatedAt ListChildrenOrder = "created_at"
|
||||
ListChildrenOrderByUpdatedAt ListChildrenOrder = "updated_at"
|
||||
)
|
||||
|
||||
type ListChildrenDirection int
|
||||
|
||||
const (
|
||||
ListChildrenDirectionAsc ListChildrenDirection = iota
|
||||
ListChildrenDirectionDesc
|
||||
)
|
||||
|
||||
const listChildrenDefaultLimit = 50
|
||||
|
||||
type VirtualFS struct {
|
||||
blobStore blob.Store
|
||||
keyResolver BlobKeyResolver
|
||||
@@ -48,6 +66,19 @@ type MoveFilesResult struct {
|
||||
Errors []MoveFileError
|
||||
}
|
||||
|
||||
type ListChildrenOptions struct {
|
||||
Limit int
|
||||
OrderBy ListChildrenOrder
|
||||
OrderDirection ListChildrenDirection
|
||||
Cursor *ListChildrenCursor
|
||||
}
|
||||
|
||||
type ListChildrenCursor struct {
|
||||
Node *Node
|
||||
OrderBy ListChildrenOrder
|
||||
OrderDirection ListChildrenDirection
|
||||
}
|
||||
|
||||
const RootDirectoryName = "root"
|
||||
|
||||
func New(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error) {
|
||||
@@ -128,26 +159,108 @@ func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, account
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node) ([]*Node, error) {
|
||||
// ListChildren returns the children of a directory node with optional sorting and cursor-based pagination.
|
||||
func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node, opts ListChildrenOptions) ([]*Node, *ListChildrenCursor, error) {
|
||||
if !node.IsAccessible() {
|
||||
return nil, ErrNodeNotFound
|
||||
return nil, nil, ErrNodeNotFound
|
||||
}
|
||||
|
||||
var nodes []*Node
|
||||
err := db.NewSelect().Model(&nodes).
|
||||
q := db.NewSelect().Model(&nodes).
|
||||
Where("account_id = ?", node.AccountID).
|
||||
Where("parent_id = ?", node.ID).
|
||||
Where("status = ?", NodeStatusReady).
|
||||
Where("deleted_at IS NULL").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return make([]*Node, 0), nil
|
||||
Where("deleted_at IS NULL")
|
||||
|
||||
var dir string
|
||||
if opts.OrderBy != "" {
|
||||
switch opts.OrderDirection {
|
||||
default:
|
||||
dir = "ASC"
|
||||
case ListChildrenDirectionAsc:
|
||||
dir = "ASC"
|
||||
case ListChildrenDirectionDesc:
|
||||
dir = "DESC"
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
// Apply sorting with directories always first, then ID as tiebreaker.
|
||||
//
|
||||
// Cursor-based pagination implementation notes:
|
||||
// - The cursor contains the last node from the previous page along with the sort configuration
|
||||
// - The WHERE clause uses tuple comparison (kind, field, id) to filter results after the cursor position
|
||||
// - Directories are always ordered before files (kind ASC puts 'directory' before 'file' alphabetically)
|
||||
// - ID is always sorted ASC as a tiebreaker, regardless of the main sort direction
|
||||
//
|
||||
// Why ID is always ASC:
|
||||
// - Ensures deterministic ordering when multiple items have the same sort field value
|
||||
// - Maintains consistent tiebreaker behavior across different sort directions
|
||||
// - Prevents pagination inconsistencies where items with the same name/date appear in different orders
|
||||
// depending on whether sorting ASC or DESC
|
||||
// - The tuple comparison in the WHERE clause correctly handles the direction for the main field,
|
||||
// while ID provides a stable secondary sort
|
||||
switch opts.OrderBy {
|
||||
case ListChildrenOrderByName:
|
||||
q = q.Order("kind ASC", "name "+dir, "id ASC")
|
||||
case ListChildrenOrderByCreatedAt:
|
||||
q = q.Order("kind ASC", "created_at "+dir, "id ASC")
|
||||
case ListChildrenOrderByUpdatedAt:
|
||||
q = q.Order("kind ASC", "updated_at "+dir, "id ASC")
|
||||
}
|
||||
|
||||
// Apply cursor filter for pagination.
|
||||
// The cursor contains the last node from the previous page. We use tuple comparison
|
||||
// (kind, field, id) to find all rows that come after the cursor position in the sorted order.
|
||||
// Kind is included to handle pagination across the directory/file boundary correctly.
|
||||
// For ASC: use > to get rows after cursor
|
||||
// For DESC: use < to get rows after cursor (because "after" in descending order means lesser values)
|
||||
if opts.Cursor != nil {
|
||||
if opts.Cursor.OrderBy != opts.OrderBy {
|
||||
return nil, nil, ErrCursorMismatchedOrderField
|
||||
}
|
||||
if opts.Cursor.OrderDirection != opts.OrderDirection {
|
||||
return nil, nil, ErrCursorMismatchedDirection
|
||||
}
|
||||
|
||||
var op string
|
||||
switch opts.Cursor.OrderDirection {
|
||||
case ListChildrenDirectionAsc:
|
||||
op = ">"
|
||||
case ListChildrenDirectionDesc:
|
||||
op = "<"
|
||||
}
|
||||
|
||||
// Include kind in tuple comparison to handle pagination across directory/file boundary
|
||||
switch opts.Cursor.OrderBy {
|
||||
case ListChildrenOrderByName:
|
||||
q = q.Where("(kind, name, id) "+op+" (?, ?, ?)", opts.Cursor.Node.Kind, opts.Cursor.Node.Name, opts.Cursor.Node.ID)
|
||||
case ListChildrenOrderByCreatedAt:
|
||||
q = q.Where("(kind, created_at, id) "+op+" (?, ?, ?)", opts.Cursor.Node.Kind, opts.Cursor.Node.CreatedAt, opts.Cursor.Node.ID)
|
||||
case ListChildrenOrderByUpdatedAt:
|
||||
q = q.Where("(kind, updated_at, id) "+op+" (?, ?, ?)", opts.Cursor.Node.Kind, opts.Cursor.Node.UpdatedAt, opts.Cursor.Node.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Limit > 0 {
|
||||
q = q.Limit(opts.Limit)
|
||||
} else {
|
||||
q = q.Limit(listChildrenDefaultLimit)
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return make([]*Node, 0), nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
c := &ListChildrenCursor{
|
||||
Node: nodes[len(nodes)-1],
|
||||
OrderBy: opts.OrderBy,
|
||||
OrderDirection: opts.OrderDirection,
|
||||
}
|
||||
|
||||
return nodes, c, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateFileOptions) (*Node, error) {
|
||||
@@ -178,7 +291,7 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.NewInsert().Model(&node).Returning("*").Exec(ctx)
|
||||
_, err = db.NewInsert().Model(&node).On("CONFLICT DO NOTHING").Returning("*").Exec(ctx)
|
||||
if err != nil {
|
||||
if database.IsUniqueViolation(err) {
|
||||
return nil, ErrNodeConflict
|
||||
@@ -355,7 +468,7 @@ func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.NewUpdate().Model(deletableNodes).
|
||||
_, err := db.NewUpdate().Model(&deletableNodes).
|
||||
Where("id IN (?)", bun.In(nodeIDs)).
|
||||
Where("status = ?", NodeStatusReady).
|
||||
Where("deleted_at IS NULL").
|
||||
@@ -363,7 +476,7 @@ func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*
|
||||
Returning("deleted_at").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to soft delete nodes: %w", err)
|
||||
}
|
||||
|
||||
return deletableNodes, nil
|
||||
|
||||
Reference in New Issue
Block a user