Skip to content

Update EQL v3 concrete type names via FFI 0.28#570

Closed
tobyhede wants to merge 7 commits into
feat/eql-v3-text-search-schemafrom
chore/eql-v3-ffi-0-27
Closed

Update EQL v3 concrete type names via FFI 0.28#570
tobyhede wants to merge 7 commits into
feat/eql-v3-text-search-schemafrom
chore/eql-v3-ffi-0-27

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Upgrade @cipherstash/protect-ffi to 0.27.0.
  • Rename EQL v3 concrete type factories/classes/domains to the new names: Integer, Smallint, Boolean, Real, Double, and Bigint.
  • Widen the stack FFI-facing encrypted payload/query types for the new v3 query shape and update v3 schema/matrix tests.

Validation

  • pnpm --filter @cipherstash/stack build
  • pnpm exec vitest run __tests__/schema-v3.test.ts __tests__/v3-matrix/matrix.test.ts
  • pnpm exec vitest --run --typecheck.only __tests__/schema-v3.test-d.ts __tests__/typed-client-v3.test-d.ts __tests__/v3-matrix/matrix.test-d.ts

Notes

  • Live credential/Postgres suites were not run locally.

Summary by CodeRabbit

  • New Features

    • Added a strongly typed EQL v3 schema and client surface with domain-specific builders for integer, smallint, boolean, real, double, and existing text/date/timestamp types.
    • Added support for Date values in v3 encryption and query encryption flows.
    • Expanded v3 query handling to support the new typed query shapes.
  • Bug Fixes

    • Updated v3 naming and examples to use the new domain names consistently.
    • Bulk decryption now reports malformed items as per-item errors instead of failing the entire request.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title captures the core change to EQL v3 concrete type names, even though the FFI version number appears off.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/eql-v3-ffi-0-27

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@changeset-bot

changeset-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 175b1ed

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@cipherstash/stack Minor
@cipherstash/bench Patch
@cipherstash/prisma-next Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@tobyhede tobyhede force-pushed the chore/eql-v3-ffi-0-27 branch from da69a6b to 1fdb1f5 Compare July 8, 2026 00:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/eql-v3-ffi-0-27-concrete-types.md:
- Around line 1-5: This changeset is marked as patch but the EQL v3
factory/domain rename is a breaking API change, so update the release type to
minor. Adjust the changeset entry for `@cipherstash/stack` to reflect the new
version bump, keeping the note about upgrading `@cipherstash/protect-ffi` and the
renamed concrete type factories/domains (Integer, Smallint, Boolean, Real,
Double, Bigint) as the reason for the higher bump.

In `@packages/stack/__tests__/schema-v3.test.ts`:
- Line 436: The parameterized schema-v3 test labels are stale and still
reference the old `int4_ord` naming, which no longer matches the renamed domain
builders. Update the case labels in `schema-v3.test.ts` to use the new
`IntegerOrd` and `IntegerOrdOre` names (matching `types.IntegerOrd` and
`types.IntegerOrdOre` / their emitted `eql_v3.integer_ord` and
`eql_v3.integer_ord_ore` output) so the test output stays consistent with the
renamed symbols.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 96442036-7c23-4cd7-abf6-647e5b3bdbe8

📥 Commits

Reviewing files that changed from the base of the PR and between 4d5bf63 and 1fdb1f5.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (21)
  • .changeset/eql-v3-ffi-0-27-concrete-types.md
  • .changeset/eql-v3-typed-schema.md
  • packages/stack/__tests__/cjs-require.test.ts
  • packages/stack/__tests__/encrypt-lock-context-guards.test.ts
  • packages/stack/__tests__/schema-v3-client.test.ts
  • packages/stack/__tests__/schema-v3-pg.test.ts
  • packages/stack/__tests__/schema-v3.test-d.ts
  • packages/stack/__tests__/schema-v3.test.ts
  • packages/stack/__tests__/typed-client-v3.test-d.ts
  • packages/stack/__tests__/v3-matrix/catalog.ts
  • packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-live.test.ts
  • packages/stack/__tests__/v3-matrix/matrix.test-d.ts
  • packages/stack/package.json
  • packages/stack/src/encryption/helpers/index.ts
  • packages/stack/src/encryption/operations/batch-encrypt-query.ts
  • packages/stack/src/eql/v3/columns.ts
  • packages/stack/src/eql/v3/index.ts
  • packages/stack/src/eql/v3/types.ts
  • packages/stack/src/types.ts

