Skip to Content
StandardsData & Schemas

Data & Schemas

Frequency uses LinkML (YAML) as the single source of truth for data models. From one schema definition, we auto-generate TypeScript types, SQL migrations, and database DDL. This eliminates drift between code and database.

The Pipeline

LinkML YAML

schemas/core.yaml

Source of truth

Generation Scripts

generate-types.mjs

generate-migration.mjs

TypeScript + SQL

generated/core.ts

Don’t edit

File Organization

packages/models/ ├── schemas/ # ✏️ Edit these — LinkML YAML │ ├── core.yaml # Core tenancy models │ └── .snapshot.json # Cached enum/table metadata for CLI ├── generated/ # 🚫 Don't edit — auto-generated │ └── core.ts # TypeScript interfaces & types ├── scripts/ # Generation tooling │ ├── generate-types.mjs # YAML → TypeScript │ ├── generate-migration.mjs # YAML → SQL DDL │ └── linkml-to-sql.mjs # LinkML → SQL conversion └── package.json # exports: ./schemas/*, ./generated/*

LinkML Schema Structure

Here’s the actual core tenancy schema:

# packages/models/schemas/core.yaml id: https://frequencyads.com/schemas/core name: core prefixes: linkml: https://w3id.org/linkml/ classes: Organization: description: Multi-tenant workspace attributes: id: range: string identifier: true description: UUID primary key name: range: string required: true slug: range: string description: URL-safe unique identifier domain: range: string description: Email domain for auto-join logo_url: range: uri created_at: range: datetime updated_at: range: datetime UserProfile: description: Extends auth.users with profile data attributes: id: range: string identifier: true description: FK to auth.users display_name: range: string avatar_url: range: uri default_org_id: range: Organization description: FK to organizations Membership: description: User-Organization relationship attributes: id: range: string identifier: true user_id: range: UserProfile org_id: range: Organization role: range: OrgRole status: range: MembershipStatus enums: OrgRole: permissible_values: owner: {} admin: {} member: {} viewer: {} MembershipStatus: permissible_values: active: {} invited: {} suspended: {} removed: {}

Generated TypeScript

Running the generation scripts produces clean TypeScript:

// packages/models/generated/core.ts (auto-generated — don't edit) export type OrgRole = 'owner' | 'admin' | 'member' | 'viewer'; export type MembershipStatus = 'active' | 'invited' | 'suspended' | 'removed'; export interface Organization { id: string; name: string; slug: string; domain?: string; logo_url?: string; created_at?: string; updated_at?: string; } export interface UserProfile { id: string; display_name?: string; avatar_url?: string; default_org_id?: string; created_at?: string; updated_at?: string; } export interface Membership { id: string; user_id: string; org_id: string; role: OrgRole; status: MembershipStatus; joined_at?: string; updated_at?: string; }

Usage:

import type { Organization, UserProfile, Membership } from '@frequencyads/models';

Generated SQL

The migration scripts produce SQL DDL for Supabase:

-- supabase/migrations/core_tenancy.sql (auto-generated) CREATE TYPE org_role AS ENUM ('owner', 'admin', 'member', 'viewer'); CREATE TYPE membership_status AS ENUM ('active', 'invited', 'suspended', 'removed'); CREATE TABLE organizations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, logo_url TEXT, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE user_profiles ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, display_name TEXT, avatar_url TEXT, default_org_id UUID REFERENCES organizations(id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE memberships ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES user_profiles(id), org_id UUID NOT NULL REFERENCES organizations(id), role org_role NOT NULL DEFAULT 'member', status membership_status NOT NULL DEFAULT 'active', joined_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), UNIQUE(user_id, org_id) );

Database Features

The generated SQL also includes:

Auto-updating Timestamps

CREATE TRIGGER update_updated_at BEFORE UPDATE ON organizations FOR EACH ROW EXECUTE FUNCTION update_updated_at();

Auto-creating User Profiles

-- Trigger on auth.users insert CREATE TRIGGER handle_new_user AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_new_user();

Auto-joining Organizations by Email Domain

CREATE TRIGGER handle_auto_join_org AFTER INSERT ON user_profiles FOR EACH ROW EXECUTE FUNCTION handle_auto_join_org();

Row-Level Security (RLS)

-- Users can view organizations they belong to ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; CREATE POLICY "Members can view their organizations" ON organizations FOR SELECT USING ( id IN (SELECT org_id FROM memberships WHERE user_id = auth.uid() AND status = 'active') ); -- Users can read/update their own profile -- Admins can manage memberships (owner/admin roles)

Supabase Integration

Browser Client (Client Components)

'use client'; import { createClient } from '@frequencyads/db/client'; function MyComponent() { const supabase = createClient(); const { data } = await supabase.from('organizations').select('*'); }

Server Client (Server Components & Route Handlers)

import { createClient } from '@frequencyads/auth/server'; async function getData() { const supabase = await createClient(); const { data, error } = await supabase.from('organizations').select('*'); return data; }

Multi-Tenancy Pattern

The @frequencyads/db package provides table prefixing for multi-tenancy:

import { prefixTable } from '@frequencyads/db/prefix'; // Scope queries to an organization const tableName = prefixTable('documents', orgId);

Best Practices

  1. Edit LinkML YAML schemas, never the generated TypeScript or SQL
  2. Run generation scripts after every schema change
  3. Review generated SQL before applying migrations — check indexes, constraints, and RLS policies
  4. Use the generated types throughout your application — they’re the contract between your code and database
  5. Add new entities by adding classes to the YAML schema, never by writing SQL manually
  6. Keep the snapshot (.snapshot.json) up to date — it caches metadata for CLI tooling
Last updated on