Supasheet.
Resource

Metadata

Configure table display, views, sections, filters, and column behaviour via JSON metadata in PostgreSQL comments

Overview

Supasheet reads a JSON object from each table's PostgreSQL comment to control how the resource is presented. The same pattern works for columns — each column's comment can carry its own metadata.

COMMENT ON TABLE your_table  IS '{ ...table metadata... }';
COMMENT ON COLUMN your_table.your_column IS '{ ...column metadata... }';

All properties are optional. The full TableMetadata shape mirrors the TypeScript type defined in src/lib/database-meta.types.ts:

type TableMetadata = {
  display?: "block" | "none"
  name?: string
  description?: string
  icon?: string
  singleton?: boolean
  inline_form?: boolean
  query?: {
    sort?:   { id: string; desc: boolean }[]
    filter?: { id: string; value: string | string[]; operator: string }[]
    join?:   { table: string; on: string; columns: string[] }[]
    select?: string[]
  }
  primary_view?: string
  views?: ViewLayout[]              // Kanban | Calendar | Gallery | List | Tree
  filter_presets?: FilterPreset[]
  fields?: {
    sections?: FieldSection[]
    metadata?: string[]
    quick_create?: string[]
    duplicated?: string[]
    behavior?: Record<string, FieldBehavior>   // keyed by field name
    lookups?: Record<string, LookupConfig>     // keyed by field name
  }
  tabs?: string[]                    // filters which related-table tabs show — see `tabs` below
}

FilterRule (used in both query.filter and filter_presets) has no variant field — only { id, value, operator }. The valid operator values depend on the target column's type; see Query Configuration for the full list.


display

Controls whether the resource appears in the sidebar.

ValueBehaviour
"block" (default)Listed in the schema's resource sidebar
"none"Hidden from the sidebar — still reachable by direct URL or as a child via inline_form
COMMENT ON TABLE supasheet.notifications IS '{"display": "none"}';

name and description

Override the default label and description shown in the sidebar, page header, and breadcrumbs.

COMMENT ON TABLE blog.blog_settings IS '{
  "name": "Blog Settings",
  "description": "Site-wide blog configuration"
}';

icon

Any Lucide React icon name.

COMMENT ON TABLE desk.tasks IS '{"icon": "ListTodo"}';
COMMENT ON TABLE store.products IS '{"icon": "Package"}';
COMMENT ON TABLE hr.employees IS '{"icon": "Users"}';

singleton

Marks the table as a singleton — the resource opens directly into the single record instead of a list. Perfect for settings tables.

COMMENT ON TABLE blog.blog_settings IS '{
  "icon": "BookOpen",
  "name": "Blog Settings",
  "singleton": true
}';

inline_form

When true, the table renders as an editable section on its parent record's detail page instead of a top-level resource. Combine with "display": "none" to hide the table from the schema sidebar.

COMMENT ON TABLE store.order_items IS '{
  "icon": "Receipt",
  "display": "none",
  "inline_form": true
}';

The parent table is detected automatically via the foreign-key relationship.


query

Default sorting, filtering, joins, and column selection applied when the resource first loads. See Query Configuration for the full reference.

COMMENT ON TABLE desk.tasks IS '{
  "query": {
    "sort": [{"id": "created_at", "desc": true}],
    "join": [
      {"table": "users", "on": "user_id", "columns": ["name", "email"]}
    ]
  }
}';

views and primary_view

views declares the alternate views (Kanban, Calendar, Gallery, List, Tree). primary_view is the id of the view to load by default. See Views for the per-view configuration reference.

COMMENT ON TABLE desk.projects IS '{
  "primary_view": "kanban",
  "views": [
    {"id": "kanban", "name": "By Status", "type": "kanban",
     "group": "status", "title": "title", "badge": "priority"},
    {"id": "calendar", "name": "Timeline", "type": "calendar",
     "title": "title", "start_date": "start_date", "end_date": "end_date"}
  ]
}';

If primary_view is omitted, the default sheet view is shown.


fields.sections

Group fields into named sections on the detail / create / edit page. Sections control both layout and what fields are visible per form mode.

