feat: add frontend index ops and feature flag for db manager

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-06-01 16:55:31 +02:00
parent f20bcfb49f
commit 85adcd3fe6
3 changed files with 192 additions and 1 deletions
@@ -9,6 +9,7 @@ export type DbFeatures = {
primaryKeys?: boolean
defaultValues?: boolean
schemas?: boolean
indexes?: boolean
}
export function getDbFeatures(dbInput: DbInput): Required<DbFeatures> {
@@ -17,7 +18,10 @@ export function getDbFeatures(dbInput: DbInput): Required<DbFeatures> {
primaryKeys: true,
defaultValues: true,
enforcedForeignKeys: true,
schemas: dbInput.type !== 'ducklake' && dbSupportsSchemas(dbInput.resourceType)
schemas: dbInput.type !== 'ducklake' && dbSupportsSchemas(dbInput.resourceType),
// Index management is currently PostgreSQL-only (backend markers reject
// other dialects). Structured as a flag so it can be widened later.
indexes: dbInput.type !== 'ducklake' && dbInput.resourceType === 'postgresql'
}
if (dbInput.type == 'ducklake')
@@ -0,0 +1,99 @@
// Index management types and transforms.
//
// Indexes are managed imperatively (create / drop happen immediately), so unlike
// foreign keys we do not round-trip a structured column model back from the DB —
// the listing shows the canonical definition string returned by the database.
// Currently PostgreSQL-only; the backend markers reject other dialects.
/** A single key of a to-be-created index. */
export type IndexColumnInput = {
value: string
/** When true, `value` is an arbitrary SQL expression (e.g. `lower(email)`)
* rather than a plain column name. */
isExpression?: boolean
}
/** User-provided values for creating an index. */
export type CreateIndexInput = {
name?: string
columns: IndexColumnInput[]
unique?: boolean
/** Index access method: btree (default) / hash / gin / gist / brin / spgist. */
method?: string
/** Partial-index predicate (raw SQL, placed after WHERE). */
where?: string
/** Covering (INCLUDE) columns. */
include?: string[]
/** Build the index without locking the table against writes. */
concurrent?: boolean
}
/** Raw row shape returned by the LIST_INDEXES marker (PostgreSQL). */
export type RawIndex = {
index_name: string
index_def: string
is_unique: boolean | string | number
is_primary: boolean | string | number
is_valid: boolean | string | number
method: string
size_bytes: number | string | null
backs_constraint: boolean | string | number
}
/** Display model for an existing index. */
export type DbIndex = {
name: string
definition: string
isUnique: boolean
isPrimary: boolean
isValid: boolean
method: string
sizeBytes: number
/** Backs a PK/unique constraint — cannot be dropped directly (read-only). */
backsConstraint: boolean
}
// Postgres booleans can arrive as true/false, 't'/'f', or 1/0 depending on how
// the driver serializes the result — coerce all of them.
function coerceBool(v: boolean | string | number | null | undefined): boolean {
if (typeof v === 'boolean') return v
if (typeof v === 'number') return v !== 0
if (typeof v === 'string') return v === 't' || v.toLowerCase() === 'true' || v === '1'
return false
}
function coerceNumber(v: number | string | null | undefined): number {
if (typeof v === 'number') return v
if (typeof v === 'string') {
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
return 0
}
export function transformIndexes(raw: RawIndex[]): DbIndex[] {
if (!raw || !Array.isArray(raw)) return []
return raw.map((r) => ({
name: r.index_name,
definition: r.index_def,
isUnique: coerceBool(r.is_unique),
isPrimary: coerceBool(r.is_primary),
isValid: coerceBool(r.is_valid),
method: r.method,
sizeBytes: coerceNumber(r.size_bytes),
backsConstraint: coerceBool(r.backs_constraint)
}))
}
/** Human-readable byte size for display (e.g. "12 kB"). */
export function formatIndexSize(bytes: number): string {
if (!bytes || bytes <= 0) return '0 B'
const units = ['B', 'kB', 'MB', 'GB', 'TB']
let i = 0
let n = bytes
while (n >= 1024 && i < units.length - 1) {
n /= 1024
i++
}
return `${i === 0 ? n : n.toFixed(1)} ${units[i]}`
}
+88
View File
@@ -18,6 +18,12 @@ import {
transformSnowflakeForeignKeys,
type RawForeignKey
} from './apps/components/display/dbtable/queries/relationalKeys'
import {
transformIndexes,
type CreateIndexInput,
type DbIndex,
type RawIndex
} from './apps/components/display/dbtable/queries/indexes'
export type IDbTableOps = {
dbType: DbType
@@ -282,6 +288,88 @@ export function dbSchemaOpsWithPreviewScripts({
}
}
export type IDbIndexOps = {
listIndexes: (params: { tableKey: string; schema?: string }) => Promise<DbIndex[]>
createIndex: (params: {
tableKey: string
schema?: string
values: CreateIndexInput
}) => Promise<void>
previewCreateIndexSql: (params: {
tableKey: string
schema?: string
values: CreateIndexInput
}) => Promise<string>
dropIndex: (params: { name: string; schema?: string; concurrent?: boolean }) => Promise<void>
}
export function dbIndexOpsWithPreviewScripts({
workspace,
input
}: {
workspace: string
input: DbInput
}): IDbIndexOps {
const dbType = getDbType(input)
const dbArg = getDatabaseArg(input)
const language = getLanguageByResourceType(dbType)
function makeMarker(op: string, payload: Record<string, unknown>): string {
return `-- WM_INTERNAL_DB_${op} ${JSON.stringify(payload)}`
}
function createIndexPayload({
tableKey,
schema,
values
}: {
tableKey: string
schema?: string
values: CreateIndexInput
}): Record<string, unknown> {
return {
name: values.name?.trim() || undefined,
table: tableKey,
schema,
columns: values.columns,
unique: values.unique ?? false,
method: values.method,
where: values.where?.trim() || undefined,
include: values.include ?? [],
concurrent: values.concurrent ?? false
}
}
return {
listIndexes: async ({ tableKey, schema }) => {
const content = makeMarker('LIST_INDEXES', { table: tableKey, schema })
const result = (await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg }, language, content }
})) as RawIndex[]
return transformIndexes(result)
},
createIndex: async ({ tableKey, schema, values }) => {
const content = makeMarker('CREATE_INDEX', createIndexPayload({ tableKey, schema, values }))
await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg }, language, content }
})
},
previewCreateIndexSql: async ({ tableKey, schema, values }) => {
const content = makeMarker('CREATE_INDEX', createIndexPayload({ tableKey, schema, values }))
return expandMarker(workspace, language, content)
},
dropIndex: async ({ name, schema, concurrent }) => {
const content = makeMarker('DROP_INDEX', { name, schema, concurrent: concurrent ?? false })
await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg }, language, content }
})
}
}
}
export async function getDucklakeSchema({
workspace,
ducklake