Context for nested spaces under a Location — rooms, floors, zones, etc. forming a per-location tree.
Same-Location parent invariant
A space's parent_uuid (when set) must reference another space in
the same Location. The DB doesn't enforce this directly — a
composite FK on (parent_uuid, location_uuid) would, but it's
heavier than the consumer surface justifies. We guard at the
context boundary instead: create_space/2 and update_space/3
reject any cross-location parent with {:error, :parent_in_other_location}.
Cycle prevention
Direct self-loop is caught by the schema changeset. Indirect cycles
(A → B → A) are blocked here in validate_no_cycle/3 before any
parent_uuid change is persisted. Walk-up depth-limited to 64 hops —
generous for any realistic building hierarchy.
Activity logging
Mutating functions accept opts \ [] and forward :actor_uuid
for the activity log. Guarded with Code.ensure_loaded?(PhoenixKit.Activity) and
rescued so logging never crashes the mutation.
Parity with Locations:
{:ok, space}— logs with space metadata, same asLocations.{:error, %Ecto.Changeset{}}— logs adb_pending: trueaudit row, same asLocations.{:error, atom}(:cycle,:parent_in_other_location,:parent_not_found,:location_not_found) — not logged: these rejections carry no changeset or resource UUID to attach to, so no partial audit row is written.
Summary
Functions
Builds an empty changeset (for :new forms).
Counts every descendant of space_uuid — children, grandchildren,
and so on — not including the space itself. 0 for a leaf, and 0
for an unknown uuid (rather than raising) so callers don't need a
defensive existence check first.
Creates a new space. Rejects parents that live in a different
Location with {:error, :parent_in_other_location}.
Hard-deletes a space. Children CASCADE via the DB FK — the entire subtree is removed. The activity log records the delete of the named root; children deletes aren't individually logged (would be noisy on deep trees).
Full breadcrumb path for a Space, root Location through the Space
itself: "Location / Floor / Zone / Shelf". nil when the space
(or its Location) can't be found.
Fetches a space by UUID. Returns nil if not found.
All spaces for a Location, ordered by (parent_uuid, position).
Returns a flat list; use list_tree/1 for a nested shape.
Nested tree of spaces for a Location. Each node carries a :children
key as a list (empty for leaves). Root-level nodes have parent_uuid == nil.
Reorders a sibling group under a single (location, parent) — accepts
the full ordered list of sibling UUIDs and rewrites their position
to match. Runs in a transaction; returns {:ok, :reordered} or
{:error, reason}.
Updates an existing space. Re-parenting is allowed but rejected if the new parent lives in another Location, or if the change would create a cycle.
Types
Functions
@spec change_space(PhoenixKitLocations.Schemas.Space.t(), map()) :: Ecto.Changeset.t()
Builds an empty changeset (for :new forms).
@spec count_descendants(uuid()) :: non_neg_integer()
Counts every descendant of space_uuid — children, grandchildren,
and so on — not including the space itself. 0 for a leaf, and 0
for an unknown uuid (rather than raising) so callers don't need a
defensive existence check first.
Backs LocationStructureLive's delete-confirmation modal: before
showing "Delete \"X\" and its N descendants?", the caller needs the
true blast radius of a hard delete (children CASCADE — see
delete_space/2).
@spec create_space(map(), opts()) :: {:ok, PhoenixKitLocations.Schemas.Space.t()} | {:error, Ecto.Changeset.t() | :parent_in_other_location | :parent_not_found | :location_not_found}
Creates a new space. Rejects parents that live in a different
Location with {:error, :parent_in_other_location}.
When attrs doesn't include an explicit position, the new space
is appended to the end of its (location_uuid, parent_uuid) sibling
group — max(position) + 1, or 0 for the first child. Without
this, every space created through the "Add space" form (which never
sends position) would sit at the schema default of 0 and jump to
the front of its siblings the next time anything reorders that
group. An explicit position in attrs — used throughout the test
suite to pre-seed sibling order — is always honored as-is.
@spec delete_space(PhoenixKitLocations.Schemas.Space.t(), opts()) :: {:ok, PhoenixKitLocations.Schemas.Space.t()} | {:error, Ecto.Changeset.t()}
Hard-deletes a space. Children CASCADE via the DB FK — the entire subtree is removed. The activity log records the delete of the named root; children deletes aren't individually logged (would be noisy on deep trees).
Full breadcrumb path for a Space, root Location through the Space
itself: "Location / Floor / Zone / Shelf". nil when the space
(or its Location) can't be found.
opts[:locale] — when given, each segment's name resolves through
PhoenixKit.Utils.Multilang.get_language_data/2 for that language
(falling back to the primary-language column when no translation
override exists). Omitted (or nil) uses the primary-language
column directly for every segment — no data JSONB read at all.
@spec get_space(uuid()) :: PhoenixKitLocations.Schemas.Space.t() | nil
Fetches a space by UUID. Returns nil if not found.
@spec list_for_location(uuid()) :: [PhoenixKitLocations.Schemas.Space.t()]
All spaces for a Location, ordered by (parent_uuid, position).
Returns a flat list; use list_tree/1 for a nested shape.
Nested tree of spaces for a Location. Each node carries a :children
key as a list (empty for leaves). Root-level nodes have parent_uuid == nil.
Single DB read — the tree is assembled in memory from the flat list.
@spec reorder_siblings(uuid(), uuid() | nil, [uuid()], opts()) :: {:ok, :reordered} | {:error, term()}
Reorders a sibling group under a single (location, parent) — accepts
the full ordered list of sibling UUIDs and rewrites their position
to match. Runs in a transaction; returns {:ok, :reordered} or
{:error, reason}.
@spec update_space(PhoenixKitLocations.Schemas.Space.t(), map(), opts()) :: {:ok, PhoenixKitLocations.Schemas.Space.t()} | {:error, Ecto.Changeset.t() | :parent_in_other_location | :parent_not_found | :location_not_found | :cycle}
Updates an existing space. Re-parenting is allowed but rejected if the new parent lives in another Location, or if the change would create a cycle.