diff --git a/.changeset/prisma-skill-psl-expression-indexes.md b/.changeset/prisma-skill-psl-expression-indexes.md new file mode 100644 index 000000000..92801a2c2 --- /dev/null +++ b/.changeset/prisma-skill-psl-expression-indexes.md @@ -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. diff --git a/skills/stash-indexing/SKILL.md b/skills/stash-indexing/SKILL.md index e64782418..21dce32c3 100644 --- a/skills/stash-indexing/SKILL.md +++ b/skills/stash-indexing/SKILL.md @@ -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`. diff --git a/skills/stash-prisma/SKILL.md b/skills/stash-prisma/SKILL.md index 382bbb59b..f556b22a2 100644 --- a/skills/stash-prisma/SKILL.md +++ b/skills/stash-prisma/SKILL.md @@ -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,