Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b8d8812
docs(adr): add ADR 210 — Index-type registry
SevInf May 6, 2026
0285be6
feat(sql-contract): add index-type registry primitive
SevInf May 6, 2026
bf19132
refactor(sql-contract): rename IndexDef using/config to type/options
SevInf May 6, 2026
bf7097c
feat(sql-contract-ts): thread __indexTypes phantom map through the co…
SevInf May 6, 2026
1fd8ff5
feat(sql-contract): wire index-type registry into validateContract
SevInf May 6, 2026
baa1d98
feat(target-postgres): emit USING/WITH from createIndex when type/opt…
SevInf May 6, 2026
7e5dc0e
feat(family-sql): consider type/options when matching contract vs sch…
SevInf May 6, 2026
50ffaec
refactor(extension-paradedb): register bm25 via the index-type regist…
SevInf May 6, 2026
24e5fb6
feat(sql-contract-ts): call-site narrowing for constraints.index({ ty…
SevInf May 6, 2026
77e09b7
feat(sql-contract-psl): support type and options on @@index in PSL
SevInf May 6, 2026
c8288d3
test(integration): PSL @@index type/options round-trip with paradedb …
SevInf May 6, 2026
8866bc1
feat(adapter-postgres): introspect index type and options into SqlInd…
SevInf May 6, 2026
1946257
chore(target-postgres): drop non-null assertions in createIndex with-…
SevInf May 6, 2026
c3e2984
fix(target-postgres): thread contract index type/options through inde…
SevInf May 6, 2026
6f50fb6
fix(sql-contract): require IndexTypeRegistry in validateSqlStorage; b…
SevInf May 6, 2026
0325b0f
docs(adr-210): clarify that strict-key rejection on options is regist…
SevInf May 6, 2026
dc0ba9a
test(integration): cover PSL @@index unregistered type and empty options
SevInf May 6, 2026
a3e2f86
refactor(sql-contract-ts, emitter): tighten index option spreads to !…
SevInf May 6, 2026
1be6538
refactor(sql-contract-ts): remove WildcardIndexTypes; default IndexTy…
SevInf May 6, 2026
92f317c
refactor(sql-contract-ts): dedupe index-type merge machinery and tigh…
SevInf May 6, 2026
de385e8
refactor(sql-contract,paradedb): drop __indexTypes phantom; pack stor…
SevInf May 6, 2026
9ab91b7
refactor(sql-contract-ts): genericize createConstraintsDsl over Index…
SevInf May 6, 2026
143bd11
review-cleanup: lock narrowing boundary, tighten error assertion, dro…
SevInf May 6, 2026
d0b7fa9
refactor(sql-contract-ts)!: drop single-field index() shortcut for pe…
SevInf May 6, 2026
ea057e5
refactor(sql-contract)!: validate index types at the IR -> Contract l…
SevInf May 7, 2026
a472302
docs(paradedb): fix stale constraints.index example after array-only …
wmadden May 9, 2026
3672867
refactor(sql-contract): move validateIndexTypes out of JSON-internal …
wmadden May 9, 2026
2a8232d
review-cleanup: tighten index-type validation, planner, introspection…
wmadden May 9, 2026
8c6195f
docs(adr-210): rewrite for accessibility — grounding example, decisio…
wmadden May 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions docs/architecture docs/adrs/ADR 210 - Index-type registry.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export type ColumnTypeDescriptor<TCodecId extends string = string> = {
export interface IndexDef {
readonly columns: readonly string[];
readonly name?: string;
readonly using?: string;
readonly config?: Record<string, unknown>;
readonly type?: string;
readonly options?: Record<string, unknown>;
}

export interface ForeignKeyDefaultsState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ describe('descriptor exports', () => {
const index: IndexDef = {
columns: ['email'],
name: 'user_email_idx',
using: 'btree',
config: { fillfactor: 90 },
type: 'btree',
options: { fillfactor: 90 },
};

expect(index).toEqual({
columns: ['email'],
name: 'user_email_idx',
using: 'btree',
config: { fillfactor: 90 },
type: 'btree',
options: { fillfactor: 90 },
});
});
});
2 changes: 2 additions & 0 deletions packages/2-sql/1-core/contract/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
],
"exports": {
"./factories": "./dist/factories.mjs",
"./index-type-validation": "./dist/index-type-validation.mjs",
"./index-types": "./dist/index-types.mjs",
"./pack-types": "./dist/pack-types.mjs",
"./types": "./dist/types.mjs",
"./validate": "./dist/validate.mjs",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../index-type-validation';
9 changes: 9 additions & 0 deletions packages/2-sql/1-core/contract/src/exports/index-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export {
createIndexTypeRegistry,
defineIndexTypes,
type IndexTypeBuilder,
type IndexTypeEntry,
type IndexTypeMap,
type IndexTypeRegistration,
type IndexTypeRegistry,
} from '../index-types';
37 changes: 37 additions & 0 deletions packages/2-sql/1-core/contract/src/index-type-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Contract } from '@prisma-next/contract/types';
import { ContractValidationError } from '@prisma-next/contract/validate-contract';
import { type } from 'arktype';
import type { IndexTypeRegistry } from './index-types';
import type { SqlStorage } from './types';

