1+ import type { Result } from '@byteslice/result'
12import {
23 and ,
34 asc ,
@@ -17,9 +18,11 @@ import {
1718import type { PgTable } from 'drizzle-orm/pg-core'
1819import type { AuditConfig } from '@/encryption/operations/base-operation'
1920import type { AnyEncryptedV3Column , AnyV3Table } from '@/eql/v3'
21+ import type { EncryptionError } from '@/errors'
2022import type { LockContext } from '@/identity'
2123import type { ColumnSchema } from '@/schema'
2224import { matchNeedleError } from '@/schema/match-defaults'
25+ import type { BulkEncryptedData , Encrypted } from '@/types'
2326import { getEqlV3Column } from './column.js'
2427import {
2528 extractEncryptionSchemaV3 ,
@@ -48,11 +51,11 @@ type OperandEncryptionClient = {
4851 encrypt (
4952 plaintext : never ,
5053 opts : { table : AnyV3Table ; column : AnyEncryptedV3Column } ,
51- ) : unknown
54+ ) : ChainableOperation < Encrypted >
5255 bulkEncrypt ?(
5356 plaintexts : never ,
5457 opts : { table : AnyV3Table ; column : AnyEncryptedV3Column } ,
55- ) : unknown
58+ ) : ChainableOperation < BulkEncryptedData >
5659}
5760
5861/**
@@ -91,13 +94,33 @@ export type EncryptionOperatorCallOpts = {
9194 audit ?: AuditConfig
9295}
9396
94- type ChainableOperation = {
95- withLockContext ( lockContext : LockContext ) : ChainableOperation
96- audit ( config : AuditConfig ) : ChainableOperation
97- then : PromiseLike < {
98- data ?: unknown
99- failure ?: { message : string }
100- } > [ 'then' ]
97+ /**
98+ * An SDK encryption operation after its lock context has been applied: still
99+ * auditable and awaitable, but not re-lockable. `withLockContext` returns this,
100+ * not the full {@link ChainableOperation}, mirroring the real
101+ * `EncryptOperationWithLockContext`, which drops `withLockContext` (you cannot
102+ * lock-context twice). Modelling that is what lets the real client type satisfy
103+ * the structural surface with no cast.
104+ */
105+ type AuditableOperation < T > = {
106+ audit ( config : AuditConfig ) : AuditableOperation < T >
107+ then : PromiseLike < Result < T , EncryptionError > > [ 'then' ]
108+ }
109+
110+ /**
111+ * The subset of an SDK encryption operation this factory drives: the fluent
112+ * `withLockContext`/`audit` chain, and a `then` that resolves the operation's
113+ * `Result`. Generic over the resolved payload `T` so `encrypt` carries an
114+ * `Encrypted` envelope and `bulkEncrypt` a `BulkEncryptedData`, rather than the
115+ * `unknown` this erased to before.
116+ *
117+ * Structural, not the concrete `EncryptOperation` class, because the client is
118+ * passed in and the factory must accept any implementation with this surface.
119+ */
120+ type ChainableOperation < T > = {
121+ withLockContext ( lockContext : LockContext ) : AuditableOperation < T >
122+ audit ( config : AuditConfig ) : AuditableOperation < T >
123+ then : PromiseLike < Result < T , EncryptionError > > [ 'then' ]
101124}
102125
103126async function mapWithConcurrency < T , R > (
@@ -225,10 +248,10 @@ export function createEncryptionOperatorsV3(
225248 const ORDERING_INDEXES = [ 'ore' , 'ope' ] as const
226249 const MATCH_INDEXES = [ 'match' ] as const
227250
228- function applyOperationOptions (
229- op : ChainableOperation ,
251+ function applyOperationOptions < T > (
252+ op : ChainableOperation < T > ,
230253 opts ?: EncryptionOperatorCallOpts ,
231- ) : ChainableOperation {
254+ ) : AuditableOperation < T > {
232255 const lockContext = opts ?. lockContext ?? defaults . lockContext
233256 const audit = opts ?. audit ?? defaults . audit
234257 const withLock = lockContext ? op . withLockContext ( lockContext ) : op
@@ -298,12 +321,13 @@ export function createEncryptionOperatorsV3(
298321 client . encrypt ( value as never , {
299322 table : ctx . table ,
300323 column : ctx . builder ,
301- } ) as unknown as ChainableOperation ,
324+ } ) ,
302325 opts ,
303326 )
304327 if ( result . failure ) {
305328 throw operandFailure ( ctx , operator , result . failure . message )
306329 }
330+ // `result.data` is now `Encrypted` — the storage envelope — not `unknown`.
307331 return sql `${ JSON . stringify ( result . data ) } `
308332 }
309333
@@ -336,19 +360,22 @@ export function createEncryptionOperatorsV3(
336360 bulkEncrypt ( values . map ( ( plaintext ) => ( { plaintext } ) ) as never , {
337361 table : ctx . table ,
338362 column : ctx . builder ,
339- } ) as unknown as ChainableOperation ,
363+ } ) ,
340364 opts ,
341365 )
342366 if ( result . failure ) {
343367 throw operandFailure ( ctx , operator , result . failure . message )
344368 }
345369
346- const encrypted = result . data as Array < { data : unknown } > | undefined
347- if ( ! Array . isArray ( encrypted ) || encrypted . length !== values . length ) {
370+ // `result.data` is `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]`
371+ // — not `unknown`. The length check stays: a position-stable contract
372+ // violation must not silently truncate the predicate.
373+ const encrypted = result . data
374+ if ( encrypted . length !== values . length ) {
348375 throw operandFailure (
349376 ctx ,
350377 operator ,
351- `bulk encryption returned ${ Array . isArray ( encrypted ) ? encrypted . length : 0 } terms for ${ values . length } values.` ,
378+ `bulk encryption returned ${ encrypted . length } terms for ${ values . length } values.` ,
352379 )
353380 }
354381 return encrypted . map ( ( term ) => sql `${ JSON . stringify ( term . data ) } ` )
0 commit comments