feat: scope default instance db name to workspace (dt_/dl_) (#9699)

* feat: default instance db name to dt_/dl_ workspace scope

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cap instance db name at 63 chars and add unit tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-06-22 10:33:49 +02:00
committed by GitHub
parent 3a55800224
commit 4a8a724895
4 changed files with 101 additions and 12 deletions
@@ -41,8 +41,6 @@
}
return s
}
let DEFAULT_DATATABLE_DB_NAME = 'datatable_db'
</script>
<script lang="ts">
@@ -61,7 +59,7 @@
import Row from '../table/Row.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Tooltip from '../Tooltip.svelte'
import { isCustomInstanceDbEnabled } from './utils.svelte'
import { isCustomInstanceDbEnabled, getUnusedInstanceDbName } from './utils.svelte'
import { random_adj } from '../random_positive_adjetive'
import { sendUserToast } from '$lib/toast'
import { SettingService, WorkspaceService, type GetSettingsResponse } from '$lib/gen'
@@ -97,6 +95,18 @@
tempSettings.dataTables.splice(index, 1)
}
const customInstanceDbs = resource([() => $workspaceStore], SettingService.listCustomInstanceDbs)
function defaultInstanceDbName(): string {
const usedNames = [
...Object.keys(customInstanceDbs.current ?? {}),
...tempSettings.dataTables
.filter((d) => d.database.resource_type === 'instance' && d.database.resource_path)
.map((d) => d.database.resource_path!)
]
return getUnusedInstanceDbName('dt', $workspaceStore ?? '', usedNames)
}
function onNewDataTable() {
const name = tempSettings.dataTables.some((d) => d.name === 'main')
? `${random_adj()}_datatable`
@@ -105,13 +115,11 @@
name,
database: {
resource_type: $isCustomInstanceDbEnabled ? 'instance' : 'postgresql',
resource_path: $isCustomInstanceDbEnabled ? DEFAULT_DATATABLE_DB_NAME : undefined
resource_path: $isCustomInstanceDbEnabled ? defaultInstanceDbName() : undefined
}
})
}
const customInstanceDbs = resource([() => $workspaceStore], SettingService.listCustomInstanceDbs)
async function onSave() {
try {
if (
@@ -228,7 +236,7 @@
dataTable.database = {
resource_type,
resource_path:
resource_type === 'instance' ? DEFAULT_DATATABLE_DB_NAME : undefined
resource_type === 'instance' ? defaultInstanceDbName() : undefined
}
}
}
@@ -80,13 +80,11 @@
import Popover from '../meltComponents/Popover.svelte'
import TextInput from '../text_input/TextInput.svelte'
import { slide } from 'svelte/transition'
import { isCustomInstanceDbEnabled } from './utils.svelte'
import { isCustomInstanceDbEnabled, getUnusedInstanceDbName } from './utils.svelte'
import { resource } from 'runed'
import CustomInstanceDbSelect from './CustomInstanceDbSelect.svelte'
import Label from '../Label.svelte'
const DEFAULT_DUCKLAKE_CATALOG_NAME = 'ducklake_catalog'
type Props = {
ducklakeSettings: DucklakeSettingsType
ducklakeSavedSettings: DucklakeSettingsType
@@ -100,6 +98,16 @@
onDiscard = undefined
}: Props = $props()
function defaultInstanceDbName(): string {
const usedNames = [
...Object.keys(customInstanceDbs.current ?? {}),
...ducklakeSettings.ducklakes
.filter((d) => d.catalog.resource_type === 'instance' && d.catalog.resource_path)
.map((d) => d.catalog.resource_path!)
]
return getUnusedInstanceDbName('dl', $workspaceStore ?? '', usedNames)
}
function onNewDucklake() {
const name = ducklakeSettings.ducklakes.some((d) => d.name === 'main')
? `${random_adj()}_ducklake`
@@ -108,7 +116,7 @@
name,
catalog: {
resource_type: $isCustomInstanceDbEnabled ? 'instance' : 'postgresql',
resource_path: $isCustomInstanceDbEnabled ? DEFAULT_DUCKLAKE_CATALOG_NAME : undefined
resource_path: $isCustomInstanceDbEnabled ? defaultInstanceDbName() : undefined
},
storage: {
storage: undefined,
@@ -272,7 +280,7 @@
ducklake.catalog = {
resource_type,
resource_path:
resource_type === 'instance' ? DEFAULT_DUCKLAKE_CATALOG_NAME : undefined
resource_type === 'instance' ? defaultInstanceDbName() : undefined
}
}
}
@@ -7,3 +7,31 @@ export let isCustomInstanceDbEnabled = derived(
[superadmin],
([superadmin_]) => superadmin_ && !isCloudHosted()
)
// Postgres caps identifiers at 63 bytes; the backend rejects longer db names.
const MAX_INSTANCE_DB_NAME_LEN = 63
// Builds a default instance database name scoped to the workspace (e.g. `dt_myworkspace`),
// appending `_1`, `_2`... until an unused name is found. Workspace ids may contain hyphens,
// which are not valid in unquoted postgres identifiers, so they are replaced with underscores.
// The result is truncated to keep it within the postgres identifier length limit.
export function getUnusedInstanceDbName(
prefix: string,
workspaceId: string,
usedNames: Iterable<string>
): string {
const used = new Set(usedNames)
const base = `${prefix}_${workspaceId.toLowerCase().replace(/-/g, '_')}`.slice(
0,
MAX_INSTANCE_DB_NAME_LEN
)
if (!used.has(base)) return base
let i = 1
let candidate: string
do {
const suffix = `_${i}`
candidate = base.slice(0, MAX_INSTANCE_DB_NAME_LEN - suffix.length) + suffix
i++
} while (used.has(candidate))
return candidate
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { getUnusedInstanceDbName } from './utils.svelte'
describe('getUnusedInstanceDbName', () => {
it('scopes the name to the workspace with the given prefix', () => {
expect(getUnusedInstanceDbName('dt', 'myworkspace', [])).toBe('dt_myworkspace')
expect(getUnusedInstanceDbName('dl', 'myworkspace', [])).toBe('dl_myworkspace')
})
it('lowercases and replaces hyphens with underscores', () => {
expect(getUnusedInstanceDbName('dt', 'My-Team', [])).toBe('dt_my_team')
})
it('appends an incrementing suffix when the name is already used', () => {
expect(getUnusedInstanceDbName('dt', 'abc', ['dt_abc'])).toBe('dt_abc_1')
expect(getUnusedInstanceDbName('dt', 'abc', ['dt_abc', 'dt_abc_1'])).toBe('dt_abc_2')
})
it('skips over already-used suffixed names', () => {
expect(getUnusedInstanceDbName('dt', 'abc', ['dt_abc', 'dt_abc_2'])).toBe('dt_abc_1')
expect(getUnusedInstanceDbName('dt', 'abc', ['dt_abc', 'dt_abc_1', 'dt_abc_2'])).toBe(
'dt_abc_3'
)
})
it('accepts any iterable of used names', () => {
expect(getUnusedInstanceDbName('dt', 'abc', new Set(['dt_abc']))).toBe('dt_abc_1')
})
it('truncates the base name to the postgres 63-char identifier limit', () => {
const longId = 'w'.repeat(100)
const name = getUnusedInstanceDbName('dt', longId, [])
expect(name.length).toBe(63)
expect(name.startsWith('dt_')).toBe(true)
})
it('keeps the result within 63 chars even when appending a suffix', () => {
const longId = 'w'.repeat(100)
const base = getUnusedInstanceDbName('dt', longId, []) // length 63
const name = getUnusedInstanceDbName('dt', longId, [base])
expect(name.length).toBeLessThanOrEqual(63)
expect(name.endsWith('_1')).toBe(true)
})
})