Fabriq
1.x
Docs/Fabriq/fs_node
Open

Reading10 min
Updated2 Aug 2026
Sourcev1/(file-plane)/fs-node.mdx

fs_node is fabriq's filesystem catalog: a folder/file tree where every node is a first-class aggregate in package fabriq, tenant-scoped at the database level through RLS and structural stamping. Folders group other nodes; file nodes reference a blob_object 1:1 and carry denormalized size, content type, and checksum. A third type, mount, appears when a remote source is grafted into the tree — see fs-satellites.

Model01

type FsNode struct {
    ID          string         `json:"id"`
    TenantID    string         `json:"tenantId"`
    ScopeID     string         `json:"scopeId"`
    Version     int64          `json:"version"`
    ParentID    string         `json:"parentId"`   // ""  = root
    Name        string         `json:"name"`
    NodeType    string         `json:"nodeType"`   // "folder" | "file" | "mount"
    BlobID      string         `json:"blobId"`     // file nodes only
    Size        int64          `json:"size"`
    ContentType string         `json:"contentType"`
    Checksum    string         `json:"checksum"`
    IsLocked    bool           `json:"isLocked"`
    LockedBy    string         `json:"lockedBy"`
    Metadata    map[string]any `json:"metadata"`   // JSONB escape hatch, never nil
    MountConfig map[string]any `json:"mountConfig"` // satellite config, never nil
    DeletedAt   *time.Time     `json:"deletedAt"`  // nil = live
    DeletedBy   string         `json:"deletedBy"`
    CreatedAt   time.Time      `json:"createdAt"`
    UpdatedAt   time.Time      `json:"updatedAt"`
}

Indexing strategy. parent_id is the only persisted tree truth — pure adjacency, no materialized path column. Paths are derived at read time: NodePath walks the parent chain (O(depth) point reads), GetNodeByPath descends one segment at a time on the (tenant_id, parent_id, name) unique index, and Descendants runs a recursive CTE over parent_id. That same unique index enforces sibling-name uniqueness at the database level (WHERE deleted_at IS NULL, partial). name and content_type are search-indexed via the search projection. Arbitrary-depth traversal is also available via the graph CHILD_OF self-edge (see graph). See ADR 0010 for why the materialized path column was dropped.

FsRef

Write operations return FsRef, not the full model:

type FsRef struct {
    ID       string `json:"id"`
    ParentID string `json:"parentId"`
    Name     string `json:"name"`
    Path     string `json:"path"`
    NodeType string `json:"nodeType"`
    Version  int64  `json:"version"`
}

Path is computed at call time from the write itself (root path + name); nothing persists it — domain.FsNode carries no Path field.

Errors

ErrorTrigger
ErrNodeNameConflicta live sibling already uses the name
ErrNotContainerparentID resolves to a non-folder
ErrNodeLockedmutating op targets a locked node

Registry spec02

registry.EntitySpec{
    Name:      "fs_node",
    Kind:      registry.KindAggregate,
    Model:     (*domain.FsNode)(nil),
    GraphNode: "FsNode",
    Edges: []registry.EdgeSpec{
        {Field: "parent_id", Rel: "CHILD_OF", Target: "fs_node"},
    },
    Search: registry.SearchSpec{
        Index:  "fs_nodes",
        Fields: []string{"name", "content_type"},
    },
    Subscribe: []registry.Scope{
        registry.ByID,
        registry.ByField("parent", "parent_id"),
        registry.ByTenant,
    },
    Live: &registry.LiveSpec{
        Filterable: []string{"parent_id", "node_type", "name", "deleted_at"},
        Sortable:   []string{"name", "size", "updated_at", "node_type"},
        MaxWindow:  500,
    },
}

Write operations03

CreateFolder

Creates a folder under parentID ("" = tree root). Validates the parent is a folder (ErrNotContainer) and that no live sibling shares the name (ErrNodeNameConflict). Emits one node-create event.

ref, err := f.CreateFolder(ctx, parentID, "reports")
// ref.Path == "/docs/reports"

CreateFile

Stores bytes via PutBlob (one blob-create event), then creates a file node referencing the resulting blob_object 1:1, with size, content_type, and checksum denormalized onto the node (one node-create event). The same name-conflict and container guards apply.

ref, err := f.CreateFile(ctx, parentID, "q1.pdf", r, fabriq.CreateFileOpts{
    ContentType: "application/pdf",
})

RenameNode

Renames a node in place: a single OpUpdate on the node's name, nothing else. Descendants are untouched — their derived paths change implicitly the next time anything reads them, because parent_id (the only tree truth) didn't move. Returns ErrNodeLocked if the node is locked; ErrNodeNameConflict if a live sibling already has newName.

ref, err := f.RenameNode(ctx, nodeID, "q1-final.pdf")

MoveNode

