From d66e5cbbe06ce37805ab7b04cd62bccdd105d785 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 13 Mar 2026 18:50:52 +0000 Subject: [PATCH 1/2] [#2] Add Supabase schema migration and client helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-2a: SQL migration with storylines, plots, donations tables per §4.1. Includes UNIQUE(tx_hash, log_index), foreign keys, writer_type, hidden columns. P0-2b: lib/supabase.ts with browser (anon) and server (service role) clients. Fixes #2 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/supabase.ts | 173 +++++++++++++++++++++++++++ package-lock.json | 128 +++++++++++++++++++- package.json | 1 + supabase/migrations/00001_schema.sql | 66 ++++++++++ 4 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 lib/supabase.ts create mode 100644 supabase/migrations/00001_schema.sql diff --git a/lib/supabase.ts b/lib/supabase.ts new file mode 100644 index 00000000..7571ebd2 --- /dev/null +++ b/lib/supabase.ts @@ -0,0 +1,173 @@ +import { createClient, SupabaseClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ""; + +// Browser-side client (anon key, respects RLS) +export const supabase: SupabaseClient | null = + supabaseUrl && supabaseAnonKey + ? createClient(supabaseUrl, supabaseAnonKey) + : null; + +// Server-side client (service role, bypasses RLS) +export function createServerClient(): SupabaseClient | null { + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; + if (!supabaseUrl || !serviceRoleKey) return null; + + return createClient(supabaseUrl, serviceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); +} + +// --------------------------------------------------------------------------- +// Database types +// --------------------------------------------------------------------------- + +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export interface Database { + public: { + Tables: { + storylines: { + Row: { + id: number; + storyline_id: number; + writer_address: string; + token_address: string; + title: string; + plot_count: number; + last_plot_time: string | null; + has_deadline: boolean; + sunset: boolean; + writer_type: number; + hidden: boolean; + tx_hash: string; + log_index: number; + block_timestamp: string | null; + indexed_at: string; + }; + Insert: { + id?: never; + storyline_id: number; + writer_address: string; + token_address: string; + title: string; + plot_count?: number; + last_plot_time?: string | null; + has_deadline?: boolean; + sunset?: boolean; + writer_type?: number; + hidden?: boolean; + tx_hash: string; + log_index: number; + block_timestamp?: string | null; + indexed_at?: string; + }; + Update: { + id?: never; + storyline_id?: number; + writer_address?: string; + token_address?: string; + title?: string; + plot_count?: number; + last_plot_time?: string | null; + has_deadline?: boolean; + sunset?: boolean; + writer_type?: number; + hidden?: boolean; + tx_hash?: string; + log_index?: number; + block_timestamp?: string | null; + indexed_at?: string; + }; + }; + plots: { + Row: { + id: number; + storyline_id: number; + plot_index: number; + writer_address: string; + content_cid: string; + content_hash: string; + hidden: boolean; + tx_hash: string; + log_index: number; + block_timestamp: string | null; + indexed_at: string; + }; + Insert: { + id?: never; + storyline_id: number; + plot_index: number; + writer_address: string; + content_cid: string; + content_hash: string; + hidden?: boolean; + tx_hash: string; + log_index: number; + block_timestamp?: string | null; + indexed_at?: string; + }; + Update: { + id?: never; + storyline_id?: number; + plot_index?: number; + writer_address?: string; + content_cid?: string; + content_hash?: string; + hidden?: boolean; + tx_hash?: string; + log_index?: number; + block_timestamp?: string | null; + indexed_at?: string; + }; + }; + donations: { + Row: { + id: number; + storyline_id: number; + donor_address: string; + amount: string; + tx_hash: string; + log_index: number; + block_timestamp: string | null; + indexed_at: string; + }; + Insert: { + id?: never; + storyline_id: number; + donor_address: string; + amount: string; + tx_hash: string; + log_index: number; + block_timestamp?: string | null; + indexed_at?: string; + }; + Update: { + id?: never; + storyline_id?: number; + donor_address?: string; + amount?: string; + tx_hash?: string; + log_index?: number; + block_timestamp?: string | null; + indexed_at?: string; + }; + }; + }; + }; +} + +// Convenience type aliases +export type Storyline = Database["public"]["Tables"]["storylines"]["Row"]; +export type Plot = Database["public"]["Tables"]["plots"]["Row"]; +export type Donation = Database["public"]["Tables"]["donations"]["Row"]; diff --git a/package-lock.json b/package-lock.json index 0c6a24f9..93d55a13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "plotlink", "version": "0.1.0", "dependencies": { + "@supabase/supabase-js": "^2.99.1", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" @@ -1233,6 +1234,86 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.1.tgz", + "integrity": "sha512-x7lKKTvKjABJt/FYcRSPiTT01Xhm2FF8RhfL8+RHMkmlwmRQ88/lREupIHKwFPW0W6pTCJqkZb7Yhpw/EZ+fNw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.1.tgz", + "integrity": "sha512-WQE62W5geYImCO4jzFxCk/avnK7JmOdtqu2eiPz3zOaNiIJajNRSAwMMDgEGd2EMs+sUVYj1LfBjfmW3EzHgIA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.1.tgz", + "integrity": "sha512-gtw2ibJrADvfqrpUWXGNlrYUvxttF4WVWfPpTFKOb2IRj7B6YRWMDgcrYqIuD4ZEabK4m6YKQCCGy6clgf1lPA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.1.tgz", + "integrity": "sha512-9EDdy/5wOseGFqxW88ShV9JMRhm7f+9JGY5x+LqT8c7R0X1CTLwg5qie8FiBWcXTZ+68yYxVWunI+7W4FhkWOg==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.1.tgz", + "integrity": "sha512-mf7zPfqofI62SOoyQJeNUVxe72E4rQsbWim6lTDPeLu3lHija/cP5utlQADGrjeTgOUN6znx/rWn7SjrETP1dw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.1.tgz", + "integrity": "sha512-5MRoYD9ffXq8F6a036dm65YoSHisC3by/d22mauKE99Vrwf792KxYIIr/iqCX7E4hkuugbPZ5EGYHTB7MKy6Vg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.99.1", + "@supabase/functions-js": "2.99.1", + "@supabase/postgrest-js": "2.99.1", + "@supabase/realtime-js": "2.99.1", + "@supabase/storage-js": "2.99.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1549,12 +1630,17 @@ "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1575,6 +1661,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", @@ -3926,6 +4021,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6352,7 +6456,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -6546,6 +6649,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 8f0ee326..3baff89e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@supabase/supabase-js": "^2.99.1", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" diff --git a/supabase/migrations/00001_schema.sql b/supabase/migrations/00001_schema.sql new file mode 100644 index 00000000..da7c28cb --- /dev/null +++ b/supabase/migrations/00001_schema.sql @@ -0,0 +1,66 @@ +-- PlotLink schema (§4.1) +-- Tables: storylines, plots, donations + +-- --------------------------------------------------------------------------- +-- storylines +-- --------------------------------------------------------------------------- +create table storylines ( + id bigint generated always as identity primary key, + storyline_id bigint not null, + writer_address text not null, + token_address text not null, + title text not null, + plot_count integer not null default 0, + last_plot_time timestamptz, + has_deadline boolean not null default false, + sunset boolean not null default false, + writer_type smallint not null default 0, -- 0 = human, 1 = agent (set by indexer) + hidden boolean not null default false, -- MVP content moderation (§8) + tx_hash text not null, + log_index integer not null, + block_timestamp timestamptz, + indexed_at timestamptz not null default now(), + + constraint storylines_tx_unique unique (tx_hash, log_index) +); + +create index idx_storylines_writer on storylines (writer_address); + +-- --------------------------------------------------------------------------- +-- plots +-- --------------------------------------------------------------------------- +create table plots ( + id bigint generated always as identity primary key, + storyline_id bigint not null references storylines (id), + plot_index integer not null, + writer_address text not null, + content_cid text not null, + content_hash text not null, + hidden boolean not null default false, -- MVP content moderation (§8) + tx_hash text not null, + log_index integer not null, + block_timestamp timestamptz, + indexed_at timestamptz not null default now(), + + constraint plots_tx_unique unique (tx_hash, log_index) +); + +create index idx_plots_storyline on plots (storyline_id); + +-- --------------------------------------------------------------------------- +-- donations +-- --------------------------------------------------------------------------- +create table donations ( + id bigint generated always as identity primary key, + storyline_id bigint not null references storylines (id), + donor_address text not null, + amount text not null, -- wei string to avoid precision loss + tx_hash text not null, + log_index integer not null, + block_timestamp timestamptz, + indexed_at timestamptz not null default now(), + + constraint donations_tx_unique unique (tx_hash, log_index) +); + +create index idx_donations_storyline on donations (storyline_id); From 96356374198e62bfa9b48cce85976b153d5b1454 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 13 Mar 2026 18:54:26 +0000 Subject: [PATCH 2/2] [#2] Address T2a review: nullable writer_type, FK to on-chain storyline_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. writer_type: nullable (NULL=unclassified, 0=human, 1=agent) instead of default 0 — prevents silent misclassification on insert. 2. FK targets: plots/donations reference storylines(storyline_id) directly instead of surrogate id — indexer can insert with on-chain ID without an extra lookup. Added UNIQUE constraint on storylines.storyline_id. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/supabase.ts | 6 +++--- supabase/migrations/00001_schema.sql | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/supabase.ts b/lib/supabase.ts index 7571ebd2..65d9b777 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -48,7 +48,7 @@ export interface Database { last_plot_time: string | null; has_deadline: boolean; sunset: boolean; - writer_type: number; + writer_type: number | null; hidden: boolean; tx_hash: string; log_index: number; @@ -65,7 +65,7 @@ export interface Database { last_plot_time?: string | null; has_deadline?: boolean; sunset?: boolean; - writer_type?: number; + writer_type?: number | null; hidden?: boolean; tx_hash: string; log_index: number; @@ -82,7 +82,7 @@ export interface Database { last_plot_time?: string | null; has_deadline?: boolean; sunset?: boolean; - writer_type?: number; + writer_type?: number | null; hidden?: boolean; tx_hash?: string; log_index?: number; diff --git a/supabase/migrations/00001_schema.sql b/supabase/migrations/00001_schema.sql index da7c28cb..eecb10e7 100644 --- a/supabase/migrations/00001_schema.sql +++ b/supabase/migrations/00001_schema.sql @@ -14,14 +14,15 @@ create table storylines ( last_plot_time timestamptz, has_deadline boolean not null default false, sunset boolean not null default false, - writer_type smallint not null default 0, -- 0 = human, 1 = agent (set by indexer) + writer_type smallint, -- NULL = unclassified, 0 = human, 1 = agent (set by indexer) hidden boolean not null default false, -- MVP content moderation (§8) tx_hash text not null, log_index integer not null, block_timestamp timestamptz, indexed_at timestamptz not null default now(), - constraint storylines_tx_unique unique (tx_hash, log_index) + constraint storylines_tx_unique unique (tx_hash, log_index), + constraint storylines_onchain_unique unique (storyline_id) ); create index idx_storylines_writer on storylines (writer_address); @@ -31,7 +32,7 @@ create index idx_storylines_writer on storylines (writer_address); -- --------------------------------------------------------------------------- create table plots ( id bigint generated always as identity primary key, - storyline_id bigint not null references storylines (id), + storyline_id bigint not null references storylines (storyline_id), plot_index integer not null, writer_address text not null, content_cid text not null, @@ -52,7 +53,7 @@ create index idx_plots_storyline on plots (storyline_id); -- --------------------------------------------------------------------------- create table donations ( id bigint generated always as identity primary key, - storyline_id bigint not null references storylines (id), + storyline_id bigint not null references storylines (storyline_id), donor_address text not null, amount text not null, -- wei string to avoid precision loss tx_hash text not null,