Database¶
tldrq's data is three tables and one function in Supabase's public schema, all defined in
supabase/schema.sql (included in full at the end of this page). Every table has Row Level
Security enabled.
collections¶
| Column | Type | Notes |
|---|---|---|
id |
uuid |
Primary key, default gen_random_uuid() |
user_id |
uuid not null |
References auth.users(id) on delete cascade |
name |
text not null |
Not unique |
is_public |
boolean not null default false |
Legacy public sharing (see below) |
share_slug |
text unique |
Legacy share code, set while is_public is true |
created_at |
timestamptz not null default now() |
Indexes: idx_collections_user_id, idx_collections_share_slug.
bookmarks¶
| Column | Type | Notes |
|---|---|---|
id |
uuid |
Primary key, default gen_random_uuid() |
user_id |
uuid not null |
References auth.users(id) on delete cascade |
collection_id |
uuid |
References collections(id) on delete set null; null means Uncategorized |
url |
text not null |
|
title, description, image_url, site_name |
text |
From the metadata fetcher; nullable |
repo_stars, repo_forks |
integer |
GitHub and GitLab only |
repo_language |
text |
GitHub's language, or GitLab's first topic |
is_read |
boolean not null default false |
|
priority |
text not null default 'normal' |
Check: urgent, high, normal, low |
priority_order |
smallint generated always as (…) stored |
0 for urgent, 1 high, 2 normal, 3 low |
created_at |
timestamptz not null default now() |
Indexes: idx_bookmarks_user_id, idx_bookmarks_collection_id, idx_bookmarks_user_read (user_id, is_read),
idx_bookmarks_priority (user_id, priority) and
idx_bookmarks_priority_order (user_id, priority_order, created_at desc), which serves the "By priority" sort.
There's no unique constraint on url: POST /api/bookmarks checks for an existing bookmark with the same URL
before inserting. priority_order has to stay in step with PRIORITY_ORDER in src/lib/utils/priority.ts.
shared_views¶
One row per share link. It stores the view's settings, not its bookmarks.
| Column | Type | Notes |
|---|---|---|
id |
uuid |
Primary key |
user_id |
uuid not null |
References auth.users(id) on delete cascade |
slug |
text not null unique |
The code in /share/slug |
title |
text |
Optional heading |
collection_id |
uuid |
References collections(id) on delete cascade |
collection_uncategorized |
boolean not null default false |
The view is Uncategorized |
filter |
text |
Check: read, unread |
priority |
text |
Check: the four levels |
sort |
text |
Check: priority |
search |
text |
|
is_active |
boolean not null default true |
False turns the link off |
created_at |
timestamptz not null default now() |
Indexes: idx_shared_views_slug, idx_shared_views_user_id. Because collection_id cascades, deleting a
collection deletes the share links made for it.
Row Level Security¶
| Table | authenticated role |
anon role |
|---|---|---|
collections |
Select, insert, update, delete where auth.uid() = user_id |
Select where is_public = true |
bookmarks |
Select, insert, update, delete where auth.uid() = user_id |
Select bookmarks whose collection is public |
shared_views |
Select, insert, update, delete where auth.uid() = user_id |
Select where is_active = true |
Every query the app makes runs with the user's session, so these policies are what keep users apart; the route
handlers' extra user_id filters are a second line.
What these policies mean for share links
- The
is_active = truepolicy is granted toanononly, and the share page queries as the visitor. Other signed-in users can't read someone else's row, so for them the share page returns 404: a share link works for signed-out visitors and for its owner, not for other signed-in users. - Anyone holding the anon key, which ships in the browser bundle, can list every active
shared_viewsrow (slug, title, search text and the owner'suser_id) and callget_shared_bookmarksfor each slug, so share links can be enumerated. Public collections and their bookmarks are exposed the same way.
get_shared_bookmarks¶
get_shared_bookmarks(view_slug text) returns setof public.bookmarks
-- language plpgsql security definer
The share page calls it with supabase.rpc("get_shared_bookmarks", { view_slug }). It:
- looks up the active
shared_viewsrow with that slug, and returns nothing if there isn't one; - builds a query over that view owner's bookmarks, adding the view's settings: Uncategorized or a collection, the
read filter, the priority, the search (
ilikeon title, description, url and site_name) and the sort (priority_order asc, created_at desc, orcreated_at desc); - runs it, with every value quoted by
quote_literal.
Running as security definer lets an anonymous viewer get exactly the bookmarks a link exposes, without an anon
policy on bookmarks that would expose more.
src/app/share/[slug]/page.tsx looks for an active shared_views row first; failing that, a collections row
with that share_slug and is_public = true (the legacy public collection); failing both, it returns 404.
Share slugs¶
generateShareSlug() in src/lib/utils/slug.ts takes 6 random bytes from Node's crypto.randomBytes and
base64url-encodes them: 8 characters, 48 bits. That makes a link hard to guess, but not secret: active links can be
listed (see Row Level Security).
Turning off a share link¶
The app has no control for this. In the Supabase SQL editor:
The share page and get_shared_bookmarks then ignore the row, and the link shows "not found". Deleting the row
works too; setting is_active back to true restores the link.
Making a collection public¶
Public collections are the older way of sharing, with no control left in the app. The API still supports it:
PATCH /api/collections/{id} with {"is_public": true} gives the collection a new share_slug, and
{"is_public": false} clears it. See the API Reference.
Migrations and resets¶
| File | Creates |
|---|---|
supabase/migrations/001_schema.sql |
collections and bookmarks, with their indexes and policies |
supabase/migrations/002_shared_views.sql |
shared_views and get_shared_bookmarks |
supabase/migrations/003_shared_views_title.sql |
Adds shared_views.title |
supabase/schema.sql is the three combined, and it starts by dropping the function and all three tables. No
migration tool is configured: apply the SQL in Supabase's SQL editor or with psql.
drop_all.sql is out of date
supabase/drop_all.sql drops only bookmarks and collections; it predates shared_views. For a full reset,
use the drop statements at the top of schema.sql.
TypeScript types¶
src/types/database.ts defines Collection, Bookmark and SharedView to match the tables (Bookmark leaves out
priority_order). Update it with any schema change.
Full schema¶
This is supabase/schema.sql, included from the repository:
-- ============================================================
-- Combined schema for tldrq
-- ============================================================
-- Drop all application objects for a clean reset
drop function if exists public.get_shared_bookmarks(text) cascade;
drop table if exists public.shared_views cascade;
drop table if exists public.bookmarks cascade;
drop table if exists public.collections cascade;
-- ============================================================
-- Collections
-- ============================================================
create table public.collections (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
name text not null,
is_public boolean not null default false,
share_slug text unique,
created_at timestamptz not null default now()
);
create index idx_collections_user_id on public.collections(user_id);
create index idx_collections_share_slug on public.collections(share_slug);
alter table public.collections enable row level security;
create policy "Users can view own collections" on public.collections for select to authenticated using (auth.uid() = user_id);
create policy "Users can insert own collections" on public.collections for insert to authenticated with check (auth.uid() = user_id);
create policy "Users can update own collections" on public.collections for update to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy "Users can delete own collections" on public.collections for delete to authenticated using (auth.uid() = user_id);
create policy "Anyone can view public collections" on public.collections for select to anon using (is_public = true);
-- ============================================================
-- Bookmarks
-- ============================================================
create table public.bookmarks (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
collection_id uuid references public.collections(id) on delete set null,
url text not null,
title text,
description text,
image_url text,
site_name text,
repo_stars integer,
repo_forks integer,
repo_language text,
is_read boolean not null default false,
priority text not null default 'normal' check (priority in ('urgent', 'high', 'normal', 'low')),
priority_order smallint generated always as (
case priority
when 'urgent' then 0
when 'high' then 1
when 'normal' then 2
when 'low' then 3
end
) stored,
created_at timestamptz not null default now()
);
create index idx_bookmarks_user_id on public.bookmarks(user_id);
create index idx_bookmarks_collection_id on public.bookmarks(collection_id);
create index idx_bookmarks_user_read on public.bookmarks(user_id, is_read);
create index idx_bookmarks_priority on public.bookmarks(user_id, priority);
create index idx_bookmarks_priority_order on public.bookmarks(user_id, priority_order, created_at desc);
alter table public.bookmarks enable row level security;
create policy "Users can view own bookmarks" on public.bookmarks for select to authenticated using (auth.uid() = user_id);
create policy "Users can insert own bookmarks" on public.bookmarks for insert to authenticated with check (auth.uid() = user_id);
create policy "Users can update own bookmarks" on public.bookmarks for update to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy "Users can delete own bookmarks" on public.bookmarks for delete to authenticated using (auth.uid() = user_id);
create policy "Anyone can view bookmarks in public collections" on public.bookmarks for select to anon using (exists (select 1 from public.collections where collections.id = bookmarks.collection_id and collections.is_public = true));
-- ============================================================
-- Shared views
-- ============================================================
create table public.shared_views (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
slug text not null unique,
title text,
collection_id uuid references public.collections(id) on delete cascade,
collection_uncategorized boolean not null default false,
filter text check (filter in ('read', 'unread')),
priority text check (priority in ('urgent', 'high', 'normal', 'low')),
sort text check (sort in ('priority')),
search text,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create index idx_shared_views_slug on public.shared_views(slug);
create index idx_shared_views_user_id on public.shared_views(user_id);
alter table public.shared_views enable row level security;
create policy "Users can view own shared views"
on public.shared_views for select to authenticated
using (auth.uid() = user_id);
create policy "Users can insert own shared views"
on public.shared_views for insert to authenticated
with check (auth.uid() = user_id);
create policy "Users can update own shared views"
on public.shared_views for update to authenticated
using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy "Users can delete own shared views"
on public.shared_views for delete to authenticated
using (auth.uid() = user_id);
create policy "Anyone can view active shared views"
on public.shared_views for select to anon
using (is_active = true);
-- ============================================================
-- Function: get_shared_bookmarks
-- ============================================================
create or replace function public.get_shared_bookmarks(view_slug text)
returns setof public.bookmarks
language plpgsql security definer
as $$
declare
v record;
q text;
begin
select * into v from public.shared_views
where slug = view_slug and is_active = true;
if not found then
return;
end if;
q := 'select * from public.bookmarks where user_id = ' || quote_literal(v.user_id);
if v.collection_uncategorized then
q := q || ' and collection_id is null';
elsif v.collection_id is not null then
q := q || ' and collection_id = ' || quote_literal(v.collection_id);
end if;
if v.filter = 'unread' then
q := q || ' and is_read = false';
elsif v.filter = 'read' then
q := q || ' and is_read = true';
end if;
if v.priority is not null then
q := q || ' and priority = ' || quote_literal(v.priority);
end if;
if v.search is not null and v.search <> '' then
q := q || ' and (title ilike ' || quote_literal('%' || v.search || '%')
|| ' or description ilike ' || quote_literal('%' || v.search || '%')
|| ' or url ilike ' || quote_literal('%' || v.search || '%')
|| ' or site_name ilike ' || quote_literal('%' || v.search || '%') || ')';
end if;
if v.sort = 'priority' then
q := q || ' order by priority_order asc, created_at desc';
else
q := q || ' order by created_at desc';
end if;
return query execute q;
end;
$$;