fayzfayz sdk
PTEN

Patterns

A plugin that follows the contract works. A plugin that follows the patterns composes: it coexists with the other plugins, accepts a real backend without a rewrite, and can be promoted to official. This page is the set of conventions that make that difference.

Factory, not instance

A plugin is exported as a factory — a function that returns the PluginManifest — not as a ready-made object. That lets whoever assembles the app inject options (a provider, a menu position, a vertical) without editing the plugin:

export function createLoyaltyPlugin(options?: LoyaltyPluginOptions): PluginManifest {
  // ...
}

Wiring the plugin up means calling the factory in the plugins array. Configuring it means passing options. It's never editing the plugin's internals.

Provider-first: data is a contract

A plugin's UI never talks to a database directly. It talks to a DataProvider — an interface every backend implements. The plugin defines the contract in data/types.ts, ships a mock and a supabase one, and picks between them with createSafeDataProvider:

const provider =
  options?.dataProvider ??
  createSafeDataProvider(
    () => createSupabaseLoyaltyProvider(), // used when a backend is available
    () => createMockLoyaltyProvider(),     // fallback without a database
  )

The payoff: the plugin runs on day zero (mock), and switching to real data touches no screen — it only switches the provider. It's the same pattern that makes a plugin testable without infrastructure (see Test and debug).

The provider boundary: talk to Fayz, not to the provider

The rule fayz doctor polices: an app or plugin does not import provider SDKs directly (@supabase/supabase-js, mercadopago, stripe, googleapis, …). It gets access through the Fayz boundary — getSupabaseClientOptional() / the DataProvider interface — or through the connector spine.

import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
// ✔ goes through the Fayz boundary, scoped by tenant

// import { createClient } from '@supabase/supabase-js'
// -> doctor would flag this as a boundary break

There's one deliberate exception: an app-local plugin may own a connector + Edge Function for a provider Fayz doesn't support yet — because the credential stays on the server and the app still calls its own boundary, not the provider from the browser. That exception is declared with a marker on the import line, not casually.

The supported surface

An app only depends on packages from the supported public surface — @fayz-ai/sdk, core, saas, ui, auth, db, storefront, shop and the @fayz-ai/plugin-* ones. Importing an internal subpath or a package outside that list makes doctor warn (off-surface-import / off-surface-dependency). The rule exists to keep upgrades safe: anything not on the surface can change without notice.

plg_ tables and core reuse

By convention, a plugin installs its own tables with the plg_ prefix (which is what shop and courses do — plg_shop_products, plg_courses_*) to make ownership obvious. The convention is recommended, not universal: not every module follows it — the openbanking connector, for instance, creates bank_integrations with no prefix. What the plugin does not create, it reuses: it references the core library tables it needs instead of copying them. A plugin never redefines a core entity; if it needs to know "who booked", it references public.people. One source of truth per entity.

The migrations a plugin ships follow rules that make fayz 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 one that's already been applied, write the next one that fixes it. There is no .down.sql.
  • idempotent — running again neither breaks nor duplicates.

Isolation, always

Every plg_ table a plugin owns carries tenant_id and RLS in the canonical form. This isn't optional or a "later" thing — it's the condition for the table to exist in the model at all. The details are in RLS and multi-tenancy.

Enforcement is soft by design: fayz doctor reports these violations as warnings, it doesn't fail the build. The idea is visibility without getting in the way of the DX while you're experimenting. But a plugin that wants to graduate to official needs a clean doctor run.


Next: exercise the app and catch breakage early in Test and debug.