Comment thread .changeset/eql-v3-ffi-0-27-concrete-types.md Outdated
Comment thread packages/stack/__tests__/schema-v3.test.ts Outdated
@tobyhede tobyhede force-pushed the chore/eql-v3-ffi-0-27 branch from f67d08d to 15e63a8 Compare July 8, 2026 01:06
@tobyhede tobyhede marked this pull request as ready for review July 8, 2026 01:39
@tobyhede tobyhede requested a review from a team as a code owner July 8, 2026 01:39
@tobyhede tobyhede requested a review from freshtonic July 8, 2026 01:39
tobyhede added 2 commits July 8, 2026 11:54
- Bump stack dependency and the e2e Deno wasm-inline pin to 0.28.0
- Regenerate lockfile
- 0.28 widens JsPlaintext to include bigint (native i64 round-tripping);
  update the v3 type test to assert bigint plaintext is now accepted
- Rename/refresh the FFI changeset for 0.28
Comment thread .changeset/eql-v3-ffi-0-28-concrete-types.md Outdated
…ngeset)

The prior commit f84e4c1 captured only the changeset rename; the actual
content edits were left unstaged. This commit carries them:
- packages/stack/package.json: protect-ffi 0.27.0 → 0.28.0
- e2e/wasm/deno.json: wasm-inline pin → 0.28.0
- pnpm-lock.yaml: regenerated for 0.28.0
- schema-v3.test-d.ts: assert bigint plaintext now accepted (JsPlaintext widened)
- changeset body: 0.27.0 → 0.28.0

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage review

Verdict: well covered, with two gaps. This PR is largely a mechanical rename of the EQL v3 concrete domains (int4→integer, int2→smallint, float4→real, float8→double, bool factory → Boolean) to match protect-ffi 0.28 / the regenerated SQL bundle, plus type-widening (Encrypted, EncryptedQuery unions) with no new runtime logic. The rename surface is thoroughly exercised: the runtime matrix.test.ts asserts getEqlType(), getQueryCapabilities(), isQueryable() and build() (incl. cast_as) for every renamed domain from the catalog, and the new schema-v3.test.ts "concrete type names" block explicitly pins each factory (including the Boolean → eql_v3.bool name divergence). The bigint-omission and per-item bulkDecrypt behaviors have new tests.

Two gaps worth closing are posted inline:

  1. Nothing cross-checks the domain names the SDK emits against the checked-in SQL bundle. The types.ts docstring explicitly warns that name drift is "a version mismatch, not a harmless naming drift" — but matrix.test.ts only proves the builder and the catalog agree with each other, not with the fixture. Since this PR's whole purpose is to keep those in lockstep, a cheap non-live guard is worth adding.
  2. The rewritten bulkDecrypt error-code test only exercises an all-malformed batch; the defining property of the fallible per-item API (a valid item still returns { data } when a malformed sibling returns { error }) is untested. This mirrors the known "one axis tested, the discriminating axis not" anti-pattern.

Out of scope (noted, not blocking)

  • The widened EncryptedQuery = CipherStashEncryptedQuery | CipherStashEncryptedV3Query and Encrypted = CipherStashEncryptedPayload are type-only changes with no explicit .test-d.ts assertion that a v3 jsonb_query needle is assignable to EncryptedQuery. A one-line expectTypeOf regression guard would be cheap, but such an assertion is close to tautological, so I did not post it inline.

'eql_v3.int2_ord_ore': { builder: types.Int2OrdOre, ColumnClass: EncryptedInt2OrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: INT2_S, errorSamples: NUM_ERR },
'eql_v3.int2_ord': { builder: types.Int2Ord, ColumnClass: EncryptedInt2OrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: INT2_S, errorSamples: NUM_ERR },
// integer
'eql_v3.integer': { builder: types.Integer, ColumnClass: EncryptedIntegerColumn, castAs: 'number', capabilities: STORAGE, indexes: NONE, samples: INTEGER_S, errorSamples: NUM_ERR },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gap: The renamed domain names (eql_v3.integer, eql_v3.smallint, eql_v3.real, eql_v3.double, …) are only checked for internal consistency — matrix.test.ts asserts the builder emits what this catalog says, but nothing asserts those names actually exist as domains in the checked-in cipherstash-encrypt-v3.sql bundle. The types.ts docstring warns that a name mismatch here is "a version mismatch, not a harmless naming drift," yet the only thing that would catch such drift today is the live Postgres suite (gated). A cheap, non-live guard reading the fixture closes exactly the invariant this PR exists to maintain.

// packages/stack/__tests__/v3-matrix/domain-names-in-sync.test.ts
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { V3_MATRIX } from './catalog'

const fixture = resolve(
  dirname(fileURLToPath(import.meta.url)),
  '../fixtures/eql-v3/cipherstash-encrypt-v3.sql',
)

