diff --git a/README.md b/README.md
index 4868de45..eab214c3 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,8 @@
CipherStash Stack for TypeScript
+ Data-level access control for TypeScript.
Every sensitive value encrypted with a unique key. Searchable on existing Postgres indexes.
A breach yields ciphertext, nothing useful.
+
@@ -13,7 +15,9 @@
## What is the stack?
-- [Encryption](https://cipherstash.com/docs/stack/cipherstash/encryption): Field-level encryption for TypeScript apps with searchable encrypted queries, zero-knowledge key management, and first-class ORM support.
+CipherStash makes access control cryptographic. The rules aren't configured — they're enforced. Every sensitive value carries a decryption policy that travels with the data, wherever it ends up: past the API response, past an agent tool call, past the database. The stack is the TypeScript surface to that model.
+
+- [Encryption](https://cipherstash.com/docs/stack/cipherstash/encryption): Searchable, application-layer field-level encryption for TypeScript apps. Range queries, exact match, and free-text fuzzy search over encrypted fields with sub-millisecond overhead on existing Postgres indexes. Identity-bound keys via `LockContext`. First-class ORM support.
## Quick look at the stack in action
@@ -79,8 +83,10 @@ bun add @cipherstash/stack
## Use cases
-- **Trusted data access**: ensure only your end-users can access their sensitive data using identity-bound encryption
-- **Reduce breach impact**: limit the blast radius of exploited vulnerabilities to only the data the affected user can decrypt
+- **A breach yields ciphertext, nothing useful** — limit the blast radius of compromised credentials and exploited vulnerabilities to the data the attacker's identity can decrypt.
+- **Per-value access policy** — enforce who can decrypt what, wherever the data ends up.
+- **Agent-safe by design** — sensitive values stay encrypted through agent tool calls and downstream pipelines until the right identity asks for them.
+- **Faster, simpler, and more reliable than row-level security** — the policy travels with the data, not the database connection.
## Documentation
diff --git a/packages/stack/README.md b/packages/stack/README.md
index a1d0fb8f..3efb8e02 100644
--- a/packages/stack/README.md
+++ b/packages/stack/README.md
@@ -1,6 +1,6 @@
# @cipherstash/stack
-The all-in-one TypeScript SDK for the CipherStash data security stack.
+**Data-level access control for TypeScript.** Every sensitive value encrypted with a unique key. Identity-bound, searchable, and built into your existing Postgres stack.
[](https://www.npmjs.com/package/@cipherstash/stack)
[](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md)
@@ -8,6 +8,12 @@ The all-in-one TypeScript SDK for the CipherStash data security stack.
--
+CipherStash makes access control cryptographic. The rules aren't configured — they're enforced. Every value is encrypted under a unique key with identity and policy baked in, decryption enforced at the moment of read. A breach yields ciphertext.
+
+This SDK is the TypeScript surface: searchable field-level encryption, identity-bound keys via `LockContext`, bulk operations against [ZeroKMS](https://cipherstash.com/products/zerokms), and first-class integrations for Drizzle, Supabase, Prisma, and DynamoDB.
+
+--
+
## Table of Contents
- [Install](#install)
diff --git a/packages/stack/__tests__/prisma-batcher.test.ts b/packages/stack/__tests__/prisma-batcher.test.ts
new file mode 100644
index 00000000..8f2a3311
--- /dev/null
+++ b/packages/stack/__tests__/prisma-batcher.test.ts
@@ -0,0 +1,81 @@
+import { createBatcher } from '@/prisma/core/batcher'
+import { describe, expect, it, vi } from 'vitest'
+
+describe('createBatcher', () => {
+ it('coalesces synchronous enqueues into a single flush call', async () => {
+ const flush = vi.fn(async (values: readonly number[]) =>
+ values.map((v) => v * 2),
+ )
+ const batcher = createBatcher(flush)
+
+ // This is the shape of `Promise.all(values.map(codec.encode))` —
+ // every enqueue runs synchronously before the first microtask fires.
+ const results = await Promise.all([
+ batcher.enqueue(1),
+ batcher.enqueue(2),
+ batcher.enqueue(3),
+ batcher.enqueue(4),
+ ])
+
+ expect(flush).toHaveBeenCalledTimes(1)
+ expect(flush.mock.calls[0]?.[0]).toEqual([1, 2, 3, 4])
+ expect(results).toEqual([2, 4, 6, 8])
+ })
+
+ it('starts a fresh batch after the previous drain resolves', async () => {
+ const flush = vi.fn(async (values: readonly string[]) => values.slice())
+ const batcher = createBatcher(flush)
+
+ await Promise.all([batcher.enqueue('a'), batcher.enqueue('b')])
+ await Promise.all([batcher.enqueue('c'), batcher.enqueue('d')])
+
+ expect(flush).toHaveBeenCalledTimes(2)
+ expect(flush.mock.calls[0]?.[0]).toEqual(['a', 'b'])
+ expect(flush.mock.calls[1]?.[0]).toEqual(['c', 'd'])
+ })
+
+ it('rejects every queued promise when the flush throws', async () => {
+ const error = new Error('flush failure')
+ const batcher = createBatcher(async () => {
+ throw error
+ })
+
+ const results = await Promise.allSettled([
+ batcher.enqueue(1),
+ batcher.enqueue(2),
+ ])
+
+ expect(results).toEqual([
+ { status: 'rejected', reason: error },
+ { status: 'rejected', reason: error },
+ ])
+ })
+
+ it('rejects every queued promise when the flush returns the wrong number of results', async () => {
+ const batcher = createBatcher(async () => [99]) // length mismatch
+
+ const results = await Promise.allSettled([
+ batcher.enqueue(1),
+ batcher.enqueue(2),
+ ])
+
+ expect(results.every((r) => r.status === 'rejected')).toBe(true)
+ })
+
+ it('preserves insertion order across all queued entries', async () => {
+ const flush = vi.fn(async (values: readonly string[]) => values.slice())
+ const batcher = createBatcher(flush)
+
+ const results = await Promise.all([
+ batcher.enqueue('a'),
+ batcher.enqueue('b'),
+ batcher.enqueue('c'),
+ batcher.enqueue('d'),
+ batcher.enqueue('e'),
+ ])
+
+ expect(flush).toHaveBeenCalledTimes(1)
+ expect(flush.mock.calls[0]?.[0]).toEqual(['a', 'b', 'c', 'd', 'e'])
+ expect(results).toEqual(['a', 'b', 'c', 'd', 'e'])
+ })
+})
diff --git a/packages/stack/__tests__/prisma-codec-errors.test.ts b/packages/stack/__tests__/prisma-codec-errors.test.ts
new file mode 100644
index 00000000..2074a491
--- /dev/null
+++ b/packages/stack/__tests__/prisma-codec-errors.test.ts
@@ -0,0 +1,288 @@
+import { createEncryptedEqTermCodec } from '@/prisma/core/codec-eq-term'
+import { createEncryptedMatchTermCodec } from '@/prisma/core/codec-match-term'
+import { createEncryptedOreTermCodec } from '@/prisma/core/codec-ore-term'
+import { createEncryptedSteVecSelectorCodec } from '@/prisma/core/codec-ste-vec-term'
+import { createEncryptedStorageCodec } from '@/prisma/core/codec-storage'
+import {
+ CipherStashCodecError,
+ assertJsTypeMatchesDataType,
+ inferJsDataType,
+} from '@/prisma/core/errors'
+import { describe, expect, it } from 'vitest'
+import {
+ createMockEncryptionClient,
+ createTestCodecContext,
+} from './prisma-test-helpers'
+
+/**
+ * Encode-time JS-runtime guards.
+ *
+ * Unsupported JS types (`bigint`, `symbol`, `function`) raise
+ * `UNSUPPORTED_PLAINTEXT_TYPE`. Mismatches between the value's runtime
+ * type and an explicitly-supplied expected `dataType` raise
+ * `JS_TYPE_MISMATCH`.
+ */
+
+describe('inferJsDataType', () => {
+ it('maps each supported JS type to the corresponding EncryptedDataType', () => {
+ expect(inferJsDataType('a')).toBe('string')
+ expect(inferJsDataType(42)).toBe('number')
+ expect(inferJsDataType(true)).toBe('boolean')
+ expect(inferJsDataType(new Date())).toBe('date')
+ expect(inferJsDataType({ a: 1 })).toBe('json')
+ expect(inferJsDataType([1, 2])).toBe('json')
+ })
+
+ it('returns undefined for unsupported runtime types', () => {
+ expect(inferJsDataType(1n)).toBeUndefined()
+ expect(inferJsDataType(Symbol('s'))).toBeUndefined()
+ expect(inferJsDataType(() => 0)).toBeUndefined()
+ expect(inferJsDataType(undefined)).toBeUndefined()
+ // null is `typeof === 'object'` so this looks like a supported
+ // type but isn't useful in practice — null is filtered upstream.
+ expect(inferJsDataType(null)).toBe('json')
+ })
+})
+
+describe('assertJsTypeMatchesDataType', () => {
+ it('returns the JS-derived dataType for supported values', () => {
+ expect(assertJsTypeMatchesDataType('a', undefined)).toBe('string')
+ expect(assertJsTypeMatchesDataType(42, undefined)).toBe('number')
+ expect(assertJsTypeMatchesDataType(true, undefined)).toBe('boolean')
+ expect(assertJsTypeMatchesDataType(new Date(), undefined)).toBe('date')
+ expect(assertJsTypeMatchesDataType({ a: 1 }, undefined)).toBe('json')
+ })
+
+ it('throws UNSUPPORTED_PLAINTEXT_TYPE for out-of-set JS types', () => {
+ let err: unknown
+ try {
+ assertJsTypeMatchesDataType(1n, undefined)
+ } catch (e) {
+ err = e
+ }
+ expect(err).toBeInstanceOf(CipherStashCodecError)
+ if (err instanceof CipherStashCodecError) {
+ expect(err.code).toBe('UNSUPPORTED_PLAINTEXT_TYPE')
+ expect(err.actualType).toBe('bigint')
+ expect(err.expectedDataType).toBeUndefined()
+ }
+ })
+
+ it('throws JS_TYPE_MISMATCH when JS type does not match the expected dataType', () => {
+ let err: unknown
+ try {
+ assertJsTypeMatchesDataType(42, 'string', 'email')
+ } catch (e) {
+ err = e
+ }
+ expect(err).toBeInstanceOf(CipherStashCodecError)
+ if (err instanceof CipherStashCodecError) {
+ expect(err.code).toBe('JS_TYPE_MISMATCH')
+ expect(err.column).toBe('email')
+ expect(err.expectedDataType).toBe('string')
+ expect(err.actualType).toBe('number')
+ expect(err.message).toContain('email')
+ expect(err.message).toContain('string')
+ expect(err.message).toContain('number')
+ }
+ })
+
+ it('accepts a matching expected dataType', () => {
+ expect(assertJsTypeMatchesDataType('a', 'string', 'email')).toBe('string')
+ expect(assertJsTypeMatchesDataType(42, 'number', 'age')).toBe('number')
+ expect(assertJsTypeMatchesDataType(true, 'boolean', 'isActive')).toBe(
+ 'boolean',
+ )
+ expect(assertJsTypeMatchesDataType(new Date(), 'date', 'createdAt')).toBe(
+ 'date',
+ )
+ expect(assertJsTypeMatchesDataType({}, 'json', 'profile')).toBe('json')
+ })
+})
+
+// =============================================================================
+// Codec-level encode-time guard behavior
+// =============================================================================
+
+describe('encryptedStorageCodec encode guard', () => {
+ it('rejects bigint plaintexts with a structured CipherStashCodecError', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ let err: unknown
+ try {
+ await codec.encode(1n)
+ } catch (e) {
+ err = e
+ }
+ expect(err).toBeInstanceOf(CipherStashCodecError)
+ if (err instanceof CipherStashCodecError) {
+ expect(err.code).toBe('UNSUPPORTED_PLAINTEXT_TYPE')
+ expect(err.actualType).toBe('bigint')
+ }
+ })
+
+ it('rejects symbol plaintexts with a structured CipherStashCodecError', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ let err: unknown
+ try {
+ await codec.encode(Symbol('s'))
+ } catch (e) {
+ err = e
+ }
+ expect(err).toBeInstanceOf(CipherStashCodecError)
+ if (err instanceof CipherStashCodecError) {
+ expect(err.code).toBe('UNSUPPORTED_PLAINTEXT_TYPE')
+ expect(err.actualType).toBe('symbol')
+ }
+ })
+
+ it('rejects function plaintexts with a structured CipherStashCodecError', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ await expect(codec.encode(() => 0)).rejects.toBeInstanceOf(
+ CipherStashCodecError,
+ )
+ })
+
+ it('accepts every supported JS type and produces a valid wire string', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ const cases: ReadonlyArray = [
+ 'string-value',
+ 42,
+ true,
+ new Date('2026-01-01T00:00:00.000Z'),
+ { name: 'Alice' },
+ ]
+ for (const v of cases) {
+ const wire = await codec.encode(v)
+ expect(typeof wire).toBe('string')
+ expect(wire.startsWith('(')).toBe(true)
+ expect(wire.endsWith(')')).toBe(true)
+ }
+ })
+})
+
+describe('encryptedMatchTermCodec encode guard', () => {
+ it('rejects non-string plaintexts with JS_TYPE_MISMATCH', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedMatchTermCodec(ctx)
+
+ let err: unknown
+ try {
+ await codec.encode(42 as unknown as string)
+ } catch (e) {
+ err = e
+ }
+ expect(err).toBeInstanceOf(CipherStashCodecError)
+ if (err instanceof CipherStashCodecError) {
+ expect(err.code).toBe('JS_TYPE_MISMATCH')
+ expect(err.expectedDataType).toBe('string')
+ expect(err.actualType).toBe('number')
+ }
+ })
+
+ it('accepts string plaintexts', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedMatchTermCodec(ctx)
+ const wire = await codec.encode('x')
+ expect(wire.startsWith('(')).toBe(true)
+ })
+})
+
+describe('encryptedOreTermCodec encode guard', () => {
+ it('rejects string plaintexts with JS_TYPE_MISMATCH', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedOreTermCodec(ctx)
+
+ let err: unknown
+ try {
+ await codec.encode('not-a-number' as unknown as number)
+ } catch (e) {
+ err = e
+ }
+ expect(err).toBeInstanceOf(CipherStashCodecError)
+ if (err instanceof CipherStashCodecError) {
+ expect(err.code).toBe('JS_TYPE_MISMATCH')
+ expect(err.expectedDataType).toBe('number')
+ expect(err.actualType).toBe('string')
+ }
+ })
+
+ it('rejects boolean plaintexts with JS_TYPE_MISMATCH', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedOreTermCodec(ctx)
+
+ let err: unknown
+ try {
+ await codec.encode(true as unknown as number)
+ } catch (e) {
+ err = e
+ }
+ expect(err).toBeInstanceOf(CipherStashCodecError)
+ if (err instanceof CipherStashCodecError) {
+ expect(err.code).toBe('JS_TYPE_MISMATCH')
+ expect(err.actualType).toBe('boolean')
+ }
+ })
+
+ it('accepts numbers and Dates', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedOreTermCodec(ctx)
+
+ const w1 = await codec.encode(42)
+ const w2 = await codec.encode(new Date('2026-01-01'))
+ expect(w1.startsWith('(')).toBe(true)
+ expect(w2.startsWith('(')).toBe(true)
+ })
+})
+
+describe('encryptedSteVecSelectorCodec encode guard', () => {
+ it('rejects non-string plaintexts with JS_TYPE_MISMATCH', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedSteVecSelectorCodec(ctx)
+
+ let err: unknown
+ try {
+ await codec.encode({ a: 1 } as unknown as string)
+ } catch (e) {
+ err = e
+ }
+ expect(err).toBeInstanceOf(CipherStashCodecError)
+ if (err instanceof CipherStashCodecError) {
+ expect(err.code).toBe('JS_TYPE_MISMATCH')
+ expect(err.expectedDataType).toBe('string')
+ }
+ })
+})
+
+describe('encryptedEqTermCodec encode guard', () => {
+ it('rejects bigint plaintexts with UNSUPPORTED_PLAINTEXT_TYPE', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedEqTermCodec(ctx)
+
+ let err: unknown
+ try {
+ await codec.encode(1n as unknown as string)
+ } catch (e) {
+ err = e
+ }
+ expect(err).toBeInstanceOf(CipherStashCodecError)
+ })
+})
diff --git a/packages/stack/__tests__/prisma-codec-pg.test.ts b/packages/stack/__tests__/prisma-codec-pg.test.ts
new file mode 100644
index 00000000..2a1674e8
--- /dev/null
+++ b/packages/stack/__tests__/prisma-codec-pg.test.ts
@@ -0,0 +1,349 @@
+/**
+ * Integration tests for the Prisma Next codec against a real Postgres
+ * instance with the EQL extension installed and a real `EncryptionClient`
+ * (ZeroKMS-backed).
+ *
+ * Required environment:
+ * - `DATABASE_URL` to a Postgres instance with EQL pre-installed
+ * (CipherStash CLI: `cipherstash install`).
+ * - `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY` for the
+ * `EncryptionClient` to authenticate against ZeroKMS.
+ *
+ * Without those env vars the suite is skipped (it does not throw at
+ * import time, so the unit-test suite can run the file freely).
+ *
+ * Run command:
+ * pnpm --filter @cipherstash/stack vitest run __tests__/prisma-codec-pg.test.ts
+ */
+
+import 'dotenv/config'
+import { Encryption, type EncryptionClient } from '@/encryption'
+import type { CipherStashCodecContext } from '@/prisma/core/codec-context'
+import { createEncryptedEqTermCodec } from '@/prisma/core/codec-eq-term'
+import { createEncryptedMatchTermCodec } from '@/prisma/core/codec-match-term'
+import { createEncryptedOreTermCodec } from '@/prisma/core/codec-ore-term'
+import { createEncryptedSteVecSelectorCodec } from '@/prisma/core/codec-ste-vec-term'
+import { createEncryptedStorageCodec } from '@/prisma/core/codec-storage'
+import { createEncryptionBinding } from '@/prisma/core/encryption-client'
+import { encryptedColumn, encryptedTable } from '@/schema'
+import postgres from 'postgres'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+
+const DATABASE_URL = process.env.DATABASE_URL
+const HAS_KMS_CREDS =
+ !!process.env.CS_WORKSPACE_CRN &&
+ !!process.env.CS_CLIENT_ID &&
+ !!process.env.CS_CLIENT_KEY
+
+const isReady = !!DATABASE_URL && HAS_KMS_CREDS
+const describeIntegration = isReady ? describe : describe.skip
+
+function buildContext(
+ client: EncryptionClient,
+ // biome-ignore lint/suspicious/noExplicitAny: integration test bridges schema generic types across the encryption-client / codec boundary
+ schemas: ReadonlyArray,
+): CipherStashCodecContext {
+ const binding = createEncryptionBinding({ client, schemas })
+ return {
+ binding,
+ emit: () => {
+ // no-op for integration tests
+ },
+ }
+}
+
+describeIntegration(
+ 'codec integration (Postgres + ZeroKMS): equality round-trip',
+ () => {
+ const usersSchema = encryptedTable('protect_prisma_users', {
+ email: encryptedColumn('email').equality(),
+ })
+
+ const TABLE_NAME = `protect_prisma_users_${Date.now()}`
+
+ let sql: ReturnType
+ let storageCodec: ReturnType
+ let eqTermCodec: ReturnType
+
+ beforeAll(async () => {
+ if (!DATABASE_URL) return
+ sql = postgres(DATABASE_URL, { prepare: false })
+ await sql`CREATE TABLE IF NOT EXISTS ${sql(TABLE_NAME)} (
+ id serial PRIMARY KEY,
+ email eql_v2_encrypted NOT NULL
+ )`
+
+ const client = await Encryption({ schemas: [usersSchema] })
+ const ctx = buildContext(client, [usersSchema])
+ storageCodec = createEncryptedStorageCodec(ctx)
+ eqTermCodec = createEncryptedEqTermCodec(ctx)
+ })
+
+ afterAll(async () => {
+ if (sql) {
+ await sql`DROP TABLE IF EXISTS ${sql(TABLE_NAME)}`
+ await sql.end()
+ }
+ })
+
+ it('round-trips a 4-row insert through encode -> SELECT -> decode in one ZeroKMS encrypt batch', async () => {
+ const inputs = [
+ 'alice@example.com',
+ 'bob@example.com',
+ 'carol@example.com',
+ 'dave@example.com',
+ ]
+
+ const wires = await Promise.all(inputs.map((v) => storageCodec.encode(v)))
+
+ for (const wire of wires) {
+ await sql`INSERT INTO ${sql(TABLE_NAME)} (email) VALUES (${wire}::eql_v2_encrypted)`
+ }
+
+ const matchWire = await eqTermCodec.encode('carol@example.com')
+ const rows = await sql`
+ SELECT email FROM ${sql(TABLE_NAME)}
+ WHERE eql_v2.eq(email, ${matchWire}::eql_v2_encrypted)
+ `
+
+ expect(rows).toHaveLength(1)
+ const wireBack = rows[0]?.email
+ expect(typeof wireBack).toBe('string')
+ const decoded = await storageCodec.decode(String(wireBack))
+ expect(decoded).toBe('carol@example.com')
+ })
+ },
+)
+
+describeIntegration('codec integration: ORE range queries on numbers', () => {
+ const numbersSchema = encryptedTable('protect_prisma_numbers', {
+ score: encryptedColumn('score').dataType('number').orderAndRange(),
+ })
+ const TABLE_NAME = `protect_prisma_numbers_${Date.now()}`
+
+ let sql: ReturnType
+ let storageCodec: ReturnType
+ let oreTermCodec: ReturnType
+
+ beforeAll(async () => {
+ if (!DATABASE_URL) return
+ sql = postgres(DATABASE_URL, { prepare: false })
+ await sql`CREATE TABLE IF NOT EXISTS ${sql(TABLE_NAME)} (
+ id serial PRIMARY KEY,
+ score eql_v2_encrypted NOT NULL
+ )`
+ const client = await Encryption({ schemas: [numbersSchema] })
+ const ctx = buildContext(client, [numbersSchema])
+ storageCodec = createEncryptedStorageCodec(ctx)
+ oreTermCodec = createEncryptedOreTermCodec(ctx)
+ })
+
+ afterAll(async () => {
+ if (sql) {
+ await sql`DROP TABLE IF EXISTS ${sql(TABLE_NAME)}`
+ await sql.end()
+ }
+ })
+
+ it('round-trips gt / gte / lt / lte / between on encrypted numbers', async () => {
+ const values = [10, 20, 30, 40, 50]
+ const wires = await Promise.all(values.map((v) => storageCodec.encode(v)))
+ for (const wire of wires) {
+ await sql`INSERT INTO ${sql(TABLE_NAME)} (score) VALUES (${wire}::eql_v2_encrypted)`
+ }
+
+ const gteTerm = await oreTermCodec.encode(30)
+ const gteRows = await sql`
+ SELECT score FROM ${sql(TABLE_NAME)}
+ WHERE eql_v2.gte(score, ${gteTerm}::eql_v2_encrypted)
+ `
+ const gteDecoded = await Promise.all(
+ gteRows.map((r) => storageCodec.decode(String(r.score))),
+ )
+ expect((gteDecoded as number[]).sort((a, b) => a - b)).toEqual([30, 40, 50])
+
+ const [minTerm, maxTerm] = await Promise.all([
+ oreTermCodec.encode(20),
+ oreTermCodec.encode(40),
+ ])
+ const betweenRows = await sql`
+ SELECT score FROM ${sql(TABLE_NAME)}
+ WHERE eql_v2.gte(score, ${minTerm}::eql_v2_encrypted)
+ AND eql_v2.lte(score, ${maxTerm}::eql_v2_encrypted)
+ `
+ const betweenDecoded = await Promise.all(
+ betweenRows.map((r) => storageCodec.decode(String(r.score))),
+ )
+ expect((betweenDecoded as number[]).sort((a, b) => a - b)).toEqual([
+ 20, 30, 40,
+ ])
+ })
+})
+
+describeIntegration('codec integration: free-text search on strings', () => {
+ const usersSchema = encryptedTable('protect_prisma_users_text', {
+ email: encryptedColumn('email').freeTextSearch(),
+ })
+ const TABLE_NAME = `protect_prisma_text_${Date.now()}`
+
+ let sql: ReturnType
+ let storageCodec: ReturnType
+ let matchTermCodec: ReturnType
+
+ beforeAll(async () => {
+ if (!DATABASE_URL) return
+ sql = postgres(DATABASE_URL, { prepare: false })
+ await sql`CREATE TABLE IF NOT EXISTS ${sql(TABLE_NAME)} (
+ id serial PRIMARY KEY,
+ email eql_v2_encrypted NOT NULL
+ )`
+ const client = await Encryption({ schemas: [usersSchema] })
+ const ctx = buildContext(client, [usersSchema])
+ storageCodec = createEncryptedStorageCodec(ctx)
+ matchTermCodec = createEncryptedMatchTermCodec(ctx)
+ })
+
+ afterAll(async () => {
+ if (sql) {
+ await sql`DROP TABLE IF EXISTS ${sql(TABLE_NAME)}`
+ await sql.end()
+ }
+ })
+
+ it('round-trips ilike against an encrypted match index', async () => {
+ const inputs = [
+ 'alice@example.com',
+ 'bob@other.org',
+ 'carol@example.com',
+ 'dave@example.org',
+ ]
+ const wires = await Promise.all(inputs.map((v) => storageCodec.encode(v)))
+ for (const wire of wires) {
+ await sql`INSERT INTO ${sql(TABLE_NAME)} (email) VALUES (${wire}::eql_v2_encrypted)`
+ }
+
+ const matchTerm = await matchTermCodec.encode('example.com')
+ const rows = await sql`
+ SELECT email FROM ${sql(TABLE_NAME)}
+ WHERE eql_v2.ilike(email, ${matchTerm}::eql_v2_encrypted)
+ `
+
+ const decoded = (await Promise.all(
+ rows.map((r) => storageCodec.decode(String(r.email))),
+ )) as string[]
+ expect(decoded.sort()).toEqual(['alice@example.com', 'carol@example.com'])
+ })
+})
+
+describeIntegration(
+ 'codec integration: Date round-trip via SDK cast_as',
+ () => {
+ const datesSchema = encryptedTable('protect_prisma_dates', {
+ created_at: encryptedColumn('created_at')
+ .dataType('date')
+ .orderAndRange(),
+ })
+ const TABLE_NAME = `protect_prisma_dates_${Date.now()}`
+
+ let sql: ReturnType
+ let storageCodec: ReturnType
+ let oreTermCodec: ReturnType
+
+ beforeAll(async () => {
+ if (!DATABASE_URL) return
+ sql = postgres(DATABASE_URL, { prepare: false })
+ await sql`CREATE TABLE IF NOT EXISTS ${sql(TABLE_NAME)} (
+ id serial PRIMARY KEY,
+ created_at eql_v2_encrypted NOT NULL
+ )`
+ const client = await Encryption({ schemas: [datesSchema] })
+ const ctx = buildContext(client, [datesSchema])
+ storageCodec = createEncryptedStorageCodec(ctx)
+ oreTermCodec = createEncryptedOreTermCodec(ctx)
+ })
+
+ afterAll(async () => {
+ if (sql) {
+ await sql`DROP TABLE IF EXISTS ${sql(TABLE_NAME)}`
+ await sql.end()
+ }
+ })
+
+ it('encodes a Date as ISO string and decodes via SDK cast_as round-trip', async () => {
+ const original = new Date('2026-04-27T12:00:00.000Z')
+ const wire = await storageCodec.encode(original)
+ await sql`INSERT INTO ${sql(TABLE_NAME)} (created_at) VALUES (${wire}::eql_v2_encrypted)`
+
+ const rows = await sql`SELECT created_at FROM ${sql(TABLE_NAME)}`
+ expect(rows).toHaveLength(1)
+ const decoded = await storageCodec.decode(String(rows[0]?.created_at))
+ // The SDK honors `cast_as: 'date'` and returns a Date instance.
+ expect(decoded).toBeInstanceOf(Date)
+ expect((decoded as Date).toISOString()).toBe(original.toISOString())
+ })
+
+ it('round-trips ORE comparisons on encrypted Date columns', async () => {
+ const cutoff = new Date('2026-04-27T12:00:00.000Z')
+ const cutoffTerm = await oreTermCodec.encode(cutoff)
+ const rows = await sql`
+ SELECT created_at FROM ${sql(TABLE_NAME)}
+ WHERE eql_v2.gte(created_at, ${cutoffTerm}::eql_v2_encrypted)
+ `
+ expect(rows.length).toBeGreaterThanOrEqual(1)
+ })
+ },
+)
+
+describeIntegration(
+ 'codec integration: searchable JSON via STE-Vec selector',
+ () => {
+ const docsSchema = encryptedTable('protect_prisma_docs', {
+ profile: encryptedColumn('profile').searchableJson(),
+ })
+ const TABLE_NAME = `protect_prisma_docs_${Date.now()}`
+
+ let sql: ReturnType
+ let storageCodec: ReturnType
+ let steVecCodec: ReturnType
+
+ beforeAll(async () => {
+ if (!DATABASE_URL) return
+ sql = postgres(DATABASE_URL, { prepare: false })
+ await sql`CREATE TABLE IF NOT EXISTS ${sql(TABLE_NAME)} (
+ id serial PRIMARY KEY,
+ profile eql_v2_encrypted NOT NULL
+ )`
+ const client = await Encryption({ schemas: [docsSchema] })
+ const ctx = buildContext(client, [docsSchema])
+ storageCodec = createEncryptedStorageCodec(ctx)
+ steVecCodec = createEncryptedSteVecSelectorCodec(ctx)
+ })
+
+ afterAll(async () => {
+ if (sql) {
+ await sql`DROP TABLE IF EXISTS ${sql(TABLE_NAME)}`
+ await sql.end()
+ }
+ })
+
+ it('round-trips jsonb_path_exists against an encrypted searchable JSON column', async () => {
+ const profiles = [
+ { name: 'Alice', role: 'admin' },
+ { name: 'Bob', role: 'user' },
+ ]
+ const wires = await Promise.all(
+ profiles.map((v) => storageCodec.encode(v)),
+ )
+ for (const wire of wires) {
+ await sql`INSERT INTO ${sql(TABLE_NAME)} (profile) VALUES (${wire}::eql_v2_encrypted)`
+ }
+
+ const selectorTerm = await steVecCodec.encode('$.role')
+ const rows = await sql`
+ SELECT profile FROM ${sql(TABLE_NAME)}
+ WHERE eql_v2.jsonb_path_exists(profile, ${selectorTerm}::eql_v2_encrypted)
+ `
+ expect(rows.length).toBeGreaterThanOrEqual(1)
+ })
+ },
+)
diff --git a/packages/stack/__tests__/prisma-codec.test.ts b/packages/stack/__tests__/prisma-codec.test.ts
new file mode 100644
index 00000000..845d8b94
--- /dev/null
+++ b/packages/stack/__tests__/prisma-codec.test.ts
@@ -0,0 +1,415 @@
+import { createEncryptedEqTermCodec } from '@/prisma/core/codec-eq-term'
+import { createEncryptedMatchTermCodec } from '@/prisma/core/codec-match-term'
+import { createEncryptedOreTermCodec } from '@/prisma/core/codec-ore-term'
+import { createEncryptedSteVecSelectorCodec } from '@/prisma/core/codec-ste-vec-term'
+import { createEncryptedStorageCodec } from '@/prisma/core/codec-storage'
+import {
+ eqlFromCompositeLiteral,
+ eqlToCompositeLiteral,
+} from '@/prisma/core/wire'
+import type { Encrypted } from '@/types'
+import { describe, expect, it } from 'vitest'
+import {
+ ALL_DATATYPES_CONTRACT,
+ createMockEncryptionClient,
+ createTestCodecContext,
+} from './prisma-test-helpers'
+
+describe('eqlToCompositeLiteral / eqlFromCompositeLiteral', () => {
+ it('round-trips an Encrypted JSON envelope through the composite literal form', () => {
+ const original: Encrypted = {
+ i: { t: 't', c: 'c' },
+ v: 1,
+ c: 'cipher-with-"quotes"',
+ }
+ const literal = eqlToCompositeLiteral(original)
+ expect(literal.startsWith('(')).toBe(true)
+ expect(literal.endsWith(')')).toBe(true)
+ const parsed = eqlFromCompositeLiteral(literal)
+ expect(parsed).toEqual(original)
+ })
+})
+
+describe('encryptedStorageCodec', () => {
+ it('encodes a single string value through bulkEncrypt and wraps it in a composite literal', async () => {
+ const { client, bulkEncrypt } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ const wire = await codec.encode('alice@example.com')
+
+ expect(bulkEncrypt).toHaveBeenCalledTimes(1)
+ expect(wire.startsWith('(')).toBe(true)
+ expect(wire.endsWith(')')).toBe(true)
+ const parsed = eqlFromCompositeLiteral(wire)
+ expect(parsed.c).toBe('enc:alice@example.com')
+ // The contract's first string column (`email` on `users`) was used.
+ expect(parsed.i.t).toBe('users')
+ expect(parsed.i.c).toBe('email')
+ })
+
+ it('coalesces N concurrent encode calls into a SINGLE bulkEncrypt invocation', async () => {
+ const { client, bulkEncrypt } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ const inputs = [
+ 'alice@example.com',
+ 'bob@example.com',
+ 'carol@example.com',
+ 'dave@example.com',
+ ]
+ const promises = inputs.map((v) => codec.encode(v))
+ const wires = await Promise.all(promises)
+
+ expect(bulkEncrypt).toHaveBeenCalledTimes(1)
+ const call = bulkEncrypt.mock.calls[0]?.[0]
+ expect(call).toBeDefined()
+ if (!Array.isArray(call)) throw new Error('expected array payload')
+ expect(call.map((p) => p.plaintext)).toEqual(inputs)
+
+ const decoded = wires.map((w: string) => eqlFromCompositeLiteral(w).c)
+ expect(decoded).toEqual(inputs.map((v) => `enc:${v}`))
+ })
+
+ it('round-trips encode -> decode back to the original string plaintext', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ const original = 'alice@example.com'
+ const wire = await codec.encode(original)
+ const decoded = await codec.decode(wire)
+
+ expect(decoded).toBe(original)
+ })
+
+ it("routes a number plaintext through the contract's number column", async () => {
+ const { client, bulkEncrypt } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ const wire = await codec.encode(42)
+ expect(bulkEncrypt).toHaveBeenCalledTimes(1)
+ const opts = bulkEncrypt.mock.calls[0]?.[1] as
+ | { column: { getName(): string } }
+ | undefined
+ expect(opts?.column.getName()).toBe('age')
+
+ const parsed = eqlFromCompositeLiteral(wire)
+ expect(parsed.i.c).toBe('age')
+ expect(parsed.c).toBe('enc:42')
+ })
+
+ it("routes a boolean plaintext through the contract's boolean column", async () => {
+ const { client, bulkEncrypt } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ await codec.encode(true)
+ const opts = bulkEncrypt.mock.calls[0]?.[1] as
+ | { column: { getName(): string } }
+ | undefined
+ expect(opts?.column.getName()).toBe('isActive')
+ })
+
+ it("routes a Date plaintext through the contract's date column with ISO serialization", async () => {
+ const { client, bulkEncrypt } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ const original = new Date('2026-04-27T12:00:00.000Z')
+ const wire = await codec.encode(original)
+
+ const opts = bulkEncrypt.mock.calls[0]?.[1] as
+ | { column: { getName(): string } }
+ | undefined
+ expect(opts?.column.getName()).toBe('createdAt')
+ const payload = bulkEncrypt.mock.calls[0]?.[0]
+ if (!Array.isArray(payload)) throw new Error('expected array payload')
+ expect(payload[0].plaintext).toBe(original.toISOString())
+
+ const parsed = eqlFromCompositeLiteral(wire)
+ expect(parsed.i.c).toBe('createdAt')
+ })
+
+ it('trusts the SDK on decode — Date columns come back as whatever the SDK returns', async () => {
+ // The mock's bulkDecrypt strips the `enc:` prefix and returns the
+ // ISO string. The codec passes that through verbatim — no payload
+ // inspection. Real SDK consumers honour `cast_as` and return a
+ // Date; the mock here doesn't, which validates the codec is
+ // *not* doing its own rehydration.
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ const original = new Date('2026-04-27T12:00:00.000Z')
+ const wire = await codec.encode(original)
+ const decoded = await codec.decode(wire)
+
+ // Mock returns the raw stripped string; the codec didn't try to
+ // rehydrate it — that's the SDK's job.
+ expect(decoded).toBe(original.toISOString())
+ })
+
+ it("routes a JSON plaintext through the contract's json column", async () => {
+ const { client, bulkEncrypt } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ const obj = { name: 'Alice', tags: ['a', 'b'] }
+ await codec.encode(obj)
+ const opts = bulkEncrypt.mock.calls[0]?.[1] as
+ | { column: { getName(): string } }
+ | undefined
+ expect(opts?.column.getName()).toBe('profile')
+ })
+
+ it('coalesces N concurrent decode calls into a SINGLE bulkDecrypt invocation', async () => {
+ const { client, bulkDecrypt } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ const wires = await Promise.all(['a', 'b', 'c'].map((v) => codec.encode(v)))
+
+ bulkDecrypt.mockClear()
+
+ const decoded = await Promise.all(wires.map((w: string) => codec.decode(w)))
+
+ expect(bulkDecrypt).toHaveBeenCalledTimes(1)
+ expect(decoded).toEqual(['a', 'b', 'c'])
+ })
+
+ it('renders the JS-side output type per dataType', () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ expect(codec.renderOutputType?.({ dataType: 'string' })).toBe('string')
+ expect(codec.renderOutputType?.({ dataType: 'number' })).toBe('number')
+ expect(codec.renderOutputType?.({ dataType: 'boolean' })).toBe('boolean')
+ expect(codec.renderOutputType?.({ dataType: 'date' })).toBe('Date')
+ expect(codec.renderOutputType?.({ dataType: 'json' })).toBe('unknown')
+ })
+
+ it('throws NO_COLUMN_FOR_DATATYPE when the contract has no column for the JS type', async () => {
+ // Contract with only a string column — encoding a number raises a
+ // structured error at the encode site.
+ const emailColumn =
+ ALL_DATATYPES_CONTRACT.storage?.tables?.users?.columns?.email
+ if (!emailColumn) throw new Error('test fixture missing email column')
+ const stringOnlyContract: typeof ALL_DATATYPES_CONTRACT = {
+ storage: {
+ tables: {
+ users: {
+ columns: {
+ email: emailColumn,
+ },
+ },
+ },
+ },
+ }
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({
+ client,
+ contract: stringOnlyContract,
+ })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ await expect(codec.encode(42)).rejects.toThrow(
+ /no encrypted column with dataType 'number'/,
+ )
+ })
+})
+
+describe('encryptedEqTermCodec', () => {
+ it("encodes a string query term through encryptQuery with the contract's string column", async () => {
+ const { client, encryptQuery } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedEqTermCodec(ctx)
+
+ const wire = await codec.encode('alice@example.com')
+
+ expect(encryptQuery).toHaveBeenCalledTimes(1)
+ const terms = encryptQuery.mock.calls[0]?.[0]
+ if (!Array.isArray(terms)) throw new Error('expected array of terms')
+ expect(terms[0].queryType).toBe('equality')
+ expect(terms[0].value).toBe('alice@example.com')
+ expect(terms[0].column.getName()).toBe('email')
+
+ const parsed = eqlFromCompositeLiteral(wire)
+ expect(parsed.c).toBe('qterm:alice@example.com')
+ })
+
+ it('coalesces N concurrent encodes into a SINGLE encryptQuery batch', async () => {
+ const { client, encryptQuery } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedEqTermCodec(ctx)
+
+ const inputs = ['x', 'y', 'z']
+ const wires = await Promise.all(inputs.map((v) => codec.encode(v)))
+
+ expect(encryptQuery).toHaveBeenCalledTimes(1)
+ expect(wires).toHaveLength(3)
+ })
+
+ it('encodes a Date eq-term as ISO string against the date column', async () => {
+ const { client, encryptQuery } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedEqTermCodec(ctx)
+
+ const date = new Date('2026-01-01T00:00:00.000Z')
+ await codec.encode(date)
+ const terms = encryptQuery.mock.calls[0]?.[0]
+ if (!Array.isArray(terms)) throw new Error('expected array of terms')
+ expect(terms[0].value).toBe(date.toISOString())
+ expect(terms[0].column.getName()).toBe('createdAt')
+ })
+
+ it('refuses to decode (write-only by construction)', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedEqTermCodec(ctx)
+ await expect(codec.decode('anything')).rejects.toThrow(/write-only/)
+ })
+})
+
+describe('encryptedMatchTermCodec', () => {
+ it('encodes a free-text term through encryptQuery with queryType=freeTextSearch', async () => {
+ const { client, encryptQuery } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedMatchTermCodec(ctx)
+
+ const wire = await codec.encode('%example.com')
+
+ expect(encryptQuery).toHaveBeenCalledTimes(1)
+ const terms = encryptQuery.mock.calls[0]?.[0]
+ if (!Array.isArray(terms)) throw new Error('expected array of terms')
+ expect(terms[0].queryType).toBe('freeTextSearch')
+ expect(terms[0].value).toBe('%example.com')
+ expect(terms[0].column.getName()).toBe('email')
+
+ const parsed = eqlFromCompositeLiteral(wire)
+ expect(parsed.c).toBe('qterm:%example.com')
+ })
+
+ it('coalesces N concurrent match-term encodes into a SINGLE batch', async () => {
+ const { client, encryptQuery } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedMatchTermCodec(ctx)
+
+ const inputs = ['alice', 'bob', 'carol']
+ const wires = await Promise.all(inputs.map((v) => codec.encode(v)))
+
+ expect(encryptQuery).toHaveBeenCalledTimes(1)
+ expect(wires).toHaveLength(3)
+ })
+
+ it('refuses to decode', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedMatchTermCodec(ctx)
+ await expect(codec.decode('anything')).rejects.toThrow(/write-only/)
+ })
+})
+
+describe('encryptedOreTermCodec', () => {
+ it('encodes a number range term through encryptQuery with queryType=orderAndRange', async () => {
+ const { client, encryptQuery } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedOreTermCodec(ctx)
+
+ await codec.encode(42)
+
+ expect(encryptQuery).toHaveBeenCalledTimes(1)
+ const terms = encryptQuery.mock.calls[0]?.[0]
+ if (!Array.isArray(terms)) throw new Error('expected array of terms')
+ expect(terms[0].queryType).toBe('orderAndRange')
+ expect(terms[0].value).toBe(42)
+ expect(terms[0].column.getName()).toBe('age')
+ })
+
+ it('encodes a Date range term as ISO string against the date column', async () => {
+ const { client, encryptQuery } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedOreTermCodec(ctx)
+
+ const date = new Date('2025-05-15T12:30:00.000Z')
+ await codec.encode(date)
+
+ const terms = encryptQuery.mock.calls[0]?.[0]
+ if (!Array.isArray(terms)) throw new Error('expected array of terms')
+ expect(terms[0].value).toBe(date.toISOString())
+ expect(terms[0].column.getName()).toBe('createdAt')
+ })
+
+ it('refuses non-numeric, non-Date plaintexts', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedOreTermCodec(ctx)
+
+ await expect(codec.encode('not-a-number')).rejects.toThrow(/number or Date/)
+ await expect(codec.encode(true)).rejects.toThrow(/number or Date/)
+ })
+
+ it('coalesces N concurrent ORE encodes (per dataType) into a SINGLE batch', async () => {
+ const { client, encryptQuery } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedOreTermCodec(ctx)
+
+ const wires = await Promise.all([
+ codec.encode(1),
+ codec.encode(2),
+ codec.encode(3),
+ ])
+
+ expect(encryptQuery).toHaveBeenCalledTimes(1)
+ expect(wires).toHaveLength(3)
+ })
+
+ it('refuses to decode', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedOreTermCodec(ctx)
+ await expect(codec.decode('anything')).rejects.toThrow(/write-only/)
+ })
+})
+
+describe('encryptedSteVecSelectorCodec', () => {
+ it('encodes a JSONPath selector through encryptQuery with queryType=steVecSelector', async () => {
+ const { client, encryptQuery } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedSteVecSelectorCodec(ctx)
+
+ const selector = '$.user.email'
+ const wire = await codec.encode(selector)
+
+ expect(encryptQuery).toHaveBeenCalledTimes(1)
+ const terms = encryptQuery.mock.calls[0]?.[0]
+ if (!Array.isArray(terms)) throw new Error('expected array of terms')
+ expect(terms[0].queryType).toBe('steVecSelector')
+ expect(terms[0].value).toBe(selector)
+ expect(terms[0].column.getName()).toBe('profile')
+
+ const parsed = eqlFromCompositeLiteral(wire)
+ expect(parsed.c).toBe(`qterm:${selector}`)
+ })
+
+ it('coalesces N concurrent selector encodes into a SINGLE batch', async () => {
+ const { client, encryptQuery } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedSteVecSelectorCodec(ctx)
+
+ const wires = await Promise.all([codec.encode('$.a'), codec.encode('$.b')])
+
+ expect(encryptQuery).toHaveBeenCalledTimes(1)
+ expect(wires).toHaveLength(2)
+ })
+
+ it('refuses to decode', async () => {
+ const { client } = createMockEncryptionClient()
+ const ctx = createTestCodecContext({ client })
+ const codec = createEncryptedSteVecSelectorCodec(ctx)
+ await expect(codec.decode('anything')).rejects.toThrow(/write-only/)
+ })
+})
diff --git a/packages/stack/__tests__/prisma-column-types.test.ts b/packages/stack/__tests__/prisma-column-types.test.ts
new file mode 100644
index 00000000..96ce5fcf
--- /dev/null
+++ b/packages/stack/__tests__/prisma-column-types.test.ts
@@ -0,0 +1,294 @@
+import {
+ encryptedBoolean,
+ encryptedDate,
+ encryptedJson,
+ encryptedNumber,
+ encryptedString,
+} from '@/prisma/exports/column-types'
+import { describe, expect, it } from 'vitest'
+
+describe('encryptedString column factory', () => {
+ it('emits a descriptor with the stable codec/native type identifiers', () => {
+ const desc = encryptedString({ equality: true })
+
+ expect(desc.codecId).toBe('cs/eql_v2_encrypted@1')
+ expect(desc.nativeType).toBe('"public"."eql_v2_encrypted"')
+ })
+
+ it('projects the equality flag through typeParams while defaulting other modes to false', () => {
+ const desc = encryptedString({ equality: true })
+
+ expect(desc.typeParams).toEqual({
+ dataType: 'string',
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ })
+ })
+
+ it('projects the freeTextSearch flag through typeParams', () => {
+ const desc = encryptedString({ equality: true, freeTextSearch: true })
+
+ expect(desc.typeParams).toEqual({
+ dataType: 'string',
+ equality: true,
+ freeTextSearch: true,
+ orderAndRange: false,
+ searchableJson: false,
+ })
+ })
+
+ it('treats a missing config object as no searchable-encryption modes', () => {
+ const desc = encryptedString()
+
+ expect(desc.typeParams).toEqual({
+ dataType: 'string',
+ equality: false,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ })
+ })
+})
+
+describe('encryptedNumber column factory', () => {
+ it('emits a number-typed encrypted column descriptor', () => {
+ const desc = encryptedNumber({ orderAndRange: true })
+ expect(desc.codecId).toBe('cs/eql_v2_encrypted@1')
+ expect(desc.typeParams).toEqual({
+ dataType: 'number',
+ equality: false,
+ freeTextSearch: false,
+ orderAndRange: true,
+ searchableJson: false,
+ })
+ })
+
+ it('supports equality + orderAndRange together', () => {
+ const desc = encryptedNumber({ equality: true, orderAndRange: true })
+ expect(desc.typeParams.equality).toBe(true)
+ expect(desc.typeParams.orderAndRange).toBe(true)
+ })
+})
+
+describe('encryptedDate column factory', () => {
+ it('emits a date-typed encrypted column descriptor', () => {
+ const desc = encryptedDate({ orderAndRange: true })
+ expect(desc.codecId).toBe('cs/eql_v2_encrypted@1')
+ expect(desc.typeParams).toEqual({
+ dataType: 'date',
+ equality: false,
+ freeTextSearch: false,
+ orderAndRange: true,
+ searchableJson: false,
+ })
+ })
+})
+
+describe('encryptedBoolean column factory', () => {
+ it('emits a boolean-typed encrypted column descriptor', () => {
+ const desc = encryptedBoolean({ equality: true })
+ expect(desc.codecId).toBe('cs/eql_v2_encrypted@1')
+ expect(desc.typeParams).toEqual({
+ dataType: 'boolean',
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ })
+ })
+})
+
+describe('encryptedJson column factory', () => {
+ it('emits a json-typed encrypted column descriptor', () => {
+ type Profile = { name: string; bio: string }
+ const desc = encryptedJson({ searchableJson: true })
+ expect(desc.codecId).toBe('cs/eql_v2_encrypted@1')
+ expect(desc.typeParams).toEqual({
+ dataType: 'json',
+ equality: false,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: true,
+ })
+ })
+
+ it('defaults to all-false when no config is provided', () => {
+ const desc = encryptedJson<{ a: number }>()
+ expect(desc.typeParams).toEqual({
+ dataType: 'json',
+ equality: false,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ })
+ })
+})
+
+// ===========================================================================
+// Type-level assertions: `OperationTypes` gating per `typeParams`.
+//
+// These checks compile or fail at build time — there is no runtime side.
+// They cover the Phase 2 deliverables:
+// - `.eq()` / `.neq()` only when `equality === true`
+// - `.gt()` / `.gte()` / `.lt()` / `.lte()` / `.between()` / `.notBetween()`
+// only when `orderAndRange === true` AND `dataType ∈ {number, date}`
+// - `.like()` / `.ilike()` / `.notIlike()` only when `freeTextSearch === true`
+// AND `dataType === 'string'`
+// - `.jsonbPathExists()` / `.jsonbPathQueryFirst()` / `.jsonbGet()` only
+// when `searchableJson === true` AND `dataType === 'json'`
+// - argument JS-type per `typeParams.dataType` (Date for `'date'`,
+// number for `'number'`, etc.).
+// ===========================================================================
+
+import type { ENCRYPTED_STORAGE_CODEC_ID } from '@/prisma/core/constants'
+import type { OperationTypes } from '@/prisma/exports/operation-types'
+
+type StorageMethodsFor> =
+ OperationTypes<
+ TParams & {
+ readonly dataType: 'string' | 'number' | 'boolean' | 'date' | 'json'
+ readonly equality: boolean
+ readonly freeTextSearch: boolean
+ readonly orderAndRange: boolean
+ readonly searchableJson: boolean
+ }
+ >[typeof ENCRYPTED_STORAGE_CODEC_ID]
+
+// ---- Equality gating ------------------------------------------------------
+type StringEqOn = StorageMethodsFor<{
+ dataType: 'string'
+ equality: true
+ freeTextSearch: false
+ orderAndRange: false
+ searchableJson: false
+}>
+type StringEqOff = StorageMethodsFor<{
+ dataType: 'string'
+ equality: false
+ freeTextSearch: false
+ orderAndRange: false
+ searchableJson: false
+}>
+
+const _eqOn: 'eq' extends keyof StringEqOn ? true : never = true
+const _neqOn: 'neq' extends keyof StringEqOn ? true : never = true
+const _eqOff: 'eq' extends keyof StringEqOff ? never : true = true
+const _gteOffByEquality: 'gte' extends keyof StringEqOn ? never : true = true
+void _eqOn
+void _neqOn
+void _eqOff
+void _gteOffByEquality
+
+// ---- Range gating (orderAndRange + dataType ∈ {number, date}) -------------
+type NumberOreOn = StorageMethodsFor<{
+ dataType: 'number'
+ equality: false
+ freeTextSearch: false
+ orderAndRange: true
+ searchableJson: false
+}>
+type DateOreOn = StorageMethodsFor<{
+ dataType: 'date'
+ equality: false
+ freeTextSearch: false
+ orderAndRange: true
+ searchableJson: false
+}>
+type StringOreOn = StorageMethodsFor<{
+ // `orderAndRange: true` on a string column is semantically nonsensical, but
+ // the column-type factory rejects it at the type level. We construct the
+ // shape directly here only to assert the operation-types layer ALSO refuses
+ // to surface ORE methods unless the dataType supports them.
+ dataType: 'string'
+ equality: false
+ freeTextSearch: false
+ orderAndRange: true
+ searchableJson: false
+}>
+
+const _gteOnNumber: 'gte' extends keyof NumberOreOn ? true : never = true
+const _gteOnDate: 'gte' extends keyof DateOreOn ? true : never = true
+const _betweenOnNumber: 'between' extends keyof NumberOreOn ? true : never =
+ true
+const _gteOffString: 'gte' extends keyof StringOreOn ? never : true = true
+void _gteOnNumber
+void _gteOnDate
+void _betweenOnNumber
+void _gteOffString
+
+// `.gte(param)` on a number column accepts `number`.
+type GteArgsNumber = NumberOreOn['gte']['args'][0]['inputType']
+const _gteArgNumber: GteArgsNumber extends number ? true : never = true
+const _gteArgNumberRefusesString: string extends GteArgsNumber ? never : true =
+ true
+void _gteArgNumber
+void _gteArgNumberRefusesString
+
+// `.gte(param)` on a date column accepts `Date`.
+type GteArgsDate = DateOreOn['gte']['args'][0]['inputType']
+const _gteArgDate: GteArgsDate extends Date ? true : never = true
+const _gteArgDateRefusesNumber: number extends GteArgsDate ? never : true = true
+void _gteArgDate
+void _gteArgDateRefusesNumber
+
+// ---- Text search gating ---------------------------------------------------
+type StringMatchOn = StorageMethodsFor<{
+ dataType: 'string'
+ equality: false
+ freeTextSearch: true
+ orderAndRange: false
+ searchableJson: false
+}>
+type NumberMatchOn = StorageMethodsFor<{
+ dataType: 'number'
+ equality: false
+ freeTextSearch: true
+ orderAndRange: false
+ searchableJson: false
+}>
+
+const _likeOnString: 'like' extends keyof StringMatchOn ? true : never = true
+const _ilikeOnString: 'ilike' extends keyof StringMatchOn ? true : never = true
+const _notIlikeOnString: 'notIlike' extends keyof StringMatchOn ? true : never =
+ true
+// Free-text on a non-string column is not surfaced (defense in depth — the
+// column-type factory already rejects it; the operation-types layer mirrors
+// that constraint).
+const _likeOffNumber: 'like' extends keyof NumberMatchOn ? never : true = true
+void _likeOnString
+void _ilikeOnString
+void _notIlikeOnString
+void _likeOffNumber
+
+// ---- JSON gating ----------------------------------------------------------
+type JsonOn = StorageMethodsFor<{
+ dataType: 'json'
+ equality: false
+ freeTextSearch: false
+ orderAndRange: false
+ searchableJson: true
+}>
+type JsonOff = StorageMethodsFor<{
+ dataType: 'json'
+ equality: false
+ freeTextSearch: false
+ orderAndRange: false
+ searchableJson: false
+}>
+
+const _jsonbPathExistsOn: 'jsonbPathExists' extends keyof JsonOn
+ ? true
+ : never = true
+const _jsonbPathQueryFirstOn: 'jsonbPathQueryFirst' extends keyof JsonOn
+ ? true
+ : never = true
+const _jsonbGetOn: 'jsonbGet' extends keyof JsonOn ? true : never = true
+const _jsonbPathExistsOff: 'jsonbPathExists' extends keyof JsonOff
+ ? never
+ : true = true
+void _jsonbPathExistsOn
+void _jsonbPathQueryFirstOn
+void _jsonbGetOn
+void _jsonbPathExistsOff
diff --git a/packages/stack/__tests__/prisma-control.test.ts b/packages/stack/__tests__/prisma-control.test.ts
new file mode 100644
index 00000000..3d79c4f4
--- /dev/null
+++ b/packages/stack/__tests__/prisma-control.test.ts
@@ -0,0 +1,58 @@
+import { ENCRYPTED_STORAGE_CODEC_ID } from '@/prisma/core/constants'
+import cipherstashEncryptionControl from '@/prisma/exports/control'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Phase 3 wires `databaseDependencies.init` and the storage codec's
+ * `planTypeOperations` hook into the SQL control extension descriptor.
+ * These tests pin the descriptor's shape so a future refactor that
+ * accidentally drops one of the wires fails loudly.
+ */
+
+describe('cipherstashEncryptionControl', () => {
+ it('exposes the EQL install bundle on databaseDependencies.init', () => {
+ expect(
+ cipherstashEncryptionControl.databaseDependencies?.init,
+ ).toBeDefined()
+ const init = cipherstashEncryptionControl.databaseDependencies?.init ?? []
+ expect(init).toHaveLength(1)
+ expect(init[0]?.id).toBe('cipherstash.eql')
+ })
+
+ it('registers the storage codec planTypeOperations hook under the storage codec ID', () => {
+ const hooks =
+ cipherstashEncryptionControl.types?.codecTypes?.controlPlaneHooks
+ expect(hooks).toBeDefined()
+ const storageHooks = hooks?.[ENCRYPTED_STORAGE_CODEC_ID]
+ expect(storageHooks?.planTypeOperations).toBeDefined()
+ })
+
+ it('uses a clean semver-compatible pack version with the EQL bundle pinned separately', () => {
+ // The descriptor version is the pack-meta version verbatim
+ // (semver-clean). The EQL bundle version is surfaced in the
+ // install operation's meta payload, not appended to the
+ // descriptor version. This separation matches F-31 from the
+ // DX audit.
+ expect(cipherstashEncryptionControl.version).toMatch(/^\d+\.\d+\.\d+$/)
+ const op =
+ cipherstashEncryptionControl.databaseDependencies?.init?.[0]?.install?.[0]
+ expect(op?.meta?.eqlBundleVersion).toBe('eql-2.2.1')
+ })
+
+ it('still surfaces the per-target storage entry from packMeta', () => {
+ expect(cipherstashEncryptionControl.types?.storage).toEqual([
+ {
+ typeId: ENCRYPTED_STORAGE_CODEC_ID,
+ familyId: 'sql',
+ targetId: 'postgres',
+ nativeType: '"public"."eql_v2_encrypted"',
+ },
+ ])
+ })
+
+ it('still wires queryOperations through to the operator descriptors', () => {
+ const ops = cipherstashEncryptionControl.queryOperations?.()
+ expect(ops?.length).toBeGreaterThan(0)
+ expect(ops?.find((op) => op.method === 'eq')).toBeDefined()
+ })
+})
diff --git a/packages/stack/__tests__/prisma-database-dependencies.test.ts b/packages/stack/__tests__/prisma-database-dependencies.test.ts
new file mode 100644
index 00000000..b4ad9920
--- /dev/null
+++ b/packages/stack/__tests__/prisma-database-dependencies.test.ts
@@ -0,0 +1,196 @@
+import { ENCRYPTED_STORAGE_CODEC_ID } from '@/prisma/core/constants'
+import {
+ getCipherStashDatabaseDependencies,
+ planEncryptedTypeOperations,
+} from '@/prisma/core/database-dependencies'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Phase 3 deliverables #5 (databaseDependencies.init) and #6
+ * (planTypeOperations).
+ *
+ * - `getCipherStashDatabaseDependencies()` returns the EQL install
+ * bundle wrapped in a `ComponentDatabaseDependency` shape the
+ * migration planner can consume.
+ * - `planEncryptedTypeOperations(input)` emits one
+ * `eql_v2.add_search_config(...)` operation per enabled
+ * searchable-encryption flag on a column's typeParams.
+ */
+
+describe('getCipherStashDatabaseDependencies', () => {
+ it('returns a single dependency bundle with the EQL install operation', () => {
+ const deps = getCipherStashDatabaseDependencies()
+ expect(deps.init).toHaveLength(1)
+ const bundle = deps.init?.[0]
+ expect(bundle?.id).toBe('cipherstash.eql')
+ expect(bundle?.install).toHaveLength(1)
+ })
+
+ it('emits an additive operation that runs the vendored EQL install bundle', () => {
+ const op = getCipherStashDatabaseDependencies().init?.[0]?.install[0]
+ expect(op?.operationClass).toBe('additive')
+ expect(op?.target.id).toBe('postgres')
+ // The execute step contains the bundle SQL; assert on a marker
+ // string that's stable across EQL versions.
+ expect(op?.execute[0]?.sql).toContain('eql_v2_configuration')
+ expect(op?.execute[0]?.sql.length).toBeGreaterThan(1000)
+ })
+
+ it('skips the install on subsequent runs via a precheck on the eql_v2_configuration table', () => {
+ const op = getCipherStashDatabaseDependencies().init?.[0]?.install[0]
+ expect(op?.precheck[0]?.sql).toContain('eql_v2_configuration')
+ expect(op?.postcheck[0]?.sql).toContain('eql_v2_configuration')
+ })
+
+ it('carries the EQL bundle version in the operation meta', () => {
+ const op = getCipherStashDatabaseDependencies().init?.[0]?.install[0]
+ expect(op?.meta?.eqlBundleVersion).toBeDefined()
+ })
+})
+
+describe('planEncryptedTypeOperations', () => {
+ function make(
+ typeName: string,
+ typeParams: Record,
+ ): Parameters[0] {
+ return {
+ typeName,
+ typeInstance: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ nativeType: '"public"."eql_v2_encrypted"',
+ typeParams,
+ },
+ }
+ }
+
+ it('emits no operations when no search modes are enabled', () => {
+ const result = planEncryptedTypeOperations(
+ make('users__email', {
+ dataType: 'string',
+ equality: false,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ }),
+ )
+ expect(result.operations).toHaveLength(0)
+ })
+
+ it('emits add_search_config for each enabled search mode', () => {
+ const result = planEncryptedTypeOperations(
+ make('users__email', {
+ dataType: 'string',
+ equality: true,
+ freeTextSearch: true,
+ orderAndRange: false,
+ searchableJson: false,
+ }),
+ )
+ expect(result.operations).toHaveLength(2)
+ const indexNames = result.operations.map((op) => op.meta?.indexName)
+ expect(indexNames).toEqual(['unique', 'match'])
+ })
+
+ it('uses the EQL cast_as mapping per dataType', () => {
+ const cases: ReadonlyArray<{
+ dataType: string
+ castAs: string
+ }> = [
+ { dataType: 'string', castAs: 'text' },
+ { dataType: 'number', castAs: 'double' },
+ { dataType: 'boolean', castAs: 'boolean' },
+ { dataType: 'date', castAs: 'date' },
+ { dataType: 'json', castAs: 'jsonb' },
+ ]
+ for (const { dataType, castAs } of cases) {
+ const result = planEncryptedTypeOperations(
+ make('t__c', {
+ dataType,
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ }),
+ )
+ const sql = result.operations[0]?.execute[0]?.sql ?? ''
+ expect(sql).toContain(`'${castAs}'`)
+ }
+ })
+
+ it('produces SQL that calls eql_v2.add_search_config with the right table/column pair', () => {
+ const result = planEncryptedTypeOperations(
+ make('users__email', {
+ dataType: 'string',
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ }),
+ )
+ const sql = result.operations[0]?.execute[0]?.sql
+ expect(sql).toContain(
+ "eql_v2.add_search_config('users', 'email', 'unique', 'text')",
+ )
+ })
+
+ it('emits one operation per searchable-encryption flag on json columns', () => {
+ const result = planEncryptedTypeOperations(
+ make('docs__profile', {
+ dataType: 'json',
+ equality: false,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: true,
+ }),
+ )
+ expect(result.operations).toHaveLength(1)
+ expect(result.operations[0]?.meta?.indexName).toBe('ste_vec')
+ expect(result.operations[0]?.execute[0]?.sql).toContain(
+ "eql_v2.add_search_config('docs', 'profile', 'ste_vec', 'jsonb')",
+ )
+ })
+
+ it('returns no operations when the typeParams shape is invalid', () => {
+ const result = planEncryptedTypeOperations(
+ make('users__email', {
+ dataType: 'unknown-shape',
+ equality: true,
+ }),
+ )
+ expect(result.operations).toEqual([])
+ })
+
+ it('returns no operations when the typeName has no `__` separator (Phase 3 placeholder)', () => {
+ const result = planEncryptedTypeOperations(
+ make('cannot-derive', {
+ dataType: 'string',
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ }),
+ )
+ // Phase 3 documented limitation: without `(table, column)` from the
+ // planner, we can't emit a sensible add_search_config call. Ship an
+ // empty result rather than a malformed one.
+ expect(result.operations).toEqual([])
+ })
+
+ it('emits a self-referential precheck so re-running the plan is idempotent', () => {
+ const result = planEncryptedTypeOperations(
+ make('users__email', {
+ dataType: 'string',
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ }),
+ )
+ const op = result.operations[0]
+ // The precheck SQL inspects eql_v2_configuration to skip when the
+ // index is already present; the postcheck inverts it.
+ expect(op?.precheck[0]?.sql).toContain('eql_v2_configuration')
+ expect(op?.precheck[0]?.sql).toContain("'unique'")
+ expect(op?.postcheck[0]?.sql).toContain('eql_v2_configuration')
+ })
+})
diff --git a/packages/stack/__tests__/prisma-decrypted.test.ts b/packages/stack/__tests__/prisma-decrypted.test.ts
new file mode 100644
index 00000000..78eaf9b7
--- /dev/null
+++ b/packages/stack/__tests__/prisma-decrypted.test.ts
@@ -0,0 +1,99 @@
+import type { Decrypted } from '@/prisma/exports/codec-types'
+import {
+ encryptedBoolean,
+ encryptedDate,
+ encryptedJson,
+ encryptedNumber,
+ encryptedString,
+} from '@/prisma/exports/column-types'
+import { describe, it } from 'vitest'
+
+/**
+ * F-16: `Decrypted` type helper.
+ *
+ * Walks the contract's models, finds columns whose `codecId === 'cs/eql_v2_encrypted@1'`,
+ * and infers the JS-side type from `typeParams.dataType` (string ->
+ * string, number -> number, date -> Date, boolean -> boolean, json ->
+ * the user's `T` from `encryptedJson`).
+ *
+ * The helper has no runtime side; this file is a type-test only.
+ */
+
+// Synthetic contract shape for the helper — mirrors what the
+// generated `contract.d.ts` emits for an encrypted-column-bearing
+// model.
+const contract = {
+ models: {
+ User: {
+ fields: {
+ // Plain integer ID — the helper widens unrelated columns to
+ // `unknown` (the integration doesn't know the JS type for
+ // non-encrypted columns; users can narrow themselves).
+ id: { codecId: 'pg/int4@1' },
+ email: encryptedString({ equality: true, freeTextSearch: true }),
+ age: encryptedNumber({ orderAndRange: true }),
+ isActive: encryptedBoolean({ equality: true }),
+ createdAt: encryptedDate({ orderAndRange: true }),
+ profile: encryptedJson<{ name: string; bio: string }>({
+ searchableJson: true,
+ }),
+ },
+ },
+ },
+} as const
+
+// ---- Type-level assertions ------------------------------------------------
+
+type DecryptedUser = Decrypted
+
+// String-typed encrypted column → string
+const _email: DecryptedUser['email'] = 'alice@example.com'
+const _emailRefusesNumber: DecryptedUser['email'] extends number
+ ? never
+ : true = true
+
+// Number-typed encrypted column → number
+const _age: DecryptedUser['age'] = 30
+const _ageRefusesString: DecryptedUser['age'] extends string ? never : true =
+ true
+
+// Boolean-typed encrypted column → boolean
+const _isActive: DecryptedUser['isActive'] = true
+
+// Date-typed encrypted column → Date
+const _createdAt: DecryptedUser['createdAt'] = new Date()
+const _createdAtRefusesString: DecryptedUser['createdAt'] extends string
+ ? never
+ : true = true
+
+// JSON-typed encrypted column with explicit shape → that shape
+const _profile: DecryptedUser['profile'] = { name: 'Alice', bio: 'Dev' }
+// The shape is preserved verbatim — accessing typed fields works
+// without any `as` cast.
+function _checkProfileShape(p: DecryptedUser['profile']): void {
+ // Both fields are typed as `string`.
+ const _name: string = p.name
+ const _bio: string = p.bio
+ void _name
+ void _bio
+}
+
+// Force the type-level assertions to materialize during compilation
+void _email
+void _emailRefusesNumber
+void _age
+void _ageRefusesString
+void _isActive
+void _createdAt
+void _createdAtRefusesString
+void _profile
+void _checkProfileShape
+
+// Runtime smoke test that compiles every assertion above.
+describe('Decrypted', () => {
+ it('compiles type-level assertions for every encrypted column data type', () => {
+ // The body is intentionally trivial — the assertions live in
+ // type space above. If the helper is wrong, this file fails to
+ // compile.
+ })
+})
diff --git a/packages/stack/__tests__/prisma-extraction.test.ts b/packages/stack/__tests__/prisma-extraction.test.ts
new file mode 100644
index 00000000..7ae5927e
--- /dev/null
+++ b/packages/stack/__tests__/prisma-extraction.test.ts
@@ -0,0 +1,226 @@
+import { ENCRYPTED_STORAGE_CODEC_ID } from '@/prisma/core/constants'
+import {
+ type ContractLike,
+ extractEncryptedSchemas,
+} from '@/prisma/core/extraction'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Phase 3 deliverable #7: walk a Prisma Next contract's storage layout
+ * and produce one `EncryptedTable` per table that holds at least one
+ * encrypted column. Each derived `EncryptedTable` carries the per-column
+ * index configuration verbatim from the contract's typeParams.
+ */
+
+describe('extractEncryptedSchemas', () => {
+ it('returns an empty array for an undefined or empty contract', () => {
+ expect(extractEncryptedSchemas(undefined)).toEqual([])
+ expect(extractEncryptedSchemas(null)).toEqual([])
+ expect(extractEncryptedSchemas({})).toEqual([])
+ expect(extractEncryptedSchemas({ storage: {} })).toEqual([])
+ expect(extractEncryptedSchemas({ storage: { tables: {} } })).toEqual([])
+ })
+
+ it('skips columns whose codecId is not the encrypted-storage codec', () => {
+ const contract: ContractLike = {
+ storage: {
+ tables: {
+ users: {
+ columns: {
+ id: { codecId: 'pg/int4@1', nativeType: 'integer' },
+ name: { codecId: 'pg/text@1', nativeType: 'text' },
+ },
+ },
+ },
+ },
+ }
+ expect(extractEncryptedSchemas(contract)).toEqual([])
+ })
+
+ it('builds one EncryptedTable per table with at least one encrypted column', () => {
+ const contract: ContractLike = {
+ storage: {
+ tables: {
+ users: {
+ columns: {
+ id: { codecId: 'pg/int4@1' },
+ email: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'string',
+ equality: true,
+ freeTextSearch: true,
+ orderAndRange: false,
+ searchableJson: false,
+ },
+ },
+ },
+ },
+ posts: {
+ columns: {
+ id: { codecId: 'pg/int4@1' },
+ author_id: { codecId: 'pg/int4@1' },
+ },
+ },
+ },
+ },
+ }
+ const tables = extractEncryptedSchemas(contract)
+ // Only `users` has an encrypted column; `posts` is omitted entirely.
+ expect(tables).toHaveLength(1)
+ expect(tables[0]?.tableName).toBe('users')
+ })
+
+ it('projects searchable-encryption flags onto the EncryptedColumn builder', () => {
+ const contract: ContractLike = {
+ storage: {
+ tables: {
+ users: {
+ columns: {
+ email: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'string',
+ equality: true,
+ freeTextSearch: true,
+ orderAndRange: false,
+ searchableJson: false,
+ },
+ },
+ age: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'number',
+ equality: false,
+ freeTextSearch: false,
+ orderAndRange: true,
+ searchableJson: false,
+ },
+ },
+ profile: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'json',
+ equality: false,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ const [users] = extractEncryptedSchemas(contract)
+ expect(users).toBeDefined()
+ if (!users) return
+ const built = users.build()
+ // build() returns { tableName, columns: { email: { cast_as, indexes }, ... } }
+ expect(built.tableName).toBe('users')
+ expect(built.columns.email?.indexes.unique).toBeDefined()
+ expect(built.columns.email?.indexes.match).toBeDefined()
+ expect(built.columns.age?.indexes.ore).toBeDefined()
+ expect(built.columns.profile?.indexes.ste_vec).toBeDefined()
+ })
+
+ it('resolves typeParams via typeRef when the column inlines no params', () => {
+ const contract: ContractLike = {
+ storage: {
+ tables: {
+ users: {
+ columns: {
+ email: { typeRef: 'EncryptedEmail' },
+ },
+ },
+ },
+ types: {
+ EncryptedEmail: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ nativeType: '"public"."eql_v2_encrypted"',
+ typeParams: {
+ dataType: 'string',
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ },
+ },
+ },
+ },
+ }
+ const [users] = extractEncryptedSchemas(contract)
+ expect(users).toBeDefined()
+ const built = users?.build()
+ expect(built?.columns.email?.indexes.unique).toBeDefined()
+ expect(built?.columns.email?.indexes.match).toBeUndefined()
+ })
+
+ it('skips columns whose typeParams.dataType is missing or invalid', () => {
+ const contract: ContractLike = {
+ storage: {
+ tables: {
+ users: {
+ columns: {
+ ok: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'string',
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ },
+ },
+ bad: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'unknown-shape',
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ const [users] = extractEncryptedSchemas(contract)
+ expect(users).toBeDefined()
+ const built = users?.build()
+ expect(Object.keys(built?.columns ?? {})).toEqual(['ok'])
+ })
+
+ it('ignores searchableJson on non-json columns and freeTextSearch on non-string columns', () => {
+ // The contract types prevent these mismatches from being authored,
+ // but a hand-crafted contract.json could carry either. The
+ // extractor is defensive: it only applies a flag when it makes
+ // sense for the column's dataType.
+ const contract: ContractLike = {
+ storage: {
+ tables: {
+ weird: {
+ columns: {
+ n: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'number',
+ equality: false,
+ freeTextSearch: true, // should be ignored
+ orderAndRange: false,
+ searchableJson: true, // should be ignored
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ const [tbl] = extractEncryptedSchemas(contract)
+ const built = tbl?.build()
+ expect(built?.columns.n?.indexes.match).toBeUndefined()
+ expect(built?.columns.n?.indexes.ste_vec).toBeUndefined()
+ })
+})
diff --git a/packages/stack/__tests__/prisma-multi-tenancy.test.ts b/packages/stack/__tests__/prisma-multi-tenancy.test.ts
new file mode 100644
index 00000000..d0d7dc74
--- /dev/null
+++ b/packages/stack/__tests__/prisma-multi-tenancy.test.ts
@@ -0,0 +1,82 @@
+import { createEncryptedStorageCodec } from '@/prisma/core/codec-storage'
+import { describe, expect, it } from 'vitest'
+import {
+ createMockEncryptionClient,
+ createTestCodecContext,
+} from './prisma-test-helpers'
+
+/**
+ * F-4: per-extension `EncryptionClient` binding.
+ *
+ * Each `cipherstashEncryption({...})` call produces a fresh codec
+ * graph with its own client closed over. Two extensions must not
+ * cross-talk: a call routed through extension A's codec never lands
+ * on extension B's client.
+ */
+
+describe('per-extension client isolation', () => {
+ it('routes calls through the bound client only — two extensions never cross-talk', async () => {
+ const tenantA = createMockEncryptionClient()
+ const tenantB = createMockEncryptionClient()
+
+ const ctxA = createTestCodecContext({ client: tenantA.client })
+ const ctxB = createTestCodecContext({ client: tenantB.client })
+
+ const codecA = createEncryptedStorageCodec(ctxA)
+ const codecB = createEncryptedStorageCodec(ctxB)
+
+ await codecA.encode('alice@example.com')
+ await codecB.encode('bob@example.com')
+ await codecA.encode('alice2@example.com')
+
+ // Each tenant's client received exactly the calls routed through
+ // its own codec — no cross-talk.
+ expect(tenantA.bulkEncrypt).toHaveBeenCalledTimes(2)
+ expect(tenantB.bulkEncrypt).toHaveBeenCalledTimes(1)
+
+ const aPayloads = tenantA.bulkEncrypt.mock.calls.flatMap((call) => {
+ const [payload] = call
+ if (!Array.isArray(payload)) return []
+ return payload.map((p) => p.plaintext)
+ })
+ const bPayloads = tenantB.bulkEncrypt.mock.calls.flatMap((call) => {
+ const [payload] = call
+ if (!Array.isArray(payload)) return []
+ return payload.map((p) => p.plaintext)
+ })
+ expect(aPayloads).toEqual(['alice@example.com', 'alice2@example.com'])
+ expect(bPayloads).toEqual(['bob@example.com'])
+ })
+
+ it('produces independent batchers per extension — concurrent calls in two extensions do not share a batch', async () => {
+ const tenantA = createMockEncryptionClient()
+ const tenantB = createMockEncryptionClient()
+
+ const ctxA = createTestCodecContext({ client: tenantA.client })
+ const ctxB = createTestCodecContext({ client: tenantB.client })
+
+ const codecA = createEncryptedStorageCodec(ctxA)
+ const codecB = createEncryptedStorageCodec(ctxB)
+
+ await Promise.all([
+ codecA.encode('a1'),
+ codecB.encode('b1'),
+ codecA.encode('a2'),
+ codecB.encode('b2'),
+ ])
+
+ // Each tenant got exactly one bulkEncrypt call (the codec's
+ // microtask batcher coalesces *within* the extension), and the
+ // payloads contain only that tenant's plaintexts.
+ expect(tenantA.bulkEncrypt).toHaveBeenCalledTimes(1)
+ expect(tenantB.bulkEncrypt).toHaveBeenCalledTimes(1)
+
+ const aPayload = tenantA.bulkEncrypt.mock.calls[0]?.[0]
+ const bPayload = tenantB.bulkEncrypt.mock.calls[0]?.[0]
+ if (!Array.isArray(aPayload) || !Array.isArray(bPayload)) {
+ throw new Error('expected array payloads')
+ }
+ expect(aPayload.map((p) => p.plaintext)).toEqual(['a1', 'a2'])
+ expect(bPayload.map((p) => p.plaintext)).toEqual(['b1', 'b2'])
+ })
+})
diff --git a/packages/stack/__tests__/prisma-observability.test.ts b/packages/stack/__tests__/prisma-observability.test.ts
new file mode 100644
index 00000000..c5cb1587
--- /dev/null
+++ b/packages/stack/__tests__/prisma-observability.test.ts
@@ -0,0 +1,207 @@
+import type { EncryptionClient } from '@/encryption'
+import {
+ type CipherStashEncryptionEvent,
+ defaultEventHook,
+} from '@/prisma/core/codec-context'
+import { createEncryptedEqTermCodec } from '@/prisma/core/codec-eq-term'
+import { createEncryptedStorageCodec } from '@/prisma/core/codec-storage'
+import { describe, expect, it, vi } from 'vitest'
+import {
+ createMockEncryptionClient,
+ createTestCodecContext,
+} from './prisma-test-helpers'
+
+/**
+ * F-17: structured `onEvent` hook.
+ *
+ * Every `bulkEncrypt` / `bulkDecrypt` / `encryptQuery` round-trip
+ * produces a structured event with `kind`, `codecId`, `batchSize`,
+ * `durationMs`, `table`, `column`, and (on failure) `error`.
+ */
+
+describe('observability — onEvent hook', () => {
+ it('fires a `bulkEncrypt` event for a 5-row insert', async () => {
+ const events: CipherStashEncryptionEvent[] = []
+ const ctx = createTestCodecContext({
+ emit: (e) => events.push(e),
+ })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ await Promise.all([
+ codec.encode('a@example.com'),
+ codec.encode('b@example.com'),
+ codec.encode('c@example.com'),
+ codec.encode('d@example.com'),
+ codec.encode('e@example.com'),
+ ])
+
+ expect(events).toHaveLength(1)
+ expect(events[0]).toMatchObject({
+ kind: 'bulkEncrypt',
+ codecId: 'cs/eql_v2_encrypted@1',
+ batchSize: 5,
+ table: 'users',
+ column: 'email',
+ error: undefined,
+ })
+ expect(events[0]?.durationMs).toBeGreaterThanOrEqual(0)
+ })
+
+ it('fires an `encryptQuery` event for a fluent eq query', async () => {
+ const events: CipherStashEncryptionEvent[] = []
+ const ctx = createTestCodecContext({
+ emit: (e) => events.push(e),
+ })
+ const codec = createEncryptedEqTermCodec(ctx)
+
+ await codec.encode('alice@example.com')
+
+ expect(events).toHaveLength(1)
+ expect(events[0]).toMatchObject({
+ kind: 'encryptQuery',
+ codecId: 'cs/eql_v2_eq_term@1',
+ batchSize: 1,
+ table: 'users',
+ column: 'email',
+ error: undefined,
+ })
+ })
+
+ it('fires a `bulkDecrypt` event on read', async () => {
+ const events: CipherStashEncryptionEvent[] = []
+ const ctx = createTestCodecContext({
+ emit: (e) => events.push(e),
+ })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ const wire = await codec.encode('alice@example.com')
+ events.length = 0
+ await codec.decode(wire)
+
+ expect(events).toHaveLength(1)
+ expect(events[0]).toMatchObject({
+ kind: 'bulkDecrypt',
+ codecId: 'cs/eql_v2_encrypted@1',
+ batchSize: 1,
+ // table/column come from the cipher's own `i.t` / `i.c` markers,
+ // which the mock populated from the encode call.
+ table: 'users',
+ column: 'email',
+ error: undefined,
+ })
+ })
+
+ it('fires a failure event when bulkEncrypt rejects', async () => {
+ const failingClient = {
+ bulkEncrypt: vi.fn(async () => ({
+ failure: { message: 'fake bulk-encrypt failure' },
+ })),
+ bulkDecrypt: vi.fn(),
+ encryptQuery: vi.fn(),
+ } as unknown as EncryptionClient
+
+ const events: CipherStashEncryptionEvent[] = []
+ const ctx = createTestCodecContext({
+ client: failingClient,
+ emit: (e) => events.push(e),
+ })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ await expect(codec.encode('alice@example.com')).rejects.toThrow(
+ /bulkEncrypt failed/,
+ )
+
+ // Even on failure the event fires — it's the load-bearing
+ // observability surface for tracing failure rates.
+ expect(events).toHaveLength(1)
+ expect(events[0]).toMatchObject({
+ kind: 'bulkEncrypt',
+ codecId: 'cs/eql_v2_encrypted@1',
+ batchSize: 1,
+ table: 'users',
+ column: 'email',
+ })
+ expect(events[0]?.error).toBeUndefined()
+ // The bulkEncrypt didn't reject — it returned a `{ failure }`
+ // result. The codec translates that into a structured error
+ // *after* the event fires; the event sees a successful
+ // round-trip from the SDK's perspective.
+ })
+
+ it('fires a failure event when bulkDecrypt rejects', async () => {
+ const failingClient = {
+ bulkEncrypt: createMockEncryptionClient().bulkEncrypt,
+ bulkDecrypt: vi.fn(async () => {
+ throw new Error('zerokms unreachable')
+ }),
+ encryptQuery: vi.fn(),
+ } as unknown as EncryptionClient
+
+ // Re-use the mock encrypt so encode succeeds, then make decrypt
+ // throw at the SDK boundary (network failure shape).
+ const mock = createMockEncryptionClient()
+ const client = {
+ bulkEncrypt: mock.bulkEncrypt,
+ bulkDecrypt: failingClient.bulkDecrypt,
+ encryptQuery: mock.encryptQuery,
+ } as unknown as EncryptionClient
+
+ const events: CipherStashEncryptionEvent[] = []
+ const ctx = createTestCodecContext({
+ client,
+ emit: (e) => events.push(e),
+ })
+ const codec = createEncryptedStorageCodec(ctx)
+
+ const wire = await codec.encode('alice@example.com')
+ events.length = 0
+
+ await expect(codec.decode(wire)).rejects.toThrow(/zerokms unreachable/)
+
+ expect(events).toHaveLength(1)
+ expect(events[0]).toMatchObject({
+ kind: 'bulkDecrypt',
+ codecId: 'cs/eql_v2_encrypted@1',
+ batchSize: 1,
+ })
+ // Network failure surfaced as a thrown error — `error` is
+ // populated and the codec re-throws a structured error.
+ expect(events[0]?.error).toBeInstanceOf(Error)
+ })
+
+ it('default behaviour (no `onEvent` provided) is silent in production and logs in dev', () => {
+ // Smoke-test only: the actual default behaviour is gated on
+ // `process.env.NODE_ENV !== 'production'`. We verify the gate is
+ // honored by switching the env var around an explicit call.
+ const origNodeEnv = process.env.NODE_ENV
+ try {
+ process.env.NODE_ENV = 'production'
+ const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
+ defaultEventHook({
+ kind: 'bulkEncrypt',
+ codecId: 'cs/eql_v2_encrypted@1',
+ batchSize: 1,
+ durationMs: 5,
+ table: 'users',
+ column: 'email',
+ error: undefined,
+ })
+ expect(debugSpy).not.toHaveBeenCalled()
+
+ process.env.NODE_ENV = 'development'
+ defaultEventHook({
+ kind: 'bulkEncrypt',
+ codecId: 'cs/eql_v2_encrypted@1',
+ batchSize: 1,
+ durationMs: 5,
+ table: 'users',
+ column: 'email',
+ error: undefined,
+ })
+ expect(debugSpy).toHaveBeenCalledTimes(1)
+ debugSpy.mockRestore()
+ } finally {
+ process.env.NODE_ENV = origNodeEnv
+ }
+ })
+})
diff --git a/packages/stack/__tests__/prisma-operation-templates.test.ts b/packages/stack/__tests__/prisma-operation-templates.test.ts
new file mode 100644
index 00000000..f79c4f1c
--- /dev/null
+++ b/packages/stack/__tests__/prisma-operation-templates.test.ts
@@ -0,0 +1,121 @@
+import {
+ ENCRYPTED_EQ_TERM_CODEC_ID,
+ ENCRYPTED_MATCH_TERM_CODEC_ID,
+ ENCRYPTED_ORE_TERM_CODEC_ID,
+ ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID,
+ ENCRYPTED_STORAGE_CODEC_ID,
+} from '@/prisma/core/constants'
+import { encryptedQueryOperations } from '@/prisma/core/operation-templates'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Operator descriptors are the contract between the runtime extension and
+ * the framework's lowering planner. Each test pins:
+ *
+ * - The exact `eql_v2.(...)` SQL template (verified against the
+ * Drizzle implementation in `src/drizzle/operators.ts`).
+ * - The per-arg `codecId`, which routes the user-side plaintext through
+ * the matching query-term codec at lower-time.
+ *
+ * Drift from the Drizzle implementation is the most likely failure mode of
+ * the Phase 2 integration; pinning the templates here catches it loudly.
+ */
+
+function findOp(method: string) {
+ const op = encryptedQueryOperations.find((o) => o.method === method)
+ if (!op) {
+ throw new Error(`Operator descriptor for '${method}' is missing`)
+ }
+ return op
+}
+
+describe('equality operators', () => {
+ it('eq lowers to eql_v2.eq with the eq-term codec on the value side', () => {
+ const op = findOp('eq')
+ expect(op.lowering.template).toBe('eql_v2.eq({{self}}, {{arg0}})')
+ expect(op.args[0]?.codecId).toBe(ENCRYPTED_STORAGE_CODEC_ID)
+ expect(op.args[1]?.codecId).toBe(ENCRYPTED_EQ_TERM_CODEC_ID)
+ expect(op.returns.codecId).toBe('core/bool@1')
+ })
+
+ it('neq lowers to eql_v2.neq with the eq-term codec on the value side', () => {
+ const op = findOp('neq')
+ expect(op.lowering.template).toBe('eql_v2.neq({{self}}, {{arg0}})')
+ expect(op.args[1]?.codecId).toBe(ENCRYPTED_EQ_TERM_CODEC_ID)
+ })
+})
+
+describe('range operators', () => {
+ for (const op of ['gt', 'gte', 'lt', 'lte'] as const) {
+ it(`${op} lowers to eql_v2.${op} with the ORE-term codec on the value side`, () => {
+ const desc = findOp(op)
+ expect(desc.lowering.template).toBe(`eql_v2.${op}({{self}}, {{arg0}})`)
+ expect(desc.args[1]?.codecId).toBe(ENCRYPTED_ORE_TERM_CODEC_ID)
+ })
+ }
+
+ it('between lowers to eql_v2.gte AND eql_v2.lte with two ORE-term args', () => {
+ const op = findOp('between')
+ expect(op.lowering.template).toBe(
+ '(eql_v2.gte({{self}}, {{arg0}}) AND eql_v2.lte({{self}}, {{arg1}}))',
+ )
+ expect(op.args).toHaveLength(3)
+ expect(op.args[1]?.codecId).toBe(ENCRYPTED_ORE_TERM_CODEC_ID)
+ expect(op.args[2]?.codecId).toBe(ENCRYPTED_ORE_TERM_CODEC_ID)
+ })
+
+ it('notBetween wraps the between body in NOT (...)', () => {
+ const op = findOp('notBetween')
+ expect(op.lowering.template).toBe(
+ 'NOT (eql_v2.gte({{self}}, {{arg0}}) AND eql_v2.lte({{self}}, {{arg1}}))',
+ )
+ })
+})
+
+describe('text-search operators', () => {
+ it('like lowers to eql_v2.like with the match-term codec on the value side', () => {
+ const op = findOp('like')
+ expect(op.lowering.template).toBe('eql_v2.like({{self}}, {{arg0}})')
+ expect(op.args[1]?.codecId).toBe(ENCRYPTED_MATCH_TERM_CODEC_ID)
+ })
+
+ it('ilike lowers to eql_v2.ilike', () => {
+ const op = findOp('ilike')
+ expect(op.lowering.template).toBe('eql_v2.ilike({{self}}, {{arg0}})')
+ expect(op.args[1]?.codecId).toBe(ENCRYPTED_MATCH_TERM_CODEC_ID)
+ })
+
+ it('notIlike wraps eql_v2.ilike in NOT (...)', () => {
+ const op = findOp('notIlike')
+ expect(op.lowering.template).toBe('NOT (eql_v2.ilike({{self}}, {{arg0}}))')
+ expect(op.args[1]?.codecId).toBe(ENCRYPTED_MATCH_TERM_CODEC_ID)
+ })
+})
+
+describe('JSONB / STE-Vec operators', () => {
+ it('jsonbPathExists lowers to eql_v2.jsonb_path_exists with selector cast', () => {
+ const op = findOp('jsonbPathExists')
+ expect(op.lowering.template).toBe(
+ 'eql_v2.jsonb_path_exists({{self}}, {{arg0}}::eql_v2_encrypted)',
+ )
+ expect(op.args[1]?.codecId).toBe(ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID)
+ expect(op.returns.codecId).toBe('core/bool@1')
+ })
+
+ it('jsonbPathQueryFirst lowers to eql_v2.jsonb_path_query_first and returns encrypted storage', () => {
+ const op = findOp('jsonbPathQueryFirst')
+ expect(op.lowering.template).toBe(
+ 'eql_v2.jsonb_path_query_first({{self}}, {{arg0}}::eql_v2_encrypted)',
+ )
+ expect(op.returns.codecId).toBe(ENCRYPTED_STORAGE_CODEC_ID)
+ })
+
+ it('jsonbGet lowers to the -> infix operator with selector cast', () => {
+ const op = findOp('jsonbGet')
+ expect(op.lowering.template).toBe(
+ '({{self}} -> {{arg0}}::eql_v2_encrypted)',
+ )
+ expect(op.lowering.strategy).toBe('infix')
+ expect(op.returns.codecId).toBe(ENCRYPTED_STORAGE_CODEC_ID)
+ })
+})
diff --git a/packages/stack/__tests__/prisma-runtime.test.ts b/packages/stack/__tests__/prisma-runtime.test.ts
new file mode 100644
index 00000000..4d997443
--- /dev/null
+++ b/packages/stack/__tests__/prisma-runtime.test.ts
@@ -0,0 +1,148 @@
+import { CipherStashCodecError } from '@/prisma/core/errors'
+import { cipherstashEncryption } from '@/prisma/exports/runtime'
+import type { ContractLike } from '@/prisma/exports/runtime'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ ALL_DATATYPES_CONTRACT,
+ createMockEncryptionClient,
+} from './prisma-test-helpers'
+
+const ENV_VAR_NAMES = [
+ 'CS_WORKSPACE_CRN',
+ 'CS_CLIENT_ID',
+ 'CS_CLIENT_KEY',
+] as const
+
+describe('cipherstashEncryption — eager env-var validation (F-5)', () => {
+ const saved: Record = {}
+
+ beforeEach(() => {
+ for (const name of ENV_VAR_NAMES) {
+ saved[name] = process.env[name]
+ delete process.env[name]
+ }
+ })
+
+ afterEach(() => {
+ for (const name of ENV_VAR_NAMES) {
+ const value = saved[name]
+ if (value === undefined) {
+ delete process.env[name]
+ } else {
+ process.env[name] = value
+ }
+ }
+ })
+
+ it('throws synchronously at construction time when no encryptionClient and required env vars are missing', () => {
+ let err: unknown
+ try {
+ cipherstashEncryption({ contract: ALL_DATATYPES_CONTRACT })
+ } catch (e) {
+ err = e
+ }
+ expect(err).toBeInstanceOf(CipherStashCodecError)
+ if (err instanceof CipherStashCodecError) {
+ expect(err.code).toBe('CONFIG_MISSING_ENV')
+ // Every missing env var named on a single line.
+ for (const name of ENV_VAR_NAMES) {
+ expect(err.message).toContain(name)
+ }
+ // Single line: should not have any newlines that would split
+ // the missing-env list across multiple log entries.
+ const firstLine = err.message.split('\n')[0] ?? ''
+ for (const name of ENV_VAR_NAMES) {
+ expect(firstLine).toContain(name)
+ }
+ }
+ })
+
+ it('does not throw at construction time when an encryptionClient is supplied', () => {
+ const { client } = createMockEncryptionClient()
+ expect(() =>
+ cipherstashEncryption({
+ encryptionClient: client,
+ contract: ALL_DATATYPES_CONTRACT,
+ }),
+ ).not.toThrow()
+ })
+
+ it('does not throw at construction time when only some env vars are present', () => {
+ process.env.CS_WORKSPACE_CRN = 'crn:test.aws:test'
+ let err: unknown
+ try {
+ cipherstashEncryption({ contract: ALL_DATATYPES_CONTRACT })
+ } catch (e) {
+ err = e
+ }
+ expect(err).toBeInstanceOf(CipherStashCodecError)
+ if (err instanceof CipherStashCodecError) {
+ expect(err.code).toBe('CONFIG_MISSING_ENV')
+ // Only the missing ones are named.
+ expect(err.message).not.toContain('CS_WORKSPACE_CRN,')
+ expect(err.message).toContain('CS_CLIENT_ID')
+ expect(err.message).toContain('CS_CLIENT_KEY')
+ }
+ })
+
+ it('does not throw when all env vars are set', () => {
+ for (const name of ENV_VAR_NAMES) {
+ process.env[name] = `placeholder-${name}`
+ }
+ expect(() =>
+ cipherstashEncryption({ contract: ALL_DATATYPES_CONTRACT }),
+ ).not.toThrow()
+ })
+})
+
+describe('cipherstashEncryption — descriptor shape', () => {
+ const { client } = createMockEncryptionClient()
+
+ it('returns a fresh descriptor on every call (no module-level singleton)', () => {
+ const a = cipherstashEncryption({
+ encryptionClient: client,
+ contract: ALL_DATATYPES_CONTRACT,
+ })
+ const b = cipherstashEncryption({
+ encryptionClient: client,
+ contract: ALL_DATATYPES_CONTRACT,
+ })
+ expect(a).not.toBe(b)
+ })
+
+ it('exposes the codec registry via codecs()', () => {
+ const ext = cipherstashEncryption({
+ encryptionClient: client,
+ contract: ALL_DATATYPES_CONTRACT,
+ })
+ const registry = ext.codecs()
+ expect(registry.get('cs/eql_v2_encrypted@1')).toBeDefined()
+ expect(registry.get('cs/eql_v2_eq_term@1')).toBeDefined()
+ expect(registry.get('cs/eql_v2_match_term@1')).toBeDefined()
+ expect(registry.get('cs/eql_v2_ore_term@1')).toBeDefined()
+ expect(registry.get('cs/eql_v2_ste_vec_selector@1')).toBeDefined()
+ })
+
+ it('exposes the operator descriptors via queryOperations()', () => {
+ const ext = cipherstashEncryption({
+ encryptionClient: client,
+ contract: ALL_DATATYPES_CONTRACT,
+ })
+ const ops = ext.queryOperations?.() ?? []
+ expect(ops.find((o) => o.method === 'eq')).toBeDefined()
+ expect(ops.find((o) => o.method === 'gte')).toBeDefined()
+ expect(ops.find((o) => o.method === 'ilike')).toBeDefined()
+ expect(ops.find((o) => o.method === 'jsonbPathExists')).toBeDefined()
+ })
+})
+
+describe('cipherstashEncryption — empty contract', () => {
+ const empty: ContractLike = { storage: { tables: {} } }
+ const { client } = createMockEncryptionClient()
+
+ it('constructs successfully when an encryptionClient is supplied', () => {
+ expect(() =>
+ cipherstashEncryption({ encryptionClient: client, contract: empty }),
+ ).not.toThrow()
+ })
+})
diff --git a/packages/stack/__tests__/prisma-test-helpers.ts b/packages/stack/__tests__/prisma-test-helpers.ts
new file mode 100644
index 00000000..953b0d49
--- /dev/null
+++ b/packages/stack/__tests__/prisma-test-helpers.ts
@@ -0,0 +1,211 @@
+import type { EncryptionClient } from '@/encryption'
+import type {
+ CipherStashCodecContext,
+ CipherStashEncryptionEvent,
+} from '@/prisma/core/codec-context'
+import { ENCRYPTED_STORAGE_CODEC_ID } from '@/prisma/core/constants'
+import { createEncryptionBinding } from '@/prisma/core/encryption-client'
+import {
+ type ContractLike,
+ extractEncryptedSchemas,
+} from '@/prisma/core/extraction'
+import type { Encrypted } from '@/types'
+import { vi } from 'vitest'
+
+/**
+ * Synthetic Prisma Next contract covering one encrypted column per
+ * data type. Codec tests use this to drive the dispatch-by-JS-runtime
+ * path without authoring a fake contract per test.
+ */
+export const ALL_DATATYPES_CONTRACT: ContractLike = {
+ storage: {
+ tables: {
+ users: {
+ columns: {
+ email: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'string',
+ equality: true,
+ freeTextSearch: true,
+ orderAndRange: false,
+ searchableJson: false,
+ },
+ },
+ age: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'number',
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: true,
+ searchableJson: false,
+ },
+ },
+ isActive: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'boolean',
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ },
+ },
+ createdAt: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'date',
+ equality: true,
+ freeTextSearch: false,
+ orderAndRange: true,
+ searchableJson: false,
+ },
+ },
+ profile: {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ typeParams: {
+ dataType: 'json',
+ equality: false,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: true,
+ },
+ },
+ },
+ },
+ },
+ },
+}
+
+/**
+ * Mock `EncryptionClient` capturing calls per method. The mock
+ * captures the resolved column name from the per-call `EncryptOptions`
+ * so tests can assert that the codec dispatched to the right column.
+ */
+export function createMockEncryptionClient() {
+ const bulkEncrypt = vi.fn(
+ async (
+ payload: ReadonlyArray<{ id?: string; plaintext: unknown }>,
+ opts: { column: { getName(): string } | string; table: unknown },
+ ): Promise<{
+ failure?: never
+ data: ReadonlyArray<{ id?: string; data: Encrypted }>
+ }> => {
+ const columnName =
+ typeof opts.column === 'string' ? opts.column : opts.column.getName()
+ const tableName =
+ typeof opts.table === 'object' &&
+ opts.table !== null &&
+ 'tableName' in opts.table
+ ? String((opts.table as { tableName: unknown }).tableName)
+ : 'unknown'
+ return {
+ data: payload.map((p) => ({
+ id: p.id,
+ data: {
+ i: { t: tableName, c: columnName },
+ v: 1,
+ c: `enc:${String(p.plaintext)}`,
+ } satisfies Encrypted,
+ })),
+ }
+ },
+ )
+
+ /**
+ * Mock decrypt that round-trips by stripping the `enc:` prefix and
+ * — when the original plaintext was a Date / number / boolean —
+ * coerces back to the right JS type. The real SDK does this via
+ * `cast_as`; the mock simulates the same contract.
+ */
+ const bulkDecrypt = vi.fn(
+ async (
+ payload: ReadonlyArray<{ id?: string; data: Encrypted }>,
+ ): Promise<{
+ failure?: never
+ data: ReadonlyArray<{ id?: string; data: unknown; error?: never }>
+ }> => ({
+ data: payload.map((p) => {
+ const cipher = p.data.c ?? ''
+ const stripped = cipher.startsWith('enc:') ? cipher.slice(4) : cipher
+ return { id: p.id, data: stripped }
+ }),
+ }),
+ )
+
+ const encryptQuery = vi.fn(async (terms: unknown) => {
+ if (!Array.isArray(terms)) {
+ throw new Error('mock encryptQuery only handles batch shape')
+ }
+ return {
+ data: terms.map(
+ (t: {
+ value: unknown
+ column: { getName(): string } | string
+ table: unknown
+ }) => {
+ const columnName =
+ typeof t.column === 'string' ? t.column : t.column.getName()
+ const tableName =
+ typeof t.table === 'object' &&
+ t.table !== null &&
+ 'tableName' in t.table
+ ? String((t.table as { tableName: unknown }).tableName)
+ : 'unknown'
+ return {
+ i: { t: tableName, c: columnName },
+ v: 1,
+ c: `qterm:${String(t.value)}`,
+ } satisfies Encrypted
+ },
+ ),
+ }
+ })
+
+ const client = {
+ bulkEncrypt,
+ bulkDecrypt,
+ encryptQuery,
+ } as unknown as EncryptionClient
+
+ return { client, bulkEncrypt, bulkDecrypt, encryptQuery }
+}
+
+export interface TestCodecContextOptions {
+ readonly contract?: ContractLike
+ readonly client?: EncryptionClient
+ readonly emit?: (event: CipherStashEncryptionEvent) => void
+}
+
+/**
+ * Build a `CipherStashCodecContext` for codec-level tests.
+ *
+ * Defaults to the synthetic all-data-types contract plus a mock
+ * client. Callers can override the contract (to drive the
+ * `NO_COLUMN_FOR_DATATYPE` path) or supply a custom event emitter
+ * (to assert observability).
+ */
+export function createTestCodecContext(
+ opts: TestCodecContextOptions = {},
+): CipherStashCodecContext & {
+ readonly emitted: CipherStashEncryptionEvent[]
+} {
+ const { client = createMockEncryptionClient().client } = opts
+ const contract = opts.contract ?? ALL_DATATYPES_CONTRACT
+ const schemas = extractEncryptedSchemas(contract)
+ const binding = createEncryptionBinding({ client, schemas })
+ const emitted: CipherStashEncryptionEvent[] = []
+ const emit =
+ opts.emit ??
+ ((event: CipherStashEncryptionEvent) => {
+ emitted.push(event)
+ })
+ return {
+ binding,
+ emit,
+ get emitted() {
+ return emitted
+ },
+ }
+}
diff --git a/packages/stack/package.json b/packages/stack/package.json
index ca8a4be2..852ae69d 100644
--- a/packages/stack/package.json
+++ b/packages/stack/package.json
@@ -62,6 +62,27 @@
],
"supabase": [
"./dist/supabase/index.d.ts"
+ ],
+ "prisma": [
+ "./dist/prisma/index.d.ts"
+ ],
+ "prisma/control": [
+ "./dist/prisma/exports/control.d.ts"
+ ],
+ "prisma/runtime": [
+ "./dist/prisma/exports/runtime.d.ts"
+ ],
+ "prisma/pack": [
+ "./dist/prisma/exports/pack.d.ts"
+ ],
+ "prisma/column-types": [
+ "./dist/prisma/exports/column-types.d.ts"
+ ],
+ "prisma/codec-types": [
+ "./dist/prisma/exports/codec-types.d.ts"
+ ],
+ "prisma/operation-types": [
+ "./dist/prisma/exports/operation-types.d.ts"
]
}
},
@@ -176,13 +197,85 @@
"default": "./dist/errors/index.cjs"
}
},
+ "./prisma": {
+ "import": {
+ "types": "./dist/prisma/index.d.ts",
+ "default": "./dist/prisma/index.js"
+ },
+ "require": {
+ "types": "./dist/prisma/index.d.cts",
+ "default": "./dist/prisma/index.cjs"
+ }
+ },
+ "./prisma/control": {
+ "import": {
+ "types": "./dist/prisma/exports/control.d.ts",
+ "default": "./dist/prisma/exports/control.js"
+ },
+ "require": {
+ "types": "./dist/prisma/exports/control.d.cts",
+ "default": "./dist/prisma/exports/control.cjs"
+ }
+ },
+ "./prisma/runtime": {
+ "import": {
+ "types": "./dist/prisma/exports/runtime.d.ts",
+ "default": "./dist/prisma/exports/runtime.js"
+ },
+ "require": {
+ "types": "./dist/prisma/exports/runtime.d.cts",
+ "default": "./dist/prisma/exports/runtime.cjs"
+ }
+ },
+ "./prisma/pack": {
+ "import": {
+ "types": "./dist/prisma/exports/pack.d.ts",
+ "default": "./dist/prisma/exports/pack.js"
+ },
+ "require": {
+ "types": "./dist/prisma/exports/pack.d.cts",
+ "default": "./dist/prisma/exports/pack.cjs"
+ }
+ },
+ "./prisma/column-types": {
+ "import": {
+ "types": "./dist/prisma/exports/column-types.d.ts",
+ "default": "./dist/prisma/exports/column-types.js"
+ },
+ "require": {
+ "types": "./dist/prisma/exports/column-types.d.cts",
+ "default": "./dist/prisma/exports/column-types.cjs"
+ }
+ },
+ "./prisma/codec-types": {
+ "import": {
+ "types": "./dist/prisma/exports/codec-types.d.ts",
+ "default": "./dist/prisma/exports/codec-types.js"
+ },
+ "require": {
+ "types": "./dist/prisma/exports/codec-types.d.cts",
+ "default": "./dist/prisma/exports/codec-types.cjs"
+ }
+ },
+ "./prisma/operation-types": {
+ "import": {
+ "types": "./dist/prisma/exports/operation-types.d.ts",
+ "default": "./dist/prisma/exports/operation-types.js"
+ },
+ "require": {
+ "types": "./dist/prisma/exports/operation-types.d.cts",
+ "default": "./dist/prisma/exports/operation-types.cjs"
+ }
+ },
"./package.json": "./package.json"
},
"scripts": {
+ "prebuild": "tsx scripts/vendor-eql-install.ts",
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
- "release": "tsup"
+ "release": "tsup",
+ "vendor-eql-install": "tsx scripts/vendor-eql-install.ts"
},
"devDependencies": {
"@clack/prompts": "^0.10.1",
@@ -204,6 +297,7 @@
"dependencies": {
"@byteslice/result": "0.2.0",
"@cipherstash/protect-ffi": "0.21.2",
+ "arktype": "^2.2.0",
"evlog": "1.9.0",
"uuid": "13.0.0",
"zod": "3.24.2"
diff --git a/packages/stack/scripts/vendor-eql-install.ts b/packages/stack/scripts/vendor-eql-install.ts
new file mode 100644
index 00000000..35c9f2bd
--- /dev/null
+++ b/packages/stack/scripts/vendor-eql-install.ts
@@ -0,0 +1,160 @@
+/**
+ * Build-time vendor script: fetches the EQL install SQL bundle from the
+ * pinned `encrypt-query-language` GitHub release, writes the raw SQL to
+ * `src/prisma/core/eql-install.sql`, and emits a tiny TypeScript module
+ * `src/prisma/core/eql-install.generated.ts` that exports the SQL as a
+ * string literal.
+ *
+ * Why two artefacts:
+ * - The raw `.sql` file is committed and human-readable: easy to
+ * inspect during code review, easy for an operator to apply manually
+ * in an emergency, and easy to diff between version bumps.
+ * - The `.generated.ts` module is what the codec imports at runtime.
+ * It works in both ESM and CJS dist outputs because the SQL becomes
+ * a regular ES module export — no `import.meta`, no `fs.readFileSync`,
+ * no path resolution. The trade-off is the bundled JS is larger by
+ * the size of the SQL bundle (~170 KB), but the migration planner
+ * is the only consumer and it'd otherwise have to ship the SQL
+ * anyway via some other channel.
+ *
+ * Usage:
+ * pnpm vendor-eql-install
+ *
+ * The script is idempotent: if both files exist and the pinned version
+ * line in the SQL header matches, the script no-ops. If the network call
+ * fails and the files are absent, the script exits 1; if the files
+ * already exist, the script logs a warning and reuses the cache.
+ */
+
+import { existsSync, readFileSync, writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+/**
+ * Pinned EQL release version. Bump this only as part of an explicit
+ * upgrade — Phase 3 ships against this version, Phase 4 will introduce
+ * `databaseDependencies.upgrade(fromVersion, toVersion)` for live DDL
+ * upgrades between bumps.
+ */
+const EQL_VERSION = 'eql-2.2.1' as const
+
+const EQL_INSTALL_URL = `https://github.com/cipherstash/encrypt-query-language/releases/download/${EQL_VERSION}/cipherstash-encrypt.sql`
+
+const __dirname = fileURLToPath(new URL('.', import.meta.url))
+const SQL_PATH = resolve(
+ __dirname,
+ '..',
+ 'src',
+ 'prisma',
+ 'core',
+ 'eql-install.sql',
+)
+const TS_PATH = resolve(
+ __dirname,
+ '..',
+ 'src',
+ 'prisma',
+ 'core',
+ 'eql-install.generated.ts',
+)
+
+/**
+ * Magic header comment we prepend to the vendored SQL so we can verify
+ * pinned-version match without re-downloading. Keeping it on the first
+ * line means a quick `head -1` check tells us which version is on disk.
+ */
+const HEADER_PREFIX =
+ '-- @cipherstash/stack/prisma — vendored EQL install bundle'
+
+function buildHeader(version: string): string {
+ return `${HEADER_PREFIX} (version: ${version})\n`
+}
+
+function existingVersion(): string | null {
+ if (!existsSync(SQL_PATH)) return null
+ const head = readFileSync(SQL_PATH, 'utf8').split('\n', 1)[0] ?? ''
+ const match = head.match(
+ /-- @cipherstash\/stack\/prisma — vendored EQL install bundle \(version: (.+?)\)/,
+ )
+ return match?.[1] ?? null
+}
+
+async function fetchBundle(url: string): Promise {
+ const res = await fetch(url)
+ if (!res.ok) {
+ throw new Error(
+ `Failed to download EQL install bundle from ${url}: ${res.status} ${res.statusText}`,
+ )
+ }
+ return res.text()
+}
+
+/**
+ * Render the TypeScript module that exports the SQL bundle as a string
+ * literal. We use a backtick-delimited template literal with the only
+ * unsafe characters (backtick and `${`) escaped — never JSON.stringify
+ * because the SQL bundle is large and we want it human-readable in the
+ * source tree.
+ */
+function renderTsModule(version: string, sql: string): string {
+ const escaped = sql
+ .replace(/\\/g, '\\\\')
+ .replace(/`/g, '\\`')
+ .replace(/\$\{/g, '\\${')
+ return `// @generated — DO NOT EDIT.
+// Source: scripts/vendor-eql-install.ts
+// Bundle pinned version: ${version}
+//
+// This file is committed to source control so dev environments and
+// offline builds work without network access. Regenerate with
+// \`pnpm vendor-eql-install\` after bumping EQL_VERSION in the script.
+
+export const EQL_INSTALL_VERSION = ${JSON.stringify(version)} as const
+
+export const EQL_INSTALL_SQL: string = \`${escaped}\`
+`
+}
+
+async function main(): Promise {
+ const onDisk = existingVersion()
+ const tsExists = existsSync(TS_PATH)
+
+ if (onDisk === EQL_VERSION && tsExists) {
+ console.log(
+ `[vendor-eql-install] Pinned version ${EQL_VERSION} already on disk — no-op.`,
+ )
+ return
+ }
+
+ console.log(
+ `[vendor-eql-install] Fetching EQL install bundle (version: ${EQL_VERSION}) from ${EQL_INSTALL_URL}`,
+ )
+
+ let body: string
+ try {
+ body = await fetchBundle(EQL_INSTALL_URL)
+ } catch (err) {
+ if (existsSync(SQL_PATH) && tsExists) {
+ console.warn(
+ '[vendor-eql-install] Network fetch failed but cached bundle and generated module exist. Using cache.',
+ )
+ console.warn(
+ `[vendor-eql-install] Cause: ${err instanceof Error ? err.message : String(err)}`,
+ )
+ return
+ }
+ throw err
+ }
+
+ const header = buildHeader(EQL_VERSION)
+ writeFileSync(SQL_PATH, header + body, 'utf8')
+ writeFileSync(TS_PATH, renderTsModule(EQL_VERSION, body), 'utf8')
+ console.log(`[vendor-eql-install] Wrote ${SQL_PATH} (${body.length} bytes)`)
+ console.log(`[vendor-eql-install] Wrote ${TS_PATH}`)
+}
+
+void main().catch((err: unknown) => {
+ const message = err instanceof Error ? err.message : String(err)
+ console.error(`[vendor-eql-install] Error: ${message}`)
+ process.exit(1)
+})
diff --git a/packages/stack/src/prisma/README.md b/packages/stack/src/prisma/README.md
new file mode 100644
index 00000000..e6074fd8
--- /dev/null
+++ b/packages/stack/src/prisma/README.md
@@ -0,0 +1,315 @@
+# `@cipherstash/stack/prisma`
+
+> Data-level access control for Prisma Next. Every sensitive value encrypted with a unique key. Searchable on existing Postgres indexes. A breach yields ciphertext, nothing useful.
+
+> This README is the source of truth for usage. The architectural reference is [`notes/cipherstash-prisma-integration-plan-v2.md`](../../../../notes/cipherstash-prisma-integration-plan-v2.md).
+
+## What this is
+
+CipherStash makes access control cryptographic. The rules aren't configured — they're enforced. `@cipherstash/stack/prisma` is the [Prisma Next](https://github.com/prisma/prisma-next) integration: searchable, application-layer field-level encryption backed by CipherStash's open-source [EQL](https://github.com/cipherstash/encrypt-query-language) extension and ZeroKMS for key management.
+
+Plaintext goes in, ciphertext lands in Postgres. Range queries, exact match, and free-text fuzzy search run over encrypted columns natively, with sub-millisecond overhead on existing Postgres indexes. The integration is a single Prisma Next extension pack — no middleware, no manual `bulkEncrypt` / `bulkDecrypt` calls, no separate query builder.
+
+Per-value decryption policies tied to caller identity are part of CipherStash's full data-level access control model; identity-binding (`LockContext`) is deferred from this initial rollout and returns when decryption policies land server-side.
+
+## Install
+
+```bash
+pnpm add @cipherstash/stack
+# pnpm add @prisma-next/cli @prisma-next/sql-runtime @prisma-next/family-sql @prisma-next/sql-contract-ts @prisma-next/target-postgres @prisma-next/adapter-postgres
+```
+
+Prisma Next is currently consumed via vendored type shapes (see `src/prisma/internal-types/prisma-next.ts`); the peer-dependency block above is commented out until Prisma Next ships to npm.
+
+## Setup
+
+### 1. Required env vars
+
+```bash
+CS_WORKSPACE_CRN=crn:.aws:
+CS_CLIENT_ID=
+CS_CLIENT_KEY=
+CS_CLIENT_ACCESS_KEY= # only required for bootstrap operations
+```
+
+These come from the CipherStash dashboard. The integration validates the first three synchronously when the extension is constructed; missing variables throw a `CipherStashCodecError` with `code: 'CONFIG_MISSING_ENV'` listing every absent variable.
+
+### 2. `prisma-next.config.ts`
+
+```ts
+import { defineConfig } from '@prisma-next/cli/config-types'
+import postgresAdapter from '@prisma-next/adapter-postgres/control'
+import sql from '@prisma-next/family-sql/control'
+import postgres from '@prisma-next/target-postgres/control'
+import cipherstashEncryption from '@cipherstash/stack/prisma/control'
+
+export default defineConfig({
+ family: sql,
+ target: postgres,
+ adapter: postgresAdapter,
+ extensionPacks: [cipherstashEncryption],
+})
+```
+
+### 3. `contract.ts`
+
+```ts
+import { int4Column, timestamptzColumn } from '@prisma-next/adapter-postgres/column-types'
+import sqlFamily from '@prisma-next/family-sql/pack'
+import postgres from '@prisma-next/target-postgres/pack'
+import { defineContract, field, model } from '@prisma-next/sql-contract-ts/contract-builder'
+import cipherstashEncryption from '@cipherstash/stack/prisma/pack'
+import {
+ encryptedBoolean,
+ encryptedDate,
+ encryptedJson,
+ encryptedNumber,
+ encryptedString,
+} from '@cipherstash/stack/prisma/column-types'
+
+export const contract = defineContract({
+ family: sqlFamily,
+ target: postgres,
+ extensionPacks: { cipherstashEncryption },
+ models: {
+ User: model('User', {
+ fields: {
+ id: field.column(int4Column).id(),
+ email: field.column(encryptedString({ equality: true, freeTextSearch: true })),
+ age: field.column(encryptedNumber({ orderAndRange: true })).optional(),
+ isActive: field.column(encryptedBoolean({ equality: true })).optional(),
+ createdAt: field.column(encryptedDate({ orderAndRange: true })),
+ profile: field.column(
+ encryptedJson<{ name: string; bio: string }>({ searchableJson: true }),
+ ).optional(),
+ },
+ }).sql({ table: 'users' }),
+ },
+})
+```
+
+The five column factories cover the supported plaintext types. Pass the JSON shape as a type argument to `encryptedJson(...)` so `Decrypted['profile']` resolves to `T` rather than `unknown`.
+
+### 4. `db.ts`
+
+```ts
+import postgres from '@prisma-next/postgres/runtime'
+import cipherstashEncryption from '@cipherstash/stack/prisma/runtime'
+import type { Contract } from './contract.d'
+import contractJson from './contract.json' with { type: 'json' }
+import { contract } from './contract'
+
+export const db = postgres({
+ contractJson,
+ extensions: [cipherstashEncryption({ contract })],
+})
+```
+
+That is the entire setup. The extension reads the contract, derives one `EncryptedTable` per encrypted column, validates env vars, and binds a fresh `EncryptionClient` to itself.
+
+### 5. Migrations
+
+```bash
+pnpm prisma-next db migrate
+```
+
+The first run installs EQL via `databaseDependencies.init` (vendored `eql-2.2.1`). Subsequent runs regenerate per-column index DDL via `planTypeOperations`. Both steps are idempotent — re-running the same plan against an already-installed schema is a no-op.
+
+## Usage
+
+### Insert
+
+```ts
+await db.orm.User.createMany({
+ data: [
+ { email: 'alice@example.com', age: 30, createdAt: new Date(), isActive: true,
+ profile: { name: 'Alice', bio: 'Dev' } },
+ { email: 'bob@example.com', age: 42, createdAt: new Date(), isActive: false,
+ profile: { name: 'Bob', bio: 'Ops' } },
+ ],
+})
+```
+
+Plaintext goes in. The codec encrypts every encrypted-column cell and the runtime issues one ZeroKMS round-trip per `createMany` call (microtask batching coalesces all in-flight `encode` calls into a single dispatch).
+
+### Equality query
+
+```ts
+import { param } from '@prisma-next/sql-query/param'
+
+const alice = await db.orm.User
+ .where(u => u.email.eq(param('e')))
+ .first({ params: { e: 'alice@example.com' } })
+```
+
+The fluent form parameterizes the value through the `eq-term` codec and lowers to `eql_v2.eq(email, $1::eql_v2_encrypted)` SQL. The shorthand `where({ email: 'alice@example.com' })` is **not yet supported** — Prisma Next inlines literal values without consulting the codec, so the encrypted column rejects shorthand at parse time with a framework-level error. Use the fluent form for now.
+
+### Range query
+
+```ts
+const adults = await db.orm.User
+ .where(u => u.age.gte(param('min')))
+ .all({ params: { min: 18 } })
+```
+
+`.gt()` / `.gte()` / `.lt()` / `.lte()` / `.between()` / `.notBetween()` are surfaced on columns whose `typeParams.orderAndRange === true`. Argument types match the column's `dataType` — `Date` for `encryptedDate`, `number` for `encryptedNumber`.
+
+### Free-text search
+
+```ts
+const matches = await db.orm.User
+ .where(u => u.email.ilike(param('q')))
+ .all({ params: { q: 'example.com' } })
+```
+
+`.like()` / `.ilike()` / `.notIlike()` are surfaced on `encryptedString({ freeTextSearch: true })` columns.
+
+### JSONB selectors
+
+```ts
+const devs = await db.orm.User
+ .where(u => u.profile.pathExists(param('sel')))
+ .all({ params: { sel: '$.bio ? (@ == "Dev")' } })
+```
+
+`.pathExists()` / `.pathQueryFirst()` / `.get()` are surfaced on `encryptedJson({ searchableJson: true })` columns. (The internal method names mirror EQL's SQL functions: `jsonbPathExists`, `jsonbPathQueryFirst`, `jsonbGet`.)
+
+### Reads
+
+Rows arrive with encrypted columns already decrypted — no `bulkDecryptModels` call:
+
+```ts
+const user = await db.orm.User.where(/* … */).first()
+console.log(user.email) // 'alice@example.com'
+console.log(user.profile.name) // 'Alice' (typed via `encryptedJson<{ name; bio }>`)
+```
+
+Use the `Decrypted` type helper when typing function signatures around decrypted rows:
+
+```ts
+import type { Decrypted } from '@cipherstash/stack/prisma/codec-types'
+
+type DecryptedUser = Decrypted
+
+function welcome(user: DecryptedUser) {
+ console.log(user.email.toLowerCase()) // string
+ console.log(user.profile.name) // typed via encryptedJson
+}
+```
+
+## Multi-tenancy
+
+Each `cipherstashEncryption({ encryptionClient, contract })` call binds a specific client instance to that extension instance. There is no module-level singleton — two extensions live side-by-side without cross-talk.
+
+For per-tenant key isolation, construct one extension per tenant and route requests through the right `db` instance:
+
+```ts
+import { Encryption } from '@cipherstash/stack'
+
+async function tenantDb(tenant: { id: string; keysetName: string }) {
+ const tenantClient = await Encryption({
+ schemas: [/* extracted from contract */],
+ config: { keyset: { name: tenant.keysetName } },
+ })
+ return postgres({
+ contractJson,
+ extensions: [
+ cipherstashEncryption({ encryptionClient: tenantClient, contract }),
+ ],
+ })
+}
+```
+
+Calls flowing through `tenantDb('a')` only ever reach client A; calls flowing through `tenantDb('b')` only reach client B.
+
+## Observability
+
+Pass an `onEvent` callback to capture every ZeroKMS round-trip:
+
+```ts
+cipherstashEncryption({
+ contract,
+ onEvent(event) {
+ metrics.histogram('cipherstash.duration_ms', event.durationMs, {
+ kind: event.kind,
+ table: event.table,
+ column: event.column,
+ ok: event.error === undefined,
+ })
+ },
+})
+```
+
+The event payload:
+
+```ts
+{
+ kind: 'bulkEncrypt' | 'bulkDecrypt' | 'encryptQuery'
+ codecId: string // e.g. 'cs/eql_v2_encrypted@1'
+ batchSize: number // values in this batch
+ durationMs: number // wall-clock time of the SDK call
+ table: string | undefined // resolved Postgres table name
+ column: string | undefined // resolved Postgres column name
+ error: unknown | undefined // populated on failure
+}
+```
+
+When `onEvent` is omitted, the default behaviour is silent in production (`NODE_ENV === 'production'`) and `console.debug(...)` per round-trip in dev. The default never logs plaintext or ciphertext.
+
+## What's not yet supported
+
+| Feature | Status |
+|---|---|
+| Shorthand `where({ email: '…' })` on encrypted columns | Deferred — Prisma Next inlines literals; pending an upstream `preferParam` trait. Use the fluent `.eq(param(…))` form. |
+| `inArray` on encrypted columns | Deferred — no `eql_v2.in_array` SQL function exists in EQL. Compose `or(...)` of `.eq()` calls or open an issue. |
+| `.order()` / `.asc()` / `.desc()` on ORE columns | Deferred — Prisma Next's fluent column-side ordering surface is unstable post-#379. Fall back to a raw SQL fragment via `sqlExpression\`eql_v2.order_by(${col}) DESC\``. |
+| Identity-aware encryption (`LockContext`) | Deferred — returning when decryption policies land server-side. |
+| Cross-row decode batching | Deferred until upstream [TML-2330](https://linear.app/prisma-company/issue/TML-2330) lands. Within-row batching (i.e. one ZeroKMS call per `decodeRow`) is shipped. |
+
+## Errors
+
+Errors raised by the integration are instances of `CipherStashCodecError`:
+
+```ts
+import { CipherStashCodecError } from '@cipherstash/stack/prisma'
+
+try {
+ await db.orm.User.createMany({ data: [{ email: 'alice@example.com' }] })
+} catch (err) {
+ if (err instanceof CipherStashCodecError) {
+ console.log(err.code) // discriminator (see below)
+ console.log(err.column) // resolved column name when known
+ console.log(err.expectedDataType) // 'string' | 'number' | …
+ console.log(err.actualType) // JS-runtime type observed
+ console.log(err.cause) // wrapped underlying error
+ }
+}
+```
+
+| `code` | Cause |
+|---|---|
+| `UNSUPPORTED_PLAINTEXT_TYPE` | Value type not in `string \| number \| boolean \| Date \| object` (e.g. `bigint`, `symbol`, `function`). |
+| `JS_TYPE_MISMATCH` | Value's runtime type doesn't match the contract column's declared `dataType` (e.g. `string` into a `number` column on a query term). |
+| `INVALID_QUERY_TERM` | `encryptQuery` rejected the term — usually a backend / schema mismatch. |
+| `DECODE_ROUND_TRIP_BROKEN` | `bulkDecrypt` rejected the cipher (wrong workspace, expired keys, ZeroKMS network). The `cause` carries the SDK's structured error. |
+| `NO_COLUMN_FOR_DATATYPE` | The contract has no encrypted column matching the JS-runtime data type the codec saw at encode time. Add a column of that type, or check the value before encoding. |
+| `CONFIG_MISSING_ENV` | `cipherstashEncryption()` was constructed without `encryptionClient` and one or more required env vars are absent. The message names every missing variable on a single line. |
+| `NO_CONTRACT_SCHEMAS` | Default-client construction was requested but the contract declared no encrypted columns. Add an encrypted column or pass a pre-constructed `encryptionClient`. |
+
+### Common misconfigurations
+
+- **Missing env vars** — `CONFIG_MISSING_ENV` raised at extension construction time, not deep in a request handler. Fix by exporting the vars or passing `encryptionClient` directly.
+- **Empty contract** — `cipherstashEncryption({ contract })` succeeds but the first encode raises `NO_COLUMN_FOR_DATATYPE`. Add at least one encrypted column.
+- **Mismatched data type** — `encryptedNumber({...})` column receives a `string`. The match-term, ORE-term, and STE-vec codecs reject mismatched values eagerly with `JS_TYPE_MISMATCH`; the storage codec falls back to JS-runtime dispatch.
+
+## Imports
+
+| Path | Use |
+|---|---|
+| `@cipherstash/stack/prisma/control` | Build-time / migration planner. Used in `prisma-next.config.ts`. |
+| `@cipherstash/stack/prisma/runtime` | Runtime extension. Used in `db.ts`. |
+| `@cipherstash/stack/prisma/pack` | Pack metadata. Used in `contract.ts`. |
+| `@cipherstash/stack/prisma/column-types` | `encryptedString` / `encryptedNumber` / etc. Used in `contract.ts`. |
+| `@cipherstash/stack/prisma/codec-types` | Type-only: `Decrypted`, `JsTypeFor`, `CodecTypes`. |
+| `@cipherstash/stack/prisma/operation-types` | Type-only: `OperationTypes` for the contract emitter. |
+| `@cipherstash/stack/prisma` | Convenience barrel re-exporting everything. Prefer the subpaths in production code so bundlers can tree-shake the EQL bundle out of browser builds. |
diff --git a/packages/stack/src/prisma/core/authoring.ts b/packages/stack/src/prisma/core/authoring.ts
new file mode 100644
index 00000000..96e4353e
--- /dev/null
+++ b/packages/stack/src/prisma/core/authoring.ts
@@ -0,0 +1,24 @@
+import { type } from 'arktype'
+
+/**
+ * Arktype schema validating the `typeParams` carried on encrypted columns.
+ *
+ * Phase 2 widens beyond Phase 1's `dataType: 'string'` constraint to cover
+ * every `EncryptedDataType`. The four searchable-encryption flags are
+ * always present (defaulting to `false` from the column-type factories) so
+ * the migration planner sees a uniform shape per column. The runtime
+ * extension's `parameterizedCodecs()` declaration invokes this schema once
+ * per column at context-creation time.
+ *
+ * Arktype is the convention for parameterized codecs in Prisma Next (see
+ * pgvector's `vectorParamsSchema`). We carry the dependency for parity.
+ */
+export const encryptedStorageParamsSchema = type({
+ dataType: "'string' | 'number' | 'boolean' | 'date' | 'json'",
+ equality: 'boolean',
+ freeTextSearch: 'boolean',
+ orderAndRange: 'boolean',
+ searchableJson: 'boolean',
+})
+
+export type EncryptedStorageParams = typeof encryptedStorageParamsSchema.infer
diff --git a/packages/stack/src/prisma/core/batcher.ts b/packages/stack/src/prisma/core/batcher.ts
new file mode 100644
index 00000000..9505703c
--- /dev/null
+++ b/packages/stack/src/prisma/core/batcher.ts
@@ -0,0 +1,77 @@
+/**
+ * Microtask-coalescing batcher for codec encrypt/decrypt calls.
+ *
+ * Why this exists:
+ * ADR 204 dispatches per-row codec calls via `Promise.all` with no
+ * batching across cells. For ZeroKMS-backed codecs this would issue one
+ * network call per cell — operationally untenable. The batcher exploits
+ * the fact that `encodeParams` (and `decodeRow`) call `codec.encode(value)`
+ * synchronously for every cell before any of the resulting Promises get
+ * a chance to resolve. All `enqueue` calls land in a single microtask
+ * window; the first enqueue schedules a `queueMicrotask(drain)`, the
+ * rest piggy-back, and `drain` sees the entire batch.
+ *
+ * Failure semantics:
+ * `flush` is expected to return one result per input, in input order.
+ * If it throws or rejects, every queued promise rejects with the same
+ * error. If it returns a wrong-length result, every queued promise
+ * rejects with a clear shape error.
+ */
+export type Batcher = {
+ enqueue(value: TIn): Promise
+}
+
+export type FlushFn = (
+ values: readonly TIn[],
+) => Promise
+
+type Pending = {
+ readonly value: TIn
+ readonly resolve: (value: TOut) => void
+ readonly reject: (reason: unknown) => void
+}
+
+export function createBatcher(
+ flush: FlushFn,
+): Batcher {
+ let pending: Array> = []
+ let scheduled = false
+
+ const drain = async (): Promise => {
+ const batch = pending
+ pending = []
+ scheduled = false
+ if (batch.length === 0) return
+ try {
+ const results = await flush(batch.map((entry) => entry.value))
+ if (results.length !== batch.length) {
+ const shapeError = new Error(
+ `Batcher flush returned ${results.length} results for ${batch.length} inputs`,
+ )
+ for (const entry of batch) entry.reject(shapeError)
+ return
+ }
+ for (let i = 0; i < batch.length; i++) {
+ const entry = batch[i]
+ const result = results[i]
+ if (entry) entry.resolve(result as TOut)
+ }
+ } catch (error) {
+ for (const entry of batch) entry.reject(error)
+ }
+ }
+
+ return {
+ enqueue(value: TIn): Promise {
+ return new Promise((resolve, reject) => {
+ pending.push({ value, resolve, reject })
+ if (!scheduled) {
+ scheduled = true
+ queueMicrotask(() => {
+ void drain()
+ })
+ }
+ })
+ },
+ }
+}
diff --git a/packages/stack/src/prisma/core/codec-context.ts b/packages/stack/src/prisma/core/codec-context.ts
new file mode 100644
index 00000000..3c90e89a
--- /dev/null
+++ b/packages/stack/src/prisma/core/codec-context.ts
@@ -0,0 +1,107 @@
+import type { CipherStashEncryptionBinding } from './encryption-client'
+
+/**
+ * Per-extension context passed to every codec factory.
+ *
+ * Carries the resolved client binding plus the optional observability
+ * hook. Codec factories close over this object so each
+ * `cipherstashEncryption({...})` call gets a fresh codec graph with
+ * its own client / contract / event sink.
+ */
+export interface CipherStashCodecContext {
+ readonly binding: CipherStashEncryptionBinding
+ readonly emit: (event: CipherStashEncryptionEvent) => void
+}
+
+/**
+ * Discriminator on every emitted observability event. Maps onto the
+ * three SDK round-trip kinds the integration drives.
+ */
+export type CipherStashEncryptionEventKind =
+ | 'bulkEncrypt'
+ | 'bulkDecrypt'
+ | 'encryptQuery'
+
+/**
+ * Structured payload emitted on every SDK round-trip — both success
+ * and failure. Surfaces enough information for users to:
+ * - count requests per kind / codec / column,
+ * - measure latency p50 / p99,
+ * - alert on failure rate.
+ *
+ * The payload deliberately excludes plaintext / ciphertext to keep
+ * default behavior safe in dev logging.
+ */
+export interface CipherStashEncryptionEvent {
+ readonly kind: CipherStashEncryptionEventKind
+ readonly codecId: string
+ readonly batchSize: number
+ readonly durationMs: number
+ readonly table: string | undefined
+ readonly column: string | undefined
+ /** When defined, the round-trip failed with this error. */
+ readonly error: unknown | undefined
+}
+
+/**
+ * Shape of the optional `onEvent` hook accepted by
+ * `cipherstashEncryption({ onEvent })`.
+ */
+export type CipherStashEncryptionEventHook = (
+ event: CipherStashEncryptionEvent,
+) => void
+
+/**
+ * Default event hook used when `onEvent` is omitted. In production
+ * this is a no-op. In dev / test (`NODE_ENV !== 'production'`), it
+ * logs a structured `console.debug(...)` line so the developer can
+ * see the round-trips without instrumenting their own hook.
+ */
+export function defaultEventHook(event: CipherStashEncryptionEvent): void {
+ if (process.env.NODE_ENV === 'production') return
+ const target =
+ event.table && event.column ? `${event.table}.${event.column}` : ''
+ if (event.error) {
+ console.debug(
+ `[cipherstash] ${event.kind}(${event.batchSize}) ${target} failed in ${event.durationMs.toFixed(1)}ms`,
+ event.error,
+ )
+ } else {
+ console.debug(
+ `[cipherstash] ${event.kind}(${event.batchSize}) ${target} ok in ${event.durationMs.toFixed(1)}ms`,
+ )
+ }
+}
+
+/**
+ * Wrap an async SDK call with timing + event emission. Accepts any
+ * thenable (so `BulkEncryptOperation` / `BulkDecryptOperation` /
+ * `BatchEncryptQueryOperation` work without their full Promise
+ * surface). Throws on failure so the caller can still propagate; the
+ * event fires both on success and on failure with `error` populated.
+ * Caller decides whether to translate into a structured
+ * `CipherStashCodecError`.
+ */
+export async function emitTimed(
+ ctx: CipherStashCodecContext,
+ base: Omit,
+ body: () => PromiseLike,
+): Promise {
+ const start = performance.now()
+ try {
+ const result = await body()
+ ctx.emit({
+ ...base,
+ durationMs: performance.now() - start,
+ error: undefined,
+ })
+ return result
+ } catch (error) {
+ ctx.emit({
+ ...base,
+ durationMs: performance.now() - start,
+ error,
+ })
+ throw error
+ }
+}
diff --git a/packages/stack/src/prisma/core/codec-eq-term.ts b/packages/stack/src/prisma/core/codec-eq-term.ts
new file mode 100644
index 00000000..6bf58a35
--- /dev/null
+++ b/packages/stack/src/prisma/core/codec-eq-term.ts
@@ -0,0 +1,143 @@
+import type { Encrypted, ScalarQueryTerm } from '@/types'
+import type { JsPlaintext } from '@cipherstash/protect-ffi'
+import type {
+ CodecTrait,
+ JsonValue,
+ SqlCodec,
+} from '../internal-types/prisma-next'
+import { createBatcher } from './batcher'
+import { type CipherStashCodecContext, emitTimed } from './codec-context'
+import { ENCRYPTED_EQ_TERM_CODEC_ID, type EncryptedDataType } from './constants'
+import { requireColumnFor } from './encryption-client'
+import {
+ CipherStashCodecError,
+ assertJsTypeMatchesDataType,
+ describeJs,
+} from './errors'
+import { eqlToCompositeLiteral } from './wire'
+
+/**
+ * Equality query-term codec factory.
+ *
+ * Used as the value-side codec for `eq` / `neq` / `inArray` operations
+ * on encrypted columns. Routes through
+ * `encryptionClient.encryptQuery({ queryType: 'equality' })` which
+ * emits only the EQ index (HMAC-SHA256 hash) rather than a full
+ * ciphertext.
+ *
+ * Date plaintexts cross the FFI as ISO strings under `cast_as: 'date'`,
+ * mirroring the storage codec.
+ */
+
+const EQ_TERM_TRAITS = [] as const satisfies readonly CodecTrait[]
+
+function toPlaintext(value: unknown, dataType: EncryptedDataType): JsPlaintext {
+ if (dataType === 'date') {
+ if (!(value instanceof Date)) {
+ throw new TypeError(
+ `Expected Date for dataType 'date', got ${describeJs(value)}`,
+ )
+ }
+ return value.toISOString()
+ }
+ return value as JsPlaintext
+}
+
+export function createEncryptedEqTermCodec(
+ ctx: CipherStashCodecContext,
+): SqlCodec<
+ typeof ENCRYPTED_EQ_TERM_CODEC_ID,
+ typeof EQ_TERM_TRAITS,
+ string,
+ unknown
+> {
+ const batchersByDataType = new Map<
+ EncryptedDataType,
+ ReturnType>
+ >()
+
+ const batcherFor = (dataType: EncryptedDataType) => {
+ let batcher = batchersByDataType.get(dataType)
+ if (batcher) return batcher
+ batcher = createBatcher(async (values) => {
+ const client = await ctx.binding.getClient()
+ const columnBinding = requireColumnFor(ctx.binding, dataType, {
+ codecLabel: 'encryptedEqTermCodec',
+ value: values[0],
+ })
+ const terms: ScalarQueryTerm[] = values.map((value) => ({
+ value: value as ScalarQueryTerm['value'],
+ column: columnBinding.column,
+ table: columnBinding.table,
+ queryType: 'equality',
+ }))
+ const result = await emitTimed(
+ ctx,
+ {
+ kind: 'encryptQuery',
+ codecId: ENCRYPTED_EQ_TERM_CODEC_ID,
+ batchSize: values.length,
+ table: columnBinding.table.tableName,
+ column: columnBinding.columnName,
+ },
+ () => client.encryptQuery(terms),
+ )
+ if (result.failure) {
+ throw new CipherStashCodecError({
+ code: 'INVALID_QUERY_TERM',
+ message: `encryptQuery (equality) failed: ${result.failure.message}`,
+ column: columnBinding.columnName,
+ expectedDataType: dataType,
+ actualType: 'unknown',
+ cause: result.failure,
+ })
+ }
+ return result.data.map((item) => {
+ if (typeof item === 'string') {
+ throw new TypeError(
+ 'encryptQuery returned composite literal where Encrypted was expected',
+ )
+ }
+ return item
+ })
+ })
+ batchersByDataType.set(dataType, batcher)
+ return batcher
+ }
+
+ return {
+ id: ENCRYPTED_EQ_TERM_CODEC_ID,
+ targetTypes: ['csEncryptedEqTerm'],
+ traits: EQ_TERM_TRAITS,
+ meta: {
+ db: {
+ sql: {
+ postgres: {
+ nativeType: 'eql_v2_encrypted',
+ },
+ },
+ },
+ },
+
+ async encode(value: unknown): Promise {
+ const dataType = assertJsTypeMatchesDataType(value, undefined)
+ const plaintext = toPlaintext(value, dataType)
+ const encrypted = await batcherFor(dataType).enqueue(plaintext)
+ return eqlToCompositeLiteral(encrypted)
+ },
+
+ async decode(_wire: string): Promise {
+ throw new Error(
+ 'cs/eql_v2_eq_term@1 is a write-only query-term codec; decode must not be called',
+ )
+ },
+
+ encodeJson(value: unknown): JsonValue {
+ if (value instanceof Date) return value.toISOString()
+ return value as JsonValue
+ },
+ decodeJson(json: JsonValue): unknown {
+ return json
+ },
+ }
+}
diff --git a/packages/stack/src/prisma/core/codec-match-term.ts b/packages/stack/src/prisma/core/codec-match-term.ts
new file mode 100644
index 00000000..08579fc3
--- /dev/null
+++ b/packages/stack/src/prisma/core/codec-match-term.ts
@@ -0,0 +1,126 @@
+import type { Encrypted, ScalarQueryTerm } from '@/types'
+import type { JsPlaintext } from '@cipherstash/protect-ffi'
+import type {
+ CodecTrait,
+ JsonValue,
+ SqlCodec,
+} from '../internal-types/prisma-next'
+import { createBatcher } from './batcher'
+import { type CipherStashCodecContext, emitTimed } from './codec-context'
+import { ENCRYPTED_MATCH_TERM_CODEC_ID } from './constants'
+import { requireColumnFor } from './encryption-client'
+import { CipherStashCodecError, describeJs, inferJsDataType } from './errors'
+import { eqlToCompositeLiteral } from './wire'
+
+/**
+ * Free-text-search query-term codec factory.
+ *
+ * Used as the value-side codec for `like` / `ilike` / `notIlike`
+ * operations on encrypted columns whose `typeParams.freeTextSearch` is
+ * `true`. Routes through
+ * `encryptionClient.encryptQuery({ queryType: 'freeTextSearch' })`
+ * which emits the bloom-filter-based MATCH index. Free-text search is
+ * meaningful on string plaintexts only.
+ */
+
+const MATCH_TERM_TRAITS = [] as const satisfies readonly CodecTrait[]
+
+export function createEncryptedMatchTermCodec(
+ ctx: CipherStashCodecContext,
+): SqlCodec<
+ typeof ENCRYPTED_MATCH_TERM_CODEC_ID,
+ typeof MATCH_TERM_TRAITS,
+ string,
+ string
+> {
+ const batcher = createBatcher(async (values) => {
+ const client = await ctx.binding.getClient()
+ const columnBinding = requireColumnFor(ctx.binding, 'string', {
+ codecLabel: 'encryptedMatchTermCodec',
+ value: values[0],
+ })
+ const terms: ScalarQueryTerm[] = values.map((value) => ({
+ value: value as ScalarQueryTerm['value'],
+ column: columnBinding.column,
+ table: columnBinding.table,
+ queryType: 'freeTextSearch',
+ }))
+ const result = await emitTimed(
+ ctx,
+ {
+ kind: 'encryptQuery',
+ codecId: ENCRYPTED_MATCH_TERM_CODEC_ID,
+ batchSize: values.length,
+ table: columnBinding.table.tableName,
+ column: columnBinding.columnName,
+ },
+ () => client.encryptQuery(terms),
+ )
+ if (result.failure) {
+ throw new CipherStashCodecError({
+ code: 'INVALID_QUERY_TERM',
+ message: `encryptQuery (freeTextSearch) failed: ${result.failure.message}`,
+ column: columnBinding.columnName,
+ expectedDataType: 'string',
+ actualType: 'unknown',
+ cause: result.failure,
+ })
+ }
+ return result.data.map((item) => {
+ if (typeof item === 'string') {
+ throw new TypeError(
+ 'encryptQuery returned composite literal where Encrypted was expected',
+ )
+ }
+ return item
+ })
+ })
+
+ return {
+ id: ENCRYPTED_MATCH_TERM_CODEC_ID,
+ targetTypes: ['csEncryptedMatchTerm'],
+ traits: MATCH_TERM_TRAITS,
+ meta: {
+ db: {
+ sql: {
+ postgres: {
+ nativeType: 'eql_v2_encrypted',
+ },
+ },
+ },
+ },
+
+ async encode(value: string): Promise {
+ const jsDataType = inferJsDataType(value)
+ if (jsDataType !== 'string') {
+ throw new CipherStashCodecError({
+ code: 'JS_TYPE_MISMATCH',
+ message: `Match-term codec only accepts string plaintexts, got JS type '${jsDataType ?? describeJs(value)}'`,
+ column: undefined,
+ expectedDataType: 'string',
+ actualType: jsDataType ?? describeJs(value),
+ })
+ }
+ const encrypted = await batcher.enqueue(value)
+ return eqlToCompositeLiteral(encrypted)
+ },
+
+ async decode(_wire: string): Promise {
+ throw new Error(
+ 'cs/eql_v2_match_term@1 is a write-only query-term codec; decode must not be called',
+ )
+ },
+
+ encodeJson(value: string): JsonValue {
+ return value
+ },
+ decodeJson(json: JsonValue): string {
+ if (typeof json !== 'string') {
+ throw new TypeError(
+ `Expected string in match-term JSON value, got ${typeof json}`,
+ )
+ }
+ return json
+ },
+ }
+}
diff --git a/packages/stack/src/prisma/core/codec-ore-term.ts b/packages/stack/src/prisma/core/codec-ore-term.ts
new file mode 100644
index 00000000..a0ba957d
--- /dev/null
+++ b/packages/stack/src/prisma/core/codec-ore-term.ts
@@ -0,0 +1,151 @@
+import type { Encrypted, ScalarQueryTerm } from '@/types'
+import type { JsPlaintext } from '@cipherstash/protect-ffi'
+import type {
+ CodecTrait,
+ JsonValue,
+ SqlCodec,
+} from '../internal-types/prisma-next'
+import { createBatcher } from './batcher'
+import { type CipherStashCodecContext, emitTimed } from './codec-context'
+import {
+ ENCRYPTED_ORE_TERM_CODEC_ID,
+ type EncryptedDataType,
+} from './constants'
+import { requireColumnFor } from './encryption-client'
+import { CipherStashCodecError, describeJs } from './errors'
+import { eqlToCompositeLiteral } from './wire'
+
+/**
+ * Order-and-range query-term codec factory.
+ *
+ * Used as the value-side codec for `gt` / `gte` / `lt` / `lte` /
+ * `between` / `notBetween` operations on encrypted columns whose
+ * `typeParams.orderAndRange` is `true`. Routes through
+ * `encryptionClient.encryptQuery({ queryType: 'orderAndRange' })`,
+ * which emits the ORE comparison index.
+ *
+ * ORE is meaningful for numbers and dates only.
+ */
+
+const ORE_TERM_TRAITS = [] as const satisfies readonly CodecTrait[]
+
+function inferOreDataType(value: unknown): EncryptedDataType {
+ if (value instanceof Date) return 'date'
+ if (typeof value === 'number') return 'number'
+ throw new CipherStashCodecError({
+ code: 'JS_TYPE_MISMATCH',
+ message: `ORE query terms require a number or Date plaintext, got ${describeJs(value)}`,
+ column: undefined,
+ expectedDataType: 'number',
+ actualType: describeJs(value),
+ })
+}
+
+function toPlaintext(value: unknown, dataType: EncryptedDataType): JsPlaintext {
+ if (dataType === 'date') {
+ if (!(value instanceof Date)) {
+ throw new TypeError('Expected Date for ORE dataType=date')
+ }
+ return value.toISOString()
+ }
+ return value as JsPlaintext
+}
+
+export function createEncryptedOreTermCodec(
+ ctx: CipherStashCodecContext,
+): SqlCodec<
+ typeof ENCRYPTED_ORE_TERM_CODEC_ID,
+ typeof ORE_TERM_TRAITS,
+ string,
+ unknown
+> {
+ const batchersByDataType = new Map<
+ EncryptedDataType,
+ ReturnType>
+ >()
+
+ const batcherFor = (dataType: EncryptedDataType) => {
+ let batcher = batchersByDataType.get(dataType)
+ if (batcher) return batcher
+ batcher = createBatcher(async (values) => {
+ const client = await ctx.binding.getClient()
+ const columnBinding = requireColumnFor(ctx.binding, dataType, {
+ codecLabel: 'encryptedOreTermCodec',
+ value: values[0],
+ })
+ const terms: ScalarQueryTerm[] = values.map((value) => ({
+ value: value as ScalarQueryTerm['value'],
+ column: columnBinding.column,
+ table: columnBinding.table,
+ queryType: 'orderAndRange',
+ }))
+ const result = await emitTimed(
+ ctx,
+ {
+ kind: 'encryptQuery',
+ codecId: ENCRYPTED_ORE_TERM_CODEC_ID,
+ batchSize: values.length,
+ table: columnBinding.table.tableName,
+ column: columnBinding.columnName,
+ },
+ () => client.encryptQuery(terms),
+ )
+ if (result.failure) {
+ throw new CipherStashCodecError({
+ code: 'INVALID_QUERY_TERM',
+ message: `encryptQuery (orderAndRange) failed: ${result.failure.message}`,
+ column: columnBinding.columnName,
+ expectedDataType: dataType,
+ actualType: 'unknown',
+ cause: result.failure,
+ })
+ }
+ return result.data.map((item) => {
+ if (typeof item === 'string') {
+ throw new TypeError(
+ 'encryptQuery returned composite literal where Encrypted was expected',
+ )
+ }
+ return item
+ })
+ })
+ batchersByDataType.set(dataType, batcher)
+ return batcher
+ }
+
+ return {
+ id: ENCRYPTED_ORE_TERM_CODEC_ID,
+ targetTypes: ['csEncryptedOreTerm'],
+ traits: ORE_TERM_TRAITS,
+ meta: {
+ db: {
+ sql: {
+ postgres: {
+ nativeType: 'eql_v2_encrypted',
+ },
+ },
+ },
+ },
+
+ async encode(value: unknown): Promise {
+ const dataType = inferOreDataType(value)
+ const plaintext = toPlaintext(value, dataType)
+ const encrypted = await batcherFor(dataType).enqueue(plaintext)
+ return eqlToCompositeLiteral(encrypted)
+ },
+
+ async decode(_wire: string): Promise {
+ throw new Error(
+ 'cs/eql_v2_ore_term@1 is a write-only query-term codec; decode must not be called',
+ )
+ },
+
+ encodeJson(value: unknown): JsonValue {
+ if (value instanceof Date) return value.toISOString()
+ return value as JsonValue
+ },
+ decodeJson(json: JsonValue): unknown {
+ return json
+ },
+ }
+}
diff --git a/packages/stack/src/prisma/core/codec-registry.ts b/packages/stack/src/prisma/core/codec-registry.ts
new file mode 100644
index 00000000..5031dbc3
--- /dev/null
+++ b/packages/stack/src/prisma/core/codec-registry.ts
@@ -0,0 +1,63 @@
+import type {
+ CodecRegistry,
+ CodecTrait,
+ SqlCodec,
+} from '../internal-types/prisma-next'
+import type { CipherStashCodecContext } from './codec-context'
+import { createEncryptedEqTermCodec } from './codec-eq-term'
+import { createEncryptedMatchTermCodec } from './codec-match-term'
+import { createEncryptedOreTermCodec } from './codec-ore-term'
+import { createEncryptedSteVecSelectorCodec } from './codec-ste-vec-term'
+import { createEncryptedStorageCodec } from './codec-storage'
+
+/**
+ * Build a per-extension codec registry. Each codec instance closes
+ * over the supplied context (client binding + observability hook),
+ * so two `cipherstashEncryption({...})` calls produce two independent
+ * codec graphs with no shared mutable state.
+ *
+ * Once Prisma Next ships, the structural compatibility with
+ * `createCodecRegistry()` from `@prisma-next/sql-relational-core/ast`
+ * is preserved — this helper can be replaced with two
+ * `register(...)` calls against the upstream registry.
+ */
+export function createEncryptionCodecRegistry(
+ ctx: CipherStashCodecContext,
+): CodecRegistry {
+ const byId = new Map()
+ const byScalar = new Map()
+
+ const register = (codec: SqlCodec): void => {
+ if (byId.has(codec.id)) {
+ throw new Error(`Codec with ID '${codec.id}' is already registered`)
+ }
+ byId.set(codec.id, codec)
+ for (const scalar of codec.targetTypes) {
+ const existing = byScalar.get(scalar)
+ if (existing) existing.push(codec)
+ else byScalar.set(scalar, [codec])
+ }
+ }
+
+ register(createEncryptedStorageCodec(ctx))
+ register(createEncryptedEqTermCodec(ctx))
+ register(createEncryptedMatchTermCodec(ctx))
+ register(createEncryptedOreTermCodec(ctx))
+ register(createEncryptedSteVecSelectorCodec(ctx))
+
+ const traitsOf = (codecId: string): readonly CodecTrait[] => {
+ return (
+ (byId.get(codecId)?.traits as readonly CodecTrait[] | undefined) ?? []
+ )
+ }
+
+ return {
+ get: (id: string) => byId.get(id),
+ has: (id: string) => byId.has(id),
+ register,
+ hasTrait: (id, trait) => traitsOf(id).includes(trait),
+ traitsOf,
+ values: () => byId.values(),
+ [Symbol.iterator]: () => byId.values(),
+ }
+}
diff --git a/packages/stack/src/prisma/core/codec-ste-vec-term.ts b/packages/stack/src/prisma/core/codec-ste-vec-term.ts
new file mode 100644
index 00000000..be20c426
--- /dev/null
+++ b/packages/stack/src/prisma/core/codec-ste-vec-term.ts
@@ -0,0 +1,125 @@
+import type { Encrypted, ScalarQueryTerm } from '@/types'
+import type { JsPlaintext } from '@cipherstash/protect-ffi'
+import type {
+ CodecTrait,
+ JsonValue,
+ SqlCodec,
+} from '../internal-types/prisma-next'
+import { createBatcher } from './batcher'
+import { type CipherStashCodecContext, emitTimed } from './codec-context'
+import { ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID } from './constants'
+import { requireColumnFor } from './encryption-client'
+import { CipherStashCodecError, describeJs, inferJsDataType } from './errors'
+import { eqlToCompositeLiteral } from './wire'
+
+/**
+ * STE-Vec selector query-term codec factory.
+ *
+ * Used as the value-side codec for `jsonbPathExists` /
+ * `jsonbPathQueryFirst` / `jsonbGet` operations on encrypted JSON
+ * columns whose `typeParams.searchableJson` is `true`.
+ *
+ * The plaintext is a JSONPath selector string (e.g. `'$.user.email'`).
+ */
+
+const STE_VEC_TRAITS = [] as const satisfies readonly CodecTrait[]
+
+export function createEncryptedSteVecSelectorCodec(
+ ctx: CipherStashCodecContext,
+): SqlCodec<
+ typeof ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID,
+ typeof STE_VEC_TRAITS,
+ string,
+ string
+> {
+ const batcher = createBatcher(async (values) => {
+ const client = await ctx.binding.getClient()
+ const columnBinding = requireColumnFor(ctx.binding, 'json', {
+ codecLabel: 'encryptedSteVecSelectorCodec',
+ value: values[0],
+ })
+ const terms: ScalarQueryTerm[] = values.map((value) => ({
+ value: value as ScalarQueryTerm['value'],
+ column: columnBinding.column,
+ table: columnBinding.table,
+ queryType: 'steVecSelector',
+ }))
+ const result = await emitTimed(
+ ctx,
+ {
+ kind: 'encryptQuery',
+ codecId: ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID,
+ batchSize: values.length,
+ table: columnBinding.table.tableName,
+ column: columnBinding.columnName,
+ },
+ () => client.encryptQuery(terms),
+ )
+ if (result.failure) {
+ throw new CipherStashCodecError({
+ code: 'INVALID_QUERY_TERM',
+ message: `encryptQuery (steVecSelector) failed: ${result.failure.message}`,
+ column: columnBinding.columnName,
+ expectedDataType: 'string',
+ actualType: 'unknown',
+ cause: result.failure,
+ })
+ }
+ return result.data.map((item) => {
+ if (typeof item === 'string') {
+ throw new TypeError(
+ 'encryptQuery returned composite literal where Encrypted was expected',
+ )
+ }
+ return item
+ })
+ })
+
+ return {
+ id: ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID,
+ targetTypes: ['csEncryptedSteVecSelector'],
+ traits: STE_VEC_TRAITS,
+ meta: {
+ db: {
+ sql: {
+ postgres: {
+ nativeType: 'eql_v2_encrypted',
+ },
+ },
+ },
+ },
+
+ async encode(value: string): Promise {
+ const jsDataType = inferJsDataType(value)
+ if (jsDataType !== 'string') {
+ throw new CipherStashCodecError({
+ code: 'JS_TYPE_MISMATCH',
+ message: `STE-Vec selector codec only accepts string plaintexts, got JS type '${jsDataType ?? describeJs(value)}'`,
+ column: undefined,
+ expectedDataType: 'string',
+ actualType: jsDataType ?? describeJs(value),
+ })
+ }
+ const encrypted = await batcher.enqueue(value)
+ return eqlToCompositeLiteral(encrypted)
+ },
+
+ async decode(_wire: string): Promise {
+ throw new Error(
+ 'cs/eql_v2_ste_vec_selector@1 is a write-only query-term codec; decode must not be called',
+ )
+ },
+
+ encodeJson(value: string): JsonValue {
+ return value
+ },
+ decodeJson(json: JsonValue): string {
+ if (typeof json !== 'string') {
+ throw new TypeError(
+ `Expected string in ste-vec selector JSON value, got ${typeof json}`,
+ )
+ }
+ return json
+ },
+ }
+}
diff --git a/packages/stack/src/prisma/core/codec-storage.ts b/packages/stack/src/prisma/core/codec-storage.ts
new file mode 100644
index 00000000..05171ee8
--- /dev/null
+++ b/packages/stack/src/prisma/core/codec-storage.ts
@@ -0,0 +1,238 @@
+import type { BulkDecryptPayload, BulkEncryptPayload, Encrypted } from '@/types'
+import type { JsPlaintext } from '@cipherstash/protect-ffi'
+import type {
+ CodecTrait,
+ JsonValue,
+ SqlCodec,
+} from '../internal-types/prisma-next'
+import { createBatcher } from './batcher'
+import { type CipherStashCodecContext, emitTimed } from './codec-context'
+import { ENCRYPTED_STORAGE_CODEC_ID, type EncryptedDataType } from './constants'
+import { requireColumnFor } from './encryption-client'
+import {
+ CipherStashCodecError,
+ assertJsTypeMatchesDataType,
+ describeJs,
+} from './errors'
+import { eqlFromCompositeLiteral, eqlToCompositeLiteral } from './wire'
+
+/**
+ * Storage codec factory for `eql_v2_encrypted` columns.
+ *
+ * Wire shape: PostgreSQL composite-literal string `("")`.
+ *
+ * The codec is constructed per-extension by `cipherstashEncryption({...})`.
+ * Each extension's codec closes over its own `EncryptionClient` binding
+ * (no module-level singleton) so multi-tenant deployments can run two
+ * extensions with two clients side-by-side without cross-talk.
+ *
+ * On encode the codec dispatches by the value's JS-runtime data type
+ * to a `(table, column)` pair drawn from the contract; the SDK's
+ * `bulkEncrypt({ items, table, column })` consumes that and the cipher
+ * produced encodes the column's `i.t` / `i.c` metadata. On decode the
+ * codec hands the cipher straight to `bulkDecrypt(items)` and trusts
+ * the SDK's `cast_as` round-trip — no inspection of the cipher
+ * payload.
+ */
+
+const STORAGE_TRAITS = ['equality'] as const satisfies readonly CodecTrait[]
+
+/** Per-data-type plaintext marshaling — Date crosses the FFI as ISO string. */
+function toPlaintext(value: unknown, dataType: EncryptedDataType): JsPlaintext {
+ if (dataType === 'date') {
+ if (!(value instanceof Date)) {
+ throw new TypeError(
+ `Expected Date for dataType 'date', got ${describeJs(value)}`,
+ )
+ }
+ return value.toISOString()
+ }
+ return value as JsPlaintext
+}
+
+/**
+ * Produce a fresh storage codec instance bound to the given context.
+ */
+export function createEncryptedStorageCodec(
+ ctx: CipherStashCodecContext,
+): SqlCodec<
+ typeof ENCRYPTED_STORAGE_CODEC_ID,
+ typeof STORAGE_TRAITS,
+ string,
+ unknown
+> {
+ // One encrypt-batcher per data type. `bulkEncrypt`'s `EncryptOptions.column`
+ // is per-call, so a homogeneous payload per call is cheaper than
+ // routing every cell through a single mega-batch.
+ const encryptBatchers = new Map<
+ EncryptedDataType,
+ ReturnType>
+ >()
+
+ const encryptBatcherFor = (dataType: EncryptedDataType) => {
+ let batcher = encryptBatchers.get(dataType)
+ if (batcher) return batcher
+ batcher = createBatcher(async (values) => {
+ const client = await ctx.binding.getClient()
+ const columnBinding = requireColumnFor(ctx.binding, dataType, {
+ codecLabel: 'encryptedStorageCodec',
+ value: values[0],
+ })
+ const payload: BulkEncryptPayload = values.map((plaintext, idx) => ({
+ id: String(idx),
+ plaintext,
+ }))
+ const result = await emitTimed(
+ ctx,
+ {
+ kind: 'bulkEncrypt',
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ batchSize: values.length,
+ table: columnBinding.table.tableName,
+ column: columnBinding.columnName,
+ },
+ () =>
+ client.bulkEncrypt(payload, {
+ column: columnBinding.column,
+ table: columnBinding.table,
+ }),
+ )
+ if (result.failure) {
+ throw new CipherStashCodecError({
+ code: 'DECODE_ROUND_TRIP_BROKEN',
+ message: `bulkEncrypt failed: ${result.failure.message}`,
+ column: columnBinding.columnName,
+ expectedDataType: dataType,
+ actualType: 'unknown',
+ cause: result.failure,
+ })
+ }
+ return result.data.map((item) => item.data)
+ })
+ encryptBatchers.set(dataType, batcher)
+ return batcher
+ }
+
+ // Single decrypt batcher; the SDK's `bulkDecrypt` does not require
+ // a `(table, column)` argument — every cipher carries its own
+ // `i.t` / `i.c` schema marker which the FFI consults to pick the
+ // right `cast_as` for the round-trip.
+ const decryptBatcher = createBatcher(async (values) => {
+ const client = await ctx.binding.getClient()
+ const payload: BulkDecryptPayload = values.map((data, idx) => ({
+ id: String(idx),
+ data,
+ }))
+ const head = values[0]
+ const result = await emitTimed(
+ ctx,
+ {
+ kind: 'bulkDecrypt',
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ batchSize: values.length,
+ table: head?.i.t,
+ column: head?.i.c,
+ },
+ () => client.bulkDecrypt(payload),
+ )
+ if (result.failure) {
+ throw new CipherStashCodecError({
+ code: 'DECODE_ROUND_TRIP_BROKEN',
+ message: `bulkDecrypt failed: ${result.failure.message}`,
+ column: head?.i.c,
+ expectedDataType: undefined,
+ actualType: 'unknown',
+ cause: result.failure,
+ })
+ }
+ return result.data.map((item, idx) => {
+ if ('error' in item && item.error) {
+ const cipher = values[idx]
+ throw new CipherStashCodecError({
+ code: 'DECODE_ROUND_TRIP_BROKEN',
+ message: `Decryption failed for row index ${idx}: ${item.error}`,
+ column: cipher?.i.c,
+ expectedDataType: undefined,
+ actualType: 'unknown',
+ cause: item.error,
+ })
+ }
+ // Trust the SDK: it honors the column's `cast_as` config and
+ // returns the right JS type already (Date for 'date', etc.).
+ return item.data
+ })
+ })
+
+ return {
+ id: ENCRYPTED_STORAGE_CODEC_ID,
+ targetTypes: ['csEncrypted'],
+ traits: STORAGE_TRAITS,
+ meta: {
+ db: {
+ sql: {
+ postgres: {
+ // Bare identifier — matches Drizzle's precedent and the
+ // qualified form in `constants.ts:ENCRYPTED_NATIVE_TYPE`
+ // is the descriptor / packMeta surface only. Keeping the
+ // codec meta unqualified avoids the introspection
+ // round-trip diff documented in F-19.
+ nativeType: 'eql_v2_encrypted',
+ },
+ },
+ },
+ },
+
+ async encode(value: unknown): Promise {
+ // The codec doesn't see the contract column's declared dataType
+ // at this site (Prisma Next runtime gap), so the JS-runtime
+ // type is the dispatch key. Unsupported types (bigint, symbol,
+ // function) raise a structured `UNSUPPORTED_PLAINTEXT_TYPE`.
+ const dataType = assertJsTypeMatchesDataType(value, undefined)
+ const plaintext = toPlaintext(value, dataType)
+ const encrypted = await encryptBatcherFor(dataType).enqueue(plaintext)
+ return eqlToCompositeLiteral(encrypted)
+ },
+
+ async decode(wire: string): Promise {
+ const encrypted = eqlFromCompositeLiteral(wire)
+ return decryptBatcher.enqueue(encrypted)
+ },
+
+ encodeJson(value: unknown): JsonValue {
+ if (value instanceof Date) return value.toISOString()
+ return value as JsonValue
+ },
+ decodeJson(json: JsonValue): unknown {
+ return json
+ },
+
+ /**
+ * Rendered into the generated `contract.d.ts` file when the
+ * contract emitter wants the JS type for an encrypted column.
+ * Dispatches on `typeParams.dataType` so `Decrypted` resolves
+ * to the correct JS type per column.
+ */
+ renderOutputType(typeParams: Record): string {
+ const dataType = typeParams.dataType
+ switch (dataType) {
+ case 'number':
+ return 'number'
+ case 'boolean':
+ return 'boolean'
+ case 'date':
+ return 'Date'
+ case 'json':
+ return 'unknown'
+ case 'string':
+ case undefined:
+ return 'string'
+ default:
+ throw new Error(
+ `Unsupported dataType in encrypted column typeParams: ${String(
+ dataType,
+ )}`,
+ )
+ }
+ },
+ }
+}
diff --git a/packages/stack/src/prisma/core/constants.ts b/packages/stack/src/prisma/core/constants.ts
new file mode 100644
index 00000000..b0925944
--- /dev/null
+++ b/packages/stack/src/prisma/core/constants.ts
@@ -0,0 +1,68 @@
+/**
+ * Stable identifiers for the CipherStash extension pack.
+ *
+ * Codec IDs follow Prisma Next's `namespace/name@version` convention. The
+ * `cs/` namespace is owned by CipherStash; the suffix mirrors the EQL
+ * extension's `eql_v2_*` SQL identifiers so codec IDs and SQL identifiers
+ * stay aligned during planning and debugging.
+ */
+
+/** Storage codec for encrypted columns (round-trip via EQL composite literal). */
+export const ENCRYPTED_STORAGE_CODEC_ID = 'cs/eql_v2_encrypted@1' as const
+
+/** Query-term codec used on the value side of equality operators. */
+export const ENCRYPTED_EQ_TERM_CODEC_ID = 'cs/eql_v2_eq_term@1' as const
+
+/** Query-term codec used on the value side of free-text search operators. */
+export const ENCRYPTED_MATCH_TERM_CODEC_ID = 'cs/eql_v2_match_term@1' as const
+
+/** Query-term codec used on the value side of ORE (range/order) operators. */
+export const ENCRYPTED_ORE_TERM_CODEC_ID = 'cs/eql_v2_ore_term@1' as const
+
+/** Query-term codec used on the value side of JSONB path/selector operators. */
+export const ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID =
+ 'cs/eql_v2_ste_vec_selector@1' as const
+
+/**
+ * Postgres-side native type for encrypted columns. Created by the EQL
+ * install bundle as a composite type in the `public` schema; the migration
+ * planner consumes the qualified identifier verbatim.
+ */
+export const ENCRYPTED_NATIVE_TYPE = '"public"."eql_v2_encrypted"' as const
+
+/**
+ * Contract scalar names registered by `defineCodecs`. Each codec advertises
+ * exactly one scalar so the registry's `byScalar` mapping is unambiguous.
+ */
+export const ENCRYPTED_STORAGE_SCALAR = 'csEncrypted' as const
+export const ENCRYPTED_EQ_TERM_SCALAR = 'csEncryptedEqTerm' as const
+export const ENCRYPTED_MATCH_TERM_SCALAR = 'csEncryptedMatchTerm' as const
+export const ENCRYPTED_ORE_TERM_SCALAR = 'csEncryptedOreTerm' as const
+export const ENCRYPTED_STE_VEC_SELECTOR_SCALAR =
+ 'csEncryptedSteVecSelector' as const
+
+/** Pack identity for the runtime / control extension descriptors. */
+export const PACK_ID = 'cipherstash-encryption' as const
+export const PACK_VERSION = '0.0.1' as const
+
+/**
+ * Plaintext data types supported by an encrypted column.
+ *
+ * Phase 2 widens beyond Phase 1's `'string'` to cover the four sibling
+ * column-type factories (`encryptedNumber`, `encryptedDate`,
+ * `encryptedBoolean`, `encryptedJson`). The data type drives:
+ * - The JS-side input/output type carried through `CodecTypes`.
+ * - The plaintext-encoding step in the storage codec's `encode` (Date is
+ * serialized to an ISO string before it crosses the FFI boundary;
+ * everything else is passed through).
+ * - The plaintext-decoding step in the storage codec's `decode` (a raw
+ * ISO string is rehydrated to a `Date` for `dataType: 'date'`; numeric
+ * strings are coerced for `'number'` if the FFI ever returns them as
+ * strings; everything else is returned as-is).
+ */
+export type EncryptedDataType =
+ | 'string'
+ | 'number'
+ | 'boolean'
+ | 'date'
+ | 'json'
diff --git a/packages/stack/src/prisma/core/database-dependencies.ts b/packages/stack/src/prisma/core/database-dependencies.ts
new file mode 100644
index 00000000..e6e94a61
--- /dev/null
+++ b/packages/stack/src/prisma/core/database-dependencies.ts
@@ -0,0 +1,328 @@
+import type {
+ ComponentDatabaseDependencies,
+ ComponentDatabaseDependency,
+ PlanTypeOperationsInput,
+ SqlMigrationPlanOperation,
+ StorageTypePlanResult,
+} from '../internal-types/prisma-next'
+import type { EncryptedDataType } from './constants'
+import { getEqlBundleVersion, getEqlInstallSql } from './eql-bundle'
+
+/**
+ * `databaseDependencies` and `planTypeOperations` for the CipherStash
+ * extension pack.
+ *
+ * Two responsibilities split across this file:
+ * 1. `getCipherStashDatabaseDependencies()` — returns the
+ * `databaseDependencies.init` payload. The migration planner runs
+ * this once when the extension first attaches; subsequent runs
+ * see the EQL configuration tables already present and skip via
+ * the `precheck` SQL.
+ * 2. `planEncryptedTypeOperations(input)` — emits per-column EQL
+ * index DDL for one `StorageTypeInstance`. The migration planner
+ * calls this for every encrypted-column type instance whenever
+ * the contract changes.
+ */
+
+// ---------------------------------------------------------------------------
+// databaseDependencies.init
+// ---------------------------------------------------------------------------
+
+/**
+ * Magic dependency-bundle ID. Stable across releases so the migration
+ * planner can correlate this dependency with previously-applied
+ * versions when the upgrade story (Phase 4) lands.
+ */
+const EQL_DEPENDENCY_ID = 'cipherstash.eql' as const
+/**
+ * Inner-operation ID. Distinct from `EQL_DEPENDENCY_ID` because a
+ * single dependency bundle can hold multiple operations; for the
+ * install case we emit exactly one.
+ */
+const EQL_INSTALL_OPERATION_ID = 'cipherstash.eql.install' as const
+
+/**
+ * Build the `databaseDependencies` value for the SQL control
+ * descriptor. Pgvector ships `CREATE EXTENSION vector`; we ship the
+ * full EQL install bundle (vendored from the pinned release).
+ *
+ * `precheck` short-circuits the install when EQL is already present
+ * (the `eql_v2_configuration` table is created at the head of the
+ * bundle and is a stable marker). The `execute` SQL is the bundle
+ * itself — Postgres parses it as a multi-statement DO/CREATE block.
+ * `postcheck` re-asserts the marker so the migration runner can
+ * distinguish "ran but didn't take" from "ran successfully".
+ */
+export function getCipherStashDatabaseDependencies(): Required<
+ Pick
+> {
+ const sql = getEqlInstallSql()
+ const version = getEqlBundleVersion()
+
+ const installOperation: SqlMigrationPlanOperation = {
+ id: EQL_INSTALL_OPERATION_ID,
+ label: 'Install CipherStash EQL extension',
+ summary:
+ 'Installs the `eql_v2` schema, types, configuration tables, and SQL functions required by encrypted columns.',
+ operationClass: 'additive',
+ target: {
+ id: 'postgres',
+ },
+ precheck: [
+ {
+ description:
+ 'Skip the install when the EQL configuration table already exists',
+ sql: "SELECT NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'eql_v2_configuration')",
+ },
+ ],
+ execute: [
+ {
+ description: `Apply the EQL install bundle (version: ${version})`,
+ sql,
+ },
+ ],
+ postcheck: [
+ {
+ description: 'Confirm the EQL configuration table exists after install',
+ sql: "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'eql_v2_configuration')",
+ },
+ ],
+ meta: { eqlBundleVersion: version },
+ }
+
+ const dependency: ComponentDatabaseDependency = {
+ id: EQL_DEPENDENCY_ID,
+ label: 'CipherStash EQL extension',
+ install: [installOperation],
+ }
+
+ return { init: [dependency] }
+}
+
+// ---------------------------------------------------------------------------
+// planTypeOperations
+// ---------------------------------------------------------------------------
+
+/**
+ * EQL `add_search_config` index names corresponding to each
+ * searchable-encryption flag on a column's typeParams. The planner
+ * threads these into one `eql_v2.add_search_config(...)` call per
+ * enabled mode.
+ */
+type SearchMode = 'unique' | 'match' | 'ore' | 'ste_vec'
+
+type SearchModeFlag = {
+ readonly mode: SearchMode
+ readonly flag:
+ | 'equality'
+ | 'freeTextSearch'
+ | 'orderAndRange'
+ | 'searchableJson'
+}
+
+/**
+ * Order matters for stable diff hashes: every column's `add_search_config`
+ * calls land in the same order so the migration planner's plan-content
+ * hash doesn't churn for trivial typeParams reorderings.
+ */
+const SEARCH_MODES: readonly SearchModeFlag[] = [
+ { mode: 'unique', flag: 'equality' },
+ { mode: 'match', flag: 'freeTextSearch' },
+ { mode: 'ore', flag: 'orderAndRange' },
+ { mode: 'ste_vec', flag: 'searchableJson' },
+]
+
+/**
+ * Map our `EncryptedDataType` values to the EQL `cast_as` argument the
+ * `add_search_config` function expects. EQL's accepted set is
+ * `{text, int, small_int, big_int, real, double, boolean, date, jsonb}`.
+ */
+function castAsForDataType(dataType: EncryptedDataType): string {
+ switch (dataType) {
+ case 'string':
+ return 'text'
+ case 'number':
+ return 'double'
+ case 'boolean':
+ return 'boolean'
+ case 'date':
+ return 'date'
+ case 'json':
+ return 'jsonb'
+ }
+}
+
+/**
+ * Type guard for the typeParams shape emitted by our `encrypted*`
+ * column factories. The migration planner hands us
+ * `Record`, so we re-derive the searchable-encryption
+ * flags from a known-shape projection. Defaults match the column-type
+ * factories: every flag false unless declared.
+ */
+type EncryptedTypeParamsView = {
+ readonly dataType: EncryptedDataType
+ readonly equality: boolean
+ readonly freeTextSearch: boolean
+ readonly orderAndRange: boolean
+ readonly searchableJson: boolean
+}
+
+function projectTypeParams(
+ raw: Record,
+): EncryptedTypeParamsView | null {
+ const dataType = raw.dataType
+ if (
+ dataType !== 'string' &&
+ dataType !== 'number' &&
+ dataType !== 'boolean' &&
+ dataType !== 'date' &&
+ dataType !== 'json'
+ ) {
+ return null
+ }
+ return {
+ dataType,
+ equality: raw.equality === true,
+ freeTextSearch: raw.freeTextSearch === true,
+ orderAndRange: raw.orderAndRange === true,
+ searchableJson: raw.searchableJson === true,
+ }
+}
+
+/**
+ * Identify (table, column) for a typeInstance. The post-#379 contract
+ * model keeps named type instances as keys in `storage.types`, with the
+ * key carrying a stable `__` shape *or* a custom name.
+ * Phase 3 supports the `__` shape and falls back to the
+ * raw typeName when it can't split cleanly. Real Phase 4 work will
+ * read `(table, column)` directly off the contract once the planner
+ * passes a richer input.
+ */
+function deriveTableAndColumn(
+ typeName: string,
+): { table: string; column: string } | null {
+ const idx = typeName.lastIndexOf('__')
+ if (idx <= 0 || idx >= typeName.length - 2) return null
+ const table = typeName.slice(0, idx)
+ const column = typeName.slice(idx + 2)
+ return { table, column }
+}
+
+/**
+ * SQL-literal escape for identifiers / values embedded in the
+ * `eql_v2.add_search_config(...)` call. We single-quote the text values
+ * (`table`, `column`, `index_name`, `cast_as`); the SQL parser treats
+ * `''` as an embedded single-quote.
+ */
+function quoteSqlString(value: string): string {
+ return `'${value.replace(/'/g, "''")}'`
+}
+
+/**
+ * Phase 3 implementation of the `planTypeOperations` codec-control
+ * hook.
+ *
+ * For each encrypted column type instance, emit zero or more
+ * `eql_v2.add_search_config(table, column, index_name, cast_as)` calls
+ * — one per enabled searchable-encryption flag. The migration planner
+ * appends these to the migration plan; on first run they create the
+ * EQL search indexes, and the precheck SQL ensures re-running the same
+ * plan after adopting the indexes is a no-op.
+ *
+ * Open questions documented for the upstream Prisma Next team:
+ * - The planner's input shape on the post-#379 trunk doesn't
+ * currently surface `(table, column)` directly to the codec hook.
+ * We derive these from a `__` typeName convention as
+ * a Phase 3 placeholder; Phase 4 should align with a richer planner
+ * input once it lands.
+ * - Removing a search-mode flag (e.g. `equality: true → false`)
+ * should emit `eql_v2.remove_search_config(...)` for the dropped
+ * mode. The current `planTypeOperations` API doesn't carry the
+ * prior-state typeParams, so we can't compute the diff directly.
+ * Phase 4 should consume the `fromContract` planner input once
+ * it's standardized; for now the hook only emits additive
+ * operations.
+ */
+export function planEncryptedTypeOperations(
+ input: PlanTypeOperationsInput,
+): StorageTypePlanResult {
+ const view = projectTypeParams(input.typeInstance.typeParams)
+ if (!view) {
+ return { operations: [] }
+ }
+
+ const where = deriveTableAndColumn(input.typeName)
+ if (!where) {
+ // No usable table/column derivation — emit nothing rather than
+ // produce a malformed `add_search_config` call. The Phase 4 work
+ // documented above will wire this up cleanly.
+ return { operations: [] }
+ }
+
+ const castAs = castAsForDataType(view.dataType)
+ const operations: SqlMigrationPlanOperation[] = []
+
+ for (const { mode, flag } of SEARCH_MODES) {
+ if (!view[flag]) continue
+ operations.push(
+ buildAddSearchConfigOperation({
+ table: where.table,
+ column: where.column,
+ indexName: mode,
+ castAs,
+ }),
+ )
+ }
+
+ return { operations }
+}
+
+function buildAddSearchConfigOperation(args: {
+ table: string
+ column: string
+ indexName: SearchMode
+ castAs: string
+}): SqlMigrationPlanOperation {
+ const callSql = `SELECT eql_v2.add_search_config(${quoteSqlString(args.table)}, ${quoteSqlString(args.column)}, ${quoteSqlString(args.indexName)}, ${quoteSqlString(args.castAs)})`
+ // Skip when the index is already configured. The EQL config payload
+ // is JSONB-shaped under `eql_v2_configuration.data` with state
+ // `'active' | 'pending'`; a path-exists check keeps the precheck
+ // self-contained.
+ const precheckSql = `SELECT NOT EXISTS (
+ SELECT 1 FROM public.eql_v2_configuration
+ WHERE (state = 'active' OR state = 'pending')
+ AND data #> ARRAY['tables', ${quoteSqlString(args.table)}, ${quoteSqlString(args.column)}, 'indexes'] ? ${quoteSqlString(args.indexName)}
+ )`
+ const postcheckSql = `SELECT EXISTS (
+ SELECT 1 FROM public.eql_v2_configuration
+ WHERE (state = 'active' OR state = 'pending')
+ AND data #> ARRAY['tables', ${quoteSqlString(args.table)}, ${quoteSqlString(args.column)}, 'indexes'] ? ${quoteSqlString(args.indexName)}
+ )`
+ return {
+ id: `cipherstash.eql.add_search_config.${args.table}.${args.column}.${args.indexName}`,
+ label: `Add EQL ${args.indexName} index on ${args.table}.${args.column}`,
+ summary: `Adds the ${args.indexName} EQL search index on ${args.table}.${args.column} (cast_as: ${args.castAs}).`,
+ operationClass: 'additive',
+ target: {
+ id: 'postgres',
+ details: {
+ objectType: 'table',
+ schema: 'public',
+ table: args.table,
+ column: args.column,
+ indexName: args.indexName,
+ },
+ },
+ precheck: [
+ {
+ description: 'Skip when the index is already configured',
+ sql: precheckSql,
+ },
+ ],
+ execute: [{ description: callSql, sql: callSql }],
+ postcheck: [
+ { description: 'Confirm the index is now configured', sql: postcheckSql },
+ ],
+ meta: { kind: 'add_search_config', indexName: args.indexName },
+ }
+}
diff --git a/packages/stack/src/prisma/core/descriptor-meta.ts b/packages/stack/src/prisma/core/descriptor-meta.ts
new file mode 100644
index 00000000..a6ee9398
--- /dev/null
+++ b/packages/stack/src/prisma/core/descriptor-meta.ts
@@ -0,0 +1,64 @@
+import {
+ ENCRYPTED_NATIVE_TYPE,
+ ENCRYPTED_STORAGE_CODEC_ID,
+ PACK_ID,
+ PACK_VERSION,
+} from './constants'
+
+/**
+ * Pack-meta object referenced by both control and runtime descriptors.
+ *
+ * This mirrors the shape pgvector uses (`pgvectorPackMetaBase`): a single
+ * `as const` literal that captures stable identity (kind / id / family /
+ * target / version), capability flags, and the type-emission hints the
+ * contract emitter needs to wire `OperationTypes` and `CodecTypes` into
+ * generated `contract.d.ts` files.
+ *
+ * Phase 1 keeps `capabilities` minimal — there's nothing target-side to
+ * negotiate yet. Phase 2 / 3 will surface searchable-encryption coverage
+ * flags here so contract validation can refuse migrations that ask for
+ * search-term combinations the deployment doesn't support.
+ */
+export const cipherstashPackMeta = {
+ kind: 'extension',
+ id: PACK_ID,
+ familyId: 'sql',
+ targetId: 'postgres',
+ version: PACK_VERSION,
+ capabilities: {
+ postgres: {
+ 'cipherstash.encrypted': true,
+ },
+ },
+ types: {
+ codecTypes: {
+ import: {
+ package: '@cipherstash/stack/prisma/codec-types',
+ named: 'CodecTypes',
+ alias: 'CipherStashCodecTypes',
+ },
+ typeImports: [
+ {
+ package: '@cipherstash/stack/prisma/codec-types',
+ named: 'JsTypeFor',
+ alias: 'CipherStashJsTypeFor',
+ },
+ ],
+ },
+ operationTypes: {
+ import: {
+ package: '@cipherstash/stack/prisma/operation-types',
+ named: 'OperationTypes',
+ alias: 'CipherStashOperationTypes',
+ },
+ },
+ storage: [
+ {
+ typeId: ENCRYPTED_STORAGE_CODEC_ID,
+ familyId: 'sql',
+ targetId: 'postgres',
+ nativeType: ENCRYPTED_NATIVE_TYPE,
+ },
+ ],
+ },
+} as const
diff --git a/packages/stack/src/prisma/core/encryption-client.ts b/packages/stack/src/prisma/core/encryption-client.ts
new file mode 100644
index 00000000..0e55bab4
--- /dev/null
+++ b/packages/stack/src/prisma/core/encryption-client.ts
@@ -0,0 +1,257 @@
+import { Encryption, type EncryptionClient } from '@/encryption'
+import type {
+ EncryptedColumn,
+ EncryptedTable,
+ EncryptedTableColumn,
+} from '@/schema'
+import type { EncryptedDataType } from './constants'
+import { CipherStashCodecError } from './errors'
+
+/**
+ * Per-extension `EncryptionClient` resolution.
+ *
+ * Each `cipherstashEncryption({ encryptionClient?, contract })` call
+ * produces a fresh extension descriptor with its own client + schemas
+ * captured in closure. There is no module-level singleton: two
+ * extensions live side-by-side without cross-talk, and multi-tenant
+ * deployments construct one extension per tenant.
+ *
+ * The schemas come from the user's contract via `extractEncryptedSchemas`.
+ * The codec dispatches by the value's JS-runtime data type at encode
+ * time, picking the first contract column that matches that data type.
+ *
+ * Upstream gap (documented in audit F-10/F-30): Prisma Next's
+ * `encodeParam` / `decodeRow` do not surface column metadata to
+ * `codec.encode` / `codec.decode`. We approximate by JS-runtime
+ * dispatch on the contract's columns; users with a single encrypted
+ * column per data type get correct behaviour. Multi-column-per-dataType
+ * contracts encrypt every value of that data type under the *first*
+ * matching column's index configuration. Track upstream concurrency /
+ * column-context plumbing under TML-2330; until then this is the best
+ * we can do without per-column codec instances.
+ */
+
+/**
+ * Resolved per-extension client binding. Carries the lazy client
+ * Promise plus the contract-derived index for JS-runtime dispatch.
+ */
+export type CipherStashEncryptionBinding = {
+ /**
+ * Resolve the active `EncryptionClient`. Cheap on the hot path: a
+ * single map lookup once initialization has resolved.
+ */
+ readonly getClient: () => Promise
+ /**
+ * Pick a `(table, column)` pair from the contract for a JS-runtime
+ * data type. Returns `null` when the contract has no encrypted
+ * column matching the data type — callers throw a
+ * `NO_COLUMN_FOR_DATATYPE` `CipherStashCodecError` in that case so
+ * the user sees an actionable message.
+ */
+ readonly resolveColumnFor: (
+ dataType: EncryptedDataType,
+ ) => ColumnBinding | null
+}
+
+export type ColumnBinding = {
+ readonly table: EncryptedTable
+ readonly column: EncryptedColumn
+ /**
+ * Stable column name on the underlying Postgres table. Surfaced on
+ * structured events (`onEvent({ table, column })`) so observability
+ * sees real column names, not synthetic ones.
+ */
+ readonly columnName: string
+}
+
+/**
+ * Build a `(dataType -> ColumnBinding)` index from the contract's
+ * extracted schemas. The first encrypted column matching each data
+ * type wins; ties are broken by table iteration order (the contract's
+ * declared order).
+ */
+function indexSchemasByDataType(
+ schemas: ReadonlyArray>,
+): ReadonlyMap {
+ const index = new Map()
+ for (const table of schemas) {
+ const built = table.build()
+ for (const [columnName, columnSchema] of Object.entries(built.columns)) {
+ const castAs = columnSchema.cast_as
+ const dataType = dataTypeFromCastAs(castAs)
+ if (!dataType) continue
+ if (index.has(dataType)) continue
+ const columnBuilder = (table as unknown as Record)[
+ columnName
+ ]
+ if (!isEncryptedColumnBuilder(columnBuilder)) continue
+ index.set(dataType, {
+ table,
+ column: columnBuilder,
+ columnName,
+ })
+ }
+ }
+ return index
+}
+
+function dataTypeFromCastAs(castAs: string): EncryptedDataType | null {
+ switch (castAs) {
+ case 'string':
+ case 'text':
+ return 'string'
+ case 'number':
+ case 'double':
+ case 'real':
+ case 'int':
+ case 'small_int':
+ case 'big_int':
+ return 'number'
+ case 'boolean':
+ return 'boolean'
+ case 'date':
+ return 'date'
+ case 'json':
+ case 'jsonb':
+ return 'json'
+ default:
+ return null
+ }
+}
+
+function isEncryptedColumnBuilder(value: unknown): value is EncryptedColumn {
+ if (typeof value !== 'object' || value === null) return false
+ const candidate = value as { getName?: unknown; build?: unknown }
+ return (
+ typeof candidate.getName === 'function' &&
+ typeof candidate.build === 'function'
+ )
+}
+
+/**
+ * Required env vars when constructing a default client. Validated
+ * synchronously at extension construction time so misconfiguration
+ * surfaces in the dev-server boot log, not deep inside a codec call.
+ */
+export const REQUIRED_ENV_VARS = [
+ 'CS_WORKSPACE_CRN',
+ 'CS_CLIENT_ID',
+ 'CS_CLIENT_KEY',
+] as const
+
+export type RequiredEnvVar = (typeof REQUIRED_ENV_VARS)[number]
+
+/**
+ * Throw a structured `CipherStashCodecError` listing every missing
+ * env var on a single line. Includes a pointer to the README anchor
+ * so the user can self-serve.
+ */
+function assertEnvIsConfigured(): void {
+ const missing: RequiredEnvVar[] = []
+ for (const name of REQUIRED_ENV_VARS) {
+ if (!process.env[name]) missing.push(name)
+ }
+ if (missing.length === 0) return
+ throw new CipherStashCodecError({
+ code: 'CONFIG_MISSING_ENV',
+ message: `cipherstashEncryption() requires the following environment variables: ${missing.join(', ')}. Either set them in your environment, or pass a pre-constructed EncryptionClient: cipherstashEncryption({ encryptionClient }). See packages/stack/src/prisma/README.md#setup for details.`,
+ column: undefined,
+ expectedDataType: undefined,
+ actualType: 'missing',
+ })
+}
+
+/**
+ * Construct a per-extension binding.
+ *
+ * - When `encryptionClient` is supplied, use it verbatim. Schema
+ * registration is the caller's responsibility — the binding still
+ * reads the contract-derived index so the codecs route real
+ * `(table, column)` pairs through to the FFI.
+ * - When `encryptionClient` is omitted, validate the required env
+ * vars synchronously and lazy-construct a default client on first
+ * encrypt/decrypt. The lazy promise is cached per binding so
+ * concurrent callers share a single in-flight initialization;
+ * a failure clears the cache so subsequent calls retry.
+ */
+export function createEncryptionBinding(opts: {
+ readonly client?: EncryptionClient
+ readonly schemas: ReadonlyArray>
+}): CipherStashEncryptionBinding {
+ const { client: providedClient, schemas } = opts
+ const columnIndex = indexSchemasByDataType(schemas)
+
+ let pending: Promise | undefined
+
+ if (!providedClient) {
+ // Eager env validation (F-5). When the caller supplies a client,
+ // they are responsible for its credentials — we don't second-guess
+ // them.
+ assertEnvIsConfigured()
+ }
+
+ const getClient = async (): Promise => {
+ if (providedClient) return providedClient
+ if (pending) return pending
+ if (schemas.length === 0) {
+ throw new CipherStashCodecError({
+ code: 'NO_CONTRACT_SCHEMAS',
+ message:
+ 'cipherstashEncryption() was constructed without `encryptionClient` and the contract declared no encrypted columns. Provide a contract with at least one `encrypted*({...})` column, or pass a pre-constructed `encryptionClient`. See packages/stack/src/prisma/README.md#setup for details.',
+ column: undefined,
+ expectedDataType: undefined,
+ actualType: 'no-schemas',
+ })
+ }
+ const schemaList = schemas as ReadonlyArray<
+ EncryptedTable
+ >
+ pending = Encryption({
+ schemas: schemaList as unknown as [
+ EncryptedTable,
+ ...EncryptedTable[],
+ ],
+ }).catch((error: unknown) => {
+ // Clear the cache on failure so the next call retries; otherwise
+ // a transient network failure during boot poisons every encode
+ // for the lifetime of the process.
+ pending = undefined
+ throw error
+ })
+ return pending
+ }
+
+ const resolveColumnFor = (
+ dataType: EncryptedDataType,
+ ): ColumnBinding | null => columnIndex.get(dataType) ?? null
+
+ return { getClient, resolveColumnFor }
+}
+
+/**
+ * Helper for codec call sites that need both the binding and a
+ * resolved `ColumnBinding`. Surfaces a structured error when the
+ * contract has no encrypted column matching the JS-runtime data type.
+ */
+export function requireColumnFor(
+ binding: CipherStashEncryptionBinding,
+ dataType: EncryptedDataType,
+ hint: { readonly codecLabel: string; readonly value: unknown },
+): ColumnBinding {
+ const column = binding.resolveColumnFor(dataType)
+ if (column) return column
+ throw new CipherStashCodecError({
+ code: 'NO_COLUMN_FOR_DATATYPE',
+ message: `${hint.codecLabel}: contract has no encrypted column with dataType '${dataType}'. Add an encrypted column of that type to the contract, or use a column type that matches the value (got JS type '${describeJs(hint.value)}').`,
+ column: undefined,
+ expectedDataType: dataType,
+ actualType: describeJs(hint.value),
+ })
+}
+
+function describeJs(value: unknown): string {
+ if (value === null) return 'null'
+ if (value instanceof Date) return 'Date'
+ if (Array.isArray(value)) return 'array'
+ return typeof value
+}
diff --git a/packages/stack/src/prisma/core/eql-bundle.ts b/packages/stack/src/prisma/core/eql-bundle.ts
new file mode 100644
index 00000000..85b2d8bb
--- /dev/null
+++ b/packages/stack/src/prisma/core/eql-bundle.ts
@@ -0,0 +1,48 @@
+import { EQL_INSTALL_SQL, EQL_INSTALL_VERSION } from './eql-install.generated'
+
+/**
+ * Vendored EQL install SQL bundle.
+ *
+ * The bundle is fetched at build time by `scripts/vendor-eql-install.ts`
+ * from a pinned `encrypt-query-language` GitHub release. Two artefacts
+ * are produced:
+ *
+ * - `core/eql-install.sql` (committed, human-readable, easy to diff
+ * between version bumps and apply manually in emergencies).
+ * - `core/eql-install.generated.ts` (committed, the same SQL exported
+ * as a TypeScript string literal so it can be imported directly
+ * into the codec module).
+ *
+ * Importing the SQL as a string literal is the simplest path to ESM/CJS
+ * portability — no `import.meta`, no `fs.readFileSync`, no path
+ * resolution. tsup inlines the literal into both dist outputs the same
+ * way it inlines any other string constant.
+ *
+ * The trade-off is bundle size: every consumer of `@cipherstash/stack/prisma`
+ * pays the SQL's ~170 KB even if they never run a migration. The
+ * migration planner is the only consumer; we accept the size for the
+ * portability win until/if Prisma Next exposes a streaming-asset
+ * resolution surface.
+ */
+
+/**
+ * Return the vendored EQL install SQL (entire bundle as one string).
+ *
+ * The bundle is intended for direct execution against Postgres as part
+ * of the `databaseDependencies.init` migration step. Phase 4's
+ * `databaseDependencies.upgrade` will diff this version against the
+ * currently-installed one and emit upgrade DDL instead.
+ */
+export function getEqlInstallSql(): string {
+ return EQL_INSTALL_SQL
+}
+
+/**
+ * Pinned EQL release version baked into the vendored bundle. Surfaced
+ * through the control descriptor's `version` field so future
+ * `databaseDependencies.upgrade(fromVersion, toVersion)` work has a
+ * hook.
+ */
+export function getEqlBundleVersion(): string {
+ return EQL_INSTALL_VERSION
+}
diff --git a/packages/stack/src/prisma/core/eql-install.generated.ts b/packages/stack/src/prisma/core/eql-install.generated.ts
new file mode 100644
index 00000000..b0bae1a3
--- /dev/null
+++ b/packages/stack/src/prisma/core/eql-install.generated.ts
@@ -0,0 +1,5751 @@
+// @generated — DO NOT EDIT.
+// Source: scripts/vendor-eql-install.ts
+// Bundle pinned version: eql-2.2.1
+//
+// This file is committed to source control so dev environments and
+// offline builds work without network access. Regenerate with
+// `pnpm vendor-eql-install` after bumping EQL_VERSION in the script.
+
+export const EQL_INSTALL_VERSION = 'eql-2.2.1' as const
+
+export const EQL_INSTALL_SQL: string = `--! @file schema.sql
+--! @brief EQL v2 schema creation
+--!
+--! Creates the eql_v2 schema which contains all Encrypt Query Language
+--! functions, types, and tables. Drops existing schema if present to
+--! support clean reinstallation.
+--!
+--! @warning DROP SCHEMA CASCADE will remove all objects in the schema
+--! @note All EQL objects (functions, types, tables) reside in eql_v2 schema
+
+--! @brief Drop existing EQL v2 schema
+--! @warning CASCADE will drop all dependent objects
+DROP SCHEMA IF EXISTS eql_v2 CASCADE;
+
+--! @brief Create EQL v2 schema
+--! @note All EQL functions and types will be created in this schema
+CREATE SCHEMA eql_v2;
+
+--! @brief Composite type for encrypted column data
+--!
+--! Core type used for all encrypted columns in EQL. Stores encrypted data as JSONB
+--! with the following structure:
+--! - \`c\`: ciphertext (base64-encoded encrypted value)
+--! - \`i\`: index terms (searchable metadata for encrypted searches)
+--! - \`k\`: key ID (identifier for encryption key)
+--! - \`m\`: metadata (additional encryption metadata)
+--!
+--! Created in public schema to persist independently of eql_v2 schema lifecycle.
+--! Customer data columns use this type, so it must not be dropped if data exists.
+--!
+--! @note DO NOT DROP this type unless absolutely certain no encrypted data uses it
+--! @see eql_v2.ciphertext
+--! @see eql_v2.meta_data
+--! @see eql_v2.add_column
+DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'eql_v2_encrypted') THEN
+ CREATE TYPE public.eql_v2_encrypted AS (
+ data jsonb
+ );
+ END IF;
+ END
+$$;
+
+
+
+
+
+
+
+
+
+
+--! @brief Bloom filter index term type
+--!
+--! Domain type representing Bloom filter bit arrays stored as smallint arrays.
+--! Used for pattern-match encrypted searches via the 'match' index type.
+--! The filter is stored in the 'bf' field of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2."~~"
+--! @note This is a transient type used only during query execution
+CREATE DOMAIN eql_v2.bloom_filter AS smallint[];
+
+
+
+--! @brief ORE block term type for Order-Revealing Encryption
+--!
+--! Composite type representing a single ORE (Order-Revealing Encryption) block term.
+--! Stores encrypted data as bytea that enables range comparisons without decryption.
+--!
+--! @see eql_v2.ore_block_u64_8_256
+--! @see eql_v2.compare_ore_block_u64_8_256_term
+CREATE TYPE eql_v2.ore_block_u64_8_256_term AS (
+ bytes bytea
+);
+
+
+--! @brief ORE block index term type for range queries
+--!
+--! Composite type containing an array of ORE block terms. Used for encrypted
+--! range queries via the 'ore' index type. The array is stored in the 'ob' field
+--! of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+--! @note This is a transient type used only during query execution
+CREATE TYPE eql_v2.ore_block_u64_8_256 AS (
+ terms eql_v2.ore_block_u64_8_256_term[]
+);
+
+--! @brief HMAC-SHA256 index term type
+--!
+--! Domain type representing HMAC-SHA256 hash values.
+--! Used for exact-match encrypted searches via the 'unique' index type.
+--! The hash is stored in the 'hm' field of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @note This is a transient type used only during query execution
+CREATE DOMAIN eql_v2.hmac_256 AS text;
+-- AUTOMATICALLY GENERATED FILE
+
+--! @file common.sql
+--! @brief Common utility functions
+--!
+--! Provides general-purpose utility functions used across EQL:
+--! - Constant-time bytea comparison for security
+--! - JSONB to bytea array conversion
+--! - Logging helpers for debugging and testing
+
+
+--! @brief Constant-time comparison of bytea values
+--! @internal
+--!
+--! Compares two bytea values in constant time to prevent timing attacks.
+--! Always checks all bytes even after finding differences, maintaining
+--! consistent execution time regardless of where differences occur.
+--!
+--! @param a bytea First value to compare
+--! @param b bytea Second value to compare
+--! @return boolean True if values are equal
+--!
+--! @note Returns false immediately if lengths differ (length is not secret)
+--! @note Used for secure comparison of cryptographic values
+CREATE FUNCTION eql_v2.bytea_eq(a bytea, b bytea) RETURNS boolean AS $$
+DECLARE
+ result boolean;
+ differing bytea;
+BEGIN
+
+ -- Check if the bytea values are the same length
+ IF LENGTH(a) != LENGTH(b) THEN
+ RETURN false;
+ END IF;
+
+ -- Compare each byte in the bytea values
+ result := true;
+ FOR i IN 1..LENGTH(a) LOOP
+ IF SUBSTRING(a FROM i FOR 1) != SUBSTRING(b FROM i FOR 1) THEN
+ result := result AND false;
+ END IF;
+ END LOOP;
+
+ RETURN result;
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @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 jsonb JSONB array of hex-encoded strings
+--! @return bytea[] Array of decoded binary values
+--!
+--! @note Returns NULL if input is JSON null
+--! @note Each array element is hex-decoded to bytea
+CREATE FUNCTION eql_v2.jsonb_array_to_bytea_array(val jsonb)
+RETURNS bytea[] AS $$
+DECLARE
+ terms_arr bytea[];
+BEGIN
+ IF jsonb_typeof(val) = 'null' THEN
+ RETURN NULL;
+ END IF;
+
+ SELECT array_agg(decode(value::text, 'hex')::bytea)
+ INTO terms_arr
+ FROM jsonb_array_elements_text(val) AS value;
+
+ RETURN terms_arr;
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Log message for debugging
+--!
+--! Convenience function to emit log messages during testing and debugging.
+--! Uses RAISE NOTICE to output messages to PostgreSQL logs.
+--!
+--! @param text Message to log
+--!
+--! @note Primarily used in tests and development
+--! @see eql_v2.log(text, text) for contextual logging
+CREATE FUNCTION eql_v2.log(s text)
+ RETURNS void
+AS $$
+ BEGIN
+ RAISE NOTICE '[LOG] %', s;
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Log message with context
+--!
+--! Overload of log function that includes context label for better
+--! log organization during testing.
+--!
+--! @param ctx text Context label (e.g., test name, module name)
+--! @param s text Message to log
+--!
+--! @note Format: "[LOG] {ctx} {message}"
+--! @see eql_v2.log(text)
+CREATE FUNCTION eql_v2.log(ctx text, s text)
+ RETURNS void
+AS $$
+ BEGIN
+ RAISE NOTICE '[LOG] % %', ctx, s;
+END;
+$$ LANGUAGE plpgsql;
+
+--! @brief CLLW ORE index term type for range queries
+--!
+--! Composite type for CLLW (Copyless Logarithmic Width) Order-Revealing Encryption.
+--! Each output block is 8-bits. Used for encrypted range queries via the 'ore' index type.
+--! The ciphertext is stored in the 'ocf' field of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.compare_ore_cllw_u64_8
+--! @note This is a transient type used only during query execution
+CREATE TYPE eql_v2.ore_cllw_u64_8 AS (
+ bytes bytea
+);
+
+--! @file crypto.sql
+--! @brief PostgreSQL pgcrypto extension enablement
+--!
+--! Enables the pgcrypto extension which provides cryptographic functions
+--! used by EQL for hashing and other cryptographic operations.
+--!
+--! @note pgcrypto provides functions like digest(), hmac(), gen_random_bytes()
+--! @note IF NOT EXISTS prevents errors if extension already enabled
+
+--! @brief Enable pgcrypto extension
+--! @note Provides cryptographic functions for hashing and random number generation
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+
+
+--! @brief Extract ciphertext from encrypted JSONB value
+--!
+--! Extracts the ciphertext (c field) from a raw JSONB encrypted value.
+--! The ciphertext is the base64-encoded encrypted data.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Text Base64-encoded ciphertext string
+--! @throws Exception if 'c' field is not present in JSONB
+--!
+--! @example
+--! -- Extract ciphertext from JSONB literal
+--! SELECT eql_v2.ciphertext('{"c":"AQIDBA==","i":{"unique":"..."}}'::jsonb);
+--!
+--! @see eql_v2.ciphertext(eql_v2_encrypted)
+--! @see eql_v2.meta_data
+CREATE FUNCTION eql_v2.ciphertext(val jsonb)
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val ? 'c' THEN
+ RETURN val->>'c';
+ END IF;
+ RAISE 'Expected a ciphertext (c) value in json: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Extract ciphertext from encrypted column value
+--!
+--! Extracts the ciphertext from an encrypted column value. Convenience
+--! overload that unwraps eql_v2_encrypted type and delegates to JSONB version.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Text Base64-encoded ciphertext string
+--! @throws Exception if encrypted value is malformed
+--!
+--! @example
+--! -- Extract ciphertext from encrypted column
+--! SELECT eql_v2.ciphertext(encrypted_email) FROM users;
+--!
+--! @see eql_v2.ciphertext(jsonb)
+--! @see eql_v2.meta_data
+CREATE FUNCTION eql_v2.ciphertext(val eql_v2_encrypted)
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.ciphertext(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief State transition function for grouped_value aggregate
+--! @internal
+--!
+--! Returns the first non-null value encountered. Used as state function
+--! for the grouped_value aggregate to select first value in each group.
+--!
+--! @param $1 JSONB Accumulated state (first non-null value found)
+--! @param $2 JSONB New value from current row
+--! @return JSONB First non-null value (state or new value)
+--!
+--! @see eql_v2.grouped_value
+CREATE FUNCTION eql_v2._first_grouped_value(jsonb, jsonb)
+RETURNS jsonb AS $$
+ SELECT COALESCE($1, $2);
+$$ LANGUAGE sql IMMUTABLE;
+
+--! @brief Return first non-null encrypted value in a group
+--!
+--! Aggregate function that returns the first non-null encrypted value
+--! encountered within a GROUP BY clause. Useful for deduplication or
+--! selecting representative values from grouped encrypted data.
+--!
+--! @param input JSONB Encrypted values to aggregate
+--! @return JSONB First non-null encrypted value in group
+--!
+--! @example
+--! -- Get first email per user group
+--! SELECT user_id, eql_v2.grouped_value(encrypted_email)
+--! FROM user_emails
+--! GROUP BY user_id;
+--!
+--! -- Deduplicate encrypted values
+--! SELECT DISTINCT ON (user_id)
+--! user_id,
+--! eql_v2.grouped_value(encrypted_ssn) as primary_ssn
+--! FROM user_records
+--! GROUP BY user_id;
+--!
+--! @see eql_v2._first_grouped_value
+CREATE AGGREGATE eql_v2.grouped_value(jsonb) (
+ SFUNC = eql_v2._first_grouped_value,
+ STYPE = jsonb
+);
+
+--! @brief Add validation constraint to encrypted column
+--!
+--! Adds a CHECK constraint to ensure column values conform to encrypted data
+--! structure. Constraint uses eql_v2.check_encrypted to validate format.
+--! Called automatically by eql_v2.add_column.
+--!
+--! @param table_name TEXT Name of table containing the column
+--! @param column_name TEXT Name of column to constrain
+--! @return Void
+--!
+--! @example
+--! -- Manually add constraint (normally done by add_column)
+--! SELECT eql_v2.add_encrypted_constraint('users', 'encrypted_email');
+--!
+--! -- Resulting constraint:
+--! -- ALTER TABLE users ADD CONSTRAINT eql_v2_encrypted_check_encrypted_email
+--! -- CHECK (eql_v2.check_encrypted(encrypted_email));
+--!
+--! @see eql_v2.add_column
+--! @see eql_v2.remove_encrypted_constraint
+CREATE FUNCTION eql_v2.add_encrypted_constraint(table_name TEXT, column_name TEXT)
+ RETURNS void
+AS $$
+ BEGIN
+ EXECUTE format('ALTER TABLE %I ADD CONSTRAINT eql_v2_encrypted_constraint_%I_%I CHECK (eql_v2.check_encrypted(%I))', table_name, table_name, column_name, column_name);
+ EXCEPTION
+ WHEN duplicate_table THEN
+ WHEN duplicate_object THEN
+ RAISE NOTICE 'Constraint \`eql_v2_encrypted_constraint_%_%\` already exists, skipping', table_name, column_name;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Remove validation constraint from encrypted column
+--!
+--! Removes the CHECK constraint that validates encrypted data structure.
+--! Called automatically by eql_v2.remove_column. Uses IF EXISTS to avoid
+--! errors if constraint doesn't exist.
+--!
+--! @param table_name TEXT Name of table containing the column
+--! @param column_name TEXT Name of column to unconstrain
+--! @return Void
+--!
+--! @example
+--! -- Manually remove constraint (normally done by remove_column)
+--! SELECT eql_v2.remove_encrypted_constraint('users', 'encrypted_email');
+--!
+--! @see eql_v2.remove_column
+--! @see eql_v2.add_encrypted_constraint
+CREATE FUNCTION eql_v2.remove_encrypted_constraint(table_name TEXT, column_name TEXT)
+ RETURNS void
+AS $$
+ BEGIN
+ EXECUTE format('ALTER TABLE %I DROP CONSTRAINT IF EXISTS eql_v2_encrypted_constraint_%I_%I', table_name, table_name, column_name);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Extract metadata from encrypted JSONB value
+--!
+--! Extracts index terms (i) and version (v) from a raw JSONB encrypted value.
+--! Returns metadata object containing searchable index terms without ciphertext.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return JSONB Metadata object with 'i' (index terms) and 'v' (version) fields
+--!
+--! @example
+--! -- Extract metadata to inspect index terms
+--! SELECT eql_v2.meta_data('{"c":"...","i":{"unique":"abc123"},"v":1}'::jsonb);
+--! -- Returns: {"i":{"unique":"abc123"},"v":1}
+--!
+--! @see eql_v2.meta_data(eql_v2_encrypted)
+--! @see eql_v2.ciphertext
+CREATE FUNCTION eql_v2.meta_data(val jsonb)
+ RETURNS jsonb
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN jsonb_build_object(
+ 'i', val->'i',
+ 'v', val->'v'
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Extract metadata from encrypted column value
+--!
+--! Extracts index terms and version from an encrypted column value.
+--! Convenience overload that unwraps eql_v2_encrypted type and
+--! delegates to JSONB version.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return JSONB Metadata object with 'i' (index terms) and 'v' (version) fields
+--!
+--! @example
+--! -- Inspect index terms for encrypted column
+--! SELECT user_id, eql_v2.meta_data(encrypted_email) as email_metadata
+--! FROM users;
+--!
+--! @see eql_v2.meta_data(jsonb)
+--! @see eql_v2.ciphertext
+CREATE FUNCTION eql_v2.meta_data(val eql_v2_encrypted)
+ RETURNS jsonb
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.meta_data(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Variable-width CLLW ORE index term type for range queries
+--!
+--! Composite type for variable-width CLLW (Copyless Logarithmic Width) Order-Revealing Encryption.
+--! Each output block is 8-bits. Unlike ore_cllw_u64_8, supports variable-length ciphertexts.
+--! Used for encrypted range queries via the 'ore' index type.
+--! The ciphertext is stored in the 'ocv' field of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.compare_ore_cllw_var_8
+--! @note This is a transient type used only during query execution
+CREATE TYPE eql_v2.ore_cllw_var_8 AS (
+ bytes bytea
+);
+
+
+--! @brief Extract CLLW ORE index term from JSONB payload
+--!
+--! Extracts the CLLW ORE ciphertext from the 'ocf' field of an encrypted
+--! data payload. Used internally for range query comparisons.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.ore_cllw_u64_8 CLLW ORE ciphertext
+--! @throws Exception if 'ocf' field is missing when ore index is expected
+--!
+--! @see eql_v2.has_ore_cllw_u64_8
+--! @see eql_v2.compare_ore_cllw_u64_8
+CREATE FUNCTION eql_v2.ore_cllw_u64_8(val jsonb)
+ RETURNS eql_v2.ore_cllw_u64_8
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF NOT (eql_v2.has_ore_cllw_u64_8(val)) THEN
+ RAISE 'Expected a ore_cllw_u64_8 index (ocf) value in json: %', val;
+ END IF;
+
+ RETURN ROW(decode(val->>'ocf', 'hex'));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract CLLW ORE index term from encrypted column value
+--!
+--! Extracts the CLLW ORE ciphertext from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.ore_cllw_u64_8 CLLW ORE ciphertext
+--!
+--! @see eql_v2.ore_cllw_u64_8(jsonb)
+CREATE FUNCTION eql_v2.ore_cllw_u64_8(val eql_v2_encrypted)
+ RETURNS eql_v2.ore_cllw_u64_8
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.ore_cllw_u64_8(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains CLLW ORE index term
+--!
+--! Tests whether the encrypted data payload includes an 'ocf' field,
+--! indicating a CLLW ORE ciphertext is available for range queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'ocf' field is present and non-null
+--!
+--! @see eql_v2.ore_cllw_u64_8
+CREATE FUNCTION eql_v2.has_ore_cllw_u64_8(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'ocf' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains CLLW ORE index term
+--!
+--! Tests whether an encrypted column value includes a CLLW ORE ciphertext
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if CLLW ORE ciphertext is present
+--!
+--! @see eql_v2.has_ore_cllw_u64_8(jsonb)
+CREATE FUNCTION eql_v2.has_ore_cllw_u64_8(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_ore_cllw_u64_8(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Compare CLLW ORE ciphertext bytes
+--! @internal
+--!
+--! Byte-by-byte comparison of CLLW ORE ciphertexts implementing the CLLW
+--! comparison algorithm. Used by both fixed-width (ore_cllw_u64_8) and
+--! variable-width (ore_cllw_var_8) ORE variants.
+--!
+--! @param a Bytea First CLLW ORE ciphertext
+--! @param b Bytea Second CLLW ORE ciphertext
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--! @throws Exception if ciphertexts are different lengths
+--!
+--! @note Shared comparison logic for multiple ORE CLLW schemes
+--! @see eql_v2.compare_ore_cllw_u64_8
+CREATE FUNCTION eql_v2.compare_ore_cllw_term_bytes(a bytea, b bytea)
+RETURNS int AS $$
+DECLARE
+ len_a INT;
+ len_b INT;
+ x BYTEA;
+ y BYTEA;
+ i INT;
+ differing boolean;
+BEGIN
+
+ -- Check if the lengths of the two bytea arguments are the same
+ len_a := LENGTH(a);
+ len_b := LENGTH(b);
+
+ IF len_a != len_b THEN
+ RAISE EXCEPTION 'ore_cllw index terms are not the same length';
+ END IF;
+
+ -- Iterate over each byte and compare them
+ FOR i IN 1..len_a LOOP
+ x := SUBSTRING(a FROM i FOR 1);
+ y := SUBSTRING(b FROM i FOR 1);
+
+ -- Check if there's a difference
+ IF x != y THEN
+ differing := true;
+ EXIT;
+ END IF;
+ END LOOP;
+
+ -- If a difference is found, compare the bytes as in Rust logic
+ IF differing THEN
+ IF (get_byte(y, 0) + 1) % 256 = get_byte(x, 0) THEN
+ RETURN 1;
+ ELSE
+ RETURN -1;
+ END IF;
+ ELSE
+ RETURN 0;
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Blake3 hash index term type
+--!
+--! Domain type representing Blake3 cryptographic hash values.
+--! Used for exact-match encrypted searches via the 'unique' index type.
+--! The hash is stored in the 'b3' field of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @note This is a transient type used only during query execution
+CREATE DOMAIN eql_v2.blake3 AS text;
+
+--! @brief Extract Blake3 hash index term from JSONB payload
+--!
+--! Extracts the Blake3 hash value from the 'b3' field of an encrypted
+--! data payload. Used internally for exact-match comparisons.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.blake3 Blake3 hash value, or NULL if not present
+--! @throws Exception if 'b3' field is missing when blake3 index is expected
+--!
+--! @see eql_v2.has_blake3
+--! @see eql_v2.compare_blake3
+CREATE FUNCTION eql_v2.blake3(val jsonb)
+ RETURNS eql_v2.blake3
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF NOT eql_v2.has_blake3(val) THEN
+ RAISE 'Expected a blake3 index (b3) value in json: %', val;
+ END IF;
+
+ IF val->>'b3' IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ RETURN val->>'b3';
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract Blake3 hash index term from encrypted column value
+--!
+--! Extracts the Blake3 hash from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.blake3 Blake3 hash value, or NULL if not present
+--!
+--! @see eql_v2.blake3(jsonb)
+CREATE FUNCTION eql_v2.blake3(val eql_v2_encrypted)
+ RETURNS eql_v2.blake3
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.blake3(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains Blake3 index term
+--!
+--! Tests whether the encrypted data payload includes a 'b3' field,
+--! indicating a Blake3 hash is available for exact-match queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'b3' field is present and non-null
+--!
+--! @see eql_v2.blake3
+CREATE FUNCTION eql_v2.has_blake3(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'b3' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains Blake3 index term
+--!
+--! Tests whether an encrypted column value includes a Blake3 hash
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if Blake3 hash is present
+--!
+--! @see eql_v2.has_blake3(jsonb)
+CREATE FUNCTION eql_v2.has_blake3(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_blake3(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract HMAC-SHA256 index term from JSONB payload
+--!
+--! Extracts the HMAC-SHA256 hash value from the 'hm' field of an encrypted
+--! data payload. Used internally for exact-match comparisons.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.hmac_256 HMAC-SHA256 hash value
+--! @throws Exception if 'hm' field is missing when hmac_256 index is expected
+--!
+--! @see eql_v2.has_hmac_256
+--! @see eql_v2.compare_hmac_256
+CREATE FUNCTION eql_v2.hmac_256(val jsonb)
+ RETURNS eql_v2.hmac_256
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF eql_v2.has_hmac_256(val) THEN
+ RETURN val->>'hm';
+ END IF;
+ RAISE 'Expected a hmac_256 index (hm) value in json: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains HMAC-SHA256 index term
+--!
+--! Tests whether the encrypted data payload includes an 'hm' field,
+--! indicating an HMAC-SHA256 hash is available for exact-match queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'hm' field is present and non-null
+--!
+--! @see eql_v2.hmac_256
+CREATE FUNCTION eql_v2.has_hmac_256(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'hm' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains HMAC-SHA256 index term
+--!
+--! Tests whether an encrypted column value includes an HMAC-SHA256 hash
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if HMAC-SHA256 hash is present
+--!
+--! @see eql_v2.has_hmac_256(jsonb)
+CREATE FUNCTION eql_v2.has_hmac_256(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_hmac_256(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Extract HMAC-SHA256 index term from encrypted column value
+--!
+--! Extracts the HMAC-SHA256 hash from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.hmac_256 HMAC-SHA256 hash value
+--!
+--! @see eql_v2.hmac_256(jsonb)
+CREATE FUNCTION eql_v2.hmac_256(val eql_v2_encrypted)
+ RETURNS eql_v2.hmac_256
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.hmac_256(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+
+--! @brief Convert JSONB array to ORE block composite type
+--! @internal
+--!
+--! Converts a JSONB array of hex-encoded ORE terms from the CipherStash Proxy
+--! payload into the PostgreSQL composite type used for ORE operations.
+--!
+--! @param val JSONB Array of hex-encoded ORE block terms
+--! @return eql_v2.ore_block_u64_8_256 ORE block composite type, or NULL if input is null
+--!
+--! @see eql_v2.ore_block_u64_8_256(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array_to_ore_block_u64_8_256(val jsonb)
+RETURNS eql_v2.ore_block_u64_8_256 AS $$
+DECLARE
+ terms eql_v2.ore_block_u64_8_256_term[];
+BEGIN
+ IF jsonb_typeof(val) = 'null' THEN
+ RETURN NULL;
+ END IF;
+
+ SELECT array_agg(ROW(b)::eql_v2.ore_block_u64_8_256_term)
+ INTO terms
+ FROM unnest(eql_v2.jsonb_array_to_bytea_array(val)) AS b;
+
+ RETURN ROW(terms)::eql_v2.ore_block_u64_8_256;
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract ORE block index term from JSONB payload
+--!
+--! Extracts the ORE block array from the 'ob' field of an encrypted
+--! data payload. Used internally for range query comparisons.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.ore_block_u64_8_256 ORE block index term
+--! @throws Exception if 'ob' field is missing when ore index is expected
+--!
+--! @see eql_v2.has_ore_block_u64_8_256
+--! @see eql_v2.compare_ore_block_u64_8_256
+CREATE FUNCTION eql_v2.ore_block_u64_8_256(val jsonb)
+ RETURNS eql_v2.ore_block_u64_8_256
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF eql_v2.has_ore_block_u64_8_256(val) THEN
+ RETURN eql_v2.jsonb_array_to_ore_block_u64_8_256(val->'ob');
+ END IF;
+ RAISE 'Expected an ore index (ob) value in json: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract ORE block index term from encrypted column value
+--!
+--! Extracts the ORE block from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.ore_block_u64_8_256 ORE block index term
+--!
+--! @see eql_v2.ore_block_u64_8_256(jsonb)
+CREATE FUNCTION eql_v2.ore_block_u64_8_256(val eql_v2_encrypted)
+ RETURNS eql_v2.ore_block_u64_8_256
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.ore_block_u64_8_256(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains ORE block index term
+--!
+--! Tests whether the encrypted data payload includes an 'ob' field,
+--! indicating an ORE block is available for range queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'ob' field is present and non-null
+--!
+--! @see eql_v2.ore_block_u64_8_256
+CREATE FUNCTION eql_v2.has_ore_block_u64_8_256(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'ob' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains ORE block index term
+--!
+--! Tests whether an encrypted column value includes an ORE block
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if ORE block is present
+--!
+--! @see eql_v2.has_ore_block_u64_8_256(jsonb)
+CREATE FUNCTION eql_v2.has_ore_block_u64_8_256(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_ore_block_u64_8_256(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Compare two ORE block terms using cryptographic comparison
+--! @internal
+--!
+--! Performs a three-way comparison (returns -1/0/1) of individual ORE block terms
+--! using the ORE cryptographic protocol. Compares PRP and PRF blocks to determine
+--! ordering without decryption.
+--!
+--! @param a eql_v2.ore_block_u64_8_256_term First ORE term to compare
+--! @param b eql_v2.ore_block_u64_8_256_term Second ORE term to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--! @throws Exception if ciphertexts are different lengths
+--!
+--! @note Uses AES-ECB encryption for bit comparisons per ORE protocol
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_term(a eql_v2.ore_block_u64_8_256_term, b eql_v2.ore_block_u64_8_256_term)
+ RETURNS integer
+AS $$
+ DECLARE
+ eq boolean := true;
+ unequal_block smallint := 0;
+ hash_key bytea;
+ data_block bytea;
+ encrypt_block bytea;
+ target_block bytea;
+
+ left_block_size CONSTANT smallint := 16;
+ right_block_size CONSTANT smallint := 32;
+ right_offset CONSTANT smallint := 136; -- 8 * 17
+
+ indicator smallint := 0;
+ BEGIN
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF bit_length(a.bytes) != bit_length(b.bytes) THEN
+ RAISE EXCEPTION 'Ciphertexts are different lengths';
+ END IF;
+
+ FOR block IN 0..7 LOOP
+ -- Compare each PRP (byte from the first 8 bytes) and PRF block (8 byte
+ -- chunks of the rest of the value).
+ -- NOTE:
+ -- * Substr is ordinally indexed (hence 1 and not 0, and 9 and not 8).
+ -- * We are not worrying about timing attacks here; don't fret about
+ -- the OR or !=.
+ IF
+ substr(a.bytes, 1 + block, 1) != substr(b.bytes, 1 + block, 1)
+ OR substr(a.bytes, 9 + left_block_size * block, left_block_size) != substr(b.bytes, 9 + left_block_size * BLOCK, left_block_size)
+ THEN
+ -- set the first unequal block we find
+ IF eq THEN
+ unequal_block := block;
+ END IF;
+ eq = false;
+ END IF;
+ END LOOP;
+
+ IF eq THEN
+ RETURN 0::integer;
+ END IF;
+
+ -- Hash key is the IV from the right CT of b
+ hash_key := substr(b.bytes, right_offset + 1, 16);
+
+ -- first right block is at right offset + nonce_size (ordinally indexed)
+ target_block := substr(b.bytes, right_offset + 17 + (unequal_block * right_block_size), right_block_size);
+
+ data_block := substr(a.bytes, 9 + (left_block_size * unequal_block), left_block_size);
+
+ encrypt_block := public.encrypt(data_block::bytea, hash_key::bytea, 'aes-ecb');
+
+ indicator := (
+ get_bit(
+ encrypt_block,
+ 0
+ ) + get_bit(target_block, get_byte(a.bytes, unequal_block))) % 2;
+
+ IF indicator = 1 THEN
+ RETURN 1::integer;
+ ELSE
+ RETURN -1::integer;
+ END IF;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Compare arrays of ORE block terms recursively
+--! @internal
+--!
+--! Recursively compares arrays of ORE block terms element-by-element.
+--! Empty arrays are considered less than non-empty arrays. If the first elements
+--! are equal, recursively compares remaining elements.
+--!
+--! @param a eql_v2.ore_block_u64_8_256_term[] First array of ORE terms
+--! @param b eql_v2.ore_block_u64_8_256_term[] Second array of ORE terms
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b, NULL if either array is NULL
+--!
+--! @note Empty arrays sort before non-empty arrays
+--! @see eql_v2.compare_ore_block_u64_8_256_term
+CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256_term[], b eql_v2.ore_block_u64_8_256_term[])
+RETURNS integer AS $$
+ DECLARE
+ cmp_result integer;
+ BEGIN
+
+ -- NULLs are NULL
+ IF a IS NULL OR b IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ -- empty a and b
+ IF cardinality(a) = 0 AND cardinality(b) = 0 THEN
+ RETURN 0;
+ END IF;
+
+ -- empty a and some b
+ IF (cardinality(a) = 0) AND cardinality(b) > 0 THEN
+ RETURN -1;
+ END IF;
+
+ -- some a and empty b
+ IF cardinality(a) > 0 AND (cardinality(b) = 0) THEN
+ RETURN 1;
+ END IF;
+
+ cmp_result := eql_v2.compare_ore_block_u64_8_256_term(a[1], b[1]);
+
+ IF cmp_result = 0 THEN
+ -- Removes the first element in the array, and calls this fn again to compare the next element/s in the array.
+ RETURN eql_v2.compare_ore_block_u64_8_256_terms(a[2:array_length(a,1)], b[2:array_length(b,1)]);
+ END IF;
+
+ RETURN cmp_result;
+ END
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Compare ORE block composite types
+--! @internal
+--!
+--! Wrapper function that extracts term arrays from ORE block composite types
+--! and delegates to the array comparison function.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 First ORE block
+--! @param b eql_v2.ore_block_u64_8_256 Second ORE block
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @see eql_v2.compare_ore_block_u64_8_256_terms(eql_v2.ore_block_u64_8_256_term[], eql_v2.ore_block_u64_8_256_term[])
+CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS integer AS $$
+ BEGIN
+ RETURN eql_v2.compare_ore_block_u64_8_256_terms(a.terms, b.terms);
+ END
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract variable-width CLLW ORE index term from JSONB payload
+--!
+--! Extracts the variable-width CLLW ORE ciphertext from the 'ocv' field of an encrypted
+--! data payload. Used internally for range query comparisons.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.ore_cllw_var_8 Variable-width CLLW ORE ciphertext
+--! @throws Exception if 'ocv' field is missing when ore index is expected
+--!
+--! @see eql_v2.has_ore_cllw_var_8
+--! @see eql_v2.compare_ore_cllw_var_8
+CREATE FUNCTION eql_v2.ore_cllw_var_8(val jsonb)
+ RETURNS eql_v2.ore_cllw_var_8
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF NOT (eql_v2.has_ore_cllw_var_8(val)) THEN
+ RAISE 'Expected a ore_cllw_var_8 index (ocv) value in json: %', val;
+ END IF;
+
+ RETURN ROW(decode(val->>'ocv', 'hex'));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract variable-width CLLW ORE index term from encrypted column value
+--!
+--! Extracts the variable-width CLLW ORE ciphertext from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.ore_cllw_var_8 Variable-width CLLW ORE ciphertext
+--!
+--! @see eql_v2.ore_cllw_var_8(jsonb)
+CREATE FUNCTION eql_v2.ore_cllw_var_8(val eql_v2_encrypted)
+ RETURNS eql_v2.ore_cllw_var_8
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.ore_cllw_var_8(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains variable-width CLLW ORE index term
+--!
+--! Tests whether the encrypted data payload includes an 'ocv' field,
+--! indicating a variable-width CLLW ORE ciphertext is available for range queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'ocv' field is present and non-null
+--!
+--! @see eql_v2.ore_cllw_var_8
+CREATE FUNCTION eql_v2.has_ore_cllw_var_8(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'ocv' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains variable-width CLLW ORE index term
+--!
+--! Tests whether an encrypted column value includes a variable-width CLLW ORE ciphertext
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if variable-width CLLW ORE ciphertext is present
+--!
+--! @see eql_v2.has_ore_cllw_var_8(jsonb)
+CREATE FUNCTION eql_v2.has_ore_cllw_var_8(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_ore_cllw_var_8(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Compare variable-width CLLW ORE ciphertext terms
+--! @internal
+--!
+--! Three-way comparison of variable-width CLLW ORE ciphertexts. Compares the common
+--! prefix using byte-by-byte CLLW comparison, then falls back to length comparison
+--! if the common prefix is equal. Used by compare_ore_cllw_var_8 for range queries.
+--!
+--! @param a eql_v2.ore_cllw_var_8 First variable-width CLLW ORE ciphertext
+--! @param b eql_v2.ore_cllw_var_8 Second variable-width CLLW ORE ciphertext
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note Handles variable-length ciphertexts by comparing common prefix first
+--! @note Returns NULL if either input is NULL
+--!
+--! @see eql_v2.compare_ore_cllw_term_bytes
+--! @see eql_v2.compare_ore_cllw_var_8
+CREATE FUNCTION eql_v2.compare_ore_cllw_var_8_term(a eql_v2.ore_cllw_var_8, b eql_v2.ore_cllw_var_8)
+RETURNS int AS $$
+DECLARE
+ len_a INT;
+ len_b INT;
+ -- length of the common part of the two bytea values
+ common_len INT;
+ cmp_result INT;
+BEGIN
+ IF a IS NULL OR b IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ -- Get the lengths of both bytea inputs
+ len_a := LENGTH(a.bytes);
+ len_b := LENGTH(b.bytes);
+
+ -- Handle empty cases
+ IF len_a = 0 AND len_b = 0 THEN
+ RETURN 0;
+ ELSIF len_a = 0 THEN
+ RETURN -1;
+ ELSIF len_b = 0 THEN
+ RETURN 1;
+ END IF;
+
+ -- Find the length of the shorter bytea
+ IF len_a < len_b THEN
+ common_len := len_a;
+ ELSE
+ common_len := len_b;
+ END IF;
+
+ -- Use the compare_ore_cllw_term function to compare byte by byte
+ cmp_result := eql_v2.compare_ore_cllw_term_bytes(
+ SUBSTRING(a.bytes FROM 1 FOR common_len),
+ SUBSTRING(b.bytes FROM 1 FOR common_len)
+ );
+
+ -- If the comparison returns 'less' or 'greater', return that result
+ IF cmp_result = -1 THEN
+ RETURN -1;
+ ELSIF cmp_result = 1 THEN
+ RETURN 1;
+ END IF;
+
+ -- If the bytea comparison is 'equal', compare lengths
+ IF len_a < len_b THEN
+ RETURN -1;
+ ELSIF len_a > len_b THEN
+ RETURN 1;
+ ELSE
+ RETURN 0;
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+
+
+
+--! @brief Core comparison function for encrypted values
+--!
+--! Compares two encrypted values using their index terms without decryption.
+--! This function implements all comparison operators required for btree indexing
+--! (<, <=, =, >=, >).
+--!
+--! Index terms are checked in the following priority order:
+--! 1. ore_block_u64_8_256 (Order-Revealing Encryption)
+--! 2. ore_cllw_u64_8 (Order-Revealing Encryption)
+--! 3. ore_cllw_var_8 (Order-Revealing Encryption)
+--! 4. hmac_256 (Hash-based equality)
+--! 5. blake3 (Hash-based equality)
+--!
+--! The first index term type present in both values is used for comparison.
+--! If no matching index terms are found, falls back to JSONB literal comparison
+--! to ensure consistent ordering (required for btree correctness).
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note Literal fallback prevents "lock BufferContent is not held" errors
+--! @see eql_v2.compare_ore_block_u64_8_256
+--! @see eql_v2.compare_blake3
+--! @see eql_v2.compare_hmac_256
+CREATE FUNCTION eql_v2.compare(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ a := eql_v2.to_ste_vec_value(a);
+ b := eql_v2.to_ste_vec_value(b);
+
+ IF eql_v2.has_ore_block_u64_8_256(a) AND eql_v2.has_ore_block_u64_8_256(b) THEN
+ RETURN eql_v2.compare_ore_block_u64_8_256(a, b);
+ END IF;
+
+ IF eql_v2.has_ore_cllw_u64_8(a) AND eql_v2.has_ore_cllw_u64_8(b) THEN
+ RETURN eql_v2.compare_ore_cllw_u64_8(a, b);
+ END IF;
+
+ IF eql_v2.has_ore_cllw_var_8(a) AND eql_v2.has_ore_cllw_var_8(b) THEN
+ RETURN eql_v2.compare_ore_cllw_var_8(a, b);
+ END IF;
+
+ IF eql_v2.has_hmac_256(a) AND eql_v2.has_hmac_256(b) THEN
+ RETURN eql_v2.compare_hmac_256(a, b);
+ END IF;
+
+ IF eql_v2.has_blake3(a) AND eql_v2.has_blake3(b) THEN
+ RETURN eql_v2.compare_blake3(a, b);
+ END IF;
+
+ -- Fallback to literal comparison of the encrypted data
+ -- Compare must have consistent ordering for a given state
+ -- Without this text fallback, database errors with "lock BufferContent is not held"
+ RETURN eql_v2.compare_literal(a, b);
+
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Convert JSONB to encrypted type
+--!
+--! Wraps a JSONB encrypted payload into the eql_v2_encrypted composite type.
+--! Used internally for type conversions and operator implementations.
+--!
+--! @param jsonb JSONB encrypted payload with structure: {"c": "...", "i": {...}, "k": "...", "v": "2"}
+--! @return eql_v2_encrypted Encrypted value wrapped in composite type
+--!
+--! @note This is primarily used for implicit casts in operator expressions
+--! @see eql_v2.to_jsonb
+CREATE FUNCTION eql_v2.to_encrypted(data jsonb)
+ RETURNS public.eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+BEGIN
+ IF data IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ RETURN ROW(data)::public.eql_v2_encrypted;
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Implicit cast from JSONB to encrypted type
+--!
+--! Enables PostgreSQL to automatically convert JSONB values to eql_v2_encrypted
+--! in assignment contexts and comparison operations.
+--!
+--! @see eql_v2.to_encrypted(jsonb)
+CREATE CAST (jsonb AS public.eql_v2_encrypted)
+ WITH FUNCTION eql_v2.to_encrypted(jsonb) AS ASSIGNMENT;
+
+
+--! @brief Convert text to encrypted type
+--!
+--! Parses a text representation of encrypted JSONB payload and wraps it
+--! in the eql_v2_encrypted composite type.
+--!
+--! @param text Text representation of JSONB encrypted payload
+--! @return eql_v2_encrypted Encrypted value wrapped in composite type
+--!
+--! @note Delegates to eql_v2.to_encrypted(jsonb) after parsing text as JSON
+--! @see eql_v2.to_encrypted(jsonb)
+CREATE FUNCTION eql_v2.to_encrypted(data text)
+ RETURNS public.eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+BEGIN
+ IF data IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ RETURN eql_v2.to_encrypted(data::jsonb);
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Implicit cast from text to encrypted type
+--!
+--! Enables PostgreSQL to automatically convert text JSON strings to eql_v2_encrypted
+--! in assignment contexts.
+--!
+--! @see eql_v2.to_encrypted(text)
+CREATE CAST (text AS public.eql_v2_encrypted)
+ WITH FUNCTION eql_v2.to_encrypted(text) AS ASSIGNMENT;
+
+
+
+--! @brief Convert encrypted type to JSONB
+--!
+--! Extracts the underlying JSONB payload from an eql_v2_encrypted composite type.
+--! Useful for debugging or when raw encrypted payload access is needed.
+--!
+--! @param e eql_v2_encrypted Encrypted value to unwrap
+--! @return jsonb Raw JSONB encrypted payload
+--!
+--! @note Returns the raw encrypted structure including ciphertext and index terms
+--! @see eql_v2.to_encrypted(jsonb)
+CREATE FUNCTION eql_v2.to_jsonb(e public.eql_v2_encrypted)
+ RETURNS jsonb
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+BEGIN
+ IF e IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ RETURN e.data;
+END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Implicit cast from encrypted type to JSONB
+--!
+--! Enables PostgreSQL to automatically extract the JSONB payload from
+--! eql_v2_encrypted values in assignment contexts.
+--!
+--! @see eql_v2.to_jsonb(eql_v2_encrypted)
+CREATE CAST (public.eql_v2_encrypted AS jsonb)
+ WITH FUNCTION eql_v2.to_jsonb(public.eql_v2_encrypted) AS ASSIGNMENT;
+
+
+
+--! @file config/types.sql
+--! @brief Configuration state type definition
+--!
+--! Defines the ENUM type for tracking encryption configuration lifecycle states.
+--! The configuration table uses this type to manage transitions between states
+--! during setup, activation, and encryption operations.
+--!
+--! @note CREATE TYPE does not support IF NOT EXISTS, so wrapped in DO block
+--! @note Configuration data stored as JSONB directly, not as DOMAIN
+--! @see config/tables.sql
+
+
+--! @brief Configuration lifecycle state
+--!
+--! Defines valid states for encryption configurations in the eql_v2_configuration table.
+--! Configurations transition through these states during setup and activation.
+--!
+--! @note Only one configuration can be in 'active', 'pending', or 'encrypting' state at once
+--! @see config/indexes.sql for uniqueness enforcement
+--! @see config/tables.sql for usage in eql_v2_configuration table
+DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'eql_v2_configuration_state') THEN
+ CREATE TYPE public.eql_v2_configuration_state AS ENUM ('active', 'inactive', 'encrypting', 'pending');
+ END IF;
+ END
+$$;
+
+
+
+--! @brief Extract Bloom filter index term from JSONB payload
+--!
+--! Extracts the Bloom filter array from the 'bf' field of an encrypted
+--! data payload. Used internally for pattern-match queries (LIKE operator).
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.bloom_filter Bloom filter as smallint array
+--! @throws Exception if 'bf' field is missing when bloom_filter index is expected
+--!
+--! @see eql_v2.has_bloom_filter
+--! @see eql_v2."~~"
+CREATE FUNCTION eql_v2.bloom_filter(val jsonb)
+ RETURNS eql_v2.bloom_filter
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF eql_v2.has_bloom_filter(val) THEN
+ RETURN ARRAY(SELECT jsonb_array_elements(val->'bf'))::eql_v2.bloom_filter;
+ END IF;
+
+ RAISE 'Expected a match index (bf) value in json: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract Bloom filter index term from encrypted column value
+--!
+--! Extracts the Bloom filter from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.bloom_filter Bloom filter as smallint array
+--!
+--! @see eql_v2.bloom_filter(jsonb)
+CREATE FUNCTION eql_v2.bloom_filter(val eql_v2_encrypted)
+ RETURNS eql_v2.bloom_filter
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.bloom_filter(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains Bloom filter index term
+--!
+--! Tests whether the encrypted data payload includes a 'bf' field,
+--! indicating a Bloom filter is available for pattern-match queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'bf' field is present and non-null
+--!
+--! @see eql_v2.bloom_filter
+CREATE FUNCTION eql_v2.has_bloom_filter(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'bf' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains Bloom filter index term
+--!
+--! Tests whether an encrypted column value includes a Bloom filter
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if Bloom filter is present
+--!
+--! @see eql_v2.has_bloom_filter(jsonb)
+CREATE FUNCTION eql_v2.has_bloom_filter(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_bloom_filter(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Fallback literal comparison for encrypted values
+--! @internal
+--!
+--! Compares two encrypted values by their raw JSONB representation when no
+--! suitable index terms are available. This ensures consistent ordering required
+--! for btree correctness and prevents "lock BufferContent is not held" errors.
+--!
+--! Used as a last resort fallback in eql_v2.compare() when encrypted values
+--! lack matching index terms (blake3, hmac_256, ore).
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note This compares the encrypted payloads directly, not the plaintext values
+--! @note Ordering is consistent but not meaningful for range queries
+--! @see eql_v2.compare
+CREATE FUNCTION eql_v2.compare_literal(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_data jsonb;
+ b_data jsonb;
+ BEGIN
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ a_data := a.data;
+ b_data := b.data;
+
+ IF a_data < b_data THEN
+ RETURN -1;
+ END IF;
+
+ IF a_data > b_data THEN
+ RETURN 1;
+ END IF;
+
+ RETURN 0;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Less-than comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for less-than testing.
+--! Returns true if first value is less than second using ORE comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if a < b (compare result = -1)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2."<"
+CREATE FUNCTION eql_v2.lt(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) = -1;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Less-than operator for encrypted values
+--!
+--! Implements the < operator for comparing two encrypted values using Order-Revealing
+--! Encryption (ORE) index terms. Enables range queries and sorting without decryption.
+--! Requires 'ore' index configuration on the column.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if a is less than b
+--!
+--! @example
+--! -- Range query on encrypted timestamps
+--! SELECT * FROM events
+--! WHERE encrypted_timestamp < '2024-01-01'::timestamp::text::eql_v2_encrypted;
+--!
+--! -- Compare encrypted numeric columns
+--! SELECT * FROM products WHERE encrypted_price < encrypted_discount_price;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2."<"(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lt(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <(
+ FUNCTION=eql_v2."<",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ COMMUTATOR = >,
+ NEGATOR = >=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief Less-than operator for encrypted value and JSONB
+--!
+--! Overload of < operator accepting JSONB on the right side. Automatically
+--! casts JSONB to eql_v2_encrypted for ORE comparison.
+--!
+--! @param eql_v2_encrypted Left operand (encrypted value)
+--! @param b JSONB Right operand (will be cast to eql_v2_encrypted)
+--! @return Boolean True if a < b
+--!
+--! @example
+--! SELECT * FROM events WHERE encrypted_age < '18'::int::text::jsonb;
+--!
+--! @see eql_v2."<"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<"(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lt(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <(
+ FUNCTION=eql_v2."<",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ COMMUTATOR = >,
+ NEGATOR = >=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief Less-than operator for JSONB and encrypted value
+--!
+--! Overload of < operator accepting JSONB on the left side. Automatically
+--! casts JSONB to eql_v2_encrypted for ORE comparison.
+--!
+--! @param a JSONB Left operand (will be cast to eql_v2_encrypted)
+--! @param eql_v2_encrypted Right operand (encrypted value)
+--! @return Boolean True if a < b
+--!
+--! @example
+--! SELECT * FROM events WHERE '2023-01-01'::date::text::jsonb < encrypted_date;
+--!
+--! @see eql_v2."<"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<"(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lt(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR <(
+ FUNCTION=eql_v2."<",
+ LEFTARG=jsonb,
+ RIGHTARG=eql_v2_encrypted,
+ COMMUTATOR = >,
+ NEGATOR = >=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+
+
+--! @brief Less-than-or-equal comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for <= testing.
+--! Returns true if first value is less than or equal to second using ORE comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if a <= b (compare result <= 0)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2."<="
+CREATE FUNCTION eql_v2.lte(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) <= 0;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Less-than-or-equal operator for encrypted values
+--!
+--! Implements the <= operator for comparing encrypted values using ORE index terms.
+--! Enables range queries with inclusive lower bounds without decryption.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if a <= b
+--!
+--! @example
+--! -- Find records with encrypted age 18 or under
+--! SELECT * FROM users WHERE encrypted_age <= '18'::int::text::eql_v2_encrypted;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2."<="(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lte(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <=(
+ FUNCTION = eql_v2."<=",
+ LEFTARG = eql_v2_encrypted,
+ RIGHTARG = eql_v2_encrypted,
+ COMMUTATOR = >=,
+ NEGATOR = >,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief <= operator for encrypted value and JSONB
+--! @see eql_v2."<="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<="(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lte(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <=(
+ FUNCTION = eql_v2."<=",
+ LEFTARG = eql_v2_encrypted,
+ RIGHTARG = jsonb,
+ COMMUTATOR = >=,
+ NEGATOR = >,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief <= operator for JSONB and encrypted value
+--! @see eql_v2."<="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<="(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lte(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR <=(
+ FUNCTION = eql_v2."<=",
+ LEFTARG = jsonb,
+ RIGHTARG = eql_v2_encrypted,
+ COMMUTATOR = >=,
+ NEGATOR = >,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+
+
+--! @brief Equality comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for equality testing.
+--! Returns true if encrypted values are equal via encrypted index comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if values are equal (compare result = 0)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2."="
+CREATE FUNCTION eql_v2.eq(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) = 0;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Equality operator for encrypted values
+--!
+--! Implements the = operator for comparing two encrypted values using their
+--! encrypted index terms (unique/blake3). Enables WHERE clause comparisons
+--! without decryption.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if encrypted values are equal
+--!
+--! @example
+--! -- Compare encrypted columns
+--! SELECT * FROM users WHERE encrypted_email = other_encrypted_email;
+--!
+--! -- Search using encrypted literal
+--! SELECT * FROM users
+--! WHERE encrypted_email = '{"c":"...","i":{"unique":"..."}}'::eql_v2_encrypted;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2."="(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.eq(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR = (
+ FUNCTION=eql_v2."=",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ NEGATOR = <>,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief Equality operator for encrypted value and JSONB
+--!
+--! Overload of = operator accepting JSONB on the right side. Automatically
+--! casts JSONB to eql_v2_encrypted for comparison. Useful for comparing
+--! against JSONB literals or columns.
+--!
+--! @param eql_v2_encrypted Left operand (encrypted value)
+--! @param b JSONB Right operand (will be cast to eql_v2_encrypted)
+--! @return Boolean True if values are equal
+--!
+--! @example
+--! -- Compare encrypted column to JSONB literal
+--! SELECT * FROM users
+--! WHERE encrypted_email = '{"c":"...","i":{"unique":"..."}}'::jsonb;
+--!
+--! @see eql_v2."="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."="(a eql_v2_encrypted, b jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.eq(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR = (
+ FUNCTION=eql_v2."=",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ NEGATOR = <>,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief Equality operator for JSONB and encrypted value
+--!
+--! Overload of = operator accepting JSONB on the left side. Automatically
+--! casts JSONB to eql_v2_encrypted for comparison. Enables commutative
+--! equality comparisons.
+--!
+--! @param a JSONB Left operand (will be cast to eql_v2_encrypted)
+--! @param eql_v2_encrypted Right operand (encrypted value)
+--! @return Boolean True if values are equal
+--!
+--! @example
+--! -- Compare JSONB literal to encrypted column
+--! SELECT * FROM users
+--! WHERE '{"c":"...","i":{"unique":"..."}}'::jsonb = encrypted_email;
+--!
+--! @see eql_v2."="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."="(a jsonb, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.eq(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR = (
+ FUNCTION=eql_v2."=",
+ LEFTARG=jsonb,
+ RIGHTARG=eql_v2_encrypted,
+ NEGATOR = <>,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+
+--! @brief Greater-than-or-equal comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for >= testing.
+--! Returns true if first value is greater than or equal to second using ORE comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if a >= b (compare result >= 0)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.">="
+CREATE FUNCTION eql_v2.gte(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) >= 0;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Greater-than-or-equal operator for encrypted values
+--!
+--! Implements the >= operator for comparing encrypted values using ORE index terms.
+--! Enables range queries with inclusive upper bounds without decryption.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if a >= b
+--!
+--! @example
+--! -- Find records with age 18 or over
+--! SELECT * FROM users WHERE encrypted_age >= '18'::int::text::eql_v2_encrypted;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.">="(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gte(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR >=(
+ FUNCTION = eql_v2.">=",
+ LEFTARG = eql_v2_encrypted,
+ RIGHTARG = eql_v2_encrypted,
+ COMMUTATOR = <=,
+ NEGATOR = <,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief >= operator for encrypted value and JSONB
+--! @see eql_v2.">="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.">="(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gte(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR >=(
+ FUNCTION = eql_v2.">=",
+ LEFTARG = eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ COMMUTATOR = <=,
+ NEGATOR = <,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief >= operator for JSONB and encrypted value
+--! @see eql_v2.">="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.">="(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gte(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR >=(
+ FUNCTION = eql_v2.">=",
+ LEFTARG = jsonb,
+ RIGHTARG =eql_v2_encrypted,
+ COMMUTATOR = <=,
+ NEGATOR = <,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+
+
+--! @brief Greater-than comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for greater-than testing.
+--! Returns true if first value is greater than second using ORE comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if a > b (compare result = 1)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.">"
+CREATE FUNCTION eql_v2.gt(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) = 1;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Greater-than operator for encrypted values
+--!
+--! Implements the > operator for comparing encrypted values using ORE index terms.
+--! Enables range queries and sorting without decryption. Requires 'ore' index
+--! configuration on the column.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if a is greater than b
+--!
+--! @example
+--! -- Find records above threshold
+--! SELECT * FROM events
+--! WHERE encrypted_value > '100'::int::text::eql_v2_encrypted;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.">"(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gt(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR >(
+ FUNCTION=eql_v2.">",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ COMMUTATOR = <,
+ NEGATOR = <=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief > operator for encrypted value and JSONB
+--! @see eql_v2.">"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.">"(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gt(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR >(
+ FUNCTION = eql_v2.">",
+ LEFTARG = eql_v2_encrypted,
+ RIGHTARG = jsonb,
+ COMMUTATOR = <,
+ NEGATOR = <=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief > operator for JSONB and encrypted value
+--! @see eql_v2.">"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.">"(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gt(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR >(
+ FUNCTION = eql_v2.">",
+ LEFTARG = jsonb,
+ RIGHTARG = eql_v2_encrypted,
+ COMMUTATOR = <,
+ NEGATOR = <=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+
+
+
+--! @brief Extract STE vector index from JSONB payload
+--!
+--! Extracts the STE (Searchable Symmetric Encryption) vector from the 'sv' field
+--! of an encrypted data payload. Returns an array of encrypted values used for
+--! containment queries (@>, <@). If no 'sv' field exists, wraps the entire payload
+--! as a single-element array.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2_encrypted[] Array of encrypted STE vector elements
+--!
+--! @see eql_v2.ste_vec(eql_v2_encrypted)
+--! @see eql_v2.ste_vec_contains
+CREATE FUNCTION eql_v2.ste_vec(val jsonb)
+ RETURNS public.eql_v2_encrypted[]
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv jsonb;
+ ary public.eql_v2_encrypted[];
+ BEGIN
+
+ IF val ? 'sv' THEN
+ sv := val->'sv';
+ ELSE
+ sv := jsonb_build_array(val);
+ END IF;
+
+ SELECT array_agg(eql_v2.to_encrypted(elem))
+ INTO ary
+ FROM jsonb_array_elements(sv) AS elem;
+
+ RETURN ary;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract STE vector index from encrypted column value
+--!
+--! Extracts the STE vector from an encrypted column value by accessing its
+--! underlying JSONB data field. Used for containment query operations.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2_encrypted[] Array of encrypted STE vector elements
+--!
+--! @see eql_v2.ste_vec(jsonb)
+CREATE FUNCTION eql_v2.ste_vec(val eql_v2_encrypted)
+ RETURNS public.eql_v2_encrypted[]
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.ste_vec(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Check if JSONB payload is a single-element STE vector
+--!
+--! Tests whether the encrypted data payload contains an 'sv' field with exactly
+--! one element. Single-element STE vectors can be treated as regular encrypted values.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'sv' field exists with exactly one element
+--!
+--! @see eql_v2.to_ste_vec_value
+CREATE FUNCTION eql_v2.is_ste_vec_value(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val ? 'sv' THEN
+ RETURN jsonb_array_length(val->'sv') = 1;
+ END IF;
+
+ RETURN false;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Check if encrypted column value is a single-element STE vector
+--!
+--! Tests whether an encrypted column value is a single-element STE vector
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if value is a single-element STE vector
+--!
+--! @see eql_v2.is_ste_vec_value(jsonb)
+CREATE FUNCTION eql_v2.is_ste_vec_value(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.is_ste_vec_value(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Convert single-element STE vector to regular encrypted value
+--!
+--! Extracts the single element from a single-element STE vector and returns it
+--! as a regular encrypted value, preserving metadata. If the input is not a
+--! single-element STE vector, returns it unchanged.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2_encrypted Regular encrypted value (unwrapped if single-element STE vector)
+--!
+--! @see eql_v2.is_ste_vec_value
+CREATE FUNCTION eql_v2.to_ste_vec_value(val jsonb)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ meta jsonb;
+ sv jsonb;
+ BEGIN
+
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF eql_v2.is_ste_vec_value(val) THEN
+ meta := eql_v2.meta_data(val);
+ sv := val->'sv';
+ sv := sv[0];
+
+ RETURN eql_v2.to_encrypted(meta || sv);
+ END IF;
+
+ RETURN eql_v2.to_encrypted(val);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Convert single-element STE vector to regular encrypted value (encrypted type)
+--!
+--! Converts an encrypted column value to a regular encrypted value by unwrapping
+--! if it's a single-element STE vector.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2_encrypted Regular encrypted value (unwrapped if single-element STE vector)
+--!
+--! @see eql_v2.to_ste_vec_value(jsonb)
+CREATE FUNCTION eql_v2.to_ste_vec_value(val eql_v2_encrypted)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.to_ste_vec_value(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Extract selector value from JSONB payload
+--!
+--! Extracts the selector ('s') field from an encrypted data payload.
+--! Selectors are used to match STE vector elements during containment queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Text The selector value
+--! @throws Exception if 's' field is missing
+--!
+--! @see eql_v2.ste_vec_contains
+CREATE FUNCTION eql_v2.selector(val jsonb)
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF val ? 's' THEN
+ RETURN val->>'s';
+ END IF;
+ RAISE 'Expected a selector index (s) value in json: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract selector value from encrypted column value
+--!
+--! Extracts the selector from an encrypted column value by accessing its
+--! underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Text The selector value
+--!
+--! @see eql_v2.selector(jsonb)
+CREATE FUNCTION eql_v2.selector(val eql_v2_encrypted)
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.selector(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Check if JSONB payload is marked as an STE vector array
+--!
+--! Tests whether the encrypted data payload has the 'a' (array) flag set to true,
+--! indicating it represents an array for STE vector operations.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'a' field is present and true
+--!
+--! @see eql_v2.ste_vec
+CREATE FUNCTION eql_v2.is_ste_vec_array(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val ? 'a' THEN
+ RETURN (val->>'a')::boolean;
+ END IF;
+
+ RETURN false;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value is marked as an STE vector array
+--!
+--! Tests whether an encrypted column value has the array flag set by checking
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if value is marked as an STE vector array
+--!
+--! @see eql_v2.is_ste_vec_array(jsonb)
+CREATE FUNCTION eql_v2.is_ste_vec_array(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.is_ste_vec_array(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Extract full encrypted JSONB elements as array
+--!
+--! Extracts all JSONB elements from the STE vector including non-deterministic fields.
+--! Use jsonb_array() instead for GIN indexing and containment queries.
+--!
+--! @param val jsonb containing encrypted EQL payload
+--! @return jsonb[] Array of full JSONB elements
+--!
+--! @see eql_v2.jsonb_array
+CREATE FUNCTION eql_v2.jsonb_array_from_array_elements(val jsonb)
+RETURNS jsonb[]
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT CASE
+ WHEN val ? 'sv' THEN
+ ARRAY(SELECT elem FROM jsonb_array_elements(val->'sv') AS elem)
+ ELSE
+ ARRAY[val]
+ END;
+$$;
+
+
+--! @brief Extract full encrypted JSONB elements as array from encrypted column
+--!
+--! @param val eql_v2_encrypted Encrypted column value
+--! @return jsonb[] Array of full JSONB elements
+--!
+--! @see eql_v2.jsonb_array_from_array_elements(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array_from_array_elements(val eql_v2_encrypted)
+RETURNS jsonb[]
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array_from_array_elements(val.data);
+$$;
+
+
+--! @brief Extract deterministic fields as array for GIN indexing
+--!
+--! Extracts only deterministic search term fields (s, b3, hm, ocv, ocf) from each
+--! STE vector element. Excludes non-deterministic ciphertext for correct containment
+--! comparison using PostgreSQL's native @> operator.
+--!
+--! @param val jsonb containing encrypted EQL payload
+--! @return jsonb[] Array of JSONB elements with only deterministic fields
+--!
+--! @note Use this for GIN indexes and containment queries
+--! @see eql_v2.jsonb_contains
+CREATE FUNCTION eql_v2.jsonb_array(val jsonb)
+RETURNS jsonb[]
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT ARRAY(
+ SELECT jsonb_object_agg(kv.key, kv.value)
+ FROM jsonb_array_elements(
+ CASE WHEN val ? 'sv' THEN val->'sv' ELSE jsonb_build_array(val) END
+ ) AS elem,
+ LATERAL jsonb_each(elem) AS kv(key, value)
+ WHERE kv.key IN ('s', 'b3', 'hm', 'ocv', 'ocf')
+ GROUP BY elem
+ );
+$$;
+
+
+--! @brief Extract deterministic fields as array from encrypted column
+--!
+--! @param val eql_v2_encrypted Encrypted column value
+--! @return jsonb[] Array of JSONB elements with only deterministic fields
+--!
+--! @see eql_v2.jsonb_array(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array(val eql_v2_encrypted)
+RETURNS jsonb[]
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(val.data);
+$$;
+
+
+--! @brief GIN-indexable JSONB containment check
+--!
+--! Checks if encrypted value 'a' contains all JSONB elements from 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! This function is designed for use with a GIN index on jsonb_array(column).
+--! When combined with such an index, PostgreSQL can efficiently search large tables.
+--!
+--! @param a eql_v2_encrypted Container value (typically a table column)
+--! @param b eql_v2_encrypted Value to search for
+--! @return Boolean True if a contains all elements of b
+--!
+--! @example
+--! -- Create GIN index for efficient containment queries
+--! CREATE INDEX idx ON mytable USING GIN (eql_v2.jsonb_array(encrypted_col));
+--!
+--! -- Query using the helper function
+--! SELECT * FROM mytable WHERE eql_v2.jsonb_contains(encrypted_col, search_value);
+--!
+--! @see eql_v2.jsonb_array
+CREATE FUNCTION eql_v2.jsonb_contains(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief GIN-indexable JSONB containment check (encrypted, jsonb)
+--!
+--! Checks if encrypted value 'a' contains all JSONB elements from jsonb value 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! @param a eql_v2_encrypted Container value (typically a table column)
+--! @param b jsonb JSONB value to search for
+--! @return Boolean True if a contains all elements of b
+--!
+--! @see eql_v2.jsonb_array
+--! @see eql_v2.jsonb_contains(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.jsonb_contains(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief GIN-indexable JSONB containment check (jsonb, encrypted)
+--!
+--! Checks if jsonb value 'a' contains all JSONB elements from encrypted value 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! @param a jsonb Container JSONB value
+--! @param b eql_v2_encrypted Encrypted value to search for
+--! @return Boolean True if a contains all elements of b
+--!
+--! @see eql_v2.jsonb_array
+--! @see eql_v2.jsonb_contains(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.jsonb_contains(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief GIN-indexable JSONB "is contained by" check
+--!
+--! Checks if all JSONB elements from 'a' are contained in 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! @param a eql_v2_encrypted Value to check (typically a table column)
+--! @param b eql_v2_encrypted Container value
+--! @return Boolean True if all elements of a are contained in b
+--!
+--! @see eql_v2.jsonb_array
+--! @see eql_v2.jsonb_contains
+CREATE FUNCTION eql_v2.jsonb_contained_by(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief GIN-indexable JSONB "is contained by" check (encrypted, jsonb)
+--!
+--! Checks if all JSONB elements from encrypted value 'a' are contained in jsonb value 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! @param a eql_v2_encrypted Value to check (typically a table column)
+--! @param b jsonb Container JSONB value
+--! @return Boolean True if all elements of a are contained in b
+--!
+--! @see eql_v2.jsonb_array
+--! @see eql_v2.jsonb_contained_by(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.jsonb_contained_by(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief GIN-indexable JSONB "is contained by" check (jsonb, encrypted)
+--!
+--! Checks if all JSONB elements from jsonb value 'a' are contained in encrypted value 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! @param a jsonb Value to check
+--! @param b eql_v2_encrypted Container encrypted value
+--! @return Boolean True if all elements of a are contained in b
+--!
+--! @see eql_v2.jsonb_array
+--! @see eql_v2.jsonb_contained_by(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.jsonb_contained_by(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief Check if STE vector array contains a specific encrypted element
+--!
+--! Tests whether any element in the STE vector array 'a' contains the encrypted value 'b'.
+--! Matching requires both the selector and encrypted value to be equal.
+--! Used internally by ste_vec_contains(encrypted, encrypted) for array containment checks.
+--!
+--! @param eql_v2_encrypted[] STE vector array to search within
+--! @param eql_v2_encrypted Encrypted element to search for
+--! @return Boolean True if b is found in any element of a
+--!
+--! @note Compares both selector and encrypted value for match
+--!
+--! @see eql_v2.selector
+--! @see eql_v2.ste_vec_contains(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.ste_vec_contains(a public.eql_v2_encrypted[], b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ result boolean;
+ _a public.eql_v2_encrypted;
+ BEGIN
+
+ result := false;
+
+ FOR idx IN 1..array_length(a, 1) LOOP
+ _a := a[idx];
+ result := result OR (eql_v2.selector(_a) = eql_v2.selector(b) AND _a = b);
+ END LOOP;
+
+ RETURN result;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted value 'a' contains all elements of encrypted value 'b'
+--!
+--! Performs STE vector containment comparison between two encrypted values.
+--! Returns true if all elements in b's STE vector are found in a's STE vector.
+--! Used internally by the @> containment operator for searchable encryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value (container)
+--! @param b eql_v2_encrypted Second encrypted value (elements to find)
+--! @return Boolean True if all elements of b are contained in a
+--!
+--! @note Empty b is always contained in any a
+--! @note Each element of b must match both selector and value in a
+--!
+--! @see eql_v2.ste_vec
+--! @see eql_v2.ste_vec_contains(eql_v2_encrypted[], eql_v2_encrypted)
+--! @see eql_v2."@>"
+CREATE FUNCTION eql_v2.ste_vec_contains(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ result boolean;
+ sv_a public.eql_v2_encrypted[];
+ sv_b public.eql_v2_encrypted[];
+ _b public.eql_v2_encrypted;
+ BEGIN
+
+ -- jsonb arrays of ste_vec encrypted values
+ sv_a := eql_v2.ste_vec(a);
+ sv_b := eql_v2.ste_vec(b);
+
+ -- an empty b is always contained in a
+ IF array_length(sv_b, 1) IS NULL THEN
+ RETURN true;
+ END IF;
+
+ IF array_length(sv_a, 1) IS NULL THEN
+ RETURN false;
+ END IF;
+
+ result := true;
+
+ -- for each element of b check if it is in a
+ FOR idx IN 1..array_length(sv_b, 1) LOOP
+ _b := sv_b[idx];
+ result := result AND eql_v2.ste_vec_contains(sv_a, _b);
+ END LOOP;
+
+ RETURN result;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @file config/tables.sql
+--! @brief Encryption configuration storage table
+--!
+--! Defines the main table for storing EQL v2 encryption configurations.
+--! Each row represents a configuration specifying which tables/columns to encrypt
+--! and what index types to use. Configurations progress through lifecycle states.
+--!
+--! @see config/types.sql for state ENUM definition
+--! @see config/indexes.sql for state uniqueness constraints
+--! @see config/constraints.sql for data validation
+
+
+--! @brief Encryption configuration table
+--!
+--! Stores encryption configurations with their state and metadata.
+--! The 'data' JSONB column contains the full configuration structure including
+--! table/column mappings, index types, and casting rules.
+--!
+--! @note Only one configuration can be 'active', 'pending', or 'encrypting' at once
+--! @note 'id' is auto-generated identity column
+--! @note 'state' defaults to 'pending' for new configurations
+--! @note 'data' validated by CHECK constraint (see config/constraints.sql)
+CREATE TABLE IF NOT EXISTS public.eql_v2_configuration
+(
+ id bigint GENERATED ALWAYS AS IDENTITY,
+ state eql_v2_configuration_state NOT NULL DEFAULT 'pending',
+ data jsonb,
+ created_at timestamptz not null default current_timestamp,
+ PRIMARY KEY(id)
+);
+
+
+--! @brief Initialize default configuration structure
+--! @internal
+--!
+--! Creates a default configuration object if input is NULL. Used internally
+--! by public configuration functions to ensure consistent structure.
+--!
+--! @param config JSONB Existing configuration or NULL
+--! @return JSONB Configuration with default structure (version 1, empty tables)
+CREATE FUNCTION eql_v2.config_default(config jsonb)
+ RETURNS jsonb
+ IMMUTABLE PARALLEL SAFE
+AS $$
+ BEGIN
+ IF config IS NULL THEN
+ SELECT jsonb_build_object('v', 1, 'tables', jsonb_build_object()) INTO config;
+ END IF;
+ RETURN config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Add table to configuration if not present
+--! @internal
+--!
+--! Ensures the specified table exists in the configuration structure.
+--! Creates empty table entry if needed. Idempotent operation.
+--!
+--! @param table_name Text Name of table to add
+--! @param config JSONB Configuration object
+--! @return JSONB Updated configuration with table entry
+CREATE FUNCTION eql_v2.config_add_table(table_name text, config jsonb)
+ RETURNS jsonb
+ IMMUTABLE PARALLEL SAFE
+AS $$
+ DECLARE
+ tbl jsonb;
+ BEGIN
+ IF NOT config #> array['tables'] ? table_name THEN
+ SELECT jsonb_insert(config, array['tables', table_name], jsonb_build_object()) INTO config;
+ END IF;
+ RETURN config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Add column to table configuration if not present
+--! @internal
+--!
+--! Ensures the specified column exists in the table's configuration structure.
+--! Creates empty column entry with indexes object if needed. Idempotent operation.
+--!
+--! @param table_name Text Name of parent table
+--! @param column_name Text Name of column to add
+--! @param config JSONB Configuration object
+--! @return JSONB Updated configuration with column entry
+CREATE FUNCTION eql_v2.config_add_column(table_name text, column_name text, config jsonb)
+ RETURNS jsonb
+ IMMUTABLE PARALLEL SAFE
+AS $$
+ DECLARE
+ col jsonb;
+ BEGIN
+ IF NOT config #> array['tables', table_name] ? column_name THEN
+ SELECT jsonb_build_object('indexes', jsonb_build_object()) into col;
+ SELECT jsonb_set(config, array['tables', table_name, column_name], col) INTO config;
+ END IF;
+ RETURN config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Set cast type for column in configuration
+--! @internal
+--!
+--! Updates the cast_as field for a column, specifying the PostgreSQL type
+--! that decrypted values should be cast to.
+--!
+--! @param table_name Text Name of parent table
+--! @param column_name Text Name of column
+--! @param cast_as Text PostgreSQL type for casting (e.g., 'text', 'int', 'jsonb')
+--! @param config JSONB Configuration object
+--! @return JSONB Updated configuration with cast_as set
+CREATE FUNCTION eql_v2.config_add_cast(table_name text, column_name text, cast_as text, config jsonb)
+ RETURNS jsonb
+ IMMUTABLE PARALLEL SAFE
+AS $$
+ BEGIN
+ SELECT jsonb_set(config, array['tables', table_name, column_name, 'cast_as'], to_jsonb(cast_as)) INTO config;
+ RETURN config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Add search index to column configuration
+--! @internal
+--!
+--! Inserts a search index entry (unique, match, ore, ste_vec) with its options
+--! into the column's indexes object.
+--!
+--! @param table_name Text Name of parent table
+--! @param column_name Text Name of column
+--! @param index_name Text Type of index to add
+--! @param opts JSONB Index-specific options
+--! @param config JSONB Configuration object
+--! @return JSONB Updated configuration with index added
+CREATE FUNCTION eql_v2.config_add_index(table_name text, column_name text, index_name text, opts jsonb, config jsonb)
+ RETURNS jsonb
+ IMMUTABLE PARALLEL SAFE
+AS $$
+ BEGIN
+ SELECT jsonb_insert(config, array['tables', table_name, column_name, 'indexes', index_name], opts) INTO config;
+ RETURN config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Generate default options for match index
+--! @internal
+--!
+--! Returns default configuration for match (LIKE) indexes: k=6, bf=2048,
+--! ngram tokenizer with token_length=3, downcase filter, include_original=true.
+--!
+--! @return JSONB Default match index options
+CREATE FUNCTION eql_v2.config_match_default()
+ RETURNS jsonb
+LANGUAGE sql STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ SELECT jsonb_build_object(
+ 'k', 6,
+ 'bf', 2048,
+ 'include_original', true,
+ 'tokenizer', json_build_object('kind', 'ngram', 'token_length', 3),
+ 'token_filters', json_build_array(json_build_object('kind', 'downcase')));
+END;
+-- AUTOMATICALLY GENERATED FILE
+-- Source is version-template.sql
+
+DROP FUNCTION IF EXISTS eql_v2.version();
+
+--! @file version.sql
+--! @brief EQL version reporting
+--!
+--! This file is auto-generated from version.template during build.
+--! The version string placeholder is replaced with the actual release version.
+
+--! @brief Get EQL library version string
+--!
+--! Returns the version string for the installed EQL library.
+--! This value is set at build time from the project version.
+--!
+--! @return text Version string (e.g., "2.1.0" or "DEV" for development builds)
+--!
+--! @note Auto-generated during build from version.template
+--!
+--! @example
+--! -- Check installed EQL version
+--! SELECT eql_v2.version();
+--! -- Returns: '2.1.0'
+CREATE FUNCTION eql_v2.version()
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ SELECT 'eql-2.2.1';
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Compare two encrypted values using variable-width CLLW ORE index terms
+--!
+--! Performs a three-way comparison (returns -1/0/1) of encrypted values using
+--! their variable-width CLLW ORE ciphertext index terms. Used internally by range operators
+--! (<, <=, >, >=) for order-revealing comparisons without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value to compare
+--! @param b eql_v2_encrypted Second encrypted value to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note NULL values are sorted before non-NULL values
+--! @note Uses variable-width CLLW ORE cryptographic protocol for secure comparisons
+--!
+--! @see eql_v2.ore_cllw_var_8
+--! @see eql_v2.has_ore_cllw_var_8
+--! @see eql_v2.compare_ore_cllw_var_8_term
+--! @see eql_v2."<"
+--! @see eql_v2.">"
+CREATE FUNCTION eql_v2.compare_ore_cllw_var_8(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_term eql_v2.ore_cllw_var_8;
+ b_term eql_v2.ore_cllw_var_8;
+ BEGIN
+
+ -- PERFORM eql_v2.log('eql_v2.compare_ore_cllw_var_8');
+ -- PERFORM eql_v2.log('a', a::text);
+ -- PERFORM eql_v2.log('b', b::text);
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF eql_v2.has_ore_cllw_var_8(a) THEN
+ a_term := eql_v2.ore_cllw_var_8(a);
+ END IF;
+
+ IF eql_v2.has_ore_cllw_var_8(a) THEN
+ b_term := eql_v2.ore_cllw_var_8(b);
+ END IF;
+
+ IF a_term IS NULL AND b_term IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b_term IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ RETURN eql_v2.compare_ore_cllw_var_8_term(a_term, b_term);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Compare two encrypted values using CLLW ORE index terms
+--!
+--! Performs a three-way comparison (returns -1/0/1) of encrypted values using
+--! their CLLW ORE ciphertext index terms. Used internally by range operators
+--! (<, <=, >, >=) for order-revealing comparisons without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value to compare
+--! @param b eql_v2_encrypted Second encrypted value to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note NULL values are sorted before non-NULL values
+--! @note Uses CLLW ORE cryptographic protocol for secure comparisons
+--!
+--! @see eql_v2.ore_cllw_u64_8
+--! @see eql_v2.has_ore_cllw_u64_8
+--! @see eql_v2.compare_ore_cllw_term_bytes
+--! @see eql_v2."<"
+--! @see eql_v2.">"
+CREATE FUNCTION eql_v2.compare_ore_cllw_u64_8(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_term eql_v2.ore_cllw_u64_8;
+ b_term eql_v2.ore_cllw_u64_8;
+ BEGIN
+
+ -- PERFORM eql_v2.log('eql_v2.compare_ore_cllw_u64_8');
+ -- PERFORM eql_v2.log('a', a::text);
+ -- PERFORM eql_v2.log('b', b::text);
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF eql_v2.has_ore_cllw_u64_8(a) THEN
+ a_term := eql_v2.ore_cllw_u64_8(a);
+ END IF;
+
+ IF eql_v2.has_ore_cllw_u64_8(a) THEN
+ b_term := eql_v2.ore_cllw_u64_8(b);
+ END IF;
+
+ IF a_term IS NULL AND b_term IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b_term IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ RETURN eql_v2.compare_ore_cllw_term_bytes(a_term.bytes, b_term.bytes);
+ END;
+$$ LANGUAGE plpgsql;
+
+-- NOTE FILE IS DISABLED
+
+
+--! @brief Equality operator for ORE block types
+--! @internal
+--!
+--! Implements the = operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if ORE blocks are equal
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_eq(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = 0
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Not equal operator for ORE block types
+--! @internal
+--!
+--! Implements the <> operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if ORE blocks are not equal
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_neq(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) <> 0
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Less than operator for ORE block types
+--! @internal
+--!
+--! Implements the < operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if left operand is less than right operand
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_lt(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = -1
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Less than or equal operator for ORE block types
+--! @internal
+--!
+--! Implements the <= operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if left operand is less than or equal to right operand
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_lte(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) != 1
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Greater than operator for ORE block types
+--! @internal
+--!
+--! Implements the > operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if left operand is greater than right operand
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_gt(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = 1
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Greater than or equal operator for ORE block types
+--! @internal
+--!
+--! Implements the >= operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if left operand is greater than or equal to right operand
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_gte(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) != -1
+$$ LANGUAGE SQL;
+
+
+
+--! @brief = operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR = (
+ FUNCTION=eql_v2.ore_block_u64_8_256_eq,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ NEGATOR = <>,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+
+
+--! @brief <> operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR <> (
+ FUNCTION=eql_v2.ore_block_u64_8_256_neq,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ NEGATOR = =,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+
+--! @brief > operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR > (
+ FUNCTION=eql_v2.ore_block_u64_8_256_gt,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ COMMUTATOR = <,
+ NEGATOR = <=,
+ RESTRICT = scalargtsel,
+ JOIN = scalargtjoinsel
+);
+
+
+
+--! @brief < operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR < (
+ FUNCTION=eql_v2.ore_block_u64_8_256_lt,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ COMMUTATOR = >,
+ NEGATOR = >=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+
+
+--! @brief <= operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR <= (
+ FUNCTION=eql_v2.ore_block_u64_8_256_lte,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ COMMUTATOR = >=,
+ NEGATOR = >,
+ RESTRICT = scalarlesel,
+ JOIN = scalarlejoinsel
+);
+
+
+
+--! @brief >= operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR >= (
+ FUNCTION=eql_v2.ore_block_u64_8_256_gte,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ COMMUTATOR = <=,
+ NEGATOR = <,
+ RESTRICT = scalarlesel,
+ JOIN = scalarlejoinsel
+);
+-- NOTE FILE IS DISABLED
+
+
+
+--! @brief B-tree operator family for ORE block types
+--!
+--! Defines the operator family for creating B-tree indexes on ORE block types.
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.ore_block_u64_8_256_operator_class
+CREATE OPERATOR FAMILY eql_v2.ore_block_u64_8_256_operator_family USING btree;
+
+--! @brief B-tree operator class for ORE block encrypted values
+--!
+--! Defines the operator class required for creating B-tree indexes on columns
+--! using the ore_block_u64_8_256 type. Enables range queries and ORDER BY on
+--! ORE-encrypted data without decryption.
+--!
+--! Supports operators: <, <=, =, >=, >
+--! Uses comparison function: compare_ore_block_u64_8_256_terms
+--!
+--! @note FILE IS DISABLED - Not included in build
+--!
+--! @example
+--! -- Would be used like (if enabled):
+--! CREATE INDEX ON events USING btree (
+--! (encrypted_timestamp::jsonb->'ob')::eql_v2.ore_block_u64_8_256
+--! );
+--!
+--! @see CREATE OPERATOR CLASS in PostgreSQL documentation
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE OPERATOR CLASS eql_v2.ore_block_u64_8_256_operator_class DEFAULT FOR TYPE eql_v2.ore_block_u64_8_256 USING btree FAMILY eql_v2.ore_block_u64_8_256_operator_family AS
+ OPERATOR 1 <,
+ OPERATOR 2 <=,
+ OPERATOR 3 =,
+ OPERATOR 4 >=,
+ OPERATOR 5 >,
+ FUNCTION 1 eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256);
+
+
+--! @brief Compare two encrypted values using ORE block index terms
+--!
+--! Performs a three-way comparison (returns -1/0/1) of encrypted values using
+--! their ORE block index terms. Used internally by range operators (<, <=, >, >=)
+--! for order-revealing comparisons without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value to compare
+--! @param b eql_v2_encrypted Second encrypted value to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note NULL values are sorted before non-NULL values
+--! @note Uses ORE cryptographic protocol for secure comparisons
+--!
+--! @see eql_v2.ore_block_u64_8_256
+--! @see eql_v2.has_ore_block_u64_8_256
+--! @see eql_v2."<"
+--! @see eql_v2.">"
+CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_term eql_v2.ore_block_u64_8_256;
+ b_term eql_v2.ore_block_u64_8_256;
+ BEGIN
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF eql_v2.has_ore_block_u64_8_256(a) THEN
+ a_term := eql_v2.ore_block_u64_8_256(a);
+ END IF;
+
+ IF eql_v2.has_ore_block_u64_8_256(a) THEN
+ b_term := eql_v2.ore_block_u64_8_256(b);
+ END IF;
+
+ IF a_term IS NULL AND b_term IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b_term IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ RETURN eql_v2.compare_ore_block_u64_8_256_terms(a_term.terms, b_term.terms);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Cast text to ORE block term
+--! @internal
+--!
+--! Converts text to bytea and wraps in ore_block_u64_8_256_term type.
+--! Used internally for ORE block extraction and manipulation.
+--!
+--! @param t Text Text value to convert
+--! @return eql_v2.ore_block_u64_8_256_term ORE term containing bytea representation
+--!
+--! @see eql_v2.ore_block_u64_8_256_term
+CREATE FUNCTION eql_v2.text_to_ore_block_u64_8_256_term(t text)
+ RETURNS eql_v2.ore_block_u64_8_256_term
+ LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ RETURN t::bytea;
+END;
+
+--! @brief Implicit cast from text to ORE block term
+--!
+--! Defines an implicit cast allowing automatic conversion of text values
+--! to ore_block_u64_8_256_term type for ORE operations.
+--!
+--! @see eql_v2.text_to_ore_block_u64_8_256_term
+CREATE CAST (text AS eql_v2.ore_block_u64_8_256_term)
+ WITH FUNCTION eql_v2.text_to_ore_block_u64_8_256_term(text) AS IMPLICIT;
+
+--! @brief Pattern matching helper using bloom filters
+--! @internal
+--!
+--! Internal helper for LIKE-style pattern matching on encrypted values.
+--! Uses bloom filter index terms to test substring containment without decryption.
+--! Requires 'match' index configuration on the column.
+--!
+--! @param a eql_v2_encrypted Haystack (value to search in)
+--! @param b eql_v2_encrypted Needle (pattern to search for)
+--! @return Boolean True if bloom filter of a contains bloom filter of b
+--!
+--! @see eql_v2."~~"
+--! @see eql_v2.bloom_filter
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.like(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean AS $$
+ SELECT eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b);
+$$ LANGUAGE SQL;
+
+--! @brief Case-insensitive pattern matching helper
+--! @internal
+--!
+--! Internal helper for ILIKE-style case-insensitive pattern matching.
+--! Case sensitivity is controlled by index configuration (token_filters with downcase).
+--! This function has same implementation as like() - actual case handling is in index terms.
+--!
+--! @param a eql_v2_encrypted Haystack (value to search in)
+--! @param b eql_v2_encrypted Needle (pattern to search for)
+--! @return Boolean True if bloom filter of a contains bloom filter of b
+--!
+--! @note Case sensitivity depends on match index token_filters configuration
+--! @see eql_v2."~~"
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.ilike(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean AS $$
+ SELECT eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b);
+$$ LANGUAGE SQL;
+
+--! @brief LIKE operator for encrypted values (pattern matching)
+--!
+--! Implements the ~~ (LIKE) operator for substring/pattern matching on encrypted
+--! text using bloom filter index terms. Enables WHERE col LIKE '%pattern%' queries
+--! without decryption. Requires 'match' index configuration on the column.
+--!
+--! Pattern matching uses n-gram tokenization configured in match index. Token length
+--! and filters affect matching behavior.
+--!
+--! @param a eql_v2_encrypted Haystack (encrypted text to search in)
+--! @param b eql_v2_encrypted Needle (encrypted pattern to search for)
+--! @return Boolean True if a contains b as substring
+--!
+--! @example
+--! -- Search for substring in encrypted email
+--! SELECT * FROM users
+--! WHERE encrypted_email ~~ '%@example.com%'::text::eql_v2_encrypted;
+--!
+--! -- Pattern matching on encrypted names
+--! SELECT * FROM customers
+--! WHERE encrypted_name ~~ 'John%'::text::eql_v2_encrypted;
+--!
+--! @brief SQL LIKE operator (~~ operator) for encrypted text pattern matching
+--!
+--! @param a eql_v2_encrypted Left operand (encrypted value)
+--! @param b eql_v2_encrypted Right operand (encrypted pattern)
+--! @return boolean True if pattern matches
+--!
+--! @note Requires match index: eql_v2.add_search_config(table, column, 'match')
+--! @see eql_v2.like
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2."~~"(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.like(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR ~~(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief Case-insensitive LIKE operator (~~*)
+--!
+--! Implements ~~* (ILIKE) operator for case-insensitive pattern matching.
+--! Case handling depends on match index token_filters configuration (use downcase filter).
+--! Same implementation as ~~, with case sensitivity controlled by index configuration.
+--!
+--! @param a eql_v2_encrypted Haystack
+--! @param b eql_v2_encrypted Needle
+--! @return Boolean True if a contains b (case-insensitive)
+--!
+--! @note Configure match index with downcase token filter for case-insensitivity
+--! @see eql_v2."~~"
+CREATE OPERATOR ~~*(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief LIKE operator for encrypted value and JSONB
+--!
+--! Overload of ~~ operator accepting JSONB on the right side. Automatically
+--! casts JSONB to eql_v2_encrypted for bloom filter pattern matching.
+--!
+--! @param eql_v2_encrypted Haystack (encrypted value)
+--! @param b JSONB Needle (will be cast to eql_v2_encrypted)
+--! @return Boolean True if a contains b as substring
+--!
+--! @example
+--! SELECT * FROM users WHERE encrypted_email ~~ '%gmail%'::jsonb;
+--!
+--! @see eql_v2."~~"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."~~"(a eql_v2_encrypted, b jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.like(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR ~~(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+CREATE OPERATOR ~~*(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief LIKE operator for JSONB and encrypted value
+--!
+--! Overload of ~~ operator accepting JSONB on the left side. Automatically
+--! casts JSONB to eql_v2_encrypted for bloom filter pattern matching.
+--!
+--! @param a JSONB Haystack (will be cast to eql_v2_encrypted)
+--! @param eql_v2_encrypted Needle (encrypted pattern)
+--! @return Boolean True if a contains b as substring
+--!
+--! @example
+--! SELECT * FROM users WHERE 'test@example.com'::jsonb ~~ encrypted_pattern;
+--!
+--! @see eql_v2."~~"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."~~"(a jsonb, b eql_v2_encrypted)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.like(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR ~~(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=jsonb,
+ RIGHTARG=eql_v2_encrypted,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+CREATE OPERATOR ~~*(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=jsonb,
+ RIGHTARG=eql_v2_encrypted,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+
+-- -----------------------------------------------------------------------------
+
+--! @brief Extract ORE index term for ordering encrypted values
+--!
+--! Helper function that extracts the ore_block_u64_8_256 index term from an encrypted value
+--! for use in ORDER BY clauses when comparison operators are not appropriate or available.
+--!
+--! @param eql_v2_encrypted Encrypted value to extract order term from
+--! @return eql_v2.ore_block_u64_8_256 ORE index term for ordering
+--!
+--! @example
+--! -- Order encrypted values without using comparison operators
+--! SELECT * FROM users ORDER BY eql_v2.order_by(encrypted_age);
+--!
+--! @note Requires 'ore' index configuration on the column
+--! @see eql_v2.ore_block_u64_8_256
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.order_by(a eql_v2_encrypted)
+ RETURNS eql_v2.ore_block_u64_8_256
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.ore_block_u64_8_256(a);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+
+--! @brief PostgreSQL operator class definitions for encrypted value indexing
+--!
+--! Defines the operator family and operator class required for btree indexing
+--! of encrypted values. This enables PostgreSQL to use encrypted columns in:
+--! - CREATE INDEX statements
+--! - ORDER BY clauses
+--! - Range queries
+--! - Primary key constraints
+--!
+--! The operator class maps the five comparison operators (<, <=, =, >=, >)
+--! to the eql_v2.compare() support function for btree index operations.
+--!
+--! @note This is the default operator class for eql_v2_encrypted type
+--! @see eql_v2.compare
+--! @see PostgreSQL documentation on operator classes
+
+--------------------
+
+CREATE OPERATOR FAMILY eql_v2.encrypted_operator_family USING btree;
+
+CREATE OPERATOR CLASS eql_v2.encrypted_operator_class DEFAULT FOR TYPE eql_v2_encrypted USING btree FAMILY eql_v2.encrypted_operator_family AS
+ OPERATOR 1 <,
+ OPERATOR 2 <=,
+ OPERATOR 3 =,
+ OPERATOR 4 >=,
+ OPERATOR 5 >,
+ FUNCTION 1 eql_v2.compare(a eql_v2_encrypted, b eql_v2_encrypted);
+
+
+--------------------
+
+-- CREATE OPERATOR FAMILY eql_v2.encrypted_operator_ordered USING btree;
+
+-- CREATE OPERATOR CLASS eql_v2.encrypted_operator_ordered FOR TYPE eql_v2_encrypted USING btree FAMILY eql_v2.encrypted_operator_ordered AS
+-- OPERATOR 1 <,
+-- OPERATOR 2 <=,
+-- OPERATOR 3 =,
+-- OPERATOR 4 >=,
+-- OPERATOR 5 >,
+-- FUNCTION 1 eql_v2.compare_ore_block_u64_8_256(a eql_v2_encrypted, b eql_v2_encrypted);
+
+--------------------
+
+-- CREATE OPERATOR FAMILY eql_v2.encrypted_hmac_256_operator USING btree;
+
+-- CREATE OPERATOR CLASS eql_v2.encrypted_hmac_256_operator FOR TYPE eql_v2_encrypted USING btree FAMILY eql_v2.encrypted_hmac_256_operator AS
+-- OPERATOR 1 <,
+-- OPERATOR 2 <=,
+-- OPERATOR 3 =,
+-- OPERATOR 4 >=,
+-- OPERATOR 5 >,
+-- FUNCTION 1 eql_v2.compare_hmac(a eql_v2_encrypted, b eql_v2_encrypted);
+
+
+--! @brief Contains operator for encrypted values (@>)
+--!
+--! Implements the @> (contains) operator for testing if left encrypted value
+--! contains the right encrypted value. Uses ste_vec (secure tree encoding vector)
+--! index terms for containment testing without decryption.
+--!
+--! Primarily used for encrypted array or set containment queries.
+--!
+--! @param a eql_v2_encrypted Left operand (container)
+--! @param b eql_v2_encrypted Right operand (contained value)
+--! @return Boolean True if a contains b
+--!
+--! @example
+--! -- Check if encrypted array contains value
+--! SELECT * FROM documents
+--! WHERE encrypted_tags @> '["security"]'::jsonb::eql_v2_encrypted;
+--!
+--! @note Requires ste_vec index configuration
+--! @see eql_v2.ste_vec_contains
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2."@>"(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean AS $$
+ SELECT eql_v2.ste_vec_contains(a, b)
+$$ LANGUAGE SQL;
+
+CREATE OPERATOR @>(
+ FUNCTION=eql_v2."@>",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted
+);
+
+--! @brief Contained-by operator for encrypted values (<@)
+--!
+--! Implements the <@ (contained-by) operator for testing if left encrypted value
+--! is contained by the right encrypted value. Uses ste_vec (secure tree encoding vector)
+--! index terms for containment testing without decryption. Reverse of @> operator.
+--!
+--! Primarily used for encrypted array or set containment queries.
+--!
+--! @param a eql_v2_encrypted Left operand (contained value)
+--! @param b eql_v2_encrypted Right operand (container)
+--! @return Boolean True if a is contained by b
+--!
+--! @example
+--! -- Check if value is contained in encrypted array
+--! SELECT * FROM documents
+--! WHERE '["security"]'::jsonb::eql_v2_encrypted <@ encrypted_tags;
+--!
+--! @note Requires ste_vec index configuration
+--! @see eql_v2.ste_vec_contains
+--! @see eql_v2.\\"@>\\"
+--! @see eql_v2.add_search_config
+
+CREATE FUNCTION eql_v2."<@"(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean AS $$
+ -- Contains with reversed arguments
+ SELECT eql_v2.ste_vec_contains(b, a)
+$$ LANGUAGE SQL;
+
+CREATE OPERATOR <@(
+ FUNCTION=eql_v2."<@",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted
+);
+
+--! @brief Not-equal comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for inequality testing.
+--! Returns true if encrypted values are not equal via encrypted index comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if values are not equal (compare result <> 0)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2."<>"
+CREATE FUNCTION eql_v2.neq(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) <> 0;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Not-equal operator for encrypted values
+--!
+--! Implements the <> (not equal) operator for comparing encrypted values using their
+--! encrypted index terms. Enables WHERE clause inequality comparisons without decryption.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if encrypted values are not equal
+--!
+--! @example
+--! -- Find records with non-matching values
+--! SELECT * FROM users
+--! WHERE encrypted_email <> 'admin@example.com'::text::eql_v2_encrypted;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2."="
+CREATE FUNCTION eql_v2."<>"(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.neq(a, b );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR <> (
+ FUNCTION=eql_v2."<>",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ NEGATOR = =,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief <> operator for encrypted value and JSONB
+--! @see eql_v2."<>"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<>"(a eql_v2_encrypted, b jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.neq(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <> (
+ FUNCTION=eql_v2."<>",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ NEGATOR = =,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief <> operator for JSONB and encrypted value
+--!
+--! @param jsonb Plain JSONB value
+--! @param eql_v2_encrypted Encrypted value
+--! @return boolean True if values are not equal
+--!
+--! @see eql_v2."<>"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<>"(a jsonb, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.neq(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <> (
+ FUNCTION=eql_v2."<>",
+ LEFTARG=jsonb,
+ RIGHTARG=eql_v2_encrypted,
+ NEGATOR = =,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+
+
+
+
+--! @brief JSONB field accessor operator alias (->>)
+--!
+--! Implements the ->> operator as an alias of -> for encrypted JSONB data. This mirrors
+--! PostgreSQL semantics where ->> returns text via implicit casts. The underlying
+--! implementation delegates to eql_v2."->" and allows PostgreSQL to coerce the result.
+--!
+--! Provides two overloads:
+--! - (eql_v2_encrypted, text) - Field name selector
+--! - (eql_v2_encrypted, eql_v2_encrypted) - Encrypted selector
+--!
+--! @see eql_v2."->"
+--! @see eql_v2.selector
+
+--! @brief ->> operator with text selector
+--! @param eql_v2_encrypted Encrypted JSONB data
+--! @param text Field name to extract
+--! @return text Encrypted value at selector, implicitly cast from eql_v2_encrypted
+--! @example
+--! SELECT encrypted_json ->> 'field_name' FROM table;
+CREATE FUNCTION eql_v2."->>"(e eql_v2_encrypted, selector text)
+ RETURNS text
+IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ found eql_v2_encrypted;
+ BEGIN
+ -- found = eql_v2."->"(e, selector);
+ -- RETURN eql_v2.ciphertext(found);
+ RETURN eql_v2."->"(e, selector);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR ->> (
+ FUNCTION=eql_v2."->>",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=text
+);
+
+
+
+---------------------------------------------------
+
+--! @brief ->> operator with encrypted selector
+--! @param e eql_v2_encrypted Encrypted JSONB data
+--! @param selector eql_v2_encrypted Encrypted field selector
+--! @return text Encrypted value at selector, implicitly cast from eql_v2_encrypted
+--! @see eql_v2."->>"(eql_v2_encrypted, text)
+CREATE FUNCTION eql_v2."->>"(e eql_v2_encrypted, selector eql_v2_encrypted)
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2."->>"(e, eql_v2.selector(selector));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR ->> (
+ FUNCTION=eql_v2."->>",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted
+);
+
+--! @brief JSONB field accessor operator for encrypted values (->)
+--!
+--! Implements the -> operator to access fields/elements from encrypted JSONB data.
+--! Returns encrypted value matching the provided selector without decryption.
+--!
+--! Encrypted JSON is represented as an array of eql_v2_encrypted values in the ste_vec format.
+--! Each element has a selector, ciphertext, and index terms:
+--! {"sv": [{"c": "", "s": "", "b3": ""}]}
+--!
+--! Provides three overloads:
+--! - (eql_v2_encrypted, text) - Field name selector
+--! - (eql_v2_encrypted, eql_v2_encrypted) - Encrypted selector
+--! - (eql_v2_encrypted, integer) - Array index selector (0-based)
+--!
+--! @note Operator resolution: Assignment casts are considered (PostgreSQL standard behavior).
+--! To use text selector, parameter may need explicit cast to text.
+--!
+--! @see eql_v2.ste_vec
+--! @see eql_v2.selector
+--! @see eql_v2."->>"
+
+--! @brief -> operator with text selector
+--! @param eql_v2_encrypted Encrypted JSONB data
+--! @param text Field name to extract
+--! @return eql_v2_encrypted Encrypted value at selector
+--! @example
+--! SELECT encrypted_json -> 'field_name' FROM table;
+CREATE FUNCTION eql_v2."->"(e eql_v2_encrypted, selector text)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ meta jsonb;
+ sv eql_v2_encrypted[];
+ found jsonb;
+ BEGIN
+
+ IF e IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ -- Column identifier and version
+ meta := eql_v2.meta_data(e);
+
+ sv := eql_v2.ste_vec(e);
+
+ FOR idx IN 1..array_length(sv, 1) LOOP
+ if eql_v2.selector(sv[idx]) = selector THEN
+ found := sv[idx];
+ END IF;
+ END LOOP;
+
+ RETURN (meta || found)::eql_v2_encrypted;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR ->(
+ FUNCTION=eql_v2."->",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=text
+);
+
+---------------------------------------------------
+
+--! @brief -> operator with encrypted selector
+--! @param e eql_v2_encrypted Encrypted JSONB data
+--! @param selector eql_v2_encrypted Encrypted field selector
+--! @return eql_v2_encrypted Encrypted value at selector
+--! @see eql_v2."->"(eql_v2_encrypted, text)
+CREATE FUNCTION eql_v2."->"(e eql_v2_encrypted, selector eql_v2_encrypted)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2."->"(e, eql_v2.selector(selector));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+CREATE OPERATOR ->(
+ FUNCTION=eql_v2."->",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted
+);
+
+
+---------------------------------------------------
+
+--! @brief -> operator with integer array index
+--! @param eql_v2_encrypted Encrypted array data
+--! @param integer Array index (0-based, JSONB convention)
+--! @return eql_v2_encrypted Encrypted value at array index
+--! @note Array index is 0-based (JSONB standard) despite PostgreSQL arrays being 1-based
+--! @example
+--! SELECT encrypted_array -> 0 FROM table;
+--! @see eql_v2.is_ste_vec_array
+CREATE FUNCTION eql_v2."->"(e eql_v2_encrypted, selector integer)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv eql_v2_encrypted[];
+ found eql_v2_encrypted;
+ BEGIN
+ IF NOT eql_v2.is_ste_vec_array(e) THEN
+ RETURN NULL;
+ END IF;
+
+ sv := eql_v2.ste_vec(e);
+
+ -- PostgreSQL arrays are 1-based
+ -- JSONB arrays are 0-based and so the selector is 0-based
+ FOR idx IN 1..array_length(sv, 1) LOOP
+ if (idx-1) = selector THEN
+ found := sv[idx];
+ END IF;
+ END LOOP;
+
+ RETURN found;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+
+
+CREATE OPERATOR ->(
+ FUNCTION=eql_v2."->",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=integer
+);
+
+
+--! @file jsonb/functions.sql
+--! @brief JSONB path query and array manipulation functions for encrypted data
+--!
+--! These functions provide PostgreSQL-compatible operations on encrypted JSONB values
+--! using Structured Transparent Encryption (STE). They support:
+--! - Path-based queries to extract nested encrypted values
+--! - Existence checks for encrypted fields
+--! - Array operations (length, elements extraction)
+--!
+--! @note STE stores encrypted JSONB as a vector of encrypted elements ('sv') with selectors
+--! @note Functions suppress errors for missing fields, type mismatches (similar to PostgreSQL jsonpath)
+
+
+--! @brief Query encrypted JSONB for elements matching selector
+--!
+--! Searches the Structured Transparent Encryption (STE) vector for elements matching
+--! the given selector path. Returns all matching encrypted elements. If multiple
+--! matches form an array, they are wrapped with array metadata.
+--!
+--! @param jsonb Encrypted JSONB payload containing STE vector ('sv')
+--! @param text Path selector to match against encrypted elements
+--! @return SETOF eql_v2_encrypted Matching encrypted elements (may return multiple rows)
+--!
+--! @note Returns empty set if selector is not found (does not throw exception)
+--! @note Array elements use same selector; multiple matches wrapped with 'a' flag
+--! @note Returns a set containing NULL if val is NULL; returns empty set if no matches found
+--! @see eql_v2.jsonb_path_query_first
+--! @see eql_v2.jsonb_path_exists
+CREATE FUNCTION eql_v2.jsonb_path_query(val jsonb, selector text)
+ RETURNS SETOF eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv eql_v2_encrypted[];
+ found jsonb[];
+ e jsonb;
+ meta jsonb;
+ ary boolean;
+ BEGIN
+
+ IF val IS NULL THEN
+ RETURN NEXT NULL;
+ END IF;
+
+ -- Column identifier and version
+ meta := eql_v2.meta_data(val);
+
+ sv := eql_v2.ste_vec(val);
+
+ FOR idx IN 1..array_length(sv, 1) LOOP
+ e := sv[idx];
+
+ IF eql_v2.selector(e) = selector THEN
+ found := array_append(found, e);
+ IF eql_v2.is_ste_vec_array(e) THEN
+ ary := true;
+ END IF;
+
+ END IF;
+ END LOOP;
+
+ IF found IS NOT NULL THEN
+
+ IF ary THEN
+ -- Wrap found array elements as eql_v2_encrypted
+
+ RETURN NEXT (meta || jsonb_build_object(
+ 'sv', found,
+ 'a', 1
+ ))::eql_v2_encrypted;
+
+ ELSE
+ RETURN NEXT (meta || found[1])::eql_v2_encrypted;
+ END IF;
+
+ END IF;
+
+ RETURN;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Query encrypted JSONB with encrypted selector
+--!
+--! Overload that accepts encrypted selector and extracts its plaintext value
+--! before delegating to main jsonb_path_query implementation.
+--!
+--! @param val eql_v2_encrypted Encrypted JSONB value to query
+--! @param selector eql_v2_encrypted Encrypted selector to match against
+--! @return SETOF eql_v2_encrypted Matching encrypted elements
+--!
+--! @see eql_v2.jsonb_path_query(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_query(val eql_v2_encrypted, selector eql_v2_encrypted)
+ RETURNS SETOF eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN QUERY
+ SELECT * FROM eql_v2.jsonb_path_query(val.data, eql_v2.selector(selector));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Query encrypted JSONB with text selector
+--!
+--! Overload that accepts encrypted JSONB value and text selector,
+--! extracting the JSONB payload before querying.
+--!
+--! @param eql_v2_encrypted Encrypted JSONB value to query
+--! @param text Path selector to match against
+--! @return SETOF eql_v2_encrypted Matching encrypted elements
+--!
+--! @example
+--! -- Query encrypted JSONB for specific field
+--! SELECT * FROM eql_v2.jsonb_path_query(encrypted_document, '$.address.city');
+--!
+--! @see eql_v2.jsonb_path_query(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_query(val eql_v2_encrypted, selector text)
+ RETURNS SETOF eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN QUERY
+ SELECT * FROM eql_v2.jsonb_path_query(val.data, selector);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+------------------------------------------------------------------------------------
+
+
+--! @brief Check if selector path exists in encrypted JSONB
+--!
+--! Tests whether any encrypted elements match the given selector path.
+--! More efficient than jsonb_path_query when only existence check is needed.
+--!
+--! @param jsonb Encrypted JSONB payload to check
+--! @param text Path selector to test
+--! @return boolean True if matching element exists, false otherwise
+--!
+--! @see eql_v2.jsonb_path_query(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_exists(val jsonb, selector text)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN EXISTS (
+ SELECT eql_v2.jsonb_path_query(val, selector)
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check existence with encrypted selector
+--!
+--! Overload that accepts encrypted selector and extracts its value
+--! before checking existence.
+--!
+--! @param val eql_v2_encrypted Encrypted JSONB value to check
+--! @param selector eql_v2_encrypted Encrypted selector to test
+--! @return boolean True if path exists
+--!
+--! @see eql_v2.jsonb_path_exists(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_exists(val eql_v2_encrypted, selector eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN EXISTS (
+ SELECT eql_v2.jsonb_path_query(val, eql_v2.selector(selector))
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check existence with text selector
+--!
+--! Overload that accepts encrypted JSONB value and text selector.
+--!
+--! @param eql_v2_encrypted Encrypted JSONB value to check
+--! @param text Path selector to test
+--! @return boolean True if path exists
+--!
+--! @example
+--! -- Check if encrypted document has address field
+--! SELECT eql_v2.jsonb_path_exists(encrypted_document, '$.address');
+--!
+--! @see eql_v2.jsonb_path_exists(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_exists(val eql_v2_encrypted, selector text)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN EXISTS (
+ SELECT eql_v2.jsonb_path_query(val, selector)
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+------------------------------------------------------------------------------------
+
+
+--! @brief Get first element matching selector
+--!
+--! Returns only the first encrypted element matching the selector path,
+--! or NULL if no match found. More efficient than jsonb_path_query when
+--! only one result is needed.
+--!
+--! @param jsonb Encrypted JSONB payload to query
+--! @param text Path selector to match
+--! @return eql_v2_encrypted First matching element or NULL
+--!
+--! @note Uses LIMIT 1 internally for efficiency
+--! @see eql_v2.jsonb_path_query(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_query_first(val jsonb, selector text)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (
+ SELECT e
+ FROM eql_v2.jsonb_path_query(val, selector) AS e
+ LIMIT 1
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Get first element with encrypted selector
+--!
+--! Overload that accepts encrypted selector and extracts its value
+--! before querying for first match.
+--!
+--! @param val eql_v2_encrypted Encrypted JSONB value to query
+--! @param selector eql_v2_encrypted Encrypted selector to match
+--! @return eql_v2_encrypted First matching element or NULL
+--!
+--! @see eql_v2.jsonb_path_query_first(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_query_first(val eql_v2_encrypted, selector eql_v2_encrypted)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (
+ SELECT e
+ FROM eql_v2.jsonb_path_query(val.data, eql_v2.selector(selector)) AS e
+ LIMIT 1
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Get first element with text selector
+--!
+--! Overload that accepts encrypted JSONB value and text selector.
+--!
+--! @param eql_v2_encrypted Encrypted JSONB value to query
+--! @param text Path selector to match
+--! @return eql_v2_encrypted First matching element or NULL
+--!
+--! @example
+--! -- Get first matching address from encrypted document
+--! SELECT eql_v2.jsonb_path_query_first(encrypted_document, '$.addresses[*]');
+--!
+--! @see eql_v2.jsonb_path_query_first(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_query_first(val eql_v2_encrypted, selector text)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (
+ SELECT e
+ FROM eql_v2.jsonb_path_query(val.data, selector) AS e
+ LIMIT 1
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+------------------------------------------------------------------------------------
+
+
+--! @brief Get length of encrypted JSONB array
+--!
+--! Returns the number of elements in an encrypted JSONB array by counting
+--! elements in the STE vector ('sv'). The encrypted value must have the
+--! array flag ('a') set to true.
+--!
+--! @param jsonb Encrypted JSONB payload representing an array
+--! @return integer Number of elements in the array
+--! @throws Exception 'cannot get array length of a non-array' if 'a' flag is missing or not true
+--!
+--! @note Array flag 'a' must be present and set to true value
+--! @see eql_v2.jsonb_array_elements
+CREATE FUNCTION eql_v2.jsonb_array_length(val jsonb)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv eql_v2_encrypted[];
+ found eql_v2_encrypted[];
+ BEGIN
+
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF eql_v2.is_ste_vec_array(val) THEN
+ sv := eql_v2.ste_vec(val);
+ RETURN array_length(sv, 1);
+ END IF;
+
+ RAISE 'cannot get array length of a non-array';
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Get array length from encrypted type
+--!
+--! Overload that accepts encrypted composite type and extracts the
+--! JSONB payload before computing array length.
+--!
+--! @param eql_v2_encrypted Encrypted array value
+--! @return integer Number of elements in the array
+--! @throws Exception if value is not an array
+--!
+--! @example
+--! -- Get length of encrypted array
+--! SELECT eql_v2.jsonb_array_length(encrypted_tags);
+--!
+--! @see eql_v2.jsonb_array_length(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array_length(val eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (
+ SELECT eql_v2.jsonb_array_length(val.data)
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+
+--! @brief Extract elements from encrypted JSONB array
+--!
+--! Returns each element of an encrypted JSONB array as a separate row.
+--! Each element is returned as an eql_v2_encrypted value with metadata
+--! preserved from the parent array.
+--!
+--! @param jsonb Encrypted JSONB payload representing an array
+--! @return SETOF eql_v2_encrypted One row per array element
+--! @throws Exception if value is not an array (missing 'a' flag)
+--!
+--! @note Each element inherits metadata (version, ident) from parent
+--! @see eql_v2.jsonb_array_length
+--! @see eql_v2.jsonb_array_elements_text
+CREATE FUNCTION eql_v2.jsonb_array_elements(val jsonb)
+ RETURNS SETOF eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv eql_v2_encrypted[];
+ meta jsonb;
+ item jsonb;
+ BEGIN
+
+ IF NOT eql_v2.is_ste_vec_array(val) THEN
+ RAISE 'cannot extract elements from non-array';
+ END IF;
+
+ -- Column identifier and version
+ meta := eql_v2.meta_data(val);
+
+ sv := eql_v2.ste_vec(val);
+
+ FOR idx IN 1..array_length(sv, 1) LOOP
+ item = sv[idx];
+ RETURN NEXT (meta || item)::eql_v2_encrypted;
+ END LOOP;
+
+ RETURN;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract elements from encrypted array type
+--!
+--! Overload that accepts encrypted composite type and extracts each
+--! array element as a separate row.
+--!
+--! @param eql_v2_encrypted Encrypted array value
+--! @return SETOF eql_v2_encrypted One row per array element
+--! @throws Exception if value is not an array
+--!
+--! @example
+--! -- Expand encrypted array into rows
+--! SELECT * FROM eql_v2.jsonb_array_elements(encrypted_tags);
+--!
+--! @see eql_v2.jsonb_array_elements(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array_elements(val eql_v2_encrypted)
+ RETURNS SETOF eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN QUERY
+ SELECT * FROM eql_v2.jsonb_array_elements(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Extract encrypted array elements as ciphertext
+--!
+--! Returns each element of an encrypted JSONB array as its raw ciphertext
+--! value (text representation). Unlike jsonb_array_elements, this returns
+--! only the ciphertext 'c' field without metadata.
+--!
+--! @param jsonb Encrypted JSONB payload representing an array
+--! @return SETOF text One ciphertext string per array element
+--! @throws Exception if value is not an array (missing 'a' flag)
+--!
+--! @note Returns ciphertext only, not full encrypted structure
+--! @see eql_v2.jsonb_array_elements
+CREATE FUNCTION eql_v2.jsonb_array_elements_text(val jsonb)
+ RETURNS SETOF text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv eql_v2_encrypted[];
+ found eql_v2_encrypted[];
+ BEGIN
+ IF NOT eql_v2.is_ste_vec_array(val) THEN
+ RAISE 'cannot extract elements from non-array';
+ END IF;
+
+ sv := eql_v2.ste_vec(val);
+
+ FOR idx IN 1..array_length(sv, 1) LOOP
+ RETURN NEXT eql_v2.ciphertext(sv[idx]);
+ END LOOP;
+
+ RETURN;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract array elements as ciphertext from encrypted type
+--!
+--! Overload that accepts encrypted composite type and extracts each
+--! array element's ciphertext as text.
+--!
+--! @param eql_v2_encrypted Encrypted array value
+--! @return SETOF text One ciphertext string per array element
+--! @throws Exception if value is not an array
+--!
+--! @example
+--! -- Get ciphertext of each array element
+--! SELECT * FROM eql_v2.jsonb_array_elements_text(encrypted_tags);
+--!
+--! @see eql_v2.jsonb_array_elements_text(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array_elements_text(val eql_v2_encrypted)
+ RETURNS SETOF text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN QUERY
+ SELECT * FROM eql_v2.jsonb_array_elements_text(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Compare two encrypted values using HMAC-SHA256 index terms
+--!
+--! Performs a three-way comparison (returns -1/0/1) of encrypted values using
+--! their HMAC-SHA256 hash index terms. Used internally by the equality operator (=)
+--! for exact-match queries without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value to compare
+--! @param b eql_v2_encrypted Second encrypted value to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note NULL values are sorted before non-NULL values
+--! @note Comparison uses underlying text type ordering of HMAC-SHA256 hashes
+--!
+--! @see eql_v2.hmac_256
+--! @see eql_v2.has_hmac_256
+--! @see eql_v2."="
+CREATE FUNCTION eql_v2.compare_hmac_256(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_term eql_v2.hmac_256;
+ b_term eql_v2.hmac_256;
+ BEGIN
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF eql_v2.has_hmac_256(a) THEN
+ a_term = eql_v2.hmac_256(a);
+ END IF;
+
+ IF eql_v2.has_hmac_256(b) THEN
+ b_term = eql_v2.hmac_256(b);
+ END IF;
+
+ IF a_term IS NULL AND b_term IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b_term IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ -- Using the underlying text type comparison
+ IF a_term = b_term THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term < b_term THEN
+ RETURN -1;
+ END IF;
+
+ IF a_term > b_term THEN
+ RETURN 1;
+ END IF;
+
+ END;
+$$ LANGUAGE plpgsql;
+--! @file encryptindex/functions.sql
+--! @brief Configuration lifecycle and column encryption management
+--!
+--! Provides functions for managing encryption configuration transitions:
+--! - Comparing configurations to identify changes
+--! - Identifying columns needing encryption
+--! - Creating and renaming encrypted columns during initial setup
+--! - Tracking encryption progress
+--!
+--! These functions support the workflow of activating a pending configuration
+--! and performing the initial encryption of plaintext columns.
+
+
+--! @brief Compare two configurations and find differences
+--! @internal
+--!
+--! Returns table/column pairs where configuration differs between two configs.
+--! Used to identify which columns need encryption when activating a pending config.
+--!
+--! @param a jsonb First configuration to compare
+--! @param b jsonb Second configuration to compare
+--! @return TABLE(table_name text, column_name text) Columns with differing configuration
+--!
+--! @note Compares configuration structure, not just presence/absence
+--! @see eql_v2.select_pending_columns
+CREATE FUNCTION eql_v2.diff_config(a JSONB, b JSONB)
+ RETURNS TABLE(table_name TEXT, column_name TEXT)
+IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN QUERY
+ WITH table_keys AS (
+ SELECT jsonb_object_keys(a->'tables') AS key
+ UNION
+ SELECT jsonb_object_keys(b->'tables') AS key
+ ),
+ column_keys AS (
+ SELECT tk.key AS table_key, jsonb_object_keys(a->'tables'->tk.key) AS column_key
+ FROM table_keys tk
+ UNION
+ SELECT tk.key AS table_key, jsonb_object_keys(b->'tables'->tk.key) AS column_key
+ FROM table_keys tk
+ )
+ SELECT
+ ck.table_key AS table_name,
+ ck.column_key AS column_name
+ FROM
+ column_keys ck
+ WHERE
+ (a->'tables'->ck.table_key->ck.column_key IS DISTINCT FROM b->'tables'->ck.table_key->ck.column_key);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Get columns with pending configuration changes
+--!
+--! Compares 'pending' and 'active' configurations to identify columns that need
+--! encryption or re-encryption. Returns columns where configuration differs.
+--!
+--! @return TABLE(table_name text, column_name text) Columns needing encryption
+--! @throws Exception if no pending configuration exists
+--!
+--! @note Treats missing active config as empty config
+--! @see eql_v2.diff_config
+--! @see eql_v2.select_target_columns
+CREATE FUNCTION eql_v2.select_pending_columns()
+ RETURNS TABLE(table_name TEXT, column_name TEXT)
+AS $$
+ DECLARE
+ active JSONB;
+ pending JSONB;
+ config_id BIGINT;
+ BEGIN
+ SELECT data INTO active FROM eql_v2_configuration WHERE state = 'active';
+
+ -- set default config
+ IF active IS NULL THEN
+ active := '{}';
+ END IF;
+
+ SELECT id, data INTO config_id, pending FROM eql_v2_configuration WHERE state = 'pending';
+
+ -- set default config
+ IF config_id IS NULL THEN
+ RAISE EXCEPTION 'No pending configuration exists to encrypt';
+ END IF;
+
+ RETURN QUERY
+ SELECT d.table_name, d.column_name FROM eql_v2.diff_config(active, pending) as d;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Map pending columns to their encrypted target columns
+--!
+--! For each column with pending configuration, identifies the corresponding
+--! encrypted column. During initial encryption, target is '{column_name}_encrypted'.
+--! Returns NULL for target_column if encrypted column doesn't exist yet.
+--!
+--! @return TABLE(table_name text, column_name text, target_column text) Column mappings
+--!
+--! @note Target column is NULL if no column exists matching either 'column_name' or 'column_name_encrypted' with type eql_v2_encrypted
+--! @note The LEFT JOIN checks both original and '_encrypted' suffix variations with type verification
+--! @see eql_v2.select_pending_columns
+--! @see eql_v2.create_encrypted_columns
+CREATE FUNCTION eql_v2.select_target_columns()
+ RETURNS TABLE(table_name TEXT, column_name TEXT, target_column TEXT)
+ STABLE STRICT PARALLEL SAFE
+AS $$
+ SELECT
+ c.table_name,
+ c.column_name,
+ s.column_name as target_column
+ FROM
+ eql_v2.select_pending_columns() c
+ LEFT JOIN information_schema.columns s ON
+ s.table_name = c.table_name AND
+ (s.column_name = c.column_name OR s.column_name = c.column_name || '_encrypted') AND
+ s.udt_name = 'eql_v2_encrypted';
+$$ LANGUAGE sql;
+
+
+--! @brief Check if database is ready for encryption
+--!
+--! Verifies that all columns with pending configuration have corresponding
+--! encrypted target columns created. Returns true if encryption can proceed.
+--!
+--! @return boolean True if all pending columns have target encrypted columns
+--!
+--! @note Returns false if any pending column lacks encrypted column
+--! @see eql_v2.select_target_columns
+--! @see eql_v2.create_encrypted_columns
+CREATE FUNCTION eql_v2.ready_for_encryption()
+ RETURNS BOOLEAN
+ STABLE STRICT PARALLEL SAFE
+AS $$
+ SELECT EXISTS (
+ SELECT *
+ FROM eql_v2.select_target_columns() AS c
+ WHERE c.target_column IS NOT NULL);
+$$ LANGUAGE sql;
+
+
+--! @brief Create encrypted columns for initial encryption
+--!
+--! For each plaintext column with pending configuration that lacks an encrypted
+--! target column, creates a new column '{column_name}_encrypted' of type
+--! eql_v2_encrypted. This prepares the database schema for initial encryption.
+--!
+--! @return TABLE(table_name text, column_name text) Created encrypted columns
+--!
+--! @warning Executes dynamic DDL (ALTER TABLE ADD COLUMN) - modifies database schema
+--! @note Only creates columns that don't already exist
+--! @see eql_v2.select_target_columns
+--! @see eql_v2.rename_encrypted_columns
+CREATE FUNCTION eql_v2.create_encrypted_columns()
+ RETURNS TABLE(table_name TEXT, column_name TEXT)
+AS $$
+ BEGIN
+ FOR table_name, column_name IN
+ SELECT c.table_name, (c.column_name || '_encrypted') FROM eql_v2.select_target_columns() AS c WHERE c.target_column IS NULL
+ LOOP
+ EXECUTE format('ALTER TABLE %I ADD column %I eql_v2_encrypted;', table_name, column_name);
+ RETURN NEXT;
+ END LOOP;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Finalize initial encryption by renaming columns
+--!
+--! After initial encryption completes, renames columns to complete the transition:
+--! - Plaintext column '{column_name}' → '{column_name}_plaintext'
+--! - Encrypted column '{column_name}_encrypted' → '{column_name}'
+--!
+--! This makes the encrypted column the primary column with the original name.
+--!
+--! @return TABLE(table_name text, column_name text, target_column text) Renamed columns
+--!
+--! @warning Executes dynamic DDL (ALTER TABLE RENAME COLUMN) - modifies database schema
+--! @note Only renames columns where target is '{column_name}_encrypted'
+--! @see eql_v2.create_encrypted_columns
+CREATE FUNCTION eql_v2.rename_encrypted_columns()
+ RETURNS TABLE(table_name TEXT, column_name TEXT, target_column TEXT)
+AS $$
+ BEGIN
+ FOR table_name, column_name, target_column IN
+ SELECT * FROM eql_v2.select_target_columns() as c WHERE c.target_column = c.column_name || '_encrypted'
+ LOOP
+ EXECUTE format('ALTER TABLE %I RENAME %I TO %I;', table_name, column_name, column_name || '_plaintext');
+ EXECUTE format('ALTER TABLE %I RENAME %I TO %I;', table_name, target_column, column_name);
+ RETURN NEXT;
+ END LOOP;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Count rows encrypted with active configuration
+--! @internal
+--!
+--! Counts rows in a table where the encrypted column was encrypted using
+--! the currently active configuration. Used to track encryption progress.
+--!
+--! @param table_name text Name of table to check
+--! @param column_name text Name of encrypted column to check
+--! @return bigint Count of rows encrypted with active configuration
+--!
+--! @note The 'v' field in encrypted payloads stores the payload version ("2"), not the configuration ID
+--! @note Configuration tracking mechanism is implementation-specific
+CREATE FUNCTION eql_v2.count_encrypted_with_active_config(table_name TEXT, column_name TEXT)
+ RETURNS BIGINT
+AS $$
+DECLARE
+ result BIGINT;
+BEGIN
+ EXECUTE format(
+ 'SELECT COUNT(%I) FROM %s t WHERE %I->>%L = (SELECT id::TEXT FROM eql_v2_configuration WHERE state = %L)',
+ column_name, table_name, column_name, 'v', 'active'
+ )
+ INTO result;
+ RETURN result;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Validate presence of ident field in encrypted payload
+--! @internal
+--!
+--! Checks that the encrypted JSONB payload contains the required 'i' (ident) field.
+--! The ident field tracks which table and column the encrypted value belongs to.
+--!
+--! @param jsonb Encrypted payload to validate
+--! @return Boolean True if 'i' field is present
+--! @throws Exception if 'i' field is missing
+--!
+--! @note Used in CHECK constraints to ensure payload structure
+--! @see eql_v2.check_encrypted
+CREATE FUNCTION eql_v2._encrypted_check_i(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF val ? 'i' THEN
+ RETURN true;
+ END IF;
+ RAISE 'Encrypted column missing ident (i) field: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate table and column fields in ident
+--! @internal
+--!
+--! Checks that the 'i' (ident) field contains both 't' (table) and 'c' (column)
+--! subfields, which identify the origin of the encrypted value.
+--!
+--! @param jsonb Encrypted payload to validate
+--! @return Boolean True if both 't' and 'c' subfields are present
+--! @throws Exception if 't' or 'c' subfields are missing
+--!
+--! @note Used in CHECK constraints to ensure payload structure
+--! @see eql_v2.check_encrypted
+CREATE FUNCTION eql_v2._encrypted_check_i_ct(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF (val->'i' ?& array['t', 'c']) THEN
+ RETURN true;
+ END IF;
+ RAISE 'Encrypted column ident (i) missing table (t) or column (c) fields: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Validate version field in encrypted payload
+--! @internal
+--!
+--! Checks that the encrypted payload has version field 'v' set to '2',
+--! the current EQL v2 payload version.
+--!
+--! @param jsonb Encrypted payload to validate
+--! @return Boolean True if 'v' field is present and equals '2'
+--! @throws Exception if 'v' field is missing or not '2'
+--!
+--! @note Used in CHECK constraints to ensure payload structure
+--! @see eql_v2.check_encrypted
+CREATE FUNCTION eql_v2._encrypted_check_v(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF (val ? 'v') THEN
+
+ IF val->>'v' <> '2' THEN
+ RAISE 'Expected encrypted column version (v) 2';
+ RETURN false;
+ END IF;
+
+ RETURN true;
+ END IF;
+ RAISE 'Encrypted column missing version (v) field: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate ciphertext field in encrypted payload
+--! @internal
+--!
+--! Checks that the encrypted payload contains the required 'c' (ciphertext) field
+--! which stores the encrypted data.
+--!
+--! @param jsonb Encrypted payload to validate
+--! @return Boolean True if 'c' field is present
+--! @throws Exception if 'c' field is missing
+--!
+--! @note Used in CHECK constraints to ensure payload structure
+--! @see eql_v2.check_encrypted
+CREATE FUNCTION eql_v2._encrypted_check_c(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF (val ? 'c') THEN
+ RETURN true;
+ END IF;
+ RAISE 'Encrypted column missing ciphertext (c) field: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate complete encrypted payload structure
+--!
+--! Comprehensive validation function that checks all required fields in an
+--! encrypted JSONB payload: version ('v'), ciphertext ('c'), ident ('i'),
+--! and ident subfields ('t', 'c').
+--!
+--! This function is used in CHECK constraints to ensure encrypted column
+--! data integrity at the database level.
+--!
+--! @param jsonb Encrypted payload to validate
+--! @return Boolean True if all structure checks pass
+--! @throws Exception if any required field is missing or invalid
+--!
+--! @example
+--! -- Add validation constraint to encrypted column
+--! ALTER TABLE users ADD CONSTRAINT check_email_encrypted
+--! CHECK (eql_v2.check_encrypted(encrypted_email::jsonb));
+--!
+--! @see eql_v2._encrypted_check_v
+--! @see eql_v2._encrypted_check_c
+--! @see eql_v2._encrypted_check_i
+--! @see eql_v2._encrypted_check_i_ct
+CREATE FUNCTION eql_v2.check_encrypted(val jsonb)
+ RETURNS BOOLEAN
+LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ RETURN (
+ eql_v2._encrypted_check_v(val) AND
+ eql_v2._encrypted_check_c(val) AND
+ eql_v2._encrypted_check_i(val) AND
+ eql_v2._encrypted_check_i_ct(val)
+ );
+END;
+
+
+--! @brief Validate encrypted composite type structure
+--!
+--! Validates an eql_v2_encrypted composite type by checking its underlying
+--! JSONB payload. Delegates to eql_v2.check_encrypted(jsonb).
+--!
+--! @param eql_v2_encrypted Encrypted value to validate
+--! @return Boolean True if structure is valid
+--! @throws Exception if any required field is missing or invalid
+--!
+--! @see eql_v2.check_encrypted(jsonb)
+CREATE FUNCTION eql_v2.check_encrypted(val eql_v2_encrypted)
+ RETURNS BOOLEAN
+LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ RETURN eql_v2.check_encrypted(val.data);
+END;
+
+
+-- Aggregate functions for ORE
+
+--! @brief State transition function for min aggregate
+--! @internal
+--!
+--! Returns the smaller of two encrypted values for use in MIN aggregate.
+--! Comparison uses ORE index terms without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return eql_v2_encrypted The smaller of the two values
+--!
+--! @see eql_v2.min(eql_v2_encrypted)
+CREATE FUNCTION eql_v2.min(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS eql_v2_encrypted
+STRICT
+AS $$
+ BEGIN
+ IF a < b THEN
+ RETURN a;
+ ELSE
+ RETURN b;
+ END IF;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Find minimum encrypted value in a group
+--!
+--! Aggregate function that returns the minimum encrypted value in a group
+--! using ORE index term comparisons without decryption.
+--!
+--! @param input eql_v2_encrypted Encrypted values to aggregate
+--! @return eql_v2_encrypted Minimum value in the group
+--!
+--! @example
+--! -- Find minimum age per department
+--! SELECT department, eql_v2.min(encrypted_age)
+--! FROM employees
+--! GROUP BY department;
+--!
+--! @note Requires 'ore' index configuration on the column
+--! @see eql_v2.min(eql_v2_encrypted, eql_v2_encrypted)
+CREATE AGGREGATE eql_v2.min(eql_v2_encrypted)
+(
+ sfunc = eql_v2.min,
+ stype = eql_v2_encrypted
+);
+
+
+--! @brief State transition function for max aggregate
+--! @internal
+--!
+--! Returns the larger of two encrypted values for use in MAX aggregate.
+--! Comparison uses ORE index terms without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return eql_v2_encrypted The larger of the two values
+--!
+--! @see eql_v2.max(eql_v2_encrypted)
+CREATE FUNCTION eql_v2.max(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS eql_v2_encrypted
+STRICT
+AS $$
+ BEGIN
+ IF a > b THEN
+ RETURN a;
+ ELSE
+ RETURN b;
+ END IF;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Find maximum encrypted value in a group
+--!
+--! Aggregate function that returns the maximum encrypted value in a group
+--! using ORE index term comparisons without decryption.
+--!
+--! @param input eql_v2_encrypted Encrypted values to aggregate
+--! @return eql_v2_encrypted Maximum value in the group
+--!
+--! @example
+--! -- Find maximum salary per department
+--! SELECT department, eql_v2.max(encrypted_salary)
+--! FROM employees
+--! GROUP BY department;
+--!
+--! @note Requires 'ore' index configuration on the column
+--! @see eql_v2.max(eql_v2_encrypted, eql_v2_encrypted)
+CREATE AGGREGATE eql_v2.max(eql_v2_encrypted)
+(
+ sfunc = eql_v2.max,
+ stype = eql_v2_encrypted
+);
+
+
+--! @file config/indexes.sql
+--! @brief Configuration state uniqueness indexes
+--!
+--! Creates partial unique indexes to enforce that only one configuration
+--! can be in 'active', 'pending', or 'encrypting' state at any time.
+--! Multiple 'inactive' configurations are allowed.
+--!
+--! @note Uses partial indexes (WHERE clauses) for efficiency
+--! @note Prevents conflicting configurations from being active simultaneously
+--! @see config/types.sql for state definitions
+
+
+--! @brief Unique active configuration constraint
+--! @note Only one configuration can be 'active' at once
+CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'active';
+
+--! @brief Unique pending configuration constraint
+--! @note Only one configuration can be 'pending' at once
+CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'pending';
+
+--! @brief Unique encrypting configuration constraint
+--! @note Only one configuration can be 'encrypting' at once
+CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'encrypting';
+
+
+--! @brief Add a search index configuration for an encrypted column
+--!
+--! Configures a searchable encryption index (unique, match, ore, or ste_vec) on an
+--! encrypted column. Creates or updates the pending configuration, then migrates
+--! and activates it unless migrating flag is set.
+--!
+--! @param table_name Text Name of the table containing the column
+--! @param column_name Text Name of the column to configure
+--! @param index_name Text Type of index ('unique', 'match', 'ore', 'ste_vec')
+--! @param cast_as Text PostgreSQL type for decrypted values (default: 'text')
+--! @param opts JSONB Index-specific options (default: '{}')
+--! @param migrating Boolean Skip auto-migration if true (default: false)
+--! @return JSONB Updated configuration object
+--! @throws Exception if index already exists for this column
+--! @throws Exception if cast_as is not a valid type
+--!
+--! @example
+--! -- Add unique index for exact-match searches
+--! SELECT eql_v2.add_search_config('users', 'email', 'unique');
+--!
+--! -- Add match index for LIKE searches with custom token length
+--! SELECT eql_v2.add_search_config('posts', 'content', 'match', 'text',
+--! '{"token_filters": [{"kind": "downcase"}], "tokenizer": {"kind": "ngram", "token_length": 3}}'
+--! );
+--!
+--! @see eql_v2.add_column
+--! @see eql_v2.remove_search_config
+CREATE FUNCTION eql_v2.add_search_config(table_name text, column_name text, index_name text, cast_as text DEFAULT 'text', opts jsonb DEFAULT '{}', migrating boolean DEFAULT false)
+ RETURNS jsonb
+
+AS $$
+ DECLARE
+ o jsonb;
+ _config jsonb;
+ BEGIN
+
+ -- set the active config
+ SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;
+
+ -- if index exists
+ IF _config #> array['tables', table_name, column_name, 'indexes'] ? index_name THEN
+ RAISE EXCEPTION '% index exists for column: % %', index_name, table_name, column_name;
+ END IF;
+
+ IF NOT cast_as = ANY('{text, int, small_int, big_int, real, double, boolean, date, jsonb}') THEN
+ RAISE EXCEPTION '% is not a valid cast type', cast_as;
+ END IF;
+
+ -- set default config
+ SELECT eql_v2.config_default(_config) INTO _config;
+
+ SELECT eql_v2.config_add_table(table_name, _config) INTO _config;
+
+ SELECT eql_v2.config_add_column(table_name, column_name, _config) INTO _config;
+
+ SELECT eql_v2.config_add_cast(table_name, column_name, cast_as, _config) INTO _config;
+
+ -- set default options for index if opts empty
+ IF index_name = 'match' AND opts = '{}' THEN
+ SELECT eql_v2.config_match_default() INTO opts;
+ END IF;
+
+ SELECT eql_v2.config_add_index(table_name, column_name, index_name, opts, _config) INTO _config;
+
+ -- create a new pending record if we don't have one
+ INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)
+ ON CONFLICT (state)
+ WHERE state = 'pending'
+ DO UPDATE
+ SET data = _config;
+
+ IF NOT migrating THEN
+ PERFORM eql_v2.migrate_config();
+ PERFORM eql_v2.activate_config();
+ END IF;
+
+ PERFORM eql_v2.add_encrypted_constraint(table_name, column_name);
+
+ -- exeunt
+ RETURN _config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Remove a search index configuration from an encrypted column
+--!
+--! Removes a previously configured search index from an encrypted column.
+--! Updates the pending configuration, then migrates and activates it
+--! unless migrating flag is set.
+--!
+--! @param table_name Text Name of the table containing the column
+--! @param column_name Text Name of the column
+--! @param index_name Text Type of index to remove
+--! @param migrating Boolean Skip auto-migration if true (default: false)
+--! @return JSONB Updated configuration object
+--! @throws Exception if no active or pending configuration exists
+--! @throws Exception if table is not configured
+--! @throws Exception if column is not configured
+--!
+--! @example
+--! -- Remove match index from column
+--! SELECT eql_v2.remove_search_config('posts', 'content', 'match');
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.modify_search_config
+CREATE FUNCTION eql_v2.remove_search_config(table_name text, column_name text, index_name text, migrating boolean DEFAULT false)
+ RETURNS jsonb
+AS $$
+ DECLARE
+ _config jsonb;
+ BEGIN
+
+ -- set the active config
+ SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;
+
+ -- if no config
+ IF _config IS NULL THEN
+ RAISE EXCEPTION 'No active or pending configuration exists';
+ END IF;
+
+ -- if the table doesn't exist
+ IF NOT _config #> array['tables'] ? table_name THEN
+ RAISE EXCEPTION 'No configuration exists for table: %', table_name;
+ END IF;
+
+ -- if the index does not exist
+ -- IF NOT _config->key ? index_name THEN
+ IF NOT _config #> array['tables', table_name] ? column_name THEN
+ RAISE EXCEPTION 'No % index exists for column: % %', index_name, table_name, column_name;
+ END IF;
+
+ -- create a new pending record if we don't have one
+ INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)
+ ON CONFLICT (state)
+ WHERE state = 'pending'
+ DO NOTHING;
+
+ -- remove the index
+ SELECT _config #- array['tables', table_name, column_name, 'indexes', index_name] INTO _config;
+
+ -- update the config and migrate (even if empty)
+ UPDATE public.eql_v2_configuration SET data = _config WHERE state = 'pending';
+
+ IF NOT migrating THEN
+ PERFORM eql_v2.migrate_config();
+ PERFORM eql_v2.activate_config();
+ END IF;
+
+ -- exeunt
+ RETURN _config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Modify a search index configuration for an encrypted column
+--!
+--! Updates an existing search index configuration by removing and re-adding it
+--! with new options. Convenience function that combines remove and add operations.
+--! If index does not exist, it is added.
+--!
+--! @param table_name Text Name of the table containing the column
+--! @param column_name Text Name of the column
+--! @param index_name Text Type of index to modify
+--! @param cast_as Text PostgreSQL type for decrypted values (default: 'text')
+--! @param opts JSONB New index-specific options (default: '{}')
+--! @param migrating Boolean Skip auto-migration if true (default: false)
+--! @return JSONB Updated configuration object
+--!
+--! @example
+--! -- Change match index tokenizer settings
+--! SELECT eql_v2.modify_search_config('posts', 'content', 'match', 'text',
+--! '{"tokenizer": {"kind": "ngram", "token_length": 4}}'
+--! );
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.remove_search_config
+CREATE FUNCTION eql_v2.modify_search_config(table_name text, column_name text, index_name text, cast_as text DEFAULT 'text', opts jsonb DEFAULT '{}', migrating boolean DEFAULT false)
+ RETURNS jsonb
+AS $$
+ BEGIN
+ PERFORM eql_v2.remove_search_config(table_name, column_name, index_name, migrating);
+ RETURN eql_v2.add_search_config(table_name, column_name, index_name, cast_as, opts, migrating);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Migrate pending configuration to encrypting state
+--!
+--! Transitions the pending configuration to encrypting state, validating that
+--! all configured columns have encrypted target columns ready. This is part of
+--! the configuration lifecycle: pending → encrypting → active.
+--!
+--! @return Boolean True if migration succeeds
+--! @throws Exception if encryption already in progress
+--! @throws Exception if no pending configuration exists
+--! @throws Exception if configured columns lack encrypted targets
+--!
+--! @example
+--! -- Manually migrate configuration (normally done automatically)
+--! SELECT eql_v2.migrate_config();
+--!
+--! @see eql_v2.activate_config
+--! @see eql_v2.add_column
+CREATE FUNCTION eql_v2.migrate_config()
+ RETURNS boolean
+AS $$
+ BEGIN
+
+ IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'encrypting') THEN
+ RAISE EXCEPTION 'An encryption is already in progress';
+ END IF;
+
+ IF NOT EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'pending') THEN
+ RAISE EXCEPTION 'No pending configuration exists to encrypt';
+ END IF;
+
+ IF NOT eql_v2.ready_for_encryption() THEN
+ RAISE EXCEPTION 'Some pending columns do not have an encrypted target';
+ END IF;
+
+ UPDATE public.eql_v2_configuration SET state = 'encrypting' WHERE state = 'pending';
+ RETURN true;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Activate encrypting configuration
+--!
+--! Transitions the encrypting configuration to active state, making it the
+--! current operational configuration. Marks previous active configuration as
+--! inactive. Final step in configuration lifecycle: pending → encrypting → active.
+--!
+--! @return Boolean True if activation succeeds
+--! @throws Exception if no encrypting configuration exists to activate
+--!
+--! @example
+--! -- Manually activate configuration (normally done automatically)
+--! SELECT eql_v2.activate_config();
+--!
+--! @see eql_v2.migrate_config
+--! @see eql_v2.add_column
+CREATE FUNCTION eql_v2.activate_config()
+ RETURNS boolean
+AS $$
+ BEGIN
+
+ IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'encrypting') THEN
+ UPDATE public.eql_v2_configuration SET state = 'inactive' WHERE state = 'active';
+ UPDATE public.eql_v2_configuration SET state = 'active' WHERE state = 'encrypting';
+ RETURN true;
+ ELSE
+ RAISE EXCEPTION 'No encrypting configuration exists to activate';
+ END IF;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Discard pending configuration
+--!
+--! Deletes the pending configuration without applying changes. Use this to
+--! abandon configuration changes before they are migrated and activated.
+--!
+--! @return Boolean True if discard succeeds
+--! @throws Exception if no pending configuration exists to discard
+--!
+--! @example
+--! -- Discard uncommitted configuration changes
+--! SELECT eql_v2.discard();
+--!
+--! @see eql_v2.add_column
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.discard()
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'pending') THEN
+ DELETE FROM public.eql_v2_configuration WHERE state = 'pending';
+ RETURN true;
+ ELSE
+ RAISE EXCEPTION 'No pending configuration exists to discard';
+ END IF;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Configure a column for encryption
+--!
+--! Adds a column to the encryption configuration, making it eligible for
+--! encrypted storage and search indexes. Creates or updates pending configuration,
+--! adds encrypted constraint, then migrates and activates unless migrating flag is set.
+--!
+--! @param table_name Text Name of the table containing the column
+--! @param column_name Text Name of the column to encrypt
+--! @param cast_as Text PostgreSQL type to cast decrypted values (default: 'text')
+--! @param migrating Boolean Skip auto-migration if true (default: false)
+--! @return JSONB Updated configuration object
+--! @throws Exception if column already configured for encryption
+--!
+--! @example
+--! -- Configure email column for encryption
+--! SELECT eql_v2.add_column('users', 'email', 'text');
+--!
+--! -- Configure age column with integer casting
+--! SELECT eql_v2.add_column('users', 'age', 'int');
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.remove_column
+CREATE FUNCTION eql_v2.add_column(table_name text, column_name text, cast_as text DEFAULT 'text', migrating boolean DEFAULT false)
+ RETURNS jsonb
+AS $$
+ DECLARE
+ key text;
+ _config jsonb;
+ BEGIN
+ -- set the active config
+ SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;
+
+ -- set default config
+ SELECT eql_v2.config_default(_config) INTO _config;
+
+ -- if index exists
+ IF _config #> array['tables', table_name] ? column_name THEN
+ RAISE EXCEPTION 'Config exists for column: % %', table_name, column_name;
+ END IF;
+
+ SELECT eql_v2.config_add_table(table_name, _config) INTO _config;
+
+ SELECT eql_v2.config_add_column(table_name, column_name, _config) INTO _config;
+
+ SELECT eql_v2.config_add_cast(table_name, column_name, cast_as, _config) INTO _config;
+
+ -- create a new pending record if we don't have one
+ INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)
+ ON CONFLICT (state)
+ WHERE state = 'pending'
+ DO UPDATE
+ SET data = _config;
+
+ IF NOT migrating THEN
+ PERFORM eql_v2.migrate_config();
+ PERFORM eql_v2.activate_config();
+ END IF;
+
+ PERFORM eql_v2.add_encrypted_constraint(table_name, column_name);
+
+ -- exeunt
+ RETURN _config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Remove a column from encryption configuration
+--!
+--! Removes a column from the encryption configuration, including all associated
+--! search indexes. Removes encrypted constraint, updates pending configuration,
+--! then migrates and activates unless migrating flag is set.
+--!
+--! @param table_name Text Name of the table containing the column
+--! @param column_name Text Name of the column to remove
+--! @param migrating Boolean Skip auto-migration if true (default: false)
+--! @return JSONB Updated configuration object
+--! @throws Exception if no active or pending configuration exists
+--! @throws Exception if table is not configured
+--! @throws Exception if column is not configured
+--!
+--! @example
+--! -- Remove email column from encryption
+--! SELECT eql_v2.remove_column('users', 'email');
+--!
+--! @see eql_v2.add_column
+--! @see eql_v2.remove_search_config
+CREATE FUNCTION eql_v2.remove_column(table_name text, column_name text, migrating boolean DEFAULT false)
+ RETURNS jsonb
+AS $$
+ DECLARE
+ key text;
+ _config jsonb;
+ BEGIN
+ -- set the active config
+ SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;
+
+ -- if no config
+ IF _config IS NULL THEN
+ RAISE EXCEPTION 'No active or pending configuration exists';
+ END IF;
+
+ -- if the table doesn't exist
+ IF NOT _config #> array['tables'] ? table_name THEN
+ RAISE EXCEPTION 'No configuration exists for table: %', table_name;
+ END IF;
+
+ -- if the column does not exist
+ IF NOT _config #> array['tables', table_name] ? column_name THEN
+ RAISE EXCEPTION 'No configuration exists for column: % %', table_name, column_name;
+ END IF;
+
+ -- create a new pending record if we don't have one
+ INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)
+ ON CONFLICT (state)
+ WHERE state = 'pending'
+ DO NOTHING;
+
+ -- remove the column
+ SELECT _config #- array['tables', table_name, column_name] INTO _config;
+
+ -- if table is now empty, remove the table
+ IF _config #> array['tables', table_name] = '{}' THEN
+ SELECT _config #- array['tables', table_name] INTO _config;
+ END IF;
+
+ PERFORM eql_v2.remove_encrypted_constraint(table_name, column_name);
+
+ -- update the config (even if empty) and activate
+ UPDATE public.eql_v2_configuration SET data = _config WHERE state = 'pending';
+
+ IF NOT migrating THEN
+ -- For empty configs, skip migration validation and directly activate
+ IF _config #> array['tables'] = '{}' THEN
+ UPDATE public.eql_v2_configuration SET state = 'inactive' WHERE state = 'active';
+ UPDATE public.eql_v2_configuration SET state = 'active' WHERE state = 'pending';
+ ELSE
+ PERFORM eql_v2.migrate_config();
+ PERFORM eql_v2.activate_config();
+ END IF;
+ END IF;
+
+ -- exeunt
+ RETURN _config;
+
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Reload configuration from CipherStash Proxy
+--!
+--! Placeholder function for reloading configuration from the CipherStash Proxy.
+--! Currently returns NULL without side effects.
+--!
+--! @return Void
+--!
+--! @note This function may be used for configuration synchronization in future versions
+CREATE FUNCTION eql_v2.reload_config()
+ RETURNS void
+LANGUAGE sql STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ RETURN NULL;
+END;
+
+--! @brief Query encryption configuration in tabular format
+--!
+--! Returns the active encryption configuration as a table for easier querying
+--! and filtering. Shows all configured tables, columns, cast types, and indexes.
+--!
+--! @return TABLE Contains configuration state, relation name, column name, cast type, and indexes
+--!
+--! @example
+--! -- View all encrypted columns
+--! SELECT * FROM eql_v2.config();
+--!
+--! -- Find all columns with match indexes
+--! SELECT relation, col_name FROM eql_v2.config()
+--! WHERE indexes ? 'match';
+--!
+--! @see eql_v2.add_column
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.config() RETURNS TABLE (
+ state eql_v2_configuration_state,
+ relation text,
+ col_name text,
+ decrypts_as text,
+ indexes jsonb
+)
+AS $$
+BEGIN
+ RETURN QUERY
+ WITH tables AS (
+ SELECT config.state, tables.key AS table, tables.value AS config
+ FROM public.eql_v2_configuration config, jsonb_each(data->'tables') tables
+ WHERE config.data->>'v' = '1'
+ )
+ SELECT
+ tables.state,
+ tables.table,
+ column_config.key,
+ column_config.value->>'cast_as',
+ column_config.value->'indexes'
+ FROM tables, jsonb_each(tables.config) column_config;
+END;
+$$ LANGUAGE plpgsql;
+
+--! @file config/constraints.sql
+--! @brief Configuration validation functions and constraints
+--!
+--! Provides CHECK constraint functions to validate encryption configuration structure.
+--! Ensures configurations have required fields (version, tables) and valid values
+--! for index types and cast types before being stored.
+--!
+--! @see config/tables.sql where constraints are applied
+
+
+--! @brief Extract index type names from configuration
+--! @internal
+--!
+--! Helper function that extracts all index type names from the configuration's
+--! 'indexes' sections across all tables and columns.
+--!
+--! @param jsonb Configuration data to extract from
+--! @return SETOF text Index type names (e.g., 'match', 'ore', 'unique', 'ste_vec')
+--!
+--! @note Used by config_check_indexes for validation
+--! @see eql_v2.config_check_indexes
+CREATE FUNCTION eql_v2.config_get_indexes(val jsonb)
+ RETURNS SETOF text
+ LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ SELECT jsonb_object_keys(jsonb_path_query(val,'$.tables.*.*.indexes'));
+END;
+
+
+--! @brief Validate index types in configuration
+--! @internal
+--!
+--! Checks that all index types specified in the configuration are valid.
+--! Valid index types are: match, ore, unique, ste_vec.
+--!
+--! @param jsonb Configuration data to validate
+--! @return boolean True if all index types are valid
+--! @throws Exception if any invalid index type found
+--!
+--! @note Used in CHECK constraint on eql_v2_configuration table
+--! @see eql_v2.config_get_indexes
+CREATE FUNCTION eql_v2.config_check_indexes(val jsonb)
+ RETURNS BOOLEAN
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+
+ IF (SELECT EXISTS (SELECT eql_v2.config_get_indexes(val))) THEN
+ IF (SELECT bool_and(index = ANY('{match, ore, unique, ste_vec}')) FROM eql_v2.config_get_indexes(val) AS index) THEN
+ RETURN true;
+ END IF;
+ RAISE 'Configuration has an invalid index (%). Index should be one of {match, ore, unique, ste_vec}', val;
+ END IF;
+ RETURN true;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate cast types in configuration
+--! @internal
+--!
+--! Checks that all 'cast_as' types specified in the configuration are valid.
+--! Valid cast types are: text, int, small_int, big_int, real, double, boolean, date, jsonb.
+--!
+--! @param jsonb Configuration data to validate
+--! @return boolean True if all cast types are valid or no cast types specified
+--! @throws Exception if any invalid cast type found
+--!
+--! @note Used in CHECK constraint on eql_v2_configuration table
+--! @note Empty configurations (no cast_as fields) are valid
+--! @note Cast type names are EQL's internal representations, not PostgreSQL native types
+CREATE FUNCTION eql_v2.config_check_cast(val jsonb)
+ RETURNS BOOLEAN
+AS $$
+ BEGIN
+ -- If there are cast_as fields, validate them
+ IF EXISTS (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.cast_as'))) THEN
+ IF (SELECT bool_and(cast_as = ANY('{text, int, small_int, big_int, real, double, boolean, date, jsonb}'))
+ FROM (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.cast_as')) AS cast_as) casts) THEN
+ RETURN true;
+ END IF;
+ RAISE 'Configuration has an invalid cast_as (%). Cast should be one of {text, int, small_int, big_int, real, double, boolean, date, jsonb}', val;
+ END IF;
+ -- If no cast_as fields exist (empty config), that's valid
+ RETURN true;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate tables field presence
+--! @internal
+--!
+--! Ensures the configuration has a 'tables' field, which is required
+--! to specify which database tables contain encrypted columns.
+--!
+--! @param jsonb Configuration data to validate
+--! @return boolean True if 'tables' field exists
+--! @throws Exception if 'tables' field is missing
+--!
+--! @note Used in CHECK constraint on eql_v2_configuration table
+CREATE FUNCTION eql_v2.config_check_tables(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF (val ? 'tables') THEN
+ RETURN true;
+ END IF;
+ RAISE 'Configuration missing tables (tables) field: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate version field presence
+--! @internal
+--!
+--! Ensures the configuration has a 'v' (version) field, which tracks
+--! the configuration format version.
+--!
+--! @param jsonb Configuration data to validate
+--! @return boolean True if 'v' field exists
+--! @throws Exception if 'v' field is missing
+--!
+--! @note Used in CHECK constraint on eql_v2_configuration table
+CREATE FUNCTION eql_v2.config_check_version(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF (val ? 'v') THEN
+ RETURN true;
+ END IF;
+ RAISE 'Configuration missing version (v) field: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Drop existing data validation constraint if present
+--! @note Allows constraint to be recreated during upgrades
+ALTER TABLE public.eql_v2_configuration DROP CONSTRAINT IF EXISTS eql_v2_configuration_data_check;
+
+
+--! @brief Comprehensive configuration data validation
+--!
+--! CHECK constraint that validates all aspects of configuration data:
+--! - Version field presence
+--! - Tables field presence
+--! - Valid cast_as types
+--! - Valid index types
+--!
+--! @note Combines all config_check_* validation functions
+--! @see eql_v2.config_check_version
+--! @see eql_v2.config_check_tables
+--! @see eql_v2.config_check_cast
+--! @see eql_v2.config_check_indexes
+ALTER TABLE public.eql_v2_configuration
+ ADD CONSTRAINT eql_v2_configuration_data_check CHECK (
+ eql_v2.config_check_version(data) AND
+ eql_v2.config_check_tables(data) AND
+ eql_v2.config_check_cast(data) AND
+ eql_v2.config_check_indexes(data)
+);
+
+
+
+
+--! @brief Compare two encrypted values using Blake3 hash index terms
+--!
+--! Performs a three-way comparison (returns -1/0/1) of encrypted values using
+--! their Blake3 hash index terms. Used internally by the equality operator (=)
+--! for exact-match queries without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value to compare
+--! @param b eql_v2_encrypted Second encrypted value to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note NULL values are sorted before non-NULL values
+--! @note Comparison uses underlying text type ordering of Blake3 hashes
+--!
+--! @see eql_v2.blake3
+--! @see eql_v2.has_blake3
+--! @see eql_v2."="
+CREATE FUNCTION eql_v2.compare_blake3(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_term eql_v2.blake3;
+ b_term eql_v2.blake3;
+ BEGIN
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF eql_v2.has_blake3(a) THEN
+ a_term = eql_v2.blake3(a);
+ END IF;
+
+ IF eql_v2.has_blake3(b) THEN
+ b_term = eql_v2.blake3(b);
+ END IF;
+
+ IF a_term IS NULL AND b_term IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b_term IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ -- Using the underlying text type comparison
+ IF a_term = b_term THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term < b_term THEN
+ RETURN -1;
+ END IF;
+
+ IF a_term > b_term THEN
+ RETURN 1;
+ END IF;
+
+ END;
+$$ LANGUAGE plpgsql;
+`
diff --git a/packages/stack/src/prisma/core/eql-install.sql b/packages/stack/src/prisma/core/eql-install.sql
new file mode 100644
index 00000000..852d91ce
--- /dev/null
+++ b/packages/stack/src/prisma/core/eql-install.sql
@@ -0,0 +1,5741 @@
+-- @cipherstash/stack/prisma — vendored EQL install bundle (version: eql-2.2.1)
+--! @file schema.sql
+--! @brief EQL v2 schema creation
+--!
+--! Creates the eql_v2 schema which contains all Encrypt Query Language
+--! functions, types, and tables. Drops existing schema if present to
+--! support clean reinstallation.
+--!
+--! @warning DROP SCHEMA CASCADE will remove all objects in the schema
+--! @note All EQL objects (functions, types, tables) reside in eql_v2 schema
+
+--! @brief Drop existing EQL v2 schema
+--! @warning CASCADE will drop all dependent objects
+DROP SCHEMA IF EXISTS eql_v2 CASCADE;
+
+--! @brief Create EQL v2 schema
+--! @note All EQL functions and types will be created in this schema
+CREATE SCHEMA eql_v2;
+
+--! @brief Composite type for encrypted column data
+--!
+--! Core type used for all encrypted columns in EQL. Stores encrypted data as JSONB
+--! with the following structure:
+--! - `c`: ciphertext (base64-encoded encrypted value)
+--! - `i`: index terms (searchable metadata for encrypted searches)
+--! - `k`: key ID (identifier for encryption key)
+--! - `m`: metadata (additional encryption metadata)
+--!
+--! Created in public schema to persist independently of eql_v2 schema lifecycle.
+--! Customer data columns use this type, so it must not be dropped if data exists.
+--!
+--! @note DO NOT DROP this type unless absolutely certain no encrypted data uses it
+--! @see eql_v2.ciphertext
+--! @see eql_v2.meta_data
+--! @see eql_v2.add_column
+DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'eql_v2_encrypted') THEN
+ CREATE TYPE public.eql_v2_encrypted AS (
+ data jsonb
+ );
+ END IF;
+ END
+$$;
+
+
+
+
+
+
+
+
+
+
+--! @brief Bloom filter index term type
+--!
+--! Domain type representing Bloom filter bit arrays stored as smallint arrays.
+--! Used for pattern-match encrypted searches via the 'match' index type.
+--! The filter is stored in the 'bf' field of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2."~~"
+--! @note This is a transient type used only during query execution
+CREATE DOMAIN eql_v2.bloom_filter AS smallint[];
+
+
+
+--! @brief ORE block term type for Order-Revealing Encryption
+--!
+--! Composite type representing a single ORE (Order-Revealing Encryption) block term.
+--! Stores encrypted data as bytea that enables range comparisons without decryption.
+--!
+--! @see eql_v2.ore_block_u64_8_256
+--! @see eql_v2.compare_ore_block_u64_8_256_term
+CREATE TYPE eql_v2.ore_block_u64_8_256_term AS (
+ bytes bytea
+);
+
+
+--! @brief ORE block index term type for range queries
+--!
+--! Composite type containing an array of ORE block terms. Used for encrypted
+--! range queries via the 'ore' index type. The array is stored in the 'ob' field
+--! of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+--! @note This is a transient type used only during query execution
+CREATE TYPE eql_v2.ore_block_u64_8_256 AS (
+ terms eql_v2.ore_block_u64_8_256_term[]
+);
+
+--! @brief HMAC-SHA256 index term type
+--!
+--! Domain type representing HMAC-SHA256 hash values.
+--! Used for exact-match encrypted searches via the 'unique' index type.
+--! The hash is stored in the 'hm' field of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @note This is a transient type used only during query execution
+CREATE DOMAIN eql_v2.hmac_256 AS text;
+-- AUTOMATICALLY GENERATED FILE
+
+--! @file common.sql
+--! @brief Common utility functions
+--!
+--! Provides general-purpose utility functions used across EQL:
+--! - Constant-time bytea comparison for security
+--! - JSONB to bytea array conversion
+--! - Logging helpers for debugging and testing
+
+
+--! @brief Constant-time comparison of bytea values
+--! @internal
+--!
+--! Compares two bytea values in constant time to prevent timing attacks.
+--! Always checks all bytes even after finding differences, maintaining
+--! consistent execution time regardless of where differences occur.
+--!
+--! @param a bytea First value to compare
+--! @param b bytea Second value to compare
+--! @return boolean True if values are equal
+--!
+--! @note Returns false immediately if lengths differ (length is not secret)
+--! @note Used for secure comparison of cryptographic values
+CREATE FUNCTION eql_v2.bytea_eq(a bytea, b bytea) RETURNS boolean AS $$
+DECLARE
+ result boolean;
+ differing bytea;
+BEGIN
+
+ -- Check if the bytea values are the same length
+ IF LENGTH(a) != LENGTH(b) THEN
+ RETURN false;
+ END IF;
+
+ -- Compare each byte in the bytea values
+ result := true;
+ FOR i IN 1..LENGTH(a) LOOP
+ IF SUBSTRING(a FROM i FOR 1) != SUBSTRING(b FROM i FOR 1) THEN
+ result := result AND false;
+ END IF;
+ END LOOP;
+
+ RETURN result;
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @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 jsonb JSONB array of hex-encoded strings
+--! @return bytea[] Array of decoded binary values
+--!
+--! @note Returns NULL if input is JSON null
+--! @note Each array element is hex-decoded to bytea
+CREATE FUNCTION eql_v2.jsonb_array_to_bytea_array(val jsonb)
+RETURNS bytea[] AS $$
+DECLARE
+ terms_arr bytea[];
+BEGIN
+ IF jsonb_typeof(val) = 'null' THEN
+ RETURN NULL;
+ END IF;
+
+ SELECT array_agg(decode(value::text, 'hex')::bytea)
+ INTO terms_arr
+ FROM jsonb_array_elements_text(val) AS value;
+
+ RETURN terms_arr;
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Log message for debugging
+--!
+--! Convenience function to emit log messages during testing and debugging.
+--! Uses RAISE NOTICE to output messages to PostgreSQL logs.
+--!
+--! @param text Message to log
+--!
+--! @note Primarily used in tests and development
+--! @see eql_v2.log(text, text) for contextual logging
+CREATE FUNCTION eql_v2.log(s text)
+ RETURNS void
+AS $$
+ BEGIN
+ RAISE NOTICE '[LOG] %', s;
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Log message with context
+--!
+--! Overload of log function that includes context label for better
+--! log organization during testing.
+--!
+--! @param ctx text Context label (e.g., test name, module name)
+--! @param s text Message to log
+--!
+--! @note Format: "[LOG] {ctx} {message}"
+--! @see eql_v2.log(text)
+CREATE FUNCTION eql_v2.log(ctx text, s text)
+ RETURNS void
+AS $$
+ BEGIN
+ RAISE NOTICE '[LOG] % %', ctx, s;
+END;
+$$ LANGUAGE plpgsql;
+
+--! @brief CLLW ORE index term type for range queries
+--!
+--! Composite type for CLLW (Copyless Logarithmic Width) Order-Revealing Encryption.
+--! Each output block is 8-bits. Used for encrypted range queries via the 'ore' index type.
+--! The ciphertext is stored in the 'ocf' field of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.compare_ore_cllw_u64_8
+--! @note This is a transient type used only during query execution
+CREATE TYPE eql_v2.ore_cllw_u64_8 AS (
+ bytes bytea
+);
+
+--! @file crypto.sql
+--! @brief PostgreSQL pgcrypto extension enablement
+--!
+--! Enables the pgcrypto extension which provides cryptographic functions
+--! used by EQL for hashing and other cryptographic operations.
+--!
+--! @note pgcrypto provides functions like digest(), hmac(), gen_random_bytes()
+--! @note IF NOT EXISTS prevents errors if extension already enabled
+
+--! @brief Enable pgcrypto extension
+--! @note Provides cryptographic functions for hashing and random number generation
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+
+
+--! @brief Extract ciphertext from encrypted JSONB value
+--!
+--! Extracts the ciphertext (c field) from a raw JSONB encrypted value.
+--! The ciphertext is the base64-encoded encrypted data.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Text Base64-encoded ciphertext string
+--! @throws Exception if 'c' field is not present in JSONB
+--!
+--! @example
+--! -- Extract ciphertext from JSONB literal
+--! SELECT eql_v2.ciphertext('{"c":"AQIDBA==","i":{"unique":"..."}}'::jsonb);
+--!
+--! @see eql_v2.ciphertext(eql_v2_encrypted)
+--! @see eql_v2.meta_data
+CREATE FUNCTION eql_v2.ciphertext(val jsonb)
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val ? 'c' THEN
+ RETURN val->>'c';
+ END IF;
+ RAISE 'Expected a ciphertext (c) value in json: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Extract ciphertext from encrypted column value
+--!
+--! Extracts the ciphertext from an encrypted column value. Convenience
+--! overload that unwraps eql_v2_encrypted type and delegates to JSONB version.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Text Base64-encoded ciphertext string
+--! @throws Exception if encrypted value is malformed
+--!
+--! @example
+--! -- Extract ciphertext from encrypted column
+--! SELECT eql_v2.ciphertext(encrypted_email) FROM users;
+--!
+--! @see eql_v2.ciphertext(jsonb)
+--! @see eql_v2.meta_data
+CREATE FUNCTION eql_v2.ciphertext(val eql_v2_encrypted)
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.ciphertext(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief State transition function for grouped_value aggregate
+--! @internal
+--!
+--! Returns the first non-null value encountered. Used as state function
+--! for the grouped_value aggregate to select first value in each group.
+--!
+--! @param $1 JSONB Accumulated state (first non-null value found)
+--! @param $2 JSONB New value from current row
+--! @return JSONB First non-null value (state or new value)
+--!
+--! @see eql_v2.grouped_value
+CREATE FUNCTION eql_v2._first_grouped_value(jsonb, jsonb)
+RETURNS jsonb AS $$
+ SELECT COALESCE($1, $2);
+$$ LANGUAGE sql IMMUTABLE;
+
+--! @brief Return first non-null encrypted value in a group
+--!
+--! Aggregate function that returns the first non-null encrypted value
+--! encountered within a GROUP BY clause. Useful for deduplication or
+--! selecting representative values from grouped encrypted data.
+--!
+--! @param input JSONB Encrypted values to aggregate
+--! @return JSONB First non-null encrypted value in group
+--!
+--! @example
+--! -- Get first email per user group
+--! SELECT user_id, eql_v2.grouped_value(encrypted_email)
+--! FROM user_emails
+--! GROUP BY user_id;
+--!
+--! -- Deduplicate encrypted values
+--! SELECT DISTINCT ON (user_id)
+--! user_id,
+--! eql_v2.grouped_value(encrypted_ssn) as primary_ssn
+--! FROM user_records
+--! GROUP BY user_id;
+--!
+--! @see eql_v2._first_grouped_value
+CREATE AGGREGATE eql_v2.grouped_value(jsonb) (
+ SFUNC = eql_v2._first_grouped_value,
+ STYPE = jsonb
+);
+
+--! @brief Add validation constraint to encrypted column
+--!
+--! Adds a CHECK constraint to ensure column values conform to encrypted data
+--! structure. Constraint uses eql_v2.check_encrypted to validate format.
+--! Called automatically by eql_v2.add_column.
+--!
+--! @param table_name TEXT Name of table containing the column
+--! @param column_name TEXT Name of column to constrain
+--! @return Void
+--!
+--! @example
+--! -- Manually add constraint (normally done by add_column)
+--! SELECT eql_v2.add_encrypted_constraint('users', 'encrypted_email');
+--!
+--! -- Resulting constraint:
+--! -- ALTER TABLE users ADD CONSTRAINT eql_v2_encrypted_check_encrypted_email
+--! -- CHECK (eql_v2.check_encrypted(encrypted_email));
+--!
+--! @see eql_v2.add_column
+--! @see eql_v2.remove_encrypted_constraint
+CREATE FUNCTION eql_v2.add_encrypted_constraint(table_name TEXT, column_name TEXT)
+ RETURNS void
+AS $$
+ BEGIN
+ EXECUTE format('ALTER TABLE %I ADD CONSTRAINT eql_v2_encrypted_constraint_%I_%I CHECK (eql_v2.check_encrypted(%I))', table_name, table_name, column_name, column_name);
+ EXCEPTION
+ WHEN duplicate_table THEN
+ WHEN duplicate_object THEN
+ RAISE NOTICE 'Constraint `eql_v2_encrypted_constraint_%_%` already exists, skipping', table_name, column_name;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Remove validation constraint from encrypted column
+--!
+--! Removes the CHECK constraint that validates encrypted data structure.
+--! Called automatically by eql_v2.remove_column. Uses IF EXISTS to avoid
+--! errors if constraint doesn't exist.
+--!
+--! @param table_name TEXT Name of table containing the column
+--! @param column_name TEXT Name of column to unconstrain
+--! @return Void
+--!
+--! @example
+--! -- Manually remove constraint (normally done by remove_column)
+--! SELECT eql_v2.remove_encrypted_constraint('users', 'encrypted_email');
+--!
+--! @see eql_v2.remove_column
+--! @see eql_v2.add_encrypted_constraint
+CREATE FUNCTION eql_v2.remove_encrypted_constraint(table_name TEXT, column_name TEXT)
+ RETURNS void
+AS $$
+ BEGIN
+ EXECUTE format('ALTER TABLE %I DROP CONSTRAINT IF EXISTS eql_v2_encrypted_constraint_%I_%I', table_name, table_name, column_name);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Extract metadata from encrypted JSONB value
+--!
+--! Extracts index terms (i) and version (v) from a raw JSONB encrypted value.
+--! Returns metadata object containing searchable index terms without ciphertext.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return JSONB Metadata object with 'i' (index terms) and 'v' (version) fields
+--!
+--! @example
+--! -- Extract metadata to inspect index terms
+--! SELECT eql_v2.meta_data('{"c":"...","i":{"unique":"abc123"},"v":1}'::jsonb);
+--! -- Returns: {"i":{"unique":"abc123"},"v":1}
+--!
+--! @see eql_v2.meta_data(eql_v2_encrypted)
+--! @see eql_v2.ciphertext
+CREATE FUNCTION eql_v2.meta_data(val jsonb)
+ RETURNS jsonb
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN jsonb_build_object(
+ 'i', val->'i',
+ 'v', val->'v'
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Extract metadata from encrypted column value
+--!
+--! Extracts index terms and version from an encrypted column value.
+--! Convenience overload that unwraps eql_v2_encrypted type and
+--! delegates to JSONB version.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return JSONB Metadata object with 'i' (index terms) and 'v' (version) fields
+--!
+--! @example
+--! -- Inspect index terms for encrypted column
+--! SELECT user_id, eql_v2.meta_data(encrypted_email) as email_metadata
+--! FROM users;
+--!
+--! @see eql_v2.meta_data(jsonb)
+--! @see eql_v2.ciphertext
+CREATE FUNCTION eql_v2.meta_data(val eql_v2_encrypted)
+ RETURNS jsonb
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.meta_data(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Variable-width CLLW ORE index term type for range queries
+--!
+--! Composite type for variable-width CLLW (Copyless Logarithmic Width) Order-Revealing Encryption.
+--! Each output block is 8-bits. Unlike ore_cllw_u64_8, supports variable-length ciphertexts.
+--! Used for encrypted range queries via the 'ore' index type.
+--! The ciphertext is stored in the 'ocv' field of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.compare_ore_cllw_var_8
+--! @note This is a transient type used only during query execution
+CREATE TYPE eql_v2.ore_cllw_var_8 AS (
+ bytes bytea
+);
+
+
+--! @brief Extract CLLW ORE index term from JSONB payload
+--!
+--! Extracts the CLLW ORE ciphertext from the 'ocf' field of an encrypted
+--! data payload. Used internally for range query comparisons.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.ore_cllw_u64_8 CLLW ORE ciphertext
+--! @throws Exception if 'ocf' field is missing when ore index is expected
+--!
+--! @see eql_v2.has_ore_cllw_u64_8
+--! @see eql_v2.compare_ore_cllw_u64_8
+CREATE FUNCTION eql_v2.ore_cllw_u64_8(val jsonb)
+ RETURNS eql_v2.ore_cllw_u64_8
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF NOT (eql_v2.has_ore_cllw_u64_8(val)) THEN
+ RAISE 'Expected a ore_cllw_u64_8 index (ocf) value in json: %', val;
+ END IF;
+
+ RETURN ROW(decode(val->>'ocf', 'hex'));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract CLLW ORE index term from encrypted column value
+--!
+--! Extracts the CLLW ORE ciphertext from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.ore_cllw_u64_8 CLLW ORE ciphertext
+--!
+--! @see eql_v2.ore_cllw_u64_8(jsonb)
+CREATE FUNCTION eql_v2.ore_cllw_u64_8(val eql_v2_encrypted)
+ RETURNS eql_v2.ore_cllw_u64_8
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.ore_cllw_u64_8(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains CLLW ORE index term
+--!
+--! Tests whether the encrypted data payload includes an 'ocf' field,
+--! indicating a CLLW ORE ciphertext is available for range queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'ocf' field is present and non-null
+--!
+--! @see eql_v2.ore_cllw_u64_8
+CREATE FUNCTION eql_v2.has_ore_cllw_u64_8(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'ocf' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains CLLW ORE index term
+--!
+--! Tests whether an encrypted column value includes a CLLW ORE ciphertext
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if CLLW ORE ciphertext is present
+--!
+--! @see eql_v2.has_ore_cllw_u64_8(jsonb)
+CREATE FUNCTION eql_v2.has_ore_cllw_u64_8(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_ore_cllw_u64_8(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Compare CLLW ORE ciphertext bytes
+--! @internal
+--!
+--! Byte-by-byte comparison of CLLW ORE ciphertexts implementing the CLLW
+--! comparison algorithm. Used by both fixed-width (ore_cllw_u64_8) and
+--! variable-width (ore_cllw_var_8) ORE variants.
+--!
+--! @param a Bytea First CLLW ORE ciphertext
+--! @param b Bytea Second CLLW ORE ciphertext
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--! @throws Exception if ciphertexts are different lengths
+--!
+--! @note Shared comparison logic for multiple ORE CLLW schemes
+--! @see eql_v2.compare_ore_cllw_u64_8
+CREATE FUNCTION eql_v2.compare_ore_cllw_term_bytes(a bytea, b bytea)
+RETURNS int AS $$
+DECLARE
+ len_a INT;
+ len_b INT;
+ x BYTEA;
+ y BYTEA;
+ i INT;
+ differing boolean;
+BEGIN
+
+ -- Check if the lengths of the two bytea arguments are the same
+ len_a := LENGTH(a);
+ len_b := LENGTH(b);
+
+ IF len_a != len_b THEN
+ RAISE EXCEPTION 'ore_cllw index terms are not the same length';
+ END IF;
+
+ -- Iterate over each byte and compare them
+ FOR i IN 1..len_a LOOP
+ x := SUBSTRING(a FROM i FOR 1);
+ y := SUBSTRING(b FROM i FOR 1);
+
+ -- Check if there's a difference
+ IF x != y THEN
+ differing := true;
+ EXIT;
+ END IF;
+ END LOOP;
+
+ -- If a difference is found, compare the bytes as in Rust logic
+ IF differing THEN
+ IF (get_byte(y, 0) + 1) % 256 = get_byte(x, 0) THEN
+ RETURN 1;
+ ELSE
+ RETURN -1;
+ END IF;
+ ELSE
+ RETURN 0;
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Blake3 hash index term type
+--!
+--! Domain type representing Blake3 cryptographic hash values.
+--! Used for exact-match encrypted searches via the 'unique' index type.
+--! The hash is stored in the 'b3' field of encrypted data payloads.
+--!
+--! @see eql_v2.add_search_config
+--! @note This is a transient type used only during query execution
+CREATE DOMAIN eql_v2.blake3 AS text;
+
+--! @brief Extract Blake3 hash index term from JSONB payload
+--!
+--! Extracts the Blake3 hash value from the 'b3' field of an encrypted
+--! data payload. Used internally for exact-match comparisons.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.blake3 Blake3 hash value, or NULL if not present
+--! @throws Exception if 'b3' field is missing when blake3 index is expected
+--!
+--! @see eql_v2.has_blake3
+--! @see eql_v2.compare_blake3
+CREATE FUNCTION eql_v2.blake3(val jsonb)
+ RETURNS eql_v2.blake3
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF NOT eql_v2.has_blake3(val) THEN
+ RAISE 'Expected a blake3 index (b3) value in json: %', val;
+ END IF;
+
+ IF val->>'b3' IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ RETURN val->>'b3';
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract Blake3 hash index term from encrypted column value
+--!
+--! Extracts the Blake3 hash from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.blake3 Blake3 hash value, or NULL if not present
+--!
+--! @see eql_v2.blake3(jsonb)
+CREATE FUNCTION eql_v2.blake3(val eql_v2_encrypted)
+ RETURNS eql_v2.blake3
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.blake3(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains Blake3 index term
+--!
+--! Tests whether the encrypted data payload includes a 'b3' field,
+--! indicating a Blake3 hash is available for exact-match queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'b3' field is present and non-null
+--!
+--! @see eql_v2.blake3
+CREATE FUNCTION eql_v2.has_blake3(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'b3' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains Blake3 index term
+--!
+--! Tests whether an encrypted column value includes a Blake3 hash
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if Blake3 hash is present
+--!
+--! @see eql_v2.has_blake3(jsonb)
+CREATE FUNCTION eql_v2.has_blake3(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_blake3(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract HMAC-SHA256 index term from JSONB payload
+--!
+--! Extracts the HMAC-SHA256 hash value from the 'hm' field of an encrypted
+--! data payload. Used internally for exact-match comparisons.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.hmac_256 HMAC-SHA256 hash value
+--! @throws Exception if 'hm' field is missing when hmac_256 index is expected
+--!
+--! @see eql_v2.has_hmac_256
+--! @see eql_v2.compare_hmac_256
+CREATE FUNCTION eql_v2.hmac_256(val jsonb)
+ RETURNS eql_v2.hmac_256
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF eql_v2.has_hmac_256(val) THEN
+ RETURN val->>'hm';
+ END IF;
+ RAISE 'Expected a hmac_256 index (hm) value in json: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains HMAC-SHA256 index term
+--!
+--! Tests whether the encrypted data payload includes an 'hm' field,
+--! indicating an HMAC-SHA256 hash is available for exact-match queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'hm' field is present and non-null
+--!
+--! @see eql_v2.hmac_256
+CREATE FUNCTION eql_v2.has_hmac_256(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'hm' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains HMAC-SHA256 index term
+--!
+--! Tests whether an encrypted column value includes an HMAC-SHA256 hash
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if HMAC-SHA256 hash is present
+--!
+--! @see eql_v2.has_hmac_256(jsonb)
+CREATE FUNCTION eql_v2.has_hmac_256(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_hmac_256(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Extract HMAC-SHA256 index term from encrypted column value
+--!
+--! Extracts the HMAC-SHA256 hash from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.hmac_256 HMAC-SHA256 hash value
+--!
+--! @see eql_v2.hmac_256(jsonb)
+CREATE FUNCTION eql_v2.hmac_256(val eql_v2_encrypted)
+ RETURNS eql_v2.hmac_256
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.hmac_256(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+
+--! @brief Convert JSONB array to ORE block composite type
+--! @internal
+--!
+--! Converts a JSONB array of hex-encoded ORE terms from the CipherStash Proxy
+--! payload into the PostgreSQL composite type used for ORE operations.
+--!
+--! @param val JSONB Array of hex-encoded ORE block terms
+--! @return eql_v2.ore_block_u64_8_256 ORE block composite type, or NULL if input is null
+--!
+--! @see eql_v2.ore_block_u64_8_256(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array_to_ore_block_u64_8_256(val jsonb)
+RETURNS eql_v2.ore_block_u64_8_256 AS $$
+DECLARE
+ terms eql_v2.ore_block_u64_8_256_term[];
+BEGIN
+ IF jsonb_typeof(val) = 'null' THEN
+ RETURN NULL;
+ END IF;
+
+ SELECT array_agg(ROW(b)::eql_v2.ore_block_u64_8_256_term)
+ INTO terms
+ FROM unnest(eql_v2.jsonb_array_to_bytea_array(val)) AS b;
+
+ RETURN ROW(terms)::eql_v2.ore_block_u64_8_256;
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract ORE block index term from JSONB payload
+--!
+--! Extracts the ORE block array from the 'ob' field of an encrypted
+--! data payload. Used internally for range query comparisons.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.ore_block_u64_8_256 ORE block index term
+--! @throws Exception if 'ob' field is missing when ore index is expected
+--!
+--! @see eql_v2.has_ore_block_u64_8_256
+--! @see eql_v2.compare_ore_block_u64_8_256
+CREATE FUNCTION eql_v2.ore_block_u64_8_256(val jsonb)
+ RETURNS eql_v2.ore_block_u64_8_256
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF eql_v2.has_ore_block_u64_8_256(val) THEN
+ RETURN eql_v2.jsonb_array_to_ore_block_u64_8_256(val->'ob');
+ END IF;
+ RAISE 'Expected an ore index (ob) value in json: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract ORE block index term from encrypted column value
+--!
+--! Extracts the ORE block from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.ore_block_u64_8_256 ORE block index term
+--!
+--! @see eql_v2.ore_block_u64_8_256(jsonb)
+CREATE FUNCTION eql_v2.ore_block_u64_8_256(val eql_v2_encrypted)
+ RETURNS eql_v2.ore_block_u64_8_256
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.ore_block_u64_8_256(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains ORE block index term
+--!
+--! Tests whether the encrypted data payload includes an 'ob' field,
+--! indicating an ORE block is available for range queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'ob' field is present and non-null
+--!
+--! @see eql_v2.ore_block_u64_8_256
+CREATE FUNCTION eql_v2.has_ore_block_u64_8_256(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'ob' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains ORE block index term
+--!
+--! Tests whether an encrypted column value includes an ORE block
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if ORE block is present
+--!
+--! @see eql_v2.has_ore_block_u64_8_256(jsonb)
+CREATE FUNCTION eql_v2.has_ore_block_u64_8_256(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_ore_block_u64_8_256(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Compare two ORE block terms using cryptographic comparison
+--! @internal
+--!
+--! Performs a three-way comparison (returns -1/0/1) of individual ORE block terms
+--! using the ORE cryptographic protocol. Compares PRP and PRF blocks to determine
+--! ordering without decryption.
+--!
+--! @param a eql_v2.ore_block_u64_8_256_term First ORE term to compare
+--! @param b eql_v2.ore_block_u64_8_256_term Second ORE term to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--! @throws Exception if ciphertexts are different lengths
+--!
+--! @note Uses AES-ECB encryption for bit comparisons per ORE protocol
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_term(a eql_v2.ore_block_u64_8_256_term, b eql_v2.ore_block_u64_8_256_term)
+ RETURNS integer
+AS $$
+ DECLARE
+ eq boolean := true;
+ unequal_block smallint := 0;
+ hash_key bytea;
+ data_block bytea;
+ encrypt_block bytea;
+ target_block bytea;
+
+ left_block_size CONSTANT smallint := 16;
+ right_block_size CONSTANT smallint := 32;
+ right_offset CONSTANT smallint := 136; -- 8 * 17
+
+ indicator smallint := 0;
+ BEGIN
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF bit_length(a.bytes) != bit_length(b.bytes) THEN
+ RAISE EXCEPTION 'Ciphertexts are different lengths';
+ END IF;
+
+ FOR block IN 0..7 LOOP
+ -- Compare each PRP (byte from the first 8 bytes) and PRF block (8 byte
+ -- chunks of the rest of the value).
+ -- NOTE:
+ -- * Substr is ordinally indexed (hence 1 and not 0, and 9 and not 8).
+ -- * We are not worrying about timing attacks here; don't fret about
+ -- the OR or !=.
+ IF
+ substr(a.bytes, 1 + block, 1) != substr(b.bytes, 1 + block, 1)
+ OR substr(a.bytes, 9 + left_block_size * block, left_block_size) != substr(b.bytes, 9 + left_block_size * BLOCK, left_block_size)
+ THEN
+ -- set the first unequal block we find
+ IF eq THEN
+ unequal_block := block;
+ END IF;
+ eq = false;
+ END IF;
+ END LOOP;
+
+ IF eq THEN
+ RETURN 0::integer;
+ END IF;
+
+ -- Hash key is the IV from the right CT of b
+ hash_key := substr(b.bytes, right_offset + 1, 16);
+
+ -- first right block is at right offset + nonce_size (ordinally indexed)
+ target_block := substr(b.bytes, right_offset + 17 + (unequal_block * right_block_size), right_block_size);
+
+ data_block := substr(a.bytes, 9 + (left_block_size * unequal_block), left_block_size);
+
+ encrypt_block := public.encrypt(data_block::bytea, hash_key::bytea, 'aes-ecb');
+
+ indicator := (
+ get_bit(
+ encrypt_block,
+ 0
+ ) + get_bit(target_block, get_byte(a.bytes, unequal_block))) % 2;
+
+ IF indicator = 1 THEN
+ RETURN 1::integer;
+ ELSE
+ RETURN -1::integer;
+ END IF;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Compare arrays of ORE block terms recursively
+--! @internal
+--!
+--! Recursively compares arrays of ORE block terms element-by-element.
+--! Empty arrays are considered less than non-empty arrays. If the first elements
+--! are equal, recursively compares remaining elements.
+--!
+--! @param a eql_v2.ore_block_u64_8_256_term[] First array of ORE terms
+--! @param b eql_v2.ore_block_u64_8_256_term[] Second array of ORE terms
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b, NULL if either array is NULL
+--!
+--! @note Empty arrays sort before non-empty arrays
+--! @see eql_v2.compare_ore_block_u64_8_256_term
+CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256_term[], b eql_v2.ore_block_u64_8_256_term[])
+RETURNS integer AS $$
+ DECLARE
+ cmp_result integer;
+ BEGIN
+
+ -- NULLs are NULL
+ IF a IS NULL OR b IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ -- empty a and b
+ IF cardinality(a) = 0 AND cardinality(b) = 0 THEN
+ RETURN 0;
+ END IF;
+
+ -- empty a and some b
+ IF (cardinality(a) = 0) AND cardinality(b) > 0 THEN
+ RETURN -1;
+ END IF;
+
+ -- some a and empty b
+ IF cardinality(a) > 0 AND (cardinality(b) = 0) THEN
+ RETURN 1;
+ END IF;
+
+ cmp_result := eql_v2.compare_ore_block_u64_8_256_term(a[1], b[1]);
+
+ IF cmp_result = 0 THEN
+ -- Removes the first element in the array, and calls this fn again to compare the next element/s in the array.
+ RETURN eql_v2.compare_ore_block_u64_8_256_terms(a[2:array_length(a,1)], b[2:array_length(b,1)]);
+ END IF;
+
+ RETURN cmp_result;
+ END
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Compare ORE block composite types
+--! @internal
+--!
+--! Wrapper function that extracts term arrays from ORE block composite types
+--! and delegates to the array comparison function.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 First ORE block
+--! @param b eql_v2.ore_block_u64_8_256 Second ORE block
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @see eql_v2.compare_ore_block_u64_8_256_terms(eql_v2.ore_block_u64_8_256_term[], eql_v2.ore_block_u64_8_256_term[])
+CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS integer AS $$
+ BEGIN
+ RETURN eql_v2.compare_ore_block_u64_8_256_terms(a.terms, b.terms);
+ END
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract variable-width CLLW ORE index term from JSONB payload
+--!
+--! Extracts the variable-width CLLW ORE ciphertext from the 'ocv' field of an encrypted
+--! data payload. Used internally for range query comparisons.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.ore_cllw_var_8 Variable-width CLLW ORE ciphertext
+--! @throws Exception if 'ocv' field is missing when ore index is expected
+--!
+--! @see eql_v2.has_ore_cllw_var_8
+--! @see eql_v2.compare_ore_cllw_var_8
+CREATE FUNCTION eql_v2.ore_cllw_var_8(val jsonb)
+ RETURNS eql_v2.ore_cllw_var_8
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF NOT (eql_v2.has_ore_cllw_var_8(val)) THEN
+ RAISE 'Expected a ore_cllw_var_8 index (ocv) value in json: %', val;
+ END IF;
+
+ RETURN ROW(decode(val->>'ocv', 'hex'));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract variable-width CLLW ORE index term from encrypted column value
+--!
+--! Extracts the variable-width CLLW ORE ciphertext from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.ore_cllw_var_8 Variable-width CLLW ORE ciphertext
+--!
+--! @see eql_v2.ore_cllw_var_8(jsonb)
+CREATE FUNCTION eql_v2.ore_cllw_var_8(val eql_v2_encrypted)
+ RETURNS eql_v2.ore_cllw_var_8
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.ore_cllw_var_8(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains variable-width CLLW ORE index term
+--!
+--! Tests whether the encrypted data payload includes an 'ocv' field,
+--! indicating a variable-width CLLW ORE ciphertext is available for range queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'ocv' field is present and non-null
+--!
+--! @see eql_v2.ore_cllw_var_8
+CREATE FUNCTION eql_v2.has_ore_cllw_var_8(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'ocv' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains variable-width CLLW ORE index term
+--!
+--! Tests whether an encrypted column value includes a variable-width CLLW ORE ciphertext
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if variable-width CLLW ORE ciphertext is present
+--!
+--! @see eql_v2.has_ore_cllw_var_8(jsonb)
+CREATE FUNCTION eql_v2.has_ore_cllw_var_8(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_ore_cllw_var_8(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Compare variable-width CLLW ORE ciphertext terms
+--! @internal
+--!
+--! Three-way comparison of variable-width CLLW ORE ciphertexts. Compares the common
+--! prefix using byte-by-byte CLLW comparison, then falls back to length comparison
+--! if the common prefix is equal. Used by compare_ore_cllw_var_8 for range queries.
+--!
+--! @param a eql_v2.ore_cllw_var_8 First variable-width CLLW ORE ciphertext
+--! @param b eql_v2.ore_cllw_var_8 Second variable-width CLLW ORE ciphertext
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note Handles variable-length ciphertexts by comparing common prefix first
+--! @note Returns NULL if either input is NULL
+--!
+--! @see eql_v2.compare_ore_cllw_term_bytes
+--! @see eql_v2.compare_ore_cllw_var_8
+CREATE FUNCTION eql_v2.compare_ore_cllw_var_8_term(a eql_v2.ore_cllw_var_8, b eql_v2.ore_cllw_var_8)
+RETURNS int AS $$
+DECLARE
+ len_a INT;
+ len_b INT;
+ -- length of the common part of the two bytea values
+ common_len INT;
+ cmp_result INT;
+BEGIN
+ IF a IS NULL OR b IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ -- Get the lengths of both bytea inputs
+ len_a := LENGTH(a.bytes);
+ len_b := LENGTH(b.bytes);
+
+ -- Handle empty cases
+ IF len_a = 0 AND len_b = 0 THEN
+ RETURN 0;
+ ELSIF len_a = 0 THEN
+ RETURN -1;
+ ELSIF len_b = 0 THEN
+ RETURN 1;
+ END IF;
+
+ -- Find the length of the shorter bytea
+ IF len_a < len_b THEN
+ common_len := len_a;
+ ELSE
+ common_len := len_b;
+ END IF;
+
+ -- Use the compare_ore_cllw_term function to compare byte by byte
+ cmp_result := eql_v2.compare_ore_cllw_term_bytes(
+ SUBSTRING(a.bytes FROM 1 FOR common_len),
+ SUBSTRING(b.bytes FROM 1 FOR common_len)
+ );
+
+ -- If the comparison returns 'less' or 'greater', return that result
+ IF cmp_result = -1 THEN
+ RETURN -1;
+ ELSIF cmp_result = 1 THEN
+ RETURN 1;
+ END IF;
+
+ -- If the bytea comparison is 'equal', compare lengths
+ IF len_a < len_b THEN
+ RETURN -1;
+ ELSIF len_a > len_b THEN
+ RETURN 1;
+ ELSE
+ RETURN 0;
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+
+
+
+--! @brief Core comparison function for encrypted values
+--!
+--! Compares two encrypted values using their index terms without decryption.
+--! This function implements all comparison operators required for btree indexing
+--! (<, <=, =, >=, >).
+--!
+--! Index terms are checked in the following priority order:
+--! 1. ore_block_u64_8_256 (Order-Revealing Encryption)
+--! 2. ore_cllw_u64_8 (Order-Revealing Encryption)
+--! 3. ore_cllw_var_8 (Order-Revealing Encryption)
+--! 4. hmac_256 (Hash-based equality)
+--! 5. blake3 (Hash-based equality)
+--!
+--! The first index term type present in both values is used for comparison.
+--! If no matching index terms are found, falls back to JSONB literal comparison
+--! to ensure consistent ordering (required for btree correctness).
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note Literal fallback prevents "lock BufferContent is not held" errors
+--! @see eql_v2.compare_ore_block_u64_8_256
+--! @see eql_v2.compare_blake3
+--! @see eql_v2.compare_hmac_256
+CREATE FUNCTION eql_v2.compare(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ a := eql_v2.to_ste_vec_value(a);
+ b := eql_v2.to_ste_vec_value(b);
+
+ IF eql_v2.has_ore_block_u64_8_256(a) AND eql_v2.has_ore_block_u64_8_256(b) THEN
+ RETURN eql_v2.compare_ore_block_u64_8_256(a, b);
+ END IF;
+
+ IF eql_v2.has_ore_cllw_u64_8(a) AND eql_v2.has_ore_cllw_u64_8(b) THEN
+ RETURN eql_v2.compare_ore_cllw_u64_8(a, b);
+ END IF;
+
+ IF eql_v2.has_ore_cllw_var_8(a) AND eql_v2.has_ore_cllw_var_8(b) THEN
+ RETURN eql_v2.compare_ore_cllw_var_8(a, b);
+ END IF;
+
+ IF eql_v2.has_hmac_256(a) AND eql_v2.has_hmac_256(b) THEN
+ RETURN eql_v2.compare_hmac_256(a, b);
+ END IF;
+
+ IF eql_v2.has_blake3(a) AND eql_v2.has_blake3(b) THEN
+ RETURN eql_v2.compare_blake3(a, b);
+ END IF;
+
+ -- Fallback to literal comparison of the encrypted data
+ -- Compare must have consistent ordering for a given state
+ -- Without this text fallback, database errors with "lock BufferContent is not held"
+ RETURN eql_v2.compare_literal(a, b);
+
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Convert JSONB to encrypted type
+--!
+--! Wraps a JSONB encrypted payload into the eql_v2_encrypted composite type.
+--! Used internally for type conversions and operator implementations.
+--!
+--! @param jsonb JSONB encrypted payload with structure: {"c": "...", "i": {...}, "k": "...", "v": "2"}
+--! @return eql_v2_encrypted Encrypted value wrapped in composite type
+--!
+--! @note This is primarily used for implicit casts in operator expressions
+--! @see eql_v2.to_jsonb
+CREATE FUNCTION eql_v2.to_encrypted(data jsonb)
+ RETURNS public.eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+BEGIN
+ IF data IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ RETURN ROW(data)::public.eql_v2_encrypted;
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Implicit cast from JSONB to encrypted type
+--!
+--! Enables PostgreSQL to automatically convert JSONB values to eql_v2_encrypted
+--! in assignment contexts and comparison operations.
+--!
+--! @see eql_v2.to_encrypted(jsonb)
+CREATE CAST (jsonb AS public.eql_v2_encrypted)
+ WITH FUNCTION eql_v2.to_encrypted(jsonb) AS ASSIGNMENT;
+
+
+--! @brief Convert text to encrypted type
+--!
+--! Parses a text representation of encrypted JSONB payload and wraps it
+--! in the eql_v2_encrypted composite type.
+--!
+--! @param text Text representation of JSONB encrypted payload
+--! @return eql_v2_encrypted Encrypted value wrapped in composite type
+--!
+--! @note Delegates to eql_v2.to_encrypted(jsonb) after parsing text as JSON
+--! @see eql_v2.to_encrypted(jsonb)
+CREATE FUNCTION eql_v2.to_encrypted(data text)
+ RETURNS public.eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+BEGIN
+ IF data IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ RETURN eql_v2.to_encrypted(data::jsonb);
+END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Implicit cast from text to encrypted type
+--!
+--! Enables PostgreSQL to automatically convert text JSON strings to eql_v2_encrypted
+--! in assignment contexts.
+--!
+--! @see eql_v2.to_encrypted(text)
+CREATE CAST (text AS public.eql_v2_encrypted)
+ WITH FUNCTION eql_v2.to_encrypted(text) AS ASSIGNMENT;
+
+
+
+--! @brief Convert encrypted type to JSONB
+--!
+--! Extracts the underlying JSONB payload from an eql_v2_encrypted composite type.
+--! Useful for debugging or when raw encrypted payload access is needed.
+--!
+--! @param e eql_v2_encrypted Encrypted value to unwrap
+--! @return jsonb Raw JSONB encrypted payload
+--!
+--! @note Returns the raw encrypted structure including ciphertext and index terms
+--! @see eql_v2.to_encrypted(jsonb)
+CREATE FUNCTION eql_v2.to_jsonb(e public.eql_v2_encrypted)
+ RETURNS jsonb
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+BEGIN
+ IF e IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ RETURN e.data;
+END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Implicit cast from encrypted type to JSONB
+--!
+--! Enables PostgreSQL to automatically extract the JSONB payload from
+--! eql_v2_encrypted values in assignment contexts.
+--!
+--! @see eql_v2.to_jsonb(eql_v2_encrypted)
+CREATE CAST (public.eql_v2_encrypted AS jsonb)
+ WITH FUNCTION eql_v2.to_jsonb(public.eql_v2_encrypted) AS ASSIGNMENT;
+
+
+
+--! @file config/types.sql
+--! @brief Configuration state type definition
+--!
+--! Defines the ENUM type for tracking encryption configuration lifecycle states.
+--! The configuration table uses this type to manage transitions between states
+--! during setup, activation, and encryption operations.
+--!
+--! @note CREATE TYPE does not support IF NOT EXISTS, so wrapped in DO block
+--! @note Configuration data stored as JSONB directly, not as DOMAIN
+--! @see config/tables.sql
+
+
+--! @brief Configuration lifecycle state
+--!
+--! Defines valid states for encryption configurations in the eql_v2_configuration table.
+--! Configurations transition through these states during setup and activation.
+--!
+--! @note Only one configuration can be in 'active', 'pending', or 'encrypting' state at once
+--! @see config/indexes.sql for uniqueness enforcement
+--! @see config/tables.sql for usage in eql_v2_configuration table
+DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'eql_v2_configuration_state') THEN
+ CREATE TYPE public.eql_v2_configuration_state AS ENUM ('active', 'inactive', 'encrypting', 'pending');
+ END IF;
+ END
+$$;
+
+
+
+--! @brief Extract Bloom filter index term from JSONB payload
+--!
+--! Extracts the Bloom filter array from the 'bf' field of an encrypted
+--! data payload. Used internally for pattern-match queries (LIKE operator).
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2.bloom_filter Bloom filter as smallint array
+--! @throws Exception if 'bf' field is missing when bloom_filter index is expected
+--!
+--! @see eql_v2.has_bloom_filter
+--! @see eql_v2."~~"
+CREATE FUNCTION eql_v2.bloom_filter(val jsonb)
+ RETURNS eql_v2.bloom_filter
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF eql_v2.has_bloom_filter(val) THEN
+ RETURN ARRAY(SELECT jsonb_array_elements(val->'bf'))::eql_v2.bloom_filter;
+ END IF;
+
+ RAISE 'Expected a match index (bf) value in json: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract Bloom filter index term from encrypted column value
+--!
+--! Extracts the Bloom filter from an encrypted column value by accessing
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2.bloom_filter Bloom filter as smallint array
+--!
+--! @see eql_v2.bloom_filter(jsonb)
+CREATE FUNCTION eql_v2.bloom_filter(val eql_v2_encrypted)
+ RETURNS eql_v2.bloom_filter
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.bloom_filter(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if JSONB payload contains Bloom filter index term
+--!
+--! Tests whether the encrypted data payload includes a 'bf' field,
+--! indicating a Bloom filter is available for pattern-match queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'bf' field is present and non-null
+--!
+--! @see eql_v2.bloom_filter
+CREATE FUNCTION eql_v2.has_bloom_filter(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN val ->> 'bf' IS NOT NULL;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value contains Bloom filter index term
+--!
+--! Tests whether an encrypted column value includes a Bloom filter
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if Bloom filter is present
+--!
+--! @see eql_v2.has_bloom_filter(jsonb)
+CREATE FUNCTION eql_v2.has_bloom_filter(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.has_bloom_filter(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Fallback literal comparison for encrypted values
+--! @internal
+--!
+--! Compares two encrypted values by their raw JSONB representation when no
+--! suitable index terms are available. This ensures consistent ordering required
+--! for btree correctness and prevents "lock BufferContent is not held" errors.
+--!
+--! Used as a last resort fallback in eql_v2.compare() when encrypted values
+--! lack matching index terms (blake3, hmac_256, ore).
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note This compares the encrypted payloads directly, not the plaintext values
+--! @note Ordering is consistent but not meaningful for range queries
+--! @see eql_v2.compare
+CREATE FUNCTION eql_v2.compare_literal(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_data jsonb;
+ b_data jsonb;
+ BEGIN
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ a_data := a.data;
+ b_data := b.data;
+
+ IF a_data < b_data THEN
+ RETURN -1;
+ END IF;
+
+ IF a_data > b_data THEN
+ RETURN 1;
+ END IF;
+
+ RETURN 0;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Less-than comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for less-than testing.
+--! Returns true if first value is less than second using ORE comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if a < b (compare result = -1)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2."<"
+CREATE FUNCTION eql_v2.lt(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) = -1;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Less-than operator for encrypted values
+--!
+--! Implements the < operator for comparing two encrypted values using Order-Revealing
+--! Encryption (ORE) index terms. Enables range queries and sorting without decryption.
+--! Requires 'ore' index configuration on the column.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if a is less than b
+--!
+--! @example
+--! -- Range query on encrypted timestamps
+--! SELECT * FROM events
+--! WHERE encrypted_timestamp < '2024-01-01'::timestamp::text::eql_v2_encrypted;
+--!
+--! -- Compare encrypted numeric columns
+--! SELECT * FROM products WHERE encrypted_price < encrypted_discount_price;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2."<"(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lt(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <(
+ FUNCTION=eql_v2."<",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ COMMUTATOR = >,
+ NEGATOR = >=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief Less-than operator for encrypted value and JSONB
+--!
+--! Overload of < operator accepting JSONB on the right side. Automatically
+--! casts JSONB to eql_v2_encrypted for ORE comparison.
+--!
+--! @param eql_v2_encrypted Left operand (encrypted value)
+--! @param b JSONB Right operand (will be cast to eql_v2_encrypted)
+--! @return Boolean True if a < b
+--!
+--! @example
+--! SELECT * FROM events WHERE encrypted_age < '18'::int::text::jsonb;
+--!
+--! @see eql_v2."<"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<"(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lt(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <(
+ FUNCTION=eql_v2."<",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ COMMUTATOR = >,
+ NEGATOR = >=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief Less-than operator for JSONB and encrypted value
+--!
+--! Overload of < operator accepting JSONB on the left side. Automatically
+--! casts JSONB to eql_v2_encrypted for ORE comparison.
+--!
+--! @param a JSONB Left operand (will be cast to eql_v2_encrypted)
+--! @param eql_v2_encrypted Right operand (encrypted value)
+--! @return Boolean True if a < b
+--!
+--! @example
+--! SELECT * FROM events WHERE '2023-01-01'::date::text::jsonb < encrypted_date;
+--!
+--! @see eql_v2."<"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<"(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lt(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR <(
+ FUNCTION=eql_v2."<",
+ LEFTARG=jsonb,
+ RIGHTARG=eql_v2_encrypted,
+ COMMUTATOR = >,
+ NEGATOR = >=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+
+
+--! @brief Less-than-or-equal comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for <= testing.
+--! Returns true if first value is less than or equal to second using ORE comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if a <= b (compare result <= 0)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2."<="
+CREATE FUNCTION eql_v2.lte(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) <= 0;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Less-than-or-equal operator for encrypted values
+--!
+--! Implements the <= operator for comparing encrypted values using ORE index terms.
+--! Enables range queries with inclusive lower bounds without decryption.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if a <= b
+--!
+--! @example
+--! -- Find records with encrypted age 18 or under
+--! SELECT * FROM users WHERE encrypted_age <= '18'::int::text::eql_v2_encrypted;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2."<="(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lte(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <=(
+ FUNCTION = eql_v2."<=",
+ LEFTARG = eql_v2_encrypted,
+ RIGHTARG = eql_v2_encrypted,
+ COMMUTATOR = >=,
+ NEGATOR = >,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief <= operator for encrypted value and JSONB
+--! @see eql_v2."<="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<="(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lte(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <=(
+ FUNCTION = eql_v2."<=",
+ LEFTARG = eql_v2_encrypted,
+ RIGHTARG = jsonb,
+ COMMUTATOR = >=,
+ NEGATOR = >,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief <= operator for JSONB and encrypted value
+--! @see eql_v2."<="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<="(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.lte(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR <=(
+ FUNCTION = eql_v2."<=",
+ LEFTARG = jsonb,
+ RIGHTARG = eql_v2_encrypted,
+ COMMUTATOR = >=,
+ NEGATOR = >,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+
+
+--! @brief Equality comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for equality testing.
+--! Returns true if encrypted values are equal via encrypted index comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if values are equal (compare result = 0)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2."="
+CREATE FUNCTION eql_v2.eq(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) = 0;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Equality operator for encrypted values
+--!
+--! Implements the = operator for comparing two encrypted values using their
+--! encrypted index terms (unique/blake3). Enables WHERE clause comparisons
+--! without decryption.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if encrypted values are equal
+--!
+--! @example
+--! -- Compare encrypted columns
+--! SELECT * FROM users WHERE encrypted_email = other_encrypted_email;
+--!
+--! -- Search using encrypted literal
+--! SELECT * FROM users
+--! WHERE encrypted_email = '{"c":"...","i":{"unique":"..."}}'::eql_v2_encrypted;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2."="(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.eq(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR = (
+ FUNCTION=eql_v2."=",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ NEGATOR = <>,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief Equality operator for encrypted value and JSONB
+--!
+--! Overload of = operator accepting JSONB on the right side. Automatically
+--! casts JSONB to eql_v2_encrypted for comparison. Useful for comparing
+--! against JSONB literals or columns.
+--!
+--! @param eql_v2_encrypted Left operand (encrypted value)
+--! @param b JSONB Right operand (will be cast to eql_v2_encrypted)
+--! @return Boolean True if values are equal
+--!
+--! @example
+--! -- Compare encrypted column to JSONB literal
+--! SELECT * FROM users
+--! WHERE encrypted_email = '{"c":"...","i":{"unique":"..."}}'::jsonb;
+--!
+--! @see eql_v2."="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."="(a eql_v2_encrypted, b jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.eq(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR = (
+ FUNCTION=eql_v2."=",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ NEGATOR = <>,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief Equality operator for JSONB and encrypted value
+--!
+--! Overload of = operator accepting JSONB on the left side. Automatically
+--! casts JSONB to eql_v2_encrypted for comparison. Enables commutative
+--! equality comparisons.
+--!
+--! @param a JSONB Left operand (will be cast to eql_v2_encrypted)
+--! @param eql_v2_encrypted Right operand (encrypted value)
+--! @return Boolean True if values are equal
+--!
+--! @example
+--! -- Compare JSONB literal to encrypted column
+--! SELECT * FROM users
+--! WHERE '{"c":"...","i":{"unique":"..."}}'::jsonb = encrypted_email;
+--!
+--! @see eql_v2."="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."="(a jsonb, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.eq(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR = (
+ FUNCTION=eql_v2."=",
+ LEFTARG=jsonb,
+ RIGHTARG=eql_v2_encrypted,
+ NEGATOR = <>,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+
+--! @brief Greater-than-or-equal comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for >= testing.
+--! Returns true if first value is greater than or equal to second using ORE comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if a >= b (compare result >= 0)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.">="
+CREATE FUNCTION eql_v2.gte(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) >= 0;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Greater-than-or-equal operator for encrypted values
+--!
+--! Implements the >= operator for comparing encrypted values using ORE index terms.
+--! Enables range queries with inclusive upper bounds without decryption.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if a >= b
+--!
+--! @example
+--! -- Find records with age 18 or over
+--! SELECT * FROM users WHERE encrypted_age >= '18'::int::text::eql_v2_encrypted;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.">="(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gte(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR >=(
+ FUNCTION = eql_v2.">=",
+ LEFTARG = eql_v2_encrypted,
+ RIGHTARG = eql_v2_encrypted,
+ COMMUTATOR = <=,
+ NEGATOR = <,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief >= operator for encrypted value and JSONB
+--! @see eql_v2.">="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.">="(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gte(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR >=(
+ FUNCTION = eql_v2.">=",
+ LEFTARG = eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ COMMUTATOR = <=,
+ NEGATOR = <,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief >= operator for JSONB and encrypted value
+--! @see eql_v2.">="(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.">="(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gte(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR >=(
+ FUNCTION = eql_v2.">=",
+ LEFTARG = jsonb,
+ RIGHTARG =eql_v2_encrypted,
+ COMMUTATOR = <=,
+ NEGATOR = <,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+
+
+--! @brief Greater-than comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for greater-than testing.
+--! Returns true if first value is greater than second using ORE comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if a > b (compare result = 1)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.">"
+CREATE FUNCTION eql_v2.gt(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) = 1;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Greater-than operator for encrypted values
+--!
+--! Implements the > operator for comparing encrypted values using ORE index terms.
+--! Enables range queries and sorting without decryption. Requires 'ore' index
+--! configuration on the column.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if a is greater than b
+--!
+--! @example
+--! -- Find records above threshold
+--! SELECT * FROM events
+--! WHERE encrypted_value > '100'::int::text::eql_v2_encrypted;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.">"(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gt(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR >(
+ FUNCTION=eql_v2.">",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ COMMUTATOR = <,
+ NEGATOR = <=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief > operator for encrypted value and JSONB
+--! @see eql_v2.">"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.">"(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gt(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR >(
+ FUNCTION = eql_v2.">",
+ LEFTARG = eql_v2_encrypted,
+ RIGHTARG = jsonb,
+ COMMUTATOR = <,
+ NEGATOR = <=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+--! @brief > operator for JSONB and encrypted value
+--! @see eql_v2.">"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.">"(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.gt(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR >(
+ FUNCTION = eql_v2.">",
+ LEFTARG = jsonb,
+ RIGHTARG = eql_v2_encrypted,
+ COMMUTATOR = <,
+ NEGATOR = <=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+
+
+
+--! @brief Extract STE vector index from JSONB payload
+--!
+--! Extracts the STE (Searchable Symmetric Encryption) vector from the 'sv' field
+--! of an encrypted data payload. Returns an array of encrypted values used for
+--! containment queries (@>, <@). If no 'sv' field exists, wraps the entire payload
+--! as a single-element array.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2_encrypted[] Array of encrypted STE vector elements
+--!
+--! @see eql_v2.ste_vec(eql_v2_encrypted)
+--! @see eql_v2.ste_vec_contains
+CREATE FUNCTION eql_v2.ste_vec(val jsonb)
+ RETURNS public.eql_v2_encrypted[]
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv jsonb;
+ ary public.eql_v2_encrypted[];
+ BEGIN
+
+ IF val ? 'sv' THEN
+ sv := val->'sv';
+ ELSE
+ sv := jsonb_build_array(val);
+ END IF;
+
+ SELECT array_agg(eql_v2.to_encrypted(elem))
+ INTO ary
+ FROM jsonb_array_elements(sv) AS elem;
+
+ RETURN ary;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract STE vector index from encrypted column value
+--!
+--! Extracts the STE vector from an encrypted column value by accessing its
+--! underlying JSONB data field. Used for containment query operations.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2_encrypted[] Array of encrypted STE vector elements
+--!
+--! @see eql_v2.ste_vec(jsonb)
+CREATE FUNCTION eql_v2.ste_vec(val eql_v2_encrypted)
+ RETURNS public.eql_v2_encrypted[]
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.ste_vec(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Check if JSONB payload is a single-element STE vector
+--!
+--! Tests whether the encrypted data payload contains an 'sv' field with exactly
+--! one element. Single-element STE vectors can be treated as regular encrypted values.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'sv' field exists with exactly one element
+--!
+--! @see eql_v2.to_ste_vec_value
+CREATE FUNCTION eql_v2.is_ste_vec_value(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val ? 'sv' THEN
+ RETURN jsonb_array_length(val->'sv') = 1;
+ END IF;
+
+ RETURN false;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Check if encrypted column value is a single-element STE vector
+--!
+--! Tests whether an encrypted column value is a single-element STE vector
+--! by checking its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if value is a single-element STE vector
+--!
+--! @see eql_v2.is_ste_vec_value(jsonb)
+CREATE FUNCTION eql_v2.is_ste_vec_value(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.is_ste_vec_value(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Convert single-element STE vector to regular encrypted value
+--!
+--! Extracts the single element from a single-element STE vector and returns it
+--! as a regular encrypted value, preserving metadata. If the input is not a
+--! single-element STE vector, returns it unchanged.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return eql_v2_encrypted Regular encrypted value (unwrapped if single-element STE vector)
+--!
+--! @see eql_v2.is_ste_vec_value
+CREATE FUNCTION eql_v2.to_ste_vec_value(val jsonb)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ meta jsonb;
+ sv jsonb;
+ BEGIN
+
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF eql_v2.is_ste_vec_value(val) THEN
+ meta := eql_v2.meta_data(val);
+ sv := val->'sv';
+ sv := sv[0];
+
+ RETURN eql_v2.to_encrypted(meta || sv);
+ END IF;
+
+ RETURN eql_v2.to_encrypted(val);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Convert single-element STE vector to regular encrypted value (encrypted type)
+--!
+--! Converts an encrypted column value to a regular encrypted value by unwrapping
+--! if it's a single-element STE vector.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return eql_v2_encrypted Regular encrypted value (unwrapped if single-element STE vector)
+--!
+--! @see eql_v2.to_ste_vec_value(jsonb)
+CREATE FUNCTION eql_v2.to_ste_vec_value(val eql_v2_encrypted)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.to_ste_vec_value(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Extract selector value from JSONB payload
+--!
+--! Extracts the selector ('s') field from an encrypted data payload.
+--! Selectors are used to match STE vector elements during containment queries.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Text The selector value
+--! @throws Exception if 's' field is missing
+--!
+--! @see eql_v2.ste_vec_contains
+CREATE FUNCTION eql_v2.selector(val jsonb)
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF val ? 's' THEN
+ RETURN val->>'s';
+ END IF;
+ RAISE 'Expected a selector index (s) value in json: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract selector value from encrypted column value
+--!
+--! Extracts the selector from an encrypted column value by accessing its
+--! underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Text The selector value
+--!
+--! @see eql_v2.selector(jsonb)
+CREATE FUNCTION eql_v2.selector(val eql_v2_encrypted)
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.selector(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Check if JSONB payload is marked as an STE vector array
+--!
+--! Tests whether the encrypted data payload has the 'a' (array) flag set to true,
+--! indicating it represents an array for STE vector operations.
+--!
+--! @param jsonb containing encrypted EQL payload
+--! @return Boolean True if 'a' field is present and true
+--!
+--! @see eql_v2.ste_vec
+CREATE FUNCTION eql_v2.is_ste_vec_array(val jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ IF val ? 'a' THEN
+ RETURN (val->>'a')::boolean;
+ END IF;
+
+ RETURN false;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted column value is marked as an STE vector array
+--!
+--! Tests whether an encrypted column value has the array flag set by checking
+--! its underlying JSONB data field.
+--!
+--! @param eql_v2_encrypted Encrypted column value
+--! @return Boolean True if value is marked as an STE vector array
+--!
+--! @see eql_v2.is_ste_vec_array(jsonb)
+CREATE FUNCTION eql_v2.is_ste_vec_array(val eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (SELECT eql_v2.is_ste_vec_array(val.data));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Extract full encrypted JSONB elements as array
+--!
+--! Extracts all JSONB elements from the STE vector including non-deterministic fields.
+--! Use jsonb_array() instead for GIN indexing and containment queries.
+--!
+--! @param val jsonb containing encrypted EQL payload
+--! @return jsonb[] Array of full JSONB elements
+--!
+--! @see eql_v2.jsonb_array
+CREATE FUNCTION eql_v2.jsonb_array_from_array_elements(val jsonb)
+RETURNS jsonb[]
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT CASE
+ WHEN val ? 'sv' THEN
+ ARRAY(SELECT elem FROM jsonb_array_elements(val->'sv') AS elem)
+ ELSE
+ ARRAY[val]
+ END;
+$$;
+
+
+--! @brief Extract full encrypted JSONB elements as array from encrypted column
+--!
+--! @param val eql_v2_encrypted Encrypted column value
+--! @return jsonb[] Array of full JSONB elements
+--!
+--! @see eql_v2.jsonb_array_from_array_elements(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array_from_array_elements(val eql_v2_encrypted)
+RETURNS jsonb[]
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array_from_array_elements(val.data);
+$$;
+
+
+--! @brief Extract deterministic fields as array for GIN indexing
+--!
+--! Extracts only deterministic search term fields (s, b3, hm, ocv, ocf) from each
+--! STE vector element. Excludes non-deterministic ciphertext for correct containment
+--! comparison using PostgreSQL's native @> operator.
+--!
+--! @param val jsonb containing encrypted EQL payload
+--! @return jsonb[] Array of JSONB elements with only deterministic fields
+--!
+--! @note Use this for GIN indexes and containment queries
+--! @see eql_v2.jsonb_contains
+CREATE FUNCTION eql_v2.jsonb_array(val jsonb)
+RETURNS jsonb[]
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT ARRAY(
+ SELECT jsonb_object_agg(kv.key, kv.value)
+ FROM jsonb_array_elements(
+ CASE WHEN val ? 'sv' THEN val->'sv' ELSE jsonb_build_array(val) END
+ ) AS elem,
+ LATERAL jsonb_each(elem) AS kv(key, value)
+ WHERE kv.key IN ('s', 'b3', 'hm', 'ocv', 'ocf')
+ GROUP BY elem
+ );
+$$;
+
+
+--! @brief Extract deterministic fields as array from encrypted column
+--!
+--! @param val eql_v2_encrypted Encrypted column value
+--! @return jsonb[] Array of JSONB elements with only deterministic fields
+--!
+--! @see eql_v2.jsonb_array(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array(val eql_v2_encrypted)
+RETURNS jsonb[]
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(val.data);
+$$;
+
+
+--! @brief GIN-indexable JSONB containment check
+--!
+--! Checks if encrypted value 'a' contains all JSONB elements from 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! This function is designed for use with a GIN index on jsonb_array(column).
+--! When combined with such an index, PostgreSQL can efficiently search large tables.
+--!
+--! @param a eql_v2_encrypted Container value (typically a table column)
+--! @param b eql_v2_encrypted Value to search for
+--! @return Boolean True if a contains all elements of b
+--!
+--! @example
+--! -- Create GIN index for efficient containment queries
+--! CREATE INDEX idx ON mytable USING GIN (eql_v2.jsonb_array(encrypted_col));
+--!
+--! -- Query using the helper function
+--! SELECT * FROM mytable WHERE eql_v2.jsonb_contains(encrypted_col, search_value);
+--!
+--! @see eql_v2.jsonb_array
+CREATE FUNCTION eql_v2.jsonb_contains(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief GIN-indexable JSONB containment check (encrypted, jsonb)
+--!
+--! Checks if encrypted value 'a' contains all JSONB elements from jsonb value 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! @param a eql_v2_encrypted Container value (typically a table column)
+--! @param b jsonb JSONB value to search for
+--! @return Boolean True if a contains all elements of b
+--!
+--! @see eql_v2.jsonb_array
+--! @see eql_v2.jsonb_contains(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.jsonb_contains(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief GIN-indexable JSONB containment check (jsonb, encrypted)
+--!
+--! Checks if jsonb value 'a' contains all JSONB elements from encrypted value 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! @param a jsonb Container JSONB value
+--! @param b eql_v2_encrypted Encrypted value to search for
+--! @return Boolean True if a contains all elements of b
+--!
+--! @see eql_v2.jsonb_array
+--! @see eql_v2.jsonb_contains(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.jsonb_contains(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief GIN-indexable JSONB "is contained by" check
+--!
+--! Checks if all JSONB elements from 'a' are contained in 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! @param a eql_v2_encrypted Value to check (typically a table column)
+--! @param b eql_v2_encrypted Container value
+--! @return Boolean True if all elements of a are contained in b
+--!
+--! @see eql_v2.jsonb_array
+--! @see eql_v2.jsonb_contains
+CREATE FUNCTION eql_v2.jsonb_contained_by(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief GIN-indexable JSONB "is contained by" check (encrypted, jsonb)
+--!
+--! Checks if all JSONB elements from encrypted value 'a' are contained in jsonb value 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! @param a eql_v2_encrypted Value to check (typically a table column)
+--! @param b jsonb Container JSONB value
+--! @return Boolean True if all elements of a are contained in b
+--!
+--! @see eql_v2.jsonb_array
+--! @see eql_v2.jsonb_contained_by(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.jsonb_contained_by(a eql_v2_encrypted, b jsonb)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief GIN-indexable JSONB "is contained by" check (jsonb, encrypted)
+--!
+--! Checks if all JSONB elements from jsonb value 'a' are contained in encrypted value 'b'.
+--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.
+--!
+--! @param a jsonb Value to check
+--! @param b eql_v2_encrypted Container encrypted value
+--! @return Boolean True if all elements of a are contained in b
+--!
+--! @see eql_v2.jsonb_array
+--! @see eql_v2.jsonb_contained_by(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.jsonb_contained_by(a jsonb, b eql_v2_encrypted)
+RETURNS boolean
+IMMUTABLE STRICT PARALLEL SAFE
+LANGUAGE SQL
+AS $$
+ SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b);
+$$;
+
+
+--! @brief Check if STE vector array contains a specific encrypted element
+--!
+--! Tests whether any element in the STE vector array 'a' contains the encrypted value 'b'.
+--! Matching requires both the selector and encrypted value to be equal.
+--! Used internally by ste_vec_contains(encrypted, encrypted) for array containment checks.
+--!
+--! @param eql_v2_encrypted[] STE vector array to search within
+--! @param eql_v2_encrypted Encrypted element to search for
+--! @return Boolean True if b is found in any element of a
+--!
+--! @note Compares both selector and encrypted value for match
+--!
+--! @see eql_v2.selector
+--! @see eql_v2.ste_vec_contains(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2.ste_vec_contains(a public.eql_v2_encrypted[], b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ result boolean;
+ _a public.eql_v2_encrypted;
+ BEGIN
+
+ result := false;
+
+ FOR idx IN 1..array_length(a, 1) LOOP
+ _a := a[idx];
+ result := result OR (eql_v2.selector(_a) = eql_v2.selector(b) AND _a = b);
+ END LOOP;
+
+ RETURN result;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check if encrypted value 'a' contains all elements of encrypted value 'b'
+--!
+--! Performs STE vector containment comparison between two encrypted values.
+--! Returns true if all elements in b's STE vector are found in a's STE vector.
+--! Used internally by the @> containment operator for searchable encryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value (container)
+--! @param b eql_v2_encrypted Second encrypted value (elements to find)
+--! @return Boolean True if all elements of b are contained in a
+--!
+--! @note Empty b is always contained in any a
+--! @note Each element of b must match both selector and value in a
+--!
+--! @see eql_v2.ste_vec
+--! @see eql_v2.ste_vec_contains(eql_v2_encrypted[], eql_v2_encrypted)
+--! @see eql_v2."@>"
+CREATE FUNCTION eql_v2.ste_vec_contains(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ result boolean;
+ sv_a public.eql_v2_encrypted[];
+ sv_b public.eql_v2_encrypted[];
+ _b public.eql_v2_encrypted;
+ BEGIN
+
+ -- jsonb arrays of ste_vec encrypted values
+ sv_a := eql_v2.ste_vec(a);
+ sv_b := eql_v2.ste_vec(b);
+
+ -- an empty b is always contained in a
+ IF array_length(sv_b, 1) IS NULL THEN
+ RETURN true;
+ END IF;
+
+ IF array_length(sv_a, 1) IS NULL THEN
+ RETURN false;
+ END IF;
+
+ result := true;
+
+ -- for each element of b check if it is in a
+ FOR idx IN 1..array_length(sv_b, 1) LOOP
+ _b := sv_b[idx];
+ result := result AND eql_v2.ste_vec_contains(sv_a, _b);
+ END LOOP;
+
+ RETURN result;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @file config/tables.sql
+--! @brief Encryption configuration storage table
+--!
+--! Defines the main table for storing EQL v2 encryption configurations.
+--! Each row represents a configuration specifying which tables/columns to encrypt
+--! and what index types to use. Configurations progress through lifecycle states.
+--!
+--! @see config/types.sql for state ENUM definition
+--! @see config/indexes.sql for state uniqueness constraints
+--! @see config/constraints.sql for data validation
+
+
+--! @brief Encryption configuration table
+--!
+--! Stores encryption configurations with their state and metadata.
+--! The 'data' JSONB column contains the full configuration structure including
+--! table/column mappings, index types, and casting rules.
+--!
+--! @note Only one configuration can be 'active', 'pending', or 'encrypting' at once
+--! @note 'id' is auto-generated identity column
+--! @note 'state' defaults to 'pending' for new configurations
+--! @note 'data' validated by CHECK constraint (see config/constraints.sql)
+CREATE TABLE IF NOT EXISTS public.eql_v2_configuration
+(
+ id bigint GENERATED ALWAYS AS IDENTITY,
+ state eql_v2_configuration_state NOT NULL DEFAULT 'pending',
+ data jsonb,
+ created_at timestamptz not null default current_timestamp,
+ PRIMARY KEY(id)
+);
+
+
+--! @brief Initialize default configuration structure
+--! @internal
+--!
+--! Creates a default configuration object if input is NULL. Used internally
+--! by public configuration functions to ensure consistent structure.
+--!
+--! @param config JSONB Existing configuration or NULL
+--! @return JSONB Configuration with default structure (version 1, empty tables)
+CREATE FUNCTION eql_v2.config_default(config jsonb)
+ RETURNS jsonb
+ IMMUTABLE PARALLEL SAFE
+AS $$
+ BEGIN
+ IF config IS NULL THEN
+ SELECT jsonb_build_object('v', 1, 'tables', jsonb_build_object()) INTO config;
+ END IF;
+ RETURN config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Add table to configuration if not present
+--! @internal
+--!
+--! Ensures the specified table exists in the configuration structure.
+--! Creates empty table entry if needed. Idempotent operation.
+--!
+--! @param table_name Text Name of table to add
+--! @param config JSONB Configuration object
+--! @return JSONB Updated configuration with table entry
+CREATE FUNCTION eql_v2.config_add_table(table_name text, config jsonb)
+ RETURNS jsonb
+ IMMUTABLE PARALLEL SAFE
+AS $$
+ DECLARE
+ tbl jsonb;
+ BEGIN
+ IF NOT config #> array['tables'] ? table_name THEN
+ SELECT jsonb_insert(config, array['tables', table_name], jsonb_build_object()) INTO config;
+ END IF;
+ RETURN config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Add column to table configuration if not present
+--! @internal
+--!
+--! Ensures the specified column exists in the table's configuration structure.
+--! Creates empty column entry with indexes object if needed. Idempotent operation.
+--!
+--! @param table_name Text Name of parent table
+--! @param column_name Text Name of column to add
+--! @param config JSONB Configuration object
+--! @return JSONB Updated configuration with column entry
+CREATE FUNCTION eql_v2.config_add_column(table_name text, column_name text, config jsonb)
+ RETURNS jsonb
+ IMMUTABLE PARALLEL SAFE
+AS $$
+ DECLARE
+ col jsonb;
+ BEGIN
+ IF NOT config #> array['tables', table_name] ? column_name THEN
+ SELECT jsonb_build_object('indexes', jsonb_build_object()) into col;
+ SELECT jsonb_set(config, array['tables', table_name, column_name], col) INTO config;
+ END IF;
+ RETURN config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Set cast type for column in configuration
+--! @internal
+--!
+--! Updates the cast_as field for a column, specifying the PostgreSQL type
+--! that decrypted values should be cast to.
+--!
+--! @param table_name Text Name of parent table
+--! @param column_name Text Name of column
+--! @param cast_as Text PostgreSQL type for casting (e.g., 'text', 'int', 'jsonb')
+--! @param config JSONB Configuration object
+--! @return JSONB Updated configuration with cast_as set
+CREATE FUNCTION eql_v2.config_add_cast(table_name text, column_name text, cast_as text, config jsonb)
+ RETURNS jsonb
+ IMMUTABLE PARALLEL SAFE
+AS $$
+ BEGIN
+ SELECT jsonb_set(config, array['tables', table_name, column_name, 'cast_as'], to_jsonb(cast_as)) INTO config;
+ RETURN config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Add search index to column configuration
+--! @internal
+--!
+--! Inserts a search index entry (unique, match, ore, ste_vec) with its options
+--! into the column's indexes object.
+--!
+--! @param table_name Text Name of parent table
+--! @param column_name Text Name of column
+--! @param index_name Text Type of index to add
+--! @param opts JSONB Index-specific options
+--! @param config JSONB Configuration object
+--! @return JSONB Updated configuration with index added
+CREATE FUNCTION eql_v2.config_add_index(table_name text, column_name text, index_name text, opts jsonb, config jsonb)
+ RETURNS jsonb
+ IMMUTABLE PARALLEL SAFE
+AS $$
+ BEGIN
+ SELECT jsonb_insert(config, array['tables', table_name, column_name, 'indexes', index_name], opts) INTO config;
+ RETURN config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Generate default options for match index
+--! @internal
+--!
+--! Returns default configuration for match (LIKE) indexes: k=6, bf=2048,
+--! ngram tokenizer with token_length=3, downcase filter, include_original=true.
+--!
+--! @return JSONB Default match index options
+CREATE FUNCTION eql_v2.config_match_default()
+ RETURNS jsonb
+LANGUAGE sql STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ SELECT jsonb_build_object(
+ 'k', 6,
+ 'bf', 2048,
+ 'include_original', true,
+ 'tokenizer', json_build_object('kind', 'ngram', 'token_length', 3),
+ 'token_filters', json_build_array(json_build_object('kind', 'downcase')));
+END;
+-- AUTOMATICALLY GENERATED FILE
+-- Source is version-template.sql
+
+DROP FUNCTION IF EXISTS eql_v2.version();
+
+--! @file version.sql
+--! @brief EQL version reporting
+--!
+--! This file is auto-generated from version.template during build.
+--! The version string placeholder is replaced with the actual release version.
+
+--! @brief Get EQL library version string
+--!
+--! Returns the version string for the installed EQL library.
+--! This value is set at build time from the project version.
+--!
+--! @return text Version string (e.g., "2.1.0" or "DEV" for development builds)
+--!
+--! @note Auto-generated during build from version.template
+--!
+--! @example
+--! -- Check installed EQL version
+--! SELECT eql_v2.version();
+--! -- Returns: '2.1.0'
+CREATE FUNCTION eql_v2.version()
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ SELECT 'eql-2.2.1';
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Compare two encrypted values using variable-width CLLW ORE index terms
+--!
+--! Performs a three-way comparison (returns -1/0/1) of encrypted values using
+--! their variable-width CLLW ORE ciphertext index terms. Used internally by range operators
+--! (<, <=, >, >=) for order-revealing comparisons without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value to compare
+--! @param b eql_v2_encrypted Second encrypted value to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note NULL values are sorted before non-NULL values
+--! @note Uses variable-width CLLW ORE cryptographic protocol for secure comparisons
+--!
+--! @see eql_v2.ore_cllw_var_8
+--! @see eql_v2.has_ore_cllw_var_8
+--! @see eql_v2.compare_ore_cllw_var_8_term
+--! @see eql_v2."<"
+--! @see eql_v2.">"
+CREATE FUNCTION eql_v2.compare_ore_cllw_var_8(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_term eql_v2.ore_cllw_var_8;
+ b_term eql_v2.ore_cllw_var_8;
+ BEGIN
+
+ -- PERFORM eql_v2.log('eql_v2.compare_ore_cllw_var_8');
+ -- PERFORM eql_v2.log('a', a::text);
+ -- PERFORM eql_v2.log('b', b::text);
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF eql_v2.has_ore_cllw_var_8(a) THEN
+ a_term := eql_v2.ore_cllw_var_8(a);
+ END IF;
+
+ IF eql_v2.has_ore_cllw_var_8(a) THEN
+ b_term := eql_v2.ore_cllw_var_8(b);
+ END IF;
+
+ IF a_term IS NULL AND b_term IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b_term IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ RETURN eql_v2.compare_ore_cllw_var_8_term(a_term, b_term);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Compare two encrypted values using CLLW ORE index terms
+--!
+--! Performs a three-way comparison (returns -1/0/1) of encrypted values using
+--! their CLLW ORE ciphertext index terms. Used internally by range operators
+--! (<, <=, >, >=) for order-revealing comparisons without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value to compare
+--! @param b eql_v2_encrypted Second encrypted value to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note NULL values are sorted before non-NULL values
+--! @note Uses CLLW ORE cryptographic protocol for secure comparisons
+--!
+--! @see eql_v2.ore_cllw_u64_8
+--! @see eql_v2.has_ore_cllw_u64_8
+--! @see eql_v2.compare_ore_cllw_term_bytes
+--! @see eql_v2."<"
+--! @see eql_v2.">"
+CREATE FUNCTION eql_v2.compare_ore_cllw_u64_8(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_term eql_v2.ore_cllw_u64_8;
+ b_term eql_v2.ore_cllw_u64_8;
+ BEGIN
+
+ -- PERFORM eql_v2.log('eql_v2.compare_ore_cllw_u64_8');
+ -- PERFORM eql_v2.log('a', a::text);
+ -- PERFORM eql_v2.log('b', b::text);
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF eql_v2.has_ore_cllw_u64_8(a) THEN
+ a_term := eql_v2.ore_cllw_u64_8(a);
+ END IF;
+
+ IF eql_v2.has_ore_cllw_u64_8(a) THEN
+ b_term := eql_v2.ore_cllw_u64_8(b);
+ END IF;
+
+ IF a_term IS NULL AND b_term IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b_term IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ RETURN eql_v2.compare_ore_cllw_term_bytes(a_term.bytes, b_term.bytes);
+ END;
+$$ LANGUAGE plpgsql;
+
+-- NOTE FILE IS DISABLED
+
+
+--! @brief Equality operator for ORE block types
+--! @internal
+--!
+--! Implements the = operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if ORE blocks are equal
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_eq(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = 0
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Not equal operator for ORE block types
+--! @internal
+--!
+--! Implements the <> operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if ORE blocks are not equal
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_neq(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) <> 0
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Less than operator for ORE block types
+--! @internal
+--!
+--! Implements the < operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if left operand is less than right operand
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_lt(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = -1
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Less than or equal operator for ORE block types
+--! @internal
+--!
+--! Implements the <= operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if left operand is less than or equal to right operand
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_lte(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) != 1
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Greater than operator for ORE block types
+--! @internal
+--!
+--! Implements the > operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if left operand is greater than right operand
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_gt(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = 1
+$$ LANGUAGE SQL;
+
+
+
+--! @brief Greater than or equal operator for ORE block types
+--! @internal
+--!
+--! Implements the >= operator for direct ORE block comparisons.
+--!
+--! @param a eql_v2.ore_block_u64_8_256 Left operand
+--! @param b eql_v2.ore_block_u64_8_256 Right operand
+--! @return Boolean True if left operand is greater than or equal to right operand
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE FUNCTION eql_v2.ore_block_u64_8_256_gte(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)
+RETURNS boolean AS $$
+ SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) != -1
+$$ LANGUAGE SQL;
+
+
+
+--! @brief = operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR = (
+ FUNCTION=eql_v2.ore_block_u64_8_256_eq,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ NEGATOR = <>,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+
+
+--! @brief <> operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR <> (
+ FUNCTION=eql_v2.ore_block_u64_8_256_neq,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ NEGATOR = =,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+
+--! @brief > operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR > (
+ FUNCTION=eql_v2.ore_block_u64_8_256_gt,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ COMMUTATOR = <,
+ NEGATOR = <=,
+ RESTRICT = scalargtsel,
+ JOIN = scalargtjoinsel
+);
+
+
+
+--! @brief < operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR < (
+ FUNCTION=eql_v2.ore_block_u64_8_256_lt,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ COMMUTATOR = >,
+ NEGATOR = >=,
+ RESTRICT = scalarltsel,
+ JOIN = scalarltjoinsel
+);
+
+
+
+--! @brief <= operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR <= (
+ FUNCTION=eql_v2.ore_block_u64_8_256_lte,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ COMMUTATOR = >=,
+ NEGATOR = >,
+ RESTRICT = scalarlesel,
+ JOIN = scalarlejoinsel
+);
+
+
+
+--! @brief >= operator for ORE block types
+--! @note FILE IS DISABLED - Not included in build
+CREATE OPERATOR >= (
+ FUNCTION=eql_v2.ore_block_u64_8_256_gte,
+ LEFTARG=eql_v2.ore_block_u64_8_256,
+ RIGHTARG=eql_v2.ore_block_u64_8_256,
+ COMMUTATOR = <=,
+ NEGATOR = <,
+ RESTRICT = scalarlesel,
+ JOIN = scalarlejoinsel
+);
+-- NOTE FILE IS DISABLED
+
+
+
+--! @brief B-tree operator family for ORE block types
+--!
+--! Defines the operator family for creating B-tree indexes on ORE block types.
+--!
+--! @note FILE IS DISABLED - Not included in build
+--! @see eql_v2.ore_block_u64_8_256_operator_class
+CREATE OPERATOR FAMILY eql_v2.ore_block_u64_8_256_operator_family USING btree;
+
+--! @brief B-tree operator class for ORE block encrypted values
+--!
+--! Defines the operator class required for creating B-tree indexes on columns
+--! using the ore_block_u64_8_256 type. Enables range queries and ORDER BY on
+--! ORE-encrypted data without decryption.
+--!
+--! Supports operators: <, <=, =, >=, >
+--! Uses comparison function: compare_ore_block_u64_8_256_terms
+--!
+--! @note FILE IS DISABLED - Not included in build
+--!
+--! @example
+--! -- Would be used like (if enabled):
+--! CREATE INDEX ON events USING btree (
+--! (encrypted_timestamp::jsonb->'ob')::eql_v2.ore_block_u64_8_256
+--! );
+--!
+--! @see CREATE OPERATOR CLASS in PostgreSQL documentation
+--! @see eql_v2.compare_ore_block_u64_8_256_terms
+CREATE OPERATOR CLASS eql_v2.ore_block_u64_8_256_operator_class DEFAULT FOR TYPE eql_v2.ore_block_u64_8_256 USING btree FAMILY eql_v2.ore_block_u64_8_256_operator_family AS
+ OPERATOR 1 <,
+ OPERATOR 2 <=,
+ OPERATOR 3 =,
+ OPERATOR 4 >=,
+ OPERATOR 5 >,
+ FUNCTION 1 eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256);
+
+
+--! @brief Compare two encrypted values using ORE block index terms
+--!
+--! Performs a three-way comparison (returns -1/0/1) of encrypted values using
+--! their ORE block index terms. Used internally by range operators (<, <=, >, >=)
+--! for order-revealing comparisons without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value to compare
+--! @param b eql_v2_encrypted Second encrypted value to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note NULL values are sorted before non-NULL values
+--! @note Uses ORE cryptographic protocol for secure comparisons
+--!
+--! @see eql_v2.ore_block_u64_8_256
+--! @see eql_v2.has_ore_block_u64_8_256
+--! @see eql_v2."<"
+--! @see eql_v2.">"
+CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_term eql_v2.ore_block_u64_8_256;
+ b_term eql_v2.ore_block_u64_8_256;
+ BEGIN
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF eql_v2.has_ore_block_u64_8_256(a) THEN
+ a_term := eql_v2.ore_block_u64_8_256(a);
+ END IF;
+
+ IF eql_v2.has_ore_block_u64_8_256(a) THEN
+ b_term := eql_v2.ore_block_u64_8_256(b);
+ END IF;
+
+ IF a_term IS NULL AND b_term IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b_term IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ RETURN eql_v2.compare_ore_block_u64_8_256_terms(a_term.terms, b_term.terms);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Cast text to ORE block term
+--! @internal
+--!
+--! Converts text to bytea and wraps in ore_block_u64_8_256_term type.
+--! Used internally for ORE block extraction and manipulation.
+--!
+--! @param t Text Text value to convert
+--! @return eql_v2.ore_block_u64_8_256_term ORE term containing bytea representation
+--!
+--! @see eql_v2.ore_block_u64_8_256_term
+CREATE FUNCTION eql_v2.text_to_ore_block_u64_8_256_term(t text)
+ RETURNS eql_v2.ore_block_u64_8_256_term
+ LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ RETURN t::bytea;
+END;
+
+--! @brief Implicit cast from text to ORE block term
+--!
+--! Defines an implicit cast allowing automatic conversion of text values
+--! to ore_block_u64_8_256_term type for ORE operations.
+--!
+--! @see eql_v2.text_to_ore_block_u64_8_256_term
+CREATE CAST (text AS eql_v2.ore_block_u64_8_256_term)
+ WITH FUNCTION eql_v2.text_to_ore_block_u64_8_256_term(text) AS IMPLICIT;
+
+--! @brief Pattern matching helper using bloom filters
+--! @internal
+--!
+--! Internal helper for LIKE-style pattern matching on encrypted values.
+--! Uses bloom filter index terms to test substring containment without decryption.
+--! Requires 'match' index configuration on the column.
+--!
+--! @param a eql_v2_encrypted Haystack (value to search in)
+--! @param b eql_v2_encrypted Needle (pattern to search for)
+--! @return Boolean True if bloom filter of a contains bloom filter of b
+--!
+--! @see eql_v2."~~"
+--! @see eql_v2.bloom_filter
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.like(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean AS $$
+ SELECT eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b);
+$$ LANGUAGE SQL;
+
+--! @brief Case-insensitive pattern matching helper
+--! @internal
+--!
+--! Internal helper for ILIKE-style case-insensitive pattern matching.
+--! Case sensitivity is controlled by index configuration (token_filters with downcase).
+--! This function has same implementation as like() - actual case handling is in index terms.
+--!
+--! @param a eql_v2_encrypted Haystack (value to search in)
+--! @param b eql_v2_encrypted Needle (pattern to search for)
+--! @return Boolean True if bloom filter of a contains bloom filter of b
+--!
+--! @note Case sensitivity depends on match index token_filters configuration
+--! @see eql_v2."~~"
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.ilike(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean AS $$
+ SELECT eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b);
+$$ LANGUAGE SQL;
+
+--! @brief LIKE operator for encrypted values (pattern matching)
+--!
+--! Implements the ~~ (LIKE) operator for substring/pattern matching on encrypted
+--! text using bloom filter index terms. Enables WHERE col LIKE '%pattern%' queries
+--! without decryption. Requires 'match' index configuration on the column.
+--!
+--! Pattern matching uses n-gram tokenization configured in match index. Token length
+--! and filters affect matching behavior.
+--!
+--! @param a eql_v2_encrypted Haystack (encrypted text to search in)
+--! @param b eql_v2_encrypted Needle (encrypted pattern to search for)
+--! @return Boolean True if a contains b as substring
+--!
+--! @example
+--! -- Search for substring in encrypted email
+--! SELECT * FROM users
+--! WHERE encrypted_email ~~ '%@example.com%'::text::eql_v2_encrypted;
+--!
+--! -- Pattern matching on encrypted names
+--! SELECT * FROM customers
+--! WHERE encrypted_name ~~ 'John%'::text::eql_v2_encrypted;
+--!
+--! @brief SQL LIKE operator (~~ operator) for encrypted text pattern matching
+--!
+--! @param a eql_v2_encrypted Left operand (encrypted value)
+--! @param b eql_v2_encrypted Right operand (encrypted pattern)
+--! @return boolean True if pattern matches
+--!
+--! @note Requires match index: eql_v2.add_search_config(table, column, 'match')
+--! @see eql_v2.like
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2."~~"(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.like(a, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR ~~(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief Case-insensitive LIKE operator (~~*)
+--!
+--! Implements ~~* (ILIKE) operator for case-insensitive pattern matching.
+--! Case handling depends on match index token_filters configuration (use downcase filter).
+--! Same implementation as ~~, with case sensitivity controlled by index configuration.
+--!
+--! @param a eql_v2_encrypted Haystack
+--! @param b eql_v2_encrypted Needle
+--! @return Boolean True if a contains b (case-insensitive)
+--!
+--! @note Configure match index with downcase token filter for case-insensitivity
+--! @see eql_v2."~~"
+CREATE OPERATOR ~~*(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief LIKE operator for encrypted value and JSONB
+--!
+--! Overload of ~~ operator accepting JSONB on the right side. Automatically
+--! casts JSONB to eql_v2_encrypted for bloom filter pattern matching.
+--!
+--! @param eql_v2_encrypted Haystack (encrypted value)
+--! @param b JSONB Needle (will be cast to eql_v2_encrypted)
+--! @return Boolean True if a contains b as substring
+--!
+--! @example
+--! SELECT * FROM users WHERE encrypted_email ~~ '%gmail%'::jsonb;
+--!
+--! @see eql_v2."~~"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."~~"(a eql_v2_encrypted, b jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.like(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR ~~(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+CREATE OPERATOR ~~*(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief LIKE operator for JSONB and encrypted value
+--!
+--! Overload of ~~ operator accepting JSONB on the left side. Automatically
+--! casts JSONB to eql_v2_encrypted for bloom filter pattern matching.
+--!
+--! @param a JSONB Haystack (will be cast to eql_v2_encrypted)
+--! @param eql_v2_encrypted Needle (encrypted pattern)
+--! @return Boolean True if a contains b as substring
+--!
+--! @example
+--! SELECT * FROM users WHERE 'test@example.com'::jsonb ~~ encrypted_pattern;
+--!
+--! @see eql_v2."~~"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."~~"(a jsonb, b eql_v2_encrypted)
+ RETURNS boolean
+AS $$
+ BEGIN
+ RETURN eql_v2.like(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR ~~(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=jsonb,
+ RIGHTARG=eql_v2_encrypted,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+CREATE OPERATOR ~~*(
+ FUNCTION=eql_v2."~~",
+ LEFTARG=jsonb,
+ RIGHTARG=eql_v2_encrypted,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+
+-- -----------------------------------------------------------------------------
+
+--! @brief Extract ORE index term for ordering encrypted values
+--!
+--! Helper function that extracts the ore_block_u64_8_256 index term from an encrypted value
+--! for use in ORDER BY clauses when comparison operators are not appropriate or available.
+--!
+--! @param eql_v2_encrypted Encrypted value to extract order term from
+--! @return eql_v2.ore_block_u64_8_256 ORE index term for ordering
+--!
+--! @example
+--! -- Order encrypted values without using comparison operators
+--! SELECT * FROM users ORDER BY eql_v2.order_by(encrypted_age);
+--!
+--! @note Requires 'ore' index configuration on the column
+--! @see eql_v2.ore_block_u64_8_256
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.order_by(a eql_v2_encrypted)
+ RETURNS eql_v2.ore_block_u64_8_256
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.ore_block_u64_8_256(a);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+
+--! @brief PostgreSQL operator class definitions for encrypted value indexing
+--!
+--! Defines the operator family and operator class required for btree indexing
+--! of encrypted values. This enables PostgreSQL to use encrypted columns in:
+--! - CREATE INDEX statements
+--! - ORDER BY clauses
+--! - Range queries
+--! - Primary key constraints
+--!
+--! The operator class maps the five comparison operators (<, <=, =, >=, >)
+--! to the eql_v2.compare() support function for btree index operations.
+--!
+--! @note This is the default operator class for eql_v2_encrypted type
+--! @see eql_v2.compare
+--! @see PostgreSQL documentation on operator classes
+
+--------------------
+
+CREATE OPERATOR FAMILY eql_v2.encrypted_operator_family USING btree;
+
+CREATE OPERATOR CLASS eql_v2.encrypted_operator_class DEFAULT FOR TYPE eql_v2_encrypted USING btree FAMILY eql_v2.encrypted_operator_family AS
+ OPERATOR 1 <,
+ OPERATOR 2 <=,
+ OPERATOR 3 =,
+ OPERATOR 4 >=,
+ OPERATOR 5 >,
+ FUNCTION 1 eql_v2.compare(a eql_v2_encrypted, b eql_v2_encrypted);
+
+
+--------------------
+
+-- CREATE OPERATOR FAMILY eql_v2.encrypted_operator_ordered USING btree;
+
+-- CREATE OPERATOR CLASS eql_v2.encrypted_operator_ordered FOR TYPE eql_v2_encrypted USING btree FAMILY eql_v2.encrypted_operator_ordered AS
+-- OPERATOR 1 <,
+-- OPERATOR 2 <=,
+-- OPERATOR 3 =,
+-- OPERATOR 4 >=,
+-- OPERATOR 5 >,
+-- FUNCTION 1 eql_v2.compare_ore_block_u64_8_256(a eql_v2_encrypted, b eql_v2_encrypted);
+
+--------------------
+
+-- CREATE OPERATOR FAMILY eql_v2.encrypted_hmac_256_operator USING btree;
+
+-- CREATE OPERATOR CLASS eql_v2.encrypted_hmac_256_operator FOR TYPE eql_v2_encrypted USING btree FAMILY eql_v2.encrypted_hmac_256_operator AS
+-- OPERATOR 1 <,
+-- OPERATOR 2 <=,
+-- OPERATOR 3 =,
+-- OPERATOR 4 >=,
+-- OPERATOR 5 >,
+-- FUNCTION 1 eql_v2.compare_hmac(a eql_v2_encrypted, b eql_v2_encrypted);
+
+
+--! @brief Contains operator for encrypted values (@>)
+--!
+--! Implements the @> (contains) operator for testing if left encrypted value
+--! contains the right encrypted value. Uses ste_vec (secure tree encoding vector)
+--! index terms for containment testing without decryption.
+--!
+--! Primarily used for encrypted array or set containment queries.
+--!
+--! @param a eql_v2_encrypted Left operand (container)
+--! @param b eql_v2_encrypted Right operand (contained value)
+--! @return Boolean True if a contains b
+--!
+--! @example
+--! -- Check if encrypted array contains value
+--! SELECT * FROM documents
+--! WHERE encrypted_tags @> '["security"]'::jsonb::eql_v2_encrypted;
+--!
+--! @note Requires ste_vec index configuration
+--! @see eql_v2.ste_vec_contains
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2."@>"(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean AS $$
+ SELECT eql_v2.ste_vec_contains(a, b)
+$$ LANGUAGE SQL;
+
+CREATE OPERATOR @>(
+ FUNCTION=eql_v2."@>",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted
+);
+
+--! @brief Contained-by operator for encrypted values (<@)
+--!
+--! Implements the <@ (contained-by) operator for testing if left encrypted value
+--! is contained by the right encrypted value. Uses ste_vec (secure tree encoding vector)
+--! index terms for containment testing without decryption. Reverse of @> operator.
+--!
+--! Primarily used for encrypted array or set containment queries.
+--!
+--! @param a eql_v2_encrypted Left operand (contained value)
+--! @param b eql_v2_encrypted Right operand (container)
+--! @return Boolean True if a is contained by b
+--!
+--! @example
+--! -- Check if value is contained in encrypted array
+--! SELECT * FROM documents
+--! WHERE '["security"]'::jsonb::eql_v2_encrypted <@ encrypted_tags;
+--!
+--! @note Requires ste_vec index configuration
+--! @see eql_v2.ste_vec_contains
+--! @see eql_v2.\"@>\"
+--! @see eql_v2.add_search_config
+
+CREATE FUNCTION eql_v2."<@"(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS boolean AS $$
+ -- Contains with reversed arguments
+ SELECT eql_v2.ste_vec_contains(b, a)
+$$ LANGUAGE SQL;
+
+CREATE OPERATOR <@(
+ FUNCTION=eql_v2."<@",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted
+);
+
+--! @brief Not-equal comparison helper for encrypted values
+--! @internal
+--!
+--! Internal helper that delegates to eql_v2.compare for inequality testing.
+--! Returns true if encrypted values are not equal via encrypted index comparison.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return Boolean True if values are not equal (compare result <> 0)
+--!
+--! @see eql_v2.compare
+--! @see eql_v2."<>"
+CREATE FUNCTION eql_v2.neq(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.compare(a, b) <> 0;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Not-equal operator for encrypted values
+--!
+--! Implements the <> (not equal) operator for comparing encrypted values using their
+--! encrypted index terms. Enables WHERE clause inequality comparisons without decryption.
+--!
+--! @param a eql_v2_encrypted Left operand
+--! @param b eql_v2_encrypted Right operand
+--! @return Boolean True if encrypted values are not equal
+--!
+--! @example
+--! -- Find records with non-matching values
+--! SELECT * FROM users
+--! WHERE encrypted_email <> 'admin@example.com'::text::eql_v2_encrypted;
+--!
+--! @see eql_v2.compare
+--! @see eql_v2."="
+CREATE FUNCTION eql_v2."<>"(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.neq(a, b );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR <> (
+ FUNCTION=eql_v2."<>",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted,
+ NEGATOR = =,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief <> operator for encrypted value and JSONB
+--! @see eql_v2."<>"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<>"(a eql_v2_encrypted, b jsonb)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.neq(a, b::eql_v2_encrypted);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <> (
+ FUNCTION=eql_v2."<>",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=jsonb,
+ NEGATOR = =,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+--! @brief <> operator for JSONB and encrypted value
+--!
+--! @param jsonb Plain JSONB value
+--! @param eql_v2_encrypted Encrypted value
+--! @return boolean True if values are not equal
+--!
+--! @see eql_v2."<>"(eql_v2_encrypted, eql_v2_encrypted)
+CREATE FUNCTION eql_v2."<>"(a jsonb, b eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2.neq(a::eql_v2_encrypted, b);
+ END;
+$$ LANGUAGE plpgsql;
+
+CREATE OPERATOR <> (
+ FUNCTION=eql_v2."<>",
+ LEFTARG=jsonb,
+ RIGHTARG=eql_v2_encrypted,
+ NEGATOR = =,
+ RESTRICT = eqsel,
+ JOIN = eqjoinsel,
+ HASHES,
+ MERGES
+);
+
+
+
+
+
+--! @brief JSONB field accessor operator alias (->>)
+--!
+--! Implements the ->> operator as an alias of -> for encrypted JSONB data. This mirrors
+--! PostgreSQL semantics where ->> returns text via implicit casts. The underlying
+--! implementation delegates to eql_v2."->" and allows PostgreSQL to coerce the result.
+--!
+--! Provides two overloads:
+--! - (eql_v2_encrypted, text) - Field name selector
+--! - (eql_v2_encrypted, eql_v2_encrypted) - Encrypted selector
+--!
+--! @see eql_v2."->"
+--! @see eql_v2.selector
+
+--! @brief ->> operator with text selector
+--! @param eql_v2_encrypted Encrypted JSONB data
+--! @param text Field name to extract
+--! @return text Encrypted value at selector, implicitly cast from eql_v2_encrypted
+--! @example
+--! SELECT encrypted_json ->> 'field_name' FROM table;
+CREATE FUNCTION eql_v2."->>"(e eql_v2_encrypted, selector text)
+ RETURNS text
+IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ found eql_v2_encrypted;
+ BEGIN
+ -- found = eql_v2."->"(e, selector);
+ -- RETURN eql_v2.ciphertext(found);
+ RETURN eql_v2."->"(e, selector);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR ->> (
+ FUNCTION=eql_v2."->>",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=text
+);
+
+
+
+---------------------------------------------------
+
+--! @brief ->> operator with encrypted selector
+--! @param e eql_v2_encrypted Encrypted JSONB data
+--! @param selector eql_v2_encrypted Encrypted field selector
+--! @return text Encrypted value at selector, implicitly cast from eql_v2_encrypted
+--! @see eql_v2."->>"(eql_v2_encrypted, text)
+CREATE FUNCTION eql_v2."->>"(e eql_v2_encrypted, selector eql_v2_encrypted)
+ RETURNS text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2."->>"(e, eql_v2.selector(selector));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR ->> (
+ FUNCTION=eql_v2."->>",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted
+);
+
+--! @brief JSONB field accessor operator for encrypted values (->)
+--!
+--! Implements the -> operator to access fields/elements from encrypted JSONB data.
+--! Returns encrypted value matching the provided selector without decryption.
+--!
+--! Encrypted JSON is represented as an array of eql_v2_encrypted values in the ste_vec format.
+--! Each element has a selector, ciphertext, and index terms:
+--! {"sv": [{"c": "", "s": "", "b3": ""}]}
+--!
+--! Provides three overloads:
+--! - (eql_v2_encrypted, text) - Field name selector
+--! - (eql_v2_encrypted, eql_v2_encrypted) - Encrypted selector
+--! - (eql_v2_encrypted, integer) - Array index selector (0-based)
+--!
+--! @note Operator resolution: Assignment casts are considered (PostgreSQL standard behavior).
+--! To use text selector, parameter may need explicit cast to text.
+--!
+--! @see eql_v2.ste_vec
+--! @see eql_v2.selector
+--! @see eql_v2."->>"
+
+--! @brief -> operator with text selector
+--! @param eql_v2_encrypted Encrypted JSONB data
+--! @param text Field name to extract
+--! @return eql_v2_encrypted Encrypted value at selector
+--! @example
+--! SELECT encrypted_json -> 'field_name' FROM table;
+CREATE FUNCTION eql_v2."->"(e eql_v2_encrypted, selector text)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ meta jsonb;
+ sv eql_v2_encrypted[];
+ found jsonb;
+ BEGIN
+
+ IF e IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ -- Column identifier and version
+ meta := eql_v2.meta_data(e);
+
+ sv := eql_v2.ste_vec(e);
+
+ FOR idx IN 1..array_length(sv, 1) LOOP
+ if eql_v2.selector(sv[idx]) = selector THEN
+ found := sv[idx];
+ END IF;
+ END LOOP;
+
+ RETURN (meta || found)::eql_v2_encrypted;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OPERATOR ->(
+ FUNCTION=eql_v2."->",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=text
+);
+
+---------------------------------------------------
+
+--! @brief -> operator with encrypted selector
+--! @param e eql_v2_encrypted Encrypted JSONB data
+--! @param selector eql_v2_encrypted Encrypted field selector
+--! @return eql_v2_encrypted Encrypted value at selector
+--! @see eql_v2."->"(eql_v2_encrypted, text)
+CREATE FUNCTION eql_v2."->"(e eql_v2_encrypted, selector eql_v2_encrypted)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN eql_v2."->"(e, eql_v2.selector(selector));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+CREATE OPERATOR ->(
+ FUNCTION=eql_v2."->",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=eql_v2_encrypted
+);
+
+
+---------------------------------------------------
+
+--! @brief -> operator with integer array index
+--! @param eql_v2_encrypted Encrypted array data
+--! @param integer Array index (0-based, JSONB convention)
+--! @return eql_v2_encrypted Encrypted value at array index
+--! @note Array index is 0-based (JSONB standard) despite PostgreSQL arrays being 1-based
+--! @example
+--! SELECT encrypted_array -> 0 FROM table;
+--! @see eql_v2.is_ste_vec_array
+CREATE FUNCTION eql_v2."->"(e eql_v2_encrypted, selector integer)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv eql_v2_encrypted[];
+ found eql_v2_encrypted;
+ BEGIN
+ IF NOT eql_v2.is_ste_vec_array(e) THEN
+ RETURN NULL;
+ END IF;
+
+ sv := eql_v2.ste_vec(e);
+
+ -- PostgreSQL arrays are 1-based
+ -- JSONB arrays are 0-based and so the selector is 0-based
+ FOR idx IN 1..array_length(sv, 1) LOOP
+ if (idx-1) = selector THEN
+ found := sv[idx];
+ END IF;
+ END LOOP;
+
+ RETURN found;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+
+
+CREATE OPERATOR ->(
+ FUNCTION=eql_v2."->",
+ LEFTARG=eql_v2_encrypted,
+ RIGHTARG=integer
+);
+
+
+--! @file jsonb/functions.sql
+--! @brief JSONB path query and array manipulation functions for encrypted data
+--!
+--! These functions provide PostgreSQL-compatible operations on encrypted JSONB values
+--! using Structured Transparent Encryption (STE). They support:
+--! - Path-based queries to extract nested encrypted values
+--! - Existence checks for encrypted fields
+--! - Array operations (length, elements extraction)
+--!
+--! @note STE stores encrypted JSONB as a vector of encrypted elements ('sv') with selectors
+--! @note Functions suppress errors for missing fields, type mismatches (similar to PostgreSQL jsonpath)
+
+
+--! @brief Query encrypted JSONB for elements matching selector
+--!
+--! Searches the Structured Transparent Encryption (STE) vector for elements matching
+--! the given selector path. Returns all matching encrypted elements. If multiple
+--! matches form an array, they are wrapped with array metadata.
+--!
+--! @param jsonb Encrypted JSONB payload containing STE vector ('sv')
+--! @param text Path selector to match against encrypted elements
+--! @return SETOF eql_v2_encrypted Matching encrypted elements (may return multiple rows)
+--!
+--! @note Returns empty set if selector is not found (does not throw exception)
+--! @note Array elements use same selector; multiple matches wrapped with 'a' flag
+--! @note Returns a set containing NULL if val is NULL; returns empty set if no matches found
+--! @see eql_v2.jsonb_path_query_first
+--! @see eql_v2.jsonb_path_exists
+CREATE FUNCTION eql_v2.jsonb_path_query(val jsonb, selector text)
+ RETURNS SETOF eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv eql_v2_encrypted[];
+ found jsonb[];
+ e jsonb;
+ meta jsonb;
+ ary boolean;
+ BEGIN
+
+ IF val IS NULL THEN
+ RETURN NEXT NULL;
+ END IF;
+
+ -- Column identifier and version
+ meta := eql_v2.meta_data(val);
+
+ sv := eql_v2.ste_vec(val);
+
+ FOR idx IN 1..array_length(sv, 1) LOOP
+ e := sv[idx];
+
+ IF eql_v2.selector(e) = selector THEN
+ found := array_append(found, e);
+ IF eql_v2.is_ste_vec_array(e) THEN
+ ary := true;
+ END IF;
+
+ END IF;
+ END LOOP;
+
+ IF found IS NOT NULL THEN
+
+ IF ary THEN
+ -- Wrap found array elements as eql_v2_encrypted
+
+ RETURN NEXT (meta || jsonb_build_object(
+ 'sv', found,
+ 'a', 1
+ ))::eql_v2_encrypted;
+
+ ELSE
+ RETURN NEXT (meta || found[1])::eql_v2_encrypted;
+ END IF;
+
+ END IF;
+
+ RETURN;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Query encrypted JSONB with encrypted selector
+--!
+--! Overload that accepts encrypted selector and extracts its plaintext value
+--! before delegating to main jsonb_path_query implementation.
+--!
+--! @param val eql_v2_encrypted Encrypted JSONB value to query
+--! @param selector eql_v2_encrypted Encrypted selector to match against
+--! @return SETOF eql_v2_encrypted Matching encrypted elements
+--!
+--! @see eql_v2.jsonb_path_query(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_query(val eql_v2_encrypted, selector eql_v2_encrypted)
+ RETURNS SETOF eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN QUERY
+ SELECT * FROM eql_v2.jsonb_path_query(val.data, eql_v2.selector(selector));
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Query encrypted JSONB with text selector
+--!
+--! Overload that accepts encrypted JSONB value and text selector,
+--! extracting the JSONB payload before querying.
+--!
+--! @param eql_v2_encrypted Encrypted JSONB value to query
+--! @param text Path selector to match against
+--! @return SETOF eql_v2_encrypted Matching encrypted elements
+--!
+--! @example
+--! -- Query encrypted JSONB for specific field
+--! SELECT * FROM eql_v2.jsonb_path_query(encrypted_document, '$.address.city');
+--!
+--! @see eql_v2.jsonb_path_query(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_query(val eql_v2_encrypted, selector text)
+ RETURNS SETOF eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN QUERY
+ SELECT * FROM eql_v2.jsonb_path_query(val.data, selector);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+------------------------------------------------------------------------------------
+
+
+--! @brief Check if selector path exists in encrypted JSONB
+--!
+--! Tests whether any encrypted elements match the given selector path.
+--! More efficient than jsonb_path_query when only existence check is needed.
+--!
+--! @param jsonb Encrypted JSONB payload to check
+--! @param text Path selector to test
+--! @return boolean True if matching element exists, false otherwise
+--!
+--! @see eql_v2.jsonb_path_query(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_exists(val jsonb, selector text)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN EXISTS (
+ SELECT eql_v2.jsonb_path_query(val, selector)
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check existence with encrypted selector
+--!
+--! Overload that accepts encrypted selector and extracts its value
+--! before checking existence.
+--!
+--! @param val eql_v2_encrypted Encrypted JSONB value to check
+--! @param selector eql_v2_encrypted Encrypted selector to test
+--! @return boolean True if path exists
+--!
+--! @see eql_v2.jsonb_path_exists(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_exists(val eql_v2_encrypted, selector eql_v2_encrypted)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN EXISTS (
+ SELECT eql_v2.jsonb_path_query(val, eql_v2.selector(selector))
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Check existence with text selector
+--!
+--! Overload that accepts encrypted JSONB value and text selector.
+--!
+--! @param eql_v2_encrypted Encrypted JSONB value to check
+--! @param text Path selector to test
+--! @return boolean True if path exists
+--!
+--! @example
+--! -- Check if encrypted document has address field
+--! SELECT eql_v2.jsonb_path_exists(encrypted_document, '$.address');
+--!
+--! @see eql_v2.jsonb_path_exists(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_exists(val eql_v2_encrypted, selector text)
+ RETURNS boolean
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN EXISTS (
+ SELECT eql_v2.jsonb_path_query(val, selector)
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+------------------------------------------------------------------------------------
+
+
+--! @brief Get first element matching selector
+--!
+--! Returns only the first encrypted element matching the selector path,
+--! or NULL if no match found. More efficient than jsonb_path_query when
+--! only one result is needed.
+--!
+--! @param jsonb Encrypted JSONB payload to query
+--! @param text Path selector to match
+--! @return eql_v2_encrypted First matching element or NULL
+--!
+--! @note Uses LIMIT 1 internally for efficiency
+--! @see eql_v2.jsonb_path_query(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_query_first(val jsonb, selector text)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (
+ SELECT e
+ FROM eql_v2.jsonb_path_query(val, selector) AS e
+ LIMIT 1
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Get first element with encrypted selector
+--!
+--! Overload that accepts encrypted selector and extracts its value
+--! before querying for first match.
+--!
+--! @param val eql_v2_encrypted Encrypted JSONB value to query
+--! @param selector eql_v2_encrypted Encrypted selector to match
+--! @return eql_v2_encrypted First matching element or NULL
+--!
+--! @see eql_v2.jsonb_path_query_first(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_query_first(val eql_v2_encrypted, selector eql_v2_encrypted)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (
+ SELECT e
+ FROM eql_v2.jsonb_path_query(val.data, eql_v2.selector(selector)) AS e
+ LIMIT 1
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Get first element with text selector
+--!
+--! Overload that accepts encrypted JSONB value and text selector.
+--!
+--! @param eql_v2_encrypted Encrypted JSONB value to query
+--! @param text Path selector to match
+--! @return eql_v2_encrypted First matching element or NULL
+--!
+--! @example
+--! -- Get first matching address from encrypted document
+--! SELECT eql_v2.jsonb_path_query_first(encrypted_document, '$.addresses[*]');
+--!
+--! @see eql_v2.jsonb_path_query_first(jsonb, text)
+CREATE FUNCTION eql_v2.jsonb_path_query_first(val eql_v2_encrypted, selector text)
+ RETURNS eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (
+ SELECT e
+ FROM eql_v2.jsonb_path_query(val.data, selector) AS e
+ LIMIT 1
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+------------------------------------------------------------------------------------
+
+
+--! @brief Get length of encrypted JSONB array
+--!
+--! Returns the number of elements in an encrypted JSONB array by counting
+--! elements in the STE vector ('sv'). The encrypted value must have the
+--! array flag ('a') set to true.
+--!
+--! @param jsonb Encrypted JSONB payload representing an array
+--! @return integer Number of elements in the array
+--! @throws Exception 'cannot get array length of a non-array' if 'a' flag is missing or not true
+--!
+--! @note Array flag 'a' must be present and set to true value
+--! @see eql_v2.jsonb_array_elements
+CREATE FUNCTION eql_v2.jsonb_array_length(val jsonb)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv eql_v2_encrypted[];
+ found eql_v2_encrypted[];
+ BEGIN
+
+ IF val IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ IF eql_v2.is_ste_vec_array(val) THEN
+ sv := eql_v2.ste_vec(val);
+ RETURN array_length(sv, 1);
+ END IF;
+
+ RAISE 'cannot get array length of a non-array';
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Get array length from encrypted type
+--!
+--! Overload that accepts encrypted composite type and extracts the
+--! JSONB payload before computing array length.
+--!
+--! @param eql_v2_encrypted Encrypted array value
+--! @return integer Number of elements in the array
+--! @throws Exception if value is not an array
+--!
+--! @example
+--! -- Get length of encrypted array
+--! SELECT eql_v2.jsonb_array_length(encrypted_tags);
+--!
+--! @see eql_v2.jsonb_array_length(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array_length(val eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN (
+ SELECT eql_v2.jsonb_array_length(val.data)
+ );
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+
+--! @brief Extract elements from encrypted JSONB array
+--!
+--! Returns each element of an encrypted JSONB array as a separate row.
+--! Each element is returned as an eql_v2_encrypted value with metadata
+--! preserved from the parent array.
+--!
+--! @param jsonb Encrypted JSONB payload representing an array
+--! @return SETOF eql_v2_encrypted One row per array element
+--! @throws Exception if value is not an array (missing 'a' flag)
+--!
+--! @note Each element inherits metadata (version, ident) from parent
+--! @see eql_v2.jsonb_array_length
+--! @see eql_v2.jsonb_array_elements_text
+CREATE FUNCTION eql_v2.jsonb_array_elements(val jsonb)
+ RETURNS SETOF eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv eql_v2_encrypted[];
+ meta jsonb;
+ item jsonb;
+ BEGIN
+
+ IF NOT eql_v2.is_ste_vec_array(val) THEN
+ RAISE 'cannot extract elements from non-array';
+ END IF;
+
+ -- Column identifier and version
+ meta := eql_v2.meta_data(val);
+
+ sv := eql_v2.ste_vec(val);
+
+ FOR idx IN 1..array_length(sv, 1) LOOP
+ item = sv[idx];
+ RETURN NEXT (meta || item)::eql_v2_encrypted;
+ END LOOP;
+
+ RETURN;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract elements from encrypted array type
+--!
+--! Overload that accepts encrypted composite type and extracts each
+--! array element as a separate row.
+--!
+--! @param eql_v2_encrypted Encrypted array value
+--! @return SETOF eql_v2_encrypted One row per array element
+--! @throws Exception if value is not an array
+--!
+--! @example
+--! -- Expand encrypted array into rows
+--! SELECT * FROM eql_v2.jsonb_array_elements(encrypted_tags);
+--!
+--! @see eql_v2.jsonb_array_elements(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array_elements(val eql_v2_encrypted)
+ RETURNS SETOF eql_v2_encrypted
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN QUERY
+ SELECT * FROM eql_v2.jsonb_array_elements(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Extract encrypted array elements as ciphertext
+--!
+--! Returns each element of an encrypted JSONB array as its raw ciphertext
+--! value (text representation). Unlike jsonb_array_elements, this returns
+--! only the ciphertext 'c' field without metadata.
+--!
+--! @param jsonb Encrypted JSONB payload representing an array
+--! @return SETOF text One ciphertext string per array element
+--! @throws Exception if value is not an array (missing 'a' flag)
+--!
+--! @note Returns ciphertext only, not full encrypted structure
+--! @see eql_v2.jsonb_array_elements
+CREATE FUNCTION eql_v2.jsonb_array_elements_text(val jsonb)
+ RETURNS SETOF text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ sv eql_v2_encrypted[];
+ found eql_v2_encrypted[];
+ BEGIN
+ IF NOT eql_v2.is_ste_vec_array(val) THEN
+ RAISE 'cannot extract elements from non-array';
+ END IF;
+
+ sv := eql_v2.ste_vec(val);
+
+ FOR idx IN 1..array_length(sv, 1) LOOP
+ RETURN NEXT eql_v2.ciphertext(sv[idx]);
+ END LOOP;
+
+ RETURN;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Extract array elements as ciphertext from encrypted type
+--!
+--! Overload that accepts encrypted composite type and extracts each
+--! array element's ciphertext as text.
+--!
+--! @param eql_v2_encrypted Encrypted array value
+--! @return SETOF text One ciphertext string per array element
+--! @throws Exception if value is not an array
+--!
+--! @example
+--! -- Get ciphertext of each array element
+--! SELECT * FROM eql_v2.jsonb_array_elements_text(encrypted_tags);
+--!
+--! @see eql_v2.jsonb_array_elements_text(jsonb)
+CREATE FUNCTION eql_v2.jsonb_array_elements_text(val eql_v2_encrypted)
+ RETURNS SETOF text
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN QUERY
+ SELECT * FROM eql_v2.jsonb_array_elements_text(val.data);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Compare two encrypted values using HMAC-SHA256 index terms
+--!
+--! Performs a three-way comparison (returns -1/0/1) of encrypted values using
+--! their HMAC-SHA256 hash index terms. Used internally by the equality operator (=)
+--! for exact-match queries without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value to compare
+--! @param b eql_v2_encrypted Second encrypted value to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note NULL values are sorted before non-NULL values
+--! @note Comparison uses underlying text type ordering of HMAC-SHA256 hashes
+--!
+--! @see eql_v2.hmac_256
+--! @see eql_v2.has_hmac_256
+--! @see eql_v2."="
+CREATE FUNCTION eql_v2.compare_hmac_256(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_term eql_v2.hmac_256;
+ b_term eql_v2.hmac_256;
+ BEGIN
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF eql_v2.has_hmac_256(a) THEN
+ a_term = eql_v2.hmac_256(a);
+ END IF;
+
+ IF eql_v2.has_hmac_256(b) THEN
+ b_term = eql_v2.hmac_256(b);
+ END IF;
+
+ IF a_term IS NULL AND b_term IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b_term IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ -- Using the underlying text type comparison
+ IF a_term = b_term THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term < b_term THEN
+ RETURN -1;
+ END IF;
+
+ IF a_term > b_term THEN
+ RETURN 1;
+ END IF;
+
+ END;
+$$ LANGUAGE plpgsql;
+--! @file encryptindex/functions.sql
+--! @brief Configuration lifecycle and column encryption management
+--!
+--! Provides functions for managing encryption configuration transitions:
+--! - Comparing configurations to identify changes
+--! - Identifying columns needing encryption
+--! - Creating and renaming encrypted columns during initial setup
+--! - Tracking encryption progress
+--!
+--! These functions support the workflow of activating a pending configuration
+--! and performing the initial encryption of plaintext columns.
+
+
+--! @brief Compare two configurations and find differences
+--! @internal
+--!
+--! Returns table/column pairs where configuration differs between two configs.
+--! Used to identify which columns need encryption when activating a pending config.
+--!
+--! @param a jsonb First configuration to compare
+--! @param b jsonb Second configuration to compare
+--! @return TABLE(table_name text, column_name text) Columns with differing configuration
+--!
+--! @note Compares configuration structure, not just presence/absence
+--! @see eql_v2.select_pending_columns
+CREATE FUNCTION eql_v2.diff_config(a JSONB, b JSONB)
+ RETURNS TABLE(table_name TEXT, column_name TEXT)
+IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+ RETURN QUERY
+ WITH table_keys AS (
+ SELECT jsonb_object_keys(a->'tables') AS key
+ UNION
+ SELECT jsonb_object_keys(b->'tables') AS key
+ ),
+ column_keys AS (
+ SELECT tk.key AS table_key, jsonb_object_keys(a->'tables'->tk.key) AS column_key
+ FROM table_keys tk
+ UNION
+ SELECT tk.key AS table_key, jsonb_object_keys(b->'tables'->tk.key) AS column_key
+ FROM table_keys tk
+ )
+ SELECT
+ ck.table_key AS table_name,
+ ck.column_key AS column_name
+ FROM
+ column_keys ck
+ WHERE
+ (a->'tables'->ck.table_key->ck.column_key IS DISTINCT FROM b->'tables'->ck.table_key->ck.column_key);
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Get columns with pending configuration changes
+--!
+--! Compares 'pending' and 'active' configurations to identify columns that need
+--! encryption or re-encryption. Returns columns where configuration differs.
+--!
+--! @return TABLE(table_name text, column_name text) Columns needing encryption
+--! @throws Exception if no pending configuration exists
+--!
+--! @note Treats missing active config as empty config
+--! @see eql_v2.diff_config
+--! @see eql_v2.select_target_columns
+CREATE FUNCTION eql_v2.select_pending_columns()
+ RETURNS TABLE(table_name TEXT, column_name TEXT)
+AS $$
+ DECLARE
+ active JSONB;
+ pending JSONB;
+ config_id BIGINT;
+ BEGIN
+ SELECT data INTO active FROM eql_v2_configuration WHERE state = 'active';
+
+ -- set default config
+ IF active IS NULL THEN
+ active := '{}';
+ END IF;
+
+ SELECT id, data INTO config_id, pending FROM eql_v2_configuration WHERE state = 'pending';
+
+ -- set default config
+ IF config_id IS NULL THEN
+ RAISE EXCEPTION 'No pending configuration exists to encrypt';
+ END IF;
+
+ RETURN QUERY
+ SELECT d.table_name, d.column_name FROM eql_v2.diff_config(active, pending) as d;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Map pending columns to their encrypted target columns
+--!
+--! For each column with pending configuration, identifies the corresponding
+--! encrypted column. During initial encryption, target is '{column_name}_encrypted'.
+--! Returns NULL for target_column if encrypted column doesn't exist yet.
+--!
+--! @return TABLE(table_name text, column_name text, target_column text) Column mappings
+--!
+--! @note Target column is NULL if no column exists matching either 'column_name' or 'column_name_encrypted' with type eql_v2_encrypted
+--! @note The LEFT JOIN checks both original and '_encrypted' suffix variations with type verification
+--! @see eql_v2.select_pending_columns
+--! @see eql_v2.create_encrypted_columns
+CREATE FUNCTION eql_v2.select_target_columns()
+ RETURNS TABLE(table_name TEXT, column_name TEXT, target_column TEXT)
+ STABLE STRICT PARALLEL SAFE
+AS $$
+ SELECT
+ c.table_name,
+ c.column_name,
+ s.column_name as target_column
+ FROM
+ eql_v2.select_pending_columns() c
+ LEFT JOIN information_schema.columns s ON
+ s.table_name = c.table_name AND
+ (s.column_name = c.column_name OR s.column_name = c.column_name || '_encrypted') AND
+ s.udt_name = 'eql_v2_encrypted';
+$$ LANGUAGE sql;
+
+
+--! @brief Check if database is ready for encryption
+--!
+--! Verifies that all columns with pending configuration have corresponding
+--! encrypted target columns created. Returns true if encryption can proceed.
+--!
+--! @return boolean True if all pending columns have target encrypted columns
+--!
+--! @note Returns false if any pending column lacks encrypted column
+--! @see eql_v2.select_target_columns
+--! @see eql_v2.create_encrypted_columns
+CREATE FUNCTION eql_v2.ready_for_encryption()
+ RETURNS BOOLEAN
+ STABLE STRICT PARALLEL SAFE
+AS $$
+ SELECT EXISTS (
+ SELECT *
+ FROM eql_v2.select_target_columns() AS c
+ WHERE c.target_column IS NOT NULL);
+$$ LANGUAGE sql;
+
+
+--! @brief Create encrypted columns for initial encryption
+--!
+--! For each plaintext column with pending configuration that lacks an encrypted
+--! target column, creates a new column '{column_name}_encrypted' of type
+--! eql_v2_encrypted. This prepares the database schema for initial encryption.
+--!
+--! @return TABLE(table_name text, column_name text) Created encrypted columns
+--!
+--! @warning Executes dynamic DDL (ALTER TABLE ADD COLUMN) - modifies database schema
+--! @note Only creates columns that don't already exist
+--! @see eql_v2.select_target_columns
+--! @see eql_v2.rename_encrypted_columns
+CREATE FUNCTION eql_v2.create_encrypted_columns()
+ RETURNS TABLE(table_name TEXT, column_name TEXT)
+AS $$
+ BEGIN
+ FOR table_name, column_name IN
+ SELECT c.table_name, (c.column_name || '_encrypted') FROM eql_v2.select_target_columns() AS c WHERE c.target_column IS NULL
+ LOOP
+ EXECUTE format('ALTER TABLE %I ADD column %I eql_v2_encrypted;', table_name, column_name);
+ RETURN NEXT;
+ END LOOP;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Finalize initial encryption by renaming columns
+--!
+--! After initial encryption completes, renames columns to complete the transition:
+--! - Plaintext column '{column_name}' → '{column_name}_plaintext'
+--! - Encrypted column '{column_name}_encrypted' → '{column_name}'
+--!
+--! This makes the encrypted column the primary column with the original name.
+--!
+--! @return TABLE(table_name text, column_name text, target_column text) Renamed columns
+--!
+--! @warning Executes dynamic DDL (ALTER TABLE RENAME COLUMN) - modifies database schema
+--! @note Only renames columns where target is '{column_name}_encrypted'
+--! @see eql_v2.create_encrypted_columns
+CREATE FUNCTION eql_v2.rename_encrypted_columns()
+ RETURNS TABLE(table_name TEXT, column_name TEXT, target_column TEXT)
+AS $$
+ BEGIN
+ FOR table_name, column_name, target_column IN
+ SELECT * FROM eql_v2.select_target_columns() as c WHERE c.target_column = c.column_name || '_encrypted'
+ LOOP
+ EXECUTE format('ALTER TABLE %I RENAME %I TO %I;', table_name, column_name, column_name || '_plaintext');
+ EXECUTE format('ALTER TABLE %I RENAME %I TO %I;', table_name, target_column, column_name);
+ RETURN NEXT;
+ END LOOP;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Count rows encrypted with active configuration
+--! @internal
+--!
+--! Counts rows in a table where the encrypted column was encrypted using
+--! the currently active configuration. Used to track encryption progress.
+--!
+--! @param table_name text Name of table to check
+--! @param column_name text Name of encrypted column to check
+--! @return bigint Count of rows encrypted with active configuration
+--!
+--! @note The 'v' field in encrypted payloads stores the payload version ("2"), not the configuration ID
+--! @note Configuration tracking mechanism is implementation-specific
+CREATE FUNCTION eql_v2.count_encrypted_with_active_config(table_name TEXT, column_name TEXT)
+ RETURNS BIGINT
+AS $$
+DECLARE
+ result BIGINT;
+BEGIN
+ EXECUTE format(
+ 'SELECT COUNT(%I) FROM %s t WHERE %I->>%L = (SELECT id::TEXT FROM eql_v2_configuration WHERE state = %L)',
+ column_name, table_name, column_name, 'v', 'active'
+ )
+ INTO result;
+ RETURN result;
+END;
+$$ LANGUAGE plpgsql;
+
+
+
+--! @brief Validate presence of ident field in encrypted payload
+--! @internal
+--!
+--! Checks that the encrypted JSONB payload contains the required 'i' (ident) field.
+--! The ident field tracks which table and column the encrypted value belongs to.
+--!
+--! @param jsonb Encrypted payload to validate
+--! @return Boolean True if 'i' field is present
+--! @throws Exception if 'i' field is missing
+--!
+--! @note Used in CHECK constraints to ensure payload structure
+--! @see eql_v2.check_encrypted
+CREATE FUNCTION eql_v2._encrypted_check_i(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF val ? 'i' THEN
+ RETURN true;
+ END IF;
+ RAISE 'Encrypted column missing ident (i) field: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate table and column fields in ident
+--! @internal
+--!
+--! Checks that the 'i' (ident) field contains both 't' (table) and 'c' (column)
+--! subfields, which identify the origin of the encrypted value.
+--!
+--! @param jsonb Encrypted payload to validate
+--! @return Boolean True if both 't' and 'c' subfields are present
+--! @throws Exception if 't' or 'c' subfields are missing
+--!
+--! @note Used in CHECK constraints to ensure payload structure
+--! @see eql_v2.check_encrypted
+CREATE FUNCTION eql_v2._encrypted_check_i_ct(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF (val->'i' ?& array['t', 'c']) THEN
+ RETURN true;
+ END IF;
+ RAISE 'Encrypted column ident (i) missing table (t) or column (c) fields: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Validate version field in encrypted payload
+--! @internal
+--!
+--! Checks that the encrypted payload has version field 'v' set to '2',
+--! the current EQL v2 payload version.
+--!
+--! @param jsonb Encrypted payload to validate
+--! @return Boolean True if 'v' field is present and equals '2'
+--! @throws Exception if 'v' field is missing or not '2'
+--!
+--! @note Used in CHECK constraints to ensure payload structure
+--! @see eql_v2.check_encrypted
+CREATE FUNCTION eql_v2._encrypted_check_v(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF (val ? 'v') THEN
+
+ IF val->>'v' <> '2' THEN
+ RAISE 'Expected encrypted column version (v) 2';
+ RETURN false;
+ END IF;
+
+ RETURN true;
+ END IF;
+ RAISE 'Encrypted column missing version (v) field: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate ciphertext field in encrypted payload
+--! @internal
+--!
+--! Checks that the encrypted payload contains the required 'c' (ciphertext) field
+--! which stores the encrypted data.
+--!
+--! @param jsonb Encrypted payload to validate
+--! @return Boolean True if 'c' field is present
+--! @throws Exception if 'c' field is missing
+--!
+--! @note Used in CHECK constraints to ensure payload structure
+--! @see eql_v2.check_encrypted
+CREATE FUNCTION eql_v2._encrypted_check_c(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF (val ? 'c') THEN
+ RETURN true;
+ END IF;
+ RAISE 'Encrypted column missing ciphertext (c) field: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate complete encrypted payload structure
+--!
+--! Comprehensive validation function that checks all required fields in an
+--! encrypted JSONB payload: version ('v'), ciphertext ('c'), ident ('i'),
+--! and ident subfields ('t', 'c').
+--!
+--! This function is used in CHECK constraints to ensure encrypted column
+--! data integrity at the database level.
+--!
+--! @param jsonb Encrypted payload to validate
+--! @return Boolean True if all structure checks pass
+--! @throws Exception if any required field is missing or invalid
+--!
+--! @example
+--! -- Add validation constraint to encrypted column
+--! ALTER TABLE users ADD CONSTRAINT check_email_encrypted
+--! CHECK (eql_v2.check_encrypted(encrypted_email::jsonb));
+--!
+--! @see eql_v2._encrypted_check_v
+--! @see eql_v2._encrypted_check_c
+--! @see eql_v2._encrypted_check_i
+--! @see eql_v2._encrypted_check_i_ct
+CREATE FUNCTION eql_v2.check_encrypted(val jsonb)
+ RETURNS BOOLEAN
+LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ RETURN (
+ eql_v2._encrypted_check_v(val) AND
+ eql_v2._encrypted_check_c(val) AND
+ eql_v2._encrypted_check_i(val) AND
+ eql_v2._encrypted_check_i_ct(val)
+ );
+END;
+
+
+--! @brief Validate encrypted composite type structure
+--!
+--! Validates an eql_v2_encrypted composite type by checking its underlying
+--! JSONB payload. Delegates to eql_v2.check_encrypted(jsonb).
+--!
+--! @param eql_v2_encrypted Encrypted value to validate
+--! @return Boolean True if structure is valid
+--! @throws Exception if any required field is missing or invalid
+--!
+--! @see eql_v2.check_encrypted(jsonb)
+CREATE FUNCTION eql_v2.check_encrypted(val eql_v2_encrypted)
+ RETURNS BOOLEAN
+LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ RETURN eql_v2.check_encrypted(val.data);
+END;
+
+
+-- Aggregate functions for ORE
+
+--! @brief State transition function for min aggregate
+--! @internal
+--!
+--! Returns the smaller of two encrypted values for use in MIN aggregate.
+--! Comparison uses ORE index terms without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return eql_v2_encrypted The smaller of the two values
+--!
+--! @see eql_v2.min(eql_v2_encrypted)
+CREATE FUNCTION eql_v2.min(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS eql_v2_encrypted
+STRICT
+AS $$
+ BEGIN
+ IF a < b THEN
+ RETURN a;
+ ELSE
+ RETURN b;
+ END IF;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Find minimum encrypted value in a group
+--!
+--! Aggregate function that returns the minimum encrypted value in a group
+--! using ORE index term comparisons without decryption.
+--!
+--! @param input eql_v2_encrypted Encrypted values to aggregate
+--! @return eql_v2_encrypted Minimum value in the group
+--!
+--! @example
+--! -- Find minimum age per department
+--! SELECT department, eql_v2.min(encrypted_age)
+--! FROM employees
+--! GROUP BY department;
+--!
+--! @note Requires 'ore' index configuration on the column
+--! @see eql_v2.min(eql_v2_encrypted, eql_v2_encrypted)
+CREATE AGGREGATE eql_v2.min(eql_v2_encrypted)
+(
+ sfunc = eql_v2.min,
+ stype = eql_v2_encrypted
+);
+
+
+--! @brief State transition function for max aggregate
+--! @internal
+--!
+--! Returns the larger of two encrypted values for use in MAX aggregate.
+--! Comparison uses ORE index terms without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value
+--! @param b eql_v2_encrypted Second encrypted value
+--! @return eql_v2_encrypted The larger of the two values
+--!
+--! @see eql_v2.max(eql_v2_encrypted)
+CREATE FUNCTION eql_v2.max(a eql_v2_encrypted, b eql_v2_encrypted)
+RETURNS eql_v2_encrypted
+STRICT
+AS $$
+ BEGIN
+ IF a > b THEN
+ RETURN a;
+ ELSE
+ RETURN b;
+ END IF;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Find maximum encrypted value in a group
+--!
+--! Aggregate function that returns the maximum encrypted value in a group
+--! using ORE index term comparisons without decryption.
+--!
+--! @param input eql_v2_encrypted Encrypted values to aggregate
+--! @return eql_v2_encrypted Maximum value in the group
+--!
+--! @example
+--! -- Find maximum salary per department
+--! SELECT department, eql_v2.max(encrypted_salary)
+--! FROM employees
+--! GROUP BY department;
+--!
+--! @note Requires 'ore' index configuration on the column
+--! @see eql_v2.max(eql_v2_encrypted, eql_v2_encrypted)
+CREATE AGGREGATE eql_v2.max(eql_v2_encrypted)
+(
+ sfunc = eql_v2.max,
+ stype = eql_v2_encrypted
+);
+
+
+--! @file config/indexes.sql
+--! @brief Configuration state uniqueness indexes
+--!
+--! Creates partial unique indexes to enforce that only one configuration
+--! can be in 'active', 'pending', or 'encrypting' state at any time.
+--! Multiple 'inactive' configurations are allowed.
+--!
+--! @note Uses partial indexes (WHERE clauses) for efficiency
+--! @note Prevents conflicting configurations from being active simultaneously
+--! @see config/types.sql for state definitions
+
+
+--! @brief Unique active configuration constraint
+--! @note Only one configuration can be 'active' at once
+CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'active';
+
+--! @brief Unique pending configuration constraint
+--! @note Only one configuration can be 'pending' at once
+CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'pending';
+
+--! @brief Unique encrypting configuration constraint
+--! @note Only one configuration can be 'encrypting' at once
+CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'encrypting';
+
+
+--! @brief Add a search index configuration for an encrypted column
+--!
+--! Configures a searchable encryption index (unique, match, ore, or ste_vec) on an
+--! encrypted column. Creates or updates the pending configuration, then migrates
+--! and activates it unless migrating flag is set.
+--!
+--! @param table_name Text Name of the table containing the column
+--! @param column_name Text Name of the column to configure
+--! @param index_name Text Type of index ('unique', 'match', 'ore', 'ste_vec')
+--! @param cast_as Text PostgreSQL type for decrypted values (default: 'text')
+--! @param opts JSONB Index-specific options (default: '{}')
+--! @param migrating Boolean Skip auto-migration if true (default: false)
+--! @return JSONB Updated configuration object
+--! @throws Exception if index already exists for this column
+--! @throws Exception if cast_as is not a valid type
+--!
+--! @example
+--! -- Add unique index for exact-match searches
+--! SELECT eql_v2.add_search_config('users', 'email', 'unique');
+--!
+--! -- Add match index for LIKE searches with custom token length
+--! SELECT eql_v2.add_search_config('posts', 'content', 'match', 'text',
+--! '{"token_filters": [{"kind": "downcase"}], "tokenizer": {"kind": "ngram", "token_length": 3}}'
+--! );
+--!
+--! @see eql_v2.add_column
+--! @see eql_v2.remove_search_config
+CREATE FUNCTION eql_v2.add_search_config(table_name text, column_name text, index_name text, cast_as text DEFAULT 'text', opts jsonb DEFAULT '{}', migrating boolean DEFAULT false)
+ RETURNS jsonb
+
+AS $$
+ DECLARE
+ o jsonb;
+ _config jsonb;
+ BEGIN
+
+ -- set the active config
+ SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;
+
+ -- if index exists
+ IF _config #> array['tables', table_name, column_name, 'indexes'] ? index_name THEN
+ RAISE EXCEPTION '% index exists for column: % %', index_name, table_name, column_name;
+ END IF;
+
+ IF NOT cast_as = ANY('{text, int, small_int, big_int, real, double, boolean, date, jsonb}') THEN
+ RAISE EXCEPTION '% is not a valid cast type', cast_as;
+ END IF;
+
+ -- set default config
+ SELECT eql_v2.config_default(_config) INTO _config;
+
+ SELECT eql_v2.config_add_table(table_name, _config) INTO _config;
+
+ SELECT eql_v2.config_add_column(table_name, column_name, _config) INTO _config;
+
+ SELECT eql_v2.config_add_cast(table_name, column_name, cast_as, _config) INTO _config;
+
+ -- set default options for index if opts empty
+ IF index_name = 'match' AND opts = '{}' THEN
+ SELECT eql_v2.config_match_default() INTO opts;
+ END IF;
+
+ SELECT eql_v2.config_add_index(table_name, column_name, index_name, opts, _config) INTO _config;
+
+ -- create a new pending record if we don't have one
+ INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)
+ ON CONFLICT (state)
+ WHERE state = 'pending'
+ DO UPDATE
+ SET data = _config;
+
+ IF NOT migrating THEN
+ PERFORM eql_v2.migrate_config();
+ PERFORM eql_v2.activate_config();
+ END IF;
+
+ PERFORM eql_v2.add_encrypted_constraint(table_name, column_name);
+
+ -- exeunt
+ RETURN _config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Remove a search index configuration from an encrypted column
+--!
+--! Removes a previously configured search index from an encrypted column.
+--! Updates the pending configuration, then migrates and activates it
+--! unless migrating flag is set.
+--!
+--! @param table_name Text Name of the table containing the column
+--! @param column_name Text Name of the column
+--! @param index_name Text Type of index to remove
+--! @param migrating Boolean Skip auto-migration if true (default: false)
+--! @return JSONB Updated configuration object
+--! @throws Exception if no active or pending configuration exists
+--! @throws Exception if table is not configured
+--! @throws Exception if column is not configured
+--!
+--! @example
+--! -- Remove match index from column
+--! SELECT eql_v2.remove_search_config('posts', 'content', 'match');
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.modify_search_config
+CREATE FUNCTION eql_v2.remove_search_config(table_name text, column_name text, index_name text, migrating boolean DEFAULT false)
+ RETURNS jsonb
+AS $$
+ DECLARE
+ _config jsonb;
+ BEGIN
+
+ -- set the active config
+ SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;
+
+ -- if no config
+ IF _config IS NULL THEN
+ RAISE EXCEPTION 'No active or pending configuration exists';
+ END IF;
+
+ -- if the table doesn't exist
+ IF NOT _config #> array['tables'] ? table_name THEN
+ RAISE EXCEPTION 'No configuration exists for table: %', table_name;
+ END IF;
+
+ -- if the index does not exist
+ -- IF NOT _config->key ? index_name THEN
+ IF NOT _config #> array['tables', table_name] ? column_name THEN
+ RAISE EXCEPTION 'No % index exists for column: % %', index_name, table_name, column_name;
+ END IF;
+
+ -- create a new pending record if we don't have one
+ INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)
+ ON CONFLICT (state)
+ WHERE state = 'pending'
+ DO NOTHING;
+
+ -- remove the index
+ SELECT _config #- array['tables', table_name, column_name, 'indexes', index_name] INTO _config;
+
+ -- update the config and migrate (even if empty)
+ UPDATE public.eql_v2_configuration SET data = _config WHERE state = 'pending';
+
+ IF NOT migrating THEN
+ PERFORM eql_v2.migrate_config();
+ PERFORM eql_v2.activate_config();
+ END IF;
+
+ -- exeunt
+ RETURN _config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Modify a search index configuration for an encrypted column
+--!
+--! Updates an existing search index configuration by removing and re-adding it
+--! with new options. Convenience function that combines remove and add operations.
+--! If index does not exist, it is added.
+--!
+--! @param table_name Text Name of the table containing the column
+--! @param column_name Text Name of the column
+--! @param index_name Text Type of index to modify
+--! @param cast_as Text PostgreSQL type for decrypted values (default: 'text')
+--! @param opts JSONB New index-specific options (default: '{}')
+--! @param migrating Boolean Skip auto-migration if true (default: false)
+--! @return JSONB Updated configuration object
+--!
+--! @example
+--! -- Change match index tokenizer settings
+--! SELECT eql_v2.modify_search_config('posts', 'content', 'match', 'text',
+--! '{"tokenizer": {"kind": "ngram", "token_length": 4}}'
+--! );
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.remove_search_config
+CREATE FUNCTION eql_v2.modify_search_config(table_name text, column_name text, index_name text, cast_as text DEFAULT 'text', opts jsonb DEFAULT '{}', migrating boolean DEFAULT false)
+ RETURNS jsonb
+AS $$
+ BEGIN
+ PERFORM eql_v2.remove_search_config(table_name, column_name, index_name, migrating);
+ RETURN eql_v2.add_search_config(table_name, column_name, index_name, cast_as, opts, migrating);
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Migrate pending configuration to encrypting state
+--!
+--! Transitions the pending configuration to encrypting state, validating that
+--! all configured columns have encrypted target columns ready. This is part of
+--! the configuration lifecycle: pending → encrypting → active.
+--!
+--! @return Boolean True if migration succeeds
+--! @throws Exception if encryption already in progress
+--! @throws Exception if no pending configuration exists
+--! @throws Exception if configured columns lack encrypted targets
+--!
+--! @example
+--! -- Manually migrate configuration (normally done automatically)
+--! SELECT eql_v2.migrate_config();
+--!
+--! @see eql_v2.activate_config
+--! @see eql_v2.add_column
+CREATE FUNCTION eql_v2.migrate_config()
+ RETURNS boolean
+AS $$
+ BEGIN
+
+ IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'encrypting') THEN
+ RAISE EXCEPTION 'An encryption is already in progress';
+ END IF;
+
+ IF NOT EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'pending') THEN
+ RAISE EXCEPTION 'No pending configuration exists to encrypt';
+ END IF;
+
+ IF NOT eql_v2.ready_for_encryption() THEN
+ RAISE EXCEPTION 'Some pending columns do not have an encrypted target';
+ END IF;
+
+ UPDATE public.eql_v2_configuration SET state = 'encrypting' WHERE state = 'pending';
+ RETURN true;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Activate encrypting configuration
+--!
+--! Transitions the encrypting configuration to active state, making it the
+--! current operational configuration. Marks previous active configuration as
+--! inactive. Final step in configuration lifecycle: pending → encrypting → active.
+--!
+--! @return Boolean True if activation succeeds
+--! @throws Exception if no encrypting configuration exists to activate
+--!
+--! @example
+--! -- Manually activate configuration (normally done automatically)
+--! SELECT eql_v2.activate_config();
+--!
+--! @see eql_v2.migrate_config
+--! @see eql_v2.add_column
+CREATE FUNCTION eql_v2.activate_config()
+ RETURNS boolean
+AS $$
+ BEGIN
+
+ IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'encrypting') THEN
+ UPDATE public.eql_v2_configuration SET state = 'inactive' WHERE state = 'active';
+ UPDATE public.eql_v2_configuration SET state = 'active' WHERE state = 'encrypting';
+ RETURN true;
+ ELSE
+ RAISE EXCEPTION 'No encrypting configuration exists to activate';
+ END IF;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Discard pending configuration
+--!
+--! Deletes the pending configuration without applying changes. Use this to
+--! abandon configuration changes before they are migrated and activated.
+--!
+--! @return Boolean True if discard succeeds
+--! @throws Exception if no pending configuration exists to discard
+--!
+--! @example
+--! -- Discard uncommitted configuration changes
+--! SELECT eql_v2.discard();
+--!
+--! @see eql_v2.add_column
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.discard()
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'pending') THEN
+ DELETE FROM public.eql_v2_configuration WHERE state = 'pending';
+ RETURN true;
+ ELSE
+ RAISE EXCEPTION 'No pending configuration exists to discard';
+ END IF;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Configure a column for encryption
+--!
+--! Adds a column to the encryption configuration, making it eligible for
+--! encrypted storage and search indexes. Creates or updates pending configuration,
+--! adds encrypted constraint, then migrates and activates unless migrating flag is set.
+--!
+--! @param table_name Text Name of the table containing the column
+--! @param column_name Text Name of the column to encrypt
+--! @param cast_as Text PostgreSQL type to cast decrypted values (default: 'text')
+--! @param migrating Boolean Skip auto-migration if true (default: false)
+--! @return JSONB Updated configuration object
+--! @throws Exception if column already configured for encryption
+--!
+--! @example
+--! -- Configure email column for encryption
+--! SELECT eql_v2.add_column('users', 'email', 'text');
+--!
+--! -- Configure age column with integer casting
+--! SELECT eql_v2.add_column('users', 'age', 'int');
+--!
+--! @see eql_v2.add_search_config
+--! @see eql_v2.remove_column
+CREATE FUNCTION eql_v2.add_column(table_name text, column_name text, cast_as text DEFAULT 'text', migrating boolean DEFAULT false)
+ RETURNS jsonb
+AS $$
+ DECLARE
+ key text;
+ _config jsonb;
+ BEGIN
+ -- set the active config
+ SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;
+
+ -- set default config
+ SELECT eql_v2.config_default(_config) INTO _config;
+
+ -- if index exists
+ IF _config #> array['tables', table_name] ? column_name THEN
+ RAISE EXCEPTION 'Config exists for column: % %', table_name, column_name;
+ END IF;
+
+ SELECT eql_v2.config_add_table(table_name, _config) INTO _config;
+
+ SELECT eql_v2.config_add_column(table_name, column_name, _config) INTO _config;
+
+ SELECT eql_v2.config_add_cast(table_name, column_name, cast_as, _config) INTO _config;
+
+ -- create a new pending record if we don't have one
+ INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)
+ ON CONFLICT (state)
+ WHERE state = 'pending'
+ DO UPDATE
+ SET data = _config;
+
+ IF NOT migrating THEN
+ PERFORM eql_v2.migrate_config();
+ PERFORM eql_v2.activate_config();
+ END IF;
+
+ PERFORM eql_v2.add_encrypted_constraint(table_name, column_name);
+
+ -- exeunt
+ RETURN _config;
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Remove a column from encryption configuration
+--!
+--! Removes a column from the encryption configuration, including all associated
+--! search indexes. Removes encrypted constraint, updates pending configuration,
+--! then migrates and activates unless migrating flag is set.
+--!
+--! @param table_name Text Name of the table containing the column
+--! @param column_name Text Name of the column to remove
+--! @param migrating Boolean Skip auto-migration if true (default: false)
+--! @return JSONB Updated configuration object
+--! @throws Exception if no active or pending configuration exists
+--! @throws Exception if table is not configured
+--! @throws Exception if column is not configured
+--!
+--! @example
+--! -- Remove email column from encryption
+--! SELECT eql_v2.remove_column('users', 'email');
+--!
+--! @see eql_v2.add_column
+--! @see eql_v2.remove_search_config
+CREATE FUNCTION eql_v2.remove_column(table_name text, column_name text, migrating boolean DEFAULT false)
+ RETURNS jsonb
+AS $$
+ DECLARE
+ key text;
+ _config jsonb;
+ BEGIN
+ -- set the active config
+ SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;
+
+ -- if no config
+ IF _config IS NULL THEN
+ RAISE EXCEPTION 'No active or pending configuration exists';
+ END IF;
+
+ -- if the table doesn't exist
+ IF NOT _config #> array['tables'] ? table_name THEN
+ RAISE EXCEPTION 'No configuration exists for table: %', table_name;
+ END IF;
+
+ -- if the column does not exist
+ IF NOT _config #> array['tables', table_name] ? column_name THEN
+ RAISE EXCEPTION 'No configuration exists for column: % %', table_name, column_name;
+ END IF;
+
+ -- create a new pending record if we don't have one
+ INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)
+ ON CONFLICT (state)
+ WHERE state = 'pending'
+ DO NOTHING;
+
+ -- remove the column
+ SELECT _config #- array['tables', table_name, column_name] INTO _config;
+
+ -- if table is now empty, remove the table
+ IF _config #> array['tables', table_name] = '{}' THEN
+ SELECT _config #- array['tables', table_name] INTO _config;
+ END IF;
+
+ PERFORM eql_v2.remove_encrypted_constraint(table_name, column_name);
+
+ -- update the config (even if empty) and activate
+ UPDATE public.eql_v2_configuration SET data = _config WHERE state = 'pending';
+
+ IF NOT migrating THEN
+ -- For empty configs, skip migration validation and directly activate
+ IF _config #> array['tables'] = '{}' THEN
+ UPDATE public.eql_v2_configuration SET state = 'inactive' WHERE state = 'active';
+ UPDATE public.eql_v2_configuration SET state = 'active' WHERE state = 'pending';
+ ELSE
+ PERFORM eql_v2.migrate_config();
+ PERFORM eql_v2.activate_config();
+ END IF;
+ END IF;
+
+ -- exeunt
+ RETURN _config;
+
+ END;
+$$ LANGUAGE plpgsql;
+
+--! @brief Reload configuration from CipherStash Proxy
+--!
+--! Placeholder function for reloading configuration from the CipherStash Proxy.
+--! Currently returns NULL without side effects.
+--!
+--! @return Void
+--!
+--! @note This function may be used for configuration synchronization in future versions
+CREATE FUNCTION eql_v2.reload_config()
+ RETURNS void
+LANGUAGE sql STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ RETURN NULL;
+END;
+
+--! @brief Query encryption configuration in tabular format
+--!
+--! Returns the active encryption configuration as a table for easier querying
+--! and filtering. Shows all configured tables, columns, cast types, and indexes.
+--!
+--! @return TABLE Contains configuration state, relation name, column name, cast type, and indexes
+--!
+--! @example
+--! -- View all encrypted columns
+--! SELECT * FROM eql_v2.config();
+--!
+--! -- Find all columns with match indexes
+--! SELECT relation, col_name FROM eql_v2.config()
+--! WHERE indexes ? 'match';
+--!
+--! @see eql_v2.add_column
+--! @see eql_v2.add_search_config
+CREATE FUNCTION eql_v2.config() RETURNS TABLE (
+ state eql_v2_configuration_state,
+ relation text,
+ col_name text,
+ decrypts_as text,
+ indexes jsonb
+)
+AS $$
+BEGIN
+ RETURN QUERY
+ WITH tables AS (
+ SELECT config.state, tables.key AS table, tables.value AS config
+ FROM public.eql_v2_configuration config, jsonb_each(data->'tables') tables
+ WHERE config.data->>'v' = '1'
+ )
+ SELECT
+ tables.state,
+ tables.table,
+ column_config.key,
+ column_config.value->>'cast_as',
+ column_config.value->'indexes'
+ FROM tables, jsonb_each(tables.config) column_config;
+END;
+$$ LANGUAGE plpgsql;
+
+--! @file config/constraints.sql
+--! @brief Configuration validation functions and constraints
+--!
+--! Provides CHECK constraint functions to validate encryption configuration structure.
+--! Ensures configurations have required fields (version, tables) and valid values
+--! for index types and cast types before being stored.
+--!
+--! @see config/tables.sql where constraints are applied
+
+
+--! @brief Extract index type names from configuration
+--! @internal
+--!
+--! Helper function that extracts all index type names from the configuration's
+--! 'indexes' sections across all tables and columns.
+--!
+--! @param jsonb Configuration data to extract from
+--! @return SETOF text Index type names (e.g., 'match', 'ore', 'unique', 'ste_vec')
+--!
+--! @note Used by config_check_indexes for validation
+--! @see eql_v2.config_check_indexes
+CREATE FUNCTION eql_v2.config_get_indexes(val jsonb)
+ RETURNS SETOF text
+ LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
+BEGIN ATOMIC
+ SELECT jsonb_object_keys(jsonb_path_query(val,'$.tables.*.*.indexes'));
+END;
+
+
+--! @brief Validate index types in configuration
+--! @internal
+--!
+--! Checks that all index types specified in the configuration are valid.
+--! Valid index types are: match, ore, unique, ste_vec.
+--!
+--! @param jsonb Configuration data to validate
+--! @return boolean True if all index types are valid
+--! @throws Exception if any invalid index type found
+--!
+--! @note Used in CHECK constraint on eql_v2_configuration table
+--! @see eql_v2.config_get_indexes
+CREATE FUNCTION eql_v2.config_check_indexes(val jsonb)
+ RETURNS BOOLEAN
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ BEGIN
+
+ IF (SELECT EXISTS (SELECT eql_v2.config_get_indexes(val))) THEN
+ IF (SELECT bool_and(index = ANY('{match, ore, unique, ste_vec}')) FROM eql_v2.config_get_indexes(val) AS index) THEN
+ RETURN true;
+ END IF;
+ RAISE 'Configuration has an invalid index (%). Index should be one of {match, ore, unique, ste_vec}', val;
+ END IF;
+ RETURN true;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate cast types in configuration
+--! @internal
+--!
+--! Checks that all 'cast_as' types specified in the configuration are valid.
+--! Valid cast types are: text, int, small_int, big_int, real, double, boolean, date, jsonb.
+--!
+--! @param jsonb Configuration data to validate
+--! @return boolean True if all cast types are valid or no cast types specified
+--! @throws Exception if any invalid cast type found
+--!
+--! @note Used in CHECK constraint on eql_v2_configuration table
+--! @note Empty configurations (no cast_as fields) are valid
+--! @note Cast type names are EQL's internal representations, not PostgreSQL native types
+CREATE FUNCTION eql_v2.config_check_cast(val jsonb)
+ RETURNS BOOLEAN
+AS $$
+ BEGIN
+ -- If there are cast_as fields, validate them
+ IF EXISTS (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.cast_as'))) THEN
+ IF (SELECT bool_and(cast_as = ANY('{text, int, small_int, big_int, real, double, boolean, date, jsonb}'))
+ FROM (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.cast_as')) AS cast_as) casts) THEN
+ RETURN true;
+ END IF;
+ RAISE 'Configuration has an invalid cast_as (%). Cast should be one of {text, int, small_int, big_int, real, double, boolean, date, jsonb}', val;
+ END IF;
+ -- If no cast_as fields exist (empty config), that's valid
+ RETURN true;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate tables field presence
+--! @internal
+--!
+--! Ensures the configuration has a 'tables' field, which is required
+--! to specify which database tables contain encrypted columns.
+--!
+--! @param jsonb Configuration data to validate
+--! @return boolean True if 'tables' field exists
+--! @throws Exception if 'tables' field is missing
+--!
+--! @note Used in CHECK constraint on eql_v2_configuration table
+CREATE FUNCTION eql_v2.config_check_tables(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF (val ? 'tables') THEN
+ RETURN true;
+ END IF;
+ RAISE 'Configuration missing tables (tables) field: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Validate version field presence
+--! @internal
+--!
+--! Ensures the configuration has a 'v' (version) field, which tracks
+--! the configuration format version.
+--!
+--! @param jsonb Configuration data to validate
+--! @return boolean True if 'v' field exists
+--! @throws Exception if 'v' field is missing
+--!
+--! @note Used in CHECK constraint on eql_v2_configuration table
+CREATE FUNCTION eql_v2.config_check_version(val jsonb)
+ RETURNS boolean
+AS $$
+ BEGIN
+ IF (val ? 'v') THEN
+ RETURN true;
+ END IF;
+ RAISE 'Configuration missing version (v) field: %', val;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+--! @brief Drop existing data validation constraint if present
+--! @note Allows constraint to be recreated during upgrades
+ALTER TABLE public.eql_v2_configuration DROP CONSTRAINT IF EXISTS eql_v2_configuration_data_check;
+
+
+--! @brief Comprehensive configuration data validation
+--!
+--! CHECK constraint that validates all aspects of configuration data:
+--! - Version field presence
+--! - Tables field presence
+--! - Valid cast_as types
+--! - Valid index types
+--!
+--! @note Combines all config_check_* validation functions
+--! @see eql_v2.config_check_version
+--! @see eql_v2.config_check_tables
+--! @see eql_v2.config_check_cast
+--! @see eql_v2.config_check_indexes
+ALTER TABLE public.eql_v2_configuration
+ ADD CONSTRAINT eql_v2_configuration_data_check CHECK (
+ eql_v2.config_check_version(data) AND
+ eql_v2.config_check_tables(data) AND
+ eql_v2.config_check_cast(data) AND
+ eql_v2.config_check_indexes(data)
+);
+
+
+
+
+--! @brief Compare two encrypted values using Blake3 hash index terms
+--!
+--! Performs a three-way comparison (returns -1/0/1) of encrypted values using
+--! their Blake3 hash index terms. Used internally by the equality operator (=)
+--! for exact-match queries without decryption.
+--!
+--! @param a eql_v2_encrypted First encrypted value to compare
+--! @param b eql_v2_encrypted Second encrypted value to compare
+--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b
+--!
+--! @note NULL values are sorted before non-NULL values
+--! @note Comparison uses underlying text type ordering of Blake3 hashes
+--!
+--! @see eql_v2.blake3
+--! @see eql_v2.has_blake3
+--! @see eql_v2."="
+CREATE FUNCTION eql_v2.compare_blake3(a eql_v2_encrypted, b eql_v2_encrypted)
+ RETURNS integer
+ IMMUTABLE STRICT PARALLEL SAFE
+AS $$
+ DECLARE
+ a_term eql_v2.blake3;
+ b_term eql_v2.blake3;
+ BEGIN
+
+ IF a IS NULL AND b IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ IF eql_v2.has_blake3(a) THEN
+ a_term = eql_v2.blake3(a);
+ END IF;
+
+ IF eql_v2.has_blake3(b) THEN
+ b_term = eql_v2.blake3(b);
+ END IF;
+
+ IF a_term IS NULL AND b_term IS NULL THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term IS NULL THEN
+ RETURN -1;
+ END IF;
+
+ IF b_term IS NULL THEN
+ RETURN 1;
+ END IF;
+
+ -- Using the underlying text type comparison
+ IF a_term = b_term THEN
+ RETURN 0;
+ END IF;
+
+ IF a_term < b_term THEN
+ RETURN -1;
+ END IF;
+
+ IF a_term > b_term THEN
+ RETURN 1;
+ END IF;
+
+ END;
+$$ LANGUAGE plpgsql;
diff --git a/packages/stack/src/prisma/core/errors.ts b/packages/stack/src/prisma/core/errors.ts
new file mode 100644
index 00000000..a836b8dc
--- /dev/null
+++ b/packages/stack/src/prisma/core/errors.ts
@@ -0,0 +1,146 @@
+import type { EncryptedDataType } from './constants'
+
+/**
+ * Structured codec errors.
+ *
+ * Application-level error handling can pattern-match on `code` for the
+ * failure modes that matter:
+ *
+ * - `JS_TYPE_MISMATCH` — value's JS type doesn't match the
+ * column's declared `dataType`.
+ * - `UNSUPPORTED_PLAINTEXT_TYPE` — value's JS type is outside the
+ * five-way `EncryptedDataType` union
+ * (`bigint`, `symbol`, `function`).
+ * - `INVALID_QUERY_TERM` — query-term codec received a value
+ * that doesn't fit the term's required
+ * JS type.
+ * - `DECODE_ROUND_TRIP_BROKEN` — decode-side shape mismatch between
+ * the FFI's `cast_as` output and the
+ * JS-side type we expected, or a
+ * decryption failure surfaced from
+ * the SDK's per-row error envelope.
+ * - `NO_COLUMN_FOR_DATATYPE` — encode-time dispatch could not
+ * find a contract column matching
+ * the JS-runtime data type.
+ * - `CONFIG_MISSING_ENV` — `cipherstashEncryption()` was
+ * constructed without `encryptionClient`
+ * and one or more required env vars
+ * are absent.
+ * - `NO_CONTRACT_SCHEMAS` — default-client construction was
+ * requested but the contract declared
+ * no encrypted columns.
+ */
+
+export type CipherStashCodecErrorCode =
+ | 'JS_TYPE_MISMATCH'
+ | 'UNSUPPORTED_PLAINTEXT_TYPE'
+ | 'INVALID_QUERY_TERM'
+ | 'DECODE_ROUND_TRIP_BROKEN'
+ | 'NO_COLUMN_FOR_DATATYPE'
+ | 'CONFIG_MISSING_ENV'
+ | 'NO_CONTRACT_SCHEMAS'
+
+export interface CipherStashCodecErrorOptions {
+ readonly code: CipherStashCodecErrorCode
+ readonly message: string
+ readonly column: string | undefined
+ readonly expectedDataType: EncryptedDataType | undefined
+ readonly actualType: string
+ readonly cause?: unknown
+}
+
+export class CipherStashCodecError extends Error {
+ readonly code: CipherStashCodecErrorCode
+ readonly column: string | undefined
+ readonly expectedDataType: EncryptedDataType | undefined
+ readonly actualType: string
+
+ constructor(opts: CipherStashCodecErrorOptions) {
+ super(opts.message, opts.cause ? { cause: opts.cause } : undefined)
+ this.name = 'CipherStashCodecError'
+ this.code = opts.code
+ this.column = opts.column
+ this.expectedDataType = opts.expectedDataType
+ this.actualType = opts.actualType
+ }
+}
+
+/**
+ * Derive the JS-runtime dataType for a value.
+ */
+export function inferJsDataType(value: unknown): EncryptedDataType | undefined {
+ if (value instanceof Date) return 'date'
+ switch (typeof value) {
+ case 'number':
+ return 'number'
+ case 'boolean':
+ return 'boolean'
+ case 'string':
+ return 'string'
+ case 'object':
+ // Treat plain objects (including arrays) as JSON; null is filtered
+ // upstream so we never see it here.
+ return 'json'
+ default:
+ return undefined
+ }
+}
+
+/**
+ * JS-runtime-type guard at encode time.
+ *
+ * When `expectedDataType` is supplied (i.e. the caller knows the
+ * contract column's declared `dataType`), the guard cross-checks the
+ * JS-derived data type and throws `JS_TYPE_MISMATCH` on mismatch.
+ *
+ * When `expectedDataType` is `undefined`, the guard still rejects
+ * unsupported JS types (`bigint`, `symbol`, `function`) with
+ * `UNSUPPORTED_PLAINTEXT_TYPE`, but skips the cross-check.
+ *
+ * The `column` field on the resulting error is the contract column's
+ * underlying database name (when known), so error consumers can
+ * correlate the failure back to the user's schema authoring site.
+ */
+export function assertJsTypeMatchesDataType(
+ value: unknown,
+ expectedDataType: EncryptedDataType | undefined,
+ columnName: string | undefined = undefined,
+): EncryptedDataType {
+ const jsDataType = inferJsDataType(value)
+ if (jsDataType === undefined) {
+ throw new CipherStashCodecError({
+ code: 'UNSUPPORTED_PLAINTEXT_TYPE',
+ message: `Unsupported plaintext type for encrypted column${
+ columnName ? ` '${columnName}'` : ''
+ }: JS type '${describeJs(value)}' is not in the supported set (string | number | boolean | Date | object).`,
+ column: columnName,
+ expectedDataType,
+ actualType: describeJs(value),
+ })
+ }
+
+ if (expectedDataType !== undefined && expectedDataType !== jsDataType) {
+ throw new CipherStashCodecError({
+ code: 'JS_TYPE_MISMATCH',
+ message: `Value type mismatch for encrypted column${
+ columnName ? ` '${columnName}'` : ''
+ }: expected dataType '${expectedDataType}', got JS type '${describeJs(value)}' (dataType '${jsDataType}').`,
+ column: columnName,
+ expectedDataType,
+ actualType: describeJs(value),
+ })
+ }
+
+ return jsDataType
+}
+
+/**
+ * Human-readable description of a JS value's runtime type, used in error
+ * messages.
+ */
+export function describeJs(value: unknown): string {
+ if (value === null) return 'null'
+ if (value instanceof Date) return 'Date'
+ if (Array.isArray(value)) return 'array'
+ return typeof value
+}
diff --git a/packages/stack/src/prisma/core/extraction.ts b/packages/stack/src/prisma/core/extraction.ts
new file mode 100644
index 00000000..0746eb3e
--- /dev/null
+++ b/packages/stack/src/prisma/core/extraction.ts
@@ -0,0 +1,178 @@
+import {
+ type EncryptedColumn,
+ type EncryptedTable,
+ encryptedColumn,
+ encryptedTable,
+} from '@/schema'
+import { ENCRYPTED_STORAGE_CODEC_ID, type EncryptedDataType } from './constants'
+
+/**
+ * Derive `EncryptedTable[]` schemas from a Prisma Next contract.
+ *
+ * Phase 2 routed every encrypted-column encrypt through one of five
+ * dataType-keyed placeholder columns (`value_string`, `value_number`, …).
+ * Phase 3 walks the contract's storage layout, finds every column whose
+ * `codecId === 'cs/eql_v2_encrypted@1'`, and builds an
+ * `EncryptedTable` with the right per-column index configuration.
+ *
+ * Why we still keep the placeholder schema:
+ * - The test suite (and a handful of cross-cutting utilities, e.g.
+ * codec round-trips inside the FFI test fixtures) call codec
+ * encode/decode without a contract loaded. Falling back to the
+ * placeholder for these cases keeps Phase 1+2's 53 tests passing.
+ * - Future work (a parameterized-codec `init` that gives the codec
+ * the column-side dataType at encode time) can retire the
+ * placeholder once the contract has been threaded through to every
+ * call site.
+ */
+
+/**
+ * Minimal shape of a Prisma Next contract's SQL storage layout. The
+ * contract has many more fields; we only consume what we need for
+ * schema derivation (and we deliberately avoid pinning to a specific
+ * post-#379 internal type so this works against the live trunk).
+ */
+export interface ContractLike {
+ readonly storage?: {
+ readonly tables?: Readonly>
+ readonly types?: Readonly>
+ }
+ readonly models?: Readonly>
+}
+
+interface ContractTable {
+ readonly columns?: Readonly>
+}
+
+interface ContractColumn {
+ readonly codecId?: string
+ readonly nativeType?: string
+ readonly typeParams?: Record
+ readonly typeRef?: string
+}
+
+interface ContractTypeInstance {
+ readonly codecId?: string
+ readonly nativeType?: string
+ readonly typeParams?: Record
+}
+
+interface ContractModelLike {
+ readonly storage?: {
+ readonly table?: string
+ readonly fields?: Readonly>
+ }
+}
+
+/**
+ * Read a column's resolved typeParams. Columns may either inline
+ * `typeParams` or reference a named type instance via `typeRef`. The
+ * post-#379 contract emits inline `typeParams` for non-shared columns;
+ * `typeRef` is for shared types declared in `storage.types`.
+ */
+function resolveTypeParams(
+ column: ContractColumn,
+ types: Readonly> | undefined,
+): Record | undefined {
+ if (column.typeParams) return column.typeParams
+ if (column.typeRef && types) {
+ const ref = types[column.typeRef]
+ if (ref?.typeParams) return ref.typeParams
+ }
+ return undefined
+}
+
+function resolveCodecId(
+ column: ContractColumn,
+ types: Readonly> | undefined,
+): string | undefined {
+ if (column.codecId) return column.codecId
+ if (column.typeRef && types) {
+ return types[column.typeRef]?.codecId
+ }
+ return undefined
+}
+
+function isEncryptedDataType(value: unknown): value is EncryptedDataType {
+ return (
+ value === 'string' ||
+ value === 'number' ||
+ value === 'boolean' ||
+ value === 'date' ||
+ value === 'json'
+ )
+}
+
+/**
+ * Apply a column's typeParams flags to an `EncryptedColumn` builder.
+ *
+ * Each searchable-encryption flag maps to a method on the builder.
+ * `dataType` is set first because some downstream methods (notably
+ * `searchableJson` requiring `dataType === 'json'`) validate against
+ * it.
+ */
+function buildEncryptedColumn(
+ columnName: string,
+ typeParams: Record,
+): EncryptedColumn | null {
+ const dataType = typeParams.dataType
+ if (!isEncryptedDataType(dataType)) return null
+
+ const builder: EncryptedColumn =
+ encryptedColumn(columnName).dataType(dataType)
+
+ if (typeParams.equality === true) {
+ builder.equality()
+ }
+ if (typeParams.freeTextSearch === true && dataType === 'string') {
+ builder.freeTextSearch()
+ }
+ if (typeParams.orderAndRange === true) {
+ builder.orderAndRange()
+ }
+ if (typeParams.searchableJson === true && dataType === 'json') {
+ builder.searchableJson()
+ }
+
+ return builder
+}
+
+/**
+ * Walk a contract and produce one `EncryptedTable` per table that has
+ * at least one encrypted column.
+ *
+ * The contract may not be fully populated in every code path
+ * (test fixtures, cross-cutting utilities), so the function is
+ * tolerant of missing fields: an undefined contract yields an empty
+ * array, and tables with no encrypted columns are skipped.
+ */
+export function extractEncryptedSchemas(
+ contract: ContractLike | undefined | null,
+): ReadonlyArray>> {
+ if (!contract) return []
+ const storage = contract.storage
+ if (!storage?.tables) return []
+
+ const types = storage.types
+ const out: EncryptedTable>[] = []
+
+ for (const [tableName, table] of Object.entries(storage.tables)) {
+ if (!table.columns) continue
+
+ const encryptedColumns: Record = {}
+ for (const [columnName, column] of Object.entries(table.columns)) {
+ const codecId = resolveCodecId(column, types)
+ if (codecId !== ENCRYPTED_STORAGE_CODEC_ID) continue
+ const typeParams = resolveTypeParams(column, types)
+ if (!typeParams) continue
+
+ const built = buildEncryptedColumn(columnName, typeParams)
+ if (built) encryptedColumns[columnName] = built
+ }
+
+ if (Object.keys(encryptedColumns).length === 0) continue
+ out.push(encryptedTable(tableName, encryptedColumns))
+ }
+
+ return out
+}
diff --git a/packages/stack/src/prisma/core/json-shape.ts b/packages/stack/src/prisma/core/json-shape.ts
new file mode 100644
index 00000000..f8e80d8c
--- /dev/null
+++ b/packages/stack/src/prisma/core/json-shape.ts
@@ -0,0 +1,11 @@
+/**
+ * Shared phantom slot symbol used to carry the user's JSON shape from
+ * `encryptedJson(...)` (authoring) through to
+ * `Decrypted` (read-side typing).
+ *
+ * Both the column-type factory and the `Decrypted` helper import this
+ * symbol so the type system treats `[JSON_SHAPE]?: T` as the same
+ * structural slot. Two separate `declare const X: unique symbol`
+ * declarations would be incompatible at the type level.
+ */
+export declare const JSON_SHAPE: unique symbol
diff --git a/packages/stack/src/prisma/core/operation-templates.ts b/packages/stack/src/prisma/core/operation-templates.ts
new file mode 100644
index 00000000..7d37d946
--- /dev/null
+++ b/packages/stack/src/prisma/core/operation-templates.ts
@@ -0,0 +1,308 @@
+import type { SqlOperationDescriptor } from '../internal-types/prisma-next'
+import {
+ ENCRYPTED_EQ_TERM_CODEC_ID,
+ ENCRYPTED_MATCH_TERM_CODEC_ID,
+ ENCRYPTED_ORE_TERM_CODEC_ID,
+ ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID,
+ ENCRYPTED_STORAGE_CODEC_ID,
+} from './constants'
+
+/**
+ * Boolean return spec used by every encrypted comparison operation.
+ *
+ * Lowering produces a SQL function call that returns Postgres `boolean`,
+ * so the runtime registry needs a codec ID for `boolean`. The framework
+ * registers a default boolean codec under `core/bool@1`; we reference it
+ * by ID here without taking a peer-dep on the package that defines it.
+ * This is the same pattern pgvector uses (`pg/float8@1`) for its return
+ * codec without importing it.
+ */
+const RETURN_BOOL = {
+ codecId: 'core/bool@1',
+ nullable: false,
+} as const
+
+/**
+ * Storage-codec self-reference (`{{self}}`) — the column being filtered.
+ * Always non-null at this site because the operator is invoked on a column
+ * accessor, not a free-standing expression.
+ */
+const SELF_STORAGE = {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ nullable: false,
+} as const
+
+/**
+ * Storage-codec return spec used by `jsonbGet` / `jsonbPathQueryFirst`.
+ * These operators return an `eql_v2_encrypted` value (the encrypted JSON
+ * sub-document), which the storage codec will decode to a JS value on
+ * read.
+ */
+const RETURN_STORAGE = {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ nullable: true,
+} as const
+
+/**
+ * Custom comparison operations for encrypted columns.
+ *
+ * Each operator pins a *value-side* codec via `args[1].codecId` so the
+ * single plaintext literal supplied to the user-facing method (`.eq(x)` /
+ * `.gte(x)` / `.like(x)` / etc.) encrypts as the right query-term shape
+ * for the underlying EQL function. The lowering template substitutes
+ * `{{self}}` for the column reference and `{{argN}}` for the user-side
+ * arguments (i.e. positional: `args[N+1]` of the descriptor — `args[0]`
+ * is `self`).
+ *
+ * Why we lower to `eql_v2.(...)` SQL functions instead of native SQL
+ * operators: EQL ciphers contain randomized nonces / IVs, so two encrypts
+ * of the same plaintext do not byte-equal under SQL `=`. The dedicated
+ * EQL functions short-circuit to the appropriate index (HMAC for
+ * equality, ORE blocks for range, bloom filter for match, STE-vec for
+ * jsonb selectors) and produce correct results in every case. This is
+ * the entire reason we diverge from pgvector's "native operator with
+ * codec coercion" pattern.
+ *
+ * Operator-to-EQL-function map verified against
+ * `packages/stack/src/drizzle/operators.ts` (the source of truth for EQL
+ * call shapes).
+ */
+export const encryptedQueryOperations: readonly SqlOperationDescriptor[] = [
+ // ---------------------------------------------------------------------
+ // Equality (gated at the column-type level on `typeParams.equality`).
+ // ---------------------------------------------------------------------
+ {
+ method: 'eq',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_EQ_TERM_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template: 'eql_v2.eq({{self}}, {{arg0}})',
+ },
+ },
+ {
+ method: 'neq',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_EQ_TERM_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template: 'eql_v2.neq({{self}}, {{arg0}})',
+ },
+ },
+
+ // ---------------------------------------------------------------------
+ // Range / order (gated on `typeParams.orderAndRange`). Drizzle
+ // implementation: `sql\`eql_v2.gt(${left}, ${bindIfParam(right)})\`` etc.
+ // ---------------------------------------------------------------------
+ {
+ method: 'gt',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_ORE_TERM_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template: 'eql_v2.gt({{self}}, {{arg0}})',
+ },
+ },
+ {
+ method: 'gte',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_ORE_TERM_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template: 'eql_v2.gte({{self}}, {{arg0}})',
+ },
+ },
+ {
+ method: 'lt',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_ORE_TERM_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template: 'eql_v2.lt({{self}}, {{arg0}})',
+ },
+ },
+ {
+ method: 'lte',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_ORE_TERM_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template: 'eql_v2.lte({{self}}, {{arg0}})',
+ },
+ },
+ // `between` and `notBetween` mirror Drizzle's lowering:
+ // eql_v2.gte(self, min) AND eql_v2.lte(self, max)
+ // notBetween wraps the above in `NOT (...)`.
+ {
+ method: 'between',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_ORE_TERM_CODEC_ID, nullable: false },
+ { codecId: ENCRYPTED_ORE_TERM_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template:
+ '(eql_v2.gte({{self}}, {{arg0}}) AND eql_v2.lte({{self}}, {{arg1}}))',
+ },
+ },
+ {
+ method: 'notBetween',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_ORE_TERM_CODEC_ID, nullable: false },
+ { codecId: ENCRYPTED_ORE_TERM_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template:
+ 'NOT (eql_v2.gte({{self}}, {{arg0}}) AND eql_v2.lte({{self}}, {{arg1}}))',
+ },
+ },
+
+ // ---------------------------------------------------------------------
+ // Free-text search (gated on `typeParams.freeTextSearch`). Drizzle
+ // implementation: `sql\`eql_v2.${operator}(${left}, ${bindIfParam(right)})\``.
+ // For `notIlike` Drizzle wraps `eql_v2.ilike(...)` in `NOT (...)` — there
+ // is no separate `eql_v2.not_ilike` SQL function.
+ // ---------------------------------------------------------------------
+ {
+ method: 'like',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_MATCH_TERM_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template: 'eql_v2.like({{self}}, {{arg0}})',
+ },
+ },
+ {
+ method: 'ilike',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_MATCH_TERM_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template: 'eql_v2.ilike({{self}}, {{arg0}})',
+ },
+ },
+ {
+ method: 'notIlike',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_MATCH_TERM_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template: 'NOT (eql_v2.ilike({{self}}, {{arg0}}))',
+ },
+ },
+
+ // ---------------------------------------------------------------------
+ // JSONB / STE-Vec (gated on `typeParams.searchableJson`). Selectors
+ // encrypt as STE-vec query terms and cast to `eql_v2_encrypted` at the
+ // call site, mirroring Drizzle's lowering.
+ // ---------------------------------------------------------------------
+ {
+ method: 'jsonbPathExists',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_BOOL,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template:
+ 'eql_v2.jsonb_path_exists({{self}}, {{arg0}}::eql_v2_encrypted)',
+ },
+ },
+ {
+ method: 'jsonbPathQueryFirst',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_STORAGE,
+ lowering: {
+ targetFamily: 'sql',
+ strategy: 'function',
+ template:
+ 'eql_v2.jsonb_path_query_first({{self}}, {{arg0}}::eql_v2_encrypted)',
+ },
+ },
+ {
+ method: 'jsonbGet',
+ args: [
+ SELF_STORAGE,
+ { codecId: ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID, nullable: false },
+ ],
+ returns: RETURN_STORAGE,
+ lowering: {
+ targetFamily: 'sql',
+ // `->` is a SQL infix operator. Drizzle emits the same shape:
+ // ${left} -> ${selector}::eql_v2_encrypted
+ strategy: 'infix',
+ template: '({{self}} -> {{arg0}}::eql_v2_encrypted)',
+ },
+ },
+
+ // ---------------------------------------------------------------------
+ // Order-by helpers — explicitly DEFERRED in Phase 2.
+ //
+ // Drizzle wraps `asc(sql\`eql_v2.order_by(${col})\`)` / `desc(...)`. The
+ // Prisma Next public surface for fluent column-side `.order().asc()` /
+ // `.desc()` and for SQL `ORDER BY`-time function wrapping is still
+ // unstable in the post-#379 type/control plane. Until that surface
+ // settles, users can fall back to a raw SQL fragment via
+ // `sqlExpression\`eql_v2.order_by(${col}) DESC\``. We'll revisit when
+ // the Prisma Next ORM exposes a clean fluent-order seam (TML-2330 is
+ // adjacent but not the same; the order-by surface is its own roadmap
+ // item).
+ // ---------------------------------------------------------------------
+
+ // ---------------------------------------------------------------------
+ // `inArray` — DEFERRED (no dedicated `eql_v2.in_array` function exists
+ // in EQL). Drizzle composes it as `OR`-ed `eql_v2.eq(...)` calls; the
+ // current Prisma Next operator-template lowering expects a single
+ // template string with positional arguments and doesn't support
+ // variadic OR-fold lowerings. Reopen if EQL gains a function or the
+ // framework gains list-aware lowering.
+ // ---------------------------------------------------------------------
+]
diff --git a/packages/stack/src/prisma/core/wire.ts b/packages/stack/src/prisma/core/wire.ts
new file mode 100644
index 00000000..e4f4ddea
--- /dev/null
+++ b/packages/stack/src/prisma/core/wire.ts
@@ -0,0 +1,55 @@
+import type { Encrypted as FfiEncrypted } from '@cipherstash/protect-ffi'
+
+/**
+ * Encode/decode helpers for the Postgres `eql_v2_encrypted` composite type.
+ *
+ * The composite has a single text field that holds the EQL JSON envelope.
+ * Postgres represents this on the wire (via node-postgres) as a literal
+ * string of the form `("")` — i.e. the outer parens delimit
+ * the composite, the inner double-quotes wrap the field, and embedded
+ * double-quotes are doubled (`""`). These helpers are the inverse of the
+ * composite-literal helpers in `packages/stack/src/encryption/helpers/`,
+ * but specialized to the codec's wire shape (string in/string out) and
+ * tolerant of the small variations the driver actually returns.
+ */
+
+export type EqlEncrypted = FfiEncrypted
+
+/**
+ * Convert an `Encrypted` JSON envelope into the Postgres composite-literal
+ * form expected by the `eql_v2_encrypted` type.
+ */
+export function eqlToCompositeLiteral(encrypted: EqlEncrypted): string {
+ const json = JSON.stringify(encrypted)
+ // Inside a composite literal, double-quotes inside a field value are
+ // doubled. The outer pair of quotes wraps the whole field; outer parens
+ // wrap the composite tuple.
+ const escaped = json.replace(/"/g, '""')
+ return `("${escaped}")`
+}
+
+/**
+ * Parse the wire string returned for an `eql_v2_encrypted` composite back
+ * into an `Encrypted` JSON envelope. Mirrors the parser in the Drizzle
+ * integration so behavioral parity is preserved for users moving between
+ * ORMs.
+ */
+export function eqlFromCompositeLiteral(wire: string): EqlEncrypted {
+ if (typeof wire !== 'string') {
+ throw new TypeError(
+ `eql_v2_encrypted wire value must be a string, got ${typeof wire}`,
+ )
+ }
+ const trimmed = wire.trim()
+ if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
+ let inner = trimmed.slice(1, -1)
+ inner = inner.replace(/""/g, '"')
+ if (inner.startsWith('"') && inner.endsWith('"')) {
+ inner = inner.slice(1, -1)
+ }
+ return JSON.parse(inner) as EqlEncrypted
+ }
+ // Some drivers or row materializers may already hand back the raw JSON
+ // (e.g. when the column is selected through a JSON-projecting helper).
+ return JSON.parse(trimmed) as EqlEncrypted
+}
diff --git a/packages/stack/src/prisma/exports/codec-types.ts b/packages/stack/src/prisma/exports/codec-types.ts
new file mode 100644
index 00000000..cc945b3b
--- /dev/null
+++ b/packages/stack/src/prisma/exports/codec-types.ts
@@ -0,0 +1,201 @@
+/**
+ * Type-only emission targets for `@cipherstash/stack/prisma`.
+ *
+ * Imported from generated `contract.d.ts` files at build time. Runtime
+ * imports go through `./runtime` and `./pack`.
+ */
+
+import type {
+ ENCRYPTED_EQ_TERM_CODEC_ID,
+ ENCRYPTED_MATCH_TERM_CODEC_ID,
+ ENCRYPTED_ORE_TERM_CODEC_ID,
+ ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID,
+ ENCRYPTED_STORAGE_CODEC_ID,
+ EncryptedDataType,
+} from '../core/constants'
+import type { JSON_SHAPE } from '../core/json-shape'
+
+/**
+ * Map a `dataType` literal to the JS-side type produced by an encrypted
+ * column on read/write paths. The dataType is read off the column's
+ * `typeParams` discriminator at type-emission time, so the generated
+ * `contract.d.ts` produces `string | number | boolean | Date | `
+ * exactly as the user authored.
+ *
+ * For `dataType: 'json'`, the `TJsonShape` type parameter carries the
+ * shape supplied via `encryptedJson(...)`. The phantom slot on the
+ * column descriptor (`__jsonShape`) propagates `T` from authoring
+ * through to the `Decrypted` helper below.
+ */
+export type JsTypeFor<
+ TDataType extends EncryptedDataType,
+ TJsonShape = unknown,
+> = TDataType extends 'string'
+ ? string
+ : TDataType extends 'number'
+ ? number
+ : TDataType extends 'boolean'
+ ? boolean
+ : TDataType extends 'date'
+ ? Date
+ : TDataType extends 'json'
+ ? TJsonShape
+ : never
+
+/**
+ * Codec input/output types keyed by codec ID. The storage codec's
+ * input/output is parameterized by `dataType` (driven by the column's
+ * `typeParams`); the query-term codecs are write-only and parameterized
+ * to the JS shape the operator surface accepts.
+ *
+ * The contract emitter consumes this map by codec ID — the same way
+ * pgvector's `CodecTypes` is consumed — and substitutes the JS-side
+ * type into the generated model definitions.
+ */
+export type CodecTypes<
+ TDataType extends EncryptedDataType = EncryptedDataType,
+> = {
+ readonly [ENCRYPTED_STORAGE_CODEC_ID]: {
+ readonly input: JsTypeFor
+ readonly output: JsTypeFor
+ readonly traits: 'equality'
+ }
+ readonly [ENCRYPTED_EQ_TERM_CODEC_ID]: {
+ readonly input: JsTypeFor
+ readonly output: never
+ readonly traits: never
+ }
+ readonly [ENCRYPTED_MATCH_TERM_CODEC_ID]: {
+ readonly input: string
+ readonly output: never
+ readonly traits: never
+ }
+ readonly [ENCRYPTED_ORE_TERM_CODEC_ID]: {
+ readonly input: number | Date
+ readonly output: never
+ readonly traits: never
+ }
+ readonly [ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID]: {
+ readonly input: string
+ readonly output: never
+ readonly traits: never
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Decrypted — public type helper for users typing
+// function signatures around decrypted rows.
+// ---------------------------------------------------------------------------
+
+/**
+ * Minimal column shape consumed by `Decrypted<...>` — the codec ID
+ * and structured `typeParams.dataType` are enough to resolve the
+ * decrypted JS type per column.
+ *
+ * The phantom `[JSON_SHAPE]` slot is the same symbol used by
+ * `encryptedJson(...)` (see `core/json-shape.ts`), so `T`
+ * propagates from authoring through to the helper without any cast.
+ */
+type DecryptedColumnShape = {
+ readonly codecId?: string
+ readonly typeParams?: {
+ readonly dataType?: EncryptedDataType
+ readonly [k: string]: unknown
+ }
+ // Phantom shape from `encryptedJson(...)` — picked up at the
+ // type level, never present at runtime.
+ readonly [JSON_SHAPE]?: unknown
+}
+
+/**
+ * Resolve the JS-side type for an encrypted column. Walks
+ * `typeParams.dataType` and, for JSON columns, prefers the phantom
+ * `__jsonShape` slot — falling back to `unknown` only when the user
+ * authored `encryptedJson({...})` without a type argument.
+ */
+type ResolveDecryptedType = TColumn extends DecryptedColumnShape
+ ? TColumn['codecId'] extends typeof ENCRYPTED_STORAGE_CODEC_ID
+ ? TColumn['typeParams'] extends { readonly dataType: infer TDataType }
+ ? TDataType extends 'json'
+ ? // Strip `undefined` (the slot is optional at the type level
+ // because authoring never sets a runtime value for it). When
+ // the column was authored with `encryptedJson(...)`,
+ // `Exclude<...>` narrows back to `T`; when authored without
+ // a type argument, the slot is `unknown` and we surface
+ // `unknown` to the user.
+ Exclude extends infer TShape
+ ? unknown extends TShape
+ ? unknown
+ : TShape
+ : unknown
+ : TDataType extends EncryptedDataType
+ ? JsTypeFor
+ : never
+ : never
+ : never
+ : never
+
+/**
+ * Resolve a single field's JS-side type. Encrypted columns surface
+ * their decrypted type; unrelated columns fall back to a permissive
+ * `unknown` (since we can only read this contract via its narrow
+ * structural slice — the integration doesn't pretend to know the JS
+ * type for non-encrypted columns).
+ */
+type ResolveFieldType = TField extends DecryptedColumnShape
+ ? TField['codecId'] extends typeof ENCRYPTED_STORAGE_CODEC_ID
+ ? ResolveDecryptedType
+ : unknown
+ : unknown
+
+/**
+ * Minimal Prisma Next contract shape consumed by `Decrypted`.
+ *
+ * The contract has many more fields; we only consume what we need to
+ * walk `models[Model].fields[FieldName]`. Authoring-time
+ * `encryptedString({...})` / `encryptedNumber({...})` / etc.
+ * descriptors live at this position.
+ */
+export type DecryptedContractShape = {
+ readonly models?: {
+ readonly [modelName: string]: {
+ readonly fields?: {
+ readonly [fieldName: string]: unknown
+ }
+ }
+ }
+}
+
+/**
+ * Walk a contract's model and return a row shape with encrypted
+ * fields narrowed to their decrypted JS types.
+ *
+ * Useful for users who want to type their function signatures around
+ * decrypted rows:
+ *
+ * ```ts
+ * import type { Decrypted } from '@cipherstash/stack/prisma/codec-types'
+ * import { contract } from './contract'
+ *
+ * type DecryptedUser = Decrypted
+ *
+ * function welcome(user: DecryptedUser) {
+ * console.log(user.email.toLowerCase()) // string
+ * console.log(user.profile.name) // typed via encryptedJson
+ * }
+ * ```
+ */
+export type Decrypted<
+ TContract extends DecryptedContractShape,
+ TModel extends keyof NonNullable,
+> = TContract['models'] extends infer TModels
+ ? TModels extends {
+ readonly [K in TModel]: { readonly fields?: infer TFields }
+ }
+ ? TFields extends Record
+ ? {
+ [K in keyof TFields]: ResolveFieldType
+ }
+ : never
+ : never
+ : never
diff --git a/packages/stack/src/prisma/exports/column-types.ts b/packages/stack/src/prisma/exports/column-types.ts
new file mode 100644
index 00000000..dae5e6e7
--- /dev/null
+++ b/packages/stack/src/prisma/exports/column-types.ts
@@ -0,0 +1,308 @@
+import {
+ ENCRYPTED_NATIVE_TYPE,
+ ENCRYPTED_STORAGE_CODEC_ID,
+ type EncryptedDataType,
+} from '../core/constants'
+import { JSON_SHAPE } from '../core/json-shape'
+import type { ColumnTypeDescriptor } from '../internal-types/prisma-next'
+
+/**
+ * Common typeParams shape carried on every encrypted column descriptor.
+ *
+ * The four searchable-encryption flags are *always present* on the
+ * descriptor (even when false) so the migration planner sees a uniform
+ * shape and can diff configs per field without normalization. The literal
+ * `dataType` discriminator drives `OperationTypes` argument-type resolution
+ * (`.gte(param)` takes `number` for `dataType: 'number'`, `Date` for
+ * `'date'`, etc.) and `CodecTypes` JS-side input/output type resolution.
+ */
+export interface EncryptedTypeParams<
+ TDataType extends EncryptedDataType = EncryptedDataType,
+> {
+ readonly dataType: TDataType
+ readonly equality: boolean
+ readonly freeTextSearch: boolean
+ readonly orderAndRange: boolean
+ readonly searchableJson: boolean
+}
+
+// =============================================================================
+// encryptedString
+// =============================================================================
+
+export interface EncryptedStringConfig {
+ readonly equality?: boolean
+ readonly freeTextSearch?: boolean
+ // String columns have no natural ordering on EQL indexes; we keep the key
+ // present in the type so configurations diff cleanly across factories,
+ // but only `false` (or omission) is accepted.
+ readonly orderAndRange?: false
+ // Searchable JSON belongs to `encryptedJson`; reject it on string columns.
+ readonly searchableJson?: false
+}
+
+export type EncryptedStringTypeParams = EncryptedTypeParams<'string'>
+
+export interface EncryptedStringColumn
+ extends ColumnTypeDescriptor {
+ readonly codecId: typeof ENCRYPTED_STORAGE_CODEC_ID
+ readonly nativeType: typeof ENCRYPTED_NATIVE_TYPE
+ readonly typeParams: {
+ readonly dataType: 'string'
+ readonly equality: TConfig['equality'] extends true ? true : false
+ readonly freeTextSearch: TConfig['freeTextSearch'] extends true
+ ? true
+ : false
+ readonly orderAndRange: false
+ readonly searchableJson: false
+ }
+}
+
+/**
+ * Encrypted string column descriptor.
+ *
+ * @example
+ * email: field.column(encryptedString({ equality: true, freeTextSearch: true }))
+ */
+export function encryptedString(
+ config: TConfig = {} as TConfig,
+): EncryptedStringColumn {
+ const typeParams = {
+ dataType: 'string',
+ equality: config.equality === true,
+ freeTextSearch: config.freeTextSearch === true,
+ orderAndRange: false,
+ searchableJson: false,
+ } as EncryptedStringColumn['typeParams']
+
+ return {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ nativeType: ENCRYPTED_NATIVE_TYPE,
+ typeParams,
+ }
+}
+
+// =============================================================================
+// encryptedNumber
+// =============================================================================
+
+export interface EncryptedNumberConfig {
+ readonly equality?: boolean
+ readonly orderAndRange?: boolean
+ readonly freeTextSearch?: false
+ readonly searchableJson?: false
+}
+
+export type EncryptedNumberTypeParams = EncryptedTypeParams<'number'>
+
+export interface EncryptedNumberColumn
+ extends ColumnTypeDescriptor {
+ readonly codecId: typeof ENCRYPTED_STORAGE_CODEC_ID
+ readonly nativeType: typeof ENCRYPTED_NATIVE_TYPE
+ readonly typeParams: {
+ readonly dataType: 'number'
+ readonly equality: TConfig['equality'] extends true ? true : false
+ readonly freeTextSearch: false
+ readonly orderAndRange: TConfig['orderAndRange'] extends true ? true : false
+ readonly searchableJson: false
+ }
+}
+
+/**
+ * Encrypted numeric column descriptor.
+ *
+ * @example
+ * age: field.column(encryptedNumber({ orderAndRange: true }))
+ */
+export function encryptedNumber(
+ config: TConfig = {} as TConfig,
+): EncryptedNumberColumn {
+ const typeParams = {
+ dataType: 'number',
+ equality: config.equality === true,
+ freeTextSearch: false,
+ orderAndRange: config.orderAndRange === true,
+ searchableJson: false,
+ } as EncryptedNumberColumn['typeParams']
+
+ return {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ nativeType: ENCRYPTED_NATIVE_TYPE,
+ typeParams,
+ }
+}
+
+// =============================================================================
+// encryptedDate
+// =============================================================================
+
+export interface EncryptedDateConfig {
+ readonly equality?: boolean
+ readonly orderAndRange?: boolean
+ readonly freeTextSearch?: false
+ readonly searchableJson?: false
+}
+
+export type EncryptedDateTypeParams = EncryptedTypeParams<'date'>
+
+export interface EncryptedDateColumn
+ extends ColumnTypeDescriptor {
+ readonly codecId: typeof ENCRYPTED_STORAGE_CODEC_ID
+ readonly nativeType: typeof ENCRYPTED_NATIVE_TYPE
+ readonly typeParams: {
+ readonly dataType: 'date'
+ readonly equality: TConfig['equality'] extends true ? true : false
+ readonly freeTextSearch: false
+ readonly orderAndRange: TConfig['orderAndRange'] extends true ? true : false
+ readonly searchableJson: false
+ }
+}
+
+/**
+ * Encrypted date/timestamp column descriptor.
+ *
+ * The JS-side type is `Date`; the codec serializes Date instances to ISO
+ * strings for the FFI's `cast_as: 'date'` round-trip and rehydrates back
+ * to a `Date` on `decode`.
+ *
+ * @example
+ * createdAt: field.column(encryptedDate({ orderAndRange: true }))
+ */
+export function encryptedDate(
+ config: TConfig = {} as TConfig,
+): EncryptedDateColumn {
+ const typeParams = {
+ dataType: 'date',
+ equality: config.equality === true,
+ freeTextSearch: false,
+ orderAndRange: config.orderAndRange === true,
+ searchableJson: false,
+ } as EncryptedDateColumn['typeParams']
+
+ return {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ nativeType: ENCRYPTED_NATIVE_TYPE,
+ typeParams,
+ }
+}
+
+// =============================================================================
+// encryptedBoolean
+// =============================================================================
+
+export interface EncryptedBooleanConfig {
+ readonly equality?: boolean
+ readonly freeTextSearch?: false
+ readonly orderAndRange?: false
+ readonly searchableJson?: false
+}
+
+export type EncryptedBooleanTypeParams = EncryptedTypeParams<'boolean'>
+
+export interface EncryptedBooleanColumn
+ extends ColumnTypeDescriptor {
+ readonly codecId: typeof ENCRYPTED_STORAGE_CODEC_ID
+ readonly nativeType: typeof ENCRYPTED_NATIVE_TYPE
+ readonly typeParams: {
+ readonly dataType: 'boolean'
+ readonly equality: TConfig['equality'] extends true ? true : false
+ readonly freeTextSearch: false
+ readonly orderAndRange: false
+ readonly searchableJson: false
+ }
+}
+
+/**
+ * Encrypted boolean column descriptor.
+ *
+ * Booleans only support equality search — there's no useful ordering or
+ * substring search on a two-element domain.
+ *
+ * @example
+ * isActive: field.column(encryptedBoolean({ equality: true }))
+ */
+export function encryptedBoolean(
+ config: TConfig = {} as TConfig,
+): EncryptedBooleanColumn {
+ const typeParams = {
+ dataType: 'boolean',
+ equality: config.equality === true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: false,
+ } as EncryptedBooleanColumn['typeParams']
+
+ return {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ nativeType: ENCRYPTED_NATIVE_TYPE,
+ typeParams,
+ }
+}
+
+// =============================================================================
+// encryptedJson
+// =============================================================================
+
+export interface EncryptedJsonConfig {
+ readonly equality?: boolean
+ readonly searchableJson?: boolean
+ readonly freeTextSearch?: false
+ readonly orderAndRange?: false
+}
+
+export type EncryptedJsonTypeParams = EncryptedTypeParams<'json'>
+
+/**
+ * Phantom slot carrying the user's JSON shape so `Decrypted` can
+ * surface `T` rather than `unknown` on the read path. The shape is
+ * never present at runtime; it only exists in the type system.
+ */
+export interface EncryptedJsonColumn<
+ TShape,
+ TConfig extends EncryptedJsonConfig,
+> extends ColumnTypeDescriptor {
+ readonly codecId: typeof ENCRYPTED_STORAGE_CODEC_ID
+ readonly nativeType: typeof ENCRYPTED_NATIVE_TYPE
+ readonly typeParams: {
+ readonly dataType: 'json'
+ readonly equality: TConfig['equality'] extends true ? true : false
+ readonly freeTextSearch: false
+ readonly orderAndRange: false
+ readonly searchableJson: TConfig['searchableJson'] extends true
+ ? true
+ : false
+ }
+ readonly [JSON_SHAPE]?: TShape
+}
+
+/**
+ * Encrypted searchable-JSON column descriptor.
+ *
+ * Pass the typed JSON shape as the type argument; the descriptor carries
+ * it forward so `Decrypted` resolves the JS-side type to
+ * `T` rather than `unknown`. The wire round-trip is plain `JSON.parse` /
+ * `JSON.stringify`.
+ *
+ * @example
+ * profile: field.column(
+ * encryptedJson<{ name: string; bio: string }>({ searchableJson: true }),
+ * )
+ */
+export function encryptedJson<
+ TShape = unknown,
+ const TConfig extends EncryptedJsonConfig = EncryptedJsonConfig,
+>(config: TConfig = {} as TConfig): EncryptedJsonColumn {
+ const typeParams = {
+ dataType: 'json',
+ equality: config.equality === true,
+ freeTextSearch: false,
+ orderAndRange: false,
+ searchableJson: config.searchableJson === true,
+ } as EncryptedJsonColumn['typeParams']
+
+ return {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ nativeType: ENCRYPTED_NATIVE_TYPE,
+ typeParams,
+ }
+}
diff --git a/packages/stack/src/prisma/exports/control.ts b/packages/stack/src/prisma/exports/control.ts
new file mode 100644
index 00000000..2cd46031
--- /dev/null
+++ b/packages/stack/src/prisma/exports/control.ts
@@ -0,0 +1,55 @@
+import { ENCRYPTED_STORAGE_CODEC_ID } from '../core/constants'
+import {
+ getCipherStashDatabaseDependencies,
+ planEncryptedTypeOperations,
+} from '../core/database-dependencies'
+import { cipherstashPackMeta } from '../core/descriptor-meta'
+import { encryptedQueryOperations } from '../core/operation-templates'
+import type {
+ CodecControlHooks,
+ SqlControlExtensionDescriptor,
+} from '../internal-types/prisma-next'
+
+/**
+ * Build-time / migration-planner descriptor.
+ *
+ * Wires:
+ * - `databaseDependencies.init` from the vendored EQL install bundle
+ * (`core/eql-install.sql`). The migration planner runs this on
+ * first attach to a database.
+ * - `controlPlaneHooks[storage codec ID].planTypeOperations` so the
+ * planner emits per-column EQL search-index DDL whenever encrypted
+ * columns are added or their typeParams change.
+ *
+ * The descriptor's `version` is the pack-meta version verbatim. The
+ * EQL bundle version is pinned at build time (`scripts/vendor-eql-install.ts`)
+ * and surfaced separately in `databaseDependencies.init[*].install[*].meta.eqlBundleVersion`
+ * so the migration planner can correlate installed-vs-target EQL
+ * versions without conflating them with the pack version.
+ */
+const cipherstashControlPlaneHooks: CodecControlHooks = {
+ planTypeOperations: planEncryptedTypeOperations,
+}
+
+const cipherstashEncryptionControl: SqlControlExtensionDescriptor<'postgres'> =
+ {
+ ...cipherstashPackMeta,
+ types: {
+ ...cipherstashPackMeta.types,
+ codecTypes: {
+ ...cipherstashPackMeta.types.codecTypes,
+ controlPlaneHooks: {
+ [ENCRYPTED_STORAGE_CODEC_ID]: cipherstashControlPlaneHooks,
+ },
+ },
+ },
+ queryOperations: () => encryptedQueryOperations,
+ databaseDependencies: getCipherStashDatabaseDependencies(),
+ create: () => ({
+ familyId: 'sql' as const,
+ targetId: 'postgres' as const,
+ }),
+ }
+
+export { cipherstashEncryptionControl }
+export default cipherstashEncryptionControl
diff --git a/packages/stack/src/prisma/exports/operation-types.ts b/packages/stack/src/prisma/exports/operation-types.ts
new file mode 100644
index 00000000..3f685b0e
--- /dev/null
+++ b/packages/stack/src/prisma/exports/operation-types.ts
@@ -0,0 +1,269 @@
+/**
+ * Conditional operation types for encrypted columns.
+ *
+ * Phase 2 adds full gating: `.eq()` / `.neq()` only on equality columns,
+ * `.gt()` / `.gte()` / `.lt()` / `.lte()` / `.between()` / `.notBetween()`
+ * only on `orderAndRange` columns, `.like()` / `.ilike()` / `.notIlike()`
+ * only on `freeTextSearch` columns, `.jsonbPathExists()` / `jsonbPathQueryFirst`
+ * / `jsonbGet` only on `searchableJson` columns. The argument JS-type is
+ * dispatched off `typeParams.dataType` (e.g. `.gte(param)` takes `Date` for
+ * an `encryptedDate` column and `number` for `encryptedNumber`).
+ *
+ * The framework's `contract.d.ts` emitter consumes the `OperationTypes`
+ * named export to attach methods onto the corresponding column accessor in
+ * `db.orm..where(u => u..(...))`. This file matches
+ * pgvector's `OperationTypes` shape one-for-one for structural fidelity.
+ */
+
+import type {
+ ENCRYPTED_EQ_TERM_CODEC_ID,
+ ENCRYPTED_MATCH_TERM_CODEC_ID,
+ ENCRYPTED_ORE_TERM_CODEC_ID,
+ ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID,
+ ENCRYPTED_STORAGE_CODEC_ID,
+ EncryptedDataType,
+} from '../core/constants'
+import type { EncryptedTypeParams } from './column-types'
+
+// ---------------------------------------------------------------------------
+// Trait gates — compile-time predicates over the column's typeParams.
+// ---------------------------------------------------------------------------
+
+type HasEquality = T extends { readonly equality: true } ? true : false
+type HasFreeTextSearch = T extends { readonly freeTextSearch: true }
+ ? true
+ : false
+type HasOrderAndRange = T extends { readonly orderAndRange: true }
+ ? true
+ : false
+type HasSearchableJson = T extends { readonly searchableJson: true }
+ ? true
+ : false
+
+// ---------------------------------------------------------------------------
+// dataType-driven JS-side argument typing.
+// ---------------------------------------------------------------------------
+
+/**
+ * Map a `dataType` literal to the JS-side argument type accepted by an
+ * operator's value-side parameter. `.gte(x)` on an `encryptedDate` column
+ * takes `Date`; on `encryptedNumber` it takes `number`. JSON columns
+ * surface `unknown` because their typed shape lives on the column
+ * descriptor's phantom slot, not in `typeParams`.
+ */
+type ArgTypeFor =
+ TDataType extends 'string'
+ ? string
+ : TDataType extends 'number'
+ ? number
+ : TDataType extends 'boolean'
+ ? boolean
+ : TDataType extends 'date'
+ ? Date
+ : TDataType extends 'json'
+ ? unknown
+ : never
+
+// ---------------------------------------------------------------------------
+// Per-method argument specs. Each spec mirrors what's emitted from the
+// runtime extension's operator descriptor in `core/operation-templates.ts`.
+// We don't redeclare lowering templates here — the emitter only inspects
+// `args` / `returns`.
+// ---------------------------------------------------------------------------
+
+type EqArgSpec = {
+ readonly args: readonly [
+ {
+ readonly codecId: typeof ENCRYPTED_EQ_TERM_CODEC_ID
+ readonly nullable: false
+ readonly inputType: ArgTypeFor
+ },
+ ]
+ readonly returns: {
+ readonly codecId: 'core/bool@1'
+ readonly nullable: false
+ }
+ readonly lowering: {
+ readonly targetFamily: 'sql'
+ readonly strategy: 'function'
+ readonly template: string
+ }
+}
+
+type OreArgSpec = {
+ readonly args: readonly [
+ {
+ readonly codecId: typeof ENCRYPTED_ORE_TERM_CODEC_ID
+ readonly nullable: false
+ readonly inputType: ArgTypeFor
+ },
+ ]
+ readonly returns: {
+ readonly codecId: 'core/bool@1'
+ readonly nullable: false
+ }
+ readonly lowering: {
+ readonly targetFamily: 'sql'
+ readonly strategy: 'function'
+ readonly template: string
+ }
+}
+
+type BetweenArgSpec = {
+ readonly args: readonly [
+ {
+ readonly codecId: typeof ENCRYPTED_ORE_TERM_CODEC_ID
+ readonly nullable: false
+ readonly inputType: ArgTypeFor
+ },
+ {
+ readonly codecId: typeof ENCRYPTED_ORE_TERM_CODEC_ID
+ readonly nullable: false
+ readonly inputType: ArgTypeFor
+ },
+ ]
+ readonly returns: {
+ readonly codecId: 'core/bool@1'
+ readonly nullable: false
+ }
+ readonly lowering: {
+ readonly targetFamily: 'sql'
+ readonly strategy: 'function'
+ readonly template: string
+ }
+}
+
+type MatchArgSpec = {
+ readonly args: readonly [
+ {
+ readonly codecId: typeof ENCRYPTED_MATCH_TERM_CODEC_ID
+ readonly nullable: false
+ readonly inputType: string
+ },
+ ]
+ readonly returns: {
+ readonly codecId: 'core/bool@1'
+ readonly nullable: false
+ }
+ readonly lowering: {
+ readonly targetFamily: 'sql'
+ readonly strategy: 'function'
+ readonly template: string
+ }
+}
+
+type SteVecBoolArgSpec = {
+ readonly args: readonly [
+ {
+ readonly codecId: typeof ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID
+ readonly nullable: false
+ readonly inputType: string
+ },
+ ]
+ readonly returns: {
+ readonly codecId: 'core/bool@1'
+ readonly nullable: false
+ }
+ readonly lowering: {
+ readonly targetFamily: 'sql'
+ readonly strategy: 'function'
+ readonly template: string
+ }
+}
+
+type SteVecStorageArgSpec = {
+ readonly args: readonly [
+ {
+ readonly codecId: typeof ENCRYPTED_STE_VEC_SELECTOR_CODEC_ID
+ readonly nullable: false
+ readonly inputType: string
+ },
+ ]
+ readonly returns: {
+ readonly codecId: typeof ENCRYPTED_STORAGE_CODEC_ID
+ readonly nullable: true
+ }
+ readonly lowering: {
+ readonly targetFamily: 'sql'
+ readonly strategy: 'function' | 'infix'
+ readonly template: string
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Conditional method bags. Each bag emits zero-or-more keys depending on
+// the corresponding `typeParams` flag. Empty branches produce
+// `Record`, which the framework's emitter treats as "no
+// methods of this kind".
+// ---------------------------------------------------------------------------
+
+type EqualityMethods =
+ HasEquality extends true
+ ? {
+ readonly eq: EqArgSpec
+ readonly neq: EqArgSpec
+ }
+ : Record
+
+type RangeMethods =
+ HasOrderAndRange extends true
+ ? TParams['dataType'] extends 'number' | 'date'
+ ? {
+ readonly gt: OreArgSpec
+ readonly gte: OreArgSpec
+ readonly lt: OreArgSpec
+ readonly lte: OreArgSpec
+ readonly between: BetweenArgSpec
+ readonly notBetween: BetweenArgSpec
+ }
+ : Record
+ : Record
+
+type TextMethods =
+ HasFreeTextSearch extends true
+ ? TParams['dataType'] extends 'string'
+ ? {
+ readonly like: MatchArgSpec
+ readonly ilike: MatchArgSpec
+ readonly notIlike: MatchArgSpec
+ }
+ : Record
+ : Record
+
+type JsonMethods =
+ HasSearchableJson extends true
+ ? TParams['dataType'] extends 'json'
+ ? {
+ readonly jsonbPathExists: SteVecBoolArgSpec
+ readonly jsonbPathQueryFirst: SteVecStorageArgSpec
+ readonly jsonbGet: SteVecStorageArgSpec
+ }
+ : Record
+ : Record
+
+/**
+ * Combined storage-codec method bag. Intersection-merge over the four
+ * trait-gated bags so the generated column accessor exposes every
+ * applicable method without overlap (the bags share no keys by
+ * construction).
+ */
+type StorageMethods =
+ EqualityMethods &
+ RangeMethods &
+ TextMethods &
+ JsonMethods
+
+/**
+ * Public `OperationTypes` map keyed by codec ID. Pgvector keys this map
+ * by codec ID; we do the same. The value is parameterized by `TParams`
+ * so the emitter can specialize per-column instead of advertising every
+ * method on every encrypted column.
+ *
+ * The four query-term codecs are write-only and don't surface user-facing
+ * methods, so they aren't keyed in this map.
+ */
+export type OperationTypes<
+ TParams extends EncryptedTypeParams = EncryptedTypeParams,
+> = {
+ readonly [ENCRYPTED_STORAGE_CODEC_ID]: StorageMethods
+}
diff --git a/packages/stack/src/prisma/exports/pack.ts b/packages/stack/src/prisma/exports/pack.ts
new file mode 100644
index 00000000..1bb40246
--- /dev/null
+++ b/packages/stack/src/prisma/exports/pack.ts
@@ -0,0 +1 @@
+export { cipherstashPackMeta as default } from '../core/descriptor-meta'
diff --git a/packages/stack/src/prisma/exports/runtime.ts b/packages/stack/src/prisma/exports/runtime.ts
new file mode 100644
index 00000000..8d7ecb26
--- /dev/null
+++ b/packages/stack/src/prisma/exports/runtime.ts
@@ -0,0 +1,131 @@
+import type { EncryptionClient } from '@/encryption'
+import { encryptedStorageParamsSchema } from '../core/authoring'
+import {
+ type CipherStashCodecContext,
+ type CipherStashEncryptionEvent,
+ type CipherStashEncryptionEventHook,
+ type CipherStashEncryptionEventKind,
+ defaultEventHook,
+} from '../core/codec-context'
+import { createEncryptionCodecRegistry } from '../core/codec-registry'
+import {
+ ENCRYPTED_STORAGE_CODEC_ID,
+ PACK_ID,
+ PACK_VERSION,
+} from '../core/constants'
+import { createEncryptionBinding } from '../core/encryption-client'
+import { type ContractLike, extractEncryptedSchemas } from '../core/extraction'
+import { encryptedQueryOperations } from '../core/operation-templates'
+import type {
+ RuntimeParameterizedCodecDescriptor,
+ SqlRuntimeExtensionDescriptor,
+} from '../internal-types/prisma-next'
+
+export type { ContractLike } from '../core/extraction'
+export type {
+ CipherStashEncryptionEvent,
+ CipherStashEncryptionEventKind,
+} from '../core/codec-context'
+
+/**
+ * Options accepted by the runtime extension factory.
+ */
+export interface CipherStashEncryptionOptions {
+ /**
+ * Pre-constructed `EncryptionClient` to bind into this extension
+ * instance. Each extension instance closes over its own client; two
+ * `cipherstashEncryption(...)` calls produce two independent codec
+ * graphs with no cross-talk.
+ *
+ * When omitted, the extension lazy-constructs a default client from
+ * the standard `CS_*` env vars on first encrypt/decrypt. The
+ * required env vars are validated synchronously at extension
+ * construction time so missing config surfaces in the dev-server
+ * boot log, not deep inside a codec call.
+ */
+ readonly encryptionClient?: EncryptionClient
+
+ /**
+ * Prisma Next contract used to derive the per-column
+ * `EncryptedTable` registrations the default `EncryptionClient` is
+ * initialized with. Every `(table, column)` pair the contract
+ * declares as encrypted gets its own schema entry with the right
+ * index configuration.
+ *
+ * Required when `encryptionClient` is omitted and any encrypted
+ * column will be exercised — without a contract there are no
+ * schemas to register and no `(table, column)` pair to thread
+ * through to the SDK.
+ */
+ readonly contract?: ContractLike
+
+ /**
+ * Optional hook invoked on every SDK round-trip — both success and
+ * failure. Receives a structured `CipherStashEncryptionEvent` with
+ * `{ kind, codecId, batchSize, durationMs, table, column, error }`.
+ *
+ * When omitted, the extension's default behaviour is:
+ * - production (`NODE_ENV === 'production'`): no-op.
+ * - development / test: `console.debug(...)` per round-trip.
+ *
+ * Use this for application metrics, distributed tracing, or
+ * structured logging. The payload deliberately omits plaintext /
+ * ciphertext so default dev logging is safe.
+ */
+ readonly onEvent?: CipherStashEncryptionEventHook
+}
+
+const parameterizedCodecs: readonly RuntimeParameterizedCodecDescriptor[] = [
+ {
+ codecId: ENCRYPTED_STORAGE_CODEC_ID,
+ paramsSchema: encryptedStorageParamsSchema,
+ },
+]
+
+/**
+ * Runtime extension factory.
+ *
+ * Each call returns a fresh descriptor with its own `EncryptionClient`
+ * binding closed inside the codec graph. There is no module-level
+ * singleton; multi-tenant deployments construct one extension per
+ * tenant scope and never see cross-talk between them.
+ *
+ * The factory eagerly:
+ * - validates the required env vars (when no `encryptionClient` is
+ * supplied) and throws a synchronous `CipherStashCodecError`
+ * listing every missing variable;
+ * - extracts encrypted-table schemas from the contract so the
+ * default client is initialized with real `(table, column)`
+ * entries.
+ */
+export function cipherstashEncryption(
+ options: CipherStashEncryptionOptions = {},
+): SqlRuntimeExtensionDescriptor<'postgres'> {
+ const schemas = extractEncryptedSchemas(options.contract)
+ const binding = createEncryptionBinding({
+ client: options.encryptionClient,
+ schemas,
+ })
+ const emit = options.onEvent ?? defaultEventHook
+ const ctx: CipherStashCodecContext = {
+ binding,
+ emit: (event: CipherStashEncryptionEvent) => emit(event),
+ }
+
+ return {
+ kind: 'extension',
+ id: PACK_ID,
+ version: PACK_VERSION,
+ familyId: 'sql',
+ targetId: 'postgres',
+ codecs: () => createEncryptionCodecRegistry(ctx),
+ queryOperations: () => encryptedQueryOperations,
+ parameterizedCodecs: () => parameterizedCodecs,
+ create: () => ({
+ familyId: 'sql' as const,
+ targetId: 'postgres' as const,
+ }),
+ }
+}
+
+export default cipherstashEncryption
diff --git a/packages/stack/src/prisma/index.ts b/packages/stack/src/prisma/index.ts
new file mode 100644
index 00000000..42b7b906
--- /dev/null
+++ b/packages/stack/src/prisma/index.ts
@@ -0,0 +1,62 @@
+/**
+ * `@cipherstash/stack/prisma` — searchable, application-layer field-level
+ * encryption for Prisma Next applications.
+ *
+ * Subpath imports are preferred over this barrel; they let bundlers
+ * tree-shake the build-time / runtime / authoring surfaces independently.
+ * This barrel exists for ergonomic access in test and prototype code only.
+ *
+ * import cipherstashEncryption from '@cipherstash/stack/prisma/runtime'
+ * import cipherstashEncryptionControl from '@cipherstash/stack/prisma/control'
+ * import { encryptedString } from '@cipherstash/stack/prisma/column-types'
+ *
+ * See `notes/cipherstash-prisma-integration-plan-v2.md` for the full
+ * design and phased rollout.
+ */
+
+export {
+ encryptedBoolean,
+ encryptedDate,
+ encryptedJson,
+ encryptedNumber,
+ encryptedString,
+} from './exports/column-types'
+export type {
+ EncryptedBooleanColumn,
+ EncryptedBooleanConfig,
+ EncryptedBooleanTypeParams,
+ EncryptedDateColumn,
+ EncryptedDateConfig,
+ EncryptedDateTypeParams,
+ EncryptedJsonColumn,
+ EncryptedJsonConfig,
+ EncryptedJsonTypeParams,
+ EncryptedNumberColumn,
+ EncryptedNumberConfig,
+ EncryptedNumberTypeParams,
+ EncryptedStringColumn,
+ EncryptedStringConfig,
+ EncryptedStringTypeParams,
+ EncryptedTypeParams,
+} from './exports/column-types'
+
+export { cipherstashEncryption } from './exports/runtime'
+export type {
+ CipherStashEncryptionEvent,
+ CipherStashEncryptionEventKind,
+ CipherStashEncryptionOptions,
+ ContractLike,
+} from './exports/runtime'
+
+export { cipherstashEncryptionControl } from './exports/control'
+
+export { cipherstashPackMeta } from './core/descriptor-meta'
+
+export type { CodecTypes, Decrypted, JsTypeFor } from './exports/codec-types'
+export type { OperationTypes } from './exports/operation-types'
+
+export { CipherStashCodecError } from './core/errors'
+export type {
+ CipherStashCodecErrorCode,
+ CipherStashCodecErrorOptions,
+} from './core/errors'
diff --git a/packages/stack/src/prisma/internal-types/prisma-next.ts b/packages/stack/src/prisma/internal-types/prisma-next.ts
new file mode 100644
index 00000000..ae08b61a
--- /dev/null
+++ b/packages/stack/src/prisma/internal-types/prisma-next.ts
@@ -0,0 +1,376 @@
+/**
+ * Vendored type shapes from `@prisma-next/*` packages.
+ *
+ * Prisma Next is pre-publish on npm at the time of writing. This module
+ * mirrors the minimal surface of the post-#379 (single-path async codec
+ * runtime) public types so `@cipherstash/stack/prisma` can build and
+ * type-check independently of a forked Prisma Next checkout. It is replaced
+ * by real peer-dependency imports once Prisma Next ships to npm.
+ *
+ * The shapes here track:
+ * - `@prisma-next/framework-components/codec`
+ * - `@prisma-next/sql-relational-core/ast`
+ * - `@prisma-next/sql-runtime`
+ * - `@prisma-next/family-sql/control`
+ * - `@prisma-next/sql-operations`
+ * - `@prisma-next/contract-authoring`
+ *
+ * They are intentionally narrow: only the fields our integration produces or
+ * consumes are vendored. Promoting to real imports is mechanical (rename and
+ * delete this file). See `notes/cipherstash-prisma-integration-plan-v2.md`
+ * for the full mapping.
+ */
+
+// ---------------------------------------------------------------------------
+// JSON values (mirrors @prisma-next/contract/types)
+// ---------------------------------------------------------------------------
+
+export type JsonValue =
+ | null
+ | boolean
+ | number
+ | string
+ | readonly JsonValue[]
+ | { readonly [key: string]: JsonValue }
+
+// ---------------------------------------------------------------------------
+// Codec contract (mirrors @prisma-next/framework-components/codec)
+// ---------------------------------------------------------------------------
+
+export type CodecTrait =
+ | 'equality'
+ | 'order'
+ | 'boolean'
+ | 'numeric'
+ | 'textual'
+
+/**
+ * Boundary contract: `encode` / `decode` are always Promise-returning even
+ * when authored synchronously. The factory in `@prisma-next/sql-relational-core/ast`
+ * lifts sync authors to async at construction time.
+ */
+export interface BaseCodec<
+ Id extends string = string,
+ TTraits extends readonly CodecTrait[] = readonly CodecTrait[],
+ TWire = unknown,
+ TInput = unknown,
+> {
+ readonly id: Id
+ readonly targetTypes: readonly string[]
+ readonly traits?: TTraits
+ encode(value: TInput): Promise
+ decode(wire: TWire): Promise
+ encodeJson(value: TInput): JsonValue
+ decodeJson(json: JsonValue): TInput
+ renderOutputType?(typeParams: Record): string | undefined
+}
+
+// ---------------------------------------------------------------------------
+// SQL codec (mirrors @prisma-next/sql-relational-core/ast)
+// ---------------------------------------------------------------------------
+
+/**
+ * SQL codec metadata is pulled into the codec object so the migration
+ * planner can introspect native types without re-deriving them from
+ * descriptors elsewhere.
+ */
+export interface CodecMeta {
+ readonly db?: {
+ readonly sql?: {
+ readonly postgres?: {
+ readonly nativeType: string
+ }
+ }
+ }
+}
+
+/**
+ * Arktype `Type` is exposed as an opaque generic on the SQL codec interface
+ * so we don't take a hard dependency on arktype's full surface here.
+ * Runtime descriptors construct real arktype values inside `exports/runtime.ts`.
+ */
+export type ArkSchema = {
+ readonly __params?: TParams
+ // Arktype's Type is a callable that returns either the validated value
+ // or an error; we don't model that here. Treating it as an opaque token is
+ // sufficient for the build-time / type-only surface we use.
+ readonly inferIn?: TParams
+}
+
+export interface SqlCodec<
+ Id extends string = string,
+ TTraits extends readonly CodecTrait[] = readonly CodecTrait[],
+ TWire = unknown,
+ TInput = unknown,
+ TParams = Record,
+ THelper = unknown,
+> extends BaseCodec {
+ readonly meta?: CodecMeta
+ readonly paramsSchema?: ArkSchema
+ readonly init?: (params: TParams) => THelper
+}
+
+/**
+ * Runtime registry. Phase 1 only uses `register` and (via the runtime
+ * extension `codecs()` factory) iteration; we keep the surface narrow.
+ */
+export interface CodecRegistry {
+ get(id: string): SqlCodec | undefined
+ has(id: string): boolean
+ register(codec: SqlCodec): void
+ hasTrait(codecId: string, trait: CodecTrait): boolean
+ traitsOf(codecId: string): readonly CodecTrait[]
+ values(): IterableIterator
+ [Symbol.iterator](): Iterator
+}
+
+// ---------------------------------------------------------------------------
+// Operations (mirrors @prisma-next/operations + @prisma-next/sql-operations)
+// ---------------------------------------------------------------------------
+
+export interface ParamSpec {
+ readonly codecId?: string
+ readonly traits?: readonly string[]
+ readonly nullable: boolean
+}
+
+export interface ReturnSpec {
+ readonly codecId: string
+ readonly nullable: boolean
+}
+
+export interface SqlLoweringSpec {
+ readonly targetFamily: 'sql'
+ readonly strategy: 'infix' | 'function'
+ readonly template: string
+}
+
+export interface SqlOperationEntry {
+ readonly args: readonly ParamSpec[]
+ readonly returns: ReturnSpec
+ readonly lowering: SqlLoweringSpec
+}
+
+export type SqlOperationDescriptor = SqlOperationEntry & {
+ readonly method: string
+}
+
+// ---------------------------------------------------------------------------
+// Contract authoring (mirrors @prisma-next/contract-authoring)
+// ---------------------------------------------------------------------------
+
+export type ColumnTypeDescriptor = {
+ readonly codecId: TCodecId
+ readonly nativeType: string
+ readonly typeParams?: Record
+ readonly typeRef?: string
+}
+
+// ---------------------------------------------------------------------------
+// Runtime extension descriptor (mirrors @prisma-next/sql-runtime)
+// ---------------------------------------------------------------------------
+
+export interface RuntimeParameterizedCodecDescriptor<
+ TParams = Record,
+ THelper = unknown,
+> {
+ readonly codecId: string
+ readonly paramsSchema: ArkSchema
+ readonly init?: (params: TParams) => THelper
+}
+
+export interface SqlRuntimeExtensionInstance {
+ readonly familyId: 'sql'
+ readonly targetId: TTargetId
+}
+
+export interface SqlRuntimeExtensionDescriptor<
+ TTargetId extends string = string,
+> {
+ readonly kind: 'extension'
+ readonly id: string
+ readonly version: string
+ readonly familyId: 'sql'
+ readonly targetId: TTargetId
+ readonly codecs: () => CodecRegistry
+ readonly queryOperations?: () => readonly SqlOperationDescriptor[]
+ readonly parameterizedCodecs?: () => readonly RuntimeParameterizedCodecDescriptor[]
+ create(): SqlRuntimeExtensionInstance
+}
+
+// ---------------------------------------------------------------------------
+// Control extension descriptor (mirrors @prisma-next/family-sql/control)
+// ---------------------------------------------------------------------------
+
+/**
+ * SQL DDL step shape used inside `databaseDependencies.init` and
+ * `planTypeOperations` results. Mirrors `SqlMigrationPlanOperationStep`
+ * in `@prisma-next/family-sql/control`.
+ */
+export interface SqlMigrationStep {
+ readonly description: string
+ readonly sql: string
+ readonly meta?: Readonly>
+}
+
+/**
+ * Operation classification used by the migration planner to distinguish
+ * additive (safe) DDL from destructive (data-losing) DDL. We declare the
+ * union here for shape-fidelity with the upstream type; phase-3 emit
+ * sites only use `'additive'` and `'destructive'`.
+ */
+export type MigrationOperationClass = 'additive' | 'destructive' | 'widening'
+
+/**
+ * Single SQL operation: install / drop / alter. The `target` payload's
+ * `details` field carries target-specific data (Postgres uses `objectType`
+ * + optional `table`). Phase 3 leaves `details` minimal to avoid
+ * pinning to a specific upstream target shape; the migration planner
+ * accepts any structurally-compatible payload.
+ */
+export interface SqlMigrationPlanOperation {
+ readonly id: string
+ readonly label: string
+ readonly summary?: string
+ readonly operationClass: MigrationOperationClass
+ readonly target: {
+ readonly id: string
+ readonly details?: Record
+ }
+ readonly precheck: readonly SqlMigrationStep[]
+ readonly execute: readonly SqlMigrationStep[]
+ readonly postcheck: readonly SqlMigrationStep[]
+ readonly meta?: Readonly>
+}
+
+/**
+ * `databaseDependencies.init` entry: a labeled bundle of `install`
+ * operations, run together when the extension first attaches to a
+ * database. Pgvector ships one entry that creates the `vector`
+ * extension; we ship one that runs the EQL install bundle.
+ */
+export interface ComponentDatabaseDependency {
+ readonly id: string
+ readonly label: string
+ readonly install: readonly SqlMigrationPlanOperation[]
+}
+
+export interface ComponentDatabaseDependencies {
+ readonly init?: readonly ComponentDatabaseDependency[]
+ /**
+ * Phase 4 hook for `databaseDependencies.upgrade(fromVersion, toVersion)`.
+ * Phase 3 ships `init` only; the upgrade signature is reserved here
+ * so the descriptor's shape doesn't churn between phases.
+ */
+ readonly upgrade?: (
+ fromVersion: string,
+ toVersion: string,
+ ctx: TUpgradeContext,
+ ) => readonly ComponentDatabaseDependency[]
+}
+
+/**
+ * `StorageTypeInstance` is the post-#379 shape of a named, parameterized
+ * type registered in `storage.types`. Each encrypted column in the
+ * contract surfaces here with `codecId: 'cs/eql_v2_encrypted@1'`,
+ * `nativeType: '"public"."eql_v2_encrypted"'`, and `typeParams` carrying
+ * the search-mode flags.
+ */
+export interface StorageTypeInstance {
+ readonly codecId: string
+ readonly nativeType: string
+ readonly typeParams: Record
+}
+
+/**
+ * Result of a `planTypeOperations` call: a flat list of DDL operations
+ * the migration planner appends to its plan. Returning an empty
+ * `operations` array is a no-op.
+ */
+export interface StorageTypePlanResult {
+ readonly operations: readonly SqlMigrationPlanOperation[]
+}
+
+/**
+ * Plan-time context the migration planner passes to `planTypeOperations`.
+ * Phase 3 only needs `typeName`, `typeInstance`, and the schema
+ * identifier — the contract / schema IR / policy fields are reserved for
+ * Phase 4 destructive-change reasoning.
+ */
+export interface PlanTypeOperationsInput {
+ readonly typeName: string
+ readonly typeInstance: StorageTypeInstance
+ readonly schemaName?: string
+ readonly contract?: unknown
+ readonly schema?: unknown
+ readonly policy?: unknown
+}
+
+export interface CodecControlHooks {
+ readonly expandNativeType?: (input: {
+ readonly nativeType: string
+ readonly typeParams: Record | undefined
+ }) => string
+ readonly resolveIdentityValue?: (input: {
+ readonly typeParams: Record | undefined
+ }) => string | null | undefined
+ /**
+ * Phase 3 hook: emit per-column EQL index DDL. Called once per
+ * `StorageTypeInstance` registered against this codec ID. Implementers
+ * inspect `typeInstance.typeParams` to decide which `eql_v2.*` calls
+ * to emit; returning an empty `operations` array is a no-op.
+ */
+ readonly planTypeOperations?: (
+ input: PlanTypeOperationsInput,
+ ) => StorageTypePlanResult
+}
+
+export interface SqlControlExtensionDescriptor<
+ TTargetId extends string = string,
+> {
+ readonly kind: 'extension'
+ readonly id: string
+ readonly familyId: 'sql'
+ readonly targetId: TTargetId
+ readonly version: string
+ readonly capabilities?: Record>
+ readonly types?: {
+ readonly codecTypes?: {
+ readonly codecInstances?: readonly SqlCodec[]
+ readonly controlPlaneHooks?: Record
+ readonly import?: {
+ readonly package: string
+ readonly named: string
+ readonly alias?: string
+ }
+ readonly typeImports?: ReadonlyArray<{
+ readonly package: string
+ readonly named: string
+ readonly alias?: string
+ }>
+ }
+ readonly operationTypes?: {
+ readonly import?: {
+ readonly package: string
+ readonly named: string
+ readonly alias?: string
+ }
+ }
+ readonly queryOperationTypes?: {
+ readonly import?: {
+ readonly package: string
+ readonly named: string
+ readonly alias?: string
+ }
+ }
+ readonly storage?: ReadonlyArray<{
+ readonly typeId: string
+ readonly familyId: 'sql'
+ readonly targetId: string
+ readonly nativeType: string
+ }>
+ }
+ readonly databaseDependencies?: ComponentDatabaseDependencies
+ readonly queryOperations?: () => readonly SqlOperationDescriptor[]
+ create(): { readonly familyId: 'sql'; readonly targetId: TTargetId }
+}
diff --git a/packages/stack/tsup.config.ts b/packages/stack/tsup.config.ts
index 2bcb6f2c..209f4f2a 100644
--- a/packages/stack/tsup.config.ts
+++ b/packages/stack/tsup.config.ts
@@ -14,6 +14,13 @@ export default defineConfig([
'src/supabase/index.ts',
'src/encryption/index.ts',
'src/errors/index.ts',
+ 'src/prisma/index.ts',
+ 'src/prisma/exports/control.ts',
+ 'src/prisma/exports/runtime.ts',
+ 'src/prisma/exports/pack.ts',
+ 'src/prisma/exports/column-types.ts',
+ 'src/prisma/exports/codec-types.ts',
+ 'src/prisma/exports/operation-types.ts',
],
format: ['cjs', 'esm'],
sourcemap: true,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ead9e3b6..0f4af420 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -285,6 +285,9 @@ importers:
'@cipherstash/protect-ffi':
specifier: 0.21.2
version: 0.21.2
+ arktype:
+ specifier: ^2.2.0
+ version: 2.2.0
evlog:
specifier: 1.9.0
version: 1.9.0
@@ -353,6 +356,12 @@ packages:
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
engines: {node: '>= 16'}
+ '@ark/schema@0.56.0':
+ resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==}
+
+ '@ark/util@0.56.0':
+ resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==}
+
'@babel/runtime@7.26.10':
resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==}
engines: {node: '>=6.9.0'}
@@ -1546,6 +1555,12 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ arkregex@0.0.5:
+ resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==}
+
+ arktype@2.2.0:
+ resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==}
+
array-union@2.1.0:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'}
@@ -3156,6 +3171,12 @@ snapshots:
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
+ '@ark/schema@0.56.0':
+ dependencies:
+ '@ark/util': 0.56.0
+
+ '@ark/util@0.56.0': {}
+
'@babel/runtime@7.26.10':
dependencies:
regenerator-runtime: 0.14.1
@@ -4134,6 +4155,16 @@ snapshots:
argparse@2.0.1: {}
+ arkregex@0.0.5:
+ dependencies:
+ '@ark/util': 0.56.0
+
+ arktype@2.2.0:
+ dependencies:
+ '@ark/schema': 0.56.0
+ '@ark/util': 0.56.0
+ arkregex: 0.0.5
+
array-union@2.1.0: {}
assertion-error@2.0.1: {}