
creating-rpc
by pariszhuang123
Kinly is a shared-household coordination app that helps people manage chores, repairs, expenses, and gratitude together. Built with Flutter and Supabase, it focuses on creating household harmony through shared visibility, gentle reminders, and collaborative tools for everyday living.
SKILL.md
name: creating-rpc description: Creates Supabase RPCs with migrations, RLS, repository integration, and pgTAP tests. Use when asked to create an RPC, add a database function, or implement a new backend endpoint.
Creating RPCs
Step-by-step workflow for creating Supabase RPCs in Kinly.
Prerequisites
Before starting, read:
AGENTS.md§ Contracts — understand entity schemasdocs/architecture/di_graph.md— dependency direction
Workflow Overview
1. Contract → 2. Migration → 3. RLS (if new table) → 4. Repository → 5. Tests → 6. Verify
Step 1: Define the Contract
Add or update the RPC signature in AGENTS.md § Contracts:
- <domain>.<action>(params) -> Returns description. Guards:
- guard 1
- guard 2
Naming convention: <domain>_<action> in SQL, e.g., homes_join, chores_create, expenses_edit
Step 2: Create Migration
Create file: supabase/migrations/<timestamp>_<descriptive_name>.sql
RPC Template
----------------------------------------------------------------------
-- RPC: <domain>_<action>
----------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.<domain>_<action>(
p_param_one uuid,
p_param_two text DEFAULT NULL
)
RETURNS <return_type>
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_user_id uuid := auth.uid();
v_row public.<table_name>;
BEGIN
-- 1. Authentication check
PERFORM public._assert_authenticated();
-- 2. Membership/authorization check
PERFORM public._assert_home_member(p_home_id);
-- 3. Input validation
IF COALESCE(btrim(p_param_two), '') = '' THEN
PERFORM public.api_error(
'INVALID_INPUT',
'Field is required.',
'22023',
jsonb_build_object('field', 'param_two')
);
END IF;
-- 4. Business logic guards (use api_assert for boolean checks)
PERFORM public.api_assert(
EXISTS (SELECT 1 FROM ... WHERE ...),
'ERROR_CODE',
'Human-readable message.',
'42501',
jsonb_build_object('key', 'value')
);
-- 5. Paywall/quota check (if applicable)
PERFORM public._home_assert_quota(
p_home_id,
jsonb_build_object('active_items', 1)
);
-- 6. DML operation
INSERT INTO public.<table> (...)
VALUES (...)
RETURNING * INTO v_row;
-- 7. Usage tracking (if applicable)
PERFORM public._home_usage_apply_delta(
p_home_id,
jsonb_build_object('active_items', 1)
);
RETURN v_row;
END;
$$;
Key Patterns
| Pattern | Helper | When to Use |
|---|---|---|
| Auth required | _assert_authenticated() | Every authenticated RPC |
| Member check | _assert_home_member(home_id) | Any home-scoped operation |
| Input validation | api_error(code, msg, sqlstate, details) | Invalid input |
| Boolean guard | api_assert(condition, code, msg, sqlstate, details) | Authorization checks |
| Quota check | _home_assert_quota(home_id, deltas) | Creating countable items |
| Usage tracking | _home_usage_apply_delta(home_id, deltas) | After successful create/delete |
Error Codes
Use consistent error codes:
INVALID_INPUT— validation failuresNOT_HOME_MEMBER— membership check failedHOME_INACTIVE— home is deactivatedUNAUTHORIZED— permission deniedQUOTA_EXCEEDED— paywall limit hitNOT_FOUND— resource doesn't exist
Step 3: Add RLS Policies (if new table)
In the same or separate migration:
ALTER TABLE public.<table> ENABLE ROW LEVEL SECURITY;
-- SELECT: members can read their home's data
CREATE POLICY "<table>_select_member"
ON public.<table>
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.memberships m
WHERE m.home_id = <table>.home_id
AND m.user_id = auth.uid()
AND m.is_current = TRUE
)
);
-- INSERT/UPDATE/DELETE: typically via RPC only (SECURITY DEFINER)
-- Add policies only if direct access is needed
Step 4: Create Repository Interface + Implementation
4a. Contract Interface (lib/contracts/<domain>/ports/<domain>_repository.dart)
abstract class <Domain>Repository {
Future<<ReturnType>> <action>({
required String paramOne,
String? paramTwo,
});
}
4b. Supabase Implementation (lib/features/<domain>/data/supabase/supabase_<domain>_repository.dart)
import 'package:kinly/contracts/<domain>/models.dart';
import 'package:kinly/core/supabase/supabase_error_mapper.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class Supabase<Domain>Repository implements <Domain>Repository {
Supabase<Domain>Repository({SupabaseClient? client})
: _client = client ?? Supabase.instance.client;
final SupabaseClient _client;
@override
Future<<ReturnType>> <action>({
required String paramOne,
String? paramTwo,
}) async {
try {
final response = await _client.rpc(
'<domain>_<action>',
params: {
'p_param_one': paramOne,
if (paramTwo != null) 'p_param_two': paramTwo,
},
);
return <ReturnType>.fromJson(
(response as Map).cast<String, dynamic>(),
);
} catch (error) {
throw SupabaseErrorMapper.map<Action>(error);
}
}
}
4c. Error Mapper (lib/core/supabase/supabase_error_mapper.dart)
Add a mapping method for the new RPC's error codes:
static Exception map<Action>(Object error) {
final code = _extractCode(error);
return switch (code) {
'INVALID_INPUT' => InvalidInputException(_extractMessage(error)),
'NOT_HOME_MEMBER' => NotMemberException(),
_ => UnknownException(error.toString()),
};
}
Step 5: Write pgTAP Tests
Create file: supabase/tests/<domain>_<feature>.sql
Test Template
SET search_path = pgtap, public, auth, extensions;
BEGIN;
SELECT no_plan();
-- ============================================================
-- FIXTURES
-- ============================================================
CREATE TEMP TABLE tmp_users (
label text PRIMARY KEY,
user_id uuid,
email text
);
CREATE TEMP TABLE tmp_homes (
label text PRIMARY KEY,
home_id uuid
);
CREATE TEMP TABLE tmp_invites (
label text PRIMARY KEY,
code text
);
-- Error assertion helper
CREATE OR REPLACE FUNCTION pg_temp.expect_api_error(
p_sql text,
p_error_code text,
p_description text
)
RETURNS text
LANGUAGE sql
AS $$
SELECT throws_like(
p_sql,
'%' || p_error_code || '%',
p_description
);
$$;
-- Seed starter avatars (required by handle_new_user trigger)
INSERT INTO public.avatars (id, storage_path, category, name)
VALUES ('00000000-0000-4000-8000-000000000001', 'avatars/default.png', 'animal', 'Test Avatar')
ON CONFLICT (id) DO NOTHING;
-- Seed test users
INSERT INTO tmp_users (label, user_id, email) VALUES
('owner', '10000000-0000-4000-9000-000000000001', 'owner@test.com'),
('member', '10000000-0000-4000-9000-000000000002', 'member@test.com'),
('outsider', '10000000-0000-4000-9000-000000000003', 'outsider@test.com');
INSERT INTO auth.users (id, instance_id, email, raw_user_meta_data, raw_app_meta_data, aud, role, encrypted_password)
SELECT user_id, '00000000-0000-0000-0000-000000000000'::uuid, email,
'{}'::jsonb, '{"provider":"email"}'::jsonb, 'authenticated', 'authenticated', 'secret'
FROM tmp_users
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- SETUP: Create home + members
-- ============================================================
SELECT set_config('request.jwt.claim.sub', (SELECT user_id::text FROM tmp_users WHERE label = 'owner'), true);
SELECT set_config('request.jwt.claim.role', 'authenticated', true);
WITH res AS (SELECT public.homes_create_with_invite() AS payload)
INSERT INTO tmp_homes (label, home_id)
SELECT 'primary', (payload->'home'->>'id')::uuid FROM res;
INSERT INTO tmp_invites (label, code)
SELECT 'primary', code::text
FROM public.invites
WHERE home_id = (SELECT home_id FROM tmp_homes WHERE label = 'primary')
AND revoked_at IS NULL
LIMIT 1;
-- Member joins
SELECT set_config('request.jwt.claim.sub', (SELECT user_id::text FROM tmp_users WHERE label = 'member'), true);
SELECT public.homes_join((SELECT code FROM tmp_invites WHERE label = 'primary'));
-- ============================================================
-- TESTS
-- ============================================================
-- Test: Happy path
SELECT set_config('request.jwt.claim.sub', (SELECT user_id::text FROM tmp_users WHERE label = 'owner'), true);
SELECT is(
(SELECT public.<domain>_<action>(
(SELECT home_id FROM tmp_homes WHERE label = 'primary'),
'test value'
)).id IS NOT NULL,
true,
'Owner can call <action> successfully'
);
-- Test: Non-member denied
SELECT set_config('request.jwt.claim.sub', (SELECT user_id::text FROM tmp_users WHERE label = 'outsider'), true);
SELECT pg_temp.expect_api_error(
format($sql$
SELECT public.<domain>_<action>('%s', 'test');
$sql$, (SELECT home_id FROM tmp_homes WHERE label = 'primary')),
'NOT_HOME_MEMBER',
'Non-member cannot call <action>'
);
-- Test: Invalid input
SELECT set_config('request.jwt.claim.sub', (SELECT user_id::text FROM tmp_users WHERE label = 'owner'), true);
SELECT pg_temp.expect_api_error(
format($sql$
SELECT public.<domain>_<action>('%s', '');
$sql$, (SELECT home_id FROM tmp_homes WHERE label = 'primary')),
'INVALID_INPUT',
'Empty input rejected'
);
-- ============================================================
-- CLEANUP
-- ============================================================
SELECT * FROM finish();
ROLLBACK;
Step 6: Verify
# Run all checks
dart run tool/check_all.dart
# Run specific test
supabase test db --linked -- supabase/tests/<domain>_<feature>.sql
# Or run all DB tests
supabase test db --linked
Checklist
- Contract added to AGENTS.md § Contracts
- Migration created with
CREATE OR REPLACE FUNCTION - Uses
SECURITY DEFINER+SET search_path = '' - Calls
_assert_authenticated()first - Calls
_assert_home_member()for home-scoped RPCs - Uses
api_error()/api_assert()for validation - Uses
_home_assert_quota()before creating countable items - Uses
_home_usage_apply_delta()after successful mutations - Repository interface defined in
lib/contracts/ - Supabase implementation uses
SupabaseErrorMapper - pgTAP test covers: happy path, non-member denied, invalid input
-
dart run tool/check_all.dartpasses -
supabase test db --linkedpasses
スコア
総合スコア
リポジトリの品質指標に基づく評価
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
3ヶ月以内に更新がある
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
レビュー
レビュー機能は近日公開予定です