Skip to content

Commit f2e5b0c

Browse files
calvinbrewerclaude
authored andcommitted
feat(prisma-next): derive v3 domain catalog + codec ids from stack DOMAIN_REGISTRY
Codec ids are the DOMAIN_REGISTRY key verbatim (cipherstash/eql-v3/eql_v3_*@1) per the GA eql_v3_* domain naming; json is a first-class exposed domain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fd0af48 commit f2e5b0c

3 files changed

Lines changed: 198 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* v3 domain catalog — derived (never hand-maintained) from the imported
3+
* `DOMAIN_REGISTRY`. For each bare domain name we probe its factory and read
4+
* `getEqlType()` (the `public.*` native type), `getQueryCapabilities()`, and
5+
* `build()` (`cast_as` + `indexes`) off the instance. Codec id is
6+
* `cipherstash/eql-v3/${bareDomain}@1` where `bareDomain` is the registry key
7+
* VERBATIM (only the `public.` schema qualifier is stripped). GA domains are
8+
* `eql_v3_*`-prefixed, so ids read `cipherstash/eql-v3/eql_v3_text_search@1` —
9+
* deliberately: the id is a mechanical bijection with the registry key, never a
10+
* prettified transform decode would have to invert. The `eql-v3` token is a
11+
* logical version tag, NOT the operator schema.
12+
*/
13+
import {
14+
type AnyEncryptedV3Column,
15+
DOMAIN_REGISTRY,
16+
type QueryCapabilities,
17+
type V3ColumnFactory,
18+
} from '@cipherstash/stack/eql/v3'
19+
20+
export type V3CastAs =
21+
| 'string'
22+
| 'number'
23+
| 'bigint'
24+
| 'boolean'
25+
| 'date'
26+
| 'timestamp'
27+
| 'json'
28+
29+
export interface V3DomainMeta {
30+
readonly nativeType: `public.${string}`
31+
readonly bareDomain: string
32+
readonly castAs: V3CastAs
33+
readonly capabilities: QueryCapabilities
34+
readonly indexes: Readonly<Record<string, unknown>>
35+
}
36+
37+
export function toV3CodecId(bareDomain: string): string {
38+
return `cipherstash/eql-v3/${bareDomain}@1`
39+
}
40+
41+
const PROBE = '__probe__'
42+
43+
function metaFor(bareDomain: string, factory: V3ColumnFactory): V3DomainMeta {
44+
const column = factory(PROBE)
45+
const built = column.build()
46+
return {
47+
nativeType: column.getEqlType(),
48+
bareDomain,
49+
castAs: built.cast_as as V3CastAs,
50+
capabilities: column.getQueryCapabilities(),
51+
indexes: built.indexes,
52+
}
53+
}
54+
55+
const registryEntries: ReadonlyArray<readonly [string, V3ColumnFactory]> =
56+
Object.entries(DOMAIN_REGISTRY)
57+
58+
const metaEntries: Array<readonly [string, V3DomainMeta]> = registryEntries.map(
59+
([bareDomain, factory]) => [toV3CodecId(bareDomain), metaFor(bareDomain, factory)] as const,
60+
)
61+
62+
export const V3_DOMAIN_META_BY_CODEC_ID: ReadonlyMap<string, V3DomainMeta> = new Map(metaEntries)
63+
64+
export const V3_CODEC_IDS: readonly string[] = metaEntries.map(([id]) => id)
65+
66+
export const V3_FACTORY_BY_NATIVE_TYPE: ReadonlyMap<
67+
string,
68+
(name: string) => AnyEncryptedV3Column
69+
> = new Map(registryEntries.map(([, factory]) => [factory(PROBE).getEqlType(), factory] as const))
70+
71+
export function isOrdOreDomain(bareDomain: string): boolean {
72+
return bareDomain.endsWith('_ord_ore')
73+
}
74+
75+
export const EXPOSED_DOMAIN_ENTRIES: ReadonlyArray<readonly [string, V3DomainMeta]> =
76+
metaEntries.filter(([, meta]) => !isOrdOreDomain(meta.bareDomain))
77+
78+
export function envelopeTypeNameForCastAs(
79+
castAs: V3CastAs,
80+
):
81+
| 'EncryptedString'
82+
| 'EncryptedNumber'
83+
| 'EncryptedBigInt'
84+
| 'EncryptedDate'
85+
| 'EncryptedBoolean'
86+
| 'EncryptedJson' {
87+
switch (castAs) {
88+
case 'string':
89+
return 'EncryptedString'
90+
case 'number':
91+
return 'EncryptedNumber'
92+
case 'bigint':
93+
return 'EncryptedBigInt'
94+
case 'date':
95+
case 'timestamp':
96+
return 'EncryptedDate'
97+
case 'boolean':
98+
return 'EncryptedBoolean'
99+
case 'json':
100+
return 'EncryptedJson'
101+
}
102+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
EXPOSED_DOMAIN_ENTRIES,
4+
envelopeTypeNameForCastAs,
5+
isOrdOreDomain,
6+
toV3CodecId,
7+
V3_CODEC_IDS,
8+
V3_DOMAIN_META_BY_CODEC_ID,
9+
V3_FACTORY_BY_NATIVE_TYPE,
10+
} from '../../src/v3/catalog'
11+
12+
describe('v3 catalog derivation', () => {
13+
it('derives a concrete codec id per domain from the registry key verbatim', () => {
14+
expect(toV3CodecId('eql_v3_text_search')).toBe('cipherstash/eql-v3/eql_v3_text_search@1')
15+
expect(toV3CodecId('eql_v3_boolean')).toBe('cipherstash/eql-v3/eql_v3_boolean@1')
16+
})
17+
18+
it('records the public.* native type (never eql_v3.*) for text_search with full caps', () => {
19+
const meta = V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/eql_v3_text_search@1')
20+
expect(meta?.nativeType).toBe('public.eql_v3_text_search')
21+
expect(meta?.nativeType.startsWith('public.')).toBe(true)
22+
expect(meta?.castAs).toBe('string')
23+
expect(meta?.capabilities).toEqual({ equality: true, orderAndRange: true, freeTextSearch: true })
24+
expect(Object.keys(meta?.indexes ?? {}).sort()).toEqual(['match', 'ope', 'unique'])
25+
})
26+
27+
it('marks public.eql_v3_boolean as storage-only (no indexes, all capabilities false)', () => {
28+
const meta = V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/eql_v3_boolean@1')
29+
expect(meta?.nativeType).toBe('public.eql_v3_boolean')
30+
expect(meta?.castAs).toBe('boolean')
31+
expect(meta?.capabilities).toEqual({ equality: false, orderAndRange: false, freeTextSearch: false })
32+
expect(Object.keys(meta?.indexes ?? {})).toEqual([])
33+
})
34+
35+
it('exposes bigint as a first-class family with bigint castAs', () => {
36+
expect(V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/eql_v3_bigint@1')?.castAs).toBe('bigint')
37+
expect(V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/eql_v3_bigint_ord@1')?.nativeType).toBe(
38+
'public.eql_v3_bigint_ord',
39+
)
40+
})
41+
42+
it('derives timestamp castAs distinctly from date', () => {
43+
expect(V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/eql_v3_timestamp@1')?.castAs).toBe('timestamp')
44+
expect(V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/eql_v3_date@1')?.castAs).toBe('date')
45+
})
46+
47+
it('exposes json as a first-class searchable-JSON domain (ste_vec index)', () => {
48+
const meta = V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/eql_v3_json@1')
49+
expect(meta?.nativeType).toBe('public.eql_v3_json')
50+
expect(meta?.castAs).toBe('json')
51+
expect(meta?.capabilities).toEqual({
52+
equality: false,
53+
orderAndRange: false,
54+
freeTextSearch: false,
55+
searchableJson: true,
56+
})
57+
expect(Object.keys(meta?.indexes ?? {})).toEqual(['ste_vec'])
58+
expect(EXPOSED_DOMAIN_ENTRIES.some(([, m]) => m.bareDomain === 'eql_v3_json')).toBe(true)
59+
})
60+
61+
it('maps native type back to its concrete factory', () => {
62+
const factory = V3_FACTORY_BY_NATIVE_TYPE.get('public.eql_v3_integer_ord')
63+
expect(factory).toBeDefined()
64+
expect(factory?.('score').getEqlType()).toBe('public.eql_v3_integer_ord')
65+
})
66+
67+
it('maps castAs to the envelope type name (bigint distinct from number, json distinct)', () => {
68+
expect(envelopeTypeNameForCastAs('string')).toBe('EncryptedString')
69+
expect(envelopeTypeNameForCastAs('number')).toBe('EncryptedNumber')
70+
expect(envelopeTypeNameForCastAs('bigint')).toBe('EncryptedBigInt')
71+
expect(envelopeTypeNameForCastAs('date')).toBe('EncryptedDate')
72+
expect(envelopeTypeNameForCastAs('timestamp')).toBe('EncryptedDate')
73+
expect(envelopeTypeNameForCastAs('boolean')).toBe('EncryptedBoolean')
74+
expect(envelopeTypeNameForCastAs('json')).toBe('EncryptedJson')
75+
})
76+
77+
it('includes *_ord_ore in the full meta but excludes it from the exposed entries', () => {
78+
expect(V3_DOMAIN_META_BY_CODEC_ID.has('cipherstash/eql-v3/eql_v3_integer_ord_ore@1')).toBe(true)
79+
expect(isOrdOreDomain('eql_v3_integer_ord_ore')).toBe(true)
80+
const exposedBare = new Set(EXPOSED_DOMAIN_ENTRIES.map(([, m]) => m.bareDomain))
81+
expect([...exposedBare].some((b) => b.endsWith('_ord_ore'))).toBe(false)
82+
expect(exposedBare.has('eql_v3_integer_ord')).toBe(true)
83+
expect(exposedBare.has('eql_v3_bigint')).toBe(true)
84+
})
85+
86+
it('every codec id is v3-namespaced and unique', () => {
87+
expect(new Set(V3_CODEC_IDS).size).toBe(V3_CODEC_IDS.length)
88+
for (const id of V3_CODEC_IDS) expect(id.startsWith('cipherstash/eql-v3/')).toBe(true)
89+
})
90+
})

packages/stack/src/eql/v3/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ export {
6262
EncryptedTimestampOrdOreColumn,
6363
TEXT_SEARCH_EQL_TYPE,
6464
} from './columns'
65+
export {
66+
DOMAIN_REGISTRY,
67+
factoryForDomain,
68+
stripDomainSchema,
69+
type V3ColumnFactory,
70+
} from './domain-registry'
6571
export type {
6672
AnyV3Table,
6773
ColumnsOf,

0 commit comments

Comments
 (0)