export function validateIndexTypes(
contract: Contract<SqlStorage>,
indexTypeRegistry: IndexTypeRegistry,
): void {
for (const [tableName, table] of Object.entries(contract.storage.tables)) {
for (const index of table.indexes) {
if (index.type === undefined && index.options !== undefined) {
throw new ContractValidationError(
`Table "${tableName}" index on columns [${index.columns.join(', ')}] has options without a type`,
'storage',
);
}
if (index.type === undefined) continue;
const entry = indexTypeRegistry.get(index.type);
if (entry === undefined) {
throw new ContractValidationError(
`Table "${tableName}" index on columns [${index.columns.join(', ')}] uses unregistered index type "${index.type}"`,
'storage',
);
}
const optionsValue = index.options ?? {};
const result = entry.options(optionsValue);
if (result instanceof type.errors) {
throw new ContractValidationError(
`Table "${tableName}" index on columns [${index.columns.join(', ')}] has invalid options for type "${index.type}": ${result.summary}`,
'storage',
);
}
}
}
}
77 changes: 77 additions & 0 deletions packages/2-sql/1-core/contract/src/index-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { Type } from 'arktype';

export interface IndexTypeEntry<TOptions = unknown> {
readonly type: string;
readonly options: Type<TOptions>;
}

export type IndexTypeMap = { readonly [K in string]: { readonly options: unknown } };

export interface IndexTypeRegistration<TMap extends IndexTypeMap = Record<never, never>> {
readonly IndexTypes: TMap;
readonly entries: ReadonlyArray<IndexTypeEntry>;
}

export interface IndexTypeBuilder<TMap extends IndexTypeMap = Record<never, never>>
extends IndexTypeRegistration<TMap> {
add<TLit extends string, TOpts>(
typeLiteral: TLit,
entry: { readonly options: Type<TOpts> },
): IndexTypeBuilder<TMap & Record<TLit, { readonly options: TOpts }>>;
}

class IndexTypeBuilderImpl<TMap extends IndexTypeMap> implements IndexTypeBuilder<TMap> {
readonly entries: ReadonlyArray<IndexTypeEntry>;
readonly IndexTypes: TMap;

constructor(entries: ReadonlyArray<IndexTypeEntry>) {
this.entries = entries;
this.IndexTypes = {} as TMap;
}

add<TLit extends string, TOpts>(
typeLiteral: TLit,
entry: { readonly options: Type<TOpts> },
): IndexTypeBuilder<TMap & Record<TLit, { readonly options: TOpts }>> {
if (this.entries.some((e) => e.type === typeLiteral)) {
throw new Error(`Index type "${typeLiteral}" is already declared in this builder`);
}
return new IndexTypeBuilderImpl<TMap & Record<TLit, { readonly options: TOpts }>>([
...this.entries,
{ type: typeLiteral, options: entry.options as Type<unknown> },
]);
}
}

export function defineIndexTypes(): IndexTypeBuilder<Record<never, never>> {
return new IndexTypeBuilderImpl([]);
}

export interface IndexTypeRegistry {
register(entry: IndexTypeEntry): void;
get(typeLiteral: string): IndexTypeEntry | undefined;
has(typeLiteral: string): boolean;
}

class IndexTypeRegistryImpl implements IndexTypeRegistry {
private readonly entries = new Map<string, IndexTypeEntry>();

register(entry: IndexTypeEntry): void {
if (this.entries.has(entry.type)) {
throw new Error(`Index type "${entry.type}" is already registered`);
}
this.entries.set(entry.type, entry);
}

get(typeLiteral: string): IndexTypeEntry | undefined {
return this.entries.get(typeLiteral);
}

has(typeLiteral: string): boolean {
return this.entries.has(typeLiteral);
}
}