type FieldSection = {
  id: string
  title: string
  description?: string
  icon?: string
  collapsible?: boolean
  fields: string[] | Partial<Record<"create" | "update" | "read", string[]>>
}
COMMENT ON TABLE desk.tasks IS '{
  "fields": {
    "sections": [
      {"id": "summary",      "title": "Summary",
       "fields": ["title", "description", "cover"]},
      {"id": "schedule",     "title": "Schedule",
       "fields": ["status", "priority", "assignee_id", "due_date"]},
      {"id": "organization", "title": "Organization",
       "fields": ["project_id", "tags", "is_important"]},
      {"id": "extras", "title": "Attachments & notes", "collapsible": true,
       "description": "Files, color tag, and free-form notes",
       "fields": ["attachments", "color", "notes"]}
    ]
  }
}';

Mode-specific fields

Pass an object instead of an array to show different fields per mode:

"fields": {
  "create": ["title", "status", "assignee_id"],
  "update": ["title", "status", "assignee_id", "due_date", "completed_at"],
  "read":   ["title", "status", "assignee_id", "due_date", "completed_at", "created_at"]
}

Any field not listed in any section is hidden from the form.


filter_presets

Pre-canned filter sets exposed as one-click chips above the table.

type FilterPreset = {
  id: string
  name: string
  description?: string
  icon?: string
  filters: {
    id: string
    value: string | string[]
    operator: string  // valid values depend on the column's type — see Query Configuration
  }[]
}
COMMENT ON TABLE desk.tasks IS '{
  "filter_presets": [
    {"id": "open",     "name": "Open (not completed)",
     "filters": [{"id": "status", "value": ["pending", "in_progress"], "operator": "in"}]},
    {"id": "critical", "name": "Critical & High",
     "filters": [{"id": "priority", "value": ["critical", "high"], "operator": "in"}]},
    {"id": "important", "name": "Important",
     "filters": [{"id": "is_important", "value": "true", "operator": "is"}]}
  ]
}';

fields.metadata

Override the columns considered "metadata" (hidden from regular forms but shown in audit / system sections). Defaults to:

["deleted_at", "created_at", "updated_at", "created_by", "updated_by"]
COMMENT ON TABLE store.products IS '{
  "fields": {
    "metadata": ["created_at", "updated_at", "created_by"]
  }
}';

fields.quick_create and fields.duplicated

quick_create lists the fields shown in the abbreviated "quick create" form (e.g. the inline creator opened from a lookup or a kanban column's "+" button) — everything else is left to its default value and can be filled in later. duplicated lists the fields carried over when a user duplicates a record (fields left out reset to their defaults, e.g. status or created_at).

COMMENT ON TABLE desk.tasks IS '{
  "fields": {
    "quick_create": ["title", "assignee_id", "due_date"],
    "duplicated": ["title", "description", "priority", "project_id", "assignee_id"]
  }
}';

fields.behavior

Conditionally show, require, or lock fields based on the values of other fields in the same form. Keyed by field name; each entry accepts visible / required / read_only, each an array of FieldCondition ({ id, operator, value }) that must all match.

type FieldCondition = {
  id: string        // the other field to check
  operator: string  // eq, neq, lt, lte, gt, gte, like, ilike, is, in, not.ilike, not.is, not.in
  value: string | string[]
}

type FieldBehavior = {
  visible?: FieldCondition[]
  required?: FieldCondition[]
  read_only?: FieldCondition[]
}
COMMENT ON TABLE desk.tasks IS '{
  "fields": {
    "behavior": {
      "due_date": {
        "required": [{"id": "status", "operator": "neq", "value": "backlog"}]
      },
      "completed_at": {
        "visible":   [{"id": "status", "operator": "eq", "value": "completed"}],
        "read_only": [{"id": "status", "operator": "neq", "value": "completed"}]
      },
      "escalation_reason": {
        "visible": [{"id": "priority", "operator": "in", "value": ["high", "critical"]}]
      }
    }
  }
}';

In this example: due_date becomes required once a task leaves backlog; completed_at only shows up (and is only editable) once status is completed; escalation_reason only shows up for high/critical priority tasks — the in operator here is the new capability, not just equality checks.


