All blocks
Work
Tags
Tenant tags with a kind, and a link from a tag to a subject. Adopt mode can use one link table per subject.
npx better-supabase sql add tagsimport { defineConfig } from "better-supabase/config";
export default defineConfig({
sql: {
modules: {
// Required by tags; sql add installs them too.
"updated-at": {},
tenant: {},
access: {},
// Modes: "managed", "adopt", "custom". The default is "managed".
tags: { mode: "managed" },
},
},
});npx better-supabase sql add tags writes these files into your declarative schema with the default layout. See SQL modules for the migration step and the layout options.
supabase/schemas/900_better_supabase_01_updated_at.sqlschemaupdated-at100 linessupabase/better-supabase-data/900_better_supabase_01_updated_at.sqldataupdated-at12 linessupabase/schemas/900_better_supabase_04_tenant.sqlschematenant156 linessupabase/better-supabase-data/900_better_supabase_04_tenant.sqldatatenant12 linessupabase/schemas/900_better_supabase_19_access.sqlschemaaccess375 linessupabase/better-supabase-data/900_better_supabase_19_access.sqldataaccess12 linessupabase/schemas/900_better_supabase_59_tags.sqlschema282 linessupabase/better-supabase-data/900_better_supabase_59_tags.sqldata12 lines
supabase/schemas/900_better_supabase_59_tags.sql
-- better-supabase module: tags
-- @bs-module tags@2 managed
-- Tenant tags with a kind, and a link from a tag to a subject. Adopt mode can use one link table per subject.
-- Managed by `better-supabase sql add`; re-running it overwrites this file.
-- Change it through `sql.modules` in better-supabase.config.ts and the module's SQL hooks.
create or replace function better_supabase.actor_may_write()
returns boolean
language sql
stable
security invoker
set search_path = ''
as $$
select case
when auth.jwt() is null then true
when coalesce(auth.jwt() -> 'act' ->> 'read_only', '') = 'false' then true
when auth.jwt() -> 'act' ->> 'kind' = 'agent'
then coalesce(auth.jwt() -> 'act' ->> 'read_only', '') <> 'true'
when auth.jwt() -> 'act' ->> 'kind' in ('support', 'impersonation') then false
when auth.jwt() -> 'act' ->> 'session_id' is not null
and auth.jwt() -> 'act' ->> 'kind' is null then false
else true
end
$$;
revoke execute on function better_supabase.actor_may_write() from public;
grant execute on function better_supabase.actor_may_write() to anon, authenticated, service_role;
create or replace function better_supabase.raise_actor_readonly()
returns void
language plpgsql
volatile
set search_path = ''
as $$
begin
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end;
$$;
revoke execute on function better_supabase.raise_actor_readonly() from public;
grant execute on function better_supabase.raise_actor_readonly() to anon, authenticated, service_role;
create schema if not exists better_supabase;
grant usage on schema better_supabase to anon, authenticated, service_role;
create table if not exists "better_supabase"."tags" (
"id" uuid primary key default gen_random_uuid(),
"tenant_id" uuid not null,
"kind" text not null,
"name" text not null check (length("name") between 1 and 80),
"slug" text not null,
"color" text,
"description" text,
"rank" text collate "C" not null default 'V',
"created_at" timestamptz not null default now(),
"updated_at" timestamptz not null default now(),
unique ("tenant_id", "kind", "slug")
);
alter table "better_supabase"."tags" enable row level security;
revoke all on "better_supabase"."tags" from anon, authenticated;
grant select on "better_supabase"."tags" to authenticated;
grant all on "better_supabase"."tags" to service_role;
create table if not exists "better_supabase"."taggings" (
"tenant_id" uuid not null,
"tag_id" uuid not null references "better_supabase"."tags" ("id") on delete cascade,
"subject_type" text not null,
"subject_id" text not null,
"created_at" timestamptz not null default now(),
primary key ("tag_id", "subject_type", "subject_id")
);
alter table "better_supabase"."taggings" enable row level security;
revoke all on "better_supabase"."taggings" from anon, authenticated;
grant select on "better_supabase"."taggings" to authenticated;
grant all on "better_supabase"."taggings" to service_role;
create or replace function "better_supabase"."create_tag"(tenant uuid, kind text, name text, color text, description text)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_id uuid;
v_slug text := trim(both '-' from regexp_replace(lower(create_tag.name), '[^a-z0-9]+', '-', 'g'));
v_last text;
v_rank text;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', create_tag.tenant, 'tags.manage'), false)) then
raise exception 'not allowed to manage tags' using errcode = '42501', hint = 'TAG_FORBIDDEN';
end if;
select max(t."rank" collate "C") into v_last from "better_supabase"."tags" t
where t."tenant_id" = create_tag.tenant and t."kind" = create_tag.kind;
v_rank := case
when v_last is null then 'V'
when length(substring(v_last from '^z*')) = length(v_last) then v_last || 'V'
else left(v_last, length(substring(v_last from '^z*'))) || substr('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', (strpos('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', substr(v_last, length(substring(v_last from '^z*')) + 1, 1)) - 1 + 62) / 2 + 1, 1)
end;
insert into "better_supabase"."tags" ("tenant_id", "kind", "name", "slug", "color", "description", "rank", "created_at")
values (create_tag.tenant, create_tag.kind, create_tag.name, v_slug, create_tag.color, create_tag.description, v_rank, now())
returning "id" into v_id;
null;
return jsonb_build_object('id', v_id, 'slug', v_slug, 'rank', v_rank);
end;
$$;
revoke execute on function "better_supabase"."create_tag"(uuid, text, text, text, text) from public, anon;
grant execute on function "better_supabase"."create_tag"(uuid, text, text, text, text) to authenticated, service_role;
create or replace function "better_supabase"."tag"(tenant uuid, subject_type text, subject_id text, tag_id uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_kind text;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if not (true) then
raise exception 'not allowed to tag this record' using errcode = '42501', hint = 'TAG_SUBJECT_FORBIDDEN';
end if;
select "kind" into v_kind from "better_supabase"."tags" where "id" = tag.tag_id and "tenant_id" = tag.tenant;
if v_kind is null then
raise exception 'tag not found' using errcode = 'P0002', hint = 'TAG_NOT_FOUND';
end if;
if not (case tag.subject_type
when '' then true
else true
end) then
raise exception 'tag kind is not allowed on this subject' using errcode = '22023', hint = 'TAG_KIND_MISMATCH';
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."before_tag_apply"(uuid, text, text, uuid)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::text, $3::text, $4::uuid)', v_hook::oid::regproc)
using tag.tenant, tag.subject_type, tag.subject_id, tag.tag_id;
end if;
end;
case tag.subject_type
when '' then null;
else
insert into "better_supabase"."taggings" ("tenant_id", "tag_id", "subject_type", "subject_id")
values (tag.tenant, tag.tag_id, tag.subject_type, tag.subject_id)
on conflict do nothing;
end case;
null;
declare
v_hook regprocedure := to_regprocedure('"public"."after_tag_apply"(uuid, text, text)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::text, $3::text)', v_hook::oid::regproc)
using tag.tag_id, tag.subject_type, tag.subject_id;
end if;
end;
return true;
end;
$$;
revoke execute on function "better_supabase"."tag"(uuid, text, text, uuid) from public, anon;
grant execute on function "better_supabase"."tag"(uuid, text, text, uuid) to authenticated, service_role;
create or replace function "better_supabase"."update_tag"(tag_id uuid, name text)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant uuid;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
select "tenant_id" into v_tenant from "better_supabase"."tags" where "id" = update_tag.tag_id;
if not found then
raise exception 'tag not found' using errcode = 'P0002', hint = 'TAG_NOT_FOUND';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'tags.manage'), false)) then
raise exception 'not allowed to manage tags' using errcode = '42501', hint = 'TAG_FORBIDDEN';
end if;
update "better_supabase"."tags" set "name" = coalesce(update_tag.name, "name"), "updated_at" = now() where "id" = update_tag.tag_id;
null;
return true;
end;
$$;
revoke execute on function "better_supabase"."update_tag"(uuid, text) from public, anon;
grant execute on function "better_supabase"."update_tag"(uuid, text) to authenticated, service_role;
-- Sets a tag's rank key, from rankBetween over its neighbours' keys.
create or replace function "better_supabase"."rank_tag"(tag_id uuid, rank text)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant uuid;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if rank_tag.rank is null or rank_tag.rank !~ '^[0-9A-Za-z]*[1-9A-Za-z]$' then
raise exception 'not a rank key' using errcode = '22023', hint = 'TAG_RANK_INVALID';
end if;
select "tenant_id" into v_tenant from "better_supabase"."tags" where "id" = rank_tag.tag_id;
if not found then
raise exception 'tag not found' using errcode = 'P0002', hint = 'TAG_NOT_FOUND';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'tags.manage'), false)) then
raise exception 'not allowed to manage tags' using errcode = '42501', hint = 'TAG_FORBIDDEN';
end if;
update "better_supabase"."tags" set "rank" = rank_tag.rank, "updated_at" = now() where "id" = rank_tag.tag_id;
return true;
end;
$$;
revoke execute on function "better_supabase"."rank_tag"(uuid, text) from public, anon;
grant execute on function "better_supabase"."rank_tag"(uuid, text) to authenticated, service_role;
create or replace function "better_supabase"."delete_tag"(tag_id uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant uuid;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
select "tenant_id" into v_tenant from "better_supabase"."tags" where "id" = delete_tag.tag_id;
if not found then
raise exception 'tag not found' using errcode = 'P0002', hint = 'TAG_NOT_FOUND';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'tags.manage'), false)) then
raise exception 'not allowed to manage tags' using errcode = '42501', hint = 'TAG_FORBIDDEN';
end if;
delete from "better_supabase"."tags" where "id" = delete_tag.tag_id;
null;
return true;
end;
$$;
revoke execute on function "better_supabase"."delete_tag"(uuid) from public, anon;
grant execute on function "better_supabase"."delete_tag"(uuid) to authenticated, service_role;
create or replace function "better_supabase"."untag"(tenant uuid, subject_type text, subject_id text, tag_id uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if not (true) then
raise exception 'not allowed to tag this record' using errcode = '42501', hint = 'TAG_SUBJECT_FORBIDDEN';
end if;
delete from "better_supabase"."taggings"
where "tenant_id" = untag.tenant
and "tag_id" = untag.tag_id
and "subject_type" = untag.subject_type
and "subject_id" = untag.subject_id;
null;
return true;
end;
$$;
revoke execute on function "better_supabase"."untag"(uuid, text, text, uuid) from public, anon;
grant execute on function "better_supabase"."untag"(uuid, text, text, uuid) to authenticated, service_role;
create schema if not exists better_supabase;
create table if not exists better_supabase.modules (
name text primary key,
version integer not null,
mode text not null,
installed_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table better_supabase.modules enable row level security;
revoke all on better_supabase.modules from anon, authenticated;
grant select on better_supabase.modules to service_role;
supabase/better-supabase-data/900_better_supabase_59_tags.sql
-- better-supabase module: tags
-- @bs-module-data tags
-- Rows and settings a schema diff doesn't capture. Run `better-supabase sql data`
-- after the schema migration to put them in a migration.
-- Managed by `better-supabase sql add`; re-running it overwrites this file.
-- Change it through `sql.modules` in better-supabase.config.ts and the module's SQL hooks.
insert into better_supabase.modules (name, version, mode)
values ('tags', 2, 'managed')
on conflict (name) do update
set version = excluded.version, mode = excluded.mode, updated_at = now();
This block has no React entry yet. Its TypeScript side is in better-supabase/blocks/tags, which runs on the server.
With the outbox installed, the module writes these events in the same transaction as the change.
- subject.tagged
- subject.untagged
- tag.created
- tag.deleted
- tag.updated