export function createIndexTypeRegistry(): IndexTypeRegistry {
return new IndexTypeRegistryImpl();
}
2 changes: 2 additions & 0 deletions packages/2-sql/1-core/contract/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './exports/factories';
export * from './exports/index-type-validation';
export * from './exports/index-types';
export * from './exports/types';
export * from './exports/validate';
export * from './exports/validators';
12 changes: 2 additions & 10 deletions packages/2-sql/1-core/contract/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,8 @@ export type UniqueConstraint = {
export type Index = {
readonly columns: readonly string[];
readonly name?: string;
/**
* Optional access method identifier.
* Extension-specific methods are represented as strings and interpreted
* by the owning extension package.
*/
readonly using?: string;
/**
* Optional extension-owned index configuration payload.
*/
readonly config?: Record<string, unknown>;
readonly type?: string;
readonly options?: Record<string, unknown>;
};

export type ForeignKeyReferences = {
Expand Down
11 changes: 7 additions & 4 deletions packages/2-sql/1-core/contract/src/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ const UniqueConstraintSchema = type.declare<UniqueConstraint>().type({
export const IndexSchema = type({
columns: type.string.array().readonly(),
'name?': 'string',
'using?': 'string',
'config?': 'Record<string, unknown>',
'type?': 'string',
'options?': 'Record<string, unknown>',
});

export const ForeignKeyReferencesSchema = type.declare<ForeignKeyReferences>().type({
Expand Down Expand Up @@ -375,6 +375,9 @@ export function validateStorageSemantics(storage: SqlStorage): string[] {
seenUniqueDefinitions.add(signature);
}

const sortOptions = (o: Record<string, unknown> | undefined): Record<string, unknown> | null =>
o ? Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b))) : null;

const seenIndexDefinitions = new Set<string>();
for (const index of table.indexes) {
const duplicateColumn = findDuplicateValue(index.columns);
Expand All @@ -384,8 +387,8 @@ export function validateStorageSemantics(storage: SqlStorage): string[] {

const signature = JSON.stringify({
columns: index.columns,
using: index.using ?? null,
config: index.config ?? null,
type: index.type ?? null,
options: sortOptions(index.options),
});
if (seenIndexDefinitions.has(signature)) {
errors.push(
Expand Down
83 changes: 83 additions & 0 deletions packages/2-sql/1-core/contract/test/index-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { type } from 'arktype';
import { describe, expect, it } from 'vitest';
import { createIndexTypeRegistry, defineIndexTypes } from '../src/index-types';

describe('defineIndexTypes builder', () => {
it('starts empty', () => {
const builder = defineIndexTypes();
expect(builder.entries).toEqual([]);
});

it('add() yields a new builder with the entry appended', () => {
const optionsValidator = type({ key_field: 'string' });
const builder = defineIndexTypes().add('bm25', { options: optionsValidator });
expect(builder.entries).toHaveLength(1);
expect(builder.entries[0]?.type).toBe('bm25');
expect(builder.entries[0]?.options).toBe(optionsValidator);
});

it('add() composes multiple distinct entries in order', () => {
const a = type({ a: 'string' });
const b = type({ b: 'string' });
const builder = defineIndexTypes().add('alpha', { options: a }).add('beta', { options: b });
expect(builder.entries.map((e) => e.type)).toEqual(['alpha', 'beta']);
});

it('add() does not mutate the prior builder', () => {
const opts = type({ x: 'string' });
const a = defineIndexTypes();
const b = a.add('alpha', { options: opts });
expect(a.entries).toEqual([]);
expect(b.entries).toHaveLength(1);
});

it('add() throws on duplicate type literal in the same builder', () => {
const opts = type({ x: 'string' });
const builder = defineIndexTypes().add('dup', { options: opts });
expect(() => builder.add('dup', { options: opts })).toThrow(/already declared/);
});
});

describe('createIndexTypeRegistry', () => {
it('register stores an entry; get returns it', () => {
const registry = createIndexTypeRegistry();
const entry = { type: 'demo', options: type({ fillfactor: 'number' }) };
registry.register(entry);
expect(registry.get('demo')).toBe(entry);
});

it('has reports presence', () => {
const registry = createIndexTypeRegistry();
expect(registry.has('absent')).toBe(false);
registry.register({ type: 'present', options: type({ k: 'string' }) });
expect(registry.has('present')).toBe(true);
});

it('get returns undefined for unknown types', () => {
const registry = createIndexTypeRegistry();
expect(registry.get('nonesuch')).toBeUndefined();
});

it('register throws on duplicate type', () => {
const registry = createIndexTypeRegistry();
const opts = type({ key: 'string' });
registry.register({ type: 'gin', options: opts });
expect(() => registry.register({ type: 'gin', options: opts })).toThrow(/already registered/);
});

it('error message names the offending type', () => {
const registry = createIndexTypeRegistry();
registry.register({ type: 'gist', options: type({ k: 'string' }) });
expect(() => registry.register({ type: 'gist', options: type({ k: 'string' }) })).toThrow(
/gist/,
);
});

it('two registries are independent', () => {
const a = createIndexTypeRegistry();
const b = createIndexTypeRegistry();
a.register({ type: 'shared', options: type({ k: 'string' }) });
expect(a.has('shared')).toBe(true);
expect(b.has('shared')).toBe(false);
});
});
Loading
Loading