feat(fase-11): tags store + lateral-join item loader (11.2)

apps/web/src/lib/stores/tags.ts — collective-scoped store for item_tags
with realtime subscription per active collective (resub on switch), plus
mutation helpers: createTag, renameTag, recolorTag, deleteTag, and the
many-to-many writers attachTag / detachTag for shopping_item_tags.

apps/web/src/lib/stores/lists.ts — new loadListItems(listId) issues a
single PostgREST embedding (`*, shopping_item_tags(item_tags(*))`) and
flattens the rows to ItemWithTags. Cast goes through `unknown` because
the curated database.ts doesn't declare the shopping_items ↔
shopping_item_tags relationship — runtime resolves it from the FK.

Tests: packages/test-utils/tests/rls-item-tags.test.ts adds 6 integration
assertions covering the policies an authenticated role sees: member
create (IT-01), guest reject (IT-02), non-member empty read (IT-03),
cross-collective attach reject (IT-04), unique violation (IT-05), and
cascade on item delete (IT-06).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 03:04:25 +02:00
parent 4c3552fb3c
commit c1152fac90
3 changed files with 422 additions and 1 deletions

View File

@@ -1,7 +1,13 @@
import { writable } from 'svelte/store';
import { getSupabase } from '$lib/supabase';
import { generateId } from '$lib/utils/id';
import type { ShoppingList, ShoppingItem, ItemFrequency } from '@colectivo/types';
import type {
ShoppingList,
ShoppingItem,
ItemFrequency,
ItemTag,
ItemWithTags
} from '@colectivo/types';
// ── Global list store (overview page) ─────────────────────────────────────────
@@ -147,6 +153,39 @@ export async function loadItems(listId: string): Promise<ShoppingItem[]> {
return data as ShoppingItem[];
}
/**
* Load items with their tag rows embedded — one PostgREST call instead of
* N+1 lookups. The join goes via shopping_item_tags; PostgREST returns
* `{ ...item, shopping_item_tags: [{ item_tags: {...tag} }, ...] }` so we
* flatten the shape before returning so callers see `tags: ItemTag[]`.
*
* Note: the realtime channel for shopping_item_tags is the source of truth
* for live updates — the embedded view here is the cold-load snapshot only.
*/
export async function loadListItems(listId: string): Promise<ItemWithTags[]> {
const { data, error } = await getSupabase()
.from('shopping_items')
.select('*, shopping_item_tags(item_tags(*))')
.eq('list_id', listId)
.order('sort_order', { ascending: true });
if (error || !data) return [];
type Embedded = ShoppingItem & {
shopping_item_tags: Array<{ item_tags: ItemTag | null }> | null;
};
// Cast via unknown: the curated database.ts does not declare the
// shopping_items ↔ shopping_item_tags relationship to PostgREST, so the
// generated query type sees `SelectQueryError`. The runtime shape is
// correct (PostgREST resolves the embedding from the FK).
return (data as unknown as Embedded[]).map(({ shopping_item_tags, ...rest }) => ({
...rest,
tags: (shopping_item_tags ?? [])
.map((row) => row.item_tags)
.filter((t): t is ItemTag => t !== null)
}));
}
/**
* Insert a new item. Accepts an optional pre-generated `id` so callers can
* use the same UUID as their local optimistic row — this makes the write

View File

@@ -0,0 +1,182 @@
/**
* Tag store — collective-scoped item tags (Fase 11).
*
* Loads + caches the tags for the active collective and subscribes to
* `item_tags` postgres-changes so concurrent rename / create / delete from
* another tab or device propagates without a reload.
*
* Per CLAUDE.md "Supabase JS v2 + SvelteKit deadlock" gotcha: this store is
* created from `onMount`-style component lifecycles, never from inside
* `onAuthStateChange`. The Realtime subscription itself does not call
* `getAccessToken()`, so the lock-defer pattern is not needed here.
*/
import { writable, get } from 'svelte/store';
import { getSupabase } from '$lib/supabase';
import type { ItemTag, ItemTagColor } from '@colectivo/types';
import type { RealtimeChannel } from '@supabase/supabase-js';
export const tags = writable<ItemTag[]>([]);
export const tagsLoading = writable(false);
let activeChannel: RealtimeChannel | null = null;
let activeCollectiveId: string | null = null;
export async function loadTags(collectiveId: string): Promise<void> {
tagsLoading.set(true);
try {
const { data, error } = await getSupabase()
.from('item_tags')
.select('*')
.eq('collective_id', collectiveId)
.order('name', { ascending: true });
if (!error && data) {
tags.set(data as ItemTag[]);
}
} finally {
tagsLoading.set(false);
}
// Re-subscribe whenever the active collective changes. Cheap: one channel
// per collective + tab.
if (activeCollectiveId !== collectiveId) {
await unsubscribe();
activeCollectiveId = collectiveId;
await subscribe(collectiveId);
}
}
async function subscribe(collectiveId: string): Promise<void> {
const channel = getSupabase().channel(`tags:${collectiveId}`);
channel.on(
// @ts-expect-error — postgres_changes is a runtime string event
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'item_tags',
filter: `collective_id=eq.${collectiveId}`
},
(payload: {
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
new: ItemTag;
old: ItemTag | { id: string };
}) => {
if (payload.eventType === 'INSERT') {
tags.update((arr) =>
arr.some((t) => t.id === payload.new.id) ? arr : sortByName([...arr, payload.new])
);
} else if (payload.eventType === 'UPDATE') {
tags.update((arr) =>
sortByName(arr.map((t) => (t.id === payload.new.id ? payload.new : t)))
);
} else {
const id = (payload.old as { id: string }).id;
tags.update((arr) => arr.filter((t) => t.id !== id));
}
}
);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('tags channel subscribe timeout')), 10_000);
channel.subscribe((status) => {
if (status === 'SUBSCRIBED') {
clearTimeout(timer);
resolve();
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
clearTimeout(timer);
reject(new Error(`tags channel subscribe failed: ${status}`));
}
});
});
activeChannel = channel;
}
async function unsubscribe(): Promise<void> {
if (!activeChannel) return;
const supabase = getSupabase();
try {
await activeChannel.unsubscribe();
await supabase.removeChannel(activeChannel);
} catch {
// idempotent cleanup
}
activeChannel = null;
}
export async function teardownTags(): Promise<void> {
await unsubscribe();
activeCollectiveId = null;
tags.set([]);
}
function sortByName(arr: ItemTag[]): ItemTag[] {
return [...arr].sort((a, b) => a.name.localeCompare(b.name));
}
// ── Mutations ─────────────────────────────────────────────────────────────────
export async function createTag(
collectiveId: string,
name: string,
color: ItemTagColor = 'slate'
): Promise<ItemTag | null> {
const trimmed = name.trim();
if (!trimmed) return null;
const { data, error } = await getSupabase()
.from('item_tags')
.insert({ collective_id: collectiveId, name: trimmed, color })
.select()
.single();
if (error || !data) return null;
const row = data as ItemTag;
// Optimistic local insert — the Realtime echo is deduped by id.
tags.update((arr) => (arr.some((t) => t.id === row.id) ? arr : sortByName([...arr, row])));
return row;
}
export async function renameTag(id: string, name: string): Promise<void> {
const trimmed = name.trim();
if (!trimmed) return;
tags.update((arr) => sortByName(arr.map((t) => (t.id === id ? { ...t, name: trimmed } : t))));
await getSupabase().from('item_tags').update({ name: trimmed }).eq('id', id);
}
export async function recolorTag(id: string, color: ItemTagColor): Promise<void> {
tags.update((arr) => arr.map((t) => (t.id === id ? { ...t, color } : t)));
await getSupabase().from('item_tags').update({ color }).eq('id', id);
}
export async function deleteTag(id: string): Promise<void> {
tags.update((arr) => arr.filter((t) => t.id !== id));
await getSupabase().from('item_tags').delete().eq('id', id);
}
// ── Attach / detach on a shopping_item ───────────────────────────────────────
export async function attachTag(itemId: string, tagId: string): Promise<boolean> {
const { error } = await getSupabase()
.from('shopping_item_tags')
.insert({ item_id: itemId, tag_id: tagId });
return !error;
}
export async function detachTag(itemId: string, tagId: string): Promise<boolean> {
const { error } = await getSupabase()
.from('shopping_item_tags')
.delete()
.eq('item_id', itemId)
.eq('tag_id', tagId);
return !error;
}
// ── Local-only helpers ───────────────────────────────────────────────────────
export function findTagByName(name: string): ItemTag | undefined {
const lookup = name.trim().toLowerCase();
return get(tags).find((t) => t.name.toLowerCase() === lookup);
}