This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This project uses mise for task management. Common commands:
mise run build(alias:mise r b) - Build SQL into single release filemise run test(alias:mise r test) - Build, reset and run testsmise run postgres:up- Start PostgreSQL containermise run postgres:down- Stop PostgreSQL containersmise run reset- Reset database statemise run clean(alias:mise r k) - Clean release files
mise run docs:generate- Generate API documentation (requires doxygen)- Outputs XML (primary) and HTML (preview) formats
- XML suitable for downstream processing/website integration
- Output is written to
docs/api/(generated, gitignored)
mise run docs:generate:markdown- Convert XML to Markdown API reference- Generates single-file API reference:
docs/api/markdown/API.md(all documented functions with parameters, return values, and source links)
- Generates single-file API reference:
mise run docs:validate- Validate documentation coverage and tagsmise run docs:package- Package XML docs for distribution (~230KB archive)
- Run all tests:
mise run test - Run SQLx tests directly:
mise run test:sqlx - Run SQLx tests in watch mode:
mise run test:sqlx:watch - Tests are located in
tests/sqlx/using Rust and SQLx framework - Property-based tests for the
eql_v3encrypted scalar domains live in three suites — catalog (pure-Rust catalog invariants, no DB), fixture (oracle over committed ciphertext), and e2e (oracle over fresh end-to-end encryption, gated behind theproptest-e2ecargo feature). The structure, the shared all-pairs oracle engine, and the conventions/footguns (e.g. why they must not live underscalars::) are documented intests/sqlx/tests/encrypted_domain/property/README.md. - Verify the scalar matrix coverage snapshot:
mise run test:matrix:inventory(no database required). Committed token-normalized baselines undertests/sqlx/snapshots/pin the set ofscalars::<T>::*test names so a silently dropped/renamed/#[cfg]-gated test fails CI'smatrix-coveragejob. The task discovers the present scalar types from the test binary's--listand cross-checks them againstcargo run -p eql-codegen -- list-types, so a catalog type missing its matrix wiring also fails. When you change which matrix tests the macro emits, regenerate and commit the affected baseline in the same change.tests/sqlx/snapshots/README.mdis the source of truth for which baselines exist and how each is regenerated.
- Dependencies are resolved using
-- REQUIRE:comments in SQL files - Build outputs to
release/directory (a single installer + uninstaller, assembled fromsrc/v3alone):cipherstash-encrypt.sql- The sole installer: the self-containedeql_v3surface, globbed fromsrc/v3only (noeql_v2; installable into a DB with noeql_v2present)cipherstash-encrypt-uninstall.sql- Matching uninstaller
There are no longer separate Main / Supabase / Protect / v3-only build variants. The combined eql_v2 build that previously produced multiple artefacts has been removed; the v3 surface now ships as one self-contained installer under the canonical cipherstash-encrypt.sql name (eql-codegen order walks src/v3 only). Because the surface owns no eql_v2 dependency, it is already Supabase / managed-Postgres compatible (functional indexes over extractors, no superuser-only operator classes) without a dedicated subset build. Self-containment — no -- REQUIRE: edge pointing outside src/v3, no eql_v2.<symbol> anywhere in the surface — is enforced at build time by surface_order's OutsideSurface error (crates/eql-codegen/src/ordering.rs) and CI-gated by mise run test:self_contained_v3.
This is the Encrypt Query Language (EQL) - a PostgreSQL extension for searchable encryption. Key architectural components:
- Schema: EQL ships two PostgreSQL schemas.
eql_v3is the public API: the encrypted-domain type families (integer,smallint,bigint,date,timestamp,numeric,text,boolean,real,double), query operators, index extractors (eq_term/ord_term/match_term),min/maxaggregates,version(),lints(), and the operator-backing comparison wrappers (eq/neq/lt/lte/gt/gte/contains/contained_by, plus the jsonb containment helpersjsonb_contains/jsonb_contained_by/jsonb_array/ste_vec_contains). The wrappers are public because they are the function-form equivalent of every supported operator — platforms without operator support (Supabase/PostgREST invoke functions, not operators) call them by name (gated bytests/sqlx/tests/v3_operator_equivalents_tests.rs).eql_v3_internalholds INTERNAL objects only: the searchable-encrypted-metadata (SEM) index-term types (eql_v3_internal.hmac_256,eql_v3_internal.ore_block_256,eql_v3_internal.bloom_filter,eql_v3_internal.ope_cllw, hand-written undersrc/v3/sem/) and their support/constructor/comparator functions, the generated blockers (which only raise on unsupported ops), the aggregate state functions, and the SteVec CHECK validators. Splitting the index-term TYPES intoeql_v3_internalkeeps the Supabase Studio Table Builder type picker (which lists every type in every non-hidden schema) free of index-term-only types. Design decision: EQL never grants permissions automatically — the installer issues noGRANT/REVOKE; access to either schema is strictly opt-in (seedocs/reference/permissions.md). Together the two schemas are self-contained and install into a database with no other EQL schema present. The earliereql_v2schema (compositeeql_v2_encryptedcolumn type, database-side configuration management, operator-class-on-column indexing) was removed in 3.0.0 — see the[Unreleased]/3.0.0 entry inCHANGELOG.md.eql_v2is no longer built or shipped; it survives only in fork-provenance comments undersrc/v3/(the v3 SEM types were forked from the old v2 originals) and in historical records (CHANGELOG.md, the v2.x upgrade guides).
src/- contains only the self-containedv3surface (the modulareql_v2component directories were removed in 3.0.0)crates/- Rust workspace:eql-domains(the catalog),eql-codegen(SQL/bindings generator),eql-bindings(payload bindings),eql-tests-macrossrc/v3/- Self-containedeql_v3/eql_v3_internalsurface:src/v3/schema.sql(creates both schemas), forkedsrc/v3/crypto.sql/src/v3/common.sql, hand-written SEM index-term types undersrc/v3/sem/(hmac_256,ore_block_256) — now created ineql_v3_internal— the generated scalar encrypted-domain families undersrc/v3/scalars/<T>/(user-column domains inpublic; extractors and the supported comparison wrappers ineql_v3; only the blockers and aggregate state functions ineql_v3_internal; plus the shared blockersrc/v3/scalars/functions.sql), and the hand-written encrypted-JSONB (SteVec) surface undersrc/v3/json/(types.sql,functions.sql,operators.sql,aggregates.sql,blockers.sql— thepublic.eql_v3_json_search/public.eql_v3_json_entrycolumn domains and their CHECK validators live inpublic, while the containment needleeql_v3.query_json— a query operand, never a column type — lives ineql_v3; typed operators,json_entrycomparison wrappers, the containment engine (ste_vec_contains), and raw-jsonb GIN helpers (jsonb_array/jsonb_contains/jsonb_contained_by) live ineql_v3; only theis_ste_vec_arrayhelper and aggregate state functions live ineql_v3_internal)tasks/- mise task scriptstests/sqlx/- Rust/SQLx test framework (PostgreSQL 14-17 support)release/- Generated SQL installation files
- Dependency System: SQL files declare dependencies via
-- REQUIRE:comments - Encrypted Data: Stored as JSONB payloads with metadata
- Index Terms: SEM index-term types in
eql_v3_internal(hmac_256,ore_block_256,bloom_filter,ope_cllw) - Operators: Support comparisons between encrypted and plain JSONB data
- Encryption client: CipherStash Stack or CipherStash Proxy is required for encryption/decryption operations
src/v3/scalars/ holds the generated encrypted-domain type families — jsonb-backed PostgreSQL domains in the public schema, one domain per operator/index capability (public.<T> storage-only, public.<T>_eq, public.<T>_ord), plus an eql_v3.query_<T>_<cap> query-operand twin per term-bearing domain (index-terms-only, no ciphertext c; in eql_v3, not public, because a query operand is never a column type). The domains are public.eql_v3_integer, public.eql_v3_integer_eq, public.eql_v3_integer_ord, public.eql_v3_integer_ord_ore; their extractors (eql_v3.eq_term, eql_v3.ord_term), aggregates (eql_v3.min/max), and the supported comparison wrappers (eq/neq/lt/lte/gt/gte/contains/contained_by) all live in eql_v3 — the wrappers are public so every operator has a callable function equivalent (Supabase/PostgREST). Only the blockers (for unsupported operators — they just raise), the aggregate state functions, and the SEM index-term types the extractors/wrappers return and construct (eql_v3_internal.hmac_256, eql_v3_internal.ore_block_256) live in eql_v3_internal — hand-written under src/v3/sem/, schema-qualified via the codegen's INTERNAL_SCHEMA constant for the generated surface (the codegen's SCHEMA constant qualifies the public wrappers; the operator_entry renderer picks the backing function's schema by whether the operator is supported) — so the whole v3 surface (both schemas together) is self-contained (no eql_v2.<symbol> appears anywhere in v3 SQL; CI gates this via mise run test:self_contained_v3 and the self-contained release/cipherstash-encrypt.sql installer). public.eql_v3_integer (PR #239, supersedes #225) is the reference scalar implementation; the catalog now generates a full surface for integer, smallint, bigint, date, timestamp, numeric, text, boolean, real, and double, all following this materializer pattern. json is a CATALOG family too, but a mixed one: its three SteVec domains (public.eql_v3_json_search document, public.eql_v3_json_entry, eql_v3.query_json) carry Shape::SteVec (the Shape enum in crates/eql-domains/src/lib.rs has two variants, Scalar and SteVec; the three SteVec domains are disambiguated by Domain.name, not distinct variants) instead of Shape::Scalar, and their SQL lives permanently hand-written under src/v3/json/ rather than src/v3/scalars/<T>/. Alongside them the family carries one generated Shape::Scalar storage domain, public.eql_v3_json (bare name: "", ciphertext-only, no index terms — the JSON analogue of public.eql_v3_boolean), which the scalar materializer renders into src/v3/scalars/json/ like every other storage-only domain. eql-codegen's SQL/bindings drivers iterate families_with_scalar_domains() (families with ≥1 scalar domain) and render each family's scalar domains only — so a fully-scalar family is unchanged, json's scalar storage domain is generated, and its hand-written SteVec domains are skipped (the family stays non-scalar because is_scalar() is .all(), so scalar_families() still excludes it wholesale). The searchable SteVec surface is a deliberate, permanent hand-written split, not a gap awaiting a future generator; only the ciphertext-only storage domain is materialized.
Adding a scalar encrypted-domain type is one row in the Rust catalog eql-domains::CATALOG (crates/eql-domains/src/lib.rs): a DomainFamily giving the type name (e.g. bigint), its ScalarKind (the kind field), the Domains mapping each generated (bare) domain name to its fixed index Terms (eq => [Hm], ord/ord_ore => [Ore]), and the Fixture value list. Term capabilities are fixed in the Term enum's impl methods (with unit tests): Hm provides equality, and Ore provides equality plus ordering. There is no TOML manifest and no Python — the catalog is the source of truth, validated by the compiler (an undefined term or unknown scalar is a compile error) plus catalog #[test]s. mise run build runs cargo run -p eql-codegen, which regenerates the scalar SQL surface into src/v3/scalars/<T>/ from CATALOG at the start of every build; that surface includes supported comparison wrappers plus blockers for native json operators that would otherwise be reachable through domain fallback. cargo run -p eql-codegen regenerates every type at once (the same call mise run build uses; there is no per-type codegen task). The generated *_types.sql / *_functions.sql / *_operators.sql / *_aggregates.sql files are committed in place under src/v3/scalars/<T>/ and drift-gated by mise run codegen:parity (regenerate in place + git diff + untracked check — the same regenerate-and-diff pattern types:check uses for the committed bindings). They are still machine-generated: change the catalog and rebuild, never hand-edit (CI fails on drift). The per-type plaintext fixture lists the SQLx matrix consumes are not a generated file — they are materialised from each CATALOG row at compile time as eql_domains::INT4_VALUES / INT2_VALUES (the int_values! macro) and read directly by ScalarType::FIXTURE_VALUES; a Rust source of truth no longer round-trips through a committed generated .rs. Generated SQL carries a -- AUTOMATICALLY GENERATED FILE header (the project-wide marker docs:validate greps on); change the catalog and rebuild, never hand-edit. Hand-written SQL beyond the fixed surface goes in src/v3/scalars/<T>/<T>_extensions.sql with no auto-generated header and explicit -- REQUIRE: edges — that file IS committed. json's SteVec domains never enter this pipeline, but its scalar storage domain does: generate_all iterates eql_domains::families_with_scalar_domains() and renders each family's scalar domains (so public.eql_v3_json is generated into src/v3/scalars/json/), while list-types and the SQLx matrix still iterate eql_domains::scalar_families(), which excludes the whole non-scalar json family — so json stays out of the operator/coverage matrix by construction (a storage-only domain carries no operator matrix), even though its ciphertext-only storage domain now has a generated SQL surface.
The same generator also emits the Rust payload bindings under crates/eql-bindings/src/v3/<family>.rs (structs + DomainType impls) and the inventory.rs all() list, from the same CATALOG — committed with a // @generated header (the bindings must exist on a clean clone because ts-rs/schemars derive the committed TypeScript/JSON Schema off them; the SQL surface is now committed too, so both generated targets are handled consistently — committed in place, drift-gated by regenerate-and-git diff). The hand-written DomainType trait, the shared newtypes (SchemaVersion/Identifier/Ciphertext/Hmac256/OreBlock256/BloomFilter), the PhantomData plumbing, and the architectural module doc (including the non-derivable float-NaN and bool storage-only caveats) stay hand-written in crates/eql-bindings/src/v3/{mod,domain_type,terms}.rs. Generated structs carry a catalog-derived struct doc — a summary line (`eql_v3.<name>` — <capability>.) plus a detail line listing the supported operators and required payload keys, all derived from data the catalog already holds (the capability label, Term::operators_for_terms, and ENVELOPE_KEYS ++ Term::term_json_keys — see struct_doc_lines in crates/eql-codegen/src/bindings.rs). The required-key list makes structural distinctions visible — e.g. text_ord lists `v` `i` `c` `hm` `ob` (dual-term) versus an integer integer_ord's `v` `i` `c` `ob`. There are no per-field docs: per-field/term semantics live on the shared term newtypes (terms.rs, flowing into the TS term files and JSON Schema $defs), and non-derivable per-family caveats (float-NaN, bool storage-only) in mod.rs. Free-form prose belongs at the struct level (a future optional catalog doc field emitted as extra #[doc] lines), never as per-field docs. JSON Schemas are emitted by schemars 1.x as JSON Schema 2020-12. mise run types:generate regenerates the Rust bindings (via eql-codegen bindings) then the TS/JSON; mise run types:check is the committed-reference drift gate — it regenerates and git diffs all three (crates/eql-bindings/src/v3 + bindings/ + schema/), the same regenerate-and-diff pattern codegen:parity now uses for the committed SQL surface. Both gates run in CI; mise run install-hooks wires a pre-commit hook that runs both locally whenever a commit touches the catalog, the generator, or a committed generated surface. The json family's SteVec structs (SteVecDocument, SteVecEntry, SteVecQuery, and SteVecQueryEntry) are hand-written for the same reason as their SQL — in crates/eql-bindings/src/v3/json.rs, symmetric with src/v3/json/. Their direct optional fields and deny_unknown_fields validation keep the Rust, TypeScript, JSON Schema, and PostgreSQL entry contracts aligned. The family's scalar storage struct is generated (a flat {v,i,c} struct, Json), via render_scalar_only_bindings(&JSON) into crates/eql-bindings/src/v3/json_storage.rs; the hand-written json.rs re-exports it (pub use crate::v3::json_storage::Json;) so super::json::Json resolves for the generated inventory/payload. Only inventory membership and ordering are catalog-driven: inventory.rs::all() iterates the full CATALOG (not scalar_families()), branching on Shape to include the three SteVec structs plus the generated Json alongside the other scalar structs.
Adding a new encrypted-domain type: follow docs/reference/adding-a-scalar-encrypted-domain-type.md. The mechanics are fixed for ordered scalar domains; the catalog row only declares the name, kind, bare domain names, and terms. New term behavior belongs in the Term enum's impl methods in crates/eql-domains/src with tests, not in free-form catalog data.
Regeneration is deterministic: an identical CATALOG produces byte-identical SQL. If mise run build produces unexpected output, the change is in crates/eql-domains/src (the catalog/terms) or crates/eql-codegen/src (the renderers) — not in random run-to-run variation.
Committed generated code is never "in conflict" and never needs analysis — the generator is the source of truth. After anything that changes the catalog (a merge/rebase that pulls in catalog changes, a crates/eql-domains edit), the committed generated surfaces (src/v3/scalars/, crates/eql-bindings/{bindings,schema}, packages/eql/src/generated) are brought up to date by regenerating and committing — it is a deterministic no-op, not a merge to resolve:
cargo clean # only if a stale incremental cache makes eql-codegen misbehave (see below)
mise run types:generate && mise run typescript:generate # regenerate bindings
mise run build # regenerate SQL surface
git add -A && git commit
Do not run the drift gates (types:check / typescript:check / codegen:parity) to "diagnose" a stale generated tree — a failing gate after a catalog change trivially means "regenerate and commit," nothing more; run the gates only to confirm after committing. And if eql-codegen fails to compile against source that is byte-identical to a base that compiles (e.g. no method named … on a catalog type), it is a stale cargo incremental cache, not a semantic merge break — cargo clean fixes it; do not go forensic on the code.
Footguns the spec exists to prevent:
- Blockers must never be
STRICT. ASTRICTblocker lets PostgreSQL skip the body and returnNULLon aNULLargument, silently bypassing the "operator not supported" exception. - No domain-over-domain (
CREATE DOMAIN a AS b). Operators resolve against the ultimate base type (jsonb), so a derived domain does not inherit the base domain's operator surface — blockers stop engaging. - No operator class on a domain. Index through a functional index on the extractor (
eq_term/ord_term), whose return type already carries a default opclass. - Inlinable functions (extractors, comparison wrappers) need
LANGUAGE sql, a single-statementSELECT,IMMUTABLE, and noSETclause — a pinnedsearch_pathdisables inlining. No per-type allowlist edit: thepin_search_path.sqlstructural rule recognises encrypted-domain functions intrinsically andtasks/test/splinter.shcovers the converged extractor/wrapper names. - Blockers must be
LANGUAGE plpgsql, notLANGUAGE sql. The inverse of the rule above. A blocker exists to always raise, but aLANGUAGE sqlbody is inlinable and the planner can elide the call when the result is provably unused (deadCASEbranch, folded predicate).LANGUAGE plpgsqlis opaque to the planner, so the call — and itsRAISE— survives. The blocker renderers incrates/eql-codegen/srcenforce this; don't "simplify" the rendered blockers toLANGUAGE sqleven though the body is a single expression. - Build with
mise run clean && mise run build— a bare build can leave stalerelease/*.sql.
- Tests are written in Rust using SQLx, located in
tests/sqlx/ - Tests run against PostgreSQL 14, 15, 16, 17 using Docker containers
- Use
mise run test --postgres 14|15|16|17to test against a specific version - Container configuration in
tests/docker-compose.yml - Database connection:
localhost:7432(cipherstash/password)
EQL is searchable encryption; tests MUST use real ciphertexts/index terms from the actual crypto,
never hand-curated or synthetic blobs. Fixtures are generated by encrypting plaintext through
cipherstash-client: mise run test:sqlx:prep runs fixture:generate:all (the
generate_all_fixtures test, --features fixture-gen, over eql-domains::CATALOG) → gitignored
tests/sqlx/fixtures/eql_v3_*.sql.
- The SQLx suite requires CipherStash creds — ZeroKMS auth (
CS_CLIENT_ACCESS_KEY+CS_WORKSPACE_CRN) AND a client key (CS_CLIENT_ID+CS_CLIENT_KEY); see thetest:sqlx:prepcomment inmise.toml. CI has them. This is expected, not a reason to avoid generated fixtures. - Do NOT add static/committed fixtures to dodge the creds dependency. There are no committed
fixture exceptions: the jsonb SteVec document fixture
tests/sqlx/fixtures/v3_ste_vec.sqlis now generated through the sameFixtureSpecmachinery (tests/sqlx/src/fixtures/v3_ste_vec.rs) and gitignored/regenerated like every scalar fixture — it is not a committed blob to copy.
All SQL functions and types must be documented using Doxygen-style comments:
- Comment Style: Use
--!prefix for Doxygen comments (not--) - Required Tags:
@brief- Short description (required for all functions/files)@param- Parameter description (required for functions with parameters)@return- Return value description (required for functions with non-void returns)
- Optional Tags:
@throws- Exception conditions@note- Important notes or caveats@warning- Warning messages (e.g., for DDL-executing functions)@see- Cross-references to related functions@example- Usage examples@internal- Mark internal/private functions@file- File-level documentation
--! @brief Convert JSONB hex array to bytea array
--! @internal
--!
--! Converts a JSONB array of hex-encoded strings into a PostgreSQL bytea array.
--! Used for deserializing binary data (like ORE terms) from JSONB storage.
--!
--! @param val jsonb JSONB array of hex-encoded strings
--! @return bytea[] Array of decoded binary values
--!
--! @note Returns NULL if input is JSON null
CREATE FUNCTION ...(Adapted from a real block in src/v3/common.sql — use existing src/v3 files as the reference for style.)
Verify documentation quality:
# Using mise (recommended - validates coverage and tags)
mise run docs:validate
# Or run individual scripts directly
mise run docs:validate:coverage # Check 100% coverage
mise run docs:validate:required-tags # Verify @brief, @param, @return tags
mise run docs:validate:documented-sql # Validate SQL syntax (requires database)Template files (e.g., version.template) must be documented. The Doxygen comments are automatically included in generated files during build.
The documentation is generated in XML format as the primary output:
- Location:
docs/api/xml/ - Format: Doxygen XML (v1.15.0) with XSD schemas
- Usage: Machine-readable, suitable for downstream processing
- Publishing: Package with
mise run docs:package→ createseql-docs-xml-<version>.tar.gz - Integration: XML output ships with XSD schemas for downstream transformation
HTML output is also generated in docs/api/html/ for local preview only.
- SQL files are modular - put operator wrappers in
operators.sql, implementation infunctions.sql - All SQL files must have
-- REQUIRE:dependency declarations - Build system resolves dependency order with
cargo run -p eql-codegen -- order, which walks the wholesrc/v3surface once and topologically sorts it from the-- REQUIRE:edges. Dangling targets, edges leavingsrc/v3, and cycles all fail the build. - Documentation: All functions/types must have Doxygen comments (see Documentation Standards above)
Prefer LANGUAGE SQL over LANGUAGE plpgsql unless you need procedural features.
| Aspect | LANGUAGE SQL | LANGUAGE plpgsql |
|---|---|---|
| Inlining | ✅ Can be inlined by planner | ❌ Never inlined |
| Call overhead | Lower (can be optimized away) | Higher (context switch) |
| Index performance | Better for GIN index expressions | Worse |
| Control flow | CASE expression | IF/THEN/ELSE |
Why SQL wins for simple functions:
- Inlining - PostgreSQL can inline simple SQL functions into the calling query, eliminating function call overhead entirely. PL/pgSQL functions are never inlined.
- Index context - Functions used in index expressions (e.g.,
CREATE INDEX ... USING GIN (eql_v3.jsonb_array(col))) are called on every row insertion/update. Inlining matters. - Simple logic - A CASE expression is a single statement. PL/pgSQL's procedural features aren't needed.
When PL/pgSQL is appropriate:
- Multiple statements with intermediate variables
- Exception handling (
BEGIN...EXCEPTION...END) - Complex control flow (loops, early returns)
- Dynamic SQL (
EXECUTE) - Functions that must remain opaque to the planner — typically blockers whose only job is to
RAISE.LANGUAGE sqlwould be inlined and may be elided when the result is provably unused;LANGUAGE plpgsqlis never inlined, so the body always runs. See the encrypted-domain footgun list above and the blocker renderers incrates/eql-codegen/src.
EQL's release version and changelog are both owned by Changesets. @cipherstash/eql's package version is the single source of truth for the release identity V (SQL, the crate, and npm all ship at V — see docs/development/releasing.md), and the release changelog — packages/eql/CHANGELOG.md (Changesets writes per-package changelogs; with everything in lockstep at one version, that file covers the whole release) — is generated from the .changeset/*.md files you add, not hand-edited. The root CHANGELOG.md is the frozen pre-3.0 archive. Per-version upgrade guides live under docs/upgrading/. What follows is what to do when working in this repo.
Cutting a release is scripted — don't hand-roll gh release create, and don't hand-edit CHANGELOG.md. The single entrypoint is .github/workflows/release.yml:
- Final (non-prerelease) release — from
main. Changesets maintains a "Version Packages" PR that bumpsVand writes theCHANGELOG.mdsection from the pending changesets; merging that PR triggersrelease.ymlto publish npm, publish the crate (viarelease-plz.yml, which crates.io Trusted Publishing requires as the entry point), and build + attach theeql-<V>SQL + docs release. - Prerelease (alpha/beta/rc) — from
eql_v3, via an explicit conventional release commit (chore(release): ...) that already pins the prerelease version (Changesets pre-mode).release.ymlbuilds SQL + docs and publishes all language packages in one run. Validate on a scratch branch before cutting a real prerelease — a package publish is irreversible.
Full reference for both: docs/development/releasing.md.
The language binding packages are generated from the same eql-domains::CATALOG as the SQL surface: the eql-bindings crate (published to crates.io by release-plz, tagged eql-bindings-v<semver>) and the @cipherstash/eql npm package (published via npm Trusted Publishing from release.yml, tagged eql-typescript-v<identity>). Both bundle the exact self-contained SQL installer/uninstaller they were generated against (the crate exposes it as eql_bindings::sql; the npm package via its ./sql subpath exports). Prerelease release commits on eql_v3 carry the committed version and bundled SQL for the release; release.yml publishes that commit as the prerelease and then dispatches release-plz.yml for the crate. release-plz publishes the committed Cargo.toml version verbatim and has no absolute-version config, so the release commit must already carry the version pin.
If your PR adds, changes, removes, deprecates, or fixes anything observable to a caller — new function, new operator, behaviour change, error message change, performance characteristic that callers might notice (e.g. an index now engages), changed default — add a changeset in the same PR (pnpm changeset, or hand-write a .changeset/<name>.md). Do not edit CHANGELOG.md directly — Changesets assembles it from changeset files at release time.
Because SQL, the crate, and npm all release in lockstep at one version, every releasable change needs a changeset — including SQL-only or crate-only changes. See .changeset/README.md.
User-facing means: someone outside EQL would care. If in doubt, add the changeset; it's cheap.
What does not need an entry:
- Internal refactors that don't change observable behaviour
- Test-only changes
- CI / tooling-only changes
- Documentation typo fixes
- Doxygen comments
A changeset is a small markdown file: YAML frontmatter selecting the bump type, then the entry body.
---
'@cipherstash/eql': minor # patch | minor | major — see Versioning below
---
**Lead with the user-visible fact.** Then a short "Why." explanation. Match the tone
and density of existing entries — a single dense paragraph, not a bullet list.The body becomes the CHANGELOG.md entry; Changesets adds the version heading and the PR/commit link (don't add one by hand). Example body (adapted from 2.3.0):
=,<>,~~(LIKE),~~*(ILIKE) oneql_v2_encryptedare now inlinable SQL functions. The planner can structurally match these operators against the documented functional indexes (eql_v2.hmac_256(col)for equality,eql_v2.bloom_filter(col)forLIKE/ILIKE), so bare-form queries (WHERE col = $1) engage the index without per-query rewriting. Previously these operators wrapped multi-branch PL/pgSQL bodies that the planner could not inline, forcing seq scans on Supabase / managed Postgres installations that lack operator-class indexes.
If the change has behaviour callers should be aware of — even when no API breaks — add a numbered upgrade note (U-NNN) to the active docs/upgrading/v<version>.md file. Examples of what warrants an upgrade note:
- Recommended recipe shifts (e.g. opclass → functional indexes)
- Tightened error semantics (e.g. "raises now where it used to silently NULL")
- Required payload terms changing (e.g. equality requires
hm) - Anything where a caller might need to audit their schema or queries
The entry under Changed / Deprecated should cross-link to the U-NNN. See docs/upgrading/v2.3.md for the format — TL;DR, compatibility table, numbered notes, verification checklist, rollback.
The eql_v3 PostgreSQL schema name is part of the public API and is independent of the EQL release version: major-version bumps to EQL do not rename the schema. eql_v3 is not a rename of eql_v2 — it is a distinct surface (the encrypted-domain scalar type families). The earlier eql_v2 schema was removed in 3.0.0 (a major, public-API-breaking change); it is no longer shipped, and eql_v3 is the sole surface going forward. When deciding on a version bump:
- Patch (
2.3.x) — bug fixes, no behaviour changes - Minor (
2.x.0) — additive changes, behaviour changes that don't break the public API (signatures, schema name, payload format, operator names) - Major (
3.0.0) — only for changes that break the public API. Do not reach for a major bump just because a behaviour change has wide blast radius — that's what upgrade notes are for.
Declare the chosen bump in your changeset's frontmatter ('@cipherstash/eql': patch|minor|major). Changesets aggregates all pending changesets to compute the next V, which sync-lockstep-versions.mjs then propagates to the crate and the bundled SQL.
Releases are cut by Changesets, not by hand — see docs/development/releasing.md for the full runbook (production and prerelease). In short, for a final release from main:
- Ensure the work to release has merged to
main, each PR carrying a changeset. - Changesets maintains a "Version Packages" PR that bumps
Vand writes theCHANGELOG.mdsection from the pending changesets. Review it. - Merge that PR.
release.ymlthen publishes npm, dispatches the crate publish, and builds + attaches theeql-<V>SQL + docs release automatically.
Do not hand-edit CHANGELOG.md or create the GitHub release manually — Changesets and release.yml own all of it.