import { assertWorkspaceSlug, type Locale, type Role } from '@wellformed/shared'; import { conflict } from '../errors.js'; import type { DemoChoice } from '../db/demo.js'; import { seed } from '../db/seed.js'; import { transactControl, type ControlDb } from '../control/db.js'; import { createWorkspace, deleteWorkspace, getWorkspaceBySlug, setMembership, type WorkspaceRecord, } from '../control/workspaces.js'; import type { TenantRegistry } from './registry.js'; export interface ProvisionInput { slug: string; name: string; /** The account that gets the `admin` membership. */ ownerId: string; /** * What to build inside it: nothing, the demo's tables and views, or the * demo with its data too. Omitted means empty — starting from your own * spreadsheet is the expected road in. */ demo?: DemoChoice; /** * The language the workspace opens in — usually the browser language of * whoever is creating it. Changeable afterwards in workspace settings. * The demo, when asked for, is built in this language. */ locale?: Locale; } /** * Create a workspace: a control record, an admin membership, a database file * and, optionally, something to look at inside it. * * The control record and the file are created in that order but rolled back * together. A control row pointing at a database that failed to build would * hand its members a workspace that 500s on every request, which is worse than * the signup simply failing. */ export function provisionWorkspace( control: ControlDb, registry: TenantRegistry, input: ProvisionInput, ): WorkspaceRecord { assertWorkspaceSlug(input.slug); if (getWorkspaceBySlug(control, input.slug)) { throw conflict(`The workspace address "${input.slug}" is already taken.`); } const workspace = transactControl(control, () => { const created = createWorkspace(control, { slug: input.slug, name: input.name, createdBy: input.ownerId, locale: input.locale, }); setMembership(control, created.id, input.ownerId, 'admin' satisfies Role); return created; }); try { const db = registry.acquire(workspace); try { const demo = input.demo ?? 'none'; if (demo !== 'none') { seed(db, { quiet: true, locale: workspace.locale, mode: demo }); } } finally { registry.release(workspace.id); } } catch (error) { // Undo both halves, in the order that leaves nothing orphaned. try { registry.destroy(workspace); } finally { deleteWorkspace(control, workspace.id); } throw error; } return workspace; } /** * Suggest a free slug near `base`, so signup does not bounce someone over a * name collision they cannot see. */ export function availableSlug(control: ControlDb, base: string): string { if (!getWorkspaceBySlug(control, base)) { try { assertWorkspaceSlug(base); return base; } catch { // Fall through to the suffixed attempts below. } } for (let n = 2; n < 500; n++) { const candidate = `${base.slice(0, 36)}-${n}`; try { assertWorkspaceSlug(candidate); } catch { continue; } if (!getWorkspaceBySlug(control, candidate)) return candidate; } throw conflict('Could not find a free workspace address. Pick one yourself.'); }