# Melange documentation > Zero-copy authorization for PostgreSQL - OpenFGA-compatible permission checking that queries your existing domain tables directly. --- # Documentation Source: https://melange.sh/docs/ > Melange compiles OpenFGA authorization models into PostgreSQL functions, so permission checks run inside your existing database. Melange is an **OpenFGA-to-PostgreSQL compiler**. It takes your authorization model (written in OpenFGA DSL) and compiles it into specialized PostgreSQL functions that run permission checks directly inside your database. ## What is an Authorization Compiler? The pattern is similar to [Protocol Buffers](https://protobuf.dev/) or [GraphQL Code Generator](https://the-guild.dev/graphql/codegen): define a schema, run a compiler, get code tailored to your definitions. Melange applies this pattern to authorization: ```mermaid flowchart LR schema["schema.fga
(OpenFGA DSL)"] --> melange["melange
migrate"] melange --> funcs["PostgreSQL Functions
(check_permission, etc)"] ``` Instead of a generic runtime that interprets your model at query time, Melange generates a SQL function for each relation in your schema. The authorization logic runs as PostgreSQL code: transaction-aware, query-planned, and with zero network hops. ## How It Works 1. **Define your model** - Write an OpenFGA schema (`.fga` file) describing types, relations, and permission rules 2. **Compile to SQL** - Run `melange migrate` to generate and install specialized PostgreSQL functions 3. **Query permissions** - Call the generated functions from any language or directly in SQL Read more about Melange's architecture and how it works [here](https://melange.sh/docs/concepts/how-it-works.md). ## Why Compile to PostgreSQL? | Traditional FGA | Melange | |----------------|---------| | Separate authorization service | Authorization lives in your database | | Network round-trip for every check | Single SQL query | | Eventual consistency with your data | Transaction-aware, always consistent | | Generic graph traversal at runtime | Specialized functions per relation | | Sync tuples to external store | Query your existing tables directly | ## Key Features - **Per-Relation Code Generation** - Each relation gets its own check function, not a generic interpreter - **Works with Your Tables** - Permissions derived from a view over your existing data (no tuple sync) - **Transaction Aware** - Permission checks see uncommitted changes within the same transaction - **Language Agnostic** - Generated SQL functions callable from Go, Python, Node.js, or any PostgreSQL client - **OpenFGA Compatible** - Use familiar OpenFGA DSL syntax for authorization models ## Getting Started - [Installation](https://melange.sh/docs/getting-started/installation.md): Install the Melange CLI via Homebrew, Go, or binary download - [Quick Start](https://melange.sh/docs/getting-started/quick-start.md): From zero to a working permission check - [Project Setup](https://melange.sh/docs/getting-started/project-setup.md): Configure the init wizard, starter templates, and code generation ## Concepts Architecture and design. - [Key Components](https://melange.sh/docs/concepts/components.md): CLI, schema, tuples view, and runtime libraries - [How It Works](https://melange.sh/docs/concepts/how-it-works.md): Specialized SQL generation, precomputed closures, and permission checks that stay flat as data grows - [Authorization Modelling](https://melange.sh/docs/concepts/modelling.md): Write OpenFGA schemas to define your permission model - [Tuples View](https://melange.sh/docs/concepts/tuples-view.md): Map your domain tables to authorization tuples ## Guides Using Melange's client libraries, middleware patterns, and operational workflows. For direct SQL access, see the [SQL API reference](https://melange.sh/docs/reference/sql-api.md). - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md): Validate access using the Checker API - [Listing Objects](https://melange.sh/docs/guides/listing-objects.md): Find all objects a subject can access - [Listing Subjects](https://melange.sh/docs/guides/listing-subjects.md): Find all subjects with access to an object - [Caching](https://melange.sh/docs/guides/caching.md): In-memory, request-scoped, and custom cache strategies - [Using Coding Agents](https://melange.sh/docs/guides/coding-agents.md): Docs for agents and project instructions for your schema - [Troubleshooting](https://melange.sh/docs/guides/troubleshooting.md): Diagnose common issues with melange doctor ## Reference Technical reference documentation. - [Configuration](https://melange.sh/docs/reference/configuration.md): Config files, environment variables, and precedence - [CLI Reference](https://melange.sh/docs/reference/cli.md): Commands for migrations, code generation, and validation - [SQL API](https://melange.sh/docs/reference/sql-api.md): Generated SQL functions for permission checks - [Go API](https://melange.sh/docs/reference/go-api.md): Runtime types, Checker, bulk checks, caching, and decision overrides - [Generated Code](https://melange.sh/docs/reference/generated-code.md): What melange generate client produces - [Errors](https://melange.sh/docs/reference/errors.md): Sentinel errors, validation codes, and error helpers - [OpenFGA Compatibility](https://melange.sh/docs/reference/openfga-compatibility.md): Feature support table and migration guidance - [Performance](https://melange.sh/docs/reference/performance.md): Benchmark results, optimization strategies, and scaling guidance ## Contributing - [Contributing Guide](https://melange.sh/docs/contributing/index.md): Run tests, benchmarks, and understand the codebase --- # Getting Started Source: https://melange.sh/docs/getting-started/ > Install the Melange CLI, set up a project, and run your first permission check in PostgreSQL. This section covers installing the CLI, setting up a project, and running your first permission check. - [Installation](https://melange.sh/docs/getting-started/installation.md): Install the Melange CLI via Homebrew, Go, or binary download - [Quick Start](https://melange.sh/docs/getting-started/quick-start.md): From zero to a working permission check - [Project Setup](https://melange.sh/docs/getting-started/project-setup.md): Configure the init wizard, starter templates, and code generation - [Creating Your Tuples View](https://melange.sh/docs/getting-started/tuples-view.md): Map your existing database tables to authorization tuples - [Your First Migration](https://melange.sh/docs/getting-started/first-migration.md): Apply your schema and verify everything works --- # Installation Source: https://melange.sh/docs/getting-started/installation/ > Install the Melange CLI with Homebrew, go install, or a prebuilt binary for Linux, macOS, or Windows. Install the Melange CLI for schema validation, migrations, and code generation. ## Prerequisites - **PostgreSQL 14 or later**. Melange generates PL/pgSQL functions that require PostgreSQL 14+. ## Install the CLI **Homebrew:** ```bash brew install pthm/tap/melange ``` **Go:** ```bash go install github.com/pthm/melange@latest ``` Requires Go 1.27 or later. The CLI lives in the root module, so install `github.com/pthm/melange`, not `.../cmd/melange` (that path was retired in v0.9.0). **Binary:** Download pre-built binaries from the [GitHub releases page](https://github.com/pthm/melange/releases). Binaries are available for Linux, macOS, and Windows (amd64 and arm64). Windows builds ship as a `.zip`. Verify the installation: ```bash melange version ``` ## Updating Melange checks for updates in the background and prints a notice when a newer version is available. The check is non-blocking, cached for 24 hours, and disabled in CI environments (when the `CI` environment variable is set). To update: **Homebrew:** ```bash brew upgrade melange ``` **Go:** ```bash go install github.com/pthm/melange@latest ``` To disable update notifications: ```bash melange --no-update-check migrate ``` ## Next Steps - [Quick Start](https://melange.sh/docs/getting-started/quick-start.md) to run your first permission check - [Project Setup](https://melange.sh/docs/getting-started/project-setup.md) for configuration details --- # Quick Start Source: https://melange.sh/docs/getting-started/quick-start/ > The shortest path from an empty project to a working permission check in PostgreSQL with Melange. This is the shortest path to a working permission check. It uses the default settings; see [Project Setup](https://melange.sh/docs/getting-started/project-setup.md) for customization. You need the `melange` CLI (`brew install pthm/tap/melange` or `go install github.com/pthm/melange@latest`; see [Installation](https://melange.sh/docs/getting-started/installation.md)) and a PostgreSQL 14+ database. ## 1. Initialize Your Project ```bash melange init -y --db postgres://localhost:5432/mydb ``` The `--db` URL is saved to `melange/config.yaml` and used by every later command. Without it, `init -y` writes the default `postgres://localhost:5432/mydb`. You can also set `MELANGE_DATABASE_URL` or pass `--db` to `melange migrate`. This creates a `melange/` directory with a config file and starter schema: ``` myproject/ ├── melange/ │ ├── config.yaml │ └── schema.fga └── ... ``` The default schema defines organizations with role hierarchies and repositories with inherited permissions: ```fga model schema 1.1 type user type organization relations define owner: [user] define admin: [user] or owner define member: [user] or admin type repository relations define org: [organization] define owner: [user] define admin: [user] or owner define can_read: member from org or admin define can_write: admin define can_delete: owner ``` > **Note:** See [Project Setup](https://melange.sh/docs/getting-started/project-setup.md) for details on starter templates, migration strategies, and customization options. ## 2. Create the melange_tuples View Melange reads authorization data from a SQL view called `melange_tuples`. This view maps your existing tables into tuples. The view below expects `organization_members`, `repositories`, and `repository_owners` tables. If you are trying Melange on an empty database, create them with some sample rows first: ```sql CREATE TABLE organization_members (user_id text, organization_id text, role text); CREATE TABLE repositories (id text, organization_id text); CREATE TABLE repository_owners (user_id text, repository_id text); INSERT INTO organization_members VALUES ('alice', 'acme', 'member'); INSERT INTO repositories VALUES ('42', 'acme'); INSERT INTO repository_owners VALUES ('bob', '42'); ``` Then create the view: ```sql CREATE OR REPLACE VIEW melange_tuples AS -- Organization memberships SELECT 'user' AS subject_type, user_id::text AS subject_id, role AS relation, -- 'owner', 'admin', or 'member' 'organization' AS object_type, organization_id::text AS object_id FROM organization_members UNION ALL -- Repository -> Organization relationship SELECT 'organization' AS subject_type, organization_id::text AS subject_id, 'org' AS relation, 'repository' AS object_type, id::text AS object_id FROM repositories UNION ALL -- Direct repository owners SELECT 'user' AS subject_type, user_id::text AS subject_id, 'owner' AS relation, 'repository' AS object_type, repository_id::text AS object_id FROM repository_owners; ``` > **Note:** This view queries your existing tables directly, so there is no separate tuple store to keep in sync. See [Creating Your Tuples View](https://melange.sh/docs/getting-started/tuples-view.md) for a detailed walkthrough. ## 3. Apply Migrations ```bash melange migrate ``` This compiles your schema into PostgreSQL functions and installs them. To preview the generated SQL without applying: ```bash melange migrate --dry-run ``` ## 4. Check Permissions The migration installed a `check_permission` function. Try it in SQL: ```sql -- Returns 1 (allowed) or 0 (denied) SELECT check_permission('user', 'alice', 'can_read', 'repository', '42'); ``` Or from application code: **Go:** ```go import ( "context" "database/sql" _ "github.com/lib/pq" // any database/sql Postgres driver works "github.com/pthm/melange/melange" ) db, _ := sql.Open("postgres", "postgres://localhost/mydb") checker := melange.NewChecker(db) user := melange.Object{Type: "user", ID: "alice"} repo := melange.Object{Type: "repository", ID: "42"} allowed, err := checker.Check(context.Background(), user, "can_read", repo) ``` Install the runtime library (stdlib-only, no external dependencies): ```bash go get github.com/pthm/melange/melange ``` **TypeScript:** ```typescript import { Pool } from 'pg'; const pool = new Pool({ connectionString: 'postgresql://localhost/mydb' }); const { rows } = await pool.query( 'SELECT check_permission($1, $2, $3, $4, $5)', ['user', 'alice', 'can_read', 'repository', '42'] ); const allowed = rows[0].check_permission === 1; ``` **SQL:** ```sql -- Direct check SELECT check_permission('user', 'alice', 'can_read', 'repository', '42'); -- Filter query results SELECT r.* FROM repositories r JOIN list_accessible_objects('user', 'alice', 'can_read', 'repository', NULL, NULL) a ON r.id::text = a.object_id; ``` ## What `melange migrate` Did 1. Parsed your OpenFGA schema 2. Analyzed each relation to detect patterns (direct assignment, role hierarchy, parent inheritance) 3. Generated a SQL function for each type+relation pair (e.g., `check_repository_can_read`) 4. Installed a dispatcher function (`check_permission`) that routes to the correct per-relation function Permission checks are SQL function calls. There is no external service, no network round-trip, and no tuple synchronization. ## Next Steps - [Creating Your Tuples View](https://melange.sh/docs/getting-started/tuples-view.md): mapping your domain tables in detail - [Your First Migration](https://melange.sh/docs/getting-started/first-migration.md): validation, health checks, and external migration frameworks - [Project Setup](https://melange.sh/docs/getting-started/project-setup.md): templates, migration strategies, and code generation options - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md): Checker API, caching, bulk checks, and transactions - [How It Works](https://melange.sh/docs/concepts/how-it-works.md): the compiler model and generated SQL --- # Project Setup Source: https://melange.sh/docs/getting-started/project-setup/ > The melange init wizard in detail: starter templates, migration strategies, database settings, and client code generation. This page covers the `melange init` wizard in detail: what each prompt does, how the starter templates differ, and how to customize the setup. For the shortest path to a working setup, see [Quick Start](https://melange.sh/docs/getting-started/quick-start.md). ## The Init Wizard ```bash melange init ``` The wizard detects your project type and prompts for configuration values. If it finds a `go.mod`, it defaults to Go; if it finds a `package.json` (and no `go.mod`), it defaults to TypeScript. When both exist, Go takes precedence. ### Wizard Prompts The wizard collects these settings in order: | Prompt | Default | Description | |--------|---------|-------------| | Schema path | `melange/schema.fga` | Where to put your authorization model | | Starter model | Organization RBAC | Pre-built schema template | | Database URL | `postgres://localhost:5432/mydb` | PostgreSQL connection string | | Migration strategy | Built-in | How schema changes are applied | | Generate client code? | Yes (if project detected) | Whether to set up type-safe code generation | | Add Melange instructions for coding agents? | Yes (skipped with `-y` unless `--agents`) | Appends a Melange section to `AGENTS.md` (or `CLAUDE.md`). See [Coding Agent Instructions](#coding-agent-instructions). | If you choose **versioned** migration strategy, you'll also be asked: | Prompt | Default | Description | |--------|---------|-------------| | Migration output directory | `migrations/` | Where to write versioned SQL files | | File format | Split | Separate up/down files, or combined | | Migration name suffix | `melange` | Suffix for generated filenames | If you enable **client code generation**, you'll also be asked: | Prompt | Default | Description | |--------|---------|-------------| | Runtime | Auto-detected | `go` or `typescript` | | Output directory | `internal/authz` (Go) or `src/authz` (TypeScript) | Where to write generated code | | Package name | `authz` | Go package name (Go only) | | ID type | `string` | Type for object/subject IDs: `string`, `int64`, `uuid.UUID` | ## Starter Templates Four starter templates are available: **Organization RBAC:** The default template. Models organizations with a role hierarchy and repositories with inherited permissions. ```fga model schema 1.1 type user type organization relations define owner: [user] define admin: [user] or owner define member: [user] or admin type repository relations define org: [organization] define owner: [user] define admin: [user] or owner define can_read: member from org or admin define can_write: admin define can_delete: owner ``` Covers role hierarchies (`owner > admin > member`) and parent inheritance (`member from org`). A good starting point for SaaS applications with organizations, teams, and resources. **Document Sharing:** Permissions cascade: owners can edit, editors can view. ```fga model schema 1.1 type user type document relations define owner: [user] define editor: [user] or owner define viewer: [user] or editor ``` A good starting point for content or document-based applications. **Minimal:** Just a `user` type. Add your own types and relations from here. ```fga model schema 1.1 type user ``` **None:** Skips schema file creation. Use this if you already have a `.fga` file. The [Authorization Modelling](https://melange.sh/docs/concepts/modelling.md) guide covers additional relation patterns including exclusions, intersections, and wildcards. ## Migration Strategies The wizard asks you to choose how schema changes are applied to your database: | Strategy | Command | Description | |----------|---------|-------------| | **Built-in** | `melange migrate` | Connects directly and applies SQL. Suited to solo projects, prototyping, and simple deployments. | | **Versioned** | `melange generate migration` | Produces `.sql` files for your existing migration framework. Suited to teams, PR reviews, and CI/CD pipelines. | **Built-in** connects directly to PostgreSQL and applies SQL functions. It tracks changes via a `melange_migrations` table and skips unchanged schemas automatically. **Versioned** produces timestamped `.sql` files (e.g., `20240315_melange.up.sql`) that you apply with your existing migration tool (golang-migrate, Atlas, Flyway, etc.). This means schema changes go through code review in PRs like any other migration. You can change strategies later by updating your config file. See [Running Migrations](https://melange.sh/docs/guides/migrations.md) for details on comparison modes and CI workflows. ## Generated Config File After running `melange init`, the generated config file looks like this: ```yaml database: url: postgres://localhost:5432/mydb generate: client: id_type: string output: internal/authz package: authz runtime: go schema: melange/schema.fga ``` With versioned migrations enabled: ```yaml database: url: postgres://localhost:5432/mydb generate: client: id_type: string output: internal/authz package: authz runtime: go migration: format: split name: melange output: migrations/ schema: melange/schema.fga ``` ### Config File Placement The config file location depends on your schema path: - Schema under `melange/` (e.g., `melange/schema.fga`): config written to `melange/config.yaml` - Otherwise: config written to `melange.yaml` at the project root Melange discovers config files by searching up from the current directory. See [Configuration Reference](https://melange.sh/docs/reference/configuration.md) for the full discovery order and all options. ## Non-Interactive Mode Use `-y` to accept all defaults without prompting: ```bash melange init -y ``` Override individual values with flags: ```bash melange init -y \ --schema melange/auth.fga \ --db postgres://prod:5432/app \ --template doc-sharing \ --migration-strategy versioned ``` Skip runtime dependency installation: ```bash melange init -y --no-install ``` ### All Init Flags | Flag | Description | Default | |------|-------------|---------| | `-y`, `--yes` | Accept all defaults without prompting | | | `--no-install` | Skip installing runtime dependencies | | | `--schema` | Schema file path | `melange/schema.fga` | | `--db` | Database URL | `postgres://localhost:5432/mydb` | | `--template` | Starter model: `org-rbac`, `doc-sharing`, `minimal`, `none` | `org-rbac` | | `--runtime` | Client runtime: `go`, `typescript` | Auto-detected | | `--output` | Client output directory | `internal/authz` (Go), `src/authz` (TS) | | `--package` | Client package name (Go only) | `authz` | | `--id-type` | Client ID type: `string`, `int64`, `uuid.UUID` | `string` | | `--migration-strategy` | Migration strategy: `builtin`, `versioned` | `builtin` | | `--migration-output` | Versioned migration output directory | `migrations/` | | `--migration-format` | Versioned migration format: `split`, `single` | `split` | | `--migration-name` | Versioned migration name suffix | `melange` | | `--agents` | Add a Melange section to `AGENTS.md` / `CLAUDE.md` without asking (otherwise skipped with `-y`) | | ## Client Code Generation When code generation is enabled, `melange init` installs the runtime dependency automatically: - **Go**: `go get github.com/pthm/melange/melange` - **TypeScript**: Detected package manager (`bun` > `pnpm` > `yarn` > `npm`) runs `add @pthm/melange` To generate client code after setup: ```bash melange generate client ``` This produces type-safe constants and constructors from your schema. For example, with Go: ```go import "myapp/internal/authz" // Generated constants and constructors allowed, err := checker.Check(ctx, authz.User("alice"), authz.RelCanRead, authz.Repository("42"), ) ``` The `--filter` option (configurable in `generate.client.filter`) lets you limit which relations are generated. For example, `--filter can_` generates only relations starting with `can_`. ## Coding Agent Instructions `melange init` can add a short Markdown section (about 300 tokens) that tells coding agents (Claude Code, Codex, Cursor, and others) how this project uses Melange: where the schema and config live, that relationship data comes from the `melange_tuples` view rather than tuple writes, the commands to run after changing the model, and how to debug an unexpected permission result. The wizard asks before adding it. With `-y`, it is skipped unless you also pass `--agents`. The section goes into the instructions file your agent already reads: 1. `AGENTS.md`, if it exists 2. otherwise `CLAUDE.md`, if it exists 3. otherwise a new `AGENTS.md` Only one file is written, because a `CLAUDE.md` often just imports `AGENTS.md`. Nothing is written if either file already has the section. For an existing project, or after changing your schema path, client output, or migration strategy, generate the section from your config and append it yourself: ```bash melange init agent >> AGENTS.md ``` See [Using Coding Agents](https://melange.sh/docs/guides/coding-agents.md) for sample output and the docs formats agents can read. ## Next Steps - [Creating Your Tuples View](https://melange.sh/docs/getting-started/tuples-view.md): mapping your domain tables to authorization tuples - [Your First Migration](https://melange.sh/docs/getting-started/first-migration.md): applying your schema and verifying the setup --- # Creating Your Tuples View Source: https://melange.sh/docs/getting-started/tuples-view/ > Create the melange_tuples view that maps your existing domain tables into authorization tuples, with no tuple sync. Melange reads authorization data from a SQL view called `melange_tuples`. This view maps your existing domain tables into the tuple format that Melange expects. There is no separate tuple store to keep in sync. ## Required Columns The `melange_tuples` view must provide exactly these five columns, all of type `text`: | Column | Description | Example | |--------|-------------|---------| | `subject_type` | Type of the entity performing the action | `'user'` | | `subject_id` | ID of that entity | `'alice'` | | `relation` | The relationship name | `'member'` | | `object_type` | Type of the resource being accessed | `'repository'` | | `object_id` | ID of that resource | `'42'` | ## Building Your View Each `UNION ALL` section in the view maps one of your tables to tuples. Each section answers: what relationships does this table represent? ### Step 1: Identify Your Relationships Look at your schema and list the relationships that need to be represented. Using the default Organization RBAC template: | Source Table | Relationship | Subject | Object | |-------------|-------------|---------|--------| | `organization_members` | `owner`, `admin`, `member` | user → organization | | | `repositories` | `org` (parent link) | organization → repository | | | `repository_owners` | `owner` | user → repository | | ### Step 2: Write a UNION ALL for Each ```sql CREATE OR REPLACE VIEW melange_tuples AS -- Organization memberships: user is {role} of organization SELECT 'user' AS subject_type, user_id::text AS subject_id, role AS relation, 'organization' AS object_type, organization_id::text AS object_id FROM organization_members UNION ALL -- Parent link: repository belongs to organization SELECT 'organization' AS subject_type, organization_id::text AS subject_id, 'org' AS relation, 'repository' AS object_type, id::text AS object_id FROM repositories UNION ALL -- Direct owners: user owns repository SELECT 'user' AS subject_type, user_id::text AS subject_id, 'owner' AS relation, 'repository' AS object_type, repository_id::text AS object_id FROM repository_owners; ``` ### Step 3: Verify After creating the view, check that it returns data: ```sql SELECT * FROM melange_tuples LIMIT 10; ``` You should see rows like: | subject_type | subject_id | relation | object_type | object_id | |:------------|:----------|:--------|:-----------|:---------| | user | 1 | owner | organization | 5 | | user | 2 | member | organization | 5 | | organization | 5 | org | repository | 42 | ## Key Points - **Column types must be `text`**. Use `::text` casts for integer IDs. This is required for wildcard support (`'*'`) and mixed ID formats. - **Use `UNION ALL`**, not `UNION`. `UNION` deduplicates rows, which adds overhead unnecessarily since tuples are naturally distinct. - **Relation names must match your schema**. If your schema says `define member: [user]`, the view must produce `'member'` as the relation value, not `'is_member'` or `'membership'`. - **The view is live**. It queries your tables directly, so permission changes take effect when the underlying data changes. Within a transaction, permission checks see uncommitted changes. ## Next Steps - [Your First Migration](https://melange.sh/docs/getting-started/first-migration.md): applying your schema and running health checks - [Tuples View (Concepts)](https://melange.sh/docs/concepts/tuples-view.md): performance optimization, expression indexes, and scaling strategies --- # Your First Migration Source: https://melange.sh/docs/getting-started/first-migration/ > Validate your schema, apply your first Melange migration, and check the result with melange doctor. With your schema and tuples view in place, the next step is compiling the authorization model into PostgreSQL functions. ## Validate Your Schema Check that your schema is syntactically valid before migrating: ```bash melange validate ``` This parses the `.fga` file and reports syntax errors or cyclic dependencies. It does not require database access. ## Apply the Migration ```bash melange migrate ``` This connects to your database (using the URL from your config file or `--db` flag), compiles your schema, and installs the generated SQL functions. You'll see output like: ``` Migrating schema... ✓ Compiled 6 functions ✓ Installed check_permission dispatcher ✓ Installed list_accessible_objects ✓ Installed list_accessible_subjects Migration complete. ``` ### Preview Without Applying To see the generated SQL without making any changes: ```bash melange migrate --dry-run ``` This outputs the full SQL that would be executed, which is useful for review or debugging. ## Verify the Setup ### Check Migration Status ```bash melange status ``` Reports whether a migration has been applied and if the schema has changed since the last migration. ### Run Health Checks ```bash melange doctor ``` The doctor command runs these checks against your database: 1. **Schema File**: exists, parses correctly, no cyclic dependencies 2. **Migration State**: `melange_migrations` table exists, schema is in sync 3. **Generated Functions**: dispatcher and per-relation functions are present, no orphans 4. **Tuples Source**: `melange_tuples` view exists with the correct columns 5. **Data Health**: tuple count, type/relation validation against schema 6. **Performance**: view analysis (UNION ALL usage, missing expression indexes) For detailed output: ```bash melange doctor --verbose ``` ## Test a Permission Check Run a permission check to confirm the setup: ```sql SELECT check_permission('user', 'alice', 'can_read', 'repository', '42'); -- Returns 1 (allowed) or 0 (denied) ``` If this returns `0` and you expect `1`, check that: - The `melange_tuples` view returns the expected rows (`SELECT * FROM melange_tuples WHERE subject_id = 'alice'`) - The relation names in your view match your schema exactly ## Generate Client Code (Optional) If you configured client code generation during `melange init`, generate the type-safe helpers: ```bash melange generate client ``` **Go:** This creates files in your output directory (e.g., `internal/authz/`) with constants and constructors: ```go import "myapp/internal/authz" allowed, err := checker.Check(ctx, authz.User("alice"), authz.RelCanRead, authz.Repository("42"), ) ``` Regenerate after any schema change to keep the generated code in sync. **TypeScript:** This creates TypeScript types and factory functions in your output directory (e.g., `src/authz/`): ```typescript import { User, Repository, RelCanRead } from './authz'; const user = User('alice'); const repo = Repository('42'); ``` ## Using an External Migration Framework If you chose the **versioned** migration strategy during init (or prefer to use golang-migrate, Atlas, Flyway, etc.), generate SQL files instead: ```bash melange generate migration ``` This produces timestamped migration files in your output directory: ``` migrations/ ├── 20240315120000_melange.up.sql └── 20240315120000_melange.down.sql ``` Apply them with your framework. See [Running Migrations](https://melange.sh/docs/guides/migrations.md) for comparison modes and CI workflows. ## Next Steps - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md): Checker API, caching, bulk checks, decision overrides, error handling - [Listing Objects](https://melange.sh/docs/guides/listing-objects.md): find all objects a user can access - [Listing Subjects](https://melange.sh/docs/guides/listing-subjects.md): find all users with access to an object - [How It Works](https://melange.sh/docs/concepts/how-it-works.md): the compiler model and generated SQL - [SQL API Reference](https://melange.sh/docs/reference/sql-api.md): calling permission functions directly from any language - [CLI Reference](https://melange.sh/docs/reference/cli.md): full command and flag documentation --- # Concepts Source: https://melange.sh/docs/concepts/ > How Melange compiles OpenFGA schemas into SQL, how relationships are modelled, and how your tables act as the source of truth. How Melange transforms OpenFGA schemas into SQL functions, how permission relationships are modelled, and how your existing database tables serve as the source of truth for authorization data. - [Key Components](https://melange.sh/docs/concepts/components.md): CLI, schema, tuples view, and runtime libraries - [How It Works](https://melange.sh/docs/concepts/how-it-works.md): Architecture, specialized SQL generation, and precomputed closures - [Authorization Modelling](https://melange.sh/docs/concepts/modelling.md): Write OpenFGA schemas to define your permission model - [Tuples View](https://melange.sh/docs/concepts/tuples-view.md): Map your domain tables to authorization tuples --- # Key Components Source: https://melange.sh/docs/concepts/components/ > The four parts of a Melange setup: the CLI, the schema file, the melange_tuples view, and the optional runtime libraries. Melange has four components. The CLI and a schema file are required. The tuples view is created by you in your database. The runtime libraries are optional. ```mermaid flowchart TD schema["schema.fga"] --> cli["melange CLI"] cli --> sql["SQL Functions
(in PostgreSQL)"] tuples["melange_tuples view
(your tables)"] --> sql sql --> app["Your Application"] runtime["Runtime Library
(Go / TypeScript)"] -.-> app ``` ## CLI (Compiler) The `melange` CLI is the compiler. It reads your `.fga` schema, analyzes each relation, and generates specialized PostgreSQL functions. Each type+relation pair gets its own check function, plus dispatchers (`check_permission`, `list_accessible_objects`, `list_accessible_subjects`) that route calls to the correct function. The CLI also handles validation, health checks, and code generation: ```bash melange validate # Check the schema melange migrate # Compile schema and install SQL functions melange doctor # Verify everything is wired up correctly melange generate client # Generate type-safe client code melange generate migration # Produce versioned .sql files for external frameworks ``` The compilation happens at migration time, not at runtime. Once the functions are installed, the CLI is not involved in permission checks. - [Installation](https://melange.sh/docs/getting-started/installation.md) - [CLI Reference](https://melange.sh/docs/reference/cli.md) ## Schema (Authorization Model) The schema defines your authorization model using the [OpenFGA DSL](https://openfga.dev/docs/configuration-language). It declares types, relations, and the rules that connect them. ```fga model schema 1.1 type user type organization relations define owner: [user] define admin: [user] or owner define member: [user] or admin type repository relations define org: [organization] define owner: [user] define can_read: member from org or owner define can_write: owner ``` This schema is the input to the compiler. Each `define` statement produces a specialized SQL function. Role hierarchies (`admin: [user] or owner`) are resolved at compile time through transitive closure computation, so runtime checks avoid graph traversal. The schema file is typically stored at `melange/schema.fga` in your project. - [Authorization Modelling](https://melange.sh/docs/concepts/modelling.md) - [How It Works](https://melange.sh/docs/concepts/how-it-works.md) ## melange_tuples View The `melange_tuples` view is a SQL view you create over your existing domain tables. It maps your data into the tuple format (`subject_type`, `subject_id`, `relation`, `object_type`, `object_id`) that the generated functions query. ```sql CREATE OR REPLACE VIEW melange_tuples AS SELECT 'user' AS subject_type, user_id::text AS subject_id, role AS relation, 'organization' AS object_type, organization_id::text AS object_id FROM organization_members UNION ALL SELECT 'organization' AS subject_type, organization_id::text AS subject_id, 'org' AS relation, 'repository' AS object_type, id::text AS object_id FROM repositories; ``` This is the relationship graph. The generated SQL functions query this view to resolve permissions. Because it's a view over your tables (not a separate store), permissions are always in sync with your data and permission checks within a transaction see uncommitted changes. You construct this view. Melange does not create or manage it. - [Creating Your Tuples View](https://melange.sh/docs/getting-started/tuples-view.md): step-by-step setup - [Tuples View](https://melange.sh/docs/concepts/tuples-view.md): schema reference, indexing, and optimization ## Runtime Libraries (Optional) The generated SQL functions are callable from any language via standard PostgreSQL queries. The runtime libraries provide a typed Go or TypeScript API on top of those functions. **Without a runtime library** (any language): ```sql SELECT check_permission('user', 'alice', 'can_read', 'repository', '42'); ``` **With the Go runtime library**: ```go checker := melange.NewChecker(db) allowed, err := checker.Check(ctx, user, "can_read", repo) ``` The runtime library adds: - **Caching**. In-memory cache with configurable TTL (about 120 ns cached, against about 0.4 to 0.7 ms uncached). - **Bulk checks**. Check up to 10,000 permissions in a single SQL call. - **Contextual tuples**. Inject temporary tuples at check time for request-scoped authorization data. - **Decision overrides**. Bypass the database for testing or admin tools. - **List operations**. Paginated listing of accessible objects or subjects. - **Error mapping**. PostgreSQL errors mapped to typed Go/TypeScript errors. **Type generation** (`melange generate client`) produces constants and constructors from your schema, adding compile-time safety: ```go import "myapp/internal/authz" allowed, err := checker.Check(ctx, authz.User("alice"), // type-safe constructor authz.RelCanRead, // generated constant authz.Repository("42"), ) ``` The runtime libraries are optional. If your stack doesn't use Go or TypeScript, use the SQL functions directly from any PostgreSQL client. - [Go API](https://melange.sh/docs/reference/go-api.md) - [TypeScript API](https://melange.sh/docs/reference/typescript-api.md) - [SQL API](https://melange.sh/docs/reference/sql-api.md) - [Generated Code](https://melange.sh/docs/reference/generated-code.md) ## Next Steps - [Quick Start](https://melange.sh/docs/getting-started/quick-start.md): all four components working together in 5 minutes - [How It Works](https://melange.sh/docs/concepts/how-it-works.md): the compilation pipeline in detail --- # How It Works Source: https://melange.sh/docs/concepts/how-it-works/ > How Melange compiles an OpenFGA model into PostgreSQL functions that run permission checks inside your database. Melange is an **OpenFGA-to-PostgreSQL compiler**. It reads your authorization model and compiles it into SQL functions that execute permission checks directly in your database. No external service, no network hops, no tuple synchronization. ## The Compiler Model Like [Protocol Buffers](https://protobuf.dev/) compiles `.proto` files into language-specific serialization code, Melange compiles `.fga` files into PostgreSQL functions. The key difference from traditional FGA implementations is **when** the schema is processed: | Approach | Schema Processing | Runtime Behavior | |----------|-------------------|------------------| | **Traditional FGA** | Interpreted at query time | Generic graph traversal | | **Melange** | Compiled at migration time | Purpose-built SQL functions | This compilation step is what enables Melange's performance: each relation in your schema gets its own function rather than passing through a generic interpreter. ## Compilation Pipeline ```mermaid flowchart LR subgraph Input schema["schema.fga
(OpenFGA DSL)"] end subgraph Compiler melange["melange migrate"] end subgraph Output["PostgreSQL Database"] funcs["Specialized check functions"] dispatch["Dispatcher function"] end schema --> melange melange --> funcs melange --> dispatch ``` ### Compile Time When you run `melange migrate` (or `melange generate migration`), the compiler: 1. **Parses** your OpenFGA schema 2. **Analyzes** relation patterns (direct, implied, union, intersection, etc.) 3. **Computes** the transitive closure of role hierarchies 4. **Generates** specialized SQL functions for each relation 5. **Installs** the functions into PostgreSQL (`migrate`) or writes them as versioned SQL files (`generate migration`) See [Running Migrations](https://melange.sh/docs/guides/migrations.md) for guidance on choosing between the two approaches. ### Runtime Permission checks are just SQL queries: ```sql SELECT check_permission('user', 'alice', 'can_read', 'document', '123'); -- Returns 1 (allowed) or 0 (denied) ``` The generated functions query a `melange_tuples` view that you define over your existing domain tables. No separate tuple storage is required. ## What the Compiler Generates For a schema like: ```fga model schema 1.1 type user type folder relations define owner: [user] define viewer: [user] or owner type document relations define parent: [folder] define owner: [user] define editor: [user] or owner define viewer: [user] or editor or viewer from parent ``` Melange compiles this into: | Generated Function | Purpose | | ------------------------- | ----------------------------------------------------------------- | | `check_folder_owner()` | Direct tuple lookup for folder owners | | `check_folder_viewer()` | Union check: direct assignment OR implied by owner | | `check_document_owner()` | Direct tuple lookup for document owners | | `check_document_editor()` | Union check: direct assignment OR implied by owner | | `check_document_viewer()` | Complex check: direct, editor hierarchy, AND parent folder access | | `check_permission()` | Dispatcher that routes to specialized functions | ### Pattern-Specific Code Generation The compiler recognizes authorization patterns and generates code specific to each: | Pattern | Schema Syntax | Generated Code | |---------|--------------|----------------| | Direct assignment | `[user]` | Simple `EXISTS` check | | Role hierarchy | `viewer: owner` | Closure inlined as `IN (...)` | | Union | `[user] or owner` | OR'd `EXISTS` clauses | | Tuple-to-userset | `viewer from parent` | Recursive lookup via parent | | Exclusion | `but not blocked` | Access check with exclusion | | Intersection | `writer and editor` | All conditions must match | ### Example: Compiled Function For `document.viewer: [user] or editor or viewer from parent`, the compiler generates: ```sql CREATE OR REPLACE FUNCTION check_document_viewer( p_subject_type TEXT, p_subject_id TEXT, p_object_id TEXT, p_visited TEXT[] DEFAULT ARRAY[]::TEXT[] ) RETURNS INTEGER AS $$ BEGIN -- Cycle detection for recursive patterns IF p_object_id = ANY(p_visited) THEN RETURN 0; END IF; -- Direct assignment or implied by editor hierarchy IF EXISTS( SELECT 1 FROM melange_tuples t WHERE t.object_type = 'document' AND t.object_id = p_object_id AND t.relation IN ('viewer', 'editor', 'owner') AND t.subject_type = p_subject_type AND (t.subject_id = p_subject_id OR t.subject_id = '*') ) THEN RETURN 1; END IF; -- Tuple-to-userset: viewer from parent IF EXISTS( SELECT 1 FROM melange_tuples link WHERE link.object_type = 'document' AND link.object_id = p_object_id AND link.relation = 'parent' AND check_permission_internal( p_subject_type, p_subject_id, 'viewer', link.subject_type, link.subject_id, array_append(p_visited, p_object_id) ) = 1 ) THEN RETURN 1; END IF; RETURN 0; END; $$ LANGUAGE plpgsql STABLE; ``` Notice how the compiler has: - Inlined the closure (`'viewer', 'editor', 'owner'`) rather than computing it at runtime - Generated pattern-specific code for tuple-to-userset traversal - Added cycle detection for recursive patterns ## The Dispatcher The `check_permission()` function acts as a router, dispatching to the appropriate specialized function: ```sql CREATE OR REPLACE FUNCTION check_permission( p_subject_type TEXT, p_subject_id TEXT, p_relation TEXT, p_object_type TEXT, p_object_id TEXT ) RETURNS INTEGER AS $$ SELECT CASE WHEN p_object_type = 'document' AND p_relation = 'viewer' THEN check_document_viewer(p_subject_type, p_subject_id, p_object_id) WHEN p_object_type = 'document' AND p_relation = 'editor' THEN check_document_editor(p_subject_type, p_subject_id, p_object_id) WHEN p_object_type = 'folder' AND p_relation = 'viewer' THEN check_folder_viewer(p_subject_type, p_subject_id, p_object_id) -- ... more cases ELSE 0 -- Unknown type/relation: deny by default END; $$ LANGUAGE sql STABLE; ``` This eliminates runtime schema interpretation and allows PostgreSQL's query planner to optimize each specialized function independently. ## Precomputed Relation Closure Role hierarchies are resolved at compile time, not runtime. For a schema with: ```fga define owner: [user] define admin: [user] or owner define member: [user] or admin ``` The compiler precomputes the transitive closure: | relation | satisfying_relation | | -------- | ------------------- | | member | member | | member | admin | | member | owner | | admin | admin | | admin | owner | | owner | owner | This closure is inlined directly into the generated SQL. Checking "does user have member?" becomes a simple `IN ('member', 'admin', 'owner')` clause rather than recursive function calls. ## The melange_tuples View Melange reads authorization data from a view you define over your existing tables: ```sql CREATE VIEW melange_tuples AS -- Team memberships SELECT 'user' AS subject_type, user_id::text AS subject_id, role AS relation, 'folder' AS object_type, folder_id::text AS object_id FROM folder_members UNION ALL -- Document -> Folder relationship SELECT 'folder' AS subject_type, folder_id::text AS subject_id, 'parent' AS relation, 'document' AS object_type, id::text AS object_id FROM documents; ``` This approach means: - **Zero tuple sync**: No separate tuple storage to maintain - **Transaction awareness**: Permission checks see uncommitted changes - **Real-time consistency**: Tuples always reflect current database state See [Tuples View](https://melange.sh/docs/concepts/tuples-view.md) for detailed guidance on mapping your domain tables. ## OpenFGA Compatibility Melange provides **full OpenFGA Schema 1.1 compatibility** (excluding conditions). The same `.fga` schema files work with both Melange and OpenFGA, continuously validated against the official OpenFGA test suite. - [OpenFGA Compatibility](https://melange.sh/docs/reference/openfga-compatibility.md): Supported features, limitations, and behavioral compatibility details ## Performance A permission check is one SQL call. On a managed PostgreSQL instance, measured from an application VM in the same zone, a check spends about 0.1 ms in the database and takes about 0.4 to 0.7 ms end to end, most of it the network round trip. Check latency stayed roughly flat across the measured fixtures, from thousands to two million tuples. This is a measured workload result, not a constant-time guarantee: recursive relations can still require traversal, and graph depth, fan-out, and indexes matter. List operations cost more: they grow with the part of the permission graph they walk. - [Performance Guide](https://melange.sh/docs/reference/performance.md): Detailed benchmarks, optimization strategies, and caching configuration ## Summary Melange's compiler architecture has four key properties: 1. **Compile-time specialization**: A dedicated SQL function for each relation 2. **Precomputed closure**: Role hierarchies resolved before runtime 3. **View-based tuples**: Direct queries against your existing tables 4. **Native SQL execution**: Uses PostgreSQL's query optimizer The measured kitchen-sink checks spend about 0.06 to 0.13 ms in the database, with OpenFGA Schema 1.1 compatibility (excluding conditions), without requiring a separate authorization service. --- # Authorization Modelling Source: https://melange.sh/docs/concepts/modelling/ > OpenFGA DSL syntax as supported by Melange: types, relations, unions, intersections, exclusions, wildcards, and parent lookups. Melange uses [OpenFGA](https://openfga.dev)'s DSL syntax to define authorization models. This page covers the DSL syntax. For a step-by-step walkthrough with end-to-end examples, see the [Modelling Guide](https://melange.sh/docs/guides/modelling-guide.md). For comprehensive modelling guidance, see the [OpenFGA documentation](https://openfga.dev/docs/modeling). ## Schema Structure A Melange schema file (`schema.fga`) has this structure: ```fga model schema 1.1 type user type organization relations define owner: [user] define admin: [user] or owner define member: [user] or admin ``` Key elements: - **`model`** - Required header - **`schema 1.1`** - Schema version (Melange supports 1.1) - **`type`** - Defines an object type (users, resources, etc.) - **`relations`** - Permissions and roles for that type ## Types Types represent entities in your system: ```fga type user type organization type repository type team ``` Types with no relations (like `user`) are typically subjects - the "who" in permission checks. ## Relations Relations define how subjects connect to objects: ### Direct Assignment Allow specific subject types to be directly assigned: ```fga type document relations define owner: [user] define editor: [user, team] ``` - `[user]` - Only users can be directly assigned - `[user, team]` - Users or teams can be assigned ### Role Hierarchy (or) Build role hierarchies where higher roles imply lower ones: ```fga type organization relations define owner: [user] define admin: [user] or owner # admins + all owners define member: [user] or admin # members + all admins (+ owners) ``` The `or` keyword creates a union - permission is granted if ANY condition matches. ### Parent Inheritance (from) Inherit permissions from a related parent object: ```fga type repository relations define org: [organization] # parent relationship define can_read: can_read from org # inherit from org define can_write: can_write from org ``` The `from` keyword creates a tuple-to-userset relationship: 1. Look up the `org` relation to find the parent organization 2. Check `can_read` on that organization ### Exclusion (but not) Deny permission when a condition is true: ```fga type pull_request relations define repo: [repository] define author: [user] define can_review: can_read from repo but not author ``` Users can review if they have `can_read` on the repo AND are NOT the author. ### Wildcard/Public Access Allow public access using wildcards: ```fga type repository relations define public: [user:*] define can_read: public or member ``` The `[user:*]` syntax means "any user". In your tuples view, map public resources to `subject_id = '*'`. ## Common Patterns ### Organization with Teams ```fga model schema 1.1 type user type team relations define member: [user] type organization relations define owner: [user] define admin: [user, team#member] or owner define member: [user, team#member] or admin type repository relations define org: [organization] define can_read: member from org define can_write: admin from org define can_delete: owner from org ``` The `team#member` syntax means "users who are members of a team". ### Document Sharing ```fga model schema 1.1 type user type folder relations define owner: [user] define viewer: [user] or owner type document relations define parent: [folder] define owner: [user] define editor: [user] or owner define viewer: [user] or editor or viewer from parent ``` Documents inherit viewer access from their parent folder. ### Multi-Tenant SaaS ```fga model schema 1.1 type user type tenant relations define admin: [user] define member: [user] or admin type project relations define tenant: [tenant] define owner: [user] define member: [user] or owner or member from tenant define can_read: member define can_write: owner or admin from tenant ``` ## Validation Validate your schema: ```bash melange validate --schema schemas/schema.fga ``` This checks syntax, references to undefined types and relations, unsupported features, and relation cycles, without a database. Add `--format json` for machine-readable output. To run OpenFGA model tests (`.fga.yaml` files with tuples and assertions), use the OpenFGA CLI: ```bash go install github.com/openfga/cli/cmd/fga@latest fga model test --tests schemas/schema.fga.yaml ``` ## Best Practices ### 1. Start with Roles, Add Permissions Define roles first, then derive permissions from them: ```fga type repository relations # Roles (assigned directly) define owner: [user] define admin: [user] or owner define writer: [user] or admin define reader: [user] or writer # Permissions (derived from roles) define can_read: reader define can_write: writer define can_delete: owner ``` ### 2. Use Consistent Naming - **Roles**: `owner`, `admin`, `member`, `viewer` - **Permissions**: `can_read`, `can_write`, `can_delete`, `can_invite` - **Parent relations**: `org`, `parent`, `folder`, `repo` ### 3. Avoid Deep Inheritance Chains Each `from` hop adds latency. Keep inheritance shallow: ```fga # Good: 1 hop define can_read: can_read from org # Avoid: Multiple hops define can_read: can_read from parent from grandparent ``` ### 4. Use Exclusion Sparingly Exclusion patterns (`but not`) are slower than positive checks. Use them only when necessary: ```fga # Necessary: prevent self-review define can_review: can_read from repo but not author # Consider alternatives for simple cases ``` ## Further Reading - [OpenFGA Modelling Guide](https://openfga.dev/docs/modeling) - Comprehensive modelling documentation - [OpenFGA Playground](https://play.fga.dev) - Interactive schema editor and testing - [Building Blocks](https://openfga.dev/docs/modeling/building-blocks) - Detailed explanation of relations - [OpenFGA Compatibility](https://melange.sh/docs/reference/openfga-compatibility.md) - What features Melange supports --- # Tuples View Source: https://melange.sh/docs/concepts/tuples-view/ > The melange_tuples view schema, query patterns for mapping domain tables to tuples, and indexing for performance. > **Note:** This page covers the tuples view schema, query patterns, and performance optimization. If you haven't created your first view yet, start with [Creating Your Tuples View](https://melange.sh/docs/getting-started/tuples-view.md). The `melange_tuples` view is the bridge between your application's domain tables and Melange's permission checking functions. It provides a unified tuple format that the generated SQL functions query at runtime. ## Zero Tuple Sync Melange queries your existing tables through the view rather than maintaining a separate tuple store. This means: - Permissions are always in sync with your domain data. - No replication lag or sync failures. - Changes to domain tables immediately affect permissions. - Permission checks within a transaction see uncommitted changes. ## View Schema The view must provide exactly these five columns, all of type `text`: | Column | Type | Description | |--------|------|-------------| | `subject_type` | `text` | Type of the subject (e.g., `'user'`, `'team'`) | | `subject_id` | `text` | ID of the subject | | `relation` | `text` | The relation name (e.g., `'owner'`, `'member'`, `'org'`) | | `object_type` | `text` | Type of the object (e.g., `'organization'`, `'repository'`) | | `object_id` | `text` | ID of the object | ### Why TEXT Columns? All ID columns must be TEXT for two reasons: 1. **Wildcard support**. Melange uses `'*'` as a subject_id for public access (e.g., `user:*`). This cannot be stored in integer or UUID columns. 2. **ID type flexibility**. Your tables may use integers, UUIDs, or strings. TEXT accommodates all of them via `::text` casts. The `::text` conversion prevents PostgreSQL from using your existing integer indexes. See [Expression Indexes](#expression-indexes) below. ## Wildcard Subjects To grant public access, use `'*'` as the subject_id: ```sql SELECT 'user' AS subject_type, '*' AS subject_id, 'reader' AS relation, 'repository' AS object_type, id::text AS object_id FROM repositories WHERE is_public = true ``` The generated `check_permission` function automatically checks for both the specific subject_id and `'*'`. ## Query Patterns The generated SQL functions use these access patterns against the view: **Direct tuple check** (most common): ```sql SELECT 1 FROM melange_tuples WHERE object_type = ? AND object_id = ? AND relation = ? AND subject_type = ? AND (subject_id = ? OR subject_id = '*') ``` **Parent relation lookup** (for `viewer from parent` patterns): ```sql SELECT subject_type, subject_id FROM melange_tuples WHERE object_type = ? AND object_id = ? AND relation = ? ``` **List operations seed**: ```sql SELECT object_id FROM melange_tuples WHERE subject_type = ? AND subject_id = ? AND relation = ? AND object_type = ? ``` Understanding these patterns is key to choosing the right indexes. ## Recommended Indexes Since `melange_tuples` is a view, you cannot index it directly. Create indexes on the underlying source tables. ### Object-Based Lookup For "does user X have role Y on object Z?" queries: ```sql CREATE INDEX idx_org_members_lookup ON organization_members (organization_id, role, user_id); ``` ### Subject-Based Lookup For list operations ("what objects does user X have role Y on?"): ```sql CREATE INDEX idx_org_members_user ON organization_members (user_id, role, organization_id); ``` ### Parent Relationship Lookup For tables defining parent-child relationships: ```sql CREATE INDEX idx_repos_parent ON repositories (id, organization_id); ``` ## Expression Indexes When your view casts integer IDs to text (`id::text`), PostgreSQL cannot use your existing integer indexes for those lookups. Expression indexes solve this: ```sql CREATE INDEX idx_org_members_text ON organization_members ((organization_id::text), (user_id::text)); ``` With this index, queries through the view like `WHERE object_id = '123'` use an index scan instead of a sequential scan. ### When to Add Them - Your view uses `::text` casts on integer or UUID columns. - `EXPLAIN ANALYZE` shows sequential scans with filters like `Filter: ((id)::text = '123'::text)`. - `melange doctor` reports missing expression indexes. Run `ANALYZE` after creating expression indexes to update query planner statistics. `melange doctor` detects missing expression indexes and provides exact `CREATE INDEX` statements. ## Verifying Your View ```sql -- Check the view returns data SELECT * FROM melange_tuples LIMIT 10; -- Verify a specific permission SELECT check_permission('user', '123', 'can_read', 'repository', '456'); -- Analyze query performance EXPLAIN ANALYZE SELECT * FROM melange_tuples WHERE object_type = 'repository' AND object_id = '456'; ``` Look for `Seq Scan` in the `EXPLAIN ANALYZE` output. Sequential scans on large tables indicate missing indexes. ## Common Issues **"relation melange_tuples does not exist"**: the view hasn't been created. See [Creating Your Tuples View](https://melange.sh/docs/getting-started/tuples-view.md). **Permissions not updating**: check that domain table changes are committed, and that the view query correctly maps your columns. Within a transaction, permission checks see uncommitted changes. **Slow permission checks**: add expression indexes for `::text` columns, add standard indexes on source tables, and consider scaling strategies. Run `melange doctor` for specific recommendations. ## Next Steps - [Creating Your Tuples View](https://melange.sh/docs/getting-started/tuples-view.md): step-by-step setup for your first view - [Performance](https://melange.sh/docs/reference/performance.md): benchmark data and optimization guidance --- # Guides Source: https://melange.sh/docs/guides/ > Task-oriented guides for checking and listing permissions, migrations, caching, testing, and troubleshooting Melange. Task-oriented guides for using Melange's client libraries, SQL functions, and operational patterns. Each guide includes examples in Go, TypeScript, and SQL where applicable. For direct SQL function signatures, see the [SQL API reference](https://melange.sh/docs/reference/sql-api.md). - [Modelling Guide](https://melange.sh/docs/guides/modelling-guide.md): Build an authorization model from scratch with end-to-end examples - [Running Migrations](https://melange.sh/docs/guides/migrations.md): Built-in vs versioned strategies, CI/CD integration, schema evolution - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md): Validate access using the Checker API - [Listing Objects](https://melange.sh/docs/guides/listing-objects.md): Find all objects a subject can access - [Listing Subjects](https://melange.sh/docs/guides/listing-subjects.md): Find all subjects with access to an object - [Explaining Decisions](https://melange.sh/docs/guides/explaining-decisions.md): Resolution traces for `why did this check return what it returned?` - [Expanding Permissions](https://melange.sh/docs/guides/expanding-permissions.md): OpenFGA-shaped UsersetTree for `who has access?` - [Contextual Tuples](https://melange.sh/docs/guides/contextual-tuples.md): Inject temporary tuples at check time for request context - [Caching](https://melange.sh/docs/guides/caching.md): In-memory, request-scoped, and custom cache strategies - [Scaling](https://melange.sh/docs/guides/scaling.md): Expression indexes, materialized views, and dedicated tables - [Testing Authorization](https://melange.sh/docs/guides/testing-authorization.md): Write integration tests for your permission setup - [Studio](https://melange.sh/docs/guides/studio.md): A local, read-only web console for a melange database - [Using Coding Agents](https://melange.sh/docs/guides/coding-agents.md): Docs for agents and project instructions for your schema - [Troubleshooting](https://melange.sh/docs/guides/troubleshooting.md): Diagnose common issues with melange doctor --- # Modelling Guide Source: https://melange.sh/docs/guides/modelling-guide/ > Build an OpenFGA authorization model for Melange from scratch, with the schema, tuples view, and checks together. Melange uses the [OpenFGA DSL](https://openfga.dev/docs/configuration-language) to define authorization models. This guide walks through building a model from scratch, with full examples showing the schema, tuples view, and permission checks together. For DSL syntax reference, see [Authorization Modelling](https://melange.sh/docs/concepts/modelling.md). For comprehensive modelling guidance, see the [OpenFGA modelling documentation](https://openfga.dev/docs/modeling). ## Core ideas Authorization in Melange is based on relationships between entities. Instead of asking "does user X have role Y", you ask "does user X have relation Y with object Z". Three things define your authorization model: 1. **Types** are the entities in your system: users, documents, organizations. 2. **Relations** define how entities connect: `owner`, `viewer`, `member`. 3. **Tuples** are the data. Each tuple is a fact: "alice is an owner of document:1". You write the types and relations in a `.fga` schema file. The tuples come from your existing database tables via the `melange_tuples` view. Melange compiles the schema into SQL functions that query the view at runtime. For background on relationship-based access control and how it compares to RBAC, see the [OpenFGA introduction](https://openfga.dev/docs/fga). ## Building a model step by step This section builds a document-sharing model from scratch. The goal: users can own documents, share them for editing or viewing, and organize them into folders that grant view access to all documents inside. ### Define the types Start by listing the entities involved: ```fga model schema 1.1 type user type folder type document ``` `user` has no relations because it is always the subject (the "who") in a permission check, never the object. ### Add direct relations Add relations for direct assignment. The type restriction in brackets specifies what types can be assigned: ```fga type folder relations define owner: [user] type document relations define owner: [user] define editor: [user] define viewer: [user] ``` With this schema, you can assign a user as an `owner`, `editor`, or `viewer` of a specific document. Each assignment requires an explicit tuple in the database. ### Add role hierarchy Owners should automatically be editors, and editors should automatically be viewers. Use `or` to create a union: ```fga type document relations define owner: [user] define editor: [user] or owner define viewer: [user] or editor ``` Now a user who is an `owner` is implicitly an `editor` and `viewer` too. Melange resolves this chain at compile time, so there is no runtime traversal. ### Add parent inheritance Documents belong to folders. Users who can view a folder should be able to view its documents. Use `from` to inherit permissions from a parent: ```fga type folder relations define owner: [user] define viewer: [user] or owner type document relations define parent: [folder] define owner: [user] define editor: [user] or owner define viewer: [user] or editor or viewer from parent ``` `viewer from parent` means: look up the document's `parent` relation to find the folder, then check `viewer` on that folder. If the user is a viewer of the parent folder, they are a viewer of the document. ### The complete schema ```fga model schema 1.1 type user type folder relations define owner: [user] define viewer: [user] or owner type document relations define parent: [folder] define owner: [user] define editor: [user] or owner define viewer: [user] or editor or viewer from parent ``` ### Map it to your database Create a `melange_tuples` view that maps your existing tables to tuples. Each `UNION ALL` section produces tuples for one relationship: ```sql CREATE OR REPLACE VIEW melange_tuples AS -- Folder ownership SELECT 'user' AS subject_type, user_id::text AS subject_id, 'owner' AS relation, 'folder' AS object_type, folder_id::text AS object_id FROM folder_owners UNION ALL -- Document ownership SELECT 'user' AS subject_type, user_id::text AS subject_id, 'owner' AS relation, 'document' AS object_type, document_id::text AS object_id FROM documents WHERE user_id IS NOT NULL UNION ALL -- Document editors (from a sharing table) SELECT 'user' AS subject_type, user_id::text AS subject_id, 'editor' AS relation, 'document' AS object_type, document_id::text AS object_id FROM document_shares WHERE role = 'editor' UNION ALL -- Document viewers (from the same sharing table) SELECT 'user' AS subject_type, user_id::text AS subject_id, 'viewer' AS relation, 'document' AS object_type, document_id::text AS object_id FROM document_shares WHERE role = 'viewer' UNION ALL -- Document -> Folder parent link SELECT 'folder' AS subject_type, folder_id::text AS subject_id, 'parent' AS relation, 'document' AS object_type, id::text AS object_id FROM documents WHERE folder_id IS NOT NULL; ``` Note the parent link at the bottom. For `from` relations to work, the tuples view must include a tuple that connects the child to the parent. Here, each document row with a `folder_id` produces a tuple like `(folder, 5, parent, document, 12)`. ### Check permissions After running `melange migrate`, you can check permissions: ```sql -- Can alice view document 12? SELECT check_permission('user', 'alice', 'viewer', 'document', '12'); ``` If alice owns folder 5, and document 12 is in folder 5, this returns 1. Melange follows the `from` chain automatically: it finds that document 12 has `parent = folder:5`, then checks that alice is a `viewer` (or `owner`) of folder 5. ## How indirect relationships resolve This section addresses a common question: how do you check permissions that flow through an intermediate entity when you only have the subject and the target object? Consider this scenario: - Organizations have members - Folders belong to organizations - Organization members should be able to read all folders in their organization You only have a `user_id` and a `folder_id` at check time. You do not have the `organization_id`. ### The schema ```fga model schema 1.1 type user type organization relations define owner: [user] define member: [user] or owner type folder relations define org: [organization] define owner: [user] define can_read: owner or member from org ``` `member from org` means: find the folder's organization (via the `org` relation), then check if the user is a `member` of that organization. ### The tuples Given this data: | Fact | Tuple | |------|-------| | alice is a member of org acme | `(user, alice, member, organization, acme)` | | folder 7 belongs to org acme | `(organization, acme, org, folder, 7)` | | bob owns folder 7 | `(user, bob, owner, folder, 7)` | ### The check ```sql SELECT check_permission('user', 'alice', 'can_read', 'folder', '7'); -- Returns: 1 ``` You pass only the user and the folder. Melange resolves the rest: 1. Check if alice is an `owner` of folder 7. She is not. 2. Evaluate `member from org`: a. Look up the `org` relation on folder 7. Find `organization:acme`. b. Check if alice is a `member` of organization acme. She is. 3. Return 1 (allowed). ```sql SELECT check_permission('user', 'bob', 'can_read', 'folder', '7'); -- Returns: 1 ``` Bob is allowed because he is an `owner` of folder 7 directly. ```sql SELECT check_permission('user', 'charlie', 'can_read', 'folder', '7'); -- Returns: 0 ``` Charlie has no relationship to folder 7 or organization acme. ### The tuples view ```sql CREATE OR REPLACE VIEW melange_tuples AS -- Organization members SELECT 'user' AS subject_type, user_id::text AS subject_id, role AS relation, 'organization' AS object_type, organization_id::text AS object_id FROM organization_members UNION ALL -- Folder -> Organization parent link SELECT 'organization' AS subject_type, organization_id::text AS subject_id, 'org' AS relation, 'folder' AS object_type, id::text AS object_id FROM folders UNION ALL -- Folder owners SELECT 'user' AS subject_type, user_id::text AS subject_id, 'owner' AS relation, 'folder' AS object_type, folder_id::text AS object_id FROM folder_owners; ``` The `org` parent link is what makes the indirect resolution work. Without it, Melange has no way to find which organization a folder belongs to. ## Pattern reference Each pattern below is a building block. They can be combined in a single relation definition. | Pattern | Schema | What it does | OpenFGA guide | |---------|--------|-------------|---------------| | Direct assignment | `define viewer: [user]` | Requires an explicit tuple | [Direct Access](https://openfga.dev/docs/modeling/direct-access) | | Role hierarchy | `define viewer: [user] or editor` | Union of direct assignment and another relation | [Roles and Permissions](https://openfga.dev/docs/modeling/roles-and-permissions) | | Parent inheritance | `define viewer: viewer from parent` | Inherit a relation from a linked object | [Parent-Child](https://openfga.dev/docs/modeling/parent-child) | | Exclusion | `define viewer: editor but not blocked` | Allow unless a deny relation exists | [Blocklists](https://openfga.dev/docs/modeling/blocklists) | | Wildcard | `define public: [user:*]` | Match any user of a given type | [Public Access](https://openfga.dev/docs/modeling/public-access) | | Userset reference | `define admin: [team#member]` | Grant access to members of a group | [User Groups](https://openfga.dev/docs/modeling/user-groups) | | Intersection | `define can_review: editor and approved` | Require multiple relations simultaneously | [Multiple Restrictions](https://openfga.dev/docs/modeling/multiple-restrictions) | For supported features and limitations in Melange, see [OpenFGA Compatibility](https://melange.sh/docs/reference/openfga-compatibility.md). ## Mapping schemas to your database Each directly assignable relation in your schema needs a corresponding section in your `melange_tuples` view. A simple rule: - Every `define X: [type]` needs tuples that produce `(type, id, X, object_type, object_id)`. - Every `define parent: [other_type]` (a linking relation used with `from`) needs tuples that produce `(other_type, id, parent, this_type, object_id)`. - Implied relations (`define viewer: editor`) do not need their own tuples. Melange resolves them from the relations they reference. For a full walkthrough of building the tuples view, see [Creating Your Tuples View](https://melange.sh/docs/getting-started/tuples-view.md). ## Further reading - [Authorization Modelling](https://melange.sh/docs/concepts/modelling.md) for DSL syntax reference - [OpenFGA Modelling Guides](https://openfga.dev/docs/modeling) for in-depth pattern documentation - [OpenFGA Playground](https://play.fga.dev) for interactive schema editing and testing - [OpenFGA Configuration Language](https://openfga.dev/docs/configuration-language) for full DSL specification - [OpenFGA Compatibility](https://melange.sh/docs/reference/openfga-compatibility.md) for Melange's feature support matrix --- # Running Migrations Source: https://melange.sh/docs/guides/migrations/ > Two ways to install Melange's generated SQL: melange migrate directly, or SQL files for your existing migration tool. Melange compiles your `.fga` schema into SQL functions that must be installed in PostgreSQL before permission checks can run. This page explains the two ways to get those functions into your database and helps you choose between them. ## Two Migration Strategies | | Built-in (`melange migrate`) | External (`melange generate migration`) | |---|---|---| | **How it works** | Connects to PostgreSQL and applies SQL directly | Produces versioned `.sql` files you apply yourself | | **Best for** | Solo projects, rapid prototyping, simple deployments | Teams using migration frameworks (golang-migrate, Atlas, Flyway, etc.) | | **Change tracking** | `melange_migrations` table in the database | Your migration framework's own tracking | | **Requires database access** | Yes, at migration time | Only when using `--db` comparison mode | | **Integrates with CI review** | Via `--dry-run` | Natively: SQL files are committed and reviewed like any other migration | > **Warning:** Pick one strategy per database. Mixing both is discouraged. `melange migrate` will warn if it detects that `generate migration` has been configured, and vice versa. ## Built-in Migrations The simplest path. Point `melange migrate` at your database and schema: ```bash melange migrate \ --db postgres://localhost/mydb \ --schema schemas/schema.fga ``` Melange connects, generates the SQL functions, and applies them in a single transaction. It records each migration in a `melange_migrations` table so it can skip unchanged schemas and clean up orphaned functions automatically. ### When to use - You control the database directly (local dev, small teams) - You don't need SQL migrations checked into version control - You want the simplest possible workflow ### Key behaviors - **Skip-if-unchanged**: Melange checksums the schema and skips the migration when nothing has changed. Use `--force` to override. - **Orphan cleanup**: Functions from removed relations are dropped automatically. - **Dry-run**: Use `--dry-run` to preview the SQL without applying it. See the [CLI Reference](https://melange.sh/docs/guides/reference/cli/#migrate) for the full flag list. ## External Migration Frameworks If your team already uses a migration framework, `melange generate migration` produces versioned SQL files that slot into your existing workflow: ```bash melange generate migration \ --schema schemas/schema.fga \ --output db/migrations ``` This writes timestamped migration files (e.g., `20260322143000_melange.up.sql` / `.down.sql`) containing the same SQL that `melange migrate` would apply, but as static files you commit, review, and apply through your framework. ### When to use - Your team uses golang-migrate, Atlas, Flyway, Liquibase, or a similar tool - You want migration SQL reviewed in pull requests before it reaches production - You need to coordinate Melange schema changes with other database migrations - You don't want Melange to have direct database access in CI ### Output formats | Format | Flag | Files produced | |--------|------|---------------| | **Split** (default) | `--format split` | `TIMESTAMP_NAME.up.sql` and `TIMESTAMP_NAME.down.sql` | | **Single** | `--format single` | `TIMESTAMP_NAME.sql` with both UP and DOWN sections | You can also write to stdout with `--up` or `--down` for scripting. ## Comparison Modes By default, `generate migration` emits **every** function as a full migration. This is correct for the first migration or when you want a complete snapshot. For subsequent migrations, you usually want only the functions that changed. Melange supports three comparison modes to achieve this: ### Full mode (default) ```bash melange generate migration --schema schemas/schema.fga --output db/migrations ``` No comparison flag is specified. The UP migration includes all functions. Use this for: - Your first migration - Periodic full snapshots - When you're unsure what changed ### Database comparison (`--db`) ```bash melange generate migration \ --schema schemas/schema.fga \ --output db/migrations \ --db postgres://localhost/mydb ``` Reads the most recent `melange_migrations` record from the database to determine which functions existed before and what their checksums were. The UP migration includes only: - Functions whose SQL body has changed - Newly added functions - `DROP` statements for orphaned functions (relations removed from the schema) - Dispatchers (always included since they reference all relations) This is the most precise mode but requires database access at generation time. ### Git comparison (`--git-ref`) ```bash melange generate migration \ --schema schemas/schema.fga \ --output db/migrations \ --git-ref main ``` Compiles the schema from a git ref (branch, tag, or commit) through the full pipeline and compares the result against the current schema. No database access required, making it suitable for CI pipelines. Common patterns: - `--git-ref main` - compare against the main branch - `--git-ref HEAD~1` - compare against the previous commit - `--git-ref v1.2.0` - compare against a release tag ### File comparison (`--previous-schema`) ```bash melange generate migration \ --schema schemas/schema.fga \ --output db/migrations \ --previous-schema schemas/schema.fga.bak ``` Compiles a previous schema from a local file and compares. Useful when you keep a copy of the last-deployed schema alongside your current one. > **Note:** The three comparison flags (`--db`, `--git-ref`, `--previous-schema`) are mutually exclusive. Specify at most one. ## How Change Detection Works When a comparison mode is active, Melange computes a SHA-256 checksum of each function's SQL body for both the current schema and the previous state. Only functions with a different (or missing) checksum appear in the UP migration. Dispatcher functions (`check_permission`, `list_accessible_objects`, etc.) are always included because they reference every relation and must be regenerated whenever any relation changes. Functions that existed in the previous state but are absent from the current schema are treated as **orphans** and receive `DROP FUNCTION IF EXISTS` statements in the UP migration. ## Migration Metadata Each generated UP migration includes a comment header with metadata: ```sql -- Melange Migration (UP) -- Melange version: v0.7.3 -- Schema checksum: a1b2c3d4... -- Codegen version: 1 -- Previous state: git:main -- Changed functions: 3 of 12 ``` This helps you understand at a glance what changed and why the migration was generated. ## Example Output To make the generated migrations concrete, here's what the UP and DOWN files look like for a simple schema: ```fga model schema 1.1 type user type document relations define viewer: [user] ``` ### UP migration The UP migration creates specialized check functions for each relation, then installs dispatchers that route `check_permission(...)` calls to the right function. ```sql -- Melange Migration (UP) -- Melange version: v0.7.3 -- Schema checksum: 9f3a... -- Codegen version: 1 -- ============================================================ -- Check Functions (1 functions) -- ============================================================ CREATE OR REPLACE FUNCTION check_document_viewer( p_subject_type TEXT, p_subject_id TEXT, p_object_id TEXT, p_visited TEXT [] DEFAULT ARRAY[]::TEXT[] ) RETURNS INTEGER AS $$ BEGIN -- ... userset subject handling omitted for brevity ... IF EXISTS ( SELECT 1 FROM melange_tuples WHERE object_type = 'document' AND relation IN ('viewer') AND object_id = p_object_id AND subject_type = p_subject_type AND subject_id = p_subject_id AND NOT (subject_id = '*') LIMIT 1 ) THEN RETURN 1; ELSE RETURN 0; END IF; END; $$ LANGUAGE plpgsql STABLE; -- ============================================================ -- No-Wildcard Check Functions (1 functions) -- ============================================================ -- Same as above but excludes wildcard (type:*) matches CREATE OR REPLACE FUNCTION check_document_viewer_no_wildcard( -- ... same signature and body, omitted for brevity ... ) RETURNS INTEGER AS $$ /* ... */ $$ LANGUAGE plpgsql STABLE; -- ============================================================ -- Check Dispatchers -- ============================================================ CREATE OR REPLACE FUNCTION check_permission_internal( p_subject_type TEXT, p_subject_id TEXT, p_relation TEXT, p_object_type TEXT, p_object_id TEXT, p_visited TEXT [] DEFAULT ARRAY[]::TEXT[] ) RETURNS INTEGER AS $$ BEGIN IF array_length(p_visited, 1) >= 25 THEN RAISE EXCEPTION 'resolution too complex' USING ERRCODE = 'M2002'; END IF; IF (p_object_type = 'document' AND p_relation = 'viewer') THEN RETURN check_document_viewer(p_subject_type, p_subject_id, p_object_id, p_visited); END IF; RETURN 0; END; $$ LANGUAGE plpgsql STABLE; -- Public entry point - this is what your application calls CREATE OR REPLACE FUNCTION check_permission( p_subject_type TEXT, p_subject_id TEXT, p_relation TEXT, p_object_type TEXT, p_object_id TEXT ) RETURNS INTEGER AS $$ SELECT check_permission_internal( p_subject_type, p_subject_id, p_relation, p_object_type, p_object_id, ARRAY[]::TEXT[]); $$ LANGUAGE sql STABLE; -- ... check_permission_no_wildcard, check_permission_bulk, -- list_accessible_objects, list_accessible_subjects omitted ... ``` > **Note:** Each relation in your schema produces a specialized function (e.g., `check_document_viewer`). The dispatchers (`check_permission`, `list_accessible_objects`, etc.) are always regenerated because they contain a `CASE` branch for every relation. ### DOWN migration The DOWN migration drops all functions installed by the UP migration. To restore a previous version, apply that version's UP migration after rolling back. ```sql -- Melange Migration (DOWN) -- To restore a previous version, apply that version's UP migration. -- Drop specialized functions DROP FUNCTION IF EXISTS check_document_viewer CASCADE; DROP FUNCTION IF EXISTS check_document_viewer_no_wildcard CASCADE; DROP FUNCTION IF EXISTS list_document_viewer_objects CASCADE; DROP FUNCTION IF EXISTS list_document_viewer_subjects CASCADE; -- Drop dispatchers DROP FUNCTION IF EXISTS check_permission CASCADE; DROP FUNCTION IF EXISTS check_permission_internal CASCADE; DROP FUNCTION IF EXISTS check_permission_no_wildcard CASCADE; DROP FUNCTION IF EXISTS check_permission_no_wildcard_internal CASCADE; DROP FUNCTION IF EXISTS check_permission_bulk CASCADE; DROP FUNCTION IF EXISTS list_accessible_objects CASCADE; DROP FUNCTION IF EXISTS list_accessible_subjects CASCADE; ``` ### Comparison mode example When using `--git-ref` or `--db`, only changed functions appear. For example, if you add an `editor` relation to `document`, the UP migration includes only the new functions and any modified dispatchers: ```sql -- Melange Migration (UP) -- Melange version: v0.7.3 -- Schema checksum: b7e4... -- Codegen version: 1 -- Previous state: git:main -- Changed functions: 2 of 4 -- ============================================================ -- Changed Functions (2 functions) -- ============================================================ CREATE OR REPLACE FUNCTION check_document_editor( -- ... new function for the added relation ... ) RETURNS INTEGER AS $$ /* ... */ $$ LANGUAGE plpgsql STABLE; CREATE OR REPLACE FUNCTION check_document_editor_no_wildcard( -- ... no-wildcard variant ... ) RETURNS INTEGER AS $$ /* ... */ $$ LANGUAGE plpgsql STABLE; -- ============================================================ -- Check Dispatchers -- ============================================================ -- Dispatchers are always regenerated to include the new routing branch CREATE OR REPLACE FUNCTION check_permission_internal( /* ... */ ) RETURNS INTEGER AS $$ BEGIN /* ... */ IF (p_object_type = 'document' AND p_relation = 'editor') THEN RETURN check_document_editor(p_subject_type, p_subject_id, p_object_id, p_visited); END IF; IF (p_object_type = 'document' AND p_relation = 'viewer') THEN RETURN check_document_viewer(p_subject_type, p_subject_id, p_object_id, p_visited); END IF; RETURN 0; END; $$ LANGUAGE plpgsql STABLE; -- ... remaining dispatchers omitted ... ``` If you **remove** a relation, the comparison mode also emits `DROP` statements for the orphaned functions: ```sql -- ============================================================ -- Drop removed functions -- ============================================================ DROP FUNCTION IF EXISTS check_document_editor CASCADE; DROP FUNCTION IF EXISTS check_document_editor_no_wildcard CASCADE; ``` ## Typical Workflows ### First-time setup with golang-migrate ```bash # Generate the initial full migration melange generate migration \ --schema schemas/schema.fga \ --output db/migrations \ --name melange # Apply with golang-migrate migrate -path db/migrations -database "$DATABASE_URL" up ``` ### Schema change in a PR ```bash # Edit schemas/schema.fga, then generate a diff migration melange generate migration \ --schema schemas/schema.fga \ --output db/migrations \ --git-ref main # Commit the .fga change and the generated SQL together git add schemas/schema.fga db/migrations/ git commit -m "Add editor relation to document type" ``` ### CI pipeline ```bash # Validate the schema melange validate --schema schemas/schema.fga # Verify no un-committed migration drift # (generate to stdout and compare against committed files) melange generate migration \ --schema schemas/schema.fga \ --git-ref main \ --up ``` ## CI/CD Integration ### Validate Schema in CI Add schema validation as an early CI step. It runs without database access and fails fast on syntax errors: ```bash melange validate ``` ### Generate and Review Migrations in PRs Compare against the main branch to generate only the delta: ```bash melange generate migration \ --schema schemas/schema.fga \ --output db/migrations \ --git-ref main ``` Commit the generated SQL alongside the schema change. Reviewers see both the intent (`.fga` diff) and the effect (`.sql` diff). ### GitHub Actions Example ```yaml name: Melange on: [push, pull_request] jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Melange run: go install github.com/pthm/melange@latest - name: Validate schema run: melange validate - name: Check migration drift run: | melange generate migration \ --schema schemas/schema.fga \ --git-ref origin/main \ --up > /tmp/expected.sql # Compare against committed migration or just validate it generates cleanly deploy: runs-on: ubuntu-latest needs: validate if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - name: Apply migrations env: MELANGE_DATABASE_URL: ${{ secrets.DATABASE_URL }} run: melange migrate ``` ### GitLab CI Example ```yaml stages: - validate - deploy validate-schema: stage: validate script: - melange validate deploy-migrations: stage: deploy only: - main script: - melange migrate variables: MELANGE_DATABASE_URL: $DATABASE_URL ``` ### Dry-Run in Staging Preview the migration without applying: ```bash melange migrate --dry-run --db "$STAGING_DATABASE_URL" ``` ## Schema Evolution ### Safe Changes These changes can be applied without coordination: - **Adding a new type**. No existing functions are affected. - **Adding a new relation**. New functions are generated; existing functions and dispatchers are updated. - **Adding a union member** (e.g., `define viewer: [user]` to `define viewer: [user] or editor`). The check function is regenerated to include the new path. ### Changes That Require Planning - **Removing a relation**. The specialized functions are dropped. Application code referencing the relation will get `ErrMissingFunction` until updated. Deploy the application change and migration together. - **Removing a type**. All functions for that type are dropped. Same coordination as removing a relation. - **Renaming a type or relation**. Melange treats this as a removal + addition. The old functions are dropped and new ones created. Update your `melange_tuples` view and application code in the same deployment. - **Changing relation semantics** (e.g., from direct assignment to implied). The function SQL changes. Test thoroughly since permission results may differ. ### Recommended Workflow 1. Make the schema change in your `.fga` file. 2. Run `melange validate` to catch syntax errors and undefined relations. 3. Generate a migration with `--git-ref main` to see only the delta. 4. Review the generated SQL. 5. Update application code and tuples view if needed. 6. Deploy the migration, application code, and view changes together. ## Next Steps - [CLI Reference: generate migration](https://melange.sh/docs/reference/cli.md#generate-migration): full flag reference and examples - [Configuration: generate.migration](https://melange.sh/docs/reference/configuration.md#generate-migration-settings): config file and environment variables - [How It Works](https://melange.sh/docs/concepts/how-it-works.md): what the generated SQL functions do - [Troubleshooting](https://melange.sh/docs/guides/troubleshooting.md): diagnosing migration issues --- # Checking Permissions Source: https://melange.sh/docs/guides/checking-permissions/ > Check whether a subject has a relation on an object with Melange from Go, TypeScript, or SQL, including bulk checks. The permission check API evaluates whether a subject has a specific relation on an object. This is the core operation for authorization decisions. ## Basic Permission Check **Go:** ```go import "github.com/pthm/melange/melange" // Create a checker checker := melange.NewChecker(db) // Define subject and object user := melange.Object{Type: "user", ID: "123"} repo := melange.Object{Type: "repository", ID: "456"} // Check permission allowed, err := checker.Check(ctx, user, "can_read", repo) if err != nil { return err } if !allowed { return ErrForbidden } ``` The Checker accepts any type implementing the `Querier` interface: - `*sql.DB` - Connection pool - `*sql.Tx` - Transaction (sees uncommitted changes) - `*sql.Conn` - Single connection **TypeScript:** ```typescript import { Pool } from 'pg'; import { Checker } from '@pthm/melange'; const pool = new Pool({ connectionString: 'postgresql://localhost/mydb' }); const checker = new Checker(pool); // Define subject and object const user = { type: 'user', id: '123' }; const repo = { type: 'repository', id: '456' }; // Check permission const decision = await checker.check(user, 'can_read', repo); if (!decision.allowed) { throw new ForbiddenError(); } ``` The Checker accepts any object implementing the `Queryable` interface. The `pg` Pool and Client both satisfy this. Use adapters for other drivers. **SQL:** ```sql -- Check if user 123 can read repository 456 SELECT check_permission('user', '123', 'can_read', 'repository', '456'); -- Returns 1 for allowed, 0 for denied -- Use in a WHERE clause to filter accessible records SELECT d.* FROM documents d WHERE check_permission('user', '123', 'viewer', 'document', d.id::text) = 1; ``` ## Type-Safe Interfaces Use generated code or implement type-safe interfaces for cleaner code: **Go:** Implement `SubjectLike` and `ObjectLike` on your domain models: ```go type User struct { ID int64 Name string } func (u User) FGASubject() melange.Object { return melange.Object{Type: "user", ID: fmt.Sprint(u.ID)} } type Repository struct { ID int64 Name string } func (r Repository) FGAObject() melange.Object { return melange.Object{Type: "repository", ID: fmt.Sprint(r.ID)} } // Now use directly in checks allowed, err := checker.Check(ctx, user, "can_read", repo) ``` With generated code from `melange generate client`: ```go import "myapp/internal/authz" allowed, err := checker.Check(ctx, authz.User("123"), authz.RelCanRead, authz.Repository("456"), ) ``` **TypeScript:** Create factory functions for cleaner code: ```typescript import { Checker } from '@pthm/melange'; // Define object factories function User(id: string) { return { type: 'user' as const, id }; } function Repository(id: string) { return { type: 'repository' as const, id }; } const RelCanRead = 'can_read'; // Use with Checker const checker = new Checker(pool); const decision = await checker.check( User('123'), RelCanRead, Repository('456') ); ``` With generated code from `melange generate client --runtime typescript`: ```typescript import { User, Repository, RelCanRead, check } from './authz'; const allowed = await check( User('123'), RelCanRead, Repository('456') ); ``` ## Caching Enable caching to avoid repeated database queries for the same permission check. See the [Caching guide](https://melange.sh/docs/guides/caching.md) for full details on TTL configuration, request-scoped caching, custom cache implementations, and cache invalidation strategies. ```go cache := melange.NewCache(melange.WithTTL(time.Minute)) checker := melange.NewChecker(db, melange.WithCache(cache)) ``` ## Decision Overrides Bypass database checks for testing or admin tools: **Go:** ```go // Always allow - for admin tools or testing authorized paths checker := melange.NewChecker(nil, melange.WithDecision(melange.DecisionAllow)) // Always deny - for testing unauthorized paths checker := melange.NewChecker(nil, melange.WithDecision(melange.DecisionDeny)) ``` When a decision override is set, no database query is performed. ### Context-Based Overrides Enable context-based decision overrides for request-scoped behavior: ```go checker := melange.NewChecker(db, melange.WithContextDecision()) // In middleware or handler: ctx := melange.WithDecisionContext(ctx, melange.DecisionAllow) // Check uses context decision, no database query allowed, _ := checker.Check(ctx, user, "can_read", repo) ``` Decision precedence (when `WithContextDecision` is enabled): 1. Context decision (via `WithDecisionContext`) 2. Checker decision (via `WithDecision`) 3. Database check **TypeScript:** ```typescript import { Checker, DecisionAllow, DecisionDeny } from '@pthm/melange'; // Always allow - for admin tools or testing authorized paths const allowChecker = new Checker(pool, { decision: DecisionAllow }); // Always deny - for testing unauthorized paths const denyChecker = new Checker(pool, { decision: DecisionDeny }); ``` When a decision override is set, no database query is performed. This works with both `check()` and `newBulkCheck()`. ```typescript // In middleware - create request-scoped checker function authMiddleware(req, res, next) { if (req.user.isAdmin) { req.checker = new Checker(pool, { decision: DecisionAllow }); } else { req.checker = new Checker(pool); } next(); } ``` ## Transaction Support Permission checks work within transactions and see uncommitted changes: **Go:** ```go tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // Insert new data within transaction _, err = tx.ExecContext(ctx, ` INSERT INTO organization_members (user_id, organization_id, role) VALUES ($1, $2, 'member') `, userID, orgID) if err != nil { return err } // Checker on transaction sees the uncommitted row checker := melange.NewChecker(tx) allowed, err := checker.Check(ctx, user, "member", org) // allowed == true, even before commit if err := tx.Commit(); err != nil { return err } ``` **TypeScript:** ```typescript import { Checker } from '@pthm/melange'; const client = await pool.connect(); try { await client.query('BEGIN'); // Insert new data within transaction await client.query( 'INSERT INTO organization_members (user_id, organization_id, role) VALUES ($1, $2, $3)', [userId, orgId, 'member'] ); // Create Checker on the transaction client to see uncommitted rows const txChecker = new Checker(client); const decision = await txChecker.check( { type: 'user', id: userId }, 'member', { type: 'organization', id: orgId } ); // decision.allowed == true, even before commit await client.query('COMMIT'); } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } ``` **SQL:** ```sql BEGIN; -- Insert new tuple (via domain table that feeds the view) INSERT INTO organization_members (user_id, organization_id, role) VALUES ('123', '456', 'member'); -- Permission check sees the uncommitted row SELECT check_permission('user', '123', 'member', 'organization', '456'); -- Returns 1 ROLLBACK; -- Now returns 0 SELECT check_permission('user', '123', 'member', 'organization', '456'); ``` ## When a Check Returns the Wrong Answer Use [Explain](https://melange.sh/docs/guides/explaining-decisions.md) to see the resolution tree behind a check: every attempted branch, the contributing tuples, and per-branch success/failure. ```go trace, err := checker.Explain(ctx, melange.Object{Type: "user", ID: "alice"}, melange.Relation("viewer"), melange.Object{Type: "document", ID: "1"}, ) if !*trace.Result { for _, attempt := range trace.Root.Children { fmt.Printf("tried %s (%s) → %v\n", attempt.Type, attempt.Label, *attempt.Result) } } ``` Or from the CLI: ```bash melange explain user:alice viewer document:1 ``` Explain is for debugging and admin tooling, not the request path. It builds a JSONB trace per call. ## Error Handling **Go:** ### Sentinel Errors ```go import "github.com/pthm/melange/melange" var ( melange.ErrNoTuplesTable // melange_tuples view doesn't exist melange.ErrMissingFunction // SQL functions not installed ) ``` ### Error Checkers ```go allowed, err := checker.Check(ctx, user, "can_read", repo) if err != nil { if melange.IsNoTuplesTableErr(err) { // melange_tuples view needs to be created log.Error("Authorization not configured: missing melange_tuples view") } else if melange.IsMissingFunctionErr(err) { // Run melange migrate log.Error("Authorization not configured: run 'melange migrate'") } return err } ``` ### Must - Panic on Failure Use `Must` for internal invariants where unauthorized access is a programmer error: ```go // Panic if check fails or errors checker.Must(ctx, user, "can_write", repo) // Only reachable if permission granted ``` Prefer `Check` for user-facing authorization. Use `Must` when: - Access denial indicates a bug (not a user error) - You've already validated access at a higher level - In tests where failure should panic **TypeScript:** ```typescript import { Checker, MelangeError, ValidationError } from '@pthm/melange'; const checker = new Checker(pool); try { const decision = await checker.check(user, 'can_read', repo); } catch (error) { if (error instanceof ValidationError) { // Invalid input (empty type, missing ID, etc.) console.error('Invalid check request:', error.message); } else if (error instanceof MelangeError) { // Other melange errors console.error('Authorization error:', error.message); } throw error; } ``` For guard-style checks with bulk operations, use `allOrError`: ```typescript import { BulkCheckDeniedError, isBulkCheckDeniedError } from '@pthm/melange'; const results = await checker.newBulkCheck() .add(user, 'can_read', repo) .add(user, 'can_write', repo) .execute(); const err = results.allOrError(); if (err) { // err is a BulkCheckDeniedError with subject, relation, object, index, total console.error(`Denied: ${err.message}`); throw err; } ``` **SQL:** ```sql -- Handle resolution too complex error (code M2002) DO $$ BEGIN PERFORM check_permission('user', '123', 'viewer', 'document', '456'); EXCEPTION WHEN SQLSTATE 'M2002' THEN RAISE NOTICE 'Permission resolution too complex'; END; $$; ``` The M2002 error occurs when permission resolution exceeds the depth limit (25 levels), which can happen with: - Deeply nested parent relationships - Complex userset chains - Cyclic permission structures ## Performance Tips ### 1. Use Request-Scoped Caching Create a cache per request to avoid stale data across requests: **Go:** ```go func authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cache := melange.NewCache() // Fresh cache per request checker := melange.NewChecker(db, melange.WithCache(cache)) ctx := context.WithValue(r.Context(), "checker", checker) next.ServeHTTP(w, r.WithContext(ctx)) }) } ``` **TypeScript:** ```typescript import { Checker, MemoryCache } from '@pthm/melange'; // Express middleware - fresh cache per request function authMiddleware(req, res, next) { const cache = new MemoryCache(); // Fresh cache per request req.checker = new Checker(pool, { cache }); next(); } // In a handler - repeated checks are cached automatically async function handleRequest(req) { const user = { type: 'user', id: req.userId }; // First check hits the database await req.checker.check(user, 'can_read', repo); // Same check later in the request uses cache await req.checker.check(user, 'can_read', repo); // Or batch multiple checks for efficiency const results = await req.checker.newBulkCheck() .add(user, 'can_read', repo1) .add(user, 'can_write', repo1) .execute(); } ``` ### 2. Batch Checks with Bulk API Use `NewBulkCheck` to check many permissions in a single SQL call instead of looping over individual checks: **Go:** ```go bulk := checker.NewBulkCheck(ctx) // Queue checks - all execute in one SQL call for _, repo := range repos { bulk.Add(user, "can_read", repo) } // Or use AddMany for one subject+relation across multiple objects bulk.AddMany(user, "can_read", repos...) results, err := bulk.Execute() if err != nil { return err } // Check aggregate results if results.All() { // Every check was allowed } if results.Any() { // At least one check was allowed } // Iterate individual results for _, r := range results.Allowed() { fmt.Printf("%s:%s is accessible\n", r.Object().Type, r.Object().ID) } // Use AllOrError for guard-style checks if err := results.AllOrError(); err != nil { return err // Returns BulkCheckDeniedError with details } ``` Use `AddWithID` to tag checks with meaningful identifiers: ```go bulk := checker.NewBulkCheck(ctx) bulk.AddWithID("read-repo", user, "can_read", repo) bulk.AddWithID("write-repo", user, "can_write", repo) bulk.AddWithID("admin-repo", user, "admin", repo) results, err := bulk.Execute() if err != nil { return err } // Look up results by ID if r := results.GetByID("write-repo"); r != nil && r.IsAllowed() { // User can write } ``` **TypeScript:** ```typescript import { Checker } from '@pthm/melange'; const checker = new Checker(pool); const results = await checker.newBulkCheck() .add(user, 'can_read', repo1) .add(user, 'can_read', repo2) .addMany(user, 'can_write', repo1, repo2) .execute(); // Check aggregate results if (results.all()) { // Every check was allowed } if (results.any()) { // At least one check was allowed } // Iterate individual results for (const r of results.allowed()) { console.log(`${r.object.type}:${r.object.id} is accessible`); } // Use allOrError for guard-style checks const err = results.allOrError(); if (err) { throw err; // BulkCheckDeniedError with details } ``` Use `addWithId` to tag checks with meaningful identifiers: ```typescript const results = await checker.newBulkCheck() .addWithId('read-repo', user, 'can_read', repo) .addWithId('write-repo', user, 'can_write', repo) .addWithId('admin-repo', user, 'admin', repo) .execute(); // Look up results by ID const writeResult = results.getById('write-repo'); if (writeResult?.allowed) { // User can write } ``` **SQL:** ```sql -- Check multiple permissions in a single call SELECT idx, allowed FROM check_permission_bulk( ARRAY['user', 'user', 'user'], ARRAY['123', '123', '123'], ARRAY['viewer', 'editor', 'admin'], ARRAY['document', 'document', 'document'], ARRAY['456', '456', '456'] ); ``` > **Note:** **Deduplication**: Duplicate checks within a batch are automatically deduplicated. Only unique permission tuples are sent to the database; results are fanned out to all original positions. > **Warning:** **Size limit**: A single bulk check supports up to 10,000 checks (`MaxBulkCheckSize` in Go, `MAX_BULK_CHECK_SIZE` in TypeScript). Exceeding this limit returns an error. ### 3. Use ListObjects for Filtering Instead of checking each object individually, use `list_accessible_objects`: **Go:** ```go // Inefficient: N database queries for _, repo := range repos { if allowed, _ := checker.Check(ctx, user, "can_read", repo); allowed { // ... } } // Efficient: 1 database query accessibleIDs, _ := checker.ListObjects(ctx, user, "can_read", "repository") idSet := make(map[string]bool) for _, id := range accessibleIDs { idSet[id] = true } for _, repo := range repos { if idSet[repo.ID] { // ... } } ``` **TypeScript:** ```typescript import { Checker } from '@pthm/melange'; const checker = new Checker(pool); const user = { type: 'user', id: userId }; // Inefficient: N database queries const visible = []; for (const repo of repos) { const d = await checker.check(user, 'can_read', { type: 'repository', id: repo.id }); if (d.allowed) visible.push(repo); } // Efficient: 1 database query const result = await checker.listObjects(user, 'can_read', 'repository'); const accessibleIds = new Set(result.items); const visible = repos.filter(repo => accessibleIds.has(repo.id)); ``` **SQL:** ```sql -- Inefficient: N function calls SELECT d.* FROM documents d WHERE check_permission('user', '123', 'viewer', 'document', d.id::text) = 1; -- Efficient: 1 function call + JOIN SELECT d.* FROM documents d JOIN list_accessible_objects('user', '123', 'viewer', 'document') a ON d.id::text = a.object_id; ``` See [Listing Objects](https://melange.sh/docs/guides/listing-objects.md) for details. ## Schema Validation On first Checker creation, Melange validates the database schema (once per process). Issues are logged as warnings: ``` [melange] WARNING: check_permission function not found. Run 'melange migrate' to create it. ``` These warnings don't prevent Checker creation, allowing applications to start before authorization is fully configured. --- # Listing Objects Source: https://melange.sh/docs/guides/listing-objects/ > List every object a subject can access with Melange's ListObjects, including pagination and performance tips. `ListObjects` returns all objects of a given type that a subject has a specific relation on. ## Basic Usage **Go:** ```go // Find all repositories user can read (with pagination) repoIDs, cursor, err := checker.ListObjects(ctx, authz.User("123"), authz.RelCanRead, "repository", melange.PageOptions{Limit: 100}, ) if err != nil { return err } // repoIDs = ["repo-1", "repo-456", "repo-789"] // cursor = nil when no more pages, or a string to fetch the next page ``` **TypeScript:** ```typescript // Find all repositories user can read (with pagination) const { rows } = await pool.query( 'SELECT object_id, next_cursor FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', '123', 'can_read', 'repository', 100, null] ); const repoIds = rows.map(row => row.object_id); const nextCursor = rows.length > 0 ? rows[0].next_cursor : null; // repoIds = ["repo-1", "repo-456", "repo-789"] ``` **SQL:** ```sql -- Get documents user 123 can view (first 100) SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, NULL); -- Returns a table with object_id and next_cursor columns -- next_cursor is NULL when no more pages exist ``` ## Parameters | Parameter | Type | Description | |-----------|------|-------------| | `subject_type` | `text` | Type of the subject (e.g., `'user'`) | | `subject_id` | `text` | ID of the subject | | `relation` | `text` | The relation to check | | `object_type` | `text` | Type of objects to return | | `p_limit` | `int` | Maximum number of results per page (NULL = no limit) | | `p_after` | `text` | Cursor from previous page (NULL = start from beginning) | ## Return Value Returns a table with `object_id` and `next_cursor` columns. The `next_cursor` value is repeated on every row for convenience - use the last row's cursor to fetch the next page. Returns an empty result set if no objects found (not an error). > **Note:** **Ordering**: Results are ordered deterministically by `object_id` to ensure stable pagination across requests. ## Pagination ### Cursor-Based Pagination Use cursor-based pagination to iterate through large result sets efficiently: **Go:** ```go // Paginate through all accessible repositories var cursor *string for { ids, next, err := checker.ListObjects(ctx, authz.User("123"), authz.RelCanRead, "repository", melange.PageOptions{Limit: 100, After: cursor}, ) if err != nil { return err } for _, id := range ids { // Process each repository ID fmt.Println("Accessible:", id) } if next == nil { break // No more pages } cursor = next } ``` **TypeScript:** ```typescript // Paginate through all accessible repositories let cursor: string | null = null; while (true) { const { rows } = await pool.query( 'SELECT object_id, next_cursor FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', userId, 'can_read', 'repository', 100, cursor] ); for (const row of rows) { // Process each repository ID console.log('Accessible:', row.object_id); } cursor = rows.length > 0 ? rows[rows.length - 1].next_cursor : null; if (!cursor) break; // No more pages } ``` **SQL:** ```sql -- First page SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, NULL); -- Returns: object_id | next_cursor -- doc-001 | doc-100 -- doc-002 | doc-100 -- ... -- doc-100 | doc-100 -- Next page (use the next_cursor value) SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, 'doc-100'); ``` ### Fetching All Results For convenience, use the `ListObjectsAll` helper which automatically paginates through all results: **Go:** ```go // Get all accessible repositories (auto-paginates internally) allRepoIDs, err := checker.ListObjectsAll(ctx, authz.User("123"), authz.RelCanRead, "repository", ) if err != nil { return err } // allRepoIDs contains all accessible repository IDs ``` > **Warning:** **Use with caution**: `ListObjectsAll` loads all IDs into memory. For large datasets, prefer paginated queries with `ListObjects` to control memory usage. ## Examples ### Filter a List of Resources **Go:** ```go // Get all repositories from your data layer repos, err := db.GetAllRepositories(ctx) if err != nil { return err } // Get IDs the user can access (using ListObjectsAll for convenience) accessibleIDs, err := checker.ListObjectsAll(ctx, user, "can_read", "repository") if err != nil { return err } // Build a set for O(1) lookup accessSet := make(map[string]bool, len(accessibleIDs)) for _, id := range accessibleIDs { accessSet[id] = true } // Filter to only accessible repos var visibleRepos []Repository for _, repo := range repos { if accessSet[fmt.Sprint(repo.ID)] { visibleRepos = append(visibleRepos, repo) } } ``` **TypeScript:** ```typescript // Get all repositories from your data layer const repos = await db.getAllRepositories(); // Get IDs the user can access (NULL limit returns all) const { rows } = await pool.query( 'SELECT object_id FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', userId, 'can_read', 'repository', null, null] ); const accessibleIds = new Set(rows.map(r => r.object_id)); // Filter to only accessible repos const visibleRepos = repos.filter(repo => accessibleIds.has(repo.id)); ``` **SQL:** ```sql -- Join with domain table to get full records (NULL limit returns all) SELECT d.* FROM documents d JOIN list_accessible_objects('user', '123', 'viewer', 'document', NULL, NULL) a ON d.id::text = a.object_id; ``` ### Fetch Only Accessible Resources **Go:** ```go // Get accessible IDs first (using ListObjectsAll) ids, err := checker.ListObjectsAll(ctx, user, "can_read", "document") if err != nil { return nil, err } if len(ids) == 0 { return []Document{}, nil } // Query only those documents docs, err := db.GetDocumentsByIDs(ctx, ids) return docs, err ``` **TypeScript:** ```typescript // Get accessible IDs first (NULL limit returns all) const { rows } = await pool.query( 'SELECT object_id FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', userId, 'can_read', 'document', null, null] ); const ids = rows.map(r => r.object_id); if (ids.length === 0) { return []; } // Query only those documents const docs = await db.getDocumentsByIds(ids); return docs; ``` **SQL:** ```sql -- Count accessible objects SELECT COUNT(*) FROM list_accessible_objects('user', '123', 'viewer', 'document', NULL, NULL); ``` ### Check Multiple Permissions **Go:** ```go type RepoWithPermissions struct { Repository CanRead bool CanWrite bool CanDelete bool } // Fetch all permission sets in parallel (using ListObjectsAll) var ( readIDs, writeIDs, deleteIDs []string readErr, writeErr, deleteErr error ) var wg sync.WaitGroup wg.Add(3) go func() { defer wg.Done() readIDs, readErr = checker.ListObjectsAll(ctx, user, "can_read", "repository") }() go func() { defer wg.Done() writeIDs, writeErr = checker.ListObjectsAll(ctx, user, "can_write", "repository") }() go func() { defer wg.Done() deleteIDs, deleteErr = checker.ListObjectsAll(ctx, user, "can_delete", "repository") }() wg.Wait() // Check errors... // Build permission maps canRead := toSet(readIDs) canWrite := toSet(writeIDs) canDelete := toSet(deleteIDs) // Annotate repos with permissions for _, repo := range repos { id := fmt.Sprint(repo.ID) result = append(result, RepoWithPermissions{ Repository: repo, CanRead: canRead[id], CanWrite: canWrite[id], CanDelete: canDelete[id], }) } ``` **TypeScript:** ```typescript interface RepoWithPermissions { id: string; name: string; canRead: boolean; canWrite: boolean; canDelete: boolean; } // Fetch all permission sets in parallel (NULL limit returns all) const [readRows, writeRows, deleteRows] = await Promise.all([ pool.query('SELECT object_id FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', userId, 'can_read', 'repository', null, null]), pool.query('SELECT object_id FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', userId, 'can_write', 'repository', null, null]), pool.query('SELECT object_id FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', userId, 'can_delete', 'repository', null, null]), ]); // Build permission sets const canRead = new Set(readRows.rows.map(r => r.object_id)); const canWrite = new Set(writeRows.rows.map(r => r.object_id)); const canDelete = new Set(deleteRows.rows.map(r => r.object_id)); // Annotate repos with permissions const result: RepoWithPermissions[] = repos.map(repo => ({ id: repo.id, name: repo.name, canRead: canRead.has(repo.id), canWrite: canWrite.has(repo.id), canDelete: canDelete.has(repo.id), })); ``` ## Performance Characteristics ListObjects walks the permission graph in a single query. Its cost grows with the number of objects Postgres has to consider, which is not always the number it returns. Measured on managed Postgres from a VM in the same zone: | Measured query | Observed latency | | --- | --- | | A few results over short paths | About 0.5 to 1 ms across the measured scales | | Objects inherited through a parent, or a broad union | PR first page: 2.8 ms with 100 accessible PRs; 262 to 269 ms with 20,000 accessible PRs. These are accessible universes, not page payloads. | | Intersections and exclusions | Follow the size of the base relation, even when few objects pass | | Recursive relations and userset subjects through nested groups | Follow the size of the reachable graph: 10 results took 188 ms over 2,000 nested groups | See [Performance](https://melange.sh/docs/reference/performance.md#listobjects) for the full tables. For large result sets: 1. **Prefer a bulk check** when you already have the candidates, such as a page of objects loaded from your own tables. 2. **Paginate to bound payload size.** In the largest measured PR fixture, first-page limits from 10 to 500 took 262 to 269 ms. A limit may not reduce expensive enumeration; inspect your query plan. This benchmark does not establish later-page latency. 3. **Cache results** for repeated queries. ## Decision Override Behavior | Decision | Behavior | |----------|----------| | `DecisionUnset` | Normal database query | | `DecisionDeny` | Returns empty slice (no access) | | `DecisionAllow` | Falls through to database query | Note: `DecisionAllow` cannot enumerate "all" objects, so it performs the normal query. If you need to return all objects when in admin mode, query your database directly. ## Caching `ListObjects` does **not** use the single-tuple permission cache. For caching list results, implement application-level caching: **Go:** ```go type CachedChecker struct { *melange.Checker listCache *lru.Cache } func (c *CachedChecker) ListObjectsAll(ctx context.Context, subject SubjectLike, relation RelationLike, objectType ObjectType) ([]string, error) { key := fmt.Sprintf("list:%s:%s:%s:%s", subject.FGASubject().Type, subject.FGASubject().ID, relation.FGARelation(), objectType, ) if cached, ok := c.listCache.Get(key); ok { return cached.([]string), nil } ids, err := c.Checker.ListObjectsAll(ctx, subject, relation, objectType) if err != nil { return nil, err } c.listCache.Add(key, ids) return ids, nil } ``` **TypeScript:** ```typescript import { LRUCache } from 'lru-cache'; const listCache = new LRUCache({ max: 1000, ttl: 60 * 1000, // 1 minute }); async function listObjectsCached( subjectType: string, subjectId: string, relation: string, objectType: string ): Promise { const key = `list:${subjectType}:${subjectId}:${relation}:${objectType}`; const cached = listCache.get(key); if (cached) { return cached; } const { rows } = await pool.query( 'SELECT object_id FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', [subjectType, subjectId, relation, objectType, null, null] ); const ids = rows.map(r => r.object_id); listCache.set(key, ids); return ids; } ``` ## Common Patterns ### Paginated Access Control **Go:** ```go func GetAccessibleRepos(ctx context.Context, user User, cursor *string, pageSize int) ([]Repository, *string, error) { // Get a page of accessible IDs with built-in pagination ids, nextCursor, err := checker.ListObjects(ctx, user, "can_read", "repository", melange.PageOptions{Limit: pageSize, After: cursor}) if err != nil { return nil, nil, err } if len(ids) == 0 { return []Repository{}, nil, nil } // Fetch the repos for this page repos, err := db.GetRepositoriesByIDs(ctx, ids) return repos, nextCursor, err } ``` **TypeScript:** ```typescript async function getAccessibleRepos( userId: string, cursor: string | null, pageSize: number ): Promise<{ repos: Repository[]; nextCursor: string | null }> { // Get a page of accessible IDs with built-in pagination const { rows } = await pool.query( 'SELECT object_id, next_cursor FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', userId, 'can_read', 'repository', pageSize, cursor] ); if (rows.length === 0) { return { repos: [], nextCursor: null }; } const ids = rows.map(r => r.object_id); const nextCursor = rows[rows.length - 1].next_cursor; // Fetch the repos for this page const repos = await db.getRepositoriesByIds(ids); return { repos, nextCursor }; } ``` ### Admin Override **Go:** ```go func GetVisibleRepos(ctx context.Context, user User, isAdmin bool) ([]Repository, error) { if isAdmin { // Admins see everything return db.GetAllRepositories(ctx) } // Regular users see only accessible repos ids, err := checker.ListObjectsAll(ctx, user, "can_read", "repository") if err != nil { return nil, err } return db.GetRepositoriesByIDs(ctx, ids) } ``` **TypeScript:** ```typescript async function getVisibleRepos(userId: string, isAdmin: boolean): Promise { if (isAdmin) { // Admins see everything return db.getAllRepositories(); } // Regular users see only accessible repos (NULL limit returns all) const { rows } = await pool.query( 'SELECT object_id FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', userId, 'can_read', 'repository', null, null] ); const ids = rows.map(r => r.object_id); return db.getRepositoriesByIds(ids); } ``` ## Transaction Consistency Paginated queries across multiple calls can observe changes between pages. For consistency-critical flows, run paging inside a transaction with repeatable-read or snapshot semantics: **Go:** ```go // For consistent pagination across pages, use a transaction tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) if err != nil { return err } defer tx.Rollback() txChecker := melange.NewChecker(tx) var allIDs []string var cursor *string for { ids, next, err := txChecker.ListObjects(ctx, user, "can_read", "document", melange.PageOptions{Limit: 100, After: cursor}) if err != nil { return err } allIDs = append(allIDs, ids...) if next == nil { break } cursor = next } tx.Commit() ``` **SQL:** ```sql -- For consistent pagination, use a transaction BEGIN ISOLATION LEVEL REPEATABLE READ; -- First page SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, NULL); -- Subsequent pages within the same transaction see consistent data SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, 'doc-100'); COMMIT; ``` ## See Also - [Listing Subjects](https://melange.sh/docs/guides/listing-subjects.md) - Find who has access to an object - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md) - Single permission checks --- # Listing Subjects Source: https://melange.sh/docs/guides/listing-subjects/ > List every subject with access to an object with Melange's ListSubjects, including pagination and performance tips. `ListSubjects` returns all subjects of a given type that have a specific relation on an object. ## Basic Usage **Go:** ```go // Find all users who can read a repository (with pagination) userIDs, cursor, err := checker.ListSubjects(ctx, authz.Repository("456"), authz.RelCanRead, "user", melange.PageOptions{Limit: 100}, ) if err != nil { return err } // userIDs = ["alice", "bob", "carol"] // cursor = nil when no more pages, or a string to fetch the next page ``` **TypeScript:** ```typescript // Find all users who can read a repository (with pagination) const { rows } = await pool.query( 'SELECT subject_id, next_cursor FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', '456', 'can_read', 'user', 100, null] ); const userIds = rows.map(row => row.subject_id); const nextCursor = rows.length > 0 ? rows[0].next_cursor : null; // userIds = ["alice", "bob", "carol"] ``` **SQL:** ```sql -- Get all users who can view document 456 (first 100) SELECT subject_id, next_cursor FROM list_accessible_subjects('document', '456', 'viewer', 'user', 100, NULL); -- Returns a table with subject_id and next_cursor columns -- next_cursor is NULL when no more pages exist ``` ## Parameters | Parameter | Type | Description | |-----------|------|-------------| | `object_type` | `text` | Type of the object (e.g., `'repository'`) | | `object_id` | `text` | ID of the object | | `relation` | `text` | The relation to check | | `subject_type` | `text` | Type of subjects to return | | `p_limit` | `int` | Maximum number of results per page (NULL = no limit) | | `p_after` | `text` | Cursor from previous page (NULL = start from beginning) | ## Return Value Returns a table with `subject_id` and `next_cursor` columns. The `next_cursor` value is repeated on every row for convenience - use the last row's cursor to fetch the next page. Returns an empty result set if no subjects found (not an error). > **Note:** **Ordering**: Results are ordered with wildcard subjects (`'*'`) first, then alphabetically by `subject_id`. This ensures stable pagination while keeping wildcard entries grouped at the top. ## Pagination ### Cursor-Based Pagination Use cursor-based pagination to iterate through large result sets efficiently: **Go:** ```go // Paginate through all users with access var cursor *string for { ids, next, err := checker.ListSubjects(ctx, authz.Repository("456"), authz.RelCanRead, "user", melange.PageOptions{Limit: 100, After: cursor}, ) if err != nil { return err } for _, id := range ids { // Process each user ID fmt.Println("Has access:", id) } if next == nil { break // No more pages } cursor = next } ``` **TypeScript:** ```typescript // Paginate through all users with access let cursor: string | null = null; while (true) { const { rows } = await pool.query( 'SELECT subject_id, next_cursor FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, 'can_read', 'user', 100, cursor] ); for (const row of rows) { // Process each user ID console.log('Has access:', row.subject_id); } cursor = rows.length > 0 ? rows[rows.length - 1].next_cursor : null; if (!cursor) break; // No more pages } ``` **SQL:** ```sql -- First page SELECT subject_id, next_cursor FROM list_accessible_subjects('document', '456', 'viewer', 'user', 100, NULL); -- Returns: subject_id | next_cursor -- * | user-100 (wildcard first) -- alice | user-100 -- bob | user-100 -- ... -- Next page (use the next_cursor value) SELECT subject_id, next_cursor FROM list_accessible_subjects('document', '456', 'viewer', 'user', 100, 'user-100'); ``` ### Fetching All Results For convenience, use the `ListSubjectsAll` helper which automatically paginates through all results: **Go:** ```go // Get all users who can read (auto-paginates internally) allUserIDs, err := checker.ListSubjectsAll(ctx, authz.Repository("456"), authz.RelCanRead, "user", ) if err != nil { return err } // allUserIDs contains all user IDs with access ``` > **Warning:** **Use with caution**: `ListSubjectsAll` loads all IDs into memory. For large datasets, prefer paginated queries with `ListSubjects` to control memory usage. ## Examples ### Show Access List Display who has access to a resource: **Go:** ```go func GetAccessList(ctx context.Context, repo Repository) ([]AccessEntry, error) { var entries []AccessEntry // Get users with each permission level permissions := []string{"owner", "admin", "can_write", "can_read"} for _, perm := range permissions { userIDs, err := checker.ListSubjectsAll(ctx, repo, perm, "user") if err != nil { return nil, err } for _, id := range userIDs { entries = append(entries, AccessEntry{ UserID: id, Permission: perm, }) } } return entries, nil } ``` **TypeScript:** ```typescript interface AccessEntry { userId: string; permission: string; } async function getAccessList(repoId: string): Promise { const permissions = ['owner', 'admin', 'can_write', 'can_read']; const entries: AccessEntry[] = []; for (const perm of permissions) { const { rows } = await pool.query( 'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, perm, 'user', null, null] ); for (const row of rows) { entries.push({ userId: row.subject_id, permission: perm, }); } } return entries; } ``` **SQL:** ```sql -- Join with users table to get full user records (NULL limit returns all) SELECT u.* FROM users u JOIN list_accessible_subjects('document', '456', 'viewer', 'user', NULL, NULL) a ON u.id::text = a.subject_id; ``` ### Check for Any Access Verify at least one user has access: **Go:** ```go func HasAnyViewers(ctx context.Context, doc Document) (bool, error) { // Use Limit: 1 to efficiently check for any results viewers, _, err := checker.ListSubjects(ctx, doc, "can_read", "user", melange.PageOptions{Limit: 1}) if err != nil { return false, err } return len(viewers) > 0, nil } ``` **TypeScript:** ```typescript async function hasAnyViewers(docId: string): Promise { // Use limit 1 to efficiently check for any results const { rows } = await pool.query( 'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6) LIMIT 1', ['document', docId, 'can_read', 'user', 1, null] ); return rows.length > 0; } ``` **SQL:** ```sql -- Check if anyone has access (returns true/false) SELECT EXISTS( SELECT 1 FROM list_accessible_subjects('document', '456', 'viewer', 'user', 1, NULL) ); ``` ### Notify All Users with Access **Go:** ```go func NotifyCollaborators(ctx context.Context, repo Repository, message string) error { // Get all users who can read (using ListSubjectsAll) userIDs, err := checker.ListSubjectsAll(ctx, repo, "can_read", "user") if err != nil { return err } for _, userID := range userIDs { if err := notificationService.Send(ctx, userID, message); err != nil { log.Printf("Failed to notify user %s: %v", userID, err) } } return nil } ``` **TypeScript:** ```typescript async function notifyCollaborators(repoId: string, message: string): Promise { // Get all users who can read (NULL limit returns all) const { rows } = await pool.query( 'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, 'can_read', 'user', null, null] ); for (const row of rows) { try { await notificationService.send(row.subject_id, message); } catch (err) { console.error(`Failed to notify user ${row.subject_id}:`, err); } } } ``` ### Audit Access **Go:** ```go type AccessAuditEntry struct { ObjectType string ObjectID string UserID string Permission string Timestamp time.Time } func AuditRepositoryAccess(ctx context.Context, repo Repository) ([]AccessAuditEntry, error) { var audit []AccessAuditEntry permissions := []string{"owner", "admin", "can_write", "can_read"} for _, perm := range permissions { userIDs, err := checker.ListSubjectsAll(ctx, repo, perm, "user") if err != nil { return nil, err } for _, userID := range userIDs { audit = append(audit, AccessAuditEntry{ ObjectType: "repository", ObjectID: fmt.Sprint(repo.ID), UserID: userID, Permission: perm, Timestamp: time.Now(), }) } } return audit, nil } ``` **TypeScript:** ```typescript interface AccessAuditEntry { objectType: string; objectId: string; userId: string; permission: string; timestamp: Date; } async function auditRepositoryAccess(repoId: string): Promise { const permissions = ['owner', 'admin', 'can_write', 'can_read']; const audit: AccessAuditEntry[] = []; for (const perm of permissions) { const { rows } = await pool.query( 'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, perm, 'user', null, null] ); for (const row of rows) { audit.push({ objectType: 'repository', objectId: repoId, userId: row.subject_id, permission: perm, timestamp: new Date(), }); } } return audit; } ``` ## Performance Characteristics Cost grows with the part of the permission graph Postgres walks to find the subjects, which can be more than the number of subjects returned. Measured on managed Postgres from a VM in the same zone: - Direct members of an organization (20 to 200): under 1.1 ms. - Readers inherited from a parent (20 to 200 accessible subjects): first-page calls took about 1 to 6.1 ms, depending on scale and limit. - Viewers of a document through nested groups and folders (16 to 169): 11 to 106 ms as the dataset grows from 4K to 840K tuples. - The measured exclusion over that document relation took 20 to 220 ms, even though the selected document's wildcard block removed every subject. A page limit bounds the payload but may leave expensive enumeration in place; verify its effect in your query plan. Keep broad ListSubjects off hot request paths. See [Performance](https://melange.sh/docs/reference/performance.md#listsubjects) for the full table. ## Decision Override Behavior | Decision | Behavior | |----------|----------| | `DecisionUnset` | Normal database query | | `DecisionDeny` | Returns empty slice (no subjects have access) | | `DecisionAllow` | Falls through to database query | Note: `DecisionAllow` cannot enumerate "all" subjects, so it performs the normal query. ## Caching Like `ListObjects`, `ListSubjects` does **not** use the permission cache. Implement application-level caching if needed: **Go:** ```go func (c *CachedChecker) ListSubjectsAll(ctx context.Context, object ObjectLike, relation RelationLike, subjectType ObjectType) ([]string, error) { key := fmt.Sprintf("subjects:%s:%s:%s:%s", object.FGAObject().Type, object.FGAObject().ID, relation.FGARelation(), subjectType, ) if cached, ok := c.cache.Get(key); ok { return cached.([]string), nil } ids, err := c.Checker.ListSubjectsAll(ctx, object, relation, subjectType) if err != nil { return nil, err } c.cache.Add(key, ids) return ids, nil } ``` **TypeScript:** ```typescript import { LRUCache } from 'lru-cache'; const subjectCache = new LRUCache({ max: 1000, ttl: 60 * 1000, // 1 minute }); async function listSubjectsCached( objectType: string, objectId: string, relation: string, subjectType: string ): Promise { const key = `subjects:${objectType}:${objectId}:${relation}:${subjectType}`; const cached = subjectCache.get(key); if (cached) { return cached; } const { rows } = await pool.query( 'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', [objectType, objectId, relation, subjectType, null, null] ); const ids = rows.map(r => r.subject_id); subjectCache.set(key, ids); return ids; } ``` ## Common Patterns ### Permission Comparison Compare who has what level of access: **Go:** ```go type PermissionBreakdown struct { Owners []string Admins []string Writers []string Readers []string } func GetPermissionBreakdown(ctx context.Context, repo Repository) (*PermissionBreakdown, error) { var ( owners, admins, writers, readers []string ownerErr, adminErr, writerErr, readerErr error ) var wg sync.WaitGroup wg.Add(4) go func() { defer wg.Done() owners, ownerErr = checker.ListSubjectsAll(ctx, repo, "owner", "user") }() go func() { defer wg.Done() admins, adminErr = checker.ListSubjectsAll(ctx, repo, "admin", "user") }() go func() { defer wg.Done() writers, writerErr = checker.ListSubjectsAll(ctx, repo, "can_write", "user") }() go func() { defer wg.Done() readers, readerErr = checker.ListSubjectsAll(ctx, repo, "can_read", "user") }() wg.Wait() // Check errors... return &PermissionBreakdown{ Owners: owners, Admins: admins, Writers: writers, Readers: readers, }, nil } ``` **TypeScript:** ```typescript interface PermissionBreakdown { owners: string[]; admins: string[]; writers: string[]; readers: string[]; } async function getPermissionBreakdown(repoId: string): Promise { // Fetch all permission sets in parallel (NULL limit returns all) const [ownersRes, adminsRes, writersRes, readersRes] = await Promise.all([ pool.query('SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, 'owner', 'user', null, null]), pool.query('SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, 'admin', 'user', null, null]), pool.query('SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, 'can_write', 'user', null, null]), pool.query('SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, 'can_read', 'user', null, null]), ]); return { owners: ownersRes.rows.map(r => r.subject_id), admins: adminsRes.rows.map(r => r.subject_id), writers: writersRes.rows.map(r => r.subject_id), readers: readersRes.rows.map(r => r.subject_id), }; } ``` ### Team Members via Object When teams are modeled as objects: **Go:** ```go // Schema: type team // relations // define member: [user] func GetTeamMembers(ctx context.Context, teamID string) ([]User, error) { team := melange.Object{Type: "team", ID: teamID} memberIDs, err := checker.ListSubjectsAll(ctx, team, "member", "user") if err != nil { return nil, err } return db.GetUsersByIDs(ctx, memberIDs) } ``` **TypeScript:** ```typescript // Schema: type team // relations // define member: [user] async function getTeamMembers(teamId: string): Promise { const { rows } = await pool.query( 'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['team', teamId, 'member', 'user', null, null] ); const memberIds = rows.map(r => r.subject_id); return db.getUsersByIds(memberIds); } ``` **SQL:** ```sql -- Get all team members with access (userset filter) -- Note: Use 'team#member' as subject_type to filter by userset SELECT subject_id, next_cursor FROM list_accessible_subjects('document', '456', 'viewer', 'team#member', NULL, NULL); ``` ## Transaction Consistency Paginated queries across multiple calls can observe changes between pages. For consistency-critical flows, run paging inside a transaction with repeatable-read or snapshot semantics: **Go:** ```go // For consistent pagination across pages, use a transaction tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) if err != nil { return err } defer tx.Rollback() txChecker := melange.NewChecker(tx) var allIDs []string var cursor *string for { ids, next, err := txChecker.ListSubjects(ctx, repo, "can_read", "user", melange.PageOptions{Limit: 100, After: cursor}) if err != nil { return err } allIDs = append(allIDs, ids...) if next == nil { break } cursor = next } tx.Commit() ``` **SQL:** ```sql -- For consistent pagination, use a transaction BEGIN ISOLATION LEVEL REPEATABLE READ; -- First page SELECT subject_id, next_cursor FROM list_accessible_subjects('repository', '456', 'can_read', 'user', 100, NULL); -- Subsequent pages within the same transaction see consistent data SELECT subject_id, next_cursor FROM list_accessible_subjects('repository', '456', 'can_read', 'user', 100, 'user-100'); COMMIT; ``` ## See Also - [Listing Objects](https://melange.sh/docs/guides/listing-objects.md) - Find what objects a subject can access - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md) - Single permission checks --- # Explaining Decisions Source: https://melange.sh/docs/guides/explaining-decisions/ > Use Explain to trace why a Melange permission check was allowed or denied, branch by branch. `Explain` returns the resolution tree behind a permission decision: every branch the engine walked, the matching tuples, and per-branch success or failure. Use it to debug a check or surface an audit path in an admin UI. Explain does more work per call than `Check` and returns a JSONB trace. Don't put it on a request-path hot loop; use `Check` there. ## Basic Usage **Go:** ```go import "github.com/pthm/melange/melange" checker := melange.NewChecker(db) trace, err := checker.Explain(ctx, melange.Object{Type: "user", ID: "alice"}, melange.Relation("viewer"), melange.Object{Type: "document", ID: "1"}, ) if err != nil { return err } if trace.Result != nil && *trace.Result { fmt.Println("allowed via", trace.Root.Type) } else { fmt.Println("denied; tried", len(trace.Root.Children), "branches") } ``` The signature matches `Check` (subject, relation, object) plus variadic `...ExplainOption`. The returned `*Trace` contains the boolean result, the resolution tree, and optional truncation metadata. **CLI:** ```text $ melange explain user:alice viewer document:1 --db postgres://localhost/mydb ✓ user:alice has viewer on document:1 └── via userset: via [group#member] → group:engineering └── direct: user:alice → member → group:engineering ``` Subject and object use `:` form. The default tree format is for terminals; use `--format=json` for the raw `Trace` JSONB. **SQL:** ```sql -- Inspect a successful permission SELECT jsonb_pretty(explain_permission('user', 'alice', 'viewer', 'document', '1')); ``` Returns JSONB matching the runtime's `Trace` type. Signature in the [SQL API reference](https://melange.sh/docs/reference/sql-api.md#explain_permission). ## Reading a Failure Trace On denial, the trace records every attempted branch with `Result: false` on each missed leaf, showing which grants are missing. **CLI:** ```text $ melange explain user:bob viewer document:1 ✗ user:bob does NOT have viewer on document:1 └── union of 3 branches ├── ✗ no direct grant ├── ✗ implied: implied via editor │ └── union of 1 branches │ └── ✗ no direct grant └── ✗ via userset: via [group#member] → group:engineering └── union of 1 branches └── ✗ no direct grant ``` The root is a `union` of three attempts: direct grant (no tuple), implied via `editor` (no editor tuple), userset via `[group#member]` (bob isn't in the group). Adding any of the three missing tuples grants `viewer`. **Go:** ```go trace, _ := checker.Explain(ctx, bob, "viewer", doc) if trace.Result != nil && !*trace.Result { for _, attempt := range trace.Root.Children { switch attempt.Type { case melange.NodeDirect: // No direct tuple. Add (user:bob, viewer, document:1). case melange.NodeImplied: // Closure relation also missed. case melange.NodeUserset: // Userset grant existed but bob's membership didn't. case melange.NodeTTU: // Linking tuple was there but the parent didn't grant. } } } ``` Each branch's `Children` holds the recursive sub-trace, so callers can walk the structure to render an audit path or surface a specific failure cause. ## The `Trace` Structure A `Trace` carries the boolean `Result` plus a `*Node` root whose `Children` recurse. Each `Node` has a discriminator `Type`, a human-readable `Label`, contributing tuples in `Evidence`, and a per-branch `Result`. JSON field names are snake_case to match the SQL columns. Full type signatures: [Go API reference](https://melange.sh/docs/reference/go-api.md#explain). TypeScript type mirrors: `clients/typescript/src/trace.ts`. ### Node Types | `Type` | Meaning | | -------------- | ---------------------------------------------------------------------------------------------------------------------- | | `direct` | Satisfied by a direct tuple in `melange_tuples`. `Evidence` carries the matching row. | | `implied` | Satisfied by a closure relation. `Children[0]` carries the underlying relation's trace. | | `userset` | Satisfied via a `[type#relation]` subject reference. `Children[0]` carries the membership relation's trace. | | `ttu` | Satisfied via `X from Y`. `Children[0]` carries the parent trace; the label inlines the resolved parent identifier. | | `union` | OR aggregation. Appears as the failure root when every attempt missed; each child is a recorded attempt. | | `intersection` | AND aggregation. `Children` carries every part; `Result=true` only when all children succeeded. | | `exclusion` | `but not X`. `Children[0]` is the base success; the node's `Result` reflects whether the exclusion fired. | | `wildcard` | `[type:*]` sentinel. Not enumerated. `Users[0]` carries the matched subject type; `id` is always `"*"`. | | `cycle` | Recursive resolution hit a cycle. `Label` carries the visited key that triggered detection. | | `truncated` | `p_max_nodes` budget was exhausted. The subtree below this point was omitted from the trace. | ## Wildcards A `[type:*]` grant matches as a `NodeWildcard` sentinel; the user list isn't enumerated. `Users[0].Type` holds the matched subject type; `id` is always `"*"`. ```json { "type": "wildcard", "users": [{"type": "user", "id": "*"}], "result": true } ``` The trace records a wildcard match rather than a specific subject id, making clear when a grant came from a `[type:*]` pattern. ## Truncation Three controls cap the node count, in priority order: 1. Per-call: `WithExplainMaxNodes(n)` in Go, `--max-nodes n` in the CLI, the `p_max_nodes` parameter in SQL. 2. Per-session: `SET melange.max_explain_nodes = N;`. Inherited by subsequent Explain calls unless overridden per-call. 3. Default: `100`. **Go:** ```go trace, err := checker.Explain(ctx, user, "viewer", doc, melange.WithExplainMaxNodes(50)) if trace.Truncated { log.Warn("trace was capped; retry with a larger budget for the full path") } ``` **CLI:** ```bash $ melange explain user:alice viewer document:1 --max-nodes 50 ``` **SQL:** ```sql -- Per-call SELECT explain_permission('user', 'alice', 'viewer', 'document', '1', 50); -- Per-session SET melange.max_explain_nodes = 200; SELECT explain_permission('user', 'alice', 'viewer', 'document', '1'); ``` When the cap is hit, `trace.Truncated` is `true`. The root may be `NodeTruncated` or a normal union, depending on where the overshoot landed. Check `Truncated` directly rather than inferring from `Root.Type`. ## Supported Schema Patterns Explain matches `Check` across the full supported schema feature set: - Direct grants (`[user]`, `[user:*]`) - Implied closure relations (`viewer: [user] or editor`), including recursive implication - TTU (`viewer from parent`), single and multi-level, single- and multi-type linking - Userset references (`[group#member]`), simple and complex (recursive-membership) - Intersection (`a and b`) with any part shape: plain relation, `[user]` inline, TTU-in-intersection, per-part exclusion - Exclusion (`a but not b`), including chained (`(a but not b) but not c`), TTU (`but not X from Y`), and intersection-group (`but not (A and B)`) subtrahends - Wildcards and per-call / per-session truncation Every `(object_type, relation)` pair in the OpenFGA compatibility suite generates a specialised `explain_*` function. The dispatcher's no-entry sentinel remains in the codebase as a runtime guard for pairs that don't exist in the schema: ```jsonc { "result": false, "root": { "type": "union", "label": "explain not yet supported for this (object_type, relation) — ..." } } ``` A sentinel response means the requested pair has no generated function. Check what you passed against the migrated schema before treating it as a bug. ## See Also - [Expanding Permissions](https://melange.sh/docs/guides/expanding-permissions.md): the companion `Expand` API for "who has access?" - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md): the request-path `Check` API - [Caching](https://melange.sh/docs/guides/caching.md): opt-in caching for Explain traces via `ExplainCache` - [Troubleshooting](https://melange.sh/docs/guides/troubleshooting.md): when checks return the wrong answer - [Studio](https://melange.sh/docs/guides/studio.md): run Explain, with what-if tuples, from a local web console - [Go API reference](https://melange.sh/docs/guides/reference/go-api/#explain): `Checker.Explain` signature - [SQL API reference](https://melange.sh/docs/guides/reference/sql-api/#explain_permission): `explain_permission` SQL function - [CLI reference](https://melange.sh/docs/guides/reference/cli/#explain): `melange explain` command --- # Expanding Permissions Source: https://melange.sh/docs/guides/expanding-permissions/ > Use Expand to get the OpenFGA userset tree for an object and relation, for audit UIs and access reviews. `Expand` returns the OpenFGA-shaped `UsersetTree` for a `(object, relation)` pair — the structured "who has access?" answer. Use it to surface an audit UI, generate an access-review export, or debug why a specific subject ended up in the result of `ListSubjects`. Expand does more work per call than `Check` or `ListSubjects` and returns a JSONB tree. Don't put it on a request-path hot loop; use `Check` there. ## Basic Usage **Go:** ```go import "github.com/pthm/melange/melange" checker := melange.NewChecker(db) tree, err := checker.Expand(ctx, melange.Object{Type: "document", ID: "1"}, melange.Relation("viewer"), ) if err != nil { return err } for _, u := range tree.FlattenUsers() { fmt.Println(u) } ``` `Expand` takes an object and a relation. The returned `*UsersetTree` mirrors `openfgav1.UsersetTree` field-for-field so existing OpenFGA tooling deserialises the JSON without an adapter. `FlattenUsers` collects every `Leaf.Users` entry across the returned tree without issuing additional queries. **CLI:** ```bash $ melange expand document:1 viewer --db postgres://localhost/mydb document:1#viewer • union of 2 ├── document:1#viewer • users │ ├── user:alice │ ├── user:bob │ └── group:eng#member └── document:1#viewer • computed pointer └── computed → document:1#editor (melange expand document:1#editor to chase) ``` `--format=json` returns the raw `UsersetTree` JSONB. `--flatten` calls `ExpandRecursive` and prints the flat, deduplicated user list. **SQL:** ```sql SELECT jsonb_pretty(expand_permission('document', '1', 'viewer')); ``` Returns JSONB matching the runtime's `UsersetTree` type. Signature in the [SQL API reference](https://melange.sh/docs/reference/sql-api.md#expand_permission). ## Shallow by Default `Expand` resolves one level. Computed rewrites (`define viewer: editor`) surface as `Leaf.Computed` pointers; TTU rewrites (`viewer from parent`) surface as `Leaf.TupleToUserset` pointers. The caller chases pointers with follow-up Expand calls: ```go if node.Leaf != nil && node.Leaf.Computed != nil { obj, rel := parsePointer(node.Leaf.Computed.Userset) // "document:1#editor" subtree, _ := checker.Expand(ctx, obj, rel) // ... recurse into subtree } ``` This matches OpenFGA's Expand behaviour: consumers walk the tree at their own pace instead of the server materialising every layer up front. For flows that want a flat user list, use `ExpandRecursive`: **Go:** ```go users, err := checker.ExpandRecursive(ctx, melange.Object{Type: "document", ID: "1"}, melange.Relation("viewer"), ) // users = ["user:alice", "user:bob", "user:carol", "group:eng#member", "user:*"] ``` `ExpandRecursive` walks `Leaf.Computed` and `Leaf.TupleToUserset` pointers with additional Expand calls until the graph is exhausted. Cycle-safe: every `(object, relation)` pair is expanded at most once per call. Wildcards and userset references survive as their string forms; the walker does not chase userset refs because OpenFGA models them as inline subjects, not pointers. Cost is N round-trips for N distinct pointers. Suitable for admin flows, not the request path. **CLI:** ```bash $ melange expand document:1 viewer --recursive user:alice user:bob user:carol group:eng#member user:* ``` ## Tree Structure A `UsersetTree` carries a single `Root *UsersetTreeNode`. Every node has a `Name` (`":#"`) and exactly one of `Leaf` / `Union` / `Intersection` / `Difference` populated: | Slot | Meaning | |------|---------| | `Leaf.Users` | Resolved direct grants: `["user:alice", "group:eng#member", "user:*"]` | | `Leaf.Computed` | Unresolved pointer to another userset (chased via follow-up Expand) | | `Leaf.TupleToUserset` | Unresolved TTU pointer: tupleset naming the linking relation + one Computed per linked object | | `Union` | OR-aggregated children — the relation has multiple rewrites | | `Intersection` | AND-aggregated children — `a and b` | | `Difference` | Named `base` / `subtract` slots — `a but not b` | Full Go type signatures: [Go API reference](https://melange.sh/docs/guides/reference/go-api/#expand). ### Direct Grants + Wildcards + Userset References All three shapes live in `Leaf.Users` as OpenFGA-formatted strings: ```json { "leaf": { "users": { "users": ["user:alice", "user:bob", "group:eng#member", "user:*"] } } } ``` Wildcards (`user:*`) mean "every subject of that type" — the tree never enumerates them. Userset references (`group:eng#member`) mean "every member of `group:eng`" — chase with a follow-up Expand call on `("group:eng", "member")` if you need the concrete users. `FlattenUsers` returns userset refs and wildcards as-is; the consumer decides whether to resolve them further. ### Intersection and Difference Intersection nodes list every AND-part as a child; a subject is in the result only if it's in every child's leaf set. Difference nodes carry named `Base` and `Subtract` slots; a subject is in the result if it's in `Base` but not in `Subtract`. Both shapes preserve structure so consumers can render an audit tree, not just a flat list. ## Melange Extensions Two options extend OpenFGA's Expand without breaking the wire shape. ### Subject-Type Filter Narrow `Leaf.Users` to a single subject type: **Go:** ```go tree, err := checker.Expand(ctx, doc, "viewer", melange.WithSubjectTypeFilter("user")) ``` **CLI:** ```bash $ melange expand document:1 viewer --subject-type=user ``` **SQL:** ```sql SELECT expand_permission('document', '1', 'viewer', 'user'); ``` Grants for other subject types are still visible in the tree structure (Computed pointers, TTU pointers, etc.) but concrete `Leaf.Users` lists are filtered. ### Per-Leaf Cap Cap each `Leaf.Users` list. When the cap fires, the leaf gets a `UsersTruncated: true` field so consumers know the list is incomplete. Three-tier precedence, per-call > session GUC > default: **Go:** ```go tree, err := checker.Expand(ctx, doc, "viewer", melange.WithExpandMaxLeaf(100)) ``` **CLI:** ```bash $ melange expand document:1 viewer --max-leaf 100 ``` **SQL:** ```sql -- Per-call SELECT expand_permission('document', '1', 'viewer', NULL, 100); -- Per-session SET melange.max_expand_leaf = 500; SELECT expand_permission('document', '1', 'viewer'); ``` Default is unbounded (matching OpenFGA). Set the GUC as a safety belt on connections that serve admin flows. ## Wildcards A `[user:*]` grant survives as a `"user:*"` entry in `Leaf.Users`: ```json {"leaf": {"users": {"users": ["user:*", "user:alice"]}}} ``` Consumers treat any `":*"` suffix as "every subject of that type". Wildcards are never enumerated to the implied user set. ## Supported Schema Patterns Expand matches `Check` across the full supported schema feature set: - Direct grants (`[user]`, `[user:*]`, `[group#member]`) - Computed rewrites (`define viewer: editor`) as `Leaf.Computed` pointers - TTU rewrites (`viewer from parent`) as `Leaf.TupleToUserset` pointers - Union, intersection (any part shape), and difference (chained, TTU-excluded, intersection-excluded) - Wildcards and userset references inlined in `Leaf.Users` Every `(object_type, relation)` pair in the OpenFGA compatibility suite generates a specialised `expand_*` function. The dispatcher returns an empty `Leaf.Users` sentinel for pairs that don't exist in the schema; a sentinel response means the requested pair was not migrated. ## Caching Opt-in via `ExpandCache` — see [Caching](https://melange.sh/docs/guides/expanding-permissions/caching/#expandcache). ## See Also - [Explaining Decisions](https://melange.sh/docs/guides/explaining-decisions.md): the companion `Explain` API for "why (or why not)?" - [Studio](https://melange.sh/docs/guides/studio.md): browse Expand trees one pointer at a time in a local web console - [Listing Subjects](https://melange.sh/docs/guides/listing-subjects.md): request-path listing without the tree structure - [Caching](https://melange.sh/docs/guides/caching.md): opt-in caching for Expand trees via `ExpandCache` - [Go API reference](https://melange.sh/docs/guides/reference/go-api/#expand): `Checker.Expand` / `ExpandRecursive` signatures - [SQL API reference](https://melange.sh/docs/guides/reference/sql-api/#expand_permission): `expand_permission` SQL function - [CLI reference](https://melange.sh/docs/guides/reference/cli/#expand): `melange expand` command --- # Contextual Tuples Source: https://melange.sh/docs/guides/contextual-tuples/ > Pass temporary tuples at check time to evaluate permissions against data that is not stored in the database. Contextual tuples are temporary tuples injected at check time. They are not persisted to the database and only affect the single check or list call they are passed to. ## When to Use Contextual tuples are for authorization data that exists at request time but is not stored in your domain tables: - **IP-based access**: inject an `ip_allowed` tuple based on the client's IP address. - **Time-based access**: inject a `within_window` tuple based on the current time. - **Feature flags**: inject a `feature_enabled` tuple based on a feature flag service. - **Request context**: inject tuples derived from headers, tokens, or session data. ## Go API ```go tuples := []melange.ContextualTuple{ { Subject: melange.Object{Type: "user", ID: "alice"}, Relation: melange.Relation("ip_allowed"), Object: melange.Object{Type: "network", ID: "office"}, }, } allowed, err := checker.CheckWithContextualTuples(ctx, user, "can_access", resource, tuples) ``` List operations also support contextual tuples: ```go ids, cursor, err := checker.ListObjectsWithContextualTuples(ctx, user, "can_read", "document", tuples, page) ids, cursor, err := checker.ListSubjectsWithContextualTuples(ctx, user, "can_read", "user", tuples, page) ``` If `tuples` is empty, these methods delegate to their non-contextual equivalents. ## SQL-Level Implementation Contextual tuples work by temporarily shadowing the `melange_tuples` view: 1. A session-scoped temporary table `melange_contextual_tuples` is created. 2. The contextual tuples are inserted into this table. 3. A temporary view `melange_tuples` is created that `UNION ALL`s the base view with the temporary table. 4. The permission check runs against this combined view. 5. Both temporary objects are dropped after the check. Because PostgreSQL resolves temporary objects before schema-qualified ones, the generated SQL functions see the combined tuples without modification. ## Connection Requirements Temporary objects in PostgreSQL are session-scoped (tied to a specific connection). The setup, check, and cleanup must all happen on the same connection. | Querier Type | Contextual Tuples | |--------------|-------------------| | `*sql.DB` | Supported. A dedicated connection is acquired from the pool, pinned for the operation, and returned afterward. | | `*sql.Tx` | Supported. Already on a single connection. Also sees uncommitted changes. | | `*sql.Conn` | Supported. Already pinned to a single connection. | | Custom `Querier` | Returns `ErrContextualTuplesUnsupported`. | ## Bulk Check with Contextual Tuples ```go results, err := checker.NewBulkCheck(ctx). Add(user, "can_read", doc1). Add(user, "can_write", doc1). WithContextualTuples( melange.ContextualTuple{ Subject: melange.Object{Type: "user", ID: "alice"}, Relation: melange.Relation("ip_allowed"), Object: melange.Object{Type: "network", ID: "office"}, }, ). Execute() ``` The contextual tuples are set up once before all checks in the batch and cleaned up afterward. ## Validation Contextual tuples are validated before setup. With a `Validator` attached to the checker, each tuple is checked against the schema. Without a validator, basic shape validation runs: - If a subject ID contains `#` (userset format like `group:123#member`), the part after `#` must not be empty. Invalid tuples return `ErrInvalidContextualTuple` with the tuple index. ## Caching Checks with contextual tuples bypass the cache entirely. Each call goes directly to the database. This is by design: contextual tuples are unique to each request, so cached results would be incorrect. ## Performance Each call with contextual tuples creates a temporary view that adds the tuples to `melange_tuples`, runs the query, and drops the view. On Cloud SQL, measured from a VM in the same zone, a check took about 7 ms with one contextual tuple and 10 ms with 100, against 0.4 ms without. List queries with contextual tuples took 7 to 11 ms. See [Performance](https://melange.sh/docs/reference/performance.md#contextual-tuples). For high-throughput paths, consider whether the authorization data can be modelled in your domain tables instead. ## Example Patterns ### IP Allowlist Schema: ```fga type network relations define ip_allowed: [user] type resource relations define can_access: [user] and ip_allowed from network ``` Application code: ```go tuples := []melange.ContextualTuple{ { Subject: melange.Object{Type: "user", ID: userID}, Relation: melange.Relation("ip_allowed"), Object: melange.Object{Type: "network", ID: "corporate"}, }, } // Only inject the tuple if the IP is in the allowlist if isAllowedIP(clientIP) { allowed, err = checker.CheckWithContextualTuples(ctx, user, "can_access", resource, tuples) } else { allowed, err = checker.Check(ctx, user, "can_access", resource) } ``` ### Temporal Access ```go // Grant access only during a maintenance window var tuples []melange.ContextualTuple if time.Now().Before(maintenanceEnd) { tuples = append(tuples, melange.ContextualTuple{ Subject: melange.Object{Type: "user", ID: userID}, Relation: melange.Relation("maintenance_access"), Object: melange.Object{Type: "system", ID: "prod"}, }) } allowed, err := checker.CheckWithContextualTuples(ctx, user, "can_deploy", system, tuples) ``` ## Next Steps - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md): core Checker API - [Go API](https://melange.sh/docs/reference/go-api.md): full method signatures and types - [Errors](https://melange.sh/docs/reference/errors.md): `ErrContextualTuplesUnsupported` and `ErrInvalidContextualTuple` --- # Replacing Conditions Source: https://melange.sh/docs/guides/conditions/ > Express OpenFGA conditions with SQL predicates in the tuples view, contextual tuples, and session settings. Melange rejects schemas that use [OpenFGA conditions](https://melange.sh/docs/reference/openfga-compatibility.md#conditions). Most conditions can still be expressed, because Melange has two places to put the logic that a CEL expression would hold: - **The tuples view.** `melange_tuples` is a SQL view over your tables, so a tuple can exist only while a SQL predicate holds. This covers conditions on stored data: expiry times, status flags, stored attributes. - **Contextual tuples.** Your application decides at request time whether to add a tuple. This covers conditions on request data: client IP, current time, feature flags, token claims. A third pattern combines the two: the application passes a request value to PostgreSQL as a session setting, and the view compares it against stored data. | Condition depends on | Put the logic in | Pattern | |----------------------|------------------|---------| | Stored data only | The tuples view | [Predicates in the view](#predicates-in-the-view) | | Request data only | The application | [Contextual tuples](#contextual-tuples) | | Request data compared with stored data | The tuples view, reading a session setting | [Session settings](#session-settings) | ## Predicates in the view In OpenFGA, a time-limited grant is a conditional tuple. The schema references the condition, and every tuple write carries the grant time and duration: ```fga # OpenFGA type document relations define viewer: [user with non_expired_grant] condition non_expired_grant(current_time: timestamp, grant_time: timestamp, grant_duration: duration) { current_time < grant_time + grant_duration } ``` In Melange, the schema drops the condition and the view only produces the tuple while the grant is live: ```fga type document relations define viewer: [user] ``` ```sql CREATE VIEW melange_tuples AS SELECT 'user' AS subject_type, user_id::text AS subject_id, 'viewer' AS relation, 'document' AS object_type, document_id::text AS object_id FROM document_grants WHERE expires_at > now(); ``` The grant parameters already live in your table, so the caller sends nothing. `now()` is evaluated when the check runs, which means an expired grant stops matching without any cleanup job. The same approach covers any condition over stored attributes: ```sql -- Only active members of an organization FROM organization_members WHERE status = 'active' -- Only documents that are not archived FROM documents WHERE archived_at IS NULL -- Only during a stored access window FROM shift_assignments WHERE now() BETWEEN starts_at AND ends_at ``` Every check, list, and expand call reads through the view, so the predicate applies to all of them. ## Contextual tuples Conditions that only depend on the request, such as "the client IP is on the corporate network", can be evaluated in the application. If the condition holds, pass a [contextual tuple](https://melange.sh/docs/guides/contextual-tuples.md) that satisfies the relation: ```fga type network relations define member: [user] type document relations define viewer: [user] define can_view: viewer and member from network define network: [network] ``` ```go var tuples []melange.ContextualTuple if isCorporateIP(clientIP) { tuples = append(tuples, melange.ContextualTuple{ Subject: melange.Object{Type: "user", ID: userID}, Relation: melange.Relation("member"), Object: melange.Object{Type: "network", ID: "corporate"}, }) } allowed, err := checker.CheckWithContextualTuples(ctx, user, "can_view", doc, tuples) ``` The condition can use anything the application can compute: time of day, request headers, JWT claims, feature flags, results from another service. Each call with contextual tuples sets up a temporary view, which adds a few milliseconds per call (see [Performance](https://melange.sh/docs/guides/contextual-tuples.md#performance)). For hot paths, prefer a session setting. ## Session settings Some conditions compare a request value with stored data. For example, each user has an allowed CIDR range, and access depends on whether the client IP falls inside it. OpenFGA writes the CIDR as a tuple parameter and the caller passes the IP as context. In Melange, store the CIDR in a table and pass the IP as a transaction-local setting. The view reads it with `current_setting`: ```sql CREATE VIEW melange_tuples AS -- ... other relations ... UNION ALL SELECT 'user' AS subject_type, user_id::text AS subject_id, 'member' AS relation, 'network' AS object_type, 'corporate' AS object_id FROM user_allowed_networks WHERE nullif(current_setting('app.client_ip', true), '')::inet <<= cidr; ``` Set the value in the same transaction as the check: ```go tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err := tx.ExecContext(ctx, "SELECT set_config('app.client_ip', $1, true)", clientIP); err != nil { return err } allowed, err := melange.NewChecker(tx).Check(ctx, user, "can_view", doc) ``` The `true` argument to `set_config` makes the setting local to the transaction (equivalent to `SET LOCAL`), so it cannot leak to the next user of a pooled connection. When the setting is missing, the predicate is `NULL` and the tuple is not produced, so the check denies. Two details make that hold: - `current_setting(name, true)` returns `NULL` instead of raising an error when the setting was never defined. - After a `SET LOCAL` ends, the same connection returns `''` rather than `NULL`. Without `nullif(..., '')`, the `::inet` cast fails and the check returns an error. Session settings cost nothing beyond the predicate itself, and the setting is visible to every check in the transaction. ## Common conditions | OpenFGA condition | Melange approach | |-------------------|------------------| | Grant expires after a duration | `WHERE expires_at > now()` in the view | | Access only within a time window | `WHERE now() BETWEEN starts_at AND ends_at` in the view | | Client IP in a fixed allowlist | Contextual tuple, computed in the application | | Client IP in a per-user or per-tenant range | Session setting compared with a stored `cidr` column | | Resource attribute matches (status, region, tier) | `WHERE` on the attribute column in the view | | Request attribute matches a stored attribute | Session setting compared with the column | | Feature flag or entitlement from another service | Contextual tuple | ## Things to know - **Caching.** The [built-in cache](https://melange.sh/docs/guides/caching.md) keys on subject, relation, and object only. It does not know about `now()`, session settings, or contextual tuples (calls with contextual tuples bypass it). Do not cache relations that depend on a session setting, and keep the TTL short for relations that depend on time. - **Missing context denies.** OpenFGA returns an error when a condition's parameters are missing. With the patterns above, a missing value produces no tuple, so the check denies. Make sure the application always sets the values it means to set. - **The schema no longer shows the rule.** The `.fga` file says `[user]` while the view decides which users qualify. Comment the relation in the schema to point at the view, and test the view with [authorization tests](https://melange.sh/docs/guides/testing-authorization.md). - **Indexes.** Predicates like `expires_at > now()` can use a normal B-tree index on `expires_at`. Partial indexes cannot reference `now()` because it is not immutable. - **Not covered.** A condition evaluated over parameters that only exist on the tuple write, with no table to store them in, needs a table first. Melange has no tuple store of its own. ## Next Steps - [Tuples View](https://melange.sh/docs/concepts/tuples-view.md): view schema, query patterns, and indexes - [Contextual Tuples](https://melange.sh/docs/guides/contextual-tuples.md): API and connection requirements - [OpenFGA Compatibility](https://melange.sh/docs/reference/openfga-compatibility.md): supported and unsupported features --- # Caching Source: https://melange.sh/docs/guides/caching/ > Use Melange's in-memory permission cache to serve repeated checks in nanoseconds, and when to avoid it. Melange's built-in cache reduces database load by storing permission check results in memory. Cached checks return in about 120 ns, compared to about 0.4 to 0.7 ms end to end for an uncached check against a managed database in the same zone (see [Performance](https://melange.sh/docs/reference/performance.md#caching)). ## Built-in Cache ```go cache := melange.NewCache(melange.WithTTL(5 * time.Minute)) checker := melange.NewChecker(db, melange.WithCache(cache)) // First check hits the database allowed, _ := checker.Check(ctx, user, "can_read", repo) // Subsequent identical checks hit the cache allowed, _ = checker.Check(ctx, user, "can_read", repo) ``` Characteristics: - **In-memory, process-local**. Not shared across processes. - **Thread-safe**. Safe for concurrent goroutines. - **Caches both allowed and denied results**. Denied checks are cached too. - **Unbounded within TTL**. Entries expire individually based on insertion time but the map grows without limit while entries are live. ### Options | Option | Description | |--------|-------------| | `WithTTL(d time.Duration)` | Time-to-live for entries. Default: no expiry. | ```go cache := melange.NewCache() // No expiry cache := melange.NewCache(melange.WithTTL(time.Minute)) // 1 minute TTL ``` ### Manual Operations ```go cache.Size() // Number of entries cache.Clear() // Remove all entries ``` ### What Is Cached `Check`, `CheckWithContextualTuples`, `Explain`, and `Expand` results all participate. List operations (`ListObjects`, `ListSubjects`) always query the database. Checks with contextual tuples bypass the cache entirely. Each contextual tuple check goes directly to the database because the temporary tuples are unique to that call. ## Explain and Expand Caching `Checker.Explain` and `Checker.Expand` consult and populate the same cache passed to `WithCache`, opt-in via interface satisfaction. The default `CacheImpl` satisfies all three: ```go cache := melange.NewCache(melange.WithTTL(5 * time.Minute)) checker := melange.NewChecker(db, melange.WithCache(cache)) // First call hits the database, populates the cache. trace1, _ := checker.Explain(ctx, user, "viewer", doc) // Second call hits the cache — even after the underlying tuple changes, // until the TTL expires or Clear is called. trace2, _ := checker.Explain(ctx, user, "viewer", doc) ``` The Checker type-asserts the injected cache to `ExplainCache` / `ExpandCache`; implementations that only satisfy `Cache` fall through to the DB on every Explain / Expand call. Existing Check-only cache implementations keep working without changes. ### Key Composition The cache key includes every option that affects output — different option values produce different traces, so they must not alias. | API | Cache key components | |-----|----------------------| | `Check` | subject, relation, object | | `Explain` | subject, relation, object, `MaxNodes` | | `Expand` | object, relation, `SubjectType` filter, `MaxLeaf` | Two Explain calls at different `WithExplainMaxNodes` values populate distinct entries because a truncated trace at one cap must not be served to a caller that passed a larger cap. Same for `Expand`'s `WithSubjectTypeFilter` and `WithExpandMaxLeaf`. ### Clearing `Clear()` resets all three families in one call. `Size()` returns the combined count. Tuple writes that would invalidate a cached Check would also invalidate cached Explain and Expand results, so one blanket Clear is the intended coarse-grained reset: ```go cache.Size() // 12 (4 Check + 5 Explain + 3 Expand) cache.Clear() // nukes everything cache.Size() // 0 ``` ## Request-Scoped Caching Create a fresh cache per request to avoid stale results across requests: **Go:** ```go func authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cache := melange.NewCache() checker := melange.NewChecker(db, melange.WithCache(cache)) ctx := context.WithValue(r.Context(), "checker", checker) next.ServeHTTP(w, r.WithContext(ctx)) }) } ``` Within a single request, repeated checks for the same permission hit the cache. The cache is discarded when the request ends. **TypeScript:** ```typescript import { Checker, MemoryCache } from '@pthm/melange'; function authMiddleware(req, res, next) { const cache = new MemoryCache(); req.checker = new Checker(pool, { cache }); next(); } ``` ## Long-Lived Cache For applications where permissions change infrequently, use a shared cache with a TTL: ```go // Create once at startup cache := melange.NewCache(melange.WithTTL(5 * time.Minute)) // Share across all requests func newChecker() *melange.Checker { return melange.NewChecker(db, melange.WithCache(cache)) } ``` TTL guidance: - **Short (seconds)**: frequently changing permissions, high security requirements. - **Medium (minutes)**: typical web applications. - **Long or none**: near-static permissions, performance-critical paths. The cache grows unbounded within the TTL window. For long-running processes with many unique permission tuples, use a short TTL or clear periodically. ## Custom Cache Implementations Three interfaces cover the three cache families. Implement only the ones you need — `Checker.Explain` and `Checker.Expand` fall through to the DB when their interface isn't satisfied. ```go type Cache interface { Get(subject Object, relation Relation, object Object) (allowed bool, err error, ok bool) Set(subject Object, relation Relation, object Object, allowed bool, err error) } type ExplainCache interface { GetExplain(subject Object, relation Relation, object Object, maxNodes int) (*Trace, error, bool) SetExplain(subject Object, relation Relation, object Object, maxNodes int, trace *Trace, err error) } type ExpandCache interface { GetExpand(object Object, relation Relation, subjectType ObjectType, maxLeaf int) (*UsersetTree, error, bool) SetExpand(object Object, relation Relation, subjectType ObjectType, maxLeaf int, tree *UsersetTree, err error) } ``` The default `CacheImpl` satisfies all three. Example Redis implementation: ```go type RedisCache struct { client *redis.Client ttl time.Duration } func (c *RedisCache) Get(subject melange.Object, relation melange.Relation, object melange.Object) (bool, error, bool) { key := fmt.Sprintf("perm:%s:%s:%s:%s:%s", subject.Type, subject.ID, relation, object.Type, object.ID) val, err := c.client.Get(ctx, key).Result() if err == redis.Nil { return false, nil, false // Not in cache } if err != nil { return false, nil, false // Treat Redis errors as cache miss } return val == "1", nil, true } func (c *RedisCache) Set(subject melange.Object, relation melange.Relation, object melange.Object, allowed bool, err error) { if err != nil { return // Don't cache errors } key := fmt.Sprintf("perm:%s:%s:%s:%s:%s", subject.Type, subject.ID, relation, object.Type, object.ID) val := "0" if allowed { val = "1" } c.client.Set(ctx, key, val, c.ttl) } ``` ## Cache and Transactions A checker created with `*sql.Tx` sees uncommitted changes. The cache does not differentiate between committed and uncommitted results. If you mix transactional and non-transactional checkers with the same cache, stale results are possible. For transactional workflows, use a dedicated cache (or no cache) for the transaction-scoped checker: ```go // Non-transactional checker with shared cache checker := melange.NewChecker(db, melange.WithCache(sharedCache)) // Transactional checker with its own cache txChecker := melange.NewChecker(tx, melange.WithCache(melange.NewCache())) ``` ## Cache Invalidation The built-in cache supports only TTL-based expiry. For event-driven invalidation: - **Clear on write**: call `cache.Clear()` after modifying domain tables that feed `melange_tuples`. - **Key-targeted invalidation**: implement `Cache` with a backend that supports key deletion. - **Hybrid**: use a short TTL combined with event-driven clearing for critical paths. ## When Not to Cache - **Transactional checks that must see uncommitted changes**. Use `*sql.Tx` without a cache. - **Contextual tuple checks**. These bypass the cache automatically. - **Very low check volume**. At a few hundred µs per uncached check, caching adds complexity without meaningful benefit if you're doing fewer than ~100 checks/second. ## Next Steps - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md): full Checker API usage - [Go API](https://melange.sh/docs/reference/go-api.md): cache interface and options reference - [Performance](https://melange.sh/docs/reference/performance.md): benchmark data --- # Scaling Source: https://melange.sh/docs/guides/scaling/ > Run Melange at scale: indexing the tables behind the tuples view, materialized views, and keeping list queries fast. Melange's check latency stayed roughly flat in the measured scale fixtures. On managed Postgres, measured from a VM in the same zone, checks spent about 0.1 ms in the database and took about 0.4 to 0.7 ms end to end across sizes from a few thousand to two million tuples; see [Performance](https://melange.sh/docs/reference/performance.md). The regular view approach works at scale when source tables are properly indexed. The areas that benefit from optimization are: - **Missing indexes** cause sequential scans on source tables, degrading all operations. - **List operations** grow with the part of the permission graph they walk, which can be much larger than the result. - **Repeated checks** benefit from caching. ## Measure First Use `EXPLAIN ANALYZE` to identify actual bottlenecks: ```sql EXPLAIN ANALYZE SELECT check_permission('user', '123', 'can_read', 'repository', '456'); ``` Look for `Seq Scan` entries. If you see sequential scans with filters like `Filter: ((id)::text = '123'::text)`, you need expression indexes. Run `melange doctor` for automated recommendations: ```bash melange doctor --db "$DATABASE_URL" ``` Doctor detects missing expression indexes and provides exact `CREATE INDEX` statements. ## Expression Indexes When your view casts integer IDs to text (`id::text`), PostgreSQL cannot use your existing integer indexes for those lookups. Expression indexes restore efficient index scans: ```sql CREATE INDEX idx_org_members_text ON organization_members ((organization_id::text), (user_id::text)); ``` Without expression indexes, PostgreSQL falls back to sequential scans on each source table in the `UNION ALL` view. This affects all operations (check, list, and doctor performance analysis). Add expression indexes for every `::text` cast in your view. Run `ANALYZE` afterward to update query planner statistics. See [Tuples View](https://melange.sh/docs/concepts/tuples-view.md) for detailed index patterns. ## Source Table Indexes Create composite indexes on the columns used in the view for each access pattern: ```sql -- Object-based lookups (check operations) CREATE INDEX idx_org_members_lookup ON organization_members (organization_id, role, user_id); -- Subject-based lookups (list operations) CREATE INDEX idx_org_members_user ON organization_members (user_id, role, organization_id); -- Parent relationship lookups CREATE INDEX idx_repos_parent ON repositories (id, organization_id); ``` See [Tuples View](https://melange.sh/docs/concepts/tuples-view.md) for the full index pattern guide. ## Application-Level Caching Caching is the highest-impact optimization for repeated checks. Cached lookups return in about 120 ns, against about 0.4 to 0.7 ms uncached in the measured same-zone setup. ```go cache := melange.NewCache(melange.WithTTL(time.Minute)) checker := melange.NewChecker(db, melange.WithCache(cache)) ``` Request-scoped caching (fresh cache per request) avoids stale results while still deduplicating multiple checks within a single request. See [Caching](https://melange.sh/docs/guides/caching.md) for details. ## List Operations at Scale List operations (ListObjects, ListSubjects) cost what it takes to walk the permission graph to their answer: - The measured short-path lists with few results took about 0.5 to 1 ms across the tested scales. - Use relation-specific measurements rather than a universal per-object budget: first-page PR listing took 2.8 ms at the smallest hierarchy scale and 262 to 269 ms at the largest. - Intersections and exclusions follow the size of their base relation, and recursive relations and nested-group usersets follow the size of the reachable graph. Either can take hundreds of milliseconds on a million-tuple dataset while returning a handful of results. At large kitchen-sink sizes nearly all of the expensive list time is spent in Postgres. The measured first-page PR query took 262 to 269 ms for limits of 10 through 500; smaller limits bounded the payload without materially reducing the work. Later-page costs were not measured by that benchmark. Inspect your plan before assuming the same behavior for every relation. See [Performance](https://melange.sh/docs/reference/performance.md#list-operations) for measurements by query shape. For paths where you only need a boolean answer, use `Check` or `BulkCheck` instead of `ListObjects`. ## Alternative View Strategies The regular view is the recommended default. These alternatives trade simplicity for specific operational properties. ### Materialized View Replaces the live view with pre-computed, directly indexable data: ```sql CREATE MATERIALIZED VIEW melange_tuples AS -- ... same query as your regular view ... WITH DATA; CREATE INDEX idx_mt_object ON melange_tuples (object_type, object_id, relation, subject_type, subject_id); CREATE INDEX idx_mt_subject ON melange_tuples (subject_type, subject_id, relation, object_type, object_id); ``` Refresh with `REFRESH MATERIALIZED VIEW CONCURRENTLY melange_tuples`. The `CONCURRENTLY` option allows reads during refresh but requires a unique index. **Trade-off**: permissions become stale between refreshes, and checks no longer see uncommitted changes within a transaction. Consider this only if you have specific reasons (e.g., very complex source queries that are expensive to plan, or you need direct index support without expression indexes). ### Dedicated Tuples Table Replaces the view with a table synced via triggers: ```sql CREATE TABLE melange_tuples ( subject_type text NOT NULL, subject_id text NOT NULL, relation text NOT NULL, object_type text NOT NULL, object_id text NOT NULL, PRIMARY KEY (object_type, object_id, relation, subject_type, subject_id) ); ``` Sync with triggers on your domain tables. This gives direct index access with real-time consistency, but adds significant write-path complexity and maintenance burden. Consider this only if you have measured a specific performance problem that expression indexes and source table indexes don't solve. ## Next Steps - [Tuples View](https://melange.sh/docs/concepts/tuples-view.md): index patterns and expression index details - [Caching](https://melange.sh/docs/guides/caching.md): cache configuration and strategies - [Performance](https://melange.sh/docs/reference/performance.md): full benchmark data --- # Testing Authorization Source: https://melange.sh/docs/guides/testing-authorization/ > Test your Melange schema and tuples view together against a real PostgreSQL database. Test your tuples view and schema together against a real PostgreSQL database. Mocking the permission layer risks divergence between test and production behavior. ## Integration Test Pattern (Go) ```go func TestPermissions(t *testing.T) { // Set up test database with melange functions db := setupTestDB(t) // Your test helper that creates a fresh DB ctx := context.Background() // Apply melange migration err := migrator.Migrate(ctx, db, "schemas/schema.fga") require.NoError(t, err) // Insert test data into domain tables tx, err := db.BeginTx(ctx, nil) require.NoError(t, err) defer tx.Rollback() _, err = tx.ExecContext(ctx, ` INSERT INTO organization_members (user_id, organization_id, role) VALUES ('alice', 'org1', 'admin') `) require.NoError(t, err) _, err = tx.ExecContext(ctx, ` INSERT INTO repositories (id, organization_id) VALUES ('repo1', 'org1') `) require.NoError(t, err) // Check permissions within the transaction checker := melange.NewChecker(tx) allowed, err := checker.Check(ctx, melange.Object{Type: "user", ID: "alice"}, melange.Relation("can_read"), melange.Object{Type: "repository", ID: "repo1"}, ) require.NoError(t, err) assert.True(t, allowed, "admin should be able to read repos in their org") } ``` Transaction rollback provides test isolation. Each test starts with a clean state without needing to truncate tables. ## Testing with SQL Directly For database-only tests or debugging, call the SQL functions directly: ```sql -- Set up test data BEGIN; INSERT INTO organization_members (user_id, organization_id, role) VALUES ('bob', 'org1', 'member'); INSERT INTO repositories (id, organization_id) VALUES ('repo1', 'org1'); -- Test permission SELECT check_permission('user', 'bob', 'can_read', 'repository', 'repo1'); -- Expected: 1 (member inherits can_read via "member from org") -- Test denial SELECT check_permission('user', 'bob', 'can_delete', 'repository', 'repo1'); -- Expected: 0 (only owners can delete) ROLLBACK; ``` ## Common Test Patterns ### Role Hierarchy Inheritance Verify that higher roles inherit lower role permissions: ```go // Owner should have all permissions for _, rel := range []string{"can_read", "can_write", "can_delete"} { allowed, err := checker.Check(ctx, owner, melange.Relation(rel), repo) require.NoError(t, err) assert.True(t, allowed, "owner should have %s", rel) } // Member should only have read allowed, _ := checker.Check(ctx, member, "can_read", repo) assert.True(t, allowed) allowed, _ = checker.Check(ctx, member, "can_write", repo) assert.False(t, allowed) ``` ### Exclusion Denials ```go // User is a writer but also blocked _, _ = tx.ExecContext(ctx, `INSERT INTO writers (user_id, doc_id) VALUES ('eve', 'doc1')`) _, _ = tx.ExecContext(ctx, `INSERT INTO blocked_users (user_id, doc_id) VALUES ('eve', 'doc1')`) // "viewer: writer but not blocked" should deny allowed, _ := checker.Check(ctx, eve, "viewer", doc) assert.False(t, allowed, "blocked user should be denied even if they are a writer") ``` ### Tuple Removal Removes Access ```go // Grant access _, _ = tx.ExecContext(ctx, `INSERT INTO team_members (user_id, team_id) VALUES ('alice', 'team1')`) allowed, _ := checker.Check(ctx, alice, "member", team) assert.True(t, allowed) // Revoke access _, _ = tx.ExecContext(ctx, `DELETE FROM team_members WHERE user_id = 'alice' AND team_id = 'team1'`) allowed, _ = checker.Check(ctx, alice, "member", team) assert.False(t, allowed) ``` ### Wildcard Access ```go // Make repository public _, _ = tx.ExecContext(ctx, `UPDATE repositories SET is_public = true WHERE id = 'repo1'`) // Any user should have read access allowed, _ := checker.Check(ctx, randomUser, "can_read", repo) assert.True(t, allowed, "public repos should be readable by any user") ``` ## Testing Decision Overrides Use decision overrides to test application behavior for both allowed and denied paths without database setup: ```go func TestAdminBypass(t *testing.T) { // No database needed checker := melange.NewChecker(nil, melange.WithDecision(melange.DecisionAllow)) allowed, err := checker.Check(ctx, anyUser, "anything", anyObject) assert.NoError(t, err) assert.True(t, allowed) } func TestDeniedPath(t *testing.T) { checker := melange.NewChecker(nil, melange.WithDecision(melange.DecisionDeny)) allowed, err := checker.Check(ctx, anyUser, "anything", anyObject) assert.NoError(t, err) assert.False(t, allowed) } ``` ## Using melange doctor in CI Run `melange doctor` as a CI step to catch configuration drift: ```yaml # GitHub Actions example - name: Health check run: melange doctor --db "$DATABASE_URL" ``` Doctor verifies that the schema, migrations, tuples view, and generated functions are all in sync. A non-zero exit code fails the CI step. ## Next Steps - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md): Checker API reference - [Caching](https://melange.sh/docs/guides/caching.md): cache behavior in tests - [CLI Reference](https://melange.sh/docs/reference/cli.md): `doctor` and `validate` commands --- # Studio Source: https://melange.sh/docs/guides/studio/ > melange studio: a local, read-only web console for exploring the deployed model, explaining checks, and browsing tuples. `melange studio` serves a local web console for inspecting a melange-managed database: the deployed model, Explain and Expand results, a check matrix, `EXPLAIN ANALYZE` timings, the tuples behind each entity, migration history, and `melange doctor` health checks. Studio is experimental. It reads the model from the database, so it needs only a connection: no schema file or repo checkout. ## Starting Studio ```bash melange studio --db postgres://localhost/mydb ``` Studio prints its URL and runs until you press Ctrl-C: ``` melange studio: http://127.0.0.1:53817 (ctrl-c to stop) ``` The default address is `127.0.0.1:0`, which picks a free port on each run. Pass `--addr` for a fixed one: ```bash melange studio --addr 127.0.0.1:7070 ``` `--addr` must be a loopback address (`127.0.0.1`, `::1`, or `localhost`). Studio refuses to start on any other address. ### Choosing a Database Studio resolves its connection like the other database commands: | Flag | Source when omitted | Purpose | | ------------- | ----------------------------------------------------- | ---------------------------------------------------------- | | `--db` | `database` settings in config, or the active `--env` | Connection string | | `--env` | `MELANGE_ENV`, then `default_environment` in config, else the base config | [Environment profile](https://melange.sh/docs/reference/configuration.md#environments) to connect to | | `--db-schema` | `database.schema` in config, else `public` | PostgreSQL schema holding `melange_tuples` and the generated functions | | `--schema` | `schema` in config, else `schemas/schema.fga` | Local `.fga` file for the Health page's file and drift checks | ```bash # Point studio at the staging profile from melange.yaml melange studio --env staging # melange objects in a non-default schema melange studio --db postgres://localhost/mydb --db-schema authz ``` An explicit `--db` wins over the environment's connection. When an environment is selected, its name is shown in the top bar, even if `--db` overrides its connection. ## Read-Only Connections Studio opens every connection with `default_transaction_read_only=on` and checks `SHOW default_transaction_read_only` at startup. If the setting did not take effect, studio exits instead of serving. The top bar shows a `read-only` badge. The one exception is Explain with what-if tuples (see [What-If Tuples](#what-if-tuples)). It begins a read-write transaction, creates a temporary view, switches the transaction to read-only, runs Explain, and always rolls back. Nothing is committed. ## Pages ### Model The Model page has two tabs: **Query** and **Deployed schema**. The deployed schema tab shows the DSL recorded by the last `melange migrate`. The sidebar lists every type and relation, with a filter; each relation links to its relation page. ![The Model page's Deployed schema tab, showing the highlighted DSL with the type and relation sidebar](https://melange.sh/images/studio/schema-light.webp) The Query form has four modes: | Mode | Inputs | Result | | ----------- | --------------------------- | ---------------------------------------------------------------------------------------- | | **Explain** | subject, relation, object | Allowed or denied, with every path the check tried. See [Explaining Decisions](https://melange.sh/docs/guides/explaining-decisions.md) | | **Expand** | relation, object | Who has the relation on the object, as a tree. Computed and tuple-to-userset pointers can be expanded in place. See [Expanding Permissions](https://melange.sh/docs/guides/expanding-permissions.md) | | **Matrix** | subject, object | Every relation on the object's type, allowed or denied for the subject. Click a relation to explain it | | **Analyze** | subject, relation, object | Runs `check_permission` under `EXPLAIN (ANALYZE, BUFFERS)` and shows the plan, timings, and the slowest generated functions | Subjects and objects use `type:id` (`user:alice`, `document:1`). Each result shows how long the call took. ![An Explain result for user:5 can_read issue:6, allowed through the repository and organization membership](https://melange.sh/images/studio/query-explain-light.webp) Matrix checks run in parallel, at most four list or check calls at a time across the whole server, each limited to 10 seconds. ![A Matrix result listing every repository relation as allowed or denied for user:2 on repository:2](https://melange.sh/images/studio/query-matrix-light.webp) #### What-If Tuples In Explain mode, open **What-if tuples** and enter one `subject relation object` tuple per line: ``` user:bob viewer document:1 group:eng#member editor document:1 ``` Studio runs Explain twice, without and with the tuples, and shows both answers. The tuples are added to `melange_tuples` through a temporary view, the same way [contextual tuples](https://melange.sh/docs/guides/contextual-tuples.md) work, inside a transaction that is always rolled back. When studio can read the deployed model, each tuple is validated against it first: an unknown relation, or a subject type the relation does not accept directly, is reported by line number. What-if needs a writable primary and the `TEMP` privilege on the database. It fails on a hot-standby replica, because a replica refuses read-write transactions. Plain Explain and the other query modes work on a replica. #### Analyze Analyze runs inside a read-only transaction with a 10 second `statement_timeout`. A check that runs longer is reported as timed out. The plan of a PL/pgSQL call is a single opaque node, so the per-function breakdown comes from `pg_stat_xact_user_functions`. That needs `track_functions`, which only a superuser can set unless it is granted: ```sql GRANT SET ON PARAMETER track_functions TO studio_reader; -- PostgreSQL 15+ ``` Without it, Analyze still shows the plan and total timings, with a note that per-function times are unavailable. #### Relation Pages Each relation has a page showing its `define` line, the relations it reads (**Depends on**) and the relations that read it (**Used by**), the code generator's feature analysis, and the SQL source of its deployed functions (check, list objects, list subjects, explain, expand), read from `pg_proc`. ![The relation page for repository#can_read, with its definition, feature analysis and dependencies](https://melange.sh/images/studio/relation-light.webp) ### Tuples The Tuples page lists every type that appears in `melange_tuples`, with the number of distinct ids. The counts take a full pass over the view and are cached for 30 seconds. Selecting a type lists its entities, 100 at a time; scrolling to the end of the list loads the next page. Search matches a substring of the id or the entity's label. ![The Tuples browser listing repositories by name, with their ids and tuple counts](https://melange.sh/images/studio/browse-light.webp) Selecting an entity opens three tabs: - **Can access**: what the entity can reach as a subject, one object type at a time, through `ListObjects`. - **Accessed by**: who can reach the entity as an object, one subject type at a time, through `ListSubjects`. - **Direct tuples**: the raw tuples with the entity as subject or as object, with exact counts and a filter. ![The Can access tab for user carol, listing her repositories with relation chips and an inline Explain](https://melange.sh/images/studio/entity-light.webp) #### Access List Limits Each access tab makes one list call per relation for the selected type. To keep large models usable: - Each relation's list stops at 5,000 ids. When any relation hits the cap, the count gets a `+` suffix (for example `5,000+`) and the list notes that there may be more. - A computed list is cached for 60 seconds and served 100 rows at a time as you scroll. The filter on these tabs runs over the cached list. - At most four list calls run at once across the server, each limited to 10 seconds. A relation that times out shows an error in place of its rows. - The count badges in the type navigation compute one at a time. These are display limits, not limits of `ListObjects` or `ListSubjects`. Use the [Go API](https://melange.sh/docs/guides/listing-objects.md) or the SQL functions for complete lists. #### Labels Where it can, studio shows a readable name next to each id. It finds the name by planning `melange_tuples` with `EXPLAIN VERBOSE`: a view branch that maps a literal type to a plain column (for example `'user'` and `team_members.user_id`) is followed through primary and foreign keys to the domain table (`users.id`). The first of these columns that the table has becomes the label: `name`, `display_name`, `title`, `label`, `username`, `email`, `slug`. If the view does not reveal a table, studio falls back to a table named after the type, or its plural (`user` or `users`), with an `id` column. The page header says where the labels came from, or that none were found. Discovery runs once per process; restart studio after schema changes. Labels need `SELECT` on the domain tables. Without it, entities show by id. ### History The History page shows the last 50 rows of `melange_migrations`, newest first: when each migration ran, the melange and codegen versions, the schema checksum, and how many functions it installed. This is the same data as [`melange history`](https://melange.sh/docs/reference/cli.md#history). ### Health The Health page runs the same checks as [`melange doctor`](https://melange.sh/docs/reference/cli.md#doctor), grouped by category. The checks that compare a local schema file with the database use `--schema`, else `schema` in config, else `schemas/schema.fga`. If no file exists at that path, the Schema File check fails. ![The Health page showing melange doctor checks grouped by category with pass and warning counts](https://melange.sh/images/studio/health-light.webp) ## Database Privileges Studio only reads, so it can run as a dedicated low-privilege role. The minimum, for melange objects in the `public` schema: ```sql CREATE ROLE studio_reader LOGIN PASSWORD '...'; GRANT USAGE ON SCHEMA public TO studio_reader; GRANT SELECT ON public.melange_tuples, public.melange_migrations TO studio_reader; ``` This covers the Model page, Explain, Expand, Matrix, Analyze without the per-function breakdown, the Tuples browser without labels, History, and Health. It relies on PostgreSQL defaults that most databases keep: - `EXECUTE` on the generated functions is granted to `PUBLIC` when a function is created. If you revoked it, grant `EXECUTE` on the functions in the melange schema. - The system catalogs (`pg_proc`, `pg_class`, `pg_constraint`, `information_schema`) are readable by every role. Relation pages, labels, and Health read them. - `TEMP` on the database is granted to `PUBLIC`. What-if needs it. Optional grants: | Feature | Grant | | -------------------------------- | --------------------------------------------------------------------- | | Labels in the Tuples browser | `SELECT` on the domain tables (or just their key and label columns) | | What-if tuples | `TEMP` on the database, if revoked from `PUBLIC` | | Per-function times in Analyze | `GRANT SET ON PARAMETER track_functions` (PostgreSQL 15+), or superuser | Without `SELECT` on `melange_migrations`, studio cannot read the deployed model, so the schema view, Matrix, relation analysis, History, Health, and the access tabs are unavailable, and what-if tuples are not validated; Explain, Expand, and Analyze still work. ## Replicas Studio works against a hot-standby replica, with one exception: what-if tuples need a read-write transaction to create the temporary view, which a replica refuses. Point studio at the primary to use what-if. Everything studio runs on the primary outside what-if is read-only. ## Security - Studio binds a loopback address only, and rejects requests whose `Host` header is not `localhost` or a loopback IP. This blocks DNS-rebinding attacks, where a hostile page resolves its own domain to `127.0.0.1`. - There is no authentication. Anyone who can reach the loopback port on your machine can use the console with studio's database role. - Pages are served with a strict Content Security Policy (`default-src 'self'`, no inline scripts or `eval`), `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and `Cache-Control: no-store`, so authorization data is not kept in the browser cache. - Scripts, styles, and fonts are embedded in the binary. Pages load nothing from other hosts. ### Update Check The only network request studio makes outside your database is the CLI's usual [update check](https://melange.sh/docs/reference/cli.md#update-notifications), made by the CLI process, not the browser. When a newer release exists, the top bar shows a `vX.Y.Z available` badge. Disable it with `--no-update-check`; it is also off when the `CI` environment variable is set. The top bar also links to the melange documentation and GitHub repository. Those are ordinary links and are only fetched if you click them. ## See Also - [CLI reference](https://melange.sh/docs/reference/cli.md#studio): `melange studio` flags - [Explaining Decisions](https://melange.sh/docs/guides/explaining-decisions.md): the trace behind the Explain mode - [Expanding Permissions](https://melange.sh/docs/guides/expanding-permissions.md): the tree behind the Expand mode - [Troubleshooting](https://melange.sh/docs/guides/troubleshooting.md): `melange doctor` and common errors --- # Using Coding Agents Source: https://melange.sh/docs/guides/coding-agents/ > Point coding agents like Claude Code, Codex, or Cursor at Melange's docs, and give them project instructions for working on your schema. Coding agents work best with Melange when they can read the docs as markdown and know the project's workflow before they start editing. This page covers both. ## Docs for Agents The documentation is published in machine-readable forms: | URL | Contents | | --- | -------- | | [`/llms.txt`](https://melange.sh/llms.txt) | An index of every docs page with a one-line description, following the [llms.txt](https://llmstxt.org) convention. | | [`/llms-full.txt`](https://melange.sh/llms-full.txt) | Every user-facing docs page as markdown in one file. It is large (around 90k tokens), so use it only when an agent needs the whole manual. | | `.md` | Any docs page as markdown, for example [`/docs/getting-started/quick-start.md`](https://melange.sh/docs/getting-started/quick-start.md). | Point an agent at `https://melange.sh/llms.txt` and it can fetch only the pages it needs. ## Project Instructions Most agents read a file of project instructions at the start of every session: `AGENTS.md` for Codex, Cursor, and most others, `CLAUDE.md` for Claude Code. A Melange section there makes the agent follow your workflow instead of guessing. Generate the section with the CLI and append it to that file: ```bash melange init agent >> AGENTS.md # or, for Claude Code melange init agent >> CLAUDE.md ``` `melange init agent` reads your [config file](https://melange.sh/docs/reference/configuration.md) and fills in the schema path, migration strategy (`melange migrate` or `melange generate migration`), and generated client location. Run it from the project root, and re-run it after changing those settings, replacing the old section. For a new project, the `melange init` wizard asks whether to add it to `AGENTS.md` (or `CLAUDE.md` if that is the only one present), and skips the step if the section is already there. In non-interactive mode, pass `melange init -y --agents`. For a project with built-in migrations and a Go client in `internal/authz`, the output is: ````markdown ## Authorization (Melange) Permissions are an OpenFGA model that Melange compiles into PostgreSQL functions. - Model: `melange/schema.fga`. Config: `melange/config.yaml`. - Relationships come from the `melange_tuples` view over our tables, defined in our own migrations (Melange doesn't create it). Never insert tuples; change the underlying rows, or the view definition. - `internal/authz` is generated; don't edit it. - No OpenFGA conditions; express them as `WHERE` clauses in the tuples view or as contextual tuples (see [Replacing Conditions](https://melange.sh/docs/guides/conditions.md)). Use `check_permission` (or the client's check) for access decisions; listing objects or subjects can be slow and suits batch or admin use. After editing the model: 1. `melange validate` 2. `melange generate client` (regenerates `internal/authz`) 3. `melange migrate` applies it to the configured database (`melange config show` prints the target). Only run it against a local dev database. Unexpected allow or deny: run `melange explain ` (e.g. `user:alice viewer document:1`) before changing the model; it shows each path tried and its tuples. `melange doctor` diagnoses setup problems. `validate` and `generate` work offline; `migrate`, `explain`, and `doctor` need the database. If the project's usual setup can't reach one, say so instead of improvising one. Docs, when needed: https://melange.sh/llms.txt ```` The section is deliberately short (about 300 tokens), because agents load it into every session. It holds what an agent can't infer from the code, the edit loop, and guardrails against the mistakes below; the docs are linked for anything else. Avoid pasting docs pages or `llms-full.txt` into your instructions file. ## What Agents Get Wrong - **Writing tuples.** Agents that know OpenFGA reach for a tuple-write API. Melange has none: the `melange_tuples` view derives tuples from your tables, so access changes are ordinary inserts and updates to those tables. - **Guessing at a wrong answer.** An agent that sees an unexpected deny tends to loosen the model. [`melange explain`](https://melange.sh/docs/guides/explaining-decisions.md) shows which branch failed, which is usually a missing row or a view mapping, not the schema. - **Forgetting to migrate.** Editing `schema.fga` changes nothing until `melange migrate` (or the generated migration) runs. `melange status` reports whether the database is behind the schema. - **Migrating the wrong database.** `melange migrate` applies to whatever database the config names. Keep a local development database there, and point other environments at their own [environment profiles](https://melange.sh/docs/reference/configuration.md#environments). - **Improvising a database.** Agents without database access can spend many turns trying to start Postgres. `validate`, `generate migration`, and `generate client` work offline; `migrate`, `explain`, and `doctor` need the database. - **Unsupported features.** OpenFGA conditions fail validation. See [Replacing Conditions](https://melange.sh/docs/guides/conditions.md) for SQL equivalents. ## Next Steps - [Explaining Decisions](https://melange.sh/docs/guides/explaining-decisions.md): reading `explain` output - [Troubleshooting](https://melange.sh/docs/guides/troubleshooting.md): common errors and `melange doctor` - [CLI Reference](https://melange.sh/docs/reference/cli.md): every command and flag --- # Troubleshooting Source: https://melange.sh/docs/guides/troubleshooting/ > Diagnose and fix common Melange problems with melange doctor, error messages, and query plans. Diagnose and fix common Melange issues. ## Using melange doctor ```bash melange doctor --db "$DATABASE_URL" ``` Doctor runs six categories of checks against your database: 1. **Schema File**: exists, parses correctly, no cyclic dependencies. 2. **Migration State**: `melange_migrations` table exists, schema is in sync, codegen version matches. 3. **Generated Functions**: dispatcher and per-relation functions are present, no orphans from previous schemas. 4. **Tuples Source**: `melange_tuples` view exists with the correct columns. 5. **Data Health**: tuple count, types and relations validate against the schema. 6. **Performance**: UNION ALL usage, missing expression indexes on `::text` cast columns. Use `--verbose` for detailed output (exact checksums, function lists, specific invalid tuples): ```bash melange doctor --verbose ``` Skip performance checks if you only need structural validation: ```bash melange doctor --skip-performance ``` ## Common Errors ### "relation melange_tuples does not exist" The `melange_tuples` view has not been created, or it was created in a different schema than the one your connection uses. **Fix**: create the view. See [Creating Your Tuples View](https://melange.sh/docs/getting-started/tuples-view.md). ### "function check_permission does not exist" The migration has not been run, or it was run against a different database or schema. **Fix**: run `melange migrate --db "$DATABASE_URL"`. ### Permission checks always return false Run `melange explain` to see which branches the engine tried: ```text $ melange explain user:alice viewer document:1 --db postgres://localhost/mydb ✗ user:alice does NOT have viewer on document:1 └── union of 2 branches ├── ✗ no direct grant └── ✗ implied: implied via editor └── union of 1 branches └── ✗ no direct grant ``` Here neither a direct `viewer` grant nor an implied path via `editor` matched — add a tuple satisfying one of them. See [Explaining Decisions](https://melange.sh/docs/guides/explaining-decisions.md) for the full guide. If Explain returns the "explain not yet supported" sentinel (the schema uses a pattern the renderer doesn't cover yet), inspect the tuples view directly: ```sql -- Check what tuples exist for the subject SELECT * FROM melange_tuples WHERE subject_type = 'user' AND subject_id = 'alice'; -- Check what tuples exist for the object SELECT * FROM melange_tuples WHERE object_type = 'repository' AND object_id = '42'; ``` Common causes: - Relation names in the view don't match the schema (e.g., `'is_member'` vs `'member'`). - ID types aren't cast to text (`user_id` vs `user_id::text`). - The domain table has no data for the given subject/object. ### Permission checks always return true A wildcard subject (`user:*`) is granting broader access than intended. `melange explain` shows wildcard matches as a `NodeWildcard` sentinel: ```text $ melange explain user:alice can_view document:1 ✓ user:alice has can_view on document:1 └── wildcard: user:* ``` Find the wildcard tuples: ```sql SELECT * FROM melange_tuples WHERE subject_id = '*'; ``` Check they're scoped to the intended relation and object type. ### Slow permission checks **Debug**: ```sql EXPLAIN ANALYZE SELECT check_permission('user', '123', 'can_read', 'repository', '456'); ``` Look for `Seq Scan` in the output. Common causes: - Missing expression indexes on `::text` cast columns. Run `melange doctor` for specific `CREATE INDEX` recommendations. - Large source tables without appropriate composite indexes. - Complex schema patterns (deeply nested exclusions or parent chains). See [Scaling](https://melange.sh/docs/guides/scaling.md) for optimization strategies. ### Schema validation errors ```bash melange validate ``` Common causes: - Syntax errors in the `.fga` file. The error message includes the line number. - Cyclic dependencies in implied-by relationships (e.g., `define a: b` and `define b: a`). - References to undefined types or relations. ### Migration "schema unchanged" when it shouldn't be Melange tracks schema changes via SHA256 checksum. If you've updated Melange itself but not the schema, the new codegen may produce different SQL. **Fix**: force re-migration: ```bash melange migrate --force ``` ### Schema out of sync after updating Melange `melange doctor` detects when the codegen version has changed since the last migration. **Fix**: run `melange migrate --force` to regenerate all SQL functions with the current Melange version. ## Debugging Techniques ### Explain a permission decision ```bash melange explain --db postgres://localhost/mydb ``` Output is a tree of every branch the engine tried with `✓`/`✗` markers per node. `--format=json` returns the raw `Trace` JSONB; `--max-nodes N` caps the trace size. See [Explaining Decisions](https://melange.sh/docs/guides/explaining-decisions.md). ### Inspect a database in studio ```bash melange studio --db postgres://localhost/mydb ``` Opens a local, read-only web console with Explain, Expand, a check matrix of every relation on an object for one subject, `EXPLAIN ANALYZE` timings, the tuples behind each entity, and the doctor checks. See [Studio](https://melange.sh/docs/guides/studio.md). ### Preview Generated SQL ```bash melange migrate --dry-run ``` Outputs the complete SQL that would be executed. Redirect to a file for inspection: ```bash melange migrate --dry-run > migration.sql ``` ### Inspect Tuple Data ```sql -- All tuples for a specific object SELECT * FROM melange_tuples WHERE object_type = 'repository' AND object_id = '42' ORDER BY relation, subject_type, subject_id; -- Count tuples by type SELECT object_type, relation, count(*) FROM melange_tuples GROUP BY object_type, relation ORDER BY count DESC; ``` ### Query Plan Analysis ```sql EXPLAIN ANALYZE SELECT * FROM melange_tuples WHERE object_type = 'repository' AND object_id = '42' AND relation = 'can_read' AND subject_type = 'user'; ``` Look for: - `Seq Scan` with `Filter:` lines containing `::text` casts. This means expression indexes are needed. - High `actual time` values on individual branches of the UNION ALL. ### Check Effective Configuration ```bash melange config show --source ``` Shows which config file is in use and the effective values after merging defaults, config file, and environment variables. ## Getting Help If you're stuck, open an issue at [github.com/pthm/melange/issues](https://github.com/pthm/melange/issues) with: - Your `.fga` schema - The `melange doctor --verbose` output - The error message or unexpected behavior - Your Melange version (`melange version`) ## Next Steps - [CLI Reference](https://melange.sh/docs/reference/cli.md): full command documentation and exit codes - [Errors](https://melange.sh/docs/reference/errors.md): error types and sentinel values - [Scaling](https://melange.sh/docs/guides/scaling.md): performance optimization strategies --- # Reference Source: https://melange.sh/docs/reference/ > Reference for the Melange CLI, configuration, Go, TypeScript, and SQL APIs, performance, and OpenFGA compatibility. Technical reference documentation for Melange. Find detailed information on CLI commands, configuration options, runtime APIs, and compatibility with the OpenFGA specification. - [Configuration](https://melange.sh/docs/reference/configuration.md): Config files, environment variables, and precedence - [CLI Reference](https://melange.sh/docs/reference/cli.md): Commands for migrations, code generation, and validation - [SQL API](https://melange.sh/docs/reference/sql-api.md): Direct SQL functions for permission checks without a client library - [Go API](https://melange.sh/docs/reference/go-api.md): Runtime types, Checker, bulk checks, caching, and decision overrides - [TypeScript API](https://melange.sh/docs/reference/typescript-api.md): TypeScript runtime client (in development) - [Generated Code](https://melange.sh/docs/reference/generated-code.md): What melange generate client produces for Go and TypeScript - [Deployed Model](https://melange.sh/docs/reference/deployed-model.md): What a migration records, and the commands that read it back - [Errors](https://melange.sh/docs/reference/errors.md): Sentinel errors, validation codes, and error helpers - [OpenFGA Compatibility](https://melange.sh/docs/reference/openfga-compatibility.md): Feature support and migration guidance - [Performance](https://melange.sh/docs/reference/performance.md): Benchmark results, optimization strategies, and scaling guidance --- # Configuration Source: https://melange.sh/docs/reference/configuration/ > Configure Melange with melange.yaml, MELANGE_ environment variables, or command-line flags, and how they take precedence. Melange supports configuration via YAML files, environment variables, and command-line flags with the following precedence (highest to lowest): 1. **Command-line flags** 2. **Environment variables** (`MELANGE_*` prefix) 3. **Configuration file** (`melange.yaml` or `melange.yml`) 4. **Built-in defaults** A selected [environment profile](#environments) overlays the base configuration before this precedence is applied, so an explicit flag still wins over the environment's value. ## Configuration File Melange automatically discovers a configuration file by checking each directory for the following names (in order): 1. `melange.yaml` 2. `melange.yml` 3. `melange/config.yaml` 4. `melange/config.yml` 5. `melange/melange.yaml` 6. `melange/melange.yml` The search starts in the current directory and walks up parent directories until a `.git` directory is found (repository boundary) or 25 levels are reached. The first match wins. > **Note:** The `melange/` directory convention is the default when you run `melange init`. Existing `melange.yaml` files at the project root continue to work. Both layouts are fully supported. You can override auto-discovery with `--config`: ```bash melange --config /path/to/custom-config.yaml migrate ``` ## File Format Create a `melange.yaml` in your project root, or use `melange init` to generate one: ```yaml # Path to OpenFGA schema file (used by all commands) schema: schemas/schema.fga # Database connection (used by migrate, status, doctor, schema pull, explain, expand) # See "Environments" below for per-environment overrides selected with --env. database: # Option 1: Full connection URL url: postgres://user:password@localhost:5432/mydb?sslmode=prefer # Option 2: Discrete fields (used when url is empty) # host: localhost # port: 5432 # name: mydb # user: melange # password: secret # sslmode: prefer # Optional: install melange objects in a specific PostgreSQL schema # schema: authz # Code generation settings generate: client: runtime: go schema: schemas/schema.fga # Overrides top-level schema output: internal/authz package: authz filter: can_ id_type: string # Migration file generation settings (for external frameworks) migration: output: db/migrations name: melange format: split # "split" or "single" # Migration settings migrate: dry_run: false force: false # Doctor command settings doctor: verbose: false skip_performance: false ``` ## Minimal Configuration A minimal configuration for most projects: ```yaml schema: schemas/schema.fga database: url: postgres://localhost/mydb generate: client: runtime: go output: internal/authz ``` ## Configuration Reference ### Top-Level Settings | Key | Type | Default | Description | |-----|------|---------|-------------| | `schema` | string | `schemas/schema.fga` | Path to the OpenFGA schema file | ### Database Settings Configure under `database:`: | Key | Type | Default | Description | |-----|------|---------|-------------| | `url` | string | - | Full PostgreSQL connection URL | | `host` | string | - | Database host (used when `url` is empty) | | `port` | int | `5432` | Database port | | `name` | string | - | Database name | | `user` | string | - | Database user | | `password` | string | - | Database password | | `sslmode` | string | `prefer` | SSL mode for connection | | `schema` | string | - | PostgreSQL schema for melange objects (see [Custom Database Schema](#custom-database-schema)) | **Connection URL format:** ``` postgres://user:password@host:5432/dbname?sslmode=require ``` **Required fields when using discrete configuration:** - `host` - `name` - `user` **SSL Mode Values:** | Value | Description | |-------|-------------| | `disable` | No SSL | | `allow` | Try non-SSL first, then SSL | | `prefer` | Try SSL first, then non-SSL (default) | | `require` | Require SSL | | `verify-ca` | Require SSL and verify CA | | `verify-full` | Require SSL and verify CA and hostname | ### Generate Client Settings Configure under `generate.client:`: | Key | Type | Default | Description | |-----|------|---------|-------------| | `runtime` | string | - | Target runtime: `go`, `typescript` | | `schema` | string | (top-level `schema`) | Path to schema file | | `output` | string | - | Output directory for generated code | | `package` | string | `authz` | Package/module name | | `filter` | string | - | Relation prefix filter (e.g., `can_`) | | `id_type` | string | `string` | ID type for constructors | ### Generate Migration Settings Configure under `generate.migration:`: | Key | Type | Default | Description | |-----|------|---------|-------------| | `output` | string | `""` (stdout) | Output directory for migration files | | `name` | string | `melange` | Migration name suffix in filenames | | `format` | string | `split` | Output format: `split` or `single` | > **Warning:** Do not configure both `generate.migration.output` and use `melange migrate` against the same database. The two strategies track state differently and mixing them causes warnings. See [Running Migrations](https://melange.sh/docs/guides/migrations.md) for guidance. ### Migrate Settings Configure under `migrate:`: | Key | Type | Default | Description | |-----|------|---------|-------------| | `dry_run` | bool | `false` | Output SQL without applying | | `force` | bool | `false` | Force migration even if unchanged | ### Doctor Settings Configure under `doctor:`: | Key | Type | Default | Description | |-----|------|---------|-------------| | `verbose` | bool | `false` | Show detailed output | | `skip_performance` | bool | `false` | Skip performance checks (view analysis) | ## Custom Database Schema By default, melange installs all its objects into the connection's current schema (usually `public`). The `database.schema` option places them in a specific PostgreSQL schema instead. Objects affected by this setting: - Generated check functions (`check_permission`, `check_document_viewer`, etc.) - Generated list functions (`list_accessible_objects`, `list_accessible_subjects`, etc.) - The `melange_migrations` tracking table - The `melange_tuples` view (you create this yourself, but it should be in the target schema) Your application tables are not affected. They remain wherever you have them. The tuples view in the target schema can query tables in any other schema. ### Prerequisites You must create the schema before running `melange migrate`: ```sql CREATE SCHEMA IF NOT EXISTS authz; ``` The schema must be on your connection's `search_path` for permission checks to work at runtime. Most PostgreSQL configurations include `public` by default, but a custom schema needs to be added: ```sql ALTER ROLE myuser SET search_path TO authz, public; ``` Or set it per-connection in your application's connection string or pool configuration. ### Configuration ```yaml database: url: postgres://localhost/mydb schema: authz ``` Or via environment variable: ```bash export MELANGE_DATABASE_SCHEMA=authz ``` Or via CLI flag: ```bash melange migrate --db postgres://localhost/mydb --db-schema authz ``` When `--db-schema` is not passed, every database command resolves it from `database.schema` in the config (or the selected environment), falling back to `public`. ### Tuples view setup Create your `melange_tuples` view in the target schema. The view itself lives in the custom schema but can reference tables in any schema: ```sql CREATE OR REPLACE VIEW authz.melange_tuples AS SELECT 'user' AS subject_type, user_id::text AS subject_id, role AS relation, 'organization' AS object_type, organization_id::text AS object_id FROM public.organization_members; ``` ### Runtime client configuration When using a custom schema, configure your runtime client to match: **Go:** ```go checker := melange.NewChecker(db, melange.WithDatabaseSchema("authz")) ``` **TypeScript:** ```typescript const checker = new Checker({ db, databaseSchema: 'authz' }); ``` ## Environments Named **environment profiles** let a single `--env` flag point a command at a specific database — `local`, `staging`, `production` — instead of hand-swapping `--db` connection strings. Each profile overlays the base configuration. ```yaml schema: melange/schema.fga # Base connection — used when no environment is selected, and the fallback # for any field an environment leaves unset. database: url: postgres://localhost:5432/mydb_dev default_environment: local # optional; used when --env / MELANGE_ENV absent environments: local: database: url: postgres://localhost:5432/mydb_dev staging: database: host: staging.db.internal name: app user: melange password: ${STAGING_DB_PASSWORD} # expanded from the OS environment schema: public production: database: url: ${PROD_DATABASE_URL} ``` Then target an environment on any database command: ```bash melange status --env production melange migrate --env staging melange schema pull --env production -o recovered.fga ``` ### Selecting an environment The active environment is resolved with this precedence (highest first): 1. `--env ` flag 2. `MELANGE_ENV` environment variable 3. `default_environment` config key 4. none → the base `database` block (behaviour identical to configs without an `environments:` map) An explicitly selected environment (`--env` or `MELANGE_ENV`) that isn't defined is a hard error, so a typo can't silently run against the wrong database. A `default_environment` that names a missing environment is treated leniently: a warning is printed and the base config is used, so a stale default never blocks diagnostic commands. ### Overlay semantics An environment's `database` block overlays the base one — fields it sets win, fields it omits fall through. To keep behaviour predictable, when an environment supplies a discrete connection (`host`/`name`) on top of a base that uses a `url`, the base URL is dropped rather than decomposed. In that case the environment must provide a complete connection (including `user`); melange does not split a base URL into its parts. ### Secrets with `${VAR}` Environment values may reference OS environment variables with `${VAR}` syntax, so production credentials stay out of the committed config file: ```yaml environments: production: database: url: ${PROD_DATABASE_URL} ``` Only the braced `${VAR}` form is expanded; a literal `$` (e.g. in a password) is left untouched. An unset variable referenced by a database field is reported **when a command actually connects** — not at startup — so diagnostic commands like `melange env list`, `melange config show`, and `melange validate` still run even if a secret is missing from the current shell. ### Inspecting environments `melange env list` prints the defined profiles and their targets (with the active one marked `*`); `${VAR}` references are shown literally and passwords redacted: ```bash $ melange env list Environments: * local postgres://localhost:5432/mydb_dev production ${PROD_DATABASE_URL} staging staging.db.internal:5432/app Default: local ``` `melange config show --env production` prints the fully-resolved configuration for a profile, with passwords masked by default (pass `--reveal-secrets` to see them). See the [CLI reference](https://melange.sh/docs/reference/cli.md#config-show). ## Environment Variables All configuration options can be set via environment variables with the `MELANGE_` prefix. Use underscores to separate nested keys: | Environment Variable | Configuration Path | |---------------------|-------------------| | `MELANGE_SCHEMA` | `schema` | | `MELANGE_DATABASE_URL` | `database.url` | | `MELANGE_DATABASE_HOST` | `database.host` | | `MELANGE_DATABASE_PORT` | `database.port` | | `MELANGE_DATABASE_NAME` | `database.name` | | `MELANGE_DATABASE_USER` | `database.user` | | `MELANGE_DATABASE_PASSWORD` | `database.password` | | `MELANGE_DATABASE_SSLMODE` | `database.sslmode` | | `MELANGE_DATABASE_SCHEMA` | `database.schema` | | `MELANGE_GENERATE_CLIENT_RUNTIME` | `generate.client.runtime` | | `MELANGE_GENERATE_CLIENT_SCHEMA` | `generate.client.schema` | | `MELANGE_GENERATE_CLIENT_OUTPUT` | `generate.client.output` | | `MELANGE_GENERATE_CLIENT_PACKAGE` | `generate.client.package` | | `MELANGE_GENERATE_CLIENT_FILTER` | `generate.client.filter` | | `MELANGE_GENERATE_CLIENT_ID_TYPE` | `generate.client.id_type` | | `MELANGE_GENERATE_MIGRATION_OUTPUT` | `generate.migration.output` | | `MELANGE_GENERATE_MIGRATION_NAME` | `generate.migration.name` | | `MELANGE_GENERATE_MIGRATION_FORMAT` | `generate.migration.format` | | `MELANGE_MIGRATE_DRY_RUN` | `migrate.dry_run` | | `MELANGE_MIGRATE_FORCE` | `migrate.force` | | `MELANGE_DOCTOR_VERBOSE` | `doctor.verbose` | | `MELANGE_DOCTOR_SKIP_PERFORMANCE` | `doctor.skip_performance` | | `MELANGE_ENV` | selects an [environment profile](#environments) (like `--env`) | | `CI` | _(special)_ | Setting `CI` to any value disables the automatic update check. Most CI providers set this automatically. **Example:** ```bash export MELANGE_DATABASE_URL="postgres://prod-user:secret@prod-db:5432/myapp" export MELANGE_GENERATE_CLIENT_RUNTIME="go" melange migrate ``` **Boolean values:** Use `true`/`false` or `1`/`0` for boolean environment variables. ## Configuration Inheritance Per-command settings can override top-level settings: ```yaml # Top-level default schema: schemas/schema.fga generate: client: # Overrides top-level schema for generate command only schema: schemas/api-schema.fga ``` ## Viewing Effective Configuration Use `melange config show` to see the effective configuration after merging all sources (and any selected environment). Passwords are masked by default; pass `--reveal-secrets` to print them. ```bash # Show effective configuration melange config show # Show with config file path and active environment melange config show --source # Show the resolved production profile melange config show --env production ``` **Output:** ``` Config file: /path/to/project/melange.yaml Environment: production schema: schemas/schema.fga database: url: postgres://prod-user:****@prod-db:5432/app host: "" port: 5432 ... ``` ## Security Considerations - **Prefer environment variables for secrets** in production environments — reference them from an [environment profile](#environments) with `${VAR}` (e.g. `url: ${PROD_DATABASE_URL}`) so the config file stays credential-free - Avoid committing `melange.yaml` files containing `database.password` to version control - Consider using connection URLs with credentials stored in secure secret management systems - For local development, discrete fields in a `.gitignore`d config file are acceptable - `melange config show` masks passwords by default; `--reveal-secrets` is opt-in ## Example Configurations ### Local Development ```yaml schema: schemas/schema.fga database: url: postgres://localhost/myapp_dev generate: client: runtime: go output: internal/authz package: authz ``` ### CI/CD Pipeline Use environment variables for credentials: ```bash # .github/workflows/deploy.yml env: MELANGE_DATABASE_URL: ${{ secrets.DATABASE_URL }} ``` With a minimal config file: ```yaml schema: schemas/schema.fga generate: client: runtime: go output: internal/authz ``` ### Multi-Environment Define [environment profiles](#environments) in one config and select them with `--env`: ```yaml # melange.yaml schema: schemas/schema.fga database: url: postgres://localhost:5432/myapp_dev # base / default default_environment: local environments: local: database: url: postgres://localhost:5432/myapp_dev production: database: url: ${PROD_DATABASE_URL} # from CI secrets, never committed ``` ```bash melange status --env production melange migrate --env production ``` Alternatively, use separate config files (`melange --config melange.prod.yaml migrate`) or override the connection with `MELANGE_DATABASE_URL` per invocation. --- # CLI Reference Source: https://melange.sh/docs/reference/cli/ > Every Melange CLI command and flag: init, validate, migrate, status, doctor, generate client, studio, and more. The Melange CLI provides commands for validating schemas, generating client code, and applying migrations to your database. Built on Cobra/Viper, it supports [configuration files](https://melange.sh/docs/reference/configuration.md), environment variables, and command-line flags with consistent precedence. ## Installation ```bash go install github.com/pthm/melange@latest ``` ## Global Flags These flags are available on all commands: | Flag | Description | | ------------------- | --------------------------------------------------------------------------------------------------- | | `--config` | Path to config file (default: auto-discover `melange.yaml`). See [Configuration](https://melange.sh/docs/reference/configuration.md). | | `--env` | Select a named environment profile. See [Configuration → Environments](https://melange.sh/docs/reference/configuration.md#environments). | | `-v`, `--verbose` | Increase verbosity (can be repeated: `-vv`, `-vvv`) | | `-q`, `--quiet` | Suppress non-error output | | `--no-update-check` | Disable automatic update checking | Any command that connects to a database accepts `--env ` to target one of the [environment profiles](https://melange.sh/docs/reference/configuration.md#environments) defined in your config (e.g. `melange status --env production`). An explicit `--db` still wins over the environment's connection. ### Update Notifications By default, Melange automatically checks for new versions in the background and displays a notification if an update is available. The check is: - **Non-blocking**: Runs in a background goroutine with a 5-second timeout - **Cached**: Results are cached for 24 hours in `~/.cache/melange/update-check.json` - **Fast**: Uses a 1-second wait time for displaying results - **Respectful**: Automatically disabled in CI environments (when `CI` env var is set) **Example notification:** ``` $ melange migrate Migration completed successfully * A new version of melange is available: v1.2.3 (current: v1.2.0) brew upgrade melange or go install github.com/pthm/melange@latest ``` **To disable update checks:** ```bash melange --no-update-check migrate ``` **To clear the cache:** ```bash rm -rf ~/.cache/melange/update-check.json ``` The cache respects the `XDG_CACHE_HOME` environment variable if set. ## Command Groups Commands are organized into logical groups: **Schema Commands:** `validate`, `migrate`, `status`, `schema pull`, `diff`, `history`, `doctor`, `explain`, `expand`, `studio` **Client Commands:** `generate client`, `generate migration` **Utility Commands:** `init`, `init agent`, `config show`, `env list`, `version`, `license` --- ## Schema Commands ### validate Check a schema without database access: syntax, references to undefined types and relations, unsupported features (conditions), and relation cycles. These are the same checks `migrate` and `generate` run before compiling. ```bash melange validate --schema schemas/schema.fga ``` **Output:** ``` Schema is valid. Found 3 types: - user (0 relations) - organization (3 relations) - repository (5 relations) ``` An invalid schema exits with code 3 and names each problem: ``` Error: parsing schema: melange: invalid schema: undefined references: document#viewer references "editr", which is not a relation on "document" ``` With `--format json`, the result is printed to stdout for both outcomes, and an invalid schema still exits non-zero: ```json { "valid": false, "schema": "melange/schema.fga", "error": "parsing schema: melange: invalid schema: undefined references: ..." } ``` A valid result lists each type with its relation names under `types`. **Flags:** | Flag | Default | Description | | ---------- | -------------------- | ----------------------------------- | | `--schema` | `schemas/schema.fga` | Path to schema.fga or fga.mod file | | `--format` | `text` | Output format: `text` or `json` | The schema path can also be set via configuration file or environment variable. See [Configuration](#configuration). ### migrate Apply the schema to your PostgreSQL database. ```bash melange migrate \ --db postgres://localhost/mydb \ --schema schemas/schema.fga ``` **Flags:** | Flag | Default | Description | | ------------------------ | -------------------- | ------------------------------------------------------------- | | `--db` | (from config) | PostgreSQL connection string | | `--db-schema` | (config, else `public`) | PostgreSQL schema for melange objects | | `--schema` | `schemas/schema.fga` | Path to schema.fga file | | `--dry-run` | `false` | Output SQL to stdout without applying changes | | `--force` | `false` | Force migration even if schema is unchanged | | `--if-deployed-checksum` | (unset) | Apply only if the deployed schema checksum matches (see below) | This command: 1. Checks if the schema has changed since the last migration 2. Installs generated SQL functions (`check_permission`, `list_accessible_objects`, etc.) 3. Cleans up orphaned functions from removed relations 4. Records the migration in `melange_migrations` table — including the schema DSL and parsed model, so the database is self-describing. This is what `melange status` and `melange schema pull` read back. **Skip-if-unchanged behavior:** Melange tracks schema changes using a SHA256 checksum. If you run `migrate` and the schema hasn't changed since the last migration, it will be skipped automatically: ``` Schema unchanged, migration skipped. Use --force to re-apply. ``` Use `--force` to re-apply the migration anyway (useful after updating Melange itself). **Drift-safe apply (`--if-deployed-checksum`):** `--if-deployed-checksum` turns migrate into a compare-and-swap: it applies only if the database is currently at the schema checksum you pass, otherwise it aborts having changed nothing and exits non-zero. This closes the window where someone else migrates the database between the time you plan a change and the time your deploy applies — which last-writer-wins would silently clobber. The intended CI pattern reads the current checksum from `status`, then gates the apply on it: ```bash CURRENT=$(melange status --env production --format json | jq -r .deployed.schema_checksum) # review / plan the change … melange migrate --env production --if-deployed-checksum "$CURRENT" ``` On a mismatch: ``` Error: migration aborted: deployed model changed: expected checksum abc… but database has def…; nothing was applied ``` An empty value (`--if-deployed-checksum ""`) matches a database with no migration recorded (a fresh database). The guard is enforced in `--dry-run` too, so a drift-gated preview aborts rather than printing SQL against a drifted database. > **Note:** The guard is verified up front and again inside the apply transaction, which holds the migration lock, so a change committed while your migration waited aborts it. **Concurrent migrations and downgrades:** Migrations against the same database schema are serialized with a transaction-scoped advisory lock, so replicas that all run `migrate` on startup are safe: the first applies, and the rest see its record under the lock and skip. `migrate` also refuses to replace functions generated by a newer melange. During a rolling deploy, a replica still on the old version would otherwise overwrite the upgraded functions on every restart, undoing codegen fixes: ``` Error: migration refused: database functions were generated by melange 0.11.0, newer than this melange (0.10.0); nothing was applied (upgrade melange, or force the migration to downgrade deliberately) ``` The refusal exits with code 5. The newer functions keep the same SQL API, so an old replica can keep serving; a startup script can treat code 5 as success: ```bash melange migrate || [ $? -eq 5 ] ``` Pass `--force` to downgrade deliberately. Versions that cannot be ordered are never refused: development builds (`dev`) and local `git describe` builds such as `v0.10.0-3-gabc1234`. **Dry-run mode:** Preview the migration SQL without applying it: ```bash melange migrate --db postgres://localhost/mydb --dry-run ``` This outputs the complete SQL that would be executed, including: - DDL for the migrations tracking table - All generated check functions - All generated list functions - Dispatcher functions - The migration record insert Dry-run output goes to stdout, so you can redirect it: ```bash melange migrate --db postgres://localhost/mydb --dry-run > migration.sql ``` **Orphan cleanup:** When you remove a relation from your schema, Melange automatically drops the orphaned SQL functions during migration. For example, if you remove the `editor` relation from `document`, the next migration will drop `check_document_editor`, `list_document_editor_objects`, etc. **melange_tuples warning:** After migration, if the `melange_tuples` view doesn't exist, you'll see a warning: ``` WARNING: melange_tuples view/table does not exist. Permission checks will fail until you create it. ``` See [Tuples View](https://melange.sh/docs/concepts/tuples-view.md) for setup instructions. ### status Show whether the schema file and `melange_tuples` view are present, what model the database has deployed, and whether the local schema file is in sync with it. ```bash melange status \ --db postgres://localhost/mydb \ --schema schemas/schema.fga ``` **Flags:** | Flag | Default | Description | | ------------- | -------------------- | -------------------------------------------------------------- | | `--db` | (from config) | PostgreSQL connection string | | `--db-schema` | (config, else `public`) | PostgreSQL schema for melange objects | | `--schema` | `schemas/schema.fga` | Path to schema.fga file | | `--format` | `text` | Output format: `text` or `json` | **Output:** ``` Schema file: present Tuples view: present Deployed: checksum d0c1746f7e26… · melange v0.9.0 · 2026-07-01T20:46:10Z Sync: in sync — local schema matches deployed ``` When the local schema has drifted, the change summary follows the Sync line: ``` Schema file: present Tuples view: present Deployed: checksum d0c1746f7e26… · melange v0.9.0 · 2026-07-01T20:46:10Z Sync: drift — local schema differs from deployed (`melange diff` to see changes, `melange migrate` to apply) 1 breaking, 2 additive + type audit_log added + relation document.can_export added - relation document.legacy_viewer removed ``` At most five changes are listed; run `melange diff` for the full list. The summary is omitted when the deployed model was never recorded, when either side fails to parse (a `Note:` explains why), or when the two models are semantically equivalent — reformatting or a comment edit moves the checksum without changing behavior, and that is reported as drift with no changes to show. The **Deployed** line reports the model recorded by the most recent migration (checksum, melange version, and time). The **Sync** state compares the local schema file's checksum against the deployed one: | Sync state | Meaning | | ---------------- | ------------------------------------------------------------------------- | | `in_sync` | The local schema matches what's deployed | | `drift` | The local schema differs — run `melange migrate` to apply | | `database_ahead` | Drift where the deployed model is in no recent commit of your schema file | | `unknown` | No local schema file to compare against | | `not_recorded` | The database has no migration record | `database_ahead` is the warning case: the deployed model is not the local file and is not any version of it in your schema's git history, so someone migrated that database from a different checkout — applying your local schema would overwrite their model rather than move the database forward. Check with `melange schema pull` before migrating, and use `melange migrate --if-deployed-checksum` to make the apply conditional. The probe is advisory and errs toward plain `drift`. It follows the schema across renames and reads each revision as a checkout would (so end-of-line conversion doesn't cause spurious mismatches), and it stays silent whenever it cannot search every version: - outside a git repository, or without git installed - in a shallow (`--depth`) or partial clone — CI checkouts are shallow by default, so this is the common case in automation - when the schema is uncommitted or has local modifications: migrating from a dirty working tree is routine, so the deployed model may be a version git never saw - for modular (`fga.mod`) schemas, whose checksum covers the manifest plus every module - for a schema with more than 50 commits of history, past which the search is no longer exhaustive - when the two models are semantically equivalent — a checksum that moved on formatting or comments alone is drift, never an allegation Treat it as a prompt to look, not a verdict: it reports what git could see, and git cannot see every place a model may have come from. Databases migrated before model storage existed (or by `MigrateWithTypes`) have no recorded model DSL; status notes this and `melange schema pull` cannot recover them. Reading the migration record is non-fatal — if it fails, status still reports schema/tuples presence and adds a `Note:` line. **JSON output** (`--format json`) emits the machine-readable report, including a `notes` array for any non-fatal warnings: ```json { "schema_file": "present", "tuples_view": "present", "sync": "in_sync", "deployed": { "melange_version": "v0.9.0", "migrated_at": "2026-07-01T20:46:10Z", "schema_checksum": "d0c1746f7e26ea40027a24b1c0e0c5f34e279e7c27f6fa17e4611ce2f1ec0962", "schema_format": "single", "model_recorded": true } } ``` When the sync state is `drift` or `database_ahead` and both models are available, a `drift` object carries the same detail as the text output — the counts plus every change, not just the first five. Changes are sorted by type, then relation, so the order is stable across runs: ```json { "sync": "drift", "deployed": { "schema_checksum": "d0c1746f7e26…", "model_recorded": true }, "drift": { "additive": 2, "breaking": 1, "changes": [ { "class": "additive", "type": "audit_log", "summary": "type audit_log added" }, { "class": "additive", "type": "document", "relation": "can_export", "summary": "relation document.can_export added" }, { "class": "breaking", "type": "document", "relation": "legacy_viewer", "summary": "relation document.legacy_viewer removed" } ] } } ``` ### schema pull Reconstruct the OpenFGA schema recorded by the most recent migration. Use it to recover a schema whose source file was lost, or to see exactly what model a database is running. ```bash # Print the deployed schema melange schema pull --db postgres://localhost/mydb # Recover it to a file, targeting a named environment melange schema pull --env production -o recovered.fga ``` **Flags:** | Flag | Default | Description | | ---------------- | -------------------- | ------------------------------------------------------ | | `--db` | (from config) | PostgreSQL connection string | | `--db-schema` | (config, else `public`) | PostgreSQL schema for melange objects | | `-o`, `--output` | (stdout) | Write to this file instead of stdout | | `--no-header` | `false` | Omit the provenance header comment | **Output:** ``` # Pulled from a melange-migrated database by `melange schema pull` # Deployed: 2026-07-01T20:46:10Z by melange v0.9.0 # Schema checksum: d0c1746f7e26ea40027a24b1c0e0c5f34e279e7c27f6fa17e4611ce2f1ec0962 model schema 1.1 type user ... ``` The provenance header is written as `#` comments (valid DSL), so the output still parses; the database URL is never included. Pass `--no-header` for the bare schema, which for a single-file schema is byte-identical to what you migrated. > **Note:** A **modular** (`fga.mod`) schema is emitted as the stored manifest + module bundle for reference/recovery. That combined form does **not** re-parse as a single `.fga`, and splitting it back into module files is not supported. Requires a database migrated by a version that records the model. Older databases did not record the model; pull reports whether the database was never migrated or was migrated before model storage existed. ### diff Show what changed between a deployed (or previous) model and your local schema, with each change classified **additive** (safe — widens access or adds structure) or **breaking** (narrows access or removes structure). ```bash # What would migrating apply to production? melange diff --env production # Compare against the schema on main, or a previous file melange diff --git-ref main melange diff --previous-schema old.fga ``` **Flags:** | Flag | Default | Description | | ------------------- | -------------------- | ------------------------------------------------------------------- | | `--db` | (from config) | Database to compare against (the default source) | | `--db-schema` | (config, else `public`) | PostgreSQL schema for melange objects | | `--schema` | `schemas/schema.fga` | Local `.fga` — the new side of the diff | | `--git-ref` | (unset) | Compare against your schema at a git commit/branch/tag | | `--previous-schema` | (unset) | Compare against a previous `.fga` file (modular not supported) | | `--format` | `tree` | Output format: `tree` (default) or `json` | | `--exit-code` | `false` | Exit 1 if any change is breaking (CI gate) | The comparison source is the deployed model by default (`--db`/`--env`, or the configured database); `--git-ref` and `--previous-schema` select a file/git source instead and are mutually exclusive with a database source. **Output:** ``` Comparing deployed → melange/schema.fga additive type audit_log added BREAKING relation document.legacy removed additive document.viewer grants [org] 1 breaking, 2 additive ``` Classification reflects melange's compiled model and is deliberately conservative: it accounts for implied-by closure, intersection subsumption, and exclusion polarity, and it never under-reports a breaking change — so `--exit-code` is safe as a CI gate. `--format=json` emits the structured `SchemaDiff` for tooling. ### history List recent entries from the `melange_migrations` table — when each migration ran, the melange version, the schema checksum, and how many functions it installed. An audit trail of how a database's model has evolved. ```bash melange history --db postgres://localhost/mydb ``` **Flags:** | Flag | Default | Description | | ------------- | -------------------- | ---------------------------------------- | | `--db` | (from config) | PostgreSQL connection string | | `--db-schema` | (config, else `public`) | PostgreSQL schema for melange objects | | `--format` | `text` | Output format: `text` (default) or `json` | | `--limit` | `20` | Maximum number of entries to show | **Output:** ``` Migration history (most recent first): 2026-07-02T20:46:10Z · melange v0.9.1 · checksum 550b0008a779… · single · 23 functions 2026-07-01T18:12:03Z · melange v0.9.0 · checksum 0ba53b1429e9… · single · 17 functions ``` ### doctor Run comprehensive health checks on your authorization infrastructure. ```bash melange doctor \ --db postgres://localhost/mydb \ --schema schemas/schema.fga ``` **Flags:** | Flag | Default | Description | | -------------------- | -------------------- | -------------------------------------------- | | `--db` | (from config) | PostgreSQL connection string | | `--db-schema` | (config, else `public`) | PostgreSQL schema for melange objects | | `--schema` | `schemas/schema.fga` | Path to schema.fga file | | `--verbose` | `false` | Show detailed output with additional context | | `--skip-performance` | `false` | Skip performance checks (view analysis) | | `--format` | `text` | Output format: `text` or `json` | With `--format json`, the report is a list of `checks` (each with `category`, `name`, `status` of `pass`, `warn`, or `fail`, `message`, and optional `details` and `fix`) plus `passed`, `warnings`, and `errors` counts. The command exits non-zero when any check fails. **Output:** ``` melange doctor - Health Check Schema File ✓ Schema file exists at schemas/schema.fga ✓ Schema is valid (3 types, 8 relations) ✓ No cyclic dependencies detected Migration State ✓ melange_migrations table exists ✓ Schema migrated (24 functions tracked) ✓ Schema is in sync with database Generated Functions ✓ All dispatcher functions present ✓ All 24 expected functions present Tuples Source ✓ melange_tuples exists (view) ✓ All required columns present Data Health ✓ melange_tuples contains 1523 tuples ✓ All sampled tuples reference valid types and relations Performance ✓ View definition parsed (3 branches) ✓ View uses UNION ALL (no unnecessary deduplication) ✓ Source tables: users, organizations, repositories ✓ All ::text cast columns have expression indexes Summary: 15 passed, 0 warnings, 0 errors ``` The doctor command performs the following checks: **Schema File:** - Verifies the schema file exists - Parses the schema and checks for undefined types and relations - Detects cyclic dependencies in implied-by relationships **Migration State:** - Checks if the `melange_migrations` tracking table exists - Verifies a migration has been applied - Compares schema checksum to detect if schema has changed since last migration - Checks if codegen version has changed (indicating Melange was updated) **Generated Functions:** - Verifies all dispatcher functions exist (`check_permission`, `list_accessible_objects`, etc.) - Compares expected functions from schema against actual functions in database - Identifies orphan functions from previous schema versions **Tuples Source:** - Verifies `melange_tuples` view/table exists - Checks required columns: `object_type`, `object_id`, `relation`, `subject_type`, `subject_id` - Warns if using a materialized view (requires manual refresh) **Data Health:** - Reports tuple count - Validates that tuples reference valid types and relations defined in the schema **Performance** (view-based `melange_tuples` only): Performance checks run automatically when `melange_tuples` is a view (not a table or materialized view). They can be disabled with `--skip-performance`. - Parses the view definition via `pg_get_viewdef()` and reports the number of UNION branches and source tables - Detects bare `UNION` (warns to use `UNION ALL` instead, since `UNION` adds deduplication overhead) - Checks for missing expression indexes on columns cast with `::text` in the view definition. Missing indexes cause sequential scans on the source tables: - **Warning** if the source table has fewer than 10,000 rows (recommended for future scaling) - **Failure** if the source table has 10,000+ rows (critical at current scale) - Provides exact `CREATE INDEX` statements as fix hints **Expand Fan-Out Advisory:** Runs whenever `melange_tuples` exists (both view and table backends). Flags relations whose Expand response can grow large depending on tuple counts: - **Wildcard grants** (`[user:*]`): every Expand response surfaces the wildcard entry (`user:*`). Downstream consumers that enumerate the wildcard client-side hit unbounded fan-out. - **Recursive TTU** (`viewer from parent` on a self-referential type): `ExpandRecursive` walks the parent pointer chain, so a deep hierarchy multiplies round-trip cost. The advisory is `StatusPass` — a hint, not a diagnosed problem — and suggests setting `melange.max_expand_leaf` as a session-level guardrail. See [Expanding Permissions](https://melange.sh/docs/guides/expanding-permissions.md#per-leaf-cap). **Verbose mode:** Use `--verbose` to see additional details for each check: ```bash melange doctor --db postgres://localhost/mydb --verbose ``` This shows: - Exact file paths and checksums - Lists of missing or orphan functions - Specific unknown types or relations found in data **Common issues and fixes:** | Issue | Fix | | ------------------------ | ----------------------------------------------------- | | Schema file not found | Create `schemas/schema.fga` | | Schema is invalid | Run `melange validate` for the specific errors | | Schema out of sync | Run `melange migrate` | | Missing functions | Run `melange migrate` | | Orphan functions | Run `melange migrate` (cleanup is automatic) | | melange_tuples missing | Create a view over your domain tables | | Missing columns | Update melange_tuples to include all required columns | | Unknown types in tuples | Update tuples view or schema to match | | UNION instead of UNION ALL | Replace `UNION` with `UNION ALL` in view definition | | Missing expression index | Run the `CREATE INDEX` command shown in the fix hint | ### explain Return the resolution trace for a check — every attempted branch, contributing tuples, per-branch success/failure. See the [Explaining Decisions guide](https://melange.sh/docs/guides/explaining-decisions.md) for the trace structure. ```bash melange explain user:alice viewer document:1 --db postgres://localhost/mydb ``` **Flags:** | Flag | Default | Description | | -------------- | ------------- | ---------------------------------------------------------------------------------------------------- | | `--db` | (from config) | PostgreSQL connection string | | `--db-schema` | (config, else `public`) | PostgreSQL schema for melange objects | | `--format` | `tree` | `tree` (unicode pretty-print) or `json` (raw `Trace` JSONB) | | `--max-nodes` | `0` | Cap on trace nodes. `0` defers to the session GUC `melange.max_explain_nodes`, then to 100 | | `--color` | `auto` | `auto` (TTY + `NO_COLOR` unset), `always`, or `never`. See [Colour Output](#colour-output) below. | **Tree output:** ```text ✓ user:alice has viewer on document:1 └── via userset: via [group#member] → group:engineering └── direct: user:alice → member → group:engineering ``` **Failure with attempted branches:** ```text ✗ user:bob does NOT have viewer on document:1 └── union of 3 branches ├── ✗ no direct grant ├── ✗ implied: implied via editor └── ✗ via userset: via [group#member] → group:engineering └── union of 1 branches └── ✗ no direct grant ``` `--format=json` returns the raw `Trace` JSONB for tooling. ### expand Return the OpenFGA-shaped `UsersetTree` for a `(object, relation)` pair — the structured "who has access?" answer. See the [Expanding Permissions guide](https://melange.sh/docs/guides/expanding-permissions.md) for the tree structure and the shallow-by-default resolution model. ```bash melange expand document:1 viewer --db postgres://localhost/mydb ``` **Flags:** | Flag | Default | Description | | ---------------- | ------------- | ---------------------------------------------------------------------------------------------------------------- | | `--db` | (from config) | PostgreSQL connection string | | `--db-schema` | (config, else `public`) | PostgreSQL schema for melange objects | | `--format` | `tree` | `tree` (unicode pretty-print) or `json` (raw `UsersetTree` JSONB) | | `--flatten` | `false` | Print the flat, deduplicated user list instead of the tree. `Leaf.Users` only — computed and TTU pointers are not chased | | `--recursive` | `false` | Print the flat list *and* chase `Leaf.Computed` / TTU pointers with follow-up Expand calls (implies `--flatten`) | | `--subject-type` | (unset) | Melange extension. Narrow `Leaf.Users` to a single subject type | | `--max-leaf` | `0` | Melange extension. Cap each `Leaf.Users` list. `0` defers to session GUC `melange.max_expand_leaf`, then unbounded | | `--color` | `auto` | `auto` (TTY + `NO_COLOR` unset), `always`, or `never`. See [Colour Output](#colour-output) below. | **Tree output:** ``` document:1#viewer • union of 2 ├── document:1#viewer • users │ ├── user:alice │ ├── user:bob │ └── group:eng#member └── document:1#viewer • computed pointer └── computed → document:1#editor (melange expand document:1#editor to chase) ``` **Flattened user list:** ``` $ melange expand document:1 viewer --flatten user:alice user:bob user:carol group:eng#member user:* ``` `--flatten` chases every `Leaf.Computed` and `Leaf.TupleToUserset` pointer with follow-up Expand calls. Cost is N round-trips per N distinct pointers; suitable for admin flows, not the request path. `--format=json` returns the raw `UsersetTree` JSONB for tooling. ### studio Serve a local, read-only web console for a database (experimental). The model is read from the database, so no schema file is required. See the [Studio guide](https://melange.sh/docs/guides/studio.md) for the pages, required privileges, and limits. ```bash melange studio --env staging melange studio --db postgres://localhost/mydb --addr 127.0.0.1:7070 ``` **Flags:** | Flag | Default | Description | | ------------- | ----------------------- | ------------------------------------------------------------------------------------ | | `--db` | (from config) | PostgreSQL connection string | | `--db-schema` | (config, else `public`) | PostgreSQL schema for melange objects | | `--schema` | (config, else `schemas/schema.fga`) | Local schema file for the Health page's file and drift checks | | `--addr` | `127.0.0.1:0` | Loopback address to listen on. Port `0` picks a free port. Non-loopback addresses are refused | Studio prints its URL on stderr and runs until Ctrl-C. Every connection is opened with `default_transaction_read_only=on`; the one exception is Explain with what-if tuples, which creates a temporary view in a transaction that is always rolled back. The global `--no-update-check` flag also hides the top bar's update badge. ### Colour output `melange explain` and `melange expand` colourise identifiers in `tree` output to match the [OpenFGA VS Code extension](https://github.com/openfga/vscode-ext)'s `openfga-dark` theme. Types render green, relations cyan, type restrictions (`[user]`, `[group#member]`) mint, keywords / delimiters grey, dim prose and tree connectors dim grey. The `✓` / `✗` result markers in the header render as bold white glyphs on a coloured background chip. Colours are emitted as 24-bit ANSI true colour so the mapping matches the VS Code palette on modern terminals. `--color=never` disables all escapes for piped writers or legacy terminals. `--color=auto` (default) enables colour when stdout is a TTY and `NO_COLOR` is unset. `--format=json` output is never colourised — the raw JSONB is unaffected by `--color`. --- ## Client Commands ### generate client Generate type-safe client code from your schema. ```bash melange generate client \ --runtime go \ --schema schemas/schema.fga \ --output internal/authz \ --package authz ``` **Flags:** | Flag | Default | Description | | ----------- | -------------------- | --------------------------------------------------------- | | `--runtime` | (required) | Target runtime: `go`, `typescript` | | `--schema` | `schemas/schema.fga` | Path to schema.fga file | | `--output` | stdout | Output directory for generated code | | `--package` | `authz` | Package name for generated code | | `--id-type` | `string` | ID type for constructors (`string`, `int64`, `uuid.UUID`) | | `--filter` | `""` | Only generate relations with this prefix (e.g., `can_`) | **Example with all options:** ```bash melange generate client \ --runtime go \ --schema schemas/schema.fga \ --output internal/authz \ --package authz \ --id-type int64 \ --filter can_ ``` **Output to stdout:** ```bash melange generate client --runtime go --schema schemas/schema.fga ``` **Generated code example:** ```go // schema_gen.go package authz import "github.com/pthm/melange/melange" // Object types const ( TypeUser melange.ObjectType = "user" TypeOrganization melange.ObjectType = "organization" TypeRepository melange.ObjectType = "repository" ) // Relation constants (filtered by prefix "can_") const ( RelCanRead melange.Relation = "can_read" RelCanWrite melange.Relation = "can_write" RelCanDelete melange.Relation = "can_delete" ) // Type-safe constructors func User(id int64) melange.Object { return melange.Object{Type: TypeUser, ID: fmt.Sprint(id)} } func Repository(id int64) melange.Object { return melange.Object{Type: TypeRepository, ID: fmt.Sprint(id)} } // Wildcard constructors func AnyUser() melange.Object { return melange.Object{Type: TypeUser, ID: "*"} } ``` **Supported runtimes:** | Runtime | Status | Description | | ------------ | ----------- | ------------------------------------------------- | | `go` | Implemented | Type-safe Go code with constants and constructors | | `typescript` | Planned | TypeScript types and factory functions | ### generate migration Generate versioned SQL migration files for use with external migration frameworks (golang-migrate, Atlas, Flyway, etc.). Instead of applying SQL directly like `melange migrate`, this command produces `.sql` files you commit, review, and apply through your existing workflow. For a conceptual overview of when to use this versus `melange migrate`, see [Running Migrations](https://melange.sh/docs/guides/migrations.md). ```bash melange generate migration \ --schema schemas/schema.fga \ --output db/migrations ``` **Flags:** | Flag | Default | Description | | --------------------- | -------------------- | -------------------------------------------------------------- | | `--schema` | `schemas/schema.fga` | Path to current `.fga` schema file (required) | | `--db-schema` | (config, else `public`) | PostgreSQL schema for melange objects | | `--output` | (stdout) | Output directory for migration files | | `--name` | `melange` | Migration name suffix used in filenames | | `--format` | `split` | Output format: `split` (`.up.sql`/`.down.sql`) or `single` | | `--up` | `false` | Output only the UP migration (stdout mode only) | | `--down` | `false` | Output only the DOWN migration (stdout mode only) | | `--db` | - | Database URL. Compare against most recent migration record | | `--git-ref` | - | Git ref. Compare against schema at that commit/branch/tag | | `--previous-schema` | - | File path. Compare against a previous `.fga` file | > **Note:** The three comparison flags (`--db`, `--git-ref`, `--previous-schema`) are mutually exclusive. When none is specified, a full migration is generated containing all functions. **Output modes:** When `--output` is specified, timestamped files are written to that directory: ``` db/migrations/20260322143000_melange.up.sql db/migrations/20260322143000_melange.down.sql ``` When `--output` is omitted (stdout mode), you must specify `--up` or `--down` to select which migration to print. This is useful for piping into other tools: ```bash melange generate migration --schema schemas/schema.fga --git-ref main --up | psql "$DATABASE_URL" ``` **Output formats:** | Format | Files | Use case | | -------- | ---------------------------------------------- | ------------------------------- | | `split` | `TIMESTAMP_NAME.up.sql`, `TIMESTAMP_NAME.down.sql` | golang-migrate, Atlas, most frameworks | | `single` | `TIMESTAMP_NAME.sql` with UP and DOWN sections | Flyway, frameworks expecting a single file | **Comparison modes:** By default, the UP migration includes every generated function (full mode). To emit only what changed, use one of: - **`--db`** - Reads the previous function inventory from the `melange_migrations` table. Most precise, but requires database access. - **`--git-ref`** - Compiles the schema from a git ref and compares. No database needed, suitable for CI. - **`--previous-schema`** - Compiles a previous schema from a local file and compares. When a comparison mode is active: - Only functions with changed SQL bodies (or newly added) are included in UP - Orphaned functions (removed from schema) get `DROP FUNCTION IF EXISTS` statements - Dispatcher functions are always included regardless of changes **Examples:** ```bash # First migration (full - all functions) melange generate migration \ --schema schemas/schema.fga \ --output db/migrations # Incremental migration comparing against database melange generate migration \ --schema schemas/schema.fga \ --output db/migrations \ --db postgres://localhost/mydb # Incremental migration comparing against git melange generate migration \ --schema schemas/schema.fga \ --output db/migrations \ --git-ref main # Preview changes to stdout melange generate migration \ --schema schemas/schema.fga \ --git-ref HEAD~1 \ --up # Single-file format for Flyway melange generate migration \ --schema schemas/schema.fga \ --output db/migrations \ --format single \ --git-ref main ``` --- ## Utility Commands ### init Initialize a new Melange project with an interactive wizard. Detects your project type (Go or TypeScript) and scaffolds a config file, starter schema, and runtime dependency. It also asks whether to add a Melange section for coding agents to `AGENTS.md` (or `CLAUDE.md` if that is the only one present, or a new `AGENTS.md`); for an existing project, use [`melange init agent`](#init-agent). ```bash melange init ``` **Flags:** | Flag | Default | Description | |------|---------|-------------| | `-y`, `--yes` | `false` | Accept all defaults without prompting | | `--no-install` | `false` | Skip installing runtime dependencies | | `--schema` | `melange/schema.fga` | Schema file path | | `--db` | `postgres://localhost:5432/mydb` | Database URL | | `--template` | `org-rbac` | Starter model: `org-rbac`, `doc-sharing`, `minimal`, `none` | | `--runtime` | (auto-detected) | Client runtime: `go`, `typescript` | | `--output` | `internal/authz` (Go) / `src/authz` (TS) | Client output directory | | `--package` | `authz` | Client package name (Go only) | | `--id-type` | `string` | Client ID type: `string`, `int64`, `uuid.UUID` | | `--migration-strategy` | (prompted) | How migrations are applied: `builtin` (`melange migrate`) or `versioned` (generated SQL files) | | `--migration-output` | `migrations/` | Output directory for versioned migration files | | `--migration-format` | `split` | Versioned migration format: `split` (`.up.sql`/`.down.sql`) or `single` | | `--migration-name` | `melange` | Versioned migration name suffix used in filenames | | `--agents` | `false` | Add a Melange section to `AGENTS.md` / `CLAUDE.md` without asking. The wizard asks; `-y` skips it unless this is set | **Project detection:** Melange inspects the current directory for project files: | File | Detected Runtime | Default Output | |------|-----------------|----------------| | `go.mod` | Go | `internal/authz` | | `package.json` | TypeScript | `src/authz` | When a project is detected, client code generation is enabled by default and the runtime dependency is installed automatically (`go get github.com/pthm/melange/melange` for Go, `npm install @pthm/melange` for TypeScript). If both `go.mod` and `package.json` exist, Go takes precedence. For TypeScript projects, the package manager is auto-detected from lock files: `bun.lockb`/`bun.lock` → bun, `pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, otherwise npm. **Starter models:** | Template | Description | |----------|-------------| | `org-rbac` | Organization with role hierarchy and repository permissions (default) | | `doc-sharing` | Google Docs-style owner/editor/viewer model | | `minimal` | Bare-bones model with just `type user` | | `none` | Skip schema file creation | **Directory convention:** By default, `init` creates a `melange/` directory containing both the config and schema: ``` myproject/ ├── melange/ │ ├── config.yaml │ └── schema.fga └── ... ``` If the schema path is outside the `melange/` directory (e.g., `--schema schemas/auth.fga`), the config is placed at the project root as `melange.yaml` instead. **Safety:** - Refuses to overwrite an existing config file in `--yes` mode (use interactive mode to confirm) - Skips schema creation if the file already exists in `--yes` mode **Examples:** ```bash # Interactive wizard melange init # Accept all defaults (in a Go project) melange init -y # Custom schema and database, skip dependency install melange init -y --schema melange/auth.fga --db postgres://prod:5432/app --no-install # Minimal schema with no client generation melange init -y --template minimal ``` ### init agent Print a Markdown section for coding agents describing the project's Melange setup and workflow, filled in from the config file: schema and config paths, generated client location, and whether to run `melange migrate` or `melange generate migration`. Append it to the instructions file your agent reads: ```bash melange init agent >> AGENTS.md ``` The output starts with a blank line so it can be appended to any file. It is the same section the `melange init` wizard offers to add. See [Using Coding Agents](https://melange.sh/docs/guides/coding-agents.md) for sample output. ### config show Display the effective configuration after merging defaults, config file, environment variables, and any selected environment profile. ```bash melange config show ``` **Flags:** | Flag | Default | Description | | ----------------- | ------- | ----------------------------------------------------------- | | `--source` | `false` | Show the config file path and active environment | | `--reveal-secrets`| `false` | Print passwords in cleartext instead of masking them | Passwords — in both `database.url` and the discrete `password` field, across the base config and every environment profile — are **masked by default**, including values resolved from `${VAR}` references. Pass `--reveal-secrets` to print them. **Example — inspect the resolved production profile:** ```bash melange config show --env production --source ``` **Output:** ``` Config file: /path/to/project/melange.yaml Environment: production schema: schemas/schema.fga database: url: postgres://prod-user:****@prod-db:5432/app ... ``` This is useful for debugging configuration issues and understanding which values are in effect for a given environment. ### env list List the [environment profiles](https://melange.sh/docs/reference/configuration.md#environments) defined in your config and their connection targets. The active environment is marked with `*`. ```bash melange env list ``` **Output:** ``` Environments: * local postgres://localhost:5432/mydb_dev production ${PROD_DATABASE_URL} staging staging.db.internal:5432/app Default: local Active: local (marked with *) ``` Targets are shown as configured — `${VAR}` references are left literal (not expanded) and any URL password is redacted, so the listing is safe to share. ### version Print version information. ```bash melange version ``` **Output:** ``` melange v1.0.0 (commit: abc1234, built: 2024-01-15) ``` ### license Print license and third-party notices. ```bash melange license ``` This displays the Melange license and attribution for all embedded third-party dependencies. --- ## Exit Codes | Code | Meaning | | ---- | -------------------------------------------------------------------- | | 0 | Success | | 1 | General error (validation, runtime, IO) | | 2 | Configuration error (invalid config file, missing required settings) | | 3 | Schema parse error | | 4 | Database connection error | | 5 | `migrate` refused: a newer melange already migrated the database | The CI gates exit non-zero on an intentional gate failure, not just tool errors: `melange diff --exit-code` exits 1 when a change is breaking, and `melange migrate --if-deployed-checksum` exits 1 when the deployed model has drifted. In both cases the accompanying message explains the condition. ## Common Workflows ### Development Setup **With `melange init` (recommended):** ```bash melange init ``` This creates a config file and starter schema. Then run commands without flags: ```bash # Validate schema melange validate # Apply to database melange migrate # Generate Go code melange generate client ``` **Without configuration file:** ```bash # 1. Validate the schema melange validate --schema schemas/schema.fga # 2. Apply to local database melange migrate \ --db postgres://localhost/myapp_dev \ --schema schemas/schema.fga # 3. Generate Go code melange generate client \ --runtime go \ --schema schemas/schema.fga \ --output internal/authz \ --package authz ``` ### CI/CD Pipeline Use environment variables for credentials: ```bash # Set database URL from CI secrets export MELANGE_DATABASE_URL="$DATABASE_URL" # Validate schema (fails fast if syntax error) melange validate # Preview migration (optional, for review) melange migrate --dry-run # Apply migrations melange migrate # Run health checks melange doctor ``` For pipelines where you want to ensure migrations are always applied (e.g., after a Melange version update): ```bash melange migrate --force ``` **Gated deploy against a live environment.** Fail the build on a breaking change, then apply only if the environment hasn't drifted since you inspected it: ```bash # Fail review if the change narrows access melange diff --env production --exit-code # Capture the state you reviewed CURRENT=$(melange status --env production --format json | jq -r .deployed.schema_checksum) # … approval gate … # Apply only if production is still at that checksum melange migrate --env production --if-deployed-checksum "$CURRENT" ``` ### Schema Updates When you modify your `.fga` schema: ```bash # 1. Validate changes melange validate # 2. Regenerate client code melange generate client # 3. Apply to database melange migrate ``` Or as a single workflow with explicit flags: ```bash melange validate --schema schemas/schema.fga && \ melange generate client --runtime go --output internal/authz && \ melange migrate --db "$DATABASE_URL" ``` ### External Migration Frameworks If you use golang-migrate, Atlas, Flyway, or a similar tool, replace `melange migrate` with `melange generate migration`: ```bash # First time - generate a full migration melange generate migration \ --schema schemas/schema.fga \ --output db/migrations # After schema changes - generate an incremental migration melange generate migration \ --schema schemas/schema.fga \ --output db/migrations \ --git-ref main # Commit the schema change and migration together git add schemas/schema.fga db/migrations/ git commit -m "Add editor relation to document type" # Apply with your framework migrate -path db/migrations -database "$DATABASE_URL" up ``` See [Running Migrations](https://melange.sh/docs/guides/migrations.md) for guidance on choosing between built-in and external migration strategies. ### Troubleshooting When permission checks aren't working as expected, use `doctor` to diagnose issues: ```bash # Run comprehensive health checks melange doctor # With verbose output for more details melange doctor --verbose # Check effective configuration melange config show --source ``` Common scenarios where `doctor` helps: 1. **Permission checks returning unexpected results** - Doctor validates that your schema, generated functions, and tuples are all in sync. 2. **After updating Melange** - Doctor detects if the codegen version has changed and functions need regenerating. 3. **New environment setup** - Doctor validates the complete authorization stack is properly configured. 4. **Data migration issues** - Doctor samples tuples and validates they reference valid types and relations. 5. **Slow permission checks** - Doctor detects missing expression indexes and inefficient view patterns that cause performance degradation at scale. --- ## Programmatic Alternative For programmatic schema management without the CLI, use the Go API: ```go import ( "github.com/pthm/melange/pkg/parser" "github.com/pthm/melange/pkg/migrator" ) // Parse schema types, err := parser.ParseSchema("schemas/schema.fga") if err != nil { log.Fatal(err) } // Apply, recording the deployed model in melange_migrations err = migrator.Migrate(ctx, db, "schemas/schema.fga") ``` `migrator.Migrate` and `migrator.MigrateFromString` record the schema they applied, so the database stays self-describing and `status`, `schema pull`, and `diff` keep working against it. `Migrator.MigrateWithTypes` (used with pre-parsed types) deliberately skips that bookkeeping — reach for it only when you don't want a migration record, such as in test fixtures. **With options (dry-run, force, skip-if-unchanged):** ```go import ( "os" "github.com/pthm/melange/pkg/migrator" ) // Dry-run: output SQL to stdout opts := migrator.MigrateOptions{ DryRun: os.Stdout, } skipped, err := migrator.MigrateWithOptions(ctx, db, "schemas/schema.fga", opts) // Force migration even if unchanged opts := migrator.MigrateOptions{ Force: true, } skipped, err := migrator.MigrateWithOptions(ctx, db, "schemas/schema.fga", opts) // Normal migration with skip detection opts := migrator.MigrateOptions{} skipped, err := migrator.MigrateWithOptions(ctx, db, "schemas/schema.fga", opts) if skipped { log.Println("Schema unchanged, migration skipped") } // Compare-and-swap: apply only if the database is still at this checksum deployed := "3f9a...c21b" opts := migrator.MigrateOptions{ IfDeployedChecksum: &deployed, } skipped, err := migrator.MigrateWithOptions(ctx, db, "schemas/schema.fga", opts) var changed *migrator.DeployedModelChangedError if errors.As(err, &changed) { log.Printf("database drifted: expected %s, found %s", changed.Expected, changed.Actual) } ``` Run health checks with the `melange doctor` command (see above). The doctor package is internal to the CLI and is not a supported programmatic API; for automation, run the command and check its exit code (non-zero on failure). See [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md) for the full Go API reference. --- # SQL API Source: https://melange.sh/docs/reference/sql-api/ > Melange's generated SQL functions, such as check_permission and list_accessible_objects, callable from any PostgreSQL client. Melange generates SQL functions that can be called directly from any PostgreSQL client. This allows you to use Melange's authorization system from any language or framework without requiring the Go library. ## Overview When you run `melange migrate`, Melange generates: | Function | Purpose | |----------|---------| | `check_permission` | Check if a subject has a relation on an object | | `check_permission_bulk` | Check multiple permissions in a single call | | `list_accessible_objects` | List all objects a subject can access (with pagination) | | `list_accessible_subjects` | List all subjects with access to an object (with pagination) | These are the primary entry points. Internally, Melange generates specialized per-relation functions (e.g., `check_document_viewer`) that the dispatchers route to. ## check_permission Checks whether a subject has a specific relation on an object. ### Signature ```sql check_permission( p_subject_type TEXT, p_subject_id TEXT, p_relation TEXT, p_object_type TEXT, p_object_id TEXT ) RETURNS INTEGER ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `p_subject_type` | TEXT | Type of the subject (e.g., `'user'`, `'team'`) | | `p_subject_id` | TEXT | ID of the subject | | `p_relation` | TEXT | Relation to check (e.g., `'viewer'`, `'can_read'`) | | `p_object_type` | TEXT | Type of the object (e.g., `'document'`, `'repository'`) | | `p_object_id` | TEXT | ID of the object | ### Return Value - `1` - Access granted - `0` - Access denied ### Examples ```sql -- Check if user 123 can view document 456 SELECT check_permission('user', '123', 'viewer', 'document', '456'); -- Use in a WHERE clause to filter accessible records SELECT d.* FROM documents d WHERE check_permission('user', '123', 'viewer', 'document', d.id::text) = 1; -- Use with CASE for conditional logic SELECT d.id, d.title, CASE WHEN check_permission('user', '123', 'editor', 'document', d.id::text) = 1 THEN true ELSE false END AS can_edit FROM documents d WHERE check_permission('user', '123', 'viewer', 'document', d.id::text) = 1; ``` ## check_permission_bulk Checks multiple permissions in a single SQL call. Each position across the input arrays forms one check request, and results are returned as a table. ### Signature ```sql check_permission_bulk( p_subject_types TEXT[], p_subject_ids TEXT[], p_relations TEXT[], p_object_types TEXT[], p_object_ids TEXT[] ) RETURNS TABLE(idx INTEGER, allowed INTEGER) ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `p_subject_types` | TEXT[] | Array of subject types (e.g., `ARRAY['user', 'user']`) | | `p_subject_ids` | TEXT[] | Array of subject IDs | | `p_relations` | TEXT[] | Array of relations to check | | `p_object_types` | TEXT[] | Array of object types | | `p_object_ids` | TEXT[] | Array of object IDs | > **Warning:** All five arrays **must** be the same length. Each position across the arrays forms one check request. For example, position 1 checks whether `p_subject_types[1]:p_subject_ids[1]` has `p_relations[1]` on `p_object_types[1]:p_object_ids[1]`. ### Return Value Returns a table with two columns: - `idx` - 1-based position corresponding to the input arrays - `allowed` - `1` for access granted, `0` for access denied One row is returned per input check, in index order. ### Examples ```sql -- Check two permissions in a single call SELECT idx, allowed FROM check_permission_bulk( ARRAY['user', 'user'], ARRAY['123', '123'], ARRAY['viewer', 'editor'], ARRAY['document', 'document'], ARRAY['456', '789'] ); -- Returns: -- idx | allowed -- ----+--------- -- 1 | 1 -- 2 | 0 -- Mixed object types and relations SELECT idx, allowed FROM check_permission_bulk( ARRAY['user', 'user', 'team'], ARRAY['123', '123', '456'], ARRAY['viewer', 'can_read', 'member'], ARRAY['document', 'repository', 'organization'], ARRAY['doc-1', 'repo-1', 'org-1'] ); -- Use results in a query by joining SELECT d.id, d.title, b.allowed FROM documents d JOIN check_permission_bulk( ARRAY(SELECT 'user' FROM documents), ARRAY(SELECT '123' FROM documents), ARRAY(SELECT 'viewer' FROM documents), ARRAY(SELECT 'document' FROM documents), ARRAY(SELECT id::text FROM documents) ) b ON d.id = b.idx ORDER BY d.id; ``` ## list_accessible_objects Returns all object IDs that a subject has a specific relation on, with cursor-based pagination support. ### Signature ```sql list_accessible_objects( p_subject_type TEXT, p_subject_id TEXT, p_relation TEXT, p_object_type TEXT, p_limit INT DEFAULT NULL, p_after TEXT DEFAULT NULL ) RETURNS TABLE(object_id TEXT, next_cursor TEXT) ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `p_subject_type` | TEXT | Type of the subject | | `p_subject_id` | TEXT | ID of the subject | | `p_relation` | TEXT | Relation to check | | `p_object_type` | TEXT | Type of objects to list | | `p_limit` | INT | Maximum results per page (NULL = no limit) | | `p_after` | TEXT | Cursor from previous page (NULL = first page) | ### Return Value Returns a table with two columns: - `object_id` - The ID of an accessible object - `next_cursor` - Cursor for the next page (NULL when no more pages) The `next_cursor` value is repeated on every row for client convenience. Use the last row's cursor value to fetch the next page. > **Note:** **Ordering**: Results are ordered deterministically by `object_id` to ensure stable pagination. ### Examples ```sql -- Get first 100 documents user 123 can view SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, NULL); -- Get next page using cursor SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, 'doc-100'); -- Get all accessible objects (no pagination) SELECT object_id FROM list_accessible_objects('user', '123', 'viewer', 'document', NULL, NULL); -- Join with domain table to get full records SELECT d.* FROM documents d JOIN list_accessible_objects('user', '123', 'viewer', 'document', NULL, NULL) a ON d.id::text = a.object_id; -- Count accessible objects SELECT COUNT(*) FROM list_accessible_objects('user', '123', 'viewer', 'document', NULL, NULL); ``` ## list_accessible_subjects Returns all subjects that have a specific relation on an object, with cursor-based pagination support. ### Signature ```sql list_accessible_subjects( p_object_type TEXT, p_object_id TEXT, p_relation TEXT, p_subject_type TEXT, p_limit INT DEFAULT NULL, p_after TEXT DEFAULT NULL ) RETURNS TABLE(subject_id TEXT, next_cursor TEXT) ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `p_object_type` | TEXT | Type of the object | | `p_object_id` | TEXT | ID of the object | | `p_relation` | TEXT | Relation to check | | `p_subject_type` | TEXT | Type of subjects to list | | `p_limit` | INT | Maximum results per page (NULL = no limit) | | `p_after` | TEXT | Cursor from previous page (NULL = first page) | ### Return Value Returns a table with two columns: - `subject_id` - The ID of a subject with access - `next_cursor` - Cursor for the next page (NULL when no more pages) > **Note:** **Ordering**: Results are ordered with wildcard subjects (`'*'`) first, then alphabetically by `subject_id`. This ensures stable pagination while keeping wildcard entries grouped at the top. ### Examples ```sql -- Get first 100 users who can view document 456 SELECT subject_id, next_cursor FROM list_accessible_subjects('document', '456', 'viewer', 'user', 100, NULL); -- Get next page using cursor SELECT subject_id, next_cursor FROM list_accessible_subjects('document', '456', 'viewer', 'user', 100, 'user-100'); -- Get all subjects with access (no pagination) SELECT subject_id FROM list_accessible_subjects('document', '456', 'viewer', 'user', NULL, NULL); -- Join with users table to get full user records SELECT u.* FROM users u JOIN list_accessible_subjects('document', '456', 'viewer', 'user', NULL, NULL) a ON u.id::text = a.subject_id; -- Get all team members with access (userset filter) -- Note: Use 'team#member' as subject_type to filter by userset SELECT subject_id, next_cursor FROM list_accessible_subjects('document', '456', 'viewer', 'team#member', NULL, NULL); ``` ## explain_permission Returns a JSONB resolution trace for a check: every attempted branch, contributing tuples, per-branch success/failure. The companion to `check_permission` for debugging and admin tooling — not the request path, since it builds a JSONB document per call. ### Signature ```sql explain_permission( p_subject_type TEXT, p_subject_id TEXT, p_relation TEXT, p_object_type TEXT, p_object_id TEXT, p_max_nodes INTEGER DEFAULT NULL ) RETURNS JSONB ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `p_subject_type` | TEXT | Subject type (e.g., `'user'`) | | `p_subject_id` | TEXT | Subject identifier | | `p_relation` | TEXT | Relation to check | | `p_object_type` | TEXT | Object type (e.g., `'document'`) | | `p_object_id` | TEXT | Object identifier | | `p_max_nodes` | INTEGER | Optional cap on trace nodes. `NULL` defers to session GUC `melange.max_explain_nodes`, then to the built-in default (100). | ### Return Value A JSONB document matching the runtime's `Trace` type. Always returns a valid trace; for an unknown `(object_type, relation)` pair the root is a sentinel rather than NULL. ```jsonc { "object": "document:1", "relation": "viewer", "subject": "user:alice", "result": true, "root": { "type": "userset", "label": "via [group#member] → group:engineering", "result": true, "children": [ { "type": "direct", "label": "direct grant", "result": true, "evidence": [ {"subject_type": "user", "subject_id": "alice", "relation": "member", "object_type": "group", "object_id": "engineering"} ] } ] }, "node_count": 2 } ``` `result` is `true` on allow, `false` on deny. `truncated` is omitted when `false`; it's `true` when `p_max_nodes` capped the trace. ### Examples ```sql -- Pretty-print the trace SELECT jsonb_pretty(explain_permission('user', 'alice', 'viewer', 'document', '1')); -- Per-call cap SELECT explain_permission('user', 'alice', 'can_read', 'repository', '42', 50); -- Per-session cap; subsequent calls inherit it SET melange.max_explain_nodes = 200; SELECT explain_permission('user', 'bob', 'can_admin', 'repository', '7'); ``` ### Use from runtime / CLI `Checker.Explain` and `melange explain` wrap this function. See the [Explaining Decisions guide](https://melange.sh/docs/guides/explaining-decisions.md). ## explain_permission_internal Internal dispatcher behind `explain_permission`. Same signature plus a `p_visited TEXT[]` cycle-detection array. Specialized functions (`explain__`) call it recursively; most callers should use `explain_permission` instead. ```sql explain_permission_internal( p_subject_type TEXT, p_subject_id TEXT, p_relation TEXT, p_object_type TEXT, p_object_id TEXT, p_visited TEXT[] DEFAULT ARRAY[]::TEXT[], p_max_nodes INTEGER DEFAULT NULL ) RETURNS JSONB ``` ## expand_permission Returns a JSONB `UsersetTree` for a `(object, relation)` pair, matching `openfgav1.UsersetTree` field-for-field. Resolution is shallow: computed rewrites surface as `Leaf.Computed` pointers, TTU rewrites as `Leaf.TupleToUserset` pointers. The companion to `list_accessible_subjects` for admin / audit tooling that needs the structural tree rather than a flat list. ### Signature ```sql expand_permission( p_object_type TEXT, p_object_id TEXT, p_relation TEXT, p_subject_type TEXT DEFAULT NULL, p_max_leaf INTEGER DEFAULT NULL ) RETURNS JSONB ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | `p_object_type` | TEXT | Object type (e.g., `'document'`) | | `p_object_id` | TEXT | Object identifier | | `p_relation` | TEXT | Relation to expand | | `p_subject_type` | TEXT | Melange extension. When set, `Leaf.Users` entries are filtered to this subject type. `NULL` matches all types. | | `p_max_leaf` | INTEGER | Melange extension. Caps each `Leaf.Users` list. `NULL` defers to session GUC `melange.max_expand_leaf`, then to unbounded (matching OpenFGA). When the cap fires the leaf's `users_truncated` field is `true`. | ### Return Value A JSONB document matching the runtime's `UsersetTree` type. Always returns a valid tree; for an unknown `(object_type, relation)` pair the root's `Leaf.Users` is empty rather than NULL. ```jsonc { "root": { "name": "document:1#viewer", "union": { "nodes": [ { "name": "document:1#viewer", "leaf": { "users": { "users": ["user:alice", "user:bob", "group:eng#member"] } } }, { "name": "document:1#viewer", "leaf": { "computed": {"userset": "document:1#editor"} } } ] } } } ``` Wildcards (`user:*`) and userset references (`group:eng#member`) survive as inline strings in `Leaf.Users`. Callers chase `Leaf.Computed` and `Leaf.TupleToUserset` pointers with follow-up `expand_permission` calls, or use `Checker.ExpandRecursive` from the runtime for the walker. ### Examples ```sql -- Full tree SELECT jsonb_pretty(expand_permission('document', '1', 'viewer')); -- Filter Leaf.Users to a single subject type SELECT expand_permission('document', '1', 'viewer', 'user'); -- Per-call cap; users_truncated fires on overflowing leaves SELECT expand_permission('document', '1', 'viewer', NULL, 100); -- Per-session default; subsequent calls inherit it SET melange.max_expand_leaf = 500; SELECT expand_permission('document', '1', 'viewer'); ``` ### Use from runtime / CLI `Checker.Expand`, `Checker.ExpandRecursive`, and `melange expand` wrap this function. See the [Expanding Permissions guide](https://melange.sh/docs/guides/expanding-permissions.md). ## expand_permission_internal Internal dispatcher behind `expand_permission`. Same signature. Specialized functions (`expand__`) call it recursively to resolve pointer chains inside `ExpandRecursive`; most callers should use `expand_permission` instead. ```sql expand_permission_internal( p_object_type TEXT, p_object_id TEXT, p_relation TEXT, p_subject_type TEXT DEFAULT NULL, p_max_leaf INTEGER DEFAULT NULL ) RETURNS JSONB ``` ## Pagination Both list functions support cursor-based (keyset) pagination for efficient traversal of large result sets. ### How Pagination Works 1. **First page**: Call with `p_limit` set and `p_after = NULL` 2. **Check for more pages**: If any row has `next_cursor` non-NULL, there are more pages 3. **Next page**: Call with `p_after` set to the `next_cursor` value from the previous page 4. **Last page**: When `next_cursor` is NULL, you've reached the end ### Example: Paginating Through All Results ```sql -- First page SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, NULL); -- Returns 100 rows, next_cursor = 'doc-100' -- Second page SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, 'doc-100'); -- Returns 100 rows, next_cursor = 'doc-200' -- Continue until next_cursor is NULL SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, 'doc-900'); -- Returns 50 rows, next_cursor = NULL (no more pages) ``` ### Transaction Consistency Paginated queries across multiple calls can observe changes between pages. For consistency-critical flows, run paging inside a transaction: ```sql BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, NULL); -- Use cursor for next pages within the same transaction COMMIT; ``` ## Error Handling ### Error Code: M2002 The functions raise an exception with error code `M2002` when the permission resolution exceeds the depth limit (25 levels): ```sql RAISE EXCEPTION 'resolution too complex' USING ERRCODE = 'M2002'; ``` This can occur with: - Deeply nested parent relationships (tuple-to-userset chains) - Complex userset chains exceeding 25 levels - Cyclic permission structures Handle this error in your application: ```sql DO $$ BEGIN PERFORM check_permission('user', '123', 'viewer', 'document', '456'); EXCEPTION WHEN SQLSTATE 'M2002' THEN RAISE NOTICE 'Permission resolution too complex'; END; $$; ``` ### Unknown Type/Relation When an unknown object type or relation is queried, the functions return: - `check_permission`: `0` (denied) - `list_accessible_objects`: Empty result set - `list_accessible_subjects`: Empty result set No error is raised for unknown types/relations. ## Usage from Different Languages ### Python (psycopg2) ```python import psycopg2 conn = psycopg2.connect("postgresql://localhost/mydb") cur = conn.cursor() # Check permission cur.execute( "SELECT check_permission(%s, %s, %s, %s, %s)", ('user', '123', 'viewer', 'document', '456') ) allowed = cur.fetchone()[0] == 1 # List accessible objects (first page) cur.execute( "SELECT object_id, next_cursor FROM list_accessible_objects(%s, %s, %s, %s, %s, %s)", ('user', '123', 'viewer', 'document', 100, None) ) rows = cur.fetchall() accessible_ids = [row[0] for row in rows] next_cursor = rows[-1][1] if rows else None # List all accessible objects (no pagination) cur.execute( "SELECT object_id FROM list_accessible_objects(%s, %s, %s, %s, %s, %s)", ('user', '123', 'viewer', 'document', None, None) ) all_accessible_ids = [row[0] for row in cur.fetchall()] ``` ### Node.js (pg) ```javascript const { Pool } = require('pg'); const pool = new Pool({ connectionString: 'postgresql://localhost/mydb' }); // Check permission const { rows } = await pool.query( 'SELECT check_permission($1, $2, $3, $4, $5)', ['user', '123', 'viewer', 'document', '456'] ); const allowed = rows[0].check_permission === 1; // List accessible objects (first page) const result = await pool.query( 'SELECT object_id, next_cursor FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', '123', 'viewer', 'document', 100, null] ); const accessibleIds = result.rows.map(row => row.object_id); const nextCursor = result.rows.length > 0 ? result.rows[result.rows.length - 1].next_cursor : null; // List all accessible objects (no pagination) const allResult = await pool.query( 'SELECT object_id FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', '123', 'viewer', 'document', null, null] ); const allAccessibleIds = allResult.rows.map(row => row.object_id); ``` ### Ruby (pg gem) ```ruby require 'pg' conn = PG.connect(dbname: 'mydb') # Check permission result = conn.exec_params( 'SELECT check_permission($1, $2, $3, $4, $5)', ['user', '123', 'viewer', 'document', '456'] ) allowed = result[0]['check_permission'] == '1' # List accessible objects (first page) result = conn.exec_params( 'SELECT object_id, next_cursor FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', '123', 'viewer', 'document', 100, nil] ) accessible_ids = result.map { |row| row['object_id'] } next_cursor = result.ntuples > 0 ? result[result.ntuples - 1]['next_cursor'] : nil # List all accessible objects (no pagination) result = conn.exec_params( 'SELECT object_id FROM list_accessible_objects($1, $2, $3, $4, $5, $6)', ['user', '123', 'viewer', 'document', nil, nil] ) all_accessible_ids = result.map { |row| row['object_id'] } ``` ### Java (JDBC) ```java import java.sql.*; import java.util.ArrayList; import java.util.List; Connection conn = DriverManager.getConnection("jdbc:postgresql://localhost/mydb"); // Check permission PreparedStatement ps = conn.prepareStatement( "SELECT check_permission(?, ?, ?, ?, ?)" ); ps.setString(1, "user"); ps.setString(2, "123"); ps.setString(3, "viewer"); ps.setString(4, "document"); ps.setString(5, "456"); ResultSet rs = ps.executeQuery(); rs.next(); boolean allowed = rs.getInt(1) == 1; // List accessible objects (first page) ps = conn.prepareStatement( "SELECT object_id, next_cursor FROM list_accessible_objects(?, ?, ?, ?, ?, ?)" ); ps.setString(1, "user"); ps.setString(2, "123"); ps.setString(3, "viewer"); ps.setString(4, "document"); ps.setInt(5, 100); ps.setNull(6, Types.VARCHAR); rs = ps.executeQuery(); List accessibleIds = new ArrayList<>(); String nextCursor = null; while (rs.next()) { accessibleIds.add(rs.getString("object_id")); nextCursor = rs.getString("next_cursor"); } // List all accessible objects (no pagination) ps = conn.prepareStatement( "SELECT object_id FROM list_accessible_objects(?, ?, ?, ?, ?, ?)" ); ps.setString(1, "user"); ps.setString(2, "123"); ps.setString(3, "viewer"); ps.setString(4, "document"); ps.setNull(5, Types.INTEGER); ps.setNull(6, Types.VARCHAR); rs = ps.executeQuery(); List allAccessibleIds = new ArrayList<>(); while (rs.next()) { allAccessibleIds.add(rs.getString("object_id")); } ``` ### Rust (tokio-postgres) ```rust use tokio_postgres::NoTls; let (client, connection) = tokio_postgres::connect( "postgresql://localhost/mydb", NoTls ).await?; // Check permission let row = client.query_one( "SELECT check_permission($1, $2, $3, $4, $5)", &[&"user", &"123", &"viewer", &"document", &"456"] ).await?; let allowed: i32 = row.get(0); let has_access = allowed == 1; // List accessible objects (first page) let rows = client.query( "SELECT object_id, next_cursor FROM list_accessible_objects($1, $2, $3, $4, $5, $6)", &[&"user", &"123", &"viewer", &"document", &Some(100i32), &None::] ).await?; let accessible_ids: Vec = rows.iter() .map(|row| row.get("object_id")) .collect(); let next_cursor: Option = rows.last() .and_then(|row| row.get("next_cursor")); // List all accessible objects (no pagination) let rows = client.query( "SELECT object_id FROM list_accessible_objects($1, $2, $3, $4, $5, $6)", &[&"user", &"123", &"viewer", &"document", &None::, &None::] ).await?; let all_accessible_ids: Vec = rows.iter() .map(|row| row.get("object_id")) .collect(); ``` ## Specialized Functions In addition to the dispatcher functions, Melange generates specialized functions for each type/relation pair. These are called internally by the dispatchers but can also be called directly if you know the exact type and relation: | Pattern | Function Name | |---------|---------------| | Check | `check_{type}_{relation}(subject_type, subject_id, object_id, visited)` | | List objects | `list_{type}_{relation}_objects(subject_type, subject_id, p_limit, p_after)` | | List subjects | `list_{type}_{relation}_subjects(object_id, subject_type, p_limit, p_after)` | For example, for a `document` type with a `viewer` relation: ```sql -- Direct specialized function call (slightly faster, bypasses dispatcher) SELECT check_document_viewer('user', '123', '456', ARRAY[]::TEXT[]); -- List objects using specialized function (with pagination) SELECT object_id, next_cursor FROM list_document_viewer_objects('user', '123', 100, NULL); -- List subjects using specialized function (with pagination) SELECT subject_id, next_cursor FROM list_document_viewer_subjects('456', 'user', 100, NULL); ``` > **Note:** **Note**: Specialized functions include a `p_visited` parameter used for cycle detection in recursive patterns. When calling directly, pass `ARRAY[]::TEXT[]` for this parameter. ## Performance Considerations ### Use List Functions for Batch Operations Instead of calling `check_permission` for each object: ```sql -- Inefficient: N function calls SELECT d.* FROM documents d WHERE check_permission('user', '123', 'viewer', 'document', d.id::text) = 1; ``` Use list functions with a JOIN: ```sql -- Efficient: 1 function call + JOIN SELECT d.* FROM documents d JOIN list_accessible_objects('user', '123', 'viewer', 'document', NULL, NULL) a ON d.id::text = a.object_id; ``` ### Use Pagination for Large Result Sets For potentially large result sets, use pagination to avoid loading unbounded data: ```sql -- Good: Controlled page size SELECT object_id, next_cursor FROM list_accessible_objects('user', '123', 'viewer', 'document', 100, NULL); -- Use with caution: Returns ALL results SELECT object_id FROM list_accessible_objects('user', '123', 'viewer', 'document', NULL, NULL); ``` ### Transaction Consistency All functions are marked `STABLE`, meaning they see a consistent snapshot of the database within a transaction. Permission checks within the same transaction will see uncommitted changes to the `melange_tuples` view. ```sql BEGIN; -- Insert new tuple (via domain table that feeds the view) INSERT INTO team_members (user_id, team_id, role) VALUES ('123', '456', 'member'); -- Permission check sees the uncommitted row SELECT check_permission('user', '123', 'member', 'team', '456'); -- Returns 1 ROLLBACK; -- Now returns 0 ``` ### Index Recommendations See [Tuples View](https://melange.sh/docs/concepts/tuples-view.md#performance-optimization) for indexing strategies that optimize the SQL functions. --- # Go API Source: https://melange.sh/docs/reference/go-api/ > Reference for Melange's Go runtime package: Checker, Object, cache, list, expand, and explain APIs. Stdlib only. Complete reference for the `github.com/pthm/melange/melange` Go runtime package. This package has zero external dependencies (stdlib only). ## Core Types ### ObjectType ```go type ObjectType string ``` String alias for type names (e.g., `"user"`, `"repository"`). ### Object ```go type Object struct { Type ObjectType ID string } ``` Represents a typed resource. Implements both `ObjectLike` and `SubjectLike`, so it can be used on either side of a permission check. - `String() string` returns `"type:id"` - `FGAObject() Object` implements `ObjectLike` - `FGASubject() Object` implements `SubjectLike` ### Relation ```go type Relation string ``` String alias for relation names (e.g., `"owner"`, `"can_read"`). - `String() string` returns the relation name - `FGARelation() Relation` implements `RelationLike` ### ContextualTuple ```go type ContextualTuple struct { Subject Object Relation Relation Object Object } ``` A temporary tuple injected at check time. Not persisted. Requires a `*sql.Tx` or `*sql.Conn` as the Querier (not `*sql.DB`). ### PageOptions ```go type PageOptions struct { Limit int After *string } ``` Controls pagination for list operations. `Limit` of 0 or negative means no limit. `After` is a cursor from a previous page. ## Interfaces ### ObjectLike ```go type ObjectLike interface { FGAObject() Object } ``` Implement this on your domain types so they can be passed directly to `Check`, `ListObjects`, etc. ### SubjectLike ```go type SubjectLike interface { FGASubject() Object } ``` Same as `ObjectLike` but for the subject side of a permission check. ### RelationLike ```go type RelationLike interface { FGARelation() Relation } ``` Implement on your relation types. The generated client code produces constants that implement this. ### Querier ```go type Querier interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) } ``` Satisfied by `*sql.DB`, `*sql.Tx`, and `*sql.Conn`. | Type | Contextual Tuples | Sees Uncommitted Data | |------|-------------------|----------------------| | `*sql.DB` | No (returns `ErrContextualTuplesUnsupported`) | No | | `*sql.Tx` | Yes | Yes | | `*sql.Conn` | Yes | No | ### Execer ```go type Execer interface { Querier ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } ``` Extends `Querier` with write capability. Required internally for contextual tuple injection. ### Cache ```go type Cache interface { Get(subject Object, relation Relation, object Object) (allowed bool, err error, ok bool) Set(subject Object, relation Relation, object Object, allowed bool, err error) } ``` Implement this interface for custom cache backends (e.g., Redis). The built-in `CacheImpl` satisfies this interface. ### ExplainCache ```go type ExplainCache interface { GetExplain(subject Object, relation Relation, object Object, maxNodes int) (trace *Trace, err error, ok bool) SetExplain(subject Object, relation Relation, object Object, maxNodes int, trace *Trace, err error) } ``` Opt-in extension for caching Explain traces. `Checker.Explain` type-asserts the injected `Cache` to `ExplainCache` and, on success, consults / populates the Explain cache before the DB call. Cache implementations that only implement `Cache` fall through to the DB on every Explain call — no breakage. `maxNodes` is part of the key. Different `WithExplainMaxNodes` values produce different traces (truncation flips), so they get distinct entries. ### ExpandCache ```go type ExpandCache interface { GetExpand(object Object, relation Relation, subjectType ObjectType, maxLeaf int) (tree *UsersetTree, err error, ok bool) SetExpand(object Object, relation Relation, subjectType ObjectType, maxLeaf int, tree *UsersetTree, err error) } ``` Opt-in extension for caching Expand trees. Same contract as `ExplainCache`. `subjectType` (the `WithSubjectTypeFilter` value) and `maxLeaf` (`WithExpandMaxLeaf`) are both part of the key because each changes the emitted tree. The default `CacheImpl` implements `Cache`, `ExplainCache`, and `ExpandCache`. Custom cache backends implement only the interfaces they support. ### Validator ```go type Validator interface { ValidateUsersetSubject(subject Object) error ValidateCheckRequest(subject Object, relation Relation, object Object) error ValidateListUsersRequest(relation Relation, object Object, subjectType ObjectType) error ValidateContextualTuple(tuple ContextualTuple) error } ``` Schema-aware validation. The generated client code can provide a validator; use `WithValidator()` to supply it. ## Checker ### Creating a Checker ```go func NewChecker(q Querier, opts ...Option) *Checker ``` Options: | Option | Description | |--------|-------------| | `WithCache(c Cache)` | Enable caching for permission checks | | `WithDecision(d Decision)` | Set a static decision override (Allow or Deny) | | `WithContextDecision()` | Enable context-based decision overrides | | `WithUsersetValidation()` | Validate userset subjects before checking | | `WithRequestValidation()` | Validate all check requests before executing | | `WithValidator(v Validator)` | Supply a schema-aware validator | | `WithDatabaseSchema(s string)` | Set the PostgreSQL schema where melange objects live (see [Custom Database Schema](https://melange.sh/docs/reference/configuration.md#custom-database-schema)) | ### Permission Checks ```go func (c *Checker) Check(ctx context.Context, subject SubjectLike, relation RelationLike, object ObjectLike) (bool, error) ``` Returns `true` if the subject has the relation on the object. ```go func (c *Checker) CheckWithContextualTuples(ctx context.Context, subject SubjectLike, relation RelationLike, object ObjectLike, tuples []ContextualTuple) (bool, error) ``` Same as `Check` but with temporary tuples injected for this call only. Requires `*sql.Tx` or `*sql.Conn`. ```go func (c *Checker) Must(ctx context.Context, subject SubjectLike, relation RelationLike, object ObjectLike) ``` Panics if the check is denied or returns an error. Use for internal invariants, not request handling. ### List Objects ```go func (c *Checker) ListObjects(ctx context.Context, subject SubjectLike, relation RelationLike, objectType ObjectType, page PageOptions) (ids []string, nextCursor *string, err error) func (c *Checker) ListObjectsAll(ctx context.Context, subject SubjectLike, relation RelationLike, objectType ObjectType) ([]string, error) func (c *Checker) ListObjectsWithContextualTuples(ctx context.Context, subject SubjectLike, relation RelationLike, objectType ObjectType, tuples []ContextualTuple, page PageOptions) (ids []string, nextCursor *string, err error) ``` `ListObjects` returns object IDs of the given type that the subject can access. `ListObjectsAll` auto-paginates to return all results. ### List Subjects ```go func (c *Checker) ListSubjects(ctx context.Context, object ObjectLike, relation RelationLike, subjectType ObjectType, page PageOptions) (ids []string, nextCursor *string, err error) func (c *Checker) ListSubjectsAll(ctx context.Context, object ObjectLike, relation RelationLike, subjectType ObjectType) ([]string, error) func (c *Checker) ListSubjectsWithContextualTuples(ctx context.Context, object ObjectLike, relation RelationLike, subjectType ObjectType, tuples []ContextualTuple, page PageOptions) (ids []string, nextCursor *string, err error) ``` `ListSubjects` returns subject IDs of the given type that have the relation on the object. `ListSubjectsAll` auto-paginates. ### Explain ```go func (c *Checker) Explain(ctx context.Context, subject SubjectLike, relation RelationLike, object ObjectLike, opts ...ExplainOption) (*Trace, error) ``` Returns the resolution tree for a check: every attempted branch, contributing tuples, per-branch success/failure. Use for debugging and admin tooling, not the request path — Explain builds a JSONB trace per call. ```go trace, err := checker.Explain(ctx, melange.Object{Type: "user", ID: "alice"}, melange.Relation("viewer"), melange.Object{Type: "document", ID: "1"}, ) if err != nil { return err } if *trace.Result { fmt.Println("allowed via", trace.Root.Type, "—", trace.Root.Label) } ``` #### Options ```go func WithExplainMaxNodes(n int) ExplainOption ``` Caps the node count in a single trace. Precedence: per-call > session GUC `melange.max_explain_nodes` > default (100). `n <= 0` is a no-op (defers to the GUC, then the default). When the cap is hit, `trace.Truncated` is `true`. #### Trace, Node, NodeType `Trace`, `Node`, `NodeType`, `TupleRef`, and `SubjectRef` model the JSONB the SQL dispatcher returns. JSON tags are snake_case to match the SQL column names. ```go type Trace struct { Object string Relation string Subject string Result *bool Root *Node Truncated bool NodeCount int } type Node struct { Type NodeType Label string Evidence []TupleRef Children []*Node Users []SubjectRef // NodeWildcard sentinel entry only Result *bool // per-branch outcome; nil on cycle/truncated nodes } ``` See the [Explaining Decisions guide](https://melange.sh/docs/guides/explaining-decisions.md) for the node-type table, truncation behaviour, and the supported schema patterns. ### Expand ```go func (c *Checker) Expand(ctx context.Context, object ObjectLike, relation RelationLike, opts ...ExpandOption) (*UsersetTree, error) ``` Returns the OpenFGA-shaped `UsersetTree` for `(object, relation)`. Resolution is shallow: computed rewrites surface as `Leaf.Computed` pointers and TTUs as `Leaf.TupleToUserset` pointers. The caller chases pointers with follow-up Expand calls, or uses `ExpandRecursive` for the walker. ```go tree, err := checker.Expand(ctx, melange.Object{Type: "document", ID: "1"}, melange.Relation("viewer"), ) if err != nil { return err } // FlattenUsers collects every Leaf.Users entry in the returned tree // (concrete users, inlined userset refs, wildcards) without chasing // pointers. for _, u := range tree.FlattenUsers() { fmt.Println(u) } ``` ```go func (c *Checker) ExpandRecursive(ctx context.Context, object ObjectLike, relation RelationLike, opts ...ExpandOption) ([]string, error) ``` Walks `Leaf.Computed` and `Leaf.TupleToUserset` pointers with additional Expand calls and returns the flat, deduplicated user-string list. Cycle-safe (every `(object, relation)` pair is expanded at most once per call). Wildcards and userset references survive as their string forms (`"user:*"`, `"group:eng#member"`); the walker does not chase userset refs because OpenFGA models them as inline subjects, not pointers. Cost is N round-trips for N distinct pointers. Suitable for admin / debugging flows, not the request path — for the request path use `ListObjects` or `Check`. #### Options ```go func WithSubjectTypeFilter(subjectType ObjectType) ExpandOption ``` Narrows `Leaf.Users` entries to a single subject type. `""` (the default) matches all types. Melange extension — OpenFGA Expand has no equivalent. ```go func WithExpandMaxLeaf(n int) ExpandOption ``` Caps each `Leaf.Users` list to `n` entries. `n <= 0` defers to session GUC `melange.max_expand_leaf`, then to unbounded (matching OpenFGA). When the cap fires, the emitted `Users.UsersTruncated` field is `true`. Melange extension. #### UsersetTree, UsersetTreeNode, Leaf, Difference, Nodes Field-for-field mirror of `openfgav1.UsersetTree` so existing OpenFGA tooling deserialises the JSON without adapters. ```go type UsersetTree struct { Root *UsersetTreeNode } type UsersetTreeNode struct { Name string // "document:1#viewer" Leaf *Leaf Difference *Difference // named base / subtract slots Union *Nodes Intersection *Nodes } type Leaf struct { Users *Users Computed *Computed // {Userset: "document:1#editor"} TupleToUserset *TupleToUserset // {Tupleset: ..., Computed: [...]} } type Users struct { Users []string // "user:alice", "group:eng#member", "user:*" UsersTruncated bool // Melange extension; set when p_max_leaf capped the list } type Difference struct { Base *UsersetTreeNode Subtract *UsersetTreeNode } type Nodes struct { Nodes []*UsersetTreeNode } ``` `FlattenUsers` collects every `Leaf.Users` entry in the tree into a deduplicated, sorted slice. Difference nodes are walked into `Base` only (the subtract side names users to exclude). See the [Expanding Permissions guide](https://melange.sh/docs/guides/expanding-permissions.md) for the tree structure, shallow-by-default semantics, and CLI usage. ## Bulk Check ### Building a Bulk Check ```go func (c *Checker) NewBulkCheck(ctx context.Context) *BulkCheckBuilder ``` ```go func (b *BulkCheckBuilder) Add(subject SubjectLike, relation RelationLike, object ObjectLike) *BulkCheckBuilder func (b *BulkCheckBuilder) AddWithID(id string, subject SubjectLike, relation RelationLike, object ObjectLike) *BulkCheckBuilder func (b *BulkCheckBuilder) AddMany(subject SubjectLike, relation RelationLike, objects ...ObjectLike) *BulkCheckBuilder func (b *BulkCheckBuilder) WithContextualTuples(tuples ...ContextualTuple) *BulkCheckBuilder func (b *BulkCheckBuilder) Execute() (*BulkCheckResults, error) ``` `Add` assigns an auto-generated ID (the index as a string). `AddWithID` lets you supply your own ID (panics on duplicate or empty ID). `AddMany` adds multiple objects for one subject+relation pair. All checks execute in a single SQL call. ```go const MaxBulkCheckSize = 10000 ``` Maximum number of checks per bulk operation. ### Reading Results ```go func (r *BulkCheckResults) Len() int func (r *BulkCheckResults) Get(index int) *BulkCheckResult func (r *BulkCheckResults) GetByID(id string) *BulkCheckResult func (r *BulkCheckResults) All() bool func (r *BulkCheckResults) Any() bool func (r *BulkCheckResults) None() bool func (r *BulkCheckResults) Results() []*BulkCheckResult func (r *BulkCheckResults) Allowed() []*BulkCheckResult func (r *BulkCheckResults) Denied() []*BulkCheckResult func (r *BulkCheckResults) Errors() []error func (r *BulkCheckResults) AllOrError() error ``` `AllOrError` returns `nil` if every check was allowed, or a `*BulkCheckDeniedError` wrapping `ErrBulkCheckDenied`. ```go func (r *BulkCheckResult) ID() string func (r *BulkCheckResult) Index() int func (r *BulkCheckResult) Subject() Object func (r *BulkCheckResult) Relation() Relation func (r *BulkCheckResult) Object() Object func (r *BulkCheckResult) IsAllowed() bool func (r *BulkCheckResult) Err() error ``` ### Example ```go results, err := checker.NewBulkCheck(ctx). Add(user, "can_read", repo1). Add(user, "can_write", repo1). Add(user, "can_delete", repo1). Execute() if results.All() { // Full access } for _, r := range results.Denied() { log.Printf("denied: %s on %s", r.Relation(), r.Object()) } ``` ## Cache ```go func NewCache(opts ...CacheOption) *CacheImpl func WithTTL(ttl time.Duration) CacheOption ``` ```go func (c *CacheImpl) Get(subject Object, relation Relation, object Object) (bool, error, bool) func (c *CacheImpl) Set(subject Object, relation Relation, object Object, allowed bool, err error) func (c *CacheImpl) GetExplain(subject Object, relation Relation, object Object, maxNodes int) (*Trace, error, bool) func (c *CacheImpl) SetExplain(subject Object, relation Relation, object Object, maxNodes int, trace *Trace, err error) func (c *CacheImpl) GetExpand(object Object, relation Relation, subjectType ObjectType, maxLeaf int) (*UsersetTree, error, bool) func (c *CacheImpl) SetExpand(object Object, relation Relation, subjectType ObjectType, maxLeaf int, tree *UsersetTree, err error) func (c *CacheImpl) Size() int func (c *CacheImpl) Clear() ``` The built-in cache is an in-memory map, thread-safe, unbounded within the TTL window. Entries expire individually based on their insertion time. `Size` returns the total count across Check, Explain, and Expand families; `Clear` resets all three. `CacheImpl` implements `Cache`, `ExplainCache`, and `ExpandCache` — one `WithCache` call activates caching for every API. ```go cache := melange.NewCache(melange.WithTTL(5 * time.Minute)) checker := melange.NewChecker(db, melange.WithCache(cache)) ``` ## Decision Overrides ```go const ( DecisionUnset Decision = iota // No override, perform normal check DecisionAllow // Always allow DecisionDeny // Always deny ) ``` **Checker-level override** (applies to all checks): ```go checker := melange.NewChecker(db, melange.WithDecision(melange.DecisionAllow)) ``` **Context-level override** (per-request): ```go checker := melange.NewChecker(db, melange.WithContextDecision()) ctx := melange.WithDecisionContext(ctx, melange.DecisionAllow) allowed, _ := checker.Check(ctx, subject, relation, object) // always true ``` ```go func WithDecisionContext(ctx context.Context, decision Decision) context.Context func GetDecisionContext(ctx context.Context) Decision ``` ## Errors See [Errors Reference](https://melange.sh/docs/reference/errors.md) for the full error type and code reference. ## Next Steps - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md): usage patterns, caching, transactions - [Errors Reference](https://melange.sh/docs/reference/errors.md): sentinel errors, validation codes, error helpers - [SQL API](https://melange.sh/docs/reference/sql-api.md): calling permission functions directly from SQL - [Generated Code](https://melange.sh/docs/reference/generated-code.md): what `melange generate client` produces --- # TypeScript API Source: https://melange.sh/docs/reference/typescript-api/ > Reference for the @pthm/melange TypeScript runtime client, mirroring the Go API with TypeScript idioms. Reference for the `@pthm/melange` TypeScript runtime client. Mirrors the [Go API](https://melange.sh/docs/reference/go-api.md) with TypeScript idioms. Ships with the type-safe generated client from `melange generate client --runtime typescript`. ## Checker ```typescript import { Checker } from '@pthm/melange'; import { Pool } from 'pg'; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const checker = new Checker(pool); ``` ### Permission Checks ```typescript class Checker { check(subject: MelangeObject, relation: Relation, object: MelangeObject): Promise checkWithContextualTuples(subject: MelangeObject, relation: Relation, object: MelangeObject, tuples: ContextualTuple[]): Promise } ``` `check` returns `true` if the subject has the relation on the object. `checkWithContextualTuples` mirrors the Go equivalent — temporary tuples for the call only; requires a client that supports transactions. ### List Operations ```typescript class Checker { listObjects(subject: MelangeObject, relation: Relation, objectType: ObjectType, page?: PageOptions): Promise<{ ids: string[]; nextCursor: string | null }> listSubjects(object: MelangeObject, relation: Relation, subjectType: ObjectType, page?: PageOptions): Promise<{ ids: string[]; nextCursor: string | null }> } ``` Cursor-paginated. Pass `page.after` (the previous page's `nextCursor`) to continue. ### Explain ```typescript class Checker { explain(subject: MelangeObject, relation: Relation, object: MelangeObject, options?: ExplainOptions): Promise } interface ExplainOptions { maxNodes?: number; // 0 or absent defers to session GUC / built-in default } ``` Returns the resolution tree for a check. See the [Explaining Decisions guide](https://melange.sh/docs/guides/explaining-decisions.md) for the trace structure. ```typescript const trace = await checker.explain( { type: 'user', id: 'alice' }, 'viewer', { type: 'document', id: '1' }, ); if (trace.result === true) { console.log('allowed via', trace.root.type); } ``` `Trace`, `Node`, `NodeType`, `TupleRef`, and `SubjectRef` type mirrors live in `@pthm/melange` (source: `clients/typescript/src/trace.ts`). JSON tags are snake_case to match the SQL columns. ### Expand ```typescript class Checker { expand(object: MelangeObject, relation: Relation, options?: ExpandOptions): Promise expandRecursive(object: MelangeObject, relation: Relation, options?: ExpandOptions): Promise } interface ExpandOptions { subjectType?: ObjectType; // Melange extension: narrow Leaf.Users to one type maxLeaf?: number; // Melange extension: cap Leaf.Users; 0 = unbounded (matches OpenFGA) } function flattenUsers(tree: UsersetTree): string[] ``` `expand` returns the OpenFGA-shaped `UsersetTree`, shallow by default (computed rewrites surface as `Leaf.Computed` pointers, TTUs as `Leaf.TupleToUserset` pointers). `expandRecursive` walks the pointer chains and returns the flat, deduplicated user list. `flattenUsers` collects every `Leaf.Users` entry from an already-returned tree without issuing additional queries. See the [Expanding Permissions guide](https://melange.sh/docs/guides/expanding-permissions.md) for the tree structure and worked examples. ```typescript const tree = await checker.expand( { type: 'document', id: '1' }, 'viewer', ); const users = flattenUsers(tree); // users = ['user:alice', 'user:bob', 'group:eng#member', 'user:*'] ``` `UsersetTree`, `UsersetTreeNode`, `Leaf`, `Users`, `Computed`, `TupleToUserset`, `Difference`, and `Nodes` type mirrors live in `@pthm/melange` (source: `clients/typescript/src/expand.ts`). Field names match OpenFGA's `openfgav1.UsersetTree` proto so existing OpenFGA tooling deserialises the JSON without adapters. ## Caching ```typescript import { Checker, MemoryCache } from '@pthm/melange'; const cache = new MemoryCache({ ttlMs: 300_000 }); const checker = new Checker(pool, { cache }); ``` `MemoryCache` is the in-memory implementation. See [Caching](https://melange.sh/docs/guides/caching.md) for the interface and custom backend guidance. ## Generated Code `melange generate client --runtime typescript` produces type-safe constants and factory functions. See [Generated Code](https://melange.sh/docs/reference/generated-code.md) for details. ## Calling SQL Directly For flows outside the client (background jobs, scripts, ad-hoc queries), call the generated SQL functions from any PostgreSQL client: ```typescript import { Pool } from 'pg'; import type { Trace } from '@pthm/melange'; const { rows } = await pool.query<{ explain_permission: Trace }>( 'SELECT explain_permission($1, $2, $3, $4, $5)', ['user', 'alice', 'viewer', 'document', '1'] ); const trace = rows[0].explain_permission; ``` See the [SQL API Reference](https://melange.sh/docs/reference/sql-api.md) for all functions. ## Next Steps - [Explaining Decisions](https://melange.sh/docs/guides/explaining-decisions.md): the `explain` API and trace structure - [Expanding Permissions](https://melange.sh/docs/guides/expanding-permissions.md): the `expand` and `expandRecursive` APIs - [Caching](https://melange.sh/docs/guides/caching.md): opt-in caching for Check, Explain, and Expand - [Go API](https://melange.sh/docs/reference/go-api.md): the Go runtime API — the TypeScript client mirrors its shape - [Generated Code](https://melange.sh/docs/reference/generated-code.md): TypeScript code generation output --- # Generated Code Source: https://melange.sh/docs/reference/generated-code/ > What melange generate client produces for Go and TypeScript: type-safe constants and constructors from your schema. `melange generate client` reads your `.fga` schema and produces type-safe constants and constructors. This page documents the output for each runtime. ## Go Generates a single file: `schema_gen.go`. ### Object Type Constants Each type in your schema produces a constant prefixed with `Type`: ```go const ( TypeUser melange.ObjectType = "user" TypeOrganization melange.ObjectType = "organization" TypeRepository melange.ObjectType = "repository" ) ``` Naming: snake_case type names are converted to PascalCase. `pull_request` becomes `TypePullRequest`. ### Relation Constants Each relation produces a constant prefixed with `Rel`: ```go const ( RelOwner melange.Relation = "owner" RelAdmin melange.Relation = "admin" RelCanRead melange.Relation = "can_read" RelCanWrite melange.Relation = "can_write" ) ``` When `--filter` is set (e.g., `--filter can_`), only relations matching the prefix are generated. ### Constructor Functions Each type gets a constructor and a wildcard constructor: ```go func User(id string) melange.Object { return melange.Object{Type: TypeUser, ID: id} } func AnyUser() melange.Object { return melange.Object{Type: TypeUser, ID: "*"} } ``` The returned `melange.Object` implements both `ObjectLike` and `SubjectLike`, so constructors work on either side of a permission check. ### ID Type The `--id-type` flag controls the constructor parameter type. **`--id-type string`** (default): ```go func Repository(id string) melange.Object { return melange.Object{Type: TypeRepository, ID: id} } ``` **`--id-type int64`**: ```go func Repository(id int64) melange.Object { return melange.Object{Type: TypeRepository, ID: fmt.Sprint(id)} } ``` **`--id-type uuid.UUID`**: ```go func Repository(id uuid.UUID) melange.Object { return melange.Object{Type: TypeRepository, ID: fmt.Sprint(id)} } ``` When the ID type is not `string`, the package imports `fmt` for the conversion. ### Usage ```go import "myapp/internal/authz" allowed, err := checker.Check(ctx, authz.User("alice"), authz.RelCanRead, authz.Repository("42"), ) ``` ## TypeScript Generates three files: `types.ts`, `schema.ts`, `index.ts`. ### types.ts Constants and union types for object types and relations: ```typescript export const ObjectTypes = { User: "user", Organization: "organization", Repository: "repository", } as const; export type ObjectType = (typeof ObjectTypes)[keyof typeof ObjectTypes]; export const Relations = { Owner: "owner", Admin: "admin", CanRead: "can_read", CanWrite: "can_write", } as const; export type Relation = (typeof Relations)[keyof typeof Relations]; ``` ### schema.ts Factory functions for each type: ```typescript import type { MelangeObject } from '@pthm/melange'; import { ObjectTypes } from './types.js'; export function user(id: string): MelangeObject { return { type: ObjectTypes.User, id }; } export function anyUser(): MelangeObject { return { type: ObjectTypes.User, id: '*' }; } export function repository(id: string): MelangeObject { return { type: ObjectTypes.Repository, id }; } export function anyRepository(): MelangeObject { return { type: ObjectTypes.Repository, id: '*' }; } ``` Naming: type names become camelCase for functions (`pull_request` becomes `pullRequest`), PascalCase for constants. ### index.ts Re-exports for a clean import surface: ```typescript export { ObjectTypes, Relations } from './types.js'; export type { ObjectType, Relation } from './types.js'; export * from './schema.js'; ``` ### Usage ```typescript import { user, repository, Relations } from './authz'; const allowed = await checkPermission( user('alice'), Relations.CanRead, repository('42'), ); ``` ### TypeScript Notes - The `--id-type` flag is ignored. IDs are always `string`. - The `--package` flag is ignored. TypeScript uses ES module exports. - The `--filter` flag works the same as Go (prefix match on relation names). ## Regeneration Run `melange generate client` after any schema change to keep the generated code in sync. The generated files include a header comment with the Melange version and source schema path for traceability. ## Configuration All options can be set in `melange.yaml` under `generate.client`: ```yaml generate: client: runtime: go output: internal/authz package: authz filter: can_ id_type: string ``` See [Configuration](https://melange.sh/docs/reference/configuration.md) for the full reference. ## Next Steps - [Go API](https://melange.sh/docs/reference/go-api.md): the runtime types that generated code builds on - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md): using generated code in permission checks - [CLI Reference](https://melange.sh/docs/reference/cli.md): `generate client` command flags --- # Deployed Model Source: https://melange.sh/docs/reference/deployed-model/ > How Melange records each applied authorization model in melange_migrations so the database describes itself. Every `melange migrate` records the authorization model it applied into the `melange_migrations` table, so the database is **self-describing**: it knows which schema is deployed without a separate state file to keep in sync. This is the foundation for drift detection, safe deploys, and recovering a schema you have lost. ## What is stored Each migration record carries, alongside the function inventory used for orphan cleanup: | Column | Contents | | ----------------- | -------------------------------------------------------------------- | | `schema_dsl` | The exact OpenFGA DSL that was applied (a `.fga` file, or an `fga.mod` bundle) | | `model_json` | The parsed model — the same structures melange compiles to SQL | | `schema_format` | `single` (a `.fga` file) or `modular` (an `fga.mod` manifest) | | `schema_checksum` | A SHA-256 of the schema, used for fast change detection | | `melange_version` | The melange version that applied the migration | `schema_dsl` and `model_json` are two independent representations of the same model. Tools prefer `model_json` (already parsed) and fall back to parsing `schema_dsl` when it is absent, so a record stays usable even if one is missing. ## What reads it The deployed model is the single source of truth behind a family of commands (see the [CLI reference](https://melange.sh/docs/reference/cli.md) for each): | Command | Uses the deployed model to… | | -------------------------------- | ------------------------------------------------------------------ | | `status` | Report whether the local schema is in sync with the database | | `schema pull` | Reconstruct and print the deployed `.fga` for recovery or review | | `diff` | Classify local changes additive/breaking against what is deployed | | `history` | List the migration audit trail (versions, checksums, timestamps) | | `doctor` | Check the model is recorded and warn on a pending breaking change | | `migrate --if-deployed-checksum` | Apply only if the database is still at the expected checksum | ## Why store the model - **No drift.** The database describes itself, so there is no external state file that can disagree with reality. - **Safe deploys.** `diff --exit-code` fails CI on a breaking change, and `migrate --if-deployed-checksum` refuses to apply against a database that has drifted since you planned the change. - **Recovery.** `schema pull` reconstructs the deployed schema straight from the database when the source `.fga` has been lost. ## Compatibility Databases migrated before model storage existed have no recorded model. The commands above detect that and report it plainly — `schema pull`, for example, distinguishes "never migrated" from "migrated before the model was recorded". Re-running `melange migrate` after upgrading backfills the model without re-applying the generated functions, so a plain rerun makes these commands work, with no `--force` needed. A **modular** (`fga.mod`) schema is stored as its manifest plus module bundle for reference and recovery. That combined form does not re-parse as a single `.fga`, so `schema pull` labels it as a non-parseable bundle rather than emitting a schema you can feed straight back into `melange migrate`. --- # Errors Source: https://melange.sh/docs/reference/errors/ > Error types, sentinel values, and helper functions in Melange's Go runtime package. All error types, sentinel values, and helper functions in the `github.com/pthm/melange/melange` package. ## Sentinel Errors | Error | Meaning | Common Cause | |-------|---------|--------------| | `ErrNoTuplesTable` | `melange_tuples` view or table not found | View not created, wrong search path | | `ErrInvalidSchema` | Schema parsing failed | Syntax error in `.fga` file | | `ErrMissingFunction` | Required PostgreSQL function missing | Migration not run, or Melange updated without re-migrating | | `ErrCyclicSchema` | Cycle detected in relation graph | Circular implied-by chain | | `ErrBulkCheckDenied` | At least one bulk check was denied | Returned by `BulkCheckResults.AllOrError()` | | `ErrContextualTuplesUnsupported` | Querier does not support contextual tuples | Using `*sql.DB` instead of `*sql.Tx` or `*sql.Conn` | | `ErrInvalidContextualTuple` | Contextual tuple failed validation | Malformed or schema-invalid tuple | All sentinel errors work with `errors.Is`: ```go allowed, err := checker.Check(ctx, subject, relation, object) if melange.IsNoTuplesTableErr(err) { // melange_tuples view is missing } ``` ## Error Helper Functions | Function | Checks for | |----------|------------| | `IsNoTuplesTableErr(err error) bool` | `ErrNoTuplesTable` | | `IsInvalidSchemaErr(err error) bool` | `ErrInvalidSchema` | | `IsMissingFunctionErr(err error) bool` | `ErrMissingFunction` | | `IsCyclicSchemaErr(err error) bool` | `ErrCyclicSchema` | | `IsBulkCheckDeniedErr(err error) bool` | `ErrBulkCheckDenied` | | `IsValidationError(err error) bool` | `ValidationError` | | `GetValidationErrorCode(err error) int` | Returns code from `ValidationError`, or 0 | ## ValidationError ```go type ValidationError struct { Code int Message string } ``` Methods: - `Error() string` implements the `error` interface - `ErrorCode() int` returns the OpenFGA-compatible error code ### Error Codes | Code | Constant | Meaning | |------|----------|---------| | 2000 | `ErrorCodeValidation` | Invalid request (bad input) | | 2001 | `ErrorCodeAuthorizationModelNotFound` | Authorization model not found | | 2002 | `ErrorCodeResolutionTooComplex` | Resolution depth or complexity limit exceeded | ```go allowed, err := checker.Check(ctx, subject, relation, object) if melange.IsValidationError(err) { code := melange.GetValidationErrorCode(err) switch code { case melange.ErrorCodeValidation: // bad input case melange.ErrorCodeResolutionTooComplex: // depth limit hit } } ``` ## BulkCheckDeniedError ```go type BulkCheckDeniedError struct { Subject Object Relation Relation Object Object Index int // Position in original request order Total int // Total denied checks in batch } ``` Methods: - `Error() string` implements the `error` interface - `Unwrap() error` returns `ErrBulkCheckDenied` Returned by `BulkCheckResults.AllOrError()` when at least one check is denied: ```go err := results.AllOrError() if err != nil { var denied *melange.BulkCheckDeniedError if errors.As(err, &denied) { log.Printf("denied: %s %s %s (and %d others)", denied.Subject, denied.Relation, denied.Object, denied.Total-1) } } ``` ## PostgreSQL Error Mapping The runtime maps PostgreSQL error codes to sentinel errors: | SQLSTATE | PostgreSQL Error | Mapped To | |----------|-----------------|-----------| | `42P01` | Undefined table | `ErrNoTuplesTable` | | `42883` | Undefined function | `ErrMissingFunction` | | `M2002` | Custom (raised by generated functions) | `ValidationError` with code 2002 | ## Next Steps - [Go API](https://melange.sh/docs/reference/go-api.md): full runtime API reference - [CLI Reference](https://melange.sh/docs/reference/cli.md): exit codes and command errors - [Checking Permissions](https://melange.sh/docs/guides/checking-permissions.md): error handling patterns in context --- # OpenFGA Compatibility Source: https://melange.sh/docs/reference/openfga-compatibility/ > Which OpenFGA features Melange supports, how it is tested against the OpenFGA suite, and how to move between the two. Melange supports **every OpenFGA Schema 1.1 feature except conditions**, running entirely in PostgreSQL. It is tested against the official OpenFGA test suite. Schemas that use conditions are rejected at compile time. ## Test Suite Compliance Melange is validated against the **official OpenFGA compatibility test suite**. This ensures that authorization behavior matches the OpenFGA specification for all supported features. The test suite covers: - Direct assignments and userset references - Union, intersection, and exclusion operators - Tuple-to-userset (parent inheritance) - Wildcards and contextual tuples - Complex nested permission patterns All tests for supported features pass. ## Feature Support | Feature | Melange | Level | Notes | | ---------------------------------------- | ------- | ------------ | ------------------------------------------- | | **Direct assignment** `[user]` | ✅ | **Full** | Subjects explicitly granted via tuples | | **Userset references** `[type#relation]` | ✅ | **Full** | Full runtime evaluation | | **Wildcards** `[user:*]` | ✅ | **Full** | Public access, `subject_id = '*'` in tuples | | **Union (OR)** | ✅ | **Full** | Any rule matches | | **Intersection (AND)** | ✅ | **Full** | All rules must match | | **Exclusion (BUT NOT)** | ✅ | **Full** | Recursive with parent inheritance | | **Computed relations** | ✅ | **Full** | `implied_by` with transitive closure | | **Tuple-to-userset (FROM)** | ✅ | **Full** | Parent inheritance | | **Contextual tuples** | ✅ | **Full** | Temporary tuples passed with check request | | **Grouping `()`** | ✅ | **Full** | Complex nested expressions | | **Schema 1.0** | ⚠️ | **Untested** | Parser potentially works, but untested | | **Schema 1.1** | ✅ | **Full** | All features except conditions | | **Conditions** | ❌ | **None** | CEL expressions not supported | | **Modular models** | ✅ | **Full** | `fga.mod` manifests, `module`, `extend type`| | **Schema 1.2** | ✅ | **Full** | Modules supported | ## Supported Features ### Direct Assignment Subjects can be directly assigned to relations: ```fga type document relations define owner: [user] define editor: [user, team] ``` ### Union (OR) Multiple conditions combined with OR: ```fga define admin: [user] or owner define reader: [user] or writer or can_read from org ``` ### Tuple-to-Userset (FROM) Inherit permissions from parent objects: ```fga type repository relations define org: [organization] define can_read: can_read from org ``` ### Role Hierarchy (Implied-By) Transitive closure is computed at schema load time: ```fga define owner: [user] define admin: [user] or owner # owner implies admin define member: [user] or admin # owner and admin imply member ``` ### Exclusion (BUT NOT) Deny permission based on another relation: ```fga define can_review: can_read from repo but not author ``` ### Wildcards Public access using wildcard subjects: ```fga define public: [user:*] define can_read: public or member ``` ### Intersection (AND) Require multiple conditions to all be satisfied: ```fga define viewer: editor and can_read from org define can_delete: owner and active ``` ### Contextual Tuples Pass temporary tuples with a check request without storing them in the database. Useful for evaluating hypothetical permissions or time-limited access: ```go // Check with contextual tuples allowed, err := checker.CheckWithContextualTuples(ctx, user, "can_read", doc, []melange.ContextualTuple{ {Subject: user, Relation: "temp_access", Object: doc}, }) ``` ### Modular Models (Schema 1.2) Split authorization schemas across multiple files using `fga.mod` manifests: ```yaml # fga.mod schema: '1.2' contents: - core.fga - issue-tracker/projects.fga - wiki/spaces.fga ``` Each file declares a module and can extend types from other modules: ```fga module wiki extend type organization relations define can_create_space: admin or member type space relations define organization: [organization] define viewer: member from organization ``` Pass the manifest path instead of a single `.fga` file: ```go err := migrator.Migrate(ctx, db, "schemas/fga.mod") ``` At compile time, all modules are merged into a unified schema. The generated SQL is identical whether you use a single file or modular models. ## Unsupported Features ### Conditions CEL expressions for conditional authorization are not supported: ```fga # NOT SUPPORTED condition ip_allowed(user_ip: ipaddress) { user_ip.in_cidr("10.0.0.0/8") } define viewer: [user with ip_allowed] ``` Melange rejects these schemas rather than dropping the condition, since a dropped condition would silently widen access: ``` $ melange validate --schema schema.fga Error: parsing schema: melange: invalid schema: conditions are not supported and would be silently dropped: document#viewer allows [user with ip_allowed]. ``` Most conditions can be expressed another way: a `WHERE` clause in the tuples view for conditions on stored data (expiry, status), and contextual tuples or session settings for conditions on request data (client IP, time). See [Replacing Conditions](https://melange.sh/docs/guides/conditions.md). ### Excluding a Parent Relation Inside a Compound Expression Melange rewrites each relation into a union of intersections before compiling it. A `but not` whose subtract is a tuple-to-userset (`x from parent`) can be compiled at the top of a relation, but not when it ends up inside an intersection: ```fga # Supported define can_view: viewer but not blocked from parent # NOT SUPPORTED define can_view: (viewer but not blocked from parent) and active ``` Melange rejects these schemas rather than compiling them approximately. Move the sub-expression into its own named relation: ```fga define viewer_not_blocked: viewer but not blocked from parent define can_view: viewer_not_blocked and active ``` ## Moving from OpenFGA to Melange If you run OpenFGA today, most of your model carries over unchanged. 1. **Reuse your `.fga` files.** Schema 1.1 models and modular models (`fga.mod`) compile as-is. Check that no relation uses a [condition](#conditions) (see [Replacing Conditions](https://melange.sh/docs/guides/conditions.md)) or [excludes a parent relation inside a compound expression](#excluding-a-parent-relation-inside-a-compound-expression), both of which Melange rejects. 2. **Replace tuple writes with a view.** Instead of writing tuples to a store, define a `melange_tuples` view over the tables that already hold the relationships (memberships, ownership, parent links). See [Creating Your Tuples View](https://melange.sh/docs/getting-started/tuples-view.md). If some relationships only exist in OpenFGA, export them with `fga tuple read` into a table and include it in the view. 3. **Swap the client.** Replace OpenFGA SDK calls with the [Go Checker](https://melange.sh/docs/guides/checking-permissions.md), the [TypeScript client](https://melange.sh/docs/reference/typescript-api.md), or plain SQL (`check_permission`). Contextual tuples, `ListObjects`, and `ListUsers` equivalents are supported. What you gain: checks run in your database transaction, see uncommitted writes, and need no separate service or tuple sync. What you give up: CEL conditions (most can be [replaced with SQL](https://melange.sh/docs/guides/conditions.md)), and the option to scale authorization separately from PostgreSQL. To try it, start with [Installation](https://melange.sh/docs/getting-started/installation.md) and the [Quick Start](https://melange.sh/docs/getting-started/quick-start.md). ## Migration Path to OpenFGA Melange is designed to be a stepping stone. If you outgrow its capabilities, you can migrate to the full OpenFGA service. **Planned**: Support for the official OpenFGA client SDK is on the roadmap, which will make migration even simpler by allowing you to use the same client code with both Melange and OpenFGA. ### When to Consider Migrating - You need **CEL conditions** that cannot be expressed in the [tuples view or contextual tuples](https://melange.sh/docs/guides/conditions.md) - You need a **dedicated authorization service** for horizontal scaling - Your **tuple volume** exceeds what PostgreSQL can handle efficiently ### Migration Steps 1. **Schema compatibility**: Your `.fga` schema files work with both Melange and OpenFGA. 2. **Export tuples**: Query your `melange_tuples` view to export tuples: ```sql SELECT subject_type, subject_id, relation, object_type, object_id FROM melange_tuples; ``` 3. **Import to OpenFGA**: Use the OpenFGA API to write tuples: ```bash fga tuple write --store-id $STORE_ID \ user:alice owner repository:123 ``` 4. **Update application code**: Replace Melange Checker with OpenFGA SDK: ```go // Before (Melange) allowed, err := checker.Check(ctx, user, "can_read", repo) // After (OpenFGA) resp, err := client.Check(ctx).Body(openfga.CheckRequest{ TupleKey: openfga.TupleKey{ User: "user:alice", Relation: "can_read", Object: "repository:123", }, }).Execute() allowed := resp.GetAllowed() ``` 5. **Sync tuples ongoing**: Replace the `melange_tuples` view with tuple writes to OpenFGA on data changes. ### What Changes | Aspect | Melange | OpenFGA | | ---------------------- | ------------------------- | ------------------------- | | Deployment | Embedded in PostgreSQL | Separate service | | Tuple storage | View over existing tables | Dedicated tuple store | | Transaction visibility | Yes (uncommitted changes) | No (eventual consistency) | | Latency | Single database query | Network round-trip | | Scaling | PostgreSQL limits | Horizontal scaling | ### What Stays the Same - Schema files (`.fga`) are compatible - Relation names and semantics - Subject/object/relation model - Basic permission patterns ## Testing Compatibility Melange includes the official OpenFGA test suite to validate Schema 1.1 compliance. Run the full suite: ```bash # Run the complete OpenFGA compatibility test suite just test-openfga # Run specific feature categories just test-openfga-feature DirectAssignment just test-openfga-feature ComputedUserset just test-openfga-feature TupleToUserset just test-openfga-feature Intersection just test-openfga-feature Exclusion just test-openfga-feature Wildcards ``` The test suite validates behavior against the official OpenFGA specification, ensuring that permission checks return identical results to the OpenFGA server for all supported patterns. See [Contributing - Testing](https://melange.sh/docs/contributing/testing.md) for details on running the compatibility test suite. --- # Performance Source: https://melange.sh/docs/reference/performance/ > Measured Melange latency on managed PostgreSQL: what checks and list queries cost, what drives the cost, and how to plan for it. Use these measurements to choose an API and identify what to profile. They come from a same-zone application VM and Cloud SQL for PostgreSQL, measured in October 2026. They describe these fixtures and hardware; benchmark your own schema before setting a latency or capacity target. Detailed [measurements](#checks), [schemas](#benchmark-models), and [methodology](#methodology) follow the optimization advice. ## Summary | Operation | What to expect | What drives the cost | | --- | --- | --- | | Check | About 0.4 to 0.7 ms end to end, about 0.1 ms of it in Postgres | Mostly network and client overhead in the measured fixtures. | | Bulk check | 100 checks in about 6 ms, one round trip | Number of checks in the batch | | Cached check | About 120 ns | No database call | | ListObjects, ListSubjects | Under 1 ms to hundreds of ms | How much of the permission graph Postgres has to walk (see [List operations](#list-operations)) | | Contextual tuples | 7.4 to 10.1 ms total for 1 to 100 tuples | Creating and dropping a temporary view on each call | A check is one SQL statement. On a same-zone connection, the round trip costs more than the query: a bare `SELECT 1` took 0.34 ms, and the kitchen-sink `PlainUser` check at the 1M label took 0.420 ms. For similar check paths, your round-trip time plus roughly 0.1 ms is a useful starting estimate, not a latency guarantee. ## Choose an API and optimize | Your request needs… | Start with | Why | | --- | --- | --- | | One permission decision | `Check` | One statement; measured checks are mostly network and client time. | | Decisions for objects already loaded | `BulkCheck` over those IDs | At the largest hierarchy scale, 100 checks took 6.32 ms in bulk versus 52.3 ms sequentially. | | Repeated decisions with an acceptable staleness window | Permission cache | A warm hit measured 117 ns; it does not measure authorization after a permission change. | | IDs accessible to a user | `ListObjects` | Profile broad or recursive relations; a small page may still be expensive. | | Everyone who can access an object | `ListSubjects` | Measure both candidate expansion and result size; schedule broad audits outside hot request paths. | | Request-specific grants | Contextual tuples | Include temporary-view setup and cleanup in the budget: 7.4–10.1 ms for the measured checks. | 1. **Measure from your application.** Compare a bare `SELECT 1` with the authorization call on the same connection path. The 0.336 ms cloud reference is an observed baseline, not a hard lower bound. 2. **Batch checks before increasing concurrency.** Batching amortizes round trips. Tune pool size against database CPU and request latency; the measured four-worker throughput is not a capacity guarantee. 3. **Index the tuple sources.** Run `melange doctor`; inspect the tables behind your view, including casts. See [Indexing](#indexing). 4. **Profile expensive lists in each direction.** Record returned rows, candidates, buffers, joins, and recursive work. An empty answer can still require a large traversal. See [Schema shape](#schema-shape). 5. **Use pagination to bound payloads.** Verify whether the limit reduces database work. The measured first-page PR query takes roughly 0.26 seconds at limits from 10 through 500. 6. **Cache deliberately.** Choose an invalidation or TTL policy that fits your permission-change requirements. The single-check cache does not cache list results; see [Caching](https://melange.sh/docs/guides/caching.md). ## Indexing Index the tables behind the `melange_tuples` view, including expression indexes for any `::text` id casts. Missing indexes can cause scans; verify index usage in your plan before applying these measurements to your workload: ```sql -- Composite index covering the columns the view selects CREATE INDEX idx_org_members_lookup ON organization_members (organization_id, role, user_id); -- Expression index for text id conversion CREATE INDEX idx_org_members_text ON organization_members ((organization_id::text), (user_id::text)); ``` `melange doctor` detects missing expression indexes. See [Tuples View](https://melange.sh/docs/concepts/tuples-view.md#recommended-indexes) for indexing guidance and [Scaling](https://melange.sh/docs/guides/scaling.md) for alternatives such as materialized views. To investigate a slow query on your own data, use `EXPLAIN ANALYZE` or the generated `explain_*` functions: ```sql EXPLAIN ANALYZE SELECT check_permission('user', '123', 'viewer', 'document', '456'); ``` ## Benchmark models Scale names such as `1K` and `1M` are labels, not exact tuple counts. The fixtures grow users, groups, hierarchy depth, and fan-out together. The results cannot isolate tuple-count scaling or a fixed cost per feature. See [Benchmark schemas](https://melange.sh/docs/reference/benchmark-models.md) for both complete FGA schemas, physical storage and indexes, seed rules, and the exact mapping from benchmark names to relations. | Label | Kitchen-sink tuples | Users | Groups / chain length | Folder depth | Hierarchy tuples | | --- | ---: | ---: | ---: | ---: | ---: | | 1K | 3,824 | 200 | 40 / 4 | 4 | 1,150 | | 10K | 25,386 | 1,000 | 120 / 5 | 5 | 21,000 | | 100K | 155,692 | 5,000 | 400 / 6 | 6 | 204,000 | | 1M | 840,877 | 25,000 | 2,000 / 6 | 7 | 2,020,000 | ## Checks In the measured kitchen-sink queries, `check_permission` latency stayed roughly flat across a 220× range of dataset sizes (3,824 to 840,877 tuples). These are selected queries, not a bound for every relation or graph: | Check | End to end | In database | Network and client | | --- | ---: | ---: | ---: | | Union of direct, group, folder-inherited, and public grants, then an exclusion (`viewer but not blocked`) | 405 to 475 µs | 83 to 94 µs | 322 to 381 µs | | Userset subject (`group:eng#member`) | 420 to 501 µs | 97 to 131 µs | 321 to 383 µs | | Service account, direct grant | 387 to 446 µs | 61 to 67 µs | 325 to 380 µs | | Intersection (`editor and active`) | 434 to 488 µs | 109 to 112 µs | 323 to 376 µs | | Public subject (`user:*`) on the same relation | 407 to 485 µs | 86 to 96 µs | 321 to 389 µs | Through the Go `Checker`, which adds input validation and connection handling, a check took 0.41 to 0.66 ms at every size from 1,150 to 2,020,000 tuples. Across 889 check assertion benchmarks in the OpenFGA compatibility suite, the median of benchmark medians was 0.60 ms and their 90th percentile was 0.76 ms. These are not per-request latency percentiles. Other fixed-input operations also stayed in a narrow band: | Operation | End to end | In database | | --- | ---: | ---: | | `expand_permission` | 0.39 to 0.44 ms | 60 to 68 µs | | `explain_permission` (full trace) | 0.63 to 0.71 ms | 266 to 296 µs | | `check_permission_bulk`, 3 checks across 3 object types | 0.83 to 0.97 ms | 454 to 509 µs | The Go `BenchmarkCheck` cases allocated about 1.3 KiB in 26 allocations per call across the measured sizes. Other APIs and cache paths have different allocation costs. ### Bulk checks Bulk checks send every check in one statement, so you pay the round trip once: | Checks | One at a time | One bulk call | | ---: | ---: | ---: | | 10 | 5.1 to 6.0 ms | 1.3 to 1.4 ms | | 100 | 52 to 65 ms | 4.5 to 6.4 ms | | 500 | Not measured | 27 to 28 ms (10K through 1M labels) | At the 1K label there are only 50 repositories, so the 500-entry batch repeats IDs and takes 4.9 ms; it is not a measurement of 500 distinct checks. Sequential cases also vary users while homogeneous bulk cases fix one user, so the comparison reflects those application patterns rather than isolating only transport overhead. Duplicate checks in a batch are evaluated once: 100 checks with 50 distinct took 3.6 to 4.4 ms. Use `BulkCheck` whenever a request needs more than a couple of answers, such as filtering a page of results or rendering per-item buttons. ### Concurrency With four benchmark workers on the 21,000-tuple hierarchy fixture, direct checks achieved about 9,400 operations/s (106 µs of elapsed benchmark time per completed operation); inherited checks achieved about 7,200 operations/s (140 µs/op). These are aggregate throughput measurements, not individual request latencies. Only this concurrency level was measured; they do not establish saturation capacity or linear scaling with more connections. ### Caching The optional in-memory cache answers repeated checks without a database call: | Scenario | Latency | | --- | ---: | | Uncached check | 616 µs | | First check for a key (cache miss) | 582 µs | | Cached check | 117 ns | | Bulk check, all cached | 68 to 84 µs | ```go cache := melange.NewCache( melange.WithTTL(time.Minute), ) checker := melange.NewChecker(db, melange.WithCache(cache)) ``` Use caching when permissions are read much more often than they change and a staleness window (typically 30 seconds to 5 minutes) is acceptable. Skip it when permissions change frequently or you need every check to see the latest writes. See [Caching](https://melange.sh/docs/guides/caching.md). ### Invalid requests The Go checker rejects invalid relations, types, and subject formats before sending anything to the database, so they cost no round trip. ## List operations `list_accessible_objects` and `list_accessible_subjects` return every match, so their cost depends on how much of the permission graph Postgres has to walk to find them. That is not always the size of the answer: - **Candidates, not results.** An intersection enumerates candidates from a part and filters them; an exclusion starts from its base relation. `viewer and active` over a document set costs about the same as listing every `viewer`, even when only a few documents pass the `active` filter. - **Graph size, not results.** Recursive relations (`viewer from parent`) and userset subjects (`group:eng#member` through nested groups) walk the reachable graph. A query that returns 10 results can still take hundreds of milliseconds if those results are found through thousands of group memberships. - **Pagination may bound the response without reducing the expensive work.** The first PR page took 262 to 269 ms at the largest hierarchy scale for limits of 10, 50, 100, and 500. Later-page costs were not measured. Inspect your plan before assuming a smaller limit makes it faster. ### ListObjects Measured end to end. Each cell shows fixture-derived result cardinality, then measured latency. The benchmark output records timings, not row counts. Scale labels refer to two different datasets; see [Benchmark models](#benchmark-models). The PR row uses a paginated first-page call: its count is the accessible universe, not the page payload. | Query shape | 1K label | 10K label | 100K label | 1M label | | --- | ---: | ---: | ---: | ---: | | Few results, short paths (organizations a user belongs to, groups a service account is in) | 1 to 2: 0.46 to 0.50 ms | 1 to 2: 0.51 to 0.55 ms | 1: 0.43 to 0.53 ms | 1: 0.45 to 0.99 ms | | Objects inherited through one parent (pull requests readable via repository → organization) | 100: 2.8 ms | 1,000: 7.5 ms | 5,000: 39 ms | 20,000: 266 ms | | Union of direct, group, folder-inherited, and public grants | 36: 6.1 ms | 380: 20 ms | 2,580: 68 ms | 13,020: 339 ms | | Intersection over that union (`viewer and active`) | 12: 5.8 ms | 20: 15 ms | 30: 43 ms | 42: 211 ms | | Recursive folders (`viewer from parent`, depth 4 to 7) | 12: 2.1 ms | 20: 6.1 ms | 30: 9.8 ms | 42: 32 ms | | Userset subject through nested groups (40 to 2,000 groups) | 3: 1.6 ms | 5: 6.3 ms | 8: 30 ms | 10: 188 ms | Do not extrapolate a universal cost per returned object from these rows. At the largest kitchen-sink scale, `DocCanView` spent 336.1 ms of 339.1 ms in PostgreSQL; `DocGated` spent 210.2 ms of 211.0 ms there despite its much smaller result. Reducing round-trip latency alone would barely change either total. Profile candidate enumeration, joins, and recursive expansion. ### ListSubjects | Query shape | 1K label | 10K label | 100K label | 1M label | | --- | ---: | ---: | ---: | ---: | | One subject (intersections such as `viewer and active`, folder editors) | 1: 0.90 to 0.95 ms | 1: 0.89 to 0.99 ms | 1: 0.93 to 0.94 ms | 1: 0.72 to 0.86 ms | | Direct members of an organization | 20: 0.62 to 0.67 ms | 50: 0.67 to 0.72 ms | 100: 0.64 to 0.79 ms | 200: 0.76 to 1.07 ms | | Readers inherited from the parent organization | 20: 0.97 to 1.03 ms | 50: 1.4 to 1.5 ms | 100: 2.0 to 2.3 ms | 200: 5.6 to 6.1 ms | | Viewers of a folder, through parent folders | 15: 2.8 ms | 32: 5.0 ms | 68: 8.0 ms | 168: 9.5 ms | | Viewers of a document, through nested groups and folders | 16: 11 ms | 33: 22 ms | 69: 44 ms | 169: 106 ms | | Same, with an exclusion that removes every subject (`viewer but not blocked`) | 0: 20 ms | 0: 40 ms | 0: 81 ms | 0: 220 ms | The last row returns nothing and still costs the most: the exclusion has to find every viewer before it can remove them. ### Where large list queries spend their time At the 1M kitchen-sink label (840,877 tuples), the expensive lists are dominated by PostgreSQL. These are selected full-result SQL calls; the paginated PR benchmarks do not record this split. | Direction / relation | Total | In database | Network and client | | --- | ---: | ---: | ---: | | Objects: document `can_view` (union + exclusion) | 339.1 ms | 336.1 ms | 3.1 ms | | Objects: document `gated` (intersection) | 211.0 ms | 210.2 ms | 0.816 ms | | Objects: organization `member`, userset subject | 188.0 ms | 187.2 ms | 0.783 ms | | Objects: document `can_edit` (intersection) | 51.9 ms | 51.1 ms | 0.776 ms | | Objects: folder `viewer` (recursive TTU) | 31.7 ms | 31.0 ms | 0.683 ms | | Subjects: document `can_view` (exclusion) | 219.6 ms | 218.9 ms | 0.752 ms | | Subjects: document `viewer` (union + inherited grants) | 106.1 ms | 105.4 ms | 0.702 ms | | Subjects: document `gated` (intersection) | 0.861 ms | 0.463 ms | 0.398 ms | ## Schema shape The same relation can behave very differently in the two listing directions. Use this map when interpreting the measurements: | Feature | What to inspect | Evidence in this run | | --- | --- | --- | | Direct / computed userset / union | Index lookups, number of grants, duplicate paths | Small fixtures generally below 0.6 ms; a broad document union plus exclusion reaches 339 ms for object listing. | | TTU (`viewer from parent`) | Parent count, join selectivity, inherited candidates | PR first pages reach 262–269 ms; a page limit does not remove the inherited-access work in this fixture. | | Recursive TTU | Depth, branching, overlap in reachable ancestors | Folder viewer object lists: 2.1–31.7 ms; subject lists: 2.8–9.5 ms. | | Userset references (`group#member`) | Nested membership expansion; querying a userset versus querying a user | Userset-subject organization lists: 1.6–188 ms, versus 0.420–0.501 ms for the corresponding point checks. | | Intersection | Candidate part size and selectivity of the other parts | Document `gated` object lists: 5.8–211 ms; subject lists: 0.861–0.993 ms. | | Exclusion | Size of base relation and cost of subtracting grants | Document `can_view` subject lists: 20.1–219.6 ms even though this selected document's wildcard block removes all subjects. | | Wildcard | Boolean membership versus expansion into objects or subjects | Named wildcard fixture object lists range from 0.451 to 21.8 ms. | Do not replace `and` with `or` as a performance optimization: that changes who has access. Keep the required semantics and optimize indexes, candidate selection, data layout, or the API used by the application. ## OpenFGA test suite The OpenFGA compatibility suite covers every supported feature on small datasets. Each assertion runs as its own benchmark, 2,032 in all: | Operation | Benchmarks | Median of medians | p90 of medians | Median database share | | --- | ---: | ---: | ---: | ---: | | Check | 889 | 0.60 ms | 0.76 ms | 24% | | BulkCheck | 194 | 0.83 ms | 1.4 ms | 38% | | ListObjects | 556 | 0.90 ms | 2.2 ms | 43% | | ListUsers | 393 | 0.79 ms | 1.4 ms | 41% | These are Melange timings while executing OpenFGA compatibility fixtures, not measurements of an OpenFGA server. Percentiles summarize equally weighted assertion benchmarks after taking each one's six-run median. They do not describe production traffic or request p90. A fixture can combine several features, so a single pooled “intersection latency” would hide both operation direction and workload differences. ### Feature examples on small fixtures Each cell is the range of six-run medians across assertions of that operation in the **named fixture**, in milliseconds. Check includes allowed and denied assertions. The examples are useful for comparing query shapes, but are not controlled experiments isolating the cost of one feature. `ListUsers` is the compatibility-suite name for listing subjects. | Feature | Fixture | Check (ms) | ListObjects (ms) | ListUsers (ms) | | --- | --- | ---: | ---: | ---: | | Direct `[user]` | `this` | 0.450–0.457 | 0.475–0.524 | 0.488–0.532 | | Computed userset / relation alias | `computed_userset` | 0.404–0.427 | 0.459–0.469 | 0.427–0.480 | | Union (`or`) | `this_and_union` | 0.410–0.418 | 0.434–0.474 | 0.469–0.481 | | Tuple-to-userset (`from`) | `tuple_to_userset` | 0.443 | 0.499 | 0.364–0.529 | | Intersection (`and`) | `this_and_intersection` | 0.403–0.430 | 0.763–0.804 | 0.623 | | Exclusion (`but not`) | `this_and_exclusion_base` | 0.361–0.415 | 0.418–0.528 | 0.467–0.477 | | Wildcard expansion | `list_objects_expands_wildcard_tuple` | 1.2–2.5 | 0.451–21.8 | 0.486–14.4 | The wildcard fixture has both cheap and expensive assertions: the expensive object lists spend 96–97% in PostgreSQL. A wildcard grant can be cheap to check while enumeration is expensive. It is not a uniformly cheap feature. For nested usersets and recursive TTU at scale, use the kitchen-sink tables above: folder viewer lists rise from 2.1 to 31.7 ms, and listing organization membership for a userset subject rises from 1.6 to 188 ms. Those queries also change graph size and fan-out; they do not measure a fixed per-hop penalty. ## Contextual tuples Contextual tuples are made visible by creating a temporary view for the call and dropping it afterwards. On Cloud SQL a check with contextual tuples took: | Contextual tuples | Latency | | ---: | ---: | | None (same connection handling) | 0.44 ms | | 1 | 7.4 ms | | 25 | 8.1 ms | | 100 | 10.1 ms | List queries with contextual tuples in the OpenFGA suite took 7 to 11 ms. The strategy timings include connection handling, temporary-view DDL, the permission query, and cleanup; they do not provide a server/client split. Some contextual compatibility cases also require multiple statements, so the one-statement check model does not apply. Keep contextual tuples off hot paths, and model data in your tables where you can. ## Methodology | | | | --- | --- | | Database | Cloud SQL for PostgreSQL 17.11, Enterprise edition, 4 vCPU, 16 GB (`db-custom-4-16384`), zonal, SSD | | Client | Compute Engine `n2d-standard-4` (4 vCPU, AMD EPYC 7B13), Go, `pgx` through `database/sql` | | Network | Same zone (`europe-west2-a`), private IP, SSL | | Melange | Source marker `49f41f35`, plus any uncommitted changes present at upload; the report does not identify that patch | | Runs | Each benchmark run 6 times; the page shows medians, or the range of medians across dataset sizes | | Date | October 20 to 21, 2026 (20:12 to 00:24 UTC) | **Datasets.** See the complete schemas and fixture configuration in [Benchmark models](#benchmark-models). Two generated datasets, each at four sizes: - A schema that uses every relation feature: nested groups, recursive folders, organizations, intersections, exclusions, and wildcards. 3,824, 25,386, 155,692, and 840,877 tuples. Measured by calling the generated SQL functions directly. - An organization → repository → pull request hierarchy. 1,150, 21,000, 204,000, and 2,020,000 tuples. Measured through the Go `Checker`. The bulk, concurrency, and cache numbers, and the list rows that mention organizations, repositories, or pull requests, use this dataset; concurrency and caching use the 21,000-tuple size. The OpenFGA suite uses each test's own small fixture, through the Go `Checker` (bulk checks call `check_permission_bulk` directly). The [downloadable measurement snapshot](https://melange.sh/benchmarks/cloud-2026-10.csv) preserves six-run medians for the scale, cache, concurrency, contextual-strategy, and named feature examples on this page, with units and sample counts. It was extracted from the raw benchmark output in [`benchmarks/2026-10-cloud-sql/`](https://github.com/pthm/melange/tree/main/benchmarks/2026-10-cloud-sql), which also has the database and network split for every kitchen-sink and OpenFGA benchmark. Blank database metrics mean that benchmark did not record a split. Six-run spreads reach about 25% for some kitchen-sink cases and 44% for one expensive wildcard object list. These are repeated benchmark averages, not individual request samples. There is no production p95/p99, cold-cache study, saturation test, or before/after baseline here. Independently calculated column medians need not add up exactly. **End to end** is the time the client measured for one call, including the network round trip and driver work. **In database** is the execution time plus any tracked planning time for the statements the call sent, from `pg_stat_statements`. Planning time is included only if `pg_stat_statements.track_planning` is enabled; the saved run does not record that setting. **Network and client** is the difference. **What will differ for you.** Your round-trip time sets the floor for every call: a cross-zone or cross-region connection adds its latency to each one. Database time depends on your schema, your data's shape, the indexes on your tuple source tables, and the instance size. Compare runs on the same setup; local Docker timings are not directly comparable to these cloud measurements. Run the same benchmarks on your own Google Cloud project with `just bench-cloud`, or locally; see [Benchmarking](https://melange.sh/docs/contributing/benchmarking.md). ## Further reading - [Benchmarking](https://melange.sh/docs/contributing/benchmarking.md): run these benchmarks yourself. - [Scaling](https://melange.sh/docs/guides/scaling.md): indexing and view strategies for large datasets. - [How It Works](https://melange.sh/docs/concepts/how-it-works.md): the architecture behind these characteristics. --- # Benchmark schemas Source: https://melange.sh/docs/reference/benchmark-models/ > Complete schemas, data layout, and query selection behind the October 2026 performance measurements. These are the models behind the [Performance reference](https://melange.sh/docs/reference/performance.md). The October 2026 cloud run uses two scale fixtures and the small, individually defined OpenFGA compatibility fixtures. A feature's cost depends on its relation, query direction, and data distribution; the models below make those differences explicit. ## Kitchen-sink collaboration model The source is `test/testutil/testdata/kitchen_sink_schema.fga`. The complete schema below omits only comments. It includes types and relations that are seeded for coverage but are not individually timed by the kitchen-sink suite. ```fga model schema 1.1 type user type service_account type platform relations define admin: [user] type mailing_list relations define subscriber: [user, service_account] type group relations define member: [user, service_account, group#member] define banned: [user, user:*] define active_member: [user, user:*, group#active_member] but not banned type organization relations define owner: [user] define admin: [user] or owner define member: [user, group#member] or admin define billing: [user] define can_admin: admin define can_read: member define guest: [user, user:*] type team relations define org: [organization] define maintainer: [user] define member: [user, group#member] or maintainer or member from org define can_read: member type project relations define org: [organization] define admin: [user] or admin from org define editor: [user, group#member] define viewer: [user, team#member] or editor define public: [user:*, service_account:*] define contributor: editor or admin type folder relations define parent: [folder, project] define org: [organization] define viewer: [user, group#member] or viewer from parent or member from org define editor: [user] or editor from parent define public: [user:*] define banned: [user] define co_owner: viewer and editor define protected: viewer from parent but not banned type document relations define folder: [folder] define owner: [user] define editor: [user, group#member, folder#editor] or owner or editor from folder define viewer: [user, group#member, folder#viewer] or editor or viewer from folder or public define public: [user:*] define active: [user] define reviewer: [user] define blocked: [user, user:*] define restricted: [user] define can_edit: editor and active define gated: viewer and active define can_manage: editor and editor from folder define audience: [group#member] and active define can_view: viewer but not blocked define can_comment: gated but not blocked define can_review: viewer but not owner define can_read_safe: viewer but not restricted define can_share: can_edit but not banned from folder define visible: viewer but not owner_and_blocked define owner_and_blocked: owner and blocked define super_view: viewer or admin from platform define platform: [platform] define elevated: can_edit or can_manage type report relations define document: [document] define audience: [group#member] define inherited_view: can_view from document type comment relations define document: [document] define author: [user] define can_read: can_view from document define can_edit: author or can_manage from document ``` ### Data shape `GenerateKitchenSinkTuples` in `test/testutil/kitchensink_fixtures.go` creates deterministic, overlapping grants: - Groups form membership chains; chain tails grant users and service accounts. Organizations combine owners, admins, direct members, and group usersets. - Each organization has teams and projects. Some organization guest and project public relations grant wildcards. - Each organization has several parallel folder chains. Root folders link to projects; deeper folders link to the previous level, and all folders also link to the organization. This is not an exponentially branching tree. - Every folder has documents with direct owners, editors, group grants, and a `folder#editor` userset. Documents link to a platform singleton. Every fourth document is public, every seventh has a wildcard block, and every document has an active user. - One report and comment per folder exercise pure userset, inherited exclusion, and TTU composition paths. | Label | Organizations | Members/org | Teams/org | Projects/org | Folders/level/org | Documents/folder | Service accounts | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | 1K | 5 | 20 | 3 | 5 | 3 | 4 | 20 | | 10K | 15 | 40 | 4 | 10 | 4 | 6 | 80 | | 100K | 40 | 80 | 6 | 20 | 5 | 10 | 300 | | 1M | 100 | 200 | 8 | 40 | 6 | 16 | 1,500 | User counts, group chain lengths, folder depths, and exact tuple counts are in the [performance dataset table](https://melange.sh/docs/reference/performance.md#benchmark-models). These parameters change together; this is a workload growth study rather than a controlled experiment varying only tuple count. ### Physical storage Unlike the hierarchy fixture's domain tables, this model uses a flat base table. `melange_tuples` is a regular view over it, not a materialized view: ```sql CREATE TABLE kitchen_sink_tuples ( subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, relation TEXT NOT NULL, object_type TEXT NOT NULL, object_id TEXT NOT NULL ); CREATE INDEX ks_by_object ON kitchen_sink_tuples (object_type, object_id, relation); CREATE INDEX ks_by_subject ON kitchen_sink_tuples (subject_type, subject_id, relation); CREATE INDEX ks_by_edge ON kitchen_sink_tuples (object_type, relation, subject_type, subject_id); CREATE VIEW melange_tuples AS SELECT subject_type, subject_id, relation, object_type, object_id FROM kitchen_sink_tuples; ``` ### What is actually timed The kitchen-sink suite calls generated SQL directly through `database/sql`; it drains list rows. It does not measure the Go Checker's input validation or pagination. Names below are suffixes under `BenchmarkKitchenSinkCheck`, `ListObjects`, or `ListSubjects`. | Benchmark suffix | Subject / object selection | Relation and feature path | | --- | --- | --- | | Check `PlainUser` | Organization owner; first document by ID | `document.can_view`: union, TTU, nested usersets, wildcard, exclusion | | Check `MemberCanView` | Direct organization member; same document | Same relation with a different subject | | Check `Wildcard` | Literal `user:*`; same document | Same relation with a wildcard query subject, not a named public user | | Check `CanEdit` | Owner subject; same document | `document.can_edit`: `editor and active` | | Check `UsersetSubject` | A stored `group#member` organization grant | `organization.member`; querying the userset itself | | Check `ServiceAccount` | Stored service-account/group membership pair | `group.member` | | ListObjects `DocCanView` | Owner subject; document objects | `document.can_view`, including exclusion | | ListObjects `DocGated` | Owner subject; document objects | `document.gated`: `viewer and active` | | ListObjects `DocCanEdit` | Owner subject; document objects | `document.can_edit`: `editor and active` | | ListObjects `DocEditor_Member` | Direct organization member; document objects | `document.editor`, including usersets and inherited editors | | ListObjects `FolderViewer` | Owner subject; folder objects | `folder.viewer`, recursive TTU plus organization anchor | | ListObjects `UsersetSubject_OrgMember` | Same userset as check; organizations | `organization.member` | | ListObjects `ServiceAccount_GroupMember` | Same service account as check; groups | `group.member` | | ListSubjects `viewer`, `can_view`, `gated`, `can_edit` | First document by ID; user subjects | The corresponding document relation | | ListSubjects `FolderViewer`, `FolderEditor` | First folder by ID; user subjects | `folder.viewer`, `folder.editor` | The selected first document has a wildcard block: its `can_view` subject list is empty even though its `viewer` list is broad. `gated` and `can_edit` subject lists each return one user. That difference explains why “intersection is always slower than exclusion” would be misleading. Setup picks an organization owner and direct member without an explicit ordering; replaying the fixture does not establish that every possible subject has the measured latency. `Expand` and `Explain` use that document's `can_view`. `Bulk` submits three checks across document, folder, and organization types. Relation presence in the schema does not mean every combination is covered by a separate timing. ## Organization / repository / pull request model This is `test/testutil/testdata/schema.fga`, used by `test/benchmark_test.go`. These benchmarks call the Go `Checker`, except where a benchmark explicitly calls SQL. The full schema is: ```fga model schema 1.1 type user type organization relations define owner: [user] define admin: [user] or owner define member: [user] or admin define billing_manager: [user] define can_read: member define can_admin: admin define can_delete: owner type team relations define org: [organization] define maintainer: [user] define member: [user] or maintainer define can_read: member define can_admin: maintainer type repository relations define org: [organization] define owner: [user] define admin: [user] or owner define maintainer: [user] or admin define writer: [user] or maintainer define reader: [user] or writer or can_read from org define author: [user] or owner define banned: [user:*] define can_read: reader define can_write: writer define can_admin: admin define can_delete: owner define can_review: can_read but not author define can_read_safe: can_read but not banned define can_deploy: can_admin from org type issue relations define repo: [repository] define author: [user] define assignee: [user] define can_read: can_read from repo define can_edit: author or can_write from repo define can_close: author or can_write from repo define can_delete: can_admin from repo type pull_request relations define repo: [repository] define author: [user] define reviewer: [user] define banned: can_admin from repo define can_read: can_read from repo define can_edit: author or can_write from repo define can_merge: can_write from repo define can_review: can_read from repo but not author define can_review_strict: can_read from repo but not banned define can_delete: can_admin from repo ``` The scale loader populates organization memberships, repositories linked to those organizations, and PRs linked to repositories with authors. Other schema types and relation definitions support integration tests but are not all populated by this scale loader. `melange_tuples` is a `UNION ALL` view over domain tables, including `organization_members`, `repositories`, and `pull_requests`, with IDs cast to text. See `test/testutil/testdata/tuples_view.sql.tmpl` and `domain_tables.sql` for the view and indexes; these timings include that view's work. | Label | Users | Organizations | Members/org | Repositories/org | PRs/repository | Tuples | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | 1K | 100 | 5 | 20 | 10 | 10 | 1,150 | | 10K | 500 | 10 | 50 | 50 | 20 | 21,000 | | 100K | 2,000 | 20 | 100 | 100 | 50 | 204,000 | | 1M | 10,000 | 50 | 200 | 200 | 100 | 2,020,000 | `ListAccessiblePRs_PageN` repeatedly requests the first page for the first user, with limit N. The accessible PR universe is 100, 1,000, 5,000, and 20,000 objects respectively; the returned payload is bounded by the page limit. It does not benchmark traversing every page. Bulk results vary batch size and duplication; caching and four-worker concurrency use the 10K label (21,000 tuples). ## OpenFGA compatibility fixtures `BenchmarkOpenFGA_All` loads the official `consolidated_1_1_tests.yaml` plus Melange's `test/openfgatests/testdata/*.yaml`. Each model receives its own database and seeded tuples. Every assertion has a named benchmark; `ListUsers` is the suite name for subject listing. Bulk batches contain that fixture's check assertions, so batch sizes differ across fixtures. These timings run Melange against OpenFGA models. They do not compare Melange with the OpenFGA service. A name-based feature category can contain several features and is not a controlled per-feature measurement. The [named fixture table](https://melange.sh/docs/reference/performance.md#feature-examples-on-small-fixtures) keeps operations separate and identifies the examples behind each range. For reproducing the run and interpreting server/client timing, see [Methodology](https://melange.sh/docs/reference/performance.md#methodology) and [Benchmarking](https://melange.sh/docs/contributing/benchmarking.md).