Canonical patterns for backend namespace usage and new SupabaseService methods.
SupabaseService has two namespaces — use the right one:
| Namespace | When to use | Examples |
|---|---|---|
backend.GET.* |
Paginated / filtered list queries exposed via GET object |
backend.GET.modules(...), backend.GET.manufacturers(...), backend.GET.patches(...) |
backend.get.* |
Simple entity lookups and user-scoped queries | backend.get.patchWithId(id), backend.get.currentUserPatches(), backend.get.rackedModules(rackId) |
backend.add.* |
Create new records | backend.add.patch(data), backend.add.manufacturers(data) |
backend.update.* |
Update existing records | backend.update.patch(data), backend.update.module(data) |
backend.delete.* |
Delete records | backend.delete.userPatch(id), backend.delete.modulePanel(panel) |
this.loadData$.pipe(
switchMap(() => this.backend.get.currentUserPatches()),
tap(data => this._data$.next(data)),
takeUntil(this.destroy$)
).subscribe();this.loadData$.pipe(
tap(() => this._isLoading$.next(true)),
switchMap(() => this.backend.get.currentUserPatches()),
tap(data => this._data$.next(data)),
catchError(error => {
console.error('Error:', error);
SharedConstants.errorCustom(this.snackBar, 'Failed to load');
return EMPTY;
}),
finalize(() => this._isLoading$.next(false)),
takeUntil(this.destroy$)
).subscribe();this.submit$.pipe(
switchMap(data => this.backend.add.patch(data)),
switchMap(created => this.backend.update.patch({...created, extra: 'data'})),
tap(() => SharedConstants.successSave(this.snackBar)),
takeUntil(this.destroy$)
).subscribe();this.loadAll$.pipe(
switchMap(() => forkJoin({
patches: this.backend.get.currentUserPatches(),
racks: this.backend.get.currentUserRacks()
})),
tap(({patches, racks}) => {
this._patches$.next(patches);
this._racks$.next(racks);
}),
takeUntil(this.destroy$)
).subscribe();When a new feature requires a new query or mutation, follow this checklist inside supabase.service.ts:
- Register the table name in
DatabaseStrings.ts(DbPaths) before writing the method. - Read-only methods (in
GETorgetnamespace): Add@Cacheable({ maxAge, cacheBusterObserver })if the data changes infrequently. Register the cache key in theCachedEntityunion type. - Write methods (add/update/delete): Always include a
cacheBust([...keys])pipe operator after the write succeeds. Bust every entity key that the write could invalidate. - Use the internal pipe helpers already defined in the file:
cacheBust(keys)— emits tocacheBuster$after successcatchErrors(this.snackBar)— logs + shows error snackbar, returnsNEVERshowSuccessMessage(this.snackBar)— shows success snackbar
// Example: adding a new write method to backend.add
addNewThing: (data: NewThingInsert) =>
rxFrom(
this.supabase.from(DbPaths.new_things).insert(data).select('id, name')
).pipe(
remapErrors(),
map(x => x.data),
cacheBust(['new_things', 'relatedEntity']), // bust anything stale
catchErrors(this.snackBar)
),Before touching supabase/migrations/, RPCs, columns, indexes, or policies — even via the Supabase MCP — walk through this list. Past mistakes live here so we don't repeat them.
racks and patches (and likely other tables) have BEFORE UPDATE triggers that auto-set updated = now(). Any UPDATE ... WHERE ... you run as part of a backfill will reset updated on every touched row — wiping the real edit history visible to users.
Past incident (2026-05-15): the public_id backfill ran one UPDATE per row across all 438 racks + 94 patches. Every row's updated is now the migration timestamp. Information is unrecoverable without PITR.
Mitigations, in order of preference:
-
Use a column DEFAULT instead of a backfill loop when possible:
alter table public.racks add column public_id text default public.generate_public_id();
PostgreSQL fills existing rows at
ADD COLUMNtime without firingBEFORE UPDATE. Caveat: doesn't work cleanly when you need retry-on-collision logic (e.g., random unique tokens) — in that case, see next option. -
Disable the trigger for the backfill window:
alter table public.racks disable trigger user; -- or the specific trigger name -- ... backfill ... alter table public.racks enable trigger user;
disable trigger userskips user-defined triggers but leaves FK/constraint triggers intact. Safest for batch backfills. -
Force-restore
updatedin the same statement:update public.racks set public_id = candidate, updated = updated where id = r.id;
Works only if the trigger guards against no-op writes (
NEW.updated IS DISTINCT FROM OLD.updated). Check the trigger source before relying on this.
Stop and ask the user (AGENTS.md §5). RLS changes require explicit manual approval. SECURITY DEFINER RPCs that bypass RLS are also a security boundary — review the function body for SQL injection (format() + %I/%L) and least-privilege return shape.
- Run
pnpm updateBackendTypes(or use the Supabase MCPgenerate_typescript_typestool if the CLI auth hangs). - After regen, double-check Insert/Update generic types for columns with DB-side defaults (triggers,
DEFAULT now(), sequences). The generator often marks themrequired: trueeven when they're filled server-side — flip them to optional manually or callers won't compile.
Every cacheBust([...]) call after a write must cover all @Cacheable reads whose results could be stale. New RPC-backed reads (e.g., get_rack_by_public_id) should reuse the same cache keys as the equivalent table read ('rackWithId') so existing write paths invalidate them.
After any non-trivial schema change use the Supabase MCP get_advisors (lint + security) tool against the project. Address error and warn items or document why they're acceptable.
Every backend change in active feature work belongs in CURRENT_FEATURE.md (with the applied-on date) and, on completion, archived to COMPLETED.md.
| Date | Change | Tables affected |
|---|---|---|
| 2026-05-15 | add touch-parent triggers on rack_modules and patch child tables | rack_modules, patches, racks |