+ {isRowNumber ? (
+ flexRender(header.column.columnDef.header, header.getContext())
+ ) : (
+
+ {TypeIcon && (
+
+ )}
+
+ {flexRender(header.column.columnDef.header, header.getContext())}
+
+
+ )}
+ {header.column.getCanResize() && (
+
{
+ e.stopPropagation();
+ header.getResizeHandler()(e);
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ header.getResizeHandler()(e);
+ }}
+ onPointerDown={(e) => e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ />
+ )}
+ {property && !isRowNumber && (
+
+
+
+
+ e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+
+
+
+ )}
+
+ );
+});
diff --git a/apps/client/src/features/base/components/grid/grid-header.tsx b/apps/client/src/features/base/components/grid/grid-header.tsx
new file mode 100644
index 00000000..f418df07
--- /dev/null
+++ b/apps/client/src/features/base/components/grid/grid-header.tsx
@@ -0,0 +1,40 @@
+import { memo, useCallback } from "react";
+import { Table } from "@tanstack/react-table";
+import { IBaseRow } from "@/features/base/types/base.types";
+import { GridHeaderCell } from "./grid-header-cell";
+import { IconPlus } from "@tabler/icons-react";
+import classes from "@/features/base/styles/grid.module.css";
+
+type GridHeaderProps = {
+ table: Table
;
+ onAddColumn?: () => void;
+};
+
+export const GridHeader = memo(function GridHeader({
+ table,
+ onAddColumn,
+}: GridHeaderProps) {
+ const headerGroups = table.getHeaderGroups();
+
+ const handleAddColumn = useCallback(() => {
+ onAddColumn?.();
+ }, [onAddColumn]);
+
+ return (
+
+ {headerGroups[0]?.headers.map((header) => (
+
+ ))}
+ {onAddColumn && (
+
+
+
+ )}
+
+ );
+});
diff --git a/apps/client/src/features/base/components/grid/grid-row.tsx b/apps/client/src/features/base/components/grid/grid-row.tsx
new file mode 100644
index 00000000..75c6b352
--- /dev/null
+++ b/apps/client/src/features/base/components/grid/grid-row.tsx
@@ -0,0 +1,84 @@
+import { memo, useCallback } from "react";
+import { Row } from "@tanstack/react-table";
+import { IBaseRow } from "@/features/base/types/base.types";
+import { GridCell } from "./grid-cell";
+import classes from "@/features/base/styles/grid.module.css";
+
+type RowDragHandlers = {
+ onDragStart: (rowId: string) => void;
+ onDragOver: (rowId: string, e: React.DragEvent) => void;
+ onDragEnd: () => void;
+ onDragLeave: () => void;
+ isDragging: boolean;
+ isDropTarget: boolean;
+ dropPosition: "above" | "below" | null;
+};
+
+type GridRowProps = {
+ row: Row;
+ rowIndex: number;
+ onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
+ dragHandlers?: RowDragHandlers;
+};
+
+export const GridRow = memo(function GridRow({
+ row,
+ rowIndex,
+ onCellUpdate,
+ dragHandlers,
+}: GridRowProps) {
+ const handleDragStart = useCallback(
+ (e: React.DragEvent) => {
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("text/plain", row.id);
+ dragHandlers?.onDragStart(row.id);
+ },
+ [row.id, dragHandlers],
+ );
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent) => {
+ dragHandlers?.onDragOver(row.id, e);
+ },
+ [row.id, dragHandlers],
+ );
+
+ const dropIndicatorClass = dragHandlers?.isDropTarget
+ ? dragHandlers.dropPosition === "above"
+ ? classes.rowDropAbove
+ : classes.rowDropBelow
+ : "";
+
+ return (
+ {
+ e.preventDefault();
+ dragHandlers?.onDragEnd();
+ }}
+ onDragLeave={dragHandlers?.onDragLeave}
+ >
+ {row.getVisibleCells().map((cell) => {
+ const isRowNumber = cell.column.id === "__row_number";
+ return (
+
+ );
+ })}
+
+ );
+});
diff --git a/apps/client/src/features/base/components/property/choice-editor.tsx b/apps/client/src/features/base/components/property/choice-editor.tsx
new file mode 100644
index 00000000..5fbe976c
--- /dev/null
+++ b/apps/client/src/features/base/components/property/choice-editor.tsx
@@ -0,0 +1,528 @@
+import { useState, useCallback, useMemo, useEffect, useRef } from "react";
+import {
+ TextInput,
+ Group,
+ Stack,
+ Text,
+ Button,
+ Popover,
+ SimpleGrid,
+ UnstyledButton,
+ CloseButton,
+ Divider,
+} from "@mantine/core";
+import {
+ IconPlus,
+ IconGripVertical,
+ IconArrowsSort,
+} from "@tabler/icons-react";
+import {
+ DndContext,
+ closestCenter,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ verticalListSortingStrategy,
+ useSortable,
+ arrayMove,
+} from "@dnd-kit/sortable";
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
+import { CSS } from "@dnd-kit/utilities";
+import { Choice } from "@/features/base/types/base.types";
+import { choiceColor } from "@/features/base/components/cells/choice-color";
+import { useTranslation } from "react-i18next";
+import { v7 as uuid7 } from "uuid";
+
+const CHOICE_COLORS = [
+ "gray", "red", "pink", "grape", "violet", "indigo",
+ "blue", "cyan", "teal", "green", "lime", "yellow", "orange",
+];
+
+const STATUS_CATEGORIES = [
+ { value: "todo", label: "To Do" },
+ { value: "inProgress", label: "In Progress" },
+ { value: "complete", label: "Complete" },
+] as const;
+
+type ChoiceEditorProps = {
+ initialChoices: Choice[];
+ onSave: (choices: Choice[]) => void;
+ onClose: () => void;
+ onDirtyChange?: (dirty: boolean) => void;
+ showCategories?: boolean;
+};
+
+export function ChoiceEditor({
+ initialChoices,
+ onSave,
+ onClose,
+ onDirtyChange,
+ showCategories = false,
+}: ChoiceEditorProps) {
+ const { t } = useTranslation();
+ const [draft, setDraft] = useState(initialChoices);
+ const [focusChoiceId, setFocusChoiceId] = useState(null);
+
+ useEffect(() => {
+ setDraft(initialChoices);
+ }, [initialChoices]);
+
+ const isDirty = useMemo(() => {
+ if (draft.length !== initialChoices.length) return true;
+ return draft.some((d, i) => {
+ const o = initialChoices[i];
+ return d.id !== o.id || d.name !== o.name || d.color !== o.color || d.category !== o.category;
+ });
+ }, [draft, initialChoices]);
+
+ useEffect(() => {
+ onDirtyChange?.(isDirty);
+ }, [isDirty, onDirtyChange]);
+
+ const hasEmptyNames = draft.some((c) => !c.name.trim());
+
+ const handleRename = useCallback((choiceId: string, name: string) => {
+ setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, name } : c)));
+ }, []);
+
+ const handleColorChange = useCallback((choiceId: string, color: string) => {
+ setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, color } : c)));
+ }, []);
+
+ const handleRemove = useCallback((choiceId: string) => {
+ setDraft((prev) => prev.filter((c) => c.id !== choiceId));
+ }, []);
+
+ const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
+ const id = uuid7();
+ setDraft((prev) => {
+ const colorIndex = prev.length % CHOICE_COLORS.length;
+ const newChoice: Choice = {
+ id,
+ name: "",
+ color: CHOICE_COLORS[colorIndex],
+ ...(category ? { category } : {}),
+ };
+ return [...prev, newChoice];
+ });
+ setFocusChoiceId(id);
+ }, []);
+
+ const handleAlphabetize = useCallback(() => {
+ setDraft((prev) => [...prev].sort((a, b) => a.name.localeCompare(b.name)));
+ }, []);
+
+ const handleSave = useCallback(() => {
+ const cleaned = draft.filter((c) => c.name.trim());
+ onSave(cleaned);
+ onClose();
+ }, [draft, onSave, onClose]);
+
+ const handleCancel = useCallback(() => {
+ setDraft(initialChoices);
+ onDirtyChange?.(false);
+ onClose();
+ }, [initialChoices, onDirtyChange, onClose]);
+
+ const handleReorder = useCallback((activeId: string, overId: string) => {
+ setDraft((prev) => {
+ const oldIndex = prev.findIndex((c) => c.id === activeId);
+ const newIndex = prev.findIndex((c) => c.id === overId);
+ if (oldIndex === -1 || newIndex === -1) return prev;
+ return arrayMove(prev, oldIndex, newIndex);
+ });
+ }, []);
+
+ const handleCategoryReorder = useCallback(
+ (category: string, activeId: string, overId: string) => {
+ setDraft((prev) => {
+ const catChoices = prev.filter((c) => (c.category ?? "todo") === category);
+ const oldIndex = catChoices.findIndex((c) => c.id === activeId);
+ const newIndex = catChoices.findIndex((c) => c.id === overId);
+ if (oldIndex === -1 || newIndex === -1) return prev;
+ const reordered = arrayMove(catChoices, oldIndex, newIndex);
+ const result: Choice[] = [];
+ for (const cat of ["todo", "inProgress", "complete"]) {
+ if (cat === category) {
+ result.push(...reordered);
+ } else {
+ result.push(...prev.filter((c) => (c.category ?? "todo") === cat));
+ }
+ }
+ return result;
+ });
+ },
+ [],
+ );
+
+ return (
+
+
+
+ {t("Options")}
+
+
+
+ {t("Alphabetize")}
+
+
+
+ {showCategories ? (
+ setFocusChoiceId(null)}
+ onRename={handleRename}
+ onColorChange={handleColorChange}
+ onRemove={handleRemove}
+ onAdd={handleAdd}
+ onCategoryReorder={handleCategoryReorder}
+ />
+ ) : (
+ setFocusChoiceId(null)}
+ onRename={handleRename}
+ onColorChange={handleColorChange}
+ onRemove={handleRemove}
+ onAdd={handleAdd}
+ onReorder={handleReorder}
+ />
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+function FlatChoiceList({
+ draft,
+ focusChoiceId,
+ onFocused,
+ onRename,
+ onColorChange,
+ onRemove,
+ onAdd,
+ onReorder,
+}: {
+ draft: Choice[];
+ focusChoiceId: string | null;
+ onFocused: () => void;
+ onRename: (id: string, name: string) => void;
+ onColorChange: (id: string, color: string) => void;
+ onRemove: (id: string) => void;
+ onAdd: () => void;
+ onReorder: (activeId: string, overId: string) => void;
+}) {
+ const { t } = useTranslation();
+ const choiceIds = useMemo(() => draft.map((c) => c.id), [draft]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ );
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ onReorder(active.id as string, over.id as string);
+ },
+ [onReorder],
+ );
+
+ const modifiers = useMemo(() => [restrictToVerticalAxis], []);
+
+ return (
+
+
+
+ {draft.map((choice) => (
+
+ ))}
+
+
+
+ onAdd()}
+ style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}
+ >
+
+ {t("Add option")}
+
+
+ );
+}
+
+function StatusChoiceList({
+ draft,
+ focusChoiceId,
+ onFocused,
+ onRename,
+ onColorChange,
+ onRemove,
+ onAdd,
+ onCategoryReorder,
+}: {
+ draft: Choice[];
+ focusChoiceId: string | null;
+ onFocused: () => void;
+ onRename: (id: string, name: string) => void;
+ onColorChange: (id: string, color: string) => void;
+ onRemove: (id: string) => void;
+ onAdd: (category: "todo" | "inProgress" | "complete") => void;
+ onCategoryReorder: (category: string, activeId: string, overId: string) => void;
+}) {
+ const grouped = useMemo(() => {
+ const groups: Record = { todo: [], inProgress: [], complete: [] };
+ for (const choice of draft) {
+ const cat = choice.category ?? "todo";
+ (groups[cat] ?? groups.todo).push(choice);
+ }
+ return groups;
+ }, [draft]);
+
+ return (
+
+ {STATUS_CATEGORIES.map(({ value: category, label }) => (
+
+ ))}
+
+ );
+}
+
+function CategorySection({
+ category,
+ label,
+ choices,
+ focusChoiceId,
+ onFocused,
+ onRename,
+ onColorChange,
+ onRemove,
+ onAdd,
+ onReorder,
+}: {
+ category: "todo" | "inProgress" | "complete";
+ label: string;
+ choices: Choice[];
+ focusChoiceId: string | null;
+ onFocused: () => void;
+ onRename: (id: string, name: string) => void;
+ onColorChange: (id: string, color: string) => void;
+ onRemove: (id: string) => void;
+ onAdd: (category: "todo" | "inProgress" | "complete") => void;
+ onReorder: (category: string, activeId: string, overId: string) => void;
+}) {
+ const { t } = useTranslation();
+ const choiceIds = useMemo(() => choices.map((c) => c.id), [choices]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ );
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ onReorder(category, active.id as string, over.id as string);
+ },
+ [category, onReorder],
+ );
+
+ const modifiers = useMemo(() => [restrictToVerticalAxis], []);
+
+ return (
+
+
+ {t(label)}
+
+
+
+
+ {choices.map((choice) => (
+
+ ))}
+
+
+
+ onAdd(category)}
+ style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}
+ >
+
+ {t("Add option")}
+
+
+ );
+}
+
+function SortableChoiceRow({
+ choice,
+ autoFocus,
+ onFocused,
+ onRename,
+ onColorChange,
+ onRemove,
+}: {
+ choice: Choice;
+ autoFocus?: boolean;
+ onFocused?: () => void;
+ onRename: (id: string, name: string) => void;
+ onColorChange: (id: string, color: string) => void;
+ onRemove: (id: string) => void;
+}) {
+ const inputRef = useRef(null);
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: choice.id });
+
+ useEffect(() => {
+ if (autoFocus) {
+ inputRef.current?.focus();
+ onFocused?.();
+ }
+ }, [autoFocus, onFocused]);
+
+ const style = {
+ transform: CSS.Transform.toString(transform ? { ...transform, scaleX: 1, scaleY: 1 } : null),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ zIndex: isDragging ? 10 : undefined,
+ };
+
+ const hasError = !choice.name.trim();
+
+ return (
+
+
+
+
+ onColorChange(choice.id, c)} />
+ onRename(choice.id, e.currentTarget.value)}
+ style={{ flex: 1 }}
+ error={hasError}
+ styles={hasError ? { input: { borderColor: "var(--mantine-color-red-6)" } } : undefined}
+ />
+ onRemove(choice.id)} />
+
+ );
+}
+
+function ColorDot({
+ color,
+ onChange,
+}: {
+ color: string;
+ onChange: (color: string) => void;
+}) {
+ const [opened, setOpened] = useState(false);
+ const colors = choiceColor(color);
+
+ return (
+
+
+ setOpened((o) => !o)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: "50%",
+ backgroundColor: colors.backgroundColor as string,
+ border: `2px solid ${colors.color as string}`,
+ flexShrink: 0,
+ }}
+ />
+
+
+
+ {CHOICE_COLORS.map((c) => {
+ const dotColors = choiceColor(c);
+ return (
+ {
+ onChange(c);
+ setOpened(false);
+ }}
+ style={{
+ width: 24,
+ height: 24,
+ borderRadius: "50%",
+ backgroundColor: dotColors.backgroundColor as string,
+ border: c === color
+ ? `2px solid ${dotColors.color as string}`
+ : "2px solid transparent",
+ }}
+ />
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/client/src/features/base/components/property/property-menu.tsx b/apps/client/src/features/base/components/property/property-menu.tsx
new file mode 100644
index 00000000..4bcfadcb
--- /dev/null
+++ b/apps/client/src/features/base/components/property/property-menu.tsx
@@ -0,0 +1,443 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import {
+ UnstyledButton,
+ TextInput,
+ Button,
+ Stack,
+ Text,
+ Group,
+ ActionIcon,
+ Divider,
+ ScrollArea,
+} from "@mantine/core";
+import {
+ IconTrash,
+ IconPencil,
+ IconChevronRight,
+ IconTransform,
+ IconSettings,
+} from "@tabler/icons-react";
+import {
+ IBaseProperty,
+ BasePropertyType,
+} from "@/features/base/types/base.types";
+import { useAtom } from "jotai";
+import { propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
+import {
+ useUpdatePropertyMutation,
+ useDeletePropertyMutation,
+} from "@/features/base/queries/base-property-query";
+import { PropertyTypePicker } from "./property-type-picker";
+import { PropertyOptions } from "./property-options";
+import { useTranslation } from "react-i18next";
+import { isSystemPropertyType } from "@/features/base/hooks/use-base-table";
+import cellClasses from "@/features/base/styles/cells.module.css";
+
+type PropertyMenuContentProps = {
+ property: IBaseProperty;
+ opened: boolean;
+ onClose: () => void;
+ onDirtyChange?: (dirty: boolean) => void;
+};
+
+type MenuPanel = "main" | "rename" | "changeType" | "options" | "confirmDelete" | "confirmDiscard";
+
+export function PropertyMenuContent({
+ property,
+ opened,
+ onClose,
+ onDirtyChange,
+}: PropertyMenuContentProps) {
+ const { t } = useTranslation();
+ const [panel, setPanel] = useState("main");
+ const [renameValue, setRenameValue] = useState(property.name);
+ const renameInputRef = useRef(null);
+ const [optionsDirty, setOptionsDirty] = useState(false);
+ const pendingActionRef = useRef<"back" | "close" | null>(null);
+ const [closeRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number];
+ const closeRequestRef = useRef(closeRequest);
+
+ const updatePropertyMutation = useUpdatePropertyMutation();
+ const deletePropertyMutation = useDeletePropertyMutation();
+
+ useEffect(() => {
+ if (opened) {
+ setPanel("main");
+ setRenameValue(property.name);
+ setOptionsDirty(false);
+ }
+ }, [opened, property.name]);
+
+ useEffect(() => {
+ if (panel === "rename") {
+ setTimeout(() => renameInputRef.current?.select(), 0);
+ }
+ }, [panel]);
+
+ const handleOptionsDirtyChange = useCallback(
+ (dirty: boolean) => {
+ setOptionsDirty(dirty);
+ onDirtyChange?.(dirty);
+ },
+ [onDirtyChange],
+ );
+
+ const commitRename = useCallback(() => {
+ const trimmed = renameValue.trim();
+ if (trimmed && trimmed !== property.name) {
+ updatePropertyMutation.mutate({
+ propertyId: property.id,
+ baseId: property.baseId,
+ name: trimmed,
+ });
+ }
+ }, [renameValue, property, updatePropertyMutation]);
+
+ const handleRenameAndClose = useCallback(() => {
+ commitRename();
+ onClose();
+ }, [commitRename, onClose]);
+
+ const handleRenameKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ e.stopPropagation();
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleRenameAndClose();
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onClose();
+ }
+ },
+ [handleRenameAndClose, onClose],
+ );
+
+ const handleTypeChange = useCallback(
+ (type: BasePropertyType) => {
+ if (type !== property.type) {
+ updatePropertyMutation.mutate({
+ propertyId: property.id,
+ baseId: property.baseId,
+ type,
+ });
+ }
+ onClose();
+ },
+ [property, updatePropertyMutation, onClose],
+ );
+
+ const handleOptionsUpdate = useCallback(
+ (typeOptions: Record) => {
+ updatePropertyMutation.mutate({
+ propertyId: property.id,
+ baseId: property.baseId,
+ typeOptions,
+ });
+ setOptionsDirty(false);
+ },
+ [property, updatePropertyMutation],
+ );
+
+ const handleDelete = useCallback(() => {
+ deletePropertyMutation.mutate({
+ propertyId: property.id,
+ baseId: property.baseId,
+ });
+ onClose();
+ }, [property, deletePropertyMutation, onClose]);
+
+ const handleOptionsBack = useCallback(() => {
+ if (optionsDirty) {
+ pendingActionRef.current = "back";
+ setPanel("confirmDiscard");
+ } else {
+ setPanel("main");
+ }
+ }, [optionsDirty]);
+
+ const requestClose = useCallback(() => {
+ if (panel === "options" && optionsDirty) {
+ pendingActionRef.current = "close";
+ setPanel("confirmDiscard");
+ } else {
+ onClose();
+ }
+ }, [panel, optionsDirty, onClose]);
+
+ useEffect(() => {
+ if (closeRequest !== closeRequestRef.current) {
+ closeRequestRef.current = closeRequest;
+ if (opened) {
+ requestClose();
+ }
+ }
+ }, [closeRequest, opened, requestClose]);
+
+ const handleConfirmDiscard = useCallback(() => {
+ setOptionsDirty(false);
+ onDirtyChange?.(false);
+ const action = pendingActionRef.current;
+ pendingActionRef.current = null;
+ if (action === "back") {
+ setPanel("main");
+ } else {
+ onClose();
+ }
+ }, [onClose, onDirtyChange]);
+
+ const handleCancelDiscard = useCallback(() => {
+ pendingActionRef.current = null;
+ setPanel("options");
+ }, []);
+
+ return (
+ <>
+ {panel === "main" && (
+ setPanel("rename")}
+ onChangeType={() => setPanel("changeType")}
+ onOptions={() => setPanel("options")}
+ onDelete={() => setPanel("confirmDelete")}
+ />
+ )}
+ {panel === "rename" && (
+
+
+ {t("Rename property")}
+
+ setRenameValue(e.currentTarget.value)}
+ onKeyDown={handleRenameKeyDown}
+ onBlur={commitRename}
+ />
+
+ )}
+ {panel === "changeType" && (
+ setPanel("main")}
+ />
+ )}
+ {(panel === "options" || panel === "confirmDiscard") && (
+
+
+
+
+
+
+ {t("Property options")}
+
+
+
+
+
+
+ )}
+ {panel === "confirmDelete" && (
+
+
+ {t("Delete property")}
+
+
+ {t("Are you sure you want to delete")} {property.name}?{" "}
+ {t("All data in this column will be lost.")}
+
+
+
+
+
+
+ )}
+ {panel === "confirmDiscard" && (
+
+
+ {t("Unsaved changes")}
+
+
+ {t("You have unsaved changes. Do you want to discard them?")}
+
+
+
+
+
+
+ )}
+ >
+ );
+}
+
+// Expose requestClose for use by parent (grid-header-cell)
+PropertyMenuContent.displayName = "PropertyMenuContent";
+
+function MenuItem({
+ icon,
+ label,
+ rightIcon,
+ color,
+ onClick,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ rightIcon?: React.ReactNode;
+ color?: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+ {rightIcon}
+
+ );
+}
+
+function MainPanel({
+ property,
+ onRename,
+ onChangeType,
+ onOptions,
+ onDelete,
+}: {
+ property: IBaseProperty;
+ onRename: () => void;
+ onChangeType: () => void;
+ onOptions: () => void;
+ onDelete: () => void;
+}) {
+ const { t } = useTranslation();
+
+ const isSystem = isSystemPropertyType(property.type);
+
+ const hasOptions =
+ !isSystem &&
+ (property.type === "select" ||
+ property.type === "multiSelect" ||
+ property.type === "status" ||
+ property.type === "number" ||
+ property.type === "date");
+
+ return (
+
+ }
+ label={t("Rename")}
+ onClick={onRename}
+ />
+ {!isSystem && (
+ }
+ label={t("Change type")}
+ rightIcon={}
+ onClick={onChangeType}
+ />
+ )}
+ {hasOptions && (
+ }
+ label={t("Options")}
+ rightIcon={}
+ onClick={onOptions}
+ />
+ )}
+ {!property.isPrimary && (
+ <>
+
+ }
+ label={t("Delete property")}
+ color="red"
+ onClick={onDelete}
+ />
+ >
+ )}
+
+ );
+}
+
+function TypePanel({
+ currentType,
+ onSelect,
+ onBack,
+}: {
+ currentType: BasePropertyType;
+ onSelect: (type: BasePropertyType) => void;
+ onBack: () => void;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+ {t("Change type")}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/base/components/property/property-options.tsx b/apps/client/src/features/base/components/property/property-options.tsx
new file mode 100644
index 00000000..7a34e69b
--- /dev/null
+++ b/apps/client/src/features/base/components/property/property-options.tsx
@@ -0,0 +1,217 @@
+import { useCallback } from "react";
+import { Stack, NumberInput, Select, Switch, Text } from "@mantine/core";
+import {
+ IBaseProperty,
+ SelectTypeOptions,
+ NumberTypeOptions,
+ DateTypeOptions,
+ Choice,
+} from "@/features/base/types/base.types";
+import { ChoiceEditor } from "./choice-editor";
+import { useTranslation } from "react-i18next";
+
+type PropertyOptionsProps = {
+ property: IBaseProperty;
+ onUpdate: (typeOptions: Record) => void;
+ onClose: () => void;
+ onDirtyChange?: (dirty: boolean) => void;
+};
+
+export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }: PropertyOptionsProps) {
+ const { t } = useTranslation();
+
+ switch (property.type) {
+ case "select":
+ case "multiSelect":
+ return (
+
+ );
+ case "status":
+ return (
+
+ );
+ case "number":
+ return (
+
+ );
+ case "date":
+ return (
+
+ );
+ default:
+ return (
+
+ {t("No options for this property type")}
+
+ );
+ }
+}
+
+function SelectOptions({
+ property,
+ onUpdate,
+ onClose,
+ onDirtyChange,
+}: {
+ property: IBaseProperty;
+ onUpdate: (typeOptions: Record) => void;
+ onClose: () => void;
+ onDirtyChange?: (dirty: boolean) => void;
+}) {
+ const options = property.typeOptions as SelectTypeOptions | undefined;
+ const choices = options?.choices ?? [];
+
+ const handleSave = useCallback(
+ (newChoices: Choice[]) => {
+ onUpdate({
+ ...property.typeOptions,
+ choices: newChoices,
+ choiceOrder: newChoices.map((c) => c.id),
+ });
+ },
+ [property.typeOptions, onUpdate],
+ );
+
+ return (
+
+ );
+}
+
+function StatusOptions({
+ property,
+ onUpdate,
+ onClose,
+ onDirtyChange,
+}: {
+ property: IBaseProperty;
+ onUpdate: (typeOptions: Record) => void;
+ onClose: () => void;
+ onDirtyChange?: (dirty: boolean) => void;
+}) {
+ const options = property.typeOptions as SelectTypeOptions | undefined;
+ const choices = options?.choices ?? [];
+
+ const handleSave = useCallback(
+ (newChoices: Choice[]) => {
+ onUpdate({
+ ...property.typeOptions,
+ choices: newChoices,
+ choiceOrder: newChoices.map((c) => c.id),
+ });
+ },
+ [property.typeOptions, onUpdate],
+ );
+
+ return (
+
+ );
+}
+
+function NumberOptions({
+ property,
+ onUpdate,
+}: {
+ property: IBaseProperty;
+ onUpdate: (typeOptions: Record) => void;
+}) {
+ const { t } = useTranslation();
+ const options = property.typeOptions as NumberTypeOptions | undefined;
+
+ return (
+
+
+ );
+}
+
+function DateOptions({
+ property,
+ onUpdate,
+}: {
+ property: IBaseProperty;
+ onUpdate: (typeOptions: Record) => void;
+}) {
+ const { t } = useTranslation();
+ const options = property.typeOptions as DateTypeOptions | undefined;
+
+ return (
+
+
+ onUpdate({
+ ...property.typeOptions,
+ includeTime: e.currentTarget.checked,
+ })
+ }
+ />
+ {options?.includeTime && (
+
+ );
+}
diff --git a/apps/client/src/features/base/components/property/property-type-picker.tsx b/apps/client/src/features/base/components/property/property-type-picker.tsx
new file mode 100644
index 00000000..5d7008ab
--- /dev/null
+++ b/apps/client/src/features/base/components/property/property-type-picker.tsx
@@ -0,0 +1,83 @@
+import { UnstyledButton, Group, Text } from "@mantine/core";
+import {
+ IconLetterT,
+ IconHash,
+ IconCircleDot,
+ IconProgressCheck,
+ IconTags,
+ IconCalendar,
+ IconUser,
+ IconPaperclip,
+ IconCheckbox,
+ IconLink,
+ IconMail,
+ IconClockPlus,
+ IconClockEdit,
+ IconUserEdit,
+ IconCheck,
+} from "@tabler/icons-react";
+import { BasePropertyType } from "@/features/base/types/base.types";
+import { useTranslation } from "react-i18next";
+import classes from "@/features/base/styles/cells.module.css";
+
+const propertyTypes: {
+ type: BasePropertyType;
+ icon: typeof IconLetterT;
+ labelKey: string;
+}[] = [
+ { type: "text", icon: IconLetterT, labelKey: "Text" },
+ { type: "number", icon: IconHash, labelKey: "Number" },
+ { type: "select", icon: IconCircleDot, labelKey: "Select" },
+ { type: "status", icon: IconProgressCheck, labelKey: "Status" },
+ { type: "multiSelect", icon: IconTags, labelKey: "Multi-select" },
+ { type: "date", icon: IconCalendar, labelKey: "Date" },
+ { type: "person", icon: IconUser, labelKey: "Person" },
+ { type: "file", icon: IconPaperclip, labelKey: "File" },
+ { type: "checkbox", icon: IconCheckbox, labelKey: "Checkbox" },
+ { type: "url", icon: IconLink, labelKey: "URL" },
+ { type: "email", icon: IconMail, labelKey: "Email" },
+ { type: "createdAt", icon: IconClockPlus, labelKey: "Created at" },
+ { type: "lastEditedAt", icon: IconClockEdit, labelKey: "Last edited at" },
+ { type: "lastEditedBy", icon: IconUserEdit, labelKey: "Last edited by" },
+];
+
+type PropertyTypePickerProps = {
+ onSelect: (type: BasePropertyType) => void;
+ currentType?: BasePropertyType;
+ excludeTypes?: Set;
+};
+
+export function PropertyTypePicker({
+ onSelect,
+ currentType,
+ excludeTypes,
+}: PropertyTypePickerProps) {
+ const { t } = useTranslation();
+
+ const types = excludeTypes
+ ? propertyTypes.filter(({ type }) => !excludeTypes.has(type))
+ : propertyTypes;
+
+ return (
+ <>
+ {types.map(({ type, icon: Icon, labelKey }) => (
+ onSelect(type)}
+ style={{
+ fontWeight: type === currentType ? 600 : 400,
+ }}
+ >
+
+
+ {t(labelKey)}
+
+ {type === currentType && }
+
+ ))}
+ >
+ );
+}
+
+export { propertyTypes };
diff --git a/apps/client/src/features/base/components/views/view-field-visibility.tsx b/apps/client/src/features/base/components/views/view-field-visibility.tsx
new file mode 100644
index 00000000..9cfe8ee1
--- /dev/null
+++ b/apps/client/src/features/base/components/views/view-field-visibility.tsx
@@ -0,0 +1,146 @@
+import { useMemo, useCallback } from "react";
+import { Popover, Switch, Stack, Text, Group, Divider, UnstyledButton } from "@mantine/core";
+import { IconEye, IconEyeOff } from "@tabler/icons-react";
+import { Table } from "@tanstack/react-table";
+import { IBaseRow, IBaseProperty } from "@/features/base/types/base.types";
+import { propertyTypes } from "@/features/base/components/property/property-type-picker";
+import { useTranslation } from "react-i18next";
+import cellClasses from "@/features/base/styles/cells.module.css";
+
+type ViewFieldVisibilityProps = {
+ opened: boolean;
+ onClose: () => void;
+ table: Table;
+ onPersist: () => void;
+ children: React.ReactNode;
+};
+
+export function ViewFieldVisibility({
+ opened,
+ onClose,
+ table,
+ onPersist,
+ children,
+}: ViewFieldVisibilityProps) {
+ const { t } = useTranslation();
+
+ const columns = useMemo(() => {
+ return table
+ .getAllLeafColumns()
+ .filter((col) => col.id !== "__row_number");
+ }, [table]);
+
+ const allVisible = columns.every((col) => col.getIsVisible());
+ const noneVisible = columns.filter((col) => col.getCanHide()).every((col) => !col.getIsVisible());
+
+ const handleToggle = useCallback(
+ (columnId: string, visible: boolean) => {
+ const col = table.getColumn(columnId);
+ if (!col) return;
+ col.toggleVisibility(visible);
+ onPersist();
+ },
+ [table, onPersist],
+ );
+
+ const handleShowAll = useCallback(() => {
+ columns.forEach((col) => {
+ if (col.getCanHide()) {
+ col.toggleVisibility(true);
+ }
+ });
+ onPersist();
+ }, [columns, onPersist]);
+
+ const handleHideAll = useCallback(() => {
+ columns.forEach((col) => {
+ if (col.getCanHide()) {
+ col.toggleVisibility(false);
+ }
+ });
+ onPersist();
+ }, [columns, onPersist]);
+
+ return (
+
+ {children}
+
+
+
+
+ {t("Fields")}
+
+
+
+
+ {t("Show all")}
+
+
+
+
+ {t("Hide all")}
+
+
+
+
+
+
+
+
+ {columns.map((col) => {
+ const property = col.columnDef.meta?.property as IBaseProperty | undefined;
+ if (!property) return null;
+
+ const canHide = col.getCanHide();
+ const isVisible = col.getIsVisible();
+ const typeConfig = propertyTypes.find((pt) => pt.type === property.type);
+ const TypeIcon = typeConfig?.icon;
+
+ return (
+ {
+ if (canHide) {
+ handleToggle(col.id, !isVisible);
+ }
+ }}
+ style={{ opacity: canHide ? 1 : 0.5 }}
+ >
+
+ {TypeIcon && }
+
+ {property.name}
+
+
+ {}}
+ styles={{ track: { cursor: canHide ? "pointer" : "not-allowed" } }}
+ />
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/base/components/views/view-filter-config.tsx b/apps/client/src/features/base/components/views/view-filter-config.tsx
new file mode 100644
index 00000000..28b91e52
--- /dev/null
+++ b/apps/client/src/features/base/components/views/view-filter-config.tsx
@@ -0,0 +1,204 @@
+import { useCallback } from "react";
+import {
+ Popover,
+ Stack,
+ Group,
+ Select,
+ TextInput,
+ ActionIcon,
+ Text,
+ UnstyledButton,
+} from "@mantine/core";
+import { IconPlus, IconTrash } from "@tabler/icons-react";
+import {
+ IBaseProperty,
+ ViewFilterConfig,
+ ViewFilterOperator,
+} from "@/features/base/types/base.types";
+import { useTranslation } from "react-i18next";
+
+const OPERATORS: { value: ViewFilterOperator; labelKey: string }[] = [
+ { value: "equals", labelKey: "Equals" },
+ { value: "notEquals", labelKey: "Not equals" },
+ { value: "contains", labelKey: "Contains" },
+ { value: "notContains", labelKey: "Not contains" },
+ { value: "isEmpty", labelKey: "Is empty" },
+ { value: "isNotEmpty", labelKey: "Is not empty" },
+ { value: "greaterThan", labelKey: "Greater than" },
+ { value: "lessThan", labelKey: "Less than" },
+ { value: "before", labelKey: "Before" },
+ { value: "after", labelKey: "After" },
+];
+
+const NO_VALUE_OPERATORS: ViewFilterOperator[] = ["isEmpty", "isNotEmpty"];
+
+type ViewFilterConfigProps = {
+ opened: boolean;
+ onClose: () => void;
+ filters: ViewFilterConfig[];
+ properties: IBaseProperty[];
+ onChange: (filters: ViewFilterConfig[]) => void;
+ children: React.ReactNode;
+};
+
+export function ViewFilterConfigPopover({
+ opened,
+ onClose,
+ filters,
+ properties,
+ onChange,
+ children,
+}: ViewFilterConfigProps) {
+ const { t } = useTranslation();
+
+ const propertyOptions = properties.map((p) => ({
+ value: p.id,
+ label: p.name,
+ }));
+
+ const operatorOptions = OPERATORS.map((op) => ({
+ value: op.value,
+ label: t(op.labelKey),
+ }));
+
+ const handleAdd = useCallback(() => {
+ const firstProperty = properties[0];
+ if (!firstProperty) return;
+ onChange([
+ ...filters,
+ { propertyId: firstProperty.id, operator: "contains" },
+ ]);
+ }, [filters, properties, onChange]);
+
+ const handleRemove = useCallback(
+ (index: number) => {
+ onChange(filters.filter((_, i) => i !== index));
+ },
+ [filters, onChange],
+ );
+
+ const handlePropertyChange = useCallback(
+ (index: number, propertyId: string | null) => {
+ if (!propertyId) return;
+ onChange(
+ filters.map((f, i) => (i === index ? { ...f, propertyId } : f)),
+ );
+ },
+ [filters, onChange],
+ );
+
+ const handleOperatorChange = useCallback(
+ (index: number, operator: string | null) => {
+ if (!operator) return;
+ const op = operator as ViewFilterOperator;
+ const needsValue = !NO_VALUE_OPERATORS.includes(op);
+ onChange(
+ filters.map((f, i) =>
+ i === index
+ ? {
+ ...f,
+ operator: op,
+ value: needsValue ? f.value : undefined,
+ }
+ : f,
+ ),
+ );
+ },
+ [filters, onChange],
+ );
+
+ const handleValueChange = useCallback(
+ (index: number, value: string) => {
+ onChange(
+ filters.map((f, i) =>
+ i === index ? { ...f, value: value || undefined } : f,
+ ),
+ );
+ },
+ [filters, onChange],
+ );
+
+ return (
+
+ {children}
+
+
+
+ {t("Filter by")}
+
+
+ {filters.length === 0 && (
+
+ {t("No filters applied")}
+
+ )}
+
+ {filters.map((filter, index) => {
+ const needsValue = !NO_VALUE_OPERATORS.includes(filter.operator);
+
+ return (
+
+
+ );
+ })}
+
+
+
+ {t("Add filter")}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/base/components/views/view-sort-config.tsx b/apps/client/src/features/base/components/views/view-sort-config.tsx
new file mode 100644
index 00000000..5043213e
--- /dev/null
+++ b/apps/client/src/features/base/components/views/view-sort-config.tsx
@@ -0,0 +1,153 @@
+import { useCallback } from "react";
+import {
+ Popover,
+ Stack,
+ Group,
+ Select,
+ ActionIcon,
+ Text,
+ UnstyledButton,
+} from "@mantine/core";
+import { IconPlus, IconTrash, IconSortAscending } from "@tabler/icons-react";
+import {
+ IBaseProperty,
+ ViewSortConfig,
+} from "@/features/base/types/base.types";
+import { useTranslation } from "react-i18next";
+
+type ViewSortConfigProps = {
+ opened: boolean;
+ onClose: () => void;
+ sorts: ViewSortConfig[];
+ properties: IBaseProperty[];
+ onChange: (sorts: ViewSortConfig[]) => void;
+ children: React.ReactNode;
+};
+
+export function ViewSortConfigPopover({
+ opened,
+ onClose,
+ sorts,
+ properties,
+ onChange,
+ children,
+}: ViewSortConfigProps) {
+ const { t } = useTranslation();
+
+ const propertyOptions = properties.map((p) => ({
+ value: p.id,
+ label: p.name,
+ }));
+
+ const directionOptions = [
+ { value: "asc", label: t("Ascending") },
+ { value: "desc", label: t("Descending") },
+ ];
+
+ const handleAdd = useCallback(() => {
+ const usedIds = new Set(sorts.map((s) => s.propertyId));
+ const available = properties.find((p) => !usedIds.has(p.id));
+ if (!available) return;
+ onChange([...sorts, { propertyId: available.id, direction: "asc" }]);
+ }, [sorts, properties, onChange]);
+
+ const handleRemove = useCallback(
+ (index: number) => {
+ onChange(sorts.filter((_, i) => i !== index));
+ },
+ [sorts, onChange],
+ );
+
+ const handlePropertyChange = useCallback(
+ (index: number, propertyId: string | null) => {
+ if (!propertyId) return;
+ onChange(
+ sorts.map((s, i) => (i === index ? { ...s, propertyId } : s)),
+ );
+ },
+ [sorts, onChange],
+ );
+
+ const handleDirectionChange = useCallback(
+ (index: number, direction: string | null) => {
+ if (!direction) return;
+ onChange(
+ sorts.map((s, i) =>
+ i === index
+ ? { ...s, direction: direction as "asc" | "desc" }
+ : s,
+ ),
+ );
+ },
+ [sorts, onChange],
+ );
+
+ return (
+
+ {children}
+
+
+
+ {t("Sort by")}
+
+
+ {sorts.length === 0 && (
+
+ {t("No sorts applied")}
+
+ )}
+
+ {sorts.map((sort, index) => (
+
+ handlePropertyChange(index, val)}
+ style={{ flex: 1 }}
+ />
+ handleDirectionChange(index, val)}
+ w={110}
+ />
+ handleRemove(index)}
+ >
+
+
+
+ ))}
+
+
+
+ {t("Add sort")}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/base/components/views/view-tabs.tsx b/apps/client/src/features/base/components/views/view-tabs.tsx
new file mode 100644
index 00000000..4f9ef769
--- /dev/null
+++ b/apps/client/src/features/base/components/views/view-tabs.tsx
@@ -0,0 +1,237 @@
+import { useState, useCallback } from "react";
+import {
+ Group,
+ UnstyledButton,
+ Text,
+ ActionIcon,
+ Tooltip,
+ TextInput,
+ Popover,
+ Stack,
+ Divider,
+} from "@mantine/core";
+import { IconPlus, IconPencil, IconTrash, IconTable } from "@tabler/icons-react";
+import { IBaseView } from "@/features/base/types/base.types";
+import {
+ useUpdateViewMutation,
+ useDeleteViewMutation,
+} from "@/features/base/queries/base-view-query";
+import { useTranslation } from "react-i18next";
+import cellClasses from "@/features/base/styles/cells.module.css";
+
+type ViewTabsProps = {
+ views: IBaseView[];
+ activeViewId: string | undefined;
+ baseId: string;
+ onViewChange: (viewId: string) => void;
+ onAddView?: () => void;
+};
+
+export function ViewTabs({
+ views,
+ activeViewId,
+ baseId,
+ onViewChange,
+ onAddView,
+}: ViewTabsProps) {
+ const { t } = useTranslation();
+ const [editingViewId, setEditingViewId] = useState(null);
+ const [editingName, setEditingName] = useState("");
+
+ const updateViewMutation = useUpdateViewMutation();
+ const deleteViewMutation = useDeleteViewMutation();
+
+ const handleRenameStart = useCallback(
+ (view: IBaseView) => {
+ setEditingViewId(view.id);
+ setEditingName(view.name);
+ },
+ [],
+ );
+
+ const handleRenameCommit = useCallback(() => {
+ if (!editingViewId) return;
+ const trimmed = editingName.trim();
+ const view = views.find((v) => v.id === editingViewId);
+ if (trimmed && view && trimmed !== view.name) {
+ updateViewMutation.mutate({
+ viewId: editingViewId,
+ baseId,
+ name: trimmed,
+ });
+ }
+ setEditingViewId(null);
+ }, [editingViewId, editingName, views, baseId, updateViewMutation]);
+
+ const handleRenameKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleRenameCommit();
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ setEditingViewId(null);
+ }
+ },
+ [handleRenameCommit],
+ );
+
+ const handleDelete = useCallback(
+ (viewId: string) => {
+ if (views.length <= 1) return;
+ deleteViewMutation.mutate({ viewId, baseId });
+ if (viewId === activeViewId && views.length > 1) {
+ const remaining = views.filter((v) => v.id !== viewId);
+ onViewChange(remaining[0].id);
+ }
+ },
+ [views, baseId, activeViewId, deleteViewMutation, onViewChange],
+ );
+
+ return (
+
+ {views.map((view) => (
+ 1}
+ onClick={() => onViewChange(view.id)}
+ onRenameStart={() => handleRenameStart(view)}
+ onRenameChange={setEditingName}
+ onRenameCommit={handleRenameCommit}
+ onRenameKeyDown={handleRenameKeyDown}
+ onDelete={() => handleDelete(view.id)}
+ />
+ ))}
+ {onAddView && (
+
+
+
+
+
+ )}
+
+ );
+}
+
+function ViewTab({
+ view,
+ isActive,
+ isEditing,
+ editingName,
+ canDelete,
+ onClick,
+ onRenameStart,
+ onRenameChange,
+ onRenameCommit,
+ onRenameKeyDown,
+ onDelete,
+}: {
+ view: IBaseView;
+ isActive: boolean;
+ isEditing: boolean;
+ editingName: string;
+ canDelete: boolean;
+ onClick: () => void;
+ onRenameStart: () => void;
+ onRenameChange: (name: string) => void;
+ onRenameCommit: () => void;
+ onRenameKeyDown: (e: React.KeyboardEvent) => void;
+ onDelete: () => void;
+}) {
+ const { t } = useTranslation();
+ const [menuOpened, setMenuOpened] = useState(false);
+
+ if (isEditing) {
+ return (
+ onRenameChange(e.currentTarget.value)}
+ onBlur={onRenameCommit}
+ onKeyDown={onRenameKeyDown}
+ autoFocus
+ />
+ );
+ }
+
+ return (
+ setMenuOpened(false)}
+ position="bottom-start"
+ shadow="md"
+ width={180}
+ withinPortal
+ >
+
+ {
+ e.preventDefault();
+ setMenuOpened(true);
+ }}
+ style={{
+ padding: "4px 10px",
+ borderRadius: "var(--mantine-radius-sm)",
+ fontWeight: isActive ? 600 : 400,
+ }}
+ >
+
+
+
+ {view.name}
+
+
+
+
+
+
+ {
+ setMenuOpened(false);
+ onRenameStart();
+ }}
+ >
+
+
+ {t("Rename")}
+
+
+ {canDelete && (
+ <>
+
+ {
+ setMenuOpened(false);
+ onDelete();
+ }}
+ style={{ color: "var(--mantine-color-red-6)" }}
+ >
+
+
+ {t("Delete view")}
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/apps/client/src/features/base/hooks/use-base-table.ts b/apps/client/src/features/base/hooks/use-base-table.ts
new file mode 100644
index 00000000..963a95e1
--- /dev/null
+++ b/apps/client/src/features/base/hooks/use-base-table.ts
@@ -0,0 +1,304 @@
+import { useMemo, useCallback, useRef, useState, useEffect } from "react";
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ createColumnHelper,
+ ColumnDef,
+ SortingState,
+ ColumnSizingState,
+ VisibilityState,
+ ColumnOrderState,
+ ColumnPinningState,
+ Table,
+} from "@tanstack/react-table";
+import {
+ IBase,
+ IBaseProperty,
+ IBaseRow,
+ IBaseView,
+ ViewConfig,
+} from "@/features/base/types/base.types";
+import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
+
+const DEFAULT_COLUMN_WIDTH = 180;
+const MIN_COLUMN_WIDTH = 80;
+const MAX_COLUMN_WIDTH = 600;
+const ROW_NUMBER_COLUMN_WIDTH = 50;
+
+export const SYSTEM_PROPERTY_TYPES = new Set(["createdAt", "lastEditedAt", "lastEditedBy"]);
+
+export function isSystemPropertyType(type: string): boolean {
+ return SYSTEM_PROPERTY_TYPES.has(type);
+}
+
+const columnHelper = createColumnHelper();
+
+function getSystemAccessor(type: string): ((row: IBaseRow) => unknown) | null {
+ switch (type) {
+ case "createdAt":
+ return (row) => row.createdAt;
+ case "lastEditedAt":
+ return (row) => row.updatedAt;
+ case "lastEditedBy":
+ return (row) => row.lastUpdatedById ?? row.creatorId;
+ default:
+ return null;
+ }
+}
+
+function buildColumns(properties: IBaseProperty[]): ColumnDef[] {
+ const rowNumberColumn = columnHelper.display({
+ id: "__row_number",
+ header: "#",
+ size: ROW_NUMBER_COLUMN_WIDTH,
+ minSize: ROW_NUMBER_COLUMN_WIDTH,
+ maxSize: ROW_NUMBER_COLUMN_WIDTH,
+ enableResizing: false,
+ enableSorting: false,
+ enableHiding: false,
+ });
+
+ const propertyColumns = properties.map((property) => {
+ const sysAccessor = getSystemAccessor(property.type);
+ if (sysAccessor) {
+ return columnHelper.accessor(sysAccessor, {
+ id: property.id,
+ header: property.name,
+ size: DEFAULT_COLUMN_WIDTH,
+ minSize: MIN_COLUMN_WIDTH,
+ maxSize: MAX_COLUMN_WIDTH,
+ enableResizing: true,
+ enableSorting: false,
+ enableHiding: !property.isPrimary,
+ meta: { property },
+ });
+ }
+
+ return columnHelper.accessor((row) => row.cells[property.id], {
+ id: property.id,
+ header: property.name,
+ size: DEFAULT_COLUMN_WIDTH,
+ minSize: MIN_COLUMN_WIDTH,
+ maxSize: MAX_COLUMN_WIDTH,
+ enableResizing: true,
+ enableSorting: true,
+ enableHiding: !property.isPrimary,
+ meta: { property },
+ });
+ });
+
+ return [rowNumberColumn, ...propertyColumns];
+}
+
+function buildSortingState(config: ViewConfig | undefined): SortingState {
+ if (!config?.sorts?.length) return [];
+ return config.sorts.map((sort) => ({
+ id: sort.propertyId,
+ desc: sort.direction === "desc",
+ }));
+}
+
+function buildColumnSizing(
+ config: ViewConfig | undefined,
+): ColumnSizingState {
+ const sizing: ColumnSizingState = {
+ __row_number: ROW_NUMBER_COLUMN_WIDTH,
+ };
+ if (config?.propertyWidths) {
+ Object.entries(config.propertyWidths).forEach(([id, width]) => {
+ sizing[id] = width;
+ });
+ }
+ return sizing;
+}
+
+function buildColumnVisibility(
+ config: ViewConfig | undefined,
+ properties: IBaseProperty[],
+): VisibilityState {
+ const visibility: VisibilityState = { __row_number: true };
+
+ if (config?.hiddenPropertyIds) {
+ const hiddenSet = new Set(config.hiddenPropertyIds);
+ properties.forEach((p) => {
+ visibility[p.id] = !hiddenSet.has(p.id);
+ });
+ return visibility;
+ }
+
+ if (config?.visiblePropertyIds?.length) {
+ const visibleSet = new Set(config.visiblePropertyIds);
+ properties.forEach((p) => {
+ visibility[p.id] = visibleSet.has(p.id);
+ });
+ return visibility;
+ }
+
+ properties.forEach((p) => {
+ visibility[p.id] = true;
+ });
+ return visibility;
+}
+
+function buildColumnOrder(
+ config: ViewConfig | undefined,
+ properties: IBaseProperty[],
+): ColumnOrderState {
+ if (config?.propertyOrder?.length) {
+ const orderSet = new Set(config.propertyOrder);
+ const missing = properties
+ .filter((p) => !orderSet.has(p.id))
+ .sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0))
+ .map((p) => p.id);
+ return ["__row_number", ...config.propertyOrder, ...missing];
+ }
+ const sorted = [...properties].sort((a, b) => {
+ if (a.isPrimary) return -1;
+ if (b.isPrimary) return 1;
+ return a.position < b.position ? -1 : a.position > b.position ? 1 : 0;
+ });
+ return ["__row_number", ...sorted.map((p) => p.id)];
+}
+
+function buildColumnPinning(
+ properties: IBaseProperty[],
+): ColumnPinningState {
+ const primary = properties.find((p) => p.isPrimary);
+ return {
+ left: primary ? ["__row_number", primary.id] : ["__row_number"],
+ right: [],
+ };
+}
+
+export type UseBaseTableResult = {
+ table: Table;
+ persistViewConfig: () => void;
+};
+
+export function useBaseTable(
+ base: IBase | undefined,
+ rows: IBaseRow[],
+ activeView: IBaseView | undefined,
+): UseBaseTableResult {
+ const updateViewMutation = useUpdateViewMutation();
+ const persistTimerRef = useRef | null>(null);
+
+ const properties = base?.properties ?? [];
+ const viewConfig = activeView?.config;
+
+ const columns = useMemo(
+ () => buildColumns(properties),
+ [properties],
+ );
+
+ const initialSorting = useMemo(
+ () => buildSortingState(viewConfig),
+ [viewConfig],
+ );
+
+ const initialColumnSizing = useMemo(
+ () => buildColumnSizing(viewConfig),
+ [viewConfig],
+ );
+
+ const derivedColumnOrder = useMemo(
+ () => buildColumnOrder(viewConfig, properties),
+ [viewConfig, properties],
+ );
+
+ const derivedColumnVisibility = useMemo(
+ () => buildColumnVisibility(viewConfig, properties),
+ [viewConfig, properties],
+ );
+
+ const [columnOrder, setColumnOrder] = useState(derivedColumnOrder);
+ const [columnVisibility, setColumnVisibility] = useState(derivedColumnVisibility);
+
+ useEffect(() => {
+ setColumnOrder(derivedColumnOrder);
+ }, [derivedColumnOrder]);
+
+ useEffect(() => {
+ setColumnVisibility(derivedColumnVisibility);
+ }, [derivedColumnVisibility]);
+
+ const columnPinning = useMemo(
+ () => buildColumnPinning(properties),
+ [properties],
+ );
+
+ const table = useReactTable({
+ data: rows,
+ columns,
+ state: {
+ columnPinning,
+ columnOrder,
+ columnVisibility,
+ },
+ onColumnOrderChange: setColumnOrder,
+ onColumnVisibilityChange: setColumnVisibility,
+ initialState: {
+ sorting: initialSorting,
+ columnSizing: initialColumnSizing,
+ },
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ columnResizeMode: "onChange",
+ enableColumnResizing: true,
+ enableSorting: true,
+ enableHiding: true,
+ getRowId: (row) => row.id,
+ });
+
+ const persistViewConfig = useCallback(() => {
+ if (!activeView || !base) return;
+
+ if (persistTimerRef.current) {
+ clearTimeout(persistTimerRef.current);
+ }
+
+ persistTimerRef.current = setTimeout(() => {
+ const state = table.getState();
+
+ const sorts = state.sorting.map((s) => ({
+ propertyId: s.id,
+ direction: (s.desc ? "desc" : "asc") as "asc" | "desc",
+ }));
+
+ const propertyWidths: Record = {};
+ Object.entries(state.columnSizing).forEach(([id, width]) => {
+ if (id !== "__row_number") {
+ propertyWidths[id] = width;
+ }
+ });
+
+ const propertyOrder = state.columnOrder.filter(
+ (id) => id !== "__row_number",
+ );
+
+ const hiddenPropertyIds = Object.entries(state.columnVisibility)
+ .filter(([id, visible]) => id !== "__row_number" && !visible)
+ .map(([id]) => id);
+
+ const config: ViewConfig = {
+ ...activeView.config,
+ sorts,
+ propertyWidths,
+ propertyOrder,
+ hiddenPropertyIds,
+ visiblePropertyIds: undefined,
+ };
+
+ updateViewMutation.mutate({
+ viewId: activeView.id,
+ baseId: base.id,
+ config,
+ });
+ }, 300);
+ }, [activeView, base, table, updateViewMutation]);
+
+ return { table, persistViewConfig };
+}
diff --git a/apps/client/src/features/base/hooks/use-column-resize.ts b/apps/client/src/features/base/hooks/use-column-resize.ts
new file mode 100644
index 00000000..4c373700
--- /dev/null
+++ b/apps/client/src/features/base/hooks/use-column-resize.ts
@@ -0,0 +1,26 @@
+import { useEffect, useRef, useCallback } from "react";
+import { Table } from "@tanstack/react-table";
+import { IBaseRow } from "@/features/base/types/base.types";
+
+export function useColumnResize(
+ table: Table,
+ onResizeEnd: () => void,
+) {
+ const wasResizingRef = useRef(false);
+
+ const checkResizeEnd = useCallback(() => {
+ const isResizing = table.getState().columnSizingInfo.isResizingColumn;
+ if (wasResizingRef.current && !isResizing) {
+ onResizeEnd();
+ }
+ wasResizingRef.current = !!isResizing;
+ }, [table, onResizeEnd]);
+
+ useEffect(() => {
+ checkResizeEnd();
+ });
+
+ return {
+ isResizing: !!table.getState().columnSizingInfo.isResizingColumn,
+ };
+}
diff --git a/apps/client/src/features/base/hooks/use-grid-keyboard-nav.ts b/apps/client/src/features/base/hooks/use-grid-keyboard-nav.ts
new file mode 100644
index 00000000..12f92ab5
--- /dev/null
+++ b/apps/client/src/features/base/hooks/use-grid-keyboard-nav.ts
@@ -0,0 +1,117 @@
+import { useCallback, useEffect } from "react";
+import { Table } from "@tanstack/react-table";
+import { IBaseRow, EditingCell } from "@/features/base/types/base.types";
+
+type UseGridKeyboardNavOptions = {
+ table: Table;
+ editingCell: EditingCell;
+ setEditingCell: (cell: EditingCell) => void;
+ containerRef: React.RefObject;
+};
+
+export function useGridKeyboardNav({
+ table,
+ editingCell,
+ setEditingCell,
+ containerRef,
+}: UseGridKeyboardNavOptions) {
+ const getNavigableColumns = useCallback(() => {
+ return table
+ .getVisibleLeafColumns()
+ .filter((col) => col.id !== "__row_number")
+ .map((col) => col.id);
+ }, [table]);
+
+ const getRowIds = useCallback(() => {
+ return table.getRowModel().rows.map((row) => row.id);
+ }, [table]);
+
+ const navigate = useCallback(
+ (rowDelta: number, colDelta: number) => {
+ if (!editingCell) return;
+
+ const columns = getNavigableColumns();
+ const rowIds = getRowIds();
+
+ const currentColIndex = columns.indexOf(editingCell.propertyId);
+ const currentRowIndex = rowIds.indexOf(editingCell.rowId);
+
+ if (currentColIndex === -1 || currentRowIndex === -1) return;
+
+ let nextColIndex = currentColIndex + colDelta;
+ let nextRowIndex = currentRowIndex + rowDelta;
+
+ if (nextColIndex < 0) {
+ nextColIndex = columns.length - 1;
+ nextRowIndex -= 1;
+ } else if (nextColIndex >= columns.length) {
+ nextColIndex = 0;
+ nextRowIndex += 1;
+ }
+
+ if (nextRowIndex < 0 || nextRowIndex >= rowIds.length) return;
+
+ setEditingCell({
+ rowId: rowIds[nextRowIndex],
+ propertyId: columns[nextColIndex],
+ });
+ },
+ [editingCell, getNavigableColumns, getRowIds, setEditingCell],
+ );
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (!editingCell) return;
+
+ const target = e.target as HTMLElement;
+ const isInputActive =
+ target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable;
+
+ switch (e.key) {
+ case "ArrowUp":
+ if (!isInputActive) {
+ e.preventDefault();
+ navigate(-1, 0);
+ }
+ break;
+ case "ArrowDown":
+ if (!isInputActive) {
+ e.preventDefault();
+ navigate(1, 0);
+ }
+ break;
+ case "ArrowLeft":
+ if (!isInputActive) {
+ e.preventDefault();
+ navigate(0, -1);
+ }
+ break;
+ case "ArrowRight":
+ if (!isInputActive) {
+ e.preventDefault();
+ navigate(0, 1);
+ }
+ break;
+ case "Tab":
+ e.preventDefault();
+ navigate(0, e.shiftKey ? -1 : 1);
+ break;
+ case "Escape":
+ e.preventDefault();
+ setEditingCell(null);
+ break;
+ }
+ },
+ [editingCell, navigate, setEditingCell],
+ );
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ container.addEventListener("keydown", handleKeyDown);
+ return () => container.removeEventListener("keydown", handleKeyDown);
+ }, [containerRef, handleKeyDown]);
+}
diff --git a/apps/client/src/features/base/hooks/use-row-drag.ts b/apps/client/src/features/base/hooks/use-row-drag.ts
new file mode 100644
index 00000000..820f7f38
--- /dev/null
+++ b/apps/client/src/features/base/hooks/use-row-drag.ts
@@ -0,0 +1,115 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+
+type RowDragState = {
+ dragRowId: string | null;
+ dropTargetRowId: string | null;
+ dropPosition: "above" | "below" | null;
+};
+
+type UseRowDragOptions = {
+ rowIds: string[];
+ onReorder: (rowId: string, targetRowId: string, position: "above" | "below") => void;
+};
+
+export function useRowDrag({ rowIds, onReorder }: UseRowDragOptions) {
+ const [dragState, setDragState] = useState({
+ dragRowId: null,
+ dropTargetRowId: null,
+ dropPosition: null,
+ });
+
+ const dragRowIdRef = useRef(null);
+ const dropTargetRef = useRef(null);
+ const dropPositionRef = useRef<"above" | "below" | null>(null);
+ const onReorderRef = useRef(onReorder);
+ onReorderRef.current = onReorder;
+
+ const handleDragStart = useCallback((rowId: string) => {
+ dragRowIdRef.current = rowId;
+ dropTargetRef.current = null;
+ dropPositionRef.current = null;
+ setDragState({
+ dragRowId: rowId,
+ dropTargetRowId: null,
+ dropPosition: null,
+ });
+ }, []);
+
+ const handleDragOver = useCallback(
+ (targetRowId: string, e: React.DragEvent) => {
+ e.preventDefault();
+ if (!dragRowIdRef.current || dragRowIdRef.current === targetRowId) return;
+
+ const rect = e.currentTarget.getBoundingClientRect();
+ const midY = rect.top + rect.height / 2;
+ const position: "above" | "below" = e.clientY < midY ? "above" : "below";
+
+ if (dropTargetRef.current === targetRowId && dropPositionRef.current === position) {
+ return;
+ }
+
+ dropTargetRef.current = targetRowId;
+ dropPositionRef.current = position;
+
+ setDragState({
+ dragRowId: dragRowIdRef.current,
+ dropTargetRowId: targetRowId,
+ dropPosition: position,
+ });
+ },
+ [],
+ );
+
+ const handleDragEnd = useCallback(() => {
+ const dragRowId = dragRowIdRef.current;
+ const dropTargetRowId = dropTargetRef.current;
+ const dropPosition = dropPositionRef.current;
+
+ if (dragRowId && dropTargetRowId && dropPosition && dragRowId !== dropTargetRowId) {
+ onReorderRef.current(dragRowId, dropTargetRowId, dropPosition);
+ }
+
+ dragRowIdRef.current = null;
+ dropTargetRef.current = null;
+ dropPositionRef.current = null;
+ setDragState({
+ dragRowId: null,
+ dropTargetRowId: null,
+ dropPosition: null,
+ });
+ }, []);
+
+ const handleDragLeave = useCallback(() => {
+ dropTargetRef.current = null;
+ dropPositionRef.current = null;
+ setDragState((prev) => ({
+ ...prev,
+ dropTargetRowId: null,
+ dropPosition: null,
+ }));
+ }, []);
+
+ useEffect(() => {
+ const handleGlobalDragEnd = () => {
+ dragRowIdRef.current = null;
+ dropTargetRef.current = null;
+ dropPositionRef.current = null;
+ setDragState({
+ dragRowId: null,
+ dropTargetRowId: null,
+ dropPosition: null,
+ });
+ };
+
+ document.addEventListener("dragend", handleGlobalDragEnd);
+ return () => document.removeEventListener("dragend", handleGlobalDragEnd);
+ }, []);
+
+ return {
+ dragState,
+ handleDragStart,
+ handleDragOver,
+ handleDragEnd,
+ handleDragLeave,
+ };
+}
diff --git a/apps/client/src/features/base/queries/base-property-query.ts b/apps/client/src/features/base/queries/base-property-query.ts
new file mode 100644
index 00000000..a4531ee8
--- /dev/null
+++ b/apps/client/src/features/base/queries/base-property-query.ts
@@ -0,0 +1,154 @@
+import { useMutation } from "@tanstack/react-query";
+import {
+ createProperty,
+ updateProperty,
+ deleteProperty,
+ reorderProperty,
+} from "@/features/base/services/base-service";
+import {
+ IBase,
+ IBaseProperty,
+ CreatePropertyInput,
+ UpdatePropertyInput,
+ DeletePropertyInput,
+ ReorderPropertyInput,
+ UpdatePropertyResult,
+} from "@/features/base/types/base.types";
+import { notifications } from "@mantine/notifications";
+import { queryClient } from "@/main";
+import { useTranslation } from "react-i18next";
+
+export function useCreatePropertyMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => createProperty(data),
+ onSuccess: (newProperty) => {
+ queryClient.setQueryData(
+ ["bases", newProperty.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ properties: [...old.properties, newProperty],
+ };
+ },
+ );
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to create property"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useUpdatePropertyMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => updateProperty(data),
+ onSuccess: (result, variables) => {
+ queryClient.setQueryData(
+ ["bases", variables.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ properties: old.properties.map((p) =>
+ p.id === result.property.id ? result.property : p,
+ ),
+ };
+ },
+ );
+
+ if (result.conversionSummary || variables.type) {
+ queryClient.invalidateQueries({
+ queryKey: ["base-rows", variables.baseId],
+ });
+ }
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to update property"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useDeletePropertyMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => deleteProperty(data),
+ onSuccess: (_, variables) => {
+ queryClient.setQueryData(
+ ["bases", variables.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ properties: old.properties.filter(
+ (p) => p.id !== variables.propertyId,
+ ),
+ };
+ },
+ );
+
+ queryClient.invalidateQueries({
+ queryKey: ["base-rows", variables.baseId],
+ });
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to delete property"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useReorderPropertyMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => reorderProperty(data),
+ onMutate: async (variables) => {
+ await queryClient.cancelQueries({
+ queryKey: ["bases", variables.baseId],
+ });
+
+ const previous = queryClient.getQueryData([
+ "bases",
+ variables.baseId,
+ ]);
+
+ queryClient.setQueryData(
+ ["bases", variables.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ properties: old.properties.map((p) =>
+ p.id === variables.propertyId
+ ? { ...p, position: variables.position }
+ : p,
+ ),
+ };
+ },
+ );
+
+ return { previous };
+ },
+ onError: (_, variables, context) => {
+ if (context?.previous) {
+ queryClient.setQueryData(
+ ["bases", variables.baseId],
+ context.previous,
+ );
+ }
+ notifications.show({
+ message: t("Failed to reorder property"),
+ color: "red",
+ });
+ },
+ });
+}
diff --git a/apps/client/src/features/base/queries/base-query.ts b/apps/client/src/features/base/queries/base-query.ts
new file mode 100644
index 00000000..95be08c4
--- /dev/null
+++ b/apps/client/src/features/base/queries/base-query.ts
@@ -0,0 +1,87 @@
+import {
+ useMutation,
+ useQuery,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import {
+ createBase,
+ getBaseInfo,
+ updateBase,
+ deleteBase,
+} from "@/features/base/services/base-service";
+import {
+ IBase,
+ CreateBaseInput,
+ UpdateBaseInput,
+} from "@/features/base/types/base.types";
+import { notifications } from "@mantine/notifications";
+import { queryClient } from "@/main";
+import { useTranslation } from "react-i18next";
+
+export function useBaseQuery(
+ baseId: string | undefined,
+): UseQueryResult {
+ return useQuery({
+ queryKey: ["bases", baseId],
+ queryFn: () => getBaseInfo(baseId!),
+ enabled: !!baseId,
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useCreateBaseMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => createBase(data),
+ onSuccess: (data) => {
+ queryClient.invalidateQueries({
+ queryKey: ["bases", "list", data.spaceId],
+ });
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to create base"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useUpdateBaseMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => updateBase(data),
+ onSuccess: (data) => {
+ queryClient.setQueryData(["bases", data.id], (old) => {
+ if (!old) return old;
+ return { ...old, ...data };
+ });
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to update base"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useDeleteBaseMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: ({ baseId }) => deleteBase(baseId),
+ onSuccess: (_, { baseId, spaceId }) => {
+ queryClient.removeQueries({ queryKey: ["bases", baseId] });
+ queryClient.invalidateQueries({
+ queryKey: ["bases", "list", spaceId],
+ });
+ notifications.show({ message: t("Base deleted") });
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to delete base"),
+ color: "red",
+ });
+ },
+ });
+}
diff --git a/apps/client/src/features/base/queries/base-row-query.ts b/apps/client/src/features/base/queries/base-row-query.ts
new file mode 100644
index 00000000..f856e52d
--- /dev/null
+++ b/apps/client/src/features/base/queries/base-row-query.ts
@@ -0,0 +1,240 @@
+import {
+ useInfiniteQuery,
+ useMutation,
+ InfiniteData,
+} from "@tanstack/react-query";
+import {
+ createRow,
+ updateRow,
+ deleteRow,
+ listRows,
+ reorderRow,
+} from "@/features/base/services/base-service";
+import {
+ IBaseRow,
+ CreateRowInput,
+ UpdateRowInput,
+ DeleteRowInput,
+ ReorderRowInput,
+} from "@/features/base/types/base.types";
+import { notifications } from "@mantine/notifications";
+import { queryClient } from "@/main";
+import { useTranslation } from "react-i18next";
+import { IPagination } from "@/lib/types";
+
+type RowCacheContext = {
+ previous: InfiniteData> | undefined;
+};
+
+export function useBaseRowsQuery(baseId: string | undefined) {
+ return useInfiniteQuery({
+ queryKey: ["base-rows", baseId],
+ queryFn: ({ pageParam }) =>
+ listRows(baseId!, { cursor: pageParam, limit: 100 }),
+ enabled: !!baseId,
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage: IPagination) =>
+ lastPage.meta?.nextCursor ?? undefined,
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function flattenRows(
+ data: InfiniteData> | undefined,
+): IBaseRow[] {
+ if (!data) return [];
+ return data.pages.flatMap((page) => page.items);
+}
+
+export function useCreateRowMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => createRow(data),
+ onSuccess: (newRow) => {
+ queryClient.setQueryData>>(
+ ["base-rows", newRow.baseId],
+ (old) => {
+ if (!old) return old;
+ const lastPageIndex = old.pages.length - 1;
+ return {
+ ...old,
+ pages: old.pages.map((page, index) => {
+ if (index === lastPageIndex) {
+ return { ...page, items: [...page.items, newRow] };
+ }
+ return page;
+ }),
+ };
+ },
+ );
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to create row"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useUpdateRowMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => updateRow(data),
+ onMutate: async (variables) => {
+ await queryClient.cancelQueries({
+ queryKey: ["base-rows", variables.baseId],
+ });
+
+ const previous = queryClient.getQueryData<
+ InfiniteData>
+ >(["base-rows", variables.baseId]);
+
+ queryClient.setQueryData>>(
+ ["base-rows", variables.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.map((row) =>
+ row.id === variables.rowId
+ ? {
+ ...row,
+ cells: { ...row.cells, ...variables.cells },
+ }
+ : row,
+ ),
+ })),
+ };
+ },
+ );
+
+ return { previous };
+ },
+ onError: (_, variables, context) => {
+ if (context?.previous) {
+ queryClient.setQueryData(
+ ["base-rows", variables.baseId],
+ context.previous,
+ );
+ }
+ notifications.show({
+ message: t("Failed to update row"),
+ color: "red",
+ });
+ },
+ onSuccess: (updatedRow) => {
+ queryClient.setQueryData>>(
+ ["base-rows", updatedRow.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.map((row) =>
+ row.id === updatedRow.id
+ ? { ...row, ...updatedRow, cells: { ...row.cells, ...updatedRow.cells } }
+ : row,
+ ),
+ })),
+ };
+ },
+ );
+ },
+ });
+}
+
+export function useDeleteRowMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => deleteRow(data),
+ onMutate: async (variables) => {
+ await queryClient.cancelQueries({
+ queryKey: ["base-rows", variables.baseId],
+ });
+
+ const previous = queryClient.getQueryData<
+ InfiniteData>
+ >(["base-rows", variables.baseId]);
+
+ queryClient.setQueryData>>(
+ ["base-rows", variables.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.filter((row) => row.id !== variables.rowId),
+ })),
+ };
+ },
+ );
+
+ return { previous };
+ },
+ onError: (_, variables, context) => {
+ if (context?.previous) {
+ queryClient.setQueryData(
+ ["base-rows", variables.baseId],
+ context.previous,
+ );
+ }
+ notifications.show({
+ message: t("Failed to delete row"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useReorderRowMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => reorderRow(data),
+ onMutate: async (variables) => {
+ await queryClient.cancelQueries({
+ queryKey: ["base-rows", variables.baseId],
+ });
+
+ const previous = queryClient.getQueryData<
+ InfiniteData>
+ >(["base-rows", variables.baseId]);
+
+ queryClient.setQueryData>>(
+ ["base-rows", variables.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.map((row) =>
+ row.id === variables.rowId
+ ? { ...row, position: variables.position }
+ : row,
+ ),
+ })),
+ };
+ },
+ );
+
+ return { previous };
+ },
+ onError: (_, variables, context) => {
+ if (context?.previous) {
+ queryClient.setQueryData(
+ ["base-rows", variables.baseId],
+ context.previous,
+ );
+ }
+ notifications.show({
+ message: t("Failed to reorder row"),
+ color: "red",
+ });
+ },
+ });
+}
diff --git a/apps/client/src/features/base/queries/base-view-query.ts b/apps/client/src/features/base/queries/base-view-query.ts
new file mode 100644
index 00000000..2ef124df
--- /dev/null
+++ b/apps/client/src/features/base/queries/base-view-query.ts
@@ -0,0 +1,137 @@
+import { useMutation } from "@tanstack/react-query";
+import {
+ createView,
+ updateView,
+ deleteView,
+} from "@/features/base/services/base-service";
+import {
+ IBase,
+ IBaseView,
+ CreateViewInput,
+ UpdateViewInput,
+ DeleteViewInput,
+} from "@/features/base/types/base.types";
+import { notifications } from "@mantine/notifications";
+import { queryClient } from "@/main";
+import { useTranslation } from "react-i18next";
+
+export function useCreateViewMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => createView(data),
+ onSuccess: (newView) => {
+ queryClient.setQueryData(
+ ["bases", newView.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ views: [...old.views, newView],
+ };
+ },
+ );
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to create view"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useUpdateViewMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => updateView(data),
+ onMutate: async (variables) => {
+ await queryClient.cancelQueries({
+ queryKey: ["bases", variables.baseId],
+ });
+
+ const previous = queryClient.getQueryData([
+ "bases",
+ variables.baseId,
+ ]);
+
+ queryClient.setQueryData(
+ ["bases", variables.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ views: old.views.map((v) =>
+ v.id === variables.viewId
+ ? {
+ ...v,
+ ...(variables.name !== undefined && {
+ name: variables.name,
+ }),
+ ...(variables.type !== undefined && {
+ type: variables.type,
+ }),
+ ...(variables.config !== undefined && {
+ config: variables.config,
+ }),
+ }
+ : v,
+ ),
+ };
+ },
+ );
+
+ return { previous };
+ },
+ onError: (_, variables, context) => {
+ if (context?.previous) {
+ queryClient.setQueryData(
+ ["bases", variables.baseId],
+ context.previous,
+ );
+ }
+ notifications.show({
+ message: t("Failed to update view"),
+ color: "red",
+ });
+ },
+ onSuccess: (updatedView) => {
+ queryClient.setQueryData(
+ ["bases", updatedView.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ views: old.views.map((v) =>
+ v.id === updatedView.id ? updatedView : v,
+ ),
+ };
+ },
+ );
+ },
+ });
+}
+
+export function useDeleteViewMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => deleteView(data),
+ onSuccess: (_, variables) => {
+ queryClient.setQueryData(
+ ["bases", variables.baseId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ views: old.views.filter((v) => v.id !== variables.viewId),
+ };
+ },
+ );
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to delete view"),
+ color: "red",
+ });
+ },
+ });
+}
diff --git a/apps/client/src/features/base/services/base-service.ts b/apps/client/src/features/base/services/base-service.ts
new file mode 100644
index 00000000..b00ad7f9
--- /dev/null
+++ b/apps/client/src/features/base/services/base-service.ts
@@ -0,0 +1,137 @@
+import api from "@/lib/api-client";
+import {
+ IBase,
+ IBaseProperty,
+ IBaseRow,
+ IBaseView,
+ CreateBaseInput,
+ UpdateBaseInput,
+ CreatePropertyInput,
+ UpdatePropertyInput,
+ DeletePropertyInput,
+ ReorderPropertyInput,
+ CreateRowInput,
+ UpdateRowInput,
+ DeleteRowInput,
+ ReorderRowInput,
+ CreateViewInput,
+ UpdateViewInput,
+ DeleteViewInput,
+ UpdatePropertyResult,
+} from "@/features/base/types/base.types";
+import { IPagination } from "@/lib/types";
+
+// --- Bases ---
+
+export async function createBase(data: CreateBaseInput): Promise {
+ const req = await api.post("/bases/create", data);
+ return req.data;
+}
+
+export async function getBaseInfo(baseId: string): Promise {
+ const req = await api.post("/bases/info", { baseId });
+ return req.data;
+}
+
+export async function updateBase(data: UpdateBaseInput): Promise {
+ const req = await api.post("/bases/update", data);
+ return req.data;
+}
+
+export async function deleteBase(baseId: string): Promise {
+ await api.post("/bases/delete", { baseId });
+}
+
+export async function listBases(
+ spaceId: string,
+ params?: { cursor?: string; limit?: number },
+): Promise> {
+ const req = await api.post("/bases/list", { spaceId, ...params });
+ return req.data;
+}
+
+// --- Properties ---
+
+export async function createProperty(
+ data: CreatePropertyInput,
+): Promise {
+ const req = await api.post("/bases/properties/create", data);
+ return req.data;
+}
+
+export async function updateProperty(
+ data: UpdatePropertyInput,
+): Promise {
+ const req = await api.post(
+ "/bases/properties/update",
+ data,
+ );
+ return req.data;
+}
+
+export async function deleteProperty(data: DeletePropertyInput): Promise {
+ await api.post("/bases/properties/delete", data);
+}
+
+export async function reorderProperty(
+ data: ReorderPropertyInput,
+): Promise {
+ await api.post("/bases/properties/reorder", data);
+}
+
+// --- Rows ---
+
+export async function createRow(data: CreateRowInput): Promise {
+ const req = await api.post("/bases/rows/create", data);
+ return req.data;
+}
+
+export async function getRowInfo(
+ rowId: string,
+ baseId: string,
+): Promise {
+ const req = await api.post("/bases/rows/info", { rowId, baseId });
+ return req.data;
+}
+
+export async function updateRow(data: UpdateRowInput): Promise {
+ const req = await api.post("/bases/rows/update", data);
+ return req.data;
+}
+
+export async function deleteRow(data: DeleteRowInput): Promise {
+ await api.post("/bases/rows/delete", data);
+}
+
+export async function listRows(
+ baseId: string,
+ params?: { viewId?: string; cursor?: string; limit?: number },
+): Promise> {
+ const req = await api.post("/bases/rows/list", { baseId, ...params });
+ return req.data;
+}
+
+export async function reorderRow(data: ReorderRowInput): Promise {
+ await api.post("/bases/rows/reorder", data);
+}
+
+// --- Views ---
+
+export async function createView(data: CreateViewInput): Promise {
+ const req = await api.post("/bases/views/create", data);
+ return req.data;
+}
+
+export async function updateView(data: UpdateViewInput): Promise {
+ const req = await api.post("/bases/views/update", data);
+ return req.data;
+}
+
+export async function deleteView(data: DeleteViewInput): Promise {
+ await api.post("/bases/views/delete", data);
+}
+
+export async function listViews(baseId: string): Promise {
+ const req = await api.post("/bases/views/list", { baseId });
+ return req.data;
+}
diff --git a/apps/client/src/features/base/styles/cells.module.css b/apps/client/src/features/base/styles/cells.module.css
new file mode 100644
index 00000000..6212a6d0
--- /dev/null
+++ b/apps/client/src/features/base/styles/cells.module.css
@@ -0,0 +1,182 @@
+.cellInput {
+ width: 100%;
+ height: 100%;
+ border: none;
+ outline: none;
+ background: transparent;
+ font-size: var(--mantine-font-size-sm);
+ font-family: inherit;
+ color: inherit;
+ padding: 0 8px;
+}
+
+.cellInput::placeholder {
+ color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
+}
+
+.numberValue {
+ text-align: right;
+ width: 100%;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 8px;
+ border-radius: 12px;
+ font-size: var(--mantine-font-size-xs);
+ font-weight: 500;
+ line-height: 1.5;
+ white-space: nowrap;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.badgeGroup {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ overflow: hidden;
+}
+
+.overflowCount {
+ font-size: 11px;
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.checkboxCell {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ cursor: pointer;
+}
+
+.urlLink {
+ color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
+ text-decoration: none;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+}
+
+.urlLink:hover {
+ text-decoration: underline;
+}
+
+.emailLink {
+ color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
+ text-decoration: none;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+}
+
+.emailLink:hover {
+ text-decoration: underline;
+}
+
+.dateValue {
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+}
+
+.emptyValue {
+ color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
+}
+
+.personGroup {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ overflow: hidden;
+}
+
+.personAvatar {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: 600;
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
+}
+
+.fileGroup {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ overflow: hidden;
+}
+
+.fileBadge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 1px 6px;
+ border-radius: var(--mantine-radius-sm);
+ font-size: 11px;
+ background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+ white-space: nowrap;
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.selectDropdown {
+ max-height: 240px;
+ overflow-y: auto;
+}
+
+.selectOption {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ cursor: pointer;
+ border-radius: var(--mantine-radius-sm);
+ transition: background-color 100ms ease;
+}
+
+.selectOption:hover {
+ background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
+}
+
+.selectOptionActive {
+ background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-blue-9));
+}
+
+.selectCategoryLabel {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+ padding: 8px 8px 4px;
+ letter-spacing: 0.5px;
+}
+
+.menuItem {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 6px 10px;
+ border-radius: var(--mantine-radius-sm);
+ font-size: var(--mantine-font-size-sm);
+ color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
+ cursor: pointer;
+ transition: background-color 100ms ease;
+}
+
+.menuItem:hover {
+ background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
+}
diff --git a/apps/client/src/features/base/styles/grid.module.css b/apps/client/src/features/base/styles/grid.module.css
new file mode 100644
index 00000000..3ca1aa66
--- /dev/null
+++ b/apps/client/src/features/base/styles/grid.module.css
@@ -0,0 +1,293 @@
+.gridWrapper {
+ position: relative;
+ overflow: auto;
+ flex: 1;
+ min-height: 0;
+}
+
+.grid {
+ display: grid;
+ min-width: max-content;
+ border: 1px solid
+ light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
+ border-radius: var(--mantine-radius-sm);
+}
+
+.headerRow {
+ display: grid;
+ grid-column: 1 / -1;
+ grid-template-columns: subgrid;
+}
+
+.headerCell {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 34px;
+ padding: 0 8px;
+ font-size: var(--mantine-font-size-xs);
+ font-weight: 600;
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-6)
+ );
+ border-bottom: 1px solid
+ light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
+ border-right: 1px solid
+ light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
+ user-select: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: default;
+}
+
+.headerCell:last-child {
+ border-right: none;
+}
+
+.headerCellPinned {
+ position: sticky;
+ z-index: 2;
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-6)
+ );
+}
+
+.headerCellContent {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.headerCellName {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.headerTypeIcon {
+ flex-shrink: 0;
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+}
+
+.resizeHandle {
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: 4px;
+ cursor: col-resize;
+ user-select: none;
+ touch-action: none;
+ z-index: 3;
+}
+
+.resizeHandle::after {
+ content: "";
+ position: absolute;
+ right: 0;
+ top: 4px;
+ bottom: 4px;
+ width: 2px;
+ border-radius: 1px;
+ background-color: transparent;
+ transition: background-color 150ms ease;
+}
+
+.resizeHandle:hover::after,
+.resizeHandleActive::after {
+ background-color: var(--mantine-color-blue-5);
+}
+
+.rowContainer {
+ display: contents;
+}
+
+.row {
+ display: grid;
+ grid-column: 1 / -1;
+ grid-template-columns: subgrid;
+}
+
+.row:hover .cell {
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-7)
+ );
+}
+
+.cell {
+ display: flex;
+ align-items: center;
+ height: 36px;
+ padding: 0 8px;
+ font-size: var(--mantine-font-size-sm);
+ color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
+ background-color: light-dark(
+ var(--mantine-color-white),
+ var(--mantine-color-dark-7)
+ );
+ border-bottom: 1px solid
+ light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
+ border-right: 1px solid
+ light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
+ overflow: hidden;
+ cursor: default;
+ outline: none;
+}
+
+.cell:last-child {
+ border-right: none;
+}
+
+.cellPinned {
+ position: sticky;
+ z-index: 1;
+ background-color: light-dark(
+ var(--mantine-color-white),
+ var(--mantine-color-dark-7)
+ );
+}
+
+.row:hover .cellPinned {
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-7)
+ );
+}
+
+.cellEditing {
+ outline: 2px solid var(--mantine-color-blue-5);
+ outline-offset: -2px;
+ z-index: 1;
+ padding: 0;
+}
+
+.cellContent {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rowNumberCell {
+ justify-content: center;
+ font-size: var(--mantine-font-size-xs);
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+}
+
+.rowNumberDraggable {
+ cursor: grab;
+}
+
+.rowNumberDraggable:active {
+ cursor: grabbing;
+}
+
+.rowDragging .cell {
+ opacity: 0.4;
+}
+
+.rowDropAbove .cell {
+ box-shadow: inset 0 2px 0 0 var(--mantine-color-blue-5);
+}
+
+.rowDropBelow .cell {
+ box-shadow: inset 0 -2px 0 0 var(--mantine-color-blue-5);
+}
+
+.addRowButton {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 34px;
+ padding: 0 8px;
+ font-size: var(--mantine-font-size-sm);
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+ border-top: 1px dashed
+ light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
+ cursor: pointer;
+ user-select: none;
+ transition: background-color 150ms ease;
+ grid-column: 1 / -1;
+}
+
+.addRowButton:hover {
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-6)
+ );
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+}
+
+.addColumnButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 34px;
+ min-width: 40px;
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-6)
+ );
+ border-bottom: 1px solid
+ light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
+ cursor: pointer;
+ user-select: none;
+ transition: background-color 150ms ease;
+}
+
+.addColumnButton:hover {
+ background-color: light-dark(
+ var(--mantine-color-gray-1),
+ var(--mantine-color-dark-5)
+ );
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+}
+
+.emptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--mantine-spacing-md);
+ padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
+ grid-column: 1 / -1;
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+}
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: var(--mantine-spacing-xs);
+ padding: var(--mantine-spacing-xs) 0;
+ margin-bottom: var(--mantine-spacing-xs);
+ flex-wrap: wrap;
+}
+
+.toolbarRight {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: var(--mantine-spacing-xs);
+}
+
+.loadingOverlay {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--mantine-spacing-xl);
+}
+
+.primaryCell {
+ font-weight: 500;
+}
diff --git a/apps/client/src/features/base/types/base.types.ts b/apps/client/src/features/base/types/base.types.ts
new file mode 100644
index 00000000..8e29024c
--- /dev/null
+++ b/apps/client/src/features/base/types/base.types.ts
@@ -0,0 +1,254 @@
+export type BasePropertyType =
+ | 'text'
+ | 'number'
+ | 'select'
+ | 'status'
+ | 'multiSelect'
+ | 'date'
+ | 'person'
+ | 'file'
+ | 'checkbox'
+ | 'url'
+ | 'email'
+ | 'createdAt'
+ | 'lastEditedAt'
+ | 'lastEditedBy';
+
+export type Choice = {
+ id: string;
+ name: string;
+ color: string;
+ category?: 'todo' | 'inProgress' | 'complete';
+};
+
+export type SelectTypeOptions = {
+ choices: Choice[];
+ choiceOrder: string[];
+ disableColors?: boolean;
+ defaultValue?: string | string[] | null;
+};
+
+export type NumberTypeOptions = {
+ format?: 'plain' | 'currency' | 'percent' | 'progress';
+ precision?: number;
+ currencySymbol?: string;
+ defaultValue?: number | null;
+};
+
+export type DateTypeOptions = {
+ dateFormat?: string;
+ timeFormat?: '12h' | '24h';
+ includeTime?: boolean;
+ defaultValue?: string | null;
+};
+
+export type TextTypeOptions = {
+ richText?: boolean;
+ defaultValue?: string | null;
+};
+
+export type CheckboxTypeOptions = {
+ defaultValue?: boolean;
+};
+
+export type UrlTypeOptions = {
+ defaultValue?: string | null;
+};
+
+export type EmailTypeOptions = {
+ defaultValue?: string | null;
+};
+
+export type TypeOptions =
+ | SelectTypeOptions
+ | NumberTypeOptions
+ | DateTypeOptions
+ | TextTypeOptions
+ | CheckboxTypeOptions
+ | UrlTypeOptions
+ | EmailTypeOptions
+ | Record;
+
+export type IBaseProperty = {
+ id: string;
+ baseId: string;
+ name: string;
+ type: BasePropertyType;
+ position: string;
+ typeOptions: TypeOptions;
+ isPrimary: boolean;
+ workspaceId: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type IBaseRow = {
+ id: string;
+ baseId: string;
+ cells: Record;
+ position: string;
+ creatorId: string;
+ lastUpdatedById: string | null;
+ workspaceId: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type ViewSortConfig = {
+ propertyId: string;
+ direction: 'asc' | 'desc';
+};
+
+export type ViewFilterOperator =
+ | 'equals'
+ | 'notEquals'
+ | 'contains'
+ | 'notContains'
+ | 'isEmpty'
+ | 'isNotEmpty'
+ | 'greaterThan'
+ | 'lessThan'
+ | 'before'
+ | 'after';
+
+export type ViewFilterConfig = {
+ propertyId: string;
+ operator: ViewFilterOperator;
+ value?: unknown;
+};
+
+export type ViewConfig = {
+ sorts?: ViewSortConfig[];
+ filters?: ViewFilterConfig[];
+ visiblePropertyIds?: string[];
+ hiddenPropertyIds?: string[];
+ propertyWidths?: Record;
+ propertyOrder?: string[];
+};
+
+export type IBaseView = {
+ id: string;
+ baseId: string;
+ name: string;
+ type: 'table' | 'kanban' | 'calendar';
+ config: ViewConfig;
+ workspaceId: string;
+ creatorId: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type IBase = {
+ id: string;
+ name: string;
+ description?: string;
+ icon?: string;
+ pageId?: string;
+ spaceId: string;
+ workspaceId: string;
+ creatorId: string;
+ properties: IBaseProperty[];
+ views: IBaseView[];
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type EditingCell = {
+ rowId: string;
+ propertyId: string;
+} | null;
+
+export type CreateBaseInput = {
+ name: string;
+ description?: string;
+ icon?: string;
+ pageId?: string;
+ spaceId: string;
+};
+
+export type UpdateBaseInput = {
+ baseId: string;
+ name?: string;
+ description?: string;
+ icon?: string;
+};
+
+export type CreatePropertyInput = {
+ baseId: string;
+ name: string;
+ type: BasePropertyType;
+ typeOptions?: TypeOptions;
+};
+
+export type UpdatePropertyInput = {
+ propertyId: string;
+ baseId: string;
+ name?: string;
+ type?: BasePropertyType;
+ typeOptions?: TypeOptions;
+};
+
+export type DeletePropertyInput = {
+ propertyId: string;
+ baseId: string;
+};
+
+export type ReorderPropertyInput = {
+ propertyId: string;
+ baseId: string;
+ position: string;
+};
+
+export type CreateRowInput = {
+ baseId: string;
+ cells?: Record;
+ afterRowId?: string;
+};
+
+export type UpdateRowInput = {
+ rowId: string;
+ baseId: string;
+ cells: Record;
+};
+
+export type DeleteRowInput = {
+ rowId: string;
+ baseId: string;
+};
+
+export type ReorderRowInput = {
+ rowId: string;
+ baseId: string;
+ position: string;
+};
+
+export type CreateViewInput = {
+ baseId: string;
+ name: string;
+ type?: 'table' | 'kanban' | 'calendar';
+ config?: ViewConfig;
+};
+
+export type UpdateViewInput = {
+ viewId: string;
+ baseId: string;
+ name?: string;
+ type?: 'table' | 'kanban' | 'calendar';
+ config?: ViewConfig;
+};
+
+export type DeleteViewInput = {
+ viewId: string;
+ baseId: string;
+};
+
+export type ConversionSummary = {
+ converted: number;
+ cleared: number;
+ total: number;
+};
+
+export type UpdatePropertyResult = {
+ property: IBaseProperty;
+ conversionSummary: ConversionSummary | null;
+};
diff --git a/apps/client/src/features/base/types/react-table.d.ts b/apps/client/src/features/base/types/react-table.d.ts
new file mode 100644
index 00000000..a2f10d49
--- /dev/null
+++ b/apps/client/src/features/base/types/react-table.d.ts
@@ -0,0 +1,8 @@
+import "@tanstack/react-table";
+import { IBaseProperty } from "@/features/base/types/base.types";
+
+declare module "@tanstack/react-table" {
+ interface ColumnMeta {
+ property?: IBaseProperty;
+ }
+}
diff --git a/apps/client/src/features/space/components/space-home-tabs.tsx b/apps/client/src/features/space/components/space-home-tabs.tsx
index 922d5cc8..77215f77 100644
--- a/apps/client/src/features/space/components/space-home-tabs.tsx
+++ b/apps/client/src/features/space/components/space-home-tabs.tsx
@@ -4,6 +4,7 @@ import RecentChanges from "@/components/common/recent-changes.tsx";
import { useParams } from "react-router-dom";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useTranslation } from "react-i18next";
+import { BaseTable } from "@/features/base/components/base-table.tsx";
export default function SpaceHomeTabs() {
const { t } = useTranslation();
@@ -22,6 +23,8 @@ export default function SpaceHomeTabs() {
+
+
{space?.id && }
diff --git a/apps/client/src/pages/base/base-page.tsx b/apps/client/src/pages/base/base-page.tsx
new file mode 100644
index 00000000..fd54664c
--- /dev/null
+++ b/apps/client/src/pages/base/base-page.tsx
@@ -0,0 +1,32 @@
+import { useParams } from "react-router-dom";
+import { Container, Title, Text, Stack } from "@mantine/core";
+import { BaseTable } from "@/features/base/components/base-table";
+import { useBaseQuery } from "@/features/base/queries/base-query";
+
+export default function BasePage() {
+ const { baseId } = useParams<{ baseId: string }>();
+ const { data: base } = useBaseQuery(baseId);
+
+ if (!baseId) {
+ return (
+
+ No base ID provided
+
+ );
+ }
+
+ return (
+
+ {base && (
+
+ {base.icon ? `${base.icon} ` : ""}{base.name}
+
+ )}
+
+
+ );
+}
diff --git a/apps/server/package.json b/apps/server/package.json
index 65cb8e59..b3ec5295 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -106,7 +106,8 @@
"tseep": "^1.3.1",
"typesense": "^2.1.0",
"ws": "^8.19.0",
- "yauzl": "^3.2.0"
+ "yauzl": "^3.2.0",
+ "zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.20.0",
diff --git a/apps/server/src/core/base/base.module.ts b/apps/server/src/core/base/base.module.ts
new file mode 100644
index 00000000..13e2f8e6
--- /dev/null
+++ b/apps/server/src/core/base/base.module.ts
@@ -0,0 +1,21 @@
+import { Module } from '@nestjs/common';
+import { BaseController } from './controllers/base.controller';
+import { BasePropertyController } from './controllers/base-property.controller';
+import { BaseRowController } from './controllers/base-row.controller';
+import { BaseViewController } from './controllers/base-view.controller';
+import { BaseService } from './services/base.service';
+import { BasePropertyService } from './services/base-property.service';
+import { BaseRowService } from './services/base-row.service';
+import { BaseViewService } from './services/base-view.service';
+
+@Module({
+ controllers: [
+ BaseController,
+ BasePropertyController,
+ BaseRowController,
+ BaseViewController,
+ ],
+ providers: [BaseService, BasePropertyService, BaseRowService, BaseViewService],
+ exports: [BaseService, BasePropertyService, BaseRowService, BaseViewService],
+})
+export class BaseModule {}
diff --git a/apps/server/src/core/base/base.schemas.ts b/apps/server/src/core/base/base.schemas.ts
new file mode 100644
index 00000000..9ecff634
--- /dev/null
+++ b/apps/server/src/core/base/base.schemas.ts
@@ -0,0 +1,270 @@
+import { z } from 'zod';
+
+export const BasePropertyType = {
+ TEXT: 'text',
+ NUMBER: 'number',
+ SELECT: 'select',
+ STATUS: 'status',
+ MULTI_SELECT: 'multiSelect',
+ DATE: 'date',
+ PERSON: 'person',
+ FILE: 'file',
+ CHECKBOX: 'checkbox',
+ URL: 'url',
+ EMAIL: 'email',
+ CREATED_AT: 'createdAt',
+ LAST_EDITED_AT: 'lastEditedAt',
+ LAST_EDITED_BY: 'lastEditedBy',
+} as const;
+
+const SYSTEM_PROPERTY_TYPES: Set = new Set([
+ BasePropertyType.CREATED_AT,
+ BasePropertyType.LAST_EDITED_AT,
+ BasePropertyType.LAST_EDITED_BY,
+]);
+
+export function isSystemPropertyType(type: string): boolean {
+ return SYSTEM_PROPERTY_TYPES.has(type);
+}
+
+export type BasePropertyTypeValue =
+ (typeof BasePropertyType)[keyof typeof BasePropertyType];
+
+export const BASE_PROPERTY_TYPES = Object.values(BasePropertyType);
+
+export const choiceSchema = z.object({
+ id: z.string().uuid(),
+ name: z.string().min(1),
+ color: z.string(),
+ category: z.enum(['todo', 'inProgress', 'complete']).optional(),
+});
+
+export const selectTypeOptionsSchema = z
+ .object({
+ choices: z.array(choiceSchema).default([]),
+ choiceOrder: z.array(z.string().uuid()).default([]),
+ disableColors: z.boolean().optional(),
+ defaultValue: z
+ .union([z.string().uuid(), z.array(z.string().uuid())])
+ .nullable()
+ .optional(),
+ })
+ .passthrough();
+
+export const numberTypeOptionsSchema = z
+ .object({
+ format: z
+ .enum(['plain', 'currency', 'percent', 'progress'])
+ .optional()
+ .default('plain'),
+ precision: z.number().int().min(0).max(10).optional(),
+ currencySymbol: z.string().max(5).optional(),
+ defaultValue: z.number().nullable().optional(),
+ })
+ .passthrough();
+
+export const dateTypeOptionsSchema = z
+ .object({
+ dateFormat: z.string().optional(),
+ timeFormat: z.enum(['12h', '24h']).optional(),
+ includeTime: z.boolean().optional(),
+ defaultValue: z.string().nullable().optional(),
+ })
+ .passthrough();
+
+export const textTypeOptionsSchema = z
+ .object({
+ richText: z.boolean().optional(),
+ defaultValue: z.string().nullable().optional(),
+ })
+ .passthrough();
+
+export const checkboxTypeOptionsSchema = z
+ .object({
+ defaultValue: z.boolean().optional(),
+ })
+ .passthrough();
+
+export const urlTypeOptionsSchema = z
+ .object({
+ defaultValue: z.string().nullable().optional(),
+ })
+ .passthrough();
+
+export const emailTypeOptionsSchema = z
+ .object({
+ defaultValue: z.string().nullable().optional(),
+ })
+ .passthrough();
+
+export const emptyTypeOptionsSchema = z.object({}).passthrough();
+
+const typeOptionsSchemaMap: Record = {
+ [BasePropertyType.TEXT]: textTypeOptionsSchema,
+ [BasePropertyType.NUMBER]: numberTypeOptionsSchema,
+ [BasePropertyType.SELECT]: selectTypeOptionsSchema,
+ [BasePropertyType.STATUS]: selectTypeOptionsSchema,
+ [BasePropertyType.MULTI_SELECT]: selectTypeOptionsSchema,
+ [BasePropertyType.DATE]: dateTypeOptionsSchema,
+ [BasePropertyType.PERSON]: emptyTypeOptionsSchema,
+ [BasePropertyType.FILE]: emptyTypeOptionsSchema,
+ [BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
+ [BasePropertyType.URL]: urlTypeOptionsSchema,
+ [BasePropertyType.EMAIL]: emailTypeOptionsSchema,
+ [BasePropertyType.CREATED_AT]: emptyTypeOptionsSchema,
+ [BasePropertyType.LAST_EDITED_AT]: emptyTypeOptionsSchema,
+ [BasePropertyType.LAST_EDITED_BY]: emptyTypeOptionsSchema,
+};
+
+export function validateTypeOptions(
+ type: BasePropertyTypeValue,
+ typeOptions: unknown,
+): z.SafeParseReturnType {
+ const schema = typeOptionsSchemaMap[type];
+ if (!schema) {
+ return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: ['type'] }]) } as z.SafeParseError;
+ }
+ return schema.safeParse(typeOptions ?? {});
+}
+
+export function parseTypeOptions(
+ type: BasePropertyTypeValue,
+ typeOptions: unknown,
+): unknown {
+ const result = validateTypeOptions(type, typeOptions);
+ if (!result.success) {
+ throw result.error;
+ }
+ return result.data;
+}
+
+const cellValueSchemaMap: Partial> = {
+ [BasePropertyType.TEXT]: z.string(),
+ [BasePropertyType.NUMBER]: z.number(),
+ [BasePropertyType.SELECT]: z.string().uuid(),
+ [BasePropertyType.STATUS]: z.string().uuid(),
+ [BasePropertyType.MULTI_SELECT]: z.array(z.string().uuid()),
+ [BasePropertyType.DATE]: z.string(),
+ [BasePropertyType.PERSON]: z.array(z.string().uuid()),
+ [BasePropertyType.FILE]: z.array(z.string().uuid()),
+ [BasePropertyType.CHECKBOX]: z.boolean(),
+ [BasePropertyType.URL]: z.string().url(),
+ [BasePropertyType.EMAIL]: z.string().email(),
+};
+
+export function getCellValueSchema(
+ type: BasePropertyTypeValue,
+): z.ZodType | undefined {
+ return cellValueSchemaMap[type];
+}
+
+export function validateCellValue(
+ type: BasePropertyTypeValue,
+ value: unknown,
+): z.SafeParseReturnType {
+ const schema = cellValueSchemaMap[type];
+ if (!schema) {
+ return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.SafeParseError;
+ }
+ return schema.safeParse(value);
+}
+
+export function attemptCellConversion(
+ fromType: BasePropertyTypeValue,
+ toType: BasePropertyTypeValue,
+ value: unknown,
+): { converted: boolean; value: unknown } {
+ if (value === null || value === undefined) {
+ return { converted: true, value: null };
+ }
+
+ const targetSchema = cellValueSchemaMap[toType];
+ if (!targetSchema) {
+ return { converted: false, value: null };
+ }
+
+ const directResult = targetSchema.safeParse(value);
+ if (directResult.success) {
+ return { converted: true, value: directResult.data };
+ }
+
+ if (toType === BasePropertyType.TEXT) {
+ return { converted: true, value: String(value) };
+ }
+
+ if (toType === BasePropertyType.NUMBER && typeof value === 'string') {
+ const num = Number(value);
+ if (!isNaN(num)) {
+ return { converted: true, value: num };
+ }
+ }
+
+ if (toType === BasePropertyType.CHECKBOX) {
+ if (typeof value === 'string') {
+ const lower = value.toLowerCase();
+ if (lower === 'true' || lower === '1' || lower === 'yes') {
+ return { converted: true, value: true };
+ }
+ if (lower === 'false' || lower === '0' || lower === 'no' || lower === '') {
+ return { converted: true, value: false };
+ }
+ }
+ if (typeof value === 'number') {
+ return { converted: true, value: value !== 0 };
+ }
+ }
+
+ if (
+ toType === BasePropertyType.MULTI_SELECT &&
+ fromType === BasePropertyType.SELECT &&
+ typeof value === 'string'
+ ) {
+ return { converted: true, value: [value] };
+ }
+
+ if (
+ toType === BasePropertyType.SELECT &&
+ fromType === BasePropertyType.MULTI_SELECT &&
+ Array.isArray(value) &&
+ value.length > 0
+ ) {
+ return { converted: true, value: value[0] };
+ }
+
+ return { converted: false, value: null };
+}
+
+export const viewSortSchema = z.object({
+ propertyId: z.string().uuid(),
+ direction: z.enum(['asc', 'desc']),
+});
+
+export const viewFilterSchema = z.object({
+ propertyId: z.string().uuid(),
+ operator: z.enum([
+ 'equals',
+ 'notEquals',
+ 'contains',
+ 'notContains',
+ 'isEmpty',
+ 'isNotEmpty',
+ 'greaterThan',
+ 'lessThan',
+ 'before',
+ 'after',
+ ]),
+ value: z.unknown().optional(),
+});
+
+export const viewConfigSchema = z
+ .object({
+ sorts: z.array(viewSortSchema).optional(),
+ filters: z.array(viewFilterSchema).optional(),
+ visiblePropertyIds: z.array(z.string().uuid()).optional(),
+ hiddenPropertyIds: z.array(z.string().uuid()).optional(),
+ propertyWidths: z.record(z.string(), z.number().positive()).optional(),
+ propertyOrder: z.array(z.string().uuid()).optional(),
+ })
+ .passthrough();
+
+export type ViewConfig = z.infer;
diff --git a/apps/server/src/core/base/controllers/base-property.controller.ts b/apps/server/src/core/base/controllers/base-property.controller.ts
new file mode 100644
index 00000000..1b04d1ef
--- /dev/null
+++ b/apps/server/src/core/base/controllers/base-property.controller.ts
@@ -0,0 +1,105 @@
+import {
+ Body,
+ Controller,
+ ForbiddenException,
+ HttpCode,
+ HttpStatus,
+ NotFoundException,
+ Post,
+ UseGuards,
+} from '@nestjs/common';
+import { BasePropertyService } from '../services/base-property.service';
+import { BaseRepo } from '@docmost/db/repos/base/base.repo';
+import { CreatePropertyDto } from '../dto/create-property.dto';
+import {
+ UpdatePropertyDto,
+ DeletePropertyDto,
+ ReorderPropertyDto,
+} from '../dto/update-property.dto';
+import { AuthUser } from '../../../common/decorators/auth-user.decorator';
+import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
+import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
+import { User, Workspace } from '@docmost/db/types/entity.types';
+import {
+ SpaceCaslAction,
+ SpaceCaslSubject,
+} from '../../casl/interfaces/space-ability.type';
+import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
+
+@UseGuards(JwtAuthGuard)
+@Controller('bases/properties')
+export class BasePropertyController {
+ constructor(
+ private readonly basePropertyService: BasePropertyService,
+ private readonly baseRepo: BaseRepo,
+ private readonly spaceAbility: SpaceAbilityFactory,
+ ) {}
+
+ @HttpCode(HttpStatus.OK)
+ @Post('create')
+ async create(
+ @Body() dto: CreatePropertyDto,
+ @AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
+ ) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.basePropertyService.create(workspace.id, dto);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('update')
+ async update(@Body() dto: UpdatePropertyDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.basePropertyService.update(dto);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('delete')
+ async delete(@Body() dto: DeletePropertyDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ await this.basePropertyService.delete(dto);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('reorder')
+ async reorder(@Body() dto: ReorderPropertyDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ await this.basePropertyService.reorder(dto);
+ }
+}
diff --git a/apps/server/src/core/base/controllers/base-row.controller.ts b/apps/server/src/core/base/controllers/base-row.controller.ts
new file mode 100644
index 00000000..b65d6060
--- /dev/null
+++ b/apps/server/src/core/base/controllers/base-row.controller.ts
@@ -0,0 +1,144 @@
+import {
+ Body,
+ Controller,
+ ForbiddenException,
+ HttpCode,
+ HttpStatus,
+ NotFoundException,
+ Post,
+ UseGuards,
+} from '@nestjs/common';
+import { BaseRowService } from '../services/base-row.service';
+import { BaseRepo } from '@docmost/db/repos/base/base.repo';
+import { CreateRowDto } from '../dto/create-row.dto';
+import {
+ UpdateRowDto,
+ DeleteRowDto,
+ RowIdDto,
+ ListRowsDto,
+ ReorderRowDto,
+} from '../dto/update-row.dto';
+import { AuthUser } from '../../../common/decorators/auth-user.decorator';
+import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
+import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
+import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
+import { User, Workspace } from '@docmost/db/types/entity.types';
+import {
+ SpaceCaslAction,
+ SpaceCaslSubject,
+} from '../../casl/interfaces/space-ability.type';
+import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
+
+@UseGuards(JwtAuthGuard)
+@Controller('bases/rows')
+export class BaseRowController {
+ constructor(
+ private readonly baseRowService: BaseRowService,
+ private readonly baseRepo: BaseRepo,
+ private readonly spaceAbility: SpaceAbilityFactory,
+ ) {}
+
+ @HttpCode(HttpStatus.OK)
+ @Post('create')
+ async create(
+ @Body() dto: CreateRowDto,
+ @AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
+ ) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.baseRowService.create(user.id, workspace.id, dto);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('info')
+ async getRow(@Body() dto: RowIdDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.baseRowService.getRowInfo(dto.rowId, dto.baseId);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('update')
+ async update(@Body() dto: UpdateRowDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.baseRowService.update(dto, user.id);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('delete')
+ async delete(@Body() dto: DeleteRowDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ await this.baseRowService.delete(dto.rowId, dto.baseId);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('list')
+ async list(
+ @Body() dto: ListRowsDto,
+ @Body() pagination: PaginationOptions,
+ @AuthUser() user: User,
+ ) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.baseRowService.list(dto, pagination);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('reorder')
+ async reorder(@Body() dto: ReorderRowDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ await this.baseRowService.reorder(dto);
+ }
+}
diff --git a/apps/server/src/core/base/controllers/base-view.controller.ts b/apps/server/src/core/base/controllers/base-view.controller.ts
new file mode 100644
index 00000000..28159a74
--- /dev/null
+++ b/apps/server/src/core/base/controllers/base-view.controller.ts
@@ -0,0 +1,102 @@
+import {
+ Body,
+ Controller,
+ ForbiddenException,
+ HttpCode,
+ HttpStatus,
+ NotFoundException,
+ Post,
+ UseGuards,
+} from '@nestjs/common';
+import { BaseViewService } from '../services/base-view.service';
+import { BaseRepo } from '@docmost/db/repos/base/base.repo';
+import { CreateViewDto } from '../dto/create-view.dto';
+import { UpdateViewDto, DeleteViewDto } from '../dto/update-view.dto';
+import { BaseIdDto } from '../dto/base.dto';
+import { AuthUser } from '../../../common/decorators/auth-user.decorator';
+import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
+import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
+import { User, Workspace } from '@docmost/db/types/entity.types';
+import {
+ SpaceCaslAction,
+ SpaceCaslSubject,
+} from '../../casl/interfaces/space-ability.type';
+import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
+
+@UseGuards(JwtAuthGuard)
+@Controller('bases/views')
+export class BaseViewController {
+ constructor(
+ private readonly baseViewService: BaseViewService,
+ private readonly baseRepo: BaseRepo,
+ private readonly spaceAbility: SpaceAbilityFactory,
+ ) {}
+
+ @HttpCode(HttpStatus.OK)
+ @Post('create')
+ async create(
+ @Body() dto: CreateViewDto,
+ @AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
+ ) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.baseViewService.create(user.id, workspace.id, dto);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('update')
+ async update(@Body() dto: UpdateViewDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.baseViewService.update(dto);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('delete')
+ async delete(@Body() dto: DeleteViewDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ await this.baseViewService.delete(dto);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('list')
+ async list(@Body() dto: BaseIdDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.baseViewService.listByBaseId(dto.baseId);
+ }
+}
diff --git a/apps/server/src/core/base/controllers/base.controller.ts b/apps/server/src/core/base/controllers/base.controller.ts
new file mode 100644
index 00000000..df09538b
--- /dev/null
+++ b/apps/server/src/core/base/controllers/base.controller.ts
@@ -0,0 +1,111 @@
+import {
+ Body,
+ Controller,
+ ForbiddenException,
+ HttpCode,
+ HttpStatus,
+ NotFoundException,
+ Post,
+ UseGuards,
+} from '@nestjs/common';
+import { BaseService } from '../services/base.service';
+import { BaseRepo } from '@docmost/db/repos/base/base.repo';
+import { CreateBaseDto } from '../dto/create-base.dto';
+import { UpdateBaseDto } from '../dto/update-base.dto';
+import { BaseIdDto } from '../dto/base.dto';
+import { AuthUser } from '../../../common/decorators/auth-user.decorator';
+import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
+import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
+import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
+import { User, Workspace } from '@docmost/db/types/entity.types';
+import {
+ SpaceCaslAction,
+ SpaceCaslSubject,
+} from '../../casl/interfaces/space-ability.type';
+import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
+import { SpaceIdDto } from '../../space/dto/space-id.dto';
+
+@UseGuards(JwtAuthGuard)
+@Controller('bases')
+export class BaseController {
+ constructor(
+ private readonly baseService: BaseService,
+ private readonly baseRepo: BaseRepo,
+ private readonly spaceAbility: SpaceAbilityFactory,
+ ) {}
+
+ @HttpCode(HttpStatus.OK)
+ @Post('create')
+ async create(
+ @Body() dto: CreateBaseDto,
+ @AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
+ ) {
+ const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
+ if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.baseService.create(user.id, workspace.id, dto);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('info')
+ async getBase(@Body() dto: BaseIdDto, @AuthUser() user: User) {
+ const base = await this.baseService.getBaseInfo(dto.baseId);
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return base;
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('update')
+ async update(@Body() dto: UpdateBaseDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.baseService.update(dto);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('delete')
+ async delete(@Body() dto: BaseIdDto, @AuthUser() user: User) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ const ability = await this.spaceAbility.createForUser(user, base.spaceId);
+ if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ await this.baseService.delete(dto.baseId);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('list')
+ async list(
+ @Body() dto: SpaceIdDto,
+ @Body() pagination: PaginationOptions,
+ @AuthUser() user: User,
+ ) {
+ const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
+ if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
+ throw new ForbiddenException();
+ }
+
+ return this.baseService.listBySpaceId(dto.spaceId, pagination);
+ }
+}
diff --git a/apps/server/src/core/base/dto/base.dto.ts b/apps/server/src/core/base/dto/base.dto.ts
new file mode 100644
index 00000000..14adf785
--- /dev/null
+++ b/apps/server/src/core/base/dto/base.dto.ts
@@ -0,0 +1,6 @@
+import { IsUUID } from 'class-validator';
+
+export class BaseIdDto {
+ @IsUUID()
+ baseId: string;
+}
diff --git a/apps/server/src/core/base/dto/create-base.dto.ts b/apps/server/src/core/base/dto/create-base.dto.ts
new file mode 100644
index 00000000..ed2fce2f
--- /dev/null
+++ b/apps/server/src/core/base/dto/create-base.dto.ts
@@ -0,0 +1,22 @@
+import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
+
+export class CreateBaseDto {
+ @IsString()
+ @IsNotEmpty()
+ name: string;
+
+ @IsOptional()
+ @IsString()
+ description?: string;
+
+ @IsOptional()
+ @IsString()
+ icon?: string;
+
+ @IsOptional()
+ @IsUUID()
+ pageId?: string;
+
+ @IsUUID()
+ spaceId: string;
+}
diff --git a/apps/server/src/core/base/dto/create-property.dto.ts b/apps/server/src/core/base/dto/create-property.dto.ts
new file mode 100644
index 00000000..6cde2fe7
--- /dev/null
+++ b/apps/server/src/core/base/dto/create-property.dto.ts
@@ -0,0 +1,25 @@
+import {
+ IsIn,
+ IsNotEmpty,
+ IsObject,
+ IsOptional,
+ IsString,
+ IsUUID,
+} from 'class-validator';
+import { BASE_PROPERTY_TYPES } from '../base.schemas';
+
+export class CreatePropertyDto {
+ @IsUUID()
+ baseId: string;
+
+ @IsString()
+ @IsNotEmpty()
+ name: string;
+
+ @IsIn(BASE_PROPERTY_TYPES)
+ type: string;
+
+ @IsOptional()
+ @IsObject()
+ typeOptions?: Record;
+}
diff --git a/apps/server/src/core/base/dto/create-row.dto.ts b/apps/server/src/core/base/dto/create-row.dto.ts
new file mode 100644
index 00000000..875b9c72
--- /dev/null
+++ b/apps/server/src/core/base/dto/create-row.dto.ts
@@ -0,0 +1,14 @@
+import { IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
+
+export class CreateRowDto {
+ @IsUUID()
+ baseId: string;
+
+ @IsOptional()
+ @IsObject()
+ cells?: Record;
+
+ @IsOptional()
+ @IsString()
+ afterRowId?: string;
+}
diff --git a/apps/server/src/core/base/dto/create-view.dto.ts b/apps/server/src/core/base/dto/create-view.dto.ts
new file mode 100644
index 00000000..2de07292
--- /dev/null
+++ b/apps/server/src/core/base/dto/create-view.dto.ts
@@ -0,0 +1,25 @@
+import {
+ IsIn,
+ IsNotEmpty,
+ IsObject,
+ IsOptional,
+ IsString,
+ IsUUID,
+} from 'class-validator';
+
+export class CreateViewDto {
+ @IsUUID()
+ baseId: string;
+
+ @IsString()
+ @IsNotEmpty()
+ name: string;
+
+ @IsOptional()
+ @IsIn(['table', 'kanban', 'calendar'])
+ type?: string;
+
+ @IsOptional()
+ @IsObject()
+ config?: Record;
+}
diff --git a/apps/server/src/core/base/dto/update-base.dto.ts b/apps/server/src/core/base/dto/update-base.dto.ts
new file mode 100644
index 00000000..803056a0
--- /dev/null
+++ b/apps/server/src/core/base/dto/update-base.dto.ts
@@ -0,0 +1,19 @@
+import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
+
+export class UpdateBaseDto {
+ @IsUUID()
+ baseId: string;
+
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ name?: string;
+
+ @IsOptional()
+ @IsString()
+ description?: string;
+
+ @IsOptional()
+ @IsString()
+ icon?: string;
+}
diff --git a/apps/server/src/core/base/dto/update-property.dto.ts b/apps/server/src/core/base/dto/update-property.dto.ts
new file mode 100644
index 00000000..4088dfde
--- /dev/null
+++ b/apps/server/src/core/base/dto/update-property.dto.ts
@@ -0,0 +1,50 @@
+import {
+ IsIn,
+ IsNotEmpty,
+ IsObject,
+ IsOptional,
+ IsString,
+ IsUUID,
+} from 'class-validator';
+import { BASE_PROPERTY_TYPES } from '../base.schemas';
+
+export class UpdatePropertyDto {
+ @IsUUID()
+ propertyId: string;
+
+ @IsUUID()
+ baseId: string;
+
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ name?: string;
+
+ @IsOptional()
+ @IsIn(BASE_PROPERTY_TYPES)
+ type?: string;
+
+ @IsOptional()
+ @IsObject()
+ typeOptions?: Record;
+}
+
+export class DeletePropertyDto {
+ @IsUUID()
+ propertyId: string;
+
+ @IsUUID()
+ baseId: string;
+}
+
+export class ReorderPropertyDto {
+ @IsUUID()
+ propertyId: string;
+
+ @IsUUID()
+ baseId: string;
+
+ @IsString()
+ @IsNotEmpty()
+ position: string;
+}
diff --git a/apps/server/src/core/base/dto/update-row.dto.ts b/apps/server/src/core/base/dto/update-row.dto.ts
new file mode 100644
index 00000000..1f8aa1be
--- /dev/null
+++ b/apps/server/src/core/base/dto/update-row.dto.ts
@@ -0,0 +1,49 @@
+import { IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
+
+export class UpdateRowDto {
+ @IsUUID()
+ rowId: string;
+
+ @IsUUID()
+ baseId: string;
+
+ @IsObject()
+ cells: Record;
+}
+
+export class DeleteRowDto {
+ @IsUUID()
+ rowId: string;
+
+ @IsUUID()
+ baseId: string;
+}
+
+export class RowIdDto {
+ @IsUUID()
+ rowId: string;
+
+ @IsUUID()
+ baseId: string;
+}
+
+export class ListRowsDto {
+ @IsUUID()
+ baseId: string;
+
+ @IsOptional()
+ @IsUUID()
+ viewId?: string;
+}
+
+export class ReorderRowDto {
+ @IsUUID()
+ rowId: string;
+
+ @IsUUID()
+ baseId: string;
+
+ @IsString()
+ @IsNotEmpty()
+ position: string;
+}
diff --git a/apps/server/src/core/base/dto/update-view.dto.ts b/apps/server/src/core/base/dto/update-view.dto.ts
new file mode 100644
index 00000000..97e5876c
--- /dev/null
+++ b/apps/server/src/core/base/dto/update-view.dto.ts
@@ -0,0 +1,37 @@
+import {
+ IsIn,
+ IsNotEmpty,
+ IsObject,
+ IsOptional,
+ IsString,
+ IsUUID,
+} from 'class-validator';
+
+export class UpdateViewDto {
+ @IsUUID()
+ viewId: string;
+
+ @IsUUID()
+ baseId: string;
+
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ name?: string;
+
+ @IsOptional()
+ @IsIn(['table', 'kanban', 'calendar'])
+ type?: string;
+
+ @IsOptional()
+ @IsObject()
+ config?: Record;
+}
+
+export class DeleteViewDto {
+ @IsUUID()
+ viewId: string;
+
+ @IsUUID()
+ baseId: string;
+}
diff --git a/apps/server/src/core/base/services/base-property.service.ts b/apps/server/src/core/base/services/base-property.service.ts
new file mode 100644
index 00000000..c1aa1dc9
--- /dev/null
+++ b/apps/server/src/core/base/services/base-property.service.ts
@@ -0,0 +1,216 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB } from '@docmost/db/types/kysely.types';
+import { executeTx } from '@docmost/db/utils';
+import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
+import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
+import { CreatePropertyDto } from '../dto/create-property.dto';
+import {
+ UpdatePropertyDto,
+ DeletePropertyDto,
+ ReorderPropertyDto,
+} from '../dto/update-property.dto';
+import {
+ BasePropertyTypeValue,
+ parseTypeOptions,
+ attemptCellConversion,
+ validateTypeOptions,
+ isSystemPropertyType,
+} from '../base.schemas';
+import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
+
+@Injectable()
+export class BasePropertyService {
+ constructor(
+ @InjectKysely() private readonly db: KyselyDB,
+ private readonly basePropertyRepo: BasePropertyRepo,
+ private readonly baseRowRepo: BaseRowRepo,
+ ) {}
+
+ async create(workspaceId: string, dto: CreatePropertyDto) {
+ const type = dto.type as BasePropertyTypeValue;
+ let validatedTypeOptions = null;
+
+ if (dto.typeOptions) {
+ validatedTypeOptions = parseTypeOptions(type, dto.typeOptions);
+ } else {
+ validatedTypeOptions = parseTypeOptions(type, {});
+ }
+
+ const lastPosition = await this.basePropertyRepo.getLastPosition(
+ dto.baseId,
+ );
+ const position = generateJitteredKeyBetween(lastPosition, null);
+
+ return this.basePropertyRepo.insertProperty({
+ baseId: dto.baseId,
+ name: dto.name,
+ type: dto.type,
+ position,
+ typeOptions: validatedTypeOptions as any,
+ workspaceId,
+ });
+ }
+
+ async update(dto: UpdatePropertyDto) {
+ const property = await this.basePropertyRepo.findById(dto.propertyId);
+ if (!property) {
+ throw new NotFoundException('Property not found');
+ }
+
+ if (property.baseId !== dto.baseId) {
+ throw new BadRequestException('Property does not belong to this base');
+ }
+
+ const isTypeChange = dto.type && dto.type !== property.type;
+ const newType = (dto.type ?? property.type) as BasePropertyTypeValue;
+
+ let validatedTypeOptions = property.typeOptions;
+ if (dto.typeOptions !== undefined) {
+ validatedTypeOptions = parseTypeOptions(newType, dto.typeOptions) as any;
+ } else if (isTypeChange) {
+ const result = validateTypeOptions(newType, {});
+ validatedTypeOptions = result.success ? (result.data as any) : null;
+ }
+
+ let conversionSummary: {
+ converted: number;
+ cleared: number;
+ total: number;
+ } | null = null;
+
+ if (isTypeChange) {
+ const involvesSystem =
+ isSystemPropertyType(property.type) || isSystemPropertyType(newType);
+
+ if (involvesSystem) {
+ conversionSummary = await this.clearCellValues(
+ dto.baseId,
+ dto.propertyId,
+ );
+ } else {
+ conversionSummary = await this.convertCellValues(
+ dto.baseId,
+ dto.propertyId,
+ property.type as BasePropertyTypeValue,
+ newType,
+ );
+ }
+ }
+
+ await this.basePropertyRepo.updateProperty(dto.propertyId, {
+ ...(dto.name !== undefined && { name: dto.name }),
+ ...(dto.type !== undefined && { type: dto.type }),
+ typeOptions: validatedTypeOptions,
+ });
+
+ const updatedProperty = await this.basePropertyRepo.findById(
+ dto.propertyId,
+ );
+
+ return { property: updatedProperty, conversionSummary };
+ }
+
+ async delete(dto: DeletePropertyDto) {
+ const property = await this.basePropertyRepo.findById(dto.propertyId);
+ if (!property) {
+ throw new NotFoundException('Property not found');
+ }
+
+ if (property.baseId !== dto.baseId) {
+ throw new BadRequestException('Property does not belong to this base');
+ }
+
+ if (property.isPrimary) {
+ throw new BadRequestException('Cannot delete the primary property');
+ }
+
+ await executeTx(this.db, async (trx) => {
+ await this.basePropertyRepo.deleteProperty(dto.propertyId, trx);
+ await this.baseRowRepo.removeCellKey(dto.baseId, dto.propertyId, trx);
+ });
+ }
+
+ async reorder(dto: ReorderPropertyDto) {
+ const property = await this.basePropertyRepo.findById(dto.propertyId);
+ if (!property) {
+ throw new NotFoundException('Property not found');
+ }
+
+ if (property.baseId !== dto.baseId) {
+ throw new BadRequestException('Property does not belong to this base');
+ }
+
+ await this.basePropertyRepo.updateProperty(dto.propertyId, {
+ position: dto.position,
+ });
+ }
+
+ private async clearCellValues(
+ baseId: string,
+ propertyId: string,
+ ): Promise<{ converted: number; cleared: number; total: number }> {
+ const rows = await this.baseRowRepo.findAllByBaseId(baseId);
+ const updates: Array<{ id: string; cells: Record }> = [];
+
+ for (const row of rows) {
+ const cells = row.cells as Record;
+ if (propertyId in cells) {
+ updates.push({ id: row.id, cells: { [propertyId]: null } });
+ }
+ }
+
+ if (updates.length > 0) {
+ await executeTx(this.db, async (trx) => {
+ await this.baseRowRepo.batchUpdateCells(updates, trx);
+ });
+ }
+
+ return { converted: 0, cleared: updates.length, total: updates.length };
+ }
+
+ private async convertCellValues(
+ baseId: string,
+ propertyId: string,
+ fromType: BasePropertyTypeValue,
+ toType: BasePropertyTypeValue,
+ ): Promise<{ converted: number; cleared: number; total: number }> {
+ const rows = await this.baseRowRepo.findAllByBaseId(baseId);
+ let converted = 0;
+ let cleared = 0;
+ let total = 0;
+
+ const updates: Array<{ id: string; cells: Record }> = [];
+
+ for (const row of rows) {
+ const cells = row.cells as Record;
+ if (!(propertyId in cells)) {
+ continue;
+ }
+
+ total++;
+ const currentValue = cells[propertyId];
+ const result = attemptCellConversion(fromType, toType, currentValue);
+
+ if (result.converted) {
+ converted++;
+ updates.push({ id: row.id, cells: { [propertyId]: result.value } });
+ } else {
+ cleared++;
+ updates.push({ id: row.id, cells: { [propertyId]: null } });
+ }
+ }
+
+ if (updates.length > 0) {
+ await executeTx(this.db, async (trx) => {
+ await this.baseRowRepo.batchUpdateCells(updates, trx);
+ });
+ }
+
+ return { converted, cleared, total };
+ }
+}
diff --git a/apps/server/src/core/base/services/base-row.service.ts b/apps/server/src/core/base/services/base-row.service.ts
new file mode 100644
index 00000000..5dbec2a4
--- /dev/null
+++ b/apps/server/src/core/base/services/base-row.service.ts
@@ -0,0 +1,162 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB } from '@docmost/db/types/kysely.types';
+import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
+import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
+import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
+import { CreateRowDto } from '../dto/create-row.dto';
+import {
+ UpdateRowDto,
+ ListRowsDto,
+ ReorderRowDto,
+} from '../dto/update-row.dto';
+import {
+ BasePropertyTypeValue,
+ validateCellValue,
+ isSystemPropertyType,
+} from '../base.schemas';
+import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
+import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
+import { BaseProperty } from '@docmost/db/types/entity.types';
+
+@Injectable()
+export class BaseRowService {
+ constructor(
+ @InjectKysely() private readonly db: KyselyDB,
+ private readonly baseRowRepo: BaseRowRepo,
+ private readonly basePropertyRepo: BasePropertyRepo,
+ private readonly baseViewRepo: BaseViewRepo,
+ ) {}
+
+ async create(userId: string, workspaceId: string, dto: CreateRowDto) {
+ let position: string;
+
+ if (dto.afterRowId) {
+ const afterRow = await this.baseRowRepo.findById(dto.afterRowId);
+ if (!afterRow || afterRow.baseId !== dto.baseId) {
+ throw new BadRequestException('Invalid afterRowId');
+ }
+ position = generateJitteredKeyBetween(afterRow.position, null);
+ } else {
+ const lastPosition = await this.baseRowRepo.getLastPosition(dto.baseId);
+ position = generateJitteredKeyBetween(lastPosition, null);
+ }
+
+ let validatedCells: Record = {};
+ if (dto.cells && Object.keys(dto.cells).length > 0) {
+ const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
+ validatedCells = this.validateCells(dto.cells, properties);
+ }
+
+ return this.baseRowRepo.insertRow({
+ baseId: dto.baseId,
+ cells: validatedCells as any,
+ position,
+ creatorId: userId,
+ workspaceId,
+ });
+ }
+
+ async getRowInfo(rowId: string, baseId: string) {
+ const row = await this.baseRowRepo.findById(rowId);
+ if (!row || row.baseId !== baseId) {
+ throw new NotFoundException('Row not found');
+ }
+ return row;
+ }
+
+ async update(dto: UpdateRowDto, userId?: string) {
+ const row = await this.baseRowRepo.findById(dto.rowId);
+ if (!row || row.baseId !== dto.baseId) {
+ throw new NotFoundException('Row not found');
+ }
+
+ const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
+ const validatedCells = this.validateCells(dto.cells, properties);
+
+ await this.baseRowRepo.updateCells(dto.rowId, validatedCells, userId);
+
+ return this.baseRowRepo.findById(dto.rowId);
+ }
+
+ async delete(rowId: string, baseId: string) {
+ const row = await this.baseRowRepo.findById(rowId);
+ if (!row || row.baseId !== baseId) {
+ throw new NotFoundException('Row not found');
+ }
+
+ await this.baseRowRepo.softDelete(rowId);
+ }
+
+ async list(dto: ListRowsDto, pagination: PaginationOptions) {
+ return this.baseRowRepo.findByBaseId(dto.baseId, pagination);
+ }
+
+ async reorder(dto: ReorderRowDto) {
+ const row = await this.baseRowRepo.findById(dto.rowId);
+ if (!row || row.baseId !== dto.baseId) {
+ throw new NotFoundException('Row not found');
+ }
+
+ try {
+ generateJitteredKeyBetween(dto.position, null);
+ } catch {
+ throw new BadRequestException('Invalid position value');
+ }
+
+ await this.baseRowRepo.updatePosition(dto.rowId, dto.position);
+ }
+
+ private validateCells(
+ cells: Record,
+ properties: BaseProperty[],
+ ): Record {
+ const propertyMap = new Map(properties.map((p) => [p.id, p]));
+ const validatedCells: Record = {};
+ const errors: string[] = [];
+
+ for (const [propertyId, value] of Object.entries(cells)) {
+ const property = propertyMap.get(propertyId);
+ if (!property) {
+ errors.push(`Unknown property: ${propertyId}`);
+ continue;
+ }
+
+ if (isSystemPropertyType(property.type)) {
+ continue;
+ }
+
+ if (value === null || value === undefined) {
+ validatedCells[propertyId] = null;
+ continue;
+ }
+
+ const result = validateCellValue(
+ property.type as BasePropertyTypeValue,
+ value,
+ );
+
+ if (!result.success) {
+ errors.push(
+ `Invalid value for property "${property.name}" (${property.type}): ${result.error.issues[0]?.message}`,
+ );
+ continue;
+ }
+
+ validatedCells[propertyId] = result.data;
+ }
+
+ if (errors.length > 0) {
+ throw new BadRequestException({
+ message: 'Cell validation failed',
+ errors,
+ });
+ }
+
+ return validatedCells;
+ }
+}
diff --git a/apps/server/src/core/base/services/base-view.service.ts b/apps/server/src/core/base/services/base-view.service.ts
new file mode 100644
index 00000000..1a940705
--- /dev/null
+++ b/apps/server/src/core/base/services/base-view.service.ts
@@ -0,0 +1,95 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
+import { CreateViewDto } from '../dto/create-view.dto';
+import { UpdateViewDto, DeleteViewDto } from '../dto/update-view.dto';
+import { viewConfigSchema } from '../base.schemas';
+import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
+
+@Injectable()
+export class BaseViewService {
+ constructor(private readonly baseViewRepo: BaseViewRepo) {}
+
+ async create(userId: string, workspaceId: string, dto: CreateViewDto) {
+ let validatedConfig = {};
+ if (dto.config) {
+ const result = viewConfigSchema.safeParse(dto.config);
+ if (!result.success) {
+ throw new BadRequestException({
+ message: 'Invalid view config',
+ errors: result.error.issues.map((i) => i.message),
+ });
+ }
+ validatedConfig = result.data;
+ }
+
+ const lastPosition = await this.baseViewRepo.getLastPosition(dto.baseId);
+ const position = generateJitteredKeyBetween(lastPosition, null);
+
+ return this.baseViewRepo.insertView({
+ baseId: dto.baseId,
+ name: dto.name,
+ type: dto.type ?? 'table',
+ position,
+ config: validatedConfig as any,
+ workspaceId,
+ creatorId: userId,
+ });
+ }
+
+ async update(dto: UpdateViewDto) {
+ const view = await this.baseViewRepo.findById(dto.viewId);
+ if (!view) {
+ throw new NotFoundException('View not found');
+ }
+
+ if (view.baseId !== dto.baseId) {
+ throw new BadRequestException('View does not belong to this base');
+ }
+
+ let validatedConfig = undefined;
+ if (dto.config !== undefined) {
+ const result = viewConfigSchema.safeParse(dto.config);
+ if (!result.success) {
+ throw new BadRequestException({
+ message: 'Invalid view config',
+ errors: result.error.issues.map((i) => i.message),
+ });
+ }
+ validatedConfig = result.data;
+ }
+
+ await this.baseViewRepo.updateView(dto.viewId, {
+ ...(dto.name !== undefined && { name: dto.name }),
+ ...(dto.type !== undefined && { type: dto.type }),
+ ...(validatedConfig !== undefined && { config: validatedConfig as any }),
+ });
+
+ return this.baseViewRepo.findById(dto.viewId);
+ }
+
+ async delete(dto: DeleteViewDto) {
+ const view = await this.baseViewRepo.findById(dto.viewId);
+ if (!view) {
+ throw new NotFoundException('View not found');
+ }
+
+ if (view.baseId !== dto.baseId) {
+ throw new BadRequestException('View does not belong to this base');
+ }
+
+ const viewCount = await this.baseViewRepo.countByBaseId(dto.baseId);
+ if (viewCount <= 1) {
+ throw new BadRequestException('Cannot delete the last view');
+ }
+
+ await this.baseViewRepo.deleteView(dto.viewId);
+ }
+
+ async listByBaseId(baseId: string) {
+ return this.baseViewRepo.findByBaseId(baseId);
+ }
+}
diff --git a/apps/server/src/core/base/services/base.service.ts b/apps/server/src/core/base/services/base.service.ts
new file mode 100644
index 00000000..a3ed057f
--- /dev/null
+++ b/apps/server/src/core/base/services/base.service.ts
@@ -0,0 +1,115 @@
+import {
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB } from '@docmost/db/types/kysely.types';
+import { executeTx } from '@docmost/db/utils';
+import { BaseRepo } from '@docmost/db/repos/base/base.repo';
+import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
+import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
+import { CreateBaseDto } from '../dto/create-base.dto';
+import { UpdateBaseDto } from '../dto/update-base.dto';
+import { BasePropertyType } from '../base.schemas';
+import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
+import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
+
+@Injectable()
+export class BaseService {
+ constructor(
+ @InjectKysely() private readonly db: KyselyDB,
+ private readonly baseRepo: BaseRepo,
+ private readonly basePropertyRepo: BasePropertyRepo,
+ private readonly baseViewRepo: BaseViewRepo,
+ ) {}
+
+ async create(userId: string, workspaceId: string, dto: CreateBaseDto) {
+ return executeTx(this.db, async (trx) => {
+ const base = await this.baseRepo.insertBase(
+ {
+ name: dto.name,
+ description: dto.description,
+ icon: dto.icon,
+ pageId: dto.pageId,
+ spaceId: dto.spaceId,
+ workspaceId,
+ creatorId: userId,
+ },
+ trx,
+ );
+
+ const firstPosition = generateJitteredKeyBetween(null, null);
+
+ await this.basePropertyRepo.insertProperty(
+ {
+ baseId: base.id,
+ name: 'Title',
+ type: BasePropertyType.TEXT,
+ position: firstPosition,
+ isPrimary: true,
+ workspaceId,
+ },
+ trx,
+ );
+
+ await this.baseViewRepo.insertView(
+ {
+ baseId: base.id,
+ name: 'Table View 1',
+ type: 'table',
+ position: firstPosition,
+ workspaceId,
+ creatorId: userId,
+ },
+ trx,
+ );
+
+ return this.baseRepo.findById(base.id, {
+ includeProperties: true,
+ includeViews: true,
+ trx,
+ });
+ });
+ }
+
+ async getBaseInfo(baseId: string) {
+ const base = await this.baseRepo.findById(baseId, {
+ includeProperties: true,
+ includeViews: true,
+ });
+
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ return base;
+ }
+
+ async update(dto: UpdateBaseDto) {
+ const base = await this.baseRepo.findById(dto.baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ await this.baseRepo.updateBase(dto.baseId, {
+ ...(dto.name !== undefined && { name: dto.name }),
+ ...(dto.description !== undefined && { description: dto.description }),
+ ...(dto.icon !== undefined && { icon: dto.icon }),
+ });
+
+ return this.baseRepo.findById(dto.baseId);
+ }
+
+ async delete(baseId: string) {
+ const base = await this.baseRepo.findById(baseId);
+ if (!base) {
+ throw new NotFoundException('Base not found');
+ }
+
+ await this.baseRepo.softDelete(baseId);
+ }
+
+ async listBySpaceId(spaceId: string, pagination: PaginationOptions) {
+ return this.baseRepo.findBySpaceId(spaceId, pagination);
+ }
+}
diff --git a/apps/server/src/core/casl/abilities/space-ability.factory.ts b/apps/server/src/core/casl/abilities/space-ability.factory.ts
index 53a57a0c..6837fb94 100644
--- a/apps/server/src/core/casl/abilities/space-ability.factory.ts
+++ b/apps/server/src/core/casl/abilities/space-ability.factory.ts
@@ -46,6 +46,7 @@ function buildSpaceAdminAbility() {
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
+ can(SpaceCaslAction.Manage, SpaceCaslSubject.Base);
return build();
}
@@ -57,6 +58,7 @@ function buildSpaceWriterAbility() {
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
+ can(SpaceCaslAction.Manage, SpaceCaslSubject.Base);
return build();
}
@@ -68,5 +70,6 @@ function buildSpaceReaderAbility() {
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
+ can(SpaceCaslAction.Read, SpaceCaslSubject.Base);
return build();
}
diff --git a/apps/server/src/core/casl/interfaces/space-ability.type.ts b/apps/server/src/core/casl/interfaces/space-ability.type.ts
index d7801cab..aa32648c 100644
--- a/apps/server/src/core/casl/interfaces/space-ability.type.ts
+++ b/apps/server/src/core/casl/interfaces/space-ability.type.ts
@@ -10,10 +10,12 @@ export enum SpaceCaslSubject {
Member = 'member',
Page = 'page',
Share = 'share',
+ Base = 'base',
}
export type ISpaceAbility =
| [SpaceCaslAction, SpaceCaslSubject.Settings]
| [SpaceCaslAction, SpaceCaslSubject.Member]
| [SpaceCaslAction, SpaceCaslSubject.Page]
- | [SpaceCaslAction, SpaceCaslSubject.Share];
+ | [SpaceCaslAction, SpaceCaslSubject.Share]
+ | [SpaceCaslAction, SpaceCaslSubject.Base];
diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts
index f8b75cd0..54c33596 100644
--- a/apps/server/src/core/core.module.ts
+++ b/apps/server/src/core/core.module.ts
@@ -18,6 +18,7 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
+import { BaseModule } from './base/base.module';
@Module({
imports: [
@@ -34,6 +35,7 @@ import { WatcherModule } from './watcher/watcher.module';
ShareModule,
NotificationModule,
WatcherModule,
+ BaseModule,
],
})
export class CoreModule implements NestModule {
diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts
index 6272ead1..b7baf43a 100644
--- a/apps/server/src/database/database.module.ts
+++ b/apps/server/src/database/database.module.ts
@@ -27,6 +27,10 @@ import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
+import { BaseRepo } from '@docmost/db/repos/base/base.repo';
+import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
+import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
+import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
import { normalizePostgresUrl } from '../common/helpers';
@@ -85,6 +89,10 @@ import { normalizePostgresUrl } from '../common/helpers';
NotificationRepo,
WatcherRepo,
PageListener,
+ BaseRepo,
+ BasePropertyRepo,
+ BaseRowRepo,
+ BaseViewRepo,
],
exports: [
WorkspaceRepo,
@@ -102,6 +110,10 @@ import { normalizePostgresUrl } from '../common/helpers';
ShareRepo,
NotificationRepo,
WatcherRepo,
+ BaseRepo,
+ BasePropertyRepo,
+ BaseRowRepo,
+ BaseViewRepo,
],
})
export class DatabaseModule
diff --git a/apps/server/src/database/migrations/20260218T120000-bases.ts b/apps/server/src/database/migrations/20260218T120000-bases.ts
new file mode 100644
index 00000000..5e1d7110
--- /dev/null
+++ b/apps/server/src/database/migrations/20260218T120000-bases.ts
@@ -0,0 +1,154 @@
+import { type Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('bases')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('name', 'varchar', (col) => col.notNull())
+ .addColumn('description', 'varchar')
+ .addColumn('icon', 'varchar')
+ .addColumn('page_id', 'uuid', (col) =>
+ col.references('pages.id').onDelete('cascade'),
+ )
+ .addColumn('space_id', 'uuid', (col) =>
+ col.references('spaces.id').onDelete('cascade').notNull(),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.references('workspaces.id').onDelete('cascade').notNull(),
+ )
+ .addColumn('creator_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('deleted_at', 'timestamptz')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_bases_space_id')
+ .on('bases')
+ .column('space_id')
+ .execute();
+
+ await db.schema
+ .createTable('base_properties')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('base_id', 'uuid', (col) =>
+ col.references('bases.id').onDelete('cascade').notNull(),
+ )
+ .addColumn('name', 'varchar', (col) => col.notNull())
+ .addColumn('type', 'varchar', (col) => col.notNull())
+ .addColumn('position', 'varchar', (col) => col.notNull())
+ .addColumn('type_options', 'jsonb')
+ .addColumn('is_primary', 'boolean', (col) =>
+ col.notNull().defaultTo(false),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.references('workspaces.id').onDelete('cascade').notNull(),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .execute();
+
+ await db.schema
+ .createIndex('idx_base_properties_base_id')
+ .on('base_properties')
+ .column('base_id')
+ .execute();
+
+ await db.schema
+ .createTable('base_rows')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('base_id', 'uuid', (col) =>
+ col.references('bases.id').onDelete('cascade').notNull(),
+ )
+ .addColumn('cells', 'jsonb', (col) =>
+ col.notNull().defaultTo(sql`'{}'::jsonb`),
+ )
+ .addColumn('position', 'varchar', (col) => col.notNull())
+ .addColumn('creator_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('last_updated_by_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.references('workspaces.id').onDelete('cascade').notNull(),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('deleted_at', 'timestamptz')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_base_rows_base_id')
+ .on('base_rows')
+ .column('base_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_base_rows_cells_gin')
+ .on('base_rows')
+ .using('gin')
+ .column('cells')
+ .execute();
+
+ await db.schema
+ .createTable('base_views')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('base_id', 'uuid', (col) =>
+ col.references('bases.id').onDelete('cascade').notNull(),
+ )
+ .addColumn('name', 'varchar', (col) => col.notNull())
+ .addColumn('type', 'varchar', (col) => col.notNull().defaultTo('table'))
+ .addColumn('position', 'varchar', (col) => col.notNull())
+ .addColumn('config', 'jsonb', (col) =>
+ col.notNull().defaultTo(sql`'{}'::jsonb`),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.references('workspaces.id').onDelete('cascade').notNull(),
+ )
+ .addColumn('creator_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .execute();
+
+ await db.schema
+ .createIndex('idx_base_views_base_id')
+ .on('base_views')
+ .column('base_id')
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable('base_views').execute();
+ await db.schema.dropTable('base_rows').execute();
+ await db.schema.dropTable('base_properties').execute();
+ await db.schema.dropTable('bases').execute();
+}
diff --git a/apps/server/src/database/repos/base/base-property.repo.ts b/apps/server/src/database/repos/base/base-property.repo.ts
new file mode 100644
index 00000000..46158172
--- /dev/null
+++ b/apps/server/src/database/repos/base/base-property.repo.ts
@@ -0,0 +1,91 @@
+import { Injectable } from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
+import { dbOrTx } from '../../utils';
+import {
+ BaseProperty,
+ InsertableBaseProperty,
+ UpdatableBaseProperty,
+} from '@docmost/db/types/entity.types';
+import { sql } from 'kysely';
+
+@Injectable()
+export class BasePropertyRepo {
+ constructor(@InjectKysely() private readonly db: KyselyDB) {}
+
+ async findById(
+ propertyId: string,
+ opts?: { trx?: KyselyTransaction },
+ ): Promise {
+ const db = dbOrTx(this.db, opts?.trx);
+ return db
+ .selectFrom('baseProperties')
+ .selectAll()
+ .where('id', '=', propertyId)
+ .executeTakeFirst() as Promise;
+ }
+
+ async findByBaseId(
+ baseId: string,
+ opts?: { trx?: KyselyTransaction },
+ ): Promise {
+ const db = dbOrTx(this.db, opts?.trx);
+ return db
+ .selectFrom('baseProperties')
+ .selectAll()
+ .where('baseId', '=', baseId)
+ .orderBy('position', 'asc')
+ .execute() as Promise;
+ }
+
+ async getLastPosition(
+ baseId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ const result = await db
+ .selectFrom('baseProperties')
+ .select('position')
+ .where('baseId', '=', baseId)
+ .orderBy(sql`position COLLATE "C"`, sql`DESC`)
+ .limit(1)
+ .executeTakeFirst();
+ return result?.position ?? null;
+ }
+
+ async insertProperty(
+ property: InsertableBaseProperty,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .insertInto('baseProperties')
+ .values(property)
+ .returningAll()
+ .executeTakeFirstOrThrow() as Promise;
+ }
+
+ async updateProperty(
+ propertyId: string,
+ data: UpdatableBaseProperty,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .updateTable('baseProperties')
+ .set({ ...data, updatedAt: new Date() })
+ .where('id', '=', propertyId)
+ .execute();
+ }
+
+ async deleteProperty(
+ propertyId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .deleteFrom('baseProperties')
+ .where('id', '=', propertyId)
+ .execute();
+ }
+}
diff --git a/apps/server/src/database/repos/base/base-row.repo.ts b/apps/server/src/database/repos/base/base-row.repo.ts
new file mode 100644
index 00000000..2f5d59a8
--- /dev/null
+++ b/apps/server/src/database/repos/base/base-row.repo.ts
@@ -0,0 +1,172 @@
+import { Injectable } from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
+import { dbOrTx } from '../../utils';
+import {
+ BaseRow,
+ InsertableBaseRow,
+} from '@docmost/db/types/entity.types';
+import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
+import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
+import { sql } from 'kysely';
+
+@Injectable()
+export class BaseRowRepo {
+ constructor(@InjectKysely() private readonly db: KyselyDB) {}
+
+ async findById(
+ rowId: string,
+ opts?: { trx?: KyselyTransaction },
+ ): Promise {
+ const db = dbOrTx(this.db, opts?.trx);
+ return db
+ .selectFrom('baseRows')
+ .selectAll()
+ .where('id', '=', rowId)
+ .where('deletedAt', 'is', null)
+ .executeTakeFirst() as Promise;
+ }
+
+ async findByBaseId(
+ baseId: string,
+ pagination: PaginationOptions,
+ opts?: { trx?: KyselyTransaction },
+ ) {
+ const db = dbOrTx(this.db, opts?.trx);
+
+ const query = db
+ .selectFrom('baseRows')
+ .selectAll()
+ .where('baseId', '=', baseId)
+ .where('deletedAt', 'is', null);
+
+ return executeWithCursorPagination(query, {
+ perPage: pagination.limit,
+ cursor: pagination.cursor,
+ beforeCursor: pagination.beforeCursor,
+ fields: [
+ { expression: 'position', direction: 'asc' },
+ { expression: 'id', direction: 'asc' },
+ ],
+ parseCursor: (cursor) => ({
+ position: cursor.position,
+ id: cursor.id,
+ }),
+ });
+ }
+
+ async getLastPosition(
+ baseId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ const result = await db
+ .selectFrom('baseRows')
+ .select('position')
+ .where('baseId', '=', baseId)
+ .where('deletedAt', 'is', null)
+ .orderBy(sql`position COLLATE "C"`, sql`DESC`)
+ .limit(1)
+ .executeTakeFirst();
+ return result?.position ?? null;
+ }
+
+ async insertRow(
+ row: InsertableBaseRow,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .insertInto('baseRows')
+ .values(row)
+ .returningAll()
+ .executeTakeFirstOrThrow() as Promise;
+ }
+
+ async updateCells(
+ rowId: string,
+ cells: Record,
+ userId?: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .updateTable('baseRows')
+ .set({
+ cells: sql`cells || ${cells}`,
+ updatedAt: new Date(),
+ lastUpdatedById: userId ?? null,
+ })
+ .where('id', '=', rowId)
+ .where('deletedAt', 'is', null)
+ .execute();
+ }
+
+ async updatePosition(
+ rowId: string,
+ position: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .updateTable('baseRows')
+ .set({ position, updatedAt: new Date() })
+ .where('id', '=', rowId)
+ .execute();
+ }
+
+ async softDelete(rowId: string, trx?: KyselyTransaction): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .updateTable('baseRows')
+ .set({ deletedAt: new Date() })
+ .where('id', '=', rowId)
+ .execute();
+ }
+
+ async removeCellKey(
+ baseId: string,
+ propertyId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .updateTable('baseRows')
+ .set({
+ cells: sql`cells - ${propertyId}`,
+ updatedAt: new Date(),
+ })
+ .where('baseId', '=', baseId)
+ .execute();
+ }
+
+ async findAllByBaseId(
+ baseId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .selectFrom('baseRows')
+ .selectAll()
+ .where('baseId', '=', baseId)
+ .where('deletedAt', 'is', null)
+ .execute() as Promise;
+ }
+
+ async batchUpdateCells(
+ updates: Array<{ id: string; cells: Record }>,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ for (const update of updates) {
+ await db
+ .updateTable('baseRows')
+ .set({
+ cells: sql`cells || ${update.cells}`,
+ updatedAt: new Date(),
+ })
+ .where('id', '=', update.id)
+ .execute();
+ }
+ }
+}
diff --git a/apps/server/src/database/repos/base/base-view.repo.ts b/apps/server/src/database/repos/base/base-view.repo.ts
new file mode 100644
index 00000000..f936ca7c
--- /dev/null
+++ b/apps/server/src/database/repos/base/base-view.repo.ts
@@ -0,0 +1,104 @@
+import { Injectable } from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
+import { dbOrTx } from '../../utils';
+import {
+ BaseView,
+ InsertableBaseView,
+ UpdatableBaseView,
+} from '@docmost/db/types/entity.types';
+import { sql } from 'kysely';
+
+@Injectable()
+export class BaseViewRepo {
+ constructor(@InjectKysely() private readonly db: KyselyDB) {}
+
+ async findById(
+ viewId: string,
+ opts?: { trx?: KyselyTransaction },
+ ): Promise {
+ const db = dbOrTx(this.db, opts?.trx);
+ return db
+ .selectFrom('baseViews')
+ .selectAll()
+ .where('id', '=', viewId)
+ .executeTakeFirst() as Promise;
+ }
+
+ async findByBaseId(
+ baseId: string,
+ opts?: { trx?: KyselyTransaction },
+ ): Promise {
+ const db = dbOrTx(this.db, opts?.trx);
+ return db
+ .selectFrom('baseViews')
+ .selectAll()
+ .where('baseId', '=', baseId)
+ .orderBy('position', 'asc')
+ .execute() as Promise;
+ }
+
+ async countByBaseId(
+ baseId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ const result = await db
+ .selectFrom('baseViews')
+ .select((eb) => eb.fn.countAll().as('count'))
+ .where('baseId', '=', baseId)
+ .executeTakeFirstOrThrow();
+ return Number(result.count);
+ }
+
+ async getLastPosition(
+ baseId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ const result = await db
+ .selectFrom('baseViews')
+ .select('position')
+ .where('baseId', '=', baseId)
+ .orderBy(sql`position COLLATE "C"`, sql`DESC`)
+ .limit(1)
+ .executeTakeFirst();
+ return result?.position ?? null;
+ }
+
+ async insertView(
+ view: InsertableBaseView,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .insertInto('baseViews')
+ .values(view)
+ .returningAll()
+ .executeTakeFirstOrThrow() as Promise;
+ }
+
+ async updateView(
+ viewId: string,
+ data: UpdatableBaseView,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .updateTable('baseViews')
+ .set({ ...data, updatedAt: new Date() })
+ .where('id', '=', viewId)
+ .execute();
+ }
+
+ async deleteView(
+ viewId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .deleteFrom('baseViews')
+ .where('id', '=', viewId)
+ .execute();
+ }
+}
diff --git a/apps/server/src/database/repos/base/base.repo.ts b/apps/server/src/database/repos/base/base.repo.ts
new file mode 100644
index 00000000..43ecebcf
--- /dev/null
+++ b/apps/server/src/database/repos/base/base.repo.ts
@@ -0,0 +1,142 @@
+import { Injectable } from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
+import { dbOrTx } from '../../utils';
+import {
+ Base,
+ InsertableBase,
+ UpdatableBase,
+} from '@docmost/db/types/entity.types';
+import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
+import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
+import { ExpressionBuilder } from 'kysely';
+import { DB } from '@docmost/db/types/db';
+import { jsonArrayFrom } from 'kysely/helpers/postgres';
+
+@Injectable()
+export class BaseRepo {
+ constructor(@InjectKysely() private readonly db: KyselyDB) {}
+
+ private baseFields: Array = [
+ 'id',
+ 'name',
+ 'description',
+ 'icon',
+ 'pageId',
+ 'spaceId',
+ 'workspaceId',
+ 'creatorId',
+ 'createdAt',
+ 'updatedAt',
+ 'deletedAt',
+ ];
+
+ async findById(
+ baseId: string,
+ opts?: {
+ includeProperties?: boolean;
+ includeViews?: boolean;
+ trx?: KyselyTransaction;
+ },
+ ): Promise {
+ const db = dbOrTx(this.db, opts?.trx);
+
+ let query = db
+ .selectFrom('bases')
+ .select(this.baseFields)
+ .where('id', '=', baseId)
+ .where('deletedAt', 'is', null);
+
+ if (opts?.includeProperties) {
+ query = query.select((eb) => this.withProperties(eb));
+ }
+
+ if (opts?.includeViews) {
+ query = query.select((eb) => this.withViews(eb));
+ }
+
+ return query.executeTakeFirst() as Promise;
+ }
+
+ async findBySpaceId(
+ spaceId: string,
+ pagination: PaginationOptions,
+ opts?: { trx?: KyselyTransaction },
+ ) {
+ const db = dbOrTx(this.db, opts?.trx);
+
+ const query = db
+ .selectFrom('bases')
+ .select(this.baseFields)
+ .where('spaceId', '=', spaceId)
+ .where('deletedAt', 'is', null);
+
+ return executeWithCursorPagination(query, {
+ perPage: pagination.limit,
+ cursor: pagination.cursor,
+ beforeCursor: pagination.beforeCursor,
+ fields: [
+ { expression: 'createdAt', direction: 'desc' },
+ { expression: 'id', direction: 'desc' },
+ ],
+ parseCursor: (cursor) => ({
+ createdAt: new Date(cursor.createdAt),
+ id: cursor.id,
+ }),
+ });
+ }
+
+ async insertBase(
+ base: InsertableBase,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .insertInto('bases')
+ .values(base)
+ .returningAll()
+ .executeTakeFirstOrThrow() as Promise;
+ }
+
+ async updateBase(
+ baseId: string,
+ data: UpdatableBase,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .updateTable('bases')
+ .set({ ...data, updatedAt: new Date() })
+ .where('id', '=', baseId)
+ .execute();
+ }
+
+ async softDelete(baseId: string, trx?: KyselyTransaction): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .updateTable('bases')
+ .set({ deletedAt: new Date() })
+ .where('id', '=', baseId)
+ .execute();
+ }
+
+ private withProperties(eb: ExpressionBuilder) {
+ return jsonArrayFrom(
+ eb
+ .selectFrom('baseProperties')
+ .selectAll('baseProperties')
+ .whereRef('baseProperties.baseId', '=', 'bases.id')
+ .orderBy('baseProperties.position', 'asc'),
+ ).as('properties');
+ }
+
+ private withViews(eb: ExpressionBuilder) {
+ return jsonArrayFrom(
+ eb
+ .selectFrom('baseViews')
+ .selectAll('baseViews')
+ .whereRef('baseViews.baseId', '=', 'bases.id')
+ .orderBy('baseViews.position', 'asc'),
+ ).as('views');
+ }
+}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 6668398b..90e20849 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -390,9 +390,66 @@ export interface Watchers {
createdAt: Generated;
}
+export interface Bases {
+ id: Generated;
+ name: string;
+ description: string | null;
+ icon: string | null;
+ pageId: string | null;
+ spaceId: string;
+ workspaceId: string;
+ creatorId: string | null;
+ createdAt: Generated;
+ updatedAt: Generated;
+ deletedAt: Timestamp | null;
+}
+
+export interface BaseProperties {
+ id: Generated;
+ baseId: string;
+ name: string;
+ type: string;
+ position: string;
+ typeOptions: Json | null;
+ isPrimary: Generated;
+ workspaceId: string;
+ createdAt: Generated;
+ updatedAt: Generated;
+}
+
+export interface BaseRows {
+ id: Generated;
+ baseId: string;
+ cells: Generated;
+ position: string;
+ creatorId: string | null;
+ lastUpdatedById: string | null;
+ workspaceId: string;
+ createdAt: Generated;
+ updatedAt: Generated;
+ deletedAt: Timestamp | null;
+}
+
+export interface BaseViews {
+ id: Generated;
+ baseId: string;
+ name: string;
+ type: Generated;
+ position: string;
+ config: Generated;
+ workspaceId: string;
+ creatorId: string | null;
+ createdAt: Generated;
+ updatedAt: Generated;
+}
+
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
+ baseProperties: BaseProperties;
+ baseRows: BaseRows;
+ baseViews: BaseViews;
+ bases: Bases;
authAccounts: AuthAccounts;
authProviders: AuthProviders;
backlinks: Backlinks;
diff --git a/apps/server/src/database/types/db.interface.ts b/apps/server/src/database/types/db.interface.ts
index 58146b9e..47950386 100644
--- a/apps/server/src/database/types/db.interface.ts
+++ b/apps/server/src/database/types/db.interface.ts
@@ -4,6 +4,10 @@ import {
AuthAccounts,
AuthProviders,
Backlinks,
+ BaseProperties,
+ BaseRows,
+ BaseViews,
+ Bases,
Billing,
Comments,
FileTasks,
@@ -27,6 +31,10 @@ import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
export interface DbInterface {
attachments: Attachments;
authAccounts: AuthAccounts;
+ baseProperties: BaseProperties;
+ baseRows: BaseRows;
+ baseViews: BaseViews;
+ bases: Bases;
authProviders: AuthProviders;
backlinks: Backlinks;
billing: Billing;
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index 65e1024a..f2714474 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -1,6 +1,10 @@
import { Insertable, Selectable, Updateable } from 'kysely';
import {
Attachments,
+ BaseProperties,
+ BaseRows,
+ BaseViews,
+ Bases,
Comments,
Groups,
Notifications,
@@ -143,3 +147,23 @@ export type UpdatableNotification = Updateable>;
export type Watcher = Selectable;
export type InsertableWatcher = Insertable;
export type UpdatableWatcher = Updateable>;
+
+// Base
+export type Base = Selectable;
+export type InsertableBase = Insertable;
+export type UpdatableBase = Updateable>;
+
+// Base Property
+export type BaseProperty = Selectable;
+export type InsertableBaseProperty = Insertable;
+export type UpdatableBaseProperty = Updateable>;
+
+// Base Row
+export type BaseRow = Selectable;
+export type InsertableBaseRow = Insertable;
+export type UpdatableBaseRow = Updateable>;
+
+// Base View
+export type BaseView = Selectable;
+export type InsertableBaseView = Insertable;
+export type UpdatableBaseView = Updateable>;
diff --git a/apps/server/src/scripts/seed-base-rows.ts b/apps/server/src/scripts/seed-base-rows.ts
new file mode 100644
index 00000000..1596b0d8
--- /dev/null
+++ b/apps/server/src/scripts/seed-base-rows.ts
@@ -0,0 +1,212 @@
+import * as path from 'path';
+import * as dotenv from 'dotenv';
+import { Kysely, sql } from 'kysely';
+import { PostgresJSDialect } from 'kysely-postgres-js';
+import postgres from 'postgres';
+import { v7 as uuid7 } from 'uuid';
+import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
+
+const BASE_ID = '019c69a5-1d84-7985-a7f6-8ee2871d8669';
+const TOTAL_ROWS = 100_000;
+const BATCH_SIZE = 2000;
+
+const envFilePath = path.resolve(process.cwd(), '..', '..', '.env');
+dotenv.config({ path: envFilePath });
+
+function normalizePostgresUrl(url: string): string {
+ const parsed = new URL(url);
+ const newParams = new URLSearchParams();
+ for (const [key, value] of parsed.searchParams) {
+ if (key === 'sslmode' && value === 'no-verify') continue;
+ if (key === 'schema') continue;
+ newParams.append(key, value);
+ }
+ parsed.search = newParams.toString();
+ return parsed.toString();
+}
+
+const db = new Kysely({
+ dialect: new PostgresJSDialect({
+ postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL!)),
+ }),
+});
+
+const SKIP_TYPES = new Set([
+ 'createdAt',
+ 'lastEditedAt',
+ 'lastEditedBy',
+ 'person',
+ 'file',
+]);
+
+const WORDS = [
+ 'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf',
+ 'Hotel', 'India', 'Juliet', 'Kilo', 'Lima', 'Mike', 'November',
+ 'Oscar', 'Papa', 'Quebec', 'Romeo', 'Sierra', 'Tango', 'Uniform',
+ 'Victor', 'Whiskey', 'X-ray', 'Yankee', 'Zulu', 'Report', 'Analysis',
+ 'Summary', 'Review', 'Update', 'Draft', 'Final', 'Proposal', 'Budget',
+ 'Timeline', 'Milestone', 'Objective', 'Strategy', 'Initiative',
+];
+
+function randomWords(min: number, max: number): string {
+ const count = min + Math.floor(Math.random() * (max - min + 1));
+ const result: string[] = [];
+ for (let i = 0; i < count; i++) {
+ result.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
+ }
+ return result.join(' ');
+}
+
+type CellGenerator = () => unknown;
+
+function buildCellGenerator(property: any): CellGenerator | null {
+ if (SKIP_TYPES.has(property.type)) return null;
+
+ const typeOptions = property.type_options;
+
+ switch (property.type) {
+ case 'text':
+ return () => randomWords(2, 6);
+
+ case 'number':
+ return () => Math.round(Math.random() * 10000 * 100) / 100;
+
+ case 'select':
+ case 'status': {
+ const choices = typeOptions?.choices ?? [];
+ if (choices.length === 0) return null;
+ return () => choices[Math.floor(Math.random() * choices.length)].id;
+ }
+
+ case 'multiSelect': {
+ const choices = typeOptions?.choices ?? [];
+ if (choices.length === 0) return () => [];
+ return () => {
+ const count = 1 + Math.floor(Math.random() * Math.min(3, choices.length));
+ const shuffled = [...choices].sort(() => Math.random() - 0.5);
+ return shuffled.slice(0, count).map((c: any) => c.id);
+ };
+ }
+
+ case 'date': {
+ const start = new Date(2020, 0, 1).getTime();
+ const range = new Date(2026, 0, 1).getTime() - start;
+ return () => new Date(start + Math.random() * range).toISOString();
+ }
+
+ case 'checkbox':
+ return () => Math.random() > 0.5;
+
+ case 'url':
+ return () => `https://example.com/page/${Math.floor(Math.random() * 100000)}`;
+
+ case 'email':
+ return () => `user${Math.floor(Math.random() * 100000)}@example.com`;
+
+ default:
+ return null;
+ }
+}
+
+async function main() {
+ console.log(`Seeding ${TOTAL_ROWS.toLocaleString()} rows for base ${BASE_ID}\n`);
+
+ const base = await db
+ .selectFrom('bases')
+ .selectAll()
+ .where('id', '=', BASE_ID)
+ .executeTakeFirstOrThrow();
+
+ const workspaceId = base.workspace_id;
+ console.log(`Workspace: ${workspaceId}`);
+
+ const user = await db
+ .selectFrom('users')
+ .select('id')
+ .limit(1)
+ .executeTakeFirst();
+
+ const creatorId = user?.id ?? null;
+ console.log(`Creator: ${creatorId ?? '(none)'}`);
+
+ const properties = await db
+ .selectFrom('base_properties')
+ .selectAll()
+ .where('base_id', '=', BASE_ID)
+ .execute();
+
+ console.log(`Properties: ${properties.length}`);
+ for (const p of properties) {
+ console.log(` - ${p.name} (${p.type})${SKIP_TYPES.has(p.type) ? ' [skipped]' : ''}`);
+ }
+
+ const generators: Array<{ propertyId: string; generate: CellGenerator }> = [];
+ for (const prop of properties) {
+ const gen = buildCellGenerator(prop);
+ if (gen) {
+ generators.push({ propertyId: prop.id, generate: gen });
+ }
+ }
+
+ console.log(`\nGenerating ${TOTAL_ROWS.toLocaleString()} positions...`);
+
+ const lastRow = await db
+ .selectFrom('base_rows')
+ .select('position')
+ .where('base_id', '=', BASE_ID)
+ .where('deleted_at', 'is', null)
+ .orderBy(sql`position COLLATE "C"`, sql`desc`)
+ .limit(1)
+ .executeTakeFirst();
+
+ let lastPosition: string | null = lastRow?.position ?? null;
+ const positions: string[] = new Array(TOTAL_ROWS);
+ for (let i = 0; i < TOTAL_ROWS; i++) {
+ lastPosition = generateJitteredKeyBetween(lastPosition, null);
+ positions[i] = lastPosition;
+ }
+ console.log(`Positions generated (last: ${positions[positions.length - 1]})\n`);
+
+ const startTime = Date.now();
+ const totalBatches = Math.ceil(TOTAL_ROWS / BATCH_SIZE);
+
+ for (let batchStart = 0; batchStart < TOTAL_ROWS; batchStart += BATCH_SIZE) {
+ const batchEnd = Math.min(batchStart + BATCH_SIZE, TOTAL_ROWS);
+ const rows: any[] = [];
+
+ for (let i = batchStart; i < batchEnd; i++) {
+ const cells: Record = {};
+ for (const { propertyId, generate } of generators) {
+ cells[propertyId] = generate();
+ }
+
+ rows.push({
+ id: uuid7(),
+ base_id: BASE_ID,
+ cells,
+ position: positions[i],
+ creator_id: creatorId,
+ workspace_id: workspaceId,
+ created_at: new Date(),
+ updated_at: new Date(),
+ });
+ }
+
+ await db.insertInto('base_rows').values(rows).execute();
+
+ const batchNum = Math.floor(batchStart / BATCH_SIZE) + 1;
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+ console.log(`Batch ${batchNum}/${totalBatches} inserted (${batchEnd.toLocaleString()} rows, ${elapsed}s elapsed)`);
+ }
+
+ const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+ console.log(`\nDone. Inserted ${TOTAL_ROWS.toLocaleString()} rows in ${totalElapsed}s`);
+
+ await db.destroy();
+ process.exit(0);
+}
+
+main().catch((err) => {
+ console.error('Seed script failed:', err);
+ db.destroy().finally(() => process.exit(1));
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 161aa6f1..cf57a344 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -239,6 +239,18 @@ importers:
'@casl/react':
specifier: ^4.0.0
version: 4.0.0(@casl/ability@6.8.0)(react@18.3.1)
+ '@dnd-kit/core':
+ specifier: ^6.3.1
+ version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@dnd-kit/modifiers':
+ specifier: ^9.0.0
+ version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
+ '@dnd-kit/sortable':
+ specifier: ^10.0.0
+ version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@18.3.1)
'@docmost/editor-ext':
specifier: workspace:*
version: link:../../packages/editor-ext
@@ -278,6 +290,12 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.17
version: 5.90.17(react@18.3.1)
+ '@tanstack/react-table':
+ specifier: ^8.21.3
+ version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@tanstack/react-virtual':
+ specifier: ^3.13.18
+ version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
alfaaz:
specifier: ^1.1.0
version: 1.1.0
@@ -449,13 +467,13 @@ importers:
dependencies:
'@ai-sdk/google':
specifier: ^3.0.29
- version: 3.0.29(zod@4.3.6)
+ version: 3.0.29(zod@3.25.76)
'@ai-sdk/openai':
specifier: ^3.0.29
- version: 3.0.29(zod@4.3.6)
+ version: 3.0.29(zod@3.25.76)
'@ai-sdk/openai-compatible':
specifier: ^2.0.30
- version: 2.0.30(zod@4.3.6)
+ version: 2.0.30(zod@3.25.76)
'@aws-sdk/client-s3':
specifier: 3.982.0
version: 3.982.0
@@ -476,10 +494,10 @@ importers:
version: 9.0.0
'@langchain/core':
specifier: 1.1.18
- version: 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
+ version: 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76))
'@langchain/textsplitters':
specifier: 1.0.1
- version: 1.0.1(@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6)))
+ version: 1.0.1(@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76)))
'@nestjs-labs/nestjs-ioredis':
specifier: ^11.0.4
version: 11.0.4(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(ioredis@5.4.1)
@@ -536,10 +554,10 @@ importers:
version: 8.3.0(socket.io-adapter@2.5.4)
ai:
specifier: ^6.0.86
- version: 6.0.86(zod@4.3.6)
+ version: 6.0.86(zod@3.25.76)
ai-sdk-ollama:
specifier: ^3.7.0
- version: 3.7.0(ai@6.0.86(zod@4.3.6))(zod@4.3.6)
+ version: 3.7.0(ai@6.0.86(zod@3.25.76))(zod@3.25.76)
bcrypt:
specifier: ^6.0.0
version: 6.0.0
@@ -678,6 +696,9 @@ importers:
yauzl:
specifier: ^3.2.0
version: 3.2.0
+ zod:
+ specifier: ^3.25.76
+ version: 3.25.76
devDependencies:
'@eslint/js':
specifier: ^9.20.0
@@ -1793,6 +1814,34 @@ packages:
resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==}
engines: {node: '>=18'}
+ '@dnd-kit/accessibility@3.1.1':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/core@6.3.1':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/modifiers@9.0.0':
+ resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/sortable@10.0.0':
+ resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/utilities@3.2.2':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
'@emnapi/core@1.2.0':
resolution: {integrity: sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w==}
@@ -4489,6 +4538,26 @@ packages:
peerDependencies:
react: ^18 || ^19
+ '@tanstack/react-table@8.21.3':
+ resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ react: '>=16.8'
+ react-dom: '>=16.8'
+
+ '@tanstack/react-virtual@3.13.18':
+ resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@tanstack/table-core@8.21.3':
+ resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
+ engines: {node: '>=12'}
+
+ '@tanstack/virtual-core@3.13.18':
+ resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
+
'@tiptap/core@3.17.1':
resolution: {integrity: sha512-f8hB9MzXqsuXoF9qXEDEH5Fb3VgwhEFMBMfk9EKN88l5adri6oM8mt2XOWVxVVssjpEW0177zXSLPKWzoS/vrw==}
peerDependencies:
@@ -10473,37 +10542,37 @@ snapshots:
'@adobe/css-tools@4.3.3': {}
- '@ai-sdk/gateway@3.0.46(zod@4.3.6)':
+ '@ai-sdk/gateway@3.0.46(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
- '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
+ '@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
'@vercel/oidc': 3.1.0
- zod: 4.3.6
+ zod: 3.25.76
- '@ai-sdk/google@3.0.29(zod@4.3.6)':
+ '@ai-sdk/google@3.0.29(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
- '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
- zod: 4.3.6
+ '@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
+ zod: 3.25.76
- '@ai-sdk/openai-compatible@2.0.30(zod@4.3.6)':
+ '@ai-sdk/openai-compatible@2.0.30(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
- '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
- zod: 4.3.6
+ '@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
+ zod: 3.25.76
- '@ai-sdk/openai@3.0.29(zod@4.3.6)':
+ '@ai-sdk/openai@3.0.29(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
- '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
- zod: 4.3.6
+ '@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
+ zod: 3.25.76
- '@ai-sdk/provider-utils@4.0.15(zod@4.3.6)':
+ '@ai-sdk/provider-utils@4.0.15(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
- zod: 4.3.6
+ zod: 3.25.76
'@ai-sdk/provider@3.0.8':
dependencies:
@@ -11972,6 +12041,38 @@ snapshots:
'@csstools/css-tokenizer@3.0.3': {}
+ '@dnd-kit/accessibility@3.1.1(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ tslib: 2.8.1
+
+ '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@18.3.1)
+ '@dnd-kit/utilities': 3.2.2(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ tslib: 2.8.1
+
+ '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@dnd-kit/utilities': 3.2.2(react@18.3.1)
+ react: 18.3.1
+ tslib: 2.8.1
+
+ '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@dnd-kit/utilities': 3.2.2(react@18.3.1)
+ react: 18.3.1
+ tslib: 2.8.1
+
+ '@dnd-kit/utilities@3.2.2(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ tslib: 2.8.1
+
'@emnapi/core@1.2.0':
dependencies:
'@emnapi/wasi-threads': 1.0.1
@@ -12869,14 +12970,14 @@ snapshots:
'@keyv/serialize@1.1.1': {}
- '@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))':
+ '@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76))':
dependencies:
'@cfworker/json-schema': 4.1.1
ansi-styles: 5.2.0
camelcase: 6.3.0
decamelize: 1.2.0
js-tiktoken: 1.0.21
- langsmith: 0.4.12(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
+ langsmith: 0.4.12(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76))
mustache: 4.2.0
p-queue: 6.6.2
uuid: 10.0.0
@@ -12887,9 +12988,9 @@ snapshots:
- '@opentelemetry/sdk-trace-base'
- openai
- '@langchain/textsplitters@1.0.1(@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6)))':
+ '@langchain/textsplitters@1.0.1(@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76)))':
dependencies:
- '@langchain/core': 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
+ '@langchain/core': 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76))
js-tiktoken: 1.0.21
'@lifeomic/attempt@3.0.3': {}
@@ -14895,6 +14996,22 @@ snapshots:
'@tanstack/query-core': 5.90.17
react: 18.3.1
+ '@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@tanstack/table-core': 8.21.3
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@tanstack/virtual-core': 3.13.18
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@tanstack/table-core@8.21.3': {}
+
+ '@tanstack/virtual-core@3.13.18': {}
+
'@tiptap/core@3.17.1(@tiptap/pm@3.17.1)':
dependencies:
'@tiptap/pm': 3.17.1
@@ -15973,23 +16090,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
- ai-sdk-ollama@3.7.0(ai@6.0.86(zod@4.3.6))(zod@4.3.6):
+ ai-sdk-ollama@3.7.0(ai@6.0.86(zod@3.25.76))(zod@3.25.76):
dependencies:
'@ai-sdk/provider': 3.0.8
- '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
- ai: 6.0.86(zod@4.3.6)
+ '@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
+ ai: 6.0.86(zod@3.25.76)
jsonrepair: 3.13.2
ollama: 0.6.3
transitivePeerDependencies:
- zod
- ai@6.0.86(zod@4.3.6):
+ ai@6.0.86(zod@3.25.76):
dependencies:
- '@ai-sdk/gateway': 3.0.46(zod@4.3.6)
+ '@ai-sdk/gateway': 3.0.46(zod@3.25.76)
'@ai-sdk/provider': 3.0.8
- '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
+ '@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
'@opentelemetry/api': 1.9.0
- zod: 4.3.6
+ zod: 3.25.76
ajv-formats@2.1.1(ajv@8.17.1):
optionalDependencies:
@@ -18801,7 +18918,7 @@ snapshots:
vscode-languageserver-textdocument: 1.0.12
vscode-uri: 3.0.8
- langsmith@0.4.12(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6)):
+ langsmith@0.4.12(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76)):
dependencies:
'@types/uuid': 10.0.0
chalk: 4.1.2
@@ -18812,7 +18929,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
- openai: 6.2.0(ws@8.19.0)(zod@4.3.6)
+ openai: 6.2.0(ws@8.19.0)(zod@3.25.76)
layout-base@1.0.2: {}
@@ -19361,10 +19478,10 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
- openai@6.2.0(ws@8.19.0)(zod@4.3.6):
+ openai@6.2.0(ws@8.19.0)(zod@3.25.76):
optionalDependencies:
ws: 8.19.0
- zod: 4.3.6
+ zod: 3.25.76
optional: true
openid-client@5.7.1: