LivePanels.Canvas.LayoutStore behaviour (livepanels v0.1.0)

Copy Markdown View Source

Behaviour for server-side canvas layout persistence.

The default implementation (LivePanels.Canvas.LayoutStore.Registry) stores layouts in an ETS table that lives for the lifetime of the server process. For durable persistence across restarts, use LivePanels.Canvas.LayoutStore.Ecto or implement this behaviour for another database or external cache.

Ecto store

defmodule MyApp.CanvasLayouts do
  use LivePanels.Canvas.LayoutStore.Ecto,
    repo: MyApp.Repo,
    schema: MyApp.LivePanelsCanvasLayout
end

The schema should have a unique canvas id field and a layout field that stores the versioned layout list unchanged. See LivePanels.Canvas.LayoutStore.Ecto for schema and migration examples.

Implementing another store

defmodule MyApp.DbLayoutStore do
  @behaviour LivePanels.Canvas.LayoutStore

  @impl true
  def save(canvas_id, layout) do
    MyApp.Repo.upsert_layout(%{canvas_id: canvas_id, layout: layout})
    :ok
  end

  @impl true
  def load(canvas_id) do
    case MyApp.Repo.get_layout(canvas_id) do
      nil -> :not_found
      %{layout: layout} -> {:ok, layout}
    end
  end
end

Then wire it into the canvas:

use MyAppWeb, :live_view

use LivePanels.Canvas,
  pubsub: MyApp.PubSub,
  session: [layout_store: MyApp.DbLayoutStore]

Layout format

Both callbacks work with the canonical versioned layout payload emitted by LivePanels.Canvas.State.serialize_layout/1:

[
  %{"__livepanels_schema__" => 10},
  %{
    item_id: "item-1",
    type: "panel_item",
    x: 100,
    y: 80,
    w: 440,
    h: 340,
    placement_space: :world,
    placement_anchor: :top_left,
    persistent: true,
    metadata: %{},
    opts: %{},
    capabilities_snapshot: %{}
  }
]

The payload is a layout codec, not a hand-authored application item list:

  • the first entry is the schema header
  • item ids are preserved on restore
  • item entries may include topology fields such as attachment data
  • connector topology persists as connector capability state

Stores should persist and return this payload unchanged.

Persistence policy

save/2 is called after mutations that affect recoverable item state:

  • Persisted: add, remove, move, resize, layout, restore_layout, durable item props, and finalized drag/resize geometry

  • Ephemeral: in-flight pointer state, browser-only previews, loading indicators, and other display state that can be rebuilt

The guiding rule: geometry changes, lifecycle events, and durable item props are persisted. Ephemeral display state is not.

load/1 is called during mount/3, before any content-bearing items are rendered. Returning {:ok, layout} causes the compositor to restore that versioned layout immediately. The runtime validates loaded layouts before restore. During persisted bootstrap, valid entries can load while rejected entries are skipped and logged; explicit restore_layout commands remain all-or-nothing.

Summary

Callbacks

Loads the most recently saved versioned layout for the given canvas.

Persists the current versioned layout for the given canvas.

Functions

Calls load/1 defensively and normalizes failures.

Calls save/2 defensively and normalizes failures.

Types

canvas_id()

@type canvas_id() :: String.t()

layout_entry()

@type layout_entry() :: map()

Callbacks

load(canvas_id)

@callback load(canvas_id()) :: {:ok, [layout_entry()]} | :not_found | {:error, term()}

Loads the most recently saved versioned layout for the given canvas.

Return {:ok, layout} where layout is a list of layout_entry/0 maps if a layout exists, :not_found if no layout is stored for this canvas (e.g. first visit), or {:error, reason} when the store cannot answer.

Called during mount/3 — keep implementations synchronous and fast to avoid blocking the initial render.

save(canvas_id, list)

@callback save(canvas_id(), [layout_entry()]) :: :ok | {:error, term()}

Persists the current versioned layout for the given canvas.

layout is the exact list returned by LivePanels.Canvas.State.serialize_layout/1. Called frequently; keep implementations fast.

Return :ok on success or {:error, reason} on failure. The default LivePanels.Canvas.LayoutStore.Registry implementation persists immediately to ETS. A failed save must never crash the compositor process.

Functions

safe_load(layout_store, canvas_id)

@spec safe_load(module(), canvas_id()) ::
  {:ok, [layout_entry()]} | :not_found | {:error, term()}

Calls load/1 defensively and normalizes failures.

This helper accepts the canonical {:ok, layout}, :not_found, and {:error, reason} returns. All other returns are treated as callback contract errors.

safe_save(layout_store, canvas_id, layout)

@spec safe_save(module(), canvas_id(), [layout_entry()]) :: :ok | {:error, term()}

Calls save/2 defensively and normalizes failures.

This helper catches callback exceptions/exits, accepts :ok and {:error, reason}, and treats all other returns as callback contract errors.