All blocks
Platform
Number sequences
Gapless numbers per tenant, entity and period. next_number locks the row in the caller's transaction, and a trigger fills the document column so a separate round trip cannot open a gap.
npx better-supabase sql add number-sequencesimport { defineConfig } from "better-supabase/config";
export default defineConfig({
sql: {
modules: {
// Required by number-sequences; sql add installs them too.
"updated-at": {},
tenant: {},
access: {},
// Modes: "managed", "adopt", "custom". The default is "managed".
"number-sequences": { mode: "managed" },
},
},
});npx better-supabase sql add number-sequences 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.sqlschemaaccess374 linessupabase/better-supabase-data/900_better_supabase_19_access.sqldataaccess12 linessupabase/schemas/900_better_supabase_57_number_sequences.sqlschema142 linessupabase/better-supabase-data/900_better_supabase_57_number_sequences.sqldata12 lines
supabase/schemas/900_better_supabase_57_number_sequences.sql
-- better-supabase module: number-sequences
-- @bs-module number-sequences@1 managed
-- Gapless numbers per tenant, entity and period. next_number locks the row in the caller's transaction, and a trigger fills the document column so a separate round trip cannot open a gap.
-- 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;
-- Gapless numbers. next_number locks the sequence row inside the caller's
-- transaction, so a rollback releases the number. Clients get a number
-- only through a trigger: the function is service-only.
create table if not exists "better_supabase"."number_sequences" (
"tenant_id" uuid not null,
"entity" text not null,
"period" text not null default '',
"last_number" bigint not null,
"created_at" timestamptz not null default now(),
"updated_at" timestamptz not null default now(),
primary key ("tenant_id", "entity", "period")
);
alter table "better_supabase"."number_sequences" enable row level security;
revoke all on "better_supabase"."number_sequences" from anon, authenticated;
grant all on "better_supabase"."number_sequences" to service_role;
create or replace function better_supabase.format_assigned_number(template text, n bigint, at timestamptz)
returns text
language plpgsql
immutable
security invoker
set search_path = ''
as $$
declare
v text := template;
width text[];
begin
v := replace(v, '{yyyy}', to_char(at at time zone 'utc', 'YYYY'));
v := replace(v, '{yy}', to_char(at at time zone 'utc', 'YY'));
v := replace(v, '{mm}', to_char(at at time zone 'utc', 'MM'));
v := replace(v, '{dd}', to_char(at at time zone 'utc', 'DD'));
while v ~ '\{n:[0-9]+\}' loop
width := regexp_match(v, '\{n:([0-9]+)\}');
v := regexp_replace(v, '\{n:[0-9]+\}', lpad(n::text, width[1]::int, '0'));
end loop;
return replace(v, '{n}', n::text);
end;
$$;
revoke execute on function better_supabase.format_assigned_number(text, bigint, timestamptz) from public, anon, authenticated;
grant execute on function better_supabase.format_assigned_number(text, bigint, timestamptz) to service_role;
-- PostgreSQL refuses CREATE OR REPLACE when an argument is renamed.
drop function if exists "better_supabase"."next_number"(uuid, text, timestamptz) cascade;
create or replace function "better_supabase"."next_number"(tenant uuid, p_entity text, at timestamptz default now())
returns bigint
language plpgsql
security definer
set search_path = ''
as $$
declare
v_period text;
v_n bigint;
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;
v_period := '';
if v_period is null then
raise exception 'unknown number sequence' using errcode = '22023', hint = 'NUMBER_ENTITY_UNKNOWN';
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."before_number_assign"(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 next_number.tenant, p_entity, v_period;
end if;
end;
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(next_number.tenant::text || ':' || p_entity || ':' || v_period, 0));
insert into "better_supabase"."number_sequences" ("tenant_id", "entity", "period", "last_number")
values (next_number.tenant, p_entity, v_period, 1)
on conflict ("tenant_id", "entity", "period")
do update set "last_number" = "better_supabase"."number_sequences"."last_number" + 1, "updated_at" = now()
returning "last_number" into v_n;
null;
declare
v_hook regprocedure := to_regprocedure('"public"."after_number_assign"(uuid, text, bigint)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::text, $3::bigint)', v_hook::oid::regproc)
using next_number.tenant, p_entity, v_n;
end if;
end;
return v_n;
end;
$$;
revoke execute on function "better_supabase"."next_number"(uuid, text, timestamptz) from public, anon, authenticated;
grant execute on function "better_supabase"."next_number"(uuid, text, timestamptz) to 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_57_number_sequences.sql
-- better-supabase module: number-sequences
-- @bs-module-data number-sequences
-- 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 ('number-sequences', 1, '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/number-sequences, which runs on the server.
With the outbox installed, the module writes these events in the same transaction as the change.
- number.assigned