fayzfayz sdk
PTEN

Data model

For an outside developer, the Fayz data model collapses into a single idea: you bring your own Supabase (BYOS) and your project gets the same schema shape the platform runs at scale. That shape has three layers — the core library, the plugin tables (plg_) and your app's tables — plus one rule that makes it all fit together: tenant_id + RLS is the isolation boundary. Get that right and the classic question — "where does this table live?" — goes away.

public.*      base tables every business needs (tenants, people, tenant_members, role_permissions…)
plg_*         tables a plugin installs and owns (plg_shop_products, plg_courses_*…)
your tables   what your app/incubator adds on top (same rules: tenant_id + RLS)

Architecture in transition. These docs describe the target shape — the core library and plugin tables with the plg_ prefix — that is shipping with the industry pools wave. Today the base tables don't live in a separate core. schema: they all live in public.* (migration 001 is explicit — all core entities live directly in the public schema), and the plg_ prefix is a convention already adopted by some modules, not a rule everyone follows yet. The command and the flow are already these; the labels and naming migrate along with the wave.

The core library — the base tables

The core library (formerly called saas_core) brings what every business needs. It isn't a separate schema with its own rules — the tables live in public.*, ready and isolated:

Core tableWhat it holds
tenantsyour SaaS's customers (a salon, a clinic) — the root of isolation
peoplethe people in the business: customers, contacts, leads
tenant_memberswho works in the tenant (professionals, staff)
role_permissionsroles and permissions inside the tenant
appointmentsbookings, reservations, meetings
transactionsfinancial movements: money in and out

Every table carries tenant_id and has RLS. That pair — column + policy — is what makes one tenant's row physically invisible to another. Isolation does not come from separating schemas; it comes from RLS keyed on tenant_id. That's the point the rest of the model rests on, and the details are in RLS and multi-tenancy.

Plugin tables — the plg_ prefix means ownership

A plugin installs its own tables with the plg_ prefix (for example plg_shop_products, plg_courses_*). The prefix signals ownership: if a table starts with plg_, it belongs to a plugin — not to core, not to your app. It's a convention — adopted today by shop and courses and recommended for new plugins — not a rule every module already follows (some connectors create tables without the prefix).

See the plg_ tables each plugin installs — along with their key columns — on the plugin's specs page in the catalog.

Beyond the tables it creates, a plugin reuses the core tables it needs instead of copying them. If the scheduling plugin needs to know "who booked", it references public.people; it never stores a copy of the person. One source of truth per entity.

Each plugin ships its own migrations, and they follow rules that make db apply reproducible:

  • ordered — the apply sequence is deterministic.
  • versioned — every migration has a version; the runner knows what already ran.
  • fix-forward — migrations are append-only: never edit a migration that has already been applied, write the next one that fixes it. There is no .down.sql.
  • idempotent — running again neither breaks nor duplicates.

Your app's tables

When your product needs fields or tables that neither core nor a plugin provides, your app (or an incubator plugin) creates its own tables. That's where the specific richness of your domain lives. The rule doesn't change: they must carry tenant_id and have RLS, exactly like core and plugins. Your tables aren't an exception to isolation — they're just like every other table.

The golden rule: same shape, three tiers

The same schema shape — core + plg_ + your tables, all isolated by tenant_id + RLS — holds wherever the app runs. That's what makes your BYOS identical to what the platform operates at scale.

How the platform hosts at scale (context — your BYOS is the same shape). The platform serves three tiers with the same shape: ① an industry pool — one Supabase project shared by many tenants in the same industry, isolated only by tenant_id + RLS; ② custom objects — extra tables in the same pool via a generic mechanism, no DDL (no stable external contract yet); ③ dedicated database — a project of your own, same shape, where promoting means copying the data and swapping the connection string. You don't need any of this to build: your BYOS is born in the same shape, and moving between tiers never rewrites the schema.

Website and admin: same tables, different RLS

A common mistake is creating pairs of public_* (for the site) and admin_* (for the panel) tables. Fayz never does this. The site side and the admin side read the same tables — what changes is the RLS policy and the role of whoever is querying: the tenant's team sees the tenant's rows; the end customer sees only their own. One table, two views, guaranteed by the database — not two tables to keep in sync.

Why this matters in practice

This single shape is what makes the CRM's customer and the scheduling booking agree on what "a person" is: both point at the same public.people. You don't have to wire any of this by hand — fayz db apply creates the layers in the right order (core first, then each plugin's versioned migrations, then your incubator ones) in your own Supabase.


Next: provision all of this in a real database with BYOS in Supabase.