describe('v3 domain names stay in sync with the SQL bundle', () => {
  it('every emitted eqlType exists as a CREATE DOMAIN in the fixture', async () => {
    const sql = await readFile(fixture, 'utf8')
    const declared = new Set(
      [...sql.matchAll(/CREATE DOMAIN (eql_v3\.[a-z0-9_]+) AS jsonb/g)].map(
        (m) => m[1],
      ),
    )
    // Subset, not equality: the bundle also declares int8_* domains the SDK
    // intentionally omits pending lossless bigint round-tripping.
    for (const eqlType of Object.keys(V3_MATRIX)) {
      expect(declared.has(eqlType), `${eqlType} missing from SQL bundle`).toBe(
        true,
      )
    }
  })
})

Expected: passes on this branch; fails the moment an emitted name drifts from the regenerated bundle (e.g. a stray eql_v3.int4 left behind, or a fixture regen that renames a domain without updating the SDK).

}

expect(result.data).toHaveLength(2)
expect(result.data.every((item) => 'error' in item)).toBe(true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gap: This rewrite now documents the fallible per-item behavior, but only for an all-malformed batch. The property that actually changed — a valid ciphertext still decrypts to { data } while a malformed sibling is isolated as { error } in the same call — is untested. This is the "one axis tested, discriminating axis not" anti-pattern: the all-invalid case would also pass under a hypothetical regression that fails the whole batch as soon as one item is bad.

it(
  'isolates malformed items without failing valid siblings',
  async () => {
    const good = await protectClient.encrypt('valid@example.com', {
      column: testSchema.email,
      table: testSchema,
    })
    if (good.failure) throw new Error(good.failure.message)

    const mixed = [
      { data: good.data },
      { data: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid' } },
    ] as unknown as BulkDecryptPayload

    const result = await protectClient.bulkDecrypt(mixed)
    if (result.failure) {
      throw new Error(`Expected per-item errors, got ${result.failure.type}`)
    }

    expect(result.data).toHaveLength(2)
    expect('data' in result.data[0]).toBe(true) // valid item survives
    expect('error' in result.data[1]).toBe(true) // malformed item isolated
  },
  FFI_TEST_TIMEOUT,
)

Expected: the valid entry round-trips to { data: 'valid@example.com' } and the malformed entry carries a per-item { error }, proving failure isolation rather than the all-or-nothing case the current test covers.

@tobyhede tobyhede changed the title Update EQL v3 concrete type names Update EQL v3 concrete type names via FFI 0.28 Jul 8, 2026
@tobyhede tobyhede closed this Jul 8, 2026
@tobyhede tobyhede deleted the chore/eql-v3-ffi-0-27 branch July 8, 2026 04:08
@tobyhede

tobyhede commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #575 — branch renamed chore/eql-v3-ffi-0-27chore/eql-v3-ffi-0-28 and history squashed (the 0.27→0.28 rename + re-bump churn collapsed into a single commit). Same content, byte-identical tree.

@coderdan

coderdan commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Code review — EQL v3 concrete type rename

Automated multi-angle review (xhigh effort): 10 finder angles → verification → sweep. Scope: the PR diff vs feat/eql-v3-text-search-schema, excluding the generated cipherstash-encrypt-v3.sql fixture and the lockfile.

Verdict: the rename is clean and safe to ship. The mechanical rename (Int4→Integer, Int2→Smallint, Float4→Real, Float8→Double, Bool→Boolean; int8/bigint dropped) is complete and internally consistent — every eqlType string matches a real SQL-fixture domain, factory→const→class wiring is TS-enforced, Boolean→eql_v3.bool is correctly preserved, no stale identifiers remain in src/, and the type widenings are safe supersets. It actually fixes a latent SDK↔DB mismatch (the SDK previously emitted eql_v3.int4_ord, which the fixture never defined). No correctness bugs in the shipped code — the findings below are release-metadata, test-quality, and structural issues.

Findings (ranked)

1. HIGH — commit & changeset advertise a 0.28 + bigint upgrade that didn't happen
.changeset/eql-v3-ffi-0-28-concrete-types.md · commit f84e4c15 · packages/stack/package.json:247
Commit f84e4c15's message says it bumps protect-ffi to 0.28.0, regenerates the lockfile, and adds native bigint ("assert bigint plaintext is now accepted") — but the commit is a zero-line changeset file rename (0-270-28). Reality: package.json pins 0.27.0, the changeset body says "0.27.0", .changeset/skip-v3-bigint.md explicitly defers bigint, and schema-v3.test-d.ts still @ts-expect-error-rejects 1n. changesets consumes this file, so the changelog will read 0.27.0 while the filename + git history claim 0.28-with-bigint — misleading release automation and reviewers.
Fix: rename the changeset to -0-27-*, and correct/rewrite the commit message.

2. MED — public Encrypted/EncryptedQuery widening is a consumer-visible shape change
packages/stack/src/types.ts:58 (and :67)
Encrypted widens from CipherStashEncrypted (v2) to CipherStashEncryptedPayload (v2 | EncryptedV3), and EncryptedQuery gains a CipherStashEncryptedV3Query arm. Both are public (re-exported via types-public.ts) and flow into EncryptedQueryResult/BulkDecryptPayload. An external consumer that narrows an Encrypted value on .k/.c/.hm now faces a wider union whose v3 arm lacks those fields → new TS2339 in their code. In-repo it's absorbed (CI green); released as minor. Please confirm the widening is intended and semver-acceptable.

3. MED — the bigint-rejection type tests pass for the wrong reason (inert guard)
packages/stack/__tests__/schema-v3.test-d.ts:110,122
client.encrypt(1n, { column: v3users.email }) under @ts-expect-error — but v3users.email is a types.TextSearch string column, so the error is bigint != string, not Plaintext excluding bigint. If a future change re-adds bigint to Plaintext, these tests keep passing (a string column still rejects bigint) → false confidence that the deferral in finding #1 is still enforced.
Fix: assert expectTypeOf<Plaintext>() directly, or exercise an integer-domain column.

4. MED — the PR's new bulkDecrypt assertions aren't type-checked
packages/stack/__tests__/error-codes.test.ts:250
vitest typecheck.include is scoped to *.test-d.ts; .test.ts files run through esbuild with types stripped. So the rewritten bulkDecrypt block (as unknown as BulkDecryptPayload, item.error, 'error' in item) is never type-verified — a real type error there passes CI silently and only surfaces in the live FFI run.

5. MED — rewritten bulkDecrypt test asserts an unverified FFI behavior change
packages/stack/__tests__/error-codes.test.ts:261
The test flips to expecting per-item errors for malformed ciphertexts and hard-throws if result.failure is set. The FFI DecryptResult type is byte-identical between 0.26 and 0.27, so this rests on an assumed runtime change; if the fallible bulk API still throws top-level on unparseable input, the test hard-fails. Worth confirming against decryptBulkFallible's actual semantics (needs the live run).

6. LOW-MED — FFI query-return union hand-duplicated
packages/stack/src/encryption/helpers/index.ts:21
EncryptedQueryTerm re-spells the Encrypted | EncryptedQuery | EncryptedV3Query union that types.ts:67 (EncryptedQuery) also spells and that the sibling batch-encrypt-query.ts:89 derives via Awaited<ReturnType<typeof ffiEncryptQueryBulk>>. This PR had to widen two copies by hand; the next FFI arm will drift them. Derive from the FFI return type or a shared alias.

7. LOW-MED — v3 domains have no single source of truth
packages/stack/src/eql/v3/columns.ts:113,578
Each domain is hand-authored at ~5 parallel sites (literal const, empty subclass, AnyEncryptedV3Column arm, barrel re-export, factory) with no compile-time link on the src side — which is why this rename fanned out to ~1300 lines across 4 files, and why a class present-but-missing-from-the-union would be a valid domain no table can place, with no error at the definition site. Only the test catalog has satisfies Record<EqlV3TypeName, …> coverage. A data-driven record per domain would collapse the next add/rename to one edit.

8. LOW — redundant as cast drops the EncryptedV3Query arm
packages/stack/src/encryption/operations/batch-encrypt-query.ts:95
encryptedValues[i] as CipherStashEncrypted | CipherStashEncryptedQuery narrows away a real FFI return arm. Safe today (v3 scalar queries throw EQL_V3_QUERY_UNSUPPORTED) and formatEncryptedResult already accepts the full union, so the cast is unnecessary — but it's a latent type hole if a scalar/bulk query ever yields a v3 needle (would JSON-serialize into a malformed v2-style literal with no compile error). Drop the cast.

9. LOW — bulk decrypt drops the per-item error code
packages/stack/src/encryption/operations/bulk-decrypt.ts:50 (enshrined at error-codes.test.ts:272)
The per-item DecryptionError shape has no code slot, so any structured code on an FFI per-item error is dropped, and the new test (expect('code' in item).toBe(false)) blesses that inside a suite literally titled "FFI Error Code Preservation." Pre-existing (bulk-decrypt.ts is unchanged by this PR); flagged because the PR codifies it — worth a follow-up if per-item codes matter to callers.

Cleared (checked, not findings)

Efficiency; conventions/changeset-format; the EncryptedValue v2-pin (correct — DynamoDB is v2-only, no v3 payload can reach it); the packages/protect twin union (unreachable + out-of-scope: protect pins ffi 0.23 and has no v3 code); and the full rename mapping (no key/value swaps, no _ord/_ord_ore slips, no smallintinteger mismaps).

🤖 Generated with automated multi-agent code review via Claude Code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants