--- name: stash-indexing description: Create and verify PostgreSQL indexes on EQL v3 encrypted columns — functional-index recipes over the term extractors (eql_v3.eq_term, ord_term, ord_term_ore, match_term, to_ste_vec_query) mapped to the types.* domains, what works without superuser on Supabase and managed Postgres versus the ORE opclass restriction, which domains have no index option, the ORDER BY / GROUP BY query shapes that engage an index, building indexes on large tables, and the EXPLAIN verification checklist. Use when creating or reviewing a schema migration that adds an encrypted column, adding an index to an encrypted column, diagnosing a slow encrypted query or an EXPLAIN plan showing Seq Scan, or answering whether encrypted columns can be indexed on Supabase or managed PostgreSQL. --- # Indexing Encrypted Columns (EQL v3) Encrypted columns **can** be indexed, and on any non-trivial table they **should** be. The model is one rule, uniform across every encrypted domain: **index a functional expression over the column's term extractor — never an operator class on the column itself.** The extractors are inlinable SQL functions, so bare-form predicates (`WHERE col = $1`, `WHERE col < $1`, `col @@ $1`) engage the index with no query rewriting. This covers EQL v3 — the bundle `stash eql install` applies (`@cipherstash/eql`). An integration that is otherwise correct (encrypted at rest, searchable, exact round-trip) but has no index on its encrypted predicates will sequential-scan every encrypted query; that is the default outcome unless you put these indexes in place — the integrations emit query operators, not index DDL (see [Where the Index DDL Goes](#where-the-index-ddl-goes)). ## When to Use This Skill - Writing or reviewing a schema migration that adds or changes an encrypted (`eql_v3_*`) column. - Deciding which indexes an encrypted column supports — or explaining why a column has none. - An encrypted query is slow, or `EXPLAIN` shows a `Seq Scan` where you expected an index. - `stash eql validate` reports "No functional index over `eql_v3.…`" for a queryable column. - Answering whether encrypted columns can be indexed on Supabase or managed PostgreSQL (yes — see the superuser section). ## Which Columns Support Which Index Capability is fixed by the column's domain type, chosen at schema definition via the `types.*` factories. `N` ranges over the numeric-and-time base types `Integer`, `Smallint`, `Bigint`, `Date`, `Timestamp`, `Numeric`, `Real`, `Double`; text is listed separately because its ordering domains behave differently (see the note below the table). `` is the lowercase SQL name (`eql_v3_integer_eq`, `eql_v3_text_ord`, …). | Schema factory | Postgres domain | Terms carried | Index recipes | |---|---|---|---| | `types.NEq`, `types.TextEq` | `public.eql_v3__eq` | `hm` | equality (`eq_term`) | | `types.NOrd` | `public.eql_v3__ord` | `op` | **one** ordering index (`ord_term`) — serves equality, range, and `ORDER BY` | | `types.NOrdOre` | `public.eql_v3__ord_ore` | `ob` | **one** ORE ordering index (`ord_term_ore`) — equality + range; superuser installs only | | `types.TextOrd` | `public.eql_v3_text_ord` | `hm`, `op` | equality (`eq_term`) **+** ordering (`ord_term`) — two indexes | | `types.TextOrdOre` | `public.eql_v3_text_ord_ore` | `hm`, `ob` | equality (`eq_term`) **+** ORE ordering (`ord_term_ore`; superuser) | | `types.TextMatch` | `public.eql_v3_text_match` | `bf` | free-text match (`match_term`) | | `types.TextSearch` | `public.eql_v3_text_search` | `hm`, `op`, `bf` | equality + ordering/range + match (three indexes) | | `types.Json` | `public.eql_v3_json_search` | `sv` (ste_vec) | containment GIN + field-level ordering | | bare `types.N` / `types.Text`, `types.Boolean` | `public.eql_v3_` | none | **none — storage-only by design** | **Why the numeric/text split**: the numeric-and-time ordering terms (OPE and ORE alike) are **injective** — distinct plaintexts produce distinct terms — so equality can ride the ordering term. Those domains carry no `hm`, the bundle defines **no `eq_term` overload** for them, and `eql_v3.eq` inlines to `ord_term(a) = ord_term(b)`: one ordering btree serves `=`, range, and `ORDER BY`. Text ordering terms are **non-injective** and cannot be relied on for equality, so `text_ord` / `text_ord_ore` also carry `hm` and answer `=` via `eq_term` — give those columns both indexes. Do not add an `eq_term` index to a numeric `_ord` / `_ord_ore` column; the overload does not exist. The last row is deliberate, not a gap: a bare `types.Text` / `types.Integer` / `types.Boolean` column carries no query terms, so there is nothing to index and nothing to query server-side. If a column needs an index, it needs a term-carrying domain first. **Choosing the domain in the first place** is the `stash-encryption` skill's **capability matrix** (`### The types Namespace`) — all 40 factories, one row each, with the predicates and the index each supports. Use it to pick the type; use this page to index it. ## The Recipes Every recipe is a functional index over the extractor, followed by `ANALYZE` (see [Making a Query Engage the Index](#making-a-query-engage-the-index) for why `ANALYZE` is mandatory). Name indexes descriptively (`users_email_eq`, `events_at_ord`) — it makes `EXPLAIN` output and maintenance legible. ### Equality — `eql_v3.eq_term` For the domains carrying `hm`: `_eq`, `text_ord`, `text_ord_ore`, and `text_search`. (On numeric/date/timestamp `_ord` / `_ord_ore` columns, equality rides the ordering index below instead — no `eq_term` overload exists for them.) ```sql CREATE INDEX users_email_eq ON users USING btree (eql_v3.eq_term(encrypted_email)); ANALYZE users; SELECT * FROM users WHERE encrypted_email = $1; -- Index Scan using users_email_eq -- Index Cond: (eql_v3.eq_term(encrypted_email) = eql_v3.eq_term($1)) ``` `btree` is the safe default: it serves `=` exactly as well as `hash` with no query-side cost, and its build scales (see [Building Indexes at Scale](#building-indexes-at-scale)). `USING hash` is fine for small and mid-size tables, but a hash index *build* degrades badly past a few million rows. ### Ordering and Range — `eql_v3.ord_term` For the OPE-backed ordering domains: `_ord` and `text_search`. ```sql CREATE INDEX events_at_ord ON events USING btree (eql_v3.ord_term(encrypted_at)); ANALYZE events; SELECT * FROM events WHERE encrypted_at < $1; ``` `eql_v3.ord_term` returns a `bytea`-backed domain, so this btree binds PostgreSQL's **default** `bytea_ops` operator class — nothing to install, no privilege required, works on Supabase and managed Postgres. The `<` `<=` `>` `>=` operators inline to comparisons on the extractor, so natural-form range predicates match the index — and on the numeric/date/timestamp `_ord` domains so does `=`, since their injective ordering term also answers equality. One index, every scalar predicate. (A `text_ord` column answers `=` via `eq_term` instead — pair this index with the equality one. `ORDER BY` needs the extractor form — see [Query-Shape Traps](#query-shape-traps).) ### ORE Ordering — `eql_v3.ord_term_ore` (superuser installs only) For the `_ord_ore` domains only: ```sql CREATE INDEX events_at_ord_ore ON events USING btree (eql_v3.ord_term_ore(encrypted_at)); ANALYZE events; ``` This one depends on a custom operator class the EQL installer must be privileged enough to create — see [Supabase and Managed Postgres](#supabase-and-managed-postgres-what-actually-needs-superuser) for which platforms allow it and for the failure mode to check. Prefer `types.NOrd` / `types.TextOrd` unless you specifically need ORE ordering on a platform whose installer could create the opclass. ### Free-Text Match — `eql_v3.match_term` For the bloom-filter domains: `text_match` and `text_search`. Engages the `@@` match operator: ```sql CREATE INDEX users_name_match ON users USING gin (eql_v3.match_term(encrypted_name)); ANALYZE users; SELECT * FROM users WHERE encrypted_name @@ $1; -- Bitmap Index Scan on users_name_match ``` ### JSON Containment — `eql_v3.to_ste_vec_query` For `public.eql_v3_json_search` (`types.Json`) document containment (`@>`): ```sql CREATE INDEX orders_data_gin ON orders USING gin ((eql_v3.to_ste_vec_query(data_encrypted)::jsonb) jsonb_path_ops); ANALYZE orders; SELECT * FROM orders WHERE data_encrypted @> $1::eql_v3.query_json; -- Bitmap Index Scan on orders_data_gin ``` The needle must be typed — `$1::eql_v3.query_json` or another `public.eql_v3_json_search` value. A bare untyped literal falls through to native `jsonb @>` and skips the index. Note `jsonb_path_ops` indexes `@>` only, not `<@`. ### Field-Level Ordering Inside Encrypted JSON For ordered access to a single field of a `types.Json` document, index the ordering extractor over the path: ```sql CREATE INDEX orders_total_ord ON orders USING btree (eql_v3.ord_term(data_encrypted -> ''::text)); ANALYZE orders; SELECT * FROM orders ORDER BY eql_v3.ord_term(data_encrypted -> ''::text) LIMIT 10; ``` - `` is the deterministic selector hash the encryption client emits (each `sv` element's `s` field) — **not** a plaintext JSONPath. To obtain it, encrypt the path through the client — `encryptQuery('$.total', { table, column, queryType: 'steVecSelector' })` — and read the selector from the returned query envelope; it is stable for a given column + path, so it can be pasted into migration DDL. (Cross-check: the same hash appears as `s` in the `sv` entries of any stored row that has the field.) - The `->` operand must be typed (`::text`); a bare literal falls through to native `jsonb ->`. - The extracted term is `bytea`-backed like top-level `ord_term` — default btree opclass, no superuser. - Entry-to-entry `=` / `<>` and exact `GROUP BY` / `DISTINCT` on extracted fields are **not supported** (an extracted entry carries no value selector). Use document containment with the GIN index above for exact field equality. ## Supabase and Managed Postgres: What Actually Needs Superuser **Only one thing on this page needs superuser: the ORE operator class behind `_ord_ore`.** Everything else — equality btree/hash, `_ord`/`text_search` ordering btree, match GIN, JSON containment GIN, field-level ordering — installs and engages with a plain non-superuser role. Do not generalize the ORE warning into "encrypted columns can't be indexed on Supabase"; the default ordering path (`_ord`, via CLLW-OPE) binds Postgres's native `bytea` btree operator class and needs nothing installed. The `_ord_ore` restriction, precisely: its btree ordering depends on a hand-written operator class created by the EQL installer, and `CREATE OPERATOR CLASS` is a superuser-gated command in stock PostgreSQL. Whether that blocks ORE is per-platform, not a blanket managed-Postgres rule: **AWS RDS and Aurora fully support it** (their master role can create operator classes), while **cloud-hosted Supabase is the one confirmed platform that refuses it**. Where the install role can't create the opclass, the installer detects this and **disables the `_ord_ore` domains** — using one raises `feature_not_supported` with a hint naming the alternatives. **The silent-failure mode to check for:** if an `_ord_ore` column somehow exists without the opclass, `CREATE INDEX … USING btree (eql_v3.ord_term_ore(col))` does **not** fail — PostgreSQL binds the generic `record_ops` instead. The index builds, occupies space, and never engages. Run `stash eql verify` first: it reads the ORE state directly and distinguishes the two healthy configurations (opclass present, or opclass skipped with every `_ord_ore` domain disabled) from the incoherent half-working state that makes this trap possible — and `stash eql install` runs the same check automatically. What `verify` does not tell you is which opclass an *existing index* bound at build time; for that, check the index itself: ```sql SELECT i.relname, oc.opcname FROM pg_index x JOIN pg_class i ON i.oid = x.indexrelid JOIN pg_opclass oc ON oc.oid = x.indclass[0] WHERE i.relname = 'events_at_ord_ore'; -- ore_block_256_operator_class → ORE ordering, index engages -- record_ops → opclass was skipped at install; index is inert ``` `_ord` has no such failure mode. ## Making a Query Engage the Index Functional-index engagement is **structural**: the planner inlines the operator into the same extractor expression the index was built on and matches the expression trees syntactically. All three of these must hold: 1. **The value must carry the term the index extracts.** `eq_term` needs `hm`, `ord_term` needs `op`, `ord_term_ore` needs `ob`, `match_term` needs `bf`, containment needs the ste_vec. The domain rows in the table above tell you which terms a column's values carry; a value with only a bloom term will never drive an equality index. 2. **The index must be created after the data carries the term.** If you change which terms a column's values carry (e.g. re-encrypt under a different domain), recreate the index — a functional index built before the term existed will not match. 3. **The query operand must be typed** so the encrypted operator resolves, not the native `jsonb` one. A typed parameter (`$1`) or an explicit cast to the domain works; a bare `::jsonb` literal falls through to native jsonb semantics and skips the index. The Drizzle, Prisma Next, and Supabase integrations emit correctly-typed operands already — this requirement only bites hand-written SQL. And after **every** index build: **run `ANALYZE`**. `CREATE INDEX` on an expression gathers no statistics for that expression, so until `ANALYZE` runs the planner has no histogram for `eql_v3.eq_term(col)` and can misjudge — or ignore — the index it just built. ## Query-Shape Traps **The `ORDER BY` sort-key trap.** The planner inlines operators in *predicates*, not *sort keys*: `ORDER BY col` adds a `Sort` node even when the ordering index exists and the `WHERE` clause is using it. To stream rows out of the index already ordered, write the sort key in extractor form: ```sql SELECT * FROM events WHERE encrypted_at < $1 ORDER BY eql_v3.ord_term(encrypted_at) DESC LIMIT 10; ``` The natural-form Top-N sort scales linearly with the rows passing `WHERE`; at scale that is the difference between seconds and milliseconds. The Drizzle integration's `asc`/`desc` already emit `ORDER BY eql_v3.ord_term(col)` for you. **The `value::jsonb` projection trap.** `SELECT col::jsonb … ORDER BY col` folds the cast into the scan and sorts on `(col)::jsonb` — which matches no index. Project the column raw, wrap the ordered query in a subquery and cast outside the `LIMIT`, or sidestep it entirely with `ORDER BY eql_v3.ord_term(col)`. **`GROUP BY` / `DISTINCT` on the extractor, not the raw column.** `GROUP BY col` hashes the entire encrypted payload (1–2 KB per row); the estimated hash table blows past `work_mem`, so the planner falls back to `GroupAggregate` — sorting kilobyte rows and spilling to disk. Group on the term instead: ```sql SELECT eql_v3.eq_term(encrypted_email), count(*) FROM users GROUP BY eql_v3.eq_term(encrypted_email); ``` The term is small and deterministic, so `HashAggregate` fits in `work_mem` with no tuning. If an ORM insists on grouping the raw column, raising `work_mem` is the rescue knob — but the extractor form is the design. Pick the extractor the domain actually has: `eq_term` on the `hm`-carrying domains (`types.*Eq`, `types.TextOrd*`, `types.TextSearch`). The numeric/date/timestamp `types.*Ord` / `*OrdOre` domains have **no** `eq_term` — group on `eql_v3.ord_term(col)` (or `ord_term_ore(col)`); their ordering term is injective, so it is an exact grouping key, and the ordering btree covers it. ## Building Indexes at Scale Query performance and *build* performance are separate axes; on large encrypted tables the build is the one that bites. - **Raise `maintenance_work_mem` for the build session** — the single highest-leverage knob. The 64 MB default spills a multi-million-row build to disk early: ```sql SET maintenance_work_mem = '2GB'; CREATE INDEX ...; ANALYZE ...; ``` - **Prefer `btree` over `hash` for equality at scale.** Build characteristics differ sharply: | Access method | Build | Scales past cache? | Parallel build? | |---|---|---|---| | btree | sort, then sequential bulk-load | yes | yes | | GIN | batched buffer build | yes | no | | hash | random bucket fill | **no** | no | A hash build scatters rows to random buckets; once the index outgrows cache it goes random-I/O-bound (a 10M-row hash build has been observed to stall after 17 hours; the btree equivalent built without drama). A btree on `eql_v3.eq_term(col)` serves `=` identically. - **The de-TOAST floor.** A functional index build de-TOASTs the whole stored value once per row to evaluate the extractor — for large `eql_v3_json_search` documents this sets an unavoidable floor on build rate, identical across access methods. Run large builds on fast native storage (containerized Postgres on a virtualized filesystem — e.g. Docker Desktop on macOS — is the worst case). - **Diagnose a slow build** from a second session: ```sql SELECT phase, tuples_done, tuples_total, round(100.0 * tuples_done / nullif(tuples_total, 0), 1) AS pct FROM pg_stat_progress_create_index; ``` A steady `tuples_done` rate is healthy; a rate that decays over time is the cache/memory wall — raise `maintenance_work_mem`, and if it's a hash index, rebuild as btree. ## Verifying with EXPLAIN The first move on any slow encrypted query is `EXPLAIN (COSTS OFF)`: - ✓ `Index Scan using ` — the functional index is engaged. - ✓ `Bitmap Index Scan on ` — same, for set-style predicates (`@@`, `@>`). - ✓ `Index Cond:` referencing the extractor (`eql_v3.eq_term(…)`, `eql_v3.ord_term(…)`) — the inlined predicate matched. - ✗ `Seq Scan` — no index used; work through [Troubleshooting](#troubleshooting). - ✗ `Filter:` showing the raw operator (`col < '…'`) — inlining did not happen. Usual causes: a pinned `search_path` on a customized extractor function, a `plpgsql` body where a `sql` one is expected, or the planner genuinely judging another plan cheaper. - ✗ A `Sort` node above an Index Scan — natural-form `ORDER BY`; switch the sort key to the extractor form. Once the plan shape is right, `EXPLAIN ANALYZE` for actual timings. ## Troubleshooting Index not being used: 1. **Verify the value carries the term:** ```sql SELECT encrypted_email::jsonb ? 'hm' AS has_hmac, encrypted_email::jsonb ? 'op' AS has_ope, encrypted_email::jsonb ? 'ob' AS has_ore_block, encrypted_email::jsonb ? 'bf' AS has_bloom FROM users LIMIT 1; ``` 2. **Verify the operand is typed** (`$1` or `$1::eql_v3.query_text_eq` — not `$1::jsonb`, and not the column domain `public.eql_v3_text_eq`: query payloads are term-only, and the column domains' CHECK requires the ciphertext key `c` that query payloads deliberately omit). 3. **Recreate the index** if the column's term composition changed after it was built. 4. **Run `ANALYZE`.** Also note: on very small tables a `Seq Scan` is the *correct* plan — don't chase it below a few thousand rows. **`=` returns zero rows**: equality needs the domain's equality-serving term — `hm` where the domain carries it (`_eq`, `text_ord`, `text_ord_ore`, `text_search`), the injective ordering term (`op` / `ob`) on the numeric/date/timestamp `_ord` / `_ord_ore` domains. A bare storage-only domain has neither; confirm the column's domain and that the client is emitting the term. **ORE index never engages:** run the `pg_opclass` query from the [superuser section](#supabase-and-managed-postgres-what-actually-needs-superuser) — a `record_ops` binding means the index is inert. ## Where the Index DDL Goes **The integrations emit the query operators for you — none applies index DDL on its own. Making sure these indexes exist is always your job.** This skill is the general model — recipes, engagement rules, verification. How to apply it in a specific integration lives in that integration's skill: - **Drizzle** — `encryptedIndexes(t)` from `@cipherstash/stack-drizzle` derives the recommended indexes for every encrypted column in the table, or declare individual expression indexes in the schema DSL. See `stash-drizzle` § Indexing Encrypted Columns. - **Prisma Next** — since Prisma Next 0.17, `@@index(expression: "eql_v3.eq_term(email)", name: "users_email_eq", type: "btree")` declares a functional index directly in `schema.prisma`; the accompanying `ANALYZE` rides a raw-SQL migration operation. See `stash-prisma` § Indexing encrypted columns. - **Supabase** — a `supabase/migrations/` file; no superuser needed (see above). See `stash-supabase`. - **Raw SQL / plain PostgreSQL** — the recipes in this skill, in whatever migration tool owns the schema. Never ad-hoc in production. The predicates those indexes serve are in `stash-postgres`. ## When to Create Indexes During an Encryption Rollout - **Fresh encrypted column (new table or new field):** ship the `CREATE INDEX` in the **same migration** that adds the column. Every value written carries its terms from day one, so the index is correct from the first row. - **Encrypting an existing column** (the `stash encrypt` lifecycle): create the indexes **after `stash encrypt backfill` completes and before switching reads** to the encrypted column. Building after backfill is one bulk pass instead of per-row index maintenance across the whole backfill, and the reads you cut over to engage an index from the first query. Remember `ANALYZE` after the build. See `stash-encryption` § "Rolling Encryption Out to Production" for the full lifecycle. **These indexes do not survive an EQL reinstall or upgrade.** The install SQL begins with `DROP SCHEMA IF EXISTS eql_v3 CASCADE`, and every functional index on an extractor depends on that schema — so `stash eql upgrade` (or `eql install --force`, or re-applying the bundle by hand) cascade-drops all of them. Columns and data are untouched (the `public.eql_v3_*` domains deliberately don't depend on the `eql_v3` schema); only the indexes vanish, and queries fall back to sequential scans without erroring. After any EQL upgrade or reinstall, recreate them — migration runners skip already-applied migrations, so add a *new* migration re-issuing the `CREATE INDEX` statements (or run the DDL directly), `ANALYZE`, and confirm with the `EXPLAIN` checklist above. ## Reference - `stash-encryption` — the `types.*` domain catalog, wire-format operators and ordering, and the staged rollout lifecycle. - `stash-cli` — `stash eql install`, `stash eql verify` (is the installed operator/opclass surface complete and the ORE state coherent), `stash eql validate` (its "No functional index over `eql_v3.…`" Info finding is resolved by this skill), and `stash encrypt backfill` / `drop`. - `stash-drizzle`, `stash-supabase`, `stash-prisma` — per-integration query patterns; index DDL placement per the section above. - `stash-postgres` — the hand-written predicate forms these indexes serve (`pg` / `postgres-js`, no ORM). - `stash-edge` — the WASM entry, for apps whose queries run on Deno / Workers / Supabase Edge Functions.