ngx-saqly-supabase
v0.0.19
Published
Angular library for building full-stack applications with [Supabase](https://supabase.com) — **no backend required**.
Maintainers
Readme
ngx-supabase
Angular library for building full-stack applications with Supabase — no backend required.
Define your database schema in TypeScript, run migrations, and perform full CRUD operations directly from Angular using a simple, type-safe API.
Table of Contents
- Installation
- Quick Setup
- Quick Setup — LMS / Feedzony SaaS (with Lemon Squeezy)
- Step 1 — Define Your Schema
- Step 2 — Run Migrations
- Step 3 — CRUD Operations
- Step 4 — Querying Across Tables (Joins)
- Relations
- Row-Level Security & Policies
- Authentication
- Complete Example — Products
- Real Example — Users, Products & Categories
- API Reference
Installation
npm install ngx-saqly-supabase @supabase/supabase-jsQuick Setup
Add the provider once in app.config.ts:
// app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideSaqlySupabase } from 'ngx-saqly-supabase';
import { environment } from '../environments/environment';
import { productsMigration } from './schema/product';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideSaqlySupabase({
url: environment.supabaseUrl,
key: environment.supabaseAnonKey,
migrations: [productsMigration],
}),
],
};Quick Setup — LMS / Feedzony SaaS (with Lemon Squeezy)
A complete, copy-pasteable recipe to bootstrap a Feedzony-style SaaS / LMS on Supabase using ngx-saqly-supabase — plus a Lemon Squeezy subscription integration.
You get four tables wired together with foreign keys, RLS, policies, and grants:
| Table | Purpose |
|---|---|
| users | App users (linked to auth.users.id) |
| subscriptions | One row per user — Lemon Squeezy plan, status, renewal date |
| projects | A user's projects (LMS courses / Feedzony products) |
| feedback | Feedback items linked to a project |
The generated SQL contains no duplicate statements — exec_sql setup, RLS, grants, and policies are emitted exactly once per object.
1. schema/user.ts
import { defineSchema, defineModel, field, policy, InferModel } from 'ngx-saqly-supabase';
export const UsersTable = defineSchema('users', {
id: field.uuid().primary(), // matches auth.users.id
email: field.string().required().unique(),
full_name: field.string(),
avatar_url: field.string(),
created_at: field.timestamp().defaultNow(),
});
export type User = InferModel<typeof UsersTable>;
export const UserModel = defineModel(UsersTable);
export const ReadOwnUserPolicy = policy
.select('users read own row')
.on(UsersTable)
.to('authenticated')
.using('auth.uid() = id')
.build();
export const InsertOwnUserPolicy = policy
.insert('users insert own row')
.on(UsersTable)
.to('authenticated')
.withCheck('auth.uid() = id')
.build();
export const UpdateOwnUserPolicy = policy
.update('users update own row')
.on(UsersTable)
.to('authenticated')
.using('auth.uid() = id')
.withCheck('auth.uid() = id')
.build();2. schema/subscription.ts
import { defineSchema, defineModel, field, policy, relation, InferModel } from 'ngx-saqly-supabase';
import { UsersTable } from './user';
export const SubscriptionsTable = defineSchema('subscriptions', {
id: field.bigint().primaryGenerated(),
user_id: field.uuid().required().unique(), // one sub per user
lemon_customer_id: field.string(), // Lemon Squeezy customer id
lemon_subscription_id: field.string().unique(), // Lemon Squeezy subscription id
variant_id: field.string(), // plan / variant id
status: field.string().default(`'inactive'`), // active | on_trial | paused | cancelled | expired
renews_at: field.timestamp(),
ends_at: field.timestamp(),
trial_ends_at: field.timestamp(),
created_at: field.timestamp().defaultNow(),
updated_at: field.timestamp().defaultNow(),
});
export type Subscription = InferModel<typeof SubscriptionsTable>;
export const SubscriptionModel = defineModel(SubscriptionsTable);
export const SubscriptionUserRelation = relation.manyToOne(SubscriptionsTable, UsersTable, {
localKey: 'user_id', foreignKey: 'id', onDelete: 'cascade', onUpdate: 'cascade',
});
export const ReadOwnSubscriptionPolicy = policy
.select('subs read own')
.on(SubscriptionsTable)
.to('authenticated')
.using('auth.uid() = user_id')
.build();The subscription row is written by the Lemon Squeezy webhook (see step 6) using the service-role key — it bypasses RLS. The user can only read their own subscription.
3. schema/project.ts
import { defineSchema, defineModel, field, policy, relation, InferModel } from 'ngx-saqly-supabase';
import { UsersTable } from './user';
export const ProjectsTable = defineSchema('projects', {
id: field.bigint().primaryGenerated(),
user_id: field.uuid().required(),
name: field.string().required(),
slug: field.string().required().unique(),
description: field.string(),
created_at: field.timestamp().defaultNow(),
});
export type Project = InferModel<typeof ProjectsTable>;
export const ProjectModel = defineModel(ProjectsTable);
export const ProjectUserRelation = relation.manyToOne(ProjectsTable, UsersTable, {
localKey: 'user_id', foreignKey: 'id', onDelete: 'cascade', onUpdate: 'cascade',
});
export const ReadOwnProjectsPolicy = policy
.select('projects read own')
.on(ProjectsTable).to('authenticated').using('auth.uid() = user_id').build();
export const InsertOwnProjectsPolicy = policy
.insert('projects insert own')
.on(ProjectsTable).to('authenticated').withCheck('auth.uid() = user_id').build();
export const UpdateOwnProjectsPolicy = policy
.update('projects update own')
.on(ProjectsTable).to('authenticated')
.using('auth.uid() = user_id').withCheck('auth.uid() = user_id').build();
export const DeleteOwnProjectsPolicy = policy
.delete('projects delete own')
.on(ProjectsTable).to('authenticated').using('auth.uid() = user_id').build();4. schema/feedback.ts
import { defineSchema, defineModel, field, policy, relation, InferModel } from 'ngx-saqly-supabase';
import { ProjectsTable } from './project';
export const FeedbackTable = defineSchema('feedback', {
id: field.bigint().primaryGenerated(),
project_id: field.bigint().required(),
author: field.string(), // optional — may be anonymous
message: field.string().required(),
rating: field.integer(), // 1..5
created_at: field.timestamp().defaultNow(),
});
export type Feedback = InferModel<typeof FeedbackTable>;
export const FeedbackModel = defineModel(FeedbackTable);
export const FeedbackProjectRelation = relation.manyToOne(FeedbackTable, ProjectsTable, {
localKey: 'project_id', foreignKey: 'id', onDelete: 'cascade', onUpdate: 'cascade',
});
// Anyone can submit feedback for any project (public widget)
export const InsertFeedbackPolicy = policy
.insert('feedback insert anyone')
.on(FeedbackTable).to('anon', 'authenticated').withCheck('true').build();
// Only the project owner can read feedback for their projects
export const ReadOwnFeedbackPolicy = policy
.select('feedback read by project owner')
.on(FeedbackTable)
.to('authenticated')
.using('exists (select 1 from public.projects p where p.id = project_id and p.user_id = auth.uid())')
.build();5. schema/migrations.ts — single migration definition
Group everything into one migration so you get a single, deduplicated SQL output.
import { MigrationDefinition } from 'ngx-saqly-supabase';
import {
UsersTable,
ReadOwnUserPolicy, InsertOwnUserPolicy, UpdateOwnUserPolicy,
} from './user';
import {
SubscriptionsTable, SubscriptionUserRelation, ReadOwnSubscriptionPolicy,
} from './subscription';
import {
ProjectsTable, ProjectUserRelation,
ReadOwnProjectsPolicy, InsertOwnProjectsPolicy,
UpdateOwnProjectsPolicy, DeleteOwnProjectsPolicy,
} from './project';
import {
FeedbackTable, FeedbackProjectRelation,
InsertFeedbackPolicy, ReadOwnFeedbackPolicy,
} from './feedback';
export const saasMigration: MigrationDefinition = {
name: 'lms-saas-initial-schema',
tables: [UsersTable, SubscriptionsTable, ProjectsTable, FeedbackTable],
relations: [
SubscriptionUserRelation,
ProjectUserRelation,
FeedbackProjectRelation,
],
rls: [UsersTable, SubscriptionsTable, ProjectsTable, FeedbackTable],
policies: [
ReadOwnUserPolicy, InsertOwnUserPolicy, UpdateOwnUserPolicy,
ReadOwnSubscriptionPolicy,
ReadOwnProjectsPolicy, InsertOwnProjectsPolicy, UpdateOwnProjectsPolicy, DeleteOwnProjectsPolicy,
InsertFeedbackPolicy, ReadOwnFeedbackPolicy,
],
};Register it once:
// app.config.ts
provideSaqlySupabase({
url: environment.supabaseUrl,
key: environment.supabaseAnonKey,
migrations: [saasMigration],
});The generated SQL contains exactly one -- Setup block, one -- Tables block, one -- Relations, one -- Row Level Security, one -- Policies, and one -- Grants — no repeated grant usage on schema public, no repeated policies, no duplicate exec_sql function.
6. Lemon Squeezy — payment & subscription sync
Lemon Squeezy handles the checkout, billing, taxes, and invoices. Your job is to:
- Send the user to the Lemon Squeezy checkout with their
user_idascustom_data. - Receive webhooks for
subscription_created,subscription_updated,subscription_cancelled. - Upsert the matching row in the
subscriptionstable using the service-role key (bypasses RLS).
6.1 Send the user to checkout
// pricing.component.ts
import { Component, inject } from '@angular/core';
import { injectAuth } from 'ngx-saqly-supabase';
@Component({
selector: 'app-pricing',
standalone: true,
template: `
<button (click)="subscribe()">Upgrade — $9 / month</button>
`,
})
export class PricingComponent {
private readonly auth = injectAuth();
// Replace with your variant URL from the Lemon Squeezy dashboard
private readonly checkoutUrl = 'https://yourstore.lemonsqueezy.com/buy/abc-123-variant';
subscribe(): void {
const user = this.auth.user();
if (!user) return;
const url = new URL(this.checkoutUrl);
url.searchParams.set('checkout[email]', user.email ?? '');
url.searchParams.set('checkout[custom][user_id]', user.id);
window.location.href = url.toString();
}
}The
custom[user_id]value is echoed back in every webhook payload asmeta.custom_data.user_id. That's how the webhook knows which Supabase user owns the subscription.
6.2 Webhook handler — Supabase Edge Function
Create supabase/functions/lemonsqueezy-webhook/index.ts (Supabase Edge Function — Deno runtime):
// supabase/functions/lemonsqueezy-webhook/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import { createHmac } from 'node:crypto';
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const LEMON_WEBHOOK_SECRET = Deno.env.get('LEMON_SQUEEZY_WEBHOOK_SECRET')!;
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
Deno.serve(async (req) => {
const raw = await req.text();
const signature = req.headers.get('x-signature') ?? '';
// Verify HMAC-SHA256 signature
const expected = createHmac('sha256', LEMON_WEBHOOK_SECRET).update(raw).digest('hex');
if (signature !== expected) {
return new Response('Invalid signature', { status: 401 });
}
const payload = JSON.parse(raw);
const event = payload.meta.event_name as string; // subscription_created | _updated | _cancelled
const userId = payload.meta.custom_data?.user_id as string | undefined;
const sub = payload.data.attributes;
if (!userId) return new Response('Missing user_id', { status: 400 });
await admin.from('subscriptions').upsert(
{
user_id: userId,
lemon_customer_id: String(sub.customer_id),
lemon_subscription_id: String(payload.data.id),
variant_id: String(sub.variant_id),
status: sub.status, // 'active' | 'on_trial' | 'paused' | 'cancelled' | 'expired'
renews_at: sub.renews_at,
ends_at: sub.ends_at,
trial_ends_at: sub.trial_ends_at,
updated_at: new Date().toISOString(),
},
{ onConflict: 'user_id' }
);
return new Response('ok', { status: 200 });
});Deploy it and set the URL in the Lemon Squeezy dashboard → Settings → Webhooks. Set the signing secret to the same value as LEMON_SQUEEZY_WEBHOOK_SECRET.
supabase functions deploy lemonsqueezy-webhook --no-verify-jwt
supabase secrets set LEMON_SQUEEZY_WEBHOOK_SECRET=whsec_xxxxxxxxxx6.3 Read the subscription in the app
import { Component, OnInit, computed, signal } from '@angular/core';
import { injectAuth, injectModel } from 'ngx-saqly-supabase';
import { Subscription, SubscriptionModel } from '../schema/subscription';
@Component({
selector: 'app-subscription-status',
standalone: true,
template: `
@if (loading()) {
<p>Loading subscription…</p>
} @else if (isActive()) {
<p>✓ Pro — renews on {{ sub()?.renews_at | date }}</p>
} @else {
<p>Free plan — <a routerLink="/pricing">upgrade</a></p>
}
`,
})
export class SubscriptionStatusComponent implements OnInit {
private readonly auth = injectAuth();
private readonly repo = injectModel(SubscriptionModel);
sub = signal<Subscription | null>(null);
loading = signal(true);
readonly isActive = computed(() => {
const s = this.sub();
return !!s && (s.status === 'active' || s.status === 'on_trial');
});
async ngOnInit(): Promise<void> {
const user = this.auth.user();
if (!user) { this.loading.set(false); return; }
this.sub.set(await this.repo.findFirst({ where: { user_id: user.id } }));
this.loading.set(false);
}
}6.4 Gate a route by subscription
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { injectAuth, injectModel } from 'ngx-saqly-supabase';
import { SubscriptionModel } from '../schema/subscription';
export const proGuard: CanActivateFn = async () => {
const auth = injectAuth();
const repo = injectModel(SubscriptionModel);
const router = inject(Router);
const user = auth.user();
if (!user) return router.parseUrl('/login');
const sub = await repo.findFirst({ where: { user_id: user.id } });
const active = sub && (sub.status === 'active' || sub.status === 'on_trial');
return active ? true : router.parseUrl('/pricing');
};// app.routes.ts
{ path: 'lms', loadComponent: () => import('./lms/lms.component'), canActivate: [proGuard] },7. Folder structure for the LMS / Feedzony SaaS
src/
├── environments/environment.ts
├── app.config.ts
├── app.component.ts
├── app.routes.ts
├── schema/
│ ├── user.ts
│ ├── subscription.ts
│ ├── project.ts
│ ├── feedback.ts
│ └── migrations.ts ← single MigrationDefinition
├── auth/
│ ├── login.component.ts
│ └── signup.component.ts
├── pricing/
│ └── pricing.component.ts ← Lemon Squeezy checkout
├── dashboard/
│ ├── projects.component.ts
│ ├── feedback.component.ts
│ └── subscription-status.component.ts
├── guards/
│ └── pro.guard.ts
└── admin/
└── admin-migration.component.tsThat's the entire stack: schema → migration → auth → checkout → webhook → gated routes — no backend code other than the single Lemon Squeezy webhook function.
Step 1 — Define Your Schema
defineSchema(tableName, fields)
Defines a database table and its columns. The TypeScript type of each row is inferred automatically — no interface needed.
// schema/product.ts
import { defineSchema, defineModel, field, InferModel, MigrationDefinition } from 'ngx-saqly-supabase';
export const ProductsTable = defineSchema('products', {
id: field.bigint().primaryGenerated(), // auto-generated PK
name: field.string().required(), // NOT NULL
price: field.integer().required(), // NOT NULL
stock: field.integer().default('0'), // always present (has default)
description: field.string(), // nullable — optional
created_at: field.timestamp().defaultNow(),
});
export type Product = InferModel<typeof ProductsTable>;
// → { id: number; name: string; price: number; stock: number; description?: string; created_at: string }
export const ProductModel = defineModel(ProductsTable);Field Types
| Builder | PostgreSQL | TypeScript |
|---|---|---|
| field.string() | text | string |
| field.text() | text | string |
| field.uuid() | uuid | string |
| field.integer() | integer | number |
| field.bigint() | bigint | number |
| field.boolean() | boolean | boolean |
| field.timestamp() | timestamp with time zone | string |
| field.jsonb() | jsonb | unknown |
Field Modifiers
| Modifier | SQL Effect | TypeScript Effect |
|---|---|---|
| .required() | NOT NULL | required property |
| .nullable() | allow NULL | optional property (\| undefined) |
| .primary() | PRIMARY KEY | required property |
| .primaryGenerated() | GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY | required property |
| .unique() | UNIQUE | no change |
| .default('value') | DEFAULT value | required property (always present) |
| .defaultNow() | DEFAULT now() | required property |
| .references(table, column, options?) | inline REFERENCES | no change |
Type Inference Rules
// REQUIRED in TypeScript (always present when reading)
id: field.bigint().primaryGenerated() // auto-generated
name: field.string().required() // NOT NULL
status: field.string().default(`'open'`) // has DB default
created_at: field.timestamp().defaultNow() // has DB default
// OPTIONAL in TypeScript (can be NULL)
notes: field.string() // → string | undefinedStep 2 — Run Migrations
Migrations convert your TypeScript schema to SQL and execute it against Supabase. All statements are idempotent — re-running the same migration is always safe:
CREATE TABLE IF NOT EXISTSADD COLUMN IF NOT EXISTSDROP POLICY IF EXISTSbefore everyCREATE POLICY
What the generated SQL looks like
When you call preview() or download(), the output is ready to paste into Supabase SQL Editor:
-- Setup
create or replace function exec_sql(sql_query text)
returns json language plpgsql security definer as $$
begin
execute sql_query;
return '{"success": true}'::json;
exception when others then
return json_build_object('error', sqlerrm);
end;
$$;
grant execute on function exec_sql(text) to anon, authenticated;
-- Migration: create-products-table
-- Tables
create table if not exists public.products (
id bigint generated by default as identity primary key,
name text not null,
price integer not null,
stock integer default 0,
description text,
created_at timestamp with time zone default now()
);
-- Row Level Security
alter table public.products enable row level security;
-- Policies
drop policy if exists "Anyone can read products" on public.products;
create policy "Anyone can read products"
on public.products
for SELECT
to anon, authenticated
using (true);
drop policy if exists "Anyone can insert products" on public.products;
create policy "Anyone can insert products"
on public.products
for INSERT
to anon, authenticated
with check (true);
-- Grants
grant usage on schema public to anon, authenticated;
grant SELECT, INSERT on table public.products to anon, authenticated;
grant usage, select on sequence public.products_id_seq to anon, authenticated;Copy the whole output → paste into Supabase → SQL Editor → click Run. Done.
Every policy is preceded by DROP POLICY IF EXISTS, so running this SQL again (or after running it manually) never fails with "policy already exists".
Step A — Preview and copy SQL (no keys needed)
Create an AdminMigrationComponent — it checks migration status on load and shows pending SQL automatically, no button click needed.
// admin-migration.component.ts
import { Component, OnInit, signal } from '@angular/core';
import { injectMigration, MigrationRunner, MigrationRunResult } from 'ngx-saqly-supabase';
import { productsMigration } from '../schema/product';
// Add every migration here — already-applied ones are skipped automatically
const ALL_MIGRATIONS = [productsMigration];
@Component({
selector: 'app-admin-migration',
standalone: true,
template: `
<section style="padding: 24px; max-width: 860px; margin: auto; font-family: sans-serif;">
<!-- one-time Supabase setup banner -->
@if (setupReady() === false) {
<div style="border: 1px solid #f90; border-radius: 8px; padding: 16px; margin-bottom: 24px; background: #fffbe6;">
<strong>⚠ One-time setup required</strong>
<p style="margin: 8px 0 12px;">
Run this SQL once in your
<a href="https://supabase.com/dashboard" target="_blank">Supabase SQL Editor</a>
to enable automatic migrations without a <code>managementKey</code>.
</p>
<pre style="background: #111; color: #0f0; padding: 16px; border-radius: 6px; overflow: auto; font-size: 13px;">{{ setupSql }}</pre>
<button (click)="copySetupSql()">{{ copied() ? '✓ Copied!' : 'Copy SQL' }}</button>
<button (click)="recheckSetup()" style="margin-left: 8px;">I've run it — re-check</button>
</div>
}
@if (setupReady() === true) {
<p style="color: green; margin-bottom: 16px;">✓ exec_sql is ready</p>
}
<!-- migration status — loads automatically on init -->
@if (setupReady() !== null) {
<div style="border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<span style="font-size: 12px; background: #eee; border-radius: 4px; padding: 2px 8px;">Step 1</span>
<h2 style="margin: 8px 0 4px;">Migrations</h2>
@if (loading()) {
<p style="color: #888; margin-top: 12px;">Checking migrations…</p>
}
@if (!loading() && pendingCount() === 0) {
<p style="color: green; margin-top: 12px;">✓ All migrations are up to date.</p>
}
@if (!loading() && pendingCount() > 0) {
<p style="margin: 12px 0 8px; color: #555;">
{{ pendingCount() }} pending migration(s) — copy the SQL below and run it in Supabase SQL Editor.
</p>
<pre style="background: #111; color: #0f0; padding: 16px; border-radius: 6px; overflow: auto; font-size: 13px;">{{ sql() }}</pre>
}
</div>
}
<!-- run results (shown after runMany) -->
@if (runResults().length) {
<div style="border: 1px solid #ddd; border-radius: 8px; padding: 16px;">
<h3 style="margin: 0 0 12px;">Run Results</h3>
@for (r of runResults(); track r.name) {
<div style="margin-bottom: 8px;">
@if (r.result.success) {
<span style="color: green;">✓ {{ r.name }}</span>
} @else {
<span style="color: red;">✗ {{ r.name }} — {{ r.result.error }}</span>
}
</div>
}
</div>
}
</section>
`,
})
export class AdminMigrationComponent implements OnInit {
private readonly migration = injectMigration();
readonly setupSql = MigrationRunner.setupSql();
setupReady = signal<boolean | null>(null);
copied = signal(false);
loading = signal(true);
sql = signal('');
pendingCount = signal(0);
runResults = signal<MigrationRunResult[]>([]);
async ngOnInit(): Promise<void> {
this.setupReady.set(await this.migration.checkSetup());
await this.loadPending();
}
async recheckSetup(): Promise<void> {
this.setupReady.set(null);
this.setupReady.set(await this.migration.checkSetup());
await this.loadPending();
}
copySetupSql(): void {
navigator.clipboard.writeText(this.setupSql).then(() => {
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
});
}
private async loadPending(): Promise<void> {
this.loading.set(true);
const pending = await this.migration.getPending(ALL_MIGRATIONS);
this.pendingCount.set(pending.length);
this.sql.set(await this.migration.previewMany(ALL_MIGRATIONS));
this.loading.set(false);
}
}To add more migrations later, just append them to ALL_MIGRATIONS:
const ALL_MIGRATIONS = [
productsMigration, // ← already applied, skipped
addStockToProductsMigration, // ← new, shown in pending SQL
];Step B — Run automatically on app startup
Pass your migrations to provideSaqlySupabase — they run once on startup and are tracked automatically.
Only new (not-yet-applied) migrations run; already-applied ones are silently skipped.
// app.config.ts
provideSaqlySupabase({
url: environment.supabaseUrl,
key: environment.supabaseAnonKey,
migrations: [
productsMigration, // ← skipped if already applied
addStockToProductsMigration, // ← runs only once, then tracked
],
})Console output on startup:
[ngx-supabase] ✓ Migration "create-products-table" applied
[ngx-supabase] ✓ Migration "add-stock-to-products" appliedUse Step A or Step B — or both together:
| Scenario | Recommendation | |---|---| | Dev / staging — you want to see the SQL before it runs | Step A | | Production — run migrations silently on deploy | Step B | | Both — see status in UI + auto-run on startup | Step A + Step B together |
Execution modes — pick one:
| What you provide | How migrations run |
|---|---|
| managementKey | Supabase Management API — no setup required |
| serviceRoleKey only | exec_sql RPC with service-role client |
| Neither | exec_sql RPC with anon client — run MigrationRunner.setupSql() once first |
Grants — automatically included
The generated SQL automatically includes GRANT statements derived from your policies.
No manual SQL needed after enabling RLS.
| Policy operation | SQL privileges granted |
|---|---|
| select | SELECT |
| insert | INSERT, SELECT |
| update | UPDATE, SELECT |
| delete | DELETE, SELECT |
| all | SELECT, INSERT, UPDATE, DELETE |
MigrationDefinition
import { MigrationDefinition } from 'ngx-saqly-supabase';
export const productsMigration: MigrationDefinition = {
name: 'create-products-table', // unique name — used for tracking
tables: [ProductsTable], // CREATE TABLE IF NOT EXISTS
alterations: [], // ALTER TABLE statements (see below)
relations: [], // foreign key constraints
rls: [ProductsTable], // enable RLS
policies: [ReadPolicy, InsertPolicy, UpdatePolicy, DeletePolicy],
};Schema Alterations — adding or removing columns
When a table already exists and you need to change its structure, create a new MigrationDefinition using alter().
No SQL knowledge required — the library generates ALTER TABLE statements automatically.
import { alter, field, MigrationDefinition } from 'ngx-saqly-supabase';
import { ProductsTable } from './product';
// Each schema change = one new migration with a unique name
export const addStockToProductsMigration: MigrationDefinition = {
name: 'add-stock-to-products',
alterations: [
alter(ProductsTable).addColumn('stock', field.integer().default('0')),
],
};
export const addDescriptionMigration: MigrationDefinition = {
name: 'add-description-to-products',
alterations: [
alter(ProductsTable).addColumn('description', field.string()),
alter(ProductsTable).dropColumn('legacy_notes'),
alter(ProductsTable).renameColumn('qty', 'stock'),
],
};Generated SQL (produced automatically, no manual writing):
alter table public.products add column if not exists stock integer default 0;
alter table public.products drop column if exists legacy_notes;
alter table public.products rename column qty to stock;alter(table) operations:
| Method | SQL |
|---|---|
| .addColumn(name, field) | ADD COLUMN IF NOT EXISTS … |
| .dropColumn(name) | DROP COLUMN IF EXISTS … |
| .renameColumn(old, new) | RENAME COLUMN … TO … |
Add all your migrations (old and new) to the same array — already-applied ones are skipped automatically:
const ALL_MIGRATIONS = [
productsMigration, // ← already applied, skipped
addStockToProductsMigration, // ← new, will run
];Step 3 — CRUD Operations
Inject the repository
import { injectModel } from 'ngx-saqly-supabase';
import { ProductModel } from './schema/product';
const repo = injectModel(ProductModel);Read
// All rows
const all = await repo.findAll();
// With filters, ordering, pagination
const latest = await repo.findMany({
where: { stock: 0 },
orderBy: 'created_at',
ascending: false,
limit: 20,
offset: 0,
});
// Select specific columns
const names = await repo.findMany({ select: 'id, name, price' });
// First match
const one = await repo.findFirst({ where: { name: 'Widget' } });
// By primary key
const byId = await repo.findById(42);Write
All write methods accept WritePayload<T> — a flat optional version of your model type.
Every field is optional and accepts null, so you only pass what you need.
// Insert — returns the created record
const created = await repo.insert({ name: 'Widget', price: 999, stock: 50 });
// Insert many
const many = await repo.insertMany([
{ name: 'A', price: 10 },
{ name: 'B', price: 20 },
]);
// Upsert (insert or update by PK)
const upserted = await repo.upsert({ id: 1, name: 'Widget', price: 899 });
// Update by PK — pass only the fields you want to change
const updated = await repo.updateById(1, { price: 799 });
// Update many rows matching a filter
await repo.updateMany({ stock: 0 }, { stock: 100 });You can import WritePayload<T> if you want to type a helper variable explicitly:
import { WritePayload } from 'ngx-saqly-supabase';
import { Product } from './schema/product';
const payload: WritePayload<Product> = {
name: 'Widget',
price: 999,
};
await repo.insert(payload);Delete
await repo.deleteById(1);
await repo.deleteMany({ stock: 0 });Aggregate
const total = await repo.count();
const lowStock = await repo.count({ where: { stock: 0 } });
const exists = await repo.exists({ where: { name: 'Widget' } });Step 4 — Querying Across Tables (Joins)
The select option passes directly to Supabase's PostgREST selector, which supports nested joins in a single request.
Syntax
column, foreignTable(col1, col2) -- left join (null if no match)
column, foreignTable!inner(col1, col2) -- inner join (excludes non-matches)
alias:foreignTable(col1, col2) -- with aliasProduct with category
const products = await repo.findMany({
select: 'id, name, price, category:categories(id, name)',
});
// [{ id: 1, name: 'Widget', category: { id: 3, name: 'Electronics' } }]Product with category and owner
const products = await repo.findMany({
select: 'id, name, price, category:categories(id, name), owner:users(id, full_name)',
});Nested (order → items → product)
const orders = await orderRepo.findMany({
select: 'id, status, items:order_items(quantity, product:products(id, name, price))',
});Relations
Relations add foreign key constraints to the database via migrations.
One-to-One
import { relation } from 'ngx-saqly-supabase';
const UserProfileRelation = relation.oneToOne(ProfilesTable, UsersTable, {
localKey: 'user_id',
foreignKey: 'id',
onDelete: 'cascade',
});One-to-Many
const CategoryProductsRelation = relation.oneToMany(CategoriesTable, ProductsTable, {
localKey: 'id',
foreignKey: 'category_id',
});Many-to-One
const ProductCategoryRelation = relation.manyToOne(ProductsTable, CategoriesTable, {
localKey: 'category_id',
foreignKey: 'id',
onDelete: 'set null',
});Many-to-Many (via junction table)
export const ProductTagsTable = defineSchema('product_tags', {
product_id: field.bigint().required(),
tag_id: field.bigint().required(),
});
const ToProduct = relation.manyToOne(ProductTagsTable, ProductsTable, {
localKey: 'product_id', foreignKey: 'id', onDelete: 'cascade',
});
const ToTag = relation.manyToOne(ProductTagsTable, TagsTable, {
localKey: 'tag_id', foreignKey: 'id', onDelete: 'cascade',
});Query:
const products = await repo.findMany({
select: 'id, name, tags:product_tags(tag:tags(id, name))',
});onDelete / onUpdate
| Value | Effect |
|---|---|
| 'cascade' | Delete/update child rows automatically |
| 'restrict' | Prevent delete/update if children exist |
| 'set null' | Set FK to NULL on parent delete |
| 'no action' | Default — error if constraint violated |
Row-Level Security & Policies
import { policy } from 'ngx-saqly-supabase';
const ReadOwnPolicy = policy
.select('Users read own tasks')
.on(TasksTable)
.to('authenticated')
.using('auth.uid() = user_id')
.build();
const InsertOwnPolicy = policy
.insert('Users insert own tasks')
.on(TasksTable)
.to('authenticated')
.withCheck('auth.uid() = user_id')
.build();Operations: select · insert · update · delete · all
Authentication
import { injectAuth } from 'ngx-saqly-supabase';
export class AppComponent {
readonly auth = injectAuth();
// auth.user() → Signal<User | null>
// auth.loading() → Signal<boolean>
}const result = await this.auth.signUp('[email protected]', 'password');
if (result.confirmationRequired) { /* email sent */ }
const result = await this.auth.signIn('[email protected]', 'password');
if (result.error) console.error(result.error);
await this.auth.signOut();@if (auth.loading()) { <p>Loading...</p> }
@if (auth.user()) {
<p>Welcome, {{ auth.user()?.email }}</p>
<button (click)="auth.signOut()">Sign out</button>
}
@if (!auth.loading() && !auth.user()) {
<!-- show login form -->
}Complete Example — Products
A full working app from schema to component — a single products table, step by step.
File structure
src/
├── environments/
│ └── environment.ts
├── app.config.ts
├── app.component.ts
├── schema/
│ └── product.ts
└── products/
├── products.component.ts
└── admin-migration.component.ts1. schema/product.ts
Define the table, the model, the policies, and the migration — all in one file.
import {
defineSchema,
defineModel,
field,
policy,
InferModel,
MigrationDefinition,
} from 'ngx-saqly-supabase';
// ── Table ──────────────────────────────────────────────────────────────────
export const ProductsTable = defineSchema('products', {
id: field.bigint().primaryGenerated(),
name: field.string().required(),
price: field.integer().required(),
stock: field.integer().default('0'),
description: field.string(),
created_at: field.timestamp().defaultNow(),
});
export type Product = InferModel<typeof ProductsTable>;
// → { id: number; name: string; price: number; stock: number; description?: string; created_at: string }
export const ProductModel = defineModel(ProductsTable);
// ── Policies ───────────────────────────────────────────────────────────────
const PublicReadPolicy = policy
.select('Anyone can read products')
.on(ProductsTable)
.to('anon', 'authenticated')
.using('true')
.build();
const PublicInsertPolicy = policy
.insert('Anyone can insert products')
.on(ProductsTable)
.to('anon', 'authenticated')
.withCheck('true')
.build();
const PublicUpdatePolicy = policy
.update('Anyone can update products')
.on(ProductsTable)
.to('anon', 'authenticated')
.using('true')
.withCheck('true')
.build();
const PublicDeletePolicy = policy
.delete('Anyone can delete products')
.on(ProductsTable)
.to('anon', 'authenticated')
.using('true')
.build();
// ── Migration ──────────────────────────────────────────────────────────────
export const productsMigration: MigrationDefinition = {
name: 'create-products-table',
tables: [ProductsTable],
rls: [ProductsTable],
policies: [PublicReadPolicy, PublicInsertPolicy, PublicUpdatePolicy, PublicDeletePolicy],
};2. app.config.ts
Register Supabase and pass the migration. It runs once automatically — already-applied migrations are skipped on every subsequent start.
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideSaqlySupabase } from 'ngx-saqly-supabase';
import { environment } from '../environments/environment';
import { productsMigration } from './schema/product';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideSaqlySupabase({
url: environment.supabaseUrl,
key: environment.supabaseAnonKey,
migrations: [productsMigration],
}),
],
};3. products/admin-migration.component.ts
Shows migration status on load. If exec_sql is not set up yet, it shows the one-time SQL to copy and run in Supabase SQL Editor.
import { Component, OnInit, signal } from '@angular/core';
import { injectMigration, MigrationRunner, MigrationRunResult } from 'ngx-saqly-supabase';
import { productsMigration } from '../schema/product';
const ALL_MIGRATIONS = [productsMigration];
@Component({
selector: 'app-admin-migration',
standalone: true,
template: `
<section style="padding: 24px; max-width: 860px; margin: auto; font-family: sans-serif;">
@if (setupReady() === false) {
<div style="border: 1px solid #f90; border-radius: 8px; padding: 16px; margin-bottom: 24px; background: #fffbe6;">
<strong>⚠ One-time setup required</strong>
<p style="margin: 8px 0 12px;">
Run this SQL once in your
<a href="https://supabase.com/dashboard" target="_blank">Supabase SQL Editor</a>.
</p>
<pre style="background: #111; color: #0f0; padding: 16px; border-radius: 6px; overflow: auto; font-size: 13px;">{{ setupSql }}</pre>
<button (click)="copySetupSql()">{{ copied() ? '✓ Copied!' : 'Copy SQL' }}</button>
<button (click)="recheckSetup()" style="margin-left: 8px;">I've run it — re-check</button>
</div>
}
@if (setupReady() === true) {
<p style="color: green; margin-bottom: 16px;">✓ exec_sql is ready</p>
}
@if (setupReady() !== null) {
<div style="border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<span style="font-size: 12px; background: #eee; border-radius: 4px; padding: 2px 8px;">Migrations</span>
<h2 style="margin: 8px 0 4px;">Status</h2>
@if (loading()) {
<p style="color: #888; margin-top: 12px;">Checking migrations…</p>
}
@if (!loading() && pendingCount() === 0) {
<p style="color: green; margin-top: 12px;">✓ All migrations are up to date.</p>
}
@if (!loading() && pendingCount() > 0) {
<p style="margin: 12px 0 8px; color: #555;">
{{ pendingCount() }} pending migration(s) — copy the SQL below and run it in Supabase SQL Editor.
</p>
<pre style="background: #111; color: #0f0; padding: 16px; border-radius: 6px; overflow: auto; font-size: 13px;">{{ sql() }}</pre>
}
</div>
}
@if (runResults().length) {
<div style="border: 1px solid #ddd; border-radius: 8px; padding: 16px;">
<h3 style="margin: 0 0 12px;">Run Results</h3>
@for (r of runResults(); track r.name) {
<div style="margin-bottom: 8px;">
@if (r.result.success) {
<span style="color: green;">✓ {{ r.name }}</span>
} @else {
<span style="color: red;">✗ {{ r.name }} — {{ r.result.error }}</span>
}
</div>
}
</div>
}
</section>
`,
})
export class AdminMigrationComponent implements OnInit {
private readonly migration = injectMigration();
readonly setupSql = MigrationRunner.setupSql();
setupReady = signal<boolean | null>(null);
copied = signal(false);
loading = signal(true);
sql = signal('');
pendingCount = signal(0);
runResults = signal<MigrationRunResult[]>([]);
async ngOnInit(): Promise<void> {
this.setupReady.set(await this.migration.checkSetup());
await this.loadPending();
}
async recheckSetup(): Promise<void> {
this.setupReady.set(null);
this.setupReady.set(await this.migration.checkSetup());
await this.loadPending();
}
copySetupSql(): void {
navigator.clipboard.writeText(this.setupSql).then(() => {
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
});
}
private async loadPending(): Promise<void> {
this.loading.set(true);
const pending = await this.migration.getPending(ALL_MIGRATIONS);
this.pendingCount.set(pending.length);
this.sql.set(await this.migration.previewMany(ALL_MIGRATIONS));
this.loading.set(false);
}
}4. products/products.component.ts
Full CRUD component — reads, inserts, updates, and deletes products.
import { Component, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { injectModel } from 'ngx-saqly-supabase';
import { Product, ProductModel } from '../schema/product';
@Component({
selector: 'app-products',
standalone: true,
imports: [FormsModule],
template: `
<section style="padding: 24px; max-width: 800px; margin: auto;">
<h1>Products</h1>
@if (message()) {
<p>{{ message() }}</p>
}
<div style="display: grid; gap: 12px; margin-bottom: 24px;">
<input type="text" placeholder="Product name" [(ngModel)]="name" />
<input type="number" placeholder="Price" [(ngModel)]="price" />
<input type="number" placeholder="Stock" [(ngModel)]="stock" />
<textarea placeholder="Description" [(ngModel)]="description"></textarea>
<button (click)="addProduct()">Add Product</button>
<button (click)="loadProducts()">Reload</button>
<button (click)="countProducts()">Count</button>
</div>
<h3>Total Products: {{ total() }}</h3>
@for (product of products(); track product.id) {
<div style="border: 1px solid #ddd; padding: 16px; margin-bottom: 12px; border-radius: 8px;">
<h3>{{ product.name }}</h3>
<p>Price: {{ product.price }}</p>
<p>Stock: {{ product.stock }}</p>
<p>Description: {{ product.description || 'No description' }}</p>
<button (click)="updateProduct(product)">+10 Price</button>
<button (click)="deleteProduct(product.id)">Delete</button>
</div>
}
@if (products().length === 0) {
<p>No products found.</p>
}
</section>
`,
})
export class ProductsComponent implements OnInit {
private readonly repo = injectModel(ProductModel);
products = signal<Product[]>([]);
total = signal(0);
message = signal('');
name = '';
price = 0;
stock = 0;
description = '';
async ngOnInit(): Promise<void> {
await this.loadProducts();
await this.countProducts();
}
async loadProducts(): Promise<void> {
try {
this.products.set(await this.repo.findMany({
orderBy: 'created_at',
ascending: false,
}));
this.message.set('Products loaded');
} catch {
this.message.set('Error loading products');
}
}
async addProduct(): Promise<void> {
if (!this.name.trim()) { this.message.set('Name is required'); return; }
if (Number(this.price) <= 0) { this.message.set('Price must be > 0'); return; }
try {
await this.repo.insert({
name: this.name.trim(),
price: Number(this.price),
stock: Number(this.stock),
description: this.description.trim() || undefined,
});
this.name = ''; this.price = 0; this.stock = 0; this.description = '';
await this.loadProducts();
await this.countProducts();
this.message.set('Product added');
} catch {
this.message.set('Error adding product');
}
}
async updateProduct(product: Product): Promise<void> {
try {
await this.repo.updateById(product.id, { price: product.price + 10 });
await this.loadProducts();
this.message.set('Product updated');
} catch {
this.message.set('Error updating product');
}
}
async deleteProduct(id: number): Promise<void> {
try {
await this.repo.deleteById(id);
await this.loadProducts();
await this.countProducts();
this.message.set('Product deleted');
} catch {
this.message.set('Error deleting product');
}
}
async countProducts(): Promise<void> {
try {
this.total.set(await this.repo.count());
} catch {
this.message.set('Error counting products');
}
}
}5. app.component.ts
Wire the two components into the root component.
import { Component } from '@angular/core';
import { ProductsComponent } from './products/products.component';
import { AdminMigrationComponent } from './products/admin-migration.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [ProductsComponent, AdminMigrationComponent],
template: `
<app-admin-migration />
<app-products />
`,
})
export class App {}Real Example — Users, Products & Categories
A full multi-table schema split across separate files — the recommended structure for real projects.
File structure
src/
├── environments/
│ └── environment.ts
├── app.config.ts
├── app.component.ts
├── schema/
│ ├── user.ts
│ ├── category.ts
│ └── product.ts ← imports from user.ts and category.ts
└── products/
├── products.component.ts
└── admin-migration.component.ts1. schema/user.ts
import { defineSchema, defineModel, field, policy, InferModel } from 'ngx-saqly-supabase';
// ── Table ──────────────────────────────────────────────────────────────────
export const UsersTable = defineSchema('users', {
id: field.uuid().primary(),
full_name: field.string().required(),
email: field.string().required().unique(),
created_at: field.timestamp().defaultNow(),
});
export type User = InferModel<typeof UsersTable>;
export const UserModel = defineModel(UsersTable);
// ── Policies ───────────────────────────────────────────────────────────────
export const ReadUsersPolicy = policy
.select('read users')
.on(UsersTable)
.to('anon', 'authenticated')
.using('true')
.build();
export const InsertUsersPolicy = policy
.insert('insert users')
.on(UsersTable)
.to('anon', 'authenticated')
.withCheck('true')
.build();2. schema/category.ts
import { defineSchema, defineModel, field, policy, InferModel } from 'ngx-saqly-supabase';
// ── Table ──────────────────────────────────────────────────────────────────
export const CategoriesTable = defineSchema('categories', {
id: field.bigint().primaryGenerated(),
name: field.string().required().unique(),
});
export type Category = InferModel<typeof CategoriesTable>;
export const CategoryModel = defineModel(CategoriesTable);
// ── Policies ───────────────────────────────────────────────────────────────
export const ReadCategoriesPolicy = policy
.select('read categories')
.on(CategoriesTable)
.to('anon', 'authenticated')
.using('true')
.build();
export const InsertCategoriesPolicy = policy
.insert('insert categories')
.on(CategoriesTable)
.to('anon', 'authenticated')
.withCheck('true')
.build();3. schema/product.ts
Imports tables and policies from user.ts and category.ts, adds relations and its own policies, then declares both migrations.
import {
defineSchema, defineModel, field,
relation, policy, alter,
InferModel, MigrationDefinition,
} from 'ngx-saqly-supabase';
import { UsersTable, ReadUsersPolicy, InsertUsersPolicy } from './user';
import { CategoriesTable, ReadCategoriesPolicy, InsertCategoriesPolicy } from './category';
// ── Table ──────────────────────────────────────────────────────────────────
export const ProductsTable = defineSchema('products', {
id: field.bigint().primaryGenerated(),
name: field.string().required(),
price: field.integer().required(),
stock: field.integer().default('0'),
user_id: field.uuid().required(),
category_id: field.bigint().required(),
created_at: field.timestamp().defaultNow(),
});
export type Product = InferModel<typeof ProductsTable>;
export const ProductModel = defineModel(ProductsTable);
// ── Relations ──────────────────────────────────────────────────────────────
export const ProductUserRelation = relation.manyToOne(ProductsTable, UsersTable, {
localKey: 'user_id', foreignKey: 'id', onDelete: 'cascade', onUpdate: 'cascade',
});
export const ProductCategoryRelation = relation.manyToOne(ProductsTable, CategoriesTable, {
localKey: 'category_id', foreignKey: 'id', onDelete: 'cascade', onUpdate: 'cascade',
});
// ── Policies ───────────────────────────────────────────────────────────────
const ReadProductsPolicy = policy
.select('read products')
.on(ProductsTable).to('anon', 'authenticated').using('true').build();
const InsertProductsPolicy = policy
.insert('insert products')
.on(ProductsTable).to('anon', 'authenticated').withCheck('true').build();
const UpdateProductsPolicy = policy
.update('update products')
.on(ProductsTable).to('anon', 'authenticated').using('true').withCheck('true').build();
const DeleteProductsPolicy = policy
.delete('delete products')
.on(ProductsTable).to('anon', 'authenticated').using('true').build();
// ── Migration 1: initial schema ────────────────────────────────────────────
export const productsMigration: MigrationDefinition = {
name: 'users-products-categories-schema',
tables: [UsersTable, CategoriesTable, ProductsTable],
relations: [ProductUserRelation, ProductCategoryRelation],
rls: [UsersTable, CategoriesTable, ProductsTable],
policies: [
ReadUsersPolicy, InsertUsersPolicy,
ReadCategoriesPolicy, InsertCategoriesPolicy,
ReadProductsPolicy, InsertProductsPolicy,
UpdateProductsPolicy, DeleteProductsPolicy,
],
};
// ── Migration 2: add a column (no SQL needed) ──────────────────────────────
export const addDescriptionMigration: MigrationDefinition = {
name: 'add-description-to-products',
alterations: [
alter(ProductsTable).addColumn('description', field.string()),
],
};4. app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideSaqlySupabase } from 'ngx-saqly-supabase';
import { environment } from '../environments/environment';
import { productsMigration, addDescriptionMigration } from './schema/product';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideSaqlySupabase({
url: environment.supabaseUrl,
key: environment.supabaseAnonKey,
migrations: [productsMigration, addDescriptionMigration],
}),
],
};5. products/admin-migration.component.ts
Same component as the products example — just update ALL_MIGRATIONS to include all your migrations. Keep this list in sync with app.config.ts.
import { Component, OnInit, signal } from '@angular/core';
import { injectMigration, MigrationRunner, MigrationRunResult } from 'ngx-saqly-supabase';
import { productsMigration, addDescriptionMigration } from '../schema/product';
// Keep in sync with app.config.ts migrations array
const ALL_MIGRATIONS = [productsMigration, addDescriptionMigration];
@Component({
selector: 'app-admin-migration',
standalone: true,
template: `
<section style="padding: 24px; max-width: 860px; margin: auto; font-family: sans-serif;">
@if (setupReady() === false) {
<div style="border: 1px solid #f90; border-radius: 8px; padding: 16px; margin-bottom: 24px; background: #fffbe6;">
<strong>⚠ One-time setup required</strong>
<p style="margin: 8px 0 12px;">
Run this SQL once in your
<a href="https://supabase.com/dashboard" target="_blank">Supabase SQL Editor</a>.
</p>
<pre style="background: #111; color: #0f0; padding: 16px; border-radius: 6px; overflow: auto; font-size: 13px;">{{ setupSql }}</pre>
<button (click)="copySetupSql()">{{ copied() ? '✓ Copied!' : 'Copy SQL' }}</button>
<button (click)="recheckSetup()" style="margin-left: 8px;">I've run it — re-check</button>
</div>
}
@if (setupReady() === true) {
<p style="color: green; margin-bottom: 16px;">✓ exec_sql is ready</p>
}
@if (setupReady() !== null) {
<div style="border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<span style="font-size: 12px; background: #eee; border-radius: 4px; padding: 2px 8px;">Migrations</span>
<h2 style="margin: 8px 0 4px;">Status</h2>
@if (loading()) {
<p style="color: #888; margin-top: 12px;">Checking migrations…</p>
}
@if (!loading() && pendingCount() === 0) {
<p style="color: green; margin-top: 12px;">✓ All migrations are up to date.</p>
}
@if (!loading() && pendingCount() > 0) {
<p style="margin: 12px 0 8px; color: #555;">
{{ pendingCount() }} pending migration(s) — copy the SQL below and run it in Supabase SQL Editor.
</p>
<pre style="background: #111; color: #0f0; padding: 16px; border-radius: 6px; overflow: auto; font-size: 13px;">{{ sql() }}</pre>
}
</div>
}
@if (runResults().length) {
<div style="border: 1px solid #ddd; border-radius: 8px; padding: 16px;">
<h3 style="margin: 0 0 12px;">Run Results</h3>
@for (r of runResults(); track r.name) {
<div style="margin-bottom: 8px;">
@if (r.result.success) {
<span style="color: green;">✓ {{ r.name }}</span>
} @else {
<span style="color: red;">✗ {{ r.name }} — {{ r.result.error }}</span>
}
</div>
}
</div>
}
</section>
`,
})
export class AdminMigrationComponent implements OnInit {
private readonly migration = injectMigration();
readonly setupSql = MigrationRunner.setupSql();
setupReady = signal<boolean | null>(null);
copied = signal(false);
loading = signal(true);
sql = signal('');
pendingCount = signal(0);
runResults = signal<MigrationRunResult[]>([]);
async ngOnInit(): Promise<void> {
this.setupReady.set(await this.migration.checkSetup());
await this.loadPending();
}
async recheckSetup(): Promise<void> {
this.setupReady.set(null);
this.setupReady.set(await this.migration.checkSetup());
await this.loadPending();
}
copySetupSql(): void {
navigator.clipboard.writeText(this.setupSql).then(() => {
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
});
}
private async loadPending(): Promise<void> {
this.loading.set(true);
const pending = await this.migration.getPending(ALL_MIGRATIONS);
this.pendingCount.set(pending.length);
this.sql.set(await this.migration.previewMany(ALL_MIGRATIONS));
this.loading.set(false);
}
}6. products/products.component.ts
Fetches products joined with their category and owner in a single query.
import { Component, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { injectModel } from 'ngx-saqly-supabase';
import { Product, ProductModel } from '../schema/product';
interface ProductWithRelations {
id: number;
name: string;
price: number;
stock: number;
description: string | undefined;
created_at: string;
category: { id: number; name: string } | null;
owner: { id: string; full_name: string } | null;
}
@Component({
selector: 'app-products',
standalone: true,
imports: [FormsModule],
template: `
<section style="padding: 24px; max-width: 860px; margin: auto; font-family: sans-serif;">
<h1>Products</h1>
@if (message()) { <p>{{ message() }}</p> }
<div style="display: grid; gap: 12px; margin-bottom: 24px;">
<input type="text" placeholder="Name" [(ngModel)]="name" />
<input type="number" placeholder="Price" [(ngModel)]="price" />
<input type="number" placeholder="Stock" [(ngModel)]="stock" />
<input type="text" placeholder="User ID" [(ngModel)]="userId" />
<input type="number" placeholder="Category ID" [(ngModel)]="categoryId" />
<textarea placeholder="Description" [(ngModel)]="description"></textarea>
<button (click)="addProduct()">Add Product</button>
<button (click)="loadProducts()">Reload</button>
</div>
<h3>Total: {{ total() }}</h3>
@for (p of products(); track p.id) {
<div style="border: 1px solid #ddd; padding: 16px; margin-bottom: 12px; border-radius: 8px;">
<strong>{{ p.name }}</strong>
<p>Price: {{ p.price }} · Stock: {{ p.stock }}</p>
<p>Category: {{ p.category?.name ?? '—' }}</p>
<p>Owner: {{ p.owner?.full_name ?? '—' }}</p>
<p>Description: {{ p.description ?? 'No description' }}</p>
<button (click)="updateProduct(p)">+10 Price</button>
<button (click)="deleteProduct(p.id)" style="margin-left: 8px;">Delete</button>
</div>
}
@if (products().length === 0) { <p>No products found.</p> }
</section>
`,
})
export class ProductsComponent implements OnInit {
private readonly repo = injectModel(ProductModel);
products = signal<ProductWithRelations[]>([]);
total = signal(0);
message = signal('');
name = '';
price = 0;
stock = 0;
userId = '';
categoryId = 0;
description = '';
async ngOnInit(): Promise<void> {
await this.loadProducts();
this.total.set(await this.repo.count());
}
async loadProducts(): Promise<void> {
try {
const data = await this.repo.findMany({
select: 'id, name, price, stock, description, created_at, category:categories(id, name), owner:users(id, full_name)',
orderBy: 'created_at',
ascending: false,
});
this.products.set(data as ProductWithRelations[]);
} catch {
this.message.set('Error loading products');
}
}
async addProduct(): Promise<void> {
if (!this.name.trim()) { this.message.set('Name is required'); return; }
if (this.price <= 0) { this.message.set('Price must be > 0'); return; }
if (!this.userId.trim()) { this.message.set('User ID is required'); return; }
if (this.categoryId <= 0) { this.message.set('Category ID is required'); return; }
try {
await this.repo.insert({
name: this.name.trim(),
price: this.price,
stock: this.stock,
user_id: this.userId.trim(),
category_id: this.categoryId,
description: this.description.trim() || undefined,
});
this.name = ''; this.price = 0; this.stock = 0;
this.userId = ''; this.categoryId = 0; this.description = '';
await this.loadProducts();
this.total.set(await this.repo.count());
this.message.set('Product added');
} catch {
this.message.set('Error adding product');
}
}
async updateProduct(product: Product): Promise<void> {
try {
await this.repo.updateById(product.id, { price: product.price + 10 });
await this.loadProducts();
this.message.set('Product updated');
} catch {
this.message.set('Error updating product');
}
}
async deleteProduct(id: number): Promise<void> {
try {
await this.repo.deleteById(id);
await this.loadProducts();
this.total.set(await this.repo.count());
this.message.set('Product deleted');
} catch {
this.message.set('Error deleting product');
}
}
}7. app.component.ts
import { Component } from '@angular/core';
import { AdminMigrationComponent } from './products/admin-migration.component';
import { ProductsComponent } from './products/products.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [AdminMigrationComponent, ProductsComponent],
template: `
<app-admin-migration />
<app-products />
`,
})
export class App {}Query — filter by category
const data = await this.repo.findMany({
where: { category_id: 3 },
select: 'id, name, price, category:categories(id, name)',
});API Reference
Providers & Tokens
| Export | Description |
|---|---|
| provideSaqlySupabase(config) | Configure the library — call once in app.config.ts |
| SAQLY_SUPABASE_CLIENT | Injection token for the regular Supabase client |
| SAQLY_SUPABASE_ADMIN_CLIENT | Injection token for the admin client (serviceRoleKey) |
| SAQLY_SUPABASE_CONFIG | Injection token for the resolved config |
SaqlySupabaseConfig
| Property | Type | Required | Description |
|---|---|---|---|
| url | string | ✓ | Supabase project URL |
| key | string | ✓ | Public anon key |
| serviceRoleKey | string | — | Service role key — bypasses RLS |
| managementKey | string | — | Personal access token — Supabase Management API |
| migrations | MigrationDefinition[] | — | Auto-run on app startup |
Schema
| Export | Description |
|---|---|
| defineSchema(table, fields) | Define a table and return a typed schema |
| field | Field factory: field.string(), field.uuid(), … |
| InferModel<TSchema> | Derive TypeScript model type from a schema |
| alter(schema) | Returns an AlterTableBuilder for generating ALTER TABLE statements |
| AlterationDefinition | Type produced by alter(table).addColumn / dropColumn / renameColumn |
Models & CRUD
| Export | Description |
|---|---|
| defineModel(schema) | Create a model with inferred type |
| defineModel<T>(table, options?) | Create a model with explicit type |
| injectModel(model) | Inject a ModelRepository<T> in a component |
| ModelRepository<T> | findAll · findMany · findFirst · findById · insert · insertMany · upsert · updateById · updateMany · deleteById · deleteMany · count · exists |
| WritePayload<T> | { [K in keyof T]?: T[K] \| null } — flat optional type accepted by all write methods |
Relations
| Export | Description |
|---|---|
| relation.oneToOne(source, target, options) | 1:1 FK constraint |
| relation.oneToMany(source, target, options) | 1:M definition |
| relation.manyToOne(source, target, options) | M:1 FK constraint — use both sides for M:M |
Migrations
| Export | Description |
|---|---|
| injectMigration() | Inject a MigrationRunner in a component |
| `Migra
