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.

+ Built by CipherStash License Docs @@ -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. [![npm version](https://img.shields.io/npm/v/@cipherstash/stack.svg?style=for-the-badge&labelColor=000000)](https://www.npmjs.com/package/@cipherstash/stack) [![License: MIT](https://img.shields.io/npm/l/@cipherstash/stack.svg?style=for-the-badge&labelColor=000000)](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: {}