Skip to content

Commit cdf40f5

Browse files
calvinbrewerclaude
authored andcommitted
feat(prisma-next): v3 codec-id closed union, traits, invariants, drift test
The closed union infers eqlType from getEqlType()'s return type — the stack's bundled d.ts drops the private definition field's type, so the EqlTypeForColumn infer-D path widens to public.${string} downstream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f2e5b0c commit cdf40f5

3 files changed

Lines changed: 263 additions & 0 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* v3 codec ids, traits, and migration invariants. Parallel to the v2
3+
* `constants.ts`; imports NO v2 codec/wire code. Codec ids are the generated
4+
* closed union derived from the imported catalog: `(codec id) ⟺ (public.*
5+
* domain)` is bijective, so the id set IS the catalog.
6+
*/
7+
import type { AnyEncryptedV3Column, QueryCapabilities } from '@cipherstash/stack/eql/v3'
8+
import {
9+
CIPHERSTASH_TRAIT_EQUALITY,
10+
CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH,
11+
CIPHERSTASH_TRAIT_ORDER_AND_RANGE,
12+
CIPHERSTASH_TRAIT_SEARCHABLE_JSON,
13+
} from './constants'
14+
15+
export {
16+
CIPHERSTASH_TRAIT_EQUALITY,
17+
CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH,
18+
CIPHERSTASH_TRAIT_ORDER_AND_RANGE,
19+
CIPHERSTASH_TRAIT_SEARCHABLE_JSON,
20+
}
21+
22+
/** Strip the `public.` schema qualifier at the type level. */
23+
type StripSchema<T> = T extends `public.${infer D}` ? D : never
24+
25+
/**
26+
* Per-column literal `eqlType`, inferred from the `getEqlType()` return type
27+
* rather than the stack's `EqlTypeForColumn<C>`. NOT a style choice: the
28+
* stack's bundled `.d.ts` emits the base class's private `definition` field
29+
* without its type, so `C extends EncryptedV3Column<infer D>` (which
30+
* `EqlTypeForColumn` uses) has nothing to infer `D` from and falls back to the
31+
* constraint — widening every id to `` `public.${string}` `` and silently
32+
* disabling the closed-union drift guards below. The getter's declared return
33+
* type survives d.ts bundling because each concrete subclass binds `D`
34+
* explicitly in its `extends` clause.
35+
*/
36+
type EqlTypeFromGetter<C> = C extends { getEqlType(): infer T } ? T : never
37+
38+
/** Compile-time closed union: one concrete codec id per catalog domain. A
39+
* catalog change that adds/drops a domain surfaces as a type error at the
40+
* `satisfies` line below. The domain segment is the GA registry key VERBATIM
41+
* (`eql_v3_*`); the leading `eql-v3` path token is the logical version tag. */
42+
export type CipherstashV3CodecId =
43+
`cipherstash/eql-v3/${StripSchema<EqlTypeFromGetter<AnyEncryptedV3Column>>}@1`
44+
45+
/**
46+
* PINNED literal tuple — the compile-time source of truth for the v3 codec-id
47+
* set. Hand-listed, one entry per catalog domain (INCLUDING the `*_ord_ore`
48+
* variants that authoring does not expose but the codec/derivation layer still
49+
* decodes). This is deliberately NOT `V3_CODEC_IDS as readonly CipherstashV3CodecId[]`
50+
* — that is a masking cast that asserts the derived array IS the union and so
51+
* catches NO drift. Here, catalog drift fails to COMPILE in both directions:
52+
* - `satisfies readonly CipherstashV3CodecId[]` fails if a listed id is no
53+
* longer a catalog domain (a domain removed / renamed).
54+
* - `_NoUnpinnedDomain` (below) fails if the catalog gained a domain not
55+
* pinned here.
56+
* The runtime drift test additionally asserts this equals the registry-derived
57+
* `V3_CODEC_IDS` (guarding the derivation itself).
58+
*/
59+
export const CIPHERSTASH_V3_CODEC_IDS = [
60+
'cipherstash/eql-v3/eql_v3_integer@1',
61+
'cipherstash/eql-v3/eql_v3_integer_eq@1',
62+
'cipherstash/eql-v3/eql_v3_integer_ord_ore@1',
63+
'cipherstash/eql-v3/eql_v3_integer_ord@1',
64+
'cipherstash/eql-v3/eql_v3_smallint@1',
65+
'cipherstash/eql-v3/eql_v3_smallint_eq@1',
66+
'cipherstash/eql-v3/eql_v3_smallint_ord_ore@1',
67+
'cipherstash/eql-v3/eql_v3_smallint_ord@1',
68+
'cipherstash/eql-v3/eql_v3_bigint@1',
69+
'cipherstash/eql-v3/eql_v3_bigint_eq@1',
70+
'cipherstash/eql-v3/eql_v3_bigint_ord_ore@1',
71+
'cipherstash/eql-v3/eql_v3_bigint_ord@1',
72+
'cipherstash/eql-v3/eql_v3_date@1',
73+
'cipherstash/eql-v3/eql_v3_date_eq@1',
74+
'cipherstash/eql-v3/eql_v3_date_ord_ore@1',
75+
'cipherstash/eql-v3/eql_v3_date_ord@1',
76+
'cipherstash/eql-v3/eql_v3_timestamp@1',
77+
'cipherstash/eql-v3/eql_v3_timestamp_eq@1',
78+
'cipherstash/eql-v3/eql_v3_timestamp_ord_ore@1',
79+
'cipherstash/eql-v3/eql_v3_timestamp_ord@1',
80+
'cipherstash/eql-v3/eql_v3_numeric@1',
81+
'cipherstash/eql-v3/eql_v3_numeric_eq@1',
82+
'cipherstash/eql-v3/eql_v3_numeric_ord_ore@1',
83+
'cipherstash/eql-v3/eql_v3_numeric_ord@1',
84+
'cipherstash/eql-v3/eql_v3_text@1',
85+
'cipherstash/eql-v3/eql_v3_text_eq@1',
86+
'cipherstash/eql-v3/eql_v3_text_match@1',
87+
'cipherstash/eql-v3/eql_v3_text_ord_ore@1',
88+
'cipherstash/eql-v3/eql_v3_text_ord@1',
89+
'cipherstash/eql-v3/eql_v3_text_search@1',
90+
'cipherstash/eql-v3/eql_v3_boolean@1',
91+
'cipherstash/eql-v3/eql_v3_real@1',
92+
'cipherstash/eql-v3/eql_v3_real_eq@1',
93+
'cipherstash/eql-v3/eql_v3_real_ord_ore@1',
94+
'cipherstash/eql-v3/eql_v3_real_ord@1',
95+
'cipherstash/eql-v3/eql_v3_double@1',
96+
'cipherstash/eql-v3/eql_v3_double_eq@1',
97+
'cipherstash/eql-v3/eql_v3_double_ord_ore@1',
98+
'cipherstash/eql-v3/eql_v3_double_ord@1',
99+
'cipherstash/eql-v3/eql_v3_json@1',
100+
] as const satisfies readonly CipherstashV3CodecId[]
101+
102+
// Bidirectional drift guard — the direction `satisfies` cannot catch. If the
103+
// catalog gains a domain that is not pinned above, `Exclude<...>` is non-`never`
104+
// and this assignment fails to compile with the offending id in the error.
105+
type _PinnedId = (typeof CIPHERSTASH_V3_CODEC_IDS)[number]
106+
type _NoUnpinnedDomain = [Exclude<CipherstashV3CodecId, _PinnedId>] extends [never]
107+
? true
108+
: ['catalog gained an unpinned codec id', Exclude<CipherstashV3CodecId, _PinnedId>]
109+
const _driftGuard: _NoUnpinnedDomain = true
110+
void _driftGuard
111+
112+
export const CIPHERSTASH_V3_CODEC_ID_SET: ReadonlySet<string> = new Set(CIPHERSTASH_V3_CODEC_IDS)
113+
114+
export function isCipherstashV3CodecId(id: string): id is CipherstashV3CodecId {
115+
return CIPHERSTASH_V3_CODEC_ID_SET.has(id)
116+
}
117+
118+
export function v3TraitsForCapabilities(caps: QueryCapabilities): readonly string[] {
119+
const traits: string[] = []
120+
if (caps.equality) traits.push(CIPHERSTASH_TRAIT_EQUALITY)
121+
if (caps.orderAndRange) traits.push(CIPHERSTASH_TRAIT_ORDER_AND_RANGE)
122+
if (caps.freeTextSearch) traits.push(CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH)
123+
if (caps.searchableJson) traits.push(CIPHERSTASH_TRAIT_SEARCHABLE_JSON)
124+
return traits
125+
}
126+
127+
export const CIPHERSTASH_V3_BASELINE_MIGRATION_NAME = '20260601T0100_install_eql_v3_bundle'
128+
129+
export const CIPHERSTASH_V3_INVARIANTS = {
130+
installBundle: 'cipherstash:install-eql-v3-bundle-v1',
131+
} as const
132+
133+
// v3's OWN extension identity (decision 1b) — DISTINCT from the v2
134+
// CIPHERSTASH_SPACE_ID / CIPHERSTASH_EXTENSION_VERSION. v2 and v3 are separate
135+
// entry points, never co-registered in one client; the distinct id keeps the
136+
// two extension descriptors cleanly separate. The shape mirrors the v2 id
137+
// (a bare, path-safe token — it may be used as a directory segment) rather
138+
// than a slashed namespace.
139+
export const CIPHERSTASH_V3_SPACE_ID = 'cipherstash-eql-v3' as const
140+
export const CIPHERSTASH_V3_EXTENSION_VERSION = '1.0.0' as const
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { DOMAIN_REGISTRY } from '@cipherstash/stack/eql/v3'
2+
import { describe, expect, it } from 'vitest'
3+
import { EXPOSED_DOMAIN_ENTRIES, V3_DOMAIN_META_BY_CODEC_ID } from '../../src/v3/catalog'
4+
5+
describe('FFI catalog invariants (non-structural — pinned)', () => {
6+
const bareDomains = Object.keys(DOMAIN_REGISTRY)
7+
8+
it('exposes json as a first-class searchable-JSON domain (ste_vec, no scalar caps)', () => {
9+
expect(bareDomains.filter((d) => d.includes('json'))).toEqual(['eql_v3_json'])
10+
const json = V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/eql_v3_json@1')
11+
expect(json?.nativeType).toBe('public.eql_v3_json')
12+
expect(json?.capabilities.searchableJson).toBe(true)
13+
expect(json?.capabilities.equality).toBe(false)
14+
expect(Object.keys(json?.indexes ?? {})).toEqual(['ste_vec'])
15+
expect(EXPOSED_DOMAIN_ENTRIES.some(([, m]) => m.bareDomain === 'eql_v3_json')).toBe(true)
16+
})
17+
18+
it('public.eql_v3_boolean is storage-only (advertises no equality, no indexes)', () => {
19+
const bool = V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/eql_v3_boolean@1')
20+
expect(bool?.nativeType).toBe('public.eql_v3_boolean')
21+
expect(bool?.capabilities.equality).toBe(false)
22+
expect(Object.keys(bool?.indexes ?? {})).toEqual([])
23+
// there is only one boolean domain (no boolean_eq)
24+
expect(bareDomains.filter((d) => d.startsWith('eql_v3_boolean'))).toEqual(['eql_v3_boolean'])
25+
})
26+
27+
it('the BigInt family IS exposed (base/_eq/_ord), *OrdOre is not', () => {
28+
const exposedBare = EXPOSED_DOMAIN_ENTRIES.map(([, m]) => m.bareDomain)
29+
expect(exposedBare).toEqual(
30+
expect.arrayContaining(['eql_v3_bigint', 'eql_v3_bigint_eq', 'eql_v3_bigint_ord']),
31+
)
32+
expect(exposedBare).not.toContain('eql_v3_bigint_ord_ore')
33+
expect(exposedBare.some((b) => b.endsWith('_ord_ore'))).toBe(false)
34+
})
35+
36+
it('plain _ord domains and text_search are OPE-backed; only _ord_ore keeps block-ORE', () => {
37+
for (const [, meta] of V3_DOMAIN_META_BY_CODEC_ID) {
38+
const indexKeys = Object.keys(meta.indexes)
39+
if (meta.bareDomain.endsWith('_ord_ore')) {
40+
expect(indexKeys).toContain('ore')
41+
expect(indexKeys).not.toContain('ope')
42+
} else if (meta.bareDomain.endsWith('_ord') || meta.bareDomain === 'eql_v3_text_search') {
43+
expect(indexKeys).toContain('ope')
44+
expect(indexKeys).not.toContain('ore')
45+
}
46+
}
47+
})
48+
49+
it('native types are public.* while operator SQL is eql_v3.* (schema split not collapsed)', () => {
50+
for (const [, meta] of V3_DOMAIN_META_BY_CODEC_ID) {
51+
expect(meta.nativeType.startsWith('public.')).toBe(true)
52+
expect(meta.nativeType.startsWith('eql_v3.')).toBe(false)
53+
}
54+
})
55+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
CIPHERSTASH_CODEC_IDS,
4+
CIPHERSTASH_EXTENSION_VERSION,
5+
CIPHERSTASH_SPACE_ID,
6+
} from '../../src/extension-metadata/constants'
7+
import {
8+
CIPHERSTASH_V3_BASELINE_MIGRATION_NAME,
9+
CIPHERSTASH_V3_CODEC_ID_SET,
10+
CIPHERSTASH_V3_CODEC_IDS,
11+
CIPHERSTASH_V3_EXTENSION_VERSION,
12+
CIPHERSTASH_V3_INVARIANTS,
13+
CIPHERSTASH_V3_SPACE_ID,
14+
isCipherstashV3CodecId,
15+
v3TraitsForCapabilities,
16+
} from '../../src/extension-metadata/constants-v3'
17+
import { V3_CODEC_IDS } from '../../src/v3/catalog'
18+
19+
describe('constants-v3', () => {
20+
it('the PINNED codec-id tuple equals the registry-derived set (drift guard, runtime side)', () => {
21+
// The compile-time `satisfies` + `Exclude` guards catch type drift; this
22+
// catches derivation drift (e.g. the registry iteration silently dropping
23+
// or reordering a domain). Both directions must hold.
24+
expect(new Set(CIPHERSTASH_V3_CODEC_IDS)).toEqual(new Set(V3_CODEC_IDS))
25+
expect(CIPHERSTASH_V3_CODEC_IDS.length).toBe(V3_CODEC_IDS.length)
26+
expect(new Set(CIPHERSTASH_V3_CODEC_IDS).size).toBe(CIPHERSTASH_V3_CODEC_IDS.length)
27+
})
28+
29+
it('v3 and v2 codec-id sets are disjoint', () => {
30+
const v2 = new Set<string>(CIPHERSTASH_CODEC_IDS)
31+
for (const id of CIPHERSTASH_V3_CODEC_IDS) expect(v2.has(id)).toBe(false)
32+
})
33+
34+
it('guard narrows only v3 ids', () => {
35+
expect(isCipherstashV3CodecId('cipherstash/eql-v3/eql_v3_text_eq@1')).toBe(true)
36+
expect(isCipherstashV3CodecId('cipherstash/string@1')).toBe(false)
37+
// GA registry keys are eql_v3_*-prefixed; the un-prefixed form is NOT an id.
38+
expect(isCipherstashV3CodecId('cipherstash/eql-v3/text_eq@1')).toBe(false)
39+
expect(CIPHERSTASH_V3_CODEC_ID_SET.has('cipherstash/eql-v3/eql_v3_text_eq@1')).toBe(true)
40+
})
41+
42+
it('derives cipherstash-namespaced traits from capabilities', () => {
43+
expect(
44+
[...v3TraitsForCapabilities({ equality: true, orderAndRange: true, freeTextSearch: true })].sort(),
45+
).toEqual(['cipherstash:equality', 'cipherstash:free-text-search', 'cipherstash:order-and-range'])
46+
expect(
47+
v3TraitsForCapabilities({ equality: false, orderAndRange: false, freeTextSearch: false }),
48+
).toEqual([])
49+
expect(
50+
v3TraitsForCapabilities({
51+
equality: false,
52+
orderAndRange: false,
53+
freeTextSearch: false,
54+
searchableJson: true,
55+
}),
56+
).toEqual(['cipherstash:searchable-json'])
57+
})
58+
59+
it('pins the migration name + invariant', () => {
60+
expect(CIPHERSTASH_V3_BASELINE_MIGRATION_NAME).toBe('20260601T0100_install_eql_v3_bundle')
61+
expect(CIPHERSTASH_V3_INVARIANTS.installBundle).toBe('cipherstash:install-eql-v3-bundle-v1')
62+
})
63+
64+
it('v3 extension identity is distinct from v2 (decision 1b — separate entry points)', () => {
65+
expect(CIPHERSTASH_V3_SPACE_ID).not.toBe(CIPHERSTASH_SPACE_ID)
66+
expect(CIPHERSTASH_V3_EXTENSION_VERSION).not.toBe(CIPHERSTASH_EXTENSION_VERSION)
67+
})
68+
})

0 commit comments

Comments
 (0)