Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/prisma-skill-psl-expression-indexes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'stash': patch
---

Correct the bundled `stash-prisma` and `stash-indexing` skills for Prisma Next 0.17's functional-index support: `@@index` now takes an `expression` argument, so the `eql_v3.*` functional indexes are declared directly in `schema.prisma` (expression indexes require a `name` or `map`; `options` requires `type`) instead of hand-written raw-SQL migration operations. `rawSql` remains the home of the post-build `ANALYZE` and the fallback for DDL that PSL cannot carry.
2 changes: 1 addition & 1 deletion skills/stash-indexing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ Index not being used:
**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** — Prisma's schema language cannot express functional indexes; the DDL goes in a migration in the adapter's flow. See `stash-prisma`.
- **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`.

Expand Down
86 changes: 46 additions & 40 deletions skills/stash-prisma/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,64 +191,70 @@ Two things are Prisma-Next-specific:

The adapter emits the encrypted query operators, but **no index DDL** — without
functional indexes over the `eql_v3.*` extractors, every encrypted predicate
sequential-scans. Two facts shape where the DDL goes:

- **`schema.prisma` cannot express functional indexes** (`@@index` takes
fields, not expressions), so the schema file is not an option.
- Prisma Next migrations execute **raw SQL operations**, so an index migration
is just an operation whose statements are the `CREATE INDEX` recipes —
authored in the same migration history that installs the EQL bundle, applied
by the same `prisma-next migrate`. Never run index DDL out-of-band.
sequential-scans. Since Prisma Next 0.17, `@@index` takes an `expression`
argument, so the indexes are declared in `schema.prisma` next to the columns
they serve and ride the same `prisma-next migration plan` / `prisma-next
migrate` flow as everything else. Never run index DDL out-of-band.

One index per capability the column's domain carries:

```sql
-- cipherstash.TextEq / TextSearch: equality
CREATE INDEX users_email_eq ON users USING btree (eql_v3.eq_term(email));
-- cipherstash.*Ord / TextSearch: ordering + range (on numeric/date/timestamp
-- _ord domains this one index serves = too; TextOrd needs the eq_term index
-- above as well)
CREATE INDEX users_created_at_ord ON users USING btree (eql_v3.ord_term(created_at));
-- cipherstash.TextMatch / TextSearch: free-text match
CREATE INDEX users_bio_match ON users USING gin (eql_v3.match_term(bio));
-- cipherstash.Json: containment
CREATE INDEX users_profile_json
ON users USING gin ((eql_v3.to_ste_vec_query(profile)::jsonb) jsonb_path_ops);

ANALYZE users;
```prisma
model User {
// ... fields, including the encrypted columns ...

// cipherstash.TextEq / TextSearch: equality
@@index(expression: "eql_v3.eq_term(email)", name: "users_email_eq", type: "btree")
// cipherstash.*Ord / TextSearch: ordering + range (on numeric/date/timestamp
// _ord domains this one index serves = too; TextOrd needs the eq_term index
// above as well)
@@index(expression: "eql_v3.ord_term(created_at)", name: "users_created_at_ord", type: "btree")
// cipherstash.TextMatch / TextSearch: free-text match
@@index(expression: "eql_v3.match_term(bio)", name: "users_bio_match", type: "gin")
// cipherstash.Json: containment
@@index(expression: "(eql_v3.to_ste_vec_query(profile)::jsonb) jsonb_path_ops", name: "users_profile_json", type: "gin")
}
```

The `ANALYZE` is part of the recipe — an expression index has no statistics
until it runs. Works as a non-superuser role (Supabase included); only the
ORE-flavour (`_ord_ore`) ordering opclass is superuser-gated. For the full
model — which domains take which index, engagement rules, `EXPLAIN`
verification, rollout timing — see the `stash-indexing` skill. For encrypted
predicates written as raw SQL rather than through the `cipherstash:*`
operators — operand casts to `eql_v3.query_*`, per-driver parameter binding —
see the `stash-postgres` skill.
Three rules the interpreter enforces: an `@@index` takes exactly one of a
fields list or an `expression`; an expression index **requires `name` or
`map`** (no default name can be derived from an expression); and an `options`
argument requires `type`. The expression string is the entire element list
between the parens of `CREATE INDEX`, inserted verbatim — which is why the
Json recipe carries its own parens and the `jsonb_path_ops` opclass. TS-authored
contracts have the same surface: `index({ expression, name, type })` alongside
the column factories.

In a migration, the recipes ride a raw-SQL operation (`rawSql` from
`@prisma/orm-postgres/migration`) in the migration's `operations`:
`ANALYZE` is still part of the recipe — an expression index has no statistics
until it runs, and PSL cannot express it — so it rides a raw-SQL operation
(`rawSql` from `@prisma/orm-postgres/migration`) in the migration that
introduces the indexes:

```typescript
rawSql({
id: 'index.users.encrypted',
label: 'Index encrypted columns on users',
id: 'analyze.users',
label: 'Refresh statistics for the new expression indexes',
operationClass: 'additive',
target: {
id: 'postgres',
details: { schema: 'public', objectType: 'index', name: 'users_email_eq', table: 'users' },
details: { schema: 'public', objectType: 'table', name: 'users' },
},
precheck: [],
execute: [
{ description: 'equality index',
sql: 'CREATE INDEX IF NOT EXISTS users_email_eq ON "public"."users" USING btree (eql_v3.eq_term(email))' },
{ description: 'refresh statistics', sql: 'ANALYZE "public"."users"' },
],
execute: [{ description: 'refresh statistics', sql: 'ANALYZE "public"."users"' }],
postcheck: [],
})
```

(`rawSql` also remains the fallback for index DDL itself if you need something
PSL doesn't carry — `CREATE INDEX CONCURRENTLY`, for instance.)

Everything above works as a non-superuser role (Supabase included); only the
ORE-flavour (`_ord_ore`) ordering opclass is superuser-gated. For the full
model — which domains take which index, engagement rules, `EXPLAIN`
verification, rollout timing — see the `stash-indexing` skill. For encrypted
predicates written as raw SQL rather than through the `cipherstash:*`
operators — operand casts to `eql_v3.query_*`, per-driver parameter binding —
see the `stash-postgres` skill.

## Writing and reading encrypted values

At the value boundary you wrap plaintext in a **runtime envelope** (primitive-named,
Expand Down
Loading