fields.lookups

Auto-fill and filter dropdown options based on a related record picked in another field. Keyed by the local field that triggers the lookup (usually a foreign key select).

type LookupFillRule   = { target: string; source: string }  // local field ← lookup column
type LookupFilterRule = { on: string; column: string }       // local field → lookup column filter

type LookupConfig = {
  fill?: LookupFillRule[]
  filter?: LookupFilterRule[]
}
-- Selecting a customer auto-fills their default shipping address,
-- and the product list is filtered to the selected warehouse's stock.
COMMENT ON TABLE store.orders IS '{
  "fields": {
    "lookups": {
      "customer_id": {
        "fill": [
          {"target": "shipping_address", "source": "default_address"},
          {"target": "shipping_city",    "source": "default_city"}
        ]
      },
      "product_id": {
        "filter": [
          {"on": "warehouse_id", "column": "warehouse_id"}
        ]
      }
    }
  }
}';

Here, picking a customer_id copies default_address/default_city from the looked-up customer into shipping_address/shipping_city on the form. Picking a warehouse_id restricts the product_id dropdown to products whose warehouse_id column matches.

The lookup target table must be in the same schema as the table declaring the lookup — this is the same PostgREST cross-schema limitation described in Cross-Schema Joins. If customer_id pointed at a table in another schema (most commonly supasheet.users), create a same-named replica view with security_invoker = true in your own schema first, then point the lookup at that view. The foreign key constraint itself must keep referencing the real table (REFERENCES supasheet.users(id)) — a foreign key can never target a view, replica or not, so this part doesn't change.


tabs

Filters which related-table tabs appear on the record detail page. By default — when tabs is omitted — every one-to-one, one-to-many, and many-to-many relationship Supasheet detects is shown as its own tab alongside the main "Detail" tab. Set tabs to a list of relationship names to show only those and hide the rest:

COMMENT ON TABLE desk.tasks IS '{
  "tabs": ["comments", "attachments"]
}';

Each string must match the relationship's name/embed key (the foreign table's PostgREST embed name), not a column or arbitrary label. The main "Detail" tab is always shown regardless of this setting.

tabs has nothing to do with the Audit Log or Comments features — those are separate, permission-gated pages (schema.table:audit / schema.table:comment) linked from the record's actions menu, not tabs controlled by this field. See Audit Logs and Comments.


Column-level Metadata

Each column's PostgreSQL comment carries its own JSON object that the cell renderer and form field consult.

Base ColumnMetadata

type ColumnMetadata = {
  name?: string         // Override the column label
  description?: string  // Helper text shown under inputs
  icon?: string         // Lucide icon
}
COMMENT ON COLUMN store.products.sku IS '{
  "name": "Product SKU",
  "description": "Unique stock-keeping unit",
  "icon": "Barcode"
}';

name isn't just a form label — it now drives the column header everywhere the column appears: the data table, report tables, foreign-key display columns, and CSV export headers. Set it once here and it's consistent across the whole app.

Enum columns

Style enum / text-with-check values with icons and badge variants. Set progress: true to render a progress bar instead.

type EnumColumnMetadata = ColumnMetadata & {
  progress?: boolean
  enums?: {
    [value: string]: {
      icon?: string
      variant: "default" | "secondary" | "success" | "warning" | "destructive" | "info"
    }
  }
}
COMMENT ON COLUMN desk.tasks.status IS '{
  "enums": {
    "pending":     {"variant": "secondary", "icon": "Clock"},
    "in_progress": {"variant": "info",      "icon": "Loader"},
    "completed":   {"variant": "success",   "icon": "Check"},
    "archived":    {"variant": "default",   "icon": "Archive"}
  }
}';

FILE columns

type FileColumnMetadata = ColumnMetadata & {
  accept?: string     // MIME types or extensions accepted by the picker
  maxFiles?: number   // Maximum file count (FILE / array)
  maxSize?: number    // Maximum size in bytes
}
COMMENT ON COLUMN desk.tasks.cover       IS '{"accept": "image/*"}';
COMMENT ON COLUMN desk.tasks.attachments IS '{"accept": "*", "maxFiles": 999}';

