>;
+ readOnlyExistingColumns?: boolean;
+ value: StudioTableSchemaChange;
+}
+
+/**
+ * Renders a single editable row in the table schema editor for a specific column.
+ *
+ * Features:
+ * - Toggle primary key participation via icon button
+ * - Toggle NULL/NOT NULL via checkbox
+ * - Displays column name, type, default value, and constraints
+ * - Provides dropdown menu for editing or removing the column
+ *
+ * It uses immutable updates with `produce` to keep the parent schema state consistent.
+ */
+export function StudioColumnSchemaEditor({
+ columnIndex,
+ highlightSchemaChanges,
+ onChange,
+ readOnlyExistingColumns,
+ value,
+}: StudioColumnSchemaEditorProps): JSX.Element | null {
+ const { openModal } = useModal();
+ const column = value.columns[columnIndex];
+
+ const isPrimaryKey =
+ column?.new?.constraint?.primaryKey ||
+ value.constraints.some((constraint) =>
+ (constraint.new?.primaryColumns ?? []).includes(column?.new?.name ?? "")
+ );
+
+ const editableColumn = column?.new;
+
+ const onPrimaryKeyClicked = useCallback((): void => {
+ if (!editableColumn) {
+ return;
+ }
+
+ onChange((prev) =>
+ produce(prev, (draft) => {
+ const pkConstraint = draft.constraints.find((c) => c.new?.primaryKey);
+
+ if (isPrimaryKey) {
+ // Remove column from primary key list
+ if (pkConstraint?.new?.primaryColumns) {
+ pkConstraint.new.primaryColumns =
+ pkConstraint.new.primaryColumns.filter(
+ (name) => name !== editableColumn.name
+ );
+
+ if (pkConstraint.new.primaryColumns.length === 0) {
+ draft.constraints = draft.constraints.filter(
+ (c) => c.key !== pkConstraint?.key
+ );
+ }
+ }
+ return;
+ }
+
+ // Add column to primary key list or create constraint if none exists
+ if (pkConstraint?.new) {
+ pkConstraint.new.primaryColumns = [
+ ...(pkConstraint.new.primaryColumns ?? []),
+ editableColumn.name,
+ ];
+ return;
+ }
+
+ draft.constraints.push({
+ new: {
+ primaryKey: true,
+ primaryColumns: [editableColumn.name],
+ },
+ old: null,
+ key: window.crypto.randomUUID(),
+ });
+ })
+ );
+ }, [editableColumn, onChange, isPrimaryKey]);
+
+ const onNullClicked = useCallback(
+ (checkedState: boolean): void => {
+ if (!column) {
+ return;
+ }
+
+ onChange((prev) =>
+ produce(prev, (draft) => {
+ const draftColumn = draft.columns.find((c) => c.key === column.key);
+ if (!draftColumn?.new) {
+ return;
+ }
+
+ if (!draftColumn.new.constraint) {
+ draftColumn.new.constraint = {};
+ }
+
+ draftColumn.new.constraint.notNull = !checkedState;
+ })
+ );
+ },
+ [column, onChange]
+ );
+
+ const handleEditColumn = useCallback((): void => {
+ if (!column?.new) {
+ return;
+ }
+
+ openModal(StudioColumnEditorModal, {
+ defaultValue: column.new,
+ onConfirm: (newColumnDef: StudioTableColumn) => {
+ onChange((prev) =>
+ produce(prev, (draft) => {
+ const targetColumn = draft.columns.find(
+ (draftColumn) => draftColumn.key === column?.key
+ );
+ if (!targetColumn) {
+ return;
+ }
+
+ targetColumn.new = newColumnDef;
+ })
+ );
+ },
+ schemaChanges: value,
+ });
+ }, [onChange, value, column, openModal]);
+
+ const handleRemoveColumn = useCallback((): void => {
+ onChange((prev) =>
+ produce(prev, (draft) => {
+ draft.columns = draft.columns.filter((c) => c.key !== column?.key);
+ })
+ );
+ }, [onChange, column]);
+
+ if (!column || !editableColumn) {
+ return null;
+ }
+
+ return (
+
+ |
+ {columnIndex + 1}
+ |
+
+
+ {isPrimaryKey ? (
+
+ ) : (
+ !readOnlyExistingColumns && (
+
+ )
+ )}
+ |
+
+
+ {editableColumn.name}
+ |
+
+ {editableColumn.type} |
+
+
+
+ |
+
+
+ {JSON.stringify(editableColumn.constraint?.defaultValue)}
+ |
+
+
+
+ |
+
+
+
+
+
+
+ }
+ />
+
+
+
+ Edit column
+
+
+
+ Remove column
+
+
+
+ |
+
+ );
+}
+
+interface ColumnConstraintDescriptionProps {
+ column: StudioTableColumn;
+ constraints: StudioTableConstraintChange[];
+}
+
+/**
+ * Displays constraint badges for a specific column, including:
+ * - Inline column constraints such as CHECK and GENERATED expressions
+ * - Foreign key references, whether defined directly on the column or
+ * as part of a table-level constraint that involves the column
+ *
+ * The component checks both column-level and table-level constraints
+ * to accurately resolve foreign key references for display.
+ */
+function ColumnConstraintDescription({
+ column,
+ constraints,
+}: ColumnConstraintDescriptionProps): JSX.Element {
+ // Check if it contains foreign key
+ let referenceTableName = column.constraint?.foreignKey?.foreignTableName;
+ let referenceColumnName = column.constraint?.foreignKey?.foreignColumns?.[0];
+
+ // Check if the reference is inside the table constraint
+ for (const constraint of constraints ?? []) {
+ const tableConstraint = constraint?.new;
+
+ if (
+ tableConstraint &&
+ tableConstraint.foreignKey &&
+ tableConstraint.foreignKey.columns &&
+ tableConstraint.foreignKey.foreignColumns
+ ) {
+ const foundIndex = tableConstraint.foreignKey.columns.indexOf(
+ column.name
+ );
+
+ if (foundIndex >= 0) {
+ referenceTableName = tableConstraint.foreignKey.foreignTableName;
+ referenceColumnName =
+ tableConstraint.foreignKey.foreignColumns[foundIndex];
+ }
+ }
+ }
+
+ return (
+
+ {column.constraint?.generatedExpression && (
+
+ {column.constraint.generatedExpression}
+
+ )}
+
+ {column.constraint?.checkExpression && (
+
+ {column.constraint.checkExpression}
+
+ )}
+
+ {referenceTableName && referenceColumnName && (
+
+ {referenceTableName}.{referenceColumnName}
+
+ )}
+
+ );
+}
+
+interface ColumnConstraintBadgeProps extends PropsWithChildren {
+ icon?: Icon;
+ name: string;
+}
+
+function ColumnConstraintBadge({
+ children,
+ icon: IconComponent,
+ name,
+}: ColumnConstraintBadgeProps): JSX.Element {
+ return (
+
+
+ {IconComponent && } {name}
+
+
+
{children}
+
+ );
+}
+
+interface StudioColumnEditiorDrawerProps {
+ closeModal?: () => void;
+ defaultValue?: StudioTableColumn;
+ isOpen?: boolean;
+ onConfirm: (value: StudioTableColumn) => void;
+ schemaChanges: StudioTableSchemaChange;
+}
+
+export function StudioColumnEditorModal({
+ closeModal,
+ defaultValue,
+ isOpen,
+ onConfirm,
+ schemaChanges,
+}: StudioColumnEditiorDrawerProps): JSX.Element {
+ const [value, setValue] = useState
(() =>
+ defaultValue
+ ? structuredClone(defaultValue)
+ : {
+ name: "",
+ type: "",
+ }
+ );
+
+ const isColumnNameDuplicated = !!schemaChanges.columns.find(
+ (column) =>
+ column.new !== defaultValue &&
+ column.new?.name.toLowerCase() === value.name.toLowerCase()
+ );
+
+ const isValid = !!value.name && !!value.type && !isColumnNameDuplicated;
+
+ const handleSubmit = (): void => {
+ if (!isValid) {
+ return;
+ }
+
+ onConfirm(value);
+ closeModal?.();
+ };
+
+ return (
+ {
+ if (!open) {
+ closeModal?.();
+ }
+ }}
+ open={isOpen}
+ >
+
+
+ );
+}
diff --git a/packages/local-explorer-ui/src/components/studio/Table/SchemaEditor/ConstraintListEditor.tsx b/packages/local-explorer-ui/src/components/studio/Table/SchemaEditor/ConstraintListEditor.tsx
new file mode 100644
index 000000000000..4b824744decc
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Table/SchemaEditor/ConstraintListEditor.tsx
@@ -0,0 +1,198 @@
+import { closestCenter, DndContext } from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { ArrowRightIcon } from "@phosphor-icons/react";
+import { produce } from "immer";
+import { useCallback } from "react";
+import type { StudioTableSchemaChange } from "../../../../types/studio";
+import type { DragEndEvent } from "@dnd-kit/core";
+import type { Dispatch, SetStateAction } from "react";
+
+interface StudioConstraintListEditorProps {
+ onChange: Dispatch>;
+ value: StudioTableSchemaChange;
+}
+
+export function StudioConstraintListEditor({
+ onChange,
+ value,
+}: StudioConstraintListEditorProps): JSX.Element | null {
+ if (value.constraints.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ Constraints
+
+
+
+ | # |
+
+ Type
+ |
+ |
+
+
+
+
+ {(value.constraints ?? []).map(
+ (constraintChange, constriantIndex): JSX.Element | null => {
+ const constraint = constraintChange.new || constraintChange.old;
+ if (!constraint) {
+ return null;
+ }
+
+ let constraintType = "";
+
+ if (constraint.unique) {
+ constraintType = "Unique";
+ }
+
+ if (constraint.foreignKey) {
+ constraintType = "Foreign Key";
+ }
+
+ if (constraint.primaryKey) {
+ constraintType = "Primary Key";
+ }
+
+ if (constraint.checkExpression) {
+ constraintType = "Check";
+ }
+
+ return (
+
+ |
+ {constriantIndex + 1}
+ |
+ {constraintType} |
+
+ {constraint.foreignKey && (
+
+ {(constraint.foreignKey.columns ?? []).map(
+ (column, columnIndex) => (
+
+ {column} {" "}
+ {constraint.foreignKey?.foreignTableName}.
+ {
+ constraint.foreignKey?.foreignColumns?.[
+ columnIndex
+ ]
+ }
+
+ )
+ )}
+
+ )}
+
+ {constraint.primaryKey && (
+ {
+ onChange((prev) =>
+ produce(prev, (draft) => {
+ draft.constraints.forEach((c) => {
+ if (
+ c.key === constraintChange.key &&
+ c.new?.primaryColumns
+ ) {
+ c.new.primaryColumns = newPrimaryColumns;
+ }
+ });
+ })
+ );
+ }}
+ value={constraint.primaryColumns ?? []}
+ />
+ )}
+ |
+
+ );
+ }
+ )}
+
+
+ >
+ );
+}
+
+interface SortableColumnListProps {
+ disabledRearrange?: boolean;
+ onChange: (newValue: string[]) => void;
+ value: string[];
+}
+
+function SortableColumnList({
+ disabledRearrange,
+ onChange,
+ value,
+}: SortableColumnListProps): JSX.Element {
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent): void => {
+ const { active, over } = event;
+
+ if (!active || !over) {
+ return;
+ }
+
+ if (active.id !== over.id) {
+ const oldIndex = value.indexOf(active.id.toString());
+ const newIndex = value.indexOf(over.id.toString());
+ onChange(arrayMove(value, oldIndex, newIndex));
+ }
+ },
+ [onChange, value]
+ );
+
+ return (
+
+
+
+ {value.map((columnName) => (
+
+ ))}
+
+
+
+ );
+}
+
+interface SortableColumnItemProps {
+ id: string;
+}
+
+function SortableColumnItem({ id }: SortableColumnItemProps): JSX.Element {
+ const { attributes, listeners, setNodeRef, transform, transition } =
+ useSortable({ id });
+
+ return (
+
+ {id}
+
+ );
+}
diff --git a/packages/local-explorer-ui/src/components/studio/Table/SchemaEditor/index.tsx b/packages/local-explorer-ui/src/components/studio/Table/SchemaEditor/index.tsx
new file mode 100644
index 000000000000..fbff3bba24a2
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Table/SchemaEditor/index.tsx
@@ -0,0 +1,275 @@
+import { Button } from "@cloudflare/kumo";
+import { SplitPane } from "@cloudflare/workers-editor-shared";
+import { KeyIcon } from "@phosphor-icons/react";
+import { produce } from "immer";
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { isEqual } from "../../../../utils/is-equal";
+import { useModal } from "../../Modal";
+import { StudioCommitConfirmation } from "../../Modal/CommitConfirmation";
+import { StudioSQLEditor } from "../../SQLEditor";
+import { StudioColumnEditorModal, StudioColumnSchemaEditor } from "./Column";
+import { StudioConstraintListEditor } from "./ConstraintListEditor";
+import type {
+ IStudioDriver,
+ StudioTableColumn,
+ StudioTableIndex,
+ StudioTableSchemaChange,
+} from "../../../../types/studio";
+import type { StudioCodeMirrorReference } from "../../Code/Mirror";
+import type { Dispatch, SetStateAction } from "react";
+
+interface StudioTableSchemaEditorProps {
+ disabledAddColumn?: boolean;
+ driver: IStudioDriver;
+ highlightSchemaChanges?: boolean;
+ onChange: Dispatch>;
+ onSaveChange: (statements: string[]) => Promise;
+ readOnlyExistingColumns?: boolean;
+ value: StudioTableSchemaChange;
+}
+
+export function StudioTableSchemaEditor({
+ disabledAddColumn,
+ driver,
+ highlightSchemaChanges,
+ onChange,
+ onSaveChange,
+ readOnlyExistingColumns,
+ value,
+}: StudioTableSchemaEditorProps): JSX.Element {
+ const { openModal } = useModal();
+
+ const editorRef = useRef(null);
+
+ const handleNameChange = useCallback(
+ (newName: string): void => {
+ onChange((prev) =>
+ produce(prev, (draft) => {
+ draft.name.new = newName;
+ })
+ );
+ },
+ [onChange]
+ );
+
+ const isSchemaDirty = useMemo(
+ (): boolean =>
+ value.name.new !== value.name.old ||
+ value.columns.some((change) => !isEqual(change.new, change.old)) ||
+ value.constraints.some((change) => !isEqual(change.new, change.old)),
+ [value]
+ );
+
+ const isSaveEnabled = useMemo(
+ // Enable save only if there's at least one column, a table name, and some change detected
+ (): boolean =>
+ isSchemaDirty && !!value.name.new && value.columns.length > 0,
+ [isSchemaDirty, value]
+ );
+
+ const handleAddColumn = useCallback((): void => {
+ openModal(StudioColumnEditorModal, {
+ onConfirm: (newColumn: StudioTableColumn) => {
+ onChange((prev) =>
+ produce(prev, (draft) => {
+ draft.columns.push({
+ key: window.crypto.randomUUID(),
+ old: null,
+ new: newColumn,
+ });
+ })
+ );
+ },
+ schemaChanges: value,
+ });
+ }, [onChange, value, openModal]);
+
+ useEffect((): void => {
+ if (!editorRef.current) {
+ return;
+ }
+
+ try {
+ editorRef.current.setValue(
+ driver.generateTableSchemaStatement(value).join("\n")
+ );
+ } catch (e) {
+ console.log("Some error", e);
+ }
+ }, [driver, value, editorRef]);
+
+ const handleSaveChange = useCallback((): void => {
+ try {
+ const previewStatements = driver.generateTableSchemaStatement(value);
+ openModal(StudioCommitConfirmation, {
+ onConfirm: async () => {
+ await onSaveChange(previewStatements);
+ },
+ statements: previewStatements,
+ });
+ } catch {
+ console.log("Cannot generate statements");
+ }
+ }, [driver, value, openModal, onSaveChange]);
+
+ const handleDiscard = useCallback((): void => {
+ onChange((prev) =>
+ produce(prev, (draft) => {
+ draft.name.new = draft.name.old;
+ draft.columns = draft.columns
+ .filter((draftColumn) => draftColumn.old)
+ .map((draftColumn) => ({
+ ...draftColumn,
+ new: draftColumn.old,
+ }));
+
+ draft.constraints = draft.constraints
+ .filter((draftConstraint) => draftConstraint.old)
+ .map((draftConstraint) => ({
+ ...draftConstraint,
+ new: draftConstraint.old,
+ }));
+ })
+ );
+ }, [onChange]);
+
+ return (
+
+
+
+
handleNameChange(e.target.value)}
+ placeholder="Table name"
+ value={value.name.new ?? ""}
+ />
+
+
+ {isSchemaDirty && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ |
+ #
+ |
+
+
+ |
+
+ Column Name
+ |
+
+ Type
+ |
+
+ NULL
+ |
+
+ Default Value
+ |
+ |
+ |
+
+
+
+ {value.columns.map((_, columnIndex) => (
+
+ ))}
+
+
+
+ {!disabledAddColumn && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+interface IndexListProps {
+ indexList: StudioTableIndex[];
+}
+
+function IndexList({ indexList }: IndexListProps): JSX.Element | null {
+ if (indexList.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ Indexes
+
+
+
+
+ | # |
+
+ Index Name
+ |
+ Type |
+ |
+
+
+
+
+ {indexList.map((index, indexIdx) => {
+ return (
+
+ | {indexIdx + 1} |
+ {index.name} |
+ {index.type} |
+
+ {index.columns.join(", ")}
+ |
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/packages/local-explorer-ui/src/components/studio/Tabs/CreateUpdateTable.tsx b/packages/local-explorer-ui/src/components/studio/Tabs/CreateUpdateTable.tsx
new file mode 100644
index 000000000000..14fa83622ca1
--- /dev/null
+++ b/packages/local-explorer-ui/src/components/studio/Tabs/CreateUpdateTable.tsx
@@ -0,0 +1,181 @@
+import { useCallback, useEffect, useState } from "react";
+import { useStudioContext } from "../Context";
+import { SkeletonBlock } from "../SkeletonBlock";
+import { StudioTableSchemaEditor } from "../Table/SchemaEditor";
+import { useStudioCurrentWindowTab } from "../WindowTab/Context";
+import type {
+ StudioTableSchema,
+ StudioTableSchemaChange,
+} from "../../../types/studio";
+
+interface StudioEditTableTabProps {
+ schemaName?: string;
+ tableName?: string;
+}
+
+const LAYOUT_CLASSES = "overflow-auto w-full h-full bg-surface";
+
+export function StudioCreateUpdateTableTab({
+ schemaName,
+ tableName,
+}: StudioEditTableTabProps): JSX.Element {
+ const { driver, refreshSchema, replaceStudioTab } = useStudioContext();
+ const { identifier: tabIdentifier } = useStudioCurrentWindowTab();
+
+ const [loading, setLoading] = useState(!!schemaName && !!tableName);
+ const [value, setValue] = useState({
+ columns: [],
+ constraints: [],
+ indexes: [],
+ name: {
+ new: "",
+ old: "",
+ },
+ schemaName: "main",
+ });
+
+ // Determines if the editor is in create mode (no previous table name)
+ const isCreateMode = !value.name.old;
+
+ useEffect((): void => {
+ async function updateValue(): Promise {
+ if (!schemaName || !tableName) {
+ return;
+ }
+
+ try {
+ const tableSchema = await driver.tableSchema(schemaName, tableName);
+ setValue(transformTableSchematableSchema(tableSchema));
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ void updateValue();
+ }, [driver, schemaName, tableName]);
+
+ const onSaveChange = useCallback(
+ async (statements: string[]): Promise => {
+ if (!value.schemaName || !value.name.new) {
+ return;
+ }
+
+ await driver.transaction(statements);
+
+ setValue(
+ transformTableSchematableSchema(
+ await driver.tableSchema(value.schemaName, value.name.new)
+ )
+ );
+
+ replaceStudioTab(
+ tabIdentifier,
+ {
+ schemaName: value.schemaName,
+ tableName: value.name.new,
+ type: "edit-table",
+ },
+ {
+ withoutReplaceComponent: true,
+ }
+ );
+
+ refreshSchema();
+ },
+ [value, driver, refreshSchema, tabIdentifier, replaceStudioTab]
+ );
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+function transformTableSchematableSchema(tableSchema: StudioTableSchema) {
+ const constraintsList = structuredClone(tableSchema.constraints ?? []);
+
+ const columnList = tableSchema.columns.map((column) => {
+ const columnCopy = { ...column };
+
+ /**
+ * Promote primary key and foreign key constraints from column-level to table-level.
+ *
+ * In SQLite, constraints like PRIMARY KEY and FOREIGN KEY can be defined at the column level:
+ *
+ * CREATE TABLE (
+ * PRIMARY KEY
+ * );
+ *
+ * For consistency and simplicity in our editor, we convert these to table-level constraints:
+ *
+ * CREATE TABLE (
+ * ,
+ * PRIMARY KEY (, ...)
+ * );
+ *
+ * Table-level constraints are more flexible (e.g., allowing composite keys),
+ * and this approach allows us to handle all constraints in a single, unified way in the UI logic.
+ */
+ if (columnCopy.constraint?.primaryKey) {
+ delete columnCopy.constraint.primaryKey;
+ delete columnCopy.constraint.primaryKeyConflict;
+ delete columnCopy.constraint.primaryKeyOrder;
+ delete columnCopy.constraint.primaryColumns;
+
+ const existingPkConstraint = constraintsList.find(
+ (constraint) => constraint.primaryKey === true
+ );
+ if (existingPkConstraint) {
+ existingPkConstraint.primaryColumns?.push(columnCopy.name);
+ return columnCopy;
+ }
+
+ constraintsList.unshift({
+ primaryKey: true,
+ primaryColumns: [columnCopy.name],
+ });
+ }
+
+ return columnCopy;
+ });
+
+ return {
+ columns: columnList.map((column) => ({
+ key: window.crypto.randomUUID(),
+ new: structuredClone(column),
+ old: structuredClone(column),
+ })),
+ constraints: constraintsList.map((constraint) => ({
+ key: window.crypto.randomUUID(),
+ new: structuredClone(constraint),
+ old: structuredClone(constraint),
+ })),
+ indexes: tableSchema.indexes ?? [],
+ name: {
+ old: tableSchema.tableName ?? null,
+ new: tableSchema.tableName ?? null,
+ },
+ schemaName: tableSchema.schemaName,
+ };
+}
diff --git a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx
index 938a36975ab0..de4732f70afd 100644
--- a/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx
+++ b/packages/local-explorer-ui/src/components/studio/Tabs/TableExplorer.tsx
@@ -398,13 +398,16 @@ export function StudioTableExplorerTab({
-