Re-parents a node under newParentID: a single OpUpdate on the node's parent_id, zero descendant writes. Guards:

  • ErrNotContainer if newParentID is not a folder

  • ErrNodeNameConflict if a live sibling at the destination already has the same name

  • cycle guard: moving a node into its own subtree is rejected with an explicit error (a single walk of the new parent's ancestor chain both checks the cycle and derives the echoed FsRef.Path)

ref, err := f.MoveNode(ctx, nodeID, destFolderID)
Note

Both RenameNode and MoveNode emit exactly one event, for the moved node only. Descendants emit no events on a move or rename — there is nothing to rewrite, since paths are derived from parent_id at read time rather than stored. A consumer that cares about descendants must treat a parent move as a subtree-scoped change (re-derive paths on read, or reconcile by prefix) rather than expecting per-descendant events. See ADR 0010.

TrashNode

Soft-deletes a node and its entire subtree by stamping deleted_at on every member. Emits one OpUpdate event per node. Trashed nodes are excluded from ListChildren, GetNodeByPath, Descendants, and the WatchChildren live window, but remain readable via GetNode.

err := f.TrashNode(ctx, folderID)

RestoreNode

Clears deleted_at on a node and its entire subtree. Emits one OpUpdate event per node.

err := f.RestoreNode(ctx, folderID)

PermanentDeleteNode

Hard-deletes a node and its subtree (one OpDelete event per node) then calls DeleteBlob for each file node's blob_object. Removing the blob_object row makes the underlying bytes GC-eligible — see blob GC for how Phase-4 reclaims unreferenced storage.

err := f.PermanentDeleteNode(ctx, nodeID)
Warning

PermanentDeleteNode is irreversible. There is no recovery path once the blob_object rows are deleted and the underlying bytes are GC-reclaimed.

LockNode / UnlockNode

Stamps or clears is_locked and locked_by on a single node. A locked node rejects RenameNode, MoveNode, and ReplaceFile with ErrNodeLocked. Lock state does not propagate to children.

err := f.LockNode(ctx, nodeID, "user:alice")
err  = f.UnlockNode(ctx, nodeID)

ReplaceFile

Stores new bytes via PutBlob and repoints blob_id, size, content_type, and checksum on the node, bumping its version. The previous blob_object is not deleted — a prior version may still be referenced elsewhere; Phase-4 GC reclaims it once it is genuinely unreferenced.

ref, err := f.ReplaceFile(ctx, fileNodeID, newReader, fabriq.CreateFileOpts{
    ContentType: "application/pdf",
})

Read operations04

GetNode

Loads any node by ID regardless of deleted_at state.

node, err := f.GetNode(ctx, nodeID)

NodePath

Derives a node's absolute path by walking the parent_id chain to the root — O(depth) point reads, nothing stored. This is the read-time replacement for the old materialized path column.

p, err := f.NodePath(ctx, nodeID)
// p == "/docs/reports/q1.pdf"

GetNodeByPath

Resolves a live (non-trashed) node by descending the tree one segment at a time on the (tenant_id, parent_id, name) unique index — O(depth) point reads, no materialized path to match against. Returns fabriqerr.ErrNotFound if no live node matches.

node, err := f.GetNodeByPath(ctx, "/docs/reports/q1.pdf")

ListChildren

Returns live children of parentID, ordered by name ASC. Paginated by limit/offset.

children, err := f.ListChildren(ctx, folderID, 50, 0)

Ancestors

Returns the chain of ancestors from root down to (but not including) the target node, in root-first order. Implemented as O(depth) sequential GetNode reads — appropriate for filesystem tree depths.

crumbs, err := f.Ancestors(ctx, nodeID)
// crumbs[0] is the root ancestor, crumbs[len-1] is the direct parent

Descendants

Returns all live nodes under the target node, ordered by their derived path. SQL backends run one recursive CTE over parent_id through the tenant-guarded raw-SQL escape hatch (RelationalQuerier.Query); backends that report fabriqerr.ErrRawSQLUnsupported (e.g. the in-memory fakes used in unit tests) fall back to a portable adjacency walk — breadth-first over parent_id via List — with identical semantics: the walk isn't filtered by deleted_at (a live grandchild under a trashed folder is still found), only the final result set is; ordering is byte-wise (COLLATE "C") on both paths. One documented divergence: past the fsMaxDepth (512) backstop, the CTE silently truncates the subtree while the portable walk returns an error — unobservable at real filesystem depths. Arbitrary-depth graph traversal is also available via the CHILD_OF edge — see graph. See ADR 0010 for the full rationale.

all, err := f.Descendants(ctx, folderID)

SearchNodesByName

SQL ILIKE name search over live nodes, ordered by name ASC. For fuzzy or cross-field search, use the search projection indexed on name and content_type — see search.

results, err := f.SearchNodesByName(ctx, "report", 20)

Live queries — WatchChildren05

WatchChildren returns a maintained result set of a folder's live children, ordered by name ASC. It is a thin wrapper over f.LiveQuery with a fixed filter on parent_id and deleted_at IS NULL.

snap, deltas, handle, err := f.WatchChildren(ctx, folderID, 100)
if err != nil {
    // unknown entity, no LiveSpec, shard count > 1, or no live-query engine configured
}
defer handle.Close()

render(snap.Rows)
for d := range deltas {
    apply(d) // livequery.LiveDelta: Op enter/leave/move/update, Row, OldIndex, NewIndex
}
Warning

Live queries — including WatchChildren — are single-shard only. In a sharded deployment (FABRIQ_SHARD_COUNT > 1) the call returns an error. See live queries for the constraint and the gateway-tier alternative.

Subscribing to a folder

For change notifications without a maintained window (e.g. cache invalidation), use f.Subscribe. The fs_node registry spec declares a "parent" subscribe scope keyed on parent_id; pass a query.SubscribeScope naming that scope and the target folder ID. See Subscriptions for the full Subscribe API and Live Queries for WatchChildren.