From 413ca3963bd4717ba5c3266b967726fae7a9c7fd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 16 Jul 2026 15:53:59 +1000 Subject: [PATCH 1/3] chore!: remove the legacy @cipherstash/drizzle package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @cipherstash/protect is sunsetting at Stack 1.0, and this package — its Drizzle integration, a ~645-line-diverged fork of what became @cipherstash/stack-drizzle — has been in maintenance-by-attrition since the adapter split. Keeping it on the release train meant republishing known-buggy code every rc (#426 rides along unreviewed at 3.0.4-rc.1) and, worse, leaving a plausible-looking `npm install @cipherstash/drizzle` that silently binds new users (and LLM assistants) to the predecessor SDK at exactly the moment 1.0 makes @cipherstash/stack-drizzle the real name — the same stale-package hazard class as #661. Removal scope: - delete packages/drizzle (git history preserves the source for emergency maintenance of already-published versions; npm keeps 3.0.3 installable) - drop it from the changesets pre-release state (pre.json initialVersions + consumed-changeset entry) and delete the pending changeset that only targeted it, so `changeset version` keeps working - remove its CI steps (tests.yml .env scaffolds ×2, Bun test-loop entry), the rebuild-docs tag trigger, and the e2e workspace dep - keep the meta files honest (AGENTS.md layout, SECURITY.md package table) - state the deprecation explicitly in the stash-drizzle skill and the stack-drizzle README so nobody installs the legacy package by mistake npm-side deprecation (`npm deprecate @cipherstash/drizzle ...`) is a registry operation done separately by an owner. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/drizzle-legacy-readme-pointer.md | 7 - .changeset/pre.json | 2 - .changeset/remove-legacy-drizzle-package.md | 13 + .github/workflows/rebuild-docs.yml | 1 - .github/workflows/tests.yml | 20 +- AGENTS.md | 1 - SECURITY.md | 1 - e2e/package.json | 1 - packages/drizzle/.npmignore | 5 - packages/drizzle/CHANGELOG.md | 142 -- .../drizzle/GENERATE_EQL_MIGRATION_CLI.md | 135 -- packages/drizzle/README.md | 362 --- packages/drizzle/__tests__/drizzle.test.ts | 401 ---- .../__tests__/fixtures/doc-seed-data.ts | 129 -- .../__tests__/fixtures/user-seed-data.ts | 54 - .../__tests__/integration-test-helpers.ts | 126 - .../drizzle/__tests__/operators-jsonb.test.ts | 184 -- packages/drizzle/__tests__/operators.test.ts | 486 ---- .../__tests__/schema-extraction.test.ts | 285 --- packages/drizzle/__tests__/test-utils.ts | 27 - .../__tests__/utils/code-executor.test.ts | 52 - .../drizzle/__tests__/utils/code-executor.ts | 65 - .../__tests__/utils/markdown-parser.test.ts | 304 --- .../__tests__/utils/markdown-parser.ts | 58 - packages/drizzle/package.json | 81 - .../drizzle/src/bin/generate-eql-migration.ts | 148 -- packages/drizzle/src/bin/runner.ts | 35 - packages/drizzle/src/pg/index.ts | 199 -- packages/drizzle/src/pg/operators.ts | 2038 ----------------- packages/drizzle/src/pg/schema-extraction.ts | 126 - packages/drizzle/tsconfig.json | 28 - packages/drizzle/tsup.config.ts | 24 - packages/drizzle/vitest.config.ts | 12 - packages/stack-drizzle/README.md | 2 +- pnpm-lock.yaml | 652 +----- skills/stash-drizzle/SKILL.md | 5 +- 36 files changed, 33 insertions(+), 6178 deletions(-) delete mode 100644 .changeset/drizzle-legacy-readme-pointer.md create mode 100644 .changeset/remove-legacy-drizzle-package.md delete mode 100644 packages/drizzle/.npmignore delete mode 100644 packages/drizzle/CHANGELOG.md delete mode 100644 packages/drizzle/GENERATE_EQL_MIGRATION_CLI.md delete mode 100644 packages/drizzle/README.md delete mode 100644 packages/drizzle/__tests__/drizzle.test.ts delete mode 100644 packages/drizzle/__tests__/fixtures/doc-seed-data.ts delete mode 100644 packages/drizzle/__tests__/fixtures/user-seed-data.ts delete mode 100644 packages/drizzle/__tests__/integration-test-helpers.ts delete mode 100644 packages/drizzle/__tests__/operators-jsonb.test.ts delete mode 100644 packages/drizzle/__tests__/operators.test.ts delete mode 100644 packages/drizzle/__tests__/schema-extraction.test.ts delete mode 100644 packages/drizzle/__tests__/test-utils.ts delete mode 100644 packages/drizzle/__tests__/utils/code-executor.test.ts delete mode 100644 packages/drizzle/__tests__/utils/code-executor.ts delete mode 100644 packages/drizzle/__tests__/utils/markdown-parser.test.ts delete mode 100644 packages/drizzle/__tests__/utils/markdown-parser.ts delete mode 100644 packages/drizzle/package.json delete mode 100644 packages/drizzle/src/bin/generate-eql-migration.ts delete mode 100644 packages/drizzle/src/bin/runner.ts delete mode 100644 packages/drizzle/src/pg/index.ts delete mode 100644 packages/drizzle/src/pg/operators.ts delete mode 100644 packages/drizzle/src/pg/schema-extraction.ts delete mode 100644 packages/drizzle/tsconfig.json delete mode 100644 packages/drizzle/tsup.config.ts delete mode 100644 packages/drizzle/vitest.config.ts diff --git a/.changeset/drizzle-legacy-readme-pointer.md b/.changeset/drizzle-legacy-readme-pointer.md deleted file mode 100644 index 50cd60eb1..000000000 --- a/.changeset/drizzle-legacy-readme-pointer.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@cipherstash/drizzle': patch ---- - -Docs: the README's "for new projects" pointer named the removed -`@cipherstash/stack/drizzle` subpath; it now points at the separate -`@cipherstash/stack-drizzle` package (EQL v3 on its `/v3` subpath). diff --git a/.changeset/pre.json b/.changeset/pre.json index 7ddcdffb1..d0bd85dfe 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -8,7 +8,6 @@ "@cipherstash/supabase-worker-example": "0.0.0", "@cipherstash/bench": "0.0.4", "stash": "0.17.1", - "@cipherstash/drizzle": "3.0.3", "@cipherstash/migrate": "0.2.0", "@cipherstash/nextjs": "4.1.1", "@cipherstash/prisma-next": "0.3.2", @@ -26,7 +25,6 @@ "adapter-split-skills", "cli-anonymous-telemetry", "cli-eql-v3-single-bundle", - "drizzle-legacy-readme-pointer", "eql-v3-adapter-type-robustness", "eql-v3-bigint-domains", "eql-v3-bundle-from-package", diff --git a/.changeset/remove-legacy-drizzle-package.md b/.changeset/remove-legacy-drizzle-package.md new file mode 100644 index 000000000..595722d34 --- /dev/null +++ b/.changeset/remove-legacy-drizzle-package.md @@ -0,0 +1,13 @@ +--- +'stash': patch +'@cipherstash/stack-drizzle': patch +--- + +The legacy `@cipherstash/drizzle` package (the `@cipherstash/protect`-based +Drizzle integration) is removed from the repository and the release train — +`@cipherstash/protect` is sunsetting at Stack 1.0, and the package's successor +is `@cipherstash/stack-drizzle`. Already-published versions remain installable +from npm (deprecated, pointing here); the git history preserves the source for +any emergency maintenance. The `stash-drizzle` skill and the +`@cipherstash/stack-drizzle` README now state the deprecation explicitly so +nobody (human or agent) installs the legacy package by mistake. diff --git a/.github/workflows/rebuild-docs.yml b/.github/workflows/rebuild-docs.yml index 27695b63e..80b320950 100644 --- a/.github/workflows/rebuild-docs.yml +++ b/.github/workflows/rebuild-docs.yml @@ -4,7 +4,6 @@ on: push: tags: - '@cipherstash/protect@*' - - '@cipherstash/drizzle@*' jobs: trigger-docs-rebuild: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6bbc3cf3a..9dc5a8ab1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -168,15 +168,6 @@ jobs: echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env - - name: Create .env file in ./packages/drizzle/ - run: | - touch ./packages/drizzle/.env - echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/drizzle/.env - echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/drizzle/.env - echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/drizzle/.env - echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/drizzle/.env - echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/drizzle/.env - # Run TurboRepo tests - name: Run tests run: pnpm run test @@ -388,22 +379,13 @@ jobs: echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env - - name: Create .env file in ./packages/drizzle/ - run: | - touch ./packages/drizzle/.env - echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/drizzle/.env - echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/drizzle/.env - echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/drizzle/.env - echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/drizzle/.env - echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/drizzle/.env - # Build with Node (turbo/tsup need Node), then run tests with Bun - name: Build packages run: pnpm turbo build --filter './packages/*' - name: Run tests with Bun run: | - for dir in packages/schema packages/protect packages/stack packages/protect-dynamodb packages/drizzle packages/stack-forge; do + for dir in packages/schema packages/protect packages/stack packages/protect-dynamodb packages/stack-forge; do if [ -f "$dir/vitest.config.ts" ] || [ -f "$dir/package.json" ]; then echo "--- Testing $dir ---" (cd "$dir" && bunx --bun vitest run) || true diff --git a/AGENTS.md b/AGENTS.md index c361e4417..1622ab963 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,6 @@ If these variables are missing, tests that require live encryption will fail or - `packages/stack-drizzle`: Drizzle ORM integration (`@cipherstash/stack-drizzle`), depends on `@cipherstash/stack` — EQL v2 (`.`) and EQL v3 (`./v3`). Split out of `@cipherstash/stack`. - `packages/stack-supabase`: Supabase integration (`@cipherstash/stack-supabase`), depends on `@cipherstash/stack` — `encryptedSupabase` (v2) and `encryptedSupabaseV3` (v3). Split out of `@cipherstash/stack`. - `packages/schema`: Schema builder utilities and types (`encryptedTable`, `encryptedColumn`, `encryptedField`) -- `packages/drizzle`: Drizzle ORM integration (`encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators`) - `packages/nextjs`: Next.js helpers and Clerk integration (`./clerk` export) - `packages/protect-dynamodb`: DynamoDB helpers (`encryptedDynamoDB`) - `packages/utils`: Shared config (`utils/config`) and logger (`utils/logger`) diff --git a/SECURITY.md b/SECURITY.md index 742c2f272..85fd4e22b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,7 +13,6 @@ This repository is the CipherStash Stack monorepo for JavaScript/TypeScript. It | `stash` | CipherStash CLI | | `@cipherstash/protect` | Core encryption library (re-exported via `@cipherstash/stack`) | | `@cipherstash/schema` | Schema builder utilities | -| `@cipherstash/drizzle` | Drizzle ORM integration | | `@cipherstash/nextjs` | Next.js helpers | | `@cipherstash/protect-dynamodb` | DynamoDB helpers | | `@cipherstash/migrate` | Plaintext-to-encrypted column migration tooling | diff --git a/e2e/package.json b/e2e/package.json index e5a05e3ff..6d261b6ca 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,7 +9,6 @@ }, "dependencies": { "stash": "workspace:*", - "@cipherstash/drizzle": "workspace:*", "@cipherstash/protect": "workspace:*", "@cipherstash/stack": "workspace:*", "@cipherstash/wizard": "workspace:*" diff --git a/packages/drizzle/.npmignore b/packages/drizzle/.npmignore deleted file mode 100644 index 3490e24db..000000000 --- a/packages/drizzle/.npmignore +++ /dev/null @@ -1,5 +0,0 @@ -.env -.turbo -node_modules -cipherstash.secret.toml -cipherstash.toml \ No newline at end of file diff --git a/packages/drizzle/CHANGELOG.md b/packages/drizzle/CHANGELOG.md deleted file mode 100644 index 50838a578..000000000 --- a/packages/drizzle/CHANGELOG.md +++ /dev/null @@ -1,142 +0,0 @@ -# @cipherstash/drizzle - -## 3.0.4-rc.1 - -### Patch Changes - -- 3fdd740: Docs: the README's "for new projects" pointer named the removed - `@cipherstash/stack/drizzle` subpath; it now points at the separate - `@cipherstash/stack-drizzle` package (EQL v3 on its `/v3` subpath). - -## 3.0.4-rc.0 - -### Patch Changes - -- Updated dependencies [229ce59] - - @cipherstash/schema@3.0.2-rc.0 - - @cipherstash/protect@12.0.2-rc.0 - -## 3.0.3 - -### Patch Changes - -- aa9c4b1: Documentation: refresh package READMEs after the protectjs → stack repository rename. Fixed repository and license links, replaced dead in-repo docs links with cipherstash.com/docs URLs, rewrote the incorrect @cipherstash/nextjs README, and added guidance pointing new projects to @cipherstash/stack. - -## 3.0.2 - -### Patch Changes - -- a8dbb65: Render every user-facing CLI string and execute every shell-out under the detected package manager (`npx` / `bunx` / `pnpm dlx` / `yarn dlx`), completing the work started in #379. Affected surfaces: `@cipherstash/cli` top-level + `auth` + `env` help, `db install` Drizzle migration steps, `db migrate` not-implemented warning, the Supabase migration SQL header, the Supabase status fallback exec, the `@cipherstash/protect` `stash` Stricli help (set/get/list/delete), the `@cipherstash/wizard` usage line and agent command allowlist, and the `@cipherstash/drizzle` `generate-eql-migration` help + drizzle-kit invocation. A new `pnpm run lint:runners` lint runs in CI and fails on any reintroduction of a hardcoded runner literal. - -## 3.0.1 - -### Patch Changes - -- b0e56b8: Upgrade protect-ffi to 0.21.0 and enable array_index_mode for searchable JSON - - - Upgrade `@cipherstash/protect-ffi` to 0.21.0 across all packages - - Enable `array_index_mode: 'all'` on STE vec indexes so JSON array operations - (jsonb_array_elements, jsonb_array_length, array containment) work correctly - - Delegate credential resolution entirely to protect-ffi's `withEnvCredentials` - - Download latest EQL at build/runtime instead of bundling hardcoded SQL files - -## 3.0.0 - -### Patch Changes - -- db72e2c: Add `encryptQuery` API for encrypting query terms with explicit query type selection. - - - New `encryptQuery()` method replaces `createSearchTerms()` with improved query type handling - - Supports `equality`, `freeTextSearch`, and `orderAndRange` query types - - Deprecates `createSearchTerms()` - use `encryptQuery()` instead - - Updates drizzle operators to use correct index selection via `queryType` parameter - -- Updated dependencies [db72e2c] -- Updated dependencies [e769740] - - @cipherstash/protect@10.5.0 - - @cipherstash/schema@2.1.0 - -## 2.3.0 - -### Patch Changes - -- Updated dependencies [9ccaf68] - - @cipherstash/protect@10.4.0 - -## 2.2.0 - -### Patch Changes - -- Updated dependencies [a1fce2b] -- Updated dependencies [622b684] - - @cipherstash/protect@10.3.0 - -## 2.1.0 - -### Minor Changes - -- 41c4169: Update drizzle imports to use /client export path from Protect.js. - -## 2.0.0 - -### Patch Changes - -- Updated dependencies [de029de] - - @cipherstash/protect@10.2.0 - -## 1.1.1 - -### Patch Changes - -- ff4421f: Expanded typedoc documentation -- Updated dependencies [ff4421f] - - @cipherstash/protect@10.1.1 - - @cipherstash/schema@2.0.1 - -## 1.1.0 - -### Patch Changes - -- Updated dependencies [6b87c17] - - @cipherstash/protect@10.1.0 - -## 1.0.0 - -### Minor Changes - -- 2edfedd: Added support for encrypted or operation. -- 7b8c719: Added `generate-eql-migration` CLI command to automate EQL migration generation. - - This command consolidates the manual process of running `drizzle-kit generate --custom` and populating the SQL file into a single command. It uses the bundled EQL SQL from `@cipherstash/schema` for offline-friendly, version-locked installations. - - Usage: - - ```bash - npx generate-eql-migration - npx generate-eql-migration --name setup-eql - npx generate-eql-migration --out migrations - ``` - -### Patch Changes - -- Updated dependencies [9005484] - - @cipherstash/schema@2.0.0 - - @cipherstash/protect@10.0.2 - -## 0.2.0 - -### Minor Changes - -- ebda487: Added explicit return type to extractProtectSchem. - -## 0.1.0 - -### Minor Changes - -- d8ed4d4: Released initial Drizzle ORM interface. - -### Patch Changes - -- Updated dependencies [d8ed4d4] - - @cipherstash/schema@1.1.0 - - @cipherstash/protect@10.0.1 diff --git a/packages/drizzle/GENERATE_EQL_MIGRATION_CLI.md b/packages/drizzle/GENERATE_EQL_MIGRATION_CLI.md deleted file mode 100644 index 0875178b3..000000000 --- a/packages/drizzle/GENERATE_EQL_MIGRATION_CLI.md +++ /dev/null @@ -1,135 +0,0 @@ -# generate-eql-migration CLI Command - -A command-line tool for easily generating Drizzle migrations that install CipherStash EQL (Encrypt Query Language) in your PostgreSQL database. - -## Purpose - -This CLI automates the process of: -1. Creating a custom Drizzle migration file -2. Populating it with the EQL SQL schema (bundled from `@cipherstash/schema`) -3. Making it ready to apply to your database - -## Installation - -The command is automatically available when you install `@cipherstash/drizzle`: - -```bash -pnpm add @cipherstash/drizzle -``` - -## Usage - -### Basic Usage - -From your project root (where your Drizzle config is located): - -```bash -npx generate-eql-migration -``` - -This will: -- Generate a migration named `install-eql` in the `drizzle/` directory -- Fill it with the EQL SQL schema -- Show you next steps - -### Options - -```bash -Usage: generate-eql-migration [options] - -Options: - -n, --name Migration name (default: "install-eql") - -o, --out Output directory (default: "drizzle") - -h, --help Display this help message -``` - -### Examples - -```bash -# Default: creates drizzle/XXXX_install-eql.sql -npx generate-eql-migration - -# Custom name -npx generate-eql-migration --name setup-eql - -# Custom output directory -npx generate-eql-migration --out migrations - -# Both custom name and directory -npx generate-eql-migration --name init-eql --out db/migrations -``` - -## How It Works - -1. **Calls drizzle-kit**: Executes `pnpm drizzle-kit generate --custom --name=` to create an empty migration file -2. **Locates EQL SQL**: Finds `cipherstash-encrypt-2-1-8.sql` from the installed `@cipherstash/schema` package -3. **Populates migration**: Writes the EQL SQL content to the generated migration file -4. **Reports success**: Shows the path to the migration and next steps - -## Implementation Details - -- **Location**: `packages/drizzle/bin/generate-eql-migration.js` -- **Package.json entry**: `"bin": { "generate-eql-migration": "./bin/generate-eql-migration.js" }` -- **Dependencies**: - - Requires `drizzle-kit` (peer dependency, optional) - - Reads SQL from `@cipherstash/schema` package - - Uses Node.js built-in modules (fs, path, child_process) - -## Error Handling - -The CLI will exit with an error if: -- `drizzle-kit` is not installed or fails to generate the migration -- The EQL SQL file cannot be found in `@cipherstash/schema` -- The Drizzle output directory doesn't exist -- The generated migration file cannot be found - -## After Running - -Once the migration is created, apply it with: - -```bash -npx drizzle-kit migrate -``` - -Or use your custom migration workflow. - -## Comparison to Manual Process - -### Before (manual): -```bash -npx drizzle-kit generate --custom --name=install-eql -curl -sL https://github.com/cipherstash/encrypt-query-language/releases/download/eql-2.3.1/cipherstash-encrypt.sql > drizzle/0001_install-eql.sql -npx drizzle-kit migrate -``` - -### After (automated): -```bash -npx generate-eql-migration -npx drizzle-kit migrate -``` - -## Benefits - -1. **Offline-friendly**: Uses the bundled SQL file from `@cipherstash/schema` instead of downloading from GitHub -2. **Version-locked**: Always installs the EQL version that matches your installed `@cipherstash/schema` package -3. **Simplified workflow**: Single command instead of multiple steps -4. **Error-resistant**: Validates each step and provides clear error messages -5. **Flexible**: Supports custom names and output directories - -## Troubleshooting - -### "Failed to generate custom migration" -- Ensure `drizzle-kit` is installed: `pnpm add -D drizzle-kit` -- Check that you're running from the project root with a valid Drizzle config - -### "Could not find EQL SQL file" -- Ensure `@cipherstash/schema` is installed (peer dependency) -- The CLI looks for `cipherstash-encrypt-2-1-8.sql` in the schema package - -### "Drizzle directory not found" -- Run the command from your project root -- Or specify the correct path with `--out` - -### "Could not find migration file" -- The CLI looks for files matching the pattern `*.sql` in the output directory -- Check that `drizzle-kit` successfully created the migration diff --git a/packages/drizzle/README.md b/packages/drizzle/README.md deleted file mode 100644 index 7a27ac833..000000000 --- a/packages/drizzle/README.md +++ /dev/null @@ -1,362 +0,0 @@ -# CipherStash Drizzle ORM Integration - -**Type-safe encryption for Drizzle ORM with searchable queries** - -Seamlessly integrate CipherStash encryption with Drizzle ORM and PostgreSQL to encrypt your data while maintaining full query capabilities—equality, range queries, text search, and sorting—all with complete TypeScript type safety. - -> [!TIP] -> For new projects we recommend [`@cipherstash/stack`](https://www.npmjs.com/package/@cipherstash/stack), which provides this integration via the separate [`@cipherstash/stack-drizzle`](https://www.npmjs.com/package/@cipherstash/stack-drizzle) package (EQL v3 on its `/v3` subpath). See the [Drizzle docs](https://cipherstash.com/docs). This package documents the legacy `@cipherstash/protect`-based API. - -## Features - -- 🔒 **Type-safe encryption/decryption** using Drizzle's inferred types -- 🔍 **Searchable encryption** with equality, range, and text search -- ⚡ **Bulk operations** for high performance -- 🎯 **Use Drizzle operators** to query encrypted data - -## Installation - -```bash -npm install @cipherstash/protect @cipherstash/drizzle drizzle-orm -``` - -## Database Setup - -Before using encrypted columns, you need to install the CipherStash EQL (Encrypt Query Language) functions in your PostgreSQL database. - -### Install EQL via migration - -The easiest way is to use the built-in CLI command: - -```bash -npx generate-eql-migration -# or: pnpm/yarn/bun generate-eql-migration -``` - -This will: -1. Generate a custom Drizzle migration (default name: `install-eql`) -2. Populate it with the EQL SQL schema from `@cipherstash/schema` -3. Place it in your `drizzle/` directory - -Then run your migrations: - -```bash -npx drizzle-kit migrate -# or: pnpm/yarn/bun drizzle-kit migrate -``` - -#### CLI Options - -```bash -Usage: generate-eql-migration [options] - -Options: - -n, --name Migration name (default: "install-eql") - -o, --out Output directory (default: "drizzle") - -h, --help Display this help message - -Examples: - npx generate-eql-migration - npx generate-eql-migration --name setup-eql - npx generate-eql-migration --out migrations -``` - -### Manual installation (alternative) - -If you prefer to install EQL manually: - -```bash -npx drizzle-kit generate --custom --name=install-eql -curl -sL https://github.com/cipherstash/encrypt-query-language/releases/download/eql-2.3.1/cipherstash-encrypt.sql > drizzle/0001_install-eql.sql -npx drizzle-kit migrate -``` - -## Quick Start - -### 1. Define your schema with encrypted columns - -```typescript -// db/schema.ts -import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' -import { encryptedType } from '@cipherstash/drizzle/pg' - -export const usersTable = pgTable('users', { - id: integer('id').primaryKey().generatedAlwaysAsIdentity(), - - // String with searchable encryption - email: encryptedType('email', { - freeTextSearch: true, - equality: true, - orderAndRange: true, - }), - - // Number with range queries - age: encryptedType('age', { - dataType: 'number', - equality: true, - orderAndRange: true, - }), - - // JSON object with searchable JSONB queries - profile: encryptedType<{ name: string; bio: string }>('profile', { - dataType: 'json', - searchableJson: true, - }), - - createdAt: timestamp('created_at').defaultNow(), -}) -``` - -> [!TIP] -> **Type Safety Tip**: Always specify the type parameter (`encryptedType`, `encryptedType`, etc.) to maintain type safety after decryption. -> Without it, decrypted values will be typed as `unknown`. -> -> This is because the database only stores and returns encrypted ciphertext, so it doesn't know the underlying original type. You must specify the decrypted type in your ORM schema for full type safety. - -### 2. Initialize Protect.js - -```typescript -// protect/config.ts -import { protect } from '@cipherstash/protect' -import { extractProtectSchema } from '@cipherstash/drizzle/pg' -import { usersTable } from '../db/schema' - -// Extract Protect.js schema from Drizzle table -export const users = extractProtectSchema(usersTable) - -// Initialize Protect.js client -export const protectClient = await protect({ - schemas: [users] -}) -``` - -### 3. Create Protect operators - -```typescript -// protect/operators.ts -import { createProtectOperators } from '@cipherstash/drizzle/pg' -import { protectClient } from './config' - -// Create operators that automatically handle encryption in queries -export const protectOps = createProtectOperators(protectClient) -``` - -## Usage Examples - -### Insert encrypted data - -```typescript -const newUsers = [ - { email: 'john@example.com', age: 25, profile: { name: 'John', bio: 'Dev' } }, - { email: 'jane@example.com', age: 30, profile: { name: 'Jane', bio: 'Designer' } }, -] - -// Encrypt all models at once -const encryptedUsers = await protectClient.bulkEncryptModels(newUsers, users) -if (encryptedUsers.failure) { - throw new Error(`Encryption failed: ${encryptedUsers.failure.message}`) -} - -// Insert encrypted data -await db.insert(usersTable).values(encryptedUsers.data) -``` - -### Select and decrypt - -```typescript -const results = await db - .select({ - id: usersTable.id, - email: usersTable.email, - age: usersTable.age, - profile: usersTable.profile, - }) - .from(usersTable) - -// Decrypt all results -const decrypted = await protectClient.bulkDecryptModels(results) -if (decrypted.failure) { - throw new Error(`Decryption failed: ${decrypted.failure.message}`) -} - -// TypeScript knows the types: email is string, age is number, etc. -decrypted.data.forEach(user => { - console.log(user.email) // ✅ string - console.log(user.age) // ✅ number - console.log(user.profile.name) // ✅ string -}) -``` - -### Search with encrypted columns - -```typescript -// Equality search -const results = await db - .select() - .from(usersTable) - .where(await protectOps.eq(usersTable.email, 'jane@example.com')) - -// Text search (LIKE/ILIKE) -const results = await db - .select() - .from(usersTable) - .where(await protectOps.ilike(usersTable.email, 'smith')) - -// Range queries -const results = await db - .select() - .from(usersTable) - .where( - await protectOps.and( - protectOps.gte(usersTable.age, 25), - protectOps.lte(usersTable.age, 35), - ), - ) - -// Decrypt results -const decrypted = await protectClient.bulkDecryptModels(results) -``` - -### Sorting encrypted columns - -```typescript -const results = await db - .select() - .from(usersTable) - .orderBy(protectOps.asc(usersTable.age)) - -const decrypted = await protectClient.bulkDecryptModels(results) -``` - -> [!IMPORTANT] -> Sorting with ORE on Supabase and other databases that don't support operator families will not work as expected. - -### Query encrypted JSONB data - -```typescript -// Check if a path exists in encrypted JSONB data -const results = await db - .select() - .from(usersTable) - .where(await protectOps.jsonbPathExists(usersTable.profile, '$.bio')) - -// Combine JSONB operators with other conditions -const results = await db - .select() - .from(usersTable) - .where( - await protectOps.and( - protectOps.jsonbPathExists(usersTable.profile, '$.name'), - protectOps.eq(usersTable.email, 'jane@example.com'), - ), - ) -``` - -> [!NOTE] -> `jsonbPathExists` returns a boolean and can be used directly in `WHERE` clauses. -> `jsonbPathQueryFirst` and `jsonbGet` return encrypted values, not booleans — use them in `SELECT` expressions, not in `WHERE` clauses. - -### Complex queries with mixed operators - -```typescript -import { eq } from 'drizzle-orm' - -// Mix Protect operators (encrypted) with regular Drizzle operators (non-encrypted) -const results = await db - .select() - .from(usersTable) - .where( - await protectOps.and( - // Protect operators for encrypted columns (batched for efficiency) - protectOps.gte(usersTable.age, 25), - protectOps.ilike(usersTable.email, 'developer'), - // Regular Drizzle operators for non-encrypted columns - eq(usersTable.id, 1), - ), - ) -``` - -> [!TIP] -> **Performance Tip**: Using `protectOps.and()` batches all encryption operations into a single `createSearchTerms` call, which is more efficient than awaiting each operator individually. - -## Available Operators - -All operators automatically handle encryption for encrypted columns. - -### Comparison Operators (async) -- `eq(left, right)` - Equality -- `ne(left, right)` - Not equal -- `gt(left, right)` - Greater than -- `gte(left, right)` - Greater than or equal -- `lt(left, right)` - Less than -- `lte(left, right)` - Less than or equal - -### Range Operators (async) -- `between(left, min, max)` - Between -- `notBetween(left, min, max)` - Not between - -### Text Search Operators (async) -- `like(left, right)` - LIKE -- `ilike(left, right)` - ILIKE (case-insensitive) -- `notIlike(left, right)` - NOT ILIKE - -### Array Operators (async) -- `inArray(left, right[])` - In array -- `notInArray(left, right[])` - Not in array - -### JSONB Operators (async) -- `jsonbPathQueryFirst(column, selector)` - Extract first value at JSONB path -- `jsonbGet(column, selector)` - Get value using JSONB `->` operator -- `jsonbPathExists(column, selector)` - Check if path exists in JSONB - -> [!IMPORTANT] -> JSONB operators require `searchableJson: true` and `dataType: 'json'` in the column's `encryptedType` config. - -### Sorting Operators (sync) -- `asc(column)` - Ascending order -- `desc(column)` - Descending order - -### Logical Operators -- `and(...conditions)` - AND (batches encryption operations) -- `or(...conditions)` - OR -- `not(condition)` - NOT - -### Null/Exists Operators -- `isNull(column)` - IS NULL -- `isNotNull(column)` - IS NOT NULL -- `exists(subquery)` - EXISTS -- `notExists(subquery)` - NOT EXISTS - -## API Reference - -### `encryptedType(columnName, options)` - -Creates an encrypted column type for Drizzle schemas. - -**Type Parameters:** -- `T` - The TypeScript type of the decrypted value (e.g., `string`, `number`, or a custom object type) - -**Options:** -- `dataType?: 'string' | 'number' | 'json'` - Data type (default: `'string'`) -- `freeTextSearch?: boolean` - Enable text search (LIKE/ILIKE) -- `equality?: boolean` - Enable equality queries -- `orderAndRange?: boolean` - Enable range queries and sorting -- `searchableJson?: boolean` - Enable JSONB path queries (requires `dataType: 'json'`) - -### `extractProtectSchema(table)` - -Extracts a Protect.js schema from a Drizzle table definition. - -**Parameters:** -- `table` - Drizzle table definition with encrypted columns - -**Returns:** Protect.js schema object - -### `createProtectOperators(protectClient)` - -Creates Drizzle-compatible operators that automatically handle encryption. - -**Parameters:** -- `protectClient` - Initialized Protect.js client - -**Returns:** Object with all operator functions diff --git a/packages/drizzle/__tests__/drizzle.test.ts b/packages/drizzle/__tests__/drizzle.test.ts deleted file mode 100644 index 7c44706f7..000000000 --- a/packages/drizzle/__tests__/drizzle.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -import 'dotenv/config' -import { - createProtectOperators, - encryptedType, - extractProtectSchema, -} from '@cipherstash/drizzle/pg' -import { protect } from '@cipherstash/protect' -import type { SQL } from 'drizzle-orm' -import { and, eq } from 'drizzle-orm' -import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' -import { drizzle } from 'drizzle-orm/postgres-js' -import postgres from 'postgres' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { userSeedData } from './fixtures/user-seed-data' -import { - decryptUserRow, - decryptUserRows, - type EncryptedUserRow, - expectRowsToBeEncrypted, - expectUsersToMatchPlaintext, - expectUserToMatchPlaintext, - type PlaintextUser, - unwrapResult, -} from './integration-test-helpers' - -if (!process.env.DATABASE_URL) { - throw new Error('Missing env.DATABASE_URL') -} - -const drizzleUsersTable = pgTable('protect-ci', { - id: integer('id').primaryKey().generatedAlwaysAsIdentity(), - email: encryptedType('email', { - freeTextSearch: true, - equality: true, - orderAndRange: true, - }), - age: encryptedType('age', { - dataType: 'number', - equality: true, - orderAndRange: true, - }), - score: encryptedType('score', { - dataType: 'number', - equality: true, - orderAndRange: true, - }), - profile: encryptedType<{ name: string; bio: string; level: number }>( - 'profile', - { - dataType: 'json', - searchableJson: true, - }, - ), - createdAt: timestamp('created_at').defaultNow(), - testRunId: text('test_run_id'), -}) - -const users = extractProtectSchema(drizzleUsersTable) - -// CI database does not currently support ORDER BY on encrypted columns. -const SKIP_ORDER_BY_TEST = true -const FALLBACK_EMAIL = 'john.doe@example.com' -const TEST_RUN_ID = `drizzle-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - -const encryptedUserSelection = { - id: drizzleUsersTable.id, - email: drizzleUsersTable.email, - age: drizzleUsersTable.age, - score: drizzleUsersTable.score, - profile: drizzleUsersTable.profile, -} - -let protectClient: Awaited> -let protectOps: ReturnType -let db: ReturnType | undefined -let postgresClient: ReturnType | undefined -let fallbackUserId = -1 - -function getDb(): ReturnType { - if (!db) { - throw new Error('Database client is not initialized') - } - return db -} - -function getSeedUser(email: string): PlaintextUser { - const user = userSeedData.find((candidate) => candidate.email === email) - if (!user) { - throw new Error(`Expected seed user not found for email: ${email}`) - } - return user -} - -function filterSeedUsers(predicate: (user: PlaintextUser) => boolean) { - return userSeedData.filter(predicate) -} - -async function selectEncryptedUsers( - condition: SQL | undefined, -): Promise { - if (!condition) { - throw new Error('Expected query condition') - } - - const rows = await getDb() - .select(encryptedUserSelection) - .from(drizzleUsersTable) - .where(condition) - - return rows as unknown as EncryptedUserRow[] -} - -beforeAll(async () => { - protectClient = await protect({ schemas: [users] }) - protectOps = createProtectOperators(protectClient) - - postgresClient = postgres(process.env.DATABASE_URL as string) - db = drizzle({ client: postgresClient }) - - // Idempotent fixture setup. The `protect-ci` table is shared across the - // drizzle + protect/supabase + stack/supabase integration suites; each - // suite's beforeAll runs the same CREATE TABLE so a fresh database is - // ready without manual DBA work. The schema is the union of every - // column those suites read or write. - await postgresClient` - CREATE TABLE IF NOT EXISTS "protect-ci" ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - email eql_v2_encrypted, - age eql_v2_encrypted, - score eql_v2_encrypted, - profile eql_v2_encrypted, - encrypted eql_v2_encrypted, - "otherField" TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - test_run_id TEXT - ) - ` - // Backfill any column added after the table was first created on a - // long-lived CI database. CREATE TABLE IF NOT EXISTS is a no-op on - // those, so new columns need an explicit ADD COLUMN IF NOT EXISTS. - await postgresClient` - ALTER TABLE "protect-ci" ADD COLUMN IF NOT EXISTS "otherField" TEXT - ` - - const encryptedUsers = unwrapResult( - await protectClient.bulkEncryptModels(userSeedData, users), - 'bulkEncryptModels', - ) - - const rowsToInsert = encryptedUsers.map((user) => ({ - ...user, - testRunId: TEST_RUN_ID, - })) - - const insertedRows = await getDb() - .insert(drizzleUsersTable) - .values(rowsToInsert) - .returning({ id: drizzleUsersTable.id }) - - expect(insertedRows).toHaveLength(userSeedData.length) - - const fallbackRows = await selectEncryptedUsers( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.eq(drizzleUsersTable.email, FALLBACK_EMAIL), - ), - ) - - expect(fallbackRows).toHaveLength(1) - fallbackUserId = fallbackRows[0].id -}, 60000) - -afterAll(async () => { - try { - if (db) { - await db - .delete(drizzleUsersTable) - .where(eq(drizzleUsersTable.testRunId, TEST_RUN_ID)) - } - } finally { - await postgresClient?.end() - } -}, 30000) - -describe('Drizzle ORM Integration with Protect.js', () => { - it('encrypts values for equality queries and decrypts back to exact plaintext', async () => { - const searchEmail = 'jane.smith@example.com' - const expectedUser = getSeedUser(searchEmail) - - const rows = await selectEncryptedUsers( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.eq(drizzleUsersTable.email, searchEmail), - ), - ) - - expect(rows).toHaveLength(1) - expectRowsToBeEncrypted(rows) - - const decryptedUser = await decryptUserRow(protectClient, rows[0]) - expectUserToMatchPlaintext(decryptedUser, expectedUser) - }, 30000) - - it('executes free-text query patterns and matches exact plaintext rows', async () => { - const searchText = 'smith' - const expectedUsers = filterSeedUsers((user) => - user.email.toLowerCase().includes(searchText), - ) - - const rows = await selectEncryptedUsers( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.ilike(drizzleUsersTable.email, searchText), - ), - ) - - expect(rows).toHaveLength(expectedUsers.length) - expectRowsToBeEncrypted(rows) - - const decryptedUsers = await decryptUserRows(protectClient, rows) - expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) - }, 30000) - - it('executes range query patterns and decrypts exact plaintext matches', async () => { - const minAge = 28 - const expectedUsers = filterSeedUsers((user) => user.age >= minAge) - - const rows = await selectEncryptedUsers( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.gte(drizzleUsersTable.age, minAge), - ), - ) - - expect(rows).toHaveLength(expectedUsers.length) - expectRowsToBeEncrypted(rows) - - const decryptedUsers = await decryptUserRows(protectClient, rows) - expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) - }, 30000) - - const orderByIt = SKIP_ORDER_BY_TEST ? it.skip : it - orderByIt( - 'supports encrypted ordering and preserves decrypted order', - async () => { - const expectedInAgeOrder = [...userSeedData].sort( - (left, right) => left.age - right.age, - ) - - const rows = (await getDb() - .select(encryptedUserSelection) - .from(drizzleUsersTable) - .where(eq(drizzleUsersTable.testRunId, TEST_RUN_ID)) - .orderBy( - protectOps.asc(drizzleUsersTable.age), - )) as unknown as EncryptedUserRow[] - - expect(rows).toHaveLength(userSeedData.length) - expectRowsToBeEncrypted(rows) - - const decryptedUsers = await decryptUserRows(protectClient, rows) - - expect(decryptedUsers.map((user) => user.age)).toEqual( - expectedInAgeOrder.map((user) => user.age), - ) - expectUsersToMatchPlaintext(decryptedUsers, expectedInAgeOrder) - }, - 30000, - ) - - it('batches encrypted predicates with and() and returns exact plaintext rows', async () => { - const minAge = 22 - const maxAge = 35 - const searchText = 'smith' - const expectedUsers = filterSeedUsers( - (user) => - user.age >= minAge && - user.age <= maxAge && - user.email.toLowerCase().includes(searchText), - ) - - const rows = await selectEncryptedUsers( - await protectOps.and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - protectOps.gte(drizzleUsersTable.age, minAge), - protectOps.lte(drizzleUsersTable.age, maxAge), - protectOps.ilike(drizzleUsersTable.email, searchText), - ), - ) - - expect(rows).toHaveLength(expectedUsers.length) - expectRowsToBeEncrypted(rows) - - const decryptedUsers = await decryptUserRows(protectClient, rows) - expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) - }, 30000) - - it('mixes encrypted and plain predicates with or() and decrypts to exact plaintext', async () => { - const targetEmails = ['jane.smith@example.com', 'bob.wilson@example.com'] - const expectedUsers = filterSeedUsers( - (user) => - targetEmails.includes(user.email) || user.email === FALLBACK_EMAIL, - ) - - expect(fallbackUserId).toBeGreaterThan(0) - - const rows = await selectEncryptedUsers( - await protectOps.and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - protectOps.or( - protectOps.eq(drizzleUsersTable.email, targetEmails[0]), - protectOps.eq(drizzleUsersTable.email, targetEmails[1]), - eq(drizzleUsersTable.id, fallbackUserId), - ), - ), - ) - - expect(rows).toHaveLength(expectedUsers.length) - expectRowsToBeEncrypted(rows) - - const decryptedUsers = await decryptUserRows(protectClient, rows) - expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) - }, 30000) - - it('decrypts nested JSON payloads back to the original plaintext object', async () => { - const searchEmail = 'alice.johnson@example.com' - const expectedUser = getSeedUser(searchEmail) - - const rows = await selectEncryptedUsers( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.eq(drizzleUsersTable.email, searchEmail), - ), - ) - - expect(rows).toHaveLength(1) - expectRowsToBeEncrypted(rows) - - const decryptedUser = await decryptUserRow(protectClient, rows[0]) - expect(decryptedUser.profile).toEqual(expectedUser.profile) - expectUserToMatchPlaintext(decryptedUser, expectedUser) - }, 30000) - - it('supports encrypted inArray query patterns with exact plaintext matching', async () => { - const searchEmails = ['jane.smith@example.com', 'bob.wilson@example.com'] - const expectedUsers = filterSeedUsers((user) => - searchEmails.includes(user.email), - ) - - const rows = await selectEncryptedUsers( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.inArray(drizzleUsersTable.email, searchEmails), - ), - ) - - expect(rows).toHaveLength(expectedUsers.length) - expectRowsToBeEncrypted(rows) - - const decryptedUsers = await decryptUserRows(protectClient, rows) - expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) - }, 30000) - - it('supports encrypted between query patterns with exact plaintext matching', async () => { - const minAge = 25 - const maxAge = 30 - const expectedUsers = filterSeedUsers( - (user) => user.age >= minAge && user.age <= maxAge, - ) - - const rows = await selectEncryptedUsers( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.between(drizzleUsersTable.age, minAge, maxAge), - ), - ) - - expect(rows).toHaveLength(expectedUsers.length) - expectRowsToBeEncrypted(rows) - - const decryptedUsers = await decryptUserRows(protectClient, rows) - expectUsersToMatchPlaintext(decryptedUsers, expectedUsers) - }, 30000) - - it('supports jsonbPathExists in WHERE clause', async () => { - const rows = await selectEncryptedUsers( - and( - eq(drizzleUsersTable.testRunId, TEST_RUN_ID), - await protectOps.jsonbPathExists(drizzleUsersTable.profile, '$.name'), - ), - ) - - expect(rows.length).toBeGreaterThan(0) - expectRowsToBeEncrypted(rows) - - const decryptedUsers = await decryptUserRows(protectClient, rows) - for (const user of decryptedUsers) { - expect(user.profile.name).toBeDefined() - } - }, 30000) -}) diff --git a/packages/drizzle/__tests__/fixtures/doc-seed-data.ts b/packages/drizzle/__tests__/fixtures/doc-seed-data.ts deleted file mode 100644 index 982500cd0..000000000 --- a/packages/drizzle/__tests__/fixtures/doc-seed-data.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Seed data for documentation examples. - * This data matches the examples in docs/reference/drizzle/*.md - * - * ## Date Strategy - * Uses relative dates (days before test run) to ensure tests remain valid - * regardless of when they're executed. Documentation examples use relative - * concepts like "recent transactions" rather than specific dates. - * - * ## Seed Data to Documentation Section Mapping - * - * | Record | Account | Amount | Description | Used In | - * |--------|---------|--------|-------------|---------| - * | 0 | 1234567890 | 800.00 | Salary deposit | drizzle.md: "Equality Matching", "Combined Queries" | - * | 1 | 0987654321 | 150.00 | Gym membership | drizzle.md: "Free Text Search" (ilike 'gym') | - * | 2 | 1234567890 | 1250.00 | Rent payment | drizzle.md: "Range Queries" (amount > 1000) | - * | 3-11 | various | various | various | drizzle.md: "Ordering Results", pagination examples | - * | 12 | 1010101010 | 60.00 | Gym supplements | drizzle.md: "Free Text Search" (second gym match) | - * | 13 | 1212121212 | 2000.00 | Bonus deposit | drizzle.md: "Range Queries" (high amount) | - * | 14 | 1313131313 | 35.00 | Book purchase | drizzle.md: "Range Queries" (low amount) | - * - * ## Maintenance - * When updating documentation examples: - * 1. Check which seed records the example depends on (see mapping above) - * 2. Update seed data if new values are needed - * 3. Update the mapping table to reflect changes - * 4. Run tests to verify examples still work - */ - -// Helper to create dates relative to test execution -function daysAgo(days: number): number { - const date = new Date() - date.setDate(date.getDate() - days) - date.setHours(10, 0, 0, 0) // Normalize to 10:00 UTC - return date.getTime() -} - -export const docSeedData = [ - { - account: '1234567890', - amount: 800.0, - description: 'Salary deposit', - createdAt: daysAgo(10), // ~10 days ago - }, - { - account: '0987654321', - amount: 150.0, - description: 'Gym membership payment', - createdAt: daysAgo(13), // ~13 days ago - }, - { - account: '1234567890', - amount: 1250.0, - description: 'Rent payment', - createdAt: daysAgo(5), // ~5 days ago - }, - { - account: '5555555555', - amount: 75.0, - description: 'Coffee subscription', - createdAt: daysAgo(7), // ~7 days ago - }, - { - account: '1111111111', - amount: 200.0, - description: 'Internet payment', - createdAt: daysAgo(11), // ~11 days ago - }, - { - account: '2222222222', - amount: 50.0, - description: 'Streaming service', - createdAt: daysAgo(9), // ~9 days ago - }, - { - account: '3333333333', - amount: 1500.0, - description: 'Car payment', - createdAt: daysAgo(3), // ~3 days ago - }, - { - account: '4444444444', - amount: 300.0, - description: 'Insurance payment', - createdAt: daysAgo(14), // ~14 days ago - }, - { - account: '6666666666', - amount: 25.0, - description: 'App subscription', - createdAt: daysAgo(2), // ~2 days ago - }, - { - account: '7777777777', - amount: 500.0, - description: 'Utility payment', - createdAt: daysAgo(6), // ~6 days ago - }, - { - account: '8888888888', - amount: 100.0, - description: 'Phone payment', - createdAt: daysAgo(12), // ~12 days ago - }, - { - account: '9999999999', - amount: 450.0, - description: 'Grocery payment', - createdAt: daysAgo(8), // ~8 days ago - }, - { - account: '1010101010', - amount: 60.0, - description: 'Gym supplements', - createdAt: daysAgo(1), // ~1 day ago - }, - { - account: '1212121212', - amount: 2000.0, - description: 'Bonus deposit', - createdAt: daysAgo(4), // ~4 days ago - }, - { - account: '1313131313', - amount: 35.0, - description: 'Book purchase', - createdAt: daysAgo(15), // ~15 days ago (oldest) - }, -] diff --git a/packages/drizzle/__tests__/fixtures/user-seed-data.ts b/packages/drizzle/__tests__/fixtures/user-seed-data.ts deleted file mode 100644 index 547541ab7..000000000 --- a/packages/drizzle/__tests__/fixtures/user-seed-data.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { PlaintextUser } from '../integration-test-helpers' - -export const userSeedData: PlaintextUser[] = [ - { - email: 'john.doe@example.com', - age: 25, - score: 85, - profile: { - name: 'John Doe', - bio: 'Software engineer with 5 years experience', - level: 3, - }, - }, - { - email: 'jane.smith@example.com', - age: 30, - score: 92, - profile: { - name: 'Jane Smith', - bio: 'Senior developer specializing in React', - level: 4, - }, - }, - { - email: 'bob.wilson@example.com', - age: 35, - score: 78, - profile: { - name: 'Bob Wilson', - bio: 'Full-stack developer and team lead', - level: 5, - }, - }, - { - email: 'alice.johnson@example.com', - age: 28, - score: 88, - profile: { - name: 'Alice Johnson', - bio: 'Frontend specialist with design skills', - level: 3, - }, - }, - { - email: 'jill.smith@example.com', - age: 22, - score: 75, - profile: { - name: 'Jill Smith', - bio: 'Backend developer with 3 years experience', - level: 3, - }, - }, -] diff --git a/packages/drizzle/__tests__/integration-test-helpers.ts b/packages/drizzle/__tests__/integration-test-helpers.ts deleted file mode 100644 index a5eb5cb2c..000000000 --- a/packages/drizzle/__tests__/integration-test-helpers.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { ProtectError, Result } from '@cipherstash/protect' -import type { ProtectClient } from '@cipherstash/protect/client' -import { expect } from 'vitest' - -type UserProfile = { - name: string - bio: string - level: number -} - -export type DecryptedUser = { - id: number - email: string - age: number - score: number - profile: UserProfile -} - -export type PlaintextUser = Omit - -export type EncryptedPayload = { - c: string -} & Record - -export type EncryptedUserRow = { - id: number - email: EncryptedPayload - age: EncryptedPayload - score: EncryptedPayload - profile: EncryptedPayload -} - -function toComparableUser(user: PlaintextUser | DecryptedUser): PlaintextUser { - return { - email: user.email, - age: user.age, - score: user.score, - profile: { - name: user.profile.name, - bio: user.profile.bio, - level: user.profile.level, - }, - } -} - -function sortByEmail(users: PlaintextUser[]): PlaintextUser[] { - return [...users].sort((left, right) => left.email.localeCompare(right.email)) -} - -function assertEncryptedPayload( - value: unknown, - columnName: string, -): asserts value is EncryptedPayload { - // EQL v2.3: scalar payloads carry the ciphertext at the root (`k: 'ct', c`); - // SteVec payloads carry it on the first sv entry (`k: 'sv', sv: [{ c }]`). - expect( - value, - `${columnName} should be returned as encrypted payload before decrypt`, - ).toEqual( - expect.objectContaining({ - k: expect.stringMatching(/^(ct|sv)$/), - }), - ) -} - -export function unwrapResult( - result: Result, - operation: string, -): T { - if (result.failure) { - throw new Error(`${operation} failed: ${result.failure.message}`) - } - - return result.data -} - -export function expectRowsToBeEncrypted(rows: EncryptedUserRow[]) { - for (const row of rows) { - expect(row.id).toEqual(expect.any(Number)) - assertEncryptedPayload(row.email, 'email') - assertEncryptedPayload(row.age, 'age') - assertEncryptedPayload(row.score, 'score') - assertEncryptedPayload(row.profile, 'profile') - } -} - -export async function decryptUserRows( - protectClient: ProtectClient, - rows: EncryptedUserRow[], -): Promise { - const decrypted = await protectClient.bulkDecryptModels(rows) - return unwrapResult( - decrypted, - 'bulkDecryptModels', - ) as unknown as DecryptedUser[] -} - -export async function decryptUserRow( - protectClient: ProtectClient, - row: EncryptedUserRow, -): Promise { - const decrypted = await protectClient.decryptModel(row) - return unwrapResult(decrypted, 'decryptModel') as unknown as DecryptedUser -} - -export function expectUserToMatchPlaintext( - actual: DecryptedUser, - expected: PlaintextUser, -) { - expect(actual.id).toEqual(expect.any(Number)) - expect(toComparableUser(actual)).toEqual(toComparableUser(expected)) -} - -export function expectUsersToMatchPlaintext( - actual: DecryptedUser[], - expected: PlaintextUser[], -) { - const normalizedActual = sortByEmail( - actual.map((user) => toComparableUser(user)), - ) - const normalizedExpected = sortByEmail( - expected.map((user) => toComparableUser(user)), - ) - - expect(normalizedActual).toEqual(normalizedExpected) -} diff --git a/packages/drizzle/__tests__/operators-jsonb.test.ts b/packages/drizzle/__tests__/operators-jsonb.test.ts deleted file mode 100644 index 8171fe13c..000000000 --- a/packages/drizzle/__tests__/operators-jsonb.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { encryptedType, ProtectOperatorError } from '@cipherstash/drizzle/pg' -import { pgTable } from 'drizzle-orm/pg-core' -import { describe, expect, it } from 'vitest' -import { setup } from './test-utils' - -const docsTable = pgTable('json_docs', { - metadata: encryptedType>('metadata', { - dataType: 'json', - searchableJson: true, - }), - noJsonConfig: encryptedType('no_json_config', { - equality: true, - }), -}) - -describe('createProtectOperators JSONB selector typing', () => { - it('casts jsonbPathQueryFirst selector params to eql_v2_encrypted', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.jsonbPathQueryFirst( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch( - /eql_v2\.jsonb_path_query_first\([^,]+,\s*\$\d+::eql_v2_encrypted\)/, - ) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) - - it('casts jsonbPathExists selector params to eql_v2_encrypted', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.jsonbPathExists( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch( - /eql_v2\.jsonb_path_exists\([^,]+,\s*\$\d+::eql_v2_encrypted\)/, - ) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) - - it('casts jsonbGet selector params to eql_v2_encrypted', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.jsonbGet( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch(/->\s*\$\d+::eql_v2_encrypted/) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) -}) - -describe('JSONB operator error paths', () => { - it('throws ProtectOperatorError when column lacks searchableJson config', () => { - const { protectOps } = setup() - - expect(() => - protectOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path'), - ).toThrow(ProtectOperatorError) - - expect(() => - protectOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path'), - ).toThrow(/searchableJson/) - }) - - it('throws ProtectOperatorError for jsonbPathExists without searchableJson', () => { - const { protectOps } = setup() - - expect(() => - protectOps.jsonbPathExists(docsTable.noJsonConfig, '$.path'), - ).toThrow(ProtectOperatorError) - }) - - it('throws ProtectOperatorError for jsonbGet without searchableJson', () => { - const { protectOps } = setup() - - expect(() => protectOps.jsonbGet(docsTable.noJsonConfig, '$.path')).toThrow( - ProtectOperatorError, - ) - }) - - it('error includes column name and operator context', () => { - const { protectOps } = setup() - - try { - protectOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path') - expect.fail('Should have thrown') - } catch (error) { - expect(error).toBeInstanceOf(ProtectOperatorError) - const opError = error as ProtectOperatorError - expect(opError.context?.columnName).toBe('no_json_config') - expect(opError.context?.operator).toBe('jsonbPathQueryFirst') - } - }) -}) - -describe('JSONB batched operations', () => { - it('batches jsonbPathQueryFirst and jsonbGet in protectOps.and()', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.and( - protectOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.email'), - protectOps.jsonbGet(docsTable.metadata, '$.profile.name'), - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.jsonb_path_query_first') - expect(query.sql).toContain('->') - // Verify batch encryption happened (at least one call with 2 terms) - expect( - encryptQuery.mock.calls.some( - (call: unknown[]) => Array.isArray(call[0]) && call[0].length === 2, - ), - ).toBe(true) - }) - - it('batches jsonbPathExists and jsonbPathQueryFirst in protectOps.or()', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.or( - protectOps.jsonbPathExists(docsTable.metadata, '$.profile.email'), - protectOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.name'), - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.jsonb_path_exists') - expect(query.sql).toContain('eql_v2.jsonb_path_query_first') - // Verify batch encryption happened (at least one call with 2 terms) - expect( - encryptQuery.mock.calls.some( - (call: unknown[]) => Array.isArray(call[0]) && call[0].length === 2, - ), - ).toBe(true) - }) - - it('generates SQL combining conditions with AND', async () => { - const { protectOps, dialect } = setup() - - const condition = await protectOps.and( - protectOps.jsonbPathQueryFirst(docsTable.metadata, '$.a'), - protectOps.jsonbPathExists(docsTable.metadata, '$.b'), - ) - const query = dialect.sqlToQuery(condition) - - // AND combines conditions - expect(query.sql).toContain(' and ') - }) - - it('generates SQL combining conditions with OR', async () => { - const { protectOps, dialect } = setup() - - const condition = await protectOps.or( - protectOps.jsonbPathQueryFirst(docsTable.metadata, '$.a'), - protectOps.jsonbPathExists(docsTable.metadata, '$.b'), - ) - const query = dialect.sqlToQuery(condition) - - // OR combines conditions - expect(query.sql).toContain(' or ') - }) -}) diff --git a/packages/drizzle/__tests__/operators.test.ts b/packages/drizzle/__tests__/operators.test.ts deleted file mode 100644 index e88c75a48..000000000 --- a/packages/drizzle/__tests__/operators.test.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { - encryptedType, - ProtectConfigError, - ProtectOperatorError, -} from '@cipherstash/drizzle/pg' -import type { SQL } from 'drizzle-orm' -import { integer, pgTable, text } from 'drizzle-orm/pg-core' -import { describe, expect, it } from 'vitest' -import { setup } from './test-utils' - -// ============================================================================ -// Test table definitions -// ============================================================================ - -const usersTable = pgTable('users', { - email: encryptedType('email', { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }), - age: encryptedType('age', { - dataType: 'number', - orderAndRange: true, - }), - name: encryptedType('name', { - equality: true, - }), - bio: encryptedType('bio', { - freeTextSearch: true, - }), -}) - -// ============================================================================ -// 2a. Comparison operators -// ============================================================================ - -describe('Comparison operators', () => { - it('eq on column with equality config uses = with encrypted param', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.eq(usersTable.name, 'Alice') - const query = dialect.sqlToQuery(condition) - - // eq with equality config uses regular Drizzle eq (= operator) - expect(query.sql).toContain('=') - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'equality' }, - ]) - }) - - it('ne on column with equality config encrypts and uses <>', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.ne(usersTable.name, 'Alice') - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('<>') - expect(query.params).toHaveLength(1) - expect(encryptQuery).toHaveBeenCalledTimes(1) - }) - - it('gt on column with orderAndRange uses eql_v2.gt()', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.gt(usersTable.age, 25) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.gt(') - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'orderAndRange' }, - ]) - }) - - it('gte on column with orderAndRange uses eql_v2.gte()', async () => { - const { protectOps, dialect } = setup() - - const condition = await protectOps.gte(usersTable.age, 25) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.gte(') - }) - - it('lt on column with orderAndRange uses eql_v2.lt()', async () => { - const { protectOps, dialect } = setup() - - const condition = await protectOps.lt(usersTable.age, 30) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.lt(') - }) - - it('lte on column with orderAndRange uses eql_v2.lte()', async () => { - const { protectOps, dialect } = setup() - - const condition = await protectOps.lte(usersTable.age, 30) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.lte(') - }) - - it('eq on column without equality falls through to plain Drizzle eq', async () => { - // age has orderAndRange but not equality - eq should fall through to regular Drizzle eq - // since the code checks equality first, then orderAndRange for gt/gte/lt/lte only - const { protectOps, dialect } = setup() - - // age column has orderAndRange but NOT equality - const condition = await protectOps.eq(usersTable.age, 25) - const query = dialect.sqlToQuery(condition) - - // Without equality config, eq falls through to regular Drizzle eq - expect(query.sql).toContain('=') - }) - - it('eq on column with both equality and orderAndRange prefers equality', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - // email has both equality and orderAndRange - const condition = await protectOps.eq(usersTable.email, 'test@example.com') - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('=') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'equality' }, - ]) - }) -}) - -// ============================================================================ -// 2b. Text search operators -// ============================================================================ - -describe('Text search operators', () => { - it('ilike on column with freeTextSearch uses eql_v2.ilike()', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.ilike(usersTable.bio, '%engineer%') - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.ilike(') - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'freeTextSearch' }, - ]) - }) - - it('like on column with freeTextSearch uses eql_v2.like()', async () => { - const { protectOps, dialect } = setup() - - const condition = await protectOps.like(usersTable.bio, '%test%') - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.like(') - }) - - it('notIlike wraps eql_v2.ilike() with NOT', async () => { - const { protectOps, dialect } = setup() - - const condition = await protectOps.notIlike(usersTable.bio, '%spam%') - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch(/NOT.*eql_v2\.ilike\(/i) - }) - - it('ilike on column without freeTextSearch falls through to Drizzle ilike', () => { - const { protectOps, dialect } = setup() - - // name column has equality but not freeTextSearch - const condition = protectOps.ilike(usersTable.name, '%test%') - - // Should be synchronous (no encryption needed) since no freeTextSearch config - expect(condition).not.toBeInstanceOf(Promise) - - const query = dialect.sqlToQuery(condition as SQL) - expect(query.sql).toContain('ilike') - }) -}) - -// ============================================================================ -// 2c. Range operators -// ============================================================================ - -describe('Range operators', () => { - it('between on column with orderAndRange generates eql_v2.gte AND eql_v2.lte', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.between(usersTable.age, 20, 30) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.gte(') - expect(query.sql).toContain('eql_v2.lte(') - // Both min and max values encrypted - expect(query.params).toHaveLength(2) - expect(query.params[0]).toContain('encrypted-value') - expect(query.params[1]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalled() - }) - - it('notBetween wraps range condition with NOT', async () => { - const { protectOps, dialect } = setup() - - const condition = await protectOps.notBetween(usersTable.age, 20, 30) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch(/NOT/) - expect(query.sql).toContain('eql_v2.gte(') - expect(query.sql).toContain('eql_v2.lte(') - }) - - it('between on column without orderAndRange falls through to Drizzle between', () => { - const { protectOps, dialect } = setup() - - // name column has equality but not orderAndRange - const condition = protectOps.between(usersTable.name, 'A', 'Z') - - // Should be synchronous (plain Drizzle between) - expect(condition).not.toBeInstanceOf(Promise) - - const query = dialect.sqlToQuery(condition as SQL) - expect(query.sql).toContain('between') - }) -}) - -// ============================================================================ -// 2d. Sorting operators -// ============================================================================ - -describe('Sorting operators', () => { - it('asc on column with orderAndRange uses eql_v2.order_by()', () => { - const { protectOps, dialect } = setup() - - const result = protectOps.asc(usersTable.age) - const query = dialect.sqlToQuery(result) - - expect(query.sql).toContain('eql_v2.order_by(') - expect(query.sql).toMatch(/asc/i) - }) - - it('desc on column with orderAndRange uses eql_v2.order_by()', () => { - const { protectOps, dialect } = setup() - - const result = protectOps.desc(usersTable.age) - const query = dialect.sqlToQuery(result) - - expect(query.sql).toContain('eql_v2.order_by(') - expect(query.sql).toMatch(/desc/i) - }) - - it('asc on column without orderAndRange uses plain Drizzle asc', () => { - const { protectOps, dialect } = setup() - - const result = protectOps.asc(usersTable.name) - const query = dialect.sqlToQuery(result) - - expect(query.sql).not.toContain('eql_v2.order_by(') - }) - - it('desc on column without orderAndRange uses plain Drizzle desc', () => { - const { protectOps, dialect } = setup() - - const result = protectOps.desc(usersTable.name) - const query = dialect.sqlToQuery(result) - - expect(query.sql).not.toContain('eql_v2.order_by(') - }) -}) - -// ============================================================================ -// 2e. Array operators -// ============================================================================ - -describe('Array operators', () => { - it('inArray on column with equality encrypts all values', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.inArray(usersTable.name, [ - 'Alice', - 'Bob', - 'Carol', - ]) - const query = dialect.sqlToQuery(condition) - - // inArray with equality uses OR of eq() conditions - expect(query.params.length).toBeGreaterThanOrEqual(3) - expect(encryptQuery).toHaveBeenCalled() - }) - - it('notInArray on column with equality encrypts all values', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.notInArray(usersTable.name, [ - 'Alice', - 'Bob', - ]) - const query = dialect.sqlToQuery(condition) - - expect(query.params.length).toBeGreaterThanOrEqual(2) - expect(encryptQuery).toHaveBeenCalled() - }) - - it('inArray batch-encrypts values in single call', async () => { - const { encryptQuery, protectOps } = setup() - - await protectOps.inArray(usersTable.name, ['Alice', 'Bob']) - - // All values should be batch-encrypted in a single call - expect(encryptQuery).toHaveBeenCalledTimes(1) - const terms = encryptQuery.mock.calls[0]?.[0] as unknown[] - expect(terms).toHaveLength(2) - }) -}) - -// ============================================================================ -// 2f. Error classes -// ============================================================================ - -describe('Error classes', () => { - it('ProtectOperatorError stores context with tableName, columnName, operator', () => { - const error = new ProtectOperatorError('Test error', { - tableName: 'users', - columnName: 'email', - operator: 'eq', - }) - - expect(error).toBeInstanceOf(Error) - expect(error).toBeInstanceOf(ProtectOperatorError) - expect(error.name).toBe('ProtectOperatorError') - expect(error.message).toBe('Test error') - expect(error.context?.tableName).toBe('users') - expect(error.context?.columnName).toBe('email') - expect(error.context?.operator).toBe('eq') - }) - - it('ProtectConfigError extends ProtectOperatorError', () => { - const error = new ProtectConfigError('Config error', { - tableName: 'users', - columnName: 'age', - operator: 'gt', - }) - - expect(error).toBeInstanceOf(Error) - expect(error).toBeInstanceOf(ProtectOperatorError) - expect(error).toBeInstanceOf(ProtectConfigError) - expect(error.name).toBe('ProtectConfigError') - expect(error.context?.tableName).toBe('users') - }) - - it('ProtectOperatorError works without context', () => { - const error = new ProtectOperatorError('No context') - - expect(error.message).toBe('No context') - expect(error.context).toBeUndefined() - }) -}) - -// ============================================================================ -// 2g. Pass-through operators -// ============================================================================ - -describe('Pass-through operators', () => { - it('exists is the Drizzle exists function', () => { - const { protectOps } = setup() - - // These should be direct references to drizzle-orm functions - expect(typeof protectOps.exists).toBe('function') - expect(typeof protectOps.notExists).toBe('function') - expect(typeof protectOps.isNull).toBe('function') - expect(typeof protectOps.isNotNull).toBe('function') - expect(typeof protectOps.not).toBe('function') - }) - - it('arrayContains/arrayContained/arrayOverlaps are Drizzle functions', () => { - const { protectOps } = setup() - - expect(typeof protectOps.arrayContains).toBe('function') - expect(typeof protectOps.arrayContained).toBe('function') - expect(typeof protectOps.arrayOverlaps).toBe('function') - }) -}) - -// ============================================================================ -// 2h. Batched and/or -// ============================================================================ - -describe('Batched and/or operators', () => { - it('protectOps.and() batches multiple encrypted operators', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.and( - protectOps.eq(usersTable.email, 'test@example.com'), - protectOps.gte(usersTable.age, 18), - protectOps.ilike(usersTable.bio, '%engineer%'), - ) - const query = dialect.sqlToQuery(condition) - - // All three conditions should be in the SQL - expect(query.sql).toContain('=') - expect(query.sql).toContain('eql_v2.gte(') - expect(query.sql).toContain('eql_v2.ilike(') - // SQL uses AND - expect(query.sql).toContain(' and ') - expect(encryptQuery).toHaveBeenCalled() - }) - - it('protectOps.or() batches and uses OR', async () => { - const { encryptQuery, protectOps, dialect } = setup() - - const condition = await protectOps.or( - protectOps.eq(usersTable.name, 'Alice'), - protectOps.eq(usersTable.name, 'Bob'), - ) - const query = dialect.sqlToQuery(condition) - - // SQL uses OR - expect(query.sql).toContain(' or ') - expect(query.params.length).toBeGreaterThanOrEqual(2) - // Both eq operators trigger encryption - expect(encryptQuery).toHaveBeenCalled() - }) - - it('protectOps.and() handles undefined conditions', async () => { - const { protectOps, dialect } = setup() - - const condition = await protectOps.and( - protectOps.eq(usersTable.name, 'Alice'), - undefined, - protectOps.gte(usersTable.age, 18), - ) - const query = dialect.sqlToQuery(condition) - - // Should still produce valid SQL with the non-undefined conditions - expect(query.sql).toContain('=') - expect(query.sql).toContain('eql_v2.gte(') - expect(query.sql).toContain(' and ') - }) - - it('protectOps.and() with only non-encrypted conditions', async () => { - const { protectOps } = setup() - - // Using the plain Drizzle eq (non-encrypted column fallback) - // age has no equality, so eq falls through to Drizzle eq (synchronous) - const eqResult = protectOps.eq(usersTable.age, 25) - - // If synchronous (non-encrypted), and() should still work - const condition = await protectOps.and(eqResult) - - expect(condition).toBeTruthy() - }) -}) - -// ============================================================================ -// bigint rejection — the EQL v2 integration cannot carry a native bigint -// (`@cipherstash/protect`'s query term type is `JsPlaintext | null`), so a -// bigint must fail loudly rather than silently coerce to the text "1". -// ============================================================================ - -describe('bigint values are rejected on the EQL v2 integration', () => { - it('gt throws ProtectOperatorError instead of silently stringifying a bigint', async () => { - const { protectOps } = setup() - - // `age` has orderAndRange, so `gt` takes the encrypting path and reaches - // `toPlaintext`, where a bigint must be rejected. - await expect( - protectOps.gt(usersTable.age, 1n as unknown as number), - ).rejects.toThrow(ProtectOperatorError) - await expect( - protectOps.gt(usersTable.age, 1n as unknown as number), - ).rejects.toThrow(/bigint values are not supported/) - }) - - it('never reaches the encrypt client when rejecting a bigint', async () => { - const { protectOps, encryptQuery } = setup() - - await expect( - protectOps.gt(usersTable.age, 1n as unknown as number), - ).rejects.toThrow(ProtectOperatorError) - expect(encryptQuery).not.toHaveBeenCalled() - }) -}) diff --git a/packages/drizzle/__tests__/schema-extraction.test.ts b/packages/drizzle/__tests__/schema-extraction.test.ts deleted file mode 100644 index e70872cab..000000000 --- a/packages/drizzle/__tests__/schema-extraction.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { encryptedType, extractProtectSchema } from '@cipherstash/drizzle/pg' -import { integer, pgTable, text } from 'drizzle-orm/pg-core' -import { describe, expect, it } from 'vitest' - -// ============================================================================ -// 3a. Basic extraction -// ============================================================================ - -describe('extractProtectSchema basic extraction', () => { - it('extracts a single encrypted column', () => { - const table = pgTable('single_col', { - email: encryptedType('email', { - equality: true, - }), - }) - - const protectTable = extractProtectSchema(table) - const built = protectTable.build() - - expect(built.tableName).toBe('single_col') - expect(built.columns).toHaveProperty('email') - expect(Object.keys(built.columns)).toHaveLength(1) - }) - - it('extracts multiple encrypted columns with different configs', () => { - const table = pgTable('multi_col', { - email: encryptedType('email', { - equality: true, - freeTextSearch: true, - }), - age: encryptedType('age', { - dataType: 'number', - orderAndRange: true, - }), - metadata: encryptedType>('metadata', { - dataType: 'json', - searchableJson: true, - }), - }) - - const protectTable = extractProtectSchema(table) - const built = protectTable.build() - - expect(built.tableName).toBe('multi_col') - expect(Object.keys(built.columns)).toHaveLength(3) - expect(built.columns).toHaveProperty('email') - expect(built.columns).toHaveProperty('age') - expect(built.columns).toHaveProperty('metadata') - }) -}) - -// ============================================================================ -// 3b. Config option mapping -// ============================================================================ - -describe('extractProtectSchema config mapping', () => { - it('equality: true -> column has unique index in build output', () => { - const table = pgTable('eq_test', { - col: encryptedType('col', { equality: true }), - }) - - const built = extractProtectSchema(table).build() - expect(built.columns.col.indexes).toHaveProperty('unique') - }) - - it('orderAndRange: true -> column has ore index in build output', () => { - const table = pgTable('ore_test', { - col: encryptedType('col', { orderAndRange: true }), - }) - - const built = extractProtectSchema(table).build() - expect(built.columns.col.indexes).toHaveProperty('ore') - }) - - it('freeTextSearch: true -> column has match index in build output', () => { - const table = pgTable('match_test', { - col: encryptedType('col', { freeTextSearch: true }), - }) - - const built = extractProtectSchema(table).build() - expect(built.columns.col.indexes).toHaveProperty('match') - }) - - it('searchableJson: true -> column has ste_vec index in build output', () => { - const table = pgTable('ste_vec_test', { - col: encryptedType>('col', { - dataType: 'json', - searchableJson: true, - }), - }) - - const built = extractProtectSchema(table).build() - expect(built.columns.col.indexes).toHaveProperty('ste_vec') - // ste_vec prefix is automatically set to tableName/columnName - expect(built.columns.col.indexes.ste_vec?.prefix).toBe('ste_vec_test/col') - }) - - it('dataType: "json" -> column has cast_as "json"', () => { - const table = pgTable('json_cast_test', { - col: encryptedType>('col', { - dataType: 'json', - searchableJson: true, - }), - }) - - const built = extractProtectSchema(table).build() - expect(built.columns.col.cast_as).toBe('json') - }) - - it('dataType: "number" -> column has appropriate cast_as', () => { - const table = pgTable('number_cast_test', { - col: encryptedType('col', { - dataType: 'number', - orderAndRange: true, - }), - }) - - const built = extractProtectSchema(table).build() - expect(built.columns.col.cast_as).toBe('number') - }) - - it('default dataType is string', () => { - const table = pgTable('default_cast_test', { - col: encryptedType('col', { equality: true }), - }) - - const built = extractProtectSchema(table).build() - expect(built.columns.col.cast_as).toBe('string') - }) - - it('combined configs: equality + orderAndRange + freeTextSearch', () => { - const table = pgTable('combined_test', { - col: encryptedType('col', { - equality: true, - orderAndRange: true, - freeTextSearch: true, - }), - }) - - const built = extractProtectSchema(table).build() - const indexes = built.columns.col.indexes - - expect(indexes).toHaveProperty('unique') - expect(indexes).toHaveProperty('ore') - expect(indexes).toHaveProperty('match') - }) - - it('combined configs: equality + searchableJson', () => { - const table = pgTable('combined_json_test', { - col: encryptedType>('col', { - dataType: 'json', - equality: true, - searchableJson: true, - }), - }) - - const built = extractProtectSchema(table).build() - const indexes = built.columns.col.indexes - - expect(indexes).toHaveProperty('unique') - expect(indexes).toHaveProperty('ste_vec') - }) -}) - -// ============================================================================ -// 3c. Edge cases -// ============================================================================ - -describe('extractProtectSchema edge cases', () => { - it('throws when table has zero encrypted columns', () => { - const table = pgTable('no_encrypted', { - title: text('title'), - count: integer('count'), - }) - - expect(() => extractProtectSchema(table)).toThrow( - /No encrypted columns found/, - ) - }) - - it('throws when searchableJson is used without dataType: json', () => { - const table = pgTable('bad_searchable_json', { - col: encryptedType('col', { - searchableJson: true, - }), - }) - - expect(() => extractProtectSchema(table)).toThrow( - /searchableJson.*requires dataType: 'json'/, - ) - }) - - it('throws when searchableJson is used with dataType: number', () => { - const table = pgTable('bad_searchable_json_number', { - col: encryptedType('col', { - dataType: 'number', - searchableJson: true, - }), - }) - - expect(() => extractProtectSchema(table)).toThrow( - /searchableJson.*requires dataType: 'json'/, - ) - }) - - it('mixed encrypted and regular columns -> only encrypted columns extracted', () => { - const table = pgTable('mixed_cols', { - id: integer('id').primaryKey(), - email: encryptedType('email', { equality: true }), - name: text('name'), - age: encryptedType('age', { - dataType: 'number', - orderAndRange: true, - }), - description: text('description'), - }) - - const built = extractProtectSchema(table).build() - - // Only encrypted columns should be in the output - expect(Object.keys(built.columns)).toHaveLength(2) - expect(built.columns).toHaveProperty('email') - expect(built.columns).toHaveProperty('age') - expect(built.columns).not.toHaveProperty('id') - expect(built.columns).not.toHaveProperty('name') - expect(built.columns).not.toHaveProperty('description') - }) - - it('uses the SQL column name (not property key) for the column', () => { - // When the property key in pgTable differs from the column name - // The encryptedType name parameter is the actual SQL column name - const table = pgTable('name_test', { - userEmail: encryptedType('user_email', { equality: true }), - }) - - const built = extractProtectSchema(table).build() - - // The column name in the build output should be the SQL column name - expect(built.columns).toHaveProperty('user_email') - }) - - it('table name matches the pgTable name', () => { - const table = pgTable('my_custom_table', { - col: encryptedType('col', { equality: true }), - }) - - const built = extractProtectSchema(table).build() - expect(built.tableName).toBe('my_custom_table') - }) - - it('equality with custom token filters is passed through', () => { - const table = pgTable('token_filter_test', { - col: encryptedType('col', { - equality: [{ kind: 'downcase' }], - }), - }) - - const built = extractProtectSchema(table).build() - expect(built.columns.col.indexes).toHaveProperty('unique') - expect(built.columns.col.indexes.unique?.token_filters).toEqual([ - { kind: 'downcase' }, - ]) - }) - - it('freeTextSearch with custom MatchIndexOpts is passed through', () => { - const table = pgTable('match_opts_test', { - col: encryptedType('col', { - freeTextSearch: { - tokenizer: { kind: 'ngram', token_length: 3 }, - k: 6, - m: 2048, - include_original: true, - token_filters: [{ kind: 'downcase' }], - }, - }), - }) - - const built = extractProtectSchema(table).build() - expect(built.columns.col.indexes).toHaveProperty('match') - expect(built.columns.col.indexes.match?.tokenizer).toEqual({ - kind: 'ngram', - token_length: 3, - }) - }) -}) diff --git a/packages/drizzle/__tests__/test-utils.ts b/packages/drizzle/__tests__/test-utils.ts deleted file mode 100644 index 8c4c0af3e..000000000 --- a/packages/drizzle/__tests__/test-utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createProtectOperators } from '@cipherstash/drizzle/pg' -import type { ProtectClient } from '@cipherstash/protect/client' -import { PgDialect } from 'drizzle-orm/pg-core' -import { vi } from 'vitest' - -export const ENCRYPTED_VALUE = '{"v":"encrypted-value"}' - -export function createMockProtectClient() { - const encryptQuery = vi.fn(async (termsOrValue: unknown) => { - if (Array.isArray(termsOrValue)) { - return { data: termsOrValue.map(() => ENCRYPTED_VALUE) } - } - return { data: ENCRYPTED_VALUE } - }) - - return { - client: { encryptQuery } as unknown as ProtectClient, - encryptQuery, - } -} - -export function setup() { - const { client, encryptQuery } = createMockProtectClient() - const protectOps = createProtectOperators(client) - const dialect = new PgDialect() - return { client, encryptQuery, protectOps, dialect } -} diff --git a/packages/drizzle/__tests__/utils/code-executor.test.ts b/packages/drizzle/__tests__/utils/code-executor.test.ts deleted file mode 100644 index 8565a2531..000000000 --- a/packages/drizzle/__tests__/utils/code-executor.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { type ExecutionContext, executeCodeBlock } from './code-executor' - -describe('executeCodeBlock', () => { - // ExecutionContext is now a simple index signature: { [key: string]: unknown } - // Tests can define any properties needed for their specific test cases - const mockContext: ExecutionContext = { - db: {}, - transactions: {}, - } - - it('executes simple code and returns result', async () => { - const code = 'return 1 + 1' - const result = await executeCodeBlock(code, mockContext) - - expect(result.success).toBe(true) - expect(result.result).toBe(2) - }) - - it('provides context variables to code', async () => { - const contextWithValue: ExecutionContext = { ...mockContext, testValue: 42 } - const code = 'return testValue' - const result = await executeCodeBlock(code, contextWithValue) - - expect(result.success).toBe(true) - expect(result.result).toBe(42) - }) - - it('handles async code', async () => { - const code = 'return await Promise.resolve("async result")' - const result = await executeCodeBlock(code, mockContext) - - expect(result.success).toBe(true) - expect(result.result).toBe('async result') - }) - - it('captures errors', async () => { - const code = 'throw new Error("test error")' - const result = await executeCodeBlock(code, mockContext) - - expect(result.success).toBe(false) - expect(result.error).toContain('test error') - }) - - it('handles syntax errors', async () => { - const code = 'return {' // Invalid syntax - const result = await executeCodeBlock(code, mockContext) - - expect(result.success).toBe(false) - expect(result.error).toBeDefined() - }) -}) diff --git a/packages/drizzle/__tests__/utils/code-executor.ts b/packages/drizzle/__tests__/utils/code-executor.ts deleted file mode 100644 index b94edefad..000000000 --- a/packages/drizzle/__tests__/utils/code-executor.ts +++ /dev/null @@ -1,65 +0,0 @@ -export interface ExecutionContext { - [key: string]: unknown -} - -export interface ExecutionResult { - success: boolean - result?: unknown - error?: string -} - -/** - * Execute a documentation code block in a controlled context. - * - * ## Security Considerations - * - * This function uses the `Function()` constructor to execute arbitrary code. - * This is equivalent to `eval()` and would normally be a serious security risk. - * - * **Why it's safe in this context:** - * 1. **Trusted source:** Code comes only from our own documentation files in the - * repository, not from user input or external sources. - * 2. **Code review:** All documentation code examples go through PR review before - * being merged, same as production code. - * 3. **No network exposure:** Tests run in CI or local dev, never in production - * environments handling user requests. - * 4. **Controlled context:** Executed code only has access to explicitly provided - * context variables (db, operators), not global scope or filesystem. - * - * **When this would NOT be safe:** - * - If code came from user input (web forms, API requests) - * - If code came from external/untrusted sources - * - If executed in a production environment - * - If the execution context included sensitive globals - * - * The eslint-disable comment below acknowledges we've considered the security - * implications and determined this usage is appropriate for the use case. - */ -export async function executeCodeBlock( - code: string, - context: ExecutionContext, -): Promise { - try { - // Create an async function with access to context variables - const contextKeys = Object.keys(context) - const contextValues = Object.values(context) - - // eslint-disable-next-line @typescript-eslint/no-implied-eval - const asyncFn = new Function( - ...contextKeys, - `return (async () => { ${code} })()`, - ) - - const result = await asyncFn(...contextValues) - - return { - success: true, - result, - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - } - } -} diff --git a/packages/drizzle/__tests__/utils/markdown-parser.test.ts b/packages/drizzle/__tests__/utils/markdown-parser.test.ts deleted file mode 100644 index 8cebc3c79..000000000 --- a/packages/drizzle/__tests__/utils/markdown-parser.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import * as fc from 'fast-check' -import { describe, expect, it } from 'vitest' -import { extractExecutableBlocks } from './markdown-parser' - -describe('extractExecutableBlocks', () => { - it('extracts ts:run code blocks', () => { - const markdown = `# Test -## Section One - -\`\`\`ts:run -const x = 1 -return x -\`\`\` -` - const blocks = extractExecutableBlocks(markdown) - - expect(blocks).toHaveLength(1) - expect(blocks[0].code).toBe('const x = 1\nreturn x') - expect(blocks[0].section).toBe('Section One') - }) - - it('ignores non-executable code blocks', () => { - const markdown = `# Test - -\`\`\`typescript -const ignored = true -\`\`\` - -\`\`\`ts:run -return 'executed' -\`\`\` -` - const blocks = extractExecutableBlocks(markdown) - - expect(blocks).toHaveLength(1) - expect(blocks[0].code).toBe("return 'executed'") - }) - - it('tracks line numbers', () => { - const markdown = `# Header - -Some text - -\`\`\`ts:run -return 1 -\`\`\` -` - const blocks = extractExecutableBlocks(markdown) - - expect(blocks[0].lineNumber).toBe(6) - }) - - it('extracts multiple blocks with unique IDs', () => { - const markdown = `# Test -## First - -\`\`\`ts:run -return 1 -\`\`\` - -## Second - -\`\`\`ts:run -return 2 -\`\`\` -` - const blocks = extractExecutableBlocks(markdown) - - expect(blocks).toHaveLength(2) - expect(blocks[0].id).not.toBe(blocks[1].id) - expect(blocks[0].section).toBe('First') - expect(blocks[1].section).toBe('Second') - }) - - // Edge cases for robustness - describe('edge cases', () => { - it('handles consecutive code blocks without headers', () => { - const markdown = `## Section - -\`\`\`ts:run -return 1 -\`\`\` - -\`\`\`ts:run -return 2 -\`\`\` -` - const blocks = extractExecutableBlocks(markdown) - - expect(blocks).toHaveLength(2) - expect(blocks[0].section).toBe('Section') - expect(blocks[1].section).toBe('Section') // Same section for both - }) - - it('handles empty code blocks', () => { - const markdown = `## Section - -\`\`\`ts:run -\`\`\` -` - const blocks = extractExecutableBlocks(markdown) - - expect(blocks).toHaveLength(1) - expect(blocks[0].code).toBe('') - }) - - it('handles code block at start of file', () => { - const markdown = `\`\`\`ts:run -return 'first' -\`\`\` -` - const blocks = extractExecutableBlocks(markdown) - - expect(blocks).toHaveLength(1) - expect(blocks[0].section).toBe('Introduction') // Default section - }) - - it('handles code block at end of file without trailing newline', () => { - const markdown = `## Section - -\`\`\`ts:run -return 'last' -\`\`\`` - const blocks = extractExecutableBlocks(markdown) - - // Note: This edge case may not be handled - document expected behavior - // Implementation may need adjustment if this fails - expect(blocks).toHaveLength(1) - expect(blocks[0].code).toBe("return 'last'") - }) - - it('ignores malformed blocks (no closing fence)', () => { - const markdown = `## Section - -\`\`\`ts:run -return 'unclosed' - -## Next Section - -Some text -` - const blocks = extractExecutableBlocks(markdown) - - // Malformed block should be ignored, not cause errors - expect(blocks).toHaveLength(0) - }) - - it('handles duplicate section names', () => { - const markdown = `## Setup - -\`\`\`ts:run -return 1 -\`\`\` - -## Setup - -\`\`\`ts:run -return 2 -\`\`\` -` - const blocks = extractExecutableBlocks(markdown) - - expect(blocks).toHaveLength(2) - expect(blocks[0].section).toBe('Setup') - expect(blocks[1].section).toBe('Setup') - expect(blocks[0].id).not.toBe(blocks[1].id) // IDs still unique - }) - - it('handles deeply nested headers', () => { - const markdown = `# H1 -## H2 -### H3 - -\`\`\`ts:run -return 'nested' -\`\`\` -` - const blocks = extractExecutableBlocks(markdown) - - expect(blocks).toHaveLength(1) - expect(blocks[0].section).toBe('H3') // Most recent header - }) - - it('handles code blocks with extra whitespace in fence', () => { - const markdown = `## Section - -\`\`\`ts:run -return 'whitespace' -\`\`\` -` - const blocks = extractExecutableBlocks(markdown) - - // Trailing whitespace in fence should still match - expect(blocks).toHaveLength(1) - }) - }) - - // Property-based tests using fast-check - describe('property-based tests', () => { - // These tests verify invariants that should hold for ANY valid input - - it('parsing is deterministic - same input always produces same output', () => { - fc.assert( - fc.property(fc.string(), (randomContent) => { - const markdown = `## Section\n\n\`\`\`ts:run\n${randomContent}\n\`\`\`\n` - const result1 = extractExecutableBlocks(markdown) - const result2 = extractExecutableBlocks(markdown) - - expect(result1).toEqual(result2) - }), - { numRuns: 100 }, - ) - }) - - it('extracted blocks always have valid structure', () => { - fc.assert( - fc.property( - fc.array( - fc.record({ - section: fc.string({ minLength: 1 }), - code: fc.string(), - }), - { minLength: 0, maxLength: 5 }, - ), - (blocks) => { - // Generate markdown from blocks - const markdown = blocks - .map( - (b) => `## ${b.section}\n\n\`\`\`ts:run\n${b.code}\n\`\`\`\n`, - ) - .join('\n') - - const result = extractExecutableBlocks(markdown) - - // Every extracted block must have required properties - for (const block of result) { - expect(block).toHaveProperty('id') - expect(block).toHaveProperty('code') - expect(block).toHaveProperty('section') - expect(block).toHaveProperty('lineNumber') - expect(typeof block.id).toBe('string') - expect(typeof block.code).toBe('string') - expect(typeof block.section).toBe('string') - expect(typeof block.lineNumber).toBe('number') - expect(block.lineNumber).toBeGreaterThan(0) - } - }, - ), - { numRuns: 50 }, - ) - }) - - it('block count never exceeds fence pair count', () => { - fc.assert( - fc.property(fc.string(), (randomMarkdown) => { - const fenceCount = (randomMarkdown.match(/```ts:run/g) || []).length - const result = extractExecutableBlocks(randomMarkdown) - - // Can't extract more blocks than opening fences - expect(result.length).toBeLessThanOrEqual(fenceCount) - }), - { numRuns: 100 }, - ) - }) - - it('handles unicode in section names', () => { - fc.assert( - fc.property( - fc.string({ minLength: 1, maxLength: 50 }), - (unicodeSection) => { - // Filter out characters that would break markdown structure - const safeSection = unicodeSection.replace(/[#\n\r`]/g, '').trim() - if (!safeSection) return true // Skip empty after filtering - - const markdown = `## ${safeSection}\n\n\`\`\`ts:run\nreturn 1\n\`\`\`\n` - const result = extractExecutableBlocks(markdown) - - expect(result).toHaveLength(1) - expect(result[0].section).toBe(safeSection) - }, - ), - { numRuns: 50 }, - ) - }) - - it('no blocks lost during parsing - all valid blocks extracted', () => { - fc.assert( - fc.property(fc.integer({ min: 1, max: 10 }), (blockCount) => { - // Generate exactly N well-formed blocks - const markdown = Array.from( - { length: blockCount }, - (_, i) => `## Section${i}\n\n\`\`\`ts:run\nreturn ${i}\n\`\`\`\n`, - ).join('\n') - - const result = extractExecutableBlocks(markdown) - - // All well-formed blocks should be extracted - expect(result).toHaveLength(blockCount) - }), - { numRuns: 20 }, - ) - }) - }) -}) diff --git a/packages/drizzle/__tests__/utils/markdown-parser.ts b/packages/drizzle/__tests__/utils/markdown-parser.ts deleted file mode 100644 index 21c0292e8..000000000 --- a/packages/drizzle/__tests__/utils/markdown-parser.ts +++ /dev/null @@ -1,58 +0,0 @@ -export interface CodeBlock { - id: string - code: string - section: string - lineNumber: number -} - -/** - * Extract executable code blocks from markdown. - * Looks for ```ts:run fenced code blocks. - */ -export function extractExecutableBlocks(markdown: string): CodeBlock[] { - const blocks: CodeBlock[] = [] - const lines = markdown.split('\n') - - let currentSection = 'Introduction' - let inCodeBlock = false - let currentCode: string[] = [] - let blockStartLine = 0 - let blockId = 0 - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - - // Track section headers (## or ### level) - const headerMatch = line.match(/^#{1,3}\s+(.+)$/) - if (headerMatch) { - currentSection = headerMatch[1].trim() - } - - // Start of executable code block - if (line.match(/^```ts:run\s*$/)) { - inCodeBlock = true - currentCode = [] - blockStartLine = i + 2 // 1-indexed, next line is code start - continue - } - - // End of code block - if (inCodeBlock && line === '```') { - blocks.push({ - id: `block-${blockId++}`, - code: currentCode.join('\n'), - section: currentSection, - lineNumber: blockStartLine, - }) - inCodeBlock = false - continue - } - - // Accumulate code - if (inCodeBlock) { - currentCode.push(line) - } - } - - return blocks -} diff --git a/packages/drizzle/package.json b/packages/drizzle/package.json deleted file mode 100644 index 7a9122544..000000000 --- a/packages/drizzle/package.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "name": "@cipherstash/drizzle", - "version": "3.0.4-rc.1", - "description": "CipherStash Protect.js Drizzle ORM integration for TypeScript", - "keywords": [ - "encrypted", - "drizzle", - "orm", - "type-safe", - "security", - "protectjs", - "postgres" - ], - "bugs": { - "url": "https://github.com/cipherstash/stack/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/cipherstash/stack.git", - "directory": "packages/drizzle" - }, - "license": "MIT", - "author": "CipherStash ", - "type": "module", - "bin": { - "generate-eql-migration": "./dist/bin/generate-eql-migration.js" - }, - "exports": { - "./pg": { - "types": "./dist/pg/index.d.ts", - "import": "./dist/pg/index.js", - "require": "./dist/pg/index.cjs" - } - }, - "files": [ - "dist", - "README.md" - ], - "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "postbuild": "chmod +x ./dist/bin/generate-eql-migration.js", - "test": "vitest run", - "release": "tsup" - }, - "peerDependencies": { - "@cipherstash/protect": ">=12.0.2-rc.0", - "@cipherstash/schema": ">=3.0.2-rc.0", - "@types/pg": "*", - "drizzle-kit": ">=0.20", - "drizzle-orm": ">=0.33", - "pg": ">=8", - "postgres": ">=3" - }, - "peerDependenciesMeta": { - "@types/pg": { - "optional": true - }, - "drizzle-kit": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - } - }, - "devDependencies": { - "@cipherstash/protect": "workspace:*", - "@cipherstash/schema": "workspace:*", - "dotenv": "^17.4.2", - "fast-check": "^4.8.0", - "tsup": "catalog:repo", - "typescript": "catalog:repo", - "vitest": "catalog:repo" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/packages/drizzle/src/bin/generate-eql-migration.ts b/packages/drizzle/src/bin/generate-eql-migration.ts deleted file mode 100644 index e4105073d..000000000 --- a/packages/drizzle/src/bin/generate-eql-migration.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { execSync } from 'node:child_process' -import { existsSync, unlinkSync, writeFileSync } from 'node:fs' -import { readdir } from 'node:fs/promises' -import { join, resolve } from 'node:path' -import { detectRunner } from './runner.js' - -// EQL release, pinned to match the EQL payload format this package emits. -// Bump in lockstep with @cipherstash/protect-ffi. -const EQL_VERSION = 'eql-2.3.1' -const EQL_INSTALL_URL = `https://github.com/cipherstash/encrypt-query-language/releases/download/${EQL_VERSION}/cipherstash-encrypt.sql` - -type CliArgs = { - migrationName: string - drizzleDir: string - showHelp: boolean -} - -function parseArgs(argv: string[]): CliArgs { - let migrationName = 'install-eql' - let drizzleDir = 'drizzle' - let showHelp = false - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--help' || arg === '-h') { - showHelp = true - } else if (arg === '--name' || arg === '-n') { - migrationName = argv[++i] ?? migrationName - } else if (arg === '--out' || arg === '-o') { - drizzleDir = argv[++i] ?? drizzleDir - } - } - - return { migrationName, drizzleDir, showHelp } -} - -function printHelp(runner: string): void { - console.log(` -Usage: generate-eql-migration [options] - -Generate a Drizzle migration that installs CipherStash EQL - -Options: - -n, --name Migration name (default: "install-eql") - -o, --out Output directory (default: "drizzle") - -h, --help Display this help message - -Examples: - ${runner} generate-eql-migration - ${runner} generate-eql-migration --name setup-eql - ${runner} generate-eql-migration --out migrations - - # Or with your package manager: - pnpm generate-eql-migration - yarn generate-eql-migration - bun generate-eql-migration -`) -} - -async function main(): Promise { - let migrationPath: string | null = null - const args = parseArgs(process.argv.slice(2)) - const runner = detectRunner() - - if (args.showHelp) { - printHelp(runner) - process.exit(0) - } - - console.log('🔐 Generating EQL migration for Drizzle...\n') - - try { - console.log(`📝 Generating custom migration: ${args.migrationName}`) - execSync( - `${runner} drizzle-kit generate --custom --name=${args.migrationName}`, - { - stdio: 'inherit', - }, - ) - } catch (error) { - console.error('❌ Failed to generate custom migration') - console.error('Make sure drizzle-kit is installed in your project.') - process.exit(1) - } - - try { - console.log(`📥 Downloading latest EQL from GitHub...`) - const response = await fetch(EQL_INSTALL_URL) - if (!response.ok) { - throw new Error( - `Failed to download EQL: ${response.status} ${response.statusText}`, - ) - } - const eqlSql = await response.text() - - const drizzlePath = resolve(process.cwd(), args.drizzleDir) - - let files: string[] - try { - files = await readdir(drizzlePath) - } catch { - throw new Error( - `Drizzle directory not found: ${drizzlePath}\nMake sure to run this command from your project root.`, - ) - } - const migrationFile = files - .filter( - (file) => file.endsWith('.sql') && file.includes(args.migrationName), - ) - .sort() - .pop() - - if (!migrationFile) { - throw new Error( - `Could not find migration file for: ${args.migrationName}\nLooked in: ${drizzlePath}`, - ) - } - - migrationPath = join(drizzlePath, migrationFile) - console.log(`\n📄 Writing EQL SQL to: ${migrationFile}`) - - writeFileSync(migrationPath, eqlSql, 'utf-8') - - console.log('\n✅ Successfully created EQL migration!') - console.log('\nNext steps:') - console.log(` 1. Review the migration: ${migrationPath}`) - console.log(` 2. Run migrations: ${runner} drizzle-kit migrate`) - console.log( - ' (or use your package manager: pnpm/yarn/bun drizzle-kit migrate)', - ) - } catch (error) { - if (migrationPath && existsSync(migrationPath)) { - try { - unlinkSync(migrationPath) - console.error(`\n🗑️ Cleaned up migration file: ${migrationPath}`) - } catch (cleanupError) { - console.error(`\n⚠️ Failed to cleanup migration file: ${migrationPath}`) - } - } - throw error - } -} - -void main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error) - console.error('❌ Error:', message) - process.exit(1) -}) diff --git a/packages/drizzle/src/bin/runner.ts b/packages/drizzle/src/bin/runner.ts deleted file mode 100644 index c313f5c5e..000000000 --- a/packages/drizzle/src/bin/runner.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { existsSync } from 'node:fs' -import { resolve } from 'node:path' - -type Pm = 'npm' | 'pnpm' | 'yarn' | 'bun' - -function fromUserAgent(): Pm | undefined { - const ua = process.env.npm_config_user_agent ?? '' - if (ua.startsWith('bun/')) return 'bun' - if (ua.startsWith('pnpm/')) return 'pnpm' - if (ua.startsWith('yarn/')) return 'yarn' - return undefined -} - -function fromLockfile(cwd: string): Pm | undefined { - if ( - existsSync(resolve(cwd, 'bun.lockb')) || - existsSync(resolve(cwd, 'bun.lock')) - ) - return 'bun' - if (existsSync(resolve(cwd, 'pnpm-lock.yaml'))) return 'pnpm' - if (existsSync(resolve(cwd, 'yarn.lock'))) return 'yarn' - if (existsSync(resolve(cwd, 'package-lock.json'))) return 'npm' - return undefined -} - -export function detectRunner(): string { - const pm = fromUserAgent() ?? fromLockfile(process.cwd()) ?? 'npm' - return pm === 'bun' - ? 'bunx' - : pm === 'pnpm' - ? 'pnpm dlx' - : pm === 'yarn' - ? 'yarn dlx' - : 'npx' -} diff --git a/packages/drizzle/src/pg/index.ts b/packages/drizzle/src/pg/index.ts deleted file mode 100644 index 2e34606f1..000000000 --- a/packages/drizzle/src/pg/index.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { CastAs, MatchIndexOpts, TokenFilter } from '@cipherstash/schema' -import { customType } from 'drizzle-orm/pg-core' - -export type { CastAs, MatchIndexOpts, TokenFilter } - -/** - * Configuration for encrypted column indexes and data types - */ -export type EncryptedColumnConfig = { - /** - * Data type for the column (default: 'string') - */ - dataType?: CastAs - /** - * Enable free text search. Can be a boolean for default options, or an object for custom configuration. - */ - freeTextSearch?: boolean | MatchIndexOpts - /** - * Enable equality index. Can be a boolean for default options, or an array of token filters. - */ - equality?: boolean | TokenFilter[] - /** - * Enable order and range index for sorting and range queries. - */ - orderAndRange?: boolean - /** - * Enable searchable JSON index for JSONB path queries. - * Requires dataType: 'json'. - */ - searchableJson?: boolean -} - -/** - * Map to store configuration for encrypted columns - * Keyed by column name (the name passed to encryptedType) - */ -const columnConfigMap = new Map< - string, - EncryptedColumnConfig & { name: string } ->() - -/** - * Creates an encrypted column type for Drizzle ORM with configurable searchable encryption options. - * - * When data is encrypted, the actual stored value is an [EQL v2](/docs/reference/eql) encrypted composite type which includes any searchable encryption indexes defined for the column. - * Importantly, the original data type is not known until it is decrypted. Therefore, this function allows specifying - * the original data type via the `dataType` option in the configuration. - * This ensures that when data is decrypted, it can be correctly interpreted as the intended TypeScript type. - * - * @typeParam TData - The TypeScript type of the data stored in the column - * @param name - The column name in the database - * @param config - Optional configuration for data type and searchable encryption indexes - * @returns A Drizzle column type that can be used in pgTable definitions - * - * ## Searchable Encryption Options - * - * - `dataType`: Specifies the original data type of the column (e.g., 'string', 'number', 'json'). Default is 'string'. - * - `freeTextSearch`: Enables free text search index. Can be a boolean for default options, or an object for custom configuration. - * - `equality`: Enables equality index. Can be a boolean for default options, or an array of token filters. - * - `orderAndRange`: Enables order and range index for sorting and range queries. - * - `searchableJson`: Enables searchable JSON index for JSONB path queries on encrypted JSON columns. - * - * See {@link EncryptedColumnConfig}. - * - * @example - * Defining a drizzle table schema for postgres table with encrypted columns. - * - * ```typescript - * import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' - * import { encryptedType } from '@cipherstash/drizzle/pg' - * - * const users = pgTable('users', { - * email: encryptedType('email', { - * freeTextSearch: true, - * equality: true, - * orderAndRange: true, - * }), - * age: encryptedType('age', { - * dataType: 'number', - * equality: true, - * orderAndRange: true, - * }), - * profile: encryptedType('profile', { - * dataType: 'json', - * }), - * }) - * ``` - */ -export const encryptedType = ( - name: string, - config?: EncryptedColumnConfig, -) => { - // Create the Drizzle custom type - const customColumnType = customType<{ data: TData; driverData: string }>({ - dataType() { - return 'eql_v2_encrypted' - }, - toDriver(value: TData): string { - const jsonStr = JSON.stringify(value) - const escaped = jsonStr.replace(/"/g, '""') - return `("${escaped}")` - }, - fromDriver(value: string): TData { - const parseComposite = (str: string) => { - if (!str || str === '') return null - - const trimmed = str.trim() - - if (trimmed.startsWith('(') && trimmed.endsWith(')')) { - let inner = trimmed.slice(1, -1) - inner = inner.replace(/""/g, '"') - - if (inner.startsWith('"') && inner.endsWith('"')) { - const stripped = inner.slice(1, -1) - return JSON.parse(stripped) - } - - if (inner.startsWith('{') || inner.startsWith('[')) { - return JSON.parse(inner) - } - - return inner - } - - return JSON.parse(str) - } - - return parseComposite(value) as TData - }, - }) - - // Create the column instance - const column = customColumnType(name) - - // Store configuration keyed by column name - // This allows us to look it up during schema extraction - const fullConfig: EncryptedColumnConfig & { name: string } = { - name, - ...config, - } - - // Store in Map keyed by column name (will be looked up during extraction) - columnConfigMap.set(name, fullConfig) - - // Also store on property for immediate access (before pgTable processes it) - // We need to use any here because Drizzle columns don't have a type for custom properties - // biome-ignore lint/suspicious/noExplicitAny: Drizzle columns don't expose custom property types - ;(column as any)._protectConfig = fullConfig - - return column -} - -/** - * Get configuration for an encrypted column by checking if it's an encrypted type - * and looking up the config by column name - * @internal - */ -export function getEncryptedColumnConfig( - columnName: string, - column: unknown, -): (EncryptedColumnConfig & { name: string }) | undefined { - // Check if this is an encrypted column - if (column && typeof column === 'object') { - // We need to use any here to access Drizzle column properties - // biome-ignore lint/suspicious/noExplicitAny: Drizzle column types don't expose all properties - const columnAny = column as any - - // Check if it's an encrypted column by checking sqlName or dataType - // After pgTable processes it, sqlName will be 'eql_v2_encrypted' - const isEncrypted = - columnAny.sqlName === 'eql_v2_encrypted' || - columnAny.dataType === 'eql_v2_encrypted' || - (columnAny.dataType && - typeof columnAny.dataType === 'function' && - columnAny.dataType() === 'eql_v2_encrypted') - - if (isEncrypted) { - // Try to get config from property (if still there) - if (columnAny._protectConfig) { - return columnAny._protectConfig - } - - // Look up config by column name (the name passed to encryptedType) - // The column.name should match what was passed to encryptedType - const lookupName = columnAny.name || columnName - return columnConfigMap.get(lookupName) - } - } - return undefined -} - -// Re-export operators -export { - createProtectOperators, - ProtectConfigError, - ProtectOperatorError, -} from './operators.js' -// Re-export schema extraction utility -export { extractProtectSchema } from './schema-extraction.js' diff --git a/packages/drizzle/src/pg/operators.ts b/packages/drizzle/src/pg/operators.ts deleted file mode 100644 index 358bb3318..000000000 --- a/packages/drizzle/src/pg/operators.ts +++ /dev/null @@ -1,2038 +0,0 @@ -import type { QueryTypeName } from '@cipherstash/protect' -import type { - ProtectClient, - ProtectColumn, - ProtectTable, - ProtectTableColumn, -} from '@cipherstash/protect/client' -import { - and, - arrayContained, - arrayContains, - arrayOverlaps, - asc, - between, - bindIfParam, - desc, - eq, - exists, - gt, - gte, - ilike, - inArray, - isNotNull, - isNull, - like, - lt, - lte, - ne, - not, - notBetween, - notExists, - notIlike, - notInArray, - or, - type SQL, - type SQLWrapper, - sql, -} from 'drizzle-orm' -import type { PgTable } from 'drizzle-orm/pg-core' -import type { EncryptedColumnConfig } from './index.js' -import { getEncryptedColumnConfig } from './index.js' -import { extractProtectSchema } from './schema-extraction.js' - -// ============================================================================ -// Type Definitions and Type Guards -// ============================================================================ - -/** - * Branded type for Drizzle table with encrypted columns - */ -// biome-ignore lint/suspicious/noExplicitAny: Drizzle table types don't expose Symbol properties -type EncryptedDrizzleTable = PgTable & { - readonly __isEncryptedTable?: true -} - -/** - * Type guard to check if a value is a Drizzle SQLWrapper - */ -function isSQLWrapper(value: unknown): value is SQLWrapper { - return ( - typeof value === 'object' && - value !== null && - 'sql' in value && - typeof (value as { sql: unknown }).sql !== 'undefined' - ) -} - -/** - * Type guard to check if a value is a Drizzle table - */ -function isPgTable(value: unknown): value is EncryptedDrizzleTable { - return ( - typeof value === 'object' && - value !== null && - Symbol.for('drizzle:Name') in value - ) -} - -/** - * Custom error types for better debugging - */ -export class ProtectOperatorError extends Error { - constructor( - message: string, - public readonly context?: { - tableName?: string - columnName?: string - operator?: string - }, - ) { - super(message) - this.name = 'ProtectOperatorError' - } -} - -export class ProtectConfigError extends ProtectOperatorError { - constructor(message: string, context?: ProtectOperatorError['context']) { - super(message, context) - this.name = 'ProtectConfigError' - } -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * Helper to extract table name from a Drizzle table - */ -function getDrizzleTableName(drizzleTable: unknown): string | undefined { - if (!isPgTable(drizzleTable)) { - return undefined - } - // Access Symbol property using Record type to avoid indexing errors - const tableWithSymbol = drizzleTable as unknown as Record< - symbol, - string | undefined - > - return tableWithSymbol[Symbol.for('drizzle:Name')] -} - -/** - * Helper to get the drizzle table from a drizzle column - */ -function getDrizzleTableFromColumn(drizzleColumn: SQLWrapper): unknown { - const column = drizzleColumn as unknown as Record - return column.table as unknown -} - -/** - * Helper to extract protect table from a drizzle column by deriving it from the column's parent table - */ -function getProtectTableFromColumn( - drizzleColumn: SQLWrapper, - protectTableCache: Map>, -): ProtectTable | undefined { - const drizzleTable = getDrizzleTableFromColumn(drizzleColumn) - if (!drizzleTable) { - return undefined - } - - const tableName = getDrizzleTableName(drizzleTable) - if (!tableName) { - return undefined - } - - // Check cache first - let protectTable = protectTableCache.get(tableName) - if (protectTable) { - return protectTable - } - - // Extract protect schema from drizzle table and cache it - try { - // biome-ignore lint/suspicious/noExplicitAny: PgTable type doesn't expose all needed properties - protectTable = extractProtectSchema(drizzleTable as PgTable) - protectTableCache.set(tableName, protectTable) - return protectTable - } catch { - // Table doesn't have encrypted columns or extraction failed - return undefined - } -} - -/** - * Helper to get the ProtectColumn for a Drizzle column from the ProtectTable - */ -function getProtectColumn( - drizzleColumn: SQLWrapper, - protectTable: ProtectTable, -): ProtectColumn | undefined { - const column = drizzleColumn as unknown as Record - const columnName = column.name as string | undefined - if (!columnName) { - return undefined - } - - const protectTableAny = protectTable as unknown as Record - return protectTableAny[columnName] as ProtectColumn | undefined -} - -/** - * Column metadata extracted from a Drizzle column - */ -interface ColumnInfo { - readonly protectColumn: ProtectColumn | undefined - readonly config: (EncryptedColumnConfig & { name: string }) | undefined - readonly protectTable: ProtectTable | undefined - readonly columnName: string - readonly tableName: string | undefined -} - -/** - * Helper to get the ProtectColumn and column config for a Drizzle column - * If protectTable is not provided, it will be derived from the column - */ -function getColumnInfo( - drizzleColumn: SQLWrapper, - protectTable: ProtectTable | undefined, - protectTableCache: Map>, -): ColumnInfo { - const column = drizzleColumn as unknown as Record - const columnName = (column.name as string | undefined) || 'unknown' - - // If protectTable not provided, try to derive it from the column - let resolvedProtectTable = protectTable - if (!resolvedProtectTable) { - resolvedProtectTable = getProtectTableFromColumn( - drizzleColumn, - protectTableCache, - ) - } - - const drizzleTable = getDrizzleTableFromColumn(drizzleColumn) - const tableName = getDrizzleTableName(drizzleTable) - - if (!resolvedProtectTable) { - // Column is not from an encrypted table - const config = getEncryptedColumnConfig(columnName, drizzleColumn) - return { - protectColumn: undefined, - config, - protectTable: undefined, - columnName, - tableName, - } - } - - const protectColumn = getProtectColumn(drizzleColumn, resolvedProtectTable) - const config = getEncryptedColumnConfig(columnName, drizzleColumn) - - return { - protectColumn, - config, - protectTable: resolvedProtectTable, - columnName, - tableName, - } -} - -/** - * Helper to convert a value to plaintext format - */ -function toPlaintext(value: unknown): string | number { - if (typeof value === 'boolean') { - return value ? 1 : 0 - } - if (typeof value === 'string' || typeof value === 'number') { - return value - } - if (value instanceof Date) { - return value.toISOString() - } - if (typeof value === 'bigint') { - // Reject rather than fall through to `String(value)` below: a stringified - // bigint would be silently encrypted as text and mismatch its column. The - // EQL v2 `@cipherstash/protect` query API types its plaintext as - // `JsPlaintext | null`, which cannot carry a native bigint, so bigint - // columns are unsupported on this integration — fail loudly instead of - // corrupting. (EQL v3 bigint columns are supported via `@cipherstash/stack`.) - throw new ProtectOperatorError( - 'bigint values are not supported for encrypted columns in the EQL v2 Drizzle integration; use @cipherstash/stack for EQL v3 bigint columns', - ) - } - return String(value) -} - -/** - * Value to encrypt with its associated column - */ -interface ValueToEncrypt { - readonly value: string | number - readonly column: SQLWrapper - readonly columnInfo: ColumnInfo - readonly queryType?: QueryTypeName - readonly originalIndex: number -} - -/** - * Helper to encrypt multiple values for use in a query - * Returns an array of encrypted search terms or original values if not encrypted - */ -async function encryptValues( - protectClient: ProtectClient, - values: Array<{ - value: unknown - column: SQLWrapper - queryType?: QueryTypeName - }>, - protectTable: ProtectTable | undefined, - protectTableCache: Map>, -): Promise { - if (values.length === 0) { - return [] - } - - // Single pass: collect values to encrypt with their metadata - const valuesToEncrypt: ValueToEncrypt[] = [] - const results: unknown[] = new Array(values.length) - - for (let i = 0; i < values.length; i++) { - const { value, column, queryType } = values[i] - const columnInfo = getColumnInfo(column, protectTable, protectTableCache) - - if ( - !columnInfo.protectColumn || - !columnInfo.config || - !columnInfo.protectTable - ) { - // Column is not encrypted, return value as-is - results[i] = value - continue - } - - const plaintextValue = toPlaintext(value) - valuesToEncrypt.push({ - value: plaintextValue, - column, - columnInfo, - queryType, - originalIndex: i, - }) - } - - if (valuesToEncrypt.length === 0) { - return results - } - - // Group values by column to batch encrypt with same column/table - const columnGroups = new Map< - string, - { - column: ProtectColumn - table: ProtectTable - columnName: string - values: Array<{ - value: string | number - index: number - queryType?: QueryTypeName - }> - resultIndices: number[] - } - >() - - let valueIndex = 0 - for (const { - value, - columnInfo, - queryType, - originalIndex, - } of valuesToEncrypt) { - // Safe access with validation - we know these exist from earlier checks - if ( - !columnInfo.config || - !columnInfo.protectColumn || - !columnInfo.protectTable - ) { - continue - } - - const columnName = columnInfo.config.name - const groupKey = `${columnInfo.tableName ?? 'unknown'}/${columnName}` - let group = columnGroups.get(groupKey) - if (!group) { - group = { - column: columnInfo.protectColumn, - table: columnInfo.protectTable, - columnName, - values: [], - resultIndices: [], - } - columnGroups.set(groupKey, group) - } - group.values.push({ value, index: valueIndex++, queryType }) - group.resultIndices.push(originalIndex) - } - - // Encrypt all values for each column in batches - for (const [, group] of columnGroups) { - const { columnName } = group - try { - const terms = group.values.map((v) => ({ - value: v.value, - column: group.column, - table: group.table, - queryType: v.queryType, - })) - - const encryptedTerms = await protectClient.encryptQuery(terms) - - if (encryptedTerms.failure) { - throw new ProtectOperatorError( - `Failed to encrypt query terms for column "${columnName}": ${encryptedTerms.failure.message}`, - { columnName }, - ) - } - - // Map results back to original indices - for (let i = 0; i < group.values.length; i++) { - const resultIndex = group.resultIndices[i] ?? -1 - if (resultIndex >= 0 && resultIndex < results.length) { - results[resultIndex] = encryptedTerms.data[i] - } - } - } catch (error) { - if (error instanceof ProtectOperatorError) { - throw error - } - const errorMessage = - error instanceof Error ? error.message : String(error) - throw new ProtectOperatorError( - `Unexpected error encrypting values for column "${columnName}": ${errorMessage}`, - { columnName }, - ) - } - } - - return results -} - -/** - * Helper to encrypt a single value for use in a query - * Returns the encrypted search term or the original value if not encrypted - */ -async function encryptValue( - protectClient: ProtectClient, - value: unknown, - drizzleColumn: SQLWrapper, - protectTable: ProtectTable | undefined, - protectTableCache: Map>, - queryType?: QueryTypeName, -): Promise { - const results = await encryptValues( - protectClient, - [{ value, column: drizzleColumn, queryType }], - protectTable, - protectTableCache, - ) - return results[0] -} - -// ============================================================================ -// Lazy Operator Pattern -// ============================================================================ - -/** - * Simplified lazy operator that defers encryption until awaited or batched - */ -interface LazyOperator { - readonly __isLazyOperator: true - readonly operator: string - readonly queryType?: QueryTypeName - readonly left: SQLWrapper - readonly right: unknown - readonly min?: unknown - readonly max?: unknown - readonly needsEncryption: boolean - readonly columnInfo: ColumnInfo - execute( - encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ): SQL -} - -/** - * Type guard for lazy operators - */ -function isLazyOperator(value: unknown): value is LazyOperator { - return ( - typeof value === 'object' && - value !== null && - '__isLazyOperator' in value && - (value as LazyOperator).__isLazyOperator === true - ) -} - -/** - * Creates a lazy operator that defers execution - */ -function createLazyOperator( - operator: string, - left: SQLWrapper, - right: unknown, - execute: ( - encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ) => SQL, - needsEncryption: boolean, - columnInfo: ColumnInfo, - protectClient: ProtectClient, - defaultProtectTable: ProtectTable | undefined, - protectTableCache: Map>, - min?: unknown, - max?: unknown, - queryType?: QueryTypeName, -): LazyOperator & Promise { - let resolvedSQL: SQL | undefined - let encryptionPromise: Promise | undefined - - const lazyOp: LazyOperator = { - __isLazyOperator: true, - operator, - queryType, - left, - right, - min, - max, - needsEncryption, - columnInfo, - execute, - } - - // Create a promise that will be resolved when encryption completes - const promise = new Promise((resolve, reject) => { - // Auto-execute when awaited directly - queueMicrotask(async () => { - if (resolvedSQL !== undefined) { - resolve(resolvedSQL) - return - } - - try { - if (!encryptionPromise) { - encryptionPromise = executeLazyOperatorDirect( - lazyOp, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - const sql = await encryptionPromise - resolvedSQL = sql - resolve(sql) - } catch (error) { - reject(error) - } - }) - }) - - // Attach lazy operator properties to the promise - return Object.assign(promise, lazyOp) -} - -/** - * Executes a lazy operator with pre-encrypted values (used in batched mode) - */ -async function executeLazyOperator( - lazyOp: LazyOperator, - encryptedValues?: { value: unknown; encrypted: unknown }[], -): Promise { - if (!lazyOp.needsEncryption) { - return lazyOp.execute(lazyOp.right) - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - // Between operator - use provided encrypted values - let encryptedMin: unknown - let encryptedMax: unknown - - if (encryptedValues && encryptedValues.length >= 2) { - encryptedMin = encryptedValues[0]?.encrypted - encryptedMax = encryptedValues[1]?.encrypted - } else { - throw new ProtectOperatorError( - 'Between operator requires both min and max encrypted values', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) - } - - if (encryptedMin === undefined || encryptedMax === undefined) { - throw new ProtectOperatorError( - 'Between operator requires both min and max values to be encrypted', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) - } - - return lazyOp.execute(undefined, encryptedMin, encryptedMax) - } - - // Single value operator - let encrypted: unknown - - if (encryptedValues && encryptedValues.length > 0) { - encrypted = encryptedValues[0]?.encrypted - } else { - throw new ProtectOperatorError( - 'Operator requires encrypted value but none provided', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) - } - - if (encrypted === undefined) { - throw new ProtectOperatorError( - 'Encryption failed or value was not encrypted', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) - } - - return lazyOp.execute(encrypted) -} - -/** - * Executes a lazy operator directly by encrypting values on demand - * Used when operator is awaited directly (not batched) - */ -async function executeLazyOperatorDirect( - lazyOp: LazyOperator, - protectClient: ProtectClient, - defaultProtectTable: ProtectTable | undefined, - protectTableCache: Map>, -): Promise { - if (!lazyOp.needsEncryption) { - return lazyOp.execute(lazyOp.right) - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - // Between operator - encrypt min and max - const [encryptedMin, encryptedMax] = await encryptValues( - protectClient, - [ - { value: lazyOp.min, column: lazyOp.left, queryType: lazyOp.queryType }, - { value: lazyOp.max, column: lazyOp.left, queryType: lazyOp.queryType }, - ], - defaultProtectTable, - protectTableCache, - ) - return lazyOp.execute(undefined, encryptedMin, encryptedMax) - } - - // Single value operator - const encrypted = await encryptValue( - protectClient, - lazyOp.right, - lazyOp.left, - defaultProtectTable, - protectTableCache, - lazyOp.queryType, - ) - - return lazyOp.execute(encrypted) -} - -// ============================================================================ -// Operator Factory Functions -// ============================================================================ - -/** - * Creates a comparison operator (eq, ne, gt, gte, lt, lte) - */ -function createComparisonOperator( - operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - protectClient: ProtectClient, - defaultProtectTable: ProtectTable | undefined, - protectTableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - // Operators requiring orderAndRange index - const requiresOrderAndRange = ['gt', 'gte', 'lt', 'lte'].includes(operator) - - if (requiresOrderAndRange) { - if (!config?.orderAndRange) { - // Return regular Drizzle operator for non-encrypted columns - switch (operator) { - case 'gt': - return gt(left, right) - case 'gte': - return gte(left, right) - case 'lt': - return lt(left, right) - case 'lte': - return lte(left, right) - } - } - - // This will be replaced with encrypted value in executeLazyOperator - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { - throw new ProtectOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - return sql`eql_v2.${sql.raw(operator)}(${left}, ${bindIfParam(encrypted, left)})` - } - - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - undefined, // min - undefined, // max - 'orderAndRange', - ) as Promise - } - - // Equality operators (eq, ne) - const requiresEquality = ['eq', 'ne'].includes(operator) - - if (requiresEquality && config?.equality) { - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { - throw new ProtectOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - return operator === 'eq' ? eq(left, encrypted) : ne(left, encrypted) - } - - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - undefined, // min - undefined, // max - 'equality', - ) as Promise - } - - // Fallback to regular Drizzle operators - return operator === 'eq' ? eq(left, right) : ne(left, right) -} - -/** - * Creates a range operator (between, notBetween) - */ -function createRangeOperator( - operator: 'between' | 'notBetween', - left: SQLWrapper, - min: unknown, - max: unknown, - columnInfo: ColumnInfo, - protectClient: ProtectClient, - defaultProtectTable: ProtectTable | undefined, - protectTableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - if (!config?.orderAndRange) { - return operator === 'between' - ? between(left, min, max) - : notBetween(left, min, max) - } - - const executeFn = ( - _encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ) => { - if (encryptedMin === undefined || encryptedMax === undefined) { - throw new ProtectOperatorError( - `${operator} operator requires both min and max values`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - - const rangeCondition = sql`eql_v2.gte(${left}, ${bindIfParam(encryptedMin, left)}) AND eql_v2.lte(${left}, ${bindIfParam(encryptedMax, left)})` - - return operator === 'between' - ? rangeCondition - : sql`NOT (${rangeCondition})` - } - - return createLazyOperator( - operator, - left, - undefined, - executeFn, - true, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - min, - max, - 'orderAndRange', - ) as Promise -} - -/** - * Creates a text search operator (like, ilike, notIlike) - */ -function createTextSearchOperator( - operator: 'like' | 'ilike' | 'notIlike', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - protectClient: ProtectClient, - defaultProtectTable: ProtectTable | undefined, - protectTableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - if (!config?.freeTextSearch) { - // Cast to satisfy TypeScript - const rightValue = right as string | SQLWrapper - switch (operator) { - case 'like': - return like(left as Parameters[0], rightValue) - case 'ilike': - return ilike(left as Parameters[0], rightValue) - case 'notIlike': - return notIlike(left as Parameters[0], rightValue) - } - } - - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { - throw new ProtectOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - - const sqlFn = sql`eql_v2.${sql.raw(operator === 'notIlike' ? 'ilike' : operator)}(${left}, ${bindIfParam(encrypted, left)})` - return operator === 'notIlike' ? sql`NOT (${sqlFn})` : sqlFn - } - - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - undefined, // min - undefined, // max - 'freeTextSearch', - ) as Promise -} - -/** - * Creates a JSONB operator that encrypts a JSON path selector and wraps it - * in the appropriate `eql_v2` function call. - * - * Supports `jsonbPathQueryFirst`, `jsonbGet`, and `jsonbPathExists`. - * The column must have `searchableJson` enabled in its {@link EncryptedColumnConfig}. - * - * @param operator - Which JSONB operation to perform. - * @param left - The encrypted column reference. - * @param right - The JSON path selector value to encrypt. - * @param columnInfo - Resolved column metadata including config and table name. - * @param protectClient - The Protect client used for encryption. - * @param defaultProtectTable - The default protect table for schema resolution. - * @param protectTableCache - Cache of resolved protect tables. - * @returns A promise resolving to the SQL condition with an encrypted, cast parameter. - * @throws {ProtectOperatorError} If `searchableJson` is not enabled on the column, or if encryption fails. - */ -function createJsonbOperator( - operator: 'jsonbPathQueryFirst' | 'jsonbGet' | 'jsonbPathExists', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - protectClient: ProtectClient, - defaultProtectTable: ProtectTable | undefined, - protectTableCache: Map>, -): Promise { - const { config } = columnInfo - const encryptedSelector = (value: unknown) => - sql`${bindIfParam(value, left)}::eql_v2_encrypted` - - if (!config?.searchableJson) { - throw new ProtectOperatorError( - `The ${operator} operator requires searchableJson to be enabled on the column configuration.`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { - throw new ProtectOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - switch (operator) { - case 'jsonbPathQueryFirst': - return sql`eql_v2.jsonb_path_query_first(${left}, ${encryptedSelector(encrypted)})` - case 'jsonbGet': - return sql`${left} -> ${encryptedSelector(encrypted)}` - case 'jsonbPathExists': - return sql`eql_v2.jsonb_path_exists(${left}, ${encryptedSelector(encrypted)})` - } - } - - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - undefined, - undefined, - 'steVecSelector', - ) as Promise -} - -// ============================================================================ -// Public API: createProtectOperators -// ============================================================================ - -/** - * Creates a set of Protect.js-aware operators that automatically encrypt values - * for encrypted columns before using them with Drizzle operators. - * - * For equality and text search operators (eq, ne, like, ilike, inArray, etc.): - * Values are encrypted and then passed to regular Drizzle operators, which use - * PostgreSQL's built-in operators for eql_v2_encrypted types. - * - * For order and range operators (gt, gte, lt, lte, between, notBetween): - * Values are encrypted and then use eql_v2.* functions (eql_v2.gt(), eql_v2.gte(), etc.) - * which are required for ORE (Order-Revealing Encryption) comparisons. - * - * @param protectClient - The Protect.js client instance - * @returns An object with all Drizzle operators wrapped for encrypted columns - * - * @example - * ```ts - * // Initialize operators - * const protectOps = createProtectOperators(protectClient) - * - * // Equality search - automatically encrypts and uses PostgreSQL operators - * const results = await db - * .select() - * .from(usersTable) - * .where(await protectOps.eq(usersTable.email, 'user@example.com')) - * - * // Range query - automatically encrypts and uses eql_v2.gte() - * const olderUsers = await db - * .select() - * .from(usersTable) - * .where(await protectOps.gte(usersTable.age, 25)) - * ``` - */ -export function createProtectOperators(protectClient: ProtectClient): { - // Comparison operators - /** - * Equality operator - encrypts value for encrypted columns. - * Requires either `equality` or `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users with a specific email address. - * ```ts - * const condition = await protectOps.eq(usersTable.email, 'user@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - eq: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Not equal operator - encrypts value for encrypted columns. - * Requires either `equality` or `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users whose email address is not a specific value. - * ```ts - * const condition = await protectOps.ne(usersTable.email, 'user@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - ne: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Greater than operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users older than a specific age. - * ```ts - * const condition = await protectOps.gt(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - gt: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Greater than or equal operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users older than or equal to a specific age. - * ```ts - * const condition = await protectOps.gte(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - gte: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Less than operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users younger than a specific age. - * ```ts - * const condition = await protectOps.lt(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - lt: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Less than or equal operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users younger than or equal to a specific age. - * ```ts - * const condition = await protectOps.lte(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - lte: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Between operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users within a specific age range. - * ```ts - * const condition = await protectOps.between(usersTable.age, 20, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - between: (left: SQLWrapper, min: unknown, max: unknown) => Promise | SQL - - /** - * Not between operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users outside a specific age range. - * ```ts - * const condition = await protectOps.notBetween(usersTable.age, 20, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - notBetween: ( - left: SQLWrapper, - min: unknown, - max: unknown, - ) => Promise | SQL - - /** - * Like operator for encrypted columns with free text search. - * Requires `freeTextSearch` to be set on {@link EncryptedColumnConfig}. - * - * > [!IMPORTANT] - * > Case sensitivity on encrypted columns depends on the {@link EncryptedColumnConfig}. - * > Ensure that the column is configured for case-insensitive search if needed. - * - * @example - * Select users with email addresses matching a pattern. - * ```ts - * const condition = await protectOps.like(usersTable.email, '%@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - like: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * ILike operator for encrypted columns with free text search. - * Requires `freeTextSearch` to be set on {@link EncryptedColumnConfig}. - * - * > [!IMPORTANT] - * > Case sensitivity on encrypted columns depends on the {@link EncryptedColumnConfig}. - * > Ensure that the column is configured for case-insensitive search if needed. - * - * @example - * Select users with email addresses matching a pattern (case-insensitive). - * ```ts - * const condition = await protectOps.ilike(usersTable.email, '%@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - ilike: (left: SQLWrapper, right: unknown) => Promise | SQL - notIlike: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * JSONB path query first operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and calls `eql_v2.jsonb_path_query_first()`, - * casting the parameter to `eql_v2_encrypted`. - * - * @example - * Query the first matching value at a JSON path inside an encrypted column. - * ```ts - * const condition = await protectOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.email') - * const results = await db.select().from(docsTable).where(condition) - * ``` - * - * @throws {ProtectOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbPathQueryFirst: (left: SQLWrapper, right: unknown) => Promise - - /** - * JSONB get operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and uses the `->` operator, - * casting the parameter to `eql_v2_encrypted`. - * - * @example - * Get a value at a JSON path inside an encrypted column. - * ```ts - * const condition = await protectOps.jsonbGet(docsTable.metadata, '$.profile.name') - * const results = await db.select().from(docsTable).where(condition) - * ``` - * - * @throws {ProtectOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbGet: (left: SQLWrapper, right: unknown) => Promise - - /** - * JSONB path exists operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and calls `eql_v2.jsonb_path_exists()`, - * casting the parameter to `eql_v2_encrypted`. - * - * @example - * Check whether a JSON path exists inside an encrypted column. - * ```ts - * const condition = await protectOps.jsonbPathExists(docsTable.metadata, '$.profile.email') - * const results = await db.select().from(docsTable).where(condition) - * ``` - * - * @throws {ProtectOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbPathExists: (left: SQLWrapper, right: unknown) => Promise - // Array operators - inArray: (left: SQLWrapper, right: unknown[] | SQLWrapper) => Promise - notInArray: (left: SQLWrapper, right: unknown[] | SQLWrapper) => Promise - // Sorting operators - asc: (column: SQLWrapper) => SQL - desc: (column: SQLWrapper) => SQL - and: ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ) => Promise - or: ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ) => Promise - // Operators that don't need encryption (pass through to Drizzle) - exists: typeof exists - notExists: typeof notExists - isNull: typeof isNull - isNotNull: typeof isNotNull - not: typeof not - // Array operators that work with arrays directly (not encrypted values) - arrayContains: typeof arrayContains - arrayContained: typeof arrayContained - arrayOverlaps: typeof arrayOverlaps -} { - // Create a cache for protect tables keyed by table name - const protectTableCache = new Map>() - const defaultProtectTable: ProtectTable | undefined = - undefined - - /** - * Equality operator - encrypts value and uses regular Drizzle operator - */ - const protectEq = (left: SQLWrapper, right: unknown): Promise | SQL => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createComparisonOperator( - 'eq', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * Not equal operator - encrypts value and uses regular Drizzle operator - */ - const protectNe = (left: SQLWrapper, right: unknown): Promise | SQL => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createComparisonOperator( - 'ne', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * Greater than operator - uses eql_v2.gt() for encrypted columns with ORE index - */ - const protectGt = (left: SQLWrapper, right: unknown): Promise | SQL => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createComparisonOperator( - 'gt', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * Greater than or equal operator - uses eql_v2.gte() for encrypted columns with ORE index - */ - const protectGte = (left: SQLWrapper, right: unknown): Promise | SQL => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createComparisonOperator( - 'gte', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * Less than operator - uses eql_v2.lt() for encrypted columns with ORE index - */ - const protectLt = (left: SQLWrapper, right: unknown): Promise | SQL => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createComparisonOperator( - 'lt', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * Less than or equal operator - uses eql_v2.lte() for encrypted columns with ORE index - */ - const protectLte = (left: SQLWrapper, right: unknown): Promise | SQL => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createComparisonOperator( - 'lte', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * Between operator - uses eql_v2.gte() and eql_v2.lte() for encrypted columns with ORE index - */ - const protectBetween = ( - left: SQLWrapper, - min: unknown, - max: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createRangeOperator( - 'between', - left, - min, - max, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * Not between operator - uses eql_v2.gte() and eql_v2.lte() for encrypted columns with ORE index - */ - const protectNotBetween = ( - left: SQLWrapper, - min: unknown, - max: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createRangeOperator( - 'notBetween', - left, - min, - max, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * Like operator - encrypts value and uses eql_v2.like() for encrypted columns with match index - */ - const protectLike = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createTextSearchOperator( - 'like', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * Case-insensitive like operator - encrypts value and uses eql_v2.ilike() for encrypted columns with match index - */ - const protectIlike = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createTextSearchOperator( - 'ilike', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * Not like operator (case insensitive) - encrypts value and uses eql_v2.ilike() for encrypted columns with match index - */ - const protectNotIlike = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createTextSearchOperator( - 'notIlike', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * JSONB path query first operator - encrypts the selector and calls - * `eql_v2.jsonb_path_query_first()` for encrypted columns with searchable JSON. - * - * @throws {ProtectOperatorError} If the column lacks `searchableJson` config. - */ - const protectJsonbPathQueryFirst = ( - left: SQLWrapper, - right: unknown, - ): Promise => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createJsonbOperator( - 'jsonbPathQueryFirst', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * JSONB get operator - encrypts the selector and uses the `->` operator - * for encrypted columns with searchable JSON. - * - * @throws {ProtectOperatorError} If the column lacks `searchableJson` config. - */ - const protectJsonbGet = (left: SQLWrapper, right: unknown): Promise => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createJsonbOperator( - 'jsonbGet', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * JSONB path exists operator - encrypts the selector and calls - * `eql_v2.jsonb_path_exists()` for encrypted columns with searchable JSON. - * - * @throws {ProtectOperatorError} If the column lacks `searchableJson` config. - */ - const protectJsonbPathExists = ( - left: SQLWrapper, - right: unknown, - ): Promise => { - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - return createJsonbOperator( - 'jsonbPathExists', - left, - right, - columnInfo, - protectClient, - defaultProtectTable, - protectTableCache, - ) - } - - /** - * In array operator - encrypts all values in the array - */ - const protectInArray = async ( - left: SQLWrapper, - right: unknown[] | SQLWrapper, - ): Promise => { - // If right is a SQLWrapper (subquery), pass through to Drizzle - if (isSQLWrapper(right)) { - return inArray(left, right as unknown as Parameters[1]) - } - - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - - if (!columnInfo.config?.equality || !Array.isArray(right)) { - return inArray(left, right as unknown[]) - } - - // Encrypt all values in the array in a single batch - const encryptedValues = await encryptValues( - protectClient, - right.map((value) => ({ - value, - column: left, - queryType: 'equality' as const, - })), - defaultProtectTable, - protectTableCache, - ) - - // Use regular eq for each encrypted value - PostgreSQL operators handle it - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => eq(left, encrypted)) - - if (conditions.length === 0) { - return sql`false` - } - - const combined = or(...conditions) - return combined ?? sql`false` - } - - /** - * Not in array operator - */ - const protectNotInArray = async ( - left: SQLWrapper, - right: unknown[] | SQLWrapper, - ): Promise => { - // If right is a SQLWrapper (subquery), pass through to Drizzle - if (isSQLWrapper(right)) { - return notInArray( - left, - right as unknown as Parameters[1], - ) - } - - const columnInfo = getColumnInfo( - left, - defaultProtectTable, - protectTableCache, - ) - - if (!columnInfo.config?.equality || !Array.isArray(right)) { - return notInArray(left, right as unknown[]) - } - - // Encrypt all values in the array in a single batch - const encryptedValues = await encryptValues( - protectClient, - right.map((value) => ({ - value, - column: left, - queryType: 'equality' as const, - })), - defaultProtectTable, - protectTableCache, - ) - - // Use regular ne for each encrypted value - PostgreSQL operators handle it - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => ne(left, encrypted)) - - if (conditions.length === 0) { - return sql`true` - } - - const combined = and(...conditions) - return combined ?? sql`true` - } - - /** - * Ascending order helper - uses eql_v2.order_by() for encrypted columns with ORE index - */ - const protectAsc = (column: SQLWrapper): SQL => { - const columnInfo = getColumnInfo( - column, - defaultProtectTable, - protectTableCache, - ) - - if (columnInfo.config?.orderAndRange) { - return asc(sql`eql_v2.order_by(${column})`) - } - - return asc(column) - } - - /** - * Descending order helper - uses eql_v2.order_by() for encrypted columns with ORE index - */ - const protectDesc = (column: SQLWrapper): SQL => { - const columnInfo = getColumnInfo( - column, - defaultProtectTable, - protectTableCache, - ) - - if (columnInfo.config?.orderAndRange) { - return desc(sql`eql_v2.order_by(${column})`) - } - - return desc(column) - } - - /** - * Batched AND operator - collects lazy operators, batches encryption, and combines conditions - */ - const protectAnd = async ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ): Promise => { - // Single pass: separate lazy operators from regular conditions - const lazyOperators: LazyOperator[] = [] - const regularConditions: (SQL | SQLWrapper | undefined)[] = [] - const regularPromises: Promise[] = [] - - for (const condition of conditions) { - if (condition === undefined) { - continue - } - - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else if (condition instanceof Promise) { - // Check if promise is also a lazy operator - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else { - regularPromises.push(condition) - } - } else { - regularConditions.push(condition) - } - } - - // If there are no lazy operators, just use Drizzle's and() - if (lazyOperators.length === 0) { - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...(await Promise.all(regularPromises)), - ] - return and(...allConditions) ?? sql`true` - } - - // Single pass: collect all values to encrypt with metadata - const valuesToEncrypt: Array<{ - value: unknown - column: SQLWrapper - columnInfo: ColumnInfo - queryType?: QueryTypeName - lazyOpIndex: number - isMin?: boolean - isMax?: boolean - }> = [] - - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - if (!lazyOp.needsEncryption) { - continue - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.min, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMin: true, - }) - valuesToEncrypt.push({ - value: lazyOp.max, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMax: true, - }) - } else if (lazyOp.right !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.right, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - }) - } - } - - // Batch encrypt all values - const encryptedResults = await encryptValues( - protectClient, - valuesToEncrypt.map((v) => ({ - value: v.value, - column: v.column, - queryType: v.queryType, - })), - defaultProtectTable, - protectTableCache, - ) - - // Group encrypted values by lazy operator index - const encryptedByLazyOp = new Map< - number, - { value?: unknown; min?: unknown; max?: unknown } - >() - - for (let i = 0; i < valuesToEncrypt.length; i++) { - const { lazyOpIndex, isMin, isMax } = valuesToEncrypt[i] - const encrypted = encryptedResults[i] - - let group = encryptedByLazyOp.get(lazyOpIndex) - if (!group) { - group = {} - encryptedByLazyOp.set(lazyOpIndex, group) - } - - if (isMin) { - group.min = encrypted - } else if (isMax) { - group.max = encrypted - } else { - group.value = encrypted - } - } - - // Execute all lazy operators with their encrypted values - const sqlConditions: SQL[] = [] - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - const encrypted = encryptedByLazyOp.get(i) - - let sqlCondition: SQL - if (lazyOp.needsEncryption && encrypted) { - const encryptedValues: Array<{ value: unknown; encrypted: unknown }> = - [] - if (encrypted.value !== undefined) { - encryptedValues.push({ - value: lazyOp.right, - encrypted: encrypted.value, - }) - } - if (encrypted.min !== undefined) { - encryptedValues.push({ value: lazyOp.min, encrypted: encrypted.min }) - } - if (encrypted.max !== undefined) { - encryptedValues.push({ value: lazyOp.max, encrypted: encrypted.max }) - } - sqlCondition = await executeLazyOperator(lazyOp, encryptedValues) - } else { - sqlCondition = lazyOp.execute(lazyOp.right) - } - - sqlConditions.push(sqlCondition) - } - - // Await any regular promises - const regularPromisesResults = await Promise.all(regularPromises) - - // Combine all conditions - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...sqlConditions, - ...regularPromisesResults, - ] - - return and(...allConditions) ?? sql`true` - } - - /** - * Batched OR operator - collects lazy operators, batches encryption, and combines conditions - */ - const protectOr = async ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ): Promise => { - const lazyOperators: LazyOperator[] = [] - const regularConditions: (SQL | SQLWrapper | undefined)[] = [] - const regularPromises: Promise[] = [] - - for (const condition of conditions) { - if (condition === undefined) { - continue - } - - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else if (condition instanceof Promise) { - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else { - regularPromises.push(condition) - } - } else { - regularConditions.push(condition) - } - } - - if (lazyOperators.length === 0) { - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...(await Promise.all(regularPromises)), - ] - return or(...allConditions) ?? sql`false` - } - - const valuesToEncrypt: Array<{ - value: unknown - column: SQLWrapper - columnInfo: ColumnInfo - queryType?: QueryTypeName - lazyOpIndex: number - isMin?: boolean - isMax?: boolean - }> = [] - - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - if (!lazyOp.needsEncryption) { - continue - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.min, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMin: true, - }) - valuesToEncrypt.push({ - value: lazyOp.max, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMax: true, - }) - } else if (lazyOp.right !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.right, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - }) - } - } - - const encryptedResults = await encryptValues( - protectClient, - valuesToEncrypt.map((v) => ({ - value: v.value, - column: v.column, - queryType: v.queryType, - })), - defaultProtectTable, - protectTableCache, - ) - - const encryptedByLazyOp = new Map< - number, - { value?: unknown; min?: unknown; max?: unknown } - >() - - for (let i = 0; i < valuesToEncrypt.length; i++) { - const { lazyOpIndex, isMin, isMax } = valuesToEncrypt[i] - const encrypted = encryptedResults[i] - - let group = encryptedByLazyOp.get(lazyOpIndex) - if (!group) { - group = {} - encryptedByLazyOp.set(lazyOpIndex, group) - } - - if (isMin) { - group.min = encrypted - } else if (isMax) { - group.max = encrypted - } else { - group.value = encrypted - } - } - - const sqlConditions: SQL[] = [] - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - const encrypted = encryptedByLazyOp.get(i) - - let sqlCondition: SQL - if (lazyOp.needsEncryption && encrypted) { - const encryptedValues: Array<{ value: unknown; encrypted: unknown }> = - [] - if (encrypted.value !== undefined) { - encryptedValues.push({ - value: lazyOp.right, - encrypted: encrypted.value, - }) - } - if (encrypted.min !== undefined) { - encryptedValues.push({ value: lazyOp.min, encrypted: encrypted.min }) - } - if (encrypted.max !== undefined) { - encryptedValues.push({ value: lazyOp.max, encrypted: encrypted.max }) - } - sqlCondition = await executeLazyOperator(lazyOp, encryptedValues) - } else { - sqlCondition = lazyOp.execute(lazyOp.right) - } - - sqlConditions.push(sqlCondition) - } - - const regularPromisesResults = await Promise.all(regularPromises) - - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...sqlConditions, - ...regularPromisesResults, - ] - - return or(...allConditions) ?? sql`false` - } - - return { - // Comparison operators - eq: protectEq, - ne: protectNe, - gt: protectGt, - gte: protectGte, - lt: protectLt, - lte: protectLte, - - // Range operators - between: protectBetween, - notBetween: protectNotBetween, - - // Text search operators - like: protectLike, - ilike: protectIlike, - notIlike: protectNotIlike, - - // Searchable JSON operators - jsonbPathQueryFirst: protectJsonbPathQueryFirst, - jsonbGet: protectJsonbGet, - jsonbPathExists: protectJsonbPathExists, - - // Array operators - inArray: protectInArray, - notInArray: protectNotInArray, - - // Sorting operators - asc: protectAsc, - desc: protectDesc, - - // AND operator - batches encryption operations - and: protectAnd, - - // OR operator - batches encryption operations - or: protectOr, - - // Operators that don't need encryption (pass through to Drizzle) - exists, - notExists, - isNull, - isNotNull, - not, - // Array operators that work with arrays directly (not encrypted values) - arrayContains, - arrayContained, - arrayOverlaps, - } -} diff --git a/packages/drizzle/src/pg/schema-extraction.ts b/packages/drizzle/src/pg/schema-extraction.ts deleted file mode 100644 index 6c3a9479e..000000000 --- a/packages/drizzle/src/pg/schema-extraction.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - csColumn, - csTable, - type ProtectColumn, - type ProtectTable, -} from '@cipherstash/protect/client' -import type { PgCustomColumn, PgTable } from 'drizzle-orm/pg-core' -import { getEncryptedColumnConfig } from './index.js' - -/** - * Extracts the encrypted column keys from a Drizzle table type. - * Columns created with `encryptedType` are `PgCustomColumn` instances; - * this picks only those keys and maps them to `ProtectColumn`. - */ -// biome-ignore lint/suspicious/noExplicitAny: PgCustomColumn requires a wide generic -type DrizzleProtectSchema = { - [K in keyof T as T[K] extends PgCustomColumn ? K : never]: ProtectColumn -} - -/** - * Extracts a Protect.js schema from a Drizzle table definition. - * This function identifies columns created with `encryptedType` and - * builds a corresponding `ProtectTable` with `csColumn` definitions. - * - * @param table - The Drizzle table definition - * @returns A ProtectTable that can be used with `protect()` initialization - * - * @example - * ```ts - * const drizzleUsersTable = pgTable('users', { - * email: encryptedType('email', { freeTextSearch: true, equality: true }), - * age: encryptedType('age', { dataType: 'number', orderAndRange: true }), - * }) - * - * const protectSchema = extractProtectSchema(drizzleUsersTable) - * const protectClient = await protect({ schemas: [protectSchema.build()] }) - * ``` - */ -// We use any for the PgTable generic because we need to access Drizzle's internal properties -// biome-ignore lint/suspicious/noExplicitAny: Drizzle table types don't expose Symbol properties -export function extractProtectSchema>( - table: T, -): ProtectTable> & DrizzleProtectSchema { - // Drizzle tables store the name in a Symbol property - // biome-ignore lint/suspicious/noExplicitAny: Drizzle tables don't expose Symbol properties in types - const tableName = (table as any)[Symbol.for('drizzle:Name')] as - | string - | undefined - if (!tableName) { - throw new Error( - 'Unable to extract table name from Drizzle table. Ensure you are using a table created with pgTable().', - ) - } - - const columns: Record = {} - - // Iterate through table columns - for (const [columnName, column] of Object.entries(table)) { - // Skip if it's not a column (could be methods or other properties) - if (typeof column !== 'object' || column === null) { - continue - } - - // Check if this column has encrypted configuration - const config = getEncryptedColumnConfig(columnName, column) - - if (config) { - // Extract the actual column name from the column object (not the schema key) - // Drizzle columns have a 'name' property that contains the actual database column name - const actualColumnName = column.name || config.name - - // This is an encrypted column - build csColumn using the actual column name - const csCol = csColumn(actualColumnName) - - // Apply data type - if (config.dataType && config.dataType !== 'string') { - csCol.dataType(config.dataType) - } - - // Apply indexes based on configuration - if (config.orderAndRange) { - csCol.orderAndRange() - } - - if (config.equality) { - if (Array.isArray(config.equality)) { - // Custom token filters - csCol.equality(config.equality) - } else { - // Default equality (boolean true) - csCol.equality() - } - } - - if (config.freeTextSearch) { - if (typeof config.freeTextSearch === 'object') { - // Custom match options - csCol.freeTextSearch(config.freeTextSearch) - } else { - // Default freeTextSearch (boolean true) - csCol.freeTextSearch() - } - } - - if (config.searchableJson) { - if (config.dataType !== 'json') { - throw new Error( - `Column "${columnName}" has searchableJson enabled but dataType is "${config.dataType ?? 'string'}". searchableJson requires dataType: 'json'.`, - ) - } - csCol.searchableJson() - } - - columns[actualColumnName] = csCol - } - } - - if (Object.keys(columns).length === 0) { - throw new Error( - `No encrypted columns found in table "${tableName}". Use encryptedType() to define encrypted columns.`, - ) - } - - return csTable(tableName, columns) as ProtectTable> & - DrizzleProtectSchema -} diff --git a/packages/drizzle/tsconfig.json b/packages/drizzle/tsconfig.json deleted file mode 100644 index 6da81c927..000000000 --- a/packages/drizzle/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - // Enable latest features - "lib": ["ESNext", "DOM"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "esModuleInterop": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } -} diff --git a/packages/drizzle/tsup.config.ts b/packages/drizzle/tsup.config.ts deleted file mode 100644 index a8d9653f3..000000000 --- a/packages/drizzle/tsup.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig([ - { - entry: ['src/pg/index.ts'], - outDir: 'dist/pg', - format: ['cjs', 'esm'], - sourcemap: true, - dts: true, - }, - { - entry: ['src/bin/generate-eql-migration.ts'], - outDir: 'dist/bin', - format: ['cjs', 'esm'], - target: 'esnext', - clean: true, - splitting: true, - minify: true, - shims: true, - banner: { - js: '#!/usr/bin/env node', - }, - }, -]) diff --git a/packages/drizzle/vitest.config.ts b/packages/drizzle/vitest.config.ts deleted file mode 100644 index d00126169..000000000 --- a/packages/drizzle/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - resolve: { - alias: { - '@cipherstash/drizzle/pg': fileURLToPath( - new URL('./src/pg/index.ts', import.meta.url), - ), - }, - }, -}) diff --git a/packages/stack-drizzle/README.md b/packages/stack-drizzle/README.md index 91f6ec00e..b7bf93d94 100644 --- a/packages/stack-drizzle/README.md +++ b/packages/stack-drizzle/README.md @@ -74,4 +74,4 @@ encrypt search values. Full guide: https://cipherstash.com/docs/integrations/drizzle — see also the bundled `stash-drizzle` agent skill. -> Not to be confused with `@cipherstash/drizzle`, the older `@cipherstash/protect`-based package. +> Not to be confused with `@cipherstash/drizzle`, the older `@cipherstash/protect`-based package — deprecated and no longer published; this package replaces it. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea152ac6b..e7f320eec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,9 +86,6 @@ importers: e2e: dependencies: - '@cipherstash/drizzle': - specifier: workspace:* - version: link:../packages/drizzle '@cipherstash/protect': specifier: workspace:* version: link:../packages/protect @@ -317,46 +314,6 @@ importers: specifier: catalog:repo version: 0.41.0 - packages/drizzle: - dependencies: - '@types/pg': - specifier: '*' - version: 8.16.0 - drizzle-kit: - specifier: '>=0.20' - version: 0.30.6 - drizzle-orm: - specifier: '>=0.33' - version: 0.45.2(@types/pg@8.16.0)(gel@2.2.0)(mysql2@3.16.0)(pg@8.13.1)(postgres@3.4.9) - pg: - specifier: '>=8' - version: 8.13.1 - postgres: - specifier: '>=3' - version: 3.4.9 - devDependencies: - '@cipherstash/protect': - specifier: workspace:* - version: link:../protect - '@cipherstash/schema': - specifier: workspace:* - version: link:../schema - dotenv: - specifier: ^17.4.2 - version: 17.4.2 - fast-check: - specifier: ^4.8.0 - version: 4.8.0 - tsup: - specifier: catalog:repo - version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.22.1)(typescript@5.9.3)(yaml@2.9.0) - typescript: - specifier: catalog:repo - version: 5.9.3 - vitest: - specifier: catalog:repo - version: 3.2.6(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.22.1)(yaml@2.9.0) - packages/migrate: dependencies: zod: @@ -1209,314 +1166,105 @@ packages: react-dom: optional: true - '@drizzle-team/brocli@0.10.2': - resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} - '@esbuild-kit/core-utils@3.3.2': - resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' - - '@esbuild-kit/esm-loader@2.6.5': - resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' - - '@esbuild/aix-ppc64@0.19.12': - resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.18.20': - resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.19.12': - resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.18.20': - resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.19.12': - resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.18.20': - resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.19.12': - resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.18.20': - resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.19.12': - resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.18.20': - resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.19.12': - resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.18.20': - resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.19.12': - resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.18.20': - resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.19.12': - resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.18.20': - resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.19.12': - resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.18.20': - resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.19.12': - resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.18.20': - resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.19.12': - resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.18.20': - resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.19.12': - resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.18.20': - resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.19.12': - resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.18.20': - resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.19.12': - resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.18.20': - resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.19.12': - resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.18.20': - resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.19.12': - resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.18.20': - resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.19.12': - resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} @@ -1529,18 +1277,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.18.20': - resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.19.12': - resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} @@ -1553,18 +1289,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.18.20': - resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.19.12': - resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} @@ -1577,72 +1301,24 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.18.20': - resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.19.12': - resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.18.20': - resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.19.12': - resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.18.20': - resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.19.12': - resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.18.20': - resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.19.12': - resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -2470,9 +2146,6 @@ packages: '@types/node@25.8.0': resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} - '@types/pg@8.16.0': - resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} - '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -2764,10 +2437,6 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} - drizzle-kit@0.30.6: - resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==} - hasBin: true - drizzle-orm@0.45.2: resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: @@ -2894,21 +2563,6 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - esbuild-register@3.6.0: - resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} - peerDependencies: - esbuild: ^0.28.1 - - esbuild@0.18.20: - resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.19.12: - resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -3096,9 +2750,6 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} - giget@3.2.0: resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} hasBin: true @@ -3566,15 +3217,9 @@ packages: perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} - pg-cloudflare@1.3.0: - resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} - pg-connection-string@2.12.0: - resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} - pg-connection-string@2.14.0: resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} @@ -3587,19 +3232,11 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-pool@3.13.0: - resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} - peerDependencies: - pg: '>=8.0' - pg-pool@3.14.0: resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} peerDependencies: pg: '>=8.0' - pg-protocol@1.13.0: - resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} - pg-protocol@1.15.0: resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} @@ -3607,15 +3244,6 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.13.1: - resolution: {integrity: sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==} - engines: {node: '>= 8.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - pg@8.20.0: resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} engines: {node: '>= 16.0.0'} @@ -3793,9 +3421,6 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -4614,233 +4239,86 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@drizzle-team/brocli@0.10.2': {} - '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 optional: true - '@esbuild-kit/core-utils@3.3.2': - dependencies: - esbuild: 0.18.20 - source-map-support: 0.5.21 - - '@esbuild-kit/esm-loader@2.6.5': - dependencies: - '@esbuild-kit/core-utils': 3.3.2 - get-tsconfig: 4.13.0 - - '@esbuild/aix-ppc64@0.19.12': - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.18.20': - optional: true - - '@esbuild/android-arm64@0.19.12': - optional: true - '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.18.20': - optional: true - - '@esbuild/android-arm@0.19.12': - optional: true - '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.18.20': - optional: true - - '@esbuild/android-x64@0.19.12': - optional: true - '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.18.20': - optional: true - - '@esbuild/darwin-arm64@0.19.12': - optional: true - '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.18.20': - optional: true - - '@esbuild/darwin-x64@0.19.12': - optional: true - '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.18.20': - optional: true - - '@esbuild/freebsd-arm64@0.19.12': - optional: true - '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.18.20': - optional: true - - '@esbuild/freebsd-x64@0.19.12': - optional: true - '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.18.20': - optional: true - - '@esbuild/linux-arm64@0.19.12': - optional: true - '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.18.20': - optional: true - - '@esbuild/linux-arm@0.19.12': - optional: true - '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.18.20': - optional: true - - '@esbuild/linux-ia32@0.19.12': - optional: true - '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.18.20': - optional: true - - '@esbuild/linux-loong64@0.19.12': - optional: true - '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.18.20': - optional: true - - '@esbuild/linux-mips64el@0.19.12': - optional: true - '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.18.20': - optional: true - - '@esbuild/linux-ppc64@0.19.12': - optional: true - '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.18.20': - optional: true - - '@esbuild/linux-riscv64@0.19.12': - optional: true - '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.18.20': - optional: true - - '@esbuild/linux-s390x@0.19.12': - optional: true - '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.18.20': - optional: true - - '@esbuild/linux-x64@0.19.12': - optional: true - '@esbuild/linux-x64@0.28.1': optional: true '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.18.20': - optional: true - - '@esbuild/netbsd-x64@0.19.12': - optional: true - '@esbuild/netbsd-x64@0.28.1': optional: true '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.18.20': - optional: true - - '@esbuild/openbsd-x64@0.19.12': - optional: true - '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.18.20': - optional: true - - '@esbuild/sunos-x64@0.19.12': - optional: true - '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.18.20': - optional: true - - '@esbuild/win32-arm64@0.19.12': - optional: true - '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.18.20': - optional: true - - '@esbuild/win32-ia32@0.19.12': - optional: true - '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.18.20': - optional: true - - '@esbuild/win32-x64@0.19.12': - optional: true - '@esbuild/win32-x64@0.28.1': optional: true @@ -5054,7 +4532,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@petamoriken/float16@3.9.3': {} + '@petamoriken/float16@3.9.3': + optional: true '@posthog/core@1.29.2': dependencies: @@ -5607,12 +5086,6 @@ snapshots: undici-types: 7.24.6 optional: true - '@types/pg@8.16.0': - dependencies: - '@types/node': 22.20.0 - pg-protocol: 1.13.0 - pg-types: 2.2.0 - '@types/pg@8.20.0': dependencies: '@types/node': 22.20.0 @@ -5754,7 +5227,8 @@ snapshots: dependencies: fill-range: 7.1.1 - buffer-from@1.1.2: {} + buffer-from@1.1.2: + optional: true bundle-require@5.1.0(esbuild@0.28.1): dependencies: @@ -5886,24 +5360,6 @@ snapshots: dotenv@17.4.2: {} - drizzle-kit@0.30.6: - dependencies: - '@drizzle-team/brocli': 0.10.2 - '@esbuild-kit/esm-loader': 2.6.5 - esbuild: 0.19.12 - esbuild-register: 3.6.0(esbuild@0.19.12) - gel: 2.2.0 - transitivePeerDependencies: - - supports-color - - drizzle-orm@0.45.2(@types/pg@8.16.0)(gel@2.2.0)(mysql2@3.16.0)(pg@8.13.1)(postgres@3.4.9): - optionalDependencies: - '@types/pg': 8.16.0 - gel: 2.2.0 - mysql2: 3.16.0 - pg: 8.13.1 - postgres: 3.4.9 - drizzle-orm@0.45.2(@types/pg@8.20.0)(gel@2.2.0)(mysql2@3.16.0)(pg@8.20.0)(postgres@3.4.9): optionalDependencies: '@types/pg': 8.20.0 @@ -5935,7 +5391,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 - env-paths@3.0.0: {} + env-paths@3.0.0: + optional: true es-define-property@1.0.1: {} @@ -5947,64 +5404,6 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild-register@3.6.0(esbuild@0.19.12): - dependencies: - debug: 4.4.3 - esbuild: 0.19.12 - transitivePeerDependencies: - - supports-color - - esbuild@0.18.20: - optionalDependencies: - '@esbuild/android-arm': 0.18.20 - '@esbuild/android-arm64': 0.18.20 - '@esbuild/android-x64': 0.18.20 - '@esbuild/darwin-arm64': 0.18.20 - '@esbuild/darwin-x64': 0.18.20 - '@esbuild/freebsd-arm64': 0.18.20 - '@esbuild/freebsd-x64': 0.18.20 - '@esbuild/linux-arm': 0.18.20 - '@esbuild/linux-arm64': 0.18.20 - '@esbuild/linux-ia32': 0.18.20 - '@esbuild/linux-loong64': 0.18.20 - '@esbuild/linux-mips64el': 0.18.20 - '@esbuild/linux-ppc64': 0.18.20 - '@esbuild/linux-riscv64': 0.18.20 - '@esbuild/linux-s390x': 0.18.20 - '@esbuild/linux-x64': 0.18.20 - '@esbuild/netbsd-x64': 0.18.20 - '@esbuild/openbsd-x64': 0.18.20 - '@esbuild/sunos-x64': 0.18.20 - '@esbuild/win32-arm64': 0.18.20 - '@esbuild/win32-ia32': 0.18.20 - '@esbuild/win32-x64': 0.18.20 - - esbuild@0.19.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.19.12 - '@esbuild/android-arm': 0.19.12 - '@esbuild/android-arm64': 0.19.12 - '@esbuild/android-x64': 0.19.12 - '@esbuild/darwin-arm64': 0.19.12 - '@esbuild/darwin-x64': 0.19.12 - '@esbuild/freebsd-arm64': 0.19.12 - '@esbuild/freebsd-x64': 0.19.12 - '@esbuild/linux-arm': 0.19.12 - '@esbuild/linux-arm64': 0.19.12 - '@esbuild/linux-ia32': 0.19.12 - '@esbuild/linux-loong64': 0.19.12 - '@esbuild/linux-mips64el': 0.19.12 - '@esbuild/linux-ppc64': 0.19.12 - '@esbuild/linux-riscv64': 0.19.12 - '@esbuild/linux-s390x': 0.19.12 - '@esbuild/linux-x64': 0.19.12 - '@esbuild/netbsd-x64': 0.19.12 - '@esbuild/openbsd-x64': 0.19.12 - '@esbuild/sunos-x64': 0.19.12 - '@esbuild/win32-arm64': 0.19.12 - '@esbuild/win32-ia32': 0.19.12 - '@esbuild/win32-x64': 0.19.12 - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -6213,6 +5612,7 @@ snapshots: which: 4.0.0 transitivePeerDependencies: - supports-color + optional: true generate-function@2.3.1: dependencies: @@ -6244,10 +5644,6 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - get-tsconfig@4.13.0: - dependencies: - resolve-pkg-maps: 1.0.0 - giget@3.2.0: {} glob-parent@5.1.2: @@ -6336,7 +5732,8 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.5: {} + isexe@3.1.5: + optional: true jiti@2.7.0: {} @@ -6627,14 +6024,9 @@ snapshots: perfect-debounce@2.1.0: {} - pg-cloudflare@1.3.0: - optional: true - pg-cloudflare@1.4.0: optional: true - pg-connection-string@2.12.0: {} - pg-connection-string@2.14.0: {} pg-cursor@2.21.0(pg@8.21.0): @@ -6643,10 +6035,6 @@ snapshots: pg-int8@1.0.1: {} - pg-pool@3.13.0(pg@8.13.1): - dependencies: - pg: 8.13.1 - pg-pool@3.14.0(pg@8.20.0): dependencies: pg: 8.20.0 @@ -6655,8 +6043,6 @@ snapshots: dependencies: pg: 8.21.0 - pg-protocol@1.13.0: {} - pg-protocol@1.15.0: {} pg-types@2.2.0: @@ -6667,16 +6053,6 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.13.1: - dependencies: - pg-connection-string: 2.12.0 - pg-pool: 3.13.0(pg@8.13.1) - pg-protocol: 1.13.0 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.3.0 - pg@8.20.0: dependencies: pg-connection-string: 2.14.0 @@ -6818,8 +6194,6 @@ snapshots: resolve-from@5.0.0: {} - resolve-pkg-maps@1.0.0: {} - reusify@1.1.0: {} rimraf@6.1.3: @@ -6948,7 +6322,8 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.9.0: {} + shell-quote@1.9.0: + optional: true side-channel-list@1.0.1: dependencies: @@ -6992,8 +6367,10 @@ snapshots: dependencies: buffer-from: 1.1.2 source-map: 0.6.1 + optional: true - source-map@0.6.1: {} + source-map@0.6.1: + optional: true source-map@0.7.6: {} @@ -7349,6 +6726,7 @@ snapshots: which@4.0.0: dependencies: isexe: 3.1.5 + optional: true why-is-node-running@2.3.0: dependencies: diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 9269526f4..0917ce18e 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -28,7 +28,8 @@ The Drizzle integration ships as its own first-party package, `@cipherstash/stack-drizzle`, which depends on `@cipherstash/stack`. Install both. The v3 surface documented here lives on the `@cipherstash/stack-drizzle/v3` subpath. It is distinct from the older, separate `@cipherstash/drizzle` package (which is -`@cipherstash/protect`-based, with different symbol names). +`@cipherstash/protect`-based, with different symbol names) — that package is +**deprecated and no longer published**; do not install it. This package replaces it. ## Database Setup @@ -622,4 +623,4 @@ Encryption client operations (`encryptModel`, `bulkDecryptModels`, ...) don't th ## Legacy: EQL v2 -The original v2 integration — `encryptedType` config-flag columns, `extractEncryptionSchema`, and `createEncryptionOperators` (with `like`/`ilike`) from the `@cipherstash/stack-drizzle` package root, over the `eql_v2_encrypted` column type installed via `stash eql install --drizzle` (the older standalone `@cipherstash/drizzle` package shipped its own `generate-eql-migration` bin for the same purpose) — still exists for existing deployments and is documented at https://cipherstash.com/docs. New projects must use the `/v3` surface documented above. Note: `stash init --drizzle` currently pins EQL v2 because the v2 Drizzle-migration install path has no v3 equivalent yet. +The original v2 integration — `encryptedType` config-flag columns, `extractEncryptionSchema`, and `createEncryptionOperators` (with `like`/`ilike`) from the `@cipherstash/stack-drizzle` package root, over the `eql_v2_encrypted` column type installed via `stash eql install --drizzle` (the deprecated standalone `@cipherstash/drizzle` package shipped its own `generate-eql-migration` bin for the same purpose) — still exists for existing deployments and is documented at https://cipherstash.com/docs. New projects must use the `/v3` surface documented above. Note: `stash init --drizzle` currently pins EQL v2 because the v2 Drizzle-migration install path has no v3 equivalent yet. From 86d3a10c8c98972c5e748764aa2c251859209004 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 16 Jul 2026 15:59:05 +1000 Subject: [PATCH 2/3] test(e2e): drop the legacy drizzle generate-eql-migration bin from the runner matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package-managers suite iterates a BIN map that still listed packages/drizzle's generate-eql-migration.js — removed with the package in the previous commit, so its 4 per-pm help-text tests failed on Cannot find module. Delete the map entry (the suite derives its tests from the map, so the tests go with it). Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- e2e/tests/package-managers.e2e.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/e2e/tests/package-managers.e2e.test.ts b/e2e/tests/package-managers.e2e.test.ts index 52df50afc..cf5e0951f 100644 --- a/e2e/tests/package-managers.e2e.test.ts +++ b/e2e/tests/package-managers.e2e.test.ts @@ -26,10 +26,9 @@ const BIN = { cli: resolve(REPO_ROOT, 'packages/cli/dist/bin/stash.js'), wizard: resolve(REPO_ROOT, 'packages/wizard/dist/bin/wizard.js'), protect: resolve(REPO_ROOT, 'packages/protect/dist/bin/stash.js'), - drizzleGen: resolve( - REPO_ROOT, - 'packages/drizzle/dist/bin/generate-eql-migration.js', - ), + // The legacy @cipherstash/drizzle `generate-eql-migration` bin is gone with + // the package (protect sunsets at 1.0; @cipherstash/stack-drizzle is the + // successor). } as const const UA: Record = { From c6915f00c46f0fa51ddc7672ea7826d40655f3a0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 16 Jul 2026 16:45:30 +1000 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/stack-drizzle/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-drizzle/README.md b/packages/stack-drizzle/README.md index b7bf93d94..22f3fede1 100644 --- a/packages/stack-drizzle/README.md +++ b/packages/stack-drizzle/README.md @@ -74,4 +74,4 @@ encrypt search values. Full guide: https://cipherstash.com/docs/integrations/drizzle — see also the bundled `stash-drizzle` agent skill. -> Not to be confused with `@cipherstash/drizzle`, the older `@cipherstash/protect`-based package — deprecated and no longer published; this package replaces it. +> Not to be confused with `@cipherstash/drizzle`, the older `@cipherstash/protect`-based package — deprecated and no longer maintained; this package replaces it.