AVATAR columns

type AvatarColumnMetadata = ColumnMetadata & {
  maxSize?: number
}

Complete Example

CREATE TABLE desk.tasks (
  id              UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
  title           TEXT NOT NULL,
  description     RICH_TEXT,
  status          TEXT NOT NULL DEFAULT 'pending'
                  CHECK (status IN ('pending', 'in_progress', 'completed', 'archived')),
  priority        TEXT NOT NULL DEFAULT 'medium'
                  CHECK (priority IN ('low', 'medium', 'high', 'critical')),
  cover           FILE,
  attachments     FILE,
  assignee_id     UUID REFERENCES supasheet.users(id),
  project_id      UUID,
  due_date        DATE,
  completed_at    TIMESTAMPTZ,
  is_important    BOOLEAN DEFAULT false,
  completion      PERCENTAGE,
  notes           TEXT,
  created_at      TIMESTAMPTZ DEFAULT now(),
  updated_at      TIMESTAMPTZ DEFAULT now()
);

COMMENT ON TABLE desk.tasks IS $$
{
  "icon": "ListTodo",
  "display": "block",
  "query": {
    "sort": [{"id": "created_at", "desc": true}],
    "join": [
      {"table": "users", "on": "assignee_id", "columns": ["name", "email"]}
    ]
  },
  "primary_view": "status",
  "views": [
    {"id": "status",   "name": "Tasks By Status",   "type": "kanban",
     "group": "status",   "title": "title", "description": "description",
     "date": "created_at", "badge": "priority"},
    {"id": "calendar", "name": "Task Schedule",     "type": "calendar",
     "title": "title", "start_date": "due_date", "badge": "status"}
  ],
  "filter_presets": [
    {"id": "open",      "name": "Open (not completed)",
     "filters": [{"id": "status", "value": ["pending", "in_progress"], "operator": "in"}]},
    {"id": "important", "name": "Important",
     "filters": [{"id": "is_important", "value": "true", "operator": "is"}]}
  ],
  "fields": {
    "quick_create": ["title", "assignee_id", "due_date"],
    "duplicated": ["title", "description", "priority", "project_id"],
    "behavior": {
      "completed_at": {
        "visible": [{"id": "status", "operator": "eq", "value": "completed"}]
      }
    },
    "sections": [
      {"id": "summary",  "title": "Summary",
       "fields": ["title", "description", "cover"]},
      {"id": "schedule", "title": "Schedule",
       "fields": ["status", "priority", "assignee_id", "due_date", "completed_at"]},
      {"id": "progress", "title": "Progress",
       "fields": ["completion", "is_important"]},
      {"id": "extras", "title": "Attachments & notes", "collapsible": true,
       "fields": ["attachments", "notes"]}
    ]
  },
  "tabs": ["tasks_history", "timesheets"]
}
$$;

COMMENT ON COLUMN desk.tasks.status IS '{
  "enums": {
    "pending":     {"variant": "secondary", "icon": "Clock"},
    "in_progress": {"variant": "info",      "icon": "Loader"},
    "completed":   {"variant": "success",   "icon": "Check"},
    "archived":    {"variant": "default",   "icon": "Archive"}
  }
}';

COMMENT ON COLUMN desk.tasks.priority IS '{
  "enums": {
    "low":      {"variant": "default",     "icon": "Minus"},
    "medium":   {"variant": "info",        "icon": "Equal"},
    "high":     {"variant": "warning",     "icon": "TrendingUp"},
    "critical": {"variant": "destructive", "icon": "AlertTriangle"}
  }
}';

COMMENT ON COLUMN desk.tasks.cover       IS '{"accept": "image/*"}';
COMMENT ON COLUMN desk.tasks.attachments IS '{"accept": "*", "maxFiles": 999}';
COMMENT ON COLUMN desk.tasks.completion  IS '{"progress": true}';

Because Supabase comments only allow single-line strings, use dollar-quoted strings ($$ ... $$) for multi-line JSON like the example above.


Next Steps

On this page